update: Added OpenLDAP and OpenVPN to the router.
Release / release (push) Successful in 18s

This commit is contained in:
slawek committed 2026-10-23 21:55:03 +02:00
1 parent 609921348b
commit cc6748a622
17 files changed
+1011 -63

No files matched your search

+73
View File
@@ -75,3 +75,76 @@ configuration file and in `/run/vpn-router/web.json`, both readable by root only
the answer cross the network unencrypted, so the page is for a trusted internal network; HTTPS the answer cross the network unencrypted, so the page is for a trusted internal network; HTTPS
would need a certificate that the package does not have. The masking is by option name, so a new would need a certificate that the package does not have. The masking is by option name, so a new
secret option needs a name that matches. secret option needs a name that matches.
## ADR-4: Users are kept in a local OpenLDAP directory
Status: Accepted
Context: OpenVPN needs user accounts with passwords, and the users are meant to cover every P2S
system of the router. The options weighed were an external OIDC provider (Entra ID, Keycloak)
with openvpn-auth-oauth2, an OIDC server embedded on the router, a file of password hashes, and
OpenLDAP on the router. OIDC for OpenVPN needs a browser on the client and a program from outside
the Ubuntu archive, and it cannot authenticate IKEv2 EAP-TLS or WireGuard. slapd, its Python
module `python3-ldap` and the OpenVPN plugin `openvpn-auth-ldap` are all in the archive; the
plugin checks a password by binding as the user, so the hash scheme is slapd's alone. A directory
can also be read later by an OIDC provider through its LDAP connector.
Decision: The users are entries in slapd. The package keeps its own database,
`dc=vpn-router,dc=local` in `/var/lib/ldap/vpn-router` (the path the slapd AppArmor profile
allows), next to the database the slapd package creates from the host's domain, so the package
does not depend on the debconf answers of slapd. Its root DN is root's peer-credential DN on the
local socket, so `vpn-router-setup` manages it with SASL EXTERNAL and no administrator password
exists. Users are changed by `cn=user-manager,ou=services`, whose ACLs allow writing `ou=users`
and setting passwords (`=wx`) but not reading them. Passwords are hashed with ARGON2, and the
ppolicy overlay locks a disabled user (`pwdAccountLockedTime`, effective with `pwdLockout`).
slapd listens on the local socket only. Provisioning checks every step before changing it, so it
runs at every start of `vpn-router-setup`.
Consequences: The package depends on `slapd`, `python3-ldap`, `openvpn` and
`openvpn-auth-ldap`, and `vpn-router-setup` and `vpn-router-user` import `python3-ldap`, the first
library outside the standard library besides `jinja2`. The service account's password is a
root-only file. The directory is user data: it is kept when the features are switched off and
on purge, and it is backed up with `slapcat`, not by copying files.
## ADR-5: OpenVPN clients need a certificate and the password of the user it names
Status: Accepted
Context: The router CA already issues client certificates for IKEv2. OpenVPN can check a
certificate, a password, or both. With both, a certificate alone and a password alone are not
enough, but `openvpn-auth-ldap` checks only the password of the user name given at login, which
need not be the user of the certificate.
Decision: OpenVPN checks the certificate against the CA and the CRL, the password with
`openvpn-auth-ldap`, and runs `openvpn-same-user` as `auth-user-pass-verify` with `via-env`,
which accepts the login only when the user name equals the certificate's common name. OpenVPN
calls both and needs both to pass. A user's certificate has the user name as its common name, is
issued when the user is added and revoked when the user is removed. The CRL is created with the
CA, so that `crl-verify` has a file from the start, and is valid for ten years, because the router
renews it only when it revokes a certificate. The server uses the router's own certificate, whose
common name is `wan.local_fqdn`; the client profile checks that name (`verify-x509-name`), since
the CA's certificates carry both the server and the client purpose.
Consequences: Disabling a user locks the password, which stops OpenVPN but not IKEv2, which
checks certificates only; removing a user revokes the certificate for both. A user's
certificate expires after the CA's default of a year and is renewed by removing and adding the
user again.
## ADR-6: The web service manages users by running vpn-router-user
Status: Accepted
Context: The web service has only the standard library (ADR-2), and managing users needs
`python3-ldap` and `simple-ca`. Its unit is sandboxed with `ProtectSystem=strict`.
Decision: `/users` runs `vpn-router-user` and returns its JSON answer, mapping its exit status
to the HTTP status. Passwords go to the program on standard input, so they never appear in a
command line. The unit allows writing the paths the program needs: `/run/vpn-router` for its
lock, `/etc/vpn-router/pki` for the certificates and the CA's state, and `/etc/swanctl/x509crl`
for the published CRL. A lock serializes the program's runs, because `simple-ca` keeps its state
in one file.
Consequences: The web service still imports nothing outside the standard library, and the same
program serves the command line and the API. Each request starts a process, which is fine for
user administration. The API is protected by the token of `/config`, so whoever has the secret
can manage users.
+116 -11
View File
@@ -4,6 +4,8 @@ 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) - 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 - 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 - optional WireGuard endpoint
- routing and NAT for a protected subnet, TCP MSS clamping, and UFW firewall rules - 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 - firewall rules for the ports Docker publishes, when Docker is installed
@@ -113,6 +115,10 @@ must have the type shown below; an unknown section or setting is an error.
| `wireguard` | `enabled` | boolean | `false` | WireGuard endpoint | | `wireguard` | `enabled` | boolean | `false` | WireGuard endpoint |
| `wireguard` | `address` | string | | Address and prefix for `wg0`, for example `192.168.200.1/24` | | `wireguard` | `address` | string | | Address and prefix for `wg0`, for example `192.168.200.1/24` |
| `wireguard` | `listen_port` | integer | `51820` | UDP port | | `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` | `mode` | string | `auto` | `auto` secures Docker when its service is installed, `on` requires it, `off` removes the rules |
| `docker` | `exposed_ports` | list | | TCP ports and port ranges (`8000:8100`) reachable from the external interface; at most 15, a range counting as two | | `docker` | `exposed_ports` | list | | TCP ports and port ranges (`8000:8100`) reachable from the external interface; at most 15, a range counting as two |
| `web` | `enabled` | boolean | `true` | The status web service on the internal address | | `web` | `enabled` | boolean | `true` | The status web service on the internal address |
@@ -138,7 +144,7 @@ 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 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; 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 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 access needs a name in `wan.local_fqdn`. 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 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 `[Interface]` section of that file and rewrites it when the settings above change, but it
@@ -234,8 +240,10 @@ systemctl restart vpn-router-setup
The service also runs at boot. With platform `none` it does nothing and says so. 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 Otherwise it regenerates the configuration files, resolves the interfaces and their
addresses as `mode` directs, applies routes, generates WireGuard keys and injects addresses as `mode` directs, applies routes, generates WireGuard keys and injects
firewall rules. It then starts `strongswan` and `wg-quick@wg0` if the corresponding firewall rules. It provisions the user directory in `slapd` while OpenVPN or road-warrior
feature is configured, reloading or restarting them only when their inputs changed, and 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 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 feature off in the configuration stops its service and removes its files. Every step is
idempotent, so restarting with no changes does nothing. idempotent, so restarting with no changes does nothing.
@@ -264,8 +272,10 @@ ca_cert.pem ca_key.pem ca_bundle.pem <label>_cert.pem <label>_key.pem crl.p
`<label>` is the first component of the router FQDN, so `router.example.com` gives `<label>` is the first component of the router FQDN, so `router.example.com` gives
`router_cert.pem`. `router_cert.pem`.
If road-warrior access is enabled and this directory is empty, a certificate authority and If road-warrior or OpenVPN access is enabled and this directory is empty, a certificate
a server certificate are created there automatically. To use your own instead, place the authority, a server certificate and a CRL valid for ten years are created there
automatically; a CA without a CRL gets one. User certificates are issued by
`vpn-router-user` into `users/`. To use your own instead, place the
files in that layout before enabling the feature; nothing is generated or overwritten when files in that layout before enabling the feature; nothing is generated or overwritten when
material is already present. material is already present.
@@ -285,6 +295,73 @@ Issue a client certificate with the bundled tool:
`simple-ca` comes from a separate project, <https://gitea.koszewscy.waw.pl/slawek/simple-ca>, `simple-ca` comes from a separate project, <https://gitea.koszewscy.waw.pl/slawek/simple-ca>,
and also supports `make-crl` and `revoke-cert`. 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
While OpenVPN or road-warrior access is configured, `vpn-router-setup` keeps a directory of
users in the local slapd:
- its own database, `dc=vpn-router,dc=local` in `/var/lib/ldap/vpn-router`, next to the one the
slapd package creates; root manages it through the local socket, with no administrator
password;
- `ou=users` holds one `inetOrgPerson` per user, named by `uid`;
- `cn=user-manager,ou=services` is the account that changes users; it may write `ou=users` and
set passwords, never read them, and its password is in
`/etc/vpn-router/ldap/user-manager.secret` (root only);
- passwords are stored as ARGON2 hashes, and a disabled user is locked with the password policy
(`pwdAccountLockedTime`);
- slapd listens on its local socket only (`SLAPD_SERVICES` in `/etc/default/slapd`).
The directory is kept when OpenVPN and road-warrior access are switched off, and on purge.
### Managing users
`vpn-router-user` manages the users. A user is the directory entry and a client certificate
with the user name as its common name, in `/etc/vpn-router/pki/users`. Passwords are read from
the first line of standard input. The answer is JSON.
```sh
echo 's3cr3t-pass' | vpn-router-user add alice
vpn-router-user list
vpn-router-user profile alice
echo 'n3w-pass' | vpn-router-user passwd alice
vpn-router-user disable alice
vpn-router-user enable alice
vpn-router-user remove alice
```
| Command | Effect |
| --- | --- |
| `list` | The users, whether each is enabled and has a certificate |
| `add <name>` | Adds the user and issues the certificate |
| `passwd <name>` | Sets the password |
| `disable <name>`, `enable <name>` | Locks the user out of OpenVPN, and lets the user in again |
| `remove <name>` | Revokes the certificate, renews the CRL and removes the user |
| `profile <name>` | The OpenVPN client profile, with the CA, the certificate and the key inline |
A user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter; a
password has 8 to 128 characters. The exit status is 0 on success, 2 for an invalid request,
3 when the user does not exist, 4 when the user exists and 1 for any other failure.
The certificate is also accepted by road-warrior (IKEv2 EAP-TLS) access, which checks
certificates only, so `disable` does not affect it; `remove` revokes the certificate for both.
## Platform modules ## Platform modules
Platform-specific values do not belong in the core, so they are Python modules in Platform-specific values do not belong in the core, so they are Python modules in
@@ -334,12 +411,15 @@ authentication, and shows no configuration: no key, identity, address or subnet.
| Page | Content | | Page | Content |
| --- | --- | | --- | --- |
| `/` | The status for people: plain text for `curl` and `wget`, a page styled with Tailwind CSS for a browser (the style sheet is loaded from a CDN, so the browser needs internet access; the text is readable without it) | | `/` | The status for people: plain text for `curl` and `wget`, a page styled with Tailwind CSS for a browser, in the browser's light or dark scheme, that reloads itself every minute (the style sheet is loaded from a CDN, so the browser needs internet access; the text is readable without it) |
| `/status` | The same status as JSON | | `/status` | The same status as JSON |
| `/config` | The configuration with its secrets masked, and what the router resolved and runs, as JSON; needs a token | | `/config` | The configuration with its secrets masked, and what the router resolved and runs, as JSON; needs a token |
| `/users` | The users of OpenVPN and road-warrior access; needs a token |
The status holds the state of `vpn-router-setup`, of `strongswan` when site-to-site or road-warrior The status holds the state of `vpn-router-setup`, of `strongswan` when site-to-site or road-warrior
access is configured, and of `wg-quick@wg0` when WireGuard is; the package version; whether access is configured, of `wg-quick@wg0` when WireGuard is, of `openvpn-server@vpn-router` when
OpenVPN is, and of `slapd` when either OpenVPN or road-warrior access 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` Docker is installed, and when it is, the state of `docker.service` and of `vpn-router-docker`
(the Docker rules), which do not change `status`; whether the (the Docker rules), which do not change `status`; whether the
site-to-site tunnel is established; the number of connected road-warrior (IPsec P2S) clients; and site-to-site tunnel is established; the number of connected road-warrior (IPsec P2S) clients; and
@@ -388,6 +468,31 @@ print(f'{head}.{body}.{sign}')
curl -H "Authorization: Bearer $TOKEN" http://10.0.0.4:8080/config curl -H "Authorization: Bearer $TOKEN" http://10.0.0.4:8080/config
``` ```
### The /users API
`/users` manages users with the same token as `/config`, by running `vpn-router-user`. Request
and answer bodies are JSON; a request body is at most 4096 bytes.
| Request | Body | Answer |
| --- | --- | --- |
| `GET /users` | | `{"users": [{"name", "enabled", "certificate"}, ...]}` |
| `POST /users` | `{"name": ..., "password": ...}` | 201 and the user |
| `PUT /users/<name>/password` | `{"password": ...}` | the user |
| `POST /users/<name>/disable` | | the user |
| `POST /users/<name>/enable` | | the user |
| `DELETE /users/<name>` | | `{"name", "removed": true}` |
| `GET /users/<name>/profile` | | `{"name", "profile"}`, the OpenVPN client profile |
A failure is `{"error": ...}` with 400 for an invalid request, 404 for a user that does not
exist, 409 for a user that exists and 500 otherwise. While neither OpenVPN nor road-warrior
access is configured, `/users` answers 404. Passwords and private keys cross the internal
network in plain HTTP, like the token.
```bash
curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name": "alice", "password": "s3cr3t-pass"}' http://10.0.0.4:8080/users
```
`vpn-router-setup` opens `web.port` on the internal interface in the firewall, writes the address, `vpn-router-setup` opens `web.port` on the internal interface in the firewall, writes the address,
the port, the secret and the facts for `/config` to `/run/vpn-router/web.json` (readable by root the port, the secret and the facts for `/config` to `/run/vpn-router/web.json` (readable by root
only) and restarts the service, so a changed `web` section only) and restarts the service, so a changed `web` section
@@ -397,15 +502,15 @@ applies with `systemctl restart vpn-router-setup`. The service ends without an e
## Removal ## Removal
`apt-get remove` stops `vpn-router-docker`, which removes the Docker rules. It terminates the `apt-get remove` stops `vpn-router-docker`, which removes the Docker rules. It terminates the
tunnels and takes `wg-quick@wg0` down, but leaves 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 configuration in place. `strongswan` is left enabled, since it is a shared service that
may be in use by something else. may be in use by something else.
`apt-get purge` additionally removes the generated files and the firewall rules, restoring `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` `/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: before deleting it so a later reinstall starts clean. Key material and users are never
`/etc/vpn-router/pki`, the WireGuard key pair and everything under `/etc/swanctl` are left deleted: `/etc/vpn-router/pki`, `/etc/vpn-router/ldap`, the user directory in slapd, the
alone. WireGuard key pair and everything under `/etc/swanctl` are left alone.
## Building ## Building
+25
View File
@@ -1,3 +1,28 @@
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 vpn-router (0.4.3-1) unstable; urgency=medium
* swanctl output with more closing than opening lines is read as not * swanctl output with more closing than opening lines is read as not
+9 -3
View File
@@ -20,13 +20,19 @@ Depends: ${misc:Depends},
openssl, openssl,
python3, python3,
python3-jinja2, python3-jinja2,
python3-ldap,
slapd,
openvpn,
openvpn-auth-ldap,
iproute2, iproute2,
systemd-resolved 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 Configures a Linux host or virtual machine as a VPN router: site-to-site
IKEv2 IPSec (strongSwan swanctl), optional road-warrior access using EAP-TLS, IKEv2 IPSec (strongSwan swanctl), optional road-warrior access using EAP-TLS,
optional WireGuard, and the routing, NAT, MSS clamping and firewall rules a optional OpenVPN with a certificate and a password per user, kept in a local
router needs. Nothing in the package is specific to a cloud provider. 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 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 install and owned by the administrator afterwards. Editing it and restarting
+3
View File
@@ -1,5 +1,8 @@
src/etc/sysctl.d/99-vpn-router.conf etc/sysctl.d/ src/etc/sysctl.d/99-vpn-router.conf etc/sysctl.d/
src/usr/lib/vpn-router/vpnrouter.py usr/lib/vpn-router/ src/usr/lib/vpn-router/vpnrouter.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_ldap.py usr/lib/vpn-router/
src/usr/lib/vpn-router/openvpn-same-user usr/lib/vpn-router/
src/usr/sbin/vpn-router-user usr/sbin/
src/usr/lib/vpn-router/simple-ca usr/lib/vpn-router/ src/usr/lib/vpn-router/simple-ca usr/lib/vpn-router/
src/usr/lib/vpn-router/configure usr/lib/vpn-router/ src/usr/lib/vpn-router/configure usr/lib/vpn-router/
src/usr/lib/vpn-router/docker-rules usr/lib/vpn-router/ src/usr/lib/vpn-router/docker-rules usr/lib/vpn-router/
+7 -4
View File
@@ -11,10 +11,10 @@ strip_ufw_blocks() {
tmp="$(mktemp)" tmp="$(mktemp)"
awk ' awk '
/^# (IPSEC|WIREGUARD|P2S DNS|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES START$/ { /^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES START$/ {
skip = 1; pending = 0; next skip = 1; pending = 0; next
} }
/^# (IPSEC|WIREGUARD|P2S DNS|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES END$/ { /^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES END$/ {
skip = 0; next skip = 0; next
} }
skip { next } skip { next }
@@ -47,10 +47,13 @@ case "$1" in
/etc/swanctl/conf.d/road-warrior.conf \ /etc/swanctl/conf.d/road-warrior.conf \
/etc/systemd/resolved.conf.d/p2s-forwarder.conf \ /etc/systemd/resolved.conf.d/p2s-forwarder.conf \
/etc/wireguard/wg0.conf \ /etc/wireguard/wg0.conf \
/etc/openvpn/server/vpn-router.conf \
/etc/openvpn/server/vpn-router-ldap.conf \
/etc/vpn-router/vpn-router.json /etc/vpn-router/vpn-router.json
# Key material is not package state: /etc/vpn-router/pki, the WireGuard # Key material and users are not package state: /etc/vpn-router/pki,
# key pair and everything under /etc/swanctl are left in place. # /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 rmdir --ignore-fail-on-non-empty /etc/vpn-router 2>/dev/null || true
;; ;;
esac esac
+11 -9
View File
@@ -11,15 +11,17 @@ case "$1" in
swanctl --terminate --ike road-warrior >/dev/null 2>&1 || true swanctl --terminate --ike road-warrior >/dev/null 2>&1 || true
fi fi
# This package enabled wg-quick@wg0, so it takes it down again. Use the # This package enabled wg-quick@wg0 and openvpn-server@vpn-router, so
# debhelper wrappers rather than systemctl, so the script behaves on a # it takes them down again. Use the debhelper wrappers rather than
# machine without systemd. # systemctl, so the script behaves on a machine without systemd.
if command -v deb-systemd-invoke >/dev/null 2>&1; then for unit in wg-quick@wg0.service openvpn-server@vpn-router.service; do
deb-systemd-invoke stop wg-quick@wg0.service >/dev/null 2>&1 || true if command -v deb-systemd-invoke >/dev/null 2>&1; then
fi deb-systemd-invoke stop "$unit" >/dev/null 2>&1 || true
if command -v deb-systemd-helper >/dev/null 2>&1; then fi
deb-systemd-helper disable wg-quick@wg0.service >/dev/null 2>&1 || true if command -v deb-systemd-helper >/dev/null 2>&1; then
fi deb-systemd-helper disable "$unit" >/dev/null 2>&1 || true
fi
done
;; ;;
esac esac
@@ -11,6 +11,7 @@ Restart=on-failure
RestartSec=15 RestartSec=15
NoNewPrivileges=yes NoNewPrivileges=yes
ProtectSystem=strict ProtectSystem=strict
ReadWritePaths=/run/vpn-router /etc/vpn-router/pki /etc/swanctl/x509crl
ProtectHome=yes ProtectHome=yes
PrivateTmp=yes PrivateTmp=yes
+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)
+62 -7
View File
@@ -20,7 +20,10 @@ import sys
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
import ldap
import vpnrouter import vpnrouter
import vpnrouter_ldap
from vpnrouter import fail from vpnrouter import fail
BEFORE_RULES = pathlib.Path('/etc/ufw/before.rules') BEFORE_RULES = pathlib.Path('/etc/ufw/before.rules')
@@ -36,6 +39,7 @@ SECRET_OPTION = re.compile(r'(^psk$)|secret|password|token|_key$')
BLOCKS = ( BLOCKS = (
('# IPSEC RULES START', '# IPSEC RULES END'), ('# IPSEC RULES START', '# IPSEC RULES END'),
('# WIREGUARD RULES START', '# WIREGUARD RULES END'), ('# WIREGUARD RULES START', '# WIREGUARD RULES END'),
('# OPENVPN RULES START', '# OPENVPN RULES END'),
('# P2S DNS RULES START', '# P2S DNS RULES END'), ('# P2S DNS RULES START', '# P2S DNS RULES END'),
('# WEB RULES START', '# WEB RULES END'), ('# WEB RULES START', '# WEB RULES END'),
('# ROUTER FORWARD RULES START', '# ROUTER FORWARD RULES END'), ('# ROUTER FORWARD RULES START', '# ROUTER FORWARD RULES END'),
@@ -121,7 +125,8 @@ def is_configured(ctx):
a host that was working before. a host that was working before.
""" """
return any((vpnrouter.s2s_ready(ctx), vpnrouter.routing_ready(ctx), 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 filter_block(ctx): def filter_block(ctx):
@@ -146,14 +151,30 @@ def filter_block(ctx):
'# WIREGUARD RULES END', '# WIREGUARD RULES END',
] ]
if vpnrouter.p2s_ready(ctx) and ctx['local_addr_list']: if vpnrouter.openvpn_ready(ctx):
pool = ctx['p2s_address_pool'] device = ctx['openvpn_device']
lines += ['', '# P2S DNS RULES START'] lines += [
for address in ctx['local_addr_list']: '',
'# 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 += [ lines += [
f'-A ufw-before-input -s {pool} -d {address} -p udp --dport 53 -j ACCEPT', f'-A ufw-before-forward -i {device} -d {subnet} -j ACCEPT',
f'-A ufw-before-input -s {pool} -d {address} -p tcp --dport 53 -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'] lines += ['# P2S DNS RULES END']
if ctx['web_enabled'] and ctx['int_iface']: if ctx['web_enabled'] and ctx['int_iface']:
@@ -302,6 +323,36 @@ def manage_wireguard(ctx, changed):
run(['systemctl', 'restart', unit], check=False) run(['systemctl', 'restart', unit], check=False)
def manage_directory(ctx):
"""Provision the user directory in slapd while a P2S system that has users is configured.
The directory is left in place when they are switched off, so the users are kept.
"""
if not (vpnrouter.p2s_ready(ctx) or vpnrouter.openvpn_ready(ctx)):
return
if vpnrouter_ldap.ensure_services() or not unit_is_active('slapd'):
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): def run_platform_setup(cfg, ctx):
"""Let the platform module apply its own state, if it defines any.""" """Let the platform module apply its own state, if it defines any."""
module = vpnrouter.platform_module(cfg) module = vpnrouter.platform_module(cfg)
@@ -353,6 +404,8 @@ def refresh_web(cfg, ctx):
'site_to_site': bool(vpnrouter.s2s_ready(ctx)), 'site_to_site': bool(vpnrouter.s2s_ready(ctx)),
'p2s': bool(vpnrouter.p2s_ready(ctx)), 'p2s': bool(vpnrouter.p2s_ready(ctx)),
'wireguard': bool(vpnrouter.wg_ready(ctx)), 'wireguard': bool(vpnrouter.wg_ready(ctx)),
'openvpn': bool(vpnrouter.openvpn_ready(ctx)),
'users': bool(vpnrouter.p2s_ready(ctx) or vpnrouter.openvpn_ready(ctx)),
'configuration': masked, 'configuration': masked,
'network': { 'network': {
'external_interface': ctx['wan_iface'], 'external_interface': ctx['wan_iface'],
@@ -400,8 +453,10 @@ def main():
elif rules_changed: elif rules_changed:
run(['ufw', 'reload'], check=False) run(['ufw', 'reload'], check=False)
manage_directory(ctx)
manage_strongswan(ctx, changed) manage_strongswan(ctx, changed)
manage_wireguard(ctx, changed) manage_wireguard(ctx, changed)
manage_openvpn(ctx, changed)
if 'resolved' in changed: if 'resolved' in changed:
run(['systemctl', 'restart', 'systemd-resolved'], check=False) run(['systemctl', 'restart', 'systemd-resolved'], check=False)
@@ -19,6 +19,7 @@ LIB_DIR = pathlib.Path(__file__).resolve().parent
if str(LIB_DIR) not in sys.path: if str(LIB_DIR) not in sys.path:
sys.path.insert(0, str(LIB_DIR)) sys.path.insert(0, str(LIB_DIR))
import vpnrouter_ldap
import vpnrouter_platforms import vpnrouter_platforms
CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.json') CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.json')
@@ -31,6 +32,9 @@ REMOTE_SITE = SWANCTL_CONF_D / 'remote-site.conf'
ROAD_WARRIOR = SWANCTL_CONF_D / 'road-warrior.conf' ROAD_WARRIOR = SWANCTL_CONF_D / 'road-warrior.conf'
P2S_FORWARDER = pathlib.Path('/etc/systemd/resolved.conf.d/p2s-forwarder.conf') P2S_FORWARDER = pathlib.Path('/etc/systemd/resolved.conf.d/p2s-forwarder.conf')
WG_CONF = pathlib.Path('/etc/wireguard/wg0.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') CA_CERT_DEST = pathlib.Path('/etc/swanctl/x509ca/ca.pem')
SERVER_CERT = pathlib.Path('/etc/swanctl/x509/server.pem') SERVER_CERT = pathlib.Path('/etc/swanctl/x509/server.pem')
@@ -47,6 +51,7 @@ SCHEMA = {
'remote': {'addrs': [], 'id': '', 'cidrs': [], 'psk': '', 'psk_file': ''}, 'remote': {'addrs': [], 'id': '', 'cidrs': [], 'psk': '', 'psk_file': ''},
'p2s': {'enabled': False, 'address_pool': '', 'ca_name': 'VPN Router CA'}, 'p2s': {'enabled': False, 'address_pool': '', 'ca_name': 'VPN Router CA'},
'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820}, 'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820},
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
'docker': {'mode': 'auto', 'exposed_ports': []}, 'docker': {'mode': 'auto', 'exposed_ports': []},
'web': {'enabled': True, 'port': 8080, 'jwt_secret': ''}, 'web': {'enabled': True, 'port': 8080, 'jwt_secret': ''},
} }
@@ -56,6 +61,14 @@ WEB_SECRET_MIN = 32
DOCKER_MODES = ('auto', 'on', 'off') 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. #: 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})?$') DOCKER_PORT = re.compile(r'^\d{1,5}(:\d{1,5})?$')
@@ -209,6 +222,13 @@ def validate(cfg):
_check_networks(_get_list(cfg, 'remote', 'cidrs'), 'remote.cidrs') _check_networks(_get_list(cfg, 'remote', 'cidrs'), 'remote.cidrs')
pool = _get(cfg, 'p2s', 'address_pool') pool = _get(cfg, 'p2s', 'address_pool')
_check_networks([pool] if pool else [], '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_addr'), 'local.int_addr')
_check_address(_get(cfg, 'local', 'int_gateway_ip'), 'local.int_gateway_ip') _check_address(_get(cfg, 'local', 'int_gateway_ip'), 'local.int_gateway_ip')
@@ -232,6 +252,15 @@ def validate(cfg):
if not 1 <= port <= 65535: if not 1 <= port <= 65535:
raise ConfigError(f'wireguard.listen_port: {port} is not a valid port') 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') web_port = _get(cfg, 'web', 'port')
if isinstance(web_port, bool) or not isinstance(web_port, int) or not 1 <= web_port <= 65535: if isinstance(web_port, bool) or not isinstance(web_port, int) or not 1 <= web_port <= 65535:
raise ConfigError(f'web.port: {web_port!r} is not a valid port') raise ConfigError(f'web.port: {web_port!r} is not a valid port')
@@ -438,6 +467,8 @@ def build_context(cfg, local_id_override=None):
id_mode = _get(cfg, 'wan', 'local_id_mode') or 'fqdn' id_mode = _get(cfg, 'wan', 'local_id_mode') or 'fqdn'
local_cidrs = _get_list(cfg, 'local', 'cidrs') local_cidrs = _get_list(cfg, 'local', 'cidrs')
remote_cidrs = _get_list(cfg, 'remote', '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: if local_id_override:
local_id = local_id_override local_id = local_id_override
@@ -477,6 +508,23 @@ def build_context(cfg, local_id_override=None):
'wg_enabled': _get(cfg, 'wireguard', 'enabled'), 'wg_enabled': _get(cfg, 'wireguard', 'enabled'),
'wg_address': _get(cfg, 'wireguard', 'address'), 'wg_address': _get(cfg, 'wireguard', 'address'),
'wg_listen_port': _get(cfg, 'wireguard', 'listen_port'), '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_enabled': _get(cfg, 'web', 'enabled'),
'web_port': _get(cfg, 'web', 'port'), 'web_port': _get(cfg, 'web', 'port'),
} }
@@ -504,6 +552,10 @@ def wg_ready(ctx):
return bool(ctx['wg_enabled'] and ctx['wg_address']) 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(): def _jinja_env():
return jinja2.Environment( return jinja2.Environment(
loader=jinja2.FileSystemLoader(str(TEMPLATE_DIR)), loader=jinja2.FileSystemLoader(str(TEMPLATE_DIR)),
@@ -593,11 +645,13 @@ def _simple_ca(*args):
def provision_pki(ctx): def provision_pki(ctx):
"""Create the CA and server certificate when the PKI directory is empty.""" """Create the CA, server certificate and CRL when the PKI directory is empty; a CA without a CRL gets one."""
PKI_DIR.mkdir(parents=True, exist_ok=True) PKI_DIR.mkdir(parents=True, exist_ok=True)
os.chmod(PKI_DIR, 0o700) os.chmod(PKI_DIR, 0o700)
if any(PKI_DIR.iterdir()): if any(PKI_DIR.iterdir()):
if (PKI_DIR / 'ca_key.pem').is_file() and not (PKI_DIR / 'crl.pem').is_file():
_simple_ca('make-crl', '--ca-dir', str(PKI_DIR), '--days', str(CRL_DAYS))
return return
if not ctx['p2s_ca_name']: if not ctx['p2s_ca_name']:
warn('p2s.ca_name is empty, not creating a local CA') warn('p2s.ca_name is empty, not creating a local CA')
@@ -608,6 +662,7 @@ def provision_pki(ctx):
_simple_ca('make-ca', '--ca-dir', str(PKI_DIR), ctx['p2s_ca_name']) _simple_ca('make-ca', '--ca-dir', str(PKI_DIR), ctx['p2s_ca_name'])
_simple_ca('make-cert', '--ca-dir', str(PKI_DIR), _simple_ca('make-cert', '--ca-dir', str(PKI_DIR),
ctx['local_fqdn'], ctx['local_fqdn']) ctx['local_fqdn'], ctx['local_fqdn'])
_simple_ca('make-crl', '--ca-dir', str(PKI_DIR), '--days', str(CRL_DAYS))
def distribute_pki(ctx): def distribute_pki(ctx):
@@ -635,7 +690,7 @@ def configure(cfg=None, local_id_override=None):
"""Render configuration files and provision PKI. """Render configuration files and provision PKI.
Returns (ctx, changed) where changed is a set naming what needs reloading: Returns (ctx, changed) where changed is a set naming what needs reloading:
'swanctl', 'resolved', 'wireguard'. 'swanctl', 'resolved', 'wireguard', 'openvpn'.
""" """
if cfg is None: if cfg is None:
cfg = load_config() cfg = load_config()
@@ -653,7 +708,7 @@ def configure(cfg=None, local_id_override=None):
changed = set() changed = set()
if p2s_ready(ctx): if p2s_ready(ctx) or openvpn_ready(ctx):
provision_pki(ctx) provision_pki(ctx)
if distribute_pki(ctx): if distribute_pki(ctx):
changed.add('swanctl') changed.add('swanctl')
@@ -669,13 +724,14 @@ def configure(cfg=None, local_id_override=None):
if p2s_ready(ctx): if p2s_ready(ctx):
if render(env, ctx, 'road-warrior.conf.j2', ROAD_WARRIOR, 0o600): if render(env, ctx, 'road-warrior.conf.j2', ROAD_WARRIOR, 0o600):
changed.add('swanctl') 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): if render(env, ctx, 'p2s-forwarder.conf.j2', P2S_FORWARDER, 0o644):
changed.add('resolved') changed.add('resolved')
else: elif remove(P2S_FORWARDER):
if remove(ROAD_WARRIOR): changed.add('resolved')
changed.add('swanctl')
if remove(P2S_FORWARDER):
changed.add('resolved')
if wg_ready(ctx): if wg_ready(ctx):
if render_wg_conf(env, ctx): if render_wg_conf(env, ctx):
@@ -683,4 +739,14 @@ def configure(cfg=None, local_id_override=None):
elif remove(WG_CONF): elif remove(WG_CONF):
changed.add('wireguard') 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 return ctx, changed
@@ -0,0 +1,182 @@
"""The router's user directory in the local slapd.
The package keeps its own database, dc=vpn-router,dc=local in /var/lib/ldap/vpn-router,
next to the one slapd's installation creates. Root manages it through the local socket
with SASL EXTERNAL: the database's root DN is root's peer-credential DN, so there is no
administrator password. Users are changed by the user-manager service account, which the
ACLs allow to write ou=users and to set, never read, passwords.
"""
import os
import pathlib
import pwd
import re
import secrets
import ldap
import ldap.modlist
import ldap.sasl
URI = 'ldapi:///'
BASE = 'dc=vpn-router,dc=local'
USERS = f'ou=users,{BASE}'
SERVICES = f'ou=services,{BASE}'
POLICIES = f'ou=policies,{BASE}'
DEFAULT_POLICY = f'cn=default,{POLICIES}'
MANAGER = f'cn=user-manager,{SERVICES}'
ROOT = 'gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth'
DB_DIRECTORY = pathlib.Path('/var/lib/ldap/vpn-router')
SECRET_DIR = pathlib.Path('/etc/vpn-router/ldap')
MANAGER_SECRET = SECRET_DIR / 'user-manager.secret'
SLAPD_DEFAULTS = pathlib.Path('/etc/default/slapd')
SLAPD_SERVICES = 'SLAPD_SERVICES="ldapi:///"'
MODULES = ('argon2', 'ppolicy')
PASSWORD_HASH = '{ARGON2}'
#: The ACLs of the database, in order.
ACCESS = (
f'to attrs=userPassword by dn.exact="{MANAGER}" =wx by self =wx by anonymous auth by * none',
f'to dn.subtree="{USERS}" by dn.exact="{MANAGER}" manage by * read',
'to * by * read',
)
#: The index of an ordered cn=config value: {0}, {1}, ...
ORDER = re.compile(rb'^\{\d+\}')
def unordered(values):
"""Values of an ordered cn=config attribute without their {n} prefixes."""
return [ORDER.sub(b'', value).decode() for value in values]
def root():
"""A connection to slapd as root, through the local socket."""
connection = ldap.initialize(URI)
connection.sasl_interactive_bind_s('', ldap.sasl.external())
return connection
def manager():
"""A connection to slapd as the user-manager service account."""
connection = ldap.initialize(URI)
connection.simple_bind_s(MANAGER, MANAGER_SECRET.read_text(encoding='utf-8'))
return connection
def ensure_services():
"""Make slapd listen on the local socket only. Returns True when the setting changed."""
text = SLAPD_DEFAULTS.read_text(encoding='utf-8')
lines = text.splitlines(keepends=True)
updated = ''.join(SLAPD_SERVICES + '\n' if line.startswith('SLAPD_SERVICES=') else line for line in lines)
if not any(line.startswith('SLAPD_SERVICES=') for line in lines):
updated = text.rstrip('\n') + '\n' + SLAPD_SERVICES + '\n'
if updated == text:
return False
SLAPD_DEFAULTS.write_text(updated, encoding='utf-8')
return True
def _ensure_modules(connection):
dn, attributes = connection.search_s('cn=config', ldap.SCOPE_ONELEVEL, '(objectClass=olcModuleList)', ['olcModuleLoad'])[0]
loaded = unordered(attributes.get('olcModuleLoad', []))
missing = [name for name in MODULES if name not in loaded and f'{name}.la' not in loaded]
if missing:
connection.modify_s(dn, [(ldap.MOD_ADD, 'olcModuleLoad', [name.encode() for name in missing])])
def _ensure_password_hash(connection):
dn = 'olcDatabase={-1}frontend,cn=config'
current = connection.search_s(dn, ldap.SCOPE_BASE, attrlist=['olcPasswordHash'])[0][1].get('olcPasswordHash', [])
if current != [PASSWORD_HASH.encode()]:
connection.modify_s(dn, [(ldap.MOD_REPLACE, 'olcPasswordHash', [PASSWORD_HASH.encode()])])
def _ensure_database(connection):
"""The DN of the package's database in cn=config, created when missing; its ACLs are kept as ACCESS."""
found = connection.search_s('cn=config', ldap.SCOPE_ONELEVEL, f'(olcSuffix={BASE})', ['olcAccess'])
if not found:
DB_DIRECTORY.mkdir(mode=0o700, parents=True, exist_ok=True)
openldap = pwd.getpwnam('openldap')
os.chown(DB_DIRECTORY, openldap.pw_uid, openldap.pw_gid)
connection.add_s('olcDatabase=mdb,cn=config', ldap.modlist.addModlist({
'objectClass': [b'olcDatabaseConfig', b'olcMdbConfig'],
'olcDatabase': [b'mdb'],
'olcSuffix': [BASE.encode()],
'olcDbDirectory': [str(DB_DIRECTORY).encode()],
'olcRootDN': [ROOT.encode()],
'olcDbIndex': [b'objectClass eq', b'uid eq'],
'olcAccess': [f'{{{index}}}{rule}'.encode() for index, rule in enumerate(ACCESS)],
}))
found = connection.search_s('cn=config', ldap.SCOPE_ONELEVEL, f'(olcSuffix={BASE})', ['olcAccess'])
dn, attributes = found[0]
if unordered(attributes.get('olcAccess', [])) != list(ACCESS):
connection.modify_s(dn, [(ldap.MOD_REPLACE, 'olcAccess', [f'{{{index}}}{rule}'.encode() for index, rule in enumerate(ACCESS)])])
return dn
def _ensure_overlay(connection, database):
if not connection.search_s(database, ldap.SCOPE_ONELEVEL, '(objectClass=olcPPolicyConfig)', ['1.1']):
connection.add_s(f'olcOverlay=ppolicy,{database}', ldap.modlist.addModlist({
'objectClass': [b'olcOverlayConfig', b'olcPPolicyConfig'],
'olcOverlay': [b'ppolicy'],
'olcPPolicyDefault': [DEFAULT_POLICY.encode()],
}))
def _ensure_entry(connection, dn, attributes):
"""Add the entry when it is missing, else set the given attributes that differ."""
try:
current = connection.search_s(dn, ldap.SCOPE_BASE, attrlist=list(attributes))[0][1]
except ldap.NO_SUCH_OBJECT:
connection.add_s(dn, ldap.modlist.addModlist(attributes))
return
changes = [(ldap.MOD_REPLACE, name, values) for name, values in attributes.items()
if name != 'objectClass' and sorted(current.get(name, [])) != sorted(values)]
if changes:
connection.modify_s(dn, changes)
def _ensure_manager(connection):
SECRET_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(SECRET_DIR, 0o700)
created = False
try:
connection.search_s(MANAGER, ldap.SCOPE_BASE, attrlist=['1.1'])
except ldap.NO_SUCH_OBJECT:
connection.add_s(MANAGER, ldap.modlist.addModlist({
'objectClass': [b'organizationalRole', b'simpleSecurityObject'],
'cn': [b'user-manager'],
'userPassword': [b'{CRYPT}!'],
}))
created = True
if created or not MANAGER_SECRET.is_file():
password = secrets.token_urlsafe(32)
connection.passwd_s(MANAGER, None, password)
MANAGER_SECRET.touch(mode=0o600)
MANAGER_SECRET.chmod(0o600)
MANAGER_SECRET.write_text(password, encoding='utf-8')
def provision():
"""Create or bring up to date the modules, database, ACLs, password policy, entries and service account."""
connection = root()
try:
_ensure_modules(connection)
_ensure_password_hash(connection)
database = _ensure_database(connection)
_ensure_overlay(connection, database)
_ensure_entry(connection, BASE, {'objectClass': [b'dcObject', b'organization'], 'dc': [b'vpn-router'], 'o': [b'vpn-router']})
for unit in (USERS, SERVICES, POLICIES):
_ensure_entry(connection, unit, {'objectClass': [b'organizationalUnit'], 'ou': [unit.split(',')[0][3:].encode()]})
_ensure_entry(connection, DEFAULT_POLICY, {
'objectClass': [b'organizationalRole', b'pwdPolicy'],
'cn': [b'default'],
'pwdAttribute': [b'userPassword'],
'pwdLockout': [b'TRUE'],
'pwdMaxFailure': [b'0'],
})
_ensure_manager(connection)
finally:
connection.unbind_s()
+163 -21
View File
@@ -1,19 +1,24 @@
#!/usr/bin/python3 #!/usr/bin/python3
"""Serve the status of the router on its internal address. """Serve the status of the router on its internal address.
Run by vpn-router-web.service. It needs nothing but the Python standard library. Run by vpn-router-web.service. It needs nothing but the Python standard library; users are
managed by running vpn-router-user.
Pages: Pages:
/ the status for people: plain text for curl and wget, a styled page / the status for people: plain text for curl and wget, a styled page
(Tailwind CSS from a CDN) for a browser (Tailwind CSS from a CDN) for a browser
/status the status as JSON /status the status as JSON
/config the configuration without its secrets, and what the router resolved and /config the configuration without its secrets, and what the router resolved and
runs, as JSON; needs an HS256 JWT in an Authorization: Bearer header runs, as JSON
that is signed with web.jwt_secret and expires within ten minutes; not /users the users of the P2S systems: list, add, set the password, disable, enable,
served (404) while web.jwt_secret is not set remove, and the OpenVPN client profile (see users())
/config and /users need an HS256 JWT in an Authorization: Bearer header that is signed
with web.jwt_secret and expires within ten minutes; they are not served (404) while
web.jwt_secret is not set.
/ and /status show only the status: whether the services run, the package version, whether / 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) and Docker is installed, and the state of site-to-site, road-warrior (IPsec P2S), OpenVPN and
WireGuard. No configuration data, key or address is shown. WireGuard. No configuration data, key or address is shown.
The address to bind to, the port, the secret and the configuration shown by /config The address to bind to, the port, the secret and the configuration shown by /config
@@ -49,6 +54,21 @@ MAX_LIFETIME = 600
TAILWIND = 'https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4' TAILWIND = 'https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4'
#: Seconds after which the status page in a browser reloads itself.
REFRESH_SECONDS = 60
USER_TOOL = '/usr/sbin/vpn-router-user'
OPENVPN_STATUS = '/run/openvpn-server/status-vpn-router.log'
#: Seconds a user command may take; adding a user creates an RSA key.
USER_TIMEOUT = 120
#: The largest request body accepted, in bytes.
BODY_LIMIT = 4096
#: The HTTP status for each exit status of vpn-router-user; any other is 500.
USER_STATUS = {0: 200, 2: 400, 3: 404, 4: 409}
_cache = {'at': 0.0, 'data': None} _cache = {'at': 0.0, 'data': None}
_lock = threading.Lock() _lock = threading.Lock()
@@ -153,6 +173,15 @@ def wireguard_peers():
return peers 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): def collect(state):
"""The status document. state says which features the router has.""" """The status document. state says which features the router has."""
units = {'vpn-router-setup': unit_state('vpn-router-setup.service')} units = {'vpn-router-setup': unit_state('vpn-router-setup.service')}
@@ -160,6 +189,10 @@ def collect(state):
units['strongswan'] = unit_state('strongswan.service') units['strongswan'] = unit_state('strongswan.service')
if state.get('wireguard'): if state.get('wireguard'):
units['wg-quick@wg0'] = unit_state('wg-quick@wg0.service') units['wg-quick@wg0'] = unit_state('wg-quick@wg0.service')
if state.get('openvpn'):
units['openvpn-server@vpn-router'] = unit_state('openvpn-server@vpn-router.service')
if state.get('users'):
units['slapd'] = unit_state('slapd.service')
status = { status = {
'status': 'ok' if all(value == 'active' for value in units.values()) else 'degraded', 'status': 'ok' if all(value == 'active' for value in units.values()) else 'degraded',
@@ -175,6 +208,8 @@ def collect(state):
status['p2s'] = {'clients': established('road-warrior')} status['p2s'] = {'clients': established('road-warrior')}
if state.get('wireguard'): if state.get('wireguard'):
status['wireguard'] = {'peers': wireguard_peers()} status['wireguard'] = {'peers': wireguard_peers()}
if state.get('openvpn'):
status['openvpn'] = {'clients': openvpn_clients()}
return status return status
@@ -278,6 +313,9 @@ def text_of(status):
if 'p2s' in status: if 'p2s' in status:
clients = status['p2s']['clients'] clients = status['p2s']['clients']
lines.append(f'Road-warrior (IPsec P2S): {"unknown" if clients is None else f"{clients} connected"}') 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: if 'wireguard' in status:
peers = status['wireguard']['peers'] peers = status['wireguard']['peers']
if peers is None: if peers is None:
@@ -293,25 +331,39 @@ def text_of(status):
def html_of(status): def html_of(status):
"""The status for a browser, as a page styled with Tailwind CSS.""" """The status for a browser, as a page styled with Tailwind CSS."""
colour = 'bg-emerald-600' if status['status'] == 'ok' else 'bg-amber-600' colour = 'bg-emerald-600 dark:bg-emerald-700' if status['status'] == 'ok' else 'bg-amber-600 dark:bg-amber-700'
body = ''.join(f'<li>{escape(line)}</li>' for line in text_of(status).splitlines()[4:] if line) # 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> return f"""<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <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> <title>VPN Router</title>
<script src="{TAILWIND}"></script> <script src="{TAILWIND}"></script>
</head> </head>
<body class="min-h-screen bg-slate-100 text-slate-900"> <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="mx-auto max-w-2xl px-4 py-10">
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-lg"> <div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-800">
<div class="{colour} px-6 py-4 text-white"> <div class="{colour} px-6 py-4 text-white">
<h1 class="text-xl font-semibold">VPN Router</h1> <h1 class="text-xl font-semibold">VPN Router</h1>
<p class="mt-1 text-sm">Status: {escape(status['status'])}, version {escape(status['version'] or 'unknown')}</p> <p class="mt-1 text-sm">Status: {escape(status['status'])}, version {escape(status['version'] or 'unknown')}</p>
</div> </div>
<ul class="space-y-2 px-6 py-6 font-mono text-sm">{body}</ul> <div class="px-6 py-4">
<p class="px-6 pb-6 text-xs text-slate-500">{escape(status['time'])} - <a class="underline" href="/status">JSON</a></p> <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>
</div> </div>
</body> </body>
@@ -319,6 +371,62 @@ def html_of(status):
""" """
def user_tool(*args, password=None):
"""Run vpn-router-user; the HTTP status for its exit status and its JSON answer."""
try:
result = subprocess.run([USER_TOOL, *args], input='' if password is None else password + '\n',
capture_output=True, text=True, timeout=USER_TIMEOUT)
document = json.loads(result.stdout)
except (OSError, subprocess.TimeoutExpired, ValueError) as exc:
return 500, {'error': f'vpn-router-user failed: {exc}'}
if not isinstance(document, dict):
return 500, {'error': 'vpn-router-user gave no object'}
return USER_STATUS.get(result.returncode, 500), document
def users(state, method, path, body):
"""Answer a /users request: the HTTP status and the JSON document.
GET /users the users
POST /users add a user: {"name": ..., "password": ...}
PUT /users/<name>/password set the password: {"password": ...}
POST /users/<name>/disable lock the user out of OpenVPN
POST /users/<name>/enable let the user in again
DELETE /users/<name> remove the user and revoke the certificate
GET /users/<name>/profile the OpenVPN client profile
"""
if not state.get('users'):
return 404, {'error': 'no P2S system with users is configured'}
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
return 400, {'error': 'the body must be a JSON object'}
body = body or {}
password = body.get('password')
if password is not None and (not isinstance(password, str) or '\n' in password or '\r' in password):
return 400, {'error': 'the password must be a string of one line'}
parts = path.strip('/').split('/')
route = (method, len(parts), parts[2] if len(parts) == 3 else None)
if route == ('GET', 1, None):
return user_tool('list')
if route == ('POST', 1, None):
name = body.get('name')
if not isinstance(name, str) or password is None:
return 400, {'error': 'name and password are required strings'}
code, document = user_tool('add', name, password=password)
return (201 if code == 200 else code), document
if route == ('PUT', 3, 'password'):
if password is None:
return 400, {'error': 'password is a required string'}
return user_tool('passwd', parts[1], password=password)
if route in (('POST', 3, 'disable'), ('POST', 3, 'enable')):
return user_tool(parts[2], parts[1])
if route == ('DELETE', 2, None):
return user_tool('remove', parts[1])
if route == ('GET', 3, 'profile'):
return user_tool('profile', parts[1])
return 404, {'error': f'no route {method} {path}'}
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
protocol_version = 'HTTP/1.1' protocol_version = 'HTTP/1.1'
@@ -329,19 +437,31 @@ class Handler(BaseHTTPRequestHandler):
_cache['at'] = time.monotonic() _cache['at'] = time.monotonic()
return _cache['data'] return _cache['data']
def authorized(self):
"""Whether the request carries a token signed with the secret; False also while no secret is set."""
secret = self.server.state.get('jwt_secret')
header = self.headers.get('Authorization', '')
return bool(secret) and header.startswith('Bearer ') and verify(header[7:].strip(), secret) is not None
def answer(self, send_body): def answer(self, send_body):
path = self.path.split('?', 1)[0] path = self.path.split('?', 1)[0]
extra = [] extra = []
if path == '/config': # A body that is not read must not be taken for the next request.
secret = self.server.state.get('jwt_secret') if self.command not in ('GET', 'HEAD'):
header = self.headers.get('Authorization', '') self.close_connection = True
if not secret: protected =path == '/config' or path == '/users' or path.startswith('/users/')
body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404 if protected and not self.server.state.get('jwt_secret'):
elif not header.startswith('Bearer ') or verify(header[7:].strip(), secret) is None: body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
body, kind, code = b'Unauthorized\n', 'text/plain; charset=utf-8', 401 elif protected and not self.authorized():
extra.append(('WWW-Authenticate', 'Bearer')) body, kind, code = b'Unauthorized\n', 'text/plain; charset=utf-8', 401
else: extra.append(('WWW-Authenticate', 'Bearer'))
body, kind, code = json.dumps(details(self.server.state), indent=2).encode() + b'\n', 'application/json', 200 elif path == '/config' and self.command in ('GET', 'HEAD'):
body, kind, code = json.dumps(details(self.server.state), indent=2).encode() + b'\n', 'application/json', 200
elif protected and path != '/config':
code, document = users(self.server.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': elif path == '/status':
body, kind, code = json.dumps(self.status(), indent=2).encode() + b'\n', 'application/json', 200 body, kind, code = json.dumps(self.status(), indent=2).encode() + b'\n', 'application/json', 200
elif path == '/': elif path == '/':
@@ -363,12 +483,34 @@ class Handler(BaseHTTPRequestHandler):
if send_body: if send_body:
self.wfile.write(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): def do_GET(self):
self.answer(True) self.answer(True)
def do_HEAD(self): def do_HEAD(self):
self.answer(False) 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): def log_message(self, format, *args):
sys.stderr.write('%s - - [%s] %s\n' % (self.client_address[0], self.log_date_time_string(), format % args)) sys.stderr.write('%s - - [%s] %s\n' % (self.client_address[0], self.log_date_time_string(), format % args))
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/python3
"""Manage the users of the router's P2S systems.
A user is an entry in the router's directory, whose password OpenVPN checks, and a client
certificate of the router CA with the user name as its common name, which OpenVPN and
IKEv2 accept. Passwords are read from the first line of standard input, never from the
command line. The answer is JSON on standard output; on failure it is {"error": ...} and
the exit status says why: 2 the request is invalid, 3 the user does not exist, 4 the user
exists, 1 anything else.
vpn-router-user list
vpn-router-user add <name> (password on stdin)
vpn-router-user passwd <name> (password on stdin)
vpn-router-user disable <name>
vpn-router-user enable <name>
vpn-router-user remove <name>
vpn-router-user profile <name>
"""
import fcntl
import json
import pathlib
import re
import subprocess
import sys
sys.path.insert(0, '/usr/lib/vpn-router')
import ldap
import ldap.modlist
import vpnrouter
import vpnrouter_ldap
#: A user name is one lowercase DNS label, as it is also the common name of the certificate.
NAME = re.compile(r'^[a-z][a-z0-9-]{0,30}[a-z0-9]$|^[a-z]$')
PASSWORD_MIN = 8
PASSWORD_MAX = 128
#: The value of pwdAccountLockedTime that locks an account until it is unlocked.
LOCKED = b'000001010000Z'
LOCK_FILE = pathlib.Path('/run/vpn-router/users.lock')
class Failure(Exception):
"""A request that cannot be done; status is the exit status."""
def __init__(self, message, status=1):
super().__init__(message)
self.status = status
def dn_of(name):
return f'uid={name},{vpnrouter_ldap.USERS}'
def certificate(name):
return vpnrouter.USER_CERT_DIR / f'{name}_cert.pem', vpnrouter.USER_CERT_DIR / f'{name}_key.pem'
def entry(connection, name):
"""The attributes of the user's entry; Failure with status 3 when there is none."""
try:
return connection.search_s(dn_of(name), ldap.SCOPE_BASE, attrlist=['uid', 'pwdAccountLockedTime'])[0][1]
except ldap.NO_SUCH_OBJECT:
raise Failure(f'user {name} does not exist', 3)
def password():
"""The password from the first line of standard input."""
value = sys.stdin.readline().rstrip('\n')
if not PASSWORD_MIN <= len(value) <= PASSWORD_MAX:
raise Failure(f'the password must have {PASSWORD_MIN} to {PASSWORD_MAX} characters', 2)
return value
def simple_ca(*args):
result = subprocess.run([sys.executable, str(vpnrouter.SIMPLE_CA), *args], capture_output=True, text=True)
if result.returncode != 0:
raise Failure(f'simple-ca {args[0]} failed: {result.stderr.strip() or result.stdout.strip()}')
def publish_crl():
"""Give strongSwan the CRL after a revocation; OpenVPN reads it at every connection."""
if vpnrouter.install_file(vpnrouter.PKI_DIR / 'crl.pem', vpnrouter.CRL_DEST, 0o644):
subprocess.run(['swanctl', '--load-creds'], capture_output=True, text=True)
def summary(attributes):
name = attributes['uid'][0].decode()
return {
'name': name,
'enabled': 'pwdAccountLockedTime' not in attributes,
'certificate': certificate(name)[0].is_file(),
}
def list_users(connection):
found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL, '(objectClass=inetOrgPerson)',
['uid', 'pwdAccountLockedTime'])
return {'users': sorted((summary(attributes) for _, attributes in found), key=lambda user: user['name'])}
def add(connection, name):
secret = password()
if not (vpnrouter.PKI_DIR / 'ca_key.pem').is_file():
raise Failure('the router CA does not exist; enable OpenVPN or IKEv2 road-warrior access first')
try:
connection.add_s(dn_of(name), ldap.modlist.addModlist({
'objectClass': [b'inetOrgPerson'], 'uid': [name.encode()], 'cn': [name.encode()], 'sn': [name.encode()],
}))
except ldap.ALREADY_EXISTS:
raise Failure(f'user {name} exists', 4)
connection.passwd_s(dn_of(name), None, secret)
vpnrouter.USER_CERT_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
for path in certificate(name):
path.unlink(missing_ok=True)
simple_ca('make-cert', '--ca-dir', str(vpnrouter.PKI_DIR), '--cert-dir', str(vpnrouter.USER_CERT_DIR), name)
return summary(entry(connection, name))
def passwd(connection, name):
secret = password()
entry(connection, name)
connection.passwd_s(dn_of(name), None, secret)
return summary(entry(connection, name))
def disable(connection, name):
if 'pwdAccountLockedTime' not in entry(connection, name):
connection.modify_s(dn_of(name), [(ldap.MOD_REPLACE, 'pwdAccountLockedTime', [LOCKED])])
return summary(entry(connection, name))
def enable(connection, name):
if 'pwdAccountLockedTime' in entry(connection, name):
connection.modify_s(dn_of(name), [(ldap.MOD_DELETE, 'pwdAccountLockedTime', None)])
return summary(entry(connection, name))
def remove(connection, name):
entry(connection, name)
cert, key = certificate(name)
if cert.is_file():
simple_ca('revoke-cert', '--ca-dir', str(vpnrouter.PKI_DIR), str(cert))
simple_ca('make-crl', '--ca-dir', str(vpnrouter.PKI_DIR), '--days', str(vpnrouter.CRL_DAYS))
publish_crl()
cert.unlink(missing_ok=True)
key.unlink(missing_ok=True)
connection.delete_s(dn_of(name))
return {'name': name, 'removed': True}
def profile(connection, name):
"""The OpenVPN client profile of the user, with the CA, the certificate and the key inline."""
entry(connection, name)
cfg = vpnrouter.load_config()
if not cfg['openvpn']['enabled']:
raise Failure('OpenVPN is not enabled', 2)
cert, key = certificate(name)
if not cert.is_file() or not key.is_file():
raise Failure(f'user {name} has no certificate')
fqdn = cfg['wan']['local_fqdn'].strip()
text = '\n'.join([
'client',
'dev tun',
f'proto {cfg["openvpn"]["protocol"]}',
f'remote {fqdn} {cfg["openvpn"]["port"]}',
'nobind',
'persist-key',
'persist-tun',
'remote-cert-tls server',
f'verify-x509-name {fqdn} name',
'auth-user-pass',
'auth-nocache',
'verb 3',
'<ca>', extract_pem(vpnrouter.CA_CERT_DEST.read_text(encoding='utf-8')), '</ca>',
'<cert>', extract_pem(cert.read_text(encoding='utf-8')), '</cert>',
'<key>', key.read_text(encoding='utf-8').strip(), '</key>',
'',
])
return {'name': name, 'profile': text}
def extract_pem(text):
"""The PEM blocks of the certificates in a file that also holds their text form."""
begin, end = '-----BEGIN CERTIFICATE-----', '-----END CERTIFICATE-----'
blocks = []
start = text.find(begin)
while start != -1:
stop = text.index(end, start) + len(end)
blocks.append(text[start:stop])
start = text.find(begin, stop)
return '\n'.join(blocks)
COMMANDS = {'list': list_users, 'add': add, 'passwd': passwd, 'disable': disable, 'enable': enable,
'remove': remove, 'profile': profile}
def main(argv):
try:
if not argv or argv[0] not in COMMANDS or len(argv) != (1 if argv[0] == 'list' else 2):
raise Failure('usage: vpn-router-user list | add|passwd|disable|enable|remove|profile <name>', 2)
if len(argv) == 2 and not NAME.match(argv[1]):
raise Failure('a user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter', 2)
LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(LOCK_FILE, 'w') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
connection = vpnrouter_ldap.manager()
try:
answer = COMMANDS[argv[0]](connection, *argv[1:])
finally:
connection.unbind_s()
except Failure as failure:
print(json.dumps({'error': str(failure)}))
return failure.status
except (ldap.LDAPError, OSError) as exc:
print(json.dumps({'error': f'the user directory failed: {exc}'}))
return 1
print(json.dumps(answer))
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))
@@ -33,6 +33,12 @@
"address": "192.168.200.1/24", "address": "192.168.200.1/24",
"listen_port": 51820 "listen_port": 51820
}, },
"openvpn": {
"enabled": false,
"address_pool": "172.16.1.0/24",
"port": 1194,
"protocol": "udp"
},
"docker": { "docker": {
"mode": "auto", "mode": "auto",
"exposed_ports": ["443"] "exposed_ports": ["443"]
@@ -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