Compare commits

..
19 Commits
Author SHA1 Message Date
slawek cda33fc4c7 fix: an upgrade keeps the configuration and asks nothing.
Release / release (push) Successful in 18s
2026-10-24 02:08:23 +02:00
slawek 08693f7e68 feature: users have a first name, a last name and an e-mail address.
Release / release (push) Successful in 11s
2026-10-24 01:47:50 +02:00
slawek b9003bacb9 update: the status web service listens on port 7081 by default.
Release / release (push) Successful in 11s
2026-10-24 01:14:21 +02:00
slawek 0bf78b16b1 feature: users and their certificates no longer need a P2S system enabled.
Release / release (push) Successful in 21s
2026-10-24 00:00:44 +02:00
slawek 0ff74cc8c9 feature: the firewall lets the Router Manager in on the internal interface.
Release / release (push) Successful in 11s
2026-10-23 23:24:59 +02:00
slawek a7b29e0655 fix: the status always shows the state of slapd.
Release / release (push) Successful in 19s
2026-10-23 22:59:17 +02:00
slawek c377801be0 fix: /config no longer shows secret options.
Release / release (push) Successful in 11s
2026-10-23 22:48:31 +02:00
slawek a4cffb67e4 fix: the web service works in every state of the router.
Release / release (push) Successful in 11s
2026-10-23 22:35:53 +02:00
slawek cc6748a622 update: Added OpenLDAP and OpenVPN to the router.
Release / release (push) Successful in 18s
2026-10-23 21:55:03 +02:00
slawek 609921348b fix: incorrect swanctl parsing.
Release / release (push) Successful in 18s
2026-10-23 20:56:52 +02:00
slawek 90e7c79840 fix: replace webservice swanctl call human readble output with structured one.
Release / release (push) Successful in 18s
2026-10-23 20:44:17 +02:00
slawek a6b25b8bb4 fix: router setup hangs on race condition with Docker.
Release / release (push) Successful in 18s
2026-10-23 20:03:17 +02:00
slawek 7163f167e0 feature: Added an internally only exposed status web service.
Release / release (push) Successful in 17s
2026-10-23 19:46:26 +02:00
slawek 5402256ef6 fix: removed unnecressary peer ID handling that is no longer required for >=5.0 StrongSwan.
Release / release (push) Successful in 17s
2026-10-23 17:31:22 +02:00
slawek 64da0cf97f fix: update interface detection code to support additional interfaces Docker adds.
Release / release (push) Successful in 17s
2026-10-23 13:43:46 +02:00
slawek 2e0cee10ac feat: added support for running the VPN router on a host with Docker Community Edition.
Release / release (push) Successful in 1m8s
2026-10-23 13:17:32 +02:00
slawek 66fcd69d40 feat: change the workflow to use owner secrets.
Release / release (push) Successful in 18s
2026-10-23 08:24:40 +02:00
slawek 19567aa273 feat: add Gitea workflow that builds package on tag push.
Release / release (push) Failing after 1m11s
2026-10-23 08:10:15 +02:00
slawek 656e1733d5 feat: change config from INI to JSON. 2026-10-23 08:04:55 +02:00
32 changed files with 3192 additions and 177 deletions

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
name: Release
on:
workflow_dispatch:
push:
tags:
- '*.*.*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Build package
working-directory: debian-package
run: ./build.sh
- name: Publish package
working-directory: debian-package
env:
SERVER_URL: ${{ secrets.CI_URL }}
OWNER: ${{ secrets.CI_OWNER }}
PACKAGES_TOKEN: ${{ secrets.CI_TOKEN }}
run: ./publish.sh
+249
View File
@@ -0,0 +1,249 @@
# 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 small status web service, bound to the internal address
Status: Accepted
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 on the standard library's
`http.server`, with the package's own modules and the Python packages of the Ubuntu archive that
the package depends on, and no web framework. It listens on the address of the internal interface and on
`web.port`, 7081 by default, and is on unless `web.enabled` is `false` (ADR-7). `/` 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, OpenVPN and WireGuard features, and nothing from
the configuration. `vpn-router-setup` opens the port on the internal interface in UFW and writes
the features and the facts it resolves to `/run/vpn-router/web.json`.
Consequences: Nothing outside the internal network can reach it, since it listens on the internal
address only and the firewall opens the port on the internal interface only. `/` and `/status`
have no authentication, which is acceptable because they are read only and show no
configuration. It runs as root, because `swanctl` and `wg` need it, with `NoNewPrivileges`,
`ProtectSystem=strict`, `ProtectHome` and `PrivateTmp`. The Tailwind style comes from a CDN, so a
browser on a network without internet shows the unstyled page, and `curl` is unaffected.
## ADR-3: /config is authenticated with a short-lived JWT signed with a shared secret
Status: Accepted
Context: A client such as the Router Manager needs the configuration and the resolved state of
the router, which `/` and `/status` must not show. The service listens on plain HTTP on the
internal network, and its configuration file is readable by root only. The platform that deploys
the router generates one secret and gives it to the router's configuration and to the client.
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 a shared secret is what both sides have; RS256 or ES256 would need a key pair
and its distribution. 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 configuration
shown has no secret options (ADR-8).
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 only in the
configuration file, 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.
## 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, including IKEv2 EAP-TLS and WireGuard, which an OIDC provider cannot
authenticate. Everything the router runs comes from the Ubuntu archive. 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. An OIDC provider
can read the directory 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
service account's password is a root-only file. The directory is user data: it is kept 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 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 and vpn-router-user share one module for users
Status: Accepted
Context: Users are managed from the command line, by an operator at a terminal, and through
`/users` of the web service, by the Router Manager. Both must apply the same rules to names,
passwords and person attributes, and both change the directory and the CA. `simple-ca` keeps
the CA's state in one file, so two changes must not run at the same time. The web unit is
sandboxed with `ProtectSystem=strict`.
Decision: `vpnrouter_users.py` holds the user operations, the checks and a session that takes a
lock and the connection of the user manager account. The web service imports it and maps a
`Failure` to the HTTP status; `vpn-router-user` imports it and adds the argument parsing and
the output, text for people and JSON with `--json`. The web unit allows writing the paths the
operations need: `/run/vpn-router` for the lock, `/etc/vpn-router/pki` for the certificates and
the CA's state, and `/etc/swanctl/x509crl` for the published CRL.
Consequences: The rules exist once. A request starts no process, and a password never leaves
the process that received it. The web service imports `python3-ldap` and the package's
configuration module. 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 status page is the tool to see a problem of `vpn-router-setup`, so it must run
whatever state setup is in, also before setup has run and while it fails. The internal address,
`web.port` and `web.enabled` can change at any time.
Decision: The service depends on nothing to start and does not end on its own. A discovery
thread works out, every 30 seconds, the internal address from `vpn-router.json` as
`general.mode` directs, with the discovery code in `vpnrouter_net.py`, which `vpnrouter.py` uses
too, and `web.port` and `web.enabled` from the same file. It runs the HTTP server in a thread of
its own at that address and port, stops it and starts a new one when they change, and runs none
while the service is disabled, the address is not known or it cannot be bound. Each request
reads the token secret from `vpn-router.json` and the features and resolved facts from
`web.json`, which setup writes when it applies the configuration, so the service shows what setup
applied; while that file is missing the router shows no features. The unit is not
ordered after `vpn-router-setup`, and setup does not restart it.
Consequences: The status page answers as soon as the internal address exists, whatever state
setup is in, and follows a changed address, port or `web.enabled` within 30 seconds without a
restart. A change of the secret applies to the next request. The interfaces are read every 30
seconds, which costs a few `ip` commands.
## ADR-8: /config leaves secret options out
Status: Accepted
Context: A secret has no use outside the router, so a client never needs it, and showing even
that a secret is set tells a reader more than it needs.
Decision: The configuration that `vpn-router-setup` writes for the web service has no secret
options at all. They are recognised by name: `psk`, and options with `secret`, `password`,
`token` or a `_key` ending in the name.
Consequences: No secret reaches the web service's state file or any answer of the API. A client
cannot tell whether a secret is set. A new secret option needs a name that matches.
## ADR-9: The Router Manager is a separate package with a small Python server
Status: Accepted
Context: The router needs a web application to manage it, which may run on the router or on
another Linux system. The router's web service needs a token signed with `web.jwt_secret` for
`/config` and `/users` and sends no CORS headers, so a browser cannot call it with the secret
kept away from the page.
Decision: `vpn-router-manager` is a Debian package of its own, built in the repository
`linux-cloud-router-manager`. A React and MUI application in the style of manage-ui is built
with Vite when the package is built and shipped as static files. A Python server, using the
Ubuntu packages `python3-jwt` and `python3-requests`, serves them and passes every `/api/<path>`
call to the router's `/<path>`, signing a short-lived token with the router's secret; it holds
no logic of the router's own. It reads its settings from `/etc/vpn-router-manager/manager.json`
at each request; on the router the settings not given come from `vpn-router.json`, and it
listens on the internal address like the status web service, with the same discovery thread.
`vpn-router-setup` opens the manager's port on the internal interface while the manager is
installed, so the firewall stays in one place. The manager asks for no authentication.
Consequences: The router needs no Node.js; the package needs only `python3` and packages of the
Ubuntu archive. The secret never reaches the browser. The application checks the shape of every
answer of the router. Anyone who reaches the manager's port manages the router, so on another
system it listens on `127.0.0.1` unless told otherwise. The router's firewall depends on whether
the manager is installed, and a changed manager port needs `vpn-router-setup` to run again.
## ADR-10: Users and their certificates do not depend on a P2S system being enabled
Status: Accepted
Context: Users are prepared before a P2S system is switched on, and the Router Manager manages
them on any configured router, with or without one.
Decision: Once a platform is chosen, `vpn-router-setup` always provisions the user directory in
slapd and the CA with its CRL; the server certificate follows once `wan.local_fqdn` is set.
`/users` and `vpn-router-user` rely only on the directory and the CA. Only the OpenVPN client
profile needs OpenVPN to be enabled.
Consequences: slapd always holds the directory and the CA always exists on a configured router,
even when no P2S system uses them. An operator who wants a CA of their own must place it before
choosing the platform.
## ADR-11: Groups are groupOfNames entries that never become empty
Status: Accepted
Context: Users are to be organised in groups. Groups record membership only; nothing on the
router reads them. LDAP clients that read groups, such as `openvpn-auth-ldap` and the LDAP
connectors of OIDC providers, expect `groupOfNames` with member DNs, and OpenLDAP's core schema
requires a `groupOfNames` to have at least one `member`.
Decision: A group is `cn=<name>,ou=groups,dc=vpn-router,dc=local`, a `groupOfNames` with the
DNs of its members and an optional `description`. A group is created with at least one member,
and taking out its last member is refused; such a group is removed instead. Removing a user
takes it out of its groups and is refused while the user is the last member of one. The group
operations are in `vpnrouter_users.py` with the user operations, so they share the session and
its lock; `vpn-router-group` is a tool of its own beside `vpn-router-user`, and the web service
serves them under `/groups`. Group names follow the rules of user names.
Consequences: A group always satisfies the schema without a placeholder member, and an empty
group cannot exist. Removing a user or a member can need another step first. A later use of
groups, such as allowing OpenVPN only to some, can read them as they are.
+339 -15
View File
@@ -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
@@ -78,8 +85,10 @@ that deploy a router on Azure or GCP.
## Configuration
`/etc/vpn-router/vpn-router.json` is JSON, mode 0600, and is created on first install. It
is managed with `ucf`, so it is yours to edit: a later `dpkg-reconfigure` applies what
changed and asks before touching anything you altered by hand. A complete example is
is managed with `ucf`, so it is yours to edit: a later `dpkg-reconfigure` applies the answers
given and asks before touching anything you altered by hand, and an upgrade leaves the file as
it is and asks nothing. Settings the file leaves out take their defaults when it is read, so a
file written by cloud-init or by hand needs only the settings that differ. A complete example is
installed at `/usr/share/doc/vpn-router/vpn-router.json.example`.
The file is an object of sections, each an object of settings. A section or setting that
@@ -92,13 +101,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 +117,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 | `7081` | TCP port of the status web service |
| `web` | `jwt_secret` | string | | Secret, at least 32 characters, that signs the tokens for `/config`; without it `/config` is not served |
A minimal site-to-site configuration in `auto` mode:
@@ -125,6 +143,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 +164,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 +242,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 +274,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 +297,115 @@ 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`;
- `ou=groups` holds one `groupOfNames` per group, named by `cn`, with its members' DNs in
`member` and an optional `description`;
- `cn=user-manager,ou=services` is the account that changes users and groups; it may write
`ou=users` and `ou=groups` 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, with a first name, a last
name and an e-mail address (`givenName`, `sn` and `mail`; `cn` is the first and the last name),
and a client certificate with the user name as its common name, in `/etc/vpn-router/pki/users`.
Passwords are read from the first line of standard input. The answer is text: a table of the
users, the client profile or a short message, and a failure is a message on standard error.
`--json`, before the command, gives the answer as JSON on standard output, and a failure as
`{"error": ...}` there too; the web service uses it. `-h` or `--help` shows the commands, and
after a command its options.
```sh
echo 's3cr3t-pass' | vpn-router-user add alice --first-name Alice --last-name Smith --email alice@example.com
vpn-router-user update alice --email a.smith@example.com
vpn-router-user list
vpn-router-user profile alice > alice.ovpn
vpn-router-user --json list
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 with their names and e-mail addresses, whether each is enabled and has a certificate |
| `add <name> --first-name F --last-name L --email E` | Adds the user and issues the certificate; all three are required |
| `update <name> [--first-name F] [--last-name L] [--email E]` | Changes the names or the e-mail address |
| `passwd <name>` | Sets the password |
| `disable <name>`, `enable <name>` | Locks the user out of OpenVPN, and lets the user in again |
| `remove <name>` | Takes the user out of its groups, revokes the certificate, renews the CRL and removes the user; refused while the user is the last member of a group |
| `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; a first or last name has 1 to 64 printable characters; an
e-mail address has one `@`, no spaces and a dot in its domain. In the answers a user added
without the names or the e-mail address has them as `null`. 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.
### Managing groups
`vpn-router-group` manages the groups. A group has a name, with the rules of a user name, an
optional description of at most 256 printable characters, and at least one member, each a user
of the directory. Groups record membership only; nothing on the router reads them. The output,
`--json`, `-h` and `--help` work as for `vpn-router-user`.
```sh
vpn-router-group add ops --member alice --member bob --description 'Operations team'
vpn-router-group list
vpn-router-group update ops --name netops
vpn-router-group update netops --description ''
vpn-router-group member-add netops carol
vpn-router-group member-remove netops bob
vpn-router-group remove netops
```
| Command | Effect |
| --- | --- |
| `list` | The groups with their members and descriptions |
| `add <name> --member U [--member U ...] [--description D]` | Adds the group with its members |
| `update <name> [--name N] [--description D]` | Renames the group or sets its description; `''` removes it |
| `remove <name>` | Removes the group; its members are kept |
| `member-add <name> <user>` | Adds the user to the group |
| `member-remove <name> <user>` | Takes the user out of the group; refused for the last member |
The exit status is 0 on success, 2 for an invalid request, 3 when the group or the user does
not exist, 4 when the group exists and 1 for any other failure.
## Platform modules
Platform-specific values do not belong in the core, so they are Python modules in
@@ -254,17 +447,148 @@ 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` (7081 by default). Its pages `/` and `/status` need no authentication and show 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 |
| `/groups` | The groups of users; 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:7081/
curl http://10.0.0.4:7081/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:7081/config
```
### The /users API
`/users` manages users with the same token as `/config`, with the module `vpn-router-user` uses
too, `/usr/lib/vpn-router/vpnrouter_users.py`, so both apply the same rules. Request
and answer bodies are JSON; a request body is at most 4096 bytes.
| Request | Body | Answer |
| --- | --- | --- |
| `GET /users` | | `{"users": [{"name", "enabled", "certificate", "first_name", "last_name", "email"}, ...]}` |
| `POST /users` | `{"name", "password", "first_name", "last_name", "email"}`, all required | 201 and the user |
| `PUT /users/<name>` | any of `{"first_name", "last_name", "email"}` | 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", "first_name": "Alice", "last_name": "Smith", "email": "alice@example.com"}' \
http://10.0.0.4:7081/users
```
### The /groups API
`/groups` manages groups with the same token and the same module as `/users`.
| Request | Body | Answer |
| --- | --- | --- |
| `GET /groups` | | `{"groups": [{"name", "description", "members": [...]}, ...]}` |
| `POST /groups` | `{"name", "members": [...]}`, and `"description"` | 201 and the group |
| `PUT /groups/<name>` | any of `{"name", "description"}`; `""` removes the description | the group |
| `DELETE /groups/<name>` | | `{"name", "removed": true}` |
| `POST /groups/<name>/members` | `{"user": ...}` | the group |
| `DELETE /groups/<name>/members/<user>` | | the group |
A failure is `{"error": ...}` with 400 for an invalid request, among them taking out the last
member, 404 for a group or user that does not exist, 409 for a group that exists and 500
otherwise. `DELETE /users/<name>` is refused with 400 while the user is the last member of a
group.
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`,
7080 by default, HTTP; 7443 is kept for it with TLS) 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
View File
Whitespace-only changes.
+10 -2
View File
@@ -1,9 +1,17 @@
#!/bin/sh
set -e
# Apple container CLI on macOS, Docker CE on Linux.
case "$(uname -s)" in
Darwin) CONTAINER_RUNTIME=container ;;
Linux) CONTAINER_RUNTIME=docker ;;
*) printf 'ERROR: unsupported platform %s\n' "$(uname -s)" >&2; exit 1 ;;
esac
mkdir -p out
container build --build-arg HOST_UID=$(id -u) --build-arg HOST_GID=$(id -g) -t vpn-router-builder "$@" .
container run --rm -v "$(pwd)/out:/mnt" vpn-router-builder sh -c 'cp -p /out/* /mnt/'
$CONTAINER_RUNTIME build --build-arg HOST_UID=$(id -u) --build-arg HOST_GID=$(id -g) -t vpn-router-builder "$@" .
# Artifacts are streamed out as a tar archive, so no bind mount is needed.
$CONTAINER_RUNTIME run --rm vpn-router-builder tar -cf - -C /out . | tar -xf - -C out
echo "Build artifacts written to out/"
+250 -1
View File
@@ -1,4 +1,253 @@
vpn-router (1.0.0-1) unstable; urgency=medium
vpn-router (0.5.8-1) unstable; urgency=medium
fix: an upgrade keeps the configuration and asks nothing.
* postinst passes on only the debconf answers that were given. The
defaults of questions never asked, such as platform none, no longer
replace settings of vpn-router.json.
* Without an answer that changes it, the configuration ucf is given is
vpn-router.json byte for byte, so an upgrade has no conflict to ask
about; settings the file leaves out keep their defaults when it is read.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 20:00:00 +0200
vpn-router (0.5.7-1) unstable; urgency=medium
feature: users have a first name, a last name and an e-mail address.
* vpn-router-user add needs --first-name, --last-name and --email, kept as
givenName, sn and mail, with cn set to the first and the last name.
vpn-router-user update changes them, and list and every answer show
them as first_name, last_name and email.
* POST /users needs first_name, last_name and email, and PUT
/users/<name> changes them.
* vpn-router-user answers in text: a table of the users, the client
profile or a short message, with failures on standard error. --json
gives the JSON answers. -h and --help show the commands and their
options.
* The web service manages users with vpnrouter_users, the module
vpn-router-user uses, instead of running vpn-router-user for each
request.
* Groups of users, kept as groupOfNames entries in ou=groups with a
description and at least one member. vpn-router-group lists, adds,
renames, describes and removes them and adds and takes out members, and
the web service serves the same under /groups. Removing a user takes it
out of its groups and is refused while it is the last member of one.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 19:00:00 +0200
vpn-router (0.5.6-1) unstable; urgency=medium
update: the status web service listens on port 7081 by default.
* web.port defaults to 7081.
* vpn-router-setup opens the Router Manager's port, 7080 by default, on
the internal interface.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 18:00:00 +0200
vpn-router (0.5.5-1) unstable; urgency=medium
feature: users and their certificates no longer need a P2S system enabled.
* vpn-router-setup provisions the user directory, the CA and its CRL once
a platform is chosen, whether or not OpenVPN or road-warrior access is
enabled. The server certificate is issued once wan.local_fqdn is set,
also for an existing CA that has none.
* /users and vpn-router-user manage users and issue and revoke their
certificates without a P2S system; only the OpenVPN client profile
needs OpenVPN.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 16:00:00 +0200
vpn-router (0.5.4-1) unstable; urgency=medium
feature: the firewall lets the Router Manager in on the internal interface.
* While vpn-router-manager is installed, vpn-router-setup opens its port,
port in /etc/vpn-router-manager/manager.json or 8090, on the internal
interface. Purging the package removes the rule.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 13:00:00 +0200
vpn-router (0.5.3-1) unstable; urgency=medium
fix: the status always shows the state of slapd.
* /status and the status page show the state of slapd whether or not
OpenVPN or road-warrior access is configured, since the package always
runs it.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 12:00:00 +0200
vpn-router (0.5.2-1) unstable; urgency=medium
fix: /config no longer shows secret options.
* The configuration shown by /config leaves out the secret options (psk,
jwt_secret, and options named like a password, token or key) instead of
showing them masked.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 11:00:00 +0200
vpn-router (0.5.1-1) unstable; urgency=medium
fix: the web service works in every state of the router.
* vpn-router-web works whether /etc/vpn-router/pki and
/etc/swanctl/x509crl exist or not; they exist only while OpenVPN or
road-warrior access is configured. The paths the unit may write are
used when they exist and ignored when they do not.
* vpn-router-web starts on its own and keeps running. Every 30 seconds a
discovery thread works out the internal address from vpn-router.json as
general.mode directs, with web.port and web.enabled, and starts, moves
or stops the server thread to match. Each request reads the token
secret, the features and the facts it shows. The service is no longer
ordered after vpn-router-setup, which no longer restarts it, and
web.json no longer holds the address, the port or the secret.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 10:00:00 +0200
vpn-router (0.5.0-1) unstable; urgency=medium
* 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.
Lists are arrays, booleans and the WireGuard listen port are native JSON
types, and unknown sections, unknown settings and wrong value types are
rejected.
* Replace remote.psk_b64 with remote.psk, holding the pre-shared key as a
plain string. remote.psk_file still takes precedence.
* Ship /usr/share/doc/vpn-router/vpn-router.json.example in place of
vpn-router.conf.example.
* Add platform "none" as the default, so installing the package changes
nothing on the machine until a platform is chosen.
* Add the mode setting (manual, interfaces, auto) controlling how much of
the network configuration is supplied and how much is read from the
system.
* Manage the configuration file with ucf, so dpkg-reconfigure reapplies
debconf answers without overwriting local edits.
* Apply NAT and forward rules to every local subnet, not only the first.
-- Sławomir Koszewski <slawek@koszewscy.waw.pl> Fri, 23 Oct 2026 08:00:06 +0200
vpn-router (0.1.0-1) unstable; urgency=medium
* Initial release.
+11 -5
View File
@@ -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
+8
View File
@@ -1,8 +1,16 @@
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/vpnrouter_users.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/sbin/vpn-router-group 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/
+30 -19
View File
@@ -2,28 +2,39 @@
set -e
. /usr/share/debconf/confmodule
# The answer to a question in ANSWER, or "" when the question has not been seen; the
# default debconf holds for an unseen question is not an answer.
answer() {
ANSWER=""
db_fget "$1" seen
if [ "$RET" = "true" ]; then
db_get "$1"
ANSWER="$RET"
fi
}
case "$1" in
configure)
# --- Read the installer's answers ---
db_get vpn-router/platform; VPN_ROUTER_PLATFORM="$RET"
db_get vpn-router/mode; VPN_ROUTER_MODE="$RET"
db_get vpn-router/int_addr; VPN_ROUTER_INT_ADDR="$RET"
db_get vpn-router/external_interface; VPN_ROUTER_EXTERNAL_INTERFACE="$RET"
db_get vpn-router/internal_interface; VPN_ROUTER_INTERNAL_INTERFACE="$RET"
db_get vpn-router/local_fqdn; VPN_ROUTER_LOCAL_FQDN="$RET"
db_get vpn-router/local_id_mode; VPN_ROUTER_LOCAL_ID_MODE="$RET"
db_get vpn-router/local_cidrs; VPN_ROUTER_LOCAL_CIDRS="$RET"
db_get vpn-router/int_gateway_ip; VPN_ROUTER_INT_GATEWAY_IP="$RET"
db_get vpn-router/remote_addrs; VPN_ROUTER_REMOTE_ADDRS="$RET"
db_get vpn-router/remote_id; VPN_ROUTER_REMOTE_ID="$RET"
db_get vpn-router/remote_cidrs; VPN_ROUTER_REMOTE_CIDRS="$RET"
db_get vpn-router/psk; VPN_ROUTER_PSK="$RET"
db_get vpn-router/p2s_enabled; VPN_ROUTER_P2S_ENABLED="$RET"
db_get vpn-router/p2s_address_pool; VPN_ROUTER_P2S_ADDRESS_POOL="$RET"
db_get vpn-router/p2s_ca_name; VPN_ROUTER_P2S_CA_NAME="$RET"
db_get vpn-router/wg_enabled; VPN_ROUTER_WG_ENABLED="$RET"
db_get vpn-router/wg_address; VPN_ROUTER_WG_ADDRESS="$RET"
db_get vpn-router/wg_listen_port; VPN_ROUTER_WG_LISTEN_PORT="$RET"
answer vpn-router/platform; VPN_ROUTER_PLATFORM="$ANSWER"
answer vpn-router/mode; VPN_ROUTER_MODE="$ANSWER"
answer vpn-router/int_addr; VPN_ROUTER_INT_ADDR="$ANSWER"
answer vpn-router/external_interface; VPN_ROUTER_EXTERNAL_INTERFACE="$ANSWER"
answer vpn-router/internal_interface; VPN_ROUTER_INTERNAL_INTERFACE="$ANSWER"
answer vpn-router/local_fqdn; VPN_ROUTER_LOCAL_FQDN="$ANSWER"
answer vpn-router/local_id_mode; VPN_ROUTER_LOCAL_ID_MODE="$ANSWER"
answer vpn-router/local_cidrs; VPN_ROUTER_LOCAL_CIDRS="$ANSWER"
answer vpn-router/int_gateway_ip; VPN_ROUTER_INT_GATEWAY_IP="$ANSWER"
answer vpn-router/remote_addrs; VPN_ROUTER_REMOTE_ADDRS="$ANSWER"
answer vpn-router/remote_id; VPN_ROUTER_REMOTE_ID="$ANSWER"
answer vpn-router/remote_cidrs; VPN_ROUTER_REMOTE_CIDRS="$ANSWER"
answer vpn-router/psk; VPN_ROUTER_PSK="$ANSWER"
answer vpn-router/p2s_enabled; VPN_ROUTER_P2S_ENABLED="$ANSWER"
answer vpn-router/p2s_address_pool; VPN_ROUTER_P2S_ADDRESS_POOL="$ANSWER"
answer vpn-router/p2s_ca_name; VPN_ROUTER_P2S_CA_NAME="$ANSWER"
answer vpn-router/wg_enabled; VPN_ROUTER_WG_ENABLED="$ANSWER"
answer vpn-router/wg_address; VPN_ROUTER_WG_ADDRESS="$ANSWER"
answer vpn-router/wg_listen_port; VPN_ROUTER_WG_LISTEN_PORT="$ANSWER"
export VPN_ROUTER_PLATFORM VPN_ROUTER_MODE VPN_ROUTER_INT_ADDR \
VPN_ROUTER_EXTERNAL_INTERFACE \
+7 -4
View File
@@ -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 -9
View File
@@ -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
+2
View File
@@ -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
+24 -4
View File
@@ -1,9 +1,21 @@
#!/bin/sh
set -e
# Apple container CLI on macOS, Docker CE on Linux.
case "$(uname -s)" in
Darwin) CONTAINER_RUNTIME=container ;;
Linux) CONTAINER_RUNTIME=docker ;;
*) printf 'ERROR: unsupported platform %s\n' "$(uname -s)" >&2; exit 1 ;;
esac
OWNER="${OWNER:-slawek}"
SERVER_URL="${SERVER_URL:-https://gitea.koszewscy.waw.pl}"
PACKAGES_TOKEN="${PACKAGES_TOKEN:-$(cat ~/.gitea-packages-token)}"
# Reads the changelog copied into the builder image, so no bind mount is needed.
changelog_field() {
container run --rm -v "$(pwd):/mnt" vpn-router-builder \
dpkg-parsechangelog --show-field "$1" -l /mnt/debian/changelog
$CONTAINER_RUNTIME run --rm vpn-router-builder \
dpkg-parsechangelog --show-field "$1" -l /build/src/debian/changelog
}
VERSION="$(changelog_field Version)"
@@ -15,6 +27,14 @@ if [ ! -f "$DEB" ]; then
exit 1
fi
curl -v --user "slawek:$(cat ~/.gitea-packages-token)" \
STATUS="$(curl -s -o /dev/null -w '%{http_code}' --user "$OWNER:$PACKAGES_TOKEN" \
"$SERVER_URL/api/v1/packages/$OWNER/debian/$PACKAGE/$VERSION")"
case "$STATUS" in
200) printf '%s %s is already published, skipping.\n' "$PACKAGE" "$VERSION"; exit 0 ;;
404) ;;
*) printf 'ERROR: version check returned HTTP %s.\n' "$STATUS" >&2; exit 1 ;;
esac
curl --fail-with-body --user "$OWNER:$PACKAGES_TOKEN" \
--upload-file "$DEB" \
"https://gitea.koszewscy.waw.pl/api/packages/slawek/debian/pool/noble/main/upload"
"$SERVER_URL/api/packages/$OWNER/debian/pool/noble/main/upload"
+114
View File
@@ -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())
@@ -6,18 +6,15 @@ Called from postinst, which hands the result to ucf(1). ucf compares it with
this script never writes to /etc and never has to worry about destroying an
administrator's edits.
The candidate is built in three layers, each overriding the one before:
1. the shipped defaults
2. the current contents of /etc/vpn-router/vpn-router.json, if it exists
3. any debconf answer that was actually given
Layer 2 is what makes reconfiguration safe: a setting the administrator edited
by hand, and that debconf has nothing to say about, is carried through
unchanged, so ucf only sees a diff where something really changed. An empty
answer means "leave alone", which is why clearing the pre-shared key from the
debconf database after installation does not blank it on the next
dpkg-reconfigure.
The candidate is the current /etc/vpn-router/vpn-router.json as it is, or the
shipped defaults when there is none, with every debconf answer that was given
written over it. Without an answer that changes it, the candidate is the current
file byte for byte, so
an upgrade or a reconfiguration that changes nothing gives ucf nothing to ask;
defaults the file leaves out are applied by vpnrouter.load_config() when the
file is read. An empty answer means "leave alone", which is why clearing the
pre-shared key from the debconf database after installation does not blank it
on the next dpkg-reconfigure.
Answers arrive as VPN_ROUTER_* environment variables and are converted to the
type of the setting they fill: comma-separated lists become arrays, "true" and
@@ -62,8 +59,21 @@ def main():
return 2
output = sys.argv[1]
cfg = vpnrouter.load_config()
try:
current = vpnrouter.CONFIG_FILE.read_text(encoding='utf-8')
except FileNotFoundError:
current = None
if current is None:
cfg = vpnrouter.load_config()
else:
try:
cfg = json.loads(current)
except ValueError as exc:
vpnrouter.fail(f'cannot parse {vpnrouter.CONFIG_FILE}: {exc}')
if not isinstance(cfg, dict):
vpnrouter.fail(f'cannot parse {vpnrouter.CONFIG_FILE}: the top level is not an object')
answered = False
for variable, (section, option) in ANSWERS.items():
value = os.environ.get(variable)
if not value:
@@ -76,11 +86,19 @@ def main():
value = int(value) if value.isdigit() else value
elif isinstance(default, list):
value = [item.strip() for item in value.split(',') if item.strip()]
cfg[section][option] = value
if not isinstance(cfg.get(section, {}), dict):
vpnrouter.fail(f'cannot parse {vpnrouter.CONFIG_FILE}: {section} is not an object')
if cfg.setdefault(section, {}).get(option) != value:
cfg[section][option] = value
answered = True
# Without a change the candidate is the current file byte for byte, as ucf compares checksums.
with open(output, 'w', encoding='utf-8') as handle:
json.dump(cfg, handle, indent=2)
handle.write('\n')
if current is not None and not answered:
handle.write(current)
else:
json.dump(cfg, handle, indent=2)
handle.write('\n')
os.chmod(output, 0o600)
return 0
+12
View File
@@ -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)
+144 -7
View File
@@ -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 = 7080
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': 7081, '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,184 @@
"""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 and groups are changed by the user-manager service account,
which the ACLs allow to write ou=users and ou=groups 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}'
GROUPS = f'ou=groups,{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',
f'to dn.subtree="{GROUPS}" 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, GROUPS, 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,410 @@
"""The users and groups of the router's directory, for vpn-router-user, vpn-router-group and the web service.
A user is an entry in the router's directory, with a first name, a last name and an e-mail
address, 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. Users exist whether or not a
P2S system is enabled.
A group is a groupOfNames in ou=groups with a name, which follows the rules of user names, an
optional description and at least one member; a group cannot be empty, so the last member
cannot leave it and a user who is the last member of a group cannot be removed. Groups only
record membership; nothing on the router reads them.
Every operation runs in a session(), which holds the lock that serializes changes and the
connection of the user manager account. A request that cannot be done raises Failure, whose
status says why: 2 the request is invalid, 3 the user does not exist, 4 the user exists, 1
anything else. Answers are dicts with the keys of the JSON answers.
"""
import contextlib
import fcntl
import pathlib
import re
import subprocess
import sys
import ldap
import ldap.filter
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 longest first or last name, in characters.
PERSON_NAME_MAX = 64
#: An e-mail address: one @, no spaces, and a dot in the domain.
EMAIL = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
EMAIL_MAX = 254
#: The keys of the person attributes and their LDAP attributes.
PERSON = (('first_name', 'givenName'), ('last_name', 'sn'), ('email', 'mail'))
#: The attributes read for a user.
ATTRIBUTES = ['uid', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail']
#: The attributes read for a group.
GROUP_ATTRIBUTES = ['cn', 'description', 'member']
#: The longest group description, in characters.
DESCRIPTION_MAX = 256
#: 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 2 invalid, 3 no such user, 4 the user exists, 1 other."""
def __init__(self, message, status=1):
super().__init__(message)
self.status = status
@contextlib.contextmanager
def session():
"""The connection of the user manager account, used under the lock that serializes changes."""
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:
yield connection
finally:
connection.unbind_s()
def check_name(name):
if not isinstance(name, str) or not NAME.match(name):
raise Failure('a user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter', 2)
def check_password(value):
if not isinstance(value, str) or '\n' in value or '\r' in value:
raise Failure('the password must be a string of one line', 2)
if not PASSWORD_MIN <= len(value) <= PASSWORD_MAX:
raise Failure(f'the password must have {PASSWORD_MIN} to {PASSWORD_MAX} characters', 2)
def person(values, required):
"""The person attributes of values ({first_name, last_name, email}), checked, as {LDAP attribute: value}.
A key whose value is None is not given; with required, all three must be given.
"""
found = {}
for key, attribute in PERSON:
value = values.get(key)
if value is None:
if required:
raise Failure(f'{key} is required', 2)
continue
if not isinstance(value, str):
raise Failure(f'{key} must be a string', 2)
value = value.strip()
if key == 'email':
if len(value) > EMAIL_MAX or not EMAIL.match(value):
raise Failure(f'{value!r} is not an e-mail address', 2)
elif not value or len(value) > PERSON_NAME_MAX or any(not character.isprintable() for character in value):
raise Failure(f'the {key.replace("_", " ")} must have 1 to {PERSON_NAME_MAX} printable characters', 2)
found[attribute] = value
return found
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."""
check_name(name)
try:
return connection.search_s(dn_of(name), ldap.SCOPE_BASE, attrlist=ATTRIBUTES)[0][1]
except ldap.NO_SUCH_OBJECT:
raise Failure(f'user {name} does not exist', 3)
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):
"""A user as the answers show it; a person attribute the entry does not have is None."""
name = attributes['uid'][0].decode()
user = {
'name': name,
'enabled': 'pwdAccountLockedTime' not in attributes,
'certificate': certificate(name)[0].is_file(),
}
for key, attribute in PERSON:
values = attributes.get(attribute)
user[key] = values[0].decode() if values else None
return user
def common_name(attributes, name):
"""The cn of an entry: the first and the last name, or the user name without them."""
full = ' '.join(value for value in (attributes.get('givenName'), attributes.get('sn')) if value)
return full or name
def list_users(connection):
found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL, '(objectClass=inetOrgPerson)', ATTRIBUTES)
return {'users': sorted((summary(attributes) for _, attributes in found), key=lambda user: user['name'])}
def add(connection, name, password, values):
"""Add a user with the person attributes of values, all three required, and issue the certificate."""
check_name(name)
details = person(values, required=True)
check_password(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')
record = {'objectClass': [b'inetOrgPerson'], 'uid': [name.encode()],
'cn': [common_name(details, name).encode()]}
record.update({attribute: [value.encode()] for attribute, value in details.items()})
try:
connection.add_s(dn_of(name), ldap.modlist.addModlist(record))
except ldap.ALREADY_EXISTS:
raise Failure(f'user {name} exists', 4)
connection.passwd_s(dn_of(name), None, password)
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 update(connection, name, values):
"""Change the person attributes values gives; the cn follows the first and the last name."""
details = person(values, required=False)
if not details:
raise Failure('give at least one of first_name, last_name and email', 2)
current = entry(connection, name)
names = {attribute: current[attribute][0].decode() for attribute in ('givenName', 'sn') if current.get(attribute)}
names.update({attribute: details[attribute] for attribute in ('givenName', 'sn') if attribute in details})
changes = [(ldap.MOD_REPLACE, attribute, [value.encode()]) for attribute, value in details.items()]
changes.append((ldap.MOD_REPLACE, 'cn', [common_name(names, name).encode()]))
connection.modify_s(dn_of(name), changes)
return summary(entry(connection, name))
def passwd(connection, name, password):
check_password(password)
entry(connection, name)
connection.passwd_s(dn_of(name), None, password)
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):
"""Remove the user from its groups and the directory, and revoke the certificate.
A user who is the last member of a group is not removed.
"""
entry(connection, name)
groups = groups_of(connection, name)
alone = sorted(group for group, members in groups.items() if members == [name])
if alone:
raise Failure(f'user {name} is the last member of {", ".join(alone)}; remove the group first', 2)
for group in groups:
connection.modify_s(group_dn(group), [(ldap.MOD_DELETE, 'member', [dn_of(name).encode()])])
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 group_dn(name):
return f'cn={name},{vpnrouter_ldap.GROUPS}'
def check_group_name(name):
if not isinstance(name, str) or not NAME.match(name):
raise Failure('a group name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter', 2)
def check_description(value):
"""The description, stripped; '' means none."""
if not isinstance(value, str):
raise Failure('the description must be a string', 2)
value = value.strip()
if len(value) > DESCRIPTION_MAX or any(not character.isprintable() for character in value):
raise Failure(f'the description must have at most {DESCRIPTION_MAX} printable characters', 2)
return value
def member_names(attributes):
"""The user names of a group's member DNs, sorted."""
names = []
for value in attributes.get('member', []):
dn = value.decode()
if dn.startswith('uid=') and dn.endswith(f',{vpnrouter_ldap.USERS}'):
names.append(dn[4:-len(f',{vpnrouter_ldap.USERS}')])
return sorted(names)
def group_summary(attributes):
"""A group as the answers show it."""
values = attributes.get('description')
return {
'name': attributes['cn'][0].decode(),
'description': values[0].decode() if values else '',
'members': member_names(attributes),
}
def group_entry(connection, name):
"""The attributes of the group's entry; Failure with status 3 when there is none."""
check_group_name(name)
try:
return connection.search_s(group_dn(name), ldap.SCOPE_BASE, attrlist=GROUP_ATTRIBUTES)[0][1]
except ldap.NO_SUCH_OBJECT:
raise Failure(f'group {name} does not exist', 3)
def groups_of(connection, name):
"""The groups the user is a member of, as {group name: its member names}."""
found = connection.search_s(vpnrouter_ldap.GROUPS, ldap.SCOPE_ONELEVEL,
f'(&(objectClass=groupOfNames)(member={ldap.filter.escape_filter_chars(dn_of(name))}))',
GROUP_ATTRIBUTES)
return {attributes['cn'][0].decode(): member_names(attributes) for _, attributes in found}
def list_groups(connection):
found = connection.search_s(vpnrouter_ldap.GROUPS, ldap.SCOPE_ONELEVEL, '(objectClass=groupOfNames)', GROUP_ATTRIBUTES)
return {'groups': sorted((group_summary(attributes) for _, attributes in found), key=lambda group: group['name'])}
def group_add(connection, name, description, members):
"""Add a group with a description ('' for none) and at least one member, each an existing user."""
check_group_name(name)
description = check_description(description if description is not None else '')
if not isinstance(members, list) or not members:
raise Failure('a group needs at least one member', 2)
for member in members:
entry(connection, member)
record = {'objectClass': [b'groupOfNames'], 'cn': [name.encode()],
'member': [dn_of(member).encode() for member in sorted(set(members))]}
if description:
record['description'] = [description.encode()]
try:
connection.add_s(group_dn(name), ldap.modlist.addModlist(record))
except ldap.ALREADY_EXISTS:
raise Failure(f'group {name} exists', 4)
return group_summary(group_entry(connection, name))
def group_update(connection, name, new_name=None, description=None):
"""Rename the group or change its description ('' removes it); at least one must be given."""
if new_name is None and description is None:
raise Failure('give a new name or a description', 2)
group_entry(connection, name)
if description is not None:
description = check_description(description)
change = (ldap.MOD_REPLACE, 'description', [description.encode()]) if description else \
(ldap.MOD_REPLACE, 'description', [])
connection.modify_s(group_dn(name), [change])
if new_name is not None and new_name != name:
check_group_name(new_name)
try:
connection.rename_s(group_dn(name), f'cn={new_name}', delold=1)
except ldap.ALREADY_EXISTS:
raise Failure(f'group {new_name} exists', 4)
name = new_name
return group_summary(group_entry(connection, name))
def group_remove(connection, name):
group_entry(connection, name)
connection.delete_s(group_dn(name))
return {'name': name, 'removed': True}
def group_member_add(connection, name, user):
attributes = group_entry(connection, name)
entry(connection, user)
if user not in member_names(attributes):
connection.modify_s(group_dn(name), [(ldap.MOD_ADD, 'member', [dn_of(user).encode()])])
return group_summary(group_entry(connection, name))
def group_member_remove(connection, name, user):
"""Take the user out of the group; the last member cannot leave."""
check_name(user)
members = member_names(group_entry(connection, name))
if user not in members:
raise Failure(f'user {user} is not a member of group {name}', 3)
if members == [user]:
raise Failure(f'user {user} is the last member of group {name}; remove the group instead', 2)
connection.modify_s(group_dn(name), [(ldap.MOD_DELETE, 'member', [dn_of(user).encode()])])
return group_summary(group_entry(connection, name))
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)
+647
View File
@@ -0,0 +1,647 @@
#!/usr/bin/python3
"""Serve the status of the router on its internal address.
Run by vpn-router-web.service. Users are managed with vpnrouter_users, the module that
vpn-router-user uses too.
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, change, set the password, disable, enable, remove, and the OpenVPN client
profile (see users())
/groups the groups of the router's directory: list, add, rename or describe, remove, and
add and take out members (see groups())
/config, /users and /groups 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 ldap
import vpnrouter_net
import vpnrouter_users
STATE_FILE = '/run/vpn-router/web.json'
CONFIG_FILE = '/etc/vpn-router/vpn-router.json'
#: The port when web.port is not set.
DEFAULT_PORT = 7081
#: Seconds between two discoveries of where the service should listen.
DISCOVERY_SECONDS = 30
COMMAND_TIMEOUT = 5
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
OPENVPN_STATUS = '/run/openvpn-server/status-vpn-router.log'
#: The largest request body accepted, in bytes.
BODY_LIMIT = 4096
#: The HTTP status for each status of a vpnrouter_users.Failure; any other is 500.
USER_STATUS = {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 users(state, method, path, body):
"""Answer a /users request with vpnrouter_users: the HTTP status and the JSON document.
GET /users the users
POST /users add a user: {"name", "password", "first_name", "last_name", "email"}
PUT /users/<name> change any of {"first_name", "last_name", "email"}
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 {}
parts = path.strip('/').split('/')
route = (method, len(parts), parts[2] if len(parts) == 3 else None)
name = parts[1] if len(parts) > 1 else None
actions = {
('GET', 1, None): lambda connection: vpnrouter_users.list_users(connection),
('POST', 1, None): lambda connection: vpnrouter_users.add(connection, body.get('name'), body.get('password'), body),
('PUT', 2, None): lambda connection: vpnrouter_users.update(connection, name, body),
('PUT', 3, 'password'): lambda connection: vpnrouter_users.passwd(connection, name, body.get('password')),
('POST', 3, 'disable'): lambda connection: vpnrouter_users.disable(connection, name),
('POST', 3, 'enable'): lambda connection: vpnrouter_users.enable(connection, name),
('DELETE', 2, None): lambda connection: vpnrouter_users.remove(connection, name),
('GET', 3, 'profile'): lambda connection: vpnrouter_users.profile(connection, name),
}
if route not in actions:
return 404, {'error': f'no route {method} {path}'}
try:
with vpnrouter_users.session() as connection:
document = actions[route](connection)
except vpnrouter_users.Failure as failure:
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
except (ldap.LDAPError, OSError) as exc:
return 500, {'error': f'the user directory failed: {exc}'}
except SystemExit:
# vpnrouter ends with SystemExit on a configuration it cannot read; it has printed the reason.
return 500, {'error': 'the router configuration cannot be read'}
return (201 if route == ('POST', 1, None) else 200), document
def groups(state, method, path, body):
"""Answer a /groups request with vpnrouter_users: the HTTP status and the JSON document.
GET /groups the groups with their members
POST /groups add a group: {"name", "members": [...], "description"?}
PUT /groups/<name> rename it or change the description: {"name"?, "description"?}
DELETE /groups/<name> remove the group; its members are kept
POST /groups/<name>/members add a member: {"user": ...}
DELETE /groups/<name>/members/<user> take a member out; the last member cannot leave
"""
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 {}
parts = path.strip('/').split('/')
name = parts[1] if len(parts) > 1 else None
route = (method, len(parts), parts[2] if len(parts) > 2 else None)
actions = {
('GET', 1, None): lambda connection: vpnrouter_users.list_groups(connection),
('POST', 1, None): lambda connection: vpnrouter_users.group_add(
connection, body.get('name'), body.get('description'), body.get('members')),
('PUT', 2, None): lambda connection: vpnrouter_users.group_update(
connection, name, body.get('name'), body.get('description')),
('DELETE', 2, None): lambda connection: vpnrouter_users.group_remove(connection, name),
('POST', 3, 'members'): lambda connection: vpnrouter_users.group_member_add(connection, name, body.get('user')),
('DELETE', 4, 'members'): lambda connection: vpnrouter_users.group_member_remove(connection, name, parts[3]),
}
if route not in actions:
return 404, {'error': f'no route {method} {path}'}
try:
with vpnrouter_users.session() as connection:
document = actions[route](connection)
except vpnrouter_users.Failure as failure:
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
except (ldap.LDAPError, OSError) as exc:
return 500, {'error': f'the user directory failed: {exc}'}
return (201 if route == ('POST', 1, None) else 200), document
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
directory = next((name for name in ('users', 'groups') if path == f'/{name}' or path.startswith(f'/{name}/')), None)
protected = path == '/config' or directory is not None
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 if directory == 'users' else groups)(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())
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/python3
"""Manage the groups of the router's directory from the command line, with vpnrouter_users.
A group has a name, an optional description and at least one member, each a user of the
directory. The answer is text for people on standard output, and a failure is a message on
standard error. With --json the answer is JSON on standard output, and a failure is
{"error": ...} there too. The exit status says what failed: 2 the request is invalid, 3 the
group or the user does not exist, 4 the group exists, 1 anything else. -h or --help shows the
commands and their options.
vpn-router-group [--json] list
vpn-router-group [--json] add <name> --member U [--member U ...] [--description D]
vpn-router-group [--json] update <name> [--name N] [--description D]
vpn-router-group [--json] remove <name>
vpn-router-group [--json] member-add <name> <user>
vpn-router-group [--json] member-remove <name> <user>
"""
import argparse
import json
import sys
sys.path.insert(0, '/usr/lib/vpn-router')
import ldap
import vpnrouter_users
from vpnrouter_users import Failure
#: The help line of each command.
HELP = {
'list': 'list the groups with their members',
'add': 'add a group with at least one member',
'update': 'rename the group or change its description',
'remove': 'remove the group; its members are kept',
'member-add': 'add a user to the group',
'member-remove': 'take a user out of the group; the last member cannot leave',
}
class Parser(argparse.ArgumentParser):
"""An argument parser whose errors are Failures with status 2, so that --json answers them as JSON."""
def error(self, message):
raise Failure(f'{message}; see {self.prog} --help', 2)
def parse(argv):
parser = Parser(prog='vpn-router-group', description='Manage the groups of the router.')
parser.add_argument('--json', action='store_true', help='answer in JSON')
commands = parser.add_subparsers(dest='command', required=True, metavar='command', parser_class=Parser)
for command, text in HELP.items():
sub = commands.add_parser(command, help=text, description=text[0].upper() + text[1:] + '.')
if command != 'list':
sub.add_argument('name', help='the group name')
if command == 'add':
sub.add_argument('--member', dest='members', action='append', required=True, metavar='USER',
help='a member; give it once for each member')
sub.add_argument('--description', metavar='TEXT', help='the description')
if command == 'update':
sub.add_argument('--name', dest='new_name', metavar='NAME', help='the new group name')
sub.add_argument('--description', metavar='TEXT', help="the description; '' removes it")
if command in ('member-add', 'member-remove'):
sub.add_argument('user', help='the user name')
return parser.parse_args(argv)
def run(connection, options):
"""The answer of the command the options name."""
command = options.command
if command == 'list':
return vpnrouter_users.list_groups(connection)
if command == 'add':
return vpnrouter_users.group_add(connection, options.name, options.description, options.members)
if command == 'update':
return vpnrouter_users.group_update(connection, options.name, options.new_name, options.description)
if command == 'remove':
return vpnrouter_users.group_remove(connection, options.name)
if command == 'member-add':
return vpnrouter_users.group_member_add(connection, options.name, options.user)
return vpnrouter_users.group_member_remove(connection, options.name, options.user)
def text_of(answer):
"""The answer as text for people."""
if answer.get('removed'):
return f'group {answer["name"]} removed'
groups = answer['groups'] if 'groups' in answer else [answer]
if not groups:
return 'no groups'
head = ('GROUP', 'MEMBERS', 'DESCRIPTION')
rows = [(group['name'], ', '.join(group['members']), group['description'] or '-') for group in groups]
widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))]
return '\n'.join(' '.join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip() for row in (head, *rows))
def main(argv):
# A request that cannot be parsed is answered in JSON when --json is among the arguments.
as_json = '--json' in argv
try:
options = parse(argv)
as_json = options.json
with vpnrouter_users.session() as connection:
answer = run(connection, options)
except Failure as failure:
message, status = str(failure), failure.status
except (ldap.LDAPError, OSError) as exc:
message, status = f'the user directory failed: {exc}', 1
else:
print(json.dumps(answer) if as_json else text_of(answer))
return 0
if as_json:
print(json.dumps({'error': message}))
else:
print(f'vpn-router-group: {message}', file=sys.stderr)
return status
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/python3
"""Manage the users of the router's directory from the command line, with vpnrouter_users.
Passwords are read from the first line of standard input, never from the command line. The
answer is text for people on standard output, and a failure is a message on standard error.
With --json the answer is JSON on standard output, and a failure is {"error": ...} there too.
The exit status says what failed: 2 the request is invalid, 3 the user does not exist, 4 the
user exists, 1 anything else. -h or --help shows the commands and their options.
vpn-router-user [--json] list
vpn-router-user [--json] add <name> --first-name F --last-name L --email E (password on stdin)
vpn-router-user [--json] update <name> [--first-name F] [--last-name L] [--email E]
vpn-router-user [--json] passwd <name> (password on stdin)
vpn-router-user [--json] disable <name>
vpn-router-user [--json] enable <name>
vpn-router-user [--json] remove <name>
vpn-router-user [--json] profile <name>
"""
import argparse
import json
import sys
sys.path.insert(0, '/usr/lib/vpn-router')
import ldap
import vpnrouter_users
from vpnrouter_users import Failure
#: The help line of each command.
HELP = {
'list': 'list the users',
'add': 'add a user and issue the certificate; the password is read from standard input',
'update': 'change the first name, the last name or the e-mail address',
'passwd': 'set the password, read from standard input',
'disable': 'lock the user out of OpenVPN',
'enable': 'let the user in again',
'remove': 'revoke the certificate and remove the user',
'profile': 'print the OpenVPN client profile',
}
#: The help of the person options.
PERSON_HELP = {'first_name': 'the first name', 'last_name': 'the last name', 'email': 'the e-mail address'}
class Parser(argparse.ArgumentParser):
"""An argument parser whose errors are Failures with status 2, so that --json answers them as JSON."""
def error(self, message):
raise Failure(f'{message}; see {self.prog} --help', 2)
def parse(argv):
parser = Parser(prog='vpn-router-user', description='Manage the users of the router.')
parser.add_argument('--json', action='store_true', help='answer in JSON')
commands = parser.add_subparsers(dest='command', required=True, metavar='command', parser_class=Parser)
for command, text in HELP.items():
sub = commands.add_parser(command, help=text, description=text[0].upper() + text[1:] + '.')
if command != 'list':
sub.add_argument('name', help='the user name')
if command in ('add', 'update'):
for key, _ in vpnrouter_users.PERSON:
sub.add_argument(f'--{key.replace("_", "-")}', dest=key, metavar='TEXT', required=command == 'add',
help=PERSON_HELP[key])
return parser.parse_args(argv)
def password():
"""The password from the first line of standard input."""
return sys.stdin.readline().rstrip('\n')
def run(connection, options, secret):
"""The answer of the command the options name; secret is the password of add and passwd."""
command = options.command
if command == 'list':
return vpnrouter_users.list_users(connection)
values = {key: getattr(options, key, None) for key, _ in vpnrouter_users.PERSON}
if command == 'add':
return vpnrouter_users.add(connection, options.name, secret, values)
if command == 'update':
return vpnrouter_users.update(connection, options.name, values)
if command == 'passwd':
return vpnrouter_users.passwd(connection, options.name, secret)
return getattr(vpnrouter_users, command)(connection, options.name)
def text_of(answer):
"""The answer as text for people."""
if 'profile' in answer:
return answer['profile'].rstrip('\n')
if answer.get('removed'):
return f'user {answer["name"]} removed'
users = answer['users'] if 'users' in answer else [answer]
if not users:
return 'no users'
head = ('USER', 'STATE', 'CERTIFICATE', 'FIRST NAME', 'LAST NAME', 'E-MAIL')
rows = [(user['name'], 'enabled' if user['enabled'] else 'disabled', 'issued' if user['certificate'] else 'missing',
user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-') for user in users]
widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))]
return '\n'.join(' '.join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip() for row in (head, *rows))
def main(argv):
# A request that cannot be parsed is answered in JSON when --json is among the arguments.
as_json = '--json' in argv
try:
options = parse(argv)
as_json = options.json
if options.command != 'list':
vpnrouter_users.check_name(options.name)
secret = password() if options.command in ('add', 'passwd') else None
with vpnrouter_users.session() as connection:
answer = run(connection, options, secret)
except Failure as failure:
message, status = str(failure), failure.status
except (ldap.LDAPError, OSError) as exc:
message, status = f'the user directory failed: {exc}', 1
else:
print(json.dumps(answer) if as_json else text_of(answer))
return 0
if as_json:
print(json.dumps({'error': message}))
else:
print(f'vpn-router-user: {message}', file=sys.stderr)
return status
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": 7081,
"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
View File
@@ -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())