Compare commits

..
2 Commits
Author SHA1 Message Date
slawek b1e6d9cd0b fix: the web service starts once libpq can be found.
Release / release (push) Successful in 12s
2026-10-24 11:07:45 +02:00
slawek 732cc8cb54 fix: the web service waits for the PostgreSQL driver instead of failing at start.
Release / release (push) Successful in 25s
2026-10-24 10:43:51 +02:00
10 changed files with 94 additions and 27 deletions

No files matched your search

+15 -4
View File
@@ -359,11 +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.
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.
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.
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 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
+24
View File
@@ -1,3 +1,27 @@
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
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 <slawek@koszewscy.waw.pl> 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.
+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/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/
+6
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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
@@ -5,12 +5,15 @@ 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.
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
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
@@ -20,7 +23,6 @@ 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 database of the refresh tokens failed."""
def key():
"""The signing key, or '' while the web service has not made it."""
try:
@@ -112,19 +118,23 @@ def admin_token(seconds):
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."""
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 +146,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 +163,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 +171,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,))
+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
+9 -8
View File
@@ -32,7 +32,8 @@ WireGuard. No configuration data, key or address is shown.
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,
@@ -58,7 +59,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 +440,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 +544,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)}
@@ -707,8 +707,9 @@ def initialize():
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')
+3 -3
View File
@@ -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