fix: the web service waits for the PostgreSQL driver instead of failing at start.
Release / release (push) Successful in 25s

This commit is contained in:
slawek committed 2026-10-24 10:43:51 +02:00
1 parent c0cd323a0c
commit 732cc8cb54
5 files changed
+71 -29

No files matched your search

+9 -4
View File
@@ -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, 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. 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 Consequences: The order of installation and of the start of PostgreSQL and of the linker cache
database that cannot be created is created once the cause is gone, and the reason is in the does not matter; a driver that cannot be loaded or a database that cannot be created is retried
service's log. The web unit may write `/etc/vpn-router` for the key and the database password. 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 ## ADR-17: Bootstrap diagnostics run only until the first setup succeeds
+13
View File
@@ -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 <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 05:00:00 +0200
vpn-router (0.9.0-1) unstable; urgency=medium vpn-router (0.9.0-1) unstable; urgency=medium
feature: bootstrap diagnostics on port 17081 during the first setup. feature: bootstrap diagnostics on port 17081 during the first setup.
@@ -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 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
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
web service's first run, with the password in DATABASE_SECRET. A refresh token is used once: refreshing replaces it. web service's first run, with the password in DATABASE_SECRET. A refresh token is used once: refreshing replaces it.
""" """
import base64 import base64
import contextlib
import hashlib import hashlib
import hmac import hmac
import json import json
@@ -19,9 +24,6 @@ import secrets
import subprocess import subprocess
import time import time
import psycopg
from psycopg.conninfo import make_conninfo
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')
@@ -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)' 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(): def key():
"""The signing key, or '' while the web service has not made it.""" """The signing key, or '' while the web service has not made it."""
try: try:
@@ -111,20 +117,34 @@ 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.""" """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()
if configured: if configured:
return configured return configured
password = DATABASE_SECRET.read_text(encoding='utf-8').strip() 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): @contextlib.contextmanager
"""A connection to the database of the refresh tokens, with their table.""" def database(cfg):
connection = psycopg.connect(connection_string(cfg), autocommit=True) """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) connection.execute(SCHEMA)
return connection yield connection
except psycopg.Error as exc:
raise DatabaseError(f'the token database failed: {exc}') from exc
def digest(token): def digest(token):
@@ -136,7 +156,7 @@ def issue(cfg, name):
secret = key() secret = key()
now = int(time.time()) now = int(time.time())
refresh = secrets.token_urlsafe(32) 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('DELETE FROM refresh_tokens WHERE expires < now()')
connection.execute("INSERT INTO refresh_tokens VALUES (%s, %s, now() + make_interval(secs => %s))", connection.execute("INSERT INTO refresh_tokens VALUES (%s, %s, now() + make_interval(secs => %s))",
(digest(refresh), name, REFRESH_SECONDS)) (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.""" """Remove an unexpired refresh token and return the user it was issued to, or None."""
if not isinstance(token, str) or not token: if not isinstance(token, str) or not token:
return None 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()', row = connection.execute('DELETE FROM refresh_tokens WHERE hash = %s RETURNING name, expires > now()',
(digest(token),)).fetchone() (digest(token),)).fetchone()
return row[0] if row and row[1] else None return row[0] if row and row[1] else None
@@ -161,7 +181,7 @@ def take(cfg, token):
def revoke(cfg, name): def revoke(cfg, name):
"""Remove all refresh tokens of the user.""" """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,)) connection.execute('DELETE FROM refresh_tokens WHERE name = %s', (name,))
+14 -10
View File
@@ -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 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 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 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.
@@ -58,7 +60,6 @@ from http.server import BaseHTTPRequestHandler
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
import ldap import ldap
import psycopg
import vpnrouter import vpnrouter
import vpnrouter_auth import vpnrouter_auth
@@ -440,8 +441,8 @@ def users(state, method, path, body):
return USER_STATUS.get(failure.status, 500), {'error': str(failure)} return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
except (ldap.LDAPError, OSError) as exc: except (ldap.LDAPError, OSError) as exc:
return 500, {'error': f'the user directory failed: {exc}'} return 500, {'error': f'the user directory failed: {exc}'}
except psycopg.Error as exc: except vpnrouter_auth.DatabaseError as exc:
return 500, {'error': f'the token database failed: {exc}'} return 500, {'error': str(exc)}
except SystemExit as exc: except SystemExit as exc:
# vpnrouter ends with SystemExit, carrying the reason, on a configuration it cannot read. # vpnrouter ends with SystemExit, carrying the reason, on a configuration it cannot read.
return 500, {'error': str(exc.code)} 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)} return (401 if failure.status == 3 else USER_STATUS.get(failure.status, 500)), {'error': str(failure)}
except (ldap.LDAPError, OSError) as exc: except (ldap.LDAPError, OSError) as exc:
return 500, {'error': f'the user directory failed: {exc}'} return 500, {'error': f'the user directory failed: {exc}'}
except psycopg.Error as exc: except vpnrouter_auth.DatabaseError as exc:
return 500, {'error': f'the token database failed: {exc}'} return 500, {'error': str(exc)}
except SystemExit as exc: except SystemExit as exc:
return 500, {'error': str(exc.code)} return 500, {'error': str(exc.code)}
@@ -696,19 +697,22 @@ def log(message):
def initialize(): 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 reported = None
while True: while True:
phase = 'reading the configuration' phase = 'loading the PostgreSQL driver'
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()
phase = 'creating the token database' phase = 'creating the token database'
vpnrouter_auth.provision(cfg) vpnrouter_auth.provision(cfg)
phase = 'connecting to the token database' phase = 'connecting to the token database'
vpnrouter_auth.connect(cfg).close() with vpnrouter_auth.database(cfg):
except (OSError, psycopg.Error, SystemExit) as exc: pass
except (OSError, SystemExit, vpnrouter_auth.DatabaseError) as exc:
error = f'{phase} failed: {exc.code if isinstance(exc, SystemExit) else exc}' error = f'{phase} failed: {exc.code if isinstance(exc, SystemExit) else exc}'
if error != reported: if error != reported:
log(f'{error}; retrying every {INIT_RETRY_SECONDS} seconds') 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') sys.path.insert(0, '/usr/lib/vpn-router')
import ldap import ldap
import psycopg
import vpnrouter_auth
import vpnrouter_users import vpnrouter_users
from vpnrouter_users import Failure from vpnrouter_users import Failure
@@ -135,8 +135,8 @@ def main(argv):
message, status = str(failure), failure.status message, status = str(failure), failure.status
except (ldap.LDAPError, OSError) as exc: except (ldap.LDAPError, OSError) as exc:
message, status = f'the user directory failed: {exc}', 1 message, status = f'the user directory failed: {exc}', 1
except psycopg.Error as exc: except vpnrouter_auth.DatabaseError as exc:
message, status = f'the token database failed: {exc}', 1 message, status = str(exc), 1
else: else:
print(json.dumps(answer) if as_json else text_of(answer)) print(json.dumps(answer) if as_json else text_of(answer))
return 0 return 0