Compare commits

...
24 Commits
Author SHA1 Message Date
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
slawek e14bbe2ba1 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.
Release / release (push) Successful in 13s
2026-10-26 16:53:42 +01:00
slawek dabe4e8365 fix: the remote access clients' resolver answers on the internal address by default, chosen by p2s.dns_listen, so the name server can take port 53 of the external addresses.
Release / release (push) Successful in 36s
2026-10-26 16:11:08 +01:00
slawek ea8ca97417 feature: Caddy serves static web sites from folders on the router, defined in their own database, with files uploaded through the web service or WebDAV, and an optional PowerDNS name server answers for a zone of the sites.
Release / release (push) Successful in 21s
2026-10-26 15:27:28 +01:00
slawek 77bdbcb058 feature: the web service serves the last lines of the journal of the router's units and of the log files web.log_files lists, at /logs.
Release / release (push) Successful in 29s
2026-10-26 12:37:51 +01:00
slawek cb33b3bfa1 feature: vpn-config-edit set and remove take --restart, which applies the change with vpn-router-setup and waits for it.
Release / release (push) Successful in 25s
2026-10-26 11:48:47 +01:00
slawek 81b7f4cac6 feature: bash completes the vpn-* commands, their options and the names they take.
Release / release (push) Successful in 12s
2026-10-26 11:35:46 +01:00
slawek 8cac21845a feature: the web service is an OpenID Provider, which signs the directory's users in to other systems, the clients being service accounts with redirect URIs.
Release / release (push) Successful in 13s
2026-10-26 11:15:12 +01:00
slawek d1d565b1d9 fix: a service account may be named by a GUID in lowercase, as the Router Manager proposes for a new one.
Release / release (push) Successful in 14s
2026-10-26 09:11:13 +01:00
slawek e675540ce2 fix: the package installs vpnrouter_passkeys, without which the web service and vpn-router-user do not start.
Release / release (push) Successful in 20s
2026-10-26 08:40:24 +01:00
slawek 90db6d46a2 feature: users sign in to the Router Manager with passkeys, and console login needs a TOTP token, so a user without one is refused the shell, SSH and sudo.
Release / release (push) Successful in 14s
2026-10-26 08:35:10 +01:00
slawek 91cc64d6a6 fix: a shell opens without the password and code again while the user's last use of a shell is within web.shell_auth_timeout minutes.
Release / release (push) Successful in 13s
2026-10-26 07:56:23 +01:00
slawek a1f314d8c1 fix: /config gives the state of caddy.service with the installed Caddy, for the Router Manager's Web page.
Release / release (push) Failing after 17m18s
2026-10-26 07:18:39 +01:00
slawek 7d8892b8c4 fix: wireguard.address must be the router's own address in its network, not the network or broadcast address, from which the peers' addresses are told apart.
Release / release (push) Failing after 16m1s
2026-10-26 02:59:56 +01:00
slawek 4491377d9b fix: /remote-access gives the router's WireGuard public key, endpoint and allowed IPs, which a peer's configuration needs.
Release / release (push) Successful in 13s
2026-10-26 02:50:39 +01:00
slawek f1d9c3bc36 feature: a VPN user has WireGuard peers, each a device with its public key and IPs, kept in the directory and written into wg0.conf by setup.
Release / release (push) Successful in 13s
2026-10-26 02:33:58 +01:00
slawek 015fba50ca fix: the status gives the number of connected OpenVPN clients at most 5 seconds old instead of up to a minute.
Release / release (push) Successful in 22s
2026-10-26 02:10:38 +01:00
slawek 9255d56276 feature: /remote-access of the web service shows and changes the settings of IKEv2 road-warrior, OpenVPN and WireGuard access and whether each runs, for the Router Manager's Remote access page.
Release / release (push) Successful in 14s
2026-10-26 00:57:22 +01:00
slawek 07dbe91d0c fix: the shell of a user's own POSIX account opens on the first setup that sets up console login, and the journal records each refused shell with the reason.
Release / release (push) Successful in 20s
2026-10-26 00:43:55 +01:00
slawek ec93bc6a2d fix: the shell runs as the console account of each member of admins, a local account or the user's own POSIX account in the directory, which also signs in with SSH and sudo, instead of one web.shell_user.
Release / release (push) Successful in 28s
2026-10-26 00:21:13 +01:00
slawek 2e1eb89f52 feature: web.shell lets /shell of the web service open a login shell of web.shell_user over a WebSocket for the Router Manager's Shell page, after the user signs in again.
Release / release (push) Successful in 15s
2026-10-26 00:05:14 +01:00
slawek 1825e1efb1 fix: the status gives the number of site-to-site connections set up and established, not each connection by name.
Release / release (push) Successful in 28s
2026-10-25 23:44:05 +01:00
48 changed files with 5940 additions and 285 deletions

No files matched your search

+308 -2
View File
@@ -98,8 +98,8 @@ Context: OpenVPN needs user accounts with passwords, and the users are meant to
system of the router, including IKEv2 EAP-TLS and WireGuard, which an OIDC provider cannot
authenticate. Everything the router runs comes from the Ubuntu archive. slapd, its Python module
`python3-ldap` and the OpenVPN plugin `openvpn-auth-ldap` are all in the archive; the plugin
checks a password by binding as the user, so the hash scheme is slapd's alone. An OIDC provider
can read the directory through its LDAP connector.
checks a password by binding as the user, so the hash scheme is slapd's alone. The router's own
OpenID Provider (ADR-37) signs the directory's users in to other systems.
Decision: The users are entries in slapd. The package keeps its own database,
`dc=vpn-router,dc=local` in `/var/lib/ldap/vpn-router` (the path the slapd AppArmor profile
@@ -895,3 +895,309 @@ Consequences: `slapcat` writes the users and the PKI into every backup in one co
restore brings both back together. The PKI depends on slapd, as the users already do, and not on
PostgreSQL. The directory holds the CA keys, so it is as sensitive as the CA itself. The schema
grows by the package's PKI attributes and classes, which setup adds to slapd's configuration.
## ADR-33: The web service opens a shell for the Router Manager over a WebSocket
Context: The Router Manager's Shell page gives an administrator a terminal on the router in the
browser, with xterm.js, the terminal of VS Code. A terminal needs a full-duplex channel, which
xterm.js expects as a WebSocket. The web service runs in a sandbox (`NoNewPrivileges`,
`ProtectSystem=strict`, `ProtectHome`) that a shell must not inherit, and as root, which a shell
in a browser should not be. A stolen session cookie or access token alone must not give a shell.
Decision: `/shell` of the web service is off until `web.shell` is set on the router; the Router
Manager cannot set it. The shell runs as the console account of the signed-in user (ADR-34), so
it is never root and each administrator's shell is the administrator's own; a user without
console login gets none. `POST /shell` signs the user in again with the password
and TOTP code and gives a one-time ticket valid 30 seconds; a WebSocket on `GET /shell` with that
ticket gets the shell. Asking for the password and code at every opening of the page is tiresome,
so within `web.shell_auth_timeout` minutes of the user's last sign-in or keystroke a ticket is given
without them; the times are kept in the web service's memory only, so a restart asks again, and 0
keeps the sign-in at every opening. The WebSocket of RFC 6455 is implemented on the standard library inside the
web service's `http.server`, server side only, without extensions. `systemd-run --pty` starts the
shell in a transient unit with a PAM login session, outside the sandbox, and `setsid --ctty`
makes the terminal its controlling terminal so window sizes reach the shell. A shell idle for
`web.shell_idle_minutes` is closed, and the journal records every opening, end and refusal. The Router
Manager checks the session and the WebSocket's origin, and relays the bytes to the router after
both handshakes.
Consequences: Who can sign in as a member of `admins` and knows the password and the current code
can get a shell when the router allows one and the user's console login is on; a session alone
cannot. The ticket travels in the URL and appears in request logs, used up. The shell's sessions
show up as `vpn-router-shell-<id>` units and logind sessions of the console account. The shell's
traffic is as protected as the Router Manager's: HTTPS to Caddy, plain HTTP on the internal
network to the web service.
## ADR-34: Console login maps an administrator to a POSIX account
Context: A shell, SSH and sudo need a POSIX account. One shared system account for every
administrator hides who did what and shares one set of rights. The administrators are users of
the router's directory, with their passwords, TOTP tokens and lockout there; a second set of
local accounts would duplicate them. slapd listens on its local socket only. SSSD authenticates
only over TLS or LDAPS and has no `ldapi://`; nslcd talks `ldapi://` and checks a password by
binding as the user.
Decision: Console login is a property of a member of `admins`, `vpnRouterConsoleLogin`, that
only root on the router sets, as for the group `admins`. It runs the console as an existing local
account (`vpnRouterConsoleAccount`), or as the user's own POSIX account: the user's entry becomes a
`posixAccount` with a uid from 20000 up and the local group `vpn-router-console` as its group.
nslcd serves the POSIX accounts with console login on, and PAM checks their passwords with a bind,
so slapd's otp overlay and password policy apply as for the Router Manager: the password followed
by the code, and the lockout. Members of `vpn-router-console` get sudo with that password. SSH
accepts it only while `ssh.ldap_login` is set, in a `Match Group` drop-in of sshd. Switching console
login off keeps the `posixAccount`, hidden by nslcd's filter, so the uid and the home stay the
user's; leaving `admins` switches it off. A console reaches root through sudo, so a password alone
must not open one: console login needs a TOTP token. Switching it on is refused without one, and
the token is checked at use rather than only when it is switched on, because an administrator can
remove it later: the shell's ticket and every minute of an open shell check it, nslcd's
`pam_authz_search` requires `oathTOTPUser`, and removing the token ends the sessions of the user's
own POSIX account.
Consequences: Each administrator's shell, SSH session and sudo run as the administrator, logged
under the administrator's name. The directory becomes part of the system's name service through
nslcd, and the system knows only the accounts with console login on. The user types the code
after the password at the SSH and sudo prompts. An administrator whose console login was switched
on before the token was required is refused until enrolling. Homes are created at the first login and
are not removed with the user. A password is changed through the directory, not with `passwd`.
## ADR-35: WireGuard peers are the devices of the VPN users, kept in the directory
Context: The WireGuard endpoint could be switched on from the Router Manager, but its peers were
written by hand into `wg0.conf`, so nobody could connect without SSH. A peer is a P2S client like
an OpenVPN or IKEv2 user, and one person may have several devices.
Decision: A VPN user's entry holds the user's WireGuard peers in the multi-valued attribute
`vpnRouterWireGuardPeer`, one value `<public key> <IP>[,<IP>...]` per device; the router never
sees a private key. `PUT /users/<name>` replaces a user's peers after checking the keys, which
are unique, and the IPs, which do not overlap another peer's or cover the router's address. Setup
reads the peers of the users who are not disabled from the directory and writes them as a marked
block of `[Peer]` sections into `wg0.conf`; sections outside the block, added by hand, stay. A
change of the peers, and disabling, enabling or removing a user with peers, restart setup. The
Router Manager adds and removes peers on the WireGuard tab of Remote access management, where the
endpoint is, choosing the user for each.
Consequences: Peers follow their users: disabling a user drops the user's devices, and the
directory's backup holds them. A peer cannot exist without a VPN user. A change restarts
`wg-quick@wg0` through setup, which briefly interrupts the other peers.
## ADR-36: Passkeys sign in to the Router Manager and the OpenID Provider, verified by the router with python3-fido2
Context: Typing the password and a TOTP code at every sign-in to the Router Manager is tedious.
WebAuthn needs a browser, HTTPS and a domain name; OpenVPN, IKEv2, WireGuard, SSH and sudo have no
browser, and the sudo PAM module for security keys needs the key at the router. The router signs
users in (`/auth`) and keeps them in its directory. The standard library cannot read CBOR or COSE
keys; Ubuntu packages Yubico's python-fido2 in noble (1.1) and resolute (2.0), whose server API is
the same once the JSON mapping of WebAuthn Level 3 is switched on, which 2.0 always uses.
Decision: A passkey is a discoverable credential with user verification of the relying party
`wan.local_fqdn`, with the user name as its user handle, so the sign-in needs no name. The router
registers and verifies passkeys with python3-fido2 in `vpnrouter_passkeys`; the origin must be
HTTPS on that name, which python-fido2 checks. Each passkey is a JSON value of the multi-valued
`vpnRouterPasskey` on the user's entry, with the public key only. Registration needs the current
password and code, so a session alone cannot add a lasting way in; removal needs neither. A
passkey sign-in gives the same tokens as `/auth`; as it binds no password, the router checks that
the user is enabled, not locked and not a service account. The begun steps are kept in the web
service's memory for 2 minutes. The browser converts the options and answers with
`PublicKeyCredential.parseCreationOptionsFromJSON`, `parseRequestOptionsFromJSON` and `toJSON`.
The OpenID Provider's sign-in page (ADR-37) takes a passkey the same way.
Consequences: Passkeys are a convenience for the Router Manager and the OpenID Provider; the
password and code stay for everything else, the shell included. A changed `wan.local_fqdn` orphans every passkey. The
Router Manager on another name, or in development on `localhost`, cannot use them. Failed passkey
sign-ins do not count towards the directory's lockout, but the sign-in calls are rate limited.
Browsers without the JSON methods of WebAuthn Level 3 cannot add or use passkeys.
## ADR-37: The web service is an OpenID Provider built on python3-oauthlib; its clients are service accounts
Status: Accepted
Context: Other systems, such as dashboards, should sign the router's users in with the directory's
passwords, TOTP codes and passkeys, without a second user store. The Ubuntu archive has no
packaged identity provider. `python3-oauthlib` and `python3-jwt` are in main for noble and
resolute; oauthlib implements the OpenID Connect flows and leaves the checks, the storage and the
ID token's signature to the application. `python3-authlib` and `python3-jwcrypto` are in universe
only. The web service already checks passwords and passkeys, rate limits sign-ins and keeps
tokens in PostgreSQL, and Caddy serves it over HTTPS on `wan.local_fqdn`. ADR-3's HS256 key must
not leave the router, but a relying party verifies an ID token itself.
Decision: The web service is the OpenID Provider, in `vpnrouter_oidc`, while `oidc.enabled` is
true; its issuer is its HTTPS site, `https://<wan.local_fqdn>:<https.web_port>`, without the port
on 443, and it answers the provider's paths only on `127.0.0.1`, where Caddy passes them. Only
the authorization code flow with PKCE S256 and the refresh token grant are allowed. A client is a
service account with redirect URIs, kept with the account in the new attributes
`vpnRouterRedirectUri`, `vpnRouterPostLogoutRedirectUri` and `vpnRouterAllowedGroup` of
`vpnRouterAccount`: its name is the client ID and its password the secret, checked with a bind.
Allowed groups limit who may sign in; without them every user may. A registered client is
trusted, so there is no consent page. The sign-in page is the web service's own, a Jinja2
template with the password, the code and a passkey, so the provider works without the Router
Manager. ID tokens are RS256, signed with an RSA key the web service makes on its first run
(`/etc/vpn-router/oidc.key`), published as a JWK named by its RFC 7638 thumbprint; `sub` is the
entry's `entryUUID`. Codes, access and refresh tokens and sessions are kept as their SHA-256 in
the database of the refresh tokens, defined in `/usr/share/vpn-router/sql/oidc.sql` with
procedures and functions the module calls; a process loads the file at its first connection. The
operations that revoke the refresh tokens of `/auth` also revoke them, and every use of a code or
token checks the user and the client's allowed groups again.
Consequences: The package depends on `python3-oauthlib` and `python3-jwt`. The provider needs
HTTPS, a public name and the web service. Clients are managed like service accounts, by the CLI,
`/service-accounts` and the Router Manager; a group a client allows cannot be removed, and renaming
it renames it in the clients. The signing key does not rotate; replacing it invalidates every ID
token issued. Wrong client secrets lock the client like any account. The implicit and hybrid
flows, dynamic client registration and public clients without a secret are not offered.
## ADR-38: Bash completions are files per command in bash-completion's completions directory
Status: Accepted
Context: The `vpn-*` commands have many subcommands, options and names (users, groups, issuing
CAs, templates, settings) that are hard to remember. bash-completion's README recommends its
`completionsdir`, `/usr/share/bash-completion/completions` on Ubuntu, whose files it loads on the
first Tab of their command, and calls the eagerly loaded `/etc/bash_completion.d` not
recommended; it prefers the name `<command>.bash`, which noble's 2.11 and resolute's 2.16 both
look up.
Decision: The package installs `<command>.bash` for each `vpn-*` command into
`/usr/share/bash-completion/completions` and recommends `bash-completion`. The completions read
the names they offer from the commands themselves (`--json list`) or the package's Python
modules (the schema, the configuration, the PKI's templates and reasons), not from copies, and
send errors to `/dev/null`, so a user who may not read them gets the fixed words only.
Consequences: Completions follow the code without being edited for each new setting or name. A
Tab that offers names runs a command or Python, which takes a fraction of a second. The files
are not conffiles; an administrator overrides one in `/etc/bash_completion.d` or the user's own
completions directory.
## ADR-39: /logs shows the last lines of listed units and files, capped per request
Status: Accepted
Context: The Router Manager should show the router's logs without SSH. The web service runs as
root, so a path or a unit taken from a request could expose any file or journal on the router,
and a log of gigabytes cannot be sent whole to a browser.
Decision: `/logs` serves the journal of a fixed list of units, the package's own and the services
it runs, or the whole journal, read with `journalctl`, and the files `web.log_files` lists,
which `vpn-config-edit` sets; a unit or a path outside the lists is refused. A request answers
at most 1000 lines, 200 by default, and a file is read from its last MiB only. The answer is
read at each request; nothing is followed or kept.
Consequences: Only administrators read logs, and only those listed. Showing a new file needs a
configuration change, not code. Long histories stay with SSH and `journalctl`.
## ADR-40: Static web sites are served by the router's Caddy and defined in their own PostgreSQL database
Status: Accepted
Context: The router should host static web sites, each on its own port, with its own TLS and host
names told apart by SNI. Caddy already runs as the router's HTTPS proxy, configured by setup from a
template, and serves several hosts on one port by SNI. The configuration file holds the router's
settings; sites are many rows that change often and are edited by people who are not the router's
administrators. A separate role per database keeps a feature's data apart from the refresh tokens
and metrics.
Decision: A site is a row of the `sites` table in the database `vpn-router-sites`, created by the
web service like `vpn-router` with its own role and password file, and changed through
`vpnrouter_sites` only, by `/sites`, `vpn-router-site` and the Router Manager; the table and its
procedures are in `/usr/share/vpn-router/sql/sites.sql`. `sites.enabled` and the upload limit stay
in the configuration file. Setup reads the enabled sites at each run and adds a block per site to
the Caddyfile, served from `/var/lib/vpn-router/sites/<name>`, with TLS none, Let's Encrypt, an
issuing CA of `pki.issuing_cas` through Caddy's internal issuer, or a certificate and key kept
with the site; it opens each site's port on the interfaces of its `listen`. Caddy runs while HTTPS
is ready or a site is served. A site may not take a port of the router's own services or
`wan.local_fqdn`; sites sharing a port need host names, the same `listen` and the same kind of
TLS. A site's `editors` group may change its files besides the admins; renaming the group renames
it in the sites, and a group a site has cannot be removed.
Consequences: Sites need no change of the configuration file and are backed up as a database dump
with their folders. A site of Let's Encrypt needs its names to resolve to the router and port 80
open, which setup opens on the external interface. While the database cannot be read, setup leaves
the Caddyfile as it is. A site's folder is kept when the site is removed, unless asked otherwise.
## ADR-41: Site files are uploaded through the web service and WebDAV served by rclone
Status: Accepted
Context: Content must reach a site's folder. Caddy's WebDAV handler is a third-party module that
neither Ubuntu's nor Caddy's packages include; a custom build would leave apt. S3 servers that use
a plain folder are rclone 1.65 and later (`serve s3`; noble has 1.60) and versitygw, which has no
package. Azurite keeps blobs in its own format, which Caddy cannot serve. rclone 1.60 in the
archive serves WebDAV with an auth proxy program and combines folders into one tree. The web
service and the Router Manager pass small JSON bodies only.
Decision: The web service serves a site's files under `/sites/<name>/files/`: listing, download,
upload of a raw body streamed to disk up to `sites.upload_max_mb`, extraction of a zip archive
that refuses absolute paths, `..`, links and oversize content before writing, folders, renaming
and removal, every path resolved inside the site's folder. While `sites.webdav` is true,
`vpn-router-webdav.service` runs `rclone serve webdav` on `127.0.0.1:7446` behind Caddy at
`https://<wan.local_fqdn>:<sites.webdav_port>`; its auth proxy, `webdav-auth`, binds as the user
and gives an admin all sites and any other user the sites of its editors groups, one folder each.
rclone and PowerDNS are installed on demand from the archive by `vpn-router-install@<feature>`,
like Caddy.
Consequences: Uploads and WebDAV use the directory's passwords, a TOTP code appended for an
enrolled user. rclone writes a WebDAV upload to its cache first and to the folder a few seconds
later. S3 and Azure Blob clients are not served.
## ADR-42: The name server is PowerDNS with its PostgreSQL backend
Status: Accepted
Context: Sites get subdomains of a selected domain, delegated to the router, without a registrar
change per site. Ubuntu has BIND, Knot and PowerDNS; only PowerDNS's gpgsql backend reads records
from PostgreSQL, so changes are rows, without zone files or reloads. systemd-resolved's stub holds
127.0.0.53 and 127.0.0.54, and the remote access clients' resolver answered on the external
addresses, the addresses an authoritative server needs. The clients reach the internal address
just as well, through the tunnel when it is in `local.cidrs` (ADR-43).
Decision: While `dns.enabled` is true, setup runs `pdns.service` with
`/etc/powerdns/pdns.d/vpn-router.conf`, written before the packages are installed, on the
addresses of `dns.listen`, with the gpgsql backend on the database `vpn-router-dns` and PowerDNS's
own schema. It writes one zone, `dns.zone`, through the procedure `dns_apply` of
`/usr/share/vpn-router/sql/dns.sql`: SOA, NS, A and AAAA records of `dns.addresses` for the apex,
the name server and every site host name in the zone, and manual records administrators add; the
serial grows only when the records change. The remote access clients' resolver answers on the
addresses of the new setting `p2s.dns_listen`, by default the internal one; a `dns.listen` that
shares an interface with it is refused while P2S or OpenVPN is enabled.
Consequences: The zone follows the sites at each setup run. One zone only, without DNSSEC or
transfers; the delegation is made at the parent zone by hand.
## ADR-43: The remote access clients' resolver is dnsmasq, not systemd-resolved
Status: Accepted
Context: IKEv2 and OpenVPN clients were given the router as their DNS server, answered by
systemd-resolved through a `DNSStubListenerExtra` drop-in. resolved synthesizes records for the
machine's own host name, all its addresses, the Docker bridge and the tunnel addresses included,
and for `_gateway` and `localhost`, on every stub, and systemd 255 has no setting that turns this
off. Its extra stub also took port 53 of the addresses PowerDNS needs. dnsmasq-base is in main,
a single binary without a service of its own, and forwards to one upstream server.
Decision: `vpn-router-resolver.service` runs dnsmasq, bound to the addresses of `p2s.dns_listen`
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
only and stays the router's own resolver, with the upstream servers of the platform. Setup writes
`/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
not the router's synthesized ones. The package depends on dnsmasq-base. PowerDNS and the clients'
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 road-warrior 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.
+491 -54
View File
@@ -14,6 +14,8 @@ A Debian package that configures a Linux host or virtual machine as a VPN router
- firewall rules for the ports Docker publishes, when Docker is installed
- metrics of the router kept in PostgreSQL, served for an OpenTelemetry Collector and pushed
over OTLP
- static web sites served by Caddy, uploaded through the web service or WebDAV, and an optional
authoritative name server (PowerDNS) for a zone of the sites
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
@@ -113,8 +115,9 @@ must have the type shown below; an unknown section or setting is an error.
| `site_to_site` | | object | | The site-to-site connections, each a name and an object of the settings below |
| `p2s` | `enabled` | boolean | `false` | Road-warrior access |
| `p2s` | `address_pool` | string | | CIDR assigned to road-warrior clients |
| `p2s` | `dns_listen` | string | `internal` | The addresses of the resolver the road-warrior, OpenVPN and WireGuard clients are given: `internal`, `external` or `both` |
| `wireguard` | `enabled` | boolean | `false` | WireGuard endpoint |
| `wireguard` | `address` | string | | Address and prefix for `wg0`, for example `192.168.200.1/24` |
| `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 |
| `openvpn` | `enabled` | boolean | `false` | OpenVPN access for users |
| `openvpn` | `address_pool` | string | | IPv4 CIDR assigned to OpenVPN clients |
@@ -128,11 +131,27 @@ must have the type shown below; an unknown section or setting is an error.
| `web` | `database` | string | | A PostgreSQL connection string for the refresh tokens; without it, the `vpn-router` database on this host that the web service creates |
| `web` | `trusted_proxies` | list | the address the call came in on | Addresses whose `X-Forwarded-For` header names the client for the `/auth` rate limit and the Client IP of the status |
| `web` | `status_content` | string | `basic` | What `/` and `/status` show: `basic`, `standard` or `full` (see Status web service) |
| `web` | `shell` | boolean | `false` | `/shell` opens a login shell for a member of `admins` with console login (see The shell) |
| `web` | `shell_idle_minutes` | integer | `15` | Minutes without a keystroke after which a shell is closed, 1 to 1440 |
| `web` | `log_files` | list | | Absolute paths of the log files `/logs` shows, at most 32 (see The /logs API) |
| `web` | `shell_auth_timeout` | integer | `15` | Minutes after the last use of a shell in which a new shell opens without signing in again, 0 (always sign in) to 1440 |
| `ssh` | `ldap_login` | boolean | `false` | sshd accepts the directory password of the console accounts in the directory (see Console login and SSH) |
| `https` | `enabled` | boolean | `true` | Caddy serves the status web service and the Router Manager over HTTPS for `wan.local_fqdn` |
| `https` | `email` | string | | The e-mail address of the Let's Encrypt account |
| `https` | `web_port` | integer | `7444` | HTTPS port of the status web service |
| `https` | `manager_port` | integer | `7443` | HTTPS port of the Router Manager |
| `https` | `caddy_source` | string | `ubuntu` | Where Caddy is installed from: `ubuntu`, the Ubuntu archive, or `upstream`, Caddy's own repository (see HTTPS) |
| `oidc` | `enabled` | boolean | `false` | The OpenID Provider at the status web service's HTTPS site, which needs `web.enabled`, `https.enabled` and `wan.local_fqdn` (see OpenID Provider) |
| `sites` | `enabled` | boolean | `false` | Caddy serves the web sites (see Web sites) |
| `sites` | `upload_max_mb` | integer | `512` | The largest upload of a site's file or zip archive, in MiB, 1 to 10240 |
| `sites` | `webdav` | boolean | `false` | WebDAV of the sites' folders, which needs `https.enabled` and `wan.local_fqdn` (see WebDAV) |
| `sites` | `webdav_port` | integer | `7445` | HTTPS port of WebDAV |
| `dns` | `enabled` | boolean | `false` | The name server of `dns.zone`, which needs `dns.zone`, `dns.nameserver` and `dns.addresses` (see Name server) |
| `dns` | `zone` | string | | The zone the name server answers for |
| `dns` | `nameserver` | string | | The name server's host name |
| `dns` | `addresses` | list | | The public addresses of the zone's apex, the name server and the sites' host names |
| `dns` | `listen` | string | `external` | The interfaces the name server answers on: `internal`, `external` or `both` |
| `dns` | `ttl` | integer | `300` | TTL of the records, 60 to 86400 seconds |
| `pki` | `root_name` | string | `VPN Router Root CA` | Common name of the root CA, when the router creates it |
| `pki` | `root_days` | integer | `7300` | Days the root CA's certificate is valid |
| `pki` | `ca_days` | integer | `3650` | Days an issuing CA's certificate is valid |
@@ -234,9 +253,17 @@ an FQDN or an IPv4 or IPv6 address. They are written to the strongSwan configura
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`.
WireGuard peers are added by hand in `/etc/wireguard/wg0.conf`. The package owns the
`[Interface]` section of that file and rewrites it when the settings above change, but it
carries every `[Peer]` section across untouched.
WireGuard peers are P2S 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
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,
the IPs of two peers may not overlap, and no peer's IPs cover the router's own address of
`wireguard.address`. The package owns the `[Interface]` section of `/etc/wireguard/wg0.conf` and,
between the lines `# BEGIN` and `# END WireGuard peers of the users of the router's directory`, a
`[Peer]` section for each peer of a user who is not disabled; setup rewrites them when the
settings or the peers change, and a change of a user's peers, disabling, enabling or removing a
user with peers restarts setup. Every other `[Peer]` section, added by hand, is carried across
untouched. While the directory cannot be reached, the users' peers in the file stay as they are.
Every feature is independent. Leaving a group empty simply means that feature is not
configured, and its generated files are removed.
@@ -330,6 +357,7 @@ vpn-config-edit set web.port 7082
vpn-config-edit set web.trusted_proxies 127.0.0.1,10.0.0.4
vpn-config-edit set site_to_site.branch.addrs branch.example.net
vpn-config-edit remove site_to_site.branch
vpn-config-edit set --restart oidc.enabled true
```
`show` prints the file as indented JSON and does not change it. `set` takes a setting as
@@ -338,10 +366,29 @@ creates with the defaults when it does not exist, and converts the value to the
a whole number, `true` or `false`, a list separated by commas (an empty value is an empty list),
or a string; an unknown setting is refused. `remove site_to_site.NAME` removes a connection. It
checks the whole configuration as `vpn-router-setup` does, and only then writes the file,
indented, with mode 0600, and reminds that `vpn-router-setup` applies it. The status web service
indented, with mode 0600, and reminds that `vpn-router-setup` applies it; with `--restart`, `set`
and `remove` restart `vpn-router-setup` and wait until it has applied the change, and exit with
status 1 when it fails. The status web service
writes the file the same way, and a lock keeps the two from overwriting each other's change. A
refused request exits with status 2, any other failure with 1.
## Bash completion
Bash completes the commands, options and arguments of `vpn-router-user`, `vpn-router-group`,
`vpn-router-token`, `vpn-config-edit`, `vpn-router-backup`, `vpn-router-ca`, `vpn-router-caddy`
and `vpn-router-site` with the Tab key. The package installs a file per command in
`/usr/share/bash-completion/completions`, which the `bash-completion` package, recommended by
`vpn-router`, loads on the first Tab after the command. For root, who can read them, the
completions also offer the users and groups of the directory, the settings and site-to-site
connections of `vpn-router.json` with `true` and `false` for a boolean setting, the issuing CAs and
certificate templates, and the web sites; for other users only the fixed words.
```sh
sudo -i
vpn-config-edit set oidc.<Tab>
vpn-router-user passwd <Tab>
```
## Applying changes
```sh
@@ -355,7 +402,7 @@ firewall rules. It provisions the user directory in `slapd` while OpenVPN or roa
access is configured. It then starts `strongswan`, `wg-quick@wg0` and
`openvpn-server@vpn-router` if the corresponding feature is configured, reloading or
restarting them only when their inputs changed, and
restarts `systemd-resolved` only when the road-warrior DNS drop-in changed. Turning a
restarts the remote access clients' resolver only when its configuration changed. Turning a
feature off in the configuration stops its service and removes its files. Every step is
idempotent, so restarting with no changes does nothing.
@@ -588,8 +635,8 @@ certificate and key from `/etc/caddy/vpn-router-pki/<name>/`, readable by its gr
With `openvpn.enabled`, an `address_pool` and `wan.local_fqdn` set, `vpn-router-setup` runs
`openvpn-server@vpn-router` with `/etc/openvpn/server/vpn-router.conf`. The server uses the
router's VPN server certificate, the root and the P2S CA and their CRLs (see Certificates),
pushes routes to `local.cidrs` and the router as DNS server, and opens `openvpn.port` on the
external interface.
pushes routes to `local.cidrs` and the router's addresses of `p2s.dns_listen` as DNS servers, and
opens `openvpn.port` on the external interface.
A client needs both a certificate of the P2S CA and the password of the user the
certificate names:
@@ -615,10 +662,12 @@ is switched on:
`account` for a Manager-only account (see Managing users);
- the package's own schema, `cn=vpn-router,cn=schema,cn=config`, under the OID
`2.25.98477352595735834532404256531620013001` of the UUID arc: the auxiliary class
`vpnRouterAccount` with `MAY ( mail $ vpnRouterScope $ displayName $ description )`, which
Manager-only and service accounts have, and the attribute `vpnRouterScope`, authorization
scopes, of which `service` marks a service account; `displayName` and `description` are the
display name and the description of a service account;
`vpnRouterAccount`, which Manager-only and service accounts have, and the attribute
`vpnRouterScope`, authorization scopes, of which `service` marks a service account;
`displayName` and `description` are the display name and the description of a service
account, and `vpnRouterRedirectUri`, `vpnRouterPostLogoutRedirectUri` and
`vpnRouterAllowedGroup` the settings of a service account that is a client of the OpenID
Provider;
- `ou=groups` holds one `groupOfNames` per group, named by `cn`, with its members' DNs in
`member` and an optional `description`;
- `cn=user-manager,ou=services` is the account that changes users and groups; it may write
@@ -653,7 +702,8 @@ it is added:
- a service account, a Manager-only account whose `vpnRouterScope` is `service`, which another
system signs in with to use a router service, such as an OpenTelemetry Collector reading
`/metrics`; it cannot sign in to `/auth`. Besides its name, it has a display name and a
description. `add --service` adds one, and the web service manages them at `/service-accounts`,
description, and a service account with redirect URIs is a client of the OpenID Provider (see
OpenID Provider). `add --service` adds one, and the web service manages them at `/service-accounts`,
not at `/users`. Internal service accounts are those the router creates and manages for its own
services, display name and description included; at present that is the metrics service's, the
account `metrics.service_account` names while metrics are enabled (see Metrics), which
@@ -688,12 +738,12 @@ vpn-router-user otp-remove alice
| Command | Effect |
| --- | --- |
| `list` | The users with their kind, names and e-mail addresses, whether each is enabled or locked, has a certificate and is enrolled for TOTP |
| `list` | The users with their kind, names and e-mail addresses, whether each is enabled or locked, has a certificate and is enrolled for TOTP, the number of passkeys and the console login |
| `add <name> --first-name F --last-name L --email E` | Adds a VPN user and issues the certificate |
| `add <name> [--email E]` | Adds a Manager-only account, with the e-mail address when given; one name without the other two options is refused |
| `add <name> --service [--display-name N] [--description D] [--email E]` | Adds a service account, with its display name, description and e-mail address when given; the names are refused |
| `add <name> --service [--display-name N] [--description D] [--email E] [--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...]` | Adds a service account, with its display name, description, e-mail address and OpenID Provider client settings when given; the names are refused |
| `update <name> [--first-name F] [--last-name L] [--email E]` | Changes the names or the e-mail address |
| `update <name> [--display-name N] [--description D] [--email E]` | Changes the display name, the description or the e-mail address of a service account; `''` removes one |
| `update <name> [--display-name N] [--description D] [--email E] [--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...]` | Changes the display name, the description, the e-mail address or the OpenID Provider client settings of a service account; `''` removes one; a repeated client option gives all values of its kind |
| `passwd <name>` | Sets the password |
| `disable <name>`, `enable <name>` | Locks the user out of OpenVPN and `/auth`, and lets the user in again |
| `unlock <name>` | Ends a lockout after failed logins and clears the failure count; a disabled user stays disabled |
@@ -701,16 +751,20 @@ vpn-router-user otp-remove alice
| `profile <name>` | The OpenVPN client profile, with the CA, the certificate and the key inline |
| `otp-new <name>` | A new TOTP secret and its `otpauth://` URI, issued by `wan.local_fqdn`, for an authenticator app; nothing is stored |
| `otp-enrol <name> <code>` | Stores the secret read from standard input when the code is a current code of it, replacing a secret the user has |
| `otp-remove <name>` | Removes the user's TOTP secret |
| `otp-remove <name>` | Removes the user's TOTP secret and ends the sessions of the user's own POSIX account |
| `console <name> (--posix \| --account LOCAL \| --off)` | Switches the console login of a member of `admins` enrolled for TOTP on, as the user's own POSIX account or a local account, or off (see Console login and SSH) |
| `passkeys-remove <name>` | Removes all the user's passkeys (see Passkeys) |
A user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter; a
A user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter, and a
service account may instead be named by a GUID in lowercase, such as
`3f2b8c1e-9a4d-4e6f-8b2a-1c5d7e9f0a3b`; a
password has 8 to 128 characters; a first or last name has 1 to 64 printable characters; an
e-mail address has one `@`, no spaces and a dot in its domain; a TOTP secret is 32 base32
characters, as `otp-new` makes it, and a code 6 digits. Once enrolled, a user logs in to OpenVPN
and `/auth` with the password followed by a current code. In the answers `manager_only`, `service`
and `internal` tell the kind, and a Manager-only account has its names as `null`; `update` changes only its e-mail
address, and `--email ''` removes it. Changing a user's password, disabling, unlocking or removing the user also revokes its
refresh tokens of `/auth`. The exit status is 0 on success, 2 for an invalid request, 3 when the
refresh tokens of `/auth` and its codes, tokens and sessions of the OpenID Provider. The exit status is 0 on success, 2 for an invalid request, 3 when the
user does not exist, 4 when the user exists and 1 for any other failure.
When the Router Manager is installed with an administrator (its debconf questions
@@ -722,12 +776,85 @@ the file.
The certificate is also accepted by road-warrior (IKEv2 EAP-TLS) access, which checks
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 road-warrior 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 road-warrior
connection takes them.
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
A member of `admins` enrolled for TOTP may have console login: the Router Manager's Shell page
(see The shell), and, for the user's own POSIX account, SSH and sudo. Only root on the router
switches it, with `vpn-router-user console` or a token of `vpn-router-token`, as for the group
`admins`. Switching it on is refused without the enrolment, and a user whose enrolment is removed
is refused the shell, SSH and sudo until enrolling again; the user's open shells end within a
minute, and removing the enrolment ends the sessions of the user's own POSIX account:
```sh
vpn-router-user console alice --posix
vpn-router-user console bob --account azureuser
vpn-router-user console alice --off
vpn-config-edit set ssh.ldap_login true
```
- **Own POSIX account** (`--posix`): the user's entry becomes a `posixAccount` of the directory
with the user's name, a uid from 20000 up, the local group `vpn-router-console` as its group,
`/home/<name>` and `/bin/bash`. `nslcd` makes it known to the system over slapd's local socket
and checks its password by binding as the user, so the user types the password followed by a
current code, failed logins count towards the lockout, and a disabled user cannot sign in. The home is created at the first login and kept when the user is removed. Members of
`vpn-router-console` may run any command with sudo, with the same password. A local account of
the same name is refused.
- **Local account** (`--account`): the console runs as an existing local account with a uid from
1000 to 19999, such as the VM's admin user; its SSH keys and sudo rights stay as they are.
- **Off** (`--off`): the system no longer knows the user's own POSIX account, and its sessions
end; its uid is kept for the next time. Leaving `admins`, or removing or renaming the group,
switches the console login of the members off.
While `ssh.ldap_login` is `true`, setup lets sshd accept the password of the members of
`vpn-router-console` (`/etc/ssh/sshd_config.d/vpn-router.conf`); other accounts keep sshd's
settings. A password change is made in the Router Manager or with `vpn-router-user passwd`, not
with `passwd`.
### Passkeys
A user who is not a service account may sign in to the Router Manager and to the OpenID Provider
with a passkey instead of the password and code. Passkeys are for those sign-ins only: OpenVPN,
the shell, SSH and sudo keep the password and code. A passkey belongs to `wan.local_fqdn`, so the
browser offers it on the HTTPS addresses on that name, and passkeys need the name set; a passkey made
before the name changes is no longer offered. The authenticator checks the user (a PIN or a
fingerprint), and the passkey is kept by it or by the user's password manager.
A user adds a passkey on the Router Manager's Account page with the current password and code,
and removes it there. Each passkey is a value of `vpnRouterPasskey` on the user's entry, with its
public key, label and the time it was added; a user has at most 10. An administrator removes all
passkeys of a user on the Users page, through `/users`, or with:
```sh
vpn-router-user passkeys-remove alice
```
A disabled or locked user is refused a passkey sign-in too.
### Managing groups
`vpn-router-group` manages the groups. A group has a name, with the rules of a user name, an
optional description of at most 256 printable characters, and at least one member, each a user
of the directory. Groups record membership only; nothing on the router reads them. The output,
`--json`, `-h` and `--help` work as for `vpn-router-user`.
of the directory. The group `admins` grants the management of the router, and the allowed groups
of a client of the OpenID Provider admit their members to it; other groups record membership
only. Renaming a group renames it in the clients that allow it, and a group a client allows
cannot be removed. The output, `--json`, `-h` and `--help` work as for `vpn-router-user`.
```sh
vpn-router-group add ops --member alice --member bob --description 'Operations team'
@@ -744,7 +871,7 @@ vpn-router-group remove netops
| `list` | The groups with their members and descriptions |
| `add <name> --member U [--member U ...] [--description D]` | Adds the group with its members |
| `update <name> [--name N] [--description D]` | Renames the group or sets its description; `''` removes it |
| `remove <name>` | Removes the group; its members are kept |
| `remove <name>` | Removes the group; its members are kept; refused while a client of the OpenID Provider allows it |
| `member-add <name> <user>` | Adds the user to the group |
| `member-remove <name> <user>` | Takes the user out of the group; refused for the last member |
@@ -762,9 +889,16 @@ deploying.
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.
The shipped modules currently add nothing. Road-warrior clients are given the router
itself as their DNS server and the router forwards through its own `systemd-resolved`, so
no provider resolver has to be advertised or routed through the tunnel on any platform.
The shipped modules currently add nothing. Road-warrior, OpenVPN and WireGuard clients are given the
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
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
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
the internal address when it is inside `local.cidrs`, which they route through the tunnel. Port 53
of the other addresses stays free for the name server (see Name server).
The modules exist as the documented place for platform specifics if that changes.
A module may define any of the following, and takes the default for anything it omits:
@@ -804,15 +938,16 @@ 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 |
| `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, and the state of each site-to-site connection, 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), road-warrior, OpenVPN and WireGuard |
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
behind Caddy or a Router Manager on the router it is the browser's. `/status` names the content in
`content` and the Client IP in `client_ip`; `/` keeps the time and the link to `/status`.
The service first makes its signing key and the refresh tokens' database, retrying every 15
seconds, for example while PostgreSQL is not running yet, and logs each failure; then it listens.
The service first makes its signing key, the OpenID Provider's key and the refresh tokens'
database, retrying every 15 seconds, for example while PostgreSQL is not running yet, and logs
each failure; then it listens.
### Bootstrap diagnostics
@@ -843,17 +978,22 @@ curl http://10.0.0.4:17081/
| `/groups` | The groups of users; needs a token |
| `/site-to-site` | The site-to-site connections: list, add, change and remove; needs a token |
| `/account` | The account of the signed-in user: its details, its password and its TOTP enrolment; needs a user's token |
| `/logs` | The last lines of the journal of the router's units and of the files of `web.log_files`; needs a token |
| `/sites` | The web sites and their files; needs a token |
| `/dns` | The name server's settings and records; needs a token |
| `/.well-known/openid-configuration`, `/oidc/...` | The OpenID Provider, on `127.0.0.1` only, where Caddy passes the requests (see OpenID Provider) |
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
`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
`openvpn-server@vpn-router` when OpenVPN is; the number of connected
OpenVPN clients, from the server's status file, which OpenVPN rewrites every minute; 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
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.connections`, whether each
site-to-site connection that is set up is established (`null` when swanctl cannot be asked); the number of connected road-warrior (IPsec P2S) clients; and
(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
swanctl cannot be asked), without their names; the number of connected road-warrior (IPsec P2S) clients; and
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.
@@ -864,7 +1004,7 @@ curl http://10.0.0.4:7081/status
### Tokens and signing in
`/config`, `/users`, `/service-accounts`, `/groups`, `/site-to-site` and `/account` need a JWT in an `Authorization: Bearer` header, signed with
`/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
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
(404). A missing, expired or wrongly signed token, or one of another algorithm, gets 401 with
@@ -891,7 +1031,9 @@ vpn-router-token --json --days 30
| Request | Body | Answer |
| --- | --- | --- |
| `POST /auth` | `{"name", "password", "code"}`; `code` only for a user enrolled for TOTP | the tokens |
| `POST /auth` | `{"name", "password", "code"}`; `code` only for a user enrolled for TOTP | the tokens and `"name"` |
| `POST /auth/passkey` | `{}` | `{"id", "options"}`: a pending sign-in, valid 2 minutes, and the options of `navigator.credentials.get` in their JSON form |
| `POST /auth/passkey/complete` | `{"id", "credential"}`, `credential` the browser's answer as its `toJSON()` gives it | the tokens and `"name"`, the user whose passkey signed |
| `POST /auth/refresh` | `{"refresh_token"}` | new tokens; the refresh token given is used up |
| `POST /auth/logout` | `{"refresh_token"}` | `{"revoked": true}`, or `false` for a token not known |
@@ -900,7 +1042,9 @@ The tokens are `{"access_token", "token_type": "Bearer", "expires_in": 300, "ref
get 401 with the same message; a refresh token is refused once its user is disabled, locked or
removed. Changing a user's password, disabling, unlocking or removing the user revokes the
user's refresh tokens. Five failed logins lock the account for 15 minutes (see the user
directory). `/auth` and `/auth/refresh` take at most 10 calls a minute from one client address,
directory). A passkey that does not sign, is not the user's, or of a disabled, locked or service
account gets 401 too (see Passkeys). `/auth`, `/auth/passkey/complete` and `/auth/refresh` take
at most 10 calls a minute from one client address,
and answer 429 with `Retry-After` beyond that; for a call from one of `web.trusted_proxies`, by
default the address the call came in on, the client address is the last entry of
`X-Forwarded-For`.
@@ -922,6 +1066,36 @@ curl -H "Authorization: Bearer $TOKEN" http://10.0.0.4:7081/config
does with `web.status_content` `full`, whatever that setting says, so the Router Manager shows
every service and tunnel while the public status page shows less.
### The shell
While `web.shell` is `true`, `/shell` gives a member of `admins` whose console login is on and
who is enrolled for TOTP (see Console login and SSH) a login shell on the router, as the user's console account, for the
Router Manager's Shell page. It is off by default and switched on and off on the router only:
```sh
vpn-router-user console alice --posix
vpn-config-edit set web.shell true
```
| Request | Body | Answer |
| --- | --- | --- |
| `POST /shell` | `{"password", "code"}`; `{}` within `web.shell_auth_timeout` | `{"ticket", "expires_in": 30}` |
| `GET /shell?ticket=<ticket>` | a WebSocket handshake | the terminal |
`POST /shell` needs an access token of `/auth`, not one of `vpn-router-token`, and signs its user
in again: a wrong password or code gets 403 and counts as a failed login; it gets 403 too while
`web.shell` is `false`, the user's console login is off or the user is not enrolled for TOTP. Without a password it gives a ticket
while the user's last sign-in of `POST /shell` or keystroke in a shell is less than
`web.shell_auth_timeout` minutes ago, and `{"ticket": null}` otherwise. The ticket opens one
shell for the same user within 30 seconds. Over the WebSocket, binary messages of the client
are keystrokes, a text message `{"cols", "rows"}` sets the window size, and binary messages of
the router are the terminal's output. The shell is a PAM login session of the console account,
run by `systemd-run` in a transient unit `vpn-router-shell-<id>`; sudo works as for SSH. It is closed after `web.shell_idle_minutes` without a keystroke, when the shell
exits, when the connection closes, or within a minute of the user's console login being switched
off or the TOTP enrolment removed; the router then sends a close frame with the reason. The
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.
### The /config page
`/config` shows what `/` and `/status` leave out, for a client such as manage-ui:
@@ -934,7 +1108,7 @@ every service and tunnel while the public status page shows less.
| `wireguard` | The public key and port of `wg0`, and each peer's public key, endpoint, allowed IPs, handshake age and traffic; no private or preshared key |
| `docker` | `docker.exposed_ports` and the rules of the `VPN-ROUTER-DOCKER` chain |
| `postgresql` | `clusters`: each PostgreSQL cluster of `pg_lsclusters` with its version, name, port, status and data directory, and for one that is online its `settings`: `server_version`, `listen_addresses`, `port`, `max_connections`, `shared_buffers`, `effective_cache_size`, `work_mem`, `password_encryption`, `ssl` and `timezone`; `null` when `pg_lsclusters` fails |
| `caddy` | The installed Caddy: `version`, and `source`, `upstream` once Caddy's repository is configured and `ubuntu` otherwise; `null` when Caddy is not installed |
| `caddy` | The installed Caddy: `version`, `source`, `upstream` once Caddy's repository is configured and `ubuntu` otherwise, and `service`, the state of `caddy.service`; `null` when Caddy is not installed |
### The /postgresql page
@@ -945,6 +1119,68 @@ router:
- **A cluster that is not online:** `databases` and `size` are `null`.
- **Collection:** the web service reads it with `psql` as the `postgres` user at each request.
### The /logs API
`/logs`, with the same token as `/config`, shows the last lines of the journal and of log files,
read at each request:
| Request | Answer |
| --- | --- |
| `GET /logs` | `{"units", "files", "max_lines"}`: the units whose journal it shows, the files of `web.log_files` and the most lines one request answers, 1000 |
| `GET /logs/journal?unit=<unit>&lines=<n>` | `{"lines": [...]}`: the last lines of the unit's journal, or of the whole journal without `unit`, with ISO times |
| `GET /logs/file?path=<path>&lines=<n>` | `{"lines": [...]}`: the last lines of a file `web.log_files` lists |
The units are the package's own (`vpn-router-setup`, `-web`, `-metrics`, `-pki`, `-backup`,
`-caddy`, `-resolver`, `-webdav`), `vpn-router-manager`, `caddy`, `slapd`, `nslcd`, `postgresql`,
`strongswan`, `wg-quick@wg0`, `openvpn-server@vpn-router`, `ssh` and `pdns`. `lines` is 1 to 1000, 200 without it. A
file is read from at most its last MiB, so very long lines give fewer lines. A unit or a file not
in the lists is 404, as is a listed file that does not exist.
```sh
vpn-config-edit set web.log_files /var/log/syslog,/var/log/auth.log
```
### The /sites API
`/sites`, with the same token as `/config`, manages the web sites, with the module
`vpn-router-site` uses too, `/usr/lib/vpn-router/vpnrouter_sites.py`:
| Request | Answer |
| --- | --- |
| `GET /sites` | `{"settings", "sites"}`: the `sites` section and the sites, each with its settings but `private_key`, with `private_key_set`, `modified` and `directory` |
| `PUT /sites` | Changes any of the settings of the `sites` section |
| `POST /sites` | Adds a site: `{"name", ...}` with any of its settings; an existing name is 409 |
| `GET /sites/<name>` | The site |
| `PUT /sites/<name>` | Changes any of its settings; an empty or absent `private_key` keeps the key |
| `DELETE /sites/<name>?content=true` | Removes the site, with `content=true` also its folder |
| `GET /sites/<name>/files/<path>` | A folder: `{"path", "entries": [{"name", "folder", "size", "modified"}]}`; a file: the file, as an attachment |
| `PUT /sites/<name>/files/<path>` | Writes the body to the file, creating its folders; with `?unzip=true` the body is a zip archive extracted into the folder of the path: `{"path", "files"}` |
| `POST /sites/<name>/files/<path>` | `{"folder": true}` creates the folder; `{"rename": "<path>"}` moves the file or folder |
| `DELETE /sites/<name>/files/<path>` | Removes the file or folder |
The paths of `/files/` are relative to the site's folder and may not leave it; a `PUT` needs a
`Content-Length`. The files of a site may also be used by a member of its `editors` group. Invalid
settings are 400, a missing site 404.
```sh
curl -X PUT -H "Authorization: Bearer $TOKEN" --data-binary @site.zip \
'https://router.example.com:7444/sites/www/files/?unzip=true'
```
### The /dns API
`/dns`, with the same token as `/config`:
| 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 |
| `PUT /dns` | Changes any of the settings of the `dns` section |
| `POST /dns/records` | Adds a manual record: `{"name", "type", "content", "ttl"?}`; `name` is relative to `dns.zone`, `@` for the zone itself, or absolute with a final dot, and `ttl` is `dns.ttl` without it |
| `DELETE /dns/records?name=&type=&content=` | Removes the manual records of the name and type, only the one of `content` when it is given |
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.
### The /users API
`/users` manages users with the same token as `/config`, with the module `vpn-router-user` uses
@@ -953,18 +1189,21 @@ and answer bodies are JSON; a request body is at most 65536 bytes.
| Request | Body | Answer |
| --- | --- | --- |
| `GET /users` | | `{"users": [{"name", "manager_only", "service", "internal", "enabled", "locked", "certificate", "otp", "first_name", "last_name", "email"}, ...]}` |
| `GET /users` | | `{"users": [{"name", "manager_only", "service", "internal", "enabled", "locked", "certificate", "otp", "console", "wireguard_peers", "passkeys", "first_name", "last_name", "email"}, ...], "wireguard": {"enabled", "address"}}`; `console` is `null` or `{"enabled", "account", "local", "uid"}`, `wireguard_peers` a list of `{"public_key", "ips"}`, `passkeys` a list of `{"id", "label", "created"}`, and `wireguard` the endpoint's settings, whose network the peers' addresses come from |
| `POST /users` | `{"name", "password", "first_name", "last_name", "email"}` for a VPN user; `{"name", "password", "email"?}` for a Manager-only account | 201 and the user |
| `PUT /users/<name>` | any of `{"first_name", "last_name", "email"}`; for a Manager-only account only `{"email"}`, `""` removing it | the user |
| `PUT /users/<name>` | any of `{"first_name", "last_name", "email", "wireguard_peers"}`; `wireguard_peers`, `[{"public_key", "ips"}]`, replaces a VPN user's WireGuard peers (see Configuration); for a Manager-only account only `{"email"}`, `""` removing it | the user |
| `PUT /users/<name>/password` | `{"password": ...}` | the user |
| `POST /users/<name>/disable` | | the user |
| `POST /users/<name>/enable` | | the user |
| `POST /users/<name>/unlock` | | the user |
| `DELETE /users/<name>` | | `{"name", "removed": true}` |
| `GET /users/<name>/profile` | | `{"name", "profile"}`, the OpenVPN client profile |
| `POST /users/<name>/apple-profile` | `{"password"}` | `{"name", "profile"}`, the Apple configuration profile of IKEv2 road-warrior access (see Apple VPN profile) |
| `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 |
| `DELETE /users/<name>/otp` | | the user |
| `DELETE /users/<name>/passkeys` | | the user, without passkeys |
| `PUT /users/<name>/console` | `{"enabled", "account"?}`; only with a token of `vpn-router-token` | the user |
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
@@ -985,16 +1224,19 @@ curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
| Request | Body | Answer |
| --- | --- | --- |
| `GET /service-accounts` | | `{"service_accounts": [{"name", "display_name", "description", "internal", "enabled", "locked", "email", ...}, ...]}`, each as `/users` shows a user |
| `POST /service-accounts` | `{"name", "password", "display_name"?, "description"?, "email"?}` | 201 and the account |
| `PUT /service-accounts/<name>` | any of `{"display_name", "description", "email"}`; `""` removes one | the account |
| `GET /service-accounts` | | `{"service_accounts": [{"name", "display_name", "description", "internal", "enabled", "locked", "email", "redirect_uris", "post_logout_redirect_uris", "allowed_groups", ...}, ...], "oidc": {"enabled", "issuer"}}`, each as `/users` shows a user, and the issuer of the OpenID Provider, `null` while it is disabled |
| `POST /service-accounts` | `{"name", "password", "display_name"?, "description"?, "email"?, "redirect_uris"?, "post_logout_redirect_uris"?, "allowed_groups"?}` | 201 and the account |
| `PUT /service-accounts/<name>` | any of `{"display_name", "description", "email", "redirect_uris", "post_logout_redirect_uris", "allowed_groups"}`; `""` removes one of the first three, `[]` the values of the others | the account |
| `PUT /service-accounts/<name>/password` | `{"password": ...}` | the account |
| `POST /service-accounts/<name>/disable` | | the account |
| `POST /service-accounts/<name>/enable` | | the account |
| `POST /service-accounts/<name>/unlock` | | the account |
| `DELETE /service-accounts/<name>` | | `{"name", "removed": true}` |
A display name has at most 64 printable characters and a description at most 256. The internal
A display name has at most 64 printable characters and a description at most 256. The client
settings are lists of at most 16 values: URIs of `https`, or of `http` for `localhost`,
`127.0.0.1` or `::1`, without a fragment and of at most 1024 characters, and names of existing
groups (see OpenID Provider). The internal
service accounts, `internal` true, are only listed: every other request on one gets 403, as the
router manages them, their display name and description included. A failure is otherwise as for
`/users`, with 404 also for a name that is not a service account.
@@ -1010,8 +1252,13 @@ router manages them, their display name and description included. A failure is o
| `PUT /account/password` | `{"current_password", "current_code", "password"}` | the user |
| `POST /account/otp` | | `{"name", "secret", "uri"}`, a new TOTP secret; nothing is stored |
| `PUT /account/otp` | `{"current_password", "current_code", "secret", "code"}` | the user, once the code matches the secret |
| `POST /account/passkeys` | `{"current_password", "current_code"}` | `{"id", "options"}`: a pending registration, valid 2 minutes, and the options of `navigator.credentials.create` in their JSON form |
| `PUT /account/passkeys` | `{"id", "credential", "label"?}`, `credential` the browser's answer as its `toJSON()` gives it; `label` at most 64 characters, `Passkey` without one | the user |
| `DELETE /account/passkeys/<id>` | | the user |
| `POST /account/profile` | `{"current_password", "current_code"}` | `{"name", "profile"}`, the user's OpenVPN client profile |
| `POST /account/apple-profile` | `{"current_password", "current_code", "password"}` | `{"name", "profile"}`, the user's Apple configuration profile of IKEv2 road-warrior access (see Apple VPN profile) |
`current_code` is needed only for a user enrolled for TOTP. 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
accepted again, and a change right after signing in waits for the next code. A user enrols or
replaces the enrolment; removing it is left to `/users` and `vpn-router-user`. Changing the
@@ -1077,6 +1324,23 @@ The answer of `GET /backup` holds:
A change is made, checked and applied as for `/site-to-site`.
### The /remote-access API
`/remote-access` shows and changes the remote access systems, IKEv2 road-warrior (`p2s`),
OpenVPN (`openvpn`) and WireGuard (`wireguard`), with the same token as `/users`.
| Request | Body | Answer |
| --- | --- | --- |
| `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 |
`running` tells for each system whether setup runs it. IKEv2 and OpenVPN run once enabled with an
address pool, `wan.local_fqdn` set and the router's VPN server certificate issued by the P2S CA;
WireGuard once enabled with an address. A system enabled without its address pool or address is
refused. A change is made, checked and applied as for `/site-to-site`. The router's firewall
follows the settings; a cloud firewall in front of the router, such as an Azure network security
group, must allow the OpenVPN and WireGuard ports itself.
### The /ca API
`/ca` manages the PKI (see Certificates) with the same token as `/users`, with the module
@@ -1134,7 +1398,7 @@ it does not redirect to HTTPS.
package's drop-in `/usr/lib/systemd/system/caddy.service.d/vpn-router.conf` makes `caddy.service`
read; `caddy.service` does not start before the file exists, and `/etc/caddy/Caddyfile` is not
used. Setup starts Caddy, reloads it when the file changes, and stops it and removes the file when
HTTPS is off. In the firewall it opens port 80 on the external interface and the HTTPS ports on the
HTTPS is off and no web site is served (see Web sites). In the firewall it opens port 80 on the external interface and the HTTPS ports on the
internal and the external interface, and ports 80 and 443 on both when `pki.publish` has `http-80`
and `https-default`, on which Caddy serves only the CA files (see Certificates). Caddy calls both services on `127.0.0.1`, where they listen
whatever `web.listen` and the manager's `listen` say, and passes the client's address in
@@ -1175,6 +1439,175 @@ does on its own: remove the source and the key, run `apt-get update`, and instal
with `apt-get install --allow-downgrades caddy=<version>`, the version `apt-cache policy caddy`
lists for the archive.
## OpenID Provider
While `oidc.enabled` is `true`, the status web service is an OpenID Provider (OpenID Connect
Core 1.0) for the users of the directory. Its issuer is the web service's HTTPS site,
`https://<wan.local_fqdn>:<https.web_port>`, without the port when it is 443; the web service
answers it only on `127.0.0.1`, where Caddy passes the requests, so the provider is never reached
over plain HTTP. The setting needs `web.enabled`, `https.enabled` and `wan.local_fqdn`.
```sh
vpn-config-edit set --restart oidc.enabled true
curl https://router.example.com:7444/.well-known/openid-configuration
```
| Endpoint | Purpose |
| --- | --- |
| `GET /.well-known/openid-configuration` | The discovery document |
| `GET /oidc/jwks` | The public key ID tokens are signed with |
| `GET`, `POST /oidc/authorize` | The authorization code flow; the sign-in page while the browser has no session |
| `POST /oidc/token` | Exchanges a code or a refresh token for tokens |
| `GET`, `POST /oidc/userinfo` | The claims of the user of an access token |
| `GET /oidc/logout` | Ends the session (RP-initiated logout) |
A client is a service account with at least one redirect URI: its name is the `client_id` and its
password the `client_secret`, sent with HTTP Basic authentication (`client_secret_basic`) or in
the body of the token request (`client_secret_post`). The password is checked with a bind, so 5
wrong secrets lock the client for 15 minutes. Its settings are:
- redirect URIs, where the provider sends the browser back with a code, matched exactly; a URI
uses `https`, or `http` for `localhost`, `127.0.0.1` or `::1`;
- post-logout redirect URIs, where `/oidc/logout` may send the browser afterwards;
- allowed groups: only their members may sign in to the client; without any, every user who is
not a service account may.
```sh
echo 'cl1ent-s3cret' | vpn-router-user add 3f2b8c1e-9a4d-4e6f-8b2a-1c5d7e9f0a3b --service \
--display-name Grafana --redirect-uri https://grafana.example.com/login/generic_oauth \
--post-logout-redirect-uri https://grafana.example.com/login --allowed-group ops
vpn-router-user update 3f2b8c1e-9a4d-4e6f-8b2a-1c5d7e9f0a3b --allowed-group ''
```
The flow is the authorization code flow with PKCE, whose `code_challenge_method` must be `S256`;
the implicit and hybrid flows, and the password and client credentials grants, are refused. A
registered client is trusted, so a user is not asked for consent. A user signs in on the
provider's page with the name, the password and, when enrolled, the TOTP code, or with a passkey
(see Passkeys); failed sign-ins count towards the lockout, and `POST /oidc/login` takes at most
10 calls a minute from one client address, as `/auth` does. A
disabled, locked or removed user, a service account, and a user outside the client's allowed
groups cannot sign in, and the codes and tokens already issued to such a user stop working. The
sign-in starts a session, a cookie of the issuer's `/oidc/` path valid for 8 hours, so later
requests of any client go on without the page; `prompt=login` and `max_age` ask for a new sign-in,
and `prompt=none` answers `login_required` without a session.
| Scope | Claims |
| --- | --- |
| `openid` (required) | `sub`, the `entryUUID` of the user's entry |
| `profile` | `preferred_username` (the user name), `name`, `given_name`, `family_name` |
| `email` | `email` |
| `groups` | `groups`, the names of the user's groups |
The claims of the scopes granted are in the ID token and in the answer of `/oidc/userinfo`. ID
tokens are RS256 JWTs, valid for 5 minutes, with `auth_time` and `amr` (`pwd`, `pwd` and `otp`, or
`hwk` and `user` for a passkey), signed with an RSA key of 3072 bits that the web service makes on
its first run, `/etc/vpn-router/oidc.key` (root only), and named in the JWKS by its RFC 7638
thumbprint. A code is valid for 1 minute and used once, an access token for 5 minutes, and a
refresh token for 8 hours and used once: each refresh answers a new one with a new ID token. The
codes, tokens and sessions are kept, as their SHA-256, in the database of the refresh tokens of
`/auth`. Changing a user's password, disabling, unlocking or removing the user ends its sessions
and revokes its codes and tokens.
## Web sites
While `sites.enabled` is `true`, Caddy serves static web sites from folders on the router, each
`/var/lib/vpn-router/sites/<name>`. The sites are rows of the database `vpn-router-sites`, which
the web service creates on its first run with its own role and the password in
`/etc/vpn-router/sites-database.secret`; they are managed with `vpn-router-site`, `/sites` and the
Router Manager, each change applied by restarting `vpn-router-setup`. A site has:
| Setting | Meaning |
| --- | --- |
| `name` | 1 to 31 lowercase letters, digits and hyphens, starting with a letter; also the name of its folder |
| `enabled` | Whether Caddy serves it |
| `hosts` | The host names (or IP addresses) Caddy answers on the port, the names TLS clients send as SNI; none answers any name |
| `port` | The TCP port, 443 by default |
| `listen` | `internal`, `external` (the default) or `both`: the interfaces whose firewall opens the port |
| `tls` | `none` (plain HTTP), `acme` (Let's Encrypt, the default), `ca` (certificates of an issuing CA of `pki.issuing_cas`, named by `ca`) or `file` (`certificate` and `private_key`, PEM) |
| `index_file` | The file served for a folder, `index.html` by default |
| `spa` | Whether a path without a file is served the site's `index_file`, for single-page applications |
| `editors` | The group whose members may change the site's files besides the admins |
A site may not take a port of the router's own services (SSH, `web.port`, the HTTPS ports, the
metrics ports, the Router Manager's, `sites.webdav_port`, 7446, 53, the bootstrap ports, a TCP
`openvpn.port` and `docker.exposed_ports`) or `wan.local_fqdn`. Enabled sites on one port need host
names of their own, the same `listen` and the same kind of TLS (plain HTTP or HTTPS). A site of
`acme` needs host names, without wildcards or addresses, that resolve to the router, and port 80,
which setup opens on the external interface. The certificate of `file` must be the key's and name
the hosts; it is written to `/etc/caddy/vpn-router-sites/<name>` (root, group `caddy`, 0640). An
issuing CA of `ca` is given to Caddy as the ACME servers' CAs are, in `/etc/caddy/vpn-router-pki`;
its certificates are trusted by clients that trust the router's root. Caddy runs while HTTPS is
ready or a site is served; while the sites cannot be read, setup leaves the Caddyfile as it is.
```sh
vpn-config-edit set --restart sites.enabled true
vpn-router-site add www --host www.example.com --host example.com --spa
vpn-router-site add intranet --host intranet.example.lan --port 8443 --listen internal --tls ca --ca web \
--editors web-team
vpn-router-site update www --no-enabled
vpn-router-site remove intranet --content
```
`vpn-router-site remove` keeps the folder without `--content`. Renaming a group renames it in the
sites; a group a site has cannot be removed.
### Site files
The files of a site are read and written through the web service (see The /sites API) by the
admins and the members of the site's `editors` group, and through WebDAV. An upload is at most
`sites.upload_max_mb` MiB (512 by default, at most 10240); a zip archive is extracted into a
folder, and one with an absolute path, a `..`, a link, more than 10000 members or more content than
the limit is refused before anything is written. Files are written with mode 0644 and folders with
0755, so Caddy reads them.
### WebDAV
While `sites.webdav` is `true` and HTTPS is ready, `vpn-router-setup` runs
`vpn-router-webdav.service`, `rclone serve webdav` on `127.0.0.1:7446`, which Caddy serves at
`https://<wan.local_fqdn>:<sites.webdav_port>` (7445 by default) and the firewall opens on both
interfaces. A user signs in with the name and password of the router's directory, followed by the
TOTP code for an enrolled user; an admin sees a folder for every site, any other user the folders of
the sites whose `editors` group the user is a member of, and no folder means no access. rclone writes
an upload to its cache in `/var/cache/vpn-router-webdav` and to the site's folder a few seconds
later. rclone is installed from the Ubuntu archive by `vpn-router-install@webdav.service` when it is
missing.
```sh
vpn-config-edit set --restart sites.webdav true
```
### Name server
While `dns.enabled` is `true`, `vpn-router-setup` runs PowerDNS (`pdns.service`) as the
authoritative name server of one zone, `dns.zone`, on port 53 of the addresses of `dns.listen`
(`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`),
read by PowerDNS's gpgsql backend; setup writes the settings
`/etc/powerdns/pdns.d/vpn-router.conf` (root, group `pdns`, 0640) and installs `pdns-server` and
`pdns-backend-pgsql` from the Ubuntu archive with `vpn-router-install@dns.service` when they are
missing, without the recommended bind backend.
| Setting | Meaning |
| --- | --- |
| `dns.zone` | The zone, such as `sites.example.com` |
| `dns.nameserver` | The name server's host name, the zone's NS and the SOA's primary |
| `dns.addresses` | The public addresses of the zone's apex, of the name server when it is in the zone, and of every host name of an enabled site in the zone |
| `dns.ttl` | The TTL of the records and the SOA's negative TTL, 60 to 86400 seconds, 300 by default |
Records added with `/dns` or the Router Manager (A, AAAA, CNAME, TXT, MX, SRV, CAA) are served
too, but not those of a name and type the zone's own records have. Setup writes the zone at each
run; the SOA's serial grows only when the records change. While P2S, OpenVPN or WireGuard is enabled,
dnsmasq answers their clients on port 53 of the addresses of `p2s.dns_listen`, by default the
internal address, so `dns.listen` must not share an interface with it. The parent zone must delegate `dns.zone` to `dns.nameserver`, with glue records for
a name server inside the zone.
```sh
vpn-config-edit set dns.zone sites.example.com
vpn-config-edit set dns.nameserver ns1.sites.example.com
vpn-config-edit set dns.addresses 203.0.113.10
vpn-config-edit set --restart dns.enabled true
```
## Metrics
While `metrics.enabled` is `true`, `vpn-router-setup` runs `vpn-router-metrics`, which every minute
@@ -1303,6 +1736,8 @@ A backup holds the state that `vpn-router-setup` cannot make again:
| `files/<path>` | Each `psk_file` outside `/etc/vpn-router` |
| `ldap/vpn-router.ldif` | The user directory, written by `slapcat`: users, groups, service accounts, password hashes and TOTP secrets, and the PKI: the CAs and their keys, the root key while it is on the router, and the certificates issued, with the keys of the router's and the users' certificates |
| `postgresql/vpn-router.dump` | With `backup.metrics`, the database of the metrics and the refresh tokens, written by `pg_dump -Fc` |
| `/var/lib/vpn-router/sites` | The folders of the web sites |
| `postgresql/vpn-router-sites.dump`, `postgresql/vpn-router-dns.dump` | The databases of the web sites and of the name server, once they exist |
| `manifest.json` | The host, the time, the version of the package, the schedule and the items |
The files and the directory are copied while the locks of the configuration and of the users are held, so no change of the API or the command-line tools falls between them; `pg_dump` reads a snapshot of its own. The copy is staged in `/var/lib/vpn-router/backup/staging`, which is removed after each run. The backup holds every secret of the router, so keep it as safe as the router itself.
@@ -1368,10 +1803,10 @@ Each run is recorded in `/var/lib/vpn-router/backup.json`, which `/backup` and t
`restore` is only on the command line. It shows what the backup holds and asks for `yes`, which `--yes` gives. It then:
1. stops the web service, the metrics service and slapd;
1. stops the web service, the metrics service, WebDAV, PowerDNS and slapd;
2. puts back the files;
3. loads the directory with `slapadd` into an empty `/var/lib/ldap/vpn-router`;
4. sets the database role's password to the restored `database.secret`, and restores the database with `pg_restore --clean` when the backup holds it;
4. sets the database roles' passwords to the restored password files, and restores each database the backup holds with `pg_restore --clean`;
5. restarts `vpn-router-setup`, which regenerates everything else, followed by WireGuard, OpenVPN, strongSwan and the Router Manager, and reloads ufw.
What it replaces is kept in `/var/lib/vpn-router/backup/before-restore/<UTC time>`. To restore onto a new VM, install the package first and let `vpn-router-setup` run once. The restored configuration names the interfaces of the old VM, so change them with `vpn-config-edit` if they differ.
@@ -1390,23 +1825,25 @@ interfaces it listens on.
`apt-get remove` stops `vpn-router-docker`, which removes the Docker rules. It terminates the
tunnels and takes `wg-quick@wg0`, `openvpn-server@vpn-router`, `caddy`, `vpn-router-metrics`,
`vpn-router-backup.timer` and `vpn-router-pki.timer` down, but leaves the
`vpn-router-webdav`, `vpn-router-backup.timer` and `vpn-router-pki.timer` down, but leaves the
configuration in place. `strongswan` is left enabled, since it is a shared service that
may be in use by something else.
`apt-get purge` additionally removes the generated files and the firewall rules, restoring
`/etc/ufw/before.rules` to its original content, and hands `vpn-router.json` back to `ucf`
before deleting it so a later reinstall starts clean. It also removes the web service's
signing key, the password file of the metrics service account, and the database of the refresh
tokens and the metrics, its role and their password
file when the package created them, and Caddy's configuration `/etc/caddy/vpn-router.Caddyfile`; the certificate in
signing key and the OpenID Provider's, the password file of the metrics service account, the database of the refresh
tokens and the metrics and those of the web sites and the name server, their roles and their password
files when the package created them, Caddy's configuration `/etc/caddy/vpn-router.Caddyfile` and PowerDNS's
`/etc/powerdns/pdns.d/vpn-router.conf`; the certificate in
`/var/lib/caddy` belongs to the `caddy` package. It removes the backup timer's schedule, the
record of the last backup, the published CA files, the copies of the ACME CAs in
`/etc/caddy/vpn-router-pki` and OpenVPN's CA and CRL files. Caddy stays installed, and with it the
source of Caddy's repository. Key material, users and backups are never deleted:
record of the last backup, the published CA files, the copies of the CAs in
`/etc/caddy/vpn-router-pki`, the sites' certificates in `/etc/caddy/vpn-router-sites` and OpenVPN's CA and CRL files.
Caddy, rclone and PowerDNS stay installed, and with Caddy the
source of its repository. Key material, users, content and backups are never deleted:
`/etc/vpn-router/ldap`, `/etc/vpn-router/rclone.conf`, the user directory in slapd with the PKI, the WireGuard
key pair, everything under `/etc/swanctl`, the archives in `backup.path` and
`/var/lib/vpn-router/backup/before-restore` are left alone.
key pair, everything under `/etc/swanctl`, the folders of the web sites in `/var/lib/vpn-router/sites`, the archives in
`backup.path` and `/var/lib/vpn-router/backup/before-restore` are left alone.
## Building
+334
View File
@@ -1,3 +1,337 @@
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
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.
* vpn-router-resolver.service runs dnsmasq on the addresses of
p2s.dns_listen, forwarding to systemd-resolved's stub on 127.0.0.53;
names without a dot are not forwarded. The package depends on
dnsmasq-base.
* systemd-resolved keeps its loopback stubs only; setup removes the
drop-in that made it listen for the clients.
* /logs shows the journal of vpn-router-resolver, vpn-router-webdav and
pdns.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 11:00:00 +0100
vpn-router (0.31.1-1) unstable; urgency=medium
fix: the remote access clients' resolver answers on the internal address by default, chosen by p2s.dns_listen, so the name server can take port 53 of the external addresses.
* The new setting p2s.dns_listen chooses the addresses of the resolver the
road-warrior and OpenVPN clients are given: internal, the default,
external or both. The firewall opens port 53 for the client pools on
those addresses only.
* dns.listen is refused only when it shares an interface with
p2s.dns_listen while IKEv2 or OpenVPN is enabled.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 23:00:00 +0100
vpn-router (0.31.0-1) unstable; urgency=medium
feature: Caddy serves static web sites from folders on the router, defined in their own database, with files uploaded through the web service or WebDAV, and an optional PowerDNS name server answers for a zone of the sites.
* The new database vpn-router-sites holds the web sites: host names,
port, interfaces, TLS (none, Let's Encrypt, an issuing CA or a
certificate and key), index file, single-page fallback and the group
whose members change the files. sites.enabled serves them from
/var/lib/vpn-router/sites/<name>; setup writes their blocks into the
Caddyfile and opens their ports, and Caddy also runs without HTTPS of
the router while a site is served.
* vpn-router-site lists, shows, adds, changes and removes the sites, and
bash completes it.
* /sites manages the sites and the settings of the sites section, and
under /sites/<name>/files/ lists, downloads, uploads, extracts zip
archives into, creates, renames and removes the files of a site, up to
sites.upload_max_mb; members of the site's editors group may use its
files.
* sites.webdav runs rclone's WebDAV server of the sites' folders behind
Caddy on sites.webdav_port, signing users in with the directory and
showing each the sites it may change.
* dns.enabled runs PowerDNS with its PostgreSQL backend in the new
database vpn-router-dns for dns.zone on the addresses of dns.listen:
SOA, NS, the A and AAAA records of dns.addresses for the zone, the
name server and the sites' host names in the zone, and manual records
added with /dns.
* rclone and PowerDNS are installed from the Ubuntu archive by
vpn-router-install@<feature>.service when their feature is on.
* Backups hold the sites' folders and the dumps of both databases; a
restore puts them back. Purge removes the databases, their roles and
password files, and the settings of PowerDNS.
* Renaming a group renames it in the sites; a group a site has cannot be
removed.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 21:00:00 +0100
vpn-router (0.30.0-1) unstable; urgency=medium
feature: the web service serves the last lines of the journal of the router's units and of the log files web.log_files lists, at /logs.
* GET /logs lists the units whose journal and the files whose lines it
serves; GET /logs/journal?unit=&lines= and /logs/file?path=&lines=
answer the last lines, 200 by default and at most 1000, a file read
from its last MiB. Units and files outside the lists are refused.
* The new setting web.log_files lists the absolute paths of the log
files, at most 32.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 19:00:00 +0100
vpn-router (0.29.0-1) unstable; urgency=medium
feature: vpn-config-edit set and remove take --restart, which applies the change with vpn-router-setup and waits for it.
* vpn-config-edit set --restart and remove --restart restart
vpn-router-setup after writing the configuration and wait until it has
applied the change; a failed run exits with status 1 and points to its
journal. Bash completes --restart.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 17:00:00 +0100
vpn-router (0.28.0-1) unstable; urgency=medium
feature: bash completes the vpn-* commands, their options and the names they take.
* Bash completes the commands, options and arguments of vpn-router-user,
vpn-router-group, vpn-router-token, vpn-config-edit, vpn-router-backup,
vpn-router-ca and vpn-router-caddy, from
/usr/share/bash-completion/completions; for root also the users,
groups, issuing CAs, templates, settings and site-to-site connections.
* The package recommends bash-completion.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 16:00:00 +0100
vpn-router (0.27.0-1) unstable; urgency=medium
feature: the web service is an OpenID Provider, which signs the directory's users in to other systems, the clients being service accounts with redirect URIs.
* oidc.enabled turns on the OpenID Provider at the issuer
https://<wan.local_fqdn>:<https.web_port>, with discovery, JWKS,
authorize, token, userinfo and RP-initiated logout under /oidc/,
answered only through Caddy. The authorization code flow with PKCE
S256 and refresh tokens are offered; ID tokens are RS256, signed with
/etc/vpn-router/oidc.key, which the web service makes on its first run.
* Users sign in on the provider's page with the password and the TOTP
code or with a passkey; the session lasts 8 hours. The scopes openid,
profile, email and groups give sub (the entry's entryUUID), the names,
the e-mail address and the user's groups.
* A service account with redirect URIs is a client: its name is the
client ID and its password the secret. Its redirect URIs,
post-logout redirect URIs and allowed groups are set with
vpn-router-user add/update --redirect-uri, --post-logout-redirect-uri
and --allowed-group, and in /service-accounts as redirect_uris,
post_logout_redirect_uris and allowed_groups; GET /service-accounts
also answers the issuer. vpn-router-user list shows them.
* Changing a user's password, disabling, unlocking or removing the user
also revokes the user's codes, tokens and sessions of the provider.
* Renaming a group renames it in the clients that allow it, and a group
a client allows cannot be removed.
* The package depends on python3-oauthlib and python3-jwt.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 14:00:00 +0100
vpn-router (0.26.2-1) unstable; urgency=medium
fix: a service account may be named by a GUID in lowercase, as the Router Manager proposes for a new one.
* vpn-router-user add --service and POST /service-accounts take a GUID
in lowercase as the name, besides a user name; users and groups keep
the rules of user names.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 10:00:00 +0100
vpn-router (0.26.1-1) unstable; urgency=medium
fix: the package installs vpnrouter_passkeys, without which the web service and vpn-router-user do not start.
* /usr/lib/vpn-router/vpnrouter_passkeys.py is part of the package.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 08:45:00 +0100
vpn-router (0.26.0-1) unstable; urgency=medium
feature: users sign in to the Router Manager with passkeys, and console login needs a TOTP token, so a user without one is refused the shell, SSH and sudo.
* Console login is switched on only for a user enrolled for TOTP. A user
whose console login is on without a token is refused POST /shell, the
shell's WebSocket, SSH and sudo; an open shell ends within a minute of
the token's removal or of console login being switched off, and
removing the token ends the sessions of the user's own POSIX account.
* POST /auth/passkey and /auth/passkey/complete sign a user in with a
passkey of wan.local_fqdn, giving the tokens of /auth; POST /auth also
answers the user's name.
* POST, PUT /account/passkeys and DELETE /account/passkeys/<id> add, with
the current password and code, and remove the user's passkeys, at most
10, kept in the new attribute vpnRouterPasskey; DELETE
/users/<name>/passkeys and vpn-router-user passkeys-remove remove all
passkeys of a user.
* Users have passkeys, [{"id", "label", "created"}], in /users and
/account; vpn-router-user list shows their number and a console login
refused for want of a TOTP token.
* The package depends on python3-fido2.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 03 Nov 2026 08:00:00 +0100
vpn-router (0.25.4-1) unstable; urgency=medium
fix: a shell opens without the password and code again while the user's last use of a shell is within web.shell_auth_timeout minutes.
* New setting web.shell_auth_timeout, 15 minutes by default, 0 to 1440:
POST /shell without a password gives a ticket while the user's last
sign-in of POST /shell or keystroke in a shell is within it, and a
null ticket otherwise; 0 asks for the password and code every time.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 03 Nov 2026 06:00:00 +0100
vpn-router (0.25.3-1) unstable; urgency=medium
fix: /config gives the state of caddy.service with the installed Caddy, for the Router Manager's Web page.
* caddy of /config is {"version", "source", "service"}.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 03 Nov 2026 05:00:00 +0100
vpn-router (0.25.2-1) unstable; urgency=medium
fix: wireguard.address must be the router's own address in its network, not the network or broadcast address, from which the peers' addresses are told apart.
* A wireguard.address that is the network or the broadcast address of a
network larger than /31, such as 192.168.232.0/24, is refused with the
first address of the network as the example.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 03 Nov 2026 02:30:00 +0100
vpn-router (0.25.1-1) unstable; urgency=medium
fix: /remote-access gives the router's WireGuard public key, endpoint and allowed IPs, which a peer's configuration needs.
* GET /remote-access answers wireguard_server: {"public_key",
"endpoint", "allowed_ips"}.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 03 Nov 2026 01:30:00 +0100
vpn-router (0.25.0-1) unstable; urgency=medium
feature: a VPN user has WireGuard peers, each a device with its public key and IPs, kept in the directory and written into wg0.conf by setup.
* PUT /users/<name> takes wireguard_peers, [{"public_key", "ips"}],
which replaces the user's peers; keys are unique and IPs may not
overlap another peer's or cover the router's WireGuard address.
* GET /users answers each user's wireguard_peers and the WireGuard
endpoint's enabled and address.
* Setup writes a [Peer] section into wg0.conf for each peer of a user
who is not disabled, in a marked block, and keeps the peers added by
hand; changing a user's peers, disabling, enabling or removing a user
with peers applies at once.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 03 Nov 2026 00:00:00 +0100
vpn-router (0.24.1-1) unstable; urgency=medium
fix: the status gives the number of connected OpenVPN clients at most 5 seconds old instead of up to a minute.
* The OpenVPN server writes its status file every 5 seconds, so a
refresh of the status shows clients that connected or left since.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 23:30:00 +0100
vpn-router (0.24.0-1) unstable; urgency=medium
feature: /remote-access of the web service shows and changes the settings of IKEv2 road-warrior, OpenVPN and WireGuard access and whether each runs, for the Router Manager's Remote access page.
* GET /remote-access answers the p2s, openvpn and wireguard sections,
which of them setup runs, and wan.local_fqdn; PUT
/remote-access/<system> changes one section and restarts setup.
* p2s.enabled and openvpn.enabled need their address_pool, and
wireguard.enabled its address.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 23:00:00 +0100
vpn-router (0.23.2-1) unstable; urgency=medium
fix: the shell of a user's own POSIX account opens on the first setup that sets up console login, and the journal records each refused shell with the reason.
* Setup restarts vpn-router-web when it adds ldap to the name service
switch, so the web service finds the POSIX accounts of the directory.
* The journal of vpn-router-web records each refused ticket and shell,
with the user, the address and the reason.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 22:00:00 +0100
vpn-router (0.23.1-1) unstable; urgency=medium
fix: the shell runs as the console account of each member of admins, a local account or the user's own POSIX account in the directory, which also signs in with SSH and sudo, instead of one web.shell_user.
* vpn-router-user console <name> --posix | --account LOCAL | --off, and
PUT /users/<name>/console with a token of vpn-router-token, switch a
member of admins' console login; leaving admins switches it off.
* The user's own POSIX account is served by nslcd from the directory,
with a uid from 20000, the group vpn-router-console, which may use
sudo, and a home created at the first login; its password is the
directory password, followed by the code once enrolled for TOTP.
* ssh.ldap_login (false) lets sshd accept that password.
* POST /shell and the shell need the user's console login; web.shell_user
is gone.
* Depends on nslcd, libnss-ldapd, libpam-ldapd and sudo.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 21:00:00 +0100
vpn-router (0.23.0-1) unstable; urgency=medium
feature: web.shell lets /shell of the web service open a login shell of web.shell_user over a WebSocket for the Router Manager's Shell page, after the user signs in again.
* web.shell (false), web.shell_user and web.shell_idle_minutes (15).
* POST /shell takes the password and the TOTP code and answers a
one-time ticket; GET /shell with it is a WebSocket carrying the
terminal, closed after web.shell_idle_minutes without a keystroke.
* The journal of vpn-router-web records each shell opened and ended.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 20:00:00 +0100
vpn-router (0.22.1-1) unstable; urgency=medium
fix: the status gives the number of site-to-site connections set up and established, not each connection by name.
* site_to_site of the status document is {"connections", "established"},
and the status page shows "Site to site: N of M established".
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 18:00:00 +0100
vpn-router (0.22.0-1) unstable; urgency=medium
feature: pki.extended_key_usages adds custom extended key usages by their OIDs, which templates give by name, and a template's policies put certificate policy OIDs into its certificates.
+15 -1
View File
@@ -22,18 +22,29 @@ Depends: ${misc:Depends},
python3-jinja2,
python3-ldap,
python3-pyotp,
python3-fido2,
python3-jwt,
python3-oauthlib,
python3-psycopg,
python3-psutil,
python3-prometheus-client,
postgresql,
slapd,
nslcd,
libnss-ldapd,
libpam-ldapd,
sudo,
openvpn,
openvpn-auth-ldap,
iproute2,
systemd-resolved,
dnsmasq-base,
zstd
Recommends: bash-completion
Suggests: caddy,
rclone,
pdns-server,
pdns-backend-pgsql,
proxmox-backup-client-static
Description: Linux VPN router with IPSec, road-warrior, OpenVPN and WireGuard support
Configures a Linux host or virtual machine as a VPN router: site-to-site
@@ -44,7 +55,10 @@ Description: Linux VPN router with IPSec, road-warrior, OpenVPN and WireGuard su
needs. Its two-level CA, whose root may be kept offline, has issuing CAs for
the VPN clients, for site-to-site and for general use, and publishes their
certificates and CRLs. Caddy, installed by the package from the Ubuntu
archive or from Caddy's repository, serves HTTPS. It keeps metrics of the
archive or from Caddy's repository, serves HTTPS and static web sites, whose
files are uploaded through the web service or WebDAV, an optional PowerDNS
name server answers for a zone of the sites, and an optional OpenID
Provider signs the directory's users in to other systems. It keeps metrics of the
router in PostgreSQL and serves them for an OpenTelemetry Collector, and
makes scheduled backups to a local folder, copied to the cloud with rclone,
or to a Proxmox Backup Server. Nothing in the package is specific to a cloud
+1
View File
@@ -2,3 +2,4 @@ etc/vpn-router
usr/lib/vpn-router
usr/lib/vpn-router/vpnrouter_platforms
usr/share/vpn-router/templates
var/lib/vpn-router/sites
+9
View File
@@ -4,11 +4,17 @@ src/usr/lib/vpn-router/vpnrouter_ldap.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_net.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_serve.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_users.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_passkeys.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_auth.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_oidc.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_metrics.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_status.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_backup.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_pki.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_sites.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_dns.py usr/lib/vpn-router/
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/openvpn-same-user usr/lib/vpn-router/
src/usr/lib/vpn-router/openvpn-issuer usr/lib/vpn-router/
src/usr/sbin/vpn-router-user usr/sbin/
@@ -18,6 +24,7 @@ src/usr/sbin/vpn-config-edit usr/sbin/
src/usr/sbin/vpn-router-backup usr/sbin/
src/usr/sbin/vpn-router-ca usr/sbin/
src/usr/sbin/vpn-router-caddy usr/sbin/
src/usr/sbin/vpn-router-site usr/sbin/
src/usr/lib/vpn-router/configure usr/lib/vpn-router/
src/usr/lib/vpn-router/docker-rules usr/lib/vpn-router/
src/usr/lib/vpn-router/setup usr/lib/vpn-router/
@@ -28,5 +35,7 @@ src/usr/lib/vpn-router/wait-for-libpq usr/lib/vpn-router/
src/usr/lib/vpn-router/generate-config usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_platforms/*.py usr/lib/vpn-router/vpnrouter_platforms/
src/usr/share/vpn-router/templates/* usr/share/vpn-router/templates/
src/usr/share/vpn-router/sql/* usr/share/vpn-router/sql/
src/usr/share/bash-completion/completions/*.bash usr/share/bash-completion/completions/
src/usr/lib/systemd/system/caddy.service.d/vpn-router.conf usr/lib/systemd/system/caddy.service.d/
src/usr/share/doc/vpn-router/vpn-router.json.example usr/share/doc/vpn-router/
+25 -7
View File
@@ -11,10 +11,10 @@ strip_ufw_blocks() {
tmp="$(mktemp)"
awk '
/^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|BOOTSTRAP|MANAGER|HTTPS|METRICS|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES START$/ {
/^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|BOOTSTRAP|MANAGER|HTTPS|METRICS|SITES|DNS|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES START$/ {
skip = 1; pending = 0; next
}
/^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|BOOTSTRAP|MANAGER|HTTPS|METRICS|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES END$/ {
/^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|BOOTSTRAP|MANAGER|HTTPS|METRICS|SITES|DNS|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES END$/ {
skip = 0; next
}
skip { next }
@@ -46,19 +46,26 @@ case "$1" in
rm -f /etc/swanctl/conf.d/site-to-site.conf \
/etc/swanctl/conf.d/road-warrior.conf \
/etc/systemd/resolved.conf.d/p2s-forwarder.conf \
/etc/vpn-router/dnsmasq.conf \
/etc/wireguard/wg0.conf \
/etc/openvpn/server/vpn-router.conf \
/etc/openvpn/server/vpn-router-ldap.conf \
/etc/caddy/vpn-router.Caddyfile \
/etc/powerdns/pdns.d/vpn-router.conf \
/etc/vpn-router/vpn-router.json \
/etc/vpn-router/web.key \
/etc/vpn-router/oidc.key \
/etc/vpn-router/metrics.secret \
/etc/systemd/system/vpn-router-backup.timer.d/schedule.conf \
/etc/sudoers.d/vpn-router \
/etc/ssh/sshd_config.d/vpn-router.conf \
/var/lib/vpn-router/setup.done \
/var/lib/vpn-router/backup.json
rmdir --ignore-fail-on-non-empty /etc/systemd/system/vpn-router-backup.timer.d 2>/dev/null || true
# The name service no longer looks up the console accounts of the router's directory.
sed -i '/^passwd:/s/ ldap\b//' /etc/nsswitch.conf
rm -rf /var/lib/vpn-router/backup/staging /var/lib/vpn-router/backup/restore \
/var/lib/vpn-router/pki-publish /etc/caddy/vpn-router-pki \
/var/lib/vpn-router/pki-publish /etc/caddy/vpn-router-pki /etc/caddy/vpn-router-sites \
/etc/openvpn/server/vpn-router-ca.pem /etc/openvpn/server/vpn-router-crl.pem
rmdir --ignore-fail-on-non-empty /var/lib/vpn-router/backup /var/lib/vpn-router 2>/dev/null || true
@@ -69,12 +76,23 @@ case "$1" in
fi
rm -f /etc/vpn-router/database.secret
# Key material and users are not package state: /etc/vpn-router/pki,
# The databases and roles of the web sites and of the name server, when the package created them.
for database in vpn-router-sites vpn-router-dns; do
secret="/etc/vpn-router/${database#vpn-router-}-database.secret"
if [ -f "$secret" ] && command -v psql >/dev/null 2>&1; then
runuser -u postgres -- psql -X -q -c "DROP DATABASE IF EXISTS \"$database\"" || true
runuser -u postgres -- psql -X -q -c "DROP ROLE IF EXISTS \"$database\"" || true
fi
rm -f "$secret"
done
# Key material, users and content are not package state: /etc/vpn-router/pki,
# /etc/vpn-router/ldap, /etc/vpn-router/rclone.conf, the user directory
# in slapd, the WireGuard key pair, everything under /etc/swanctl, the
# backups and what a restore replaced are left in place. Caddy stays
# installed, so its repository, which vpn-router-caddy configures, stays
# too.
# files of the web sites in /var/lib/vpn-router/sites, the backups and
# what a restore replaced are left in place. Caddy, rclone and PowerDNS
# stay installed, so Caddy's repository, which vpn-router-caddy
# configures, stays too.
rmdir --ignore-fail-on-non-empty /etc/vpn-router 2>/dev/null || true
;;
esac
+3
View File
@@ -11,3 +11,6 @@ override_dh_installsystemd:
dh_installsystemd --name=vpn-router-backup --no-enable --no-start
dh_installsystemd --name=vpn-router-pki --no-enable --no-start
dh_installsystemd --name=vpn-router-caddy --no-enable --no-start
dh_installsystemd --name=vpn-router-webdav --no-enable --no-start
dh_installsystemd --name=vpn-router-resolver --no-enable --no-start
dh_installsystemd --name=vpn-router-install@ --no-enable --no-start
@@ -0,0 +1,10 @@
[Unit]
Description=VPN Router installation of the packages of %i
Documentation=file:/usr/share/doc/vpn-router/README.md
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/lib/vpn-router/install-packages %i
TimeoutStartSec=20min
@@ -0,0 +1,15 @@
[Unit]
Description=VPN Router DNS resolver of the remote access clients
Documentation=file:/usr/share/doc/vpn-router/README.md
After=network-online.target systemd-resolved.service
Wants=network-online.target
ConditionPathExists=/etc/vpn-router/dnsmasq.conf
[Service]
Type=simple
ExecStart=/usr/sbin/dnsmasq --keep-in-foreground --pid-file= --log-facility=- --conf-file=/etc/vpn-router/dnsmasq.conf
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
@@ -15,7 +15,7 @@ NoNewPrivileges=yes
ProtectSystem=strict
RuntimeDirectory=vpn-router
RuntimeDirectoryPreserve=yes
ReadWritePaths=-/etc/vpn-router -/etc/swanctl/x509crl -/etc/openvpn/server -/var/lib/vpn-router/pki-publish
ReadWritePaths=-/etc/vpn-router -/etc/swanctl/x509crl -/etc/openvpn/server -/var/lib/vpn-router/pki-publish -/var/lib/vpn-router/sites
ProtectHome=yes
PrivateTmp=yes
@@ -0,0 +1,21 @@
[Unit]
Description=VPN Router WebDAV of the web sites' folders
Documentation=file:/usr/share/doc/vpn-router/README.md
After=network-online.target slapd.service postgresql.service
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/rclone serve webdav --addr 127.0.0.1:7446 --auth-proxy /usr/lib/vpn-router/webdav-auth --config /dev/null --cache-dir /var/cache/vpn-router-webdav --vfs-cache-mode writes --umask 022
Restart=on-failure
RestartSec=15
NoNewPrivileges=yes
ProtectSystem=strict
ReadWritePaths=/var/lib/vpn-router/sites
CacheDirectory=vpn-router-webdav
RuntimeDirectory=vpn-router-webdav
ProtectHome=yes
PrivateTmp=yes
[Install]
WantedBy=multi-user.target
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/python3
"""Install the packages of a feature from the Ubuntu archive, then restart vpn-router-setup without waiting.
Run by vpn-router-install@<feature>.service, which vpn-router-setup starts when the feature is on and its packages are
missing: webdav (rclone) and dns (PowerDNS with its gpgsql backend). Recommended packages are left out, so PowerDNS
comes without its bind backend. A failure is a message on standard error and the exit status 1.
install-packages FEATURE
"""
import os
import subprocess
import sys
#: The packages of each feature.
FEATURES = {'webdav': ['rclone'], 'dns': ['pdns-server', 'pdns-backend-pgsql']}
#: apt-get, waiting up to ten minutes for dpkg's lock, which a package installation that runs setup holds.
APT = ['apt-get', '-o', 'DPkg::Lock::Timeout=600']
def main():
if len(sys.argv) != 2 or sys.argv[1] not in FEATURES:
sys.exit(f'usage: install-packages {"|".join(FEATURES)}')
environment = dict(os.environ, DEBIAN_FRONTEND='noninteractive')
for args in ([*APT, 'update'], [*APT, '-y', '--no-install-recommends', '-o', 'Dpkg::Options::=--force-confdef',
'-o', 'Dpkg::Options::=--force-confold', 'install', *FEATURES[sys.argv[1]]]):
print(f'$ {" ".join(args)}', flush=True)
if subprocess.run(args, env=environment).returncode != 0:
sys.exit(f'install-packages: {args[0]} failed')
subprocess.run(['systemctl', 'restart', '--no-block', 'vpn-router-setup.service'])
return 0
if __name__ == '__main__':
sys.exit(main())
+321 -45
View File
@@ -7,7 +7,7 @@ and service reloads. Idempotent - a second run changes nothing.
Until a run has succeeded (SETUP_DONE), every run starts the bootstrap
services, BOOTSTRAP_UNITS, which show the progress and the failure recorded in
SETUP_STATE on BOOTSTRAP_PORTS, and the firewall opens those ports on the
SETUP_STATE on vpnrouter.BOOTSTRAP_PORTS, and the firewall opens those ports on the
internal interface. The first successful run writes SETUP_DONE, closes the
ports and stops the bootstrap services; a failed run leaves them running.
@@ -33,8 +33,11 @@ sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
import ldap
import vpnrouter
import vpnrouter_auth
import vpnrouter_dns
import vpnrouter_ldap
import vpnrouter_pki
import vpnrouter_sites
import vpnrouter_users
from vpnrouter import fail, warn
@@ -49,18 +52,12 @@ SETUP_STATE = pathlib.Path('/run/vpn-router/setup.json')
#: Written once a run has succeeded; until then setup runs the bootstrap services and opens their ports.
SETUP_DONE = pathlib.Path('/var/lib/vpn-router/setup.done')
#: The units of the bootstrap services, and their ports: the Router Manager's and the router's.
#: The units of the bootstrap services, whose ports are vpnrouter.BOOTSTRAP_PORTS.
BOOTSTRAP_UNITS = ('vpn-router-bootstrap.service', 'vpn-router-manager-bootstrap.service')
BOOTSTRAP_PORTS = (17080, 17081)
#: The Router Manager, vpn-router-manager, when it is installed, and its settings.
MANAGER = pathlib.Path('/usr/lib/vpn-router-manager/manager')
MANAGER_CONFIG = pathlib.Path('/etc/vpn-router-manager/manager.json')
#: The default admin of the Router Manager, {"username", "password"}, which its package writes from
#: its debconf answers; setup creates the account and removes the file.
MANAGER_ADMIN = pathlib.Path('/etc/vpn-router-manager/admin.json')
MANAGER_PORT = 7080
#: The password of the metrics service account, which setup makes and keeps (root only).
METRICS_SECRET = pathlib.Path('/etc/vpn-router/metrics.secret')
@@ -77,12 +74,34 @@ CADDY_GROUP = 'caddy'
#: The unit that installs Caddy from https.caddy_source with vpn-router-caddy, outside setup.
CADDY_INSTALL_UNIT = 'vpn-router-caddy.service'
#: The certificates and keys of the issuing CAs behind Caddy's ACME server, <name>/cert.pem and key.pem.
#: The certificates and keys of the issuing CAs behind Caddy's ACME server and the sites of tls ca, <name>/cert.pem and
#: key.pem.
CADDY_PKI_DIR = pathlib.Path('/etc/caddy/vpn-router-pki')
#: The certificates and keys of the sites of tls file, <site>/cert.pem and key.pem.
CADDY_SITES_DIR = pathlib.Path('/etc/caddy/vpn-router-sites')
#: The unit of dnsmasq, the resolver of the remote access clients.
RESOLVER_UNIT = 'vpn-router-resolver.service'
#: The unit of rclone's WebDAV server of the sites' folders.
WEBDAV_UNIT = 'vpn-router-webdav.service'
#: The unit that installs the packages of a feature with install-packages, outside setup; {} is the feature.
INSTALL_UNIT = 'vpn-router-install@{}.service'
#: The timer of vpn-router-ca maintain, which keeps the CRLs fresh.
PKI_TIMER = 'vpn-router-pki.timer'
#: The system's side of console login (vpnrouter_users.console): nslcd's configuration, which serves the posixAccounts
#: of the directory with console login on, the name service switch, the sudo rule of their group, and the drop-in of
#: sshd that lets them sign in with their password while ssh.ldap_login is true.
NSLCD_CONF = pathlib.Path('/etc/nslcd.conf')
NSSWITCH = pathlib.Path('/etc/nsswitch.conf')
SUDOERS = pathlib.Path('/etc/sudoers.d/vpn-router')
SSHD_DROPIN = pathlib.Path('/etc/ssh/sshd_config.d/vpn-router.conf')
PAM_SESSION = pathlib.Path('/etc/pam.d/common-session')
FILTER_ANCHOR = '# End required lines'
#: Configuration options left out wherever the configuration is shown.
@@ -98,6 +117,8 @@ BLOCKS = (
('# MANAGER RULES START', '# MANAGER RULES END'),
('# HTTPS RULES START', '# HTTPS RULES END'),
('# METRICS RULES START', '# METRICS RULES END'),
('# SITES RULES START', '# SITES RULES END'),
('# DNS RULES START', '# DNS RULES END'),
('# ROUTER FORWARD RULES START', '# ROUTER FORWARD RULES END'),
('# ROUTER NAT RULES START', '# ROUTER NAT RULES END'),
('# ROUTER MSS RULES START', '# ROUTER MSS RULES END'),
@@ -185,15 +206,6 @@ def is_configured(ctx):
vpnrouter.openvpn_ready(ctx)))
def manager_port():
"""The port of the Router Manager: port in its settings, or MANAGER_PORT when it is not set or not a port."""
try:
port = json.loads(MANAGER_CONFIG.read_text(encoding='utf-8')).get('port')
except (OSError, ValueError, AttributeError):
return MANAGER_PORT
return port if isinstance(port, int) and not isinstance(port, bool) and 1 <= port <= 65535 else MANAGER_PORT
def filter_block(ctx):
lines = [
'',
@@ -231,11 +243,13 @@ def filter_block(ctx):
lines += ['# OPENVPN RULES END']
pools = [pool for ready, pool in ((vpnrouter.p2s_ready(ctx), ctx['p2s_address_pool']),
(vpnrouter.openvpn_ready(ctx), ctx['openvpn_pool'])) if ready]
if pools and ctx['local_addr_list']:
(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.
if pools and ctx['p2s_resolver_addrs']:
lines += ['', '# P2S DNS RULES START']
for pool in pools:
for address in ctx['local_addr_list']:
for address in ctx['p2s_resolver_addrs']:
lines += [
f'-A ufw-before-input -s {pool} -d {address} -p udp --dport 53 -j ACCEPT',
f'-A ufw-before-input -s {pool} -d {address} -p tcp --dport 53 -j ACCEPT',
@@ -256,12 +270,13 @@ def filter_block(ctx):
lines += [
'',
'# BOOTSTRAP RULES START',
*(f'-A ufw-before-input -i {ctx["int_iface"]} -p tcp --dport {port} -j ACCEPT' for port in BOOTSTRAP_PORTS),
*(f'-A ufw-before-input -i {ctx["int_iface"]} -p tcp --dport {port} -j ACCEPT'
for port in vpnrouter.BOOTSTRAP_PORTS),
'# BOOTSTRAP RULES END',
]
if MANAGER.exists() and interfaces:
port = manager_port()
if vpnrouter.MANAGER.exists() and interfaces:
port = vpnrouter.manager_port()
lines += [
'',
'# MANAGER RULES START',
@@ -283,8 +298,9 @@ def filter_block(ctx):
publish = ctx['pki_publish']
ports = [port for wanted, port in ((ctx['web_enabled'] or 'https' in publish or ctx['pki_acme'],
ctx['https_web_port']),
(MANAGER.exists(), ctx['https_manager_port']),
(vpnrouter.MANAGER.exists(), ctx['https_manager_port']),
(vpnrouter.metrics_ready(ctx), ctx['metrics_https_port']),
(vpnrouter.webdav_ready(ctx), ctx['webdav_port']),
('http-80' in publish, vpnrouter.ACME_HTTP_PORT),
('https-default' in publish, vpnrouter.HTTPS_DEFAULT_PORT)) if wanted]
lines += [
@@ -296,6 +312,32 @@ def filter_block(ctx):
'# HTTPS RULES END',
]
# Each site's port is open on the interfaces of its listen; a site with a certificate of Let's Encrypt needs the HTTP
# challenge on the external interface.
by_listen = {'internal': [ctx['int_iface']], 'external': [ctx['wan_iface']],
'both': [ctx['int_iface'], ctx['wan_iface']]}
sites = ctx['sites'] or []
rules = sorted({(name, site['port']) for site in sites for name in by_listen[site['listen']] if name})
if ctx['wan_iface'] and any(site['tls'] == 'acme' for site in sites):
rules = sorted(set(rules) | {(ctx['wan_iface'], vpnrouter.ACME_HTTP_PORT)})
if rules:
lines += [
'',
'# SITES RULES START',
*(f'-A ufw-before-input -i {name} -p tcp --dport {port} -j ACCEPT' for name, port in rules),
'# SITES RULES END',
]
dns_interfaces = [name for name in by_listen[ctx['dns_listen']] if name] if vpnrouter.dns_ready(ctx) else []
if dns_interfaces:
lines += [
'',
'# DNS RULES START',
*(f'-A ufw-before-input -i {name} -p {protocol} --dport 53 -j ACCEPT'
for name in dns_interfaces for protocol in ('udp', 'tcp')),
'# DNS RULES END',
]
if vpnrouter.routing_ready(ctx):
lines += ['', '# ROUTER FORWARD RULES START']
for subnet in ctx['local_cidr_list']:
@@ -529,6 +571,93 @@ def manage_metrics_account(ctx):
return ''
def write_if_changed(path, text, mode=0o644):
"""Write the file when its content differs; whether it was written."""
try:
if path.read_text(encoding='utf-8') == text:
return False
except OSError:
pass
path.write_text(text, encoding='utf-8')
path.chmod(mode)
return True
def manage_console(cfg):
"""Make the system serve console login; the problem, or ''.
The local group vpnrouter_ldap.CONSOLE_GROUP is created when missing, and sudo lets its members run any command
with their password. nslcd serves the directory's posixAccounts with console login on through the local socket,
and checks their passwords by binding as the user; the passwd line of the name service switch gains ldap, which
restarts the web service, and PAM creates a home at the first login. While ssh.ldap_login is true, sshd accepts the password of the group's members;
a drop-in sshd -t refuses is removed again.
"""
group = vpnrouter_ldap.CONSOLE_GROUP
try:
grp.getgrnam(group)
except KeyError:
result = run(['groupadd', '--system', group], check=False)
if result.returncode != 0:
return f'the group {group} cannot be created: {result.stderr.strip()}'
rule = f'%{group} ALL=(ALL:ALL) ALL\n'
try:
if not SUDOERS.is_file() or SUDOERS.read_text(encoding='utf-8') != rule:
staged = SUDOERS.with_name('.vpn-router.new')
staged.write_text(rule, encoding='utf-8')
staged.chmod(0o440)
checked = run(['visudo', '-cf', str(staged)], check=False)
if checked.returncode != 0:
staged.unlink()
return f'the sudo rule of {group} is refused: {checked.stdout.strip()} {checked.stderr.strip()}'
staged.replace(SUDOERS)
uri = 'ldapi://' + vpnrouter_ldap.SLAPD_SOCKET.replace('/', '%2f') + '/'
nslcd = '\n'.join([
'uid nslcd',
'gid nslcd',
f'uri {uri}',
f'base {vpnrouter_ldap.BASE}',
'filter passwd (&(objectClass=posixAccount)(vpnRouterConsoleLogin=TRUE))',
f'nss_min_uid {vpnrouter_ldap.CONSOLE_UID_MIN}',
'pam_authz_search (&(objectClass=posixAccount)(objectClass=oathTOTPUser)(uid=$username)(vpnRouterConsoleLogin=TRUE))',
'pam_password_prohibit_message The password is changed in the Router Manager or with vpn-router-user.',
'',
])
if write_if_changed(NSLCD_CONF, nslcd, 0o640) or not unit_is_active('nslcd'):
os.chown(NSLCD_CONF, 0, grp.getgrnam('nslcd').gr_gid)
run(['systemctl', 'enable', 'nslcd'], check=False)
run(['systemctl', 'restart', 'nslcd'], check=False)
lines = NSSWITCH.read_text(encoding='utf-8').splitlines(keepends=True)
switched = [line.rstrip('\n') + ' ldap\n' if line.startswith('passwd:') and 'ldap' not in line.split() else line
for line in lines]
if switched != lines:
NSSWITCH.write_text(''.join(switched), encoding='utf-8')
# A running process keeps the name service switch it read first, so the web service's shell would not find
# the console accounts of the directory until it restarts.
run(['systemctl', 'try-restart', 'vpn-router-web.service'], check=False)
if 'pam_mkhomedir' not in PAM_SESSION.read_text(encoding='utf-8'):
run(['pam-auth-update', '--enable', 'mkhomedir'], check=False)
if cfg['ssh']['ldap_login']:
changed = write_if_changed(SSHD_DROPIN, f'Match Group {group}\n PasswordAuthentication yes\n')
else:
changed = SSHD_DROPIN.exists()
SSHD_DROPIN.unlink(missing_ok=True)
if changed:
checked = run(['sshd', '-t'], check=False)
if checked.returncode != 0:
SSHD_DROPIN.unlink(missing_ok=True)
return f'sshd refuses {SSHD_DROPIN}: {checked.stderr.strip()}'
if unit_is_active('ssh'):
run(['systemctl', 'reload', 'ssh'], check=False)
except (OSError, KeyError) as exc:
return f'console login cannot be set up: {exc}'
return ''
def manage_openvpn(ctx, changed):
"""Run the OpenVPN server when it is configured, and stop it when it is not."""
unit = 'openvpn-server@vpn-router'
@@ -544,22 +673,50 @@ def manage_openvpn(ctx, changed):
run(['systemctl', 'restart', unit], check=False)
def manage_caddy(cfg, ctx):
"""Run Caddy, the HTTPS proxy of the web service, the Router Manager and the metrics service, while HTTPS is ready, and stop it when not.
def caddy_address(host, port, tls):
"""The address of a site block of the Caddyfile: the host, an IPv6 address in brackets, and the port, with http://
for a plain HTTP site; no host answers any name."""
host = f'[{host}]' if ':' in host else host
return f'{"http://" if tls == "none" else ""}{host}:{port}'
CADDYFILE serves the web service on https.web_port, while the manager is installed the
manager on https.manager_port, and while metrics are enabled the metrics service on
metrics.https_port, all for wan.local_fqdn with a certificate from Let's Encrypt, and all
calling the service on its loopback address. It also serves the CA files of
vpnrouter_pki.PUBLISH_DIR at /pki/ on the endpoints of pki.publish that are Caddy's, and an
ACME server at /acme/<name>/ on https.web_port for each issuing CA with acme and a
certificate, whose certificate and key are copied to CADDY_PKI_DIR.
def manage_resolver(changed):
"""Run dnsmasq, the resolver of the remote access clients, while configure wrote its configuration, restarting it when
that changed, and stop it when not. It starts after systemd-resolved has left the addresses it takes."""
if not vpnrouter.RESOLVER_CONF.exists():
if unit_is_active(RESOLVER_UNIT):
run(['systemctl', 'disable', '--now', RESOLVER_UNIT], check=False)
return
if not unit_is_active(RESOLVER_UNIT):
run(['systemctl', 'enable', '--now', RESOLVER_UNIT], check=False)
elif 'resolver' in changed:
run(['systemctl', 'restart', RESOLVER_UNIT], check=False)
def manage_caddy(cfg, ctx):
"""Run Caddy, the HTTPS proxy of the router's services and the server of the web sites, while either is wanted, and
stop it when not.
While HTTPS is ready, CADDYFILE serves the web service on https.web_port, while the manager is installed the
manager on https.manager_port, while metrics are enabled the metrics service on metrics.https_port, and while
WebDAV is ready WebDAV on sites.webdav_port, all for wan.local_fqdn with a certificate from Let's Encrypt, and all
calling the service on its loopback address. It also serves the CA files of vpnrouter_pki.PUBLISH_DIR at /pki/ on
the endpoints of pki.publish that are Caddy's, and an ACME server at /acme/<name>/ on https.web_port for each
issuing CA with acme and a certificate.
It serves each site of ctx's sites from its folder, with the TLS of the site: an issuing CA of tls ca issues its
certificates, and the certificate and key of tls file are written to CADDY_SITES_DIR/<name>. The certificates and
keys of the issuing CAs of both are copied to CADDY_PKI_DIR. While the sites cannot be read (ctx's sites is None),
the Caddyfile is left as it is.
Caddy is installed by CADDY_INSTALL_UNIT, outside setup, when it is missing or when
https.caddy_source is upstream and Caddy's repository is not configured; the unit restarts
setup, whose next run serves with it.
"""
if not vpnrouter.https_ready(ctx):
sites = ctx['sites']
if not vpnrouter.https_ready(ctx) and not sites:
if sites is None:
return
vpnrouter.remove(CADDYFILE)
if unit_is_active(CADDY_UNIT):
run(['systemctl', 'disable', '--now', CADDY_UNIT], check=False)
@@ -573,21 +730,33 @@ def manage_caddy(cfg, ctx):
if source == 'ubuntu' and vpnrouter.CADDY_APT_SOURCE.is_file():
warn("https.caddy_source ubuntu is ignored: Caddy's repository is configured, and Caddy stays upstream's")
if sites is None:
warn('the web sites cannot be read; the Caddyfile is left as it is')
if not unit_is_active(CADDY_UNIT) and CADDYFILE.exists():
run(['systemctl', 'enable', '--now', CADDY_UNIT], check=False)
return
try:
group = grp.getgrnam(CADDY_GROUP).gr_gid
except KeyError:
fail(f'the group {CADDY_GROUP} does not exist; is the caddy package installed?')
https = vpnrouter.https_ready(ctx)
wanted = list(dict.fromkeys([*(ctx['pki_acme'] if https else []),
*(site['ca'] for site in sites if site['tls'] == 'ca')]))
try:
credentials = {name: vpnrouter_pki.authority_files(name) for name in ctx['pki_acme']}
credentials = {name: vpnrouter_pki.authority_files(name) for name in wanted}
except ldap.LDAPError as exc:
warn(f'the ACME servers are left out: the PKI cannot be read: {exc}')
warn(f'the ACME servers and the sites of issuing CAs are left out: the PKI cannot be read: {exc}')
credentials = {}
acme = [name for name, files in credentials.items() if files is not None]
authorities = [name for name, files in credentials.items() if files is not None]
for site in sites:
if site['tls'] == 'ca' and site['ca'] not in authorities:
warn(f'site {site["name"]} is left out: issuing CA {site["ca"]} has no certificate')
sites = [site for site in sites if site['tls'] != 'ca' or site['ca'] in authorities]
copied = False
for folder in (CADDY_PKI_DIR.iterdir() if CADDY_PKI_DIR.is_dir() else []):
if folder.name not in acme:
if folder.name not in authorities:
shutil.rmtree(folder)
for name in acme:
for name in authorities:
folder = CADDY_PKI_DIR / name
for path in (CADDY_PKI_DIR, folder):
path.mkdir(mode=0o750, exist_ok=True)
@@ -595,12 +764,33 @@ def manage_caddy(cfg, ctx):
for file, data in zip(('cert.pem', 'key.pem'), credentials[name]):
copied |= vpnrouter_pki.write(folder / file, data, 0o640)
os.chown(folder / file, 0, group)
authorities = vpnrouter.issuing_cas(cfg)
caddy_ctx = dict(ctx, manager_port=manager_port() if MANAGER.exists() else 0,
files = {site['name']: site for site in sites if site['tls'] == 'file'}
for folder in (CADDY_SITES_DIR.iterdir() if CADDY_SITES_DIR.is_dir() else []):
if folder.name not in files:
shutil.rmtree(folder)
for name, site in files.items():
folder = CADDY_SITES_DIR / name
for path in (CADDY_SITES_DIR, folder):
path.mkdir(mode=0o750, exist_ok=True)
os.chown(path, 0, group)
for file, data in (('cert.pem', site['certificate']), ('key.pem', site['private_key'])):
copied |= vpnrouter_pki.write(folder / file, data.strip().encode() + b'\n', 0o640)
os.chown(folder / file, 0, group)
vpnrouter_sites.ensure_folders(site['name'] for site in sites)
names = vpnrouter.issuing_cas(cfg)
caddy_ctx = dict(ctx, https=https, manager_port=vpnrouter.manager_port() if vpnrouter.MANAGER.exists() else 0,
pki_publish_dir=str(vpnrouter_pki.PUBLISH_DIR),
acme_cas=[{'id': name, 'name': authorities[name]['name'].strip(),
'cert': str(CADDY_PKI_DIR / name / 'cert.pem'),
'key': str(CADDY_PKI_DIR / name / 'key.pem')} for name in acme])
pki_cas=[{'id': name, 'name': names[name]['name'].strip(),
'cert': str(CADDY_PKI_DIR / name / 'cert.pem'),
'key': str(CADDY_PKI_DIR / name / 'key.pem')} for name in authorities],
acme_cas=[name for name in authorities if https and name in ctx['pki_acme']],
webdav=vpnrouter.webdav_ready(ctx), webdav_loopback_port=vpnrouter.WEBDAV_LOOPBACK_PORT,
sites=[{'name': site['name'], 'tls': site['tls'], 'ca': site['ca'], 'spa': site['spa'],
'index': site['index_file'], 'root': str(vpnrouter_sites.SITES_DIR / site['name']),
'addresses': [caddy_address(host, site['port'], site['tls']) for host in site['hosts']]
or [caddy_address('', site['port'], site['tls'])],
'cert': str(CADDY_SITES_DIR / site['name'] / 'cert.pem'),
'key': str(CADDY_SITES_DIR / site['name'] / 'key.pem')} for site in sites])
changed = vpnrouter.render(vpnrouter._jinja_env(), caddy_ctx, 'Caddyfile.j2', CADDYFILE, 0o640)
os.chown(CADDYFILE, 0, group)
if not unit_is_active(CADDY_UNIT):
@@ -609,6 +799,71 @@ def manage_caddy(cfg, ctx):
run(['systemctl', 'reload', CADDY_UNIT], check=False)
def install_packages(feature):
"""Start the installation of the packages of a feature by INSTALL_UNIT, which runs setup again, and say so."""
unit = INSTALL_UNIT.format(feature)
run(['systemctl', 'start', '--no-block', unit], check=False)
warn(f'the packages of {feature} are installed by {unit}, which runs setup again')
def manage_webdav(ctx):
"""Run rclone's WebDAV server of the sites' folders while WebDAV is ready, and stop it when not; rclone is installed
when it is missing."""
if not vpnrouter.webdav_ready(ctx):
if unit_is_active(WEBDAV_UNIT):
run(['systemctl', 'disable', '--now', WEBDAV_UNIT], check=False)
return
if shutil.which('rclone') is None:
install_packages('webdav')
return
vpnrouter_sites.ensure_folders([])
run(['systemctl', 'enable', '--now', WEBDAV_UNIT], check=False)
def manage_dns(cfg, ctx):
"""Run PowerDNS, the name server of dns.zone, on the addresses of dns.listen while DNS is enabled, and stop it when
not; its packages are installed when they are missing.
The settings, vpnrouter_dns.PDNS_CONF, are written before the packages are installed, so the server their
installation starts answers on those addresses only, beside systemd-resolved's stub on 127.0.0.53. The zone gets
the host names of the enabled sites, and keeps those it has while the sites cannot be read.
"""
if not vpnrouter.dns_ready(ctx):
if unit_is_active(vpnrouter_dns.UNIT):
run(['systemctl', 'disable', '--now', vpnrouter_dns.UNIT], check=False)
vpnrouter.remove(vpnrouter_dns.PDNS_CONF)
if vpnrouter_dns.DATABASE_SECRET.is_file():
try:
vpnrouter_dns.apply(cfg, [])
except vpnrouter_dns.DatabaseError as exc:
warn(str(exc))
return
addresses = {'internal': [ctx['int_addr']], 'external': ctx['local_addr_list'],
'both': [ctx['int_addr'], *ctx['local_addr_list']]}[ctx['dns_listen']]
addresses = [address for address in addresses if address]
if not addresses:
warn(f'the name server does not run: the interfaces of dns.listen {ctx["dns_listen"]} have no address')
return
try:
vpnrouter_auth.provision_database(vpnrouter_dns.DATABASE, vpnrouter_dns.DATABASE_SECRET)
vpnrouter_dns.PDNS_CONF.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
changed = write_if_changed(vpnrouter_dns.PDNS_CONF, vpnrouter_dns.config_text(addresses), 0o640)
if not vpnrouter_dns.PDNS_SERVER.exists() or not vpnrouter_dns.PDNS_SCHEMA.exists():
install_packages('dns')
return
os.chown(vpnrouter_dns.PDNS_CONF, 0, grp.getgrnam(vpnrouter_dns.GROUP).gr_gid)
vpnrouter_dns.provision()
if ctx['sites'] is not None:
vpnrouter_dns.apply(cfg, [host for site in ctx['sites'] for host in site['hosts']])
except (OSError, KeyError, vpnrouter_dns.DatabaseError) as exc:
warn(f'the name server cannot be set up: {exc}')
return
if not unit_is_active(vpnrouter_dns.UNIT):
run(['systemctl', 'enable', '--now', vpnrouter_dns.UNIT], check=False)
elif changed:
run(['systemctl', 'restart', vpnrouter_dns.UNIT], check=False)
def manage_metrics(ctx):
"""Run the metrics service while metrics are enabled, and stop it when not.
@@ -699,6 +954,7 @@ def refresh_web(cfg, ctx):
'internal_address': ctx['int_addr'],
'internal_gateway': ctx['int_gateway_ip'],
'local_cidrs': ctx['local_cidr_list'],
'client_dns': ctx['p2s_resolver_addrs'] if vpnrouter.RESOLVER_CONF.exists() else [],
'site_to_site': [{
'name': connection['name'],
'local_identity': connection['local_id'],
@@ -767,6 +1023,16 @@ def main():
ctx, more = vpnrouter.configure(cfg, public_ip=address)
changed |= more
# The enabled sites while sites are on, None while they cannot be read.
ctx['sites'] = []
if cfg['sites']['enabled']:
progress('running', 'web sites')
try:
ctx['sites'] = vpnrouter_sites.served()
except vpnrouter_sites.DatabaseError as exc:
warn(str(exc))
ctx['sites'] = None
progress('running', 'WireGuard keys')
setup_wireguard(ctx)
@@ -796,16 +1062,26 @@ def main():
failed_step = 'metrics service account'
progress('running', failed_step)
problem = manage_metrics_account(ctx)
if not problem:
failed_step = 'console login'
progress('running', failed_step)
problem = manage_console(cfg)
progress('running', 'OpenVPN')
manage_openvpn(ctx, changed)
progress('running', 'HTTPS proxy')
manage_caddy(cfg, ctx)
progress('running', 'WebDAV')
manage_webdav(ctx)
progress('running', 'name server')
manage_dns(cfg, ctx)
progress('running', 'PKI timer')
run(['systemctl', 'enable', '--now', PKI_TIMER], check=False)
if 'resolved' in changed:
progress('running', 'resolver')
run(['systemctl', 'restart', 'systemd-resolved'], check=False)
progress('running', 'resolver of the remote access clients')
manage_resolver(changed)
progress('running', 'platform module')
run_platform_setup(cfg, ctx)
@@ -26,6 +26,7 @@ if str(LIB_DIR) not in sys.path:
import vpnrouter_ldap
import vpnrouter_pki
import vpnrouter_platforms
import vpnrouter_status
from vpnrouter_net import DEFAULT_LISTEN, LISTEN_CHOICES, LOOPBACK, ConfigError, detect_interfaces, interface_addresses, ip_output
CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.json')
@@ -35,8 +36,16 @@ TEMPLATE_DIR = pathlib.Path('/usr/share/vpn-router/templates')
SWANCTL_CONF_D = pathlib.Path('/etc/swanctl/conf.d')
SITE_TO_SITE = SWANCTL_CONF_D / 'site-to-site.conf'
ROAD_WARRIOR = SWANCTL_CONF_D / 'road-warrior.conf'
#: The drop-in of systemd-resolved that earlier versions wrote for the remote access clients; removed when found.
P2S_FORWARDER = pathlib.Path('/etc/systemd/resolved.conf.d/p2s-forwarder.conf')
#: The configuration of dnsmasq, the resolver of the remote access clients (vpn-router-resolver.service).
RESOLVER_CONF = pathlib.Path('/etc/vpn-router/dnsmasq.conf')
WG_CONF = pathlib.Path('/etc/wireguard/wg0.conf')
#: The router's WireGuard public key, which setup writes with its private key.
WG_PUBLIC_KEY = pathlib.Path('/etc/wireguard/wg0.pub')
#: The lines around the [Peer] sections of the users' WireGuard peers in WG_CONF, which setup writes from the directory.
WG_PEERS_BEGIN = "# BEGIN WireGuard peers of the users of the router's directory"
WG_PEERS_END = "# END WireGuard peers of the users of the router's directory"
OPENVPN_CONF = pathlib.Path('/etc/openvpn/server/vpn-router.conf')
OPENVPN_LDAP = pathlib.Path('/etc/openvpn/server/vpn-router-ldap.conf')
@@ -47,13 +56,18 @@ SCHEMA = {
'wan': {'local_fqdn': ''},
'local': {'cidrs': [], 'int_addr': '', 'int_gateway_ip': ''},
'site_to_site': {},
'p2s': {'enabled': False, 'address_pool': ''},
'p2s': {'enabled': False, 'address_pool': '', 'dns_listen': 'internal'},
'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820},
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
'docker': {'mode': 'auto', 'exposed_ports': []},
'web': {'enabled': True, 'listen': DEFAULT_LISTEN, 'port': 7081, 'database': '', 'trusted_proxies': [],
'status_content': 'basic'},
'status_content': 'basic', 'shell': False, 'shell_idle_minutes': 15, 'shell_auth_timeout': 15,
'log_files': []},
'ssh': {'ldap_login': False},
'https': {'enabled': True, 'email': '', 'web_port': 7444, 'manager_port': 7443, 'caddy_source': 'ubuntu'},
'oidc': {'enabled': False},
'sites': {'enabled': False, 'upload_max_mb': 512, 'webdav': False, 'webdav_port': 7445},
'dns': {'enabled': False, 'zone': '', 'nameserver': '', 'addresses': [], 'listen': 'external', 'ttl': 300},
'pki': {'root_name': 'VPN Router Root CA', 'root_days': 7300, 'ca_days': 3650, 'key': 'rsa-4096',
'crl_days': 7, 'root_crl_days': 180, 'publish': ['http'], 'p2s_name': 'VPN Router P2S CA',
's2s_name': 'VPN Router Site-to-Site CA', 'issuing_cas': {}, 'templates': {},
@@ -118,6 +132,29 @@ ACME_HTTP_PORT = 80
#: The default port of HTTPS, where Caddy publishes the CA files when pki.publish has https-default.
HTTPS_DEFAULT_PORT = 443
#: The most files web.log_files lists.
LOG_FILES_MAX = 32
#: The port rclone serves WebDAV on, on the loopback address, behind Caddy.
WEBDAV_LOOPBACK_PORT = 7446
#: The largest sites.upload_max_mb, 10 GiB.
UPLOAD_MAX_MB = 10240
#: The shortest and the longest dns.ttl, in seconds.
DNS_TTL_RANGE = (60, 86400)
#: A DNS name without a wildcard, in lowercase, of dns.zone and dns.nameserver.
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])?)+$')
#: The ports of the bootstrap services: the Router Manager's and the router's.
BOOTSTRAP_PORTS = (17080, 17081)
#: The Router Manager, vpn-router-manager, when it is installed, its settings and its port when they set none.
MANAGER = pathlib.Path('/usr/lib/vpn-router-manager/manager')
MANAGER_CONFIG = pathlib.Path('/etc/vpn-router-manager/manager.json')
MANAGER_PORT = 7080
#: Where vpn-router-setup installs Caddy from, https.caddy_source: the Ubuntu archive or Caddy's own repository.
CADDY_SOURCES = ('ubuntu', 'upstream')
@@ -194,6 +231,9 @@ CA_NAME_MAX = 64
#: What / and /status of the web service show, web.status_content, from the least to the most.
STATUS_CONTENTS = ('basic', 'standard', 'full')
#: The longest web.shell_idle_minutes and web.shell_auth_timeout, a day.
SHELL_IDLE_MAX = 1440
DOCKER_MODES = ('auto', 'on', 'off')
OPENVPN_PROTOCOLS = ('udp', 'tcp')
@@ -431,6 +471,9 @@ def validate(cfg):
raise ValueError
except ValueError:
raise ConfigError(f'openvpn.address_pool: {openvpn_pool!r} is not an IPv4 network')
for system, option in (('p2s', 'address_pool'), ('openvpn', 'address_pool'), ('wireguard', 'address')):
if _get(cfg, system, 'enabled') and not _get(cfg, system, option):
raise ConfigError(f'{system}.enabled needs {system}.{option}')
_check_address(_get(cfg, 'local', 'int_addr'), 'local.int_addr')
_check_address(_get(cfg, 'local', 'int_gateway_ip'), 'local.int_gateway_ip')
@@ -445,10 +488,16 @@ def validate(cfg):
wg_address = _get(cfg, 'wireguard', 'address')
if wg_address:
try:
ipaddress.ip_interface(wg_address)
interface = ipaddress.ip_interface(wg_address)
except ValueError:
raise ConfigError(
f'wireguard.address: {wg_address!r} is not an address with prefix')
# The router's own address on wg0, from which the peers' addresses of the same network are told apart.
network = interface.network
if network.num_addresses > 2 and interface.ip in (network.network_address, network.broadcast_address):
raise ConfigError(
f'wireguard.address: {wg_address} is the network or broadcast address of {network}; give the '
f'router\'s own address in it, such as {next(network.hosts())}/{network.prefixlen}')
port = _get(cfg, 'wireguard', 'listen_port')
if not 1 <= port <= 65535:
@@ -477,6 +526,13 @@ def validate(cfg):
raise ConfigError(
f'web.status_content: {status_content!r} is not one of {", ".join(STATUS_CONTENTS)}')
shell_idle = _get(cfg, 'web', 'shell_idle_minutes')
if not 1 <= shell_idle <= SHELL_IDLE_MAX:
raise ConfigError(f'web.shell_idle_minutes: {shell_idle} is not between 1 and {SHELL_IDLE_MAX}')
shell_auth = _get(cfg, 'web', 'shell_auth_timeout')
if not 0 <= shell_auth <= SHELL_IDLE_MAX:
raise ConfigError(f'web.shell_auth_timeout: {shell_auth} is not between 0 and {SHELL_IDLE_MAX}')
https_ports = []
for option in ('web_port', 'manager_port'):
port = _get(cfg, 'https', option)
@@ -491,10 +547,53 @@ def validate(cfg):
for item in _get_list(cfg, 'web', 'trusted_proxies'):
_check_address(item, 'web.trusted_proxies')
log_files = _get_list(cfg, 'web', 'log_files')
if len(log_files) > LOG_FILES_MAX:
raise ConfigError(f'web.log_files: at most {LOG_FILES_MAX} files')
for item in log_files:
if not item.startswith('/') or '/../' in f'{item}/':
raise ConfigError(f'web.log_files: {item!r} is not an absolute path')
caddy_source = _get(cfg, 'https', 'caddy_source')
if caddy_source not in CADDY_SOURCES:
raise ConfigError(f'https.caddy_source: {caddy_source!r} is not one of {", ".join(CADDY_SOURCES)}')
if _get(cfg, 'oidc', 'enabled') and not (_get(cfg, 'web', 'enabled') and _get(cfg, 'https', 'enabled')
and _get(cfg, 'wan', 'local_fqdn')):
raise ConfigError('oidc.enabled: the OpenID Provider is the web service served by Caddy at '
'https://<wan.local_fqdn>:<https.web_port>, which needs web.enabled, https.enabled and '
'wan.local_fqdn')
upload_max = _get(cfg, 'sites', 'upload_max_mb')
if not 1 <= upload_max <= UPLOAD_MAX_MB:
raise ConfigError(f'sites.upload_max_mb: {upload_max} is not between 1 and {UPLOAD_MAX_MB}')
if _get(cfg, 'sites', 'webdav') and not (_get(cfg, 'https', 'enabled') and _get(cfg, 'wan', 'local_fqdn')):
raise ConfigError('sites.webdav: WebDAV is served by Caddy at https://<wan.local_fqdn>:<sites.webdav_port>, '
'which needs https.enabled and wan.local_fqdn')
dns = cfg['dns']
if dns['enabled']:
for option in ('zone', 'nameserver'):
if not ZONE_NAME.match(dns[option].strip()):
raise ConfigError(f'dns.{option}: {dns[option]!r} is not a DNS name in lowercase')
if not _get_list(cfg, 'dns', 'addresses'):
raise ConfigError('dns.enabled needs dns.addresses, the public addresses of the zone')
for item in _get_list(cfg, 'dns', 'addresses'):
_check_address(item, 'dns.addresses')
if dns['listen'] not in LISTEN_CHOICES:
raise ConfigError(f'dns.listen: {dns["listen"]!r} is not one of {", ".join(LISTEN_CHOICES)}')
if not DNS_TTL_RANGE[0] <= dns['ttl'] <= DNS_TTL_RANGE[1]:
raise ConfigError(f'dns.ttl: {dns["ttl"]} is not between {DNS_TTL_RANGE[0]} and {DNS_TTL_RANGE[1]} seconds')
resolver_listen = _get(cfg, 'p2s', 'dns_listen')
if resolver_listen not in LISTEN_CHOICES:
raise ConfigError(f'p2s.dns_listen: {resolver_listen!r} is not one of {", ".join(LISTEN_CHOICES)}')
sides = {'internal': {'internal'}, 'external': {'external'}, 'both': {'internal', 'external'}}
remote_access = any(_get(cfg, system, 'enabled') for system in ('p2s', 'openvpn', 'wireguard'))
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]))} '
f'addresses is the resolver of the remote access clients (p2s.dns_listen {resolver_listen}) '
'while p2s.enabled, openvpn.enabled or wireguard.enabled is true')
for option in ('root_name', 'p2s_name', 's2s_name'):
value = _get(cfg, 'pki', option)
if not value or len(value) > CA_NAME_MAX or not value.isprintable():
@@ -585,13 +684,13 @@ def validate(cfg):
if _get(cfg, 'openvpn', 'protocol') == 'tcp' and _get(cfg, 'openvpn', 'port') == HTTPS_DEFAULT_PORT:
raise ConfigError(f'pki.publish: https-default takes port {HTTPS_DEFAULT_PORT}, the port of openvpn.port')
taken[HTTPS_DEFAULT_PORT] = 'pki.publish https-default'
for option in ('port', 'https_port'):
port = _get(cfg, 'metrics', option)
for section_name, option in (('metrics', 'port'), ('metrics', 'https_port'), ('sites', 'webdav_port')):
port = _get(cfg, section_name, option)
if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
raise ConfigError(f'metrics.{option}: {port!r} is not a valid port')
raise ConfigError(f'{section_name}.{option}: {port!r} is not a valid port')
if port in taken:
raise ConfigError(f'metrics.{option}: {port} is the port of {taken[port]}')
taken[port] = f'metrics.{option}'
raise ConfigError(f'{section_name}.{option}: {port} is the port of {taken[port]}')
taken[port] = f'{section_name}.{option}'
metrics_listen = _get(cfg, 'metrics', 'listen')
if metrics_listen not in LISTEN_CHOICES:
@@ -720,6 +819,21 @@ def pki_base_urls(cfg):
return [bases[item] for item in _get_list(cfg, 'pki', 'publish') if item in bases] if fqdn else []
def manager_port():
"""The port of the Router Manager: port in its settings, or MANAGER_PORT when it is not set or not a port."""
try:
port = json.loads(MANAGER_CONFIG.read_text(encoding='utf-8')).get('port')
except (OSError, ValueError, AttributeError):
return MANAGER_PORT
return port if isinstance(port, int) and not isinstance(port, bool) and 1 <= port <= 65535 else MANAGER_PORT
def oidc_issuer(cfg):
"""The issuer of the OpenID Provider: the web service's HTTPS site, without the port when it is HTTPS_DEFAULT_PORT."""
port = _get(cfg, 'https', 'web_port')
return f'https://{_get(cfg, "wan", "local_fqdn")}' + ('' if port == HTTPS_DEFAULT_PORT else f':{port}')
def resolve_psk(connection, name):
"""Return the pre-shared key of the site_to_site connection: psk_file wins, else psk; '' when none is configured."""
psk_file = connection['psk_file'].strip()
@@ -828,6 +942,10 @@ def build_context(cfg, public_ip=None):
openvpn_pool = _get(cfg, 'openvpn', 'address_pool')
openvpn_network = ipaddress.ip_network(openvpn_pool) if openvpn_pool else None
identities = {'fqdn': local_fqdn, 'public_ip': public_ip or '', 'internal_ip': int_addr}
# The addresses the remote access clients' resolver answers on, which p2s.dns_listen chooses.
resolver_addrs = [address for address in {'internal': [int_addr], 'external': local_addrs,
'both': [int_addr, *local_addrs]}[_get(cfg, 'p2s', 'dns_listen')]
if address]
connections = []
for name, connection in cfg['site_to_site'].items():
@@ -871,10 +989,15 @@ def build_context(cfg, public_ip=None):
'p2s_enabled': _get(cfg, 'p2s', 'enabled'),
'p2s_address_pool': _get(cfg, 'p2s', 'address_pool'),
'public_ip': public_ip or '',
'p2s_dns': _join(local_addrs, _split_list(extras['EXTRA_P2S_DNS'])),
'p2s_resolver_addrs': resolver_addrs,
'p2s_dns': _join(resolver_addrs, _split_list(extras['EXTRA_P2S_DNS'])),
'wg_enabled': _get(cfg, 'wireguard', 'enabled'),
'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_peers_begin': WG_PEERS_BEGIN,
'wg_peers_end': WG_PEERS_END,
'openvpn_enabled': _get(cfg, 'openvpn', 'enabled'),
'openvpn_pool': openvpn_pool,
'openvpn_network': str(openvpn_network.network_address) if openvpn_network else '',
@@ -885,7 +1008,9 @@ def build_context(cfg, public_ip=None):
'openvpn_protocol': _get(cfg, 'openvpn', 'protocol'),
'openvpn_device': OPENVPN_DEVICE,
'openvpn_ldap_conf': str(OPENVPN_LDAP),
'p2s_dns_list': _split_list(_join(local_addrs, _split_list(extras['EXTRA_P2S_DNS']))),
'openvpn_status': vpnrouter_status.OPENVPN_STATUS,
'openvpn_status_seconds': vpnrouter_status.OPENVPN_STATUS_SECONDS,
'p2s_dns_list': _split_list(_join(resolver_addrs, _split_list(extras['EXTRA_P2S_DNS']))),
'openvpn_ca': str(vpnrouter_pki.OPENVPN_CA),
'openvpn_crl': str(vpnrouter_pki.OPENVPN_CRL),
'server_cert': str(vpnrouter_pki.VPN_SERVER_CERT),
@@ -905,6 +1030,11 @@ def build_context(cfg, public_ip=None):
'metrics_port': _get(cfg, 'metrics', 'port'),
'metrics_https_port': _get(cfg, 'metrics', 'https_port'),
'metrics_service_account': _get(cfg, 'metrics', 'service_account'),
'sites_enabled': _get(cfg, 'sites', 'enabled'),
'webdav_enabled': _get(cfg, 'sites', 'webdav'),
'webdav_port': _get(cfg, 'sites', 'webdav_port'),
'dns_enabled': _get(cfg, 'dns', 'enabled'),
'dns_listen': _get(cfg, 'dns', 'listen'),
'loopback': LOOPBACK,
}
@@ -944,6 +1074,16 @@ def metrics_ready(ctx):
return bool(ctx['metrics_enabled'])
def webdav_ready(ctx):
"""WebDAV of the sites' folders is served by Caddy, on the router's own name, while it is on and HTTPS is ready."""
return bool(ctx['webdav_enabled'] and https_ready(ctx))
def dns_ready(ctx):
"""The name server answers for dns.zone while dns.enabled is true."""
return bool(ctx['dns_enabled'])
def _jinja_env():
return jinja2.Environment(
loader=jinja2.FileSystemLoader(str(TEMPLATE_DIR)),
@@ -967,27 +1107,38 @@ def render(env, ctx, template_name, dest, mode):
def existing_peers(path):
"""Return the [Peer] sections of an existing WireGuard config.
"""The block of the users' peers and the [Peer] sections added by hand of an existing WireGuard config, each ''
without one.
Peers are added by the operator by hand, so re-rendering the [Interface]
section must not discard them.
The block lies between WG_PEERS_BEGIN and WG_PEERS_END; the [Peer] sections outside it are the operator's, which
re-rendering the file keeps.
"""
path = pathlib.Path(path)
if not path.is_file():
return ''
lines = path.read_text(encoding='utf-8').splitlines(keepends=True)
return '', ''
text = path.read_text(encoding='utf-8')
block = ''
begin, end = text.find(WG_PEERS_BEGIN), text.find(WG_PEERS_END)
if begin != -1 and end > begin:
block = text[begin:end + len(WG_PEERS_END)]
text = text[:begin] + text[end + len(WG_PEERS_END):]
lines = text.splitlines(keepends=True)
for index, line in enumerate(lines):
if line.strip().lower() == '[peer]':
return ''.join(lines[index:])
return ''
return block, ''.join(lines[index:])
return block, ''
def render_wg_conf(env, ctx, dest=WG_CONF, mode=0o600):
"""Render wg0.conf, carrying any operator-defined peers across."""
content = env.get_template('wg0.conf.j2').render(ctx)
peers = existing_peers(dest)
if peers:
content = content.rstrip('\n') + '\n\n' + peers
"""Render wg0.conf: the [Interface], the block of the users' peers of ctx's wg_peers, the block the file holds while
wg_peers is None, and the peers the operator added by hand, carried across."""
content = env.get_template('wg0.conf.j2').render(ctx).rstrip('\n') + '\n'
block, own = existing_peers(dest)
if ctx['wg_peers'] is not None:
block = env.get_template('wg0-peers.conf.j2').render(ctx).strip('\n') if ctx['wg_peers'] else ''
for part in (block, own):
if part.strip():
content += '\n' + part.strip('\n') + '\n'
dest = pathlib.Path(dest)
if dest.exists() and dest.read_text(encoding='utf-8') == content:
@@ -1026,7 +1177,7 @@ def configure(cfg=None, public_ip=None):
"""Render configuration files and provision PKI.
Returns (ctx, changed) where changed is a set naming what needs reloading:
'swanctl', 'resolved', 'wireguard', 'openvpn'.
'swanctl', 'resolved', 'resolver', 'wireguard', 'openvpn'.
"""
if cfg is None:
cfg = load_config()
@@ -1056,6 +1207,13 @@ def configure(cfg=None, public_ip=None):
notices = [f'the PKI is left as it is: the directory cannot be reached: {exc}']
for notice in notices:
warn(notice)
# The WireGuard peers of the users; None keeps those of wg0.conf while the directory cannot be reached.
ctx['wg_peers'] = None
if wg_ready(ctx):
try:
ctx['wg_peers'] = vpnrouter_ldap.wireguard_peers()
except ldap.LDAPError as exc:
warn(f'the WireGuard peers of the users are left as they are: the directory cannot be reached: {exc}')
# What needs the router's own certificates.
ctx['server_certificate'] = vpnrouter_pki.VPN_SERVER_CERT.is_file()
for connection in ctx['connections']:
@@ -1076,11 +1234,15 @@ def configure(cfg=None, public_ip=None):
elif remove(ROAD_WARRIOR):
changed.add('swanctl')
if p2s_ready(ctx) or openvpn_ready(ctx):
if render(env, ctx, 'p2s-forwarder.conf.j2', P2S_FORWARDER, 0o644):
changed.add('resolved')
elif remove(P2S_FORWARDER):
# The resolver of the remote access clients is dnsmasq on the addresses of p2s.dns_listen; systemd-resolved keeps
# its loopback stub only, so its records of the router's own name stay on the router.
if remove(P2S_FORWARDER):
changed.add('resolved')
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):
changed.add('resolver')
elif remove(RESOLVER_CONF):
changed.add('resolver')
if wg_ready(ctx):
if render_wg_conf(env, ctx):
@@ -10,6 +10,9 @@ Every failure of the database raises DatabaseError, the only database exception
Refresh tokens are random strings kept in PostgreSQL as their SHA-256, in the database of
web.database, by default the vpn-router database on this host that provision() creates on the
web service's first run, with the password in DATABASE_SECRET. A refresh token is used once: refreshing replaces it.
The same database holds the state of the OpenID Provider (vpnrouter_oidc), whose tables and procedures OIDC_SQL
defines; the first connection of a process loads it. revoke() removes a user's refresh tokens and OpenID Provider
codes, tokens and sessions.
"""
import base64
@@ -26,6 +29,7 @@ import psycopg
WEB_KEY = pathlib.Path('/etc/vpn-router/web.key')
DATABASE_SECRET = pathlib.Path('/etc/vpn-router/database.secret')
OIDC_SQL = pathlib.Path('/usr/share/vpn-router/sql/oidc.sql')
#: The role and the database provision() creates when web.database is not set.
DATABASE = 'vpn-router'
@@ -45,6 +49,9 @@ LEEWAY = 60
SCHEMA = 'CREATE TABLE IF NOT EXISTS refresh_tokens (hash text PRIMARY KEY, name text NOT NULL, expires timestamptz NOT NULL)'
#: Whether this process has loaded OIDC_SQL.
_oidc_loaded = False
class DatabaseError(Exception):
"""The database of the refresh tokens failed."""
@@ -120,21 +127,36 @@ def admin_token(seconds):
return {'token': sign({'admin': True, 'iat': now, 'exp': now + seconds}, secret), 'expires': now + seconds}
def local_connection(name, secret):
"""The connection string of a database provision_database() created; its password, of URL-safe characters, is
written unquoted."""
password = secret.read_text(encoding='utf-8').strip()
return f'host=localhost dbname={name} user={name} password={password}'
def connection_string(cfg):
"""web.database, or the database provision() creates; its password, of URL-safe characters, is written unquoted."""
"""web.database, or the database provision() creates."""
configured = cfg['web']['database'].strip()
if configured:
return configured
password = DATABASE_SECRET.read_text(encoding='utf-8').strip()
return f'host=localhost dbname={DATABASE} user={DATABASE} password={password}'
return local_connection(DATABASE, DATABASE_SECRET)
@contextlib.contextmanager
def database(cfg):
"""A connection to the database of the refresh tokens, with their table, closed at the end; DatabaseError on any failure."""
"""A connection to the database of the refresh tokens, with their table and the OpenID Provider's, closed at the end;
DatabaseError on any failure."""
global _oidc_loaded
try:
oidc = None if _oidc_loaded else OIDC_SQL.read_text(encoding='utf-8')
except OSError as exc:
raise DatabaseError(f'{OIDC_SQL} cannot be read: {exc}') from exc
try:
with psycopg.connect(connection_string(cfg), autocommit=True) as connection:
connection.execute(SCHEMA)
if oidc is not None:
connection.execute(oidc)
_oidc_loaded = True
yield connection
except psycopg.Error as exc:
raise DatabaseError(f'the token database failed: {exc}') from exc
@@ -173,9 +195,10 @@ def take(cfg, token):
def revoke(cfg, name):
"""Remove all refresh tokens of the user."""
"""Remove all refresh tokens of the user, and its codes, tokens and sessions of the OpenID Provider."""
with database(cfg) as connection:
connection.execute('DELETE FROM refresh_tokens WHERE name = %s', (name,))
connection.execute('CALL oidc_revoke(%s)', (name,))
def psql(sql, name):
@@ -187,21 +210,33 @@ def psql(sql, name):
return result.stdout.strip()
def provision(cfg):
"""Create the role, its password file and the database when web.database is not set and they are missing.
def provision_database(name, secret):
"""Create the role of the name, its password file secret and its database of the same name when they are missing.
The password, of URL-safe characters, reaches psql on standard input, not on its command line.
"""
if cfg['web']['database'].strip():
return
if not psql("SELECT 1 FROM pg_roles WHERE rolname = :'v1'", DATABASE):
DATABASE_SECRET.unlink(missing_ok=True)
psql("CREATE ROLE :\"v1\" LOGIN", DATABASE)
if not DATABASE_SECRET.is_file():
if not psql("SELECT 1 FROM pg_roles WHERE rolname = :'v1'", name):
secret.unlink(missing_ok=True)
psql("CREATE ROLE :\"v1\" LOGIN", name)
if not secret.is_file():
password = secrets.token_urlsafe(32)
psql(f"ALTER ROLE :\"v1\" PASSWORD '{password}'", DATABASE)
DATABASE_SECRET.touch(mode=0o600)
DATABASE_SECRET.chmod(0o600)
DATABASE_SECRET.write_text(password + '\n', encoding='utf-8')
if not psql("SELECT 1 FROM pg_database WHERE datname = :'v1'", DATABASE):
psql("CREATE DATABASE :\"v1\" OWNER :\"v1\"", DATABASE)
psql(f"ALTER ROLE :\"v1\" PASSWORD '{password}'", name)
secret.touch(mode=0o600)
secret.chmod(0o600)
secret.write_text(password + '\n', encoding='utf-8')
if not psql("SELECT 1 FROM pg_database WHERE datname = :'v1'", name):
psql("CREATE DATABASE :\"v1\" OWNER :\"v1\"", name)
def reset_password(name, secret):
"""Give the role of the name the password of its file secret, when both exist, as after a restore."""
if secret.is_file() and psql("SELECT 1 FROM pg_roles WHERE rolname = :'v1'", name):
password = secret.read_text(encoding='utf-8').strip()
psql(f"ALTER ROLE :\"v1\" PASSWORD '{password}'", name)
def provision(cfg):
"""Create the role, its password file and the database of the refresh tokens when web.database is not set and they
are missing."""
if not cfg['web']['database'].strip():
provision_database(DATABASE, DATABASE_SECRET)
@@ -1,9 +1,10 @@
"""The backups of the router, for vpn-router-backup and the web service.
A backup is a consistent copy of the state that setup cannot make again, staged in STAGING: the
trees of TREES, the files of site_to_site psk_file outside them under files/, the directory as
slapcat writes it (ldap/vpn-router.ldif), with backup.metrics the database as pg_dump writes it
(postgresql/vpn-router.dump), and manifest.json. The trees and the directory are copied while
trees of TREES, the folders of the web sites among them, the files of site_to_site psk_file outside them under files/,
the directory as slapcat writes it (ldap/vpn-router.ldif), with backup.metrics the database as pg_dump writes it
(postgresql/vpn-router.dump), the databases of the web sites and of the name server (DUMPS) once they exist, and
manifest.json. The trees and the directory are copied while
the configuration lock and the users lock are held, so the configuration, the PKI and the users
match; pg_dump reads a snapshot of its own.
@@ -33,7 +34,9 @@ import time
import vpnrouter
import vpnrouter_auth
import vpnrouter_dns
import vpnrouter_ldap
import vpnrouter_sites
import vpnrouter_users
STATE = pathlib.Path('/var/lib/vpn-router/backup.json')
@@ -49,7 +52,12 @@ UNIT = 'vpn-router-backup.service'
TIMER = 'vpn-router-backup.timer'
#: The state a backup copies: directories and files, each where it is on the system.
TREES = ('/etc/vpn-router', '/etc/wireguard', '/etc/ufw', '/etc/default/ufw', '/etc/vpn-router-manager')
TREES = ('/etc/vpn-router', '/etc/wireguard', '/etc/ufw', '/etc/default/ufw', '/etc/vpn-router-manager',
str(vpnrouter_sites.SITES_DIR))
#: The databases a backup always dumps, each while it exists: the dump -> the database and its password file.
DUMPS = {'postgresql/vpn-router-sites.dump': (vpnrouter_sites.DATABASE, vpnrouter_sites.DATABASE_SECRET),
'postgresql/vpn-router-dns.dump': (vpnrouter_dns.DATABASE, vpnrouter_dns.DATABASE_SECRET)}
#: The name of a local archive: the host and the UTC time of the backup.
ARCHIVE = re.compile(r'^vpn-router-(?P<host>[A-Za-z0-9][A-Za-z0-9.-]*)-(?P<stamp>\d{8}T\d{6}Z)\.tar\.zst$')
@@ -64,7 +72,8 @@ PERIODS = {'keep_hourly': '%Y-%m-%d %H', 'keep_daily': '%Y-%m-%d', 'keep_weekly'
'keep_yearly': '%Y'}
#: The units a restore stops while it replaces the directory and the database, and the ones it restarts after setup.
RESTORE_STOPS = ('vpn-router-web.service', 'vpn-router-metrics.service', 'slapd.service')
RESTORE_STOPS = ('vpn-router-web.service', 'vpn-router-metrics.service', 'vpn-router-webdav.service', 'pdns.service',
'slapd.service')
RESTORE_RESTARTS = ('wg-quick@wg0.service', 'openvpn-server@vpn-router.service', 'strongswan.service',
'vpn-router-manager.service')
@@ -223,14 +232,16 @@ def stage(cfg):
(STAGING / LDIF).parent.mkdir()
command(['slapcat', '-b', vpnrouter_ldap.BASE, '-l', str(STAGING / LDIF)])
items = trees + [f'files{name}' for name in files] + [LDIF]
if cfg['backup']['metrics']:
(STAGING / DUMP).parent.mkdir()
prefix, database = database_args(cfg)
with open(STAGING / DUMP, 'wb') as dump:
dumps = {**({DUMP: database_args(cfg)} if cfg['backup']['metrics'] else {}),
**{item: (['runuser', '-u', 'postgres', '--'], [f'--dbname={name}'])
for item, (name, secret) in DUMPS.items() if secret.is_file()}}
for item, (prefix, database) in dumps.items():
(STAGING / item).parent.mkdir(exist_ok=True)
with open(STAGING / item, 'wb') as dump:
result = subprocess.run([*prefix, 'pg_dump', '-Fc', *database], stdout=dump, stderr=subprocess.PIPE)
if result.returncode != 0:
raise Failure(f'pg_dump failed: {result.stderr.decode(errors="replace").strip()}')
items.append(DUMP)
raise Failure(f'pg_dump for {item} failed: {result.stderr.decode(errors="replace").strip()}')
items.append(item)
version = subprocess.run(['dpkg-query', '-W', '-f=${Version}', 'vpn-router'], capture_output=True, text=True)
manifest = {'host': host(), 'time': now(), 'version': version.stdout.strip() or None,
'schedule': cfg['backup']['schedule'], 'items': items}
@@ -389,19 +400,22 @@ def restore(source, pbs, confirm):
subprocess.run(['systemctl', 'start', 'slapd.service'], capture_output=True)
restored = vpnrouter.load_config()
if not restored['web']['database'].strip():
secret = vpnrouter_auth.DATABASE_SECRET
if secret.is_file() and vpnrouter_auth.psql("SELECT 1 FROM pg_roles WHERE rolname = :'v1'",
vpnrouter_auth.DATABASE):
password = secret.read_text(encoding='utf-8').strip()
vpnrouter_auth.psql(f"ALTER ROLE :\"v1\" PASSWORD '{password}'", vpnrouter_auth.DATABASE)
vpnrouter_auth.reset_password(vpnrouter_auth.DATABASE, vpnrouter_auth.DATABASE_SECRET)
vpnrouter_auth.provision(restored)
if DUMP in manifest['items']:
prefix, database = database_args(restored)
with open(RESTORE_DIR / DUMP, 'rb') as dump:
dumps = {DUMP: database_args(restored)}
for item, (name, secret) in DUMPS.items():
if item in manifest['items']:
vpnrouter_auth.reset_password(name, secret)
vpnrouter_auth.provision_database(name, secret)
dumps[item] = (['runuser', '-u', 'postgres', '--'], [f'--dbname={name}'])
for item, (prefix, database) in dumps.items():
if item not in manifest['items']:
continue
with open(RESTORE_DIR / item, 'rb') as dump:
result = subprocess.run([*prefix, 'pg_restore', '--clean', '--if-exists', *database], stdin=dump,
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
if result.returncode != 0:
raise Failure(f'pg_restore failed: {result.stderr.decode(errors="replace").strip()}')
raise Failure(f'pg_restore of {item} failed: {result.stderr.decode(errors="replace").strip()}')
subprocess.run(['systemctl', 'start', 'vpn-router-web.service'], capture_output=True)
command(['systemctl', 'restart', 'vpn-router-setup.service'])
for unit in RESTORE_RESTARTS:
@@ -0,0 +1,268 @@
"""The router's authoritative name server, PowerDNS with its gpgsql backend, for vpn-router-setup and the web service.
The name server answers for one zone, dns.zone, on the addresses of the interfaces of dns.listen. Its records are in
the vpn-router-dns database, which provision() creates with its role and the password in DATABASE_SECRET, and fills with
the schema of pdns-backend-pgsql, PDNS_SCHEMA; DNS_SQL defines the router's own tables and procedures, and the first
connection of a process loads it. apply() writes the zone: its SOA, its NS, dns.nameserver, A and AAAA records of
dns.addresses for the zone's apex, for the name server when it is in the zone, and for every host name of an enabled
site in the zone, and the records an administrator added (manual records), but those of a name and type the records
before them have. The serial of the SOA grows only when the records change.
Every failure of the database raises DatabaseError; a record that cannot be added raises Failure.
"""
import contextlib
import ipaddress
import pathlib
import re
import psycopg
from psycopg.rows import dict_row
import vpnrouter_auth
DATABASE = 'vpn-router-dns'
DATABASE_SECRET = pathlib.Path('/etc/vpn-router/dns-database.secret')
DNS_SQL = pathlib.Path('/usr/share/vpn-router/sql/dns.sql')
#: The schema of PowerDNS's gpgsql backend, which pdns-backend-pgsql installs.
PDNS_SCHEMA = pathlib.Path('/usr/share/pdns-backend-pgsql/schema/schema.pgsql.sql')
#: The settings of PowerDNS that vpn-router-setup writes, read after /etc/powerdns/pdns.conf; the server, its unit and
#: the group it runs as.
PDNS_CONF = pathlib.Path('/etc/powerdns/pdns.d/vpn-router.conf')
PDNS_SERVER = pathlib.Path('/usr/sbin/pdns_server')
UNIT = 'pdns.service'
GROUP = 'pdns'
#: The types of the manual records.
TYPES = ('A', 'AAAA', 'CNAME', 'TXT', 'MX', 'SRV', 'CAA')
#: The most manual records.
MANUAL_MAX = 1000
#: The refresh, retry and expire times of the SOA, in seconds.
SOA_TIMES = '10800 3600 604800'
#: A label of a record's name: letters, digits, hyphens and underscores, or * as the first label.
LABEL = re.compile(r'^[a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9_])?$')
#: The content of an MX, SRV and CAA record, and a DNS name a record points to.
MX = re.compile(r'^(\d{1,5}) (\S+)$')
SRV = re.compile(r'^(\d{1,5}) (\d{1,5}) (\d{1,5}) (\S+)$')
CAA = re.compile(r'^(0|128) (issue|issuewild|iodef) "[^"\\]*"$')
TARGET = 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_])?)*$')
#: The longest content of a TXT record, in characters.
TXT_MAX = 2048
#: Whether this process has loaded DNS_SQL.
_loaded = False
class DatabaseError(Exception):
"""The database of the name server failed."""
class Failure(Exception):
"""A record that cannot be added or removed; status is 2 invalid, 3 no such record."""
def __init__(self, message, status=2):
super().__init__(message)
self.status = status
def provision():
"""Create the role, its password file and the database of the name server when they are missing, and PowerDNS's
tables in it; DatabaseError while pdns-backend-pgsql is not installed."""
vpnrouter_auth.provision_database(DATABASE, DATABASE_SECRET)
try:
with psycopg.connect(vpnrouter_auth.local_connection(DATABASE, DATABASE_SECRET), autocommit=True) as connection:
if connection.execute("SELECT to_regclass('public.domains')").fetchone()[0] is None:
connection.execute(PDNS_SCHEMA.read_text(encoding='utf-8'))
except OSError as exc:
raise DatabaseError(f'the schema of PowerDNS cannot be read: {exc}') from exc
except psycopg.Error as exc:
raise DatabaseError(f'the database of the name server failed: {exc}') from exc
@contextlib.contextmanager
def database():
"""A connection to the database of the name server, with the router's tables and procedures, closed at the end;
DatabaseError on any failure, also before provision() made it."""
global _loaded
try:
schema = None if _loaded else DNS_SQL.read_text(encoding='utf-8')
target = vpnrouter_auth.local_connection(DATABASE, DATABASE_SECRET)
except OSError as exc:
raise DatabaseError(f'the database of the name server is not ready: {exc}') from exc
try:
with psycopg.connect(target, autocommit=True, row_factory=dict_row) as connection:
if schema is not None:
connection.execute(schema)
_loaded = True
yield connection
except psycopg.Error as exc:
raise DatabaseError(f'the database of the name server failed: {exc}') from exc
def in_zone(zone, name):
return name == zone or name.endswith(f'.{zone}')
def absolute(zone, name):
"""The record name in the zone: @ or '' is the zone itself, a name ending in a dot is taken as it is, any other is
relative to the zone; Failure for a name outside the zone or not a DNS name."""
if not isinstance(name, str):
raise Failure('name must be a string')
name = name.strip().lower()
if name in ('', '@'):
return zone
full = name[:-1] if name.endswith('.') else f'{name}.{zone}'
labels = full.split('.')
if not in_zone(zone, full) or not all(LABEL.match(label) or (index == 0 and label == '*')
for index, label in enumerate(labels)):
raise Failure(f'{name!r} is not a name in {zone}')
return full
def check_record(zone, record):
"""The manual record {name, type, content, ttl?} with its name made absolute and its content checked; Failure when
it is not valid. A TXT content not in quotes is quoted."""
if not isinstance(record, dict):
raise Failure('a record is an object of name, type, content and ttl')
name = absolute(zone, record.get('name'))
kind = record.get('type')
content = record.get('content')
ttl = record.get('ttl')
if kind not in TYPES:
raise Failure(f'type must be one of {", ".join(TYPES)}')
if not isinstance(content, str) or not content.strip():
raise Failure('content must be a string')
if ttl is not None and (isinstance(ttl, bool) or not isinstance(ttl, int) or not 60 <= ttl <= 86400):
raise Failure('ttl must be 60 to 86400 seconds')
content = content.strip()
if kind in ('A', 'AAAA'):
try:
address = ipaddress.ip_address(content)
except ValueError:
address = None
if address is None or address.version != (4 if kind == 'A' else 6):
raise Failure(f'{content!r} is not an IPv{4 if kind == "A" else 6} address')
content = str(address)
elif kind == 'CNAME':
if name == zone:
raise Failure('the zone itself cannot have a CNAME record')
content = content.lower().rstrip('.')
if not TARGET.match(content):
raise Failure(f'{content!r} is not a DNS name')
elif kind == 'TXT':
if not (content.startswith('"') and content.endswith('"') and len(content) > 1):
content = '"' + content.replace('\\', '\\\\').replace('"', '\\"') + '"'
if len(content) > TXT_MAX or not content.isprintable():
raise Failure(f'the text must have at most {TXT_MAX} printable characters')
elif kind in ('MX', 'SRV'):
match = (MX if kind == 'MX' else SRV).match(content.lower())
if not match or any(int(value) > 65535 for value in match.groups()[:-1]) or \
not TARGET.match(match.groups()[-1].rstrip('.')):
raise Failure(f'{kind} content is ' + ('"<preference> <host>"' if kind == 'MX'
else '"<priority> <weight> <port> <host>"'))
content = ' '.join([*match.groups()[:-1], match.groups()[-1].rstrip('.')])
elif not CAA.match(content):
raise Failure('CAA content is "<0 or 128> <issue, issuewild or iodef> \\"<value>\\""')
return {'name': name, 'type': kind, 'content': content, 'ttl': ttl}
def manual():
"""The manual records."""
with database() as connection:
return connection.execute('SELECT * FROM dns_manual_list()').fetchall()
def add(cfg, record):
"""Add the manual record, or set the TTL of the same one; the record."""
zone = cfg['dns']['zone'].strip()
if not zone:
raise Failure('dns.zone is not set')
record = check_record(zone, record)
record['ttl'] = record['ttl'] or cfg['dns']['ttl']
with database() as connection:
records = connection.execute('SELECT * FROM dns_manual_list()').fetchall()
same = [row for row in records if (row['name'], row['type']) == (record['name'], record['type'])]
others = [row for row in records if row['name'] == record['name'] and row['type'] != record['type']]
if len(records) >= MANUAL_MAX and record not in records:
raise Failure(f'the zone has {MANUAL_MAX} manual records')
if record['type'] == 'CNAME' and (others or [row for row in same if row['content'] != record['content']]):
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):
raise Failure(f'{record["name"]} has a CNAME record, which must be its only record')
connection.execute('CALL dns_manual_put(%s, %s, %s, %s)',
(record['name'], record['type'], record['content'], record['ttl']))
return record
def remove(cfg, name, kind, content):
"""Remove the manual records of the name and type, only the one of the content when it is given."""
zone = cfg['dns']['zone'].strip()
full = absolute(zone, name)
with database() as connection:
found = [row for row in connection.execute('SELECT * FROM dns_manual_list()').fetchall()
if (row['name'], row['type']) == (full, kind) and content in (None, row['content'])]
if not found:
raise Failure(f'no {kind} record of {full}', 3)
connection.execute('CALL dns_manual_remove(%s, %s, %s)', (full, kind, content))
return {'name': full, 'type': kind, 'removed': len(found)}
def served(zone):
"""The records of the zone as the name server answers them, [] when it serves none."""
with database() as connection:
return connection.execute('SELECT * FROM dns_served(%s)', (zone,)).fetchall()
def records(cfg, hosts, manual_records):
"""The records of the zone besides its SOA, as (name, type, content, ttl): its NS, the A and AAAA records of
dns.addresses for the apex, the name server and each host name in the zone, and the manual records."""
dns = cfg['dns']
zone, nameserver, ttl = dns['zone'].strip(), dns['nameserver'].strip(), dns['ttl']
addresses = [ipaddress.ip_address(item.strip()) for item in dns['addresses'] if item.strip()]
found = [(zone, 'NS', nameserver, ttl)]
for name in dict.fromkeys([zone, *([nameserver] if in_zone(zone, nameserver) else []),
*(host for host in hosts if in_zone(zone, host))]):
found += [(name, 'A' if address.version == 4 else 'AAAA', str(address), ttl) for address in addresses]
taken = {(name, kind) for name, kind, _, _ in found}
found += [(row['name'], row['type'], row['content'], row['ttl']) for row in manual_records
if (row['name'], row['type']) not in taken]
return list(dict.fromkeys(found))
def apply(cfg, hosts):
"""Make the name server serve dns.zone with records(), or no zone while dns.enabled is false."""
with database() as connection:
if not cfg['dns']['enabled']:
connection.execute('CALL dns_clear()')
return
dns = cfg['dns']
zone = dns['zone'].strip()
rows = records(cfg, hosts, connection.execute('SELECT * FROM dns_manual_list()').fetchall())
soa = f'{dns["nameserver"].strip()} hostmaster.{zone} {{serial}} {SOA_TIMES} {dns["ttl"]}'
connection.execute('CALL dns_apply(%s, %s, %s, %s, %s, %s, %s)',
(zone, soa, dns['ttl'], [row[0] for row in rows], [row[1] for row in rows],
[row[2] for row in rows], [row[3] for row in rows]))
def config_text(addresses):
"""The settings of PowerDNS: the gpgsql backend on the database of the name server, and the addresses it answers
on."""
password = DATABASE_SECRET.read_text(encoding='utf-8').strip()
return '\n'.join([
'# Written by vpn-router-setup from /etc/vpn-router/vpn-router.json; edits are overwritten.',
'launch=gpgsql',
'gpgsql-host=localhost',
f'gpgsql-dbname={DATABASE}',
f'gpgsql-user={DATABASE}',
f'gpgsql-password={password}',
f'local-address={", ".join(addresses)}',
'local-port=53',
'',
])
@@ -12,6 +12,10 @@ a successful bind clears the count. slapd's otp overlay is configured on the dat
bind of a user with a TOTP token takes the password followed by a current code.
cn=totp,ou=policies holds the TOTP parameters the token of every user points to.
A user with console login on its own account is also a posixAccount, with a uidNumber from
CONSOLE_UID_MIN and the gidNumber of the local group CONSOLE_GROUP; nslcd serves those with
vpnRouterConsoleLogin TRUE to the system over the local socket.
ou=pki holds the router's PKI (vpnrouter_pki): an entry per CA, and under it an entry per
certificate the CA issued. Private keys are readable by root alone, the database's root DN.
"""
@@ -27,6 +31,8 @@ import ldap.modlist
import ldap.sasl
URI = 'ldapi:///'
#: The local socket ldapi:/// names, which slapd listens on.
SLAPD_SOCKET = '/var/run/slapd/ldapi'
BASE = 'dc=vpn-router,dc=local'
USERS = f'ou=users,{BASE}'
GROUPS = f'ou=groups,{BASE}'
@@ -57,8 +63,15 @@ OVERLAYS = ('ppolicy', 'otp')
#: The package's own schema, under an OID of the UUID arc 2.25 (ITU-T X.667): attribute types
#: under .1 and object classes under .2. vpnRouterAccount, auxiliary, adds properties of the
#: package to a user of either kind: mail, for a Manager-only account, vpnRouterScope,
#: authorization scopes, of which service marks a service account, and displayName and
#: description, the labels of a service account. vpnRouterAuthority is a CA of the PKI: its
#: authorization scopes, of which service marks a service account, displayName and
#: description, the labels of a service account, and vpnRouterConsoleLogin and
#: vpnRouterConsoleAccount, whether the user may use a console and the local account it runs as,
#: without which it runs as the user's own posixAccount, and vpnRouterWireGuardPeer, each a
#: WireGuard peer of the user as "<public key> <IP>[,<IP>...]", and vpnRouterPasskey, each a
#: passkey of the user as JSON (vpnrouter_passkeys). A service account with vpnRouterRedirectUri
#: is a client of the OpenID Provider (vpnrouter_oidc): the URIs it is sent back to after a sign-in
#: and, vpnRouterPostLogoutRedirectUri, after a sign-out, and vpnRouterAllowedGroup, the DNs of the
#: groups whose members may sign in to it. vpnRouterAuthority is a CA of the PKI: its
#: certificate and CRL (cACertificate and certificateRevocationList of RFC 4523), its key, the
#: CSR it waits with for the offline root, the number of its last CRL, where the CRL came from,
#: and when it was retired. vpnRouterCertificate is a certificate a CA issued, named by its
@@ -85,10 +98,26 @@ SCHEMA_ATTRIBUTE_TYPES = (
f"( {SCHEMA_OID}.1.10 NAME 'vpnRouterNotAfter' DESC 'VPN router: when a certificate expires' {_TIME} )",
f"( {SCHEMA_OID}.1.11 NAME 'vpnRouterRevoked' DESC 'VPN router: when a certificate was revoked' {_TIME} )",
f"( {SCHEMA_OID}.1.12 NAME 'vpnRouterRevocationReason' DESC 'VPN router: why a certificate was revoked' {_STRING} )",
f"( {SCHEMA_OID}.1.13 NAME 'vpnRouterConsoleLogin' DESC 'VPN router: whether the user may use a console' "
"EQUALITY booleanMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 SINGLE-VALUE )",
f"( {SCHEMA_OID}.1.14 NAME 'vpnRouterConsoleAccount' DESC 'VPN router: the local account the console of the user runs as' "
f"{_STRING} )",
f"( {SCHEMA_OID}.1.15 NAME 'vpnRouterWireGuardPeer' DESC 'VPN router: a WireGuard peer of the user, its public key and IPs' "
"EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1024} )",
f"( {SCHEMA_OID}.1.16 NAME 'vpnRouterPasskey' DESC 'VPN router: a passkey of the user, its credential as JSON' "
"EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{4096} )",
f"( {SCHEMA_OID}.1.17 NAME 'vpnRouterRedirectUri' DESC 'VPN router: a redirect URI of an OpenID Connect client' "
"EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1024} )",
f"( {SCHEMA_OID}.1.18 NAME 'vpnRouterPostLogoutRedirectUri' DESC 'VPN router: a post-logout redirect URI of an "
"OpenID Connect client' EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1024} )",
f"( {SCHEMA_OID}.1.19 NAME 'vpnRouterAllowedGroup' DESC 'VPN router: a group whose members may sign in to an "
"OpenID Connect client' EQUALITY distinguishedNameMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 )",
)
SCHEMA_OBJECT_CLASSES = (
f"( {SCHEMA_OID}.2.1 NAME 'vpnRouterAccount' DESC 'VPN router: properties of a user or a Manager-only account' "
"SUP top AUXILIARY MAY ( mail $ vpnRouterScope $ displayName $ description ) )",
"SUP top AUXILIARY MAY ( mail $ vpnRouterScope $ displayName $ description $ vpnRouterConsoleLogin $ "
"vpnRouterConsoleAccount $ vpnRouterWireGuardPeer $ vpnRouterPasskey $ vpnRouterRedirectUri $ "
"vpnRouterPostLogoutRedirectUri $ vpnRouterAllowedGroup ) )",
f"( {SCHEMA_OID}.2.2 NAME 'vpnRouterAuthority' DESC 'VPN router: a CA of the PKI' SUP top STRUCTURAL MUST cn "
"MAY ( cACertificate $ certificateRevocationList $ vpnRouterPrivateKey $ vpnRouterCertificateRequest $ "
"vpnRouterCRLNumber $ vpnRouterCRLSource $ vpnRouterRetired ) )",
@@ -103,6 +132,19 @@ TOTP_ALGORITHM = '1.2.840.113549.2.7'
TOTP_PERIOD = 30
TOTP_WINDOW = 1
#: The value of pwdAccountLockedTime that disables a user until it is enabled again.
DISABLED = '000001010000Z'
#: The indexes of the database.
INDEXES = ('objectClass eq', 'uid eq', 'uidNumber eq', 'vpnRouterConsoleLogin eq')
#: The POSIX accounts of console login (vpnrouter_users.console): the lowest uidNumber, which nslcd's nss_min_uid
#: matches, the local group that is their primary group and grants sudo, the shell and the parent of their homes.
CONSOLE_UID_MIN = 20000
CONSOLE_GROUP = 'vpn-router-console'
CONSOLE_SHELL = '/bin/bash'
CONSOLE_HOMES = '/home'
#: The ACLs of the database, in order.
ACCESS = (
'to attrs=vpnRouterPrivateKey by * none',
@@ -189,8 +231,9 @@ def _ensure_password_hash(connection):
def _ensure_database(connection):
"""The DN of the package's database in cn=config, created when missing; its ACLs are kept as ACCESS."""
found = connection.search_s('cn=config', ldap.SCOPE_ONELEVEL, f'(olcSuffix={BASE})', ['olcAccess'])
"""The DN of the package's database in cn=config, created when missing; its ACLs are kept as ACCESS, and the
indexes of INDEXES it lacks are added, which slapd builds online."""
found = connection.search_s('cn=config', ldap.SCOPE_ONELEVEL, f'(olcSuffix={BASE})', ['olcAccess', 'olcDbIndex'])
if not found:
DB_DIRECTORY.mkdir(mode=0o700, parents=True, exist_ok=True)
openldap = pwd.getpwnam('openldap')
@@ -201,13 +244,17 @@ def _ensure_database(connection):
'olcSuffix': [BASE.encode()],
'olcDbDirectory': [str(DB_DIRECTORY).encode()],
'olcRootDN': [ROOT.encode()],
'olcDbIndex': [b'objectClass eq', b'uid eq'],
'olcDbIndex': [index.encode() for index in INDEXES],
'olcAccess': [f'{{{index}}}{rule}'.encode() for index, rule in enumerate(ACCESS)],
}))
found = connection.search_s('cn=config', ldap.SCOPE_ONELEVEL, f'(olcSuffix={BASE})', ['olcAccess'])
found = connection.search_s('cn=config', ldap.SCOPE_ONELEVEL, f'(olcSuffix={BASE})', ['olcAccess', 'olcDbIndex'])
dn, attributes = found[0]
if unordered(attributes.get('olcAccess', [])) != list(ACCESS):
connection.modify_s(dn, [(ldap.MOD_REPLACE, 'olcAccess', [f'{{{index}}}{rule}'.encode() for index, rule in enumerate(ACCESS)])])
present = [value.decode() for value in attributes.get('olcDbIndex', [])]
missing = [index for index in INDEXES if index not in present]
if missing:
connection.modify_s(dn, [(ldap.MOD_ADD, 'olcDbIndex', [index.encode() for index in missing])])
return dn
@@ -259,6 +306,23 @@ def _ensure_manager(connection):
MANAGER_SECRET.write_text(password, encoding='utf-8')
def wireguard_peers():
"""The WireGuard peers of the users who are not disabled, read as root: [(user, public key, [IP, ...])], sorted."""
connection = root()
try:
found = connection.search_s(USERS, ldap.SCOPE_ONELEVEL,
f'(&(vpnRouterWireGuardPeer=*)(!(pwdAccountLockedTime={DISABLED})))',
['uid', 'vpnRouterWireGuardPeer'])
finally:
connection.unbind_s()
peers = []
for _, attributes in found:
for value in attributes['vpnRouterWireGuardPeer']:
key, _, ips = value.decode().partition(' ')
peers.append((attributes['uid'][0].decode(), key, ips.split(',')))
return sorted(peers)
def provision():
"""Create or bring up to date the modules, the package's schema, database, ACLs, password policy, entries and service account."""
connection = root()
@@ -0,0 +1,584 @@
"""The router's OpenID Provider, for the web service, with python3-oauthlib and python3-jwt.
While oidc.enabled is true, the web service answers OpenID Connect at the issuer vpnrouter.oidc_issuer, its HTTPS site
that Caddy serves; the web service answers it only on the loopback address, where Caddy passes the requests. The users
of the router's directory sign in, with the password and the TOTP code or with a passkey (vpnrouter_passkeys), to its
clients, the service accounts with redirect URIs (vpnrouter_users.client_settings): a client's ID is its name and its
secret its password, sent with HTTP Basic authentication or in the body of the token request. A client with allowed
groups admits only their members; a service account never signs in as a user.
GET /.well-known/openid-configuration the discovery document
GET /oidc/jwks the public key the ID tokens are signed with
GET, POST /oidc/authorize the authorization code flow, PKCE with S256 required; the sign-in page while
the browser has no session, or with prompt=login or an older one than max_age
POST /oidc/login the sign-in page's form: the name, the password and the code, or a passkey
POST /oidc/passkey begin a sign-in with a passkey: {"id", "options"}
POST /oidc/token exchange a code or a refresh token for tokens
GET, POST /oidc/userinfo the claims of the user an access token was issued for
GET /oidc/logout end the session, then go to a post-logout redirect URI of the client
ID tokens are RS256 JWTs signed with KEY, an RSA key the web service makes on its first run on the router, named by
the RFC 7638 thumbprint of its public key. sub is the entryUUID of the user's entry, so a user added again under the
same name is another subject. The scopes: openid, required; profile, preferred_username and the names; email; groups,
the names of the user's groups; their claims are in the ID token and the userinfo answer. A code is valid
CODE_SECONDS, an access token and an ID token ACCESS_SECONDS, a refresh token REFRESH_SECONDS, used once. A session,
the cookie COOKIE of the issuer's /oidc/ path, lasts SESSION_SECONDS. Codes, tokens and sessions are kept in the
database of vpnrouter_auth as their SHA-256 (vpnrouter_auth.OIDC_SQL); vpnrouter_auth.revoke removes a user's. A
client is registered by an administrator, so a user is not asked for consent.
"""
import base64
import collections
import contextlib
import datetime
import hashlib
import http.cookies
import json
import pathlib
import secrets
import time
import types
import urllib.parse
import jinja2
import jwt
import ldap
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from oauthlib.common import add_params_to_uri
from oauthlib.oauth2 import AccessDeniedError, FatalClientError, InvalidRequestError, OAuth2Error
from oauthlib.openid import RequestValidator, Server
from oauthlib.openid.connect.core.exceptions import LoginRequired
import vpnrouter
import vpnrouter_auth
import vpnrouter_ldap
import vpnrouter_passkeys
import vpnrouter_users
KEY = pathlib.Path('/etc/vpn-router/oidc.key')
KEY_BITS = 3072
CODE_SECONDS = 60
ACCESS_SECONDS = 300
REFRESH_SECONDS = 8 * 3600
SESSION_SECONDS = 8 * 3600
COOKIE = 'vpn_router_oidc'
#: The claims of each scope but openid, which gives sub.
SCOPE_CLAIMS = {
'profile': ('preferred_username', 'name', 'given_name', 'family_name'),
'email': ('email',),
'groups': ('groups',),
}
SCOPES = ('openid', *SCOPE_CLAIMS)
#: The authentication methods (RFC 8176) of a sign-in with a password, with a password and a TOTP code, and with a
#: passkey, which verifies the user.
PASSWORD_AMR = ['pwd']
OTP_AMR = ['pwd', 'otp']
PASSKEY_AMR = ['hwk', 'user']
TEMPLATE = 'oidc-page.html.j2'
#: The headers of the pages: no framing, and nothing loaded but from the page itself and the router.
PAGE_HEADERS = [('Content-Security-Policy', "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; "
"connect-src 'self'; frame-ancestors 'none'")]
#: The headers of oauthlib's answers that the web service sets itself.
OWN_HEADERS = ('content-type', 'cache-control', 'content-length')
#: A user's sign-in at the OpenID Provider: the user name, when, a datetime, and how (amr).
Session = collections.namedtuple('Session', 'name auth_time amr')
_pages = jinja2.Environment(loader=jinja2.FileSystemLoader(str(vpnrouter.TEMPLATE_DIR)), autoescape=True,
undefined=jinja2.StrictUndefined)
def ensure_key():
"""Make the signing key when it is missing."""
if KEY.is_file() and KEY.stat().st_size:
return
key = rsa.generate_private_key(public_exponent=65537, key_size=KEY_BITS)
KEY.parent.mkdir(parents=True, exist_ok=True)
KEY.touch(mode=0o600)
KEY.chmod(0o600)
KEY.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
serialization.NoEncryption()))
def private_key():
return serialization.load_pem_private_key(KEY.read_bytes(), None)
def public_jwk(key):
"""The JWK of the key's public key, its kid the RFC 7638 thumbprint."""
numbers = key.public_key().public_numbers()
jwk = {'e': numbers.e, 'kty': 'RSA', 'n': numbers.n}
jwk = {name: vpnrouter_auth.b64encode(value.to_bytes((value.bit_length() + 7) // 8, 'big')) if isinstance(value, int)
else value for name, value in jwk.items()}
thumbprint = hashlib.sha256(json.dumps(jwk, sort_keys=True, separators=(',', ':')).encode()).digest()
return {**jwk, 'kid': vpnrouter_auth.b64encode(thumbprint), 'use': 'sig', 'alg': 'RS256'}
def sign(claims):
key = private_key()
return jwt.encode(claims, key, algorithm='RS256', headers={'kid': public_jwk(key)['kid']})
def discovery(issuer):
"""The discovery document of OpenID Connect Discovery 1.0."""
return {
'issuer': issuer,
'authorization_endpoint': f'{issuer}/oidc/authorize',
'token_endpoint': f'{issuer}/oidc/token',
'userinfo_endpoint': f'{issuer}/oidc/userinfo',
'jwks_uri': f'{issuer}/oidc/jwks',
'end_session_endpoint': f'{issuer}/oidc/logout',
'scopes_supported': list(SCOPES),
'response_types_supported': ['code'],
'response_modes_supported': ['query'],
'grant_types_supported': ['authorization_code', 'refresh_token'],
'subject_types_supported': ['public'],
'id_token_signing_alg_values_supported': ['RS256'],
'token_endpoint_auth_methods_supported': ['client_secret_basic', 'client_secret_post'],
'code_challenge_methods_supported': ['S256'],
'claims_supported': ['iss', 'sub', 'aud', 'exp', 'iat', 'auth_time', 'nonce', 'amr', 'at_hash',
*(claim for claims in SCOPE_CLAIMS.values() for claim in claims)],
}
@contextlib.contextmanager
def directory():
"""A connection to the router's directory as the user manager account, for reading."""
connection = vpnrouter_ldap.manager()
try:
yield connection
finally:
connection.unbind_s()
def client_of(client_id):
"""The client of the ID, a namespace of its vpnrouter_users.summary with client_id, when it is an enabled service
account with redirect URIs; else None."""
if not isinstance(client_id, str):
return None
with directory() as connection:
try:
user = vpnrouter_users.summary(vpnrouter_users.entry(connection, client_id))
except vpnrouter_users.Failure:
return None
if not user['service'] or not user['enabled'] or not user['redirect_uris']:
return None
return types.SimpleNamespace(client_id=client_id, **user)
def subject(name, client):
"""The claims of every scope of the user, with sub, when the user may sign in to the client: an enabled, unlocked
user who is not a service account, a member of one of the client's allowed groups when it has any; else None."""
with directory() as connection:
try:
attributes = vpnrouter_users.entry(connection, name)
except vpnrouter_users.Failure:
return None
user = vpnrouter_users.summary(attributes)
groups = sorted(vpnrouter_users.groups_of(connection, name))
uuid = connection.search_s(vpnrouter_users.dn_of(name), ldap.SCOPE_BASE, attrlist=['entryUUID'])[0][1]['entryUUID']
if user['service'] or not user['enabled'] or user['locked']:
return None
if client.allowed_groups and not set(client.allowed_groups) & set(groups):
return None
full = ' '.join(part for part in (user['first_name'], user['last_name']) if part)
claims = {'sub': uuid[0].decode(), 'preferred_username': name, 'name': full or None, 'given_name': user['first_name'],
'family_name': user['last_name'], 'email': user['email'], 'groups': groups}
return {claim: value for claim, value in claims.items() if value is not None}
def claims_of(person, scopes):
"""The claims of the scopes of a subject's claims (subject)."""
return {'sub': person['sub'], **{claim: person[claim] for scope in scopes for claim in SCOPE_CLAIMS.get(scope, ())
if claim in person}}
def cookie_value(cookies):
"""The value of the session cookie in a Cookie header, or ''."""
try:
morsel = http.cookies.SimpleCookie(cookies or '').get(COOKIE)
except http.cookies.CookieError:
return ''
return morsel.value if morsel is not None else ''
def session_of(cfg, cookies):
"""The unexpired Session the session cookie in a Cookie header names, or None."""
value = cookie_value(cookies)
if not value:
return None
with vpnrouter_auth.database(cfg) as connection:
row = connection.execute('SELECT * FROM oidc_session_get(%s)', (vpnrouter_auth.digest(value),)).fetchone()
return Session(*row) if row else None
class Validator(RequestValidator):
"""The checks and the storage of oauthlib's server for the router's clients, users and database.
Only the authorization code grant with PKCE S256 and the refresh token grant are allowed. The values of a code, a
token or a session found are kept on the request as oidc_code, oidc_scopes and oidc_person.
"""
def __init__(self, cfg):
super().__init__()
self.cfg = cfg
def fetch(self, sql, values):
with vpnrouter_auth.database(self.cfg) as connection:
return connection.execute(sql, values).fetchone()
def call(self, *statements):
with vpnrouter_auth.database(self.cfg) as connection:
for sql, values in statements:
connection.execute(sql, values)
def session(self, request):
return session_of(self.cfg, request.headers.get('Cookie'))
# Clients.
def validate_client_id(self, client_id, request, *args, **kwargs):
request.client = client_of(client_id)
return request.client is not None
def validate_redirect_uri(self, client_id, redirect_uri, request, *args, **kwargs):
return redirect_uri in request.client.redirect_uris
def get_default_redirect_uri(self, client_id, request, *args, **kwargs):
return None
def validate_response_type(self, client_id, response_type, client, request, *args, **kwargs):
return response_type == 'code'
def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs):
return 'openid' in scopes and set(scopes) <= set(SCOPES)
def get_default_scopes(self, client_id, request, *args, **kwargs):
return ['openid']
def is_pkce_required(self, client_id, request):
return True
def authenticate_client(self, request, *args, **kwargs):
"""Check the client's secret, the password of its service account, given with HTTP Basic authentication, its
ID and secret form-encoded (RFC 6749 2.3.1), or in the body."""
name, secret = request.client_id, request.client_secret
header = request.headers.get('Authorization', '')
if header[:6].lower() == 'basic ':
try:
name, _, secret = base64.b64decode(header[6:].strip(), validate=True).decode().partition(':')
except (ValueError, UnicodeDecodeError):
return False
name, secret = urllib.parse.unquote_plus(name), urllib.parse.unquote_plus(secret)
client = client_of(name)
if client is None:
return False
try:
vpnrouter_users.login(name, secret, None)
except vpnrouter_users.Failure:
return False
request.client = client
return True
def authenticate_client_id(self, client_id, request, *args, **kwargs):
return False
def validate_grant_type(self, client_id, grant_type, client, request, *args, **kwargs):
return grant_type in ('authorization_code', 'refresh_token')
def validate_user(self, username, password, client, request, *args, **kwargs):
return False
# Codes.
def save_authorization_code(self, client_id, code, request, *args, **kwargs):
self.call(('CALL oidc_code_save(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)',
(vpnrouter_auth.digest(code['code']), client_id, request.user, request.redirect_uri, request.scopes,
request.nonce, request.code_challenge, request.code_challenge_method, request.auth_time, request.amr,
CODE_SECONDS)))
def code(self, code):
row = self.fetch('SELECT * FROM oidc_code_get(%s)', (vpnrouter_auth.digest(code),)) if isinstance(code, str) else None
return dict(zip(('client_id', 'name', 'redirect_uri', 'scopes', 'nonce', 'code_challenge', 'code_challenge_method',
'auth_time', 'amr'), row)) if row else None
def get_authorization_code_scopes(self, client_id, code, redirect_uri, request):
found = self.code(code)
return found['scopes'] if found else []
def validate_code(self, client_id, code, client, request, *args, **kwargs):
found = self.code(code)
if found is None or found['client_id'] != client_id:
return False
person = subject(found['name'], client)
if person is None:
return False
request.user, request.scopes, request.auth_time, request.amr = found['name'], found['scopes'], found['auth_time'], found['amr']
request.oidc_code, request.oidc_person = found, person
return True
def get_code_challenge(self, code, request):
return request.oidc_code['code_challenge']
def get_code_challenge_method(self, code, request):
return request.oidc_code['code_challenge_method']
def confirm_redirect_uri(self, client_id, code, redirect_uri, client, request, *args, **kwargs):
return redirect_uri == request.oidc_code['redirect_uri']
def get_authorization_code_nonce(self, client_id, code, redirect_uri, request):
return request.oidc_code['nonce']
def invalidate_authorization_code(self, client_id, code, request, *args, **kwargs):
self.call(('CALL oidc_code_delete(%s)', (vpnrouter_auth.digest(code),)))
# Tokens.
def token(self, token, kind):
row = self.fetch('SELECT * FROM oidc_token_get(%s, %s)', (vpnrouter_auth.digest(token), kind)) \
if isinstance(token, str) and token else None
return dict(zip(('client_id', 'name', 'scopes', 'auth_time', 'amr'), row)) if row else None
def save_bearer_token(self, token, request, *args, **kwargs):
"""Keep the access token and the refresh token; a refresh token that was exchanged is used up."""
values = (request.client.client_id, request.user, request.scopes, request.auth_time, request.amr)
statements = [('CALL oidc_token_save(%s, %s, %s, %s, %s, %s, %s, %s)',
(vpnrouter_auth.digest(token['access_token']), 'access', *values, ACCESS_SECONDS))]
if token.get('refresh_token'):
statements.append(('CALL oidc_token_save(%s, %s, %s, %s, %s, %s, %s, %s)',
(vpnrouter_auth.digest(token['refresh_token']), 'refresh', *values, REFRESH_SECONDS)))
if request.grant_type == 'refresh_token':
statements.append(('CALL oidc_token_delete(%s)', (vpnrouter_auth.digest(request.refresh_token),)))
self.call(*statements)
def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs):
found = self.token(refresh_token, 'refresh')
if found is None or found['client_id'] != client.client_id:
return False
person = subject(found['name'], client)
if person is None:
return False
request.user, request.auth_time, request.amr = found['name'], found['auth_time'], found['amr']
request.oidc_scopes, request.oidc_person = found['scopes'], person
return True
def get_original_scopes(self, refresh_token, request, *args, **kwargs):
return request.oidc_scopes
def validate_bearer_token(self, token, scopes, request):
found = self.token(token, 'access')
if found is None or not set(scopes) <= set(found['scopes']):
return False
client = client_of(found['client_id'])
person = subject(found['name'], client) if client else None
if person is None:
return False
request.client, request.user, request.oidc_scopes, request.oidc_person = client, found['name'], found['scopes'], person
return True
def get_userinfo_claims(self, request):
return claims_of(request.oidc_person, request.oidc_scopes)
# OpenID Connect.
def finalize_id_token(self, id_token, token, token_handler, request):
now = int(time.time())
id_token.update(claims_of(request.oidc_person, request.scopes))
id_token.update({'iss': vpnrouter.oidc_issuer(self.cfg), 'exp': now + ACCESS_SECONDS,
'auth_time': int(request.auth_time.timestamp()), 'amr': request.amr})
return sign(id_token)
def validate_silent_login(self, request):
session = self.session(request)
return session is not None and subject(session.name, request.client) is not None
def validate_silent_authorization(self, request):
return True
def validate_user_match(self, id_token_hint, scopes, claims, request):
"""Whether the user of the session, when there is one, is the subject of an ID token the router signed."""
session = self.session(request) if id_token_hint else None
if session is None:
return True
try:
hint = jwt.decode(id_token_hint, private_key().public_key(), algorithms=['RS256'],
options={'verify_exp': False, 'verify_aud': False})
except jwt.InvalidTokenError:
return False
person = subject(session.name, request.client)
return person is not None and hint.get('sub') == person['sub']
def page(status, cfg, title, message='', error='', form=False, request='', name='', headers=()):
"""An HTML page of TEMPLATE: a message, an error, and with form the sign-in form for the authorization request."""
body = _pages.get_template(TEMPLATE).render(title=title, message=message, error=error, form=form, request=request,
name=name, issuer=vpnrouter.oidc_issuer(cfg))
return status, 'text/html; charset=utf-8', body.encode(), [*PAGE_HEADERS, *headers]
def document(status, value, headers=()):
return status, 'application/json', json.dumps(value, indent=2).encode() + b'\n', list(headers)
def relayed(headers, body, status):
"""An answer of oauthlib's server, without the headers the web service sets."""
kind = next((value for name, value in headers.items() if name.lower() == 'content-type'), 'application/json')
return status, kind, (body or '').encode(), [(name, value) for name, value in headers.items()
if name.lower() not in OWN_HEADERS]
def redirect(location, headers=()):
return 302, 'text/plain; charset=utf-8', b'', [('Location', location), *headers]
def cookie(value, seconds):
return 'Set-Cookie', f'{COOKIE}={value}; Path=/oidc/; Max-Age={seconds}; Secure; HttpOnly; SameSite=Lax'
def authorize(server, cfg, params, headers, fresh=None, name='', error=''):
"""Answer an authorization request, the query or the form params: the sign-in page while the browser has no
session, or with prompt=login or max_age older than the session, else a redirect to the client with a code.
fresh is the session of a sign-in just made, which counts whatever prompt and max_age say; the redirect then sets
its cookie, fresh.cookie. error is why a sign-in failed: the sign-in page shows it, filled with the name.
"""
uri = f'{vpnrouter.oidc_issuer(cfg)}/oidc/authorize?{params}'
try:
scopes, info = server.validate_authorization_request(uri, 'GET', None, headers)
except FatalClientError as error:
return page(error.status_code, cfg, 'The sign-in request is refused', error=error.description or error.error)
except OAuth2Error as error:
if not error.redirect_uri:
return page(error.status_code, cfg, 'The sign-in request is refused', error=error.description or error.error)
return redirect(error.in_uri(error.redirect_uri))
request = info['request']
title = f'Sign in to {request.client.display_name or request.client.client_id}'
if info.get('code_challenge_method') != 'S256':
return redirect(InvalidRequestError(description='PKCE with code_challenge_method S256 is required',
request=request).in_uri(request.redirect_uri))
if error:
return page(401, cfg, title, error=error, form=True, request=params, name=name)
session = fresh.session if fresh else session_of(cfg, headers.get('Cookie'))
max_age = getattr(request, 'max_age', None)
stale = session is not None and isinstance(max_age, str) and max_age.isdigit() and \
time.time() - session.auth_time.timestamp() > int(max_age)
prompt = info.get('prompt') or set()
if session is None or (not fresh and ('login' in prompt or stale)):
if 'none' in prompt:
return redirect(LoginRequired(request=request).in_uri(request.redirect_uri))
return page(200, cfg, title, form=True, request=params, name=name)
if subject(session.name, request.client) is None:
if fresh:
return redirect(AccessDeniedError(description=f'{session.name} may not sign in to this client',
request=request).in_uri(request.redirect_uri), [fresh.cookie])
return page(403, cfg, title, error=f'{session.name} may not sign in here; sign in as another user.', form=True,
request=params)
answer, _, _ = server.create_authorization_response(uri, 'GET', None, headers, scopes, credentials={
'user': session.name, 'auth_time': session.auth_time, 'amr': session.amr})
return redirect(answer['Location'], [fresh.cookie] if fresh else [])
def login(server, cfg, form, headers):
"""Sign a user in with the sign-in page's form, start the session and go on with its authorization request."""
params = form.get('request', '')
name = form.get('name', '').strip()
try:
if form.get('passkey_id'):
with vpnrouter_users.session() as connection:
name = vpnrouter_passkeys.login_complete(connection, form['passkey_id'], json.loads(form.get('credential', '')))
amr = PASSKEY_AMR
else:
code = form.get('code', '').strip()
vpnrouter_users.login(name, form.get('password', ''), code or None)
with directory() as connection:
if vpnrouter_users.service(vpnrouter_users.entry(connection, name)):
raise vpnrouter_users.Failure('the name, the password or the code is wrong', 5)
amr = OTP_AMR if code else PASSWORD_AMR
except vpnrouter_users.Failure as failure:
return authorize(server, cfg, params, headers, name=name, error=str(failure))
except ValueError:
return authorize(server, cfg, params, headers, name=name, error='the passkey is not accepted')
value = secrets.token_urlsafe(32)
with vpnrouter_auth.database(cfg) as connection:
connection.execute('CALL oidc_session_save(%s, %s, %s, %s)', (vpnrouter_auth.digest(value), name, amr, SESSION_SECONDS))
fresh = types.SimpleNamespace(session=Session(name, datetime.datetime.now(datetime.timezone.utc), amr),
cookie=cookie(value, SESSION_SECONDS))
return authorize(server, cfg, params, headers, fresh=fresh)
def logout(cfg, query, headers):
"""End the session the cookie names, then go to post_logout_redirect_uri, with state, when it is a post-logout
redirect URI of the client that client_id or the audience of id_token_hint names; else show that the user is
signed out."""
values = dict(urllib.parse.parse_qsl(query))
value = cookie_value(headers.get('Cookie'))
if value:
with vpnrouter_auth.database(cfg) as connection:
connection.execute('CALL oidc_session_delete(%s)', (vpnrouter_auth.digest(value),))
client_id = values.get('client_id')
if not client_id and values.get('id_token_hint'):
try:
audience = jwt.decode(values['id_token_hint'], private_key().public_key(), algorithms=['RS256'],
options={'verify_exp': False, 'verify_aud': False}).get('aud')
except jwt.InvalidTokenError:
audience = None
client_id = audience[0] if isinstance(audience, list) and audience else audience
client = client_of(client_id)
target = values.get('post_logout_redirect_uri')
expired = cookie('', 0)
if client is not None and target in client.post_logout_redirect_uris:
return redirect(add_params_to_uri(target, [('state', values['state'])]) if values.get('state') else target, [expired])
return page(200, cfg, 'Signed out', message='You are signed out of the router.', headers=[expired])
def answer(method, target, headers, body):
"""Answer a request of the OpenID Provider: the HTTP status, the content type, the body and [(header, value)]; 404
while oidc.enabled is false. target is the path with the query, headers a mapping of the request's headers and
body its bytes; HEAD is answered as GET."""
path, _, query = target.partition('?')
method = 'GET' if method == 'HEAD' else method
try:
cfg = vpnrouter.load_config()
if not cfg['oidc']['enabled']:
return 404, 'text/plain; charset=utf-8', b'Not found\n', []
issuer = vpnrouter.oidc_issuer(cfg)
headers = dict(headers.items())
text = body.decode('utf-8', 'replace') if body else ''
server = Server(Validator(cfg), token_expires_in=ACCESS_SECONDS)
if (method, path) == ('GET', '/.well-known/openid-configuration'):
return document(200, discovery(issuer))
if (method, path) == ('GET', '/oidc/jwks'):
return document(200, {'keys': [public_jwk(private_key())]})
if path == '/oidc/authorize' and method in ('GET', 'POST'):
return authorize(server, cfg, query if method == 'GET' else text, headers)
if (method, path) == ('POST', '/oidc/login'):
return login(server, cfg, dict(urllib.parse.parse_qsl(text)), headers)
if (method, path) == ('POST', '/oidc/passkey'):
return document(200, vpnrouter_passkeys.login_begin())
if (method, path) == ('POST', '/oidc/token'):
return relayed(*server.create_token_response(f'{issuer}{path}', 'POST', text, headers))
if path == '/oidc/userinfo' and method in ('GET', 'POST'):
return relayed(*server.create_userinfo_response(f'{issuer}{target}', method, text or None, headers))
if (method, path) == ('GET', '/oidc/logout'):
return logout(cfg, query, headers)
return 404, 'text/plain; charset=utf-8', b'Not found\n', []
except OAuth2Error as error:
return error.status_code, 'application/json', error.json.encode(), list(error.headers.items())
except vpnrouter_users.Failure as failure:
return document(400 if failure.status == 2 else 500, {'error': str(failure)})
except (ldap.LDAPError, OSError) as exc:
return document(500, {'error': f'the OpenID Provider failed: {exc}'})
except vpnrouter_auth.DatabaseError as exc:
return document(500, {'error': str(exc)})
except SystemExit as exc:
return document(500, {'error': str(exc.code)})
except ValueError as exc:
return document(400, {'error': 'invalid_request', 'error_description': str(exc)})
@@ -0,0 +1,174 @@
"""The passkeys of the router's users, for signing in to the Router Manager and the OpenID Provider (vpnrouter_oidc),
with python3-fido2.
A passkey is a discoverable WebAuthn credential with user verification, of the relying party
wan.local_fqdn, so the browser offers it at the HTTPS addresses on that name.
Its user handle is the user name. Each passkey of a user is a value of vpnRouterPasskey on the
user entry: a JSON object {"id", "credential", "label", "created"}, id the credential ID and
credential the attested credential data (with the public key), both base64url. A user has at
most PASSKEYS_MAX passkeys; service accounts have none.
Registration and sign-in are two steps each: begin answers the options for the browser and a
pending id, whose state the web service keeps in memory for PENDING_SECONDS; complete takes the
id and the browser's answer. A request that cannot be done raises vpnrouter_users.Failure, with
the statuses of that module; a sign-in that fails gives status 5 whatever the reason.
"""
import json
import secrets
import threading
import time
import fido2.features
import ldap
from fido2.server import Fido2Server
from fido2.utils import websafe_decode, websafe_encode
from fido2.webauthn import (AttestedCredentialData, PublicKeyCredentialRpEntity, PublicKeyCredentialUserEntity,
ResidentKeyRequirement, UserVerificationRequirement)
import vpnrouter
import vpnrouter_users
# The options and the browser's answers are in the JSON mapping of WebAuthn Level 3; python3-fido2 2.0 always uses it.
_json_mapping = getattr(fido2.features, 'webauthn_json_mapping', None)
if _json_mapping is not None:
_json_mapping.enabled = True
#: The most passkeys of one user.
PASSKEYS_MAX = 10
#: The longest label of a passkey, in characters.
LABEL_MAX = 64
#: Seconds a begun registration or sign-in may be completed.
PENDING_SECONDS = 120
#: The most begun registrations and sign-ins kept at once.
PENDING_MAX = 1000
#: The begun registrations and sign-ins: pending id -> (kind, user name or None, state, monotonic time of expiry).
_pending = {}
_lock = threading.Lock()
def server():
"""The Fido2Server of the relying party wan.local_fqdn; passkeys need the name."""
fqdn = vpnrouter.load_config()['wan']['local_fqdn'].strip()
if not fqdn:
raise vpnrouter_users.Failure('passkeys need wan.local_fqdn, the name the Router Manager is reached at', 2)
return Fido2Server(rp=PublicKeyCredentialRpEntity(name=fqdn, id=fqdn))
def keep(kind, name, state):
"""Keep the state of a begun step and answer its pending id."""
pending = secrets.token_urlsafe(24)
now = time.monotonic()
with _lock:
for old in [key for key, (*_, expires) in _pending.items() if expires <= now]:
del _pending[old]
if len(_pending) >= PENDING_MAX:
raise vpnrouter_users.Failure('too many passkey requests at once; retry in a moment')
_pending[pending] = (kind, name, state, now + PENDING_SECONDS)
return pending
def take(kind, name, pending):
"""The state of a begun step of the kind and user, used up; None when it is unknown or expired."""
with _lock:
found = _pending.pop(pending, None) if isinstance(pending, str) else None
if found is None or found[0] != kind or found[1] != name or found[3] <= time.monotonic():
return None
return found[2]
def credentials(attributes):
"""The attested credential data of the passkeys of an entry."""
return [AttestedCredentialData(websafe_decode(passkey['credential']))
for _, passkey in vpnrouter_users.passkeys_of(attributes)]
def register_begin(connection, name):
"""Begin the registration of a passkey of the user: {"id", "options"}, options those of navigator.credentials.create."""
current = vpnrouter_users.entry(connection, name)
if vpnrouter_users.service(current):
raise vpnrouter_users.Failure(f'{name} is a service account, which has no passkeys', 2)
if len(vpnrouter_users.passkeys_of(current)) >= PASSKEYS_MAX:
raise vpnrouter_users.Failure(f'user {name} has {PASSKEYS_MAX} passkeys, the most; remove one first', 2)
options, state = server().register_begin(
PublicKeyCredentialUserEntity(id=name.encode(), name=name, display_name=name),
credentials(current),
resident_key_requirement=ResidentKeyRequirement.REQUIRED,
user_verification=UserVerificationRequirement.REQUIRED,
)
return {'id': keep('register', name, state), 'options': dict(options)}
def register_complete(connection, name, pending, response, label):
"""Store the passkey the browser created for a begun registration, with its label; the user (vpnrouter_users.summary)."""
state = take('register', name, pending)
if state is None:
raise vpnrouter_users.Failure('the registration is unknown or expired; begin it again', 2)
if label is None or (isinstance(label, str) and not label.strip()):
label = 'Passkey'
if not isinstance(label, str) or len(label.strip()) > LABEL_MAX:
raise vpnrouter_users.Failure(f'the label must be text of at most {LABEL_MAX} characters', 2)
if not isinstance(response, dict):
raise vpnrouter_users.Failure('credential must be the answer of navigator.credentials.create as JSON', 2)
try:
data = server().register_complete(state, response).credential_data
except (ValueError, KeyError, TypeError) as exc:
raise vpnrouter_users.Failure(f'the passkey is refused: {exc}', 2)
current = vpnrouter_users.entry(connection, name)
value = json.dumps({
'id': websafe_encode(data.credential_id),
'credential': websafe_encode(bytes(data)),
'label': label.strip(),
'created': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
}, separators=(',', ':'))
changes = [] if 'vpnrouteraccount' in vpnrouter_users.classes(current) else [(ldap.MOD_ADD, 'objectClass', [b'vpnRouterAccount'])]
changes.append((ldap.MOD_ADD, 'vpnRouterPasskey', [value.encode()]))
connection.modify_s(vpnrouter_users.dn_of(name), changes)
return vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
def remove(connection, name, passkey_id=None):
"""Remove the user's passkey of the id, or all its passkeys without one; the user (vpnrouter_users.summary)."""
current = vpnrouter_users.entry(connection, name)
values = current.get('vpnRouterPasskey', [])
kept = [value for value, passkey in vpnrouter_users.passkeys_of(current)
if passkey_id is not None and passkey['id'] != passkey_id]
if passkey_id is not None and len(kept) == len(vpnrouter_users.passkeys_of(current)):
raise vpnrouter_users.Failure(f'user {name} has no passkey {passkey_id}', 3)
if len(kept) != len(values):
connection.modify_s(vpnrouter_users.dn_of(name), [(ldap.MOD_REPLACE, 'vpnRouterPasskey', kept)])
return vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
def login_begin():
"""Begin a sign-in with a passkey: {"id", "options"}, options those of navigator.credentials.get, for any user."""
options, state = server().authenticate_begin(user_verification=UserVerificationRequirement.REQUIRED)
return {'id': keep('login', None, state), 'options': dict(options)}
def login_complete(connection, pending, response):
"""The name of the user whose passkey signed a begun sign-in, when the user is enabled, not locked and not a
service account."""
refused = vpnrouter_users.Failure('the passkey is not accepted', 5)
state = take('login', None, pending)
if state is None or not isinstance(response, dict):
raise refused
try:
name = websafe_decode(response['response']['userHandle']).decode()
except (KeyError, TypeError, ValueError):
raise refused
if not vpnrouter_users.NAME.match(name):
raise refused
try:
current = vpnrouter_users.entry(connection, name)
except vpnrouter_users.Failure:
raise refused
user = vpnrouter_users.summary(current)
if user['service'] or not user['enabled'] or user['locked']:
raise refused
try:
server().authenticate_complete(state, credentials(current), response)
except (ValueError, KeyError, TypeError):
raise refused
return name
@@ -4,8 +4,8 @@ No platform-specific configuration is currently required.
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
given the router itself as their DNS server, and the router forwards through
its own systemd-resolved via the p2s-forwarder drop-in. That resolves
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
Azure-internal names without routing the wireserver address through the tunnel
and without a second resolver in the pool.
"""
@@ -0,0 +1,439 @@
"""The static web sites Caddy serves, for the web service, vpn-router-site, vpn-router-setup and webdav-auth.
A site is a row of the sites table in the vpn-router-sites database, which the web service creates on its first run
(provision) with its role and the password in DATABASE_SECRET; SITES_SQL defines the table and its procedures, and the
first connection of a process loads it. A site has a name, which also names its folder SITES_DIR/<name>, the host names
Caddy answers on its port (hosts, the names TLS clients send as SNI; none answers any name), the interfaces whose
firewall opens the port (listen), and TLS: none (plain HTTP), acme (a certificate of Let's Encrypt), ca (certificates
Caddy issues with one of pki.issuing_cas) or file (a certificate and key given with the site). Caddy serves the files of
the folder, index_file for a folder, and with spa index_file of the site for any path without a file. editors names
the group whose members change the site's files besides the admins, through the web service and WebDAV.
The sites are served while sites.enabled is true; vpn-router-setup writes Caddy's configuration and opens the ports. A
site is changed only through this module, which checks it (check), and its callers then restart vpn-router-setup.
The files of a site are read and written by path relative to its folder; resolve() refuses a path that leaves it.
receive() writes a file from a stream, extract() a zip archive into a folder; both stop at sites.upload_max_mb.
Every failure of the database raises DatabaseError; a request that cannot be done raises Failure, whose status says
why: 2 invalid, 3 no such site, 4 the site exists, 1 anything else.
"""
import contextlib
import datetime
import os
import pathlib
import re
import shutil
import stat
import tempfile
import zipfile
import psycopg
from psycopg.rows import dict_row
from cryptography import x509
from cryptography.hazmat.primitives import serialization
import vpnrouter
import vpnrouter_auth
import vpnrouter_pki
import vpnrouter_users
DATABASE = 'vpn-router-sites'
DATABASE_SECRET = pathlib.Path('/etc/vpn-router/sites-database.secret')
SITES_SQL = pathlib.Path('/usr/share/vpn-router/sql/sites.sql')
#: The folders of the sites, SITES_DIR/<name>.
SITES_DIR = pathlib.Path('/var/lib/vpn-router/sites')
#: The name of a site, which also names its folder.
NAME = re.compile(r'^[a-z][a-z0-9-]{0,30}$')
#: A file name of index_file.
INDEX = re.compile(r'^[A-Za-z0-9_-][A-Za-z0-9._-]{0,63}$')
LISTEN = vpnrouter.LISTEN_CHOICES
TLS = ('none', 'acme', 'ca', 'file')
#: The most host names of one site.
HOSTS_MAX = 16
#: The most members a zip archive extract() takes.
ARCHIVE_MEMBERS_MAX = 10000
#: The bytes copied at once from a stream.
CHUNK = 65536
#: The settings of a site and their defaults; the type of a default is the required type.
SETTINGS = {'enabled': True, 'hosts': [], 'port': 443, 'listen': 'external', 'tls': 'acme', 'ca': '', 'certificate': '',
'private_key': '', 'index_file': 'index.html', 'spa': False, 'editors': ''}
#: Whether this process has loaded SITES_SQL.
_loaded = False
class DatabaseError(Exception):
"""The database of the sites failed."""
class Failure(Exception):
"""A request that cannot be done; status is 2 invalid, 3 no such site, 4 the site exists, 1 other."""
def __init__(self, message, status=1):
super().__init__(message)
self.status = status
def provision():
"""Create the role, its password file and the database of the sites when they are missing."""
vpnrouter_auth.provision_database(DATABASE, DATABASE_SECRET)
@contextlib.contextmanager
def database():
"""A connection to the database of the sites, with its table and procedures, closed at the end; DatabaseError on any
failure, also before the web service has created the database."""
global _loaded
try:
schema = None if _loaded else SITES_SQL.read_text(encoding='utf-8')
target = vpnrouter_auth.local_connection(DATABASE, DATABASE_SECRET)
except OSError as exc:
raise DatabaseError(f'the database of the sites is not ready: {exc}') from exc
try:
with psycopg.connect(target, autocommit=True, row_factory=dict_row) as connection:
if schema is not None:
connection.execute(schema)
_loaded = True
yield connection
except psycopg.Error as exc:
raise DatabaseError(f'the database of the sites failed: {exc}') from exc
def shown(row):
"""A site as the answers show it: its settings without the private key, with private_key_set and its folder."""
site = {key: value for key, value in row.items() if key != 'private_key'}
site['private_key_set'] = bool(row.get('private_key'))
site['modified'] = row['modified'].isoformat(timespec='seconds')
site['directory'] = str(SITES_DIR / row['name'])
return site
def listing():
"""Every site, as shown()."""
with database() as connection:
return [shown(row) for row in connection.execute('SELECT * FROM sites_list()').fetchall()]
def served():
"""The enabled sites with their private keys."""
with database() as connection:
return connection.execute('SELECT * FROM sites_served()').fetchall()
def get(connection, name):
"""The site with its private key; Failure with status 3 when there is none."""
if not isinstance(name, str) or not NAME.match(name):
raise Failure(f'no site {name}', 3)
row = connection.execute('SELECT * FROM site_get(%s)', (name,)).fetchone()
if row is None:
raise Failure(f'no site {name}', 3)
return row
def editable(groups):
"""The names of the sites whose files the members of the groups may change."""
with database() as connection:
return [row['name'] for row in connection.execute('SELECT * FROM sites_editable(%s)', (list(groups),))]
def sites_of_group(group):
"""The names of the sites whose editors are the group; [] before the web service has created the database."""
if not DATABASE_SECRET.is_file():
return []
with database() as connection:
return [row['name'] for row in connection.execute('SELECT * FROM sites_of_group(%s)', (group,))]
def rename_group(old, new):
"""Give the sites whose editors are the group old its new name."""
if DATABASE_SECRET.is_file():
with database() as connection:
connection.execute('CALL sites_rename_group(%s, %s)', (old, new))
def taken_ports(cfg):
"""The TCP ports of the router's own services, port -> what has it, which no site may take."""
taken = {22: 'SSH', cfg['web']['port']: 'web.port', cfg['https']['web_port']: 'https.web_port',
cfg['https']['manager_port']: 'https.manager_port', cfg['metrics']['port']: 'metrics.port',
cfg['metrics']['https_port']: 'metrics.https_port', vpnrouter.manager_port(): 'the Router Manager',
cfg['sites']['webdav_port']: 'sites.webdav_port', vpnrouter.WEBDAV_LOOPBACK_PORT: 'rclone\'s WebDAV',
53: 'DNS'}
for port in vpnrouter.BOOTSTRAP_PORTS:
taken[port] = 'the bootstrap services'
if cfg['openvpn']['protocol'] == 'tcp':
taken[cfg['openvpn']['port']] = 'openvpn.port'
for item in cfg['docker']['exposed_ports']:
first, _, last = item.strip().partition(':')
if first.isdigit() and (not last or last.isdigit()):
for port in range(int(first), int(last or first) + 1):
taken[port] = 'docker.exposed_ports'
return taken
def check_certificate(certificate, private_key, hosts):
"""Refuse a PEM certificate chain whose first certificate is not the key's or names none of the hosts."""
try:
chain = x509.load_pem_x509_certificates(certificate.encode())
except ValueError:
raise Failure('certificate must be PEM certificates, the site\'s first', 2)
try:
key = serialization.load_pem_private_key(private_key.encode(), password=None)
except (ValueError, TypeError):
raise Failure('private_key must be an unencrypted PEM private key', 2)
if not vpnrouter_pki.same_key(chain[0].public_key(), key.public_key()):
raise Failure('private_key is not the key of the certificate', 2)
names = {value.partition(':')[2] for value in vpnrouter_pki.san_values(chain[0]) if not value.startswith('email:')}
missing = [host for host in hosts if host not in names and f'*.{host.partition(".")[2]}' not in names]
if missing:
raise Failure(f'the certificate does not name {", ".join(missing)}', 2)
def check(cfg, sites, site):
"""Refuse a site, a dict of SETTINGS with its name, that is invalid or collides with the other sites (sites, rows of
the table) or with the router's own ports."""
name = site['name']
if not isinstance(name, str) or not NAME.match(name):
raise Failure('a site name starts with a lowercase letter and has at most 31 lowercase letters, digits and '
'hyphens', 2)
for option, default in SETTINGS.items():
value = site[option]
if type(value) is not type(default) or (isinstance(value, list) and not all(isinstance(item, str) for item in value)):
raise Failure(f'{option} must be {vpnrouter.TYPE_NAMES[type(default)]}', 2)
hosts = site['hosts']
if len(hosts) > HOSTS_MAX or len(set(hosts)) != len(hosts):
raise Failure(f'hosts must be at most {HOSTS_MAX} different host names', 2)
for host in hosts:
if not (vpnrouter_pki.DNS_NAME.match(host) or vpnrouter_pki.is_address(host)):
raise Failure(f'hosts: {host!r} is not a host name in lowercase or an IP address', 2)
if host == cfg['wan']['local_fqdn'].strip():
raise Failure(f'hosts: {host} is wan.local_fqdn, the name of the router\'s own services', 2)
port = site['port']
if not 1 <= port <= 65535:
raise Failure(f'port: {port} is not a port', 2)
taken = taken_ports(cfg)
if port in taken:
raise Failure(f'port: {port} is the port of {taken[port]}', 2)
if site['listen'] not in LISTEN:
raise Failure(f'listen: {site["listen"]!r} is not one of {", ".join(LISTEN)}', 2)
tls = site['tls']
if tls not in TLS:
raise Failure(f'tls: {tls!r} is not one of {", ".join(TLS)}', 2)
if tls != 'none' and not hosts:
raise Failure(f'tls {tls} needs hosts, the names of the certificate', 2)
if tls == 'acme' and any(host.startswith('*.') or vpnrouter_pki.is_address(host) for host in hosts):
raise Failure('tls acme takes host names only, without wildcards', 2)
if tls == 'ca':
authorities = {key: value for key, value in vpnrouter.issuing_cas(cfg).items() if not value['builtin']}
if site['ca'] not in authorities:
raise Failure(f'ca: {site["ca"]!r} is not one of pki.issuing_cas', 2)
if tls == 'file':
check_certificate(site['certificate'], site['private_key'], hosts)
if not INDEX.match(site['index_file']):
raise Failure('index_file must be a file name', 2)
if site['editors']:
with vpnrouter_users.session() as connection:
try:
vpnrouter_users.group_entry(connection, site['editors'])
except vpnrouter_users.Failure:
raise Failure(f'editors: group {site["editors"]} does not exist', 2)
if not site['enabled']:
return
for other in sites:
if other['name'] == name or not other['enabled'] or other['port'] != port:
continue
prefix = f'port {port} is also the port of site {other["name"]}'
if (other['tls'] == 'none') != (tls == 'none'):
raise Failure(f'{prefix}, which is {"plain HTTP" if other["tls"] == "none" else "HTTPS"}', 2)
if other['listen'] != site['listen']:
raise Failure(f'{prefix}, which listens on {other["listen"]}', 2)
if not hosts or not other['hosts']:
raise Failure(f'{prefix}; sites sharing a port need host names', 2)
common = sorted(set(hosts) & set(other['hosts']))
if common:
raise Failure(f'{prefix}, which has {", ".join(common)} too', 2)
def save(cfg, name, values, create):
"""Add the site with values, a dict of some of SETTINGS (create), or change those of an existing one; the site as
shown(). An absent or empty private_key keeps the current one."""
unknown = sorted(set(values) - set(SETTINGS))
if unknown:
raise Failure(f'unknown settings: {", ".join(unknown)}', 2)
with database() as connection:
rows = connection.execute('SELECT * FROM sites_list()').fetchall()
current = next((row for row in rows if row['name'] == name), None)
if create and current is not None:
raise Failure(f'site {name} exists', 4)
if not create:
current = get(connection, name)
site = {**SETTINGS, **({key: current[key] for key in SETTINGS} if current else {}), 'name': name}
site.update({key: value for key, value in values.items() if key != 'private_key' or value})
if isinstance(site['hosts'], list):
site['hosts'] = [host.strip().lower() for host in site['hosts'] if isinstance(host, str) and host.strip()]
if site['tls'] != 'file':
site['certificate'] = site['private_key'] = ''
if site['tls'] != 'ca':
site['ca'] = ''
check(cfg, rows, site)
connection.execute('CALL site_put(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)',
tuple(site[key] for key in ('name', *SETTINGS)))
ensure_folders([name])
return shown(get(connection, name))
def remove(name, content):
"""Remove the site, and with content its folder."""
with database() as connection:
get(connection, name)
connection.execute('CALL site_remove(%s)', (name,))
if content:
shutil.rmtree(SITES_DIR / name, ignore_errors=True)
return {'name': name, 'removed': True}
def ensure_folders(names):
"""Create the folders of the sites that miss one, readable by all."""
SITES_DIR.mkdir(mode=0o755, parents=True, exist_ok=True)
for name in names:
(SITES_DIR / name).mkdir(mode=0o755, exist_ok=True)
def resolve(name, relative):
"""The path of the site's file or folder relative to its folder; Failure with status 2 for a path that leaves it,
status 3 when the site has no folder."""
if not isinstance(name, str) or not NAME.match(name):
raise Failure(f'no site {name}', 3)
base = SITES_DIR / name
if not base.is_dir():
raise Failure(f'site {name} has no folder', 3)
if '\0' in relative or any(part in ('.', '..') for part in relative.split('/')):
raise Failure(f'{relative!r} is not a path in the site\'s folder', 2)
target = pathlib.Path(os.path.realpath(base / relative.strip('/')))
if target != base and base not in target.parents:
raise Failure(f'{relative!r} is not a path in the site\'s folder', 2)
return target
def relative_of(name, path):
return str(path.relative_to(SITES_DIR / name))
def entries(name, folder):
"""The entries of a folder of the site: [{name, folder, size, modified}], folders first, then by name."""
found = []
for item in os.scandir(folder):
try:
info = item.stat(follow_symlinks=False)
except OSError:
continue
found.append({'name': item.name, 'folder': stat.S_ISDIR(info.st_mode),
'size': info.st_size if stat.S_ISREG(info.st_mode) else 0,
'modified': datetime.datetime.fromtimestamp(info.st_mtime, datetime.timezone.utc)
.isoformat(timespec='seconds')})
return {'path': relative_of(name, folder) if folder != SITES_DIR / name else '',
'entries': sorted(found, key=lambda entry: (not entry['folder'], entry['name']))}
def receive(stream, length, target, limit):
"""Write length bytes of the stream to the file target, replacing it in one step; Failure with status 2 over limit
bytes. Its folders are created."""
if length > limit:
raise Failure(f'the file has {length} bytes, more than sites.upload_max_mb allows', 2)
if target.is_dir():
raise Failure(f'{target.name} is a folder', 2)
target.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
descriptor, name = tempfile.mkstemp(dir=target.parent, prefix=f'.{target.name}.')
try:
with os.fdopen(descriptor, 'wb') as handle:
left = length
while left:
data = stream.read(min(CHUNK, left))
if not data:
raise Failure('the client sent less than its Content-Length', 2)
handle.write(data)
left -= len(data)
os.chmod(name, 0o644)
os.replace(name, target)
except BaseException:
os.unlink(name)
raise
def extract(stream, length, folder, limit):
"""Extract the zip archive of length bytes of the stream into the folder, created when missing; the number of files.
A member with an absolute path, a .. or a link, more than ARCHIVE_MEMBERS_MAX members or more than limit bytes
unpacked refuse the whole archive, before anything is written.
"""
if length > limit:
raise Failure(f'the archive has {length} bytes, more than sites.upload_max_mb allows', 2)
folder.mkdir(mode=0o755, parents=True, exist_ok=True)
with tempfile.TemporaryFile(dir=folder) as spool:
left = length
while left:
data = stream.read(min(CHUNK, left))
if not data:
raise Failure('the client sent less than its Content-Length', 2)
spool.write(data)
left -= len(data)
spool.seek(0)
try:
archive = zipfile.ZipFile(spool)
except zipfile.BadZipFile:
raise Failure('the file is not a zip archive', 2)
with archive:
members = archive.infolist()
if len(members) > ARCHIVE_MEMBERS_MAX:
raise Failure(f'the archive has more than {ARCHIVE_MEMBERS_MAX} members', 2)
if sum(member.file_size for member in members) > limit:
raise Failure('the archive unpacks to more than sites.upload_max_mb allows', 2)
for member in members:
parts = member.filename.replace('\\', '/').split('/')
if member.filename.startswith('/') or '..' in parts or '\0' in member.filename or \
stat.S_ISLNK(member.external_attr >> 16):
raise Failure(f'the archive member {member.filename!r} is a link or leaves the folder', 2)
count = 0
for member in members:
target = folder / member.filename.replace('\\', '/').strip('/')
if member.is_dir():
target.mkdir(mode=0o755, parents=True, exist_ok=True)
continue
target.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
with archive.open(member) as source:
receive(source, member.file_size, target, limit)
count += 1
return count
def make_folder(target):
target.mkdir(mode=0o755, parents=True, exist_ok=True)
def rename(name, source, relative):
"""Move the file or folder source of the site to the path relative to its folder; Failure when that exists."""
target = resolve(name, relative)
if target.exists():
raise Failure(f'{relative} exists', 4)
target.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
os.replace(source, target)
return target
def delete(name, target):
"""Remove the file or folder of the site, but not its folder."""
if target == SITES_DIR / name:
raise Failure('the site\'s folder itself cannot be removed', 2)
if target.is_dir() and not target.is_symlink():
shutil.rmtree(target)
else:
target.unlink()
@@ -12,6 +12,9 @@ COMMAND_TIMEOUT = 5
OPENVPN_STATUS = '/run/openvpn-server/status-vpn-router.log'
#: Seconds between two writes of OPENVPN_STATUS by the OpenVPN server, whose configuration sets it (OpenVPN's default is 60).
OPENVPN_STATUS_SECONDS = 5
#: Seconds since its last handshake within which a WireGuard peer counts as connected.
WIREGUARD_CONNECTED_SECONDS = 180
@@ -15,10 +15,21 @@ internal service accounts are those the router creates and manages for its own s
included (internal_accounts); vpn-router-setup keeps the metrics service's.
Users exist whether or not a P2S system is enabled.
A service account with redirect URIs is a client of the OpenID Provider (vpnrouter_oidc), its name the client ID and
its password the client secret (client_settings): redirect_uris, where the provider sends a user back after a sign-in,
post_logout_redirect_uris, after a sign-out, and allowed_groups, the groups whose members may sign in to it, every
user who is not a service account when there are none.
A group is a groupOfNames in ou=groups with a name, which follows the rules of user names, an
optional description and at least one member; a group cannot be empty, so the last member
cannot leave it and a user who is the last member of a group cannot be removed. Groups only
record membership; nothing on the router reads them.
cannot leave it and a user who is the last member of a group cannot be removed. ADMIN_GROUP grants the management of
the router, the allowed groups of the OpenID Provider's clients who may sign in to them, and the editors group of a web
site who may change its files (vpnrouter_sites); renaming a group renames it in those, and a group a client allows or a
site has cannot be removed.
A VPN user may have WireGuard peers, each a device with its public key and its IPs, in
vpnRouterWireGuardPeer; vpn-router-setup writes a [Peer] of wg0.conf for each peer of a user who
is not disabled.
A user may have a TOTP token: the user entry itself is an oathTOTPUser and oathTOTPToken whose
oathTOTPToken points to itself, with the secret in oathSecret and the parameters of
@@ -26,10 +37,19 @@ vpnrouter_ldap.TOTP_PARAMS. Enrolment stores nothing until a code of the new sec
verified: otp_new makes a secret and its otpauth URI, and otp_enrol stores the secret given a
code of it. slapd's otp overlay then takes the password followed by a code at every bind.
A user who is not a service account may have passkeys for signing in to the Router Manager, in
vpnRouterPasskey (vpnrouter_passkeys registers and checks them).
A user is disabled when pwdAccountLockedTime holds LOCKED, and locked when the password
policy has set it after failed binds, for vpnrouter_ldap.LOCKOUT_SECONDS; unlock ends that.
login() checks a user's password and code with a bind. Members of ADMIN_GROUP manage the
router through the web service. The module knows only the directory; the callers revoke a
router through the web service.
A member of ADMIN_GROUP with a TOTP token may have console login (console): the Shell page of the
Router Manager, and SSH and sudo with the directory's password followed by the code; without the
token the console is refused, also for one whose console login was switched on before. The
console runs as a local account, or as the user's own posixAccount, which the user entry becomes
and nslcd serves to the system. Leaving ADMIN_GROUP switches it off. The module knows only the directory; the callers revoke a
user's refresh tokens after the operations of vpnrouter_auth.REVOKING.
Every operation runs in a session(), which holds the lock that serializes changes, the PKI's
@@ -42,13 +62,25 @@ import base64
import calendar
import contextlib
import fcntl
import grp
import ipaddress
import json
import plistlib
import pwd
import re
import subprocess
import time
import urllib.parse
import uuid
import ldap
import ldap.filter
import ldap.modlist
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_ldap
@@ -56,6 +88,8 @@ import vpnrouter_pki
#: A user name is one lowercase DNS label, as it is also the common name of the certificate.
NAME = re.compile(r'^[a-z][a-z0-9-]{0,30}[a-z0-9]$|^[a-z]$')
#: The other name of a service account: a GUID in lowercase hexadecimal, as the Router Manager proposes one.
GUID = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')
PASSWORD_MIN = 8
PASSWORD_MAX = 128
#: The longest first or last name, in characters.
@@ -69,7 +103,18 @@ PERSON = (('first_name', 'givenName'), ('last_name', 'sn'), ('email', 'mail'))
LABELS = (('display_name', 'displayName'), ('description', 'description'))
#: The attributes read for a user.
ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken', 'vpnRouterScope',
'displayName', 'description', 'userCertificate;binary']
'displayName', 'description', 'userCertificate;binary', 'vpnRouterConsoleLogin', 'vpnRouterConsoleAccount',
'uidNumber', 'vpnRouterWireGuardPeer', 'vpnRouterPasskey', 'vpnRouterRedirectUri',
'vpnRouterPostLogoutRedirectUri', 'vpnRouterAllowedGroup']
#: The keys of the URIs of an OpenID Provider client and their LDAP attributes.
CLIENT_URIS = (('redirect_uris', 'vpnRouterRedirectUri'), ('post_logout_redirect_uris', 'vpnRouterPostLogoutRedirectUri'))
#: The most URIs of each kind and allowed groups of one client, and the longest URI.
CLIENT_VALUES_MAX = 16
URI_MAX = 1024
#: The hosts a client's URI may name over plain HTTP: those of the system it runs on.
LOOPBACK_HOSTS = ('localhost', '127.0.0.1', '::1')
#: The most WireGuard peers of one user.
WIREGUARD_PEERS_MAX = 16
#: The vpnRouterScope of a service account.
SERVICE_SCOPE = 'service'
#: The object classes of a VPN user and of a Manager-only account.
@@ -85,7 +130,7 @@ GROUP_ATTRIBUTES = ['cn', 'description', 'member']
#: The longest group description, in characters.
DESCRIPTION_MAX = 256
#: The value of pwdAccountLockedTime that disables an account until it is enabled.
LOCKED = b'000001010000Z'
LOCKED = vpnrouter_ldap.DISABLED.encode()
#: The group whose members manage the router through the web service.
ADMIN_GROUP = 'admins'
#: The digits of a TOTP code given to login().
@@ -114,9 +159,11 @@ def session():
connection.unbind_s()
def check_name(name):
if not isinstance(name, str) or not NAME.match(name):
raise Failure('a user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter', 2)
def check_name(name, service=True):
"""Refuse a name that is not a user name (NAME), or, with service, the GUID of a service account either."""
if not isinstance(name, str) or not (NAME.match(name) or service and GUID.match(name)):
raise Failure('a user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter'
+ (', or, for a service account, a GUID in lowercase' if service else ''), 2)
def check_password(value):
@@ -170,6 +217,47 @@ def labels(values):
return found
def client_settings(connection, values):
"""The OpenID Provider client settings of a service account in values ({redirect_uris, post_logout_redirect_uris,
allowed_groups}), checked, as {LDAP attribute: [value, ...]}; a key whose value is None is not given, [] means none.
A URI is absolute, has no fragment and uses https, or http for a host of LOOPBACK_HOSTS; an allowed group exists. Each
list has at most CLIENT_VALUES_MAX values.
"""
found = {}
for key, attribute in CLIENT_URIS:
uris = values.get(key)
if uris is None:
continue
if not isinstance(uris, list) or len(uris) > CLIENT_VALUES_MAX or not all(isinstance(uri, str) for uri in uris):
raise Failure(f'{key} must be a list of at most {CLIENT_VALUES_MAX} URIs', 2)
for uri in uris:
try:
parts = urllib.parse.urlsplit(uri)
host = parts.hostname
except ValueError:
host = None
if len(uri) > URI_MAX or not host or parts.fragment or any(character.isspace() for character in uri) or \
not (parts.scheme == 'https' or parts.scheme == 'http' and host in LOOPBACK_HOSTS):
raise Failure(f'{uri!r} is not an https URI, or an http URI of localhost, without a fragment and of at '
f'most {URI_MAX} characters', 2)
found[attribute] = [uri.encode() for uri in dict.fromkeys(uris)]
allowed = values.get('allowed_groups')
if allowed is not None:
if not isinstance(allowed, list) or len(allowed) > CLIENT_VALUES_MAX:
raise Failure(f'allowed_groups must be a list of at most {CLIENT_VALUES_MAX} group names', 2)
for group in allowed:
group_entry(connection, group)
found['vpnRouterAllowedGroup'] = [group_dn(group).encode() for group in sorted(set(allowed))]
return found
def refuse_client_settings(name, values):
"""Refuse OpenID Provider client settings for a user who is not a service account."""
if any(values.get(key) is not None for key in ('redirect_uris', 'post_logout_redirect_uris', 'allowed_groups')):
raise Failure(f'{name} is not a service account; only a service account is a client of the OpenID Provider', 2)
def dn_of(name):
return f'uid={name},{vpnrouter_ldap.USERS}'
@@ -221,7 +309,8 @@ def internal_accounts():
def summary(attributes):
"""A user as the answers show it; a person attribute or a label the entry does not have is None.
internal is whether it is an internal service account (internal_accounts).
internal is whether it is an internal service account (internal_accounts). A service account also has its OpenID
Provider client settings (client_settings), allowed_groups as group names.
"""
name = attributes['uid'][0].decode()
lock = attributes.get('pwdAccountLockedTime', [None])[0]
@@ -234,13 +323,204 @@ def summary(attributes):
'locked': lock not in (None, LOCKED) and locked_since(lock) > time.time() - vpnrouter_ldap.LOCKOUT_SECONDS,
'certificate': 'userCertificate;binary' in attributes,
'otp': 'oathTOTPToken' in attributes,
'console': console_of(attributes),
'wireguard_peers': peers_of(attributes),
'passkeys': [{key: passkey[key] for key in ('id', 'label', 'created')} for _, passkey in passkeys_of(attributes)],
}
for key, attribute in PERSON + LABELS:
values = attributes.get(attribute)
user[key] = values[0].decode() if values else None
if user['service']:
for key, attribute in CLIENT_URIS:
user[key] = [value.decode() for value in attributes.get(attribute, [])]
suffix = f',{vpnrouter_ldap.GROUPS}'
user['allowed_groups'] = sorted(value.decode()[3:-len(suffix)] for value in attributes.get('vpnRouterAllowedGroup', [])
if value.decode().startswith('cn=') and value.decode().endswith(suffix))
return user
def console_of(attributes):
"""The console login of an entry as the answers show it: None when it was never set, else {enabled, account, local,
uid}: account the system account the console runs as, the local one of vpnRouterConsoleAccount (local true) or the
user's own posixAccount, and uid the uidNumber of that posixAccount, None without one."""
if 'vpnRouterConsoleLogin' not in attributes:
return None
local = attributes.get('vpnRouterConsoleAccount')
number = attributes.get('uidNumber')
return {
'enabled': attributes['vpnRouterConsoleLogin'] == [b'TRUE'],
'account': local[0].decode() if local else attributes['uid'][0].decode(),
'local': bool(local),
'uid': int(number[0]) if number else None,
}
def peers_of(attributes):
"""The WireGuard peers of an entry as the answers show them: [{public_key, ips}], in the order they are stored."""
peers = []
for value in attributes.get('vpnRouterWireGuardPeer', []):
key, _, ips = value.decode().partition(' ')
peers.append({'public_key': key, 'ips': ips.split(',')})
return peers
def passkeys_of(attributes):
"""The passkeys of an entry (vpnrouter_passkeys) with their stored values: [(value, {id, credential, label,
created})]; values that cannot be read are left out."""
passkeys = []
for value in attributes.get('vpnRouterPasskey', []):
try:
passkey = json.loads(value)
except ValueError:
continue
if isinstance(passkey, dict) and all(isinstance(passkey.get(key), str) for key in ('id', 'credential', 'label', 'created')):
passkeys.append((value, passkey))
return passkeys
def checked_peers(connection, name, peers):
"""The values of vpnRouterWireGuardPeer for the user's WireGuard peers, [{public_key, ips}], when they are valid.
A public key is 32 bytes in base64, used by no other peer of any user; each peer has at least one IP, an IPv4
address or network, which no other peer of any user covers and which does not cover the router's own address of
wireguard.address. A user has at most WIREGUARD_PEERS_MAX peers.
"""
if not isinstance(peers, list) or len(peers) > WIREGUARD_PEERS_MAX:
raise Failure(f'wireguard_peers must be a list of at most {WIREGUARD_PEERS_MAX} peers', 2)
router = vpnrouter.load_config()['wireguard']['address'].strip()
taken = [(other['name'], key, network)
for other in list_users(connection)['users'] if other['name'] != name
for peer in other['wireguard_peers'] for key in [peer['public_key']]
for network in (ipaddress.ip_network(ip) for ip in peer['ips'])]
values = []
for peer in peers:
key = peer.get('public_key') if isinstance(peer, dict) else None
ips = peer.get('ips') if isinstance(peer, dict) else None
try:
if not isinstance(key, str) or len(base64.b64decode(key.strip(), validate=True)) != 32:
raise ValueError
except ValueError:
raise Failure(f'{key!r} is not a WireGuard public key: 32 bytes in base64', 2)
key = key.strip()
if not isinstance(ips, list) or not ips or not all(isinstance(ip, str) for ip in ips):
raise Failure(f'the peer {key} needs at least one IP', 2)
networks = []
for ip in ips:
try:
network = ipaddress.ip_network(ip.strip(), strict=True)
except ValueError:
raise Failure(f'{ip!r} is not an IPv4 address or network of the peer {key}', 2)
if network.version != 4:
raise Failure(f'{ip!r} of the peer {key} is not IPv4', 2)
if router and ipaddress.ip_interface(router).ip in network:
raise Failure(f'{network} of the peer {key} covers the router\'s own address {router}', 2)
for owner, other_key, other in taken:
if other_key == key:
raise Failure(f'the key {key} is a peer of {owner}', 4)
if network.overlaps(other):
raise Failure(f'{network} of the peer {key} overlaps {other} of {owner}', 4)
networks.append(network)
taken += [(name, key, network) for network in networks]
values.append(f'{key} {",".join(str(network) for network in networks)}'.encode())
return values
def classes(attributes):
"""The object classes of an entry, in lower case."""
return {value.decode().lower() for value in attributes.get('objectClass', [])}
def free_uid(connection):
"""The lowest uidNumber from vpnrouter_ldap.CONSOLE_UID_MIN above every posixAccount's that no local account has."""
found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL, '(uidNumber=*)', ['uidNumber'])
number = max([vpnrouter_ldap.CONSOLE_UID_MIN - 1] + [int(attributes['uidNumber'][0]) for _, attributes in found]) + 1
while True:
try:
pwd.getpwuid(number)
except KeyError:
return number
number += 1
def console(connection, name, enabled, account=None):
"""Switch the console login of a member of ADMIN_GROUP with a TOTP token on, as the local account given or, without
one, as the user's own posixAccount, or off.
The posixAccount is added the first time and kept when the console login is switched off, so its uidNumber and the
home it owns stay the user's. A local account must exist in the system's passwd with a uid from 1000 to below
vpnrouter_ldap.CONSOLE_UID_MIN, and not be nobody; the user's own account needs a name no local account has.
Switching off ends the sessions of the user's own account.
"""
current = entry(connection, name)
if not isinstance(enabled, bool):
raise Failure('enabled must be true or false', 2)
if account is not None and (not isinstance(account, str) or not account):
raise Failure('account must be the name of a local account', 2)
changes = [] if 'vpnrouteraccount' in classes(current) else [(ldap.MOD_ADD, 'objectClass', [b'vpnRouterAccount'])]
if not enabled:
if 'vpnRouterConsoleLogin' not in current:
return summary(current)
connection.modify_s(dn_of(name), changes + [(ldap.MOD_REPLACE, 'vpnRouterConsoleLogin', [b'FALSE'])])
end_sessions(current)
return summary(entry(connection, name))
if service(current):
raise Failure(f'{name} is a service account, which has no console', 2)
if ADMIN_GROUP not in groups_of(connection, name):
raise Failure(f'user {name} is not a member of {ADMIN_GROUP}', 2)
if 'oathTOTPToken' not in current:
raise Failure(f'user {name} has no TOTP token; console login needs one', 2)
if account is not None:
try:
local = pwd.getpwnam(account)
except KeyError:
raise Failure(f'there is no local account {account}', 2)
if not 1000 <= local.pw_uid < vpnrouter_ldap.CONSOLE_UID_MIN or account == 'nobody':
raise Failure(f'{account} is not a local user account with a uid from 1000 to {vpnrouter_ldap.CONSOLE_UID_MIN - 1}', 2)
changes.append((ldap.MOD_REPLACE, 'vpnRouterConsoleAccount', [account.encode()]))
else:
changes.append((ldap.MOD_REPLACE, 'vpnRouterConsoleAccount', []))
if 'posixaccount' not in classes(current):
try:
pwd.getpwnam(name)
except KeyError:
pass
else:
raise Failure(f'a local account {name} exists; give it as the account, or use another user', 4)
try:
group = grp.getgrnam(vpnrouter_ldap.CONSOLE_GROUP).gr_gid
except KeyError:
raise Failure(f'the group {vpnrouter_ldap.CONSOLE_GROUP} does not exist yet; vpn-router-setup creates it')
changes += [
(ldap.MOD_ADD, 'objectClass', [b'posixAccount']),
(ldap.MOD_REPLACE, 'uidNumber', [str(free_uid(connection)).encode()]),
(ldap.MOD_REPLACE, 'gidNumber', [str(group).encode()]),
(ldap.MOD_REPLACE, 'homeDirectory', [f'{vpnrouter_ldap.CONSOLE_HOMES}/{name}'.encode()]),
(ldap.MOD_REPLACE, 'loginShell', [vpnrouter_ldap.CONSOLE_SHELL.encode()]),
]
if manager_only(current):
changes.append((ldap.MOD_REPLACE, 'cn', [name.encode()]))
changes.append((ldap.MOD_REPLACE, 'vpnRouterConsoleLogin', [b'TRUE']))
connection.modify_s(dn_of(name), changes)
return summary(entry(connection, name))
def end_sessions(attributes):
"""End the login sessions of the user's own posixAccount, when the entry has one; those of a local account stay."""
if 'posixaccount' in classes(attributes) and not attributes.get('vpnRouterConsoleAccount'):
subprocess.run(['loginctl', 'terminate-user', attributes['uid'][0].decode()], capture_output=True)
def console_off(connection, names):
"""Switch the console login off for each of the users that has it on; for those who leave ADMIN_GROUP."""
for name in names:
try:
current = entry(connection, name)
except Failure:
continue
if current.get('vpnRouterConsoleLogin') == [b'TRUE']:
console(connection, name, False)
def common_name(attributes, name):
"""The cn of an entry: the first and the last name, or the user name without them."""
full = ' '.join(value for value in (attributes.get('givenName'), attributes.get('sn')) if value)
@@ -256,14 +536,19 @@ def list_users(connection):
def add(connection, name, password, values, service=False):
"""Add a user: a VPN user with its certificate when values gives all three person attributes, a Manager-only account
with at most the e-mail address when it gives neither name, and, with service, a service account, with at most the
e-mail address and its labels (labels)."""
check_name(name)
e-mail address, its labels (labels) and its OpenID Provider client settings (client_settings); only a service
account may be named by a GUID."""
check_name(name, service)
details = person(values, required=False)
vpn_user = 'givenName' in details or 'sn' in details
if service and vpn_user:
raise Failure('a service account has no first or last name', 2)
client = {}
if service:
details.update({attribute: value for attribute, value in labels(values).items() if value})
client = {attribute: found for attribute, found in client_settings(connection, values).items() if found}
else:
refuse_client_settings(name, values)
if vpn_user and len(details) != len(PERSON):
raise Failure('give first_name, last_name and email for a VPN user, or at most email for a Manager-only account', 2)
check_password(password)
@@ -278,6 +563,7 @@ def add(connection, name, password, values, service=False):
if service:
record['vpnRouterScope'] = [SERVICE_SCOPE.encode()]
record.update({attribute: [value.encode()] for attribute, value in details.items()})
record.update(client)
try:
connection.add_s(dn_of(name), ldap.modlist.addModlist(record))
except ldap.ALREADY_EXISTS:
@@ -292,7 +578,8 @@ def add(connection, name, password, values, service=False):
def update(connection, name, values):
"""Change the person attributes values gives; the cn follows the first and the last name.
"""Change the person attributes values gives, the cn following the first and the last name, and of a VPN user the
WireGuard peers, wireguard_peers, which replace the user's peers (checked_peers).
A Manager-only account has only the e-mail address, which '' removes; a service account also its labels
(update_service).
@@ -300,7 +587,10 @@ def update(connection, name, values):
current = entry(connection, name)
if service(current):
return update_service(connection, name, values)
refuse_client_settings(name, values)
if manager_only(current):
if values.get('wireguard_peers') is not None:
raise Failure(f'user {name} is a Manager-only account, which has no VPN access and so no WireGuard peers', 2)
if values.get('first_name') is not None or values.get('last_name') is not None:
raise Failure(f'user {name} is a Manager-only account, which has no first or last name', 2)
email = values.get('email')
@@ -313,27 +603,43 @@ def update(connection, name, values):
connection.modify_s(dn_of(name), [(ldap.MOD_REPLACE, 'mail', [person({'email': email}, required=False)['mail'].encode()])])
return summary(entry(connection, name))
details = person(values, required=False)
if not details:
raise Failure('give at least one of first_name, last_name and email', 2)
names = {attribute: current[attribute][0].decode() for attribute in ('givenName', 'sn') if current.get(attribute)}
names.update({attribute: details[attribute] for attribute in ('givenName', 'sn') if attribute in details})
changes = [(ldap.MOD_REPLACE, attribute, [value.encode()]) for attribute, value in details.items()]
changes.append((ldap.MOD_REPLACE, 'cn', [common_name(names, name).encode()]))
peers = values.get('wireguard_peers')
if not details and peers is None:
raise Failure('give at least one of first_name, last_name, email and wireguard_peers', 2)
changes = []
if details:
names = {attribute: current[attribute][0].decode() for attribute in ('givenName', 'sn') if current.get(attribute)}
names.update({attribute: details[attribute] for attribute in ('givenName', 'sn') if attribute in details})
changes += [(ldap.MOD_REPLACE, attribute, [value.encode()]) for attribute, value in details.items()]
changes.append((ldap.MOD_REPLACE, 'cn', [common_name(names, name).encode()]))
if peers is not None:
if 'vpnrouteraccount' not in classes(current):
changes.append((ldap.MOD_ADD, 'objectClass', [b'vpnRouterAccount']))
changes.append((ldap.MOD_REPLACE, 'vpnRouterWireGuardPeer', checked_peers(connection, name, peers)))
connection.modify_s(dn_of(name), changes)
if peers is not None:
apply_wireguard()
return summary(entry(connection, name))
def apply_wireguard():
"""Have vpn-router-setup write the WireGuard peers of the users into wg0.conf, without waiting for it."""
subprocess.run(['systemctl', 'restart', '--no-block', 'vpn-router-setup.service'], capture_output=True)
def update_service(connection, name, values):
"""Change any of the e-mail address and the labels (labels) of a service account that values gives; '' removes one."""
"""Change any of the e-mail address, the labels (labels) and the OpenID Provider client settings (client_settings) of
a service account that values gives; '' removes the e-mail address or a label, [] the values of a client setting."""
if not service(entry(connection, name)):
raise Failure(f'{name} is not a service account', 3)
changes = [(ldap.MOD_REPLACE, attribute, [value.encode()] if value else []) for attribute, value in labels(values).items()]
changes += [(ldap.MOD_REPLACE, attribute, found) for attribute, found in client_settings(connection, values).items()]
email = values.get('email')
if email is not None:
address = '' if isinstance(email, str) and email.strip() == '' else person({'email': email}, required=False)['mail']
changes.append((ldap.MOD_REPLACE, 'mail', [address.encode()] if address else []))
if not changes:
raise Failure('give email, display_name or description', 2)
raise Failure('give email, display_name, description, redirect_uris, post_logout_redirect_uris or allowed_groups', 2)
connection.modify_s(dn_of(name), changes)
return summary(entry(connection, name))
@@ -346,14 +652,22 @@ def passwd(connection, name, password):
def disable(connection, name):
if entry(connection, name).get('pwdAccountLockedTime') != [LOCKED]:
"""Lock the user out; the WireGuard peers of a disabled user are left out of wg0.conf."""
current = entry(connection, name)
if current.get('pwdAccountLockedTime') != [LOCKED]:
connection.modify_s(dn_of(name), [(ldap.MOD_REPLACE, 'pwdAccountLockedTime', [LOCKED])])
if 'vpnRouterWireGuardPeer' in current:
apply_wireguard()
return summary(entry(connection, name))
def enable(connection, name):
if entry(connection, name).get('pwdAccountLockedTime') == [LOCKED]:
"""Let the user in again, with the WireGuard peers."""
current = entry(connection, name)
if current.get('pwdAccountLockedTime') == [LOCKED]:
connection.modify_s(dn_of(name), [(ldap.MOD_DELETE, 'pwdAccountLockedTime', None)])
if 'vpnRouterWireGuardPeer' in current:
apply_wireguard()
return summary(entry(connection, name))
@@ -370,12 +684,13 @@ def unlock(connection, name):
def login(name, password, code):
"""Check the user's password, and the TOTP code of an enrolled user, with a bind as the user.
"""Check the user's password, and the TOTP code of an enrolled user, with a bind as the user; a service account may be
named by a GUID.
A wrong name, password or code, a locked or a disabled user give the same Failure, status 5.
"""
refused = Failure('the name, the password or the code is wrong', 5)
if not isinstance(name, str) or not NAME.match(name) or not isinstance(password, str) or not password:
if not isinstance(name, str) or not (NAME.match(name) or GUID.match(name)) or not isinstance(password, str) or not password:
raise refused
if code is not None and code != '' and (not isinstance(code, str) or not CODE.match(code)):
raise refused
@@ -430,22 +745,25 @@ def otp_enrol(connection, name, secret, code):
def otp_remove(connection, name):
"""Remove the user's TOTP token, when the user has one."""
if 'oathTOTPToken' in entry(connection, name):
"""Remove the user's TOTP token, when the user has one, and end the console sessions it needed (end_sessions)."""
current = entry(connection, name)
if 'oathTOTPToken' in current:
token = connection.search_s(dn_of(name), ldap.SCOPE_BASE, attrlist=TOKEN_ATTRIBUTES)[0][1]
changes = [(ldap.MOD_DELETE, 'objectClass', [b'oathTOTPUser', b'oathTOTPToken']),
(ldap.MOD_DELETE, 'oathSecret', None)]
changes += [(ldap.MOD_DELETE, attribute, None) for attribute in token]
connection.modify_s(dn_of(name), changes)
end_sessions(current)
return summary(entry(connection, name))
def remove(connection, name):
"""Remove the user from its groups and the directory, and revoke the certificate.
"""Remove the user from its groups and the directory, revoke the certificate and end the sessions of its own
posixAccount.
A user who is the last member of a group is not removed.
"""
entry(connection, name)
current = entry(connection, name)
groups = groups_of(connection, name)
alone = sorted(group for group, members in groups.items() if members == [name])
if alone:
@@ -457,6 +775,9 @@ def remove(connection, name):
except vpnrouter_pki.Failure as failure:
raise Failure(f'the certificate cannot be revoked: {failure}', failure.status)
connection.delete_s(dn_of(name))
end_sessions(current)
if 'vpnRouterWireGuardPeer' in current:
apply_wireguard()
return {'name': name, 'removed': True}
@@ -492,6 +813,72 @@ def profile(connection, name):
return {'name': name, 'profile': text}
def apple_profile(connection, name, password):
"""The Apple configuration profile (.mobileconfig, unsigned) of the user's IKEv2 road-warrior 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. 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 road-warrior 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})
payloads.append({
'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):
return f'cn={name},{vpnrouter_ldap.GROUPS}'
@@ -548,6 +935,13 @@ def groups_of(connection, name):
return {attributes['cn'][0].decode(): member_names(attributes) for _, attributes in found}
def allowing(connection, name):
"""The names of the OpenID Provider's clients whose allowed groups have the group, sorted."""
found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL,
f'(vpnRouterAllowedGroup={ldap.filter.escape_filter_chars(group_dn(name))})', ['uid'])
return sorted(attributes['uid'][0].decode() for _, attributes in found)
def list_groups(connection):
found = connection.search_s(vpnrouter_ldap.GROUPS, ldap.SCOPE_ONELEVEL, '(objectClass=groupOfNames)', GROUP_ATTRIBUTES)
return {'groups': sorted((group_summary(attributes) for _, attributes in found), key=lambda group: group['name'])}
@@ -573,10 +967,13 @@ def group_add(connection, name, description, members):
def group_update(connection, name, new_name=None, description=None):
"""Rename the group or change its description ('' removes it); at least one must be given."""
"""Rename the group or change its description ('' removes it); at least one must be given. Renaming ADMIN_GROUP
switches the console login of its members off; renaming a group a client of the OpenID Provider allows renames it
there, and renaming the editors group of web sites renames it in the sites."""
import vpnrouter_sites
if new_name is None and description is None:
raise Failure('give a new name or a description', 2)
group_entry(connection, name)
members = member_names(group_entry(connection, name))
if description is not None:
description = check_description(description)
change = (ldap.MOD_REPLACE, 'description', [description.encode()]) if description else \
@@ -588,13 +985,36 @@ def group_update(connection, name, new_name=None, description=None):
connection.rename_s(group_dn(name), f'cn={new_name}', delold=1)
except ldap.ALREADY_EXISTS:
raise Failure(f'group {new_name} exists', 4)
for client in allowing(connection, name):
connection.modify_s(dn_of(client), [(ldap.MOD_DELETE, 'vpnRouterAllowedGroup', [group_dn(name).encode()]),
(ldap.MOD_ADD, 'vpnRouterAllowedGroup', [group_dn(new_name).encode()])])
try:
vpnrouter_sites.rename_group(name, new_name)
except vpnrouter_sites.DatabaseError as exc:
raise Failure(f'group {name} is renamed {new_name}, but not in the web sites: {exc}')
if name == ADMIN_GROUP:
console_off(connection, members)
name = new_name
return group_summary(group_entry(connection, name))
def group_remove(connection, name):
group_entry(connection, name)
"""Remove the group, unless a client of the OpenID Provider allows it or it is the editors group of web sites;
removing ADMIN_GROUP switches the console login of its members off."""
import vpnrouter_sites
members = member_names(group_entry(connection, name))
clients = allowing(connection, name)
if clients:
raise Failure(f'group {name} is an allowed group of the clients {", ".join(clients)}; take it out of them first', 2)
try:
sites = vpnrouter_sites.sites_of_group(name)
except vpnrouter_sites.DatabaseError as exc:
raise Failure(f'the web sites cannot be read: {exc}')
if sites:
raise Failure(f'group {name} is the editors group of the web sites {", ".join(sites)}; change them first', 2)
connection.delete_s(group_dn(name))
if name == ADMIN_GROUP:
console_off(connection, members)
return {'name': name, 'removed': True}
@@ -607,7 +1027,8 @@ def group_member_add(connection, name, user):
def group_member_remove(connection, name, user):
"""Take the user out of the group; the last member cannot leave."""
"""Take the user out of the group; the last member cannot leave. Leaving ADMIN_GROUP switches the user's console
login off."""
check_name(user)
members = member_names(group_entry(connection, name))
if user not in members:
@@ -615,6 +1036,8 @@ def group_member_remove(connection, name, user):
if members == [user]:
raise Failure(f'user {user} is the last member of group {name}; remove the group instead', 2)
connection.modify_s(group_dn(name), [(ldap.MOD_DELETE, 'member', [dn_of(user).encode()])])
if name == ADMIN_GROUP:
console_off(connection, [user])
return group_summary(group_entry(connection, name))
File diff suppressed because it is too large. Load diff
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/python3
"""The auth proxy of rclone's WebDAV server of the web sites' folders (vpn-router-webdav.service).
rclone runs it for each new user and password with {"user", "pass"} on standard input. The user signs in with the
password of the router's directory, followed by the TOTP code for an enrolled user. A member of
vpnrouter_users.ADMIN_GROUP sees the folders of all sites, any other user those of the sites whose editors group
the user is a member of (vpnrouter_sites.editable), each as a folder named after its site. The answer on standard output
is rclone's combine backend of those folders; a failed sign-in, a user with no site or any failure is a message on
standard error and the exit status 1, which rclone refuses the user with.
"""
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
import ldap
import vpnrouter_ldap
import vpnrouter_sites
import vpnrouter_users
def main():
try:
request = json.load(sys.stdin)
name, password = request['user'], request['pass']
vpnrouter_users.login(name, password, None)
connection = vpnrouter_ldap.manager()
try:
groups = vpnrouter_users.groups_of(connection, name)
finally:
connection.unbind_s()
if vpnrouter_users.ADMIN_GROUP in groups:
sites = [site['name'] for site in vpnrouter_sites.listing()]
else:
sites = vpnrouter_sites.editable(groups)
except (ValueError, KeyError, TypeError, OSError, ldap.LDAPError, vpnrouter_users.Failure,
vpnrouter_sites.DatabaseError) as exc:
sys.exit(f'webdav-auth: refused: {exc}')
if not sites:
sys.exit(f'webdav-auth: refused: {name} may change no site')
vpnrouter_sites.ensure_folders(sites)
upstreams = ' '.join(f'{site}={vpnrouter_sites.SITES_DIR / site}' for site in sites)
json.dump({'type': 'combine', '_root': '', 'upstreams': upstreams}, sys.stdout)
return 0
if __name__ == '__main__':
sys.exit(main())
+19 -6
View File
@@ -9,18 +9,21 @@ number, true or false, a list of strings separated by commas (an empty value is
or a string. remove takes site_to_site.NAME and removes that connection. The configuration with
the change is checked as vpn-router-setup checks it, and only then written with
vpnrouter.save_config, holding vpnrouter.config_lock; vpn-router-setup applies it at its next
run, which set and remove remind of. A failure is a message on standard error; the exit status
is 2 for an invalid request and 1 for any other failure. -h or --help shows the options.
run, which set and remove remind of. With --restart they restart vpn-router-setup and wait until
it has applied the change. A failure is a message on standard error; the exit status is 2 for an
invalid request and 1 for any other failure, a failed run of vpn-router-setup included. -h or
--help shows the options.
vpn-config-edit show
vpn-config-edit set SECTION.OPTION VALUE
vpn-config-edit set site_to_site.NAME.OPTION VALUE
vpn-config-edit remove site_to_site.NAME
vpn-config-edit set [--restart] SECTION.OPTION VALUE
vpn-config-edit set [--restart] site_to_site.NAME.OPTION VALUE
vpn-config-edit remove [--restart] site_to_site.NAME
"""
import argparse
import copy
import json
import subprocess
import sys
sys.path.insert(0, '/usr/lib/vpn-router')
@@ -86,6 +89,9 @@ def main(argv):
setter.add_argument('value', metavar='VALUE', help='its value; a list separated by commas')
remover = commands.add_parser('remove', help='remove a site-to-site connection and write the configuration')
remover.add_argument('key', metavar='site_to_site.NAME', help='the connection')
for changer in (setter, remover):
changer.add_argument('--restart', action='store_true',
help='restart vpn-router-setup and wait until it has applied the change')
options = parser.parse_args(argv)
if options.command == 'show':
@@ -124,7 +130,14 @@ def main(argv):
print(f'{options.key} set to {json.dumps(value)} in {vpnrouter.CONFIG_FILE}.')
else:
print(f'{options.key} removed from {vpnrouter.CONFIG_FILE}.')
print('Apply it with: systemctl restart vpn-router-setup')
if not options.restart:
print('Apply it with: systemctl restart vpn-router-setup, or --restart')
return 0
print('Restarting vpn-router-setup...', flush=True)
if subprocess.run(['systemctl', 'restart', 'vpn-router-setup.service']).returncode != 0:
print('vpn-config-edit: vpn-router-setup failed; see journalctl -u vpn-router-setup', file=sys.stderr)
return 1
print('vpn-router-setup applied it.')
return 0
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/python3
"""Manage the web sites Caddy serves from the command line, with vpnrouter_sites.
A site has a name, which also names its folder in /var/lib/vpn-router/sites, host names, a port, the interfaces whose
firewall opens it, TLS (none, acme, ca with --ca, or file with --certificate and --key), the file served for a folder,
whether any path without a file is served that file of the site (--spa), and the group whose members change its files.
A change is applied by restarting vpn-router-setup without waiting for it. The answer is text for people on standard
output, and a failure is a message on standard error. With --json the answer is JSON on standard output, and a failure
is {"error": ...} there too. The exit status says what failed: 2 the request is invalid, 3 the site does not exist, 4
the site exists, 1 anything else. -h or --help shows the commands and their options.
vpn-router-site [--json] list
vpn-router-site [--json] show <name>
vpn-router-site [--json] add <name> [options]
vpn-router-site [--json] update <name> [options]
vpn-router-site [--json] remove <name> [--content]
options: [--host H ...] [--no-hosts] [--port P] [--listen internal|external|both] [--tls none|acme|ca|file]
[--ca NAME] [--certificate FILE] [--key FILE] [--index FILE] [--spa | --no-spa]
[--enabled | --no-enabled] [--editors GROUP]
"""
import argparse
import json
import subprocess
import sys
sys.path.insert(0, '/usr/lib/vpn-router')
import ldap
import vpnrouter
import vpnrouter_sites
from vpnrouter_sites import Failure
#: The help line of each command.
HELP = {
'list': 'list the sites',
'show': 'show a site',
'add': 'add a site',
'update': 'change the settings of a site',
'remove': 'remove a site; its folder is kept without --content',
}
class Parser(argparse.ArgumentParser):
"""An argument parser whose errors are Failures with status 2, so that --json answers them as JSON."""
def error(self, message):
raise Failure(f'{message}; see {self.prog} --help', 2)
def parse(argv):
parser = Parser(prog='vpn-router-site', description='Manage the web sites of the router.')
parser.add_argument('--json', action='store_true', help='answer in JSON')
commands = parser.add_subparsers(dest='command', required=True, metavar='command', parser_class=Parser)
for command, text in HELP.items():
sub = commands.add_parser(command, help=text, description=text[0].upper() + text[1:] + '.')
if command != 'list':
sub.add_argument('name', help='the site name')
if command in ('add', 'update'):
sub.add_argument('--host', dest='hosts', action='append', metavar='HOST',
help='a host name; give it once for each name, replacing the current ones')
sub.add_argument('--no-hosts', action='store_true', help='answer any host name')
sub.add_argument('--port', type=int, metavar='PORT', help='the TCP port')
sub.add_argument('--listen', choices=vpnrouter_sites.LISTEN, help='the interfaces whose firewall opens the port')
sub.add_argument('--tls', choices=vpnrouter_sites.TLS, help='where the certificate comes from')
sub.add_argument('--ca', metavar='NAME', help='the issuing CA of tls ca')
sub.add_argument('--certificate', metavar='FILE', help='the PEM certificate chain of tls file')
sub.add_argument('--key', metavar='FILE', help='the PEM private key of tls file')
sub.add_argument('--index', metavar='FILE', help='the file served for a folder')
sub.add_argument('--spa', action=argparse.BooleanOptionalAction, help='serve the index of the site for any '
'path without a file')
sub.add_argument('--enabled', action=argparse.BooleanOptionalAction, help='serve the site')
sub.add_argument('--editors', metavar='GROUP', help="the group whose members change the files; '' for none")
if command == 'remove':
sub.add_argument('--content', action='store_true', help='remove the site\'s folder too')
return parser.parse_args(argv)
def values_of(options):
"""The settings the options give."""
values = {key: getattr(options, option) for key, option in (('port', 'port'), ('listen', 'listen'), ('tls', 'tls'),
('ca', 'ca'), ('index_file', 'index'), ('spa', 'spa'),
('enabled', 'enabled'), ('editors', 'editors'))
if getattr(options, option) is not None}
if options.no_hosts:
values['hosts'] = []
elif options.hosts:
values['hosts'] = options.hosts
for key, option in (('certificate', 'certificate'), ('private_key', 'key')):
path = getattr(options, option)
if path is not None:
try:
with open(path, encoding='utf-8') as handle:
values[key] = handle.read()
except OSError as exc:
raise Failure(f'{path} cannot be read: {exc.strerror}', 2)
return values
def run(options):
"""The answer of the command the options name, and whether it changed a site."""
command = options.command
if command == 'list':
return {'sites': vpnrouter_sites.listing()}, False
if command == 'show':
with vpnrouter_sites.database() as connection:
return vpnrouter_sites.shown(vpnrouter_sites.get(connection, options.name)), False
if command == 'remove':
return vpnrouter_sites.remove(options.name, options.content), True
cfg = vpnrouter.load_config()
return vpnrouter_sites.save(cfg, options.name, values_of(options), command == 'add'), True
def text_of(answer):
"""The answer as text for people."""
if answer.get('removed'):
return f'site {answer["name"]} removed'
sites = answer['sites'] if 'sites' in answer else [answer]
if not sites:
return 'no sites'
head = ('SITE', 'ENABLED', 'HOSTS', 'PORT', 'LISTEN', 'TLS', 'EDITORS')
rows = [(site['name'], 'yes' if site['enabled'] else 'no', ', '.join(site['hosts']) or '*', str(site['port']),
site['listen'], site['tls'] + (f' {site["ca"]}' if site['tls'] == 'ca' else ''), site['editors'] or '-')
for site in sites]
widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))]
return '\n'.join(' '.join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip() for row in (head, *rows))
def main(argv):
# A request that cannot be parsed is answered in JSON when --json is among the arguments.
as_json = '--json' in argv
try:
options = parse(argv)
as_json = options.json
answer, changed = run(options)
except Failure as failure:
message, status = str(failure), failure.status
except vpnrouter_sites.DatabaseError as exc:
message, status = str(exc), 1
except (ldap.LDAPError, OSError) as exc:
message, status = f'the user directory failed: {exc}', 1
else:
if changed:
subprocess.run(['systemctl', 'restart', '--no-block', 'vpn-router-setup.service'])
print(json.dumps(answer) if as_json else text_of(answer))
return 0
if as_json:
print(json.dumps({'error': message}))
else:
print(f'vpn-router-site: {message}', file=sys.stderr)
return status
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))
+59 -3
View File
@@ -12,9 +12,15 @@ user exists, 1 anything else. -h or --help shows the commands and their options.
vpn-router-user [--json] add <name> --first-name F --last-name L --email E (password on stdin)
vpn-router-user [--json] add <name> [--email E] (a Manager-only account; password on stdin)
vpn-router-user [--json] add <name> --service [--display-name N] [--description D] [--email E]
[--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...]
(a service account; password on stdin)
vpn-router-user [--json] update <name> [--first-name F] [--last-name L] [--email E]
vpn-router-user [--json] update <name> [--display-name N] [--description D] [--email E] (a service account)
vpn-router-user [--json] update <name> [--display-name N] [--description D] [--email E]
[--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...]
(a service account)
A service account with a redirect URI is a client of the OpenID Provider; each of the three options may be repeated,
and replaces all values of its kind; one '' removes them.
vpn-router-user [--json] passwd <name> (password on stdin)
vpn-router-user [--json] disable <name>
vpn-router-user [--json] enable <name>
@@ -24,6 +30,8 @@ user exists, 1 anything else. -h or --help shows the commands and their options.
vpn-router-user [--json] otp-new <name>
vpn-router-user [--json] otp-enrol <name> <code> (secret on stdin)
vpn-router-user [--json] otp-remove <name>
vpn-router-user [--json] console <name> (--posix | --account LOCAL | --off)
vpn-router-user [--json] passkeys-remove <name>
"""
import argparse
@@ -36,6 +44,7 @@ import ldap
import vpnrouter
import vpnrouter_auth
import vpnrouter_passkeys
import vpnrouter_users
from vpnrouter_users import Failure
@@ -56,6 +65,9 @@ HELP = {
'otp-new': 'make a TOTP secret and its otpauth URI; nothing is stored',
'otp-enrol': 'store the TOTP secret read from standard input, when the code is a code of it',
'otp-remove': 'remove the TOTP token',
'console': "switch the console login of a member of admins with a TOTP token on, as the user's own POSIX account "
'in the directory or as a local account, or off',
'passkeys-remove': "remove all the user's passkeys",
}
#: The help of the person options.
@@ -63,6 +75,13 @@ PERSON_HELP = {'first_name': 'the first name', 'last_name': 'the last name', 'em
'display_name': "a service account's display name; '' removes it",
'description': "a service account's description; '' removes it"}
#: The OpenID Provider client settings of a service account, their options and help.
CLIENT_OPTIONS = (
('redirect_uris', 'redirect-uri', 'a redirect URI of the OpenID Provider client'),
('post_logout_redirect_uris', 'post-logout-redirect-uri', 'a post-logout redirect URI of the OpenID Provider client'),
('allowed_groups', 'allowed-group', 'a group whose members may sign in to the OpenID Provider client'),
)
class Parser(argparse.ArgumentParser):
"""An argument parser whose errors are Failures with status 2, so that --json answers them as JSON."""
@@ -82,10 +101,18 @@ def parse(argv):
if command in ('add', 'update'):
for key, _ in vpnrouter_users.PERSON + vpnrouter_users.LABELS:
sub.add_argument(f'--{key.replace("_", "-")}', dest=key, metavar='TEXT', help=PERSON_HELP[key])
for key, option, text in CLIENT_OPTIONS:
sub.add_argument(f'--{option}', dest=key, action='append', metavar='VALUE',
help=f"{text}, of a service account; repeat it for more, '' for none")
if command == 'add':
sub.add_argument('--service', action='store_true', help='add a service account, which cannot sign in to the web service')
if command == 'otp-enrol':
sub.add_argument('code', help='a code of the secret from the authenticator')
if command == 'console':
choice = sub.add_mutually_exclusive_group(required=True)
choice.add_argument('--posix', action='store_true', help="run the console as the user's own POSIX account")
choice.add_argument('--account', metavar='LOCAL', help='run the console as this local account')
choice.add_argument('--off', action='store_true', help='switch console login off')
return parser.parse_args(argv)
@@ -100,6 +127,10 @@ def run(connection, options, secret):
if command == 'list':
return vpnrouter_users.list_users(connection)
values = {key: getattr(options, key, None) for key, _ in vpnrouter_users.PERSON + vpnrouter_users.LABELS}
for key, _, _ in CLIENT_OPTIONS:
given = getattr(options, key, None)
if given is not None:
values[key] = [value for value in given if value]
if command == 'add':
return vpnrouter_users.add(connection, options.name, secret, values, service=options.service)
if command == 'update':
@@ -108,9 +139,32 @@ def run(connection, options, secret):
return vpnrouter_users.passwd(connection, options.name, secret)
if command == 'otp-enrol':
return vpnrouter_users.otp_enrol(connection, options.name, secret, options.code)
if command == 'console':
return vpnrouter_users.console(connection, options.name, not options.off, options.account)
if command == 'passkeys-remove':
return vpnrouter_passkeys.remove(connection, options.name)
return getattr(vpnrouter_users, command.replace('-', '_'))(connection, options.name)
def console_text(user):
"""The console login of a user as a cell: -, off, posix (uid N) or local and the account, refused without a TOTP
token."""
console = user['console']
if console is None or not console['enabled']:
return '-' if console is None else 'off'
text = f'local {console["account"]}' if console['local'] else f'posix (uid {console["uid"]})'
return text if user['otp'] else f'{text}, refused: no OTP'
def client_text(user):
"""The OpenID Provider client settings of a service account as a cell: -, or the numbers of its URIs and its allowed
groups."""
if not user.get('redirect_uris'):
return '-'
groups = ','.join(user['allowed_groups']) or 'all users'
return f'{len(user["redirect_uris"])} redirect, {len(user["post_logout_redirect_uris"])} logout, {groups}'
def text_of(answer):
"""The answer as text for people."""
if 'profile' in answer:
@@ -122,11 +176,13 @@ def text_of(answer):
users = answer['users'] if 'users' in answer else [answer]
if not users:
return 'no users'
head = ('USER', 'KIND', 'STATE', 'CERTIFICATE', 'OTP', 'FIRST NAME', 'LAST NAME', 'E-MAIL')
head = ('USER', 'KIND', 'STATE', 'CERTIFICATE', 'OTP', 'PASSKEYS', 'CONSOLE', 'FIRST NAME', 'LAST NAME', 'E-MAIL',
'OIDC CLIENT')
rows = [(user['name'], 'internal' if user['internal'] else 'service' if user['service'] else 'manager' if user['manager_only'] else 'vpn',
'disabled' if not user['enabled'] else 'locked' if user['locked'] else 'enabled',
'issued' if user['certificate'] else 'missing',
'enrolled' if user['otp'] else '-', user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-')
'enrolled' if user['otp'] else '-', str(len(user['passkeys'])) if user['passkeys'] else '-', console_text(user),
user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-', client_text(user))
for user in users]
widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))]
return '\n'.join(' '.join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip() for row in (head, *rows))
@@ -0,0 +1,59 @@
# bash completion for vpn-config-edit -*- shell-script -*-
# Python of the package that answers from its schema (vpnrouter.SCHEMA, vpnrouter.CONNECTION) and, read as root, the
# site-to-site connections of the configuration: settings prints SECTION.OPTION of every setting and
# site_to_site.NAME.OPTION of every connection, connections site_to_site.NAME, and values the values of a boolean
# setting, $1.
_vpn_config_edit_python()
{
python3 -c '
import sys
sys.path.insert(0, "/usr/lib/vpn-router")
import vpnrouter
try:
connections = vpnrouter.read_settings().get("site_to_site", {})
except (OSError, vpnrouter.ConfigError):
connections = {}
if sys.argv[1] == "settings":
print("\n".join(f"{section}.{option}" for section, options in vpnrouter.SCHEMA.items() for option in options))
print("\n".join(f"site_to_site.{name}.{option}" for name in connections for option in vpnrouter.CONNECTION))
elif sys.argv[1] == "connections":
print("\n".join(f"site_to_site.{name}" for name in connections))
else:
section, _, option = sys.argv[2].rpartition(".")
defaults = vpnrouter.CONNECTION if section.startswith("site_to_site.") else vpnrouter.SCHEMA.get(section, {})
if isinstance(defaults.get(option), bool):
print("true\nfalse")
' "$@" 2>/dev/null
}
_vpn_config_edit()
{
local cur prev words cword split
_init_completion -s || return
# The command is the first word that is not an option; the arguments are the words after it.
local i command='' args=()
for ((i = 1; i < cword; i++)); do
if [[ -z $command ]]; then
[[ ${words[i]} != -* ]] && command=${words[i]}
elif [[ ${words[i]} != -* ]]; then
args+=("${words[i]}")
fi
done
if [[ $cur == -* ]]; then
COMPREPLY=($(compgen -W "--help$([[ $command == @(set|remove) ]] && echo ' --restart')" -- "$cur"))
return
fi
case $command,${#args[@]} in
,*) COMPREPLY=($(compgen -W 'show set remove' -- "$cur")) ;;
set,0) COMPREPLY=($(compgen -W "$(_vpn_config_edit_python settings)" -- "$cur")) ;;
set,1) COMPREPLY=($(compgen -W "$(_vpn_config_edit_python values "${args[0]}")" -- "$cur")) ;;
remove,0) COMPREPLY=($(compgen -W "$(_vpn_config_edit_python connections)" -- "$cur")) ;;
esac
} &&
complete -F _vpn_config_edit vpn-config-edit
# ex: filetype=sh
@@ -0,0 +1,41 @@
# bash completion for vpn-router-backup -*- shell-script -*-
_vpn_router_backup()
{
local cur prev words cword split
_init_completion -s || return
# The command is the first word that is not an option; the arguments are the words after it.
local i command='' args=0 pbs=false
for ((i = 1; i < cword; i++)); do
if [[ -z $command ]]; then
[[ ${words[i]} != -* ]] && command=${words[i]}
elif [[ ${words[i]} == --pbs ]]; then
pbs=true
elif [[ ${words[i]} != -* ]]; then
((args++))
fi
done
if [[ -z $command ]]; then
if [[ $cur == -* ]]; then
COMPREPLY=($(compgen -W '--help' -- "$cur"))
else
COMPREPLY=($(compgen -W 'run list restore' -- "$cur"))
fi
return
fi
if [[ $cur == -* ]]; then
COMPREPLY=($(compgen -W "--help$([[ $command == restore ]] && echo ' --pbs --yes')" -- "$cur"))
return
fi
# restore takes a local archive, or with --pbs a snapshot name, which vpn-router-backup list shows.
if [[ $command == restore ]] && ((args == 0)) && ! $pbs; then
_filedir
fi
} &&
complete -F _vpn_router_backup vpn-router-backup
# ex: filetype=sh
@@ -0,0 +1,98 @@
# bash completion for vpn-router-ca -*- shell-script -*-
# Python of the package that prints the revocation reasons (reasons) or, from the configuration read as root, the
# issuing CAs (cas) or the certificate templates (templates), which other users do not get.
_vpn_router_ca_python()
{
python3 -c '
import sys
sys.path.insert(0, "/usr/lib/vpn-router")
import vpnrouter
import vpnrouter_pki
if sys.argv[1] == "reasons":
print("\n".join(vpnrouter_pki.REASONS))
else:
cfg = vpnrouter.load_config()
print("\n".join(vpnrouter.issuing_cas(cfg) if sys.argv[1] == "cas" else vpnrouter.pki_templates(cfg)))
' "$@" 2>/dev/null
}
_vpn_router_ca()
{
local cur prev words cword split
_init_completion -s || return
# The command and, for ca and root, the action are the first words that are not options; the arguments are the
# words after them but option values.
local i command='' action='' args=0
for ((i = 1; i < cword; i++)); do
case ${words[i]} in
--ca | --template | --csr | --subject | --san | --pfx | --reason | --out) ((i++)) ;;
-*) ;;
*)
if [[ -z $command ]]; then
command=${words[i]}
elif [[ $command == @(ca|root) && -z $action ]]; then
action=${words[i]}
else
((args++))
fi
;;
esac
done
case $prev in
--ca)
COMPREPLY=($(compgen -W "$(_vpn_router_ca_python cas)" -- "$cur"))
return
;;
--template)
COMPREPLY=($(compgen -W "$(_vpn_router_ca_python templates)" -- "$cur"))
return
;;
--reason)
COMPREPLY=($(compgen -W "$(_vpn_router_ca_python reasons)" -- "$cur"))
return
;;
--csr | --pfx | --out)
_filedir
return
;;
--subject | --san)
return
;;
esac
$split && return
if [[ -z $command ]]; then
if [[ $cur == -* ]]; then
COMPREPLY=($(compgen -W '--json --help' -- "$cur"))
else
COMPREPLY=($(compgen -W 'status list issue revoke maintain ca root' -- "$cur"))
fi
return
fi
if [[ $cur == -* ]]; then
local options='--help'
case $command$action in
list) options+=' --ca' ;;
issue) options+=' --ca --template --csr --subject --san --pfx' ;;
revoke) options+=' --reason' ;;
rootexport-key) options+=' --out' ;;
rootremove-key) options+=' --yes' ;;
esac
COMPREPLY=($(compgen -W "$options" -- "$cur"))
return
fi
case $command,$action,$args in
ca,,*) COMPREPLY=($(compgen -W 'csr import' -- "$cur")) ;;
root,,*) COMPREPLY=($(compgen -W 'export-key remove-key import-key import-crl' -- "$cur")) ;;
ca,*,0) COMPREPLY=($(compgen -W "$(_vpn_router_ca_python cas)" -- "$cur")) ;;
ca,import,1 | root,import-key,0 | root,import-crl,0) _filedir ;;
esac
} &&
complete -F _vpn_router_ca vpn-router-ca
# ex: filetype=sh
@@ -0,0 +1,24 @@
# bash completion for vpn-router-caddy -*- shell-script -*-
_vpn_router_caddy()
{
local cur prev words cword
_init_completion || return
# After the command only --help is left.
local word
for word in "${words[@]:1:cword-1}"; do
if [[ $word != -* ]]; then
[[ $cur == -* ]] && COMPREPLY=($(compgen -W '--help' -- "$cur"))
return
fi
done
if [[ $cur == -* ]]; then
COMPREPLY=($(compgen -W '--json --help' -- "$cur"))
else
COMPREPLY=($(compgen -W 'status apply use-upstream' -- "$cur"))
fi
} &&
complete -F _vpn_router_caddy vpn-router-caddy
# ex: filetype=sh
@@ -0,0 +1,74 @@
# bash completion for vpn-router-group -*- shell-script -*-
# The names of the users and of the groups of the router's directory, read as root; nothing for other users.
_vpn_router_group_users()
{
vpn-router-user --json list 2>/dev/null |
python3 -c 'import json, sys; print("\n".join(user["name"] for user in json.load(sys.stdin)["users"]))' 2>/dev/null
}
_vpn_router_group_names()
{
vpn-router-group --json list 2>/dev/null |
python3 -c 'import json, sys; print("\n".join(group["name"] for group in json.load(sys.stdin)["groups"]))' 2>/dev/null
}
_vpn_router_group()
{
local cur prev words cword split
_init_completion -s || return
# The command is the first word that is not an option; the arguments are the words after it but option values.
local i command='' args=0
for ((i = 1; i < cword; i++)); do
if [[ -z $command ]]; then
[[ ${words[i]} != -* ]] && command=${words[i]}
continue
fi
case ${words[i]} in
--member | --description | --name) ((i++)) ;;
-*) ;;
*) ((args++)) ;;
esac
done
if [[ -z $command ]]; then
if [[ $cur == -* ]]; then
COMPREPLY=($(compgen -W '--json --help' -- "$cur"))
else
COMPREPLY=($(compgen -W 'list add update remove member-add member-remove' -- "$cur"))
fi
return
fi
case $prev in
--member)
COMPREPLY=($(compgen -W "$(_vpn_router_group_users)" -- "$cur"))
return
;;
--description | --name)
return
;;
esac
$split && return
if [[ $cur == -* ]]; then
local options='--help'
case $command in
add) options+=' --member --description' ;;
update) options+=' --name --description' ;;
esac
COMPREPLY=($(compgen -W "$options" -- "$cur"))
return
fi
# The group, which add takes new, then the user of member-add and member-remove.
if ((args == 0)) && [[ $command != @(list|add) ]]; then
COMPREPLY=($(compgen -W "$(_vpn_router_group_names)" -- "$cur"))
elif ((args == 1)) && [[ $command == member-@(add|remove) ]]; then
COMPREPLY=($(compgen -W "$(_vpn_router_group_users)" -- "$cur"))
fi
} &&
complete -F _vpn_router_group vpn-router-group
# ex: filetype=sh
@@ -0,0 +1,95 @@
# bash completion for vpn-router-site -*- shell-script -*-
# The names of the sites, of the groups and of the general-purpose issuing CAs, read as root; nothing for other users.
_vpn_router_site_names()
{
vpn-router-site --json list 2>/dev/null |
python3 -c 'import json, sys; print("\n".join(site["name"] for site in json.load(sys.stdin)["sites"]))' 2>/dev/null
}
_vpn_router_site_groups()
{
vpn-router-group --json list 2>/dev/null |
python3 -c 'import json, sys; print("\n".join(group["name"] for group in json.load(sys.stdin)["groups"]))' 2>/dev/null
}
_vpn_router_site_cas()
{
python3 -c 'import sys; sys.path.insert(0, "/usr/lib/vpn-router"); import vpnrouter
print("\n".join(vpnrouter.load_config()["pki"]["issuing_cas"]))' 2>/dev/null
}
_vpn_router_site()
{
local cur prev words cword split
_init_completion -s || return
# The command is the first word that is not an option; the arguments are the words after it but option values.
local i command='' args=0
for ((i = 1; i < cword; i++)); do
if [[ -z $command ]]; then
[[ ${words[i]} != -* ]] && command=${words[i]}
continue
fi
case ${words[i]} in
--host | --port | --listen | --tls | --ca | --certificate | --key | --index | --editors) ((i++)) ;;
-*) ;;
*) ((args++)) ;;
esac
done
if [[ -z $command ]]; then
if [[ $cur == -* ]]; then
COMPREPLY=($(compgen -W '--json --help' -- "$cur"))
else
COMPREPLY=($(compgen -W 'list show add update remove' -- "$cur"))
fi
return
fi
case $prev in
--listen)
COMPREPLY=($(compgen -W 'internal external both' -- "$cur"))
return
;;
--tls)
COMPREPLY=($(compgen -W 'none acme ca file' -- "$cur"))
return
;;
--ca)
COMPREPLY=($(compgen -W "$(_vpn_router_site_cas)" -- "$cur"))
return
;;
--editors)
COMPREPLY=($(compgen -W "$(_vpn_router_site_groups)" -- "$cur"))
return
;;
--certificate | --key)
_filedir
return
;;
--host | --port | --index)
return
;;
esac
$split && return
if [[ $cur == -* ]]; then
local options='--help'
case $command in
add | update) options+=' --host --no-hosts --port --listen --tls --ca --certificate --key --index --spa
--no-spa --enabled --no-enabled --editors' ;;
remove) options+=' --content' ;;
esac
COMPREPLY=($(compgen -W "$options" -- "$cur"))
return
fi
# The site, which add takes new.
if ((args == 0)) && [[ $command != @(list|add) ]]; then
COMPREPLY=($(compgen -W "$(_vpn_router_site_names)" -- "$cur"))
fi
} &&
complete -F _vpn_router_site vpn-router-site
# ex: filetype=sh
@@ -0,0 +1,25 @@
# bash completion for vpn-router-token -*- shell-script -*-
_vpn_router_token()
{
local cur prev words cword split
_init_completion -s || return
case $prev in
--minutes | --hours | --days)
return
;;
esac
$split && return
# The lifetime is one of --minutes, --hours and --days.
local options='--json --help' word
for word in "${words[@]:1:cword-1}"; do
[[ $word == --@(minutes|hours|days)* ]] && break
done
[[ $word == --@(minutes|hours|days)* ]] || options+=' --minutes --hours --days'
COMPREPLY=($(compgen -W "$options" -- "$cur"))
} &&
complete -F _vpn_router_token vpn-router-token
# ex: filetype=sh
@@ -0,0 +1,84 @@
# bash completion for vpn-router-user -*- shell-script -*-
# The names of the users and of the groups of the router's directory, read as root; nothing for other users.
_vpn_router_user_names()
{
vpn-router-user --json list 2>/dev/null |
python3 -c 'import json, sys; print("\n".join(user["name"] for user in json.load(sys.stdin)["users"]))' 2>/dev/null
}
_vpn_router_user_groups()
{
vpn-router-group --json list 2>/dev/null |
python3 -c 'import json, sys; print("\n".join(group["name"] for group in json.load(sys.stdin)["groups"]))' 2>/dev/null
}
_vpn_router_user()
{
local cur prev words cword split
_init_completion -s || return
local commands='list add update passwd disable enable unlock remove profile otp-new otp-enrol otp-remove console
passkeys-remove'
# The command is the first word that is not an option; the arguments are the words after it but option values.
local i command='' args=0
for ((i = 1; i < cword; i++)); do
if [[ -z $command ]]; then
[[ ${words[i]} != -* ]] && command=${words[i]}
continue
fi
case ${words[i]} in
--first-name | --last-name | --email | --display-name | --description | --redirect-uri | \
--post-logout-redirect-uri | --allowed-group | --account) ((i++)) ;;
-*) ;;
*) ((args++)) ;;
esac
done
if [[ -z $command ]]; then
if [[ $cur == -* ]]; then
COMPREPLY=($(compgen -W '--json --help' -- "$cur"))
else
COMPREPLY=($(compgen -W "$commands" -- "$cur"))
fi
return
fi
case $prev in
--allowed-group)
COMPREPLY=($(compgen -W "$(_vpn_router_user_groups)" -- "$cur"))
return
;;
--account)
# The local accounts console --account takes: a uid from 1000 to 19999, not nobody.
COMPREPLY=($(compgen -W "$(getent passwd | awk -F: '$3 >= 1000 && $3 < 20000 && $1 != "nobody" { print $1 }')" -- "$cur"))
return
;;
--first-name | --last-name | --email | --display-name | --description | --redirect-uri | --post-logout-redirect-uri)
return
;;
esac
$split && return
if [[ $cur == -* ]]; then
local options='--help'
case $command in
add) options+=' --first-name --last-name --email --service --display-name --description --redirect-uri
--post-logout-redirect-uri --allowed-group' ;;
update) options+=' --first-name --last-name --email --display-name --description --redirect-uri
--post-logout-redirect-uri --allowed-group' ;;
console) options+=' --posix --account --off' ;;
esac
COMPREPLY=($(compgen -W "$options" -- "$cur"))
return
fi
# The user name, which add takes new; otp-enrol then takes a code.
if ((args == 0)) && [[ $command != @(list|add) ]]; then
COMPREPLY=($(compgen -W "$(_vpn_router_user_names)" -- "$cur"))
fi
} &&
complete -F _vpn_router_user vpn-router-user
# ex: filetype=sh
@@ -38,7 +38,8 @@
},
"p2s": {
"enabled": false,
"address_pool": "172.16.0.0/24"
"address_pool": "172.16.0.0/24",
"dns_listen": "internal"
},
"wireguard": {
"enabled": false,
@@ -61,7 +62,14 @@
"port": 7081,
"database": "",
"trusted_proxies": [],
"status_content": "basic"
"status_content": "basic",
"shell": false,
"shell_idle_minutes": 15,
"shell_auth_timeout": 15,
"log_files": ["/var/log/syslog", "/var/log/auth.log"]
},
"ssh": {
"ldap_login": false
},
"https": {
"enabled": true,
@@ -70,6 +78,23 @@
"manager_port": 7443,
"caddy_source": "ubuntu"
},
"oidc": {
"enabled": false
},
"sites": {
"enabled": false,
"upload_max_mb": 512,
"webdav": false,
"webdav_port": 7445
},
"dns": {
"enabled": false,
"zone": "",
"nameserver": "",
"addresses": [],
"listen": "external",
"ttl": 300
},
"pki": {
"root_name": "VPN Router Root CA",
"root_days": 7300,
@@ -0,0 +1,87 @@
-- The zone the router's name server (PowerDNS, gpgsql backend) answers for (vpnrouter_dns), in the vpn-router-dns
-- database, whose domains and records tables are PowerDNS's own schema.
-- The whole file runs as one transaction, serialized by an advisory lock, so that two processes can load it at once.
SELECT pg_advisory_xact_lock(hashtext('vpn-router dns schema'));
-- A record an administrator added to the zone, besides those of the zone itself and of the sites.
CREATE TABLE IF NOT EXISTS dns_manual_records (
name text NOT NULL,
type text NOT NULL,
content text NOT NULL,
ttl integer NOT NULL,
PRIMARY KEY (name, type, content)
);
-- The digest of the records last written to the zone and the serial of its SOA.
CREATE TABLE IF NOT EXISTS dns_state (
zone text PRIMARY KEY,
digest text NOT NULL,
serial bigint NOT NULL
);
CREATE OR REPLACE FUNCTION dns_manual_list()
RETURNS TABLE (name text, type text, content text, ttl integer)
LANGUAGE sql STABLE AS $$
SELECT name, type, content, ttl FROM dns_manual_records ORDER BY name, type, content;
$$;
-- Add the record, or set its TTL.
CREATE OR REPLACE PROCEDURE dns_manual_put(p_name text, p_type text, p_content text, p_ttl integer)
LANGUAGE sql AS $$
INSERT INTO dns_manual_records VALUES (p_name, p_type, p_content, p_ttl)
ON CONFLICT (name, type, content) DO UPDATE SET ttl = p_ttl;
$$;
-- Remove the records of the name and type, all of them without content.
CREATE OR REPLACE PROCEDURE dns_manual_remove(p_name text, p_type text, p_content text)
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);
$$;
-- Make the zone the only one served and give it the records, its SOA first, whose content has {serial} for the serial.
-- The serial grows, to at least the current Unix time, only when the records differ from those last written.
CREATE OR REPLACE PROCEDURE dns_apply(p_zone text, p_soa text, p_ttl integer, p_names text[], p_types text[],
p_contents text[], p_ttls integer[])
LANGUAGE plpgsql AS $$
DECLARE
v_domain integer;
v_serial bigint;
v_digest text := md5(concat_ws('|', p_soa, p_ttl, array_to_string(p_names, ','), array_to_string(p_types, ','),
array_to_string(p_contents, ','), array_to_string(p_ttls, ',')));
BEGIN
DELETE FROM domains WHERE name <> p_zone;
DELETE FROM dns_state WHERE zone <> p_zone;
SELECT id INTO v_domain FROM domains WHERE name = p_zone;
IF v_domain IS NULL THEN
INSERT INTO domains (name, type) VALUES (p_zone, 'NATIVE') RETURNING id INTO v_domain;
ELSIF EXISTS (SELECT 1 FROM dns_state WHERE zone = p_zone AND digest = v_digest) THEN
RETURN;
END IF;
SELECT greatest(coalesce(max(serial) + 1, 1), extract(epoch FROM now())::bigint) INTO v_serial
FROM dns_state WHERE zone = p_zone;
DELETE FROM records WHERE domain_id = v_domain;
INSERT INTO records (domain_id, name, type, content, ttl, prio, disabled, auth)
VALUES (v_domain, p_zone, 'SOA', replace(p_soa, '{serial}', v_serial::text), p_ttl, 0, false, true);
INSERT INTO records (domain_id, name, type, content, ttl, prio, disabled, auth)
SELECT v_domain, r.name, r.type, r.content, r.ttl, 0, false, true
FROM unnest(p_names, p_types, p_contents, p_ttls) AS r(name, type, content, ttl);
INSERT INTO dns_state VALUES (p_zone, v_digest, v_serial)
ON CONFLICT (zone) DO UPDATE SET digest = v_digest, serial = v_serial;
END;
$$;
-- Serve no zone.
CREATE OR REPLACE PROCEDURE dns_clear()
LANGUAGE sql AS $$
DELETE FROM domains;
DELETE FROM dns_state;
$$;
-- The records of the zone as they are served.
CREATE OR REPLACE FUNCTION dns_served(p_zone text)
RETURNS TABLE (name text, type text, content text, ttl integer)
LANGUAGE sql STABLE AS $$
SELECT r.name, r.type, r.content, r.ttl FROM records r JOIN domains d ON d.id = r.domain_id
WHERE d.name = p_zone ORDER BY r.type <> 'SOA', r.name, r.type, r.content;
$$;
@@ -0,0 +1,113 @@
-- The state of the router's OpenID Provider (vpnrouter_oidc) in the database of web.database.
-- Codes, tokens and sessions are kept as the SHA-256 of their values; expired rows are purged when new ones are saved.
-- 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 oidc schema'));
-- An authorization code, valid until it is exchanged for tokens or expires.
CREATE TABLE IF NOT EXISTS oidc_codes (
hash text PRIMARY KEY,
client_id text NOT NULL,
name text NOT NULL,
redirect_uri text NOT NULL,
scopes text[] NOT NULL,
nonce text,
code_challenge text NOT NULL,
code_challenge_method text NOT NULL,
auth_time timestamptz NOT NULL,
amr text[] NOT NULL,
expires timestamptz NOT NULL
);
-- An access token or a refresh token (kind) a client was issued for a user.
CREATE TABLE IF NOT EXISTS oidc_tokens (
hash text PRIMARY KEY,
kind text NOT NULL CHECK (kind IN ('access', 'refresh')),
client_id text NOT NULL,
name text NOT NULL,
scopes text[] NOT NULL,
auth_time timestamptz NOT NULL,
amr text[] NOT NULL,
expires timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS oidc_tokens_name ON oidc_tokens (name);
-- A user's sign-in at the OpenID Provider, which the session cookie names.
CREATE TABLE IF NOT EXISTS oidc_sessions (
hash text PRIMARY KEY,
name text NOT NULL,
auth_time timestamptz NOT NULL,
amr text[] NOT NULL,
expires timestamptz NOT NULL
);
CREATE OR REPLACE PROCEDURE oidc_code_save(p_hash text, p_client_id text, p_name text, p_redirect_uri text,
p_scopes text[], p_nonce text, p_code_challenge text,
p_code_challenge_method text, p_auth_time timestamptz, p_amr text[],
p_seconds integer)
LANGUAGE sql AS $$
DELETE FROM oidc_codes WHERE expires < now();
INSERT INTO oidc_codes VALUES (p_hash, p_client_id, p_name, p_redirect_uri, p_scopes, p_nonce, p_code_challenge,
p_code_challenge_method, p_auth_time, p_amr, now() + make_interval(secs => p_seconds));
$$;
-- The unexpired code.
CREATE OR REPLACE FUNCTION oidc_code_get(p_hash text)
RETURNS TABLE (client_id text, name text, redirect_uri text, scopes text[], nonce text, code_challenge text,
code_challenge_method text, auth_time timestamptz, amr text[])
LANGUAGE sql STABLE AS $$
SELECT client_id, name, redirect_uri, scopes, nonce, code_challenge, code_challenge_method, auth_time, amr
FROM oidc_codes WHERE hash = p_hash AND expires > now();
$$;
CREATE OR REPLACE PROCEDURE oidc_code_delete(p_hash text)
LANGUAGE sql AS $$
DELETE FROM oidc_codes WHERE hash = p_hash;
$$;
CREATE OR REPLACE PROCEDURE oidc_token_save(p_hash text, p_kind text, p_client_id text, p_name text, p_scopes text[],
p_auth_time timestamptz, p_amr text[], p_seconds integer)
LANGUAGE sql AS $$
DELETE FROM oidc_tokens WHERE expires < now();
INSERT INTO oidc_tokens VALUES (p_hash, p_kind, p_client_id, p_name, p_scopes, p_auth_time, p_amr,
now() + make_interval(secs => p_seconds));
$$;
-- The unexpired token of the kind.
CREATE OR REPLACE FUNCTION oidc_token_get(p_hash text, p_kind text)
RETURNS TABLE (client_id text, name text, scopes text[], auth_time timestamptz, amr text[])
LANGUAGE sql STABLE AS $$
SELECT client_id, name, scopes, auth_time, amr FROM oidc_tokens
WHERE hash = p_hash AND kind = p_kind AND expires > now();
$$;
CREATE OR REPLACE PROCEDURE oidc_token_delete(p_hash text)
LANGUAGE sql AS $$
DELETE FROM oidc_tokens WHERE hash = p_hash;
$$;
CREATE OR REPLACE PROCEDURE oidc_session_save(p_hash text, p_name text, p_amr text[], p_seconds integer)
LANGUAGE sql AS $$
DELETE FROM oidc_sessions WHERE expires < now();
INSERT INTO oidc_sessions VALUES (p_hash, p_name, now(), p_amr, now() + make_interval(secs => p_seconds));
$$;
-- The unexpired session.
CREATE OR REPLACE FUNCTION oidc_session_get(p_hash text)
RETURNS TABLE (name text, auth_time timestamptz, amr text[])
LANGUAGE sql STABLE AS $$
SELECT name, auth_time, amr FROM oidc_sessions WHERE hash = p_hash AND expires > now();
$$;
CREATE OR REPLACE PROCEDURE oidc_session_delete(p_hash text)
LANGUAGE sql AS $$
DELETE FROM oidc_sessions WHERE hash = p_hash;
$$;
-- Remove every code, token and session of the user.
CREATE OR REPLACE PROCEDURE oidc_revoke(p_name text)
LANGUAGE sql AS $$
DELETE FROM oidc_codes WHERE name = p_name;
DELETE FROM oidc_tokens WHERE name = p_name;
DELETE FROM oidc_sessions WHERE name = p_name;
$$;
@@ -0,0 +1,85 @@
-- The web sites Caddy serves (vpnrouter_sites), in the vpn-router-sites database.
-- 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 sites schema'));
-- A static web site: its host names, port, interfaces and TLS, and the group whose members change its files.
CREATE TABLE IF NOT EXISTS sites (
name text PRIMARY KEY,
enabled boolean NOT NULL,
hosts text[] NOT NULL,
port integer NOT NULL,
listen text NOT NULL CHECK (listen IN ('internal', 'external', 'both')),
tls text NOT NULL CHECK (tls IN ('none', 'acme', 'ca', 'file')),
ca text NOT NULL,
certificate text NOT NULL,
private_key text NOT NULL,
index_file text NOT NULL,
spa boolean NOT NULL,
editors text NOT NULL,
modified timestamptz NOT NULL
);
-- Every site, by name; the private key is left out.
CREATE OR REPLACE FUNCTION sites_list()
RETURNS TABLE (name text, enabled boolean, hosts text[], port integer, listen text, tls text, ca text,
certificate text, index_file text, spa boolean, editors text, modified timestamptz)
LANGUAGE sql STABLE AS $$
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, index_file, spa, editors, modified
FROM sites ORDER BY name;
$$;
-- The site with its private key.
CREATE OR REPLACE FUNCTION site_get(p_name text)
RETURNS TABLE (name text, enabled boolean, hosts text[], port integer, listen text, tls text, ca text,
certificate text, private_key text, index_file text, spa boolean, editors text, modified timestamptz)
LANGUAGE sql STABLE AS $$
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, private_key, index_file, spa, editors, modified
FROM sites WHERE name = p_name;
$$;
-- The enabled sites with their private keys, by name, for Caddy's configuration and certificate files.
CREATE OR REPLACE FUNCTION sites_served()
RETURNS TABLE (name text, enabled boolean, hosts text[], port integer, listen text, tls text, ca text,
certificate text, private_key text, index_file text, spa boolean, editors text, modified timestamptz)
LANGUAGE sql STABLE AS $$
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, private_key, index_file, spa, editors, modified
FROM sites WHERE enabled ORDER BY name;
$$;
-- Add the site, or replace all its settings.
CREATE OR REPLACE PROCEDURE site_put(p_name text, p_enabled boolean, p_hosts text[], p_port integer, p_listen text,
p_tls text, p_ca text, p_certificate text, p_private_key text, p_index_file text,
p_spa boolean, p_editors text)
LANGUAGE sql AS $$
INSERT INTO sites VALUES (p_name, p_enabled, p_hosts, p_port, p_listen, p_tls, p_ca, p_certificate, p_private_key,
p_index_file, p_spa, p_editors, now())
ON CONFLICT (name) DO UPDATE SET enabled = p_enabled, hosts = p_hosts, port = p_port, listen = p_listen,
tls = p_tls, ca = p_ca, certificate = p_certificate, private_key = p_private_key, index_file = p_index_file,
spa = p_spa, editors = p_editors, modified = now();
$$;
CREATE OR REPLACE PROCEDURE site_remove(p_name text)
LANGUAGE sql AS $$
DELETE FROM sites WHERE name = p_name;
$$;
-- The names of the sites whose files the members of the groups may change.
CREATE OR REPLACE FUNCTION sites_editable(p_groups text[])
RETURNS TABLE (name text)
LANGUAGE sql STABLE AS $$
SELECT name FROM sites WHERE editors <> '' AND editors = ANY (p_groups) ORDER BY name;
$$;
-- The names of the sites whose editors are the group.
CREATE OR REPLACE FUNCTION sites_of_group(p_group text)
RETURNS TABLE (name text)
LANGUAGE sql STABLE AS $$
SELECT name FROM sites WHERE editors = p_group ORDER BY name;
$$;
-- Give the sites of a renamed group its new name.
CREATE OR REPLACE PROCEDURE sites_rename_group(p_old text, p_new text)
LANGUAGE sql AS $$
UPDATE sites SET editors = p_new, modified = now() WHERE editors = p_old;
$$;
@@ -1,13 +1,13 @@
# 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 and the web sites; edits are overwritten.
{
auto_https disable_redirects
{%- if https_email %}
email {{ https_email }}
{%- endif %}
{%- if acme_cas %}
{%- if pki_cas %}
skip_install_trust
pki {
{%- for ca in acme_cas %}
{%- for ca in pki_cas %}
ca {{ ca.id }} {
name "{{ ca.name }}"
root {
@@ -23,7 +23,7 @@
}
{%- endif %}
}
{% if web_enabled or 'https' in pki_publish or acme_cas %}
{% if https and (web_enabled or 'https' in pki_publish or acme_cas) %}
{{ local_fqdn }}:{{ https_web_port }} {
{%- if 'https' in pki_publish %}
handle_path /pki/* {
@@ -32,9 +32,9 @@
}
{%- endif %}
{%- for ca in acme_cas %}
handle /acme/{{ ca.id }}/* {
handle /acme/{{ ca }}/* {
acme_server {
ca {{ ca.id }}
ca {{ ca }}
}
}
{%- endfor %}
@@ -47,7 +47,7 @@
}
}
{% endif %}
{%- if 'http-80' in pki_publish %}
{%- if https and 'http-80' in pki_publish %}
http://{{ local_fqdn }} {
handle_path /pki/* {
root * {{ pki_publish_dir }}
@@ -58,7 +58,7 @@ http://{{ local_fqdn }} {
}
}
{% endif %}
{%- if 'https-default' in pki_publish %}
{%- if https and 'https-default' in pki_publish %}
{{ local_fqdn }}:443 {
handle_path /pki/* {
root * {{ pki_publish_dir }}
@@ -69,13 +69,39 @@ http://{{ local_fqdn }} {
}
}
{% endif %}
{%- if manager_port %}
{%- if https and manager_port %}
{{ local_fqdn }}:{{ https_manager_port }} {
reverse_proxy {{ loopback }}:{{ manager_port }}
}
{% endif %}
{%- if metrics_enabled %}
{%- if https and metrics_enabled %}
{{ local_fqdn }}:{{ metrics_https_port }} {
reverse_proxy {{ loopback }}:{{ metrics_port }}
}
{% endif %}
{%- if webdav %}
{{ local_fqdn }}:{{ webdav_port }} {
reverse_proxy {{ loopback }}:{{ webdav_loopback_port }}
}
{% endif %}
{%- for site in sites %}
# Site {{ site.name }}
{{ site.addresses | join(', ') }} {
root * {{ site.root }}
{%- if site.tls == 'ca' %}
tls {
issuer internal {
ca {{ site.ca }}
}
}
{%- elif site.tls == 'file' %}
tls {{ site.cert }} {{ site.key }}
{%- endif %}
{%- if site.spa %}
try_files {path} /{{ site.index }}
{%- endif %}
file_server {
index {{ site.index }}
}
}
{% endfor %}
@@ -0,0 +1,11 @@
# Written by vpn-router-setup from /etc/vpn-router/vpn-router.json; edits are overwritten.
# The resolver of the remote access clients, on the addresses of p2s.dns_listen, forwarding to systemd-resolved's stub.
{% for address in p2s_resolver_addrs -%}
listen-address={{ address }}
{% endfor -%}
bind-interfaces
no-resolv
server=127.0.0.53
domain-needed
no-hosts
cache-size=1000
@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>{{ title }}</title>
<style>
body { font-family: system-ui, sans-serif; background: #f4f5f7; color: #1f2937; margin: 0; }
main { max-width: 22rem; margin: 4rem auto; background: #fff; padding: 2rem; border-radius: 0.5rem; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); }
h1 { font-size: 1.25rem; margin: 0 0 0.25rem; }
p { margin: 0 0 1rem; color: #4b5563; }
label { display: block; font-size: 0.875rem; margin: 0.75rem 0 0.25rem; }
input { box-sizing: border-box; width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 0.25rem; font-size: 1rem; }
button { width: 100%; margin-top: 1.25rem; padding: 0.6rem; border: 0; border-radius: 0.25rem; font-size: 1rem; cursor: pointer; background: #1d4ed8; color: #fff; }
button.secondary { background: #fff; color: #1d4ed8; border: 1px solid #1d4ed8; margin-top: 0.75rem; }
.error { background: #fee2e2; color: #991b1b; padding: 0.5rem 0.75rem; border-radius: 0.25rem; margin-bottom: 1rem; }
.issuer { font-size: 0.75rem; color: #6b7280; margin: 1.5rem 0 0; }
</style>
</head>
<body>
<main>
<h1>{{ title }}</h1>
{%- if message %}
<p>{{ message }}</p>
{%- endif %}
{%- if error %}
<div class="error" role="alert">{{ error }}</div>
{%- endif %}
{%- if form %}
<form method="post" action="/oidc/login">
<input type="hidden" name="request" value="{{ request }}">
<label for="name">Name</label>
<input id="name" name="name" value="{{ name }}" autocomplete="username webauthn" autocapitalize="none" spellcheck="false" required autofocus>
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
<label for="code">Code from the authenticator, when you have one</label>
<input id="code" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code">
<button type="submit">Sign in</button>
</form>
<form id="passkey-form" method="post" action="/oidc/login" hidden>
<input type="hidden" name="request" value="{{ request }}">
<input type="hidden" name="passkey_id">
<input type="hidden" name="credential">
</form>
<button id="passkey" class="secondary" type="button" hidden>Sign in with a passkey</button>
<script>
// A passkey sign-in begins at /oidc/passkey and posts the browser's answer to /oidc/login with the authorization request.
(function () {
var button = document.getElementById('passkey');
if (!window.PublicKeyCredential || !PublicKeyCredential.parseRequestOptionsFromJSON) {
return;
}
button.hidden = false;
button.addEventListener('click', async function () {
button.disabled = true;
try {
var begun = await (await fetch('/oidc/passkey', { method: 'POST' })).json();
var credential = await navigator.credentials.get({ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(begun.options.publicKey) });
var form = document.getElementById('passkey-form');
form.elements.passkey_id.value = begun.id;
form.elements.credential.value = JSON.stringify(credential.toJSON());
form.submit();
} catch (error) {
button.disabled = false;
}
});
})();
</script>
{%- endif %}
<p class="issuer">{{ issuer }}</p>
</main>
</body>
</html>
@@ -24,6 +24,8 @@ push "route {{ network }} {{ netmask }}"
push "dhcp-option DNS {{ address }}"
{% endfor -%}
status {{ openvpn_status }} {{ openvpn_status_seconds }}
keepalive 10 120
{% if openvpn_protocol == 'udp' -%}
explicit-exit-notify 1
@@ -1,4 +0,0 @@
[Resolve]
{% for address in local_addr_list -%}
DNSStubListenerExtra={{ address }}
{% endfor -%}
@@ -0,0 +1,9 @@
{{ wg_peers_begin }}
{% for user, key, ips in wg_peers -%}
[Peer]
# {{ user }}
PublicKey = {{ key }}
AllowedIPs = {{ ips | join(', ') }}
{% endfor -%}
{{ wg_peers_end }}