update: the router web service is split into the package vpnrouter_web, with one module per family of paths.
Release / release (push) Successful in 14s

This commit is contained in:
slawek committed 2026-10-29 18:52:51 +01:00
1 parent 3b14b7723e
commit 46d3550eab
19 files changed
+2359 -2136

No files matched your search

@@ -0,0 +1,468 @@
"""/shell: the ticket, the WebSocket of the terminal (shell), and the editor of the shell: the socket vpn-router-editor
names files on (editor_listen) and /shell/editor/... (shell_editor)."""
import base64
import fcntl
import hashlib
import json
import os
import pwd
import secrets
import select
import socket
import struct
import subprocess
import termios
import threading
import time
import urllib.parse
import ldap
import vpnrouter
import vpnrouter_users
from vpnrouter_status import run
from .common import BODY_LIMIT, MIB, USER_STATUS, log
#: Seconds a ticket of POST /shell may open a shell.
SHELL_TICKET_SECONDS = 30
#: Seconds between the checks of an open shell's user, which end the shell once its console is refused.
SHELL_CHECK_SECONDS = 60
#: The largest WebSocket message accepted from a client, in bytes.
MESSAGE_LIMIT = 65536
#: The socket vpn-router-editor (vre) names files on to open them in the editor of a shell (see editor_listen()).
EDITOR_SOCKET = '/run/vpn-router/editor.sock'
#: The helper that reads and writes a file of the editor as the shell's user.
EDITOR_FILE = '/usr/lib/vpn-router/editor-file'
#: The largest file the editor opens, in bytes, as editor-file allows; a body of PUT /shell/editor/... may hold its
#: content escaped in JSON.
EDITOR_MAX_BYTES = MIB
EDITOR_BODY_LIMIT = 2 * EDITOR_MAX_BYTES + BODY_LIMIT
#: The most files a shell has open in its editor at once.
EDITOR_FILES_MAX = 20
#: Seconds a reader of the editor socket and a run of editor-file may take.
EDITOR_TIMEOUT = 30
#: 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
#: The unused tickets of POST /shell: ticket -> (user, monotonic time of expiry).
_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 = {}
#: The open shells by their session, which each shell has in VPN_ROUTER_SHELL: session -> {"user", "account", "uid",
#: "send", the function that sends a WebSocket frame to its client, "files": {file id: path}}.
_shell_sessions = {}
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. 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, the user's console login is not on or the user has no TOTP token.
"""
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:
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'}
refusal = console_account(name)[1]
if refusal:
return 403, {'error': refusal}
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'}
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)
_shell_used[name] = now
return 200, {'ticket': ticket, 'expires_in': SHELL_TICKET_SECONDS}
def console_account(name):
"""The system account the console of the user runs as and None, or None and the reason the user may not use a
console: its console login is off, or it has no TOTP token."""
with vpnrouter_users.session() as connection:
user = vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
if not user['console'] or not user['console']['enabled']:
return None, f'console login is not enabled for {name}; vpn-router-user console sets it'
if not user['otp']:
return None, f'{name} has no TOTP token; the shell needs one, enrolled on the Account page'
return user['console']['account'], None
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, session):
"""The command that runs the login shell of the system user account in the transient unit, on its terminal, with
the shell's session in VPN_ROUTER_SHELL for vpn-router-editor.
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', f'--setenv=VPN_ROUTER_SHELL={session}', account.pw_shell or '/bin/sh', '-l']
def editor_file(account, action, path, content=b'', version=''):
"""Run editor-file as the system user account outside the sandbox: read the file, or write content to it when it
still has version. The exit status of editor-file and its output, or its message on failure."""
command = ['systemd-run', '--quiet', '--collect', '--pipe', f'--uid={account}', EDITOR_FILE, action, path]
try:
result = subprocess.run(command + ([version] if action == 'write' else []), input=content, capture_output=True,
timeout=EDITOR_TIMEOUT)
except (OSError, subprocess.TimeoutExpired) as exc:
return 1, f'editor-file cannot be run: {exc}'.encode()
return result.returncode, result.stdout if result.returncode == 0 else result.stderr.strip() or \
f'editor-file ended with status {result.returncode}'.encode()
def shell_editor(name, method, parts, body):
"""Answer a request of a file open in the editor of one of the user's shells: the HTTP status and the JSON document.
GET /shell/editor/<session>/<id> {"path", "content", "version"}, version the SHA-256 of the content
PUT /shell/editor/<session>/<id> {"content", "version"} writes the content when the file still has version,
else 409; answers {"path", "version"}
DELETE /shell/editor/<session>/<id> closes the file
The file is read and written as the shell's console account (editor_file). 404 for a session that is not a shell of
the user or a file it does not have open.
"""
if len(parts) != 4:
return 404, {'error': f'no route {method} /{"/".join(parts)}'}
with _shell_lock:
session = _shell_sessions.get(parts[2])
path = session['files'].get(parts[3]) if session and session['user'] == name else None
account = session['account'] if path else None
if path is None:
return 404, {'error': 'no such file is open in a shell of yours'}
if method == 'DELETE':
with _shell_lock:
session['files'].pop(parts[3], None)
return 200, {'path': path, 'closed': True}
if method == 'GET':
status, output = editor_file(account, 'read', path)
if status != 0:
return (400 if status == 2 else 500), {'error': output.decode(errors='replace')}
return 200, {'path': path, 'content': output.decode(), 'version': hashlib.sha256(output).hexdigest()}
if method != 'PUT':
return 405, {'error': f'{method} is not allowed on a file of the editor'}
if not isinstance(body, dict) or not isinstance(body.get('content'), str) or not isinstance(body.get('version'), str):
return 400, {'error': 'the body must be {"content", "version"}'}
content = body['content'].encode()
if len(content) > EDITOR_MAX_BYTES:
return 413, {'error': f'the content is larger than {EDITOR_MAX_BYTES // 1024} KiB'}
status, output = editor_file(account, 'write', path, content, body['version'])
if status != 0:
return {2: 400, 3: 409}.get(status, 500), {'error': output.decode(errors='replace')}
log(f'editor of {name} saved {path} as {account}')
return 200, {'path': path, 'version': hashlib.sha256(content).hexdigest()}
def editor_request(connection):
"""Answer one request of vpn-router-editor on the editor socket: {"session", "path"} on a line opens the file in the
editor of the shell of the session, whose client is sent {"editor": "open", "session", "id", "path"} as a text
message.
The peer must run as the shell's console account and the path must be absolute. The answer is {"ok": true} or
{"error"} on a line.
"""
try:
connection.settimeout(EDITOR_TIMEOUT)
_, uid, _ = struct.unpack('iII', connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED,
struct.calcsize('iII')))
request = json.loads(connection.makefile('rb').readline(4096) or b'null')
if not isinstance(request, dict) or not isinstance(request.get('session'), str) or \
not isinstance(request.get('path'), str):
answer = {'error': 'the request must be {"session", "path"}'}
elif not os.path.isabs(request['path']):
answer = {'error': 'the path must be absolute'}
else:
with _shell_lock:
session = _shell_sessions.get(request['session'])
if session is None or session['uid'] != uid:
answer = {'error': 'no shell of the Router Manager has this session; vre works only in its shell'}
elif len(session['files']) >= EDITOR_FILES_MAX:
answer = {'error': f'the editor has {EDITOR_FILES_MAX} files open; close one first'}
else:
file_id = secrets.token_urlsafe(8)
session['files'][file_id] = request['path']
answer = None
if answer is None:
session['send'](TEXT, json.dumps({'editor': 'open', 'session': request['session'], 'id': file_id,
'path': request['path']}).encode())
log(f'editor of {session["user"]} opened {request["path"]}')
answer = {'ok': True}
connection.sendall(json.dumps(answer).encode() + b'\n')
except (OSError, ValueError, struct.error):
pass
finally:
connection.close()
def editor_listen():
"""Listen on EDITOR_SOCKET, which any local user may connect to, answering each connection in a thread with
editor_request; the socket of an earlier run is replaced. Does not return; a failure to listen is logged and the
editor stays closed."""
try:
if os.path.lexists(EDITOR_SOCKET):
os.unlink(EDITOR_SOCKET)
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
listener.bind(EDITOR_SOCKET)
os.chmod(EDITOR_SOCKET, 0o666)
listener.listen(16)
except OSError as exc:
log(f'the editor socket {EDITOR_SOCKET} cannot be opened: {exc}')
return
while True:
try:
connection, _ = listener.accept()
except OSError:
continue
threading.Thread(target=editor_request, args=(connection,), name='editor request', daemon=True).start()
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 exact(handler, size):
"""The next size bytes from the client of the Handler handler; ConnectionError when it closes first."""
data = handler.rfile.read(size)
if len(data) != size:
raise ConnectionError('the client closed the connection')
return data
def message(handler, send):
"""The opcode and the payload of the next WebSocket data message of the client of the Handler handler, 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 = exact(handler, 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(exact(handler, 2), 'big')
elif size == 127:
size = int.from_bytes(exact(handler, 8), 'big')
if len(payload) + size > MESSAGE_LIMIT:
raise ValueError(f'a message is larger than {MESSAGE_LIMIT} bytes')
mask = exact(handler, 4) * (size // 4 + 1)
data = bytes(byte ^ key for byte, key in zip(exact(handler, 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(handler, name):
"""Answer GET /shell?ticket=<ticket>, a WebSocket handshake the Handler handler received, with a login shell of the
user's console account (vpnrouter_users.console) 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; a text message of the server {"editor": "open", "session", "id", "path"} opens a file the
shell named with vpn-router-editor (editor_request), which the client reads and saves with
/shell/editor/<session>/<id> (shell_editor) while the shell lives. The shell ends after web.shell_idle_minutes
without a keystroke, when it exits, when the client closes the connection, or when a check of the user (SHELL_CHECK_SECONDS) finds its console login off or
its TOTP token gone; 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 {handler.client()} refused: {message}')
body = json.dumps({'error': message}).encode() + b'\n'
handler.send_response(code)
handler.send_header('Content-Type', 'application/json')
handler.send_header('Content-Length', str(len(body)))
handler.end_headers()
handler.wfile.write(body)
key = handler.headers.get('Sec-WebSocket-Key', '').strip()
if handler.headers.get('Upgrade', '').lower() != 'websocket' or 'upgrade' not in handler.headers.get('Connection', '').lower() \
or handler.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(handler.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:
login, refusal = console_account(name)
except (vpnrouter_users.Failure, ldap.LDAPError, OSError) as exc:
return refuse(500, f'the user directory failed: {exc}')
if refusal:
return refuse(403, refusal)
try:
account = pwd.getpwnam(login)
except KeyError:
return refuse(500, f'the system does not know the account {login}; is nslcd running?')
unit = f'vpn-router-shell-{secrets.token_hex(4)}'
session = secrets.token_urlsafe(24)
master, terminal = os.openpty()
try:
process = subprocess.Popen(shell_command(account, unit, session), 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()
handler.send_response(101, 'Switching Protocols')
handler.send_header('Upgrade', 'websocket')
handler.send_header('Connection', 'Upgrade')
handler.send_header('Sec-WebSocket-Accept', accept)
handler.end_headers()
handler.close_connection = True
client, started, idle = handler.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:
handler.wfile.write(frame(opcode, payload))
with _shell_lock:
_shell_sessions[session] = {'user': name, 'account': account.pw_name, 'uid': account.pw_uid, 'send': send,
'files': {}}
def receive():
"""Pass the client's keystrokes and window sizes to the terminal until it closes the connection."""
try:
while True:
opcode, payload = message(handler, send)
if opcode == CLOSE:
break
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))
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'
checked = started
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
# The user's console login and TOTP token are checked again every SHELL_CHECK_SECONDS.
if time.monotonic() - checked >= SHELL_CHECK_SECONDS:
checked = time.monotonic()
try:
refusal = console_account(name)[1]
except (vpnrouter_users.Failure, ldap.LDAPError, OSError) as exc:
refusal = f'the user directory failed: {exc}'
if refusal:
reason = refusal
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'
with _shell_lock:
_shell_sessions.pop(session, None)
run('systemctl', 'stop', unit)
try:
send(CLOSE, (1000).to_bytes(2, 'big') + reason.encode()[:120])
except OSError:
pass
try:
handler.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}')