Compare commits

..
5 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
slawek c0cd323a0c feature: bootstrap diagnostics on port 17081 during the first setup.
Release / release (push) Successful in 13s
2026-10-24 10:07:22 +02:00
slawek 2e2bacee7d feature: the web service creates its key and database itself and shows its starting phase.
Release / release (push) Successful in 29s
2026-10-24 09:07:28 +02:00
slawek 4f1aa282f6 fix: a failing user directory or token database no longer leaves the tunnels down.
Release / release (push) Failing after 15m34s
2026-10-24 08:55:25 +02:00
18 changed files with 503 additions and 119 deletions

No files matched your search

+58 -4
View File
@@ -64,8 +64,8 @@ The service listens on plain HTTP on the internal network.
Decision: `/config`, `/users` and `/groups` need a JWT in an `Authorization: Bearer` header, Decision: `/config`, `/users` and `/groups` need a JWT in an `Authorization: Bearer` header,
HS256, verified with `hmac` and `hashlib`; no other algorithm is accepted. The key is HS256, verified with `hmac` and `hashlib`; no other algorithm is accepted. The key is
`/etc/vpn-router/web.key`, random, made by `vpn-router-setup` on the router and readable by root `/etc/vpn-router/web.key`, random, made by the web service on its first run and readable by root
only; it is never configured or output, and `web.jwt_secret` is ignored. The router itself issues only; it is never configured or output. The router itself issues
the tokens: `POST /auth` checks a user's password and TOTP code with a bind (ADR-14) and answers the tokens: `POST /auth` checks a user's password and TOTP code with a bind (ADR-14) and answers
an access token naming the user in `sub`, valid for 5 minutes, and a refresh token valid for 8 an access token naming the user in `sub`, valid for 5 minutes, and a refresh token valid for 8
hours (ADR-15); `vpn-router-token` makes tokens with the `admin` claim (ADR-13). A token without hours (ADR-15); `vpn-router-token` makes tokens with the `admin` claim (ADR-13). A token without
@@ -337,8 +337,8 @@ sessions in PostgreSQL too.
Decision: Refresh tokens are random strings, stored as their SHA-256 with the user name and the Decision: Refresh tokens are random strings, stored as their SHA-256 with the user name and the
expiry in the table `refresh_tokens`, through psycopg 3 (`python3-psycopg`). The database is expiry in the table `refresh_tokens`, through psycopg 3 (`python3-psycopg`). The database is
`web.database`, or by default the database and role `vpn-router` on the router, which `web.database`, or by default the database and role `vpn-router` on the router, which the web
`vpn-router-setup` creates with `runuser -u postgres -- psql` and a random password in service creates on its first run (ADR-16) with `runuser -u postgres -- psql` and a random password in
`/etc/vpn-router/database.secret`, reached over TCP on `localhost`, as Ubuntu's `pg_hba.conf` `/etc/vpn-router/database.secret`, reached over TCP on `localhost`, as Ubuntu's `pg_hba.conf`
takes peer authentication on the local socket. A refresh token is used once: refreshing deletes takes peer authentication on the local socket. A refresh token is used once: refreshing deletes
it and issues a new one, after checking that the user is neither disabled nor locked. it and issues a new one, after checking that the user is neither disabled nor locked.
@@ -347,3 +347,57 @@ Consequences: The package depends on `postgresql` and `python3-psycopg`. A stole
no usable token. Changing a user's password, disabling, unlocking or removing the user, and no usable token. Changing a user's password, disabling, unlocking or removing the user, and
signing out, end the user's sessions at once; an access token already issued lives out its signing out, end the user's sessions at once; an access token already issued lives out its
5 minutes. 5 minutes.
## ADR-16: Each service creates what it needs on its first run
Status: Accepted
Context: The package manager configures packages in an order of its own; on a new VM the Router
Manager was configured before the PostgreSQL server, so a database created at installation was
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. `vpnrouter_auth` turns every failure of the database into
`DatabaseError`, the only database exception the web service and `vpn-router-user` handle.
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
Status: Accepted
Context: A new VM is reachable only once its setup has worked, and the reason of a failure must
be readable from the internal network before an SSH session is possible. A service that reports
the setup cannot be one of the services being set up: those import packages that may not load yet
(psycopg could not find libpq before `ldconfig` ran), and they would fail before reporting
anything. Once the router has been set up, SSH is the way to diagnose it. The more a diagnostic
page does, the more ways it has to fail itself.
Decision: Each package ships a bootstrap service of the Python standard library alone, a script
that answers every request with the plain-text output of a fixed list of commands: the setup
state, the services, the failed units, the addresses and the end of the logs. The router's
listens on 17081 and the Router Manager's on 17080, beside the ports of the services, 7081 and
7080, on every address. The units are never enabled and not started at installation;
`vpn-router-setup` starts them while `/var/lib/vpn-router/setup.done` does not exist, and opens
their ports on the internal interface. The first successful run writes that file, closes the ports
and stops them; a failed run leaves them running, so the reason stays readable. Setup records its
state, step and failure in `/run/vpn-router/setup.json` at every step; `vpnrouter.fail` ends with
a `SystemExit` carrying its message, so setup records it.
Consequences: The diagnostics need nothing the setup provides and cannot collide with the real
services. After the first success they never run again, also not after an upgrade or a reboot.
The page shows log lines to whoever reaches the internal network during the first setup.
+24 -3
View File
@@ -125,7 +125,7 @@ must have the type shown below; an unknown section or setting is an error.
| `docker` | `exposed_ports` | list | | TCP ports and port ranges (`8000:8100`) reachable from the external interface; at most 15, a range counting as two | | `docker` | `exposed_ports` | list | | TCP ports and port ranges (`8000:8100`) reachable from the external interface; at most 15, a range counting as two |
| `web` | `enabled` | boolean | `true` | The status web service on the internal address | | `web` | `enabled` | boolean | `true` | The status web service on the internal address |
| `web` | `port` | integer | `7081` | TCP port of the status web service | | `web` | `port` | integer | `7081` | TCP port of the status web service |
| `web` | `database` | string | | A PostgreSQL connection string for the refresh tokens; without it, the `vpn-router` database on this host that `vpn-router-setup` creates | | `web` | `database` | string | | A PostgreSQL connection string for the refresh tokens; without it, the `vpn-router` database on this host that the web service creates |
| `web` | `trusted_proxies` | list | the listening address | Addresses whose `X-Forwarded-For` header names the client for the `/auth` rate limit | | `web` | `trusted_proxies` | list | the listening address | Addresses whose `X-Forwarded-For` header names the client for the `/auth` rate limit |
A minimal site-to-site configuration in `auto` mode: A minimal site-to-site configuration in `auto` mode:
@@ -473,6 +473,27 @@ The `vpn-router-web` service shows the state of the router on its internal addre
`web.port` (7081 by default). Its pages `/` and `/status` need no authentication and show no `web.port` (7081 by default). Its pages `/` and `/status` need no authentication and show no
configuration: no key, identity, address or subnet. configuration: no key, identity, address or subnet.
The service first makes its signing key and the refresh tokens' database, retrying every 15
seconds, for example while PostgreSQL is not running yet, and logs each failure; then it listens.
### Bootstrap diagnostics
Until a run of `vpn-router-setup` has succeeded, every run starts `vpn-router-bootstrap`, which
serves diagnostics as plain text on port 17081 of every address, and the firewall opens 17081 and
17080 (the Router Manager's bootstrap) on the internal interface. The first successful run stops
the bootstrap services and closes their ports, and writes `/var/lib/vpn-router/setup.done`, after
which they never run again; a failed run leaves them running, so the reason stays readable until
a later run succeeds. The unit is never enabled.
The page, read again every 10 seconds, shows the state, the step and the failure of the last
setup run (`/run/vpn-router/setup.json`, which setup writes at every step), the state of the
router's services, the failed units, the addresses and routes, `cloud-init status`, and the end of
the logs of `vpn-router-setup` and `vpn-router-web`:
```bash
curl http://10.0.0.4:17081/
```
| Page | Content | | Page | Content |
| --- | --- | | --- | --- |
| `/` | The status for people: plain text for `curl` and `wget`, a page styled with Tailwind CSS for a browser, in the browser's light or dark scheme, that reloads itself every minute (the style sheet is loaded from a CDN, so the browser needs internet access; the text is readable without it) | | `/` | The status for people: plain text for `curl` and `wget`, a page styled with Tailwind CSS for a browser, in the browser's light or dark scheme, that reloads itself every minute (the style sheet is loaded from a CDN, so the browser needs internet access; the text is readable without it) |
@@ -500,7 +521,7 @@ curl http://10.0.0.4:7081/status
### Tokens and signing in ### Tokens and signing in
`/config`, `/users` and `/groups` need a JWT in an `Authorization: Bearer` header, signed with `/config`, `/users` and `/groups` need a JWT in an `Authorization: Bearer` header, signed with
HS256 by the key `vpn-router-setup` makes on the router, `/etc/vpn-router/web.key` (root only). HS256 by the key the web service makes on its first run, `/etc/vpn-router/web.key` (root only).
The key never leaves the router; while it does not exist these paths and `/auth` are not served The key never leaves the router; while it does not exist these paths and `/auth` are not served
(404). A missing, expired or wrongly signed token, or one of another algorithm, gets 401 with (404). A missing, expired or wrongly signed token, or one of another algorithm, gets 401 with
`WWW-Authenticate: Bearer`. There are two kinds of tokens: `WWW-Authenticate: Bearer`. There are two kinds of tokens:
@@ -539,7 +560,7 @@ default the address the service listens on, the client address is the last entry
`X-Forwarded-For`. `X-Forwarded-For`.
Refresh tokens are kept, as their SHA-256, in PostgreSQL: by default in the database and role Refresh tokens are kept, as their SHA-256, in PostgreSQL: by default in the database and role
`vpn-router` on the router, which `vpn-router-setup` creates with a password in `vpn-router` on the router, which the web service creates on its first run with a password in
`/etc/vpn-router/database.secret` (root only), reached over TCP on `localhost`; `web.database` `/etc/vpn-router/database.secret` (root only), reached over TCP on `localhost`; `web.database`
names another database. Tokens and passwords travel over plain HTTP on the internal network. names another database. Tokens and passwords travel over plain HTTP on the internal network.
+66
View File
@@ -1,3 +1,69 @@
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.
* vpn-router-bootstrap serves, on port 17081, the setup state, the
services, the failed units, the addresses, cloud-init status and the end
of the logs of setup and the web service, as plain text.
* vpn-router-setup starts it, and the Router Manager's bootstrap on port
17080, until a run has succeeded, and opens both ports on the internal
interface; the first successful run writes
/var/lib/vpn-router/setup.done, closes the ports and stops them. A
failed run leaves them running.
* The web service no longer has a starting phase: it creates its key and
database, then listens.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 04:00:00 +0200
vpn-router (0.8.0-1) unstable; urgency=medium
feature: the web service creates its key and database itself and shows its starting phase.
* vpn-router-web makes its signing key and creates the refresh tokens'
database on its first run, retrying every 15 seconds until PostgreSQL
answers; vpn-router-setup no longer creates them.
* Until then / and /status show the web service's phase and its failure,
with the state, the step and the failure of the last run of
vpn-router-setup, and every page answers 503.
* vpn-router-setup records its state, current step and failure in
/run/vpn-router/setup.json.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 03:00:00 +0200
vpn-router (0.7.1-1) unstable; urgency=medium
fix: a failing user directory or token database no longer leaves the tunnels down.
* vpn-router-setup brings up strongSwan and WireGuard before it provisions
the user directory and the token database. A failure of either no
longer stops the other steps; setup reports it and fails at the end.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 02:00:00 +0200
vpn-router (0.7.0-1) unstable; urgency=medium vpn-router (0.7.0-1) unstable; urgency=medium
feature: users sign in to the web service, and only members of admins manage the router. feature: users sign in to the web service, and only members of admins manage the router.
+3
View File
@@ -2,6 +2,7 @@ src/etc/sysctl.d/99-vpn-router.conf etc/sysctl.d/
src/usr/lib/vpn-router/vpnrouter.py usr/lib/vpn-router/ src/usr/lib/vpn-router/vpnrouter.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_ldap.py usr/lib/vpn-router/ src/usr/lib/vpn-router/vpnrouter_ldap.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_net.py usr/lib/vpn-router/ src/usr/lib/vpn-router/vpnrouter_net.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_serve.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_users.py usr/lib/vpn-router/ src/usr/lib/vpn-router/vpnrouter_users.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_auth.py usr/lib/vpn-router/ src/usr/lib/vpn-router/vpnrouter_auth.py usr/lib/vpn-router/
src/usr/lib/vpn-router/openvpn-same-user usr/lib/vpn-router/ src/usr/lib/vpn-router/openvpn-same-user usr/lib/vpn-router/
@@ -13,6 +14,8 @@ src/usr/lib/vpn-router/configure usr/lib/vpn-router/
src/usr/lib/vpn-router/docker-rules usr/lib/vpn-router/ 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/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/
+6
View File
@@ -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 -3
View File
@@ -11,10 +11,10 @@ strip_ufw_blocks() {
tmp="$(mktemp)" tmp="$(mktemp)"
awk ' awk '
/^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|MANAGER|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES START$/ { /^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|BOOTSTRAP|MANAGER|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES START$/ {
skip = 1; pending = 0; next skip = 1; pending = 0; next
} }
/^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|MANAGER|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES END$/ { /^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|BOOTSTRAP|MANAGER|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES END$/ {
skip = 0; next skip = 0; next
} }
skip { next } skip { next }
@@ -50,7 +50,9 @@ case "$1" in
/etc/openvpn/server/vpn-router.conf \ /etc/openvpn/server/vpn-router.conf \
/etc/openvpn/server/vpn-router-ldap.conf \ /etc/openvpn/server/vpn-router-ldap.conf \
/etc/vpn-router/vpn-router.json \ /etc/vpn-router/vpn-router.json \
/etc/vpn-router/web.key /etc/vpn-router/web.key \
/var/lib/vpn-router/setup.done
rmdir --ignore-fail-on-non-empty /var/lib/vpn-router 2>/dev/null || true
# The refresh tokens' database and role, when the package created them. # The refresh tokens' database and role, when the package created them.
if [ -f /etc/vpn-router/database.secret ] && command -v psql >/dev/null 2>&1; then if [ -f /etc/vpn-router/database.secret ] && command -v psql >/dev/null 2>&1; then
+2 -1
View File
@@ -5,4 +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
@@ -0,0 +1,14 @@
[Unit]
Description=VPN Router bootstrap diagnostics page
Documentation=file:/usr/share/doc/vpn-router/README.md
# Never enabled: vpn-router-setup starts it until a setup has succeeded, and stops it after that.
[Service]
Type=simple
ExecStart=/usr/bin/python3 /usr/lib/vpn-router/bootstrap
Restart=on-failure
RestartSec=5
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
@@ -6,12 +6,14 @@ 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
ProtectSystem=strict ProtectSystem=strict
ReadWritePaths=-/run/vpn-router -/etc/vpn-router/pki -/etc/swanctl/x509crl ReadWritePaths=-/run/vpn-router -/etc/vpn-router -/etc/swanctl/x509crl
ProtectHome=yes ProtectHome=yes
PrivateTmp=yes PrivateTmp=yes
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/python3
"""Serve diagnostics of the router's first setup on PORT, with the Python standard library alone.
Run by vpn-router-bootstrap.service, which vpn-router-setup starts until a setup has succeeded and
then stops. Every request on any address answers, as plain text, the output of COMMANDS run at that
moment: the state of the last setup run, the state of the services, the addresses, and the end of
the logs.
"""
import subprocess
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
PORT = 17081
#: The title of each section and its command.
COMMANDS = (
('Setup state', ['cat', '/run/vpn-router/setup.json']),
('Services', ['systemctl', 'is-active', 'vpn-router-setup', 'vpn-router-web', 'slapd', 'postgresql',
'strongswan', 'openvpn-server@vpn-router', 'wg-quick@wg0']),
('Failed units', ['systemctl', '--failed', '--no-legend', '--plain']),
('Addresses', ['ip', '-brief', 'address']),
('Routes', ['ip', 'route']),
('cloud-init', ['cloud-init', 'status', '--long']),
('vpn-router-setup log', ['journalctl', '--unit', 'vpn-router-setup', '--lines', '60', '--no-pager', '--output', 'short-iso']),
('vpn-router-web log', ['journalctl', '--unit', 'vpn-router-web', '--lines', '30', '--no-pager', '--output', 'short-iso']),
)
def report():
"""The output of every command of COMMANDS under its title."""
sections = []
for title, command in COMMANDS:
try:
result = subprocess.run(command, capture_output=True, text=True, timeout=10)
text = (result.stdout + result.stderr).rstrip()
except (OSError, subprocess.TimeoutExpired) as exc:
text = f'cannot run {command[0]}: {exc}'
sections.append(f'== {title} ==\n{text}\n')
return '\n'.join(sections)
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
body = report().encode()
self.send_response(200)
self.send_header('Content-Type', 'text/plain; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
self.send_header('Cache-Control', 'no-store')
self.send_header('Refresh', '10')
self.end_headers()
self.wfile.write(body)
if __name__ == '__main__':
ThreadingHTTPServer(('', PORT), Handler).serve_forever()
+106 -19
View File
@@ -5,6 +5,12 @@ Regenerates the configuration files first, then does everything that needs
the live system: routes, DNS-derived identity, WireGuard keys, firewall rules the live system: routes, DNS-derived identity, WireGuard keys, firewall rules
and service reloads. Idempotent - a second run changes nothing. and service reloads. Idempotent - a second run changes nothing.
Until a run has succeeded (SETUP_DONE), every run starts the bootstrap
services, BOOTSTRAP_UNITS, which show the progress and the failure recorded in
SETUP_STATE on BOOTSTRAP_PORTS, and the firewall opens those ports on the
internal interface. The first successful run writes SETUP_DONE, closes the
ports and stops the bootstrap services; a failed run leaves them running.
Interface names come from the configuration and are never guessed. The Interface names come from the configuration and are never guessed. The
firewall and NAT rules are written against them, so choosing the wrong one firewall and NAT rules are written against them, so choosing the wrong one
would be a silent and security-relevant mistake. would be a silent and security-relevant mistake.
@@ -17,13 +23,11 @@ import re
import socket import socket
import subprocess import subprocess
import sys import sys
import time
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 vpnrouter import vpnrouter
import vpnrouter_auth
import vpnrouter_ldap import vpnrouter_ldap
from vpnrouter import fail from vpnrouter import fail
@@ -32,6 +36,16 @@ UFW_DEFAULTS = pathlib.Path('/etc/default/ufw')
WG_DIR = pathlib.Path('/etc/wireguard') WG_DIR = pathlib.Path('/etc/wireguard')
WEB_STATE = pathlib.Path('/run/vpn-router/web.json') WEB_STATE = pathlib.Path('/run/vpn-router/web.json')
#: The state, the current step and the failure of the last run, which the bootstrap services show.
SETUP_STATE = pathlib.Path('/run/vpn-router/setup.json')
#: Written once a run has succeeded; until then setup runs the bootstrap services and opens their ports.
SETUP_DONE = pathlib.Path('/var/lib/vpn-router/setup.done')
#: The units of the bootstrap services, and their ports: the Router Manager's and the router's.
BOOTSTRAP_UNITS = ('vpn-router-bootstrap.service', 'vpn-router-manager-bootstrap.service')
BOOTSTRAP_PORTS = (17080, 17081)
#: The Router Manager, vpn-router-manager, when it is installed, and its settings. #: The Router Manager, vpn-router-manager, when it is installed, and its settings.
MANAGER = pathlib.Path('/usr/lib/vpn-router-manager/manager') MANAGER = pathlib.Path('/usr/lib/vpn-router-manager/manager')
MANAGER_CONFIG = pathlib.Path('/etc/vpn-router-manager/manager.json') MANAGER_CONFIG = pathlib.Path('/etc/vpn-router-manager/manager.json')
@@ -48,6 +62,7 @@ BLOCKS = (
('# OPENVPN RULES START', '# OPENVPN RULES END'), ('# OPENVPN RULES START', '# OPENVPN RULES END'),
('# P2S DNS RULES START', '# P2S DNS RULES END'), ('# P2S DNS RULES START', '# P2S DNS RULES END'),
('# WEB RULES START', '# WEB RULES END'), ('# WEB RULES START', '# WEB RULES END'),
('# BOOTSTRAP RULES START', '# BOOTSTRAP RULES END'),
('# MANAGER RULES START', '# MANAGER RULES END'), ('# MANAGER RULES START', '# MANAGER RULES END'),
('# ROUTER FORWARD RULES START', '# ROUTER FORWARD RULES END'), ('# ROUTER FORWARD RULES START', '# ROUTER FORWARD RULES END'),
('# ROUTER NAT RULES START', '# ROUTER NAT RULES END'), ('# ROUTER NAT RULES START', '# ROUTER NAT RULES END'),
@@ -201,6 +216,14 @@ def filter_block(ctx):
'# WEB RULES END', '# WEB RULES END',
] ]
if not SETUP_DONE.exists() and ctx['int_iface']:
lines += [
'',
'# BOOTSTRAP RULES START',
*(f'-A ufw-before-input -i {ctx["int_iface"]} -p tcp --dport {port} -j ACCEPT' for port in BOOTSTRAP_PORTS),
'# BOOTSTRAP RULES END',
]
if MANAGER.exists() and ctx['int_iface']: if MANAGER.exists() and ctx['int_iface']:
port = manager_port() port = manager_port()
lines += [ lines += [
@@ -349,22 +372,16 @@ def manage_wireguard(ctx, changed):
def manage_directory(): def manage_directory():
"""Provision the user directory in slapd, whether or not a P2S system is configured.""" """Provision the user directory in slapd, whether or not a P2S system is configured; the problem, or ''."""
if vpnrouter_ldap.ensure_services() or not unit_is_active('slapd'): if vpnrouter_ldap.ensure_services() or not unit_is_active('slapd'):
run(['systemctl', 'restart', 'slapd']) result = run(['systemctl', 'restart', 'slapd'], check=False)
if result.returncode != 0:
return f'slapd cannot be restarted: {result.stderr.strip()}'
try: try:
vpnrouter_ldap.provision() vpnrouter_ldap.provision()
except ldap.LDAPError as exc: except Exception as exc:
fail(f'the user directory cannot be provisioned: {exc}') return f'the user directory cannot be provisioned: {exc!r}'
return ''
def manage_tokens(cfg):
"""Make the web service's signing key, and the database of its refresh tokens unless web.database names one."""
vpnrouter_auth.ensure_key()
try:
vpnrouter_auth.provision(cfg)
except OSError as exc:
fail(f'the token database cannot be provisioned: {exc}')
def manage_openvpn(ctx, changed): def manage_openvpn(ctx, changed):
@@ -442,25 +459,62 @@ def refresh_web(cfg, ctx):
}) + '\n', encoding='utf-8') }) + '\n', encoding='utf-8')
def progress(state, step, error=None):
"""Record where setup is in SETUP_STATE: running, deferred, done or failed, the step, and the error of a failure."""
SETUP_STATE.parent.mkdir(parents=True, exist_ok=True)
SETUP_STATE.write_text(json.dumps({
'state': state,
'step': step,
'error': error,
'time': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
}) + '\n', encoding='utf-8')
def start_bootstrap():
"""Start the bootstrap services while no run has succeeded; a unit that is not installed is skipped."""
if not SETUP_DONE.exists():
for unit in BOOTSTRAP_UNITS:
run(['systemctl', 'start', '--no-block', unit], check=False)
def end_bootstrap(ctx):
"""After the first successful run: write SETUP_DONE, close the bootstrap ports and stop the bootstrap services."""
if SETUP_DONE.exists():
return
SETUP_DONE.parent.mkdir(parents=True, exist_ok=True)
SETUP_DONE.touch()
if setup_ufw(ctx):
run(['ufw', 'reload'], check=False)
for unit in BOOTSTRAP_UNITS:
run(['systemctl', 'stop', unit], check=False)
def main(): def main():
start_bootstrap()
progress('running', 'configuration')
cfg = vpnrouter.load_config() cfg = vpnrouter.load_config()
ctx, changed = vpnrouter.configure(cfg) ctx, changed = vpnrouter.configure(cfg)
if ctx is None: if ctx is None:
print('vpn-router: platform is none, configuration deferred; ' print('vpn-router: platform is none, configuration deferred; '
f'set general.platform in {vpnrouter.CONFIG_FILE}') f'set general.platform in {vpnrouter.CONFIG_FILE}')
progress('deferred', 'configuration', f'general.platform is none; set it in {vpnrouter.CONFIG_FILE}')
return 0 return 0
progress('running', 'routes')
if vpnrouter.routing_ready(ctx): if vpnrouter.routing_ready(ctx):
apply_routes(ctx) apply_routes(ctx)
if ctx['local_id_mode'] == 'public_ip' and ctx['local_fqdn']: if ctx['local_id_mode'] == 'public_ip' and ctx['local_fqdn']:
progress('running', 'public address')
address = resolve_public_ip(ctx['local_fqdn']) address = resolve_public_ip(ctx['local_fqdn'])
ctx, more = vpnrouter.configure(cfg, local_id_override=address) ctx, more = vpnrouter.configure(cfg, local_id_override=address)
changed |= more changed |= more
progress('running', 'WireGuard keys')
setup_wireguard(ctx) setup_wireguard(ctx)
progress('running', 'firewall')
rules_changed = setup_ufw(ctx) rules_changed = setup_ufw(ctx)
if is_configured(ctx): if is_configured(ctx):
status = run(['ufw', 'status'], check=False) status = run(['ufw', 'status'], check=False)
@@ -473,20 +527,53 @@ def main():
elif rules_changed: elif rules_changed:
run(['ufw', 'reload'], check=False) run(['ufw', 'reload'], check=False)
manage_directory() progress('running', 'strongSwan')
manage_tokens(cfg)
manage_strongswan(ctx, changed) manage_strongswan(ctx, changed)
progress('running', 'WireGuard')
manage_wireguard(ctx, changed) manage_wireguard(ctx, changed)
# The tunnels are up before the directory, whose problem ends setup only after every other step.
progress('running', 'user directory')
problem = manage_directory()
progress('running', 'OpenVPN')
manage_openvpn(ctx, changed) manage_openvpn(ctx, changed)
if 'resolved' in changed: if 'resolved' in changed:
progress('running', 'resolver')
run(['systemctl', 'restart', 'systemd-resolved'], check=False) run(['systemctl', 'restart', 'systemd-resolved'], check=False)
progress('running', 'platform module')
run_platform_setup(cfg, ctx) run_platform_setup(cfg, ctx)
progress('running', 'Docker rules')
refresh_docker_rules() refresh_docker_rules()
progress('running', 'web service state')
refresh_web(cfg, ctx) refresh_web(cfg, ctx)
if problem:
progress('failed', 'user directory', problem)
print(f'vpn-router: error: {problem}', file=sys.stderr)
return 1
progress('done', 'done')
end_bootstrap(ctx)
return 0 return 0
def record_failure(error):
"""Mark the run failed in the step SETUP_STATE holds."""
try:
step = json.loads(SETUP_STATE.read_text(encoding='utf-8')).get('step')
except (OSError, ValueError):
step = None
progress('failed', step, error)
if __name__ == '__main__': if __name__ == '__main__':
sys.exit(main()) # A failing step ends with SystemExit carrying its message (vpnrouter.fail).
try:
code = main()
except SystemExit as exc:
record_failure(str(exc.code))
raise
except Exception as exc:
record_failure(repr(exc))
raise
sys.exit(code)
@@ -54,8 +54,7 @@ SCHEMA = {
'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820}, 'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820},
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'}, 'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
'docker': {'mode': 'auto', 'exposed_ports': []}, 'docker': {'mode': 'auto', 'exposed_ports': []},
# web.jwt_secret is read and ignored; the tokens are signed with vpnrouter_auth.WEB_KEY. 'web': {'enabled': True, 'port': 7081, 'database': '', 'trusted_proxies': []},
'web': {'enabled': True, 'port': 7081, 'jwt_secret': '', 'database': '', 'trusted_proxies': []},
} }
DOCKER_MODES = ('auto', 'on', 'off') DOCKER_MODES = ('auto', 'on', 'off')
@@ -96,8 +95,8 @@ def warn(message):
def fail(message): def fail(message):
print(f'vpn-router: error: {message}', file=sys.stderr) """End with status 1; SystemExit carries the message, which Python prints to standard error."""
sys.exit(1) sys.exit(f'vpn-router: error: {message}')
def load_config(path=None): def load_config(path=None):
@@ -1,16 +1,19 @@
"""The tokens of the router's web service, for the web service and vpn-router-token. """The tokens of the router's web service, for the web service and vpn-router-token.
Tokens are HS256 JWTs signed with WEB_KEY, a key vpn-router-setup makes on the router and that Tokens are HS256 JWTs signed with WEB_KEY, a key the web service makes on its first run on the
never leaves it. /auth issues an access token naming the user in sub, valid ACCESS_SECONDS, and router and that never leaves it. /auth issues an access token naming the user in sub, valid ACCESS_SECONDS, and
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, with web.database, by default the vpn-router database on this host that provision() creates on the
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,8 +43,12 @@ 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 vpn-router-setup has not made it.""" """The signing key, or '' while the web service has not made it."""
try: try:
return WEB_KEY.read_text(encoding='utf-8').strip() return WEB_KEY.read_text(encoding='utf-8').strip()
except OSError: except OSError:
@@ -106,25 +112,29 @@ def admin_token(seconds):
"""A token with the admin claim, valid for the seconds given.""" """A token with the admin claim, valid for the seconds given."""
secret = key() secret = key()
if not secret: if not secret:
raise OSError(f'{WEB_KEY} does not exist; vpn-router-setup makes it once a platform is chosen') raise OSError(f'{WEB_KEY} does not exist; vpn-router-web makes it when it starts')
now = int(time.time()) now = int(time.time())
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 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,))
@@ -1,6 +1,7 @@
"""Interfaces and addresses of the router, read from the system. """Interfaces and addresses of the router, read from the system.
Only the Python standard library is used, so the web service can use this module too. Only the Python standard library is used, so the web service and its bootstrap server can use
this module too.
""" """
import pathlib import pathlib
@@ -107,3 +108,28 @@ def internal_address(mode, internal, int_addr):
except ConfigError: except ConfigError:
return '' return ''
return addresses[0] if addresses else '' return addresses[0] if addresses else ''
def web_listen(configuration, default_port):
"""Where the web service listens, from vpn-router.json read as configuration, as ((address, port), '').
The address is the internal address as general.mode directs, the port web.port, else
default_port. While web.enabled is false or the address is not known, the answer is (None,
the reason).
"""
def section(name):
value = configuration.get(name)
return value if isinstance(value, dict) else {}
web = section('web')
if web.get('enabled', True) is False:
return None, 'the web service is disabled in the configuration'
port = web.get('port', default_port)
if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
port = default_port
general, interfaces, local = section('general'), section('interfaces'), section('local')
address = internal_address(str(general.get('mode') or 'manual'), str(interfaces.get('internal') or ''),
str(local.get('int_addr') or ''))
if not address:
return None, 'the internal address is not known yet'
return (address, port), ''
@@ -0,0 +1,40 @@
"""The listening loop of the web service and of its bootstrap server.
Only the Python standard library is used, so the bootstrap server, which must run whatever else
is broken, can use it.
"""
import threading
import time
from http.server import ThreadingHTTPServer
def serve(wanted, handler, log, interval):
"""Work out where to listen every interval seconds with wanted, and run an HTTP server of handler there.
wanted answers ((address, port), '') or (None, the reason not to listen). When the address or
the port changes, the server is stopped and a new one listens at the new place; while there is
no place or it cannot be bound, no server runs and the next round tries again. Every new
reason is logged with log. Does not return.
"""
server, thread, listening, reported = None, None, None, None
while True:
target, reason = wanted()
if target != listening and server is not None:
server.shutdown()
server.server_close()
thread.join()
server, listening = None, None
if target is not None and server is None:
try:
server = ThreadingHTTPServer(target, handler)
except OSError as exc:
reason = f'cannot listen on {target[0]}:{target[1]}: {exc}'
else:
thread = threading.Thread(target=server.serve_forever, name='server', daemon=True)
thread.start()
listening, reason = target, f'serving on http://{target[0]}:{target[1]}'
if reason and reason != reported:
log(reason)
reported = reason
time.sleep(interval)
+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
+49 -63
View File
@@ -30,11 +30,16 @@ 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 needs nothing else to start and does not end on its own. A discovery thread works The service first initializes, without listening: it makes the signing key and the refresh
out the internal address from /etc/vpn-router/vpn-router.json as general.mode directs, with tokens' database (vpnrouter_auth), retrying every INIT_RETRY_SECONDS until both are ready and
web.port and web.enabled, every DISCOVERY_SECONDS, and runs the server thread there: it starts logging each failure. Then it listens. Its unit starts it only once wait-for-libpq has found libpq
it, moves it when the address or the port changes, and stops it while the service is disabled in the linker cache, where psycopg looks it up.
or the address is not known. Every request reads what it needs when it is made: the signing
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,
with web.port and web.enabled (vpnrouter_net.web_listen), and runs the server there
(vpnrouter_serve.serve): it starts it, moves it when the address or the port changes, and stops
it while the service is disabled or the address is not known. Every request reads what it needs when it is made: the signing
key, the configuration, and the features of the router and the facts shown by /config from key, the configuration, and the features of the router and the facts shown by /config from
/run/vpn-router/web.json, which vpn-router-setup writes. Before setup has written that file /run/vpn-router/web.json, which vpn-router-setup writes. Before setup has written that file
the router has no features, and /config shows no configuration or interfaces. the router has no features, and /config shows no configuration or interfaces.
@@ -49,21 +54,24 @@ import sys
import threading import threading
import time import time
from html import escape from html import escape
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer 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
import vpnrouter_net import vpnrouter_net
import vpnrouter_serve
import vpnrouter_users import vpnrouter_users
STATE_FILE = '/run/vpn-router/web.json' STATE_FILE = '/run/vpn-router/web.json'
CONFIG_FILE = '/etc/vpn-router/vpn-router.json' CONFIG_FILE = '/etc/vpn-router/vpn-router.json'
#: Seconds between two attempts of the initialization.
INIT_RETRY_SECONDS = 15
#: The port when web.port is not set. #: The port when web.port is not set.
DEFAULT_PORT = 7081 DEFAULT_PORT = 7081
@@ -432,11 +440,11 @@ 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: except SystemExit as exc:
# vpnrouter ends with SystemExit on a configuration it cannot read; it has printed the reason. # vpnrouter ends with SystemExit, carrying the reason, on a configuration it cannot read.
return 500, {'error': 'the router configuration cannot be read'} return 500, {'error': str(exc.code)}
return (201 if route == ('POST', 1, None) else 200), document return (201 if route == ('POST', 1, None) else 200), document
@@ -536,10 +544,10 @@ 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: except SystemExit as exc:
return 500, {'error': 'the router configuration cannot be read'} return 500, {'error': str(exc.code)}
def auth_wait(address): def auth_wait(address):
@@ -683,63 +691,41 @@ class Handler(BaseHTTPRequestHandler):
sys.stderr.write('%s - %s [%s] %s\n' % (self.client_address[0], principal, self.log_date_time_string(), format % args)) sys.stderr.write('%s - %s [%s] %s\n' % (self.client_address[0], principal, self.log_date_time_string(), format % args))
def wanted():
"""Where the service should listen now, as (address, port), or the reason it should not listen."""
configuration = read_json(CONFIG_FILE)
web = section(configuration, 'web')
if web.get('enabled', True) is False:
return None, 'the web service is disabled in the configuration'
port = web.get('port', DEFAULT_PORT)
if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
port = DEFAULT_PORT
general, interfaces, local = (section(configuration, name) for name in ('general', 'interfaces', 'local'))
address = vpnrouter_net.internal_address(str(general.get('mode') or 'manual'), str(interfaces.get('internal') or ''),
str(local.get('int_addr') or ''))
if not address:
return None, 'the internal address is not known yet'
return (address, port), ''
def log(message): def log(message):
print(f'vpn-router-web: {message}', file=sys.stderr, flush=True) print(f'vpn-router-web: {message}', file=sys.stderr, flush=True)
def discover(): def initialize():
"""Work out where to listen every DISCOVERY_SECONDS, and run the server thread there. """Make the signing key and the refresh tokens' database, retrying every INIT_RETRY_SECONDS until both are ready."""
reported = None
When the address or the port changes, the server thread is stopped and a new one listens at
the new place; while the service is disabled, the address is not known or it cannot be bound,
no server runs and the next round tries again.
"""
server, thread, listening, reported = None, None, None, None
while True: while True:
target, reason = wanted() phase = 'reading the configuration'
if target != listening and server is not None: try:
server.shutdown() cfg = vpnrouter.load_config()
server.server_close() phase = 'making the signing key'
thread.join() vpnrouter_auth.ensure_key()
server, listening = None, None phase = 'creating the token database'
if target is not None and server is None: vpnrouter_auth.provision(cfg)
try: phase = 'connecting to the token database'
server = ThreadingHTTPServer(target, Handler) with vpnrouter_auth.database(cfg):
except OSError as exc: pass
reason = f'cannot listen on {target[0]}:{target[1]}: {exc}' except (OSError, SystemExit, vpnrouter_auth.DatabaseError) as exc:
else: error = f'{phase} failed: {exc.code if isinstance(exc, SystemExit) else exc}'
thread = threading.Thread(target=server.serve_forever, name='server', daemon=True) if error != reported:
thread.start() log(f'{error}; retrying every {INIT_RETRY_SECONDS} seconds')
listening, reason = target, f'serving on http://{target[0]}:{target[1]}' reported = error
if reason and reason != reported: time.sleep(INIT_RETRY_SECONDS)
log(reason) continue
reported = reason log('ready')
time.sleep(DISCOVERY_SECONDS) return
def main(): def main():
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
discovery = threading.Thread(target=discover, name='discovery', daemon=True) initialize()
discovery.start()
try: try:
discovery.join() vpnrouter_serve.serve(lambda: vpnrouter_net.web_listen(read_json(CONFIG_FILE), DEFAULT_PORT), Handler, log,
DISCOVERY_SECONDS)
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass
return 0 return 0
+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