Dynamic DNS for home routers the hacker way

Posted on Oct 28, 2021, seven minutes to read.

Don’t rely on public DDNS services when you can achieve the same with a swiss-army knife in form of Python. In this writeup you will find how to modify DNS record, how to upload a zone file over ssh and how to expose it as a simple web service. All that using Python.

I have a Mikrotik router that gets a dynamic public IP address. I give it a domain name on my primary DNS, and my goal is to keep it up-to-date. Initially, I thought of pulling a dynamic DNS using a standard protocol. After some research, I had to abandon this idea. Mikrotik’s also provide a way to use their cloud services to get the similar result, yet I find it less convenient compared to my custom solution.

After a little research I came across a way to modify the zonefile using Python which lead me to the solution described in this post.

Modify DNS zone file

Python has a library for every possible problem. In this case, I am using dnspython. In the examples below, I will only cover IPv4. Yet, I am confident it’s a simple update to extend the solution to cover also IPv6.

In a nutshell, the solution can be summarized as follows:

  1. open the zone file
  2. modify the record
  3. store the file back

To put that in Python:

def update(zone, name, newip4):
    original = f'zones/{zone}.zone'
    updated = update_dynamic_ip4(original, name, newip4, 3600)
    with open(original, 'w') as file:
        file.write(f'$ORIGIN {zone}.\n')
        file.write(updated)

As for the actual update, it’s pretty straightforward as well:

  1. load a zonefile
  2. find the proper record
  3. modify the record - stop the operation if there’s no change
  4. update SOA
  5. convert the updated zone to string to return

Do not forget the SOA update. Without it, other DNS servers in your zone will not detect the change.

As for the details, I will post my code here. The dnspython library does the job, but the API is not my favourite cup of tea.

class NoUpdate(ValueError):
    pass

def update_dynamic_ip4(zonefile, name, newip4, ttl):
    z = dns.zone.from_file(zonefile)
    new_data = dns.rdataset.from_text(dns.rdataclass.IN, dns.rdatatype.A, ttl, newip4)
    node = z.find_node(name)
    oldip4 = node.get_rdataset(dns.rdataclass.IN, dns.rdatatype.A).to_text().split()[3]
    if str(oldip4) == newip4:
        raise NoUpdate
    node.replace_rdataset(new_data)

    soa = z.find_node("@").find_rdataset(dns.rdataclass.IN, dns.rdatatype.SOA)
    soa_updated = update_soa(soa)
    z.find_node("@").replace_rdataset(soa_updated)
    return z.to_text(relativize=False)

As for the SOA record, I keep the version as a date (%Y%m%d) and a revision number. So, if the date is still today, I increment the revision. If not, I start from zero for today.

def update_soa(soa):
    soa_parts = list(soa.to_text().split())
    version = soa_parts[-5]
    today = date.today().strftime("%Y%m%d")
    increment = 0
    if version.startswith(today):
        increment = int(version[len(today):]) + 1
    version = f'{today}{increment:02d}'
    soa_parts[-5] = version
    soa_new = " ".join(soa_parts[3:])
    ttl = soa_parts[0]
    return dns.rdataset.from_text(dns.rdataclass.IN, dns.rdatatype.SOA, ttl, soa_new)

Upload the zone file and reload DNS

Alright, the zone file is up-to-date, and now we are eager to publish it. By hand, you could ssh to the DNS server, update the file there and reload the server. That’s precisely the process I’ll cover with Python now.

def upload_zone(zone, localfile, remotefile, remotehost, remoteport, keyfile):
    with paramiko.SSHClient() as client:
        client.load_system_host_keys()
        client.connect(remotehost, remoteport, key_filename=keyfile)
        with client.open_sftp() as ftp:
            ftp.put(localfile, remotefile)
        client.exec_command('nsd-reload')  # you might want to modify this

Create a REST API

At this moment, we can update a zone file and upload it to the DNS server to perform a zone update. Now let’s put it all together.

Our happy execution path looks like this

update(zone, name, newip)
upload_zone(zone, remotefile, remotehost, remoteport, keyfile)

remotefile, remotehost, remoteport and keyfile come from config.

As I hinted already, the whole update is done via REST API. The router will send us a request, and based on that, we do a DNS update.

For security, the IP address to use will be taken from the request and the router will identify itself using a JWT token.

I am using Flask to setup the API and flask_jwt_extended for JWT. In this example I will create new tokens each time the app is started

app = Flask(__name__)
app.config["JWT_SECRET_KEY"] = str(uuid.uuid4())
jwt = JWTManager(app)
with app.app_context():
    token = create_access_token(identity={'name': name, 'zone': zone},
                                expires_delta=False)

The endpoint with a happy execution path can look like this:

@app.route('/api/update/ipv4', methods=['POST'])
@jwt_required()
def update_endpoint():
    identity = get_jwt_identity()
    name = identity['name']
    zone = identity['zone']
    if 'X-Forwarded-For' in request.headers:
        newip = request.headers['X-Forwarded-For']
    else:
        newip = request.remote_addr
    update(zone, name, newip)
    upload_zone(zone, remotefile, remotehost, remoteport, keyfile)

Once deployed, we set up a periodic call to such API from our Mikrotik. Keep in mind that you should at least add exception handling and input validation in the endpoint handler. I skipped those for conciseness.

Did you find this useful?

You can unsubscribe at any time by clicking the link in the footer of our emails. For information about our privacy practices, please visit our website.

I use Mailchimp as our marketing platform. By clicking below to subscribe, you acknowledge that your information will be transferred to Mailchimp for processing. Learn more about Mailchimp's privacy practices here.