Compare commits

..
62 Commits
Author SHA1 Message Date
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
slawek d648d294ec 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.
Release / release (push) Successful in 20s
2026-10-25 23:27:19 +01:00
slawek a245ec26cd fix: /status/full of the status web service gives an authenticated client the whole status, whatever web.status_content says.
Release / release (push) Successful in 28s
2026-10-25 23:16:18 +01:00
slawek 7404ee88c4 fix: vpn-router-setup, vpn-router-ca and /ca read a CA's key and CRL from the directory each as itself, instead of failing on a CA with a CRL.
Release / release (push) Successful in 26s
2026-10-25 23:07:24 +01:00
slawek 448ce05b3a fix: the PKI is kept in ou=pki of the router's directory instead of files under /etc/vpn-router/pki, and the router's site-to-site certificate names only the identities its connections present, and /ca/bundle gives the certificate bundle of a CA.
Release / release (push) Successful in 29s
2026-10-25 23:03:03 +01:00
slawek e11c88f0de fix: pki.root_key_warning silences the warning of vpn-router-ca status and /ca about the root key on the router.
Release / release (push) Successful in 24s
2026-10-25 22:20:39 +01:00
slawek c19b195ea0 fix: vpn-router-ca status and /ca warn about a CRL the router signs only once vpn-router-pki.timer has not renewed it in time.
Release / release (push) Successful in 28s
2026-10-25 22:12:13 +01:00
slawek 8d3469d10a feature: the router has a two-level CA with an optional offline root, issuing CAs for P2S, site-to-site and general use, templates, published certificates and CRLs and an ACME server, site-to-site connections authenticate with certificates, and setup installs Caddy from Ubuntu or Caddy's repository.
Release / release (push) Successful in 13s
2026-10-25 22:05:39 +01:00
slawek fe45272f7c feature: the router makes scheduled backups to a local folder, copied to the cloud with rclone, or to a Proxmox Backup Server, and vpn-router-backup restores them.
Release / release (push) Successful in 13s
2026-10-25 20:18:00 +01:00
slawek 7d1f639508 update: ADR-24 names Ubuntu's apt-daily timer, not a cron job, as what updates the package lists.
Release / release (push) Successful in 12s
2026-10-25 19:08:24 +01:00
slawek 959ca0cd32 update: ADR-24 says that the Router Manager checks for updates on demand.
Release / release (push) Successful in 13s
2026-10-25 19:02:19 +01:00
slawek 1cef4c044f fix: /metrics/history gives the availability of each site-to-site connection as the percentage of time it was up in slots of the view.
Release / release (push) Successful in 30s
2026-10-25 18:49:47 +01:00
slawek 25c151841b feature: service accounts have a display name and a description, which the router sets for its internal ones.
Release / release (push) Successful in 20s
2026-10-25 18:34:59 +01:00
slawek 68ebd541b2 fix: internal service accounts are the router's own, of which the metrics service's is the one at present, and the documentation says what a service account is.
Release / release (push) Successful in 13s
2026-10-25 18:27:10 +01:00
slawek f01be41191 fix: service accounts are managed at /service-accounts, not /users, and the internal one of the metrics service is only listed.
Release / release (push) Successful in 12s
2026-10-25 18:18:03 +01:00
slawek d1970b77b7 fix: vpn-router-setup owns only the creation of the metrics service's account, which is marked internal, and administrators add service accounts with vpn-router-user add --service and /users.
Release / release (push) Successful in 32s
2026-10-25 18:00:25 +01:00
slawek bf651da5a6 update: release.py tags a Debian revision other than 1 as x.y.z-n.
Release / release (push) Successful in 19s
2026-10-25 17:32:48 +01:00
slawek 4a5b15a559 feature: site_to_site holds any number of named site-to-site connections, which /site-to-site lists, adds, changes and removes.
Release / release (push) Successful in 12s
2026-10-25 17:14:23 +01:00
slawek a79510a02f feature: /config shows the router's PostgreSQL clusters and their settings, and /postgresql their databases with their sizes.
Release / release (push) Successful in 28s
2026-10-25 15:26:54 +01:00
slawek 38151bf5be feature: the router keeps its metrics in PostgreSQL, serves them for an OpenTelemetry Collector and pushes them over OTLP.
Release / release (push) Successful in 14s
2026-10-25 14:57:47 +01:00
slawek 83c1c50d8d feature: vpn-config-edit shows and changes the router's configuration.
Release / release (push) Successful in 19s
2026-10-25 13:29:12 +01:00
slawek 650896131d fix: web.status_content is basic by default.
Release / release (push) Successful in 19s
2026-10-25 13:09:57 +01:00
slawek dbc2046e24 feature: the status page shows the Client IP, and web.status_content sets how much it shows.
Release / release (push) Successful in 12s
2026-10-25 13:08:08 +01:00
slawek 45d73abe7b feature: Caddy serves the status page and the Router Manager over HTTPS with a certificate from Let's Encrypt.
Release / release (push) Successful in 23s
2026-10-25 12:25:57 +01:00
slawek 993a7a9fa9 fix: the web service listens on the internal address, the external address or both.
Release / release (push) Successful in 25s
2026-10-25 11:37:43 +01:00
slawek 581b53d447 fix: the web service manages users also when it started before setup.
Release / release (push) Successful in 19s
2026-10-24 21:32:00 +02:00
slawek 8782edff0f fix: the status page shows whether the Router Manager runs.
Release / release (push) Successful in 19s
2026-10-24 12:50:49 +02:00
slawek fe17e83b49 feature: every user manages their own account through /account.
Release / release (push) Successful in 11s
2026-10-24 12:44:49 +02:00
slawek 17331eea37 feature: the status page shows the Router Manager's version.
Release / release (push) Successful in 12s
2026-10-24 12:16:02 +02:00
slawek af3127d1e8 feature: Manager-only accounts, and the Router Manager's first administrator.
Release / release (push) Successful in 20s
2026-10-24 11:56:58 +02:00
slawek b1e6d9cd0b fix: the web service starts once libpq can be found.
Release / release (push) Successful in 12s
2026-10-24 11:07:45 +02:00
slawek 732cc8cb54 fix: the web service waits for the PostgreSQL driver instead of failing at start.
Release / release (push) Successful in 25s
2026-10-24 10:43:51 +02:00
slawek c0cd323a0c feature: bootstrap diagnostics on port 17081 during the first setup.
Release / release (push) Successful in 13s
2026-10-24 10:07:22 +02:00
slawek 2e2bacee7d feature: the web service creates its key and database itself and shows its starting phase.
Release / release (push) Successful in 29s
2026-10-24 09:07:28 +02:00
slawek 4f1aa282f6 fix: a failing user directory or token database no longer leaves the tunnels down.
Release / release (push) Failing after 15m34s
2026-10-24 08:55:25 +02:00
slawek caf0e334f0 feature: users sign in to the web service, and only members of admins manage the router.
Release / release (push) Failing after 16m57s
2026-10-24 04:19:01 +02:00
slawek 791fc7fddd feature: users can be enrolled for TOTP one-time passwords.
Release / release (push) Failing after 15m23s
2026-10-24 03:00:35 +02:00
slawek f135d776f8 fix: package upgrades no longer ask the configuration questions.
Release / release (push) Successful in 18s
2026-10-24 02:16:19 +02:00
slawek cda33fc4c7 fix: an upgrade keeps the configuration and asks nothing.
Release / release (push) Successful in 18s
2026-10-24 02:08:23 +02:00
slawek 08693f7e68 feature: users have a first name, a last name and an e-mail address.
Release / release (push) Successful in 11s
2026-10-24 01:47:50 +02:00
slawek b9003bacb9 update: the status web service listens on port 7081 by default.
Release / release (push) Successful in 11s
2026-10-24 01:14:21 +02:00
slawek 0bf78b16b1 feature: users and their certificates no longer need a P2S system enabled.
Release / release (push) Successful in 21s
2026-10-24 00:00:44 +02:00
slawek 0ff74cc8c9 feature: the firewall lets the Router Manager in on the internal interface.
Release / release (push) Successful in 11s
2026-10-23 23:24:59 +02:00
slawek a7b29e0655 fix: the status always shows the state of slapd.
Release / release (push) Successful in 19s
2026-10-23 22:59:17 +02:00
slawek c377801be0 fix: /config no longer shows secret options.
Release / release (push) Successful in 11s
2026-10-23 22:48:31 +02:00
slawek a4cffb67e4 fix: the web service works in every state of the router.
Release / release (push) Successful in 11s
2026-10-23 22:35:53 +02:00
slawek cc6748a622 update: Added OpenLDAP and OpenVPN to the router.
Release / release (push) Successful in 18s
2026-10-23 21:55:03 +02:00
slawek 609921348b fix: incorrect swanctl parsing.
Release / release (push) Successful in 18s
2026-10-23 20:56:52 +02:00
slawek 90e7c79840 fix: replace webservice swanctl call human readble output with structured one.
Release / release (push) Successful in 18s
2026-10-23 20:44:17 +02:00
slawek a6b25b8bb4 fix: router setup hangs on race condition with Docker.
Release / release (push) Successful in 18s
2026-10-23 20:03:17 +02:00
slawek 7163f167e0 feature: Added an internally only exposed status web service.
Release / release (push) Successful in 17s
2026-10-23 19:46:26 +02:00
slawek 5402256ef6 fix: removed unnecressary peer ID handling that is no longer required for >=5.0 StrongSwan.
Release / release (push) Successful in 17s
2026-10-23 17:31:22 +02:00
slawek 64da0cf97f fix: update interface detection code to support additional interfaces Docker adds.
Release / release (push) Successful in 17s
2026-10-23 13:43:46 +02:00
slawek 2e0cee10ac feat: added support for running the VPN router on a host with Docker Community Edition.
Release / release (push) Successful in 1m8s
2026-10-23 13:17:32 +02:00
68 changed files with 12160 additions and 1157 deletions

No files matched your search

+971
View File
@@ -0,0 +1,971 @@
# Architecture Decision Records
## ADR-1: Docker's published ports are filtered in DOCKER-USER by the package
Status: Accepted
Context: Docker adds its own iptables rules for published ports. They are evaluated before
UFW's, so a published port is reachable from the external interface whatever UFW says. Docker
reserves the `DOCKER-USER` chain, evaluated first in `FORWARD`, for rules of the operator. The
router is an internet-facing host, and Docker is installed on it for services run alongside the
router.
Decision: The package secures Docker itself. A `docker` configuration section has `mode`
(`auto`, `on`, `off`) and `exposed_ports`. In `auto` mode the rules are applied when
`docker.service` is installed, which is what the package looks up; `on` requires Docker and
`off` removes the rules. The rules are in a chain of their own, `VPN-ROUTER-DOCKER`, entered by
one jump at the top of `DOCKER-USER`. For traffic from the external interface they accept IPsec
protected traffic, TCP to `exposed_ports` and established connections, and drop the rest. The
`vpn-router-docker` unit applies them after `docker.service` starts and removes them when it
stops; `vpn-router-setup` restarts it while Docker runs.
Consequences: Installing Docker first is recommended; `auto` mode then finds it as soon as the
package is configured. When Docker is installed after the package, the rules are applied by
restarting `vpn-router-setup`, a reboot or a restart of Docker, not by Docker's first start,
because `vpn-router-docker` is already active. A published
port is reachable from the external interface only when it is in `exposed_ports`. Other rules
in `DOCKER-USER` are not touched, because the package owns only its own chain. Restarting Docker
restores the rules. The rules cover IPv4 only.
## ADR-2: A small status web service, on the interfaces web.listen chooses
Status: Accepted
Context: The router has no way to see its state without a shell. A check from the protected
network, by a person or by a monitor, needs a page or an endpoint, and so does the platform that
deploys the router, which reaches it on its public address. The router is an internet-facing
host, so what the service shows and where it listens matter.
Decision: The `vpn-router-web` unit runs one Python script on the standard library's
`http.server`, with the package's own modules and the Python packages of the Ubuntu archive that
the package depends on, and no web framework. It listens on `web.port`, 7081 by default, on the
address of the internal interface, of the external interface or of both, as `web.listen` sets
(`internal`, `external` or `both`, the default), with a server for each address, and is on
unless `web.enabled` is `false` (ADR-7). `/` is for people, in plain text for `curl` and `wget`
and as a Tailwind CSS page for a browser, and `/status` is the same status as JSON. Both show as
much as `web.status_content` says, since the router is reachable from the internet and the
operator decides how much it tells strangers: `basic`, the default because it tells the least,
the overall status and the Client IP, `standard` also the package versions and the number of
active and other services, and `full` also each service and the state of the site-to-site, road-warrior, OpenVPN and
WireGuard features. The Client IP is the address the request came from, through the trusted
proxies, so a person can see the address the router sees. Nothing comes from the configuration.
`/status` names its content, so clients such as manage-ui and the Router Manager show what it
has. `vpn-router-setup` opens the
port on both interfaces in UFW, so the choice of `web.listen` is made by the service alone, and
writes the features and the facts it resolves to `/run/vpn-router/web.json`.
Consequences: By default the service is reachable from the internet on the external address;
`web.listen` set to `internal` keeps it to the internal network. It serves plain HTTP itself;
HTTPS is Caddy's (ADR-21), so on the plain HTTP port the passwords of `/auth` and the tokens
cross the internet unencrypted. A change of `web.listen`
needs no run of setup, since the firewall does not depend on it; the package's upgrade runs
setup, which writes the rules. `/` and `/status` have no authentication, which is acceptable because they are
read only and show no configuration. It runs as root, because `swanctl` and `wg` need it, with `NoNewPrivileges`,
`ProtectSystem=strict`, `ProtectHome` and `PrivateTmp`. The Tailwind style comes from a CDN, so a
browser on a network without internet shows the unstyled page, and `curl` is unaffected.
## ADR-3: The protected paths take JWTs that only the router signs
Status: Accepted
Context: Clients such as the Router Manager, and people with `curl`, need the configuration,
the users and the groups of the router, which `/` and `/status` must not show. Both people and
programs sign in with a user of the directory. A secret shared with the platform that deploys
the router would let whoever holds it, and every copy of the deployment's state, make tokens.
The service listens on plain HTTP.
Decision: `/config`, `/users` and `/groups` need a JWT in an `Authorization: Bearer` header,
HS256, verified with `hmac` and `hashlib`; no other algorithm is accepted. The key is
`/etc/vpn-router/web.key`, random, made by the web service on its first run and readable by root
only; it is never configured or output. The router itself issues
the tokens: `POST /auth` checks a user's password and TOTP code with a bind (ADR-14) and answers
an access token naming the user in `sub`, valid for 5 minutes, and a refresh token valid for 8
hours (ADR-15); `vpn-router-token` makes tokens with the `admin` claim (ADR-13). A token without
`admin` must expire within ten minutes, so a leaked one is of little use. Without the key the
paths and `/auth` are not served. The configuration shown has no secret options (ADR-8).
Consequences: Nothing outside the router can make a token, so a token is either issued to a
user who signed in or made by root on the router. A client keeps the refresh token to stay
signed in. A token of `vpn-router-token` can be revoked only by replacing the key, which ends
every token. Tokens, passwords and answers cross the network unencrypted, so the API is for a
trusted internal network; HTTPS would need a certificate that the package does not have.
## ADR-4: Users are kept in a local OpenLDAP directory
Status: Accepted
Context: OpenVPN needs user accounts with passwords, and the users are meant to cover every P2S
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.
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
allows), next to the database the slapd package creates from the host's domain, so the package
does not depend on the debconf answers of slapd. Its root DN is root's peer-credential DN on the
local socket, so `vpn-router-setup` manages it with SASL EXTERNAL and no administrator password
exists. Users are changed by `cn=user-manager,ou=services`, whose ACLs allow writing `ou=users`
and setting passwords (`=wx`) but not reading them. Passwords are hashed with ARGON2, and the
ppolicy overlay locks a disabled user (`pwdAccountLockedTime`, effective with `pwdLockout`).
slapd listens on the local socket only. Provisioning checks every step before changing it, so it
runs at every start of `vpn-router-setup`.
Consequences: The package depends on `slapd`, `python3-ldap`, `openvpn` and
`openvpn-auth-ldap`, and `vpn-router-setup` and `vpn-router-user` import `python3-ldap`. The
service account's password is a root-only file. The directory is user data: it is kept on purge,
and it is backed up with `slapcat`, not by copying files.
## ADR-5: OpenVPN clients need a certificate and the password of the user it names
Status: Accepted
Context: The P2S issuing CA (ADR-27) issues the users' client certificates, for IKEv2 and
OpenVPN. OpenVPN can check a certificate, a password, or both. With both, a certificate alone and
a password alone are not enough, but `openvpn-auth-ldap` checks only the password of the user name
given at login, which need not be the user of the certificate. OpenVPN trusts every chain to a
CA of its `ca` file and has no option that accepts one intermediate CA only, while the root also
signs the site-to-site and the general-purpose CAs.
Decision: OpenVPN checks the certificate against the root and the P2S CA and their CRLs, which
it needs for every CA of the chain, the password with `openvpn-auth-ldap`, and runs
`openvpn-same-user` as `auth-user-pass-verify` with `via-env`, which accepts the login only when
the user name equals the certificate's common name. `openvpn-issuer`, as `tls-verify`, accepts
the certificate at depth 1 only when its SHA-256 fingerprint, which OpenVPN gives the script,
is the P2S CA's. OpenVPN calls all of them and needs all to pass. A user's certificate has the
user name as its common name, is issued with the template `vpn-user`, client authentication
only, when the user is added, and revoked when the user is removed. The server uses the router's
VPN server certificate, whose common name is `wan.local_fqdn`; the client profile checks that
name (`verify-x509-name`).
Consequences: Disabling a user locks the password, which stops OpenVPN but not IKEv2, which
checks certificates only; removing a user revokes the certificate for both. A certificate of
another issuing CA with a user's name does not reach the password check. A user's certificate
expires after the days of `vpn-user` and is renewed by removing and adding the user again. An
expired root CRL stops every OpenVPN client, so an offline root needs its CRL renewed in time
(ADR-28).
## ADR-6: The web service and vpn-router-user share one module for users
Status: Accepted
Context: Users are managed from the command line, by an operator at a terminal, and through
`/users` of the web service, by the Router Manager. Both must apply the same rules to names,
passwords and person attributes, and both change the directory and the PKI, whose CRL numbers and
CRLs two changes must not write at the same time. The web unit is sandboxed with
`ProtectSystem=strict`.
Decision: `vpnrouter_users.py` holds the user operations, the checks and a session that takes a
lock and the connection of the user manager account. The web service imports it and maps a
`Failure` to the HTTP status; `vpn-router-user` imports it and adds the argument parsing and
the output, text for people and JSON with `--json`. The web unit allows writing the paths the
operations need: `/run/vpn-router` for the lock, its `RuntimeDirectory`, which systemd creates
before the service starts, also before setup has run, and keeps when it stops, as setup's state
files are there too, and `/etc/swanctl/x509crl`, `/etc/openvpn/server` and
`/var/lib/vpn-router/pki-publish` for the copies of the CRLs a revocation signs; the PKI itself is
in the directory (ADR-32). The PKI module, `vpnrouter_pki.py`, works the same way for `/ca`,
`vpn-router-ca` and `vpn-router-setup`, and its lock is the users' lock, as adding and removing
a user issue and revoke a certificate.
Consequences: The rules exist once. A request starts no process but `swanctl --load-creds` after
a revocation, and a password never leaves the process that received it. The web service imports `python3-ldap` and the package's
configuration module. The API is protected by the tokens of ADR-3, so only members of `admins`
and the holders of an admin token manage users.
## ADR-7: The web service starts on its own and reads its inputs at each request
Status: Accepted
Context: The status page is the tool to see a problem of `vpn-router-setup`, so it must run
whatever state setup is in, also before setup has run and while it fails. The addresses,
`web.listen`, `web.port` and `web.enabled` can change at any time.
Decision: The service depends on nothing to start and does not end on its own. A discovery
thread works out, every 30 seconds, the addresses of the interfaces `web.listen` names from
`vpn-router.json` as `general.mode` directs, with the discovery code in `vpnrouter_net.py`,
which `vpnrouter.py` uses too, and `web.port` and `web.enabled` from the same file. It runs an
HTTP server in a thread of its own at each address and the port, stops one and starts a new one
when they change, and runs none while the service is disabled or no address is known; an address
that cannot be bound is tried again the next round. Each request
reads the signing key and `vpn-router.json`, and the features and resolved facts from
`web.json`, which setup writes when it applies the configuration, so the service shows what setup
applied; while that file is missing the router shows no features. The unit is not
ordered after `vpn-router-setup`, and setup does not restart it.
Consequences: The status page answers as soon as an address exists, whatever state setup is in,
and follows a changed address, `web.listen`, port or `web.enabled` within 30 seconds without a
restart. A new signing key applies to the next request. The interfaces are read every 30
seconds, which costs a few `ip` commands.
## ADR-8: /config leaves secret options out
Status: Accepted
Context: A secret has no use outside the router, so a client never needs it, and showing even
that a secret is set tells a reader more than it needs.
Decision: The configuration that `vpn-router-setup` writes for the web service has no secret
options at all. They are recognised by name: `psk`, `database`, whose connection string may hold
a password, and options with `secret`, `password`, `token` or a `_key` ending in the name. The
names are checked at every level, so the settings of each site-to-site connection lose their
`psk` too; an object is kept whatever its name, so a connection named like a secret is still
shown.
Consequences: No secret reaches the web service's state file or any answer of the API. Through
`/config` a client cannot tell whether a secret is set; `/site-to-site` says whether each
connection has a key (ADR-25), as an admin managing connections needs to know. A new secret
option needs a name that matches.
## ADR-9: The Router Manager is a separate package with a small Python server
Status: Accepted
Context: The router needs a web application to manage it, which may run on the router or on
another Linux system. The router's web service issues tokens through `/auth` (ADR-3) and sends
no CORS headers, so a browser cannot call it directly, and tokens kept in the page could be read
by its scripts.
Decision: `vpn-router-manager` is a Debian package of its own, built in the repository
`linux-cloud-router-manager`. A React and MUI application in the style of manage-ui is built
with Vite when the package is built and shipped as static files. A Python server, using the
Ubuntu packages `python3-requests` and `python3-psycopg`, serves them, signs users in through the
router's `/auth`, keeps their tokens in a server-side session in PostgreSQL behind an
`HttpOnly` cookie, and passes every other `/api/<path>` call to the router's `/<path>` with the
session's access token, refreshing it as needed; it holds no logic of the router's own and no
key of it. It reads its settings from `/etc/vpn-router-manager/manager.json` at each request; on
the router the settings not given come from `vpn-router.json`, and it listens on the internal
address, the external address or both, as its own `listen` sets, like the status web service,
with the same discovery thread. `vpn-router-setup` opens the manager's port on the internal and
the external interface while the manager is installed, so the firewall stays in one place.
Consequences: The router needs no Node.js; the package needs only `python3` and packages of the
Ubuntu archive. Tokens never reach the browser, and sessions survive a restart of the manager.
The router decides what a user may do; the manager only signs in. The application checks the
shape of every answer of the router. The router's firewall depends on whether the manager is
installed, and a changed manager port needs `vpn-router-setup` to run again.
## ADR-10: Users and their certificates do not depend on a P2S system being enabled
Status: Accepted
Context: Users are prepared before a P2S system is switched on, and the Router Manager manages
them on any configured router, with or without one.
Decision: Once a platform is chosen, `vpn-router-setup` always provisions the user directory in
slapd and the PKI (ADR-27) with its CRLs; the router's certificates follow once `wan.local_fqdn`
is set. `/users` and `vpn-router-user` rely only on the directory and the P2S CA. Only the
OpenVPN client profile needs OpenVPN to be enabled.
Consequences: slapd always holds the directory and the CAs exist on a configured router, even
when no P2S system uses them; with an offline root the P2S CA, and so the users' certificates,
wait for its certificate. An operator who wants a root CA of their own must place it before
choosing the platform.
## ADR-11: Groups are groupOfNames entries that never become empty
Status: Accepted
Context: Users are to be organised in groups. Groups record membership only; nothing on the
router reads them. LDAP clients that read groups, such as `openvpn-auth-ldap` and the LDAP
connectors of OIDC providers, expect `groupOfNames` with member DNs, and OpenLDAP's core schema
requires a `groupOfNames` to have at least one `member`.
Decision: A group is `cn=<name>,ou=groups,dc=vpn-router,dc=local`, a `groupOfNames` with the
DNs of its members and an optional `description`. A group is created with at least one member,
and taking out its last member is refused; such a group is removed instead. Removing a user
takes it out of its groups and is refused while the user is the last member of one. The group
operations are in `vpnrouter_users.py` with the user operations, so they share the session and
its lock; `vpn-router-group` is a tool of its own beside `vpn-router-user`, and the web service
serves them under `/groups`. Group names follow the rules of user names.
Consequences: A group always satisfies the schema without a placeholder member, and an empty
group cannot exist. Removing a user or a member can need another step first. A later use of
groups, such as allowing OpenVPN only to some, can read them as they are.
## ADR-12: TOTP secrets are kept with the OATH-LDAP schema of slapd's otp module
Status: Accepted
Context: Users are enrolled for TOTP one-time passwords, and enrolled users must give a code
whenever they log in, to OpenVPN and to `/auth`. Ubuntu's slapd ships the `otp` module, which
holds the OATH-LDAP schema and an overlay that checks a code appended to the password at bind
time; both OpenVPN (`openvpn-auth-ldap`) and `/auth` check passwords with a bind. Ubuntu
packages `python3-pyotp`. An authenticator app is enrolled from a QR code of an `otpauth://` URI.
Decision: The `otp` module is loaded and its overlay is configured on the package's database,
after ppolicy, so every bind of an enrolled user takes the password followed by a current code
and slapd records the time step used. The TOTP token is the user entry itself: enrolment adds `oathTOTPUser` and `oathTOTPToken` to it, with
`oathTOTPToken` naming the entry, the secret in `oathSecret` and `oathTOTPParams` naming one
shared parameter entry, `cn=totp,ou=policies`, with the parameters authenticator apps assume
(6 digits, HMAC-SHA1, 30 seconds). An ACL lets the user manager account set `oathSecret` and
no one read it. Enrolment is confirmed: `otp_new` makes a secret with `pyotp` and returns it
with its URI, storing nothing, and `otp_enrol` stores the secret it is given back only when a
code of it verifies, so a secret is never stored that the user's app does not produce. The
router answers the URI; the client renders the QR code.
Consequences: Removing a user removes its token. One check covers OpenVPN and `/auth`, and
an enrolled user types the code after the password in the OpenVPN client. The secret passes
through the client between `otp_new` and `otp_enrol`, so the router keeps no pending state;
enrolling again replaces the secret only once a code of the new one verifies.
## ADR-13: Members of admins manage the router; only CLI tokens change admins
Status: Accepted
Context: Signing in through `/auth` (ADR-3) is open to every user of the directory, most of
whom only use OpenVPN. Finer permissions per operation are not needed. Somebody must be able to
manage the router before any administrator exists and after every administrator lost access,
and administrators must not be able to grant themselves or others the administrator role over
the API.
Decision: A token naming a user in `sub` may call the protected paths only while the user is a
member of the group `admins`, read at each request; other users get 403. `vpn-router-token`, run
as root on the router, makes tokens with the claim `admin: true` and a lifetime given at
creation, without a maximum, signed with the router's key; `/auth` never issues the claim. An
admin token may call everything, and only an admin token may change `admins`: create it, rename a
group to it, change or remove it, add or take out its members, or remove a user who is a member
of it. The first member is added with `vpn-router-group` on the router, which acts as root.
Consequences: Removing a user from `admins` takes effect at the next request, without waiting
for the user's tokens to expire. Membership of `admins` changes only through root on the router.
An admin token is as powerful as root over the API and is revoked only by replacing the key, so
it is given a short lifetime unless it is meant for automation.
## ADR-14: Failed logins lock the account in slapd; /auth is rate limited per client
Status: Accepted
Context: `/auth` is open to the internal network and checks passwords, so it invites guessing;
OpenVPN checks passwords from the internet. Both check with a bind, so slapd sees every attempt.
ppolicy locks an account after a number of failed binds for a fixed time; it has no growing
lockout. A Router Manager signs all its users in from one address.
Decision: The default password policy locks an account for 15 minutes after 5 failed binds
within 15 minutes; a successful bind clears the count; `vpn-router-user unlock` and
`POST /users/<name>/unlock` end a lockout and clear the count, and leave a disabled user disabled.
`/auth` answers the same 401 for a wrong name, password or code and for a locked or disabled
user. `/auth` and `/auth/refresh` allow 10 calls a minute per client address, counted in memory,
and answer 429 with `Retry-After` beyond that. The client address is the peer, or the last
`X-Forwarded-For` entry for a peer in `web.trusted_proxies`, by default the address the call came
in on, which is the address a manager on the router calls from.
Consequences: One lockout covers OpenVPN and `/auth`, kept by slapd alone. Someone who knows a
user name can lock the user out for 15 minutes at a time. The rate limit restarts with the
service. A manager elsewhere than on the router has its address added to `web.trusted_proxies`,
or its users share one limit.
## ADR-15: Refresh tokens are kept in PostgreSQL
Status: Accepted
Context: An access token lives 5 minutes, and a client stays signed in with a refresh token for
8 hours; refresh tokens must be revocable when a user signs out, is disabled, changes the
password or is removed, and must survive a restart of the service. The Router Manager keeps its
sessions in PostgreSQL too.
Decision: Refresh tokens are random strings, stored as their SHA-256 with the user name and the
expiry in the table `refresh_tokens`, through psycopg 3 (`python3-psycopg`). The database is
`web.database`, or by default the database and role `vpn-router` on the router, which the web
service creates on its first run (ADR-16) with `runuser -u postgres -- psql` and a random password in
`/etc/vpn-router/database.secret`, reached over TCP on `localhost`, as Ubuntu's `pg_hba.conf`
takes peer authentication on the local socket. A refresh token is used once: refreshing deletes
it and issues a new one, after checking that the user is neither disabled nor locked.
Consequences: The package depends on `postgresql` and `python3-psycopg`. A stolen database holds
no usable token. Changing a user's password, disabling, unlocking or removing the user, and
signing out, end the user's sessions at once; an access token already issued lives out its
5 minutes.
## ADR-16: Each service creates what it needs on its first run
Status: Accepted
Context: The package manager configures packages in an order of its own; on a new VM the Router
Manager was configured before the PostgreSQL server, so a database created at installation was
missing. PostgreSQL may also start after the services at boot.
Decision: The web service makes its signing key and creates the refresh tokens' database itself,
and the Router Manager its sessions database; neither `vpn-router-setup` nor a maintainer script
creates them. Each service does it before it listens, retrying every 15 seconds and logging each
failure, then listens. `vpnrouter_auth` turns every failure of the database into
`DatabaseError`, the only database exception the web service and `vpn-router-user` handle.
psycopg (Ubuntu ships only its pure-Python implementation) finds libpq through the dynamic linker
cache, which dpkg refreshes with its deferred `ldconfig` only at the end of an installation, so a
service started during the installation cannot import it yet; a failed import cannot be repeated
in the same process either, as psycopg caches the lookup. The services therefore import psycopg
plainly, and their units wait instead: `ExecStartPre=wait-for-libpq`, a shell script of each
package, waits until `ldconfig -p` lists `libpq.so.5`, within `TimeoutStartSec=5min`. The
maintainer scripts start the units with `systemctl --no-block` (debhelper's start is turned off),
because a start that waited would hold dpkg before its `ldconfig`.
Consequences: The order of installation and of the start of PostgreSQL and of the linker cache
does not matter; a database that cannot be created is retried until the cause is gone, and the
reason is in the service's log. Outside an installation the wait ends at once. The web unit may
write `/etc/vpn-router` for the key and the database password.
## ADR-17: Bootstrap diagnostics run only until the first setup succeeds
Status: Accepted
Context: A new VM is reachable only once its setup has worked, and the reason of a failure must
be readable from the internal network before an SSH session is possible. A service that reports
the setup cannot be one of the services being set up: those import packages that may not load yet
(psycopg could not find libpq before `ldconfig` ran), and they would fail before reporting
anything. Once the router has been set up, SSH is the way to diagnose it. The more a diagnostic
page does, the more ways it has to fail itself.
Decision: Each package ships a bootstrap service of the Python standard library alone, a script
that answers every request with the plain-text output of a fixed list of commands: the setup
state, the services, the failed units, the addresses and the end of the logs. The router's
listens on 17081 and the Router Manager's on 17080, beside the ports of the services, 7081 and
7080, on every address. The units are never enabled and not started at installation;
`vpn-router-setup` starts them while `/var/lib/vpn-router/setup.done` does not exist, and opens
their ports on the internal interface. The first successful run writes that file, closes the ports
and stops them; a failed run leaves them running, so the reason stays readable. Setup records its
state, step and failure in `/run/vpn-router/setup.json` at every step; `vpnrouter.fail` ends with
a `SystemExit` carrying its message, so setup records it.
Consequences: The diagnostics need nothing the setup provides and cannot collide with the real
services. After the first success they never run again, also not after an upgrade or a reboot.
The page shows log lines to whoever reaches the internal network during the first setup.
## ADR-18: Manager-only accounts are a second kind of user; the package can create the first admin
Status: Accepted
Context: The Router Manager needs a first administrator before anyone has added users, as other
packages create a default user from their debconf answers. Such an account, and others that only
manage the router, have no person behind them and need no VPN access. A VPN user is an
`inetOrgPerson`, which requires a last name and a common name, and its certificate needs the
names. The Manager is configured before the router's directory exists during an installation.
Decision: A user is one of two kinds in `ou=users`, fixed when it is added: a VPN user, an
`inetOrgPerson` with the three person attributes and a certificate, or a Manager-only account,
an `account`, `simpleSecurityObject` and `vpnRouterAccount` (ADR-19) with the name, the password,
an optional e-mail address for notifications, and no certificate. `add` with all three person
attributes adds a VPN user and without the first and the last name a Manager-only account; one
name without the rest is refused. The CLI and the API add both kinds; the Manager's dialog
requires the names unless Manager access only is chosen, which offers the e-mail address only.
Answers carry `manager_only`, and `update` changes only the e-mail address of a Manager-only
account. The Manager's package asks `admin_username` and `admin_password`; with both
it writes them to a root-only file, clears the answers, and `vpn-router-setup` creates the account
after it provisions the directory, adds it to `admins` (creating the group when missing) and
removes the file. Without a name nothing is created. `vpnrouter_users` knows only the directory,
so setup can use it before PostgreSQL's driver can be loaded; `web` and `vpn-router-user` revoke
refresh tokens after the operations of `vpnrouter_auth.REVOKING`.
Consequences: A Manager-only account cannot become a VPN user, as LDAP cannot change an entry's
structural class and its password hash cannot be copied; it is removed and added again instead.
It has no certificate, so OpenVPN and road-warrior access refuse it. The administrator's password
is on disk only until setup has run.
## ADR-19: The package has an LDAP schema of its own under a UUID OID
Status: Accepted
Context: A Manager-only account (ADR-18) is an `account`, which allows no `mail`, yet notifications
need an address for it; no standard auxiliary class allows `mail` except `extensibleObject`, which
allows every attribute. Authorization scopes for users of both kinds may follow. An attribute or
class of its own needs an OID, and the package has no registered arc.
Decision: The package defines its schema, `cn=vpn-router,cn=schema,cn=config`, which
`vpnrouter_ldap.provision` adds when missing and keeps as `SCHEMA_ATTRIBUTE_TYPES` and
`SCHEMA_OBJECT_CLASSES`. Its OID is `2.25.98477352595735834532404256531620013001`, made from a
random UUID under the arc 2.25 (ITU-T X.667), which needs no registration; attribute types are
under `.1` and object classes under `.2`. It has the attribute `vpnRouterScope` (multi-valued,
case-insensitive strings) and the auxiliary class `vpnRouterAccount`,
`MAY ( mail $ vpnRouterScope $ displayName $ description )`; `displayName` and `description`, the
standard attributes, are the display name and the description of a service account, which
`account` cannot hold. Manager-only accounts have the class from their creation, so an e-mail
address can be added later without changing their classes; VPN users get it once scopes are used.
`vpnrouter_ldap.provision` replaces the definitions in `cn=config` that differ, so an extended class
reaches an existing directory at the next setup run.
Consequences: The schema check stays strict. New attributes extend `vpnRouterAccount`, and their
OIDs continue the arcs. The scope `service` marks a service account (ADR-23). The OID must never
be reused for anything else.
## ADR-20: Users manage their own account through /account, proving who they are again
Status: Accepted
Context: Users who are not members of `admins` can sign in but get 403 on every path, so they
cannot change their password or enrol an authenticator without an administrator. A session
token alone is a weak proof for such changes: whoever holds an open session could lock the user
out. A wrong current password must not look like an ended session to the Manager, which signs a
user out on 401.
Decision: `/account` serves the user named in the token's `sub`, admin or not: `GET` the user
and whether they are a member of `admins`, `PUT /account/password`, and `POST` and
`PUT /account/otp` to enrol or replace the TOTP enrolment. The routes do not take a user name.
Each change sends the current password and, for an enrolled user, a current code, checked with a
bind as the user like `/auth`, so it counts toward the lockout (ADR-14); a wrong one is 403. A
new password is checked before the bind, so an invalid one does not use up a code. A user cannot
remove their own enrolment; `/users` and `vpn-router-user` do. Tokens of `vpn-router-token` name
no user and get 403 there.
Consequences: slapd's `otp` overlay accepts each code once, so a change right after signing in
needs the next code. Changing the password revokes the user's refresh tokens, so the user's
Manager session ends with its access token. An enrolled user who has lost the authenticator
needs an administrator.
## ADR-21: Caddy from the Ubuntu archive serves HTTPS with a certificate from Let's Encrypt
Status: Accepted
Context: The status web service and the Router Manager are reachable from the internet (ADR-2),
and `/auth` and the Manager's sign-in carry passwords. Both are small servers on the standard
library's `http.server`, which would need certificates, their renewal and a TLS setup of their
own. Browsers trust a public CA, not the router's.
Decision: Caddy, the `caddy` package of the Ubuntu archive, is the HTTPS proxy. While
`https.enabled` is `true` (the default) and `wan.local_fqdn` is set, `vpn-router-setup` writes
`/etc/caddy/vpn-router.Caddyfile`: a site for `wan.local_fqdn` on `https.web_port` (7444) for the
web service and, while the Router Manager is installed, one on `https.manager_port` (7443) for
the manager, each passing calls to the service on `127.0.0.1`. Caddy obtains and renews the
certificate from Let's Encrypt with the HTTP challenge on port 80, which the firewall opens on
the external interface; `auto_https disable_redirects` keeps port 80 to the challenge. The
package's drop-in for `caddy.service` points it at that file and keeps it from starting before
the file exists, so `/etc/caddy/Caddyfile`, a configuration file of the `caddy` package, is left
unchanged. Both services always listen on `127.0.0.1` as well as on the interfaces their
`listen` settings name, and trust `X-Forwarded-For` from it, which Caddy sets from the peer and
never takes from the client.
Consequences: HTTPS needs `wan.local_fqdn` in public DNS pointing at the router and port 80
reachable from the internet; until then Caddy retries and the HTTPS ports do not answer. The
certificate and the ACME account are in `/var/lib/caddy`. Plain HTTP stays as the `listen`
settings set it, so `internal` keeps passwords off the internet. Caddy's admin endpoint listens
on `localhost:2019`, which `caddy reload` uses. Port 80 and the HTTPS ports are not available
to Docker containers while HTTPS is on. The Manager's HTTPS port is a setting of the router, not
of the manager.
## ADR-22: Metrics are kept in PostgreSQL and served in the Prometheus format, pushed with OTLP
Status: Accepted
Context: The router should keep its CPU, memory, disk, network, load and VPN client metrics for
hour, day, month and year views, as Proxmox VE does, and expose them to OpenTelemetry. Ubuntu
noble has no OpenTelemetry Python SDK and no OpenTelemetry Collector package. OTLP itself is
push-only; a collector reads a scraped endpoint in the Prometheus format with its `prometheus`
receiver. The router already runs PostgreSQL for the refresh tokens (ADR-15).
Decision: A service of its own, `vpn-router-metrics`, takes a sample every minute with `psutil`
(`python3-psutil`) and the VPN readers it shares with the web service (`vpnrouter_status`). It
keeps the samples in the router's database, in four detail tables (`metrics_system`,
`metrics_network`, `metrics_vpn`, `metrics_site_to_site`, see ADR-25). Each detail table has hourly, daily, monthly and yearly summary
tables, with the average and the maximum of each value per period, written when the clock passes a
period from the level below with `INSERT ... ON CONFLICT DO NOTHING`, so a stopped service catches
up. Garbage collection runs on start and once a day, not with every write, and deletes the rows
older than the retention of their level, `metrics.keep_*`, PostgreSQL intervals. The service serves
the last sample as `/metrics` in the Prometheus text format with `prometheus_client`
(`python3-prometheus-client`) and pushes it as OTLP/HTTP JSON, built with the standard library, to
`metrics.otlp_endpoint`. The names follow the OpenTelemetry semantic conventions. The web service
answers the views from the tables at `/metrics/history`, protected like `/config`, for the Router
Manager.
Consequences: The metrics live in the database of the refresh tokens and are dropped with it on
purge. A view shows only what its level keeps, so a shorter retention empties the older part of a
view. A month view needs the hourly rows and a year view the monthly rows. `/metrics` has only the
last sample; history is not scraped.
## ADR-23: /metrics takes HTTP Basic authentication of an internal service account the router manages
Status: Accepted
Context: An OpenTelemetry Collector's `prometheus` receiver authenticates with HTTP Basic, not with
the router's tokens, which last minutes (ADR-3). The scraper should not be able to sign in to the
API or the Manager, and its password should be checked and locked out like any other. The account
belongs to the package's metrics, not to the people using the router.
Decision: A service account is a Manager-only account (ADR-18) with `vpnRouterScope` `service`,
which another system signs in with to use a router service. Administrators add service accounts
with `vpn-router-user add --service` and the web service's `/service-accounts`.
`/service-accounts` is a path of its own, apart from `/users`, which does not list or serve them:
a service account has no names, certificate, profile or TOTP, and is not a person an
administrator manages with the users. A service account has a display name and a description
besides its name, so an administrator can tell what each is for. Internal service accounts are
those the router creates and manages for its own services (`vpnrouter_users.internal_accounts`),
with every property, display name and description included, so the system, not an
administrator, owns them; at present that is the metrics service's, which setup names Metrics. `/service-accounts` marks
them as `internal` and only lists them: every other request on one, and putting one in or taking
one out of a group at `/groups`, gets 403, whatever the token. The router changes them through the
directory's user-manager account, not the API, and root may still with `vpn-router-user`. For the
metrics service, `vpn-router-setup` adds the account `metrics.service_account` names, `metrics` by
default, while metrics are enabled, with a random password it writes to
`/etc/vpn-router/metrics.secret` (root only), and sets a new random password, ending a lockout,
when the file is missing or no longer holds the password. `/auth` refuses every service account with the message of a wrong password. `/metrics` accepts HTTP Basic only for that account, and
only when it is a service account; the password is checked with a bind as the account, so the
password policy's lockout applies (ADR-14). TLS is Caddy's, on `metrics.https_port` (ADR-21);
plain HTTP is served on the interfaces `metrics.listen` names, `internal` by default.
Consequences: Every scrape binds to slapd once. The collector's operator copies the password from
the router; deleting the file and running setup changes it. A user who already has the name is not
taken over; setup fails until `metrics.service_account` names another. An account root disabled
stays disabled. A scraper on the external interface over plain HTTP sends the password in the clear, so
`metrics.listen` stays `internal` unless HTTPS is used.
## ADR-24: The Router Manager upgrades the router's packages in a transient unit outside its own
Status: Accepted
Context: An admin should upgrade `vpn-router` and `vpn-router-manager` from the Router Manager. The
upgrade restarts the process that starts it: the manager's postinst restarts
`vpn-router-manager.service`, and the router's restarts `vpn-router-web` and `vpn-router-setup`. A
child of the manager would be stopped with the manager's cgroup in the middle of dpkg, leaving
packages half configured, and the manager's unit cannot run apt at all, since `ProtectSystem=strict`
makes `/usr` and `/var/lib/dpkg` read-only. Ubuntu's `apt-daily` timer updates the package lists
twice a day, so a release newer than its last run is not seen until the next one.
Decision: The manager starts the helper `/usr/lib/vpn-router-manager/upgrade` with `systemd-run` as
the transient unit `vpn-router-manager-upgrade`, of its own cgroup and without a sandbox, and does
not wait for it. The helper:
- finishes an interrupted dpkg run with `dpkg --configure -a`;
- runs `apt-get install --only-upgrade` of the two packages, non-interactive, keeping changed configuration files, with `needrestart` suspended, and waiting for the dpkg lock that `apt-daily` or `unattended-upgrades` may hold;
- records the state and the result in `/var/lib/vpn-router-manager/upgrade.json`, replaced by a rename, and the output in `upgrade.log`.
A restarted manager reads them again. A state of running without an active unit is shown as interrupted. The manager reboots the router the same way, as a transient timer `vpn-router-manager-reboot` 5 seconds later, so the answer reaches the browser first. It checks for updates the same way too: Check update runs `apt-get update` in the transient unit `vpn-router-manager-check`, which touches apt's update stamp when it succeeds, so an admin need not wait for the `apt-daily` timer. Only members of `admins`, as the router's `/account` answers, may start any of them.
Consequences: The fixed unit name allows one upgrade at a time. The page loses the manager and the
router's web service for a while during an upgrade and reads again until they answer. Sessions
survive in PostgreSQL. The manager can upgrade only the system it runs on, so a manager on another
host upgrades that host's packages, not the router's. The operating system's other packages are
left to unattended-upgrades.
## ADR-25: Site-to-site connections are a map of named connections, edited through the API and applied by setup
Status: Accepted
Context: The router had one IPsec site-to-site tunnel, the `remote` section, run as the swanctl
connection `remote-site`, with the local identity mode in `wan` and the local subnets in `local`.
A router often links more than one site, each with its own peer, subnets and key, and an admin
should add them without a shell. The install answers and the Cloud Platform describe one
connection, and the platform rewrites `vpn-router.json` when it rebuilds the VM.
Decision: `site_to_site` maps a connection name to its settings: `addrs`, `id`, `cidrs`,
`local_cidrs`, `local_id_mode`, `psk` and `psk_file`. `remote` and `wan.local_id_mode` are gone,
without a fallback for the old format. The name, a lowercase letter followed by at most 30
lowercase letters, digits and hyphens, names the swanctl connection, its child and its secret
`ike-psk-<name>`, so it is a valid swanctl section name and stays the same while the connection
lives. The install answers and the Cloud Platform write the connection `remote-site`, the first
one, so its name is the one the tunnel always had.
- **Local settings per connection:** each connection has its own `local_cidrs` and
`local_id_mode`, as each peer expects its own traffic selectors and identity. `wan.local_fqdn`
stays the router's one name, used by the `fqdn` mode, resolved once for the `public_ip` mode,
and used by HTTPS, road-warrior and OpenVPN. `local.cidrs` stays the subnets the router
routes, masquerades and offers to road-warrior and OpenVPN clients.
- **One file:** `vpn-router-setup` writes every connection that is ready to
`/etc/swanctl/conf.d/site-to-site.conf`, with the proposals and timers the one tunnel had, and
removes the file when none is. The NAT exemptions pair each connection's `local_cidrs` with its
`cidrs`.
- **Editing:** the status web service serves `/site-to-site` to members of `admins` and admin
tokens, like `/users`. It never returns a key, only whether one is set, and takes a new key only
when one is given. `vpn-config-edit` edits the same map. Both read, check with the validation
of `vpn-router-setup` and write `vpn-router.json` holding one lock, replacing the file in one
step, so neither loses the other's change.
- **Applying:** the web service restarts `vpn-router-setup` without waiting for it, the one
place that renders, loads and opens the firewall, so a change through the API takes the same
path as a change by hand.
- **Metrics per connection:** the metrics service keeps, per connection, whether it is up, the
bytes and packets per second through its child SAs and the seconds its IKE SA has been up, in
a detail table of its own, `metrics_site_to_site`, keyed by the connection's name, with the
summaries and retention of the other tables (ADR-22). One row per connection keeps the values
of a tunnel together and lets the set of connections change without changing the schema;
`metrics_vpn` counts only clients. swanctl's child SA counters start again at a rekey, so the
service keeps rates computed between two samples, treating a lower counter as counted from
zero, and `/metrics` and the OTLP push serve them as gauges rather than counters that would
appear to reset.
Consequences: A configuration with `remote` is refused, so the package and a configuration in
the new format go together. The status shows each connection. A rate across a rekey misses the
traffic of the old child SA since the previous sample. A renamed connection starts a new series.
A connection added through the API lives in
`vpn-router.json` only; the Cloud Platform's rebuild of the VM writes the file again with its
one connection, dropping the others. A change through the API is answered before setup has
applied it, so a configuration that setup cannot apply shows in the setup state, not in the
answer. A connection with a key file keeps it until a key is given through the API, which then
replaces the file.
## ADR-26: Backups are a staged, consistent copy, archived locally with tar and zstd or sent to a Proxmox Backup Server
Status: Accepted
Context: An image backup of the VM, such as Azure Backup, restores the whole disk. It cannot be
exported as a file, read on another system, or restored onto a new VM. The state that matters is
small, but it is spread over several places: `vpn-router.json` and the secrets, the PKI, the
WireGuard keys and peers, the firewall rules, the slapd directory and, optionally, the
PostgreSQL database. Only part of it can be made again: the CA, the password hashes and the TOTP
secrets cannot. The API and the command-line tools change the configuration, the PKI and the
directory while the router runs.
Decision: `vpn-router-backup run`, started by a timer on `backup.schedule`, stages a copy of that
state in one directory and then archives or sends it.
- **Consistency:** the files are copied and the directory is written with `slapcat` while the
configuration lock and the users lock are held, the locks every change of the API and the tools
takes, so a user, their certificate and the CRL are copied together. `slapcat` reads the MDB
database consistently while slapd runs (ADR-4), and `pg_dump` reads a snapshot. Files that
`vpn-router-setup` regenerates are left out.
- **Local target:** a tar archive compressed with zstd, both from the Ubuntu archive, so a local
backup needs nothing else and the archive can be read on any Linux system. A `pxar` archive
would need Proxmox's client and is not compressed.
- **Retention:** local archives are pruned by the rules of Proxmox Backup Server's prune, so the
settings mean the same for both targets and can be checked with Proxmox's prune simulator.
Settings finer than the schedule do not apply.
- **Cloud copy:** rclone, from the Ubuntu archive, copies the archives to any storage it
supports. The remote is defined in `/etc/vpn-router/rclone.conf` with `rclone config`, since
each backend has its own settings. `vpn-router.json` names only the remote, and the
configuration file is part of the backup. `sync` makes retention apply to the remote as well;
`copy` leaves it to the storage's lifecycle rules. Only this host's archives are included, so a
shared remote path is safe.
- **Proxmox Backup Server:** the static `proxmox-backup-client` sends the staged directory as one
`pxar` archive of a host snapshot. Deduplication makes frequent backups cheap. The server's
prune jobs keep the snapshots, so the token needs only `DatastoreBackup`, which cannot delete.
The client is a suggestion, not a dependency, because it comes from Proxmox's repository, which
an Ubuntu system does not know. Without it, only a PBS backup fails.
- **Restore:** only on the command line, as root. It replaces the directory and the database, so
it stops their services, keeps what it replaces and lets `vpn-router-setup` rebuild the rest.
The API offers no restore, so a stolen admin session cannot replace the router's keys.
Consequences: A backup holds every secret of the router, including the keys of the issuing CAs
and the root key while it is on the router, in plain form
unless a PBS key file encrypts it, so where it is kept must be as safe as the router. A restore
onto another VM brings back the old interface names and addresses, which may need changing. The
timer's schedules are systemd's shorthands, so all backups of one schedule start at the same
moment. A change of the users between the end of the copy and the next backup is in no backup
until then.
## ADR-27: A two-level CA with dedicated issuing CAs, whose root may be offline
Status: Accepted
Context: One self-signed CA signed every certificate of the router with the same server and
client purposes, so a certificate for any use was also a VPN client certificate, and the key
that signed everything had to stay on the router. The router needs CAs for the VPN clients, for
site-to-site certificates and for the administrator's own uses, and a root whose key can be kept
elsewhere. The `simple-ca` script handled one level and ran as a separate process with its own
state file.
Decision: The root signs only issuing CAs, path length 1; the issuing CAs, path length 0, sign
the certificates. `p2s` signs the router's VPN server certificate and the users' certificates,
`s2s` the site-to-site certificates, and the CAs of `pki.issuing_cas` what their templates
allow. A template fixes the key, the lifetime, the key usages, the extended key usages, the kind
of common name and the subject alternative names a certificate may have; `vpn-server` and
`vpn-user` are issued by the router only, so no certificate for another use comes from the P2S
CA. The module `vpnrouter_pki.py`, with `python3-cryptography` from the Ubuntu archive, builds
the keys, certificates, CSRs and CRLs in the processes that need them, as the user module does
(ADR-6); the settings are the `pki` section of `vpn-router.json`, and the CAs and the
certificates are kept in the router's directory (ADR-32). Keeping the root key off the router is recommended, not required: while it is
on the router a new issuing CA is signed at once, and while it is not the CA gets its key and a
CSR, signed offline and imported with `vpn-router-ca` or `/ca`. The root key itself crosses
neither the API nor the Manager. For a certificate of something other than the router, the router
signs the requester's CSR, or makes the key and returns it once in a PKCS#12 file, keeping only
the certificate, so a key it gave away cannot leak from it later. The PKCS#12 file uses 3DES and
SHA-1 MACs, the encryption the key stores of Windows and macOS import. strongSwan reads only the
first certificate of a PEM file, so each CA has a file of its own in `/etc/swanctl/x509ca`;
road-warrior and site-to-site name their CA in `cacerts`, which restricts the trust chain to it.
Consequences: The P2S CA's certificates are accepted by road-warrior and OpenVPN only, the site-to-site CA's by
site-to-site only. With an offline root, new issuing CAs and the root CRL need the offline step,
and a router whose P2S CA waits has no VPN server certificate and so no P2S access. The router
keeps the users' keys, as the OpenVPN profile embeds them.
## ADR-28: CA certificates and CRLs are published at configurable endpoints, and CRLs are short-lived
Status: Accepted
Context: Clients outside the router, such as Windows and servers using the general-purpose CAs,
find the issuer's certificate and the CRL through the AIA and CRL distribution point extensions,
and cache a CRL until its next update. The router's CRLs were valid for ten years and published
nowhere. Caddy serves files itself, and the web service already answers on the status port.
Decision: `pki.publish` lists the endpoints, in the order the URLs appear in the certificates:
plain HTTP on the status port, served by the web service; plain HTTP on port 80, HTTPS on the
status HTTPS port and HTTPS on port 443, served by Caddy's `file_server`. The files are DER, as
RFC 5280 asks of these URLs, copied to `/var/lib/vpn-router/pki-publish`, outside the 0700 PKI
directory, so that Caddy's user reads them. Ports 80 and 443 serve only the CA files and are opened
on both interfaces when chosen. The CRLs of the issuing CAs are valid for `pki.crl_days`, a week
by default, signed again at every revocation and by a daily timer once half their validity has
passed; the root CRL has its own `pki.root_crl_days`, half a year by default, so an offline root
needs its CRL signed twice a year, not weekly.
Consequences: A client that caches a CRL sees a revocation within a week. The URLs are fixed in a
certificate when it is signed, so a change of the endpoints or of `wan.local_fqdn` applies to
certificates signed afterwards. HTTPS URLs are offered for clients that cannot use plain HTTP,
although a client checking the revocation of the HTTPS server's own certificate cannot use them;
Caddy's certificate is Let's Encrypt's, so the router's endpoints do not depend on themselves.
## ADR-29: Site-to-site connections authenticate with a pre-shared key or certificates
Status: Accepted
Context: Site-to-site connections used pre-shared keys only. Peers that are routers of this
package, or have a PKI of their own, can authenticate with certificates, which leaves no shared
secret to distribute. Azure's VPN gateway authenticates site-to-site connections with pre-shared
keys only.
Decision: A connection's `auth` is `psk`, the default, or `certificate`. With `certificate` the
router presents one site-to-site certificate of its `s2s` CA, whose names are `wan.local_fqdn` and
the local identities the connections with `certificate` use, the public or the internal address of
their `local_id_mode`, and no address no connection presents, such as an internal address the peers
never see; the peer's
certificate must name the connection's `id` and be issued by the router's `s2s` CA or, when the
connection has `remote_ca`, by that CA, its PEM certificate kept in the connection and copied to
`/etc/swanctl/x509ca/s2s-<name>.pem`. A peer without a PKI gets its certificate from the router.
Consequences: The connection Azure's gateway needs stays `psk`, so the platform writes nothing
new. The router's site-to-site certificate is issued again when the set of names it needs changes,
such as when a connection's `local_id_mode` or its address changes. The
router does not check a remote CA's revocations unless strongSwan can fetch the CRL its
certificates name.
## ADR-30: A general-purpose issuing CA can back an ACME server of Caddy
Status: Accepted
Context: Servers that use a general-purpose CA need their certificates renewed, which ACME clients
automate. Caddy, which the router runs for HTTPS, has an ACME server built on a CA of its `pki`
app. Caddy 2.6.2 of the Ubuntu archive needs the key file of that CA's root, never renews an
intermediate given to it, keeps no CRL and takes only the CA in the `acme_server` directive,
issuing certificates valid for 12 hours. Its ACME handler answers every path under `/acme/`.
Decision: An issuing CA of `pki.issuing_cas` with `acme` makes Caddy serve an ACME server for it
at `/acme/<name>/` of the HTTPS status port, each in a `handle` block of its own. The issuing CA's
certificate and key are given to Caddy as both the root and the intermediate of a `pki` CA, copied
to `/etc/caddy/vpn-router-pki/<name>/` for Caddy's group, so that the router's root key never
reaches Caddy and may be offline.
Consequences: The certificates Caddy issues chain to the router's root through the issuing CA, are
not in the router's inventory, follow no template and are not revoked; their short lifetime takes
the place of revocation. An issuing CA waiting for the offline root has no ACME server.
## ADR-31: vpn-router-setup installs Caddy from the Ubuntu archive or from Caddy's repository
Status: Accepted
Context: Caddy 2.6.2 of the Ubuntu archive lacks later features, such as a pki root without its key
and the settings of the ACME server. Caddy's own repository has current versions of the package of
the same name. A dependency on `caddy` installs the archive's package with `vpn-router`. apt cannot
run inside `vpn-router-setup` when the package's installation runs setup, as dpkg holds its lock.
Decision: `vpn-router` only suggests `caddy`. While HTTPS is on, setup has
`vpn-router-caddy.service` install it from `https.caddy_source`: `ubuntu`, or `upstream`, for
which the unit writes the deb822 source and the key it downloads over HTTPS from Caddy's
repository. The unit waits for dpkg's lock and runs setup again when Caddy is in place.
`vpn-router-caddy use-upstream` switches an installed router. Upstream's versions sort above the
archive's, so once the repository is configured apt keeps upstream's Caddy without a pin, and
`ubuntu` has no effect; nothing downgrades Caddy on its own, as a downgrade may break the
configuration and is the administrator's decision. Only the CLI switches the source; the Router
Manager shows which one is in effect.
Consequences: The first setup run with HTTPS starts the installation and serves HTTPS on the run
that follows it. The key of Caddy's repository is trusted as downloaded over HTTPS, the way Caddy's
installation instructions do it. Going back to the archive's Caddy is a manual downgrade.
## ADR-32: The PKI is kept in the router's directory
Status: Accepted
Context: The CAs, their keys and CRLs, the inventory of the certificates issued and their
revocations, and the router's and the users' certificates and keys need a store. Files with a
JSON state file cannot be searched, and are copied apart from the users the certificates belong
to. The router runs slapd, which holds the users and is provisioned on every
configured router (ADR-10), and PostgreSQL, which holds the refresh tokens and the metrics, may
be another server (`web.database`) and is in a backup only with `backup.metrics`. RFC 4523
defines `pkiCA` with `cACertificate` and `certificateRevocationList`, and `userCertificate`, which
OpenLDAP's core schema includes.
Decision: The PKI is held in `ou=pki` of the router's directory. A CA is an entry of the package's
class `vpnRouterAuthority` with `cACertificate` and `certificateRevocationList`, its key, its CSR,
its CRL number, the source of the root CRL and the time it was retired; a certificate is an entry
of `vpnRouterCertificate` named by its serial number under its CA, with `userCertificate`, its
kind, template, names, expiry and revocation, and the key of a router or user certificate. A
user's certificate is also the `userCertificate` of the user's entry. An ACL lets no account read
`vpnRouterPrivateKey`; only root, the database's root DN, does, through the local socket. A
retired CA keeps its entry, renamed, so its certificates stay in the inventory. A root supplied
from outside, by cloud-init or by hand, is placed as files before the first setup with a platform,
which imports and removes them. strongSwan, OpenVPN, Caddy and the system's trust store read files,
so setup and every revocation write them from the directory. Setup provisions the directory before
the PKI; without the directory it leaves the PKI and its files as they are and configures the rest.
Consequences: `slapcat` writes the users and the PKI into every backup in one consistent copy, and a
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. 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.
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. A user enrolled for TOTP types
the code after the password at the SSH and sudo prompts. 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.
+1286 -54
View File
File diff suppressed because it is too large. Load diff
View File
Whitespace-only changes.
+920
View File
@@ -1,3 +1,923 @@
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.
* pki.extended_key_usages maps a name to the OID of a custom extended
key usage; a template's extended_key_usage takes the built-in and the
custom names.
* The template option policies lists certificate policy OIDs, written
into a Certificate Policies extension.
* GET /ca answers extended_key_usages, each usage with its OID and
whether it is built in.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 14:00:00 +0100
vpn-router (0.21.5-1) unstable; urgency=medium
fix: /status/full of the status web service gives an authenticated client the whole status, whatever web.status_content says.
* /status/full takes the token of /config and answers the status
document as web.status_content full shows it.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 12:00:00 +0100
vpn-router (0.21.4-1) unstable; urgency=medium
fix: vpn-router-setup, vpn-router-ca and /ca read a CA's key and CRL from the directory each as itself, instead of failing on a CA with a CRL.
* A CA's CRL was read as its key and its key as its CRL, so every
operation on a CA with a CRL failed with "Could not deserialize key
data".
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 10:00:00 +0100
vpn-router (0.21.3-1) unstable; urgency=medium
fix: the PKI is kept in ou=pki of the router's directory instead of files under /etc/vpn-router/pki, and the router's site-to-site certificate names only the identities its connections present, and /ca/bundle gives the certificate bundle of a CA.
* The CAs with their certificates, keys, CSRs and CRLs, and every
certificate issued with its revocation, are entries of the
package's schema in ou=pki; a user's certificate is also the
userCertificate of the user's entry, and only root reads a key.
* slapcat puts the PKI into every backup with the users.
* A root CA supplied as /etc/vpn-router/pki/root/cert.pem and key.pem
before the first setup is imported into the directory, and the files
are removed; vpn-router-ca root import-cert is gone.
* Setup provisions the directory before the configuration; without it,
the PKI and its files are left as they are.
* The router's site-to-site certificate names wan.local_fqdn and the
public or internal address a connection with auth certificate uses
as its identity, not every address of the router, and is issued
again when those names change.
* GET /ca/bundle/<name> of the status web service answers the PEM
certificates a client trusts a CA with: the root alone, or an issuing
CA followed by the root.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 01 Nov 2026 10:00:00 +0100
vpn-router (0.21.2-1) unstable; urgency=medium
fix: pki.root_key_warning silences the warning of vpn-router-ca status and /ca about the root key on the router.
* pki.root_key_warning, true by default, set to false silences the
warning that the root key is on the router.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 31 Oct 2026 10:00:00 +0100
vpn-router (0.21.1-1) unstable; urgency=medium
fix: vpn-router-ca status and /ca warn about a CRL the router signs only once vpn-router-pki.timer has not renewed it in time.
* A CRL whose CA key is on the router is reported once past half of its
validity, when the timer should have signed it again; a root CRL
signed offline is still reported 30 days before it expires.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 30 Oct 2026 10:00:00 +0100
vpn-router (0.21.0-1) unstable; urgency=medium
feature: the router has a two-level CA with an optional offline root, issuing CAs for P2S, site-to-site and general use, templates, published certificates and CRLs and an ACME server, site-to-site connections authenticate with certificates, and setup installs Caddy from Ubuntu or Caddy's repository.
* The root CA signs the issuing CAs p2s, which issues the router's VPN
server certificate and the users' certificates, s2s, which issues
the site-to-site certificates, and the general-purpose CAs of
pki.issuing_cas; road-warrior and OpenVPN accept certificates of p2s
only.
* The pki section of vpn-router.json sets the names, lifetimes and
key type of the CAs, the validity of the CRLs, where certificates
and CRLs are published, the general-purpose CAs and the certificate
templates; p2s.ca_name and the vpn-router/p2s_ca_name question are
replaced by pki.root_name and vpn-router/pki_root_name.
* Without the root key on the router, new issuing CAs wait with a CSR
for an offline signature; vpn-router-ca exports, removes and imports
the root key, imports the signed certificates and a root CRL, issues
certificates from a CSR or into a PFX file, lists and revokes them.
* Certificates name their issuer's certificate and CRL at the
endpoints of pki.publish: the status port over HTTP, or Caddy on port
80, on the HTTPS status port or on port 443; the CRLs are signed
again at every revocation and by vpn-router-pki.timer.
* A general-purpose CA with acme backs an ACME server of Caddy.
* The router renews its own certificates before they expire.
* A site-to-site connection with auth certificate authenticates with
the router's site-to-site certificate and trusts the s2s CA or the
connection's remote_ca.
* /ca of the status web service shows and changes the PKI; /pki/
serves the published certificates and CRLs; /config shows the
installed Caddy and its source; request bodies may be 64 KiB.
* vpn-router no longer depends on caddy: vpn-router-setup installs it
from https.caddy_source, the Ubuntu archive or Caddy's repository,
and vpn-router-caddy use-upstream switches an installed router to
Caddy's repository for good.
* The package depends on python3-cryptography instead of openssl, and
simple-ca is gone.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Thu, 29 Oct 2026 10:00:00 +0100
vpn-router (0.20.0-1) unstable; urgency=medium
feature: the router makes scheduled backups to a local folder, copied to the cloud with rclone, or to a Proxmox Backup Server, and vpn-router-backup restores them.
* The backup section of vpn-router.json switches on backups and sets
the schedule (hourly, daily or weekly), the target (local or pbs),
the folder, the retention of local archives, whether the metrics
database is included, the rclone remote and mode, and the Proxmox
Backup Server repository, token secret, fingerprint, namespace and
key file.
* A backup holds /etc/vpn-router, /etc/wireguard, the ufw rules, the
Router Manager's settings, PSK files outside /etc/vpn-router, the
user directory as slapcat writes it and, optionally, the database as
pg_dump writes it, copied while the configuration and the users are
locked.
* Local backups are tar archives compressed with zstd. Archives the
retention settings no longer keep are deleted by the rules of Proxmox
Backup Server's prune, and rclone can sync or copy the archives to a
remote of /etc/vpn-router/rclone.conf.
* Backups to a Proxmox Backup Server are made with
proxmox-backup-client-static, which the package suggests; the server
keeps them.
* vpn-router-backup runs a backup, lists the backups and restores a
local archive or a snapshot; vpn-router-backup.timer runs it on the
schedule.
* /backup of the status web service shows and changes the backup
settings, shows the last run, the local archives and the next run,
and starts a backup.
* The package depends on zstd and suggests rclone and
proxmox-backup-client-static.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Wed, 28 Oct 2026 10:00:00 +0100
vpn-router (0.19.1-3) unstable; urgency=medium
update: ADR-24 names Ubuntu's apt-daily timer, not a cron job, as what updates the package lists.
* ADR-24 says that Ubuntu's apt-daily timer updates the package lists
twice a day, and that apt-daily or unattended-upgrades may hold the
dpkg lock the upgrade waits for.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 10:00:00 +0100
vpn-router (0.19.1-2) unstable; urgency=medium
update: ADR-24 says that the Router Manager checks for updates on demand.
* ADR-24 describes Check update of the Router Manager, which updates
apt's package lists in the transient unit vpn-router-manager-check.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 09:00:00 +0100
vpn-router (0.19.1-1) unstable; urgency=medium
fix: /metrics/history gives the availability of each site-to-site connection as the percentage of time it was up in slots of the view.
* site_to_site_up of /metrics/history divides the view into slots of 5
minutes for an hour, 1 hour for a day, 1 day for a month and 1 month
for a year, and gives for each slot and connection the percentage of
the samples in which the connection was up.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 21:00:00 +0100
vpn-router (0.19.0-1) unstable; urgency=medium
feature: service accounts have a display name and a description, which the router sets for its internal ones.
* vpnRouterAccount of the package's schema also takes displayName and
description; vpn-router-setup updates the schema of an existing
directory.
* /service-accounts shows display_name and description, takes them when
adding a service account and changes them with PUT; "" removes one.
/users shows them as null.
* vpn-router-user add --service and update take --display-name and
--description.
* vpn-router-setup gives the metrics service's internal account the
display name Metrics and its description, and sets them again when
they differ.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 20:00:00 +0100
vpn-router (0.18.3-1) unstable; urgency=medium
fix: internal service accounts are the router's own, of which the metrics service's is the one at present, and the documentation says what a service account is.
* /service-accounts, /groups and the internal field of the answers
take every internal service account, the accounts the router creates
and manages for its own services, instead of only the metrics
service's; the 403 answers say that the router manages them.
* The README, the ADR and the users module describe a service account
as the account another system, such as an OpenTelemetry Collector
reading /metrics, signs in with, and the internal ones as managed by
the router.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 19:00:00 +0100
vpn-router (0.18.2-1) unstable; urgency=medium
fix: service accounts are managed at /service-accounts, not /users, and the internal one of the metrics service is only listed.
* /service-accounts, for members of admins and admin tokens, lists the
service accounts, adds one with a password and an optional e-mail
address, changes the e-mail address, sets the password, disables,
enables, unlocks and removes them.
* /users no longer lists service accounts or takes "service" on POST,
and answers 404 to a request naming one.
* /service-accounts answers 403 to every request but the list on the
account metrics.service_account names while metrics are enabled, and
/groups to putting it in or taking it out of a group;
vpn-router-setup and root's vpn-router-user still change it.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 18:00:00 +0100
vpn-router (0.18.1-1) unstable; urgency=medium
fix: vpn-router-setup owns only the creation of the metrics service's account, which is marked internal, and administrators add service accounts with vpn-router-user add --service and /users.
* POST /users takes "service": true and adds a service account, which
cannot sign in to /auth; vpn-router-user add takes --service.
* The users of /users and vpn-router-user have internal, true for the
account metrics.service_account names while metrics are enabled;
vpn-router-user list shows its kind as internal.
* PUT /users/<name>/password refuses the internal service account,
whose password vpn-router-setup keeps.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 17:00:00 +0100
vpn-router (0.18.0-2) unstable; urgency=medium
update: release.py tags a Debian revision other than 1 as x.y.z-n.
* release.py tags the newest entry x.y.z for the Debian revision 1 and
x.y.z-n for a later revision, so a packaging change gets a release
of its own.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 14:00:00 +0100
vpn-router (0.18.0-1) unstable; urgency=medium
feature: site_to_site holds any number of named site-to-site connections, which /site-to-site lists, adds, changes and removes.
* site_to_site maps a connection name to addrs, id, cidrs, local_cidrs,
local_id_mode, psk and psk_file; it replaces remote and
wan.local_id_mode, and a configuration with them is refused.
* The install answers fill the connection remote-site; the local subnets
fill both its local_cidrs and local.cidrs.
* Every ready connection is written to
/etc/swanctl/conf.d/site-to-site.conf as the swanctl connection and
child of its name with the secret ike-psk-<name>; remote-site.conf is
no longer written.
* Traffic from each connection's local_cidrs to its cidrs is not
masqueraded.
* /site-to-site, for members of admins and admin tokens, lists the
connections without their keys, adds, changes and removes them, and
restarts vpn-router-setup to apply the change.
* vpn-config-edit sets site_to_site.NAME.option, creating the
connection, and removes site_to_site.NAME; it and the web service
write vpn-router.json under one lock.
* The status shows site_to_site.connections, whether each connection is
established.
* The metrics service keeps, per site-to-site connection, whether it is
up, the bytes and packets per second received and sent, and the
seconds its IKE SA has been up, in metrics_site_to_site and its
summaries; /metrics/history has them in site_to_site of each point.
* /metrics serves vpn_router_site_to_site_up,
vpn_router_site_to_site_uptime_seconds,
vpn_router_site_to_site_io_bytes_per_second and
vpn_router_site_to_site_packets_per_second by connection, and the
OTLP push the vpn_router.site_to_site gauges; vpn_router_clients and
metrics_vpn no longer have the kind site_to_site.
* /config shows each connection in network.site_to_site, and no psk at
any level of the configuration.
* Removing the package terminates every site-to-site connection; purging
removes site-to-site.conf.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 23:00:00 +0100
vpn-router (0.17.0-1) unstable; urgency=medium
feature: /config shows the router's PostgreSQL clusters and their settings, and /postgresql their databases with their sizes.
* /config has a postgresql section: each cluster of pg_lsclusters with
its version, name, port, status and data directory, and the main
settings of each online cluster.
* /postgresql, protected like /config, lists the databases of each
online cluster with owner, encoding and size, and the total size.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 21:00:00 +0200
vpn-router (0.16.0-1) unstable; urgency=medium
feature: the router keeps its metrics in PostgreSQL, serves them for an OpenTelemetry Collector and pushes them over OTLP.
* vpn-router-metrics takes a sample every minute: CPU, memory, free
space of /, bytes per second on the external and the internal
interface, the load averages, and the VPN clients of each enabled
system.
* The samples are kept in metrics_system, metrics_network and
metrics_vpn, and summarized hourly, daily, monthly and yearly in
their _hourly, _daily, _monthly and _yearly tables; once a day the
rows older than metrics.keep_details, keep_hourly, keep_daily,
keep_monthly and keep_yearly are deleted.
* /metrics serves the last sample in the Prometheus format on the
interfaces metrics.listen names (internal by default) at
metrics.port, and over HTTPS by Caddy at metrics.https_port, with
HTTP Basic authentication of the service account
metrics.service_account names (metrics by default).
* metrics.otlp_endpoint pushes every sample as OTLP/HTTP JSON, with
metrics.otlp_username and metrics.otlp_password.
* /metrics/history of the web service answers the hour, day, month
and year views for the Router Manager.
* vpn-router-setup adds and keeps that service account, which cannot
sign in to /auth, with a random password in
/etc/vpn-router/metrics.secret, set anew when the file is missing.
* Depends on python3-psutil and python3-prometheus-client.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 19:00:00 +0200
vpn-router (0.15.0-1) unstable; urgency=medium
feature: vpn-config-edit shows and changes the router's configuration.
* vpn-config-edit show prints /etc/vpn-router/vpn-router.json as
indented JSON.
* vpn-config-edit set SECTION.OPTION VALUE converts the value to the
setting's type, checks the whole configuration as vpn-router-setup
does, writes the file indented with mode 0600, and reminds that
vpn-router-setup applies it.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 18:00:00 +0200
vpn-router (0.14.1-1) unstable; urgency=medium
fix: web.status_content is basic by default.
* Without web.status_content, or with a value that is not allowed, /
and /status show only the overall status, the time and the Client
IP; set it to standard or full to show more.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 17:00:00 +0200
vpn-router (0.14.0-1) unstable; urgency=medium
feature: the status page shows the Client IP, and web.status_content sets how much it shows.
* / and /status show the Client IP, the address the request came from:
the last X-Forwarded-For entry for a request from a trusted proxy, so
behind Caddy or the Router Manager the browser's; /status has it as
client_ip.
* web.status_content sets what / and /status show: basic the overall
status, the time and the Client IP; standard also the package
versions and the number of active and failed services; full, the
default, everything as before. /status names it in content.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 16:00:00 +0200
vpn-router (0.13.0-1) unstable; urgency=medium
feature: Caddy serves the status page and the Router Manager over HTTPS with a certificate from Let's Encrypt.
* The package depends on caddy. While https.enabled is true, the
default, and wan.local_fqdn is set, vpn-router-setup writes
/etc/caddy/vpn-router.Caddyfile and runs Caddy, which serves the
status page on https.web_port (7444) and the Router Manager, when it
is installed, on https.manager_port (7443) for wan.local_fqdn, and
obtains the certificate from Let's Encrypt with the HTTP challenge.
* The package's drop-in for caddy.service makes Caddy read that file
and keeps it from starting before the file exists. The file belongs
to root and the group caddy, which Caddy runs as, with mode 0640.
* The firewall opens port 80 on the external interface and the HTTPS
ports on both interfaces while HTTPS is on.
* The web service also listens on 127.0.0.1, where Caddy calls it.
* docker.exposed_ports may not take port 80 or the HTTPS ports while
HTTPS is on.
* Removing the package stops and disables caddy; purging it removes
/etc/caddy/vpn-router.Caddyfile.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 15:00:00 +0200
vpn-router (0.12.3-1) unstable; urgency=medium
fix: the web service listens on the internal address, the external address or both.
* web.listen in vpn-router.json sets the interfaces the web service
listens on: internal, external or both, the default. It is read
every 30 seconds, so a change takes effect without running setup,
at the latest when vpn-router-web restarts.
* The firewall opens the ports of the web service and of the Router
Manager on the internal and the external interface.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 13:00:00 +0200
vpn-router (0.12.2-1) unstable; urgency=medium
fix: the web service manages users also when it started before setup.
* vpn-router-web creates /run/vpn-router when it starts, so the user
operations take their lock there; started before vpn-router-setup had
made the directory, they failed with "Read-only file system".
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 12:00:00 +0200
vpn-router (0.12.1-1) unstable; urgency=medium
fix: the status page shows whether the Router Manager runs.
* When vpn-router-manager is installed, the services show the state of
vpn-router-manager, and status is degraded while it is not active.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 11:00:00 +0200
vpn-router (0.12.0-1) unstable; urgency=medium
feature: every user manages their own account through /account.
* /account serves the signed-in user, admin or not: GET shows the user
and whether they are a member of admins; PUT /account/password sets
the password and POST and PUT /account/otp enrol or replace the TOTP
enrolment, given the current password and, when enrolled, a current
code. A wrong one is 403 and counts as a failed login. Tokens of
vpn-router-token get 403 there.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 10:00:00 +0200
vpn-router (0.11.0-1) unstable; urgency=medium
feature: the status page shows the Router Manager's version.
* / and /status show the installed version of vpn-router-manager, as
manager_version in JSON, or that it is not installed.
* The services show the state of each PostgreSQL cluster, as
postgresql@<version>-<cluster>, or postgresql: no cluster without one.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 09:00:00 +0200
vpn-router (0.10.0-1) unstable; urgency=medium
feature: Manager-only accounts, and the Router Manager's first administrator.
* vpn-router-user add without the person options, and POST /users without
them, add a Manager-only account: a name and a password, no names, no
e-mail address and no certificate, so no VPN access. Giving only some of
the three is refused. The answers show the kind as manager_only, list
shows a KIND column, and update refuses a Manager-only account.
* vpn-router-setup creates the administrator the Router Manager's package
leaves in /etc/vpn-router-manager/admin.json as a Manager-only account in
the group admins, which it creates when missing, and removes the file.
* The directory has the package's own schema, with the auxiliary class
vpnRouterAccount and the attribute vpnRouterScope. A Manager-only account
can have an e-mail address: add --email, update --email ('' removes it),
and email in POST and PUT /users.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 08:00:00 +0200
vpn-router (0.9.2-1) unstable; urgency=medium
fix: the web service starts once libpq can be found.
* The web service unit waits, with wait-for-libpq, until libpq is in the
linker cache before it starts the service, for at most 5 minutes; the
service imports psycopg at start again.
* The package starts the web service without waiting for it.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 06:00:00 +0200
vpn-router (0.9.1-1) unstable; urgency=medium
fix: the web service waits for the PostgreSQL driver instead of failing at start.
* The web service imports psycopg as the first step of its
initialization, retrying every 15 seconds and logging the failure while
libpq cannot be found yet during an installation.
* vpn-router-user and vpn-router-token load without psycopg; a command
that needs the token database then reports that the driver cannot be
loaded, as it reports every failure of that database.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 05:00:00 +0200
vpn-router (0.9.0-1) unstable; urgency=medium
feature: bootstrap diagnostics on port 17081 during the first setup.
* vpn-router-bootstrap serves, on port 17081, the setup state, the
services, the failed units, the addresses, cloud-init status and the end
of the logs of setup and the web service, as plain text.
* vpn-router-setup starts it, and the Router Manager's bootstrap on port
17080, until a run has succeeded, and opens both ports on the internal
interface; the first successful run writes
/var/lib/vpn-router/setup.done, closes the ports and stops them. A
failed run leaves them running.
* The web service no longer has a starting phase: it creates its key and
database, then listens.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 04:00:00 +0200
vpn-router (0.8.0-1) unstable; urgency=medium
feature: the web service creates its key and database itself and shows its starting phase.
* vpn-router-web makes its signing key and creates the refresh tokens'
database on its first run, retrying every 15 seconds until PostgreSQL
answers; vpn-router-setup no longer creates them.
* Until then / and /status show the web service's phase and its failure,
with the state, the step and the failure of the last run of
vpn-router-setup, and every page answers 503.
* vpn-router-setup records its state, current step and failure in
/run/vpn-router/setup.json.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 03:00:00 +0200
vpn-router (0.7.1-1) unstable; urgency=medium
fix: a failing user directory or token database no longer leaves the tunnels down.
* vpn-router-setup brings up strongSwan and WireGuard before it provisions
the user directory and the token database. A failure of either no
longer stops the other steps; setup reports it and fails at the end.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 02:00:00 +0200
vpn-router (0.7.0-1) unstable; urgency=medium
feature: users sign in to the web service, and only members of admins manage the router.
* POST /auth signs a user in with the password, and the TOTP code for an
enrolled user, and answers a 5 minute access token and an 8 hour refresh
token; /auth/refresh renews them and /auth/logout revokes the refresh
token.
* /config, /users and /groups take tokens of users in the group admins,
and tokens of the new vpn-router-token, which has an admin claim and a
chosen lifetime. Only these may change the group admins.
* The tokens are signed with /etc/vpn-router/web.key, which
vpn-router-setup makes on the router; web.jwt_secret is ignored.
* Five failed logins lock an account for 15 minutes, for OpenVPN and
/auth. vpn-router-user unlock and POST /users/<name>/unlock end the
lockout, and the users show locked.
* /auth takes at most 10 calls a minute per client address; for calls from
web.trusted_proxies the address is read from X-Forwarded-For.
* Users enrolled for TOTP log in to OpenVPN with the password followed by
the code.
* Refresh tokens are kept in PostgreSQL, in the database web.database
names or in the vpn-router database vpn-router-setup creates. The
package depends on postgresql and python3-psycopg.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 01:00:00 +0200
vpn-router (0.6.0-1) unstable; urgency=medium
feature: users can be enrolled for TOTP one-time passwords.
* vpn-router-user otp-new makes a TOTP secret and its otpauth URI,
otp-enrol stores the secret, read from standard input, given a code of
it, and otp-remove removes it. list and every answer show whether a user
is enrolled, as otp.
* The web service enrols with POST, PUT and DELETE /users/<name>/otp.
* The secret is kept in the user's entry with the OATH-LDAP schema of
slapd's otp module; only the user manager account may set it, and no
one can read it. Logins do not ask for the codes.
* The package depends on python3-pyotp.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 23:30:00 +0200
vpn-router (0.5.9-1) unstable; urgency=medium
fix: package upgrades no longer ask the configuration questions.
* The configuration questions are asked only on a fresh installation and
by dpkg-reconfigure.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 21:00:00 +0200
vpn-router (0.5.8-1) unstable; urgency=medium
fix: an upgrade keeps the configuration and asks nothing.
* postinst passes on only the debconf answers that were given. The
defaults of questions never asked, such as platform none, no longer
replace settings of vpn-router.json.
* Without an answer that changes it, the configuration ucf is given is
vpn-router.json byte for byte, so an upgrade has no conflict to ask
about; settings the file leaves out keep their defaults when it is read.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 20:00:00 +0200
vpn-router (0.5.7-1) unstable; urgency=medium
feature: users have a first name, a last name and an e-mail address.
* vpn-router-user add needs --first-name, --last-name and --email, kept as
givenName, sn and mail, with cn set to the first and the last name.
vpn-router-user update changes them, and list and every answer show
them as first_name, last_name and email.
* POST /users needs first_name, last_name and email, and PUT
/users/<name> changes them.
* vpn-router-user answers in text: a table of the users, the client
profile or a short message, with failures on standard error. --json
gives the JSON answers. -h and --help show the commands and their
options.
* The web service manages users with vpnrouter_users, the module
vpn-router-user uses, instead of running vpn-router-user for each
request.
* Groups of users, kept as groupOfNames entries in ou=groups with a
description and at least one member. vpn-router-group lists, adds,
renames, describes and removes them and adds and takes out members, and
the web service serves the same under /groups. Removing a user takes it
out of its groups and is refused while it is the last member of one.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 19:00:00 +0200
vpn-router (0.5.6-1) unstable; urgency=medium
update: the status web service listens on port 7081 by default.
* web.port defaults to 7081.
* vpn-router-setup opens the Router Manager's port, 7080 by default, on
the internal interface.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 18:00:00 +0200
vpn-router (0.5.5-1) unstable; urgency=medium
feature: users and their certificates no longer need a P2S system enabled.
* vpn-router-setup provisions the user directory, the CA and its CRL once
a platform is chosen, whether or not OpenVPN or road-warrior access is
enabled. The server certificate is issued once wan.local_fqdn is set,
also for an existing CA that has none.
* /users and vpn-router-user manage users and issue and revoke their
certificates without a P2S system; only the OpenVPN client profile
needs OpenVPN.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 16:00:00 +0200
vpn-router (0.5.4-1) unstable; urgency=medium
feature: the firewall lets the Router Manager in on the internal interface.
* While vpn-router-manager is installed, vpn-router-setup opens its port,
port in /etc/vpn-router-manager/manager.json or 8090, on the internal
interface. Purging the package removes the rule.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 13:00:00 +0200
vpn-router (0.5.3-1) unstable; urgency=medium
fix: the status always shows the state of slapd.
* /status and the status page show the state of slapd whether or not
OpenVPN or road-warrior access is configured, since the package always
runs it.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 12:00:00 +0200
vpn-router (0.5.2-1) unstable; urgency=medium
fix: /config no longer shows secret options.
* The configuration shown by /config leaves out the secret options (psk,
jwt_secret, and options named like a password, token or key) instead of
showing them masked.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 11:00:00 +0200
vpn-router (0.5.1-1) unstable; urgency=medium
fix: the web service works in every state of the router.
* vpn-router-web works whether /etc/vpn-router/pki and
/etc/swanctl/x509crl exist or not; they exist only while OpenVPN or
road-warrior access is configured. The paths the unit may write are
used when they exist and ignored when they do not.
* vpn-router-web starts on its own and keeps running. Every 30 seconds a
discovery thread works out the internal address from vpn-router.json as
general.mode directs, with web.port and web.enabled, and starts, moves
or stops the server thread to match. Each request reads the token
secret, the features and the facts it shows. The service is no longer
ordered after vpn-router-setup, which no longer restarts it, and
web.json no longer holds the address, the port or the secret.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 10:00:00 +0200
vpn-router (0.5.0-1) unstable; urgency=medium
* OpenVPN P2S access, set in the openvpn section (enabled, address_pool,
port, protocol). Clients need a certificate of the router CA and the
password of the user named in it; the password is checked against a
local LDAP directory by openvpn-auth-ldap.
* The users live in the package's own slapd database, dc=vpn-router,
dc=local, which vpn-router-setup provisions while OpenVPN or road-warrior
access is configured. slapd listens on its local socket only. Passwords
are stored as ARGON2 hashes, and a disabled user is locked with the
password policy.
* vpn-router-user lists, adds, disables, enables and removes users, sets
passwords and gives OpenVPN client profiles. Adding a user also issues
the user's client certificate; removing one revokes it.
* The web service manages users under /users, with the same token as
/config, and /status shows the OpenVPN clients.
* The CA gets a certificate revocation list when it is created, and an
existing CA without one gets one.
* Purging the package removes the firewall rules of the web service, which
it left behind before.
* The status page follows the browser's light or dark colour scheme,
uses a proportional font and reloads itself every minute.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 23 Oct 2026 23:00:00 +0200
vpn-router (0.4.3-1) unstable; urgency=medium
* swanctl output with more closing than opening lines is read as not
available instead of failing the request.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 23 Oct 2026 22:00:00 +0200
vpn-router (0.4.2-1) unstable; urgency=medium
* /config gives the IPsec connections and SAs as objects, read from
swanctl --pretty, and the routes as objects from ip -j, instead of
lines of text. The site-to-site and road-warrior state of /status is
counted from the same swanctl objects.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 23 Oct 2026 21:30:00 +0200
vpn-router (0.4.1-1) unstable; urgency=medium
* Fix the start of vpn-router-setup hanging on a host that runs Docker.
vpn-router-setup restarted vpn-router-docker and waited for it, but that
unit was ordered after vpn-router-setup, so each waited for the other,
the installation of the package never finished and vpn-router-web was
never enabled or started. vpn-router-docker is no longer ordered after
vpn-router-setup, so vpn-router-setup still waits for it and the Docker
rules are in place when it ends. The restart of vpn-router-web is queued
without waiting.
* Start vpn-router-setup after docker.service, so that Docker is up when
the setup looks for it and does not start in parallel with it. The
ordering applies only when Docker is installed.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 23 Oct 2026 20:10:00 +0200
vpn-router (0.4.0-1) unstable; urgency=medium
* Add the vpn-router-web service, a small status web service that uses only
the Python standard library. It listens on the internal address and on
web.port, 8080 by default. / shows the state of the router for people,
as plain text for curl and wget and as a Tailwind CSS page for a
browser, and /status shows the same as JSON: the state of the services,
the package version, whether Docker is installed, and the state of
site-to-site, road-warrior and WireGuard, with no configuration data.
* Add the web configuration section. web.enabled (true by default) and
web.port. vpn-router-setup opens the port on the internal interface in
UFW and restarts the service.
* Add /config to the status web service, for a client such as manage-ui. It
shows the configuration with its secrets masked, the interfaces,
addresses, identities and routes the router resolved, the IPsec
connections and SAs, the WireGuard peers (no keys) and the Docker rules.
It needs a JWT signed with HS256 by web.jwt_secret, with an exp claim
within ten minutes, and is not served while web.jwt_secret is not set.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 23 Oct 2026 19:00:00 +0200
vpn-router (0.3.2-1) unstable; urgency=medium
* Accept an IPv4 or IPv6 address as remote.id and as wan.local_fqdn. The
identities are written to the strongSwan configuration as given, without
an @ added, and strongSwan reads an address as an IP identity and a name
as an FQDN. A value that starts with @ is refused with an error, since
strongSwan 5.0 and later need no @ to avoid resolving a name.
Road-warrior access still needs a name in wan.local_fqdn.
* Depend on strongswan-swanctl and charon-systemd 5.0 or later.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 23 Oct 2026 18:00:00 +0200
vpn-router (0.3.1-1) unstable; urgency=medium
* Ignore virtual interfaces when mode auto looks for the internal
interface. The Docker bridge docker0, which has an address, was counted
as a second internal interface, so vpn-router-setup and
vpn-router-docker failed on a host with Docker installed.
-- Sławomir Koszewski <slawek@koszewscy.waw.pl> Fri, 23 Oct 2026 13:44:00 +0200
vpn-router (0.3.0-1) unstable; urgency=medium
* Secure the ports Docker publishes. Rules in a chain of their own,
VPN-ROUTER-DOCKER, entered by a jump at the top of DOCKER-USER, accept
IPsec protected traffic, TCP to docker.exposed_ports and established
connections from the external interface and drop the rest of it. Other
rules in DOCKER-USER are left alone. The rules cover IPv4 only.
* Add the docker configuration section. docker.mode is auto (the default,
secures Docker when docker.service is installed), on (requires Docker) or
off (removes the rules); docker.exposed_ports lists the TCP ports and port
ranges reachable from the external interface, none by default.
* Add the vpn-router-docker service, which applies the Docker rules after
docker.service starts and removes them when it stops. vpn-router-setup
restarts it while Docker runs, so a changed configuration takes effect.
* Installing Docker before this package is recommended. When Docker is
installed later, restart vpn-router-setup to apply the rules.
-- Sławomir Koszewski <slawek@koszewscy.waw.pl> Fri, 23 Oct 2026 12:44:45 +0200
vpn-router (0.2.0-1) unstable; urgency=medium
* Change the configuration file to JSON, /etc/vpn-router/vpn-router.json.
+7 -1
View File
@@ -2,6 +2,12 @@
set -e
. /usr/share/debconf/confmodule
# An upgrade (configure with the old version) asks nothing; vpn-router.json holds the
# configuration, and dpkg-reconfigure asks the questions again.
if [ "$1" = "configure" ] && [ -n "$2" ]; then
exit 0
fi
# Every question has a default, so a non-interactive install with nothing
# preseeded completes without prompting. The defaults are platform "none" and
# mode "manual", which together mean: install the files, configure nothing.
@@ -45,6 +51,7 @@ db_input high vpn-router/remote_addrs || true
db_input high vpn-router/remote_id || true
db_input high vpn-router/remote_cidrs || true
db_input high vpn-router/psk || true
db_input high vpn-router/pki_root_name || true
db_input high vpn-router/p2s_enabled || true
db_input high vpn-router/wg_enabled || true
db_go || true
@@ -52,7 +59,6 @@ db_go || true
db_get vpn-router/p2s_enabled
if [ "$RET" = "true" ]; then
db_input high vpn-router/p2s_address_pool || true
db_input high vpn-router/p2s_ca_name || true
db_go || true
fi
+34 -8
View File
@@ -9,24 +9,50 @@ Rules-Requires-Root: no
Package: vpn-router
Architecture: all
Depends: ${misc:Depends},
strongswan-swanctl,
charon-systemd,
strongswan-swanctl (>= 5.0),
charon-systemd (>= 5.0),
libstrongswan-extra-plugins,
libcharon-extra-plugins,
wireguard-tools,
ufw,
debconf,
ucf,
openssl,
python3,
python3-cryptography,
python3-jinja2,
python3-ldap,
python3-pyotp,
python3-psycopg,
python3-psutil,
python3-prometheus-client,
postgresql,
slapd,
nslcd,
libnss-ldapd,
libpam-ldapd,
sudo,
openvpn,
openvpn-auth-ldap,
iproute2,
systemd-resolved
Description: Linux VPN router with IPSec, road-warrior and WireGuard support
systemd-resolved,
zstd
Suggests: caddy,
rclone,
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
IKEv2 IPSec (strongSwan swanctl), optional road-warrior access using EAP-TLS,
optional WireGuard, and the routing, NAT, MSS clamping and firewall rules a
router needs. Nothing in the package is specific to a cloud provider.
IKEv2 IPSec (strongSwan swanctl) with pre-shared keys or certificates,
optional road-warrior access using EAP-TLS, optional OpenVPN with a
certificate and a password per user, kept in a local LDAP directory, optional
WireGuard, and the routing, NAT, MSS clamping and firewall rules a router
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
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
provider.
.
Settings live in /etc/vpn-router/vpn-router.json, which is created on first
install and owned by the administrator afterwards. Editing it and restarting
+24 -1
View File
@@ -1,9 +1,32 @@
src/etc/sysctl.d/99-vpn-router.conf etc/sysctl.d/
src/usr/lib/vpn-router/vpnrouter.py usr/lib/vpn-router/
src/usr/lib/vpn-router/simple-ca usr/lib/vpn-router/
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_auth.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/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/
src/usr/sbin/vpn-router-group usr/sbin/
src/usr/sbin/vpn-router-token usr/sbin/
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/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/
src/usr/lib/vpn-router/web usr/lib/vpn-router/
src/usr/lib/vpn-router/metrics usr/lib/vpn-router/
src/usr/lib/vpn-router/bootstrap usr/lib/vpn-router/
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/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/
+37 -20
View File
@@ -2,28 +2,39 @@
set -e
. /usr/share/debconf/confmodule
# The answer to a question in ANSWER, or "" when the question has not been seen; the
# default debconf holds for an unseen question is not an answer.
answer() {
ANSWER=""
db_fget "$1" seen
if [ "$RET" = "true" ]; then
db_get "$1"
ANSWER="$RET"
fi
}
case "$1" in
configure)
# --- Read the installer's answers ---
db_get vpn-router/platform; VPN_ROUTER_PLATFORM="$RET"
db_get vpn-router/mode; VPN_ROUTER_MODE="$RET"
db_get vpn-router/int_addr; VPN_ROUTER_INT_ADDR="$RET"
db_get vpn-router/external_interface; VPN_ROUTER_EXTERNAL_INTERFACE="$RET"
db_get vpn-router/internal_interface; VPN_ROUTER_INTERNAL_INTERFACE="$RET"
db_get vpn-router/local_fqdn; VPN_ROUTER_LOCAL_FQDN="$RET"
db_get vpn-router/local_id_mode; VPN_ROUTER_LOCAL_ID_MODE="$RET"
db_get vpn-router/local_cidrs; VPN_ROUTER_LOCAL_CIDRS="$RET"
db_get vpn-router/int_gateway_ip; VPN_ROUTER_INT_GATEWAY_IP="$RET"
db_get vpn-router/remote_addrs; VPN_ROUTER_REMOTE_ADDRS="$RET"
db_get vpn-router/remote_id; VPN_ROUTER_REMOTE_ID="$RET"
db_get vpn-router/remote_cidrs; VPN_ROUTER_REMOTE_CIDRS="$RET"
db_get vpn-router/psk; VPN_ROUTER_PSK="$RET"
db_get vpn-router/p2s_enabled; VPN_ROUTER_P2S_ENABLED="$RET"
db_get vpn-router/p2s_address_pool; VPN_ROUTER_P2S_ADDRESS_POOL="$RET"
db_get vpn-router/p2s_ca_name; VPN_ROUTER_P2S_CA_NAME="$RET"
db_get vpn-router/wg_enabled; VPN_ROUTER_WG_ENABLED="$RET"
db_get vpn-router/wg_address; VPN_ROUTER_WG_ADDRESS="$RET"
db_get vpn-router/wg_listen_port; VPN_ROUTER_WG_LISTEN_PORT="$RET"
answer vpn-router/platform; VPN_ROUTER_PLATFORM="$ANSWER"
answer vpn-router/mode; VPN_ROUTER_MODE="$ANSWER"
answer vpn-router/int_addr; VPN_ROUTER_INT_ADDR="$ANSWER"
answer vpn-router/external_interface; VPN_ROUTER_EXTERNAL_INTERFACE="$ANSWER"
answer vpn-router/internal_interface; VPN_ROUTER_INTERNAL_INTERFACE="$ANSWER"
answer vpn-router/local_fqdn; VPN_ROUTER_LOCAL_FQDN="$ANSWER"
answer vpn-router/local_id_mode; VPN_ROUTER_LOCAL_ID_MODE="$ANSWER"
answer vpn-router/local_cidrs; VPN_ROUTER_LOCAL_CIDRS="$ANSWER"
answer vpn-router/int_gateway_ip; VPN_ROUTER_INT_GATEWAY_IP="$ANSWER"
answer vpn-router/remote_addrs; VPN_ROUTER_REMOTE_ADDRS="$ANSWER"
answer vpn-router/remote_id; VPN_ROUTER_REMOTE_ID="$ANSWER"
answer vpn-router/remote_cidrs; VPN_ROUTER_REMOTE_CIDRS="$ANSWER"
answer vpn-router/psk; VPN_ROUTER_PSK="$ANSWER"
answer vpn-router/p2s_enabled; VPN_ROUTER_P2S_ENABLED="$ANSWER"
answer vpn-router/p2s_address_pool; VPN_ROUTER_P2S_ADDRESS_POOL="$ANSWER"
answer vpn-router/pki_root_name; VPN_ROUTER_PKI_ROOT_NAME="$ANSWER"
answer vpn-router/wg_enabled; VPN_ROUTER_WG_ENABLED="$ANSWER"
answer vpn-router/wg_address; VPN_ROUTER_WG_ADDRESS="$ANSWER"
answer vpn-router/wg_listen_port; VPN_ROUTER_WG_LISTEN_PORT="$ANSWER"
export VPN_ROUTER_PLATFORM VPN_ROUTER_MODE VPN_ROUTER_INT_ADDR \
VPN_ROUTER_EXTERNAL_INTERFACE \
@@ -33,7 +44,7 @@ case "$1" in
VPN_ROUTER_REMOTE_ADDRS VPN_ROUTER_REMOTE_ID \
VPN_ROUTER_REMOTE_CIDRS VPN_ROUTER_PSK \
VPN_ROUTER_P2S_ENABLED VPN_ROUTER_P2S_ADDRESS_POOL \
VPN_ROUTER_P2S_CA_NAME VPN_ROUTER_WG_ENABLED \
VPN_ROUTER_PKI_ROOT_NAME VPN_ROUTER_WG_ENABLED \
VPN_ROUTER_WG_ADDRESS VPN_ROUTER_WG_LISTEN_PORT
# --- Hand a candidate configuration to ucf ---
@@ -59,6 +70,12 @@ case "$1" in
# The firewall is not touched here. Enabling it, allowing SSH and
# setting the forward policy all belong to vpn-router-setup, which does
# them only once the configuration says this host is a router.
# The web service waits for libpq in the linker cache, which dpkg
# refreshes after this script, so it is started without waiting for it.
if [ -d /run/systemd/system ]; then
systemctl --no-block restart vpn-router-web.service >/dev/null 2>&1 || true
fi
;;
esac
+34 -6
View File
@@ -11,10 +11,10 @@ strip_ufw_blocks() {
tmp="$(mktemp)"
awk '
/^# (IPSEC|WIREGUARD|P2S DNS|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES START$/ {
/^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|BOOTSTRAP|MANAGER|HTTPS|METRICS|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES START$/ {
skip = 1; pending = 0; next
}
/^# (IPSEC|WIREGUARD|P2S DNS|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES END$/ {
/^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|BOOTSTRAP|MANAGER|HTTPS|METRICS|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES END$/ {
skip = 0; next
}
skip { next }
@@ -43,14 +43,42 @@ case "$1" in
ucfr --purge vpn-router /etc/vpn-router/vpn-router.json
fi
rm -f /etc/swanctl/conf.d/remote-site.conf \
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/wireguard/wg0.conf \
/etc/vpn-router/vpn-router.json
/etc/openvpn/server/vpn-router.conf \
/etc/openvpn/server/vpn-router-ldap.conf \
/etc/caddy/vpn-router.Caddyfile \
/etc/vpn-router/vpn-router.json \
/etc/vpn-router/web.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 \
/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
# Key material is not package state: /etc/vpn-router/pki, the WireGuard
# key pair and everything under /etc/swanctl are left in place.
# The refresh tokens' database and role, when the package created them.
if [ -f /etc/vpn-router/database.secret ] && command -v psql >/dev/null 2>&1; then
runuser -u postgres -- psql -X -q -c 'DROP DATABASE IF EXISTS "vpn-router"' || true
runuser -u postgres -- psql -X -q -c 'DROP ROLE IF EXISTS "vpn-router"' || true
fi
rm -f /etc/vpn-router/database.secret
# Key material and users 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.
rmdir --ignore-fail-on-non-empty /etc/vpn-router 2>/dev/null || true
;;
esac
+19 -10
View File
@@ -6,20 +6,29 @@ case "$1" in
# Stop offering the tunnels, but leave strongSwan itself enabled: it is
# a shared service and may be in use by something other than this
# package. The connection files are removed on purge.
# The site-to-site connections are the names of site_to_site in vpn-router.json.
if [ -d /run/systemd/system ]; then
swanctl --terminate --ike remote-site >/dev/null 2>&1 || true
connections="$(python3 -c 'import json, sys; print(" ".join(json.load(open(sys.argv[1])).get("site_to_site", {})))' \
/etc/vpn-router/vpn-router.json 2>/dev/null || true)"
for connection in $connections; do
swanctl --terminate --ike "$connection" >/dev/null 2>&1 || true
done
swanctl --terminate --ike road-warrior >/dev/null 2>&1 || true
fi
# This package enabled wg-quick@wg0, so it takes it down again. Use the
# debhelper wrappers rather than systemctl, so the script behaves on a
# machine without systemd.
if command -v deb-systemd-invoke >/dev/null 2>&1; then
deb-systemd-invoke stop wg-quick@wg0.service >/dev/null 2>&1 || true
fi
if command -v deb-systemd-helper >/dev/null 2>&1; then
deb-systemd-helper disable wg-quick@wg0.service >/dev/null 2>&1 || true
fi
# vpn-router-setup enabled wg-quick@wg0, openvpn-server@vpn-router,
# caddy, vpn-router-metrics, the backup timer and the PKI timer, so the
# package takes them down again. Use the debhelper wrappers rather than
# systemctl, so the script behaves on a machine without systemd.
for unit in wg-quick@wg0.service openvpn-server@vpn-router.service caddy.service vpn-router-metrics.service \
vpn-router-backup.timer vpn-router-pki.timer; do
if command -v deb-systemd-invoke >/dev/null 2>&1; then
deb-systemd-invoke stop "$unit" >/dev/null 2>&1 || true
fi
if command -v deb-systemd-helper >/dev/null 2>&1; then
deb-systemd-helper disable "$unit" >/dev/null 2>&1 || true
fi
done
;;
esac
+7
View File
@@ -4,3 +4,10 @@
override_dh_installsystemd:
dh_installsystemd --name=vpn-router-setup
dh_installsystemd --name=vpn-router-docker
dh_installsystemd --name=vpn-router-web --no-start
dh_installsystemd --name=vpn-router-metrics --no-enable --no-start
dh_installsystemd --name=vpn-router-bootstrap --no-enable --no-start
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
+16 -14
View File
@@ -66,7 +66,8 @@ Type: select
Choices: fqdn, public_ip, internal_ip
Default: fqdn
Description: IKE local identity mode:
How to derive the IKE identity advertised to the remote site:
How to derive the IKE identity the first site-to-site connection
advertises to the remote site:
.
fqdn - use the FQDN, which must match what the peer expects.
.
@@ -78,8 +79,9 @@ Template: vpn-router/local_cidrs
Type: string
Default:
Description: Local subnet CIDR(s):
Comma-separated list of local subnet CIDRs to advertise into the
site-to-site tunnel (for example 10.0.0.0/24 or 10.0.0.0/24,10.0.1.0/24).
Comma-separated list of local subnet CIDRs routed by this router and
advertised into the first site-to-site connection (for example
10.0.0.0/24 or 10.0.0.0/24,10.0.1.0/24).
Template: vpn-router/int_gateway_ip
Type: string
@@ -94,25 +96,26 @@ Type: string
Default:
Description: Remote site WAN IP address(es):
Comma-separated list of remote gateway addresses or FQDNs for the
site-to-site IPSec tunnel.
first site-to-site connection.
Template: vpn-router/remote_id
Type: string
Default:
Description: Remote site IKE identity:
IKE identity of the remote peer, without a leading @.
IKE identity of the remote peer of the first site-to-site connection,
without a leading @.
Template: vpn-router/remote_cidrs
Type: string
Default:
Description: Remote subnet CIDR(s):
Comma-separated list of remote subnet CIDRs reachable through the
site-to-site tunnel (for example 192.168.0.0/24).
first site-to-site connection (for example 192.168.0.0/24).
Template: vpn-router/psk
Type: password
Description: Pre-shared key (PSK):
Pre-shared key for the site-to-site IKEv2 tunnel. Must match the value
Pre-shared key of the first site-to-site connection. Must match the value
configured on the remote peer. Stored in the configuration file and cleared
from the debconf database after installation.
@@ -121,8 +124,7 @@ Type: boolean
Default: false
Description: Enable road-warrior (P2S) access?
If enabled, this router accepts IKEv2 EAP-TLS connections from individual
clients. Certificates are taken from /etc/vpn-router/pki, and a local
certificate authority is created there if that directory is empty.
clients, with certificates of the router's P2S issuing CA.
Template: vpn-router/p2s_address_pool
Type: string
@@ -130,12 +132,12 @@ Default:
Description: Road-warrior address pool:
CIDR block assigned to road-warrior clients (for example 172.16.0.0/24).
Template: vpn-router/p2s_ca_name
Template: vpn-router/pki_root_name
Type: string
Default: VPN Router CA
Description: Road-warrior CA name:
Common name for the certificate authority created in /etc/vpn-router/pki
when that directory is empty. Ignored when certificates are supplied.
Default: VPN Router Root CA
Description: Root CA name:
Common name of the root certificate authority the router creates in
/etc/vpn-router/pki/root. Ignored when a root certificate is placed there.
Template: vpn-router/wg_enabled
Type: boolean
@@ -0,0 +1,11 @@
[Unit]
Description=VPN Router backup
Documentation=file:/usr/share/doc/vpn-router/README.md
After=network-online.target slapd.service postgresql.service
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/sbin/vpn-router-backup run
Nice=10
IOSchedulingClass=idle
@@ -0,0 +1,10 @@
[Unit]
Description=VPN Router backup schedule
Documentation=file:/usr/share/doc/vpn-router/README.md
[Timer]
OnCalendar=daily
Persistent=true
[Install]
WantedBy=timers.target
@@ -0,0 +1,14 @@
[Unit]
Description=VPN Router bootstrap diagnostics page
Documentation=file:/usr/share/doc/vpn-router/README.md
# Never enabled: vpn-router-setup starts it until a setup has succeeded, and stops it after that.
[Service]
Type=simple
ExecStart=/usr/bin/python3 /usr/lib/vpn-router/bootstrap
Restart=on-failure
RestartSec=5
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
@@ -0,0 +1,10 @@
[Unit]
Description=VPN Router Caddy installation
Documentation=file:/usr/share/doc/vpn-router/README.md
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/sbin/vpn-router-caddy apply
TimeoutStartSec=20min
@@ -0,0 +1,14 @@
[Unit]
Description=VPN Router rules for Docker published ports
Documentation=file:/usr/share/doc/vpn-router/README.md
After=docker.service
PartOf=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/lib/vpn-router/docker-rules apply
ExecStop=/usr/lib/vpn-router/docker-rules remove
[Install]
WantedBy=docker.service
@@ -0,0 +1,22 @@
[Unit]
Description=VPN Router metrics service
Documentation=file:/usr/share/doc/vpn-router/README.md
After=network-online.target postgresql.service vpn-router-web.service
Wants=network-online.target
[Service]
Type=simple
ExecStartPre=/usr/lib/vpn-router/wait-for-libpq
ExecStart=/usr/bin/python3 /usr/lib/vpn-router/metrics
TimeoutStartSec=5min
Restart=on-failure
RestartSec=15
NoNewPrivileges=yes
ProtectSystem=strict
RuntimeDirectory=vpn-router
RuntimeDirectoryPreserve=yes
ProtectHome=yes
PrivateTmp=yes
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,7 @@
[Unit]
Description=VPN Router PKI maintenance
Documentation=file:/usr/share/doc/vpn-router/README.md
[Service]
Type=oneshot
ExecStart=/usr/sbin/vpn-router-ca maintain
@@ -0,0 +1,10 @@
[Unit]
Description=VPN Router PKI maintenance schedule
Documentation=file:/usr/share/doc/vpn-router/README.md
[Timer]
OnCalendar=daily
Persistent=true
[Install]
WantedBy=timers.target
@@ -1,8 +1,8 @@
[Unit]
Description=VPN Router Setup
Documentation=file:/usr/share/doc/vpn-router/README.md
Wants=network-online.target
After=network-online.target
Wants=network-online.target postgresql.service
After=network-online.target docker.service postgresql.service
[Service]
Type=oneshot
@@ -0,0 +1,23 @@
[Unit]
Description=VPN Router status web service
Documentation=file:/usr/share/doc/vpn-router/README.md
After=network-online.target postgresql.service
Wants=network-online.target
[Service]
Type=simple
ExecStartPre=/usr/lib/vpn-router/wait-for-libpq
ExecStart=/usr/bin/python3 /usr/lib/vpn-router/web
TimeoutStartSec=5min
Restart=on-failure
RestartSec=15
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
ProtectHome=yes
PrivateTmp=yes
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,10 @@
# Caddy is the HTTPS proxy of vpn-router: it reads the configuration vpn-router-setup writes, and
# does not start until that exists.
[Unit]
ConditionPathExists=/etc/caddy/vpn-router.Caddyfile
[Service]
ExecStart=
ExecStart=/usr/bin/caddy run --environ --adapter caddyfile --config /etc/caddy/vpn-router.Caddyfile
ExecReload=
ExecReload=/usr/bin/caddy reload --adapter caddyfile --config /etc/caddy/vpn-router.Caddyfile --force
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/python3
"""Serve diagnostics of the router's first setup on PORT, with the Python standard library alone.
Run by vpn-router-bootstrap.service, which vpn-router-setup starts until a setup has succeeded and
then stops. Every request on any address answers, as plain text, the output of COMMANDS run at that
moment: the state of the last setup run, the state of the services, the addresses, and the end of
the logs.
"""
import subprocess
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
PORT = 17081
#: The title of each section and its command.
COMMANDS = (
('Setup state', ['cat', '/run/vpn-router/setup.json']),
('Services', ['systemctl', 'is-active', 'vpn-router-setup', 'vpn-router-web', 'slapd', 'postgresql',
'strongswan', 'openvpn-server@vpn-router', 'wg-quick@wg0']),
('Failed units', ['systemctl', '--failed', '--no-legend', '--plain']),
('Addresses', ['ip', '-brief', 'address']),
('Routes', ['ip', 'route']),
('cloud-init', ['cloud-init', 'status', '--long']),
('vpn-router-setup log', ['journalctl', '--unit', 'vpn-router-setup', '--lines', '60', '--no-pager', '--output', 'short-iso']),
('vpn-router-web log', ['journalctl', '--unit', 'vpn-router-web', '--lines', '30', '--no-pager', '--output', 'short-iso']),
)
def report():
"""The output of every command of COMMANDS under its title."""
sections = []
for title, command in COMMANDS:
try:
result = subprocess.run(command, capture_output=True, text=True, timeout=10)
text = (result.stdout + result.stderr).rstrip()
except (OSError, subprocess.TimeoutExpired) as exc:
text = f'cannot run {command[0]}: {exc}'
sections.append(f'== {title} ==\n{text}\n')
return '\n'.join(sections)
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
body = report().encode()
self.send_response(200)
self.send_header('Content-Type', 'text/plain; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
self.send_header('Cache-Control', 'no-store')
self.send_header('Refresh', '10')
self.end_headers()
self.wfile.write(body)
if __name__ == '__main__':
ThreadingHTTPServer(('', PORT), Handler).serve_forever()
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/python3
"""Secure the ports Docker publishes, with rules in the DOCKER-USER chain.
Usage: docker-rules apply|remove
Docker evaluates DOCKER-USER in FORWARD before its own rules, which makes it the
place to filter traffic to containers. The rules live in their own chain,
VPN-ROUTER-DOCKER, entered by one jump at the top of DOCKER-USER, so anything
else in DOCKER-USER is left alone. Traffic arriving on the external interface is
accepted when it is IPsec protected, when it is TCP to docker.exposed_ports, or
when it belongs to an established connection; all other traffic from the
external interface is dropped. Traffic from other interfaces is not affected.
docker.mode decides whether this runs: auto applies the rules when the Docker
service is installed, on requires it, off removes the rules. The vpn-router-docker
unit runs apply after docker.service starts and remove when it stops.
"""
import os
import subprocess
import sys
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
import vpnrouter
from vpnrouter import fail, warn
CHAIN = 'VPN-ROUTER-DOCKER'
USER_CHAIN = 'DOCKER-USER'
def iptables(*args, check=True):
result = subprocess.run(['iptables', *args], capture_output=True, text=True)
if check and result.returncode != 0:
fail(f'iptables {" ".join(args)} failed: {result.stderr.strip()}')
return result
def docker_installed():
"""True when systemd knows the docker.service unit."""
result = subprocess.run(
['systemctl', 'list-unit-files', '--no-legend', 'docker.service'],
capture_output=True, text=True)
return result.returncode == 0 and result.stdout.strip() != ''
def remove():
"""Take the jump and the chain out; nothing happens when they are absent."""
while iptables('-D', USER_CHAIN, '-j', CHAIN, check=False).returncode == 0:
pass
iptables('-F', CHAIN, check=False)
iptables('-X', CHAIN, check=False)
def apply(cfg):
mode = cfg['docker']['mode']
if mode == 'off':
remove()
return
if not docker_installed():
if mode == 'on':
fail('docker.mode is on but the docker service is not installed; '
'install Docker before vpn-router')
return
if iptables('-S', USER_CHAIN, check=False).returncode != 0:
if mode == 'on':
fail(f'docker.mode is on but there is no {USER_CHAIN} chain; '
'is the Docker daemon running?')
warn(f'no {USER_CHAIN} chain, the Docker daemon is not running; '
'the rules are applied when it starts')
return
ctx = vpnrouter.build_context(cfg)
wan = ctx['wan_iface']
if not wan:
fail('the external interface is not known; set interfaces.external '
'or use mode auto or interfaces')
ports = ','.join(item.strip() for item in cfg['docker']['exposed_ports'])
remove()
iptables('-N', CHAIN)
iptables('-A', CHAIN, '-i', wan, '-m', 'policy', '--dir', 'in', '--pol', 'ipsec', '-j', 'ACCEPT')
if ports:
iptables('-A', CHAIN, '-i', wan, '-p', 'tcp', '-m', 'multiport', '--dports', ports, '-j', 'ACCEPT')
iptables('-A', CHAIN, '-i', wan, '-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED', '-j', 'ACCEPT')
iptables('-A', CHAIN, '-i', wan, '-j', 'DROP')
iptables('-I', USER_CHAIN, '1', '-j', CHAIN)
def main():
if len(sys.argv) != 2 or sys.argv[1] not in ('apply', 'remove'):
print('usage: docker-rules apply|remove', file=sys.stderr)
return 2
if sys.argv[1] == 'remove':
remove()
return 0
cfg = vpnrouter.load_config()
if vpnrouter.deferred(cfg):
return 0
try:
vpnrouter.validate(cfg)
apply(cfg)
except vpnrouter.ConfigError as exc:
fail(str(exc))
return 0
if __name__ == '__main__':
sys.exit(main())
@@ -6,22 +6,21 @@ Called from postinst, which hands the result to ucf(1). ucf compares it with
this script never writes to /etc and never has to worry about destroying an
administrator's edits.
The candidate is built in three layers, each overriding the one before:
1. the shipped defaults
2. the current contents of /etc/vpn-router/vpn-router.json, if it exists
3. any debconf answer that was actually given
Layer 2 is what makes reconfiguration safe: a setting the administrator edited
by hand, and that debconf has nothing to say about, is carried through
unchanged, so ucf only sees a diff where something really changed. An empty
answer means "leave alone", which is why clearing the pre-shared key from the
debconf database after installation does not blank it on the next
dpkg-reconfigure.
The candidate is the current /etc/vpn-router/vpn-router.json as it is, or the
shipped defaults when there is none, with every debconf answer that was given
written over it. Without an answer that changes it, the candidate is the current
file byte for byte, so
an upgrade or a reconfiguration that changes nothing gives ucf nothing to ask;
defaults the file leaves out are applied by vpnrouter.load_config() when the
file is read. An empty answer means "leave alone", which is why clearing the
pre-shared key from the debconf database after installation does not blank it
on the next dpkg-reconfigure.
Answers arrive as VPN_ROUTER_* environment variables and are converted to the
type of the setting they fill: comma-separated lists become arrays, "true" and
"false" become booleans and digits become integers.
"false" become booleans and digits become integers. The site-to-site answers
fill the connection vpnrouter.FIRST_CONNECTION of site_to_site; the local
subnets fill both its local_cidrs and local.cidrs.
"""
import json
@@ -32,27 +31,29 @@ sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
import vpnrouter
# environment variable -> (section, option)
FIRST = ('site_to_site', vpnrouter.FIRST_CONNECTION)
# environment variable -> the settings it fills, each (section, option) or (site_to_site, name, option)
ANSWERS = {
'VPN_ROUTER_PLATFORM': ('general', 'platform'),
'VPN_ROUTER_MODE': ('general', 'mode'),
'VPN_ROUTER_INT_ADDR': ('local', 'int_addr'),
'VPN_ROUTER_EXTERNAL_INTERFACE': ('interfaces', 'external'),
'VPN_ROUTER_INTERNAL_INTERFACE': ('interfaces', 'internal'),
'VPN_ROUTER_LOCAL_FQDN': ('wan', 'local_fqdn'),
'VPN_ROUTER_LOCAL_ID_MODE': ('wan', 'local_id_mode'),
'VPN_ROUTER_LOCAL_CIDRS': ('local', 'cidrs'),
'VPN_ROUTER_INT_GATEWAY_IP': ('local', 'int_gateway_ip'),
'VPN_ROUTER_REMOTE_ADDRS': ('remote', 'addrs'),
'VPN_ROUTER_REMOTE_ID': ('remote', 'id'),
'VPN_ROUTER_REMOTE_CIDRS': ('remote', 'cidrs'),
'VPN_ROUTER_PSK': ('remote', 'psk'),
'VPN_ROUTER_P2S_ENABLED': ('p2s', 'enabled'),
'VPN_ROUTER_P2S_ADDRESS_POOL': ('p2s', 'address_pool'),
'VPN_ROUTER_P2S_CA_NAME': ('p2s', 'ca_name'),
'VPN_ROUTER_WG_ENABLED': ('wireguard', 'enabled'),
'VPN_ROUTER_WG_ADDRESS': ('wireguard', 'address'),
'VPN_ROUTER_WG_LISTEN_PORT': ('wireguard', 'listen_port'),
'VPN_ROUTER_PLATFORM': (('general', 'platform'),),
'VPN_ROUTER_MODE': (('general', 'mode'),),
'VPN_ROUTER_INT_ADDR': (('local', 'int_addr'),),
'VPN_ROUTER_EXTERNAL_INTERFACE': (('interfaces', 'external'),),
'VPN_ROUTER_INTERNAL_INTERFACE': (('interfaces', 'internal'),),
'VPN_ROUTER_LOCAL_FQDN': (('wan', 'local_fqdn'),),
'VPN_ROUTER_LOCAL_ID_MODE': ((*FIRST, 'local_id_mode'),),
'VPN_ROUTER_LOCAL_CIDRS': (('local', 'cidrs'), (*FIRST, 'local_cidrs')),
'VPN_ROUTER_INT_GATEWAY_IP': (('local', 'int_gateway_ip'),),
'VPN_ROUTER_REMOTE_ADDRS': ((*FIRST, 'addrs'),),
'VPN_ROUTER_REMOTE_ID': ((*FIRST, 'id'),),
'VPN_ROUTER_REMOTE_CIDRS': ((*FIRST, 'cidrs'),),
'VPN_ROUTER_PSK': ((*FIRST, 'psk'),),
'VPN_ROUTER_P2S_ENABLED': (('p2s', 'enabled'),),
'VPN_ROUTER_P2S_ADDRESS_POOL': (('p2s', 'address_pool'),),
'VPN_ROUTER_PKI_ROOT_NAME': (('pki', 'root_name'),),
'VPN_ROUTER_WG_ENABLED': (('wireguard', 'enabled'),),
'VPN_ROUTER_WG_ADDRESS': (('wireguard', 'address'),),
'VPN_ROUTER_WG_LISTEN_PORT': (('wireguard', 'listen_port'),),
}
@@ -62,25 +63,51 @@ def main():
return 2
output = sys.argv[1]
cfg = vpnrouter.load_config()
try:
current = vpnrouter.CONFIG_FILE.read_text(encoding='utf-8')
except FileNotFoundError:
current = None
if current is None:
cfg = vpnrouter.load_config()
else:
try:
cfg = json.loads(current)
except ValueError as exc:
vpnrouter.fail(f'cannot parse {vpnrouter.CONFIG_FILE}: {exc}')
if not isinstance(cfg, dict):
vpnrouter.fail(f'cannot parse {vpnrouter.CONFIG_FILE}: the top level is not an object')
for variable, (section, option) in ANSWERS.items():
value = os.environ.get(variable)
if not value:
answered = False
for variable, paths in ANSWERS.items():
answer = os.environ.get(variable)
if not answer:
continue
# A value that does not convert is kept as a string for validate() to report.
default = vpnrouter.SCHEMA[section][option]
if isinstance(default, bool):
value = value == 'true'
elif isinstance(default, int):
value = int(value) if value.isdigit() else value
elif isinstance(default, list):
value = [item.strip() for item in value.split(',') if item.strip()]
cfg[section][option] = value
for path in paths:
# A value that does not convert is kept as a string for validate() to report.
default = vpnrouter.CONNECTION[path[2]] if path[0] == 'site_to_site' else vpnrouter.SCHEMA[path[0]][path[1]]
value = answer
if isinstance(default, bool):
value = value == 'true'
elif isinstance(default, int):
value = int(value) if value.isdigit() else value
elif isinstance(default, list):
value = [item.strip() for item in value.split(',') if item.strip()]
target = cfg
for key in path[:-1]:
if not isinstance(target.setdefault(key, {}), dict):
vpnrouter.fail(f'cannot parse {vpnrouter.CONFIG_FILE}: {".".join(path[:-1])} is not an object')
target = target[key]
if target.get(path[-1]) != value:
target[path[-1]] = value
answered = True
# Without a change the candidate is the current file byte for byte, as ucf compares checksums.
with open(output, 'w', encoding='utf-8') as handle:
json.dump(cfg, handle, indent=2)
handle.write('\n')
if current is not None and not answered:
handle.write(current)
else:
json.dump(cfg, handle, indent=2)
handle.write('\n')
os.chmod(output, 0o600)
return 0
+456
View File
@@ -0,0 +1,456 @@
#!/usr/bin/python3
"""Collect the metrics of the router, keep them in PostgreSQL, serve them for scraping and push them with OTLP.
Run by vpn-router-metrics.service while metrics.enabled is true; vpn-router-setup enables and
disables the unit.
Every SAMPLE_SECONDS, on the minute, a sample is taken with psutil: the CPU utilization since the
previous sample, the memory used and in total, the free and the total space of /, the bytes
received and sent per second on the external and the internal interface, and the load averages;
and the VPN clients of each enabled VPN system (vpnrouter_status): road-warrior (p2s) and
OpenVPN clients and WireGuard peers with a recent handshake; and of each site-to-site connection
that is set up, whether it is up, the bytes and packets per second through its child SAs since
the previous sample, and how long its IKE SA has been up. The interfaces, the enabled systems and
the connections are those vpn-router-setup resolved, read from /run/vpn-router/web.json.
The sample is written to the detail tables of vpnrouter_metrics. When the clock has passed into
a new hour, day, month or year, the complete periods are summarized, and once a day, after the
daily summary, the rows older than their retention are deleted; both also run on start. A
failure of the database is logged and tried again with the next sample.
GET /metrics answers the last sample in the Prometheus text format, written by
prometheus_client, with OpenTelemetry semantic convention names in their Prometheus form, for an
OpenTelemetry Collector's prometheus receiver. It needs HTTP Basic authentication as the service
account metrics.service_account names, which vpn-router-setup keeps with the password in
/etc/vpn-router/metrics.secret, checked with a bind to the router's directory, so failed attempts
lock the account as for any user. The service listens on the interfaces metrics.listen
names and on the loopback address, where Caddy serves it over HTTPS at metrics.https_port; the
address of a call through Caddy is the last entry of its X-Forwarded-For header.
When metrics.otlp_endpoint is set, every sample is also pushed there as an OTLP/HTTP JSON
ExportMetricsServiceRequest, with HTTP Basic authentication when metrics.otlp_username is set.
"""
import base64
import datetime
import json
import os
import signal
import socket
import sys
import threading
import time
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
import ldap
import psutil
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
from prometheus_client.core import CollectorRegistry, CounterMetricFamily, GaugeMetricFamily
import vpnrouter
import vpnrouter_metrics
import vpnrouter_net
import vpnrouter_serve
import vpnrouter_status
import vpnrouter_users
STATE_FILE = '/run/vpn-router/web.json'
CONFIG_FILE = '/etc/vpn-router/vpn-router.json'
SAMPLE_SECONDS = 60
#: Seconds between two discoveries of where the service should listen.
DISCOVERY_SECONDS = 30
#: Seconds an OTLP push may take.
OTLP_TIMEOUT = 10
#: The mount point whose space is measured.
MOUNTPOINT = '/'
#: The last sample, as sample() makes it, or None before the first.
_latest = {'sample': None}
_latest_lock = threading.Lock()
def log(message):
print(f'vpn-router-metrics: {message}', file=sys.stderr, flush=True)
def read_json(path):
"""The object in a JSON file, or {} when the file is missing or holds no object."""
try:
with open(path, encoding='utf-8') as handle:
data = json.load(handle)
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) else {}
def section(configuration, name):
"""A section of the configuration, or {} when it is missing or not an object."""
value = configuration.get(name)
return value if isinstance(value, dict) else {}
def clients(state):
"""The VPN clients of each enabled VPN system, None where it cannot be asked."""
counts = {}
if state.get('p2s'):
counts['p2s'] = vpnrouter_status.established('road-warrior')
if state.get('openvpn'):
counts['openvpn'] = vpnrouter_status.openvpn_clients()
if state.get('wireguard'):
counts['wireguard'] = vpnrouter_status.wireguard_connected()
return counts
def sample(previous):
"""A sample of the router, with the rates since the previous sample, None for the first."""
state = read_json(STATE_FILE)
network = section(state, 'network')
names = [name for name in dict.fromkeys((network.get('external_interface'), network.get('internal_interface')))
if isinstance(name, str) and name]
counters = psutil.net_io_counters(pernic=True)
memory = psutil.virtual_memory()
disk = psutil.disk_usage(MOUNTPOINT)
load1, load5, load15 = os.getloadavg()
connections = state.get('site_to_site')
tunnels = vpnrouter_status.site_to_site_tunnels(connections) if isinstance(connections, list) and connections else {}
now = time.time()
current = {
'time': now,
'monotonic': time.monotonic(),
'system': {
'cpu': psutil.cpu_percent(interval=None),
'memory_used': memory.total - memory.available,
'memory_total': memory.total,
'disk_free': disk.free,
'disk_total': disk.total,
'load1': load1,
'load5': load5,
'load15': load15,
},
'counters': {name: (counters[name].bytes_recv, counters[name].bytes_sent) for name in names if name in counters},
'clients': clients(state),
'rates': {},
'tunnels': tunnels or {},
'site_to_site': {},
}
elapsed = current['monotonic'] - previous['monotonic'] if previous is not None else 0
if elapsed > 0:
for name, (received, sent) in current['counters'].items():
if name in previous['counters']:
before_received, before_sent = previous['counters'][name]
current['rates'][name] = (max(received - before_received, 0) / elapsed, max(sent - before_sent, 0) / elapsed)
# A connection's rates need its counters of the previous sample; a counter that went down was rekeyed and counts from zero.
for name, tunnel in current['tunnels'].items():
before = previous['tunnels'].get(name) if elapsed > 0 else None
values = {'up': tunnel['up'], 'uptime': tunnel['uptime']}
for value, counter in (('rx', 'bytes_in'), ('tx', 'bytes_out'), ('packets_rx', 'packets_in'), ('packets_tx', 'packets_out')):
if before is None:
values[value] = None
else:
count = tunnel[counter]
values[value] = (count - before[counter] if count >= before[counter] else count) / elapsed
current['site_to_site'][name] = values
return current
def periods(moment):
"""The hour, the day, the month and the year of a UTC moment, keyed by the levels of vpnrouter_metrics.LEVELS."""
return {'hourly': moment.strftime('%Y-%m-%dT%H'), 'daily': moment.strftime('%Y-%m-%d'),
'monthly': moment.strftime('%Y-%m'), 'yearly': moment.strftime('%Y')}
def store(cfg, current, done):
"""Write the sample, summarize the periods the clock has passed and collect the garbage once a day.
done holds the periods summarized so far, and garbage the day of the last garbage collection;
it is updated only for what succeeded.
"""
now = periods(datetime.datetime.now(datetime.timezone.utc))
with vpnrouter_metrics.database(cfg) as connection:
vpnrouter_metrics.write(connection, current['system'], current['rates'], current['clients'], current['site_to_site'])
for level, _, _ in vpnrouter_metrics.LEVELS:
if done.get(level) != now[level]:
vpnrouter_metrics.summarize(connection, level)
done[level] = now[level]
if done.get('garbage') != now['daily']:
vpnrouter_metrics.collect_garbage(connection, cfg)
done['garbage'] = now['daily']
def attributes(**values):
"""OTLP attributes of string values."""
return [{'key': key, 'value': {'stringValue': value}} for key, value in values.items()]
def otlp_request(current, started):
"""The OTLP/HTTP JSON ExportMetricsServiceRequest of a sample; started is when the counters were first read, in Unix seconds."""
stamp = str(int(current['time'] * 1e9))
start = str(int(started * 1e9))
system = current['system']
def gauge(name, unit, points):
return {'name': name, 'unit': unit, 'gauge': {'dataPoints': [
{'timeUnixNano': stamp, 'asDouble': float(value), 'attributes': attributes(**labels)}
for value, labels in points if value is not None]}}
metrics = [
gauge('system.cpu.utilization', '1', [(system['cpu'] / 100, {})]),
gauge('system.memory.usage', 'By', [(system['memory_used'], {'state': 'used'})]),
gauge('system.memory.limit', 'By', [(system['memory_total'], {})]),
gauge('system.filesystem.usage', 'By', [(system['disk_free'], {'mountpoint': MOUNTPOINT, 'state': 'free'}),
(system['disk_total'] - system['disk_free'], {'mountpoint': MOUNTPOINT, 'state': 'used'})]),
gauge('system.filesystem.utilization', '1',
[(1 - system['disk_free'] / system['disk_total'] if system['disk_total'] else None, {'mountpoint': MOUNTPOINT})]),
gauge('system.cpu.load_average.1m', '{thread}', [(system['load1'], {})]),
gauge('system.cpu.load_average.5m', '{thread}', [(system['load5'], {})]),
gauge('system.cpu.load_average.15m', '{thread}', [(system['load15'], {})]),
gauge('vpn_router.clients', '{client}', [(count, {'kind': kind}) for kind, count in current['clients'].items()]),
gauge('vpn_router.site_to_site.up', '1', [(values['up'], {'connection': name})
for name, values in current['site_to_site'].items()]),
gauge('vpn_router.site_to_site.uptime', 's', [(values['uptime'], {'connection': name})
for name, values in current['site_to_site'].items()]),
gauge('vpn_router.site_to_site.io', 'By/s', [(values[value], {'connection': name, 'direction': direction})
for name, values in current['site_to_site'].items()
for value, direction in (('rx', 'receive'), ('tx', 'transmit'))]),
gauge('vpn_router.site_to_site.packets', '{packet}/s',
[(values[value], {'connection': name, 'direction': direction})
for name, values in current['site_to_site'].items()
for value, direction in (('packets_rx', 'receive'), ('packets_tx', 'transmit'))]),
{'name': 'system.network.io', 'unit': 'By', 'sum': {
'aggregationTemporality': 2, 'isMonotonic': True, 'dataPoints': [
{'startTimeUnixNano': start, 'timeUnixNano': stamp, 'asInt': str(value),
'attributes': attributes(device=name, direction=direction)}
for name, counters in current['counters'].items()
for value, direction in zip(counters, ('receive', 'transmit'))]}},
]
return {'resourceMetrics': [{
'resource': {'attributes': attributes(**{'service.name': 'vpn-router', 'host.name': socket.gethostname()})},
'scopeMetrics': [{'scope': {'name': 'vpn-router-metrics'}, 'metrics': metrics}],
}]}
def push(metrics, current, started):
"""Push the sample to metrics.otlp_endpoint; raises OSError when the collector does not take it."""
request = urllib.request.Request(metrics['otlp_endpoint'], data=json.dumps(otlp_request(current, started)).encode(),
headers={'Content-Type': 'application/json'}, method='POST')
if metrics['otlp_username']:
credentials = f"{metrics['otlp_username']}:{metrics['otlp_password']}".encode()
request.add_header('Authorization', 'Basic ' + base64.b64encode(credentials).decode())
with urllib.request.urlopen(request, timeout=OTLP_TIMEOUT) as response:
response.read()
def collector_loop():
"""Take a sample on every minute, store it and push it; every new failure is logged once."""
previous, done, reported = None, {}, {}
started = time.time()
psutil.cpu_percent(interval=None)
def report(kind, message):
if message != reported.get(kind):
if message:
log(message)
elif reported.get(kind):
log(f'{kind} works again')
reported[kind] = message
while True:
time.sleep(SAMPLE_SECONDS - time.time() % SAMPLE_SECONDS)
try:
cfg = vpnrouter.load_config()
except SystemExit as exc:
report('configuration', str(exc.code))
continue
report('configuration', '')
current = sample(previous)
previous = current
with _latest_lock:
_latest['sample'] = current
try:
store(cfg, current, done)
report('database', '')
except vpnrouter_metrics.DatabaseError as exc:
report('database', str(exc))
metrics = cfg['metrics']
if metrics['otlp_endpoint']:
try:
push(metrics, current, started)
report('OTLP push', '')
except (OSError, ValueError) as exc:
report('OTLP push', f"pushing to {metrics['otlp_endpoint']} failed: {exc}")
class Sample:
"""A prometheus_client collector of a sample."""
def __init__(self, current):
self.current = current
def collect(self):
system = self.current['system']
yield GaugeMetricFamily('system_cpu_utilization', 'CPU utilization since the previous sample, 0 to 1',
value=system['cpu'] / 100)
memory = GaugeMetricFamily('system_memory_usage_bytes', 'Memory in use', labels=['state'])
memory.add_metric(['used'], system['memory_used'])
yield memory
yield GaugeMetricFamily('system_memory_limit_bytes', 'Total memory', value=system['memory_total'])
usage = GaugeMetricFamily('system_filesystem_usage_bytes', 'Space of the file system', labels=['mountpoint', 'state'])
usage.add_metric([MOUNTPOINT, 'free'], system['disk_free'])
usage.add_metric([MOUNTPOINT, 'used'], system['disk_total'] - system['disk_free'])
yield usage
if system['disk_total']:
utilization = GaugeMetricFamily('system_filesystem_utilization', 'Used part of the file system, 0 to 1',
labels=['mountpoint'])
utilization.add_metric([MOUNTPOINT], 1 - system['disk_free'] / system['disk_total'])
yield utilization
for period in ('1m', '5m', '15m'):
yield GaugeMetricFamily(f'system_cpu_load_average_{period}', f'Load average over {period}',
value=system[f'load{period[:-1]}'])
network = CounterMetricFamily('system_network_io_bytes', 'Bytes received and sent', labels=['device', 'direction'])
for name, (received, sent) in self.current['counters'].items():
network.add_metric([name, 'receive'], received)
network.add_metric([name, 'transmit'], sent)
yield network
vpn = GaugeMetricFamily('vpn_router_clients', 'Connected VPN clients', labels=['kind'])
for kind, count in self.current['clients'].items():
if count is not None:
vpn.add_metric([kind], count)
yield vpn
tunnels = self.current['site_to_site']
up = GaugeMetricFamily('vpn_router_site_to_site_up', 'Whether the site-to-site connection is established, 1 or 0',
labels=['connection'])
uptime = GaugeMetricFamily('vpn_router_site_to_site_uptime_seconds', 'Seconds the IKE SA of the site-to-site connection has been up',
labels=['connection'])
io = GaugeMetricFamily('vpn_router_site_to_site_io_bytes_per_second',
'Bytes per second received and sent through the site-to-site connection', labels=['connection', 'direction'])
packets = GaugeMetricFamily('vpn_router_site_to_site_packets_per_second',
'Packets per second received and sent through the site-to-site connection',
labels=['connection', 'direction'])
for name, values in tunnels.items():
up.add_metric([name], values['up'])
if values['uptime'] is not None:
uptime.add_metric([name], values['uptime'])
for family, received, sent in ((io, 'rx', 'tx'), (packets, 'packets_rx', 'packets_tx')):
for value, direction in ((received, 'receive'), (sent, 'transmit')):
if values[value] is not None:
family.add_metric([name, direction], values[value])
yield from (up, uptime, io, packets)
def authorized(header, account):
"""Whether an Authorization header holds the password of the service account; LDAP errors propagate."""
if not header.startswith('Basic '):
return False
try:
name, _, password = base64.b64decode(header[6:].strip(), validate=True).decode().partition(':')
except ValueError:
return False
if name != account:
return False
try:
with vpnrouter_users.session() as connection:
if not vpnrouter_users.service(vpnrouter_users.entry(connection, name)):
return False
vpnrouter_users.login(name, password, None)
except vpnrouter_users.Failure:
return False
return True
class Handler(BaseHTTPRequestHandler):
protocol_version = 'HTTP/1.1'
def client(self):
"""The client address: the last X-Forwarded-For entry of a call through Caddy on the loopback address, else the peer."""
peer = self.client_address[0]
forwarded = self.headers.get('X-Forwarded-For', '')
if peer == vpnrouter_net.LOOPBACK and forwarded.strip():
return forwarded.split(',')[-1].strip()
return peer
def answer(self, send_body):
self.principal = None
extra = []
path = self.path.split('?', 1)[0]
account = str(section(read_json(CONFIG_FILE), 'metrics').get('service_account',
vpnrouter.SCHEMA['metrics']['service_account'])).strip()
with _latest_lock:
current = _latest['sample']
if path != '/metrics':
body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
elif not account:
body, kind, code = b'metrics.service_account is not set\n', 'text/plain; charset=utf-8', 503
else:
try:
allowed = authorized(self.headers.get('Authorization', ''), account)
except (ldap.LDAPError, OSError) as exc:
allowed = None
body, kind, code = f'the user directory failed: {exc}\n'.encode(), 'text/plain; charset=utf-8', 500
if allowed is False:
body, kind, code = b'Unauthorized\n', 'text/plain; charset=utf-8', 401
extra.append(('WWW-Authenticate', 'Basic realm="vpn-router metrics", charset="UTF-8"'))
elif allowed:
self.principal = account
if current is None:
body, kind, code = b'no sample yet\n', 'text/plain; charset=utf-8', 503
else:
registry = CollectorRegistry()
registry.register(Sample(current))
body, kind, code = generate_latest(registry), CONTENT_TYPE_LATEST, 200
self.send_response(code)
self.send_header('Content-Type', kind)
self.send_header('Content-Length', str(len(body)))
self.send_header('Cache-Control', 'no-store')
for name, value in extra:
self.send_header(name, value)
self.end_headers()
if send_body:
self.wfile.write(body)
def do_GET(self):
self.answer(True)
def do_HEAD(self):
self.answer(False)
def log_message(self, format, *args):
principal = getattr(self, 'principal', None) or '-'
sys.stderr.write('%s - %s [%s] %s\n' % (self.client(), principal, self.log_date_time_string(), format % args))
def wanted():
"""Where the service listens: the addresses of the interfaces metrics.listen names and LOOPBACK, at metrics.port."""
configuration = read_json(CONFIG_FILE)
metrics = section(configuration, 'metrics')
defaults = vpnrouter.SCHEMA['metrics']
if metrics.get('enabled', defaults['enabled']) is False:
return [], 'metrics are disabled in the configuration'
port = metrics.get('port', defaults['port'])
if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
port = defaults['port']
addresses = vpnrouter_net.listen_addresses(configuration, metrics.get('listen', defaults['listen']))
if not addresses:
return [], 'the addresses to listen on are not known yet'
return [(address, port) for address in [*addresses, vpnrouter_net.LOOPBACK]], ''
def main():
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
threading.Thread(target=collector_loop, name='collector', daemon=True).start()
try:
vpnrouter_serve.serve(wanted, Handler, log, DISCOVERY_SECONDS)
except KeyboardInterrupt:
pass
return 0
if __name__ == '__main__':
sys.exit(main())
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/python3
"""Accept an OpenVPN client certificate only when the P2S issuing CA signed it.
Run by the OpenVPN server as tls-verify for each certificate of the client's chain, with its depth and subject as the
arguments. At depth 1, the CA that signed the client's certificate, the SHA-256 fingerprint OpenVPN gives in
tls_digest_sha256_1 must be that of P2S_CA; the other depths are accepted.
"""
import hashlib
import os
import ssl
import sys
P2S_CA = '/etc/swanctl/x509ca/p2s.pem'
if len(sys.argv) < 2 or sys.argv[1] != '1':
sys.exit(0)
try:
with open(P2S_CA, encoding='ascii') as handle:
expected = hashlib.sha256(ssl.PEM_cert_to_DER_cert(handle.read())).hexdigest()
except (OSError, ValueError):
sys.exit(1)
given = os.environ.get('tls_digest_sha256_1', '').replace(':', '').lower()
sys.exit(0 if given == expected else 1)
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/python3
"""Accept an OpenVPN login only when the user name is the common name of the client certificate.
Run by the OpenVPN server as auth-user-pass-verify with via-env, next to the LDAP plugin that
checks the password, so a user's certificate cannot be used with another user's password.
"""
import os
import sys
username = os.environ.get('username', '')
sys.exit(0 if username and username == os.environ.get('common_name') else 1)
+625 -21
View File
@@ -5,32 +5,108 @@ Regenerates the configuration files first, then does everything that needs
the live system: routes, DNS-derived identity, WireGuard keys, firewall rules
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
internal interface. The first successful run writes SETUP_DONE, closes the
ports and stops the bootstrap services; a failed run leaves them running.
Interface names come from the configuration and are never guessed. The
firewall and NAT rules are written against them, so choosing the wrong one
would be a silent and security-relevant mistake.
"""
import grp
import json
import os
import pathlib
import re
import secrets
import shutil
import socket
import subprocess
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
import ldap
import vpnrouter
from vpnrouter import fail
import vpnrouter_ldap
import vpnrouter_pki
import vpnrouter_users
from vpnrouter import fail, warn
BEFORE_RULES = pathlib.Path('/etc/ufw/before.rules')
UFW_DEFAULTS = pathlib.Path('/etc/default/ufw')
WG_DIR = pathlib.Path('/etc/wireguard')
WEB_STATE = pathlib.Path('/run/vpn-router/web.json')
#: The state, the current step and the failure of the last run, which the bootstrap services show.
SETUP_STATE = pathlib.Path('/run/vpn-router/setup.json')
#: 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.
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')
#: The schedule of the backup timer, written from backup.schedule.
BACKUP_SCHEDULE = pathlib.Path('/etc/systemd/system/vpn-router-backup.timer.d/schedule.conf')
#: The configuration of Caddy, the HTTPS proxy, which the package's drop-in for caddy.service names;
#: Caddy runs as the user and group CADDY_GROUP, which the caddy package creates.
CADDYFILE = pathlib.Path('/etc/caddy/vpn-router.Caddyfile')
CADDY_UNIT = 'caddy.service'
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.
CADDY_PKI_DIR = pathlib.Path('/etc/caddy/vpn-router-pki')
#: 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.
SECRET_OPTION = re.compile(r'(^psk$)|secret|password|token|_key$|^database$')
BLOCKS = (
('# IPSEC RULES START', '# IPSEC RULES END'),
('# WIREGUARD RULES START', '# WIREGUARD RULES END'),
('# OPENVPN RULES START', '# OPENVPN RULES END'),
('# P2S DNS RULES START', '# P2S DNS RULES END'),
('# WEB RULES START', '# WEB RULES END'),
('# BOOTSTRAP RULES START', '# BOOTSTRAP RULES END'),
('# MANAGER RULES START', '# MANAGER RULES END'),
('# HTTPS RULES START', '# HTTPS RULES END'),
('# METRICS RULES START', '# METRICS 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'),
@@ -114,7 +190,17 @@ def is_configured(ctx):
a host that was working before.
"""
return any((vpnrouter.s2s_ready(ctx), vpnrouter.routing_ready(ctx),
vpnrouter.p2s_ready(ctx), vpnrouter.wg_ready(ctx)))
vpnrouter.p2s_ready(ctx), vpnrouter.wg_ready(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):
@@ -139,16 +225,86 @@ def filter_block(ctx):
'# WIREGUARD RULES END',
]
if vpnrouter.p2s_ready(ctx) and ctx['local_addr_list']:
pool = ctx['p2s_address_pool']
lines += ['', '# P2S DNS RULES START']
for address in ctx['local_addr_list']:
if vpnrouter.openvpn_ready(ctx):
device = ctx['openvpn_device']
lines += [
'',
'# OPENVPN RULES START',
f'-A ufw-before-input -i {ctx["wan_iface"]} -p {ctx["openvpn_protocol"]} --dport {ctx["openvpn_port"]} -j ACCEPT',
]
for subnet in ctx['local_cidr_list']:
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',
f'-A ufw-before-forward -i {device} -d {subnet} -j ACCEPT',
f'-A ufw-before-forward -o {device} -s {subnet} -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT',
]
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']:
lines += ['', '# P2S DNS RULES START']
for pool in pools:
for address in ctx['local_addr_list']:
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',
]
lines += ['# P2S DNS RULES END']
# The web service, the Router Manager and the metrics service choose their interfaces themselves, so their ports are open on both.
interfaces = [name for name in (ctx['int_iface'], ctx['wan_iface']) if name]
if ctx['web_enabled'] and interfaces:
lines += [
'',
'# WEB RULES START',
*(f'-A ufw-before-input -i {name} -p tcp --dport {ctx["web_port"]} -j ACCEPT' for name in interfaces),
'# WEB RULES END',
]
if not SETUP_DONE.exists() and ctx['int_iface']:
lines += [
'',
'# BOOTSTRAP RULES START',
*(f'-A ufw-before-input -i {ctx["int_iface"]} -p tcp --dport {port} -j ACCEPT' for port in BOOTSTRAP_PORTS),
'# BOOTSTRAP RULES END',
]
if MANAGER.exists() and interfaces:
port = manager_port()
lines += [
'',
'# MANAGER RULES START',
*(f'-A ufw-before-input -i {name} -p tcp --dport {port} -j ACCEPT' for name in interfaces),
'# MANAGER RULES END',
]
if vpnrouter.metrics_ready(ctx) and interfaces:
lines += [
'',
'# METRICS RULES START',
*(f'-A ufw-before-input -i {name} -p tcp --dport {ctx["metrics_port"]} -j ACCEPT' for name in interfaces),
'# METRICS RULES END',
]
# Let's Encrypt reaches the HTTP challenge on the external interface, and the CA files published on port 80 are
# read on both; the HTTPS ports are open on both.
if vpnrouter.https_ready(ctx) and interfaces:
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.metrics_ready(ctx), ctx['metrics_https_port']),
('http-80' in publish, vpnrouter.ACME_HTTP_PORT),
('https-default' in publish, vpnrouter.HTTPS_DEFAULT_PORT)) if wanted]
lines += [
'',
'# HTTPS RULES START',
*([f'-A ufw-before-input -i {ctx["wan_iface"]} -p tcp --dport {vpnrouter.ACME_HTTP_PORT} -j ACCEPT']
if ctx['wan_iface'] and 'http-80' not in publish else []),
*(f'-A ufw-before-input -i {name} -p tcp --dport {port} -j ACCEPT' for name in interfaces for port in ports),
'# HTTPS RULES END',
]
if vpnrouter.routing_ready(ctx):
lines += ['', '# ROUTER FORWARD RULES START']
for subnet in ctx['local_cidr_list']:
@@ -166,7 +322,6 @@ def nat_and_mangle_block(ctx):
if vpnrouter.routing_ready(ctx):
subnets = ctx['local_cidr_list']
remote = ctx['remote_cidr_list']
lines += [
'',
'# ROUTER NAT RULES START',
@@ -174,11 +329,14 @@ def nat_and_mangle_block(ctx):
':POSTROUTING ACCEPT [0:0]',
'-F POSTROUTING',
]
# Every RETURN precedes every MASQUERADE, so tunnel-bound traffic
# escapes NAT whichever local subnet it came from.
for subnet in subnets:
for cidr in remote:
lines.append(f'-A POSTROUTING -s {subnet} -d {cidr} -j RETURN')
# Every RETURN precedes every MASQUERADE, so traffic bound for a tunnel
# escapes NAT; each ready connection pairs its local and remote subnets.
for connection in ctx['connections']:
if not connection['ready']:
continue
for subnet in connection['local_cidr_list']:
for cidr in connection['remote_cidr_list']:
lines.append(f'-A POSTROUTING -s {subnet} -d {cidr} -j RETURN')
for subnet in subnets:
lines.append(
f'-A POSTROUTING -s {subnet} -o {ctx["wan_iface"]} -j MASQUERADE')
@@ -287,6 +445,301 @@ def manage_wireguard(ctx, changed):
run(['systemctl', 'restart', unit], check=False)
def manage_directory():
"""Provision the user directory in slapd, whether or not a P2S system is configured; the problem, or ''."""
if vpnrouter_ldap.ensure_services() or not unit_is_active('slapd'):
result = run(['systemctl', 'restart', 'slapd'], check=False)
if result.returncode != 0:
return f'slapd cannot be restarted: {result.stderr.strip()}'
try:
vpnrouter_ldap.provision()
except Exception as exc:
return f'the user directory cannot be provisioned: {exc!r}'
return ''
def manage_admin():
"""Create the Router Manager's default admin from MANAGER_ADMIN, then remove the file; the problem, or ''.
The admin is a Manager-only account and a member of the admins group, which is created with it
when it does not exist.
"""
if not MANAGER_ADMIN.exists():
return ''
try:
answers = json.loads(MANAGER_ADMIN.read_text(encoding='utf-8'))
name, password = answers['username'], answers['password']
with vpnrouter_users.session() as connection:
vpnrouter_users.add(connection, name, password, {})
try:
vpnrouter_users.group_member_add(connection, vpnrouter_users.ADMIN_GROUP, name)
except vpnrouter_users.Failure as failure:
if failure.status != 3:
raise
vpnrouter_users.group_add(connection, vpnrouter_users.ADMIN_GROUP, 'Router administrators', [name])
MANAGER_ADMIN.unlink()
except (OSError, ValueError, KeyError, TypeError, ldap.LDAPError, vpnrouter_users.Failure) as exc:
return f'the Router Manager admin cannot be created from {MANAGER_ADMIN}: {exc}'
return ''
def manage_metrics_account(ctx):
"""Keep the service account metrics.service_account names, whose password is in METRICS_SECRET; the problem, or ''.
While metrics are enabled, setup owns the account, an internal service account: it adds it as a service account
with a random password and the labels of vpnrouter_users.internal_accounts when it is missing, sets those labels
when they differ, and gives it a new random password, ending any lockout, when METRICS_SECRET is missing or does
not hold its password. The password of a disabled account is left as it is. An account of that name that is not a
service account is left alone and is a problem.
"""
if not vpnrouter.metrics_ready(ctx):
return ''
name = ctx['metrics_service_account']
wanted = vpnrouter_users.internal_accounts().get(name, {})
try:
vpnrouter_users.check_name(name)
try:
password = METRICS_SECRET.read_text(encoding='utf-8').strip()
except OSError:
password = ''
with vpnrouter_users.session() as connection:
try:
current = vpnrouter_users.entry(connection, name)
except vpnrouter_users.Failure as failure:
if failure.status != 3:
raise
current = None
if current is not None and not vpnrouter_users.service(current):
return f'metrics.service_account: {name} is a user, not a service account'
if current is not None and any(vpnrouter_users.summary(current)[key] != value for key, value in wanted.items()):
vpnrouter_users.update_service(connection, name, wanted)
# An administrator who disabled the account meant it; its password is left as it is.
if current is not None and not vpnrouter_users.summary(current)['enabled']:
return ''
valid = False
if current is not None and password:
try:
vpnrouter_users.login(name, password, None)
valid = True
except vpnrouter_users.Failure:
pass
if not valid:
password = secrets.token_urlsafe(32)
METRICS_SECRET.touch(mode=0o600)
METRICS_SECRET.chmod(0o600)
METRICS_SECRET.write_text(password + '\n', encoding='utf-8')
if current is None:
vpnrouter_users.add(connection, name, password, wanted, service=True)
else:
vpnrouter_users.passwd(connection, name, password)
vpnrouter_users.unlock(connection, name)
except (OSError, ldap.LDAPError, vpnrouter_users.Failure) as exc:
return f'the metrics service account cannot be kept: {exc}'
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)(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'
if not vpnrouter.openvpn_ready(ctx):
if unit_is_active(unit):
run(['systemctl', 'disable', '--now', unit], check=False)
return
if not unit_is_active(unit):
run(['systemctl', 'enable', '--now', unit], check=False)
elif 'openvpn' in changed or 'swanctl' in 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.
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.
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):
vpnrouter.remove(CADDYFILE)
if unit_is_active(CADDY_UNIT):
run(['systemctl', 'disable', '--now', CADDY_UNIT], check=False)
return
source = cfg['https']['caddy_source']
if shutil.which('caddy') is None or (source == 'upstream' and not vpnrouter.CADDY_APT_SOURCE.is_file()):
run(['systemctl', 'start', '--no-block', CADDY_INSTALL_UNIT], check=False)
warn(f'Caddy is installed from the {source} source by {CADDY_INSTALL_UNIT}, which runs setup again')
return
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")
try:
group = grp.getgrnam(CADDY_GROUP).gr_gid
except KeyError:
fail(f'the group {CADDY_GROUP} does not exist; is the caddy package installed?')
try:
credentials = {name: vpnrouter_pki.authority_files(name) for name in ctx['pki_acme']}
except ldap.LDAPError as exc:
warn(f'the ACME servers are left out: the PKI cannot be read: {exc}')
credentials = {}
acme = [name for name, files in credentials.items() if files is not None]
copied = False
for folder in (CADDY_PKI_DIR.iterdir() if CADDY_PKI_DIR.is_dir() else []):
if folder.name not in acme:
shutil.rmtree(folder)
for name in acme:
folder = CADDY_PKI_DIR / name
for path in (CADDY_PKI_DIR, folder):
path.mkdir(mode=0o750, exist_ok=True)
os.chown(path, 0, group)
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,
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])
changed = vpnrouter.render(vpnrouter._jinja_env(), caddy_ctx, 'Caddyfile.j2', CADDYFILE, 0o640)
os.chown(CADDYFILE, 0, group)
if not unit_is_active(CADDY_UNIT):
run(['systemctl', 'enable', '--now', CADDY_UNIT], check=False)
elif changed or copied:
run(['systemctl', 'reload', CADDY_UNIT], check=False)
def manage_metrics(ctx):
"""Run the metrics service while metrics are enabled, and stop it when not.
The service reads its settings itself, so it is restarted only to run the code of a new package version, which
every run of setup after an upgrade does.
"""
unit = 'vpn-router-metrics.service'
if not vpnrouter.metrics_ready(ctx):
run(['systemctl', 'disable', '--now', unit], check=False)
return
run(['systemctl', 'enable', unit], check=False)
run(['systemctl', 'restart', unit], check=False)
def manage_backup(cfg):
"""Run the backup timer on backup.schedule while backups are enabled, and stop it when not.
The schedule is a drop-in of the timer, BACKUP_SCHEDULE, which replaces the OnCalendar of the shipped unit.
"""
unit = 'vpn-router-backup.timer'
if not cfg['backup']['enabled']:
run(['systemctl', 'disable', '--now', unit], check=False)
if BACKUP_SCHEDULE.exists():
BACKUP_SCHEDULE.unlink()
run(['systemctl', 'daemon-reload'], check=False)
return
text = f'[Timer]\nOnCalendar=\nOnCalendar={cfg["backup"]["schedule"]}\n'
if not BACKUP_SCHEDULE.exists() or BACKUP_SCHEDULE.read_text(encoding='utf-8') != text:
BACKUP_SCHEDULE.parent.mkdir(parents=True, exist_ok=True)
BACKUP_SCHEDULE.write_text(text, encoding='utf-8')
run(['systemctl', 'daemon-reload'], check=False)
run(['systemctl', 'restart', unit], check=False)
run(['systemctl', 'enable', '--now', unit], check=False)
def run_platform_setup(cfg, ctx):
"""Let the platform module apply its own state, if it defines any."""
module = vpnrouter.platform_module(cfg)
@@ -299,25 +752,121 @@ def run_platform_setup(cfg, ctx):
fail(f'platform module {module.__name__} failed: {exc}')
def main():
cfg = vpnrouter.load_config()
ctx, changed = vpnrouter.configure(cfg)
def refresh_docker_rules():
"""Apply the new settings to the Docker rules while Docker is running.
if ctx is None:
When Docker is not running, vpn-router-docker applies them after
docker.service starts. The restart is waited for, so the rules are in place
when this setup ends; vpn-router-docker is therefore not ordered after
vpn-router-setup.
"""
if unit_is_active('docker.service'):
run(['systemctl', 'restart', 'vpn-router-docker.service'], check=False)
def refresh_web(cfg, ctx):
"""Hand the web service the features and facts it shows.
The service reads the file at each request: the features of the router,
the configuration without its secret options and the facts resolved from
the system, which it cannot work out with the standard library alone. The
file is readable by root only. The service follows a changed address or
port itself. Secret options are left out at every level, so also from each
site-to-site connection.
"""
def public(options):
return {option: public(value) if isinstance(value, dict) else value
for option, value in options.items()
if isinstance(value, dict) or not SECRET_OPTION.search(option)}
WEB_STATE.parent.mkdir(parents=True, exist_ok=True)
WEB_STATE.touch(mode=0o600)
WEB_STATE.chmod(0o600)
WEB_STATE.write_text(json.dumps({
'site_to_site': [connection['name'] for connection in ctx['connections'] if connection['ready']],
'p2s': bool(vpnrouter.p2s_ready(ctx)),
'wireguard': bool(vpnrouter.wg_ready(ctx)),
'openvpn': bool(vpnrouter.openvpn_ready(ctx)),
'configuration': public(cfg),
'network': {
'external_interface': ctx['wan_iface'],
'internal_interface': ctx['int_iface'],
'external_addresses': ctx['local_addr_list'],
'internal_address': ctx['int_addr'],
'internal_gateway': ctx['int_gateway_ip'],
'local_cidrs': ctx['local_cidr_list'],
'site_to_site': [{
'name': connection['name'],
'local_identity': connection['local_id'],
'remote_identity': connection['remote_id'],
'local_cidrs': connection['local_cidr_list'],
'remote_cidrs': connection['remote_cidr_list'],
} for connection in ctx['connections']],
},
}) + '\n', encoding='utf-8')
def progress(state, step, error=None):
"""Record where setup is in SETUP_STATE: running, deferred, done or failed, the step, and the error of a failure."""
SETUP_STATE.parent.mkdir(parents=True, exist_ok=True)
SETUP_STATE.write_text(json.dumps({
'state': state,
'step': step,
'error': error,
'time': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
}) + '\n', encoding='utf-8')
def start_bootstrap():
"""Start the bootstrap services while no run has succeeded; a unit that is not installed is skipped."""
if not SETUP_DONE.exists():
for unit in BOOTSTRAP_UNITS:
run(['systemctl', 'start', '--no-block', unit], check=False)
def end_bootstrap(ctx):
"""After the first successful run: write SETUP_DONE, close the bootstrap ports and stop the bootstrap services."""
if SETUP_DONE.exists():
return
SETUP_DONE.parent.mkdir(parents=True, exist_ok=True)
SETUP_DONE.touch()
if setup_ufw(ctx):
run(['ufw', 'reload'], check=False)
for unit in BOOTSTRAP_UNITS:
run(['systemctl', 'stop', unit], check=False)
def main():
start_bootstrap()
progress('running', 'configuration')
cfg = vpnrouter.load_config()
if vpnrouter.deferred(cfg):
print('vpn-router: platform is none, configuration deferred; '
f'set general.platform in {vpnrouter.CONFIG_FILE}')
progress('deferred', 'configuration', f'general.platform is none; set it in {vpnrouter.CONFIG_FILE}')
return 0
# The directory holds the PKI, which the configuration needs; its problem ends setup only after every other step.
failed_step = 'user directory'
progress('running', failed_step)
problem = manage_directory()
progress('running', 'configuration')
ctx, changed = vpnrouter.configure(cfg)
progress('running', 'routes')
if vpnrouter.routing_ready(ctx):
apply_routes(ctx)
if ctx['local_id_mode'] == 'public_ip' and ctx['local_fqdn']:
if ctx['local_fqdn'] and any(connection['local_id_mode'] == 'public_ip' for connection in ctx['connections']):
progress('running', 'public address')
address = resolve_public_ip(ctx['local_fqdn'])
ctx, more = vpnrouter.configure(cfg, local_id_override=address)
ctx, more = vpnrouter.configure(cfg, public_ip=address)
changed |= more
progress('running', 'WireGuard keys')
setup_wireguard(ctx)
progress('running', 'firewall')
rules_changed = setup_ufw(ctx)
if is_configured(ctx):
status = run(['ufw', 'status'], check=False)
@@ -330,15 +879,70 @@ def main():
elif rules_changed:
run(['ufw', 'reload'], check=False)
progress('running', 'strongSwan')
manage_strongswan(ctx, changed)
progress('running', 'WireGuard')
manage_wireguard(ctx, changed)
if not problem:
failed_step = 'Router Manager admin'
progress('running', failed_step)
problem = manage_admin()
if not problem:
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', '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', 'platform module')
run_platform_setup(cfg, ctx)
progress('running', 'Docker rules')
refresh_docker_rules()
progress('running', 'web service state')
refresh_web(cfg, ctx)
progress('running', 'metrics service')
manage_metrics(ctx)
progress('running', 'backup timer')
manage_backup(cfg)
if problem:
progress('failed', failed_step, problem)
print(f'vpn-router: error: {problem}', file=sys.stderr)
return 1
progress('done', 'done')
end_bootstrap(ctx)
return 0
def record_failure(error):
"""Mark the run failed in the step SETUP_STATE holds."""
try:
step = json.loads(SETUP_STATE.read_text(encoding='utf-8')).get('step')
except (OSError, ValueError):
step = None
progress('failed', step, error)
if __name__ == '__main__':
sys.exit(main())
# A failing step ends with SystemExit carrying its message (vpnrouter.fail).
try:
code = main()
except SystemExit as exc:
record_failure(str(exc.code))
raise
except Exception as exc:
record_failure(repr(exc))
raise
sys.exit(code)
@@ -1,611 +0,0 @@
#!/usr/bin/env python3
# MIT License
#
# Copyright (c) 2026 Sławomir Koszewski
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# This module requires Python 3.8+ and OpenSSL to be installed on the system.
import argparse
import datetime
import json
import os
import re
import subprocess
import sys
import tempfile
OPENSSL = "openssl"
class Config:
FILE = "simple-ca.json"
def __init__(self, ca_dir: str):
self._path = os.path.join(ca_dir, self.FILE)
self._data: dict = {}
if not os.path.isfile(self._path):
return
try:
with open(self._path, "r") as f:
self._data = json.load(f)
except (json.JSONDecodeError, OSError) as e:
print(f"WARNING: could not read {self._path}: {e}", file=sys.stderr)
def get(self, key, default=None):
return self._data.get(key, default)
def update(self, patch: dict):
file_missing = not os.path.isfile(self._path)
changed = any(self._data.get(k) != v for k, v in patch.items())
if file_missing or changed:
self._data.update(patch)
self._save()
def append_history(self, ca_key: str, entry: dict):
history = self._data.get("history", {})
history.setdefault(ca_key, []).append(entry)
self._data["history"] = history
self._save()
def revoke_in_history(self, ca_key: str, serial: str, revoked_at: str):
"""Mark a certificate as revoked. Returns True if newly revoked,
False if already revoked, None if serial not found."""
history = self._data.get("history", {})
for entry in history.get(ca_key, []):
if entry.get("serial") == serial:
if "revoked" in entry:
return False
entry["revoked"] = revoked_at
self._data["history"] = history
self._save()
return True
return None
def _save(self):
with open(self._path, "w") as f:
json.dump(self._data, f, indent=2)
f.write("\n")
_config: Config
def _now() -> str:
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _err(msg):
print(f"ERROR: {msg}", file=sys.stderr)
def _rebuild_ca_bundle(ca_dir):
bundle_path = os.path.join(ca_dir, "ca_bundle.pem")
parts = []
root = os.path.join(ca_dir, "ca_cert.pem")
if os.path.isfile(root):
with open(root, "rb") as f:
parts.append(f.read())
for name in sorted(_config.get("subordinates", [])):
sub_cert = os.path.join(ca_dir, name, "ca_cert.pem")
if os.path.isfile(sub_cert):
with open(sub_cert, "rb") as f:
parts.append(f.read())
with open(bundle_path, "wb") as f:
f.write(b"".join(parts))
def _read_serial(cert_path) -> str:
result = subprocess.run(
[OPENSSL, "x509", "-in", cert_path, "-noout", "-serial"],
capture_output=True, text=True,
)
return result.stdout.strip().split("=", 1)[-1]
def _read_expiry(cert_path) -> str:
"""Return the certificate notAfter date as an ISO 8601 UTC string."""
result = subprocess.run(
[OPENSSL, "x509", "-in", cert_path, "-noout", "-enddate"],
capture_output=True, text=True,
)
raw = result.stdout.strip().split("=", 1)[-1]
raw = " ".join(raw.split()) # normalize whitespace ("May 4" -> "May 4")
dt = datetime.datetime.strptime(raw, "%b %d %H:%M:%S %Y GMT")
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
def _iso_to_asn1(iso: str) -> str:
"""Convert "2026-05-24T14:28:10Z" -> "260524142810Z" for OpenSSL index.txt."""
dt = datetime.datetime.strptime(iso, "%Y-%m-%dT%H:%M:%SZ")
return dt.strftime("%y%m%d%H%M%SZ")
_IP_RE = re.compile(r"^[0-9]{1,3}(\.[0-9]{1,3}){3}$")
_DNS_RE = re.compile(r"^[a-z0-9-]+(\.[a-z0-9-]+)*$")
def _is_ip(value):
return bool(_IP_RE.match(value))
def _is_dns(value):
return bool(_DNS_RE.match(value))
def _pipe(cmd1, cmd2):
p1 = subprocess.Popen(cmd1, stdout=subprocess.PIPE)
p2 = subprocess.Popen(cmd2, stdin=p1.stdout)
p1.stdout.close()
p2.communicate()
p1.wait()
return p1.returncode == 0 and p2.returncode == 0
def make_ca(ca_dir, ca_name, days=3650, issuing_ca=None, ca_publish_base_url=None):
if issuing_ca == "ca":
_err("--issuing-ca cannot be 'ca' as it is reserved for the root CA.")
return False
if not ca_dir or not os.path.isdir(ca_dir):
_err(f"Certificate directory {ca_dir} does not exist.")
return False
if not ca_name:
_err("CA name is required.")
return False
root_ca_cert_path = os.path.join(ca_dir, "ca_cert.pem")
root_ca_key_path = os.path.join(ca_dir, "ca_key.pem")
if not os.path.isfile(root_ca_cert_path) or not os.path.isfile(root_ca_key_path):
if issuing_ca:
_err(
f"Cannot create issuing CA '{ca_name}' without existing root CA "
"certificate and key. Please create the root CA first."
)
return False
print(f"Generating CA certificate '{ca_name}' and key...")
# Path length constraint of 1: allows one level of issuing CAs.
cmd = [
OPENSSL, "req",
"-x509",
"-newkey", "rsa:4096",
"-keyout", root_ca_key_path,
"-out", root_ca_cert_path,
"-days", str(days),
"-noenc",
"-subj", f"/CN={ca_name}",
"-text",
"-addext", "basicConstraints=critical,CA:TRUE,pathlen:1",
"-addext", "keyUsage=critical,keyCertSign,cRLSign",
]
if subprocess.run(cmd).returncode != 0:
_err("Failed to generate CA certificate and key.")
return False
_rebuild_ca_bundle(ca_dir)
patch = {"name": ca_name, "created": _now()}
if ca_publish_base_url:
patch["ca_publish_base_url"] = ca_publish_base_url
_config.update(patch)
return True
issuing_ca_dir = os.path.join(ca_dir, issuing_ca)
issuing_ca_cert = os.path.join(issuing_ca_dir, "ca_cert.pem")
issuing_ca_key = os.path.join(issuing_ca_dir, "ca_key.pem")
if not os.path.isfile(issuing_ca_cert) or not os.path.isfile(issuing_ca_key):
print(f"Generating issuing CA certificate '{ca_name}' and key...")
os.makedirs(issuing_ca_dir, exist_ok=True)
req_cmd = [
OPENSSL, "req",
"-newkey", "rsa:4096",
"-keyout", issuing_ca_key,
"-noenc",
"-subj", f"/CN={ca_name}",
"-addext", "basicConstraints=critical,CA:TRUE,pathlen:0",
"-addext", "keyUsage=critical,keyCertSign,cRLSign",
]
if ca_publish_base_url:
req_cmd += [
"-addext", f"authorityInfoAccess=caIssuers;URI:{ca_publish_base_url}/ca_cert.crt",
"-addext", f"crlDistributionPoints=URI:{ca_publish_base_url}/crl.pem",
]
x509_cmd = [
OPENSSL, "x509",
"-req",
"-CA", root_ca_cert_path,
"-CAkey", root_ca_key_path,
"-copy_extensions", "copyall",
"-days", str(days),
"-text",
"-out", issuing_ca_cert,
]
if not _pipe(req_cmd, x509_cmd):
_err("Failed to generate issuing CA certificate and key.")
return False
patch = {"ca_publish_base_url": ca_publish_base_url} if ca_publish_base_url else {}
subs = _config.get("subordinates", [])
if issuing_ca not in subs:
patch["subordinates"] = subs + [issuing_ca]
_config.update(patch)
_rebuild_ca_bundle(ca_dir)
return True
def make_cert(cert_subject_name, sans=None, ca_dir=None, cert_dir=None,
issuing_ca=None, days=365, ca_publish_base_url=None):
if issuing_ca == "ca":
_err("--issuing-ca cannot be 'ca' as it is reserved for the root CA.")
return False
if not ca_dir or not os.path.isdir(ca_dir):
_err(f"CA directory {ca_dir} does not exist.")
return False
if not cert_subject_name:
_err("Subject name is required.")
return False
if not _is_dns(cert_subject_name):
_err(f"Invalid subject name '{cert_subject_name}'. Must be a valid DNS name.")
return False
signing_dir = os.path.join(ca_dir, issuing_ca) if issuing_ca else ca_dir
cert_dir = cert_dir or signing_dir
if not os.path.isdir(cert_dir):
_err(f"Certificate directory {cert_dir} does not exist.")
return False
ca_cert_path = os.path.join(signing_dir, "ca_cert.pem")
ca_key_path = os.path.join(signing_dir, "ca_key.pem")
if not os.path.isfile(ca_cert_path) or not os.path.isfile(ca_key_path):
_err(
f"Signing CA certificate and key not found in {signing_dir}. "
"Please set up a signing CA first."
)
return False
aia_url = cdp_url = ""
if ca_publish_base_url:
if issuing_ca:
aia_url = f"{ca_publish_base_url}/{issuing_ca}/ca_cert.crt"
cdp_url = f"{ca_publish_base_url}/{issuing_ca}/crl.pem"
else:
aia_url = f"{ca_publish_base_url}/ca_cert.crt"
cdp_url = f"{ca_publish_base_url}/crl.pem"
cert_name = cert_subject_name.split(".", 1)[0]
san_entries = [f"DNS:{cert_subject_name}"]
for entry in sans or []:
if _is_ip(entry):
san_entries.append(f"IP:{entry}")
elif _is_dns(entry):
san_entries.append(f"DNS:{entry}")
else:
_err(f"Invalid SAN entry '{entry}'")
return False
sans_ext = "subjectAltName=" + ",".join(san_entries)
print(f"Generating server certificate for '{cert_subject_name}' with SANs:")
for san in san_entries:
print(f" - {san}")
cert_out = os.path.join(cert_dir, f"{cert_name}_cert.pem")
key_out = os.path.join(cert_dir, f"{cert_name}_key.pem")
if not os.path.isfile(cert_out) or not os.path.isfile(key_out):
print("Generating server certificate and key...")
req_cmd = [
OPENSSL, "req",
"-newkey", "rsa:4096",
"-keyout", key_out,
"-noenc",
"-subj", f"/CN={cert_subject_name}",
"-addext", "basicConstraints=critical,CA:FALSE",
"-addext", "keyUsage=critical,digitalSignature,keyEncipherment",
"-addext", "extendedKeyUsage=serverAuth,clientAuth",
"-addext", sans_ext,
]
if aia_url:
req_cmd += ["-addext", f"authorityInfoAccess=caIssuers;URI:{aia_url}"]
if cdp_url:
req_cmd += ["-addext", f"crlDistributionPoints=URI:{cdp_url}"]
x509_cmd = [
OPENSSL, "x509",
"-req",
"-CA", ca_cert_path,
"-CAkey", ca_key_path,
"-copy_extensions", "copyall",
"-days", str(days),
"-text",
"-out", cert_out,
]
if not _pipe(req_cmd, x509_cmd):
_err("Failed to generate server certificate and key.")
return False
_config.append_history(issuing_ca or "ca", {
"name": cert_subject_name,
"serial": _read_serial(cert_out),
"created": _now(),
"expires": _read_expiry(cert_out),
})
return True
def make_pfx(cert_path, ca_dir, issuing_ca=None, password=None, apple_openssl=False):
if issuing_ca == "ca":
_err("--issuing-ca cannot be 'ca' as it is reserved for the root CA.")
return False
cert_dir = os.path.dirname(cert_path)
cert_basename = os.path.basename(cert_path)
cert_name = cert_basename[:-len("_cert.pem")] if cert_basename.endswith("_cert.pem") else cert_basename
key_path = os.path.join(cert_dir, f"{cert_name}_key.pem")
if not cert_dir or not os.path.isdir(cert_dir):
_err(f"Certificate directory {cert_dir} does not exist.")
return False
if not ca_dir or not os.path.isdir(ca_dir):
_err(f"CA directory {ca_dir} does not exist.")
return False
if not os.path.isfile(cert_path) or not os.path.isfile(key_path):
_err("Server certificate or key not found.")
return False
root_ca_cert_path = os.path.join(ca_dir, "ca_cert.pem")
root_ca_key_path = os.path.join(ca_dir, "ca_key.pem")
if not os.path.isfile(root_ca_cert_path) or not os.path.isfile(root_ca_key_path):
_err(f"CA certificate or key not found in {ca_dir}.")
return False
if issuing_ca:
issuing_ca_cert_path = os.path.join(ca_dir, issuing_ca, "ca_cert.pem")
if not os.path.isfile(issuing_ca_cert_path):
_err(f"Issuing CA certificate not found: {issuing_ca_cert_path}.")
return False
if not password:
password = "changeit"
pfx_path = os.path.join(cert_dir, f"{cert_name}.pfx")
if os.path.isfile(pfx_path):
print("PKCS#12 (PFX) file already exists, aborting generation.")
return False
print("Generating PKCS#12 (PFX) file...", end="")
chain_bytes = b""
with open(root_ca_cert_path, "rb") as f:
chain_bytes += f.read()
if issuing_ca:
with open(issuing_ca_cert_path, "rb") as f:
chain_bytes += f.read()
chain_fd, chain_file = tempfile.mkstemp()
try:
with os.fdopen(chain_fd, "wb") as f:
f.write(chain_bytes)
cmd = [
"/usr/bin/openssl" if apple_openssl else OPENSSL, "pkcs12",
"-export", "-out", pfx_path,
"-inkey", key_path,
"-in", cert_path,
"-certfile", chain_file,
"-password", f"pass:{password}",
]
if subprocess.run(cmd).returncode != 0:
_err("Failed to generate PKCS#12 (PFX) file.")
return False
finally:
if os.path.exists(chain_file):
os.remove(chain_file)
print("done.")
return True
def make_crl(ca_dir, issuing_ca=None, days=30):
signing_dir = os.path.join(ca_dir, issuing_ca) if issuing_ca else ca_dir
ca_cert = os.path.join(signing_dir, "ca_cert.pem")
ca_key = os.path.join(signing_dir, "ca_key.pem")
if not os.path.isfile(ca_cert) or not os.path.isfile(ca_key):
_err(f"CA certificate or key not found in {signing_dir}.")
return False
crl_path = os.path.join(signing_dir, "crl.pem")
history_key = issuing_ca or "ca"
revoked_entries = [
e for e in _config.get("history", {}).get(history_key, [])
if "revoked" in e and "expires" in e
]
with tempfile.TemporaryDirectory() as tmp:
index_txt = os.path.join(tmp, "index.txt")
crlnumber = os.path.join(tmp, "crlnumber")
cnf_path = os.path.join(tmp, "openssl.cnf")
with open(index_txt, "w") as f:
for e in revoked_entries:
expires_asn1 = _iso_to_asn1(e["expires"])
revoked_asn1 = _iso_to_asn1(e["revoked"])
f.write(f"R\t{expires_asn1}\t{revoked_asn1}\t{e['serial']}\tunknown\t/CN={e['name']}\n")
with open(crlnumber, "w") as f:
f.write("01\n")
with open(cnf_path, "w") as f:
f.write(
"[ ca ]\ndefault_ca = CA_default\n\n"
"[ CA_default ]\n"
f"database = {index_txt}\n"
f"crlnumber = {crlnumber}\n"
f"certificate = {ca_cert}\n"
f"private_key = {ca_key}\n"
f"default_crl_days = {days}\n"
"default_md = sha256\n\n"
"[ crl_ext ]\n"
"authorityKeyIdentifier = keyid:always\n"
)
if subprocess.run([OPENSSL, "ca", "-gencrl", "-config", cnf_path, "-out", crl_path]).returncode != 0:
_err("Failed to generate CRL.")
return False
print(f"CRL written to {crl_path}")
return True
def revoke_cert(cert_path, ca_dir, issuing_ca=None):
if not os.path.isfile(cert_path):
_err(f"Certificate not found: {cert_path}")
return False
signing_dir = os.path.join(ca_dir, issuing_ca) if issuing_ca else ca_dir
if not os.path.isdir(signing_dir):
_err(f"CA directory not found: {signing_dir}")
return False
ca_key = issuing_ca or "ca"
serial = _read_serial(cert_path)
result = _config.revoke_in_history(ca_key, serial, _now())
if result is None:
_err(f"Certificate with serial {serial} not found in history for CA '{ca_key}'.")
return False
if result is False:
print(f"Certificate {cert_path} (serial {serial}) is already revoked.")
return True
print(f"Certificate {cert_path} (serial {serial}) marked as revoked.")
return True
def _build_parser():
parser = argparse.ArgumentParser(
description="Simple CA for creating and managing test certificates."
)
sub = parser.add_subparsers(dest="command", required=True)
p_ca = sub.add_parser("make-ca", help="Create a root or issuing CA.")
p_ca.add_argument("--days", type=int, default=None, help="Validity period in days (default: 3650)")
p_ca.add_argument("--issuing-ca", default=None, help="Specify the issuing CA")
p_ca.add_argument("--ca-publish-base-url", default=None,
help="Base URL for AIA and CRL distribution point extensions")
p_ca.add_argument("--ca-dir", help="Directory to store the CA files")
p_ca.add_argument("--openssl", default=None, metavar="PATH",
help=f"Path to the openssl binary (default: {OPENSSL})")
p_ca.add_argument("ca_name", help="Name of the CA")
p_cert = sub.add_parser("make-cert", help="Create a server/client certificate.")
p_cert.add_argument("--ca-dir", help="Directory of the CA")
p_cert.add_argument("--issuing-ca", default=None, help="Specify the issuing CA")
p_cert.add_argument("--days", type=int, default=None, help="Validity period in days (default: 365)")
p_cert.add_argument("--cert-dir", help="Directory to store the certificate files")
p_cert.add_argument("--openssl", default=None, metavar="PATH",
help=f"Path to the openssl binary (default: {OPENSSL})")
p_cert.add_argument("subject_name", help="Subject name for the certificate")
p_cert.add_argument("sans", nargs="*", help="Subject Alternative Names (SANs) for the certificate")
p_pfx = sub.add_parser("make-pfx", help="Create a PKCS#12 (PFX) bundle.")
p_pfx.add_argument("--issuing-ca", default=None, help="Specify the issuing CA")
p_pfx.add_argument("--ca-dir", help="Directory of the CA")
p_pfx.add_argument("--password", help="Password for the PFX file")
p_pfx.add_argument("--apple-openssl", action="store_true", default=False,
help="Use Apple's bundled /usr/bin/openssl for PKCS12 generation")
p_pfx.add_argument("path", help="Path to the certificate file")
p_crl = sub.add_parser("make-crl", help="Generate a CRL for a CA.")
p_crl.add_argument("--ca-dir", help="Directory of the CA")
p_crl.add_argument("--issuing-ca", default=None, help="Generate CRL for this issuing CA")
p_crl.add_argument("--days", type=int, default=None, help="CRL validity in days (default: 30)")
p_rev = sub.add_parser("revoke-cert", help="Revoke a certificate.")
p_rev.add_argument("--ca-dir", help="Directory of the CA")
p_rev.add_argument("--issuing-ca", default=None, help="Issuing CA that signed the certificate")
p_rev.add_argument("cert_path", help="Path to the certificate file to revoke")
return parser
def main(argv=None):
global OPENSSL, _config
parser = _build_parser()
args = parser.parse_args(argv)
ca_dir = args.ca_dir or os.environ.get("SIMPLE_CA_DIR") or os.getcwd()
_config = Config(ca_dir)
OPENSSL = getattr(args, "openssl", None) or _config.get("openssl", OPENSSL)
issuing_ca = args.issuing_ca or _config.get("issuing_ca")
ca_publish_base_url = getattr(args, "ca_publish_base_url", None) or _config.get("ca_publish_base_url")
days_cfg = _config.get("days", {})
if args.command == "make-ca":
days = args.days or days_cfg.get("ca", 3650)
ok = make_ca(
ca_dir, args.ca_name,
days=days,
issuing_ca=issuing_ca,
ca_publish_base_url=ca_publish_base_url,
)
elif args.command == "make-cert":
days = args.days or days_cfg.get("cert", 365)
ok = make_cert(
args.subject_name,
sans=args.sans,
ca_dir=ca_dir,
cert_dir=getattr(args, "cert_dir", None),
issuing_ca=issuing_ca,
days=days,
ca_publish_base_url=ca_publish_base_url,
)
elif args.command == "make-pfx":
ok = make_pfx(
args.path, ca_dir,
issuing_ca=issuing_ca,
password=args.password,
apple_openssl=args.apple_openssl,
)
elif args.command == "make-crl":
days = args.days or days_cfg.get("crl", 30)
ok = make_crl(ca_dir, issuing_ca=issuing_ca, days=days)
elif args.command == "revoke-cert":
ok = revoke_cert(args.cert_path, ca_dir, issuing_ca=issuing_ca)
else:
parser.error(f"Unknown command: {args.command}")
ok = False
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,207 @@
"""The tokens of the router's web service, for the web service and vpn-router-token.
Tokens are HS256 JWTs signed with WEB_KEY, a key the web service makes on its first run on the
router and that never leaves it. /auth issues an access token naming the user in sub, valid ACCESS_SECONDS, and
a refresh token valid REFRESH_SECONDS; vpn-router-token issues tokens with the admin claim and
any lifetime. A token without the admin claim must expire within MAX_LIFETIME seconds.
Every failure of the database raises DatabaseError, the only database exception callers see.
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.
"""
import base64
import contextlib
import hashlib
import hmac
import json
import pathlib
import secrets
import subprocess
import time
import psycopg
WEB_KEY = pathlib.Path('/etc/vpn-router/web.key')
DATABASE_SECRET = pathlib.Path('/etc/vpn-router/database.secret')
#: The role and the database provision() creates when web.database is not set.
DATABASE = 'vpn-router'
#: The user operations of vpnrouter_users after which the user's refresh tokens are revoked.
REVOKING = ('passwd', 'disable', 'unlock', 'remove')
#: Seconds an access token of /auth and a refresh token are valid.
ACCESS_SECONDS = 300
REFRESH_SECONDS = 8 * 3600
#: The longest a token without the admin claim may be valid, from now, in seconds.
MAX_LIFETIME = 600
#: Seconds of clock difference accepted between a token and this router.
LEEWAY = 60
SCHEMA = 'CREATE TABLE IF NOT EXISTS refresh_tokens (hash text PRIMARY KEY, name text NOT NULL, expires timestamptz NOT NULL)'
class DatabaseError(Exception):
"""The database of the refresh tokens failed."""
def key():
"""The signing key, or '' while the web service has not made it."""
try:
return WEB_KEY.read_text(encoding='utf-8').strip()
except OSError:
return ''
def ensure_key():
"""Make the signing key when it is missing."""
if not key():
WEB_KEY.parent.mkdir(parents=True, exist_ok=True)
WEB_KEY.touch(mode=0o600)
WEB_KEY.chmod(0o600)
WEB_KEY.write_text(secrets.token_urlsafe(36) + '\n', encoding='utf-8')
def b64encode(data):
return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
def b64decode(part):
return base64.urlsafe_b64decode(part + '=' * (-len(part) % 4))
def sign(claims, secret):
head = b64encode(json.dumps({'alg': 'HS256', 'typ': 'JWT'}).encode())
body = b64encode(json.dumps(claims).encode())
signature = hmac.new(secret.encode(), f'{head}.{body}'.encode(), hashlib.sha256).digest()
return f'{head}.{body}.{b64encode(signature)}'
def verify(token, secret):
"""The claims of a token signed with HS256 by the secret, or None when it is not acceptable.
Only HS256 is accepted, so a token cannot choose a weaker algorithm. The token must expire
(exp); without the admin claim not later than MAX_LIFETIME seconds from now, so that a stolen
token is of little use. LEEWAY seconds of clock difference are allowed.
"""
try:
header, payload, signature = token.split('.')
if json.loads(b64decode(header)).get('alg') != 'HS256':
return None
expected = hmac.new(secret.encode(), f'{header}.{payload}'.encode(), hashlib.sha256).digest()
if not hmac.compare_digest(expected, b64decode(signature)):
return None
claims = json.loads(b64decode(payload))
now = time.time()
expires = claims['exp']
if isinstance(expires, bool) or not isinstance(expires, (int, float)) or expires < now - LEEWAY:
return None
if claims.get('admin') is not True and expires > now + MAX_LIFETIME + LEEWAY:
return None
not_before = claims.get('nbf')
if not_before is not None and (isinstance(not_before, bool) or not isinstance(not_before, (int, float)) or not_before > now + LEEWAY):
return None
except (ValueError, KeyError, TypeError, AttributeError):
return None
return claims
def admin_token(seconds):
"""A token with the admin claim, valid for the seconds given."""
secret = key()
if not secret:
raise OSError(f'{WEB_KEY} does not exist; vpn-router-web makes it when it starts')
now = int(time.time())
return {'token': sign({'admin': True, 'iat': now, 'exp': now + seconds}, secret), 'expires': now + seconds}
def connection_string(cfg):
"""web.database, or the database provision() creates; its password, of URL-safe characters, is written unquoted."""
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}'
@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."""
try:
with psycopg.connect(connection_string(cfg), autocommit=True) as connection:
connection.execute(SCHEMA)
yield connection
except psycopg.Error as exc:
raise DatabaseError(f'the token database failed: {exc}') from exc
def digest(token):
return hashlib.sha256(token.encode()).hexdigest()
def issue(cfg, name):
"""An access token and a refresh token for the user, as /auth answers them."""
secret = key()
now = int(time.time())
refresh = secrets.token_urlsafe(32)
with database(cfg) as connection:
connection.execute('DELETE FROM refresh_tokens WHERE expires < now()')
connection.execute("INSERT INTO refresh_tokens VALUES (%s, %s, now() + make_interval(secs => %s))",
(digest(refresh), name, REFRESH_SECONDS))
return {
'access_token': sign({'sub': name, 'iat': now, 'exp': now + ACCESS_SECONDS}, secret),
'token_type': 'Bearer',
'expires_in': ACCESS_SECONDS,
'refresh_token': refresh,
'refresh_expires_in': REFRESH_SECONDS,
}
def take(cfg, token):
"""Remove an unexpired refresh token and return the user it was issued to, or None."""
if not isinstance(token, str) or not token:
return None
with database(cfg) as connection:
row = connection.execute('DELETE FROM refresh_tokens WHERE hash = %s RETURNING name, expires > now()',
(digest(token),)).fetchone()
return row[0] if row and row[1] else None
def revoke(cfg, name):
"""Remove all refresh tokens of the user."""
with database(cfg) as connection:
connection.execute('DELETE FROM refresh_tokens WHERE name = %s', (name,))
def psql(sql, name):
"""The output of an SQL statement, read from standard input, run by psql as postgres with name as the variable v1."""
args = ['runuser', '-u', 'postgres', '--', 'psql', '-X', '-q', '-t', '-A', '-v', 'ON_ERROR_STOP=1', '-v', f'v1={name}']
result = subprocess.run(args, input=sql, capture_output=True, text=True)
if result.returncode != 0:
raise OSError(f'psql failed: {result.stderr.strip()}')
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.
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():
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)
@@ -0,0 +1,412 @@
"""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
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.
backup.target local writes the staging directory as a tar archive compressed with zstd,
ARCHIVE, to backup.path, removes the archives of this host that the retention settings of the
schedule no longer keep, and with backup.rclone_remote syncs or copies the archives of this host
to the remote of RCLONE_CONFIG. backup.target pbs sends the staging directory to a Proxmox Backup
Server as the archive router.pxar of the snapshot host/<host>/<time>; the server keeps them.
A run holds RUN_LOCK and records itself in STATE: result running, ok, warning (the backup is
made, the cloud copy failed) or failed, with the times, the target, the archive or snapshot,
its size, the error and the cloud copy. restore() puts a backup back and runs setup.
"""
import contextlib
import datetime
import fcntl
import json
import os
import pathlib
import re
import shutil
import socket
import subprocess
import tempfile
import time
import vpnrouter
import vpnrouter_auth
import vpnrouter_ldap
import vpnrouter_users
STATE = pathlib.Path('/var/lib/vpn-router/backup.json')
WORK_DIR = pathlib.Path('/var/lib/vpn-router/backup')
STAGING = WORK_DIR / 'staging'
RESTORE_DIR = WORK_DIR / 'restore'
#: What a restore replaces is kept here, in a directory named after the time of the restore.
BEFORE_RESTORE = WORK_DIR / 'before-restore'
RUN_LOCK = pathlib.Path('/run/vpn-router/backup.lock')
RCLONE_CONFIG = pathlib.Path('/etc/vpn-router/rclone.conf')
PBS_CLIENT = pathlib.Path('/usr/bin/proxmox-backup-client')
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')
#: 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$')
STAMP = '%Y%m%dT%H%M%SZ'
LDIF = 'ldap/vpn-router.ldif'
DUMP = 'postgresql/vpn-router.dump'
PBS_ARCHIVE = 'router.pxar'
#: The period of each retention setting, as strftime formats of local time; weeks are ISO weeks.
PERIODS = {'keep_hourly': '%Y-%m-%d %H', 'keep_daily': '%Y-%m-%d', 'keep_weekly': '%G-%V', 'keep_monthly': '%Y-%m',
'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_RESTARTS = ('wg-quick@wg0.service', 'openvpn-server@vpn-router.service', 'strongswan.service',
'vpn-router-manager.service')
class Failure(Exception):
"""A backup or a restore that cannot be done."""
def now():
"""The local time in ISO 8601, to the second, with its offset."""
return datetime.datetime.now().astimezone().isoformat(timespec='seconds')
def host():
"""The name of this host, which names its archives and its PBS backup group."""
return socket.gethostname().split('.')[0]
def command(args, **kwargs):
"""Run a command; Failure with its standard error when it fails."""
try:
result = subprocess.run(args, capture_output=True, text=True, **kwargs)
except OSError as exc:
raise Failure(f'{args[0]}: {exc.strerror}')
if result.returncode != 0:
raise Failure(f'{" ".join(args[:2])} failed: {result.stderr.strip() or result.stdout.strip()}')
return result.stdout
def write_state(state):
"""Write STATE in one step."""
STATE.parent.mkdir(parents=True, exist_ok=True)
descriptor, name = tempfile.mkstemp(dir=STATE.parent, prefix='.backup.json.')
with os.fdopen(descriptor, 'w', encoding='utf-8') as handle:
handle.write(json.dumps(state, indent=2) + '\n')
os.chmod(name, 0o600)
os.replace(name, STATE)
def running():
"""Whether a run holds RUN_LOCK."""
try:
with open(RUN_LOCK, encoding='utf-8') as handle:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
except FileNotFoundError:
return False
except BlockingIOError:
return True
return False
def state():
"""The state of the last run, {} when there was none; a run that ended without recording it is interrupted."""
try:
recorded = json.loads(STATE.read_text(encoding='utf-8'))
except (OSError, ValueError):
return {}
if recorded.get('result') == 'running' and not running():
recorded['result'] = 'interrupted'
return recorded
@contextlib.contextmanager
def run_lock():
"""Hold RUN_LOCK; Failure when another run holds it."""
RUN_LOCK.parent.mkdir(parents=True, exist_ok=True)
with open(RUN_LOCK, 'a', encoding='utf-8') as handle:
try:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise Failure('another backup or restore is running')
yield
def archives(path):
"""The archives of this host in path, newest first, as [{name, time, size}]; time is the UTC time of the backup."""
found = []
try:
entries = list(os.scandir(path))
except OSError:
return found
for entry in entries:
match = ARCHIVE.match(entry.name)
if match and match['host'] == host() and entry.is_file():
when = datetime.datetime.strptime(match['stamp'], STAMP).replace(tzinfo=datetime.timezone.utc)
found.append({'name': entry.name, 'time': when.isoformat(), 'size': entry.stat().st_size})
return sorted(found, key=lambda item: item['time'], reverse=True)
def kept(found, keeps):
"""The names of the archives the retention settings keep, by the rules of Proxmox Backup Server's prune.
found is newest first. The settings apply in the order of PERIODS: each keeps the newest archive of each of its
last N periods that have archives and no archive a former setting kept, and the older archives of those periods
are removed, so no later setting keeps them.
"""
marks = {}
for option, period in PERIODS.items():
count = keeps.get(option, 0)
if not count:
continue
periods = {moment(item).strftime(period) for item in found if marks.get(item['name']) == 'keep'}
included = set()
for item in found:
if item['name'] in marks:
continue
key = moment(item).strftime(period)
if key in periods:
continue
if key not in included:
if len(included) >= count:
break
included.add(key)
marks[item['name']] = 'keep'
else:
marks[item['name']] = 'remove'
return {name for name, mark in marks.items() if mark == 'keep'}
def moment(item):
"""The local time of an archive of archives()."""
return datetime.datetime.fromisoformat(item['time']).astimezone()
def psk_files(cfg):
"""The psk_file of each site_to_site connection outside the trees of TREES."""
files = []
for connection in cfg['site_to_site'].values():
name = connection['psk_file'].strip()
if name and not any(name == tree or name.startswith(tree + '/') for tree in TREES) and os.path.isfile(name):
files.append(name)
return files
def database_args(cfg):
"""The command prefix and the database argument of pg_dump and pg_restore: postgres and vpn-router, or web.database."""
configured = cfg['web']['database'].strip()
if configured:
return [], [f'--dbname={configured}']
return ['runuser', '-u', 'postgres', '--'], [f'--dbname={vpnrouter_auth.DATABASE}']
def stage(cfg):
"""Copy what a backup holds to STAGING, emptied first, and return the manifest."""
shutil.rmtree(STAGING, ignore_errors=True)
STAGING.mkdir(parents=True, mode=0o700)
trees = [tree for tree in TREES if os.path.exists(tree)]
files = psk_files(cfg)
vpnrouter_users.LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
with vpnrouter.config_lock(), open(vpnrouter_users.LOCK_FILE, 'w') as users:
fcntl.flock(users, fcntl.LOCK_EX)
command(['cp', '-a', '--parents', *trees, str(STAGING)])
if files:
(STAGING / 'files').mkdir()
command(['cp', '-a', '--parents', *files, str(STAGING / 'files')])
(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:
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)
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}
(STAGING / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n', encoding='utf-8')
return manifest
def pbs_environment(cfg):
"""The environment of proxmox-backup-client: the repository, the token secret and the fingerprint of backup."""
if not PBS_CLIENT.exists():
raise Failure(f'{PBS_CLIENT} does not exist; install proxmox-backup-client-static')
backup = cfg['backup']
environment = dict(os.environ, PBS_REPOSITORY=backup['pbs_repository'], PBS_PASSWORD=backup['pbs_password'])
if backup['pbs_fingerprint']:
environment['PBS_FINGERPRINT'] = backup['pbs_fingerprint']
return environment
def pbs_options(cfg):
"""The namespace and key file options of proxmox-backup-client from backup."""
options = []
if cfg['backup']['pbs_namespace']:
options += ['--ns', cfg['backup']['pbs_namespace']]
if cfg['backup']['pbs_keyfile']:
options += ['--keyfile', cfg['backup']['pbs_keyfile']]
return options
def cloud(cfg):
"""Sync or copy the archives of this host in backup.path to backup.rclone_remote; Failure when it fails."""
backup = cfg['backup']
rclone = shutil.which('rclone')
if not rclone:
raise Failure('rclone is not installed')
if not RCLONE_CONFIG.is_file():
raise Failure(f'{RCLONE_CONFIG} does not exist; make the remote with rclone config --config {RCLONE_CONFIG}')
command([rclone, backup['rclone_mode'], backup['path'], backup['rclone_remote'], '--config', str(RCLONE_CONFIG),
'--include', f'vpn-router-{host()}-*.tar.zst'])
def run():
"""Make a backup to backup.target as the configuration says, recording it in STATE; the recorded state."""
cfg = vpnrouter.load_config()
backup = cfg['backup']
started = time.time()
record = {'result': 'running', 'started': now(), 'finished': None, 'target': backup['target'], 'archive': None,
'snapshot': None, 'size': None, 'removed': [], 'error': None, 'cloud': None}
with run_lock():
write_state(record)
try:
stage(cfg)
if backup['target'] == 'pbs':
epoch = int(started)
command([str(PBS_CLIENT), 'backup', f'{PBS_ARCHIVE}:{STAGING}', '--backup-type', 'host',
'--backup-id', host(), '--backup-time', str(epoch), *pbs_options(cfg)],
env=pbs_environment(cfg))
stamp = datetime.datetime.fromtimestamp(epoch, datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
record['snapshot'] = f'host/{host()}/{stamp}'
record['size'] = sum(path.stat().st_size for path in STAGING.rglob('*') if path.is_file())
else:
path = pathlib.Path(backup['path'])
path.mkdir(parents=True, mode=0o700, exist_ok=True)
name = f'vpn-router-{host()}-{time.strftime(STAMP, time.gmtime(started))}.tar.zst'
partial = path / f'{name}.partial'
command(['tar', '--zstd', '-cf', str(partial), '-C', str(STAGING), '.'])
partial.chmod(0o600)
partial.rename(path / name)
record['archive'] = name
record['size'] = (path / name).stat().st_size
found = archives(path)
keep = kept(found, {option: backup[option] for option in vpnrouter.backup_keeps(backup['schedule'])})
for item in found:
if item['name'] not in keep:
(path / item['name']).unlink()
record['removed'].append(item['name'])
if backup['rclone_remote']:
record['cloud'] = {'remote': backup['rclone_remote'], 'mode': backup['rclone_mode'],
'result': 'ok', 'error': None}
try:
cloud(cfg)
except Failure as exc:
record['cloud'].update(result='failed', error=str(exc))
record['result'] = 'warning' if record['cloud'] and record['cloud']['result'] != 'ok' else 'ok'
except (Failure, OSError) as exc:
record['result'] = 'failed'
record['error'] = str(exc)
finally:
shutil.rmtree(STAGING, ignore_errors=True)
record['finished'] = now()
write_state(record)
return record
def snapshots(cfg):
"""The PBS snapshots of this host, as proxmox-backup-client lists them."""
namespace = cfg['backup']['pbs_namespace']
return command([str(PBS_CLIENT), 'snapshot', 'list', f'host/{host()}', *(['--ns', namespace] if namespace else [])],
env=pbs_environment(cfg))
def extract(cfg, source, pbs):
"""Unpack a local archive, or with pbs fetch a snapshot, to RESTORE_DIR, emptied first; the manifest."""
shutil.rmtree(RESTORE_DIR, ignore_errors=True)
RESTORE_DIR.mkdir(parents=True, mode=0o700)
if pbs:
command([str(PBS_CLIENT), 'restore', source, PBS_ARCHIVE, str(RESTORE_DIR), *pbs_options(cfg)],
env=pbs_environment(cfg))
else:
command(['tar', '--zstd', '-xf', source, '-C', str(RESTORE_DIR)])
try:
return json.loads((RESTORE_DIR / 'manifest.json').read_text(encoding='utf-8'))
except (OSError, ValueError):
raise Failure(f'{source} is not a backup of vpn-router-backup: it has no manifest.json')
def put_back(relative, target, saved):
"""Move target to saved, keeping its path, and copy the staged relative path of RESTORE_DIR in its place."""
target = pathlib.Path(target)
if target.exists() or target.is_symlink():
keep = saved / target.relative_to('/')
keep.parent.mkdir(parents=True, exist_ok=True)
shutil.move(target, keep)
target.parent.mkdir(parents=True, exist_ok=True)
command(['cp', '-a', str(RESTORE_DIR / relative), str(target)])
def restore(source, pbs, confirm):
"""Put the backup back: a local archive, or with pbs a snapshot; confirm(manifest) is asked before any change.
What is replaced is kept in BEFORE_RESTORE. The services of the directory and the database are stopped while they
are replaced, and setup applies the configuration afterwards. Returns the directory of what was replaced, or None
when confirm declined.
"""
cfg = vpnrouter.load_config()
with run_lock():
try:
manifest = extract(cfg, source, pbs)
if not confirm(manifest):
return None
saved = BEFORE_RESTORE / time.strftime(STAMP, time.gmtime())
saved.mkdir(parents=True, mode=0o700)
for unit in RESTORE_STOPS:
subprocess.run(['systemctl', 'stop', unit], capture_output=True)
for item in manifest['items']:
if item.startswith('/'):
put_back(item.lstrip('/'), item, saved)
elif item.startswith('files/'):
put_back(item, item[len('files'):], saved)
if LDIF in manifest['items']:
directory = vpnrouter_ldap.DB_DIRECTORY
if directory.exists():
shutil.move(directory, saved / 'ldap')
directory.mkdir(mode=0o700)
command(['slapadd', '-b', vpnrouter_ldap.BASE, '-l', str(RESTORE_DIR / LDIF)])
command(['chown', '-R', 'openldap:openldap', str(directory)])
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.provision(restored)
if DUMP in manifest['items']:
prefix, database = database_args(restored)
with open(RESTORE_DIR / DUMP, '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()}')
subprocess.run(['systemctl', 'start', 'vpn-router-web.service'], capture_output=True)
command(['systemctl', 'restart', 'vpn-router-setup.service'])
for unit in RESTORE_RESTARTS:
subprocess.run(['systemctl', 'try-restart', unit], capture_output=True)
subprocess.run(['ufw', 'reload'], capture_output=True)
return saved
finally:
shutil.rmtree(RESTORE_DIR, ignore_errors=True)
@@ -0,0 +1,344 @@
"""The router's user directory in the local slapd.
The package keeps its own database, dc=vpn-router,dc=local in /var/lib/ldap/vpn-router,
next to the one slapd's installation creates. Root manages it through the local socket
with SASL EXTERNAL: the database's root DN is root's peer-credential DN, so there is no
administrator password. Users and groups are changed by the user-manager service account,
which the ACLs allow to write ou=users and ou=groups and to set, never read, passwords and
TOTP secrets.
The password policy locks an account for LOCKOUT_SECONDS after LOCKOUT_FAILURES failed binds;
a successful bind clears the count. slapd's otp overlay is configured on the database, so a
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.
"""
import os
import pathlib
import pwd
import re
import secrets
import ldap
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}'
SERVICES = f'ou=services,{BASE}'
POLICIES = f'ou=policies,{BASE}'
PKI = f'ou=pki,{BASE}'
DEFAULT_POLICY = f'cn=default,{POLICIES}'
TOTP_PARAMS = f'cn=totp,{POLICIES}'
MANAGER = f'cn=user-manager,{SERVICES}'
ROOT = 'gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth'
DB_DIRECTORY = pathlib.Path('/var/lib/ldap/vpn-router')
SECRET_DIR = pathlib.Path('/etc/vpn-router/ldap')
MANAGER_SECRET = SECRET_DIR / 'user-manager.secret'
SLAPD_DEFAULTS = pathlib.Path('/etc/default/slapd')
SLAPD_SERVICES = 'SLAPD_SERVICES="ldapi:///"'
MODULES = ('argon2', 'ppolicy', 'otp')
PASSWORD_HASH = '{ARGON2}'
#: Failed binds that lock an account, within LOCKOUT_SECONDS, and the seconds it stays locked.
LOCKOUT_FAILURES = 5
LOCKOUT_SECONDS = 900
#: The overlays of the database, in the order they are configured.
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, 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>...]". 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
#: serial number: the certificate, the key the router keeps for its own and the users'
#: certificates, its kind, template, names and expiry, and its revocation.
SCHEMA_OID = '2.25.98477352595735834532404256531620013001'
SCHEMA_NAME = 'vpn-router'
_STRING = 'EQUALITY caseIgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} SINGLE-VALUE'
_TIME = 'EQUALITY generalizedTimeMatch ORDERING generalizedTimeOrderingMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 SINGLE-VALUE'
_OCTETS = 'EQUALITY octetStringMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.40 SINGLE-VALUE'
SCHEMA_ATTRIBUTE_TYPES = (
f"( {SCHEMA_OID}.1.1 NAME 'vpnRouterScope' DESC 'VPN router: an authorization scope of the account' "
"EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} )",
f"( {SCHEMA_OID}.1.2 NAME 'vpnRouterPrivateKey' DESC 'VPN router: a private key, PEM' {_OCTETS} )",
f"( {SCHEMA_OID}.1.3 NAME 'vpnRouterCertificateRequest' DESC 'VPN router: the CSR of a CA, PEM' {_OCTETS} )",
f"( {SCHEMA_OID}.1.4 NAME 'vpnRouterCRLNumber' DESC 'VPN router: the number of the last CRL of a CA' "
"EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE )",
f"( {SCHEMA_OID}.1.5 NAME 'vpnRouterCRLSource' DESC 'VPN router: signed or uploaded, where the CRL of a CA came from' {_STRING} )",
f"( {SCHEMA_OID}.1.6 NAME 'vpnRouterRetired' DESC 'VPN router: when a CA was retired' {_TIME} )",
f"( {SCHEMA_OID}.1.7 NAME 'vpnRouterKind' DESC 'VPN router: ca, router, user or external, the kind of a certificate' {_STRING} )",
f"( {SCHEMA_OID}.1.8 NAME 'vpnRouterTemplate' DESC 'VPN router: the template of a certificate' {_STRING} )",
f"( {SCHEMA_OID}.1.9 NAME 'vpnRouterSubjectAltName' DESC 'VPN router: a subject alternative name of a certificate' "
"EQUALITY caseIgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} )",
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} )",
)
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 $ vpnRouterConsoleLogin $ "
"vpnRouterConsoleAccount $ vpnRouterWireGuardPeer ) )",
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 ) )",
f"( {SCHEMA_OID}.2.3 NAME 'vpnRouterCertificate' DESC 'VPN router: a certificate a CA of the PKI issued' SUP top "
"STRUCTURAL MUST serialNumber MAY ( cn $ userCertificate $ vpnRouterPrivateKey $ vpnRouterKind $ vpnRouterTemplate $ "
"vpnRouterSubjectAltName $ vpnRouterNotAfter $ vpnRouterRevoked $ vpnRouterRevocationReason ) )",
)
#: The TOTP parameters: digits, the OID of HMAC-SHA1, seconds per time step, and the time steps accepted before and after now.
TOTP_DIGITS = 6
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',
f'to attrs=userPassword by dn.exact="{MANAGER}" =wx by self =wx by anonymous auth by * none',
f'to attrs=oathSecret by dn.exact="{MANAGER}" =w by * none',
f'to dn.subtree="{USERS}" by dn.exact="{MANAGER}" manage by * read',
f'to dn.subtree="{GROUPS}" by dn.exact="{MANAGER}" manage by * read',
'to * by * read',
)
#: The index of an ordered cn=config value: {0}, {1}, ...
ORDER = re.compile(rb'^\{\d+\}')
def unordered(values):
"""Values of an ordered cn=config attribute without their {n} prefixes."""
return [ORDER.sub(b'', value).decode() for value in values]
def root():
"""A connection to slapd as root, through the local socket."""
connection = ldap.initialize(URI)
connection.sasl_interactive_bind_s('', ldap.sasl.external())
return connection
def manager():
"""A connection to slapd as the user-manager service account."""
connection = ldap.initialize(URI)
connection.simple_bind_s(MANAGER, MANAGER_SECRET.read_text(encoding='utf-8'))
return connection
def ensure_services():
"""Make slapd listen on the local socket only. Returns True when the setting changed."""
text = SLAPD_DEFAULTS.read_text(encoding='utf-8')
lines = text.splitlines(keepends=True)
updated = ''.join(SLAPD_SERVICES + '\n' if line.startswith('SLAPD_SERVICES=') else line for line in lines)
if not any(line.startswith('SLAPD_SERVICES=') for line in lines):
updated = text.rstrip('\n') + '\n' + SLAPD_SERVICES + '\n'
if updated == text:
return False
SLAPD_DEFAULTS.write_text(updated, encoding='utf-8')
return True
def _ensure_modules(connection):
dn, attributes = connection.search_s('cn=config', ldap.SCOPE_ONELEVEL, '(objectClass=olcModuleList)', ['olcModuleLoad'])[0]
loaded = unordered(attributes.get('olcModuleLoad', []))
missing = [name for name in MODULES if name not in loaded and f'{name}.la' not in loaded]
if missing:
connection.modify_s(dn, [(ldap.MOD_ADD, 'olcModuleLoad', [name.encode() for name in missing])])
def _ensure_schema(connection):
"""Add the package's schema to cn=config when missing, else add the definitions it lacks; a definition that differs
from the one of the same OID replaces the whole list of its kind."""
found = connection.search_s('cn=schema,cn=config', ldap.SCOPE_ONELEVEL, f'(cn=*{SCHEMA_NAME})',
['olcAttributeTypes', 'olcObjectClasses'])
attributes = {'olcAttributeTypes': list(SCHEMA_ATTRIBUTE_TYPES), 'olcObjectClasses': list(SCHEMA_OBJECT_CLASSES)}
if not found:
connection.add_s(f'cn={SCHEMA_NAME},cn=schema,cn=config', ldap.modlist.addModlist(
dict({name: [value.encode() for value in values] for name, values in attributes.items()},
objectClass=[b'olcSchemaConfig'], cn=[SCHEMA_NAME.encode()])))
return
dn, current = found[0]
for name, wanted in attributes.items():
present = unordered(current.get(name, []))
oids = {value.split()[1] for value in present}
missing = [value for value in wanted if value not in present]
if not missing:
continue
if any(value.split()[1] in oids for value in missing):
connection.modify_s(dn, [(ldap.MOD_REPLACE, name, [value.encode() for value in wanted])])
else:
connection.modify_s(dn, [(ldap.MOD_ADD, name, [value.encode() for value in missing])])
def _ensure_password_hash(connection):
dn = 'olcDatabase={-1}frontend,cn=config'
current = connection.search_s(dn, ldap.SCOPE_BASE, attrlist=['olcPasswordHash'])[0][1].get('olcPasswordHash', [])
if current != [PASSWORD_HASH.encode()]:
connection.modify_s(dn, [(ldap.MOD_REPLACE, 'olcPasswordHash', [PASSWORD_HASH.encode()])])
def _ensure_database(connection):
"""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')
os.chown(DB_DIRECTORY, openldap.pw_uid, openldap.pw_gid)
connection.add_s('olcDatabase=mdb,cn=config', ldap.modlist.addModlist({
'objectClass': [b'olcDatabaseConfig', b'olcMdbConfig'],
'olcDatabase': [b'mdb'],
'olcSuffix': [BASE.encode()],
'olcDbDirectory': [str(DB_DIRECTORY).encode()],
'olcRootDN': [ROOT.encode()],
'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', '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
def _ensure_overlays(connection, database):
"""Add the overlays of OVERLAYS the database lacks; the otp overlay has no settings of its own."""
found = connection.search_s(database, ldap.SCOPE_ONELEVEL, '(objectClass=olcOverlayConfig)', ['olcOverlay'])
present = {ORDER.sub(b'', attributes['olcOverlay'][0]).decode() for _, attributes in found}
for name in OVERLAYS:
if name in present:
continue
record = {'objectClass': [b'olcOverlayConfig'], 'olcOverlay': [name.encode()]}
if name == 'ppolicy':
record = {'objectClass': [b'olcOverlayConfig', b'olcPPolicyConfig'], 'olcOverlay': [b'ppolicy'],
'olcPPolicyDefault': [DEFAULT_POLICY.encode()]}
connection.add_s(f'olcOverlay={name},{database}', ldap.modlist.addModlist(record))
def _ensure_entry(connection, dn, attributes):
"""Add the entry when it is missing, else set the given attributes that differ."""
try:
current = connection.search_s(dn, ldap.SCOPE_BASE, attrlist=list(attributes))[0][1]
except ldap.NO_SUCH_OBJECT:
connection.add_s(dn, ldap.modlist.addModlist(attributes))
return
changes = [(ldap.MOD_REPLACE, name, values) for name, values in attributes.items()
if name != 'objectClass' and sorted(current.get(name, [])) != sorted(values)]
if changes:
connection.modify_s(dn, changes)
def _ensure_manager(connection):
SECRET_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(SECRET_DIR, 0o700)
created = False
try:
connection.search_s(MANAGER, ldap.SCOPE_BASE, attrlist=['1.1'])
except ldap.NO_SUCH_OBJECT:
connection.add_s(MANAGER, ldap.modlist.addModlist({
'objectClass': [b'organizationalRole', b'simpleSecurityObject'],
'cn': [b'user-manager'],
'userPassword': [b'{CRYPT}!'],
}))
created = True
if created or not MANAGER_SECRET.is_file():
password = secrets.token_urlsafe(32)
connection.passwd_s(MANAGER, None, password)
MANAGER_SECRET.touch(mode=0o600)
MANAGER_SECRET.chmod(0o600)
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()
try:
_ensure_modules(connection)
_ensure_schema(connection)
_ensure_password_hash(connection)
database = _ensure_database(connection)
_ensure_overlays(connection, database)
_ensure_entry(connection, BASE, {'objectClass': [b'dcObject', b'organization'], 'dc': [b'vpn-router'], 'o': [b'vpn-router']})
for unit in (USERS, GROUPS, SERVICES, POLICIES, PKI):
_ensure_entry(connection, unit, {'objectClass': [b'organizationalUnit'], 'ou': [unit.split(',')[0][3:].encode()]})
_ensure_entry(connection, DEFAULT_POLICY, {
'objectClass': [b'organizationalRole', b'pwdPolicy'],
'cn': [b'default'],
'pwdAttribute': [b'userPassword'],
'pwdLockout': [b'TRUE'],
'pwdMaxFailure': [str(LOCKOUT_FAILURES).encode()],
'pwdLockoutDuration': [str(LOCKOUT_SECONDS).encode()],
'pwdFailureCountInterval': [str(LOCKOUT_SECONDS).encode()],
})
_ensure_entry(connection, TOTP_PARAMS, {
'objectClass': [b'organizationalRole', b'oathTOTPParams'],
'cn': [b'totp'],
'oathOTPLength': [str(TOTP_DIGITS).encode()],
'oathHMACAlgorithm': [TOTP_ALGORITHM.encode()],
'oathTOTPTimeStepPeriod': [str(TOTP_PERIOD).encode()],
'oathTOTPTimeStepWindow': [str(TOTP_WINDOW).encode()],
})
_ensure_manager(connection)
finally:
connection.unbind_s()
@@ -0,0 +1,207 @@
"""The metrics of the router in PostgreSQL, for the metrics service and the web service.
The metrics service writes one detail row per minute to the four detail tables, of TABLES:
metrics_system (CPU, memory, disk and load averages), metrics_network (bytes per second
received and sent per interface), metrics_vpn (clients per kind) and metrics_site_to_site (per
site-to-site connection: up, 1 or 0, bytes and packets per second received and sent, and the
seconds its IKE SA has been up). Each detail table has four
summary tables, of LEVELS: <table>_hourly from the details, <table>_daily from the hourly,
<table>_monthly from the daily and <table>_yearly from the monthly rows. A summary row has the
start of its period, in UTC, and the average and the maximum of each value over the period: of
the details for the hourly rows, else the average of the averages and the maximum of the maxima
below. A period is summarized once it is complete; summarize() catches up every complete period
after the last summarized one, so a stopped service loses no summary of what was written.
collect_garbage() deletes the rows older than the retention of their level, metrics.keep_*;
the service runs it on start and once a day. history() answers the views of the Metrics tab.
The tables are in the database of the refresh tokens (vpnrouter_auth.connection_string) and are
created when missing. Every failure of the database raises DatabaseError.
"""
import contextlib
import psycopg
import vpnrouter_auth
#: The detail tables: their key columns besides time, and their value columns.
TABLES = {
'metrics_system': ((), ('cpu', 'memory_used', 'memory_total', 'disk_free', 'disk_total', 'load1', 'load5', 'load15')),
'metrics_network': (('interface',), ('rx', 'tx')),
'metrics_vpn': (('kind',), ('clients',)),
'metrics_site_to_site': (('connection',), ('up', 'rx', 'tx', 'packets_rx', 'packets_tx', 'uptime')),
}
#: The summary levels, from the finest: the suffix of the tables, the period of date_trunc and the retention setting.
LEVELS = (
('hourly', 'hour', 'keep_hourly'),
('daily', 'day', 'keep_daily'),
('monthly', 'month', 'keep_monthly'),
('yearly', 'year', 'keep_yearly'),
)
#: The kinds of VPN clients of metrics_vpn.
VPN_KINDS = ('p2s', 'openvpn', 'wireguard')
#: The time slots of the site-to-site availability of each view of history(): the summary level read (None: the
#: details), the slot as PostgreSQL starts it from time, and its length.
UP_SLOTS = {
'hour': (None, "date_bin('5 minutes', time, timestamptz '2000-01-01 00:00:00+00')", '5 minutes'),
'day': (None, "date_trunc('hour', time, 'UTC')", '1 hour'),
'month': ('hourly', "date_trunc('day', time, 'UTC')", '1 day'),
'year': ('monthly', "date_trunc('month', time, 'UTC')", '1 month'),
}
#: The views of history(): the summary level read (None: the details), the bucket and the span.
RANGES = {
'hour': (None, '1 minute', '1 hour'),
'day': (None, '10 minutes', '1 day'),
'month': ('hourly', '6 hours', '1 month'),
'year': ('monthly', '1 month', '1 year'),
}
class DatabaseError(Exception):
"""The metrics database failed."""
def schema():
"""The statements that create the detail and the summary tables."""
statements = []
for table, (keys, values) in TABLES.items():
key_columns = ''.join(f', {key} text NOT NULL' for key in keys)
primary = ', '.join(('time', *keys))
detail = ''.join(f', {value} double precision' for value in values)
statements.append(f'CREATE TABLE IF NOT EXISTS {table} (time timestamptz NOT NULL{key_columns}{detail}, '
f'PRIMARY KEY ({primary}))')
summary = ''.join(f', {value}_avg double precision, {value}_max double precision' for value in values)
for suffix, _, _ in LEVELS:
statements.append(f'CREATE TABLE IF NOT EXISTS {table}_{suffix} (time timestamptz NOT NULL{key_columns}{summary}, '
f'PRIMARY KEY ({primary}))')
return statements
@contextlib.contextmanager
def database(cfg):
"""A connection to the metrics database, with its tables, closed at the end; DatabaseError on any failure."""
try:
with psycopg.connect(vpnrouter_auth.connection_string(cfg), autocommit=True) as connection:
for statement in schema():
connection.execute(statement)
yield connection
except (psycopg.Error, OSError) as exc:
raise DatabaseError(f'the metrics database failed: {exc}') from exc
def write(connection, system, network, vpn, site_to_site):
"""Write a sample as the detail rows of the current minute.
system maps the values of metrics_system to numbers; network maps an interface to its rx and
tx; vpn maps a kind of VPN_KINDS to its clients; site_to_site maps a connection to the values
of metrics_site_to_site. A value that is None is written as NULL.
"""
with connection.transaction():
minute = connection.execute("SELECT date_trunc('minute', now(), 'UTC')").fetchone()[0]
names = TABLES['metrics_system'][1]
connection.execute(f"INSERT INTO metrics_system (time, {', '.join(names)}) "
f"VALUES (%s{', %s' * len(names)}) ON CONFLICT DO NOTHING",
(minute, *(system.get(name) for name in names)))
for interface, (rx, tx) in network.items():
connection.execute('INSERT INTO metrics_network (time, interface, rx, tx) VALUES (%s, %s, %s, %s) '
'ON CONFLICT DO NOTHING', (minute, interface, rx, tx))
for kind, clients in vpn.items():
connection.execute('INSERT INTO metrics_vpn (time, kind, clients) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING',
(minute, kind, clients))
names = TABLES['metrics_site_to_site'][1]
for name, values in site_to_site.items():
connection.execute(f"INSERT INTO metrics_site_to_site (time, connection, {', '.join(names)}) "
f"VALUES (%s, %s{', %s' * len(names)}) ON CONFLICT DO NOTHING",
(minute, name, *(values.get(value) for value in names)))
def summarize(connection, level):
"""Summarize the complete periods of a level of LEVELS that are not summarized yet, for every detail table."""
index = [suffix for suffix, _, _ in LEVELS].index(level)
_, period, _ = LEVELS[index]
for table, (keys, values) in TABLES.items():
target = f'{table}_{level}'
source = table if index == 0 else f'{table}_{LEVELS[index - 1][0]}'
if index == 0:
aggregates = ', '.join(f'avg({value}), max({value})' for value in values)
else:
aggregates = ', '.join(f'avg({value}_avg), max({value}_max)' for value in values)
columns = ', '.join(('time', *keys, *(f'{value}_{kind}' for value in values for kind in ('avg', 'max'))))
groups = ''.join(f', {key}' for key in keys)
connection.execute(
f"INSERT INTO {target} ({columns}) "
f"SELECT date_trunc('{period}', time, 'UTC') AS period{groups}, {aggregates} FROM {source} "
f"WHERE time < date_trunc('{period}', now(), 'UTC') "
f"AND time >= coalesce((SELECT max(time) FROM {target}) + interval '1 {period}', '-infinity') "
f"GROUP BY period{groups} ON CONFLICT DO NOTHING")
def collect_garbage(connection, cfg):
"""Delete the rows older than the retention of their level: metrics.keep_details, keep_hourly and so on."""
metrics = cfg['metrics']
for table in TABLES:
connection.execute(f'DELETE FROM {table} WHERE time < now() - %s::interval', (metrics['keep_details'],))
for suffix, _, keep in LEVELS:
connection.execute(f'DELETE FROM {table}_{suffix} WHERE time < now() - %s::interval', (metrics[keep],))
def history(connection, view):
"""The points of a view of RANGES, oldest first, each with the start of its bucket in Unix seconds.
A point has the average and the maximum of each value of metrics_system, network, which maps
each interface to its rx and tx, clients, which maps each kind of VPN client to its clients,
and site_to_site, which maps each site-to-site connection to the values of
metrics_site_to_site; a value without data is None.
site_to_site_up is the availability of each site-to-site connection in the time slots of UP_SLOTS, oldest first:
for each slot its start in Unix seconds and, per connection, the percentage of its samples in which the connection
was up, 0 to 100; the summaries' averages of up give it for the month and the year.
"""
level, bucket, span = RANGES[view]
up_level, up_start, up_slot = UP_SLOTS[view]
def rows(table):
keys, values = TABLES[table]
source = table if level is None else f'{table}_{level}'
if level is None:
aggregates = ', '.join(f'avg({value}), max({value})' for value in values)
else:
aggregates = ', '.join(f'avg({value}_avg), max({value}_max)' for value in values)
start = (f"date_bin('{bucket}', time, timestamptz '2000-01-01 00:00:00+00')" if view != 'year'
else "date_trunc('month', time, 'UTC')")
groups = ''.join(f', {key}' for key in keys)
return connection.execute(
f"SELECT extract(epoch FROM {start})::bigint AS bucket{groups}, {aggregates} FROM {source} "
f"WHERE time >= now() - interval '{span}' GROUP BY bucket{groups} ORDER BY bucket").fetchall()
def pairs(row, values):
return {value: {'avg': row[2 * index], 'max': row[2 * index + 1]} for index, value in enumerate(values)}
points = {}
def point(start):
return points.setdefault(start, {'time': start, 'network': {}, 'clients': {}, 'site_to_site': {}})
for row in rows('metrics_system'):
point(row[0]).update(pairs(row[1:], TABLES['metrics_system'][1]))
for row in rows('metrics_network'):
point(row[0])['network'][row[1]] = pairs(row[2:], TABLES['metrics_network'][1])
for row in rows('metrics_vpn'):
point(row[0])['clients'][row[1]] = pairs(row[2:], TABLES['metrics_vpn'][1])['clients']
for row in rows('metrics_site_to_site'):
point(row[0])['site_to_site'][row[1]] = pairs(row[2:], TABLES['metrics_site_to_site'][1])
empty = {value: {'avg': None, 'max': None} for value in TABLES['metrics_system'][1]}
slots = {}
for start, name, percent in connection.execute(
f"SELECT extract(epoch FROM {up_start})::bigint AS slot, connection, avg({'up' if up_level is None else 'up_avg'}) * 100 "
f"FROM {'metrics_site_to_site' if up_level is None else f'metrics_site_to_site_{up_level}'} "
f"WHERE time >= now() - interval '{span}' GROUP BY slot, connection ORDER BY slot").fetchall():
slots.setdefault(start, {'time': start, 'connections': {}})['connections'][name] = percent
return {'range': view, 'bucket': bucket, 'points': [{**empty, **points[start]} for start in sorted(points)],
'site_to_site_up': {'slot': up_slot, 'points': [slots[start] for start in sorted(slots)]}}
@@ -0,0 +1,176 @@
"""Interfaces and addresses of the router, read from the system.
Only the Python standard library is used, so the web service and its bootstrap server can use
this module too.
"""
import pathlib
import subprocess
#: The values of web.listen and of the Router Manager's listen: the interfaces a service listens on.
LISTEN_CHOICES = ('internal', 'external', 'both')
#: The interfaces a service listens on when it is not set.
DEFAULT_LISTEN = 'both'
#: The address every service also listens on, where the HTTPS proxy calls it.
LOOPBACK = '127.0.0.1'
class ConfigError(Exception):
"""A setting is present but malformed."""
def ip_output(*args):
"""The output of an ip command, or '' when it fails."""
try:
result = subprocess.run(['ip', *args], capture_output=True, text=True)
except OSError:
return ''
return result.stdout if result.returncode == 0 else ''
def detect_interfaces():
"""Work out the external and internal interface names.
Only reached in auto mode, where the operator has accepted that a wrong
guess is possible. The external interface is whichever one carries the
default route; the internal one is the only other addressed interface.
"""
tokens = ip_output('route', 'get', '1.1.1.1').split()
external = ''
for index, token in enumerate(tokens):
if token == 'dev' and index + 1 < len(tokens):
external = tokens[index + 1]
break
if not external:
raise ConfigError('auto mode: cannot determine the external interface')
candidates = [name for name in _addressed_interfaces() if name != external]
if len(candidates) != 1:
raise ConfigError(
f'auto mode: expected one internal interface, found {len(candidates)}; '
'use mode = interfaces and name them')
return external, candidates[0]
def _is_virtual(name):
"""True for an interface the kernel creates without a device behind it.
Loopback, bridges, veth pairs and tunnels live under /sys/devices/virtual,
which is where the Docker bridge is; a NIC of the virtual machine does not.
"""
link = pathlib.Path('/sys/class/net') / name
return '/devices/virtual/' in str(link.resolve())
def _addressed_interfaces():
"""Names of the NICs holding at least one IPv4 address; virtual interfaces are left out."""
names = []
for line in ip_output('-o', '-4', 'addr', 'show').splitlines():
fields = line.split()
if len(fields) >= 4 and fields[1] not in names and not _is_virtual(fields[1]):
names.append(fields[1])
return names
def interface_addresses(name, setting):
"""IPv4 addresses configured on an interface.
The interface name is configuration; its addresses are read from the
system. Looking up the addresses of a named interface is deterministic -
unlike working out which interface to use, which is a guess the package
deliberately does not make.
"""
if not name:
return []
if not (pathlib.Path('/sys/class/net') / name).exists():
raise ConfigError(f'{setting}: interface {name!r} does not exist')
result = subprocess.run(
['ip', '-o', '-4', 'addr', 'show', 'dev', name],
capture_output=True, text=True,
)
if result.returncode != 0:
raise ConfigError(f'{setting}: cannot read addresses of {name!r}')
addresses = []
for line in result.stdout.splitlines():
fields = line.split()
if len(fields) >= 4:
addresses.append(fields[3].split('/')[0])
return addresses
def internal_address(mode, internal, int_addr):
"""The router's address on the internal network, as general.mode directs, or '' when it is not known yet.
manual takes local.int_addr; interfaces reads the address of interfaces.internal; auto also
works out which interface that is.
"""
if mode == 'manual':
return int_addr
try:
if mode == 'auto':
internal = detect_interfaces()[1]
addresses = interface_addresses(internal, 'interfaces.internal')
except ConfigError:
return ''
return addresses[0] if addresses else ''
def external_address(mode, external):
"""The router's address on the external interface, as general.mode directs, or '' when it is not known yet.
auto works out which interface that is; the other modes read the address of interfaces.external.
"""
try:
if mode == 'auto':
external = detect_interfaces()[0]
addresses = interface_addresses(external, 'interfaces.external')
except ConfigError:
return ''
return addresses[0] if addresses else ''
def _section(configuration, name):
"""A section of a configuration, or {} when it is missing or not an object."""
value = configuration.get(name)
return value if isinstance(value, dict) else {}
def listen_addresses(configuration, choice):
"""The addresses a service listens on for choice of LISTEN_CHOICES, from vpn-router.json read as configuration.
The internal address comes first; an address not known yet is left out. A choice that is not
one of LISTEN_CHOICES is DEFAULT_LISTEN. LOOPBACK is not among them; web_listen adds it.
"""
if choice not in LISTEN_CHOICES:
choice = DEFAULT_LISTEN
general, interfaces, local = (_section(configuration, name) for name in ('general', 'interfaces', 'local'))
mode = str(general.get('mode') or 'manual')
addresses = []
if choice in ('internal', 'both'):
addresses.append(internal_address(mode, str(interfaces.get('internal') or ''), str(local.get('int_addr') or '')))
if choice in ('external', 'both'):
addresses.append(external_address(mode, str(interfaces.get('external') or '')))
return [address for address in addresses if address]
def web_listen(configuration, default_port):
"""Where the web service listens, from vpn-router.json read as configuration, as ([(address, port), ...], '').
The addresses are those of the interfaces web.listen names (listen_addresses), then
LOOPBACK, the port web.port, else default_port. While web.enabled is false or no address of
an interface is known, the answer is ([], the reason).
"""
web = _section(configuration, 'web')
if web.get('enabled', True) is False:
return [], 'the web service is disabled in the configuration'
port = web.get('port', default_port)
if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
port = default_port
addresses = listen_addresses(configuration, web.get('listen', DEFAULT_LISTEN))
if not addresses:
return [], 'the addresses to listen on are not known yet'
return [(address, port) for address in [*addresses, LOOPBACK]], ''
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,46 @@
"""The listening loop of the web service and of its bootstrap server.
Only the Python standard library is used, so the bootstrap server, which must run whatever else
is broken, can use it.
"""
import threading
import time
from http.server import ThreadingHTTPServer
def serve(wanted, handler, log, interval):
"""Work out where to listen every interval seconds with wanted, and run an HTTP server of handler at each place.
wanted answers ([(address, port), ...], '') or ([], the reason not to listen). A server whose
place is no longer wanted is stopped, and a new one listens at each new place; a place that
cannot be bound is tried again the next round. Every new message is logged with log. Does
not return.
"""
servers, reported = {}, None
while True:
targets, reason = wanted()
for target in [target for target in servers if target not in targets]:
server, thread = servers.pop(target)
server.shutdown()
server.server_close()
thread.join()
messages = [reason] if reason else []
for target in targets:
if target in servers:
continue
try:
server = ThreadingHTTPServer(target, handler)
except OSError as exc:
messages.append(f'cannot listen on {target[0]}:{target[1]}: {exc}')
continue
thread = threading.Thread(target=server.serve_forever, name=f'server {target[0]}', daemon=True)
thread.start()
servers[target] = (server, thread)
if servers:
messages.append('serving on ' + ', '.join(f'http://{address}:{port}' for address, port in servers))
message = '; '.join(messages)
if message and message != reported:
log(message)
reported = message
time.sleep(interval)
@@ -0,0 +1,148 @@
"""What the router's VPN systems report, for the web service and the metrics service.
Each reader asks the running system once and answers None when it cannot be asked: swanctl for
the IKE SAs and the state and traffic of the site-to-site connections, wg for the WireGuard
peers and the OpenVPN server's status file for its clients.
"""
import subprocess
import time
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
def run(*args):
"""The finished process of a command, or None when it cannot be run."""
try:
return subprocess.run(args, capture_output=True, text=True, timeout=COMMAND_TIMEOUT)
except (OSError, subprocess.TimeoutExpired):
return None
def swanctl(command):
"""The entries of a swanctl list command, or None when swanctl cannot be asked.
The --pretty output has one item per line: "name {" opens a section, "key = [" a list
whose items follow one per line, "}" and "]" close them, and "key = value" is a pair
whose value may contain spaces. Every "... event {" section at the top holds one
connection or SA; each becomes an entry with its name under "name". The closing
"... reply {" section is left out. Values stay strings, as swanctl prints them.
Output with more closing than opening lines gives None.
"""
result = run('swanctl', command, '--pretty')
if result is None or result.returncode != 0:
return None
events = []
stack = []
for line in result.stdout.splitlines():
line = line.strip()
if not line:
continue
if line in ('}', ']'):
if not stack:
return None
stack.pop()
elif not stack:
section = {}
if line.endswith(' event {'):
events.append(section)
stack.append(section)
elif isinstance(stack[-1], list):
stack[-1].append(line)
elif line.endswith(' {'):
stack[-1][line[:-2]] = section = {}
stack.append(section)
elif line.endswith(' = ['):
stack[-1][line[:-4]] = items = []
stack.append(items)
else:
key, _, value = line.partition(' = ')
stack[-1][key] = value
return [dict(body, name=name) for event in events for name, body in event.items()]
def established_each(connections):
"""The number of established IKE SAs of each of the connections by name, None for each when swanctl cannot be asked."""
sas = swanctl('--list-sas')
return {name: None if sas is None else sum(1 for sa in sas if sa['name'] == name and sa.get('state') == 'ESTABLISHED')
for name in connections}
def _count(text):
"""A counter swanctl prints as a string, as an integer; 0 when it is not a whole number."""
return int(text) if isinstance(text, str) and text.isdigit() else 0
def site_to_site_tunnels(connections):
"""The state of each of the connections by name, or None when swanctl cannot be asked.
Each has up, 1 while it has an established IKE SA and 0 otherwise; uptime, the seconds since
its oldest established IKE SA was established, None while it is down; and bytes_in,
bytes_out, packets_in and packets_out, the sums over the child SAs of its established IKE
SAs. The sums start again from zero when a child SA is rekeyed.
"""
sas = swanctl('--list-sas')
if sas is None:
return None
tunnels = {}
for name in connections:
up = [sa for sa in sas if sa['name'] == name and sa.get('state') == 'ESTABLISHED']
children = [child for sa in up for child in (sa.get('child-sas') or {}).values() if isinstance(child, dict)]
tunnels[name] = {
'up': 1 if up else 0,
'uptime': max((_count(sa.get('established')) for sa in up), default=None),
**{key.replace('-', '_'): sum(_count(child.get(key)) for child in children)
for key in ('bytes-in', 'bytes-out', 'packets-in', 'packets-out')},
}
return tunnels
def established(connection):
"""The number of established IKE SAs of the connection, or None when swanctl cannot be asked."""
return established_each([connection])[connection]
def wireguard_peers():
"""One entry per WireGuard peer, with its handshake age and traffic, or None when wg0 is down."""
result = run('wg', 'show', 'wg0', 'dump')
if result is None or result.returncode != 0:
return None
now = int(time.time())
peers = []
for line in result.stdout.splitlines()[1:]:
fields = line.split('\t')
if len(fields) < 7:
continue
handshake = int(fields[4])
peers.append({
'handshake_seconds_ago': now - handshake if handshake else None,
'received_bytes': int(fields[5]),
'sent_bytes': int(fields[6]),
})
return peers
def wireguard_connected():
"""The number of WireGuard peers with a handshake in the last WIREGUARD_CONNECTED_SECONDS, or None when wg0 is down."""
peers = wireguard_peers()
if peers is None:
return None
return sum(1 for peer in peers
if peer['handshake_seconds_ago'] is not None and peer['handshake_seconds_ago'] < WIREGUARD_CONNECTED_SECONDS)
def openvpn_clients():
"""The number of connected OpenVPN clients, from the server's status file, or None when it cannot be read."""
try:
with open(OPENVPN_STATUS, encoding='utf-8') as handle:
return sum(1 for line in handle if line.startswith('CLIENT_LIST,'))
except OSError:
return None
@@ -0,0 +1,852 @@
"""The users and groups of the router's directory, for vpn-router-user, vpn-router-group and the web service.
A user is an entry in ou=users of the router's directory, of one of two kinds, fixed when it is
added. A VPN user is an inetOrgPerson with a first name, a last name and an e-mail address, whose
password OpenVPN checks, and a client certificate of the P2S issuing CA (vpnrouter_pki) with the
user name as its common name, which OpenVPN and IKEv2 accept; the certificate is the entry's
userCertificate, and the PKI keeps it with its key. A Manager-only account is an account,
simpleSecurityObject and vpnRouterAccount (the package's schema) with a name, a password and
optionally an e-mail address, no names, no certificate and so no VPN access; it is added when
neither the first nor the last name is given, and can sign in to the web service. A service
account is a Manager-only account with the scope SERVICE_SCOPE in vpnRouterScope, which another
system signs in with to use a router service, and which cannot sign in to the web service; it has
a display name and a description (labels), in displayName and description of vpnRouterAccount. The
internal service accounts are those the router creates and manages for its own services, labels
included (internal_accounts); vpn-router-setup keeps the metrics service's.
Users exist whether or not a P2S system is enabled.
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.
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
vpnrouter_ldap.TOTP_PARAMS. Enrolment stores nothing until a code of the new secret is
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 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.
A member of ADMIN_GROUP 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 for an enrolled user. 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
lock too, and the connection of the user manager account. A request that cannot be done raises Failure, whose
status says why: 2 the request is invalid, 3 the user does not exist, 4 the user exists, 5 a
login failed, 1 anything else. Answers are dicts with the keys of the JSON answers.
"""
import base64
import calendar
import contextlib
import fcntl
import grp
import ipaddress
import pwd
import re
import subprocess
import time
import ldap
import ldap.filter
import ldap.modlist
import pyotp
import vpnrouter
import vpnrouter_ldap
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]$')
PASSWORD_MIN = 8
PASSWORD_MAX = 128
#: The longest first or last name, in characters.
PERSON_NAME_MAX = 64
#: An e-mail address: one @, no spaces, and a dot in the domain.
EMAIL = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
EMAIL_MAX = 254
#: The keys of the person attributes and their LDAP attributes.
PERSON = (('first_name', 'givenName'), ('last_name', 'sn'), ('email', 'mail'))
#: The keys of the labels of a service account and their LDAP attributes.
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', 'vpnRouterConsoleLogin', 'vpnRouterConsoleAccount',
'uidNumber', 'vpnRouterWireGuardPeer']
#: 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.
VPN_USER_CLASSES = [b'inetOrgPerson']
MANAGER_ONLY_CLASSES = [b'account', b'simpleSecurityObject', b'vpnRouterAccount']
#: The readable attributes of a TOTP token; oathSecret is never readable.
TOKEN_ATTRIBUTES = ['oathTOTPToken', 'oathTOTPParams', 'oathSecretTime', 'oathTOTPLastTimeStep', 'oathTOTPTimeStepDrift',
'oathLastLogin', 'oathLastFailure', 'oathFailureCount']
#: A TOTP secret as otp_new makes it: 32 base32 characters, 160 bits.
TOTP_SECRET = re.compile(r'^[A-Z2-7]{32}$')
#: The attributes read for a group.
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 = 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().
CODE = re.compile(r'^\d{6}$')
LOCK_FILE = vpnrouter_pki.LOCK_FILE
class Failure(Exception):
"""A request that cannot be done; status is 2 invalid, 3 no such user, 4 the user exists, 5 failed login, 1 other."""
def __init__(self, message, status=1):
super().__init__(message)
self.status = status
@contextlib.contextmanager
def session():
"""The connection of the user manager account, used under the lock that serializes changes."""
LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(LOCK_FILE, 'w') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
connection = vpnrouter_ldap.manager()
try:
yield connection
finally:
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_password(value):
if not isinstance(value, str) or '\n' in value or '\r' in value:
raise Failure('the password must be a string of one line', 2)
if not PASSWORD_MIN <= len(value) <= PASSWORD_MAX:
raise Failure(f'the password must have {PASSWORD_MIN} to {PASSWORD_MAX} characters', 2)
def person(values, required):
"""The person attributes of values ({first_name, last_name, email}), checked, as {LDAP attribute: value}.
A key whose value is None is not given; with required, all three must be given.
"""
found = {}
for key, attribute in PERSON:
value = values.get(key)
if value is None:
if required:
raise Failure(f'{key} is required', 2)
continue
if not isinstance(value, str):
raise Failure(f'{key} must be a string', 2)
value = value.strip()
if key == 'email':
if len(value) > EMAIL_MAX or not EMAIL.match(value):
raise Failure(f'{value!r} is not an e-mail address', 2)
elif not value or len(value) > PERSON_NAME_MAX or any(not character.isprintable() for character in value):
raise Failure(f'the {key.replace("_", " ")} must have 1 to {PERSON_NAME_MAX} printable characters', 2)
found[attribute] = value
return found
def labels(values):
"""The labels of a service account in values ({display_name, description}), checked, as {LDAP attribute: value}.
A key whose value is None is not given; '' means none. A display name has at most PERSON_NAME_MAX printable
characters and a description at most DESCRIPTION_MAX.
"""
found = {}
name = values.get('display_name')
if name is not None:
if not isinstance(name, str):
raise Failure('display_name must be a string', 2)
name = name.strip()
if len(name) > PERSON_NAME_MAX or any(not character.isprintable() for character in name):
raise Failure(f'the display name must have at most {PERSON_NAME_MAX} printable characters', 2)
found['displayName'] = name
if values.get('description') is not None:
found['description'] = check_description(values['description'])
return found
def dn_of(name):
return f'uid={name},{vpnrouter_ldap.USERS}'
def entry(connection, name):
"""The attributes of the user's entry; Failure with status 3 when there is none."""
check_name(name)
try:
return connection.search_s(dn_of(name), ldap.SCOPE_BASE, attrlist=ATTRIBUTES)[0][1]
except ldap.NO_SUCH_OBJECT:
raise Failure(f'user {name} does not exist', 3)
def locked_since(value):
"""The time a pwdAccountLockedTime value names, in seconds since the epoch, or 0 when it names none."""
try:
return calendar.timegm(time.strptime(value[:14].decode(), '%Y%m%d%H%M%S'))
except ValueError:
return 0
def manager_only(attributes):
"""Whether the entry is a Manager-only account."""
return b'account' in (value.lower() for value in attributes.get('objectClass', []))
def service(attributes):
"""Whether the entry is a service account."""
return SERVICE_SCOPE.encode() in (value.lower() for value in attributes.get('vpnRouterScope', []))
def internal_accounts():
"""The internal service accounts, which the router creates and manages for its own services, by name, each with
the labels the router gives it ({display_name, description}): the one metrics.service_account names while metrics
are enabled."""
try:
metrics = vpnrouter.load_config()['metrics']
except SystemExit:
return {}
if not metrics['enabled']:
return {}
return {metrics['service_account']: {
'display_name': 'Metrics',
'description': 'The account an OpenTelemetry Collector signs in with to read /metrics of the metrics service.',
}}
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).
"""
name = attributes['uid'][0].decode()
lock = attributes.get('pwdAccountLockedTime', [None])[0]
user = {
'name': name,
'manager_only': manager_only(attributes),
'service': service(attributes),
'internal': service(attributes) and name in internal_accounts(),
'enabled': lock != LOCKED,
'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),
}
for key, attribute in PERSON + LABELS:
values = attributes.get(attribute)
user[key] = values[0].decode() if values else None
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 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 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 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)
return full or name
def list_users(connection):
found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL, '(|(objectClass=inetOrgPerson)(objectClass=account))',
ATTRIBUTES)
return {'users': sorted((summary(attributes) for _, attributes in found), key=lambda user: user['name'])}
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)
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)
if service:
details.update({attribute: value for attribute, value in labels(values).items() if value})
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)
if vpn_user and not vpnrouter_pki.ready('p2s'):
raise Failure('the P2S issuing CA has no certificate yet; vpn-router-setup creates it once a platform is chosen, '
'or, while the root CA is offline, once its certificate is imported')
if vpn_user:
record = {'objectClass': VPN_USER_CLASSES, 'uid': [name.encode()], 'cn': [common_name(details, name).encode()]}
else:
# simpleSecurityObject requires a userPassword; passwd_s sets the real one at once.
record = {'objectClass': MANAGER_ONLY_CLASSES, 'uid': [name.encode()], 'userPassword': [b'{CRYPT}!']}
if service:
record['vpnRouterScope'] = [SERVICE_SCOPE.encode()]
record.update({attribute: [value.encode()] for attribute, value in details.items()})
try:
connection.add_s(dn_of(name), ldap.modlist.addModlist(record))
except ldap.ALREADY_EXISTS:
raise Failure(f'user {name} exists', 4)
connection.passwd_s(dn_of(name), None, password)
if vpn_user:
try:
vpnrouter_pki.issue_user(vpnrouter.load_config(), name, details['mail'])
except vpnrouter_pki.Failure as failure:
raise Failure(f'the certificate cannot be issued: {failure}', failure.status)
return summary(entry(connection, name))
def update(connection, name, values):
"""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).
"""
current = entry(connection, name)
if service(current):
return update_service(connection, 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')
if email is None:
raise Failure('give email', 2)
if isinstance(email, str) and email.strip() == '':
if 'mail' in current:
connection.modify_s(dn_of(name), [(ldap.MOD_DELETE, 'mail', None)])
else:
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)
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."""
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()]
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)
connection.modify_s(dn_of(name), changes)
return summary(entry(connection, name))
def passwd(connection, name, password):
check_password(password)
entry(connection, name)
connection.passwd_s(dn_of(name), None, password)
return summary(entry(connection, name))
def disable(connection, name):
"""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):
"""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))
def unlock(connection, name):
"""End a lockout after failed binds and clear the failure count; a disabled user stays disabled."""
entry(connection, name)
current = connection.search_s(dn_of(name), ldap.SCOPE_BASE, attrlist=['pwdAccountLockedTime', 'pwdFailureTime'])[0][1]
changes = [(ldap.MOD_DELETE, 'pwdFailureTime', None)] if 'pwdFailureTime' in current else []
if 'pwdAccountLockedTime' in current and current['pwdAccountLockedTime'] != [LOCKED]:
changes.append((ldap.MOD_DELETE, 'pwdAccountLockedTime', None))
if changes:
connection.modify_s(dn_of(name), changes)
return summary(entry(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.
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:
raise refused
if code is not None and code != '' and (not isinstance(code, str) or not CODE.match(code)):
raise refused
connection = ldap.initialize(vpnrouter_ldap.URI)
try:
connection.simple_bind_s(dn_of(name), password + (code or ''))
except ldap.INVALID_CREDENTIALS:
raise refused
finally:
connection.unbind_s()
def is_admin(connection, name):
"""Whether the user exists and is a member of ADMIN_GROUP."""
try:
entry(connection, name)
except Failure:
return False
return ADMIN_GROUP in groups_of(connection, name)
def totp(secret):
return pyotp.TOTP(secret, digits=vpnrouter_ldap.TOTP_DIGITS, interval=vpnrouter_ldap.TOTP_PERIOD)
def otp_new(connection, name):
"""A new TOTP secret for the user and its otpauth URI, issued by the router's wan.local_fqdn; nothing is stored."""
entry(connection, name)
secret = pyotp.random_base32()
issuer = vpnrouter.load_config()['wan']['local_fqdn'].strip()
return {'name': name, 'secret': secret, 'uri': totp(secret).provisioning_uri(name=name, issuer_name=issuer or None)}
def otp_enrol(connection, name, secret, code):
"""Store the secret of otp_new as the user's TOTP token, replacing one the user has, when code is a code of it."""
current = entry(connection, name)
if not isinstance(secret, str) or not TOTP_SECRET.match(secret):
raise Failure('the secret must be 32 base32 characters, as a new secret has them', 2)
if not isinstance(code, str) or not re.fullmatch(rf'\d{{{vpnrouter_ldap.TOTP_DIGITS}}}', code):
raise Failure(f'the code must be {vpnrouter_ldap.TOTP_DIGITS} digits', 2)
if not totp(secret).verify(code, valid_window=vpnrouter_ldap.TOTP_WINDOW):
raise Failure('the code does not match the secret; check the clock of the authenticator', 2)
changes = [] if 'oathTOTPToken' in current else [(ldap.MOD_ADD, 'objectClass', [b'oathTOTPUser', b'oathTOTPToken'])]
changes += [
(ldap.MOD_REPLACE, 'oathTOTPToken', [dn_of(name).encode()]),
(ldap.MOD_REPLACE, 'oathTOTPParams', [vpnrouter_ldap.TOTP_PARAMS.encode()]),
(ldap.MOD_REPLACE, 'oathSecret', [base64.b32decode(secret)]),
(ldap.MOD_REPLACE, 'oathSecretTime', [time.strftime('%Y%m%d%H%M%SZ', time.gmtime()).encode()]),
]
connection.modify_s(dn_of(name), changes)
return summary(entry(connection, name))
def otp_remove(connection, name):
"""Remove the user's TOTP token, when the user has one."""
if 'oathTOTPToken' in entry(connection, name):
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)
return summary(entry(connection, name))
def remove(connection, name):
"""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.
"""
current = entry(connection, name)
groups = groups_of(connection, name)
alone = sorted(group for group, members in groups.items() if members == [name])
if alone:
raise Failure(f'user {name} is the last member of {", ".join(alone)}; remove the group first', 2)
for group in groups:
connection.modify_s(group_dn(group), [(ldap.MOD_DELETE, 'member', [dn_of(name).encode()])])
try:
vpnrouter_pki.revoke_user(vpnrouter.load_config(), 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}
def profile(connection, name):
"""The OpenVPN client profile of the user, with the root and the P2S CA, the certificate and the key inline."""
entry(connection, name)
cfg = vpnrouter.load_config()
if not cfg['openvpn']['enabled']:
raise Failure('OpenVPN is not enabled', 2)
credentials = vpnrouter_pki.user_credentials(name)
if credentials is None:
raise Failure(f'user {name} has no certificate')
cert, key = (value.decode() for value in credentials)
fqdn = cfg['wan']['local_fqdn'].strip()
text = '\n'.join([
'client',
'dev tun',
f'proto {cfg["openvpn"]["protocol"]}',
f'remote {fqdn} {cfg["openvpn"]["port"]}',
'nobind',
'persist-key',
'persist-tun',
'remote-cert-tls server',
f'verify-x509-name {fqdn} name',
'auth-user-pass',
'auth-nocache',
'verb 3',
'<ca>', extract_pem(vpnrouter_pki.OPENVPN_CA.read_text(encoding='utf-8')), '</ca>',
'<cert>', extract_pem(cert), '</cert>',
'<key>', key.strip(), '</key>',
'',
])
return {'name': name, 'profile': text}
def group_dn(name):
return f'cn={name},{vpnrouter_ldap.GROUPS}'
def check_group_name(name):
if not isinstance(name, str) or not NAME.match(name):
raise Failure('a group name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter', 2)
def check_description(value):
"""The description, stripped; '' means none."""
if not isinstance(value, str):
raise Failure('the description must be a string', 2)
value = value.strip()
if len(value) > DESCRIPTION_MAX or any(not character.isprintable() for character in value):
raise Failure(f'the description must have at most {DESCRIPTION_MAX} printable characters', 2)
return value
def member_names(attributes):
"""The user names of a group's member DNs, sorted."""
names = []
for value in attributes.get('member', []):
dn = value.decode()
if dn.startswith('uid=') and dn.endswith(f',{vpnrouter_ldap.USERS}'):
names.append(dn[4:-len(f',{vpnrouter_ldap.USERS}')])
return sorted(names)
def group_summary(attributes):
"""A group as the answers show it."""
values = attributes.get('description')
return {
'name': attributes['cn'][0].decode(),
'description': values[0].decode() if values else '',
'members': member_names(attributes),
}
def group_entry(connection, name):
"""The attributes of the group's entry; Failure with status 3 when there is none."""
check_group_name(name)
try:
return connection.search_s(group_dn(name), ldap.SCOPE_BASE, attrlist=GROUP_ATTRIBUTES)[0][1]
except ldap.NO_SUCH_OBJECT:
raise Failure(f'group {name} does not exist', 3)
def groups_of(connection, name):
"""The groups the user is a member of, as {group name: its member names}."""
found = connection.search_s(vpnrouter_ldap.GROUPS, ldap.SCOPE_ONELEVEL,
f'(&(objectClass=groupOfNames)(member={ldap.filter.escape_filter_chars(dn_of(name))}))',
GROUP_ATTRIBUTES)
return {attributes['cn'][0].decode(): member_names(attributes) 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'])}
def group_add(connection, name, description, members):
"""Add a group with a description ('' for none) and at least one member, each an existing user."""
check_group_name(name)
description = check_description(description if description is not None else '')
if not isinstance(members, list) or not members:
raise Failure('a group needs at least one member', 2)
for member in members:
entry(connection, member)
record = {'objectClass': [b'groupOfNames'], 'cn': [name.encode()],
'member': [dn_of(member).encode() for member in sorted(set(members))]}
if description:
record['description'] = [description.encode()]
try:
connection.add_s(group_dn(name), ldap.modlist.addModlist(record))
except ldap.ALREADY_EXISTS:
raise Failure(f'group {name} exists', 4)
return group_summary(group_entry(connection, name))
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. Renaming ADMIN_GROUP
switches the console login of its members off."""
if new_name is None and description is None:
raise Failure('give a new name or a description', 2)
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 \
(ldap.MOD_REPLACE, 'description', [])
connection.modify_s(group_dn(name), [change])
if new_name is not None and new_name != name:
check_group_name(new_name)
try:
connection.rename_s(group_dn(name), f'cn={new_name}', delold=1)
except ldap.ALREADY_EXISTS:
raise Failure(f'group {new_name} exists', 4)
if name == ADMIN_GROUP:
console_off(connection, members)
name = new_name
return group_summary(group_entry(connection, name))
def group_remove(connection, name):
"""Remove the group; removing ADMIN_GROUP switches the console login of its members off."""
members = member_names(group_entry(connection, name))
connection.delete_s(group_dn(name))
if name == ADMIN_GROUP:
console_off(connection, members)
return {'name': name, 'removed': True}
def group_member_add(connection, name, user):
attributes = group_entry(connection, name)
entry(connection, user)
if user not in member_names(attributes):
connection.modify_s(group_dn(name), [(ldap.MOD_ADD, 'member', [dn_of(user).encode()])])
return group_summary(group_entry(connection, name))
def group_member_remove(connection, name, user):
"""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:
raise Failure(f'user {user} is not a member of group {name}', 3)
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))
def extract_pem(text):
"""The PEM blocks of the certificates in a file that also holds their text form."""
begin, end = '-----BEGIN CERTIFICATE-----', '-----END CERTIFICATE-----'
blocks = []
start = text.find(begin)
while start != -1:
stop = text.index(end, start) + len(end)
blocks.append(text[start:stop])
start = text.find(begin, stop)
return '\n'.join(blocks)
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# Wait until libpq is in the dynamic linker cache, where psycopg looks it up.
# ExecStartPre= of vpn-router-web.service; TimeoutStartSec= of the unit bounds the wait.
reported=
until ldconfig -p | grep -q '^[[:space:]]*libpq\.so\.5 '; do
if [ -z "$reported" ]; then
echo 'waiting for libpq.so.5 in the linker cache'
reported=yes
fi
sleep 2
done
File diff suppressed because it is too large. Load diff
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/python3
"""Show and change the router's configuration, /etc/vpn-router/vpn-router.json.
show prints the file as indented JSON; the file is not changed. set takes a setting as
section.option, such as web.port, or as site_to_site.NAME.option for a site-to-site connection,
which it creates with the defaults (vpnrouter.CONNECTION) when it does not exist, and a value,
which it converts to the type the package's schema (vpnrouter.SCHEMA) gives the setting: a whole
number, true or false, a list of strings separated by commas (an empty value is an empty list),
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.
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
"""
import argparse
import copy
import json
import sys
sys.path.insert(0, '/usr/lib/vpn-router')
import vpnrouter
class Failure(Exception):
"""A request that cannot be done; status is 2 invalid, 1 other."""
def __init__(self, message, status=1):
super().__init__(message)
self.status = status
class Parser(argparse.ArgumentParser):
"""An argument parser whose errors are Failures with status 2."""
def error(self, message):
raise Failure(f'{message}; see {self.prog} --help', 2)
def read():
"""The settings in the configuration file, without the defaults; {} when the file does not exist."""
try:
return vpnrouter.read_settings()
except vpnrouter.ConfigError as exc:
raise Failure(str(exc))
def converted(key, text):
"""The path of the setting key, section.option or site_to_site.NAME.option, and text as the type the schema gives it."""
path = key.split('.')
if path[0] == 'site_to_site' and len(path) == 3 and path[2] in vpnrouter.CONNECTION:
kind = type(vpnrouter.CONNECTION[path[2]])
elif path[0] != 'site_to_site' and len(path) == 2 and path[0] in vpnrouter.SCHEMA and path[1] in vpnrouter.SCHEMA[path[0]]:
kind = type(vpnrouter.SCHEMA[path[0]][path[1]])
else:
raise Failure(f'{key}: unknown setting; settings are section.option, such as web.port, '
'or site_to_site.NAME.option', 2)
if kind is bool:
if text not in ('true', 'false'):
raise Failure(f'{key} takes true or false', 2)
return path, text == 'true'
if kind is int:
try:
return path, int(text)
except ValueError:
raise Failure(f'{key} takes a whole number', 2)
if kind is list:
return path, [item.strip() for item in text.split(',') if item.strip()]
return path, text
def main(argv):
try:
parser = Parser(prog='vpn-config-edit', description='Show and change the router configuration.')
commands = parser.add_subparsers(dest='command', required=True, parser_class=Parser)
commands.add_parser('show', help='print the configuration as indented JSON')
setter = commands.add_parser('set', help='set a setting and write the configuration')
setter.add_argument('key', metavar='SECTION.OPTION',
help='the setting, such as web.port or site_to_site.NAME.option')
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')
options = parser.parse_args(argv)
if options.command == 'show':
print(json.dumps(read(), indent=2))
return 0
with vpnrouter.config_lock():
data = read()
if options.command == 'set':
path, value = converted(options.key, options.value)
if path[0] == 'site_to_site':
connections = data.setdefault('site_to_site', {})
connection = connections.setdefault(path[1], copy.deepcopy(vpnrouter.CONNECTION))
if not isinstance(connection, dict):
raise Failure(f'site_to_site.{path[1]} is not an object')
connection[path[2]] = value
else:
data.setdefault(path[0], {})[path[1]] = value
else:
section, _, name = options.key.partition('.')
if section != 'site_to_site' or not name or name not in data.get('site_to_site', {}):
raise Failure(f'{options.key}: no such site-to-site connection', 2)
del data['site_to_site'][name]
try:
vpnrouter.validate(vpnrouter.with_defaults(data))
except vpnrouter.ConfigError as exc:
raise Failure(str(exc), 2)
vpnrouter.save_config(data)
except Failure as failure:
print(f'vpn-config-edit: {failure}', file=sys.stderr)
return failure.status
except OSError as exc:
print(f'vpn-config-edit: {exc}', file=sys.stderr)
return 1
if options.command == 'set':
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')
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/python3
"""Make, list and restore backups of the router (vpnrouter_backup).
run makes a backup as backup in /etc/vpn-router/vpn-router.json says, whether or not backups are
enabled, and prints its result; vpn-router-backup.service runs it on the schedule of its timer.
list prints the local archives of this host in backup.path, newest first, or with the pbs target
the snapshots of this host on the Proxmox Backup Server. restore puts back a local archive, or
with --pbs a snapshot, such as host/router/2026-10-25T03:00:00Z, after showing what it holds and
asking for yes, which --yes gives; what it replaces is kept in
/var/lib/vpn-router/backup/before-restore. 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.
vpn-router-backup run
vpn-router-backup list
vpn-router-backup restore [--yes] ARCHIVE
vpn-router-backup restore [--yes] --pbs SNAPSHOT
"""
import argparse
import os
import sys
sys.path.insert(0, '/usr/lib/vpn-router')
import vpnrouter
import vpnrouter_backup
class Parser(argparse.ArgumentParser):
"""An argument parser whose errors end with status 2."""
def error(self, message):
print(f'{self.prog}: {message}; see {self.prog} --help', file=sys.stderr)
sys.exit(2)
def confirmed(manifest, yes):
"""Whether to restore the backup of the manifest: yes, or the answer yes to the question."""
print(f'Backup of {manifest.get("host")} made {manifest.get("time")} by vpn-router {manifest.get("version")}:')
for item in manifest.get('items', []):
print(f' {item}')
if yes:
return True
return input('Replace the router state with it? Type yes: ').strip() == 'yes'
def main(argv):
parser = Parser(prog='vpn-router-backup', description='Make, list and restore backups of the router.')
commands = parser.add_subparsers(dest='command', required=True, parser_class=Parser)
commands.add_parser('run', help='make a backup now')
commands.add_parser('list', help='list the backups of this host')
restorer = commands.add_parser('restore', help='put a backup back')
restorer.add_argument('source', metavar='ARCHIVE|SNAPSHOT', help='a local archive, or with --pbs a snapshot')
restorer.add_argument('--pbs', action='store_true', help='restore a snapshot of the Proxmox Backup Server')
restorer.add_argument('--yes', action='store_true', help='do not ask before replacing the router state')
options = parser.parse_args(argv)
os.umask(0o077)
try:
if options.command == 'run':
record = vpnrouter_backup.run()
if record['result'] == 'failed':
print(f'vpn-router-backup: {record["error"]}', file=sys.stderr)
return 1
print(f'Backup {record["archive"] or record["snapshot"]} made, {record["size"]} bytes.')
for name in record['removed']:
print(f'Removed {name}.')
if record['cloud']:
print(f'Cloud copy to {record["cloud"]["remote"]}: {record["cloud"]["error"] or "ok"}.')
return 0
cfg = vpnrouter.load_config()
if options.command == 'list':
if cfg['backup']['target'] == 'pbs':
print(vpnrouter_backup.snapshots(cfg), end='')
else:
for item in vpnrouter_backup.archives(cfg['backup']['path']):
print(f'{item["name"]} {item["size"]}')
return 0
if options.pbs and options.source.startswith('/'):
print('vpn-router-backup: a snapshot is type/id/time, such as host/router/2026-10-25T03:00:00Z',
file=sys.stderr)
return 2
saved = vpnrouter_backup.restore(options.source, options.pbs, lambda manifest: confirmed(manifest, options.yes))
except vpnrouter_backup.Failure as exc:
print(f'vpn-router-backup: {exc}', file=sys.stderr)
return 1
except OSError as exc:
print(f'vpn-router-backup: {exc}', file=sys.stderr)
return 1
if saved is None:
print('Nothing restored.')
return 1
print(f'Restored; what it replaced is in {saved}.')
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))
+212
View File
@@ -0,0 +1,212 @@
#!/usr/bin/python3
"""Manage the router's PKI, which the router's directory holds, from the command line, with vpnrouter_pki.
status shows the root, the issuing CAs and the warnings; list the certificates the router issued. issue signs a CSR,
or makes a key and writes a PFX file whose password is read from the first line of standard input. ca csr prints the
CSR of an issuing CA that waits for the offline root, with the openssl extensions to sign it with, and ca import takes
the signed certificate. root export-key writes the root key encrypted with the passphrase read from standard input,
root remove-key removes it from the router, root import-key puts it back, and root import-crl takes a root CRL signed
offline. maintain signs the stale CRLs again; vpn-router-pki.service runs it daily. A change that needs
vpn-router-setup restarts it without waiting.
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 something it names does not exist, 4 the state does not allow it, 1 anything else. -h or --help shows the
commands and their options.
vpn-router-ca [--json] status
vpn-router-ca [--json] list [--ca NAME]
vpn-router-ca [--json] issue --ca NAME --template NAME --csr FILE
vpn-router-ca [--json] issue --ca NAME --template NAME --subject NAME [--san NAME ...] --pfx FILE (password on stdin)
vpn-router-ca [--json] revoke SERIAL [--reason REASON]
vpn-router-ca [--json] maintain
vpn-router-ca [--json] ca csr NAME
vpn-router-ca [--json] ca import NAME FILE
vpn-router-ca [--json] root export-key --out FILE (passphrase on stdin)
vpn-router-ca [--json] root remove-key [--yes]
vpn-router-ca [--json] root import-key FILE (passphrase on stdin, an empty line for none)
vpn-router-ca [--json] root import-crl FILE
"""
import argparse
import base64
import json
import os
import pathlib
import subprocess
import sys
sys.path.insert(0, '/usr/lib/vpn-router')
import ldap
import vpnrouter
import vpnrouter_pki
from vpnrouter_pki import Failure
class Parser(argparse.ArgumentParser):
"""An argument parser whose errors end with status 2."""
def error(self, message):
print(f'{self.prog}: {message}; see {self.prog} --help', file=sys.stderr)
sys.exit(2)
def secret():
"""The first line of standard input."""
return sys.stdin.readline().rstrip('\r\n')
def restart_setup():
subprocess.run(['systemctl', 'restart', '--no-block', 'vpn-router-setup.service'], capture_output=True, text=True)
def show_status(document):
root = document['root']
if root is None:
print('Root CA: none; vpn-router-setup creates it once a platform is chosen')
else:
print(f'Root CA: {root["subject"]}, expires {root["expires"]}, key {"on the router" if root["key"] else "offline"}'
f', CRL until {root["crl_next_update"] or "none"}')
for authority in document['authorities']:
line = f'Issuing CA {authority["name"]}: {authority["common_name"]}, {authority["state"]}'
if authority['expires']:
line += f', expires {authority["expires"]}, CRL until {authority["crl_next_update"] or "none"}'
line += f', templates {", ".join(authority["templates"])}'
if authority['acme_url']:
line += f', ACME {authority["acme_url"]}'
print(line)
for retired in document['retired']:
print(f'Retired CA {retired["name"]}: {retired["retired"]}')
for warning in document['warnings']:
print(f'Warning: {warning}')
def run(options):
"""Do the command of the options; the answer as a document, and a text for people."""
cfg = vpnrouter.load_config()
if options.command == 'status':
document = vpnrouter_pki.status(cfg)
return document, None
if options.command == 'list':
found = [entry for entry in vpnrouter_pki.certificates() if options.ca in (None, entry['ca'])]
text = '\n'.join(f'{entry["serial"]} {entry["ca"]} {entry["kind"]} {entry["subject"]} {entry["state"]} '
f'{entry["expires"]}' for entry in found)
return {'certificates': found}, text
with vpnrouter_pki.locked():
if options.command == 'issue':
if options.csr:
document = vpnrouter_pki.issue(cfg, options.ca, options.template,
csr=pathlib.Path(options.csr).read_text(encoding='ascii'))
return document, document['certificate'] + document['chain']
if not options.subject or not options.pfx:
raise Failure('give --csr, or --subject and --pfx', 2)
document = vpnrouter_pki.issue(cfg, options.ca, options.template, subject=options.subject,
sans=options.san or [], password=secret())
out = pathlib.Path(options.pfx)
with open(out, 'xb') as handle:
handle.write(base64.b64decode(document['pfx']))
return {'serial': document['serial'], 'pfx': str(out)}, f'Certificate {document["serial"]} written to {out}.'
if options.command == 'revoke':
document = vpnrouter_pki.revoke(cfg, options.serial, options.reason)
if document['reissue']:
restart_setup()
return document, f'Certificate {document["serial"]} revoked' + \
('; vpn-router-setup issues a new one.' if document['reissue'] else '.')
if options.command == 'maintain':
due = vpnrouter_pki.maintain(cfg)
if due:
restart_setup()
return {'renewal': due}, 'CRLs checked' + ('; vpn-router-setup renews the router certificates.' if due else '.')
if options.command == 'ca' and options.action == 'csr':
document = vpnrouter_pki.csr(cfg, options.name)
return document, document['csr'] + '\n' + document['extensions']
if options.command == 'ca':
document = vpnrouter_pki.import_ca(cfg, options.name, pathlib.Path(options.file).read_text(encoding='ascii'))
restart_setup()
return document, f'Issuing CA {options.name} has its certificate, which expires {document["expires"]}.'
if options.action == 'export-key':
data = vpnrouter_pki.export_root_key(secret())
out = pathlib.Path(options.out)
with open(out, 'xb') as handle:
handle.write(data)
return {'exported': str(out)}, f'Root key written to {out}, encrypted.'
if options.action == 'remove-key':
if not options.yes:
print('Remove the root key from the router? Keep an exported copy first. Type yes: ', end='',
file=sys.stderr, flush=True)
if secret().strip() != 'yes':
return {'removed': False}, 'Nothing removed.'
vpnrouter_pki.remove_root_key()
return {'removed': True}, 'Root key removed; new issuing CAs now wait for an offline signature.'
if options.action == 'import-key':
vpnrouter_pki.import_root_key(pathlib.Path(options.file).read_bytes(), secret() or None)
restart_setup()
return {'imported': True}, 'Root key back on the router.'
document = vpnrouter_pki.import_root_crl(pathlib.Path(options.file).read_bytes())
return document, f'Root CRL taken, valid until {document["next_update"]}.'
def main(argv):
parser = Parser(prog='vpn-router-ca', description="Manage the router's PKI.")
parser.add_argument('--json', action='store_true', help='answer in JSON')
commands = parser.add_subparsers(dest='command', required=True, parser_class=Parser)
commands.add_parser('status', help='show the root, the issuing CAs and the warnings')
lister = commands.add_parser('list', help='list the certificates the router issued')
lister.add_argument('--ca', help='only those of this issuing CA')
issuer = commands.add_parser('issue', help='issue a certificate from a CSR, or with a new key into a PFX file')
issuer.add_argument('--ca', required=True, help='the issuing CA')
issuer.add_argument('--template', required=True, help='the certificate template')
issuer.add_argument('--csr', help='the PEM CSR to sign')
issuer.add_argument('--subject', help='the common name, without --csr')
issuer.add_argument('--san', action='append', help='a subject alternative name, without --csr; repeat for more')
issuer.add_argument('--pfx', help='the PFX file to write, without --csr; the password is read from standard input')
revoker = commands.add_parser('revoke', help='revoke a certificate the router issued')
revoker.add_argument('serial', help='the serial number, hexadecimal')
revoker.add_argument('--reason', choices=vpnrouter_pki.REASONS, help='the reason, unspecified by default')
commands.add_parser('maintain', help='sign the stale CRLs again')
authority = commands.add_parser('ca', help='an issuing CA that waits for the offline root')
actions = authority.add_subparsers(dest='action', required=True, parser_class=Parser)
actions.add_parser('csr', help='print its CSR and the openssl extensions').add_argument('name')
importer = actions.add_parser('import', help='take its certificate signed by the root')
importer.add_argument('name')
importer.add_argument('file')
root = commands.add_parser('root', help='the root CA')
root_actions = root.add_subparsers(dest='action', required=True, parser_class=Parser)
exporter = root_actions.add_parser('export-key', help='write the root key encrypted with the passphrase from stdin')
exporter.add_argument('--out', required=True, help='the file to write')
remover = root_actions.add_parser('remove-key', help='remove the root key from the router')
remover.add_argument('--yes', action='store_true', help='do not ask')
root_actions.add_parser('import-key', help='put the root key back').add_argument('file')
root_actions.add_parser('import-crl', help='take a root CRL signed offline').add_argument('file')
options = parser.parse_args(argv)
os.umask(0o077)
try:
document, text = run(options)
except Failure as failure:
code, message = failure.status, str(failure)
except OSError as exc:
code, message = 1, str(exc)
except ldap.LDAPError as exc:
code, message = 1, f'the directory, which holds the PKI, failed: {exc}'
except SystemExit as exc:
code, message = 1, str(exc.code)
else:
if options.json:
print(json.dumps(document, indent=2))
elif text is None:
show_status(document)
elif text:
print(text.rstrip('\n'))
return 0
if options.json:
print(json.dumps({'error': message}, indent=2))
else:
print(f'vpn-router-ca: {message}', file=sys.stderr)
return code
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/python3
"""Install Caddy, the router's HTTPS proxy, from the source https.caddy_source names.
status shows the configured source, the source in effect and the installed and candidate versions of Caddy. apply
installs Caddy when it is missing: from the Ubuntu archive, or with upstream from Caddy's own repository, which it
configures first (vpnrouter.CADDY_APT_SOURCE, with the key in vpnrouter.CADDY_KEYRING). When upstream is configured
and the repository is not, it configures the repository and replaces the installed Caddy with upstream's. It then
restarts vpn-router-setup without waiting, which serves with the new Caddy; vpn-router-caddy.service runs it for
vpn-router-setup. use-upstream sets https.caddy_source to upstream and applies it, printing each step.
Once the repository is configured Caddy stays upstream's: apt keeps the higher upstream version, and ubuntu has no
effect. A failure is a message on standard error and the exit status 1. -h or --help shows the commands.
vpn-router-caddy [--json] status
vpn-router-caddy apply
vpn-router-caddy use-upstream
"""
import argparse
import json
import os
import subprocess
import sys
import urllib.request
sys.path.insert(0, '/usr/lib/vpn-router')
import vpnrouter
#: 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']
#: The options of apt-get install: no questions, and the configuration files of the installed Caddy kept.
INSTALL = ['-y', '-o', 'Dpkg::Options::=--force-confdef', '-o', 'Dpkg::Options::=--force-confold', 'install']
#: Seconds the key of Caddy's repository may take to download.
KEY_TIMEOUT = 60
class Parser(argparse.ArgumentParser):
"""An argument parser whose errors end with status 2."""
def error(self, message):
print(f'{self.prog}: {message}; see {self.prog} --help', file=sys.stderr)
sys.exit(2)
def step(message):
print(message, flush=True)
def installed():
"""The installed version of caddy, or None when it is not installed."""
result = subprocess.run(['dpkg-query', '-W', '-f=${Status}\t${Version}', 'caddy'], capture_output=True, text=True)
status, _, version = result.stdout.partition('\t')
return version if result.returncode == 0 and status == 'install ok installed' else None
def candidate():
"""The version of caddy apt would install, or None when it knows none."""
result = subprocess.run(['apt-cache', 'policy', 'caddy'], capture_output=True, text=True)
for line in result.stdout.splitlines():
if line.strip().startswith('Candidate:'):
value = line.split(':', 1)[1].strip()
return None if value == '(none)' else value
return None
def effective():
"""The source Caddy comes from: upstream once its repository is configured, ubuntu otherwise."""
return 'upstream' if vpnrouter.CADDY_APT_SOURCE.is_file() else 'ubuntu'
def command(args):
"""Run a command with its output shown; SystemExit when it fails."""
step(f'$ {" ".join(args)}')
result = subprocess.run(args, env=dict(os.environ, DEBIAN_FRONTEND='noninteractive'))
if result.returncode != 0:
sys.exit(f'vpn-router-caddy: {args[0]} failed with status {result.returncode}')
def apply():
"""Install Caddy, or replace it with upstream's, as https.caddy_source asks (see the module)."""
source = vpnrouter.load_config()['https']['caddy_source']
repository = source == 'upstream' and not vpnrouter.CADDY_APT_SOURCE.is_file()
if installed() and not repository:
if source == 'ubuntu' and effective() == 'upstream':
step("https.caddy_source is ubuntu, but Caddy's repository is configured, so Caddy stays upstream's.")
else:
step(f'Caddy {installed()} is installed from the {source} source.')
return
if repository:
step(f"Downloading the key of Caddy's repository from {vpnrouter.CADDY_KEY_URL}")
with urllib.request.urlopen(vpnrouter.CADDY_KEY_URL, timeout=KEY_TIMEOUT) as response:
key = response.read()
if not key.lstrip().startswith(b'-----BEGIN PGP PUBLIC KEY BLOCK-----'):
sys.exit(f'vpn-router-caddy: {vpnrouter.CADDY_KEY_URL} did not answer with an OpenPGP public key')
vpnrouter.CADDY_KEYRING.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
vpnrouter.CADDY_KEYRING.write_bytes(key)
os.chmod(vpnrouter.CADDY_KEYRING, 0o644)
vpnrouter.CADDY_APT_SOURCE.write_text(
f'Types: deb\nURIs: {vpnrouter.CADDY_REPOSITORY}\nSuites: any-version\nComponents: main\n'
f'Signed-By: {vpnrouter.CADDY_KEYRING}\n', encoding='utf-8')
os.chmod(vpnrouter.CADDY_APT_SOURCE, 0o644)
step(f"Caddy's repository is configured in {vpnrouter.CADDY_APT_SOURCE}.")
command([*APT, 'update'])
command([*APT, *INSTALL, 'caddy'])
step(f'Caddy {installed()} is installed from the {effective()} source.')
subprocess.run(['systemctl', 'restart', '--no-block', 'vpn-router-setup.service'], capture_output=True, text=True)
step('vpn-router-setup restarted.')
def main(argv):
parser = Parser(prog='vpn-router-caddy', description='Install Caddy from the source https.caddy_source names.')
parser.add_argument('--json', action='store_true', help='answer status in JSON')
commands = parser.add_subparsers(dest='command', required=True, parser_class=Parser)
commands.add_parser('status', help='show the sources and the versions of Caddy')
commands.add_parser('apply', help='install Caddy, or replace it with upstream\'s, as https.caddy_source asks')
commands.add_parser('use-upstream', help="switch to Caddy's own repository and replace Caddy with upstream's")
options = parser.parse_args(argv)
try:
if options.command == 'status':
document = {'configured': vpnrouter.load_config()['https']['caddy_source'], 'source': effective(),
'installed': installed(), 'candidate': candidate()}
if options.json:
print(json.dumps(document, indent=2))
else:
print(f'Configured source: {document["configured"]}\nSource in effect: {document["source"]}\n'
f'Installed: {document["installed"] or "no"}\nCandidate: {document["candidate"] or "none"}')
return 0
if options.command == 'use-upstream':
with vpnrouter.config_lock():
data = vpnrouter.read_settings()
current = data.get('https')
data['https'] = {**(current if isinstance(current, dict) else {}), 'caddy_source': 'upstream'}
vpnrouter.validate(vpnrouter.with_defaults(data))
vpnrouter.save_config(data)
step('https.caddy_source is upstream.')
apply()
except vpnrouter.ConfigError as exc:
print(f'vpn-router-caddy: {exc}', file=sys.stderr)
return 1
except OSError as exc:
print(f'vpn-router-caddy: {exc}', file=sys.stderr)
return 1
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/python3
"""Manage the groups of the router's directory from the command line, with vpnrouter_users.
A group has a name, an optional description and at least one member, each a user of the
directory. 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
group or the user does not exist, 4 the group exists, 1 anything else. -h or --help shows the
commands and their options.
vpn-router-group [--json] list
vpn-router-group [--json] add <name> --member U [--member U ...] [--description D]
vpn-router-group [--json] update <name> [--name N] [--description D]
vpn-router-group [--json] remove <name>
vpn-router-group [--json] member-add <name> <user>
vpn-router-group [--json] member-remove <name> <user>
"""
import argparse
import json
import sys
sys.path.insert(0, '/usr/lib/vpn-router')
import ldap
import vpnrouter_users
from vpnrouter_users import Failure
#: The help line of each command.
HELP = {
'list': 'list the groups with their members',
'add': 'add a group with at least one member',
'update': 'rename the group or change its description',
'remove': 'remove the group; its members are kept',
'member-add': 'add a user to the group',
'member-remove': 'take a user out of the group; the last member cannot leave',
}
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-group', description='Manage the groups 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 group name')
if command == 'add':
sub.add_argument('--member', dest='members', action='append', required=True, metavar='USER',
help='a member; give it once for each member')
sub.add_argument('--description', metavar='TEXT', help='the description')
if command == 'update':
sub.add_argument('--name', dest='new_name', metavar='NAME', help='the new group name')
sub.add_argument('--description', metavar='TEXT', help="the description; '' removes it")
if command in ('member-add', 'member-remove'):
sub.add_argument('user', help='the user name')
return parser.parse_args(argv)
def run(connection, options):
"""The answer of the command the options name."""
command = options.command
if command == 'list':
return vpnrouter_users.list_groups(connection)
if command == 'add':
return vpnrouter_users.group_add(connection, options.name, options.description, options.members)
if command == 'update':
return vpnrouter_users.group_update(connection, options.name, options.new_name, options.description)
if command == 'remove':
return vpnrouter_users.group_remove(connection, options.name)
if command == 'member-add':
return vpnrouter_users.group_member_add(connection, options.name, options.user)
return vpnrouter_users.group_member_remove(connection, options.name, options.user)
def text_of(answer):
"""The answer as text for people."""
if answer.get('removed'):
return f'group {answer["name"]} removed'
groups = answer['groups'] if 'groups' in answer else [answer]
if not groups:
return 'no groups'
head = ('GROUP', 'MEMBERS', 'DESCRIPTION')
rows = [(group['name'], ', '.join(group['members']), group['description'] or '-') for group in groups]
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
with vpnrouter_users.session() as connection:
answer = run(connection, options)
except Failure as failure:
message, status = str(failure), failure.status
except (ldap.LDAPError, OSError) as exc:
message, status = f'the user directory failed: {exc}', 1
else:
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-group: {message}', file=sys.stderr)
return status
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/python3
"""Make a token with the admin claim for the router's web service, with vpnrouter_auth.
The token is signed with the router's key and is valid for the lifetime given, without a
maximum. It may call every protected path, and it is the only token that may change the
admins group. The answer is the token on standard output, and a failure is a message on
standard error. With --json the answer is {"token", "expires"} on standard output, and a
failure is {"error": ...} there too. The exit status is 2 for an invalid request and 1 for
any other failure. -h or --help shows the options.
vpn-router-token [--json] (--minutes N | --hours N | --days N)
"""
import argparse
import json
import sys
sys.path.insert(0, '/usr/lib/vpn-router')
import vpnrouter_auth
#: The seconds of each unit of the lifetime.
UNITS = {'minutes': 60, 'hours': 3600, 'days': 86400}
class Failure(Exception):
"""A request that cannot be done; status is 2 invalid, 1 other."""
def __init__(self, message, status=1):
super().__init__(message)
self.status = status
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 positive(value):
try:
number = int(value)
except ValueError:
number = 0
if number < 1:
raise argparse.ArgumentTypeError(f'{value!r} is not a positive whole number')
return number
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:
parser = Parser(prog='vpn-router-token', description='Make a token with the admin claim for the web service.')
parser.add_argument('--json', action='store_true', help='answer in JSON')
lifetime = parser.add_mutually_exclusive_group(required=True)
for unit in UNITS:
lifetime.add_argument(f'--{unit}', type=positive, metavar='N', help=f'valid for N {unit}')
options = parser.parse_args(argv)
as_json = options.json
seconds = next(getattr(options, unit) * factor for unit, factor in UNITS.items() if getattr(options, unit))
answer = vpnrouter_auth.admin_token(seconds)
except Failure as failure:
message, status = str(failure), failure.status
except OSError as exc:
message, status = str(exc), 1
else:
print(json.dumps(answer) if as_json else answer['token'])
return 0
if as_json:
print(json.dumps({'error': message}))
else:
print(f'vpn-router-token: {message}', file=sys.stderr)
return status
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/python3
"""Manage the users of the router's directory from the command line, with vpnrouter_users.
Passwords and TOTP secrets are read from the first line of standard input, never from the
command line. 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 user does not exist, 4 the
user exists, 1 anything else. -h or --help shows the commands and their options.
vpn-router-user [--json] list
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]
(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] passwd <name> (password on stdin)
vpn-router-user [--json] disable <name>
vpn-router-user [--json] enable <name>
vpn-router-user [--json] unlock <name>
vpn-router-user [--json] remove <name>
vpn-router-user [--json] profile <name>
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)
"""
import argparse
import json
import sys
sys.path.insert(0, '/usr/lib/vpn-router')
import ldap
import vpnrouter
import vpnrouter_auth
import vpnrouter_users
from vpnrouter_users import Failure
#: The help line of each command.
HELP = {
'list': 'list the users',
'add': 'add a VPN user with the three person options and issue the certificate, or without the names a '
'Manager-only account, or with --service a service account, each with an optional e-mail address; the '
'password is read from standard input',
'update': "change the first name, the last name or the e-mail address; a Manager-only account has only the "
"e-mail address, which '' removes",
'passwd': 'set the password, read from standard input',
'disable': 'lock the user out of OpenVPN',
'enable': 'let the user in again',
'unlock': 'end a lockout after failed logins',
'remove': 'revoke the certificate and remove the user',
'profile': 'print the OpenVPN client profile',
'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 on, as the user's own POSIX account in the directory "
'or as a local account, or off',
}
#: The help of the person options.
PERSON_HELP = {'first_name': 'the first name', 'last_name': 'the last name', 'email': 'the e-mail address',
'display_name': "a service account's display name; '' removes it",
'description': "a service account's description; '' removes it"}
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-user', description='Manage the users 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 user name')
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])
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)
def password():
"""The password or the TOTP secret from the first line of standard input."""
return sys.stdin.readline().rstrip('\n')
def run(connection, options, secret):
"""The answer of the command the options name; secret is the password of add and passwd, the TOTP secret of otp-enrol."""
command = options.command
if command == 'list':
return vpnrouter_users.list_users(connection)
values = {key: getattr(options, key, None) for key, _ in vpnrouter_users.PERSON + vpnrouter_users.LABELS}
if command == 'add':
return vpnrouter_users.add(connection, options.name, secret, values, service=options.service)
if command == 'update':
return vpnrouter_users.update(connection, options.name, values)
if command == 'passwd':
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)
return getattr(vpnrouter_users, command.replace('-', '_'))(connection, options.name)
def console_text(console):
"""The console login of a user as a cell: -, off, posix (uid N) or local and the account."""
if console is None or not console['enabled']:
return '-' if console is None else 'off'
return f'local {console["account"]}' if console['local'] else f'posix (uid {console["uid"]})'
def text_of(answer):
"""The answer as text for people."""
if 'profile' in answer:
return answer['profile'].rstrip('\n')
if 'secret' in answer:
return f'secret {answer["secret"]}\nuri {answer["uri"]}'
if answer.get('removed'):
return f'user {answer["name"]} removed'
users = answer['users'] if 'users' in answer else [answer]
if not users:
return 'no users'
head = ('USER', 'KIND', 'STATE', 'CERTIFICATE', 'OTP', 'CONSOLE', 'FIRST NAME', 'LAST NAME', 'E-MAIL')
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 '-', console_text(user['console']),
user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-')
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))
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
if options.command != 'list':
vpnrouter_users.check_name(options.name)
secret = password() if options.command in ('add', 'passwd', 'otp-enrol') else None
with vpnrouter_users.session() as connection:
answer = run(connection, options, secret)
if options.command in vpnrouter_auth.REVOKING:
vpnrouter_auth.revoke(vpnrouter.load_config(), options.name)
except Failure as failure:
message, status = str(failure), failure.status
except (ldap.LDAPError, OSError) as exc:
message, status = f'the user directory failed: {exc}', 1
except vpnrouter_auth.DatabaseError as exc:
message, status = str(exc), 1
else:
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-user: {message}', file=sys.stderr)
return status
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))
@@ -8,29 +8,132 @@
"internal": "eth1"
},
"wan": {
"local_fqdn": "router.example.com",
"local_id_mode": "fqdn"
"local_fqdn": "router.example.com"
},
"local": {
"cidrs": ["10.0.0.0/24"],
"int_addr": "10.1.1.4",
"int_gateway_ip": "10.1.1.1"
},
"remote": {
"addrs": ["peer.example.net"],
"id": "peer.example.net",
"cidrs": ["192.168.0.0/24"],
"psk": "change-me",
"psk_file": ""
"site_to_site": {
"remote-site": {
"addrs": ["peer.example.net"],
"id": "peer.example.net",
"cidrs": ["192.168.0.0/24"],
"local_cidrs": ["10.0.0.0/24"],
"local_id_mode": "fqdn",
"auth": "psk",
"psk": "change-me",
"psk_file": "",
"remote_ca": ""
},
"branch": {
"addrs": ["branch.example.org"],
"id": "branch.example.org",
"cidrs": ["192.168.10.0/24"],
"local_cidrs": ["10.0.0.0/24"],
"local_id_mode": "fqdn",
"auth": "certificate"
}
},
"p2s": {
"enabled": false,
"address_pool": "172.16.0.0/24",
"ca_name": "VPN Router CA"
"address_pool": "172.16.0.0/24"
},
"wireguard": {
"enabled": false,
"address": "192.168.200.1/24",
"listen_port": 51820
},
"openvpn": {
"enabled": false,
"address_pool": "172.16.1.0/24",
"port": 1194,
"protocol": "udp"
},
"docker": {
"mode": "auto",
"exposed_ports": ["443"]
},
"web": {
"enabled": true,
"listen": "both",
"port": 7081,
"database": "",
"trusted_proxies": [],
"status_content": "basic",
"shell": false,
"shell_idle_minutes": 15
},
"ssh": {
"ldap_login": false
},
"https": {
"enabled": true,
"email": "",
"web_port": 7444,
"manager_port": 7443,
"caddy_source": "ubuntu"
},
"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": {
"servers": {
"name": "VPN Router Server CA",
"templates": ["server"],
"acme": false
}
},
"templates": {
"server": {
"days": 397
}
},
"extended_key_usages": {
"smartcard_logon": "1.3.6.1.4.1.311.20.2.2"
},
"root_key_warning": true
},
"metrics": {
"enabled": true,
"listen": "internal",
"port": 9464,
"https_port": 9465,
"service_account": "metrics",
"otlp_endpoint": "",
"otlp_username": "",
"otlp_password": "",
"keep_details": "24 hours",
"keep_hourly": "1 month",
"keep_daily": "3 months",
"keep_monthly": "1 year",
"keep_yearly": "10 years"
},
"backup": {
"enabled": false,
"schedule": "daily",
"target": "local",
"path": "/var/backups/vpn-router",
"keep_hourly": 24,
"keep_daily": 7,
"keep_weekly": 4,
"keep_monthly": 12,
"keep_yearly": 0,
"metrics": false,
"rclone_remote": "",
"rclone_mode": "sync",
"pbs_repository": "",
"pbs_password": "",
"pbs_fingerprint": "",
"pbs_namespace": "",
"pbs_keyfile": ""
}
}
@@ -0,0 +1,81 @@
# Written by vpn-router-setup from /etc/vpn-router/vpn-router.json; edits are overwritten.
{
auto_https disable_redirects
{%- if https_email %}
email {{ https_email }}
{%- endif %}
{%- if acme_cas %}
skip_install_trust
pki {
{%- for ca in acme_cas %}
ca {{ ca.id }} {
name "{{ ca.name }}"
root {
cert {{ ca.cert }}
key {{ ca.key }}
}
intermediate {
cert {{ ca.cert }}
key {{ ca.key }}
}
}
{%- endfor %}
}
{%- endif %}
}
{% if web_enabled or 'https' in pki_publish or acme_cas %}
{{ local_fqdn }}:{{ https_web_port }} {
{%- if 'https' in pki_publish %}
handle_path /pki/* {
root * {{ pki_publish_dir }}
file_server
}
{%- endif %}
{%- for ca in acme_cas %}
handle /acme/{{ ca.id }}/* {
acme_server {
ca {{ ca.id }}
}
}
{%- endfor %}
handle {
{%- if web_enabled %}
reverse_proxy {{ loopback }}:{{ web_port }}
{%- else %}
respond 404
{%- endif %}
}
}
{% endif %}
{%- if 'http-80' in pki_publish %}
http://{{ local_fqdn }} {
handle_path /pki/* {
root * {{ pki_publish_dir }}
file_server
}
handle {
respond 404
}
}
{% endif %}
{%- if 'https-default' in pki_publish %}
{{ local_fqdn }}:443 {
handle_path /pki/* {
root * {{ pki_publish_dir }}
file_server
}
handle {
respond 404
}
}
{% endif %}
{%- if manager_port %}
{{ local_fqdn }}:{{ https_manager_port }} {
reverse_proxy {{ loopback }}:{{ manager_port }}
}
{% endif %}
{%- if metrics_enabled %}
{{ local_fqdn }}:{{ metrics_https_port }} {
reverse_proxy {{ loopback }}:{{ metrics_port }}
}
{% endif %}
@@ -0,0 +1,12 @@
<LDAP>
URL {{ ldap_uri }}
Timeout 5
TLSEnable no
FollowReferrals no
</LDAP>
<Authorization>
BaseDN "{{ ldap_users }}"
SearchFilter "(&(objectClass=inetOrgPerson)(uid=%u))"
RequireGroup false
</Authorization>
@@ -0,0 +1,33 @@
port {{ openvpn_port }}
proto {{ openvpn_protocol }}
dev {{ openvpn_device }}
dev-type tun
topology subnet
server {{ openvpn_network }} {{ openvpn_netmask }}
ca {{ openvpn_ca }}
cert {{ server_cert }}
key {{ server_key }}
crl-verify {{ openvpn_crl }}
dh none
remote-cert-tls client
plugin /usr/lib/openvpn/openvpn-auth-ldap.so {{ openvpn_ldap_conf }}
script-security 2
tls-verify /usr/lib/vpn-router/openvpn-issuer
auth-user-pass-verify /usr/lib/vpn-router/openvpn-same-user via-env
{% for network, netmask in openvpn_routes -%}
push "route {{ network }} {{ netmask }}"
{% endfor -%}
{% for address in p2s_dns_list -%}
push "dhcp-option DNS {{ address }}"
{% endfor -%}
status {{ openvpn_status }} {{ openvpn_status_seconds }}
keepalive 10 120
{% if openvpn_protocol == 'udp' -%}
explicit-exit-notify 1
{% endif -%}
verb 3
@@ -1,46 +0,0 @@
connections {
remote-site {
version = 2
proposals = aes256-sha256-modp2048,aes256-sha1-modp1024
reauth_time = 28800
unique = replace
local_addrs = {{ local_addrs }}
remote_addrs = {{ remote_addrs }}
local {
id = {{ local_id }}
auth = psk
}
remote {
id = @{{ remote_id }}
auth = psk
}
children {
site2site {
mode = tunnel
local_ts = {{ local_ts }}
remote_ts = {{ remote_cidrs }}
esp_proposals = aes256-sha256,aes256-sha1
life_time = 3600
dpd_action = restart
start_action = start
close_action = none
}
}
rekey_time = 10800
dpd_delay = 10
dpd_timeout = 120
}
}
secrets {
ike-psk {
id-1 = {{ local_id }}
id-2 = @{{ remote_id }}
secret = "{{ psk }}"
}
}
@@ -8,13 +8,13 @@ connections {
local {
auth = pubkey
certs = server.pem
certs = vpn-server.pem
id = @{{ local_fqdn }}
}
remote {
auth = eap-tls
cacerts = ca.pem
cacerts = p2s.pem
eap_id = %any
}
@@ -0,0 +1,60 @@
connections {
{%- for connection in connections if connection.ready %}
{{ connection.name }} {
version = 2
proposals = aes256-sha256-modp2048,aes256-sha1-modp1024
reauth_time = 28800
unique = replace
local_addrs = {{ local_addrs }}
remote_addrs = {{ connection.remote_addrs }}
local {
id = {{ connection.local_id }}
{%- if connection.auth == 'certificate' %}
auth = pubkey
certs = site-to-site.pem
{%- else %}
auth = psk
{%- endif %}
}
remote {
id = {{ connection.remote_id }}
{%- if connection.auth == 'certificate' %}
auth = pubkey
cacerts = {{ connection.remote_cacert }}
{%- else %}
auth = psk
{%- endif %}
}
children {
{{ connection.name }} {
mode = tunnel
local_ts = {{ connection.local_ts }}
remote_ts = {{ connection.remote_cidrs }}
esp_proposals = aes256-sha256,aes256-sha1
life_time = 3600
dpd_action = restart
start_action = start
close_action = none
}
}
rekey_time = 10800
dpd_delay = 10
dpd_timeout = 120
}
{%- endfor %}
}
secrets {
{%- for connection in connections if connection.ready and connection.auth == 'psk' %}
ike-psk-{{ connection.name }} {
id-1 = {{ connection.local_id }}
id-2 = {{ connection.remote_id }}
secret = "{{ connection.psk }}"
}
{%- 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 }}
+15 -13
View File
@@ -3,7 +3,7 @@ data "http" "repo_gpg_key" {
}
locals {
supply_pki = var.ca_cert_file != ""
supply_pki = var.root_cert_file != ""
cloud_init_vars = {
hostname = var.name
@@ -15,15 +15,19 @@ locals {
# Contents of /etc/vpn-router/vpn-router.json.
config = {
general = { platform = "azure", mode = "auto" }
wan = { local_fqdn = var.local_fqdn, local_id_mode = var.local_id_mode }
local = { cidrs = [for c in split(",", var.local_cidrs) : trimspace(c) if trimspace(c) != ""] }
remote = {
addrs = [for a in split(",", var.remote_addrs) : trimspace(a) if trimspace(a) != ""]
id = var.remote_id
cidrs = local.remote_cidr_list
psk = var.psk
wan = { local_fqdn = var.local_fqdn }
local = { cidrs = local.local_cidr_list }
site_to_site = {
"remote-site" = {
addrs = [for a in split(",", var.remote_addrs) : trimspace(a) if trimspace(a) != ""]
id = var.remote_id
cidrs = local.remote_cidr_list
local_cidrs = local.local_cidr_list
local_id_mode = var.local_id_mode
psk = var.psk
}
}
p2s = { enabled = var.p2s_enabled, address_pool = var.p2s_address_pool, ca_name = "VPN Router CA" }
p2s = { enabled = var.p2s_enabled, address_pool = var.p2s_address_pool }
wireguard = { enabled = var.wireguard_enabled, address = var.wireguard_address, listen_port = var.wireguard_listen_port }
}
}
@@ -98,10 +102,8 @@ resource "azurerm_linux_virtual_machine" "router" {
custom_data = base64encode(
local.supply_pki
? templatefile("${path.module}/../cloud-init-with-pki.yaml.tpl", merge(local.cloud_init_vars, {
label = split(".", var.local_fqdn)[0]
ca_cert = file(var.ca_cert_file)
server_cert = file(var.server_cert_file)
server_key = file(var.server_key_file)
root_cert = file(var.root_cert_file)
root_key = var.root_key_file != "" ? file(var.root_key_file) : ""
}))
: templatefile("${path.module}/../cloud-init.yaml.tpl", local.cloud_init_vars)
)
+1
View File
@@ -1,5 +1,6 @@
locals {
remote_cidr_list = [for c in split(",", var.remote_cidrs) : trimspace(c) if trimspace(c) != ""]
local_cidr_list = [for c in split(",", var.local_cidrs) : trimspace(c) if trimspace(c) != ""]
}
resource "azurerm_route_table" "router" {
+4 -10
View File
@@ -100,20 +100,14 @@ variable "p2s_address_pool" {
default = ""
}
variable "ca_cert_file" {
description = "Path to an existing CA certificate PEM to supply instead of letting the package generate one. Leave empty to auto-generate."
variable "root_cert_file" {
description = "Path to an existing root CA certificate PEM to supply instead of letting the package create one. Leave empty to create one."
type = string
default = ""
}
variable "server_cert_file" {
description = "Path to an existing server certificate PEM, paired with ca_cert_file."
type = string
default = ""
}
variable "server_key_file" {
description = "Path to an existing server private key PEM, paired with ca_cert_file."
variable "root_key_file" {
description = "Path to the private key PEM of root_cert_file. Leave empty to keep the root offline: the issuing CAs then wait for certificates signed elsewhere."
type = string
default = ""
sensitive = true
+11 -13
View File
@@ -1,12 +1,13 @@
#cloud-config
#
# Example: install and configure vpn-router during first boot, supplying an
# existing CA and server certificate instead of letting the package generate
# one. See cloud-init.yaml.tpl for the variant that generates its own CA.
# existing root CA instead of letting the package create one. See
# cloud-init.yaml.tpl for the variant that creates its own.
#
# Template variables are Terraform templatefile() placeholders. Adapt or drop
# them for whatever renders this file. <label> is the first component of the
# FQDN, for example "router" in router.example.com.
# them for whatever renders this file. With root_key empty the root stays
# offline: the router's issuing CAs wait for their certificates, signed with
# the root elsewhere and imported with vpn-router-ca ca import.
hostname: ${hostname}
fqdn: ${fqdn}
@@ -28,21 +29,18 @@ write_files:
content: |
${jsonencode(config)}
- path: /etc/vpn-router/pki/ca_cert.pem
- path: /etc/vpn-router/pki/root/cert.pem
permissions: '0644'
owner: root:root
content: |
${indent(6, trimspace(ca_cert))}
- path: /etc/vpn-router/pki/${label}_cert.pem
permissions: '0644'
owner: root:root
content: |
${indent(6, trimspace(server_cert))}
- path: /etc/vpn-router/pki/${label}_key.pem
${indent(6, trimspace(root_cert))}
%{ if root_key != "" ~}
- path: /etc/vpn-router/pki/root/key.pem
permissions: '0600'
owner: root:root
content: |
${indent(6, trimspace(server_key))}
${indent(6, trimspace(root_key))}
%{ endif ~}
package_update: true
+3 -3
View File
@@ -10,9 +10,9 @@
# Template variables are Terraform templatefile() placeholders. Adapt or drop
# them for whatever renders this file.
#
# This variant lets the package generate its own CA on first boot. See
# cloud-init-with-pki.yaml.tpl for the variant that supplies existing PKI
# material instead.
# This variant lets the package create its own CAs on first boot. See
# cloud-init-with-pki.yaml.tpl for the variant that supplies an existing root
# CA instead.
hostname: ${hostname}
fqdn: ${fqdn}
+15 -13
View File
@@ -3,7 +3,7 @@ data "http" "repo_gpg_key" {
}
locals {
supply_pki = var.ca_cert_file != ""
supply_pki = var.root_cert_file != ""
cloud_init_vars = {
hostname = var.name
@@ -15,24 +15,26 @@ locals {
# Contents of /etc/vpn-router/vpn-router.json.
config = {
general = { platform = "gcp", mode = "auto" }
wan = { local_fqdn = var.local_fqdn, local_id_mode = var.local_id_mode }
local = { cidrs = [for c in split(",", var.local_cidrs) : trimspace(c) if trimspace(c) != ""] }
remote = {
addrs = [for a in split(",", var.remote_addrs) : trimspace(a) if trimspace(a) != ""]
id = var.remote_id
cidrs = local.remote_cidr_list
psk = var.psk
wan = { local_fqdn = var.local_fqdn }
local = { cidrs = local.local_cidr_list }
site_to_site = {
"remote-site" = {
addrs = [for a in split(",", var.remote_addrs) : trimspace(a) if trimspace(a) != ""]
id = var.remote_id
cidrs = local.remote_cidr_list
local_cidrs = local.local_cidr_list
local_id_mode = var.local_id_mode
psk = var.psk
}
}
p2s = { enabled = var.p2s_enabled, address_pool = var.p2s_address_pool, ca_name = "VPN Router CA" }
p2s = { enabled = var.p2s_enabled, address_pool = var.p2s_address_pool }
wireguard = { enabled = var.wireguard_enabled, address = var.wireguard_address, listen_port = var.wireguard_listen_port }
}
}
cloud_init_rendered = local.supply_pki ? templatefile("${path.module}/../cloud-init-with-pki.yaml.tpl", merge(local.cloud_init_vars, {
label = split(".", var.local_fqdn)[0]
ca_cert = file(var.ca_cert_file)
server_cert = file(var.server_cert_file)
server_key = file(var.server_key_file)
root_cert = file(var.root_cert_file)
root_key = var.root_key_file != "" ? file(var.root_key_file) : ""
})) : templatefile("${path.module}/../cloud-init.yaml.tpl", local.cloud_init_vars)
}
+1
View File
@@ -1,5 +1,6 @@
locals {
remote_cidr_list = [for c in split(",", var.remote_cidrs) : trimspace(c) if trimspace(c) != ""]
local_cidr_list = [for c in split(",", var.local_cidrs) : trimspace(c) if trimspace(c) != ""]
}
resource "google_compute_route" "to_remote" {
+4 -10
View File
@@ -97,20 +97,14 @@ variable "p2s_address_pool" {
default = ""
}
variable "ca_cert_file" {
description = "Path to an existing CA certificate PEM to supply instead of letting the package generate one. Leave empty to auto-generate."
variable "root_cert_file" {
description = "Path to an existing root CA certificate PEM to supply instead of letting the package create one. Leave empty to create one."
type = string
default = ""
}
variable "server_cert_file" {
description = "Path to an existing server certificate PEM, paired with ca_cert_file."
type = string
default = ""
}
variable "server_key_file" {
description = "Path to an existing server private key PEM, paired with ca_cert_file."
variable "root_key_file" {
description = "Path to the private key PEM of root_cert_file. Leave empty to keep the root offline: the issuing CAs then wait for certificates signed elsewhere."
type = string
default = ""
sensitive = true
+15 -10
View File
@@ -26,7 +26,7 @@ REMOTE_CIDRS="${REMOTE_CIDRS:-[\"192.168.0.0/24\"]}"
PSK="${PSK:-change-me}"
P2S_ENABLED="${P2S_ENABLED:-false}"
P2S_ADDRESS_POOL="${P2S_ADDRESS_POOL:-172.16.0.0/24}"
P2S_CA_NAME="${P2S_CA_NAME:-VPN Router CA}"
PKI_ROOT_NAME="${PKI_ROOT_NAME:-VPN Router Root CA}"
WG_ENABLED="${WG_ENABLED:-false}"
WG_ADDRESS="${WG_ADDRESS:-}"
WG_LISTEN_PORT="${WG_LISTEN_PORT:-51820}"
@@ -47,24 +47,29 @@ cat > /etc/vpn-router/vpn-router.json <<EOF
"internal": "${INTERNAL_INTERFACE}"
},
"wan": {
"local_fqdn": "${LOCAL_FQDN}",
"local_id_mode": "${LOCAL_ID_MODE}"
"local_fqdn": "${LOCAL_FQDN}"
},
"local": {
"cidrs": ${LOCAL_CIDRS},
"int_addr": "${INT_ADDR}",
"int_gateway_ip": "${INT_GATEWAY_IP}"
},
"remote": {
"addrs": ${REMOTE_ADDRS},
"id": "${REMOTE_ID}",
"cidrs": ${REMOTE_CIDRS},
"psk_file": "/etc/vpn-router/psk"
"site_to_site": {
"remote-site": {
"addrs": ${REMOTE_ADDRS},
"id": "${REMOTE_ID}",
"cidrs": ${REMOTE_CIDRS},
"local_cidrs": ${LOCAL_CIDRS},
"local_id_mode": "${LOCAL_ID_MODE}",
"psk_file": "/etc/vpn-router/psk"
}
},
"p2s": {
"enabled": ${P2S_ENABLED},
"address_pool": "${P2S_ADDRESS_POOL}",
"ca_name": "${P2S_CA_NAME}"
"address_pool": "${P2S_ADDRESS_POOL}"
},
"pki": {
"root_name": "${PKI_ROOT_NAME}"
},
"wireguard": {
"enabled": ${WG_ENABLED},
Executable
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Commit everything, tag the version of the newest changelog entry and push main with the tags.
The tag is the version of the first entry of debian-package/debian/changelog, without the
Debian revision 1: vpn-router (0.5.1-1) is tagged 0.5.1 and (0.5.1-2) 0.5.1-2. The tag
starts the release workflow.
"""
import argparse
import pathlib
import shlex
import subprocess
import sys
ROOT = pathlib.Path(__file__).resolve().parent
CHANGELOG = ROOT / 'debian-package' / 'debian' / 'changelog'
#: Print the git commands instead of running them; set by -n/--dry-run.
DRY_RUN = False
def git(*args):
"""Run git in the repository, or print the command on a dry run; stop with git's exit status when it fails."""
command = ['git', *args]
if DRY_RUN:
print(shlex.join(command))
return
result = subprocess.run(command, cwd=ROOT)
if result.returncode != 0:
sys.exit(result.returncode)
def summary():
"""The summary line of the first changelog entry, or None when it has none.
The summary is the entry's first text line: indented by two spaces, not a "*" item,
with a blank line between it and the header and a blank line after it.
"""
lines = CHANGELOG.read_text(encoding='utf-8').splitlines()
if len(lines) < 4 or lines[1].strip() or lines[3].strip():
return None
line = lines[2]
text = line.strip()
if not line.startswith(' ') or line.startswith(' ') or not text or text.startswith('*') or line.startswith(' -- '):
return None
return text
def version():
"""The tag of the first changelog entry: its version, without the Debian revision when that is 1."""
first = CHANGELOG.read_text(encoding='utf-8').splitlines()[0]
start, end = first.find('('), first.find(')')
if start == -1 or end < start:
sys.exit(f'{CHANGELOG}: the first line has no version: {first}')
full = first[start + 1:end]
upstream, _, revision = full.rpartition('-')
return upstream if upstream and revision == '1' else full
def main():
global DRY_RUN
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument('-m', '--message', help='commit message; default: the summary line of the newest changelog entry, a line without "*" between blank lines right after its header')
parser.add_argument('-n', '--dry-run', action='store_true', help='print the git commands instead of running them')
parser.add_argument('--no-push', action='store_true', help='commit and tag, but do not push')
args = parser.parse_args()
DRY_RUN = args.dry_run
message = args.message or summary()
if not message:
sys.exit(f'no commit message: pass -m, or start the newest entry of {CHANGELOG} with a summary line '
'without "*", indented by two spaces, with a blank line above and below it')
tag = version()
if subprocess.run(['git', 'rev-parse', '-q', '--verify', f'refs/tags/{tag}'], cwd=ROOT,
capture_output=True).returncode == 0:
sys.exit(f'tag {tag} exists; add a new changelog entry first')
git('add', '-A')
git('commit', '-m', message)
git('tag', tag)
if not args.no_push:
git('push', 'origin', 'main', '--tags')
return 0
if __name__ == '__main__':
sys.exit(main())