From e63a7e557da86afc69f987df416b9932d0822d31 Mon Sep 17 00:00:00 2001
From: jaseg <code@jaseg.net>
Date: Tue, 29 Dec 2020 13:08:13 +0100
Subject: Add dns, dyndns services

---
 gerboweb/deploy/dns.yml             |  91 +++++++++
 gerboweb/deploy/dyndns.py           | 149 +++++++++++++++
 gerboweb/deploy/dyndns_config.py.j2 |  14 ++
 gerboweb/deploy/nsd.conf            | 372 ++++++++++++++++++++++++++++++++++++
 gerboweb/deploy/setup_dyndns.yml    |  80 ++++++++
 gerboweb/deploy/uwsgi-dyndns.ini    |  10 +
 6 files changed, 716 insertions(+)
 create mode 100644 gerboweb/deploy/dns.yml
 create mode 100644 gerboweb/deploy/dyndns.py
 create mode 100644 gerboweb/deploy/dyndns_config.py.j2
 create mode 100644 gerboweb/deploy/nsd.conf
 create mode 100644 gerboweb/deploy/setup_dyndns.yml
 create mode 100644 gerboweb/deploy/uwsgi-dyndns.ini

(limited to 'gerboweb')

diff --git a/gerboweb/deploy/dns.yml b/gerboweb/deploy/dns.yml
new file mode 100644
index 0000000..0fd753a
--- /dev/null
+++ b/gerboweb/deploy/dns.yml
@@ -0,0 +1,91 @@
+- name: Setup subdomain A records pointing to wendelstein
+  inwx:
+      domain: "{{item.partition('.')[2]}}"
+      record: "{{item.partition('.')[0]}}"
+      type: A
+      value: "{{ hostvars['wendelstein']['ansible_default_ipv4']['address'] }}"
+  loop: "{{subdomains}}"
+
+- name: Setup dyndns A record
+  inwx:
+      domain: jaseg.de
+      record: ns
+      type: A
+      value: "{{ hostvars['wendelstein']['ansible_default_ipv4']['address'] }}"
+
+- name: Setup dyndns NS record
+  inwx:
+      domain: jaseg.de
+      record: dyn
+      type: NS
+      value: 'ns.jaseg.de'
+
+- name: Setup subdomain AAAA records pointing to wendelstein
+  inwx:
+      domain: "{{item.partition('.')[2]}}"
+      record: "{{item.partition('.')[0]}}"
+      type: AAAA
+      value: "{{ hostvars['wendelstein']['ansible_default_ipv6']['address'] }}"
+  loop: "{{subdomains}}"
+
+- name: Setup jaseg.net subdomain MX records pointing to fastmail
+  inwx:
+      domain: "{{item.partition('.')[2]}}"
+      record: "{{item.partition('.')[0]}}"
+      type: MX
+      priority: 10
+      value: in1-smtp.messagingengine.com
+  loop: "{{subdomains}}"
+
+- name: Setup jaseg.net subdomain MX records pointing to fastmail
+  inwx:
+      domain: "{{item.partition('.')[2]}}"
+      record: "{{item.partition('.')[0]}}"
+      type: MX
+      priority: 20
+      value: in2-smtp.messagingengine.com
+  loop: "{{subdomains}}"
+
+- name: Setup sendgrid gateway
+  inwx:
+    domain: jaseg.de
+    type: CNAME
+    record: "{{item.split(' ')[0]}}"
+    value: "{{item.split(' ')[1]}}"
+  loop:
+    - em6100.automation u14518136.wl137.sendgrid.net  
+    - s1._domainkey.automation s1.domainkey.u14518136.wl137.sendgrid.net  
+    - s2._domainkey.automation s2.domainkey.u14518136.wl137.sendgrid.net
+
+- name: Set fastmail DNS entry template
+  set_fact:
+    fastmail_dns_entries:
+    - {rtype: MX,    record: ".", prio: 10,     value: in1-smtp.messagingengine.com}
+    - {rtype: MX,    record: ".", prio: 20,     value: in2-smtp.messagingengine.com}
+    - {rtype: MX,    record: "*", prio: 10,     value: in1-smtp.messagingengine.com}
+    - {rtype: MX,    record: "*", prio: 20,     value: in2-smtp.messagingengine.com}
+    - {rtype: TXT,   record: ".",               value: "v=spf1 include:spf.messagingengine.com ?all"}
+    - {rtype: CNAME, record: mesmtp._domainkey, value: mesmtp.jaseg.de.dkim.fmhosted.com}
+    - {rtype: CNAME, record: fm1._domainkey,    value: fm1.jaseg.de.dkim.fmhosted.com}
+    - {rtype: CNAME, record: fm2._domainkey,    value: fm2.jaseg.de.dkim.fmhosted.com}
+    - {rtype: CNAME, record: fm3._domainkey,    value: fm3.jaseg.de.dkim.fmhosted.com}
+    - {rtype: SRV,   record: _submission._tcp,  prio: 0, weight: 1, port: 587, value: smtp.fastmail.com}
+    - {rtype: SRV,   record: _imap._tcp,        prio: 0, weight: 0, port: 0, value: "."}
+    - {rtype: SRV,   record: _imaps._tcp,       prio: 0, weight: 1, port: 993, value: imap.fastmail.com}
+    - {rtype: SRV,   record: _pop3._tcp,        prio: 0, weight: 0, port: 0, value: "."}
+    - {rtype: SRV,   record: _pop3s._tcp,       prio: 10, weight: 1, port: 995, value: pop.fastmail.com}
+    - {rtype: SRV,   record: _jmap._tcp,        prio: 0, weight: 1, port: 443, value: jmap.fastmail.com}
+    - {rtype: SRV,   record: _carddav._tcp,     prio: 0, weight: 0, port: 0, value: "."}
+    - {rtype: SRV,   record: _carddavs._tcp,    prio: 0, weight: 1, port: 443, value: carddav.fastmail.com}
+
+- name: Setup fastmail DNS entries
+  inwx:
+      domain: "{{ item[1] }}"
+      type: "{{ item[0]['rtype'] }}"
+      record: "{{ item[0]['record'] | regex_replace('\\.*$', '') }}"
+      priority: "{{ item[0].get('prio') | int }}"
+      port: "{{ item[0].get('port') | int}}"
+      weight: "{{ item[0].get('weight') | int }}"
+      value: "{{ item[0]['value'] }}"
+  loop: "{{ fastmail_dns_entries | product(fastmail_domains) | list }}"
+
diff --git a/gerboweb/deploy/dyndns.py b/gerboweb/deploy/dyndns.py
new file mode 100644
index 0000000..2546dce
--- /dev/null
+++ b/gerboweb/deploy/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()
+
diff --git a/gerboweb/deploy/dyndns_config.py.j2 b/gerboweb/deploy/dyndns_config.py.j2
new file mode 100644
index 0000000..3212a1e
--- /dev/null
+++ b/gerboweb/deploy/dyndns_config.py.j2
@@ -0,0 +1,14 @@
+
+SQLITE_DB = '{{dyndns_sqlite_dbfile}}'
+
+NAMESERVER = 'ns.jaseg.de'
+NAMESERVER_MAIL = 'dns@jaseg.de'
+ZONEFILE = 'dyn.jaseg.de.zone'
+ZONE = 'dyn.jaseg.de'
+NSD_CONTROL = 'sudo -u nsd nsd-control'.split()
+
+DYNAMIC_RECORDS = {
+  'bigdata': ('v6', '{{ lookup('password', 'dyndns_secret_bigdata.txt length=32') }}'),
+  'raspi': ('v6', '{{ lookup('password', 'dyndns_secret_raspi.txt length=32') }}'),
+}
+
diff --git a/gerboweb/deploy/nsd.conf b/gerboweb/deploy/nsd.conf
new file mode 100644
index 0000000..d4b577f
--- /dev/null
+++ b/gerboweb/deploy/nsd.conf
@@ -0,0 +1,372 @@
+#
+# nsd.conf -- the NSD(8) configuration file, nsd.conf(5).
+#
+# Copyright (c) 2001-2011, NLnet Labs. All rights reserved.
+#
+# See LICENSE for the license.
+#
+
+# This is a comment.
+# Sample configuration file
+# include: "file" # include that file's text over here.  Globbed, "*.conf"
+
+# options for the nsd server
+server:
+	# Number of NSD servers to fork.  Put the number of CPUs to use here.
+	server-count: 1
+
+	# uncomment to specify specific interfaces to bind (default are the
+	# wildcard interfaces 0.0.0.0 and ::0).
+	# For servers with multiple IP addresses, list them one by one,
+	# or the source address of replies could be wrong.
+	# Use ip-transparent to be able to list addresses that turn on later.
+	# ip-address: 1.2.3.4
+	# ip-address: 1.2.3.4@5678
+	# ip-address: 12fe::8ef0
+
+	# Allow binding to non local addresses. Default no.
+	# ip-transparent: no
+
+	# Allow binding to addresses that are down.  Default no.
+	# ip-freebind: no
+
+	# use the reuseport socket option for performance. Default no.
+	reuseport: yes
+
+	# override maximum socket send buffer size.  Default of 0 results in
+	# send buffer size being set to 1048576 (bytes).
+	# send-buffer-size: 1048576
+
+	# override maximum socket receive buffer size. Default of 0 results in
+	# receive buffer size being set to 1048576 (bytes).
+	# receive-buffer-size: 1048576
+
+	# enable debug mode, does not fork daemon process into the background.
+	# debug-mode: no
+
+	# listen on IPv4 connections
+	# do-ip4: yes
+
+	# listen on IPv6 connections
+	# do-ip6: yes
+
+	# port to answer queries on. default is 53.
+	# port: 53
+
+	# Verbosity level.
+	# verbosity: 0
+
+	# After binding socket, drop user privileges.
+	# can be a username, id or id.gid.
+	# username: nsd
+
+	# Run NSD in a chroot-jail.
+	# make sure to have pidfile and database reachable from there.
+	# by default, no chroot-jail is used.
+	# chroot: "/etc/nsd"
+
+	# The directory for zonefile: files.  The daemon chdirs here.
+	zonesdir: "/etc/nsd"
+
+	# the list of dynamically added zones.
+	# zonelistfile: "/var/lib/nsd/zone.list"
+
+	# the database to use
+	# if set to "" then no disk-database is used, less memory usage.
+	database: ""
+
+	# log messages to file. Default to stderr and syslog (with
+	# facility LOG_DAEMON).  stderr disappears when daemon goes to bg.
+	# logfile: "/var/log/nsd.log"
+
+	# File to store pid for nsd in.
+	# pidfile: "/run/nsd/nsd.pid"
+
+	# The file where secondary zone refresh and expire timeouts are kept.
+	# If you delete this file, all secondary zones are forced to be
+	# 'refreshing' (as if nsd got a notify).  Set to "" to disable.
+	# xfrdfile: "/var/lib/nsd/ixfr.state"
+
+	# The directory where zone transfers are stored, in a subdir of it.
+	# xfrdir: "/tmp"
+
+	# don't answer VERSION.BIND and VERSION.SERVER CHAOS class queries
+	hide-version: yes
+
+	# don't answer HOSTNAME.BIND and ID.SERVER CHAOS class queries
+	hide-identity: yes
+
+	# version string the server responds with for chaos queries.
+	# default is 'NSD x.y.z' with the server's version number.
+	# version: "NSD"
+
+	# identify the server (CH TXT ID.SERVER entry).
+	# identity: "unidentified server"
+
+	# NSID identity (hex string, or "ascii_somestring"). default disabled.
+	# nsid: "aabbccdd"
+
+	# Maximum number of concurrent TCP connections per server.
+	# tcp-count: 100
+
+	# Accept (and immediately close) TCP connections after maximum number
+	# of connections is reached to prevent kernel connection queue from
+	# growing.
+	# tcp-reject-overflow: no
+
+	# Maximum number of queries served on a single TCP connection.
+	# By default 0, which means no maximum.
+	# tcp-query-count: 0
+
+	# Override the default (120 seconds) TCP timeout.
+	# tcp-timeout: 120
+
+	# Maximum segment size (MSS) of TCP socket on which the server
+	# responds to queries. Default is 0, system default MSS.
+	# tcp-mss: 0
+
+	# Maximum segment size (MSS) of TCP socket for outgoing AXFR request.
+	# Default is 0, system default MSS.
+	# outgoing-tcp-mss: 0
+
+	# Preferred EDNS buffer size for IPv4.
+	# ipv4-edns-size: 4096
+
+	# Preferred EDNS buffer size for IPv6.
+	# ipv6-edns-size: 4096
+
+	# statistics are produced every number of seconds. Prints to log.
+	# Default is 0, meaning no statistics are produced.
+	# statistics: 3600
+
+	# Number of seconds between reloads triggered by xfrd.
+	# xfrd-reload-timeout: 1
+
+	# log timestamp in ascii (y-m-d h:m:s.msec), yes is default.
+	# log-time-ascii: yes
+
+	# round robin rotation of records in the answer.
+	round-robin: yes
+
+	# minimal-responses only emits extra data for referrals.
+	minimal-responses: yes
+
+	# Do not return additional information if the apex zone of the
+	# additional information is configured but does not match the apex zone
+	# of the initial query.
+	# confine-to-zone: no
+
+	# refuse queries of type ANY.  For stopping floods.
+	refuse-any: yes
+
+	# check mtime of all zone files on start and sighup
+	# zonefiles-check: yes
+
+	# write changed zonefiles to disk, every N seconds.
+	# default is 0(disabled) or 3600(if database is "").
+	# zonefiles-write: 3600
+
+	# RRLconfig
+	# Response Rate Limiting, size of the hashtable. Default 1000000.
+	# rrl-size: 1000000
+
+	# Response Rate Limiting, maximum QPS allowed (from one query source).
+	# If set to 0, ratelimiting is disabled. Also set
+	# rrl-whitelist-ratelimit to 0 to disable ratelimit processing.
+	# Default is on.
+	# rrl-ratelimit: 200
+
+	# Response Rate Limiting, number of packets to discard before
+	# sending a SLIP response (a truncated one, allowing an honest
+	# resolver to retry with TCP). Default is 2 (one half of the
+	# queries will receive a SLIP response, 0 disables SLIP (all
+	# packets are discarded), 1 means every request will get a
+	# SLIP response.  When the ratelimit is hit the traffic is
+	# divided by the rrl-slip value.
+	# rrl-slip: 2
+
+	# Response Rate Limiting, IPv4 prefix length. Addresses are
+	# grouped by netblock.
+	# rrl-ipv4-prefix-length: 24
+
+	# Response Rate Limiting, IPv6 prefix length. Addresses are
+	# grouped by netblock.
+	# rrl-ipv6-prefix-length: 64
+
+	# Response Rate Limiting, maximum QPS allowed (from one query source)
+	# for whitelisted types. Default is on.
+	# rrl-whitelist-ratelimit: 2000
+	# RRLend
+
+	# Optional local server config
+	include: "/etc/nsd/server.d/*.conf"
+
+# Include optional local configs.
+include: "/etc/nsd/conf.d/*.conf"
+
+# Fedora: DNSTAP not yet enabled
+# dnstap:
+	# set this to yes and set one or more of dnstap-log-..-messages to yes.
+	# dnstap-enable: no
+	# dnstap-socket-path: "/var/run/dnstap.sock"
+	# dnstap-send-identity: no
+	# dnstap-send-version: no
+	# dnstap-identity: ""
+	# dnstap-version: ""
+	# dnstap-log-auth-query-messages: no
+	# dnstap-log-auth-response-messages: no
+
+	# Service clients over TLS (on the TCP sockets), with plain DNS inside
+	# the TLS stream. Give the certificate to use and private key.
+	# Default is "" (disabled). Requires restart to take effect.
+	# tls-service-key: "path/to/privatekeyfile.key"
+	# tls-service-pem: "path/to/publiccertfile.pem"
+	# tls-service-ocsp: "path/to/ocsp.pem"
+	# tls-port: 853
+
+# Remote control config section.
+remote-control:
+	# Enable remote control with nsd-control(8) here.
+	# set up the keys and certificates with nsd-control-setup.
+	control-enable: yes
+
+	# what interfaces are listened to for control, default is on localhost.
+	# with an absolute path, a unix local named pipe is used for control
+	# (and key and cert files are not needed, use directory permissions).
+	# control-interface: 127.0.0.1
+	# control-interface: ::1
+	control-interface: /run/nsd/nsd.ctl
+
+	# port number for remote control operations (uses TLS over TCP).
+	# control-port: 8952
+
+	# nsd server key file for remote control.
+	# server-key-file: "/etc/nsd/nsd_server.key"
+
+	# nsd server certificate file for remote control.
+	# server-cert-file: "/etc/nsd/nsd_server.pem"
+
+	# nsd-control key file.
+	# control-key-file: "/etc/nsd/nsd_control.key"
+
+	# nsd-control certificate file.
+	# control-cert-file: "/etc/nsd/nsd_control.pem"
+
+
+# Secret keys for TSIGs that secure zone transfers.
+# You could include: "secret.keys" and put the 'key:' statements in there,
+# and give that file special access control permissions.
+#
+# key:
+	# The key name is sent to the other party, it must be the same
+	#name: "keyname"
+	# algorithm hmac-md5, or sha1, sha256, sha224, sha384, sha512
+	#algorithm: sha256
+	# secret material, must be the same as the other party uses.
+	# base64 encoded random number.
+	# e.g. from dd if=/dev/random of=/dev/stdout count=1 bs=32 | base64
+	#secret: "K2tf3TRjvQkVCmJF3/Z9vA=="
+
+
+# Patterns have zone configuration and they are shared by one or more zones.
+#
+# pattern:
+	# name by which the pattern is referred to
+	#name: "myzones"
+	# the zonefile for the zones that use this pattern.
+	# if relative then from the zonesdir (inside the chroot).
+	# the name is processed: %s - zone name (as appears in zone:name).
+	# %1 - first character of zone name, %2 second, %3 third.
+	# %z - topleveldomain label of zone, %y, %x next labels in name.
+	# if label or character does not exist you get a dot '.'.
+	# for example "%s.zone" or "zones/%1/%2/%3/%s" or "secondary/%z/%s"
+	#zonefile: "%s.zone"
+
+	# If no master and slave access control elements are provided,
+	# this zone will not be served to/from other servers.
+
+	# A master zone needs notify: and provide-xfr: lists.  A slave
+	# may also allow zone transfer (for debug or other secondaries).
+	# notify these slaves when the master zone changes, address TSIG|NOKEY
+	# IP can be ipv4 and ipv6, with @port for a nondefault port number.
+	#notify: 192.0.2.1 NOKEY
+	# allow these IPs and TSIG to transfer zones, addr TSIG|NOKEY|BLOCKED
+	# address range 192.0.2.0/24, 1.2.3.4&255.255.0.0, 3.0.2.20-3.0.2.40
+	#provide-xfr: 192.0.2.0/24 my_tsig_key_name
+	# set the number of retries for notify.
+	#notify-retry: 5
+
+	# uncomment to provide AXFR to all the world
+	# provide-xfr: 0.0.0.0/0 NOKEY
+	# provide-xfr: ::0/0 NOKEY
+
+	# A slave zone needs allow-notify: and request-xfr: lists.
+	#allow-notify: 2001:db8::0/64 my_tsig_key_name
+	# By default, a slave will request a zone transfer with IXFR/TCP.
+	# If you want to make use of IXFR/UDP use: UDP addr tsigkey
+	# for a master that only speaks AXFR (like NSD) use AXFR addr tsigkey
+	#request-xfr: 192.0.2.2 the_tsig_key_name
+	# Attention: You cannot use UDP and AXFR together. AXFR is always over
+	# TCP. If you use UDP, we higly recommend you to deploy TSIG.
+	# Allow AXFR fallback if the master does not support IXFR. Default
+	# is yes.
+	#allow-axfr-fallback: yes
+	# set local interface for sending zone transfer requests.
+	# default is let the OS choose.
+	#outgoing-interface: 10.0.0.10
+	# limit the refresh and retry interval in seconds.
+	#max-refresh-time: 2419200
+	#min-refresh-time: 0
+	#max-retry-time: 1209600
+	#min-retry-time: 0
+
+	# Slave server tries zone transfer to all masters and picks highest
+	# zone version available, for when masters have different versions.
+	#multi-master-check: no
+
+	# limit the zone transfer size (in bytes), stops very large transfers
+	# 0 is no limits enforced.
+	# size-limit-xfr: 0
+
+	# if compiled with --enable-zone-stats, give name of stat block for
+	# this zone (or group of zones).  Output from nsd-control stats.
+	# zonestats: "%s"
+
+	# if you give another pattern name here, at this point the settings
+	# from that pattern are inserted into this one (as if it were a
+	# macro).  The statement can be given in between other statements,
+	# because the order of access control elements can make a difference
+	# (which master to request from first, which slave to notify first).
+	#include-pattern: "common-masters"
+
+
+# Fixed zone entries.  Here you can config zones that cannot be deleted.
+# Zones that are dynamically added and deleted are put in the zonelist file.
+#
+# zone:
+	# name: "example.com"
+	# you can give a pattern here, all the settings from that pattern
+	# are then inserted at this point
+	# include-pattern: "master"
+	# You can also specify (additional) options directly for this zone.
+	# zonefile: "example.com.zone"
+	# request-xfr: 192.0.2.1 example.com.key
+
+	# RRLconfig
+	# Response Rate Limiting, whitelist types
+	# rrl-whitelist: nxdomain
+	# rrl-whitelist: error
+	# rrl-whitelist: referral
+	# rrl-whitelist: any
+	# rrl-whitelist: rrsig
+	# rrl-whitelist: wildcard
+	# rrl-whitelist: nodata
+	# rrl-whitelist: dnskey
+	# rrl-whitelist: positive
+	# rrl-whitelist: all
+	# RRLend
+
+zone:
+  name: "dyn.jaseg.de"
+  zonefile: "/var/lib/dyndns/dyn.jaseg.de.zone"
+
diff --git a/gerboweb/deploy/setup_dyndns.yml b/gerboweb/deploy/setup_dyndns.yml
new file mode 100644
index 0000000..d9735c7
--- /dev/null
+++ b/gerboweb/deploy/setup_dyndns.yml
@@ -0,0 +1,80 @@
+---
+- name: Set local facts
+  set_fact:
+      dyndns_sqlite_dbfile: /var/lib/dyndns/db.sqlite3
+
+- name: Copy nsd config
+  copy:
+    src: nsd.conf
+    dest: /etc/nsd/nsd.conf
+    owner: root
+    group: root
+    mode: 0644
+
+- name: Enable and launch nsd systemd service
+  systemd:
+      name: nsd.service
+      enabled: yes
+      state: restarted
+
+- name: Create dyndns worker user and group
+  user:
+      name: uwsgi-dyndns
+      create_home: no
+      group: uwsgi
+      password: '!'
+      shell: /sbin/nologin
+      system: yes
+
+- name: Allow dyndns app to kick nsd
+  lineinfile:
+    path: /etc/sudoers
+    line: 'uwsgi-dyndns ALL=(nsd) NOPASSWD: /usr/sbin/nsd-control reload dyn.jaseg.de'
+
+- name: Create webapp dir
+  file:
+      path: /var/lib/dyndns
+      state: directory
+      owner: uwsgi-dyndns
+      group: nsd
+      mode: 0750
+
+- name: Copy webapp sources
+  copy:
+      src: dyndns.py
+      dest: /var/lib/dyndns/
+      owner: uwsgi-dyndns
+      group: uwsgi
+      mode: 0440
+
+- name: Template webapp config
+  template:
+    src: dyndns_config.py.j2
+    dest: /var/lib/dyndns/config.py
+    owner: uwsgi-dyndns
+    group: root
+    mode: 0660
+
+- name: Copy uwsgi config
+  copy:
+      src: uwsgi-dyndns.ini
+      dest: /etc/uwsgi.d/dyndns.ini
+      owner: uwsgi-dyndns
+      group: uwsgi
+      mode: 0440
+
+- name: Enable uwsgi systemd socket
+  systemd:
+      daemon-reload: yes
+      name: uwsgi-app@dyndns.socket
+      enabled: yes
+
+- name: Create sqlite db file
+  file:
+    path: "{{dyndns_sqlite_dbfile}}"
+    owner: uwsgi-dyndns
+    group: uwsgi
+    mode: 0660
+    state: touch
+
+
diff --git a/gerboweb/deploy/uwsgi-dyndns.ini b/gerboweb/deploy/uwsgi-dyndns.ini
new file mode 100644
index 0000000..b62e2af
--- /dev/null
+++ b/gerboweb/deploy/uwsgi-dyndns.ini
@@ -0,0 +1,10 @@
+[uwsgi]
+master = True
+cheap = True
+die-on-idle = False
+manage-script-name = True
+log-format = [pid: %(pid)|app: -|req: -/-] %(addr) (%(user)) {%(vars) vars in %(pktsize) bytes} [%(ctime)] %(method) [URI hidden] => generated %(rsize) bytes in %(msecs) msecs (%(proto) %(status)) %(headers) headers in %(hsize) bytes (%(switches) switches on core %(core))
+plugins = python3
+chdir = /var/lib/dyndns
+mount = /=dyndns:app
+
-- 
cgit