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.
Release / release (push) Successful in 13s

This commit is contained in:
slawek committed 2026-10-26 07:56:23 +01:00
1 parent a1f314d8c1
commit 91cc64d6a6
6 files changed
+48 -11

No files matched your search

+4 -1
View File
@@ -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 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 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 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 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` 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 makes the terminal its controlling terminal so window sizes reach the shell. A shell idle for
+5 -2
View File
@@ -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` | `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` | 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_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) | | `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` | `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 | | `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 | | 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=<ticket>` | a WebSocket handshake | the terminal | | `GET /shell?ticket=<ticket>` | a WebSocket handshake | the terminal |
`POST /shell` needs an access token of `/auth`, not one of `vpn-router-token`, and signs its user `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 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 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 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, the router are the terminal's output. The shell is a PAM login session of the console account,
+11
View File
@@ -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 <slawek@koszewscy.waw.pl> Tue, 03 Nov 2026 06:00:00 +0100
vpn-router (0.25.3-1) unstable; urgency=medium 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. fix: /config gives the state of caddy.service with the installed Caddy, for the Router Manager's Web page.
@@ -58,7 +58,7 @@ SCHEMA = {
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'}, 'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
'docker': {'mode': 'auto', 'exposed_ports': []}, 'docker': {'mode': 'auto', 'exposed_ports': []},
'web': {'enabled': True, 'listen': DEFAULT_LISTEN, 'port': 7081, 'database': '', 'trusted_proxies': [], '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}, 'ssh': {'ldap_login': False},
'https': {'enabled': True, 'email': '', 'web_port': 7444, 'manager_port': 7443, 'caddy_source': 'ubuntu'}, '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', '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. #: What / and /status of the web service show, web.status_content, from the least to the most.
STATUS_CONTENTS = ('basic', 'standard', 'full') 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 SHELL_IDLE_MAX = 1440
DOCKER_MODES = ('auto', 'on', 'off') DOCKER_MODES = ('auto', 'on', 'off')
@@ -499,6 +499,9 @@ def validate(cfg):
shell_idle = _get(cfg, 'web', 'shell_idle_minutes') shell_idle = _get(cfg, 'web', 'shell_idle_minutes')
if not 1 <= shell_idle <= SHELL_IDLE_MAX: 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}') 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 = [] https_ports = []
for option in ('web_port', 'manager_port'): for option in ('web_port', 'manager_port'):
+21 -5
View File
@@ -38,8 +38,9 @@ Pages:
the DER certificate and CRL of the root (root) and of each issuing CA, which the 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 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 /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 a member of admins whose console login is on: POST signs the user in again, unless
user in again for a one-time ticket (see shell_ticket()), GET with it is a WebSocket 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())
/auth sign-in with a user's password and TOTP code, refresh and sign-out (see auth()) /auth sign-in with a user's password and TOTP code, refresh and sign-out (see auth())
/metrics/history /metrics/history
@@ -189,6 +190,9 @@ _auth_lock = threading.Lock()
_shell_tickets = {} _shell_tickets = {}
_shell_lock = threading.Lock() _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): 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."""
@@ -1105,18 +1109,27 @@ def shell_ticket(name, body):
"""Answer POST /shell: the HTTP status and the JSON document. """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 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 as a failed login. Without a password, the user is not asked to sign in while its last use of a shell (see
same user within SHELL_TICKET_SECONDS. 403 while web.shell is false or the user's console login is not on. _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): if not isinstance(name, str):
return 403, {'error': 'the shell needs a token of a signed-in user'} return 403, {'error': 'the shell needs a token of a signed-in user'}
if not isinstance(body, dict): if not isinstance(body, dict):
return 400, {'error': 'the body must be a JSON object'} return 400, {'error': 'the body must be a JSON object'}
try: 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'} return 403, {'error': 'the shell is disabled; vpn-config-edit set web.shell true enables it'}
if console_account(name) is None: if console_account(name) is None:
return 403, {'error': f'console login is not enabled for {name}; vpn-router-user console sets it'} return 403, {'error': f'console login is not enabled for {name}; vpn-router-user console sets it'}
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')) vpnrouter_users.login(name, body.get('password'), body.get('code'))
except vpnrouter_users.Failure as failure: except vpnrouter_users.Failure as failure:
if failure.status == 5: if failure.status == 5:
@@ -1132,6 +1145,7 @@ def shell_ticket(name, body):
for unused in [key for key, (_, expires) in _shell_tickets.items() if expires <= now]: for unused in [key for key, (_, expires) in _shell_tickets.items() if expires <= now]:
del _shell_tickets[unused] del _shell_tickets[unused]
_shell_tickets[ticket] = (name, now + SHELL_TICKET_SECONDS) _shell_tickets[ticket] = (name, now + SHELL_TICKET_SECONDS)
_shell_used[name] = now
return 200, {'ticket': ticket, 'expires_in': SHELL_TICKET_SECONDS} return 200, {'ticket': ticket, 'expires_in': SHELL_TICKET_SECONDS}
@@ -1336,6 +1350,8 @@ class Handler(BaseHTTPRequestHandler):
if opcode == BINARY: if opcode == BINARY:
os.write(master, payload) os.write(master, payload)
typed[0] = time.monotonic() typed[0] = time.monotonic()
with _shell_lock:
_shell_used[name] = typed[0]
elif opcode == TEXT: elif opcode == TEXT:
size = json.loads(payload) size = json.loads(payload)
fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack('HHHH', int(size['rows']), int(size['cols']), 0, 0)) fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack('HHHH', int(size['rows']), int(size['cols']), 0, 0))
@@ -63,7 +63,8 @@
"trusted_proxies": [], "trusted_proxies": [],
"status_content": "basic", "status_content": "basic",
"shell": false, "shell": false,
"shell_idle_minutes": 15 "shell_idle_minutes": 15,
"shell_auth_timeout": 15
}, },
"ssh": { "ssh": {
"ldap_login": false "ldap_login": false