Files
linux-cloud-router/debian-package/src/usr/sbin/vpn-router-user
T

225 lines
11 KiB
Python
Executable File

#!/usr/bin/python3
"""Manage the users of the router's directory from the command line, with vpnrouter_users.
Passwords and TOTP secrets 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 [--json] list
vpn-router-user [--json] add <name> --first-name F --last-name L --email E (password on stdin)
vpn-router-user [--json] add <name> [--email E] (a Manager-only account; password on stdin)
vpn-router-user [--json] add <name> --service [--display-name N] [--description D] [--email E]
[--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...]
(a service account; password on stdin)
vpn-router-user [--json] update <name> [--first-name F] [--last-name L] [--email E]
vpn-router-user [--json] update <name> [--display-name N] [--description D] [--email E]
[--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...]
(a service account)
A service account with a redirect URI is a client of the OpenID Provider; each of the three options may be repeated,
and replaces all values of its kind; one '' removes them.
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] unlock <name>
vpn-router-user [--json] remove <name>
vpn-router-user [--json] profile <name>
vpn-router-user [--json] otp-new <name>
vpn-router-user [--json] otp-enrol <name> <code> (secret on stdin)
vpn-router-user [--json] otp-remove <name>
vpn-router-user [--json] console <name> (--posix | --account LOCAL | --off)
vpn-router-user [--json] passkeys-remove <name>
"""
import argparse
import json
import sys
sys.path.insert(0, '/usr/lib/vpn-router')
import ldap
import vpnrouter
import vpnrouter_auth
import vpnrouter_passkeys
import vpnrouter_s3
import vpnrouter_users
from vpnrouter_users import Failure
#: The help line of each command.
HELP = {
'list': 'list the users',
'add': 'add a VPN user with the three person options and issue the certificate, or without the names a '
'Manager-only account, or with --service a service account, each with an optional e-mail address; the '
'password is read from standard input',
'update': "change the first name, the last name or the e-mail address; a Manager-only account has only the "
"e-mail address, which '' removes",
'passwd': 'set the password, read from standard input',
'disable': 'lock the user out of OpenVPN',
'enable': 'let the user in again',
'unlock': 'end a lockout after failed logins',
'remove': 'revoke the certificate and remove the user',
'profile': 'print the OpenVPN client profile',
'otp-new': 'make a TOTP secret and its otpauth URI; nothing is stored',
'otp-enrol': 'store the TOTP secret read from standard input, when the code is a code of it',
'otp-remove': 'remove the TOTP token',
'console': "switch the console login of a member of admins with a TOTP token on, as the user's own POSIX account "
'in the directory or as a local account, or off',
'passkeys-remove': "remove all the user's passkeys",
}
#: The help of the person options.
PERSON_HELP = {'first_name': 'the first name', 'last_name': 'the last name', 'email': 'the e-mail address',
'display_name': "a service account's display name; '' removes it",
'description': "a service account's description; '' removes it"}
#: The OpenID Provider client settings of a service account, their options and help.
CLIENT_OPTIONS = (
('redirect_uris', 'redirect-uri', 'a redirect URI of the OpenID Provider client'),
('post_logout_redirect_uris', 'post-logout-redirect-uri', 'a post-logout redirect URI of the OpenID Provider client'),
('allowed_groups', 'allowed-group', 'a group whose members may sign in to the OpenID Provider client'),
)
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-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 + vpnrouter_users.LABELS:
sub.add_argument(f'--{key.replace("_", "-")}', dest=key, metavar='TEXT', help=PERSON_HELP[key])
for key, option, text in CLIENT_OPTIONS:
sub.add_argument(f'--{option}', dest=key, action='append', metavar='VALUE',
help=f"{text}, of a service account; repeat it for more, '' for none")
if command == 'add':
sub.add_argument('--service', action='store_true', help='add a service account, which cannot sign in to the web service')
if command == 'otp-enrol':
sub.add_argument('code', help='a code of the secret from the authenticator')
if command == 'console':
choice = sub.add_mutually_exclusive_group(required=True)
choice.add_argument('--posix', action='store_true', help="run the console as the user's own POSIX account")
choice.add_argument('--account', metavar='LOCAL', help='run the console as this local account')
choice.add_argument('--off', action='store_true', help='switch console login off')
return parser.parse_args(argv)
def password():
"""The password or the TOTP secret from the first line of standard input."""
return sys.stdin.readline().rstrip('\n')
def run(connection, options, secret):
"""The answer of the command the options name; secret is the password of add and passwd, the TOTP secret of otp-enrol."""
command = options.command
if command == 'list':
return vpnrouter_users.list_users(connection)
values = {key: getattr(options, key, None) for key, _ in vpnrouter_users.PERSON + vpnrouter_users.LABELS}
for key, _, _ in CLIENT_OPTIONS:
given = getattr(options, key, None)
if given is not None:
values[key] = [value for value in given if value]
if command == 'add':
return vpnrouter_users.add(connection, options.name, secret, values, service=options.service)
if command == 'update':
return vpnrouter_users.update(connection, options.name, values)
if command == 'passwd':
return vpnrouter_users.passwd(connection, options.name, secret)
if command == 'otp-enrol':
return vpnrouter_users.otp_enrol(connection, options.name, secret, options.code)
if command == 'console':
return vpnrouter_users.console(connection, options.name, not options.off, options.account)
if command == 'passkeys-remove':
return vpnrouter_passkeys.remove(connection, options.name)
return getattr(vpnrouter_users, command.replace('-', '_'))(connection, options.name)
def console_text(user):
"""The console login of a user as a cell: -, off, posix (uid N) or local and the account, refused without a TOTP
token."""
console = user['console']
if console is None or not console['enabled']:
return '-' if console is None else 'off'
text = f'local {console["account"]}' if console['local'] else f'posix (uid {console["uid"]})'
return text if user['otp'] else f'{text}, refused: no OTP'
def client_text(user):
"""The OpenID Provider client settings of a service account as a cell: -, or the numbers of its URIs and its allowed
groups."""
if not user.get('redirect_uris'):
return '-'
groups = ','.join(user['allowed_groups']) or 'all users'
return f'{len(user["redirect_uris"])} redirect, {len(user["post_logout_redirect_uris"])} logout, {groups}'
def text_of(answer):
"""The answer as text for people."""
if 'profile' in answer:
return answer['profile'].rstrip('\n')
if 'secret' in answer:
return f'secret {answer["secret"]}\nuri {answer["uri"]}'
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', 'KIND', 'STATE', 'CERTIFICATE', 'OTP', 'PASSKEYS', 'CONSOLE', 'FIRST NAME', 'LAST NAME', 'E-MAIL',
'OIDC CLIENT')
rows = [(user['name'], 'internal' if user['internal'] else 'service' if user['service'] else 'manager' if user['manager_only'] else 'vpn',
'disabled' if not user['enabled'] else 'locked' if user['locked'] else 'enabled',
'issued' if user['certificate'] else 'missing',
'enrolled' if user['otp'] else '-', str(len(user['passkeys'])) if user['passkeys'] else '-', console_text(user),
user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-', client_text(user))
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:
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', 'otp-enrol') else None
with vpnrouter_users.session() as connection:
answer = run(connection, options, secret)
if options.command in vpnrouter_auth.REVOKING:
vpnrouter_auth.revoke(vpnrouter.load_config(), options.name)
if options.command in vpnrouter_s3.FOLLOWING and (problem := vpnrouter_s3.follow(options.name)):
print(f'vpn-router-user: {problem}', file=sys.stderr)
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
except vpnrouter_auth.DatabaseError as exc:
message, status = str(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__':
sys.exit(main(sys.argv[1:]))