Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91cc64d6a6 | ||
|
|
a1f314d8c1 | ||
|
|
7d8892b8c4 | ||
|
|
4491377d9b | ||
|
|
f1d9c3bc36 | ||
|
|
015fba50ca | ||
|
|
9255d56276 | ||
|
|
07dbe91d0c |
No files matched your search
@@ -909,11 +909,14 @@ Manager cannot set it. The shell runs as the console account of the signed-in us
|
||||
it is never root and each administrator's shell is the administrator's own; a user without
|
||||
console login gets none. `POST /shell` signs the user in again with the password
|
||||
and TOTP code and gives a one-time ticket valid 30 seconds; a WebSocket on `GET /shell` with that
|
||||
ticket gets the shell. The WebSocket of RFC 6455 is implemented on the standard library inside the
|
||||
ticket gets the shell. Asking for the password and code at every opening of the page is tiresome,
|
||||
so within `web.shell_auth_timeout` minutes of the user's last sign-in or keystroke a ticket is given
|
||||
without them; the times are kept in the web service's memory only, so a restart asks again, and 0
|
||||
keeps the sign-in at every opening. The WebSocket of RFC 6455 is implemented on the standard library inside the
|
||||
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 +952,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.
|
||||
@@ -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` | `address_pool` | string | | CIDR assigned to road-warrior clients |
|
||||
| `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 |
|
||||
| `openvpn` | `enabled` | boolean | `false` | OpenVPN access for users |
|
||||
| `openvpn` | `address_pool` | string | | IPv4 CIDR assigned to OpenVPN clients |
|
||||
@@ -130,6 +130,7 @@ must have the type shown below; an unknown section or setting is an error.
|
||||
| `web` | `status_content` | string | `basic` | What `/` and `/status` show: `basic`, `standard` or `full` (see Status web service) |
|
||||
| `web` | `shell` | boolean | `false` | `/shell` opens a login shell for a member of `admins` with console login (see The shell) |
|
||||
| `web` | `shell_idle_minutes` | integer | `15` | Minutes without a keystroke after which a shell is closed, 1 to 1440 |
|
||||
| `web` | `shell_auth_timeout` | integer | `15` | Minutes after the last use of a shell in which a new shell opens without signing in again, 0 (always sign in) to 1440 |
|
||||
| `ssh` | `ldap_login` | boolean | `false` | sshd accepts the directory password of the console accounts in the directory (see Console login and SSH) |
|
||||
| `https` | `enabled` | boolean | `true` | Caddy serves the status web service and the Router Manager over HTTPS for `wan.local_fqdn` |
|
||||
| `https` | `email` | string | | The e-mail address of the Let's Encrypt account |
|
||||
@@ -237,9 +238,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 +894,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 +910,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
|
||||
@@ -972,19 +981,21 @@ vpn-config-edit set web.shell true
|
||||
|
||||
| Request | Body | Answer |
|
||||
| --- | --- | --- |
|
||||
| `POST /shell` | `{"password", "code"}`; `code` only for a user enrolled for TOTP | `{"ticket", "expires_in": 30}` |
|
||||
| `POST /shell` | `{"password", "code"}`; `code` only for a user enrolled for TOTP; `{}` within `web.shell_auth_timeout` | `{"ticket", "expires_in": 30}` |
|
||||
| `GET /shell?ticket=<ticket>` | a WebSocket handshake | the terminal |
|
||||
|
||||
`POST /shell` needs an access token of `/auth`, not one of `vpn-router-token`, and signs its user
|
||||
in again: a wrong password or code gets 403 and counts as a failed login; it gets 403 too while
|
||||
`web.shell` is `false` or the user's console login is off. The ticket opens one
|
||||
`web.shell` is `false` or the user's console login is off. Without a password it gives a ticket
|
||||
while the user's last sign-in of `POST /shell` or keystroke in a shell is less than
|
||||
`web.shell_auth_timeout` minutes ago, and `{"ticket": null}` otherwise. The ticket opens one
|
||||
shell for the same user within 30 seconds. Over the WebSocket, binary messages of the client
|
||||
are keystrokes, a text message `{"cols", "rows"}` sets the window size, and binary messages of
|
||||
the router are the terminal's output. The shell is a PAM login session of the console account,
|
||||
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
|
||||
|
||||
@@ -998,7 +1009,7 @@ when it ended and why.
|
||||
| `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 |
|
||||
| `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
|
||||
|
||||
@@ -1017,9 +1028,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 +1153,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", "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
|
||||
|
||||
`/ca` manages the PKI (see Certificates) with the same token as `/users`, with the module
|
||||
|
||||
@@ -1,3 +1,89 @@
|
||||
vpn-router (0.25.4-1) unstable; urgency=medium
|
||||
|
||||
fix: a shell opens without the password and code again while the user's last use of a shell is within web.shell_auth_timeout minutes.
|
||||
|
||||
* New setting web.shell_auth_timeout, 15 minutes by default, 0 to 1440:
|
||||
POST /shell without a password gives a ticket while the user's last
|
||||
sign-in of POST /shell or keystroke in a shell is within it, and a
|
||||
null ticket otherwise; 0 asks for the password and code every time.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 03 Nov 2026 06:00:00 +0100
|
||||
|
||||
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
|
||||
|
||||
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,11 @@ 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 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_LDAP = pathlib.Path('/etc/openvpn/server/vpn-router-ldap.conf')
|
||||
|
||||
@@ -52,7 +58,7 @@ SCHEMA = {
|
||||
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
|
||||
'docker': {'mode': 'auto', 'exposed_ports': []},
|
||||
'web': {'enabled': True, 'listen': DEFAULT_LISTEN, 'port': 7081, 'database': '', 'trusted_proxies': [],
|
||||
'status_content': 'basic', 'shell': False, 'shell_idle_minutes': 15},
|
||||
'status_content': 'basic', 'shell': False, 'shell_idle_minutes': 15, 'shell_auth_timeout': 15},
|
||||
'ssh': {'ldap_login': False},
|
||||
'https': {'enabled': True, 'email': '', 'web_port': 7444, 'manager_port': 7443, 'caddy_source': 'ubuntu'},
|
||||
'pki': {'root_name': 'VPN Router Root CA', 'root_days': 7300, 'ca_days': 3650, 'key': 'rsa-4096',
|
||||
@@ -195,7 +201,7 @@ CA_NAME_MAX = 64
|
||||
#: What / and /status of the web service show, web.status_content, from the least to the most.
|
||||
STATUS_CONTENTS = ('basic', 'standard', 'full')
|
||||
|
||||
#: The longest web.shell_idle_minutes, a day.
|
||||
#: The longest web.shell_idle_minutes and web.shell_auth_timeout, a day.
|
||||
SHELL_IDLE_MAX = 1440
|
||||
|
||||
DOCKER_MODES = ('auto', 'on', 'off')
|
||||
@@ -435,6 +441,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')
|
||||
|
||||
@@ -449,10 +458,16 @@ def validate(cfg):
|
||||
wg_address = _get(cfg, 'wireguard', 'address')
|
||||
if wg_address:
|
||||
try:
|
||||
ipaddress.ip_interface(wg_address)
|
||||
interface = ipaddress.ip_interface(wg_address)
|
||||
except ValueError:
|
||||
raise ConfigError(
|
||||
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')
|
||||
if not 1 <= port <= 65535:
|
||||
@@ -484,6 +499,9 @@ def validate(cfg):
|
||||
shell_idle = _get(cfg, 'web', 'shell_idle_minutes')
|
||||
if not 1 <= shell_idle <= SHELL_IDLE_MAX:
|
||||
raise ConfigError(f'web.shell_idle_minutes: {shell_idle} is not between 1 and {SHELL_IDLE_MAX}')
|
||||
shell_auth = _get(cfg, 'web', 'shell_auth_timeout')
|
||||
if not 0 <= shell_auth <= SHELL_IDLE_MAX:
|
||||
raise ConfigError(f'web.shell_auth_timeout: {shell_auth} is not between 0 and {SHELL_IDLE_MAX}')
|
||||
|
||||
https_ports = []
|
||||
for option in ('web_port', 'manager_port'):
|
||||
@@ -883,6 +901,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 +913,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 +997,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 +1097,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())
|
||||
@@ -35,8 +38,9 @@ Pages:
|
||||
the DER certificate and CRL of the root (root) and of each issuing CA, which the
|
||||
certificates name in their AIA and CRL distribution points; no authentication
|
||||
/shell while web.shell is true, a login shell of the user's console account on a terminal, for
|
||||
a member of admins whose console login is on: POST signs the
|
||||
user in again for a one-time ticket (see shell_ticket()), GET with it is a WebSocket
|
||||
a member of admins whose console login is on: POST signs the user in again, unless
|
||||
the user used a shell within web.shell_auth_timeout minutes, for a one-time ticket
|
||||
(see shell_ticket()), GET with it is a WebSocket
|
||||
that carries the terminal (see Handler.shell())
|
||||
/auth sign-in with a user's password and TOTP code, refresh and sign-out (see auth())
|
||||
/metrics/history
|
||||
@@ -46,7 +50,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
|
||||
@@ -86,6 +90,7 @@ import collections
|
||||
import configparser
|
||||
import fcntl
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import pwd
|
||||
@@ -150,6 +155,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}
|
||||
|
||||
@@ -182,6 +190,9 @@ _auth_lock = threading.Lock()
|
||||
_shell_tickets = {}
|
||||
_shell_lock = threading.Lock()
|
||||
|
||||
#: The last use of a shell of each user, a sign-in of POST /shell or a keystroke: user -> monotonic time.
|
||||
_shell_used = {}
|
||||
|
||||
|
||||
def read_json(path):
|
||||
"""The object in a JSON file, or {} when the file is missing or holds no object."""
|
||||
@@ -396,7 +407,7 @@ def postgresql_databases():
|
||||
|
||||
def details(state):
|
||||
"""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', {})
|
||||
caddy = package_version('caddy')
|
||||
return {
|
||||
@@ -413,8 +424,8 @@ def details(state):
|
||||
},
|
||||
'postgresql': postgresql_configuration(),
|
||||
# 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'}
|
||||
if caddy else None,
|
||||
'caddy': {'version': caddy, 'source': 'upstream' if vpnrouter.CADDY_APT_SOURCE.is_file() else 'ubuntu',
|
||||
'service': unit_state('caddy.service')} if caddy else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -501,10 +512,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 +540,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 +828,68 @@ def backup(method, path, body):
|
||||
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):
|
||||
"""Answer a /ca request with vpnrouter_pki: the HTTP status and the JSON document.
|
||||
|
||||
@@ -1033,19 +1109,28 @@ def shell_ticket(name, body):
|
||||
"""Answer POST /shell: the HTTP status and the JSON document.
|
||||
|
||||
The user named by the token signs in again with {"password", "code"?}; a wrong password or code is 403 and counts
|
||||
as a failed login. The answer is {"ticket", "expires_in"}: a ticket that opens one shell (GET /shell) for the
|
||||
same user within SHELL_TICKET_SECONDS. 403 while web.shell is false or the user's console login is not on.
|
||||
as a failed login. Without a password, the user is not asked to sign in while its last use of a shell (see
|
||||
_shell_used) is less than web.shell_auth_timeout minutes ago. The answer is {"ticket", "expires_in"}: a ticket that
|
||||
opens one shell (GET /shell) for the same user within SHELL_TICKET_SECONDS, or a null ticket when the user must
|
||||
sign in. 403 while web.shell is false or the user's console login is not on.
|
||||
"""
|
||||
if not isinstance(name, str):
|
||||
return 403, {'error': 'the shell needs a token of a signed-in user'}
|
||||
if not isinstance(body, dict):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
try:
|
||||
if not vpnrouter.load_config()['web']['shell']:
|
||||
options = vpnrouter.load_config()['web']
|
||||
if not options['shell']:
|
||||
return 403, {'error': 'the shell is disabled; vpn-config-edit set web.shell true enables it'}
|
||||
if console_account(name) is None:
|
||||
return 403, {'error': f'console login is not enabled for {name}; vpn-router-user console sets it'}
|
||||
vpnrouter_users.login(name, body.get('password'), body.get('code'))
|
||||
if 'password' not in body:
|
||||
with _shell_lock:
|
||||
used = _shell_used.get(name)
|
||||
if used is None or time.monotonic() - used >= options['shell_auth_timeout'] * 60:
|
||||
return 200, {'ticket': None, 'expires_in': 0}
|
||||
else:
|
||||
vpnrouter_users.login(name, body.get('password'), body.get('code'))
|
||||
except vpnrouter_users.Failure as failure:
|
||||
if failure.status == 5:
|
||||
return 403, {'error': 'the password or the code is wrong'}
|
||||
@@ -1060,6 +1145,7 @@ def shell_ticket(name, body):
|
||||
for unused in [key for key, (_, expires) in _shell_tickets.items() if expires <= now]:
|
||||
del _shell_tickets[unused]
|
||||
_shell_tickets[ticket] = (name, now + SHELL_TICKET_SECONDS)
|
||||
_shell_used[name] = now
|
||||
return 200, {'ticket': ticket, 'expires_in': SHELL_TICKET_SECONDS}
|
||||
|
||||
|
||||
@@ -1193,9 +1279,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')
|
||||
@@ -1262,6 +1350,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if opcode == BINARY:
|
||||
os.write(master, payload)
|
||||
typed[0] = time.monotonic()
|
||||
with _shell_lock:
|
||||
_shell_used[name] = typed[0]
|
||||
elif opcode == TEXT:
|
||||
size = json.loads(payload)
|
||||
fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack('HHHH', int(size['rows']), int(size['cols']), 0, 0))
|
||||
@@ -1317,7 +1407,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 +1450,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 +1459,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'
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
"trusted_proxies": [],
|
||||
"status_content": "basic",
|
||||
"shell": false,
|
||||
"shell_idle_minutes": 15
|
||||
"shell_idle_minutes": 15,
|
||||
"shell_auth_timeout": 15
|
||||
},
|
||||
"ssh": {
|
||||
"ldap_login": false
|
||||
|
||||
@@ -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