Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9255d56276 | ||
|
|
07dbe91d0c | ||
|
|
ec93bc6a2d | ||
|
|
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
|
||||
`/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
|
||||
file, so two changes must not run at the same time. The web unit is sandboxed with
|
||||
passwords and person attributes, and both change the directory and the PKI, whose CRL numbers and
|
||||
CRLs two changes must not write at the same time. The web unit is sandboxed with
|
||||
`ProtectSystem=strict`.
|
||||
|
||||
Decision: `vpnrouter_users.py` holds the user operations, the checks and a session that takes a
|
||||
@@ -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
|
||||
operations need: `/run/vpn-router` for the lock, its `RuntimeDirectory`, which systemd creates
|
||||
before the service starts, also before setup has run, and keeps when it stops, as setup's state
|
||||
files are there too; `/etc/vpn-router/pki` for the certificates and the PKI's state, and
|
||||
`/etc/swanctl/x509crl`, `/etc/openvpn/server` and `/var/lib/vpn-router/pki-publish` for the
|
||||
CRLs a revocation signs. The PKI module, `vpnrouter_pki.py`, works the same way for `/ca`,
|
||||
files are there too, and `/etc/swanctl/x509crl`, `/etc/openvpn/server` and
|
||||
`/var/lib/vpn-router/pki-publish` for the copies of the CRLs a revocation signs; the PKI itself is
|
||||
in the directory (ADR-32). The PKI module, `vpnrouter_pki.py`, works the same way for `/ca`,
|
||||
`vpn-router-ca` and `vpn-router-setup`, and its lock is the users' lock, as adding and removing
|
||||
a user issue and revoke a certificate.
|
||||
|
||||
@@ -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
|
||||
CA. The module `vpnrouter_pki.py`, with `python3-cryptography` from the Ubuntu archive, builds
|
||||
the keys, certificates, CSRs and CRLs in the processes that need them, as the user module does
|
||||
(ADR-6); the settings are the `pki` section of `vpn-router.json` and the state is
|
||||
`pki/state.json`. Keeping the root key off the router is recommended, not required: while it is
|
||||
(ADR-6); the settings are the `pki` section of `vpn-router.json`, and the CAs and the
|
||||
certificates are kept in the router's directory (ADR-32). Keeping the root key off the router is recommended, not required: while it is
|
||||
on the router a new issuing CA is signed at once, and while it is not the CA gets its key and a
|
||||
CSR, signed offline and imported with `vpn-router-ca` or `/ca`. The root key itself crosses
|
||||
neither the API nor the Manager. For a certificate of something other than the router, the router
|
||||
@@ -808,14 +808,17 @@ secret to distribute. Azure's VPN gateway authenticates site-to-site connections
|
||||
keys only.
|
||||
|
||||
Decision: A connection's `auth` is `psk`, the default, or `certificate`. With `certificate` the
|
||||
router presents one site-to-site certificate of its `s2s` CA, whose names are `wan.local_fqdn`,
|
||||
the public address and the internal address, so it fits every `local_id_mode`; the peer's
|
||||
router presents one site-to-site certificate of its `s2s` CA, whose names are `wan.local_fqdn` and
|
||||
the local identities the connections with `certificate` use, the public or the internal address of
|
||||
their `local_id_mode`, and no address no connection presents, such as an internal address the peers
|
||||
never see; the peer's
|
||||
certificate must name the connection's `id` and be issued by the router's `s2s` CA or, when the
|
||||
connection has `remote_ca`, by that CA, its PEM certificate kept in the connection and copied to
|
||||
`/etc/swanctl/x509ca/s2s-<name>.pem`. A peer without a PKI gets its certificate from the router.
|
||||
|
||||
Consequences: The connection Azure's gateway needs stays `psk`, so the platform writes nothing
|
||||
new. The router's site-to-site certificate is issued again when 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
|
||||
certificates name.
|
||||
|
||||
@@ -861,3 +864,88 @@ Manager shows which one is in effect.
|
||||
Consequences: The first setup run with HTTPS starts the installation and serves HTTPS on the run
|
||||
that follows it. The key of Caddy's repository is trusted as downloaded over HTTPS, the way Caddy's
|
||||
installation instructions do it. Going back to the archive's Caddy is a manual downgrade.
|
||||
|
||||
## ADR-32: The PKI is kept in the router's directory
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: The CAs, their keys and CRLs, the inventory of the certificates issued and their
|
||||
revocations, and the router's and the users' certificates and keys need a store. Files with a
|
||||
JSON state file cannot be searched, and are copied apart from the users the certificates belong
|
||||
to. The router runs slapd, which holds the users and is provisioned on every
|
||||
configured router (ADR-10), and PostgreSQL, which holds the refresh tokens and the metrics, may
|
||||
be another server (`web.database`) and is in a backup only with `backup.metrics`. RFC 4523
|
||||
defines `pkiCA` with `cACertificate` and `certificateRevocationList`, and `userCertificate`, which
|
||||
OpenLDAP's core schema includes.
|
||||
|
||||
Decision: The PKI is held in `ou=pki` of the router's directory. A CA is an entry of the package's
|
||||
class `vpnRouterAuthority` with `cACertificate` and `certificateRevocationList`, its key, its CSR,
|
||||
its CRL number, the source of the root CRL and the time it was retired; a certificate is an entry
|
||||
of `vpnRouterCertificate` named by its serial number under its CA, with `userCertificate`, its
|
||||
kind, template, names, expiry and revocation, and the key of a router or user certificate. A
|
||||
user's certificate is also the `userCertificate` of the user's entry. An ACL lets no account read
|
||||
`vpnRouterPrivateKey`; only root, the database's root DN, does, through the local socket. A
|
||||
retired CA keeps its entry, renamed, so its certificates stay in the inventory. A root supplied
|
||||
from outside, by cloud-init or by hand, is placed as files before the first setup with a platform,
|
||||
which imports and removes them. strongSwan, OpenVPN, Caddy and the system's trust store read files,
|
||||
so setup and every revocation write them from the directory. Setup provisions the directory before
|
||||
the PKI; without the directory it leaves the PKI and its files as they are and configures the rest.
|
||||
|
||||
Consequences: `slapcat` writes the users and the PKI into every backup in one consistent copy, and a
|
||||
restore brings both back together. The PKI depends on slapd, as the users already do, and not on
|
||||
PostgreSQL. The directory holds the CA keys, so it is as sensitive as the CA itself. The schema
|
||||
grows by the package's PKI attributes and classes, which setup adds to slapd's configuration.
|
||||
|
||||
## 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 the console account of the signed-in user (ADR-34), so
|
||||
it is never root and each administrator's shell is the administrator's own; a user without
|
||||
console login gets none. `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, end and refusal. 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 and the user's console login is on; 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 the console account. 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.
|
||||
|
||||
## ADR-34: Console login maps an administrator to a POSIX account
|
||||
|
||||
Context: A shell, SSH and sudo need a POSIX account. One shared system account for every
|
||||
administrator hides who did what and shares one set of rights. The administrators are users of
|
||||
the router's directory, with their passwords, TOTP tokens and lockout there; a second set of
|
||||
local accounts would duplicate them. slapd listens on its local socket only. SSSD authenticates
|
||||
only over TLS or LDAPS and has no `ldapi://`; nslcd talks `ldapi://` and checks a password by
|
||||
binding as the user.
|
||||
|
||||
Decision: Console login is a property of a member of `admins`, `vpnRouterConsoleLogin`, that
|
||||
only root on the router sets, as for the group `admins`. It runs the console as an existing local
|
||||
account (`vpnRouterConsoleAccount`), or as the user's own POSIX account: the user's entry becomes a
|
||||
`posixAccount` with a uid from 20000 up and the local group `vpn-router-console` as its group.
|
||||
nslcd serves the POSIX accounts with console login on, and PAM checks their passwords with a bind,
|
||||
so slapd's otp overlay and password policy apply as for the Router Manager: the password followed
|
||||
by the code, and the lockout. Members of `vpn-router-console` get sudo with that password. SSH
|
||||
accepts it only while `ssh.ldap_login` is set, in a `Match Group` drop-in of sshd. Switching console
|
||||
login off keeps the `posixAccount`, hidden by nslcd's filter, so the uid and the home stay the
|
||||
user's; leaving `admins` switches it off.
|
||||
|
||||
Consequences: Each administrator's shell, SSH session and sudo run as the administrator, logged
|
||||
under the administrator's name. The directory becomes part of the system's name service through
|
||||
nslcd, and the system knows only the accounts with console login on. A user enrolled for TOTP types
|
||||
the code after the password at the SSH and sudo prompts. Homes are created at the first login and
|
||||
are not removed with the user. A password is changed through the directory, not with `passwd`.
|
||||
@@ -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` | `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` | `shell` | boolean | `false` | `/shell` opens a login shell for a member of `admins` with console login (see The shell) |
|
||||
| `web` | `shell_idle_minutes` | integer | `15` | Minutes without a keystroke after which a shell is closed, 1 to 1440 |
|
||||
| `ssh` | `ldap_login` | boolean | `false` | sshd accepts the directory password of the console accounts in the directory (see Console login and SSH) |
|
||||
| `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` | `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` | `issuing_cas` | object | | The general-purpose issuing CAs, each a name and `{name, templates, acme}` (see Certificates) |
|
||||
| `pki` | `templates` | object | | Certificate templates, each a name and the options it changes or defines (see Certificates) |
|
||||
| `pki` | `extended_key_usages` | object | | Custom extended key usages templates may give, each a name and its OID in dotted form (see Certificates) |
|
||||
| `pki` | `root_key_warning` | boolean | `true` | `vpn-router-ca status` and `/ca` warn while the root key is on the router; `false` silences that warning |
|
||||
| `metrics` | `enabled` | boolean | `true` | The metrics service: collection, history and `/metrics` |
|
||||
| `metrics` | `listen` | string | `internal` | The interfaces `/metrics` is served on: `internal`, `external` or `both` |
|
||||
@@ -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.
|
||||
|
||||
With `auth` `certificate`, the router authenticates with its site-to-site certificate, which the
|
||||
site-to-site issuing CA signs for `wan.local_fqdn`, the public address and the internal
|
||||
address, so it fits every `local_id_mode`. The peer's certificate must name `id` and be issued
|
||||
site-to-site issuing CA signs for `wan.local_fqdn` and for the public or the internal address
|
||||
that a connection with `auth` `certificate` uses as its identity (`local_id_mode` `public_ip` or
|
||||
`internal_ip`); it is issued again when that set of names changes. The peer's certificate must name `id` and be issued
|
||||
by the router's site-to-site CA, or by the CA of `remote_ca`. A peer with no PKI of its own gets
|
||||
its certificate from the router (template `site-to-site`, see Certificates), with the root and
|
||||
the site-to-site CA certificates as its trust anchors.
|
||||
@@ -371,18 +376,27 @@ services - that is what `systemctl restart vpn-router-setup` adds.
|
||||
|
||||
## Certificates
|
||||
|
||||
The router has a two-level certificate authority in `/etc/vpn-router/pki` (mode 0700), kept by
|
||||
`vpn-router-setup` and managed with `vpn-router-ca`, `vpn-router-user` and `/ca` of the status
|
||||
web service, which the Router Manager uses:
|
||||
The router has a two-level certificate authority, kept by `vpn-router-setup` and managed with
|
||||
`vpn-router-ca`, `vpn-router-user` and `/ca` of the status web service, which the Router Manager
|
||||
uses. It is held in `ou=pki,dc=vpn-router,dc=local` of the router's directory, next to the users:
|
||||
|
||||
```
|
||||
root/cert.pem root/key.pem root/crl.pem the root CA; key.pem is absent while the root is offline
|
||||
ca/<name>/cert.pem key.pem crl.pem csr.pem an issuing CA; csr.pem while it waits for the offline root
|
||||
router/vpn-server_cert.pem _key.pem the router's VPN server certificate
|
||||
router/site-to-site_cert.pem _key.pem the router's site-to-site certificate
|
||||
users/<user>_cert.pem _key.pem the users' certificates
|
||||
retired/<name>-<time>/ the issuing CAs taken out of use
|
||||
state.json the certificates issued, revocations and CRL numbers
|
||||
cn=root the root CA; without vpnRouterPrivateKey while the root is offline
|
||||
cn=<name> an issuing CA; with vpnRouterCertificateRequest while it waits for the offline root
|
||||
cn=<name>-<time> an issuing CA taken out of use, with vpnRouterRetired
|
||||
serialNumber=<serial>,cn=<CA> a certificate the CA issued
|
||||
```
|
||||
|
||||
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
|
||||
@@ -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
|
||||
neither certificate nor key, each issuing CA, the router's certificates once `wan.local_fqdn` is
|
||||
set, and the CRLs. The names, lifetimes and key type of `pki` apply when something is created. An
|
||||
issuing CA removed from `pki.issuing_cas` is retired: its directory moves to `retired/` and its
|
||||
certificate is revoked on the root CRL. The router renews its own certificates when less than a
|
||||
issuing CA removed from `pki.issuing_cas` is retired: its entry is renamed `cn=<name>-<time>`,
|
||||
loses its key, and its certificate is revoked on the root CRL. The router renews its own certificates when less than a
|
||||
third of their lifetime is left, when the names they need change, or when their CA changes; a
|
||||
user's certificate is renewed by removing and adding the user.
|
||||
|
||||
@@ -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 |
|
||||
| `days` | integer | `365` | Days the certificate is valid, at most until its CA's certificate expires |
|
||||
| `key_usage` | list | `digital_signature` | Of `digital_signature`, `content_commitment`, `key_encipherment`, `data_encipherment`, `key_agreement` |
|
||||
| `extended_key_usage` | list | | Of `server_auth`, `client_auth`, `code_signing`, `email_protection`, `time_stamping`, `ocsp_signing`, `ipsec_ike`, `ike_intermediate` |
|
||||
| `extended_key_usage` | list | | Of the built-in `server_auth`, `client_auth`, `code_signing`, `email_protection`, `time_stamping`, `ocsp_signing`, `ipsec_ike`, `ike_intermediate`, and the custom usages of `pki.extended_key_usages` |
|
||||
| `policies` | list | | Certificate policy OIDs in dotted form, written into a Certificate Policies extension |
|
||||
| `subject` | string | `name` | The common name: `dns`, a DNS name, which is also a subject alternative name; `name`, any name; `email`, an e-mail address |
|
||||
| `sans` | list | | The subject alternative names allowed: of `dns`, `ip`, `email` |
|
||||
|
||||
@@ -451,15 +466,25 @@ templates of its own, named like the issuing CAs:
|
||||
|
||||
```json
|
||||
"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": {
|
||||
"server": { "days": 397 },
|
||||
"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
|
||||
|
||||
```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
|
||||
use it, before choosing the platform: `vpn-router-ca root import-cert root-cert.pem`, or the files
|
||||
`root/cert.pem` and `root/key.pem`. Without the key, the issuing CAs wait for their certificates
|
||||
as above.
|
||||
use it, as `/etc/vpn-router/pki/root/cert.pem` and `key.pem` before `vpn-router-setup` first runs
|
||||
with a platform, as cloud-init can. Setup imports them into the directory and removes the files.
|
||||
Without the key, the issuing CAs wait for their certificates as above.
|
||||
|
||||
### ACME server
|
||||
|
||||
@@ -623,7 +648,7 @@ it is added:
|
||||
|
||||
- a VPN user, an `inetOrgPerson` with a first name, a last name and an e-mail address
|
||||
(`givenName`, `sn` and `mail`; `cn` is the first and the last name), and a client certificate
|
||||
with the user name as its common name, in `/etc/vpn-router/pki/users`;
|
||||
with the user name as its common name, the entry's `userCertificate`, whose key the PKI keeps;
|
||||
- a Manager-only account, an `account`, `simpleSecurityObject` and `vpnRouterAccount` with the
|
||||
name, the password and an optional e-mail address, for notifications: no names and no
|
||||
certificate, so no VPN access. It can sign in to `/auth` and, as a member of `admins`, manage
|
||||
@@ -680,6 +705,7 @@ vpn-router-user otp-remove alice
|
||||
| `otp-new <name>` | A new TOTP secret and its `otpauth://` URI, issued by `wan.local_fqdn`, for an authenticator app; nothing is stored |
|
||||
| `otp-enrol <name> <code>` | Stores the secret read from standard input when the code is a current code of it, replacing a secret the user has |
|
||||
| `otp-remove <name>` | Removes the user's TOTP secret |
|
||||
| `console <name> (--posix \| --account LOCAL \| --off)` | Switches the console login of a member of `admins` on, as the user's own POSIX account or a local account, or off (see Console login and SSH) |
|
||||
|
||||
A user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter; a
|
||||
password has 8 to 128 characters; a first or last name has 1 to 64 printable characters; an
|
||||
@@ -700,6 +726,38 @@ the file.
|
||||
The certificate is also accepted by road-warrior (IKEv2 EAP-TLS) access, which checks
|
||||
certificates only, so `disable` does not affect it; `remove` revokes the certificate for both.
|
||||
|
||||
### Console login and SSH
|
||||
|
||||
A member of `admins` may have console login: the Router Manager's Shell page (see The shell),
|
||||
and, for the user's own POSIX account, SSH and sudo. Only root on the router switches it, with
|
||||
`vpn-router-user console` or a token of `vpn-router-token`, as for the group `admins`:
|
||||
|
||||
```sh
|
||||
vpn-router-user console alice --posix
|
||||
vpn-router-user console bob --account azureuser
|
||||
vpn-router-user console alice --off
|
||||
vpn-config-edit set ssh.ldap_login true
|
||||
```
|
||||
|
||||
- **Own POSIX account** (`--posix`): the user's entry becomes a `posixAccount` of the directory
|
||||
with the user's name, a uid from 20000 up, the local group `vpn-router-console` as its group,
|
||||
`/home/<name>` and `/bin/bash`. `nslcd` makes it known to the system over slapd's local socket
|
||||
and checks its password by binding as the user, so a user enrolled for TOTP types the password
|
||||
followed by a current code, failed logins count towards the lockout, and a disabled user cannot
|
||||
sign in. The home is created at the first login and kept when the user is removed. Members of
|
||||
`vpn-router-console` may run any command with sudo, with the same password. A local account of
|
||||
the same name is refused.
|
||||
- **Local account** (`--account`): the console runs as an existing local account with a uid from
|
||||
1000 to 19999, such as the VM's admin user; its SSH keys and sudo rights stay as they are.
|
||||
- **Off** (`--off`): the system no longer knows the user's own POSIX account, and its sessions
|
||||
end; its uid is kept for the next time. Leaving `admins`, or removing or renaming the group,
|
||||
switches the console login of the members off.
|
||||
|
||||
While `ssh.ldap_login` is `true`, setup lets sshd accept the password of the members of
|
||||
`vpn-router-console` (`/etc/ssh/sshd_config.d/vpn-router.conf`); other accounts keep sshd's
|
||||
settings. A password change is made in the Router Manager or with `vpn-router-user passwd`, not
|
||||
with `passwd`.
|
||||
|
||||
### Managing groups
|
||||
|
||||
`vpn-router-group` manages the groups. A group has a name, with the rules of a user name, an
|
||||
@@ -782,7 +840,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 |
|
||||
| `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
|
||||
request from one of `web.trusted_proxies`, by default the address the request came in on, so
|
||||
@@ -830,8 +888,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,
|
||||
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`
|
||||
(the Docker rules), which do not change `status`; as `site_to_site.connections`, whether each
|
||||
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
|
||||
(the Docker rules), which do not change `status`; as `site_to_site`, the number of site-to-site
|
||||
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
|
||||
all these services are active, and `degraded` otherwise.
|
||||
|
||||
@@ -842,7 +901,7 @@ curl http://10.0.0.4:7081/status
|
||||
|
||||
### 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`, `/backup`, `/remote-access`, `/ca`, `/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).
|
||||
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
|
||||
@@ -894,6 +953,39 @@ curl -H 'Content-Type: application/json' -d '{"name": "alice", "password": "s3cr
|
||||
curl -H "Authorization: Bearer $TOKEN" http://10.0.0.4:7081/config
|
||||
```
|
||||
|
||||
### The /status/full page
|
||||
|
||||
`/status/full`, with the same token as `/config`, shows the whole status document, as `/status`
|
||||
does with `web.status_content` `full`, whatever that setting says, so the Router Manager shows
|
||||
every service and tunnel while the public status page shows less.
|
||||
|
||||
### The shell
|
||||
|
||||
While `web.shell` is `true`, `/shell` gives a member of `admins` whose console login is on (see
|
||||
Console login and SSH) a login shell on the router, as the user's console account, for the
|
||||
Router Manager's Shell page. It is off by default and switched on and off on the router only:
|
||||
|
||||
```sh
|
||||
vpn-router-user console alice --posix
|
||||
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; it gets 403 too while
|
||||
`web.shell` is `false` or the user's console login is off. 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 the console account,
|
||||
run by `systemd-run` in a transient unit `vpn-router-shell-<id>`; sudo works as for SSH. 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, and each refused ticket or shell with the reason.
|
||||
|
||||
### The /config page
|
||||
|
||||
`/config` shows what `/` and `/status` leave out, for a client such as manage-ui:
|
||||
@@ -925,7 +1017,7 @@ and answer bodies are JSON; a request body is at most 65536 bytes.
|
||||
|
||||
| Request | Body | Answer |
|
||||
| --- | --- | --- |
|
||||
| `GET /users` | | `{"users": [{"name", "manager_only", "service", "internal", "enabled", "locked", "certificate", "otp", "first_name", "last_name", "email"}, ...]}` |
|
||||
| `GET /users` | | `{"users": [{"name", "manager_only", "service", "internal", "enabled", "locked", "certificate", "otp", "console", "first_name", "last_name", "email"}, ...]}`; `console` is `null` or `{"enabled", "account", "local", "uid"}` |
|
||||
| `POST /users` | `{"name", "password", "first_name", "last_name", "email"}` for a VPN user; `{"name", "password", "email"?}` for a Manager-only account | 201 and the user |
|
||||
| `PUT /users/<name>` | any of `{"first_name", "last_name", "email"}`; for a Manager-only account only `{"email"}`, `""` removing it | the user |
|
||||
| `PUT /users/<name>/password` | `{"password": ...}` | the user |
|
||||
@@ -937,6 +1029,7 @@ and answer bodies are JSON; a request body is at most 65536 bytes.
|
||||
| `POST /users/<name>/otp` | | `{"name", "secret", "uri"}`, a new TOTP secret; nothing is stored |
|
||||
| `PUT /users/<name>/otp` | `{"secret", "code"}` | the user, once the code matches the secret |
|
||||
| `DELETE /users/<name>/otp` | | the user |
|
||||
| `PUT /users/<name>/console` | `{"enabled", "account"?}`; only with a token of `vpn-router-token` | the user |
|
||||
|
||||
A failure is `{"error": ...}` with 400 for an invalid request, 404 for a user that does not
|
||||
exist or is a service account, 409 for a user that exists and 500 otherwise. `/users` does not
|
||||
@@ -1049,6 +1142,23 @@ The answer of `GET /backup` holds:
|
||||
|
||||
A change is made, checked and applied as for `/site-to-site`.
|
||||
|
||||
### The /remote-access API
|
||||
|
||||
`/remote-access` shows and changes the remote access systems, IKEv2 road-warrior (`p2s`),
|
||||
OpenVPN (`openvpn`) and WireGuard (`wireguard`), with the same token as `/users`.
|
||||
|
||||
| Request | Body | Answer |
|
||||
| --- | --- | --- |
|
||||
| `GET /remote-access` | | `{"p2s", "openvpn", "wireguard", "running", "local_fqdn"}` |
|
||||
| `PUT /remote-access/<system>` | any of the settings of the section `p2s`, `openvpn` or `wireguard` | the section |
|
||||
|
||||
`running` tells for each system whether setup runs it. IKEv2 and OpenVPN run once enabled with an
|
||||
address pool, `wan.local_fqdn` set and the router's VPN server certificate issued by the P2S CA;
|
||||
WireGuard once enabled with an address. A system enabled without its address pool or address is
|
||||
refused. A change is made, checked and applied as for `/site-to-site`. The router's firewall
|
||||
follows the settings; a cloud firewall in front of the router, such as an Azure network security
|
||||
group, must allow the OpenVPN and WireGuard ports itself.
|
||||
|
||||
### The /ca API
|
||||
|
||||
`/ca` manages the PKI (see Certificates) with the same token as `/users`, with the module
|
||||
@@ -1056,11 +1166,12 @@ A change is made, checked and applied as for `/site-to-site`.
|
||||
|
||||
| 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"}` |
|
||||
| `POST /ca/certificates` | `{"ca", "template", "csr"}` | 201 and `{"serial", "certificate", "chain"}`, PEM |
|
||||
| `POST /ca/certificates` | `{"ca", "template", "subject", "sans", "password"}` | 201 and `{"serial", "pfx"}`, a PKCS#12 file in base64 |
|
||||
| `POST /ca/certificates/<serial>/revoke` | `{"reason"}`, optional | the certificate, with `reissue` for a router certificate |
|
||||
| `GET /ca/bundle/<name>` | | `{"name", "pem"}`: the certificates a client trusts the CA with, the root alone for `root`, the issuing CA followed by the root for an issuing CA |
|
||||
| `GET /ca/issuing/<name>/csr` | | `{"name", "csr", "extensions"}` of an issuing CA waiting for the offline root |
|
||||
| `PUT /ca/issuing/<name>/certificate` | `{"certificate"}`, PEM, signed by the root | `{"name", "subject", "expires"}` |
|
||||
| `PUT /ca/root/crl` | `{"crl"}`, PEM, signed by the root | `{"next_update"}` |
|
||||
@@ -1069,6 +1180,7 @@ The answer of `GET /ca` holds:
|
||||
|
||||
- **`settings`:** the `pki` section.
|
||||
- **`templates`:** every template, the built-in ones included, with all its options.
|
||||
- **`extended_key_usages`:** every extended key usage templates may give, name -> `{"oid", "builtin"}`.
|
||||
- **`status`:** `root` (`subject`, `expires`, `key`, whether the root key is on the router, `crl_next_update` and `crl_source`, `signed` or `uploaded`), `authorities`, one per issuing CA (`name`, `common_name`, `state`: `active`, `pending` or `missing`, `builtin`, `templates`, `acme`, `subject`, `expires`, `key`, `crl_next_update`, `acme_url`), `retired` and `warnings`.
|
||||
- **`certificates`:** the certificates issued, newest first, each `{"serial", "ca", "kind", "template", "subject", "sans", "created", "expires", "revoked", "reason", "state"}`; `kind` is `ca`, `router`, `user` or `external`, and `state` `valid`, `expired` or `revoked`.
|
||||
|
||||
@@ -1266,12 +1378,12 @@ A backup holds the state that `vpn-router-setup` cannot make again:
|
||||
|
||||
| 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/ufw`, `/etc/default/ufw` | The firewall rules |
|
||||
| `/etc/vpn-router-manager` | The Router Manager's settings, when it is installed |
|
||||
| `files/<path>` | Each `psk_file` outside `/etc/vpn-router` |
|
||||
| `ldap/vpn-router.ldif` | The user directory, written by `slapcat`: users, groups, service accounts, password hashes and TOTP secrets |
|
||||
| `ldap/vpn-router.ldif` | The user directory, written by `slapcat`: users, groups, service accounts, password hashes and TOTP secrets, and the PKI: the CAs and their keys, the root key while it is on the router, and the certificates issued, with the keys of the router's and the users' certificates |
|
||||
| `postgresql/vpn-router.dump` | With `backup.metrics`, the database of the metrics and the refresh tokens, written by `pg_dump -Fc` |
|
||||
| `manifest.json` | The host, the time, the version of the package, the schedule and the items |
|
||||
|
||||
@@ -1373,8 +1485,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
|
||||
record of the last backup, the published CA files, the copies of the ACME CAs in
|
||||
`/etc/caddy/vpn-router-pki` and OpenVPN's CA and CRL files. Caddy stays installed, and with it the
|
||||
source of Caddy's repository. Key material, users and backups are never deleted: `/etc/vpn-router/pki`,
|
||||
`/etc/vpn-router/ldap`, `/etc/vpn-router/rclone.conf`, the user directory in slapd, the WireGuard
|
||||
source of Caddy's repository. Key material, users and backups are never deleted:
|
||||
`/etc/vpn-router/ldap`, `/etc/vpn-router/rclone.conf`, the user directory in slapd with the PKI, the WireGuard
|
||||
key pair, everything under `/etc/swanctl`, the archives in `backup.path` and
|
||||
`/var/lib/vpn-router/backup/before-restore` are left alone.
|
||||
|
||||
|
||||
@@ -1,3 +1,122 @@
|
||||
vpn-router (0.24.0-1) unstable; urgency=medium
|
||||
|
||||
feature: /remote-access of the web service shows and changes the settings of IKEv2 road-warrior, OpenVPN and WireGuard access and whether each runs, for the Router Manager's Remote access page.
|
||||
|
||||
* GET /remote-access answers the p2s, openvpn and wireguard sections,
|
||||
which of them setup runs, and wan.local_fqdn; PUT
|
||||
/remote-access/<system> changes one section and restarts setup.
|
||||
* p2s.enabled and openvpn.enabled need their address_pool, and
|
||||
wireguard.enabled its address.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 23:00:00 +0100
|
||||
|
||||
vpn-router (0.23.2-1) unstable; urgency=medium
|
||||
|
||||
fix: the shell of a user's own POSIX account opens on the first setup that sets up console login, and the journal records each refused shell with the reason.
|
||||
|
||||
* Setup restarts vpn-router-web when it adds ldap to the name service
|
||||
switch, so the web service finds the POSIX accounts of the directory.
|
||||
* The journal of vpn-router-web records each refused ticket and shell,
|
||||
with the user, the address and the reason.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 22:00:00 +0100
|
||||
|
||||
vpn-router (0.23.1-1) unstable; urgency=medium
|
||||
|
||||
fix: the shell runs as the console account of each member of admins, a local account or the user's own POSIX account in the directory, which also signs in with SSH and sudo, instead of one web.shell_user.
|
||||
|
||||
* vpn-router-user console <name> --posix | --account LOCAL | --off, and
|
||||
PUT /users/<name>/console with a token of vpn-router-token, switch a
|
||||
member of admins' console login; leaving admins switches it off.
|
||||
* The user's own POSIX account is served by nslcd from the directory,
|
||||
with a uid from 20000, the group vpn-router-console, which may use
|
||||
sudo, and a home created at the first login; its password is the
|
||||
directory password, followed by the code once enrolled for TOTP.
|
||||
* ssh.ldap_login (false) lets sshd accept that password.
|
||||
* POST /shell and the shell need the user's console login; web.shell_user
|
||||
is gone.
|
||||
* Depends on nslcd, libnss-ldapd, libpam-ldapd and sudo.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 21:00:00 +0100
|
||||
|
||||
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
|
||||
|
||||
fix: pki.root_key_warning silences the warning of vpn-router-ca status and /ca about the root key on the router.
|
||||
|
||||
@@ -27,6 +27,10 @@ Depends: ${misc:Depends},
|
||||
python3-prometheus-client,
|
||||
postgresql,
|
||||
slapd,
|
||||
nslcd,
|
||||
libnss-ldapd,
|
||||
libpam-ldapd,
|
||||
sudo,
|
||||
openvpn,
|
||||
openvpn-auth-ldap,
|
||||
iproute2,
|
||||
|
||||
@@ -54,9 +54,13 @@ case "$1" in
|
||||
/etc/vpn-router/web.key \
|
||||
/etc/vpn-router/metrics.secret \
|
||||
/etc/systemd/system/vpn-router-backup.timer.d/schedule.conf \
|
||||
/etc/sudoers.d/vpn-router \
|
||||
/etc/ssh/sshd_config.d/vpn-router.conf \
|
||||
/var/lib/vpn-router/setup.done \
|
||||
/var/lib/vpn-router/backup.json
|
||||
rmdir --ignore-fail-on-non-empty /etc/systemd/system/vpn-router-backup.timer.d 2>/dev/null || true
|
||||
# The name service no longer looks up the console accounts of the router's directory.
|
||||
sed -i '/^passwd:/s/ ldap\b//' /etc/nsswitch.conf
|
||||
rm -rf /var/lib/vpn-router/backup/staging /var/lib/vpn-router/backup/restore \
|
||||
/var/lib/vpn-router/pki-publish /etc/caddy/vpn-router-pki \
|
||||
/etc/openvpn/server/vpn-router-ca.pem /etc/openvpn/server/vpn-router-crl.pem
|
||||
|
||||
@@ -83,6 +83,15 @@ CADDY_PKI_DIR = pathlib.Path('/etc/caddy/vpn-router-pki')
|
||||
#: The timer of vpn-router-ca maintain, which keeps the CRLs fresh.
|
||||
PKI_TIMER = 'vpn-router-pki.timer'
|
||||
|
||||
#: The system's side of console login (vpnrouter_users.console): nslcd's configuration, which serves the posixAccounts
|
||||
#: of the directory with console login on, the name service switch, the sudo rule of their group, and the drop-in of
|
||||
#: sshd that lets them sign in with their password while ssh.ldap_login is true.
|
||||
NSLCD_CONF = pathlib.Path('/etc/nslcd.conf')
|
||||
NSSWITCH = pathlib.Path('/etc/nsswitch.conf')
|
||||
SUDOERS = pathlib.Path('/etc/sudoers.d/vpn-router')
|
||||
SSHD_DROPIN = pathlib.Path('/etc/ssh/sshd_config.d/vpn-router.conf')
|
||||
PAM_SESSION = pathlib.Path('/etc/pam.d/common-session')
|
||||
|
||||
FILTER_ANCHOR = '# End required lines'
|
||||
|
||||
#: Configuration options left out wherever the configuration is shown.
|
||||
@@ -529,6 +538,93 @@ def manage_metrics_account(ctx):
|
||||
return ''
|
||||
|
||||
|
||||
def write_if_changed(path, text, mode=0o644):
|
||||
"""Write the file when its content differs; whether it was written."""
|
||||
try:
|
||||
if path.read_text(encoding='utf-8') == text:
|
||||
return False
|
||||
except OSError:
|
||||
pass
|
||||
path.write_text(text, encoding='utf-8')
|
||||
path.chmod(mode)
|
||||
return True
|
||||
|
||||
|
||||
def manage_console(cfg):
|
||||
"""Make the system serve console login; the problem, or ''.
|
||||
|
||||
The local group vpnrouter_ldap.CONSOLE_GROUP is created when missing, and sudo lets its members run any command
|
||||
with their password. nslcd serves the directory's posixAccounts with console login on through the local socket,
|
||||
and checks their passwords by binding as the user; the passwd line of the name service switch gains ldap, which
|
||||
restarts the web service, and PAM creates a home at the first login. While ssh.ldap_login is true, sshd accepts the password of the group's members;
|
||||
a drop-in sshd -t refuses is removed again.
|
||||
"""
|
||||
group = vpnrouter_ldap.CONSOLE_GROUP
|
||||
try:
|
||||
grp.getgrnam(group)
|
||||
except KeyError:
|
||||
result = run(['groupadd', '--system', group], check=False)
|
||||
if result.returncode != 0:
|
||||
return f'the group {group} cannot be created: {result.stderr.strip()}'
|
||||
|
||||
rule = f'%{group} ALL=(ALL:ALL) ALL\n'
|
||||
try:
|
||||
if not SUDOERS.is_file() or SUDOERS.read_text(encoding='utf-8') != rule:
|
||||
staged = SUDOERS.with_name('.vpn-router.new')
|
||||
staged.write_text(rule, encoding='utf-8')
|
||||
staged.chmod(0o440)
|
||||
checked = run(['visudo', '-cf', str(staged)], check=False)
|
||||
if checked.returncode != 0:
|
||||
staged.unlink()
|
||||
return f'the sudo rule of {group} is refused: {checked.stdout.strip()} {checked.stderr.strip()}'
|
||||
staged.replace(SUDOERS)
|
||||
|
||||
uri = 'ldapi://' + vpnrouter_ldap.SLAPD_SOCKET.replace('/', '%2f') + '/'
|
||||
nslcd = '\n'.join([
|
||||
'uid nslcd',
|
||||
'gid nslcd',
|
||||
f'uri {uri}',
|
||||
f'base {vpnrouter_ldap.BASE}',
|
||||
'filter passwd (&(objectClass=posixAccount)(vpnRouterConsoleLogin=TRUE))',
|
||||
f'nss_min_uid {vpnrouter_ldap.CONSOLE_UID_MIN}',
|
||||
'pam_authz_search (&(objectClass=posixAccount)(uid=$username)(vpnRouterConsoleLogin=TRUE))',
|
||||
'pam_password_prohibit_message The password is changed in the Router Manager or with vpn-router-user.',
|
||||
'',
|
||||
])
|
||||
if write_if_changed(NSLCD_CONF, nslcd, 0o640) or not unit_is_active('nslcd'):
|
||||
os.chown(NSLCD_CONF, 0, grp.getgrnam('nslcd').gr_gid)
|
||||
run(['systemctl', 'enable', 'nslcd'], check=False)
|
||||
run(['systemctl', 'restart', 'nslcd'], check=False)
|
||||
|
||||
lines = NSSWITCH.read_text(encoding='utf-8').splitlines(keepends=True)
|
||||
switched = [line.rstrip('\n') + ' ldap\n' if line.startswith('passwd:') and 'ldap' not in line.split() else line
|
||||
for line in lines]
|
||||
if switched != lines:
|
||||
NSSWITCH.write_text(''.join(switched), encoding='utf-8')
|
||||
# A running process keeps the name service switch it read first, so the web service's shell would not find
|
||||
# the console accounts of the directory until it restarts.
|
||||
run(['systemctl', 'try-restart', 'vpn-router-web.service'], check=False)
|
||||
|
||||
if 'pam_mkhomedir' not in PAM_SESSION.read_text(encoding='utf-8'):
|
||||
run(['pam-auth-update', '--enable', 'mkhomedir'], check=False)
|
||||
|
||||
if cfg['ssh']['ldap_login']:
|
||||
changed = write_if_changed(SSHD_DROPIN, f'Match Group {group}\n PasswordAuthentication yes\n')
|
||||
else:
|
||||
changed = SSHD_DROPIN.exists()
|
||||
SSHD_DROPIN.unlink(missing_ok=True)
|
||||
if changed:
|
||||
checked = run(['sshd', '-t'], check=False)
|
||||
if checked.returncode != 0:
|
||||
SSHD_DROPIN.unlink(missing_ok=True)
|
||||
return f'sshd refuses {SSHD_DROPIN}: {checked.stderr.strip()}'
|
||||
if unit_is_active('ssh'):
|
||||
run(['systemctl', 'reload', 'ssh'], check=False)
|
||||
except (OSError, KeyError) as exc:
|
||||
return f'console login cannot be set up: {exc}'
|
||||
return ''
|
||||
|
||||
|
||||
def manage_openvpn(ctx, changed):
|
||||
"""Run the OpenVPN server when it is configured, and stop it when it is not."""
|
||||
unit = 'openvpn-server@vpn-router'
|
||||
@@ -577,7 +673,12 @@ def manage_caddy(cfg, ctx):
|
||||
group = grp.getgrnam(CADDY_GROUP).gr_gid
|
||||
except KeyError:
|
||||
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
|
||||
for folder in (CADDY_PKI_DIR.iterdir() if CADDY_PKI_DIR.is_dir() else []):
|
||||
if folder.name not in acme:
|
||||
@@ -587,8 +688,8 @@ def manage_caddy(cfg, ctx):
|
||||
for path in (CADDY_PKI_DIR, folder):
|
||||
path.mkdir(mode=0o750, exist_ok=True)
|
||||
os.chown(path, 0, group)
|
||||
for file in ('cert.pem', 'key.pem'):
|
||||
copied |= vpnrouter.install_file(vpnrouter_pki.CA_DIR / name / file, folder / file, 0o640)
|
||||
for file, data in zip(('cert.pem', 'key.pem'), credentials[name]):
|
||||
copied |= vpnrouter_pki.write(folder / file, data, 0o640)
|
||||
os.chown(folder / file, 0, group)
|
||||
authorities = vpnrouter.issuing_cas(cfg)
|
||||
caddy_ctx = dict(ctx, manager_port=manager_port() if MANAGER.exists() else 0,
|
||||
@@ -739,14 +840,19 @@ def main():
|
||||
start_bootstrap()
|
||||
progress('running', 'configuration')
|
||||
cfg = vpnrouter.load_config()
|
||||
ctx, changed = vpnrouter.configure(cfg)
|
||||
|
||||
if ctx is None:
|
||||
if vpnrouter.deferred(cfg):
|
||||
print('vpn-router: platform is none, configuration deferred; '
|
||||
f'set general.platform in {vpnrouter.CONFIG_FILE}')
|
||||
progress('deferred', 'configuration', f'general.platform is none; set it in {vpnrouter.CONFIG_FILE}')
|
||||
return 0
|
||||
|
||||
# The directory holds the PKI, which the configuration needs; its problem ends setup only after every other step.
|
||||
failed_step = 'user directory'
|
||||
progress('running', failed_step)
|
||||
problem = manage_directory()
|
||||
progress('running', 'configuration')
|
||||
ctx, changed = vpnrouter.configure(cfg)
|
||||
|
||||
progress('running', 'routes')
|
||||
if vpnrouter.routing_ready(ctx):
|
||||
apply_routes(ctx)
|
||||
@@ -778,10 +884,6 @@ def main():
|
||||
progress('running', 'WireGuard')
|
||||
manage_wireguard(ctx, changed)
|
||||
|
||||
# The tunnels are up before the directory and the admin, whose problem ends setup only after every other step.
|
||||
failed_step = 'user directory'
|
||||
progress('running', failed_step)
|
||||
problem = manage_directory()
|
||||
if not problem:
|
||||
failed_step = 'Router Manager admin'
|
||||
progress('running', failed_step)
|
||||
@@ -790,6 +892,10 @@ def main():
|
||||
failed_step = 'metrics service account'
|
||||
progress('running', failed_step)
|
||||
problem = manage_metrics_account(ctx)
|
||||
if not problem:
|
||||
failed_step = 'console login'
|
||||
progress('running', failed_step)
|
||||
problem = manage_console(cfg)
|
||||
progress('running', 'OpenVPN')
|
||||
manage_openvpn(ctx, changed)
|
||||
progress('running', 'HTTPS proxy')
|
||||
|
||||
@@ -17,6 +17,7 @@ import tempfile
|
||||
import urllib.parse
|
||||
|
||||
import jinja2
|
||||
import ldap
|
||||
|
||||
LIB_DIR = pathlib.Path(__file__).resolve().parent
|
||||
if str(LIB_DIR) not in sys.path:
|
||||
@@ -51,12 +52,13 @@ SCHEMA = {
|
||||
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
|
||||
'docker': {'mode': 'auto', 'exposed_ports': []},
|
||||
'web': {'enabled': True, 'listen': DEFAULT_LISTEN, 'port': 7081, 'database': '', 'trusted_proxies': [],
|
||||
'status_content': 'basic'},
|
||||
'status_content': 'basic', 'shell': False, 'shell_idle_minutes': 15},
|
||||
'ssh': {'ldap_login': False},
|
||||
'https': {'enabled': True, 'email': '', 'web_port': 7444, 'manager_port': 7443, 'caddy_source': 'ubuntu'},
|
||||
'pki': {'root_name': 'VPN Router Root CA', 'root_days': 7300, 'ca_days': 3650, 'key': 'rsa-4096',
|
||||
'crl_days': 7, 'root_crl_days': 180, 'publish': ['http'], 'p2s_name': 'VPN Router P2S CA',
|
||||
's2s_name': 'VPN Router Site-to-Site CA', 'issuing_cas': {}, 'templates': {},
|
||||
'root_key_warning': True},
|
||||
'extended_key_usages': {}, 'root_key_warning': True},
|
||||
'metrics': {'enabled': True, 'listen': 'internal', 'port': 9464, 'https_port': 9465, 'service_account': 'metrics',
|
||||
'otlp_endpoint': '', 'otlp_username': '', 'otlp_password': '',
|
||||
'keep_details': '24 hours', 'keep_hourly': '1 month', 'keep_daily': '3 months',
|
||||
@@ -135,9 +137,23 @@ PKI_PUBLISH = ('http', 'http-80', 'https', 'https-default')
|
||||
#: The key usages a template may give, as the arguments of cryptography's KeyUsage.
|
||||
KEY_USAGES = ('digital_signature', 'content_commitment', 'key_encipherment', 'data_encipherment', 'key_agreement')
|
||||
|
||||
#: The extended key usages a template may give.
|
||||
EXTENDED_KEY_USAGES = ('server_auth', 'client_auth', 'code_signing', 'email_protection', 'time_stamping',
|
||||
'ocsp_signing', 'ipsec_ike', 'ike_intermediate')
|
||||
#: The built-in extended key usages a template may give, name -> OID; pki.extended_key_usages adds custom ones.
|
||||
EXTENDED_KEY_USAGES = {
|
||||
'server_auth': '1.3.6.1.5.5.7.3.1',
|
||||
'client_auth': '1.3.6.1.5.5.7.3.2',
|
||||
'code_signing': '1.3.6.1.5.5.7.3.3',
|
||||
'email_protection': '1.3.6.1.5.5.7.3.4',
|
||||
'time_stamping': '1.3.6.1.5.5.7.3.8',
|
||||
'ocsp_signing': '1.3.6.1.5.5.7.3.9',
|
||||
'ipsec_ike': '1.3.6.1.5.5.7.3.17',
|
||||
'ike_intermediate': '1.3.6.1.5.5.8.2.2',
|
||||
}
|
||||
|
||||
#: The name of a custom extended key usage of pki.extended_key_usages.
|
||||
EKU_NAME = re.compile(r'^[a-z][a-z0-9_-]{0,30}$')
|
||||
|
||||
#: An object identifier in dotted form, of a custom extended key usage or a certificate policy.
|
||||
OID = re.compile(r'^[0-2](\.(0|[1-9][0-9]*)){1,63}$')
|
||||
|
||||
#: What the common name of a template's certificates is: a DNS name, any name, or an e-mail address.
|
||||
TEMPLATE_SUBJECTS = ('dns', 'name', 'email')
|
||||
@@ -147,7 +163,7 @@ SAN_TYPES = ('dns', 'ip', 'email')
|
||||
|
||||
#: A certificate template: option -> default, the type of the default being the required type.
|
||||
TEMPLATE = {'key': 'rsa-3072', 'days': 365, 'key_usage': ['digital_signature'], 'extended_key_usage': [],
|
||||
'subject': 'name', 'sans': []}
|
||||
'subject': 'name', 'sans': [], 'policies': []}
|
||||
|
||||
#: The built-in templates; pki.templates changes their options and adds templates of its own.
|
||||
PKI_TEMPLATES = {
|
||||
@@ -179,6 +195,9 @@ CA_NAME_MAX = 64
|
||||
#: What / and /status of the web service show, web.status_content, from the least to the most.
|
||||
STATUS_CONTENTS = ('basic', 'standard', 'full')
|
||||
|
||||
#: The longest web.shell_idle_minutes, a day.
|
||||
SHELL_IDLE_MAX = 1440
|
||||
|
||||
DOCKER_MODES = ('auto', 'on', 'off')
|
||||
|
||||
OPENVPN_PROTOCOLS = ('udp', 'tcp')
|
||||
@@ -416,6 +435,9 @@ def validate(cfg):
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise ConfigError(f'openvpn.address_pool: {openvpn_pool!r} is not an IPv4 network')
|
||||
for system, option in (('p2s', 'address_pool'), ('openvpn', 'address_pool'), ('wireguard', 'address')):
|
||||
if _get(cfg, system, 'enabled') and not _get(cfg, system, option):
|
||||
raise ConfigError(f'{system}.enabled needs {system}.{option}')
|
||||
_check_address(_get(cfg, 'local', 'int_addr'), 'local.int_addr')
|
||||
_check_address(_get(cfg, 'local', 'int_gateway_ip'), 'local.int_gateway_ip')
|
||||
|
||||
@@ -462,6 +484,10 @@ def validate(cfg):
|
||||
raise ConfigError(
|
||||
f'web.status_content: {status_content!r} is not one of {", ".join(STATUS_CONTENTS)}')
|
||||
|
||||
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 = []
|
||||
for option in ('web_port', 'manager_port'):
|
||||
port = _get(cfg, 'https', option)
|
||||
@@ -501,6 +527,14 @@ def validate(cfg):
|
||||
raise ConfigError('pki.publish: http is served by the web service, which needs web.enabled')
|
||||
if set(publish) - {'http'} and not _get(cfg, 'https', 'enabled'):
|
||||
raise ConfigError('pki.publish: http-80, https and https-default are served by Caddy, which needs https.enabled')
|
||||
for name, oid in cfg['pki']['extended_key_usages'].items():
|
||||
prefix = f'pki.extended_key_usages.{name}'
|
||||
if not EKU_NAME.match(name) or name in EXTENDED_KEY_USAGES:
|
||||
raise ConfigError(f'{prefix}: an extended key usage name starts with a lowercase letter, has at most 31 '
|
||||
'lowercase letters, digits, hyphens and underscores, and is not a built-in one')
|
||||
if not isinstance(oid, str) or not OID.match(oid):
|
||||
raise ConfigError(f'{prefix} must be an OID in dotted form, such as 1.3.6.1.4.1.311.20.2.2')
|
||||
usages = extended_key_usages(cfg)
|
||||
for name, template in cfg['pki']['templates'].items():
|
||||
prefix = f'pki.templates.{name}'
|
||||
if not PKI_NAME.match(name):
|
||||
@@ -516,11 +550,14 @@ def validate(cfg):
|
||||
raise ConfigError(f'{prefix}.key: {template["key"]!r} is not one of {", ".join(PKI_KEYS)}')
|
||||
if not 1 <= template['days'] <= 36500:
|
||||
raise ConfigError(f'{prefix}.days must be 1 to 36500 days')
|
||||
for option, allowed in (('key_usage', KEY_USAGES), ('extended_key_usage', EXTENDED_KEY_USAGES),
|
||||
for option, allowed in (('key_usage', KEY_USAGES), ('extended_key_usage', tuple(usages)),
|
||||
('sans', SAN_TYPES)):
|
||||
for item in template[option]:
|
||||
if item not in allowed:
|
||||
raise ConfigError(f'{prefix}.{option}: {item!r} is not one of {", ".join(allowed)}')
|
||||
for item in template['policies']:
|
||||
if not OID.match(item):
|
||||
raise ConfigError(f'{prefix}.policies: {item!r} is not an OID in dotted form, such as 1.3.6.1.4.1.99999.1')
|
||||
if not template['key_usage']:
|
||||
raise ConfigError(f'{prefix}.key_usage needs at least one usage')
|
||||
if template['subject'] not in TEMPLATE_SUBJECTS:
|
||||
@@ -664,6 +701,13 @@ def pki_templates(cfg):
|
||||
for name in [*PKI_TEMPLATES, *(name for name in custom if name not in PKI_TEMPLATES)]}
|
||||
|
||||
|
||||
def extended_key_usages(cfg):
|
||||
"""The extended key usages templates may give, name -> OID: the built-in ones (EXTENDED_KEY_USAGES) and those of
|
||||
pki.extended_key_usages."""
|
||||
custom = cfg['pki']['extended_key_usages']
|
||||
return {**EXTENDED_KEY_USAGES, **{name: oid for name, oid in custom.items() if isinstance(oid, str)}}
|
||||
|
||||
|
||||
def issuing_cas(cfg):
|
||||
"""The issuing CAs, name -> {name, templates, acme, builtin}: p2s and s2s (BUILTIN_CAS) with their common names from
|
||||
the pki section, then those of pki.issuing_cas, each filled from ISSUING_CA."""
|
||||
@@ -1011,6 +1055,7 @@ def configure(cfg=None, public_ip=None):
|
||||
|
||||
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():
|
||||
try:
|
||||
notices = vpnrouter_pki.provision(cfg, ctx)
|
||||
@@ -1018,6 +1063,8 @@ def configure(cfg=None, public_ip=None):
|
||||
changed.add('swanctl')
|
||||
except vpnrouter_pki.Failure as failure:
|
||||
fail(str(failure))
|
||||
except ldap.LDAPError as exc:
|
||||
notices = [f'the PKI is left as it is: the directory cannot be reached: {exc}']
|
||||
for notice in notices:
|
||||
warn(notice)
|
||||
# What needs the router's own certificates.
|
||||
|
||||
@@ -11,6 +11,13 @@ The password policy locks an account for LOCKOUT_SECONDS after LOCKOUT_FAILURES
|
||||
a successful bind clears the count. slapd's otp overlay is configured on the database, so a
|
||||
bind of a user with a TOTP token takes the password followed by a current code.
|
||||
cn=totp,ou=policies holds the TOTP parameters the token of every user points to.
|
||||
|
||||
A user with console login on its own account is also a posixAccount, with a uidNumber from
|
||||
CONSOLE_UID_MIN and the gidNumber of the local group CONSOLE_GROUP; nslcd serves those with
|
||||
vpnRouterConsoleLogin TRUE to the system over the local socket.
|
||||
|
||||
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
|
||||
@@ -24,11 +31,14 @@ import ldap.modlist
|
||||
import ldap.sasl
|
||||
|
||||
URI = 'ldapi:///'
|
||||
#: The local socket ldapi:/// names, which slapd listens on.
|
||||
SLAPD_SOCKET = '/var/run/slapd/ldapi'
|
||||
BASE = 'dc=vpn-router,dc=local'
|
||||
USERS = f'ou=users,{BASE}'
|
||||
GROUPS = f'ou=groups,{BASE}'
|
||||
SERVICES = f'ou=services,{BASE}'
|
||||
POLICIES = f'ou=policies,{BASE}'
|
||||
PKI = f'ou=pki,{BASE}'
|
||||
DEFAULT_POLICY = f'cn=default,{POLICIES}'
|
||||
TOTP_PARAMS = f'cn=totp,{POLICIES}'
|
||||
MANAGER = f'cn=user-manager,{SERVICES}'
|
||||
@@ -53,17 +63,51 @@ OVERLAYS = ('ppolicy', 'otp')
|
||||
#: The package's own schema, under an OID of the UUID arc 2.25 (ITU-T X.667): attribute types
|
||||
#: under .1 and object classes under .2. vpnRouterAccount, auxiliary, adds properties of the
|
||||
#: package to a user of either kind: mail, for a Manager-only account, vpnRouterScope,
|
||||
#: authorization scopes, of which service marks a service account, and displayName and
|
||||
#: description, the labels of a service account.
|
||||
#: authorization scopes, of which service marks a service account, displayName and
|
||||
#: description, the labels of a service account, and vpnRouterConsoleLogin and
|
||||
#: vpnRouterConsoleAccount, whether the user may use a console and the local account it runs as,
|
||||
#: without which it runs as the user's own posixAccount. vpnRouterAuthority is a CA of the PKI: its
|
||||
#: certificate and CRL (cACertificate and certificateRevocationList of RFC 4523), its key, the
|
||||
#: CSR it waits with for the offline root, the number of its last CRL, where the CRL came from,
|
||||
#: and when it was retired. vpnRouterCertificate is a certificate a CA issued, named by its
|
||||
#: serial number: the certificate, the key the router keeps for its own and the users'
|
||||
#: certificates, its kind, template, names and expiry, and its revocation.
|
||||
SCHEMA_OID = '2.25.98477352595735834532404256531620013001'
|
||||
SCHEMA_NAME = 'vpn-router'
|
||||
_STRING = 'EQUALITY caseIgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} SINGLE-VALUE'
|
||||
_TIME = 'EQUALITY generalizedTimeMatch ORDERING generalizedTimeOrderingMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 SINGLE-VALUE'
|
||||
_OCTETS = 'EQUALITY octetStringMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.40 SINGLE-VALUE'
|
||||
SCHEMA_ATTRIBUTE_TYPES = (
|
||||
f"( {SCHEMA_OID}.1.1 NAME 'vpnRouterScope' DESC 'VPN router: an authorization scope of the account' "
|
||||
"EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} )",
|
||||
f"( {SCHEMA_OID}.1.2 NAME 'vpnRouterPrivateKey' DESC 'VPN router: a private key, PEM' {_OCTETS} )",
|
||||
f"( {SCHEMA_OID}.1.3 NAME 'vpnRouterCertificateRequest' DESC 'VPN router: the CSR of a CA, PEM' {_OCTETS} )",
|
||||
f"( {SCHEMA_OID}.1.4 NAME 'vpnRouterCRLNumber' DESC 'VPN router: the number of the last CRL of a CA' "
|
||||
"EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE )",
|
||||
f"( {SCHEMA_OID}.1.5 NAME 'vpnRouterCRLSource' DESC 'VPN router: signed or uploaded, where the CRL of a CA came from' {_STRING} )",
|
||||
f"( {SCHEMA_OID}.1.6 NAME 'vpnRouterRetired' DESC 'VPN router: when a CA was retired' {_TIME} )",
|
||||
f"( {SCHEMA_OID}.1.7 NAME 'vpnRouterKind' DESC 'VPN router: ca, router, user or external, the kind of a certificate' {_STRING} )",
|
||||
f"( {SCHEMA_OID}.1.8 NAME 'vpnRouterTemplate' DESC 'VPN router: the template of a certificate' {_STRING} )",
|
||||
f"( {SCHEMA_OID}.1.9 NAME 'vpnRouterSubjectAltName' DESC 'VPN router: a subject alternative name of a certificate' "
|
||||
"EQUALITY caseIgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} )",
|
||||
f"( {SCHEMA_OID}.1.10 NAME 'vpnRouterNotAfter' DESC 'VPN router: when a certificate expires' {_TIME} )",
|
||||
f"( {SCHEMA_OID}.1.11 NAME 'vpnRouterRevoked' DESC 'VPN router: when a certificate was revoked' {_TIME} )",
|
||||
f"( {SCHEMA_OID}.1.12 NAME 'vpnRouterRevocationReason' DESC 'VPN router: why a certificate was revoked' {_STRING} )",
|
||||
f"( {SCHEMA_OID}.1.13 NAME 'vpnRouterConsoleLogin' DESC 'VPN router: whether the user may use a console' "
|
||||
"EQUALITY booleanMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 SINGLE-VALUE )",
|
||||
f"( {SCHEMA_OID}.1.14 NAME 'vpnRouterConsoleAccount' DESC 'VPN router: the local account the console of the user runs as' "
|
||||
f"{_STRING} )",
|
||||
)
|
||||
SCHEMA_OBJECT_CLASSES = (
|
||||
f"( {SCHEMA_OID}.2.1 NAME 'vpnRouterAccount' DESC 'VPN router: properties of a user or a Manager-only account' "
|
||||
"SUP top AUXILIARY MAY ( mail $ vpnRouterScope $ displayName $ description ) )",
|
||||
"SUP top AUXILIARY MAY ( mail $ vpnRouterScope $ displayName $ description $ vpnRouterConsoleLogin $ "
|
||||
"vpnRouterConsoleAccount ) )",
|
||||
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.
|
||||
@@ -72,8 +116,19 @@ TOTP_ALGORITHM = '1.2.840.113549.2.7'
|
||||
TOTP_PERIOD = 30
|
||||
TOTP_WINDOW = 1
|
||||
|
||||
#: The indexes of the database.
|
||||
INDEXES = ('objectClass eq', 'uid eq', 'uidNumber eq', 'vpnRouterConsoleLogin eq')
|
||||
|
||||
#: The POSIX accounts of console login (vpnrouter_users.console): the lowest uidNumber, which nslcd's nss_min_uid
|
||||
#: matches, the local group that is their primary group and grants sudo, the shell and the parent of their homes.
|
||||
CONSOLE_UID_MIN = 20000
|
||||
CONSOLE_GROUP = 'vpn-router-console'
|
||||
CONSOLE_SHELL = '/bin/bash'
|
||||
CONSOLE_HOMES = '/home'
|
||||
|
||||
#: The ACLs of the database, in order.
|
||||
ACCESS = (
|
||||
'to attrs=vpnRouterPrivateKey by * none',
|
||||
f'to attrs=userPassword by dn.exact="{MANAGER}" =wx by self =wx by anonymous auth by * none',
|
||||
f'to attrs=oathSecret by dn.exact="{MANAGER}" =w by * none',
|
||||
f'to dn.subtree="{USERS}" by dn.exact="{MANAGER}" manage by * read',
|
||||
@@ -126,20 +181,27 @@ def _ensure_modules(connection):
|
||||
|
||||
|
||||
def _ensure_schema(connection):
|
||||
"""Add the package's schema to cn=config when missing, else set its definitions that differ."""
|
||||
"""Add the package's schema to cn=config when missing, else add the definitions it lacks; a definition that differs
|
||||
from the one of the same OID replaces the whole list of its kind."""
|
||||
found = connection.search_s('cn=schema,cn=config', ldap.SCOPE_ONELEVEL, f'(cn=*{SCHEMA_NAME})',
|
||||
['olcAttributeTypes', 'olcObjectClasses'])
|
||||
attributes = {'olcAttributeTypes': [value.encode() for value in SCHEMA_ATTRIBUTE_TYPES],
|
||||
'olcObjectClasses': [value.encode() for value in SCHEMA_OBJECT_CLASSES]}
|
||||
attributes = {'olcAttributeTypes': list(SCHEMA_ATTRIBUTE_TYPES), 'olcObjectClasses': list(SCHEMA_OBJECT_CLASSES)}
|
||||
if not found:
|
||||
connection.add_s(f'cn={SCHEMA_NAME},cn=schema,cn=config', ldap.modlist.addModlist(
|
||||
dict(attributes, objectClass=[b'olcSchemaConfig'], cn=[SCHEMA_NAME.encode()])))
|
||||
dict({name: [value.encode() for value in values] for name, values in attributes.items()},
|
||||
objectClass=[b'olcSchemaConfig'], cn=[SCHEMA_NAME.encode()])))
|
||||
return
|
||||
dn, current = found[0]
|
||||
changes = [(ldap.MOD_REPLACE, name, values) for name, values in attributes.items()
|
||||
if unordered(current.get(name, [])) != [value.decode() for value in values]]
|
||||
if changes:
|
||||
connection.modify_s(dn, changes)
|
||||
for name, wanted in attributes.items():
|
||||
present = unordered(current.get(name, []))
|
||||
oids = {value.split()[1] for value in present}
|
||||
missing = [value for value in wanted if value not in present]
|
||||
if not missing:
|
||||
continue
|
||||
if any(value.split()[1] in oids for value in missing):
|
||||
connection.modify_s(dn, [(ldap.MOD_REPLACE, name, [value.encode() for value in wanted])])
|
||||
else:
|
||||
connection.modify_s(dn, [(ldap.MOD_ADD, name, [value.encode() for value in missing])])
|
||||
|
||||
|
||||
def _ensure_password_hash(connection):
|
||||
@@ -150,8 +212,9 @@ def _ensure_password_hash(connection):
|
||||
|
||||
|
||||
def _ensure_database(connection):
|
||||
"""The DN of the package's database in cn=config, created when missing; its ACLs are kept as ACCESS."""
|
||||
found = connection.search_s('cn=config', ldap.SCOPE_ONELEVEL, f'(olcSuffix={BASE})', ['olcAccess'])
|
||||
"""The DN of the package's database in cn=config, created when missing; its ACLs are kept as ACCESS, and the
|
||||
indexes of INDEXES it lacks are added, which slapd builds online."""
|
||||
found = connection.search_s('cn=config', ldap.SCOPE_ONELEVEL, f'(olcSuffix={BASE})', ['olcAccess', 'olcDbIndex'])
|
||||
if not found:
|
||||
DB_DIRECTORY.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
openldap = pwd.getpwnam('openldap')
|
||||
@@ -162,13 +225,17 @@ def _ensure_database(connection):
|
||||
'olcSuffix': [BASE.encode()],
|
||||
'olcDbDirectory': [str(DB_DIRECTORY).encode()],
|
||||
'olcRootDN': [ROOT.encode()],
|
||||
'olcDbIndex': [b'objectClass eq', b'uid eq'],
|
||||
'olcDbIndex': [index.encode() for index in INDEXES],
|
||||
'olcAccess': [f'{{{index}}}{rule}'.encode() for index, rule in enumerate(ACCESS)],
|
||||
}))
|
||||
found = connection.search_s('cn=config', ldap.SCOPE_ONELEVEL, f'(olcSuffix={BASE})', ['olcAccess'])
|
||||
found = connection.search_s('cn=config', ldap.SCOPE_ONELEVEL, f'(olcSuffix={BASE})', ['olcAccess', 'olcDbIndex'])
|
||||
dn, attributes = found[0]
|
||||
if unordered(attributes.get('olcAccess', [])) != list(ACCESS):
|
||||
connection.modify_s(dn, [(ldap.MOD_REPLACE, 'olcAccess', [f'{{{index}}}{rule}'.encode() for index, rule in enumerate(ACCESS)])])
|
||||
present = [value.decode() for value in attributes.get('olcDbIndex', [])]
|
||||
missing = [index for index in INDEXES if index not in present]
|
||||
if missing:
|
||||
connection.modify_s(dn, [(ldap.MOD_ADD, 'olcDbIndex', [index.encode() for index in missing])])
|
||||
return dn
|
||||
|
||||
|
||||
@@ -230,7 +297,7 @@ def provision():
|
||||
database = _ensure_database(connection)
|
||||
_ensure_overlays(connection, database)
|
||||
_ensure_entry(connection, BASE, {'objectClass': [b'dcObject', b'organization'], 'dc': [b'vpn-router'], 'o': [b'vpn-router']})
|
||||
for unit in (USERS, GROUPS, SERVICES, POLICIES):
|
||||
for unit in (USERS, GROUPS, SERVICES, POLICIES, PKI):
|
||||
_ensure_entry(connection, unit, {'objectClass': [b'organizationalUnit'], 'ou': [unit.split(',')[0][3:].encode()]})
|
||||
_ensure_entry(connection, DEFAULT_POLICY, {
|
||||
'objectClass': [b'organizationalRole', b'pwdPolicy'],
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -28,7 +29,12 @@ code of it. slapd's otp overlay then takes the password followed by a code at ev
|
||||
A user is disabled when pwdAccountLockedTime holds LOCKED, and locked when the password
|
||||
policy has set it after failed binds, for vpnrouter_ldap.LOCKOUT_SECONDS; unlock ends that.
|
||||
login() checks a user's password and code with a bind. Members of ADMIN_GROUP manage the
|
||||
router through the web service. The module knows only the directory; the callers revoke a
|
||||
router through the web service.
|
||||
|
||||
A member of ADMIN_GROUP may have console login (console): the Shell page of the Router Manager,
|
||||
and SSH and sudo with the directory's password, followed by the code for an enrolled user. The
|
||||
console runs as a local account, or as the user's own posixAccount, which the user entry becomes
|
||||
and nslcd serves to the system. Leaving ADMIN_GROUP switches it off. The module knows only the directory; the callers revoke a
|
||||
user's refresh tokens after the operations of vpnrouter_auth.REVOKING.
|
||||
|
||||
Every operation runs in a session(), which holds the lock that serializes changes, the PKI's
|
||||
@@ -41,7 +47,10 @@ import base64
|
||||
import calendar
|
||||
import contextlib
|
||||
import fcntl
|
||||
import grp
|
||||
import pwd
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
import ldap
|
||||
@@ -68,7 +77,8 @@ PERSON = (('first_name', 'givenName'), ('last_name', 'sn'), ('email', 'mail'))
|
||||
LABELS = (('display_name', 'displayName'), ('description', 'description'))
|
||||
#: The attributes read for a user.
|
||||
ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken', 'vpnRouterScope',
|
||||
'displayName', 'description']
|
||||
'displayName', 'description', 'userCertificate;binary', 'vpnRouterConsoleLogin', 'vpnRouterConsoleAccount',
|
||||
'uidNumber']
|
||||
#: The vpnRouterScope of a service account.
|
||||
SERVICE_SCOPE = 'service'
|
||||
#: The object classes of a VPN user and of a Manager-only account.
|
||||
@@ -173,9 +183,6 @@ def dn_of(name):
|
||||
return f'uid={name},{vpnrouter_ldap.USERS}'
|
||||
|
||||
|
||||
def certificate(name):
|
||||
return vpnrouter_pki.user_files(name)
|
||||
|
||||
|
||||
def entry(connection, name):
|
||||
"""The attributes of the user's entry; Failure with status 3 when there is none."""
|
||||
@@ -234,8 +241,9 @@ def summary(attributes):
|
||||
'internal': service(attributes) and name in internal_accounts(),
|
||||
'enabled': lock != LOCKED,
|
||||
'locked': lock not in (None, LOCKED) and locked_since(lock) > time.time() - vpnrouter_ldap.LOCKOUT_SECONDS,
|
||||
'certificate': certificate(name)[0].is_file(),
|
||||
'certificate': 'userCertificate;binary' in attributes,
|
||||
'otp': 'oathTOTPToken' in attributes,
|
||||
'console': console_of(attributes),
|
||||
}
|
||||
for key, attribute in PERSON + LABELS:
|
||||
values = attributes.get(attribute)
|
||||
@@ -243,6 +251,116 @@ def summary(attributes):
|
||||
return user
|
||||
|
||||
|
||||
def console_of(attributes):
|
||||
"""The console login of an entry as the answers show it: None when it was never set, else {enabled, account, local,
|
||||
uid}: account the system account the console runs as, the local one of vpnRouterConsoleAccount (local true) or the
|
||||
user's own posixAccount, and uid the uidNumber of that posixAccount, None without one."""
|
||||
if 'vpnRouterConsoleLogin' not in attributes:
|
||||
return None
|
||||
local = attributes.get('vpnRouterConsoleAccount')
|
||||
number = attributes.get('uidNumber')
|
||||
return {
|
||||
'enabled': attributes['vpnRouterConsoleLogin'] == [b'TRUE'],
|
||||
'account': local[0].decode() if local else attributes['uid'][0].decode(),
|
||||
'local': bool(local),
|
||||
'uid': int(number[0]) if number else None,
|
||||
}
|
||||
|
||||
|
||||
def classes(attributes):
|
||||
"""The object classes of an entry, in lower case."""
|
||||
return {value.decode().lower() for value in attributes.get('objectClass', [])}
|
||||
|
||||
|
||||
def free_uid(connection):
|
||||
"""The lowest uidNumber from vpnrouter_ldap.CONSOLE_UID_MIN above every posixAccount's that no local account has."""
|
||||
found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL, '(uidNumber=*)', ['uidNumber'])
|
||||
number = max([vpnrouter_ldap.CONSOLE_UID_MIN - 1] + [int(attributes['uidNumber'][0]) for _, attributes in found]) + 1
|
||||
while True:
|
||||
try:
|
||||
pwd.getpwuid(number)
|
||||
except KeyError:
|
||||
return number
|
||||
number += 1
|
||||
|
||||
|
||||
def console(connection, name, enabled, account=None):
|
||||
"""Switch the console login of a member of ADMIN_GROUP on, as the local account given or, without one, as the
|
||||
user's own posixAccount, or off.
|
||||
|
||||
The posixAccount is added the first time and kept when the console login is switched off, so its uidNumber and the
|
||||
home it owns stay the user's. A local account must exist in the system's passwd with a uid from 1000 to below
|
||||
vpnrouter_ldap.CONSOLE_UID_MIN, and not be nobody; the user's own account needs a name no local account has.
|
||||
Switching off ends the sessions of the user's own account.
|
||||
"""
|
||||
current = entry(connection, name)
|
||||
if not isinstance(enabled, bool):
|
||||
raise Failure('enabled must be true or false', 2)
|
||||
if account is not None and (not isinstance(account, str) or not account):
|
||||
raise Failure('account must be the name of a local account', 2)
|
||||
changes = [] if 'vpnrouteraccount' in classes(current) else [(ldap.MOD_ADD, 'objectClass', [b'vpnRouterAccount'])]
|
||||
if not enabled:
|
||||
if 'vpnRouterConsoleLogin' not in current:
|
||||
return summary(current)
|
||||
connection.modify_s(dn_of(name), changes + [(ldap.MOD_REPLACE, 'vpnRouterConsoleLogin', [b'FALSE'])])
|
||||
end_sessions(current)
|
||||
return summary(entry(connection, name))
|
||||
if service(current):
|
||||
raise Failure(f'{name} is a service account, which has no console', 2)
|
||||
if ADMIN_GROUP not in groups_of(connection, name):
|
||||
raise Failure(f'user {name} is not a member of {ADMIN_GROUP}', 2)
|
||||
if account is not None:
|
||||
try:
|
||||
local = pwd.getpwnam(account)
|
||||
except KeyError:
|
||||
raise Failure(f'there is no local account {account}', 2)
|
||||
if not 1000 <= local.pw_uid < vpnrouter_ldap.CONSOLE_UID_MIN or account == 'nobody':
|
||||
raise Failure(f'{account} is not a local user account with a uid from 1000 to {vpnrouter_ldap.CONSOLE_UID_MIN - 1}', 2)
|
||||
changes.append((ldap.MOD_REPLACE, 'vpnRouterConsoleAccount', [account.encode()]))
|
||||
else:
|
||||
changes.append((ldap.MOD_REPLACE, 'vpnRouterConsoleAccount', []))
|
||||
if 'posixaccount' not in classes(current):
|
||||
try:
|
||||
pwd.getpwnam(name)
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
raise Failure(f'a local account {name} exists; give it as the account, or use another user', 4)
|
||||
try:
|
||||
group = grp.getgrnam(vpnrouter_ldap.CONSOLE_GROUP).gr_gid
|
||||
except KeyError:
|
||||
raise Failure(f'the group {vpnrouter_ldap.CONSOLE_GROUP} does not exist yet; vpn-router-setup creates it')
|
||||
changes += [
|
||||
(ldap.MOD_ADD, 'objectClass', [b'posixAccount']),
|
||||
(ldap.MOD_REPLACE, 'uidNumber', [str(free_uid(connection)).encode()]),
|
||||
(ldap.MOD_REPLACE, 'gidNumber', [str(group).encode()]),
|
||||
(ldap.MOD_REPLACE, 'homeDirectory', [f'{vpnrouter_ldap.CONSOLE_HOMES}/{name}'.encode()]),
|
||||
(ldap.MOD_REPLACE, 'loginShell', [vpnrouter_ldap.CONSOLE_SHELL.encode()]),
|
||||
]
|
||||
if manager_only(current):
|
||||
changes.append((ldap.MOD_REPLACE, 'cn', [name.encode()]))
|
||||
changes.append((ldap.MOD_REPLACE, 'vpnRouterConsoleLogin', [b'TRUE']))
|
||||
connection.modify_s(dn_of(name), changes)
|
||||
return summary(entry(connection, name))
|
||||
|
||||
|
||||
def end_sessions(attributes):
|
||||
"""End the login sessions of the user's own posixAccount, when the entry has one; those of a local account stay."""
|
||||
if 'posixaccount' in classes(attributes) and not attributes.get('vpnRouterConsoleAccount'):
|
||||
subprocess.run(['loginctl', 'terminate-user', attributes['uid'][0].decode()], capture_output=True)
|
||||
|
||||
|
||||
def console_off(connection, names):
|
||||
"""Switch the console login off for each of the users that has it on; for those who leave ADMIN_GROUP."""
|
||||
for name in names:
|
||||
try:
|
||||
current = entry(connection, name)
|
||||
except Failure:
|
||||
continue
|
||||
if current.get('vpnRouterConsoleLogin') == [b'TRUE']:
|
||||
console(connection, name, False)
|
||||
|
||||
|
||||
def common_name(attributes, name):
|
||||
"""The cn of an entry: the first and the last name, or the user name without them."""
|
||||
full = ' '.join(value for value in (attributes.get('givenName'), attributes.get('sn')) if value)
|
||||
@@ -443,11 +561,12 @@ def otp_remove(connection, name):
|
||||
|
||||
|
||||
def remove(connection, name):
|
||||
"""Remove the user from its groups and the directory, and revoke the certificate.
|
||||
"""Remove the user from its groups and the directory, revoke the certificate and end the sessions of its own
|
||||
posixAccount.
|
||||
|
||||
A user who is the last member of a group is not removed.
|
||||
"""
|
||||
entry(connection, name)
|
||||
current = entry(connection, name)
|
||||
groups = groups_of(connection, name)
|
||||
alone = sorted(group for group, members in groups.items() if members == [name])
|
||||
if alone:
|
||||
@@ -459,6 +578,7 @@ def remove(connection, name):
|
||||
except vpnrouter_pki.Failure as failure:
|
||||
raise Failure(f'the certificate cannot be revoked: {failure}', failure.status)
|
||||
connection.delete_s(dn_of(name))
|
||||
end_sessions(current)
|
||||
return {'name': name, 'removed': True}
|
||||
|
||||
|
||||
@@ -468,9 +588,10 @@ def profile(connection, name):
|
||||
cfg = vpnrouter.load_config()
|
||||
if not cfg['openvpn']['enabled']:
|
||||
raise Failure('OpenVPN is not enabled', 2)
|
||||
cert, key = certificate(name)
|
||||
if not cert.is_file() or not key.is_file():
|
||||
credentials = vpnrouter_pki.user_credentials(name)
|
||||
if credentials is None:
|
||||
raise Failure(f'user {name} has no certificate')
|
||||
cert, key = (value.decode() for value in credentials)
|
||||
fqdn = cfg['wan']['local_fqdn'].strip()
|
||||
text = '\n'.join([
|
||||
'client',
|
||||
@@ -486,8 +607,8 @@ def profile(connection, name):
|
||||
'auth-nocache',
|
||||
'verb 3',
|
||||
'<ca>', extract_pem(vpnrouter_pki.OPENVPN_CA.read_text(encoding='utf-8')), '</ca>',
|
||||
'<cert>', extract_pem(cert.read_text(encoding='utf-8')), '</cert>',
|
||||
'<key>', key.read_text(encoding='utf-8').strip(), '</key>',
|
||||
'<cert>', extract_pem(cert), '</cert>',
|
||||
'<key>', key.strip(), '</key>',
|
||||
'',
|
||||
])
|
||||
return {'name': name, 'profile': text}
|
||||
@@ -574,10 +695,11 @@ def group_add(connection, name, description, members):
|
||||
|
||||
|
||||
def group_update(connection, name, new_name=None, description=None):
|
||||
"""Rename the group or change its description ('' removes it); at least one must be given."""
|
||||
"""Rename the group or change its description ('' removes it); at least one must be given. Renaming ADMIN_GROUP
|
||||
switches the console login of its members off."""
|
||||
if new_name is None and description is None:
|
||||
raise Failure('give a new name or a description', 2)
|
||||
group_entry(connection, name)
|
||||
members = member_names(group_entry(connection, name))
|
||||
if description is not None:
|
||||
description = check_description(description)
|
||||
change = (ldap.MOD_REPLACE, 'description', [description.encode()]) if description else \
|
||||
@@ -589,13 +711,18 @@ def group_update(connection, name, new_name=None, description=None):
|
||||
connection.rename_s(group_dn(name), f'cn={new_name}', delold=1)
|
||||
except ldap.ALREADY_EXISTS:
|
||||
raise Failure(f'group {new_name} exists', 4)
|
||||
if name == ADMIN_GROUP:
|
||||
console_off(connection, members)
|
||||
name = new_name
|
||||
return group_summary(group_entry(connection, name))
|
||||
|
||||
|
||||
def group_remove(connection, name):
|
||||
group_entry(connection, name)
|
||||
"""Remove the group; removing ADMIN_GROUP switches the console login of its members off."""
|
||||
members = member_names(group_entry(connection, name))
|
||||
connection.delete_s(group_dn(name))
|
||||
if name == ADMIN_GROUP:
|
||||
console_off(connection, members)
|
||||
return {'name': name, 'removed': True}
|
||||
|
||||
|
||||
@@ -608,7 +735,8 @@ def group_member_add(connection, name, user):
|
||||
|
||||
|
||||
def group_member_remove(connection, name, user):
|
||||
"""Take the user out of the group; the last member cannot leave."""
|
||||
"""Take the user out of the group; the last member cannot leave. Leaving ADMIN_GROUP switches the user's console
|
||||
login off."""
|
||||
check_name(user)
|
||||
members = member_names(group_entry(connection, name))
|
||||
if user not in members:
|
||||
@@ -616,6 +744,8 @@ def group_member_remove(connection, name, user):
|
||||
if members == [user]:
|
||||
raise Failure(f'user {user} is the last member of group {name}; remove the group instead', 2)
|
||||
connection.modify_s(group_dn(name), [(ldap.MOD_DELETE, 'member', [dn_of(user).encode()])])
|
||||
if name == ADMIN_GROUP:
|
||||
console_off(connection, [user])
|
||||
return group_summary(group_entry(connection, name))
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ Pages:
|
||||
/ the status for people: plain text for curl and wget, a styled page
|
||||
(Tailwind CSS from a CDN) for a browser
|
||||
/status the status as JSON
|
||||
/status/full
|
||||
the whole status as JSON, whatever web.status_content says, for the Router Manager
|
||||
/config the configuration without its secrets, and what the router resolved and
|
||||
runs, 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,
|
||||
@@ -26,12 +28,19 @@ Pages:
|
||||
applied by vpn-router-setup (see site_to_site())
|
||||
/backup the backup settings, the last run and the local archives: show, change and run
|
||||
now (see backup())
|
||||
/remote-access
|
||||
the settings of IKEv2 road-warrior, OpenVPN and WireGuard access and whether each
|
||||
runs: show and change, applied by vpn-router-setup (see remote_access())
|
||||
/ca the PKI: its settings, the root and issuing CAs, the certificates issued; change the
|
||||
settings, issue and revoke certificates, and take the certificate of an issuing CA
|
||||
and a root CRL signed offline (see ca())
|
||||
/pki/<name>.crt, /pki/<name>.crl
|
||||
the DER certificate and CRL of the root (root) and of each issuing CA, which the
|
||||
certificates name in their AIA and CRL distribution points; no authentication
|
||||
/shell while web.shell is true, a login shell of the user's console account on a terminal, for
|
||||
a member of admins whose console login is on: 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())
|
||||
/metrics/history
|
||||
the metrics the metrics service keeps, for a view: hour, day, month or year (see
|
||||
@@ -40,12 +49,13 @@ Pages:
|
||||
the databases of each PostgreSQL cluster on the router with their sizes, as JSON (see
|
||||
postgresql_databases()); /config has the clusters' settings
|
||||
|
||||
/config, /metrics/history, /postgresql, /users, /service-accounts, /groups, /site-to-site, /backup, /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, /remote-access, /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;
|
||||
/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
|
||||
/account, and only such a token may change ADMIN_GROUP: create, rename to it, change, remove it,
|
||||
add or take out its members, and remove one of its members. These paths and /auth are not
|
||||
add or take out its members, and remove one of its members, and switch a user's console login.
|
||||
These paths and /auth are not
|
||||
served (404) while the key does not exist. /auth is limited to AUTH_LIMIT calls per client
|
||||
address in AUTH_WINDOW seconds; the address of a call from one of web.trusted_proxies, by
|
||||
default the address the call came in on, is the last entry of its X-Forwarded-For header.
|
||||
@@ -54,9 +64,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
|
||||
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
|
||||
whether each service runs, whether Docker is installed, and the state of each site-to-site connection, road-warrior
|
||||
(IPsec P2S), OpenVPN and WireGuard. No configuration data or key is shown, and no address but the
|
||||
client's.
|
||||
whether each service runs, whether Docker is installed, how many of the site-to-site connections are established
|
||||
(site_to_site: connections and established, null when swanctl cannot be asked), and road-warrior (IPsec P2S), OpenVPN
|
||||
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
|
||||
tokens' database (vpnrouter_auth), retrying every INIT_RETRY_SECONDS until both are ready and
|
||||
@@ -74,13 +84,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.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import collections
|
||||
import configparser
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import pwd
|
||||
import secrets
|
||||
import select
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import termios
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
@@ -133,6 +153,9 @@ BODY_LIMIT = 65536
|
||||
#: The settings of a site_to_site connection that /site-to-site adds and changes.
|
||||
CONNECTION_SETTINGS = ('addrs', 'id', 'cidrs', 'local_cidrs', 'local_id_mode', 'auth', 'psk', 'remote_ca')
|
||||
|
||||
#: The sections of the remote access systems /remote-access changes: IKEv2 road-warrior, OpenVPN and WireGuard.
|
||||
REMOTE_ACCESS = ('p2s', 'openvpn', 'wireguard')
|
||||
|
||||
#: The HTTP status for each status of a vpnrouter_users.Failure; any other is 500.
|
||||
USER_STATUS = {2: 400, 3: 404, 4: 409, 5: 401}
|
||||
|
||||
@@ -142,6 +165,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.
|
||||
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}
|
||||
_lock = threading.Lock()
|
||||
|
||||
@@ -149,6 +184,10 @@ _lock = threading.Lock()
|
||||
_auth_calls = collections.defaultdict(collections.deque)
|
||||
_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):
|
||||
"""The object in a JSON file, or {} when the file is missing or holds no object."""
|
||||
@@ -232,8 +271,9 @@ def collect(state):
|
||||
}
|
||||
names = state.get('site_to_site')
|
||||
if isinstance(names, list) and names:
|
||||
status['site_to_site'] = {'connections': {name: None if count is None else count > 0
|
||||
for name, count in established_each(names).items()}}
|
||||
counts = established_each(names).values()
|
||||
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'):
|
||||
status['p2s'] = {'clients': established('road-warrior')}
|
||||
if state.get('wireguard'):
|
||||
@@ -399,8 +439,9 @@ def text_of(status):
|
||||
docker = status['docker']
|
||||
lines.append('Docker: not installed' if not docker['installed']
|
||||
else f'Docker: installed, service {docker["service"]}, rules {docker["rules"]}')
|
||||
for name, value in status.get('site_to_site', {}).get('connections', {}).items():
|
||||
lines.append(f'Site to site {name}: {"unknown" if value is None else "established" if value else "not established"}')
|
||||
if 'site_to_site' in status:
|
||||
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:
|
||||
clients = status['p2s']['clients']
|
||||
lines.append(f'Road-warrior (IPsec P2S): {"unknown" if clients is None else f"{clients} connected"}')
|
||||
@@ -479,6 +520,8 @@ def users(state, method, path, body):
|
||||
POST /users/<name>/otp a new TOTP secret and its otpauth URI; nothing is stored
|
||||
PUT /users/<name>/otp store a TOTP secret: {"secret", "code"}, code a code of the secret
|
||||
DELETE /users/<name>/otp remove the TOTP token
|
||||
PUT /users/<name>/console switch console login on or off: {"enabled", "account"?}, account a local account,
|
||||
without which the user's own posixAccount; only a token of vpn-router-token
|
||||
|
||||
Service accounts are served by /service-accounts: they are not listed, and a request naming one is 404.
|
||||
"""
|
||||
@@ -502,6 +545,7 @@ def users(state, method, path, body):
|
||||
('POST', 3, 'otp'): lambda connection: vpnrouter_users.otp_new(connection, name),
|
||||
('PUT', 3, 'otp'): lambda connection: vpnrouter_users.otp_enrol(connection, name, body.get('secret'), body.get('code')),
|
||||
('DELETE', 3, 'otp'): lambda connection: vpnrouter_users.otp_remove(connection, name),
|
||||
('PUT', 3, 'console'): lambda connection: vpnrouter_users.console(connection, name, body.get('enabled'), body.get('account')),
|
||||
}
|
||||
# The vpnrouter_users operation of each route that may revoke refresh tokens.
|
||||
operations = {('PUT', 3, 'password'): 'passwd', ('POST', 3, 'disable'): 'disable', ('POST', 3, 'unlock'): 'unlock',
|
||||
@@ -776,18 +820,63 @@ def backup(method, path, body):
|
||||
return 200, {'settings': backup_settings(cfg['backup'])}
|
||||
|
||||
|
||||
def remote_access(state, method, path, body):
|
||||
"""Answer a /remote-access request: the HTTP status and the JSON document.
|
||||
|
||||
GET /remote-access {"p2s", "openvpn", "wireguard", "running", "local_fqdn"}: the settings of the three
|
||||
remote access systems (REMOTE_ACCESS), whether setup runs each (running: {p2s,
|
||||
openvpn, wireguard}), and wan.local_fqdn, which IKEv2 and OpenVPN need with the
|
||||
router's VPN server certificate
|
||||
PUT /remote-access/<system> change any of the settings of the system's section; answers the section
|
||||
|
||||
A change is made as /site-to-site makes one, and applied by restarting vpn-router-setup without waiting for it.
|
||||
"""
|
||||
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
body = body or {}
|
||||
parts = path.strip('/').split('/')
|
||||
try:
|
||||
if (method, len(parts)) == ('GET', 1):
|
||||
cfg = vpnrouter.load_config()
|
||||
return 200, {**{system: cfg[system] for system in REMOTE_ACCESS},
|
||||
'running': {system: bool(state.get(system)) for system in REMOTE_ACCESS},
|
||||
'local_fqdn': cfg['wan']['local_fqdn']}
|
||||
if method != 'PUT' or len(parts) != 2 or parts[1] not in REMOTE_ACCESS:
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
system = parts[1]
|
||||
settings = {option: body[option] for option in vpnrouter.SCHEMA[system] if option in body}
|
||||
with vpnrouter.config_lock():
|
||||
data = vpnrouter.read_settings()
|
||||
current = data.get(system)
|
||||
data[system] = {**(current if isinstance(current, dict) else {}), **settings}
|
||||
cfg = vpnrouter.with_defaults(data)
|
||||
vpnrouter.validate(cfg)
|
||||
vpnrouter.save_config(data)
|
||||
except vpnrouter.ConfigError as exc:
|
||||
return 400, {'error': str(exc)}
|
||||
except OSError as exc:
|
||||
return 500, {'error': f'the configuration cannot be written: {exc}'}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
|
||||
return 200, cfg[system]
|
||||
|
||||
|
||||
def ca(method, path, body):
|
||||
"""Answer a /ca request with vpnrouter_pki: the HTTP status and the JSON document.
|
||||
|
||||
GET /ca {"settings", "templates", "status", "certificates"}: the pki section,
|
||||
the templates with the built-in ones, the root, the issuing CAs, the
|
||||
retired CAs and the warnings (vpnrouter_pki.status), and the
|
||||
certificates issued, newest first
|
||||
GET /ca {"settings", "templates", "extended_key_usages", "status",
|
||||
"certificates"}: the pki section, the templates with the built-in
|
||||
ones, the extended key usages, each {oid, builtin}, the root, the
|
||||
issuing CAs, the retired CAs and the warnings (vpnrouter_pki.status),
|
||||
and the certificates issued, newest first
|
||||
PUT /ca change any of the settings of the pki section
|
||||
POST /ca/certificates issue a certificate: {"ca", "template", "csr"} answers {"serial",
|
||||
"certificate", "chain"}; {"ca", "template", "subject", "sans",
|
||||
"password"} answers {"serial", "pfx"}, a PKCS#12 file in base64
|
||||
POST /ca/certificates/<serial>/revoke revoke a certificate: {"reason"?}
|
||||
GET /ca/bundle/<name> the PEM certificates a client trusts the CA with: {"name", "pem"}; for
|
||||
root the root alone, for an issuing CA the issuing CA and the root
|
||||
GET /ca/issuing/<name>/csr the CSR of an issuing CA that waits for the offline root, with the
|
||||
openssl extensions to sign it with: {"name", "csr", "extensions"}
|
||||
PUT /ca/issuing/<name>/certificate take its certificate signed by the root: {"certificate"}
|
||||
@@ -803,8 +892,12 @@ def ca(method, path, body):
|
||||
route = (method, *parts[1:])
|
||||
try:
|
||||
cfg = vpnrouter.load_config()
|
||||
if len(route) == 3 and route[:2] == ('GET', 'bundle'):
|
||||
return 200, vpnrouter_pki.bundle(route[2])
|
||||
if route == ('GET',):
|
||||
return 200, {'settings': cfg['pki'], 'templates': vpnrouter.pki_templates(cfg),
|
||||
'extended_key_usages': {name: {'oid': oid, 'builtin': name in vpnrouter.EXTENDED_KEY_USAGES}
|
||||
for name, oid in vpnrouter.extended_key_usages(cfg).items()},
|
||||
'status': vpnrouter_pki.status(cfg), 'certificates': vpnrouter_pki.certificates()}
|
||||
if route == ('PUT',):
|
||||
settings = {option: body[option] for option in vpnrouter.SCHEMA['pki'] if option in body}
|
||||
@@ -836,6 +929,8 @@ def ca(method, path, body):
|
||||
return 400, {'error': str(exc)}
|
||||
except vpnrouter_pki.Failure as failure:
|
||||
return PKI_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||
except ldap.LDAPError as exc:
|
||||
return 500, {'error': f'the directory, which holds the PKI, failed: {exc}'}
|
||||
except OSError as exc:
|
||||
return 500, {'error': f'the PKI cannot be changed: {exc}'}
|
||||
except SystemExit as exc:
|
||||
@@ -937,8 +1032,11 @@ def refusal(claims, method, path, body):
|
||||
with vpnrouter_users.session() as connection:
|
||||
if not vpnrouter_users.is_admin(connection, name):
|
||||
return f'user {name} is not a member of {admins}'
|
||||
if changes_admins(connection, method, path.strip('/').split('/'), body if isinstance(body, dict) else {}):
|
||||
parts = path.strip('/').split('/')
|
||||
if changes_admins(connection, method, parts, body if isinstance(body, dict) else {}):
|
||||
return f'only a token of vpn-router-token may change the group {admins} or remove its members'
|
||||
if parts[0] == 'users' and len(parts) == 3 and parts[2] == 'console' and method != 'GET':
|
||||
return 'only a token of vpn-router-token may change console login'
|
||||
return ''
|
||||
|
||||
|
||||
@@ -979,6 +1077,77 @@ def auth(path, body):
|
||||
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 or the user's console login is not on.
|
||||
"""
|
||||
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'}
|
||||
if console_account(name) is None:
|
||||
return 403, {'error': f'console login is not enabled for {name}; vpn-router-user console sets 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 console_account(name):
|
||||
"""The system account the console of the user runs as, or None while its console login is not on."""
|
||||
with vpnrouter_users.session() as connection:
|
||||
console = vpnrouter_users.summary(vpnrouter_users.entry(connection, name))['console']
|
||||
return console['account'] if console and console['enabled'] else None
|
||||
|
||||
|
||||
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):
|
||||
"""Seconds until the client address may call /auth again, 0 when it may now; a call that may is counted."""
|
||||
now = time.monotonic()
|
||||
@@ -1026,6 +1195,170 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return 500, f'the user directory failed: {exc}'
|
||||
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 the user's console account
|
||||
(vpnrouter_users.console) 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, end and refusal is logged
|
||||
with its reason.
|
||||
"""
|
||||
def refuse(code, message):
|
||||
log(f'shell of {name} from {self.client()} refused: {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:
|
||||
login = console_account(name)
|
||||
except (vpnrouter_users.Failure, ldap.LDAPError, OSError) as exc:
|
||||
return refuse(500, f'the user directory failed: {exc}')
|
||||
if login is None:
|
||||
return refuse(403, f'console login is not enabled for {name}')
|
||||
try:
|
||||
account = pwd.getpwnam(login)
|
||||
except KeyError:
|
||||
return refuse(500, f'the system does not know the account {login}; is nslcd running?')
|
||||
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):
|
||||
path = self.path.split('?', 1)[0]
|
||||
extra = []
|
||||
@@ -1034,9 +1367,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if self.command not in ('GET', 'HEAD'):
|
||||
self.close_connection = True
|
||||
directory = next((name for name in ('users', 'service-accounts', 'groups', 'account', 'site-to-site', 'backup',
|
||||
'ca')
|
||||
'ca', 'shell', 'remote-access')
|
||||
if path == f'/{name}' or path.startswith(f'/{name}/')), None)
|
||||
protected = path in ('/config', '/metrics/history', '/postgresql') or directory is not None
|
||||
protected = path in ('/status/full', '/config', '/metrics/history', '/postgresql') or directory is not None
|
||||
signing = path in ('/auth', '/auth/refresh', '/auth/logout')
|
||||
state = current() if protected or signing else {}
|
||||
claims = self.claims(state['key']) if protected and state['key'] else None
|
||||
@@ -1061,6 +1394,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
elif protected and (refused := self.refusal(claims, path, payload)):
|
||||
code, kind = refused[0], 'application/json'
|
||||
body = json.dumps({'error': refused[1]}, indent=2).encode() + b'\n'
|
||||
elif path == '/status/full' and self.command in ('GET', 'HEAD'):
|
||||
document = shown(self.status(), 'full', self.client())
|
||||
body, kind, code = json.dumps(document, indent=2).encode() + b'\n', 'application/json', 200
|
||||
elif path == '/config' and self.command in ('GET', 'HEAD'):
|
||||
body, kind, code = json.dumps(details(state), indent=2).encode() + b'\n', 'application/json', 200
|
||||
elif path == '/postgresql' and self.command in ('GET', 'HEAD'):
|
||||
@@ -1069,12 +1405,23 @@ class Handler(BaseHTTPRequestHandler):
|
||||
query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)
|
||||
code, document = metrics_history(query.get('range', ['hour'])[0])
|
||||
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}'})
|
||||
if code != 200:
|
||||
log(f'shell ticket of {claims.get("sub")} from {self.client()} refused: {document["error"]}')
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif directory == 'account':
|
||||
code, document = account(claims['sub'], self.command, path, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif directory == 'site-to-site':
|
||||
code, document = site_to_site(self.command, path, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif directory == 'remote-access':
|
||||
code, document = remote_access(state, self.command, path, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif directory == 'backup':
|
||||
code, document = backup(self.command, path, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
#!/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,
|
||||
or makes a key and writes a PFX file whose password is read from the first line of standard input. ca csr prints the
|
||||
CSR of an issuing CA that waits for the offline root, with the openssl extensions to sign it with, and ca import takes
|
||||
the signed certificate. root export-key writes the root key encrypted with the passphrase read from standard input,
|
||||
root remove-key removes it from the router, root import-key puts it back, root import-cert places an existing root's
|
||||
certificate before the root exists, and root import-crl takes a root CRL signed offline. maintain signs the stale CRLs
|
||||
again; vpn-router-pki.service runs it daily. A change that needs vpn-router-setup restarts it without waiting.
|
||||
root remove-key removes it from the router, root import-key puts it back, and root import-crl takes a root CRL signed
|
||||
offline. maintain signs the stale CRLs again; vpn-router-pki.service runs it daily. A change that needs
|
||||
vpn-router-setup restarts it without waiting.
|
||||
|
||||
The answer is text for people on standard output, and a failure is a message on standard error. With --json the answer
|
||||
is JSON on standard output, and a failure is {"error": ...} there too. The exit status says what failed: 2 the request
|
||||
@@ -22,7 +22,6 @@ commands and their options.
|
||||
vpn-router-ca [--json] maintain
|
||||
vpn-router-ca [--json] ca csr NAME
|
||||
vpn-router-ca [--json] ca import NAME FILE
|
||||
vpn-router-ca [--json] root import-cert FILE
|
||||
vpn-router-ca [--json] root export-key --out FILE (passphrase on stdin)
|
||||
vpn-router-ca [--json] root remove-key [--yes]
|
||||
vpn-router-ca [--json] root import-key FILE (passphrase on stdin, an empty line for none)
|
||||
@@ -39,6 +38,8 @@ import sys
|
||||
|
||||
sys.path.insert(0, '/usr/lib/vpn-router')
|
||||
|
||||
import ldap
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_pki
|
||||
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'))
|
||||
restart_setup()
|
||||
return document, f'Issuing CA {options.name} has its certificate, which expires {document["expires"]}.'
|
||||
if options.action == 'import-cert':
|
||||
document = vpnrouter_pki.import_root_cert(pathlib.Path(options.file).read_bytes())
|
||||
restart_setup()
|
||||
return document, f'Root CA {document["subject"]} placed, without its key.'
|
||||
if options.action == 'export-key':
|
||||
data = vpnrouter_pki.export_root_key(secret())
|
||||
out = pathlib.Path(options.out)
|
||||
@@ -177,7 +174,6 @@ def main(argv):
|
||||
importer.add_argument('file')
|
||||
root = commands.add_parser('root', help='the root CA')
|
||||
root_actions = root.add_subparsers(dest='action', required=True, parser_class=Parser)
|
||||
root_actions.add_parser('import-cert', help="place an existing root's certificate").add_argument('file')
|
||||
exporter = root_actions.add_parser('export-key', help='write the root key encrypted with the passphrase from stdin')
|
||||
exporter.add_argument('--out', required=True, help='the file to write')
|
||||
remover = root_actions.add_parser('remove-key', help='remove the root key from the router')
|
||||
@@ -193,6 +189,8 @@ def main(argv):
|
||||
code, message = failure.status, str(failure)
|
||||
except OSError as exc:
|
||||
code, message = 1, str(exc)
|
||||
except ldap.LDAPError as exc:
|
||||
code, message = 1, f'the directory, which holds the PKI, failed: {exc}'
|
||||
except SystemExit as exc:
|
||||
code, message = 1, str(exc.code)
|
||||
else:
|
||||
|
||||
@@ -24,6 +24,7 @@ user exists, 1 anything else. -h or --help shows the commands and their options.
|
||||
vpn-router-user [--json] otp-new <name>
|
||||
vpn-router-user [--json] otp-enrol <name> <code> (secret on stdin)
|
||||
vpn-router-user [--json] otp-remove <name>
|
||||
vpn-router-user [--json] console <name> (--posix | --account LOCAL | --off)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -56,6 +57,8 @@ HELP = {
|
||||
'otp-new': 'make a TOTP secret and its otpauth URI; nothing is stored',
|
||||
'otp-enrol': 'store the TOTP secret read from standard input, when the code is a code of it',
|
||||
'otp-remove': 'remove the TOTP token',
|
||||
'console': "switch the console login of a member of admins on, as the user's own POSIX account in the directory "
|
||||
'or as a local account, or off',
|
||||
}
|
||||
|
||||
#: The help of the person options.
|
||||
@@ -86,6 +89,11 @@ def parse(argv):
|
||||
sub.add_argument('--service', action='store_true', help='add a service account, which cannot sign in to the web service')
|
||||
if command == 'otp-enrol':
|
||||
sub.add_argument('code', help='a code of the secret from the authenticator')
|
||||
if command == 'console':
|
||||
choice = sub.add_mutually_exclusive_group(required=True)
|
||||
choice.add_argument('--posix', action='store_true', help="run the console as the user's own POSIX account")
|
||||
choice.add_argument('--account', metavar='LOCAL', help='run the console as this local account')
|
||||
choice.add_argument('--off', action='store_true', help='switch console login off')
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
@@ -108,9 +116,18 @@ def run(connection, options, secret):
|
||||
return vpnrouter_users.passwd(connection, options.name, secret)
|
||||
if command == 'otp-enrol':
|
||||
return vpnrouter_users.otp_enrol(connection, options.name, secret, options.code)
|
||||
if command == 'console':
|
||||
return vpnrouter_users.console(connection, options.name, not options.off, options.account)
|
||||
return getattr(vpnrouter_users, command.replace('-', '_'))(connection, options.name)
|
||||
|
||||
|
||||
def console_text(console):
|
||||
"""The console login of a user as a cell: -, off, posix (uid N) or local and the account."""
|
||||
if console is None or not console['enabled']:
|
||||
return '-' if console is None else 'off'
|
||||
return f'local {console["account"]}' if console['local'] else f'posix (uid {console["uid"]})'
|
||||
|
||||
|
||||
def text_of(answer):
|
||||
"""The answer as text for people."""
|
||||
if 'profile' in answer:
|
||||
@@ -122,11 +139,12 @@ def text_of(answer):
|
||||
users = answer['users'] if 'users' in answer else [answer]
|
||||
if not users:
|
||||
return 'no users'
|
||||
head = ('USER', 'KIND', 'STATE', 'CERTIFICATE', 'OTP', 'FIRST NAME', 'LAST NAME', 'E-MAIL')
|
||||
head = ('USER', 'KIND', 'STATE', 'CERTIFICATE', 'OTP', 'CONSOLE', 'FIRST NAME', 'LAST NAME', 'E-MAIL')
|
||||
rows = [(user['name'], 'internal' if user['internal'] else 'service' if user['service'] else 'manager' if user['manager_only'] else 'vpn',
|
||||
'disabled' if not user['enabled'] else 'locked' if user['locked'] else 'enabled',
|
||||
'issued' if user['certificate'] else 'missing',
|
||||
'enrolled' if user['otp'] else '-', user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-')
|
||||
'enrolled' if user['otp'] else '-', console_text(user['console']),
|
||||
user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-')
|
||||
for user in users]
|
||||
widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))]
|
||||
return '\n'.join(' '.join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip() for row in (head, *rows))
|
||||
|
||||
@@ -61,7 +61,12 @@
|
||||
"port": 7081,
|
||||
"database": "",
|
||||
"trusted_proxies": [],
|
||||
"status_content": "basic"
|
||||
"status_content": "basic",
|
||||
"shell": false,
|
||||
"shell_idle_minutes": 15
|
||||
},
|
||||
"ssh": {
|
||||
"ldap_login": false
|
||||
},
|
||||
"https": {
|
||||
"enabled": true,
|
||||
@@ -92,6 +97,9 @@
|
||||
"days": 397
|
||||
}
|
||||
},
|
||||
"extended_key_usages": {
|
||||
"smartcard_logon": "1.3.6.1.4.1.311.20.2.2"
|
||||
},
|
||||
"root_key_warning": true
|
||||
},
|
||||
"metrics": {
|
||||
|
||||
Reference in new issue
Block a user