Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1d9c3bc36 | ||
|
|
015fba50ca | ||
|
|
9255d56276 | ||
|
|
07dbe91d0c |
No files matched your search
@@ -913,7 +913,7 @@ ticket gets the shell. The WebSocket of RFC 6455 is implemented on the standard
|
||||
web service's `http.server`, server side only, without extensions. `systemd-run --pty` starts the
|
||||
shell in a transient unit with a PAM login session, outside the sandbox, and `setsid --ctty`
|
||||
makes the terminal its controlling terminal so window sizes reach the shell. A shell idle for
|
||||
`web.shell_idle_minutes` is closed, and the journal records every opening and end. The Router
|
||||
`web.shell_idle_minutes` is closed, and the journal records every opening, end and refusal. The Router
|
||||
Manager checks the session and the WebSocket's origin, and relays the bytes to the router after
|
||||
both handshakes.
|
||||
|
||||
@@ -949,3 +949,23 @@ under the administrator's name. The directory becomes part of the system's name
|
||||
nslcd, and the system knows only the accounts with console login on. A user enrolled for TOTP types
|
||||
the code after the password at the SSH and sudo prompts. Homes are created at the first login and
|
||||
are not removed with the user. A password is changed through the directory, not with `passwd`.
|
||||
|
||||
## ADR-35: WireGuard peers are the devices of the VPN users, kept in the directory
|
||||
|
||||
Context: The WireGuard endpoint could be switched on from the Router Manager, but its peers were
|
||||
written by hand into `wg0.conf`, so nobody could connect without SSH. A peer is a P2S client like
|
||||
an OpenVPN or IKEv2 user, and one person may have several devices.
|
||||
|
||||
Decision: A VPN user's entry holds the user's WireGuard peers in the multi-valued attribute
|
||||
`vpnRouterWireGuardPeer`, one value `<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
|
||||
and is refused. Road-warrior and OpenVPN access need a name in `wan.local_fqdn`.
|
||||
|
||||
WireGuard peers are added by hand in `/etc/wireguard/wg0.conf`. The package owns the
|
||||
`[Interface]` section of that file and rewrites it when the settings above change, but it
|
||||
carries every `[Peer]` section across untouched.
|
||||
WireGuard peers are P2S users' devices: a VPN user of the router's directory may have up to 16
|
||||
peers, each with its public key and its IPs, IPv4 addresses or networks, kept in the user's
|
||||
entry (`vpnRouterWireGuardPeer`) and set with `PUT /users/<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
|
||||
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
|
||||
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 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
|
||||
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
|
||||
@@ -901,7 +909,7 @@ curl http://10.0.0.4:7081/status
|
||||
|
||||
### 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).
|
||||
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
|
||||
@@ -984,7 +992,7 @@ the router are the terminal's output. The shell is a PAM login session of the co
|
||||
run by `systemd-run` in a transient unit `vpn-router-shell-<id>`; sudo works as for SSH. It is closed after `web.shell_idle_minutes` without a keystroke, when the shell
|
||||
exits, or when the connection closes; the router then sends a close frame with the reason. The
|
||||
journal of `vpn-router-web` records who opened each shell, from which address, in which unit,
|
||||
when it ended and why.
|
||||
when it ended and why, and each refused ticket or shell with the reason.
|
||||
|
||||
### The /config page
|
||||
|
||||
@@ -1017,9 +1025,9 @@ and answer bodies are JSON; a request body is at most 65536 bytes.
|
||||
|
||||
| Request | Body | Answer |
|
||||
| --- | --- | --- |
|
||||
| `GET /users` | | `{"users": [{"name", "manager_only", "service", "internal", "enabled", "locked", "certificate", "otp", "console", "first_name", "last_name", "email"}, ...]}`; `console` is `null` or `{"enabled", "account", "local", "uid"}` |
|
||||
| `GET /users` | | `{"users": [{"name", "manager_only", "service", "internal", "enabled", "locked", "certificate", "otp", "console", "wireguard_peers", "first_name", "last_name", "email"}, ...], "wireguard": {"enabled", "address"}}`; `console` is `null` or `{"enabled", "account", "local", "uid"}`, `wireguard_peers` a list of `{"public_key", "ips"}`, and `wireguard` the endpoint's settings, whose network the peers' addresses come from |
|
||||
| `POST /users` | `{"name", "password", "first_name", "last_name", "email"}` for a VPN user; `{"name", "password", "email"?}` for a Manager-only account | 201 and the user |
|
||||
| `PUT /users/<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 |
|
||||
| `POST /users/<name>/disable` | | 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`.
|
||||
|
||||
### 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"}` |
|
||||
| `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
|
||||
|
||||
`/ca` manages the PKI (see Certificates) with the same token as `/users`, with the module
|
||||
|
||||
@@ -1,3 +1,51 @@
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
* Setup restarts vpn-router-web when it adds ldap to the name service
|
||||
switch, so the web service finds the POSIX accounts of the directory.
|
||||
* The journal of vpn-router-web records each refused ticket and shell,
|
||||
with the user, the address and the reason.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 22:00:00 +0100
|
||||
|
||||
vpn-router (0.23.1-1) unstable; urgency=medium
|
||||
|
||||
fix: the shell runs as the console account of each member of admins, a local account or the user's own POSIX account in the directory, which also signs in with SSH and sudo, instead of one web.shell_user.
|
||||
|
||||
@@ -555,8 +555,8 @@ def manage_console(cfg):
|
||||
|
||||
The local group vpnrouter_ldap.CONSOLE_GROUP is created when missing, and sudo lets its members run any command
|
||||
with their password. nslcd serves the directory's posixAccounts with console login on through the local socket,
|
||||
and checks their passwords by binding as the user; the passwd line of the name service switch gains ldap, and PAM
|
||||
creates a home at the first login. While ssh.ldap_login is true, sshd accepts the password of the group's members;
|
||||
and checks their passwords by binding as the user; the passwd line of the name service switch gains ldap, which
|
||||
restarts the web service, and PAM creates a home at the first login. While ssh.ldap_login is true, sshd accepts the password of the group's members;
|
||||
a drop-in sshd -t refuses is removed again.
|
||||
"""
|
||||
group = vpnrouter_ldap.CONSOLE_GROUP
|
||||
@@ -601,6 +601,9 @@ def manage_console(cfg):
|
||||
for line in lines]
|
||||
if switched != lines:
|
||||
NSSWITCH.write_text(''.join(switched), encoding='utf-8')
|
||||
# A running process keeps the name service switch it read first, so the web service's shell would not find
|
||||
# the console accounts of the directory until it restarts.
|
||||
run(['systemctl', 'try-restart', 'vpn-router-web.service'], check=False)
|
||||
|
||||
if 'pam_mkhomedir' not in PAM_SESSION.read_text(encoding='utf-8'):
|
||||
run(['pam-auth-update', '--enable', 'mkhomedir'], check=False)
|
||||
|
||||
@@ -26,6 +26,7 @@ if str(LIB_DIR) not in sys.path:
|
||||
import vpnrouter_ldap
|
||||
import vpnrouter_pki
|
||||
import vpnrouter_platforms
|
||||
import vpnrouter_status
|
||||
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')
|
||||
@@ -37,6 +38,9 @@ SITE_TO_SITE = SWANCTL_CONF_D / 'site-to-site.conf'
|
||||
ROAD_WARRIOR = SWANCTL_CONF_D / 'road-warrior.conf'
|
||||
P2S_FORWARDER = pathlib.Path('/etc/systemd/resolved.conf.d/p2s-forwarder.conf')
|
||||
WG_CONF = pathlib.Path('/etc/wireguard/wg0.conf')
|
||||
#: The lines around the [Peer] sections of the users' WireGuard peers in WG_CONF, which setup writes from the directory.
|
||||
WG_PEERS_BEGIN = "# BEGIN WireGuard peers of the users of the router's directory"
|
||||
WG_PEERS_END = "# END WireGuard peers of the users of the router's directory"
|
||||
OPENVPN_CONF = pathlib.Path('/etc/openvpn/server/vpn-router.conf')
|
||||
OPENVPN_LDAP = pathlib.Path('/etc/openvpn/server/vpn-router-ldap.conf')
|
||||
|
||||
@@ -435,6 +439,9 @@ def validate(cfg):
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
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_gateway_ip'), 'local.int_gateway_ip')
|
||||
|
||||
@@ -883,6 +890,8 @@ def build_context(cfg, public_ip=None):
|
||||
'wg_enabled': _get(cfg, 'wireguard', 'enabled'),
|
||||
'wg_address': _get(cfg, 'wireguard', 'address'),
|
||||
'wg_listen_port': _get(cfg, 'wireguard', 'listen_port'),
|
||||
'wg_peers_begin': WG_PEERS_BEGIN,
|
||||
'wg_peers_end': WG_PEERS_END,
|
||||
'openvpn_enabled': _get(cfg, 'openvpn', 'enabled'),
|
||||
'openvpn_pool': openvpn_pool,
|
||||
'openvpn_network': str(openvpn_network.network_address) if openvpn_network else '',
|
||||
@@ -893,6 +902,8 @@ def build_context(cfg, public_ip=None):
|
||||
'openvpn_protocol': _get(cfg, 'openvpn', 'protocol'),
|
||||
'openvpn_device': OPENVPN_DEVICE,
|
||||
'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']))),
|
||||
'openvpn_ca': str(vpnrouter_pki.OPENVPN_CA),
|
||||
'openvpn_crl': str(vpnrouter_pki.OPENVPN_CRL),
|
||||
@@ -975,27 +986,38 @@ def render(env, ctx, template_name, dest, mode):
|
||||
|
||||
|
||||
def existing_peers(path):
|
||||
"""Return the [Peer] sections of an existing WireGuard config.
|
||||
"""The block of the users' peers and the [Peer] sections added by hand of an existing WireGuard config, each ''
|
||||
without one.
|
||||
|
||||
Peers are added by the operator by hand, so re-rendering the [Interface]
|
||||
section must not discard them.
|
||||
The block lies between WG_PEERS_BEGIN and WG_PEERS_END; the [Peer] sections outside it are the operator's, which
|
||||
re-rendering the file keeps.
|
||||
"""
|
||||
path = pathlib.Path(path)
|
||||
if not path.is_file():
|
||||
return ''
|
||||
lines = path.read_text(encoding='utf-8').splitlines(keepends=True)
|
||||
return '', ''
|
||||
text = path.read_text(encoding='utf-8')
|
||||
block = ''
|
||||
begin, end = text.find(WG_PEERS_BEGIN), text.find(WG_PEERS_END)
|
||||
if begin != -1 and end > begin:
|
||||
block = text[begin:end + len(WG_PEERS_END)]
|
||||
text = text[:begin] + text[end + len(WG_PEERS_END):]
|
||||
lines = text.splitlines(keepends=True)
|
||||
for index, line in enumerate(lines):
|
||||
if line.strip().lower() == '[peer]':
|
||||
return ''.join(lines[index:])
|
||||
return ''
|
||||
return block, ''.join(lines[index:])
|
||||
return block, ''
|
||||
|
||||
|
||||
def render_wg_conf(env, ctx, dest=WG_CONF, mode=0o600):
|
||||
"""Render wg0.conf, carrying any operator-defined peers across."""
|
||||
content = env.get_template('wg0.conf.j2').render(ctx)
|
||||
peers = existing_peers(dest)
|
||||
if peers:
|
||||
content = content.rstrip('\n') + '\n\n' + peers
|
||||
"""Render wg0.conf: the [Interface], the block of the users' peers of ctx's wg_peers, the block the file holds while
|
||||
wg_peers is None, and the peers the operator added by hand, carried across."""
|
||||
content = env.get_template('wg0.conf.j2').render(ctx).rstrip('\n') + '\n'
|
||||
block, own = existing_peers(dest)
|
||||
if ctx['wg_peers'] is not None:
|
||||
block = env.get_template('wg0-peers.conf.j2').render(ctx).strip('\n') if ctx['wg_peers'] else ''
|
||||
for part in (block, own):
|
||||
if part.strip():
|
||||
content += '\n' + part.strip('\n') + '\n'
|
||||
|
||||
dest = pathlib.Path(dest)
|
||||
if dest.exists() and dest.read_text(encoding='utf-8') == content:
|
||||
@@ -1064,6 +1086,13 @@ def configure(cfg=None, public_ip=None):
|
||||
notices = [f'the PKI is left as it is: the directory cannot be reached: {exc}']
|
||||
for notice in notices:
|
||||
warn(notice)
|
||||
# The WireGuard peers of the users; None keeps those of wg0.conf while the directory cannot be reached.
|
||||
ctx['wg_peers'] = None
|
||||
if wg_ready(ctx):
|
||||
try:
|
||||
ctx['wg_peers'] = vpnrouter_ldap.wireguard_peers()
|
||||
except ldap.LDAPError as exc:
|
||||
warn(f'the WireGuard peers of the users are left as they are: the directory cannot be reached: {exc}')
|
||||
# What needs the router's own certificates.
|
||||
ctx['server_certificate'] = vpnrouter_pki.VPN_SERVER_CERT.is_file()
|
||||
for connection in ctx['connections']:
|
||||
|
||||
@@ -66,7 +66,8 @@ OVERLAYS = ('ppolicy', 'otp')
|
||||
#: authorization scopes, of which service marks a service account, displayName and
|
||||
#: description, the labels of a service account, and vpnRouterConsoleLogin and
|
||||
#: vpnRouterConsoleAccount, whether the user may use a console and the local account it runs as,
|
||||
#: without which it runs as the user's own posixAccount. vpnRouterAuthority is a CA of the PKI: its
|
||||
#: without which it runs as the user's own posixAccount, and vpnRouterWireGuardPeer, each a
|
||||
#: WireGuard peer of the user as "<public key> <IP>[,<IP>...]". vpnRouterAuthority is a CA of the PKI: its
|
||||
#: certificate and CRL (cACertificate and certificateRevocationList of RFC 4523), its key, the
|
||||
#: CSR it waits with for the offline root, the number of its last CRL, where the CRL came from,
|
||||
#: and when it was retired. vpnRouterCertificate is a certificate a CA issued, named by its
|
||||
@@ -97,11 +98,13 @@ SCHEMA_ATTRIBUTE_TYPES = (
|
||||
"EQUALITY booleanMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 SINGLE-VALUE )",
|
||||
f"( {SCHEMA_OID}.1.14 NAME 'vpnRouterConsoleAccount' DESC 'VPN router: the local account the console of the user runs as' "
|
||||
f"{_STRING} )",
|
||||
f"( {SCHEMA_OID}.1.15 NAME 'vpnRouterWireGuardPeer' DESC 'VPN router: a WireGuard peer of the user, its public key and IPs' "
|
||||
"EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1024} )",
|
||||
)
|
||||
SCHEMA_OBJECT_CLASSES = (
|
||||
f"( {SCHEMA_OID}.2.1 NAME 'vpnRouterAccount' DESC 'VPN router: properties of a user or a Manager-only account' "
|
||||
"SUP top AUXILIARY MAY ( mail $ vpnRouterScope $ displayName $ description $ vpnRouterConsoleLogin $ "
|
||||
"vpnRouterConsoleAccount ) )",
|
||||
"vpnRouterConsoleAccount $ vpnRouterWireGuardPeer ) )",
|
||||
f"( {SCHEMA_OID}.2.2 NAME 'vpnRouterAuthority' DESC 'VPN router: a CA of the PKI' SUP top STRUCTURAL MUST cn "
|
||||
"MAY ( cACertificate $ certificateRevocationList $ vpnRouterPrivateKey $ vpnRouterCertificateRequest $ "
|
||||
"vpnRouterCRLNumber $ vpnRouterCRLSource $ vpnRouterRetired ) )",
|
||||
@@ -116,6 +119,9 @@ TOTP_ALGORITHM = '1.2.840.113549.2.7'
|
||||
TOTP_PERIOD = 30
|
||||
TOTP_WINDOW = 1
|
||||
|
||||
#: The value of pwdAccountLockedTime that disables a user until it is enabled again.
|
||||
DISABLED = '000001010000Z'
|
||||
|
||||
#: The indexes of the database.
|
||||
INDEXES = ('objectClass eq', 'uid eq', 'uidNumber eq', 'vpnRouterConsoleLogin eq')
|
||||
|
||||
@@ -287,6 +293,23 @@ def _ensure_manager(connection):
|
||||
MANAGER_SECRET.write_text(password, encoding='utf-8')
|
||||
|
||||
|
||||
def wireguard_peers():
|
||||
"""The WireGuard peers of the users who are not disabled, read as root: [(user, public key, [IP, ...])], sorted."""
|
||||
connection = root()
|
||||
try:
|
||||
found = connection.search_s(USERS, ldap.SCOPE_ONELEVEL,
|
||||
f'(&(vpnRouterWireGuardPeer=*)(!(pwdAccountLockedTime={DISABLED})))',
|
||||
['uid', 'vpnRouterWireGuardPeer'])
|
||||
finally:
|
||||
connection.unbind_s()
|
||||
peers = []
|
||||
for _, attributes in found:
|
||||
for value in attributes['vpnRouterWireGuardPeer']:
|
||||
key, _, ips = value.decode().partition(' ')
|
||||
peers.append((attributes['uid'][0].decode(), key, ips.split(',')))
|
||||
return sorted(peers)
|
||||
|
||||
|
||||
def provision():
|
||||
"""Create or bring up to date the modules, the package's schema, database, ACLs, password policy, entries and service account."""
|
||||
connection = root()
|
||||
|
||||
@@ -12,6 +12,9 @@ COMMAND_TIMEOUT = 5
|
||||
|
||||
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.
|
||||
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
|
||||
record membership; nothing on the router reads them.
|
||||
|
||||
A VPN user may have WireGuard peers, each a device with its public key and its IPs, in
|
||||
vpnRouterWireGuardPeer; vpn-router-setup writes a [Peer] of wg0.conf for each peer of a user who
|
||||
is not disabled.
|
||||
|
||||
A user may have a TOTP token: the user entry itself is an oathTOTPUser and oathTOTPToken whose
|
||||
oathTOTPToken points to itself, with the secret in oathSecret and the parameters of
|
||||
vpnrouter_ldap.TOTP_PARAMS. Enrolment stores nothing until a code of the new secret is
|
||||
@@ -48,6 +52,7 @@ import calendar
|
||||
import contextlib
|
||||
import fcntl
|
||||
import grp
|
||||
import ipaddress
|
||||
import pwd
|
||||
import re
|
||||
import subprocess
|
||||
@@ -78,7 +83,9 @@ LABELS = (('display_name', 'displayName'), ('description', 'description'))
|
||||
#: The attributes read for a user.
|
||||
ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken', 'vpnRouterScope',
|
||||
'displayName', 'description', 'userCertificate;binary', 'vpnRouterConsoleLogin', 'vpnRouterConsoleAccount',
|
||||
'uidNumber']
|
||||
'uidNumber', 'vpnRouterWireGuardPeer']
|
||||
#: The most WireGuard peers of one user.
|
||||
WIREGUARD_PEERS_MAX = 16
|
||||
#: The vpnRouterScope of a service account.
|
||||
SERVICE_SCOPE = 'service'
|
||||
#: The object classes of a VPN user and of a Manager-only account.
|
||||
@@ -94,7 +101,7 @@ GROUP_ATTRIBUTES = ['cn', 'description', 'member']
|
||||
#: The longest group description, in characters.
|
||||
DESCRIPTION_MAX = 256
|
||||
#: The value of pwdAccountLockedTime that disables an account until it is enabled.
|
||||
LOCKED = b'000001010000Z'
|
||||
LOCKED = vpnrouter_ldap.DISABLED.encode()
|
||||
#: The group whose members manage the router through the web service.
|
||||
ADMIN_GROUP = 'admins'
|
||||
#: The digits of a TOTP code given to login().
|
||||
@@ -244,6 +251,7 @@ def summary(attributes):
|
||||
'certificate': 'userCertificate;binary' in attributes,
|
||||
'otp': 'oathTOTPToken' in attributes,
|
||||
'console': console_of(attributes),
|
||||
'wireguard_peers': peers_of(attributes),
|
||||
}
|
||||
for key, attribute in PERSON + LABELS:
|
||||
values = attributes.get(attribute)
|
||||
@@ -267,6 +275,62 @@ def console_of(attributes):
|
||||
}
|
||||
|
||||
|
||||
def peers_of(attributes):
|
||||
"""The WireGuard peers of an entry as the answers show them: [{public_key, ips}], in the order they are stored."""
|
||||
peers = []
|
||||
for value in attributes.get('vpnRouterWireGuardPeer', []):
|
||||
key, _, ips = value.decode().partition(' ')
|
||||
peers.append({'public_key': key, 'ips': ips.split(',')})
|
||||
return peers
|
||||
|
||||
|
||||
def checked_peers(connection, name, peers):
|
||||
"""The values of vpnRouterWireGuardPeer for the user's WireGuard peers, [{public_key, ips}], when they are valid.
|
||||
|
||||
A public key is 32 bytes in base64, used by no other peer of any user; each peer has at least one IP, an IPv4
|
||||
address or network, which no other peer of any user covers and which does not cover the router's own address of
|
||||
wireguard.address. A user has at most WIREGUARD_PEERS_MAX peers.
|
||||
"""
|
||||
if not isinstance(peers, list) or len(peers) > WIREGUARD_PEERS_MAX:
|
||||
raise Failure(f'wireguard_peers must be a list of at most {WIREGUARD_PEERS_MAX} peers', 2)
|
||||
router = vpnrouter.load_config()['wireguard']['address'].strip()
|
||||
taken = [(other['name'], key, network)
|
||||
for other in list_users(connection)['users'] if other['name'] != name
|
||||
for peer in other['wireguard_peers'] for key in [peer['public_key']]
|
||||
for network in (ipaddress.ip_network(ip) for ip in peer['ips'])]
|
||||
values = []
|
||||
for peer in peers:
|
||||
key = peer.get('public_key') if isinstance(peer, dict) else None
|
||||
ips = peer.get('ips') if isinstance(peer, dict) else None
|
||||
try:
|
||||
if not isinstance(key, str) or len(base64.b64decode(key.strip(), validate=True)) != 32:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise Failure(f'{key!r} is not a WireGuard public key: 32 bytes in base64', 2)
|
||||
key = key.strip()
|
||||
if not isinstance(ips, list) or not ips or not all(isinstance(ip, str) for ip in ips):
|
||||
raise Failure(f'the peer {key} needs at least one IP', 2)
|
||||
networks = []
|
||||
for ip in ips:
|
||||
try:
|
||||
network = ipaddress.ip_network(ip.strip(), strict=True)
|
||||
except ValueError:
|
||||
raise Failure(f'{ip!r} is not an IPv4 address or network of the peer {key}', 2)
|
||||
if network.version != 4:
|
||||
raise Failure(f'{ip!r} of the peer {key} is not IPv4', 2)
|
||||
if router and ipaddress.ip_interface(router).ip in network:
|
||||
raise Failure(f'{network} of the peer {key} covers the router\'s own address {router}', 2)
|
||||
for owner, other_key, other in taken:
|
||||
if other_key == key:
|
||||
raise Failure(f'the key {key} is a peer of {owner}', 4)
|
||||
if network.overlaps(other):
|
||||
raise Failure(f'{network} of the peer {key} overlaps {other} of {owner}', 4)
|
||||
networks.append(network)
|
||||
taken += [(name, key, network) for network in networks]
|
||||
values.append(f'{key} {",".join(str(network) for network in networks)}'.encode())
|
||||
return values
|
||||
|
||||
|
||||
def classes(attributes):
|
||||
"""The object classes of an entry, in lower case."""
|
||||
return {value.decode().lower() for value in attributes.get('objectClass', [])}
|
||||
@@ -412,7 +476,8 @@ def add(connection, name, password, values, service=False):
|
||||
|
||||
|
||||
def update(connection, name, values):
|
||||
"""Change the person attributes values gives; the cn follows the first and the last name.
|
||||
"""Change the person attributes values gives, the cn following the first and the last name, and of a VPN user the
|
||||
WireGuard peers, wireguard_peers, which replace the user's peers (checked_peers).
|
||||
|
||||
A Manager-only account has only the e-mail address, which '' removes; a service account also its labels
|
||||
(update_service).
|
||||
@@ -421,6 +486,8 @@ def update(connection, name, values):
|
||||
if service(current):
|
||||
return update_service(connection, name, values)
|
||||
if manager_only(current):
|
||||
if values.get('wireguard_peers') is not None:
|
||||
raise Failure(f'user {name} is a Manager-only account, which has no VPN access and so no WireGuard peers', 2)
|
||||
if values.get('first_name') is not None or values.get('last_name') is not None:
|
||||
raise Failure(f'user {name} is a Manager-only account, which has no first or last name', 2)
|
||||
email = values.get('email')
|
||||
@@ -433,16 +500,30 @@ def update(connection, name, values):
|
||||
connection.modify_s(dn_of(name), [(ldap.MOD_REPLACE, 'mail', [person({'email': email}, required=False)['mail'].encode()])])
|
||||
return summary(entry(connection, name))
|
||||
details = person(values, required=False)
|
||||
if not details:
|
||||
raise Failure('give at least one of first_name, last_name and email', 2)
|
||||
names = {attribute: current[attribute][0].decode() for attribute in ('givenName', 'sn') if current.get(attribute)}
|
||||
names.update({attribute: details[attribute] for attribute in ('givenName', 'sn') if attribute in details})
|
||||
changes = [(ldap.MOD_REPLACE, attribute, [value.encode()]) for attribute, value in details.items()]
|
||||
changes.append((ldap.MOD_REPLACE, 'cn', [common_name(names, name).encode()]))
|
||||
peers = values.get('wireguard_peers')
|
||||
if not details and peers is None:
|
||||
raise Failure('give at least one of first_name, last_name, email and wireguard_peers', 2)
|
||||
changes = []
|
||||
if details:
|
||||
names = {attribute: current[attribute][0].decode() for attribute in ('givenName', 'sn') if current.get(attribute)}
|
||||
names.update({attribute: details[attribute] for attribute in ('givenName', 'sn') if attribute in details})
|
||||
changes += [(ldap.MOD_REPLACE, attribute, [value.encode()]) for attribute, value in details.items()]
|
||||
changes.append((ldap.MOD_REPLACE, 'cn', [common_name(names, name).encode()]))
|
||||
if peers is not None:
|
||||
if 'vpnrouteraccount' not in classes(current):
|
||||
changes.append((ldap.MOD_ADD, 'objectClass', [b'vpnRouterAccount']))
|
||||
changes.append((ldap.MOD_REPLACE, 'vpnRouterWireGuardPeer', checked_peers(connection, name, peers)))
|
||||
connection.modify_s(dn_of(name), changes)
|
||||
if peers is not None:
|
||||
apply_wireguard()
|
||||
return summary(entry(connection, name))
|
||||
|
||||
|
||||
def apply_wireguard():
|
||||
"""Have vpn-router-setup write the WireGuard peers of the users into wg0.conf, without waiting for it."""
|
||||
subprocess.run(['systemctl', 'restart', '--no-block', 'vpn-router-setup.service'], capture_output=True)
|
||||
|
||||
|
||||
def update_service(connection, name, values):
|
||||
"""Change any of the e-mail address and the labels (labels) of a service account that values gives; '' removes one."""
|
||||
if not service(entry(connection, name)):
|
||||
@@ -466,14 +547,22 @@ def passwd(connection, name, password):
|
||||
|
||||
|
||||
def disable(connection, name):
|
||||
if entry(connection, name).get('pwdAccountLockedTime') != [LOCKED]:
|
||||
"""Lock the user out; the WireGuard peers of a disabled user are left out of wg0.conf."""
|
||||
current = entry(connection, name)
|
||||
if current.get('pwdAccountLockedTime') != [LOCKED]:
|
||||
connection.modify_s(dn_of(name), [(ldap.MOD_REPLACE, 'pwdAccountLockedTime', [LOCKED])])
|
||||
if 'vpnRouterWireGuardPeer' in current:
|
||||
apply_wireguard()
|
||||
return summary(entry(connection, name))
|
||||
|
||||
|
||||
def enable(connection, name):
|
||||
if entry(connection, name).get('pwdAccountLockedTime') == [LOCKED]:
|
||||
"""Let the user in again, with the WireGuard peers."""
|
||||
current = entry(connection, name)
|
||||
if current.get('pwdAccountLockedTime') == [LOCKED]:
|
||||
connection.modify_s(dn_of(name), [(ldap.MOD_DELETE, 'pwdAccountLockedTime', None)])
|
||||
if 'vpnRouterWireGuardPeer' in current:
|
||||
apply_wireguard()
|
||||
return summary(entry(connection, name))
|
||||
|
||||
|
||||
@@ -579,6 +668,8 @@ def remove(connection, name):
|
||||
raise Failure(f'the certificate cannot be revoked: {failure}', failure.status)
|
||||
connection.delete_s(dn_of(name))
|
||||
end_sessions(current)
|
||||
if 'vpnRouterWireGuardPeer' in current:
|
||||
apply_wireguard()
|
||||
return {'name': name, 'removed': True}
|
||||
|
||||
|
||||
|
||||
@@ -28,6 +28,9 @@ Pages:
|
||||
applied by vpn-router-setup (see site_to_site())
|
||||
/backup the backup settings, the last run and the local archives: show, change and run
|
||||
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
|
||||
settings, issue and revoke certificates, and take the certificate of an issuing 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
|
||||
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;
|
||||
/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
|
||||
@@ -150,6 +153,9 @@ BODY_LIMIT = 65536
|
||||
#: 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')
|
||||
|
||||
#: 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.
|
||||
USER_STATUS = {2: 400, 3: 404, 4: 409, 5: 401}
|
||||
|
||||
@@ -501,10 +507,12 @@ def html_of(status):
|
||||
def users(state, method, path, body):
|
||||
"""Answer a /users request with vpnrouter_users: the HTTP status and the JSON document.
|
||||
|
||||
GET /users the users
|
||||
GET /users the users, and {"enabled", "address"} of wireguard, whose network their
|
||||
WireGuard peers take their addresses from
|
||||
POST /users add a user: {"name", "password", "first_name", "last_name", "email"};
|
||||
without the names a Manager-only account
|
||||
PUT /users/<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": ...}
|
||||
POST /users/<name>/disable lock the user out of OpenVPN
|
||||
POST /users/<name>/enable let the user in again
|
||||
@@ -527,7 +535,8 @@ def users(state, method, path, body):
|
||||
name = parts[1] if len(parts) > 1 else None
|
||||
actions = {
|
||||
('GET', 1, None): lambda connection: {
|
||||
'users': [user for user in vpnrouter_users.list_users(connection)['users'] if not user['service']]},
|
||||
'users': [user for user in vpnrouter_users.list_users(connection)['users'] if not user['service']],
|
||||
'wireguard': {option: vpnrouter.load_config()['wireguard'][option] for option in ('enabled', 'address')}},
|
||||
('POST', 1, None): lambda connection: vpnrouter_users.add(connection, body.get('name'), body.get('password'), body),
|
||||
('PUT', 2, None): lambda connection: vpnrouter_users.update(connection, name, body),
|
||||
('PUT', 3, 'password'): lambda connection: vpnrouter_users.passwd(connection, name, body.get('password')),
|
||||
@@ -814,6 +823,48 @@ def backup(method, path, body):
|
||||
return 200, {'settings': backup_settings(cfg['backup'])}
|
||||
|
||||
|
||||
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"}: the settings of the three
|
||||
remote access systems (REMOTE_ACCESS), whether setup runs each (running: {p2s,
|
||||
openvpn, wireguard}), and wan.local_fqdn, which IKEv2 and OpenVPN need with the
|
||||
router's VPN server certificate
|
||||
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']}
|
||||
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):
|
||||
"""Answer a /ca request with vpnrouter_pki: the HTTP status and the JSON document.
|
||||
|
||||
@@ -1193,9 +1244,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||
The ticket must come from POST /shell for the user named by the token. Binary messages of the client are its
|
||||
keystrokes, a text message {"cols", "rows"} sets the window size, and binary messages of the server are the
|
||||
terminal's output. The shell ends after web.shell_idle_minutes without a keystroke, when it exits, or when the
|
||||
client closes the connection; the server then closes it with the reason. Each opening and end is logged.
|
||||
client closes the connection; the server then closes it with the reason. Each opening, end and refusal is logged
|
||||
with its reason.
|
||||
"""
|
||||
def refuse(code, message):
|
||||
log(f'shell of {name} from {self.client()} refused: {message}')
|
||||
body = json.dumps({'error': message}).encode() + b'\n'
|
||||
self.send_response(code)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
@@ -1317,7 +1370,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if self.command not in ('GET', 'HEAD'):
|
||||
self.close_connection = True
|
||||
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)
|
||||
protected = path in ('/status/full', '/config', '/metrics/history', '/postgresql') or directory is not None
|
||||
signing = path in ('/auth', '/auth/refresh', '/auth/logout')
|
||||
@@ -1360,6 +1413,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
elif directory == 'shell':
|
||||
code, document = shell_ticket(claims.get('sub'), payload) if (path, self.command) == ('/shell', 'POST') \
|
||||
else (404, {'error': f'no route {self.command} {path}'})
|
||||
if code != 200:
|
||||
log(f'shell ticket of {claims.get("sub")} from {self.client()} refused: {document["error"]}')
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif directory == 'account':
|
||||
code, document = account(claims['sub'], self.command, path, payload)
|
||||
@@ -1367,6 +1422,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
elif directory == 'site-to-site':
|
||||
code, document = site_to_site(self.command, path, payload)
|
||||
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':
|
||||
code, document = backup(self.command, path, payload)
|
||||
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 }}"
|
||||
{% endfor -%}
|
||||
|
||||
status {{ openvpn_status }} {{ openvpn_status_seconds }}
|
||||
|
||||
keepalive 10 120
|
||||
{% if openvpn_protocol == 'udp' -%}
|
||||
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 }}
|
||||
Reference in new issue
Block a user