Compare commits

...
10 Commits
Author SHA1 Message Date
slawek 3b14b7723e feature: vre opens files from the Router Manager's shell in the editor of its Shell page.
Release / release (push) Successful in 17s
2026-10-29 18:10:58 +01:00
slawek 5303779559 feature: the web service shows and changes the settings of the metrics service at /metrics/settings, and OpenVPN starts on a new router, as setup installs CRLs that revoke nothing.
Release / release (push) Failing after 14m34s
2026-10-28 22:31:27 +01:00
slawek 2dc4db440f fix: OpenVPN starts on a new router, as setup installs CRLs that revoke nothing.
Release / release (push) Failing after 15m31s
2026-10-28 22:00:37 +01:00
slawek 45cea95728 fix: vpn-router-setup runs on a new installation once libpq can be found.
Release / release (push) Failing after 14m15s
2026-10-28 21:41:52 +01:00
slawek dd2dfaaf2b fix: the router's IPsec remote access is called IKEv2 instead of road-warrior.
Release / release (push) Successful in 53s
2026-10-27 08:46:36 +01:00
slawek 1697e1aa38 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.
Release / release (push) Successful in 13s
2026-10-26 21:26:47 +01:00
slawek 57e8bbcffe feature: road-warrior clients resolve the domains of the new setting p2s.dns_domains with the router's resolver.
Release / release (push) Successful in 14s
2026-10-26 20:58:33 +01:00
slawek 9ca8fce14e fix: a VPN user downloads the own OpenVPN client profile through /account, with the current password and code.
Release / release (push) Successful in 22s
2026-10-26 20:43:33 +01:00
slawek 74c24d7bc6 feature: the web service gives Apple VPN profiles that set up IKEv2 road-warrior access on macOS, iOS and iPadOS.
Release / release (push) Successful in 14s
2026-10-26 20:37:58 +01:00
slawek 770332728e fix: WireGuard clients may use the router's DNS resolver, which runs while WireGuard does, and /remote-access names its addresses for their configuration.
Release / release (push) Successful in 14s
2026-10-26 18:33:03 +01:00
28 changed files with 1205 additions and 241 deletions

No files matched your search

+91 -19
View File
@@ -46,7 +46,7 @@ and as a Tailwind CSS page for a browser, and `/status` is the same status as JS
much as `web.status_content` says, since the router is reachable from the internet and the much as `web.status_content` says, since the router is reachable from the internet and the
operator decides how much it tells strangers: `basic`, the default because it tells the least, operator decides how much it tells strangers: `basic`, the default because it tells the least,
the overall status and the Client IP, `standard` also the package versions and the number of the overall status and the Client IP, `standard` also the package versions and the number of
active and other services, and `full` also each service and the state of the site-to-site, road-warrior, OpenVPN and active and other services, and `full` also each service and the state of the site-to-site, IKEv2, OpenVPN and
WireGuard features. The Client IP is the address the request came from, through the trusted WireGuard features. The Client IP is the address the request came from, through the trusted
proxies, so a person can see the address the router sees. Nothing comes from the configuration. proxies, so a person can see the address the router sees. Nothing comes from the configuration.
`/status` names its content, so clients such as manage-ui and the Router Manager show what it `/status` names its content, so clients such as manage-ui and the Router Manager show what it
@@ -394,8 +394,8 @@ failure, then listens. `vpnrouter_auth` turns every failure of the database into
psycopg (Ubuntu ships only its pure-Python implementation) finds libpq through the dynamic linker psycopg (Ubuntu ships only its pure-Python implementation) finds libpq through the dynamic linker
cache, which dpkg refreshes with its deferred `ldconfig` only at the end of an installation, so a cache, which dpkg refreshes with its deferred `ldconfig` only at the end of an installation, so a
service started during the installation cannot import it yet; a failed import cannot be repeated service started during the installation cannot import it yet; a failed import cannot be repeated
in the same process either, as psycopg caches the lookup. The services therefore import psycopg in the same process either, as psycopg caches the lookup. The services and `vpn-router-setup`
plainly, and their units wait instead: `ExecStartPre=wait-for-libpq`, a shell script of each therefore import psycopg plainly, and their units wait instead: `ExecStartPre=wait-for-libpq`, a shell script of each
package, waits until `ldconfig -p` lists `libpq.so.5`, within `TimeoutStartSec=5min`. The package, waits until `ldconfig -p` lists `libpq.so.5`, within `TimeoutStartSec=5min`. The
maintainer scripts start the units with `systemctl --no-block` (debhelper's start is turned off), maintainer scripts start the units with `systemctl --no-block` (debhelper's start is turned off),
because a start that waited would hold dpkg before its `ldconfig`. because a start that waited would hold dpkg before its `ldconfig`.
@@ -458,7 +458,7 @@ refresh tokens after the operations of `vpnrouter_auth.REVOKING`.
Consequences: A Manager-only account cannot become a VPN user, as LDAP cannot change an entry's Consequences: A Manager-only account cannot become a VPN user, as LDAP cannot change an entry's
structural class and its password hash cannot be copied; it is removed and added again instead. structural class and its password hash cannot be copied; it is removed and added again instead.
It has no certificate, so OpenVPN and road-warrior access refuse it. The administrator's password It has no certificate, so OpenVPN and IKEv2 access refuse it. The administrator's password
is on disk only until setup has run. is on disk only until setup has run.
## ADR-19: The package has an LDAP schema of its own under a UUID OID ## ADR-19: The package has an LDAP schema of its own under a UUID OID
@@ -655,8 +655,8 @@ one, so its name is the one the tunnel always had.
- **Local settings per connection:** each connection has its own `local_cidrs` and - **Local settings per connection:** each connection has its own `local_cidrs` and
`local_id_mode`, as each peer expects its own traffic selectors and identity. `wan.local_fqdn` `local_id_mode`, as each peer expects its own traffic selectors and identity. `wan.local_fqdn`
stays the router's one name, used by the `fqdn` mode, resolved once for the `public_ip` mode, stays the router's one name, used by the `fqdn` mode, resolved once for the `public_ip` mode,
and used by HTTPS, road-warrior and OpenVPN. `local.cidrs` stays the subnets the router and used by HTTPS, IKEv2 and OpenVPN. `local.cidrs` stays the subnets the router
routes, masquerades and offers to road-warrior and OpenVPN clients. routes, masquerades and offers to IKEv2 and OpenVPN clients.
- **One file:** `vpn-router-setup` writes every connection that is ready to - **One file:** `vpn-router-setup` writes every connection that is ready to
`/etc/swanctl/conf.d/site-to-site.conf`, with the proposals and timers the one tunnel had, and `/etc/swanctl/conf.d/site-to-site.conf`, with the proposals and timers the one tunnel had, and
removes the file when none is. The NAT exemptions pair each connection's `local_cidrs` with its removes the file when none is. The NAT exemptions pair each connection's `local_cidrs` with its
@@ -766,9 +766,9 @@ signs the requester's CSR, or makes the key and returns it once in a PKCS#12 fil
the certificate, so a key it gave away cannot leak from it later. The PKCS#12 file uses 3DES and the certificate, so a key it gave away cannot leak from it later. The PKCS#12 file uses 3DES and
SHA-1 MACs, the encryption the key stores of Windows and macOS import. strongSwan reads only the SHA-1 MACs, the encryption the key stores of Windows and macOS import. strongSwan reads only the
first certificate of a PEM file, so each CA has a file of its own in `/etc/swanctl/x509ca`; first certificate of a PEM file, so each CA has a file of its own in `/etc/swanctl/x509ca`;
road-warrior and site-to-site name their CA in `cacerts`, which restricts the trust chain to it. IKEv2 access and site-to-site name their CA in `cacerts`, which restricts the trust chain to it.
Consequences: The P2S CA's certificates are accepted by road-warrior and OpenVPN only, the site-to-site CA's by Consequences: The P2S CA's certificates are accepted by IKEv2 and OpenVPN only, the site-to-site CA's by
site-to-site only. With an offline root, new issuing CAs and the root CRL need the offline step, site-to-site only. With an offline root, new issuing CAs and the root CRL need the offline step,
and a router whose P2S CA waits has no VPN server certificate and so no P2S access. The router and a router whose P2S CA waits has no VPN server certificate and so no P2S access. The router
keeps the users' keys, as the OpenVPN profile embeds them. keeps the users' keys, as the OpenVPN profile embeds them.
@@ -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
@@ -1176,8 +1187,69 @@ Decision: `vpn-router-resolver.service` runs dnsmasq, bound to the addresses of
only (`bind-interfaces`), forwarding to resolved's stub on 127.0.0.53, without `/etc/hosts` and only (`bind-interfaces`), forwarding to resolved's stub on 127.0.0.53, without `/etc/hosts` and
without forwarding names that have no dot (`domain-needed`). resolved keeps its loopback stubs without forwarding names that have no dot (`domain-needed`). resolved keeps its loopback stubs
only and stays the router's own resolver, with the upstream servers of the platform. Setup writes only and stays the router's own resolver, with the upstream servers of the platform. Setup writes
`/etc/vpn-router/dnsmasq.conf` while IKEv2 or OpenVPN runs and removes the earlier drop-in. `/etc/vpn-router/dnsmasq.conf` while IKEv2, OpenVPN or WireGuard runs, opens port 53 there for
their client networks, and removes the earlier drop-in.
Consequences: Clients resolve the names resolved resolves, Azure's internal names included, but Consequences: Clients resolve the names resolved resolves, Azure's internal names included, but
not the router's synthesized ones. The package depends on dnsmasq-base. PowerDNS and the clients' not the router's synthesized ones. The package depends on dnsmasq-base. PowerDNS and the clients'
resolver share no address. resolver share no address.
## ADR-44: Apple VPN profiles are unsigned IKEv2 profiles with a PKCS#12 encrypted by a password given per download
Status: Accepted
Context: macOS, iOS and iPadOS connect to the IKEv2 access with their built-in IKEv2 client,
configured by a configuration profile. The router keeps each user's certificate and key. Apple's
clients read a PKCS#12 only when it is encrypted with 3DES and SHA-1, send one proposal per SA,
and show an unsigned profile as unverified.
Decision: The web service builds the profile with `plistlib` and python3-cryptography: the P2S CA
and the root, the user's PKCS#12 encrypted with a password the caller gives with each request,
and an IKEv2 payload matching `road-warrior.conf` (EAP-TLS with the certificate, AES-256,
SHA2-256, group 14). The profile is not signed. Admins get any user's profile through `/users`,
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: IKEv2 clients get split DNS domains, p2s.dns_domains
Status: Accepted
Context: The IKEv2 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
IKEv2 address 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.
## ADR-46: vre opens files in the Shell page's editor over a socket checked by the peer's uid
Status: Accepted
Context: Administrators edit the router's files from the Router Manager's Shell page, in an editor
of the page rather than a terminal editor. The command that names a file runs in the shell, as the
console account. The web service runs as root, sandboxed with `ProtectHome=yes`, so it cannot see
the accounts' home folders. Reading and writing as root would also bypass the account's
permissions.
Decision: Each shell has a random session in `VPN_ROUTER_SHELL`. `vpn-router-editor` (`vre`) sends
the session and an absolute path to the web service on `/run/vpn-router/editor.sock`, which anyone
may connect to. The web service accepts a request only when the peer's uid (`SO_PEERCRED`) is the
uid of the session's console account. It then tells the shell's client over the shell's WebSocket,
and the page reads and saves the file through `/shell/editor/<session>/<id>`. Files are read and
written by `editor-file`, run with `systemd-run --uid=<account> --pipe` outside the sandbox, as the
shell is. A save carries the SHA-256 of the content it replaces and is refused when the file
changed since. Files open only while their shell lives.
Consequences: The editor edits only what the console account may; a file only root may write is
edited in the terminal. `vre` run under sudo is refused, as its uid is not the console account's.
Each read and save starts a transient unit. Files are limited to 1 MiB of UTF-8 text.
+126 -47
View File
@@ -3,11 +3,11 @@
A Debian package that configures a Linux host or virtual machine as a VPN router: A Debian package that configures a Linux host or virtual machine as a VPN router:
- site-to-site IKEv2 IPSec with pre-shared key or certificate authentication (strongSwan swanctl) - site-to-site IKEv2 IPSec with pre-shared key or certificate authentication (strongSwan swanctl)
- road-warrior (P2S) access using IKEv2 EAP-TLS - IKEv2 access using EAP-TLS
- a two-level certificate authority: a root, which may be kept offline, and issuing CAs for the VPN - a two-level certificate authority: a root, which may be kept offline, and issuing CAs for the VPN
clients, for site-to-site and for general use, with certificate templates, published certificates clients, for site-to-site and for general use, with certificate templates, published certificates
and CRLs, and an optional ACME server and CRLs, and an optional ACME server
- OpenVPN (P2S) access with a certificate and a password per user, the users kept in a local - OpenVPN access with a certificate and a password per user, the users kept in a local
LDAP directory and managed with `vpn-router-user` or the web service LDAP directory and managed with `vpn-router-user` or the web service
- optional WireGuard endpoint - optional WireGuard endpoint
- routing and NAT for a protected subnet, TCP MSS clamping, and UFW firewall rules - routing and NAT for a protected subnet, TCP MSS clamping, and UFW firewall rules
@@ -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
@@ -108,14 +108,15 @@ must have the type shown below; an unknown section or setting is an error.
| `general` | `mode` | string | `manual` | How much you supply: `manual`, `interfaces` or `auto` | | `general` | `mode` | string | `manual` | How much you supply: `manual`, `interfaces` or `auto` |
| `interfaces` | `external` | string | | Name of the NIC facing the untrusted network | | `interfaces` | `external` | string | | Name of the NIC facing the untrusted network |
| `interfaces` | `internal` | string | | Name of the NIC facing the protected network | | `interfaces` | `internal` | string | | Name of the NIC facing the protected network |
| `wan` | `local_fqdn` | string | | This router's name or IP address: its IKE identity in `fqdn` mode, the name resolved in `public_ip` mode, and the name of HTTPS, road-warrior and OpenVPN | | `wan` | `local_fqdn` | string | | This router's name or IP address: its IKE identity in `fqdn` mode, the name resolved in `public_ip` mode, and the name of HTTPS, IKEv2 and OpenVPN |
| `local` | `cidrs` | list | | Local subnets routed via the internal side, masqueraded to the external one, and offered to road-warrior and OpenVPN clients | | `local` | `cidrs` | list | | Local subnets routed via the internal side, masqueraded to the external one, and offered to IKEv2 and OpenVPN clients |
| `local` | `int_addr` | string | | This host's address on the internal network | | `local` | `int_addr` | string | | This host's address on the internal network |
| `local` | `int_gateway_ip` | string | | Next hop on the internal side | | `local` | `int_gateway_ip` | string | | Next hop on the internal side |
| `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` | IKEv2 access |
| `p2s` | `address_pool` | string | | CIDR assigned to road-warrior clients | | `p2s` | `address_pool` | string | | CIDR assigned to IKEv2 clients |
| `p2s` | `dns_listen` | string | `internal` | The addresses of the resolver the road-warrior and OpenVPN clients are given: `internal`, `external` or `both` | | `p2s` | `dns_domains` | list | | The domains IKEv2 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 IKEv2, 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 |
| `wireguard` | `listen_port` | integer | `51820` | UDP port | | `wireguard` | `listen_port` | integer | `51820` | UDP port |
@@ -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 |
@@ -251,9 +251,9 @@ A minimal site-to-site configuration in `auto` mode:
The router's IKE identity (`wan.local_fqdn` in `fqdn` mode) and the remote one (`id`) are The router's IKE identity (`wan.local_fqdn` in `fqdn` mode) and the remote one (`id`) are
an FQDN or an IPv4 or IPv6 address. They are written to the strongSwan configuration as given; an FQDN or an IPv4 or IPv6 address. They are written to the strongSwan configuration as given;
strongSwan reads an address as an IP identity and a name as an FQDN, so a leading `@` is not needed strongSwan reads an address as an IP identity and a name as an FQDN, so a leading `@` is not needed
and is refused. Road-warrior and OpenVPN access need a name in `wan.local_fqdn`. and is refused. IKEv2 and OpenVPN access need a name in `wan.local_fqdn`.
WireGuard peers are P2S users' devices: a VPN user of the router's directory may have up to 16 WireGuard peers are VPN users' devices: a VPN user of the router's directory may have up to 16
peers, each with its public key and its IPs, IPv4 addresses or networks, kept in the user's peers, each with its public key and its IPs, IPv4 addresses or networks, kept in the user's
entry (`vpnRouterWireGuardPeer`) and set with `PUT /users/<name>` (`wireguard_peers`) or on the entry (`vpnRouterWireGuardPeer`) and set with `PUT /users/<name>` (`wireguard_peers`) or on the
WireGuard tab of the Router Manager's Remote access management. A key is used by one peer only, WireGuard tab of the Router Manager's Remote access management. A key is used by one peer only,
@@ -398,7 +398,7 @@ systemctl restart vpn-router-setup
The service also runs at boot. With platform `none` it does nothing and says so. The service also runs at boot. With platform `none` it does nothing and says so.
Otherwise it regenerates the configuration files, resolves the interfaces and their Otherwise it regenerates the configuration files, resolves the interfaces and their
addresses as `mode` directs, applies routes, generates WireGuard keys and injects addresses as `mode` directs, applies routes, generates WireGuard keys and injects
firewall rules. It provisions the user directory in `slapd` while OpenVPN or road-warrior firewall rules. It provisions the user directory in `slapd` while OpenVPN or IKEv2
access is configured. It then starts `strongswan`, `wg-quick@wg0` and access is configured. It then starts `strongswan`, `wg-quick@wg0` and
`openvpn-server@vpn-router` if the corresponding feature is configured, reloading or `openvpn-server@vpn-router` if the corresponding feature is configured, reloading or
restarting them only when their inputs changed, and restarting them only when their inputs changed, and
@@ -449,7 +449,7 @@ The root signs the issuing CAs only (path length 1), and the issuing CAs sign th
(path length 0): (path length 0):
- `p2s`, named `pki.p2s_name`, signs the router's VPN server certificate, which IKEv2 and OpenVPN - `p2s`, named `pki.p2s_name`, signs the router's VPN server certificate, which IKEv2 and OpenVPN
present, and the users' certificates. Road-warrior and OpenVPN accept client certificates of present, and the users' certificates. IKEv2 and OpenVPN accept client certificates of
this CA only. this CA only.
- `s2s`, named `pki.s2s_name`, signs the router's site-to-site certificate and the certificates - `s2s`, named `pki.s2s_name`, signs the router's site-to-site certificate and the certificates
of its peers. of its peers.
@@ -652,7 +652,7 @@ certificate names:
### The user directory ### The user directory
Once a platform is chosen, `vpn-router-setup` keeps a directory of users in the local slapd, Once a platform is chosen, `vpn-router-setup` keeps a directory of users in the local slapd,
whether or not OpenVPN or road-warrior access is enabled, so users can be managed before either whether or not OpenVPN or IKEv2 access is enabled, so users can be managed before either
is switched on: is switched on:
- its own database, `dc=vpn-router,dc=local` in `/var/lib/ldap/vpn-router`, next to the one the - its own database, `dc=vpn-router,dc=local` in `/var/lib/ldap/vpn-router`, next to the one the
@@ -773,9 +773,29 @@ When the Router Manager is installed with an administrator (its debconf question
Manager-only account, adds it to the group `admins`, which it creates when missing, and removes Manager-only account, adds it to the group `admins`, which it creates when missing, and removes
the file. the file.
The certificate is also accepted by road-warrior (IKEv2 EAP-TLS) access, which checks The certificate is also accepted by IKEv2 (EAP-TLS) access, which checks
certificates only, so `disable` does not affect it; `remove` revokes the certificate for both. certificates only, so `disable` does not affect it; `remove` revokes the certificate for both.
### Apple VPN profile
While `p2s.enabled` is `true` and `wan.local_fqdn` is set, `/users/<name>/apple-profile` and a
user's own `/account/apple-profile` give an unsigned Apple configuration profile (`.mobileconfig`)
that sets up IKEv2 access in the built-in VPN client of macOS, iOS and iPadOS. It holds:
- the P2S CA and the root CA, the root installed as a trusted root;
- the user's certificate and key as a PKCS#12 encrypted with the password of the request, with
3DES and SHA-1, the only encryption Apple's clients read; the device asks for the password when
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 IKEv2
connection (`road-warrior`) 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.
### Console login and SSH ### Console login and SSH
A member of `admins` enrolled for TOTP may have console login: the Router Manager's Shell page A member of `admins` enrolled for TOTP may have console login: the Router Manager's Shell page
@@ -872,10 +892,11 @@ deploying.
which is how a deferred install is expressed. `generic` is the module to choose when the which is how a deferred install is expressed. `generic` is the module to choose when the
router should be configured but the platform adds nothing. router should be configured but the platform adds nothing.
The shipped modules currently add nothing. Road-warrior and OpenVPN clients are given the The shipped modules currently add nothing. IKEv2, OpenVPN and WireGuard clients are given the
router's addresses of `p2s.dns_listen` as their DNS servers, by default the internal address, router's addresses of `p2s.dns_listen` as their DNS servers, by default the internal address,
the WireGuard clients in the configuration the Router Manager makes for a device (`DNS =`),
where `vpn-router-resolver.service`, dnsmasq with `/etc/vpn-router/dnsmasq.conf`, answers and where `vpn-router-resolver.service`, dnsmasq with `/etc/vpn-router/dnsmasq.conf`, answers and
the firewall opens port 53 for the client pools, so no provider resolver has to be advertised or the firewall opens port 53 for the client pools and the WireGuard network, so no provider resolver has to be advertised or
routed through the tunnel on any platform. dnsmasq forwards to `systemd-resolved` on routed through the tunnel on any platform. dnsmasq forwards to `systemd-resolved` on
127.0.0.53, which keeps only its loopback stubs, and forwards no name without a dot, so the 127.0.0.53, which keeps only its loopback stubs, and forwards no name without a dot, so the
router's own host name, `_gateway` and `localhost` are not answered to the clients. The clients reach router's own host name, `_gateway` and `localhost` are not answered to the clients. The clients reach
@@ -887,7 +908,7 @@ A module may define any of the following, and takes the default for anything it
| Name | Type | Effect | | Name | Type | Effect |
|---|---|---| |---|---|---|
| `EXTRA_P2S_DNS` | `str` | added to the road-warrior pool `dns` list | | `EXTRA_P2S_DNS` | `str` | added to the `dns` list of the IKEv2 address pool |
| `EXTRA_LOCAL_TS` | `str` | added to `local_ts` on both connections | | `EXTRA_LOCAL_TS` | `str` | added to `local_ts` on both connections |
| `apply(ctx)` | function | called once the system is configured, to apply platform-specific state; must be idempotent | | `apply(ctx)` | function | called once the system is configured, to apply platform-specific state; must be idempotent |
@@ -920,7 +941,7 @@ configuration: no key, identity, subnet, or address but the client's. They show
|---|---| |---|---|
| `basic` (default) | The overall status (`ok` or `degraded`), the time and the Client IP | | `basic` (default) | The overall status (`ok` or `degraded`), the time and the Client IP |
| `standard` | Also the `vpn-router` version, the `vpn-router-manager` version or that it is not installed, and the number of active services and of services in any other state, without their names | | `standard` | Also the `vpn-router` version, the `vpn-router-manager` version or that it is not installed, and the number of active services and of services in any other state, without their names |
| `full` | Also each service and its state, Docker, how many site-to-site connections are set up and how many of them are established (`site_to_site`: `connections` and `established`, `null` when swanctl cannot be asked), road-warrior, OpenVPN and WireGuard | | `full` | Also each service and its state, Docker, how many site-to-site connections are set up and how many of them are established (`site_to_site`: `connections` and `established`, `null` when swanctl cannot be asked), IKEv2, OpenVPN and WireGuard |
The Client IP is the address the request came from: the last entry of `X-Forwarded-For` for a The Client IP is the address the request came from: the last entry of `X-Forwarded-For` for a
request from one of `web.trusted_proxies`, by default the address the request came in on, so request from one of `web.trusted_proxies`, by default the address the request came in on, so
@@ -955,7 +976,7 @@ curl http://10.0.0.4:17081/
| `/status` | The same status as JSON | | `/status` | The same status as JSON |
| `/auth` | Signing in with a user's password and TOTP code, refreshing and signing out | | `/auth` | Signing in with a user's password and TOTP code, refreshing and signing out |
| `/config` | The configuration without its secret options, and what the router resolved and runs, as JSON; needs a token | | `/config` | The configuration without its secret options, and what the router resolved and runs, as JSON; needs a token |
| `/users` | The users of OpenVPN and road-warrior access; needs a token | | `/users` | The users of OpenVPN and IKEv2 access; needs a token |
| `/service-accounts` | The service accounts; needs a token | | `/service-accounts` | The service accounts; needs a token |
| `/groups` | The groups of users; needs a token | | `/groups` | The groups of users; needs a token |
| `/site-to-site` | The site-to-site connections: list, add, change and remove; needs a token | | `/site-to-site` | The site-to-site connections: list, add, change and remove; needs a token |
@@ -968,14 +989,14 @@ curl http://10.0.0.4:17081/
The status holds the state of `vpn-router-setup`, `slapd` and each PostgreSQL cluster unit The status holds the state of `vpn-router-setup`, `slapd` and each PostgreSQL cluster unit
(`postgresql@<version>-<cluster>`, or `postgresql` as `no cluster` without one), of (`postgresql@<version>-<cluster>`, or `postgresql` as `no cluster` without one), of
`vpn-router-manager` when that package is installed, of `strongswan` when site-to-site or `vpn-router-manager` when that package is installed, of `strongswan` when site-to-site or
road-warrior access is configured, of `wg-quick@wg0` when WireGuard is, and of IKEv2 access is configured, of `wg-quick@wg0` when WireGuard is, and of
`openvpn-server@vpn-router` when OpenVPN is; the number of connected `openvpn-server@vpn-router` when OpenVPN is; the number of connected
OpenVPN clients, from the server's status file, which OpenVPN rewrites every 5 seconds; the package version and, OpenVPN clients, from the server's status file, which OpenVPN rewrites every 5 seconds; the package version and,
as `manager_version`, the installed version of `vpn-router-manager` (`null`, shown as not installed, without it); whether as `manager_version`, the installed version of `vpn-router-manager` (`null`, shown as not installed, without it); whether
Docker is installed, and when it is, the state of `docker.service` and of `vpn-router-docker` Docker is installed, and when it is, the state of `docker.service` and of `vpn-router-docker`
(the Docker rules), which do not change `status`; as `site_to_site`, the number of site-to-site (the Docker rules), which do not change `status`; as `site_to_site`, the number of site-to-site
connections that are set up (`connections`) and of those established (`established`, `null` when connections that are set up (`connections`) and of those established (`established`, `null` when
swanctl cannot be asked), without their names; the number of connected road-warrior (IPsec P2S) clients; and swanctl cannot be asked), without their names; the number of connected IKEv2 clients; and
the WireGuard peers with the age of their last handshake and their traffic. `status` is `ok` when the WireGuard peers with the age of their last handshake and their traffic. `status` is `ok` when
all these services are active, and `degraded` otherwise. all these services are active, and `degraded` otherwise.
@@ -986,7 +1007,7 @@ curl http://10.0.0.4:7081/status
### Tokens and signing in ### Tokens and signing in
`/config`, `/users`, `/service-accounts`, `/groups`, `/site-to-site`, `/backup`, `/remote-access`, `/ca`, `/logs`, `/sites`, `/dns`, `/shell` and `/account` need a JWT in an `Authorization: Bearer` header, signed with `/config`, `/metrics/history`, `/metrics/settings`, `/users`, `/service-accounts`, `/groups`, `/site-to-site`, `/backup`, `/remote-access`, `/ca`, `/logs`, `/sites`, `/dns`, `/shell` and `/account` need a JWT in an `Authorization: Bearer` header, signed with
HS256 by the key the web service makes on its first run, `/etc/vpn-router/web.key` (root only). HS256 by the key the web service makes on its first run, `/etc/vpn-router/web.key` (root only).
The key never leaves the router; while it does not exist these paths and `/auth` are not served The key never leaves the router; while it does not exist these paths and `/auth` are not served
(404). A missing, expired or wrongly signed token, or one of another algorithm, gets 401 with (404). A missing, expired or wrongly signed token, or one of another algorithm, gets 401 with
@@ -1078,6 +1099,34 @@ off or the TOTP enrolment removed; the router then sends a close frame with the
journal of `vpn-router-web` records who opened each shell, from which address, in which unit, journal of `vpn-router-web` records who opened each shell, from which address, in which unit,
when it ended and why, and each refused ticket or shell with the reason. when it ended and why, and each refused ticket or shell with the reason.
#### The editor of the shell
In the shell, `vpn-router-editor FILE...`, or its short name `vre`, opens files in the editor of
the Router Manager's Shell page and returns at once:
```sh
vre /etc/vpn-router/vpn-router.json ~/notes.txt
```
- **How the files reach the page:** each shell has its session in `VPN_ROUTER_SHELL`. `vre`
names each file, as an absolute path, with that session on the socket
`/run/vpn-router/editor.sock`. The web service accepts it only from a process that runs as the
shell's console account, and sends the page a text message `{"editor": "open", "session", "id", "path"}`
over the shell's WebSocket.
- **Reading and saving:** the page uses the routes below, which need the user's access token. The
file is read and written as the console account by `/usr/lib/vpn-router/editor-file`, run with
`systemd-run --uid --pipe`, so the account's permissions apply. A save writes the file in place,
keeping its owner and mode, and creates a file that does not exist.
- **Limits:** only regular files of UTF-8 text up to 1 MiB, and at most 20 open files per shell.
- **Lifetime:** the files are open while the shell lives. `vre` outside the Router Manager's shell
exits with status 2.
| Request | Body | Answer |
| --- | --- | --- |
| `GET /shell/editor/<session>/<id>` | | `{"path", "content", "version"}`, `version` the SHA-256 of the content |
| `PUT /shell/editor/<session>/<id>` | `{"content", "version"}` | `{"path", "version"}`; 409 when the file no longer has `version` |
| `DELETE /shell/editor/<session>/<id>` | | `{"path", "closed": true}` |
### The /config page ### The /config page
`/config` shows what `/` and `/status` leave out, for a client such as manage-ui: `/config` shows what `/` and `/status` leave out, for a client such as manage-ui:
@@ -1155,10 +1204,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.
@@ -1180,6 +1234,7 @@ and answer bodies are JSON; a request body is at most 65536 bytes.
| `POST /users/<name>/unlock` | | the user | | `POST /users/<name>/unlock` | | the user |
| `DELETE /users/<name>` | | `{"name", "removed": true}` | | `DELETE /users/<name>` | | `{"name", "removed": true}` |
| `GET /users/<name>/profile` | | `{"name", "profile"}`, the OpenVPN client profile | | `GET /users/<name>/profile` | | `{"name", "profile"}`, the OpenVPN client profile |
| `POST /users/<name>/apple-profile` | `{"password"}` | `{"name", "profile"}`, the Apple configuration profile of IKEv2 access (see Apple VPN profile) |
| `POST /users/<name>/otp` | | `{"name", "secret", "uri"}`, a new TOTP secret; nothing is stored | | `POST /users/<name>/otp` | | `{"name", "secret", "uri"}`, a new TOTP secret; nothing is stored |
| `PUT /users/<name>/otp` | `{"secret", "code"}` | the user, once the code matches the secret | | `PUT /users/<name>/otp` | `{"secret", "code"}` | the user, once the code matches the secret |
| `DELETE /users/<name>/otp` | | the user | | `DELETE /users/<name>/otp` | | the user |
@@ -1189,7 +1244,7 @@ and answer bodies are JSON; a request body is at most 65536 bytes.
A failure is `{"error": ...}` with 400 for an invalid request, 404 for a user that does not A failure is `{"error": ...}` with 400 for an invalid request, 404 for a user that does not
exist or is a service account, 409 for a user that exists and 500 otherwise. `/users` does not exist or is a service account, 409 for a user that exists and 500 otherwise. `/users` does not
list service accounts; `/service-accounts` serves them. `/users` works whether or not OpenVPN or list service accounts; `/service-accounts` serves them. `/users` works whether or not OpenVPN or
road-warrior access is enabled; the profile needs OpenVPN. Passwords, private keys and TOTP IKEv2 access is enabled; the profile needs OpenVPN. Passwords, private keys and TOTP
secrets cross the internal network in plain HTTP, like the token. secrets cross the internal network in plain HTTP, like the token.
```bash ```bash
@@ -1236,6 +1291,8 @@ 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 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
403 and counts as a failed login; slapd accepts each code once, so a code used to sign in is not 403 and counts as a failed login; slapd accepts each code once, so a code used to sign in is not
@@ -1303,14 +1360,26 @@ The answer of `GET /backup` holds:
A change is made, checked and applied as for `/site-to-site`. A change is made, checked and applied as for `/site-to-site`.
### The /metrics/settings API
`/metrics/settings` shows and changes the settings of the metrics service, with the same token as
`/users`.
| Request | Body | Answer |
| --- | --- | --- |
| `GET /metrics/settings` | | `{"settings"}`: the `metrics` section without `otlp_password`, with `otlp_password_set` |
| `PUT /metrics/settings` | any of the settings of the `metrics` section; an empty or absent `otlp_password` keeps the secret, and an empty `otlp_username` removes it | `{"settings"}` |
A change is made, checked and applied as for `/site-to-site`.
### The /remote-access API ### The /remote-access API
`/remote-access` shows and changes the remote access systems, IKEv2 road-warrior (`p2s`), `/remote-access` shows and changes the remote access systems, IKEv2 (`p2s`),
OpenVPN (`openvpn`) and WireGuard (`wireguard`), with the same token as `/users`. OpenVPN (`openvpn`) and WireGuard (`wireguard`), with the same token as `/users`.
| Request | Body | Answer | | Request | Body | Answer |
| --- | --- | --- | | --- | --- | --- |
| `GET /remote-access` | | `{"p2s", "openvpn", "wireguard", "running", "local_fqdn", "wireguard_server"}`; `wireguard_server` is `{"public_key", "endpoint", "allowed_ips"}`: the router's WireGuard public key (`null` before setup made it), `<wan.local_fqdn>:<wireguard.listen_port>` (`null` without a name) and the networks a peer reaches through the tunnel, the router's local CIDRs and the WireGuard network | | `GET /remote-access` | | `{"p2s", "openvpn", "wireguard", "running", "local_fqdn", "wireguard_server"}`; `wireguard_server` is `{"public_key", "endpoint", "allowed_ips", "dns"}`: the router's WireGuard public key (`null` before setup made it), `<wan.local_fqdn>:<wireguard.listen_port>` (`null` without a name), the networks a peer reaches through the tunnel, the router's local CIDRs and the WireGuard network, and the addresses of the remote access clients' resolver (`[]` while it does not run) |
| `PUT /remote-access/<system>` | any of the settings of the section `p2s`, `openvpn` or `wireguard` | the section | | `PUT /remote-access/<system>` | any of the settings of the section `p2s`, `openvpn` or `wireguard` | the section |
`running` tells for each system whether setup runs it. IKEv2 and OpenVPN run once enabled with an `running` tells for each system whether setup runs it. IKEv2 and OpenVPN run once enabled with an
@@ -1558,9 +1627,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
@@ -1568,21 +1637,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 or OpenVPN 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 IKEv2, 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
``` ```
@@ -1597,7 +1676,7 @@ takes a sample of the router with `psutil`:
- the free and the total space of `/`; - the free and the total space of `/`;
- the bytes received and sent per second on the external and the internal interface; - the bytes received and sent per second on the external and the internal interface;
- the 1, 5 and 15 minute load averages; - the 1, 5 and 15 minute load averages;
- the VPN clients of each enabled VPN system: road-warrior (IPsec P2S) and OpenVPN clients, and - the VPN clients of each enabled VPN system: IKEv2 and OpenVPN clients, and
WireGuard peers with a handshake in the last 3 minutes; WireGuard peers with a handshake in the last 3 minutes;
- for each site-to-site connection that is set up: whether it is up (1 or 0), the bytes and the - for each site-to-site connection that is set up: whether it is up (1 or 0), the bytes and the
packets per second received and sent through its child SAs since the previous sample, and the packets per second received and sent through its child SAs since the previous sample, and the
+115
View File
@@ -1,3 +1,118 @@
vpn-router (0.35.0-1) unstable; urgency=medium
feature: vre opens files from the Router Manager's shell in the editor of its Shell page.
* vpn-router-editor FILE..., or vre, in the shell of the Router Manager
opens the files in the editor of the Shell page and returns at once;
outside that shell it exits with status 2.
* GET, PUT and DELETE /shell/editor/<session>/<id> read, save and close
a file opened so, as the shell's console account; a save is refused
with 409 when the file changed since it was read.
* The editor takes regular files of UTF-8 text up to 1 MiB, at most 20
per shell, while the shell lives.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Thu, 29 Oct 2026 18:02:27 +0100
vpn-router (0.34.0-1) unstable; urgency=medium
feature: the web service shows and changes the settings of the metrics service at /metrics/settings, and OpenVPN starts on a new router, as setup installs CRLs that revoke nothing.
* GET and PUT /metrics/settings show and change the metrics section;
otlp_password is never shown, an empty one keeps it, and an empty
otlp_username removes it.
* A CRL without revoked certificates is installed for strongSwan and
OpenVPN.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Wed, 28 Oct 2026 22:00:00 +0100
vpn-router (0.33.3-1) unstable; urgency=medium
fix: vpn-router-setup runs on a new installation once libpq can be found.
* The vpn-router-setup unit waits, with wait-for-libpq, until libpq is in
the linker cache before it runs setup.
* The package starts vpn-router-setup without waiting for it.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Wed, 28 Oct 2026 21:00:00 +0100
vpn-router (0.33.2-1) unstable; urgency=medium
fix: the router's IPsec remote access is called IKEv2 instead of road-warrior.
* The README, the package description, the installer's questions, the
status page, the messages of the web service and the examples say
IKEv2 access and IKEv2 clients; the strongSwan connection keeps its
name, road-warrior.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 23:00:00 +0100
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.
* POST /users/<name>/apple-profile and POST /account/apple-profile answer
an unsigned .mobileconfig with the P2S CA and the root CA, the user's
certificate and key as a PKCS#12 encrypted with the password of the
request, and an IKEv2 VPN to wan.local_fqdn with EAP-TLS and the
proposals of the road-warrior connection.
* A user's own profile needs the current password and, when enrolled, a
code.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 14:00:00 +0100
vpn-router (0.31.3-1) unstable; urgency=medium
fix: WireGuard clients may use the router's DNS resolver, which runs while WireGuard does, and /remote-access names its addresses for their configuration.
* The firewall opens port 53 of the remote access clients' resolver for
the WireGuard network, as it does for the IKEv2 and OpenVPN pools.
* The resolver runs while WireGuard is the only remote access enabled.
* GET /remote-access answers the resolver's addresses as
wireguard_server.dns.
* dns.listen may not share an interface with p2s.dns_listen while
WireGuard is enabled either.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 12:00:00 +0100
vpn-router (0.31.2-1) unstable; urgency=medium vpn-router (0.31.2-1) unstable; urgency=medium
fix: dnsmasq answers the remote access clients' DNS queries, so they no longer get the router's own host name with all its addresses from systemd-resolved. fix: dnsmasq answers the remote access clients' DNS queries, so they no longer get the router's own host name with all its addresses from systemd-resolved.
+2 -2
View File
@@ -46,10 +46,10 @@ Suggests: caddy,
pdns-server, pdns-server,
pdns-backend-pgsql, pdns-backend-pgsql,
proxmox-backup-client-static proxmox-backup-client-static
Description: Linux VPN router with IPSec, road-warrior, OpenVPN and WireGuard support Description: Linux VPN router with IPSec, IKEv2, OpenVPN and WireGuard support
Configures a Linux host or virtual machine as a VPN router: site-to-site Configures a Linux host or virtual machine as a VPN router: site-to-site
IKEv2 IPSec (strongSwan swanctl) with pre-shared keys or certificates, IKEv2 IPSec (strongSwan swanctl) with pre-shared keys or certificates,
optional road-warrior access using EAP-TLS, optional OpenVPN with a optional IKEv2 access using EAP-TLS, optional OpenVPN with a
certificate and a password per user, kept in a local LDAP directory, optional certificate and a password per user, kept in a local LDAP directory, optional
WireGuard, and the routing, NAT, MSS clamping and firewall rules a router WireGuard, and the routing, NAT, MSS clamping and firewall rules a router
needs. Its two-level CA, whose root may be kept offline, has issuing CAs for needs. Its two-level CA, whose root may be kept offline, has issuing CAs for
+2
View File
@@ -17,6 +17,8 @@ src/usr/lib/vpn-router/install-packages usr/lib/vpn-router/
src/usr/lib/vpn-router/webdav-auth usr/lib/vpn-router/ src/usr/lib/vpn-router/webdav-auth usr/lib/vpn-router/
src/usr/lib/vpn-router/openvpn-same-user usr/lib/vpn-router/ src/usr/lib/vpn-router/openvpn-same-user usr/lib/vpn-router/
src/usr/lib/vpn-router/openvpn-issuer usr/lib/vpn-router/ src/usr/lib/vpn-router/openvpn-issuer usr/lib/vpn-router/
src/usr/lib/vpn-router/editor-file usr/lib/vpn-router/
src/usr/bin/vpn-router-editor usr/bin/
src/usr/sbin/vpn-router-user usr/sbin/ src/usr/sbin/vpn-router-user usr/sbin/
src/usr/sbin/vpn-router-group usr/sbin/ src/usr/sbin/vpn-router-group usr/sbin/
src/usr/sbin/vpn-router-token usr/sbin/ src/usr/sbin/vpn-router-token usr/sbin/
+1
View File
@@ -0,0 +1 @@
usr/bin/vpn-router-editor usr/bin/vre
+4 -2
View File
@@ -71,9 +71,11 @@ case "$1" in
# setting the forward policy all belong to vpn-router-setup, which does # setting the forward policy all belong to vpn-router-setup, which does
# them only once the configuration says this host is a router. # them only once the configuration says this host is a router.
# The web service waits for libpq in the linker cache, which dpkg # The setup and web services wait for libpq in the linker cache, which
# refreshes after this script, so it is started without waiting for it. # dpkg refreshes after this script, so they are started without waiting
# for them.
if [ -d /run/systemd/system ]; then if [ -d /run/systemd/system ]; then
systemctl --no-block restart vpn-router-setup.service >/dev/null 2>&1 || true
systemctl --no-block restart vpn-router-web.service >/dev/null 2>&1 || true systemctl --no-block restart vpn-router-web.service >/dev/null 2>&1 || true
fi fi
;; ;;
+1 -1
View File
@@ -3,7 +3,7 @@
dh $@ dh $@
override_dh_installsystemd: override_dh_installsystemd:
dh_installsystemd --name=vpn-router-setup dh_installsystemd --name=vpn-router-setup --no-start
dh_installsystemd --name=vpn-router-docker dh_installsystemd --name=vpn-router-docker
dh_installsystemd --name=vpn-router-web --no-start dh_installsystemd --name=vpn-router-web --no-start
dh_installsystemd --name=vpn-router-metrics --no-enable --no-start dh_installsystemd --name=vpn-router-metrics --no-enable --no-start
+5 -5
View File
@@ -4,7 +4,7 @@ Choices: none, generic, azure, gcp
Default: none Default: none
Description: Platform: Description: Platform:
Which platform this router runs on. The choice loads platform-specific Which platform this router runs on. The choice loads platform-specific
additions, such as the provider DNS resolver for road-warrior clients. additions, such as the provider DNS resolver for IKEv2 clients.
. .
none defers configuration entirely: the package installs its files and none defers configuration entirely: the package installs its files and
changes nothing on the machine. Choose it when the router will be configured changes nothing on the machine. Choose it when the router will be configured
@@ -58,7 +58,7 @@ Type: string
Default: Default:
Description: Local router FQDN: Description: Local router FQDN:
Fully-qualified domain name of this router (for example Fully-qualified domain name of this router (for example
router.example.com). Used as the road-warrior server identity and router.example.com). Used as the IKEv2 server identity and
certificate common name. certificate common name.
Template: vpn-router/local_id_mode Template: vpn-router/local_id_mode
@@ -122,15 +122,15 @@ Description: Pre-shared key (PSK):
Template: vpn-router/p2s_enabled Template: vpn-router/p2s_enabled
Type: boolean Type: boolean
Default: false Default: false
Description: Enable road-warrior (P2S) access? Description: Enable IKEv2 access?
If enabled, this router accepts IKEv2 EAP-TLS connections from individual If enabled, this router accepts IKEv2 EAP-TLS connections from individual
clients, with certificates of the router's P2S issuing CA. clients, with certificates of the router's P2S issuing CA.
Template: vpn-router/p2s_address_pool Template: vpn-router/p2s_address_pool
Type: string Type: string
Default: Default:
Description: Road-warrior address pool: Description: IKEv2 address pool:
CIDR block assigned to road-warrior clients (for example 172.16.0.0/24). CIDR block assigned to IKEv2 clients (for example 172.16.0.0/24).
Template: vpn-router/pki_root_name Template: vpn-router/pki_root_name
Type: string Type: string
@@ -6,6 +6,7 @@ After=network-online.target docker.service postgresql.service
[Service] [Service]
Type=oneshot Type=oneshot
ExecStartPre=/usr/lib/vpn-router/wait-for-libpq
ExecStart=/usr/lib/vpn-router/setup ExecStart=/usr/lib/vpn-router/setup
RemainAfterExit=yes RemainAfterExit=yes
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/python3
"""Open files in the editor of the Router Manager's Shell page, from the shell of that page; vre is its short name.
Each file is named to the router's web service over SOCKET with the shell's session,
$VPN_ROUTER_SHELL, which the web service sets in the shells it starts; the Shell page then shows
the file in a tab of its editor, where it is read and saved. The command returns at once. A
relative path is taken from the current directory, and a file that does not exist is created when
it is saved. The exit status is 0 when every file was opened, 1 when the web service refused one,
with its reason, and 2 outside the Router Manager's shell or for an invalid request. -h or --help
shows the usage.
vre FILE...
"""
import argparse
import json
import os
import socket
import sys
#: The socket of the router's web service that opens files in the editor of a shell.
SOCKET = '/run/vpn-router/editor.sock'
#: Seconds to wait for the web service's answer.
TIMEOUT = 10
class Parser(argparse.ArgumentParser):
"""An argument parser whose errors end with status 2."""
def error(self, message):
print(f'{self.prog}: {message}; see {self.prog} --help', file=sys.stderr)
sys.exit(2)
def opened(session, path):
"""Ask the web service to open the file in the editor of the session; None, or the reason it refused."""
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
connection.settimeout(TIMEOUT)
connection.connect(SOCKET)
connection.sendall(json.dumps({'session': session, 'path': path}).encode() + b'\n')
answer = json.loads(connection.makefile('rb').readline() or b'{}')
return None if answer.get('ok') is True else str(answer.get('error', 'no answer'))
def main(argv):
prog = os.path.basename(sys.argv[0])
parser = Parser(prog=prog, description="Open files in the editor of the Router Manager's Shell page.")
parser.add_argument('files', nargs='+', metavar='FILE', help='a file to open')
options = parser.parse_args(argv)
session = os.environ.get('VPN_ROUTER_SHELL', '')
if not session:
print(f'{prog}: {prog} works only in the shell of the Router Manager', file=sys.stderr)
return 2
status = 0
for name in options.files:
path = os.path.abspath(os.path.expanduser(name))
try:
reason = opened(session, path)
except (OSError, ValueError) as exc:
reason = f'the web service cannot be reached: {exc}'
if reason:
print(f'{prog}: {path}: {reason}', file=sys.stderr)
status = 1
else:
print(f'Opened {path} in the editor of the Shell page.')
return status
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/python3
"""Read or write a file of the editor of the Router Manager's Shell page, as the user it runs as.
The web service runs it with systemd-run --uid=<console account> --pipe, outside its sandbox, so the
file is read and written with the rights of the shell's user. The version of a content is the
hexadecimal SHA-256 of its bytes; a missing file has the version of the empty content.
editor-file read PATH print the content of the file; nothing for a missing file
editor-file write PATH VERSION replace the content with standard input when the file still has
VERSION, writing in place, so its owner, mode and links stay;
a missing file is created
Only regular files of at most MAX_BYTES bytes of UTF-8 text are read and written. The exit status
is 0 on success, 2 for a file that is refused, with the reason on standard error, 3 when the file
does not have VERSION any more, and 1 for any other failure.
"""
import hashlib
import os
import sys
#: The largest file the editor opens, in bytes.
MAX_BYTES = 1024 * 1024
class Refused(Exception):
"""A file the editor does not open or write; status is the exit status."""
def __init__(self, message, status=2):
super().__init__(message)
self.status = status
def text(data, path):
"""The bytes when they are UTF-8 text of at most MAX_BYTES; Refused otherwise."""
if len(data) > MAX_BYTES:
raise Refused(f'{path} is larger than {MAX_BYTES // 1024} KiB')
try:
data.decode('utf-8')
except UnicodeDecodeError:
raise Refused(f'{path} is not UTF-8 text')
return data
def current(path):
"""The content of the file, b'' when it does not exist; Refused for a file that is not a regular one."""
if not os.path.lexists(path):
return b''
if not os.path.isfile(path):
raise Refused(f'{path} is not a regular file')
with open(path, 'rb') as handle:
return text(handle.read(MAX_BYTES + 1), path)
def main(argv):
if len(argv) < 2 or (argv[0], len(argv)) not in (('read', 2), ('write', 3)):
print('usage: editor-file read PATH | editor-file write PATH VERSION', file=sys.stderr)
return 2
path = argv[1]
try:
if argv[0] == 'read':
sys.stdout.buffer.write(current(path))
return 0
content = text(sys.stdin.buffer.read(MAX_BYTES + 1), path)
if hashlib.sha256(current(path)).hexdigest() != argv[2]:
raise Refused(f'{path} changed since it was read', 3)
with open(path, 'r+b' if os.path.lexists(path) else 'xb') as handle:
handle.write(content)
handle.truncate()
handle.flush()
os.fsync(handle.fileno())
except Refused as refused:
print(refused, file=sys.stderr)
return refused.status
except OSError as exc:
print(f'{exc.filename or path}: {exc.strerror}', file=sys.stderr)
return 1
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))
@@ -7,7 +7,7 @@ disables the unit.
Every SAMPLE_SECONDS, on the minute, a sample is taken with psutil: the CPU utilization since the Every SAMPLE_SECONDS, on the minute, a sample is taken with psutil: the CPU utilization since the
previous sample, the memory used and in total, the free and the total space of /, the bytes previous sample, the memory used and in total, the free and the total space of /, the bytes
received and sent per second on the external and the internal interface, and the load averages; received and sent per second on the external and the internal interface, and the load averages;
and the VPN clients of each enabled VPN system (vpnrouter_status): road-warrior (p2s) and and the VPN clients of each enabled VPN system (vpnrouter_status): IKEv2 (p2s) and
OpenVPN clients and WireGuard peers with a recent handshake; and of each site-to-site connection OpenVPN clients and WireGuard peers with a recent handshake; and of each site-to-site connection
that is set up, whether it is up, the bytes and packets per second through its child SAs since that is set up, whether it is up, the bytes and packets per second through its child SAs since
the previous sample, and how long its IKE SA has been up. The interfaces, the enabled systems and the previous sample, and how long its IKE SA has been up. The interfaces, the enabled systems and
+8 -6
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')
@@ -243,7 +243,8 @@ def filter_block(ctx):
lines += ['# OPENVPN RULES END'] lines += ['# OPENVPN RULES END']
pools = [pool for ready, pool in ((vpnrouter.p2s_ready(ctx), ctx['p2s_address_pool']), pools = [pool for ready, pool in ((vpnrouter.p2s_ready(ctx), ctx['p2s_address_pool']),
(vpnrouter.openvpn_ready(ctx), ctx['openvpn_pool'])) if ready] (vpnrouter.openvpn_ready(ctx), ctx['openvpn_pool']),
(vpnrouter.wg_ready(ctx), ctx['wg_network'])) if ready]
# The remote access clients' resolver answers on the addresses of p2s.dns_listen. # The remote access clients' resolver answers on the addresses of p2s.dns_listen.
if pools and ctx['p2s_resolver_addrs']: if pools and ctx['p2s_resolver_addrs']:
lines += ['', '# P2S DNS RULES START'] lines += ['', '# P2S DNS RULES START']
@@ -820,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):
@@ -953,6 +954,7 @@ def refresh_web(cfg, ctx):
'internal_address': ctx['int_addr'], 'internal_address': ctx['int_addr'],
'internal_gateway': ctx['int_gateway_ip'], 'internal_gateway': ctx['int_gateway_ip'],
'local_cidrs': ctx['local_cidr_list'], 'local_cidrs': ctx['local_cidr_list'],
'client_dns': ctx['p2s_resolver_addrs'] if vpnrouter.RESOLVER_CONF.exists() else [],
'site_to_site': [{ 'site_to_site': [{
'name': connection['name'], 'name': connection['name'],
'local_identity': connection['local_id'], 'local_identity': connection['local_id'],
@@ -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,26 +579,31 @@ 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)}')
sides = {'internal': {'internal'}, 'external': {'external'}, 'both': {'internal', 'external'}} sides = {'internal': {'internal'}, 'external': {'external'}, 'both': {'internal', 'external'}}
if dns['enabled'] and (_get(cfg, 'p2s', 'enabled') or _get(cfg, 'openvpn', 'enabled')) and \ remote_access = any(_get(cfg, system, 'enabled') for system in ('p2s', 'openvpn', 'wireguard'))
sides[dns['listen']] & sides[resolver_listen]: if dns['enabled'] and remote_access and sides[dns['listen']] & sides[resolver_listen]:
raise ConfigError(f'dns.listen: port 53 of the {" and ".join(sorted(sides[dns["listen"]] & sides[resolver_listen]))} ' raise ConfigError(f'dns.listen: port 53 of the {" and ".join(sorted(sides[dns["listen"]] & sides[resolver_listen]))} '
f'addresses is the resolver of the remote access clients (p2s.dns_listen {resolver_listen}) ' f'addresses is the resolver of the remote access clients (p2s.dns_listen {resolver_listen}) '
'while p2s.enabled or openvpn.enabled is true') 'while p2s.enabled, openvpn.enabled or wireguard.enabled is true')
for option in ('root_name', 'p2s_name', 's2s_name'): for option in ('root_name', 'p2s_name', 's2s_name'):
value = _get(cfg, 'pki', option) value = _get(cfg, 'pki', option)
@@ -990,9 +1001,12 @@ 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'),
'wg_network': str(ipaddress.ip_interface(_get(cfg, 'wireguard', 'address')).network)
if _get(cfg, 'wireguard', 'address') else '',
'wg_listen_port': _get(cfg, 'wireguard', 'listen_port'), 'wg_listen_port': _get(cfg, 'wireguard', 'listen_port'),
'wg_peers_begin': WG_PEERS_BEGIN, 'wg_peers_begin': WG_PEERS_BEGIN,
'wg_peers_end': WG_PEERS_END, 'wg_peers_end': WG_PEERS_END,
@@ -1049,7 +1063,7 @@ def routing_ready(ctx):
def p2s_ready(ctx): def p2s_ready(ctx):
"""Road-warrior access needs its settings and the router's VPN server certificate, which the P2S CA issues.""" """IKEv2 access needs its settings and the router's VPN server certificate, which the P2S CA issues."""
return bool(ctx['p2s_enabled'] and ctx['p2s_address_pool'] and ctx['local_fqdn'] and ctx['server_certificate']) return bool(ctx['p2s_enabled'] and ctx['p2s_address_pool'] and ctx['local_fqdn'] and ctx['server_certificate'])
@@ -1078,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'])
@@ -1236,7 +1250,7 @@ def configure(cfg=None, public_ip=None):
# its loopback stub only, so its records of the router's own name stay on the router. # its loopback stub only, so its records of the router's own name stay on the router.
if remove(P2S_FORWARDER): if remove(P2S_FORWARDER):
changed.add('resolved') changed.add('resolved')
if (p2s_ready(ctx) or openvpn_ready(ctx)) and ctx['p2s_resolver_addrs']: if (p2s_ready(ctx) or openvpn_ready(ctx) or wg_ready(ctx)) and ctx['p2s_resolver_addrs']:
if render(env, ctx, 'dnsmasq.conf.j2', RESOLVER_CONF, 0o644): if render(env, ctx, 'dnsmasq.conf.j2', RESOLVER_CONF, 0o644):
changed.add('resolver') changed.add('resolver')
elif remove(RESOLVER_CONF): elif remove(RESOLVER_CONF):
@@ -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',
'', '',
@@ -722,7 +722,7 @@ def install_crls(connection):
crls = {} crls = {}
for name, file in SWANCTL_CA_FILES.items(): for name, file in SWANCTL_CA_FILES.items():
entry = authority(connection, name) entry = authority(connection, name)
crls[name] = entry['crl'].public_bytes(serialization.Encoding.PEM) if entry and entry['crl'] else None crls[name] = entry['crl'].public_bytes(serialization.Encoding.PEM) if entry and entry['crl'] is not None else None
changed |= write(X509CRL_DIR / file, crls[name], 0o644) if crls[name] else remove(X509CRL_DIR / file) changed |= write(X509CRL_DIR / file, crls[name], 0o644) if crls[name] else remove(X509CRL_DIR / file)
if crls['root'] and crls['p2s']: if crls['root'] and crls['p2s']:
changed |= write(OPENVPN_CRL, crls['root'] + crls['p2s'], 0o644) changed |= write(OPENVPN_CRL, crls['root'] + crls['p2s'], 0o644)
@@ -10,7 +10,7 @@ adding one file here. Nothing in the core has to be edited.
A module may define any of: A module may define any of:
EXTRA_P2S_DNS str, added to the road-warrior pool 'dns' list EXTRA_P2S_DNS str, added to the 'dns' list of the IKEv2 address pool
EXTRA_LOCAL_TS str, added to 'local_ts' on both connections EXTRA_LOCAL_TS str, added to 'local_ts' on both connections
apply(ctx) called once the system has been configured, to apply apply(ctx) called once the system has been configured, to apply
platform-specific state. Must be idempotent. ctx is the platform-specific state. Must be idempotent. ctx is the
@@ -3,7 +3,7 @@
No platform-specific configuration is currently required. No platform-specific configuration is currently required.
The Azure platform resolver 168.63.129.16 is deliberately *not* advertised to The Azure platform resolver 168.63.129.16 is deliberately *not* advertised to
road-warrior clients and *not* added to the traffic selectors. Clients are IKEv2 clients and *not* added to the traffic selectors. Clients are
given the router's addresses of p2s.dns_listen as their DNS servers, where dnsmasq given the router's addresses of p2s.dns_listen as their DNS servers, where dnsmasq
forwards to the router's own systemd-resolved. That resolves forwards to the router's own systemd-resolved. That resolves
Azure-internal names without routing the wireserver address through the tunnel Azure-internal names without routing the wireserver address through the tunnel
@@ -2,7 +2,7 @@
No platform-specific configuration is currently required. No platform-specific configuration is currently required.
As on Azure, road-warrior clients are given the router itself as their DNS As on Azure, IKEv2 clients are given the router itself as their DNS
server and the router forwards through its own systemd-resolved, so the server and the router forwards through its own systemd-resolved, so the
metadata resolver does not belong in the pool or in the traffic selectors. metadata resolver does not belong in the pool or in the traffic selectors.
""" """
@@ -65,16 +65,22 @@ import fcntl
import grp import grp
import ipaddress import ipaddress
import json import json
import plistlib
import pwd import pwd
import re import re
import subprocess import subprocess
import time import time
import urllib.parse import urllib.parse
import uuid
import ldap import ldap
import ldap.filter import ldap.filter
import ldap.modlist import ldap.modlist
import pyotp import pyotp
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec, rsa
from cryptography.hazmat.primitives.serialization import pkcs12
import vpnrouter import vpnrouter
import vpnrouter_ldap import vpnrouter_ldap
@@ -807,6 +813,80 @@ def profile(connection, name):
return {'name': name, 'profile': text} return {'name': name, 'profile': text}
def apple_profile(connection, name, password):
"""The Apple configuration profile (.mobileconfig, unsigned) of the user's IKEv2 access: the root CA and
the P2S CA, the user's certificate and key as a PKCS#12 encrypted with the password, and the VPN.
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. 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)
if not isinstance(password, str) or not PASSWORD_MIN <= len(password) <= PASSWORD_MAX:
raise Failure(f'the password of the profile must have {PASSWORD_MIN} to {PASSWORD_MAX} characters', 2)
cfg = vpnrouter.load_config()
fqdn = cfg['wan']['local_fqdn'].strip()
if not cfg['p2s']['enabled'] or not fqdn:
raise Failure('IKEv2 access is not enabled', 2)
credentials = vpnrouter_pki.user_credentials(name)
if credentials is None:
raise Failure(f'user {name} has no certificate')
try:
chain = x509.load_pem_x509_certificates(vpnrouter_pki.bundle('p2s')['pem'].encode())
except vpnrouter_pki.Failure as failure:
raise Failure(str(failure), 4)
cert = x509.load_pem_x509_certificate(credentials[0])
key = serialization.load_pem_private_key(credentials[1], password=None)
encryption = (serialization.PrivateFormat.PKCS12.encryption_builder().kdf_rounds(2048)
.key_cert_algorithm(pkcs12.PBES.PBESv1SHA1And3KeyTripleDESCBC).hmac_hash(hashes.SHA1())
.build(password.encode()))
bundle = pkcs12.serialize_key_and_certificates(name.encode(), key, cert, None, encryption)
certificate_type = ('RSA' if isinstance(key, rsa.RSAPrivateKey)
else 'ECDSA384' if isinstance(key, ec.EllipticCurvePrivateKey) and key.curve.key_size == 384
else 'ECDSA256')
identifier = f'{".".join(reversed(fqdn.split(".")))}.vpn-router.{name}'
ids = {kind: str(uuid.uuid5(uuid.NAMESPACE_URL, f'{identifier}.{kind}')) for kind in ('profile', 'pkcs12', 'vpn')}
proposal = {'EncryptionAlgorithm': 'AES-256', 'IntegrityAlgorithm': 'SHA2-256', 'DiffieHellmanGroup': 14,
'LifeTimeInMinutes': 60}
payloads = [{'PayloadType': 'com.apple.security.root' if index == len(chain) - 1 else 'com.apple.security.pkcs1',
'PayloadVersion': 1, 'PayloadIdentifier': f'{identifier}.ca{index}',
'PayloadUUID': str(uuid.uuid5(uuid.NAMESPACE_URL, f'{identifier}.ca{index}')),
'PayloadDisplayName': vpnrouter_pki.common_name(item),
'PayloadContent': item.public_bytes(serialization.Encoding.DER)}
for index, item in enumerate(chain)]
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',
'IKEv2': {
'RemoteAddress': fqdn, 'RemoteIdentifier': fqdn, 'LocalIdentifier': name,
'AuthenticationMethod': 'Certificate', 'ExtendedAuthEnabled': 1, 'PayloadCertificateUUID': ids['pkcs12'],
'CertificateType': certificate_type,
'ServerCertificateIssuerCommonName': vpnrouter_pki.common_name(chain[0]), 'ServerCertificateCommonName': fqdn,
'IKESecurityAssociationParameters': {**proposal, 'LifeTimeInMinutes': 480},
'ChildSecurityAssociationParameters': proposal,
'EnablePFS': 0, 'DeadPeerDetectionRate': 'Medium', 'DisableMOBIKE': 0, 'DisableRedirect': 0,
},
})
document = {'PayloadType': 'Configuration', 'PayloadVersion': 1, 'PayloadIdentifier': identifier,
'PayloadUUID': ids['profile'], 'PayloadDisplayName': f'VPN Router {fqdn} ({name})',
'PayloadDescription': f'IKEv2 access of {name} to {fqdn}', 'PayloadRemovalDisallowed': False,
'PayloadContent': payloads}
return {'name': name, 'profile': plistlib.dumps(document).decode()}
def group_dn(name): def group_dn(name):
return f'cn={name},{vpnrouter_ldap.GROUPS}' return f'cn={name},{vpnrouter_ldap.GROUPS}'
@@ -1,6 +1,7 @@
#!/bin/sh #!/bin/sh
# Wait until libpq is in the dynamic linker cache, where psycopg looks it up. # Wait until libpq is in the dynamic linker cache, where psycopg looks it up.
# ExecStartPre= of vpn-router-web.service; TimeoutStartSec= of the unit bounds the wait. # ExecStartPre= of vpn-router-setup.service, vpn-router-web.service and vpn-router-metrics.service;
# TimeoutStartSec= of the unit bounds the wait.
reported= reported=
until ldconfig -p | grep -q '^[[:space:]]*libpq\.so\.5 '; do until ldconfig -p | grep -q '^[[:space:]]*libpq\.so\.5 '; do
+289 -46
View File
@@ -29,7 +29,7 @@ Pages:
/backup the backup settings, the last run and the local archives: show, change and run /backup the backup settings, the last run and the local archives: show, change and run
now (see backup()) now (see backup())
/remote-access /remote-access
the settings of IKEv2 road-warrior, OpenVPN and WireGuard access and whether each the settings of IKEv2, OpenVPN and WireGuard access and whether each
runs: show and change, applied by vpn-router-setup (see remote_access()) runs: show and change, applied by vpn-router-setup (see remote_access())
/ca the PKI: its settings, the root and issuing CAs, the certificates issued; change the /ca the PKI: its settings, the root and issuing CAs, the certificates issued; change the
settings, issue and revoke certificates, and take the certificate of an issuing CA settings, issue and revoke certificates, and take the certificate of an issuing CA
@@ -41,19 +41,24 @@ Pages:
a member of admins whose console login is on and who has a TOTP token: POST signs the user in again, unless a member of admins whose console login is on and who has a TOTP token: POST signs the user in again, unless
the user used a shell within web.shell_auth_timeout minutes, for a one-time ticket the user used a shell within web.shell_auth_timeout minutes, for a one-time ticket
(see shell_ticket()), GET with it is a WebSocket (see shell_ticket()), GET with it is a WebSocket
that carries the terminal (see Handler.shell()) that carries the terminal (see Handler.shell()); /shell/editor/<session>/<id> reads, saves and
closes a file the shell opened with vpn-router-editor (vre) over EDITOR_SOCKET (see
shell_editor() and editor_request())
/auth sign-in with a user's password and TOTP code or with a passkey, refresh and sign-out /auth sign-in with a user's password and TOTP code or with a passkey, refresh and sign-out
(see auth()) (see auth())
/metrics/history /metrics/history
the metrics the metrics service keeps, for a view: hour, day, month or year (see the metrics the metrics service keeps, for a view: hour, day, month or year (see
metrics_history()) metrics_history())
/logs the last lines of the journal of the router's units and of the files web.log_files lists /metrics/settings
the settings of the metrics service: show and change, applied by vpn-router-setup (see
metrics_settings())
/logs the last lines of the journal of the router's units and of the files web.log_files lists
(see logs()) (see logs())
/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
@@ -62,7 +67,7 @@ Pages:
loopback address, where Caddy passes the requests of the HTTPS site; POST /oidc/login counts loopback address, where Caddy passes the requests of the HTTPS site; POST /oidc/login counts
towards the limit of /auth calls towards the limit of /auth calls
/status/full, /config, /metrics/history, /postgresql, /users, /service-accounts, /groups, /site-to-site, /backup, /remote-access, /ca, /logs, /sites, /dns, /shell and /account need an HS256 JWT in an Authorization: Bearer header signed /status/full, /config, /metrics/history, /metrics/settings, /postgresql, /users, /service-accounts, /groups, /site-to-site, /backup, /remote-access, /ca, /logs, /sites, /dns, /shell and /account need an HS256 JWT in an Authorization: Bearer header signed
with vpnrouter_auth.WEB_KEY (see vpnrouter_auth.verify). A token of /auth names a user in sub; with vpnrouter_auth.WEB_KEY (see vpnrouter_auth.verify). A token of /auth names a user in sub;
/account serves that user, and the other paths need a member of vpnrouter_users.ADMIN_GROUP. A /account serves that user, and the other paths need a member of vpnrouter_users.ADMIN_GROUP. A
token of vpn-router-token has the admin claim and names no user; it may call every path but token of vpn-router-token has the admin claim and names no user; it may call every path but
@@ -79,7 +84,7 @@ default, the overall status, the time and the client's address (its address as f
below); standard also the package version, the installed version of vpn-router-manager below); standard also the package version, the installed version of vpn-router-manager
(manager_version, null when it is not installed) and the number of active and other services; full (manager_version, null when it is not installed) and the number of active and other services; full
whether each service runs, whether Docker is installed, how many of the site-to-site connections are established whether each service runs, whether Docker is installed, how many of the site-to-site connections are established
(site_to_site: connections and established, null when swanctl cannot be asked), and road-warrior (IPsec P2S), OpenVPN (site_to_site: connections and established, null when swanctl cannot be asked), and IKEv2, OpenVPN
and WireGuard. No configuration data, key or connection name is shown, and no address but the client's. and WireGuard. No configuration data, key or connection name is shown, and no address but the client's.
The service first initializes, without listening: it makes the signing key, the OpenID Provider's key The service first initializes, without listening: it makes the signing key, the OpenID Provider's key
@@ -184,7 +189,7 @@ BODY_LIMIT = 65536
#: The settings of a site_to_site connection that /site-to-site adds and changes. #: The settings of a site_to_site connection that /site-to-site adds and changes.
CONNECTION_SETTINGS = ('addrs', 'id', 'cidrs', 'local_cidrs', 'local_id_mode', 'auth', 'psk', 'remote_ca') CONNECTION_SETTINGS = ('addrs', 'id', 'cidrs', 'local_cidrs', 'local_id_mode', 'auth', 'psk', 'remote_ca')
#: The sections of the remote access systems /remote-access changes: IKEv2 road-warrior, OpenVPN and WireGuard. #: The sections of the remote access systems /remote-access changes: IKEv2, OpenVPN and WireGuard.
REMOTE_ACCESS = ('p2s', 'openvpn', 'wireguard') REMOTE_ACCESS = ('p2s', 'openvpn', 'wireguard')
#: The HTTP status for each status of a vpnrouter_users.Failure; any other is 500. #: The HTTP status for each status of a vpnrouter_users.Failure; any other is 500.
@@ -214,6 +219,23 @@ SHELL_CHECK_SECONDS = 60
#: The largest WebSocket message accepted from a client, in bytes. #: The largest WebSocket message accepted from a client, in bytes.
MESSAGE_LIMIT = 65536 MESSAGE_LIMIT = 65536
#: The socket vpn-router-editor (vre) names files on to open them in the editor of a shell (see editor_listen()).
EDITOR_SOCKET = '/run/vpn-router/editor.sock'
#: The helper that reads and writes a file of the editor as the shell's user.
EDITOR_FILE = '/usr/lib/vpn-router/editor-file'
#: The largest file the editor opens, in bytes, as editor-file allows; a body of PUT /shell/editor/... may hold its
#: content escaped in JSON.
EDITOR_MAX_BYTES = MIB
EDITOR_BODY_LIMIT = 2 * EDITOR_MAX_BYTES + BODY_LIMIT
#: The most files a shell has open in its editor at once.
EDITOR_FILES_MAX = 20
#: Seconds a reader of the editor socket and a run of editor-file may take.
EDITOR_TIMEOUT = 30
#: The GUID of RFC 6455 that the Sec-WebSocket-Accept of a handshake is made with. #: The GUID of RFC 6455 that the Sec-WebSocket-Accept of a handshake is made with.
WEBSOCKET_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11' WEBSOCKET_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
@@ -234,6 +256,10 @@ _shell_lock = threading.Lock()
#: The last use of a shell of each user, a sign-in of POST /shell or a keystroke: user -> monotonic time. #: The last use of a shell of each user, a sign-in of POST /shell or a keystroke: user -> monotonic time.
_shell_used = {} _shell_used = {}
#: The open shells by their session, which each shell has in VPN_ROUTER_SHELL: session -> {"user", "account", "uid",
#: "send", the function that sends a WebSocket frame to its client, "files": {file id: path}}.
_shell_sessions = {}
def read_json(path): def read_json(path):
"""The object in a JSON file, or {} when the file is missing or holds no object.""" """The object in a JSON file, or {} when the file is missing or holds no object."""
@@ -490,7 +516,7 @@ def text_of(status):
lines.append(f'Site to site: {"unknown" if established is None else established} of {connections} established') lines.append(f'Site to site: {"unknown" if established is None else established} of {connections} established')
if 'p2s' in status: if 'p2s' in status:
clients = status['p2s']['clients'] clients = status['p2s']['clients']
lines.append(f'Road-warrior (IPsec P2S): {"unknown" if clients is None else f"{clients} connected"}') lines.append(f'IKEv2: {"unknown" if clients is None else f"{clients} connected"}')
if 'openvpn' in status: if 'openvpn' in status:
clients = status['openvpn']['clients'] clients = status['openvpn']['clients']
lines.append(f'OpenVPN: {"unknown" if clients is None else f"{clients} connected"}') lines.append(f'OpenVPN: {"unknown" if clients is None else f"{clients} connected"}')
@@ -565,7 +591,10 @@ def users(state, method, path, body):
POST /users/<name>/unlock end a lockout after failed logins POST /users/<name>/unlock end a lockout after failed logins
DELETE /users/<name> remove the user and revoke the certificate DELETE /users/<name> remove the user and revoke the certificate
GET /users/<name>/profile the OpenVPN client profile GET /users/<name>/profile the OpenVPN client profile
POST /users/<name>/otp a new TOTP secret and its otpauth URI; nothing is stored POST /users/<name>/apple-profile
the Apple configuration profile of IKEv2 access: {"password"}, which
encrypts the user's key in it; {"name", "profile"}
POST /users/<name>/otp a new TOTP secret and its otpauth URI; nothing is stored
PUT /users/<name>/otp store a TOTP secret: {"secret", "code"}, code a code of the secret PUT /users/<name>/otp store a TOTP secret: {"secret", "code"}, code a code of the secret
DELETE /users/<name>/otp remove the TOTP token DELETE /users/<name>/otp remove the TOTP token
DELETE /users/<name>/passkeys remove all the user's passkeys DELETE /users/<name>/passkeys remove all the user's passkeys
@@ -592,6 +621,8 @@ def users(state, method, path, body):
('POST', 3, 'unlock'): lambda connection: vpnrouter_users.unlock(connection, name), ('POST', 3, 'unlock'): lambda connection: vpnrouter_users.unlock(connection, name),
('DELETE', 2, None): lambda connection: vpnrouter_users.remove(connection, name), ('DELETE', 2, None): lambda connection: vpnrouter_users.remove(connection, name),
('GET', 3, 'profile'): lambda connection: vpnrouter_users.profile(connection, name), ('GET', 3, 'profile'): lambda connection: vpnrouter_users.profile(connection, name),
('POST', 3, 'apple-profile'): lambda connection: vpnrouter_users.apple_profile(connection, name,
body.get('password')),
('POST', 3, 'otp'): lambda connection: vpnrouter_users.otp_new(connection, name), ('POST', 3, 'otp'): lambda connection: vpnrouter_users.otp_new(connection, name),
('PUT', 3, 'otp'): lambda connection: vpnrouter_users.otp_enrol(connection, name, body.get('secret'), body.get('code')), ('PUT', 3, 'otp'): lambda connection: vpnrouter_users.otp_enrol(connection, name, body.get('secret'), body.get('code')),
('DELETE', 3, 'otp'): lambda connection: vpnrouter_users.otp_remove(connection, name), ('DELETE', 3, 'otp'): lambda connection: vpnrouter_users.otp_remove(connection, name),
@@ -888,9 +919,10 @@ def backup(method, path, body):
def wireguard_server(cfg, state): def wireguard_server(cfg, state):
"""What a WireGuard peer's configuration needs of the router: {public_key, endpoint, allowed_ips}; public_key is """What a WireGuard peer's configuration needs of the router: {public_key, endpoint, allowed_ips, dns}; public_key is
null before setup made the key pair, endpoint, <wan.local_fqdn>:<wireguard.listen_port>, null without a name, and null before setup made the key pair, endpoint, <wan.local_fqdn>:<wireguard.listen_port>, null without a name,
allowed_ips the networks reached through the tunnel, the router's local CIDRs and the WireGuard network.""" allowed_ips the networks reached through the tunnel, the router's local CIDRs and the WireGuard network, and dns the
addresses of the remote access clients' resolver, [] while it does not run."""
try: try:
public_key = vpnrouter.WG_PUBLIC_KEY.read_text(encoding='utf-8').strip() or None public_key = vpnrouter.WG_PUBLIC_KEY.read_text(encoding='utf-8').strip() or None
except OSError: except OSError:
@@ -901,8 +933,10 @@ def wireguard_server(cfg, state):
allowed = [cidr for cidr in networks if isinstance(cidr, str)] if isinstance(networks, list) else [] allowed = [cidr for cidr in networks if isinstance(cidr, str)] if isinstance(networks, list) else []
if address: if address:
allowed.append(str(ipaddress.ip_interface(address).network)) allowed.append(str(ipaddress.ip_interface(address).network))
dns = section(state, 'network').get('client_dns')
return {'public_key': public_key, 'endpoint': f'{fqdn}:{cfg["wireguard"]["listen_port"]}' if fqdn else None, return {'public_key': public_key, 'endpoint': f'{fqdn}:{cfg["wireguard"]["listen_port"]}' if fqdn else None,
'allowed_ips': list(dict.fromkeys(allowed))} 'allowed_ips': list(dict.fromkeys(allowed)),
'dns': [address for address in dns if isinstance(address, str)] if isinstance(dns, list) else []}
def remote_access(state, method, path, body): def remote_access(state, method, path, body):
@@ -1040,8 +1074,15 @@ 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
the user's Apple configuration profile of IKEv2 access:
{"current_password", "current_code"?, "password"}, password encrypting the user's key in
it; {"name", "profile"}
A change but the removal of a passkey needs the current password and, for an enrolled user, a current code; a wrong 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. one is 403 and counts as a failed login. A password change revokes the user's refresh tokens.
""" """
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)):
@@ -1050,12 +1091,14 @@ def account(name, method, path, body):
route = (method, path.rstrip('/')) route = (method, path.rstrip('/'))
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/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/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'):
@@ -1065,6 +1108,10 @@ 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'):
document = vpnrouter_users.apple_profile(connection, name, body.get('password'))
elif route == ('POST', '/account/passkeys'): elif route == ('POST', '/account/passkeys'):
document = vpnrouter_passkeys.register_begin(connection, name) document = vpnrouter_passkeys.register_begin(connection, name)
elif route == ('PUT', '/account/passkeys'): elif route == ('PUT', '/account/passkeys'):
@@ -1243,44 +1290,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:
@@ -1305,6 +1377,39 @@ def metrics_history(view):
return 500, {'error': str(exc.code)} return 500, {'error': str(exc.code)}
def metrics_shown(settings):
"""The metrics section as /metrics/settings shows it: its settings but otlp_password, with otlp_password_set."""
return {**{option: value for option, value in settings.items() if option != 'otlp_password'},
'otlp_password_set': bool(settings['otlp_password'])}
def metrics_settings(method, body):
"""Answer a /metrics/settings request: the HTTP status and the JSON document.
GET /metrics/settings {"settings"}: the metrics section without otlp_password, with otlp_password_set
PUT /metrics/settings change any of the settings of the metrics section; an empty or absent otlp_password keeps
the current secret, and an empty otlp_username removes 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)):
return 400, {'error': 'the body must be a JSON object'}
if method in ('GET', 'HEAD'):
try:
return 200, {'settings': metrics_shown(vpnrouter.load_config()['metrics'])}
except SystemExit as exc:
return 500, {'error': str(exc.code)}
if method != 'PUT':
return 404, {'error': f'no route {method} /metrics/settings'}
body = dict(body or {})
if not body.get('otlp_password'):
body.pop('otlp_password', None)
if body.get('otlp_username') == '':
body['otlp_password'] = ''
changed = section_change('metrics', body)
return changed if isinstance(changed, tuple) else (200, {'settings': metrics_shown(changed)})
def changes_admins(connection, method, parts, body): def changes_admins(connection, method, parts, body):
"""Whether a /users, /service-accounts or /groups request creates, renames to, changes or removes ADMIN_GROUP, or """Whether a /users, /service-accounts or /groups request creates, renames to, changes or removes ADMIN_GROUP, or
removes a member of it.""" removes a member of it."""
@@ -1457,15 +1562,134 @@ def take_ticket(name, ticket):
return holder == name and expires > time.monotonic() return holder == name and expires > time.monotonic()
def shell_command(account, unit): def shell_command(account, unit, session):
"""The command that runs the login shell of the system user account in the transient unit, on its terminal. """The command that runs the login shell of the system user account in the transient unit, on its terminal, with
the shell's session in VPN_ROUTER_SHELL for vpn-router-editor.
setsid makes the terminal the controlling one of systemd-run, which passes the window size on; systemd-run starts setsid makes the terminal the controlling one of systemd-run, which passes the window size on; systemd-run starts
the shell outside the web service's sandbox, in a PAM login session of the user. the shell outside the web service's sandbox, in a PAM login session of the user.
""" """
return ['setsid', '--ctty', 'systemd-run', '--quiet', '--collect', '--pty', '--send-sighup', f'--unit={unit}', return ['setsid', '--ctty', 'systemd-run', '--quiet', '--collect', '--pty', '--send-sighup', f'--unit={unit}',
f'--uid={account.pw_name}', '--property=PAMName=login', f'--working-directory={account.pw_dir}', f'--uid={account.pw_name}', '--property=PAMName=login', f'--working-directory={account.pw_dir}',
'--setenv=TERM=xterm-256color', account.pw_shell or '/bin/sh', '-l'] '--setenv=TERM=xterm-256color', f'--setenv=VPN_ROUTER_SHELL={session}', account.pw_shell or '/bin/sh', '-l']
def editor_file(account, action, path, content=b'', version=''):
"""Run editor-file as the system user account outside the sandbox: read the file, or write content to it when it
still has version. The exit status of editor-file and its output, or its message on failure."""
command = ['systemd-run', '--quiet', '--collect', '--pipe', f'--uid={account}', EDITOR_FILE, action, path]
try:
result = subprocess.run(command + ([version] if action == 'write' else []), input=content, capture_output=True,
timeout=EDITOR_TIMEOUT)
except (OSError, subprocess.TimeoutExpired) as exc:
return 1, f'editor-file cannot be run: {exc}'.encode()
return result.returncode, result.stdout if result.returncode == 0 else result.stderr.strip() or \
f'editor-file ended with status {result.returncode}'.encode()
def shell_editor(name, method, parts, body):
"""Answer a request of a file open in the editor of one of the user's shells: the HTTP status and the JSON document.
GET /shell/editor/<session>/<id> {"path", "content", "version"}, version the SHA-256 of the content
PUT /shell/editor/<session>/<id> {"content", "version"} writes the content when the file still has version,
else 409; answers {"path", "version"}
DELETE /shell/editor/<session>/<id> closes the file
The file is read and written as the shell's console account (editor_file). 404 for a session that is not a shell of
the user or a file it does not have open.
"""
if len(parts) != 4:
return 404, {'error': f'no route {method} /{"/".join(parts)}'}
with _shell_lock:
session = _shell_sessions.get(parts[2])
path = session['files'].get(parts[3]) if session and session['user'] == name else None
account = session['account'] if path else None
if path is None:
return 404, {'error': 'no such file is open in a shell of yours'}
if method == 'DELETE':
with _shell_lock:
session['files'].pop(parts[3], None)
return 200, {'path': path, 'closed': True}
if method == 'GET':
status, output = editor_file(account, 'read', path)
if status != 0:
return (400 if status == 2 else 500), {'error': output.decode(errors='replace')}
return 200, {'path': path, 'content': output.decode(), 'version': hashlib.sha256(output).hexdigest()}
if method != 'PUT':
return 405, {'error': f'{method} is not allowed on a file of the editor'}
if not isinstance(body, dict) or not isinstance(body.get('content'), str) or not isinstance(body.get('version'), str):
return 400, {'error': 'the body must be {"content", "version"}'}
content = body['content'].encode()
if len(content) > EDITOR_MAX_BYTES:
return 413, {'error': f'the content is larger than {EDITOR_MAX_BYTES // 1024} KiB'}
status, output = editor_file(account, 'write', path, content, body['version'])
if status != 0:
return {2: 400, 3: 409}.get(status, 500), {'error': output.decode(errors='replace')}
log(f'editor of {name} saved {path} as {account}')
return 200, {'path': path, 'version': hashlib.sha256(content).hexdigest()}
def editor_request(connection):
"""Answer one request of vpn-router-editor on the editor socket: {"session", "path"} on a line opens the file in the
editor of the shell of the session, whose client is sent {"editor": "open", "session", "id", "path"} as a text
message.
The peer must run as the shell's console account and the path must be absolute. The answer is {"ok": true} or
{"error"} on a line.
"""
try:
connection.settimeout(EDITOR_TIMEOUT)
_, uid, _ = struct.unpack('iII', connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED,
struct.calcsize('iII')))
request = json.loads(connection.makefile('rb').readline(4096) or b'null')
if not isinstance(request, dict) or not isinstance(request.get('session'), str) or \
not isinstance(request.get('path'), str):
answer = {'error': 'the request must be {"session", "path"}'}
elif not os.path.isabs(request['path']):
answer = {'error': 'the path must be absolute'}
else:
with _shell_lock:
session = _shell_sessions.get(request['session'])
if session is None or session['uid'] != uid:
answer = {'error': 'no shell of the Router Manager has this session; vre works only in its shell'}
elif len(session['files']) >= EDITOR_FILES_MAX:
answer = {'error': f'the editor has {EDITOR_FILES_MAX} files open; close one first'}
else:
file_id = secrets.token_urlsafe(8)
session['files'][file_id] = request['path']
answer = None
if answer is None:
session['send'](TEXT, json.dumps({'editor': 'open', 'session': request['session'], 'id': file_id,
'path': request['path']}).encode())
log(f'editor of {session["user"]} opened {request["path"]}')
answer = {'ok': True}
connection.sendall(json.dumps(answer).encode() + b'\n')
except (OSError, ValueError, struct.error):
pass
finally:
connection.close()
def editor_listen():
"""Listen on EDITOR_SOCKET, which any local user may connect to, answering each connection in a thread with
editor_request; the socket of an earlier run is replaced. Does not return; a failure to listen is logged and the
editor stays closed."""
try:
if os.path.lexists(EDITOR_SOCKET):
os.unlink(EDITOR_SOCKET)
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
listener.bind(EDITOR_SOCKET)
os.chmod(EDITOR_SOCKET, 0o666)
listener.listen(16)
except OSError as exc:
log(f'the editor socket {EDITOR_SOCKET} cannot be opened: {exc}')
return
while True:
try:
connection, _ = listener.accept()
except OSError:
continue
threading.Thread(target=editor_request, args=(connection,), name='editor request', daemon=True).start()
def frame(opcode, payload): def frame(opcode, payload):
@@ -1630,8 +1854,10 @@ class Handler(BaseHTTPRequestHandler):
The ticket must come from POST /shell for the user named by the token. Binary messages of the client are its The ticket must come from POST /shell for the user named by the token. Binary messages of the client are its
keystrokes, a text message {"cols", "rows"} sets the window size, and binary messages of the server are the keystrokes, a text message {"cols", "rows"} sets the window size, and binary messages of the server are the
terminal's output. The shell ends after web.shell_idle_minutes without a keystroke, when it exits, when the terminal's output; a text message of the server {"editor": "open", "session", "id", "path"} opens a file the
client closes the connection, or when a check of the user (SHELL_CHECK_SECONDS) finds its console login off or shell named with vpn-router-editor (editor_request), which the client reads and saves with
/shell/editor/<session>/<id> (shell_editor) while the shell lives. The shell ends after web.shell_idle_minutes
without a keystroke, when it exits, when the client closes the connection, or when a check of the user (SHELL_CHECK_SECONDS) finds its console login off or
its TOTP token gone; the server then closes it with the reason. Each opening, end and refusal is logged its TOTP token gone; the server then closes it with the reason. Each opening, end and refusal is logged
with its reason. with its reason.
""" """
@@ -1668,9 +1894,10 @@ class Handler(BaseHTTPRequestHandler):
except KeyError: except KeyError:
return refuse(500, f'the system does not know the account {login}; is nslcd running?') return refuse(500, f'the system does not know the account {login}; is nslcd running?')
unit = f'vpn-router-shell-{secrets.token_hex(4)}' unit = f'vpn-router-shell-{secrets.token_hex(4)}'
session = secrets.token_urlsafe(24)
master, terminal = os.openpty() master, terminal = os.openpty()
try: try:
process = subprocess.Popen(shell_command(account, unit), stdin=terminal, stdout=terminal, stderr=terminal, process = subprocess.Popen(shell_command(account, unit, session), stdin=terminal, stdout=terminal, stderr=terminal,
env={'PATH': '/usr/sbin:/usr/bin:/sbin:/bin'}) env={'PATH': '/usr/sbin:/usr/bin:/sbin:/bin'})
except OSError as exc: except OSError as exc:
os.close(master) os.close(master)
@@ -1693,6 +1920,10 @@ class Handler(BaseHTTPRequestHandler):
with lock: with lock:
self.wfile.write(frame(opcode, payload)) self.wfile.write(frame(opcode, payload))
with _shell_lock:
_shell_sessions[session] = {'user': name, 'account': account.pw_name, 'uid': account.pw_uid, 'send': send,
'files': {}}
def receive(): def receive():
"""Pass the client's keystrokes and window sizes to the terminal until it closes the connection.""" """Pass the client's keystrokes and window sizes to the terminal until it closes the connection."""
try: try:
@@ -1745,6 +1976,8 @@ class Handler(BaseHTTPRequestHandler):
reason = 'the client closed the connection' reason = 'the client closed the connection'
except OSError: except OSError:
reason = 'the connection failed' reason = 'the connection failed'
with _shell_lock:
_shell_sessions.pop(session, None)
run('systemctl', 'stop', unit) run('systemctl', 'stop', unit)
try: try:
send(CLOSE, (1000).to_bytes(2, 'big') + reason.encode()[:120]) send(CLOSE, (1000).to_bytes(2, 'big') + reason.encode()[:120])
@@ -1773,7 +2006,8 @@ class Handler(BaseHTTPRequestHandler):
directory = next((name for name in ('users', 'service-accounts', 'groups', 'account', 'site-to-site', 'backup', directory = next((name for name in ('users', 'service-accounts', 'groups', 'account', 'site-to-site', 'backup',
'ca', 'shell', 'remote-access', 'logs', 'sites', 'dns') 'ca', 'shell', 'remote-access', 'logs', 'sites', 'dns')
if path == f'/{name}' or path.startswith(f'/{name}/')), None) if path == f'/{name}' or path.startswith(f'/{name}/')), None)
protected = path in ('/status/full', '/config', '/metrics/history', '/postgresql') or directory is not None protected = path in ('/status/full', '/config', '/metrics/history', '/metrics/settings', '/postgresql') or \
directory is not None
signing = path in ('/auth', '/auth/passkey', '/auth/passkey/complete', '/auth/refresh', '/auth/logout') signing = path in ('/auth', '/auth/passkey', '/auth/passkey/complete', '/auth/refresh', '/auth/logout')
oidc = (path == '/.well-known/openid-configuration' or path.startswith('/oidc/')) and \ oidc = (path == '/.well-known/openid-configuration' or path.startswith('/oidc/')) and \
self.server.server_address[0] == vpnrouter_net.LOOPBACK self.server.server_address[0] == vpnrouter_net.LOOPBACK
@@ -1782,8 +2016,9 @@ class Handler(BaseHTTPRequestHandler):
state = current() if protected or signing else {} state = current() if protected or signing else {}
claims = self.claims(state['key']) if protected and state['key'] else None claims = self.claims(state['key']) if protected and state['key'] else None
# The body of a file upload is read as a stream, after the request is allowed. # The body of a file upload is read as a stream, after the request is allowed.
payload = self.request_body() if (protected or signing) and self.command not in ('GET', 'HEAD') and \ payload = self.request_body(EDITOR_BODY_LIMIT if path.startswith('/shell/editor/') else BODY_LIMIT) \
not (files and self.command == 'PUT') else None if (protected or signing) and self.command not in ('GET', 'HEAD') and not (files and self.command == 'PUT') \
else None
if claims is not None: if claims is not None:
self.principal = 'admin' if claims.get('admin') is True else claims.get('sub') self.principal = 'admin' if claims.get('admin') is True else claims.get('sub')
if (protected or signing) and not state['key']: if (protected or signing) and not state['key']:
@@ -1815,8 +2050,14 @@ class Handler(BaseHTTPRequestHandler):
query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query) query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)
code, document = metrics_history(query.get('range', ['hour'])[0]) code, document = metrics_history(query.get('range', ['hour'])[0])
body, kind = json.dumps(document).encode() + b'\n', 'application/json' body, kind = json.dumps(document).encode() + b'\n', 'application/json'
elif path == '/metrics/settings':
code, document = metrics_settings(self.command, payload)
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif directory == 'shell' and (path, self.command) == ('/shell', 'GET'): elif directory == 'shell' and (path, self.command) == ('/shell', 'GET'):
return self.shell(claims.get('sub')) return self.shell(claims.get('sub'))
elif directory == 'shell' and path.startswith('/shell/editor/'):
code, document = shell_editor(claims.get('sub'), self.command, path.strip('/').split('/'), payload)
body, kind = json.dumps(document).encode() + b'\n', 'application/json'
elif directory == 'shell': elif directory == 'shell':
code, document = shell_ticket(claims.get('sub'), payload) if (path, self.command) == ('/shell', 'POST') \ code, document = shell_ticket(claims.get('sub'), payload) if (path, self.command) == ('/shell', 'POST') \
else (404, {'error': f'no route {self.command} {path}'}) else (404, {'error': f'no route {self.command} {path}'})
@@ -1903,14 +2144,15 @@ class Handler(BaseHTTPRequestHandler):
return b'' return b''
return self.rfile.read(size) if 0 < size <= BODY_LIMIT else b'' return self.rfile.read(size) if 0 < size <= BODY_LIMIT else b''
def request_body(self): def request_body(self, limit=BODY_LIMIT):
"""The request body as JSON, None when there is none, or the string 'invalid'.""" """The request body as JSON, None when there is none, or the string 'invalid', also when it is larger than
limit bytes."""
length = self.headers.get('Content-Length') length = self.headers.get('Content-Length')
if not length: if not length:
return None return None
try: try:
size = int(length) size = int(length)
if not 0 <= size <= BODY_LIMIT: if not 0 <= size <= limit:
return 'invalid' return 'invalid'
return json.loads(self.rfile.read(size) or b'null') return json.loads(self.rfile.read(size) or b'null')
except ValueError: except ValueError:
@@ -1976,6 +2218,7 @@ def initialize():
def main(): def main():
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
initialize() initialize()
threading.Thread(target=editor_listen, name='editor socket', daemon=True).start()
try: try:
vpnrouter_serve.serve(lambda: vpnrouter_net.web_listen(read_json(CONFIG_FILE), DEFAULT_PORT), Handler, log, vpnrouter_serve.serve(lambda: vpnrouter_net.web_listen(read_json(CONFIG_FILE), DEFAULT_PORT), Handler, log,
DISCOVERY_SECONDS) DISCOVERY_SECONDS)
@@ -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 %}
} }
} }
+3 -3
View File
@@ -45,7 +45,7 @@ variable "vm_size" {
} }
variable "local_fqdn" { variable "local_fqdn" {
description = "FQDN of the router, used as the road-warrior/IKE identity and, when dns_zone_id is set, as the name of the A record created for it." description = "FQDN of the router, used as the IKE identity and, when dns_zone_id is set, as the name of the A record created for it."
type = string type = string
} }
@@ -89,13 +89,13 @@ variable "psk" {
} }
variable "p2s_enabled" { variable "p2s_enabled" {
description = "Enable road-warrior (P2S) access." description = "Enable IKEv2 access."
type = bool type = bool
default = false default = false
} }
variable "p2s_address_pool" { variable "p2s_address_pool" {
description = "CIDR block assigned to road-warrior clients." description = "CIDR block assigned to IKEv2 clients."
type = string type = string
default = "" default = ""
} }
+3 -3
View File
@@ -37,7 +37,7 @@ variable "machine_type" {
} }
variable "local_fqdn" { variable "local_fqdn" {
description = "FQDN of the router, used as the road-warrior/IKE identity and, when dns_managed_zone is set, as the name of the A record created for it." description = "FQDN of the router, used as the IKE identity and, when dns_managed_zone is set, as the name of the A record created for it."
type = string type = string
} }
@@ -86,13 +86,13 @@ variable "psk" {
} }
variable "p2s_enabled" { variable "p2s_enabled" {
description = "Enable road-warrior (P2S) access." description = "Enable IKEv2 access."
type = bool type = bool
default = false default = false
} }
variable "p2s_address_pool" { variable "p2s_address_pool" {
description = "CIDR block assigned to road-warrior clients." description = "CIDR block assigned to IKEv2 clients."
type = string type = string
default = "" default = ""
} }