Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83c1c50d8d | ||
|
|
650896131d | ||
|
|
dbc2046e24 | ||
|
|
45d73abe7b | ||
|
|
993a7a9fa9 | ||
|
|
581b53d447 | ||
|
|
8782edff0f | ||
|
|
fe17e83b49 |
No files matched your search
@@ -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
|
||||||
@@ -454,3 +469,57 @@ used.
|
|||||||
Consequences: The schema check stays strict. New attributes extend `vpnRouterAccount`, and their
|
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
|
OIDs continue the arcs. Nothing reads `vpnRouterScope` yet. The OID must never be reused for
|
||||||
anything else.
|
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.
|
||||||
@@ -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:
|
||||||
|
|
||||||
@@ -234,6 +240,24 @@ vpn-router-setup` applies a changed configuration while Docker runs. With `docke
|
|||||||
`on`, the service fails when Docker is not installed or its daemon is not running; with
|
`on`, the service fails when Docker is not installed or its daemon is not running; with
|
||||||
`off` it removes the rules.
|
`off` it removes the rules.
|
||||||
|
|
||||||
|
## Editing the configuration
|
||||||
|
|
||||||
|
`vpn-config-edit`, run as root, shows and changes `/etc/vpn-router/vpn-router.json`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vpn-config-edit show
|
||||||
|
vpn-config-edit set web.status_content standard
|
||||||
|
vpn-config-edit set web.port 7082
|
||||||
|
vpn-config-edit set web.trusted_proxies 127.0.0.1,10.0.0.4
|
||||||
|
```
|
||||||
|
|
||||||
|
`show` prints the file as indented JSON and does not change it. `set` takes a setting as
|
||||||
|
`section.option` and converts the value to the setting's type: a whole number, `true` or
|
||||||
|
`false`, a list separated by commas (an empty value is an empty list), or a string; an unknown
|
||||||
|
setting is refused. It checks the whole configuration as `vpn-router-setup` does, and only then
|
||||||
|
writes the file, indented, with mode 0600, and reminds that `vpn-router-setup` applies it. A
|
||||||
|
refused request exits with status 2, any other failure with 1.
|
||||||
|
|
||||||
## Applying changes
|
## Applying changes
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -492,9 +516,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.
|
||||||
@@ -525,9 +562,11 @@ 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`, `slapd` and each PostgreSQL cluster unit
|
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 `strongswan` when site-to-site or
|
(`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 and,
|
OpenVPN clients, from the server's status file, which OpenVPN rewrites every minute; the package version and,
|
||||||
@@ -545,16 +584,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.
|
||||||
|
|
||||||
@@ -581,7 +622,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
|
||||||
@@ -639,6 +680,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`.
|
||||||
@@ -658,13 +717,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
|
||||||
|
|
||||||
@@ -672,13 +759,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.
|
||||||
|
|
||||||
@@ -686,7 +774,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
|
||||||
|
|||||||
@@ -1,3 +1,109 @@
|
|||||||
|
vpn-router (0.15.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
feature: vpn-config-edit shows and changes the router's configuration.
|
||||||
|
|
||||||
|
* vpn-config-edit show prints /etc/vpn-router/vpn-router.json as
|
||||||
|
indented JSON.
|
||||||
|
* vpn-config-edit set SECTION.OPTION VALUE converts the value to the
|
||||||
|
setting's type, checks the whole configuration as vpn-router-setup
|
||||||
|
does, writes the file indented with mode 0600, and reminds that
|
||||||
|
vpn-router-setup applies it.
|
||||||
|
|
||||||
|
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 18:00:00 +0200
|
||||||
|
|
||||||
|
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
|
vpn-router (0.11.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
feature: the status page shows the Router Manager's version.
|
feature: the status page shows the Router Manager's version.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ src/usr/lib/vpn-router/openvpn-same-user usr/lib/vpn-router/
|
|||||||
src/usr/sbin/vpn-router-user usr/sbin/
|
src/usr/sbin/vpn-router-user usr/sbin/
|
||||||
src/usr/sbin/vpn-router-group usr/sbin/
|
src/usr/sbin/vpn-router-group usr/sbin/
|
||||||
src/usr/sbin/vpn-router-token usr/sbin/
|
src/usr/sbin/vpn-router-token usr/sbin/
|
||||||
|
src/usr/sbin/vpn-config-edit usr/sbin/
|
||||||
src/usr/lib/vpn-router/simple-ca usr/lib/vpn-router/
|
src/usr/lib/vpn-router/simple-ca usr/lib/vpn-router/
|
||||||
src/usr/lib/vpn-router/configure usr/lib/vpn-router/
|
src/usr/lib/vpn-router/configure usr/lib/vpn-router/
|
||||||
src/usr/lib/vpn-router/docker-rules usr/lib/vpn-router/
|
src/usr/lib/vpn-router/docker-rules usr/lib/vpn-router/
|
||||||
@@ -19,4 +20,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/
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -58,6 +59,12 @@ MANAGER_CONFIG = pathlib.Path('/etc/vpn-router-manager/manager.json')
|
|||||||
MANAGER_ADMIN = pathlib.Path('/etc/vpn-router-manager/admin.json')
|
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.
|
||||||
@@ -71,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'),
|
||||||
@@ -215,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',
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -231,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']:
|
||||||
@@ -431,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)
|
||||||
@@ -574,6 +623,8 @@ def main():
|
|||||||
problem = manage_admin()
|
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')
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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,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,21 +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 and the
|
/ and /status show only the status, as much as web.status_content says (see shown): basic, the
|
||||||
installed version of vpn-router-manager (manager_version, null when it is not installed), whether
|
default, the overall status, the time and the client's address (its address as for the rate limit
|
||||||
Docker is installed, and the state of site-to-site, road-warrior (IPsec P2S), OpenVPN and
|
below); standard also the package version, the installed version of vpn-router-manager
|
||||||
WireGuard. No configuration data, key or address is shown.
|
(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
|
||||||
@@ -37,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.
|
||||||
@@ -255,6 +262,9 @@ 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()}
|
**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'):
|
||||||
@@ -265,7 +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': package_version('vpn-router-manager'),
|
'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(),
|
||||||
@@ -282,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)
|
||||||
@@ -339,13 +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.append(f'Router Manager: {status["manager_version"] or "not installed"}')
|
if 'version' in status:
|
||||||
lines += [f'{name}: {value}' for name, value in status['units'].items()]
|
lines.append(f'Version: {status["version"] or "unknown"}')
|
||||||
docker = status['docker']
|
lines += ['', f'Client IP: {status["client_ip"]}']
|
||||||
lines.append('Docker: not installed' if not docker['installed']
|
if 'manager_version' in status:
|
||||||
else f'Docker: installed, service {docker["service"]}, rules {docker["rules"]}')
|
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"}')
|
||||||
@@ -369,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(': ')
|
||||||
@@ -397,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>
|
||||||
@@ -507,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
|
||||||
@@ -523,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
|
||||||
@@ -625,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 {}
|
||||||
@@ -653,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
|
||||||
|
|||||||
Executable
+129
@@ -0,0 +1,129 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
"""Show and change the router's configuration, /etc/vpn-router/vpn-router.json.
|
||||||
|
|
||||||
|
show prints the file as indented JSON; the file is not changed. set takes a setting as
|
||||||
|
section.option, such as web.port, and a value, which it converts to the type the package's
|
||||||
|
schema (vpnrouter.SCHEMA) gives the setting: a whole number, true or false, a list of strings
|
||||||
|
separated by commas (an empty value is an empty list), or a string. The configuration with the
|
||||||
|
new value is checked as vpn-router-setup checks it, and only then written, indented, with mode
|
||||||
|
0600; vpn-router-setup applies it at its next run, which set reminds of. A failure is a message
|
||||||
|
on standard error; the exit status is 2 for an invalid request and 1 for any other failure.
|
||||||
|
-h or --help shows the options.
|
||||||
|
|
||||||
|
vpn-config-edit show
|
||||||
|
vpn-config-edit set SECTION.OPTION VALUE
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
sys.path.insert(0, '/usr/lib/vpn-router')
|
||||||
|
|
||||||
|
import vpnrouter
|
||||||
|
|
||||||
|
|
||||||
|
class Failure(Exception):
|
||||||
|
"""A request that cannot be done; status is 2 invalid, 1 other."""
|
||||||
|
|
||||||
|
def __init__(self, message, status=1):
|
||||||
|
super().__init__(message)
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
|
||||||
|
class Parser(argparse.ArgumentParser):
|
||||||
|
"""An argument parser whose errors are Failures with status 2."""
|
||||||
|
|
||||||
|
def error(self, message):
|
||||||
|
raise Failure(f'{message}; see {self.prog} --help', 2)
|
||||||
|
|
||||||
|
|
||||||
|
def read():
|
||||||
|
"""The settings in the configuration file, without the defaults; {} when the file does not exist."""
|
||||||
|
try:
|
||||||
|
data = json.loads(vpnrouter.CONFIG_FILE.read_text(encoding='utf-8'))
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {}
|
||||||
|
except ValueError as exc:
|
||||||
|
raise Failure(f'cannot parse {vpnrouter.CONFIG_FILE}: {exc}')
|
||||||
|
if not isinstance(data, dict) or not all(isinstance(options, dict) for options in data.values()):
|
||||||
|
raise Failure(f'cannot parse {vpnrouter.CONFIG_FILE}: it is not an object of sections')
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def converted(key, text):
|
||||||
|
"""The value of text for the setting key, section.option, of the type the schema gives it."""
|
||||||
|
section, _, option = key.partition('.')
|
||||||
|
if section not in vpnrouter.SCHEMA or option not in vpnrouter.SCHEMA[section]:
|
||||||
|
raise Failure(f'{key}: unknown setting; settings are section.option, such as web.port', 2)
|
||||||
|
kind = type(vpnrouter.SCHEMA[section][option])
|
||||||
|
if kind is bool:
|
||||||
|
if text not in ('true', 'false'):
|
||||||
|
raise Failure(f'{key} takes true or false', 2)
|
||||||
|
return text == 'true'
|
||||||
|
if kind is int:
|
||||||
|
try:
|
||||||
|
return int(text)
|
||||||
|
except ValueError:
|
||||||
|
raise Failure(f'{key} takes a whole number', 2)
|
||||||
|
if kind is list:
|
||||||
|
return [item.strip() for item in text.split(',') if item.strip()]
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def write(data):
|
||||||
|
"""Write the settings, indented, with mode 0600, replacing the file in one step."""
|
||||||
|
directory = vpnrouter.CONFIG_FILE.parent
|
||||||
|
descriptor, name = tempfile.mkstemp(dir=directory, prefix='.vpn-router.json.')
|
||||||
|
try:
|
||||||
|
with os.fdopen(descriptor, 'w', encoding='utf-8') as handle:
|
||||||
|
handle.write(json.dumps(data, indent=2) + '\n')
|
||||||
|
os.chmod(name, 0o600)
|
||||||
|
os.replace(name, vpnrouter.CONFIG_FILE)
|
||||||
|
except OSError:
|
||||||
|
os.unlink(name)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
try:
|
||||||
|
parser = Parser(prog='vpn-config-edit', description='Show and change the router configuration.')
|
||||||
|
commands = parser.add_subparsers(dest='command', required=True, parser_class=Parser)
|
||||||
|
commands.add_parser('show', help='print the configuration as indented JSON')
|
||||||
|
setter = commands.add_parser('set', help='set a setting and write the configuration')
|
||||||
|
setter.add_argument('key', metavar='SECTION.OPTION', help='the setting, such as web.port')
|
||||||
|
setter.add_argument('value', metavar='VALUE', help='its value; a list separated by commas')
|
||||||
|
options = parser.parse_args(argv)
|
||||||
|
|
||||||
|
data = read()
|
||||||
|
if options.command == 'show':
|
||||||
|
print(json.dumps(data, indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
section, _, option = options.key.partition('.')
|
||||||
|
value = converted(options.key, options.value)
|
||||||
|
data.setdefault(section, {})[option] = value
|
||||||
|
# The configuration as vpn-router-setup reads it: the defaults with the file's settings.
|
||||||
|
candidate = {name: dict(defaults) for name, defaults in vpnrouter.SCHEMA.items()}
|
||||||
|
for name, settings in data.items():
|
||||||
|
candidate.setdefault(name, {}).update(settings)
|
||||||
|
try:
|
||||||
|
vpnrouter.validate(candidate)
|
||||||
|
except vpnrouter.ConfigError as exc:
|
||||||
|
raise Failure(str(exc), 2)
|
||||||
|
write(data)
|
||||||
|
except Failure as failure:
|
||||||
|
print(f'vpn-config-edit: {failure}', file=sys.stderr)
|
||||||
|
return failure.status
|
||||||
|
except OSError as exc:
|
||||||
|
print(f'vpn-config-edit: {exc}', file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(f'{options.key} set to {json.dumps(value)} in {vpnrouter.CONFIG_FILE}.')
|
||||||
|
print('Apply it with: systemctl restart vpn-router-setup')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main(sys.argv[1:]))
|
||||||
@@ -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 %}
|
||||||
Reference in new issue
Block a user