Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d648d294ec | ||
|
|
a245ec26cd | ||
|
|
7404ee88c4 | ||
|
|
448ce05b3a | ||
|
|
e11c88f0de | ||
|
|
c19b195ea0 | ||
|
|
8d3469d10a | ||
|
|
fe45272f7c | ||
|
|
7d1f639508 | ||
|
|
959ca0cd32 |
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 +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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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:]))
|
||||
Executable
+212
@@ -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:]))
|
||||
Executable
+151
@@ -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 }}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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},
|
||||
|
||||
Reference in new issue
Block a user