158 lines
7.4 KiB
Python
Executable File
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:]))
|