From f1d9c3bc361e43c918ea40d4cabd07969908b131 Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Mon, 26 Oct 2026 02:33:58 +0100 Subject: [PATCH] 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. --- ADR.md | 20 ++++ README.md | 18 ++- debian-package/debian/changelog | 16 +++ .../src/usr/lib/vpn-router/vpnrouter.py | 47 ++++++-- .../src/usr/lib/vpn-router/vpnrouter_ldap.py | 27 ++++- .../src/usr/lib/vpn-router/vpnrouter_users.py | 113 ++++++++++++++++-- debian-package/src/usr/lib/vpn-router/web | 9 +- .../vpn-router/templates/wg0-peers.conf.j2 | 9 ++ 8 files changed, 226 insertions(+), 33 deletions(-) create mode 100644 debian-package/src/usr/share/vpn-router/templates/wg0-peers.conf.j2 diff --git a/ADR.md b/ADR.md index be84b0c..28097e1 100644 --- a/ADR.md +++ b/ADR.md @@ -949,3 +949,23 @@ under the administrator's name. The directory becomes part of the system's name nslcd, and the system knows only the accounts with console login on. A user enrolled for TOTP types the code after the password at the SSH and sudo prompts. Homes are created at the first login and are not removed with the user. A password is changed through the directory, not with `passwd`. + +## ADR-35: WireGuard peers are the devices of the VPN users, kept in the directory + +Context: The WireGuard endpoint could be switched on from the Router Manager, but its peers were +written by hand into `wg0.conf`, so nobody could connect without SSH. A peer is a P2S client like +an OpenVPN or IKEv2 user, and one person may have several devices. + +Decision: A VPN user's entry holds the user's WireGuard peers in the multi-valued attribute +`vpnRouterWireGuardPeer`, one value ` [,...]` per device; the router never +sees a private key. `PUT /users/` replaces a user's peers after checking the keys, which +are unique, and the IPs, which do not overlap another peer's or cover the router's address. Setup +reads the peers of the users who are not disabled from the directory and writes them as a marked +block of `[Peer]` sections into `wg0.conf`; sections outside the block, added by hand, stay. A +change of the peers, and disabling, enabling or removing a user with peers, restart setup. The +Router Manager adds and removes peers on the WireGuard tab of Remote access management, where the +endpoint is, choosing the user for each. + +Consequences: Peers follow their users: disabling a user drops the user's devices, and the +directory's backup holds them. A peer cannot exist without a VPN user. A change restarts +`wg-quick@wg0` through setup, which briefly interrupts the other peers. diff --git a/README.md b/README.md index d0c459e..9254817 100644 --- a/README.md +++ b/README.md @@ -237,9 +237,17 @@ an FQDN or an IPv4 or IPv6 address. They are written to the strongSwan configura strongSwan reads an address as an IP identity and a name as an FQDN, so a leading `@` is not needed and is refused. Road-warrior and OpenVPN access need a name in `wan.local_fqdn`. -WireGuard peers are added by hand in `/etc/wireguard/wg0.conf`. The package owns the -`[Interface]` section of that file and rewrites it when the settings above change, but it -carries every `[Peer]` section across untouched. +WireGuard peers are P2S users' devices: a VPN user of the router's directory may have up to 16 +peers, each with its public key and its IPs, IPv4 addresses or networks, kept in the user's +entry (`vpnRouterWireGuardPeer`) and set with `PUT /users/` (`wireguard_peers`) or on the +WireGuard tab of the Router Manager's Remote access management. A key is used by one peer only, +the IPs of two peers may not overlap, and no peer's IPs cover the router's own address of +`wireguard.address`. The package owns the `[Interface]` section of `/etc/wireguard/wg0.conf` and, +between the lines `# BEGIN` and `# END WireGuard peers of the users of the router's directory`, a +`[Peer]` section for each peer of a user who is not disabled; setup rewrites them when the +settings or the peers change, and a change of a user's peers, disabling, enabling or removing a +user with peers restarts setup. Every other `[Peer]` section, added by hand, is carried across +untouched. While the directory cannot be reached, the users' peers in the file stay as they are. Every feature is independent. Leaving a group empty simply means that feature is not configured, and its generated files are removed. @@ -1017,9 +1025,9 @@ and answer bodies are JSON; a request body is at most 65536 bytes. | Request | Body | Answer | | --- | --- | --- | -| `GET /users` | | `{"users": [{"name", "manager_only", "service", "internal", "enabled", "locked", "certificate", "otp", "console", "first_name", "last_name", "email"}, ...]}`; `console` is `null` or `{"enabled", "account", "local", "uid"}` | +| `GET /users` | | `{"users": [{"name", "manager_only", "service", "internal", "enabled", "locked", "certificate", "otp", "console", "wireguard_peers", "first_name", "last_name", "email"}, ...], "wireguard": {"enabled", "address"}}`; `console` is `null` or `{"enabled", "account", "local", "uid"}`, `wireguard_peers` a list of `{"public_key", "ips"}`, and `wireguard` the endpoint's settings, whose network the peers' addresses come from | | `POST /users` | `{"name", "password", "first_name", "last_name", "email"}` for a VPN user; `{"name", "password", "email"?}` for a Manager-only account | 201 and the user | -| `PUT /users/` | any of `{"first_name", "last_name", "email"}`; for a Manager-only account only `{"email"}`, `""` removing it | the user | +| `PUT /users/` | any of `{"first_name", "last_name", "email", "wireguard_peers"}`; `wireguard_peers`, `[{"public_key", "ips"}]`, replaces a VPN user's WireGuard peers (see Configuration); for a Manager-only account only `{"email"}`, `""` removing it | the user | | `PUT /users//password` | `{"password": ...}` | the user | | `POST /users//disable` | | the user | | `POST /users//enable` | | the user | diff --git a/debian-package/debian/changelog b/debian-package/debian/changelog index e9f9516..6e10903 100644 --- a/debian-package/debian/changelog +++ b/debian-package/debian/changelog @@ -1,3 +1,19 @@ +vpn-router (0.25.0-1) unstable; urgency=medium + + 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. + + * PUT /users/ takes wireguard_peers, [{"public_key", "ips"}], + which replaces the user's peers; keys are unique and IPs may not + overlap another peer's or cover the router's WireGuard address. + * GET /users answers each user's wireguard_peers and the WireGuard + endpoint's enabled and address. + * Setup writes a [Peer] section into wg0.conf for each peer of a user + who is not disabled, in a marked block, and keeps the peers added by + hand; changing a user's peers, disabling, enabling or removing a user + with peers applies at once. + + -- Slawomir Koszewski Tue, 03 Nov 2026 00:00:00 +0100 + vpn-router (0.24.1-1) unstable; urgency=medium fix: the status gives the number of connected OpenVPN clients at most 5 seconds old instead of up to a minute. diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter.py b/debian-package/src/usr/lib/vpn-router/vpnrouter.py index 6f028d7..a996a8c 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter.py @@ -38,6 +38,9 @@ SITE_TO_SITE = SWANCTL_CONF_D / 'site-to-site.conf' ROAD_WARRIOR = SWANCTL_CONF_D / 'road-warrior.conf' P2S_FORWARDER = pathlib.Path('/etc/systemd/resolved.conf.d/p2s-forwarder.conf') WG_CONF = pathlib.Path('/etc/wireguard/wg0.conf') +#: The lines around the [Peer] sections of the users' WireGuard peers in WG_CONF, which setup writes from the directory. +WG_PEERS_BEGIN = "# BEGIN WireGuard peers of the users of the router's directory" +WG_PEERS_END = "# END WireGuard peers of the users of the router's directory" OPENVPN_CONF = pathlib.Path('/etc/openvpn/server/vpn-router.conf') OPENVPN_LDAP = pathlib.Path('/etc/openvpn/server/vpn-router-ldap.conf') @@ -887,6 +890,8 @@ def build_context(cfg, public_ip=None): 'wg_enabled': _get(cfg, 'wireguard', 'enabled'), 'wg_address': _get(cfg, 'wireguard', 'address'), 'wg_listen_port': _get(cfg, 'wireguard', 'listen_port'), + 'wg_peers_begin': WG_PEERS_BEGIN, + 'wg_peers_end': WG_PEERS_END, 'openvpn_enabled': _get(cfg, 'openvpn', 'enabled'), 'openvpn_pool': openvpn_pool, 'openvpn_network': str(openvpn_network.network_address) if openvpn_network else '', @@ -981,27 +986,38 @@ def render(env, ctx, template_name, dest, mode): def existing_peers(path): - """Return the [Peer] sections of an existing WireGuard config. + """The block of the users' peers and the [Peer] sections added by hand of an existing WireGuard config, each '' + without one. - Peers are added by the operator by hand, so re-rendering the [Interface] - section must not discard them. + The block lies between WG_PEERS_BEGIN and WG_PEERS_END; the [Peer] sections outside it are the operator's, which + re-rendering the file keeps. """ path = pathlib.Path(path) if not path.is_file(): - return '' - lines = path.read_text(encoding='utf-8').splitlines(keepends=True) + return '', '' + text = path.read_text(encoding='utf-8') + block = '' + begin, end = text.find(WG_PEERS_BEGIN), text.find(WG_PEERS_END) + if begin != -1 and end > begin: + block = text[begin:end + len(WG_PEERS_END)] + text = text[:begin] + text[end + len(WG_PEERS_END):] + lines = text.splitlines(keepends=True) for index, line in enumerate(lines): if line.strip().lower() == '[peer]': - return ''.join(lines[index:]) - return '' + return block, ''.join(lines[index:]) + return block, '' def render_wg_conf(env, ctx, dest=WG_CONF, mode=0o600): - """Render wg0.conf, carrying any operator-defined peers across.""" - content = env.get_template('wg0.conf.j2').render(ctx) - peers = existing_peers(dest) - if peers: - content = content.rstrip('\n') + '\n\n' + peers + """Render wg0.conf: the [Interface], the block of the users' peers of ctx's wg_peers, the block the file holds while + wg_peers is None, and the peers the operator added by hand, carried across.""" + content = env.get_template('wg0.conf.j2').render(ctx).rstrip('\n') + '\n' + block, own = existing_peers(dest) + if ctx['wg_peers'] is not None: + block = env.get_template('wg0-peers.conf.j2').render(ctx).strip('\n') if ctx['wg_peers'] else '' + for part in (block, own): + if part.strip(): + content += '\n' + part.strip('\n') + '\n' dest = pathlib.Path(dest) if dest.exists() and dest.read_text(encoding='utf-8') == content: @@ -1070,6 +1086,13 @@ def configure(cfg=None, public_ip=None): notices = [f'the PKI is left as it is: the directory cannot be reached: {exc}'] for notice in notices: warn(notice) + # The WireGuard peers of the users; None keeps those of wg0.conf while the directory cannot be reached. + ctx['wg_peers'] = None + if wg_ready(ctx): + try: + ctx['wg_peers'] = vpnrouter_ldap.wireguard_peers() + except ldap.LDAPError as exc: + warn(f'the WireGuard peers of the users are left as they are: the directory cannot be reached: {exc}') # What needs the router's own certificates. ctx['server_certificate'] = vpnrouter_pki.VPN_SERVER_CERT.is_file() for connection in ctx['connections']: diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py index 2563660..3d3dd2f 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py @@ -66,7 +66,8 @@ OVERLAYS = ('ppolicy', 'otp') #: authorization scopes, of which service marks a service account, displayName and #: description, the labels of a service account, and vpnRouterConsoleLogin and #: vpnRouterConsoleAccount, whether the user may use a console and the local account it runs as, -#: without which it runs as the user's own posixAccount. vpnRouterAuthority is a CA of the PKI: its +#: without which it runs as the user's own posixAccount, and vpnRouterWireGuardPeer, each a +#: WireGuard peer of the user as " [,...]". 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 @@ -97,11 +98,13 @@ SCHEMA_ATTRIBUTE_TYPES = ( "EQUALITY booleanMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 SINGLE-VALUE )", f"( {SCHEMA_OID}.1.14 NAME 'vpnRouterConsoleAccount' DESC 'VPN router: the local account the console of the user runs as' " f"{_STRING} )", + f"( {SCHEMA_OID}.1.15 NAME 'vpnRouterWireGuardPeer' DESC 'VPN router: a WireGuard peer of the user, its public key and IPs' " + "EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1024} )", ) 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 $ vpnRouterConsoleLogin $ " - "vpnRouterConsoleAccount ) )", + "vpnRouterConsoleAccount $ vpnRouterWireGuardPeer ) )", 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 ) )", @@ -116,6 +119,9 @@ TOTP_ALGORITHM = '1.2.840.113549.2.7' TOTP_PERIOD = 30 TOTP_WINDOW = 1 +#: The value of pwdAccountLockedTime that disables a user until it is enabled again. +DISABLED = '000001010000Z' + #: The indexes of the database. INDEXES = ('objectClass eq', 'uid eq', 'uidNumber eq', 'vpnRouterConsoleLogin eq') @@ -287,6 +293,23 @@ def _ensure_manager(connection): MANAGER_SECRET.write_text(password, encoding='utf-8') +def wireguard_peers(): + """The WireGuard peers of the users who are not disabled, read as root: [(user, public key, [IP, ...])], sorted.""" + connection = root() + try: + found = connection.search_s(USERS, ldap.SCOPE_ONELEVEL, + f'(&(vpnRouterWireGuardPeer=*)(!(pwdAccountLockedTime={DISABLED})))', + ['uid', 'vpnRouterWireGuardPeer']) + finally: + connection.unbind_s() + peers = [] + for _, attributes in found: + for value in attributes['vpnRouterWireGuardPeer']: + key, _, ips = value.decode().partition(' ') + peers.append((attributes['uid'][0].decode(), key, ips.split(','))) + return sorted(peers) + + def provision(): """Create or bring up to date the modules, the package's schema, database, ACLs, password policy, entries and service account.""" connection = root() diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py index 7e84d88..50cca0c 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py @@ -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} diff --git a/debian-package/src/usr/lib/vpn-router/web b/debian-package/src/usr/lib/vpn-router/web index cd7a01f..b71551e 100644 --- a/debian-package/src/usr/lib/vpn-router/web +++ b/debian-package/src/usr/lib/vpn-router/web @@ -507,10 +507,12 @@ def html_of(status): def users(state, method, path, body): """Answer a /users request with vpnrouter_users: the HTTP status and the JSON document. - GET /users the users + GET /users the users, and {"enabled", "address"} of wireguard, whose network their + WireGuard peers take their addresses from POST /users add a user: {"name", "password", "first_name", "last_name", "email"}; without the names a Manager-only account - PUT /users/ change any of {"first_name", "last_name", "email"} + PUT /users/ change any of {"first_name", "last_name", "email", "wireguard_peers"}; + wireguard_peers, [{"public_key", "ips"}], replaces the user's peers PUT /users//password set the password: {"password": ...} POST /users//disable lock the user out of OpenVPN POST /users//enable let the user in again @@ -533,7 +535,8 @@ def users(state, method, path, body): name = parts[1] if len(parts) > 1 else None actions = { ('GET', 1, None): lambda connection: { - 'users': [user for user in vpnrouter_users.list_users(connection)['users'] if not user['service']]}, + 'users': [user for user in vpnrouter_users.list_users(connection)['users'] if not user['service']], + 'wireguard': {option: vpnrouter.load_config()['wireguard'][option] for option in ('enabled', 'address')}}, ('POST', 1, None): lambda connection: vpnrouter_users.add(connection, body.get('name'), body.get('password'), body), ('PUT', 2, None): lambda connection: vpnrouter_users.update(connection, name, body), ('PUT', 3, 'password'): lambda connection: vpnrouter_users.passwd(connection, name, body.get('password')), diff --git a/debian-package/src/usr/share/vpn-router/templates/wg0-peers.conf.j2 b/debian-package/src/usr/share/vpn-router/templates/wg0-peers.conf.j2 new file mode 100644 index 0000000..88605e4 --- /dev/null +++ b/debian-package/src/usr/share/vpn-router/templates/wg0-peers.conf.j2 @@ -0,0 +1,9 @@ +{{ wg_peers_begin }} +{% for user, key, ips in wg_peers -%} +[Peer] +# {{ user }} +PublicKey = {{ key }} +AllowedIPs = {{ ips | join(', ') }} + +{% endfor -%} +{{ wg_peers_end }}