Compare commits

..
5 Commits
11 changed files with 938 additions and 550 deletions

No files matched your search

+44 -10
View File
@@ -152,8 +152,8 @@ Status: Accepted
Context: Users are managed from the command line, by an operator at a terminal, and through 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, `/users` of the web service, by the Router Manager. Both must apply the same rules to names,
passwords and person attributes, and both change the directory and the PKI, whose state is one passwords and person attributes, and both change the directory and the PKI, whose CRL numbers and
file, so two changes must not run at the same time. The web unit is sandboxed with CRLs two changes must not write at the same time. The web unit is sandboxed with
`ProtectSystem=strict`. `ProtectSystem=strict`.
Decision: `vpnrouter_users.py` holds the user operations, the checks and a session that takes a Decision: `vpnrouter_users.py` holds the user operations, the checks and a session that takes a
@@ -162,9 +162,9 @@ 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 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 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 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 PKI's state, and files are there too, and `/etc/swanctl/x509crl`, `/etc/openvpn/server` and
`/etc/swanctl/x509crl`, `/etc/openvpn/server` and `/var/lib/vpn-router/pki-publish` for the `/var/lib/vpn-router/pki-publish` for the copies of the CRLs a revocation signs; the PKI itself is
CRLs a revocation signs. The PKI module, `vpnrouter_pki.py`, works the same way for `/ca`, in the directory (ADR-32). The PKI module, `vpnrouter_pki.py`, works the same way for `/ca`,
`vpn-router-ca` and `vpn-router-setup`, and its lock is the users' lock, as adding and removing `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. a user issue and revoke a certificate.
@@ -757,8 +757,8 @@ of common name and the subject alternative names a certificate may have; `vpn-se
`vpn-user` are issued by the router only, so no certificate for another use comes from the P2S `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 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 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 (ADR-6); the settings are the `pki` section of `vpn-router.json`, and the CAs and the
`pki/state.json`. Keeping the root key off the router is recommended, not required: while it is certificates are kept in the router's directory (ADR-32). Keeping the root key off the router is recommended, not required: while it is
on the router a new issuing CA is signed at once, and while it is not the CA gets its key and a 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 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 neither the API nor the Manager. For a certificate of something other than the router, the router
@@ -808,14 +808,17 @@ secret to distribute. Azure's VPN gateway authenticates site-to-site connections
keys only. keys only.
Decision: A connection's `auth` is `psk`, the default, or `certificate`. With `certificate` the 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`, router presents one site-to-site certificate of its `s2s` CA, whose names are `wan.local_fqdn` and
the public address and the internal address, so it fits every `local_id_mode`; the peer's the local identities the connections with `certificate` use, the public or the internal address of
their `local_id_mode`, and no address no connection presents, such as an internal address the peers
never see; the peer's
certificate must name the connection's `id` and be issued by the router's `s2s` CA or, when the 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 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. `/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 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 new. The router's site-to-site certificate is issued again when the set of names it needs changes,
such as when a connection's `local_id_mode` or its address changes. The
router does not check a remote CA's revocations unless strongSwan can fetch the CRL its router does not check a remote CA's revocations unless strongSwan can fetch the CRL its
certificates name. certificates name.
@@ -861,3 +864,34 @@ Manager shows which one is in effect.
Consequences: The first setup run with HTTPS starts the installation and serves HTTPS on the run 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 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. installation instructions do it. Going back to the archive's Caddy is a manual downgrade.
## ADR-32: The PKI is kept in the router's directory
Status: Accepted
Context: The CAs, their keys and CRLs, the inventory of the certificates issued and their
revocations, and the router's and the users' certificates and keys need a store. Files with a
JSON state file cannot be searched, and are copied apart from the users the certificates belong
to. The router runs slapd, which holds the users and is provisioned on every
configured router (ADR-10), and PostgreSQL, which holds the refresh tokens and the metrics, may
be another server (`web.database`) and is in a backup only with `backup.metrics`. RFC 4523
defines `pkiCA` with `cACertificate` and `certificateRevocationList`, and `userCertificate`, which
OpenLDAP's core schema includes.
Decision: The PKI is held in `ou=pki` of the router's directory. A CA is an entry of the package's
class `vpnRouterAuthority` with `cACertificate` and `certificateRevocationList`, its key, its CSR,
its CRL number, the source of the root CRL and the time it was retired; a certificate is an entry
of `vpnRouterCertificate` named by its serial number under its CA, with `userCertificate`, its
kind, template, names, expiry and revocation, and the key of a router or user certificate. A
user's certificate is also the `userCertificate` of the user's entry. An ACL lets no account read
`vpnRouterPrivateKey`; only root, the database's root DN, does, through the local socket. A
retired CA keeps its entry, renamed, so its certificates stay in the inventory. A root supplied
from outside, by cloud-init or by hand, is placed as files before the first setup with a platform,
which imports and removes them. strongSwan, OpenVPN, Caddy and the system's trust store read files,
so setup and every revocation write them from the directory. Setup provisions the directory before
the PKI; without the directory it leaves the PKI and its files as they are and configures the rest.
Consequences: `slapcat` writes the users and the PKI into every backup in one consistent copy, and a
restore brings both back together. The PKI depends on slapd, as the users already do, and not on
PostgreSQL. The directory holds the CA keys, so it is as sensitive as the CA itself. The schema
grows by the package's PKI attributes and classes, which setup adds to slapd's configuration.
+60 -29
View File
@@ -144,6 +144,7 @@ must have the type shown below; an unknown section or setting is an error.
| `pki` | `s2s_name` | string | `VPN Router Site-to-Site CA` | Common name of the site-to-site 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` | `issuing_cas` | object | | The general-purpose issuing CAs, each a name and `{name, templates, acme}` (see Certificates) |
| `pki` | `templates` | object | | Certificate templates, each a name and the options it changes or defines (see Certificates) | | `pki` | `templates` | object | | Certificate templates, each a name and the options it changes or defines (see Certificates) |
| `pki` | `extended_key_usages` | object | | Custom extended key usages templates may give, each a name and its OID in dotted form (see Certificates) |
| `pki` | `root_key_warning` | boolean | `true` | `vpn-router-ca status` and `/ca` warn while the root key is on the router; `false` silences that warning | | `pki` | `root_key_warning` | boolean | `true` | `vpn-router-ca status` and `/ca` warn while the root key is on the router; `false` silences that warning |
| `metrics` | `enabled` | boolean | `true` | The metrics service: collection, history and `/metrics` | | `metrics` | `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` | `listen` | string | `internal` | The interfaces `/metrics` is served on: `internal`, `external` or `both` |
@@ -202,8 +203,9 @@ certificate; all such connections are written to `/etc/swanctl/conf.d/site-to-si
Traffic from a connection's `local_cidrs` to its `cidrs` is not masqueraded. 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 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 site-to-site issuing CA signs for `wan.local_fqdn` and for the public or the internal address
address, so it fits every `local_id_mode`. The peer's certificate must name `id` and be issued that a connection with `auth` `certificate` uses as its identity (`local_id_mode` `public_ip` or
`internal_ip`); it is issued again when that set of names changes. The peer's certificate must name `id` and be issued
by the router's site-to-site CA, or by the CA of `remote_ca`. A peer with no PKI of its own gets 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 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. the site-to-site CA certificates as its trust anchors.
@@ -371,18 +373,27 @@ services - that is what `systemctl restart vpn-router-setup` adds.
## Certificates ## Certificates
The router has a two-level certificate authority in `/etc/vpn-router/pki` (mode 0700), kept by The router has a two-level certificate authority, kept by `vpn-router-setup` and managed with
`vpn-router-setup` and managed with `vpn-router-ca`, `vpn-router-user` and `/ca` of the status `vpn-router-ca`, `vpn-router-user` and `/ca` of the status web service, which the Router Manager
web service, which the Router Manager uses: uses. It is held in `ou=pki,dc=vpn-router,dc=local` of the router's directory, next to the users:
``` ```
root/cert.pem root/key.pem root/crl.pem the root CA; key.pem is absent while the root is offline cn=root the root CA; without vpnRouterPrivateKey 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 cn=<name> an issuing CA; with vpnRouterCertificateRequest while it waits for the offline root
router/vpn-server_cert.pem _key.pem the router's VPN server certificate cn=<name>-<time> an issuing CA taken out of use, with vpnRouterRetired
router/site-to-site_cert.pem _key.pem the router's site-to-site certificate serialNumber=<serial>,cn=<CA> a certificate the CA issued
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 A CA entry (class `vpnRouterAuthority`) has its certificate (`cACertificate;binary`), key, CRL
(`certificateRevocationList;binary`) and CRL number. A certificate entry (class
`vpnRouterCertificate`) has the certificate (`userCertificate;binary`), its kind (`ca`, `router`,
`user` or `external`), template, names, expiry and revocation, and for the router's and the users'
certificates the key. A user's certificate is also the `userCertificate` of the user's entry. No
account of the directory reads a key (`vpnRouterPrivateKey`); only root does, through the local
socket. `slapcat` writes the whole PKI into every backup with the users.
```sh
ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b ou=pki,dc=vpn-router,dc=local -s one cn
``` ```
### The CAs ### The CAs
@@ -403,8 +414,8 @@ The root signs the issuing CAs only (path length 1), and the issuing CAs sign th
Once a platform is chosen, `vpn-router-setup` creates what is missing: the root when it has 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 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 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 issuing CA removed from `pki.issuing_cas` is retired: its entry is renamed `cn=<name>-<time>`,
certificate is revoked on the root CRL. The router renews its own certificates when less than a loses its key, and its certificate is revoked on the root CRL. The router renews its own certificates when less than a
third of their lifetime is left, when the names they need change, or when their CA changes; a 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. user's certificate is renewed by removing and adding the user.
@@ -432,7 +443,8 @@ A template says what a certificate holds:
| `key` | string | `rsa-3072` | `rsa-2048`, `rsa-3072`, `rsa-4096`, `ec-p256` or `ec-p384`, for a key the router makes | | `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 | | `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` | | `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` | | `extended_key_usage` | list | | Of the built-in `server_auth`, `client_auth`, `code_signing`, `email_protection`, `time_stamping`, `ocsp_signing`, `ipsec_ike`, `ike_intermediate`, and the custom usages of `pki.extended_key_usages` |
| `policies` | list | | Certificate policy OIDs in dotted form, written into a Certificate Policies extension |
| `subject` | string | `name` | The common name: `dns`, a DNS name, which is also a subject alternative name; `name`, any name; `email`, an e-mail address | | `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` | | `sans` | list | | The subject alternative names allowed: of `dns`, `ip`, `email` |
@@ -451,15 +463,25 @@ templates of its own, named like the issuing CAs:
```json ```json
"pki": { "pki": {
"issuing_cas": { "servers": { "name": "Example Server CA", "templates": ["server", "web"] } }, "issuing_cas": { "servers": { "name": "Example Server CA", "templates": ["server", "web", "smartcard"] } },
"extended_key_usages": { "smartcard_logon": "1.3.6.1.4.1.311.20.2.2" },
"templates": { "templates": {
"server": { "days": 397 }, "server": { "days": 397 },
"web": { "key": "ec-p256", "days": 90, "key_usage": ["digital_signature"], "web": { "key": "ec-p256", "days": 90, "key_usage": ["digital_signature"],
"extended_key_usage": ["server_auth"], "subject": "dns", "sans": ["dns"] } "extended_key_usage": ["server_auth"], "subject": "dns", "sans": ["dns"] },
"smartcard": { "key_usage": ["digital_signature", "key_encipherment"],
"extended_key_usage": ["client_auth", "smartcard_logon"], "subject": "name",
"sans": ["email"], "policies": ["1.3.6.1.4.1.99999.1.1"] }
} }
} }
``` ```
A custom extended key usage is first added to `pki.extended_key_usages`, a name of at most 31
lowercase letters, digits, hyphens and underscores that is not a built-in one, and its OID; a
template then gives it by its name. A usage a template gives cannot be removed. `policies` puts
the OIDs of the issuance policies a certificate is issued under into its Certificate Policies
extension, without qualifiers.
### Issuing and revoking ### Issuing and revoking
```sh ```sh
@@ -541,9 +563,9 @@ 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 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 use it, as `/etc/vpn-router/pki/root/cert.pem` and `key.pem` before `vpn-router-setup` first runs
`root/cert.pem` and `root/key.pem`. Without the key, the issuing CAs wait for their certificates with a platform, as cloud-init can. Setup imports them into the directory and removes the files.
as above. Without the key, the issuing CAs wait for their certificates as above.
### ACME server ### ACME server
@@ -623,7 +645,7 @@ it is added:
- a VPN user, an `inetOrgPerson` with a first name, a last name and an e-mail address - a VPN user, an `inetOrgPerson` with a first name, a last name and an e-mail address
(`givenName`, `sn` and `mail`; `cn` is the first and the last name), and a client certificate (`givenName`, `sn` and `mail`; `cn` is the first and the last name), and a client certificate
with the user name as its common name, in `/etc/vpn-router/pki/users`; with the user name as its common name, the entry's `userCertificate`, whose key the PKI keeps;
- a Manager-only account, an `account`, `simpleSecurityObject` and `vpnRouterAccount` with the - a Manager-only account, an `account`, `simpleSecurityObject` and `vpnRouterAccount` with the
name, the password and an optional e-mail address, for notifications: no names and no name, the password and an optional e-mail address, for notifications: no names and no
certificate, so no VPN access. It can sign in to `/auth` and, as a member of `admins`, manage certificate, so no VPN access. It can sign in to `/auth` and, as a member of `admins`, manage
@@ -782,7 +804,7 @@ configuration: no key, identity, subnet, or address but the client's. They show
|---|---| |---|---|
| `basic` (default) | The overall status (`ok` or `degraded`), the time and the Client IP | | `basic` (default) | The overall status (`ok` or `degraded`), the time and the Client IP |
| `standard` | Also the `vpn-router` version, the `vpn-router-manager` version or that it is not installed, and the number of active services and of services in any other state, without their names | | `standard` | Also the `vpn-router` version, the `vpn-router-manager` version or that it is not installed, and the number of active services and of services in any other state, without their names |
| `full` | Also each service and its state, Docker, and the state of each site-to-site connection, road-warrior, OpenVPN and WireGuard | | `full` | Also each service and its state, Docker, how many site-to-site connections are set up and how many of them are established (`site_to_site`: `connections` and `established`, `null` when swanctl cannot be asked), road-warrior, OpenVPN and WireGuard |
The Client IP is the address the request came from: the last entry of `X-Forwarded-For` for a The Client IP is the address the request came from: the last entry of `X-Forwarded-For` for a
request from one of `web.trusted_proxies`, by default the address the request came in on, so request from one of `web.trusted_proxies`, by default the address the request came in on, so
@@ -830,8 +852,9 @@ road-warrior access is configured, of `wg-quick@wg0` when WireGuard is, and of
OpenVPN clients, from the server's status file, which OpenVPN rewrites every minute; the package version and, OpenVPN clients, from the server's status file, which OpenVPN rewrites every minute; the package version and,
as `manager_version`, the installed version of `vpn-router-manager` (`null`, shown as not installed, without it); whether as `manager_version`, the installed version of `vpn-router-manager` (`null`, shown as not installed, without it); whether
Docker is installed, and when it is, the state of `docker.service` and of `vpn-router-docker` Docker is installed, and when it is, the state of `docker.service` and of `vpn-router-docker`
(the Docker rules), which do not change `status`; as `site_to_site.connections`, whether each (the Docker rules), which do not change `status`; as `site_to_site`, the number of site-to-site
site-to-site connection that is set up is established (`null` when swanctl cannot be asked); the number of connected road-warrior (IPsec P2S) clients; and connections that are set up (`connections`) and of those established (`established`, `null` when
swanctl cannot be asked), without their names; the number of connected road-warrior (IPsec P2S) clients; and
the WireGuard peers with the age of their last handshake and their traffic. `status` is `ok` when the WireGuard peers with the age of their last handshake and their traffic. `status` is `ok` when
all these services are active, and `degraded` otherwise. all these services are active, and `degraded` otherwise.
@@ -894,6 +917,12 @@ curl -H 'Content-Type: application/json' -d '{"name": "alice", "password": "s3cr
curl -H "Authorization: Bearer $TOKEN" http://10.0.0.4:7081/config curl -H "Authorization: Bearer $TOKEN" http://10.0.0.4:7081/config
``` ```
### The /status/full page
`/status/full`, with the same token as `/config`, shows the whole status document, as `/status`
does with `web.status_content` `full`, whatever that setting says, so the Router Manager shows
every service and tunnel while the public status page shows less.
### The /config page ### The /config page
`/config` shows what `/` and `/status` leave out, for a client such as manage-ui: `/config` shows what `/` and `/status` leave out, for a client such as manage-ui:
@@ -1056,11 +1085,12 @@ A change is made, checked and applied as for `/site-to-site`.
| Request | Body | Answer | | Request | Body | Answer |
| --- | --- | --- | | --- | --- | --- |
| `GET /ca` | | `{"settings", "templates", "status", "certificates"}` | | `GET /ca` | | `{"settings", "templates", "extended_key_usages", "status", "certificates"}` |
| `PUT /ca` | any of the settings of the `pki` section | `{"settings", "templates"}` | | `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", "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` | `{"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 | | `POST /ca/certificates/<serial>/revoke` | `{"reason"}`, optional | the certificate, with `reissue` for a router certificate |
| `GET /ca/bundle/<name>` | | `{"name", "pem"}`: the certificates a client trusts the CA with, the root alone for `root`, the issuing CA followed by the root for an issuing CA |
| `GET /ca/issuing/<name>/csr` | | `{"name", "csr", "extensions"}` of an issuing CA waiting for the offline root | | `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/issuing/<name>/certificate` | `{"certificate"}`, PEM, signed by the root | `{"name", "subject", "expires"}` |
| `PUT /ca/root/crl` | `{"crl"}`, PEM, signed by the root | `{"next_update"}` | | `PUT /ca/root/crl` | `{"crl"}`, PEM, signed by the root | `{"next_update"}` |
@@ -1069,6 +1099,7 @@ The answer of `GET /ca` holds:
- **`settings`:** the `pki` section. - **`settings`:** the `pki` section.
- **`templates`:** every template, the built-in ones included, with all its options. - **`templates`:** every template, the built-in ones included, with all its options.
- **`extended_key_usages`:** every extended key usage templates may give, name -> `{"oid", "builtin"}`.
- **`status`:** `root` (`subject`, `expires`, `key`, whether the root key is on the router, `crl_next_update` and `crl_source`, `signed` or `uploaded`), `authorities`, one per issuing CA (`name`, `common_name`, `state`: `active`, `pending` or `missing`, `builtin`, `templates`, `acme`, `subject`, `expires`, `key`, `crl_next_update`, `acme_url`), `retired` and `warnings`. - **`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`. - **`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`.
@@ -1266,12 +1297,12 @@ A backup holds the state that `vpn-router-setup` cannot make again:
| Item | Content | | Item | Content |
|---|---| |---|---|
| `/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/vpn-router` | `vpn-router.json`, the secrets and `rclone.conf` |
| `/etc/wireguard` | The WireGuard keys and the peers | | `/etc/wireguard` | The WireGuard keys and the peers |
| `/etc/ufw`, `/etc/default/ufw` | The firewall rules | | `/etc/ufw`, `/etc/default/ufw` | The firewall rules |
| `/etc/vpn-router-manager` | The Router Manager's settings, when it is installed | | `/etc/vpn-router-manager` | The Router Manager's settings, when it is installed |
| `files/<path>` | Each `psk_file` outside `/etc/vpn-router` | | `files/<path>` | Each `psk_file` outside `/etc/vpn-router` |
| `ldap/vpn-router.ldif` | The user directory, written by `slapcat`: users, groups, service accounts, password hashes and TOTP secrets | | `ldap/vpn-router.ldif` | The user directory, written by `slapcat`: users, groups, service accounts, password hashes and TOTP secrets, and the PKI: the CAs and their keys, the root key while it is on the router, and the certificates issued, with the keys of the router's and the users' certificates |
| `postgresql/vpn-router.dump` | With `backup.metrics`, the database of the metrics and the refresh tokens, written by `pg_dump -Fc` | | `postgresql/vpn-router.dump` | With `backup.metrics`, the database of the metrics and the refresh tokens, written by `pg_dump -Fc` |
| `manifest.json` | The host, the time, the version of the package, the schedule and the items | | `manifest.json` | The host, the time, the version of the package, the schedule and the items |
@@ -1373,8 +1404,8 @@ file when the package created them, and Caddy's configuration `/etc/caddy/vpn-ro
`/var/lib/caddy` belongs to the `caddy` package. It removes the backup timer's schedule, the `/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 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 `/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`, source of Caddy's repository. Key material, users and backups are never deleted:
`/etc/vpn-router/ldap`, `/etc/vpn-router/rclone.conf`, the user directory in slapd, the WireGuard `/etc/vpn-router/ldap`, `/etc/vpn-router/rclone.conf`, the user directory in slapd with the PKI, the WireGuard
key pair, everything under `/etc/swanctl`, the archives in `backup.path` and key pair, everything under `/etc/swanctl`, the archives in `backup.path` and
`/var/lib/vpn-router/backup/before-restore` are left alone. `/var/lib/vpn-router/backup/before-restore` are left alone.
+66
View File
@@ -1,3 +1,69 @@
vpn-router (0.22.1-1) unstable; urgency=medium
fix: the status gives the number of site-to-site connections set up and established, not each connection by name.
* site_to_site of the status document is {"connections", "established"},
and the status page shows "Site to site: N of M established".
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 18:00:00 +0100
vpn-router (0.22.0-1) unstable; urgency=medium
feature: pki.extended_key_usages adds custom extended key usages by their OIDs, which templates give by name, and a template's policies put certificate policy OIDs into its certificates.
* pki.extended_key_usages maps a name to the OID of a custom extended
key usage; a template's extended_key_usage takes the built-in and the
custom names.
* The template option policies lists certificate policy OIDs, written
into a Certificate Policies extension.
* GET /ca answers extended_key_usages, each usage with its OID and
whether it is built in.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 14:00:00 +0100
vpn-router (0.21.5-1) unstable; urgency=medium
fix: /status/full of the status web service gives an authenticated client the whole status, whatever web.status_content says.
* /status/full takes the token of /config and answers the status
document as web.status_content full shows it.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 12:00:00 +0100
vpn-router (0.21.4-1) unstable; urgency=medium
fix: vpn-router-setup, vpn-router-ca and /ca read a CA's key and CRL from the directory each as itself, instead of failing on a CA with a CRL.
* A CA's CRL was read as its key and its key as its CRL, so every
operation on a CA with a CRL failed with "Could not deserialize key
data".
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 10:00:00 +0100
vpn-router (0.21.3-1) unstable; urgency=medium
fix: the PKI is kept in ou=pki of the router's directory instead of files under /etc/vpn-router/pki, and the router's site-to-site certificate names only the identities its connections present, and /ca/bundle gives the certificate bundle of a CA.
* The CAs with their certificates, keys, CSRs and CRLs, and every
certificate issued with its revocation, are entries of the
package's schema in ou=pki; a user's certificate is also the
userCertificate of the user's entry, and only root reads a key.
* slapcat puts the PKI into every backup with the users.
* A root CA supplied as /etc/vpn-router/pki/root/cert.pem and key.pem
before the first setup is imported into the directory, and the files
are removed; vpn-router-ca root import-cert is gone.
* Setup provisions the directory before the configuration; without it,
the PKI and its files are left as they are.
* The router's site-to-site certificate names wan.local_fqdn and the
public or internal address a connection with auth certificate uses
as its identity, not every address of the router, and is issued
again when those names change.
* GET /ca/bundle/<name> of the status web service answers the PEM
certificates a client trusts a CA with: the root alone, or an issuing
CA followed by the root.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 01 Nov 2026 10:00:00 +0100
vpn-router (0.21.2-1) unstable; urgency=medium vpn-router (0.21.2-1) unstable; urgency=medium
fix: pki.root_key_warning silences the warning of vpn-router-ca status and /ca about the root key on the router. fix: pki.root_key_warning silences the warning of vpn-router-ca status and /ca about the root key on the router.
+16 -10
View File
@@ -577,7 +577,12 @@ def manage_caddy(cfg, ctx):
group = grp.getgrnam(CADDY_GROUP).gr_gid group = grp.getgrnam(CADDY_GROUP).gr_gid
except KeyError: except KeyError:
fail(f'the group {CADDY_GROUP} does not exist; is the caddy package installed?') fail(f'the group {CADDY_GROUP} does not exist; is the caddy package installed?')
acme = [name for name in ctx['pki_acme'] if vpnrouter_pki.ready(name)] try:
credentials = {name: vpnrouter_pki.authority_files(name) for name in ctx['pki_acme']}
except ldap.LDAPError as exc:
warn(f'the ACME servers are left out: the PKI cannot be read: {exc}')
credentials = {}
acme = [name for name, files in credentials.items() if files is not None]
copied = False copied = False
for folder in (CADDY_PKI_DIR.iterdir() if CADDY_PKI_DIR.is_dir() else []): for folder in (CADDY_PKI_DIR.iterdir() if CADDY_PKI_DIR.is_dir() else []):
if folder.name not in acme: if folder.name not in acme:
@@ -587,8 +592,8 @@ def manage_caddy(cfg, ctx):
for path in (CADDY_PKI_DIR, folder): for path in (CADDY_PKI_DIR, folder):
path.mkdir(mode=0o750, exist_ok=True) path.mkdir(mode=0o750, exist_ok=True)
os.chown(path, 0, group) os.chown(path, 0, group)
for file in ('cert.pem', 'key.pem'): for file, data in zip(('cert.pem', 'key.pem'), credentials[name]):
copied |= vpnrouter.install_file(vpnrouter_pki.CA_DIR / name / file, folder / file, 0o640) copied |= vpnrouter_pki.write(folder / file, data, 0o640)
os.chown(folder / file, 0, group) os.chown(folder / file, 0, group)
authorities = vpnrouter.issuing_cas(cfg) authorities = vpnrouter.issuing_cas(cfg)
caddy_ctx = dict(ctx, manager_port=manager_port() if MANAGER.exists() else 0, caddy_ctx = dict(ctx, manager_port=manager_port() if MANAGER.exists() else 0,
@@ -739,14 +744,19 @@ def main():
start_bootstrap() start_bootstrap()
progress('running', 'configuration') progress('running', 'configuration')
cfg = vpnrouter.load_config() cfg = vpnrouter.load_config()
ctx, changed = vpnrouter.configure(cfg) if vpnrouter.deferred(cfg):
if ctx is None:
print('vpn-router: platform is none, configuration deferred; ' print('vpn-router: platform is none, configuration deferred; '
f'set general.platform in {vpnrouter.CONFIG_FILE}') f'set general.platform in {vpnrouter.CONFIG_FILE}')
progress('deferred', 'configuration', f'general.platform is none; set it in {vpnrouter.CONFIG_FILE}') progress('deferred', 'configuration', f'general.platform is none; set it in {vpnrouter.CONFIG_FILE}')
return 0 return 0
# The directory holds the PKI, which the configuration needs; its problem ends setup only after every other step.
failed_step = 'user directory'
progress('running', failed_step)
problem = manage_directory()
progress('running', 'configuration')
ctx, changed = vpnrouter.configure(cfg)
progress('running', 'routes') progress('running', 'routes')
if vpnrouter.routing_ready(ctx): if vpnrouter.routing_ready(ctx):
apply_routes(ctx) apply_routes(ctx)
@@ -778,10 +788,6 @@ def main():
progress('running', 'WireGuard') progress('running', 'WireGuard')
manage_wireguard(ctx, changed) manage_wireguard(ctx, changed)
# The tunnels are up before the directory and the admin, whose problem ends setup only after every other step.
failed_step = 'user directory'
progress('running', failed_step)
problem = manage_directory()
if not problem: if not problem:
failed_step = 'Router Manager admin' failed_step = 'Router Manager admin'
progress('running', failed_step) progress('running', failed_step)
@@ -17,6 +17,7 @@ import tempfile
import urllib.parse import urllib.parse
import jinja2 import jinja2
import ldap
LIB_DIR = pathlib.Path(__file__).resolve().parent LIB_DIR = pathlib.Path(__file__).resolve().parent
if str(LIB_DIR) not in sys.path: if str(LIB_DIR) not in sys.path:
@@ -56,7 +57,7 @@ SCHEMA = {
'pki': {'root_name': 'VPN Router Root CA', 'root_days': 7300, 'ca_days': 3650, 'key': 'rsa-4096', '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', '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': {}, 's2s_name': 'VPN Router Site-to-Site CA', 'issuing_cas': {}, 'templates': {},
'root_key_warning': True}, 'extended_key_usages': {}, 'root_key_warning': True},
'metrics': {'enabled': True, 'listen': 'internal', 'port': 9464, 'https_port': 9465, 'service_account': 'metrics', 'metrics': {'enabled': True, 'listen': 'internal', 'port': 9464, 'https_port': 9465, 'service_account': 'metrics',
'otlp_endpoint': '', 'otlp_username': '', 'otlp_password': '', 'otlp_endpoint': '', 'otlp_username': '', 'otlp_password': '',
'keep_details': '24 hours', 'keep_hourly': '1 month', 'keep_daily': '3 months', 'keep_details': '24 hours', 'keep_hourly': '1 month', 'keep_daily': '3 months',
@@ -135,9 +136,23 @@ PKI_PUBLISH = ('http', 'http-80', 'https', 'https-default')
#: The key usages a template may give, as the arguments of cryptography's KeyUsage. #: 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') KEY_USAGES = ('digital_signature', 'content_commitment', 'key_encipherment', 'data_encipherment', 'key_agreement')
#: The extended key usages a template may give. #: The built-in extended key usages a template may give, name -> OID; pki.extended_key_usages adds custom ones.
EXTENDED_KEY_USAGES = ('server_auth', 'client_auth', 'code_signing', 'email_protection', 'time_stamping', EXTENDED_KEY_USAGES = {
'ocsp_signing', 'ipsec_ike', 'ike_intermediate') 'server_auth': '1.3.6.1.5.5.7.3.1',
'client_auth': '1.3.6.1.5.5.7.3.2',
'code_signing': '1.3.6.1.5.5.7.3.3',
'email_protection': '1.3.6.1.5.5.7.3.4',
'time_stamping': '1.3.6.1.5.5.7.3.8',
'ocsp_signing': '1.3.6.1.5.5.7.3.9',
'ipsec_ike': '1.3.6.1.5.5.7.3.17',
'ike_intermediate': '1.3.6.1.5.5.8.2.2',
}
#: The name of a custom extended key usage of pki.extended_key_usages.
EKU_NAME = re.compile(r'^[a-z][a-z0-9_-]{0,30}$')
#: An object identifier in dotted form, of a custom extended key usage or a certificate policy.
OID = re.compile(r'^[0-2](\.(0|[1-9][0-9]*)){1,63}$')
#: What the common name of a template's certificates is: a DNS name, any name, or an e-mail address. #: 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') TEMPLATE_SUBJECTS = ('dns', 'name', 'email')
@@ -147,7 +162,7 @@ SAN_TYPES = ('dns', 'ip', 'email')
#: A certificate template: option -> default, the type of the default being the required type. #: 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': [], TEMPLATE = {'key': 'rsa-3072', 'days': 365, 'key_usage': ['digital_signature'], 'extended_key_usage': [],
'subject': 'name', 'sans': []} 'subject': 'name', 'sans': [], 'policies': []}
#: The built-in templates; pki.templates changes their options and adds templates of its own. #: The built-in templates; pki.templates changes their options and adds templates of its own.
PKI_TEMPLATES = { PKI_TEMPLATES = {
@@ -501,6 +516,14 @@ def validate(cfg):
raise ConfigError('pki.publish: http is served by the web service, which needs 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'): 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') raise ConfigError('pki.publish: http-80, https and https-default are served by Caddy, which needs https.enabled')
for name, oid in cfg['pki']['extended_key_usages'].items():
prefix = f'pki.extended_key_usages.{name}'
if not EKU_NAME.match(name) or name in EXTENDED_KEY_USAGES:
raise ConfigError(f'{prefix}: an extended key usage name starts with a lowercase letter, has at most 31 '
'lowercase letters, digits, hyphens and underscores, and is not a built-in one')
if not isinstance(oid, str) or not OID.match(oid):
raise ConfigError(f'{prefix} must be an OID in dotted form, such as 1.3.6.1.4.1.311.20.2.2')
usages = extended_key_usages(cfg)
for name, template in cfg['pki']['templates'].items(): for name, template in cfg['pki']['templates'].items():
prefix = f'pki.templates.{name}' prefix = f'pki.templates.{name}'
if not PKI_NAME.match(name): if not PKI_NAME.match(name):
@@ -516,11 +539,14 @@ def validate(cfg):
raise ConfigError(f'{prefix}.key: {template["key"]!r} is not one of {", ".join(PKI_KEYS)}') raise ConfigError(f'{prefix}.key: {template["key"]!r} is not one of {", ".join(PKI_KEYS)}')
if not 1 <= template['days'] <= 36500: if not 1 <= template['days'] <= 36500:
raise ConfigError(f'{prefix}.days must be 1 to 36500 days') 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), for option, allowed in (('key_usage', KEY_USAGES), ('extended_key_usage', tuple(usages)),
('sans', SAN_TYPES)): ('sans', SAN_TYPES)):
for item in template[option]: for item in template[option]:
if item not in allowed: if item not in allowed:
raise ConfigError(f'{prefix}.{option}: {item!r} is not one of {", ".join(allowed)}') raise ConfigError(f'{prefix}.{option}: {item!r} is not one of {", ".join(allowed)}')
for item in template['policies']:
if not OID.match(item):
raise ConfigError(f'{prefix}.policies: {item!r} is not an OID in dotted form, such as 1.3.6.1.4.1.99999.1')
if not template['key_usage']: if not template['key_usage']:
raise ConfigError(f'{prefix}.key_usage needs at least one usage') raise ConfigError(f'{prefix}.key_usage needs at least one usage')
if template['subject'] not in TEMPLATE_SUBJECTS: if template['subject'] not in TEMPLATE_SUBJECTS:
@@ -664,6 +690,13 @@ def pki_templates(cfg):
for name in [*PKI_TEMPLATES, *(name for name in custom if name not in PKI_TEMPLATES)]} for name in [*PKI_TEMPLATES, *(name for name in custom if name not in PKI_TEMPLATES)]}
def extended_key_usages(cfg):
"""The extended key usages templates may give, name -> OID: the built-in ones (EXTENDED_KEY_USAGES) and those of
pki.extended_key_usages."""
custom = cfg['pki']['extended_key_usages']
return {**EXTENDED_KEY_USAGES, **{name: oid for name, oid in custom.items() if isinstance(oid, str)}}
def issuing_cas(cfg): def issuing_cas(cfg):
"""The issuing CAs, name -> {name, templates, acme, builtin}: p2s and s2s (BUILTIN_CAS) with their common names from """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.""" the pki section, then those of pki.issuing_cas, each filled from ISSUING_CA."""
@@ -1011,6 +1044,7 @@ def configure(cfg=None, public_ip=None):
changed = set() changed = set()
# Without the directory, which holds the PKI, the copies of the last run stay and what needs none is configured.
with vpnrouter_pki.locked(): with vpnrouter_pki.locked():
try: try:
notices = vpnrouter_pki.provision(cfg, ctx) notices = vpnrouter_pki.provision(cfg, ctx)
@@ -1018,6 +1052,8 @@ def configure(cfg=None, public_ip=None):
changed.add('swanctl') changed.add('swanctl')
except vpnrouter_pki.Failure as failure: except vpnrouter_pki.Failure as failure:
fail(str(failure)) fail(str(failure))
except ldap.LDAPError as exc:
notices = [f'the PKI is left as it is: the directory cannot be reached: {exc}']
for notice in notices: for notice in notices:
warn(notice) warn(notice)
# What needs the router's own certificates. # What needs the router's own certificates.
@@ -11,6 +11,9 @@ The password policy locks an account for LOCKOUT_SECONDS after LOCKOUT_FAILURES
a successful bind clears the count. slapd's otp overlay is configured on the database, so a a successful bind clears the count. slapd's otp overlay is configured on the database, so a
bind of a user with a TOTP token takes the password followed by a current code. bind of a user with a TOTP token takes the password followed by a current code.
cn=totp,ou=policies holds the TOTP parameters the token of every user points to. cn=totp,ou=policies holds the TOTP parameters the token of every user points to.
ou=pki holds the router's PKI (vpnrouter_pki): an entry per CA, and under it an entry per
certificate the CA issued. Private keys are readable by root alone, the database's root DN.
""" """
import os import os
@@ -29,6 +32,7 @@ USERS = f'ou=users,{BASE}'
GROUPS = f'ou=groups,{BASE}' GROUPS = f'ou=groups,{BASE}'
SERVICES = f'ou=services,{BASE}' SERVICES = f'ou=services,{BASE}'
POLICIES = f'ou=policies,{BASE}' POLICIES = f'ou=policies,{BASE}'
PKI = f'ou=pki,{BASE}'
DEFAULT_POLICY = f'cn=default,{POLICIES}' DEFAULT_POLICY = f'cn=default,{POLICIES}'
TOTP_PARAMS = f'cn=totp,{POLICIES}' TOTP_PARAMS = f'cn=totp,{POLICIES}'
MANAGER = f'cn=user-manager,{SERVICES}' MANAGER = f'cn=user-manager,{SERVICES}'
@@ -54,16 +58,43 @@ OVERLAYS = ('ppolicy', 'otp')
#: under .1 and object classes under .2. vpnRouterAccount, auxiliary, adds properties of the #: under .1 and object classes under .2. vpnRouterAccount, auxiliary, adds properties of the
#: package to a user of either kind: mail, for a Manager-only account, vpnRouterScope, #: package to a user of either kind: mail, for a Manager-only account, vpnRouterScope,
#: authorization scopes, of which service marks a service account, and displayName and #: authorization scopes, of which service marks a service account, and displayName and
#: description, the labels of a service account. #: description, the labels of a service account. vpnRouterAuthority is a CA of the PKI: its
#: certificate and CRL (cACertificate and certificateRevocationList of RFC 4523), its key, the
#: CSR it waits with for the offline root, the number of its last CRL, where the CRL came from,
#: and when it was retired. vpnRouterCertificate is a certificate a CA issued, named by its
#: serial number: the certificate, the key the router keeps for its own and the users'
#: certificates, its kind, template, names and expiry, and its revocation.
SCHEMA_OID = '2.25.98477352595735834532404256531620013001' SCHEMA_OID = '2.25.98477352595735834532404256531620013001'
SCHEMA_NAME = 'vpn-router' SCHEMA_NAME = 'vpn-router'
_STRING = 'EQUALITY caseIgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} SINGLE-VALUE'
_TIME = 'EQUALITY generalizedTimeMatch ORDERING generalizedTimeOrderingMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 SINGLE-VALUE'
_OCTETS = 'EQUALITY octetStringMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.40 SINGLE-VALUE'
SCHEMA_ATTRIBUTE_TYPES = ( SCHEMA_ATTRIBUTE_TYPES = (
f"( {SCHEMA_OID}.1.1 NAME 'vpnRouterScope' DESC 'VPN router: an authorization scope of the account' " f"( {SCHEMA_OID}.1.1 NAME 'vpnRouterScope' DESC 'VPN router: an authorization scope of the account' "
"EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} )", "EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} )",
f"( {SCHEMA_OID}.1.2 NAME 'vpnRouterPrivateKey' DESC 'VPN router: a private key, PEM' {_OCTETS} )",
f"( {SCHEMA_OID}.1.3 NAME 'vpnRouterCertificateRequest' DESC 'VPN router: the CSR of a CA, PEM' {_OCTETS} )",
f"( {SCHEMA_OID}.1.4 NAME 'vpnRouterCRLNumber' DESC 'VPN router: the number of the last CRL of a CA' "
"EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE )",
f"( {SCHEMA_OID}.1.5 NAME 'vpnRouterCRLSource' DESC 'VPN router: signed or uploaded, where the CRL of a CA came from' {_STRING} )",
f"( {SCHEMA_OID}.1.6 NAME 'vpnRouterRetired' DESC 'VPN router: when a CA was retired' {_TIME} )",
f"( {SCHEMA_OID}.1.7 NAME 'vpnRouterKind' DESC 'VPN router: ca, router, user or external, the kind of a certificate' {_STRING} )",
f"( {SCHEMA_OID}.1.8 NAME 'vpnRouterTemplate' DESC 'VPN router: the template of a certificate' {_STRING} )",
f"( {SCHEMA_OID}.1.9 NAME 'vpnRouterSubjectAltName' DESC 'VPN router: a subject alternative name of a certificate' "
"EQUALITY caseIgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} )",
f"( {SCHEMA_OID}.1.10 NAME 'vpnRouterNotAfter' DESC 'VPN router: when a certificate expires' {_TIME} )",
f"( {SCHEMA_OID}.1.11 NAME 'vpnRouterRevoked' DESC 'VPN router: when a certificate was revoked' {_TIME} )",
f"( {SCHEMA_OID}.1.12 NAME 'vpnRouterRevocationReason' DESC 'VPN router: why a certificate was revoked' {_STRING} )",
) )
SCHEMA_OBJECT_CLASSES = ( SCHEMA_OBJECT_CLASSES = (
f"( {SCHEMA_OID}.2.1 NAME 'vpnRouterAccount' DESC 'VPN router: properties of a user or a Manager-only account' " f"( {SCHEMA_OID}.2.1 NAME 'vpnRouterAccount' DESC 'VPN router: properties of a user or a Manager-only account' "
"SUP top AUXILIARY MAY ( mail $ vpnRouterScope $ displayName $ description ) )", "SUP top AUXILIARY MAY ( mail $ vpnRouterScope $ displayName $ description ) )",
f"( {SCHEMA_OID}.2.2 NAME 'vpnRouterAuthority' DESC 'VPN router: a CA of the PKI' SUP top STRUCTURAL MUST cn "
"MAY ( cACertificate $ certificateRevocationList $ vpnRouterPrivateKey $ vpnRouterCertificateRequest $ "
"vpnRouterCRLNumber $ vpnRouterCRLSource $ vpnRouterRetired ) )",
f"( {SCHEMA_OID}.2.3 NAME 'vpnRouterCertificate' DESC 'VPN router: a certificate a CA of the PKI issued' SUP top "
"STRUCTURAL MUST serialNumber MAY ( cn $ userCertificate $ vpnRouterPrivateKey $ vpnRouterKind $ vpnRouterTemplate $ "
"vpnRouterSubjectAltName $ vpnRouterNotAfter $ vpnRouterRevoked $ vpnRouterRevocationReason ) )",
) )
#: The TOTP parameters: digits, the OID of HMAC-SHA1, seconds per time step, and the time steps accepted before and after now. #: The TOTP parameters: digits, the OID of HMAC-SHA1, seconds per time step, and the time steps accepted before and after now.
@@ -74,6 +105,7 @@ TOTP_WINDOW = 1
#: The ACLs of the database, in order. #: The ACLs of the database, in order.
ACCESS = ( ACCESS = (
'to attrs=vpnRouterPrivateKey by * none',
f'to attrs=userPassword by dn.exact="{MANAGER}" =wx by self =wx by anonymous auth by * none', f'to attrs=userPassword by dn.exact="{MANAGER}" =wx by self =wx by anonymous auth by * none',
f'to attrs=oathSecret by dn.exact="{MANAGER}" =w by * none', f'to attrs=oathSecret by dn.exact="{MANAGER}" =w by * none',
f'to dn.subtree="{USERS}" by dn.exact="{MANAGER}" manage by * read', f'to dn.subtree="{USERS}" by dn.exact="{MANAGER}" manage by * read',
@@ -126,20 +158,27 @@ def _ensure_modules(connection):
def _ensure_schema(connection): def _ensure_schema(connection):
"""Add the package's schema to cn=config when missing, else set its definitions that differ.""" """Add the package's schema to cn=config when missing, else add the definitions it lacks; a definition that differs
from the one of the same OID replaces the whole list of its kind."""
found = connection.search_s('cn=schema,cn=config', ldap.SCOPE_ONELEVEL, f'(cn=*{SCHEMA_NAME})', found = connection.search_s('cn=schema,cn=config', ldap.SCOPE_ONELEVEL, f'(cn=*{SCHEMA_NAME})',
['olcAttributeTypes', 'olcObjectClasses']) ['olcAttributeTypes', 'olcObjectClasses'])
attributes = {'olcAttributeTypes': [value.encode() for value in SCHEMA_ATTRIBUTE_TYPES], attributes = {'olcAttributeTypes': list(SCHEMA_ATTRIBUTE_TYPES), 'olcObjectClasses': list(SCHEMA_OBJECT_CLASSES)}
'olcObjectClasses': [value.encode() for value in SCHEMA_OBJECT_CLASSES]}
if not found: if not found:
connection.add_s(f'cn={SCHEMA_NAME},cn=schema,cn=config', ldap.modlist.addModlist( connection.add_s(f'cn={SCHEMA_NAME},cn=schema,cn=config', ldap.modlist.addModlist(
dict(attributes, objectClass=[b'olcSchemaConfig'], cn=[SCHEMA_NAME.encode()]))) dict({name: [value.encode() for value in values] for name, values in attributes.items()},
objectClass=[b'olcSchemaConfig'], cn=[SCHEMA_NAME.encode()])))
return return
dn, current = found[0] dn, current = found[0]
changes = [(ldap.MOD_REPLACE, name, values) for name, values in attributes.items() for name, wanted in attributes.items():
if unordered(current.get(name, [])) != [value.decode() for value in values]] present = unordered(current.get(name, []))
if changes: oids = {value.split()[1] for value in present}
connection.modify_s(dn, changes) missing = [value for value in wanted if value not in present]
if not missing:
continue
if any(value.split()[1] in oids for value in missing):
connection.modify_s(dn, [(ldap.MOD_REPLACE, name, [value.encode() for value in wanted])])
else:
connection.modify_s(dn, [(ldap.MOD_ADD, name, [value.encode() for value in missing])])
def _ensure_password_hash(connection): def _ensure_password_hash(connection):
@@ -230,7 +269,7 @@ def provision():
database = _ensure_database(connection) database = _ensure_database(connection)
_ensure_overlays(connection, database) _ensure_overlays(connection, database)
_ensure_entry(connection, BASE, {'objectClass': [b'dcObject', b'organization'], 'dc': [b'vpn-router'], 'o': [b'vpn-router']}) _ensure_entry(connection, BASE, {'objectClass': [b'dcObject', b'organization'], 'dc': [b'vpn-router'], 'o': [b'vpn-router']})
for unit in (USERS, GROUPS, SERVICES, POLICIES): for unit in (USERS, GROUPS, SERVICES, POLICIES, PKI):
_ensure_entry(connection, unit, {'objectClass': [b'organizationalUnit'], 'ou': [unit.split(',')[0][3:].encode()]}) _ensure_entry(connection, unit, {'objectClass': [b'organizationalUnit'], 'ou': [unit.split(',')[0][3:].encode()]})
_ensure_entry(connection, DEFAULT_POLICY, { _ensure_entry(connection, DEFAULT_POLICY, {
'objectClass': [b'organizationalRole', b'pwdPolicy'], 'objectClass': [b'organizationalRole', b'pwdPolicy'],
File diff suppressed because it is too large. Load diff
@@ -3,7 +3,8 @@
A user is an entry in ou=users of the router's directory, of one of two kinds, fixed when it is 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 added. A VPN user is an inetOrgPerson with a first name, a last name and an e-mail address, whose
password OpenVPN checks, and a client certificate of the P2S issuing CA (vpnrouter_pki) with the 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, user name as its common name, which OpenVPN and IKEv2 accept; the certificate is the entry's
userCertificate, and the PKI keeps it with its key. A Manager-only account is an account,
simpleSecurityObject and vpnRouterAccount (the package's schema) with a name, a password and 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 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 neither the first nor the last name is given, and can sign in to the web service. A service
@@ -68,7 +69,7 @@ PERSON = (('first_name', 'givenName'), ('last_name', 'sn'), ('email', 'mail'))
LABELS = (('display_name', 'displayName'), ('description', 'description')) LABELS = (('display_name', 'displayName'), ('description', 'description'))
#: The attributes read for a user. #: The attributes read for a user.
ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken', 'vpnRouterScope', ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken', 'vpnRouterScope',
'displayName', 'description'] 'displayName', 'description', 'userCertificate;binary']
#: The vpnRouterScope of a service account. #: The vpnRouterScope of a service account.
SERVICE_SCOPE = 'service' SERVICE_SCOPE = 'service'
#: The object classes of a VPN user and of a Manager-only account. #: The object classes of a VPN user and of a Manager-only account.
@@ -173,9 +174,6 @@ def dn_of(name):
return f'uid={name},{vpnrouter_ldap.USERS}' return f'uid={name},{vpnrouter_ldap.USERS}'
def certificate(name):
return vpnrouter_pki.user_files(name)
def entry(connection, name): def entry(connection, name):
"""The attributes of the user's entry; Failure with status 3 when there is none.""" """The attributes of the user's entry; Failure with status 3 when there is none."""
@@ -234,7 +232,7 @@ def summary(attributes):
'internal': service(attributes) and name in internal_accounts(), 'internal': service(attributes) and name in internal_accounts(),
'enabled': lock != LOCKED, 'enabled': lock != LOCKED,
'locked': lock not in (None, LOCKED) and locked_since(lock) > time.time() - vpnrouter_ldap.LOCKOUT_SECONDS, 'locked': lock not in (None, LOCKED) and locked_since(lock) > time.time() - vpnrouter_ldap.LOCKOUT_SECONDS,
'certificate': certificate(name)[0].is_file(), 'certificate': 'userCertificate;binary' in attributes,
'otp': 'oathTOTPToken' in attributes, 'otp': 'oathTOTPToken' in attributes,
} }
for key, attribute in PERSON + LABELS: for key, attribute in PERSON + LABELS:
@@ -468,9 +466,10 @@ def profile(connection, name):
cfg = vpnrouter.load_config() cfg = vpnrouter.load_config()
if not cfg['openvpn']['enabled']: if not cfg['openvpn']['enabled']:
raise Failure('OpenVPN is not enabled', 2) raise Failure('OpenVPN is not enabled', 2)
cert, key = certificate(name) credentials = vpnrouter_pki.user_credentials(name)
if not cert.is_file() or not key.is_file(): if credentials is None:
raise Failure(f'user {name} has no certificate') raise Failure(f'user {name} has no certificate')
cert, key = (value.decode() for value in credentials)
fqdn = cfg['wan']['local_fqdn'].strip() fqdn = cfg['wan']['local_fqdn'].strip()
text = '\n'.join([ text = '\n'.join([
'client', 'client',
@@ -486,8 +485,8 @@ def profile(connection, name):
'auth-nocache', 'auth-nocache',
'verb 3', 'verb 3',
'<ca>', extract_pem(vpnrouter_pki.OPENVPN_CA.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>', '<cert>', extract_pem(cert), '</cert>',
'<key>', key.read_text(encoding='utf-8').strip(), '</key>', '<key>', key.strip(), '</key>',
'', '',
]) ])
return {'name': name, 'profile': text} return {'name': name, 'profile': text}
+29 -13
View File
@@ -8,6 +8,8 @@ Pages:
/ the status for people: plain text for curl and wget, a styled page / the status for people: plain text for curl and wget, a styled page
(Tailwind CSS from a CDN) for a browser (Tailwind CSS from a CDN) for a browser
/status the status as JSON /status the status as JSON
/status/full
the whole status as JSON, whatever web.status_content says, for the Router Manager
/config the configuration without its secrets, and what the router resolved and /config the configuration without its secrets, and what the router resolved and
runs, with the installed Caddy and where it comes from, 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, /users the users in the router's directory, whether or not a P2S system is enabled: list,
@@ -40,7 +42,7 @@ Pages:
the databases of each PostgreSQL cluster on the router with their sizes, as JSON (see the databases of each PostgreSQL cluster on the router with their sizes, as JSON (see
postgresql_databases()); /config has the clusters' settings postgresql_databases()); /config has the clusters' settings
/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 /status/full, /config, /metrics/history, /postgresql, /users, /service-accounts, /groups, /site-to-site, /backup, /ca and /account need an HS256 JWT in an Authorization: Bearer header signed
with vpnrouter_auth.WEB_KEY (see vpnrouter_auth.verify). A token of /auth names a user in sub; 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 /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 token of vpn-router-token has the admin claim and names no user; it may call every path but
@@ -54,9 +56,9 @@ default the address the call came in on, is the last entry of its X-Forwarded-Fo
default, the overall status, the time and the client's address (its address as for the rate limit default, the overall status, the time and the client's address (its address as for the rate limit
below); standard also the package version, the installed version of vpn-router-manager below); standard also the package version, the installed version of vpn-router-manager
(manager_version, null when it is not installed) and the number of active and other services; full (manager_version, null when it is not installed) and the number of active and other services; full
whether each service runs, whether Docker is installed, and the state of each site-to-site connection, road-warrior whether each service runs, whether Docker is installed, how many of the site-to-site connections are established
(IPsec P2S), OpenVPN and WireGuard. No configuration data or key is shown, and no address but the (site_to_site: connections and established, null when swanctl cannot be asked), and road-warrior (IPsec P2S), OpenVPN
client's. and WireGuard. No configuration data, key or connection name is shown, and no address but the client's.
The service first initializes, without listening: it makes the signing key and the refresh The service first initializes, without listening: it makes the signing key and the refresh
tokens' database (vpnrouter_auth), retrying every INIT_RETRY_SECONDS until both are ready and tokens' database (vpnrouter_auth), retrying every INIT_RETRY_SECONDS until both are ready and
@@ -232,8 +234,9 @@ def collect(state):
} }
names = state.get('site_to_site') names = state.get('site_to_site')
if isinstance(names, list) and names: if isinstance(names, list) and names:
status['site_to_site'] = {'connections': {name: None if count is None else count > 0 counts = established_each(names).values()
for name, count in established_each(names).items()}} status['site_to_site'] = {'connections': len(names),
'established': None if None in counts else sum(1 for count in counts if count)}
if state.get('p2s'): if state.get('p2s'):
status['p2s'] = {'clients': established('road-warrior')} status['p2s'] = {'clients': established('road-warrior')}
if state.get('wireguard'): if state.get('wireguard'):
@@ -399,8 +402,9 @@ def text_of(status):
docker = status['docker'] docker = status['docker']
lines.append('Docker: not installed' if not docker['installed'] lines.append('Docker: not installed' if not docker['installed']
else f'Docker: installed, service {docker["service"]}, rules {docker["rules"]}') else f'Docker: installed, service {docker["service"]}, rules {docker["rules"]}')
for name, value in status.get('site_to_site', {}).get('connections', {}).items(): if 'site_to_site' in status:
lines.append(f'Site to site {name}: {"unknown" if value is None else "established" if value else "not established"}') established, connections = status['site_to_site']['established'], status['site_to_site']['connections']
lines.append(f'Site to site: {"unknown" if established is None else established} of {connections} established')
if 'p2s' in status: if 'p2s' in status:
clients = status['p2s']['clients'] clients = status['p2s']['clients']
lines.append(f'Road-warrior (IPsec P2S): {"unknown" if clients is None else f"{clients} connected"}') lines.append(f'Road-warrior (IPsec P2S): {"unknown" if clients is None else f"{clients} connected"}')
@@ -779,15 +783,18 @@ def backup(method, path, body):
def ca(method, path, body): def ca(method, path, body):
"""Answer a /ca request with vpnrouter_pki: the HTTP status and the JSON document. """Answer a /ca request with vpnrouter_pki: the HTTP status and the JSON document.
GET /ca {"settings", "templates", "status", "certificates"}: the pki section, GET /ca {"settings", "templates", "extended_key_usages", "status",
the templates with the built-in ones, the root, the issuing CAs, the "certificates"}: the pki section, the templates with the built-in
retired CAs and the warnings (vpnrouter_pki.status), and the ones, the extended key usages, each {oid, builtin}, the root, the
certificates issued, newest first 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 PUT /ca change any of the settings of the pki section
POST /ca/certificates issue a certificate: {"ca", "template", "csr"} answers {"serial", POST /ca/certificates issue a certificate: {"ca", "template", "csr"} answers {"serial",
"certificate", "chain"}; {"ca", "template", "subject", "sans", "certificate", "chain"}; {"ca", "template", "subject", "sans",
"password"} answers {"serial", "pfx"}, a PKCS#12 file in base64 "password"} answers {"serial", "pfx"}, a PKCS#12 file in base64
POST /ca/certificates/<serial>/revoke revoke a certificate: {"reason"?} POST /ca/certificates/<serial>/revoke revoke a certificate: {"reason"?}
GET /ca/bundle/<name> the PEM certificates a client trusts the CA with: {"name", "pem"}; for
root the root alone, for an issuing CA the issuing CA and the root
GET /ca/issuing/<name>/csr the CSR of an issuing CA that waits for the offline root, with the 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"} openssl extensions to sign it with: {"name", "csr", "extensions"}
PUT /ca/issuing/<name>/certificate take its certificate signed by the root: {"certificate"} PUT /ca/issuing/<name>/certificate take its certificate signed by the root: {"certificate"}
@@ -803,8 +810,12 @@ def ca(method, path, body):
route = (method, *parts[1:]) route = (method, *parts[1:])
try: try:
cfg = vpnrouter.load_config() cfg = vpnrouter.load_config()
if len(route) == 3 and route[:2] == ('GET', 'bundle'):
return 200, vpnrouter_pki.bundle(route[2])
if route == ('GET',): if route == ('GET',):
return 200, {'settings': cfg['pki'], 'templates': vpnrouter.pki_templates(cfg), return 200, {'settings': cfg['pki'], 'templates': vpnrouter.pki_templates(cfg),
'extended_key_usages': {name: {'oid': oid, 'builtin': name in vpnrouter.EXTENDED_KEY_USAGES}
for name, oid in vpnrouter.extended_key_usages(cfg).items()},
'status': vpnrouter_pki.status(cfg), 'certificates': vpnrouter_pki.certificates()} 'status': vpnrouter_pki.status(cfg), 'certificates': vpnrouter_pki.certificates()}
if route == ('PUT',): if route == ('PUT',):
settings = {option: body[option] for option in vpnrouter.SCHEMA['pki'] if option in body} settings = {option: body[option] for option in vpnrouter.SCHEMA['pki'] if option in body}
@@ -836,6 +847,8 @@ def ca(method, path, body):
return 400, {'error': str(exc)} return 400, {'error': str(exc)}
except vpnrouter_pki.Failure as failure: except vpnrouter_pki.Failure as failure:
return PKI_STATUS.get(failure.status, 500), {'error': str(failure)} return PKI_STATUS.get(failure.status, 500), {'error': str(failure)}
except ldap.LDAPError as exc:
return 500, {'error': f'the directory, which holds the PKI, failed: {exc}'}
except OSError as exc: except OSError as exc:
return 500, {'error': f'the PKI cannot be changed: {exc}'} return 500, {'error': f'the PKI cannot be changed: {exc}'}
except SystemExit as exc: except SystemExit as exc:
@@ -1036,7 +1049,7 @@ class Handler(BaseHTTPRequestHandler):
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') 'ca')
if path == f'/{name}' or path.startswith(f'/{name}/')), None) if path == f'/{name}' or path.startswith(f'/{name}/')), None)
protected = path in ('/config', '/metrics/history', '/postgresql') or directory is not None protected = path in ('/status/full', '/config', '/metrics/history', '/postgresql') or directory is not None
signing = path in ('/auth', '/auth/refresh', '/auth/logout') signing = path in ('/auth', '/auth/refresh', '/auth/logout')
state = current() if protected or signing else {} state = current() if protected or signing else {}
claims = self.claims(state['key']) if protected and state['key'] else None claims = self.claims(state['key']) if protected and state['key'] else None
@@ -1061,6 +1074,9 @@ class Handler(BaseHTTPRequestHandler):
elif protected and (refused := self.refusal(claims, path, payload)): elif protected and (refused := self.refusal(claims, path, payload)):
code, kind = refused[0], 'application/json' code, kind = refused[0], 'application/json'
body = json.dumps({'error': refused[1]}, indent=2).encode() + b'\n' body = json.dumps({'error': refused[1]}, indent=2).encode() + b'\n'
elif path == '/status/full' and self.command in ('GET', 'HEAD'):
document = shown(self.status(), 'full', self.client())
body, kind, code = json.dumps(document, indent=2).encode() + b'\n', 'application/json', 200
elif path == '/config' and self.command in ('GET', 'HEAD'): elif path == '/config' and self.command in ('GET', 'HEAD'):
body, kind, code = json.dumps(details(state), indent=2).encode() + b'\n', 'application/json', 200 body, kind, code = json.dumps(details(state), indent=2).encode() + b'\n', 'application/json', 200
elif path == '/postgresql' and self.command in ('GET', 'HEAD'): elif path == '/postgresql' and self.command in ('GET', 'HEAD'):
+8 -10
View File
@@ -1,13 +1,13 @@
#!/usr/bin/python3 #!/usr/bin/python3
"""Manage the router's PKI from the command line, with vpnrouter_pki. """Manage the router's PKI, which the router's directory holds, from the command line, with vpnrouter_pki.
status shows the root, the issuing CAs and the warnings; list the certificates the router issued. issue signs a CSR, 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 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 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, 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 root remove-key removes it from the router, root import-key puts it back, and root import-crl takes a root CRL signed
certificate before the root exists, and root import-crl takes a root CRL signed offline. maintain signs the stale CRLs offline. maintain signs the stale CRLs again; vpn-router-pki.service runs it daily. A change that needs
again; vpn-router-pki.service runs it daily. A change that needs vpn-router-setup restarts it without waiting. 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 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 JSON on standard output, and a failure is {"error": ...} there too. The exit status says what failed: 2 the request
@@ -22,7 +22,6 @@ commands and their options.
vpn-router-ca [--json] maintain vpn-router-ca [--json] maintain
vpn-router-ca [--json] ca csr NAME vpn-router-ca [--json] ca csr NAME
vpn-router-ca [--json] ca import NAME FILE 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 export-key --out FILE (passphrase on stdin)
vpn-router-ca [--json] root remove-key [--yes] 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-key FILE (passphrase on stdin, an empty line for none)
@@ -39,6 +38,8 @@ import sys
sys.path.insert(0, '/usr/lib/vpn-router') sys.path.insert(0, '/usr/lib/vpn-router')
import ldap
import vpnrouter import vpnrouter
import vpnrouter_pki import vpnrouter_pki
from vpnrouter_pki import Failure from vpnrouter_pki import Failure
@@ -125,10 +126,6 @@ def run(options):
document = vpnrouter_pki.import_ca(cfg, options.name, pathlib.Path(options.file).read_text(encoding='ascii')) document = vpnrouter_pki.import_ca(cfg, options.name, pathlib.Path(options.file).read_text(encoding='ascii'))
restart_setup() restart_setup()
return document, f'Issuing CA {options.name} has its certificate, which expires {document["expires"]}.' 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': if options.action == 'export-key':
data = vpnrouter_pki.export_root_key(secret()) data = vpnrouter_pki.export_root_key(secret())
out = pathlib.Path(options.out) out = pathlib.Path(options.out)
@@ -177,7 +174,6 @@ def main(argv):
importer.add_argument('file') importer.add_argument('file')
root = commands.add_parser('root', help='the root CA') root = commands.add_parser('root', help='the root CA')
root_actions = root.add_subparsers(dest='action', required=True, parser_class=Parser) 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 = 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') 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 = root_actions.add_parser('remove-key', help='remove the root key from the router')
@@ -193,6 +189,8 @@ def main(argv):
code, message = failure.status, str(failure) code, message = failure.status, str(failure)
except OSError as exc: except OSError as exc:
code, message = 1, str(exc) code, message = 1, str(exc)
except ldap.LDAPError as exc:
code, message = 1, f'the directory, which holds the PKI, failed: {exc}'
except SystemExit as exc: except SystemExit as exc:
code, message = 1, str(exc.code) code, message = 1, str(exc.code)
else: else:
@@ -92,6 +92,9 @@
"days": 397 "days": 397
} }
}, },
"extended_key_usages": {
"smartcard_logon": "1.3.6.1.4.1.311.20.2.2"
},
"root_key_warning": true "root_key_warning": true
}, },
"metrics": { "metrics": {