From b1e6d9cd0be5cfafe8febc0f8f00651e02743e08 Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Sat, 24 Oct 2026 11:07:45 +0200 Subject: [PATCH] fix: the web service starts once libpq can be found. --- ADR.md | 20 ++++++++++++------- debian-package/debian/changelog | 11 ++++++++++ debian-package/debian/install | 1 + debian-package/debian/postinst | 6 ++++++ debian-package/debian/rules | 2 +- .../debian/vpn-router.vpn-router-web.service | 2 ++ .../src/usr/lib/vpn-router/vpnrouter_auth.py | 18 ++++------------- .../src/usr/lib/vpn-router/wait-for-libpq | 12 +++++++++++ debian-package/src/usr/lib/vpn-router/web | 13 +++++------- 9 files changed, 55 insertions(+), 30 deletions(-) create mode 100755 debian-package/src/usr/lib/vpn-router/wait-for-libpq diff --git a/ADR.md b/ADR.md index ce54763..06f3956 100644 --- a/ADR.md +++ b/ADR.md @@ -359,16 +359,22 @@ 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. 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 +failure, then listens. `vpnrouter_auth` turns every failure of the database into `DatabaseError`, the only database exception the web service and `vpn-router-user` handle. +psycopg (Ubuntu ships only its pure-Python implementation) finds libpq through the dynamic linker +cache, which dpkg refreshes with its deferred `ldconfig` only at the end of an installation, so a +service started during the installation cannot import it yet; a failed import cannot be repeated +in the same process either, as psycopg caches the lookup. The services therefore import psycopg +plainly, and their units wait instead: `ExecStartPre=wait-for-libpq`, a shell script of each +package, waits until `ldconfig -p` lists `libpq.so.5`, within `TimeoutStartSec=5min`. The +maintainer scripts start the units with `systemctl --no-block` (debhelper's start is turned off), +because a start that waited would hold dpkg before its `ldconfig`. + 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. +does not matter; a database that cannot be created is retried until the cause is gone, and the +reason is in the service's log. Outside an installation the wait ends at once. 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 be992d9..697e0cf 100644 --- a/debian-package/debian/changelog +++ b/debian-package/debian/changelog @@ -1,3 +1,14 @@ +vpn-router (0.9.2-1) unstable; urgency=medium + + fix: the web service starts once libpq can be found. + + * The web service unit waits, with wait-for-libpq, until libpq is in the + linker cache before it starts the service, for at most 5 minutes; the + service imports psycopg at start again. + * The package starts the web service without waiting for it. + + -- Slawomir Koszewski Sun, 25 Oct 2026 06:00:00 +0200 + vpn-router (0.9.1-1) unstable; urgency=medium fix: the web service waits for the PostgreSQL driver instead of failing at start. diff --git a/debian-package/debian/install b/debian-package/debian/install index 69169ae..d2d3108 100644 --- a/debian-package/debian/install +++ b/debian-package/debian/install @@ -15,6 +15,7 @@ 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/wait-for-libpq 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/postinst b/debian-package/debian/postinst index ed241ba..b635269 100755 --- a/debian-package/debian/postinst +++ b/debian-package/debian/postinst @@ -70,6 +70,12 @@ case "$1" in # The firewall is not touched here. Enabling it, allowing SSH and # setting the forward policy all belong to vpn-router-setup, which does # them only once the configuration says this host is a router. + + # The web service waits for libpq in the linker cache, which dpkg + # refreshes after this script, so it is started without waiting for it. + if [ -d /run/systemd/system ]; then + systemctl --no-block restart vpn-router-web.service >/dev/null 2>&1 || true + fi ;; esac diff --git a/debian-package/debian/rules b/debian-package/debian/rules index cb61494..ba17514 100755 --- a/debian-package/debian/rules +++ b/debian-package/debian/rules @@ -5,5 +5,5 @@ 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-web --no-start dh_installsystemd --name=vpn-router-bootstrap --no-enable --no-start diff --git a/debian-package/debian/vpn-router.vpn-router-web.service b/debian-package/debian/vpn-router.vpn-router-web.service index c45bba2..7277c7a 100644 --- a/debian-package/debian/vpn-router.vpn-router-web.service +++ b/debian-package/debian/vpn-router.vpn-router-web.service @@ -6,7 +6,9 @@ Wants=network-online.target [Service] Type=simple +ExecStartPre=/usr/lib/vpn-router/wait-for-libpq ExecStart=/usr/bin/python3 /usr/lib/vpn-router/web +TimeoutStartSec=5min Restart=on-failure RestartSec=15 NoNewPrivileges=yes 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 bb50b00..392ccab 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_auth.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_auth.py @@ -5,9 +5,7 @@ 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. +Every failure of the database raises 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 @@ -24,6 +22,8 @@ import secrets import subprocess import time +import psycopg + WEB_KEY = pathlib.Path('/etc/vpn-router/web.key') DATABASE_SECRET = pathlib.Path('/etc/vpn-router/database.secret') @@ -44,7 +44,7 @@ SCHEMA = 'CREATE TABLE IF NOT EXISTS refresh_tokens (hash text PRIMARY KEY, name class DatabaseError(Exception): - """The PostgreSQL driver cannot be loaded, or the database of the refresh tokens failed.""" + """The database of the refresh tokens failed.""" def key(): @@ -117,15 +117,6 @@ 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; its password, of URL-safe characters, is written unquoted.""" configured = cfg['web']['database'].strip() @@ -138,7 +129,6 @@ def connection_string(cfg): @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) diff --git a/debian-package/src/usr/lib/vpn-router/wait-for-libpq b/debian-package/src/usr/lib/vpn-router/wait-for-libpq new file mode 100755 index 0000000..3f9e0fd --- /dev/null +++ b/debian-package/src/usr/lib/vpn-router/wait-for-libpq @@ -0,0 +1,12 @@ +#!/bin/sh +# Wait until libpq is in the dynamic linker cache, where psycopg looks it up. +# ExecStartPre= of vpn-router-web.service; TimeoutStartSec= of the unit bounds the wait. + +reported= +until ldconfig -p | grep -q '^[[:space:]]*libpq\.so\.5 '; do + if [ -z "$reported" ]; then + echo 'waiting for libpq.so.5 in the linker cache' + reported=yes + fi + sleep 2 +done diff --git a/debian-package/src/usr/lib/vpn-router/web b/debian-package/src/usr/lib/vpn-router/web index e8eceed..d71fe46 100644 --- a/debian-package/src/usr/lib/vpn-router/web +++ b/debian-package/src/usr/lib/vpn-router/web @@ -30,11 +30,10 @@ 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 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 +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. +logging each failure. Then it listens. Its unit starts it only once wait-for-libpq has found libpq +in the linker cache, where psycopg looks it up. 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, @@ -697,13 +696,11 @@ def log(message): def initialize(): - """Load the PostgreSQL driver, make the signing key and the refresh tokens' database, retrying every INIT_RETRY_SECONDS until all are ready.""" + """Make the signing key and the refresh tokens' database, retrying every INIT_RETRY_SECONDS until both are ready.""" reported = None while True: - phase = 'loading the PostgreSQL driver' + phase = 'reading the configuration' try: - vpnrouter_auth.driver() - phase = 'reading the configuration' cfg = vpnrouter.load_config() phase = 'making the signing key' vpnrouter_auth.ensure_key()