#!/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 --first-name F --last-name L --email E (password on stdin) vpn-router-user [--json] add [--email E] (a Manager-only account; password on stdin) vpn-router-user [--json] add --service [--display-name N] [--description D] [--email E] (a service account; password on stdin) vpn-router-user [--json] update [--first-name F] [--last-name L] [--email E] vpn-router-user [--json] update [--display-name N] [--description D] [--email E] (a service account) vpn-router-user [--json] passwd (password on stdin) vpn-router-user [--json] disable vpn-router-user [--json] enable vpn-router-user [--json] unlock vpn-router-user [--json] remove vpn-router-user [--json] profile vpn-router-user [--json] otp-new vpn-router-user [--json] otp-enrol (secret on stdin) vpn-router-user [--json] otp-remove """ import argparse import json import sys sys.path.insert(0, '/usr/lib/vpn-router') import ldap import vpnrouter import vpnrouter_auth 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', } #: 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"} 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]) 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') 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} 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) return getattr(vpnrouter_users, command.replace('-', '_'))(connection, options.name) 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', 'FIRST NAME', 'LAST NAME', 'E-MAIL') 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 '-', 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: 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) 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:]))