aboutsummaryrefslogtreecommitdiff
path: root/dyndns.py
blob: 2546dcec95a97238744b0d7aa1d2f22c90bda41f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
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()