feature: web.shell lets /shell of the web service open a login shell of web.shell_user over a WebSocket for the Router Manager's Shell page, after the user signs in again.
Release / release (push) Successful in 15s

This commit is contained in:
slawek committed 2026-10-26 00:05:14 +01:00
1 parent 1825e1efb1
commit 2e1eb89f52
6 files changed
+343 -5

No files matched your search

+26
View File
@@ -895,3 +895,29 @@ Consequences: `slapcat` writes the users and the PKI into every backup in one co
restore brings both back together. The PKI depends on slapd, as the users already do, and not on
PostgreSQL. The directory holds the CA keys, so it is as sensitive as the CA itself. The schema
grows by the package's PKI attributes and classes, which setup adds to slapd's configuration.
## ADR-33: The web service opens a shell for the Router Manager over a WebSocket
Context: The Router Manager's Shell page gives an administrator a terminal on the router in the
browser, with xterm.js, the terminal of VS Code. A terminal needs a full-duplex channel, which
xterm.js expects as a WebSocket. The web service runs in a sandbox (`NoNewPrivileges`,
`ProtectSystem=strict`, `ProtectHome`) that a shell must not inherit, and as root, which a shell
in a browser should not be. A stolen session cookie or access token alone must not give a shell.
Decision: `/shell` of the web service is off until `web.shell` is set on the router; the Router
Manager cannot set it. The shell runs as `web.shell_user`, a system user other than root, which
reaches root through its sudoers entry. `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
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
Manager checks the session and the WebSocket's origin, and relays the bytes to the router after
both handshakes.
Consequences: Who can sign in as a member of `admins` and knows the password and the current code
can get a shell when the router allows one; a session alone cannot. The ticket travels in the URL
and appears in request logs, used up. The shell's sessions show up as `vpn-router-shell-<id>`
units and logind sessions of `web.shell_user`. The shell's traffic is as protected as the Router
Manager's: HTTPS to Caddy, plain HTTP on the internal network to the web service.
+31 -1
View File
@@ -128,6 +128,9 @@ must have the type shown below; an unknown section or setting is an error.
| `web` | `database` | string | | A PostgreSQL connection string for the refresh tokens; without it, the `vpn-router` database on this host that the web service creates |
| `web` | `trusted_proxies` | list | the address the call came in on | Addresses whose `X-Forwarded-For` header names the client for the `/auth` rate limit and the Client IP of the status |
| `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 of `shell_user` for a member of `admins` (see The shell) |
| `web` | `shell_user` | string | | The system user the shell runs as, not `root`; required by `shell` |
| `web` | `shell_idle_minutes` | integer | `15` | Minutes without a keystroke after which a shell is closed, 1 to 1440 |
| `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 |
| `https` | `web_port` | integer | `7444` | HTTPS port of the status web service |
@@ -865,7 +868,7 @@ curl http://10.0.0.4:7081/status
### Tokens and signing in
`/config`, `/users`, `/service-accounts`, `/groups`, `/site-to-site` and `/account` need a JWT in an `Authorization: Bearer` header, signed with
`/config`, `/users`, `/service-accounts`, `/groups`, `/site-to-site`, `/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
@@ -923,6 +926,33 @@ curl -H "Authorization: Bearer $TOKEN" http://10.0.0.4:7081/config
does with `web.status_content` `full`, whatever that setting says, so the Router Manager shows
every service and tunnel while the public status page shows less.
### The shell
While `web.shell` is `true`, `/shell` gives a member of `admins` a login shell on the router, as
the system user `web.shell_user`, for the Router Manager's Shell page. It is off by default and
switched on and off on the router only:
```sh
vpn-config-edit set web.shell_user azureuser
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}` |
| `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. 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 `web.shell_user`, run
by `systemd-run` in a transient unit `vpn-router-shell-<id>`; sudo works as the user's sudoers
entry allows. 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.
### The /config page
`/config` shows what `/` and `/status` leave out, for a client such as manage-ui:
+12
View File
@@ -1,3 +1,15 @@
vpn-router (0.23.0-1) unstable; urgency=medium
feature: web.shell lets /shell of the web service open a login shell of web.shell_user over a WebSocket for the Router Manager's Shell page, after the user signs in again.
* web.shell (false), web.shell_user and web.shell_idle_minutes (15).
* POST /shell takes the password and the TOTP code and answers a
one-time ticket; GET /shell with it is a WebSocket carrying the
terminal, closed after web.shell_idle_minutes without a keystroke.
* The journal of vpn-router-web records each shell opened and ended.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 20:00:00 +0100
vpn-router (0.22.1-1) unstable; urgency=medium
fix: the status gives the number of site-to-site connections set up and established, not each connection by name.
@@ -52,7 +52,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'},
'status_content': 'basic', 'shell': False, 'shell_user': '', 'shell_idle_minutes': 15},
'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',
'crl_days': 7, 'root_crl_days': 180, 'publish': ['http'], 'p2s_name': 'VPN Router P2S CA',
@@ -194,6 +194,12 @@ 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')
#: A system user the shell of the web service runs as, web.shell_user; root is refused.
SHELL_USER = re.compile(r'^[a-z_][a-z0-9_-]{0,31}$')
#: The longest web.shell_idle_minutes, a day.
SHELL_IDLE_MAX = 1440
DOCKER_MODES = ('auto', 'on', 'off')
OPENVPN_PROTOCOLS = ('udp', 'tcp')
@@ -477,6 +483,15 @@ def validate(cfg):
raise ConfigError(
f'web.status_content: {status_content!r} is not one of {", ".join(STATUS_CONTENTS)}')
shell_user = _get(cfg, 'web', 'shell_user')
if shell_user and (not SHELL_USER.match(shell_user) or shell_user == 'root'):
raise ConfigError(f'web.shell_user: {shell_user!r} is not a system user name other than root')
if _get(cfg, 'web', 'shell') and not shell_user:
raise ConfigError('web.shell needs web.shell_user, the system user the shell runs as')
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}')
https_ports = []
for option in ('web_port', 'manager_port'):
port = _get(cfg, 'https', option)
+254 -2
View File
@@ -34,6 +34,9 @@ Pages:
/pki/<name>.crt, /pki/<name>.crl
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 web.shell_user on a terminal: POST signs the
user in again 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
the metrics the metrics service keeps, for a view: hour, day, month or year (see
@@ -42,7 +45,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 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, /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
@@ -76,13 +79,23 @@ key, the configuration, and the features of the router and the facts shown by /c
the router has no features, and /config shows no configuration or interfaces.
"""
import base64
import collections
import configparser
import fcntl
import hashlib
import json
import os
import pwd
import secrets
import select
import shutil
import signal
import socket
import struct
import subprocess
import sys
import termios
import threading
import time
import urllib.parse
@@ -144,6 +157,18 @@ PKI_STATUS = {2: 400, 3: 404, 4: 409}
#: A file /pki/ serves: the certificate or the CRL of a CA, and its content type.
PUBLISHED = {'.crt': 'application/pkix-cert', '.crl': 'application/pkix-crl'}
#: Seconds a ticket of POST /shell may open a shell.
SHELL_TICKET_SECONDS = 30
#: The largest WebSocket message accepted from a client, in bytes.
MESSAGE_LIMIT = 65536
#: The GUID of RFC 6455 that the Sec-WebSocket-Accept of a handshake is made with.
WEBSOCKET_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
#: WebSocket opcodes.
TEXT, BINARY, CLOSE, PING, PONG = 1, 2, 8, 9, 10
_cache = {'at': 0.0, 'data': None}
_lock = threading.Lock()
@@ -151,6 +176,10 @@ _lock = threading.Lock()
_auth_calls = collections.defaultdict(collections.deque)
_auth_lock = threading.Lock()
#: The unused tickets of POST /shell: ticket -> (user, monotonic time of expiry).
_shell_tickets = {}
_shell_lock = threading.Lock()
def read_json(path):
"""The object in a JSON file, or {} when the file is missing or holds no object."""
@@ -992,6 +1021,68 @@ def auth(path, body):
return 500, {'error': str(exc.code)}
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.
"""
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']:
return 403, {'error': 'the shell is disabled; vpn-config-edit set web.shell true enables it'}
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'}
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
except (ldap.LDAPError, OSError) as exc:
return 500, {'error': f'the user directory failed: {exc}'}
except SystemExit as exc:
return 500, {'error': str(exc.code)}
ticket = secrets.token_urlsafe(32)
now = time.monotonic()
with _shell_lock:
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)
return 200, {'ticket': ticket, 'expires_in': SHELL_TICKET_SECONDS}
def take_ticket(name, ticket):
"""Whether the ticket of POST /shell is unexpired and was given to the user; it is used up either way."""
with _shell_lock:
holder, expires = _shell_tickets.pop(ticket, (None, 0.0))
return holder == name and expires > time.monotonic()
def shell_command(account, unit):
"""The command that runs the login shell of the system user account in the transient unit, on its terminal.
setsid makes the terminal the controlling one of systemd-run, which passes the window size on; systemd-run starts
the shell outside the web service's sandbox, in a PAM login session of the user.
"""
return ['setsid', '--ctty', 'systemd-run', '--quiet', '--collect', '--pty', '--send-sighup', f'--unit={unit}',
f'--uid={account.pw_name}', '--property=PAMName=login', f'--working-directory={account.pw_dir}',
'--setenv=TERM=xterm-256color', account.pw_shell or '/bin/sh', '-l']
def frame(opcode, payload):
"""A final, unmasked WebSocket frame of a server."""
size = len(payload)
if size < 126:
length = bytes([size])
elif size < 65536:
length = bytes([126]) + size.to_bytes(2, 'big')
else:
length = bytes([127]) + size.to_bytes(8, 'big')
return bytes([0x80 | opcode]) + length + payload
def auth_wait(address):
"""Seconds until the client address may call /auth again, 0 when it may now; a call that may is counted."""
now = time.monotonic()
@@ -1039,6 +1130,161 @@ class Handler(BaseHTTPRequestHandler):
return 500, f'the user directory failed: {exc}'
return (403, reason) if reason else None
def exact(self, size):
"""The next size bytes from the client; ConnectionError when it closes first."""
data = self.rfile.read(size)
if len(data) != size:
raise ConnectionError('the client closed the connection')
return data
def message(self, send):
"""The opcode and the payload of the client's next WebSocket data message, or CLOSE and its payload.
Fragments are joined and unmasked; a ping is answered with send, a pong ignored. ValueError for an unmasked
frame or a message over MESSAGE_LIMIT.
"""
opcode, payload = None, b''
while True:
head = self.exact(2)
code, size = head[0] & 0x0F, head[1] & 0x7F
if not head[1] & 0x80:
raise ValueError('a frame of the client is not masked')
if size == 126:
size = int.from_bytes(self.exact(2), 'big')
elif size == 127:
size = int.from_bytes(self.exact(8), 'big')
if len(payload) + size > MESSAGE_LIMIT:
raise ValueError(f'a message is larger than {MESSAGE_LIMIT} bytes')
mask = self.exact(4) * (size // 4 + 1)
data = bytes(byte ^ key for byte, key in zip(self.exact(size), mask))
if code == PING:
send(PONG, data)
continue
if code == PONG:
continue
if code == CLOSE:
return CLOSE, data
opcode = code if opcode is None else opcode
payload += data
if head[0] & 0x80:
return opcode, payload
def shell(self, name):
"""Answer GET /shell?ticket=<ticket>, a WebSocket handshake, with a login shell of web.shell_user on a terminal.
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.
"""
def refuse(code, message):
body = json.dumps({'error': message}).encode() + b'\n'
self.send_response(code)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
key = self.headers.get('Sec-WebSocket-Key', '').strip()
if self.headers.get('Upgrade', '').lower() != 'websocket' or 'upgrade' not in self.headers.get('Connection', '').lower() \
or self.headers.get('Sec-WebSocket-Version') != '13' or not key:
return refuse(400, 'GET /shell must be a WebSocket handshake')
query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)
if not take_ticket(name, query.get('ticket', [''])[0]):
return refuse(403, 'the ticket is not valid; POST /shell gives one')
try:
options = vpnrouter.load_config()['web']
except SystemExit as exc:
return refuse(500, str(exc.code))
if not options['shell']:
return refuse(403, 'the shell is disabled; vpn-config-edit set web.shell true enables it')
try:
account = pwd.getpwnam(options['shell_user'])
except KeyError:
return refuse(500, f'the system user {options["shell_user"]} of web.shell_user does not exist')
unit = f'vpn-router-shell-{secrets.token_hex(4)}'
master, terminal = os.openpty()
try:
process = subprocess.Popen(shell_command(account, unit), stdin=terminal, stdout=terminal, stderr=terminal,
env={'PATH': '/usr/sbin:/usr/bin:/sbin:/bin'})
except OSError as exc:
os.close(master)
return refuse(500, f'the shell cannot be started: {exc}')
finally:
os.close(terminal)
accept = base64.b64encode(hashlib.sha1((key + WEBSOCKET_GUID).encode()).digest()).decode()
self.send_response(101, 'Switching Protocols')
self.send_header('Upgrade', 'websocket')
self.send_header('Connection', 'Upgrade')
self.send_header('Sec-WebSocket-Accept', accept)
self.end_headers()
self.close_connection = True
client, started, idle = self.client(), time.monotonic(), options['shell_idle_minutes'] * 60
log(f'shell of {name} from {client} opened as {account.pw_name} in {unit}')
lock, ended, typed = threading.Lock(), threading.Event(), [started]
def send(opcode, payload):
with lock:
self.wfile.write(frame(opcode, payload))
def receive():
"""Pass the client's keystrokes and window sizes to the terminal until it closes the connection."""
try:
while True:
opcode, payload = self.message(send)
if opcode == CLOSE:
break
if opcode == BINARY:
os.write(master, payload)
typed[0] = time.monotonic()
elif opcode == TEXT:
size = json.loads(payload)
fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack('HHHH', int(size['rows']), int(size['cols']), 0, 0))
except (OSError, ValueError, KeyError, TypeError, struct.error):
pass
finally:
ended.set()
reader = threading.Thread(target=receive, name=f'{unit} input', daemon=True)
reader.start()
reason = 'the shell ended'
try:
while not ended.is_set():
left = typed[0] + idle - time.monotonic()
if left <= 0:
reason = f'no keystroke for {options["shell_idle_minutes"]} minutes'
break
if select.select([master], [], [], min(left, 1))[0]:
try:
output = os.read(master, 65536)
except OSError:
output = b''
if not output:
break
send(BINARY, output)
else:
reason = 'the client closed the connection'
except OSError:
reason = 'the connection failed'
run('systemctl', 'stop', unit)
try:
send(CLOSE, (1000).to_bytes(2, 'big') + reason.encode()[:120])
except OSError:
pass
try:
self.connection.shutdown(socket.SHUT_RDWR)
except OSError:
pass
reader.join()
os.close(master)
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
log(f'shell of {name} from {client} in {unit} ended after {int(time.monotonic() - started)} seconds: {reason}')
def answer(self, send_body):
path = self.path.split('?', 1)[0]
extra = []
@@ -1047,7 +1293,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')
'ca', 'shell')
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')
@@ -1085,6 +1331,12 @@ class Handler(BaseHTTPRequestHandler):
query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)
code, document = metrics_history(query.get('range', ['hour'])[0])
body, kind = json.dumps(document).encode() + b'\n', 'application/json'
elif directory == 'shell' and (path, self.command) == ('/shell', 'GET'):
return self.shell(claims.get('sub'))
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}'})
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif directory == 'account':
code, document = account(claims['sub'], self.command, path, payload)
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
@@ -61,7 +61,10 @@
"port": 7081,
"database": "",
"trusted_proxies": [],
"status_content": "basic"
"status_content": "basic",
"shell": false,
"shell_user": "",
"shell_idle_minutes": 15
},
"https": {
"enabled": true,