#!/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 service needs nothing else to start and does not end on its own. A discovery thread works
out the internal address from /etc/vpn-router/vpn-router.json as general.mode directs, with
web.port and web.enabled, every DISCOVERY_SECONDS, and runs the server thread there: 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 token secret
from 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 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

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

import vpnrouter_net

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

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

#: Seconds between two discoveries of where the service should listen.
DISCOVERY_SECONDS = 30
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 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 token secret.

    Before setup has run the router has no features, and without a secret /config and /users are
    not served.
    """
    state = read_json(STATE_FILE)
    secret = section(read_json(CONFIG_FILE), 'web').get('jwt_secret')
    return dict(state, jwt_secret=secret if isinstance(secret, str) else '')


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

    def authorized(self, secret):
        """Whether the request carries a token signed with the secret; False also while no secret is set."""
        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/')
        state = current() if protected else {}
        if protected and not state['jwt_secret']:
            body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
        elif protected and not self.authorized(state['jwt_secret']):
            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(state), indent=2).encode() + b'\n', 'application/json', 200
        elif protected and path != '/config':
            code, document = users(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 wanted():
    """Where the service should listen now, as (address, port), or the reason it should not listen."""
    configuration = read_json(CONFIG_FILE)
    web = section(configuration, 'web')
    if web.get('enabled', True) is False:
        return None, 'the web service is disabled in the configuration'
    port = web.get('port', DEFAULT_PORT)
    if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
        port = DEFAULT_PORT
    general, interfaces, local = (section(configuration, name) for name in ('general', 'interfaces', 'local'))
    address = vpnrouter_net.internal_address(str(general.get('mode') or 'manual'), str(interfaces.get('internal') or ''),
                                             str(local.get('int_addr') or ''))
    if not address:
        return None, 'the internal address is not known yet'
    return (address, port), ''


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


def discover():
    """Work out where to listen every DISCOVERY_SECONDS, and run the server thread there.

    When the address or the port changes, the server thread is stopped and a new one listens at
    the new place; while the service is disabled, the address is not known or it cannot be bound,
    no server runs and the next round tries again.
    """
    server, thread, listening, reported = None, None, None, None
    while True:
        target, reason = wanted()
        if target != listening and server is not None:
            server.shutdown()
            server.server_close()
            thread.join()
            server, listening = None, None
        if target is not None and server is None:
            try:
                server = ThreadingHTTPServer(target, Handler)
            except OSError as exc:
                reason = f'cannot listen on {target[0]}:{target[1]}: {exc}'
            else:
                thread = threading.Thread(target=server.serve_forever, name='server', daemon=True)
                thread.start()
                listening, reason = target, f'serving on http://{target[0]}:{target[1]}'
        if reason and reason != reported:
            log(reason)
            reported = reason
        time.sleep(DISCOVERY_SECONDS)


def main():
    signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
    discovery = threading.Thread(target=discover, name='discovery', daemon=True)
    discovery.start()
    try:
        discovery.join()
    except KeyboardInterrupt:
        pass
    return 0


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