fix: the shell runs as the console account of each member of admins, a local account or the user's own POSIX account in the directory, which also signs in with SSH and sudo, instead of one web.shell_user.
Release / release (push) Successful in 28s
Release / release (push) Successful in 28s
This commit is contained in:
1 parent
2e1eb89f52
commit
ec93bc6a2d
12 files changed
+432
-51
No files matched your search
@@ -905,8 +905,9 @@ xterm.js expects as a WebSocket. The web service runs in a sandbox (`NoNewPrivil
|
|||||||
in a browser should not be. A stolen session cookie or access token alone must not give a shell.
|
in a browser should not be. A stolen session cookie or access token alone must not give a shell.
|
||||||
|
|
||||||
Decision: `/shell` of the web service is off until `web.shell` is set on the router; the Router
|
Decision: `/shell` of the web service is off until `web.shell` is set on the router; the Router
|
||||||
Manager cannot set it. The shell runs as `web.shell_user`, a system user other than root, which
|
Manager cannot set it. The shell runs as the console account of the signed-in user (ADR-34), so
|
||||||
reaches root through its sudoers entry. `POST /shell` signs the user in again with the password
|
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
|
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. 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
|
||||||
@@ -917,7 +918,34 @@ Manager checks the session and the WebSocket's origin, and relays the bytes to t
|
|||||||
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; a session alone cannot. The ticket travels in the URL
|
can get a shell when the router allows one and the user's console login is on; a session alone
|
||||||
and appears in request logs, used up. The shell's sessions show up as `vpn-router-shell-<id>`
|
cannot. The ticket travels in the URL and appears in request logs, used up. The shell's sessions
|
||||||
units and logind sessions of `web.shell_user`. The shell's traffic is as protected as the Router
|
show up as `vpn-router-shell-<id>` units and logind sessions of the console account. The shell's
|
||||||
Manager's: HTTPS to Caddy, plain HTTP on the internal network to the web service.
|
traffic is as protected as the Router Manager's: HTTPS to Caddy, plain HTTP on the internal
|
||||||
|
network to the web service.
|
||||||
|
|
||||||
|
## ADR-34: Console login maps an administrator to a POSIX account
|
||||||
|
|
||||||
|
Context: A shell, SSH and sudo need a POSIX account. One shared system account for every
|
||||||
|
administrator hides who did what and shares one set of rights. The administrators are users of
|
||||||
|
the router's directory, with their passwords, TOTP tokens and lockout there; a second set of
|
||||||
|
local accounts would duplicate them. slapd listens on its local socket only. SSSD authenticates
|
||||||
|
only over TLS or LDAPS and has no `ldapi://`; nslcd talks `ldapi://` and checks a password by
|
||||||
|
binding as the user.
|
||||||
|
|
||||||
|
Decision: Console login is a property of a member of `admins`, `vpnRouterConsoleLogin`, that
|
||||||
|
only root on the router sets, as for the group `admins`. It runs the console as an existing local
|
||||||
|
account (`vpnRouterConsoleAccount`), or as the user's own POSIX account: the user's entry becomes a
|
||||||
|
`posixAccount` with a uid from 20000 up and the local group `vpn-router-console` as its group.
|
||||||
|
nslcd serves the POSIX accounts with console login on, and PAM checks their passwords with a bind,
|
||||||
|
so slapd's otp overlay and password policy apply as for the Router Manager: the password followed
|
||||||
|
by the code, and the lockout. Members of `vpn-router-console` get sudo with that password. SSH
|
||||||
|
accepts it only while `ssh.ldap_login` is set, in a `Match Group` drop-in of sshd. Switching console
|
||||||
|
login off keeps the `posixAccount`, hidden by nslcd's filter, so the uid and the home stay the
|
||||||
|
user's; leaving `admins` switches it off.
|
||||||
|
|
||||||
|
Consequences: Each administrator's shell, SSH session and sudo run as the administrator, logged
|
||||||
|
under the administrator's name. The directory becomes part of the system's name service through
|
||||||
|
nslcd, and the system knows only the accounts with console login on. A user enrolled for TOTP types
|
||||||
|
the code after the password at the SSH and sudo prompts. Homes are created at the first login and
|
||||||
|
are not removed with the user. A password is changed through the directory, not with `passwd`.
|
||||||
@@ -128,9 +128,9 @@ must have the type shown below; an unknown section or setting is an error.
|
|||||||
| `web` | `database` | string | | A PostgreSQL connection string for the refresh tokens; without it, the `vpn-router` database on this host that the web service creates |
|
| `web` | `database` | string | | A PostgreSQL connection string for the refresh tokens; without it, the `vpn-router` database on this host that the web service creates |
|
||||||
| `web` | `trusted_proxies` | list | the address the call came in on | Addresses whose `X-Forwarded-For` header names the client for the `/auth` rate limit and the Client IP of the status |
|
| `web` | `trusted_proxies` | list | the address the call came in on | Addresses whose `X-Forwarded-For` header names the client for the `/auth` rate limit and the Client IP of the status |
|
||||||
| `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 of `shell_user` for a member of `admins` (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_user` | string | | The system user the shell runs as, not `root`; required by `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 |
|
||||||
|
| `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 |
|
||||||
| `https` | `web_port` | integer | `7444` | HTTPS port of the status web service |
|
| `https` | `web_port` | integer | `7444` | HTTPS port of the status web service |
|
||||||
@@ -705,6 +705,7 @@ vpn-router-user otp-remove alice
|
|||||||
| `otp-new <name>` | A new TOTP secret and its `otpauth://` URI, issued by `wan.local_fqdn`, for an authenticator app; nothing is stored |
|
| `otp-new <name>` | A new TOTP secret and its `otpauth://` URI, issued by `wan.local_fqdn`, for an authenticator app; nothing is stored |
|
||||||
| `otp-enrol <name> <code>` | Stores the secret read from standard input when the code is a current code of it, replacing a secret the user has |
|
| `otp-enrol <name> <code>` | Stores the secret read from standard input when the code is a current code of it, replacing a secret the user has |
|
||||||
| `otp-remove <name>` | Removes the user's TOTP secret |
|
| `otp-remove <name>` | Removes the user's TOTP secret |
|
||||||
|
| `console <name> (--posix \| --account LOCAL \| --off)` | Switches the console login of a member of `admins` on, as the user's own POSIX account or a local account, or off (see Console login and SSH) |
|
||||||
|
|
||||||
A user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter; a
|
A user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter; a
|
||||||
password has 8 to 128 characters; a first or last name has 1 to 64 printable characters; an
|
password has 8 to 128 characters; a first or last name has 1 to 64 printable characters; an
|
||||||
@@ -725,6 +726,38 @@ the file.
|
|||||||
The certificate is also accepted by road-warrior (IKEv2 EAP-TLS) access, which checks
|
The certificate is also accepted by road-warrior (IKEv2 EAP-TLS) access, which checks
|
||||||
certificates only, so `disable` does not affect it; `remove` revokes the certificate for both.
|
certificates only, so `disable` does not affect it; `remove` revokes the certificate for both.
|
||||||
|
|
||||||
|
### Console login and SSH
|
||||||
|
|
||||||
|
A member of `admins` may have console login: the Router Manager's Shell page (see The shell),
|
||||||
|
and, for the user's own POSIX account, SSH and sudo. Only root on the router switches it, with
|
||||||
|
`vpn-router-user console` or a token of `vpn-router-token`, as for the group `admins`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vpn-router-user console alice --posix
|
||||||
|
vpn-router-user console bob --account azureuser
|
||||||
|
vpn-router-user console alice --off
|
||||||
|
vpn-config-edit set ssh.ldap_login true
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Own POSIX account** (`--posix`): the user's entry becomes a `posixAccount` of the directory
|
||||||
|
with the user's name, a uid from 20000 up, the local group `vpn-router-console` as its group,
|
||||||
|
`/home/<name>` and `/bin/bash`. `nslcd` makes it known to the system over slapd's local socket
|
||||||
|
and checks its password by binding as the user, so a user enrolled for TOTP types the password
|
||||||
|
followed by a current code, failed logins count towards the lockout, and a disabled user cannot
|
||||||
|
sign in. The home is created at the first login and kept when the user is removed. Members of
|
||||||
|
`vpn-router-console` may run any command with sudo, with the same password. A local account of
|
||||||
|
the same name is refused.
|
||||||
|
- **Local account** (`--account`): the console runs as an existing local account with a uid from
|
||||||
|
1000 to 19999, such as the VM's admin user; its SSH keys and sudo rights stay as they are.
|
||||||
|
- **Off** (`--off`): the system no longer knows the user's own POSIX account, and its sessions
|
||||||
|
end; its uid is kept for the next time. Leaving `admins`, or removing or renaming the group,
|
||||||
|
switches the console login of the members off.
|
||||||
|
|
||||||
|
While `ssh.ldap_login` is `true`, setup lets sshd accept the password of the members of
|
||||||
|
`vpn-router-console` (`/etc/ssh/sshd_config.d/vpn-router.conf`); other accounts keep sshd's
|
||||||
|
settings. A password change is made in the Router Manager or with `vpn-router-user passwd`, not
|
||||||
|
with `passwd`.
|
||||||
|
|
||||||
### Managing groups
|
### Managing groups
|
||||||
|
|
||||||
`vpn-router-group` manages the groups. A group has a name, with the rules of a user name, an
|
`vpn-router-group` manages the groups. A group has a name, with the rules of a user name, an
|
||||||
@@ -928,12 +961,12 @@ 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` a login shell on the router, as
|
While `web.shell` is `true`, `/shell` gives a member of `admins` whose console login is on (see
|
||||||
the system user `web.shell_user`, for the Router Manager's Shell page. It is off by default and
|
Console login and SSH) a login shell on the router, as the user's console account, for the
|
||||||
switched on and off on the router only:
|
Router Manager's Shell page. It is off by default and switched on and off on the router only:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
vpn-config-edit set web.shell_user azureuser
|
vpn-router-user console alice --posix
|
||||||
vpn-config-edit set web.shell true
|
vpn-config-edit set web.shell true
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -943,12 +976,12 @@ vpn-config-edit set web.shell true
|
|||||||
| `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. The ticket opens one
|
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
|
||||||
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 `web.shell_user`, run
|
the router are the terminal's output. The shell is a PAM login session of the console account,
|
||||||
by `systemd-run` in a transient unit `vpn-router-shell-<id>`; sudo works as the user's sudoers
|
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
|
||||||
entry allows. It is closed after `web.shell_idle_minutes` without a keystroke, when the shell
|
|
||||||
exits, or when the connection closes; the router then sends a close frame with the reason. The
|
exits, or when the connection closes; 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.
|
when it ended and why.
|
||||||
@@ -984,7 +1017,7 @@ and answer bodies are JSON; a request body is at most 65536 bytes.
|
|||||||
|
|
||||||
| Request | Body | Answer |
|
| Request | Body | Answer |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `GET /users` | | `{"users": [{"name", "manager_only", "service", "internal", "enabled", "locked", "certificate", "otp", "first_name", "last_name", "email"}, ...]}` |
|
| `GET /users` | | `{"users": [{"name", "manager_only", "service", "internal", "enabled", "locked", "certificate", "otp", "console", "first_name", "last_name", "email"}, ...]}`; `console` is `null` or `{"enabled", "account", "local", "uid"}` |
|
||||||
| `POST /users` | `{"name", "password", "first_name", "last_name", "email"}` for a VPN user; `{"name", "password", "email"?}` for a Manager-only account | 201 and the user |
|
| `POST /users` | `{"name", "password", "first_name", "last_name", "email"}` for a VPN user; `{"name", "password", "email"?}` for a Manager-only account | 201 and the user |
|
||||||
| `PUT /users/<name>` | any of `{"first_name", "last_name", "email"}`; for a Manager-only account only `{"email"}`, `""` removing it | the user |
|
| `PUT /users/<name>` | any of `{"first_name", "last_name", "email"}`; for a Manager-only account only `{"email"}`, `""` removing it | the user |
|
||||||
| `PUT /users/<name>/password` | `{"password": ...}` | the user |
|
| `PUT /users/<name>/password` | `{"password": ...}` | the user |
|
||||||
@@ -996,6 +1029,7 @@ and answer bodies are JSON; a request body is at most 65536 bytes.
|
|||||||
| `POST /users/<name>/otp` | | `{"name", "secret", "uri"}`, a new TOTP secret; nothing is stored |
|
| `POST /users/<name>/otp` | | `{"name", "secret", "uri"}`, a new TOTP secret; nothing is stored |
|
||||||
| `PUT /users/<name>/otp` | `{"secret", "code"}` | the user, once the code matches the secret |
|
| `PUT /users/<name>/otp` | `{"secret", "code"}` | the user, once the code matches the secret |
|
||||||
| `DELETE /users/<name>/otp` | | the user |
|
| `DELETE /users/<name>/otp` | | the user |
|
||||||
|
| `PUT /users/<name>/console` | `{"enabled", "account"?}`; only with a token of `vpn-router-token` | the user |
|
||||||
|
|
||||||
A failure is `{"error": ...}` with 400 for an invalid request, 404 for a user that does not
|
A failure is `{"error": ...}` with 400 for an invalid request, 404 for a user that does not
|
||||||
exist or is a service account, 409 for a user that exists and 500 otherwise. `/users` does not
|
exist or is a service account, 409 for a user that exists and 500 otherwise. `/users` does not
|
||||||
|
|||||||
@@ -1,3 +1,21 @@
|
|||||||
|
vpn-router (0.23.1-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
fix: the shell runs as the console account of each member of admins, a local account or the user's own POSIX account in the directory, which also signs in with SSH and sudo, instead of one web.shell_user.
|
||||||
|
|
||||||
|
* vpn-router-user console <name> --posix | --account LOCAL | --off, and
|
||||||
|
PUT /users/<name>/console with a token of vpn-router-token, switch a
|
||||||
|
member of admins' console login; leaving admins switches it off.
|
||||||
|
* The user's own POSIX account is served by nslcd from the directory,
|
||||||
|
with a uid from 20000, the group vpn-router-console, which may use
|
||||||
|
sudo, and a home created at the first login; its password is the
|
||||||
|
directory password, followed by the code once enrolled for TOTP.
|
||||||
|
* ssh.ldap_login (false) lets sshd accept that password.
|
||||||
|
* POST /shell and the shell need the user's console login; web.shell_user
|
||||||
|
is gone.
|
||||||
|
* Depends on nslcd, libnss-ldapd, libpam-ldapd and sudo.
|
||||||
|
|
||||||
|
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 21:00:00 +0100
|
||||||
|
|
||||||
vpn-router (0.23.0-1) unstable; urgency=medium
|
vpn-router (0.23.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
feature: web.shell lets /shell of the web service open a login shell of web.shell_user over a WebSocket for the Router Manager's Shell page, after the user signs in again.
|
feature: web.shell lets /shell of the web service open a login shell of web.shell_user over a WebSocket for the Router Manager's Shell page, after the user signs in again.
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ Depends: ${misc:Depends},
|
|||||||
python3-prometheus-client,
|
python3-prometheus-client,
|
||||||
postgresql,
|
postgresql,
|
||||||
slapd,
|
slapd,
|
||||||
|
nslcd,
|
||||||
|
libnss-ldapd,
|
||||||
|
libpam-ldapd,
|
||||||
|
sudo,
|
||||||
openvpn,
|
openvpn,
|
||||||
openvpn-auth-ldap,
|
openvpn-auth-ldap,
|
||||||
iproute2,
|
iproute2,
|
||||||
|
|||||||
@@ -54,9 +54,13 @@ case "$1" in
|
|||||||
/etc/vpn-router/web.key \
|
/etc/vpn-router/web.key \
|
||||||
/etc/vpn-router/metrics.secret \
|
/etc/vpn-router/metrics.secret \
|
||||||
/etc/systemd/system/vpn-router-backup.timer.d/schedule.conf \
|
/etc/systemd/system/vpn-router-backup.timer.d/schedule.conf \
|
||||||
|
/etc/sudoers.d/vpn-router \
|
||||||
|
/etc/ssh/sshd_config.d/vpn-router.conf \
|
||||||
/var/lib/vpn-router/setup.done \
|
/var/lib/vpn-router/setup.done \
|
||||||
/var/lib/vpn-router/backup.json
|
/var/lib/vpn-router/backup.json
|
||||||
rmdir --ignore-fail-on-non-empty /etc/systemd/system/vpn-router-backup.timer.d 2>/dev/null || true
|
rmdir --ignore-fail-on-non-empty /etc/systemd/system/vpn-router-backup.timer.d 2>/dev/null || true
|
||||||
|
# The name service no longer looks up the console accounts of the router's directory.
|
||||||
|
sed -i '/^passwd:/s/ ldap\b//' /etc/nsswitch.conf
|
||||||
rm -rf /var/lib/vpn-router/backup/staging /var/lib/vpn-router/backup/restore \
|
rm -rf /var/lib/vpn-router/backup/staging /var/lib/vpn-router/backup/restore \
|
||||||
/var/lib/vpn-router/pki-publish /etc/caddy/vpn-router-pki \
|
/var/lib/vpn-router/pki-publish /etc/caddy/vpn-router-pki \
|
||||||
/etc/openvpn/server/vpn-router-ca.pem /etc/openvpn/server/vpn-router-crl.pem
|
/etc/openvpn/server/vpn-router-ca.pem /etc/openvpn/server/vpn-router-crl.pem
|
||||||
|
|||||||
@@ -83,6 +83,15 @@ CADDY_PKI_DIR = pathlib.Path('/etc/caddy/vpn-router-pki')
|
|||||||
#: The timer of vpn-router-ca maintain, which keeps the CRLs fresh.
|
#: The timer of vpn-router-ca maintain, which keeps the CRLs fresh.
|
||||||
PKI_TIMER = 'vpn-router-pki.timer'
|
PKI_TIMER = 'vpn-router-pki.timer'
|
||||||
|
|
||||||
|
#: The system's side of console login (vpnrouter_users.console): nslcd's configuration, which serves the posixAccounts
|
||||||
|
#: of the directory with console login on, the name service switch, the sudo rule of their group, and the drop-in of
|
||||||
|
#: sshd that lets them sign in with their password while ssh.ldap_login is true.
|
||||||
|
NSLCD_CONF = pathlib.Path('/etc/nslcd.conf')
|
||||||
|
NSSWITCH = pathlib.Path('/etc/nsswitch.conf')
|
||||||
|
SUDOERS = pathlib.Path('/etc/sudoers.d/vpn-router')
|
||||||
|
SSHD_DROPIN = pathlib.Path('/etc/ssh/sshd_config.d/vpn-router.conf')
|
||||||
|
PAM_SESSION = pathlib.Path('/etc/pam.d/common-session')
|
||||||
|
|
||||||
FILTER_ANCHOR = '# End required lines'
|
FILTER_ANCHOR = '# End required lines'
|
||||||
|
|
||||||
#: Configuration options left out wherever the configuration is shown.
|
#: Configuration options left out wherever the configuration is shown.
|
||||||
@@ -529,6 +538,90 @@ def manage_metrics_account(ctx):
|
|||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def write_if_changed(path, text, mode=0o644):
|
||||||
|
"""Write the file when its content differs; whether it was written."""
|
||||||
|
try:
|
||||||
|
if path.read_text(encoding='utf-8') == text:
|
||||||
|
return False
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
path.write_text(text, encoding='utf-8')
|
||||||
|
path.chmod(mode)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def manage_console(cfg):
|
||||||
|
"""Make the system serve console login; the problem, or ''.
|
||||||
|
|
||||||
|
The local group vpnrouter_ldap.CONSOLE_GROUP is created when missing, and sudo lets its members run any command
|
||||||
|
with their password. nslcd serves the directory's posixAccounts with console login on through the local socket,
|
||||||
|
and checks their passwords by binding as the user; the passwd line of the name service switch gains ldap, and PAM
|
||||||
|
creates a home at the first login. While ssh.ldap_login is true, sshd accepts the password of the group's members;
|
||||||
|
a drop-in sshd -t refuses is removed again.
|
||||||
|
"""
|
||||||
|
group = vpnrouter_ldap.CONSOLE_GROUP
|
||||||
|
try:
|
||||||
|
grp.getgrnam(group)
|
||||||
|
except KeyError:
|
||||||
|
result = run(['groupadd', '--system', group], check=False)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return f'the group {group} cannot be created: {result.stderr.strip()}'
|
||||||
|
|
||||||
|
rule = f'%{group} ALL=(ALL:ALL) ALL\n'
|
||||||
|
try:
|
||||||
|
if not SUDOERS.is_file() or SUDOERS.read_text(encoding='utf-8') != rule:
|
||||||
|
staged = SUDOERS.with_name('.vpn-router.new')
|
||||||
|
staged.write_text(rule, encoding='utf-8')
|
||||||
|
staged.chmod(0o440)
|
||||||
|
checked = run(['visudo', '-cf', str(staged)], check=False)
|
||||||
|
if checked.returncode != 0:
|
||||||
|
staged.unlink()
|
||||||
|
return f'the sudo rule of {group} is refused: {checked.stdout.strip()} {checked.stderr.strip()}'
|
||||||
|
staged.replace(SUDOERS)
|
||||||
|
|
||||||
|
uri = 'ldapi://' + vpnrouter_ldap.SLAPD_SOCKET.replace('/', '%2f') + '/'
|
||||||
|
nslcd = '\n'.join([
|
||||||
|
'uid nslcd',
|
||||||
|
'gid nslcd',
|
||||||
|
f'uri {uri}',
|
||||||
|
f'base {vpnrouter_ldap.BASE}',
|
||||||
|
'filter passwd (&(objectClass=posixAccount)(vpnRouterConsoleLogin=TRUE))',
|
||||||
|
f'nss_min_uid {vpnrouter_ldap.CONSOLE_UID_MIN}',
|
||||||
|
'pam_authz_search (&(objectClass=posixAccount)(uid=$username)(vpnRouterConsoleLogin=TRUE))',
|
||||||
|
'pam_password_prohibit_message The password is changed in the Router Manager or with vpn-router-user.',
|
||||||
|
'',
|
||||||
|
])
|
||||||
|
if write_if_changed(NSLCD_CONF, nslcd, 0o640) or not unit_is_active('nslcd'):
|
||||||
|
os.chown(NSLCD_CONF, 0, grp.getgrnam('nslcd').gr_gid)
|
||||||
|
run(['systemctl', 'enable', 'nslcd'], check=False)
|
||||||
|
run(['systemctl', 'restart', 'nslcd'], check=False)
|
||||||
|
|
||||||
|
lines = NSSWITCH.read_text(encoding='utf-8').splitlines(keepends=True)
|
||||||
|
switched = [line.rstrip('\n') + ' ldap\n' if line.startswith('passwd:') and 'ldap' not in line.split() else line
|
||||||
|
for line in lines]
|
||||||
|
if switched != lines:
|
||||||
|
NSSWITCH.write_text(''.join(switched), encoding='utf-8')
|
||||||
|
|
||||||
|
if 'pam_mkhomedir' not in PAM_SESSION.read_text(encoding='utf-8'):
|
||||||
|
run(['pam-auth-update', '--enable', 'mkhomedir'], check=False)
|
||||||
|
|
||||||
|
if cfg['ssh']['ldap_login']:
|
||||||
|
changed = write_if_changed(SSHD_DROPIN, f'Match Group {group}\n PasswordAuthentication yes\n')
|
||||||
|
else:
|
||||||
|
changed = SSHD_DROPIN.exists()
|
||||||
|
SSHD_DROPIN.unlink(missing_ok=True)
|
||||||
|
if changed:
|
||||||
|
checked = run(['sshd', '-t'], check=False)
|
||||||
|
if checked.returncode != 0:
|
||||||
|
SSHD_DROPIN.unlink(missing_ok=True)
|
||||||
|
return f'sshd refuses {SSHD_DROPIN}: {checked.stderr.strip()}'
|
||||||
|
if unit_is_active('ssh'):
|
||||||
|
run(['systemctl', 'reload', 'ssh'], check=False)
|
||||||
|
except (OSError, KeyError) as exc:
|
||||||
|
return f'console login cannot be set up: {exc}'
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
def manage_openvpn(ctx, changed):
|
def manage_openvpn(ctx, changed):
|
||||||
"""Run the OpenVPN server when it is configured, and stop it when it is not."""
|
"""Run the OpenVPN server when it is configured, and stop it when it is not."""
|
||||||
unit = 'openvpn-server@vpn-router'
|
unit = 'openvpn-server@vpn-router'
|
||||||
@@ -796,6 +889,10 @@ def main():
|
|||||||
failed_step = 'metrics service account'
|
failed_step = 'metrics service account'
|
||||||
progress('running', failed_step)
|
progress('running', failed_step)
|
||||||
problem = manage_metrics_account(ctx)
|
problem = manage_metrics_account(ctx)
|
||||||
|
if not problem:
|
||||||
|
failed_step = 'console login'
|
||||||
|
progress('running', failed_step)
|
||||||
|
problem = manage_console(cfg)
|
||||||
progress('running', 'OpenVPN')
|
progress('running', 'OpenVPN')
|
||||||
manage_openvpn(ctx, changed)
|
manage_openvpn(ctx, changed)
|
||||||
progress('running', 'HTTPS proxy')
|
progress('running', 'HTTPS proxy')
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ 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_user': '', 'shell_idle_minutes': 15},
|
'status_content': 'basic', 'shell': False, 'shell_idle_minutes': 15},
|
||||||
|
'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',
|
||||||
'crl_days': 7, 'root_crl_days': 180, 'publish': ['http'], 'p2s_name': 'VPN Router P2S CA',
|
'crl_days': 7, 'root_crl_days': 180, 'publish': ['http'], 'p2s_name': 'VPN Router P2S CA',
|
||||||
@@ -194,9 +195,6 @@ 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')
|
||||||
|
|
||||||
#: A system user the shell of the web service runs as, web.shell_user; root is refused.
|
|
||||||
SHELL_USER = re.compile(r'^[a-z_][a-z0-9_-]{0,31}$')
|
|
||||||
|
|
||||||
#: The longest web.shell_idle_minutes, a day.
|
#: The longest web.shell_idle_minutes, a day.
|
||||||
SHELL_IDLE_MAX = 1440
|
SHELL_IDLE_MAX = 1440
|
||||||
|
|
||||||
@@ -483,11 +481,6 @@ def validate(cfg):
|
|||||||
raise ConfigError(
|
raise ConfigError(
|
||||||
f'web.status_content: {status_content!r} is not one of {", ".join(STATUS_CONTENTS)}')
|
f'web.status_content: {status_content!r} is not one of {", ".join(STATUS_CONTENTS)}')
|
||||||
|
|
||||||
shell_user = _get(cfg, 'web', 'shell_user')
|
|
||||||
if shell_user and (not SHELL_USER.match(shell_user) or shell_user == 'root'):
|
|
||||||
raise ConfigError(f'web.shell_user: {shell_user!r} is not a system user name other than root')
|
|
||||||
if _get(cfg, 'web', 'shell') and not shell_user:
|
|
||||||
raise ConfigError('web.shell needs web.shell_user, the system user the shell runs as')
|
|
||||||
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}')
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ a successful bind clears the count. slapd's otp overlay is configured on the dat
|
|||||||
bind of a user with a TOTP token takes the password followed by a current code.
|
bind of a user with a TOTP token takes the password followed by a current code.
|
||||||
cn=totp,ou=policies holds the TOTP parameters the token of every user points to.
|
cn=totp,ou=policies holds the TOTP parameters the token of every user points to.
|
||||||
|
|
||||||
|
A user with console login on its own account is also a posixAccount, with a uidNumber from
|
||||||
|
CONSOLE_UID_MIN and the gidNumber of the local group CONSOLE_GROUP; nslcd serves those with
|
||||||
|
vpnRouterConsoleLogin TRUE to the system over the local socket.
|
||||||
|
|
||||||
ou=pki holds the router's PKI (vpnrouter_pki): an entry per CA, and under it an entry per
|
ou=pki holds the router's PKI (vpnrouter_pki): an entry per CA, and under it an entry per
|
||||||
certificate the CA issued. Private keys are readable by root alone, the database's root DN.
|
certificate the CA issued. Private keys are readable by root alone, the database's root DN.
|
||||||
"""
|
"""
|
||||||
@@ -27,6 +31,8 @@ import ldap.modlist
|
|||||||
import ldap.sasl
|
import ldap.sasl
|
||||||
|
|
||||||
URI = 'ldapi:///'
|
URI = 'ldapi:///'
|
||||||
|
#: The local socket ldapi:/// names, which slapd listens on.
|
||||||
|
SLAPD_SOCKET = '/var/run/slapd/ldapi'
|
||||||
BASE = 'dc=vpn-router,dc=local'
|
BASE = 'dc=vpn-router,dc=local'
|
||||||
USERS = f'ou=users,{BASE}'
|
USERS = f'ou=users,{BASE}'
|
||||||
GROUPS = f'ou=groups,{BASE}'
|
GROUPS = f'ou=groups,{BASE}'
|
||||||
@@ -57,8 +63,10 @@ OVERLAYS = ('ppolicy', 'otp')
|
|||||||
#: The package's own schema, under an OID of the UUID arc 2.25 (ITU-T X.667): attribute types
|
#: The package's own schema, under an OID of the UUID arc 2.25 (ITU-T X.667): attribute types
|
||||||
#: under .1 and object classes under .2. vpnRouterAccount, auxiliary, adds properties of the
|
#: under .1 and object classes under .2. vpnRouterAccount, auxiliary, adds properties of the
|
||||||
#: package to a user of either kind: mail, for a Manager-only account, vpnRouterScope,
|
#: package to a user of either kind: mail, for a Manager-only account, vpnRouterScope,
|
||||||
#: authorization scopes, of which service marks a service account, and displayName and
|
#: authorization scopes, of which service marks a service account, displayName and
|
||||||
#: description, the labels of a service account. vpnRouterAuthority is a CA of the PKI: its
|
#: description, the labels of a service account, and vpnRouterConsoleLogin and
|
||||||
|
#: vpnRouterConsoleAccount, whether the user may use a console and the local account it runs as,
|
||||||
|
#: without which it runs as the user's own posixAccount. vpnRouterAuthority is a CA of the PKI: its
|
||||||
#: certificate and CRL (cACertificate and certificateRevocationList of RFC 4523), its key, the
|
#: certificate and CRL (cACertificate and certificateRevocationList of RFC 4523), its key, the
|
||||||
#: CSR it waits with for the offline root, the number of its last CRL, where the CRL came from,
|
#: CSR it waits with for the offline root, the number of its last CRL, where the CRL came from,
|
||||||
#: and when it was retired. vpnRouterCertificate is a certificate a CA issued, named by its
|
#: and when it was retired. vpnRouterCertificate is a certificate a CA issued, named by its
|
||||||
@@ -85,10 +93,15 @@ SCHEMA_ATTRIBUTE_TYPES = (
|
|||||||
f"( {SCHEMA_OID}.1.10 NAME 'vpnRouterNotAfter' DESC 'VPN router: when a certificate expires' {_TIME} )",
|
f"( {SCHEMA_OID}.1.10 NAME 'vpnRouterNotAfter' DESC 'VPN router: when a certificate expires' {_TIME} )",
|
||||||
f"( {SCHEMA_OID}.1.11 NAME 'vpnRouterRevoked' DESC 'VPN router: when a certificate was revoked' {_TIME} )",
|
f"( {SCHEMA_OID}.1.11 NAME 'vpnRouterRevoked' DESC 'VPN router: when a certificate was revoked' {_TIME} )",
|
||||||
f"( {SCHEMA_OID}.1.12 NAME 'vpnRouterRevocationReason' DESC 'VPN router: why a certificate was revoked' {_STRING} )",
|
f"( {SCHEMA_OID}.1.12 NAME 'vpnRouterRevocationReason' DESC 'VPN router: why a certificate was revoked' {_STRING} )",
|
||||||
|
f"( {SCHEMA_OID}.1.13 NAME 'vpnRouterConsoleLogin' DESC 'VPN router: whether the user may use a console' "
|
||||||
|
"EQUALITY booleanMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 SINGLE-VALUE )",
|
||||||
|
f"( {SCHEMA_OID}.1.14 NAME 'vpnRouterConsoleAccount' DESC 'VPN router: the local account the console of the user runs as' "
|
||||||
|
f"{_STRING} )",
|
||||||
)
|
)
|
||||||
SCHEMA_OBJECT_CLASSES = (
|
SCHEMA_OBJECT_CLASSES = (
|
||||||
f"( {SCHEMA_OID}.2.1 NAME 'vpnRouterAccount' DESC 'VPN router: properties of a user or a Manager-only account' "
|
f"( {SCHEMA_OID}.2.1 NAME 'vpnRouterAccount' DESC 'VPN router: properties of a user or a Manager-only account' "
|
||||||
"SUP top AUXILIARY MAY ( mail $ vpnRouterScope $ displayName $ description ) )",
|
"SUP top AUXILIARY MAY ( mail $ vpnRouterScope $ displayName $ description $ vpnRouterConsoleLogin $ "
|
||||||
|
"vpnRouterConsoleAccount ) )",
|
||||||
f"( {SCHEMA_OID}.2.2 NAME 'vpnRouterAuthority' DESC 'VPN router: a CA of the PKI' SUP top STRUCTURAL MUST cn "
|
f"( {SCHEMA_OID}.2.2 NAME 'vpnRouterAuthority' DESC 'VPN router: a CA of the PKI' SUP top STRUCTURAL MUST cn "
|
||||||
"MAY ( cACertificate $ certificateRevocationList $ vpnRouterPrivateKey $ vpnRouterCertificateRequest $ "
|
"MAY ( cACertificate $ certificateRevocationList $ vpnRouterPrivateKey $ vpnRouterCertificateRequest $ "
|
||||||
"vpnRouterCRLNumber $ vpnRouterCRLSource $ vpnRouterRetired ) )",
|
"vpnRouterCRLNumber $ vpnRouterCRLSource $ vpnRouterRetired ) )",
|
||||||
@@ -103,6 +116,16 @@ TOTP_ALGORITHM = '1.2.840.113549.2.7'
|
|||||||
TOTP_PERIOD = 30
|
TOTP_PERIOD = 30
|
||||||
TOTP_WINDOW = 1
|
TOTP_WINDOW = 1
|
||||||
|
|
||||||
|
#: The indexes of the database.
|
||||||
|
INDEXES = ('objectClass eq', 'uid eq', 'uidNumber eq', 'vpnRouterConsoleLogin eq')
|
||||||
|
|
||||||
|
#: The POSIX accounts of console login (vpnrouter_users.console): the lowest uidNumber, which nslcd's nss_min_uid
|
||||||
|
#: matches, the local group that is their primary group and grants sudo, the shell and the parent of their homes.
|
||||||
|
CONSOLE_UID_MIN = 20000
|
||||||
|
CONSOLE_GROUP = 'vpn-router-console'
|
||||||
|
CONSOLE_SHELL = '/bin/bash'
|
||||||
|
CONSOLE_HOMES = '/home'
|
||||||
|
|
||||||
#: The ACLs of the database, in order.
|
#: The ACLs of the database, in order.
|
||||||
ACCESS = (
|
ACCESS = (
|
||||||
'to attrs=vpnRouterPrivateKey by * none',
|
'to attrs=vpnRouterPrivateKey by * none',
|
||||||
@@ -189,8 +212,9 @@ def _ensure_password_hash(connection):
|
|||||||
|
|
||||||
|
|
||||||
def _ensure_database(connection):
|
def _ensure_database(connection):
|
||||||
"""The DN of the package's database in cn=config, created when missing; its ACLs are kept as ACCESS."""
|
"""The DN of the package's database in cn=config, created when missing; its ACLs are kept as ACCESS, and the
|
||||||
found = connection.search_s('cn=config', ldap.SCOPE_ONELEVEL, f'(olcSuffix={BASE})', ['olcAccess'])
|
indexes of INDEXES it lacks are added, which slapd builds online."""
|
||||||
|
found = connection.search_s('cn=config', ldap.SCOPE_ONELEVEL, f'(olcSuffix={BASE})', ['olcAccess', 'olcDbIndex'])
|
||||||
if not found:
|
if not found:
|
||||||
DB_DIRECTORY.mkdir(mode=0o700, parents=True, exist_ok=True)
|
DB_DIRECTORY.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
openldap = pwd.getpwnam('openldap')
|
openldap = pwd.getpwnam('openldap')
|
||||||
@@ -201,13 +225,17 @@ def _ensure_database(connection):
|
|||||||
'olcSuffix': [BASE.encode()],
|
'olcSuffix': [BASE.encode()],
|
||||||
'olcDbDirectory': [str(DB_DIRECTORY).encode()],
|
'olcDbDirectory': [str(DB_DIRECTORY).encode()],
|
||||||
'olcRootDN': [ROOT.encode()],
|
'olcRootDN': [ROOT.encode()],
|
||||||
'olcDbIndex': [b'objectClass eq', b'uid eq'],
|
'olcDbIndex': [index.encode() for index in INDEXES],
|
||||||
'olcAccess': [f'{{{index}}}{rule}'.encode() for index, rule in enumerate(ACCESS)],
|
'olcAccess': [f'{{{index}}}{rule}'.encode() for index, rule in enumerate(ACCESS)],
|
||||||
}))
|
}))
|
||||||
found = connection.search_s('cn=config', ldap.SCOPE_ONELEVEL, f'(olcSuffix={BASE})', ['olcAccess'])
|
found = connection.search_s('cn=config', ldap.SCOPE_ONELEVEL, f'(olcSuffix={BASE})', ['olcAccess', 'olcDbIndex'])
|
||||||
dn, attributes = found[0]
|
dn, attributes = found[0]
|
||||||
if unordered(attributes.get('olcAccess', [])) != list(ACCESS):
|
if unordered(attributes.get('olcAccess', [])) != list(ACCESS):
|
||||||
connection.modify_s(dn, [(ldap.MOD_REPLACE, 'olcAccess', [f'{{{index}}}{rule}'.encode() for index, rule in enumerate(ACCESS)])])
|
connection.modify_s(dn, [(ldap.MOD_REPLACE, 'olcAccess', [f'{{{index}}}{rule}'.encode() for index, rule in enumerate(ACCESS)])])
|
||||||
|
present = [value.decode() for value in attributes.get('olcDbIndex', [])]
|
||||||
|
missing = [index for index in INDEXES if index not in present]
|
||||||
|
if missing:
|
||||||
|
connection.modify_s(dn, [(ldap.MOD_ADD, 'olcDbIndex', [index.encode() for index in missing])])
|
||||||
return dn
|
return dn
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,12 @@ code of it. slapd's otp overlay then takes the password followed by a code at ev
|
|||||||
A user is disabled when pwdAccountLockedTime holds LOCKED, and locked when the password
|
A user is disabled when pwdAccountLockedTime holds LOCKED, and locked when the password
|
||||||
policy has set it after failed binds, for vpnrouter_ldap.LOCKOUT_SECONDS; unlock ends that.
|
policy has set it after failed binds, for vpnrouter_ldap.LOCKOUT_SECONDS; unlock ends that.
|
||||||
login() checks a user's password and code with a bind. Members of ADMIN_GROUP manage the
|
login() checks a user's password and code with a bind. Members of ADMIN_GROUP manage the
|
||||||
router through the web service. The module knows only the directory; the callers revoke a
|
router through the web service.
|
||||||
|
|
||||||
|
A member of ADMIN_GROUP may have console login (console): the Shell page of the Router Manager,
|
||||||
|
and SSH and sudo with the directory's password, followed by the code for an enrolled user. The
|
||||||
|
console runs as a local account, or as the user's own posixAccount, which the user entry becomes
|
||||||
|
and nslcd serves to the system. Leaving ADMIN_GROUP switches it off. The module knows only the directory; the callers revoke a
|
||||||
user's refresh tokens after the operations of vpnrouter_auth.REVOKING.
|
user's refresh tokens after the operations of vpnrouter_auth.REVOKING.
|
||||||
|
|
||||||
Every operation runs in a session(), which holds the lock that serializes changes, the PKI's
|
Every operation runs in a session(), which holds the lock that serializes changes, the PKI's
|
||||||
@@ -42,7 +47,10 @@ import base64
|
|||||||
import calendar
|
import calendar
|
||||||
import contextlib
|
import contextlib
|
||||||
import fcntl
|
import fcntl
|
||||||
|
import grp
|
||||||
|
import pwd
|
||||||
import re
|
import re
|
||||||
|
import subprocess
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import ldap
|
import ldap
|
||||||
@@ -69,7 +77,8 @@ PERSON = (('first_name', 'givenName'), ('last_name', 'sn'), ('email', 'mail'))
|
|||||||
LABELS = (('display_name', 'displayName'), ('description', 'description'))
|
LABELS = (('display_name', 'displayName'), ('description', 'description'))
|
||||||
#: The attributes read for a user.
|
#: The attributes read for a user.
|
||||||
ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken', 'vpnRouterScope',
|
ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken', 'vpnRouterScope',
|
||||||
'displayName', 'description', 'userCertificate;binary']
|
'displayName', 'description', 'userCertificate;binary', 'vpnRouterConsoleLogin', 'vpnRouterConsoleAccount',
|
||||||
|
'uidNumber']
|
||||||
#: The vpnRouterScope of a service account.
|
#: The vpnRouterScope of a service account.
|
||||||
SERVICE_SCOPE = 'service'
|
SERVICE_SCOPE = 'service'
|
||||||
#: The object classes of a VPN user and of a Manager-only account.
|
#: The object classes of a VPN user and of a Manager-only account.
|
||||||
@@ -234,6 +243,7 @@ def summary(attributes):
|
|||||||
'locked': lock not in (None, LOCKED) and locked_since(lock) > time.time() - vpnrouter_ldap.LOCKOUT_SECONDS,
|
'locked': lock not in (None, LOCKED) and locked_since(lock) > time.time() - vpnrouter_ldap.LOCKOUT_SECONDS,
|
||||||
'certificate': 'userCertificate;binary' in attributes,
|
'certificate': 'userCertificate;binary' in attributes,
|
||||||
'otp': 'oathTOTPToken' in attributes,
|
'otp': 'oathTOTPToken' in attributes,
|
||||||
|
'console': console_of(attributes),
|
||||||
}
|
}
|
||||||
for key, attribute in PERSON + LABELS:
|
for key, attribute in PERSON + LABELS:
|
||||||
values = attributes.get(attribute)
|
values = attributes.get(attribute)
|
||||||
@@ -241,6 +251,116 @@ def summary(attributes):
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def console_of(attributes):
|
||||||
|
"""The console login of an entry as the answers show it: None when it was never set, else {enabled, account, local,
|
||||||
|
uid}: account the system account the console runs as, the local one of vpnRouterConsoleAccount (local true) or the
|
||||||
|
user's own posixAccount, and uid the uidNumber of that posixAccount, None without one."""
|
||||||
|
if 'vpnRouterConsoleLogin' not in attributes:
|
||||||
|
return None
|
||||||
|
local = attributes.get('vpnRouterConsoleAccount')
|
||||||
|
number = attributes.get('uidNumber')
|
||||||
|
return {
|
||||||
|
'enabled': attributes['vpnRouterConsoleLogin'] == [b'TRUE'],
|
||||||
|
'account': local[0].decode() if local else attributes['uid'][0].decode(),
|
||||||
|
'local': bool(local),
|
||||||
|
'uid': int(number[0]) if number else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def classes(attributes):
|
||||||
|
"""The object classes of an entry, in lower case."""
|
||||||
|
return {value.decode().lower() for value in attributes.get('objectClass', [])}
|
||||||
|
|
||||||
|
|
||||||
|
def free_uid(connection):
|
||||||
|
"""The lowest uidNumber from vpnrouter_ldap.CONSOLE_UID_MIN above every posixAccount's that no local account has."""
|
||||||
|
found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL, '(uidNumber=*)', ['uidNumber'])
|
||||||
|
number = max([vpnrouter_ldap.CONSOLE_UID_MIN - 1] + [int(attributes['uidNumber'][0]) for _, attributes in found]) + 1
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
pwd.getpwuid(number)
|
||||||
|
except KeyError:
|
||||||
|
return number
|
||||||
|
number += 1
|
||||||
|
|
||||||
|
|
||||||
|
def console(connection, name, enabled, account=None):
|
||||||
|
"""Switch the console login of a member of ADMIN_GROUP on, as the local account given or, without one, as the
|
||||||
|
user's own posixAccount, or off.
|
||||||
|
|
||||||
|
The posixAccount is added the first time and kept when the console login is switched off, so its uidNumber and the
|
||||||
|
home it owns stay the user's. A local account must exist in the system's passwd with a uid from 1000 to below
|
||||||
|
vpnrouter_ldap.CONSOLE_UID_MIN, and not be nobody; the user's own account needs a name no local account has.
|
||||||
|
Switching off ends the sessions of the user's own account.
|
||||||
|
"""
|
||||||
|
current = entry(connection, name)
|
||||||
|
if not isinstance(enabled, bool):
|
||||||
|
raise Failure('enabled must be true or false', 2)
|
||||||
|
if account is not None and (not isinstance(account, str) or not account):
|
||||||
|
raise Failure('account must be the name of a local account', 2)
|
||||||
|
changes = [] if 'vpnrouteraccount' in classes(current) else [(ldap.MOD_ADD, 'objectClass', [b'vpnRouterAccount'])]
|
||||||
|
if not enabled:
|
||||||
|
if 'vpnRouterConsoleLogin' not in current:
|
||||||
|
return summary(current)
|
||||||
|
connection.modify_s(dn_of(name), changes + [(ldap.MOD_REPLACE, 'vpnRouterConsoleLogin', [b'FALSE'])])
|
||||||
|
end_sessions(current)
|
||||||
|
return summary(entry(connection, name))
|
||||||
|
if service(current):
|
||||||
|
raise Failure(f'{name} is a service account, which has no console', 2)
|
||||||
|
if ADMIN_GROUP not in groups_of(connection, name):
|
||||||
|
raise Failure(f'user {name} is not a member of {ADMIN_GROUP}', 2)
|
||||||
|
if account is not None:
|
||||||
|
try:
|
||||||
|
local = pwd.getpwnam(account)
|
||||||
|
except KeyError:
|
||||||
|
raise Failure(f'there is no local account {account}', 2)
|
||||||
|
if not 1000 <= local.pw_uid < vpnrouter_ldap.CONSOLE_UID_MIN or account == 'nobody':
|
||||||
|
raise Failure(f'{account} is not a local user account with a uid from 1000 to {vpnrouter_ldap.CONSOLE_UID_MIN - 1}', 2)
|
||||||
|
changes.append((ldap.MOD_REPLACE, 'vpnRouterConsoleAccount', [account.encode()]))
|
||||||
|
else:
|
||||||
|
changes.append((ldap.MOD_REPLACE, 'vpnRouterConsoleAccount', []))
|
||||||
|
if 'posixaccount' not in classes(current):
|
||||||
|
try:
|
||||||
|
pwd.getpwnam(name)
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise Failure(f'a local account {name} exists; give it as the account, or use another user', 4)
|
||||||
|
try:
|
||||||
|
group = grp.getgrnam(vpnrouter_ldap.CONSOLE_GROUP).gr_gid
|
||||||
|
except KeyError:
|
||||||
|
raise Failure(f'the group {vpnrouter_ldap.CONSOLE_GROUP} does not exist yet; vpn-router-setup creates it')
|
||||||
|
changes += [
|
||||||
|
(ldap.MOD_ADD, 'objectClass', [b'posixAccount']),
|
||||||
|
(ldap.MOD_REPLACE, 'uidNumber', [str(free_uid(connection)).encode()]),
|
||||||
|
(ldap.MOD_REPLACE, 'gidNumber', [str(group).encode()]),
|
||||||
|
(ldap.MOD_REPLACE, 'homeDirectory', [f'{vpnrouter_ldap.CONSOLE_HOMES}/{name}'.encode()]),
|
||||||
|
(ldap.MOD_REPLACE, 'loginShell', [vpnrouter_ldap.CONSOLE_SHELL.encode()]),
|
||||||
|
]
|
||||||
|
if manager_only(current):
|
||||||
|
changes.append((ldap.MOD_REPLACE, 'cn', [name.encode()]))
|
||||||
|
changes.append((ldap.MOD_REPLACE, 'vpnRouterConsoleLogin', [b'TRUE']))
|
||||||
|
connection.modify_s(dn_of(name), changes)
|
||||||
|
return summary(entry(connection, name))
|
||||||
|
|
||||||
|
|
||||||
|
def end_sessions(attributes):
|
||||||
|
"""End the login sessions of the user's own posixAccount, when the entry has one; those of a local account stay."""
|
||||||
|
if 'posixaccount' in classes(attributes) and not attributes.get('vpnRouterConsoleAccount'):
|
||||||
|
subprocess.run(['loginctl', 'terminate-user', attributes['uid'][0].decode()], capture_output=True)
|
||||||
|
|
||||||
|
|
||||||
|
def console_off(connection, names):
|
||||||
|
"""Switch the console login off for each of the users that has it on; for those who leave ADMIN_GROUP."""
|
||||||
|
for name in names:
|
||||||
|
try:
|
||||||
|
current = entry(connection, name)
|
||||||
|
except Failure:
|
||||||
|
continue
|
||||||
|
if current.get('vpnRouterConsoleLogin') == [b'TRUE']:
|
||||||
|
console(connection, name, False)
|
||||||
|
|
||||||
|
|
||||||
def common_name(attributes, name):
|
def common_name(attributes, name):
|
||||||
"""The cn of an entry: the first and the last name, or the user name without them."""
|
"""The cn of an entry: the first and the last name, or the user name without them."""
|
||||||
full = ' '.join(value for value in (attributes.get('givenName'), attributes.get('sn')) if value)
|
full = ' '.join(value for value in (attributes.get('givenName'), attributes.get('sn')) if value)
|
||||||
@@ -441,11 +561,12 @@ def otp_remove(connection, name):
|
|||||||
|
|
||||||
|
|
||||||
def remove(connection, name):
|
def remove(connection, name):
|
||||||
"""Remove the user from its groups and the directory, and revoke the certificate.
|
"""Remove the user from its groups and the directory, revoke the certificate and end the sessions of its own
|
||||||
|
posixAccount.
|
||||||
|
|
||||||
A user who is the last member of a group is not removed.
|
A user who is the last member of a group is not removed.
|
||||||
"""
|
"""
|
||||||
entry(connection, name)
|
current = entry(connection, name)
|
||||||
groups = groups_of(connection, name)
|
groups = groups_of(connection, name)
|
||||||
alone = sorted(group for group, members in groups.items() if members == [name])
|
alone = sorted(group for group, members in groups.items() if members == [name])
|
||||||
if alone:
|
if alone:
|
||||||
@@ -457,6 +578,7 @@ def remove(connection, name):
|
|||||||
except vpnrouter_pki.Failure as failure:
|
except vpnrouter_pki.Failure as failure:
|
||||||
raise Failure(f'the certificate cannot be revoked: {failure}', failure.status)
|
raise Failure(f'the certificate cannot be revoked: {failure}', failure.status)
|
||||||
connection.delete_s(dn_of(name))
|
connection.delete_s(dn_of(name))
|
||||||
|
end_sessions(current)
|
||||||
return {'name': name, 'removed': True}
|
return {'name': name, 'removed': True}
|
||||||
|
|
||||||
|
|
||||||
@@ -573,10 +695,11 @@ def group_add(connection, name, description, members):
|
|||||||
|
|
||||||
|
|
||||||
def group_update(connection, name, new_name=None, description=None):
|
def group_update(connection, name, new_name=None, description=None):
|
||||||
"""Rename the group or change its description ('' removes it); at least one must be given."""
|
"""Rename the group or change its description ('' removes it); at least one must be given. Renaming ADMIN_GROUP
|
||||||
|
switches the console login of its members off."""
|
||||||
if new_name is None and description is None:
|
if new_name is None and description is None:
|
||||||
raise Failure('give a new name or a description', 2)
|
raise Failure('give a new name or a description', 2)
|
||||||
group_entry(connection, name)
|
members = member_names(group_entry(connection, name))
|
||||||
if description is not None:
|
if description is not None:
|
||||||
description = check_description(description)
|
description = check_description(description)
|
||||||
change = (ldap.MOD_REPLACE, 'description', [description.encode()]) if description else \
|
change = (ldap.MOD_REPLACE, 'description', [description.encode()]) if description else \
|
||||||
@@ -588,13 +711,18 @@ def group_update(connection, name, new_name=None, description=None):
|
|||||||
connection.rename_s(group_dn(name), f'cn={new_name}', delold=1)
|
connection.rename_s(group_dn(name), f'cn={new_name}', delold=1)
|
||||||
except ldap.ALREADY_EXISTS:
|
except ldap.ALREADY_EXISTS:
|
||||||
raise Failure(f'group {new_name} exists', 4)
|
raise Failure(f'group {new_name} exists', 4)
|
||||||
|
if name == ADMIN_GROUP:
|
||||||
|
console_off(connection, members)
|
||||||
name = new_name
|
name = new_name
|
||||||
return group_summary(group_entry(connection, name))
|
return group_summary(group_entry(connection, name))
|
||||||
|
|
||||||
|
|
||||||
def group_remove(connection, name):
|
def group_remove(connection, name):
|
||||||
group_entry(connection, name)
|
"""Remove the group; removing ADMIN_GROUP switches the console login of its members off."""
|
||||||
|
members = member_names(group_entry(connection, name))
|
||||||
connection.delete_s(group_dn(name))
|
connection.delete_s(group_dn(name))
|
||||||
|
if name == ADMIN_GROUP:
|
||||||
|
console_off(connection, members)
|
||||||
return {'name': name, 'removed': True}
|
return {'name': name, 'removed': True}
|
||||||
|
|
||||||
|
|
||||||
@@ -607,7 +735,8 @@ def group_member_add(connection, name, user):
|
|||||||
|
|
||||||
|
|
||||||
def group_member_remove(connection, name, user):
|
def group_member_remove(connection, name, user):
|
||||||
"""Take the user out of the group; the last member cannot leave."""
|
"""Take the user out of the group; the last member cannot leave. Leaving ADMIN_GROUP switches the user's console
|
||||||
|
login off."""
|
||||||
check_name(user)
|
check_name(user)
|
||||||
members = member_names(group_entry(connection, name))
|
members = member_names(group_entry(connection, name))
|
||||||
if user not in members:
|
if user not in members:
|
||||||
@@ -615,6 +744,8 @@ def group_member_remove(connection, name, user):
|
|||||||
if members == [user]:
|
if members == [user]:
|
||||||
raise Failure(f'user {user} is the last member of group {name}; remove the group instead', 2)
|
raise Failure(f'user {user} is the last member of group {name}; remove the group instead', 2)
|
||||||
connection.modify_s(group_dn(name), [(ldap.MOD_DELETE, 'member', [dn_of(user).encode()])])
|
connection.modify_s(group_dn(name), [(ldap.MOD_DELETE, 'member', [dn_of(user).encode()])])
|
||||||
|
if name == ADMIN_GROUP:
|
||||||
|
console_off(connection, [user])
|
||||||
return group_summary(group_entry(connection, name))
|
return group_summary(group_entry(connection, name))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ Pages:
|
|||||||
/pki/<name>.crt, /pki/<name>.crl
|
/pki/<name>.crt, /pki/<name>.crl
|
||||||
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 web.shell_user on a terminal: POST signs the
|
/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
|
user in again 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())
|
||||||
@@ -50,7 +51,8 @@ with vpnrouter_auth.WEB_KEY (see vpnrouter_auth.verify). A token of /auth names
|
|||||||
/account serves that user, and the other paths need a member of vpnrouter_users.ADMIN_GROUP. A
|
/account serves that user, and the other paths need a member of vpnrouter_users.ADMIN_GROUP. A
|
||||||
token of vpn-router-token has the admin claim and names no user; it may call every path but
|
token of vpn-router-token has the admin claim and names no user; it may call every path but
|
||||||
/account, and only such a token may change ADMIN_GROUP: create, rename to it, change, remove it,
|
/account, and only such a token may change ADMIN_GROUP: create, rename to it, change, remove it,
|
||||||
add or take out its members, and remove one of its members. These paths and /auth are not
|
add or take out its members, and remove one of its members, and switch a user's console login.
|
||||||
|
These paths and /auth are not
|
||||||
served (404) while the key does not exist. /auth is limited to AUTH_LIMIT calls per client
|
served (404) while the key does not exist. /auth is limited to AUTH_LIMIT calls per client
|
||||||
address in AUTH_WINDOW seconds; the address of a call from one of web.trusted_proxies, by
|
address in AUTH_WINDOW seconds; the address of a call from one of web.trusted_proxies, by
|
||||||
default the address the call came in on, is the last entry of its X-Forwarded-For header.
|
default the address the call came in on, is the last entry of its X-Forwarded-For header.
|
||||||
@@ -512,6 +514,8 @@ def users(state, method, path, body):
|
|||||||
POST /users/<name>/otp a new TOTP secret and its otpauth URI; nothing is stored
|
POST /users/<name>/otp a new TOTP secret and its otpauth URI; nothing is stored
|
||||||
PUT /users/<name>/otp store a TOTP secret: {"secret", "code"}, code a code of the secret
|
PUT /users/<name>/otp store a TOTP secret: {"secret", "code"}, code a code of the secret
|
||||||
DELETE /users/<name>/otp remove the TOTP token
|
DELETE /users/<name>/otp remove the TOTP token
|
||||||
|
PUT /users/<name>/console switch console login on or off: {"enabled", "account"?}, account a local account,
|
||||||
|
without which the user's own posixAccount; only a token of vpn-router-token
|
||||||
|
|
||||||
Service accounts are served by /service-accounts: they are not listed, and a request naming one is 404.
|
Service accounts are served by /service-accounts: they are not listed, and a request naming one is 404.
|
||||||
"""
|
"""
|
||||||
@@ -535,6 +539,7 @@ def users(state, method, path, body):
|
|||||||
('POST', 3, 'otp'): lambda connection: vpnrouter_users.otp_new(connection, name),
|
('POST', 3, 'otp'): lambda connection: vpnrouter_users.otp_new(connection, name),
|
||||||
('PUT', 3, 'otp'): lambda connection: vpnrouter_users.otp_enrol(connection, name, body.get('secret'), body.get('code')),
|
('PUT', 3, 'otp'): lambda connection: vpnrouter_users.otp_enrol(connection, name, body.get('secret'), body.get('code')),
|
||||||
('DELETE', 3, 'otp'): lambda connection: vpnrouter_users.otp_remove(connection, name),
|
('DELETE', 3, 'otp'): lambda connection: vpnrouter_users.otp_remove(connection, name),
|
||||||
|
('PUT', 3, 'console'): lambda connection: vpnrouter_users.console(connection, name, body.get('enabled'), body.get('account')),
|
||||||
}
|
}
|
||||||
# The vpnrouter_users operation of each route that may revoke refresh tokens.
|
# The vpnrouter_users operation of each route that may revoke refresh tokens.
|
||||||
operations = {('PUT', 3, 'password'): 'passwd', ('POST', 3, 'disable'): 'disable', ('POST', 3, 'unlock'): 'unlock',
|
operations = {('PUT', 3, 'password'): 'passwd', ('POST', 3, 'disable'): 'disable', ('POST', 3, 'unlock'): 'unlock',
|
||||||
@@ -979,8 +984,11 @@ def refusal(claims, method, path, body):
|
|||||||
with vpnrouter_users.session() as connection:
|
with vpnrouter_users.session() as connection:
|
||||||
if not vpnrouter_users.is_admin(connection, name):
|
if not vpnrouter_users.is_admin(connection, name):
|
||||||
return f'user {name} is not a member of {admins}'
|
return f'user {name} is not a member of {admins}'
|
||||||
if changes_admins(connection, method, path.strip('/').split('/'), body if isinstance(body, dict) else {}):
|
parts = path.strip('/').split('/')
|
||||||
|
if changes_admins(connection, method, parts, body if isinstance(body, dict) else {}):
|
||||||
return f'only a token of vpn-router-token may change the group {admins} or remove its members'
|
return f'only a token of vpn-router-token may change the group {admins} or remove its members'
|
||||||
|
if parts[0] == 'users' and len(parts) == 3 and parts[2] == 'console' and method != 'GET':
|
||||||
|
return 'only a token of vpn-router-token may change console login'
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
|
||||||
@@ -1026,7 +1034,7 @@ def shell_ticket(name, body):
|
|||||||
|
|
||||||
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. 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.
|
same user within SHELL_TICKET_SECONDS. 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'}
|
||||||
@@ -1035,6 +1043,8 @@ def shell_ticket(name, body):
|
|||||||
try:
|
try:
|
||||||
if not vpnrouter.load_config()['web']['shell']:
|
if not vpnrouter.load_config()['web']['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:
|
||||||
|
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'))
|
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:
|
||||||
@@ -1053,6 +1063,13 @@ def shell_ticket(name, body):
|
|||||||
return 200, {'ticket': ticket, 'expires_in': SHELL_TICKET_SECONDS}
|
return 200, {'ticket': ticket, 'expires_in': SHELL_TICKET_SECONDS}
|
||||||
|
|
||||||
|
|
||||||
|
def console_account(name):
|
||||||
|
"""The system account the console of the user runs as, or None while its console login is not on."""
|
||||||
|
with vpnrouter_users.session() as connection:
|
||||||
|
console = vpnrouter_users.summary(vpnrouter_users.entry(connection, name))['console']
|
||||||
|
return console['account'] if console and console['enabled'] else None
|
||||||
|
|
||||||
|
|
||||||
def take_ticket(name, ticket):
|
def take_ticket(name, ticket):
|
||||||
"""Whether the ticket of POST /shell is unexpired and was given to the user; it is used up either way."""
|
"""Whether the ticket of POST /shell is unexpired and was given to the user; it is used up either way."""
|
||||||
with _shell_lock:
|
with _shell_lock:
|
||||||
@@ -1170,7 +1187,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return opcode, payload
|
return opcode, payload
|
||||||
|
|
||||||
def shell(self, name):
|
def shell(self, name):
|
||||||
"""Answer GET /shell?ticket=<ticket>, a WebSocket handshake, with a login shell of web.shell_user on a terminal.
|
"""Answer GET /shell?ticket=<ticket>, a WebSocket handshake, with a login shell of the user's console account
|
||||||
|
(vpnrouter_users.console) on a terminal.
|
||||||
|
|
||||||
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
|
||||||
@@ -1199,9 +1217,15 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if not options['shell']:
|
if not options['shell']:
|
||||||
return refuse(403, 'the shell is disabled; vpn-config-edit set web.shell true enables it')
|
return refuse(403, 'the shell is disabled; vpn-config-edit set web.shell true enables it')
|
||||||
try:
|
try:
|
||||||
account = pwd.getpwnam(options['shell_user'])
|
login = console_account(name)
|
||||||
|
except (vpnrouter_users.Failure, ldap.LDAPError, OSError) as exc:
|
||||||
|
return refuse(500, f'the user directory failed: {exc}')
|
||||||
|
if login is None:
|
||||||
|
return refuse(403, f'console login is not enabled for {name}')
|
||||||
|
try:
|
||||||
|
account = pwd.getpwnam(login)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return refuse(500, f'the system user {options["shell_user"]} of web.shell_user does not exist')
|
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)}'
|
||||||
master, terminal = os.openpty()
|
master, terminal = os.openpty()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ user exists, 1 anything else. -h or --help shows the commands and their options.
|
|||||||
vpn-router-user [--json] otp-new <name>
|
vpn-router-user [--json] otp-new <name>
|
||||||
vpn-router-user [--json] otp-enrol <name> <code> (secret on stdin)
|
vpn-router-user [--json] otp-enrol <name> <code> (secret on stdin)
|
||||||
vpn-router-user [--json] otp-remove <name>
|
vpn-router-user [--json] otp-remove <name>
|
||||||
|
vpn-router-user [--json] console <name> (--posix | --account LOCAL | --off)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -56,6 +57,8 @@ HELP = {
|
|||||||
'otp-new': 'make a TOTP secret and its otpauth URI; nothing is stored',
|
'otp-new': 'make a TOTP secret and its otpauth URI; nothing is stored',
|
||||||
'otp-enrol': 'store the TOTP secret read from standard input, when the code is a code of it',
|
'otp-enrol': 'store the TOTP secret read from standard input, when the code is a code of it',
|
||||||
'otp-remove': 'remove the TOTP token',
|
'otp-remove': 'remove the TOTP token',
|
||||||
|
'console': "switch the console login of a member of admins on, as the user's own POSIX account in the directory "
|
||||||
|
'or as a local account, or off',
|
||||||
}
|
}
|
||||||
|
|
||||||
#: The help of the person options.
|
#: The help of the person options.
|
||||||
@@ -86,6 +89,11 @@ def parse(argv):
|
|||||||
sub.add_argument('--service', action='store_true', help='add a service account, which cannot sign in to the web service')
|
sub.add_argument('--service', action='store_true', help='add a service account, which cannot sign in to the web service')
|
||||||
if command == 'otp-enrol':
|
if command == 'otp-enrol':
|
||||||
sub.add_argument('code', help='a code of the secret from the authenticator')
|
sub.add_argument('code', help='a code of the secret from the authenticator')
|
||||||
|
if command == 'console':
|
||||||
|
choice = sub.add_mutually_exclusive_group(required=True)
|
||||||
|
choice.add_argument('--posix', action='store_true', help="run the console as the user's own POSIX account")
|
||||||
|
choice.add_argument('--account', metavar='LOCAL', help='run the console as this local account')
|
||||||
|
choice.add_argument('--off', action='store_true', help='switch console login off')
|
||||||
return parser.parse_args(argv)
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
@@ -108,9 +116,18 @@ def run(connection, options, secret):
|
|||||||
return vpnrouter_users.passwd(connection, options.name, secret)
|
return vpnrouter_users.passwd(connection, options.name, secret)
|
||||||
if command == 'otp-enrol':
|
if command == 'otp-enrol':
|
||||||
return vpnrouter_users.otp_enrol(connection, options.name, secret, options.code)
|
return vpnrouter_users.otp_enrol(connection, options.name, secret, options.code)
|
||||||
|
if command == 'console':
|
||||||
|
return vpnrouter_users.console(connection, options.name, not options.off, options.account)
|
||||||
return getattr(vpnrouter_users, command.replace('-', '_'))(connection, options.name)
|
return getattr(vpnrouter_users, command.replace('-', '_'))(connection, options.name)
|
||||||
|
|
||||||
|
|
||||||
|
def console_text(console):
|
||||||
|
"""The console login of a user as a cell: -, off, posix (uid N) or local and the account."""
|
||||||
|
if console is None or not console['enabled']:
|
||||||
|
return '-' if console is None else 'off'
|
||||||
|
return f'local {console["account"]}' if console['local'] else f'posix (uid {console["uid"]})'
|
||||||
|
|
||||||
|
|
||||||
def text_of(answer):
|
def text_of(answer):
|
||||||
"""The answer as text for people."""
|
"""The answer as text for people."""
|
||||||
if 'profile' in answer:
|
if 'profile' in answer:
|
||||||
@@ -122,11 +139,12 @@ def text_of(answer):
|
|||||||
users = answer['users'] if 'users' in answer else [answer]
|
users = answer['users'] if 'users' in answer else [answer]
|
||||||
if not users:
|
if not users:
|
||||||
return 'no users'
|
return 'no users'
|
||||||
head = ('USER', 'KIND', 'STATE', 'CERTIFICATE', 'OTP', 'FIRST NAME', 'LAST NAME', 'E-MAIL')
|
head = ('USER', 'KIND', 'STATE', 'CERTIFICATE', 'OTP', 'CONSOLE', 'FIRST NAME', 'LAST NAME', 'E-MAIL')
|
||||||
rows = [(user['name'], 'internal' if user['internal'] else 'service' if user['service'] else 'manager' if user['manager_only'] else 'vpn',
|
rows = [(user['name'], 'internal' if user['internal'] else 'service' if user['service'] else 'manager' if user['manager_only'] else 'vpn',
|
||||||
'disabled' if not user['enabled'] else 'locked' if user['locked'] else 'enabled',
|
'disabled' if not user['enabled'] else 'locked' if user['locked'] else 'enabled',
|
||||||
'issued' if user['certificate'] else 'missing',
|
'issued' if user['certificate'] else 'missing',
|
||||||
'enrolled' if user['otp'] else '-', user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-')
|
'enrolled' if user['otp'] else '-', console_text(user['console']),
|
||||||
|
user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-')
|
||||||
for user in users]
|
for user in users]
|
||||||
widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))]
|
widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))]
|
||||||
return '\n'.join(' '.join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip() for row in (head, *rows))
|
return '\n'.join(' '.join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip() for row in (head, *rows))
|
||||||
|
|||||||
@@ -63,9 +63,11 @@
|
|||||||
"trusted_proxies": [],
|
"trusted_proxies": [],
|
||||||
"status_content": "basic",
|
"status_content": "basic",
|
||||||
"shell": false,
|
"shell": false,
|
||||||
"shell_user": "",
|
|
||||||
"shell_idle_minutes": 15
|
"shell_idle_minutes": 15
|
||||||
},
|
},
|
||||||
|
"ssh": {
|
||||||
|
"ldap_login": false
|
||||||
|
},
|
||||||
"https": {
|
"https": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"email": "",
|
"email": "",
|
||||||
|
|||||||
Reference in new issue
Block a user