diff --git a/ADR.md b/ADR.md index 0732a2f..652ad8c 100644 --- a/ADR.md +++ b/ADR.md @@ -65,7 +65,7 @@ The service listens on plain HTTP on the internal network. Decision: `/config`, `/users` and `/groups` need a JWT in an `Authorization: Bearer` header, HS256, verified with `hmac` and `hashlib`; no other algorithm is accepted. The key is `/etc/vpn-router/web.key`, random, made by the web service on its first run and readable by root -only; it is never configured or output, and `web.jwt_secret` is ignored. The router itself issues +only; it is never configured or output. The router itself issues the tokens: `POST /auth` checks a user's password and TOTP code with a bind (ADR-14) and answers an access token naming the user in `sub`, valid for 5 minutes, and a refresh token valid for 8 hours (ADR-15); `vpn-router-token` makes tokens with the `admin` claim (ADR-13). A token without @@ -348,26 +348,45 @@ no usable token. Changing a user's password, disabling, unlocking or removing th signing out, end the user's sessions at once; an access token already issued lives out its 5 minutes. -## ADR-16: Each service creates what it needs on its first run and shows its starting phase +## ADR-16: Each service creates what it needs on its first run Status: Accepted Context: The package manager configures packages in an order of its own; on a new VM the Router Manager was configured before the PostgreSQL server, so a database created at installation was -missing. PostgreSQL may also start after the services at boot. A router whose setup fails can -be unreachable, and the reason must be visible without a shell where possible. +missing. PostgreSQL may also start after the services at boot. Decision: The web service makes its signing key and creates the refresh tokens' database itself, and the Router Manager its sessions database; neither `vpn-router-setup` nor a maintainer script -creates them. Each service starts with a starting phase: an initialization thread does the work -and retries every 15 seconds until it succeeds, and meanwhile the service listens as usual but -serves only a page of its phase and the failure of the last attempt, answering 503. The web -service also shows the state, the step and the failure of the last run of `vpn-router-setup`, -which setup records in `/run/vpn-router/setup.json` at every step; `vpnrouter.fail` ends with a -`SystemExit` carrying its message, so setup records it. Once the initialization succeeds, the -service serves its real pages. +creates them. Each service does it before it listens, retrying every 15 seconds and logging each +failure, then listens. Consequences: The order of installation and of the start of PostgreSQL does not matter; a -database that cannot be created shows why on the status page and is created once the cause is -gone. The web unit may write `/etc/vpn-router` for the key and the database password. While the -web service starts, `/status` is not the router's status, and clients see 503. +database that cannot be created is created once the cause is gone, and the reason is in the +service's log. The web unit may write `/etc/vpn-router` for the key and the database password. + +## ADR-17: Bootstrap diagnostics run only until the first setup succeeds + +Status: Accepted + +Context: A new VM is reachable only once its setup has worked, and the reason of a failure must +be readable from the internal network before an SSH session is possible. A service that reports +the setup cannot be one of the services being set up: those import packages that may not load yet +(psycopg could not find libpq before `ldconfig` ran), and they would fail before reporting +anything. Once the router has been set up, SSH is the way to diagnose it. The more a diagnostic +page does, the more ways it has to fail itself. + +Decision: Each package ships a bootstrap service of the Python standard library alone, a script +that answers every request with the plain-text output of a fixed list of commands: the setup +state, the services, the failed units, the addresses and the end of the logs. The router's +listens on 17081 and the Router Manager's on 17080, beside the ports of the services, 7081 and +7080, on every address. The units are never enabled and not started at installation; +`vpn-router-setup` starts them while `/var/lib/vpn-router/setup.done` does not exist, and opens +their ports on the internal interface. The first successful run writes that file, closes the ports +and stops them; a failed run leaves them running, so the reason stays readable. Setup records its +state, step and failure in `/run/vpn-router/setup.json` at every step; `vpnrouter.fail` ends with +a `SystemExit` carrying its message, so setup records it. + +Consequences: The diagnostics need nothing the setup provides and cannot collide with the real +services. After the first success they never run again, also not after an upgrade or a reboot. +The page shows log lines to whoever reaches the internal network during the first setup. diff --git a/README.md b/README.md index 1424e61..8eb61e2 100644 --- a/README.md +++ b/README.md @@ -473,21 +473,25 @@ The `vpn-router-web` service shows the state of the router on its internal addre `web.port` (7081 by default). Its pages `/` and `/status` need no authentication and show no configuration: no key, identity, address or subnet. -The service starts in a starting phase: it makes its signing key and the refresh tokens' -database, retrying every 15 seconds, for example while PostgreSQL is not running yet. Until that -succeeds, `/` and `/status` show only the phase and its failure, with the state, the current step -and the failure of the last run of `vpn-router-setup` (from `/run/vpn-router/setup.json`, which -setup writes at every step), and every page answers 503 with `Retry-After`; then the pages below -are served. +The service first makes its signing key and the refresh tokens' database, retrying every 15 +seconds, for example while PostgreSQL is not running yet, and logs each failure; then it listens. -```text -VPN Router -Status: starting +### Bootstrap diagnostics -Web service: creating the token database -Web service failure: creating the token database failed: psql failed: ..., retrying every 15 s -Setup: failed, step firewall, at 2026-10-25T02:00:00+0200 -Setup failure: vpn-router: error: /etc/ufw/before.rules does not exist +Until a run of `vpn-router-setup` has succeeded, every run starts `vpn-router-bootstrap`, which +serves diagnostics as plain text on port 17081 of every address, and the firewall opens 17081 and +17080 (the Router Manager's bootstrap) on the internal interface. The first successful run stops +the bootstrap services and closes their ports, and writes `/var/lib/vpn-router/setup.done`, after +which they never run again; a failed run leaves them running, so the reason stays readable until +a later run succeeds. The unit is never enabled. + +The page, read again every 10 seconds, shows the state, the step and the failure of the last +setup run (`/run/vpn-router/setup.json`, which setup writes at every step), the state of the +router's services, the failed units, the addresses and routes, `cloud-init status`, and the end of +the logs of `vpn-router-setup` and `vpn-router-web`: + +```bash +curl http://10.0.0.4:17081/ ``` | Page | Content | diff --git a/debian-package/debian/changelog b/debian-package/debian/changelog index 930db0e..a3aa324 100644 --- a/debian-package/debian/changelog +++ b/debian-package/debian/changelog @@ -1,3 +1,20 @@ +vpn-router (0.9.0-1) unstable; urgency=medium + + feature: bootstrap diagnostics on port 17081 during the first setup. + + * vpn-router-bootstrap serves, on port 17081, the setup state, the + services, the failed units, the addresses, cloud-init status and the end + of the logs of setup and the web service, as plain text. + * vpn-router-setup starts it, and the Router Manager's bootstrap on port + 17080, until a run has succeeded, and opens both ports on the internal + interface; the first successful run writes + /var/lib/vpn-router/setup.done, closes the ports and stops them. A + failed run leaves them running. + * The web service no longer has a starting phase: it creates its key and + database, then listens. + + -- Slawomir Koszewski Sun, 25 Oct 2026 04:00:00 +0200 + vpn-router (0.8.0-1) unstable; urgency=medium feature: the web service creates its key and database itself and shows its starting phase. diff --git a/debian-package/debian/install b/debian-package/debian/install index b8ab0c6..69169ae 100644 --- a/debian-package/debian/install +++ b/debian-package/debian/install @@ -2,6 +2,7 @@ src/etc/sysctl.d/99-vpn-router.conf etc/sysctl.d/ src/usr/lib/vpn-router/vpnrouter.py usr/lib/vpn-router/ src/usr/lib/vpn-router/vpnrouter_ldap.py usr/lib/vpn-router/ src/usr/lib/vpn-router/vpnrouter_net.py usr/lib/vpn-router/ +src/usr/lib/vpn-router/vpnrouter_serve.py usr/lib/vpn-router/ src/usr/lib/vpn-router/vpnrouter_users.py usr/lib/vpn-router/ src/usr/lib/vpn-router/vpnrouter_auth.py usr/lib/vpn-router/ src/usr/lib/vpn-router/openvpn-same-user usr/lib/vpn-router/ @@ -13,6 +14,7 @@ src/usr/lib/vpn-router/configure usr/lib/vpn-router/ src/usr/lib/vpn-router/docker-rules usr/lib/vpn-router/ src/usr/lib/vpn-router/setup usr/lib/vpn-router/ src/usr/lib/vpn-router/web usr/lib/vpn-router/ +src/usr/lib/vpn-router/bootstrap usr/lib/vpn-router/ src/usr/lib/vpn-router/generate-config usr/lib/vpn-router/ src/usr/lib/vpn-router/vpnrouter_platforms/*.py usr/lib/vpn-router/vpnrouter_platforms/ src/usr/share/vpn-router/templates/* usr/share/vpn-router/templates/ diff --git a/debian-package/debian/postrm b/debian-package/debian/postrm index 63d24e4..b7421a4 100755 --- a/debian-package/debian/postrm +++ b/debian-package/debian/postrm @@ -11,10 +11,10 @@ strip_ufw_blocks() { tmp="$(mktemp)" awk ' - /^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|MANAGER|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES START$/ { + /^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|BOOTSTRAP|MANAGER|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES START$/ { skip = 1; pending = 0; next } - /^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|MANAGER|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES END$/ { + /^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|BOOTSTRAP|MANAGER|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES END$/ { skip = 0; next } skip { next } @@ -50,7 +50,9 @@ case "$1" in /etc/openvpn/server/vpn-router.conf \ /etc/openvpn/server/vpn-router-ldap.conf \ /etc/vpn-router/vpn-router.json \ - /etc/vpn-router/web.key + /etc/vpn-router/web.key \ + /var/lib/vpn-router/setup.done + rmdir --ignore-fail-on-non-empty /var/lib/vpn-router 2>/dev/null || true # The refresh tokens' database and role, when the package created them. if [ -f /etc/vpn-router/database.secret ] && command -v psql >/dev/null 2>&1; then diff --git a/debian-package/debian/rules b/debian-package/debian/rules index b6e26ad..cb61494 100755 --- a/debian-package/debian/rules +++ b/debian-package/debian/rules @@ -6,3 +6,4 @@ override_dh_installsystemd: dh_installsystemd --name=vpn-router-setup dh_installsystemd --name=vpn-router-docker dh_installsystemd --name=vpn-router-web + dh_installsystemd --name=vpn-router-bootstrap --no-enable --no-start diff --git a/debian-package/debian/vpn-router.vpn-router-bootstrap.service b/debian-package/debian/vpn-router.vpn-router-bootstrap.service new file mode 100644 index 0000000..0a41359 --- /dev/null +++ b/debian-package/debian/vpn-router.vpn-router-bootstrap.service @@ -0,0 +1,14 @@ +[Unit] +Description=VPN Router bootstrap diagnostics page +Documentation=file:/usr/share/doc/vpn-router/README.md + +# Never enabled: vpn-router-setup starts it until a setup has succeeded, and stops it after that. +[Service] +Type=simple +ExecStart=/usr/bin/python3 /usr/lib/vpn-router/bootstrap +Restart=on-failure +RestartSec=5 +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes diff --git a/debian-package/src/usr/lib/vpn-router/bootstrap b/debian-package/src/usr/lib/vpn-router/bootstrap new file mode 100755 index 0000000..7e8fbe2 --- /dev/null +++ b/debian-package/src/usr/lib/vpn-router/bootstrap @@ -0,0 +1,55 @@ +#!/usr/bin/python3 +"""Serve diagnostics of the router's first setup on PORT, with the Python standard library alone. + +Run by vpn-router-bootstrap.service, which vpn-router-setup starts until a setup has succeeded and +then stops. Every request on any address answers, as plain text, the output of COMMANDS run at that +moment: the state of the last setup run, the state of the services, the addresses, and the end of +the logs. +""" + +import subprocess +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +PORT = 17081 + +#: The title of each section and its command. +COMMANDS = ( + ('Setup state', ['cat', '/run/vpn-router/setup.json']), + ('Services', ['systemctl', 'is-active', 'vpn-router-setup', 'vpn-router-web', 'slapd', 'postgresql', + 'strongswan', 'openvpn-server@vpn-router', 'wg-quick@wg0']), + ('Failed units', ['systemctl', '--failed', '--no-legend', '--plain']), + ('Addresses', ['ip', '-brief', 'address']), + ('Routes', ['ip', 'route']), + ('cloud-init', ['cloud-init', 'status', '--long']), + ('vpn-router-setup log', ['journalctl', '--unit', 'vpn-router-setup', '--lines', '60', '--no-pager', '--output', 'short-iso']), + ('vpn-router-web log', ['journalctl', '--unit', 'vpn-router-web', '--lines', '30', '--no-pager', '--output', 'short-iso']), +) + + +def report(): + """The output of every command of COMMANDS under its title.""" + sections = [] + for title, command in COMMANDS: + try: + result = subprocess.run(command, capture_output=True, text=True, timeout=10) + text = (result.stdout + result.stderr).rstrip() + except (OSError, subprocess.TimeoutExpired) as exc: + text = f'cannot run {command[0]}: {exc}' + sections.append(f'== {title} ==\n{text}\n') + return '\n'.join(sections) + + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + body = report().encode() + self.send_response(200) + self.send_header('Content-Type', 'text/plain; charset=utf-8') + self.send_header('Content-Length', str(len(body))) + self.send_header('Cache-Control', 'no-store') + self.send_header('Refresh', '10') + self.end_headers() + self.wfile.write(body) + + +if __name__ == '__main__': + ThreadingHTTPServer(('', PORT), Handler).serve_forever() diff --git a/debian-package/src/usr/lib/vpn-router/setup b/debian-package/src/usr/lib/vpn-router/setup index 8568aca..d47b61f 100755 --- a/debian-package/src/usr/lib/vpn-router/setup +++ b/debian-package/src/usr/lib/vpn-router/setup @@ -5,6 +5,12 @@ Regenerates the configuration files first, then does everything that needs the live system: routes, DNS-derived identity, WireGuard keys, firewall rules and service reloads. Idempotent - a second run changes nothing. +Until a run has succeeded (SETUP_DONE), every run starts the bootstrap +services, BOOTSTRAP_UNITS, which show the progress and the failure recorded in +SETUP_STATE on BOOTSTRAP_PORTS, and the firewall opens those ports on the +internal interface. The first successful run writes SETUP_DONE, closes the +ports and stops the bootstrap services; a failed run leaves them running. + Interface names come from the configuration and are never guessed. The firewall and NAT rules are written against them, so choosing the wrong one would be a silent and security-relevant mistake. @@ -30,9 +36,16 @@ UFW_DEFAULTS = pathlib.Path('/etc/default/ufw') WG_DIR = pathlib.Path('/etc/wireguard') WEB_STATE = pathlib.Path('/run/vpn-router/web.json') -#: The state, the current step and the failure of the last run, which the web service shows while it starts. +#: The state, the current step and the failure of the last run, which the bootstrap services show. SETUP_STATE = pathlib.Path('/run/vpn-router/setup.json') +#: Written once a run has succeeded; until then setup runs the bootstrap services and opens their ports. +SETUP_DONE = pathlib.Path('/var/lib/vpn-router/setup.done') + +#: The units of the bootstrap services, and their ports: the Router Manager's and the router's. +BOOTSTRAP_UNITS = ('vpn-router-bootstrap.service', 'vpn-router-manager-bootstrap.service') +BOOTSTRAP_PORTS = (17080, 17081) + #: The Router Manager, vpn-router-manager, when it is installed, and its settings. MANAGER = pathlib.Path('/usr/lib/vpn-router-manager/manager') MANAGER_CONFIG = pathlib.Path('/etc/vpn-router-manager/manager.json') @@ -49,6 +62,7 @@ BLOCKS = ( ('# OPENVPN RULES START', '# OPENVPN RULES END'), ('# P2S DNS RULES START', '# P2S DNS RULES END'), ('# WEB RULES START', '# WEB RULES END'), + ('# BOOTSTRAP RULES START', '# BOOTSTRAP RULES END'), ('# MANAGER RULES START', '# MANAGER RULES END'), ('# ROUTER FORWARD RULES START', '# ROUTER FORWARD RULES END'), ('# ROUTER NAT RULES START', '# ROUTER NAT RULES END'), @@ -202,6 +216,14 @@ def filter_block(ctx): '# WEB RULES END', ] + if not SETUP_DONE.exists() and ctx['int_iface']: + lines += [ + '', + '# BOOTSTRAP RULES START', + *(f'-A ufw-before-input -i {ctx["int_iface"]} -p tcp --dport {port} -j ACCEPT' for port in BOOTSTRAP_PORTS), + '# BOOTSTRAP RULES END', + ] + if MANAGER.exists() and ctx['int_iface']: port = manager_port() lines += [ @@ -448,7 +470,27 @@ def progress(state, step, error=None): }) + '\n', encoding='utf-8') +def start_bootstrap(): + """Start the bootstrap services while no run has succeeded; a unit that is not installed is skipped.""" + if not SETUP_DONE.exists(): + for unit in BOOTSTRAP_UNITS: + run(['systemctl', 'start', '--no-block', unit], check=False) + + +def end_bootstrap(ctx): + """After the first successful run: write SETUP_DONE, close the bootstrap ports and stop the bootstrap services.""" + if SETUP_DONE.exists(): + return + SETUP_DONE.parent.mkdir(parents=True, exist_ok=True) + SETUP_DONE.touch() + if setup_ufw(ctx): + run(['ufw', 'reload'], check=False) + for unit in BOOTSTRAP_UNITS: + run(['systemctl', 'stop', unit], check=False) + + def main(): + start_bootstrap() progress('running', 'configuration') cfg = vpnrouter.load_config() ctx, changed = vpnrouter.configure(cfg) @@ -511,6 +553,7 @@ def main(): print(f'vpn-router: error: {problem}', file=sys.stderr) return 1 progress('done', 'done') + end_bootstrap(ctx) return 0 diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter.py b/debian-package/src/usr/lib/vpn-router/vpnrouter.py index 8663c2b..dc11e12 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter.py @@ -54,8 +54,7 @@ SCHEMA = { 'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820}, 'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'}, 'docker': {'mode': 'auto', 'exposed_ports': []}, - # web.jwt_secret is read and ignored; the tokens are signed with vpnrouter_auth.WEB_KEY. - 'web': {'enabled': True, 'port': 7081, 'jwt_secret': '', 'database': '', 'trusted_proxies': []}, + 'web': {'enabled': True, 'port': 7081, 'database': '', 'trusted_proxies': []}, } DOCKER_MODES = ('auto', 'on', 'off') diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_net.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_net.py index 7efdacd..ededa5b 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_net.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_net.py @@ -1,6 +1,7 @@ """Interfaces and addresses of the router, read from the system. -Only the Python standard library is used, so the web service can use this module too. +Only the Python standard library is used, so the web service and its bootstrap server can use +this module too. """ import pathlib @@ -107,3 +108,28 @@ def internal_address(mode, internal, int_addr): except ConfigError: return '' return addresses[0] if addresses else '' + + +def web_listen(configuration, default_port): + """Where the web service listens, from vpn-router.json read as configuration, as ((address, port), ''). + + The address is the internal address as general.mode directs, the port web.port, else + default_port. While web.enabled is false or the address is not known, the answer is (None, + the reason). + """ + def section(name): + value = configuration.get(name) + return value if isinstance(value, dict) else {} + + web = section('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('general'), section('interfaces'), section('local') + address = 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), '' diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_serve.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_serve.py new file mode 100644 index 0000000..dc3c331 --- /dev/null +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_serve.py @@ -0,0 +1,40 @@ +"""The listening loop of the web service and of its bootstrap server. + +Only the Python standard library is used, so the bootstrap server, which must run whatever else +is broken, can use it. +""" + +import threading +import time +from http.server import ThreadingHTTPServer + + +def serve(wanted, handler, log, interval): + """Work out where to listen every interval seconds with wanted, and run an HTTP server of handler there. + + wanted answers ((address, port), '') or (None, the reason not to listen). When the address or + the port changes, the server is stopped and a new one listens at the new place; while there is + no place or it cannot be bound, no server runs and the next round tries again. Every new + reason is logged with log. Does not return. + """ + 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(interval) diff --git a/debian-package/src/usr/lib/vpn-router/web b/debian-package/src/usr/lib/vpn-router/web index 32e84d7..94a2618 100644 --- a/debian-package/src/usr/lib/vpn-router/web +++ b/debian-package/src/usr/lib/vpn-router/web @@ -30,17 +30,15 @@ default the address the service listens on, is the last entry of its X-Forwarded 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 starts in a starting phase, while an initialization thread makes the signing key and -the refresh tokens' database (vpnrouter_auth), retrying every INIT_RETRY_SECONDS until both are -ready. Meanwhile / and /status show only that phase and its failure, with the state, step and -failure of vpn-router-setup from SETUP_STATE, and answer 503; every other path answers 503 with -the phase. Once the initialization succeeds the service serves the pages above. +The service first initializes, without listening: it makes the signing key and the refresh +tokens' database (vpnrouter_auth), retrying every INIT_RETRY_SECONDS until both are ready and +logging each failure. Then it listens. -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 signing +The service needs nothing else to start and does not end on its own. Every DISCOVERY_SECONDS it +works out the internal address from /etc/vpn-router/vpn-router.json as general.mode directs, +with web.port and web.enabled (vpnrouter_net.web_listen), and runs the server there +(vpnrouter_serve.serve): 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 signing key, the configuration, and the features of the router and the facts shown by /config from /run/vpn-router/web.json, which vpn-router-setup writes. Before setup has written that file the router has no features, and /config shows no configuration or interfaces. @@ -55,7 +53,7 @@ import sys import threading import time from html import escape -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from http.server import BaseHTTPRequestHandler sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) @@ -65,14 +63,12 @@ import psycopg import vpnrouter import vpnrouter_auth import vpnrouter_net +import vpnrouter_serve import vpnrouter_users STATE_FILE = '/run/vpn-router/web.json' CONFIG_FILE = '/etc/vpn-router/vpn-router.json' -#: The state, the step and the failure of the last run of vpn-router-setup. -SETUP_STATE = '/run/vpn-router/setup.json' - #: Seconds between two attempts of the initialization. INIT_RETRY_SECONDS = 15 @@ -104,10 +100,6 @@ USER_STATUS = {2: 400, 3: 404, 4: 409, 5: 401} _cache = {'at': 0.0, 'data': None} _lock = threading.Lock() -#: The initialization: its current phase, the failure of the last attempt, and whether it has succeeded. -_init = {'phase': 'starting', 'error': None, 'ready': False} -_init_lock = threading.Lock() - #: The times of the recent /auth calls of each client address. _auth_calls = collections.defaultdict(collections.deque) _auth_lock = threading.Lock() @@ -620,24 +612,7 @@ class Handler(BaseHTTPRequestHandler): payload = self.request_body() if (protected or signing) and self.command not in ('GET', 'HEAD') else None if claims is not None: self.principal = 'admin' if claims.get('admin') is True else claims.get('sub') - with _init_lock: - ready = _init['ready'] - if not ready: - starting = starting_status() - code = 503 - if path == '/status': - body, kind = json.dumps(starting, indent=2).encode() + b'\n', 'application/json' - elif path == '/': - agent = self.headers.get('User-Agent', '').lower() - if 'curl' in agent or 'wget' in agent: - body, kind = starting_text(starting).encode(), 'text/plain; charset=utf-8' - else: - body, kind = starting_html(starting).encode(), 'text/html; charset=utf-8' - else: - document = {'error': f'the web service is starting: {starting["service"]["phase"]}', **starting} - body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json' - extra.append(('Retry-After', str(INIT_RETRY_SECONDS))) - elif (protected or signing) and not state['key']: + if (protected or signing) and not state['key']: body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404 elif signing and self.command != 'POST': body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405 @@ -716,143 +691,40 @@ class Handler(BaseHTTPRequestHandler): sys.stderr.write('%s - %s [%s] %s\n' % (self.client_address[0], principal, 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 phase(name): - with _init_lock: - _init['phase'] = name - - def initialize(): """Make the signing key and the refresh tokens' database, retrying every INIT_RETRY_SECONDS until both are ready.""" reported = None while True: + phase = 'reading the configuration' try: - phase('reading the configuration') cfg = vpnrouter.load_config() - phase('making the signing key') + phase = 'making the signing key' vpnrouter_auth.ensure_key() - phase('creating the token database') + phase = 'creating the token database' vpnrouter_auth.provision(cfg) - phase('connecting to the token database') + phase = 'connecting to the token database' vpnrouter_auth.connect(cfg).close() except (OSError, psycopg.Error, SystemExit) as exc: - with _init_lock: - _init['error'] = f'{_init["phase"]} failed: {exc.code if isinstance(exc, SystemExit) else exc}' - error = _init['error'] + error = f'{phase} failed: {exc.code if isinstance(exc, SystemExit) else exc}' if error != reported: log(f'{error}; retrying every {INIT_RETRY_SECONDS} seconds') reported = error time.sleep(INIT_RETRY_SECONDS) continue - with _init_lock: - _init.update(phase='ready', error=None, ready=True) log('ready') return -def starting_status(): - """What / and /status show while the service starts: its phase and failure, and the state of vpn-router-setup.""" - with _init_lock: - service = {'phase': _init['phase'], 'error': _init['error']} - setup = read_json(SETUP_STATE) - return { - 'status': 'starting', - 'time': time.strftime('%Y-%m-%dT%H:%M:%S%z'), - 'service': service, - 'setup': {key: setup.get(key) for key in ('state', 'step', 'error', 'time')} if setup else None, - } - - -def starting_text(status): - """The starting status for people, as plain text.""" - service, setup = status['service'], status['setup'] - lines = ['VPN Router', 'Status: starting', '', f'Web service: {service["phase"]}'] - if service['error']: - lines.append(f'Web service failure: {service["error"]}, retrying every {INIT_RETRY_SECONDS} s') - if setup is None: - lines.append('Setup: has not run yet') - else: - lines.append(f'Setup: {setup["state"]}, step {setup["step"]}, at {setup["time"]}') - if setup['error']: - lines.append(f'Setup failure: {setup["error"]}') - return '\n'.join(lines) + '\n' - - -def starting_html(status): - """The starting status for a browser: the text in a page that reloads itself every INIT_RETRY_SECONDS.""" - return f""" - - - - - - - VPN Router - starting - - -
{escape(starting_text(status))}
- - -""" - - -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)) - threading.Thread(target=initialize, name='initialization', daemon=True).start() - discovery = threading.Thread(target=discover, name='discovery', daemon=True) - discovery.start() + initialize() try: - discovery.join() + vpnrouter_serve.serve(lambda: vpnrouter_net.web_listen(read_json(CONFIG_FILE), DEFAULT_PORT), Handler, log, + DISCOVERY_SECONDS) except KeyboardInterrupt: pass return 0