Compare commits

...
10 Commits
Author SHA1 Message Date
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
42 changed files with 3922 additions and 955 deletions

No files matched your search

+248 -32
View File
@@ -121,25 +121,30 @@ and it is backed up with `slapcat`, not by copying files.
Status: Accepted
Context: The router CA issues client certificates for IKEv2. 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.
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 CA and the CRL, 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
calls both and needs both to pass. A user's certificate has the user name as its common name, is
issued when the user is added and revoked when the user is removed. The CRL is created with the
CA, so that `crl-verify` has a file from the start, and is valid for ten years, because the router
renews it only when it revokes a certificate. The server uses the router's own certificate, whose
common name is `wan.local_fqdn`; the client profile checks that name (`verify-x509-name`), since
the CA's certificates carry both the server and the client purpose.
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 user's
certificate expires after the CA's default of a year and is renewed by removing and adding the
user again.
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
@@ -147,9 +152,9 @@ 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 CA. `simple-ca` keeps
the CA's state in one file, so two changes must not run at the same time. The web unit is
sandboxed with `ProtectSystem=strict`.
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
@@ -157,11 +162,14 @@ lock and the connection of the user manager account. The web service imports it
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; `/etc/vpn-router/pki` for the certificates and the CA's state, and
`/etc/swanctl/x509crl` for the published CRL.
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, and a password never leaves
the process that received it. The web service imports `python3-ldap` and the package's
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.
@@ -245,12 +253,13 @@ Context: Users are prepared before a P2S system is switched on, and the Router M
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 CA with its CRL; the server certificate follows once `wan.local_fqdn` is set.
`/users` and `vpn-router-user` rely only on the directory and the CA. Only the OpenVPN client
profile needs OpenVPN to be enabled.
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 CA always exists on a configured router,
even when no P2S system uses them. An operator who wants a CA of their own must place it before
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
@@ -606,18 +615,18 @@ 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. The package lists are updated by a cron job, not by the
manager.
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 the cron job may hold;
- 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. Only members of `admins`, as the router's `/account` answers, may start either.
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
@@ -679,3 +688,210 @@ one connection, dropping the others. A change through the API is answered before
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.
+465 -52
View File
@@ -2,8 +2,11 @@
A Debian package that configures a Linux host or virtual machine as a VPN router:
- site-to-site IKEv2 IPSec with pre-shared key authentication (strongSwan swanctl)
- road-warrior (P2S) access using IKEv2 EAP-TLS, with a bundled certificate authority
- site-to-site IKEv2 IPSec with pre-shared key or certificate authentication (strongSwan swanctl)
- road-warrior (P2S) access using IKEv2 EAP-TLS
- a two-level certificate authority: a root, which may be kept offline, and issuing CAs for the VPN
clients, for site-to-site and for general use, with certificate templates, published certificates
and CRLs, and an optional ACME server
- OpenVPN (P2S) access with a certificate and a password per user, the users kept in a local
LDAP directory and managed with `vpn-router-user` or the web service
- optional WireGuard endpoint
@@ -110,7 +113,6 @@ must have the type shown below; an unknown section or setting is an error.
| `site_to_site` | | object | | The site-to-site connections, each a name and an object of the settings below |
| `p2s` | `enabled` | boolean | `false` | Road-warrior access |
| `p2s` | `address_pool` | string | | CIDR assigned to road-warrior clients |
| `p2s` | `ca_name` | string | `VPN Router CA` | Common name for a locally created CA |
| `wireguard` | `enabled` | boolean | `false` | WireGuard endpoint |
| `wireguard` | `address` | string | | Address and prefix for `wg0`, for example `192.168.200.1/24` |
| `wireguard` | `listen_port` | integer | `51820` | UDP port |
@@ -130,6 +132,20 @@ must have the type shown below; an unknown section or setting is an error.
| `https` | `email` | string | | The e-mail address of the Let's Encrypt account |
| `https` | `web_port` | integer | `7444` | HTTPS port of the status web service |
| `https` | `manager_port` | integer | `7443` | HTTPS port of the Router Manager |
| `https` | `caddy_source` | string | `ubuntu` | Where Caddy is installed from: `ubuntu`, the Ubuntu archive, or `upstream`, Caddy's own repository (see HTTPS) |
| `pki` | `root_name` | string | `VPN Router Root CA` | Common name of the root CA, when the router creates it |
| `pki` | `root_days` | integer | `7300` | Days the root CA's certificate is valid |
| `pki` | `ca_days` | integer | `3650` | Days an issuing CA's certificate is valid |
| `pki` | `key` | string | `rsa-4096` | Key of the root and the issuing CAs: `rsa-2048`, `rsa-3072`, `rsa-4096`, `ec-p256` or `ec-p384` |
| `pki` | `crl_days` | integer | `7` | Days the CRLs of the issuing CAs are valid |
| `pki` | `root_crl_days` | integer | `180` | Days the root CRL is valid, when the router signs it |
| `pki` | `publish` | list | `http` | Where the CA certificates and CRLs are published: `http`, `http-80`, `https`, `https-default` (see Certificates) |
| `pki` | `p2s_name` | string | `VPN Router P2S CA` | Common name of the P2S issuing CA |
| `pki` | `s2s_name` | string | `VPN Router Site-to-Site CA` | Common name of the site-to-site issuing CA |
| `pki` | `issuing_cas` | object | | The general-purpose issuing CAs, each a name and `{name, templates, acme}` (see Certificates) |
| `pki` | `templates` | object | | Certificate templates, each a name and the options it changes or defines (see Certificates) |
| `pki` | `extended_key_usages` | object | | Custom extended key usages templates may give, each a name and its OID in dotted form (see Certificates) |
| `pki` | `root_key_warning` | boolean | `true` | `vpn-router-ca status` and `/ca` warn while the root key is on the router; `false` silences that warning |
| `metrics` | `enabled` | boolean | `true` | The metrics service: collection, history and `/metrics` |
| `metrics` | `listen` | string | `internal` | The interfaces `/metrics` is served on: `internal`, `external` or `both` |
| `metrics` | `port` | integer | `9464` | TCP port of `/metrics` |
@@ -143,6 +159,23 @@ must have the type shown below; an unknown section or setting is an error.
| `metrics` | `keep_daily` | string | `3 months` | How long the daily summaries are kept |
| `metrics` | `keep_monthly` | string | `1 year` | How long the monthly summaries are kept |
| `metrics` | `keep_yearly` | string | `10 years` | How long the yearly summaries are kept |
| `backup` | `enabled` | boolean | `false` | Scheduled backups (see Backups) |
| `backup` | `schedule` | string | `daily` | `hourly`, `daily` or `weekly` |
| `backup` | `target` | string | `local` | `local`, an archive in `backup.path`, or `pbs`, a Proxmox Backup Server |
| `backup` | `path` | string | `/var/backups/vpn-router` | The folder of local archives, an absolute path |
| `backup` | `keep_hourly` | integer | `24` | Hourly local archives kept, with the hourly schedule |
| `backup` | `keep_daily` | integer | `7` | Daily local archives kept, with the hourly and the daily schedule |
| `backup` | `keep_weekly` | integer | `4` | Weekly local archives kept |
| `backup` | `keep_monthly` | integer | `12` | Monthly local archives kept |
| `backup` | `keep_yearly` | integer | `0` | Yearly local archives kept |
| `backup` | `metrics` | boolean | `false` | The backup holds the database of the metrics and the refresh tokens |
| `backup` | `rclone_remote` | string | | `remote:path` of `/etc/vpn-router/rclone.conf` the local archives are copied to |
| `backup` | `rclone_mode` | string | `sync` | `sync` mirrors the archives, `copy` deletes nothing in the cloud |
| `backup` | `pbs_repository` | string | | `user@realm!token@host[:port]:datastore` |
| `backup` | `pbs_password` | string | | The secret of the API token |
| `backup` | `pbs_fingerprint` | string | | The SHA-256 fingerprint of the server's certificate, when the system's CAs do not verify it |
| `backup` | `pbs_namespace` | string | | The namespace of the datastore, such as `routers/azure` |
| `backup` | `pbs_keyfile` | string | | An encryption key of `proxmox-backup-client key create`, an absolute path |
### Site-to-site connections
@@ -159,13 +192,23 @@ of the status web service, which the Router Manager uses.
| `cidrs` | list | | Remote subnets reachable through the tunnel |
| `local_cidrs` | list | | Local subnets advertised into the tunnel |
| `local_id_mode` | string | `fqdn` | The router's IKE identity: `fqdn` is `wan.local_fqdn`, `public_ip` the address it resolves to, `internal_ip` the internal address |
| `psk` | string | | Pre-shared key |
| `auth` | string | `psk` | How both sides authenticate: `psk`, a pre-shared key, or `certificate` |
| `psk` | string | | Pre-shared key, with `auth` `psk` |
| `psk_file` | string | | Path to a file holding the key; wins over `psk` |
| `remote_ca` | string | | With `auth` `certificate`, the PEM certificate of the CA that issued the peer's certificate; without it the router's site-to-site CA |
A connection is set up once all of `addrs`, `id`, `cidrs`, `local_cidrs`, the key and its
local identity are known; all such connections are written to
`/etc/swanctl/conf.d/site-to-site.conf`. Traffic from a connection's `local_cidrs` to its
`cidrs` is not masqueraded.
A connection is set up once all of `addrs`, `id`, `cidrs`, `local_cidrs` and its local
identity are known, with `psk` the key, and with `certificate` the router's site-to-site
certificate; all such connections are written to `/etc/swanctl/conf.d/site-to-site.conf`.
Traffic from a connection's `local_cidrs` to its `cidrs` is not masqueraded.
With `auth` `certificate`, the router authenticates with its site-to-site certificate, which the
site-to-site issuing CA signs for `wan.local_fqdn` and for the public or the internal address
that a connection with `auth` `certificate` uses as its identity (`local_id_mode` `public_ip` or
`internal_ip`); it is issued again when that set of names changes. The peer's certificate must name `id` and be issued
by the router's site-to-site CA, or by the CA of `remote_ca`. A peer with no PKI of its own gets
its certificate from the router (template `site-to-site`, see Certificates), with the root and
the site-to-site CA certificates as its trust anchors.
A minimal site-to-site configuration in `auto` mode:
@@ -324,57 +367,236 @@ anything:
```
This does still write to the system: it renders into `/etc/swanctl/`,
`/etc/systemd/resolved.conf.d/` and `/etc/wireguard/`, and if road-warrior access is
enabled with an empty PKI directory it creates the certificate authority and adds it
to the host trust store. What it leaves alone is routes, firewall rules and services -
that is what `systemctl restart vpn-router-setup` adds.
`/etc/systemd/resolved.conf.d/` and `/etc/wireguard/`, creates what the PKI is missing and
adds the root CA to the host trust store. What it leaves alone is routes, firewall rules and
services - that is what `systemctl restart vpn-router-setup` adds.
## Certificates
`/etc/vpn-router/pki` is the PKI directory, laid out the way `simple-ca` expects:
The router has a two-level certificate authority, kept by `vpn-router-setup` and managed with
`vpn-router-ca`, `vpn-router-user` and `/ca` of the status web service, which the Router Manager
uses. It is held in `ou=pki,dc=vpn-router,dc=local` of the router's directory, next to the users:
```
ca_cert.pem ca_key.pem ca_bundle.pem <label>_cert.pem <label>_key.pem crl.pem
cn=root the root CA; without vpnRouterPrivateKey while the root is offline
cn=<name> an issuing CA; with vpnRouterCertificateRequest while it waits for the offline root
cn=<name>-<time> an issuing CA taken out of use, with vpnRouterRetired
serialNumber=<serial>,cn=<CA> a certificate the CA issued
```
`<label>` is the first component of the router FQDN, so `router.example.com` gives
`router_cert.pem`.
Once a platform is chosen and while this directory is empty, a certificate authority and a CRL
valid for ten years are created there automatically, whether or not road-warrior or OpenVPN
access is enabled. A CA of the router's own (with `ca_key.pem`) without a CRL gets one, and
without a server certificate gets one once `wan.local_fqdn` is set. User certificates are issued
by `vpn-router-user` into `users/`. To use your own CA instead, place the files in that layout
before choosing the platform; nothing present is overwritten.
Either way, the package copies what each consumer needs into place: the CA into
`/etc/swanctl/x509ca/`, the server certificate and key into `/etc/swanctl/x509/` and
`/etc/swanctl/private/`, a CRL into `/etc/swanctl/x509crl/`, and the CA into the system
trust store.
Issue a client certificate with the bundled tool:
A CA entry (class `vpnRouterAuthority`) has its certificate (`cACertificate;binary`), key, CRL
(`certificateRevocationList;binary`) and CRL number. A certificate entry (class
`vpnRouterCertificate`) has the certificate (`userCertificate;binary`), its kind (`ca`, `router`,
`user` or `external`), template, names, expiry and revocation, and for the router's and the users'
certificates the key. A user's certificate is also the `userCertificate` of the user's entry. No
account of the directory reads a key (`vpnRouterPrivateKey`); only root does, through the local
socket. `slapcat` writes the whole PKI into every backup with the users.
```sh
/usr/lib/vpn-router/simple-ca make-cert --ca-dir /etc/vpn-router/pki alice
/usr/lib/vpn-router/simple-ca make-pfx --ca-dir /etc/vpn-router/pki \
--password s3cr3t /etc/vpn-router/pki/alice_cert.pem
ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b ou=pki,dc=vpn-router,dc=local -s one cn
```
`simple-ca` comes from a separate project, <https://gitea.koszewscy.waw.pl/slawek/simple-ca>,
and also supports `make-crl` and `revoke-cert`.
### The CAs
The root signs the issuing CAs only (path length 1), and the issuing CAs sign the certificates
(path length 0):
- `p2s`, named `pki.p2s_name`, signs the router's VPN server certificate, which IKEv2 and OpenVPN
present, and the users' certificates. Road-warrior and OpenVPN accept client certificates of
this CA only.
- `s2s`, named `pki.s2s_name`, signs the router's site-to-site certificate and the certificates
of its peers.
- The general-purpose CAs of `pki.issuing_cas`, each a name and `{"name": common name,
"templates": [...], "acme": false}`. The name starts with a lowercase letter, has at most 31
lowercase letters, digits and hyphens, and is not `root`, `p2s` or `s2s`; `templates` are the
templates the CA issues.
Once a platform is chosen, `vpn-router-setup` creates what is missing: the root when it has
neither certificate nor key, each issuing CA, the router's certificates once `wan.local_fqdn` is
set, and the CRLs. The names, lifetimes and key type of `pki` apply when something is created. An
issuing CA removed from `pki.issuing_cas` is retired: its entry is renamed `cn=<name>-<time>`,
loses its key, and its certificate is revoked on the root CRL. The router renews its own certificates when less than a
third of their lifetime is left, when the names they need change, or when their CA changes; a
user's certificate is renewed by removing and adding the user.
The package copies what each consumer needs: the root, `p2s` and `s2s` into
`/etc/swanctl/x509ca/` as `root.pem`, `p2s.pem` and `s2s.pem` (strongSwan reads one certificate
per file), the remote CA of each site-to-site connection that has one as `s2s-<name>.pem`, the
router's certificates into `/etc/swanctl/x509/` and their keys into `/etc/swanctl/private/`, the
CRLs into `/etc/swanctl/x509crl/`, the root and `p2s` with their CRLs into
`/etc/openvpn/server/vpn-router-ca.pem` and `vpn-router-crl.pem`, and the root into the system
trust store.
`vpn-router-ca status` shows the root, the issuing CAs and what needs attention:
```sh
vpn-router-ca status
vpn-router-ca list --ca p2s
```
### Templates
A template says what a certificate holds:
| Option | Type | Default | Meaning |
|---|---|---|---|
| `key` | string | `rsa-3072` | `rsa-2048`, `rsa-3072`, `rsa-4096`, `ec-p256` or `ec-p384`, for a key the router makes |
| `days` | integer | `365` | Days the certificate is valid, at most until its CA's certificate expires |
| `key_usage` | list | `digital_signature` | Of `digital_signature`, `content_commitment`, `key_encipherment`, `data_encipherment`, `key_agreement` |
| `extended_key_usage` | list | | Of the built-in `server_auth`, `client_auth`, `code_signing`, `email_protection`, `time_stamping`, `ocsp_signing`, `ipsec_ike`, `ike_intermediate`, and the custom usages of `pki.extended_key_usages` |
| `policies` | list | | Certificate policy OIDs in dotted form, written into a Certificate Policies extension |
| `subject` | string | `name` | The common name: `dns`, a DNS name, which is also a subject alternative name; `name`, any name; `email`, an e-mail address |
| `sans` | list | | The subject alternative names allowed: of `dns`, `ip`, `email` |
The built-in templates:
| Template | Key usage | Extended key usage | Subject | Names | Issued by |
|---|---|---|---|---|---|
| `vpn-server` | digital signature, key encipherment | server auth, IKE intermediate | `dns` | `dns`, `ip` | `p2s`, for the router only |
| `vpn-user` | digital signature | client auth | `name` | `dns`, `email` | `p2s`, for the users only |
| `site-to-site` | digital signature, key encipherment | server auth, client auth | `dns` | `dns`, `ip` | `s2s` |
| `server` | digital signature, key encipherment | server auth | `dns` | `dns`, `ip` | general-purpose CAs |
| `client` | digital signature | client auth | `name` | `dns`, `email` | general-purpose CAs |
`pki.templates` changes options of the built-in templates, which cannot be removed, and adds
templates of its own, named like the issuing CAs:
```json
"pki": {
"issuing_cas": { "servers": { "name": "Example Server CA", "templates": ["server", "web", "smartcard"] } },
"extended_key_usages": { "smartcard_logon": "1.3.6.1.4.1.311.20.2.2" },
"templates": {
"server": { "days": 397 },
"web": { "key": "ec-p256", "days": 90, "key_usage": ["digital_signature"],
"extended_key_usage": ["server_auth"], "subject": "dns", "sans": ["dns"] },
"smartcard": { "key_usage": ["digital_signature", "key_encipherment"],
"extended_key_usage": ["client_auth", "smartcard_logon"], "subject": "name",
"sans": ["email"], "policies": ["1.3.6.1.4.1.99999.1.1"] }
}
}
```
A custom extended key usage is first added to `pki.extended_key_usages`, a name of at most 31
lowercase letters, digits, hyphens and underscores that is not a built-in one, and its OID; a
template then gives it by its name. A usage a template gives cannot be removed. `policies` puts
the OIDs of the issuance policies a certificate is issued under into its Certificate Policies
extension, without qualifiers.
### Issuing and revoking
```sh
vpn-router-ca issue --ca servers --template server --csr web.csr > web.pem
vpn-router-ca issue --ca servers --template server --subject web.example.com --san 10.0.0.5 --pfx web.pfx
vpn-router-ca revoke 5F0A12C3D4 --reason key_compromise
```
With a CSR, the certificate is for the CSR's key, common name and subject alternative names,
checked against the template, and the certificate and the chain (issuing CA, root) are printed.
Without one, the router makes a key as the template says and writes a PKCS#12 file with the key,
the certificate and the chain, protected by the password read from the first line of standard
input; the router keeps the certificate and not the key. `vpn-server` and `vpn-user` are issued by
the router only.
`revoke` takes the serial number `list` shows and a reason: `unspecified`, `key_compromise`,
`affiliation_changed`, `superseded`, `cessation_of_operation` or `privilege_withdrawn`. A user's
certificate is revoked by removing the user, and an issuing CA by removing it from
`pki.issuing_cas`; a revoked router certificate is issued again by `vpn-router-setup`.
### Published certificates and CRLs
Every certificate names its issuer's certificate (AIA) and CRL (CRL distribution point) at each
endpoint of `pki.publish`, in that order. They are served, without authentication, as DER at
`/pki/<name>.crt` and `/pki/<name>.crl`, the root as `root`, from copies in
`/var/lib/vpn-router/pki-publish`:
| Endpoint | URL | Served by |
|---|---|---|
| `http` | `http://<wan.local_fqdn>:<web.port>/pki/` | the status web service; needs `web.enabled` |
| `http-80` | `http://<wan.local_fqdn>/pki/` | Caddy, on port 80, open on both interfaces; needs `https.enabled` |
| `https` | `https://<wan.local_fqdn>:<https.web_port>/pki/` | Caddy; needs `https.enabled` |
| `https-default` | `https://<wan.local_fqdn>/pki/` | Caddy, on port 443, open on both interfaces; needs `https.enabled` |
The URLs are written into a certificate when it is signed, so a change of `pki.publish` or of the
names applies to the certificates signed afterwards. An empty `pki.publish` leaves the extensions
out.
The CRLs of the issuing CAs are valid for `pki.crl_days`, and the root CRL, when the router signs
it, for `pki.root_crl_days`. A CRL is signed again at every revocation and, once half of its
validity has passed, by `vpn-router-pki.timer`, which runs `vpn-router-ca maintain` daily; it also
has `vpn-router-setup` renew the router's certificates when they are due. OpenVPN checks the CRL
of every CA of a client's chain, the root's included, and refuses every client while one has
expired.
### Offline root
Keeping the root key off the router is recommended, not required; while it is on the router,
`vpn-router-ca status` says so, unless `pki.root_key_warning` is `false`. Export it, encrypted with a
passphrase read from the first line of standard input, keep the file in a safe place, and remove
the key from the router:
```sh
vpn-router-ca root export-key --out /root/vpn-router-root-key.pem
vpn-router-ca root remove-key
```
While the root key is not on the router, a new issuing CA gets its key and a CSR and waits;
`vpn-router-ca status` says so. Print the CSR and the extensions to sign it with, sign it with the
root on the offline machine, and import the certificate:
```sh
vpn-router-ca ca csr servers # the CSR, then the [ issuing_ca ] extensions
# on the offline machine, with the CSR in servers.csr and the extensions in issuing_ca.cnf:
openssl x509 -req -in servers.csr -CA root-cert.pem -CAkey vpn-router-root-key.pem -CAcreateserial \
-days 3650 -sha256 -extfile issuing_ca.cnf -extensions issuing_ca -out servers.pem
vpn-router-ca ca import servers servers.pem
```
The router cannot sign the root CRL without the key. Sign it where the key is, or put the key back
for a moment, and take the CRL before the current one expires:
```sh
vpn-router-ca root import-key /root/vpn-router-root-key.pem # the passphrase on standard input
vpn-router-ca maintain
vpn-router-ca root remove-key
# or, with a CRL signed elsewhere:
vpn-router-ca root import-crl root.crl
```
An existing root CA of your own is used by placing its certificate, and its key if the router may
use it, as `/etc/vpn-router/pki/root/cert.pem` and `key.pem` before `vpn-router-setup` first runs
with a platform, as cloud-init can. Setup imports them into the directory and removes the files.
Without the key, the issuing CAs wait for their certificates as above.
### ACME server
A general-purpose CA with `"acme": true` backs an ACME server of Caddy, while `https.enabled`, at
`https://<wan.local_fqdn>:<https.web_port>/acme/<name>/directory`, so servers get and renew
certificates with any ACME client:
```sh
certbot certonly --standalone -d web.example.com \
--server https://router.example.com:7444/acme/servers/directory
```
Caddy proves the client's control of the name as Let's Encrypt does, so it must reach the client.
The certificates are valid for 12 hours, carry Caddy's extensions, are not listed by
`vpn-router-ca list`, cannot be revoked, and no template applies to them. Caddy reads the CA's
certificate and key from `/etc/caddy/vpn-router-pki/<name>/`, readable by its group.
## OpenVPN and users
With `openvpn.enabled`, an `address_pool` and `wan.local_fqdn` set, `vpn-router-setup` runs
`openvpn-server@vpn-router` with `/etc/openvpn/server/vpn-router.conf`. The server uses the
router's CA, certificate and CRL (the copies under `/etc/swanctl`), pushes routes to
`local.cidrs` and the router as DNS server, and opens `openvpn.port` on the external interface.
router's VPN server certificate, the root and the P2S CA and their CRLs (see Certificates),
pushes routes to `local.cidrs` and the router as DNS server, and opens `openvpn.port` on the
external interface.
A client needs both a certificate of the router CA and the password of the user the
A client needs both a certificate of the P2S CA and the password of the user the
certificate names:
- the certificate is checked against the CA and the CRL (`crl-verify`, read at every
connection);
- the certificate is checked against the root and the P2S CA and their CRLs (`crl-verify`, read
again when the file changes), and `/usr/lib/vpn-router/openvpn-issuer` (`tls-verify`) refuses
a certificate another issuing CA signed;
- the password is checked by `openvpn-auth-ldap`, which binds to the user directory as the
user; a user enrolled for TOTP types the password followed by the current 6-digit code;
- `/usr/lib/vpn-router/openvpn-same-user` refuses a login whose user name is not the common
@@ -423,7 +645,7 @@ it is added:
- a VPN user, an `inetOrgPerson` with a first name, a last name and an e-mail address
(`givenName`, `sn` and `mail`; `cn` is the first and the last name), and a client certificate
with the user name as its common name, in `/etc/vpn-router/pki/users`;
with the user name as its common name, the entry's `userCertificate`, whose key the PKI keeps;
- a Manager-only account, an `account`, `simpleSecurityObject` and `vpnRouterAccount` with the
name, the password and an optional e-mail address, for notifications: no names and no
certificate, so no VPN access. It can sign in to `/auth` and, as a member of `admins`, manage
@@ -694,6 +916,12 @@ curl -H 'Content-Type: application/json' -d '{"name": "alice", "password": "s3cr
curl -H "Authorization: Bearer $TOKEN" http://10.0.0.4:7081/config
```
### The /status/full page
`/status/full`, with the same token as `/config`, shows the whole status document, as `/status`
does with `web.status_content` `full`, whatever that setting says, so the Router Manager shows
every service and tunnel while the public status page shows less.
### The /config page
`/config` shows what `/` and `/status` leave out, for a client such as manage-ui:
@@ -706,6 +934,7 @@ curl -H "Authorization: Bearer $TOKEN" http://10.0.0.4:7081/config
| `wireguard` | The public key and port of `wg0`, and each peer's public key, endpoint, allowed IPs, handshake age and traffic; no private or preshared key |
| `docker` | `docker.exposed_ports` and the rules of the `VPN-ROUTER-DOCKER` chain |
| `postgresql` | `clusters`: each PostgreSQL cluster of `pg_lsclusters` with its version, name, port, status and data directory, and for one that is online its `settings`: `server_version`, `listen_addresses`, `port`, `max_connections`, `shared_buffers`, `effective_cache_size`, `work_mem`, `password_encryption`, `ssl` and `timezone`; `null` when `pg_lsclusters` fails |
| `caddy` | The installed Caddy: `version`, and `source`, `upstream` once Caddy's repository is configured and `ubuntu` otherwise; `null` when Caddy is not installed |
### The /postgresql page
@@ -720,7 +949,7 @@ router:
`/users` manages users with the same token as `/config`, with the module `vpn-router-user` uses
too, `/usr/lib/vpn-router/vpnrouter_users.py`, so both apply the same rules. Request
and answer bodies are JSON; a request body is at most 4096 bytes.
and answer bodies are JSON; a request body is at most 65536 bytes.
| Request | Body | Answer |
| --- | --- | --- |
@@ -813,8 +1042,8 @@ group.
| Request | Body | Answer |
| --- | --- | --- |
| `GET /site-to-site` | | `{"connections": [{"name", "addrs", "id", "cidrs", "local_cidrs", "local_id_mode", "psk_file", "psk_set", "established"}, ...]}` |
| `POST /site-to-site` | `{"name", "addrs", "id", "cidrs", "local_cidrs", "local_id_mode", "psk"}`; `psk` is required | 201 and the connection |
| `GET /site-to-site` | | `{"connections": [{"name", "addrs", "id", "cidrs", "local_cidrs", "local_id_mode", "auth", "psk_file", "remote_ca", "psk_set", "established"}, ...]}` |
| `POST /site-to-site` | `{"name", "addrs", "id", "cidrs", "local_cidrs", "local_id_mode", "auth", "psk", "remote_ca"}`; `psk` is required with `auth` `psk`, the default | 201 and the connection |
| `PUT /site-to-site/<name>` | any of the settings of `POST` but `name`; an empty or absent `psk` keeps the key, a `psk` given replaces `psk_file` | the connection |
| `DELETE /site-to-site/<name>` | | `{"name", "removed": true}` |
@@ -826,6 +1055,60 @@ is `{"error": ...}` with 400 for an invalid request or configuration, with the r
connection that does not exist, 409 for a name that exists and 500 otherwise. Keys cross the
internal network in plain HTTP, like the token.
### The /backup API
`/backup` shows and changes the backup settings and starts a backup, with the same token as
`/users`.
| Request | Body | Answer |
| --- | --- | --- |
| `GET /backup` | | `{"settings", "state", "archives", "next", "client", "rclone"}` |
| `PUT /backup` | any of the settings of the `backup` section; an empty or absent `pbs_password` keeps the secret | `{"settings"}` |
| `POST /backup/run` | | 202 and `{"started": true}`; 409 while a backup runs |
The answer of `GET /backup` holds:
- **`settings`:** the `backup` section without `pbs_password`, with `pbs_password_set`.
- **`state`:** the last run, `null` before the first. Its `result` is `running`, `ok`, `warning` (the backup was made but the cloud copy failed), `failed` or `interrupted` (the run ended without recording its result). It also has `started`, `finished`, `target`, `archive` or `snapshot`, `size` in bytes, `removed` (the archives retention deleted), `error` and `cloud` (`{"remote", "mode", "result", "error"}`).
- **`archives`:** the local archives of this host, newest first, as `{"name", "time", "size"}`.
- **`next`:** the next run as systemd shows it, `null` while backups are off.
- **`client`:** whether `proxmox-backup-client` is installed.
- **`rclone`:** `{"installed", "config", "remotes"}`, the remotes being the sections of `rclone.conf`.
A change is made, checked and applied as for `/site-to-site`.
### The /ca API
`/ca` manages the PKI (see Certificates) with the same token as `/users`, with the module
`vpn-router-ca` uses too, `/usr/lib/vpn-router/vpnrouter_pki.py`.
| Request | Body | Answer |
| --- | --- | --- |
| `GET /ca` | | `{"settings", "templates", "extended_key_usages", "status", "certificates"}` |
| `PUT /ca` | any of the settings of the `pki` section | `{"settings", "templates"}` |
| `POST /ca/certificates` | `{"ca", "template", "csr"}` | 201 and `{"serial", "certificate", "chain"}`, PEM |
| `POST /ca/certificates` | `{"ca", "template", "subject", "sans", "password"}` | 201 and `{"serial", "pfx"}`, a PKCS#12 file in base64 |
| `POST /ca/certificates/<serial>/revoke` | `{"reason"}`, optional | the certificate, with `reissue` for a router certificate |
| `GET /ca/bundle/<name>` | | `{"name", "pem"}`: the certificates a client trusts the CA with, the root alone for `root`, the issuing CA followed by the root for an issuing CA |
| `GET /ca/issuing/<name>/csr` | | `{"name", "csr", "extensions"}` of an issuing CA waiting for the offline root |
| `PUT /ca/issuing/<name>/certificate` | `{"certificate"}`, PEM, signed by the root | `{"name", "subject", "expires"}` |
| `PUT /ca/root/crl` | `{"crl"}`, PEM, signed by the root | `{"next_update"}` |
The answer of `GET /ca` holds:
- **`settings`:** the `pki` section.
- **`templates`:** every template, the built-in ones included, with all its options.
- **`extended_key_usages`:** every extended key usage templates may give, name -> `{"oid", "builtin"}`.
- **`status`:** `root` (`subject`, `expires`, `key`, whether the root key is on the router, `crl_next_update` and `crl_source`, `signed` or `uploaded`), `authorities`, one per issuing CA (`name`, `common_name`, `state`: `active`, `pending` or `missing`, `builtin`, `templates`, `acme`, `subject`, `expires`, `key`, `crl_next_update`, `acme_url`), `retired` and `warnings`.
- **`certificates`:** the certificates issued, newest first, each `{"serial", "ca", "kind", "template", "subject", "sans", "created", "expires", "revoked", "reason", "state"}`; `kind` is `ca`, `router`, `user` or `external`, and `state` `valid`, `expired` or `revoked`.
A change of the settings is made, checked and applied as for `/site-to-site`; an imported issuing
CA certificate and a revoked router certificate restart `vpn-router-setup` too. A failure is
`{"error": ...}` with 400 for an invalid request, 404 for something that does not exist, 409 when
the state does not allow it, as for a revoked certificate or an issuing CA that has its
certificate, and 500 otherwise. The root key never crosses the API; `vpn-router-ca` exports,
removes and imports it.
The service starts on its own, without waiting for `vpn-router-setup`, and keeps running. Every
30 seconds it reads `web.listen`, `web.port` and `web.enabled` from `vpn-router.json` and works
out the addresses of the interfaces `web.listen` names as `general.mode` directs; it listens on
@@ -838,7 +1121,7 @@ and the facts for `/config` from `/run/vpn-router/web.json` (readable by root on
## HTTPS
Caddy, from Ubuntu's `caddy` package, serves the status web service on `https.web_port` (7444 by
Caddy serves the status web service on `https.web_port` (7444 by
default), while the Router Manager is installed the manager on `https.manager_port` (7443), and
while metrics are enabled the metrics service on `metrics.https_port` (9465), all for `wan.local_fqdn`, while `https.enabled` is `true` and `wan.local_fqdn` is set. It obtains
and renews the certificate from Let's Encrypt with the HTTP challenge, so `wan.local_fqdn` must
@@ -852,7 +1135,8 @@ package's drop-in `/usr/lib/systemd/system/caddy.service.d/vpn-router.conf` make
read; `caddy.service` does not start before the file exists, and `/etc/caddy/Caddyfile` is not
used. Setup starts Caddy, reloads it when the file changes, and stops it and removes the file when
HTTPS is off. In the firewall it opens port 80 on the external interface and the HTTPS ports on the
internal and the external interface. Caddy calls both services on `127.0.0.1`, where they listen
internal and the external interface, and ports 80 and 443 on both when `pki.publish` has `http-80`
and `https-default`, on which Caddy serves only the CA files (see Certificates). Caddy calls both services on `127.0.0.1`, where they listen
whatever `web.listen` and the manager's `listen` say, and passes the client's address in
`X-Forwarded-For`, which the manager trusts from `127.0.0.1` and the web service by default, as
the address the call came in on; a `web.trusted_proxies` that is set must list `127.0.0.1`. The plain HTTP ports stay as
@@ -863,6 +1147,34 @@ the HTTPS ports while `https.enabled` is `true`.
curl https://router.example.com:7444/status
```
### Installing Caddy
The package does not depend on Caddy; `vpn-router-setup` has it installed, while HTTPS is on,
from the source `https.caddy_source` names:
- `ubuntu`, the default: the `caddy` package of the Ubuntu archive.
- `upstream`: Caddy's own repository, `https://dl.cloudsmith.io/public/caddy/stable/deb/debian`,
whose source the package writes to `/etc/apt/sources.list.d/caddy-stable.sources` with its key,
downloaded over HTTPS, in `/etc/apt/keyrings/caddy-stable.asc`.
apt runs in `vpn-router-caddy.service`, which waits for dpkg's lock, so setup run by a package
installation does not wait for itself; the unit runs `vpn-router-caddy apply` and then setup
again, which serves with the new Caddy. `vpn-router-caddy status` shows the configured source, the
source in effect and the versions.
A router installed with `ubuntu` is switched to upstream's Caddy with:
```sh
vpn-router-caddy use-upstream
```
It sets `https.caddy_source` to `upstream`, configures the repository and replaces Caddy, printing
each step. Upstream versions sort above Ubuntu's, so once the repository is configured apt keeps
upstream's Caddy, and `ubuntu` has no effect; setup says so. Going back is a downgrade nothing
does on its own: remove the source and the key, run `apt-get update`, and install Ubuntu's version
with `apt-get install --allow-downgrades caddy=<version>`, the version `apt-cache policy caddy`
lists for the archive.
## Metrics
While `metrics.enabled` is `true`, `vpn-router-setup` runs `vpn-router-metrics`, which every minute
@@ -968,11 +1280,107 @@ When `metrics.otlp_endpoint` is set, every sample is also pushed there as an OTL
- **Authentication:** HTTP Basic with `metrics.otlp_username` and `metrics.otlp_password` when they are set.
- **Failures:** a failed push is logged and not repeated; the next sample is pushed a minute later.
## Backups
While `backup.enabled` is `true`, `vpn-router-setup` runs `vpn-router-backup.timer` on `backup.schedule`. The timer starts `vpn-router-backup.service`, which runs `vpn-router-backup run`. The schedule is written to `/etc/systemd/system/vpn-router-backup.timer.d/schedule.conf`:
- `hourly` runs at the start of each hour;
- `daily` runs at midnight;
- `weekly` runs at midnight between Sunday and Monday.
A run missed while the router was off is made at the next boot.
### What a backup holds
A backup holds the state that `vpn-router-setup` cannot make again:
| Item | Content |
|---|---|
| `/etc/vpn-router` | `vpn-router.json`, the secrets and `rclone.conf` |
| `/etc/wireguard` | The WireGuard keys and the peers |
| `/etc/ufw`, `/etc/default/ufw` | The firewall rules |
| `/etc/vpn-router-manager` | The Router Manager's settings, when it is installed |
| `files/<path>` | Each `psk_file` outside `/etc/vpn-router` |
| `ldap/vpn-router.ldif` | The user directory, written by `slapcat`: users, groups, service accounts, password hashes and TOTP secrets, and the PKI: the CAs and their keys, the root key while it is on the router, and the certificates issued, with the keys of the router's and the users' certificates |
| `postgresql/vpn-router.dump` | With `backup.metrics`, the database of the metrics and the refresh tokens, written by `pg_dump -Fc` |
| `manifest.json` | The host, the time, the version of the package, the schedule and the items |
The files and the directory are copied while the locks of the configuration and of the users are held, so no change of the API or the command-line tools falls between them; `pg_dump` reads a snapshot of its own. The copy is staged in `/var/lib/vpn-router/backup/staging`, which is removed after each run. The backup holds every secret of the router, so keep it as safe as the router itself.
### Local archives
With `backup.target` `local`, a backup is a tar archive compressed with zstd, `vpn-router-<host>-<UTC time>.tar.zst`, in `backup.path` (mode 0600, the folder 0700). The folder may be a mount, such as an NFS share or an Azure Files share, which exports the archives.
After each backup, the archives of this host that the retention settings no longer keep are deleted. The rules are those of Proxmox Backup Server's prune:
- The settings apply in order: `keep_hourly`, `keep_daily`, `keep_weekly` (ISO weeks, Monday to Sunday), `keep_monthly`, `keep_yearly`.
- Each keeps the newest archive of each of its last N periods that have archives, in local time.
- A period that has an archive a former setting kept is passed over.
- A setting finer than the schedule does not apply: `keep_hourly` only with `hourly`, `keep_daily` with `hourly` and `daily`. At least one setting that applies must be above 0.
### Cloud copy with rclone
With `backup.rclone_remote` set, each local backup is followed by an rclone run that copies the archives of this host to the remote. rclone comes from the `rclone` package, which the package suggests:
```sh
sudo apt-get install rclone
sudo rclone config --config /etc/vpn-router/rclone.conf
sudo chmod 0600 /etc/vpn-router/rclone.conf
sudo vpn-config-edit set backup.rclone_remote azure:backups/router
sudo systemctl restart vpn-router-setup
```
The two modes:
- `sync` (the default) makes the remote mirror the archives of this host, so an archive retention deletes is deleted remotely too.
- `copy` uploads new archives and deletes nothing, so retention in the cloud is left to its own rules, such as a lifecycle policy.
Other files in the remote path are left alone in both modes. A failed cloud copy leaves the local backup in place, and the run's result is `warning`.
### Proxmox Backup Server
With `backup.target` `pbs`, the staged copy is sent with `proxmox-backup-client` as the archive `router.pxar` of the snapshot `host/<host>/<time>`, in `backup.pbs_namespace` when it is set. The server keeps the snapshots by its own prune jobs; the router prunes nothing.
The static client comes from Proxmox's client repository, which the package suggests but does not set up. Following Proxmox's installation guide, as `/etc/apt/sources.list.d/pbs-client.sources`:
```text
Types: deb
URIs: http://download.proxmox.com/debian/pbs-client
Suites: trixie
Components: main
Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg
```
Get the key `https://enterprise.proxmox.com/debian/proxmox-archive-keyring-trixie.gpg` into `/usr/share/keyrings/proxmox-archive-keyring.gpg`, check it against the SHA-256 the [installation guide](https://pbs.proxmox.com/docs/installation.html) gives, and install `proxmox-backup-client-static`. Until it is installed, a backup to PBS fails with a message that names the package; nothing else depends on it.
On the server, make an API token for the router and give the token itself the `DatastoreBackup` role on `/datastore/<store>`, or on the namespace path. `backup.pbs_repository` names the token, such as `backup@pbs!router@pbs.example.com:store`, and `backup.pbs_password` holds its secret. Set `backup.pbs_fingerprint` when the server's certificate is self-signed. With `backup.pbs_keyfile`, the snapshot is encrypted with a key of `proxmox-backup-client key create --kdf none <file>`. Keep a copy of that key away from the router, since a restore needs it.
### Running, listing and restoring
```sh
sudo vpn-router-backup run # a backup now, whether or not backups are enabled
sudo vpn-router-backup list # the local archives, or the snapshots on the server
sudo vpn-router-backup restore /var/backups/vpn-router/vpn-router-router-20261025T030000Z.tar.zst
sudo vpn-router-backup restore --pbs host/router/2026-10-25T03:00:00Z
```
Each run is recorded in `/var/lib/vpn-router/backup.json`, which `/backup` and the Router Manager show. Only one run or restore takes place at a time.
`restore` is only on the command line. It shows what the backup holds and asks for `yes`, which `--yes` gives. It then:
1. stops the web service, the metrics service and slapd;
2. puts back the files;
3. loads the directory with `slapadd` into an empty `/var/lib/ldap/vpn-router`;
4. sets the database role's password to the restored `database.secret`, and restores the database with `pg_restore --clean` when the backup holds it;
5. restarts `vpn-router-setup`, which regenerates everything else, followed by WireGuard, OpenVPN, strongSwan and the Router Manager, and reloads ufw.
What it replaces is kept in `/var/lib/vpn-router/backup/before-restore/<UTC time>`. To restore onto a new VM, install the package first and let `vpn-router-setup` run once. The restored configuration names the interfaces of the old VM, so change them with `vpn-config-edit` if they differ.
## Router Manager
The `vpn-router-manager` package, built in the repository `linux-cloud-router-manager`, is a web
application that shows the state of the router and manages its users and site-to-site
connections through the status web service. It can be installed on the router or on another Linux system. While it is installed on
application that shows the state of the router and manages its users, site-to-site
connections, backups and certificates through the status web service. It can be installed on the router or on another Linux system. While it is installed on
the router, `vpn-router-setup` opens its port (`port` in `/etc/vpn-router-manager/manager.json`,
7080 by default, HTTP; HTTPS is Caddy's, on `https.manager_port`) on the internal and the external
interface; restart `vpn-router-setup` after changing it. The manager's `listen` chooses the
@@ -981,8 +1389,8 @@ interfaces it listens on.
## Removal
`apt-get remove` stops `vpn-router-docker`, which removes the Docker rules. It terminates the
tunnels and takes `wg-quick@wg0`, `openvpn-server@vpn-router`, `caddy` and `vpn-router-metrics`
down, but leaves the
tunnels and takes `wg-quick@wg0`, `openvpn-server@vpn-router`, `caddy`, `vpn-router-metrics`,
`vpn-router-backup.timer` and `vpn-router-pki.timer` down, but leaves the
configuration in place. `strongswan` is left enabled, since it is a shared service that
may be in use by something else.
@@ -992,8 +1400,13 @@ before deleting it so a later reinstall starts clean. It also removes the web se
signing key, the password file of the metrics service account, and the database of the refresh
tokens and the metrics, its role and their password
file when the package created them, and Caddy's configuration `/etc/caddy/vpn-router.Caddyfile`; the certificate in
`/var/lib/caddy` belongs to the `caddy` package. Key material and users are never deleted: `/etc/vpn-router/pki`, `/etc/vpn-router/ldap`, the user directory in slapd, the
WireGuard key pair and everything under `/etc/swanctl` are left alone.
`/var/lib/caddy` belongs to the `caddy` package. It removes the backup timer's schedule, the
record of the last backup, the published CA files, the copies of the ACME CAs in
`/etc/caddy/vpn-router-pki` and OpenVPN's CA and CRL files. Caddy stays installed, and with it the
source of Caddy's repository. Key material, users and backups are never deleted:
`/etc/vpn-router/ldap`, `/etc/vpn-router/rclone.conf`, the user directory in slapd with the PKI, the WireGuard
key pair, everything under `/etc/swanctl`, the archives in `backup.path` and
`/var/lib/vpn-router/backup/before-restore` are left alone.
## Building
+167
View File
@@ -1,3 +1,170 @@
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.
+1 -1
View File
@@ -51,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
@@ -58,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
+17 -8
View File
@@ -17,8 +17,8 @@ Depends: ${misc:Depends},
ufw,
debconf,
ucf,
openssl,
python3,
python3-cryptography,
python3-jinja2,
python3-ldap,
python3-pyotp,
@@ -31,15 +31,24 @@ Depends: ${misc:Depends},
openvpn-auth-ldap,
iproute2,
systemd-resolved,
caddy
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 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. It keeps metrics of the router in PostgreSQL
and serves them for an OpenTelemetry Collector. 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
+6 -1
View File
@@ -7,12 +7,17 @@ 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/lib/vpn-router/simple-ca usr/lib/vpn-router/
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/
+2 -2
View File
@@ -31,7 +31,7 @@ case "$1" in
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/p2s_ca_name; VPN_ROUTER_P2S_CA_NAME="$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"
@@ -44,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 ---
+13 -4
View File
@@ -53,8 +53,14 @@ case "$1" in
/etc/vpn-router/vpn-router.json \
/etc/vpn-router/web.key \
/etc/vpn-router/metrics.secret \
/var/lib/vpn-router/setup.done
rmdir --ignore-fail-on-non-empty /var/lib/vpn-router 2>/dev/null || true
/etc/systemd/system/vpn-router-backup.timer.d/schedule.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
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
# 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
@@ -64,8 +70,11 @@ case "$1" in
rm -f /etc/vpn-router/database.secret
# Key material and users are not package state: /etc/vpn-router/pki,
# /etc/vpn-router/ldap, the user directory in slapd, the WireGuard key
# pair and everything under /etc/swanctl are left in place.
# /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
+5 -4
View File
@@ -17,10 +17,11 @@ case "$1" in
fi
# vpn-router-setup enabled wg-quick@wg0, openvpn-server@vpn-router,
# caddy and vpn-router-metrics, 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; do
# 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
+3
View File
@@ -8,3 +8,6 @@ override_dh_installsystemd:
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
+6 -7
View File
@@ -124,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
@@ -133,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,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,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
@@ -15,7 +15,7 @@ NoNewPrivileges=yes
ProtectSystem=strict
RuntimeDirectory=vpn-router
RuntimeDirectoryPreserve=yes
ReadWritePaths=-/etc/vpn-router -/etc/swanctl/x509crl
ReadWritePaths=-/etc/vpn-router -/etc/swanctl/x509crl -/etc/openvpn/server -/var/lib/vpn-router/pki-publish
ProtectHome=yes
PrivateTmp=yes
@@ -50,7 +50,7 @@ ANSWERS = {
'VPN_ROUTER_PSK': ((*FIRST, '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_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'),),
+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)
+100 -17
View File
@@ -22,6 +22,7 @@ import os
import pathlib
import re
import secrets
import shutil
import socket
import subprocess
import sys
@@ -33,8 +34,9 @@ import ldap
import vpnrouter
import vpnrouter_ldap
import vpnrouter_pki
import vpnrouter_users
from vpnrouter import fail
from vpnrouter import fail, warn
BEFORE_RULES = pathlib.Path('/etc/ufw/before.rules')
UFW_DEFAULTS = pathlib.Path('/etc/default/ufw')
@@ -63,12 +65,24 @@ 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'
FILTER_ANCHOR = '# End required lines'
#: Configuration options left out wherever the configuration is shown.
@@ -263,16 +277,21 @@ def filter_block(ctx):
'# METRICS RULES END',
]
# Let's Encrypt reaches the HTTP challenge on the external interface; the HTTPS ports are open on both.
# 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:
ports = [port for wanted, port in ((ctx['web_enabled'], ctx['https_web_port']),
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'])) if wanted]
(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'] else []),
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',
]
@@ -525,13 +544,20 @@ def manage_openvpn(ctx, changed):
run(['systemctl', 'restart', unit], check=False)
def manage_caddy(ctx):
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.
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)
@@ -539,16 +565,47 @@ def manage_caddy(ctx):
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?')
caddy_ctx = dict(ctx, manager_port=manager_port() if MANAGER.exists() else 0)
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:
elif changed or copied:
run(['systemctl', 'reload', CADDY_UNIT], check=False)
@@ -566,6 +623,27 @@ def manage_metrics(ctx):
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)
@@ -666,14 +744,19 @@ def main():
start_bootstrap()
progress('running', 'configuration')
cfg = vpnrouter.load_config()
ctx, changed = vpnrouter.configure(cfg)
if ctx is None:
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)
@@ -705,10 +788,6 @@ def main():
progress('running', 'WireGuard')
manage_wireguard(ctx, changed)
# The tunnels are up before the directory and the admin, whose problem ends setup only after every other step.
failed_step = 'user directory'
progress('running', failed_step)
problem = manage_directory()
if not problem:
failed_step = 'Router Manager admin'
progress('running', failed_step)
@@ -720,7 +799,9 @@ def main():
progress('running', 'OpenVPN')
manage_openvpn(ctx, changed)
progress('running', 'HTTPS proxy')
manage_caddy(ctx)
manage_caddy(cfg, ctx)
progress('running', 'PKI timer')
run(['systemctl', 'enable', '--now', PKI_TIMER], check=False)
if 'resolved' in changed:
progress('running', 'resolver')
@@ -734,6 +815,8 @@ def main():
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)
@@ -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())
@@ -12,26 +12,25 @@ import os
import pathlib
import re
import shutil
import subprocess
import sys
import tempfile
import urllib.parse
import jinja2
import ldap
LIB_DIR = pathlib.Path(__file__).resolve().parent
if str(LIB_DIR) not in sys.path:
sys.path.insert(0, str(LIB_DIR))
import vpnrouter_ldap
import vpnrouter_pki
import vpnrouter_platforms
from vpnrouter_net import DEFAULT_LISTEN, LISTEN_CHOICES, LOOPBACK, ConfigError, detect_interfaces, interface_addresses, ip_output
CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.json')
CONFIG_LOCK = pathlib.Path('/run/vpn-router/config.lock')
TEMPLATE_DIR = pathlib.Path('/usr/share/vpn-router/templates')
PKI_DIR = pathlib.Path('/etc/vpn-router/pki')
SIMPLE_CA = LIB_DIR / 'simple-ca'
SWANCTL_CONF_D = pathlib.Path('/etc/swanctl/conf.d')
SITE_TO_SITE = SWANCTL_CONF_D / 'site-to-site.conf'
@@ -40,13 +39,6 @@ P2S_FORWARDER = pathlib.Path('/etc/systemd/resolved.conf.d/p2s-forwarder.conf')
WG_CONF = pathlib.Path('/etc/wireguard/wg0.conf')
OPENVPN_CONF = pathlib.Path('/etc/openvpn/server/vpn-router.conf')
OPENVPN_LDAP = pathlib.Path('/etc/openvpn/server/vpn-router-ldap.conf')
USER_CERT_DIR = PKI_DIR / 'users'
CA_CERT_DEST = pathlib.Path('/etc/swanctl/x509ca/ca.pem')
SERVER_CERT = pathlib.Path('/etc/swanctl/x509/server.pem')
SERVER_KEY = pathlib.Path('/etc/swanctl/private/server.key')
CRL_DEST = pathlib.Path('/etc/swanctl/x509crl/crl.pem')
TRUST_ANCHOR = pathlib.Path('/usr/local/share/ca-certificates/vpn-router-ca.crt')
# section -> option -> default; the type of the default is the required type.
SCHEMA = {
@@ -55,21 +47,34 @@ SCHEMA = {
'wan': {'local_fqdn': ''},
'local': {'cidrs': [], 'int_addr': '', 'int_gateway_ip': ''},
'site_to_site': {},
'p2s': {'enabled': False, 'address_pool': '', 'ca_name': 'VPN Router CA'},
'p2s': {'enabled': False, 'address_pool': ''},
'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820},
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
'docker': {'mode': 'auto', 'exposed_ports': []},
'web': {'enabled': True, 'listen': DEFAULT_LISTEN, 'port': 7081, 'database': '', 'trusted_proxies': [],
'status_content': 'basic'},
'https': {'enabled': True, 'email': '', 'web_port': 7444, 'manager_port': 7443},
'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': {}, 'templates': {},
'extended_key_usages': {}, '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': ''},
}
#: site_to_site maps a connection name to its settings: option -> default, the type of the default being the required type.
CONNECTION = {'addrs': [], 'id': '', 'cidrs': [], 'local_cidrs': [], 'local_id_mode': 'fqdn', 'psk': '', 'psk_file': ''}
CONNECTION = {'addrs': [], 'id': '', 'cidrs': [], 'local_cidrs': [], 'local_id_mode': 'fqdn', 'auth': 'psk', 'psk': '',
'psk_file': '', 'remote_ca': ''}
#: How a site_to_site connection authenticates: a pre-shared key, or certificates.
CONNECTION_AUTH = ('psk', 'certificate')
#: The name of a site_to_site connection, which also names its swanctl connection, child and secret.
CONNECTION_NAME = re.compile(r'^[a-z][a-z0-9-]{0,30}$')
@@ -83,9 +88,109 @@ METRICS_KEEP = ('keep_details', 'keep_hourly', 'keep_daily', 'keep_monthly', 'ke
#: A retention period as PostgreSQL reads an interval: a count and a unit.
METRICS_INTERVAL = re.compile(r'^[1-9]\d{0,4} (hour|day|month|year)s?$')
#: How often backups run, as systemd OnCalendar shorthands, from the most to the least often.
BACKUP_SCHEDULES = ('hourly', 'daily', 'weekly')
#: Where backups go: an archive in backup.path, or a Proxmox Backup Server.
BACKUP_TARGETS = ('local', 'pbs')
#: How rclone follows backup.path in the cloud: sync mirrors it, copy deletes nothing remotely.
RCLONE_MODES = ('sync', 'copy')
#: The retention settings of local backups, from the hourly to the yearly ones.
BACKUP_KEEP = ('keep_hourly', 'keep_daily', 'keep_weekly', 'keep_monthly', 'keep_yearly')
#: A remote of rclone.conf and an optional path: remote:path.
RCLONE_REMOTE = re.compile(r'^[\w.][\w. -]*:\S*$')
#: A PBS repository: [[auth-id@]server[:port]:]datastore.
PBS_REPOSITORY = re.compile(r'^(\S+:)?[A-Za-z0-9_][A-Za-z0-9._-]*$')
#: The SHA-256 fingerprint of a server certificate, as PBS shows it.
PBS_FINGERPRINT = re.compile(r'^([0-9A-Fa-f]{2}:){31}[0-9A-Fa-f]{2}$')
#: A PBS namespace: up to seven names joined by /.
PBS_NAMESPACE = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9._-]*(/[A-Za-z0-9_][A-Za-z0-9._-]*){0,6}$')
#: The port of Let's Encrypt's HTTP challenge, which Caddy answers while HTTPS is on.
ACME_HTTP_PORT = 80
#: The default port of HTTPS, where Caddy publishes the CA files when pki.publish has https-default.
HTTPS_DEFAULT_PORT = 443
#: Where vpn-router-setup installs Caddy from, https.caddy_source: the Ubuntu archive or Caddy's own repository.
CADDY_SOURCES = ('ubuntu', 'upstream')
#: Caddy's own repository, which https.caddy_source upstream configures: the source, its signing key and their URLs.
CADDY_APT_SOURCE = pathlib.Path('/etc/apt/sources.list.d/caddy-stable.sources')
CADDY_KEYRING = pathlib.Path('/etc/apt/keyrings/caddy-stable.asc')
CADDY_KEY_URL = 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key'
CADDY_REPOSITORY = 'https://dl.cloudsmith.io/public/caddy/stable/deb/debian'
#: The key types of the CAs, pki.key, and of the templates.
PKI_KEYS = ('rsa-2048', 'rsa-3072', 'rsa-4096', 'ec-p256', 'ec-p384')
#: Where the CA certificates and CRLs are published, pki.publish, as the URLs in the certificates list them.
PKI_PUBLISH = ('http', 'http-80', 'https', 'https-default')
#: The key usages a template may give, as the arguments of cryptography's KeyUsage.
KEY_USAGES = ('digital_signature', 'content_commitment', 'key_encipherment', 'data_encipherment', 'key_agreement')
#: The built-in extended key usages a template may give, name -> OID; pki.extended_key_usages adds custom ones.
EXTENDED_KEY_USAGES = {
'server_auth': '1.3.6.1.5.5.7.3.1',
'client_auth': '1.3.6.1.5.5.7.3.2',
'code_signing': '1.3.6.1.5.5.7.3.3',
'email_protection': '1.3.6.1.5.5.7.3.4',
'time_stamping': '1.3.6.1.5.5.7.3.8',
'ocsp_signing': '1.3.6.1.5.5.7.3.9',
'ipsec_ike': '1.3.6.1.5.5.7.3.17',
'ike_intermediate': '1.3.6.1.5.5.8.2.2',
}
#: The name of a custom extended key usage of pki.extended_key_usages.
EKU_NAME = re.compile(r'^[a-z][a-z0-9_-]{0,30}$')
#: An object identifier in dotted form, of a custom extended key usage or a certificate policy.
OID = re.compile(r'^[0-2](\.(0|[1-9][0-9]*)){1,63}$')
#: What the common name of a template's certificates is: a DNS name, any name, or an e-mail address.
TEMPLATE_SUBJECTS = ('dns', 'name', 'email')
#: The subject alternative names a template's certificates may have.
SAN_TYPES = ('dns', 'ip', 'email')
#: A certificate template: option -> default, the type of the default being the required type.
TEMPLATE = {'key': 'rsa-3072', 'days': 365, 'key_usage': ['digital_signature'], 'extended_key_usage': [],
'subject': 'name', 'sans': [], 'policies': []}
#: The built-in templates; pki.templates changes their options and adds templates of its own.
PKI_TEMPLATES = {
'vpn-server': {'key_usage': ['digital_signature', 'key_encipherment'],
'extended_key_usage': ['server_auth', 'ike_intermediate'], 'subject': 'dns', 'sans': ['dns', 'ip']},
'vpn-user': {'extended_key_usage': ['client_auth'], 'subject': 'name', 'sans': ['dns', 'email']},
'site-to-site': {'key_usage': ['digital_signature', 'key_encipherment'],
'extended_key_usage': ['server_auth', 'client_auth'], 'subject': 'dns', 'sans': ['dns', 'ip']},
'server': {'key_usage': ['digital_signature', 'key_encipherment'], 'extended_key_usage': ['server_auth'],
'subject': 'dns', 'sans': ['dns', 'ip']},
'client': {'extended_key_usage': ['client_auth'], 'subject': 'name', 'sans': ['dns', 'email']},
}
#: The templates only the router issues: its VPN server certificate and the users' certificates.
ROUTER_TEMPLATES = ('vpn-server', 'vpn-user')
#: The built-in issuing CAs: name -> the pki option of the common name and the templates the CA issues.
BUILTIN_CAS = {'p2s': ('p2s_name', ['vpn-server', 'vpn-user']), 's2s': ('s2s_name', ['site-to-site'])}
#: A general-purpose issuing CA of pki.issuing_cas: option -> default, the type of the default being the required type.
ISSUING_CA = {'name': '', 'templates': [], 'acme': False}
#: The name of an issuing CA or a template; an issuing CA's name also names its files.
PKI_NAME = re.compile(r'^[a-z][a-z0-9-]{0,30}$')
#: The longest common name of a CA, in characters.
CA_NAME_MAX = 64
#: What / and /status of the web service show, web.status_content, from the least to the most.
STATUS_CONTENTS = ('basic', 'standard', 'full')
@@ -96,9 +201,6 @@ OPENVPN_PROTOCOLS = ('udp', 'tcp')
#: The tun device of the OpenVPN server.
OPENVPN_DEVICE = 'ovpn0'
#: Days a certificate revocation list is valid; the router only reads its own lists.
CRL_DAYS = 3650
#: A port or a port range as iptables multiport takes it: 443 or 8000:8100.
DOCKER_PORT = re.compile(r'^\d{1,5}(:\d{1,5})?$')
@@ -308,6 +410,10 @@ def validate(cfg):
f'{prefix}.local_id_mode: {id_mode!r} is not one of {", ".join(ID_MODES)}')
_check_networks(_clean(connection['cidrs']), f'{prefix}.cidrs')
_check_networks(_clean(connection['local_cidrs']), f'{prefix}.local_cidrs')
if connection['auth'] not in CONNECTION_AUTH:
raise ConfigError(f'{prefix}.auth: {connection["auth"]!r} is not one of {", ".join(CONNECTION_AUTH)}')
if connection['remote_ca'].strip() and not vpnrouter_pki.is_ca_certificate(connection['remote_ca']):
raise ConfigError(f'{prefix}.remote_ca is not the PEM certificate of a CA')
for option, value in identities:
if value.startswith('@'):
@@ -385,8 +491,100 @@ def validate(cfg):
for item in _get_list(cfg, 'web', 'trusted_proxies'):
_check_address(item, 'web.trusted_proxies')
caddy_source = _get(cfg, 'https', 'caddy_source')
if caddy_source not in CADDY_SOURCES:
raise ConfigError(f'https.caddy_source: {caddy_source!r} is not one of {", ".join(CADDY_SOURCES)}')
for option in ('root_name', 'p2s_name', 's2s_name'):
value = _get(cfg, 'pki', option)
if not value or len(value) > CA_NAME_MAX or not value.isprintable():
raise ConfigError(f'pki.{option} must have 1 to {CA_NAME_MAX} printable characters')
for option in ('root_days', 'ca_days', 'crl_days', 'root_crl_days'):
if not 1 <= _get(cfg, 'pki', option) <= 36500:
raise ConfigError(f'pki.{option} must be 1 to 36500 days')
if _get(cfg, 'pki', 'key') not in PKI_KEYS:
raise ConfigError(f'pki.key: {_get(cfg, "pki", "key")!r} is not one of {", ".join(PKI_KEYS)}')
publish = _get_list(cfg, 'pki', 'publish')
for item in publish:
if item not in PKI_PUBLISH:
raise ConfigError(f'pki.publish: {item!r} is not one of {", ".join(PKI_PUBLISH)}')
if len(set(publish)) != len(publish):
raise ConfigError('pki.publish names an endpoint twice')
if publish and not _get(cfg, 'wan', 'local_fqdn'):
raise ConfigError('pki.publish needs wan.local_fqdn, the name in the published URLs')
if 'http' in publish and not _get(cfg, 'web', 'enabled'):
raise ConfigError('pki.publish: http is served by the web service, which needs web.enabled')
if set(publish) - {'http'} and not _get(cfg, 'https', 'enabled'):
raise ConfigError('pki.publish: http-80, https and https-default are served by Caddy, which needs https.enabled')
for name, oid in cfg['pki']['extended_key_usages'].items():
prefix = f'pki.extended_key_usages.{name}'
if not EKU_NAME.match(name) or name in EXTENDED_KEY_USAGES:
raise ConfigError(f'{prefix}: an extended key usage name starts with a lowercase letter, has at most 31 '
'lowercase letters, digits, hyphens and underscores, and is not a built-in one')
if not isinstance(oid, str) or not OID.match(oid):
raise ConfigError(f'{prefix} must be an OID in dotted form, such as 1.3.6.1.4.1.311.20.2.2')
usages = extended_key_usages(cfg)
for name, template in cfg['pki']['templates'].items():
prefix = f'pki.templates.{name}'
if not PKI_NAME.match(name):
raise ConfigError(f'{prefix}: a template name starts with a lowercase letter and has at most 31 lowercase '
'letters, digits and hyphens')
if not isinstance(template, dict):
raise ConfigError(f'{prefix} must be {TYPE_NAMES[dict]}')
_check_options(template, TEMPLATE, prefix)
templates = pki_templates(cfg)
for name, template in templates.items():
prefix = f'pki.templates.{name}'
if template['key'] not in PKI_KEYS:
raise ConfigError(f'{prefix}.key: {template["key"]!r} is not one of {", ".join(PKI_KEYS)}')
if not 1 <= template['days'] <= 36500:
raise ConfigError(f'{prefix}.days must be 1 to 36500 days')
for option, allowed in (('key_usage', KEY_USAGES), ('extended_key_usage', tuple(usages)),
('sans', SAN_TYPES)):
for item in template[option]:
if item not in allowed:
raise ConfigError(f'{prefix}.{option}: {item!r} is not one of {", ".join(allowed)}')
for item in template['policies']:
if not OID.match(item):
raise ConfigError(f'{prefix}.policies: {item!r} is not an OID in dotted form, such as 1.3.6.1.4.1.99999.1')
if not template['key_usage']:
raise ConfigError(f'{prefix}.key_usage needs at least one usage')
if template['subject'] not in TEMPLATE_SUBJECTS:
raise ConfigError(f'{prefix}.subject: {template["subject"]!r} is not one of {", ".join(TEMPLATE_SUBJECTS)}')
for name, authority in cfg['pki']['issuing_cas'].items():
prefix = f'pki.issuing_cas.{name}'
if not PKI_NAME.match(name) or name in BUILTIN_CAS or name == 'root':
raise ConfigError(f'{prefix}: an issuing CA name starts with a lowercase letter and has at most 31 '
f'lowercase letters, digits and hyphens, and is not root, {" or ".join(BUILTIN_CAS)}')
if not isinstance(authority, dict):
raise ConfigError(f'{prefix} must be {TYPE_NAMES[dict]}')
_check_options(authority, ISSUING_CA, prefix)
for name, authority in issuing_cas(cfg).items():
prefix = f'pki.issuing_cas.{name}'
if authority['builtin']:
continue
common_name = authority['name'].strip()
if not common_name or len(common_name) > CA_NAME_MAX or not common_name.isprintable():
raise ConfigError(f'{prefix}.name must have 1 to {CA_NAME_MAX} printable characters')
if not authority['templates']:
raise ConfigError(f'{prefix}.templates needs at least one template')
for template in authority['templates']:
if template not in templates:
raise ConfigError(f'{prefix}.templates: {template!r} is not a template')
if template in ROUTER_TEMPLATES:
raise ConfigError(f'{prefix}.templates: {template} is issued by the P2S CA only')
if authority['acme'] and not _get(cfg, 'https', 'enabled'):
raise ConfigError(f'{prefix}.acme: the ACME server is Caddy\'s, which needs https.enabled')
taken = {web_port: 'web.port', ACME_HTTP_PORT: 'the HTTP challenge',
https_ports[0]: 'https.web_port', https_ports[1]: 'https.manager_port'}
if 'https-default' in publish:
if HTTPS_DEFAULT_PORT in taken:
raise ConfigError(f'pki.publish: https-default takes port {HTTPS_DEFAULT_PORT}, the port of '
f'{taken[HTTPS_DEFAULT_PORT]}')
if _get(cfg, 'openvpn', 'protocol') == 'tcp' and _get(cfg, 'openvpn', 'port') == HTTPS_DEFAULT_PORT:
raise ConfigError(f'pki.publish: https-default takes port {HTTPS_DEFAULT_PORT}, the port of openvpn.port')
taken[HTTPS_DEFAULT_PORT] = 'pki.publish https-default'
for option in ('port', 'https_port'):
port = _get(cfg, 'metrics', option)
if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
@@ -412,6 +610,46 @@ def validate(cfg):
raise ConfigError(
f'metrics.{option}: {keep!r} is not a count and a unit, such as "24 hours" or "3 months"')
schedule = _get(cfg, 'backup', 'schedule')
if schedule not in BACKUP_SCHEDULES:
raise ConfigError(
f'backup.schedule: {schedule!r} is not one of {", ".join(BACKUP_SCHEDULES)}')
target = _get(cfg, 'backup', 'target')
if target not in BACKUP_TARGETS:
raise ConfigError(f'backup.target: {target!r} is not one of {", ".join(BACKUP_TARGETS)}')
for option in ('path', 'pbs_keyfile'):
value = _get(cfg, 'backup', option)
if value and not value.startswith('/'):
raise ConfigError(f'backup.{option}: {value!r} is not an absolute path')
if not _get(cfg, 'backup', 'path'):
raise ConfigError('backup.path is required')
for option in BACKUP_KEEP:
if _get(cfg, 'backup', option) < 0:
raise ConfigError(f'backup.{option} must not be negative')
if target == 'local' and not any(_get(cfg, 'backup', option) for option in backup_keeps(schedule)):
raise ConfigError(
f'backup: with the {schedule} schedule, at least one of {", ".join(backup_keeps(schedule))} '
'is above 0')
remote = _get(cfg, 'backup', 'rclone_remote')
if remote and not RCLONE_REMOTE.match(remote):
raise ConfigError(f'backup.rclone_remote: {remote!r} is not a remote and a path, such as azure:backups/router')
rclone_mode = _get(cfg, 'backup', 'rclone_mode')
if rclone_mode not in RCLONE_MODES:
raise ConfigError(f'backup.rclone_mode: {rclone_mode!r} is not one of {", ".join(RCLONE_MODES)}')
if target == 'pbs':
repository = _get(cfg, 'backup', 'pbs_repository')
if not repository or not _get(cfg, 'backup', 'pbs_password'):
raise ConfigError('backup.pbs_repository and backup.pbs_password are required with the pbs target')
if not PBS_REPOSITORY.match(repository):
raise ConfigError(
f'backup.pbs_repository: {repository!r} is not of the form user@realm!token@host:datastore')
fingerprint = _get(cfg, 'backup', 'pbs_fingerprint')
if fingerprint and not PBS_FINGERPRINT.match(fingerprint):
raise ConfigError(f'backup.pbs_fingerprint: {fingerprint!r} is not 32 hexadecimal pairs joined by colons')
namespace = _get(cfg, 'backup', 'pbs_namespace')
if namespace and not PBS_NAMESPACE.match(namespace):
raise ConfigError(f'backup.pbs_namespace: {namespace!r} is not up to seven names joined by /')
docker_mode = _get(cfg, 'docker', 'mode')
if docker_mode not in DOCKER_MODES:
raise ConfigError(
@@ -427,7 +665,8 @@ def validate(cfg):
weight += 2 if ':' in item else 1
first, _, last = item.partition(':')
if _get(cfg, 'https', 'enabled'):
for port in (ACME_HTTP_PORT, *https_ports):
caddy_ports = (ACME_HTTP_PORT, *https_ports, *([HTTPS_DEFAULT_PORT] if 'https-default' in publish else []))
for port in caddy_ports:
if int(first) <= port <= int(last or first):
raise ConfigError(
f'docker.exposed_ports: {item!r} takes port {port}, which Caddy serves while '
@@ -438,6 +677,49 @@ def validate(cfg):
'a range counting as two')
def backup_keeps(schedule):
"""The retention settings of local backups that apply to the schedule: none finer than it."""
return BACKUP_KEEP[BACKUP_SCHEDULES.index(schedule):]
def pki_templates(cfg):
"""The certificate templates: the built-in ones (PKI_TEMPLATES) and those of pki.templates, each filled from TEMPLATE."""
custom = cfg['pki']['templates']
return {name: {**TEMPLATE, **PKI_TEMPLATES.get(name, {}),
**(custom.get(name) if isinstance(custom.get(name), dict) else {})}
for name in [*PKI_TEMPLATES, *(name for name in custom if name not in PKI_TEMPLATES)]}
def extended_key_usages(cfg):
"""The extended key usages templates may give, name -> OID: the built-in ones (EXTENDED_KEY_USAGES) and those of
pki.extended_key_usages."""
custom = cfg['pki']['extended_key_usages']
return {**EXTENDED_KEY_USAGES, **{name: oid for name, oid in custom.items() if isinstance(oid, str)}}
def issuing_cas(cfg):
"""The issuing CAs, name -> {name, templates, acme, builtin}: p2s and s2s (BUILTIN_CAS) with their common names from
the pki section, then those of pki.issuing_cas, each filled from ISSUING_CA."""
found = {name: {'name': _get(cfg, 'pki', option), 'templates': list(templates), 'acme': False, 'builtin': True}
for name, (option, templates) in BUILTIN_CAS.items()}
for name, options in cfg['pki']['issuing_cas'].items():
if isinstance(options, dict):
found[name] = {**ISSUING_CA, **options, 'builtin': False}
return found
def pki_base_urls(cfg):
"""The base URLs, ending in /, under which the CA certificates and CRLs are published, in the order of pki.publish."""
fqdn = _get(cfg, 'wan', 'local_fqdn')
bases = {
'http': f'http://{fqdn}:{_get(cfg, "web", "port")}/pki/',
'http-80': f'http://{fqdn}/pki/',
'https': f'https://{fqdn}:{_get(cfg, "https", "web_port")}/pki/',
'https-default': f'https://{fqdn}/pki/',
}
return [bases[item] for item in _get_list(cfg, 'pki', 'publish') if item in bases] if fqdn else []
def resolve_psk(connection, name):
"""Return the pre-shared key of the site_to_site connection: psk_file wins, else psk; '' when none is configured."""
psk_file = connection['psk_file'].strip()
@@ -552,6 +834,7 @@ def build_context(cfg, public_ip=None):
id_mode = connection['local_id_mode'].strip() or 'fqdn'
remote_cidrs = _clean(connection['cidrs'])
connection_cidrs = _clean(connection['local_cidrs'])
auth = connection['auth']
entry = {
'name': name,
'remote_addrs': ', '.join(_clean(connection['addrs'])),
@@ -562,10 +845,13 @@ def build_context(cfg, public_ip=None):
'local_cidr_list': connection_cidrs,
'local_id_mode': id_mode,
'local_id': identities.get(id_mode, ''),
'psk': resolve_psk(connection, name),
'auth': auth,
'psk': resolve_psk(connection, name) if auth == 'psk' else '',
'remote_cacert': vpnrouter_pki.remote_ca_file(name) if connection['remote_ca'].strip()
else vpnrouter_pki.SWANCTL_CA_FILES['s2s'],
}
entry['ready'] = bool(local_addrs and connection_cidrs and all(
entry[key] for key in ('remote_addrs', 'remote_id', 'remote_cidrs', 'local_id', 'psk')))
required = ('remote_addrs', 'remote_id', 'remote_cidrs', 'local_id', *(('psk',) if auth == 'psk' else ()))
entry['ready'] = bool(local_addrs and connection_cidrs and all(entry[key] for key in required))
connections.append(entry)
return {
@@ -584,7 +870,7 @@ def build_context(cfg, public_ip=None):
'connections': connections,
'p2s_enabled': _get(cfg, 'p2s', 'enabled'),
'p2s_address_pool': _get(cfg, 'p2s', 'address_pool'),
'p2s_ca_name': _get(cfg, 'p2s', 'ca_name'),
'public_ip': public_ip or '',
'p2s_dns': _join(local_addrs, _split_list(extras['EXTRA_P2S_DNS'])),
'wg_enabled': _get(cfg, 'wireguard', 'enabled'),
'wg_address': _get(cfg, 'wireguard', 'address'),
@@ -600,10 +886,11 @@ def build_context(cfg, public_ip=None):
'openvpn_device': OPENVPN_DEVICE,
'openvpn_ldap_conf': str(OPENVPN_LDAP),
'p2s_dns_list': _split_list(_join(local_addrs, _split_list(extras['EXTRA_P2S_DNS']))),
'ca_cert': str(CA_CERT_DEST),
'server_cert': str(SERVER_CERT),
'server_key': str(SERVER_KEY),
'crl': str(CRL_DEST),
'openvpn_ca': str(vpnrouter_pki.OPENVPN_CA),
'openvpn_crl': str(vpnrouter_pki.OPENVPN_CRL),
'server_cert': str(vpnrouter_pki.VPN_SERVER_CERT),
'server_key': str(vpnrouter_pki.VPN_SERVER_KEY),
'server_certificate': False,
'ldap_uri': vpnrouter_ldap.URI,
'ldap_users': vpnrouter_ldap.USERS,
'web_enabled': _get(cfg, 'web', 'enabled'),
@@ -612,6 +899,8 @@ def build_context(cfg, public_ip=None):
'https_email': _get(cfg, 'https', 'email'),
'https_web_port': _get(cfg, 'https', 'web_port'),
'https_manager_port': _get(cfg, 'https', 'manager_port'),
'pki_publish': _get_list(cfg, 'pki', 'publish'),
'pki_acme': [name for name, authority in issuing_cas(cfg).items() if authority['acme']],
'metrics_enabled': _get(cfg, 'metrics', 'enabled'),
'metrics_port': _get(cfg, 'metrics', 'port'),
'metrics_https_port': _get(cfg, 'metrics', 'https_port'),
@@ -632,7 +921,8 @@ def routing_ready(ctx):
def p2s_ready(ctx):
return bool(ctx['p2s_enabled'] and ctx['p2s_address_pool'] and ctx['local_fqdn'])
"""Road-warrior access needs its settings and the router's VPN server certificate, which the P2S CA issues."""
return bool(ctx['p2s_enabled'] and ctx['p2s_address_pool'] and ctx['local_fqdn'] and ctx['server_certificate'])
def wg_ready(ctx):
@@ -640,7 +930,8 @@ def wg_ready(ctx):
def openvpn_ready(ctx):
return bool(ctx['openvpn_enabled'] and ctx['openvpn_pool'] and ctx['local_fqdn'])
"""OpenVPN needs its settings and the router's VPN server certificate, which the P2S CA issues."""
return bool(ctx['openvpn_enabled'] and ctx['openvpn_pool'] and ctx['local_fqdn'] and ctx['server_certificate'])
def https_ready(ctx):
@@ -731,62 +1022,6 @@ def install_file(src, dest, mode):
return True
def _simple_ca(*args):
result = subprocess.run(
[sys.executable, str(SIMPLE_CA), *args],
capture_output=True, text=True,
)
if result.returncode != 0:
fail(f'simple-ca {" ".join(args)} failed: {result.stderr.strip()}')
return result.stdout
def provision_pki(ctx):
"""Create the CA and CRL when the PKI directory is empty, and the server certificate once wan.local_fqdn is set.
A CA of the router's own (ca_key.pem present) without a CRL gets one, and without a
certificate for wan.local_fqdn gets one too; nothing present is overwritten.
"""
PKI_DIR.mkdir(parents=True, exist_ok=True)
os.chmod(PKI_DIR, 0o700)
if not SIMPLE_CA.is_file():
fail(f'{SIMPLE_CA} is missing')
if not any(PKI_DIR.iterdir()):
if not ctx['p2s_ca_name']:
warn('p2s.ca_name is empty, not creating a local CA')
return
_simple_ca('make-ca', '--ca-dir', str(PKI_DIR), ctx['p2s_ca_name'])
if not (PKI_DIR / 'ca_key.pem').is_file():
return
if not (PKI_DIR / 'crl.pem').is_file():
_simple_ca('make-crl', '--ca-dir', str(PKI_DIR), '--days', str(CRL_DAYS))
label = ctx['local_fqdn'].split('.', 1)[0] if ctx['local_fqdn'] else ''
if label and not (PKI_DIR / f'{label}_cert.pem').is_file():
_simple_ca('make-cert', '--ca-dir', str(PKI_DIR), ctx['local_fqdn'], ctx['local_fqdn'])
def distribute_pki(ctx):
"""Copy PKI material to each consumer. Returns True when anything changed."""
label = ctx['local_fqdn'].split('.', 1)[0] if ctx['local_fqdn'] else ''
bundle = PKI_DIR / 'ca_bundle.pem'
ca_src = bundle if bundle.is_file() else PKI_DIR / 'ca_cert.pem'
changed = False
changed |= install_file(ca_src, CA_CERT_DEST, 0o644)
if label:
changed |= install_file(PKI_DIR / f'{label}_cert.pem', SERVER_CERT, 0o644)
changed |= install_file(PKI_DIR / f'{label}_key.pem', SERVER_KEY, 0o600)
changed |= install_file(PKI_DIR / 'crl.pem', CRL_DEST, 0o644)
if install_file(PKI_DIR / 'ca_cert.pem', TRUST_ANCHOR, 0o644):
changed = True
if shutil.which('update-ca-certificates'):
subprocess.run(['update-ca-certificates'],
capture_output=True, text=True)
return changed
def configure(cfg=None, public_ip=None):
"""Render configuration files and provision PKI.
@@ -809,9 +1044,23 @@ def configure(cfg=None, public_ip=None):
changed = set()
provision_pki(ctx)
if distribute_pki(ctx):
changed.add('swanctl')
# Without the directory, which holds the PKI, the copies of the last run stay and what needs none is configured.
with vpnrouter_pki.locked():
try:
notices = vpnrouter_pki.provision(cfg, ctx)
if vpnrouter_pki.distribute(cfg):
changed.add('swanctl')
except vpnrouter_pki.Failure as failure:
fail(str(failure))
except ldap.LDAPError as exc:
notices = [f'the PKI is left as it is: the directory cannot be reached: {exc}']
for notice in notices:
warn(notice)
# What needs the router's own certificates.
ctx['server_certificate'] = vpnrouter_pki.VPN_SERVER_CERT.is_file()
for connection in ctx['connections']:
if connection['auth'] == 'certificate':
connection['ready'] = connection['ready'] and vpnrouter_pki.S2S_CERT.is_file()
env = _jinja_env()
@@ -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)
@@ -11,6 +11,9 @@ The password policy locks an account for LOCKOUT_SECONDS after LOCKOUT_FAILURES
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.
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
@@ -29,6 +32,7 @@ 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}'
@@ -54,16 +58,43 @@ OVERLAYS = ('ppolicy', 'otp')
#: under .1 and object classes under .2. vpnRouterAccount, auxiliary, adds properties of the
#: package to a user of either kind: mail, for a Manager-only account, vpnRouterScope,
#: authorization scopes, of which service marks a service account, and displayName and
#: description, the labels of a service account.
#: description, the labels of a service account. 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} )",
)
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 ) )",
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.
@@ -74,6 +105,7 @@ TOTP_WINDOW = 1
#: 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',
@@ -126,20 +158,27 @@ def _ensure_modules(connection):
def _ensure_schema(connection):
"""Add the package's schema to cn=config when missing, else set its definitions that differ."""
"""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': [value.encode() for value in SCHEMA_ATTRIBUTE_TYPES],
'olcObjectClasses': [value.encode() for value in SCHEMA_OBJECT_CLASSES]}
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(attributes, objectClass=[b'olcSchemaConfig'], cn=[SCHEMA_NAME.encode()])))
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]
changes = [(ldap.MOD_REPLACE, name, values) for name, values in attributes.items()
if unordered(current.get(name, [])) != [value.decode() for value in values]]
if changes:
connection.modify_s(dn, changes)
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):
@@ -230,7 +269,7 @@ def provision():
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):
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'],
File diff suppressed because it is too large. Load diff
@@ -2,8 +2,9 @@
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 router CA with the user name as its
common name, which OpenVPN and IKEv2 accept. A Manager-only account is an account,
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
@@ -31,8 +32,8 @@ login() checks a user's password and code with a bind. Members of ADMIN_GROUP ma
router through the web service. 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 and the
connection of the user manager account. A request that cannot be done raises Failure, whose
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.
"""
@@ -41,10 +42,7 @@ import base64
import calendar
import contextlib
import fcntl
import pathlib
import re
import subprocess
import sys
import time
import ldap
@@ -54,6 +52,7 @@ 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]$')
@@ -70,7 +69,7 @@ PERSON = (('first_name', 'givenName'), ('last_name', 'sn'), ('email', 'mail'))
LABELS = (('display_name', 'displayName'), ('description', 'description'))
#: The attributes read for a user.
ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken', 'vpnRouterScope',
'displayName', 'description']
'displayName', 'description', 'userCertificate;binary']
#: The vpnRouterScope of a service account.
SERVICE_SCOPE = 'service'
#: The object classes of a VPN user and of a Manager-only account.
@@ -91,7 +90,7 @@ LOCKED = b'000001010000Z'
ADMIN_GROUP = 'admins'
#: The digits of a TOTP code given to login().
CODE = re.compile(r'^\d{6}$')
LOCK_FILE = pathlib.Path('/run/vpn-router/users.lock')
LOCK_FILE = vpnrouter_pki.LOCK_FILE
class Failure(Exception):
@@ -175,9 +174,6 @@ def dn_of(name):
return f'uid={name},{vpnrouter_ldap.USERS}'
def certificate(name):
return vpnrouter.USER_CERT_DIR / f'{name}_cert.pem', vpnrouter.USER_CERT_DIR / f'{name}_key.pem'
def entry(connection, name):
"""The attributes of the user's entry; Failure with status 3 when there is none."""
@@ -188,18 +184,6 @@ def entry(connection, name):
raise Failure(f'user {name} does not exist', 3)
def simple_ca(*args):
result = subprocess.run([sys.executable, str(vpnrouter.SIMPLE_CA), *args], capture_output=True, text=True)
if result.returncode != 0:
raise Failure(f'simple-ca {args[0]} failed: {result.stderr.strip() or result.stdout.strip()}')
def publish_crl():
"""Give strongSwan the CRL after a revocation; OpenVPN reads it at every connection."""
if vpnrouter.install_file(vpnrouter.PKI_DIR / 'crl.pem', vpnrouter.CRL_DEST, 0o644):
subprocess.run(['swanctl', '--load-creds'], capture_output=True, text=True)
def locked_since(value):
"""The time a pwdAccountLockedTime value names, in seconds since the epoch, or 0 when it names none."""
try:
@@ -248,7 +232,7 @@ def summary(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': certificate(name)[0].is_file(),
'certificate': 'userCertificate;binary' in attributes,
'otp': 'oathTOTPToken' in attributes,
}
for key, attribute in PERSON + LABELS:
@@ -283,8 +267,9 @@ def add(connection, name, password, values, service=False):
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_DIR / 'ca_key.pem').is_file():
raise Failure('the router CA does not exist; vpn-router-setup creates it once a platform is chosen')
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:
@@ -299,10 +284,10 @@ def add(connection, name, password, values, service=False):
raise Failure(f'user {name} exists', 4)
connection.passwd_s(dn_of(name), None, password)
if vpn_user:
vpnrouter.USER_CERT_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
for path in certificate(name):
path.unlink(missing_ok=True)
simple_ca('make-cert', '--ca-dir', str(vpnrouter.PKI_DIR), '--cert-dir', str(vpnrouter.USER_CERT_DIR), name)
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))
@@ -467,26 +452,24 @@ def remove(connection, name):
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()])])
cert, key = certificate(name)
if cert.is_file():
simple_ca('revoke-cert', '--ca-dir', str(vpnrouter.PKI_DIR), str(cert))
simple_ca('make-crl', '--ca-dir', str(vpnrouter.PKI_DIR), '--days', str(vpnrouter.CRL_DAYS))
publish_crl()
cert.unlink(missing_ok=True)
key.unlink(missing_ok=True)
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))
return {'name': name, 'removed': True}
def profile(connection, name):
"""The OpenVPN client profile of the user, with the CA, the certificate and the key inline."""
"""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)
cert, key = certificate(name)
if not cert.is_file() or not key.is_file():
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',
@@ -501,9 +484,9 @@ def profile(connection, name):
'auth-user-pass',
'auth-nocache',
'verb 3',
'<ca>', extract_pem(vpnrouter.CA_CERT_DEST.read_text(encoding='utf-8')), '</ca>',
'<cert>', extract_pem(cert.read_text(encoding='utf-8')), '</cert>',
'<key>', key.read_text(encoding='utf-8').strip(), '</key>',
'<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}
+205 -12
View File
@@ -8,8 +8,10 @@ Pages:
/ the status for people: plain text for curl and wget, a styled page
(Tailwind CSS from a CDN) for a browser
/status the status as JSON
/status/full
the whole status as JSON, whatever web.status_content says, for the Router Manager
/config the configuration without its secrets, and what the router resolved and
runs, as JSON
runs, with the installed Caddy and where it comes from, as JSON
/users the users in the router's directory, whether or not a P2S system is enabled: list,
add, change, set the password, disable, enable, remove, the OpenVPN client
profile, and TOTP enrolment (see users()); service accounts are not among them
@@ -24,7 +26,15 @@ Pages:
/site-to-site
the site-to-site connections of the configuration: list, add, change and remove,
applied by vpn-router-setup (see site_to_site())
/auth sign-in with a user's password and TOTP code, refresh and sign-out (see auth())
/backup the backup settings, the last run and the local archives: show, change and run
now (see backup())
/ca the PKI: its settings, the root and issuing CAs, the certificates issued; change the
settings, issue and revoke certificates, and take the certificate of an issuing CA
and a root CRL signed offline (see ca())
/pki/<name>.crt, /pki/<name>.crl
the DER certificate and CRL of the root (root) and of each issuing CA, which the
certificates name in their AIA and CRL distribution points; no authentication
/auth sign-in with a user's password and TOTP code, refresh and sign-out (see auth())
/metrics/history
the metrics the metrics service keeps, for a view: hour, day, month or year (see
metrics_history())
@@ -32,7 +42,7 @@ Pages:
the databases of each PostgreSQL cluster on the router with their sizes, as JSON (see
postgresql_databases()); /config has the clusters' settings
/config, /metrics/history, /postgresql, /users, /service-accounts, /groups, /site-to-site and /account need an HS256 JWT in an Authorization: Bearer header signed
/status/full, /config, /metrics/history, /postgresql, /users, /service-accounts, /groups, /site-to-site, /backup, /ca and /account need an HS256 JWT in an Authorization: Bearer header signed
with vpnrouter_auth.WEB_KEY (see vpnrouter_auth.verify). A token of /auth names a user in sub;
/account serves that user, and the other paths need a member of vpnrouter_users.ADMIN_GROUP. A
token of vpn-router-token has the admin claim and names no user; it may call every path but
@@ -67,8 +77,10 @@ the router has no features, and /config shows no configuration or interfaces.
"""
import collections
import configparser
import json
import os
import shutil
import signal
import sys
import threading
@@ -83,8 +95,10 @@ import ldap
import vpnrouter
import vpnrouter_auth
import vpnrouter_backup
import vpnrouter_metrics
import vpnrouter_net
import vpnrouter_pki
import vpnrouter_serve
import vpnrouter_users
from vpnrouter_status import established, established_each, openvpn_clients, run, swanctl, wireguard_peers
@@ -115,15 +129,21 @@ REFRESH_SECONDS = 60
POSTGRESQL_SETTINGS = ('server_version', 'listen_addresses', 'port', 'max_connections', 'shared_buffers',
'effective_cache_size', 'work_mem', 'password_encryption', 'ssl', 'timezone')
#: The largest request body accepted, in bytes.
BODY_LIMIT = 4096
#: The largest request body accepted, in bytes; a CSR or a certificate chain in PEM is a few kilobytes.
BODY_LIMIT = 65536
#: The settings of a site_to_site connection that /site-to-site adds and changes.
CONNECTION_SETTINGS = ('addrs', 'id', 'cidrs', 'local_cidrs', 'local_id_mode', 'psk')
CONNECTION_SETTINGS = ('addrs', 'id', 'cidrs', 'local_cidrs', 'local_id_mode', 'auth', 'psk', 'remote_ca')
#: The HTTP status for each status of a vpnrouter_users.Failure; any other is 500.
USER_STATUS = {2: 400, 3: 404, 4: 409, 5: 401}
#: The HTTP status for each status of a vpnrouter_pki.Failure; any other is 500.
PKI_STATUS = {2: 400, 3: 404, 4: 409}
#: A file /pki/ serves: the certificate or the CRL of a CA, and its content type.
PUBLISHED = {'.crt': 'application/pkix-cert', '.crl': 'application/pkix-crl'}
_cache = {'at': 0.0, 'data': None}
_lock = threading.Lock()
@@ -343,8 +363,10 @@ def postgresql_databases():
def details(state):
"""What /config shows: the configuration without its secrets, and what the router resolved and runs."""
"""What /config shows: the configuration without its secrets, and what the router resolved and runs; caddy is the
installed Caddy, {version, source}, null when it is not installed."""
configuration = state.get('configuration', {})
caddy = package_version('caddy')
return {
'configuration': configuration,
'network': dict(state.get('network', {}), routes=routes()),
@@ -358,6 +380,9 @@ def details(state):
'rules': lines_of('iptables', '-S', 'VPN-ROUTER-DOCKER'),
},
'postgresql': postgresql_configuration(),
# Caddy is upstream's once its repository is configured (vpn-router-caddy).
'caddy': {'version': caddy, 'source': 'upstream' if vpnrouter.CADDY_APT_SOURCE.is_file() else 'ubuntu'}
if caddy else None,
}
@@ -617,7 +642,8 @@ def site_to_site(method, path, body):
psk_set, whether psk holds a key, and established, whether its tunnel is up,
null when swanctl cannot be asked
POST /site-to-site add a connection: {"name", "addrs", "id", "cidrs", "local_cidrs", "local_id_mode",
"psk"}; psk is required, and an existing name is 409
"auth", "psk", "remote_ca"}; psk is required with auth psk, the default, and an
existing name is 409
PUT /site-to-site/<name> change any of the settings of POST but name; an empty or absent psk keeps the
current key, and a psk given replaces psk_file
DELETE /site-to-site/<name> remove the connection
@@ -647,8 +673,8 @@ def site_to_site(method, path, body):
return 400, {'error': 'name is required'}
if name in connections:
return 409, {'error': f'connection {name} exists'}
if not isinstance(settings.get('psk'), str) or not settings['psk']:
return 400, {'error': 'psk is required'}
if settings.get('auth', 'psk') == 'psk' and (not isinstance(settings.get('psk'), str) or not settings['psk']):
return 400, {'error': 'psk is required with auth psk'}
connections[name] = settings
elif name not in connections:
return 404, {'error': f'no connection {name}'}
@@ -676,6 +702,160 @@ def site_to_site(method, path, body):
return (201 if method == 'POST' else 200), connection_of(name, cfg['site_to_site'][name], None)
def backup_settings(settings):
"""The backup section as /backup shows it: its settings but pbs_password, with pbs_password_set."""
return {**{option: value for option, value in settings.items() if option != 'pbs_password'},
'pbs_password_set': bool(settings['pbs_password'])}
def rclone_remotes():
"""The names of the remotes of vpnrouter_backup.RCLONE_CONFIG; [] when it cannot be read, as when it is encrypted."""
parser = configparser.ConfigParser(interpolation=None)
try:
parser.read(vpnrouter_backup.RCLONE_CONFIG, encoding='utf-8')
except (OSError, configparser.Error):
return []
return parser.sections()
def backup(method, path, body):
"""Answer a /backup request: the HTTP status and the JSON document.
GET /backup {"settings", "state", "archives", "next", "client", "rclone"}: the backup section without
pbs_password, with pbs_password_set; the last run (vpnrouter_backup.state), null before the
first; the local archives of this host, newest first, [{name, time, size}], [] with the pbs
target; the next run of the timer as systemd shows it, null while backups are disabled;
whether proxmox-backup-client is installed; and {"installed", "config", "remotes"} of rclone
PUT /backup change any of the settings of the backup section; an empty or absent pbs_password keeps the
current secret
POST /backup/run start a backup now, without waiting for it; 409 while one runs
A change is made as /site-to-site makes one, and applied by restarting vpn-router-setup without waiting for it.
"""
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
return 400, {'error': 'the body must be a JSON object'}
body = body or {}
if (method, path) == ('POST', '/backup/run'):
if vpnrouter_backup.running():
return 409, {'error': 'a backup is running'}
result = run('systemctl', 'start', '--no-block', vpnrouter_backup.UNIT)
if result is None or result.returncode != 0:
return 500, {'error': f'the backup cannot be started: {result.stderr.strip() if result else "no systemctl"}'}
return 202, {'started': True}
if (method, path) not in (('GET', '/backup'), ('PUT', '/backup')):
return 404, {'error': f'no route {method} {path}'}
try:
if method == 'GET':
settings = vpnrouter.load_config()['backup']
timer = run('systemctl', 'show', '-p', 'NextElapseUSecRealtime', '--value', vpnrouter_backup.TIMER)
next_run = timer.stdout.strip() if timer and timer.returncode == 0 else ''
return 200, {
'settings': backup_settings(settings),
'state': vpnrouter_backup.state() or None,
'archives': vpnrouter_backup.archives(settings['path']) if settings['target'] == 'local' else [],
'next': next_run if settings['enabled'] and next_run not in ('', 'n/a') else None,
'client': vpnrouter_backup.PBS_CLIENT.exists(),
'rclone': {'installed': shutil.which('rclone') is not None,
'config': vpnrouter_backup.RCLONE_CONFIG.is_file(), 'remotes': rclone_remotes()},
}
settings = {option: body[option] for option in vpnrouter.SCHEMA['backup'] if option in body}
if not settings.get('pbs_password'):
settings.pop('pbs_password', None)
with vpnrouter.config_lock():
data = vpnrouter.read_settings()
current = data.get('backup')
data['backup'] = {**(current if isinstance(current, dict) else {}), **settings}
cfg = vpnrouter.with_defaults(data)
vpnrouter.validate(cfg)
vpnrouter.save_config(data)
except vpnrouter.ConfigError as exc:
return 400, {'error': str(exc)}
except OSError as exc:
return 500, {'error': f'the configuration cannot be written: {exc}'}
except SystemExit as exc:
return 500, {'error': str(exc.code)}
run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
return 200, {'settings': backup_settings(cfg['backup'])}
def ca(method, path, body):
"""Answer a /ca request with vpnrouter_pki: the HTTP status and the JSON document.
GET /ca {"settings", "templates", "extended_key_usages", "status",
"certificates"}: the pki section, the templates with the built-in
ones, the extended key usages, each {oid, builtin}, the root, the
issuing CAs, the retired CAs and the warnings (vpnrouter_pki.status),
and the certificates issued, newest first
PUT /ca change any of the settings of the pki section
POST /ca/certificates issue a certificate: {"ca", "template", "csr"} answers {"serial",
"certificate", "chain"}; {"ca", "template", "subject", "sans",
"password"} answers {"serial", "pfx"}, a PKCS#12 file in base64
POST /ca/certificates/<serial>/revoke revoke a certificate: {"reason"?}
GET /ca/bundle/<name> the PEM certificates a client trusts the CA with: {"name", "pem"}; for
root the root alone, for an issuing CA the issuing CA and the root
GET /ca/issuing/<name>/csr the CSR of an issuing CA that waits for the offline root, with the
openssl extensions to sign it with: {"name", "csr", "extensions"}
PUT /ca/issuing/<name>/certificate take its certificate signed by the root: {"certificate"}
PUT /ca/root/crl take a root CRL signed offline: {"crl"}
A change of the settings is made as /site-to-site makes one; it, a revoked router certificate and an imported
issuing CA certificate are applied by restarting vpn-router-setup without waiting for it.
"""
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
return 400, {'error': 'the body must be a JSON object'}
body = body or {}
parts = path.strip('/').split('/')
route = (method, *parts[1:])
try:
cfg = vpnrouter.load_config()
if len(route) == 3 and route[:2] == ('GET', 'bundle'):
return 200, vpnrouter_pki.bundle(route[2])
if route == ('GET',):
return 200, {'settings': cfg['pki'], 'templates': vpnrouter.pki_templates(cfg),
'extended_key_usages': {name: {'oid': oid, 'builtin': name in vpnrouter.EXTENDED_KEY_USAGES}
for name, oid in vpnrouter.extended_key_usages(cfg).items()},
'status': vpnrouter_pki.status(cfg), 'certificates': vpnrouter_pki.certificates()}
if route == ('PUT',):
settings = {option: body[option] for option in vpnrouter.SCHEMA['pki'] if option in body}
with vpnrouter.config_lock():
data = vpnrouter.read_settings()
current = data.get('pki')
data['pki'] = {**(current if isinstance(current, dict) else {}), **settings}
cfg = vpnrouter.with_defaults(data)
vpnrouter.validate(cfg)
vpnrouter.save_config(data)
run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
return 200, {'settings': cfg['pki'], 'templates': vpnrouter.pki_templates(cfg)}
with vpnrouter_pki.locked():
if route == ('POST', 'certificates'):
return 201, vpnrouter_pki.issue(cfg, body.get('ca'), body.get('template'), csr=body.get('csr'),
subject=body.get('subject'), sans=body.get('sans'),
password=body.get('password'))
if len(route) == 4 and route[:2] == ('POST', 'certificates') and route[3] == 'revoke':
document = vpnrouter_pki.revoke(cfg, route[2], body.get('reason'))
elif len(route) == 4 and route[:2] == ('GET', 'issuing') and route[3] == 'csr':
return 200, vpnrouter_pki.csr(cfg, route[2])
elif len(route) == 4 and route[:2] == ('PUT', 'issuing') and route[3] == 'certificate':
document = vpnrouter_pki.import_ca(cfg, route[2], body.get('certificate'))
elif route == ('PUT', 'root', 'crl'):
return 200, vpnrouter_pki.import_root_crl(body.get('crl'))
else:
return 404, {'error': f'no route {method} {path}'}
except vpnrouter.ConfigError as exc:
return 400, {'error': str(exc)}
except vpnrouter_pki.Failure as failure:
return PKI_STATUS.get(failure.status, 500), {'error': str(failure)}
except ldap.LDAPError as exc:
return 500, {'error': f'the directory, which holds the PKI, failed: {exc}'}
except OSError as exc:
return 500, {'error': f'the PKI cannot be changed: {exc}'}
except SystemExit as exc:
return 500, {'error': str(exc.code)}
if route[1] == 'issuing' or document.get('reissue'):
run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
return 200, document
def account(name, method, path, body):
"""Answer an /account request of the user named by the token with vpnrouter_users: the HTTP status and the JSON document.
@@ -864,9 +1044,10 @@ class Handler(BaseHTTPRequestHandler):
# A body that is not read must not be taken for the next request.
if self.command not in ('GET', 'HEAD'):
self.close_connection = True
directory = next((name for name in ('users', 'service-accounts', 'groups', 'account', 'site-to-site')
directory = next((name for name in ('users', 'service-accounts', 'groups', 'account', 'site-to-site', 'backup',
'ca')
if path == f'/{name}' or path.startswith(f'/{name}/')), None)
protected = path in ('/config', '/metrics/history', '/postgresql') or directory is not None
protected = path in ('/status/full', '/config', '/metrics/history', '/postgresql') or directory is not None
signing = path in ('/auth', '/auth/refresh', '/auth/logout')
state = current() if protected or signing else {}
claims = self.claims(state['key']) if protected and state['key'] else None
@@ -891,6 +1072,9 @@ class Handler(BaseHTTPRequestHandler):
elif protected and (refused := self.refusal(claims, path, payload)):
code, kind = refused[0], 'application/json'
body = json.dumps({'error': refused[1]}, indent=2).encode() + b'\n'
elif path == '/status/full' and self.command in ('GET', 'HEAD'):
document = shown(self.status(), 'full', self.client())
body, kind, code = json.dumps(document, indent=2).encode() + b'\n', 'application/json', 200
elif path == '/config' and self.command in ('GET', 'HEAD'):
body, kind, code = json.dumps(details(state), indent=2).encode() + b'\n', 'application/json', 200
elif path == '/postgresql' and self.command in ('GET', 'HEAD'):
@@ -905,6 +1089,12 @@ class Handler(BaseHTTPRequestHandler):
elif directory == 'site-to-site':
code, document = site_to_site(self.command, path, payload)
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif directory == 'backup':
code, document = backup(self.command, path, payload)
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif directory == 'ca':
code, document = ca(self.command, path, payload)
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif directory == 'service-accounts':
code, document = service_accounts(self.command, path, payload)
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
@@ -913,6 +1103,9 @@ class Handler(BaseHTTPRequestHandler):
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif self.command not in ('GET', 'HEAD'):
body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405
elif path.startswith('/pki/') and vpnrouter.PKI_NAME.match(path[5:-4]) and path[-4:] in PUBLISHED and \
(vpnrouter_pki.PUBLISH_DIR / path[5:]).is_file():
body, kind, code = (vpnrouter_pki.PUBLISH_DIR / path[5:]).read_bytes(), PUBLISHED[path[-4:]], 200
elif path in ('/status', '/'):
document = shown(self.status(), section(read_json(CONFIG_FILE), 'web').get('status_content'), self.client())
agent = self.headers.get('User-Agent', '').lower()
+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:]))
@@ -22,14 +22,23 @@
"cidrs": ["192.168.0.0/24"],
"local_cidrs": ["10.0.0.0/24"],
"local_id_mode": "fqdn",
"auth": "psk",
"psk": "change-me",
"psk_file": ""
"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,
@@ -58,7 +67,35 @@
"enabled": true,
"email": "",
"web_port": 7444,
"manager_port": 7443
"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,
@@ -74,5 +111,24 @@
"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": ""
}
}
@@ -4,10 +4,69 @@
{%- 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 %}
{% if web_enabled or 'https' in pki_publish or acme_cas %}
{{ local_fqdn }}:{{ https_web_port }} {
reverse_proxy {{ loopback }}:{{ 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 %}
@@ -5,15 +5,16 @@ dev-type tun
topology subnet
server {{ openvpn_network }} {{ openvpn_netmask }}
ca {{ ca_cert }}
ca {{ openvpn_ca }}
cert {{ server_cert }}
key {{ server_key }}
crl-verify {{ crl }}
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 -%}
@@ -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
}
@@ -11,12 +11,22 @@ connections {
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 {
@@ -40,7 +50,7 @@ connections {
}
secrets {
{%- for connection in connections if connection.ready %}
{%- 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 }}
+4 -6
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
@@ -27,7 +27,7 @@ locals {
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 }
}
}
@@ -102,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)
)
+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}
+4 -6
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
@@ -27,16 +27,14 @@ locals {
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)
}
+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
+5 -3
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}"
@@ -66,8 +66,10 @@ cat > /etc/vpn-router/vpn-router.json <<EOF
},
"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},