#!/usr/bin/python3
"""Serve the status of the router on its internal address, its external address or both, as web.listen sets.

Run by vpn-router-web.service. Users are managed with vpnrouter_users, the module that
vpn-router-user uses too.

Pages:
  /        the status for people: plain text for curl and wget, a styled page
           (Tailwind CSS from a CDN) for a browser
  /status  the status as JSON
  /status/full
           the whole status as JSON, whatever web.status_content says, for the Router Manager
  /config  the configuration without its secrets, and what the router resolved and
           runs, with the installed Caddy and where it comes from, as JSON
  /users   the users in the router's directory, whether or not a P2S system is enabled: list,
           add, change, set the password, disable, enable, remove, the OpenVPN client
           profile, and TOTP enrolment (see users()); service accounts are not among them
  /service-accounts
           the service accounts: list, add, change the e-mail address, set the password,
           disable, enable, unlock and remove; the internal ones are only listed (see
           service_accounts())
  /groups  the groups of the router's directory: list, add, rename or describe, remove, and
           add and take out members (see groups())
  /account the account of the signed-in user: its details, its password and its TOTP
           enrolment (see account())
  /site-to-site
           the site-to-site connections of the configuration: list, add, change and remove,
           applied by vpn-router-setup (see site_to_site())
  /backup  the backup settings, the last run and the local archives: show, change and run
           now (see backup())
  /ca      the PKI: its settings, the root and issuing CAs, the certificates issued; change the
           settings, issue and revoke certificates, and take the certificate of an issuing CA
           and a root CRL signed offline (see ca())
  /pki/<name>.crt, /pki/<name>.crl
           the DER certificate and CRL of the root (root) and of each issuing CA, which the
           certificates name in their AIA and CRL distribution points; no authentication
  /auth   sign-in with a user's password and TOTP code, refresh and sign-out (see auth())
  /metrics/history
           the metrics the metrics service keeps, for a view: hour, day, month or year (see
           metrics_history())
  /postgresql
           the databases of each PostgreSQL cluster on the router with their sizes, as JSON (see
           postgresql_databases()); /config has the clusters' settings

/status/full, /config, /metrics/history, /postgresql, /users, /service-accounts, /groups, /site-to-site, /backup, /ca and /account need an HS256 JWT in an Authorization: Bearer header signed
with vpnrouter_auth.WEB_KEY (see vpnrouter_auth.verify). A token of /auth names a user in sub;
/account serves that user, and the other paths need a member of vpnrouter_users.ADMIN_GROUP. A
token of vpn-router-token has the admin claim and names no user; it may call every path but
/account, and only such a token may change ADMIN_GROUP: create, rename to it, change, remove it,
add or take out its members, and remove one of its members. These paths and /auth are not
served (404) while the key does not exist. /auth is limited to AUTH_LIMIT calls per client
address in AUTH_WINDOW seconds; the address of a call from one of web.trusted_proxies, by
default the address the call came in on, is the last entry of its X-Forwarded-For header.

/ and /status show only the status, as much as web.status_content says (see shown): basic, the
default, the overall status, the time and the client's address (its address as for the rate limit
below); standard also the package version, the installed version of vpn-router-manager
(manager_version, null when it is not installed) and the number of active and other services; full
whether each service runs, whether Docker is installed, and the state of each site-to-site connection, road-warrior
(IPsec P2S), OpenVPN and WireGuard. No configuration data or key is shown, and no address but the
client's.

The service first initializes, without listening: it makes the signing key and the refresh
tokens' database (vpnrouter_auth), retrying every INIT_RETRY_SECONDS until both are ready and
logging each failure. Then it listens. Its unit starts it only once wait-for-libpq has found libpq
in the linker cache, where psycopg looks it up.

The service needs nothing else to start and does not end on its own. Every DISCOVERY_SECONDS it
works out from /etc/vpn-router/vpn-router.json the addresses of the interfaces web.listen names,
internal, external or both, as general.mode directs, with web.port and web.enabled
(vpnrouter_net.web_listen), and runs a server on each (vpnrouter_serve.serve): it starts them,
moves them when an address or the port changes, and stops them while the service is disabled or
no address is known. Every request reads what it needs when it is made: the signing
key, the configuration, and the features of the router and the facts shown by /config from
/run/vpn-router/web.json, which vpn-router-setup writes. Before setup has written that file
the router has no features, and /config shows no configuration or interfaces.
"""

import collections
import configparser
import json
import os
import shutil
import signal
import sys
import threading
import time
import urllib.parse
from html import escape
from http.server import BaseHTTPRequestHandler

sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))

import ldap

import vpnrouter
import vpnrouter_auth
import vpnrouter_backup
import vpnrouter_metrics
import vpnrouter_net
import vpnrouter_pki
import vpnrouter_serve
import vpnrouter_users
from vpnrouter_status import established, established_each, openvpn_clients, run, swanctl, wireguard_peers

STATE_FILE = '/run/vpn-router/web.json'
CONFIG_FILE = '/etc/vpn-router/vpn-router.json'

#: Seconds between two attempts of the initialization.
INIT_RETRY_SECONDS = 15

#: The port when web.port is not set.
DEFAULT_PORT = 7081

#: Seconds between two discoveries of where the service should listen.
DISCOVERY_SECONDS = 30
CACHE_SECONDS = 2

#: The calls of /auth allowed per client address in AUTH_WINDOW seconds.
AUTH_LIMIT = 10
AUTH_WINDOW = 60

TAILWIND = 'https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4'

#: Seconds after which the status page in a browser reloads itself.
REFRESH_SECONDS = 60

#: The PostgreSQL settings /config shows for each cluster.
POSTGRESQL_SETTINGS = ('server_version', 'listen_addresses', 'port', 'max_connections', 'shared_buffers',
                       'effective_cache_size', 'work_mem', 'password_encryption', 'ssl', 'timezone')

#: The largest request body accepted, in bytes; a CSR or a certificate chain in PEM is a few kilobytes.
BODY_LIMIT = 65536

#: The settings of a site_to_site connection that /site-to-site adds and changes.
CONNECTION_SETTINGS = ('addrs', 'id', 'cidrs', 'local_cidrs', 'local_id_mode', 'auth', 'psk', 'remote_ca')

#: The HTTP status for each status of a vpnrouter_users.Failure; any other is 500.
USER_STATUS = {2: 400, 3: 404, 4: 409, 5: 401}

#: The HTTP status for each status of a vpnrouter_pki.Failure; any other is 500.
PKI_STATUS = {2: 400, 3: 404, 4: 409}

#: A file /pki/ serves: the certificate or the CRL of a CA, and its content type.
PUBLISHED = {'.crt': 'application/pkix-cert', '.crl': 'application/pkix-crl'}

_cache = {'at': 0.0, 'data': None}
_lock = threading.Lock()

#: The times of the recent /auth calls of each client address.
_auth_calls = collections.defaultdict(collections.deque)
_auth_lock = threading.Lock()


def read_json(path):
    """The object in a JSON file, or {} when the file is missing or holds no object."""
    try:
        with open(path, encoding='utf-8') as handle:
            data = json.load(handle)
    except (OSError, ValueError):
        return {}
    return data if isinstance(data, dict) else {}


def section(configuration, name):
    """A section of the configuration, or {} when it is missing or not an object."""
    value = configuration.get(name)
    return value if isinstance(value, dict) else {}


def current():
    """What a request needs, read now: the features and facts setup resolved, and the signing key.

    Before setup has run the router has no features, and without a key the protected paths and
    /auth are not served.
    """
    return dict(read_json(STATE_FILE), key=vpnrouter_auth.key())


def unit_state(unit):
    result = run('systemctl', 'is-active', unit)
    return result.stdout.strip() if result and result.stdout.strip() else 'unknown'


def package_version(package='vpn-router'):
    """The installed version of the package, or None when it is not installed."""
    result = run('dpkg-query', '-W', '-f=${Version}', package)
    return (result.stdout.strip() or None) if result and result.returncode == 0 else None


def docker_status():
    """Whether Docker is installed, and the state of its service and of the Docker rules when it is."""
    result = run('systemctl', 'list-unit-files', '--no-legend', 'docker.service')
    if result is None or result.returncode != 0 or not result.stdout.strip():
        return {'installed': False}
    return {
        'installed': True,
        'service': unit_state('docker.service'),
        'rules': unit_state('vpn-router-docker.service'),
    }


def postgresql_clusters():
    """The state of each PostgreSQL cluster unit (postgresql@<version>-<name>), or {'postgresql': 'no cluster'} without one.

    postgresql.service, which only runs /bin/true, is not reported.
    """
    lines = lines_of('systemctl', 'list-units', 'postgresql@*.service', '--all', '--plain', '--no-legend') or []
    names = [line.split()[0].removesuffix('.service') for line in lines if line.strip()]
    return {name: unit_state(f'{name}.service') for name in names} or {'postgresql': 'no cluster'}


def collect(state):
    """The status document. state says which features the router has."""
    units = {'vpn-router-setup': unit_state('vpn-router-setup.service'), 'slapd': unit_state('slapd.service'),
             **postgresql_clusters()}
    manager_version = package_version('vpn-router-manager')
    if manager_version:
        units['vpn-router-manager'] = unit_state('vpn-router-manager.service')
    if state.get('site_to_site') or state.get('p2s'):
        units['strongswan'] = unit_state('strongswan.service')
    if state.get('wireguard'):
        units['wg-quick@wg0'] = unit_state('wg-quick@wg0.service')
    if state.get('openvpn'):
        units['openvpn-server@vpn-router'] = unit_state('openvpn-server@vpn-router.service')

    status = {
        'status': 'ok' if all(value == 'active' for value in units.values()) else 'degraded',
        'version': package_version(),
        'manager_version': manager_version,
        'time': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
        'units': units,
        'docker': docker_status(),
    }
    names = state.get('site_to_site')
    if isinstance(names, list) and names:
        status['site_to_site'] = {'connections': {name: None if count is None else count > 0
                                                  for name, count in established_each(names).items()}}
    if state.get('p2s'):
        status['p2s'] = {'clients': established('road-warrior')}
    if state.get('wireguard'):
        status['wireguard'] = {'peers': wireguard_peers()}
    if state.get('openvpn'):
        status['openvpn'] = {'clients': openvpn_clients()}
    return status


def shown(status, content, client):
    """The status document as web.status_content shows it to the client at its address.

    Every content has status, time and client_ip, the client's address, and content, which one it is: basic nothing
    more; standard adds version, manager_version and services, the number of active units and of units in every
    other state; full adds the whole status document of collect. Content that is not one of
    vpnrouter.STATUS_CONTENTS, or not set, is basic.
    """
    if content not in vpnrouter.STATUS_CONTENTS:
        content = 'basic'
    if content == 'full':
        return {**status, 'content': content, 'client_ip': client}
    document = {'status': status['status'], 'content': content, 'time': status['time'], 'client_ip': client}
    if content == 'standard':
        active = sum(1 for value in status['units'].values() if value == 'active')
        document.update(version=status['version'], manager_version=status['manager_version'],
                        services={'active': active, 'failed': len(status['units']) - active})
    return document


def lines_of(*args):
    """The output lines of a command, or None when it fails."""
    result = run(*args)
    return result.stdout.splitlines() if result and result.returncode == 0 else None


def routes():
    """The IPv4 routes as ip -j prints them, or None when ip fails."""
    result = run('ip', '-j', '-4', 'route', 'show')
    if result is None or result.returncode != 0:
        return None
    try:
        return json.loads(result.stdout)
    except ValueError:
        return None


def wireguard_details():
    """The interface and the peers of wg0, with endpoints and allowed IPs; no private or preshared key."""
    result = run('wg', 'show', 'wg0', 'dump')
    if result is None or result.returncode != 0:
        return None
    rows = [line.split('\t') for line in result.stdout.splitlines()]
    now = int(time.time())
    return {
        'public_key': rows[0][1] if rows and len(rows[0]) > 2 else None,
        'listen_port': int(rows[0][2]) if rows and len(rows[0]) > 2 else None,
        'peers': [{
            'public_key': row[0],
            'endpoint': None if row[2] == '(none)' else row[2],
            'allowed_ips': row[3].split(','),
            'handshake_seconds_ago': now - int(row[4]) if int(row[4]) else None,
            'received_bytes': int(row[5]),
            'sent_bytes': int(row[6]),
        } for row in rows[1:] if len(row) >= 7],
    }


def postgresql_query(cluster, sql):
    """The JSON value an SQL statement answers on a cluster (version/name), run by psql as postgres, or None when it fails."""
    result = run('runuser', '-u', 'postgres', '--', 'psql', '--cluster', cluster, '-X', '-q', '-t', '-A',
                 '-v', 'ON_ERROR_STOP=1', '-d', 'postgres', '-c', sql)
    if result is None or result.returncode != 0:
        return None
    try:
        return json.loads(result.stdout)
    except ValueError:
        return None


def postgresql_clusters_of():
    """The PostgreSQL clusters of pg_lsclusters, each with version, name, port, status and data_directory, or None when it fails."""
    lines = lines_of('pg_lsclusters', '--no-header')
    if lines is None:
        return None
    clusters = []
    for line in lines:
        fields = line.split()
        if len(fields) >= 6:
            clusters.append({'version': fields[0], 'name': fields[1], 'port': int(fields[2]) if fields[2].isdigit() else None,
                             'status': fields[3], 'data_directory': fields[5]})
    return clusters


def postgresql_configuration():
    """The PostgreSQL clusters on the router with the settings of POSTGRESQL_SETTINGS of each one that is online (None
    for one that is not), or None when pg_lsclusters fails."""
    clusters = postgresql_clusters_of()
    if clusters is None:
        return None
    names = ', '.join(f"'{name}'" for name in POSTGRESQL_SETTINGS)
    for cluster in clusters:
        cluster['settings'] = postgresql_query(
            f"{cluster['version']}/{cluster['name']}",
            f'SELECT json_object_agg(name, current_setting(name) ORDER BY name) FROM pg_settings WHERE name IN ({names})',
        ) if cluster['status'].startswith('online') else None
    return {'clusters': clusters}


def postgresql_databases():
    """What /postgresql shows: the PostgreSQL clusters on the router, each with its databases (name, owner, encoding and
    size in bytes) and their total size, None for a cluster that is not online; clusters is None when pg_lsclusters fails."""
    clusters = postgresql_clusters_of()
    if clusters is None:
        return {'clusters': None}
    for cluster in clusters:
        databases = postgresql_query(
            f"{cluster['version']}/{cluster['name']}",
            "SELECT coalesce(json_agg(json_build_object('name', datname, 'owner', pg_get_userbyid(datdba), "
            "'encoding', pg_encoding_to_char(encoding), 'size', pg_database_size(oid)) ORDER BY datname), '[]') "
            'FROM pg_database',
        ) if cluster['status'].startswith('online') else None
        cluster['databases'] = databases
        cluster['size'] = sum(database['size'] for database in databases) if isinstance(databases, list) else None
    return {'clusters': clusters}


def details(state):
    """What /config shows: the configuration without its secrets, and what the router resolved and runs; caddy is the
    installed Caddy, {version, source}, null when it is not installed."""
    configuration = state.get('configuration', {})
    caddy = package_version('caddy')
    return {
        'configuration': configuration,
        'network': dict(state.get('network', {}), routes=routes()),
        'ipsec': {
            'connections': swanctl('--list-conns'),
            'sas': swanctl('--list-sas'),
        },
        'wireguard': wireguard_details(),
        'docker': {
            'exposed_ports': configuration.get('docker', {}).get('exposed_ports', []),
            'rules': lines_of('iptables', '-S', 'VPN-ROUTER-DOCKER'),
        },
        'postgresql': postgresql_configuration(),
        # Caddy is upstream's once its repository is configured (vpn-router-caddy).
        'caddy': {'version': caddy, 'source': 'upstream' if vpnrouter.CADDY_APT_SOURCE.is_file() else 'ubuntu'}
                 if caddy else None,
    }


def text_of(status):
    """The status document of shown for people, as plain text: a header, a blank line, then name: value lines."""
    lines = ['VPN Router', f'Status: {status["status"]}']
    if 'version' in status:
        lines.append(f'Version: {status["version"] or "unknown"}')
    lines += ['', f'Client IP: {status["client_ip"]}']
    if 'manager_version' in status:
        lines.append(f'Router Manager: {status["manager_version"] or "not installed"}')
    if 'services' in status:
        lines.append(f'Services: {status["services"]["active"]} active, {status["services"]["failed"]} failed')
    lines += [f'{name}: {value}' for name, value in status.get('units', {}).items()]
    if 'docker' in status:
        docker = status['docker']
        lines.append('Docker: not installed' if not docker['installed']
                     else f'Docker: installed, service {docker["service"]}, rules {docker["rules"]}')
    for name, value in status.get('site_to_site', {}).get('connections', {}).items():
        lines.append(f'Site to site {name}: {"unknown" if value is None else "established" if value else "not established"}')
    if 'p2s' in status:
        clients = status['p2s']['clients']
        lines.append(f'Road-warrior (IPsec P2S): {"unknown" if clients is None else f"{clients} connected"}')
    if 'openvpn' in status:
        clients = status['openvpn']['clients']
        lines.append(f'OpenVPN: {"unknown" if clients is None else f"{clients} connected"}')
    if 'wireguard' in status:
        peers = status['wireguard']['peers']
        if peers is None:
            lines.append('WireGuard: down')
        else:
            lines.append(f'WireGuard: {len(peers)} peers')
            for number, peer in enumerate(peers, 1):
                age = peer['handshake_seconds_ago']
                lines.append(f'  peer {number}: {"no handshake yet" if age is None else f"handshake {age} s ago"}, '
                             f'received {peer["received_bytes"]} B, sent {peer["sent_bytes"]} B')
    return '\n'.join(lines) + '\n'


def html_of(status):
    """The status document of shown for a browser, as a page styled with Tailwind CSS."""
    colour = 'bg-emerald-600 dark:bg-emerald-700' if status['status'] == 'ok' else 'bg-amber-600 dark:bg-amber-700'
    version = f', version {status["version"] or "unknown"}' if 'version' in status else ''
    # Each "name: value" line after the header of the text is a row; indented lines, such as WireGuard peers, are indented here too.
    rows = []
    for line in text_of(status).split('\n\n', 1)[1].splitlines():
        if not line:
            continue
        name, _, value = line.partition(': ')
        indent = ' pl-8' if line.startswith(' ') else ''
        rows.append(f'<tr class="border-t border-slate-200 first:border-t-0 dark:border-slate-700">'
                    f'<th class="py-2 pr-4{indent} text-left font-medium text-slate-600 dark:text-slate-300">{escape(name.strip())}</th>'
                    f'<td class="py-2">{escape(value)}</td></tr>')
    body = ''.join(rows)
    return f"""<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="color-scheme" content="light dark">
    <meta http-equiv="refresh" content="{REFRESH_SECONDS}">
    <title>VPN Router</title>
    <script src="{TAILWIND}"></script>
  </head>
  <body class="min-h-screen bg-slate-100 text-slate-900 dark:bg-slate-900 dark:text-slate-100">
    <div class="mx-auto max-w-2xl px-4 py-10">
      <div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-800">
        <div class="{colour} px-6 py-4 text-white">
          <h1 class="text-xl font-semibold">VPN Router</h1>
          <p class="mt-1 text-sm">Status: {escape(status['status'] + version)}</p>
        </div>
        <div class="px-6 py-4">
          <table class="w-full text-sm"><tbody>{body}</tbody></table>
        </div>
        <p class="px-6 pb-6 text-xs text-slate-500 dark:text-slate-400">{escape(status['time'])} - <a class="underline" href="/status">JSON</a></p>
      </div>
    </div>
  </body>
</html>
"""


def users(state, method, path, body):
    """Answer a /users request with vpnrouter_users: the HTTP status and the JSON document.

      GET    /users                   the users
      POST   /users                   add a user: {"name", "password", "first_name", "last_name", "email"};
                                      without the names a Manager-only account
      PUT    /users/<name>            change any of {"first_name", "last_name", "email"}
      PUT    /users/<name>/password   set the password: {"password": ...}
      POST   /users/<name>/disable    lock the user out of OpenVPN
      POST   /users/<name>/enable     let the user in again
      POST   /users/<name>/unlock     end a lockout after failed logins
      DELETE /users/<name>            remove the user and revoke the certificate
      GET    /users/<name>/profile    the OpenVPN client profile
      POST   /users/<name>/otp        a new TOTP secret and its otpauth URI; nothing is stored
      PUT    /users/<name>/otp        store a TOTP secret: {"secret", "code"}, code a code of the secret
      DELETE /users/<name>/otp        remove the TOTP token

    Service accounts are served by /service-accounts: they are not listed, and a request naming one is 404.
    """
    if body == 'invalid' or (body is not None and not isinstance(body, dict)):
        return 400, {'error': 'the body must be a JSON object'}
    body = body or {}
    parts = path.strip('/').split('/')
    route = (method, len(parts), parts[2] if len(parts) == 3 else None)
    name = parts[1] if len(parts) > 1 else None
    actions = {
        ('GET', 1, None): lambda connection: {
            'users': [user for user in vpnrouter_users.list_users(connection)['users'] if not user['service']]},
        ('POST', 1, None): lambda connection: vpnrouter_users.add(connection, body.get('name'), body.get('password'), body),
        ('PUT', 2, None): lambda connection: vpnrouter_users.update(connection, name, body),
        ('PUT', 3, 'password'): lambda connection: vpnrouter_users.passwd(connection, name, body.get('password')),
        ('POST', 3, 'disable'): lambda connection: vpnrouter_users.disable(connection, name),
        ('POST', 3, 'enable'): lambda connection: vpnrouter_users.enable(connection, name),
        ('POST', 3, 'unlock'): lambda connection: vpnrouter_users.unlock(connection, name),
        ('DELETE', 2, None): lambda connection: vpnrouter_users.remove(connection, name),
        ('GET', 3, 'profile'): lambda connection: vpnrouter_users.profile(connection, name),
        ('POST', 3, 'otp'): lambda connection: vpnrouter_users.otp_new(connection, name),
        ('PUT', 3, 'otp'): lambda connection: vpnrouter_users.otp_enrol(connection, name, body.get('secret'), body.get('code')),
        ('DELETE', 3, 'otp'): lambda connection: vpnrouter_users.otp_remove(connection, name),
    }
    # The vpnrouter_users operation of each route that may revoke refresh tokens.
    operations = {('PUT', 3, 'password'): 'passwd', ('POST', 3, 'disable'): 'disable', ('POST', 3, 'unlock'): 'unlock',
                  ('DELETE', 2, None): 'remove'}
    if route not in actions:
        return 404, {'error': f'no route {method} {path}'}
    try:
        with vpnrouter_users.session() as connection:
            if name is not None and vpnrouter_users.service(vpnrouter_users.entry(connection, name)):
                raise vpnrouter_users.Failure(f'{name} is a service account; see /service-accounts', 3)
            document = actions[route](connection)
        if operations.get(route) in vpnrouter_auth.REVOKING:
            vpnrouter_auth.revoke(vpnrouter.load_config(), name)
    except vpnrouter_users.Failure as failure:
        return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
    except (ldap.LDAPError, OSError) as exc:
        return 500, {'error': f'the user directory failed: {exc}'}
    except vpnrouter_auth.DatabaseError as exc:
        return 500, {'error': str(exc)}
    except SystemExit as exc:
        # vpnrouter ends with SystemExit, carrying the reason, on a configuration it cannot read.
        return 500, {'error': str(exc.code)}
    return (201 if route == ('POST', 1, None) else 200), document


def service_accounts(method, path, body):
    """Answer a /service-accounts request with vpnrouter_users: the HTTP status and the JSON document.

      GET    /service-accounts                   the service accounts: {"service_accounts": [...]}
      POST   /service-accounts                   add one: {"name", "password", "display_name"?, "description"?, "email"?}
      PUT    /service-accounts/<name>            change any of {"display_name", "description", "email"}; "" removes one
      PUT    /service-accounts/<name>/password   set the password: {"password": ...}
      POST   /service-accounts/<name>/disable    lock it out
      POST   /service-accounts/<name>/enable     let it in again
      POST   /service-accounts/<name>/unlock     end a lockout after failed logins
      DELETE /service-accounts/<name>            remove it

    A name that is not a service account is 404. The internal service accounts (vpnrouter_users.internal_accounts),
    which the router manages, are only listed: every other request on one is refused with 403.
    """
    if body == 'invalid' or (body is not None and not isinstance(body, dict)):
        return 400, {'error': 'the body must be a JSON object'}
    body = body or {}
    parts = path.strip('/').split('/')
    route = (method, len(parts), parts[2] if len(parts) == 3 else None)
    name = parts[1] if len(parts) > 1 else None
    settings = {key: body.get(key) for key in ('email', 'display_name', 'description')}
    actions = {
        ('GET', 1, None): lambda connection: {
            'service_accounts': [user for user in vpnrouter_users.list_users(connection)['users'] if user['service']]},
        ('POST', 1, None): lambda connection: vpnrouter_users.add(connection, body.get('name'), body.get('password'), settings,
                                                                  service=True),
        ('PUT', 2, None): lambda connection: vpnrouter_users.update_service(connection, name, settings),
        ('PUT', 3, 'password'): lambda connection: vpnrouter_users.passwd(connection, name, body.get('password')),
        ('POST', 3, 'disable'): lambda connection: vpnrouter_users.disable(connection, name),
        ('POST', 3, 'enable'): lambda connection: vpnrouter_users.enable(connection, name),
        ('POST', 3, 'unlock'): lambda connection: vpnrouter_users.unlock(connection, name),
        ('DELETE', 2, None): lambda connection: vpnrouter_users.remove(connection, name),
    }
    if route not in actions:
        return 404, {'error': f'no route {method} {path}'}
    if name is not None and name in vpnrouter_users.internal_accounts():
        return 403, {'error': f'{name} is an internal service account, which the router manages'}
    try:
        with vpnrouter_users.session() as connection:
            if name is not None and not vpnrouter_users.service(vpnrouter_users.entry(connection, name)):
                raise vpnrouter_users.Failure(f'{name} is not a service account', 3)
            document = actions[route](connection)
    except vpnrouter_users.Failure as failure:
        return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
    except (ldap.LDAPError, OSError) as exc:
        return 500, {'error': f'the user directory failed: {exc}'}
    return (201 if route == ('POST', 1, None) else 200), document


def groups(state, method, path, body):
    """Answer a /groups request with vpnrouter_users: the HTTP status and the JSON document.

      GET    /groups                          the groups with their members
      POST   /groups                          add a group: {"name", "members": [...], "description"?}
      PUT    /groups/<name>                   rename it or change the description: {"name"?, "description"?}
      DELETE /groups/<name>                   remove the group; its members are kept
      POST   /groups/<name>/members           add a member: {"user": ...}
      DELETE /groups/<name>/members/<user>    take a member out; the last member cannot leave

    An internal service account (vpnrouter_users.internal_accounts) is not put in or taken out of a group: such a
    request is refused with 403.
    """
    if body == 'invalid' or (body is not None and not isinstance(body, dict)):
        return 400, {'error': 'the body must be a JSON object'}
    body = body or {}
    parts = path.strip('/').split('/')
    name = parts[1] if len(parts) > 1 else None
    route = (method, len(parts), parts[2] if len(parts) > 2 else None)
    members = body.get('members') if route == ('POST', 1, None) else [body.get('user')] if route == ('POST', 3, 'members') \
        else [parts[3]] if route == ('DELETE', 4, 'members') else []
    internal = sorted(set(vpnrouter_users.internal_accounts()).intersection(
        member for member in (members if isinstance(members, list) else []) if isinstance(member, str)))
    if internal:
        return 403, {'error': f'{", ".join(internal)}: internal service accounts, which the router manages, are not group members'}
    actions = {
        ('GET', 1, None): lambda connection: vpnrouter_users.list_groups(connection),
        ('POST', 1, None): lambda connection: vpnrouter_users.group_add(
            connection, body.get('name'), body.get('description'), body.get('members')),
        ('PUT', 2, None): lambda connection: vpnrouter_users.group_update(
            connection, name, body.get('name'), body.get('description')),
        ('DELETE', 2, None): lambda connection: vpnrouter_users.group_remove(connection, name),
        ('POST', 3, 'members'): lambda connection: vpnrouter_users.group_member_add(connection, name, body.get('user')),
        ('DELETE', 4, 'members'): lambda connection: vpnrouter_users.group_member_remove(connection, name, parts[3]),
    }
    if route not in actions:
        return 404, {'error': f'no route {method} {path}'}
    try:
        with vpnrouter_users.session() as connection:
            document = actions[route](connection)
    except vpnrouter_users.Failure as failure:
        return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
    except (ldap.LDAPError, OSError) as exc:
        return 500, {'error': f'the user directory failed: {exc}'}
    return (201 if route == ('POST', 1, None) else 200), document


def connection_of(name, connection, count):
    """A site_to_site connection as /site-to-site shows it: its settings but psk, psk_set and established.

    count is the number of its established IKE SAs, None when it is not known.
    """
    return {'name': name, **{option: value for option, value in connection.items() if option != 'psk'},
            'psk_set': bool(connection['psk']), 'established': None if count is None else count > 0}


def site_to_site(method, path, body):
    """Answer a /site-to-site request: the HTTP status and the JSON document.

      GET    /site-to-site          the connections, {"connections": [...]}: each with its settings but psk, with
                                    psk_set, whether psk holds a key, and established, whether its tunnel is up,
                                    null when swanctl cannot be asked
      POST   /site-to-site          add a connection: {"name", "addrs", "id", "cidrs", "local_cidrs", "local_id_mode",
                                    "auth", "psk", "remote_ca"}; psk is required with auth psk, the default, and an
                                    existing name is 409
      PUT    /site-to-site/<name>   change any of the settings of POST but name; an empty or absent psk keeps the
                                    current key, and a psk given replaces psk_file
      DELETE /site-to-site/<name>   remove the connection

    A change is made holding vpnrouter.config_lock, checked as vpn-router-setup checks the configuration (400 with
    the reason), written with vpnrouter.save_config, and applied by restarting vpn-router-setup without waiting for it.
    """
    if body == 'invalid' or (body is not None and not isinstance(body, dict)):
        return 400, {'error': 'the body must be a JSON object'}
    body = body or {}
    parts = path.strip('/').split('/')
    name = parts[1] if len(parts) == 2 else None
    if (method, len(parts)) not in (('GET', 1), ('POST', 1), ('PUT', 2), ('DELETE', 2)):
        return 404, {'error': f'no route {method} {path}'}
    try:
        if method == 'GET':
            connections = vpnrouter.load_config()['site_to_site']
            counts = established_each(connections)
            return 200, {'connections': [connection_of(key, value, counts[key]) for key, value in connections.items()]}
        settings = {option: body[option] for option in CONNECTION_SETTINGS if option in body}
        with vpnrouter.config_lock():
            data = vpnrouter.read_settings()
            connections = data.setdefault('site_to_site', {})
            if method == 'POST':
                name = body.get('name')
                if not isinstance(name, str) or not name:
                    return 400, {'error': 'name is required'}
                if name in connections:
                    return 409, {'error': f'connection {name} exists'}
                if settings.get('auth', 'psk') == 'psk' and (not isinstance(settings.get('psk'), str) or not settings['psk']):
                    return 400, {'error': 'psk is required with auth psk'}
                connections[name] = settings
            elif name not in connections:
                return 404, {'error': f'no connection {name}'}
            elif method == 'PUT':
                connection = dict(connections[name]) if isinstance(connections[name], dict) else {}
                if settings.get('psk'):
                    connection.pop('psk_file', None)
                else:
                    settings.pop('psk', None)
                connections[name] = {**connection, **settings}
            else:
                del connections[name]
            cfg = vpnrouter.with_defaults(data)
            vpnrouter.validate(cfg)
            vpnrouter.save_config(data)
    except vpnrouter.ConfigError as exc:
        return 400, {'error': str(exc)}
    except OSError as exc:
        return 500, {'error': f'the configuration cannot be written: {exc}'}
    except SystemExit as exc:
        return 500, {'error': str(exc.code)}
    run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
    if method == 'DELETE':
        return 200, {'name': name, 'removed': True}
    return (201 if method == 'POST' else 200), connection_of(name, cfg['site_to_site'][name], None)


def backup_settings(settings):
    """The backup section as /backup shows it: its settings but pbs_password, with pbs_password_set."""
    return {**{option: value for option, value in settings.items() if option != 'pbs_password'},
            'pbs_password_set': bool(settings['pbs_password'])}


def rclone_remotes():
    """The names of the remotes of vpnrouter_backup.RCLONE_CONFIG; [] when it cannot be read, as when it is encrypted."""
    parser = configparser.ConfigParser(interpolation=None)
    try:
        parser.read(vpnrouter_backup.RCLONE_CONFIG, encoding='utf-8')
    except (OSError, configparser.Error):
        return []
    return parser.sections()


def backup(method, path, body):
    """Answer a /backup request: the HTTP status and the JSON document.

      GET  /backup      {"settings", "state", "archives", "next", "client", "rclone"}: the backup section without
                        pbs_password, with pbs_password_set; the last run (vpnrouter_backup.state), null before the
                        first; the local archives of this host, newest first, [{name, time, size}], [] with the pbs
                        target; the next run of the timer as systemd shows it, null while backups are disabled;
                        whether proxmox-backup-client is installed; and {"installed", "config", "remotes"} of rclone
      PUT  /backup      change any of the settings of the backup section; an empty or absent pbs_password keeps the
                        current secret
      POST /backup/run  start a backup now, without waiting for it; 409 while one runs

    A change is made as /site-to-site makes one, and applied by restarting vpn-router-setup without waiting for it.
    """
    if body == 'invalid' or (body is not None and not isinstance(body, dict)):
        return 400, {'error': 'the body must be a JSON object'}
    body = body or {}
    if (method, path) == ('POST', '/backup/run'):
        if vpnrouter_backup.running():
            return 409, {'error': 'a backup is running'}
        result = run('systemctl', 'start', '--no-block', vpnrouter_backup.UNIT)
        if result is None or result.returncode != 0:
            return 500, {'error': f'the backup cannot be started: {result.stderr.strip() if result else "no systemctl"}'}
        return 202, {'started': True}
    if (method, path) not in (('GET', '/backup'), ('PUT', '/backup')):
        return 404, {'error': f'no route {method} {path}'}
    try:
        if method == 'GET':
            settings = vpnrouter.load_config()['backup']
            timer = run('systemctl', 'show', '-p', 'NextElapseUSecRealtime', '--value', vpnrouter_backup.TIMER)
            next_run = timer.stdout.strip() if timer and timer.returncode == 0 else ''
            return 200, {
                'settings': backup_settings(settings),
                'state': vpnrouter_backup.state() or None,
                'archives': vpnrouter_backup.archives(settings['path']) if settings['target'] == 'local' else [],
                'next': next_run if settings['enabled'] and next_run not in ('', 'n/a') else None,
                'client': vpnrouter_backup.PBS_CLIENT.exists(),
                'rclone': {'installed': shutil.which('rclone') is not None,
                           'config': vpnrouter_backup.RCLONE_CONFIG.is_file(), 'remotes': rclone_remotes()},
            }
        settings = {option: body[option] for option in vpnrouter.SCHEMA['backup'] if option in body}
        if not settings.get('pbs_password'):
            settings.pop('pbs_password', None)
        with vpnrouter.config_lock():
            data = vpnrouter.read_settings()
            current = data.get('backup')
            data['backup'] = {**(current if isinstance(current, dict) else {}), **settings}
            cfg = vpnrouter.with_defaults(data)
            vpnrouter.validate(cfg)
            vpnrouter.save_config(data)
    except vpnrouter.ConfigError as exc:
        return 400, {'error': str(exc)}
    except OSError as exc:
        return 500, {'error': f'the configuration cannot be written: {exc}'}
    except SystemExit as exc:
        return 500, {'error': str(exc.code)}
    run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
    return 200, {'settings': backup_settings(cfg['backup'])}


def ca(method, path, body):
    """Answer a /ca request with vpnrouter_pki: the HTTP status and the JSON document.

      GET  /ca                                  {"settings", "templates", "status", "certificates"}: the pki section,
                                                the templates with the built-in ones, the root, the issuing CAs, the
                                                retired CAs and the warnings (vpnrouter_pki.status), and the
                                                certificates issued, newest first
      PUT  /ca                                  change any of the settings of the pki section
      POST /ca/certificates                     issue a certificate: {"ca", "template", "csr"} answers {"serial",
                                                "certificate", "chain"}; {"ca", "template", "subject", "sans",
                                                "password"} answers {"serial", "pfx"}, a PKCS#12 file in base64
      POST /ca/certificates/<serial>/revoke     revoke a certificate: {"reason"?}
      GET  /ca/bundle/<name>                    the PEM certificates a client trusts the CA with: {"name", "pem"}; for
                                                root the root alone, for an issuing CA the issuing CA and the root
      GET  /ca/issuing/<name>/csr               the CSR of an issuing CA that waits for the offline root, with the
                                                openssl extensions to sign it with: {"name", "csr", "extensions"}
      PUT  /ca/issuing/<name>/certificate       take its certificate signed by the root: {"certificate"}
      PUT  /ca/root/crl                         take a root CRL signed offline: {"crl"}

    A change of the settings is made as /site-to-site makes one; it, a revoked router certificate and an imported
    issuing CA certificate are applied by restarting vpn-router-setup without waiting for it.
    """
    if body == 'invalid' or (body is not None and not isinstance(body, dict)):
        return 400, {'error': 'the body must be a JSON object'}
    body = body or {}
    parts = path.strip('/').split('/')
    route = (method, *parts[1:])
    try:
        cfg = vpnrouter.load_config()
        if len(route) == 3 and route[:2] == ('GET', 'bundle'):
            return 200, vpnrouter_pki.bundle(route[2])
        if route == ('GET',):
            return 200, {'settings': cfg['pki'], 'templates': vpnrouter.pki_templates(cfg),
                         'status': vpnrouter_pki.status(cfg), 'certificates': vpnrouter_pki.certificates()}
        if route == ('PUT',):
            settings = {option: body[option] for option in vpnrouter.SCHEMA['pki'] if option in body}
            with vpnrouter.config_lock():
                data = vpnrouter.read_settings()
                current = data.get('pki')
                data['pki'] = {**(current if isinstance(current, dict) else {}), **settings}
                cfg = vpnrouter.with_defaults(data)
                vpnrouter.validate(cfg)
                vpnrouter.save_config(data)
            run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
            return 200, {'settings': cfg['pki'], 'templates': vpnrouter.pki_templates(cfg)}
        with vpnrouter_pki.locked():
            if route == ('POST', 'certificates'):
                return 201, vpnrouter_pki.issue(cfg, body.get('ca'), body.get('template'), csr=body.get('csr'),
                                                subject=body.get('subject'), sans=body.get('sans'),
                                                password=body.get('password'))
            if len(route) == 4 and route[:2] == ('POST', 'certificates') and route[3] == 'revoke':
                document = vpnrouter_pki.revoke(cfg, route[2], body.get('reason'))
            elif len(route) == 4 and route[:2] == ('GET', 'issuing') and route[3] == 'csr':
                return 200, vpnrouter_pki.csr(cfg, route[2])
            elif len(route) == 4 and route[:2] == ('PUT', 'issuing') and route[3] == 'certificate':
                document = vpnrouter_pki.import_ca(cfg, route[2], body.get('certificate'))
            elif route == ('PUT', 'root', 'crl'):
                return 200, vpnrouter_pki.import_root_crl(body.get('crl'))
            else:
                return 404, {'error': f'no route {method} {path}'}
    except vpnrouter.ConfigError as exc:
        return 400, {'error': str(exc)}
    except vpnrouter_pki.Failure as failure:
        return PKI_STATUS.get(failure.status, 500), {'error': str(failure)}
    except ldap.LDAPError as exc:
        return 500, {'error': f'the directory, which holds the PKI, failed: {exc}'}
    except OSError as exc:
        return 500, {'error': f'the PKI cannot be changed: {exc}'}
    except SystemExit as exc:
        return 500, {'error': str(exc.code)}
    if route[1] == 'issuing' or document.get('reissue'):
        run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
    return 200, document


def account(name, method, path, body):
    """Answer an /account request of the user named by the token with vpnrouter_users: the HTTP status and the JSON document.

      GET  /account            the user, with admin, whether the user is a member of ADMIN_GROUP
      PUT  /account/password   set the password: {"current_password", "current_code"?, "password"}
      POST /account/otp        a new TOTP secret and its otpauth URI; nothing is stored
      PUT  /account/otp        store a TOTP secret: {"current_password", "current_code"?, "secret", "code"},
                               code a code of the secret

    A change needs the current password and, for an enrolled user, a current code; a wrong one is 403 and counts as
    a failed login. A password change revokes the user's refresh tokens.
    """
    if body == 'invalid' or (body is not None and not isinstance(body, dict)):
        return 400, {'error': 'the body must be a JSON object'}
    body = body or {}
    route = (method, path.rstrip('/'))
    if route not in (('GET', '/account'), ('PUT', '/account/password'), ('POST', '/account/otp'), ('PUT', '/account/otp')):
        return 404, {'error': f'no route {method} {path}'}
    try:
        if route == ('PUT', '/account/password'):
            vpnrouter_users.check_password(body.get('password'))
        if method == 'PUT':
            vpnrouter_users.login(name, body.get('current_password'), body.get('current_code'))
        with vpnrouter_users.session() as connection:
            if route == ('GET', '/account'):
                document = {**vpnrouter_users.summary(vpnrouter_users.entry(connection, name)),
                            'admin': vpnrouter_users.is_admin(connection, name)}
            elif route == ('PUT', '/account/password'):
                document = vpnrouter_users.passwd(connection, name, body['password'])
            elif route == ('POST', '/account/otp'):
                document = vpnrouter_users.otp_new(connection, name)
            else:
                document = vpnrouter_users.otp_enrol(connection, name, body.get('secret'), body.get('code'))
        if route == ('PUT', '/account/password'):
            vpnrouter_auth.revoke(vpnrouter.load_config(), name)
    except vpnrouter_users.Failure as failure:
        if failure.status == 5:
            return 403, {'error': 'the current password or code is wrong'}
        return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
    except (ldap.LDAPError, OSError) as exc:
        return 500, {'error': f'the user directory failed: {exc}'}
    except vpnrouter_auth.DatabaseError as exc:
        return 500, {'error': str(exc)}
    except SystemExit as exc:
        return 500, {'error': str(exc.code)}
    return 200, document


def metrics_history(view):
    """Answer GET /metrics/history?range=<view> with vpnrouter_metrics.history: the HTTP status and the JSON document.

    view is hour, the default, day, month or year (vpnrouter_metrics.RANGES).
    """
    if view not in vpnrouter_metrics.RANGES:
        return 400, {'error': f'range must be one of {", ".join(vpnrouter_metrics.RANGES)}'}
    try:
        with vpnrouter_metrics.database(vpnrouter.load_config()) as connection:
            return 200, vpnrouter_metrics.history(connection, view)
    except vpnrouter_metrics.DatabaseError as exc:
        return 500, {'error': str(exc)}
    except SystemExit as exc:
        return 500, {'error': str(exc.code)}


def changes_admins(connection, method, parts, body):
    """Whether a /users, /service-accounts or /groups request creates, renames to, changes or removes ADMIN_GROUP, or
    removes a member of it."""
    admins = vpnrouter_users.ADMIN_GROUP
    if parts[0] == 'groups':
        if method == 'POST' and len(parts) == 1:
            return body.get('name') == admins
        if method != 'GET' and len(parts) > 1 and parts[1] == admins:
            return True
        return method == 'PUT' and len(parts) == 2 and body.get('name') == admins
    if parts[0] in ('users', 'service-accounts') and method == 'DELETE' and len(parts) == 2:
        return admins in vpnrouter_users.groups_of(connection, parts[1])
    return False


def refusal(claims, method, path, body):
    """Why the holder of the claims may not make the request to a protected path, or '' when it may."""
    name = claims.get('sub')
    if path == '/account' or path.startswith('/account/'):
        return '' if isinstance(name, str) else 'the token names no user'
    if claims.get('admin') is True:
        return ''
    if not isinstance(name, str):
        return 'the token names no user'
    admins = vpnrouter_users.ADMIN_GROUP
    with vpnrouter_users.session() as connection:
        if not vpnrouter_users.is_admin(connection, name):
            return f'user {name} is not a member of {admins}'
        if changes_admins(connection, method, path.strip('/').split('/'), body if isinstance(body, dict) else {}):
            return f'only a token of vpn-router-token may change the group {admins} or remove its members'
    return ''


def auth(path, body):
    """Answer a POST to /auth with vpnrouter_users and vpnrouter_auth: the HTTP status and the JSON document.

      POST /auth           sign in: {"name", "password", "code"?}; the tokens
      POST /auth/refresh   {"refresh_token"}; new tokens, the refresh token used up
      POST /auth/logout    {"refresh_token"}; the refresh token revoked
    """
    if not isinstance(body, dict):
        return 400, {'error': 'the body must be a JSON object'}
    try:
        cfg = vpnrouter.load_config()
        if path == '/auth':
            vpnrouter_users.login(body.get('name'), body.get('password'), body.get('code'))
            with vpnrouter_users.session() as connection:
                if vpnrouter_users.service(vpnrouter_users.entry(connection, body['name'])):
                    raise vpnrouter_users.Failure('the name, the password or the code is wrong', 5)
            return 200, vpnrouter_auth.issue(cfg, body['name'])
        name = vpnrouter_auth.take(cfg, body.get('refresh_token'))
        if path == '/auth/logout':
            return 200, {'revoked': name is not None}
        if name is None:
            return 401, {'error': 'the refresh token is not valid'}
        with vpnrouter_users.session() as connection:
            user = vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
        if not user['enabled'] or user['locked']:
            return 401, {'error': f'user {name} is disabled or locked'}
        return 200, vpnrouter_auth.issue(cfg, name)
    except vpnrouter_users.Failure as failure:
        return (401 if failure.status == 3 else USER_STATUS.get(failure.status, 500)), {'error': str(failure)}
    except (ldap.LDAPError, OSError) as exc:
        return 500, {'error': f'the user directory failed: {exc}'}
    except vpnrouter_auth.DatabaseError as exc:
        return 500, {'error': str(exc)}
    except SystemExit as exc:
        return 500, {'error': str(exc.code)}


def auth_wait(address):
    """Seconds until the client address may call /auth again, 0 when it may now; a call that may is counted."""
    now = time.monotonic()
    with _auth_lock:
        for idle in [key for key, times in _auth_calls.items() if times[-1] <= now - AUTH_WINDOW]:
            del _auth_calls[idle]
        calls = _auth_calls[address]
        while calls and calls[0] <= now - AUTH_WINDOW:
            calls.popleft()
        if len(calls) >= AUTH_LIMIT:
            return int(calls[0] + AUTH_WINDOW - now) + 1
        calls.append(now)
        return 0


class Handler(BaseHTTPRequestHandler):
    protocol_version = 'HTTP/1.1'

    def status(self):
        with _lock:
            if _cache['data'] is None or time.monotonic() - _cache['at'] > CACHE_SECONDS:
                _cache['data'] = collect(current())
                _cache['at'] = time.monotonic()
            return _cache['data']

    def claims(self, secret):
        """The claims of the request's token signed with the secret, or None without an acceptable one."""
        header = self.headers.get('Authorization', '')
        return vpnrouter_auth.verify(header[7:].strip(), secret) if header.startswith('Bearer ') else None

    def client(self):
        """The client address: the last X-Forwarded-For entry when the peer is a trusted proxy, else the peer."""
        peer = self.client_address[0]
        trusted = section(read_json(CONFIG_FILE), 'web').get('trusted_proxies') or [self.server.server_address[0]]
        forwarded = self.headers.get('X-Forwarded-For', '')
        if isinstance(trusted, list) and peer in trusted and forwarded.strip():
            return forwarded.split(',')[-1].strip()
        return peer

    def refusal(self, claims, path, payload):
        """The HTTP status and the message that refuse a request to a protected path, or None when it may be made."""
        try:
            reason = refusal(claims, self.command, path, payload)
        except (ldap.LDAPError, OSError) as exc:
            return 500, f'the user directory failed: {exc}'
        return (403, reason) if reason else None

    def answer(self, send_body):
        path = self.path.split('?', 1)[0]
        extra = []
        self.principal = None
        # A body that is not read must not be taken for the next request.
        if self.command not in ('GET', 'HEAD'):
            self.close_connection = True
        directory = next((name for name in ('users', 'service-accounts', 'groups', 'account', 'site-to-site', 'backup',
                                            'ca')
                          if path == f'/{name}' or path.startswith(f'/{name}/')), None)
        protected = path in ('/status/full', '/config', '/metrics/history', '/postgresql') or directory is not None
        signing = path in ('/auth', '/auth/refresh', '/auth/logout')
        state = current() if protected or signing else {}
        claims = self.claims(state['key']) if protected and state['key'] else None
        payload = self.request_body() if (protected or signing) and self.command not in ('GET', 'HEAD') else None
        if claims is not None:
            self.principal = 'admin' if claims.get('admin') is True else claims.get('sub')
        if (protected or signing) and not state['key']:
            body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
        elif signing and self.command != 'POST':
            body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405
        elif signing:
            wait = auth_wait(self.client()) if path != '/auth/logout' else 0
            if wait:
                code, document = 429, {'error': f'too many sign-in calls; retry in {wait} seconds'}
                extra.append(('Retry-After', str(wait)))
            else:
                code, document = auth(path, payload)
            body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
        elif protected and claims is None:
            body, kind, code = b'Unauthorized\n', 'text/plain; charset=utf-8', 401
            extra.append(('WWW-Authenticate', 'Bearer'))
        elif protected and (refused := self.refusal(claims, path, payload)):
            code, kind = refused[0], 'application/json'
            body = json.dumps({'error': refused[1]}, indent=2).encode() + b'\n'
        elif path == '/status/full' and self.command in ('GET', 'HEAD'):
            document = shown(self.status(), 'full', self.client())
            body, kind, code = json.dumps(document, indent=2).encode() + b'\n', 'application/json', 200
        elif path == '/config' and self.command in ('GET', 'HEAD'):
            body, kind, code = json.dumps(details(state), indent=2).encode() + b'\n', 'application/json', 200
        elif path == '/postgresql' and self.command in ('GET', 'HEAD'):
            body, kind, code = json.dumps(postgresql_databases(), indent=2).encode() + b'\n', 'application/json', 200
        elif path == '/metrics/history' and self.command in ('GET', 'HEAD'):
            query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)
            code, document = metrics_history(query.get('range', ['hour'])[0])
            body, kind = json.dumps(document).encode() + b'\n', 'application/json'
        elif directory == 'account':
            code, document = account(claims['sub'], self.command, path, payload)
            body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
        elif directory == 'site-to-site':
            code, document = site_to_site(self.command, path, payload)
            body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
        elif directory == 'backup':
            code, document = backup(self.command, path, payload)
            body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
        elif directory == 'ca':
            code, document = ca(self.command, path, payload)
            body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
        elif directory == 'service-accounts':
            code, document = service_accounts(self.command, path, payload)
            body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
        elif directory is not None:
            code, document = (users if directory == 'users' else groups)(state, self.command, path, payload)
            body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
        elif self.command not in ('GET', 'HEAD'):
            body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405
        elif path.startswith('/pki/') and vpnrouter.PKI_NAME.match(path[5:-4]) and path[-4:] in PUBLISHED and \
                (vpnrouter_pki.PUBLISH_DIR / path[5:]).is_file():
            body, kind, code = (vpnrouter_pki.PUBLISH_DIR / path[5:]).read_bytes(), PUBLISHED[path[-4:]], 200
        elif path in ('/status', '/'):
            document = shown(self.status(), section(read_json(CONFIG_FILE), 'web').get('status_content'), self.client())
            agent = self.headers.get('User-Agent', '').lower()
            if path == '/status':
                body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
            elif 'curl' in agent or 'wget' in agent:
                body, kind = text_of(document).encode(), 'text/plain; charset=utf-8'
            else:
                body, kind = html_of(document).encode(), 'text/html; charset=utf-8'
            code = 200
        else:
            body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
        self.send_response(code)
        self.send_header('Content-Type', kind)
        self.send_header('Content-Length', str(len(body)))
        self.send_header('Cache-Control', 'no-store')
        for name, value in extra:
            self.send_header(name, value)
        self.end_headers()
        if send_body:
            self.wfile.write(body)

    def request_body(self):
        """The request body as JSON, None when there is none, or the string 'invalid'."""
        length = self.headers.get('Content-Length')
        if not length:
            return None
        try:
            size = int(length)
            if not 0 <= size <= BODY_LIMIT:
                return 'invalid'
            return json.loads(self.rfile.read(size) or b'null')
        except ValueError:
            return 'invalid'

    def do_GET(self):
        self.answer(True)

    def do_HEAD(self):
        self.answer(False)

    def do_POST(self):
        self.answer(True)

    def do_PUT(self):
        self.answer(True)

    def do_DELETE(self):
        self.answer(True)

    def log_message(self, format, *args):
        principal = getattr(self, 'principal', None) or '-'
        sys.stderr.write('%s - %s [%s] %s\n' % (self.client_address[0], principal, self.log_date_time_string(), format % args))


def log(message):
    print(f'vpn-router-web: {message}', file=sys.stderr, flush=True)


def initialize():
    """Make the signing key and the refresh tokens' database, retrying every INIT_RETRY_SECONDS until both are ready."""
    reported = None
    while True:
        phase = 'reading the configuration'
        try:
            cfg = vpnrouter.load_config()
            phase = 'making the signing key'
            vpnrouter_auth.ensure_key()
            phase = 'creating the token database'
            vpnrouter_auth.provision(cfg)
            phase = 'connecting to the token database'
            with vpnrouter_auth.database(cfg):
                pass
        except (OSError, SystemExit, vpnrouter_auth.DatabaseError) as exc:
            error = f'{phase} failed: {exc.code if isinstance(exc, SystemExit) else exc}'
            if error != reported:
                log(f'{error}; retrying every {INIT_RETRY_SECONDS} seconds')
                reported = error
            time.sleep(INIT_RETRY_SECONDS)
            continue
        log('ready')
        return


def main():
    signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
    initialize()
    try:
        vpnrouter_serve.serve(lambda: vpnrouter_net.web_listen(read_json(CONFIG_FILE), DEFAULT_PORT), Handler, log,
                              DISCOVERY_SECONDS)
    except KeyboardInterrupt:
        pass
    return 0


if __name__ == '__main__':
    sys.exit(main())
