feature: vre opens files from the Router Manager's shell in the editor of its Shell page.
Release / release (push) Successful in 17s
Release / release (push) Successful in 17s
This commit is contained in:
1 parent
5303779559
commit
3b14b7723e
8 files changed
+391
-12
No files matched your search
@@ -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.
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
usr/bin/vpn-router-editor usr/bin/vre
|
||||||
Executable
+71
@@ -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
@@ -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:]))
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user