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

Run by vpn-router-web.service. It needs nothing but the Python standard library.

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; needs an HS256 JWT in an Authorization: Bearer header
           that is signed with web.jwt_secret and expires within ten minutes; not
           served (404) while web.jwt_secret is not set

/ 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) and
WireGuard. No configuration data, key or address is shown.

The address to bind to, the port, the secret and the configuration shown by /config
are not read from the configuration here: vpn-router-setup resolves them, with the
interfaces, and writes them to /run/vpn-router/web.json, readable by root only, before
it restarts this service. The service ends with
success when the file says the service is disabled, and with failure, to be
started again, when the file is missing or the router has no internal address yet.
"""

import base64
import hashlib
import hmac
import json
import os
import signal
import subprocess
import sys
import threading
import time
from html import escape
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

STATE_FILE = '/run/vpn-router/web.json'
COMMAND_TIMEOUT = 5
CACHE_SECONDS = 2

#: Seconds of clock difference accepted between a token and this router.
LEEWAY = 60

#: The longest a token may be valid, from now, in seconds.
MAX_LIFETIME = 600

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

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


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.
    """
    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 ('}', ']'):
            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 collect(state):
    """The status document. state says which features the router has."""
    units = {'vpn-router-setup': unit_state('vpn-router-setup.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')

    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()}
    return status


def b64decode(part):
    return base64.urlsafe_b64decode(part + '=' * (-len(part) % 4))


def verify(token, secret):
    """The claims of a token signed with HS256 by the secret, or None when it is not acceptable.

    Only HS256 is accepted, so a token cannot choose a weaker algorithm. The token
    must expire (exp), and not later than MAX_LIFETIME seconds from now, so that a
    stolen token is of little use; LEEWAY seconds of clock difference are allowed.
    """
    try:
        header, payload, signature = token.split('.')
        if json.loads(b64decode(header)).get('alg') != 'HS256':
            return None
        expected = hmac.new(secret.encode(), f'{header}.{payload}'.encode(), hashlib.sha256).digest()
        if not hmac.compare_digest(expected, b64decode(signature)):
            return None
        claims = json.loads(b64decode(payload))
        now = time.time()
        expires = claims['exp']
        if isinstance(expires, bool) or not isinstance(expires, (int, float)) or not now - LEEWAY <= expires <= now + MAX_LIFETIME + LEEWAY:
            return None
        not_before = claims.get('nbf')
        if not_before is not None and (isinstance(not_before, bool) or not isinstance(not_before, (int, float)) or not_before > now + LEEWAY):
            return None
    except (ValueError, KeyError, TypeError, AttributeError):
        return None
    return claims


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 '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' if status['status'] == 'ok' else 'bg-amber-600'
    body = ''.join(f'<li>{escape(line)}</li>' for line in text_of(status).splitlines()[4:] if line)
    return f"""<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>VPN Router</title>
    <script src="{TAILWIND}"></script>
  </head>
  <body class="min-h-screen bg-slate-100 text-slate-900">
    <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">
        <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>
        <ul class="space-y-2 px-6 py-6 font-mono text-sm">{body}</ul>
        <p class="px-6 pb-6 text-xs text-slate-500">{escape(status['time'])} - <a class="underline" href="/status">JSON</a></p>
      </div>
    </div>
  </body>
</html>
"""


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(self.server.state)
                _cache['at'] = time.monotonic()
            return _cache['data']

    def answer(self, send_body):
        path = self.path.split('?', 1)[0]
        extra = []
        if path == '/config':
            secret = self.server.state.get('jwt_secret')
            header = self.headers.get('Authorization', '')
            if not secret:
                body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
            elif not header.startswith('Bearer ') or verify(header[7:].strip(), secret) is None:
                body, kind, code = b'Unauthorized\n', 'text/plain; charset=utf-8', 401
                extra.append(('WWW-Authenticate', 'Bearer'))
            else:
                body, kind, code = json.dumps(details(self.server.state), indent=2).encode() + b'\n', 'application/json', 200
        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 do_GET(self):
        self.answer(True)

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

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


def main():
    try:
        with open(STATE_FILE, encoding='utf-8') as handle:
            state = json.load(handle)
    except (OSError, ValueError) as exc:
        print(f'vpn-router-web: cannot read {STATE_FILE}: {exc}', file=sys.stderr)
        return 1

    if not state.get('enabled'):
        print('vpn-router-web: the web service is disabled in the configuration')
        return 0
    if not state.get('bind'):
        print('vpn-router-web: the router has no internal address yet', file=sys.stderr)
        return 1

    server = ThreadingHTTPServer((state['bind'], state['port']), Handler)
    server.state = state
    signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
    print(f'vpn-router-web: serving on http://{state["bind"]}:{state["port"]}')
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        server.server_close()
    return 0


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