Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ed79c7563 | ||
|
|
3e78d520ae | ||
|
|
d99e4ea425 |
No files matched your search
@@ -908,8 +908,14 @@ Decision: `/shell` of the web service is off until `web.shell` is set on the rou
|
|||||||
Manager cannot set it. The shell runs as the console account of the signed-in user (ADR-34), so
|
Manager cannot set it. The shell runs as the console account of the signed-in user (ADR-34), so
|
||||||
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, or with one of the user's own passkeys (ADR-36), and gives a one-time ticket valid 30
|
||||||
ticket gets the shell. Asking for the password and code at every opening of the page is tiresome,
|
seconds; a WebSocket on `GET /shell` with that ticket gets the shell. A passkey with user
|
||||||
|
verification is a possession and a PIN or biometric in one step and cannot be phished, so it is
|
||||||
|
as good as the password and the code; `POST /shell/passkey` begins a proof bound to the signed-in
|
||||||
|
user and its credentials, and python3-fido2 refuses an answer without the user-verified flag. The
|
||||||
|
TOTP enrolment stays required with a passkey: the PAM login session of the shell, through the
|
||||||
|
`pam_authz_search` of nslcd for a directory account, and sudo in it need it, and loosening that
|
||||||
|
filter would let such a user log in over SSH with the password alone. 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
|
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
|
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
|
keeps the sign-in at every opening. The WebSocket of RFC 6455 is implemented on the standard library inside the
|
||||||
@@ -920,9 +926,10 @@ makes the terminal its controlling terminal so window sizes reach the shell. A s
|
|||||||
Manager checks the session and the WebSocket's origin, and relays the bytes to the router after
|
Manager checks the session and the WebSocket's origin, and relays the bytes to the router after
|
||||||
both handshakes.
|
both handshakes.
|
||||||
|
|
||||||
Consequences: Who can sign in as a member of `admins` and knows the password and the current code
|
Consequences: Who can sign in as a member of `admins` and knows the password and the current code,
|
||||||
can get a shell when the router allows one and the user's console login is on; a session alone
|
or holds one of the user's passkeys and unlocks it, can get a shell when the router allows one and
|
||||||
cannot. The ticket travels in the URL and appears in request logs, used up. The shell's sessions
|
the user's console login is on; a session alone cannot. A failed passkey proof is refused but not
|
||||||
|
counted as a failed login, as with the passkey sign-in. The ticket travels in the URL and appears in request logs, used up. The shell's sessions
|
||||||
show up as `vpn-router-shell-<id>` units and logind sessions of the console account. The shell's
|
show up as `vpn-router-shell-<id>` units and logind sessions of the console account. The shell's
|
||||||
traffic is as protected as the Router Manager's: HTTPS to Caddy, plain HTTP on the internal
|
traffic is as protected as the Router Manager's: HTTPS to Caddy, plain HTTP on the internal
|
||||||
network to the web service.
|
network to the web service.
|
||||||
|
|||||||
@@ -1080,8 +1080,9 @@ every service and tunnel while the public status page shows less.
|
|||||||
### The shell
|
### The shell
|
||||||
|
|
||||||
While `web.shell` is `true`, `/shell` gives a member of `admins` whose console login is on and
|
While `web.shell` is `true`, `/shell` gives a member of `admins` whose console login is on and
|
||||||
who is enrolled for TOTP (see Console login and SSH) a login shell on the router, as the user's console account, for the
|
who is enrolled for TOTP (see Console login and SSH) a login shell on the router, as the user's
|
||||||
Router Manager's Shell page. It is off by default and switched on and off on the router only:
|
console account, for the Router Manager's Shell page. It is off by default and switched on and
|
||||||
|
off on the router only:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
vpn-router-user console alice --posix
|
vpn-router-user console alice --posix
|
||||||
@@ -1090,20 +1091,29 @@ vpn-config-edit set web.shell true
|
|||||||
|
|
||||||
| Request | Body | Answer |
|
| Request | Body | Answer |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `POST /shell` | `{"password", "code"}`; `{}` within `web.shell_auth_timeout` | `{"ticket", "expires_in": 30}` |
|
| `POST /shell/passkey` | `{}` | `{"id", "options"}`, `options` those of `navigator.credentials.get` for the user's own passkeys |
|
||||||
|
| `POST /shell` | `{"password", "code"}`, or `{"passkey": {"id", "credential"}}`; `{}` 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, with the password and the TOTP code, or with a passkey: `POST /shell/passkey` begins a
|
||||||
`web.shell` is `false`, the user's console login is off or the user is not enrolled for TOTP. Without a password it gives a ticket
|
proof for one of the user's own passkeys with user verification, which the browser answers within
|
||||||
while the user's last sign-in of `POST /shell` or keystroke in a shell is less than
|
two minutes, and `POST /shell` takes its `id` and the browser's answer as `credential`. A passkey
|
||||||
`web.shell_auth_timeout` minutes ago, and `{"ticket": null}` otherwise. The ticket opens one
|
with user verification is two factors in one, so it takes the place of the password and the code.
|
||||||
|
A wrong password or code gets 403 and counts as a failed login, and a passkey that is not accepted
|
||||||
|
gets 403. `POST /shell` gets 403 too while `web.shell` is `false`, the user's console login is off
|
||||||
|
or the user is not enrolled for TOTP: also with a passkey, the PAM login session of the shell,
|
||||||
|
`pam_authz_search` of nslcd for a directory account, and sudo need the enrolment. Without a
|
||||||
|
password or a passkey 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,
|
||||||
run by `systemd-run` in a transient unit `vpn-router-shell-<id>`; sudo works as for SSH. It is closed after `web.shell_idle_minutes` without a keystroke, when the shell
|
run by `systemd-run` in a transient unit `vpn-router-shell-<id>`; sudo works as for SSH, with the
|
||||||
exits, when the connection closes, or within a minute of the user's console login being switched
|
password and the TOTP code. It is closed after `web.shell_idle_minutes` without a keystroke, when
|
||||||
off or the TOTP enrolment removed; the router then sends a close frame with the reason. The
|
the shell exits, when the connection closes, or within a minute of the user's console login being
|
||||||
|
switched off or the TOTP enrolment removed; the router then sends a close frame with the reason. 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.
|
||||||
|
|
||||||
@@ -1919,4 +1929,6 @@ cd debian-package
|
|||||||
```
|
```
|
||||||
|
|
||||||
The build runs in a container and writes the package to `debian-package/out/`.
|
The build runs in a container and writes the package to `debian-package/out/`.
|
||||||
`./publish.sh` uploads it to the configured repository.
|
`./publish.sh` uploads it to the component `main` of the distributions `noble` and `resolute` of
|
||||||
|
the configured repository, skipping a distribution that has the version already;
|
||||||
|
`DISTRIBUTIONS` names others.
|
||||||
@@ -1,3 +1,37 @@
|
|||||||
|
vpn-router (0.36.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
feature: a shell opens with one of the user's passkeys in place of the password and the TOTP code.
|
||||||
|
|
||||||
|
* POST /shell/passkey begins a proof for one of the signed-in user's own
|
||||||
|
passkeys, with user verification; POST /shell takes the browser's
|
||||||
|
answer as {"passkey": {"id", "credential"}} in place of the password
|
||||||
|
and the code, and gives the ticket of the shell.
|
||||||
|
* A passkey that is not accepted is refused with 403. The shell still
|
||||||
|
needs the user's TOTP enrolment, which its login session and sudo in
|
||||||
|
it need.
|
||||||
|
|
||||||
|
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Thu, 29 Oct 2026 23:09:43 +0100
|
||||||
|
|
||||||
|
vpn-router (0.35.2-2) unstable; urgency=medium
|
||||||
|
|
||||||
|
update: the package is published for Ubuntu 26.04 (resolute) as well as 24.04 (noble).
|
||||||
|
|
||||||
|
* publish.sh uploads the package to the distributions noble and resolute
|
||||||
|
of the repository, component main, skipping a distribution that has
|
||||||
|
the version already; DISTRIBUTIONS names others. A router on Ubuntu
|
||||||
|
26.04 installs vpn-router from the distribution resolute.
|
||||||
|
|
||||||
|
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Thu, 29 Oct 2026 21:41:32 +0100
|
||||||
|
|
||||||
|
vpn-router (0.35.2-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
fix: the web service answers /, /status and /status/full again while IKEv2 runs.
|
||||||
|
|
||||||
|
* The status of IKEv2 clients is collected again; the requests that
|
||||||
|
collect the status were dropped with a NameError since 0.35.1.
|
||||||
|
|
||||||
|
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Thu, 29 Oct 2026 19:03:16 +0100
|
||||||
|
|
||||||
vpn-router (0.35.1-1) unstable; urgency=medium
|
vpn-router (0.35.1-1) unstable; urgency=medium
|
||||||
|
|
||||||
update: the router web service is split into the package vpnrouter_web, with one module per family of paths.
|
update: the router web service is split into the package vpnrouter_web, with one module per family of paths.
|
||||||
|
|||||||
+15
-10
@@ -11,6 +11,8 @@ esac
|
|||||||
OWNER="${OWNER:-slawek}"
|
OWNER="${OWNER:-slawek}"
|
||||||
SERVER_URL="${SERVER_URL:-https://gitea.koszewscy.waw.pl}"
|
SERVER_URL="${SERVER_URL:-https://gitea.koszewscy.waw.pl}"
|
||||||
PACKAGES_TOKEN="${PACKAGES_TOKEN:-$(cat ~/.gitea-packages-token)}"
|
PACKAGES_TOKEN="${PACKAGES_TOKEN:-$(cat ~/.gitea-packages-token)}"
|
||||||
|
# The Ubuntu releases whose distribution of the repository gets the package, in its component main.
|
||||||
|
DISTRIBUTIONS="${DISTRIBUTIONS:-noble resolute}"
|
||||||
|
|
||||||
# Reads the changelog copied into the builder image, so no bind mount is needed.
|
# Reads the changelog copied into the builder image, so no bind mount is needed.
|
||||||
changelog_field() {
|
changelog_field() {
|
||||||
@@ -27,14 +29,17 @@ if [ ! -f "$DEB" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
STATUS="$(curl -s -o /dev/null -w '%{http_code}' --user "$OWNER:$PACKAGES_TOKEN" \
|
BODY="$(mktemp)"
|
||||||
"$SERVER_URL/api/v1/packages/$OWNER/debian/$PACKAGE/$VERSION")"
|
trap 'rm -f "$BODY"' EXIT
|
||||||
case "$STATUS" in
|
|
||||||
200) printf '%s %s is already published, skipping.\n' "$PACKAGE" "$VERSION"; exit 0 ;;
|
|
||||||
404) ;;
|
|
||||||
*) printf 'ERROR: version check returned HTTP %s.\n' "$STATUS" >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
curl --fail-with-body --user "$OWNER:$PACKAGES_TOKEN" \
|
# Gitea answers 409 for a version that is in the distribution already.
|
||||||
--upload-file "$DEB" \
|
for DISTRIBUTION in $DISTRIBUTIONS; do
|
||||||
"$SERVER_URL/api/packages/$OWNER/debian/pool/noble/main/upload"
|
STATUS="$(curl -s -o "$BODY" -w '%{http_code}' --user "$OWNER:$PACKAGES_TOKEN" \
|
||||||
|
--upload-file "$DEB" \
|
||||||
|
"$SERVER_URL/api/packages/$OWNER/debian/pool/$DISTRIBUTION/main/upload")"
|
||||||
|
case "$STATUS" in
|
||||||
|
2??) printf '%s %s is published in %s/main.\n' "$PACKAGE" "$VERSION" "$DISTRIBUTION" ;;
|
||||||
|
409) printf '%s %s is already in %s/main, skipping.\n' "$PACKAGE" "$VERSION" "$DISTRIBUTION" ;;
|
||||||
|
*) printf 'ERROR: the upload to %s/main returned HTTP %s: %s\n' "$DISTRIBUTION" "$STATUS" "$(cat "$BODY")" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""The passkeys of the router's users, for signing in to the Router Manager and the OpenID Provider (vpnrouter_oidc),
|
"""The passkeys of the router's users, for signing in to the Router Manager and the OpenID Provider (vpnrouter_oidc),
|
||||||
with python3-fido2.
|
and for opening a shell, with python3-fido2.
|
||||||
|
|
||||||
A passkey is a discoverable WebAuthn credential with user verification, of the relying party
|
A passkey is a discoverable WebAuthn credential with user verification, of the relying party
|
||||||
wan.local_fqdn, so the browser offers it at the HTTPS addresses on that name.
|
wan.local_fqdn, so the browser offers it at the HTTPS addresses on that name.
|
||||||
@@ -8,10 +8,11 @@ user entry: a JSON object {"id", "credential", "label", "created"}, id the crede
|
|||||||
credential the attested credential data (with the public key), both base64url. A user has at
|
credential the attested credential data (with the public key), both base64url. A user has at
|
||||||
most PASSKEYS_MAX passkeys; service accounts have none.
|
most PASSKEYS_MAX passkeys; service accounts have none.
|
||||||
|
|
||||||
Registration and sign-in are two steps each: begin answers the options for the browser and a
|
Registration, sign-in and the proof of a signed-in user for a shell are two steps each: begin
|
||||||
pending id, whose state the web service keeps in memory for PENDING_SECONDS; complete takes the
|
answers the options for the browser and a pending id, whose state the web service keeps in memory
|
||||||
id and the browser's answer. A request that cannot be done raises vpnrouter_users.Failure, with
|
for PENDING_SECONDS; complete takes the id and the browser's answer. A request that cannot be done
|
||||||
the statuses of that module; a sign-in that fails gives status 5 whatever the reason.
|
raises vpnrouter_users.Failure, with the statuses of that module; a sign-in or a proof that fails
|
||||||
|
gives status 5 whatever the reason.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -172,3 +173,30 @@ def login_complete(connection, pending, response):
|
|||||||
except (ValueError, KeyError, TypeError):
|
except (ValueError, KeyError, TypeError):
|
||||||
raise refused
|
raise refused
|
||||||
return name
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def shell_begin(connection, name):
|
||||||
|
"""Begin the proof of the signed-in user for a shell: {"id", "options"}, options those of navigator.credentials.get
|
||||||
|
for the user's own passkeys, with user verification."""
|
||||||
|
own = credentials(vpnrouter_users.entry(connection, name))
|
||||||
|
if not own:
|
||||||
|
raise vpnrouter_users.Failure(f'{name} has no passkey; add one on the Account page', 2)
|
||||||
|
options, state = server().authenticate_begin(own, user_verification=UserVerificationRequirement.REQUIRED)
|
||||||
|
return {'id': keep('shell', name, state), 'options': dict(options)}
|
||||||
|
|
||||||
|
|
||||||
|
def shell_complete(connection, name, pending, response):
|
||||||
|
"""Check the browser's answer to a begun proof of the user for a shell: one of the user's own passkeys, with the
|
||||||
|
user verified, while the user is enabled and not locked."""
|
||||||
|
refused = vpnrouter_users.Failure('the passkey is not accepted', 5)
|
||||||
|
state = take('shell', name, pending)
|
||||||
|
if state is None or not isinstance(response, dict):
|
||||||
|
raise refused
|
||||||
|
current = vpnrouter_users.entry(connection, name)
|
||||||
|
user = vpnrouter_users.summary(current)
|
||||||
|
if not user['enabled'] or user['locked']:
|
||||||
|
raise refused
|
||||||
|
try:
|
||||||
|
server().authenticate_complete(state, credentials(current), response)
|
||||||
|
except (ValueError, KeyError, TypeError):
|
||||||
|
raise refused
|
||||||
@@ -34,9 +34,10 @@ 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 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,
|
||||||
the user used a shell within web.shell_auth_timeout minutes, for a one-time ticket
|
with the password and the TOTP code or with a passkey whose proof POST /shell/passkey begins,
|
||||||
(see shell_ticket()), GET with it is a WebSocket
|
unless the user used a shell within web.shell_auth_timeout minutes, for a one-time ticket
|
||||||
|
(see shell_ticket() and shell_passkey()), GET with it is a WebSocket
|
||||||
that carries the terminal (see Handler.shell()); /shell/editor/<session>/<id> reads, saves and
|
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
|
closes a file the shell opened with vpn-router-editor (vre) over EDITOR_SOCKET (see
|
||||||
shell_editor() and editor_request())
|
shell_editor() and editor_request())
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from .identities import account, groups, service_accounts, users
|
|||||||
from .logs import logs
|
from .logs import logs
|
||||||
from .metrics import metrics_history, metrics_settings
|
from .metrics import metrics_history, metrics_settings
|
||||||
from .pki import PUBLISHED, ca
|
from .pki import PUBLISHED, ca
|
||||||
from .shell import EDITOR_BODY_LIMIT, shell, shell_editor, shell_ticket
|
from .shell import EDITOR_BODY_LIMIT, shell, shell_editor, shell_passkey, shell_ticket
|
||||||
from .sites import SITE_FILES, site_files, sites
|
from .sites import SITE_FILES, site_files, sites
|
||||||
from .status import cached, details, html_of, postgresql_databases, shown, text_of
|
from .status import cached, details, html_of, postgresql_databases, shown, text_of
|
||||||
|
|
||||||
@@ -120,6 +120,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
body, kind = json.dumps(document).encode() + b'\n', 'application/json'
|
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 shell_passkey(claims.get('sub')) if (path, self.command) == ('/shell/passkey', 'POST') \
|
||||||
else (404, {'error': f'no route {self.command} {path}'})
|
else (404, {'error': f'no route {self.command} {path}'})
|
||||||
if code != 200:
|
if code != 200:
|
||||||
log(f'shell ticket of {claims.get("sub")} from {self.client()} refused: {document["error"]}')
|
log(f'shell ticket of {claims.get("sub")} from {self.client()} refused: {document["error"]}')
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""/shell: the ticket, the WebSocket of the terminal (shell), and the editor of the shell: the socket vpn-router-editor
|
"""/shell: the ticket (shell_ticket), with the passkey proof it may take (shell_passkey), the WebSocket of the terminal
|
||||||
names files on (editor_listen) and /shell/editor/... (shell_editor)."""
|
(shell), and the editor of the shell: the socket vpn-router-editor names files on (editor_listen) and /shell/editor/...
|
||||||
|
(shell_editor)."""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import fcntl
|
import fcntl
|
||||||
@@ -20,6 +21,7 @@ import urllib.parse
|
|||||||
import ldap
|
import ldap
|
||||||
|
|
||||||
import vpnrouter
|
import vpnrouter
|
||||||
|
import vpnrouter_passkeys
|
||||||
import vpnrouter_users
|
import vpnrouter_users
|
||||||
from vpnrouter_status import run
|
from vpnrouter_status import run
|
||||||
|
|
||||||
@@ -74,11 +76,14 @@ _shell_sessions = {}
|
|||||||
def shell_ticket(name, body):
|
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"}, its password and TOTP code, or with
|
||||||
as a failed login. Without a password, the user is not asked to sign in while its last use of a shell (see
|
{"passkey": {"id", "credential"}}, the answer of the browser to a proof begun by POST /shell/passkey (see
|
||||||
_shell_used) is less than web.shell_auth_timeout minutes ago. The answer is {"ticket", "expires_in"}: a ticket that
|
shell_passkey) with one of its own passkeys and the user verified. A wrong password or code counts as a failed
|
||||||
opens one shell (GET /shell) for the same user within SHELL_TICKET_SECONDS, or a null ticket when the user must
|
login; it and a passkey that is not accepted are 403. Without either, the user is not asked to sign in while its
|
||||||
sign in. 403 while web.shell is false, the user's console login is not on or the user has no TOTP token.
|
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, the user's console login is not on or the user has
|
||||||
|
no TOTP token.
|
||||||
"""
|
"""
|
||||||
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'}
|
||||||
@@ -91,16 +96,20 @@ def shell_ticket(name, body):
|
|||||||
refusal = console_account(name)[1]
|
refusal = console_account(name)[1]
|
||||||
if refusal:
|
if refusal:
|
||||||
return 403, {'error': refusal}
|
return 403, {'error': refusal}
|
||||||
if 'password' not in body:
|
if 'passkey' in body:
|
||||||
|
passkey = body['passkey'] if isinstance(body['passkey'], dict) else {}
|
||||||
|
with vpnrouter_users.session() as connection:
|
||||||
|
vpnrouter_passkeys.shell_complete(connection, name, passkey.get('id'), passkey.get('credential'))
|
||||||
|
elif 'password' in body:
|
||||||
|
vpnrouter_users.login(name, body.get('password'), body.get('code'))
|
||||||
|
else:
|
||||||
with _shell_lock:
|
with _shell_lock:
|
||||||
used = _shell_used.get(name)
|
used = _shell_used.get(name)
|
||||||
if used is None or time.monotonic() - used >= options['shell_auth_timeout'] * 60:
|
if used is None or time.monotonic() - used >= options['shell_auth_timeout'] * 60:
|
||||||
return 200, {'ticket': None, 'expires_in': 0}
|
return 200, {'ticket': None, 'expires_in': 0}
|
||||||
else:
|
|
||||||
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:
|
||||||
return 403, {'error': 'the password or the code is wrong'}
|
return 403, {'error': 'the passkey is not accepted' if 'passkey' in body else 'the password or the code is wrong'}
|
||||||
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
|
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||||
except (ldap.LDAPError, OSError) as exc:
|
except (ldap.LDAPError, OSError) as exc:
|
||||||
return 500, {'error': f'the user directory failed: {exc}'}
|
return 500, {'error': f'the user directory failed: {exc}'}
|
||||||
@@ -116,9 +125,32 @@ def shell_ticket(name, body):
|
|||||||
return 200, {'ticket': ticket, 'expires_in': SHELL_TICKET_SECONDS}
|
return 200, {'ticket': ticket, 'expires_in': SHELL_TICKET_SECONDS}
|
||||||
|
|
||||||
|
|
||||||
|
def shell_passkey(name):
|
||||||
|
"""Answer POST /shell/passkey: begin the proof of the signed-in user with one of its own passkeys for POST /shell,
|
||||||
|
{"id", "options"}, options those of navigator.credentials.get; the HTTP status and the JSON document. 403 while
|
||||||
|
the user may not use a shell (console_account), 400 for a user without a passkey."""
|
||||||
|
if not isinstance(name, str):
|
||||||
|
return 403, {'error': 'the shell needs a token of a signed-in user'}
|
||||||
|
try:
|
||||||
|
if not vpnrouter.load_config()['web']['shell']:
|
||||||
|
return 403, {'error': 'the shell is disabled; vpn-config-edit set web.shell true enables it'}
|
||||||
|
refusal = console_account(name)[1]
|
||||||
|
if refusal:
|
||||||
|
return 403, {'error': refusal}
|
||||||
|
with vpnrouter_users.session() as connection:
|
||||||
|
return 200, vpnrouter_passkeys.shell_begin(connection, name)
|
||||||
|
except vpnrouter_users.Failure as failure:
|
||||||
|
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||||
|
except (ldap.LDAPError, OSError) as exc:
|
||||||
|
return 500, {'error': f'the user directory failed: {exc}'}
|
||||||
|
except SystemExit as exc:
|
||||||
|
return 500, {'error': str(exc.code)}
|
||||||
|
|
||||||
|
|
||||||
def console_account(name):
|
def console_account(name):
|
||||||
"""The system account the console of the user runs as and None, or None and the reason the user may not use a
|
"""The system account the console of the user runs as and None, or None and the reason the user may not use a
|
||||||
console: its console login is off, or it has no TOTP token."""
|
console: its console login is off, or it has no TOTP token, which the PAM login session of the shell and sudo need
|
||||||
|
also when a passkey opens the shell."""
|
||||||
with vpnrouter_users.session() as connection:
|
with vpnrouter_users.session() as connection:
|
||||||
user = vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
|
user = vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
|
||||||
if not user['console'] or not user['console']['enabled']:
|
if not user['console'] or not user['console']['enabled']:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import time
|
|||||||
from html import escape
|
from html import escape
|
||||||
|
|
||||||
import vpnrouter
|
import vpnrouter
|
||||||
from vpnrouter_status import established_each, openvpn_clients, run, swanctl, wireguard_peers
|
from vpnrouter_status import established, established_each, openvpn_clients, run, swanctl, wireguard_peers
|
||||||
|
|
||||||
from .common import current, unit_state
|
from .common import current, unit_state
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user