From 732cc8cb549d9c38ab8a2ab052c3bebb4efb2396 Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Sat, 24 Oct 2026 10:43:51 +0200 Subject: [PATCH] fix: the web service waits for the PostgreSQL driver instead of failing at start. --- ADR.md | 13 ++++-- debian-package/debian/changelog | 13 ++++++ .../src/usr/lib/vpn-router/vpnrouter_auth.py | 46 +++++++++++++------ debian-package/src/usr/lib/vpn-router/web | 24 ++++++---- debian-package/src/usr/sbin/vpn-router-user | 6 +-- 5 files changed, 72 insertions(+), 30 deletions(-) diff --git a/ADR.md b/ADR.md index 652ad8c..ce54763 100644 --- a/ADR.md +++ b/ADR.md @@ -359,11 +359,16 @@ 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 does it before it listens, retrying every 15 seconds and logging each -failure, then listens. +failure, then listens. The first step is the PostgreSQL driver: psycopg finds libpq through the +linker cache, which the package manager refreshes only at the end of an installation, so a +service started during the installation cannot import it yet. The Manager imports psycopg only in +its retry loop. On the router, `vpnrouter_auth` alone imports it, in `driver()`, which the web +service's retry loop calls first, and turns a failed import and every database failure into +`DatabaseError`, the only database exception the web service and `vpn-router-user` handle. -Consequences: The order of installation and of the start of PostgreSQL does not matter; a -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. +Consequences: The order of installation and of the start of PostgreSQL and of the linker cache +does not matter; a driver that cannot be loaded or a database that cannot be created is retried +until the cause is gone, and the reason is in the service's log, without restarts. 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 diff --git a/debian-package/debian/changelog b/debian-package/debian/changelog index a3aa324..be992d9 100644 --- a/debian-package/debian/changelog +++ b/debian-package/debian/changelog @@ -1,3 +1,16 @@ +vpn-router (0.9.1-1) unstable; urgency=medium + + fix: the web service waits for the PostgreSQL driver instead of failing at start. + + * The web service imports psycopg as the first step of its + initialization, retrying every 15 seconds and logging the failure while + libpq cannot be found yet during an installation. + * vpn-router-user and vpn-router-token load without psycopg; a command + that needs the token database then reports that the driver cannot be + loaded, as it reports every failure of that database. + + -- Slawomir Koszewski Sun, 25 Oct 2026 05:00:00 +0200 + vpn-router (0.9.0-1) unstable; urgency=medium feature: bootstrap diagnostics on port 17081 during the first setup. diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_auth.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_auth.py index 0337cfe..bb50b00 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_auth.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_auth.py @@ -5,12 +5,17 @@ router and that never leaves it. /auth issues an access token naming the user in 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. +psycopg is imported by driver() when the database is used, not when the module is loaded, so the +module loads while libpq cannot be found yet. A driver that cannot be imported and every +failure of the database raise DatabaseError, the only database exception callers see. + 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 on the web service's first run, with the password in DATABASE_SECRET. A refresh token is used once: refreshing replaces it. """ import base64 +import contextlib import hashlib import hmac import json @@ -19,9 +24,6 @@ import secrets import subprocess import time -import psycopg -from psycopg.conninfo import make_conninfo - WEB_KEY = pathlib.Path('/etc/vpn-router/web.key') DATABASE_SECRET = pathlib.Path('/etc/vpn-router/database.secret') @@ -41,6 +43,10 @@ LEEWAY = 60 SCHEMA = 'CREATE TABLE IF NOT EXISTS refresh_tokens (hash text PRIMARY KEY, name text NOT NULL, expires timestamptz NOT NULL)' +class DatabaseError(Exception): + """The PostgreSQL driver cannot be loaded, or the database of the refresh tokens failed.""" + + def key(): """The signing key, or '' while the web service has not made it.""" try: @@ -111,20 +117,34 @@ def admin_token(seconds): return {'token': sign({'admin': True, 'iat': now, 'exp': now + seconds}, secret), 'expires': now + seconds} +def driver(): + """The psycopg module; DatabaseError while libpq cannot be found.""" + try: + import psycopg + except ImportError as exc: + raise DatabaseError(f'the PostgreSQL driver cannot be loaded: {exc}') from exc + return psycopg + + def connection_string(cfg): - """web.database, or the database provision() creates.""" + """web.database, or the database provision() creates; its password, of URL-safe characters, is written unquoted.""" configured = cfg['web']['database'].strip() if configured: return configured password = DATABASE_SECRET.read_text(encoding='utf-8').strip() - return make_conninfo(host='localhost', dbname=DATABASE, user=DATABASE, password=password) + return f'host=localhost dbname={DATABASE} user={DATABASE} password={password}' -def connect(cfg): - """A connection to the database of the refresh tokens, with their table.""" - connection = psycopg.connect(connection_string(cfg), autocommit=True) - connection.execute(SCHEMA) - return connection +@contextlib.contextmanager +def database(cfg): + """A connection to the database of the refresh tokens, with their table, closed at the end; DatabaseError on any failure.""" + psycopg = driver() + try: + with psycopg.connect(connection_string(cfg), autocommit=True) as connection: + connection.execute(SCHEMA) + yield connection + except psycopg.Error as exc: + raise DatabaseError(f'the token database failed: {exc}') from exc def digest(token): @@ -136,7 +156,7 @@ def issue(cfg, name): secret = key() now = int(time.time()) refresh = secrets.token_urlsafe(32) - with connect(cfg) as connection: + with database(cfg) as connection: connection.execute('DELETE FROM refresh_tokens WHERE expires < now()') connection.execute("INSERT INTO refresh_tokens VALUES (%s, %s, now() + make_interval(secs => %s))", (digest(refresh), name, REFRESH_SECONDS)) @@ -153,7 +173,7 @@ def take(cfg, token): """Remove an unexpired refresh token and return the user it was issued to, or None.""" if not isinstance(token, str) or not token: return None - with connect(cfg) as connection: + with database(cfg) as connection: row = connection.execute('DELETE FROM refresh_tokens WHERE hash = %s RETURNING name, expires > now()', (digest(token),)).fetchone() return row[0] if row and row[1] else None @@ -161,7 +181,7 @@ def take(cfg, token): def revoke(cfg, name): """Remove all refresh tokens of the user.""" - with connect(cfg) as connection: + with database(cfg) as connection: connection.execute('DELETE FROM refresh_tokens WHERE name = %s', (name,)) diff --git a/debian-package/src/usr/lib/vpn-router/web b/debian-package/src/usr/lib/vpn-router/web index 94a2618..e8eceed 100644 --- a/debian-package/src/usr/lib/vpn-router/web +++ b/debian-package/src/usr/lib/vpn-router/web @@ -30,7 +30,9 @@ 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 first initializes, without listening: it makes the signing key and the refresh +The service first initializes, without listening: it loads the PostgreSQL driver +(vpnrouter_auth.driver; psycopg finds libpq through the linker cache, which the package manager +refreshes only at the end of an installation), then 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. @@ -58,7 +60,6 @@ from http.server import BaseHTTPRequestHandler sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) import ldap -import psycopg import vpnrouter import vpnrouter_auth @@ -440,8 +441,8 @@ def users(state, method, path, body): 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 psycopg.Error as exc: - return 500, {'error': f'the token database failed: {exc}'} + except vpnrouter_auth.DatabaseError as exc: + return 500, {'error': str(exc)} except SystemExit as exc: # vpnrouter ends with SystemExit, carrying the reason, on a configuration it cannot read. return 500, {'error': str(exc.code)} @@ -544,8 +545,8 @@ def auth(path, body): return (401 if failure.status == 3 else 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 psycopg.Error as exc: - return 500, {'error': f'the token database failed: {exc}'} + except vpnrouter_auth.DatabaseError as exc: + return 500, {'error': str(exc)} except SystemExit as exc: return 500, {'error': str(exc.code)} @@ -696,19 +697,22 @@ def log(message): def initialize(): - """Make the signing key and the refresh tokens' database, retrying every INIT_RETRY_SECONDS until both are ready.""" + """Load the PostgreSQL driver, make the signing key and the refresh tokens' database, retrying every INIT_RETRY_SECONDS until all are ready.""" reported = None while True: - phase = 'reading the configuration' + phase = 'loading the PostgreSQL driver' try: + vpnrouter_auth.driver() + 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 vpnrouter_auth.database(cfg): + pass + except (OSError, SystemExit, vpnrouter_auth.DatabaseError) as exc: error = f'{phase} failed: {exc.code if isinstance(exc, SystemExit) else exc}' if error != reported: log(f'{error}; retrying every {INIT_RETRY_SECONDS} seconds') diff --git a/debian-package/src/usr/sbin/vpn-router-user b/debian-package/src/usr/sbin/vpn-router-user index a361b60..43aa7a4 100755 --- a/debian-package/src/usr/sbin/vpn-router-user +++ b/debian-package/src/usr/sbin/vpn-router-user @@ -29,8 +29,8 @@ import sys sys.path.insert(0, '/usr/lib/vpn-router') import ldap -import psycopg +import vpnrouter_auth import vpnrouter_users from vpnrouter_users import Failure @@ -135,8 +135,8 @@ def main(argv): message, status = str(failure), failure.status except (ldap.LDAPError, OSError) as exc: message, status = f'the user directory failed: {exc}', 1 - except psycopg.Error as exc: - message, status = f'the token database failed: {exc}', 1 + except vpnrouter_auth.DatabaseError as exc: + message, status = str(exc), 1 else: print(json.dumps(answer) if as_json else text_of(answer)) return 0