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

@@ -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))