Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e1eb89f52 | ||
|
|
1825e1efb1 | ||
|
|
d648d294ec | ||
|
|
a245ec26cd | ||
|
|
7404ee88c4 | ||
|
|
448ce05b3a |
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,60 @@ 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.
|
||||||
|
|
||||||
|
## ADR-33: The web service opens a shell for the Router Manager over a WebSocket
|
||||||
|
|
||||||
|
Context: The Router Manager's Shell page gives an administrator a terminal on the router in the
|
||||||
|
browser, with xterm.js, the terminal of VS Code. A terminal needs a full-duplex channel, which
|
||||||
|
xterm.js expects as a WebSocket. The web service runs in a sandbox (`NoNewPrivileges`,
|
||||||
|
`ProtectSystem=strict`, `ProtectHome`) that a shell must not inherit, and as root, which a shell
|
||||||
|
in a browser should not be. A stolen session cookie or access token alone must not give a shell.
|
||||||
|
|
||||||
|
Decision: `/shell` of the web service is off until `web.shell` is set on the router; the Router
|
||||||
|
Manager cannot set it. The shell runs as `web.shell_user`, a system user other than root, which
|
||||||
|
reaches root through its sudoers entry. `POST /shell` signs the user in again with the password
|
||||||
|
and TOTP code and gives a one-time ticket valid 30 seconds; a WebSocket on `GET /shell` with that
|
||||||
|
ticket gets the shell. The WebSocket of RFC 6455 is implemented on the standard library inside the
|
||||||
|
web service's `http.server`, server side only, without extensions. `systemd-run --pty` starts the
|
||||||
|
shell in a transient unit with a PAM login session, outside the sandbox, and `setsid --ctty`
|
||||||
|
makes the terminal its controlling terminal so window sizes reach the shell. A shell idle for
|
||||||
|
`web.shell_idle_minutes` is closed, and the journal records every opening and end. The Router
|
||||||
|
Manager checks the session and the WebSocket's origin, and relays the bytes to the router after
|
||||||
|
both handshakes.
|
||||||
|
|
||||||
|
Consequences: Who can sign in as a member of `admins` and knows the password and the current code
|
||||||
|
can get a shell when the router allows one; a session alone cannot. The ticket travels in the URL
|
||||||
|
and appears in request logs, used up. The shell's sessions show up as `vpn-router-shell-<id>`
|
||||||
|
units and logind sessions of `web.shell_user`. The shell's traffic is as protected as the Router
|
||||||
|
Manager's: HTTPS to Caddy, plain HTTP on the internal network to the web service.
|
||||||
@@ -128,6 +128,9 @@ must have the type shown below; an unknown section or setting is an error.
|
|||||||
| `web` | `database` | string | | A PostgreSQL connection string for the refresh tokens; without it, the `vpn-router` database on this host that the web service creates |
|
| `web` | `database` | string | | A PostgreSQL connection string for the refresh tokens; without it, the `vpn-router` database on this host that the web service creates |
|
||||||
| `web` | `trusted_proxies` | list | the address the call came in on | Addresses whose `X-Forwarded-For` header names the client for the `/auth` rate limit and the Client IP of the status |
|
| `web` | `trusted_proxies` | list | the address the call came in on | Addresses whose `X-Forwarded-For` header names the client for the `/auth` rate limit and the Client IP of the status |
|
||||||
| `web` | `status_content` | string | `basic` | What `/` and `/status` show: `basic`, `standard` or `full` (see Status web service) |
|
| `web` | `status_content` | string | `basic` | What `/` and `/status` show: `basic`, `standard` or `full` (see Status web service) |
|
||||||
|
| `web` | `shell` | boolean | `false` | `/shell` opens a login shell of `shell_user` for a member of `admins` (see The shell) |
|
||||||
|
| `web` | `shell_user` | string | | The system user the shell runs as, not `root`; required by `shell` |
|
||||||
|
| `web` | `shell_idle_minutes` | integer | `15` | Minutes without a keystroke after which a shell is closed, 1 to 1440 |
|
||||||
| `https` | `enabled` | boolean | `true` | Caddy serves the status web service and the Router Manager over HTTPS for `wan.local_fqdn` |
|
| `https` | `enabled` | boolean | `true` | Caddy serves the status web service and the Router Manager over HTTPS for `wan.local_fqdn` |
|
||||||
| `https` | `email` | string | | The e-mail address of the Let's Encrypt account |
|
| `https` | `email` | string | | The e-mail address of the Let's Encrypt account |
|
||||||
| `https` | `web_port` | integer | `7444` | HTTPS port of the status web service |
|
| `https` | `web_port` | integer | `7444` | HTTPS port of the status web service |
|
||||||
@@ -144,6 +147,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 +206,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 +376,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 +417,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 +446,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 +466,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 +566,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 +648,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 +807,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 +855,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.
|
||||||
|
|
||||||
@@ -842,7 +868,7 @@ curl http://10.0.0.4:7081/status
|
|||||||
|
|
||||||
### Tokens and signing in
|
### Tokens and signing in
|
||||||
|
|
||||||
`/config`, `/users`, `/service-accounts`, `/groups`, `/site-to-site` and `/account` need a JWT in an `Authorization: Bearer` header, signed with
|
`/config`, `/users`, `/service-accounts`, `/groups`, `/site-to-site`, `/shell` and `/account` need a JWT in an `Authorization: Bearer` header, signed with
|
||||||
HS256 by the key the web service makes on its first run, `/etc/vpn-router/web.key` (root only).
|
HS256 by the key the web service makes on its first run, `/etc/vpn-router/web.key` (root only).
|
||||||
The key never leaves the router; while it does not exist these paths and `/auth` are not served
|
The key never leaves the router; while it does not exist these paths and `/auth` are not served
|
||||||
(404). A missing, expired or wrongly signed token, or one of another algorithm, gets 401 with
|
(404). A missing, expired or wrongly signed token, or one of another algorithm, gets 401 with
|
||||||
@@ -894,6 +920,39 @@ 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 shell
|
||||||
|
|
||||||
|
While `web.shell` is `true`, `/shell` gives a member of `admins` a login shell on the router, as
|
||||||
|
the system user `web.shell_user`, for the Router Manager's Shell page. It is off by default and
|
||||||
|
switched on and off on the router only:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vpn-config-edit set web.shell_user azureuser
|
||||||
|
vpn-config-edit set web.shell true
|
||||||
|
```
|
||||||
|
|
||||||
|
| Request | Body | Answer |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `POST /shell` | `{"password", "code"}`; `code` only for a user enrolled for TOTP | `{"ticket", "expires_in": 30}` |
|
||||||
|
| `GET /shell?ticket=<ticket>` | a WebSocket handshake | the terminal |
|
||||||
|
|
||||||
|
`POST /shell` needs an access token of `/auth`, not one of `vpn-router-token`, and signs its user
|
||||||
|
in again: a wrong password or code gets 403 and counts as a failed login. The ticket opens one
|
||||||
|
shell for the same user within 30 seconds. Over the WebSocket, binary messages of the client
|
||||||
|
are keystrokes, a text message `{"cols", "rows"}` sets the window size, and binary messages of
|
||||||
|
the router are the terminal's output. The shell is a PAM login session of `web.shell_user`, run
|
||||||
|
by `systemd-run` in a transient unit `vpn-router-shell-<id>`; sudo works as the user's sudoers
|
||||||
|
entry allows. It is closed after `web.shell_idle_minutes` without a keystroke, when the shell
|
||||||
|
exits, or when the connection closes; the router then sends a close frame with the reason. The
|
||||||
|
journal of `vpn-router-web` records who opened each shell, from which address, in which unit,
|
||||||
|
when it ended and why.
|
||||||
|
|
||||||
### 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 +1115,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 +1129,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 +1327,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 +1434,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,81 @@
|
|||||||
|
vpn-router (0.23.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
feature: web.shell lets /shell of the web service open a login shell of web.shell_user over a WebSocket for the Router Manager's Shell page, after the user signs in again.
|
||||||
|
|
||||||
|
* web.shell (false), web.shell_user and web.shell_idle_minutes (15).
|
||||||
|
* POST /shell takes the password and the TOTP code and answers a
|
||||||
|
one-time ticket; GET /shell with it is a WebSocket carrying the
|
||||||
|
terminal, closed after web.shell_idle_minutes without a keystroke.
|
||||||
|
* The journal of vpn-router-web records each shell opened and ended.
|
||||||
|
|
||||||
|
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 20:00:00 +0100
|
||||||
|
|
||||||
|
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.
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -51,12 +52,12 @@ SCHEMA = {
|
|||||||
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
|
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
|
||||||
'docker': {'mode': 'auto', 'exposed_ports': []},
|
'docker': {'mode': 'auto', 'exposed_ports': []},
|
||||||
'web': {'enabled': True, 'listen': DEFAULT_LISTEN, 'port': 7081, 'database': '', 'trusted_proxies': [],
|
'web': {'enabled': True, 'listen': DEFAULT_LISTEN, 'port': 7081, 'database': '', 'trusted_proxies': [],
|
||||||
'status_content': 'basic'},
|
'status_content': 'basic', 'shell': False, 'shell_user': '', 'shell_idle_minutes': 15},
|
||||||
'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': {},
|
||||||
'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 = {
|
||||||
@@ -179,6 +194,12 @@ CA_NAME_MAX = 64
|
|||||||
#: What / and /status of the web service show, web.status_content, from the least to the most.
|
#: What / and /status of the web service show, web.status_content, from the least to the most.
|
||||||
STATUS_CONTENTS = ('basic', 'standard', 'full')
|
STATUS_CONTENTS = ('basic', 'standard', 'full')
|
||||||
|
|
||||||
|
#: A system user the shell of the web service runs as, web.shell_user; root is refused.
|
||||||
|
SHELL_USER = re.compile(r'^[a-z_][a-z0-9_-]{0,31}$')
|
||||||
|
|
||||||
|
#: The longest web.shell_idle_minutes, a day.
|
||||||
|
SHELL_IDLE_MAX = 1440
|
||||||
|
|
||||||
DOCKER_MODES = ('auto', 'on', 'off')
|
DOCKER_MODES = ('auto', 'on', 'off')
|
||||||
|
|
||||||
OPENVPN_PROTOCOLS = ('udp', 'tcp')
|
OPENVPN_PROTOCOLS = ('udp', 'tcp')
|
||||||
@@ -462,6 +483,15 @@ def validate(cfg):
|
|||||||
raise ConfigError(
|
raise ConfigError(
|
||||||
f'web.status_content: {status_content!r} is not one of {", ".join(STATUS_CONTENTS)}')
|
f'web.status_content: {status_content!r} is not one of {", ".join(STATUS_CONTENTS)}')
|
||||||
|
|
||||||
|
shell_user = _get(cfg, 'web', 'shell_user')
|
||||||
|
if shell_user and (not SHELL_USER.match(shell_user) or shell_user == 'root'):
|
||||||
|
raise ConfigError(f'web.shell_user: {shell_user!r} is not a system user name other than root')
|
||||||
|
if _get(cfg, 'web', 'shell') and not shell_user:
|
||||||
|
raise ConfigError('web.shell needs web.shell_user, the system user the shell runs as')
|
||||||
|
shell_idle = _get(cfg, 'web', 'shell_idle_minutes')
|
||||||
|
if not 1 <= shell_idle <= SHELL_IDLE_MAX:
|
||||||
|
raise ConfigError(f'web.shell_idle_minutes: {shell_idle} is not between 1 and {SHELL_IDLE_MAX}')
|
||||||
|
|
||||||
https_ports = []
|
https_ports = []
|
||||||
for option in ('web_port', 'manager_port'):
|
for option in ('web_port', 'manager_port'):
|
||||||
port = _get(cfg, 'https', option)
|
port = _get(cfg, 'https', option)
|
||||||
@@ -501,6 +531,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 +554,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 +705,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 +1059,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 +1067,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,
|
||||||
@@ -32,6 +34,9 @@ Pages:
|
|||||||
/pki/<name>.crt, /pki/<name>.crl
|
/pki/<name>.crt, /pki/<name>.crl
|
||||||
the DER certificate and CRL of the root (root) and of each issuing CA, which the
|
the DER certificate and CRL of the root (root) and of each issuing CA, which the
|
||||||
certificates name in their AIA and CRL distribution points; no authentication
|
certificates name in their AIA and CRL distribution points; no authentication
|
||||||
|
/shell while web.shell is true, a login shell of web.shell_user on a terminal: POST signs the
|
||||||
|
user in again for a one-time ticket (see shell_ticket()), GET with it is a WebSocket
|
||||||
|
that carries the terminal (see Handler.shell())
|
||||||
/auth sign-in with a user's password and TOTP code, refresh and sign-out (see auth())
|
/auth sign-in with a user's password and TOTP code, refresh and sign-out (see auth())
|
||||||
/metrics/history
|
/metrics/history
|
||||||
the metrics the metrics service keeps, for a view: hour, day, month or year (see
|
the metrics the metrics service keeps, for a view: hour, day, month or year (see
|
||||||
@@ -40,7 +45,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, /shell 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 +59,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
|
||||||
@@ -74,13 +79,23 @@ key, the configuration, and the features of the router and the facts shown by /c
|
|||||||
the router has no features, and /config shows no configuration or interfaces.
|
the router has no features, and /config shows no configuration or interfaces.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
import collections
|
import collections
|
||||||
import configparser
|
import configparser
|
||||||
|
import fcntl
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import pwd
|
||||||
|
import secrets
|
||||||
|
import select
|
||||||
import shutil
|
import shutil
|
||||||
import signal
|
import signal
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import termios
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
@@ -142,6 +157,18 @@ PKI_STATUS = {2: 400, 3: 404, 4: 409}
|
|||||||
#: A file /pki/ serves: the certificate or the CRL of a CA, and its content type.
|
#: A file /pki/ serves: the certificate or the CRL of a CA, and its content type.
|
||||||
PUBLISHED = {'.crt': 'application/pkix-cert', '.crl': 'application/pkix-crl'}
|
PUBLISHED = {'.crt': 'application/pkix-cert', '.crl': 'application/pkix-crl'}
|
||||||
|
|
||||||
|
#: Seconds a ticket of POST /shell may open a shell.
|
||||||
|
SHELL_TICKET_SECONDS = 30
|
||||||
|
|
||||||
|
#: The largest WebSocket message accepted from a client, in bytes.
|
||||||
|
MESSAGE_LIMIT = 65536
|
||||||
|
|
||||||
|
#: The GUID of RFC 6455 that the Sec-WebSocket-Accept of a handshake is made with.
|
||||||
|
WEBSOCKET_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
||||||
|
|
||||||
|
#: WebSocket opcodes.
|
||||||
|
TEXT, BINARY, CLOSE, PING, PONG = 1, 2, 8, 9, 10
|
||||||
|
|
||||||
_cache = {'at': 0.0, 'data': None}
|
_cache = {'at': 0.0, 'data': None}
|
||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
|
|
||||||
@@ -149,6 +176,10 @@ _lock = threading.Lock()
|
|||||||
_auth_calls = collections.defaultdict(collections.deque)
|
_auth_calls = collections.defaultdict(collections.deque)
|
||||||
_auth_lock = threading.Lock()
|
_auth_lock = threading.Lock()
|
||||||
|
|
||||||
|
#: The unused tickets of POST /shell: ticket -> (user, monotonic time of expiry).
|
||||||
|
_shell_tickets = {}
|
||||||
|
_shell_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def read_json(path):
|
def read_json(path):
|
||||||
"""The object in a JSON file, or {} when the file is missing or holds no object."""
|
"""The object in a JSON file, or {} when the file is missing or holds no object."""
|
||||||
@@ -232,8 +263,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 +431,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 +812,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 +839,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 +876,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:
|
||||||
@@ -979,6 +1021,68 @@ def auth(path, body):
|
|||||||
return 500, {'error': str(exc.code)}
|
return 500, {'error': str(exc.code)}
|
||||||
|
|
||||||
|
|
||||||
|
def shell_ticket(name, body):
|
||||||
|
"""Answer POST /shell: the HTTP status and the JSON document.
|
||||||
|
|
||||||
|
The user named by the token signs in again with {"password", "code"?}; a wrong password or code is 403 and counts
|
||||||
|
as a failed login. The answer is {"ticket", "expires_in"}: a ticket that opens one shell (GET /shell) for the
|
||||||
|
same user within SHELL_TICKET_SECONDS. 403 while web.shell is false.
|
||||||
|
"""
|
||||||
|
if not isinstance(name, str):
|
||||||
|
return 403, {'error': 'the shell needs a token of a signed-in user'}
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
return 400, {'error': 'the body must be a JSON object'}
|
||||||
|
try:
|
||||||
|
if not vpnrouter.load_config()['web']['shell']:
|
||||||
|
return 403, {'error': 'the shell is disabled; vpn-config-edit set web.shell true enables it'}
|
||||||
|
vpnrouter_users.login(name, body.get('password'), body.get('code'))
|
||||||
|
except vpnrouter_users.Failure as failure:
|
||||||
|
if failure.status == 5:
|
||||||
|
return 403, {'error': 'the password or the code is wrong'}
|
||||||
|
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||||
|
except (ldap.LDAPError, OSError) as exc:
|
||||||
|
return 500, {'error': f'the user directory failed: {exc}'}
|
||||||
|
except SystemExit as exc:
|
||||||
|
return 500, {'error': str(exc.code)}
|
||||||
|
ticket = secrets.token_urlsafe(32)
|
||||||
|
now = time.monotonic()
|
||||||
|
with _shell_lock:
|
||||||
|
for unused in [key for key, (_, expires) in _shell_tickets.items() if expires <= now]:
|
||||||
|
del _shell_tickets[unused]
|
||||||
|
_shell_tickets[ticket] = (name, now + SHELL_TICKET_SECONDS)
|
||||||
|
return 200, {'ticket': ticket, 'expires_in': SHELL_TICKET_SECONDS}
|
||||||
|
|
||||||
|
|
||||||
|
def take_ticket(name, ticket):
|
||||||
|
"""Whether the ticket of POST /shell is unexpired and was given to the user; it is used up either way."""
|
||||||
|
with _shell_lock:
|
||||||
|
holder, expires = _shell_tickets.pop(ticket, (None, 0.0))
|
||||||
|
return holder == name and expires > time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
def shell_command(account, unit):
|
||||||
|
"""The command that runs the login shell of the system user account in the transient unit, on its terminal.
|
||||||
|
|
||||||
|
setsid makes the terminal the controlling one of systemd-run, which passes the window size on; systemd-run starts
|
||||||
|
the shell outside the web service's sandbox, in a PAM login session of the user.
|
||||||
|
"""
|
||||||
|
return ['setsid', '--ctty', 'systemd-run', '--quiet', '--collect', '--pty', '--send-sighup', f'--unit={unit}',
|
||||||
|
f'--uid={account.pw_name}', '--property=PAMName=login', f'--working-directory={account.pw_dir}',
|
||||||
|
'--setenv=TERM=xterm-256color', account.pw_shell or '/bin/sh', '-l']
|
||||||
|
|
||||||
|
|
||||||
|
def frame(opcode, payload):
|
||||||
|
"""A final, unmasked WebSocket frame of a server."""
|
||||||
|
size = len(payload)
|
||||||
|
if size < 126:
|
||||||
|
length = bytes([size])
|
||||||
|
elif size < 65536:
|
||||||
|
length = bytes([126]) + size.to_bytes(2, 'big')
|
||||||
|
else:
|
||||||
|
length = bytes([127]) + size.to_bytes(8, 'big')
|
||||||
|
return bytes([0x80 | opcode]) + length + payload
|
||||||
|
|
||||||
|
|
||||||
def auth_wait(address):
|
def auth_wait(address):
|
||||||
"""Seconds until the client address may call /auth again, 0 when it may now; a call that may is counted."""
|
"""Seconds until the client address may call /auth again, 0 when it may now; a call that may is counted."""
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
@@ -1026,6 +1130,161 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return 500, f'the user directory failed: {exc}'
|
return 500, f'the user directory failed: {exc}'
|
||||||
return (403, reason) if reason else None
|
return (403, reason) if reason else None
|
||||||
|
|
||||||
|
def exact(self, size):
|
||||||
|
"""The next size bytes from the client; ConnectionError when it closes first."""
|
||||||
|
data = self.rfile.read(size)
|
||||||
|
if len(data) != size:
|
||||||
|
raise ConnectionError('the client closed the connection')
|
||||||
|
return data
|
||||||
|
|
||||||
|
def message(self, send):
|
||||||
|
"""The opcode and the payload of the client's next WebSocket data message, or CLOSE and its payload.
|
||||||
|
|
||||||
|
Fragments are joined and unmasked; a ping is answered with send, a pong ignored. ValueError for an unmasked
|
||||||
|
frame or a message over MESSAGE_LIMIT.
|
||||||
|
"""
|
||||||
|
opcode, payload = None, b''
|
||||||
|
while True:
|
||||||
|
head = self.exact(2)
|
||||||
|
code, size = head[0] & 0x0F, head[1] & 0x7F
|
||||||
|
if not head[1] & 0x80:
|
||||||
|
raise ValueError('a frame of the client is not masked')
|
||||||
|
if size == 126:
|
||||||
|
size = int.from_bytes(self.exact(2), 'big')
|
||||||
|
elif size == 127:
|
||||||
|
size = int.from_bytes(self.exact(8), 'big')
|
||||||
|
if len(payload) + size > MESSAGE_LIMIT:
|
||||||
|
raise ValueError(f'a message is larger than {MESSAGE_LIMIT} bytes')
|
||||||
|
mask = self.exact(4) * (size // 4 + 1)
|
||||||
|
data = bytes(byte ^ key for byte, key in zip(self.exact(size), mask))
|
||||||
|
if code == PING:
|
||||||
|
send(PONG, data)
|
||||||
|
continue
|
||||||
|
if code == PONG:
|
||||||
|
continue
|
||||||
|
if code == CLOSE:
|
||||||
|
return CLOSE, data
|
||||||
|
opcode = code if opcode is None else opcode
|
||||||
|
payload += data
|
||||||
|
if head[0] & 0x80:
|
||||||
|
return opcode, payload
|
||||||
|
|
||||||
|
def shell(self, name):
|
||||||
|
"""Answer GET /shell?ticket=<ticket>, a WebSocket handshake, with a login shell of web.shell_user on a terminal.
|
||||||
|
|
||||||
|
The ticket must come from POST /shell for the user named by the token. Binary messages of the client are its
|
||||||
|
keystrokes, a text message {"cols", "rows"} sets the window size, and binary messages of the server are the
|
||||||
|
terminal's output. The shell ends after web.shell_idle_minutes without a keystroke, when it exits, or when the
|
||||||
|
client closes the connection; the server then closes it with the reason. Each opening and end is logged.
|
||||||
|
"""
|
||||||
|
def refuse(code, message):
|
||||||
|
body = json.dumps({'error': message}).encode() + b'\n'
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header('Content-Type', 'application/json')
|
||||||
|
self.send_header('Content-Length', str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
key = self.headers.get('Sec-WebSocket-Key', '').strip()
|
||||||
|
if self.headers.get('Upgrade', '').lower() != 'websocket' or 'upgrade' not in self.headers.get('Connection', '').lower() \
|
||||||
|
or self.headers.get('Sec-WebSocket-Version') != '13' or not key:
|
||||||
|
return refuse(400, 'GET /shell must be a WebSocket handshake')
|
||||||
|
query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)
|
||||||
|
if not take_ticket(name, query.get('ticket', [''])[0]):
|
||||||
|
return refuse(403, 'the ticket is not valid; POST /shell gives one')
|
||||||
|
try:
|
||||||
|
options = vpnrouter.load_config()['web']
|
||||||
|
except SystemExit as exc:
|
||||||
|
return refuse(500, str(exc.code))
|
||||||
|
if not options['shell']:
|
||||||
|
return refuse(403, 'the shell is disabled; vpn-config-edit set web.shell true enables it')
|
||||||
|
try:
|
||||||
|
account = pwd.getpwnam(options['shell_user'])
|
||||||
|
except KeyError:
|
||||||
|
return refuse(500, f'the system user {options["shell_user"]} of web.shell_user does not exist')
|
||||||
|
unit = f'vpn-router-shell-{secrets.token_hex(4)}'
|
||||||
|
master, terminal = os.openpty()
|
||||||
|
try:
|
||||||
|
process = subprocess.Popen(shell_command(account, unit), stdin=terminal, stdout=terminal, stderr=terminal,
|
||||||
|
env={'PATH': '/usr/sbin:/usr/bin:/sbin:/bin'})
|
||||||
|
except OSError as exc:
|
||||||
|
os.close(master)
|
||||||
|
return refuse(500, f'the shell cannot be started: {exc}')
|
||||||
|
finally:
|
||||||
|
os.close(terminal)
|
||||||
|
accept = base64.b64encode(hashlib.sha1((key + WEBSOCKET_GUID).encode()).digest()).decode()
|
||||||
|
self.send_response(101, 'Switching Protocols')
|
||||||
|
self.send_header('Upgrade', 'websocket')
|
||||||
|
self.send_header('Connection', 'Upgrade')
|
||||||
|
self.send_header('Sec-WebSocket-Accept', accept)
|
||||||
|
self.end_headers()
|
||||||
|
self.close_connection = True
|
||||||
|
client, started, idle = self.client(), time.monotonic(), options['shell_idle_minutes'] * 60
|
||||||
|
log(f'shell of {name} from {client} opened as {account.pw_name} in {unit}')
|
||||||
|
|
||||||
|
lock, ended, typed = threading.Lock(), threading.Event(), [started]
|
||||||
|
|
||||||
|
def send(opcode, payload):
|
||||||
|
with lock:
|
||||||
|
self.wfile.write(frame(opcode, payload))
|
||||||
|
|
||||||
|
def receive():
|
||||||
|
"""Pass the client's keystrokes and window sizes to the terminal until it closes the connection."""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
opcode, payload = self.message(send)
|
||||||
|
if opcode == CLOSE:
|
||||||
|
break
|
||||||
|
if opcode == BINARY:
|
||||||
|
os.write(master, payload)
|
||||||
|
typed[0] = time.monotonic()
|
||||||
|
elif opcode == TEXT:
|
||||||
|
size = json.loads(payload)
|
||||||
|
fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack('HHHH', int(size['rows']), int(size['cols']), 0, 0))
|
||||||
|
except (OSError, ValueError, KeyError, TypeError, struct.error):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
ended.set()
|
||||||
|
|
||||||
|
reader = threading.Thread(target=receive, name=f'{unit} input', daemon=True)
|
||||||
|
reader.start()
|
||||||
|
reason = 'the shell ended'
|
||||||
|
try:
|
||||||
|
while not ended.is_set():
|
||||||
|
left = typed[0] + idle - time.monotonic()
|
||||||
|
if left <= 0:
|
||||||
|
reason = f'no keystroke for {options["shell_idle_minutes"]} minutes'
|
||||||
|
break
|
||||||
|
if select.select([master], [], [], min(left, 1))[0]:
|
||||||
|
try:
|
||||||
|
output = os.read(master, 65536)
|
||||||
|
except OSError:
|
||||||
|
output = b''
|
||||||
|
if not output:
|
||||||
|
break
|
||||||
|
send(BINARY, output)
|
||||||
|
else:
|
||||||
|
reason = 'the client closed the connection'
|
||||||
|
except OSError:
|
||||||
|
reason = 'the connection failed'
|
||||||
|
run('systemctl', 'stop', unit)
|
||||||
|
try:
|
||||||
|
send(CLOSE, (1000).to_bytes(2, 'big') + reason.encode()[:120])
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
self.connection.shutdown(socket.SHUT_RDWR)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
reader.join()
|
||||||
|
os.close(master)
|
||||||
|
try:
|
||||||
|
process.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.kill()
|
||||||
|
process.wait()
|
||||||
|
log(f'shell of {name} from {client} in {unit} ended after {int(time.monotonic() - started)} seconds: {reason}')
|
||||||
|
|
||||||
def answer(self, send_body):
|
def answer(self, send_body):
|
||||||
path = self.path.split('?', 1)[0]
|
path = self.path.split('?', 1)[0]
|
||||||
extra = []
|
extra = []
|
||||||
@@ -1034,9 +1293,9 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if self.command not in ('GET', 'HEAD'):
|
if self.command not in ('GET', 'HEAD'):
|
||||||
self.close_connection = True
|
self.close_connection = True
|
||||||
directory = next((name for name in ('users', 'service-accounts', 'groups', 'account', 'site-to-site', 'backup',
|
directory = next((name for name in ('users', 'service-accounts', 'groups', 'account', 'site-to-site', 'backup',
|
||||||
'ca')
|
'ca', 'shell')
|
||||||
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 +1320,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'):
|
||||||
@@ -1069,6 +1331,12 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)
|
query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)
|
||||||
code, document = metrics_history(query.get('range', ['hour'])[0])
|
code, document = metrics_history(query.get('range', ['hour'])[0])
|
||||||
body, kind = json.dumps(document).encode() + b'\n', 'application/json'
|
body, kind = json.dumps(document).encode() + b'\n', 'application/json'
|
||||||
|
elif directory == 'shell' and (path, self.command) == ('/shell', 'GET'):
|
||||||
|
return self.shell(claims.get('sub'))
|
||||||
|
elif directory == 'shell':
|
||||||
|
code, document = shell_ticket(claims.get('sub'), payload) if (path, self.command) == ('/shell', 'POST') \
|
||||||
|
else (404, {'error': f'no route {self.command} {path}'})
|
||||||
|
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||||
elif directory == 'account':
|
elif directory == 'account':
|
||||||
code, document = account(claims['sub'], self.command, path, payload)
|
code, document = account(claims['sub'], self.command, path, payload)
|
||||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -61,7 +61,10 @@
|
|||||||
"port": 7081,
|
"port": 7081,
|
||||||
"database": "",
|
"database": "",
|
||||||
"trusted_proxies": [],
|
"trusted_proxies": [],
|
||||||
"status_content": "basic"
|
"status_content": "basic",
|
||||||
|
"shell": false,
|
||||||
|
"shell_user": "",
|
||||||
|
"shell_idle_minutes": 15
|
||||||
},
|
},
|
||||||
"https": {
|
"https": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -92,6 +95,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": {
|
||||||
|
|||||||
Reference in new issue
Block a user