#!/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; users are
managed by running vpn-router-user.

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 of the P2S systems: list, add, set the password, disable, enable,
           remove, and the OpenVPN client profile (see users())

/config and /users need an HS256 JWT in an Authorization: Bearer header that is signed
with web.jwt_secret and expires within ten minutes; they are 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), OpenVPN 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'

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

USER_TOOL = '/usr/sbin/vpn-router-user'
OPENVPN_STATUS = '/run/openvpn-server/status-vpn-router.log'

#: Seconds a user command may take; adding a user creates an RSA key.
USER_TIMEOUT = 120

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

#: The HTTP status for each exit status of vpn-router-user; any other is 500.
USER_STATUS = {0: 200, 2: 400, 3: 404, 4: 409}

_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.
    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')}
    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')
    if state.get('users'):
        units['slapd'] = unit_state('slapd.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 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 '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 user_tool(*args, password=None):
    """Run vpn-router-user; the HTTP status for its exit status and its JSON answer."""
    try:
        result = subprocess.run([USER_TOOL, *args], input='' if password is None else password + '\n',
                                capture_output=True, text=True, timeout=USER_TIMEOUT)
        document = json.loads(result.stdout)
    except (OSError, subprocess.TimeoutExpired, ValueError) as exc:
        return 500, {'error': f'vpn-router-user failed: {exc}'}
    if not isinstance(document, dict):
        return 500, {'error': 'vpn-router-user gave no object'}
    return USER_STATUS.get(result.returncode, 500), document


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

      GET    /users                   the users
      POST   /users                   add a user: {"name": ..., "password": ...}
      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
      DELETE /users/<name>            remove the user and revoke the certificate
      GET    /users/<name>/profile    the OpenVPN client profile
    """
    if not state.get('users'):
        return 404, {'error': 'no P2S system with users is configured'}
    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 {}
    password = body.get('password')
    if password is not None and (not isinstance(password, str) or '\n' in password or '\r' in password):
        return 400, {'error': 'the password must be a string of one line'}

    parts = path.strip('/').split('/')
    route = (method, len(parts), parts[2] if len(parts) == 3 else None)
    if route == ('GET', 1, None):
        return user_tool('list')
    if route == ('POST', 1, None):
        name = body.get('name')
        if not isinstance(name, str) or password is None:
            return 400, {'error': 'name and password are required strings'}
        code, document = user_tool('add', name, password=password)
        return (201 if code == 200 else code), document
    if route == ('PUT', 3, 'password'):
        if password is None:
            return 400, {'error': 'password is a required string'}
        return user_tool('passwd', parts[1], password=password)
    if route in (('POST', 3, 'disable'), ('POST', 3, 'enable')):
        return user_tool(parts[2], parts[1])
    if route == ('DELETE', 2, None):
        return user_tool('remove', parts[1])
    if route == ('GET', 3, 'profile'):
        return user_tool('profile', parts[1])
    return 404, {'error': f'no route {method} {path}'}


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 authorized(self):
        """Whether the request carries a token signed with the secret; False also while no secret is set."""
        secret = self.server.state.get('jwt_secret')
        header = self.headers.get('Authorization', '')
        return bool(secret) and header.startswith('Bearer ') and verify(header[7:].strip(), secret) is not None

    def answer(self, send_body):
        path = self.path.split('?', 1)[0]
        extra = []
        # 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
        protected =path == '/config' or path == '/users' or path.startswith('/users/')
        if protected and not self.server.state.get('jwt_secret'):
            body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
        elif protected and not self.authorized():
            body, kind, code = b'Unauthorized\n', 'text/plain; charset=utf-8', 401
            extra.append(('WWW-Authenticate', 'Bearer'))
        elif path == '/config' and self.command in ('GET', 'HEAD'):
            body, kind, code = json.dumps(details(self.server.state), indent=2).encode() + b'\n', 'application/json', 200
        elif protected and path != '/config':
            code, document = users(self.server.state, self.command, path, self.request_body())
            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):
        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())
