From ec93bc6a2d4b9d2ed036ab417308c9474ae6a189 Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Mon, 26 Oct 2026 00:21:13 +0100 Subject: [PATCH] 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. --- ADR.md | 40 ++++- README.md | 56 +++++-- debian-package/debian/changelog | 18 +++ debian-package/debian/control | 4 + debian-package/debian/postrm | 4 + debian-package/src/usr/lib/vpn-router/setup | 97 ++++++++++++ .../src/usr/lib/vpn-router/vpnrouter.py | 11 +- .../src/usr/lib/vpn-router/vpnrouter_ldap.py | 42 ++++- .../src/usr/lib/vpn-router/vpnrouter_users.py | 147 +++++++++++++++++- debian-package/src/usr/lib/vpn-router/web | 38 ++++- debian-package/src/usr/sbin/vpn-router-user | 22 ++- .../doc/vpn-router/vpn-router.json.example | 4 +- 12 files changed, 432 insertions(+), 51 deletions(-) diff --git a/ADR.md b/ADR.md index 719e579..e47cc4f 100644 --- a/ADR.md +++ b/ADR.md @@ -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. 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 -reaches root through its sudoers entry. `POST /shell` signs the user in again with the password +Manager cannot set it. The shell runs as the console account of the signed-in user (ADR-34), so +it is never root and each administrator's shell is the administrator's own; a user without +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 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 @@ -917,7 +918,34 @@ Manager checks the session and the WebSocket's origin, and relays the bytes to t both handshakes. 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 -and appears in request logs, used up. The shell's sessions show up as `vpn-router-shell-` -units and logind sessions of `web.shell_user`. The shell's traffic is as protected as the Router -Manager's: HTTPS to Caddy, plain HTTP on the internal network to the web service. +can get a shell when the router allows one and the user's console login is on; a session alone +cannot. The ticket travels in the URL and appears in request logs, used up. The shell's sessions +show up as `vpn-router-shell-` units and logind sessions of the console account. The shell's +traffic is as protected as the Router Manager's: HTTPS to Caddy, plain HTTP on the internal +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`. diff --git a/README.md b/README.md index fb55e90..e2f7fc0 100644 --- a/README.md +++ b/README.md @@ -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` | `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` | `shell` | boolean | `false` | `/shell` opens a login shell of `shell_user` for a member of `admins` (see The shell) | -| `web` | `shell_user` | string | | The system user the shell runs as, not `root`; required by `shell` | +| `web` | `shell` | boolean | `false` | `/shell` opens a login shell for a member of `admins` with console login (see The shell) | | `web` | `shell_idle_minutes` | integer | `15` | Minutes without a keystroke after which a shell is closed, 1 to 1440 | +| `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` | `email` | string | | The e-mail address of the Let's Encrypt account | | `https` | `web_port` | integer | `7444` | HTTPS port of the status web service | @@ -705,6 +705,7 @@ vpn-router-user otp-remove alice | `otp-new ` | A new TOTP secret and its `otpauth://` URI, issued by `wan.local_fqdn`, for an authenticator app; nothing is stored | | `otp-enrol ` | Stores the secret read from standard input when the code is a current code of it, replacing a secret the user has | | `otp-remove ` | Removes the user's TOTP secret | +| `console (--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 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 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/` 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 `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 -While `web.shell` is `true`, `/shell` gives a member of `admins` a login shell on the router, as -the system user `web.shell_user`, for the Router Manager's Shell page. It is off by default and -switched on and off on the router only: +While `web.shell` is `true`, `/shell` gives a member of `admins` whose console login is on (see +Console login and SSH) a login shell on the router, as the user's console account, for the +Router Manager's Shell page. It is off by default and switched on and off on the router only: ```sh -vpn-config-edit set web.shell_user azureuser +vpn-router-user console alice --posix vpn-config-edit set web.shell true ``` @@ -943,12 +976,12 @@ vpn-config-edit set web.shell true | `GET /shell?ticket=` | a WebSocket handshake | the terminal | `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 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 -by `systemd-run` in a transient unit `vpn-router-shell-`; sudo works as the user's sudoers -entry allows. It is closed after `web.shell_idle_minutes` without a keystroke, when the shell +the router are the terminal's output. The shell is a PAM login session of the console account, +run by `systemd-run` in a transient unit `vpn-router-shell-`; sudo works as for SSH. 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 journal of `vpn-router-web` records who opened each shell, from which address, in which unit, 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 | | --- | --- | --- | -| `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 | | `PUT /users/` | any of `{"first_name", "last_name", "email"}`; for a Manager-only account only `{"email"}`, `""` removing it | the user | | `PUT /users//password` | `{"password": ...}` | the user | @@ -996,6 +1029,7 @@ and answer bodies are JSON; a request body is at most 65536 bytes. | `POST /users//otp` | | `{"name", "secret", "uri"}`, a new TOTP secret; nothing is stored | | `PUT /users//otp` | `{"secret", "code"}` | the user, once the code matches the secret | | `DELETE /users//otp` | | the user | +| `PUT /users//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 exist or is a service account, 409 for a user that exists and 500 otherwise. `/users` does not diff --git a/debian-package/debian/changelog b/debian-package/debian/changelog index d50eb9d..d6e39c7 100644 --- a/debian-package/debian/changelog +++ b/debian-package/debian/changelog @@ -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 --posix | --account LOCAL | --off, and + PUT /users//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 Mon, 02 Nov 2026 21:00:00 +0100 + 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. diff --git a/debian-package/debian/control b/debian-package/debian/control index 450c979..aa3fc78 100644 --- a/debian-package/debian/control +++ b/debian-package/debian/control @@ -27,6 +27,10 @@ Depends: ${misc:Depends}, python3-prometheus-client, postgresql, slapd, + nslcd, + libnss-ldapd, + libpam-ldapd, + sudo, openvpn, openvpn-auth-ldap, iproute2, diff --git a/debian-package/debian/postrm b/debian-package/debian/postrm index c0ad1c2..e20e2bd 100755 --- a/debian-package/debian/postrm +++ b/debian-package/debian/postrm @@ -54,9 +54,13 @@ case "$1" in /etc/vpn-router/web.key \ /etc/vpn-router/metrics.secret \ /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/backup.json 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 \ /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 diff --git a/debian-package/src/usr/lib/vpn-router/setup b/debian-package/src/usr/lib/vpn-router/setup index 5b5fc03..1cc938e 100755 --- a/debian-package/src/usr/lib/vpn-router/setup +++ b/debian-package/src/usr/lib/vpn-router/setup @@ -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. 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' #: Configuration options left out wherever the configuration is shown. @@ -529,6 +538,90 @@ def manage_metrics_account(ctx): 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): """Run the OpenVPN server when it is configured, and stop it when it is not.""" unit = 'openvpn-server@vpn-router' @@ -796,6 +889,10 @@ def main(): failed_step = 'metrics service account' progress('running', failed_step) problem = manage_metrics_account(ctx) + if not problem: + failed_step = 'console login' + progress('running', failed_step) + problem = manage_console(cfg) progress('running', 'OpenVPN') manage_openvpn(ctx, changed) progress('running', 'HTTPS proxy') diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter.py b/debian-package/src/usr/lib/vpn-router/vpnrouter.py index 5319b46..3341ad4 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter.py @@ -52,7 +52,8 @@ SCHEMA = { 'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'}, 'docker': {'mode': 'auto', 'exposed_ports': []}, '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'}, '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', @@ -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. 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. SHELL_IDLE_MAX = 1440 @@ -483,11 +481,6 @@ def validate(cfg): raise ConfigError( 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') 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}') diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py index cdde4ab..2563660 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py @@ -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. 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 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 URI = 'ldapi:///' +#: The local socket ldapi:/// names, which slapd listens on. +SLAPD_SOCKET = '/var/run/slapd/ldapi' BASE = 'dc=vpn-router,dc=local' USERS = f'ou=users,{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 #: 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, -#: authorization scopes, of which service marks a service account, and displayName and -#: description, the labels of a service account. vpnRouterAuthority is a CA of the PKI: its +#: authorization scopes, of which service marks a service account, displayName and +#: 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 #: 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 @@ -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.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.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 = ( 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 " "MAY ( cACertificate $ certificateRevocationList $ vpnRouterPrivateKey $ vpnRouterCertificateRequest $ " "vpnRouterCRLNumber $ vpnRouterCRLSource $ vpnRouterRetired ) )", @@ -103,6 +116,16 @@ TOTP_ALGORITHM = '1.2.840.113549.2.7' TOTP_PERIOD = 30 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. ACCESS = ( 'to attrs=vpnRouterPrivateKey by * none', @@ -189,8 +212,9 @@ def _ensure_password_hash(connection): def _ensure_database(connection): - """The DN of the package's database in cn=config, created when missing; its ACLs are kept as ACCESS.""" - found = connection.search_s('cn=config', ldap.SCOPE_ONELEVEL, f'(olcSuffix={BASE})', ['olcAccess']) + """The DN of the package's database in cn=config, created when missing; its ACLs are kept as ACCESS, and the + 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: DB_DIRECTORY.mkdir(mode=0o700, parents=True, exist_ok=True) openldap = pwd.getpwnam('openldap') @@ -201,13 +225,17 @@ def _ensure_database(connection): 'olcSuffix': [BASE.encode()], 'olcDbDirectory': [str(DB_DIRECTORY).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)], })) - 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] 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)])]) + 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 diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py index fb47f4c..7e84d88 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py @@ -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 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 -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. 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 contextlib import fcntl +import grp +import pwd import re +import subprocess import time import ldap @@ -69,7 +77,8 @@ PERSON = (('first_name', 'givenName'), ('last_name', 'sn'), ('email', 'mail')) LABELS = (('display_name', 'displayName'), ('description', 'description')) #: The attributes read for a user. 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. SERVICE_SCOPE = 'service' #: 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, 'certificate': 'userCertificate;binary' in attributes, 'otp': 'oathTOTPToken' in attributes, + 'console': console_of(attributes), } for key, attribute in PERSON + LABELS: values = attributes.get(attribute) @@ -241,6 +251,116 @@ def summary(attributes): 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): """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) @@ -441,11 +561,12 @@ def otp_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. """ - entry(connection, name) + current = entry(connection, name) groups = groups_of(connection, name) alone = sorted(group for group, members in groups.items() if members == [name]) if alone: @@ -457,6 +578,7 @@ def remove(connection, name): except vpnrouter_pki.Failure as failure: raise Failure(f'the certificate cannot be revoked: {failure}', failure.status) connection.delete_s(dn_of(name)) + end_sessions(current) 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): - """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: 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: description = check_description(description) 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) except ldap.ALREADY_EXISTS: raise Failure(f'group {new_name} exists', 4) + if name == ADMIN_GROUP: + console_off(connection, members) name = new_name return group_summary(group_entry(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)) + if name == ADMIN_GROUP: + console_off(connection, members) return {'name': name, 'removed': True} @@ -607,7 +735,8 @@ def group_member_add(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) members = member_names(group_entry(connection, name)) if user not in members: @@ -615,6 +744,8 @@ def group_member_remove(connection, name, user): if members == [user]: 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()])]) + if name == ADMIN_GROUP: + console_off(connection, [user]) return group_summary(group_entry(connection, name)) diff --git a/debian-package/src/usr/lib/vpn-router/web b/debian-package/src/usr/lib/vpn-router/web index 5755bfd..99bb0cb 100644 --- a/debian-package/src/usr/lib/vpn-router/web +++ b/debian-package/src/usr/lib/vpn-router/web @@ -34,7 +34,8 @@ Pages: /pki/.crt, /pki/.crl 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 - /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 that carries the terminal (see Handler.shell()) /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 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, -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 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. @@ -512,6 +514,8 @@ def users(state, method, path, body): POST /users//otp a new TOTP secret and its otpauth URI; nothing is stored PUT /users//otp store a TOTP secret: {"secret", "code"}, code a code of the secret DELETE /users//otp remove the TOTP token + PUT /users//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. """ @@ -535,6 +539,7 @@ def users(state, method, path, body): ('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')), ('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. 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: if not vpnrouter_users.is_admin(connection, name): 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' + 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 '' @@ -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 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): return 403, {'error': 'the shell needs a token of a signed-in user'} @@ -1035,6 +1043,8 @@ def shell_ticket(name, body): try: if not vpnrouter.load_config()['web']['shell']: 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')) except vpnrouter_users.Failure as failure: if failure.status == 5: @@ -1053,6 +1063,13 @@ def shell_ticket(name, body): 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): """Whether the ticket of POST /shell is unexpired and was given to the user; it is used up either way.""" with _shell_lock: @@ -1170,7 +1187,8 @@ class Handler(BaseHTTPRequestHandler): return opcode, payload def shell(self, name): - """Answer GET /shell?ticket=, a WebSocket handshake, with a login shell of web.shell_user on a terminal. + """Answer GET /shell?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 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']: return refuse(403, 'the shell is disabled; vpn-config-edit set web.shell true enables it') 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: - 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)}' master, terminal = os.openpty() try: diff --git a/debian-package/src/usr/sbin/vpn-router-user b/debian-package/src/usr/sbin/vpn-router-user index 345a311..a738f66 100755 --- a/debian-package/src/usr/sbin/vpn-router-user +++ b/debian-package/src/usr/sbin/vpn-router-user @@ -24,6 +24,7 @@ user exists, 1 anything else. -h or --help shows the commands and their options. vpn-router-user [--json] otp-new vpn-router-user [--json] otp-enrol (secret on stdin) vpn-router-user [--json] otp-remove + vpn-router-user [--json] console (--posix | --account LOCAL | --off) """ import argparse @@ -56,6 +57,8 @@ HELP = { '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-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. @@ -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') if command == 'otp-enrol': 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) @@ -108,9 +116,18 @@ def run(connection, options, secret): return vpnrouter_users.passwd(connection, options.name, secret) if command == 'otp-enrol': 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) +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): """The answer as text for people.""" if 'profile' in answer: @@ -122,11 +139,12 @@ def text_of(answer): users = answer['users'] if 'users' in answer else [answer] if not 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', 'disabled' if not user['enabled'] else 'locked' if user['locked'] else 'enabled', '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] 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)) diff --git a/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example b/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example index f005953..c976261 100644 --- a/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example +++ b/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example @@ -63,9 +63,11 @@ "trusted_proxies": [], "status_content": "basic", "shell": false, - "shell_user": "", "shell_idle_minutes": 15 }, + "ssh": { + "ldap_login": false + }, "https": { "enabled": true, "email": "",