feature: a VPN user has WireGuard peers, each a device with its public key and IPs, kept in the directory and written into wg0.conf by setup.
Release / release (push) Successful in 13s

This commit is contained in:
slawek committed 2026-10-26 02:33:58 +01:00
1 parent 015fba50ca
commit f1d9c3bc36
8 files changed
+226 -33

No files matched your search

@@ -20,6 +20,10 @@ optional description and at least one member; a group cannot be empty, so the la
cannot leave it and a user who is the last member of a group cannot be removed. Groups only
record membership; nothing on the router reads them.
A VPN user may have WireGuard peers, each a device with its public key and its IPs, in
vpnRouterWireGuardPeer; vpn-router-setup writes a [Peer] of wg0.conf for each peer of a user who
is not disabled.
A user may have a TOTP token: the user entry itself is an oathTOTPUser and oathTOTPToken whose
oathTOTPToken points to itself, with the secret in oathSecret and the parameters of
vpnrouter_ldap.TOTP_PARAMS. Enrolment stores nothing until a code of the new secret is
@@ -48,6 +52,7 @@ import calendar
import contextlib
import fcntl
import grp
import ipaddress
import pwd
import re
import subprocess
@@ -78,7 +83,9 @@ LABELS = (('display_name', 'displayName'), ('description', 'description'))
#: The attributes read for a user.
ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken', 'vpnRouterScope',
'displayName', 'description', 'userCertificate;binary', 'vpnRouterConsoleLogin', 'vpnRouterConsoleAccount',
'uidNumber']
'uidNumber', 'vpnRouterWireGuardPeer']
#: The most WireGuard peers of one user.
WIREGUARD_PEERS_MAX = 16
#: The vpnRouterScope of a service account.
SERVICE_SCOPE = 'service'
#: The object classes of a VPN user and of a Manager-only account.
@@ -94,7 +101,7 @@ GROUP_ATTRIBUTES = ['cn', 'description', 'member']
#: The longest group description, in characters.
DESCRIPTION_MAX = 256
#: The value of pwdAccountLockedTime that disables an account until it is enabled.
LOCKED = b'000001010000Z'
LOCKED = vpnrouter_ldap.DISABLED.encode()
#: The group whose members manage the router through the web service.
ADMIN_GROUP = 'admins'
#: The digits of a TOTP code given to login().
@@ -244,6 +251,7 @@ def summary(attributes):
'certificate': 'userCertificate;binary' in attributes,
'otp': 'oathTOTPToken' in attributes,
'console': console_of(attributes),
'wireguard_peers': peers_of(attributes),
}
for key, attribute in PERSON + LABELS:
values = attributes.get(attribute)
@@ -267,6 +275,62 @@ def console_of(attributes):
}
def peers_of(attributes):
"""The WireGuard peers of an entry as the answers show them: [{public_key, ips}], in the order they are stored."""
peers = []
for value in attributes.get('vpnRouterWireGuardPeer', []):
key, _, ips = value.decode().partition(' ')
peers.append({'public_key': key, 'ips': ips.split(',')})
return peers
def checked_peers(connection, name, peers):
"""The values of vpnRouterWireGuardPeer for the user's WireGuard peers, [{public_key, ips}], when they are valid.
A public key is 32 bytes in base64, used by no other peer of any user; each peer has at least one IP, an IPv4
address or network, which no other peer of any user covers and which does not cover the router's own address of
wireguard.address. A user has at most WIREGUARD_PEERS_MAX peers.
"""
if not isinstance(peers, list) or len(peers) > WIREGUARD_PEERS_MAX:
raise Failure(f'wireguard_peers must be a list of at most {WIREGUARD_PEERS_MAX} peers', 2)
router = vpnrouter.load_config()['wireguard']['address'].strip()
taken = [(other['name'], key, network)
for other in list_users(connection)['users'] if other['name'] != name
for peer in other['wireguard_peers'] for key in [peer['public_key']]
for network in (ipaddress.ip_network(ip) for ip in peer['ips'])]
values = []
for peer in peers:
key = peer.get('public_key') if isinstance(peer, dict) else None
ips = peer.get('ips') if isinstance(peer, dict) else None
try:
if not isinstance(key, str) or len(base64.b64decode(key.strip(), validate=True)) != 32:
raise ValueError
except ValueError:
raise Failure(f'{key!r} is not a WireGuard public key: 32 bytes in base64', 2)
key = key.strip()
if not isinstance(ips, list) or not ips or not all(isinstance(ip, str) for ip in ips):
raise Failure(f'the peer {key} needs at least one IP', 2)
networks = []
for ip in ips:
try:
network = ipaddress.ip_network(ip.strip(), strict=True)
except ValueError:
raise Failure(f'{ip!r} is not an IPv4 address or network of the peer {key}', 2)
if network.version != 4:
raise Failure(f'{ip!r} of the peer {key} is not IPv4', 2)
if router and ipaddress.ip_interface(router).ip in network:
raise Failure(f'{network} of the peer {key} covers the router\'s own address {router}', 2)
for owner, other_key, other in taken:
if other_key == key:
raise Failure(f'the key {key} is a peer of {owner}', 4)
if network.overlaps(other):
raise Failure(f'{network} of the peer {key} overlaps {other} of {owner}', 4)
networks.append(network)
taken += [(name, key, network) for network in networks]
values.append(f'{key} {",".join(str(network) for network in networks)}'.encode())
return values
def classes(attributes):
"""The object classes of an entry, in lower case."""
return {value.decode().lower() for value in attributes.get('objectClass', [])}
@@ -412,7 +476,8 @@ def add(connection, name, password, values, service=False):
def update(connection, name, values):
"""Change the person attributes values gives; the cn follows the first and the last name.
"""Change the person attributes values gives, the cn following the first and the last name, and of a VPN user the
WireGuard peers, wireguard_peers, which replace the user's peers (checked_peers).
A Manager-only account has only the e-mail address, which '' removes; a service account also its labels
(update_service).
@@ -421,6 +486,8 @@ def update(connection, name, values):
if service(current):
return update_service(connection, name, values)
if manager_only(current):
if values.get('wireguard_peers') is not None:
raise Failure(f'user {name} is a Manager-only account, which has no VPN access and so no WireGuard peers', 2)
if values.get('first_name') is not None or values.get('last_name') is not None:
raise Failure(f'user {name} is a Manager-only account, which has no first or last name', 2)
email = values.get('email')
@@ -433,16 +500,30 @@ def update(connection, name, values):
connection.modify_s(dn_of(name), [(ldap.MOD_REPLACE, 'mail', [person({'email': email}, required=False)['mail'].encode()])])
return summary(entry(connection, name))
details = person(values, required=False)
if not details:
raise Failure('give at least one of first_name, last_name and email', 2)
names = {attribute: current[attribute][0].decode() for attribute in ('givenName', 'sn') if current.get(attribute)}
names.update({attribute: details[attribute] for attribute in ('givenName', 'sn') if attribute in details})
changes = [(ldap.MOD_REPLACE, attribute, [value.encode()]) for attribute, value in details.items()]
changes.append((ldap.MOD_REPLACE, 'cn', [common_name(names, name).encode()]))
peers = values.get('wireguard_peers')
if not details and peers is None:
raise Failure('give at least one of first_name, last_name, email and wireguard_peers', 2)
changes = []
if details:
names = {attribute: current[attribute][0].decode() for attribute in ('givenName', 'sn') if current.get(attribute)}
names.update({attribute: details[attribute] for attribute in ('givenName', 'sn') if attribute in details})
changes += [(ldap.MOD_REPLACE, attribute, [value.encode()]) for attribute, value in details.items()]
changes.append((ldap.MOD_REPLACE, 'cn', [common_name(names, name).encode()]))
if peers is not None:
if 'vpnrouteraccount' not in classes(current):
changes.append((ldap.MOD_ADD, 'objectClass', [b'vpnRouterAccount']))
changes.append((ldap.MOD_REPLACE, 'vpnRouterWireGuardPeer', checked_peers(connection, name, peers)))
connection.modify_s(dn_of(name), changes)
if peers is not None:
apply_wireguard()
return summary(entry(connection, name))
def apply_wireguard():
"""Have vpn-router-setup write the WireGuard peers of the users into wg0.conf, without waiting for it."""
subprocess.run(['systemctl', 'restart', '--no-block', 'vpn-router-setup.service'], capture_output=True)
def update_service(connection, name, values):
"""Change any of the e-mail address and the labels (labels) of a service account that values gives; '' removes one."""
if not service(entry(connection, name)):
@@ -466,14 +547,22 @@ def passwd(connection, name, password):
def disable(connection, name):
if entry(connection, name).get('pwdAccountLockedTime') != [LOCKED]:
"""Lock the user out; the WireGuard peers of a disabled user are left out of wg0.conf."""
current = entry(connection, name)
if current.get('pwdAccountLockedTime') != [LOCKED]:
connection.modify_s(dn_of(name), [(ldap.MOD_REPLACE, 'pwdAccountLockedTime', [LOCKED])])
if 'vpnRouterWireGuardPeer' in current:
apply_wireguard()
return summary(entry(connection, name))
def enable(connection, name):
if entry(connection, name).get('pwdAccountLockedTime') == [LOCKED]:
"""Let the user in again, with the WireGuard peers."""
current = entry(connection, name)
if current.get('pwdAccountLockedTime') == [LOCKED]:
connection.modify_s(dn_of(name), [(ldap.MOD_DELETE, 'pwdAccountLockedTime', None)])
if 'vpnRouterWireGuardPeer' in current:
apply_wireguard()
return summary(entry(connection, name))
@@ -579,6 +668,8 @@ def remove(connection, name):
raise Failure(f'the certificate cannot be revoked: {failure}', failure.status)
connection.delete_s(dn_of(name))
end_sessions(current)
if 'vpnRouterWireGuardPeer' in current:
apply_wireguard()
return {'name': name, 'removed': True}