From d1b0579a41c8215487a17317851f01756a1d938d Mon Sep 17 00:00:00 2001 From: jaseg Date: Tue, 29 Dec 2020 13:08:13 +0100 Subject: Add dns, dyndns services --- dyndns.py | 149 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 dyndns.py (limited to 'dyndns.py') diff --git a/dyndns.py b/dyndns.py new file mode 100644 index 0000000..2546dce --- /dev/null +++ b/dyndns.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 + +import time +from contextlib import contextmanager +import re +import os +import os.path +import random +import string +import subprocess +import sqlite3 +import hmac +from ipaddress import IPv4Address, IPv6Address + +from flask import Flask, request, abort +import uwsgidecorators + +app = Flask(__name__) +app.config.update(dict( + RECORD_EXPIRY_S = 86400, + NSD_CONTROL = 'nsd-control' + )) +app.config.from_pyfile('config.py') + + +ZONEFILE_TEMPLATE = '''\ +; #################################################### ; +; THIS FILE IS AUTOMATICALLY GENERATED! DO NOT MODIFY! ; +; #################################################### ; + +$ORIGIN {zone}. +$TTL 1800 +@ IN SOA {ns}. {mail}. ( + {serial} ; serial number + 60 ; refresh + 60 ; retry + {expire} ; expire + 60 ; ttl + ) +; Name servers + IN NS {ns}. + +; Additional A records from template +; @ IN A 192.0.2.3 +; www IN A 192.0.2.3 + +; Dynamically generated records +{dynamic_records} +''' + +db = sqlite3.connect(app.config['SQLITE_DB'], check_same_thread=False) +with db as conn: + conn.execute('''CREATE TABLE IF NOT EXISTS zone_versions (date TEXT)''') + conn.execute('''CREATE TABLE IF NOT EXISTS records + (name TEXT PRIMARY KEY, ipv4 TEXT, ipv6 TEXT, last_update INTEGER)''') + +def purge_expired_records(): + with db as conn: + conn.execute('DELETE FROM records WHERE last_update < ?', + (int(time.time()) - app.config['RECORD_EXPIRY_S'],)) + +def update_record(record, ipv4=None, ipv6=None): + with db as conn: + old_v4, old_v6 = conn.execute('SELECT ipv4, ipv6 FROM records WHERE name=?', (record,)).fetchone() or (None, None) + conn.execute('INSERT OR REPLACE INTO records VALUES (?, ?, ?, ?)', (record, ipv4, ipv6, int(time.time()))) + return ipv4 != old_v4 or ipv6 != old_v6 + +@contextmanager +def inplace_rewrite(filename, cleanup=True): + print('Writing', filename) + filename = os.path.abspath(filename) + if cleanup: + basename = os.path.basename(filename) + for entry in os.scandir(os.path.dirname(filename)): + if entry.name.startswith(basename) and re.match(r'\.tmp-[a-zA-Z0-9]{8}', entry.name[len(basename):]): + os.remove(entry.path) + + tmp_fn = filename + f'.tmp-' + ''.join(random.choices(string.ascii_letters + string.digits, k=8)) + with open(tmp_fn, 'w') as tmp_f: + yield tmp_f + tmp_f.flush() + os.fsync(tmp_f.fileno()) + os.rename(tmp_fn, filename) + +def write_zonefile(): + # Find the next free zonefile version number + with db as conn: + conn.execute('INSERT INTO zone_versions VALUES (DATE())') + date, version_num, = conn.execute('SELECT zone_versions.date, COUNT(*) FROM zone_versions WHERE zone_versions.date = DATE()').fetchone() + zone_version = f'{date.replace("-", "")}{version_num:02d}' + + # Generate dynamic record block + with db as conn: + records = db.execute('SELECT name, "A", ipv4 FROM records UNION SELECT name, "AAAA", ipv6 FROM records') + dynamic_records = '\n'.join(f'{name:<20} IN {rtype:<4} {value}' for name, rtype, value in records if value is not None) + + # Template zone file content + content = ZONEFILE_TEMPLATE.format( + zone = app.config['ZONE'], + ns = app.config['NAMESERVER'], + mail = app.config['NAMESERVER_MAIL'].replace('@', '.'), + serial = zone_version, + dynamic_records = dynamic_records, + expire = app.config['RECORD_EXPIRY_S'] + ) + + with inplace_rewrite(app.config['ZONEFILE'], cleanup=True) as f: + f.write(content) + +def kick_nsd(): + prog = app.config['NSD_CONTROL'] + if isinstance(prog, str): + prog = [prog] + subprocess.run([*prog, 'reload', app.config['ZONE']], check=True) + +@app.before_first_request +@uwsgidecorators.timer(300) +def update_zonefile(): + purge_expired_records() + write_zonefile() + kick_nsd() + +@app.route('/update', methods=['POST']) +def route_update(): + if request.authorization is None: + abort(403) + + record = request.authorization['username'] + record_config = app.config['DYNAMIC_RECORDS'].get(record) + if record_config is None: + abort(403) + + *supported_formats, password = record_config + if not hmac.compare_digest(request.authorization['password'], password): + abort(403) + + ipv4 = request.args.get('ipv4', '127.0.0.1') + ipv6 = request.args.get('ipv6', '::1') + ipv4 = str(IPv4Address(ipv4)) if 'v4' in supported_formats else None + ipv6 = str(IPv6Address(ipv6)) if 'v6' in supported_formats else None + if update_record(record, ipv4=ipv4, ipv6=ipv6): + update_zonefile() + + return 'success' + + +if __name__ == '__main__': + app.run() + -- cgit