feature: bootstrap diagnostics on port 17081 during the first setup.
Release / release (push) Successful in 13s

This commit is contained in:
slawek committed 2026-10-24 10:07:22 +02:00
1 parent 2e2bacee7d
commit c0cd323a0c
13 files changed
+275 -181

No files matched your search

+19 -147
View File
@@ -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"""<!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="{INIT_RETRY_SECONDS}">
<title>VPN Router - starting</title>
</head>
<body>
<pre>{escape(starting_text(status))}</pre>
</body>
</html>
"""
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