feature: vre opens files from the Router Manager's shell in the editor of its Shell page.
Release / release (push) Successful in 17s

This commit is contained in:
slawek committed 2026-10-29 18:10:58 +01:00
1 parent 5303779559
commit 3b14b7723e
8 files changed
+391 -12

No files matched your search

+23
View File
@@ -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 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 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. 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/<session>/<id>`. Files are read and
written by `editor-file`, run with `systemd-run --uid=<account> --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.
+28
View File
@@ -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, 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. 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/<session>/<id>` | | `{"path", "content", "version"}`, `version` the SHA-256 of the content |
| `PUT /shell/editor/<session>/<id>` | `{"content", "version"}` | `{"path", "version"}`; 409 when the file no longer has `version` |
| `DELETE /shell/editor/<session>/<id>` | | `{"path", "closed": true}` |
### The /config page ### The /config page
`/config` shows what `/` and `/status` leave out, for a client such as manage-ui: `/config` shows what `/` and `/status` leave out, for a client such as manage-ui:
+15
View File
@@ -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/<session>/<id> 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 <slawek@koszewscy.waw.pl> Thu, 29 Oct 2026 18:02:27 +0100
vpn-router (0.34.0-1) unstable; urgency=medium 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. 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.
+2
View File
@@ -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/webdav-auth usr/lib/vpn-router/
src/usr/lib/vpn-router/openvpn-same-user 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/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-user usr/sbin/
src/usr/sbin/vpn-router-group usr/sbin/ src/usr/sbin/vpn-router-group usr/sbin/
src/usr/sbin/vpn-router-token usr/sbin/ src/usr/sbin/vpn-router-token usr/sbin/
+1
View File
@@ -0,0 +1 @@
usr/bin/vpn-router-editor usr/bin/vre
+71
View File
@@ -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:]))
+82
View 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=<console account> --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:]))
+169 -12
View File
@@ -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 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 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 (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/<session>/<id> 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 /auth sign-in with a user's password and TOTP code or with a passkey, refresh and sign-out
(see auth()) (see auth())
/metrics/history /metrics/history
@@ -217,6 +219,23 @@ SHELL_CHECK_SECONDS = 60
#: The largest WebSocket message accepted from a client, in bytes. #: The largest WebSocket message accepted from a client, in bytes.
MESSAGE_LIMIT = 65536 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. #: The GUID of RFC 6455 that the Sec-WebSocket-Accept of a handshake is made with.
WEBSOCKET_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11' 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. #: The last use of a shell of each user, a sign-in of POST /shell or a keystroke: user -> monotonic time.
_shell_used = {} _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): def read_json(path):
"""The object in a JSON file, or {} when the file is missing or holds no object.""" """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() return holder == name and expires > time.monotonic()
def shell_command(account, unit): 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. """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 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. 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}', 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}', 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/<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): 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 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, when the terminal's output; a text message of the server {"editor": "open", "session", "id", "path"} opens a file the
client closes the connection, or when a check of the user (SHELL_CHECK_SECONDS) finds its console login off or 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 its TOTP token gone; the server then closes it with the reason. Each opening, end and refusal is logged
with its reason. with its reason.
""" """
@@ -1750,9 +1894,10 @@ class Handler(BaseHTTPRequestHandler):
except KeyError: except KeyError:
return refuse(500, f'the system does not know the account {login}; is nslcd running?') return refuse(500, f'the system does not know the account {login}; is nslcd running?')
unit = f'vpn-router-shell-{secrets.token_hex(4)}' unit = f'vpn-router-shell-{secrets.token_hex(4)}'
session = secrets.token_urlsafe(24)
master, terminal = os.openpty() master, terminal = os.openpty()
try: 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'}) env={'PATH': '/usr/sbin:/usr/bin:/sbin:/bin'})
except OSError as exc: except OSError as exc:
os.close(master) os.close(master)
@@ -1775,6 +1920,10 @@ class Handler(BaseHTTPRequestHandler):
with lock: with lock:
self.wfile.write(frame(opcode, payload)) 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(): def receive():
"""Pass the client's keystrokes and window sizes to the terminal until it closes the connection.""" """Pass the client's keystrokes and window sizes to the terminal until it closes the connection."""
try: try:
@@ -1827,6 +1976,8 @@ class Handler(BaseHTTPRequestHandler):
reason = 'the client closed the connection' reason = 'the client closed the connection'
except OSError: except OSError:
reason = 'the connection failed' reason = 'the connection failed'
with _shell_lock:
_shell_sessions.pop(session, None)
run('systemctl', 'stop', unit) run('systemctl', 'stop', unit)
try: try:
send(CLOSE, (1000).to_bytes(2, 'big') + reason.encode()[:120]) send(CLOSE, (1000).to_bytes(2, 'big') + reason.encode()[:120])
@@ -1865,8 +2016,9 @@ class Handler(BaseHTTPRequestHandler):
state = current() if protected or signing else {} state = current() if protected or signing else {}
claims = self.claims(state['key']) if protected and state['key'] else None 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. # 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 \ payload = self.request_body(EDITOR_BODY_LIMIT if path.startswith('/shell/editor/') else BODY_LIMIT) \
not (files and self.command == 'PUT') else None 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: if claims is not None:
self.principal = 'admin' if claims.get('admin') is True else claims.get('sub') self.principal = 'admin' if claims.get('admin') is True else claims.get('sub')
if (protected or signing) and not state['key']: 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' body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif directory == 'shell' and (path, self.command) == ('/shell', 'GET'): elif directory == 'shell' and (path, self.command) == ('/shell', 'GET'):
return self.shell(claims.get('sub')) 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': 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}'})
@@ -1989,14 +2144,15 @@ class Handler(BaseHTTPRequestHandler):
return b'' return b''
return self.rfile.read(size) if 0 < size <= BODY_LIMIT else b'' return self.rfile.read(size) if 0 < size <= BODY_LIMIT else b''
def request_body(self): def request_body(self, limit=BODY_LIMIT):
"""The request body as JSON, None when there is none, or the string 'invalid'.""" """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') length = self.headers.get('Content-Length')
if not length: if not length:
return None return None
try: try:
size = int(length) size = int(length)
if not 0 <= size <= BODY_LIMIT: if not 0 <= size <= limit:
return 'invalid' return 'invalid'
return json.loads(self.rfile.read(size) or b'null') return json.loads(self.rfile.read(size) or b'null')
except ValueError: except ValueError:
@@ -2062,6 +2218,7 @@ def initialize():
def main(): def main():
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
initialize() initialize()
threading.Thread(target=editor_listen, name='editor socket', daemon=True).start()
try: try:
vpnrouter_serve.serve(lambda: vpnrouter_net.web_listen(read_json(CONFIG_FILE), DEFAULT_PORT), Handler, log, vpnrouter_serve.serve(lambda: vpnrouter_net.web_listen(read_json(CONFIG_FILE), DEFAULT_PORT), Handler, log,
DISCOVERY_SECONDS) DISCOVERY_SECONDS)