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

This commit is contained in:
slawek committed 2026-10-26 00:21:13 +01:00
1 parent 2e1eb89f52
commit ec93bc6a2d
12 files changed
+432 -51

No files matched your search

+34 -6
View File
@@ -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`.
+45 -11
View File
@@ -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
+18
View File
@@ -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.
+4
View File
@@ -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,
+4
View File
@@ -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))
+31 -7
View File
@@ -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:
+20 -2
View File
@@ -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": "",