diff --git a/ADR.md b/ADR.md index d5a39bb..719e579 100644 --- a/ADR.md +++ b/ADR.md @@ -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-` +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. diff --git a/README.md b/README.md index 00e4fbd..fb55e90 100644 --- a/README.md +++ b/README.md @@ -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=` | 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-`; 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: diff --git a/debian-package/debian/changelog b/debian-package/debian/changelog index ff6a9f5..d50eb9d 100644 --- a/debian-package/debian/changelog +++ b/debian-package/debian/changelog @@ -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 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. diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter.py b/debian-package/src/usr/lib/vpn-router/vpnrouter.py index 0f7ecef..5319b46 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter.py @@ -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) diff --git a/debian-package/src/usr/lib/vpn-router/web b/debian-package/src/usr/lib/vpn-router/web index 33b04ea..5755bfd 100644 --- a/debian-package/src/usr/lib/vpn-router/web +++ b/debian-package/src/usr/lib/vpn-router/web @@ -34,6 +34,9 @@ Pages: /pki/.crt, /pki/.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=, 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' diff --git a/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example b/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example index c30e22b..f005953 100644 --- a/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example +++ b/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example @@ -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,