Compare commits

..
6 Commits
10 changed files with 392 additions and 43 deletions

No files matched your search

+20
View File
@@ -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.
+34 -9
View File
@@ -114,7 +114,7 @@ must have the type shown below; an unknown section or setting is an error.
| `p2s` | `enabled` | boolean | `false` | Road-warrior access | | `p2s` | `enabled` | boolean | `false` | Road-warrior access |
| `p2s` | `address_pool` | string | | CIDR assigned to road-warrior clients | | `p2s` | `address_pool` | string | | CIDR assigned to road-warrior clients |
| `wireguard` | `enabled` | boolean | `false` | WireGuard endpoint | | `wireguard` | `enabled` | boolean | `false` | WireGuard endpoint |
| `wireguard` | `address` | string | | Address and prefix for `wg0`, for example `192.168.200.1/24` | | `wireguard` | `address` | string | | The router's own address on `wg0` with the prefix of the peers' network, for example `192.168.200.1/24`; the network and the broadcast address of a network larger than /31 are refused |
| `wireguard` | `listen_port` | integer | `51820` | UDP port | | `wireguard` | `listen_port` | integer | `51820` | UDP port |
| `openvpn` | `enabled` | boolean | `false` | OpenVPN access for users | | `openvpn` | `enabled` | boolean | `false` | OpenVPN access for users |
| `openvpn` | `address_pool` | string | | IPv4 CIDR assigned to OpenVPN clients | | `openvpn` | `address_pool` | string | | IPv4 CIDR assigned to OpenVPN clients |
@@ -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.
@@ -885,7 +893,7 @@ The status holds the state of `vpn-router-setup`, `slapd` and each PostgreSQL cl
`vpn-router-manager` when that package is installed, of `strongswan` when site-to-site or `vpn-router-manager` when that package is installed, of `strongswan` when site-to-site or
road-warrior access is configured, of `wg-quick@wg0` when WireGuard is, and of road-warrior access is configured, of `wg-quick@wg0` when WireGuard is, and of
`openvpn-server@vpn-router` when OpenVPN is; the number of connected `openvpn-server@vpn-router` when OpenVPN is; the number of connected
OpenVPN clients, from the server's status file, which OpenVPN rewrites every minute; the package version and, OpenVPN clients, from the server's status file, which OpenVPN rewrites every 5 seconds; the package version and,
as `manager_version`, the installed version of `vpn-router-manager` (`null`, shown as not installed, without it); whether as `manager_version`, the installed version of `vpn-router-manager` (`null`, shown as not installed, without it); whether
Docker is installed, and when it is, the state of `docker.service` and of `vpn-router-docker` Docker is installed, and when it is, the state of `docker.service` and of `vpn-router-docker`
(the Docker rules), which do not change `status`; as `site_to_site`, the number of site-to-site (the Docker rules), which do not change `status`; as `site_to_site`, the number of site-to-site
@@ -901,7 +909,7 @@ curl http://10.0.0.4:7081/status
### Tokens and signing in ### Tokens and signing in
`/config`, `/users`, `/service-accounts`, `/groups`, `/site-to-site`, `/shell` and `/account` need a JWT in an `Authorization: Bearer` header, signed with `/config`, `/users`, `/service-accounts`, `/groups`, `/site-to-site`, `/backup`, `/remote-access`, `/ca`, `/shell` and `/account` need a JWT in an `Authorization: Bearer` header, signed with
HS256 by the key the web service makes on its first run, `/etc/vpn-router/web.key` (root only). HS256 by the key the web service makes on its first run, `/etc/vpn-router/web.key` (root only).
The key never leaves the router; while it does not exist these paths and `/auth` are not served The key never leaves the router; while it does not exist these paths and `/auth` are not served
(404). A missing, expired or wrongly signed token, or one of another algorithm, gets 401 with (404). A missing, expired or wrongly signed token, or one of another algorithm, gets 401 with
@@ -998,7 +1006,7 @@ when it ended and why, and each refused ticket or shell with the reason.
| `wireguard` | The public key and port of `wg0`, and each peer's public key, endpoint, allowed IPs, handshake age and traffic; no private or preshared key | | `wireguard` | The public key and port of `wg0`, and each peer's public key, endpoint, allowed IPs, handshake age and traffic; no private or preshared key |
| `docker` | `docker.exposed_ports` and the rules of the `VPN-ROUTER-DOCKER` chain | | `docker` | `docker.exposed_ports` and the rules of the `VPN-ROUTER-DOCKER` chain |
| `postgresql` | `clusters`: each PostgreSQL cluster of `pg_lsclusters` with its version, name, port, status and data directory, and for one that is online its `settings`: `server_version`, `listen_addresses`, `port`, `max_connections`, `shared_buffers`, `effective_cache_size`, `work_mem`, `password_encryption`, `ssl` and `timezone`; `null` when `pg_lsclusters` fails | | `postgresql` | `clusters`: each PostgreSQL cluster of `pg_lsclusters` with its version, name, port, status and data directory, and for one that is online its `settings`: `server_version`, `listen_addresses`, `port`, `max_connections`, `shared_buffers`, `effective_cache_size`, `work_mem`, `password_encryption`, `ssl` and `timezone`; `null` when `pg_lsclusters` fails |
| `caddy` | The installed Caddy: `version`, and `source`, `upstream` once Caddy's repository is configured and `ubuntu` otherwise; `null` when Caddy is not installed | | `caddy` | The installed Caddy: `version`, `source`, `upstream` once Caddy's repository is configured and `ubuntu` otherwise, and `service`, the state of `caddy.service`; `null` when Caddy is not installed |
### The /postgresql page ### The /postgresql page
@@ -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 |
@@ -1142,6 +1150,23 @@ The answer of `GET /backup` holds:
A change is made, checked and applied as for `/site-to-site`. A change is made, checked and applied as for `/site-to-site`.
### The /remote-access API
`/remote-access` shows and changes the remote access systems, IKEv2 road-warrior (`p2s`),
OpenVPN (`openvpn`) and WireGuard (`wireguard`), with the same token as `/users`.
| Request | Body | Answer |
| --- | --- | --- |
| `GET /remote-access` | | `{"p2s", "openvpn", "wireguard", "running", "local_fqdn", "wireguard_server"}`; `wireguard_server` is `{"public_key", "endpoint", "allowed_ips"}`: the router's WireGuard public key (`null` before setup made it), `<wan.local_fqdn>:<wireguard.listen_port>` (`null` without a name) and the networks a peer reaches through the tunnel, the router's local CIDRs and the WireGuard network |
| `PUT /remote-access/<system>` | any of the settings of the section `p2s`, `openvpn` or `wireguard` | the section |
`running` tells for each system whether setup runs it. IKEv2 and OpenVPN run once enabled with an
address pool, `wan.local_fqdn` set and the router's VPN server certificate issued by the P2S CA;
WireGuard once enabled with an address. A system enabled without its address pool or address is
refused. A change is made, checked and applied as for `/site-to-site`. The router's firewall
follows the settings; a cloud firewall in front of the router, such as an Azure network security
group, must allow the OpenVPN and WireGuard ports itself.
### The /ca API ### The /ca API
`/ca` manages the PKI (see Certificates) with the same token as `/users`, with the module `/ca` manages the PKI (see Certificates) with the same token as `/users`, with the module
+64
View File
@@ -1,3 +1,67 @@
vpn-router (0.25.3-1) unstable; urgency=medium
fix: /config gives the state of caddy.service with the installed Caddy, for the Router Manager's Web page.
* caddy of /config is {"version", "source", "service"}.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 03 Nov 2026 05:00:00 +0100
vpn-router (0.25.2-1) unstable; urgency=medium
fix: wireguard.address must be the router's own address in its network, not the network or broadcast address, from which the peers' addresses are told apart.
* A wireguard.address that is the network or the broadcast address of a
network larger than /31, such as 192.168.232.0/24, is refused with the
first address of the network as the example.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 03 Nov 2026 02:30:00 +0100
vpn-router (0.25.1-1) unstable; urgency=medium
fix: /remote-access gives the router's WireGuard public key, endpoint and allowed IPs, which a peer's configuration needs.
* GET /remote-access answers wireguard_server: {"public_key",
"endpoint", "allowed_ips"}.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 03 Nov 2026 01:30:00 +0100
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
fix: the status gives the number of connected OpenVPN clients at most 5 seconds old instead of up to a minute.
* The OpenVPN server writes its status file every 5 seconds, so a
refresh of the status shows clients that connected or left since.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 23:30:00 +0100
vpn-router (0.24.0-1) unstable; urgency=medium
feature: /remote-access of the web service shows and changes the settings of IKEv2 road-warrior, OpenVPN and WireGuard access and whether each runs, for the Router Manager's Remote access page.
* GET /remote-access answers the p2s, openvpn and wireguard sections,
which of them setup runs, and wan.local_fqdn; PUT
/remote-access/<system> changes one section and restarts setup.
* p2s.enabled and openvpn.enabled need their address_pool, and
wireguard.enabled its address.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 23:00:00 +0100
vpn-router (0.23.2-1) unstable; urgency=medium vpn-router (0.23.2-1) unstable; urgency=medium
fix: the shell of a user's own POSIX account opens on the first setup that sets up console login, and the journal records each refused shell with the reason. fix: the shell of a user's own POSIX account opens on the first setup that sets up console login, and the journal records each refused shell with the reason.
@@ -26,6 +26,7 @@ if str(LIB_DIR) not in sys.path:
import vpnrouter_ldap import vpnrouter_ldap
import vpnrouter_pki import vpnrouter_pki
import vpnrouter_platforms import vpnrouter_platforms
import vpnrouter_status
from vpnrouter_net import DEFAULT_LISTEN, LISTEN_CHOICES, LOOPBACK, ConfigError, detect_interfaces, interface_addresses, ip_output from vpnrouter_net import DEFAULT_LISTEN, LISTEN_CHOICES, LOOPBACK, ConfigError, detect_interfaces, interface_addresses, ip_output
CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.json') CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.json')
@@ -37,6 +38,11 @@ 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 router's WireGuard public key, which setup writes with its private key.
WG_PUBLIC_KEY = pathlib.Path('/etc/wireguard/wg0.pub')
#: 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')
@@ -435,6 +441,9 @@ def validate(cfg):
raise ValueError raise ValueError
except ValueError: except ValueError:
raise ConfigError(f'openvpn.address_pool: {openvpn_pool!r} is not an IPv4 network') raise ConfigError(f'openvpn.address_pool: {openvpn_pool!r} is not an IPv4 network')
for system, option in (('p2s', 'address_pool'), ('openvpn', 'address_pool'), ('wireguard', 'address')):
if _get(cfg, system, 'enabled') and not _get(cfg, system, option):
raise ConfigError(f'{system}.enabled needs {system}.{option}')
_check_address(_get(cfg, 'local', 'int_addr'), 'local.int_addr') _check_address(_get(cfg, 'local', 'int_addr'), 'local.int_addr')
_check_address(_get(cfg, 'local', 'int_gateway_ip'), 'local.int_gateway_ip') _check_address(_get(cfg, 'local', 'int_gateway_ip'), 'local.int_gateway_ip')
@@ -449,10 +458,16 @@ def validate(cfg):
wg_address = _get(cfg, 'wireguard', 'address') wg_address = _get(cfg, 'wireguard', 'address')
if wg_address: if wg_address:
try: try:
ipaddress.ip_interface(wg_address) interface = ipaddress.ip_interface(wg_address)
except ValueError: except ValueError:
raise ConfigError( raise ConfigError(
f'wireguard.address: {wg_address!r} is not an address with prefix') f'wireguard.address: {wg_address!r} is not an address with prefix')
# The router's own address on wg0, from which the peers' addresses of the same network are told apart.
network = interface.network
if network.num_addresses > 2 and interface.ip in (network.network_address, network.broadcast_address):
raise ConfigError(
f'wireguard.address: {wg_address} is the network or broadcast address of {network}; give the '
f'router\'s own address in it, such as {next(network.hosts())}/{network.prefixlen}')
port = _get(cfg, 'wireguard', 'listen_port') port = _get(cfg, 'wireguard', 'listen_port')
if not 1 <= port <= 65535: if not 1 <= port <= 65535:
@@ -883,6 +898,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 '',
@@ -893,6 +910,8 @@ def build_context(cfg, public_ip=None):
'openvpn_protocol': _get(cfg, 'openvpn', 'protocol'), 'openvpn_protocol': _get(cfg, 'openvpn', 'protocol'),
'openvpn_device': OPENVPN_DEVICE, 'openvpn_device': OPENVPN_DEVICE,
'openvpn_ldap_conf': str(OPENVPN_LDAP), 'openvpn_ldap_conf': str(OPENVPN_LDAP),
'openvpn_status': vpnrouter_status.OPENVPN_STATUS,
'openvpn_status_seconds': vpnrouter_status.OPENVPN_STATUS_SECONDS,
'p2s_dns_list': _split_list(_join(local_addrs, _split_list(extras['EXTRA_P2S_DNS']))), 'p2s_dns_list': _split_list(_join(local_addrs, _split_list(extras['EXTRA_P2S_DNS']))),
'openvpn_ca': str(vpnrouter_pki.OPENVPN_CA), 'openvpn_ca': str(vpnrouter_pki.OPENVPN_CA),
'openvpn_crl': str(vpnrouter_pki.OPENVPN_CRL), 'openvpn_crl': str(vpnrouter_pki.OPENVPN_CRL),
@@ -975,27 +994,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:
@@ -1064,6 +1094,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()
@@ -12,6 +12,9 @@ COMMAND_TIMEOUT = 5
OPENVPN_STATUS = '/run/openvpn-server/status-vpn-router.log' OPENVPN_STATUS = '/run/openvpn-server/status-vpn-router.log'
#: Seconds between two writes of OPENVPN_STATUS by the OpenVPN server, whose configuration sets it (OpenVPN's default is 60).
OPENVPN_STATUS_SECONDS = 5
#: Seconds since its last handshake within which a WireGuard peer counts as connected. #: Seconds since its last handshake within which a WireGuard peer counts as connected.
WIREGUARD_CONNECTED_SECONDS = 180 WIREGUARD_CONNECTED_SECONDS = 180
@@ -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:
names = {attribute: current[attribute][0].decode() for attribute in ('givenName', 'sn') if current.get(attribute)} raise Failure('give at least one of first_name, last_name, email and wireguard_peers', 2)
names.update({attribute: details[attribute] for attribute in ('givenName', 'sn') if attribute in details}) changes = []
changes = [(ldap.MOD_REPLACE, attribute, [value.encode()]) for attribute, value in details.items()] if details:
changes.append((ldap.MOD_REPLACE, 'cn', [common_name(names, name).encode()])) 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) 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}
+83 -8
View File
@@ -28,6 +28,9 @@ Pages:
applied by vpn-router-setup (see site_to_site()) applied by vpn-router-setup (see site_to_site())
/backup the backup settings, the last run and the local archives: show, change and run /backup the backup settings, the last run and the local archives: show, change and run
now (see backup()) now (see backup())
/remote-access
the settings of IKEv2 road-warrior, OpenVPN and WireGuard access and whether each
runs: show and change, applied by vpn-router-setup (see remote_access())
/ca the PKI: its settings, the root and issuing CAs, the certificates issued; change the /ca the PKI: its settings, the root and issuing CAs, the certificates issued; change the
settings, issue and revoke certificates, and take the certificate of an issuing CA settings, issue and revoke certificates, and take the certificate of an issuing CA
and a root CRL signed offline (see ca()) and a root CRL signed offline (see ca())
@@ -46,7 +49,7 @@ Pages:
the databases of each PostgreSQL cluster on the router with their sizes, as JSON (see the databases of each PostgreSQL cluster on the router with their sizes, as JSON (see
postgresql_databases()); /config has the clusters' settings postgresql_databases()); /config has the clusters' settings
/status/full, /config, /metrics/history, /postgresql, /users, /service-accounts, /groups, /site-to-site, /backup, /ca, /shell and /account need an HS256 JWT in an Authorization: Bearer header signed /status/full, /config, /metrics/history, /postgresql, /users, /service-accounts, /groups, /site-to-site, /backup, /remote-access, /ca, /shell and /account need an HS256 JWT in an Authorization: Bearer header signed
with vpnrouter_auth.WEB_KEY (see vpnrouter_auth.verify). A token of /auth names a user in sub; with vpnrouter_auth.WEB_KEY (see vpnrouter_auth.verify). A token of /auth names a user in sub;
/account serves that user, and the other paths need a member of vpnrouter_users.ADMIN_GROUP. A /account serves that user, and the other paths need a member of vpnrouter_users.ADMIN_GROUP. A
token of vpn-router-token has the admin claim and names no user; it may call every path but token of vpn-router-token has the admin claim and names no user; it may call every path but
@@ -86,6 +89,7 @@ import collections
import configparser import configparser
import fcntl import fcntl
import hashlib import hashlib
import ipaddress
import json import json
import os import os
import pwd import pwd
@@ -150,6 +154,9 @@ BODY_LIMIT = 65536
#: The settings of a site_to_site connection that /site-to-site adds and changes. #: The settings of a site_to_site connection that /site-to-site adds and changes.
CONNECTION_SETTINGS = ('addrs', 'id', 'cidrs', 'local_cidrs', 'local_id_mode', 'auth', 'psk', 'remote_ca') CONNECTION_SETTINGS = ('addrs', 'id', 'cidrs', 'local_cidrs', 'local_id_mode', 'auth', 'psk', 'remote_ca')
#: The sections of the remote access systems /remote-access changes: IKEv2 road-warrior, OpenVPN and WireGuard.
REMOTE_ACCESS = ('p2s', 'openvpn', 'wireguard')
#: The HTTP status for each status of a vpnrouter_users.Failure; any other is 500. #: The HTTP status for each status of a vpnrouter_users.Failure; any other is 500.
USER_STATUS = {2: 400, 3: 404, 4: 409, 5: 401} USER_STATUS = {2: 400, 3: 404, 4: 409, 5: 401}
@@ -396,7 +403,7 @@ def postgresql_databases():
def details(state): def details(state):
"""What /config shows: the configuration without its secrets, and what the router resolved and runs; caddy is the """What /config shows: the configuration without its secrets, and what the router resolved and runs; caddy is the
installed Caddy, {version, source}, null when it is not installed.""" installed Caddy, {version, source, service}, service the state of caddy.service, null when it is not installed."""
configuration = state.get('configuration', {}) configuration = state.get('configuration', {})
caddy = package_version('caddy') caddy = package_version('caddy')
return { return {
@@ -413,8 +420,8 @@ def details(state):
}, },
'postgresql': postgresql_configuration(), 'postgresql': postgresql_configuration(),
# Caddy is upstream's once its repository is configured (vpn-router-caddy). # Caddy is upstream's once its repository is configured (vpn-router-caddy).
'caddy': {'version': caddy, 'source': 'upstream' if vpnrouter.CADDY_APT_SOURCE.is_file() else 'ubuntu'} 'caddy': {'version': caddy, 'source': 'upstream' if vpnrouter.CADDY_APT_SOURCE.is_file() else 'ubuntu',
if caddy else None, 'service': unit_state('caddy.service')} if caddy else None,
} }
@@ -501,10 +508,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
@@ -527,7 +536,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')),
@@ -814,6 +824,68 @@ def backup(method, path, body):
return 200, {'settings': backup_settings(cfg['backup'])} return 200, {'settings': backup_settings(cfg['backup'])}
def wireguard_server(cfg, state):
"""What a WireGuard peer's configuration needs of the router: {public_key, endpoint, allowed_ips}; public_key is
null before setup made the key pair, endpoint, <wan.local_fqdn>:<wireguard.listen_port>, null without a name, and
allowed_ips the networks reached through the tunnel, the router's local CIDRs and the WireGuard network."""
try:
public_key = vpnrouter.WG_PUBLIC_KEY.read_text(encoding='utf-8').strip() or None
except OSError:
public_key = None
fqdn = cfg['wan']['local_fqdn'].strip()
address = cfg['wireguard']['address'].strip()
networks = section(state, 'network').get('local_cidrs')
allowed = [cidr for cidr in networks if isinstance(cidr, str)] if isinstance(networks, list) else []
if address:
allowed.append(str(ipaddress.ip_interface(address).network))
return {'public_key': public_key, 'endpoint': f'{fqdn}:{cfg["wireguard"]["listen_port"]}' if fqdn else None,
'allowed_ips': list(dict.fromkeys(allowed))}
def remote_access(state, method, path, body):
"""Answer a /remote-access request: the HTTP status and the JSON document.
GET /remote-access {"p2s", "openvpn", "wireguard", "running", "local_fqdn", "wireguard_server"}: the
settings of the three remote access systems (REMOTE_ACCESS), whether setup runs
each (running: {p2s, openvpn, wireguard}), wan.local_fqdn, which IKEv2 and OpenVPN
need with the router's VPN server certificate, and what a WireGuard peer's
configuration needs of the router (wireguard_server)
PUT /remote-access/<system> change any of the settings of the system's section; answers the section
A change is made as /site-to-site makes one, and applied by restarting vpn-router-setup without waiting for it.
"""
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
return 400, {'error': 'the body must be a JSON object'}
body = body or {}
parts = path.strip('/').split('/')
try:
if (method, len(parts)) == ('GET', 1):
cfg = vpnrouter.load_config()
return 200, {**{system: cfg[system] for system in REMOTE_ACCESS},
'running': {system: bool(state.get(system)) for system in REMOTE_ACCESS},
'local_fqdn': cfg['wan']['local_fqdn'],
'wireguard_server': wireguard_server(cfg, state)}
if method != 'PUT' or len(parts) != 2 or parts[1] not in REMOTE_ACCESS:
return 404, {'error': f'no route {method} {path}'}
system = parts[1]
settings = {option: body[option] for option in vpnrouter.SCHEMA[system] if option in body}
with vpnrouter.config_lock():
data = vpnrouter.read_settings()
current = data.get(system)
data[system] = {**(current if isinstance(current, dict) else {}), **settings}
cfg = vpnrouter.with_defaults(data)
vpnrouter.validate(cfg)
vpnrouter.save_config(data)
except vpnrouter.ConfigError as exc:
return 400, {'error': str(exc)}
except OSError as exc:
return 500, {'error': f'the configuration cannot be written: {exc}'}
except SystemExit as exc:
return 500, {'error': str(exc.code)}
run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
return 200, cfg[system]
def ca(method, path, body): def ca(method, path, body):
"""Answer a /ca request with vpnrouter_pki: the HTTP status and the JSON document. """Answer a /ca request with vpnrouter_pki: the HTTP status and the JSON document.
@@ -1319,7 +1391,7 @@ class Handler(BaseHTTPRequestHandler):
if self.command not in ('GET', 'HEAD'): if self.command not in ('GET', 'HEAD'):
self.close_connection = True self.close_connection = True
directory = next((name for name in ('users', 'service-accounts', 'groups', 'account', 'site-to-site', 'backup', directory = next((name for name in ('users', 'service-accounts', 'groups', 'account', 'site-to-site', 'backup',
'ca', 'shell') 'ca', 'shell', 'remote-access')
if path == f'/{name}' or path.startswith(f'/{name}/')), None) if path == f'/{name}' or path.startswith(f'/{name}/')), None)
protected = path in ('/status/full', '/config', '/metrics/history', '/postgresql') or directory is not None protected = path in ('/status/full', '/config', '/metrics/history', '/postgresql') or directory is not None
signing = path in ('/auth', '/auth/refresh', '/auth/logout') signing = path in ('/auth', '/auth/refresh', '/auth/logout')
@@ -1371,6 +1443,9 @@ class Handler(BaseHTTPRequestHandler):
elif directory == 'site-to-site': elif directory == 'site-to-site':
code, document = site_to_site(self.command, path, payload) code, document = site_to_site(self.command, path, payload)
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json' body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif directory == 'remote-access':
code, document = remote_access(state, self.command, path, payload)
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif directory == 'backup': elif directory == 'backup':
code, document = backup(self.command, path, payload) code, document = backup(self.command, path, payload)
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json' body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
@@ -24,6 +24,8 @@ push "route {{ network }} {{ netmask }}"
push "dhcp-option DNS {{ address }}" push "dhcp-option DNS {{ address }}"
{% endfor -%} {% endfor -%}
status {{ openvpn_status }} {{ openvpn_status_seconds }}
keepalive 10 120 keepalive 10 120
{% if openvpn_protocol == 'udp' -%} {% if openvpn_protocol == 'udp' -%}
explicit-exit-notify 1 explicit-exit-notify 1
@@ -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 }}