Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bf78b16b1 | ||
|
|
0ff74cc8c9 | ||
|
|
a7b29e0655 | ||
|
|
c377801be0 | ||
|
|
a4cffb67e4 | ||
|
|
cc6748a622 | ||
|
|
609921348b | ||
|
|
90e7c79840 | ||
|
|
a6b25b8bb4 | ||
|
|
7163f167e0 | ||
|
|
5402256ef6 | ||
|
|
64da0cf97f | ||
|
|
2e0cee10ac |
No files matched your search
@@ -0,0 +1,234 @@
|
||||
# Architecture Decision Records
|
||||
|
||||
## ADR-1: Docker's published ports are filtered in DOCKER-USER by the package
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: Docker adds its own iptables rules for published ports. They are evaluated before
|
||||
UFW's, so a published port is reachable from the external interface whatever UFW says. Docker
|
||||
reserves the `DOCKER-USER` chain, evaluated first in `FORWARD`, for rules of the operator. The
|
||||
router is an internet-facing host, and Docker is installed on it for services run alongside the
|
||||
router.
|
||||
|
||||
Decision: The package secures Docker itself. A `docker` configuration section has `mode`
|
||||
(`auto`, `on`, `off`) and `exposed_ports`. In `auto` mode the rules are applied when
|
||||
`docker.service` is installed, which is what the package looks up; `on` requires Docker and
|
||||
`off` removes the rules. The rules are in a chain of their own, `VPN-ROUTER-DOCKER`, entered by
|
||||
one jump at the top of `DOCKER-USER`. For traffic from the external interface they accept IPsec
|
||||
protected traffic, TCP to `exposed_ports` and established connections, and drop the rest. The
|
||||
`vpn-router-docker` unit applies them after `docker.service` starts and removes them when it
|
||||
stops; `vpn-router-setup` restarts it while Docker runs.
|
||||
|
||||
Consequences: Installing Docker first is recommended; `auto` mode then finds it as soon as the
|
||||
package is configured. When Docker is installed after the package, the rules are applied by
|
||||
restarting `vpn-router-setup`, a reboot or a restart of Docker, not by Docker's first start,
|
||||
because `vpn-router-docker` is already active. A published
|
||||
port is reachable from the external interface only when it is in `exposed_ports`. Other rules
|
||||
in `DOCKER-USER` are not touched, because the package owns only its own chain. Restarting Docker
|
||||
restores the rules. The rules cover IPv4 only.
|
||||
|
||||
## ADR-2: A status web service of the standard library, bound to the internal address
|
||||
|
||||
Status: Accepted; amended by ADR-7
|
||||
|
||||
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
|
||||
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
|
||||
the way of a small test server. It binds to the address of the internal interface and to
|
||||
`web.port`, on by default at 8080. `/` is for people, in plain text for `curl` and `wget` and as
|
||||
a Tailwind CSS page for a browser, and `/status` is the same status as JSON. It shows the
|
||||
state of the services, the package version and the state of the site-to-site, road-warrior and
|
||||
WireGuard features, and nothing from the configuration. `vpn-router-setup` resolves the
|
||||
interface address, writes it with the port and the features to `/run/vpn-router/web.json`,
|
||||
opens the port on the internal interface in UFW and restarts the service, so the service does
|
||||
not need the package's configuration code, which needs Jinja2.
|
||||
|
||||
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
|
||||
authentication, which is acceptable because it is read only and shows 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 browser on a network
|
||||
without internet shows the unstyled page, and `curl` is unaffected. A change of the internal
|
||||
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
|
||||
|
||||
Status: Accepted; amended by ADR-7 and ADR-8
|
||||
|
||||
Context: A client such as manage-ui needs the configuration and the resolved state of the
|
||||
router, which `/` and `/status` must not show. The service has only the standard library, 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
|
||||
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
|
||||
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
|
||||
configuration shown are masked by name (`psk`, and options with `secret`, `password`, `token` or a
|
||||
`_key` ending in the name).
|
||||
|
||||
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
|
||||
configuration file and in `/run/vpn-router/web.json`, both readable by root only. The token and
|
||||
the answer cross the network unencrypted, so the page is for a trusted internal network; HTTPS
|
||||
would need a certificate that the package does not have. The masking is by option name, so a new
|
||||
secret option needs a name that matches.
|
||||
|
||||
## ADR-4: Users are kept in a local OpenLDAP directory
|
||||
|
||||
Status: Accepted
|
||||
|
||||
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)
|
||||
with openvpn-auth-oauth2, an OIDC server embedded on the router, a file of password hashes, and
|
||||
OpenLDAP on the router. OIDC for OpenVPN needs a browser on the client and a program from outside
|
||||
the Ubuntu archive, and it cannot authenticate IKEv2 EAP-TLS or WireGuard. slapd, its Python
|
||||
module `python3-ldap` and the OpenVPN plugin `openvpn-auth-ldap` are all in the archive; the
|
||||
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,
|
||||
`dc=vpn-router,dc=local` in `/var/lib/ldap/vpn-router` (the path the slapd AppArmor profile
|
||||
allows), next to the database the slapd package creates from the host's domain, so the package
|
||||
does not depend on the debconf answers of slapd. Its root DN is root's peer-credential DN on the
|
||||
local socket, so `vpn-router-setup` manages it with SASL EXTERNAL and no administrator password
|
||||
exists. Users are changed by `cn=user-manager,ou=services`, whose ACLs allow writing `ou=users`
|
||||
and setting passwords (`=wx`) but not reading them. Passwords are hashed with ARGON2, and the
|
||||
ppolicy overlay locks a disabled user (`pwdAccountLockedTime`, effective with `pwdLockout`).
|
||||
slapd listens on the local socket only. Provisioning checks every step before changing it, so it
|
||||
runs at every start of `vpn-router-setup`.
|
||||
|
||||
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
|
||||
library outside the standard library besides `jinja2`. The service account's password is a
|
||||
root-only file. The directory is user data: it is kept when the features are switched off and
|
||||
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
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: The router CA already issues client certificates for IKEv2. OpenVPN can check a
|
||||
certificate, a password, or both. With both, a certificate alone and a password alone are not
|
||||
enough, but `openvpn-auth-ldap` checks only the password of the user name given at login, which
|
||||
need not be the user of the certificate.
|
||||
|
||||
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`,
|
||||
which accepts the login only when the user name equals the certificate's common name. OpenVPN
|
||||
calls both and needs both to pass. A user's certificate has the user name as its common name, is
|
||||
issued when the user is added and revoked when the user is removed. The CRL is created with the
|
||||
CA, so that `crl-verify` has a file from the start, and is valid for ten years, because the router
|
||||
renews it only when it revokes a certificate. The server uses the router's own certificate, whose
|
||||
common name is `wan.local_fqdn`; the client profile checks that name (`verify-x509-name`), since
|
||||
the CA's certificates carry both the server and the client purpose.
|
||||
|
||||
Consequences: Disabling a user locks the password, which stops OpenVPN but not IKEv2, which
|
||||
checks certificates only; removing a user revokes the certificate for both. A user's
|
||||
certificate expires after the CA's default of a year and is renewed by removing and adding the
|
||||
user again.
|
||||
|
||||
## ADR-6: The web service manages users by running vpn-router-user
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: The web service has only the standard library (ADR-2), and managing users needs
|
||||
`python3-ldap` and `simple-ca`. Its unit is sandboxed with `ProtectSystem=strict`.
|
||||
|
||||
Decision: `/users` runs `vpn-router-user` and returns its JSON answer, mapping its exit status
|
||||
to the HTTP status. Passwords go to the program on standard input, so they never appear in a
|
||||
command line. The unit allows writing the paths the program needs: `/run/vpn-router` for its
|
||||
lock, `/etc/vpn-router/pki` for the certificates and the CA's state, and `/etc/swanctl/x509crl`
|
||||
for the published CRL. A lock serializes the program's runs, because `simple-ca` keeps its state
|
||||
in one file.
|
||||
|
||||
Consequences: The web service still imports nothing outside the standard library, and the same
|
||||
program serves the command line and the API. Each request starts a process, which is fine for
|
||||
user administration. The API is protected by the token of `/config`, so whoever has the secret
|
||||
can manage users.
|
||||
|
||||
## ADR-7: The web service starts on its own and reads its inputs at each request
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: The web service took the address to listen on, the port, the token secret and the
|
||||
features from `/run/vpn-router/web.json`, written by `vpn-router-setup`, and ended with a failure
|
||||
while the file or the internal address was missing. It was ordered after `vpn-router-setup`, so a
|
||||
problem in the setup kept the status page, the tool to see that problem, from running.
|
||||
|
||||
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 still 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. `web.json` no longer holds the address, the port or the secret, so the secret is only in
|
||||
`vpn-router.json`. 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: `/config` showed the secret options of the configuration with their values replaced by
|
||||
`********`. That still tells a reader which secrets are set, and a secret has no use outside the
|
||||
router, so a client never needs it.
|
||||
|
||||
Decision: The configuration that `vpn-router-setup` writes for the web service has no secret
|
||||
options at all. They are recognised by name as before: `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 still 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 yet.
|
||||
|
||||
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: The user directory and the CA were provisioned, and `/users` was served, only while
|
||||
OpenVPN or road-warrior access was enabled. Users could not be prepared before a P2S system was
|
||||
switched on, and the Router Manager could not manage them on a router 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.
|
||||
@@ -4,8 +4,11 @@ A Debian package that configures a Linux host or virtual machine as a VPN router
|
||||
|
||||
- site-to-site IKEv2 IPSec with pre-shared key authentication (strongSwan swanctl)
|
||||
- road-warrior (P2S) access using IKEv2 EAP-TLS, with a bundled certificate authority
|
||||
- OpenVPN (P2S) access with a certificate and a password per user, the users kept in a local
|
||||
LDAP directory and managed with `vpn-router-user` or the web service
|
||||
- optional WireGuard endpoint
|
||||
- routing and NAT for a protected subnet, TCP MSS clamping, and UFW firewall rules
|
||||
- firewall rules for the ports Docker publishes, when Docker is installed
|
||||
|
||||
Nothing in the package is specific to a cloud provider. It is built on the external and
|
||||
internal NIC names, and a `mode` setting says how much of the rest you are supplying and
|
||||
@@ -28,6 +31,10 @@ service that applies it.
|
||||
systemctl restart vpn-router-setup apply it
|
||||
```
|
||||
|
||||
If the host runs Docker, the recommended order is Docker first and the package second. The
|
||||
package secures the ports Docker publishes when it finds the Docker service; the steps for a
|
||||
host that already runs Docker, and for installing Docker later, are under [Docker](#docker).
|
||||
|
||||
### By hand
|
||||
|
||||
```sh
|
||||
@@ -92,13 +99,13 @@ must have the type shown below; an unknown section or setting is an error.
|
||||
| `general` | `mode` | string | `manual` | How much you supply: `manual`, `interfaces` or `auto` |
|
||||
| `interfaces` | `external` | string | | Name of the NIC facing the untrusted network |
|
||||
| `interfaces` | `internal` | string | | Name of the NIC facing the protected network |
|
||||
| `wan` | `local_fqdn` | string | | This router's FQDN |
|
||||
| `wan` | `local_fqdn` | string | | This router's name or IP address, used as its IKE identity in `fqdn` mode |
|
||||
| `wan` | `local_id_mode` | string | `fqdn` | IKE identity source: `fqdn`, `public_ip` or `internal_ip` |
|
||||
| `local` | `cidrs` | list | | Local subnets advertised into the tunnel |
|
||||
| `local` | `int_addr` | string | | This host's address on the internal network |
|
||||
| `local` | `int_gateway_ip` | string | | Next hop on the internal side |
|
||||
| `remote` | `addrs` | list | | Remote gateway addresses or FQDNs |
|
||||
| `remote` | `id` | string | | Remote IKE identity, without a leading `@` |
|
||||
| `remote` | `id` | string | | Remote IKE identity, an FQDN or an IPv4 or IPv6 address |
|
||||
| `remote` | `cidrs` | list | | Remote subnets reachable through the tunnel |
|
||||
| `remote` | `psk` | string | | Pre-shared key |
|
||||
| `remote` | `psk_file` | string | | Path to a file holding the key; wins over `psk` |
|
||||
@@ -108,6 +115,15 @@ must have the type shown below; an unknown section or setting is an error.
|
||||
| `wireguard` | `enabled` | boolean | `false` | WireGuard endpoint |
|
||||
| `wireguard` | `address` | string | | Address and prefix for `wg0`, for example `192.168.200.1/24` |
|
||||
| `wireguard` | `listen_port` | integer | `51820` | UDP port |
|
||||
| `openvpn` | `enabled` | boolean | `false` | OpenVPN access for users |
|
||||
| `openvpn` | `address_pool` | string | | IPv4 CIDR assigned to OpenVPN clients |
|
||||
| `openvpn` | `port` | integer | `1194` | Port of the OpenVPN server on the external interface |
|
||||
| `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` | `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` | `port` | integer | `8080` | 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 |
|
||||
|
||||
A minimal site-to-site configuration in `auto` mode:
|
||||
|
||||
@@ -125,6 +141,11 @@ A minimal site-to-site configuration in `auto` mode:
|
||||
}
|
||||
```
|
||||
|
||||
The router's IKE identity (`wan.local_fqdn` in `fqdn` mode) and the remote one (`remote.id`) are
|
||||
an FQDN or an IPv4 or IPv6 address. They are written to the strongSwan configuration as given;
|
||||
strongSwan reads an address as an IP identity and a name as an FQDN, so a leading `@` is not needed
|
||||
and is refused. Road-warrior and OpenVPN access need a name in `wan.local_fqdn`.
|
||||
|
||||
WireGuard peers are added by hand in `/etc/wireguard/wg0.conf`. The package owns the
|
||||
`[Interface]` section of that file and rewrites it when the settings above change, but it
|
||||
carries every `[Peer]` section across untouched.
|
||||
@@ -141,18 +162,75 @@ much the package reads from the system:
|
||||
|---|---|---|
|
||||
| `manual` | both interface names, `int_addr`, `int_gateway_ip` | nothing |
|
||||
| `interfaces` | both interface names | `int_addr` and `int_gateway_ip`, from those interfaces |
|
||||
| `auto` | nothing | the interface names too, from the routing table |
|
||||
| `auto` | nothing | the interface names too: the external one from the routing table, the internal one as the only other NIC with an address |
|
||||
|
||||
`manual` is the only mode in which no detection code runs at all. `auto` is the
|
||||
convenient one and can pick the wrong interface, which is a trade you make deliberately
|
||||
by choosing it rather than something the package does behind your back.
|
||||
|
||||
Detection ignores virtual interfaces, those the kernel lists under `/sys/devices/virtual`:
|
||||
loopback, bridges such as `docker0`, veth pairs and tunnels. Only the NICs of the machine
|
||||
count, so Docker does not change the result. If the host has more than two NICs with an
|
||||
address, `auto` stops and says so; use mode `interfaces` and name them.
|
||||
|
||||
The addresses strongSwan binds on always come from the external NIC, in every mode, so
|
||||
they are never configured twice and cannot drift out of step with the host. If a named
|
||||
interface does not exist the service stops and says so. In `interfaces` and `auto` mode
|
||||
the internal gateway is taken from the routing table, falling back to the first host
|
||||
address of the connected subnet with a warning when the routing table has nothing to say.
|
||||
|
||||
## Docker
|
||||
|
||||
Docker publishes container ports in its own firewall rules, which bypass UFW and can expose
|
||||
ports on the external interface. Docker evaluates the `DOCKER-USER` chain before its own
|
||||
rules, so the package filters there. With `docker.mode` set to `auto`, the package secures
|
||||
Docker when `docker.service` is installed, and does nothing otherwise.
|
||||
|
||||
### On a host that already runs Docker
|
||||
|
||||
Installing Docker first is the recommended order. After installing the package:
|
||||
|
||||
1. Set `general.platform` in `/etc/vpn-router/vpn-router.json`. While it is `none`, nothing is
|
||||
configured, the Docker rules included.
|
||||
2. List the TCP ports the running containers must expose to the internet, for example
|
||||
`"docker": { "exposed_ports": ["443"] }`. The default is none: once the rules are applied,
|
||||
every published port that is not listed is unreachable from the external interface, and
|
||||
remains reachable through the IPsec tunnel.
|
||||
3. Apply the configuration with `systemctl restart vpn-router-setup`. Docker is running, so
|
||||
this also restarts `vpn-router-docker`, which installs the rules.
|
||||
4. Check the result:
|
||||
|
||||
```sh
|
||||
iptables -S DOCKER-USER # "-j VPN-ROUTER-DOCKER" is the first rule
|
||||
iptables -S VPN-ROUTER-DOCKER # the rules of the external interface
|
||||
```
|
||||
|
||||
### When Docker is installed after the package
|
||||
|
||||
The Docker rules are not applied when Docker starts for the first time, so run
|
||||
`systemctl restart vpn-router-setup` after installing Docker, then check as above. A reboot or a
|
||||
restart of Docker applies them as well.
|
||||
|
||||
The rules sit in their own chain, `VPN-ROUTER-DOCKER`, entered by one jump at the top of
|
||||
`DOCKER-USER`; other rules in `DOCKER-USER` are left alone. For traffic that arrives on the
|
||||
external interface, in this order:
|
||||
|
||||
1. IPsec protected traffic is accepted.
|
||||
2. TCP to the ports in `docker.exposed_ports` is accepted; none by default.
|
||||
3. Packets of established and related connections are accepted.
|
||||
4. Everything else is dropped.
|
||||
|
||||
Traffic from every other interface, including the internal one and the Docker bridges, is
|
||||
not affected. The rules cover IPv4 only.
|
||||
|
||||
`vpn-router-setup` starts after `docker.service`, so Docker is up when it looks for it; the
|
||||
ordering has no effect on a host without Docker. The `vpn-router-docker` service applies the
|
||||
rules after `docker.service` starts and removes
|
||||
them when Docker stops, so restarting Docker restores them. `systemctl restart
|
||||
vpn-router-setup` applies a changed configuration while Docker runs. With `docker.mode` set to
|
||||
`on`, the service fails when Docker is not installed or its daemon is not running; with
|
||||
`off` it removes the rules.
|
||||
|
||||
## Applying changes
|
||||
|
||||
```sh
|
||||
@@ -162,8 +240,10 @@ systemctl restart vpn-router-setup
|
||||
The service also runs at boot. With platform `none` it does nothing and says so.
|
||||
Otherwise it regenerates the configuration files, resolves the interfaces and their
|
||||
addresses as `mode` directs, applies routes, generates WireGuard keys and injects
|
||||
firewall rules. It then starts `strongswan` and `wg-quick@wg0` if the corresponding
|
||||
feature is configured, reloading or restarting them only when their inputs changed, and
|
||||
firewall rules. It provisions the user directory in `slapd` while OpenVPN or road-warrior
|
||||
access is configured. It then starts `strongswan`, `wg-quick@wg0` and
|
||||
`openvpn-server@vpn-router` if the corresponding feature is configured, reloading or
|
||||
restarting them only when their inputs changed, and
|
||||
restarts `systemd-resolved` only when the road-warrior DNS drop-in changed. Turning a
|
||||
feature off in the configuration stops its service and removes its files. Every step is
|
||||
idempotent, so restarting with no changes does nothing.
|
||||
@@ -192,10 +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
|
||||
`router_cert.pem`.
|
||||
|
||||
If road-warrior access is enabled and this directory is empty, a certificate authority and
|
||||
a server certificate are created there automatically. To use your own instead, place the
|
||||
files in that layout before enabling the feature; nothing is generated or overwritten when
|
||||
material is already present.
|
||||
Once a platform is chosen and while this directory is empty, a certificate authority and a CRL
|
||||
valid for ten years are created there automatically, whether or not road-warrior or OpenVPN
|
||||
access is enabled. A CA of the router's own (with `ca_key.pem`) without a CRL gets one, and
|
||||
without a server certificate gets one once `wan.local_fqdn` is set. User certificates are issued
|
||||
by `vpn-router-user` into `users/`. To use your own CA instead, place the files in that layout
|
||||
before choosing the platform; nothing present is overwritten.
|
||||
|
||||
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
|
||||
@@ -213,6 +295,74 @@ Issue a client certificate with the bundled tool:
|
||||
`simple-ca` comes from a separate project, <https://gitea.koszewscy.waw.pl/slawek/simple-ca>,
|
||||
and also supports `make-crl` and `revoke-cert`.
|
||||
|
||||
## OpenVPN and users
|
||||
|
||||
With `openvpn.enabled`, an `address_pool` and `wan.local_fqdn` set, `vpn-router-setup` runs
|
||||
`openvpn-server@vpn-router` with `/etc/openvpn/server/vpn-router.conf`. The server uses the
|
||||
router's CA, certificate and CRL (the copies under `/etc/swanctl`), pushes routes to
|
||||
`local.cidrs` and the router as DNS server, and opens `openvpn.port` on the external interface.
|
||||
|
||||
A client needs both a certificate of the router CA and the password of the user the
|
||||
certificate names:
|
||||
|
||||
- the certificate is checked against the CA and the CRL (`crl-verify`, read at every
|
||||
connection);
|
||||
- the password is checked by `openvpn-auth-ldap`, which binds to the user directory as the
|
||||
user;
|
||||
- `/usr/lib/vpn-router/openvpn-same-user` refuses a login whose user name is not the common
|
||||
name of the certificate.
|
||||
|
||||
### The user directory
|
||||
|
||||
Once a platform is chosen, `vpn-router-setup` keeps a directory of 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
|
||||
slapd package creates; root manages it through the local socket, with no administrator
|
||||
password;
|
||||
- `ou=users` holds one `inetOrgPerson` per user, named by `uid`;
|
||||
- `cn=user-manager,ou=services` is the account that changes users; it may write `ou=users` and
|
||||
set passwords, never read them, and its password is in
|
||||
`/etc/vpn-router/ldap/user-manager.secret` (root only);
|
||||
- passwords are stored as ARGON2 hashes, and a disabled user is locked with the password policy
|
||||
(`pwdAccountLockedTime`);
|
||||
- slapd listens on its local socket only (`SLAPD_SERVICES` in `/etc/default/slapd`).
|
||||
|
||||
The directory is kept on purge.
|
||||
|
||||
### Managing users
|
||||
|
||||
`vpn-router-user` manages the users. A user is the directory entry and a client certificate
|
||||
with the user name as its common name, in `/etc/vpn-router/pki/users`. Passwords are read from
|
||||
the first line of standard input. The answer is JSON.
|
||||
|
||||
```sh
|
||||
echo 's3cr3t-pass' | vpn-router-user add alice
|
||||
vpn-router-user list
|
||||
vpn-router-user profile alice
|
||||
echo 'n3w-pass' | vpn-router-user passwd alice
|
||||
vpn-router-user disable alice
|
||||
vpn-router-user enable alice
|
||||
vpn-router-user remove alice
|
||||
```
|
||||
|
||||
| Command | Effect |
|
||||
| --- | --- |
|
||||
| `list` | The users, whether each is enabled and has a certificate |
|
||||
| `add <name>` | Adds the user and issues the certificate |
|
||||
| `passwd <name>` | Sets the password |
|
||||
| `disable <name>`, `enable <name>` | Locks the user out of OpenVPN, and lets the user in again |
|
||||
| `remove <name>` | Revokes the certificate, renews the CRL and removes the user |
|
||||
| `profile <name>` | The OpenVPN client profile, with the CA, the certificate and the key inline |
|
||||
|
||||
A user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter; a
|
||||
password has 8 to 128 characters. The exit status is 0 on success, 2 for an invalid request,
|
||||
3 when the user does not exist, 4 when the user exists and 1 for any other failure.
|
||||
|
||||
The certificate is also accepted by road-warrior (IKEv2 EAP-TLS) access, which checks
|
||||
certificates only, so `disable` does not affect it; `remove` revokes the certificate for both.
|
||||
|
||||
## Platform modules
|
||||
|
||||
Platform-specific values do not belong in the core, so they are Python modules in
|
||||
@@ -254,17 +404,125 @@ EXTRA_LOCAL_TS = '192.0.2.53/32'
|
||||
Set `general.platform` to `example` and restart the service. The debconf question offers only the
|
||||
modules shipped with the package; the configuration file accepts any module installed.
|
||||
|
||||
## Status web service
|
||||
|
||||
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
|
||||
authentication, and shows no configuration: no key, identity, address or subnet.
|
||||
|
||||
| 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) |
|
||||
| `/status` | The same status as JSON |
|
||||
| `/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 |
|
||||
|
||||
The status holds the state of `vpn-router-setup` and `slapd`, of `strongswan` when site-to-site or
|
||||
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 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`
|
||||
(the Docker rules), which do not change `status`; whether the
|
||||
site-to-site tunnel is established; the number of connected road-warrior (IPsec P2S) clients; and
|
||||
the WireGuard peers with the age of their last handshake and their traffic. `status` is `ok` when
|
||||
all these services are active, and `degraded` otherwise.
|
||||
|
||||
```bash
|
||||
curl http://10.0.0.4:8080/
|
||||
curl http://10.0.0.4:8080/status
|
||||
```
|
||||
|
||||
### The /config page
|
||||
|
||||
`/config` shows what `/` and `/status` leave out, for a client such as manage-ui:
|
||||
|
||||
| Key | Content |
|
||||
| --- | --- |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `docker` | `docker.exposed_ports` and the rules of the `VPN-ROUTER-DOCKER` chain |
|
||||
|
||||
The page needs a JWT in an `Authorization: Bearer` header, signed with HS256 by `web.jwt_secret`.
|
||||
The token must have an `exp` claim, not more than ten minutes from now (a minute of clock
|
||||
difference is allowed); `nbf` is checked when present. Other algorithms are refused, with 401 and
|
||||
`WWW-Authenticate: Bearer`. While `web.jwt_secret` is not set the page is not served (404). The
|
||||
token and the page travel over plain HTTP on the internal network, and the token can be used
|
||||
until it expires, so keep the lifetime short. A client makes a token from the secret with only
|
||||
HMAC-SHA256; with Python:
|
||||
|
||||
```python
|
||||
import base64, hashlib, hmac, json, time
|
||||
|
||||
def b64(data):
|
||||
return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
|
||||
|
||||
secret = 'the web.jwt_secret value'
|
||||
head = b64(json.dumps({'alg': 'HS256', 'typ': 'JWT'}).encode())
|
||||
body = b64(json.dumps({'exp': int(time.time()) + 300}).encode())
|
||||
sign = b64(hmac.new(secret.encode(), f'{head}.{body}'.encode(), hashlib.sha256).digest())
|
||||
print(f'{head}.{body}.{sign}')
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $TOKEN" http://10.0.0.4:8080/config
|
||||
```
|
||||
|
||||
### The /users API
|
||||
|
||||
`/users` manages users with the same token as `/config`, by running `vpn-router-user`. Request
|
||||
and answer bodies are JSON; a request body is at most 4096 bytes.
|
||||
|
||||
| Request | Body | Answer |
|
||||
| --- | --- | --- |
|
||||
| `GET /users` | | `{"users": [{"name", "enabled", "certificate"}, ...]}` |
|
||||
| `POST /users` | `{"name": ..., "password": ...}` | 201 and the user |
|
||||
| `PUT /users/<name>/password` | `{"password": ...}` | the user |
|
||||
| `POST /users/<name>/disable` | | the user |
|
||||
| `POST /users/<name>/enable` | | the user |
|
||||
| `DELETE /users/<name>` | | `{"name", "removed": true}` |
|
||||
| `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
|
||||
exist, 409 for a user that exists and 500 otherwise. `/users` works whether or not OpenVPN or
|
||||
road-warrior access is enabled; the profile needs OpenVPN. Passwords and private keys cross the internal
|
||||
network in plain HTTP, like the token.
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"name": "alice", "password": "s3cr3t-pass"}' http://10.0.0.4:8080/users
|
||||
```
|
||||
|
||||
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,
|
||||
and reads `web.port` and `web.enabled`; it listens there, moves when the address or the port
|
||||
changes, and stops listening while `web.enabled` is `false` or the address is not known. Each
|
||||
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`,
|
||||
8090 by default) on the internal interface; restart `vpn-router-setup` after changing it.
|
||||
|
||||
## Removal
|
||||
|
||||
`apt-get remove` terminates the tunnels and takes `wg-quick@wg0` down, but leaves 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
|
||||
configuration in place. `strongswan` is left enabled, since it is a shared service that
|
||||
may be in use by something else.
|
||||
|
||||
`apt-get purge` additionally removes the generated files and the firewall rules, restoring
|
||||
`/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. Key material is never deleted:
|
||||
`/etc/vpn-router/pki`, the WireGuard key pair and everything under `/etc/swanctl` are left
|
||||
alone.
|
||||
before deleting it so a later reinstall starts clean. 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.
|
||||
|
||||
## Building
|
||||
|
||||
|
||||
Whitespace-only changes.
@@ -1,3 +1,183 @@
|
||||
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
|
||||
|
||||
* OpenVPN P2S access, set in the openvpn section (enabled, address_pool,
|
||||
port, protocol). Clients need a certificate of the router CA and the
|
||||
password of the user named in it; the password is checked against a
|
||||
local LDAP directory by openvpn-auth-ldap.
|
||||
* The users live in the package's own slapd database, dc=vpn-router,
|
||||
dc=local, which vpn-router-setup provisions while OpenVPN or road-warrior
|
||||
access is configured. slapd listens on its local socket only. Passwords
|
||||
are stored as ARGON2 hashes, and a disabled user is locked with the
|
||||
password policy.
|
||||
* vpn-router-user lists, adds, disables, enables and removes users, sets
|
||||
passwords and gives OpenVPN client profiles. Adding a user also issues
|
||||
the user's client certificate; removing one revokes it.
|
||||
* The web service manages users under /users, with the same token as
|
||||
/config, and /status shows the OpenVPN clients.
|
||||
* The CA gets a certificate revocation list when it is created, and an
|
||||
existing CA without one gets one.
|
||||
* Purging the package removes the firewall rules of the web service, which
|
||||
it left behind before.
|
||||
* The status page follows the browser's light or dark colour scheme,
|
||||
uses a proportional font and reloads itself every minute.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 23 Oct 2026 23:00:00 +0200
|
||||
|
||||
vpn-router (0.4.3-1) unstable; urgency=medium
|
||||
|
||||
* swanctl output with more closing than opening lines is read as not
|
||||
available instead of failing the request.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 23 Oct 2026 22:00:00 +0200
|
||||
|
||||
vpn-router (0.4.2-1) unstable; urgency=medium
|
||||
|
||||
* /config gives the IPsec connections and SAs as objects, read from
|
||||
swanctl --pretty, and the routes as objects from ip -j, instead of
|
||||
lines of text. The site-to-site and road-warrior state of /status is
|
||||
counted from the same swanctl objects.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 23 Oct 2026 21:30:00 +0200
|
||||
|
||||
vpn-router (0.4.1-1) unstable; urgency=medium
|
||||
|
||||
* Fix the start of vpn-router-setup hanging on a host that runs Docker.
|
||||
vpn-router-setup restarted vpn-router-docker and waited for it, but that
|
||||
unit was ordered after vpn-router-setup, so each waited for the other,
|
||||
the installation of the package never finished and vpn-router-web was
|
||||
never enabled or started. vpn-router-docker is no longer ordered after
|
||||
vpn-router-setup, so vpn-router-setup still waits for it and the Docker
|
||||
rules are in place when it ends. The restart of vpn-router-web is queued
|
||||
without waiting.
|
||||
* Start vpn-router-setup after docker.service, so that Docker is up when
|
||||
the setup looks for it and does not start in parallel with it. The
|
||||
ordering applies only when Docker is installed.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 23 Oct 2026 20:10:00 +0200
|
||||
|
||||
vpn-router (0.4.0-1) unstable; urgency=medium
|
||||
|
||||
* Add the vpn-router-web service, a small status web service that uses only
|
||||
the Python standard library. It listens on the internal address and on
|
||||
web.port, 8080 by default. / shows the state of the router for people,
|
||||
as plain text for curl and wget and as a Tailwind CSS page for a
|
||||
browser, and /status shows the same as JSON: the state of the services,
|
||||
the package version, whether Docker is installed, and the state of
|
||||
site-to-site, road-warrior and WireGuard, with no configuration data.
|
||||
* Add the web configuration section. web.enabled (true by default) and
|
||||
web.port. vpn-router-setup opens the port on the internal interface in
|
||||
UFW and restarts the service.
|
||||
* Add /config to the status web service, for a client such as manage-ui. It
|
||||
shows the configuration with its secrets masked, the interfaces,
|
||||
addresses, identities and routes the router resolved, the IPsec
|
||||
connections and SAs, the WireGuard peers (no keys) and the Docker rules.
|
||||
It needs a JWT signed with HS256 by web.jwt_secret, with an exp claim
|
||||
within ten minutes, and is not served while web.jwt_secret is not set.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 23 Oct 2026 19:00:00 +0200
|
||||
|
||||
vpn-router (0.3.2-1) unstable; urgency=medium
|
||||
|
||||
* Accept an IPv4 or IPv6 address as remote.id and as wan.local_fqdn. The
|
||||
identities are written to the strongSwan configuration as given, without
|
||||
an @ added, and strongSwan reads an address as an IP identity and a name
|
||||
as an FQDN. A value that starts with @ is refused with an error, since
|
||||
strongSwan 5.0 and later need no @ to avoid resolving a name.
|
||||
Road-warrior access still needs a name in wan.local_fqdn.
|
||||
* Depend on strongswan-swanctl and charon-systemd 5.0 or later.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 23 Oct 2026 18:00:00 +0200
|
||||
|
||||
vpn-router (0.3.1-1) unstable; urgency=medium
|
||||
|
||||
* Ignore virtual interfaces when mode auto looks for the internal
|
||||
interface. The Docker bridge docker0, which has an address, was counted
|
||||
as a second internal interface, so vpn-router-setup and
|
||||
vpn-router-docker failed on a host with Docker installed.
|
||||
|
||||
-- Sławomir Koszewski <slawek@koszewscy.waw.pl> Fri, 23 Oct 2026 13:44:00 +0200
|
||||
|
||||
vpn-router (0.3.0-1) unstable; urgency=medium
|
||||
|
||||
* Secure the ports Docker publishes. Rules in a chain of their own,
|
||||
VPN-ROUTER-DOCKER, entered by a jump at the top of DOCKER-USER, accept
|
||||
IPsec protected traffic, TCP to docker.exposed_ports and established
|
||||
connections from the external interface and drop the rest of it. Other
|
||||
rules in DOCKER-USER are left alone. The rules cover IPv4 only.
|
||||
* Add the docker configuration section. docker.mode is auto (the default,
|
||||
secures Docker when docker.service is installed), on (requires Docker) or
|
||||
off (removes the rules); docker.exposed_ports lists the TCP ports and port
|
||||
ranges reachable from the external interface, none by default.
|
||||
* Add the vpn-router-docker service, which applies the Docker rules after
|
||||
docker.service starts and removes them when it stops. vpn-router-setup
|
||||
restarts it while Docker runs, so a changed configuration takes effect.
|
||||
* Installing Docker before this package is recommended. When Docker is
|
||||
installed later, restart vpn-router-setup to apply the rules.
|
||||
|
||||
-- Sławomir Koszewski <slawek@koszewscy.waw.pl> Fri, 23 Oct 2026 12:44:45 +0200
|
||||
|
||||
vpn-router (0.2.0-1) unstable; urgency=medium
|
||||
|
||||
* Change the configuration file to JSON, /etc/vpn-router/vpn-router.json.
|
||||
|
||||
@@ -9,8 +9,8 @@ Rules-Requires-Root: no
|
||||
Package: vpn-router
|
||||
Architecture: all
|
||||
Depends: ${misc:Depends},
|
||||
strongswan-swanctl,
|
||||
charon-systemd,
|
||||
strongswan-swanctl (>= 5.0),
|
||||
charon-systemd (>= 5.0),
|
||||
libstrongswan-extra-plugins,
|
||||
libcharon-extra-plugins,
|
||||
wireguard-tools,
|
||||
@@ -20,13 +20,19 @@ Depends: ${misc:Depends},
|
||||
openssl,
|
||||
python3,
|
||||
python3-jinja2,
|
||||
python3-ldap,
|
||||
slapd,
|
||||
openvpn,
|
||||
openvpn-auth-ldap,
|
||||
iproute2,
|
||||
systemd-resolved
|
||||
Description: Linux VPN router with IPSec, road-warrior 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
|
||||
IKEv2 IPSec (strongSwan swanctl), optional road-warrior access using EAP-TLS,
|
||||
optional WireGuard, and the routing, NAT, MSS clamping and firewall rules a
|
||||
router needs. Nothing in the package is specific to a cloud provider.
|
||||
optional OpenVPN with a certificate and a password per user, kept in a local
|
||||
LDAP directory, optional WireGuard, and the routing, NAT, MSS clamping and
|
||||
firewall rules a router needs. Nothing in the package is specific to a cloud
|
||||
provider.
|
||||
.
|
||||
Settings live in /etc/vpn-router/vpn-router.json, which is created on first
|
||||
install and owned by the administrator afterwards. Editing it and restarting
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
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_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/sbin/vpn-router-user usr/sbin/
|
||||
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/docker-rules usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/setup usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/web usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/generate-config usr/lib/vpn-router/
|
||||
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/
|
||||
|
||||
@@ -11,10 +11,10 @@ strip_ufw_blocks() {
|
||||
|
||||
tmp="$(mktemp)"
|
||||
awk '
|
||||
/^# (IPSEC|WIREGUARD|P2S DNS|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
|
||||
}
|
||||
/^# (IPSEC|WIREGUARD|P2S DNS|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 { next }
|
||||
@@ -47,10 +47,13 @@ case "$1" in
|
||||
/etc/swanctl/conf.d/road-warrior.conf \
|
||||
/etc/systemd/resolved.conf.d/p2s-forwarder.conf \
|
||||
/etc/wireguard/wg0.conf \
|
||||
/etc/openvpn/server/vpn-router.conf \
|
||||
/etc/openvpn/server/vpn-router-ldap.conf \
|
||||
/etc/vpn-router/vpn-router.json
|
||||
|
||||
# Key material is not package state: /etc/vpn-router/pki, the WireGuard
|
||||
# key pair and everything under /etc/swanctl are left in place.
|
||||
# Key material and users are not package state: /etc/vpn-router/pki,
|
||||
# /etc/vpn-router/ldap, the user directory in slapd, the WireGuard key
|
||||
# pair and everything under /etc/swanctl are left in place.
|
||||
rmdir --ignore-fail-on-non-empty /etc/vpn-router 2>/dev/null || true
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -11,15 +11,17 @@ case "$1" in
|
||||
swanctl --terminate --ike road-warrior >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# This package enabled wg-quick@wg0, so it takes it down again. Use the
|
||||
# debhelper wrappers rather than systemctl, so the script behaves on a
|
||||
# machine without systemd.
|
||||
if command -v deb-systemd-invoke >/dev/null 2>&1; then
|
||||
deb-systemd-invoke stop wg-quick@wg0.service >/dev/null 2>&1 || true
|
||||
fi
|
||||
if command -v deb-systemd-helper >/dev/null 2>&1; then
|
||||
deb-systemd-helper disable wg-quick@wg0.service >/dev/null 2>&1 || true
|
||||
fi
|
||||
# This package enabled wg-quick@wg0 and openvpn-server@vpn-router, so
|
||||
# it takes them down again. Use the debhelper wrappers rather than
|
||||
# systemctl, so the script behaves on a machine without systemd.
|
||||
for unit in wg-quick@wg0.service openvpn-server@vpn-router.service; do
|
||||
if command -v deb-systemd-invoke >/dev/null 2>&1; then
|
||||
deb-systemd-invoke stop "$unit" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if command -v deb-systemd-helper >/dev/null 2>&1; then
|
||||
deb-systemd-helper disable "$unit" >/dev/null 2>&1 || true
|
||||
fi
|
||||
done
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
@@ -4,3 +4,5 @@
|
||||
|
||||
override_dh_installsystemd:
|
||||
dh_installsystemd --name=vpn-router-setup
|
||||
dh_installsystemd --name=vpn-router-docker
|
||||
dh_installsystemd --name=vpn-router-web
|
||||
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=VPN Router rules for Docker published ports
|
||||
Documentation=file:/usr/share/doc/vpn-router/README.md
|
||||
After=docker.service
|
||||
PartOf=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/usr/lib/vpn-router/docker-rules apply
|
||||
ExecStop=/usr/lib/vpn-router/docker-rules remove
|
||||
|
||||
[Install]
|
||||
WantedBy=docker.service
|
||||
@@ -2,7 +2,7 @@
|
||||
Description=VPN Router Setup
|
||||
Documentation=file:/usr/share/doc/vpn-router/README.md
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
After=network-online.target docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=VPN Router status web service
|
||||
Documentation=file:/usr/share/doc/vpn-router/README.md
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/python3 /usr/lib/vpn-router/web
|
||||
Restart=on-failure
|
||||
RestartSec=15
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=-/run/vpn-router -/etc/vpn-router/pki -/etc/swanctl/x509crl
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/python3
|
||||
"""Secure the ports Docker publishes, with rules in the DOCKER-USER chain.
|
||||
|
||||
Usage: docker-rules apply|remove
|
||||
|
||||
Docker evaluates DOCKER-USER in FORWARD before its own rules, which makes it the
|
||||
place to filter traffic to containers. The rules live in their own chain,
|
||||
VPN-ROUTER-DOCKER, entered by one jump at the top of DOCKER-USER, so anything
|
||||
else in DOCKER-USER is left alone. Traffic arriving on the external interface is
|
||||
accepted when it is IPsec protected, when it is TCP to docker.exposed_ports, or
|
||||
when it belongs to an established connection; all other traffic from the
|
||||
external interface is dropped. Traffic from other interfaces is not affected.
|
||||
|
||||
docker.mode decides whether this runs: auto applies the rules when the Docker
|
||||
service is installed, on requires it, off removes the rules. The vpn-router-docker
|
||||
unit runs apply after docker.service starts and remove when it stops.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
|
||||
|
||||
import vpnrouter
|
||||
from vpnrouter import fail, warn
|
||||
|
||||
CHAIN = 'VPN-ROUTER-DOCKER'
|
||||
USER_CHAIN = 'DOCKER-USER'
|
||||
|
||||
|
||||
def iptables(*args, check=True):
|
||||
result = subprocess.run(['iptables', *args], capture_output=True, text=True)
|
||||
if check and result.returncode != 0:
|
||||
fail(f'iptables {" ".join(args)} failed: {result.stderr.strip()}')
|
||||
return result
|
||||
|
||||
|
||||
def docker_installed():
|
||||
"""True when systemd knows the docker.service unit."""
|
||||
result = subprocess.run(
|
||||
['systemctl', 'list-unit-files', '--no-legend', 'docker.service'],
|
||||
capture_output=True, text=True)
|
||||
return result.returncode == 0 and result.stdout.strip() != ''
|
||||
|
||||
|
||||
def remove():
|
||||
"""Take the jump and the chain out; nothing happens when they are absent."""
|
||||
while iptables('-D', USER_CHAIN, '-j', CHAIN, check=False).returncode == 0:
|
||||
pass
|
||||
iptables('-F', CHAIN, check=False)
|
||||
iptables('-X', CHAIN, check=False)
|
||||
|
||||
|
||||
def apply(cfg):
|
||||
mode = cfg['docker']['mode']
|
||||
if mode == 'off':
|
||||
remove()
|
||||
return
|
||||
|
||||
if not docker_installed():
|
||||
if mode == 'on':
|
||||
fail('docker.mode is on but the docker service is not installed; '
|
||||
'install Docker before vpn-router')
|
||||
return
|
||||
|
||||
if iptables('-S', USER_CHAIN, check=False).returncode != 0:
|
||||
if mode == 'on':
|
||||
fail(f'docker.mode is on but there is no {USER_CHAIN} chain; '
|
||||
'is the Docker daemon running?')
|
||||
warn(f'no {USER_CHAIN} chain, the Docker daemon is not running; '
|
||||
'the rules are applied when it starts')
|
||||
return
|
||||
|
||||
ctx = vpnrouter.build_context(cfg)
|
||||
wan = ctx['wan_iface']
|
||||
if not wan:
|
||||
fail('the external interface is not known; set interfaces.external '
|
||||
'or use mode auto or interfaces')
|
||||
|
||||
ports = ','.join(item.strip() for item in cfg['docker']['exposed_ports'])
|
||||
|
||||
remove()
|
||||
iptables('-N', CHAIN)
|
||||
iptables('-A', CHAIN, '-i', wan, '-m', 'policy', '--dir', 'in', '--pol', 'ipsec', '-j', 'ACCEPT')
|
||||
if ports:
|
||||
iptables('-A', CHAIN, '-i', wan, '-p', 'tcp', '-m', 'multiport', '--dports', ports, '-j', 'ACCEPT')
|
||||
iptables('-A', CHAIN, '-i', wan, '-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED', '-j', 'ACCEPT')
|
||||
iptables('-A', CHAIN, '-i', wan, '-j', 'DROP')
|
||||
iptables('-I', USER_CHAIN, '1', '-j', CHAIN)
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 2 or sys.argv[1] not in ('apply', 'remove'):
|
||||
print('usage: docker-rules apply|remove', file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if sys.argv[1] == 'remove':
|
||||
remove()
|
||||
return 0
|
||||
|
||||
cfg = vpnrouter.load_config()
|
||||
if vpnrouter.deferred(cfg):
|
||||
return 0
|
||||
try:
|
||||
vpnrouter.validate(cfg)
|
||||
apply(cfg)
|
||||
except vpnrouter.ConfigError as exc:
|
||||
fail(str(exc))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/python3
|
||||
"""Accept an OpenVPN login only when the user name is the common name of the client certificate.
|
||||
|
||||
Run by the OpenVPN server as auth-user-pass-verify with via-env, next to the LDAP plugin that
|
||||
checks the password, so a user's certificate cannot be used with another user's password.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
username = os.environ.get('username', '')
|
||||
sys.exit(0 if username and username == os.environ.get('common_name') else 1)
|
||||
@@ -10,27 +10,44 @@ firewall and NAT rules are written against them, so choosing the wrong one
|
||||
would be a silent and security-relevant mistake.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
|
||||
|
||||
import ldap
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_ldap
|
||||
from vpnrouter import fail
|
||||
|
||||
BEFORE_RULES = pathlib.Path('/etc/ufw/before.rules')
|
||||
UFW_DEFAULTS = pathlib.Path('/etc/default/ufw')
|
||||
WG_DIR = pathlib.Path('/etc/wireguard')
|
||||
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 = 8090
|
||||
|
||||
FILTER_ANCHOR = '# End required lines'
|
||||
|
||||
#: Configuration options left out wherever the configuration is shown.
|
||||
SECRET_OPTION = re.compile(r'(^psk$)|secret|password|token|_key$')
|
||||
|
||||
BLOCKS = (
|
||||
('# IPSEC RULES START', '# IPSEC RULES END'),
|
||||
('# WIREGUARD RULES START', '# WIREGUARD RULES END'),
|
||||
('# OPENVPN RULES START', '# OPENVPN RULES END'),
|
||||
('# P2S DNS RULES START', '# P2S DNS RULES END'),
|
||||
('# WEB RULES START', '# WEB RULES END'),
|
||||
('# MANAGER RULES START', '# MANAGER RULES END'),
|
||||
('# ROUTER FORWARD RULES START', '# ROUTER FORWARD RULES END'),
|
||||
('# ROUTER NAT RULES START', '# ROUTER NAT RULES END'),
|
||||
('# ROUTER MSS RULES START', '# ROUTER MSS RULES END'),
|
||||
@@ -114,7 +131,17 @@ def is_configured(ctx):
|
||||
a host that was working before.
|
||||
"""
|
||||
return any((vpnrouter.s2s_ready(ctx), vpnrouter.routing_ready(ctx),
|
||||
vpnrouter.p2s_ready(ctx), vpnrouter.wg_ready(ctx)))
|
||||
vpnrouter.p2s_ready(ctx), vpnrouter.wg_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):
|
||||
@@ -139,16 +166,49 @@ def filter_block(ctx):
|
||||
'# WIREGUARD RULES END',
|
||||
]
|
||||
|
||||
if vpnrouter.p2s_ready(ctx) and ctx['local_addr_list']:
|
||||
pool = ctx['p2s_address_pool']
|
||||
lines += ['', '# P2S DNS RULES START']
|
||||
for address in ctx['local_addr_list']:
|
||||
if vpnrouter.openvpn_ready(ctx):
|
||||
device = ctx['openvpn_device']
|
||||
lines += [
|
||||
'',
|
||||
'# OPENVPN RULES START',
|
||||
f'-A ufw-before-input -i {ctx["wan_iface"]} -p {ctx["openvpn_protocol"]} --dport {ctx["openvpn_port"]} -j ACCEPT',
|
||||
]
|
||||
for subnet in ctx['local_cidr_list']:
|
||||
lines += [
|
||||
f'-A ufw-before-input -s {pool} -d {address} -p udp --dport 53 -j ACCEPT',
|
||||
f'-A ufw-before-input -s {pool} -d {address} -p tcp --dport 53 -j ACCEPT',
|
||||
f'-A ufw-before-forward -i {device} -d {subnet} -j ACCEPT',
|
||||
f'-A ufw-before-forward -o {device} -s {subnet} -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT',
|
||||
]
|
||||
lines += ['# OPENVPN RULES END']
|
||||
|
||||
pools = [pool for ready, pool in ((vpnrouter.p2s_ready(ctx), ctx['p2s_address_pool']),
|
||||
(vpnrouter.openvpn_ready(ctx), ctx['openvpn_pool'])) if ready]
|
||||
if pools and ctx['local_addr_list']:
|
||||
lines += ['', '# P2S DNS RULES START']
|
||||
for pool in pools:
|
||||
for address in ctx['local_addr_list']:
|
||||
lines += [
|
||||
f'-A ufw-before-input -s {pool} -d {address} -p udp --dport 53 -j ACCEPT',
|
||||
f'-A ufw-before-input -s {pool} -d {address} -p tcp --dport 53 -j ACCEPT',
|
||||
]
|
||||
lines += ['# P2S DNS RULES END']
|
||||
|
||||
if ctx['web_enabled'] and ctx['int_iface']:
|
||||
lines += [
|
||||
'',
|
||||
'# WEB RULES START',
|
||||
f'-A ufw-before-input -i {ctx["int_iface"]} -p tcp --dport {ctx["web_port"]} -j ACCEPT',
|
||||
'# 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):
|
||||
lines += ['', '# ROUTER FORWARD RULES START']
|
||||
for subnet in ctx['local_cidr_list']:
|
||||
@@ -287,6 +347,31 @@ def manage_wireguard(ctx, changed):
|
||||
run(['systemctl', 'restart', unit], check=False)
|
||||
|
||||
|
||||
def manage_directory():
|
||||
"""Provision the user directory in slapd, whether or not a P2S system is configured."""
|
||||
if vpnrouter_ldap.ensure_services() or not unit_is_active('slapd'):
|
||||
run(['systemctl', 'restart', 'slapd'])
|
||||
try:
|
||||
vpnrouter_ldap.provision()
|
||||
except ldap.LDAPError as exc:
|
||||
fail(f'the user directory cannot be provisioned: {exc}')
|
||||
|
||||
|
||||
def manage_openvpn(ctx, changed):
|
||||
"""Run the OpenVPN server when it is configured, and stop it when it is not."""
|
||||
unit = 'openvpn-server@vpn-router'
|
||||
|
||||
if not vpnrouter.openvpn_ready(ctx):
|
||||
if unit_is_active(unit):
|
||||
run(['systemctl', 'disable', '--now', unit], check=False)
|
||||
return
|
||||
|
||||
if not unit_is_active(unit):
|
||||
run(['systemctl', 'enable', '--now', unit], check=False)
|
||||
elif 'openvpn' in changed or 'swanctl' in changed:
|
||||
run(['systemctl', 'restart', unit], check=False)
|
||||
|
||||
|
||||
def run_platform_setup(cfg, ctx):
|
||||
"""Let the platform module apply its own state, if it defines any."""
|
||||
module = vpnrouter.platform_module(cfg)
|
||||
@@ -299,6 +384,54 @@ def run_platform_setup(cfg, ctx):
|
||||
fail(f'platform module {module.__name__} failed: {exc}')
|
||||
|
||||
|
||||
def refresh_docker_rules():
|
||||
"""Apply the new settings to the Docker rules while Docker is running.
|
||||
|
||||
When Docker is not running, vpn-router-docker applies them after
|
||||
docker.service starts. The restart is waited for, so the rules are in place
|
||||
when this setup ends; vpn-router-docker is therefore not ordered after
|
||||
vpn-router-setup.
|
||||
"""
|
||||
if unit_is_active('docker.service'):
|
||||
run(['systemctl', 'restart', 'vpn-router-docker.service'], check=False)
|
||||
|
||||
|
||||
def refresh_web(cfg, ctx):
|
||||
"""Hand the web service the features and facts it shows.
|
||||
|
||||
The service reads the file at each request: the features of the router,
|
||||
the configuration without its secret options and the facts resolved from
|
||||
the system, which it cannot work out with the standard library alone. The
|
||||
file is readable by root only. The service follows a changed address or
|
||||
port itself.
|
||||
"""
|
||||
public = {
|
||||
section: {option: value for option, value in options.items() if not SECRET_OPTION.search(option)}
|
||||
for section, options in cfg.items()
|
||||
}
|
||||
WEB_STATE.parent.mkdir(parents=True, exist_ok=True)
|
||||
WEB_STATE.touch(mode=0o600)
|
||||
WEB_STATE.chmod(0o600)
|
||||
WEB_STATE.write_text(json.dumps({
|
||||
'site_to_site': bool(vpnrouter.s2s_ready(ctx)),
|
||||
'p2s': bool(vpnrouter.p2s_ready(ctx)),
|
||||
'wireguard': bool(vpnrouter.wg_ready(ctx)),
|
||||
'openvpn': bool(vpnrouter.openvpn_ready(ctx)),
|
||||
'configuration': public,
|
||||
'network': {
|
||||
'external_interface': ctx['wan_iface'],
|
||||
'internal_interface': ctx['int_iface'],
|
||||
'external_addresses': ctx['local_addr_list'],
|
||||
'internal_address': ctx['int_addr'],
|
||||
'internal_gateway': ctx['int_gateway_ip'],
|
||||
'local_identity': ctx['local_id'],
|
||||
'remote_identity': ctx['remote_id'],
|
||||
'local_cidrs': ctx['local_cidr_list'],
|
||||
'remote_cidrs': ctx['remote_cidr_list'],
|
||||
},
|
||||
}) + '\n', encoding='utf-8')
|
||||
|
||||
|
||||
def main():
|
||||
cfg = vpnrouter.load_config()
|
||||
ctx, changed = vpnrouter.configure(cfg)
|
||||
@@ -330,13 +463,17 @@ def main():
|
||||
elif rules_changed:
|
||||
run(['ufw', 'reload'], check=False)
|
||||
|
||||
manage_directory()
|
||||
manage_strongswan(ctx, changed)
|
||||
manage_wireguard(ctx, changed)
|
||||
manage_openvpn(ctx, changed)
|
||||
|
||||
if 'resolved' in changed:
|
||||
run(['systemctl', 'restart', 'systemd-resolved'], check=False)
|
||||
|
||||
run_platform_setup(cfg, ctx)
|
||||
refresh_docker_rules()
|
||||
refresh_web(cfg, ctx)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import ipaddress
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -18,7 +19,9 @@ LIB_DIR = pathlib.Path(__file__).resolve().parent
|
||||
if str(LIB_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(LIB_DIR))
|
||||
|
||||
import vpnrouter_ldap
|
||||
import vpnrouter_platforms
|
||||
from vpnrouter_net import ConfigError, detect_interfaces, interface_addresses, ip_output
|
||||
|
||||
CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.json')
|
||||
TEMPLATE_DIR = pathlib.Path('/usr/share/vpn-router/templates')
|
||||
@@ -30,6 +33,9 @@ REMOTE_SITE = SWANCTL_CONF_D / 'remote-site.conf'
|
||||
ROAD_WARRIOR = SWANCTL_CONF_D / 'road-warrior.conf'
|
||||
P2S_FORWARDER = pathlib.Path('/etc/systemd/resolved.conf.d/p2s-forwarder.conf')
|
||||
WG_CONF = pathlib.Path('/etc/wireguard/wg0.conf')
|
||||
OPENVPN_CONF = pathlib.Path('/etc/openvpn/server/vpn-router.conf')
|
||||
OPENVPN_LDAP = pathlib.Path('/etc/openvpn/server/vpn-router-ldap.conf')
|
||||
USER_CERT_DIR = PKI_DIR / 'users'
|
||||
|
||||
CA_CERT_DEST = pathlib.Path('/etc/swanctl/x509ca/ca.pem')
|
||||
SERVER_CERT = pathlib.Path('/etc/swanctl/x509/server.pem')
|
||||
@@ -46,8 +52,30 @@ SCHEMA = {
|
||||
'remote': {'addrs': [], 'id': '', 'cidrs': [], 'psk': '', 'psk_file': ''},
|
||||
'p2s': {'enabled': False, 'address_pool': '', 'ca_name': 'VPN Router CA'},
|
||||
'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820},
|
||||
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
|
||||
'docker': {'mode': 'auto', 'exposed_ports': []},
|
||||
'web': {'enabled': True, 'port': 8080, 'jwt_secret': ''},
|
||||
}
|
||||
|
||||
#: The shortest secret that signs the tokens of the status web service.
|
||||
WEB_SECRET_MIN = 32
|
||||
|
||||
DOCKER_MODES = ('auto', 'on', 'off')
|
||||
|
||||
OPENVPN_PROTOCOLS = ('udp', 'tcp')
|
||||
|
||||
#: The tun device of the OpenVPN server.
|
||||
OPENVPN_DEVICE = 'ovpn0'
|
||||
|
||||
#: Days a certificate revocation list is valid; the router only reads its own lists.
|
||||
CRL_DAYS = 3650
|
||||
|
||||
#: A port or a port range as iptables multiport takes it: 443 or 8000:8100.
|
||||
DOCKER_PORT = re.compile(r'^\d{1,5}(:\d{1,5})?$')
|
||||
|
||||
#: The multiport match takes at most 15 ports, and a range counts as two.
|
||||
DOCKER_PORT_LIMIT = 15
|
||||
|
||||
TYPE_NAMES = {str: 'a string', list: 'a list of strings', bool: 'a boolean',
|
||||
int: 'an integer'}
|
||||
|
||||
@@ -65,10 +93,6 @@ MODES = ('manual', 'interfaces', 'auto')
|
||||
PLATFORM_NONE = 'none'
|
||||
|
||||
|
||||
class ConfigError(Exception):
|
||||
"""A setting is present but malformed."""
|
||||
|
||||
|
||||
def warn(message):
|
||||
print(f'vpn-router: warning: {message}', file=sys.stderr)
|
||||
|
||||
@@ -180,6 +204,12 @@ def validate(cfg):
|
||||
raise ConfigError(
|
||||
f'{section}.{option} is required in mode {mode or "manual"}')
|
||||
|
||||
for section, option in (('wan', 'local_fqdn'), ('remote', 'id')):
|
||||
if str(_get(cfg, section, option)).startswith('@'):
|
||||
raise ConfigError(
|
||||
f'{section}.{option}: remove the leading @; strongSwan reads an '
|
||||
'address as an IP identity and a name as an FQDN')
|
||||
|
||||
id_mode = _get(cfg, 'wan', 'local_id_mode')
|
||||
if id_mode and id_mode not in ID_MODES:
|
||||
raise ConfigError(
|
||||
@@ -189,6 +219,13 @@ def validate(cfg):
|
||||
_check_networks(_get_list(cfg, 'remote', 'cidrs'), 'remote.cidrs')
|
||||
pool = _get(cfg, 'p2s', 'address_pool')
|
||||
_check_networks([pool] if pool else [], 'p2s.address_pool')
|
||||
openvpn_pool = _get(cfg, 'openvpn', 'address_pool')
|
||||
if openvpn_pool:
|
||||
try:
|
||||
if ipaddress.ip_network(openvpn_pool).version != 4:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise ConfigError(f'openvpn.address_pool: {openvpn_pool!r} is not an IPv4 network')
|
||||
_check_address(_get(cfg, 'local', 'int_addr'), 'local.int_addr')
|
||||
_check_address(_get(cfg, 'local', 'int_gateway_ip'), 'local.int_gateway_ip')
|
||||
|
||||
@@ -212,6 +249,41 @@ def validate(cfg):
|
||||
if not 1 <= port <= 65535:
|
||||
raise ConfigError(f'wireguard.listen_port: {port} is not a valid port')
|
||||
|
||||
openvpn_port = _get(cfg, 'openvpn', 'port')
|
||||
if not 1 <= openvpn_port <= 65535:
|
||||
raise ConfigError(f'openvpn.port: {openvpn_port} is not a valid port')
|
||||
|
||||
protocol = _get(cfg, 'openvpn', 'protocol')
|
||||
if protocol not in OPENVPN_PROTOCOLS:
|
||||
raise ConfigError(
|
||||
f'openvpn.protocol: {protocol!r} is not one of {", ".join(OPENVPN_PROTOCOLS)}')
|
||||
|
||||
web_port = _get(cfg, 'web', 'port')
|
||||
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')
|
||||
|
||||
web_secret = _get(cfg, 'web', 'jwt_secret')
|
||||
if web_secret and len(web_secret) < WEB_SECRET_MIN:
|
||||
raise ConfigError(f'web.jwt_secret: use at least {WEB_SECRET_MIN} characters')
|
||||
|
||||
docker_mode = _get(cfg, 'docker', 'mode')
|
||||
if docker_mode not in DOCKER_MODES:
|
||||
raise ConfigError(
|
||||
f'docker.mode: {docker_mode!r} is not one of {", ".join(DOCKER_MODES)}')
|
||||
|
||||
ports = _get_list(cfg, 'docker', 'exposed_ports')
|
||||
weight = 0
|
||||
for item in ports:
|
||||
if not DOCKER_PORT.match(item) or any(
|
||||
not 1 <= int(part) <= 65535 for part in item.split(':')):
|
||||
raise ConfigError(
|
||||
f'docker.exposed_ports: {item!r} is not a port or a port range')
|
||||
weight += 2 if ':' in item else 1
|
||||
if weight > DOCKER_PORT_LIMIT:
|
||||
raise ConfigError(
|
||||
f'docker.exposed_ports: at most {DOCKER_PORT_LIMIT} ports are allowed, '
|
||||
'a range counting as two')
|
||||
|
||||
|
||||
def resolve_psk(cfg):
|
||||
"""Return the pre-shared key, or '' when none is configured."""
|
||||
@@ -252,45 +324,6 @@ def deferred(cfg):
|
||||
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 _addressed_interfaces():
|
||||
"""Interface names holding at least one IPv4 address, excluding loopback."""
|
||||
names = []
|
||||
for line in _ip_route('-o', '-4', 'addr', 'show').splitlines():
|
||||
fields = line.split()
|
||||
if len(fields) >= 4 and fields[1] != 'lo' and fields[1] not in names:
|
||||
names.append(fields[1])
|
||||
return names
|
||||
|
||||
|
||||
def detect_gateway(iface, address):
|
||||
"""Derive the next hop on the internal side.
|
||||
|
||||
@@ -298,12 +331,12 @@ def detect_gateway(iface, address):
|
||||
back to the first host address of the connected subnet, which is a
|
||||
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()
|
||||
if 'via' in fields:
|
||||
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()
|
||||
if len(fields) >= 4 and fields[3].split('/')[0] == address:
|
||||
network = ipaddress.ip_interface(fields[3]).network
|
||||
@@ -314,34 +347,6 @@ def detect_gateway(iface, address):
|
||||
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):
|
||||
"""Return (ext_iface, int_iface, local_addrs, int_addr, int_gateway_ip).
|
||||
|
||||
@@ -382,11 +387,13 @@ def build_context(cfg, local_id_override=None):
|
||||
id_mode = _get(cfg, 'wan', 'local_id_mode') or 'fqdn'
|
||||
local_cidrs = _get_list(cfg, 'local', 'cidrs')
|
||||
remote_cidrs = _get_list(cfg, 'remote', 'cidrs')
|
||||
openvpn_pool = _get(cfg, 'openvpn', 'address_pool')
|
||||
openvpn_network = ipaddress.ip_network(openvpn_pool) if openvpn_pool else None
|
||||
|
||||
if local_id_override:
|
||||
local_id = local_id_override
|
||||
elif id_mode == 'fqdn':
|
||||
local_id = f'@{local_fqdn}' if local_fqdn else ''
|
||||
local_id = local_fqdn
|
||||
elif id_mode == 'internal_ip':
|
||||
local_id = int_addr
|
||||
else:
|
||||
@@ -421,6 +428,25 @@ def build_context(cfg, local_id_override=None):
|
||||
'wg_enabled': _get(cfg, 'wireguard', 'enabled'),
|
||||
'wg_address': _get(cfg, 'wireguard', 'address'),
|
||||
'wg_listen_port': _get(cfg, 'wireguard', 'listen_port'),
|
||||
'openvpn_enabled': _get(cfg, 'openvpn', 'enabled'),
|
||||
'openvpn_pool': openvpn_pool,
|
||||
'openvpn_network': str(openvpn_network.network_address) if openvpn_network else '',
|
||||
'openvpn_netmask': str(openvpn_network.netmask) if openvpn_network else '',
|
||||
'openvpn_routes': [(str(network.network_address), str(network.netmask))
|
||||
for network in (ipaddress.ip_network(cidr, strict=False) for cidr in local_cidrs)],
|
||||
'openvpn_port': _get(cfg, 'openvpn', 'port'),
|
||||
'openvpn_protocol': _get(cfg, 'openvpn', 'protocol'),
|
||||
'openvpn_device': OPENVPN_DEVICE,
|
||||
'openvpn_ldap_conf': str(OPENVPN_LDAP),
|
||||
'p2s_dns_list': _split_list(_join(local_addrs, _split_list(extras['EXTRA_P2S_DNS']))),
|
||||
'ca_cert': str(CA_CERT_DEST),
|
||||
'server_cert': str(SERVER_CERT),
|
||||
'server_key': str(SERVER_KEY),
|
||||
'crl': str(CRL_DEST),
|
||||
'ldap_uri': vpnrouter_ldap.URI,
|
||||
'ldap_users': vpnrouter_ldap.USERS,
|
||||
'web_enabled': _get(cfg, 'web', 'enabled'),
|
||||
'web_port': _get(cfg, 'web', 'port'),
|
||||
}
|
||||
|
||||
|
||||
@@ -446,6 +472,10 @@ def wg_ready(ctx):
|
||||
return bool(ctx['wg_enabled'] and ctx['wg_address'])
|
||||
|
||||
|
||||
def openvpn_ready(ctx):
|
||||
return bool(ctx['openvpn_enabled'] and ctx['openvpn_pool'] and ctx['local_fqdn'])
|
||||
|
||||
|
||||
def _jinja_env():
|
||||
return jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(str(TEMPLATE_DIR)),
|
||||
@@ -535,21 +565,28 @@ def _simple_ca(*args):
|
||||
|
||||
|
||||
def provision_pki(ctx):
|
||||
"""Create the CA and server certificate when the PKI directory is empty."""
|
||||
"""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)
|
||||
os.chmod(PKI_DIR, 0o700)
|
||||
|
||||
if any(PKI_DIR.iterdir()):
|
||||
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():
|
||||
fail(f'{SIMPLE_CA} is missing')
|
||||
|
||||
_simple_ca('make-ca', '--ca-dir', str(PKI_DIR), ctx['p2s_ca_name'])
|
||||
_simple_ca('make-cert', '--ca-dir', str(PKI_DIR),
|
||||
ctx['local_fqdn'], ctx['local_fqdn'])
|
||||
if not any(PKI_DIR.iterdir()):
|
||||
if not ctx['p2s_ca_name']:
|
||||
warn('p2s.ca_name is empty, not creating a local CA')
|
||||
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):
|
||||
@@ -577,7 +614,7 @@ def configure(cfg=None, local_id_override=None):
|
||||
"""Render configuration files and provision PKI.
|
||||
|
||||
Returns (ctx, changed) where changed is a set naming what needs reloading:
|
||||
'swanctl', 'resolved', 'wireguard'.
|
||||
'swanctl', 'resolved', 'wireguard', 'openvpn'.
|
||||
"""
|
||||
if cfg is None:
|
||||
cfg = load_config()
|
||||
@@ -595,8 +632,7 @@ def configure(cfg=None, local_id_override=None):
|
||||
|
||||
changed = set()
|
||||
|
||||
if p2s_ready(ctx):
|
||||
provision_pki(ctx)
|
||||
provision_pki(ctx)
|
||||
if distribute_pki(ctx):
|
||||
changed.add('swanctl')
|
||||
|
||||
@@ -611,13 +647,14 @@ def configure(cfg=None, local_id_override=None):
|
||||
if p2s_ready(ctx):
|
||||
if render(env, ctx, 'road-warrior.conf.j2', ROAD_WARRIOR, 0o600):
|
||||
changed.add('swanctl')
|
||||
elif remove(ROAD_WARRIOR):
|
||||
changed.add('swanctl')
|
||||
|
||||
if p2s_ready(ctx) or openvpn_ready(ctx):
|
||||
if render(env, ctx, 'p2s-forwarder.conf.j2', P2S_FORWARDER, 0o644):
|
||||
changed.add('resolved')
|
||||
else:
|
||||
if remove(ROAD_WARRIOR):
|
||||
changed.add('swanctl')
|
||||
if remove(P2S_FORWARDER):
|
||||
changed.add('resolved')
|
||||
elif remove(P2S_FORWARDER):
|
||||
changed.add('resolved')
|
||||
|
||||
if wg_ready(ctx):
|
||||
if render_wg_conf(env, ctx):
|
||||
@@ -625,4 +662,14 @@ def configure(cfg=None, local_id_override=None):
|
||||
elif remove(WG_CONF):
|
||||
changed.add('wireguard')
|
||||
|
||||
if openvpn_ready(ctx):
|
||||
if render(env, ctx, 'openvpn-server.conf.j2', OPENVPN_CONF, 0o600):
|
||||
changed.add('openvpn')
|
||||
if render(env, ctx, 'openvpn-ldap.conf.j2', OPENVPN_LDAP, 0o600):
|
||||
changed.add('openvpn')
|
||||
else:
|
||||
for path in (OPENVPN_CONF, OPENVPN_LDAP):
|
||||
if remove(path):
|
||||
changed.add('openvpn')
|
||||
|
||||
return ctx, changed
|
||||
@@ -0,0 +1,182 @@
|
||||
"""The router's user directory in the local slapd.
|
||||
|
||||
The package keeps its own database, dc=vpn-router,dc=local in /var/lib/ldap/vpn-router,
|
||||
next to the one slapd's installation creates. Root manages it through the local socket
|
||||
with SASL EXTERNAL: the database's root DN is root's peer-credential DN, so there is no
|
||||
administrator password. Users are changed by the user-manager service account, which the
|
||||
ACLs allow to write ou=users and to set, never read, passwords.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
import pwd
|
||||
import re
|
||||
import secrets
|
||||
|
||||
import ldap
|
||||
import ldap.modlist
|
||||
import ldap.sasl
|
||||
|
||||
URI = 'ldapi:///'
|
||||
BASE = 'dc=vpn-router,dc=local'
|
||||
USERS = f'ou=users,{BASE}'
|
||||
SERVICES = f'ou=services,{BASE}'
|
||||
POLICIES = f'ou=policies,{BASE}'
|
||||
DEFAULT_POLICY = f'cn=default,{POLICIES}'
|
||||
MANAGER = f'cn=user-manager,{SERVICES}'
|
||||
ROOT = 'gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth'
|
||||
|
||||
DB_DIRECTORY = pathlib.Path('/var/lib/ldap/vpn-router')
|
||||
SECRET_DIR = pathlib.Path('/etc/vpn-router/ldap')
|
||||
MANAGER_SECRET = SECRET_DIR / 'user-manager.secret'
|
||||
SLAPD_DEFAULTS = pathlib.Path('/etc/default/slapd')
|
||||
SLAPD_SERVICES = 'SLAPD_SERVICES="ldapi:///"'
|
||||
|
||||
MODULES = ('argon2', 'ppolicy')
|
||||
PASSWORD_HASH = '{ARGON2}'
|
||||
|
||||
#: The ACLs of the database, in order.
|
||||
ACCESS = (
|
||||
f'to attrs=userPassword by dn.exact="{MANAGER}" =wx by self =wx by anonymous auth by * none',
|
||||
f'to dn.subtree="{USERS}" by dn.exact="{MANAGER}" manage by * read',
|
||||
'to * by * read',
|
||||
)
|
||||
|
||||
#: The index of an ordered cn=config value: {0}, {1}, ...
|
||||
ORDER = re.compile(rb'^\{\d+\}')
|
||||
|
||||
|
||||
def unordered(values):
|
||||
"""Values of an ordered cn=config attribute without their {n} prefixes."""
|
||||
return [ORDER.sub(b'', value).decode() for value in values]
|
||||
|
||||
|
||||
def root():
|
||||
"""A connection to slapd as root, through the local socket."""
|
||||
connection = ldap.initialize(URI)
|
||||
connection.sasl_interactive_bind_s('', ldap.sasl.external())
|
||||
return connection
|
||||
|
||||
|
||||
def manager():
|
||||
"""A connection to slapd as the user-manager service account."""
|
||||
connection = ldap.initialize(URI)
|
||||
connection.simple_bind_s(MANAGER, MANAGER_SECRET.read_text(encoding='utf-8'))
|
||||
return connection
|
||||
|
||||
|
||||
def ensure_services():
|
||||
"""Make slapd listen on the local socket only. Returns True when the setting changed."""
|
||||
text = SLAPD_DEFAULTS.read_text(encoding='utf-8')
|
||||
lines = text.splitlines(keepends=True)
|
||||
updated = ''.join(SLAPD_SERVICES + '\n' if line.startswith('SLAPD_SERVICES=') else line for line in lines)
|
||||
if not any(line.startswith('SLAPD_SERVICES=') for line in lines):
|
||||
updated = text.rstrip('\n') + '\n' + SLAPD_SERVICES + '\n'
|
||||
if updated == text:
|
||||
return False
|
||||
SLAPD_DEFAULTS.write_text(updated, encoding='utf-8')
|
||||
return True
|
||||
|
||||
|
||||
def _ensure_modules(connection):
|
||||
dn, attributes = connection.search_s('cn=config', ldap.SCOPE_ONELEVEL, '(objectClass=olcModuleList)', ['olcModuleLoad'])[0]
|
||||
loaded = unordered(attributes.get('olcModuleLoad', []))
|
||||
missing = [name for name in MODULES if name not in loaded and f'{name}.la' not in loaded]
|
||||
if missing:
|
||||
connection.modify_s(dn, [(ldap.MOD_ADD, 'olcModuleLoad', [name.encode() for name in missing])])
|
||||
|
||||
|
||||
def _ensure_password_hash(connection):
|
||||
dn = 'olcDatabase={-1}frontend,cn=config'
|
||||
current = connection.search_s(dn, ldap.SCOPE_BASE, attrlist=['olcPasswordHash'])[0][1].get('olcPasswordHash', [])
|
||||
if current != [PASSWORD_HASH.encode()]:
|
||||
connection.modify_s(dn, [(ldap.MOD_REPLACE, 'olcPasswordHash', [PASSWORD_HASH.encode()])])
|
||||
|
||||
|
||||
def _ensure_database(connection):
|
||||
"""The DN of the package's database in cn=config, created when missing; its ACLs are kept as ACCESS."""
|
||||
found = connection.search_s('cn=config', ldap.SCOPE_ONELEVEL, f'(olcSuffix={BASE})', ['olcAccess'])
|
||||
if not found:
|
||||
DB_DIRECTORY.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
openldap = pwd.getpwnam('openldap')
|
||||
os.chown(DB_DIRECTORY, openldap.pw_uid, openldap.pw_gid)
|
||||
connection.add_s('olcDatabase=mdb,cn=config', ldap.modlist.addModlist({
|
||||
'objectClass': [b'olcDatabaseConfig', b'olcMdbConfig'],
|
||||
'olcDatabase': [b'mdb'],
|
||||
'olcSuffix': [BASE.encode()],
|
||||
'olcDbDirectory': [str(DB_DIRECTORY).encode()],
|
||||
'olcRootDN': [ROOT.encode()],
|
||||
'olcDbIndex': [b'objectClass eq', b'uid eq'],
|
||||
'olcAccess': [f'{{{index}}}{rule}'.encode() for index, rule in enumerate(ACCESS)],
|
||||
}))
|
||||
found = connection.search_s('cn=config', ldap.SCOPE_ONELEVEL, f'(olcSuffix={BASE})', ['olcAccess'])
|
||||
dn, attributes = found[0]
|
||||
if unordered(attributes.get('olcAccess', [])) != list(ACCESS):
|
||||
connection.modify_s(dn, [(ldap.MOD_REPLACE, 'olcAccess', [f'{{{index}}}{rule}'.encode() for index, rule in enumerate(ACCESS)])])
|
||||
return dn
|
||||
|
||||
|
||||
def _ensure_overlay(connection, database):
|
||||
if not connection.search_s(database, ldap.SCOPE_ONELEVEL, '(objectClass=olcPPolicyConfig)', ['1.1']):
|
||||
connection.add_s(f'olcOverlay=ppolicy,{database}', ldap.modlist.addModlist({
|
||||
'objectClass': [b'olcOverlayConfig', b'olcPPolicyConfig'],
|
||||
'olcOverlay': [b'ppolicy'],
|
||||
'olcPPolicyDefault': [DEFAULT_POLICY.encode()],
|
||||
}))
|
||||
|
||||
|
||||
def _ensure_entry(connection, dn, attributes):
|
||||
"""Add the entry when it is missing, else set the given attributes that differ."""
|
||||
try:
|
||||
current = connection.search_s(dn, ldap.SCOPE_BASE, attrlist=list(attributes))[0][1]
|
||||
except ldap.NO_SUCH_OBJECT:
|
||||
connection.add_s(dn, ldap.modlist.addModlist(attributes))
|
||||
return
|
||||
changes = [(ldap.MOD_REPLACE, name, values) for name, values in attributes.items()
|
||||
if name != 'objectClass' and sorted(current.get(name, [])) != sorted(values)]
|
||||
if changes:
|
||||
connection.modify_s(dn, changes)
|
||||
|
||||
|
||||
def _ensure_manager(connection):
|
||||
SECRET_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
os.chmod(SECRET_DIR, 0o700)
|
||||
created = False
|
||||
try:
|
||||
connection.search_s(MANAGER, ldap.SCOPE_BASE, attrlist=['1.1'])
|
||||
except ldap.NO_SUCH_OBJECT:
|
||||
connection.add_s(MANAGER, ldap.modlist.addModlist({
|
||||
'objectClass': [b'organizationalRole', b'simpleSecurityObject'],
|
||||
'cn': [b'user-manager'],
|
||||
'userPassword': [b'{CRYPT}!'],
|
||||
}))
|
||||
created = True
|
||||
if created or not MANAGER_SECRET.is_file():
|
||||
password = secrets.token_urlsafe(32)
|
||||
connection.passwd_s(MANAGER, None, password)
|
||||
MANAGER_SECRET.touch(mode=0o600)
|
||||
MANAGER_SECRET.chmod(0o600)
|
||||
MANAGER_SECRET.write_text(password, encoding='utf-8')
|
||||
|
||||
|
||||
def provision():
|
||||
"""Create or bring up to date the modules, database, ACLs, password policy, entries and service account."""
|
||||
connection = root()
|
||||
try:
|
||||
_ensure_modules(connection)
|
||||
_ensure_password_hash(connection)
|
||||
database = _ensure_database(connection)
|
||||
_ensure_overlay(connection, database)
|
||||
_ensure_entry(connection, BASE, {'objectClass': [b'dcObject', b'organization'], 'dc': [b'vpn-router'], 'o': [b'vpn-router']})
|
||||
for unit in (USERS, SERVICES, POLICIES):
|
||||
_ensure_entry(connection, unit, {'objectClass': [b'organizationalUnit'], 'ou': [unit.split(',')[0][3:].encode()]})
|
||||
_ensure_entry(connection, DEFAULT_POLICY, {
|
||||
'objectClass': [b'organizationalRole', b'pwdPolicy'],
|
||||
'cn': [b'default'],
|
||||
'pwdAttribute': [b'userPassword'],
|
||||
'pwdLockout': [b'TRUE'],
|
||||
'pwdMaxFailure': [b'0'],
|
||||
})
|
||||
_ensure_manager(connection)
|
||||
finally:
|
||||
connection.unbind_s()
|
||||
@@ -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 ''
|
||||
@@ -0,0 +1,617 @@
|
||||
#!/usr/bin/python3
|
||||
"""Serve the status of the router on its internal address.
|
||||
|
||||
Run by vpn-router-web.service. It needs nothing but the Python standard library; users are
|
||||
managed by running vpn-router-user.
|
||||
|
||||
Pages:
|
||||
/ the status for people: plain text for curl and wget, a styled page
|
||||
(Tailwind CSS from a CDN) for a browser
|
||||
/status the status as JSON
|
||||
/config the configuration without its secrets, and what the router resolved and
|
||||
runs, as JSON
|
||||
/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())
|
||||
|
||||
/config and /users need an HS256 JWT in an Authorization: Bearer header that is signed
|
||||
with web.jwt_secret and expires within ten minutes; they are not served (404) while
|
||||
web.jwt_secret is not set.
|
||||
|
||||
/ and /status show only the status: whether the services run, the package version, whether
|
||||
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.
|
||||
|
||||
The service needs nothing else to start and does not end on its own. A discovery thread works
|
||||
out the internal address from /etc/vpn-router/vpn-router.json as general.mode directs, with
|
||||
web.port and web.enabled, every DISCOVERY_SECONDS, and runs the server thread there: it starts
|
||||
it, moves it when the address or the port changes, and stops it while the service is disabled
|
||||
or the address is not known. Every request reads what it needs when it is made: the token secret
|
||||
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 hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from html import escape
|
||||
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'
|
||||
CONFIG_FILE = '/etc/vpn-router/vpn-router.json'
|
||||
|
||||
#: The port when web.port is not set.
|
||||
DEFAULT_PORT = 8080
|
||||
|
||||
#: Seconds between two discoveries of where the service should listen.
|
||||
DISCOVERY_SECONDS = 30
|
||||
COMMAND_TIMEOUT = 5
|
||||
CACHE_SECONDS = 2
|
||||
|
||||
#: Seconds of clock difference accepted between a token and this router.
|
||||
LEEWAY = 60
|
||||
|
||||
#: The longest a token may be valid, from now, in seconds.
|
||||
MAX_LIFETIME = 600
|
||||
|
||||
TAILWIND = 'https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4'
|
||||
|
||||
#: Seconds after which the status page in a browser reloads itself.
|
||||
REFRESH_SECONDS = 60
|
||||
|
||||
USER_TOOL = '/usr/sbin/vpn-router-user'
|
||||
OPENVPN_STATUS = '/run/openvpn-server/status-vpn-router.log'
|
||||
|
||||
#: Seconds a user command may take; adding a user creates an RSA key.
|
||||
USER_TIMEOUT = 120
|
||||
|
||||
#: The largest request body accepted, in bytes.
|
||||
BODY_LIMIT = 4096
|
||||
|
||||
#: The HTTP status for each exit status of vpn-router-user; any other is 500.
|
||||
USER_STATUS = {0: 200, 2: 400, 3: 404, 4: 409}
|
||||
|
||||
_cache = {'at': 0.0, 'data': None}
|
||||
_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):
|
||||
"""The finished process of a command, or None when it cannot be run."""
|
||||
try:
|
||||
return subprocess.run(args, capture_output=True, text=True, timeout=COMMAND_TIMEOUT)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
|
||||
|
||||
def unit_state(unit):
|
||||
result = run('systemctl', 'is-active', unit)
|
||||
return result.stdout.strip() if result and result.stdout.strip() else 'unknown'
|
||||
|
||||
|
||||
def package_version():
|
||||
result = run('dpkg-query', '-W', '-f=${Version}', 'vpn-router')
|
||||
return result.stdout.strip() if result and result.returncode == 0 else None
|
||||
|
||||
|
||||
def docker_status():
|
||||
"""Whether Docker is installed, and the state of its service and of the Docker rules when it is."""
|
||||
result = run('systemctl', 'list-unit-files', '--no-legend', 'docker.service')
|
||||
if result is None or result.returncode != 0 or not result.stdout.strip():
|
||||
return {'installed': False}
|
||||
return {
|
||||
'installed': True,
|
||||
'service': unit_state('docker.service'),
|
||||
'rules': unit_state('vpn-router-docker.service'),
|
||||
}
|
||||
|
||||
|
||||
def swanctl(command):
|
||||
"""The entries of a swanctl list command, or None when swanctl cannot be asked.
|
||||
|
||||
The --pretty output has one item per line: "name {" opens a section, "key = [" a list
|
||||
whose items follow one per line, "}" and "]" close them, and "key = value" is a pair
|
||||
whose value may contain spaces. Every "... event {" section at the top holds one
|
||||
connection or SA; each becomes an entry with its name under "name". The closing
|
||||
"... reply {" section is left out. Values stay strings, as swanctl prints them.
|
||||
Output with more closing than opening lines gives None.
|
||||
"""
|
||||
result = run('swanctl', command, '--pretty')
|
||||
if result is None or result.returncode != 0:
|
||||
return None
|
||||
events = []
|
||||
stack = []
|
||||
for line in result.stdout.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line in ('}', ']'):
|
||||
if not stack:
|
||||
return None
|
||||
stack.pop()
|
||||
elif not stack:
|
||||
section = {}
|
||||
if line.endswith(' event {'):
|
||||
events.append(section)
|
||||
stack.append(section)
|
||||
elif isinstance(stack[-1], list):
|
||||
stack[-1].append(line)
|
||||
elif line.endswith(' {'):
|
||||
stack[-1][line[:-2]] = section = {}
|
||||
stack.append(section)
|
||||
elif line.endswith(' = ['):
|
||||
stack[-1][line[:-4]] = items = []
|
||||
stack.append(items)
|
||||
else:
|
||||
key, _, value = line.partition(' = ')
|
||||
stack[-1][key] = value
|
||||
return [dict(body, name=name) for event in events for name, body in event.items()]
|
||||
|
||||
|
||||
def established(connection):
|
||||
"""The number of established IKE SAs of the connection, or None when swanctl cannot be asked."""
|
||||
sas = swanctl('--list-sas')
|
||||
if sas is None:
|
||||
return None
|
||||
return sum(1 for sa in sas if sa['name'] == connection and sa.get('state') == 'ESTABLISHED')
|
||||
|
||||
|
||||
def wireguard_peers():
|
||||
"""One entry per WireGuard peer, with its handshake age and traffic, or None when wg0 is down."""
|
||||
result = run('wg', 'show', 'wg0', 'dump')
|
||||
if result is None or result.returncode != 0:
|
||||
return None
|
||||
now = int(time.time())
|
||||
peers = []
|
||||
for line in result.stdout.splitlines()[1:]:
|
||||
fields = line.split('\t')
|
||||
if len(fields) < 7:
|
||||
continue
|
||||
handshake = int(fields[4])
|
||||
peers.append({
|
||||
'handshake_seconds_ago': now - handshake if handshake else None,
|
||||
'received_bytes': int(fields[5]),
|
||||
'sent_bytes': int(fields[6]),
|
||||
})
|
||||
return peers
|
||||
|
||||
|
||||
def openvpn_clients():
|
||||
"""The number of connected OpenVPN clients, from the server's status file, or None when it cannot be read."""
|
||||
try:
|
||||
with open(OPENVPN_STATUS, encoding='utf-8') as handle:
|
||||
return sum(1 for line in handle if line.startswith('CLIENT_LIST,'))
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def collect(state):
|
||||
"""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')}
|
||||
if state.get('site_to_site') or state.get('p2s'):
|
||||
units['strongswan'] = unit_state('strongswan.service')
|
||||
if state.get('wireguard'):
|
||||
units['wg-quick@wg0'] = unit_state('wg-quick@wg0.service')
|
||||
if state.get('openvpn'):
|
||||
units['openvpn-server@vpn-router'] = unit_state('openvpn-server@vpn-router.service')
|
||||
|
||||
status = {
|
||||
'status': 'ok' if all(value == 'active' for value in units.values()) else 'degraded',
|
||||
'version': package_version(),
|
||||
'time': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
|
||||
'units': units,
|
||||
'docker': docker_status(),
|
||||
}
|
||||
if state.get('site_to_site'):
|
||||
count = established('remote-site')
|
||||
status['site_to_site'] = {'established': None if count is None else count > 0}
|
||||
if state.get('p2s'):
|
||||
status['p2s'] = {'clients': established('road-warrior')}
|
||||
if state.get('wireguard'):
|
||||
status['wireguard'] = {'peers': wireguard_peers()}
|
||||
if state.get('openvpn'):
|
||||
status['openvpn'] = {'clients': openvpn_clients()}
|
||||
return status
|
||||
|
||||
|
||||
def b64decode(part):
|
||||
return base64.urlsafe_b64decode(part + '=' * (-len(part) % 4))
|
||||
|
||||
|
||||
def verify(token, secret):
|
||||
"""The claims of a token signed with HS256 by the secret, or None when it is not acceptable.
|
||||
|
||||
Only HS256 is accepted, so a token cannot choose a weaker algorithm. The token
|
||||
must expire (exp), and not later than MAX_LIFETIME seconds from now, so that a
|
||||
stolen token is of little use; LEEWAY seconds of clock difference are allowed.
|
||||
"""
|
||||
try:
|
||||
header, payload, signature = token.split('.')
|
||||
if json.loads(b64decode(header)).get('alg') != 'HS256':
|
||||
return None
|
||||
expected = hmac.new(secret.encode(), f'{header}.{payload}'.encode(), hashlib.sha256).digest()
|
||||
if not hmac.compare_digest(expected, b64decode(signature)):
|
||||
return None
|
||||
claims = json.loads(b64decode(payload))
|
||||
now = time.time()
|
||||
expires = claims['exp']
|
||||
if isinstance(expires, bool) or not isinstance(expires, (int, float)) or not now - LEEWAY <= expires <= now + MAX_LIFETIME + LEEWAY:
|
||||
return None
|
||||
not_before = claims.get('nbf')
|
||||
if not_before is not None and (isinstance(not_before, bool) or not isinstance(not_before, (int, float)) or not_before > now + LEEWAY):
|
||||
return None
|
||||
except (ValueError, KeyError, TypeError, AttributeError):
|
||||
return None
|
||||
return claims
|
||||
|
||||
|
||||
def lines_of(*args):
|
||||
"""The output lines of a command, or None when it fails."""
|
||||
result = run(*args)
|
||||
return result.stdout.splitlines() if result and result.returncode == 0 else None
|
||||
|
||||
|
||||
def routes():
|
||||
"""The IPv4 routes as ip -j prints them, or None when ip fails."""
|
||||
result = run('ip', '-j', '-4', 'route', 'show')
|
||||
if result is None or result.returncode != 0:
|
||||
return None
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def wireguard_details():
|
||||
"""The interface and the peers of wg0, with endpoints and allowed IPs; no private or preshared key."""
|
||||
result = run('wg', 'show', 'wg0', 'dump')
|
||||
if result is None or result.returncode != 0:
|
||||
return None
|
||||
rows = [line.split('\t') for line in result.stdout.splitlines()]
|
||||
now = int(time.time())
|
||||
return {
|
||||
'public_key': rows[0][1] if rows and len(rows[0]) > 2 else None,
|
||||
'listen_port': int(rows[0][2]) if rows and len(rows[0]) > 2 else None,
|
||||
'peers': [{
|
||||
'public_key': row[0],
|
||||
'endpoint': None if row[2] == '(none)' else row[2],
|
||||
'allowed_ips': row[3].split(','),
|
||||
'handshake_seconds_ago': now - int(row[4]) if int(row[4]) else None,
|
||||
'received_bytes': int(row[5]),
|
||||
'sent_bytes': int(row[6]),
|
||||
} for row in rows[1:] if len(row) >= 7],
|
||||
}
|
||||
|
||||
|
||||
def details(state):
|
||||
"""What /config shows: the configuration without its secrets, and what the router resolved and runs."""
|
||||
configuration = state.get('configuration', {})
|
||||
return {
|
||||
'configuration': configuration,
|
||||
'network': dict(state.get('network', {}), routes=routes()),
|
||||
'ipsec': {
|
||||
'connections': swanctl('--list-conns'),
|
||||
'sas': swanctl('--list-sas'),
|
||||
},
|
||||
'wireguard': wireguard_details(),
|
||||
'docker': {
|
||||
'exposed_ports': configuration.get('docker', {}).get('exposed_ports', []),
|
||||
'rules': lines_of('iptables', '-S', 'VPN-ROUTER-DOCKER'),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def text_of(status):
|
||||
"""The status for people, as plain text."""
|
||||
lines = ['VPN Router', f'Status: {status["status"]}', f'Version: {status["version"] or "unknown"}', '']
|
||||
lines += [f'{name}: {value}' for name, value in status['units'].items()]
|
||||
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:
|
||||
value = status['site_to_site']['established']
|
||||
lines.append(f'Site to site: {"unknown" if value is None else "established" if value else "not established"}')
|
||||
if 'p2s' in status:
|
||||
clients = status['p2s']['clients']
|
||||
lines.append(f'Road-warrior (IPsec P2S): {"unknown" if clients is None else f"{clients} connected"}')
|
||||
if 'openvpn' in status:
|
||||
clients = status['openvpn']['clients']
|
||||
lines.append(f'OpenVPN: {"unknown" if clients is None else f"{clients} connected"}')
|
||||
if 'wireguard' in status:
|
||||
peers = status['wireguard']['peers']
|
||||
if peers is None:
|
||||
lines.append('WireGuard: down')
|
||||
else:
|
||||
lines.append(f'WireGuard: {len(peers)} peers')
|
||||
for number, peer in enumerate(peers, 1):
|
||||
age = peer['handshake_seconds_ago']
|
||||
lines.append(f' peer {number}: {"no handshake yet" if age is None else f"handshake {age} s ago"}, '
|
||||
f'received {peer["received_bytes"]} B, sent {peer["sent_bytes"]} B')
|
||||
return '\n'.join(lines) + '\n'
|
||||
|
||||
|
||||
def html_of(status):
|
||||
"""The status 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'
|
||||
# Each "name: value" line of the text is a row; indented lines, such as WireGuard peers, are indented here too.
|
||||
rows = []
|
||||
for line in text_of(status).splitlines()[4:]:
|
||||
if not line:
|
||||
continue
|
||||
name, _, value = line.partition(': ')
|
||||
indent = ' pl-8' if line.startswith(' ') else ''
|
||||
rows.append(f'<tr class="border-t border-slate-200 first:border-t-0 dark:border-slate-700">'
|
||||
f'<th class="py-2 pr-4{indent} text-left font-medium text-slate-600 dark:text-slate-300">{escape(name.strip())}</th>'
|
||||
f'<td class="py-2">{escape(value)}</td></tr>')
|
||||
body = ''.join(rows)
|
||||
return f"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta http-equiv="refresh" content="{REFRESH_SECONDS}">
|
||||
<title>VPN Router</title>
|
||||
<script src="{TAILWIND}"></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-100 text-slate-900 dark:bg-slate-900 dark:text-slate-100">
|
||||
<div class="mx-auto max-w-2xl px-4 py-10">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<table class="w-full text-sm"><tbody>{body}</tbody></table>
|
||||
</div>
|
||||
<p class="px-6 pb-6 text-xs text-slate-500 dark:text-slate-400">{escape(status['time'])} - <a class="underline" href="/status">JSON</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def user_tool(*args, password=None):
|
||||
"""Run vpn-router-user; the HTTP status for its exit status and its JSON answer."""
|
||||
try:
|
||||
result = subprocess.run([USER_TOOL, *args], input='' if password is None else password + '\n',
|
||||
capture_output=True, text=True, timeout=USER_TIMEOUT)
|
||||
document = json.loads(result.stdout)
|
||||
except (OSError, subprocess.TimeoutExpired, ValueError) as exc:
|
||||
return 500, {'error': f'vpn-router-user failed: {exc}'}
|
||||
if not isinstance(document, dict):
|
||||
return 500, {'error': 'vpn-router-user gave no object'}
|
||||
return USER_STATUS.get(result.returncode, 500), document
|
||||
|
||||
|
||||
def users(state, method, path, body):
|
||||
"""Answer a /users request: the HTTP status and the JSON document.
|
||||
|
||||
GET /users the users
|
||||
POST /users add a user: {"name": ..., "password": ...}
|
||||
PUT /users/<name>/password set the password: {"password": ...}
|
||||
POST /users/<name>/disable lock the user out of OpenVPN
|
||||
POST /users/<name>/enable let the user in again
|
||||
DELETE /users/<name> remove the user and revoke the certificate
|
||||
GET /users/<name>/profile the OpenVPN client profile
|
||||
"""
|
||||
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 {}
|
||||
password = body.get('password')
|
||||
if password is not None and (not isinstance(password, str) or '\n' in password or '\r' in password):
|
||||
return 400, {'error': 'the password must be a string of one line'}
|
||||
|
||||
parts = path.strip('/').split('/')
|
||||
route = (method, len(parts), parts[2] if len(parts) == 3 else None)
|
||||
if route == ('GET', 1, None):
|
||||
return user_tool('list')
|
||||
if route == ('POST', 1, None):
|
||||
name = body.get('name')
|
||||
if not isinstance(name, str) or password is None:
|
||||
return 400, {'error': 'name and password are required strings'}
|
||||
code, document = user_tool('add', name, password=password)
|
||||
return (201 if code == 200 else code), document
|
||||
if route == ('PUT', 3, 'password'):
|
||||
if password is None:
|
||||
return 400, {'error': 'password is a required string'}
|
||||
return user_tool('passwd', parts[1], password=password)
|
||||
if route in (('POST', 3, 'disable'), ('POST', 3, 'enable')):
|
||||
return user_tool(parts[2], parts[1])
|
||||
if route == ('DELETE', 2, None):
|
||||
return user_tool('remove', parts[1])
|
||||
if route == ('GET', 3, 'profile'):
|
||||
return user_tool('profile', parts[1])
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = 'HTTP/1.1'
|
||||
|
||||
def status(self):
|
||||
with _lock:
|
||||
if _cache['data'] is None or time.monotonic() - _cache['at'] > CACHE_SECONDS:
|
||||
_cache['data'] = collect(current())
|
||||
_cache['at'] = time.monotonic()
|
||||
return _cache['data']
|
||||
|
||||
def authorized(self, secret):
|
||||
"""Whether the request carries a token signed with the secret; False also while no secret is set."""
|
||||
header = self.headers.get('Authorization', '')
|
||||
return bool(secret) and header.startswith('Bearer ') and verify(header[7:].strip(), secret) is not None
|
||||
|
||||
def answer(self, send_body):
|
||||
path = self.path.split('?', 1)[0]
|
||||
extra = []
|
||||
# A body that is not read must not be taken for the next request.
|
||||
if self.command not in ('GET', 'HEAD'):
|
||||
self.close_connection = True
|
||||
protected = path == '/config' or path == '/users' or path.startswith('/users/')
|
||||
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
|
||||
elif protected and not self.authorized(state['jwt_secret']):
|
||||
body, kind, code = b'Unauthorized\n', 'text/plain; charset=utf-8', 401
|
||||
extra.append(('WWW-Authenticate', 'Bearer'))
|
||||
elif path == '/config' and self.command in ('GET', 'HEAD'):
|
||||
body, kind, code = json.dumps(details(state), indent=2).encode() + b'\n', 'application/json', 200
|
||||
elif protected and path != '/config':
|
||||
code, document = users(state, self.command, path, self.request_body())
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif self.command not in ('GET', 'HEAD'):
|
||||
body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405
|
||||
elif path == '/status':
|
||||
body, kind, code = json.dumps(self.status(), indent=2).encode() + b'\n', 'application/json', 200
|
||||
elif path == '/':
|
||||
agent = self.headers.get('User-Agent', '').lower()
|
||||
if 'curl' in agent or 'wget' in agent:
|
||||
body, kind = text_of(self.status()).encode(), 'text/plain; charset=utf-8'
|
||||
else:
|
||||
body, kind = html_of(self.status()).encode(), 'text/html; charset=utf-8'
|
||||
code = 200
|
||||
else:
|
||||
body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
|
||||
self.send_response(code)
|
||||
self.send_header('Content-Type', kind)
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
for name, value in extra:
|
||||
self.send_header(name, value)
|
||||
self.end_headers()
|
||||
if send_body:
|
||||
self.wfile.write(body)
|
||||
|
||||
def request_body(self):
|
||||
"""The request body as JSON, None when there is none, or the string 'invalid'."""
|
||||
length = self.headers.get('Content-Length')
|
||||
if not length:
|
||||
return None
|
||||
try:
|
||||
size = int(length)
|
||||
if not 0 <= size <= BODY_LIMIT:
|
||||
return 'invalid'
|
||||
return json.loads(self.rfile.read(size) or b'null')
|
||||
except ValueError:
|
||||
return 'invalid'
|
||||
|
||||
def do_GET(self):
|
||||
self.answer(True)
|
||||
|
||||
def do_HEAD(self):
|
||||
self.answer(False)
|
||||
|
||||
def do_POST(self):
|
||||
self.answer(True)
|
||||
|
||||
def do_PUT(self):
|
||||
self.answer(True)
|
||||
|
||||
def do_DELETE(self):
|
||||
self.answer(True)
|
||||
|
||||
def log_message(self, 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():
|
||||
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
||||
discovery = threading.Thread(target=discover, name='discovery', daemon=True)
|
||||
discovery.start()
|
||||
try:
|
||||
discovery.join()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
Executable
+225
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/python3
|
||||
"""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
|
||||
certificate of the router CA with the user name as its common name, which OpenVPN and
|
||||
IKEv2 accept. Passwords are read from the first line of standard input, never from the
|
||||
command line. The answer is JSON on standard output; on failure it is {"error": ...} and
|
||||
the exit status says why: 2 the request is invalid, 3 the user does not exist, 4 the user
|
||||
exists, 1 anything else.
|
||||
|
||||
vpn-router-user list
|
||||
vpn-router-user add <name> (password on stdin)
|
||||
vpn-router-user passwd <name> (password on stdin)
|
||||
vpn-router-user disable <name>
|
||||
vpn-router-user enable <name>
|
||||
vpn-router-user remove <name>
|
||||
vpn-router-user profile <name>
|
||||
"""
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '/usr/lib/vpn-router')
|
||||
|
||||
import ldap
|
||||
import ldap.modlist
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_ldap
|
||||
|
||||
#: A user name is one lowercase DNS label, as it is also the common name of the certificate.
|
||||
NAME = re.compile(r'^[a-z][a-z0-9-]{0,30}[a-z0-9]$|^[a-z]$')
|
||||
PASSWORD_MIN = 8
|
||||
PASSWORD_MAX = 128
|
||||
#: The value of pwdAccountLockedTime that locks an account until it is unlocked.
|
||||
LOCKED = b'000001010000Z'
|
||||
LOCK_FILE = pathlib.Path('/run/vpn-router/users.lock')
|
||||
|
||||
|
||||
class Failure(Exception):
|
||||
"""A request that cannot be done; status is the exit status."""
|
||||
|
||||
def __init__(self, message, status=1):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
||||
|
||||
def dn_of(name):
|
||||
return f'uid={name},{vpnrouter_ldap.USERS}'
|
||||
|
||||
|
||||
def certificate(name):
|
||||
return vpnrouter.USER_CERT_DIR / f'{name}_cert.pem', vpnrouter.USER_CERT_DIR / f'{name}_key.pem'
|
||||
|
||||
|
||||
def entry(connection, name):
|
||||
"""The attributes of the user's entry; Failure with status 3 when there is none."""
|
||||
try:
|
||||
return connection.search_s(dn_of(name), ldap.SCOPE_BASE, attrlist=['uid', 'pwdAccountLockedTime'])[0][1]
|
||||
except ldap.NO_SUCH_OBJECT:
|
||||
raise Failure(f'user {name} does not exist', 3)
|
||||
|
||||
|
||||
def password():
|
||||
"""The password from the first line of standard input."""
|
||||
value = sys.stdin.readline().rstrip('\n')
|
||||
if not PASSWORD_MIN <= len(value) <= PASSWORD_MAX:
|
||||
raise Failure(f'the password must have {PASSWORD_MIN} to {PASSWORD_MAX} characters', 2)
|
||||
return value
|
||||
|
||||
|
||||
def simple_ca(*args):
|
||||
result = subprocess.run([sys.executable, str(vpnrouter.SIMPLE_CA), *args], capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise Failure(f'simple-ca {args[0]} failed: {result.stderr.strip() or result.stdout.strip()}')
|
||||
|
||||
|
||||
def publish_crl():
|
||||
"""Give strongSwan the CRL after a revocation; OpenVPN reads it at every connection."""
|
||||
if vpnrouter.install_file(vpnrouter.PKI_DIR / 'crl.pem', vpnrouter.CRL_DEST, 0o644):
|
||||
subprocess.run(['swanctl', '--load-creds'], capture_output=True, text=True)
|
||||
|
||||
|
||||
def summary(attributes):
|
||||
name = attributes['uid'][0].decode()
|
||||
return {
|
||||
'name': name,
|
||||
'enabled': 'pwdAccountLockedTime' not in attributes,
|
||||
'certificate': certificate(name)[0].is_file(),
|
||||
}
|
||||
|
||||
|
||||
def list_users(connection):
|
||||
found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL, '(objectClass=inetOrgPerson)',
|
||||
['uid', 'pwdAccountLockedTime'])
|
||||
return {'users': sorted((summary(attributes) for _, attributes in found), key=lambda user: user['name'])}
|
||||
|
||||
|
||||
def add(connection, name):
|
||||
secret = password()
|
||||
if not (vpnrouter.PKI_DIR / 'ca_key.pem').is_file():
|
||||
raise Failure('the router CA does not exist; vpn-router-setup creates it once a platform is chosen')
|
||||
try:
|
||||
connection.add_s(dn_of(name), ldap.modlist.addModlist({
|
||||
'objectClass': [b'inetOrgPerson'], 'uid': [name.encode()], 'cn': [name.encode()], 'sn': [name.encode()],
|
||||
}))
|
||||
except ldap.ALREADY_EXISTS:
|
||||
raise Failure(f'user {name} exists', 4)
|
||||
connection.passwd_s(dn_of(name), None, secret)
|
||||
vpnrouter.USER_CERT_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
for path in certificate(name):
|
||||
path.unlink(missing_ok=True)
|
||||
simple_ca('make-cert', '--ca-dir', str(vpnrouter.PKI_DIR), '--cert-dir', str(vpnrouter.USER_CERT_DIR), name)
|
||||
return summary(entry(connection, name))
|
||||
|
||||
|
||||
def passwd(connection, name):
|
||||
secret = password()
|
||||
entry(connection, name)
|
||||
connection.passwd_s(dn_of(name), None, secret)
|
||||
return summary(entry(connection, name))
|
||||
|
||||
|
||||
def disable(connection, name):
|
||||
if 'pwdAccountLockedTime' not in entry(connection, name):
|
||||
connection.modify_s(dn_of(name), [(ldap.MOD_REPLACE, 'pwdAccountLockedTime', [LOCKED])])
|
||||
return summary(entry(connection, name))
|
||||
|
||||
|
||||
def enable(connection, name):
|
||||
if 'pwdAccountLockedTime' in entry(connection, name):
|
||||
connection.modify_s(dn_of(name), [(ldap.MOD_DELETE, 'pwdAccountLockedTime', None)])
|
||||
return summary(entry(connection, name))
|
||||
|
||||
|
||||
def remove(connection, name):
|
||||
entry(connection, name)
|
||||
cert, key = certificate(name)
|
||||
if cert.is_file():
|
||||
simple_ca('revoke-cert', '--ca-dir', str(vpnrouter.PKI_DIR), str(cert))
|
||||
simple_ca('make-crl', '--ca-dir', str(vpnrouter.PKI_DIR), '--days', str(vpnrouter.CRL_DAYS))
|
||||
publish_crl()
|
||||
cert.unlink(missing_ok=True)
|
||||
key.unlink(missing_ok=True)
|
||||
connection.delete_s(dn_of(name))
|
||||
return {'name': name, 'removed': True}
|
||||
|
||||
|
||||
def profile(connection, name):
|
||||
"""The OpenVPN client profile of the user, with the CA, the certificate and the key inline."""
|
||||
entry(connection, name)
|
||||
cfg = vpnrouter.load_config()
|
||||
if not cfg['openvpn']['enabled']:
|
||||
raise Failure('OpenVPN is not enabled', 2)
|
||||
cert, key = certificate(name)
|
||||
if not cert.is_file() or not key.is_file():
|
||||
raise Failure(f'user {name} has no certificate')
|
||||
fqdn = cfg['wan']['local_fqdn'].strip()
|
||||
text = '\n'.join([
|
||||
'client',
|
||||
'dev tun',
|
||||
f'proto {cfg["openvpn"]["protocol"]}',
|
||||
f'remote {fqdn} {cfg["openvpn"]["port"]}',
|
||||
'nobind',
|
||||
'persist-key',
|
||||
'persist-tun',
|
||||
'remote-cert-tls server',
|
||||
f'verify-x509-name {fqdn} name',
|
||||
'auth-user-pass',
|
||||
'auth-nocache',
|
||||
'verb 3',
|
||||
'<ca>', extract_pem(vpnrouter.CA_CERT_DEST.read_text(encoding='utf-8')), '</ca>',
|
||||
'<cert>', extract_pem(cert.read_text(encoding='utf-8')), '</cert>',
|
||||
'<key>', key.read_text(encoding='utf-8').strip(), '</key>',
|
||||
'',
|
||||
])
|
||||
return {'name': name, 'profile': text}
|
||||
|
||||
|
||||
def extract_pem(text):
|
||||
"""The PEM blocks of the certificates in a file that also holds their text form."""
|
||||
begin, end = '-----BEGIN CERTIFICATE-----', '-----END CERTIFICATE-----'
|
||||
blocks = []
|
||||
start = text.find(begin)
|
||||
while start != -1:
|
||||
stop = text.index(end, start) + len(end)
|
||||
blocks.append(text[start:stop])
|
||||
start = text.find(begin, stop)
|
||||
return '\n'.join(blocks)
|
||||
|
||||
|
||||
COMMANDS = {'list': list_users, 'add': add, 'passwd': passwd, 'disable': disable, 'enable': enable,
|
||||
'remove': remove, 'profile': profile}
|
||||
|
||||
|
||||
def main(argv):
|
||||
try:
|
||||
if not argv or argv[0] not in COMMANDS or len(argv) != (1 if argv[0] == 'list' else 2):
|
||||
raise Failure('usage: vpn-router-user list | add|passwd|disable|enable|remove|profile <name>', 2)
|
||||
if len(argv) == 2 and not NAME.match(argv[1]):
|
||||
raise Failure('a user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter', 2)
|
||||
LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(LOCK_FILE, 'w') as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
connection = vpnrouter_ldap.manager()
|
||||
try:
|
||||
answer = COMMANDS[argv[0]](connection, *argv[1:])
|
||||
finally:
|
||||
connection.unbind_s()
|
||||
except Failure as failure:
|
||||
print(json.dumps({'error': str(failure)}))
|
||||
return failure.status
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
print(json.dumps({'error': f'the user directory failed: {exc}'}))
|
||||
return 1
|
||||
print(json.dumps(answer))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -32,5 +32,20 @@
|
||||
"enabled": false,
|
||||
"address": "192.168.200.1/24",
|
||||
"listen_port": 51820
|
||||
},
|
||||
"openvpn": {
|
||||
"enabled": false,
|
||||
"address_pool": "172.16.1.0/24",
|
||||
"port": 1194,
|
||||
"protocol": "udp"
|
||||
},
|
||||
"docker": {
|
||||
"mode": "auto",
|
||||
"exposed_ports": ["443"]
|
||||
},
|
||||
"web": {
|
||||
"enabled": true,
|
||||
"port": 8080,
|
||||
"jwt_secret": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<LDAP>
|
||||
URL {{ ldap_uri }}
|
||||
Timeout 5
|
||||
TLSEnable no
|
||||
FollowReferrals no
|
||||
</LDAP>
|
||||
|
||||
<Authorization>
|
||||
BaseDN "{{ ldap_users }}"
|
||||
SearchFilter "(&(objectClass=inetOrgPerson)(uid=%u))"
|
||||
RequireGroup false
|
||||
</Authorization>
|
||||
@@ -0,0 +1,30 @@
|
||||
port {{ openvpn_port }}
|
||||
proto {{ openvpn_protocol }}
|
||||
dev {{ openvpn_device }}
|
||||
dev-type tun
|
||||
topology subnet
|
||||
server {{ openvpn_network }} {{ openvpn_netmask }}
|
||||
|
||||
ca {{ ca_cert }}
|
||||
cert {{ server_cert }}
|
||||
key {{ server_key }}
|
||||
crl-verify {{ crl }}
|
||||
dh none
|
||||
remote-cert-tls client
|
||||
|
||||
plugin /usr/lib/openvpn/openvpn-auth-ldap.so {{ openvpn_ldap_conf }}
|
||||
script-security 2
|
||||
auth-user-pass-verify /usr/lib/vpn-router/openvpn-same-user via-env
|
||||
|
||||
{% for network, netmask in openvpn_routes -%}
|
||||
push "route {{ network }} {{ netmask }}"
|
||||
{% endfor -%}
|
||||
{% for address in p2s_dns_list -%}
|
||||
push "dhcp-option DNS {{ address }}"
|
||||
{% endfor -%}
|
||||
|
||||
keepalive 10 120
|
||||
{% if openvpn_protocol == 'udp' -%}
|
||||
explicit-exit-notify 1
|
||||
{% endif -%}
|
||||
verb 3
|
||||
@@ -14,7 +14,7 @@ connections {
|
||||
}
|
||||
|
||||
remote {
|
||||
id = @{{ remote_id }}
|
||||
id = {{ remote_id }}
|
||||
auth = psk
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ connections {
|
||||
secrets {
|
||||
ike-psk {
|
||||
id-1 = {{ local_id }}
|
||||
id-2 = @{{ remote_id }}
|
||||
id-2 = {{ remote_id }}
|
||||
secret = "{{ psk }}"
|
||||
}
|
||||
}
|
||||
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