#!/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
  /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())
  /account the account of the signed-in user: its details, its password and its TOTP
           enrolment (see account())
  /auth    sign-in with a user's password and TOTP code, refresh and sign-out (see auth())

/config, /users, /groups 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
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, the default,
whether each service runs, whether Docker is installed, and the state of site-to-site, 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 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(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 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 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(),
    }
    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 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 is full.
    """
    if content not in vpnrouter.STATUS_CONTENTS:
        content = 'full'
    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 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 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"]}')
    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 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 last three 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
    """
    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),
    }
    # 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:
            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 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 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 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."""
    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'))
            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', 'account') 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 directory == 'account':
            code, document = account(claims['sub'], self.command, path, payload)
            body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
        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 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())
