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
Release / release (push) Successful in 13s
This commit is contained in:
1 parent
015fba50ca
commit
f1d9c3bc36
8 files changed
+223
-30
No files matched your search
@@ -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
|
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
|
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`.
|
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 `<public key> <IP>[,<IP>...]` per device; the router never
|
||||||
|
sees a private key. `PUT /users/<name>` 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.
|
||||||
@@ -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
|
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`.
|
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
|
WireGuard peers are P2S users' devices: a VPN user of the router's directory may have up to 16
|
||||||
`[Interface]` section of that file and rewrites it when the settings above change, but it
|
peers, each with its public key and its IPs, IPv4 addresses or networks, kept in the user's
|
||||||
carries every `[Peer]` section across untouched.
|
entry (`vpnRouterWireGuardPeer`) and set with `PUT /users/<name>` (`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
|
Every feature is independent. Leaving a group empty simply means that feature is not
|
||||||
configured, and its generated files are removed.
|
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 |
|
| 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 |
|
| `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/<name>` | any of `{"first_name", "last_name", "email"}`; for a Manager-only account only `{"email"}`, `""` removing it | the user |
|
| `PUT /users/<name>` | 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/<name>/password` | `{"password": ...}` | the user |
|
| `PUT /users/<name>/password` | `{"password": ...}` | the user |
|
||||||
| `POST /users/<name>/disable` | | the user |
|
| `POST /users/<name>/disable` | | the user |
|
||||||
| `POST /users/<name>/enable` | | the user |
|
| `POST /users/<name>/enable` | | the user |
|
||||||
|
|||||||
@@ -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/<name> 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 <slawek@koszewscy.waw.pl> Tue, 03 Nov 2026 00:00:00 +0100
|
||||||
|
|
||||||
vpn-router (0.24.1-1) unstable; urgency=medium
|
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.
|
fix: the status gives the number of connected OpenVPN clients at most 5 seconds old instead of up to a minute.
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ SITE_TO_SITE = SWANCTL_CONF_D / 'site-to-site.conf'
|
|||||||
ROAD_WARRIOR = SWANCTL_CONF_D / 'road-warrior.conf'
|
ROAD_WARRIOR = SWANCTL_CONF_D / 'road-warrior.conf'
|
||||||
P2S_FORWARDER = pathlib.Path('/etc/systemd/resolved.conf.d/p2s-forwarder.conf')
|
P2S_FORWARDER = pathlib.Path('/etc/systemd/resolved.conf.d/p2s-forwarder.conf')
|
||||||
WG_CONF = pathlib.Path('/etc/wireguard/wg0.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_CONF = pathlib.Path('/etc/openvpn/server/vpn-router.conf')
|
||||||
OPENVPN_LDAP = pathlib.Path('/etc/openvpn/server/vpn-router-ldap.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_enabled': _get(cfg, 'wireguard', 'enabled'),
|
||||||
'wg_address': _get(cfg, 'wireguard', 'address'),
|
'wg_address': _get(cfg, 'wireguard', 'address'),
|
||||||
'wg_listen_port': _get(cfg, 'wireguard', 'listen_port'),
|
'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_enabled': _get(cfg, 'openvpn', 'enabled'),
|
||||||
'openvpn_pool': openvpn_pool,
|
'openvpn_pool': openvpn_pool,
|
||||||
'openvpn_network': str(openvpn_network.network_address) if openvpn_network else '',
|
'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):
|
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]
|
The block lies between WG_PEERS_BEGIN and WG_PEERS_END; the [Peer] sections outside it are the operator's, which
|
||||||
section must not discard them.
|
re-rendering the file keeps.
|
||||||
"""
|
"""
|
||||||
path = pathlib.Path(path)
|
path = pathlib.Path(path)
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
return ''
|
return '', ''
|
||||||
lines = path.read_text(encoding='utf-8').splitlines(keepends=True)
|
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):
|
for index, line in enumerate(lines):
|
||||||
if line.strip().lower() == '[peer]':
|
if line.strip().lower() == '[peer]':
|
||||||
return ''.join(lines[index:])
|
return block, ''.join(lines[index:])
|
||||||
return ''
|
return block, ''
|
||||||
|
|
||||||
|
|
||||||
def render_wg_conf(env, ctx, dest=WG_CONF, mode=0o600):
|
def render_wg_conf(env, ctx, dest=WG_CONF, mode=0o600):
|
||||||
"""Render wg0.conf, carrying any operator-defined peers across."""
|
"""Render wg0.conf: the [Interface], the block of the users' peers of ctx's wg_peers, the block the file holds while
|
||||||
content = env.get_template('wg0.conf.j2').render(ctx)
|
wg_peers is None, and the peers the operator added by hand, carried across."""
|
||||||
peers = existing_peers(dest)
|
content = env.get_template('wg0.conf.j2').render(ctx).rstrip('\n') + '\n'
|
||||||
if peers:
|
block, own = existing_peers(dest)
|
||||||
content = content.rstrip('\n') + '\n\n' + peers
|
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)
|
dest = pathlib.Path(dest)
|
||||||
if dest.exists() and dest.read_text(encoding='utf-8') == content:
|
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}']
|
notices = [f'the PKI is left as it is: the directory cannot be reached: {exc}']
|
||||||
for notice in notices:
|
for notice in notices:
|
||||||
warn(notice)
|
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.
|
# What needs the router's own certificates.
|
||||||
ctx['server_certificate'] = vpnrouter_pki.VPN_SERVER_CERT.is_file()
|
ctx['server_certificate'] = vpnrouter_pki.VPN_SERVER_CERT.is_file()
|
||||||
for connection in ctx['connections']:
|
for connection in ctx['connections']:
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ OVERLAYS = ('ppolicy', 'otp')
|
|||||||
#: authorization scopes, of which service marks a service account, displayName and
|
#: authorization scopes, of which service marks a service account, displayName and
|
||||||
#: description, the labels of a service account, and vpnRouterConsoleLogin 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,
|
#: 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 "<public key> <IP>[,<IP>...]". vpnRouterAuthority is a CA of the PKI: its
|
||||||
#: certificate and CRL (cACertificate and certificateRevocationList of RFC 4523), its key, the
|
#: 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,
|
#: 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
|
#: 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 )",
|
"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"( {SCHEMA_OID}.1.14 NAME 'vpnRouterConsoleAccount' DESC 'VPN router: the local account the console of the user runs as' "
|
||||||
f"{_STRING} )",
|
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 = (
|
SCHEMA_OBJECT_CLASSES = (
|
||||||
f"( {SCHEMA_OID}.2.1 NAME 'vpnRouterAccount' DESC 'VPN router: properties of a user or a Manager-only account' "
|
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 $ "
|
"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 "
|
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 $ "
|
"MAY ( cACertificate $ certificateRevocationList $ vpnRouterPrivateKey $ vpnRouterCertificateRequest $ "
|
||||||
"vpnRouterCRLNumber $ vpnRouterCRLSource $ vpnRouterRetired ) )",
|
"vpnRouterCRLNumber $ vpnRouterCRLSource $ vpnRouterRetired ) )",
|
||||||
@@ -116,6 +119,9 @@ TOTP_ALGORITHM = '1.2.840.113549.2.7'
|
|||||||
TOTP_PERIOD = 30
|
TOTP_PERIOD = 30
|
||||||
TOTP_WINDOW = 1
|
TOTP_WINDOW = 1
|
||||||
|
|
||||||
|
#: The value of pwdAccountLockedTime that disables a user until it is enabled again.
|
||||||
|
DISABLED = '000001010000Z'
|
||||||
|
|
||||||
#: The indexes of the database.
|
#: The indexes of the database.
|
||||||
INDEXES = ('objectClass eq', 'uid eq', 'uidNumber eq', 'vpnRouterConsoleLogin eq')
|
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')
|
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():
|
def provision():
|
||||||
"""Create or bring up to date the modules, the package's schema, database, ACLs, password policy, entries and service account."""
|
"""Create or bring up to date the modules, the package's schema, database, ACLs, password policy, entries and service account."""
|
||||||
connection = root()
|
connection = root()
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
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
|
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
|
vpnrouter_ldap.TOTP_PARAMS. Enrolment stores nothing until a code of the new secret is
|
||||||
@@ -48,6 +52,7 @@ import calendar
|
|||||||
import contextlib
|
import contextlib
|
||||||
import fcntl
|
import fcntl
|
||||||
import grp
|
import grp
|
||||||
|
import ipaddress
|
||||||
import pwd
|
import pwd
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -78,7 +83,9 @@ LABELS = (('display_name', 'displayName'), ('description', 'description'))
|
|||||||
#: The attributes read for a user.
|
#: The attributes read for a user.
|
||||||
ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken', 'vpnRouterScope',
|
ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken', 'vpnRouterScope',
|
||||||
'displayName', 'description', 'userCertificate;binary', 'vpnRouterConsoleLogin', 'vpnRouterConsoleAccount',
|
'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.
|
#: The vpnRouterScope of a service account.
|
||||||
SERVICE_SCOPE = 'service'
|
SERVICE_SCOPE = 'service'
|
||||||
#: The object classes of a VPN user and of a Manager-only account.
|
#: 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.
|
#: The longest group description, in characters.
|
||||||
DESCRIPTION_MAX = 256
|
DESCRIPTION_MAX = 256
|
||||||
#: The value of pwdAccountLockedTime that disables an account until it is enabled.
|
#: 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.
|
#: The group whose members manage the router through the web service.
|
||||||
ADMIN_GROUP = 'admins'
|
ADMIN_GROUP = 'admins'
|
||||||
#: The digits of a TOTP code given to login().
|
#: The digits of a TOTP code given to login().
|
||||||
@@ -244,6 +251,7 @@ def summary(attributes):
|
|||||||
'certificate': 'userCertificate;binary' in attributes,
|
'certificate': 'userCertificate;binary' in attributes,
|
||||||
'otp': 'oathTOTPToken' in attributes,
|
'otp': 'oathTOTPToken' in attributes,
|
||||||
'console': console_of(attributes),
|
'console': console_of(attributes),
|
||||||
|
'wireguard_peers': peers_of(attributes),
|
||||||
}
|
}
|
||||||
for key, attribute in PERSON + LABELS:
|
for key, attribute in PERSON + LABELS:
|
||||||
values = attributes.get(attribute)
|
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):
|
def classes(attributes):
|
||||||
"""The object classes of an entry, in lower case."""
|
"""The object classes of an entry, in lower case."""
|
||||||
return {value.decode().lower() for value in attributes.get('objectClass', [])}
|
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):
|
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
|
A Manager-only account has only the e-mail address, which '' removes; a service account also its labels
|
||||||
(update_service).
|
(update_service).
|
||||||
@@ -421,6 +486,8 @@ def update(connection, name, values):
|
|||||||
if service(current):
|
if service(current):
|
||||||
return update_service(connection, name, values)
|
return update_service(connection, name, values)
|
||||||
if manager_only(current):
|
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:
|
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)
|
raise Failure(f'user {name} is a Manager-only account, which has no first or last name', 2)
|
||||||
email = values.get('email')
|
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()])])
|
connection.modify_s(dn_of(name), [(ldap.MOD_REPLACE, 'mail', [person({'email': email}, required=False)['mail'].encode()])])
|
||||||
return summary(entry(connection, name))
|
return summary(entry(connection, name))
|
||||||
details = person(values, required=False)
|
details = person(values, required=False)
|
||||||
if not details:
|
peers = values.get('wireguard_peers')
|
||||||
raise Failure('give at least one of first_name, last_name and email', 2)
|
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 = {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})
|
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 += [(ldap.MOD_REPLACE, attribute, [value.encode()]) for attribute, value in details.items()]
|
||||||
changes.append((ldap.MOD_REPLACE, 'cn', [common_name(names, name).encode()]))
|
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)
|
connection.modify_s(dn_of(name), changes)
|
||||||
|
if peers is not None:
|
||||||
|
apply_wireguard()
|
||||||
return summary(entry(connection, name))
|
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):
|
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."""
|
"""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)):
|
if not service(entry(connection, name)):
|
||||||
@@ -466,14 +547,22 @@ def passwd(connection, name, password):
|
|||||||
|
|
||||||
|
|
||||||
def disable(connection, name):
|
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])])
|
connection.modify_s(dn_of(name), [(ldap.MOD_REPLACE, 'pwdAccountLockedTime', [LOCKED])])
|
||||||
|
if 'vpnRouterWireGuardPeer' in current:
|
||||||
|
apply_wireguard()
|
||||||
return summary(entry(connection, name))
|
return summary(entry(connection, name))
|
||||||
|
|
||||||
|
|
||||||
def enable(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)])
|
connection.modify_s(dn_of(name), [(ldap.MOD_DELETE, 'pwdAccountLockedTime', None)])
|
||||||
|
if 'vpnRouterWireGuardPeer' in current:
|
||||||
|
apply_wireguard()
|
||||||
return summary(entry(connection, name))
|
return summary(entry(connection, name))
|
||||||
|
|
||||||
|
|
||||||
@@ -579,6 +668,8 @@ def remove(connection, name):
|
|||||||
raise Failure(f'the certificate cannot be revoked: {failure}', failure.status)
|
raise Failure(f'the certificate cannot be revoked: {failure}', failure.status)
|
||||||
connection.delete_s(dn_of(name))
|
connection.delete_s(dn_of(name))
|
||||||
end_sessions(current)
|
end_sessions(current)
|
||||||
|
if 'vpnRouterWireGuardPeer' in current:
|
||||||
|
apply_wireguard()
|
||||||
return {'name': name, 'removed': True}
|
return {'name': name, 'removed': True}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -507,10 +507,12 @@ def html_of(status):
|
|||||||
def users(state, method, path, body):
|
def users(state, method, path, body):
|
||||||
"""Answer a /users request with vpnrouter_users: the HTTP status and the JSON document.
|
"""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"};
|
POST /users add a user: {"name", "password", "first_name", "last_name", "email"};
|
||||||
without the names a Manager-only account
|
without the names a Manager-only account
|
||||||
PUT /users/<name> change any of {"first_name", "last_name", "email"}
|
PUT /users/<name> change any of {"first_name", "last_name", "email", "wireguard_peers"};
|
||||||
|
wireguard_peers, [{"public_key", "ips"}], replaces the user's peers
|
||||||
PUT /users/<name>/password set the password: {"password": ...}
|
PUT /users/<name>/password set the password: {"password": ...}
|
||||||
POST /users/<name>/disable lock the user out of OpenVPN
|
POST /users/<name>/disable lock the user out of OpenVPN
|
||||||
POST /users/<name>/enable let the user in again
|
POST /users/<name>/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
|
name = parts[1] if len(parts) > 1 else None
|
||||||
actions = {
|
actions = {
|
||||||
('GET', 1, None): lambda connection: {
|
('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),
|
('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', 2, None): lambda connection: vpnrouter_users.update(connection, name, body),
|
||||||
('PUT', 3, 'password'): lambda connection: vpnrouter_users.passwd(connection, name, body.get('password')),
|
('PUT', 3, 'password'): lambda connection: vpnrouter_users.passwd(connection, name, body.get('password')),
|
||||||
|
|||||||
@@ -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 }}
|
||||||
Reference in new issue
Block a user