From cc6748a6220a156c30381875f5d2e0d1b2ac56a7 Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Fri, 23 Oct 2026 21:55:03 +0200 Subject: [PATCH] update: Added OpenLDAP and OpenVPN to the router. --- ADR.md | 73 ++++++ README.md | 127 +++++++++- debian-package/debian/changelog | 25 ++ debian-package/debian/control | 12 +- debian-package/debian/install | 3 + debian-package/debian/postrm | 11 +- debian-package/debian/prerm | 20 +- .../debian/vpn-router.vpn-router-web.service | 1 + .../src/usr/lib/vpn-router/openvpn-same-user | 12 + debian-package/src/usr/lib/vpn-router/setup | 69 +++++- .../src/usr/lib/vpn-router/vpnrouter.py | 82 ++++++- .../src/usr/lib/vpn-router/vpnrouter_ldap.py | 182 ++++++++++++++ debian-package/src/usr/lib/vpn-router/web | 184 ++++++++++++-- debian-package/src/usr/sbin/vpn-router-user | 225 ++++++++++++++++++ .../doc/vpn-router/vpn-router.json.example | 6 + .../vpn-router/templates/openvpn-ldap.conf.j2 | 12 + .../templates/openvpn-server.conf.j2 | 30 +++ 17 files changed, 1011 insertions(+), 63 deletions(-) create mode 100755 debian-package/src/usr/lib/vpn-router/openvpn-same-user create mode 100644 debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py create mode 100755 debian-package/src/usr/sbin/vpn-router-user create mode 100644 debian-package/src/usr/share/vpn-router/templates/openvpn-ldap.conf.j2 create mode 100644 debian-package/src/usr/share/vpn-router/templates/openvpn-server.conf.j2 diff --git a/ADR.md b/ADR.md index fabc356..320c065 100644 --- a/ADR.md +++ b/ADR.md @@ -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 would need a certificate that the package does not have. The masking is by option name, so a new secret option needs a name that matches. + +## ADR-4: Users are kept in a local OpenLDAP directory + +Status: Accepted + +Context: OpenVPN needs user accounts with passwords, and the users are meant to cover every P2S +system of the router. The options weighed were an external OIDC provider (Entra ID, Keycloak) +with openvpn-auth-oauth2, an OIDC server embedded on the router, a file of password hashes, and +OpenLDAP on the router. OIDC for OpenVPN needs a browser on the client and a program from outside +the Ubuntu archive, and it cannot authenticate IKEv2 EAP-TLS or WireGuard. slapd, its Python +module `python3-ldap` and the OpenVPN plugin `openvpn-auth-ldap` are all in the archive; the +plugin checks a password by binding as the user, so the hash scheme is slapd's alone. A directory +can also be read later by an OIDC provider through its LDAP connector. + +Decision: The users are entries in slapd. The package keeps its own database, +`dc=vpn-router,dc=local` in `/var/lib/ldap/vpn-router` (the path the slapd AppArmor profile +allows), next to the database the slapd package creates from the host's domain, so the package +does not depend on the debconf answers of slapd. Its root DN is root's peer-credential DN on the +local socket, so `vpn-router-setup` manages it with SASL EXTERNAL and no administrator password +exists. Users are changed by `cn=user-manager,ou=services`, whose ACLs allow writing `ou=users` +and setting passwords (`=wx`) but not reading them. Passwords are hashed with ARGON2, and the +ppolicy overlay locks a disabled user (`pwdAccountLockedTime`, effective with `pwdLockout`). +slapd listens on the local socket only. Provisioning checks every step before changing it, so it +runs at every start of `vpn-router-setup`. + +Consequences: The package depends on `slapd`, `python3-ldap`, `openvpn` and +`openvpn-auth-ldap`, and `vpn-router-setup` and `vpn-router-user` import `python3-ldap`, the first +library outside the standard library besides `jinja2`. The service account's password is a +root-only file. The directory is user data: it is kept when the features are switched off and +on purge, and it is backed up with `slapcat`, not by copying files. + +## ADR-5: OpenVPN clients need a certificate and the password of the user it names + +Status: Accepted + +Context: The router CA already issues client certificates for IKEv2. OpenVPN can check a +certificate, a password, or both. With both, a certificate alone and a password alone are not +enough, but `openvpn-auth-ldap` checks only the password of the user name given at login, which +need not be the user of the certificate. + +Decision: OpenVPN checks the certificate against the CA and the CRL, the password with +`openvpn-auth-ldap`, and runs `openvpn-same-user` as `auth-user-pass-verify` with `via-env`, +which accepts the login only when the user name equals the certificate's common name. OpenVPN +calls both and needs both to pass. A user's certificate has the user name as its common name, is +issued when the user is added and revoked when the user is removed. The CRL is created with the +CA, so that `crl-verify` has a file from the start, and is valid for ten years, because the router +renews it only when it revokes a certificate. The server uses the router's own certificate, whose +common name is `wan.local_fqdn`; the client profile checks that name (`verify-x509-name`), since +the CA's certificates carry both the server and the client purpose. + +Consequences: Disabling a user locks the password, which stops OpenVPN but not IKEv2, which +checks certificates only; removing a user revokes the certificate for both. A user's +certificate expires after the CA's default of a year and is renewed by removing and adding the +user again. + +## ADR-6: The web service manages users by running vpn-router-user + +Status: Accepted + +Context: The web service has only the standard library (ADR-2), and managing users needs +`python3-ldap` and `simple-ca`. Its unit is sandboxed with `ProtectSystem=strict`. + +Decision: `/users` runs `vpn-router-user` and returns its JSON answer, mapping its exit status +to the HTTP status. Passwords go to the program on standard input, so they never appear in a +command line. The unit allows writing the paths the program needs: `/run/vpn-router` for its +lock, `/etc/vpn-router/pki` for the certificates and the CA's state, and `/etc/swanctl/x509crl` +for the published CRL. A lock serializes the program's runs, because `simple-ca` keeps its state +in one file. + +Consequences: The web service still imports nothing outside the standard library, and the same +program serves the command line and the API. Each request starts a process, which is fine for +user administration. The API is protected by the token of `/config`, so whoever has the secret +can manage users. diff --git a/README.md b/README.md index f430b53..b9b3ecc 100644 --- a/README.md +++ b/README.md @@ -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) - 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 @@ -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` | `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 | @@ -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 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 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 `[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. 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. @@ -264,8 +272,10 @@ ca_cert.pem ca_key.pem ca_bundle.pem