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

158 lines
7.4 KiB
Python
Executable File

#!/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 <name>
vpn-router-site [--json] add <name> [options]
vpn-router-site [--json] update <name> [options]
vpn-router-site [--json] remove <name> [--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:]))