Compare commits

...
2 Commits
6 changed files with 114 additions and 43 deletions

No files matched your search

+21 -7
View File
@@ -144,6 +144,7 @@ must have the type shown below; an unknown section or setting is an error.
| `pki` | `s2s_name` | string | `VPN Router Site-to-Site CA` | Common name of the site-to-site issuing CA |
| `pki` | `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 +443,8 @@ A template says what a certificate holds:
| `key` | string | `rsa-3072` | `rsa-2048`, `rsa-3072`, `rsa-4096`, `ec-p256` or `ec-p384`, for a key the router makes |
| `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 +463,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 +804,7 @@ configuration: no key, identity, subnet, or address but the client's. They show
|---|---|
| `basic` (default) | The overall status (`ok` or `degraded`), the time and the Client IP |
| `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 +852,9 @@ road-warrior access is configured, of `wg-quick@wg0` when WireGuard is, and of
OpenVPN clients, from the server's status file, which OpenVPN rewrites every minute; the package version and,
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.
@@ -1072,7 +1085,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 |
@@ -1086,6 +1099,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`.
+23
View File
@@ -1,3 +1,26 @@
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.
@@ -57,7 +57,7 @@ SCHEMA = {
'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 = {
@@ -502,6 +516,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 +539,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 +690,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)
+16 -11
View File
@@ -56,9 +56,9 @@ default the address the call came in on, is the last entry of its X-Forwarded-Fo
default, the overall status, the time and the client's address (its address as for the rate limit
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
@@ -234,8 +234,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'):
@@ -401,8 +402,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"}')
@@ -781,10 +783,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",
@@ -811,6 +814,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}
@@ -92,6 +92,9 @@
"days": 397
}
},
"extended_key_usages": {
"smartcard_logon": "1.3.6.1.4.1.311.20.2.2"
},
"root_key_warning": true
},
"metrics": {