Compare commits

...
3 Commits
11 changed files with 492 additions and 157 deletions

No files matched your search

+39 -10
View File
@@ -1141,8 +1141,9 @@ later. S3 and Azure Blob clients are not served.
Status: Accepted Status: Accepted
Context: Sites get subdomains of a selected domain, delegated to the router, without a registrar Context: The router hosts authoritative domains as their primary, with optional secondary name
change per site. Ubuntu has BIND, Knot and PowerDNS; only PowerDNS's gpgsql backend reads records 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 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 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 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 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 `/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 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 own schema, and `primary=yes`. The domains, their secondaries and manual records are rows of the
`/usr/share/vpn-router/sql/dns.sql`: SOA, NS, A and AAAA records of `dns.addresses` for the apex, router's tables in that database, like the sites in `vpn-router-sites`, not settings of
the name server and every site host name in the zone, and manual records administrators add; the `vpn-router.json`: they are many and change through the web service. `dns.nameserver` and
serial grows only when the records change. The remote access clients' resolver answers on the `dns.addresses` stay settings, the router's one identity as the primary NS of every domain. Setup
addresses of the new setting `p2s.dns_listen`, by default the internal one; a `dns.listen` that writes each domain as a MASTER zone through the procedure `dns_apply` of
shares an interface with it is refused while P2S or OpenVPN is enabled. `/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 Consequences: The domains follow the sites at each setup run. Transfers are authorized by address,
transfers; the delegation is made at the parent zone by hand. 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 ## ADR-43: The remote access clients' resolver is dnsmasq, not systemd-resolved
@@ -1201,3 +1212,21 @@ users their own through `/account` with their current password and code.
Consequences: The password is never stored; the device asks for it when the profile is installed. Consequences: The password is never stored; the device asks for it when the profile is installed.
A profile shows as unverified. A changed proposal of `road-warrior.conf` must be changed in the A profile shows as unverified. A changed proposal of `road-warrior.conf` must be changed in the
profile too. WireGuard is not part of it. profile too. WireGuard is not part of it.
## ADR-45: Road-warrior clients get split DNS domains, p2s.dns_domains
Status: Accepted
Context: The road-warrior access is a split tunnel. macOS and iOS use the DNS server an IKEv2 VPN
pushes only for queries scoped to the tunnel, so names only the router resolves, such as Azure's
internal names, fail. RFC 8598 defines INTERNAL_DNS_DOMAIN (attribute 25): the client resolves the
listed domains and their subdomains with the VPN's servers. strongSwan's pools take numeric
attributes with string values; Apple profiles name such domains in `SupplementalMatchDomains`.
Decision: The setting `p2s.dns_domains` lists the domains; setup pushes each as attribute 25 of the
road-warrior pool, and the Apple VPN profile gives them as match domains of the router's resolver,
the addresses setup records for the web service.
Consequences: Clients resolve the listed domains through the tunnel and everything else with their
own resolvers. Clients that ignore attribute 25 keep the scoped behaviour; the Apple profile
covers Apple devices. A changed list reaches an Apple device with a newly installed profile.
+42 -23
View File
@@ -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 - metrics of the router kept in PostgreSQL, served for an OpenTelemetry Collector and pushed
over OTLP over OTLP
- static web sites served by Caddy, uploaded through the web service or WebDAV, and an optional - 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 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 internal NIC names, and a `mode` setting says how much of the rest you are supplying and
@@ -115,6 +115,7 @@ must have the type shown below; an unknown section or setting is an error.
| `site_to_site` | | object | | The site-to-site connections, each a name and an object of the settings below | | `site_to_site` | | object | | The site-to-site connections, each a name and an object of the settings below |
| `p2s` | `enabled` | boolean | `false` | Road-warrior access | | `p2s` | `enabled` | boolean | `false` | Road-warrior access |
| `p2s` | `address_pool` | string | | CIDR assigned to road-warrior clients | | `p2s` | `address_pool` | string | | CIDR assigned to road-warrior clients |
| `p2s` | `dns_domains` | list | | The domains road-warrior clients resolve with the router's resolver (INTERNAL_DNS_DOMAIN, RFC 8598, and the match domains of the Apple VPN profile), at most 16 |
| `p2s` | `dns_listen` | string | `internal` | The addresses of the resolver the road-warrior, OpenVPN and WireGuard clients are given: `internal`, `external` or `both` | | `p2s` | `dns_listen` | string | `internal` | The addresses of the resolver the road-warrior, OpenVPN and WireGuard clients are given: `internal`, `external` or `both` |
| `wireguard` | `enabled` | boolean | `false` | WireGuard endpoint | | `wireguard` | `enabled` | boolean | `false` | WireGuard endpoint |
| `wireguard` | `address` | string | | The router's own address on `wg0` with the prefix of the peers' network, for example `192.168.200.1/24`; the network and the broadcast address of a network larger than /31 are refused | | `wireguard` | `address` | string | | The router's own address on `wg0` with the prefix of the peers' network, for example `192.168.200.1/24`; the network and the broadcast address of a network larger than /31 are refused |
@@ -146,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` | `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` | 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 | | `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` | `enabled` | boolean | `false` | The primary name server of the router's domains, which needs `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, the primary NS of every domain |
| `dns` | `nameserver` | string | | The name server's host name | | `dns` | `addresses` | list | | The public addresses of the name server, the domains' apexes and the sites' host names |
| `dns` | `addresses` | list | | The public addresses of the zone's apex, the name server and the sites' host names |
| `dns` | `listen` | string | `external` | The interfaces the name server answers on: `internal`, `external` or `both` | | `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 | | `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 | | `pki` | `root_name` | string | `VPN Router Root CA` | Common name of the root CA, when the router creates it |
@@ -788,7 +788,10 @@ that sets up road-warrior access in the built-in VPN client of macOS, iOS and iP
the profile is installed, and the router does not keep it; the profile is installed, and the router does not keep it;
- an IKEv2 VPN to `wan.local_fqdn`, with the user name as the local identity, the certificate over - an IKEv2 VPN to `wan.local_fqdn`, with the user name as the local identity, the certificate over
EAP-TLS, and AES-256, SHA2-256 and group 14 for IKE and ESP, as the router's road-warrior EAP-TLS, and AES-256, SHA2-256 and group 14 for IKE and ESP, as the router's road-warrior
connection takes them. connection takes them;
- with `p2s.dns_domains`, the router's resolver for those domains and their subdomains
(`SupplementalMatchDomains`). macOS and iOS use the DNS server of a VPN that does not carry all
traffic only for its match domains.
The password is 8 to 128 characters. A user asking for the own profile also gives the current The password is 8 to 128 characters. A user asking for the own profile also gives the current
password and, when enrolled, a code. password and, when enrolled, a code.
@@ -1173,10 +1176,15 @@ curl -X PUT -H "Authorization: Bearer $TOKEN" --data-binary @site.zip \
| Request | Answer | | 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 | | `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 | | `POST /dns/domains` | Adds a domain: `{"name", "sites"?, "ttl"?}`; an existing name is 409 |
| `DELETE /dns/records?name=&type=&content=` | Removes the manual records of the name and type, only the one of `content` when it is given | | `PUT /dns/domains/<domain>` | Changes `sites` or `ttl` of the domain |
| `DELETE /dns/domains/<domain>` | Removes the domain with its secondaries and manual records |
| `POST /dns/domains/<domain>/secondaries` | Adds a secondary name server, or changes its address: `{"nameserver", "address"}` |
| `DELETE /dns/domains/<domain>/secondaries/<nameserver>` | Removes the secondary name server |
| `POST /dns/domains/<domain>/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/<domain>/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 `<preference> <host>`, SRV `<priority> <weight> A TXT content without quotes is quoted; MX is `<preference> <host>`, SRV `<priority> <weight>
<port> <host>` and CAA `<flags> <tag> "<value>"`. A CNAME must be its name's only record. <port> <host>` and CAA `<flags> <tag> "<value>"`. A CNAME must be its name's only record.
@@ -1255,6 +1263,7 @@ router manages them, their display name and description included. A failure is o
| `POST /account/passkeys` | `{"current_password", "current_code"}` | `{"id", "options"}`: a pending registration, valid 2 minutes, and the options of `navigator.credentials.create` in their JSON form | | `POST /account/passkeys` | `{"current_password", "current_code"}` | `{"id", "options"}`: a pending registration, valid 2 minutes, and the options of `navigator.credentials.create` in their JSON form |
| `PUT /account/passkeys` | `{"id", "credential", "label"?}`, `credential` the browser's answer as its `toJSON()` gives it; `label` at most 64 characters, `Passkey` without one | the user | | `PUT /account/passkeys` | `{"id", "credential", "label"?}`, `credential` the browser's answer as its `toJSON()` gives it; `label` at most 64 characters, `Passkey` without one | the user |
| `DELETE /account/passkeys/<id>` | | the user | | `DELETE /account/passkeys/<id>` | | the user |
| `POST /account/profile` | `{"current_password", "current_code"}` | `{"name", "profile"}`, the user's OpenVPN client profile |
| `POST /account/apple-profile` | `{"current_password", "current_code", "password"}` | `{"name", "profile"}`, the user's Apple configuration profile of IKEv2 road-warrior access (see Apple VPN profile) | | `POST /account/apple-profile` | `{"current_password", "current_code", "password"}` | `{"name", "profile"}`, the user's Apple configuration profile of IKEv2 road-warrior access (see Apple VPN profile) |
`current_code` is needed only for a user enrolled for TOTP; removing a passkey needs neither. A wrong current password or code gets `current_code` is needed only for a user enrolled for TOTP; removing a passkey needs neither. A wrong current password or code gets
@@ -1578,9 +1587,9 @@ vpn-config-edit set --restart sites.webdav true
### Name server ### Name server
While `dns.enabled` is `true`, `vpn-router-setup` runs PowerDNS (`pdns.service`) as the 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 (`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 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 `/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 `pdns-backend-pgsql` from the Ubuntu archive with `vpn-router-install@dns.service` when they are
@@ -1588,21 +1597,31 @@ missing, without the recommended bind backend.
| Setting | Meaning | | Setting | Meaning |
| --- | --- | | --- | --- |
| `dns.zone` | The zone, such as `sites.example.com` | | `dns.nameserver` | The name server's host name, the primary NS and the SOA's primary of every domain |
| `dns.nameserver` | The name server's host name, the zone's NS and the SOA's primary | | `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.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 of a domain without its own, 60 to 86400 seconds, 300 by default |
| `dns.ttl` | The TTL of the records and the SOA's negative TTL, 60 to 86400 seconds, 300 by default |
Records added with `/dns` or the Router Manager (A, AAAA, CNAME, TXT, MX, SRV, CAA) are served The domains are added with `/dns` or the Router Manager, each with:
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, - **Site records**: every host name of an enabled site gets A and AAAA records of `dns.addresses`
dnsmasq answers their clients on port 53 of the addresses of `p2s.dns_listen`, by default the in the closest domain it is in, when that domain has site records;
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 - **TTL**: the TTL of its records, `dns.ttl` when not set;
a name server inside the zone. - **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 ```sh
vpn-config-edit set dns.zone sites.example.com vpn-config-edit set dns.nameserver ns1.example.com
vpn-config-edit set dns.nameserver ns1.sites.example.com
vpn-config-edit set dns.addresses 203.0.113.10 vpn-config-edit set dns.addresses 203.0.113.10
vpn-config-edit set --restart dns.enabled true vpn-config-edit set --restart dns.enabled true
``` ```
+39
View File
@@ -1,3 +1,42 @@
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 <slawek@koszewscy.waw.pl> 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.
* p2s.dns_domains lists up to 16 domains; setup pushes them to IKEv2
clients as INTERNAL_DNS_DOMAIN (RFC 8598, attribute 25) of the
road-warrior pool.
* The Apple VPN profile names them as match domains of the router's
resolver, which macOS and iOS use for those domains.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 18:00:00 +0100
vpn-router (0.32.1-1) unstable; urgency=medium
fix: a VPN user downloads the own OpenVPN client profile through /account, with the current password and code.
* POST /account/profile answers the user's OpenVPN client profile; it
needs the current password and, when enrolled, a code.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 16:00:00 +0100
vpn-router (0.32.0-1) unstable; urgency=medium vpn-router (0.32.0-1) unstable; urgency=medium
feature: the web service gives Apple VPN profiles that set up IKEv2 road-warrior access on macOS, iOS and iPadOS. feature: the web service gives Apple VPN profiles that set up IKEv2 road-warrior access on macOS, iOS and iPadOS.
+5 -5
View File
@@ -44,7 +44,7 @@ from vpnrouter import fail, warn
BEFORE_RULES = pathlib.Path('/etc/ufw/before.rules') BEFORE_RULES = pathlib.Path('/etc/ufw/before.rules')
UFW_DEFAULTS = pathlib.Path('/etc/default/ufw') UFW_DEFAULTS = pathlib.Path('/etc/default/ufw')
WG_DIR = pathlib.Path('/etc/wireguard') WG_DIR = pathlib.Path('/etc/wireguard')
WEB_STATE = pathlib.Path('/run/vpn-router/web.json') WEB_STATE = vpnrouter.WEB_STATE
#: The state, the current step and the failure of the last run, which the bootstrap services show. #: The state, the current step and the failure of the last run, which the bootstrap services show.
SETUP_STATE = pathlib.Path('/run/vpn-router/setup.json') SETUP_STATE = pathlib.Path('/run/vpn-router/setup.json')
@@ -821,12 +821,12 @@ def manage_webdav(ctx):
def manage_dns(cfg, 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 """Run PowerDNS, the primary name server of the domains of vpnrouter_dns, on the addresses of dns.listen while DNS
not; its packages are installed when they are missing. 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 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 installation starts answers on those addresses only, beside systemd-resolved's stub on 127.0.0.53. The domains
the host names of the enabled sites, and keeps those it has while the sites cannot be read. 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 not vpnrouter.dns_ready(ctx):
if unit_is_active(vpnrouter_dns.UNIT): if unit_is_active(vpnrouter_dns.UNIT):
@@ -31,6 +31,8 @@ from vpnrouter_net import DEFAULT_LISTEN, LISTEN_CHOICES, LOOPBACK, ConfigError,
CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.json') CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.json')
CONFIG_LOCK = pathlib.Path('/run/vpn-router/config.lock') CONFIG_LOCK = pathlib.Path('/run/vpn-router/config.lock')
#: What setup resolved and runs, for the web service (setup's refresh_web).
WEB_STATE = pathlib.Path('/run/vpn-router/web.json')
TEMPLATE_DIR = pathlib.Path('/usr/share/vpn-router/templates') TEMPLATE_DIR = pathlib.Path('/usr/share/vpn-router/templates')
SWANCTL_CONF_D = pathlib.Path('/etc/swanctl/conf.d') SWANCTL_CONF_D = pathlib.Path('/etc/swanctl/conf.d')
@@ -56,7 +58,7 @@ SCHEMA = {
'wan': {'local_fqdn': ''}, 'wan': {'local_fqdn': ''},
'local': {'cidrs': [], 'int_addr': '', 'int_gateway_ip': ''}, 'local': {'cidrs': [], 'int_addr': '', 'int_gateway_ip': ''},
'site_to_site': {}, 'site_to_site': {},
'p2s': {'enabled': False, 'address_pool': '', 'dns_listen': 'internal'}, 'p2s': {'enabled': False, 'address_pool': '', 'dns_listen': 'internal', 'dns_domains': []},
'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820}, 'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820},
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'}, 'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
'docker': {'mode': 'auto', 'exposed_ports': []}, 'docker': {'mode': 'auto', 'exposed_ports': []},
@@ -67,7 +69,7 @@ SCHEMA = {
'https': {'enabled': True, 'email': '', 'web_port': 7444, 'manager_port': 7443, 'caddy_source': 'ubuntu'}, 'https': {'enabled': True, 'email': '', 'web_port': 7444, 'manager_port': 7443, 'caddy_source': 'ubuntu'},
'oidc': {'enabled': False}, 'oidc': {'enabled': False},
'sites': {'enabled': False, 'upload_max_mb': 512, 'webdav': False, 'webdav_port': 7445}, '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', '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', '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': {}, 's2s_name': 'VPN Router Site-to-Site CA', 'issuing_cas': {}, 'templates': {},
@@ -144,9 +146,13 @@ UPLOAD_MAX_MB = 10240
#: The shortest and the longest dns.ttl, in seconds. #: The shortest and the longest dns.ttl, in seconds.
DNS_TTL_RANGE = (60, 86400) 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])?)+$') 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.
DNS_DOMAIN = 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])?)*$')
DNS_DOMAINS_MAX = 16
#: The ports of the bootstrap services: the Router Manager's and the router's. #: The ports of the bootstrap services: the Router Manager's and the router's.
BOOTSTRAP_PORTS = (17080, 17081) BOOTSTRAP_PORTS = (17080, 17081)
@@ -573,17 +579,22 @@ def validate(cfg):
dns = cfg['dns'] dns = cfg['dns']
if dns['enabled']: if dns['enabled']:
for option in ('zone', 'nameserver'): if not ZONE_NAME.match(dns['nameserver'].strip()):
if not ZONE_NAME.match(dns[option].strip()): raise ConfigError(f'dns.nameserver: {dns["nameserver"]!r} is not a DNS name in lowercase')
raise ConfigError(f'dns.{option}: {dns[option]!r} is not a DNS name in lowercase')
if not _get_list(cfg, 'dns', 'addresses'): 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'): for item in _get_list(cfg, 'dns', 'addresses'):
_check_address(item, 'dns.addresses') _check_address(item, 'dns.addresses')
if dns['listen'] not in LISTEN_CHOICES: if dns['listen'] not in LISTEN_CHOICES:
raise ConfigError(f'dns.listen: {dns["listen"]!r} is not one of {", ".join(LISTEN_CHOICES)}') raise ConfigError(f'dns.listen: {dns["listen"]!r} is not one of {", ".join(LISTEN_CHOICES)}')
if not DNS_TTL_RANGE[0] <= dns['ttl'] <= DNS_TTL_RANGE[1]: if not DNS_TTL_RANGE[0] <= dns['ttl'] <= DNS_TTL_RANGE[1]:
raise ConfigError(f'dns.ttl: {dns["ttl"]} is not between {DNS_TTL_RANGE[0]} and {DNS_TTL_RANGE[1]} seconds') raise ConfigError(f'dns.ttl: {dns["ttl"]} is not between {DNS_TTL_RANGE[0]} and {DNS_TTL_RANGE[1]} seconds')
dns_domains = _get_list(cfg, 'p2s', 'dns_domains')
if len(dns_domains) > DNS_DOMAINS_MAX or len(set(dns_domains)) != len(dns_domains):
raise ConfigError(f'p2s.dns_domains: at most {DNS_DOMAINS_MAX} different domains')
for item in dns_domains:
if not DNS_DOMAIN.match(item):
raise ConfigError(f'p2s.dns_domains: {item!r} is not a DNS name in lowercase')
resolver_listen = _get(cfg, 'p2s', 'dns_listen') resolver_listen = _get(cfg, 'p2s', 'dns_listen')
if resolver_listen not in LISTEN_CHOICES: if resolver_listen not in LISTEN_CHOICES:
raise ConfigError(f'p2s.dns_listen: {resolver_listen!r} is not one of {", ".join(LISTEN_CHOICES)}') raise ConfigError(f'p2s.dns_listen: {resolver_listen!r} is not one of {", ".join(LISTEN_CHOICES)}')
@@ -990,6 +1001,7 @@ def build_context(cfg, public_ip=None):
'p2s_address_pool': _get(cfg, 'p2s', 'address_pool'), 'p2s_address_pool': _get(cfg, 'p2s', 'address_pool'),
'public_ip': public_ip or '', 'public_ip': public_ip or '',
'p2s_resolver_addrs': resolver_addrs, 'p2s_resolver_addrs': resolver_addrs,
'p2s_dns_domains': _get_list(cfg, 'p2s', 'dns_domains'),
'p2s_dns': _join(resolver_addrs, _split_list(extras['EXTRA_P2S_DNS'])), 'p2s_dns': _join(resolver_addrs, _split_list(extras['EXTRA_P2S_DNS'])),
'wg_enabled': _get(cfg, 'wireguard', 'enabled'), 'wg_enabled': _get(cfg, 'wireguard', 'enabled'),
'wg_address': _get(cfg, 'wireguard', 'address'), 'wg_address': _get(cfg, 'wireguard', 'address'),
@@ -1080,7 +1092,7 @@ def webdav_ready(ctx):
def dns_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']) return bool(ctx['dns_enabled'])
@@ -1,14 +1,19 @@
"""The router's authoritative name server, PowerDNS with its gpgsql backend, for vpn-router-setup and the web service. """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 name server is the primary of its domains and answers on the addresses of the interfaces of dns.listen. The
the vpn-router-dns database, which provision() creates with its role and the password in DATABASE_SECRET, and fills with 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 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 connection of a process loads it.
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.
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 import contextlib
@@ -19,6 +24,7 @@ import re
import psycopg import psycopg
from psycopg.rows import dict_row from psycopg.rows import dict_row
import vpnrouter
import vpnrouter_auth import vpnrouter_auth
DATABASE = 'vpn-router-dns' DATABASE = 'vpn-router-dns'
@@ -38,7 +44,9 @@ GROUP = 'pdns'
#: The types of the manual records. #: The types of the manual records.
TYPES = ('A', 'AAAA', 'CNAME', 'TXT', 'MX', 'SRV', 'CAA') 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 MANUAL_MAX = 1000
#: The refresh, retry and expire times of the SOA, in seconds. #: The refresh, retry and expire times of the SOA, in seconds.
@@ -65,7 +73,8 @@ class DatabaseError(Exception):
class Failure(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): def __init__(self, message, status=2):
super().__init__(message) super().__init__(message)
@@ -173,87 +182,210 @@ def check_record(zone, record):
return {'name': name, 'type': kind, 'content': content, 'ttl': ttl} return {'name': name, 'type': kind, 'content': content, 'ttl': ttl}
def manual(): def closest(zones, name):
"""The manual records.""" """The longest of the zones the name is in, None when it is in none."""
with database() as connection: return max((zone for zone in zones if in_zone(zone, name)), key=len, default=None)
return connection.execute('SELECT * FROM dns_manual_list()').fetchall()
def add(cfg, record): def check_domain(name):
"""Add the manual record, or set the TTL of the same one; the record.""" """The domain name in lowercase without a final dot; Failure when it is not a DNS name."""
zone = cfg['dns']['zone'].strip() if not isinstance(name, str) or not vpnrouter.ZONE_NAME.match(name.strip().lower().rstrip('.')):
if not zone: raise Failure(f'{name!r} is not a domain name')
raise Failure('dns.zone is not set') return name.strip().lower().rstrip('.')
record = check_record(zone, record)
record['ttl'] = record['ttl'] or cfg['dns']['ttl']
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: with database() as connection:
records = connection.execute('SELECT * FROM dns_manual_list()').fetchall() secondaries = connection.execute('SELECT * FROM dns_secondaries_list()').fetchall()
same = [row for row in records if (row['name'], row['type']) == (record['name'], record['type'])] manual = connection.execute('SELECT * FROM dns_records_list()').fetchall()
others = [row for row in records if row['name'] == record['name'] and row['type'] != record['type']] return [{**row,
if len(records) >= MANUAL_MAX and record not in records: 'secondaries': [{key: item[key] for key in ('nameserver', 'address')}
raise Failure(f'the zone has {MANUAL_MAX} manual records') for item in secondaries if item['domain'] == row['name']],
if record['type'] == 'CNAME' and (others or [row for row in same if row['content'] != record['content']]): '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') 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') 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)', connection.execute('CALL dns_record_put(%s, %s, %s, %s, %s)',
(record['name'], record['type'], record['content'], record['ttl'])) (name, record['name'], record['type'], record['content'], record['ttl']))
return record return record
def remove(cfg, name, kind, content): def remove(name, record_name, kind, content):
"""Remove the manual records of the name and type, only the one of the content when it is given.""" """Remove the manual records of the name and type in the domain, only the one of the content when it is given."""
zone = cfg['dns']['zone'].strip() full = absolute(name, record_name)
full = absolute(zone, name)
with database() as connection: with database() as connection:
found = [row for row in connection.execute('SELECT * FROM dns_manual_list()').fetchall() domain(connection, name)
if (row['name'], row['type']) == (full, kind) and content in (None, row['content'])] 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: if not found:
raise Failure(f'no {kind} record of {full}', 3) 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)} return {'name': full, 'type': kind, 'removed': len(found)}
def served(zone): def records(cfg, row, zones, secondaries, hosts, manual_records):
"""The records of the zone as the name server answers them, [] when it serves none.""" """The records of the domain row besides its SOA, as (name, type, content, ttl): the NS of the domain and of the
with database() as connection: domains directly below it, the A and AAAA records of dns.addresses for its apex, dns.nameserver when it is inside
return connection.execute('SELECT * FROM dns_served(%s)', (zone,)).fetchall() 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."""
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."""
dns = cfg['dns'] 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()] 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 []), 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] 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} taken = {(name, kind) for name, kind, _, _ in found}
found += [(row['name'], row['type'], row['content'], row['ttl']) for row in manual_records found += [(item['name'], item['type'], item['content'], item['ttl']) for item in manual_records
if (row['name'], row['type']) not in taken] if (item['name'], item['type']) not in taken]
return list(dict.fromkeys(found)) return list(dict.fromkeys(found))
def apply(cfg, hosts): 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: with database() as connection:
if not cfg['dns']['enabled']: if not cfg['dns']['enabled']:
connection.execute('CALL dns_clear()') connection.execute('CALL dns_retain(%s::text[])', ([],))
return return
dns = cfg['dns'] dns = cfg['dns']
zone = dns['zone'].strip() domains = connection.execute('SELECT * FROM dns_domains_list()').fetchall()
rows = records(cfg, hosts, connection.execute('SELECT * FROM dns_manual_list()').fetchall()) zones = [row['name'] for row in domains]
soa = f'{dns["nameserver"].strip()} hostmaster.{zone} {{serial}} {SOA_TIMES} {dns["ttl"]}' secondaries, manual = {}, {}
connection.execute('CALL dns_apply(%s, %s, %s, %s, %s, %s, %s)', for item in connection.execute('SELECT * FROM dns_secondaries_list()').fetchall():
(zone, soa, dns['ttl'], [row[0] for row in rows], [row[1] for row in rows], secondaries.setdefault(item['domain'], []).append(item)
[row[2] for row in rows], [row[3] for row in rows])) 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): def config_text(addresses):
"""The settings of PowerDNS: the gpgsql backend on the database of the name server, and the addresses it answers """The settings of PowerDNS: the gpgsql backend on the database of the name server, primary operation, and the
on.""" addresses it answers on."""
password = DATABASE_SECRET.read_text(encoding='utf-8').strip() password = DATABASE_SECRET.read_text(encoding='utf-8').strip()
return '\n'.join([ return '\n'.join([
'# Written by vpn-router-setup from /etc/vpn-router/vpn-router.json; edits are overwritten.', '# 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-dbname={DATABASE}',
f'gpgsql-user={DATABASE}', f'gpgsql-user={DATABASE}',
f'gpgsql-password={password}', f'gpgsql-password={password}',
'primary=yes',
f'local-address={", ".join(addresses)}', f'local-address={", ".join(addresses)}',
'local-port=53', 'local-port=53',
'', '',
@@ -819,7 +819,7 @@ def apple_profile(connection, name, password):
The connection matches road-warrior.conf: the router's wan.local_fqdn as address and identity, the user name, a The connection matches road-warrior.conf: the router's wan.local_fqdn as address and identity, the user name, a
DNS name of the certificate, as the local identity, the certificate over EAP-TLS, and AES-256, SHA2-256 and group 14 DNS name of the certificate, as the local identity, the certificate over EAP-TLS, and AES-256, SHA2-256 and group 14
for IKE and ESP. The PKCS#12 uses 3DES and SHA-1, the only encryption Apple's clients read; they ask for the for IKE and ESP. With p2s.dns_domains, the device resolves those domains with the router's resolver. The PKCS#12 uses 3DES and SHA-1, the only encryption Apple's clients read; they ask for the
password when the profile is installed. password when the profile is installed.
""" """
entry(connection, name) entry(connection, name)
@@ -858,7 +858,15 @@ def apple_profile(connection, name, password):
payloads.append({'PayloadType': 'com.apple.security.pkcs12', 'PayloadVersion': 1, payloads.append({'PayloadType': 'com.apple.security.pkcs12', 'PayloadVersion': 1,
'PayloadIdentifier': f'{identifier}.pkcs12', 'PayloadUUID': ids['pkcs12'], 'PayloadIdentifier': f'{identifier}.pkcs12', 'PayloadUUID': ids['pkcs12'],
'PayloadDisplayName': f'{name} ({fqdn})', 'PayloadContent': bundle}) 'PayloadDisplayName': f'{name} ({fqdn})', 'PayloadContent': bundle})
# The router's resolver, which setup records, answers the names of p2s.dns_domains; the others stay with the device's.
try:
servers = json.loads(vpnrouter.WEB_STATE.read_text(encoding='utf-8'))['network']['client_dns']
except (OSError, ValueError, KeyError, TypeError):
servers = []
domains = [item.strip() for item in cfg['p2s']['dns_domains'] if item.strip()]
dns = {'DNS': {'ServerAddresses': servers, 'SupplementalMatchDomains': domains}} if servers and domains else {}
payloads.append({ payloads.append({
**dns,
'PayloadType': 'com.apple.vpn.managed', 'PayloadVersion': 1, 'PayloadIdentifier': f'{identifier}.vpn', 'PayloadType': 'com.apple.vpn.managed', 'PayloadVersion': 1, 'PayloadIdentifier': f'{identifier}.vpn',
'PayloadUUID': ids['vpn'], 'PayloadDisplayName': f'VPN Router {fqdn}', 'UserDefinedName': f'VPN Router {fqdn}', 'PayloadUUID': ids['vpn'], 'PayloadDisplayName': f'VPN Router {fqdn}', 'UserDefinedName': f'VPN Router {fqdn}',
'VPNType': 'IKEv2', 'VPNType': 'IKEv2',
+51 -22
View File
@@ -52,8 +52,8 @@ Pages:
/sites the web sites Caddy serves: the settings of sites, list, add, change and remove (see sites()), /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 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 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 /dns the primary name server of the router's domains: its settings, and the domains: add, change and
remove (see dns()) remove, each with its secondaries, the records it serves, and its manual records (see dns())
/postgresql /postgresql
the databases of each PostgreSQL cluster on the router with their sizes, as JSON (see the databases of each PostgreSQL cluster on the router with their sizes, as JSON (see
postgresql_databases()); /config has the clusters' settings postgresql_databases()); /config has the clusters' settings
@@ -1048,12 +1048,14 @@ def account(name, method, path, body):
PUT /account/passkeys store the passkey: {"id", "credential", "label"?}, credential the browser's answer PUT /account/passkeys store the passkey: {"id", "credential", "label"?}, credential the browser's answer
DELETE /account/passkeys/<id> DELETE /account/passkeys/<id>
remove a passkey remove a passkey
POST /account/profile the user's OpenVPN client profile: {"current_password", "current_code"?}; {"name",
"profile"}
POST /account/apple-profile POST /account/apple-profile
the user's Apple configuration profile of IKEv2 road-warrior access: the user's Apple configuration profile of IKEv2 road-warrior access:
{"current_password", "current_code"?, "password"}, password encrypting the user's key in {"current_password", "current_code"?, "password"}, password encrypting the user's key in
it; {"name", "profile"} it; {"name", "profile"}
A change but the removal of a passkey, and the Apple profile, need the current password and, for an enrolled user, a A change but the removal of a passkey, and the profiles, need the current password and, for an enrolled user, a
current code; a wrong current code; a wrong
one is 403 and counts as a failed login. A password change revokes the user's refresh tokens. one is 403 and counts as a failed login. A password change revokes the user's refresh tokens.
""" """
@@ -1064,13 +1066,13 @@ def account(name, method, path, body):
removing = method == 'DELETE' and path.startswith('/account/passkeys/') and len(path.strip('/').split('/')) == 3 removing = method == 'DELETE' and path.startswith('/account/passkeys/') and len(path.strip('/').split('/')) == 3
if not removing and route not in (('GET', '/account'), ('PUT', '/account/password'), ('POST', '/account/otp'), if not removing and route not in (('GET', '/account'), ('PUT', '/account/password'), ('POST', '/account/otp'),
('PUT', '/account/otp'), ('POST', '/account/passkeys'), ('PUT', '/account/passkeys'), ('PUT', '/account/otp'), ('POST', '/account/passkeys'), ('PUT', '/account/passkeys'),
('POST', '/account/apple-profile')): ('POST', '/account/profile'), ('POST', '/account/apple-profile')):
return 404, {'error': f'no route {method} {path}'} return 404, {'error': f'no route {method} {path}'}
try: try:
if route == ('PUT', '/account/password'): if route == ('PUT', '/account/password'):
vpnrouter_users.check_password(body.get('password')) vpnrouter_users.check_password(body.get('password'))
if route in (('PUT', '/account/password'), ('PUT', '/account/otp'), ('POST', '/account/passkeys'), if route in (('PUT', '/account/password'), ('PUT', '/account/otp'), ('POST', '/account/passkeys'),
('POST', '/account/apple-profile')): ('POST', '/account/profile'), ('POST', '/account/apple-profile')):
vpnrouter_users.login(name, body.get('current_password'), body.get('current_code')) vpnrouter_users.login(name, body.get('current_password'), body.get('current_code'))
with vpnrouter_users.session() as connection: with vpnrouter_users.session() as connection:
if route == ('GET', '/account'): if route == ('GET', '/account'):
@@ -1080,6 +1082,8 @@ def account(name, method, path, body):
document = vpnrouter_users.passwd(connection, name, body['password']) document = vpnrouter_users.passwd(connection, name, body['password'])
elif route == ('POST', '/account/otp'): elif route == ('POST', '/account/otp'):
document = vpnrouter_users.otp_new(connection, name) document = vpnrouter_users.otp_new(connection, name)
elif route == ('POST', '/account/profile'):
document = vpnrouter_users.profile(connection, name)
elif route == ('POST', '/account/apple-profile'): elif route == ('POST', '/account/apple-profile'):
document = vpnrouter_users.apple_profile(connection, name, body.get('password')) document = vpnrouter_users.apple_profile(connection, name, body.get('password'))
elif route == ('POST', '/account/passkeys'): elif route == ('POST', '/account/passkeys'):
@@ -1260,44 +1264,69 @@ def site_file_action(name, target, body):
def dns(method, path, query, body): def dns(method, path, query, body):
"""Answer a /dns request with vpnrouter_dns: the HTTP status and the JSON document. """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 GET /dns {"settings", "installed", "running", "domains", "problem"}: the dns section of the
configuration, whether PowerDNS is installed and runs, the records the name server serves, configuration, whether PowerDNS is installed and runs, the domains, each {"name", "sites",
the manual records, and why the records cannot be read, null when they can "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 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, @ POST /dns/domains add a domain: {"name", "sites"?, "ttl"?}; sites gives the host names of the enabled sites
for the zone itself, or absolute with a final dot; ttl is dns.ttl without it in it records; ttl is dns.ttl when null or absent; an existing name is 409
DELETE /dns/records remove the manual records of name= and type=, with content= only that one PUT /dns/domains/<domain>
change "sites" or "ttl" of the domain
DELETE /dns/domains/<domain>
remove the domain with its secondaries and manual records
POST /dns/domains/<domain>/secondaries
add a secondary name server of the domain, or change its address: {"nameserver", "address"}
DELETE /dns/domains/<domain>/secondaries/<nameserver>
remove the secondary name server
POST /dns/domains/<domain>/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/<domain>/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. 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)): if body == 'invalid' or (body is not None and not isinstance(body, dict)):
return 400, {'error': 'the body must be a JSON object'} return 400, {'error': 'the body must be a JSON object'}
body = body or {} body = body or {}
parts = path.strip('/').split('/')
route = (method, *parts[:2], *parts[3:4])
try: try:
cfg = vpnrouter.load_config() cfg = vpnrouter.load_config()
if (method, path) == ('GET', '/dns'): if route == ('GET', 'dns'):
served, manual, problem = [], [], None domains, problem = [], None
try: try:
manual = vpnrouter_dns.manual() domains = vpnrouter_dns.listing()
served = vpnrouter_dns.served(cfg['dns']['zone'].strip())
except vpnrouter_dns.DatabaseError as exc: except vpnrouter_dns.DatabaseError as exc:
problem = str(exc) problem = str(exc)
return 200, {'settings': cfg['dns'], 'installed': vpnrouter_dns.PDNS_SERVER.exists(), 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} 'problem': problem}
if (method, path) == ('PUT', '/dns'): if route == ('PUT', 'dns'):
changed = section_change('dns', body) changed = section_change('dns', body)
return changed if isinstance(changed, tuple) else (200, {'settings': changed}) return changed if isinstance(changed, tuple) else (200, {'settings': changed})
if (method, path) == ('POST', '/dns/records'): if route == ('POST', 'dns', 'domains') and len(parts) == 2:
document, code = vpnrouter_dns.add(cfg, body), 201 document, code = vpnrouter_dns.domain_add(body), 201
elif (method, path) == ('DELETE', '/dns/records'): 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] 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 code = 200
else: else:
return 404, {'error': f'no route {method} {path}'} return 404, {'error': f'no route {method} {path}'}
except vpnrouter_dns.Failure as failure: 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: except vpnrouter_dns.DatabaseError as exc:
return 500, {'error': str(exc)} return 500, {'error': str(exc)}
except SystemExit as exc: except SystemExit as exc:
@@ -89,7 +89,6 @@
}, },
"dns": { "dns": {
"enabled": false, "enabled": false,
"zone": "",
"nameserver": "", "nameserver": "",
"addresses": [], "addresses": [],
"listen": "external", "listen": "external",
@@ -1,48 +1,106 @@
-- The zone the router's name server (PowerDNS, gpgsql backend) answers for (vpnrouter_dns), in the vpn-router-dns -- The domains the router's name server (PowerDNS, gpgsql backend) is the primary of (vpnrouter_dns), in the
-- database, whose domains and records tables are PowerDNS's own schema. -- 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. -- 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')); 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. DROP TABLE IF EXISTS dns_manual_records;
CREATE TABLE IF NOT 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, name text NOT NULL,
type text NOT NULL, type text NOT NULL,
content text NOT NULL, content text NOT NULL,
ttl integer 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 ( CREATE TABLE IF NOT EXISTS dns_state (
zone text PRIMARY KEY, zone text PRIMARY KEY,
digest text NOT NULL, digest text NOT NULL,
serial bigint NOT NULL serial bigint NOT NULL
); );
CREATE OR REPLACE FUNCTION dns_manual_list() CREATE OR REPLACE FUNCTION dns_domains_list()
RETURNS TABLE (name text, type text, content text, ttl integer) RETURNS TABLE (name text, sites boolean, ttl integer)
LANGUAGE sql STABLE AS $$ 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. -- Add the domain, or set its sites and TTL.
CREATE OR REPLACE PROCEDURE dns_manual_put(p_name text, p_type text, p_content text, p_ttl integer) CREATE OR REPLACE PROCEDURE dns_domain_put(p_name text, p_sites boolean, p_ttl integer)
LANGUAGE sql AS $$ LANGUAGE sql AS $$
INSERT INTO dns_manual_records VALUES (p_name, p_type, p_content, p_ttl) INSERT INTO dns_domains VALUES (p_name, p_sites, p_ttl)
ON CONFLICT (name, type, content) DO UPDATE SET ttl = 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. -- Remove the domain with its secondaries and records.
CREATE OR REPLACE PROCEDURE dns_manual_remove(p_name text, p_type text, p_content text) CREATE OR REPLACE PROCEDURE dns_domain_remove(p_name text)
LANGUAGE sql AS $$ 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. CREATE OR REPLACE FUNCTION dns_secondaries_list()
-- The serial grows, to at least the current Unix time, only when the records differ from those last written. 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[], 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 $$ LANGUAGE plpgsql AS $$
DECLARE DECLARE
v_domain integer; 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, ','), 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, ','))); array_to_string(p_contents, ','), array_to_string(p_ttls, ',')));
BEGIN 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; SELECT id INTO v_domain FROM domains WHERE name = p_zone;
IF v_domain IS NULL THEN IF v_domain IS NULL THEN
INSERT INTO domains (name, type) VALUES (p_zone, 'NATIVE') RETURNING id INTO v_domain; INSERT INTO domains (name, type) VALUES (p_zone, 'MASTER') RETURNING id INTO v_domain;
ELSIF EXISTS (SELECT 1 FROM dns_state WHERE zone = p_zone AND digest = v_digest) THEN 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; RETURN;
END IF; END IF;
SELECT greatest(coalesce(max(serial) + 1, 1), extract(epoch FROM now())::bigint) INTO v_serial SELECT greatest(coalesce(max(serial) + 1, 1), extract(epoch FROM now())::bigint) INTO v_serial
@@ -71,11 +134,11 @@ BEGIN
END; END;
$$; $$;
-- Serve no zone. -- Serve only the zones of the list, none for an empty one.
CREATE OR REPLACE PROCEDURE dns_clear() CREATE OR REPLACE PROCEDURE dns_retain(p_zones text[])
LANGUAGE sql AS $$ LANGUAGE sql AS $$
DELETE FROM domains; DELETE FROM domains WHERE name <> ALL (p_zones);
DELETE FROM dns_state; DELETE FROM dns_state WHERE zone <> ALL (p_zones);
$$; $$;
-- The records of the zone as they are served. -- The records of the zone as they are served.
@@ -39,5 +39,9 @@ pools {
rw-pool { rw-pool {
addrs = {{ p2s_address_pool }} addrs = {{ p2s_address_pool }}
dns = {{ p2s_dns }} dns = {{ p2s_dns }}
{%- if p2s_dns_domains %}
# INTERNAL_DNS_DOMAIN (RFC 8598): the domains the clients resolve with the DNS servers above.
25 = {{ p2s_dns_domains | join(', ') }}
{%- endif %}
} }
} }