Compare commits

...
4 Commits
7 changed files with 478 additions and 49 deletions

No files matched your search

+26
View File
@@ -895,3 +895,29 @@ Consequences: `slapcat` writes the users and the PKI into every backup in one co
restore brings both back together. The PKI depends on slapd, as the users already do, and not on
PostgreSQL. The directory holds the CA keys, so it is as sensitive as the CA itself. The schema
grows by the package's PKI attributes and classes, which setup adds to slapd's configuration.
## ADR-33: The web service opens a shell for the Router Manager over a WebSocket
Context: The Router Manager's Shell page gives an administrator a terminal on the router in the
browser, with xterm.js, the terminal of VS Code. A terminal needs a full-duplex channel, which
xterm.js expects as a WebSocket. The web service runs in a sandbox (`NoNewPrivileges`,
`ProtectSystem=strict`, `ProtectHome`) that a shell must not inherit, and as root, which a shell
in a browser should not be. A stolen session cookie or access token alone must not give a shell.
Decision: `/shell` of the web service is off until `web.shell` is set on the router; the Router
Manager cannot set it. The shell runs as `web.shell_user`, a system user other than root, which
reaches root through its sudoers entry. `POST /shell` signs the user in again with the password
and TOTP code and gives a one-time ticket valid 30 seconds; a WebSocket on `GET /shell` with that
ticket gets the shell. The WebSocket of RFC 6455 is implemented on the standard library inside the
web service's `http.server`, server side only, without extensions. `systemd-run --pty` starts the
shell in a transient unit with a PAM login session, outside the sandbox, and `setsid --ctty`
makes the terminal its controlling terminal so window sizes reach the shell. A shell idle for
`web.shell_idle_minutes` is closed, and the journal records every opening and end. The Router
Manager checks the session and the WebSocket's origin, and relays the bytes to the router after
both handshakes.
Consequences: Who can sign in as a member of `admins` and knows the password and the current code
can get a shell when the router allows one; a session alone cannot. The ticket travels in the URL
and appears in request logs, used up. The shell's sessions show up as `vpn-router-shell-<id>`
units and logind sessions of `web.shell_user`. The shell's traffic is as protected as the Router
Manager's: HTTPS to Caddy, plain HTTP on the internal network to the web service.
+58 -8
View File
@@ -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 of `shell_user` for a member of `admins` (see The shell) |
| `web` | `shell_user` | string | | The system user the shell runs as, not `root`; required by `shell` |
| `web` | `shell_idle_minutes` | integer | `15` | Minutes without a keystroke after which a shell is closed, 1 to 1440 |
| `https` | `enabled` | boolean | `true` | Caddy serves the status web service and the Router Manager over HTTPS for `wan.local_fqdn` |
| `https` | `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` |
@@ -442,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` |
@@ -461,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
@@ -792,7 +807,7 @@ configuration: no key, identity, subnet, or address but the client's. They show
|---|---|
| `basic` (default) | The overall status (`ok` or `degraded`), the time and the Client IP |
| `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
@@ -840,8 +855,9 @@ road-warrior access is configured, of `wg-quick@wg0` when WireGuard is, and of
OpenVPN clients, from the server's status file, which OpenVPN rewrites every minute; the package version and,
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.
@@ -852,7 +868,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`, `/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
@@ -904,6 +920,39 @@ curl -H 'Content-Type: application/json' -d '{"name": "alice", "password": "s3cr
curl -H "Authorization: Bearer $TOKEN" http://10.0.0.4:7081/config
```
### The /status/full page
`/status/full`, with the same token as `/config`, shows the whole status document, as `/status`
does with `web.status_content` `full`, whatever that setting says, so the Router Manager shows
every service and tunnel while the public status page shows less.
### The shell
While `web.shell` is `true`, `/shell` gives a member of `admins` a login shell on the router, as
the system user `web.shell_user`, for the Router Manager's Shell page. It is off by default and
switched on and off on the router only:
```sh
vpn-config-edit set web.shell_user azureuser
vpn-config-edit set web.shell true
```
| Request | Body | Answer |
| --- | --- | --- |
| `POST /shell` | `{"password", "code"}`; `code` only for a user enrolled for TOTP | `{"ticket", "expires_in": 30}` |
| `GET /shell?ticket=<ticket>` | a WebSocket handshake | the terminal |
`POST /shell` needs an access token of `/auth`, not one of `vpn-router-token`, and signs its user
in again: a wrong password or code gets 403 and counts as a failed login. The ticket opens one
shell for the same user within 30 seconds. Over the WebSocket, binary messages of the client
are keystrokes, a text message `{"cols", "rows"}` sets the window size, and binary messages of
the router are the terminal's output. The shell is a PAM login session of `web.shell_user`, run
by `systemd-run` in a transient unit `vpn-router-shell-<id>`; sudo works as the user's sudoers
entry allows. It is closed after `web.shell_idle_minutes` without a keystroke, when the shell
exits, or when the connection closes; the router then sends a close frame with the reason. The
journal of `vpn-router-web` records who opened each shell, from which address, in which unit,
when it ended and why.
### The /config page
`/config` shows what `/` and `/status` leave out, for a client such as manage-ui:
@@ -1066,7 +1115,7 @@ 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 |
@@ -1080,6 +1129,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`.
+44
View File
@@ -1,3 +1,47 @@
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.
@@ -52,12 +52,12 @@ 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_user': '', 'shell_idle_minutes': 15},
'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',
@@ -136,9 +136,23 @@ PKI_PUBLISH = ('http', 'http-80', 'https', 'https-default')
#: The key usages a template may give, as the arguments of cryptography's KeyUsage.
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')
@@ -148,7 +162,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 = {
@@ -180,6 +194,12 @@ 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')
#: A system user the shell of the web service runs as, web.shell_user; root is refused.
SHELL_USER = re.compile(r'^[a-z_][a-z0-9_-]{0,31}$')
#: The longest web.shell_idle_minutes, a day.
SHELL_IDLE_MAX = 1440
DOCKER_MODES = ('auto', 'on', 'off')
OPENVPN_PROTOCOLS = ('udp', 'tcp')
@@ -463,6 +483,15 @@ def validate(cfg):
raise ConfigError(
f'web.status_content: {status_content!r} is not one of {", ".join(STATUS_CONTENTS)}')
shell_user = _get(cfg, 'web', 'shell_user')
if shell_user and (not SHELL_USER.match(shell_user) or shell_user == 'root'):
raise ConfigError(f'web.shell_user: {shell_user!r} is not a system user name other than root')
if _get(cfg, 'web', 'shell') and not shell_user:
raise ConfigError('web.shell needs web.shell_user, the system user the shell runs as')
shell_idle = _get(cfg, 'web', 'shell_idle_minutes')
if not 1 <= shell_idle <= SHELL_IDLE_MAX:
raise ConfigError(f'web.shell_idle_minutes: {shell_idle} is not between 1 and {SHELL_IDLE_MAX}')
https_ports = []
for option in ('web_port', 'manager_port'):
port = _get(cfg, 'https', option)
@@ -502,6 +531,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):
@@ -517,11 +554,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:
@@ -665,6 +705,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."""
@@ -44,7 +44,7 @@ from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec, rsa
from cryptography.hazmat.primitives.serialization import pkcs12
from cryptography.x509.oid import AuthorityInformationAccessOID, ExtendedKeyUsageOID, NameOID
from cryptography.x509.oid import AuthorityInformationAccessOID, NameOID
import vpnrouter
import vpnrouter_ldap
@@ -89,18 +89,6 @@ CERTIFICATE_ATTRIBUTES = ['serialNumber', 'cn', 'vpnRouterKind', 'vpnRouterTempl
#: The suffix of a retired CA's name: -<time of the retirement>.
RETIRED_SUFFIX = re.compile(r'-\d{14}$')
#: The extended key usages of vpnrouter.EXTENDED_KEY_USAGES.
EKU_OIDS = {
'server_auth': ExtendedKeyUsageOID.SERVER_AUTH,
'client_auth': ExtendedKeyUsageOID.CLIENT_AUTH,
'code_signing': ExtendedKeyUsageOID.CODE_SIGNING,
'email_protection': ExtendedKeyUsageOID.EMAIL_PROTECTION,
'time_stamping': ExtendedKeyUsageOID.TIME_STAMPING,
'ocsp_signing': ExtendedKeyUsageOID.OCSP_SIGNING,
'ipsec_ike': ExtendedKeyUsageOID.IPSEC_IKE,
'ike_intermediate': x509.ObjectIdentifier('1.3.6.1.5.5.8.2.2'),
}
#: The revocation reasons revoke takes.
REASONS = ('unspecified', 'key_compromise', 'affiliation_changed', 'superseded', 'cessation_of_operation',
'privilege_withdrawn')
@@ -457,13 +445,19 @@ def general_names(values, allowed):
return names
def leaf_extensions(template, names):
"""The extensions of a certificate of the template with the subject alternative names."""
def leaf_extensions(cfg, template, names):
"""The extensions of a certificate of the template with the subject alternative names: the extended key usages by
their OIDs (vpnrouter.extended_key_usages), and the template's policies as certificate policies."""
usage = x509.KeyUsage(**{option: option in template['key_usage'] for option in vpnrouter.KEY_USAGES},
key_cert_sign=False, crl_sign=False, encipher_only=False, decipher_only=False)
extensions = [(x509.BasicConstraints(ca=False, path_length=None), True), (usage, True)]
if template['extended_key_usage']:
extensions.append((x509.ExtendedKeyUsage([EKU_OIDS[item] for item in template['extended_key_usage']]), False))
oids = vpnrouter.extended_key_usages(cfg)
extensions.append((x509.ExtendedKeyUsage([x509.ObjectIdentifier(oids[item])
for item in template['extended_key_usage']]), False))
if template['policies']:
extensions.append((x509.CertificatePolicies([x509.PolicyInformation(x509.ObjectIdentifier(oid), None)
for oid in template['policies']]), False))
if names:
extensions.append((x509.SubjectAlternativeName(names), False))
return extensions
@@ -589,7 +583,7 @@ def ensure_router_cert(connection, cfg, ctx, name, urls):
template = vpnrouter.pki_templates(cfg)[template_name]
key = new_key(template['key'])
cert = sign(subject, key.public_key(), issuer['cert'], issuer['key'], template['days'],
leaf_extensions(template, general_names(sans, vpnrouter.SAN_TYPES)), urls, ca)
leaf_extensions(cfg, template, general_names(sans, vpnrouter.SAN_TYPES)), urls, ca)
record(connection, cert, ca, 'router', template_name, key)
for dn, _, _ in current:
drop_key(connection, dn)
@@ -852,7 +846,7 @@ def issue(cfg, ca, template, csr=None, subject=None, sans=None, password=None):
subject = check_subject(values['subject'], subject)
names = general_names(([subject] if values['subject'] == 'dns' else []) + list(sans),
set(values['sans']) | ({'dns'} if values['subject'] == 'dns' else set()))
cert = sign(subject, public_key, issuer['cert'], issuer['key'], values['days'], leaf_extensions(values, names),
cert = sign(subject, public_key, issuer['cert'], issuer['key'], values['days'], leaf_extensions(cfg, values, names),
vpnrouter.pki_base_urls(cfg), ca)
record(connection, cert, ca, 'external', template)
root = authority(connection, 'root')['cert']
@@ -880,7 +874,7 @@ def issue_user(cfg, name, email):
issuer, values = issuer_of(connection, cfg, 'p2s', 'vpn-user')
key = new_key(values['key'])
cert = sign(name, key.public_key(), issuer['cert'], issuer['key'], values['days'],
leaf_extensions(values, general_names([name, email], vpnrouter.SAN_TYPES)),
leaf_extensions(cfg, values, general_names([name, email], vpnrouter.SAN_TYPES)),
vpnrouter.pki_base_urls(cfg), 'p2s')
for dn, _, _ in holders(connection, 'p2s', user_condition(name)):
drop_key(connection, dn)
+276 -14
View File
@@ -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,
@@ -32,6 +34,9 @@ Pages:
/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 web.shell_user on a terminal: POST signs the
user in again for a one-time ticket (see shell_ticket()), GET with it is a WebSocket
that carries the terminal (see Handler.shell())
/auth sign-in with a user's password and TOTP code, refresh and sign-out (see auth())
/metrics/history
the metrics the metrics service keeps, for a view: hour, day, month or year (see
@@ -40,7 +45,7 @@ Pages:
the databases of each PostgreSQL cluster on the router with their sizes, as JSON (see
postgresql_databases()); /config has the clusters' settings
/config, /metrics/history, /postgresql, /users, /service-accounts, /groups, /site-to-site, /backup, /ca and /account need an HS256 JWT in an Authorization: Bearer header signed
/status/full, /config, /metrics/history, /postgresql, /users, /service-accounts, /groups, /site-to-site, /backup, /ca, /shell and /account need an HS256 JWT in an Authorization: Bearer header signed
with vpnrouter_auth.WEB_KEY (see vpnrouter_auth.verify). A token of /auth names a user in sub;
/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
@@ -54,9 +59,9 @@ default the address the call came in on, is the last entry of its X-Forwarded-Fo
default, the overall status, the time and the client's address (its address as for the rate limit
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 +79,23 @@ key, the configuration, and the features of the router and the facts shown by /c
the router has no features, and /config shows no configuration or interfaces.
"""
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
@@ -142,6 +157,18 @@ PKI_STATUS = {2: 400, 3: 404, 4: 409}
#: A file /pki/ serves: the certificate or the CRL of a CA, and its content type.
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 +176,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 +263,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 +431,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"}')
@@ -779,10 +812,11 @@ def backup(method, path, body):
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",
@@ -809,6 +843,8 @@ def ca(method, path, body):
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}
@@ -985,6 +1021,68 @@ 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.
"""
if not isinstance(name, str):
return 403, {'error': 'the shell needs a token of a signed-in user'}
if not isinstance(body, dict):
return 400, {'error': 'the body must be a JSON object'}
try:
if not vpnrouter.load_config()['web']['shell']:
return 403, {'error': 'the shell is disabled; vpn-config-edit set web.shell true enables it'}
vpnrouter_users.login(name, body.get('password'), body.get('code'))
except vpnrouter_users.Failure as failure:
if failure.status == 5:
return 403, {'error': 'the password or the code is wrong'}
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
except (ldap.LDAPError, OSError) as exc:
return 500, {'error': f'the user directory failed: {exc}'}
except SystemExit as exc:
return 500, {'error': str(exc.code)}
ticket = secrets.token_urlsafe(32)
now = time.monotonic()
with _shell_lock:
for unused in [key for key, (_, expires) in _shell_tickets.items() if expires <= now]:
del _shell_tickets[unused]
_shell_tickets[ticket] = (name, now + SHELL_TICKET_SECONDS)
return 200, {'ticket': ticket, 'expires_in': SHELL_TICKET_SECONDS}
def take_ticket(name, ticket):
"""Whether the ticket of POST /shell is unexpired and was given to the user; it is used up either way."""
with _shell_lock:
holder, expires = _shell_tickets.pop(ticket, (None, 0.0))
return holder == name and expires > time.monotonic()
def shell_command(account, unit):
"""The command that runs the login shell of the system user account in the transient unit, on its terminal.
setsid makes the terminal the controlling one of systemd-run, which passes the window size on; systemd-run starts
the shell outside the web service's sandbox, in a PAM login session of the user.
"""
return ['setsid', '--ctty', 'systemd-run', '--quiet', '--collect', '--pty', '--send-sighup', f'--unit={unit}',
f'--uid={account.pw_name}', '--property=PAMName=login', f'--working-directory={account.pw_dir}',
'--setenv=TERM=xterm-256color', account.pw_shell or '/bin/sh', '-l']
def frame(opcode, payload):
"""A final, unmasked WebSocket frame of a server."""
size = len(payload)
if size < 126:
length = bytes([size])
elif size < 65536:
length = bytes([126]) + size.to_bytes(2, 'big')
else:
length = bytes([127]) + size.to_bytes(8, 'big')
return bytes([0x80 | opcode]) + length + payload
def auth_wait(address):
"""Seconds until the client address may call /auth again, 0 when it may now; a call that may is counted."""
now = time.monotonic()
@@ -1032,6 +1130,161 @@ 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 web.shell_user on a terminal.
The ticket must come from POST /shell for the user named by the token. Binary messages of the client are its
keystrokes, a text message {"cols", "rows"} sets the window size, and binary messages of the server are the
terminal's output. The shell ends after web.shell_idle_minutes without a keystroke, when it exits, or when the
client closes the connection; the server then closes it with the reason. Each opening and end is logged.
"""
def refuse(code, message):
body = json.dumps({'error': message}).encode() + b'\n'
self.send_response(code)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
key = self.headers.get('Sec-WebSocket-Key', '').strip()
if self.headers.get('Upgrade', '').lower() != 'websocket' or 'upgrade' not in self.headers.get('Connection', '').lower() \
or self.headers.get('Sec-WebSocket-Version') != '13' or not key:
return refuse(400, 'GET /shell must be a WebSocket handshake')
query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)
if not take_ticket(name, query.get('ticket', [''])[0]):
return refuse(403, 'the ticket is not valid; POST /shell gives one')
try:
options = vpnrouter.load_config()['web']
except SystemExit as exc:
return refuse(500, str(exc.code))
if not options['shell']:
return refuse(403, 'the shell is disabled; vpn-config-edit set web.shell true enables it')
try:
account = pwd.getpwnam(options['shell_user'])
except KeyError:
return refuse(500, f'the system user {options["shell_user"]} of web.shell_user does not exist')
unit = f'vpn-router-shell-{secrets.token_hex(4)}'
master, terminal = os.openpty()
try:
process = subprocess.Popen(shell_command(account, unit), stdin=terminal, stdout=terminal, stderr=terminal,
env={'PATH': '/usr/sbin:/usr/bin:/sbin:/bin'})
except OSError as exc:
os.close(master)
return refuse(500, f'the shell cannot be started: {exc}')
finally:
os.close(terminal)
accept = base64.b64encode(hashlib.sha1((key + WEBSOCKET_GUID).encode()).digest()).decode()
self.send_response(101, 'Switching Protocols')
self.send_header('Upgrade', 'websocket')
self.send_header('Connection', 'Upgrade')
self.send_header('Sec-WebSocket-Accept', accept)
self.end_headers()
self.close_connection = True
client, started, idle = self.client(), time.monotonic(), options['shell_idle_minutes'] * 60
log(f'shell of {name} from {client} opened as {account.pw_name} in {unit}')
lock, ended, typed = threading.Lock(), threading.Event(), [started]
def send(opcode, payload):
with lock:
self.wfile.write(frame(opcode, payload))
def receive():
"""Pass the client's keystrokes and window sizes to the terminal until it closes the connection."""
try:
while True:
opcode, payload = self.message(send)
if opcode == CLOSE:
break
if opcode == BINARY:
os.write(master, payload)
typed[0] = time.monotonic()
elif opcode == TEXT:
size = json.loads(payload)
fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack('HHHH', int(size['rows']), int(size['cols']), 0, 0))
except (OSError, ValueError, KeyError, TypeError, struct.error):
pass
finally:
ended.set()
reader = threading.Thread(target=receive, name=f'{unit} input', daemon=True)
reader.start()
reason = 'the shell ended'
try:
while not ended.is_set():
left = typed[0] + idle - time.monotonic()
if left <= 0:
reason = f'no keystroke for {options["shell_idle_minutes"]} minutes'
break
if select.select([master], [], [], min(left, 1))[0]:
try:
output = os.read(master, 65536)
except OSError:
output = b''
if not output:
break
send(BINARY, output)
else:
reason = 'the client closed the connection'
except OSError:
reason = 'the connection failed'
run('systemctl', 'stop', unit)
try:
send(CLOSE, (1000).to_bytes(2, 'big') + reason.encode()[:120])
except OSError:
pass
try:
self.connection.shutdown(socket.SHUT_RDWR)
except OSError:
pass
reader.join()
os.close(master)
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
log(f'shell of {name} from {client} in {unit} ended after {int(time.monotonic() - started)} seconds: {reason}')
def answer(self, send_body):
path = self.path.split('?', 1)[0]
extra = []
@@ -1040,9 +1293,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')
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
@@ -1067,6 +1320,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'):
@@ -1075,6 +1331,12 @@ 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}'})
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'
@@ -61,7 +61,10 @@
"port": 7081,
"database": "",
"trusted_proxies": [],
"status_content": "basic"
"status_content": "basic",
"shell": false,
"shell_user": "",
"shell_idle_minutes": 15
},
"https": {
"enabled": true,
@@ -92,6 +95,9 @@
"days": 397
}
},
"extended_key_usages": {
"smartcard_logon": "1.3.6.1.4.1.311.20.2.2"
},
"root_key_warning": true
},
"metrics": {