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

Run by vpn-router-web.service. Users are managed with vpnrouter_users, the module that
vpn-router-user uses too.

Pages:
  /        the status for people: plain text for curl and wget, a styled page
           (Tailwind CSS from a CDN) for a browser
  /status  the status as JSON
  /config  the configuration without its secrets, and what the router resolved and
           runs, as JSON
  /users   the users in the router's directory, whether or not a P2S system is enabled: list,
           add, change, set the password, disable, enable, remove, the OpenVPN client
           profile, and TOTP enrolment (see users())
  /groups  the groups of the router's directory: list, add, rename or describe, remove, and
           add and take out members (see groups())

/config, /users and /groups 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 ldap

import vpnrouter_net
import vpnrouter_users

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 = 7081

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

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}

_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'), 'slapd': unit_state('slapd.service')}
    if state.get('site_to_site') or state.get('p2s'):
        units['strongswan'] = unit_state('strongswan.service')
    if state.get('wireguard'):
        units['wg-quick@wg0'] = unit_state('wg-quick@wg0.service')
    if state.get('openvpn'):
        units['openvpn-server@vpn-router'] = unit_state('openvpn-server@vpn-router.service')

    status = {
        'status': 'ok' if all(value == 'active' for value in units.values()) else 'degraded',
        'version': package_version(),
        'time': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
        'units': units,
        'docker': docker_status(),
    }
    if state.get('site_to_site'):
        count = established('remote-site')
        status['site_to_site'] = {'established': None if count is None else count > 0}
    if state.get('p2s'):
        status['p2s'] = {'clients': established('road-warrior')}
    if state.get('wireguard'):
        status['wireguard'] = {'peers': wireguard_peers()}
    if state.get('openvpn'):
        status['openvpn'] = {'clients': openvpn_clients()}
    return status


def 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 users(state, method, path, body):
    """Answer a /users request with vpnrouter_users: the HTTP status and the JSON document.

      GET    /users                   the users
      POST   /users                   add a user: {"name", "password", "first_name", "last_name", "email"}
      PUT    /users/<name>            change any of {"first_name", "last_name", "email"}
      PUT    /users/<name>/password   set the password: {"password": ...}
      POST   /users/<name>/disable    lock the user out of OpenVPN
      POST   /users/<name>/enable     let the user in again
      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),
        ('DELETE', 2, None): lambda connection: vpnrouter_users.remove(connection, name),
        ('GET', 3, 'profile'): lambda connection: vpnrouter_users.profile(connection, name),
        ('POST', 3, 'otp'): lambda connection: vpnrouter_users.otp_new(connection, name),
        ('PUT', 3, 'otp'): lambda connection: vpnrouter_users.otp_enrol(connection, name, body.get('secret'), body.get('code')),
        ('DELETE', 3, 'otp'): lambda connection: vpnrouter_users.otp_remove(connection, name),
    }
    if route not in actions:
        return 404, {'error': f'no route {method} {path}'}
    try:
        with vpnrouter_users.session() as connection:
            document = actions[route](connection)
    except vpnrouter_users.Failure as failure:
        return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
    except (ldap.LDAPError, OSError) as exc:
        return 500, {'error': f'the user directory failed: {exc}'}
    except SystemExit:
        # vpnrouter ends with SystemExit on a configuration it cannot read; it has printed the reason.
        return 500, {'error': 'the router configuration cannot be read'}
    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


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
        directory = next((name for name in ('users', 'groups') if path == f'/{name}' or path.startswith(f'/{name}/')), None)
        protected = path == '/config' or directory is not None
        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 if directory == 'users' else groups)(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())
