feature: the router has a two-level CA with an optional offline root, issuing CAs for P2S, site-to-site and general use, templates, published certificates and CRLs and an ACME server, site-to-site connections authenticate with certificates, and setup installs Caddy from Ubuntu or Caddy's repository.
Release / release (push) Successful in 13s

This commit is contained in:
slawek committed 2026-10-25 22:05:39 +01:00
1 parent fe45272f7c
commit 8d3469d10a
37 files changed
+2499 -921

No files matched your search

+163 -29
View File
@@ -121,25 +121,30 @@ and it is backed up with `slapcat`, not by copying files.
Status: Accepted
Context: The router CA issues client certificates for IKEv2. OpenVPN can check a certificate, a
password, or both. With both, a certificate alone and a password alone are not enough, but
`openvpn-auth-ldap` checks only the password of the user name given at login, which need not be
the user of the certificate.
Context: The P2S issuing CA (ADR-27) issues the users' client certificates, for IKEv2 and
OpenVPN. OpenVPN can check a certificate, a password, or both. With both, a certificate alone and
a password alone are not enough, but `openvpn-auth-ldap` checks only the password of the user name
given at login, which need not be the user of the certificate. OpenVPN trusts every chain to a
CA of its `ca` file and has no option that accepts one intermediate CA only, while the root also
signs the site-to-site and the general-purpose CAs.
Decision: OpenVPN checks the certificate against the CA and the CRL, the password with
`openvpn-auth-ldap`, and runs `openvpn-same-user` as `auth-user-pass-verify` with `via-env`,
which accepts the login only when the user name equals the certificate's common name. OpenVPN
calls both and needs both to pass. A user's certificate has the user name as its common name, is
issued when the user is added and revoked when the user is removed. The CRL is created with the
CA, so that `crl-verify` has a file from the start, and is valid for ten years, because the router
renews it only when it revokes a certificate. The server uses the router's own certificate, whose
common name is `wan.local_fqdn`; the client profile checks that name (`verify-x509-name`), since
the CA's certificates carry both the server and the client purpose.
Decision: OpenVPN checks the certificate against the root and the P2S CA and their CRLs, which
it needs for every CA of the chain, the password with `openvpn-auth-ldap`, and runs
`openvpn-same-user` as `auth-user-pass-verify` with `via-env`, which accepts the login only when
the user name equals the certificate's common name. `openvpn-issuer`, as `tls-verify`, accepts
the certificate at depth 1 only when its SHA-256 fingerprint, which OpenVPN gives the script,
is the P2S CA's. OpenVPN calls all of them and needs all to pass. A user's certificate has the
user name as its common name, is issued with the template `vpn-user`, client authentication
only, when the user is added, and revoked when the user is removed. The server uses the router's
VPN server certificate, whose common name is `wan.local_fqdn`; the client profile checks that
name (`verify-x509-name`).
Consequences: Disabling a user locks the password, which stops OpenVPN but not IKEv2, which
checks certificates only; removing a user revokes the certificate for both. A user's
certificate expires after the CA's default of a year and is renewed by removing and adding the
user again.
checks certificates only; removing a user revokes the certificate for both. A certificate of
another issuing CA with a user's name does not reach the password check. A user's certificate
expires after the days of `vpn-user` and is renewed by removing and adding the user again. An
expired root CRL stops every OpenVPN client, so an offline root needs its CRL renewed in time
(ADR-28).
## ADR-6: The web service and vpn-router-user share one module for users
@@ -147,9 +152,9 @@ Status: Accepted
Context: Users are managed from the command line, by an operator at a terminal, and through
`/users` of the web service, by the Router Manager. Both must apply the same rules to names,
passwords and person attributes, and both change the directory and the CA. `simple-ca` keeps
the CA's state in one file, so two changes must not run at the same time. The web unit is
sandboxed with `ProtectSystem=strict`.
passwords and person attributes, and both change the directory and the PKI, whose state is one
file, so two changes must not run 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; `/etc/vpn-router/pki` for the certificates and the PKI's state, and
`/etc/swanctl/x509crl`, `/etc/openvpn/server` and `/var/lib/vpn-router/pki-publish` for the
CRLs a revocation signs. 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
@@ -721,9 +730,134 @@ state in one directory and then archives or sends it.
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 CA key, in plain form
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 state is
`pki/state.json`. 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`,
the public address and the internal address, so it fits every `local_id_mode`; 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 an address it names 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.
+290 -47
View File
@@ -2,8 +2,11 @@
A Debian package that configures a Linux host or virtual machine as a VPN router:
- site-to-site IKEv2 IPSec with pre-shared key authentication (strongSwan swanctl)
- road-warrior (P2S) access using IKEv2 EAP-TLS, with a bundled certificate authority
- site-to-site IKEv2 IPSec with pre-shared key or certificate authentication (strongSwan swanctl)
- road-warrior (P2S) access using IKEv2 EAP-TLS
- a two-level certificate authority: a root, which may be kept offline, and issuing CAs for the VPN
clients, for site-to-site and for general use, with certificate templates, published certificates
and CRLs, and an optional ACME server
- OpenVPN (P2S) access with a certificate and a password per user, the users kept in a local
LDAP directory and managed with `vpn-router-user` or the web service
- optional WireGuard endpoint
@@ -110,7 +113,6 @@ must have the type shown below; an unknown section or setting is an error.
| `site_to_site` | | object | | The site-to-site connections, each a name and an object of the settings below |
| `p2s` | `enabled` | boolean | `false` | Road-warrior access |
| `p2s` | `address_pool` | string | | CIDR assigned to road-warrior clients |
| `p2s` | `ca_name` | string | `VPN Router CA` | Common name for a locally created CA |
| `wireguard` | `enabled` | boolean | `false` | WireGuard endpoint |
| `wireguard` | `address` | string | | Address and prefix for `wg0`, for example `192.168.200.1/24` |
| `wireguard` | `listen_port` | integer | `51820` | UDP port |
@@ -130,6 +132,18 @@ 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) |
| `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` |
@@ -176,13 +190,22 @@ 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`, the public address and the internal
address, so it fits every `local_id_mode`. 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:
@@ -341,57 +364,215 @@ 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 in `/etc/vpn-router/pki` (mode 0700), 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:
```
ca_cert.pem ca_key.pem ca_bundle.pem <label>_cert.pem <label>_key.pem crl.pem
root/cert.pem root/key.pem root/crl.pem the root CA; key.pem is absent while the root is offline
ca/<name>/cert.pem key.pem crl.pem csr.pem an issuing CA; csr.pem while it waits for the offline root
router/vpn-server_cert.pem _key.pem the router's VPN server certificate
router/site-to-site_cert.pem _key.pem the router's site-to-site certificate
users/<user>_cert.pem _key.pem the users' certificates
retired/<name>-<time>/ the issuing CAs taken out of use
state.json the certificates issued, revocations and CRL numbers
```
`<label>` is the first component of the router FQDN, so `router.example.com` gives
`router_cert.pem`.
### The CAs
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.
The root signs the issuing CAs only (path length 1), and the issuing CAs sign the certificates
(path length 0):
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
- `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 directory moves to `retired/` 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.
Issue a client certificate with the bundled tool:
`vpn-router-ca status` shows the root, the issuing CAs and what needs attention:
```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
vpn-router-ca status
vpn-router-ca list --ca p2s
```
`simple-ca` comes from a separate project, <https://gitea.koszewscy.waw.pl/slawek/simple-ca>,
and also supports `make-crl` and `revoke-cert`.
### 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 `server_auth`, `client_auth`, `code_signing`, `email_protection`, `time_stamping`, `ocsp_signing`, `ipsec_ike`, `ike_intermediate` |
| `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"] } },
"templates": {
"server": { "days": 397 },
"web": { "key": "ec-p256", "days": 90, "key_usage": ["digital_signature"],
"extended_key_usage": ["server_auth"], "subject": "dns", "sans": ["dns"] }
}
}
```
### 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. 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, before choosing the platform: `vpn-router-ca root import-cert root-cert.pem`, or the files
`root/cert.pem` and `root/key.pem`. 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
@@ -723,6 +904,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
@@ -737,7 +919,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 |
| --- | --- | --- |
@@ -830,8 +1012,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}` |
@@ -865,6 +1047,36 @@ The answer of `GET /backup` holds:
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", "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/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.
- **`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
@@ -877,7 +1089,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
@@ -891,7 +1103,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
@@ -902,6 +1115,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
@@ -1023,7 +1264,7 @@ A backup holds the state that `vpn-router-setup` cannot make again:
| Item | Content |
|---|---|
| `/etc/vpn-router` | `vpn-router.json`, the secrets, `rclone.conf`, the PKI and the certificates of the users |
| `/etc/vpn-router` | `vpn-router.json`, the secrets, `rclone.conf`, and the PKI: the CAs, the root key while it is on the router, and the certificates of the router and the users |
| `/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 |
@@ -1107,7 +1348,7 @@ What it replaces is kept in `/var/lib/vpn-router/backup/before-restore/<UTC time
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, site-to-site
connections and backups through the status web service. It can be installed on the router or on another Linux system. While it is installed on
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
@@ -1116,8 +1357,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`, `vpn-router-metrics` and
`vpn-router-backup.timer` 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.
@@ -1127,8 +1368,10 @@ 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. It removes the backup timer's schedule and the
record of the last backup. Key material, users and backups are never deleted: `/etc/vpn-router/pki`,
`/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/pki`,
`/etc/vpn-router/ldap`, `/etc/vpn-router/rclone.conf`, the user directory in slapd, the WireGuard
key pair, everything under `/etc/swanctl`, the archives in `backup.path` and
`/var/lib/vpn-router/backup/before-restore` are left alone.
+39
View File
@@ -1,3 +1,42 @@
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.
+1 -1
View File
@@ -51,6 +51,7 @@ db_input high vpn-router/remote_addrs || true
db_input high vpn-router/remote_id || true
db_input high vpn-router/remote_cidrs || true
db_input high vpn-router/psk || true
db_input high vpn-router/pki_root_name || true
db_input high vpn-router/p2s_enabled || true
db_input high vpn-router/wg_enabled || true
db_go || true
@@ -58,7 +59,6 @@ db_go || true
db_get vpn-router/p2s_enabled
if [ "$RET" = "true" ]; then
db_input high vpn-router/p2s_address_pool || true
db_input high vpn-router/p2s_ca_name || true
db_go || true
fi
+15 -11
View File
@@ -17,8 +17,8 @@ Depends: ${misc:Depends},
ufw,
debconf,
ucf,
openssl,
python3,
python3-cryptography,
python3-jinja2,
python3-ldap,
python3-pyotp,
@@ -31,20 +31,24 @@ Depends: ${misc:Depends},
openvpn-auth-ldap,
iproute2,
systemd-resolved,
caddy,
zstd
Suggests: rclone,
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, 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.
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
+4 -1
View File
@@ -8,13 +8,16 @@ src/usr/lib/vpn-router/vpnrouter_auth.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_metrics.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_status.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_backup.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_pki.py usr/lib/vpn-router/
src/usr/lib/vpn-router/openvpn-same-user usr/lib/vpn-router/
src/usr/lib/vpn-router/openvpn-issuer usr/lib/vpn-router/
src/usr/sbin/vpn-router-user usr/sbin/
src/usr/sbin/vpn-router-group usr/sbin/
src/usr/sbin/vpn-router-token usr/sbin/
src/usr/sbin/vpn-config-edit usr/sbin/
src/usr/sbin/vpn-router-backup usr/sbin/
src/usr/lib/vpn-router/simple-ca usr/lib/vpn-router/
src/usr/sbin/vpn-router-ca usr/sbin/
src/usr/sbin/vpn-router-caddy usr/sbin/
src/usr/lib/vpn-router/configure usr/lib/vpn-router/
src/usr/lib/vpn-router/docker-rules usr/lib/vpn-router/
src/usr/lib/vpn-router/setup usr/lib/vpn-router/
+2 -2
View File
@@ -31,7 +31,7 @@ case "$1" in
answer vpn-router/psk; VPN_ROUTER_PSK="$ANSWER"
answer vpn-router/p2s_enabled; VPN_ROUTER_P2S_ENABLED="$ANSWER"
answer vpn-router/p2s_address_pool; VPN_ROUTER_P2S_ADDRESS_POOL="$ANSWER"
answer vpn-router/p2s_ca_name; VPN_ROUTER_P2S_CA_NAME="$ANSWER"
answer vpn-router/pki_root_name; VPN_ROUTER_PKI_ROOT_NAME="$ANSWER"
answer vpn-router/wg_enabled; VPN_ROUTER_WG_ENABLED="$ANSWER"
answer vpn-router/wg_address; VPN_ROUTER_WG_ADDRESS="$ANSWER"
answer vpn-router/wg_listen_port; VPN_ROUTER_WG_LISTEN_PORT="$ANSWER"
@@ -44,7 +44,7 @@ case "$1" in
VPN_ROUTER_REMOTE_ADDRS VPN_ROUTER_REMOTE_ID \
VPN_ROUTER_REMOTE_CIDRS VPN_ROUTER_PSK \
VPN_ROUTER_P2S_ENABLED VPN_ROUTER_P2S_ADDRESS_POOL \
VPN_ROUTER_P2S_CA_NAME VPN_ROUTER_WG_ENABLED \
VPN_ROUTER_PKI_ROOT_NAME VPN_ROUTER_WG_ENABLED \
VPN_ROUTER_WG_ADDRESS VPN_ROUTER_WG_LISTEN_PORT
# --- Hand a candidate configuration to ucf ---
+6 -2
View File
@@ -57,7 +57,9 @@ case "$1" in
/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
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.
@@ -70,7 +72,9 @@ case "$1" in
# Key material and users are not package state: /etc/vpn-router/pki,
# /etc/vpn-router/ldap, /etc/vpn-router/rclone.conf, the user directory
# in slapd, the WireGuard key pair, everything under /etc/swanctl, the
# backups and what a restore replaced are left in place.
# 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
+4 -4
View File
@@ -17,11 +17,11 @@ case "$1" in
fi
# vpn-router-setup enabled wg-quick@wg0, openvpn-server@vpn-router,
# caddy, vpn-router-metrics and the backup timer, so the package takes
# them down again. Use the debhelper wrappers rather than systemctl, so
# the script behaves on a machine without systemd.
# 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; do
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
+2
View File
@@ -9,3 +9,5 @@ override_dh_installsystemd:
dh_installsystemd --name=vpn-router-metrics --no-enable --no-start
dh_installsystemd --name=vpn-router-bootstrap --no-enable --no-start
dh_installsystemd --name=vpn-router-backup --no-enable --no-start
dh_installsystemd --name=vpn-router-pki --no-enable --no-start
dh_installsystemd --name=vpn-router-caddy --no-enable --no-start
+6 -7
View File
@@ -124,8 +124,7 @@ Type: boolean
Default: false
Description: Enable road-warrior (P2S) access?
If enabled, this router accepts IKEv2 EAP-TLS connections from individual
clients. Certificates are taken from /etc/vpn-router/pki, and a local
certificate authority is created there if that directory is empty.
clients, with certificates of the router's P2S issuing CA.
Template: vpn-router/p2s_address_pool
Type: string
@@ -133,12 +132,12 @@ Default:
Description: Road-warrior address pool:
CIDR block assigned to road-warrior clients (for example 172.16.0.0/24).
Template: vpn-router/p2s_ca_name
Template: vpn-router/pki_root_name
Type: string
Default: VPN Router CA
Description: Road-warrior CA name:
Common name for the certificate authority created in /etc/vpn-router/pki
when that directory is empty. Ignored when certificates are supplied.
Default: VPN Router Root CA
Description: Root CA name:
Common name of the root certificate authority the router creates in
/etc/vpn-router/pki/root. Ignored when a root certificate is placed there.
Template: vpn-router/wg_enabled
Type: boolean
@@ -0,0 +1,10 @@
[Unit]
Description=VPN Router Caddy installation
Documentation=file:/usr/share/doc/vpn-router/README.md
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/sbin/vpn-router-caddy apply
TimeoutStartSec=20min
@@ -0,0 +1,7 @@
[Unit]
Description=VPN Router PKI maintenance
Documentation=file:/usr/share/doc/vpn-router/README.md
[Service]
Type=oneshot
ExecStart=/usr/sbin/vpn-router-ca maintain
@@ -0,0 +1,10 @@
[Unit]
Description=VPN Router PKI maintenance schedule
Documentation=file:/usr/share/doc/vpn-router/README.md
[Timer]
OnCalendar=daily
Persistent=true
[Install]
WantedBy=timers.target
@@ -15,7 +15,7 @@ NoNewPrivileges=yes
ProtectSystem=strict
RuntimeDirectory=vpn-router
RuntimeDirectoryPreserve=yes
ReadWritePaths=-/etc/vpn-router -/etc/swanctl/x509crl
ReadWritePaths=-/etc/vpn-router -/etc/swanctl/x509crl -/etc/openvpn/server -/var/lib/vpn-router/pki-publish
ProtectHome=yes
PrivateTmp=yes
@@ -50,7 +50,7 @@ ANSWERS = {
'VPN_ROUTER_PSK': ((*FIRST, 'psk'),),
'VPN_ROUTER_P2S_ENABLED': (('p2s', 'enabled'),),
'VPN_ROUTER_P2S_ADDRESS_POOL': (('p2s', 'address_pool'),),
'VPN_ROUTER_P2S_CA_NAME': (('p2s', 'ca_name'),),
'VPN_ROUTER_PKI_ROOT_NAME': (('pki', 'root_name'),),
'VPN_ROUTER_WG_ENABLED': (('wireguard', 'enabled'),),
'VPN_ROUTER_WG_ADDRESS': (('wireguard', 'address'),),
'VPN_ROUTER_WG_LISTEN_PORT': (('wireguard', 'listen_port'),),
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/python3
"""Accept an OpenVPN client certificate only when the P2S issuing CA signed it.
Run by the OpenVPN server as tls-verify for each certificate of the client's chain, with its depth and subject as the
arguments. At depth 1, the CA that signed the client's certificate, the SHA-256 fingerprint OpenVPN gives in
tls_digest_sha256_1 must be that of P2S_CA; the other depths are accepted.
"""
import hashlib
import os
import ssl
import sys
P2S_CA = '/etc/swanctl/x509ca/p2s.pem'
if len(sys.argv) < 2 or sys.argv[1] != '1':
sys.exit(0)
try:
with open(P2S_CA, encoding='ascii') as handle:
expected = hashlib.sha256(ssl.PEM_cert_to_DER_cert(handle.read())).hexdigest()
except (OSError, ValueError):
sys.exit(1)
given = os.environ.get('tls_digest_sha256_1', '').replace(':', '').lower()
sys.exit(0 if given == expected else 1)
+61 -10
View File
@@ -22,6 +22,7 @@ import os
import pathlib
import re
import secrets
import shutil
import socket
import subprocess
import sys
@@ -33,8 +34,9 @@ import ldap
import vpnrouter
import vpnrouter_ldap
import vpnrouter_pki
import vpnrouter_users
from vpnrouter import fail
from vpnrouter import fail, warn
BEFORE_RULES = pathlib.Path('/etc/ufw/before.rules')
UFW_DEFAULTS = pathlib.Path('/etc/default/ufw')
@@ -72,6 +74,15 @@ 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.
@@ -266,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',
]
@@ -528,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)
@@ -542,16 +565,42 @@ 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)
acme = [name for name in ctx['pki_acme'] if vpnrouter_pki.ready(name)]
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 in ('cert.pem', 'key.pem'):
copied |= vpnrouter.install_file(vpnrouter_pki.CA_DIR / name / file, folder / file, 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)
@@ -744,7 +793,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')
@@ -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,7 +12,6 @@ import os
import pathlib
import re
import shutil
import subprocess
import sys
import tempfile
import urllib.parse
@@ -24,14 +23,13 @@ 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 +38,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,13 +46,16 @@ 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': {}},
'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',
@@ -74,7 +68,11 @@ SCHEMA = {
}
#: 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}$')
@@ -115,6 +113,68 @@ PBS_NAMESPACE = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9._-]*(/[A-Za-z0-9_][A-Za-z0-
#: 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 extended key usages a template may give.
EXTENDED_KEY_USAGES = ('server_auth', 'client_auth', 'code_signing', 'email_protection', 'time_stamping',
'ocsp_signing', 'ipsec_ike', 'ike_intermediate')
#: 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': []}
#: 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')
@@ -125,9 +185,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})?$')
@@ -337,6 +394,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('@'):
@@ -414,8 +475,89 @@ 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, 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', EXTENDED_KEY_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)}')
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:
@@ -496,7 +638,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 '
@@ -512,6 +655,37 @@ def backup_keeps(schedule):
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 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()
@@ -626,6 +800,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'])),
@@ -636,10 +811,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 {
@@ -658,7 +836,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'),
@@ -674,10 +852,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'),
@@ -686,6 +865,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'),
@@ -706,7 +887,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):
@@ -714,7 +896,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):
@@ -805,62 +988,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.
@@ -883,9 +1010,20 @@ def configure(cfg=None, public_ip=None):
changed = set()
provision_pki(ctx)
if distribute_pki(ctx):
changed.add('swanctl')
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))
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,990 @@
"""The router's PKI, for vpn-router-setup, the web service, vpn-router-ca and the user module.
The root CA (ROOT_DIR) signs the issuing CAs, and only them: p2s, which signs the router's VPN server certificate and
the users' certificates, s2s, which signs the router's site-to-site certificate and those of its peers, and the
general-purpose CAs of pki.issuing_cas. Each CA is a directory with cert.pem, key.pem and crl.pem. While the root key is
not on the router, a new issuing CA has key.pem and csr.pem and is pending until import_ca takes its certificate, signed
offline. The router's own certificates are in ROUTER_DIR and the users' in USER_DIR, with their keys, which the OpenVPN
profile embeds; a certificate issued for anything else is kept without its key. STATE records every certificate issued,
its revocation, the CRL numbers, where the root CRL came from and the retired CAs.
The certificates carry the URLs of pki.publish for their issuer's certificate (AIA) and CRL (CRL distribution point);
PUBLISH_DIR holds the DER copies served there. distribute copies what strongSwan and OpenVPN read.
Callers hold locked() around every operation that writes, which is the lock of the user module's sessions too. A request
that cannot be done raises Failure, whose status says why: 2 the request is invalid, 3 something it names does not
exist, 4 the state does not allow it, 1 anything else.
"""
import base64
import contextlib
import datetime
import fcntl
import ipaddress
import json
import os
import pathlib
import re
import shutil
import subprocess
import tempfile
from cryptography import x509
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec, rsa
from cryptography.hazmat.primitives.serialization import pkcs12
from cryptography.x509.oid import AuthorityInformationAccessOID, ExtendedKeyUsageOID, NameOID
import vpnrouter
PKI_DIR = pathlib.Path('/etc/vpn-router/pki')
ROOT_DIR = PKI_DIR / 'root'
CA_DIR = PKI_DIR / 'ca'
ROUTER_DIR = PKI_DIR / 'router'
USER_DIR = PKI_DIR / 'users'
RETIRED_DIR = PKI_DIR / 'retired'
STATE = PKI_DIR / 'state.json'
#: The DER copies of the CA certificates and CRLs, <name>.crt and <name>.crl, which the web service and Caddy serve.
PUBLISH_DIR = pathlib.Path('/var/lib/vpn-router/pki-publish')
#: The lock of the PKI and of the user module's sessions.
LOCK_FILE = pathlib.Path('/run/vpn-router/users.lock')
#: The copies strongSwan reads: one CA certificate per file, as strongSwan reads only the first of a file.
X509CA_DIR = pathlib.Path('/etc/swanctl/x509ca')
X509_DIR = pathlib.Path('/etc/swanctl/x509')
PRIVATE_DIR = pathlib.Path('/etc/swanctl/private')
X509CRL_DIR = pathlib.Path('/etc/swanctl/x509crl')
SWANCTL_CA_FILES = {'root': 'root.pem', 'p2s': 'p2s.pem', 's2s': 's2s.pem'}
VPN_SERVER_CERT = X509_DIR / 'vpn-server.pem'
VPN_SERVER_KEY = PRIVATE_DIR / 'vpn-server.key'
S2S_CERT = X509_DIR / 'site-to-site.pem'
S2S_KEY = PRIVATE_DIR / 'site-to-site.key'
#: What OpenVPN reads: the root and the P2S CA, and their CRLs, which it needs for every CA of a client's chain.
OPENVPN_CA = pathlib.Path('/etc/openvpn/server/vpn-router-ca.pem')
OPENVPN_CRL = pathlib.Path('/etc/openvpn/server/vpn-router-crl.pem')
#: The root CA in the system's trust store.
TRUST_ANCHOR = pathlib.Path('/usr/local/share/ca-certificates/vpn-router-ca.crt')
#: The router's own certificates: name -> the issuing CA and the template.
ROUTER_CERTS = {'vpn-server': ('p2s', 'vpn-server'), 'site-to-site': ('s2s', 'site-to-site')}
#: The extended key usages of vpnrouter.EXTENDED_KEY_USAGES.
EKU_OIDS = {
'server_auth': ExtendedKeyUsageOID.SERVER_AUTH,
'client_auth': ExtendedKeyUsageOID.CLIENT_AUTH,
'code_signing': ExtendedKeyUsageOID.CODE_SIGNING,
'email_protection': ExtendedKeyUsageOID.EMAIL_PROTECTION,
'time_stamping': ExtendedKeyUsageOID.TIME_STAMPING,
'ocsp_signing': ExtendedKeyUsageOID.OCSP_SIGNING,
'ipsec_ike': ExtendedKeyUsageOID.IPSEC_IKE,
'ike_intermediate': x509.ObjectIdentifier('1.3.6.1.5.5.8.2.2'),
}
#: The revocation reasons revoke takes.
REASONS = ('unspecified', 'key_compromise', 'affiliation_changed', 'superseded', 'cessation_of_operation',
'privilege_withdrawn')
#: Days before an expiry or a CRL's next update that status warns.
WARN_DAYS = 30
#: The longest PFX password, in characters.
PASSWORD_MAX = 128
DNS_NAME = re.compile(r'^(\*\.)?[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$')
EMAIL = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
SERIAL = re.compile(r'^[0-9A-Fa-f]{1,40}$')
CA_USAGE = x509.KeyUsage(digital_signature=False, content_commitment=False, key_encipherment=False,
data_encipherment=False, key_agreement=False, key_cert_sign=True, crl_sign=True,
encipher_only=False, decipher_only=False)
class Failure(Exception):
"""A request that cannot be done; status is 2 invalid, 3 not found, 4 not allowed in this state, 1 other."""
def __init__(self, message, status=1):
super().__init__(message)
self.status = status
@contextlib.contextmanager
def locked():
"""Hold the lock of the PKI, which the user module's sessions share."""
LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(LOCK_FILE, 'w') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
yield
def now():
return datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0)
def iso(moment):
return moment.astimezone(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
def parse_iso(text):
return datetime.datetime.strptime(text, '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=datetime.timezone.utc)
def utc(item, name):
"""A time of a certificate or CRL in UTC: the *_utc attribute of newer cryptography, or the naive one of older."""
if hasattr(item, f'{name}_utc'):
return getattr(item, f'{name}_utc')
value = getattr(item, name)
return value.replace(tzinfo=datetime.timezone.utc) if value is not None else None
def directory(name):
"""The directory of the root (root) or of an issuing CA."""
return ROOT_DIR if name == 'root' else CA_DIR / name
def read_cert(path):
return x509.load_pem_x509_certificate(path.read_bytes()) if path.is_file() else None
def read_key(path):
return serialization.load_pem_private_key(path.read_bytes(), None) if path.is_file() else None
def read_crl(path):
return x509.load_pem_x509_crl(path.read_bytes()) if path.is_file() else None
def cert_pem(cert):
return cert.public_bytes(serialization.Encoding.PEM)
def key_pem(key, password=None):
encryption = serialization.BestAvailableEncryption(password) if password else serialization.NoEncryption()
return key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, encryption)
def write(path, data, mode):
"""Write the bytes to the file with the mode, replacing it in one step; True when its content changed."""
if path.is_file() and path.read_bytes() == data:
os.chmod(path, mode)
return False
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
descriptor, name = tempfile.mkstemp(dir=path.parent, prefix=f'.{path.name}.')
try:
with os.fdopen(descriptor, 'wb') as handle:
handle.write(data)
os.chmod(name, mode)
os.replace(name, path)
except OSError:
os.unlink(name)
raise
return True
def remove(path):
"""Remove the file; True when it existed."""
if path.is_file():
path.unlink()
return True
return False
def read_state():
try:
state = json.loads(STATE.read_text(encoding='utf-8'))
except FileNotFoundError:
state = {}
state.setdefault('certificates', [])
state.setdefault('crl_numbers', {})
state.setdefault('retired', [])
state.setdefault('root_crl', None)
return state
def write_state(state):
write(STATE, (json.dumps(state, indent=2) + '\n').encode(), 0o600)
def new_key(kind):
"""A new private key of one of vpnrouter.PKI_KEYS."""
if kind.startswith('rsa-'):
return rsa.generate_private_key(public_exponent=65537, key_size=int(kind[4:]))
return ec.generate_private_key(ec.SECP384R1() if kind == 'ec-p384' else ec.SECP256R1())
def digest(key):
"""The hash a key signs with: SHA-384 for a P-384 key, SHA-256 otherwise."""
return hashes.SHA384() if isinstance(key, ec.EllipticCurvePrivateKey) and key.curve.name == 'secp384r1' \
else hashes.SHA256()
def common_name(item):
"""The common name of a certificate's or a CSR's subject, or the whole subject without one."""
names = item.subject.get_attributes_for_oid(NameOID.COMMON_NAME)
return names[0].value if names else item.subject.rfc4514_string()
def san_values(item):
"""The subject alternative names of a certificate or a CSR as strings: DNS:, IP: and email: values."""
try:
names = item.extensions.get_extension_for_class(x509.SubjectAlternativeName).value
except x509.ExtensionNotFound:
return []
return ([f'DNS:{value}' for value in names.get_values_for_type(x509.DNSName)] +
[f'IP:{value}' for value in names.get_values_for_type(x509.IPAddress)] +
[f'email:{value}' for value in names.get_values_for_type(x509.RFC822Name)])
def same_key(first, second):
der = serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo
return first.public_bytes(*der) == second.public_bytes(*der)
def issued_by(cert, issuer):
"""Whether the issuer's key signed the certificate."""
try:
cert.verify_directly_issued_by(issuer)
except (ValueError, TypeError, InvalidSignature):
return False
return True
def is_ca(cert):
try:
return cert.extensions.get_extension_for_class(x509.BasicConstraints).value.ca
except x509.ExtensionNotFound:
return False
def is_ca_certificate(text):
"""Whether the text is the PEM certificate of a CA."""
try:
return is_ca(x509.load_pem_x509_certificate(text.strip().encode()))
except ValueError:
return False
def authority_key_id(issuer):
try:
return x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(
issuer.extensions.get_extension_for_class(x509.SubjectKeyIdentifier).value)
except x509.ExtensionNotFound:
return x509.AuthorityKeyIdentifier.from_issuer_public_key(issuer.public_key())
def sign(subject, public_key, issuer, issuer_key, days, extensions, urls=(), issuer_name=None):
"""A certificate with the common name subject for public_key, signed by issuer_key, valid for days but not past the
issuer, with the extensions [(extension, critical)].
issuer is the issuer's certificate, None for a self-signed one. The certificate points at the issuer's certificate
and CRL, issuer_name.crt and issuer_name.crl, under each of the base urls.
"""
moment = now()
end = moment + datetime.timedelta(days=days)
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, subject)])
builder = (x509.CertificateBuilder()
.subject_name(name)
.issuer_name(issuer.subject if issuer is not None else name)
.public_key(public_key)
.serial_number(x509.random_serial_number())
.not_valid_before(moment - datetime.timedelta(minutes=5))
.not_valid_after(min(end, utc(issuer, 'not_valid_after')) if issuer is not None else end)
.add_extension(x509.SubjectKeyIdentifier.from_public_key(public_key), False))
if issuer is not None:
builder = builder.add_extension(authority_key_id(issuer), False)
if issuer is not None and urls:
builder = builder.add_extension(x509.AuthorityInformationAccess([
x509.AccessDescription(AuthorityInformationAccessOID.CA_ISSUERS,
x509.UniformResourceIdentifier(f'{url}{issuer_name}.crt')) for url in urls]), False)
builder = builder.add_extension(x509.CRLDistributionPoints([x509.DistributionPoint(
[x509.UniformResourceIdentifier(f'{url}{issuer_name}.crl') for url in urls], None, None, None)]), False)
for extension, critical in extensions:
builder = builder.add_extension(extension, critical)
return builder.sign(issuer_key, digest(issuer_key))
def ca_extensions(path_length):
return [(x509.BasicConstraints(ca=True, path_length=path_length), True), (CA_USAGE, True)]
def general_names(values, allowed):
"""The subject alternative names of values, each a DNS name, an IP address or an e-mail address of the allowed
types (vpnrouter.SAN_TYPES)."""
names = []
for value in values:
if not isinstance(value, str) or not value.strip():
raise Failure('a subject alternative name must be a non-empty string', 2)
value = value.strip()
try:
kind, name = 'ip', x509.IPAddress(ipaddress.ip_address(value))
except ValueError:
if '@' in value:
if not EMAIL.match(value):
raise Failure(f'{value!r} is not an e-mail address', 2)
kind, name = 'email', x509.RFC822Name(value)
else:
if not DNS_NAME.match(value.lower()):
raise Failure(f'{value!r} is not a DNS name, an IP address or an e-mail address', 2)
kind, name = 'dns', x509.DNSName(value.lower())
if kind not in allowed:
raise Failure(f'{value}: the template allows no {kind} names', 2)
if name not in names:
names.append(name)
return names
def leaf_extensions(template, names):
"""The extensions of a certificate of the template with the subject alternative names."""
usage = x509.KeyUsage(**{option: option in template['key_usage'] for option in vpnrouter.KEY_USAGES},
key_cert_sign=False, crl_sign=False, encipher_only=False, decipher_only=False)
extensions = [(x509.BasicConstraints(ca=False, path_length=None), True), (usage, True)]
if template['extended_key_usage']:
extensions.append((x509.ExtendedKeyUsage([EKU_OIDS[item] for item in template['extended_key_usage']]), False))
if names:
extensions.append((x509.SubjectAlternativeName(names), False))
return extensions
def check_subject(kind, subject):
"""Check a common name against the subject of a template (vpnrouter.TEMPLATE_SUBJECTS)."""
if not isinstance(subject, str) or not subject.strip():
raise Failure('the subject is required', 2)
subject = subject.strip()
if kind == 'dns' and not DNS_NAME.match(subject.lower()):
raise Failure(f'{subject!r} is not a DNS name, which the template needs as the subject', 2)
if kind == 'email' and not EMAIL.match(subject):
raise Failure(f'{subject!r} is not an e-mail address, which the template needs as the subject', 2)
if len(subject) > 64 or not subject.isprintable():
raise Failure('the subject must have 1 to 64 printable characters', 2)
return subject.lower() if kind == 'dns' else subject
def record(state, cert, ca, kind, template=None):
"""Add a certificate to the state: kind is ca, router, user or external."""
state['certificates'].append({
'serial': f'{cert.serial_number:X}', 'ca': ca, 'kind': kind, 'template': template,
'subject': common_name(cert), 'sans': san_values(cert), 'created': iso(now()),
'expires': iso(utc(cert, 'not_valid_after')), 'revoked': None, 'reason': None,
})
def sign_crl(cfg, name, state):
"""Sign a new CRL of the CA with the certificates it revoked that have not expired, valid for pki.root_crl_days for
the root and pki.crl_days for an issuing CA."""
folder = directory(name)
cert, key = read_cert(folder / 'cert.pem'), read_key(folder / 'key.pem')
number = state['crl_numbers'].get(name, 0) + 1
moment = now()
days = cfg['pki']['root_crl_days'] if name == 'root' else cfg['pki']['crl_days']
builder = (x509.CertificateRevocationListBuilder()
.issuer_name(cert.subject)
.last_update(moment)
.next_update(moment + datetime.timedelta(days=days))
.add_extension(x509.CRLNumber(number), False)
.add_extension(authority_key_id(cert), False))
for entry in state['certificates']:
if entry['ca'] == name and entry['revoked'] and entry['expires'] > iso(moment):
revoked = (x509.RevokedCertificateBuilder()
.serial_number(int(entry['serial'], 16))
.revocation_date(parse_iso(entry['revoked'])))
if entry['reason'] and entry['reason'] != 'unspecified':
revoked = revoked.add_extension(x509.CRLReason(x509.ReasonFlags[entry['reason']]), False)
builder = builder.add_revoked_certificate(revoked.build())
crl = builder.sign(key, digest(key))
state['crl_numbers'][name] = number
if name == 'root':
state['root_crl'] = 'signed'
write(folder / 'crl.pem', crl.public_bytes(serialization.Encoding.PEM), 0o644)
def crl_stale(path):
"""Whether the CRL is missing or past half of its validity."""
crl = read_crl(path)
if crl is None:
return True
last, following = utc(crl, 'last_update'), utc(crl, 'next_update')
return following is None or now() >= last + (following - last) / 2
def active():
"""The names of the issuing CAs with a certificate, sorted."""
return sorted(folder.name for folder in CA_DIR.iterdir() if (folder / 'cert.pem').is_file()) \
if CA_DIR.is_dir() else []
def ready(name):
"""Whether the issuing CA has its certificate and key, and so issues certificates."""
return (CA_DIR / name / 'cert.pem').is_file() and (CA_DIR / name / 'key.pem').is_file()
def refresh_crls(cfg, state, force=()):
"""Sign a new CRL for each CA with its key on the router whose CRL is stale, or that force names."""
for name in ['root', *active()]:
folder = directory(name)
if (folder / 'key.pem').is_file() and (folder / 'cert.pem').is_file() and \
(name in force or crl_stale(folder / 'crl.pem')):
sign_crl(cfg, name, state)
def renewal_due(cert):
"""Whether less than a third of the certificate's lifetime is left."""
start, end = utc(cert, 'not_valid_before'), utc(cert, 'not_valid_after')
return now() >= end - (end - start) / 3
def router_identities(name, ctx):
"""The common name and the subject alternative names of one of the router's certificates (ROUTER_CERTS)."""
fqdn = ctx['local_fqdn']
if name == 'vpn-server':
return fqdn, [fqdn]
return fqdn, [value for value in (fqdn, ctx['public_ip'], ctx['int_addr']) if value]
def ensure_router_cert(cfg, ctx, state, name, urls):
"""Issue one of the router's certificates when it is missing, due for renewal, not issued by its CA's current key,
named differently or without one of the names it needs; True when it was issued."""
ca, template_name = ROUTER_CERTS[name]
issuer, issuer_key = read_cert(CA_DIR / ca / 'cert.pem'), read_key(CA_DIR / ca / 'key.pem')
subject, sans = router_identities(name, ctx)
if issuer is None or issuer_key is None or not subject:
return False
cert_path, key_path = ROUTER_DIR / f'{name}_cert.pem', ROUTER_DIR / f'{name}_key.pem'
cert = read_cert(cert_path)
wanted = {f'IP:{value}' if is_address(value) else f'DNS:{value.lower()}' for value in sans}
if cert is not None and key_path.is_file() and not renewal_due(cert) and issued_by(cert, issuer) and \
common_name(cert) == subject and wanted <= set(san_values(cert)):
return False
template = vpnrouter.pki_templates(cfg)[template_name]
key = new_key(template['key'])
cert = sign(subject, key.public_key(), issuer, issuer_key, template['days'],
leaf_extensions(template, general_names(sans, vpnrouter.SAN_TYPES)), urls, ca)
write(key_path, key_pem(key), 0o600)
write(cert_path, cert_pem(cert), 0o644)
record(state, cert, ca, 'router', template_name)
return True
def is_address(value):
try:
ipaddress.ip_address(value)
except ValueError:
return False
return True
def provision(cfg, ctx):
"""Create what the configuration needs and is missing, and keep it current; the notices to report.
The root is created when it has neither certificate nor key. Each issuing CA without a certificate is signed by
the root key, or, while the root key is not on the router, gets its key and a CSR and is pending. An issuing CA
that pki.issuing_cas no longer names is retired: its directory moves to RETIRED_DIR, and its certificate is revoked
on the root CRL. Stale CRLs are signed again, the router's certificates issued or renewed (ensure_router_cert), and
the copies of PUBLISH_DIR written.
"""
for folder in (PKI_DIR, CA_DIR, ROUTER_DIR, USER_DIR):
folder.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(folder, 0o700)
pki = cfg['pki']
state = read_state()
urls = vpnrouter.pki_base_urls(cfg)
notices = []
root_cert, root_key = read_cert(ROOT_DIR / 'cert.pem'), read_key(ROOT_DIR / 'key.pem')
if root_cert is None:
if root_key is not None:
raise Failure(f'{ROOT_DIR / "key.pem"} has no certificate; place root/cert.pem beside it')
root_key = new_key(pki['key'])
root_cert = sign(pki['root_name'], root_key.public_key(), None, root_key, pki['root_days'], ca_extensions(1))
write(ROOT_DIR / 'key.pem', key_pem(root_key), 0o600)
write(ROOT_DIR / 'cert.pem', cert_pem(root_cert), 0o644)
authorities = vpnrouter.issuing_cas(cfg)
force = set()
for name, authority in authorities.items():
folder = CA_DIR / name
if (folder / 'cert.pem').is_file():
continue
key = read_key(folder / 'key.pem')
if key is None:
key = new_key(pki['key'])
write(folder / 'key.pem', key_pem(key), 0o600)
if root_key is not None:
cert = sign(authority['name'].strip(), key.public_key(), root_cert, root_key, pki['ca_days'],
ca_extensions(0), urls, 'root')
write(folder / 'cert.pem', cert_pem(cert), 0o644)
remove(folder / 'csr.pem')
record(state, cert, 'root', 'ca')
force.add(name)
continue
if not (folder / 'csr.pem').is_file():
request = (x509.CertificateSigningRequestBuilder()
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, authority['name'].strip())]))
.add_extension(x509.BasicConstraints(ca=True, path_length=0), True)
.add_extension(CA_USAGE, True)
.sign(key, digest(key)))
write(folder / 'csr.pem', request.public_bytes(serialization.Encoding.PEM), 0o644)
notices.append(f'issuing CA {name} waits for its certificate: sign {folder / "csr.pem"} with the root CA and '
f'import it with vpn-router-ca ca import {name}')
for folder in sorted(CA_DIR.iterdir()):
if not folder.is_dir() or folder.name in authorities:
continue
cert = read_cert(folder / 'cert.pem')
RETIRED_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
shutil.move(str(folder), str(RETIRED_DIR / f'{folder.name}-{now().strftime("%Y%m%d%H%M%S")}'))
state['retired'].append({'name': folder.name, 'retired': iso(now()),
'serial': f'{cert.serial_number:X}' if cert is not None else None})
for entry in state['certificates']:
if cert is not None and entry['ca'] == 'root' and entry['serial'] == f'{cert.serial_number:X}' and \
not entry['revoked']:
entry['revoked'], entry['reason'] = iso(now()), 'cessation_of_operation'
force.add('root')
if root_key is None and cert is not None:
notices.append(f'issuing CA {folder.name} was retired: revoke its certificate on the root CRL offline and '
'upload the CRL with vpn-router-ca root import-crl')
refresh_crls(cfg, state, force)
for name in ROUTER_CERTS:
ensure_router_cert(cfg, ctx, state, name, urls)
write_state(state)
publish()
return notices
def publish():
"""Write the DER copies of the certificates and CRLs of the root and the issuing CAs with a certificate to
PUBLISH_DIR, and remove any other file there; True when anything changed."""
PUBLISH_DIR.mkdir(parents=True, exist_ok=True)
os.chmod(PUBLISH_DIR, 0o755)
wanted = {}
for name in ['root', *active()]:
cert, crl = read_cert(directory(name) / 'cert.pem'), read_crl(directory(name) / 'crl.pem')
if cert is not None:
wanted[f'{name}.crt'] = cert.public_bytes(serialization.Encoding.DER)
if crl is not None:
wanted[f'{name}.crl'] = crl.public_bytes(serialization.Encoding.DER)
changed = False
for path in PUBLISH_DIR.iterdir():
if path.name not in wanted:
path.unlink()
changed = True
for name, data in wanted.items():
changed |= write(PUBLISH_DIR / name, data, 0o644)
return changed
def remote_ca_file(connection):
"""The name in X509CA_DIR of the remote CA certificate of a site_to_site connection."""
return f's2s-{connection}.pem'
def install_crls():
"""Copy the CRLs of the root, p2s and s2s to strongSwan, and those of the root and p2s to OpenVPN; True on change."""
changed = False
for name, file in SWANCTL_CA_FILES.items():
source = directory(name) / 'crl.pem'
changed |= vpnrouter.install_file(source, X509CRL_DIR / file, 0o644) if source.is_file() \
else remove(X509CRL_DIR / file)
crls = [directory(name) / 'crl.pem' for name in ('root', 'p2s')]
if all(path.is_file() for path in crls):
changed |= write(OPENVPN_CRL, b''.join(path.read_bytes() for path in crls), 0o644)
return changed
def reload_crls():
"""Install the CRLs, and have strongSwan load them when they changed; OpenVPN reads its file when it changes."""
if install_crls():
subprocess.run(['swanctl', '--load-creds'], capture_output=True, text=True)
def distribute(cfg):
"""Copy the certificates, keys and CRLs strongSwan, OpenVPN and the system's trust store read; True on change.
strongSwan gets the root, p2s and s2s, the remote CA of each site_to_site connection that has one, the router's
certificates and keys, and the CRLs; OpenVPN the root and p2s in one file, and their CRLs (install_crls).
"""
changed = False
for name, file in SWANCTL_CA_FILES.items():
source = directory(name) / 'cert.pem'
changed |= vpnrouter.install_file(source, X509CA_DIR / file, 0o644) if source.is_file() \
else remove(X509CA_DIR / file)
remote = {remote_ca_file(name): connection['remote_ca'].strip().encode() + b'\n'
for name, connection in cfg['site_to_site'].items() if connection['remote_ca'].strip()}
for path in X509CA_DIR.glob(remote_ca_file('*')):
if path.name not in remote:
changed |= remove(path)
for file, data in remote.items():
changed |= write(X509CA_DIR / file, data, 0o644)
for name, cert, key in (('vpn-server', VPN_SERVER_CERT, VPN_SERVER_KEY), ('site-to-site', S2S_CERT, S2S_KEY)):
if (ROUTER_DIR / f'{name}_cert.pem').is_file():
changed |= vpnrouter.install_file(ROUTER_DIR / f'{name}_cert.pem', cert, 0o644)
changed |= vpnrouter.install_file(ROUTER_DIR / f'{name}_key.pem', key, 0o600)
else:
changed |= remove(cert) | remove(key)
chain = [directory(name) / 'cert.pem' for name in ('root', 'p2s')]
if all(path.is_file() for path in chain):
changed |= write(OPENVPN_CA, b''.join(path.read_bytes() for path in chain), 0o644)
changed |= install_crls()
if vpnrouter.install_file(ROOT_DIR / '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 chain_of(ca):
"""The PEM certificates of the issuing CA and the root."""
return (cert_pem(read_cert(CA_DIR / ca / 'cert.pem')) + cert_pem(read_cert(ROOT_DIR / 'cert.pem'))).decode()
def issuer_of(cfg, ca, template):
"""The certificate and key of the issuing CA, and the template, checked to be one the CA issues."""
authorities = vpnrouter.issuing_cas(cfg)
if ca not in authorities:
raise Failure(f'no issuing CA {ca}', 3)
if template not in authorities[ca]['templates']:
raise Failure(f'issuing CA {ca} does not issue {template} certificates', 2)
if not ready(ca):
raise Failure(f'issuing CA {ca} has no certificate yet', 4)
return read_cert(CA_DIR / ca / 'cert.pem'), read_key(CA_DIR / ca / 'key.pem'), \
vpnrouter.pki_templates(cfg)[template]
def issue(cfg, ca, template, csr=None, subject=None, sans=None, password=None):
"""Issue a certificate of the template from the issuing CA for something other than the router.
With a CSR, the certificate is for the CSR's key, common name and subject alternative names, and the answer is
{serial, certificate, chain} in PEM. Without one, the router makes a key of the template for the subject and sans,
and the answer is {serial, pfx}: a PKCS#12 file in base64 with the key, the certificate and the chain, protected
by the password; the key is not kept. The templates of vpnrouter.ROUTER_TEMPLATES are not issued here.
"""
if template in vpnrouter.ROUTER_TEMPLATES:
raise Failure(f'{template} certificates are issued by the router only', 2)
issuer, issuer_key, values = issuer_of(cfg, ca, template)
key = None
if csr is not None:
try:
request = x509.load_pem_x509_csr(csr.strip().encode() if isinstance(csr, str) else b'')
except ValueError:
raise Failure('the CSR is not a PEM certificate signing request', 2)
if not request.is_signature_valid:
raise Failure('the signature of the CSR is not valid', 2)
subject = common_name(request)
sans = [value.split(':', 1)[1] for value in san_values(request)]
public_key = request.public_key()
else:
if not isinstance(password, str) or not 1 <= len(password) <= PASSWORD_MAX:
raise Failure(f'the password of the PFX file must have 1 to {PASSWORD_MAX} characters', 2)
if sans is None:
sans = []
if not isinstance(sans, list):
raise Failure('sans must be a list of names', 2)
key = new_key(values['key'])
public_key = key.public_key()
subject = check_subject(values['subject'], subject)
names = general_names(([subject] if values['subject'] == 'dns' else []) + list(sans),
set(values['sans']) | ({'dns'} if values['subject'] == 'dns' else set()))
cert = sign(subject, public_key, issuer, issuer_key, values['days'], leaf_extensions(values, names),
vpnrouter.pki_base_urls(cfg), ca)
state = read_state()
record(state, cert, ca, 'external', template)
write_state(state)
serial = f'{cert.serial_number:X}'
if key is None:
return {'serial': serial, 'certificate': cert_pem(cert).decode(), 'chain': chain_of(ca)}
encryption = (serialization.PrivateFormat.PKCS12.encryption_builder()
.kdf_rounds(50000)
.key_cert_algorithm(pkcs12.PBES.PBESv1SHA1And3KeyTripleDESCBC)
.hmac_hash(hashes.SHA1())
.build(password.encode()))
pfx = pkcs12.serialize_key_and_certificates(subject.encode(), key, cert,
[issuer, read_cert(ROOT_DIR / 'cert.pem')], encryption)
return {'serial': serial, 'pfx': base64.b64encode(pfx).decode()}
def user_files(name):
"""The certificate and the key of a user."""
return USER_DIR / f'{name}_cert.pem', USER_DIR / f'{name}_key.pem'
def issue_user(cfg, name, email):
"""Issue the user's certificate from the P2S CA with the template vpn-user: the user name as the common name and a
DNS name, and the e-mail address; the certificate and the key are kept in USER_DIR."""
issuer, issuer_key, values = issuer_of(cfg, 'p2s', 'vpn-user')
key = new_key(values['key'])
cert = sign(name, key.public_key(), issuer, issuer_key, values['days'],
leaf_extensions(values, general_names([name, email], vpnrouter.SAN_TYPES)),
vpnrouter.pki_base_urls(cfg), 'p2s')
cert_path, key_path = user_files(name)
write(key_path, key_pem(key), 0o600)
write(cert_path, cert_pem(cert), 0o644)
state = read_state()
record(state, cert, 'p2s', 'user', 'vpn-user')
write_state(state)
def revoke_entries(cfg, state, entries, reason):
"""Mark the certificates revoked, sign new CRLs of their CAs, and publish and install them."""
for entry in entries:
entry['revoked'], entry['reason'] = iso(now()), reason
names = {entry['ca'] for entry in entries}
for name in names:
if not (directory(name) / 'key.pem').is_file():
raise Failure(f'the key of {name} is not on the router, so it cannot sign its CRL', 4)
for name in names:
sign_crl(cfg, name, state)
write_state(state)
publish()
reload_crls()
def revoke_user(cfg, name):
"""Revoke the user's certificates and remove the certificate and the key."""
state = read_state()
entries = [entry for entry in state['certificates']
if entry['kind'] == 'user' and entry['subject'] == name and not entry['revoked']]
if entries:
revoke_entries(cfg, state, entries, 'cessation_of_operation')
for path in user_files(name):
path.unlink(missing_ok=True)
def revoke(cfg, serial, reason=None):
"""Revoke a certificate the router issued, by its serial number, for one of REASONS.
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 removed, so that vpn-router-setup issues a new one; the answer says so with
reissue.
"""
reason = reason or 'unspecified'
if reason not in REASONS:
raise Failure(f'the reason must be one of {", ".join(REASONS)}', 2)
if not isinstance(serial, str) or not SERIAL.match(serial):
raise Failure('the serial number must be hexadecimal', 2)
state = read_state()
entry = next((entry for entry in state['certificates'] if entry['serial'] == serial.upper().lstrip('0')), None)
if entry is None:
raise Failure(f'no certificate with the serial number {serial}', 3)
if entry['revoked']:
raise Failure(f'certificate {serial} is revoked', 4)
if entry['kind'] == 'user':
raise Failure(f'certificate {serial} is the certificate of user {entry["subject"]}; remove the user', 2)
if entry['kind'] == 'ca':
raise Failure(f'certificate {serial} is an issuing CA; remove it from pki.issuing_cas', 2)
revoke_entries(cfg, state, [entry], reason)
reissue = False
if entry['kind'] == 'router':
for name, (ca, _) in ROUTER_CERTS.items():
cert = read_cert(ROUTER_DIR / f'{name}_cert.pem')
if cert is not None and f'{cert.serial_number:X}' == entry['serial']:
remove(ROUTER_DIR / f'{name}_cert.pem')
reissue = True
return {**entry, 'reissue': reissue}
def certificates():
"""The certificates the router issued, newest first, each with state: valid, expired or revoked."""
moment = iso(now())
return [{**entry, 'state': 'revoked' if entry['revoked'] else 'expired' if entry['expires'] <= moment else 'valid'}
for entry in reversed(read_state()['certificates'])]
def csr(cfg, name):
"""The CSR of a pending issuing CA and the openssl extension section to sign it with: {name, csr, extensions}."""
if name not in vpnrouter.issuing_cas(cfg):
raise Failure(f'no issuing CA {name}', 3)
path = CA_DIR / name / 'csr.pem'
if not path.is_file():
raise Failure(f'issuing CA {name} is not waiting for a certificate', 4)
urls = vpnrouter.pki_base_urls(cfg)
lines = ['[ issuing_ca ]', 'basicConstraints = critical, CA:TRUE, pathlen:0', 'keyUsage = critical, keyCertSign, cRLSign',
'subjectKeyIdentifier = hash', 'authorityKeyIdentifier = keyid:always']
if urls:
lines.append('authorityInfoAccess = ' + ', '.join(f'caIssuers;URI:{url}root.crt' for url in urls))
lines.append('crlDistributionPoints = ' + ', '.join(f'URI:{url}root.crl' for url in urls))
return {'name': name, 'csr': path.read_text(encoding='utf-8'), 'extensions': '\n'.join(lines) + '\n'}
def import_ca(cfg, name, text):
"""Take the certificate of a pending issuing CA, signed by the root offline: it must be a CA certificate the root
signed for the key of the CA's CSR."""
if name not in vpnrouter.issuing_cas(cfg):
raise Failure(f'no issuing CA {name}', 3)
folder = CA_DIR / name
if (folder / 'cert.pem').is_file():
raise Failure(f'issuing CA {name} has its certificate', 4)
key = read_key(folder / 'key.pem')
if key is None:
raise Failure(f'issuing CA {name} has no key; vpn-router-setup makes it with the CSR', 3)
try:
cert = x509.load_pem_x509_certificate(text.strip().encode() if isinstance(text, str) else b'')
except ValueError:
raise Failure('the certificate is not a PEM certificate', 2)
if not issued_by(cert, read_cert(ROOT_DIR / 'cert.pem')):
raise Failure('the certificate is not signed by the root CA', 2)
if not same_key(cert.public_key(), key.public_key()):
raise Failure(f'the certificate is not for the key of the CSR of {name}', 2)
if not is_ca(cert):
raise Failure('the certificate is not a CA certificate', 2)
write(folder / 'cert.pem', cert_pem(cert), 0o644)
remove(folder / 'csr.pem')
state = read_state()
record(state, cert, 'root', 'ca')
sign_crl(cfg, name, state)
write_state(state)
publish()
return {'name': name, 'subject': common_name(cert), 'expires': iso(utc(cert, 'not_valid_after'))}
def import_root_crl(data):
"""Take a CRL of the root signed offline, in PEM or DER; it must be signed by the root and not older than the
current one."""
if isinstance(data, str):
data = data.strip().encode()
try:
crl = x509.load_pem_x509_crl(data) if data.startswith(b'-----') else x509.load_der_x509_crl(data)
except ValueError:
raise Failure('the CRL is not a PEM or DER certificate revocation list', 2)
root = read_cert(ROOT_DIR / 'cert.pem')
if root is None:
raise Failure('there is no root CA', 3)
if crl.issuer != root.subject or not crl.is_signature_valid(root.public_key()):
raise Failure('the CRL is not signed by the root CA', 2)
current = read_crl(ROOT_DIR / 'crl.pem')
if current is not None and utc(crl, 'last_update') < utc(current, 'last_update'):
raise Failure('the CRL is older than the current one', 2)
write(ROOT_DIR / 'crl.pem', crl.public_bytes(serialization.Encoding.PEM), 0o644)
state = read_state()
state['root_crl'] = 'uploaded'
write_state(state)
publish()
reload_crls()
following = utc(crl, 'next_update')
return {'next_update': iso(following) if following else None}
def import_root_cert(data):
"""Place an existing root CA's certificate, PEM, before the root exists: without its key the root is offline."""
if read_cert(ROOT_DIR / 'cert.pem') is not None or (ROOT_DIR / 'key.pem').exists():
raise Failure('the root CA exists', 4)
try:
cert = x509.load_pem_x509_certificate(data.strip() if isinstance(data, bytes) else data.strip().encode())
except ValueError:
raise Failure('the certificate is not a PEM certificate', 2)
if not is_ca(cert):
raise Failure('the certificate is not a CA certificate', 2)
write(ROOT_DIR / 'cert.pem', cert_pem(cert), 0o644)
return {'subject': common_name(cert), 'expires': iso(utc(cert, 'not_valid_after'))}
def export_root_key(passphrase):
"""The root key in PEM, encrypted with the passphrase."""
key = read_key(ROOT_DIR / 'key.pem')
if key is None:
raise Failure('the root key is not on the router', 3)
if not passphrase:
raise Failure('the passphrase is required', 2)
return key_pem(key, passphrase.encode())
def remove_root_key():
if not remove(ROOT_DIR / 'key.pem'):
raise Failure('the root key is not on the router', 3)
def import_root_key(data, passphrase=None):
"""Put the root key back, PEM, encrypted with the passphrase or not; it must be the key of the root certificate."""
root = read_cert(ROOT_DIR / 'cert.pem')
if root is None:
raise Failure('there is no root CA', 3)
try:
key = serialization.load_pem_private_key(data, passphrase.encode() if passphrase else None)
except (ValueError, TypeError):
raise Failure('the file is not a PEM private key, or the passphrase is wrong', 2)
if not same_key(key.public_key(), root.public_key()):
raise Failure('the key is not the key of the root CA', 2)
write(ROOT_DIR / 'key.pem', key_pem(key), 0o600)
def maintain(cfg):
"""Sign the stale CRLs again, publish and install them; True when one of the router's certificates is due for
renewal, which vpn-router-setup does."""
state = read_state()
refresh_crls(cfg, state)
write_state(state)
publish()
reload_crls()
for name in ROUTER_CERTS:
cert = read_cert(ROUTER_DIR / f'{name}_cert.pem')
if cert is not None and renewal_due(cert):
return True
return False
def acme_url(cfg, name):
"""The ACME directory of an issuing CA served by Caddy's ACME server."""
return f'https://{cfg["wan"]["local_fqdn"].strip()}:{cfg["https"]["web_port"]}/acme/{name}/directory'
def status(cfg):
"""The root, the issuing CAs, the retired CAs and the warnings, as /ca and vpn-router-ca status show them."""
moment = now()
soon = moment + datetime.timedelta(days=WARN_DAYS)
state = read_state()
warnings = []
def crl_of(name):
crl = read_crl(directory(name) / 'crl.pem')
following = utc(crl, 'next_update') if crl is not None else None
if crl is None:
warnings.append(f'{name} has no CRL')
elif following is not None and following <= soon:
warnings.append(f'the CRL of {name} {"expired" if following <= moment else "expires"} on {iso(following)}')
return iso(following) if following else None
root_cert = read_cert(ROOT_DIR / 'cert.pem')
root = None
if root_cert is not None:
key = (ROOT_DIR / 'key.pem').is_file()
root = {'subject': common_name(root_cert), 'expires': iso(utc(root_cert, 'not_valid_after')), 'key': key,
'crl_next_update': crl_of('root') if key or (ROOT_DIR / 'crl.pem').is_file() else None,
'crl_source': state['root_crl']}
if key:
warnings.append('the root key is on the router; export it with vpn-router-ca root export-key and remove it '
'with vpn-router-ca root remove-key to keep the root offline')
elif not (ROOT_DIR / 'crl.pem').is_file():
warnings.append('the root has no CRL; sign one offline and upload it with vpn-router-ca root import-crl')
if utc(root_cert, 'not_valid_after') <= soon:
warnings.append(f'the root certificate expires on {root["expires"]}')
authorities = []
for name, authority in vpnrouter.issuing_cas(cfg).items():
folder = CA_DIR / name
cert = read_cert(folder / 'cert.pem')
kind = 'active' if cert is not None else 'pending' if (folder / 'csr.pem').is_file() else 'missing'
entry = {'name': name, 'common_name': authority['name'], 'state': kind, 'builtin': authority['builtin'],
'templates': authority['templates'], 'acme': authority['acme'],
'subject': common_name(cert) if cert is not None else None,
'expires': iso(utc(cert, 'not_valid_after')) if cert is not None else None,
'key': (folder / 'key.pem').is_file(), 'crl_next_update': crl_of(name) if cert is not None else None,
'acme_url': acme_url(cfg, name) if authority['acme'] and cert is not None else None}
if kind == 'pending':
warnings.append(f'issuing CA {name} waits for its certificate signed by the root CA')
if cert is not None and utc(cert, 'not_valid_after') <= soon:
warnings.append(f'the certificate of issuing CA {name} expires on {entry["expires"]}')
authorities.append(entry)
root_crl = read_crl(ROOT_DIR / 'crl.pem')
for retired in state['retired']:
if retired['serial'] and root_crl is not None and \
root_crl.get_revoked_certificate_by_serial_number(int(retired['serial'], 16)) is None:
warnings.append(f'the root CRL does not revoke retired issuing CA {retired["name"]}')
for name in ROUTER_CERTS:
cert = read_cert(ROUTER_DIR / f'{name}_cert.pem')
if cert is not None and utc(cert, 'not_valid_after') <= soon:
warnings.append(f'the router certificate {name} expires on {iso(utc(cert, "not_valid_after"))}')
return {'root': root, 'authorities': authorities, 'retired': state['retired'], 'warnings': warnings}
@@ -2,8 +2,8 @@
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. 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 +31,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 +41,7 @@ import base64
import calendar
import contextlib
import fcntl
import pathlib
import re
import subprocess
import sys
import time
import ldap
@@ -54,6 +51,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]$')
@@ -91,7 +89,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):
@@ -176,7 +174,7 @@ def dn_of(name):
def certificate(name):
return vpnrouter.USER_CERT_DIR / f'{name}_cert.pem', vpnrouter.USER_CERT_DIR / f'{name}_key.pem'
return vpnrouter_pki.user_files(name)
def entry(connection, name):
@@ -188,18 +186,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:
@@ -283,8 +269,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 +286,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,19 +454,16 @@ 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']:
@@ -501,7 +485,7 @@ def profile(connection, name):
'auth-user-pass',
'auth-nocache',
'verb 3',
'<ca>', extract_pem(vpnrouter.CA_CERT_DEST.read_text(encoding='utf-8')), '</ca>',
'<ca>', extract_pem(vpnrouter_pki.OPENVPN_CA.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>',
'',
+105 -10
View File
@@ -9,7 +9,7 @@ Pages:
(Tailwind CSS from a CDN) for a browser
/status the status as JSON
/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
@@ -26,6 +26,12 @@ Pages:
applied by vpn-router-setup (see site_to_site())
/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
@@ -34,7 +40,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, /backup and /account need an HS256 JWT in an Authorization: Bearer header signed
/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
@@ -90,6 +96,7 @@ 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
@@ -120,15 +127,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()
@@ -348,8 +361,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()),
@@ -363,6 +378,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,
}
@@ -622,7 +640,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
@@ -652,8 +671,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}'}
@@ -757,6 +776,75 @@ def backup(method, path, body):
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", "status", "certificates"}: the pki section,
the templates with the built-in ones, 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/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 route == ('GET',):
return 200, {'settings': cfg['pki'], 'templates': vpnrouter.pki_templates(cfg),
'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 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.
@@ -945,7 +1033,8 @@ 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', 'backup')
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
signing = path in ('/auth', '/auth/refresh', '/auth/logout')
@@ -989,6 +1078,9 @@ class Handler(BaseHTTPRequestHandler):
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'
@@ -997,6 +1089,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()
+214
View File
@@ -0,0 +1,214 @@
#!/usr/bin/python3
"""Manage the router's PKI 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, root import-cert places an existing root's
certificate before the root exists, 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 import-cert 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 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 == 'import-cert':
document = vpnrouter_pki.import_root_cert(pathlib.Path(options.file).read_bytes())
restart_setup()
return document, f'Root CA {document["subject"]} placed, without its key.'
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)
root_actions.add_parser('import-cert', help="place an existing root's certificate").add_argument('file')
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 SystemExit as exc:
code, message = 1, str(exc.code)
else:
if options.json:
print(json.dumps(document, indent=2))
elif text is None:
show_status(document)
elif text:
print(text.rstrip('\n'))
return 0
if options.json:
print(json.dumps({'error': message}, indent=2))
else:
print(f'vpn-router-ca: {message}', file=sys.stderr)
return code
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/python3
"""Install Caddy, the router's HTTPS proxy, from the source https.caddy_source names.
status shows the configured source, the source in effect and the installed and candidate versions of Caddy. apply
installs Caddy when it is missing: from the Ubuntu archive, or with upstream from Caddy's own repository, which it
configures first (vpnrouter.CADDY_APT_SOURCE, with the key in vpnrouter.CADDY_KEYRING). When upstream is configured
and the repository is not, it configures the repository and replaces the installed Caddy with upstream's. It then
restarts vpn-router-setup without waiting, which serves with the new Caddy; vpn-router-caddy.service runs it for
vpn-router-setup. use-upstream sets https.caddy_source to upstream and applies it, printing each step.
Once the repository is configured Caddy stays upstream's: apt keeps the higher upstream version, and ubuntu has no
effect. A failure is a message on standard error and the exit status 1. -h or --help shows the commands.
vpn-router-caddy [--json] status
vpn-router-caddy apply
vpn-router-caddy use-upstream
"""
import argparse
import json
import os
import subprocess
import sys
import urllib.request
sys.path.insert(0, '/usr/lib/vpn-router')
import vpnrouter
#: apt-get, waiting up to ten minutes for dpkg's lock, which a package installation that runs setup holds.
APT = ['apt-get', '-o', 'DPkg::Lock::Timeout=600']
#: The options of apt-get install: no questions, and the configuration files of the installed Caddy kept.
INSTALL = ['-y', '-o', 'Dpkg::Options::=--force-confdef', '-o', 'Dpkg::Options::=--force-confold', 'install']
#: Seconds the key of Caddy's repository may take to download.
KEY_TIMEOUT = 60
class Parser(argparse.ArgumentParser):
"""An argument parser whose errors end with status 2."""
def error(self, message):
print(f'{self.prog}: {message}; see {self.prog} --help', file=sys.stderr)
sys.exit(2)
def step(message):
print(message, flush=True)
def installed():
"""The installed version of caddy, or None when it is not installed."""
result = subprocess.run(['dpkg-query', '-W', '-f=${Status}\t${Version}', 'caddy'], capture_output=True, text=True)
status, _, version = result.stdout.partition('\t')
return version if result.returncode == 0 and status == 'install ok installed' else None
def candidate():
"""The version of caddy apt would install, or None when it knows none."""
result = subprocess.run(['apt-cache', 'policy', 'caddy'], capture_output=True, text=True)
for line in result.stdout.splitlines():
if line.strip().startswith('Candidate:'):
value = line.split(':', 1)[1].strip()
return None if value == '(none)' else value
return None
def effective():
"""The source Caddy comes from: upstream once its repository is configured, ubuntu otherwise."""
return 'upstream' if vpnrouter.CADDY_APT_SOURCE.is_file() else 'ubuntu'
def command(args):
"""Run a command with its output shown; SystemExit when it fails."""
step(f'$ {" ".join(args)}')
result = subprocess.run(args, env=dict(os.environ, DEBIAN_FRONTEND='noninteractive'))
if result.returncode != 0:
sys.exit(f'vpn-router-caddy: {args[0]} failed with status {result.returncode}')
def apply():
"""Install Caddy, or replace it with upstream's, as https.caddy_source asks (see the module)."""
source = vpnrouter.load_config()['https']['caddy_source']
repository = source == 'upstream' and not vpnrouter.CADDY_APT_SOURCE.is_file()
if installed() and not repository:
if source == 'ubuntu' and effective() == 'upstream':
step("https.caddy_source is ubuntu, but Caddy's repository is configured, so Caddy stays upstream's.")
else:
step(f'Caddy {installed()} is installed from the {source} source.')
return
if repository:
step(f"Downloading the key of Caddy's repository from {vpnrouter.CADDY_KEY_URL}")
with urllib.request.urlopen(vpnrouter.CADDY_KEY_URL, timeout=KEY_TIMEOUT) as response:
key = response.read()
if not key.lstrip().startswith(b'-----BEGIN PGP PUBLIC KEY BLOCK-----'):
sys.exit(f'vpn-router-caddy: {vpnrouter.CADDY_KEY_URL} did not answer with an OpenPGP public key')
vpnrouter.CADDY_KEYRING.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
vpnrouter.CADDY_KEYRING.write_bytes(key)
os.chmod(vpnrouter.CADDY_KEYRING, 0o644)
vpnrouter.CADDY_APT_SOURCE.write_text(
f'Types: deb\nURIs: {vpnrouter.CADDY_REPOSITORY}\nSuites: any-version\nComponents: main\n'
f'Signed-By: {vpnrouter.CADDY_KEYRING}\n', encoding='utf-8')
os.chmod(vpnrouter.CADDY_APT_SOURCE, 0o644)
step(f"Caddy's repository is configured in {vpnrouter.CADDY_APT_SOURCE}.")
command([*APT, 'update'])
command([*APT, *INSTALL, 'caddy'])
step(f'Caddy {installed()} is installed from the {effective()} source.')
subprocess.run(['systemctl', 'restart', '--no-block', 'vpn-router-setup.service'], capture_output=True, text=True)
step('vpn-router-setup restarted.')
def main(argv):
parser = Parser(prog='vpn-router-caddy', description='Install Caddy from the source https.caddy_source names.')
parser.add_argument('--json', action='store_true', help='answer status in JSON')
commands = parser.add_subparsers(dest='command', required=True, parser_class=Parser)
commands.add_parser('status', help='show the sources and the versions of Caddy')
commands.add_parser('apply', help='install Caddy, or replace it with upstream\'s, as https.caddy_source asks')
commands.add_parser('use-upstream', help="switch to Caddy's own repository and replace Caddy with upstream's")
options = parser.parse_args(argv)
try:
if options.command == 'status':
document = {'configured': vpnrouter.load_config()['https']['caddy_source'], 'source': effective(),
'installed': installed(), 'candidate': candidate()}
if options.json:
print(json.dumps(document, indent=2))
else:
print(f'Configured source: {document["configured"]}\nSource in effect: {document["source"]}\n'
f'Installed: {document["installed"] or "no"}\nCandidate: {document["candidate"] or "none"}')
return 0
if options.command == 'use-upstream':
with vpnrouter.config_lock():
data = vpnrouter.read_settings()
current = data.get('https')
data['https'] = {**(current if isinstance(current, dict) else {}), 'caddy_source': 'upstream'}
vpnrouter.validate(vpnrouter.with_defaults(data))
vpnrouter.save_config(data)
step('https.caddy_source is upstream.')
apply()
except vpnrouter.ConfigError as exc:
print(f'vpn-router-caddy: {exc}', file=sys.stderr)
return 1
except OSError as exc:
print(f'vpn-router-caddy: {exc}', file=sys.stderr)
return 1
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))
@@ -22,14 +22,23 @@
"cidrs": ["192.168.0.0/24"],
"local_cidrs": ["10.0.0.0/24"],
"local_id_mode": "fqdn",
"auth": "psk",
"psk": "change-me",
"psk_file": ""
"psk_file": "",
"remote_ca": ""
},
"branch": {
"addrs": ["branch.example.org"],
"id": "branch.example.org",
"cidrs": ["192.168.10.0/24"],
"local_cidrs": ["10.0.0.0/24"],
"local_id_mode": "fqdn",
"auth": "certificate"
}
},
"p2s": {
"enabled": false,
"address_pool": "172.16.0.0/24",
"ca_name": "VPN Router CA"
"address_pool": "172.16.0.0/24"
},
"wireguard": {
"enabled": false,
@@ -58,7 +67,31 @@
"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
}
}
},
"metrics": {
"enabled": true,
@@ -4,10 +4,69 @@
{%- if https_email %}
email {{ https_email }}
{%- endif %}
{%- if acme_cas %}
skip_install_trust
pki {
{%- for ca in acme_cas %}
ca {{ ca.id }} {
name "{{ ca.name }}"
root {
cert {{ ca.cert }}
key {{ ca.key }}
}
intermediate {
cert {{ ca.cert }}
key {{ ca.key }}
}
}
{%- endfor %}
}
{%- endif %}
}
{% if web_enabled %}
{% if web_enabled or 'https' in pki_publish or acme_cas %}
{{ local_fqdn }}:{{ https_web_port }} {
reverse_proxy {{ loopback }}:{{ web_port }}
{%- if 'https' in pki_publish %}
handle_path /pki/* {
root * {{ pki_publish_dir }}
file_server
}
{%- endif %}
{%- for ca in acme_cas %}
handle /acme/{{ ca.id }}/* {
acme_server {
ca {{ ca.id }}
}
}
{%- endfor %}
handle {
{%- if web_enabled %}
reverse_proxy {{ loopback }}:{{ web_port }}
{%- else %}
respond 404
{%- endif %}
}
}
{% endif %}
{%- if 'http-80' in pki_publish %}
http://{{ local_fqdn }} {
handle_path /pki/* {
root * {{ pki_publish_dir }}
file_server
}
handle {
respond 404
}
}
{% endif %}
{%- if 'https-default' in pki_publish %}
{{ local_fqdn }}:443 {
handle_path /pki/* {
root * {{ pki_publish_dir }}
file_server
}
handle {
respond 404
}
}
{% endif %}
{%- if manager_port %}
@@ -5,15 +5,16 @@ dev-type tun
topology subnet
server {{ openvpn_network }} {{ openvpn_netmask }}
ca {{ ca_cert }}
ca {{ openvpn_ca }}
cert {{ server_cert }}
key {{ server_key }}
crl-verify {{ crl }}
crl-verify {{ openvpn_crl }}
dh none
remote-cert-tls client
plugin /usr/lib/openvpn/openvpn-auth-ldap.so {{ openvpn_ldap_conf }}
script-security 2
tls-verify /usr/lib/vpn-router/openvpn-issuer
auth-user-pass-verify /usr/lib/vpn-router/openvpn-same-user via-env
{% for network, netmask in openvpn_routes -%}
@@ -8,13 +8,13 @@ connections {
local {
auth = pubkey
certs = server.pem
certs = vpn-server.pem
id = @{{ local_fqdn }}
}
remote {
auth = eap-tls
cacerts = ca.pem
cacerts = p2s.pem
eap_id = %any
}
@@ -11,12 +11,22 @@ connections {
local {
id = {{ connection.local_id }}
{%- if connection.auth == 'certificate' %}
auth = pubkey
certs = site-to-site.pem
{%- else %}
auth = psk
{%- endif %}
}
remote {
id = {{ connection.remote_id }}
{%- if connection.auth == 'certificate' %}
auth = pubkey
cacerts = {{ connection.remote_cacert }}
{%- else %}
auth = psk
{%- endif %}
}
children {
@@ -40,7 +50,7 @@ connections {
}
secrets {
{%- for connection in connections if connection.ready %}
{%- for connection in connections if connection.ready and connection.auth == 'psk' %}
ike-psk-{{ connection.name }} {
id-1 = {{ connection.local_id }}
id-2 = {{ connection.remote_id }}
+4 -6
View File
@@ -3,7 +3,7 @@ data "http" "repo_gpg_key" {
}
locals {
supply_pki = var.ca_cert_file != ""
supply_pki = var.root_cert_file != ""
cloud_init_vars = {
hostname = var.name
@@ -27,7 +27,7 @@ locals {
psk = var.psk
}
}
p2s = { enabled = var.p2s_enabled, address_pool = var.p2s_address_pool, ca_name = "VPN Router CA" }
p2s = { enabled = var.p2s_enabled, address_pool = var.p2s_address_pool }
wireguard = { enabled = var.wireguard_enabled, address = var.wireguard_address, listen_port = var.wireguard_listen_port }
}
}
@@ -102,10 +102,8 @@ resource "azurerm_linux_virtual_machine" "router" {
custom_data = base64encode(
local.supply_pki
? templatefile("${path.module}/../cloud-init-with-pki.yaml.tpl", merge(local.cloud_init_vars, {
label = split(".", var.local_fqdn)[0]
ca_cert = file(var.ca_cert_file)
server_cert = file(var.server_cert_file)
server_key = file(var.server_key_file)
root_cert = file(var.root_cert_file)
root_key = var.root_key_file != "" ? file(var.root_key_file) : ""
}))
: templatefile("${path.module}/../cloud-init.yaml.tpl", local.cloud_init_vars)
)
+4 -10
View File
@@ -100,20 +100,14 @@ variable "p2s_address_pool" {
default = ""
}
variable "ca_cert_file" {
description = "Path to an existing CA certificate PEM to supply instead of letting the package generate one. Leave empty to auto-generate."
variable "root_cert_file" {
description = "Path to an existing root CA certificate PEM to supply instead of letting the package create one. Leave empty to create one."
type = string
default = ""
}
variable "server_cert_file" {
description = "Path to an existing server certificate PEM, paired with ca_cert_file."
type = string
default = ""
}
variable "server_key_file" {
description = "Path to an existing server private key PEM, paired with ca_cert_file."
variable "root_key_file" {
description = "Path to the private key PEM of root_cert_file. Leave empty to keep the root offline: the issuing CAs then wait for certificates signed elsewhere."
type = string
default = ""
sensitive = true
+11 -13
View File
@@ -1,12 +1,13 @@
#cloud-config
#
# Example: install and configure vpn-router during first boot, supplying an
# existing CA and server certificate instead of letting the package generate
# one. See cloud-init.yaml.tpl for the variant that generates its own CA.
# existing root CA instead of letting the package create one. See
# cloud-init.yaml.tpl for the variant that creates its own.
#
# Template variables are Terraform templatefile() placeholders. Adapt or drop
# them for whatever renders this file. <label> is the first component of the
# FQDN, for example "router" in router.example.com.
# them for whatever renders this file. With root_key empty the root stays
# offline: the router's issuing CAs wait for their certificates, signed with
# the root elsewhere and imported with vpn-router-ca ca import.
hostname: ${hostname}
fqdn: ${fqdn}
@@ -28,21 +29,18 @@ write_files:
content: |
${jsonencode(config)}
- path: /etc/vpn-router/pki/ca_cert.pem
- path: /etc/vpn-router/pki/root/cert.pem
permissions: '0644'
owner: root:root
content: |
${indent(6, trimspace(ca_cert))}
- path: /etc/vpn-router/pki/${label}_cert.pem
permissions: '0644'
owner: root:root
content: |
${indent(6, trimspace(server_cert))}
- path: /etc/vpn-router/pki/${label}_key.pem
${indent(6, trimspace(root_cert))}
%{ if root_key != "" ~}
- path: /etc/vpn-router/pki/root/key.pem
permissions: '0600'
owner: root:root
content: |
${indent(6, trimspace(server_key))}
${indent(6, trimspace(root_key))}
%{ endif ~}
package_update: true
+3 -3
View File
@@ -10,9 +10,9 @@
# Template variables are Terraform templatefile() placeholders. Adapt or drop
# them for whatever renders this file.
#
# This variant lets the package generate its own CA on first boot. See
# cloud-init-with-pki.yaml.tpl for the variant that supplies existing PKI
# material instead.
# This variant lets the package create its own CAs on first boot. See
# cloud-init-with-pki.yaml.tpl for the variant that supplies an existing root
# CA instead.
hostname: ${hostname}
fqdn: ${fqdn}
+4 -6
View File
@@ -3,7 +3,7 @@ data "http" "repo_gpg_key" {
}
locals {
supply_pki = var.ca_cert_file != ""
supply_pki = var.root_cert_file != ""
cloud_init_vars = {
hostname = var.name
@@ -27,16 +27,14 @@ locals {
psk = var.psk
}
}
p2s = { enabled = var.p2s_enabled, address_pool = var.p2s_address_pool, ca_name = "VPN Router CA" }
p2s = { enabled = var.p2s_enabled, address_pool = var.p2s_address_pool }
wireguard = { enabled = var.wireguard_enabled, address = var.wireguard_address, listen_port = var.wireguard_listen_port }
}
}
cloud_init_rendered = local.supply_pki ? templatefile("${path.module}/../cloud-init-with-pki.yaml.tpl", merge(local.cloud_init_vars, {
label = split(".", var.local_fqdn)[0]
ca_cert = file(var.ca_cert_file)
server_cert = file(var.server_cert_file)
server_key = file(var.server_key_file)
root_cert = file(var.root_cert_file)
root_key = var.root_key_file != "" ? file(var.root_key_file) : ""
})) : templatefile("${path.module}/../cloud-init.yaml.tpl", local.cloud_init_vars)
}
+4 -10
View File
@@ -97,20 +97,14 @@ variable "p2s_address_pool" {
default = ""
}
variable "ca_cert_file" {
description = "Path to an existing CA certificate PEM to supply instead of letting the package generate one. Leave empty to auto-generate."
variable "root_cert_file" {
description = "Path to an existing root CA certificate PEM to supply instead of letting the package create one. Leave empty to create one."
type = string
default = ""
}
variable "server_cert_file" {
description = "Path to an existing server certificate PEM, paired with ca_cert_file."
type = string
default = ""
}
variable "server_key_file" {
description = "Path to an existing server private key PEM, paired with ca_cert_file."
variable "root_key_file" {
description = "Path to the private key PEM of root_cert_file. Leave empty to keep the root offline: the issuing CAs then wait for certificates signed elsewhere."
type = string
default = ""
sensitive = true
+5 -3
View File
@@ -26,7 +26,7 @@ REMOTE_CIDRS="${REMOTE_CIDRS:-[\"192.168.0.0/24\"]}"
PSK="${PSK:-change-me}"
P2S_ENABLED="${P2S_ENABLED:-false}"
P2S_ADDRESS_POOL="${P2S_ADDRESS_POOL:-172.16.0.0/24}"
P2S_CA_NAME="${P2S_CA_NAME:-VPN Router CA}"
PKI_ROOT_NAME="${PKI_ROOT_NAME:-VPN Router Root CA}"
WG_ENABLED="${WG_ENABLED:-false}"
WG_ADDRESS="${WG_ADDRESS:-}"
WG_LISTEN_PORT="${WG_LISTEN_PORT:-51820}"
@@ -66,8 +66,10 @@ cat > /etc/vpn-router/vpn-router.json <<EOF
},
"p2s": {
"enabled": ${P2S_ENABLED},
"address_pool": "${P2S_ADDRESS_POOL}",
"ca_name": "${P2S_CA_NAME}"
"address_pool": "${P2S_ADDRESS_POOL}"
},
"pki": {
"root_name": "${PKI_ROOT_NAME}"
},
"wireguard": {
"enabled": ${WG_ENABLED},