Compare commits

..
1 Commits
Author SHA1 Message Date
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
5 changed files with 72 additions and 30 deletions

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,
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
+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
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
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,))
+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
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')
+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