feature: the web service creates its key and database itself and shows its starting phase.
Release / release (push) Successful in 29s

This commit is contained in:
slawek committed 2026-10-24 09:07:28 +02:00
1 parent 4f1aa282f6
commit 2e2bacee7d
8 files changed
+245 -37

No files matched your search

+15
View File
@@ -1,3 +1,18 @@
vpn-router (0.8.0-1) unstable; urgency=medium
feature: the web service creates its key and database itself and shows its starting phase.
* vpn-router-web makes its signing key and creates the refresh tokens'
database on its first run, retrying every 15 seconds until PostgreSQL
answers; vpn-router-setup no longer creates them.
* Until then / and /status show the web service's phase and its failure,
with the state, the step and the failure of the last run of
vpn-router-setup, and every page answers 503.
* vpn-router-setup records its state, current step and failure in
/run/vpn-router/setup.json.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 03:00:00 +0200
vpn-router (0.7.1-1) unstable; urgency=medium
fix: a failing user directory or token database no longer leaves the tunnels down.
@@ -11,7 +11,7 @@ Restart=on-failure
RestartSec=15
NoNewPrivileges=yes
ProtectSystem=strict
ReadWritePaths=-/run/vpn-router -/etc/vpn-router/pki -/etc/swanctl/x509crl
ReadWritePaths=-/run/vpn-router -/etc/vpn-router -/etc/swanctl/x509crl
ProtectHome=yes
PrivateTmp=yes
+55 -16
View File
@@ -17,11 +17,11 @@ import re
import socket
import subprocess
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
import vpnrouter
import vpnrouter_auth
import vpnrouter_ldap
from vpnrouter import fail
@@ -30,6 +30,9 @@ 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.
SETUP_STATE = pathlib.Path('/run/vpn-router/setup.json')
#: 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')
@@ -359,16 +362,6 @@ def manage_directory():
return ''
def manage_tokens(cfg):
"""Make the web service's signing key, and the database of its refresh tokens unless web.database names one; the problem, or ''."""
try:
vpnrouter_auth.ensure_key()
vpnrouter_auth.provision(cfg)
except Exception as exc:
return f'the token database cannot be provisioned: {exc!r}'
return ''
def manage_openvpn(ctx, changed):
"""Run the OpenVPN server when it is configured, and stop it when it is not."""
unit = 'openvpn-server@vpn-router'
@@ -444,25 +437,42 @@ def refresh_web(cfg, ctx):
}) + '\n', encoding='utf-8')
def progress(state, step, error=None):
"""Record where setup is in SETUP_STATE: running, deferred, done or failed, the step, and the error of a failure."""
SETUP_STATE.parent.mkdir(parents=True, exist_ok=True)
SETUP_STATE.write_text(json.dumps({
'state': state,
'step': step,
'error': error,
'time': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
}) + '\n', encoding='utf-8')
def main():
progress('running', 'configuration')
cfg = vpnrouter.load_config()
ctx, changed = vpnrouter.configure(cfg)
if ctx is None:
print('vpn-router: platform is none, configuration deferred; '
f'set general.platform in {vpnrouter.CONFIG_FILE}')
progress('deferred', 'configuration', f'general.platform is none; set it in {vpnrouter.CONFIG_FILE}')
return 0
progress('running', 'routes')
if vpnrouter.routing_ready(ctx):
apply_routes(ctx)
if ctx['local_id_mode'] == 'public_ip' and ctx['local_fqdn']:
progress('running', 'public address')
address = resolve_public_ip(ctx['local_fqdn'])
ctx, more = vpnrouter.configure(cfg, local_id_override=address)
changed |= more
progress('running', 'WireGuard keys')
setup_wireguard(ctx)
progress('running', 'firewall')
rules_changed = setup_ufw(ctx)
if is_configured(ctx):
status = run(['ufw', 'status'], check=False)
@@ -475,23 +485,52 @@ def main():
elif rules_changed:
run(['ufw', 'reload'], check=False)
progress('running', 'strongSwan')
manage_strongswan(ctx, changed)
progress('running', 'WireGuard')
manage_wireguard(ctx, changed)
# The tunnels are up before the directory and the tokens, whose problems end setup only after every other step.
problems = [problem for problem in (manage_directory(), manage_tokens(cfg)) if problem]
# The tunnels are up before the directory, whose problem ends setup only after every other step.
progress('running', 'user directory')
problem = manage_directory()
progress('running', 'OpenVPN')
manage_openvpn(ctx, changed)
if 'resolved' in changed:
progress('running', 'resolver')
run(['systemctl', 'restart', 'systemd-resolved'], check=False)
progress('running', 'platform module')
run_platform_setup(cfg, ctx)
progress('running', 'Docker rules')
refresh_docker_rules()
progress('running', 'web service state')
refresh_web(cfg, ctx)
for problem in problems:
if problem:
progress('failed', 'user directory', problem)
print(f'vpn-router: error: {problem}', file=sys.stderr)
return 1 if problems else 0
return 1
progress('done', 'done')
return 0
def record_failure(error):
"""Mark the run failed in the step SETUP_STATE holds."""
try:
step = json.loads(SETUP_STATE.read_text(encoding='utf-8')).get('step')
except (OSError, ValueError):
step = None
progress('failed', step, error)
if __name__ == '__main__':
sys.exit(main())
# A failing step ends with SystemExit carrying its message (vpnrouter.fail).
try:
code = main()
except SystemExit as exc:
record_failure(str(exc.code))
raise
except Exception as exc:
record_failure(repr(exc))
raise
sys.exit(code)
@@ -96,8 +96,8 @@ def warn(message):
def fail(message):
print(f'vpn-router: error: {message}', file=sys.stderr)
sys.exit(1)
"""End with status 1; SystemExit carries the message, which Python prints to standard error."""
sys.exit(f'vpn-router: error: {message}')
def load_config(path=None):
@@ -1,13 +1,13 @@
"""The tokens of the router's web service, for the web service and vpn-router-token.
Tokens are HS256 JWTs signed with WEB_KEY, a key vpn-router-setup makes on the router and that
never leaves it. /auth issues an access token naming the user in sub, valid ACCESS_SECONDS, and
Tokens are HS256 JWTs signed with WEB_KEY, a key the web service makes on its first run on the
router and that never leaves it. /auth issues an access token naming the user in sub, valid ACCESS_SECONDS, and
a refresh token valid REFRESH_SECONDS; vpn-router-token issues tokens with the admin claim and
any lifetime. A token without the admin claim must expire within MAX_LIFETIME seconds.
Refresh tokens are random strings kept in PostgreSQL as their SHA-256, in the database of
web.database, by default the vpn-router database on this host that provision() creates, with
the password in DATABASE_SECRET. A refresh token is used once: refreshing replaces it.
web.database, by default the vpn-router database on this host that provision() creates on the
web service's first run, with the password in DATABASE_SECRET. A refresh token is used once: refreshing replaces it.
"""
import base64
@@ -42,7 +42,7 @@ SCHEMA = 'CREATE TABLE IF NOT EXISTS refresh_tokens (hash text PRIMARY KEY, name
def key():
"""The signing key, or '' while vpn-router-setup has not made it."""
"""The signing key, or '' while the web service has not made it."""
try:
return WEB_KEY.read_text(encoding='utf-8').strip()
except OSError:
@@ -106,7 +106,7 @@ def admin_token(seconds):
"""A token with the admin claim, valid for the seconds given."""
secret = key()
if not secret:
raise OSError(f'{WEB_KEY} does not exist; vpn-router-setup makes it once a platform is chosen')
raise OSError(f'{WEB_KEY} does not exist; vpn-router-web makes it when it starts')
now = int(time.time())
return {'token': sign({'admin': True, 'iat': now, 'exp': now + seconds}, secret), 'expires': now + seconds}
+119 -6
View File
@@ -30,6 +30,12 @@ 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 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
@@ -64,6 +70,12 @@ 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
#: The port when web.port is not set.
DEFAULT_PORT = 7081
@@ -92,6 +104,10 @@ 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()
@@ -434,9 +450,9 @@ def users(state, method, path, body):
return 500, {'error': f'the user directory failed: {exc}'}
except psycopg.Error as exc:
return 500, {'error': f'the token database 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'}
except SystemExit as exc:
# vpnrouter ends with SystemExit, carrying the reason, on a configuration it cannot read.
return 500, {'error': str(exc.code)}
return (201 if route == ('POST', 1, None) else 200), document
@@ -538,8 +554,8 @@ def auth(path, body):
return 500, {'error': f'the user directory failed: {exc}'}
except psycopg.Error as exc:
return 500, {'error': f'the token database failed: {exc}'}
except SystemExit:
return 500, {'error': 'the router configuration cannot be read'}
except SystemExit as exc:
return 500, {'error': str(exc.code)}
def auth_wait(address):
@@ -604,7 +620,24 @@ 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')
if (protected or signing) and not state['key']:
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']:
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
@@ -704,6 +737,85 @@ 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:
try:
phase('reading the configuration')
cfg = vpnrouter.load_config()
phase('making the signing key')
vpnrouter_auth.ensure_key()
phase('creating the token database')
vpnrouter_auth.provision(cfg)
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']
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.
@@ -736,6 +848,7 @@ def discover():
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()
try: