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.
Release / release (push) Successful in 29s

This commit is contained in:
slawek committed 2026-10-25 23:03:03 +01:00
1 parent e11c88f0de
commit 448ce05b3a
10 files changed
+796 -509

No files matched your search

+24
View File
@@ -1,3 +1,27 @@
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.
+16 -10
View File
@@ -577,7 +577,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 +592,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 +744,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 +788,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)
@@ -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:
@@ -1011,6 +1012,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 +1020,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,9 @@ The password policy locks an account for LOCKOUT_SECONDS after LOCKOUT_FAILURES
a successful bind clears the count. slapd's otp overlay is configured on the database, so a
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.
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
@@ -29,6 +32,7 @@ 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}'
@@ -54,16 +58,43 @@ OVERLAYS = ('ppolicy', 'otp')
#: 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.
#: description, the labels of a service account. vpnRouterAuthority is a CA of the PKI: its
#: certificate and CRL (cACertificate and certificateRevocationList of RFC 4523), its key, the
#: CSR it waits with for the offline root, the number of its last CRL, where the CRL came from,
#: and when it was retired. vpnRouterCertificate is a certificate a CA issued, named by its
#: serial number: the certificate, the key the router keeps for its own and the users'
#: certificates, its kind, template, names and expiry, and its revocation.
SCHEMA_OID = '2.25.98477352595735834532404256531620013001'
SCHEMA_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} )",
)
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 ) )",
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.
@@ -74,6 +105,7 @@ TOTP_WINDOW = 1
#: 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 +158,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):
@@ -230,7 +269,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
@@ -68,7 +69,7 @@ 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']
#: The vpnRouterScope of a service account.
SERVICE_SCOPE = 'service'
#: The object classes of a VPN user and of a Manager-only account.
@@ -173,9 +174,6 @@ def dn_of(name):
return f'uid={name},{vpnrouter_ldap.USERS}'
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,7 +232,7 @@ 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,
}
for key, attribute in PERSON + LABELS:
@@ -468,9 +466,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 +485,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}
@@ -788,6 +788,8 @@ def ca(method, path, body):
"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,6 +805,8 @@ 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),
'status': vpnrouter_pki.status(cfg), 'certificates': vpnrouter_pki.certificates()}
@@ -836,6 +840,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:
+8 -10
View File
@@ -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: