aboutsummaryrefslogtreecommitdiff
path: root/dyndns.py
diff options
context:
space:
mode:
authorjaseg <code@jaseg.net>2020-12-29 13:08:13 +0100
committerjaseg <code@jaseg.net>2020-12-29 13:08:13 +0100
commitd1b0579a41c8215487a17317851f01756a1d938d (patch)
tree60d56112542ee5af43f73ad0e10dda9c0b3b1d09 /dyndns.py
parente346c558ea3d9761d7affe796664e1574f33773a (diff)
downloadinfra-d1b0579a41c8215487a17317851f01756a1d938d.tar.gz
infra-d1b0579a41c8215487a17317851f01756a1d938d.tar.bz2
infra-d1b0579a41c8215487a17317851f01756a1d938d.zip
Add dns, dyndns services
Diffstat (limited to 'dyndns.py')
-rw-r--r--dyndns.py149
1 files changed, 149 insertions, 0 deletions
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()
+