#!/usr/bin/python3 """Manage the web sites Caddy serves from the command line, with vpnrouter_sites. A site has a name, which also names its folder in /var/lib/vpn-router/sites, host names, a port, the interfaces whose firewall opens it, TLS (none, acme, ca with --ca, or file with --certificate and --key), the file served for a folder, whether any path without a file is served that file of the site (--spa), and the group whose members change its files. A change is applied by restarting vpn-router-setup without waiting for it. 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 site does not exist, 4 the site exists, 1 anything else. -h or --help shows the commands and their options. vpn-router-site [--json] list vpn-router-site [--json] show vpn-router-site [--json] add [options] vpn-router-site [--json] update [options] vpn-router-site [--json] remove [--content] options: [--host H ...] [--no-hosts] [--port P] [--listen internal|external|both] [--tls none|acme|ca|file] [--ca NAME] [--certificate FILE] [--key FILE] [--index FILE] [--spa | --no-spa] [--enabled | --no-enabled] [--editors GROUP] """ import argparse import json import subprocess import sys sys.path.insert(0, '/usr/lib/vpn-router') import ldap import vpnrouter import vpnrouter_sites from vpnrouter_sites import Failure #: The help line of each command. HELP = { 'list': 'list the sites', 'show': 'show a site', 'add': 'add a site', 'update': 'change the settings of a site', 'remove': 'remove a site; its folder is kept without --content', } 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-site', description='Manage the web sites 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 site name') if command in ('add', 'update'): sub.add_argument('--host', dest='hosts', action='append', metavar='HOST', help='a host name; give it once for each name, replacing the current ones') sub.add_argument('--no-hosts', action='store_true', help='answer any host name') sub.add_argument('--port', type=int, metavar='PORT', help='the TCP port') sub.add_argument('--listen', choices=vpnrouter_sites.LISTEN, help='the interfaces whose firewall opens the port') sub.add_argument('--tls', choices=vpnrouter_sites.TLS, help='where the certificate comes from') sub.add_argument('--ca', metavar='NAME', help='the issuing CA of tls ca') sub.add_argument('--certificate', metavar='FILE', help='the PEM certificate chain of tls file') sub.add_argument('--key', metavar='FILE', help='the PEM private key of tls file') sub.add_argument('--index', metavar='FILE', help='the file served for a folder') sub.add_argument('--spa', action=argparse.BooleanOptionalAction, help='serve the index of the site for any ' 'path without a file') sub.add_argument('--enabled', action=argparse.BooleanOptionalAction, help='serve the site') sub.add_argument('--editors', metavar='GROUP', help="the group whose members change the files; '' for none") if command == 'remove': sub.add_argument('--content', action='store_true', help='remove the site\'s folder too') return parser.parse_args(argv) def values_of(options): """The settings the options give.""" values = {key: getattr(options, option) for key, option in (('port', 'port'), ('listen', 'listen'), ('tls', 'tls'), ('ca', 'ca'), ('index_file', 'index'), ('spa', 'spa'), ('enabled', 'enabled'), ('editors', 'editors')) if getattr(options, option) is not None} if options.no_hosts: values['hosts'] = [] elif options.hosts: values['hosts'] = options.hosts for key, option in (('certificate', 'certificate'), ('private_key', 'key')): path = getattr(options, option) if path is not None: try: with open(path, encoding='utf-8') as handle: values[key] = handle.read() except OSError as exc: raise Failure(f'{path} cannot be read: {exc.strerror}', 2) return values def run(options): """The answer of the command the options name, and whether it changed a site.""" command = options.command if command == 'list': return {'sites': vpnrouter_sites.listing()}, False if command == 'show': with vpnrouter_sites.database() as connection: return vpnrouter_sites.shown(vpnrouter_sites.get(connection, options.name)), False if command == 'remove': return vpnrouter_sites.remove(options.name, options.content), True cfg = vpnrouter.load_config() return vpnrouter_sites.save(cfg, options.name, values_of(options), command == 'add'), True def text_of(answer): """The answer as text for people.""" if answer.get('removed'): return f'site {answer["name"]} removed' sites = answer['sites'] if 'sites' in answer else [answer] if not sites: return 'no sites' head = ('SITE', 'ENABLED', 'HOSTS', 'PORT', 'LISTEN', 'TLS', 'EDITORS') rows = [(site['name'], 'yes' if site['enabled'] else 'no', ', '.join(site['hosts']) or '*', str(site['port']), site['listen'], site['tls'] + (f' {site["ca"]}' if site['tls'] == 'ca' else ''), site['editors'] or '-') for site in sites] 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 answer, changed = run(options) except Failure as failure: message, status = str(failure), failure.status except vpnrouter_sites.DatabaseError as exc: message, status = str(exc), 1 except (ldap.LDAPError, OSError) as exc: message, status = f'the user directory failed: {exc}', 1 else: if changed: subprocess.run(['systemctl', 'restart', '--no-block', 'vpn-router-setup.service']) 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-site: {message}', file=sys.stderr) return status if __name__ == '__main__': sys.exit(main(sys.argv[1:]))