Dynamic DNS for home routers the hacker way
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, andflask_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:
- Load the zone file.
- Find the record.
- Replace it, or stop if the address hasn’t changed.
- Update the SOA serial.
- 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.