Compare commits

...
3 Commits
8 changed files with 127 additions and 9 deletions

No files matched your search

+1 -1
View File
@@ -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 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` 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 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 Manager checks the session and the WebSocket's origin, and relays the bytes to the router after
both handshakes. both handshakes.
+20 -3
View File
@@ -885,7 +885,7 @@ The status holds the state of `vpn-router-setup`, `slapd` and each PostgreSQL cl
`vpn-router-manager` when that package is installed, of `strongswan` when site-to-site or `vpn-router-manager` when that package is installed, of `strongswan` when site-to-site or
road-warrior access is configured, of `wg-quick@wg0` when WireGuard is, and of road-warrior access is configured, of `wg-quick@wg0` when WireGuard is, and of
`openvpn-server@vpn-router` when OpenVPN is; the number of connected `openvpn-server@vpn-router` when OpenVPN is; the number of connected
OpenVPN clients, from the server's status file, which OpenVPN rewrites every minute; the package version and, OpenVPN clients, from the server's status file, which OpenVPN rewrites every 5 seconds; the package version and,
as `manager_version`, the installed version of `vpn-router-manager` (`null`, shown as not installed, without it); whether as `manager_version`, the installed version of `vpn-router-manager` (`null`, shown as not installed, without it); whether
Docker is installed, and when it is, the state of `docker.service` and of `vpn-router-docker` Docker is installed, and when it is, the state of `docker.service` and of `vpn-router-docker`
(the Docker rules), which do not change `status`; as `site_to_site`, the number of site-to-site (the Docker rules), which do not change `status`; as `site_to_site`, the number of site-to-site
@@ -901,7 +901,7 @@ curl http://10.0.0.4:7081/status
### Tokens and signing in ### Tokens and signing in
`/config`, `/users`, `/service-accounts`, `/groups`, `/site-to-site`, `/shell` and `/account` need a JWT in an `Authorization: Bearer` header, signed with `/config`, `/users`, `/service-accounts`, `/groups`, `/site-to-site`, `/backup`, `/remote-access`, `/ca`, `/shell` and `/account` need a JWT in an `Authorization: Bearer` header, signed with
HS256 by the key the web service makes on its first run, `/etc/vpn-router/web.key` (root only). HS256 by the key the web service makes on its first run, `/etc/vpn-router/web.key` (root only).
The key never leaves the router; while it does not exist these paths and `/auth` are not served The key never leaves the router; while it does not exist these paths and `/auth` are not served
(404). A missing, expired or wrongly signed token, or one of another algorithm, gets 401 with (404). A missing, expired or wrongly signed token, or one of another algorithm, gets 401 with
@@ -984,7 +984,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 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 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, 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 ### The /config page
@@ -1142,6 +1142,23 @@ The answer of `GET /backup` holds:
A change is made, checked and applied as for `/site-to-site`. A change is made, checked and applied as for `/site-to-site`.
### The /remote-access API
`/remote-access` shows and changes the remote access systems, IKEv2 road-warrior (`p2s`),
OpenVPN (`openvpn`) and WireGuard (`wireguard`), with the same token as `/users`.
| Request | Body | Answer |
| --- | --- | --- |
| `GET /remote-access` | | `{"p2s", "openvpn", "wireguard", "running", "local_fqdn"}` |
| `PUT /remote-access/<system>` | any of the settings of the section `p2s`, `openvpn` or `wireguard` | the section |
`running` tells for each system whether setup runs it. IKEv2 and OpenVPN run once enabled with an
address pool, `wan.local_fqdn` set and the router's VPN server certificate issued by the P2S CA;
WireGuard once enabled with an address. A system enabled without its address pool or address is
refused. A change is made, checked and applied as for `/site-to-site`. The router's firewall
follows the settings; a cloud firewall in front of the router, such as an Azure network security
group, must allow the OpenVPN and WireGuard ports itself.
### The /ca API ### The /ca API
`/ca` manages the PKI (see Certificates) with the same token as `/users`, with the module `/ca` manages the PKI (see Certificates) with the same token as `/users`, with the module
+32
View File
@@ -1,3 +1,35 @@
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 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. 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.
+5 -2
View File
@@ -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 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, 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 and checks their passwords by binding as the user; the passwd line of the name service switch gains ldap, which
creates a home at the first login. While ssh.ldap_login is true, sshd accepts the password of the group's members; 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. a drop-in sshd -t refuses is removed again.
""" """
group = vpnrouter_ldap.CONSOLE_GROUP group = vpnrouter_ldap.CONSOLE_GROUP
@@ -601,6 +601,9 @@ def manage_console(cfg):
for line in lines] for line in lines]
if switched != lines: if switched != lines:
NSSWITCH.write_text(''.join(switched), encoding='utf-8') 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'): if 'pam_mkhomedir' not in PAM_SESSION.read_text(encoding='utf-8'):
run(['pam-auth-update', '--enable', 'mkhomedir'], check=False) 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_ldap
import vpnrouter_pki import vpnrouter_pki
import vpnrouter_platforms import vpnrouter_platforms
import vpnrouter_status
from vpnrouter_net import DEFAULT_LISTEN, LISTEN_CHOICES, LOOPBACK, ConfigError, detect_interfaces, interface_addresses, ip_output from vpnrouter_net import DEFAULT_LISTEN, LISTEN_CHOICES, LOOPBACK, ConfigError, detect_interfaces, interface_addresses, ip_output
CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.json') CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.json')
@@ -435,6 +436,9 @@ def validate(cfg):
raise ValueError raise ValueError
except ValueError: except ValueError:
raise ConfigError(f'openvpn.address_pool: {openvpn_pool!r} is not an IPv4 network') raise ConfigError(f'openvpn.address_pool: {openvpn_pool!r} is not an IPv4 network')
for system, option in (('p2s', 'address_pool'), ('openvpn', 'address_pool'), ('wireguard', 'address')):
if _get(cfg, system, 'enabled') and not _get(cfg, system, option):
raise ConfigError(f'{system}.enabled needs {system}.{option}')
_check_address(_get(cfg, 'local', 'int_addr'), 'local.int_addr') _check_address(_get(cfg, 'local', 'int_addr'), 'local.int_addr')
_check_address(_get(cfg, 'local', 'int_gateway_ip'), 'local.int_gateway_ip') _check_address(_get(cfg, 'local', 'int_gateway_ip'), 'local.int_gateway_ip')
@@ -893,6 +897,8 @@ def build_context(cfg, public_ip=None):
'openvpn_protocol': _get(cfg, 'openvpn', 'protocol'), 'openvpn_protocol': _get(cfg, 'openvpn', 'protocol'),
'openvpn_device': OPENVPN_DEVICE, 'openvpn_device': OPENVPN_DEVICE,
'openvpn_ldap_conf': str(OPENVPN_LDAP), 'openvpn_ldap_conf': str(OPENVPN_LDAP),
'openvpn_status': vpnrouter_status.OPENVPN_STATUS,
'openvpn_status_seconds': vpnrouter_status.OPENVPN_STATUS_SECONDS,
'p2s_dns_list': _split_list(_join(local_addrs, _split_list(extras['EXTRA_P2S_DNS']))), 'p2s_dns_list': _split_list(_join(local_addrs, _split_list(extras['EXTRA_P2S_DNS']))),
'openvpn_ca': str(vpnrouter_pki.OPENVPN_CA), 'openvpn_ca': str(vpnrouter_pki.OPENVPN_CA),
'openvpn_crl': str(vpnrouter_pki.OPENVPN_CRL), 'openvpn_crl': str(vpnrouter_pki.OPENVPN_CRL),
@@ -12,6 +12,9 @@ COMMAND_TIMEOUT = 5
OPENVPN_STATUS = '/run/openvpn-server/status-vpn-router.log' OPENVPN_STATUS = '/run/openvpn-server/status-vpn-router.log'
#: Seconds between two writes of OPENVPN_STATUS by the OpenVPN server, whose configuration sets it (OpenVPN's default is 60).
OPENVPN_STATUS_SECONDS = 5
#: Seconds since its last handshake within which a WireGuard peer counts as connected. #: Seconds since its last handshake within which a WireGuard peer counts as connected.
WIREGUARD_CONNECTED_SECONDS = 180 WIREGUARD_CONNECTED_SECONDS = 180
+58 -3
View File
@@ -28,6 +28,9 @@ Pages:
applied by vpn-router-setup (see site_to_site()) applied by vpn-router-setup (see site_to_site())
/backup the backup settings, the last run and the local archives: show, change and run /backup the backup settings, the last run and the local archives: show, change and run
now (see backup()) now (see backup())
/remote-access
the settings of IKEv2 road-warrior, OpenVPN and WireGuard access and whether each
runs: show and change, applied by vpn-router-setup (see remote_access())
/ca the PKI: its settings, the root and issuing CAs, the certificates issued; change the /ca the PKI: its settings, the root and issuing CAs, the certificates issued; change the
settings, issue and revoke certificates, and take the certificate of an issuing CA settings, issue and revoke certificates, and take the certificate of an issuing CA
and a root CRL signed offline (see ca()) and a root CRL signed offline (see ca())
@@ -46,7 +49,7 @@ Pages:
the databases of each PostgreSQL cluster on the router with their sizes, as JSON (see the databases of each PostgreSQL cluster on the router with their sizes, as JSON (see
postgresql_databases()); /config has the clusters' settings postgresql_databases()); /config has the clusters' settings
/status/full, /config, /metrics/history, /postgresql, /users, /service-accounts, /groups, /site-to-site, /backup, /ca, /shell and /account need an HS256 JWT in an Authorization: Bearer header signed /status/full, /config, /metrics/history, /postgresql, /users, /service-accounts, /groups, /site-to-site, /backup, /remote-access, /ca, /shell and /account need an HS256 JWT in an Authorization: Bearer header signed
with vpnrouter_auth.WEB_KEY (see vpnrouter_auth.verify). A token of /auth names a user in sub; with vpnrouter_auth.WEB_KEY (see vpnrouter_auth.verify). A token of /auth names a user in sub;
/account serves that user, and the other paths need a member of vpnrouter_users.ADMIN_GROUP. A /account serves that user, and the other paths need a member of vpnrouter_users.ADMIN_GROUP. A
token of vpn-router-token has the admin claim and names no user; it may call every path but token of vpn-router-token has the admin claim and names no user; it may call every path but
@@ -150,6 +153,9 @@ BODY_LIMIT = 65536
#: The settings of a site_to_site connection that /site-to-site adds and changes. #: The settings of a site_to_site connection that /site-to-site adds and changes.
CONNECTION_SETTINGS = ('addrs', 'id', 'cidrs', 'local_cidrs', 'local_id_mode', 'auth', 'psk', 'remote_ca') CONNECTION_SETTINGS = ('addrs', 'id', 'cidrs', 'local_cidrs', 'local_id_mode', 'auth', 'psk', 'remote_ca')
#: The sections of the remote access systems /remote-access changes: IKEv2 road-warrior, OpenVPN and WireGuard.
REMOTE_ACCESS = ('p2s', 'openvpn', 'wireguard')
#: The HTTP status for each status of a vpnrouter_users.Failure; any other is 500. #: The HTTP status for each status of a vpnrouter_users.Failure; any other is 500.
USER_STATUS = {2: 400, 3: 404, 4: 409, 5: 401} USER_STATUS = {2: 400, 3: 404, 4: 409, 5: 401}
@@ -814,6 +820,48 @@ def backup(method, path, body):
return 200, {'settings': backup_settings(cfg['backup'])} 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): def ca(method, path, body):
"""Answer a /ca request with vpnrouter_pki: the HTTP status and the JSON document. """Answer a /ca request with vpnrouter_pki: the HTTP status and the JSON document.
@@ -1193,9 +1241,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 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 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 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): def refuse(code, message):
log(f'shell of {name} from {self.client()} refused: {message}')
body = json.dumps({'error': message}).encode() + b'\n' body = json.dumps({'error': message}).encode() + b'\n'
self.send_response(code) self.send_response(code)
self.send_header('Content-Type', 'application/json') self.send_header('Content-Type', 'application/json')
@@ -1317,7 +1367,7 @@ class Handler(BaseHTTPRequestHandler):
if self.command not in ('GET', 'HEAD'): if self.command not in ('GET', 'HEAD'):
self.close_connection = True self.close_connection = True
directory = next((name for name in ('users', 'service-accounts', 'groups', 'account', 'site-to-site', 'backup', directory = next((name for name in ('users', 'service-accounts', 'groups', 'account', 'site-to-site', 'backup',
'ca', 'shell') 'ca', 'shell', 'remote-access')
if path == f'/{name}' or path.startswith(f'/{name}/')), None) if path == f'/{name}' or path.startswith(f'/{name}/')), None)
protected = path in ('/status/full', '/config', '/metrics/history', '/postgresql') or directory is not None protected = path in ('/status/full', '/config', '/metrics/history', '/postgresql') or directory is not None
signing = path in ('/auth', '/auth/refresh', '/auth/logout') signing = path in ('/auth', '/auth/refresh', '/auth/logout')
@@ -1360,6 +1410,8 @@ class Handler(BaseHTTPRequestHandler):
elif directory == 'shell': elif directory == 'shell':
code, document = shell_ticket(claims.get('sub'), payload) if (path, self.command) == ('/shell', 'POST') \ code, document = shell_ticket(claims.get('sub'), payload) if (path, self.command) == ('/shell', 'POST') \
else (404, {'error': f'no route {self.command} {path}'}) 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' body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif directory == 'account': elif directory == 'account':
code, document = account(claims['sub'], self.command, path, payload) code, document = account(claims['sub'], self.command, path, payload)
@@ -1367,6 +1419,9 @@ class Handler(BaseHTTPRequestHandler):
elif directory == 'site-to-site': elif directory == 'site-to-site':
code, document = site_to_site(self.command, path, payload) code, document = site_to_site(self.command, path, payload)
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json' body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif directory == 'remote-access':
code, document = remote_access(state, self.command, path, payload)
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif directory == 'backup': elif directory == 'backup':
code, document = backup(self.command, path, payload) code, document = backup(self.command, path, payload)
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json' body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
@@ -24,6 +24,8 @@ push "route {{ network }} {{ netmask }}"
push "dhcp-option DNS {{ address }}" push "dhcp-option DNS {{ address }}"
{% endfor -%} {% endfor -%}
status {{ openvpn_status }} {{ openvpn_status_seconds }}
keepalive 10 120 keepalive 10 120
{% if openvpn_protocol == 'udp' -%} {% if openvpn_protocol == 'udp' -%}
explicit-exit-notify 1 explicit-exit-notify 1