feature: users have a first name, a last name and an e-mail address.
Release / release (push) Successful in 11s
Release / release (push) Successful in 11s
This commit is contained in:
1 parent
b9003bacb9
commit
08693f7e68
9 files changed
+872
-290
No files matched your search
Executable
+120
@@ -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:]))
|
||||
@@ -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__':
|
||||
|
||||
Reference in new issue
Block a user