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
Release / release (push) Successful in 13s
This commit is contained in:
1 parent
a1f314d8c1
commit
91cc64d6a6
6 files changed
+48
-11
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user