1062 lines
53 KiB
Python
1062 lines
53 KiB
Python
"""The users and groups of the router's directory, for vpn-router-user, vpn-router-group and the web service.
|
|
|
|
A user is an entry in ou=users of the router's directory, of one of two kinds, fixed when it is
|
|
added. A VPN user is an inetOrgPerson with a first name, a last name and an e-mail address, whose
|
|
password OpenVPN checks, and a client certificate of the P2S issuing CA (vpnrouter_pki) with the
|
|
user name as its common name, which OpenVPN and IKEv2 accept; the certificate is the entry's
|
|
userCertificate, and the PKI keeps it with its key. A Manager-only account is an account,
|
|
simpleSecurityObject and vpnRouterAccount (the package's schema) with a name, a password and
|
|
optionally an e-mail address, no names, no certificate and so no VPN access; it is added when
|
|
neither the first nor the last name is given, and can sign in to the web service. A service
|
|
account is a Manager-only account with the scope SERVICE_SCOPE in vpnRouterScope, which another
|
|
system signs in with to use a router service, and which cannot sign in to the web service; it has
|
|
a display name and a description (labels), in displayName and description of vpnRouterAccount. The
|
|
internal service accounts are those the router creates and manages for its own services, labels
|
|
included (internal_accounts); vpn-router-setup keeps the metrics service's.
|
|
Users exist whether or not a P2S system is enabled.
|
|
|
|
A service account with redirect URIs is a client of the OpenID Provider (vpnrouter_oidc), its name the client ID and
|
|
its password the client secret (client_settings): redirect_uris, where the provider sends a user back after a sign-in,
|
|
post_logout_redirect_uris, after a sign-out, and allowed_groups, the groups whose members may sign in to it, every
|
|
user who is not a service account when there are none.
|
|
|
|
A group is a groupOfNames in ou=groups with a name, which follows the rules of user names, an
|
|
optional description and at least one member; a group cannot be empty, so the last member
|
|
cannot leave it and a user who is the last member of a group cannot be removed. ADMIN_GROUP grants the management of
|
|
the router, the allowed groups of the OpenID Provider's clients who may sign in to them, and the editors group of a web
|
|
site who may change its files (vpnrouter_sites); renaming a group renames it in those, and a group a client allows or a
|
|
site has cannot be removed.
|
|
|
|
A VPN user may have WireGuard peers, each a device with its public key and its IPs, in
|
|
vpnRouterWireGuardPeer; vpn-router-setup writes a [Peer] of wg0.conf for each peer of a user who
|
|
is not disabled.
|
|
|
|
A user may have a TOTP token: the user entry itself is an oathTOTPUser and oathTOTPToken whose
|
|
oathTOTPToken points to itself, with the secret in oathSecret and the parameters of
|
|
vpnrouter_ldap.TOTP_PARAMS. Enrolment stores nothing until a code of the new secret is
|
|
verified: otp_new makes a secret and its otpauth URI, and otp_enrol stores the secret given a
|
|
code of it. slapd's otp overlay then takes the password followed by a code at every bind.
|
|
|
|
A user who is not a service account may have passkeys for signing in to the Router Manager, in
|
|
vpnRouterPasskey (vpnrouter_passkeys registers and checks them).
|
|
|
|
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.
|
|
|
|
A member of ADMIN_GROUP with a TOTP token 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; without the
|
|
token the console is refused, also for one whose console login was switched on before. 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
|
|
lock too, and the connection of the user manager account. A request that cannot be done raises Failure, whose
|
|
status says why: 2 the request is invalid, 3 the user does not exist, 4 the user exists, 5 a
|
|
login failed, 1 anything else. Answers are dicts with the keys of the JSON answers.
|
|
"""
|
|
|
|
import base64
|
|
import calendar
|
|
import contextlib
|
|
import fcntl
|
|
import grp
|
|
import ipaddress
|
|
import json
|
|
import plistlib
|
|
import pwd
|
|
import re
|
|
import subprocess
|
|
import time
|
|
import urllib.parse
|
|
import uuid
|
|
|
|
import ldap
|
|
import ldap.filter
|
|
import ldap.modlist
|
|
import pyotp
|
|
from cryptography import x509
|
|
from cryptography.hazmat.primitives import hashes, serialization
|
|
from cryptography.hazmat.primitives.asymmetric import ec, rsa
|
|
from cryptography.hazmat.primitives.serialization import pkcs12
|
|
|
|
import vpnrouter
|
|
import vpnrouter_ldap
|
|
import vpnrouter_pki
|
|
|
|
#: A user name is one lowercase DNS label, as it is also the common name of the certificate.
|
|
NAME = re.compile(r'^[a-z][a-z0-9-]{0,30}[a-z0-9]$|^[a-z]$')
|
|
#: The other name of a service account: a GUID in lowercase hexadecimal, as the Router Manager proposes one.
|
|
GUID = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')
|
|
PASSWORD_MIN = 8
|
|
PASSWORD_MAX = 128
|
|
#: The longest first or last name, in characters.
|
|
PERSON_NAME_MAX = 64
|
|
#: An e-mail address: one @, no spaces, and a dot in the domain.
|
|
EMAIL = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
|
|
EMAIL_MAX = 254
|
|
#: The keys of the person attributes and their LDAP attributes.
|
|
PERSON = (('first_name', 'givenName'), ('last_name', 'sn'), ('email', 'mail'))
|
|
#: The keys of the labels of a service account and their LDAP attributes.
|
|
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', 'vpnRouterConsoleLogin', 'vpnRouterConsoleAccount',
|
|
'uidNumber', 'vpnRouterWireGuardPeer', 'vpnRouterPasskey', 'vpnRouterRedirectUri',
|
|
'vpnRouterPostLogoutRedirectUri', 'vpnRouterAllowedGroup']
|
|
#: The keys of the URIs of an OpenID Provider client and their LDAP attributes.
|
|
CLIENT_URIS = (('redirect_uris', 'vpnRouterRedirectUri'), ('post_logout_redirect_uris', 'vpnRouterPostLogoutRedirectUri'))
|
|
#: The most URIs of each kind and allowed groups of one client, and the longest URI.
|
|
CLIENT_VALUES_MAX = 16
|
|
URI_MAX = 1024
|
|
#: The hosts a client's URI may name over plain HTTP: those of the system it runs on.
|
|
LOOPBACK_HOSTS = ('localhost', '127.0.0.1', '::1')
|
|
#: The most WireGuard peers of one user.
|
|
WIREGUARD_PEERS_MAX = 16
|
|
#: The vpnRouterScope of a service account.
|
|
SERVICE_SCOPE = 'service'
|
|
#: The object classes of a VPN user and of a Manager-only account.
|
|
VPN_USER_CLASSES = [b'inetOrgPerson']
|
|
MANAGER_ONLY_CLASSES = [b'account', b'simpleSecurityObject', b'vpnRouterAccount']
|
|
#: The readable attributes of a TOTP token; oathSecret is never readable.
|
|
TOKEN_ATTRIBUTES = ['oathTOTPToken', 'oathTOTPParams', 'oathSecretTime', 'oathTOTPLastTimeStep', 'oathTOTPTimeStepDrift',
|
|
'oathLastLogin', 'oathLastFailure', 'oathFailureCount']
|
|
#: A TOTP secret as otp_new makes it: 32 base32 characters, 160 bits.
|
|
TOTP_SECRET = re.compile(r'^[A-Z2-7]{32}$')
|
|
#: The attributes read for a group.
|
|
GROUP_ATTRIBUTES = ['cn', 'description', 'member']
|
|
#: The longest group description, in characters.
|
|
DESCRIPTION_MAX = 256
|
|
#: The value of pwdAccountLockedTime that disables an account until it is enabled.
|
|
LOCKED = vpnrouter_ldap.DISABLED.encode()
|
|
#: The group whose members manage the router through the web service.
|
|
ADMIN_GROUP = 'admins'
|
|
#: The digits of a TOTP code given to login().
|
|
CODE = re.compile(r'^\d{6}$')
|
|
LOCK_FILE = vpnrouter_pki.LOCK_FILE
|
|
|
|
|
|
class Failure(Exception):
|
|
"""A request that cannot be done; status is 2 invalid, 3 no such user, 4 the user exists, 5 failed login, 1 other."""
|
|
|
|
def __init__(self, message, status=1):
|
|
super().__init__(message)
|
|
self.status = status
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def session():
|
|
"""The connection of the user manager account, used under the lock that serializes changes."""
|
|
LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(LOCK_FILE, 'w') as lock:
|
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
connection = vpnrouter_ldap.manager()
|
|
try:
|
|
yield connection
|
|
finally:
|
|
connection.unbind_s()
|
|
|
|
|
|
def check_name(name, service=True):
|
|
"""Refuse a name that is not a user name (NAME), or, with service, the GUID of a service account either."""
|
|
if not isinstance(name, str) or not (NAME.match(name) or service and GUID.match(name)):
|
|
raise Failure('a user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter'
|
|
+ (', or, for a service account, a GUID in lowercase' if service else ''), 2)
|
|
|
|
|
|
def check_password(value):
|
|
if not isinstance(value, str) or '\n' in value or '\r' in value:
|
|
raise Failure('the password must be a string of one line', 2)
|
|
if not PASSWORD_MIN <= len(value) <= PASSWORD_MAX:
|
|
raise Failure(f'the password must have {PASSWORD_MIN} to {PASSWORD_MAX} characters', 2)
|
|
|
|
|
|
def person(values, required):
|
|
"""The person attributes of values ({first_name, last_name, email}), checked, as {LDAP attribute: value}.
|
|
|
|
A key whose value is None is not given; with required, all three must be given.
|
|
"""
|
|
found = {}
|
|
for key, attribute in PERSON:
|
|
value = values.get(key)
|
|
if value is None:
|
|
if required:
|
|
raise Failure(f'{key} is required', 2)
|
|
continue
|
|
if not isinstance(value, str):
|
|
raise Failure(f'{key} must be a string', 2)
|
|
value = value.strip()
|
|
if key == 'email':
|
|
if len(value) > EMAIL_MAX or not EMAIL.match(value):
|
|
raise Failure(f'{value!r} is not an e-mail address', 2)
|
|
elif not value or len(value) > PERSON_NAME_MAX or any(not character.isprintable() for character in value):
|
|
raise Failure(f'the {key.replace("_", " ")} must have 1 to {PERSON_NAME_MAX} printable characters', 2)
|
|
found[attribute] = value
|
|
return found
|
|
|
|
|
|
def labels(values):
|
|
"""The labels of a service account in values ({display_name, description}), checked, as {LDAP attribute: value}.
|
|
|
|
A key whose value is None is not given; '' means none. A display name has at most PERSON_NAME_MAX printable
|
|
characters and a description at most DESCRIPTION_MAX.
|
|
"""
|
|
found = {}
|
|
name = values.get('display_name')
|
|
if name is not None:
|
|
if not isinstance(name, str):
|
|
raise Failure('display_name must be a string', 2)
|
|
name = name.strip()
|
|
if len(name) > PERSON_NAME_MAX or any(not character.isprintable() for character in name):
|
|
raise Failure(f'the display name must have at most {PERSON_NAME_MAX} printable characters', 2)
|
|
found['displayName'] = name
|
|
if values.get('description') is not None:
|
|
found['description'] = check_description(values['description'])
|
|
return found
|
|
|
|
|
|
def client_settings(connection, values):
|
|
"""The OpenID Provider client settings of a service account in values ({redirect_uris, post_logout_redirect_uris,
|
|
allowed_groups}), checked, as {LDAP attribute: [value, ...]}; a key whose value is None is not given, [] means none.
|
|
|
|
A URI is absolute, has no fragment and uses https, or http for a host of LOOPBACK_HOSTS; an allowed group exists. Each
|
|
list has at most CLIENT_VALUES_MAX values.
|
|
"""
|
|
found = {}
|
|
for key, attribute in CLIENT_URIS:
|
|
uris = values.get(key)
|
|
if uris is None:
|
|
continue
|
|
if not isinstance(uris, list) or len(uris) > CLIENT_VALUES_MAX or not all(isinstance(uri, str) for uri in uris):
|
|
raise Failure(f'{key} must be a list of at most {CLIENT_VALUES_MAX} URIs', 2)
|
|
for uri in uris:
|
|
try:
|
|
parts = urllib.parse.urlsplit(uri)
|
|
host = parts.hostname
|
|
except ValueError:
|
|
host = None
|
|
if len(uri) > URI_MAX or not host or parts.fragment or any(character.isspace() for character in uri) or \
|
|
not (parts.scheme == 'https' or parts.scheme == 'http' and host in LOOPBACK_HOSTS):
|
|
raise Failure(f'{uri!r} is not an https URI, or an http URI of localhost, without a fragment and of at '
|
|
f'most {URI_MAX} characters', 2)
|
|
found[attribute] = [uri.encode() for uri in dict.fromkeys(uris)]
|
|
allowed = values.get('allowed_groups')
|
|
if allowed is not None:
|
|
if not isinstance(allowed, list) or len(allowed) > CLIENT_VALUES_MAX:
|
|
raise Failure(f'allowed_groups must be a list of at most {CLIENT_VALUES_MAX} group names', 2)
|
|
for group in allowed:
|
|
group_entry(connection, group)
|
|
found['vpnRouterAllowedGroup'] = [group_dn(group).encode() for group in sorted(set(allowed))]
|
|
return found
|
|
|
|
|
|
def refuse_client_settings(name, values):
|
|
"""Refuse OpenID Provider client settings for a user who is not a service account."""
|
|
if any(values.get(key) is not None for key in ('redirect_uris', 'post_logout_redirect_uris', 'allowed_groups')):
|
|
raise Failure(f'{name} is not a service account; only a service account is a client of the OpenID Provider', 2)
|
|
|
|
|
|
def dn_of(name):
|
|
return f'uid={name},{vpnrouter_ldap.USERS}'
|
|
|
|
|
|
|
|
def entry(connection, name):
|
|
"""The attributes of the user's entry; Failure with status 3 when there is none."""
|
|
check_name(name)
|
|
try:
|
|
return connection.search_s(dn_of(name), ldap.SCOPE_BASE, attrlist=ATTRIBUTES)[0][1]
|
|
except ldap.NO_SUCH_OBJECT:
|
|
raise Failure(f'user {name} does not exist', 3)
|
|
|
|
|
|
def locked_since(value):
|
|
"""The time a pwdAccountLockedTime value names, in seconds since the epoch, or 0 when it names none."""
|
|
try:
|
|
return calendar.timegm(time.strptime(value[:14].decode(), '%Y%m%d%H%M%S'))
|
|
except ValueError:
|
|
return 0
|
|
|
|
|
|
def manager_only(attributes):
|
|
"""Whether the entry is a Manager-only account."""
|
|
return b'account' in (value.lower() for value in attributes.get('objectClass', []))
|
|
|
|
|
|
def service(attributes):
|
|
"""Whether the entry is a service account."""
|
|
return SERVICE_SCOPE.encode() in (value.lower() for value in attributes.get('vpnRouterScope', []))
|
|
|
|
|
|
def internal_accounts():
|
|
"""The internal service accounts, which the router creates and manages for its own services, by name, each with
|
|
the labels the router gives it ({display_name, description}): the one metrics.service_account names while metrics
|
|
are enabled."""
|
|
try:
|
|
metrics = vpnrouter.load_config()['metrics']
|
|
except SystemExit:
|
|
return {}
|
|
if not metrics['enabled']:
|
|
return {}
|
|
return {metrics['service_account']: {
|
|
'display_name': 'Metrics',
|
|
'description': 'The account an OpenTelemetry Collector signs in with to read /metrics of the metrics service.',
|
|
}}
|
|
|
|
|
|
def summary(attributes):
|
|
"""A user as the answers show it; a person attribute or a label the entry does not have is None.
|
|
|
|
internal is whether it is an internal service account (internal_accounts). A service account also has its OpenID
|
|
Provider client settings (client_settings), allowed_groups as group names.
|
|
"""
|
|
name = attributes['uid'][0].decode()
|
|
lock = attributes.get('pwdAccountLockedTime', [None])[0]
|
|
user = {
|
|
'name': name,
|
|
'manager_only': manager_only(attributes),
|
|
'service': service(attributes),
|
|
'internal': service(attributes) and name in internal_accounts(),
|
|
'enabled': lock != LOCKED,
|
|
'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),
|
|
'wireguard_peers': peers_of(attributes),
|
|
'passkeys': [{key: passkey[key] for key in ('id', 'label', 'created')} for _, passkey in passkeys_of(attributes)],
|
|
}
|
|
for key, attribute in PERSON + LABELS:
|
|
values = attributes.get(attribute)
|
|
user[key] = values[0].decode() if values else None
|
|
if user['service']:
|
|
for key, attribute in CLIENT_URIS:
|
|
user[key] = [value.decode() for value in attributes.get(attribute, [])]
|
|
suffix = f',{vpnrouter_ldap.GROUPS}'
|
|
user['allowed_groups'] = sorted(value.decode()[3:-len(suffix)] for value in attributes.get('vpnRouterAllowedGroup', [])
|
|
if value.decode().startswith('cn=') and value.decode().endswith(suffix))
|
|
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 peers_of(attributes):
|
|
"""The WireGuard peers of an entry as the answers show them: [{public_key, ips}], in the order they are stored."""
|
|
peers = []
|
|
for value in attributes.get('vpnRouterWireGuardPeer', []):
|
|
key, _, ips = value.decode().partition(' ')
|
|
peers.append({'public_key': key, 'ips': ips.split(',')})
|
|
return peers
|
|
|
|
|
|
def passkeys_of(attributes):
|
|
"""The passkeys of an entry (vpnrouter_passkeys) with their stored values: [(value, {id, credential, label,
|
|
created})]; values that cannot be read are left out."""
|
|
passkeys = []
|
|
for value in attributes.get('vpnRouterPasskey', []):
|
|
try:
|
|
passkey = json.loads(value)
|
|
except ValueError:
|
|
continue
|
|
if isinstance(passkey, dict) and all(isinstance(passkey.get(key), str) for key in ('id', 'credential', 'label', 'created')):
|
|
passkeys.append((value, passkey))
|
|
return passkeys
|
|
|
|
|
|
def checked_peers(connection, name, peers):
|
|
"""The values of vpnRouterWireGuardPeer for the user's WireGuard peers, [{public_key, ips}], when they are valid.
|
|
|
|
A public key is 32 bytes in base64, used by no other peer of any user; each peer has at least one IP, an IPv4
|
|
address or network, which no other peer of any user covers and which does not cover the router's own address of
|
|
wireguard.address. A user has at most WIREGUARD_PEERS_MAX peers.
|
|
"""
|
|
if not isinstance(peers, list) or len(peers) > WIREGUARD_PEERS_MAX:
|
|
raise Failure(f'wireguard_peers must be a list of at most {WIREGUARD_PEERS_MAX} peers', 2)
|
|
router = vpnrouter.load_config()['wireguard']['address'].strip()
|
|
taken = [(other['name'], key, network)
|
|
for other in list_users(connection)['users'] if other['name'] != name
|
|
for peer in other['wireguard_peers'] for key in [peer['public_key']]
|
|
for network in (ipaddress.ip_network(ip) for ip in peer['ips'])]
|
|
values = []
|
|
for peer in peers:
|
|
key = peer.get('public_key') if isinstance(peer, dict) else None
|
|
ips = peer.get('ips') if isinstance(peer, dict) else None
|
|
try:
|
|
if not isinstance(key, str) or len(base64.b64decode(key.strip(), validate=True)) != 32:
|
|
raise ValueError
|
|
except ValueError:
|
|
raise Failure(f'{key!r} is not a WireGuard public key: 32 bytes in base64', 2)
|
|
key = key.strip()
|
|
if not isinstance(ips, list) or not ips or not all(isinstance(ip, str) for ip in ips):
|
|
raise Failure(f'the peer {key} needs at least one IP', 2)
|
|
networks = []
|
|
for ip in ips:
|
|
try:
|
|
network = ipaddress.ip_network(ip.strip(), strict=True)
|
|
except ValueError:
|
|
raise Failure(f'{ip!r} is not an IPv4 address or network of the peer {key}', 2)
|
|
if network.version != 4:
|
|
raise Failure(f'{ip!r} of the peer {key} is not IPv4', 2)
|
|
if router and ipaddress.ip_interface(router).ip in network:
|
|
raise Failure(f'{network} of the peer {key} covers the router\'s own address {router}', 2)
|
|
for owner, other_key, other in taken:
|
|
if other_key == key:
|
|
raise Failure(f'the key {key} is a peer of {owner}', 4)
|
|
if network.overlaps(other):
|
|
raise Failure(f'{network} of the peer {key} overlaps {other} of {owner}', 4)
|
|
networks.append(network)
|
|
taken += [(name, key, network) for network in networks]
|
|
values.append(f'{key} {",".join(str(network) for network in networks)}'.encode())
|
|
return values
|
|
|
|
|
|
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 with a TOTP token 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 'oathTOTPToken' not in current:
|
|
raise Failure(f'user {name} has no TOTP token; console login needs one', 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)
|
|
return full or name
|
|
|
|
|
|
def list_users(connection):
|
|
found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL, '(|(objectClass=inetOrgPerson)(objectClass=account))',
|
|
ATTRIBUTES)
|
|
return {'users': sorted((summary(attributes) for _, attributes in found), key=lambda user: user['name'])}
|
|
|
|
|
|
def add(connection, name, password, values, service=False):
|
|
"""Add a user: a VPN user with its certificate when values gives all three person attributes, a Manager-only account
|
|
with at most the e-mail address when it gives neither name, and, with service, a service account, with at most the
|
|
e-mail address, its labels (labels) and its OpenID Provider client settings (client_settings); only a service
|
|
account may be named by a GUID."""
|
|
check_name(name, service)
|
|
details = person(values, required=False)
|
|
vpn_user = 'givenName' in details or 'sn' in details
|
|
if service and vpn_user:
|
|
raise Failure('a service account has no first or last name', 2)
|
|
client = {}
|
|
if service:
|
|
details.update({attribute: value for attribute, value in labels(values).items() if value})
|
|
client = {attribute: found for attribute, found in client_settings(connection, values).items() if found}
|
|
else:
|
|
refuse_client_settings(name, values)
|
|
if vpn_user and len(details) != len(PERSON):
|
|
raise Failure('give first_name, last_name and email for a VPN user, or at most email for a Manager-only account', 2)
|
|
check_password(password)
|
|
if vpn_user and not vpnrouter_pki.ready('p2s'):
|
|
raise Failure('the P2S issuing CA has no certificate yet; vpn-router-setup creates it once a platform is chosen, '
|
|
'or, while the root CA is offline, once its certificate is imported')
|
|
if vpn_user:
|
|
record = {'objectClass': VPN_USER_CLASSES, 'uid': [name.encode()], 'cn': [common_name(details, name).encode()]}
|
|
else:
|
|
# simpleSecurityObject requires a userPassword; passwd_s sets the real one at once.
|
|
record = {'objectClass': MANAGER_ONLY_CLASSES, 'uid': [name.encode()], 'userPassword': [b'{CRYPT}!']}
|
|
if service:
|
|
record['vpnRouterScope'] = [SERVICE_SCOPE.encode()]
|
|
record.update({attribute: [value.encode()] for attribute, value in details.items()})
|
|
record.update(client)
|
|
try:
|
|
connection.add_s(dn_of(name), ldap.modlist.addModlist(record))
|
|
except ldap.ALREADY_EXISTS:
|
|
raise Failure(f'user {name} exists', 4)
|
|
connection.passwd_s(dn_of(name), None, password)
|
|
if vpn_user:
|
|
try:
|
|
vpnrouter_pki.issue_user(vpnrouter.load_config(), name, details['mail'])
|
|
except vpnrouter_pki.Failure as failure:
|
|
raise Failure(f'the certificate cannot be issued: {failure}', failure.status)
|
|
return summary(entry(connection, name))
|
|
|
|
|
|
def update(connection, name, values):
|
|
"""Change the person attributes values gives, the cn following the first and the last name, and of a VPN user the
|
|
WireGuard peers, wireguard_peers, which replace the user's peers (checked_peers).
|
|
|
|
A Manager-only account has only the e-mail address, which '' removes; a service account also its labels
|
|
(update_service).
|
|
"""
|
|
current = entry(connection, name)
|
|
if service(current):
|
|
return update_service(connection, name, values)
|
|
refuse_client_settings(name, values)
|
|
if manager_only(current):
|
|
if values.get('wireguard_peers') is not None:
|
|
raise Failure(f'user {name} is a Manager-only account, which has no VPN access and so no WireGuard peers', 2)
|
|
if values.get('first_name') is not None or values.get('last_name') is not None:
|
|
raise Failure(f'user {name} is a Manager-only account, which has no first or last name', 2)
|
|
email = values.get('email')
|
|
if email is None:
|
|
raise Failure('give email', 2)
|
|
if isinstance(email, str) and email.strip() == '':
|
|
if 'mail' in current:
|
|
connection.modify_s(dn_of(name), [(ldap.MOD_DELETE, 'mail', None)])
|
|
else:
|
|
connection.modify_s(dn_of(name), [(ldap.MOD_REPLACE, 'mail', [person({'email': email}, required=False)['mail'].encode()])])
|
|
return summary(entry(connection, name))
|
|
details = person(values, required=False)
|
|
peers = values.get('wireguard_peers')
|
|
if not details and peers is None:
|
|
raise Failure('give at least one of first_name, last_name, email and wireguard_peers', 2)
|
|
changes = []
|
|
if details:
|
|
names = {attribute: current[attribute][0].decode() for attribute in ('givenName', 'sn') if current.get(attribute)}
|
|
names.update({attribute: details[attribute] for attribute in ('givenName', 'sn') if attribute in details})
|
|
changes += [(ldap.MOD_REPLACE, attribute, [value.encode()]) for attribute, value in details.items()]
|
|
changes.append((ldap.MOD_REPLACE, 'cn', [common_name(names, name).encode()]))
|
|
if peers is not None:
|
|
if 'vpnrouteraccount' not in classes(current):
|
|
changes.append((ldap.MOD_ADD, 'objectClass', [b'vpnRouterAccount']))
|
|
changes.append((ldap.MOD_REPLACE, 'vpnRouterWireGuardPeer', checked_peers(connection, name, peers)))
|
|
connection.modify_s(dn_of(name), changes)
|
|
if peers is not None:
|
|
apply_wireguard()
|
|
return summary(entry(connection, name))
|
|
|
|
|
|
def apply_wireguard():
|
|
"""Have vpn-router-setup write the WireGuard peers of the users into wg0.conf, without waiting for it."""
|
|
subprocess.run(['systemctl', 'restart', '--no-block', 'vpn-router-setup.service'], capture_output=True)
|
|
|
|
|
|
def update_service(connection, name, values):
|
|
"""Change any of the e-mail address, the labels (labels) and the OpenID Provider client settings (client_settings) of
|
|
a service account that values gives; '' removes the e-mail address or a label, [] the values of a client setting."""
|
|
if not service(entry(connection, name)):
|
|
raise Failure(f'{name} is not a service account', 3)
|
|
changes = [(ldap.MOD_REPLACE, attribute, [value.encode()] if value else []) for attribute, value in labels(values).items()]
|
|
changes += [(ldap.MOD_REPLACE, attribute, found) for attribute, found in client_settings(connection, values).items()]
|
|
email = values.get('email')
|
|
if email is not None:
|
|
address = '' if isinstance(email, str) and email.strip() == '' else person({'email': email}, required=False)['mail']
|
|
changes.append((ldap.MOD_REPLACE, 'mail', [address.encode()] if address else []))
|
|
if not changes:
|
|
raise Failure('give email, display_name, description, redirect_uris, post_logout_redirect_uris or allowed_groups', 2)
|
|
connection.modify_s(dn_of(name), changes)
|
|
return summary(entry(connection, name))
|
|
|
|
|
|
def passwd(connection, name, password):
|
|
check_password(password)
|
|
entry(connection, name)
|
|
connection.passwd_s(dn_of(name), None, password)
|
|
return summary(entry(connection, name))
|
|
|
|
|
|
def disable(connection, name):
|
|
"""Lock the user out; the WireGuard peers of a disabled user are left out of wg0.conf."""
|
|
current = entry(connection, name)
|
|
if current.get('pwdAccountLockedTime') != [LOCKED]:
|
|
connection.modify_s(dn_of(name), [(ldap.MOD_REPLACE, 'pwdAccountLockedTime', [LOCKED])])
|
|
if 'vpnRouterWireGuardPeer' in current:
|
|
apply_wireguard()
|
|
return summary(entry(connection, name))
|
|
|
|
|
|
def enable(connection, name):
|
|
"""Let the user in again, with the WireGuard peers."""
|
|
current = entry(connection, name)
|
|
if current.get('pwdAccountLockedTime') == [LOCKED]:
|
|
connection.modify_s(dn_of(name), [(ldap.MOD_DELETE, 'pwdAccountLockedTime', None)])
|
|
if 'vpnRouterWireGuardPeer' in current:
|
|
apply_wireguard()
|
|
return summary(entry(connection, name))
|
|
|
|
|
|
def unlock(connection, name):
|
|
"""End a lockout after failed binds and clear the failure count; a disabled user stays disabled."""
|
|
entry(connection, name)
|
|
current = connection.search_s(dn_of(name), ldap.SCOPE_BASE, attrlist=['pwdAccountLockedTime', 'pwdFailureTime'])[0][1]
|
|
changes = [(ldap.MOD_DELETE, 'pwdFailureTime', None)] if 'pwdFailureTime' in current else []
|
|
if 'pwdAccountLockedTime' in current and current['pwdAccountLockedTime'] != [LOCKED]:
|
|
changes.append((ldap.MOD_DELETE, 'pwdAccountLockedTime', None))
|
|
if changes:
|
|
connection.modify_s(dn_of(name), changes)
|
|
return summary(entry(connection, name))
|
|
|
|
|
|
def login(name, password, code):
|
|
"""Check the user's password, and the TOTP code of an enrolled user, with a bind as the user; a service account may be
|
|
named by a GUID.
|
|
|
|
A wrong name, password or code, a locked or a disabled user give the same Failure, status 5.
|
|
"""
|
|
refused = Failure('the name, the password or the code is wrong', 5)
|
|
if not isinstance(name, str) or not (NAME.match(name) or GUID.match(name)) or not isinstance(password, str) or not password:
|
|
raise refused
|
|
if code is not None and code != '' and (not isinstance(code, str) or not CODE.match(code)):
|
|
raise refused
|
|
connection = ldap.initialize(vpnrouter_ldap.URI)
|
|
try:
|
|
connection.simple_bind_s(dn_of(name), password + (code or ''))
|
|
except ldap.INVALID_CREDENTIALS:
|
|
raise refused
|
|
finally:
|
|
connection.unbind_s()
|
|
|
|
|
|
def is_admin(connection, name):
|
|
"""Whether the user exists and is a member of ADMIN_GROUP."""
|
|
try:
|
|
entry(connection, name)
|
|
except Failure:
|
|
return False
|
|
return ADMIN_GROUP in groups_of(connection, name)
|
|
|
|
|
|
def totp(secret):
|
|
return pyotp.TOTP(secret, digits=vpnrouter_ldap.TOTP_DIGITS, interval=vpnrouter_ldap.TOTP_PERIOD)
|
|
|
|
|
|
def otp_new(connection, name):
|
|
"""A new TOTP secret for the user and its otpauth URI, issued by the router's wan.local_fqdn; nothing is stored."""
|
|
entry(connection, name)
|
|
secret = pyotp.random_base32()
|
|
issuer = vpnrouter.load_config()['wan']['local_fqdn'].strip()
|
|
return {'name': name, 'secret': secret, 'uri': totp(secret).provisioning_uri(name=name, issuer_name=issuer or None)}
|
|
|
|
|
|
def otp_enrol(connection, name, secret, code):
|
|
"""Store the secret of otp_new as the user's TOTP token, replacing one the user has, when code is a code of it."""
|
|
current = entry(connection, name)
|
|
if not isinstance(secret, str) or not TOTP_SECRET.match(secret):
|
|
raise Failure('the secret must be 32 base32 characters, as a new secret has them', 2)
|
|
if not isinstance(code, str) or not re.fullmatch(rf'\d{{{vpnrouter_ldap.TOTP_DIGITS}}}', code):
|
|
raise Failure(f'the code must be {vpnrouter_ldap.TOTP_DIGITS} digits', 2)
|
|
if not totp(secret).verify(code, valid_window=vpnrouter_ldap.TOTP_WINDOW):
|
|
raise Failure('the code does not match the secret; check the clock of the authenticator', 2)
|
|
changes = [] if 'oathTOTPToken' in current else [(ldap.MOD_ADD, 'objectClass', [b'oathTOTPUser', b'oathTOTPToken'])]
|
|
changes += [
|
|
(ldap.MOD_REPLACE, 'oathTOTPToken', [dn_of(name).encode()]),
|
|
(ldap.MOD_REPLACE, 'oathTOTPParams', [vpnrouter_ldap.TOTP_PARAMS.encode()]),
|
|
(ldap.MOD_REPLACE, 'oathSecret', [base64.b32decode(secret)]),
|
|
(ldap.MOD_REPLACE, 'oathSecretTime', [time.strftime('%Y%m%d%H%M%SZ', time.gmtime()).encode()]),
|
|
]
|
|
connection.modify_s(dn_of(name), changes)
|
|
return summary(entry(connection, name))
|
|
|
|
|
|
def otp_remove(connection, name):
|
|
"""Remove the user's TOTP token, when the user has one, and end the console sessions it needed (end_sessions)."""
|
|
current = entry(connection, name)
|
|
if 'oathTOTPToken' in current:
|
|
token = connection.search_s(dn_of(name), ldap.SCOPE_BASE, attrlist=TOKEN_ATTRIBUTES)[0][1]
|
|
changes = [(ldap.MOD_DELETE, 'objectClass', [b'oathTOTPUser', b'oathTOTPToken']),
|
|
(ldap.MOD_DELETE, 'oathSecret', None)]
|
|
changes += [(ldap.MOD_DELETE, attribute, None) for attribute in token]
|
|
connection.modify_s(dn_of(name), changes)
|
|
end_sessions(current)
|
|
return summary(entry(connection, name))
|
|
|
|
|
|
def remove(connection, name):
|
|
"""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.
|
|
"""
|
|
current = entry(connection, name)
|
|
groups = groups_of(connection, name)
|
|
alone = sorted(group for group, members in groups.items() if members == [name])
|
|
if alone:
|
|
raise Failure(f'user {name} is the last member of {", ".join(alone)}; remove the group first', 2)
|
|
for group in groups:
|
|
connection.modify_s(group_dn(group), [(ldap.MOD_DELETE, 'member', [dn_of(name).encode()])])
|
|
try:
|
|
vpnrouter_pki.revoke_user(vpnrouter.load_config(), 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)
|
|
if 'vpnRouterWireGuardPeer' in current:
|
|
apply_wireguard()
|
|
return {'name': name, 'removed': True}
|
|
|
|
|
|
def profile(connection, name):
|
|
"""The OpenVPN client profile of the user, with the root and the P2S CA, the certificate and the key inline."""
|
|
entry(connection, name)
|
|
cfg = vpnrouter.load_config()
|
|
if not cfg['openvpn']['enabled']:
|
|
raise Failure('OpenVPN is not enabled', 2)
|
|
credentials = vpnrouter_pki.user_credentials(name)
|
|
if credentials is None:
|
|
raise Failure(f'user {name} has no certificate')
|
|
cert, key = (value.decode() for value in credentials)
|
|
fqdn = cfg['wan']['local_fqdn'].strip()
|
|
text = '\n'.join([
|
|
'client',
|
|
'dev tun',
|
|
f'proto {cfg["openvpn"]["protocol"]}',
|
|
f'remote {fqdn} {cfg["openvpn"]["port"]}',
|
|
'nobind',
|
|
'persist-key',
|
|
'persist-tun',
|
|
'remote-cert-tls server',
|
|
f'verify-x509-name {fqdn} name',
|
|
'auth-user-pass',
|
|
'auth-nocache',
|
|
'verb 3',
|
|
'<ca>', extract_pem(vpnrouter_pki.OPENVPN_CA.read_text(encoding='utf-8')), '</ca>',
|
|
'<cert>', extract_pem(cert), '</cert>',
|
|
'<key>', key.strip(), '</key>',
|
|
'',
|
|
])
|
|
return {'name': name, 'profile': text}
|
|
|
|
|
|
def apple_profile(connection, name, password):
|
|
"""The Apple configuration profile (.mobileconfig, unsigned) of the user's IKEv2 access: the root CA and
|
|
the P2S CA, the user's certificate and key as a PKCS#12 encrypted with the password, and the VPN.
|
|
|
|
The connection matches road-warrior.conf: the router's wan.local_fqdn as address and identity, the user name, a
|
|
DNS name of the certificate, as the local identity, the certificate over EAP-TLS, and AES-256, SHA2-256 and group 14
|
|
for IKE and ESP. With p2s.dns_domains, the device resolves those domains with the router's resolver. The PKCS#12 uses 3DES and SHA-1, the only encryption Apple's clients read; they ask for the
|
|
password when the profile is installed.
|
|
"""
|
|
entry(connection, name)
|
|
if not isinstance(password, str) or not PASSWORD_MIN <= len(password) <= PASSWORD_MAX:
|
|
raise Failure(f'the password of the profile must have {PASSWORD_MIN} to {PASSWORD_MAX} characters', 2)
|
|
cfg = vpnrouter.load_config()
|
|
fqdn = cfg['wan']['local_fqdn'].strip()
|
|
if not cfg['p2s']['enabled'] or not fqdn:
|
|
raise Failure('IKEv2 access is not enabled', 2)
|
|
credentials = vpnrouter_pki.user_credentials(name)
|
|
if credentials is None:
|
|
raise Failure(f'user {name} has no certificate')
|
|
try:
|
|
chain = x509.load_pem_x509_certificates(vpnrouter_pki.bundle('p2s')['pem'].encode())
|
|
except vpnrouter_pki.Failure as failure:
|
|
raise Failure(str(failure), 4)
|
|
cert = x509.load_pem_x509_certificate(credentials[0])
|
|
key = serialization.load_pem_private_key(credentials[1], password=None)
|
|
encryption = (serialization.PrivateFormat.PKCS12.encryption_builder().kdf_rounds(2048)
|
|
.key_cert_algorithm(pkcs12.PBES.PBESv1SHA1And3KeyTripleDESCBC).hmac_hash(hashes.SHA1())
|
|
.build(password.encode()))
|
|
bundle = pkcs12.serialize_key_and_certificates(name.encode(), key, cert, None, encryption)
|
|
certificate_type = ('RSA' if isinstance(key, rsa.RSAPrivateKey)
|
|
else 'ECDSA384' if isinstance(key, ec.EllipticCurvePrivateKey) and key.curve.key_size == 384
|
|
else 'ECDSA256')
|
|
identifier = f'{".".join(reversed(fqdn.split(".")))}.vpn-router.{name}'
|
|
ids = {kind: str(uuid.uuid5(uuid.NAMESPACE_URL, f'{identifier}.{kind}')) for kind in ('profile', 'pkcs12', 'vpn')}
|
|
proposal = {'EncryptionAlgorithm': 'AES-256', 'IntegrityAlgorithm': 'SHA2-256', 'DiffieHellmanGroup': 14,
|
|
'LifeTimeInMinutes': 60}
|
|
payloads = [{'PayloadType': 'com.apple.security.root' if index == len(chain) - 1 else 'com.apple.security.pkcs1',
|
|
'PayloadVersion': 1, 'PayloadIdentifier': f'{identifier}.ca{index}',
|
|
'PayloadUUID': str(uuid.uuid5(uuid.NAMESPACE_URL, f'{identifier}.ca{index}')),
|
|
'PayloadDisplayName': vpnrouter_pki.common_name(item),
|
|
'PayloadContent': item.public_bytes(serialization.Encoding.DER)}
|
|
for index, item in enumerate(chain)]
|
|
payloads.append({'PayloadType': 'com.apple.security.pkcs12', 'PayloadVersion': 1,
|
|
'PayloadIdentifier': f'{identifier}.pkcs12', 'PayloadUUID': ids['pkcs12'],
|
|
'PayloadDisplayName': f'{name} ({fqdn})', 'PayloadContent': bundle})
|
|
# The router's resolver, which setup records, answers the names of p2s.dns_domains; the others stay with the device's.
|
|
try:
|
|
servers = json.loads(vpnrouter.WEB_STATE.read_text(encoding='utf-8'))['network']['client_dns']
|
|
except (OSError, ValueError, KeyError, TypeError):
|
|
servers = []
|
|
domains = [item.strip() for item in cfg['p2s']['dns_domains'] if item.strip()]
|
|
dns = {'DNS': {'ServerAddresses': servers, 'SupplementalMatchDomains': domains}} if servers and domains else {}
|
|
payloads.append({
|
|
**dns,
|
|
'PayloadType': 'com.apple.vpn.managed', 'PayloadVersion': 1, 'PayloadIdentifier': f'{identifier}.vpn',
|
|
'PayloadUUID': ids['vpn'], 'PayloadDisplayName': f'VPN Router {fqdn}', 'UserDefinedName': f'VPN Router {fqdn}',
|
|
'VPNType': 'IKEv2',
|
|
'IKEv2': {
|
|
'RemoteAddress': fqdn, 'RemoteIdentifier': fqdn, 'LocalIdentifier': name,
|
|
'AuthenticationMethod': 'Certificate', 'ExtendedAuthEnabled': 1, 'PayloadCertificateUUID': ids['pkcs12'],
|
|
'CertificateType': certificate_type,
|
|
'ServerCertificateIssuerCommonName': vpnrouter_pki.common_name(chain[0]), 'ServerCertificateCommonName': fqdn,
|
|
'IKESecurityAssociationParameters': {**proposal, 'LifeTimeInMinutes': 480},
|
|
'ChildSecurityAssociationParameters': proposal,
|
|
'EnablePFS': 0, 'DeadPeerDetectionRate': 'Medium', 'DisableMOBIKE': 0, 'DisableRedirect': 0,
|
|
},
|
|
})
|
|
document = {'PayloadType': 'Configuration', 'PayloadVersion': 1, 'PayloadIdentifier': identifier,
|
|
'PayloadUUID': ids['profile'], 'PayloadDisplayName': f'VPN Router {fqdn} ({name})',
|
|
'PayloadDescription': f'IKEv2 access of {name} to {fqdn}', 'PayloadRemovalDisallowed': False,
|
|
'PayloadContent': payloads}
|
|
return {'name': name, 'profile': plistlib.dumps(document).decode()}
|
|
|
|
|
|
def group_dn(name):
|
|
return f'cn={name},{vpnrouter_ldap.GROUPS}'
|
|
|
|
|
|
def check_group_name(name):
|
|
if not isinstance(name, str) or not NAME.match(name):
|
|
raise Failure('a group name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter', 2)
|
|
|
|
|
|
def check_description(value):
|
|
"""The description, stripped; '' means none."""
|
|
if not isinstance(value, str):
|
|
raise Failure('the description must be a string', 2)
|
|
value = value.strip()
|
|
if len(value) > DESCRIPTION_MAX or any(not character.isprintable() for character in value):
|
|
raise Failure(f'the description must have at most {DESCRIPTION_MAX} printable characters', 2)
|
|
return value
|
|
|
|
|
|
def member_names(attributes):
|
|
"""The user names of a group's member DNs, sorted."""
|
|
names = []
|
|
for value in attributes.get('member', []):
|
|
dn = value.decode()
|
|
if dn.startswith('uid=') and dn.endswith(f',{vpnrouter_ldap.USERS}'):
|
|
names.append(dn[4:-len(f',{vpnrouter_ldap.USERS}')])
|
|
return sorted(names)
|
|
|
|
|
|
def group_summary(attributes):
|
|
"""A group as the answers show it."""
|
|
values = attributes.get('description')
|
|
return {
|
|
'name': attributes['cn'][0].decode(),
|
|
'description': values[0].decode() if values else '',
|
|
'members': member_names(attributes),
|
|
}
|
|
|
|
|
|
def group_entry(connection, name):
|
|
"""The attributes of the group's entry; Failure with status 3 when there is none."""
|
|
check_group_name(name)
|
|
try:
|
|
return connection.search_s(group_dn(name), ldap.SCOPE_BASE, attrlist=GROUP_ATTRIBUTES)[0][1]
|
|
except ldap.NO_SUCH_OBJECT:
|
|
raise Failure(f'group {name} does not exist', 3)
|
|
|
|
|
|
def groups_of(connection, name):
|
|
"""The groups the user is a member of, as {group name: its member names}."""
|
|
found = connection.search_s(vpnrouter_ldap.GROUPS, ldap.SCOPE_ONELEVEL,
|
|
f'(&(objectClass=groupOfNames)(member={ldap.filter.escape_filter_chars(dn_of(name))}))',
|
|
GROUP_ATTRIBUTES)
|
|
return {attributes['cn'][0].decode(): member_names(attributes) for _, attributes in found}
|
|
|
|
|
|
def allowing(connection, name):
|
|
"""The names of the OpenID Provider's clients whose allowed groups have the group, sorted."""
|
|
found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL,
|
|
f'(vpnRouterAllowedGroup={ldap.filter.escape_filter_chars(group_dn(name))})', ['uid'])
|
|
return sorted(attributes['uid'][0].decode() for _, attributes in found)
|
|
|
|
|
|
def list_groups(connection):
|
|
found = connection.search_s(vpnrouter_ldap.GROUPS, ldap.SCOPE_ONELEVEL, '(objectClass=groupOfNames)', GROUP_ATTRIBUTES)
|
|
return {'groups': sorted((group_summary(attributes) for _, attributes in found), key=lambda group: group['name'])}
|
|
|
|
|
|
def group_add(connection, name, description, members):
|
|
"""Add a group with a description ('' for none) and at least one member, each an existing user."""
|
|
check_group_name(name)
|
|
description = check_description(description if description is not None else '')
|
|
if not isinstance(members, list) or not members:
|
|
raise Failure('a group needs at least one member', 2)
|
|
for member in members:
|
|
entry(connection, member)
|
|
record = {'objectClass': [b'groupOfNames'], 'cn': [name.encode()],
|
|
'member': [dn_of(member).encode() for member in sorted(set(members))]}
|
|
if description:
|
|
record['description'] = [description.encode()]
|
|
try:
|
|
connection.add_s(group_dn(name), ldap.modlist.addModlist(record))
|
|
except ldap.ALREADY_EXISTS:
|
|
raise Failure(f'group {name} exists', 4)
|
|
return group_summary(group_entry(connection, name))
|
|
|
|
|
|
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. Renaming ADMIN_GROUP
|
|
switches the console login of its members off; renaming a group a client of the OpenID Provider allows renames it
|
|
there, and renaming the editors group of web sites renames it in the sites."""
|
|
import vpnrouter_sites
|
|
if new_name is None and description is None:
|
|
raise Failure('give a new name or a description', 2)
|
|
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 \
|
|
(ldap.MOD_REPLACE, 'description', [])
|
|
connection.modify_s(group_dn(name), [change])
|
|
if new_name is not None and new_name != name:
|
|
check_group_name(new_name)
|
|
try:
|
|
connection.rename_s(group_dn(name), f'cn={new_name}', delold=1)
|
|
except ldap.ALREADY_EXISTS:
|
|
raise Failure(f'group {new_name} exists', 4)
|
|
for client in allowing(connection, name):
|
|
connection.modify_s(dn_of(client), [(ldap.MOD_DELETE, 'vpnRouterAllowedGroup', [group_dn(name).encode()]),
|
|
(ldap.MOD_ADD, 'vpnRouterAllowedGroup', [group_dn(new_name).encode()])])
|
|
try:
|
|
vpnrouter_sites.rename_group(name, new_name)
|
|
except vpnrouter_sites.DatabaseError as exc:
|
|
raise Failure(f'group {name} is renamed {new_name}, but not in the web sites: {exc}')
|
|
if name == ADMIN_GROUP:
|
|
console_off(connection, members)
|
|
name = new_name
|
|
return group_summary(group_entry(connection, name))
|
|
|
|
|
|
def group_remove(connection, name):
|
|
"""Remove the group, unless a client of the OpenID Provider allows it or it is the editors group of web sites;
|
|
removing ADMIN_GROUP switches the console login of its members off."""
|
|
import vpnrouter_sites
|
|
members = member_names(group_entry(connection, name))
|
|
clients = allowing(connection, name)
|
|
if clients:
|
|
raise Failure(f'group {name} is an allowed group of the clients {", ".join(clients)}; take it out of them first', 2)
|
|
try:
|
|
sites = vpnrouter_sites.sites_of_group(name)
|
|
except vpnrouter_sites.DatabaseError as exc:
|
|
raise Failure(f'the web sites cannot be read: {exc}')
|
|
if sites:
|
|
raise Failure(f'group {name} is the editors group of the web sites {", ".join(sites)}; change them first', 2)
|
|
connection.delete_s(group_dn(name))
|
|
if name == ADMIN_GROUP:
|
|
console_off(connection, members)
|
|
return {'name': name, 'removed': True}
|
|
|
|
|
|
def group_member_add(connection, name, user):
|
|
attributes = group_entry(connection, name)
|
|
entry(connection, user)
|
|
if user not in member_names(attributes):
|
|
connection.modify_s(group_dn(name), [(ldap.MOD_ADD, 'member', [dn_of(user).encode()])])
|
|
return group_summary(group_entry(connection, name))
|
|
|
|
|
|
def group_member_remove(connection, name, user):
|
|
"""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:
|
|
raise Failure(f'user {user} is not a member of group {name}', 3)
|
|
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))
|
|
|
|
|
|
def extract_pem(text):
|
|
"""The PEM blocks of the certificates in a file that also holds their text form."""
|
|
begin, end = '-----BEGIN CERTIFICATE-----', '-----END CERTIFICATE-----'
|
|
blocks = []
|
|
start = text.find(begin)
|
|
while start != -1:
|
|
stop = text.index(end, start) + len(end)
|
|
blocks.append(text[start:stop])
|
|
start = text.find(begin, stop)
|
|
return '\n'.join(blocks)
|