fix: the web service starts once libpq can be found.
Release / release (push) Successful in 12s

This commit is contained in:
slawek committed 2026-10-24 11:07:45 +02:00
1 parent 732cc8cb54
commit b1e6d9cd0b
9 files changed
+55 -30

No files matched your search

+13 -7
View File
@@ -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, 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 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 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 failure, then listens. `vpnrouter_auth` turns every failure of the database into
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. `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 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 does not matter; a database that cannot be created is retried until the cause is gone, and the
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. 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 ## ADR-17: Bootstrap diagnostics run only until the first setup succeeds
+11
View File
@@ -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 <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 06:00:00 +0200
vpn-router (0.9.1-1) unstable; urgency=medium vpn-router (0.9.1-1) unstable; urgency=medium
fix: the web service waits for the PostgreSQL driver instead of failing at start. fix: the web service waits for the PostgreSQL driver instead of failing at start.
+1
View File
@@ -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/setup usr/lib/vpn-router/
src/usr/lib/vpn-router/web 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/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/generate-config usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_platforms/*.py usr/lib/vpn-router/vpnrouter_platforms/ 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/ src/usr/share/vpn-router/templates/* usr/share/vpn-router/templates/
+6
View File
@@ -70,6 +70,12 @@ case "$1" in
# The firewall is not touched here. Enabling it, allowing SSH and # The firewall is not touched here. Enabling it, allowing SSH and
# setting the forward policy all belong to vpn-router-setup, which does # setting the forward policy all belong to vpn-router-setup, which does
# them only once the configuration says this host is a router. # 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 esac
+1 -1
View File
@@ -5,5 +5,5 @@
override_dh_installsystemd: override_dh_installsystemd:
dh_installsystemd --name=vpn-router-setup dh_installsystemd --name=vpn-router-setup
dh_installsystemd --name=vpn-router-docker 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 dh_installsystemd --name=vpn-router-bootstrap --no-enable --no-start
@@ -6,7 +6,9 @@ Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
ExecStartPre=/usr/lib/vpn-router/wait-for-libpq
ExecStart=/usr/bin/python3 /usr/lib/vpn-router/web ExecStart=/usr/bin/python3 /usr/lib/vpn-router/web
TimeoutStartSec=5min
Restart=on-failure Restart=on-failure
RestartSec=15 RestartSec=15
NoNewPrivileges=yes NoNewPrivileges=yes
@@ -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 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. 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 Every failure of the database raises DatabaseError, the only database exception callers see.
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 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.database, by default the vpn-router database on this host that provision() creates on the
@@ -24,6 +22,8 @@ import secrets
import subprocess import subprocess
import time import time
import psycopg
WEB_KEY = pathlib.Path('/etc/vpn-router/web.key') WEB_KEY = pathlib.Path('/etc/vpn-router/web.key')
DATABASE_SECRET = pathlib.Path('/etc/vpn-router/database.secret') 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): 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(): def key():
@@ -117,15 +117,6 @@ def admin_token(seconds):
return {'token': sign({'admin': True, 'iat': now, 'exp': now + seconds}, secret), 'expires': now + 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): def connection_string(cfg):
"""web.database, or the database provision() creates; its password, of URL-safe characters, is written unquoted.""" """web.database, or the database provision() creates; its password, of URL-safe characters, is written unquoted."""
configured = cfg['web']['database'].strip() configured = cfg['web']['database'].strip()
@@ -138,7 +129,6 @@ def connection_string(cfg):
@contextlib.contextmanager @contextlib.contextmanager
def database(cfg): def database(cfg):
"""A connection to the database of the refresh tokens, with their table, closed at the end; DatabaseError on any failure.""" """A connection to the database of the refresh tokens, with their table, closed at the end; DatabaseError on any failure."""
psycopg = driver()
try: try:
with psycopg.connect(connection_string(cfg), autocommit=True) as connection: with psycopg.connect(connection_string(cfg), autocommit=True) as connection:
connection.execute(SCHEMA) connection.execute(SCHEMA)
+12
View File
@@ -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
+5 -8
View File
@@ -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 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. WireGuard. No configuration data, key or address is shown.
The service first initializes, without listening: it loads the PostgreSQL driver The service first initializes, without listening: it makes the signing key and the refresh
(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 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 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, 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(): 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 reported = None
while True: while True:
phase = 'loading the PostgreSQL driver' phase = 'reading the configuration'
try: try:
vpnrouter_auth.driver()
phase = 'reading the configuration'
cfg = vpnrouter.load_config() cfg = vpnrouter.load_config()
phase = 'making the signing key' phase = 'making the signing key'
vpnrouter_auth.ensure_key() vpnrouter_auth.ensure_key()