From 91cc64d6a67cb9b89a0c2cd98e89dd36892d0a61 Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Mon, 26 Oct 2026 07:56:23 +0100 Subject: [PATCH] fix: a shell opens without the password and code again while the user's last use of a shell is within web.shell_auth_timeout minutes. --- ADR.md | 5 +++- README.md | 7 +++-- debian-package/debian/changelog | 11 ++++++++ .../src/usr/lib/vpn-router/vpnrouter.py | 7 +++-- debian-package/src/usr/lib/vpn-router/web | 28 +++++++++++++++---- .../doc/vpn-router/vpn-router.json.example | 3 +- 6 files changed, 49 insertions(+), 12 deletions(-) diff --git a/ADR.md b/ADR.md index 28097e1..d7efb9b 100644 --- a/ADR.md +++ b/ADR.md @@ -909,7 +909,10 @@ Manager cannot set it. The shell runs as the console account of the signed-in us it is never root and each administrator's shell is the administrator's own; a user without console login gets none. `POST /shell` signs the user in again with the password and TOTP code and gives a one-time ticket valid 30 seconds; a WebSocket on `GET /shell` with that -ticket gets the shell. The WebSocket of RFC 6455 is implemented on the standard library inside the +ticket gets the shell. Asking for the password and code at every opening of the page is tiresome, +so within `web.shell_auth_timeout` minutes of the user's last sign-in or keystroke a ticket is given +without them; the times are kept in the web service's memory only, so a restart asks again, and 0 +keeps the sign-in at every opening. The WebSocket of RFC 6455 is implemented on the standard library inside the web service's `http.server`, server side only, without extensions. `systemd-run --pty` starts the shell in a transient unit with a PAM login session, outside the sandbox, and `setsid --ctty` makes the terminal its controlling terminal so window sizes reach the shell. A shell idle for diff --git a/README.md b/README.md index 5ff909f..524f926 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,7 @@ must have the type shown below; an unknown section or setting is an error. | `web` | `status_content` | string | `basic` | What `/` and `/status` show: `basic`, `standard` or `full` (see Status web service) | | `web` | `shell` | boolean | `false` | `/shell` opens a login shell for a member of `admins` with console login (see The shell) | | `web` | `shell_idle_minutes` | integer | `15` | Minutes without a keystroke after which a shell is closed, 1 to 1440 | +| `web` | `shell_auth_timeout` | integer | `15` | Minutes after the last use of a shell in which a new shell opens without signing in again, 0 (always sign in) to 1440 | | `ssh` | `ldap_login` | boolean | `false` | sshd accepts the directory password of the console accounts in the directory (see Console login and SSH) | | `https` | `enabled` | boolean | `true` | Caddy serves the status web service and the Router Manager over HTTPS for `wan.local_fqdn` | | `https` | `email` | string | | The e-mail address of the Let's Encrypt account | @@ -980,12 +981,14 @@ vpn-config-edit set web.shell true | Request | Body | Answer | | --- | --- | --- | -| `POST /shell` | `{"password", "code"}`; `code` only for a user enrolled for TOTP | `{"ticket", "expires_in": 30}` | +| `POST /shell` | `{"password", "code"}`; `code` only for a user enrolled for TOTP; `{}` within `web.shell_auth_timeout` | `{"ticket", "expires_in": 30}` | | `GET /shell?ticket=` | a WebSocket handshake | the terminal | `POST /shell` needs an access token of `/auth`, not one of `vpn-router-token`, and signs its user in again: a wrong password or code gets 403 and counts as a failed login; it gets 403 too while -`web.shell` is `false` or the user's console login is off. The ticket opens one +`web.shell` is `false` or the user's console login is off. Without a password it gives a ticket +while the user's last sign-in of `POST /shell` or keystroke in a shell is less than +`web.shell_auth_timeout` minutes ago, and `{"ticket": null}` otherwise. The ticket opens one shell for the same user within 30 seconds. Over the WebSocket, binary messages of the client are keystrokes, a text message `{"cols", "rows"}` sets the window size, and binary messages of the router are the terminal's output. The shell is a PAM login session of the console account, diff --git a/debian-package/debian/changelog b/debian-package/debian/changelog index 810940b..dc99656 100644 --- a/debian-package/debian/changelog +++ b/debian-package/debian/changelog @@ -1,3 +1,14 @@ +vpn-router (0.25.4-1) unstable; urgency=medium + + fix: a shell opens without the password and code again while the user's last use of a shell is within web.shell_auth_timeout minutes. + + * New setting web.shell_auth_timeout, 15 minutes by default, 0 to 1440: + POST /shell without a password gives a ticket while the user's last + sign-in of POST /shell or keystroke in a shell is within it, and a + null ticket otherwise; 0 asks for the password and code every time. + + -- Slawomir Koszewski Tue, 03 Nov 2026 06:00:00 +0100 + vpn-router (0.25.3-1) unstable; urgency=medium fix: /config gives the state of caddy.service with the installed Caddy, for the Router Manager's Web page. diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter.py b/debian-package/src/usr/lib/vpn-router/vpnrouter.py index 6e09fd2..a29a524 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter.py @@ -58,7 +58,7 @@ SCHEMA = { 'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'}, 'docker': {'mode': 'auto', 'exposed_ports': []}, 'web': {'enabled': True, 'listen': DEFAULT_LISTEN, 'port': 7081, 'database': '', 'trusted_proxies': [], - 'status_content': 'basic', 'shell': False, 'shell_idle_minutes': 15}, + 'status_content': 'basic', 'shell': False, 'shell_idle_minutes': 15, 'shell_auth_timeout': 15}, 'ssh': {'ldap_login': False}, 'https': {'enabled': True, 'email': '', 'web_port': 7444, 'manager_port': 7443, 'caddy_source': 'ubuntu'}, 'pki': {'root_name': 'VPN Router Root CA', 'root_days': 7300, 'ca_days': 3650, 'key': 'rsa-4096', @@ -201,7 +201,7 @@ CA_NAME_MAX = 64 #: What / and /status of the web service show, web.status_content, from the least to the most. STATUS_CONTENTS = ('basic', 'standard', 'full') -#: The longest web.shell_idle_minutes, a day. +#: The longest web.shell_idle_minutes and web.shell_auth_timeout, a day. SHELL_IDLE_MAX = 1440 DOCKER_MODES = ('auto', 'on', 'off') @@ -499,6 +499,9 @@ def validate(cfg): shell_idle = _get(cfg, 'web', 'shell_idle_minutes') if not 1 <= shell_idle <= SHELL_IDLE_MAX: raise ConfigError(f'web.shell_idle_minutes: {shell_idle} is not between 1 and {SHELL_IDLE_MAX}') + shell_auth = _get(cfg, 'web', 'shell_auth_timeout') + if not 0 <= shell_auth <= SHELL_IDLE_MAX: + raise ConfigError(f'web.shell_auth_timeout: {shell_auth} is not between 0 and {SHELL_IDLE_MAX}') https_ports = [] for option in ('web_port', 'manager_port'): diff --git a/debian-package/src/usr/lib/vpn-router/web b/debian-package/src/usr/lib/vpn-router/web index 4ad2251..dcf73d6 100644 --- a/debian-package/src/usr/lib/vpn-router/web +++ b/debian-package/src/usr/lib/vpn-router/web @@ -38,8 +38,9 @@ Pages: the DER certificate and CRL of the root (root) and of each issuing CA, which the certificates name in their AIA and CRL distribution points; no authentication /shell while web.shell is true, a login shell of the user's console account on a terminal, for - a member of admins whose console login is on: POST signs the - user in again for a one-time ticket (see shell_ticket()), GET with it is a WebSocket + a member of admins whose console login is on: 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()) /auth sign-in with a user's password and TOTP code, refresh and sign-out (see auth()) /metrics/history @@ -189,6 +190,9 @@ _auth_lock = threading.Lock() _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 = {} + def read_json(path): """The object in a JSON file, or {} when the file is missing or holds no object.""" @@ -1105,19 +1109,28 @@ def shell_ticket(name, body): """Answer POST /shell: the HTTP status and the JSON document. The user named by the token signs in again with {"password", "code"?}; a wrong password or code is 403 and counts - as a failed login. The answer is {"ticket", "expires_in"}: a ticket that opens one shell (GET /shell) for the - same user within SHELL_TICKET_SECONDS. 403 while web.shell is false or the user's console login is not on. + 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 or the user's console login is not on. """ if not isinstance(name, str): return 403, {'error': 'the shell needs a token of a signed-in user'} if not isinstance(body, dict): return 400, {'error': 'the body must be a JSON object'} try: - if not vpnrouter.load_config()['web']['shell']: + 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'} if console_account(name) is None: return 403, {'error': f'console login is not enabled for {name}; vpn-router-user console sets it'} - vpnrouter_users.login(name, body.get('password'), body.get('code')) + 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'} @@ -1132,6 +1145,7 @@ def shell_ticket(name, body): 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} @@ -1336,6 +1350,8 @@ class Handler(BaseHTTPRequestHandler): 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)) diff --git a/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example b/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example index c976261..1a65772 100644 --- a/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example +++ b/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example @@ -63,7 +63,8 @@ "trusted_proxies": [], "status_content": "basic", "shell": false, - "shell_idle_minutes": 15 + "shell_idle_minutes": 15, + "shell_auth_timeout": 15 }, "ssh": { "ldap_login": false