feature: Caddy serves static web sites from folders on the router, defined in their own database, with files uploaded through the web service or WebDAV, and an optional PowerDNS name server answers for a zone of the sites.
Release / release (push) Successful in 21s

This commit is contained in:
slawek committed 2026-10-26 15:27:28 +01:00
1 parent 77bdbcb058
commit ea8ca97417
27 files changed
+2234 -134

No files matched your search

+157
View File
@@ -0,0 +1,157 @@
#!/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:]))