Dynamic DNS for home routers the hacker way

·4 mins

Skip public DDNS services: Python can handle the same job. This post shows how to modify a DNS record in a zone file, upload the zone over SSH, and expose the update as a small web service.

The setup

  • A MikroTik router with a dynamic public IPv4 address
  • A name for the router in my own zone, on my primary DNS server
  • Python with dnspython, paramiko, Flask, and flask_jwt_extended

I first looked at dynamic DNS over a standard protocol, but I had to abandon the idea. MikroTik also offers its cloud service for a similar result, but I find it less convenient than a name in my own zone. That led me to a way to modify the zone file with Python.

The examples cover IPv4 only. Extending them to IPv6 is a small change.

Modify the zone file

The update has three steps: open the zone file, modify the record, and save the file.

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)

The record update itself:

  1. Load the zone file.
  2. Find the record.
  3. Replace it, or stop if the address hasn’t changed.
  4. Update the SOA serial.
  5. Return the zone as text.

Don’t forget the SOA serial. Without the bump, the secondary servers of your zone don’t see the change.

dnspython does the job, but its API is not my favorite cup of tea:

from datetime import date

import dns.rdataclass
import dns.rdataset
import dns.rdatatype
import dns.zone


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)

I keep the serial as a date (%Y%m%d) and a two-digit revision. If the serial is from today, the revision increases by one; otherwise, it starts at zero for today. That allows 100 changes a day, plenty for one dynamic address.

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 the DNS server

By hand, you’d SSH to the DNS server, replace the file, and reload the server. paramiko does the same:

import paramiko


def upload_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')  # the reload command of your DNS server

The DNS server’s host key must already be in your known_hosts.

Expose it as a REST API

The router sends a request, and the app updates the zone. The new address comes from the request itself, and the router identifies itself with a JWT. remotefile, remotehost, remoteport, and keyfile come from the configuration.

This example creates a new secret and token each time the app starts, so the router needs the new token after a restart:

import uuid

from flask import Flask, request
from flask_jwt_extended import JWTManager, create_access_token, get_jwt_identity, jwt_required

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:

@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
    try:
        update(zone, name, newip)
    except NoUpdate:
        return {'status': 'unchanged'}
    upload_zone(f'zones/{zone}.zone', remotefile, remotehost, remoteport, keyfile)
    return {'status': 'updated'}

Most periodic calls find the address unchanged, so NoUpdate is the common path, not an error. Read X-Forwarded-For only when the app runs behind your own reverse proxy that sets it; otherwise, any client can put an arbitrary address there. Before deploying, add input validation and handling for the remaining exceptions.

Once deployed, set up the router to call the endpoint periodically.