fix: the web service waits for the PostgreSQL driver instead of failing at start.
Release / release (push) Successful in 25s
Release / release (push) Successful in 25s
This commit is contained in:
1 parent
c0cd323a0c
commit
732cc8cb54
5 files changed
+72
-30
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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,))
|
||||
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user