#!/usr/bin/python3
"""Serve the status of the router on its internal address.

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
  /config  the configuration without its secrets, and what the router resolved and
           runs, 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())
  /groups  the groups of the router's directory: list, add, rename or describe, remove, and
           add and take out members (see groups())
  /auth    sign-in with a user's password and TOTP code, refresh and sign-out (see auth())

/config, /users and /groups 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, who
must be a member of vpnrouter_users.ADMIN_GROUP; a token of vpn-router-token has the admin
claim, 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 service listens on, is the last entry of its X-Forwarded-For header.

/ and /status show only the status: whether the services run, the package version, whether
Docker is installed, and the state of site-to-site, road-warrior (IPsec P2S), OpenVPN and
WireGuard. No configuration data, key or address is shown.

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 the internal address from /etc/vpn-router/vpn-router.json as general.mode directs,
with web.port and web.enabled (vpnrouter_net.web_listen), and runs the server there
(vpnrouter_serve.serve): it starts it, moves it when the address or the port changes, and stops
it while the service is disabled or the address is not 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 json
import os
import signal
import subprocess
import sys
import threading
import time
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_net
import vpnrouter_serve
import vpnrouter_users

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
COMMAND_TIMEOUT = 5
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

OPENVPN_STATUS = '/run/openvpn-server/status-vpn-router.log'

#: The largest request body accepted, in bytes.
BODY_LIMIT = 4096

#: 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}

_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 run(*args):
    """The finished process of a command, or None when it cannot be run."""
    try:
        return subprocess.run(args, capture_output=True, text=True, timeout=COMMAND_TIMEOUT)
    except (OSError, subprocess.TimeoutExpired):
        return None


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():
    result = run('dpkg-query', '-W', '-f=${Version}', 'vpn-router')
    return result.stdout.strip() 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 swanctl(command):
    """The entries of a swanctl list command, or None when swanctl cannot be asked.

    The --pretty output has one item per line: "name {" opens a section, "key = [" a list
    whose items follow one per line, "}" and "]" close them, and "key = value" is a pair
    whose value may contain spaces. Every "... event {" section at the top holds one
    connection or SA; each becomes an entry with its name under "name". The closing
    "... reply {" section is left out. Values stay strings, as swanctl prints them.
    Output with more closing than opening lines gives None.
    """
    result = run('swanctl', command, '--pretty')
    if result is None or result.returncode != 0:
        return None
    events = []
    stack = []
    for line in result.stdout.splitlines():
        line = line.strip()
        if not line:
            continue
        if line in ('}', ']'):
            if not stack:
                return None
            stack.pop()
        elif not stack:
            section = {}
            if line.endswith(' event {'):
                events.append(section)
            stack.append(section)
        elif isinstance(stack[-1], list):
            stack[-1].append(line)
        elif line.endswith(' {'):
            stack[-1][line[:-2]] = section = {}
            stack.append(section)
        elif line.endswith(' = ['):
            stack[-1][line[:-4]] = items = []
            stack.append(items)
        else:
            key, _, value = line.partition(' = ')
            stack[-1][key] = value
    return [dict(body, name=name) for event in events for name, body in event.items()]


def established(connection):
    """The number of established IKE SAs of the connection, or None when swanctl cannot be asked."""
    sas = swanctl('--list-sas')
    if sas is None:
        return None
    return sum(1 for sa in sas if sa['name'] == connection and sa.get('state') == 'ESTABLISHED')


def wireguard_peers():
    """One entry per WireGuard peer, with its handshake age and traffic, or None when wg0 is down."""
    result = run('wg', 'show', 'wg0', 'dump')
    if result is None or result.returncode != 0:
        return None
    now = int(time.time())
    peers = []
    for line in result.stdout.splitlines()[1:]:
        fields = line.split('\t')
        if len(fields) < 7:
            continue
        handshake = int(fields[4])
        peers.append({
            'handshake_seconds_ago': now - handshake if handshake else None,
            'received_bytes': int(fields[5]),
            'sent_bytes': int(fields[6]),
        })
    return peers


def openvpn_clients():
    """The number of connected OpenVPN clients, from the server's status file, or None when it cannot be read."""
    try:
        with open(OPENVPN_STATUS, encoding='utf-8') as handle:
            return sum(1 for line in handle if line.startswith('CLIENT_LIST,'))
    except OSError:
        return None


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')}
    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(),
        'time': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
        'units': units,
        'docker': docker_status(),
    }
    if state.get('site_to_site'):
        count = established('remote-site')
        status['site_to_site'] = {'established': None if count is None else count > 0}
    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 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 details(state):
    """What /config shows: the configuration without its secrets, and what the router resolved and runs."""
    configuration = state.get('configuration', {})
    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'),
        },
    }


def text_of(status):
    """The status for people, as plain text."""
    lines = ['VPN Router', f'Status: {status["status"]}', f'Version: {status["version"] or "unknown"}', '']
    lines += [f'{name}: {value}' for name, value in status['units'].items()]
    docker = status['docker']
    lines.append('Docker: not installed' if not docker['installed']
                 else f'Docker: installed, service {docker["service"]}, rules {docker["rules"]}')
    if 'site_to_site' in status:
        value = status['site_to_site']['established']
        lines.append(f'Site to site: {"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 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'
    # Each "name: value" line of the text is a row; indented lines, such as WireGuard peers, are indented here too.
    rows = []
    for line in text_of(status).splitlines()[4:]:
        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 {escape(status['version'] or 'unknown')}</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"}
      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
    """
    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: vpnrouter_users.list_users(connection),
        ('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),
    }
    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}'}
    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 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
    """
    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)
    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 changes_admins(connection, method, parts, body):
    """Whether a /users 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] == 'users' 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."""
    if claims.get('admin') is True:
        return ''
    name = claims.get('sub')
    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'))
            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', 'groups') if path == f'/{name}' or path.startswith(f'/{name}/')), None)
        protected = path == '/config' 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 == '/config' and self.command in ('GET', 'HEAD'):
            body, kind, code = json.dumps(details(state), indent=2).encode() + b'\n', 'application/json', 200
        elif protected and path != '/config':
            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 == '/status':
            body, kind, code = json.dumps(self.status(), indent=2).encode() + b'\n', 'application/json', 200
        elif path == '/':
            agent = self.headers.get('User-Agent', '').lower()
            if 'curl' in agent or 'wget' in agent:
                body, kind = text_of(self.status()).encode(), 'text/plain; charset=utf-8'
            else:
                body, kind = html_of(self.status()).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())
