From 1697e1aa389c74aa377bafb860966c21dc0b68d0 Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Mon, 26 Oct 2026 21:26:47 +0100 Subject: [PATCH] fix: the name server is the primary of any number of domains, each with secondary name servers and a switch for the records of the sites. --- ADR.md | 31 ++- README.md | 58 ++-- debian-package/debian/changelog | 18 ++ debian-package/src/usr/lib/vpn-router/setup | 8 +- .../src/usr/lib/vpn-router/vpnrouter.py | 13 +- .../src/usr/lib/vpn-router/vpnrouter_dns.py | 253 +++++++++++++----- debian-package/src/usr/lib/vpn-router/web | 63 +++-- .../doc/vpn-router/vpn-router.json.example | 1 - .../src/usr/share/vpn-router/sql/dns.sql | 117 ++++++-- 9 files changed, 412 insertions(+), 150 deletions(-) diff --git a/ADR.md b/ADR.md index d31a493..ef6be1a 100644 --- a/ADR.md +++ b/ADR.md @@ -1141,8 +1141,9 @@ later. S3 and Azure Blob clients are not served. Status: Accepted -Context: Sites get subdomains of a selected domain, delegated to the router, without a registrar -change per site. Ubuntu has BIND, Knot and PowerDNS; only PowerDNS's gpgsql backend reads records +Context: The router hosts authoritative domains as their primary, with optional secondary name +servers, and a domain can give the sites their records without a registrar change per site. Ubuntu +has BIND, Knot and PowerDNS; only PowerDNS's gpgsql backend reads records from PostgreSQL, so changes are rows, without zone files or reloads. systemd-resolved's stub holds 127.0.0.53 and 127.0.0.54, and the remote access clients' resolver answered on the external addresses, the addresses an authoritative server needs. The clients reach the internal address @@ -1151,15 +1152,25 @@ just as well, through the tunnel when it is in `local.cidrs` (ADR-43). Decision: While `dns.enabled` is true, setup runs `pdns.service` with `/etc/powerdns/pdns.d/vpn-router.conf`, written before the packages are installed, on the addresses of `dns.listen`, with the gpgsql backend on the database `vpn-router-dns` and PowerDNS's -own schema. It writes one zone, `dns.zone`, through the procedure `dns_apply` of -`/usr/share/vpn-router/sql/dns.sql`: SOA, NS, A and AAAA records of `dns.addresses` for the apex, -the name server and every site host name in the zone, and manual records administrators add; the -serial grows only when the records change. The remote access clients' resolver answers on the -addresses of the new setting `p2s.dns_listen`, by default the internal one; a `dns.listen` that -shares an interface with it is refused while P2S or OpenVPN is enabled. +own schema, and `primary=yes`. The domains, their secondaries and manual records are rows of the +router's tables in that database, like the sites in `vpn-router-sites`, not settings of +`vpn-router.json`: they are many and change through the web service. `dns.nameserver` and +`dns.addresses` stay settings, the router's one identity as the primary NS of every domain. Setup +writes each domain as a MASTER zone through the procedure `dns_apply` of +`/usr/share/vpn-router/sql/dns.sql`: SOA, NS of the router and the secondaries, A and AAAA records +of `dns.addresses` for the apex, the name server and, with the domain's site records switch, every +site host name whose closest domain it is, and the manual records; the serial grows only when the +records change, which makes PowerDNS notify. A secondary is a name, its NS, and an address, which +goes to the domain's `ALSO-NOTIFY` and `ALLOW-AXFR-FROM` metadata, so the global +`allow-axfr-ips` stays the loopback default. A host name belongs to its closest domain only, and a +domain inside another is delegated from it with NS records, so a name is never answered by two +zones. The remote access clients' resolver answers on the addresses of the setting +`p2s.dns_listen`, by default the internal one; a `dns.listen` that shares an interface with it is +refused while P2S or OpenVPN is enabled. -Consequences: The zone follows the sites at each setup run. One zone only, without DNSSEC or -transfers; the delegation is made at the parent zone by hand. +Consequences: The domains follow the sites at each setup run. Transfers are authorized by address, +without TSIG, and there is no DNSSEC; the delegation of each domain is made at its parent zone by +hand. ## ADR-43: The remote access clients' resolver is dnsmasq, not systemd-resolved diff --git a/README.md b/README.md index 17c8344..d292af7 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ A Debian package that configures a Linux host or virtual machine as a VPN router - metrics of the router kept in PostgreSQL, served for an OpenTelemetry Collector and pushed over OTLP - static web sites served by Caddy, uploaded through the web service or WebDAV, and an optional - authoritative name server (PowerDNS) for a zone of the sites + primary name server (PowerDNS) of the router's domains, with secondaries and records for the sites Nothing in the package is specific to a cloud provider. It is built on the external and internal NIC names, and a `mode` setting says how much of the rest you are supplying and @@ -147,10 +147,9 @@ must have the type shown below; an unknown section or setting is an error. | `sites` | `upload_max_mb` | integer | `512` | The largest upload of a site's file or zip archive, in MiB, 1 to 10240 | | `sites` | `webdav` | boolean | `false` | WebDAV of the sites' folders, which needs `https.enabled` and `wan.local_fqdn` (see WebDAV) | | `sites` | `webdav_port` | integer | `7445` | HTTPS port of WebDAV | -| `dns` | `enabled` | boolean | `false` | The name server of `dns.zone`, which needs `dns.zone`, `dns.nameserver` and `dns.addresses` (see Name server) | -| `dns` | `zone` | string | | The zone the name server answers for | -| `dns` | `nameserver` | string | | The name server's host name | -| `dns` | `addresses` | list | | The public addresses of the zone's apex, the name server and the sites' host names | +| `dns` | `enabled` | boolean | `false` | The primary name server of the router's domains, which needs `dns.nameserver` and `dns.addresses` (see Name server) | +| `dns` | `nameserver` | string | | The name server's host name, the primary NS of every domain | +| `dns` | `addresses` | list | | The public addresses of the name server, the domains' apexes and the sites' host names | | `dns` | `listen` | string | `external` | The interfaces the name server answers on: `internal`, `external` or `both` | | `dns` | `ttl` | integer | `300` | TTL of the records, 60 to 86400 seconds | | `pki` | `root_name` | string | `VPN Router Root CA` | Common name of the root CA, when the router creates it | @@ -1177,10 +1176,15 @@ curl -X PUT -H "Authorization: Bearer $TOKEN" --data-binary @site.zip \ | Request | Answer | | --- | --- | -| `GET /dns` | `{"settings", "installed", "running", "records", "manual", "problem"}`: the `dns` section, whether PowerDNS is installed and runs, the records it serves, the manual records, and why they cannot be read, `null` when they can | +| `GET /dns` | `{"settings", "installed", "running", "domains", "problem"}`: the `dns` section, whether PowerDNS is installed and runs, the domains, each `{"name", "sites", "ttl", "secondaries", "manual", "records"}` with its secondaries `{"nameserver", "address"}`, its manual records and the records it serves, and why they cannot be read, `null` when they can | | `PUT /dns` | Changes any of the settings of the `dns` section | -| `POST /dns/records` | Adds a manual record: `{"name", "type", "content", "ttl"?}`; `name` is relative to `dns.zone`, `@` for the zone itself, or absolute with a final dot, and `ttl` is `dns.ttl` without it | -| `DELETE /dns/records?name=&type=&content=` | Removes the manual records of the name and type, only the one of `content` when it is given | +| `POST /dns/domains` | Adds a domain: `{"name", "sites"?, "ttl"?}`; an existing name is 409 | +| `PUT /dns/domains/` | Changes `sites` or `ttl` of the domain | +| `DELETE /dns/domains/` | Removes the domain with its secondaries and manual records | +| `POST /dns/domains//secondaries` | Adds a secondary name server, or changes its address: `{"nameserver", "address"}` | +| `DELETE /dns/domains//secondaries/` | Removes the secondary name server | +| `POST /dns/domains//records` | Adds a manual record: `{"name", "type", "content", "ttl"?}`; `name` is relative to the domain, `@` for the domain itself, or absolute with a final dot, and `ttl` is the domain's without it | +| `DELETE /dns/domains//records?name=&type=&content=` | Removes the manual records of the name and type, only the one of `content` when it is given | A TXT content without quotes is quoted; MX is ` `, SRV ` ` and CAA ` ""`. A CNAME must be its name's only record. @@ -1583,9 +1587,9 @@ vpn-config-edit set --restart sites.webdav true ### Name server While `dns.enabled` is `true`, `vpn-router-setup` runs PowerDNS (`pdns.service`) as the -authoritative name server of one zone, `dns.zone`, on port 53 of the addresses of `dns.listen` +primary name server of the router's domains, on port 53 of the addresses of `dns.listen` (`internal`, `external`, the default, or `both`), which the firewall opens for UDP and TCP. The -records are in the database `vpn-router-dns` (password in `/etc/vpn-router/dns-database.secret`), +domains and records are in the database `vpn-router-dns` (password in `/etc/vpn-router/dns-database.secret`), read by PowerDNS's gpgsql backend; setup writes the settings `/etc/powerdns/pdns.d/vpn-router.conf` (root, group `pdns`, 0640) and installs `pdns-server` and `pdns-backend-pgsql` from the Ubuntu archive with `vpn-router-install@dns.service` when they are @@ -1593,21 +1597,31 @@ missing, without the recommended bind backend. | Setting | Meaning | | --- | --- | -| `dns.zone` | The zone, such as `sites.example.com` | -| `dns.nameserver` | The name server's host name, the zone's NS and the SOA's primary | -| `dns.addresses` | The public addresses of the zone's apex, of the name server when it is in the zone, and of every host name of an enabled site in the zone | -| `dns.ttl` | The TTL of the records and the SOA's negative TTL, 60 to 86400 seconds, 300 by default | +| `dns.nameserver` | The name server's host name, the primary NS and the SOA's primary of every domain | +| `dns.addresses` | The public addresses of the domains' apexes, of the name server in a domain it is inside, and of the sites' host names | +| `dns.ttl` | The TTL of the records and the SOA's negative TTL of a domain without its own, 60 to 86400 seconds, 300 by default | -Records added with `/dns` or the Router Manager (A, AAAA, CNAME, TXT, MX, SRV, CAA) are served -too, but not those of a name and type the zone's own records have. Setup writes the zone at each -run; the SOA's serial grows only when the records change. While P2S, OpenVPN or WireGuard is enabled, -dnsmasq answers their clients on port 53 of the addresses of `p2s.dns_listen`, by default the -internal address, so `dns.listen` must not share an interface with it. The parent zone must delegate `dns.zone` to `dns.nameserver`, with glue records for -a name server inside the zone. +The domains are added with `/dns` or the Router Manager, each with: + +- **Site records**: every host name of an enabled site gets A and AAAA records of `dns.addresses` + in the closest domain it is in, when that domain has site records; +- **TTL**: the TTL of its records, `dns.ttl` when not set; +- **Secondaries**: name servers, each a host name, which becomes an NS of the domain, and the + address it transfers the domain from. The router notifies that address of every change and + allows it zone transfers (AXFR); a secondary's name inside the domain gets an A or AAAA record of + that address; +- **Manual records** (A, AAAA, CNAME, TXT, MX, SRV, CAA), served too, but not those of a name and + type the domain's own records have. + +A domain inside another domain of the router is delegated from it with NS records. Setup writes the +domains at each run, and after each change through `/dns`; the SOA's serial grows only when the +records change. While P2S, OpenVPN or WireGuard is enabled, dnsmasq answers their clients on port 53 +of the addresses of `p2s.dns_listen`, by default the internal address, so `dns.listen` must not +share an interface with it. The parent zone of each domain must delegate it to `dns.nameserver` and +the secondaries, with glue records for name servers inside the domain. ```sh -vpn-config-edit set dns.zone sites.example.com -vpn-config-edit set dns.nameserver ns1.sites.example.com +vpn-config-edit set dns.nameserver ns1.example.com vpn-config-edit set dns.addresses 203.0.113.10 vpn-config-edit set --restart dns.enabled true ``` diff --git a/debian-package/debian/changelog b/debian-package/debian/changelog index d86b266..066334c 100644 --- a/debian-package/debian/changelog +++ b/debian-package/debian/changelog @@ -1,3 +1,21 @@ +vpn-router (0.33.1-1) unstable; urgency=medium + + fix: the name server is the primary of any number of domains, each with secondary name servers and a switch for the records of the sites. + + * The domains, their secondaries and manual records are kept in the + vpn-router-dns database and changed through /dns/domains; dns.zone is + removed. + * Each domain is served as a primary zone: the router and its + secondaries are its NS, and each secondary's address is notified of + changes and may transfer the zone. + * A domain with site records gives the host names of the enabled sites + in it A and AAAA records of dns.addresses; a domain inside another is + delegated from it. + * Manual records belong to a domain; those of the single zone are + dropped. + + -- Slawomir Koszewski Tue, 27 Oct 2026 20:00:00 +0100 + vpn-router (0.33.0-1) unstable; urgency=medium feature: road-warrior clients resolve the domains of the new setting p2s.dns_domains with the router's resolver. diff --git a/debian-package/src/usr/lib/vpn-router/setup b/debian-package/src/usr/lib/vpn-router/setup index ffaa2b5..1dc5490 100755 --- a/debian-package/src/usr/lib/vpn-router/setup +++ b/debian-package/src/usr/lib/vpn-router/setup @@ -821,12 +821,12 @@ def manage_webdav(ctx): def manage_dns(cfg, ctx): - """Run PowerDNS, the name server of dns.zone, on the addresses of dns.listen while DNS is enabled, and stop it when - not; its packages are installed when they are missing. + """Run PowerDNS, the primary name server of the domains of vpnrouter_dns, on the addresses of dns.listen while DNS + is enabled, and stop it when not; its packages are installed when they are missing. The settings, vpnrouter_dns.PDNS_CONF, are written before the packages are installed, so the server their - installation starts answers on those addresses only, beside systemd-resolved's stub on 127.0.0.53. The zone gets - the host names of the enabled sites, and keeps those it has while the sites cannot be read. + installation starts answers on those addresses only, beside systemd-resolved's stub on 127.0.0.53. The domains + with site records get the host names of the enabled sites, and keep those they have while the sites cannot be read. """ if not vpnrouter.dns_ready(ctx): if unit_is_active(vpnrouter_dns.UNIT): diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter.py b/debian-package/src/usr/lib/vpn-router/vpnrouter.py index 984051a..3de675a 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter.py @@ -69,7 +69,7 @@ SCHEMA = { 'https': {'enabled': True, 'email': '', 'web_port': 7444, 'manager_port': 7443, 'caddy_source': 'ubuntu'}, 'oidc': {'enabled': False}, 'sites': {'enabled': False, 'upload_max_mb': 512, 'webdav': False, 'webdav_port': 7445}, - 'dns': {'enabled': False, 'zone': '', 'nameserver': '', 'addresses': [], 'listen': 'external', 'ttl': 300}, + 'dns': {'enabled': False, 'nameserver': '', 'addresses': [], 'listen': 'external', 'ttl': 300}, 'pki': {'root_name': 'VPN Router Root CA', 'root_days': 7300, 'ca_days': 3650, 'key': 'rsa-4096', 'crl_days': 7, 'root_crl_days': 180, 'publish': ['http'], 'p2s_name': 'VPN Router P2S CA', 's2s_name': 'VPN Router Site-to-Site CA', 'issuing_cas': {}, 'templates': {}, @@ -146,7 +146,7 @@ UPLOAD_MAX_MB = 10240 #: The shortest and the longest dns.ttl, in seconds. DNS_TTL_RANGE = (60, 86400) -#: A DNS name without a wildcard, in lowercase, of dns.zone and dns.nameserver. +#: A DNS name without a wildcard, in lowercase, of dns.nameserver and the domains of the name server. ZONE_NAME = re.compile(r'^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$') #: A domain of p2s.dns_domains, one label or more, and the most domains. @@ -579,11 +579,10 @@ def validate(cfg): dns = cfg['dns'] if dns['enabled']: - for option in ('zone', 'nameserver'): - if not ZONE_NAME.match(dns[option].strip()): - raise ConfigError(f'dns.{option}: {dns[option]!r} is not a DNS name in lowercase') + if not ZONE_NAME.match(dns['nameserver'].strip()): + raise ConfigError(f'dns.nameserver: {dns["nameserver"]!r} is not a DNS name in lowercase') if not _get_list(cfg, 'dns', 'addresses'): - raise ConfigError('dns.enabled needs dns.addresses, the public addresses of the zone') + raise ConfigError('dns.enabled needs dns.addresses, the public addresses of the name server and the sites') for item in _get_list(cfg, 'dns', 'addresses'): _check_address(item, 'dns.addresses') if dns['listen'] not in LISTEN_CHOICES: @@ -1093,7 +1092,7 @@ def webdav_ready(ctx): def dns_ready(ctx): - """The name server answers for dns.zone while dns.enabled is true.""" + """The name server answers for its domains while dns.enabled is true.""" return bool(ctx['dns_enabled']) diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_dns.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_dns.py index 1f7c39c..85c3b06 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_dns.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_dns.py @@ -1,14 +1,19 @@ """The router's authoritative name server, PowerDNS with its gpgsql backend, for vpn-router-setup and the web service. -The name server answers for one zone, dns.zone, on the addresses of the interfaces of dns.listen. Its records are in -the vpn-router-dns database, which provision() creates with its role and the password in DATABASE_SECRET, and fills with +The name server is the primary of its domains and answers on the addresses of the interfaces of dns.listen. The +domains, their secondary name servers and the records an administrator added (manual records) are in the +vpn-router-dns database, which provision() creates with its role and the password in DATABASE_SECRET, and fills with the schema of pdns-backend-pgsql, PDNS_SCHEMA; DNS_SQL defines the router's own tables and procedures, and the first -connection of a process loads it. apply() writes the zone: its SOA, its NS, dns.nameserver, A and AAAA records of -dns.addresses for the zone's apex, for the name server when it is in the zone, and for every host name of an enabled -site in the zone, and the records an administrator added (manual records), but those of a name and type the records -before them have. The serial of the SOA grows only when the records change. +connection of a process loads it. -Every failure of the database raises DatabaseError; a record that cannot be added raises Failure. +apply() writes every domain as a primary zone: its SOA with dns.nameserver, its NS, dns.nameserver and the name +servers of its secondaries, the NS of the domains directly below it, A and AAAA records of dns.addresses for its apex, +for those name servers inside it that are the router, and, when the domain has site records, for every host name of an +enabled site whose closest domain it is, and the manual records, but those of a name and type the records before them +have. A secondary's address is notified of every change (ALSO-NOTIFY) and may transfer the zone (ALLOW-AXFR-FROM); the +serial of the SOA grows only when the records change. + +Every failure of the database raises DatabaseError; a request that cannot be done raises Failure. """ import contextlib @@ -19,6 +24,7 @@ import re import psycopg from psycopg.rows import dict_row +import vpnrouter import vpnrouter_auth DATABASE = 'vpn-router-dns' @@ -38,7 +44,9 @@ GROUP = 'pdns' #: The types of the manual records. TYPES = ('A', 'AAAA', 'CNAME', 'TXT', 'MX', 'SRV', 'CAA') -#: The most manual records. +#: The most domains, secondaries of a domain, and manual records of a domain. +DOMAINS_MAX = 100 +SECONDARIES_MAX = 8 MANUAL_MAX = 1000 #: The refresh, retry and expire times of the SOA, in seconds. @@ -65,7 +73,8 @@ class DatabaseError(Exception): class Failure(Exception): - """A record that cannot be added or removed; status is 2 invalid, 3 no such record.""" + """A request that cannot be done; status is 2 invalid, 3 no such domain, secondary or record, 4 the domain + exists.""" def __init__(self, message, status=2): super().__init__(message) @@ -173,87 +182,210 @@ def check_record(zone, record): return {'name': name, 'type': kind, 'content': content, 'ttl': ttl} -def manual(): - """The manual records.""" - with database() as connection: - return connection.execute('SELECT * FROM dns_manual_list()').fetchall() +def closest(zones, name): + """The longest of the zones the name is in, None when it is in none.""" + return max((zone for zone in zones if in_zone(zone, name)), key=len, default=None) -def add(cfg, record): - """Add the manual record, or set the TTL of the same one; the record.""" - zone = cfg['dns']['zone'].strip() - if not zone: - raise Failure('dns.zone is not set') - record = check_record(zone, record) - record['ttl'] = record['ttl'] or cfg['dns']['ttl'] +def check_domain(name): + """The domain name in lowercase without a final dot; Failure when it is not a DNS name.""" + if not isinstance(name, str) or not vpnrouter.ZONE_NAME.match(name.strip().lower().rstrip('.')): + raise Failure(f'{name!r} is not a domain name') + return name.strip().lower().rstrip('.') + + +def check_ttl(ttl): + if ttl is not None and (isinstance(ttl, bool) or not isinstance(ttl, int) or + not vpnrouter.DNS_TTL_RANGE[0] <= ttl <= vpnrouter.DNS_TTL_RANGE[1]): + raise Failure(f'ttl must be {vpnrouter.DNS_TTL_RANGE[0]} to {vpnrouter.DNS_TTL_RANGE[1]} seconds') + return ttl + + +def domain(connection, name): + """The domain; Failure with status 3 when there is none.""" + row = next((row for row in connection.execute('SELECT * FROM dns_domains_list()').fetchall() + if row['name'] == name), None) + if row is None: + raise Failure(f'no domain {name}', 3) + return row + + +def listing(): + """Every domain with its secondaries, its manual records and the records the name server answers for it.""" with database() as connection: - records = connection.execute('SELECT * FROM dns_manual_list()').fetchall() - same = [row for row in records if (row['name'], row['type']) == (record['name'], record['type'])] - others = [row for row in records if row['name'] == record['name'] and row['type'] != record['type']] - if len(records) >= MANUAL_MAX and record not in records: - raise Failure(f'the zone has {MANUAL_MAX} manual records') - if record['type'] == 'CNAME' and (others or [row for row in same if row['content'] != record['content']]): + secondaries = connection.execute('SELECT * FROM dns_secondaries_list()').fetchall() + manual = connection.execute('SELECT * FROM dns_records_list()').fetchall() + return [{**row, + 'secondaries': [{key: item[key] for key in ('nameserver', 'address')} + for item in secondaries if item['domain'] == row['name']], + 'manual': [{key: item[key] for key in ('name', 'type', 'content', 'ttl')} + for item in manual if item['domain'] == row['name']], + 'records': connection.execute('SELECT * FROM dns_served(%s)', (row['name'],)).fetchall()} + for row in connection.execute('SELECT * FROM dns_domains_list()').fetchall()] + + +def domain_add(body): + """Add the domain {name, sites, ttl?}; the domain.""" + if not isinstance(body, dict) or not isinstance(body.get('sites', False), bool): + raise Failure('a domain is an object of name, sites and ttl') + name, sites, ttl = check_domain(body.get('name')), body.get('sites', False), check_ttl(body.get('ttl')) + with database() as connection: + names = [row['name'] for row in connection.execute('SELECT * FROM dns_domains_list()').fetchall()] + if name in names: + raise Failure(f'the domain {name} exists', 4) + if len(names) >= DOMAINS_MAX: + raise Failure(f'the name server has {DOMAINS_MAX} domains') + connection.execute('CALL dns_domain_put(%s, %s, %s)', (name, sites, ttl)) + return {'name': name, 'sites': sites, 'ttl': ttl} + + +def domain_change(name, body): + """Change the sites and ttl of the domain; the domain.""" + if not isinstance(body, dict) or not set(body) <= {'sites', 'ttl'} or \ + not isinstance(body.get('sites', False), bool): + raise Failure('a change of a domain is an object of sites and ttl') + with database() as connection: + row = domain(connection, name) + sites, ttl = body.get('sites', row['sites']), check_ttl(body.get('ttl', row['ttl'])) + connection.execute('CALL dns_domain_put(%s, %s, %s)', (name, sites, ttl)) + return {'name': name, 'sites': sites, 'ttl': ttl} + + +def domain_remove(name): + """Remove the domain with its secondaries and manual records.""" + with database() as connection: + domain(connection, name) + connection.execute('CALL dns_domain_remove(%s)', (name,)) + return {'name': name} + + +def secondary_add(name, body): + """Add the secondary name server {nameserver, address} of the domain, or set its address; the secondary.""" + if not isinstance(body, dict): + raise Failure('a secondary is an object of nameserver and address') + nameserver = body.get('nameserver') + if not isinstance(nameserver, str) or not vpnrouter.ZONE_NAME.match(nameserver.strip().lower().rstrip('.')): + raise Failure(f'{nameserver!r} is not a name server name') + nameserver = nameserver.strip().lower().rstrip('.') + try: + address = str(ipaddress.ip_address(str(body.get('address', '')).strip())) + except ValueError as exc: + raise Failure(f'{body.get("address")!r} is not an IP address') from exc + with database() as connection: + domain(connection, name) + others = [row for row in connection.execute('SELECT * FROM dns_secondaries_list()').fetchall() + if row['domain'] == name and row['nameserver'] != nameserver] + if len(others) >= SECONDARIES_MAX: + raise Failure(f'the domain {name} has {SECONDARIES_MAX} secondaries') + connection.execute('CALL dns_secondary_put(%s, %s, %s)', (name, nameserver, address)) + return {'domain': name, 'nameserver': nameserver, 'address': address} + + +def secondary_remove(name, nameserver): + """Remove the secondary name server of the domain.""" + with database() as connection: + domain(connection, name) + if not any(row['domain'] == name and row['nameserver'] == nameserver + for row in connection.execute('SELECT * FROM dns_secondaries_list()').fetchall()): + raise Failure(f'no secondary {nameserver} of {name}', 3) + connection.execute('CALL dns_secondary_remove(%s, %s)', (name, nameserver)) + return {'domain': name, 'nameserver': nameserver} + + +def add(cfg, name, record): + """Add the manual record to the domain, or set the TTL of the same one; the record. A name in a domain below it + belongs to that domain.""" + with database() as connection: + row = domain(connection, name) + record = check_record(name, record) + record['ttl'] = record['ttl'] or row['ttl'] or cfg['dns']['ttl'] + zones = [item['name'] for item in connection.execute('SELECT * FROM dns_domains_list()').fetchall()] + owner = closest(zones, record['name']) + if owner != name: + raise Failure(f'{record["name"]} is in the domain {owner}') + records = [item for item in connection.execute('SELECT * FROM dns_records_list()').fetchall() + if item['domain'] == name] + same = [item for item in records if (item['name'], item['type']) == (record['name'], record['type'])] + others = [item for item in records if item['name'] == record['name'] and item['type'] != record['type']] + if len(records) >= MANUAL_MAX and not same: + raise Failure(f'the domain {name} has {MANUAL_MAX} manual records') + if record['type'] == 'CNAME' and (others or [item for item in same if item['content'] != record['content']]): raise Failure(f'{record["name"]} has other records; a CNAME record must be its only record') - if any(row['type'] == 'CNAME' for row in others): + if any(item['type'] == 'CNAME' for item in others): raise Failure(f'{record["name"]} has a CNAME record, which must be its only record') - connection.execute('CALL dns_manual_put(%s, %s, %s, %s)', - (record['name'], record['type'], record['content'], record['ttl'])) + connection.execute('CALL dns_record_put(%s, %s, %s, %s, %s)', + (name, record['name'], record['type'], record['content'], record['ttl'])) return record -def remove(cfg, name, kind, content): - """Remove the manual records of the name and type, only the one of the content when it is given.""" - zone = cfg['dns']['zone'].strip() - full = absolute(zone, name) +def remove(name, record_name, kind, content): + """Remove the manual records of the name and type in the domain, only the one of the content when it is given.""" + full = absolute(name, record_name) with database() as connection: - found = [row for row in connection.execute('SELECT * FROM dns_manual_list()').fetchall() - if (row['name'], row['type']) == (full, kind) and content in (None, row['content'])] + domain(connection, name) + found = [row for row in connection.execute('SELECT * FROM dns_records_list()').fetchall() + if (row['domain'], row['name'], row['type']) == (name, full, kind) + and content in (None, row['content'])] if not found: raise Failure(f'no {kind} record of {full}', 3) - connection.execute('CALL dns_manual_remove(%s, %s, %s)', (full, kind, content)) + connection.execute('CALL dns_record_remove(%s, %s, %s, %s)', (name, full, kind, content)) return {'name': full, 'type': kind, 'removed': len(found)} -def served(zone): - """The records of the zone as the name server answers them, [] when it serves none.""" - with database() as connection: - return connection.execute('SELECT * FROM dns_served(%s)', (zone,)).fetchall() - - -def records(cfg, hosts, manual_records): - """The records of the zone besides its SOA, as (name, type, content, ttl): its NS, the A and AAAA records of - dns.addresses for the apex, the name server and each host name in the zone, and the manual records.""" +def records(cfg, row, zones, secondaries, hosts, manual_records): + """The records of the domain row besides its SOA, as (name, type, content, ttl): the NS of the domain and of the + domains directly below it, the A and AAAA records of dns.addresses for its apex, dns.nameserver when it is inside + and, with site records, each host name whose closest domain it is, the glue of the secondaries inside it, and its + manual records. secondaries maps a domain to its secondaries.""" dns = cfg['dns'] - zone, nameserver, ttl = dns['zone'].strip(), dns['nameserver'].strip(), dns['ttl'] + zone, nameserver, ttl = row['name'], dns['nameserver'].strip(), row['ttl'] or dns['ttl'] addresses = [ipaddress.ip_address(item.strip()) for item in dns['addresses'] if item.strip()] - found = [(zone, 'NS', nameserver, ttl)] + children = [name for name in zones + if name != zone and closest([item for item in zones if item != name], name) == zone] + found = [(name, 'NS', server, ttl) for name in [zone, *children] + for server in [nameserver, *(item['nameserver'] for item in secondaries.get(name, []))]] for name in dict.fromkeys([zone, *([nameserver] if in_zone(zone, nameserver) else []), - *(host for host in hosts if in_zone(zone, host))]): + *(host for host in hosts if row['sites'] and closest(zones, host) == zone)]): found += [(name, 'A' if address.version == 4 else 'AAAA', str(address), ttl) for address in addresses] + for item in (item for name in [zone, *children] for item in secondaries.get(name, [])): + if in_zone(zone, item['nameserver']) and item['nameserver'] != nameserver: + address = ipaddress.ip_address(item['address']) + found.append((item['nameserver'], 'A' if address.version == 4 else 'AAAA', str(address), ttl)) taken = {(name, kind) for name, kind, _, _ in found} - found += [(row['name'], row['type'], row['content'], row['ttl']) for row in manual_records - if (row['name'], row['type']) not in taken] + found += [(item['name'], item['type'], item['content'], item['ttl']) for item in manual_records + if (item['name'], item['type']) not in taken] return list(dict.fromkeys(found)) def apply(cfg, hosts): - """Make the name server serve dns.zone with records(), or no zone while dns.enabled is false.""" + """Make the name server the primary of every domain with records(), and of none while dns.enabled is false.""" with database() as connection: if not cfg['dns']['enabled']: - connection.execute('CALL dns_clear()') + connection.execute('CALL dns_retain(%s::text[])', ([],)) return dns = cfg['dns'] - zone = dns['zone'].strip() - rows = records(cfg, hosts, connection.execute('SELECT * FROM dns_manual_list()').fetchall()) - soa = f'{dns["nameserver"].strip()} hostmaster.{zone} {{serial}} {SOA_TIMES} {dns["ttl"]}' - connection.execute('CALL dns_apply(%s, %s, %s, %s, %s, %s, %s)', - (zone, soa, dns['ttl'], [row[0] for row in rows], [row[1] for row in rows], - [row[2] for row in rows], [row[3] for row in rows])) + domains = connection.execute('SELECT * FROM dns_domains_list()').fetchall() + zones = [row['name'] for row in domains] + secondaries, manual = {}, {} + for item in connection.execute('SELECT * FROM dns_secondaries_list()').fetchall(): + secondaries.setdefault(item['domain'], []).append(item) + for item in connection.execute('SELECT * FROM dns_records_list()').fetchall(): + manual.setdefault(item['domain'], []).append(item) + for row in domains: + zone, ttl = row['name'], row['ttl'] or dns['ttl'] + rows = records(cfg, row, zones, secondaries, hosts, manual.get(zone, [])) + soa = f'{dns["nameserver"].strip()} hostmaster.{zone} {{serial}} {SOA_TIMES} {ttl}' + connection.execute('CALL dns_apply(%s, %s, %s, %s::text[], %s::text[], %s::text[], %s::integer[], ' + '%s::text[])', + (zone, soa, ttl, [item[0] for item in rows], [item[1] for item in rows], + [item[2] for item in rows], [item[3] for item in rows], + [item['address'] for item in secondaries.get(zone, [])])) + connection.execute('CALL dns_retain(%s::text[])', (zones,)) def config_text(addresses): - """The settings of PowerDNS: the gpgsql backend on the database of the name server, and the addresses it answers - on.""" + """The settings of PowerDNS: the gpgsql backend on the database of the name server, primary operation, and the + addresses it answers on.""" password = DATABASE_SECRET.read_text(encoding='utf-8').strip() return '\n'.join([ '# Written by vpn-router-setup from /etc/vpn-router/vpn-router.json; edits are overwritten.', @@ -262,6 +394,7 @@ def config_text(addresses): f'gpgsql-dbname={DATABASE}', f'gpgsql-user={DATABASE}', f'gpgsql-password={password}', + 'primary=yes', f'local-address={", ".join(addresses)}', 'local-port=53', '', diff --git a/debian-package/src/usr/lib/vpn-router/web b/debian-package/src/usr/lib/vpn-router/web index 152428f..8e96898 100644 --- a/debian-package/src/usr/lib/vpn-router/web +++ b/debian-package/src/usr/lib/vpn-router/web @@ -52,8 +52,8 @@ Pages: /sites the web sites Caddy serves: the settings of sites, list, add, change and remove (see sites()), and the files of each: list, download, upload, extract a zip archive, create a folder, rename and remove (see Handler.site_files()); a member of a site's editors group may use the site's files - /dns the name server of dns.zone: its settings, the records it serves, and the manual records: add and - remove (see dns()) + /dns the primary name server of the router's domains: its settings, and the domains: add, change and + remove, each with its secondaries, the records it serves, and its manual records (see dns()) /postgresql the databases of each PostgreSQL cluster on the router with their sizes, as JSON (see postgresql_databases()); /config has the clusters' settings @@ -1264,44 +1264,69 @@ def site_file_action(name, target, body): def dns(method, path, query, body): """Answer a /dns request with vpnrouter_dns: the HTTP status and the JSON document. - GET /dns {"settings", "installed", "running", "records", "manual", "problem"}: the dns section of the - configuration, whether PowerDNS is installed and runs, the records the name server serves, - the manual records, and why the records cannot be read, null when they can + GET /dns {"settings", "installed", "running", "domains", "problem"}: the dns section of the + configuration, whether PowerDNS is installed and runs, the domains, each {"name", "sites", + "ttl", "secondaries", "manual", "records"} with its secondaries {"nameserver", "address"}, + its manual records and the records the name server serves, and why the domains cannot be + read, null when they can PUT /dns change any of the settings of the dns section - POST /dns/records add a manual record: {"name", "type", "content", "ttl"?}; name is relative to dns.zone, @ - for the zone itself, or absolute with a final dot; ttl is dns.ttl without it - DELETE /dns/records remove the manual records of name= and type=, with content= only that one + POST /dns/domains add a domain: {"name", "sites"?, "ttl"?}; sites gives the host names of the enabled sites + in it records; ttl is dns.ttl when null or absent; an existing name is 409 + PUT /dns/domains/ + change "sites" or "ttl" of the domain + DELETE /dns/domains/ + remove the domain with its secondaries and manual records + POST /dns/domains//secondaries + add a secondary name server of the domain, or change its address: {"nameserver", "address"} + DELETE /dns/domains//secondaries/ + remove the secondary name server + POST /dns/domains//records + add a manual record: {"name", "type", "content", "ttl"?}; name is relative to the domain, + @ for the domain itself, or absolute with a final dot; ttl is the domain's without it + DELETE /dns/domains//records + remove the manual records of name= and type=, with content= only that one A change is applied by restarting vpn-router-setup without waiting for it. """ if body == 'invalid' or (body is not None and not isinstance(body, dict)): return 400, {'error': 'the body must be a JSON object'} body = body or {} + parts = path.strip('/').split('/') + route = (method, *parts[:2], *parts[3:4]) try: cfg = vpnrouter.load_config() - if (method, path) == ('GET', '/dns'): - served, manual, problem = [], [], None + if route == ('GET', 'dns'): + domains, problem = [], None try: - manual = vpnrouter_dns.manual() - served = vpnrouter_dns.served(cfg['dns']['zone'].strip()) + domains = vpnrouter_dns.listing() except vpnrouter_dns.DatabaseError as exc: problem = str(exc) return 200, {'settings': cfg['dns'], 'installed': vpnrouter_dns.PDNS_SERVER.exists(), - 'running': unit_state(vpnrouter_dns.UNIT) == 'active', 'records': served, 'manual': manual, + 'running': unit_state(vpnrouter_dns.UNIT) == 'active', 'domains': domains, 'problem': problem} - if (method, path) == ('PUT', '/dns'): + if route == ('PUT', 'dns'): changed = section_change('dns', body) return changed if isinstance(changed, tuple) else (200, {'settings': changed}) - if (method, path) == ('POST', '/dns/records'): - document, code = vpnrouter_dns.add(cfg, body), 201 - elif (method, path) == ('DELETE', '/dns/records'): + if route == ('POST', 'dns', 'domains') and len(parts) == 2: + document, code = vpnrouter_dns.domain_add(body), 201 + elif route == ('PUT', 'dns', 'domains') and len(parts) == 3: + document, code = vpnrouter_dns.domain_change(parts[2], body), 200 + elif route == ('DELETE', 'dns', 'domains') and len(parts) == 3: + document, code = vpnrouter_dns.domain_remove(parts[2]), 200 + elif route == ('POST', 'dns', 'domains', 'secondaries') and len(parts) == 4: + document, code = vpnrouter_dns.secondary_add(parts[2], body), 201 + elif route == ('DELETE', 'dns', 'domains', 'secondaries') and len(parts) == 5: + document, code = vpnrouter_dns.secondary_remove(parts[2], parts[4]), 200 + elif route == ('POST', 'dns', 'domains', 'records') and len(parts) == 4: + document, code = vpnrouter_dns.add(cfg, parts[2], body), 201 + elif route == ('DELETE', 'dns', 'domains', 'records') and len(parts) == 4: content = query.get('content', [None])[0] - document = vpnrouter_dns.remove(cfg, query.get('name', [''])[0], query.get('type', [''])[0], content) + document = vpnrouter_dns.remove(parts[2], query.get('name', [''])[0], query.get('type', [''])[0], content) code = 200 else: return 404, {'error': f'no route {method} {path}'} except vpnrouter_dns.Failure as failure: - return (404 if failure.status == 3 else 400), {'error': str(failure)} + return {3: 404, 4: 409}.get(failure.status, 400), {'error': str(failure)} except vpnrouter_dns.DatabaseError as exc: return 500, {'error': str(exc)} except SystemExit as exc: diff --git a/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example b/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example index 833b771..2342e20 100644 --- a/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example +++ b/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example @@ -89,7 +89,6 @@ }, "dns": { "enabled": false, - "zone": "", "nameserver": "", "addresses": [], "listen": "external", diff --git a/debian-package/src/usr/share/vpn-router/sql/dns.sql b/debian-package/src/usr/share/vpn-router/sql/dns.sql index 625c53f..10f1fa1 100644 --- a/debian-package/src/usr/share/vpn-router/sql/dns.sql +++ b/debian-package/src/usr/share/vpn-router/sql/dns.sql @@ -1,48 +1,106 @@ --- The zone the router's name server (PowerDNS, gpgsql backend) answers for (vpnrouter_dns), in the vpn-router-dns --- database, whose domains and records tables are PowerDNS's own schema. +-- The domains the router's name server (PowerDNS, gpgsql backend) is the primary of (vpnrouter_dns), in the +-- vpn-router-dns database, whose domains, records and domainmetadata tables are PowerDNS's own schema. -- The whole file runs as one transaction, serialized by an advisory lock, so that two processes can load it at once. SELECT pg_advisory_xact_lock(hashtext('vpn-router dns schema')); --- A record an administrator added to the zone, besides those of the zone itself and of the sites. -CREATE TABLE IF NOT EXISTS dns_manual_records ( +DROP TABLE IF EXISTS dns_manual_records; + +-- A domain the router serves as its primary name server; sites says whether the host names of the enabled sites in +-- it get records, ttl is the TTL of its records, null for dns.ttl. +CREATE TABLE IF NOT EXISTS dns_domains ( + name text PRIMARY KEY, + sites boolean NOT NULL, + ttl integer +); + +-- A secondary name server of a domain: its NS name and the address it transfers the domain from. +CREATE TABLE IF NOT EXISTS dns_secondaries ( + domain text NOT NULL REFERENCES dns_domains (name) ON DELETE CASCADE, + nameserver text NOT NULL, + address inet NOT NULL, + PRIMARY KEY (domain, nameserver) +); + +-- A record an administrator added to a domain, besides those of the domain itself and of the sites. +CREATE TABLE IF NOT EXISTS dns_records ( + domain text NOT NULL REFERENCES dns_domains (name) ON DELETE CASCADE, name text NOT NULL, type text NOT NULL, content text NOT NULL, ttl integer NOT NULL, - PRIMARY KEY (name, type, content) + PRIMARY KEY (domain, name, type, content) ); --- The digest of the records last written to the zone and the serial of its SOA. +-- The digest of the records last written to a zone and the serial of its SOA. CREATE TABLE IF NOT EXISTS dns_state ( zone text PRIMARY KEY, digest text NOT NULL, serial bigint NOT NULL ); -CREATE OR REPLACE FUNCTION dns_manual_list() -RETURNS TABLE (name text, type text, content text, ttl integer) +CREATE OR REPLACE FUNCTION dns_domains_list() +RETURNS TABLE (name text, sites boolean, ttl integer) LANGUAGE sql STABLE AS $$ - SELECT name, type, content, ttl FROM dns_manual_records ORDER BY name, type, content; + SELECT name, sites, ttl FROM dns_domains ORDER BY name; $$; --- Add the record, or set its TTL. -CREATE OR REPLACE PROCEDURE dns_manual_put(p_name text, p_type text, p_content text, p_ttl integer) +-- Add the domain, or set its sites and TTL. +CREATE OR REPLACE PROCEDURE dns_domain_put(p_name text, p_sites boolean, p_ttl integer) LANGUAGE sql AS $$ - INSERT INTO dns_manual_records VALUES (p_name, p_type, p_content, p_ttl) - ON CONFLICT (name, type, content) DO UPDATE SET ttl = p_ttl; + INSERT INTO dns_domains VALUES (p_name, p_sites, p_ttl) + ON CONFLICT (name) DO UPDATE SET sites = p_sites, ttl = p_ttl; $$; --- Remove the records of the name and type, all of them without content. -CREATE OR REPLACE PROCEDURE dns_manual_remove(p_name text, p_type text, p_content text) +-- Remove the domain with its secondaries and records. +CREATE OR REPLACE PROCEDURE dns_domain_remove(p_name text) LANGUAGE sql AS $$ - DELETE FROM dns_manual_records WHERE name = p_name AND type = p_type AND (p_content IS NULL OR content = p_content); + DELETE FROM dns_domains WHERE name = p_name; $$; --- Make the zone the only one served and give it the records, its SOA first, whose content has {serial} for the serial. --- The serial grows, to at least the current Unix time, only when the records differ from those last written. +CREATE OR REPLACE FUNCTION dns_secondaries_list() +RETURNS TABLE (domain text, nameserver text, address text) +LANGUAGE sql STABLE AS $$ + SELECT domain, nameserver, host(address) FROM dns_secondaries ORDER BY domain, nameserver; +$$; + +-- Add the secondary of the domain, or set its address. +CREATE OR REPLACE PROCEDURE dns_secondary_put(p_domain text, p_nameserver text, p_address inet) +LANGUAGE sql AS $$ + INSERT INTO dns_secondaries VALUES (p_domain, p_nameserver, p_address) + ON CONFLICT (domain, nameserver) DO UPDATE SET address = p_address; +$$; + +CREATE OR REPLACE PROCEDURE dns_secondary_remove(p_domain text, p_nameserver text) +LANGUAGE sql AS $$ + DELETE FROM dns_secondaries WHERE domain = p_domain AND nameserver = p_nameserver; +$$; + +CREATE OR REPLACE FUNCTION dns_records_list() +RETURNS TABLE (domain text, name text, type text, content text, ttl integer) +LANGUAGE sql STABLE AS $$ + SELECT domain, name, type, content, ttl FROM dns_records ORDER BY domain, name, type, content; +$$; + +-- Add the record to the domain, or set its TTL. +CREATE OR REPLACE PROCEDURE dns_record_put(p_domain text, p_name text, p_type text, p_content text, p_ttl integer) +LANGUAGE sql AS $$ + INSERT INTO dns_records VALUES (p_domain, p_name, p_type, p_content, p_ttl) + ON CONFLICT (domain, name, type, content) DO UPDATE SET ttl = p_ttl; +$$; + +-- Remove the records of the name and type in the domain, all of them without content. +CREATE OR REPLACE PROCEDURE dns_record_remove(p_domain text, p_name text, p_type text, p_content text) +LANGUAGE sql AS $$ + DELETE FROM dns_records + WHERE domain = p_domain AND name = p_name AND type = p_type AND (p_content IS NULL OR content = p_content); +$$; + +-- Serve the zone as its primary and give it the records, its SOA first, whose content has {serial} for the serial, +-- and the addresses of its secondaries, which are notified of its changes and may transfer it. The serial grows, to +-- at least the current Unix time, only when the records differ from those last written. CREATE OR REPLACE PROCEDURE dns_apply(p_zone text, p_soa text, p_ttl integer, p_names text[], p_types text[], - p_contents text[], p_ttls integer[]) + p_contents text[], p_ttls integer[], p_secondaries text[]) LANGUAGE plpgsql AS $$ DECLARE v_domain integer; @@ -50,12 +108,17 @@ DECLARE v_digest text := md5(concat_ws('|', p_soa, p_ttl, array_to_string(p_names, ','), array_to_string(p_types, ','), array_to_string(p_contents, ','), array_to_string(p_ttls, ','))); BEGIN - DELETE FROM domains WHERE name <> p_zone; - DELETE FROM dns_state WHERE zone <> p_zone; SELECT id INTO v_domain FROM domains WHERE name = p_zone; IF v_domain IS NULL THEN - INSERT INTO domains (name, type) VALUES (p_zone, 'NATIVE') RETURNING id INTO v_domain; - ELSIF EXISTS (SELECT 1 FROM dns_state WHERE zone = p_zone AND digest = v_digest) THEN + INSERT INTO domains (name, type) VALUES (p_zone, 'MASTER') RETURNING id INTO v_domain; + ELSE + UPDATE domains SET type = 'MASTER' WHERE id = v_domain AND type <> 'MASTER'; + END IF; + DELETE FROM domainmetadata WHERE domain_id = v_domain AND kind IN ('ALSO-NOTIFY', 'ALLOW-AXFR-FROM'); + INSERT INTO domainmetadata (domain_id, kind, content) + SELECT v_domain, kind, address FROM unnest(p_secondaries) AS address, + unnest(ARRAY['ALSO-NOTIFY', 'ALLOW-AXFR-FROM']) AS kind; + IF EXISTS (SELECT 1 FROM dns_state WHERE zone = p_zone AND digest = v_digest) THEN RETURN; END IF; SELECT greatest(coalesce(max(serial) + 1, 1), extract(epoch FROM now())::bigint) INTO v_serial @@ -71,11 +134,11 @@ BEGIN END; $$; --- Serve no zone. -CREATE OR REPLACE PROCEDURE dns_clear() +-- Serve only the zones of the list, none for an empty one. +CREATE OR REPLACE PROCEDURE dns_retain(p_zones text[]) LANGUAGE sql AS $$ - DELETE FROM domains; - DELETE FROM dns_state; + DELETE FROM domains WHERE name <> ALL (p_zones); + DELETE FROM dns_state WHERE zone <> ALL (p_zones); $$; -- The records of the zone as they are served.