Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1697e1aa38 | ||
|
|
57e8bbcffe | ||
|
|
9ca8fce14e |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
|
||||
## 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.
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
| `p2s` | `enabled` | boolean | `false` | Road-warrior access |
|
||||
| `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` |
|
||||
| `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 |
|
||||
@@ -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` | `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 |
|
||||
@@ -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;
|
||||
- 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
|
||||
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
|
||||
password and, when enrolled, a code.
|
||||
@@ -1173,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/<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>
|
||||
<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 |
|
||||
| `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 |
|
||||
| `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) |
|
||||
|
||||
`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
|
||||
|
||||
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
|
||||
@@ -1588,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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
feature: the web service gives Apple VPN profiles that set up IKEv2 road-warrior access on macOS, iOS and iPadOS.
|
||||
|
||||
@@ -44,7 +44,7 @@ from vpnrouter import fail, warn
|
||||
BEFORE_RULES = pathlib.Path('/etc/ufw/before.rules')
|
||||
UFW_DEFAULTS = pathlib.Path('/etc/default/ufw')
|
||||
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.
|
||||
SETUP_STATE = pathlib.Path('/run/vpn-router/setup.json')
|
||||
@@ -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):
|
||||
|
||||
@@ -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_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')
|
||||
|
||||
SWANCTL_CONF_D = pathlib.Path('/etc/swanctl/conf.d')
|
||||
@@ -56,7 +58,7 @@ SCHEMA = {
|
||||
'wan': {'local_fqdn': ''},
|
||||
'local': {'cidrs': [], 'int_addr': '', 'int_gateway_ip': ''},
|
||||
'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},
|
||||
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
|
||||
'docker': {'mode': 'auto', 'exposed_ports': []},
|
||||
@@ -67,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': {},
|
||||
@@ -144,9 +146,13 @@ 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.
|
||||
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.
|
||||
BOOTSTRAP_PORTS = (17080, 17081)
|
||||
|
||||
@@ -573,17 +579,22 @@ 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:
|
||||
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]:
|
||||
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')
|
||||
if resolver_listen not in 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'),
|
||||
'public_ip': public_ip or '',
|
||||
'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'])),
|
||||
'wg_enabled': _get(cfg, 'wireguard', 'enabled'),
|
||||
'wg_address': _get(cfg, 'wireguard', 'address'),
|
||||
@@ -1080,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'])
|
||||
|
||||
|
||||
|
||||
@@ -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',
|
||||
'',
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
"""
|
||||
entry(connection, name)
|
||||
@@ -858,7 +858,15 @@ def apple_profile(connection, name, password):
|
||||
payloads.append({'PayloadType': 'com.apple.security.pkcs12', 'PayloadVersion': 1,
|
||||
'PayloadIdentifier': f'{identifier}.pkcs12', 'PayloadUUID': ids['pkcs12'],
|
||||
'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({
|
||||
**dns,
|
||||
'PayloadType': 'com.apple.vpn.managed', 'PayloadVersion': 1, 'PayloadIdentifier': f'{identifier}.vpn',
|
||||
'PayloadUUID': ids['vpn'], 'PayloadDisplayName': f'VPN Router {fqdn}', 'UserDefinedName': f'VPN Router {fqdn}',
|
||||
'VPNType': 'IKEv2',
|
||||
|
||||
@@ -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
|
||||
@@ -1048,12 +1048,14 @@ def account(name, method, path, body):
|
||||
PUT /account/passkeys store the passkey: {"id", "credential", "label"?}, credential the browser's answer
|
||||
DELETE /account/passkeys/<id>
|
||||
remove a passkey
|
||||
POST /account/profile the user's OpenVPN client profile: {"current_password", "current_code"?}; {"name",
|
||||
"profile"}
|
||||
POST /account/apple-profile
|
||||
the user's Apple configuration profile of IKEv2 road-warrior access:
|
||||
{"current_password", "current_code"?, "password"}, password encrypting the user's key in
|
||||
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
|
||||
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
|
||||
if not removing and route not in (('GET', '/account'), ('PUT', '/account/password'), ('POST', '/account/otp'),
|
||||
('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}'}
|
||||
try:
|
||||
if route == ('PUT', '/account/password'):
|
||||
vpnrouter_users.check_password(body.get('password'))
|
||||
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'))
|
||||
with vpnrouter_users.session() as connection:
|
||||
if route == ('GET', '/account'):
|
||||
@@ -1080,6 +1082,8 @@ def account(name, method, path, body):
|
||||
document = vpnrouter_users.passwd(connection, name, body['password'])
|
||||
elif route == ('POST', '/account/otp'):
|
||||
document = vpnrouter_users.otp_new(connection, name)
|
||||
elif route == ('POST', '/account/profile'):
|
||||
document = vpnrouter_users.profile(connection, name)
|
||||
elif route == ('POST', '/account/apple-profile'):
|
||||
document = vpnrouter_users.apple_profile(connection, name, body.get('password'))
|
||||
elif route == ('POST', '/account/passkeys'):
|
||||
@@ -1260,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/<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.
|
||||
"""
|
||||
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:
|
||||
|
||||
@@ -89,7 +89,6 @@
|
||||
},
|
||||
"dns": {
|
||||
"enabled": false,
|
||||
"zone": "",
|
||||
"nameserver": "",
|
||||
"addresses": [],
|
||||
"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
|
||||
-- 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.
|
||||
|
||||
@@ -39,5 +39,9 @@ pools {
|
||||
rw-pool {
|
||||
addrs = {{ p2s_address_pool }}
|
||||
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 %}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user