feature: users have a first name, a last name and an e-mail address.
Release / release (push) Successful in 11s

This commit is contained in:
slawek committed 2026-10-24 01:47:50 +02:00
1 parent b9003bacb9
commit 08693f7e68
9 files changed
+872 -290

No files matched your search

+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/python3
"""Manage the groups of the router's directory from the command line, with vpnrouter_users.
A group has a name, an optional description and at least one member, each a user of the
directory. The answer is text for people on standard output, and a failure is a message on
standard error. With --json the answer is JSON on standard output, and a failure is
{"error": ...} there too. The exit status says what failed: 2 the request is invalid, 3 the
group or the user does not exist, 4 the group exists, 1 anything else. -h or --help shows the
commands and their options.
vpn-router-group [--json] list
vpn-router-group [--json] add <name> --member U [--member U ...] [--description D]
vpn-router-group [--json] update <name> [--name N] [--description D]
vpn-router-group [--json] remove <name>
vpn-router-group [--json] member-add <name> <user>
vpn-router-group [--json] member-remove <name> <user>
"""
import argparse
import json
import sys
sys.path.insert(0, '/usr/lib/vpn-router')
import ldap
import vpnrouter_users
from vpnrouter_users import Failure
#: The help line of each command.
HELP = {
'list': 'list the groups with their members',
'add': 'add a group with at least one member',
'update': 'rename the group or change its description',
'remove': 'remove the group; its members are kept',
'member-add': 'add a user to the group',
'member-remove': 'take a user out of the group; the last member cannot leave',
}
class Parser(argparse.ArgumentParser):
"""An argument parser whose errors are Failures with status 2, so that --json answers them as JSON."""
def error(self, message):
raise Failure(f'{message}; see {self.prog} --help', 2)
def parse(argv):
parser = Parser(prog='vpn-router-group', description='Manage the groups of the router.')
parser.add_argument('--json', action='store_true', help='answer in JSON')
commands = parser.add_subparsers(dest='command', required=True, metavar='command', parser_class=Parser)
for command, text in HELP.items():
sub = commands.add_parser(command, help=text, description=text[0].upper() + text[1:] + '.')
if command != 'list':
sub.add_argument('name', help='the group name')
if command == 'add':
sub.add_argument('--member', dest='members', action='append', required=True, metavar='USER',
help='a member; give it once for each member')
sub.add_argument('--description', metavar='TEXT', help='the description')
if command == 'update':
sub.add_argument('--name', dest='new_name', metavar='NAME', help='the new group name')
sub.add_argument('--description', metavar='TEXT', help="the description; '' removes it")
if command in ('member-add', 'member-remove'):
sub.add_argument('user', help='the user name')
return parser.parse_args(argv)
def run(connection, options):
"""The answer of the command the options name."""
command = options.command
if command == 'list':
return vpnrouter_users.list_groups(connection)
if command == 'add':
return vpnrouter_users.group_add(connection, options.name, options.description, options.members)
if command == 'update':
return vpnrouter_users.group_update(connection, options.name, options.new_name, options.description)
if command == 'remove':
return vpnrouter_users.group_remove(connection, options.name)
if command == 'member-add':
return vpnrouter_users.group_member_add(connection, options.name, options.user)
return vpnrouter_users.group_member_remove(connection, options.name, options.user)
def text_of(answer):
"""The answer as text for people."""
if answer.get('removed'):
return f'group {answer["name"]} removed'
groups = answer['groups'] if 'groups' in answer else [answer]
if not groups:
return 'no groups'
head = ('GROUP', 'MEMBERS', 'DESCRIPTION')
rows = [(group['name'], ', '.join(group['members']), group['description'] or '-') for group in groups]
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))
def main(argv):
# A request that cannot be parsed is answered in JSON when --json is among the arguments.
as_json = '--json' in argv
try:
options = parse(argv)
as_json = options.json
with vpnrouter_users.session() as connection:
answer = run(connection, options)
except Failure as failure:
message, status = str(failure), failure.status
except (ldap.LDAPError, OSError) as exc:
message, status = f'the user directory failed: {exc}', 1
else:
print(json.dumps(answer) if as_json else text_of(answer))
return 0
if as_json:
print(json.dumps({'error': message}))
else:
print(f'vpn-router-group: {message}', file=sys.stderr)
return status
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))
+95 -189
View File
@@ -1,224 +1,130 @@
#!/usr/bin/python3
"""Manage the users of the router's directory, whether or not a P2S system is enabled.
"""Manage the users of the router's directory from the command line, with vpnrouter_users.
A user is an entry in the router's directory, whose password OpenVPN checks, and a client
certificate of the router CA with the user name as its common name, which OpenVPN and
IKEv2 accept. Passwords are read from the first line of standard input, never from the
command line. The answer is JSON on standard output; on failure it is {"error": ...} and
the exit status says why: 2 the request is invalid, 3 the user does not exist, 4 the user
exists, 1 anything else.
Passwords are read from the first line of standard input, never from the command line. The
answer is text for people on standard output, and a failure is a message on standard error.
With --json the answer is JSON on standard output, and a failure is {"error": ...} there too.
The exit status says what failed: 2 the request is invalid, 3 the user does not exist, 4 the
user exists, 1 anything else. -h or --help shows the commands and their options.
vpn-router-user list
vpn-router-user add <name> (password on stdin)
vpn-router-user passwd <name> (password on stdin)
vpn-router-user disable <name>
vpn-router-user enable <name>
vpn-router-user remove <name>
vpn-router-user profile <name>
vpn-router-user [--json] list
vpn-router-user [--json] add <name> --first-name F --last-name L --email E (password on stdin)
vpn-router-user [--json] update <name> [--first-name F] [--last-name L] [--email E]
vpn-router-user [--json] passwd <name> (password on stdin)
vpn-router-user [--json] disable <name>
vpn-router-user [--json] enable <name>
vpn-router-user [--json] remove <name>
vpn-router-user [--json] profile <name>
"""
import fcntl
import argparse
import json
import pathlib
import re
import subprocess
import sys
sys.path.insert(0, '/usr/lib/vpn-router')
import ldap
import ldap.modlist
import vpnrouter
import vpnrouter_ldap
import vpnrouter_users
from vpnrouter_users import Failure
#: 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]$')
PASSWORD_MIN = 8
PASSWORD_MAX = 128
#: The value of pwdAccountLockedTime that locks an account until it is unlocked.
LOCKED = b'000001010000Z'
LOCK_FILE = pathlib.Path('/run/vpn-router/users.lock')
#: The help line of each command.
HELP = {
'list': 'list the users',
'add': 'add a user and issue the certificate; the password is read from standard input',
'update': 'change the first name, the last name or the e-mail address',
'passwd': 'set the password, read from standard input',
'disable': 'lock the user out of OpenVPN',
'enable': 'let the user in again',
'remove': 'revoke the certificate and remove the user',
'profile': 'print the OpenVPN client profile',
}
#: The help of the person options.
PERSON_HELP = {'first_name': 'the first name', 'last_name': 'the last name', 'email': 'the e-mail address'}
class Failure(Exception):
"""A request that cannot be done; status is the exit status."""
class Parser(argparse.ArgumentParser):
"""An argument parser whose errors are Failures with status 2, so that --json answers them as JSON."""
def __init__(self, message, status=1):
super().__init__(message)
self.status = status
def error(self, message):
raise Failure(f'{message}; see {self.prog} --help', 2)
def dn_of(name):
return f'uid={name},{vpnrouter_ldap.USERS}'
def certificate(name):
return vpnrouter.USER_CERT_DIR / f'{name}_cert.pem', vpnrouter.USER_CERT_DIR / f'{name}_key.pem'
def entry(connection, name):
"""The attributes of the user's entry; Failure with status 3 when there is none."""
try:
return connection.search_s(dn_of(name), ldap.SCOPE_BASE, attrlist=['uid', 'pwdAccountLockedTime'])[0][1]
except ldap.NO_SUCH_OBJECT:
raise Failure(f'user {name} does not exist', 3)
def parse(argv):
parser = Parser(prog='vpn-router-user', description='Manage the users of the router.')
parser.add_argument('--json', action='store_true', help='answer in JSON')
commands = parser.add_subparsers(dest='command', required=True, metavar='command', parser_class=Parser)
for command, text in HELP.items():
sub = commands.add_parser(command, help=text, description=text[0].upper() + text[1:] + '.')
if command != 'list':
sub.add_argument('name', help='the user name')
if command in ('add', 'update'):
for key, _ in vpnrouter_users.PERSON:
sub.add_argument(f'--{key.replace("_", "-")}', dest=key, metavar='TEXT', required=command == 'add',
help=PERSON_HELP[key])
return parser.parse_args(argv)
def password():
"""The password from the first line of standard input."""
value = sys.stdin.readline().rstrip('\n')
if not PASSWORD_MIN <= len(value) <= PASSWORD_MAX:
raise Failure(f'the password must have {PASSWORD_MIN} to {PASSWORD_MAX} characters', 2)
return value
return sys.stdin.readline().rstrip('\n')
def simple_ca(*args):
result = subprocess.run([sys.executable, str(vpnrouter.SIMPLE_CA), *args], capture_output=True, text=True)
if result.returncode != 0:
raise Failure(f'simple-ca {args[0]} failed: {result.stderr.strip() or result.stdout.strip()}')
def run(connection, options, secret):
"""The answer of the command the options name; secret is the password of add and passwd."""
command = options.command
if command == 'list':
return vpnrouter_users.list_users(connection)
values = {key: getattr(options, key, None) for key, _ in vpnrouter_users.PERSON}
if command == 'add':
return vpnrouter_users.add(connection, options.name, secret, values)
if command == 'update':
return vpnrouter_users.update(connection, options.name, values)
if command == 'passwd':
return vpnrouter_users.passwd(connection, options.name, secret)
return getattr(vpnrouter_users, command)(connection, options.name)
def publish_crl():
"""Give strongSwan the CRL after a revocation; OpenVPN reads it at every connection."""
if vpnrouter.install_file(vpnrouter.PKI_DIR / 'crl.pem', vpnrouter.CRL_DEST, 0o644):
subprocess.run(['swanctl', '--load-creds'], capture_output=True, text=True)
def summary(attributes):
name = attributes['uid'][0].decode()
return {
'name': name,
'enabled': 'pwdAccountLockedTime' not in attributes,
'certificate': certificate(name)[0].is_file(),
}
def list_users(connection):
found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL, '(objectClass=inetOrgPerson)',
['uid', 'pwdAccountLockedTime'])
return {'users': sorted((summary(attributes) for _, attributes in found), key=lambda user: user['name'])}
def add(connection, name):
secret = password()
if not (vpnrouter.PKI_DIR / 'ca_key.pem').is_file():
raise Failure('the router CA does not exist; vpn-router-setup creates it once a platform is chosen')
try:
connection.add_s(dn_of(name), ldap.modlist.addModlist({
'objectClass': [b'inetOrgPerson'], 'uid': [name.encode()], 'cn': [name.encode()], 'sn': [name.encode()],
}))
except ldap.ALREADY_EXISTS:
raise Failure(f'user {name} exists', 4)
connection.passwd_s(dn_of(name), None, secret)
vpnrouter.USER_CERT_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
for path in certificate(name):
path.unlink(missing_ok=True)
simple_ca('make-cert', '--ca-dir', str(vpnrouter.PKI_DIR), '--cert-dir', str(vpnrouter.USER_CERT_DIR), name)
return summary(entry(connection, name))
def passwd(connection, name):
secret = password()
entry(connection, name)
connection.passwd_s(dn_of(name), None, secret)
return summary(entry(connection, name))
def disable(connection, name):
if 'pwdAccountLockedTime' not in entry(connection, name):
connection.modify_s(dn_of(name), [(ldap.MOD_REPLACE, 'pwdAccountLockedTime', [LOCKED])])
return summary(entry(connection, name))
def enable(connection, name):
if 'pwdAccountLockedTime' in entry(connection, name):
connection.modify_s(dn_of(name), [(ldap.MOD_DELETE, 'pwdAccountLockedTime', None)])
return summary(entry(connection, name))
def remove(connection, name):
entry(connection, name)
cert, key = certificate(name)
if cert.is_file():
simple_ca('revoke-cert', '--ca-dir', str(vpnrouter.PKI_DIR), str(cert))
simple_ca('make-crl', '--ca-dir', str(vpnrouter.PKI_DIR), '--days', str(vpnrouter.CRL_DAYS))
publish_crl()
cert.unlink(missing_ok=True)
key.unlink(missing_ok=True)
connection.delete_s(dn_of(name))
return {'name': name, 'removed': True}
def profile(connection, name):
"""The OpenVPN client profile of the user, with the 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)
cert, key = certificate(name)
if not cert.is_file() or not key.is_file():
raise Failure(f'user {name} has no certificate')
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.CA_CERT_DEST.read_text(encoding='utf-8')), '</ca>',
'<cert>', extract_pem(cert.read_text(encoding='utf-8')), '</cert>',
'<key>', key.read_text(encoding='utf-8').strip(), '</key>',
'',
])
return {'name': name, 'profile': text}
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)
COMMANDS = {'list': list_users, 'add': add, 'passwd': passwd, 'disable': disable, 'enable': enable,
'remove': remove, 'profile': profile}
def text_of(answer):
"""The answer as text for people."""
if 'profile' in answer:
return answer['profile'].rstrip('\n')
if answer.get('removed'):
return f'user {answer["name"]} removed'
users = answer['users'] if 'users' in answer else [answer]
if not users:
return 'no users'
head = ('USER', 'STATE', 'CERTIFICATE', 'FIRST NAME', 'LAST NAME', 'E-MAIL')
rows = [(user['name'], 'enabled' if user['enabled'] else 'disabled', 'issued' if user['certificate'] else 'missing',
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))
def main(argv):
# A request that cannot be parsed is answered in JSON when --json is among the arguments.
as_json = '--json' in argv
try:
if not argv or argv[0] not in COMMANDS or len(argv) != (1 if argv[0] == 'list' else 2):
raise Failure('usage: vpn-router-user list | add|passwd|disable|enable|remove|profile <name>', 2)
if len(argv) == 2 and not NAME.match(argv[1]):
raise Failure('a user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter', 2)
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:
answer = COMMANDS[argv[0]](connection, *argv[1:])
finally:
connection.unbind_s()
options = parse(argv)
as_json = options.json
if options.command != 'list':
vpnrouter_users.check_name(options.name)
secret = password() if options.command in ('add', 'passwd') else None
with vpnrouter_users.session() as connection:
answer = run(connection, options, secret)
except Failure as failure:
print(json.dumps({'error': str(failure)}))
return failure.status
message, status = str(failure), failure.status
except (ldap.LDAPError, OSError) as exc:
print(json.dumps({'error': f'the user directory failed: {exc}'}))
return 1
print(json.dumps(answer))
return 0
message, status = f'the user directory failed: {exc}', 1
else:
print(json.dumps(answer) if as_json else text_of(answer))
return 0
if as_json:
print(json.dumps({'error': message}))
else:
print(f'vpn-router-user: {message}', file=sys.stderr)
return status
if __name__ == '__main__':