Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d648d294ec | ||
|
|
a245ec26cd | ||
|
|
7404ee88c4 | ||
|
|
448ce05b3a | ||
|
|
e11c88f0de | ||
|
|
c19b195ea0 |
No files matched your search
@@ -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.
|
||||||
@@ -144,6 +144,8 @@ 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 |
|
||||||
| `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` |
|
||||||
| `metrics` | `port` | integer | `9464` | TCP port of `/metrics` |
|
| `metrics` | `port` | integer | `9464` | TCP port of `/metrics` |
|
||||||
@@ -201,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.
|
||||||
@@ -370,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
|
||||||
@@ -402,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.
|
||||||
|
|
||||||
@@ -431,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` |
|
||||||
|
|
||||||
@@ -450,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
|
||||||
@@ -506,7 +529,8 @@ expired.
|
|||||||
|
|
||||||
### Offline root
|
### Offline root
|
||||||
|
|
||||||
Keeping the root key off the router is recommended, not required. Export it, encrypted with a
|
Keeping the root key off the router is recommended, not required; while it is on the router,
|
||||||
|
`vpn-router-ca status` says so, unless `pki.root_key_warning` is `false`. Export it, encrypted with a
|
||||||
passphrase read from the first line of standard input, keep the file in a safe place, and remove
|
passphrase read from the first line of standard input, keep the file in a safe place, and remove
|
||||||
the key from the router:
|
the key from the router:
|
||||||
|
|
||||||
@@ -539,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
|
||||||
|
|
||||||
@@ -621,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
|
||||||
@@ -892,6 +916,12 @@ curl -H 'Content-Type: application/json' -d '{"name": "alice", "password": "s3cr
|
|||||||
curl -H "Authorization: Bearer $TOKEN" http://10.0.0.4:7081/config
|
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:
|
||||||
@@ -1054,11 +1084,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"}` |
|
||||||
@@ -1067,6 +1098,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`.
|
||||||
|
|
||||||
@@ -1264,12 +1296,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 |
|
||||||
|
|
||||||
@@ -1371,8 +1403,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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,79 @@
|
|||||||
|
vpn-router (0.22.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
feature: pki.extended_key_usages adds custom extended key usages by their OIDs, which templates give by name, and a template's policies put certificate policy OIDs into its certificates.
|
||||||
|
|
||||||
|
* pki.extended_key_usages maps a name to the OID of a custom extended
|
||||||
|
key usage; a template's extended_key_usage takes the built-in and the
|
||||||
|
custom names.
|
||||||
|
* The template option policies lists certificate policy OIDs, written
|
||||||
|
into a Certificate Policies extension.
|
||||||
|
* GET /ca answers extended_key_usages, each usage with its OID and
|
||||||
|
whether it is built in.
|
||||||
|
|
||||||
|
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 14:00:00 +0100
|
||||||
|
|
||||||
|
vpn-router (0.21.5-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
fix: /status/full of the status web service gives an authenticated client the whole status, whatever web.status_content says.
|
||||||
|
|
||||||
|
* /status/full takes the token of /config and answers the status
|
||||||
|
document as web.status_content full shows it.
|
||||||
|
|
||||||
|
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 12:00:00 +0100
|
||||||
|
|
||||||
|
vpn-router (0.21.4-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
fix: vpn-router-setup, vpn-router-ca and /ca read a CA's key and CRL from the directory each as itself, instead of failing on a CA with a CRL.
|
||||||
|
|
||||||
|
* A CA's CRL was read as its key and its key as its CRL, so every
|
||||||
|
operation on a CA with a CRL failed with "Could not deserialize key
|
||||||
|
data".
|
||||||
|
|
||||||
|
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 10:00:00 +0100
|
||||||
|
|
||||||
|
vpn-router (0.21.3-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
fix: the PKI is kept in ou=pki of the router's directory instead of files under /etc/vpn-router/pki, and the router's site-to-site certificate names only the identities its connections present, and /ca/bundle gives the certificate bundle of a CA.
|
||||||
|
|
||||||
|
* The CAs with their certificates, keys, CSRs and CRLs, and every
|
||||||
|
certificate issued with its revocation, are entries of the
|
||||||
|
package's schema in ou=pki; a user's certificate is also the
|
||||||
|
userCertificate of the user's entry, and only root reads a key.
|
||||||
|
* slapcat puts the PKI into every backup with the users.
|
||||||
|
* A root CA supplied as /etc/vpn-router/pki/root/cert.pem and key.pem
|
||||||
|
before the first setup is imported into the directory, and the files
|
||||||
|
are removed; vpn-router-ca root import-cert is gone.
|
||||||
|
* Setup provisions the directory before the configuration; without it,
|
||||||
|
the PKI and its files are left as they are.
|
||||||
|
* The router's site-to-site certificate names wan.local_fqdn and the
|
||||||
|
public or internal address a connection with auth certificate uses
|
||||||
|
as its identity, not every address of the router, and is issued
|
||||||
|
again when those names change.
|
||||||
|
* GET /ca/bundle/<name> of the status web service answers the PEM
|
||||||
|
certificates a client trusts a CA with: the root alone, or an issuing
|
||||||
|
CA followed by the root.
|
||||||
|
|
||||||
|
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 01 Nov 2026 10:00:00 +0100
|
||||||
|
|
||||||
|
vpn-router (0.21.2-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
fix: pki.root_key_warning silences the warning of vpn-router-ca status and /ca about the root key on the router.
|
||||||
|
|
||||||
|
* pki.root_key_warning, true by default, set to false silences the
|
||||||
|
warning that the root key is on the router.
|
||||||
|
|
||||||
|
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 31 Oct 2026 10:00:00 +0100
|
||||||
|
|
||||||
|
vpn-router (0.21.1-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
fix: vpn-router-ca status and /ca warn about a CRL the router signs only once vpn-router-pki.timer has not renewed it in time.
|
||||||
|
|
||||||
|
* A CRL whose CA key is on the router is reported once past half of its
|
||||||
|
validity, when the timer should have signed it again; a root CRL
|
||||||
|
signed offline is still reported 30 days before it expires.
|
||||||
|
|
||||||
|
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 30 Oct 2026 10:00:00 +0100
|
||||||
|
|
||||||
vpn-router (0.21.0-1) unstable; urgency=medium
|
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.
|
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.
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -55,7 +56,8 @@ SCHEMA = {
|
|||||||
'https': {'enabled': True, 'email': '', 'web_port': 7444, 'manager_port': 7443, 'caddy_source': 'ubuntu'},
|
'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',
|
'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': {},
|
||||||
|
'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',
|
||||||
@@ -134,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')
|
||||||
@@ -146,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 = {
|
||||||
@@ -500,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):
|
||||||
@@ -515,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:
|
||||||
@@ -663,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."""
|
||||||
@@ -1010,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)
|
||||||
@@ -1017,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}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -779,15 +781,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 +808,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 +845,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 +1047,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 +1072,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'):
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -91,7 +91,11 @@
|
|||||||
"server": {
|
"server": {
|
||||||
"days": 397
|
"days": 397
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"extended_key_usages": {
|
||||||
|
"smartcard_logon": "1.3.6.1.4.1.311.20.2.2"
|
||||||
|
},
|
||||||
|
"root_key_warning": true
|
||||||
},
|
},
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
Reference in new issue
Block a user