Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1e6d9cd0b | ||
|
|
732cc8cb54 | ||
|
|
c0cd323a0c | ||
|
|
2e2bacee7d | ||
|
|
4f1aa282f6 |
No files matched your search
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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