Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1e6d9cd0b | ||
|
|
732cc8cb54 |
No files matched your search
@@ -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,
|
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. `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
|
psycopg (Ubuntu ships only its pure-Python implementation) finds libpq through the dynamic linker
|
||||||
database that cannot be created is created once the cause is gone, and the reason is in the
|
cache, which dpkg refreshes with its deferred `ldconfig` only at the end of an installation, so a
|
||||||
service's log. The web unit may write `/etc/vpn-router` for the key and the database password.
|
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
|
## ADR-17: Bootstrap diagnostics run only until the first setup succeeds
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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.
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,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
|
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.
|
||||||
|
|
||||||
|
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
|
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
|
||||||
@@ -20,7 +23,6 @@ import subprocess
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
import psycopg
|
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 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:
|
||||||
@@ -112,19 +118,23 @@ def admin_token(seconds):
|
|||||||
|
|
||||||
|
|
||||||
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."""
|
||||||
connection.execute(SCHEMA)
|
try:
|
||||||
return connection
|
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):
|
def digest(token):
|
||||||
@@ -136,7 +146,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 +163,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 +171,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,))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+12
@@ -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
|
||||||
@@ -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
|
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
|
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,
|
||||||
@@ -58,7 +59,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 +440,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 +544,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)}
|
||||||
|
|
||||||
@@ -707,8 +707,9 @@ def initialize():
|
|||||||
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')
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user