Compare commits

..
9 Commits
Author SHA1 Message Date
slawek 650896131d fix: web.status_content is basic by default.
Release / release (push) Successful in 19s
2026-10-25 13:09:57 +01:00
slawek dbc2046e24 feature: the status page shows the Client IP, and web.status_content sets how much it shows.
Release / release (push) Successful in 12s
2026-10-25 13:08:08 +01:00
slawek 45d73abe7b feature: Caddy serves the status page and the Router Manager over HTTPS with a certificate from Let's Encrypt.
Release / release (push) Successful in 23s
2026-10-25 12:25:57 +01:00
slawek 993a7a9fa9 fix: the web service listens on the internal address, the external address or both.
Release / release (push) Successful in 25s
2026-10-25 11:37:43 +01:00
slawek 581b53d447 fix: the web service manages users also when it started before setup.
Release / release (push) Successful in 19s
2026-10-24 21:32:00 +02:00
slawek 8782edff0f fix: the status page shows whether the Router Manager runs.
Release / release (push) Successful in 19s
2026-10-24 12:50:49 +02:00
slawek fe17e83b49 feature: every user manages their own account through /account.
Release / release (push) Successful in 11s
2026-10-24 12:44:49 +02:00
slawek 17331eea37 feature: the status page shows the Router Manager's version.
Release / release (push) Successful in 12s
2026-10-24 12:16:02 +02:00
slawek af3127d1e8 feature: Manager-only accounts, and the Router Manager's first administrator.
Release / release (push) Successful in 20s
2026-10-24 11:56:58 +02:00
20 changed files with 934 additions and 185 deletions

No files matched your search

+153 -31
View File
@@ -27,28 +27,40 @@ port is reachable from the external interface only when it is in `exposed_ports`
in `DOCKER-USER` are not touched, because the package owns only its own chain. Restarting Docker in `DOCKER-USER` are not touched, because the package owns only its own chain. Restarting Docker
restores the rules. The rules cover IPv4 only. restores the rules. The rules cover IPv4 only.
## ADR-2: A small status web service, bound to the internal address ## ADR-2: A small status web service, on the interfaces web.listen chooses
Status: Accepted Status: Accepted
Context: The router has no way to see its state without a shell. A check from the protected Context: The router has no way to see its state without a shell. A check from the protected
network, by a person or by a monitor, needs a page or an endpoint. The router is an network, by a person or by a monitor, needs a page or an endpoint, and so does the platform that
internet-facing host, so what the service shows and where it listens matter. deploys the router, which reaches it on its public address. The router is an internet-facing
host, so what the service shows and where it listens matter.
Decision: The `vpn-router-web` unit runs one Python script on the standard library's Decision: The `vpn-router-web` unit runs one Python script on the standard library's
`http.server`, with the package's own modules and the Python packages of the Ubuntu archive that `http.server`, with the package's own modules and the Python packages of the Ubuntu archive that
the package depends on, and no web framework. It listens on the address of the internal interface and on the package depends on, and no web framework. It listens on `web.port`, 7081 by default, on the
`web.port`, 7081 by default, and is on unless `web.enabled` is `false` (ADR-7). `/` is for address of the internal interface, of the external interface or of both, as `web.listen` sets
people, in plain text for `curl` and `wget` and as a Tailwind CSS page for a browser, and (`internal`, `external` or `both`, the default), with a server for each address, and is on
`/status` is the same status as JSON. It shows the state of the services, the package version unless `web.enabled` is `false` (ADR-7). `/` is for people, in plain text for `curl` and `wget`
and the state of the site-to-site, road-warrior, OpenVPN and WireGuard features, and nothing from and as a Tailwind CSS page for a browser, and `/status` is the same status as JSON. Both show as
the configuration. `vpn-router-setup` opens the port on the internal interface in UFW and writes much as `web.status_content` says, since the router is reachable from the internet and the
the features and the facts it resolves to `/run/vpn-router/web.json`. operator decides how much it tells strangers: `basic`, the default because it tells the least,
the overall status and the Client IP, `standard` also the package versions and the number of
active and other services, and `full` also each service and the state of the site-to-site, road-warrior, OpenVPN and
WireGuard features. The Client IP is the address the request came from, through the trusted
proxies, so a person can see the address the router sees. Nothing comes from the configuration.
`/status` names its content, so clients such as manage-ui and the Router Manager show what it
has. `vpn-router-setup` opens the
port on both interfaces in UFW, so the choice of `web.listen` is made by the service alone, and
writes the features and the facts it resolves to `/run/vpn-router/web.json`.
Consequences: Nothing outside the internal network can reach it, since it listens on the internal Consequences: By default the service is reachable from the internet on the external address;
address only and the firewall opens the port on the internal interface only. `/` and `/status` `web.listen` set to `internal` keeps it to the internal network. It serves plain HTTP itself;
have no authentication, which is acceptable because they are read only and show no HTTPS is Caddy's (ADR-21), so on the plain HTTP port the passwords of `/auth` and the tokens
configuration. It runs as root, because `swanctl` and `wg` need it, with `NoNewPrivileges`, cross the internet unencrypted. A change of `web.listen`
needs no run of setup, since the firewall does not depend on it; the package's upgrade runs
setup, which writes the rules. `/` and `/status` have no authentication, which is acceptable because they are
read only and show no configuration. It runs as root, because `swanctl` and `wg` need it, with `NoNewPrivileges`,
`ProtectSystem=strict`, `ProtectHome` and `PrivateTmp`. The Tailwind style comes from a CDN, so a `ProtectSystem=strict`, `ProtectHome` and `PrivateTmp`. The Tailwind style comes from a CDN, so a
browser on a network without internet shows the unstyled page, and `curl` is unaffected. browser on a network without internet shows the unstyled page, and `curl` is unaffected.
@@ -60,7 +72,7 @@ Context: Clients such as the Router Manager, and people with `curl`, need the co
the users and the groups of the router, which `/` and `/status` must not show. Both people and the users and the groups of the router, which `/` and `/status` must not show. Both people and
programs sign in with a user of the directory. A secret shared with the platform that deploys programs sign in with a user of the directory. A secret shared with the platform that deploys
the router would let whoever holds it, and every copy of the deployment's state, make tokens. the router would let whoever holds it, and every copy of the deployment's state, make tokens.
The service listens on plain HTTP on the internal network. The service listens on plain HTTP.
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
@@ -143,8 +155,10 @@ Decision: `vpnrouter_users.py` holds the user operations, the checks and a sessi
lock and the connection of the user manager account. The web service imports it and maps a lock and the connection of the user manager account. The web service imports it and maps a
`Failure` to the HTTP status; `vpn-router-user` imports it and adds the argument parsing and `Failure` to the HTTP status; `vpn-router-user` imports it and adds the argument parsing and
the output, text for people and JSON with `--json`. The web unit allows writing the paths the the output, text for people and JSON with `--json`. The web unit allows writing the paths the
operations need: `/run/vpn-router` for the lock, `/etc/vpn-router/pki` for the certificates and operations need: `/run/vpn-router` for the lock, its `RuntimeDirectory`, which systemd creates
the CA's state, and `/etc/swanctl/x509crl` for the published CRL. before the service starts, also before setup has run, and keeps when it stops, as setup's state
files are there too; `/etc/vpn-router/pki` for the certificates and the CA's state, and
`/etc/swanctl/x509crl` for the published CRL.
Consequences: The rules exist once. A request starts no process, and a password never leaves Consequences: The rules exist once. A request starts no process, and a password never leaves
the process that received it. The web service imports `python3-ldap` and the package's the process that received it. The web service imports `python3-ldap` and the package's
@@ -156,22 +170,23 @@ and the holders of an admin token manage users.
Status: Accepted Status: Accepted
Context: The status page is the tool to see a problem of `vpn-router-setup`, so it must run Context: The status page is the tool to see a problem of `vpn-router-setup`, so it must run
whatever state setup is in, also before setup has run and while it fails. The internal address, whatever state setup is in, also before setup has run and while it fails. The addresses,
`web.port` and `web.enabled` can change at any time. `web.listen`, `web.port` and `web.enabled` can change at any time.
Decision: The service depends on nothing to start and does not end on its own. A discovery Decision: The service depends on nothing to start and does not end on its own. A discovery
thread works out, every 30 seconds, the internal address from `vpn-router.json` as thread works out, every 30 seconds, the addresses of the interfaces `web.listen` names from
`general.mode` directs, with the discovery code in `vpnrouter_net.py`, which `vpnrouter.py` uses `vpn-router.json` as `general.mode` directs, with the discovery code in `vpnrouter_net.py`,
too, and `web.port` and `web.enabled` from the same file. It runs the HTTP server in a thread of which `vpnrouter.py` uses too, and `web.port` and `web.enabled` from the same file. It runs an
its own at that address and port, stops it and starts a new one when they change, and runs none HTTP server in a thread of its own at each address and the port, stops one and starts a new one
while the service is disabled, the address is not known or it cannot be bound. Each request when they change, and runs none while the service is disabled or no address is known; an address
that cannot be bound is tried again the next round. Each request
reads the signing key and `vpn-router.json`, and the features and resolved facts from reads the signing key and `vpn-router.json`, and the features and resolved facts from
`web.json`, which setup writes when it applies the configuration, so the service shows what setup `web.json`, which setup writes when it applies the configuration, so the service shows what setup
applied; while that file is missing the router shows no features. The unit is not applied; while that file is missing the router shows no features. The unit is not
ordered after `vpn-router-setup`, and setup does not restart it. ordered after `vpn-router-setup`, and setup does not restart it.
Consequences: The status page answers as soon as the internal address exists, whatever state Consequences: The status page answers as soon as an address exists, whatever state setup is in,
setup is in, and follows a changed address, port or `web.enabled` within 30 seconds without a and follows a changed address, `web.listen`, port or `web.enabled` within 30 seconds without a
restart. A new signing key applies to the next request. The interfaces are read every 30 restart. A new signing key applies to the next request. The interfaces are read every 30
seconds, which costs a few `ip` commands. seconds, which costs a few `ip` commands.
@@ -207,9 +222,9 @@ router's `/auth`, keeps their tokens in a server-side session in PostgreSQL behi
session's access token, refreshing it as needed; it holds no logic of the router's own and no session's access token, refreshing it as needed; it holds no logic of the router's own and no
key of it. It reads its settings from `/etc/vpn-router-manager/manager.json` at each request; on key of it. It reads its settings from `/etc/vpn-router-manager/manager.json` at each request; on
the router the settings not given come from `vpn-router.json`, and it listens on the internal the router the settings not given come from `vpn-router.json`, and it listens on the internal
address like the status web service, with the same discovery thread. `vpn-router-setup` opens address, the external address or both, as its own `listen` sets, like the status web service,
the manager's port on the internal interface while the manager is installed, so the firewall with the same discovery thread. `vpn-router-setup` opens the manager's port on the internal and
stays in one place. the external interface while the manager is installed, so the firewall stays in one place.
Consequences: The router needs no Node.js; the package needs only `python3` and packages of the Consequences: The router needs no Node.js; the package needs only `python3` and packages of the
Ubuntu archive. Tokens never reach the browser, and sessions survive a restart of the manager. Ubuntu archive. Tokens never reach the browser, and sessions survive a restart of the manager.
@@ -318,8 +333,8 @@ within 15 minutes; a successful bind clears the count; `vpn-router-user unlock`
`/auth` answers the same 401 for a wrong name, password or code and for a locked or disabled `/auth` answers the same 401 for a wrong name, password or code and for a locked or disabled
user. `/auth` and `/auth/refresh` allow 10 calls a minute per client address, counted in memory, user. `/auth` and `/auth/refresh` allow 10 calls a minute per client address, counted in memory,
and answer 429 with `Retry-After` beyond that. The client address is the peer, or the last and answer 429 with `Retry-After` beyond that. The client address is the peer, or the last
`X-Forwarded-For` entry for a peer in `web.trusted_proxies`, by default the address the service `X-Forwarded-For` entry for a peer in `web.trusted_proxies`, by default the address the call came
listens on, where a manager on the router calls from. in on, which is the address a manager on the router calls from.
Consequences: One lockout covers OpenVPN and `/auth`, kept by slapd alone. Someone who knows a Consequences: One lockout covers OpenVPN and `/auth`, kept by slapd alone. Someone who knows a
user name can lock the user out for 15 minutes at a time. The rate limit restarts with the user name can lock the user out for 15 minutes at a time. The rate limit restarts with the
@@ -401,3 +416,110 @@ a `SystemExit` carrying its message, so setup records it.
Consequences: The diagnostics need nothing the setup provides and cannot collide with the real 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. 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. The page shows log lines to whoever reaches the internal network during the first setup.
## ADR-18: Manager-only accounts are a second kind of user; the package can create the first admin
Status: Accepted
Context: The Router Manager needs a first administrator before anyone has added users, as other
packages create a default user from their debconf answers. Such an account, and others that only
manage the router, have no person behind them and need no VPN access. A VPN user is an
`inetOrgPerson`, which requires a last name and a common name, and its certificate needs the
names. The Manager is configured before the router's directory exists during an installation.
Decision: A user is one of two kinds in `ou=users`, fixed when it is added: a VPN user, an
`inetOrgPerson` with the three person attributes and a certificate, or a Manager-only account,
an `account`, `simpleSecurityObject` and `vpnRouterAccount` (ADR-19) with the name, the password,
an optional e-mail address for notifications, and no certificate. `add` with all three person
attributes adds a VPN user and without the first and the last name a Manager-only account; one
name without the rest is refused. The CLI and the API add both kinds; the Manager's dialog
requires the names unless Manager access only is chosen, which offers the e-mail address only.
Answers carry `manager_only`, and `update` changes only the e-mail address of a Manager-only
account. The Manager's package asks `admin_username` and `admin_password`; with both
it writes them to a root-only file, clears the answers, and `vpn-router-setup` creates the account
after it provisions the directory, adds it to `admins` (creating the group when missing) and
removes the file. Without a name nothing is created. `vpnrouter_users` knows only the directory,
so setup can use it before PostgreSQL's driver can be loaded; `web` and `vpn-router-user` revoke
refresh tokens after the operations of `vpnrouter_auth.REVOKING`.
Consequences: A Manager-only account cannot become a VPN user, as LDAP cannot change an entry's
structural class and its password hash cannot be copied; it is removed and added again instead.
It has no certificate, so OpenVPN and road-warrior access refuse it. The administrator's password
is on disk only until setup has run.
## ADR-19: The package has an LDAP schema of its own under a UUID OID
Status: Accepted
Context: A Manager-only account (ADR-18) is an `account`, which allows no `mail`, yet notifications
need an address for it; no standard auxiliary class allows `mail` except `extensibleObject`, which
allows every attribute. Authorization scopes for users of both kinds may follow. An attribute or
class of its own needs an OID, and the package has no registered arc.
Decision: The package defines its schema, `cn=vpn-router,cn=schema,cn=config`, which
`vpnrouter_ldap.provision` adds when missing and keeps as `SCHEMA_ATTRIBUTE_TYPES` and
`SCHEMA_OBJECT_CLASSES`. Its OID is `2.25.98477352595735834532404256531620013001`, made from a
random UUID under the arc 2.25 (ITU-T X.667), which needs no registration; attribute types are
under `.1` and object classes under `.2`. The first version has the attribute `vpnRouterScope`
(multi-valued, case-insensitive strings) and the auxiliary class `vpnRouterAccount`,
`MAY ( mail $ vpnRouterScope )`. Manager-only accounts have the class from their creation, so an
e-mail address can be added later without changing their classes; VPN users get it once scopes are
used.
Consequences: The schema check stays strict. New attributes extend `vpnRouterAccount`, and their
OIDs continue the arcs. Nothing reads `vpnRouterScope` yet. The OID must never be reused for
anything else.
## ADR-20: Users manage their own account through /account, proving who they are again
Status: Accepted
Context: Users who are not members of `admins` can sign in but get 403 on every path, so they
cannot change their password or enrol an authenticator without an administrator. A session
token alone is a weak proof for such changes: whoever holds an open session could lock the user
out. A wrong current password must not look like an ended session to the Manager, which signs a
user out on 401.
Decision: `/account` serves the user named in the token's `sub`, admin or not: `GET` the user
and whether they are a member of `admins`, `PUT /account/password`, and `POST` and
`PUT /account/otp` to enrol or replace the TOTP enrolment. The routes do not take a user name.
Each change sends the current password and, for an enrolled user, a current code, checked with a
bind as the user like `/auth`, so it counts toward the lockout (ADR-14); a wrong one is 403. A
new password is checked before the bind, so an invalid one does not use up a code. A user cannot
remove their own enrolment; `/users` and `vpn-router-user` do. Tokens of `vpn-router-token` name
no user and get 403 there.
Consequences: slapd's `otp` overlay accepts each code once, so a change right after signing in
needs the next code. Changing the password revokes the user's refresh tokens, so the user's
Manager session ends with its access token. An enrolled user who has lost the authenticator
needs an administrator.
## ADR-21: Caddy from the Ubuntu archive serves HTTPS with a certificate from Let's Encrypt
Status: Accepted
Context: The status web service and the Router Manager are reachable from the internet (ADR-2),
and `/auth` and the Manager's sign-in carry passwords. Both are small servers on the standard
library's `http.server`, which would need certificates, their renewal and a TLS setup of their
own. Browsers trust a public CA, not the router's.
Decision: Caddy, the `caddy` package of the Ubuntu archive, is the HTTPS proxy. While
`https.enabled` is `true` (the default) and `wan.local_fqdn` is set, `vpn-router-setup` writes
`/etc/caddy/vpn-router.Caddyfile`: a site for `wan.local_fqdn` on `https.web_port` (7444) for the
web service and, while the Router Manager is installed, one on `https.manager_port` (7443) for
the manager, each passing calls to the service on `127.0.0.1`. Caddy obtains and renews the
certificate from Let's Encrypt with the HTTP challenge on port 80, which the firewall opens on
the external interface; `auto_https disable_redirects` keeps port 80 to the challenge. The
package's drop-in for `caddy.service` points it at that file and keeps it from starting before
the file exists, so `/etc/caddy/Caddyfile`, a configuration file of the `caddy` package, is left
unchanged. Both services always listen on `127.0.0.1` as well as on the interfaces their
`listen` settings name, and trust `X-Forwarded-For` from it, which Caddy sets from the peer and
never takes from the client.
Consequences: HTTPS needs `wan.local_fqdn` in public DNS pointing at the router and port 80
reachable from the internet; until then Caddy retries and the HTTPS ports do not answer. The
certificate and the ACME account are in `/var/lib/caddy`. Plain HTTP stays as the `listen`
settings set it, so `internal` keeps passwords off the internet. Caddy's admin endpoint listens
on `localhost:2019`, which `caddy reload` uses. Port 80 and the HTTPS ports are not available
to Docker containers while HTTPS is on. The Manager's HTTPS port is a setting of the router, not
of the manager.
+131 -35
View File
@@ -123,10 +123,16 @@ must have the type shown below; an unknown section or setting is an error.
| `openvpn` | `protocol` | string | `udp` | `udp` or `tcp` | | `openvpn` | `protocol` | string | `udp` | `udp` or `tcp` |
| `docker` | `mode` | string | `auto` | `auto` secures Docker when its service is installed, `on` requires it, `off` removes the rules | | `docker` | `mode` | string | `auto` | `auto` secures Docker when its service is installed, `on` requires it, `off` removes the rules |
| `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 |
| `web` | `listen` | string | `both` | The interfaces the status web service listens on: `internal`, `external` or `both` |
| `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 the web service 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 address the call came in on | Addresses whose `X-Forwarded-For` header names the client for the `/auth` rate limit and the Client IP of the status |
| `web` | `status_content` | string | `basic` | What `/` and `/status` show: `basic`, `standard` or `full` (see Status web service) |
| `https` | `enabled` | boolean | `true` | Caddy serves the status web service and the Router Manager over HTTPS for `wan.local_fqdn` |
| `https` | `email` | string | | The e-mail address of the Let's Encrypt account |
| `https` | `web_port` | integer | `7444` | HTTPS port of the status web service |
| `https` | `manager_port` | integer | `7443` | HTTPS port of the Router Manager |
A minimal site-to-site configuration in `auto` mode: A minimal site-to-site configuration in `auto` mode:
@@ -324,7 +330,12 @@ is switched on:
- its own database, `dc=vpn-router,dc=local` in `/var/lib/ldap/vpn-router`, next to the one the - its own database, `dc=vpn-router,dc=local` in `/var/lib/ldap/vpn-router`, next to the one the
slapd package creates; root manages it through the local socket, with no administrator slapd package creates; root manages it through the local socket, with no administrator
password; password;
- `ou=users` holds one `inetOrgPerson` per user, named by `uid`; - `ou=users` holds one entry per user, named by `uid`: an `inetOrgPerson` for a VPN user, an
`account` for a Manager-only account (see Managing users);
- the package's own schema, `cn=vpn-router,cn=schema,cn=config`, under the OID
`2.25.98477352595735834532404256531620013001` of the UUID arc: the auxiliary class
`vpnRouterAccount` with `MAY ( mail $ vpnRouterScope )`, which Manager-only accounts have, and
the attribute `vpnRouterScope`, authorization scopes that nothing reads yet;
- `ou=groups` holds one `groupOfNames` per group, named by `cn`, with its members' DNs in - `ou=groups` holds one `groupOfNames` per group, named by `cn`, with its members' DNs in
`member` and an optional `description`; `member` and an optional `description`;
- `cn=user-manager,ou=services` is the account that changes users and groups; it may write - `cn=user-manager,ou=services` is the account that changes users and groups; it may write
@@ -346,9 +357,17 @@ The directory is kept on purge.
### Managing users ### Managing users
`vpn-router-user` manages the users. A user is the directory entry, with a first name, a last `vpn-router-user` manages the users. A user is a directory entry of one of two kinds, fixed when it
name and an e-mail address (`givenName`, `sn` and `mail`; `cn` is the first and the last name), is added:
and a client certificate with the user name as its common name, in `/etc/vpn-router/pki/users`.
- a VPN user, an `inetOrgPerson` with a first name, a last name and an e-mail address
(`givenName`, `sn` and `mail`; `cn` is the first and the last name), and a client certificate
with the user name as its common name, in `/etc/vpn-router/pki/users`;
- a Manager-only account, an `account`, `simpleSecurityObject` and `vpnRouterAccount` with the
name, the password and an optional e-mail address, for notifications: no names and no
certificate, so no VPN access. It can sign in to `/auth` and, as a member of `admins`, manage
the router. `add` without the first and the last name adds one; its names cannot be set later.
Passwords and TOTP secrets are read from the first line of standard input. The answer is text: a Passwords and TOTP secrets are read from the first line of standard input. The answer is text: a
table of the users, the client profile, a TOTP secret or a short message, and a failure is a table of the users, the client profile, a TOTP secret or a short message, and a failure is a
message on standard error. message on standard error.
@@ -358,6 +377,7 @@ after a command its options.
```sh ```sh
echo 's3cr3t-pass' | vpn-router-user add alice --first-name Alice --last-name Smith --email alice@example.com echo 's3cr3t-pass' | vpn-router-user add alice --first-name Alice --last-name Smith --email alice@example.com
echo 'm4nager-pass' | vpn-router-user add ops
vpn-router-user update alice --email a.smith@example.com vpn-router-user update alice --email a.smith@example.com
vpn-router-user list vpn-router-user list
vpn-router-user profile alice > alice.ovpn vpn-router-user profile alice > alice.ovpn
@@ -374,8 +394,9 @@ vpn-router-user otp-remove alice
| Command | Effect | | Command | Effect |
| --- | --- | | --- | --- |
| `list` | The users with their names and e-mail addresses, whether each is enabled or locked, has a certificate and is enrolled for TOTP | | `list` | The users with their kind, names and e-mail addresses, whether each is enabled or locked, has a certificate and is enrolled for TOTP |
| `add <name> --first-name F --last-name L --email E` | Adds the user and issues the certificate; all three are required | | `add <name> --first-name F --last-name L --email E` | Adds a VPN user and issues the certificate |
| `add <name> [--email E]` | Adds a Manager-only account, with the e-mail address when given; one name without the other two options is refused |
| `update <name> [--first-name F] [--last-name L] [--email E]` | Changes the names or the e-mail address | | `update <name> [--first-name F] [--last-name L] [--email E]` | Changes the names or the e-mail address |
| `passwd <name>` | Sets the password | | `passwd <name>` | Sets the password |
| `disable <name>`, `enable <name>` | Locks the user out of OpenVPN and `/auth`, and lets the user in again | | `disable <name>`, `enable <name>` | Locks the user out of OpenVPN and `/auth`, and lets the user in again |
@@ -390,9 +411,17 @@ A user name is 1 to 32 lowercase letters, digits and hyphens, starting with a le
password has 8 to 128 characters; a first or last name has 1 to 64 printable characters; an password has 8 to 128 characters; a first or last name has 1 to 64 printable characters; an
e-mail address has one `@`, no spaces and a dot in its domain; a TOTP secret is 32 base32 e-mail address has one `@`, no spaces and a dot in its domain; a TOTP secret is 32 base32
characters, as `otp-new` makes it, and a code 6 digits. Once enrolled, a user logs in to OpenVPN characters, as `otp-new` makes it, and a code 6 digits. Once enrolled, a user logs in to OpenVPN
and `/auth` with the password followed by a current code. In the answers a user added and `/auth` with the password followed by a current code. In the answers `manager_only` tells the
without the names or the e-mail address has them as `null`. The exit status is 0 on success, 2 for an invalid request, kind, and a Manager-only account has its names as `null`; `update` changes only its e-mail
3 when the user does not exist, 4 when the user exists and 1 for any other failure. address, and `--email ''` removes it. Changing a user's password, disabling, unlocking or removing the user also revokes its
refresh tokens of `/auth`. The exit status is 0 on success, 2 for an invalid request, 3 when the
user does not exist, 4 when the user exists and 1 for any other failure.
When the Router Manager is installed with an administrator (its debconf questions
`vpn-router-manager/admin_username` and `admin_password`), its package leaves the answers in
`/etc/vpn-router-manager/admin.json` (root only). `vpn-router-setup` then creates that user as a
Manager-only account, adds it to the group `admins`, which it creates when missing, and removes
the file.
The certificate is also accepted by road-warrior (IKEv2 EAP-TLS) access, which checks The certificate is also accepted by road-warrior (IKEv2 EAP-TLS) access, which checks
certificates only, so `disable` does not affect it; `remove` revokes the certificate for both. certificates only, so `disable` does not affect it; `remove` revokes the certificate for both.
@@ -469,9 +498,22 @@ modules shipped with the package; the configuration file accepts any module inst
## Status web service ## Status web service
The `vpn-router-web` service shows the state of the router on its internal address and The `vpn-router-web` service shows the state of the router on the addresses of the interfaces
`web.port` (7081 by default). Its pages `/` and `/status` need no authentication and show no `web.listen` names, the internal, the external or both (the default), and on `127.0.0.1`, where
configuration: no key, identity, address or subnet. Caddy calls it (see HTTPS), with `web.port` (7081 by default). Its pages `/` and `/status` need no authentication and show no
configuration: no key, identity, subnet, or address but the client's. They show as much as
`web.status_content` says, read at each request:
| `status_content` | `/` and `/status` show |
|---|---|
| `basic` (default) | The overall status (`ok` or `degraded`), the time and the Client IP |
| `standard` | Also the `vpn-router` version, the `vpn-router-manager` version or that it is not installed, and the number of active services and of services in any other state, without their names |
| `full` | Also each service and its state, Docker, and the state of site-to-site, road-warrior, OpenVPN and WireGuard |
The Client IP is the address the request came from: the last entry of `X-Forwarded-For` for a
request from one of `web.trusted_proxies`, by default the address the request came in on, so
behind Caddy or a Router Manager on the router it is the browser's. `/status` names the content in
`content` and the Client IP in `client_ip`; `/` keeps the time and the link to `/status`.
The service first makes its signing key and the refresh tokens' database, retrying every 15 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. seconds, for example while PostgreSQL is not running yet, and logs each failure; then it listens.
@@ -502,11 +544,15 @@ curl http://10.0.0.4:17081/
| `/config` | The configuration without its secret options, and what the router resolved and runs, as JSON; needs a token | | `/config` | The configuration without its secret options, and what the router resolved and runs, as JSON; needs a token |
| `/users` | The users of OpenVPN and road-warrior access; needs a token | | `/users` | The users of OpenVPN and road-warrior access; needs a token |
| `/groups` | The groups of users; needs a token | | `/groups` | The groups of users; needs a token |
| `/account` | The account of the signed-in user: its details, its password and its TOTP enrolment; needs a user's token |
The status holds the state of `vpn-router-setup` and `slapd`, of `strongswan` when site-to-site or The status holds the state of `vpn-router-setup`, `slapd` and each PostgreSQL cluster unit
(`postgresql@<version>-<cluster>`, or `postgresql` as `no cluster` without one), of
`vpn-router-manager` when that package is installed, of `strongswan` when site-to-site or
road-warrior access is configured, of `wg-quick@wg0` when WireGuard is, and of road-warrior access is configured, of `wg-quick@wg0` when WireGuard is, and of
`openvpn-server@vpn-router` when OpenVPN is; the number of connected `openvpn-server@vpn-router` when OpenVPN is; the number of connected
OpenVPN clients, from the server's status file, which OpenVPN rewrites every minute; the package version; whether OpenVPN clients, from the server's status file, which OpenVPN rewrites every minute; the package version and,
as `manager_version`, the installed version of `vpn-router-manager` (`null`, shown as not installed, without it); whether
Docker is installed, and when it is, the state of `docker.service` and of `vpn-router-docker` Docker is installed, and when it is, the state of `docker.service` and of `vpn-router-docker`
(the Docker rules), which do not change `status`; whether the (the Docker rules), which do not change `status`; whether the
site-to-site tunnel is established; the number of connected road-warrior (IPsec P2S) clients; and site-to-site tunnel is established; the number of connected road-warrior (IPsec P2S) clients; and
@@ -520,16 +566,18 @@ 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`, `/groups` and `/account` need a JWT in an `Authorization: Bearer` header, signed with
HS256 by the key the web service makes on its first run, `/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:
- an access token of `/auth` names a user in `sub` and is valid for 5 minutes; the user must - an access token of `/auth` names a user in `sub` and is valid for 5 minutes; every user may
be a member of the group `admins`, otherwise the request gets 403; call `/account`, and for the other paths the user must be a member of the group `admins`,
otherwise the request gets 403;
- a token of `vpn-router-token`, run as root on the router, has the claim `admin` and is valid - a token of `vpn-router-token`, run as root on the router, has the claim `admin` and is valid
for the lifetime given, without a maximum. It may call everything, and only such a token may for the lifetime given, without a maximum. It names no user, so `/account` refuses it with 403;
it may call everything else, and only such a token may
change the group `admins`: create it, rename a group to it, change or remove it, add or take change the group `admins`: create it, rename a group to it, change or remove it, add or take
out its members, or remove a user who is a member of it. out its members, or remove a user who is a member of it.
@@ -556,7 +604,7 @@ removed. Changing a user's password, disabling, unlocking or removing the user r
user's refresh tokens. Five failed logins lock the account for 15 minutes (see the user user's refresh tokens. Five failed logins lock the account for 15 minutes (see the user
directory). `/auth` and `/auth/refresh` take at most 10 calls a minute from one client address, directory). `/auth` and `/auth/refresh` take at most 10 calls a minute from one client address,
and answer 429 with `Retry-After` beyond that; for a call from one of `web.trusted_proxies`, by and answer 429 with `Retry-After` beyond that; for a call from one of `web.trusted_proxies`, by
default the address the service listens on, the client address is the last entry of default the address the call came in on, the client address is the last entry of
`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
@@ -590,9 +638,9 @@ and answer bodies are JSON; a request body is at most 4096 bytes.
| Request | Body | Answer | | Request | Body | Answer |
| --- | --- | --- | | --- | --- | --- |
| `GET /users` | | `{"users": [{"name", "enabled", "locked", "certificate", "otp", "first_name", "last_name", "email"}, ...]}` | | `GET /users` | | `{"users": [{"name", "manager_only", "enabled", "locked", "certificate", "otp", "first_name", "last_name", "email"}, ...]}` |
| `POST /users` | `{"name", "password", "first_name", "last_name", "email"}`, all required | 201 and the user | | `POST /users` | `{"name", "password", "first_name", "last_name", "email"}` for a VPN user; `{"name", "password", "email"?}` for a Manager-only account | 201 and the user |
| `PUT /users/<name>` | any of `{"first_name", "last_name", "email"}` | the user | | `PUT /users/<name>` | any of `{"first_name", "last_name", "email"}`; for a Manager-only account only `{"email"}`, `""` removing it | the user |
| `PUT /users/<name>/password` | `{"password": ...}` | the user | | `PUT /users/<name>/password` | `{"password": ...}` | the user |
| `POST /users/<name>/disable` | | the user | | `POST /users/<name>/disable` | | the user |
| `POST /users/<name>/enable` | | the user | | `POST /users/<name>/enable` | | the user |
@@ -614,6 +662,24 @@ curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
http://10.0.0.4:7081/users http://10.0.0.4:7081/users
``` ```
### The /account API
`/account` serves the user the access token names, whether or not the user is a member of
`admins`, with the same module as `/users`:
| Request | Body | Answer |
| --- | --- | --- |
| `GET /account` | | the user as `/users` shows it, and `"admin"`, whether the user is a member of `admins` |
| `PUT /account/password` | `{"current_password", "current_code", "password"}` | the user |
| `POST /account/otp` | | `{"name", "secret", "uri"}`, a new TOTP secret; nothing is stored |
| `PUT /account/otp` | `{"current_password", "current_code", "secret", "code"}` | the user, once the code matches the secret |
`current_code` is needed only for a user enrolled for TOTP. A wrong current password or code gets
403 and counts as a failed login; slapd accepts each code once, so a code used to sign in is not
accepted again, and a change right after signing in waits for the next code. A user enrols or
replaces the enrolment; removing it is left to `/users` and `vpn-router-user`. Changing the
password revokes the user's refresh tokens. Other failures are those of `/users`.
### The /groups API ### The /groups API
`/groups` manages groups with the same token and the same module as `/users`. `/groups` manages groups with the same token and the same module as `/users`.
@@ -633,13 +699,41 @@ otherwise. `DELETE /users/<name>` is refused with 400 while the user is the last
group. group.
The service starts on its own, without waiting for `vpn-router-setup`, and keeps running. Every The service starts on its own, without waiting for `vpn-router-setup`, and keeps running. Every
30 seconds it works out the internal address from `vpn-router.json` as `general.mode` directs, 30 seconds it reads `web.listen`, `web.port` and `web.enabled` from `vpn-router.json` and works
and reads `web.port` and `web.enabled`; it listens there, moves when the address or the port out the addresses of the interfaces `web.listen` names as `general.mode` directs; it listens on
changes, and stops listening while `web.enabled` is `false` or the address is not known. Each each, moves when an address or the port changes, and stops listening while `web.enabled` is
request reads the signing key and `vpn-router.json`, and the features and the facts for `false` or no address is known. A change of `web.listen` takes effect within 30 seconds, or when
`/config` from `/run/vpn-router/web.json` (readable by root only), which `vpn-router-setup` writes; the service restarts. Each request reads the signing key and `vpn-router.json`, and the features
before that file exists the page shows no features. `vpn-router-setup` opens `web.port` on the and the facts for `/config` from `/run/vpn-router/web.json` (readable by root only), which
internal interface in the firewall. `vpn-router-setup` writes; before that file exists the page shows no features.
`vpn-router-setup` opens `web.port` on the internal and the external interface in the firewall.
## HTTPS
Caddy, from Ubuntu's `caddy` package, serves the status web service on `https.web_port` (7444 by
default) and, while the Router Manager is installed, the manager on `https.manager_port` (7443),
both for `wan.local_fqdn`, while `https.enabled` is `true` and `wan.local_fqdn` is set. It obtains
and renews the certificate from Let's Encrypt with the HTTP challenge, so `wan.local_fqdn` must
resolve to the router's public address and port 80 must be reachable from the internet; the
certificate and the ACME account are kept in `/var/lib/caddy`. Port 80 answers the challenge only;
it does not redirect to HTTPS.
`vpn-router-setup` writes Caddy's configuration to `/etc/caddy/vpn-router.Caddyfile` (root, group
`caddy`, the group Caddy runs as, mode 0640), which the
package's drop-in `/usr/lib/systemd/system/caddy.service.d/vpn-router.conf` makes `caddy.service`
read; `caddy.service` does not start before the file exists, and `/etc/caddy/Caddyfile` is not
used. Setup starts Caddy, reloads it when the file changes, and stops it and removes the file when
HTTPS is off. In the firewall it opens port 80 on the external interface and the HTTPS ports on the
internal and the external interface. Caddy calls both services on `127.0.0.1`, where they listen
whatever `web.listen` and the manager's `listen` say, and passes the client's address in
`X-Forwarded-For`, which the manager trusts from `127.0.0.1` and the web service by default, as
the address the call came in on; a `web.trusted_proxies` that is set must list `127.0.0.1`. The plain HTTP ports stay as
`web.listen` and the manager's `listen` set them. `docker.exposed_ports` may not take port 80 or
the HTTPS ports while `https.enabled` is `true`.
```bash
curl https://router.example.com:7444/status
```
## Router Manager ## Router Manager
@@ -647,13 +741,14 @@ The `vpn-router-manager` package, built in the repository `linux-cloud-router-ma
application that shows the state of the router and manages its users through the status web application that shows the state of the router and manages its users through the status web
service. It can be installed on the router or on another Linux system. While it is installed on service. It can be installed on the router or on another Linux system. While it is installed on
the router, `vpn-router-setup` opens its port (`port` in `/etc/vpn-router-manager/manager.json`, the router, `vpn-router-setup` opens its port (`port` in `/etc/vpn-router-manager/manager.json`,
7080 by default, HTTP; 7443 is kept for it with TLS) on the internal interface; restart 7080 by default, HTTP; HTTPS is Caddy's, on `https.manager_port`) on the internal and the external
`vpn-router-setup` after changing it. interface; restart `vpn-router-setup` after changing it. The manager's `listen` chooses the
interfaces it listens on.
## Removal ## Removal
`apt-get remove` stops `vpn-router-docker`, which removes the Docker rules. It terminates the `apt-get remove` stops `vpn-router-docker`, which removes the Docker rules. It terminates the
tunnels and takes `wg-quick@wg0` and `openvpn-server@vpn-router` down, but leaves the tunnels and takes `wg-quick@wg0`, `openvpn-server@vpn-router` and `caddy` down, but leaves the
configuration in place. `strongswan` is left enabled, since it is a shared service that configuration in place. `strongswan` is left enabled, since it is a shared service that
may be in use by something else. may be in use by something else.
@@ -661,7 +756,8 @@ may be in use by something else.
`/etc/ufw/before.rules` to its original content, and hands `vpn-router.json` back to `ucf` `/etc/ufw/before.rules` to its original content, and hands `vpn-router.json` back to `ucf`
before deleting it so a later reinstall starts clean. It also removes the web service's before deleting it so a later reinstall starts clean. It also removes the web service's
signing key, and the refresh tokens' database, its role and their password file when the package signing key, and the refresh tokens' database, its role and their password file when the package
created them. Key material and users are never deleted: `/etc/vpn-router/pki`, `/etc/vpn-router/ldap`, the user directory in slapd, the created them, and Caddy's configuration `/etc/caddy/vpn-router.Caddyfile`; the certificate in
`/var/lib/caddy` belongs to the `caddy` package. Key material and users are never deleted: `/etc/vpn-router/pki`, `/etc/vpn-router/ldap`, the user directory in slapd, the
WireGuard key pair and everything under `/etc/swanctl` are left alone. WireGuard key pair and everything under `/etc/swanctl` are left alone.
## Building ## Building
+123
View File
@@ -1,3 +1,126 @@
vpn-router (0.14.1-1) unstable; urgency=medium
fix: web.status_content is basic by default.
* Without web.status_content, or with a value that is not allowed, /
and /status show only the overall status, the time and the Client
IP; set it to standard or full to show more.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 17:00:00 +0200
vpn-router (0.14.0-1) unstable; urgency=medium
feature: the status page shows the Client IP, and web.status_content sets how much it shows.
* / and /status show the Client IP, the address the request came from:
the last X-Forwarded-For entry for a request from a trusted proxy, so
behind Caddy or the Router Manager the browser's; /status has it as
client_ip.
* web.status_content sets what / and /status show: basic the overall
status, the time and the Client IP; standard also the package
versions and the number of active and failed services; full, the
default, everything as before. /status names it in content.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 16:00:00 +0200
vpn-router (0.13.0-1) unstable; urgency=medium
feature: Caddy serves the status page and the Router Manager over HTTPS with a certificate from Let's Encrypt.
* The package depends on caddy. While https.enabled is true, the
default, and wan.local_fqdn is set, vpn-router-setup writes
/etc/caddy/vpn-router.Caddyfile and runs Caddy, which serves the
status page on https.web_port (7444) and the Router Manager, when it
is installed, on https.manager_port (7443) for wan.local_fqdn, and
obtains the certificate from Let's Encrypt with the HTTP challenge.
* The package's drop-in for caddy.service makes Caddy read that file
and keeps it from starting before the file exists. The file belongs
to root and the group caddy, which Caddy runs as, with mode 0640.
* The firewall opens port 80 on the external interface and the HTTPS
ports on both interfaces while HTTPS is on.
* The web service also listens on 127.0.0.1, where Caddy calls it.
* docker.exposed_ports may not take port 80 or the HTTPS ports while
HTTPS is on.
* Removing the package stops and disables caddy; purging it removes
/etc/caddy/vpn-router.Caddyfile.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 15:00:00 +0200
vpn-router (0.12.3-1) unstable; urgency=medium
fix: the web service listens on the internal address, the external address or both.
* web.listen in vpn-router.json sets the interfaces the web service
listens on: internal, external or both, the default. It is read
every 30 seconds, so a change takes effect without running setup,
at the latest when vpn-router-web restarts.
* The firewall opens the ports of the web service and of the Router
Manager on the internal and the external interface.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 13:00:00 +0200
vpn-router (0.12.2-1) unstable; urgency=medium
fix: the web service manages users also when it started before setup.
* vpn-router-web creates /run/vpn-router when it starts, so the user
operations take their lock there; started before vpn-router-setup had
made the directory, they failed with "Read-only file system".
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 12:00:00 +0200
vpn-router (0.12.1-1) unstable; urgency=medium
fix: the status page shows whether the Router Manager runs.
* When vpn-router-manager is installed, the services show the state of
vpn-router-manager, and status is degraded while it is not active.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 11:00:00 +0200
vpn-router (0.12.0-1) unstable; urgency=medium
feature: every user manages their own account through /account.
* /account serves the signed-in user, admin or not: GET shows the user
and whether they are a member of admins; PUT /account/password sets
the password and POST and PUT /account/otp enrol or replace the TOTP
enrolment, given the current password and, when enrolled, a current
code. A wrong one is 403 and counts as a failed login. Tokens of
vpn-router-token get 403 there.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 10:00:00 +0200
vpn-router (0.11.0-1) unstable; urgency=medium
feature: the status page shows the Router Manager's version.
* / and /status show the installed version of vpn-router-manager, as
manager_version in JSON, or that it is not installed.
* The services show the state of each PostgreSQL cluster, as
postgresql@<version>-<cluster>, or postgresql: no cluster without one.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 09:00:00 +0200
vpn-router (0.10.0-1) unstable; urgency=medium
feature: Manager-only accounts, and the Router Manager's first administrator.
* vpn-router-user add without the person options, and POST /users without
them, add a Manager-only account: a name and a password, no names, no
e-mail address and no certificate, so no VPN access. Giving only some of
the three is refused. The answers show the kind as manager_only, list
shows a KIND column, and update refuses a Manager-only account.
* vpn-router-setup creates the administrator the Router Manager's package
leaves in /etc/vpn-router-manager/admin.json as a Manager-only account in
the group admins, which it creates when missing, and removes the file.
* The directory has the package's own schema, with the auxiliary class
vpnRouterAccount and the attribute vpnRouterScope. A Manager-only account
can have an e-mail address: add --email, update --email ('' removes it),
and email in POST and PUT /users.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 08:00:00 +0200
vpn-router (0.9.2-1) unstable; urgency=medium vpn-router (0.9.2-1) unstable; urgency=medium
fix: the web service starts once libpq can be found. fix: the web service starts once libpq can be found.
+2 -1
View File
@@ -28,7 +28,8 @@ Depends: ${misc:Depends},
openvpn, openvpn,
openvpn-auth-ldap, openvpn-auth-ldap,
iproute2, iproute2,
systemd-resolved systemd-resolved,
caddy
Description: Linux VPN router with IPSec, road-warrior, OpenVPN and WireGuard support Description: Linux VPN router with IPSec, road-warrior, OpenVPN and WireGuard support
Configures a Linux host or virtual machine as a VPN router: site-to-site Configures a Linux host or virtual machine as a VPN router: site-to-site
IKEv2 IPSec (strongSwan swanctl), optional road-warrior access using EAP-TLS, IKEv2 IPSec (strongSwan swanctl), optional road-warrior access using EAP-TLS,
+1
View File
@@ -19,4 +19,5 @@ 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/
src/usr/lib/systemd/system/caddy.service.d/vpn-router.conf usr/lib/systemd/system/caddy.service.d/
src/usr/share/doc/vpn-router/vpn-router.json.example usr/share/doc/vpn-router/ src/usr/share/doc/vpn-router/vpn-router.json.example usr/share/doc/vpn-router/
+3 -2
View File
@@ -11,10 +11,10 @@ strip_ufw_blocks() {
tmp="$(mktemp)" tmp="$(mktemp)"
awk ' awk '
/^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|BOOTSTRAP|MANAGER|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES START$/ { /^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|BOOTSTRAP|MANAGER|HTTPS|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES START$/ {
skip = 1; pending = 0; next skip = 1; pending = 0; next
} }
/^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|BOOTSTRAP|MANAGER|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES END$/ { /^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|BOOTSTRAP|MANAGER|HTTPS|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES END$/ {
skip = 0; next skip = 0; next
} }
skip { next } skip { next }
@@ -49,6 +49,7 @@ case "$1" in
/etc/wireguard/wg0.conf \ /etc/wireguard/wg0.conf \
/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/caddy/vpn-router.Caddyfile \
/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 /var/lib/vpn-router/setup.done
+4 -4
View File
@@ -11,10 +11,10 @@ case "$1" in
swanctl --terminate --ike road-warrior >/dev/null 2>&1 || true swanctl --terminate --ike road-warrior >/dev/null 2>&1 || true
fi fi
# This package enabled wg-quick@wg0 and openvpn-server@vpn-router, so # This package enabled wg-quick@wg0, openvpn-server@vpn-router and
# it takes them down again. Use the debhelper wrappers rather than # caddy, so it takes them down again. Use the debhelper wrappers rather
# systemctl, so the script behaves on a machine without systemd. # than systemctl, so the script behaves on a machine without systemd.
for unit in wg-quick@wg0.service openvpn-server@vpn-router.service; do for unit in wg-quick@wg0.service openvpn-server@vpn-router.service caddy.service; do
if command -v deb-systemd-invoke >/dev/null 2>&1; then if command -v deb-systemd-invoke >/dev/null 2>&1; then
deb-systemd-invoke stop "$unit" >/dev/null 2>&1 || true deb-systemd-invoke stop "$unit" >/dev/null 2>&1 || true
fi fi
@@ -13,7 +13,9 @@ Restart=on-failure
RestartSec=15 RestartSec=15
NoNewPrivileges=yes NoNewPrivileges=yes
ProtectSystem=strict ProtectSystem=strict
ReadWritePaths=-/run/vpn-router -/etc/vpn-router -/etc/swanctl/x509crl RuntimeDirectory=vpn-router
RuntimeDirectoryPreserve=yes
ReadWritePaths=-/etc/vpn-router -/etc/swanctl/x509crl
ProtectHome=yes ProtectHome=yes
PrivateTmp=yes PrivateTmp=yes
@@ -0,0 +1,10 @@
# Caddy is the HTTPS proxy of vpn-router: it reads the configuration vpn-router-setup writes, and
# does not start until that exists.
[Unit]
ConditionPathExists=/etc/caddy/vpn-router.Caddyfile
[Service]
ExecStart=
ExecStart=/usr/bin/caddy run --environ --adapter caddyfile --config /etc/caddy/vpn-router.Caddyfile
ExecReload=
ExecReload=/usr/bin/caddy reload --adapter caddyfile --config /etc/caddy/vpn-router.Caddyfile --force
+95 -7
View File
@@ -16,6 +16,7 @@ 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.
""" """
import grp
import json import json
import os import os
import pathlib import pathlib
@@ -27,8 +28,11 @@ 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_ldap import vpnrouter_ldap
import vpnrouter_users
from vpnrouter import fail from vpnrouter import fail
BEFORE_RULES = pathlib.Path('/etc/ufw/before.rules') BEFORE_RULES = pathlib.Path('/etc/ufw/before.rules')
@@ -49,8 +53,18 @@ 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')
#: The default admin of the Router Manager, {"username", "password"}, which its package writes from
#: its debconf answers; setup creates the account and removes the file.
MANAGER_ADMIN = pathlib.Path('/etc/vpn-router-manager/admin.json')
MANAGER_PORT = 7080 MANAGER_PORT = 7080
#: The configuration of Caddy, the HTTPS proxy, which the package's drop-in for caddy.service names;
#: Caddy runs as the user and group CADDY_GROUP, which the caddy package creates.
CADDYFILE = pathlib.Path('/etc/caddy/vpn-router.Caddyfile')
CADDY_UNIT = 'caddy.service'
CADDY_GROUP = 'caddy'
FILTER_ANCHOR = '# End required lines' FILTER_ANCHOR = '# End required lines'
#: Configuration options left out wherever the configuration is shown. #: Configuration options left out wherever the configuration is shown.
@@ -64,6 +78,7 @@ BLOCKS = (
('# WEB RULES START', '# WEB RULES END'), ('# WEB RULES START', '# WEB RULES END'),
('# BOOTSTRAP RULES START', '# BOOTSTRAP RULES END'), ('# BOOTSTRAP RULES START', '# BOOTSTRAP RULES END'),
('# MANAGER RULES START', '# MANAGER RULES END'), ('# MANAGER RULES START', '# MANAGER RULES END'),
('# HTTPS RULES START', '# HTTPS 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'),
('# ROUTER MSS RULES START', '# ROUTER MSS RULES END'), ('# ROUTER MSS RULES START', '# ROUTER MSS RULES END'),
@@ -208,11 +223,13 @@ def filter_block(ctx):
] ]
lines += ['# P2S DNS RULES END'] lines += ['# P2S DNS RULES END']
if ctx['web_enabled'] and ctx['int_iface']: # The web service and the Router Manager choose their interfaces themselves, so their ports are open on both.
interfaces = [name for name in (ctx['int_iface'], ctx['wan_iface']) if name]
if ctx['web_enabled'] and interfaces:
lines += [ lines += [
'', '',
'# WEB RULES START', '# WEB RULES START',
f'-A ufw-before-input -i {ctx["int_iface"]} -p tcp --dport {ctx["web_port"]} -j ACCEPT', *(f'-A ufw-before-input -i {name} -p tcp --dport {ctx["web_port"]} -j ACCEPT' for name in interfaces),
'# WEB RULES END', '# WEB RULES END',
] ]
@@ -224,15 +241,28 @@ def filter_block(ctx):
'# BOOTSTRAP RULES END', '# BOOTSTRAP RULES END',
] ]
if MANAGER.exists() and ctx['int_iface']: if MANAGER.exists() and interfaces:
port = manager_port() port = manager_port()
lines += [ lines += [
'', '',
'# MANAGER RULES START', '# MANAGER RULES START',
f'-A ufw-before-input -i {ctx["int_iface"]} -p tcp --dport {port} -j ACCEPT', *(f'-A ufw-before-input -i {name} -p tcp --dport {port} -j ACCEPT' for name in interfaces),
'# MANAGER RULES END', '# MANAGER RULES END',
] ]
# Let's Encrypt reaches the HTTP challenge on the external interface; the HTTPS ports are open on both.
if vpnrouter.https_ready(ctx) and interfaces:
ports = [port for wanted, port in ((ctx['web_enabled'], ctx['https_web_port']),
(MANAGER.exists(), ctx['https_manager_port'])) if wanted]
lines += [
'',
'# HTTPS RULES START',
*([f'-A ufw-before-input -i {ctx["wan_iface"]} -p tcp --dport {vpnrouter.ACME_HTTP_PORT} -j ACCEPT']
if ctx['wan_iface'] else []),
*(f'-A ufw-before-input -i {name} -p tcp --dport {port} -j ACCEPT' for name in interfaces for port in ports),
'# HTTPS RULES END',
]
if vpnrouter.routing_ready(ctx): if vpnrouter.routing_ready(ctx):
lines += ['', '# ROUTER FORWARD RULES START'] lines += ['', '# ROUTER FORWARD RULES START']
for subnet in ctx['local_cidr_list']: for subnet in ctx['local_cidr_list']:
@@ -384,6 +414,31 @@ def manage_directory():
return '' return ''
def manage_admin():
"""Create the Router Manager's default admin from MANAGER_ADMIN, then remove the file; the problem, or ''.
The admin is a Manager-only account and a member of the admins group, which is created with it
when it does not exist.
"""
if not MANAGER_ADMIN.exists():
return ''
try:
answers = json.loads(MANAGER_ADMIN.read_text(encoding='utf-8'))
name, password = answers['username'], answers['password']
with vpnrouter_users.session() as connection:
vpnrouter_users.add(connection, name, password, {})
try:
vpnrouter_users.group_member_add(connection, vpnrouter_users.ADMIN_GROUP, name)
except vpnrouter_users.Failure as failure:
if failure.status != 3:
raise
vpnrouter_users.group_add(connection, vpnrouter_users.ADMIN_GROUP, 'Router administrators', [name])
MANAGER_ADMIN.unlink()
except (OSError, ValueError, KeyError, TypeError, ldap.LDAPError, vpnrouter_users.Failure) as exc:
return f'the Router Manager admin cannot be created from {MANAGER_ADMIN}: {exc}'
return ''
def manage_openvpn(ctx, changed): def manage_openvpn(ctx, changed):
"""Run the OpenVPN server when it is configured, and stop it when it is not.""" """Run the OpenVPN server when it is configured, and stop it when it is not."""
unit = 'openvpn-server@vpn-router' unit = 'openvpn-server@vpn-router'
@@ -399,6 +454,32 @@ def manage_openvpn(ctx, changed):
run(['systemctl', 'restart', unit], check=False) run(['systemctl', 'restart', unit], check=False)
def manage_caddy(ctx):
"""Run Caddy, the HTTPS proxy of the web service and the Router Manager, while HTTPS is ready, and stop it when not.
CADDYFILE serves the web service on https.web_port and, while the manager is installed, the
manager on https.manager_port, both for wan.local_fqdn with a certificate from Let's Encrypt,
and both calling the service on its loopback address.
"""
if not vpnrouter.https_ready(ctx):
vpnrouter.remove(CADDYFILE)
if unit_is_active(CADDY_UNIT):
run(['systemctl', 'disable', '--now', CADDY_UNIT], check=False)
return
try:
group = grp.getgrnam(CADDY_GROUP).gr_gid
except KeyError:
fail(f'the group {CADDY_GROUP} does not exist; is the caddy package installed?')
caddy_ctx = dict(ctx, manager_port=manager_port() if MANAGER.exists() else 0)
changed = vpnrouter.render(vpnrouter._jinja_env(), caddy_ctx, 'Caddyfile.j2', CADDYFILE, 0o640)
os.chown(CADDYFILE, 0, group)
if not unit_is_active(CADDY_UNIT):
run(['systemctl', 'enable', '--now', CADDY_UNIT], check=False)
elif changed:
run(['systemctl', 'reload', CADDY_UNIT], check=False)
def run_platform_setup(cfg, ctx): def run_platform_setup(cfg, ctx):
"""Let the platform module apply its own state, if it defines any.""" """Let the platform module apply its own state, if it defines any."""
module = vpnrouter.platform_module(cfg) module = vpnrouter.platform_module(cfg)
@@ -532,11 +613,18 @@ def main():
progress('running', 'WireGuard') 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. # The tunnels are up before the directory and the admin, whose problem ends setup only after every other step.
progress('running', 'user directory') failed_step = 'user directory'
progress('running', failed_step)
problem = manage_directory() problem = manage_directory()
if not problem:
failed_step = 'Router Manager admin'
progress('running', failed_step)
problem = manage_admin()
progress('running', 'OpenVPN') progress('running', 'OpenVPN')
manage_openvpn(ctx, changed) manage_openvpn(ctx, changed)
progress('running', 'HTTPS proxy')
manage_caddy(ctx)
if 'resolved' in changed: if 'resolved' in changed:
progress('running', 'resolver') progress('running', 'resolver')
@@ -549,7 +637,7 @@ def main():
progress('running', 'web service state') progress('running', 'web service state')
refresh_web(cfg, ctx) refresh_web(cfg, ctx)
if problem: if problem:
progress('failed', 'user directory', problem) progress('failed', failed_step, problem)
print(f'vpn-router: error: {problem}', file=sys.stderr) print(f'vpn-router: error: {problem}', file=sys.stderr)
return 1 return 1
progress('done', 'done') progress('done', 'done')
@@ -21,7 +21,7 @@ if str(LIB_DIR) not in sys.path:
import vpnrouter_ldap import vpnrouter_ldap
import vpnrouter_platforms import vpnrouter_platforms
from vpnrouter_net import ConfigError, detect_interfaces, interface_addresses, ip_output from vpnrouter_net import DEFAULT_LISTEN, LISTEN_CHOICES, LOOPBACK, ConfigError, detect_interfaces, interface_addresses, ip_output
CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.json') CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.json')
TEMPLATE_DIR = pathlib.Path('/usr/share/vpn-router/templates') TEMPLATE_DIR = pathlib.Path('/usr/share/vpn-router/templates')
@@ -54,9 +54,17 @@ 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': {'enabled': True, 'port': 7081, 'database': '', 'trusted_proxies': []}, 'web': {'enabled': True, 'listen': DEFAULT_LISTEN, 'port': 7081, 'database': '', 'trusted_proxies': [],
'status_content': 'basic'},
'https': {'enabled': True, 'email': '', 'web_port': 7444, 'manager_port': 7443},
} }
#: The port of Let's Encrypt's HTTP challenge, which Caddy answers while HTTPS is on.
ACME_HTTP_PORT = 80
#: What / and /status of the web service show, web.status_content, from the least to the most.
STATUS_CONTENTS = ('basic', 'standard', 'full')
DOCKER_MODES = ('auto', 'on', 'off') DOCKER_MODES = ('auto', 'on', 'off')
OPENVPN_PROTOCOLS = ('udp', 'tcp') OPENVPN_PROTOCOLS = ('udp', 'tcp')
@@ -259,6 +267,27 @@ def validate(cfg):
if isinstance(web_port, bool) or not isinstance(web_port, int) or not 1 <= web_port <= 65535: if isinstance(web_port, bool) or not isinstance(web_port, int) or not 1 <= web_port <= 65535:
raise ConfigError(f'web.port: {web_port!r} is not a valid port') raise ConfigError(f'web.port: {web_port!r} is not a valid port')
web_listen = _get(cfg, 'web', 'listen')
if web_listen not in LISTEN_CHOICES:
raise ConfigError(
f'web.listen: {web_listen!r} is not one of {", ".join(LISTEN_CHOICES)}')
status_content = _get(cfg, 'web', 'status_content')
if status_content not in STATUS_CONTENTS:
raise ConfigError(
f'web.status_content: {status_content!r} is not one of {", ".join(STATUS_CONTENTS)}')
https_ports = []
for option in ('web_port', 'manager_port'):
port = _get(cfg, 'https', option)
if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
raise ConfigError(f'https.{option}: {port!r} is not a valid port')
if port == ACME_HTTP_PORT:
raise ConfigError(f'https.{option}: {port} is the port of the HTTP challenge')
https_ports.append(port)
if https_ports[0] == https_ports[1]:
raise ConfigError('https.web_port and https.manager_port must differ')
for item in _get_list(cfg, 'web', 'trusted_proxies'): for item in _get_list(cfg, 'web', 'trusted_proxies'):
_check_address(item, 'web.trusted_proxies') _check_address(item, 'web.trusted_proxies')
@@ -275,6 +304,13 @@ def validate(cfg):
raise ConfigError( raise ConfigError(
f'docker.exposed_ports: {item!r} is not a port or a port range') f'docker.exposed_ports: {item!r} is not a port or a port range')
weight += 2 if ':' in item else 1 weight += 2 if ':' in item else 1
first, _, last = item.partition(':')
if _get(cfg, 'https', 'enabled'):
for port in (ACME_HTTP_PORT, *https_ports):
if int(first) <= port <= int(last or first):
raise ConfigError(
f'docker.exposed_ports: {item!r} takes port {port}, which Caddy serves while '
'https.enabled is true')
if weight > DOCKER_PORT_LIMIT: if weight > DOCKER_PORT_LIMIT:
raise ConfigError( raise ConfigError(
f'docker.exposed_ports: at most {DOCKER_PORT_LIMIT} ports are allowed, ' f'docker.exposed_ports: at most {DOCKER_PORT_LIMIT} ports are allowed, '
@@ -443,6 +479,11 @@ def build_context(cfg, local_id_override=None):
'ldap_users': vpnrouter_ldap.USERS, 'ldap_users': vpnrouter_ldap.USERS,
'web_enabled': _get(cfg, 'web', 'enabled'), 'web_enabled': _get(cfg, 'web', 'enabled'),
'web_port': _get(cfg, 'web', 'port'), 'web_port': _get(cfg, 'web', 'port'),
'https_enabled': _get(cfg, 'https', 'enabled'),
'https_email': _get(cfg, 'https', 'email'),
'https_web_port': _get(cfg, 'https', 'web_port'),
'https_manager_port': _get(cfg, 'https', 'manager_port'),
'loopback': LOOPBACK,
} }
@@ -472,6 +513,11 @@ def openvpn_ready(ctx):
return bool(ctx['openvpn_enabled'] and ctx['openvpn_pool'] and ctx['local_fqdn']) return bool(ctx['openvpn_enabled'] and ctx['openvpn_pool'] and ctx['local_fqdn'])
def https_ready(ctx):
"""Caddy serves HTTPS once it is enabled and wan.local_fqdn names the certificate."""
return bool(ctx['https_enabled'] and ctx['local_fqdn'])
def _jinja_env(): def _jinja_env():
return jinja2.Environment( return jinja2.Environment(
loader=jinja2.FileSystemLoader(str(TEMPLATE_DIR)), loader=jinja2.FileSystemLoader(str(TEMPLATE_DIR)),
@@ -30,6 +30,9 @@ DATABASE_SECRET = pathlib.Path('/etc/vpn-router/database.secret')
#: The role and the database provision() creates when web.database is not set. #: The role and the database provision() creates when web.database is not set.
DATABASE = 'vpn-router' DATABASE = 'vpn-router'
#: The user operations of vpnrouter_users after which the user's refresh tokens are revoked.
REVOKING = ('passwd', 'disable', 'unlock', 'remove')
#: Seconds an access token of /auth and a refresh token are valid. #: Seconds an access token of /auth and a refresh token are valid.
ACCESS_SECONDS = 300 ACCESS_SECONDS = 300
REFRESH_SECONDS = 8 * 3600 REFRESH_SECONDS = 8 * 3600
@@ -50,6 +50,21 @@ LOCKOUT_SECONDS = 900
#: The overlays of the database, in the order they are configured. #: The overlays of the database, in the order they are configured.
OVERLAYS = ('ppolicy', 'otp') OVERLAYS = ('ppolicy', 'otp')
#: The package's own schema, under an OID of the UUID arc 2.25 (ITU-T X.667): attribute types
#: under .1 and object classes under .2. vpnRouterAccount, auxiliary, adds properties of the
#: package to a user of either kind: mail, for a Manager-only account, and vpnRouterScope,
#: authorization scopes that nothing reads yet.
SCHEMA_OID = '2.25.98477352595735834532404256531620013001'
SCHEMA_NAME = 'vpn-router'
SCHEMA_ATTRIBUTE_TYPES = (
f"( {SCHEMA_OID}.1.1 NAME 'vpnRouterScope' DESC 'VPN router: an authorization scope of the account' "
"EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} )",
)
SCHEMA_OBJECT_CLASSES = (
f"( {SCHEMA_OID}.2.1 NAME 'vpnRouterAccount' DESC 'VPN router: properties of a user or a Manager-only account' "
"SUP top AUXILIARY MAY ( mail $ vpnRouterScope ) )",
)
#: The TOTP parameters: digits, the OID of HMAC-SHA1, seconds per time step, and the time steps accepted before and after now. #: The TOTP parameters: digits, the OID of HMAC-SHA1, seconds per time step, and the time steps accepted before and after now.
TOTP_DIGITS = 6 TOTP_DIGITS = 6
TOTP_ALGORITHM = '1.2.840.113549.2.7' TOTP_ALGORITHM = '1.2.840.113549.2.7'
@@ -109,6 +124,23 @@ def _ensure_modules(connection):
connection.modify_s(dn, [(ldap.MOD_ADD, 'olcModuleLoad', [name.encode() for name in missing])]) connection.modify_s(dn, [(ldap.MOD_ADD, 'olcModuleLoad', [name.encode() for name in missing])])
def _ensure_schema(connection):
"""Add the package's schema to cn=config when missing, else set its definitions that differ."""
found = connection.search_s('cn=schema,cn=config', ldap.SCOPE_ONELEVEL, f'(cn=*{SCHEMA_NAME})',
['olcAttributeTypes', 'olcObjectClasses'])
attributes = {'olcAttributeTypes': [value.encode() for value in SCHEMA_ATTRIBUTE_TYPES],
'olcObjectClasses': [value.encode() for value in SCHEMA_OBJECT_CLASSES]}
if not found:
connection.add_s(f'cn={SCHEMA_NAME},cn=schema,cn=config', ldap.modlist.addModlist(
dict(attributes, objectClass=[b'olcSchemaConfig'], cn=[SCHEMA_NAME.encode()])))
return
dn, current = found[0]
changes = [(ldap.MOD_REPLACE, name, values) for name, values in attributes.items()
if unordered(current.get(name, [])) != [value.decode() for value in values]]
if changes:
connection.modify_s(dn, changes)
def _ensure_password_hash(connection): def _ensure_password_hash(connection):
dn = 'olcDatabase={-1}frontend,cn=config' dn = 'olcDatabase={-1}frontend,cn=config'
current = connection.search_s(dn, ldap.SCOPE_BASE, attrlist=['olcPasswordHash'])[0][1].get('olcPasswordHash', []) current = connection.search_s(dn, ldap.SCOPE_BASE, attrlist=['olcPasswordHash'])[0][1].get('olcPasswordHash', [])
@@ -188,10 +220,11 @@ def _ensure_manager(connection):
def provision(): def provision():
"""Create or bring up to date the modules, database, ACLs, password policy, entries and service account.""" """Create or bring up to date the modules, the package's schema, database, ACLs, password policy, entries and service account."""
connection = root() connection = root()
try: try:
_ensure_modules(connection) _ensure_modules(connection)
_ensure_schema(connection)
_ensure_password_hash(connection) _ensure_password_hash(connection)
database = _ensure_database(connection) database = _ensure_database(connection)
_ensure_overlays(connection, database) _ensure_overlays(connection, database)
@@ -7,6 +7,15 @@ this module too.
import pathlib import pathlib
import subprocess import subprocess
#: The values of web.listen and of the Router Manager's listen: the interfaces a service listens on.
LISTEN_CHOICES = ('internal', 'external', 'both')
#: The interfaces a service listens on when it is not set.
DEFAULT_LISTEN = 'both'
#: The address every service also listens on, where the HTTPS proxy calls it.
LOOPBACK = '127.0.0.1'
class ConfigError(Exception): class ConfigError(Exception):
"""A setting is present but malformed.""" """A setting is present but malformed."""
@@ -110,26 +119,58 @@ def internal_address(mode, internal, int_addr):
return addresses[0] if addresses else '' return addresses[0] if addresses else ''
def web_listen(configuration, default_port): def external_address(mode, external):
"""Where the web service listens, from vpn-router.json read as configuration, as ((address, port), ''). """The router's address on the external interface, as general.mode directs, or '' when it is not known yet.
The address is the internal address as general.mode directs, the port web.port, else auto works out which interface that is; the other modes read the address of interfaces.external.
default_port. While web.enabled is false or the address is not known, the answer is (None,
the reason).
""" """
def section(name): try:
value = configuration.get(name) if mode == 'auto':
return value if isinstance(value, dict) else {} external = detect_interfaces()[0]
addresses = interface_addresses(external, 'interfaces.external')
except ConfigError:
return ''
return addresses[0] if addresses else ''
web = section('web')
def _section(configuration, name):
"""A section of a configuration, or {} when it is missing or not an object."""
value = configuration.get(name)
return value if isinstance(value, dict) else {}
def listen_addresses(configuration, choice):
"""The addresses a service listens on for choice of LISTEN_CHOICES, from vpn-router.json read as configuration.
The internal address comes first; an address not known yet is left out. A choice that is not
one of LISTEN_CHOICES is DEFAULT_LISTEN. LOOPBACK is not among them; web_listen adds it.
"""
if choice not in LISTEN_CHOICES:
choice = DEFAULT_LISTEN
general, interfaces, local = (_section(configuration, name) for name in ('general', 'interfaces', 'local'))
mode = str(general.get('mode') or 'manual')
addresses = []
if choice in ('internal', 'both'):
addresses.append(internal_address(mode, str(interfaces.get('internal') or ''), str(local.get('int_addr') or '')))
if choice in ('external', 'both'):
addresses.append(external_address(mode, str(interfaces.get('external') or '')))
return [address for address in addresses if address]
def web_listen(configuration, default_port):
"""Where the web service listens, from vpn-router.json read as configuration, as ([(address, port), ...], '').
The addresses are those of the interfaces web.listen names (listen_addresses), then
LOOPBACK, the port web.port, else default_port. While web.enabled is false or no address of
an interface is known, the answer is ([], the reason).
"""
web = _section(configuration, 'web')
if web.get('enabled', True) is False: if web.get('enabled', True) is False:
return None, 'the web service is disabled in the configuration' return [], 'the web service is disabled in the configuration'
port = web.get('port', default_port) port = web.get('port', default_port)
if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535: if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
port = default_port port = default_port
general, interfaces, local = section('general'), section('interfaces'), section('local') addresses = listen_addresses(configuration, web.get('listen', DEFAULT_LISTEN))
address = internal_address(str(general.get('mode') or 'manual'), str(interfaces.get('internal') or ''), if not addresses:
str(local.get('int_addr') or '')) return [], 'the addresses to listen on are not known yet'
if not address: return [(address, port) for address in [*addresses, LOOPBACK]], ''
return None, 'the internal address is not known yet'
return (address, port), ''
@@ -10,31 +10,37 @@ from http.server import ThreadingHTTPServer
def serve(wanted, handler, log, interval): def serve(wanted, handler, log, interval):
"""Work out where to listen every interval seconds with wanted, and run an HTTP server of handler there. """Work out where to listen every interval seconds with wanted, and run an HTTP server of handler at each place.
wanted answers ((address, port), '') or (None, the reason not to listen). When the address or wanted answers ([(address, port), ...], '') or ([], the reason not to listen). A server whose
the port changes, the server is stopped and a new one listens at the new place; while there is place is no longer wanted is stopped, and a new one listens at each new place; a place that
no place or it cannot be bound, no server runs and the next round tries again. Every new cannot be bound is tried again the next round. Every new message is logged with log. Does
reason is logged with log. Does not return. not return.
""" """
server, thread, listening, reported = None, None, None, None servers, reported = {}, None
while True: while True:
target, reason = wanted() targets, reason = wanted()
if target != listening and server is not None: for target in [target for target in servers if target not in targets]:
server, thread = servers.pop(target)
server.shutdown() server.shutdown()
server.server_close() server.server_close()
thread.join() thread.join()
server, listening = None, None messages = [reason] if reason else []
if target is not None and server is None: for target in targets:
if target in servers:
continue
try: try:
server = ThreadingHTTPServer(target, handler) server = ThreadingHTTPServer(target, handler)
except OSError as exc: except OSError as exc:
reason = f'cannot listen on {target[0]}:{target[1]}: {exc}' messages.append(f'cannot listen on {target[0]}:{target[1]}: {exc}')
else: continue
thread = threading.Thread(target=server.serve_forever, name='server', daemon=True) thread = threading.Thread(target=server.serve_forever, name=f'server {target[0]}', daemon=True)
thread.start() thread.start()
listening, reason = target, f'serving on http://{target[0]}:{target[1]}' servers[target] = (server, thread)
if reason and reason != reported: if servers:
log(reason) messages.append('serving on ' + ', '.join(f'http://{address}:{port}' for address, port in servers))
reported = reason message = '; '.join(messages)
if message and message != reported:
log(message)
reported = message
time.sleep(interval) time.sleep(interval)
@@ -1,9 +1,13 @@
"""The users and groups of the router's directory, for vpn-router-user, vpn-router-group and the web service. """The users and groups of the router's directory, for vpn-router-user, vpn-router-group and the web service.
A user is an entry in the router's directory, with a first name, a last name and an e-mail A user is an entry in ou=users of the router's directory, of one of two kinds, fixed when it is
address, whose password OpenVPN checks, and a client certificate of the router CA with the added. A VPN user is an inetOrgPerson with a first name, a last name and an e-mail address, whose
user name as its common name, which OpenVPN and IKEv2 accept. Users exist whether or not a password OpenVPN checks, and a client certificate of the router CA with the user name as its
P2S system is enabled. common name, which OpenVPN and IKEv2 accept. A Manager-only account is an account,
simpleSecurityObject and vpnRouterAccount (the package's schema) with a name, a password and
optionally an e-mail address, no names, no certificate and so no VPN access; it is added when
neither the first nor the last name is given, and can sign in to the web service. Users exist
whether or not a P2S system is enabled.
A group is a groupOfNames in ou=groups with a name, which follows the rules of user names, an A group is a groupOfNames in ou=groups with a name, which follows the rules of user names, an
optional description and at least one member; a group cannot be empty, so the last member optional description and at least one member; a group cannot be empty, so the last member
@@ -19,8 +23,8 @@ code of it. slapd's otp overlay then takes the password followed by a code at ev
A user is disabled when pwdAccountLockedTime holds LOCKED, and locked when the password A user is disabled when pwdAccountLockedTime holds LOCKED, and locked when the password
policy has set it after failed binds, for vpnrouter_ldap.LOCKOUT_SECONDS; unlock ends that. policy has set it after failed binds, for vpnrouter_ldap.LOCKOUT_SECONDS; unlock ends that.
login() checks a user's password and code with a bind. Members of ADMIN_GROUP manage the login() checks a user's password and code with a bind. Members of ADMIN_GROUP manage the
router through the web service. Changing the password, disabling, unlocking and removing a router through the web service. The module knows only the directory; the callers revoke a
user revoke its refresh tokens. user's refresh tokens after the operations of vpnrouter_auth.REVOKING.
Every operation runs in a session(), which holds the lock that serializes changes and the Every operation runs in a session(), which holds the lock that serializes changes and the
connection of the user manager account. A request that cannot be done raises Failure, whose connection of the user manager account. A request that cannot be done raises Failure, whose
@@ -44,7 +48,6 @@ import ldap.modlist
import pyotp import pyotp
import vpnrouter import vpnrouter
import vpnrouter_auth
import vpnrouter_ldap import vpnrouter_ldap
#: A user name is one lowercase DNS label, as it is also the common name of the certificate. #: A user name is one lowercase DNS label, as it is also the common name of the certificate.
@@ -59,7 +62,10 @@ EMAIL_MAX = 254
#: The keys of the person attributes and their LDAP attributes. #: The keys of the person attributes and their LDAP attributes.
PERSON = (('first_name', 'givenName'), ('last_name', 'sn'), ('email', 'mail')) PERSON = (('first_name', 'givenName'), ('last_name', 'sn'), ('email', 'mail'))
#: The attributes read for a user. #: The attributes read for a user.
ATTRIBUTES = ['uid', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken'] ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken']
#: The object classes of a VPN user and of a Manager-only account.
VPN_USER_CLASSES = [b'inetOrgPerson']
MANAGER_ONLY_CLASSES = [b'account', b'simpleSecurityObject', b'vpnRouterAccount']
#: The readable attributes of a TOTP token; oathSecret is never readable. #: The readable attributes of a TOTP token; oathSecret is never readable.
TOKEN_ATTRIBUTES = ['oathTOTPToken', 'oathTOTPParams', 'oathSecretTime', 'oathTOTPLastTimeStep', 'oathTOTPTimeStepDrift', TOKEN_ATTRIBUTES = ['oathTOTPToken', 'oathTOTPParams', 'oathSecretTime', 'oathTOTPLastTimeStep', 'oathTOTPTimeStepDrift',
'oathLastLogin', 'oathLastFailure', 'oathFailureCount'] 'oathLastLogin', 'oathLastFailure', 'oathFailureCount']
@@ -172,12 +178,18 @@ def locked_since(value):
return 0 return 0
def manager_only(attributes):
"""Whether the entry is a Manager-only account."""
return b'account' in (value.lower() for value in attributes.get('objectClass', []))
def summary(attributes): def summary(attributes):
"""A user as the answers show it; a person attribute the entry does not have is None.""" """A user as the answers show it; a person attribute the entry does not have is None."""
name = attributes['uid'][0].decode() name = attributes['uid'][0].decode()
lock = attributes.get('pwdAccountLockedTime', [None])[0] lock = attributes.get('pwdAccountLockedTime', [None])[0]
user = { user = {
'name': name, 'name': name,
'manager_only': manager_only(attributes),
'enabled': lock != LOCKED, 'enabled': lock != LOCKED,
'locked': lock not in (None, LOCKED) and locked_since(lock) > time.time() - vpnrouter_ldap.LOCKOUT_SECONDS, 'locked': lock not in (None, LOCKED) and locked_since(lock) > time.time() - vpnrouter_ldap.LOCKOUT_SECONDS,
'certificate': certificate(name)[0].is_file(), 'certificate': certificate(name)[0].is_file(),
@@ -196,38 +208,62 @@ def common_name(attributes, name):
def list_users(connection): def list_users(connection):
found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL, '(objectClass=inetOrgPerson)', ATTRIBUTES) found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL, '(|(objectClass=inetOrgPerson)(objectClass=account))',
ATTRIBUTES)
return {'users': sorted((summary(attributes) for _, attributes in found), key=lambda user: user['name'])} return {'users': sorted((summary(attributes) for _, attributes in found), key=lambda user: user['name'])}
def add(connection, name, password, values): def add(connection, name, password, values):
"""Add a user with the person attributes of values, all three required, and issue the certificate.""" """Add a user: a VPN user with its certificate when values gives all three person attributes, a Manager-only account
with at most the e-mail address when it gives neither name."""
check_name(name) check_name(name)
details = person(values, required=True) details = person(values, required=False)
vpn_user = 'givenName' in details or 'sn' in details
if vpn_user and len(details) != len(PERSON):
raise Failure('give first_name, last_name and email for a VPN user, or at most email for a Manager-only account', 2)
check_password(password) check_password(password)
if not (vpnrouter.PKI_DIR / 'ca_key.pem').is_file(): if vpn_user and not (vpnrouter.PKI_DIR / 'ca_key.pem').is_file():
raise Failure('the router CA does not exist; vpn-router-setup creates it once a platform is chosen') raise Failure('the router CA does not exist; vpn-router-setup creates it once a platform is chosen')
record = {'objectClass': [b'inetOrgPerson'], 'uid': [name.encode()], if vpn_user:
'cn': [common_name(details, name).encode()]} record = {'objectClass': VPN_USER_CLASSES, 'uid': [name.encode()], 'cn': [common_name(details, name).encode()]}
else:
# simpleSecurityObject requires a userPassword; passwd_s sets the real one at once.
record = {'objectClass': MANAGER_ONLY_CLASSES, 'uid': [name.encode()], 'userPassword': [b'{CRYPT}!']}
record.update({attribute: [value.encode()] for attribute, value in details.items()}) record.update({attribute: [value.encode()] for attribute, value in details.items()})
try: try:
connection.add_s(dn_of(name), ldap.modlist.addModlist(record)) connection.add_s(dn_of(name), ldap.modlist.addModlist(record))
except ldap.ALREADY_EXISTS: except ldap.ALREADY_EXISTS:
raise Failure(f'user {name} exists', 4) raise Failure(f'user {name} exists', 4)
connection.passwd_s(dn_of(name), None, password) connection.passwd_s(dn_of(name), None, password)
vpnrouter.USER_CERT_DIR.mkdir(mode=0o700, parents=True, exist_ok=True) if vpn_user:
for path in certificate(name): vpnrouter.USER_CERT_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
path.unlink(missing_ok=True) for path in certificate(name):
simple_ca('make-cert', '--ca-dir', str(vpnrouter.PKI_DIR), '--cert-dir', str(vpnrouter.USER_CERT_DIR), name) path.unlink(missing_ok=True)
simple_ca('make-cert', '--ca-dir', str(vpnrouter.PKI_DIR), '--cert-dir', str(vpnrouter.USER_CERT_DIR), name)
return summary(entry(connection, name)) return summary(entry(connection, name))
def update(connection, name, values): def update(connection, name, values):
"""Change the person attributes values gives; the cn follows the first and the last name.""" """Change the person attributes values gives; the cn follows the first and the last name.
A Manager-only account has only the e-mail address, which '' removes.
"""
current = entry(connection, name)
if manager_only(current):
if values.get('first_name') is not None or values.get('last_name') is not None:
raise Failure(f'user {name} is a Manager-only account, which has no first or last name', 2)
email = values.get('email')
if email is None:
raise Failure('give email', 2)
if isinstance(email, str) and email.strip() == '':
if 'mail' in current:
connection.modify_s(dn_of(name), [(ldap.MOD_DELETE, 'mail', None)])
else:
connection.modify_s(dn_of(name), [(ldap.MOD_REPLACE, 'mail', [person({'email': email}, required=False)['mail'].encode()])])
return summary(entry(connection, name))
details = person(values, required=False) details = person(values, required=False)
if not details: if not details:
raise Failure('give at least one of first_name, last_name and email', 2) raise Failure('give at least one of first_name, last_name and email', 2)
current = entry(connection, name)
names = {attribute: current[attribute][0].decode() for attribute in ('givenName', 'sn') if current.get(attribute)} names = {attribute: current[attribute][0].decode() for attribute in ('givenName', 'sn') if current.get(attribute)}
names.update({attribute: details[attribute] for attribute in ('givenName', 'sn') if attribute in details}) names.update({attribute: details[attribute] for attribute in ('givenName', 'sn') if attribute in details})
changes = [(ldap.MOD_REPLACE, attribute, [value.encode()]) for attribute, value in details.items()] changes = [(ldap.MOD_REPLACE, attribute, [value.encode()]) for attribute, value in details.items()]
@@ -240,14 +276,12 @@ def passwd(connection, name, password):
check_password(password) check_password(password)
entry(connection, name) entry(connection, name)
connection.passwd_s(dn_of(name), None, password) connection.passwd_s(dn_of(name), None, password)
vpnrouter_auth.revoke(vpnrouter.load_config(), name)
return summary(entry(connection, name)) return summary(entry(connection, name))
def disable(connection, name): def disable(connection, name):
if entry(connection, name).get('pwdAccountLockedTime') != [LOCKED]: if entry(connection, name).get('pwdAccountLockedTime') != [LOCKED]:
connection.modify_s(dn_of(name), [(ldap.MOD_REPLACE, 'pwdAccountLockedTime', [LOCKED])]) connection.modify_s(dn_of(name), [(ldap.MOD_REPLACE, 'pwdAccountLockedTime', [LOCKED])])
vpnrouter_auth.revoke(vpnrouter.load_config(), name)
return summary(entry(connection, name)) return summary(entry(connection, name))
@@ -266,7 +300,6 @@ def unlock(connection, name):
changes.append((ldap.MOD_DELETE, 'pwdAccountLockedTime', None)) changes.append((ldap.MOD_DELETE, 'pwdAccountLockedTime', None))
if changes: if changes:
connection.modify_s(dn_of(name), changes) connection.modify_s(dn_of(name), changes)
vpnrouter_auth.revoke(vpnrouter.load_config(), name)
return summary(entry(connection, name)) return summary(entry(connection, name))
@@ -361,7 +394,6 @@ def remove(connection, name):
cert.unlink(missing_ok=True) cert.unlink(missing_ok=True)
key.unlink(missing_ok=True) key.unlink(missing_ok=True)
connection.delete_s(dn_of(name)) connection.delete_s(dn_of(name))
vpnrouter_auth.revoke(vpnrouter.load_config(), name)
return {'name': name, 'removed': True} return {'name': name, 'removed': True}
+149 -36
View File
@@ -1,5 +1,5 @@
#!/usr/bin/python3 #!/usr/bin/python3
"""Serve the status of the router on its internal address. """Serve the status of the router on its internal address, its external address or both, as web.listen sets.
Run by vpn-router-web.service. Users are managed with vpnrouter_users, the module that Run by vpn-router-web.service. Users are managed with vpnrouter_users, the module that
vpn-router-user uses too. vpn-router-user uses too.
@@ -15,20 +15,27 @@ Pages:
profile, and TOTP enrolment (see users()) profile, and TOTP enrolment (see users())
/groups the groups of the router's directory: list, add, rename or describe, remove, and /groups the groups of the router's directory: list, add, rename or describe, remove, and
add and take out members (see groups()) add and take out members (see groups())
/account the account of the signed-in user: its details, its password and its TOTP
enrolment (see account())
/auth sign-in with a user's password and TOTP code, refresh and sign-out (see auth()) /auth sign-in with a user's password and TOTP code, refresh and sign-out (see auth())
/config, /users and /groups need an HS256 JWT in an Authorization: Bearer header signed with /config, /users, /groups and /account need an HS256 JWT in an Authorization: Bearer header signed
vpnrouter_auth.WEB_KEY (see vpnrouter_auth.verify). A token of /auth names a user in sub, who with vpnrouter_auth.WEB_KEY (see vpnrouter_auth.verify). A token of /auth names a user in sub;
must be a member of vpnrouter_users.ADMIN_GROUP; a token of vpn-router-token has the admin /account serves that user, and the other paths need a member of vpnrouter_users.ADMIN_GROUP. A
claim, and only such a token may change ADMIN_GROUP: create, rename to it, change, remove it, token of vpn-router-token has the admin claim and names no user; it may call every path but
/account, and only such a token may change ADMIN_GROUP: create, rename to it, change, remove it,
add or take out its members, and remove one of its members. These paths and /auth are not add or take out its members, and remove one of its members. These paths and /auth are not
served (404) while the key does not exist. /auth is limited to AUTH_LIMIT calls per client served (404) while the key does not exist. /auth is limited to AUTH_LIMIT calls per client
address in AUTH_WINDOW seconds; the address of a call from one of web.trusted_proxies, by address in AUTH_WINDOW seconds; the address of a call from one of web.trusted_proxies, by
default the address the service listens on, is the last entry of its X-Forwarded-For header. default the address the call came in on, is the last entry of its X-Forwarded-For header.
/ and /status show only the status: whether the services run, the package version, whether / and /status show only the status, as much as web.status_content says (see shown): basic, the
Docker is installed, and the state of site-to-site, road-warrior (IPsec P2S), OpenVPN and default, the overall status, the time and the client's address (its address as for the rate limit
WireGuard. No configuration data, key or address is shown. below); standard also the package version, the installed version of vpn-router-manager
(manager_version, null when it is not installed) and the number of active and other services; full
whether each service runs, whether Docker is installed, and the state of site-to-site, road-warrior
(IPsec P2S), OpenVPN and WireGuard. No configuration data or key is shown, and no address but the
client's.
The service first initializes, without listening: it makes the signing key and the refresh The service first initializes, without listening: it makes the signing key and the refresh
tokens' database (vpnrouter_auth), retrying every INIT_RETRY_SECONDS until both are ready and tokens' database (vpnrouter_auth), retrying every INIT_RETRY_SECONDS until both are ready and
@@ -36,10 +43,11 @@ logging each failure. Then it listens. Its unit starts it only once wait-for-lib
in the linker cache, where psycopg looks it up. in the linker cache, where psycopg looks it up.
The service needs nothing else to start and does not end on its own. Every DISCOVERY_SECONDS it The service needs nothing else to start and does not end on its own. Every DISCOVERY_SECONDS it
works out the internal address from /etc/vpn-router/vpn-router.json as general.mode directs, works out from /etc/vpn-router/vpn-router.json the addresses of the interfaces web.listen names,
with web.port and web.enabled (vpnrouter_net.web_listen), and runs the server there internal, external or both, as general.mode directs, with web.port and web.enabled
(vpnrouter_serve.serve): it starts it, moves it when the address or the port changes, and stops (vpnrouter_net.web_listen), and runs a server on each (vpnrouter_serve.serve): it starts them,
it while the service is disabled or the address is not known. Every request reads what it needs when it is made: the signing moves them when an address or the port changes, and stops them while the service is disabled or
no address is 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.
@@ -143,9 +151,10 @@ def unit_state(unit):
return result.stdout.strip() if result and result.stdout.strip() else 'unknown' return result.stdout.strip() if result and result.stdout.strip() else 'unknown'
def package_version(): def package_version(package='vpn-router'):
result = run('dpkg-query', '-W', '-f=${Version}', 'vpn-router') """The installed version of the package, or None when it is not installed."""
return result.stdout.strip() if result and result.returncode == 0 else None result = run('dpkg-query', '-W', '-f=${Version}', package)
return (result.stdout.strip() or None) if result and result.returncode == 0 else None
def docker_status(): def docker_status():
@@ -239,9 +248,23 @@ def openvpn_clients():
return None return None
def postgresql_clusters():
"""The state of each PostgreSQL cluster unit (postgresql@<version>-<name>), or {'postgresql': 'no cluster'} without one.
postgresql.service, which only runs /bin/true, is not reported.
"""
lines = lines_of('systemctl', 'list-units', 'postgresql@*.service', '--all', '--plain', '--no-legend') or []
names = [line.split()[0].removesuffix('.service') for line in lines if line.strip()]
return {name: unit_state(f'{name}.service') for name in names} or {'postgresql': 'no cluster'}
def collect(state): def collect(state):
"""The status document. state says which features the router has.""" """The status document. state says which features the router has."""
units = {'vpn-router-setup': unit_state('vpn-router-setup.service'), 'slapd': unit_state('slapd.service')} units = {'vpn-router-setup': unit_state('vpn-router-setup.service'), 'slapd': unit_state('slapd.service'),
**postgresql_clusters()}
manager_version = package_version('vpn-router-manager')
if manager_version:
units['vpn-router-manager'] = unit_state('vpn-router-manager.service')
if state.get('site_to_site') or state.get('p2s'): if state.get('site_to_site') or state.get('p2s'):
units['strongswan'] = unit_state('strongswan.service') units['strongswan'] = unit_state('strongswan.service')
if state.get('wireguard'): if state.get('wireguard'):
@@ -252,6 +275,7 @@ def collect(state):
status = { status = {
'status': 'ok' if all(value == 'active' for value in units.values()) else 'degraded', 'status': 'ok' if all(value == 'active' for value in units.values()) else 'degraded',
'version': package_version(), 'version': package_version(),
'manager_version': manager_version,
'time': time.strftime('%Y-%m-%dT%H:%M:%S%z'), 'time': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
'units': units, 'units': units,
'docker': docker_status(), 'docker': docker_status(),
@@ -268,6 +292,26 @@ def collect(state):
return status return status
def shown(status, content, client):
"""The status document as web.status_content shows it to the client at its address.
Every content has status, time and client_ip, the client's address, and content, which one it is: basic nothing
more; standard adds version, manager_version and services, the number of active units and of units in every
other state; full adds the whole status document of collect. Content that is not one of
vpnrouter.STATUS_CONTENTS, or not set, is basic.
"""
if content not in vpnrouter.STATUS_CONTENTS:
content = 'basic'
if content == 'full':
return {**status, 'content': content, 'client_ip': client}
document = {'status': status['status'], 'content': content, 'time': status['time'], 'client_ip': client}
if content == 'standard':
active = sum(1 for value in status['units'].values() if value == 'active')
document.update(version=status['version'], manager_version=status['manager_version'],
services={'active': active, 'failed': len(status['units']) - active})
return document
def lines_of(*args): def lines_of(*args):
"""The output lines of a command, or None when it fails.""" """The output lines of a command, or None when it fails."""
result = run(*args) result = run(*args)
@@ -325,12 +369,20 @@ def details(state):
def text_of(status): def text_of(status):
"""The status for people, as plain text.""" """The status document of shown for people, as plain text: a header, a blank line, then name: value lines."""
lines = ['VPN Router', f'Status: {status["status"]}', f'Version: {status["version"] or "unknown"}', ''] lines = ['VPN Router', f'Status: {status["status"]}']
lines += [f'{name}: {value}' for name, value in status['units'].items()] if 'version' in status:
docker = status['docker'] lines.append(f'Version: {status["version"] or "unknown"}')
lines.append('Docker: not installed' if not docker['installed'] lines += ['', f'Client IP: {status["client_ip"]}']
else f'Docker: installed, service {docker["service"]}, rules {docker["rules"]}') if 'manager_version' in status:
lines.append(f'Router Manager: {status["manager_version"] or "not installed"}')
if 'services' in status:
lines.append(f'Services: {status["services"]["active"]} active, {status["services"]["failed"]} failed')
lines += [f'{name}: {value}' for name, value in status.get('units', {}).items()]
if 'docker' in status:
docker = status['docker']
lines.append('Docker: not installed' if not docker['installed']
else f'Docker: installed, service {docker["service"]}, rules {docker["rules"]}')
if 'site_to_site' in status: if 'site_to_site' in status:
value = status['site_to_site']['established'] value = status['site_to_site']['established']
lines.append(f'Site to site: {"unknown" if value is None else "established" if value else "not established"}') lines.append(f'Site to site: {"unknown" if value is None else "established" if value else "not established"}')
@@ -354,11 +406,12 @@ def text_of(status):
def html_of(status): def html_of(status):
"""The status for a browser, as a page styled with Tailwind CSS.""" """The status document of shown for a browser, as a page styled with Tailwind CSS."""
colour = 'bg-emerald-600 dark:bg-emerald-700' if status['status'] == 'ok' else 'bg-amber-600 dark:bg-amber-700' colour = 'bg-emerald-600 dark:bg-emerald-700' if status['status'] == 'ok' else 'bg-amber-600 dark:bg-amber-700'
# Each "name: value" line of the text is a row; indented lines, such as WireGuard peers, are indented here too. version = f', version {status["version"] or "unknown"}' if 'version' in status else ''
# Each "name: value" line after the header of the text is a row; indented lines, such as WireGuard peers, are indented here too.
rows = [] rows = []
for line in text_of(status).splitlines()[4:]: for line in text_of(status).split('\n\n', 1)[1].splitlines():
if not line: if not line:
continue continue
name, _, value = line.partition(': ') name, _, value = line.partition(': ')
@@ -382,7 +435,7 @@ def html_of(status):
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-800"> <div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-800">
<div class="{colour} px-6 py-4 text-white"> <div class="{colour} px-6 py-4 text-white">
<h1 class="text-xl font-semibold">VPN Router</h1> <h1 class="text-xl font-semibold">VPN Router</h1>
<p class="mt-1 text-sm">Status: {escape(status['status'])}, version {escape(status['version'] or 'unknown')}</p> <p class="mt-1 text-sm">Status: {escape(status['status'] + version)}</p>
</div> </div>
<div class="px-6 py-4"> <div class="px-6 py-4">
<table class="w-full text-sm"><tbody>{body}</tbody></table> <table class="w-full text-sm"><tbody>{body}</tbody></table>
@@ -399,7 +452,8 @@ def users(state, method, path, body):
"""Answer a /users request with vpnrouter_users: the HTTP status and the JSON document. """Answer a /users request with vpnrouter_users: the HTTP status and the JSON document.
GET /users the users GET /users the users
POST /users add a user: {"name", "password", "first_name", "last_name", "email"} POST /users add a user: {"name", "password", "first_name", "last_name", "email"};
without the last three a Manager-only account
PUT /users/<name> change any of {"first_name", "last_name", "email"} PUT /users/<name> change any of {"first_name", "last_name", "email"}
PUT /users/<name>/password set the password: {"password": ...} PUT /users/<name>/password set the password: {"password": ...}
POST /users/<name>/disable lock the user out of OpenVPN POST /users/<name>/disable lock the user out of OpenVPN
@@ -431,11 +485,16 @@ def users(state, method, path, body):
('PUT', 3, 'otp'): lambda connection: vpnrouter_users.otp_enrol(connection, name, body.get('secret'), body.get('code')), ('PUT', 3, 'otp'): lambda connection: vpnrouter_users.otp_enrol(connection, name, body.get('secret'), body.get('code')),
('DELETE', 3, 'otp'): lambda connection: vpnrouter_users.otp_remove(connection, name), ('DELETE', 3, 'otp'): lambda connection: vpnrouter_users.otp_remove(connection, name),
} }
# The vpnrouter_users operation of each route that may revoke refresh tokens.
operations = {('PUT', 3, 'password'): 'passwd', ('POST', 3, 'disable'): 'disable', ('POST', 3, 'unlock'): 'unlock',
('DELETE', 2, None): 'remove'}
if route not in actions: if route not in actions:
return 404, {'error': f'no route {method} {path}'} return 404, {'error': f'no route {method} {path}'}
try: try:
with vpnrouter_users.session() as connection: with vpnrouter_users.session() as connection:
document = actions[route](connection) document = actions[route](connection)
if operations.get(route) in vpnrouter_auth.REVOKING:
vpnrouter_auth.revoke(vpnrouter.load_config(), name)
except vpnrouter_users.Failure as failure: except vpnrouter_users.Failure as failure:
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:
@@ -486,6 +545,54 @@ def groups(state, method, path, body):
return (201 if route == ('POST', 1, None) else 200), document return (201 if route == ('POST', 1, None) else 200), document
def account(name, method, path, body):
"""Answer an /account request of the user named by the token with vpnrouter_users: the HTTP status and the JSON document.
GET /account the user, with admin, whether the user is a member of ADMIN_GROUP
PUT /account/password set the password: {"current_password", "current_code"?, "password"}
POST /account/otp a new TOTP secret and its otpauth URI; nothing is stored
PUT /account/otp store a TOTP secret: {"current_password", "current_code"?, "secret", "code"},
code a code of the secret
A change needs the current password and, for an enrolled user, a current code; a wrong one is 403 and counts as
a failed login. A password change revokes the user's refresh tokens.
"""
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
return 400, {'error': 'the body must be a JSON object'}
body = body or {}
route = (method, path.rstrip('/'))
if route not in (('GET', '/account'), ('PUT', '/account/password'), ('POST', '/account/otp'), ('PUT', '/account/otp')):
return 404, {'error': f'no route {method} {path}'}
try:
if route == ('PUT', '/account/password'):
vpnrouter_users.check_password(body.get('password'))
if method == 'PUT':
vpnrouter_users.login(name, body.get('current_password'), body.get('current_code'))
with vpnrouter_users.session() as connection:
if route == ('GET', '/account'):
document = {**vpnrouter_users.summary(vpnrouter_users.entry(connection, name)),
'admin': vpnrouter_users.is_admin(connection, name)}
elif route == ('PUT', '/account/password'):
document = vpnrouter_users.passwd(connection, name, body['password'])
elif route == ('POST', '/account/otp'):
document = vpnrouter_users.otp_new(connection, name)
else:
document = vpnrouter_users.otp_enrol(connection, name, body.get('secret'), body.get('code'))
if route == ('PUT', '/account/password'):
vpnrouter_auth.revoke(vpnrouter.load_config(), name)
except vpnrouter_users.Failure as failure:
if failure.status == 5:
return 403, {'error': 'the current password or code is wrong'}
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
except (ldap.LDAPError, OSError) as exc:
return 500, {'error': f'the user directory failed: {exc}'}
except vpnrouter_auth.DatabaseError as exc:
return 500, {'error': str(exc)}
except SystemExit as exc:
return 500, {'error': str(exc.code)}
return 200, document
def changes_admins(connection, method, parts, body): def changes_admins(connection, method, parts, body):
"""Whether a /users or /groups request creates, renames to, changes or removes ADMIN_GROUP, or removes a member of it.""" """Whether a /users or /groups request creates, renames to, changes or removes ADMIN_GROUP, or removes a member of it."""
admins = vpnrouter_users.ADMIN_GROUP admins = vpnrouter_users.ADMIN_GROUP
@@ -502,9 +609,11 @@ def changes_admins(connection, method, parts, body):
def refusal(claims, method, path, body): def refusal(claims, method, path, body):
"""Why the holder of the claims may not make the request to a protected path, or '' when it may.""" """Why the holder of the claims may not make the request to a protected path, or '' when it may."""
name = claims.get('sub')
if path == '/account' or path.startswith('/account/'):
return '' if isinstance(name, str) else 'the token names no user'
if claims.get('admin') is True: if claims.get('admin') is True:
return '' return ''
name = claims.get('sub')
if not isinstance(name, str): if not isinstance(name, str):
return 'the token names no user' return 'the token names no user'
admins = vpnrouter_users.ADMIN_GROUP admins = vpnrouter_users.ADMIN_GROUP
@@ -604,7 +713,7 @@ class Handler(BaseHTTPRequestHandler):
# A body that is not read must not be taken for the next request. # A body that is not read must not be taken for the next request.
if self.command not in ('GET', 'HEAD'): if self.command not in ('GET', 'HEAD'):
self.close_connection = True self.close_connection = True
directory = next((name for name in ('users', 'groups') if path == f'/{name}' or path.startswith(f'/{name}/')), None) directory = next((name for name in ('users', 'groups', 'account') if path == f'/{name}' or path.startswith(f'/{name}/')), None)
protected = path == '/config' or directory is not None protected = path == '/config' or directory is not None
signing = path in ('/auth', '/auth/refresh', '/auth/logout') signing = path in ('/auth', '/auth/refresh', '/auth/logout')
state = current() if protected or signing else {} state = current() if protected or signing else {}
@@ -632,19 +741,23 @@ class Handler(BaseHTTPRequestHandler):
body = json.dumps({'error': refused[1]}, indent=2).encode() + b'\n' body = json.dumps({'error': refused[1]}, indent=2).encode() + b'\n'
elif path == '/config' and self.command in ('GET', 'HEAD'): elif path == '/config' and self.command in ('GET', 'HEAD'):
body, kind, code = json.dumps(details(state), indent=2).encode() + b'\n', 'application/json', 200 body, kind, code = json.dumps(details(state), indent=2).encode() + b'\n', 'application/json', 200
elif directory == 'account':
code, document = account(claims['sub'], self.command, path, payload)
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif protected and path != '/config': elif protected and path != '/config':
code, document = (users if directory == 'users' else groups)(state, self.command, path, payload) code, document = (users if directory == 'users' else groups)(state, self.command, path, payload)
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json' body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif self.command not in ('GET', 'HEAD'): elif self.command not in ('GET', 'HEAD'):
body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405 body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405
elif path == '/status': elif path in ('/status', '/'):
body, kind, code = json.dumps(self.status(), indent=2).encode() + b'\n', 'application/json', 200 document = shown(self.status(), section(read_json(CONFIG_FILE), 'web').get('status_content'), self.client())
elif path == '/':
agent = self.headers.get('User-Agent', '').lower() agent = self.headers.get('User-Agent', '').lower()
if 'curl' in agent or 'wget' in agent: if path == '/status':
body, kind = text_of(self.status()).encode(), 'text/plain; charset=utf-8' body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif 'curl' in agent or 'wget' in agent:
body, kind = text_of(document).encode(), 'text/plain; charset=utf-8'
else: else:
body, kind = html_of(self.status()).encode(), 'text/html; charset=utf-8' body, kind = html_of(document).encode(), 'text/html; charset=utf-8'
code = 200 code = 200
else: else:
body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404 body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
+12 -6
View File
@@ -10,6 +10,7 @@ user exists, 1 anything else. -h or --help shows the commands and their options.
vpn-router-user [--json] list vpn-router-user [--json] list
vpn-router-user [--json] add <name> --first-name F --last-name L --email E (password on stdin) vpn-router-user [--json] add <name> --first-name F --last-name L --email E (password on stdin)
vpn-router-user [--json] add <name> [--email E] (a Manager-only account; password on stdin)
vpn-router-user [--json] update <name> [--first-name F] [--last-name L] [--email E] vpn-router-user [--json] update <name> [--first-name F] [--last-name L] [--email E]
vpn-router-user [--json] passwd <name> (password on stdin) vpn-router-user [--json] passwd <name> (password on stdin)
vpn-router-user [--json] disable <name> vpn-router-user [--json] disable <name>
@@ -30,6 +31,7 @@ sys.path.insert(0, '/usr/lib/vpn-router')
import ldap import ldap
import vpnrouter
import vpnrouter_auth import vpnrouter_auth
import vpnrouter_users import vpnrouter_users
from vpnrouter_users import Failure from vpnrouter_users import Failure
@@ -37,8 +39,10 @@ from vpnrouter_users import Failure
#: The help line of each command. #: The help line of each command.
HELP = { HELP = {
'list': 'list the users', 'list': 'list the users',
'add': 'add a user and issue the certificate; the password is read from standard input', 'add': 'add a VPN user with the three person options and issue the certificate, or without the names a '
'update': 'change the first name, the last name or the e-mail address', 'Manager-only account, with an optional e-mail address; the password is read from standard input',
'update': "change the first name, the last name or the e-mail address; a Manager-only account has only the "
"e-mail address, which '' removes",
'passwd': 'set the password, read from standard input', 'passwd': 'set the password, read from standard input',
'disable': 'lock the user out of OpenVPN', 'disable': 'lock the user out of OpenVPN',
'enable': 'let the user in again', 'enable': 'let the user in again',
@@ -71,8 +75,7 @@ def parse(argv):
sub.add_argument('name', help='the user name') sub.add_argument('name', help='the user name')
if command in ('add', 'update'): if command in ('add', 'update'):
for key, _ in vpnrouter_users.PERSON: for key, _ in vpnrouter_users.PERSON:
sub.add_argument(f'--{key.replace("_", "-")}', dest=key, metavar='TEXT', required=command == 'add', sub.add_argument(f'--{key.replace("_", "-")}', dest=key, metavar='TEXT', help=PERSON_HELP[key])
help=PERSON_HELP[key])
if command == 'otp-enrol': if command == 'otp-enrol':
sub.add_argument('code', help='a code of the secret from the authenticator') sub.add_argument('code', help='a code of the secret from the authenticator')
return parser.parse_args(argv) return parser.parse_args(argv)
@@ -111,8 +114,9 @@ def text_of(answer):
users = answer['users'] if 'users' in answer else [answer] users = answer['users'] if 'users' in answer else [answer]
if not users: if not users:
return 'no users' return 'no users'
head = ('USER', 'STATE', 'CERTIFICATE', 'OTP', 'FIRST NAME', 'LAST NAME', 'E-MAIL') head = ('USER', 'KIND', 'STATE', 'CERTIFICATE', 'OTP', 'FIRST NAME', 'LAST NAME', 'E-MAIL')
rows = [(user['name'], 'disabled' if not user['enabled'] else 'locked' if user['locked'] else 'enabled', rows = [(user['name'], 'manager' if user['manager_only'] else 'vpn',
'disabled' if not user['enabled'] else 'locked' if user['locked'] else 'enabled',
'issued' if user['certificate'] else 'missing', 'issued' if user['certificate'] else 'missing',
'enrolled' if user['otp'] else '-', user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-') 'enrolled' if user['otp'] else '-', user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-')
for user in users] for user in users]
@@ -131,6 +135,8 @@ def main(argv):
secret = password() if options.command in ('add', 'passwd', 'otp-enrol') else None secret = password() if options.command in ('add', 'passwd', 'otp-enrol') else None
with vpnrouter_users.session() as connection: with vpnrouter_users.session() as connection:
answer = run(connection, options, secret) answer = run(connection, options, secret)
if options.command in vpnrouter_auth.REVOKING:
vpnrouter_auth.revoke(vpnrouter.load_config(), options.name)
except Failure as failure: except Failure as failure:
message, status = str(failure), failure.status message, status = str(failure), failure.status
except (ldap.LDAPError, OSError) as exc: except (ldap.LDAPError, OSError) as exc:
@@ -45,8 +45,16 @@
}, },
"web": { "web": {
"enabled": true, "enabled": true,
"listen": "both",
"port": 7081, "port": 7081,
"database": "", "database": "",
"trusted_proxies": [] "trusted_proxies": [],
"status_content": "basic"
},
"https": {
"enabled": true,
"email": "",
"web_port": 7444,
"manager_port": 7443
} }
} }
@@ -0,0 +1,17 @@
# Written by vpn-router-setup from /etc/vpn-router/vpn-router.json; edits are overwritten.
{
auto_https disable_redirects
{%- if https_email %}
email {{ https_email }}
{%- endif %}
}
{% if web_enabled %}
{{ local_fqdn }}:{{ https_web_port }} {
reverse_proxy {{ loopback }}:{{ web_port }}
}
{% endif %}
{%- if manager_port %}
{{ local_fqdn }}:{{ https_manager_port }} {
reverse_proxy {{ loopback }}:{{ manager_port }}
}
{% endif %}