Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9003bacb9 | ||
|
|
0bf78b16b1 | ||
|
|
0ff74cc8c9 | ||
|
|
a7b29e0655 | ||
|
|
c377801be0 | ||
|
|
a4cffb67e4 |
No files matched your search
@@ -36,58 +36,54 @@ network, by a person or by a monitor, needs a page or an endpoint. The router is
|
|||||||
internet-facing host, so what the service shows and where it listens matter.
|
internet-facing host, so what the service shows and where it listens matter.
|
||||||
|
|
||||||
Decision: The `vpn-router-web` unit runs one Python script that uses only the standard library, in
|
Decision: The `vpn-router-web` unit runs one Python script that uses only the standard library, in
|
||||||
the way of a small test server. It binds to the address of the internal interface and to
|
the way of a small test server. It listens on the address of the internal interface and on
|
||||||
`web.port`, on by default at 8080. `/` is for people, in plain text for `curl` and `wget` and as
|
`web.port`, 7081 by default, and is on unless `web.enabled` is `false` (ADR-7). `/` is for
|
||||||
a Tailwind CSS page for a browser, and `/status` is the same status as JSON. It shows the
|
people, in plain text for `curl` and `wget` and as a Tailwind CSS page for a browser, and
|
||||||
state of the services, the package version and the state of the site-to-site, road-warrior and
|
`/status` is the same status as JSON. It shows the state of the services, the package version
|
||||||
WireGuard features, and nothing from the configuration. `vpn-router-setup` resolves the
|
and the state of the site-to-site, road-warrior, OpenVPN and WireGuard features, and nothing from
|
||||||
interface address, writes it with the port and the features to `/run/vpn-router/web.json`,
|
the configuration. `vpn-router-setup` opens the port on the internal interface in UFW and writes
|
||||||
opens the port on the internal interface in UFW and restarts the service, so the service does
|
the features and the facts it resolves to `/run/vpn-router/web.json`, so the service does not
|
||||||
not need the package's configuration code, which needs Jinja2.
|
need the package's configuration code, which needs Jinja2.
|
||||||
|
|
||||||
Consequences: Nothing outside the internal network can reach it, since it listens on the internal
|
Consequences: Nothing outside the internal network can reach it, since it listens on the internal
|
||||||
address only and the firewall opens the port on the internal interface only. It has no
|
address only and the firewall opens the port on the internal interface only. `/` and `/status`
|
||||||
authentication, which is acceptable because it is read only and shows no configuration. It runs
|
have no authentication, which is acceptable because they are read only and show no
|
||||||
as root, because `swanctl` and `wg` need it, with `NoNewPrivileges`, `ProtectSystem=strict`,
|
configuration. It runs as root, because `swanctl` and `wg` need it, with `NoNewPrivileges`,
|
||||||
`ProtectHome` and `PrivateTmp`. The Tailwind style comes from a CDN, so a browser on a network
|
`ProtectSystem=strict`, `ProtectHome` and `PrivateTmp`. The Tailwind style comes from a CDN, so a
|
||||||
without internet shows the unstyled page, and `curl` is unaffected. A change of the internal
|
browser on a network without internet shows the unstyled page, and `curl` is unaffected.
|
||||||
address is picked up at the next run of `vpn-router-setup`.
|
|
||||||
|
|
||||||
## ADR-3: /config is authenticated with a short-lived JWT signed with a shared secret
|
## ADR-3: /config is authenticated with a short-lived JWT signed with a shared secret
|
||||||
|
|
||||||
Status: Accepted
|
Status: Accepted
|
||||||
|
|
||||||
Context: A client such as manage-ui needs the configuration and the resolved state of the
|
Context: A client such as the Router Manager needs the configuration and the resolved state of
|
||||||
router, which `/` and `/status` must not show. The service has only the standard library, listens
|
the router, which `/` and `/status` must not show. The service has only the standard library,
|
||||||
on plain HTTP on the internal network, and its configuration file is readable by root only.
|
listens on plain HTTP on the internal network, and its configuration file is readable by root
|
||||||
|
only.
|
||||||
|
|
||||||
Decision: `/config` needs a JWT in an `Authorization: Bearer` header. The service verifies HS256
|
Decision: `/config` needs a JWT in an `Authorization: Bearer` header. The service verifies HS256
|
||||||
tokens with `hmac` and `hashlib`, with the secret `web.jwt_secret`, and accepts no other
|
tokens with `hmac` and `hashlib`, with the secret `web.jwt_secret`, and accepts no other
|
||||||
algorithm, because verifying RS256 or ES256 needs a library outside the standard library. The
|
algorithm, because verifying RS256 or ES256 needs a library outside the standard library. The
|
||||||
token must carry `exp`, not more than ten minutes ahead, so that the client makes a fresh token for
|
token must carry `exp`, not more than ten minutes ahead, so that the client makes a fresh token for
|
||||||
each use and a leaked one expires. Without a secret the page is not served. The secrets in the
|
each use and a leaked one expires. Without a secret the page is not served. The configuration
|
||||||
configuration shown are masked by name (`psk`, and options with `secret`, `password`, `token` or a
|
shown has no secret options (ADR-8).
|
||||||
`_key` ending in the name).
|
|
||||||
|
|
||||||
Consequences: The client and the router share one secret, and whoever holds it can make tokens,
|
Consequences: The client and the router share one secret, and whoever holds it can make tokens,
|
||||||
so it is long and random; it is checked to have at least 32 characters. The secret is in the
|
so it is long and random; it is checked to have at least 32 characters. The secret is only in the
|
||||||
configuration file and in `/run/vpn-router/web.json`, both readable by root only. The token and
|
configuration file, readable by root only. The token and the answer cross the network
|
||||||
the answer cross the network unencrypted, so the page is for a trusted internal network; HTTPS
|
unencrypted, so the page is for a trusted internal network; HTTPS would need a certificate that
|
||||||
would need a certificate that the package does not have. The masking is by option name, so a new
|
the package does not have.
|
||||||
secret option needs a name that matches.
|
|
||||||
|
|
||||||
## ADR-4: Users are kept in a local OpenLDAP directory
|
## ADR-4: Users are kept in a local OpenLDAP directory
|
||||||
|
|
||||||
Status: Accepted
|
Status: Accepted
|
||||||
|
|
||||||
Context: OpenVPN needs user accounts with passwords, and the users are meant to cover every P2S
|
Context: OpenVPN needs user accounts with passwords, and the users are meant to cover every P2S
|
||||||
system of the router. The options weighed were an external OIDC provider (Entra ID, Keycloak)
|
system of the router, including IKEv2 EAP-TLS and WireGuard, which an OIDC provider cannot
|
||||||
with openvpn-auth-oauth2, an OIDC server embedded on the router, a file of password hashes, and
|
authenticate. Everything the router runs comes from the Ubuntu archive. slapd, its Python module
|
||||||
OpenLDAP on the router. OIDC for OpenVPN needs a browser on the client and a program from outside
|
`python3-ldap` and the OpenVPN plugin `openvpn-auth-ldap` are all in the archive; the plugin
|
||||||
the Ubuntu archive, and it cannot authenticate IKEv2 EAP-TLS or WireGuard. slapd, its Python
|
checks a password by binding as the user, so the hash scheme is slapd's alone. An OIDC provider
|
||||||
module `python3-ldap` and the OpenVPN plugin `openvpn-auth-ldap` are all in the archive; the
|
can read the directory through its LDAP connector.
|
||||||
plugin checks a password by binding as the user, so the hash scheme is slapd's alone. A directory
|
|
||||||
can also be read later by an OIDC provider through its LDAP connector.
|
|
||||||
|
|
||||||
Decision: The users are entries in slapd. The package keeps its own database,
|
Decision: The users are entries in slapd. The package keeps its own database,
|
||||||
`dc=vpn-router,dc=local` in `/var/lib/ldap/vpn-router` (the path the slapd AppArmor profile
|
`dc=vpn-router,dc=local` in `/var/lib/ldap/vpn-router` (the path the slapd AppArmor profile
|
||||||
@@ -101,19 +97,18 @@ slapd listens on the local socket only. Provisioning checks every step before ch
|
|||||||
runs at every start of `vpn-router-setup`.
|
runs at every start of `vpn-router-setup`.
|
||||||
|
|
||||||
Consequences: The package depends on `slapd`, `python3-ldap`, `openvpn` and
|
Consequences: The package depends on `slapd`, `python3-ldap`, `openvpn` and
|
||||||
`openvpn-auth-ldap`, and `vpn-router-setup` and `vpn-router-user` import `python3-ldap`, the first
|
`openvpn-auth-ldap`, and `vpn-router-setup` and `vpn-router-user` import `python3-ldap`. The
|
||||||
library outside the standard library besides `jinja2`. The service account's password is a
|
service account's password is a root-only file. The directory is user data: it is kept on purge,
|
||||||
root-only file. The directory is user data: it is kept when the features are switched off and
|
and it is backed up with `slapcat`, not by copying files.
|
||||||
on purge, and it is backed up with `slapcat`, not by copying files.
|
|
||||||
|
|
||||||
## ADR-5: OpenVPN clients need a certificate and the password of the user it names
|
## ADR-5: OpenVPN clients need a certificate and the password of the user it names
|
||||||
|
|
||||||
Status: Accepted
|
Status: Accepted
|
||||||
|
|
||||||
Context: The router CA already issues client certificates for IKEv2. OpenVPN can check a
|
Context: The router CA issues client certificates for IKEv2. OpenVPN can check a certificate, a
|
||||||
certificate, a password, or both. With both, a certificate alone and a password alone are not
|
password, or both. With both, a certificate alone and a password alone are not enough, but
|
||||||
enough, but `openvpn-auth-ldap` checks only the password of the user name given at login, which
|
`openvpn-auth-ldap` checks only the password of the user name given at login, which need not be
|
||||||
need not be the user of the certificate.
|
the user of the certificate.
|
||||||
|
|
||||||
Decision: OpenVPN checks the certificate against the CA and the CRL, the password with
|
Decision: OpenVPN checks the certificate against the CA and the CRL, the password with
|
||||||
`openvpn-auth-ldap`, and runs `openvpn-same-user` as `auth-user-pass-verify` with `via-env`,
|
`openvpn-auth-ldap`, and runs `openvpn-same-user` as `auth-user-pass-verify` with `via-env`,
|
||||||
@@ -144,7 +139,87 @@ lock, `/etc/vpn-router/pki` for the certificates and the CA's state, and `/etc/s
|
|||||||
for the published CRL. A lock serializes the program's runs, because `simple-ca` keeps its state
|
for the published CRL. A lock serializes the program's runs, because `simple-ca` keeps its state
|
||||||
in one file.
|
in one file.
|
||||||
|
|
||||||
Consequences: The web service still imports nothing outside the standard library, and the same
|
Consequences: The web service imports nothing outside the standard library, and the same program
|
||||||
program serves the command line and the API. Each request starts a process, which is fine for
|
serves the command line and the API. Each request starts a process, which is fine for user
|
||||||
user administration. The API is protected by the token of `/config`, so whoever has the secret
|
administration. The API is protected by the token of `/config`, so whoever has the secret can
|
||||||
can manage users.
|
manage users.
|
||||||
|
|
||||||
|
## ADR-7: The web service starts on its own and reads its inputs at each request
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
|
||||||
|
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,
|
||||||
|
`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
|
||||||
|
thread works out, every 30 seconds, the internal address from `vpn-router.json` as
|
||||||
|
`general.mode` directs, with the discovery code in `vpnrouter_net.py`, which uses only the
|
||||||
|
standard library and which `vpnrouter.py` uses too, and `web.port` and `web.enabled` from the
|
||||||
|
same file. It runs the HTTP server in a thread of its own at that address and port, stops it and
|
||||||
|
starts a new one when they change, and runs none while the service is disabled, the address is
|
||||||
|
not known or it cannot be bound. Each request reads the token secret from `vpn-router.json` and
|
||||||
|
the features and resolved facts from `web.json`, which setup writes, because they need the
|
||||||
|
configuration code; 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.
|
||||||
|
|
||||||
|
Consequences: The status page answers as soon as the internal address exists, whatever state
|
||||||
|
setup is in, and follows a changed address, port or `web.enabled` within 30 seconds without a
|
||||||
|
restart. A change of the secret applies to the next request. The interfaces are read every 30
|
||||||
|
seconds, which costs a few `ip` commands.
|
||||||
|
|
||||||
|
## ADR-8: /config leaves secret options out
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
|
||||||
|
Context: A secret has no use outside the router, so a client never needs it, and showing even
|
||||||
|
that a secret is set tells a reader more than it needs.
|
||||||
|
|
||||||
|
Decision: The configuration that `vpn-router-setup` writes for the web service has no secret
|
||||||
|
options at all. They are recognised by name: `psk`, and options with `secret`, `password`,
|
||||||
|
`token` or a `_key` ending in the name.
|
||||||
|
|
||||||
|
Consequences: No secret reaches the web service's state file or any answer of the API. A client
|
||||||
|
cannot tell whether a secret is set. A new secret option needs a name that matches.
|
||||||
|
|
||||||
|
## ADR-9: The Router Manager is a separate package with a small Python server
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
|
||||||
|
Context: The router needs a web application to manage it, which may run on the router or on
|
||||||
|
another Linux system. The router's web service needs a token signed with `web.jwt_secret` for
|
||||||
|
`/config` and `/users` and sends no CORS headers, so a browser cannot call it with the secret
|
||||||
|
kept away from the page.
|
||||||
|
|
||||||
|
Decision: `vpn-router-manager` is a Debian package of its own, built in the repository
|
||||||
|
`linux-cloud-router-manager`. A React and MUI application in the style of manage-ui is built
|
||||||
|
with Vite when the package is built and shipped as static files. A Python server, using the
|
||||||
|
Ubuntu packages `python3-jwt` and `python3-requests`, serves them and passes every `/api/<path>`
|
||||||
|
call to the router's `/<path>`, signing a short-lived token with the router's secret; it holds
|
||||||
|
no logic of the router's own. 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 address like the status web service, with the same discovery thread.
|
||||||
|
`vpn-router-setup` opens the manager's port on the internal interface while the manager is
|
||||||
|
installed, so the firewall stays in one place. The manager asks for no authentication.
|
||||||
|
|
||||||
|
Consequences: The router needs no Node.js; the package needs only `python3` and packages of the
|
||||||
|
Ubuntu archive. The secret never reaches the browser. The application checks the shape of every
|
||||||
|
answer of the router. Anyone who reaches the manager's port manages the router, so on another
|
||||||
|
system it listens on `127.0.0.1` unless told otherwise. The router's firewall depends on whether
|
||||||
|
the manager is installed, and a changed manager port needs `vpn-router-setup` to run again.
|
||||||
|
|
||||||
|
## ADR-10: Users and their certificates do not depend on a P2S system being enabled
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
|
||||||
|
Context: Users are prepared before a P2S system is switched on, and the Router Manager manages
|
||||||
|
them on any configured router, with or without one.
|
||||||
|
|
||||||
|
Decision: Once a platform is chosen, `vpn-router-setup` always provisions the user directory in
|
||||||
|
slapd and the CA with its CRL; the server certificate follows once `wan.local_fqdn` is set.
|
||||||
|
`/users` and `vpn-router-user` rely only on the directory and the CA. Only the OpenVPN client
|
||||||
|
profile needs OpenVPN to be enabled.
|
||||||
|
|
||||||
|
Consequences: slapd always holds the directory and the CA always exists on a configured router,
|
||||||
|
even when no P2S system uses them. An operator who wants a CA of their own must place it before
|
||||||
|
choosing the platform.
|
||||||
@@ -122,7 +122,7 @@ must have the type shown below; an unknown section or setting is an error.
|
|||||||
| `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 on the internal address |
|
||||||
| `web` | `port` | integer | `8080` | TCP port of the status web service |
|
| `web` | `port` | integer | `7081` | TCP port of the status web service |
|
||||||
| `web` | `jwt_secret` | string | | Secret, at least 32 characters, that signs the tokens for `/config`; without it `/config` is not served |
|
| `web` | `jwt_secret` | string | | Secret, at least 32 characters, that signs the tokens for `/config`; without it `/config` is not served |
|
||||||
|
|
||||||
A minimal site-to-site configuration in `auto` mode:
|
A minimal site-to-site configuration in `auto` mode:
|
||||||
@@ -272,12 +272,12 @@ ca_cert.pem ca_key.pem ca_bundle.pem <label>_cert.pem <label>_key.pem crl.p
|
|||||||
`<label>` is the first component of the router FQDN, so `router.example.com` gives
|
`<label>` is the first component of the router FQDN, so `router.example.com` gives
|
||||||
`router_cert.pem`.
|
`router_cert.pem`.
|
||||||
|
|
||||||
If road-warrior or OpenVPN access is enabled and this directory is empty, a certificate
|
Once a platform is chosen and while this directory is empty, a certificate authority and a CRL
|
||||||
authority, a server certificate and a CRL valid for ten years are created there
|
valid for ten years are created there automatically, whether or not road-warrior or OpenVPN
|
||||||
automatically; a CA without a CRL gets one. User certificates are issued by
|
access is enabled. A CA of the router's own (with `ca_key.pem`) without a CRL gets one, and
|
||||||
`vpn-router-user` into `users/`. To use your own instead, place the
|
without a server certificate gets one once `wan.local_fqdn` is set. User certificates are issued
|
||||||
files in that layout before enabling the feature; nothing is generated or overwritten when
|
by `vpn-router-user` into `users/`. To use your own CA instead, place the files in that layout
|
||||||
material is already present.
|
before choosing the platform; nothing present is overwritten.
|
||||||
|
|
||||||
Either way, the package copies what each consumer needs into place: the CA into
|
Either way, the package copies what each consumer needs into place: the CA into
|
||||||
`/etc/swanctl/x509ca/`, the server certificate and key into `/etc/swanctl/x509/` and
|
`/etc/swanctl/x509ca/`, the server certificate and key into `/etc/swanctl/x509/` and
|
||||||
@@ -314,8 +314,9 @@ certificate names:
|
|||||||
|
|
||||||
### The user directory
|
### The user directory
|
||||||
|
|
||||||
While OpenVPN or road-warrior access is configured, `vpn-router-setup` keeps a directory of
|
Once a platform is chosen, `vpn-router-setup` keeps a directory of users in the local slapd,
|
||||||
users in the local slapd:
|
whether or not OpenVPN or road-warrior access is enabled, so users can be managed before either
|
||||||
|
is switched on:
|
||||||
|
|
||||||
- its own database, `dc=vpn-router,dc=local` in `/var/lib/ldap/vpn-router`, next to the one the
|
- its own database, `dc=vpn-router,dc=local` in `/var/lib/ldap/vpn-router`, next to the one the
|
||||||
slapd package creates; root manages it through the local socket, with no administrator
|
slapd package creates; root manages it through the local socket, with no administrator
|
||||||
@@ -328,7 +329,7 @@ users in the local slapd:
|
|||||||
(`pwdAccountLockedTime`);
|
(`pwdAccountLockedTime`);
|
||||||
- slapd listens on its local socket only (`SLAPD_SERVICES` in `/etc/default/slapd`).
|
- slapd listens on its local socket only (`SLAPD_SERVICES` in `/etc/default/slapd`).
|
||||||
|
|
||||||
The directory is kept when OpenVPN and road-warrior access are switched off, and on purge.
|
The directory is kept on purge.
|
||||||
|
|
||||||
### Managing users
|
### Managing users
|
||||||
|
|
||||||
@@ -406,19 +407,19 @@ 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 its internal address and
|
||||||
`web.port` (8080 by default). It uses only the Python standard library, serves HTTP without
|
`web.port` (7081 by default). It uses only the Python standard library, serves HTTP without
|
||||||
authentication, and shows no configuration: no key, identity, address or subnet.
|
authentication, and shows no configuration: no key, identity, address or subnet.
|
||||||
|
|
||||||
| Page | Content |
|
| Page | Content |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `/` | The status for people: plain text for `curl` and `wget`, a page styled with Tailwind CSS for a browser, in the browser's light or dark scheme, that reloads itself every minute (the style sheet is loaded from a CDN, so the browser needs internet access; the text is readable without it) |
|
| `/` | The status for people: plain text for `curl` and `wget`, a page styled with Tailwind CSS for a browser, in the browser's light or dark scheme, that reloads itself every minute (the style sheet is loaded from a CDN, so the browser needs internet access; the text is readable without it) |
|
||||||
| `/status` | The same status as JSON |
|
| `/status` | The same status as JSON |
|
||||||
| `/config` | The configuration with its secrets masked, 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 |
|
||||||
|
|
||||||
The status holds the state of `vpn-router-setup`, of `strongswan` when site-to-site or road-warrior
|
The status holds the state of `vpn-router-setup` and `slapd`, of `strongswan` when site-to-site or
|
||||||
access is configured, of `wg-quick@wg0` when WireGuard is, of `openvpn-server@vpn-router` when
|
road-warrior access is configured, of `wg-quick@wg0` when WireGuard is, and of
|
||||||
OpenVPN is, and of `slapd` when either OpenVPN or road-warrior access is; the number of connected
|
`openvpn-server@vpn-router` when OpenVPN is; the number of connected
|
||||||
OpenVPN clients, from the server's status file, which OpenVPN rewrites every minute; the package version; whether
|
OpenVPN clients, from the server's status file, which OpenVPN rewrites every minute; the package version; whether
|
||||||
Docker is installed, and when it is, the state of `docker.service` and of `vpn-router-docker`
|
Docker is installed, and when it is, the state of `docker.service` and of `vpn-router-docker`
|
||||||
(the Docker rules), which do not change `status`; whether the
|
(the Docker rules), which do not change `status`; whether the
|
||||||
@@ -427,8 +428,8 @@ the WireGuard peers with the age of their last handshake and their traffic. `sta
|
|||||||
all these services are active, and `degraded` otherwise.
|
all these services are active, and `degraded` otherwise.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://10.0.0.4:8080/
|
curl http://10.0.0.4:7081/
|
||||||
curl http://10.0.0.4:8080/status
|
curl http://10.0.0.4:7081/status
|
||||||
```
|
```
|
||||||
|
|
||||||
### The /config page
|
### The /config page
|
||||||
@@ -437,7 +438,7 @@ curl http://10.0.0.4:8080/status
|
|||||||
|
|
||||||
| Key | Content |
|
| Key | Content |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `configuration` | `vpn-router.json` with the defaults applied and the secrets masked (`psk`, `jwt_secret`, and any option named like a password, token or key) |
|
| `configuration` | `vpn-router.json` with the defaults applied, without its secret options (`psk`, `jwt_secret`, and any option named like a password, token or key) |
|
||||||
| `network` | The external and internal interface, their addresses, the internal gateway, the local and remote IKE identities and subnets, and the IPv4 routes as `ip -j -4 route` prints them |
|
| `network` | The external and internal interface, their addresses, the internal gateway, the local and remote IKE identities and subnets, and the IPv4 routes as `ip -j -4 route` prints them |
|
||||||
| `ipsec` | `connections` and `sas`: one object per connection and per IKE SA, read from `swanctl --list-conns --pretty` and `swanctl --list-sas --pretty`, with the connection's name under `name` and swanctl's keys and string values |
|
| `ipsec` | `connections` and `sas`: one object per connection and per IKE SA, read from `swanctl --list-conns --pretty` and `swanctl --list-sas --pretty`, with the connection's name under `name` and swanctl's keys and string values |
|
||||||
| `wireguard` | The public key and port of `wg0`, and each peer's public key, endpoint, allowed IPs, handshake age and traffic; no private or preshared key |
|
| `wireguard` | The public key and port of `wg0`, and each peer's public key, endpoint, allowed IPs, handshake age and traffic; no private or preshared key |
|
||||||
@@ -465,7 +466,7 @@ print(f'{head}.{body}.{sign}')
|
|||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -H "Authorization: Bearer $TOKEN" http://10.0.0.4:8080/config
|
curl -H "Authorization: Bearer $TOKEN" http://10.0.0.4:7081/config
|
||||||
```
|
```
|
||||||
|
|
||||||
### The /users API
|
### The /users API
|
||||||
@@ -484,20 +485,32 @@ and answer bodies are JSON; a request body is at most 4096 bytes.
|
|||||||
| `GET /users/<name>/profile` | | `{"name", "profile"}`, the OpenVPN client profile |
|
| `GET /users/<name>/profile` | | `{"name", "profile"}`, the OpenVPN client profile |
|
||||||
|
|
||||||
A failure is `{"error": ...}` with 400 for an invalid request, 404 for a user that does not
|
A failure is `{"error": ...}` with 400 for an invalid request, 404 for a user that does not
|
||||||
exist, 409 for a user that exists and 500 otherwise. While neither OpenVPN nor road-warrior
|
exist, 409 for a user that exists and 500 otherwise. `/users` works whether or not OpenVPN or
|
||||||
access is configured, `/users` answers 404. Passwords and private keys cross the internal
|
road-warrior access is enabled; the profile needs OpenVPN. Passwords and private keys cross the internal
|
||||||
network in plain HTTP, like the token.
|
network in plain HTTP, like the token.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||||
-d '{"name": "alice", "password": "s3cr3t-pass"}' http://10.0.0.4:8080/users
|
-d '{"name": "alice", "password": "s3cr3t-pass"}' http://10.0.0.4:7081/users
|
||||||
```
|
```
|
||||||
|
|
||||||
`vpn-router-setup` opens `web.port` on the internal interface in the firewall, writes the address,
|
The service starts on its own, without waiting for `vpn-router-setup`, and keeps running. Every
|
||||||
the port, the secret and the facts for `/config` to `/run/vpn-router/web.json` (readable by root
|
30 seconds it works out the internal address from `vpn-router.json` as `general.mode` directs,
|
||||||
only) and restarts the service, so a changed `web` section
|
and reads `web.port` and `web.enabled`; it listens there, moves when the address or the port
|
||||||
applies with `systemctl restart vpn-router-setup`. The service ends without an error when
|
changes, and stops listening while `web.enabled` is `false` or the address is not known. Each
|
||||||
`web.enabled` is `false`, and keeps trying to start until the router has an internal address.
|
request reads the token secret from `vpn-router.json`, and the features and the facts for
|
||||||
|
`/config` from `/run/vpn-router/web.json` (readable by root only), which `vpn-router-setup` writes;
|
||||||
|
before that file exists the page shows no features. `vpn-router-setup` opens `web.port` on the
|
||||||
|
internal interface in the firewall.
|
||||||
|
|
||||||
|
## Router Manager
|
||||||
|
|
||||||
|
The `vpn-router-manager` package, built in the repository `linux-cloud-router-manager`, is a 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
|
||||||
|
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
|
||||||
|
`vpn-router-setup` after changing it.
|
||||||
|
|
||||||
## Removal
|
## Removal
|
||||||
|
|
||||||
|
|||||||
Whitespace-only changes.
@@ -1,3 +1,75 @@
|
|||||||
|
vpn-router (0.5.6-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
update: the status web service listens on port 7081 by default.
|
||||||
|
|
||||||
|
* web.port defaults to 7081.
|
||||||
|
* vpn-router-setup opens the Router Manager's port, 7080 by default, on
|
||||||
|
the internal interface.
|
||||||
|
|
||||||
|
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 18:00:00 +0200
|
||||||
|
|
||||||
|
vpn-router (0.5.5-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
feature: users and their certificates no longer need a P2S system enabled.
|
||||||
|
|
||||||
|
* vpn-router-setup provisions the user directory, the CA and its CRL once
|
||||||
|
a platform is chosen, whether or not OpenVPN or road-warrior access is
|
||||||
|
enabled. The server certificate is issued once wan.local_fqdn is set,
|
||||||
|
also for an existing CA that has none.
|
||||||
|
* /users and vpn-router-user manage users and issue and revoke their
|
||||||
|
certificates without a P2S system; only the OpenVPN client profile
|
||||||
|
needs OpenVPN.
|
||||||
|
|
||||||
|
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 16:00:00 +0200
|
||||||
|
|
||||||
|
vpn-router (0.5.4-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
feature: the firewall lets the Router Manager in on the internal interface.
|
||||||
|
|
||||||
|
* While vpn-router-manager is installed, vpn-router-setup opens its port,
|
||||||
|
port in /etc/vpn-router-manager/manager.json or 8090, on the internal
|
||||||
|
interface. Purging the package removes the rule.
|
||||||
|
|
||||||
|
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 13:00:00 +0200
|
||||||
|
|
||||||
|
vpn-router (0.5.3-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
fix: the status always shows the state of slapd.
|
||||||
|
|
||||||
|
* /status and the status page show the state of slapd whether or not
|
||||||
|
OpenVPN or road-warrior access is configured, since the package always
|
||||||
|
runs it.
|
||||||
|
|
||||||
|
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 12:00:00 +0200
|
||||||
|
|
||||||
|
vpn-router (0.5.2-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
fix: /config no longer shows secret options.
|
||||||
|
|
||||||
|
* The configuration shown by /config leaves out the secret options (psk,
|
||||||
|
jwt_secret, and options named like a password, token or key) instead of
|
||||||
|
showing them masked.
|
||||||
|
|
||||||
|
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 11:00:00 +0200
|
||||||
|
|
||||||
|
vpn-router (0.5.1-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
fix: the web service works in every state of the router.
|
||||||
|
|
||||||
|
* vpn-router-web works whether /etc/vpn-router/pki and
|
||||||
|
/etc/swanctl/x509crl exist or not; they exist only while OpenVPN or
|
||||||
|
road-warrior access is configured. The paths the unit may write are
|
||||||
|
used when they exist and ignored when they do not.
|
||||||
|
* vpn-router-web starts on its own and keeps running. Every 30 seconds a
|
||||||
|
discovery thread works out the internal address from vpn-router.json as
|
||||||
|
general.mode directs, with web.port and web.enabled, and starts, moves
|
||||||
|
or stops the server thread to match. Each request reads the token
|
||||||
|
secret, the features and the facts it shows. The service is no longer
|
||||||
|
ordered after vpn-router-setup, which no longer restarts it, and
|
||||||
|
web.json no longer holds the address, the port or the secret.
|
||||||
|
|
||||||
|
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 10:00:00 +0200
|
||||||
|
|
||||||
vpn-router (0.5.0-1) unstable; urgency=medium
|
vpn-router (0.5.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
* OpenVPN P2S access, set in the openvpn section (enabled, address_pool,
|
* OpenVPN P2S access, set in the openvpn section (enabled, address_pool,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
src/etc/sysctl.d/99-vpn-router.conf etc/sysctl.d/
|
src/etc/sysctl.d/99-vpn-router.conf etc/sysctl.d/
|
||||||
src/usr/lib/vpn-router/vpnrouter.py usr/lib/vpn-router/
|
src/usr/lib/vpn-router/vpnrouter.py usr/lib/vpn-router/
|
||||||
src/usr/lib/vpn-router/vpnrouter_ldap.py usr/lib/vpn-router/
|
src/usr/lib/vpn-router/vpnrouter_ldap.py usr/lib/vpn-router/
|
||||||
|
src/usr/lib/vpn-router/vpnrouter_net.py usr/lib/vpn-router/
|
||||||
src/usr/lib/vpn-router/openvpn-same-user usr/lib/vpn-router/
|
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/lib/vpn-router/simple-ca usr/lib/vpn-router/
|
src/usr/lib/vpn-router/simple-ca usr/lib/vpn-router/
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ strip_ufw_blocks() {
|
|||||||
|
|
||||||
tmp="$(mktemp)"
|
tmp="$(mktemp)"
|
||||||
awk '
|
awk '
|
||||||
/^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES START$/ {
|
/^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|MANAGER|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES START$/ {
|
||||||
skip = 1; pending = 0; next
|
skip = 1; pending = 0; next
|
||||||
}
|
}
|
||||||
/^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES END$/ {
|
/^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|MANAGER|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES END$/ {
|
||||||
skip = 0; next
|
skip = 0; next
|
||||||
}
|
}
|
||||||
skip { next }
|
skip { next }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=VPN Router status web service
|
Description=VPN Router status web service
|
||||||
Documentation=file:/usr/share/doc/vpn-router/README.md
|
Documentation=file:/usr/share/doc/vpn-router/README.md
|
||||||
After=network-online.target vpn-router-setup.service
|
After=network-online.target
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
@@ -11,7 +11,7 @@ Restart=on-failure
|
|||||||
RestartSec=15
|
RestartSec=15
|
||||||
NoNewPrivileges=yes
|
NoNewPrivileges=yes
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
ReadWritePaths=/run/vpn-router /etc/vpn-router/pki /etc/swanctl/x509crl
|
ReadWritePaths=-/run/vpn-router -/etc/vpn-router/pki -/etc/swanctl/x509crl
|
||||||
ProtectHome=yes
|
ProtectHome=yes
|
||||||
PrivateTmp=yes
|
PrivateTmp=yes
|
||||||
|
|
||||||
|
|||||||
@@ -31,9 +31,14 @@ UFW_DEFAULTS = pathlib.Path('/etc/default/ufw')
|
|||||||
WG_DIR = pathlib.Path('/etc/wireguard')
|
WG_DIR = pathlib.Path('/etc/wireguard')
|
||||||
WEB_STATE = pathlib.Path('/run/vpn-router/web.json')
|
WEB_STATE = pathlib.Path('/run/vpn-router/web.json')
|
||||||
|
|
||||||
|
#: The Router Manager, vpn-router-manager, when it is installed, and its settings.
|
||||||
|
MANAGER = pathlib.Path('/usr/lib/vpn-router-manager/manager')
|
||||||
|
MANAGER_CONFIG = pathlib.Path('/etc/vpn-router-manager/manager.json')
|
||||||
|
MANAGER_PORT = 7080
|
||||||
|
|
||||||
FILTER_ANCHOR = '# End required lines'
|
FILTER_ANCHOR = '# End required lines'
|
||||||
|
|
||||||
#: Configuration options whose values are masked wherever the configuration is shown.
|
#: Configuration options left out wherever the configuration is shown.
|
||||||
SECRET_OPTION = re.compile(r'(^psk$)|secret|password|token|_key$')
|
SECRET_OPTION = re.compile(r'(^psk$)|secret|password|token|_key$')
|
||||||
|
|
||||||
BLOCKS = (
|
BLOCKS = (
|
||||||
@@ -42,6 +47,7 @@ BLOCKS = (
|
|||||||
('# OPENVPN RULES START', '# OPENVPN RULES END'),
|
('# OPENVPN RULES START', '# OPENVPN RULES END'),
|
||||||
('# P2S DNS RULES START', '# P2S DNS RULES END'),
|
('# P2S DNS RULES START', '# P2S DNS RULES END'),
|
||||||
('# WEB RULES START', '# WEB RULES END'),
|
('# WEB RULES START', '# WEB RULES END'),
|
||||||
|
('# MANAGER RULES START', '# MANAGER RULES END'),
|
||||||
('# ROUTER FORWARD RULES START', '# ROUTER FORWARD RULES END'),
|
('# ROUTER FORWARD RULES START', '# ROUTER FORWARD RULES END'),
|
||||||
('# ROUTER NAT RULES START', '# ROUTER NAT RULES END'),
|
('# ROUTER NAT RULES START', '# ROUTER NAT RULES END'),
|
||||||
('# ROUTER MSS RULES START', '# ROUTER MSS RULES END'),
|
('# ROUTER MSS RULES START', '# ROUTER MSS RULES END'),
|
||||||
@@ -129,6 +135,15 @@ def is_configured(ctx):
|
|||||||
vpnrouter.openvpn_ready(ctx)))
|
vpnrouter.openvpn_ready(ctx)))
|
||||||
|
|
||||||
|
|
||||||
|
def manager_port():
|
||||||
|
"""The port of the Router Manager: port in its settings, or MANAGER_PORT when it is not set or not a port."""
|
||||||
|
try:
|
||||||
|
port = json.loads(MANAGER_CONFIG.read_text(encoding='utf-8')).get('port')
|
||||||
|
except (OSError, ValueError, AttributeError):
|
||||||
|
return MANAGER_PORT
|
||||||
|
return port if isinstance(port, int) and not isinstance(port, bool) and 1 <= port <= 65535 else MANAGER_PORT
|
||||||
|
|
||||||
|
|
||||||
def filter_block(ctx):
|
def filter_block(ctx):
|
||||||
lines = [
|
lines = [
|
||||||
'',
|
'',
|
||||||
@@ -185,6 +200,15 @@ def filter_block(ctx):
|
|||||||
'# WEB RULES END',
|
'# WEB RULES END',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if MANAGER.exists() and ctx['int_iface']:
|
||||||
|
port = manager_port()
|
||||||
|
lines += [
|
||||||
|
'',
|
||||||
|
'# MANAGER RULES START',
|
||||||
|
f'-A ufw-before-input -i {ctx["int_iface"]} -p tcp --dport {port} -j ACCEPT',
|
||||||
|
'# MANAGER 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']:
|
||||||
@@ -323,13 +347,8 @@ def manage_wireguard(ctx, changed):
|
|||||||
run(['systemctl', 'restart', unit], check=False)
|
run(['systemctl', 'restart', unit], check=False)
|
||||||
|
|
||||||
|
|
||||||
def manage_directory(ctx):
|
def manage_directory():
|
||||||
"""Provision the user directory in slapd while a P2S system that has users is configured.
|
"""Provision the user directory in slapd, whether or not a P2S system is configured."""
|
||||||
|
|
||||||
The directory is left in place when they are switched off, so the users are kept.
|
|
||||||
"""
|
|
||||||
if not (vpnrouter.p2s_ready(ctx) or vpnrouter.openvpn_ready(ctx)):
|
|
||||||
return
|
|
||||||
if vpnrouter_ldap.ensure_services() or not unit_is_active('slapd'):
|
if vpnrouter_ldap.ensure_services() or not unit_is_active('slapd'):
|
||||||
run(['systemctl', 'restart', 'slapd'])
|
run(['systemctl', 'restart', 'slapd'])
|
||||||
try:
|
try:
|
||||||
@@ -378,35 +397,27 @@ def refresh_docker_rules():
|
|||||||
|
|
||||||
|
|
||||||
def refresh_web(cfg, ctx):
|
def refresh_web(cfg, ctx):
|
||||||
"""Hand the web service what it needs and restart it.
|
"""Hand the web service the features and facts it shows.
|
||||||
|
|
||||||
The service takes the address to bind to, the port, the features of the
|
The service reads the file at each request: the features of the router,
|
||||||
router, the secret that signs its tokens, the configuration with its
|
the configuration without its secret options and the facts resolved from
|
||||||
secrets masked and the facts resolved from the system from a file here, so
|
the system, which it cannot work out with the standard library alone. The
|
||||||
that it needs nothing but the standard library and the configuration code.
|
file is readable by root only. The service follows a changed address or
|
||||||
The file is readable by root only. The service ends quietly when
|
port itself.
|
||||||
web.enabled is false. The restart is queued, not waited for, because the
|
|
||||||
unit is ordered after vpn-router-setup.
|
|
||||||
"""
|
"""
|
||||||
masked = {
|
public = {
|
||||||
section: {option: '********' if SECRET_OPTION.search(option) and value else value
|
section: {option: value for option, value in options.items() if not SECRET_OPTION.search(option)}
|
||||||
for option, value in options.items()}
|
|
||||||
for section, options in cfg.items()
|
for section, options in cfg.items()
|
||||||
}
|
}
|
||||||
WEB_STATE.parent.mkdir(parents=True, exist_ok=True)
|
WEB_STATE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
WEB_STATE.touch(mode=0o600)
|
WEB_STATE.touch(mode=0o600)
|
||||||
WEB_STATE.chmod(0o600)
|
WEB_STATE.chmod(0o600)
|
||||||
WEB_STATE.write_text(json.dumps({
|
WEB_STATE.write_text(json.dumps({
|
||||||
'enabled': bool(ctx['web_enabled']),
|
|
||||||
'bind': ctx['int_addr'],
|
|
||||||
'port': ctx['web_port'],
|
|
||||||
'jwt_secret': cfg['web']['jwt_secret'],
|
|
||||||
'site_to_site': bool(vpnrouter.s2s_ready(ctx)),
|
'site_to_site': bool(vpnrouter.s2s_ready(ctx)),
|
||||||
'p2s': bool(vpnrouter.p2s_ready(ctx)),
|
'p2s': bool(vpnrouter.p2s_ready(ctx)),
|
||||||
'wireguard': bool(vpnrouter.wg_ready(ctx)),
|
'wireguard': bool(vpnrouter.wg_ready(ctx)),
|
||||||
'openvpn': bool(vpnrouter.openvpn_ready(ctx)),
|
'openvpn': bool(vpnrouter.openvpn_ready(ctx)),
|
||||||
'users': bool(vpnrouter.p2s_ready(ctx) or vpnrouter.openvpn_ready(ctx)),
|
'configuration': public,
|
||||||
'configuration': masked,
|
|
||||||
'network': {
|
'network': {
|
||||||
'external_interface': ctx['wan_iface'],
|
'external_interface': ctx['wan_iface'],
|
||||||
'internal_interface': ctx['int_iface'],
|
'internal_interface': ctx['int_iface'],
|
||||||
@@ -419,7 +430,6 @@ def refresh_web(cfg, ctx):
|
|||||||
'remote_cidrs': ctx['remote_cidr_list'],
|
'remote_cidrs': ctx['remote_cidr_list'],
|
||||||
},
|
},
|
||||||
}) + '\n', encoding='utf-8')
|
}) + '\n', encoding='utf-8')
|
||||||
run(['systemctl', 'restart', '--no-block', 'vpn-router-web.service'], check=False)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -453,7 +463,7 @@ def main():
|
|||||||
elif rules_changed:
|
elif rules_changed:
|
||||||
run(['ufw', 'reload'], check=False)
|
run(['ufw', 'reload'], check=False)
|
||||||
|
|
||||||
manage_directory(ctx)
|
manage_directory()
|
||||||
manage_strongswan(ctx, changed)
|
manage_strongswan(ctx, changed)
|
||||||
manage_wireguard(ctx, changed)
|
manage_wireguard(ctx, changed)
|
||||||
manage_openvpn(ctx, changed)
|
manage_openvpn(ctx, changed)
|
||||||
|
|||||||
@@ -21,6 +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
|
||||||
|
|
||||||
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')
|
||||||
@@ -53,7 +54,7 @@ SCHEMA = {
|
|||||||
'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820},
|
'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820},
|
||||||
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
|
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
|
||||||
'docker': {'mode': 'auto', 'exposed_ports': []},
|
'docker': {'mode': 'auto', 'exposed_ports': []},
|
||||||
'web': {'enabled': True, 'port': 8080, 'jwt_secret': ''},
|
'web': {'enabled': True, 'port': 7081, 'jwt_secret': ''},
|
||||||
}
|
}
|
||||||
|
|
||||||
#: The shortest secret that signs the tokens of the status web service.
|
#: The shortest secret that signs the tokens of the status web service.
|
||||||
@@ -92,10 +93,6 @@ MODES = ('manual', 'interfaces', 'auto')
|
|||||||
PLATFORM_NONE = 'none'
|
PLATFORM_NONE = 'none'
|
||||||
|
|
||||||
|
|
||||||
class ConfigError(Exception):
|
|
||||||
"""A setting is present but malformed."""
|
|
||||||
|
|
||||||
|
|
||||||
def warn(message):
|
def warn(message):
|
||||||
print(f'vpn-router: warning: {message}', file=sys.stderr)
|
print(f'vpn-router: warning: {message}', file=sys.stderr)
|
||||||
|
|
||||||
@@ -327,55 +324,6 @@ def deferred(cfg):
|
|||||||
return (_get(cfg, 'general', 'platform') or PLATFORM_NONE) == PLATFORM_NONE
|
return (_get(cfg, 'general', 'platform') or PLATFORM_NONE) == PLATFORM_NONE
|
||||||
|
|
||||||
|
|
||||||
def _ip_route(*args):
|
|
||||||
result = subprocess.run(['ip', *args], capture_output=True, text=True)
|
|
||||||
return result.stdout if result.returncode == 0 else ''
|
|
||||||
|
|
||||||
|
|
||||||
def detect_interfaces():
|
|
||||||
"""Work out the external and internal interface names.
|
|
||||||
|
|
||||||
Only reached in auto mode, where the operator has accepted that a wrong
|
|
||||||
guess is possible. The external interface is whichever one carries the
|
|
||||||
default route; the internal one is the only other addressed interface.
|
|
||||||
"""
|
|
||||||
tokens = _ip_route('route', 'get', '1.1.1.1').split()
|
|
||||||
external = ''
|
|
||||||
for index, token in enumerate(tokens):
|
|
||||||
if token == 'dev' and index + 1 < len(tokens):
|
|
||||||
external = tokens[index + 1]
|
|
||||||
break
|
|
||||||
if not external:
|
|
||||||
raise ConfigError('auto mode: cannot determine the external interface')
|
|
||||||
|
|
||||||
candidates = [name for name in _addressed_interfaces() if name != external]
|
|
||||||
if len(candidates) != 1:
|
|
||||||
raise ConfigError(
|
|
||||||
f'auto mode: expected one internal interface, found {len(candidates)}; '
|
|
||||||
'use mode = interfaces and name them')
|
|
||||||
return external, candidates[0]
|
|
||||||
|
|
||||||
|
|
||||||
def _is_virtual(name):
|
|
||||||
"""True for an interface the kernel creates without a device behind it.
|
|
||||||
|
|
||||||
Loopback, bridges, veth pairs and tunnels live under /sys/devices/virtual,
|
|
||||||
which is where the Docker bridge is; a NIC of the virtual machine does not.
|
|
||||||
"""
|
|
||||||
link = pathlib.Path('/sys/class/net') / name
|
|
||||||
return '/devices/virtual/' in str(link.resolve())
|
|
||||||
|
|
||||||
|
|
||||||
def _addressed_interfaces():
|
|
||||||
"""Names of the NICs holding at least one IPv4 address; virtual interfaces are left out."""
|
|
||||||
names = []
|
|
||||||
for line in _ip_route('-o', '-4', 'addr', 'show').splitlines():
|
|
||||||
fields = line.split()
|
|
||||||
if len(fields) >= 4 and fields[1] not in names and not _is_virtual(fields[1]):
|
|
||||||
names.append(fields[1])
|
|
||||||
return names
|
|
||||||
|
|
||||||
|
|
||||||
def detect_gateway(iface, address):
|
def detect_gateway(iface, address):
|
||||||
"""Derive the next hop on the internal side.
|
"""Derive the next hop on the internal side.
|
||||||
|
|
||||||
@@ -383,12 +331,12 @@ def detect_gateway(iface, address):
|
|||||||
back to the first host address of the connected subnet, which is a
|
back to the first host address of the connected subnet, which is a
|
||||||
convention rather than a fact - so say so.
|
convention rather than a fact - so say so.
|
||||||
"""
|
"""
|
||||||
for line in _ip_route('-o', 'route', 'show', 'dev', iface).splitlines():
|
for line in ip_output('-o', 'route', 'show', 'dev', iface).splitlines():
|
||||||
fields = line.split()
|
fields = line.split()
|
||||||
if 'via' in fields:
|
if 'via' in fields:
|
||||||
return fields[fields.index('via') + 1]
|
return fields[fields.index('via') + 1]
|
||||||
|
|
||||||
for line in _ip_route('-o', '-4', 'addr', 'show', 'dev', iface).splitlines():
|
for line in ip_output('-o', '-4', 'addr', 'show', 'dev', iface).splitlines():
|
||||||
fields = line.split()
|
fields = line.split()
|
||||||
if len(fields) >= 4 and fields[3].split('/')[0] == address:
|
if len(fields) >= 4 and fields[3].split('/')[0] == address:
|
||||||
network = ipaddress.ip_interface(fields[3]).network
|
network = ipaddress.ip_interface(fields[3]).network
|
||||||
@@ -399,34 +347,6 @@ def detect_gateway(iface, address):
|
|||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
|
||||||
def interface_addresses(name, setting):
|
|
||||||
"""IPv4 addresses configured on an interface.
|
|
||||||
|
|
||||||
The interface name is configuration; its addresses are read from the
|
|
||||||
system. Looking up the addresses of a named interface is deterministic -
|
|
||||||
unlike working out which interface to use, which is a guess the package
|
|
||||||
deliberately does not make.
|
|
||||||
"""
|
|
||||||
if not name:
|
|
||||||
return []
|
|
||||||
if not (pathlib.Path('/sys/class/net') / name).exists():
|
|
||||||
raise ConfigError(f'{setting}: interface {name!r} does not exist')
|
|
||||||
|
|
||||||
result = subprocess.run(
|
|
||||||
['ip', '-o', '-4', 'addr', 'show', 'dev', name],
|
|
||||||
capture_output=True, text=True,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise ConfigError(f'{setting}: cannot read addresses of {name!r}')
|
|
||||||
|
|
||||||
addresses = []
|
|
||||||
for line in result.stdout.splitlines():
|
|
||||||
fields = line.split()
|
|
||||||
if len(fields) >= 4:
|
|
||||||
addresses.append(fields[3].split('/')[0])
|
|
||||||
return addresses
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_interfaces(cfg):
|
def resolve_interfaces(cfg):
|
||||||
"""Return (ext_iface, int_iface, local_addrs, int_addr, int_gateway_ip).
|
"""Return (ext_iface, int_iface, local_addrs, int_addr, int_gateway_ip).
|
||||||
|
|
||||||
@@ -645,24 +565,28 @@ def _simple_ca(*args):
|
|||||||
|
|
||||||
|
|
||||||
def provision_pki(ctx):
|
def provision_pki(ctx):
|
||||||
"""Create the CA, server certificate and CRL when the PKI directory is empty; a CA without a CRL gets one."""
|
"""Create the CA and CRL when the PKI directory is empty, and the server certificate once wan.local_fqdn is set.
|
||||||
|
|
||||||
|
A CA of the router's own (ca_key.pem present) without a CRL gets one, and without a
|
||||||
|
certificate for wan.local_fqdn gets one too; nothing present is overwritten.
|
||||||
|
"""
|
||||||
PKI_DIR.mkdir(parents=True, exist_ok=True)
|
PKI_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
os.chmod(PKI_DIR, 0o700)
|
os.chmod(PKI_DIR, 0o700)
|
||||||
|
|
||||||
if any(PKI_DIR.iterdir()):
|
|
||||||
if (PKI_DIR / 'ca_key.pem').is_file() and not (PKI_DIR / 'crl.pem').is_file():
|
|
||||||
_simple_ca('make-crl', '--ca-dir', str(PKI_DIR), '--days', str(CRL_DAYS))
|
|
||||||
return
|
|
||||||
if not ctx['p2s_ca_name']:
|
|
||||||
warn('p2s.ca_name is empty, not creating a local CA')
|
|
||||||
return
|
|
||||||
if not SIMPLE_CA.is_file():
|
if not SIMPLE_CA.is_file():
|
||||||
fail(f'{SIMPLE_CA} is missing')
|
fail(f'{SIMPLE_CA} is missing')
|
||||||
|
|
||||||
_simple_ca('make-ca', '--ca-dir', str(PKI_DIR), ctx['p2s_ca_name'])
|
if not any(PKI_DIR.iterdir()):
|
||||||
_simple_ca('make-cert', '--ca-dir', str(PKI_DIR),
|
if not ctx['p2s_ca_name']:
|
||||||
ctx['local_fqdn'], ctx['local_fqdn'])
|
warn('p2s.ca_name is empty, not creating a local CA')
|
||||||
_simple_ca('make-crl', '--ca-dir', str(PKI_DIR), '--days', str(CRL_DAYS))
|
return
|
||||||
|
_simple_ca('make-ca', '--ca-dir', str(PKI_DIR), ctx['p2s_ca_name'])
|
||||||
|
if not (PKI_DIR / 'ca_key.pem').is_file():
|
||||||
|
return
|
||||||
|
if not (PKI_DIR / 'crl.pem').is_file():
|
||||||
|
_simple_ca('make-crl', '--ca-dir', str(PKI_DIR), '--days', str(CRL_DAYS))
|
||||||
|
label = ctx['local_fqdn'].split('.', 1)[0] if ctx['local_fqdn'] else ''
|
||||||
|
if label and not (PKI_DIR / f'{label}_cert.pem').is_file():
|
||||||
|
_simple_ca('make-cert', '--ca-dir', str(PKI_DIR), ctx['local_fqdn'], ctx['local_fqdn'])
|
||||||
|
|
||||||
|
|
||||||
def distribute_pki(ctx):
|
def distribute_pki(ctx):
|
||||||
@@ -708,8 +632,7 @@ def configure(cfg=None, local_id_override=None):
|
|||||||
|
|
||||||
changed = set()
|
changed = set()
|
||||||
|
|
||||||
if p2s_ready(ctx) or openvpn_ready(ctx):
|
provision_pki(ctx)
|
||||||
provision_pki(ctx)
|
|
||||||
if distribute_pki(ctx):
|
if distribute_pki(ctx):
|
||||||
changed.add('swanctl')
|
changed.add('swanctl')
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Interfaces and addresses of the router, read from the system.
|
||||||
|
|
||||||
|
Only the Python standard library is used, so the web service can use this module too.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(Exception):
|
||||||
|
"""A setting is present but malformed."""
|
||||||
|
|
||||||
|
|
||||||
|
def ip_output(*args):
|
||||||
|
"""The output of an ip command, or '' when it fails."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(['ip', *args], capture_output=True, text=True)
|
||||||
|
except OSError:
|
||||||
|
return ''
|
||||||
|
return result.stdout if result.returncode == 0 else ''
|
||||||
|
|
||||||
|
|
||||||
|
def detect_interfaces():
|
||||||
|
"""Work out the external and internal interface names.
|
||||||
|
|
||||||
|
Only reached in auto mode, where the operator has accepted that a wrong
|
||||||
|
guess is possible. The external interface is whichever one carries the
|
||||||
|
default route; the internal one is the only other addressed interface.
|
||||||
|
"""
|
||||||
|
tokens = ip_output('route', 'get', '1.1.1.1').split()
|
||||||
|
external = ''
|
||||||
|
for index, token in enumerate(tokens):
|
||||||
|
if token == 'dev' and index + 1 < len(tokens):
|
||||||
|
external = tokens[index + 1]
|
||||||
|
break
|
||||||
|
if not external:
|
||||||
|
raise ConfigError('auto mode: cannot determine the external interface')
|
||||||
|
|
||||||
|
candidates = [name for name in _addressed_interfaces() if name != external]
|
||||||
|
if len(candidates) != 1:
|
||||||
|
raise ConfigError(
|
||||||
|
f'auto mode: expected one internal interface, found {len(candidates)}; '
|
||||||
|
'use mode = interfaces and name them')
|
||||||
|
return external, candidates[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_virtual(name):
|
||||||
|
"""True for an interface the kernel creates without a device behind it.
|
||||||
|
|
||||||
|
Loopback, bridges, veth pairs and tunnels live under /sys/devices/virtual,
|
||||||
|
which is where the Docker bridge is; a NIC of the virtual machine does not.
|
||||||
|
"""
|
||||||
|
link = pathlib.Path('/sys/class/net') / name
|
||||||
|
return '/devices/virtual/' in str(link.resolve())
|
||||||
|
|
||||||
|
|
||||||
|
def _addressed_interfaces():
|
||||||
|
"""Names of the NICs holding at least one IPv4 address; virtual interfaces are left out."""
|
||||||
|
names = []
|
||||||
|
for line in ip_output('-o', '-4', 'addr', 'show').splitlines():
|
||||||
|
fields = line.split()
|
||||||
|
if len(fields) >= 4 and fields[1] not in names and not _is_virtual(fields[1]):
|
||||||
|
names.append(fields[1])
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def interface_addresses(name, setting):
|
||||||
|
"""IPv4 addresses configured on an interface.
|
||||||
|
|
||||||
|
The interface name is configuration; its addresses are read from the
|
||||||
|
system. Looking up the addresses of a named interface is deterministic -
|
||||||
|
unlike working out which interface to use, which is a guess the package
|
||||||
|
deliberately does not make.
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
return []
|
||||||
|
if not (pathlib.Path('/sys/class/net') / name).exists():
|
||||||
|
raise ConfigError(f'{setting}: interface {name!r} does not exist')
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
['ip', '-o', '-4', 'addr', 'show', 'dev', name],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise ConfigError(f'{setting}: cannot read addresses of {name!r}')
|
||||||
|
|
||||||
|
addresses = []
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
fields = line.split()
|
||||||
|
if len(fields) >= 4:
|
||||||
|
addresses.append(fields[3].split('/')[0])
|
||||||
|
return addresses
|
||||||
|
|
||||||
|
|
||||||
|
def internal_address(mode, internal, int_addr):
|
||||||
|
"""The router's address on the internal network, as general.mode directs, or '' when it is not known yet.
|
||||||
|
|
||||||
|
manual takes local.int_addr; interfaces reads the address of interfaces.internal; auto also
|
||||||
|
works out which interface that is.
|
||||||
|
"""
|
||||||
|
if mode == 'manual':
|
||||||
|
return int_addr
|
||||||
|
try:
|
||||||
|
if mode == 'auto':
|
||||||
|
internal = detect_interfaces()[1]
|
||||||
|
addresses = interface_addresses(internal, 'interfaces.internal')
|
||||||
|
except ConfigError:
|
||||||
|
return ''
|
||||||
|
return addresses[0] if addresses else ''
|
||||||
@@ -10,7 +10,7 @@ Pages:
|
|||||||
/status the status as JSON
|
/status the status as JSON
|
||||||
/config the configuration without its secrets, and what the router resolved and
|
/config the configuration without its secrets, and what the router resolved and
|
||||||
runs, as JSON
|
runs, as JSON
|
||||||
/users the users of the P2S systems: list, add, set the password, disable, enable,
|
/users the users in the router's directory, whether or not a P2S system is enabled: list, add, set the password, disable, enable,
|
||||||
remove, and the OpenVPN client profile (see users())
|
remove, and the OpenVPN client profile (see users())
|
||||||
|
|
||||||
/config and /users need an HS256 JWT in an Authorization: Bearer header that is signed
|
/config and /users need an HS256 JWT in an Authorization: Bearer header that is signed
|
||||||
@@ -21,12 +21,14 @@ web.jwt_secret is not set.
|
|||||||
Docker is installed, and the state of site-to-site, road-warrior (IPsec P2S), OpenVPN and
|
Docker is installed, and the state of site-to-site, road-warrior (IPsec P2S), OpenVPN and
|
||||||
WireGuard. No configuration data, key or address is shown.
|
WireGuard. No configuration data, key or address is shown.
|
||||||
|
|
||||||
The address to bind to, the port, the secret and the configuration shown by /config
|
The service needs nothing else to start and does not end on its own. A discovery thread works
|
||||||
are not read from the configuration here: vpn-router-setup resolves them, with the
|
out the internal address from /etc/vpn-router/vpn-router.json as general.mode directs, with
|
||||||
interfaces, and writes them to /run/vpn-router/web.json, readable by root only, before
|
web.port and web.enabled, every DISCOVERY_SECONDS, and runs the server thread there: it starts
|
||||||
it restarts this service. The service ends with
|
it, moves it when the address or the port changes, and stops it while the service is disabled
|
||||||
success when the file says the service is disabled, and with failure, to be
|
or the address is not known. Every request reads what it needs when it is made: the token secret
|
||||||
started again, when the file is missing or the router has no internal address yet.
|
from 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
|
||||||
|
the router has no features, and /config shows no configuration or interfaces.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
@@ -42,7 +44,18 @@ import time
|
|||||||
from html import escape
|
from html import escape
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
|
||||||
|
|
||||||
|
import vpnrouter_net
|
||||||
|
|
||||||
STATE_FILE = '/run/vpn-router/web.json'
|
STATE_FILE = '/run/vpn-router/web.json'
|
||||||
|
CONFIG_FILE = '/etc/vpn-router/vpn-router.json'
|
||||||
|
|
||||||
|
#: The port when web.port is not set.
|
||||||
|
DEFAULT_PORT = 7081
|
||||||
|
|
||||||
|
#: Seconds between two discoveries of where the service should listen.
|
||||||
|
DISCOVERY_SECONDS = 30
|
||||||
COMMAND_TIMEOUT = 5
|
COMMAND_TIMEOUT = 5
|
||||||
CACHE_SECONDS = 2
|
CACHE_SECONDS = 2
|
||||||
|
|
||||||
@@ -73,6 +86,33 @@ _cache = {'at': 0.0, 'data': None}
|
|||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def read_json(path):
|
||||||
|
"""The object in a JSON file, or {} when the file is missing or holds no object."""
|
||||||
|
try:
|
||||||
|
with open(path, encoding='utf-8') as handle:
|
||||||
|
data = json.load(handle)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def section(configuration, name):
|
||||||
|
"""A section of the configuration, or {} when it is missing or not an object."""
|
||||||
|
value = configuration.get(name)
|
||||||
|
return value if isinstance(value, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def current():
|
||||||
|
"""What a request needs, read now: the features and facts setup resolved, and the token secret.
|
||||||
|
|
||||||
|
Before setup has run the router has no features, and without a secret /config and /users are
|
||||||
|
not served.
|
||||||
|
"""
|
||||||
|
state = read_json(STATE_FILE)
|
||||||
|
secret = section(read_json(CONFIG_FILE), 'web').get('jwt_secret')
|
||||||
|
return dict(state, jwt_secret=secret if isinstance(secret, str) else '')
|
||||||
|
|
||||||
|
|
||||||
def run(*args):
|
def run(*args):
|
||||||
"""The finished process of a command, or None when it cannot be run."""
|
"""The finished process of a command, or None when it cannot be run."""
|
||||||
try:
|
try:
|
||||||
@@ -184,15 +224,13 @@ def openvpn_clients():
|
|||||||
|
|
||||||
def collect(state):
|
def collect(state):
|
||||||
"""The status document. state says which features the router has."""
|
"""The status document. state says which features the router has."""
|
||||||
units = {'vpn-router-setup': unit_state('vpn-router-setup.service')}
|
units = {'vpn-router-setup': unit_state('vpn-router-setup.service'), 'slapd': unit_state('slapd.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'):
|
||||||
units['wg-quick@wg0'] = unit_state('wg-quick@wg0.service')
|
units['wg-quick@wg0'] = unit_state('wg-quick@wg0.service')
|
||||||
if state.get('openvpn'):
|
if state.get('openvpn'):
|
||||||
units['openvpn-server@vpn-router'] = unit_state('openvpn-server@vpn-router.service')
|
units['openvpn-server@vpn-router'] = unit_state('openvpn-server@vpn-router.service')
|
||||||
if state.get('users'):
|
|
||||||
units['slapd'] = unit_state('slapd.service')
|
|
||||||
|
|
||||||
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',
|
||||||
@@ -395,8 +433,6 @@ def users(state, method, path, body):
|
|||||||
DELETE /users/<name> remove the user and revoke the certificate
|
DELETE /users/<name> remove the user and revoke the certificate
|
||||||
GET /users/<name>/profile the OpenVPN client profile
|
GET /users/<name>/profile the OpenVPN client profile
|
||||||
"""
|
"""
|
||||||
if not state.get('users'):
|
|
||||||
return 404, {'error': 'no P2S system with users is configured'}
|
|
||||||
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||||
return 400, {'error': 'the body must be a JSON object'}
|
return 400, {'error': 'the body must be a JSON object'}
|
||||||
body = body or {}
|
body = body or {}
|
||||||
@@ -433,13 +469,12 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
def status(self):
|
def status(self):
|
||||||
with _lock:
|
with _lock:
|
||||||
if _cache['data'] is None or time.monotonic() - _cache['at'] > CACHE_SECONDS:
|
if _cache['data'] is None or time.monotonic() - _cache['at'] > CACHE_SECONDS:
|
||||||
_cache['data'] = collect(self.server.state)
|
_cache['data'] = collect(current())
|
||||||
_cache['at'] = time.monotonic()
|
_cache['at'] = time.monotonic()
|
||||||
return _cache['data']
|
return _cache['data']
|
||||||
|
|
||||||
def authorized(self):
|
def authorized(self, secret):
|
||||||
"""Whether the request carries a token signed with the secret; False also while no secret is set."""
|
"""Whether the request carries a token signed with the secret; False also while no secret is set."""
|
||||||
secret = self.server.state.get('jwt_secret')
|
|
||||||
header = self.headers.get('Authorization', '')
|
header = self.headers.get('Authorization', '')
|
||||||
return bool(secret) and header.startswith('Bearer ') and verify(header[7:].strip(), secret) is not None
|
return bool(secret) and header.startswith('Bearer ') and verify(header[7:].strip(), secret) is not None
|
||||||
|
|
||||||
@@ -449,16 +484,17 @@ 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
|
||||||
protected =path == '/config' or path == '/users' or path.startswith('/users/')
|
protected = path == '/config' or path == '/users' or path.startswith('/users/')
|
||||||
if protected and not self.server.state.get('jwt_secret'):
|
state = current() if protected else {}
|
||||||
|
if protected and not state['jwt_secret']:
|
||||||
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
|
||||||
elif protected and not self.authorized():
|
elif protected and not self.authorized(state['jwt_secret']):
|
||||||
body, kind, code = b'Unauthorized\n', 'text/plain; charset=utf-8', 401
|
body, kind, code = b'Unauthorized\n', 'text/plain; charset=utf-8', 401
|
||||||
extra.append(('WWW-Authenticate', 'Bearer'))
|
extra.append(('WWW-Authenticate', 'Bearer'))
|
||||||
elif path == '/config' and self.command in ('GET', 'HEAD'):
|
elif path == '/config' and self.command in ('GET', 'HEAD'):
|
||||||
body, kind, code = json.dumps(details(self.server.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 protected and path != '/config':
|
elif protected and path != '/config':
|
||||||
code, document = users(self.server.state, self.command, path, self.request_body())
|
code, document = users(state, self.command, path, self.request_body())
|
||||||
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
|
||||||
@@ -515,31 +551,65 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
sys.stderr.write('%s - - [%s] %s\n' % (self.client_address[0], self.log_date_time_string(), format % args))
|
sys.stderr.write('%s - - [%s] %s\n' % (self.client_address[0], self.log_date_time_string(), format % args))
|
||||||
|
|
||||||
|
|
||||||
|
def wanted():
|
||||||
|
"""Where the service should listen now, as (address, port), or the reason it should not listen."""
|
||||||
|
configuration = read_json(CONFIG_FILE)
|
||||||
|
web = section(configuration, 'web')
|
||||||
|
if web.get('enabled', True) is False:
|
||||||
|
return None, 'the web service is disabled in the configuration'
|
||||||
|
port = web.get('port', DEFAULT_PORT)
|
||||||
|
if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
|
||||||
|
port = DEFAULT_PORT
|
||||||
|
general, interfaces, local = (section(configuration, name) for name in ('general', 'interfaces', 'local'))
|
||||||
|
address = vpnrouter_net.internal_address(str(general.get('mode') or 'manual'), str(interfaces.get('internal') or ''),
|
||||||
|
str(local.get('int_addr') or ''))
|
||||||
|
if not address:
|
||||||
|
return None, 'the internal address is not known yet'
|
||||||
|
return (address, port), ''
|
||||||
|
|
||||||
|
|
||||||
|
def log(message):
|
||||||
|
print(f'vpn-router-web: {message}', file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def discover():
|
||||||
|
"""Work out where to listen every DISCOVERY_SECONDS, and run the server thread there.
|
||||||
|
|
||||||
|
When the address or the port changes, the server thread is stopped and a new one listens at
|
||||||
|
the new place; while the service is disabled, the address is not known or it cannot be bound,
|
||||||
|
no server runs and the next round tries again.
|
||||||
|
"""
|
||||||
|
server, thread, listening, reported = None, None, None, None
|
||||||
|
while True:
|
||||||
|
target, reason = wanted()
|
||||||
|
if target != listening and server is not None:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
thread.join()
|
||||||
|
server, listening = None, None
|
||||||
|
if target is not None and server is None:
|
||||||
|
try:
|
||||||
|
server = ThreadingHTTPServer(target, Handler)
|
||||||
|
except OSError as exc:
|
||||||
|
reason = f'cannot listen on {target[0]}:{target[1]}: {exc}'
|
||||||
|
else:
|
||||||
|
thread = threading.Thread(target=server.serve_forever, name='server', daemon=True)
|
||||||
|
thread.start()
|
||||||
|
listening, reason = target, f'serving on http://{target[0]}:{target[1]}'
|
||||||
|
if reason and reason != reported:
|
||||||
|
log(reason)
|
||||||
|
reported = reason
|
||||||
|
time.sleep(DISCOVERY_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
try:
|
|
||||||
with open(STATE_FILE, encoding='utf-8') as handle:
|
|
||||||
state = json.load(handle)
|
|
||||||
except (OSError, ValueError) as exc:
|
|
||||||
print(f'vpn-router-web: cannot read {STATE_FILE}: {exc}', file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
if not state.get('enabled'):
|
|
||||||
print('vpn-router-web: the web service is disabled in the configuration')
|
|
||||||
return 0
|
|
||||||
if not state.get('bind'):
|
|
||||||
print('vpn-router-web: the router has no internal address yet', file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
server = ThreadingHTTPServer((state['bind'], state['port']), Handler)
|
|
||||||
server.state = state
|
|
||||||
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
||||||
print(f'vpn-router-web: serving on http://{state["bind"]}:{state["port"]}')
|
discovery = threading.Thread(target=discover, name='discovery', daemon=True)
|
||||||
|
discovery.start()
|
||||||
try:
|
try:
|
||||||
server.serve_forever()
|
discovery.join()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
pass
|
pass
|
||||||
finally:
|
|
||||||
server.server_close()
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
"""Manage the users of the router's P2S systems.
|
"""Manage the users of the router's directory, whether or not a P2S system is enabled.
|
||||||
|
|
||||||
A user is an entry in the router's directory, whose password OpenVPN checks, and a client
|
A user is an entry in the router's directory, whose password OpenVPN checks, and a client
|
||||||
certificate of the router CA with the user name as its common name, which OpenVPN and
|
certificate of the router CA with the user name as its common name, which OpenVPN and
|
||||||
@@ -103,7 +103,7 @@ def list_users(connection):
|
|||||||
def add(connection, name):
|
def add(connection, name):
|
||||||
secret = password()
|
secret = password()
|
||||||
if not (vpnrouter.PKI_DIR / 'ca_key.pem').is_file():
|
if not (vpnrouter.PKI_DIR / 'ca_key.pem').is_file():
|
||||||
raise Failure('the router CA does not exist; enable OpenVPN or IKEv2 road-warrior access first')
|
raise Failure('the router CA does not exist; vpn-router-setup creates it once a platform is chosen')
|
||||||
try:
|
try:
|
||||||
connection.add_s(dn_of(name), ldap.modlist.addModlist({
|
connection.add_s(dn_of(name), ldap.modlist.addModlist({
|
||||||
'objectClass': [b'inetOrgPerson'], 'uid': [name.encode()], 'cn': [name.encode()], 'sn': [name.encode()],
|
'objectClass': [b'inetOrgPerson'], 'uid': [name.encode()], 'cn': [name.encode()], 'sn': [name.encode()],
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"port": 8080,
|
"port": 7081,
|
||||||
"jwt_secret": ""
|
"jwt_secret": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Executable
+87
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Commit everything, tag the version of the newest changelog entry and push main with the tags.
|
||||||
|
|
||||||
|
The tag is the upstream version of the first entry of debian-package/debian/changelog,
|
||||||
|
without the Debian revision: vpn-router (0.5.1-1) is tagged 0.5.1, which starts the
|
||||||
|
release workflow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import pathlib
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parent
|
||||||
|
CHANGELOG = ROOT / 'debian-package' / 'debian' / 'changelog'
|
||||||
|
|
||||||
|
|
||||||
|
#: Print the git commands instead of running them; set by -n/--dry-run.
|
||||||
|
DRY_RUN = False
|
||||||
|
|
||||||
|
|
||||||
|
def git(*args):
|
||||||
|
"""Run git in the repository, or print the command on a dry run; stop with git's exit status when it fails."""
|
||||||
|
command = ['git', *args]
|
||||||
|
if DRY_RUN:
|
||||||
|
print(shlex.join(command))
|
||||||
|
return
|
||||||
|
result = subprocess.run(command, cwd=ROOT)
|
||||||
|
if result.returncode != 0:
|
||||||
|
sys.exit(result.returncode)
|
||||||
|
|
||||||
|
|
||||||
|
def summary():
|
||||||
|
"""The summary line of the first changelog entry, or None when it has none.
|
||||||
|
|
||||||
|
The summary is the entry's first text line: indented by two spaces, not a "*" item,
|
||||||
|
with a blank line between it and the header and a blank line after it.
|
||||||
|
"""
|
||||||
|
lines = CHANGELOG.read_text(encoding='utf-8').splitlines()
|
||||||
|
if len(lines) < 4 or lines[1].strip() or lines[3].strip():
|
||||||
|
return None
|
||||||
|
line = lines[2]
|
||||||
|
text = line.strip()
|
||||||
|
if not line.startswith(' ') or line.startswith(' ') or not text or text.startswith('*') or line.startswith(' -- '):
|
||||||
|
return None
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def version():
|
||||||
|
"""The upstream version of the first changelog entry."""
|
||||||
|
first = CHANGELOG.read_text(encoding='utf-8').splitlines()[0]
|
||||||
|
start, end = first.find('('), first.find(')')
|
||||||
|
if start == -1 or end < start:
|
||||||
|
sys.exit(f'{CHANGELOG}: the first line has no version: {first}')
|
||||||
|
full = first[start + 1:end]
|
||||||
|
return full.rsplit('-', 1)[0] if '-' in full else full
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
global DRY_RUN
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
parser.add_argument('-m', '--message', help='commit message; default: the summary line of the newest changelog entry, a line without "*" between blank lines right after its header')
|
||||||
|
parser.add_argument('-n', '--dry-run', action='store_true', help='print the git commands instead of running them')
|
||||||
|
parser.add_argument('--no-push', action='store_true', help='commit and tag, but do not push')
|
||||||
|
args = parser.parse_args()
|
||||||
|
DRY_RUN = args.dry_run
|
||||||
|
|
||||||
|
message = args.message or summary()
|
||||||
|
if not message:
|
||||||
|
sys.exit(f'no commit message: pass -m, or start the newest entry of {CHANGELOG} with a summary line '
|
||||||
|
'without "*", indented by two spaces, with a blank line above and below it')
|
||||||
|
tag = version()
|
||||||
|
if subprocess.run(['git', 'rev-parse', '-q', '--verify', f'refs/tags/{tag}'], cwd=ROOT,
|
||||||
|
capture_output=True).returncode == 0:
|
||||||
|
sys.exit(f'tag {tag} exists; add a new changelog entry first')
|
||||||
|
|
||||||
|
git('add', '-A')
|
||||||
|
git('commit', '-m', message)
|
||||||
|
git('tag', tag)
|
||||||
|
if not args.no_push:
|
||||||
|
git('push', 'origin', 'main', '--tags')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
Reference in new issue
Block a user