From 3b14b7723e38e8d9ab65da354ce9cdee287b7e7d Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Thu, 29 Oct 2026 18:10:58 +0100 Subject: [PATCH] feature: vre opens files from the Router Manager's shell in the editor of its Shell page. --- ADR.md | 23 +++ README.md | 28 +++ debian-package/debian/changelog | 15 ++ debian-package/debian/install | 2 + debian-package/debian/links | 1 + debian-package/src/usr/bin/vpn-router-editor | 71 +++++++ .../src/usr/lib/vpn-router/editor-file | 82 ++++++++ debian-package/src/usr/lib/vpn-router/web | 181 ++++++++++++++++-- 8 files changed, 391 insertions(+), 12 deletions(-) create mode 100644 debian-package/debian/links create mode 100755 debian-package/src/usr/bin/vpn-router-editor create mode 100755 debian-package/src/usr/lib/vpn-router/editor-file diff --git a/ADR.md b/ADR.md index 86a8c4b..85c5e2a 100644 --- a/ADR.md +++ b/ADR.md @@ -1230,3 +1230,26 @@ the addresses setup records for the web service. Consequences: Clients resolve the listed domains through the tunnel and everything else with their own resolvers. Clients that ignore attribute 25 keep the scoped behaviour; the Apple profile covers Apple devices. A changed list reaches an Apple device with a newly installed profile. + +## ADR-46: vre opens files in the Shell page's editor over a socket checked by the peer's uid + +Status: Accepted + +Context: Administrators edit the router's files from the Router Manager's Shell page, in an editor +of the page rather than a terminal editor. The command that names a file runs in the shell, as the +console account. The web service runs as root, sandboxed with `ProtectHome=yes`, so it cannot see +the accounts' home folders. Reading and writing as root would also bypass the account's +permissions. + +Decision: Each shell has a random session in `VPN_ROUTER_SHELL`. `vpn-router-editor` (`vre`) sends +the session and an absolute path to the web service on `/run/vpn-router/editor.sock`, which anyone +may connect to. The web service accepts a request only when the peer's uid (`SO_PEERCRED`) is the +uid of the session's console account. It then tells the shell's client over the shell's WebSocket, +and the page reads and saves the file through `/shell/editor//`. Files are read and +written by `editor-file`, run with `systemd-run --uid= --pipe` outside the sandbox, as the +shell is. A save carries the SHA-256 of the content it replaces and is refused when the file +changed since. Files open only while their shell lives. + +Consequences: The editor edits only what the console account may; a file only root may write is +edited in the terminal. `vre` run under sudo is refused, as its uid is not the console account's. +Each read and save starts a transient unit. Files are limited to 1 MiB of UTF-8 text. diff --git a/README.md b/README.md index 3d6668c..67c80b8 100644 --- a/README.md +++ b/README.md @@ -1099,6 +1099,34 @@ off or the TOTP enrolment removed; the router then sends a close frame with the journal of `vpn-router-web` records who opened each shell, from which address, in which unit, when it ended and why, and each refused ticket or shell with the reason. +#### The editor of the shell + +In the shell, `vpn-router-editor FILE...`, or its short name `vre`, opens files in the editor of +the Router Manager's Shell page and returns at once: + +```sh +vre /etc/vpn-router/vpn-router.json ~/notes.txt +``` + +- **How the files reach the page:** each shell has its session in `VPN_ROUTER_SHELL`. `vre` + names each file, as an absolute path, with that session on the socket + `/run/vpn-router/editor.sock`. The web service accepts it only from a process that runs as the + shell's console account, and sends the page a text message `{"editor": "open", "session", "id", "path"}` + over the shell's WebSocket. +- **Reading and saving:** the page uses the routes below, which need the user's access token. The + file is read and written as the console account by `/usr/lib/vpn-router/editor-file`, run with + `systemd-run --uid --pipe`, so the account's permissions apply. A save writes the file in place, + keeping its owner and mode, and creates a file that does not exist. +- **Limits:** only regular files of UTF-8 text up to 1 MiB, and at most 20 open files per shell. +- **Lifetime:** the files are open while the shell lives. `vre` outside the Router Manager's shell + exits with status 2. + +| Request | Body | Answer | +| --- | --- | --- | +| `GET /shell/editor//` | | `{"path", "content", "version"}`, `version` the SHA-256 of the content | +| `PUT /shell/editor//` | `{"content", "version"}` | `{"path", "version"}`; 409 when the file no longer has `version` | +| `DELETE /shell/editor//` | | `{"path", "closed": true}` | + ### 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 b54eac3..0088689 100644 --- a/debian-package/debian/changelog +++ b/debian-package/debian/changelog @@ -1,3 +1,18 @@ +vpn-router (0.35.0-1) unstable; urgency=medium + + feature: vre opens files from the Router Manager's shell in the editor of its Shell page. + + * vpn-router-editor FILE..., or vre, in the shell of the Router Manager + opens the files in the editor of the Shell page and returns at once; + outside that shell it exits with status 2. + * GET, PUT and DELETE /shell/editor// read, save and close + a file opened so, as the shell's console account; a save is refused + with 409 when the file changed since it was read. + * The editor takes regular files of UTF-8 text up to 1 MiB, at most 20 + per shell, while the shell lives. + + -- Slawomir Koszewski Thu, 29 Oct 2026 18:02:27 +0100 + vpn-router (0.34.0-1) unstable; urgency=medium feature: the web service shows and changes the settings of the metrics service at /metrics/settings, and OpenVPN starts on a new router, as setup installs CRLs that revoke nothing. diff --git a/debian-package/debian/install b/debian-package/debian/install index 7792a4b..2fe01ba 100644 --- a/debian-package/debian/install +++ b/debian-package/debian/install @@ -17,6 +17,8 @@ src/usr/lib/vpn-router/install-packages usr/lib/vpn-router/ src/usr/lib/vpn-router/webdav-auth usr/lib/vpn-router/ src/usr/lib/vpn-router/openvpn-same-user usr/lib/vpn-router/ src/usr/lib/vpn-router/openvpn-issuer usr/lib/vpn-router/ +src/usr/lib/vpn-router/editor-file usr/lib/vpn-router/ +src/usr/bin/vpn-router-editor usr/bin/ src/usr/sbin/vpn-router-user usr/sbin/ src/usr/sbin/vpn-router-group usr/sbin/ src/usr/sbin/vpn-router-token usr/sbin/ diff --git a/debian-package/debian/links b/debian-package/debian/links new file mode 100644 index 0000000..88f9041 --- /dev/null +++ b/debian-package/debian/links @@ -0,0 +1 @@ +usr/bin/vpn-router-editor usr/bin/vre diff --git a/debian-package/src/usr/bin/vpn-router-editor b/debian-package/src/usr/bin/vpn-router-editor new file mode 100755 index 0000000..c450484 --- /dev/null +++ b/debian-package/src/usr/bin/vpn-router-editor @@ -0,0 +1,71 @@ +#!/usr/bin/python3 +"""Open files in the editor of the Router Manager's Shell page, from the shell of that page; vre is its short name. + +Each file is named to the router's web service over SOCKET with the shell's session, +$VPN_ROUTER_SHELL, which the web service sets in the shells it starts; the Shell page then shows +the file in a tab of its editor, where it is read and saved. The command returns at once. A +relative path is taken from the current directory, and a file that does not exist is created when +it is saved. The exit status is 0 when every file was opened, 1 when the web service refused one, +with its reason, and 2 outside the Router Manager's shell or for an invalid request. -h or --help +shows the usage. + + vre FILE... +""" + +import argparse +import json +import os +import socket +import sys + +#: The socket of the router's web service that opens files in the editor of a shell. +SOCKET = '/run/vpn-router/editor.sock' + +#: Seconds to wait for the web service's answer. +TIMEOUT = 10 + + +class Parser(argparse.ArgumentParser): + """An argument parser whose errors end with status 2.""" + + def error(self, message): + print(f'{self.prog}: {message}; see {self.prog} --help', file=sys.stderr) + sys.exit(2) + + +def opened(session, path): + """Ask the web service to open the file in the editor of the session; None, or the reason it refused.""" + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection: + connection.settimeout(TIMEOUT) + connection.connect(SOCKET) + connection.sendall(json.dumps({'session': session, 'path': path}).encode() + b'\n') + answer = json.loads(connection.makefile('rb').readline() or b'{}') + return None if answer.get('ok') is True else str(answer.get('error', 'no answer')) + + +def main(argv): + prog = os.path.basename(sys.argv[0]) + parser = Parser(prog=prog, description="Open files in the editor of the Router Manager's Shell page.") + parser.add_argument('files', nargs='+', metavar='FILE', help='a file to open') + options = parser.parse_args(argv) + session = os.environ.get('VPN_ROUTER_SHELL', '') + if not session: + print(f'{prog}: {prog} works only in the shell of the Router Manager', file=sys.stderr) + return 2 + status = 0 + for name in options.files: + path = os.path.abspath(os.path.expanduser(name)) + try: + reason = opened(session, path) + except (OSError, ValueError) as exc: + reason = f'the web service cannot be reached: {exc}' + if reason: + print(f'{prog}: {path}: {reason}', file=sys.stderr) + status = 1 + else: + print(f'Opened {path} in the editor of the Shell page.') + return status + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:])) diff --git a/debian-package/src/usr/lib/vpn-router/editor-file b/debian-package/src/usr/lib/vpn-router/editor-file new file mode 100755 index 0000000..dee6584 --- /dev/null +++ b/debian-package/src/usr/lib/vpn-router/editor-file @@ -0,0 +1,82 @@ +#!/usr/bin/python3 +"""Read or write a file of the editor of the Router Manager's Shell page, as the user it runs as. + +The web service runs it with systemd-run --uid= --pipe, outside its sandbox, so the +file is read and written with the rights of the shell's user. The version of a content is the +hexadecimal SHA-256 of its bytes; a missing file has the version of the empty content. + + editor-file read PATH print the content of the file; nothing for a missing file + editor-file write PATH VERSION replace the content with standard input when the file still has + VERSION, writing in place, so its owner, mode and links stay; + a missing file is created + +Only regular files of at most MAX_BYTES bytes of UTF-8 text are read and written. The exit status +is 0 on success, 2 for a file that is refused, with the reason on standard error, 3 when the file +does not have VERSION any more, and 1 for any other failure. +""" + +import hashlib +import os +import sys + +#: The largest file the editor opens, in bytes. +MAX_BYTES = 1024 * 1024 + + +class Refused(Exception): + """A file the editor does not open or write; status is the exit status.""" + + def __init__(self, message, status=2): + super().__init__(message) + self.status = status + + +def text(data, path): + """The bytes when they are UTF-8 text of at most MAX_BYTES; Refused otherwise.""" + if len(data) > MAX_BYTES: + raise Refused(f'{path} is larger than {MAX_BYTES // 1024} KiB') + try: + data.decode('utf-8') + except UnicodeDecodeError: + raise Refused(f'{path} is not UTF-8 text') + return data + + +def current(path): + """The content of the file, b'' when it does not exist; Refused for a file that is not a regular one.""" + if not os.path.lexists(path): + return b'' + if not os.path.isfile(path): + raise Refused(f'{path} is not a regular file') + with open(path, 'rb') as handle: + return text(handle.read(MAX_BYTES + 1), path) + + +def main(argv): + if len(argv) < 2 or (argv[0], len(argv)) not in (('read', 2), ('write', 3)): + print('usage: editor-file read PATH | editor-file write PATH VERSION', file=sys.stderr) + return 2 + path = argv[1] + try: + if argv[0] == 'read': + sys.stdout.buffer.write(current(path)) + return 0 + content = text(sys.stdin.buffer.read(MAX_BYTES + 1), path) + if hashlib.sha256(current(path)).hexdigest() != argv[2]: + raise Refused(f'{path} changed since it was read', 3) + with open(path, 'r+b' if os.path.lexists(path) else 'xb') as handle: + handle.write(content) + handle.truncate() + handle.flush() + os.fsync(handle.fileno()) + except Refused as refused: + print(refused, file=sys.stderr) + return refused.status + except OSError as exc: + print(f'{exc.filename or path}: {exc.strerror}', file=sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:])) diff --git a/debian-package/src/usr/lib/vpn-router/web b/debian-package/src/usr/lib/vpn-router/web index af9c47a..5d5ee9e 100644 --- a/debian-package/src/usr/lib/vpn-router/web +++ b/debian-package/src/usr/lib/vpn-router/web @@ -41,7 +41,9 @@ Pages: a member of admins whose console login is on and who has a TOTP token: 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()) + that carries the terminal (see Handler.shell()); /shell/editor// reads, saves and + closes a file the shell opened with vpn-router-editor (vre) over EDITOR_SOCKET (see + shell_editor() and editor_request()) /auth sign-in with a user's password and TOTP code or with a passkey, refresh and sign-out (see auth()) /metrics/history @@ -217,6 +219,23 @@ 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' @@ -237,6 +256,10 @@ _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 read_json(path): """The object in a JSON file, or {} when the file is missing or holds no object.""" @@ -1539,15 +1562,134 @@ def take_ticket(name, ticket): 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. +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', account.pw_shell or '/bin/sh', '-l'] + '--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// {"path", "content", "version"}, version the SHA-256 of the content + PUT /shell/editor// {"content", "version"} writes the content when the file still has version, + else 409; answers {"path", "version"} + DELETE /shell/editor// 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): @@ -1712,8 +1854,10 @@ 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, when the - client closes the connection, or when a check of the user (SHELL_CHECK_SECONDS) finds its console login off or + 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// (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. """ @@ -1750,9 +1894,10 @@ class Handler(BaseHTTPRequestHandler): 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), stdin=terminal, stdout=terminal, stderr=terminal, + 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) @@ -1775,6 +1920,10 @@ class Handler(BaseHTTPRequestHandler): with lock: self.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: @@ -1827,6 +1976,8 @@ class Handler(BaseHTTPRequestHandler): 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]) @@ -1865,8 +2016,9 @@ class Handler(BaseHTTPRequestHandler): state = current() if protected or signing else {} claims = self.claims(state['key']) if protected and state['key'] else None # The body of a file upload is read as a stream, after the request is allowed. - payload = self.request_body() if (protected or signing) and self.command not in ('GET', 'HEAD') and \ - not (files and self.command == 'PUT') else None + payload = self.request_body(EDITOR_BODY_LIMIT if path.startswith('/shell/editor/') else BODY_LIMIT) \ + if (protected or signing) and self.command not in ('GET', 'HEAD') and not (files and self.command == 'PUT') \ + else None if claims is not None: self.principal = 'admin' if claims.get('admin') is True else claims.get('sub') if (protected or signing) and not state['key']: @@ -1903,6 +2055,9 @@ class Handler(BaseHTTPRequestHandler): body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json' elif directory == 'shell' and (path, self.command) == ('/shell', 'GET'): return self.shell(claims.get('sub')) + elif directory == 'shell' and path.startswith('/shell/editor/'): + code, document = shell_editor(claims.get('sub'), self.command, path.strip('/').split('/'), payload) + body, kind = json.dumps(document).encode() + b'\n', 'application/json' 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}'}) @@ -1989,14 +2144,15 @@ class Handler(BaseHTTPRequestHandler): return b'' return self.rfile.read(size) if 0 < size <= BODY_LIMIT else b'' - def request_body(self): - """The request body as JSON, None when there is none, or the string 'invalid'.""" + def request_body(self, limit=BODY_LIMIT): + """The request body as JSON, None when there is none, or the string 'invalid', also when it is larger than + limit bytes.""" length = self.headers.get('Content-Length') if not length: return None try: size = int(length) - if not 0 <= size <= BODY_LIMIT: + if not 0 <= size <= limit: return 'invalid' return json.loads(self.rfile.read(size) or b'null') except ValueError: @@ -2062,6 +2218,7 @@ def initialize(): def main(): signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) initialize() + threading.Thread(target=editor_listen, name='editor socket', daemon=True).start() try: vpnrouter_serve.serve(lambda: vpnrouter_net.web_listen(read_json(CONFIG_FILE), DEFAULT_PORT), Handler, log, DISCOVERY_SECONDS)