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

+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: