This commit is contained in:
1 parent
609921348b
commit
cc6748a622
17 files changed
+1011
-63
No files matched your search
@@ -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.
|
||||
@@ -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 <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
|
||||
If road-warrior or OpenVPN access is enabled and this directory is empty, a certificate
|
||||
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
|
||||
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>,
|
||||
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-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 |
|
||||
| --- | --- |
|
||||
| `/` | 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 |
|
||||
| `/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
|
||||
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`
|
||||
(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
|
||||
@@ -388,6 +468,31 @@ print(f'{head}.{body}.{sign}')
|
||||
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,
|
||||
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
|
||||
@@ -397,15 +502,15 @@ applies with `systemctl restart vpn-router-setup`. The service ends without an e
|
||||
## Removal
|
||||
|
||||
`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
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
* swanctl output with more closing than opening lines is read as not
|
||||
|
||||
@@ -20,13 +20,19 @@ Depends: ${misc:Depends},
|
||||
openssl,
|
||||
python3,
|
||||
python3-jinja2,
|
||||
python3-ldap,
|
||||
slapd,
|
||||
openvpn,
|
||||
openvpn-auth-ldap,
|
||||
iproute2,
|
||||
systemd-resolved
|
||||
Description: Linux VPN router with IPSec, road-warrior and WireGuard support
|
||||
Description: Linux VPN router with IPSec, road-warrior, OpenVPN and WireGuard support
|
||||
Configures a Linux host or virtual machine as a VPN router: site-to-site
|
||||
IKEv2 IPSec (strongSwan swanctl), optional road-warrior access using EAP-TLS,
|
||||
optional WireGuard, and the routing, NAT, MSS clamping and firewall rules a
|
||||
router needs. Nothing in the package is specific to a cloud provider.
|
||||
optional OpenVPN with a certificate and a password per user, kept in a local
|
||||
LDAP directory, optional WireGuard, and the routing, NAT, MSS clamping and
|
||||
firewall rules a router needs. Nothing in the package is specific to a cloud
|
||||
provider.
|
||||
.
|
||||
Settings live in /etc/vpn-router/vpn-router.json, which is created on first
|
||||
install and owned by the administrator afterwards. Editing it and restarting
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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/openvpn-same-user usr/lib/vpn-router/
|
||||
src/usr/sbin/vpn-router-user usr/sbin/
|
||||
src/usr/lib/vpn-router/simple-ca usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/configure usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/docker-rules usr/lib/vpn-router/
|
||||
|
||||
@@ -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|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|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES END$/ {
|
||||
skip = 0; next
|
||||
}
|
||||
skip { next }
|
||||
@@ -47,10 +47,13 @@ case "$1" in
|
||||
/etc/swanctl/conf.d/road-warrior.conf \
|
||||
/etc/systemd/resolved.conf.d/p2s-forwarder.conf \
|
||||
/etc/wireguard/wg0.conf \
|
||||
/etc/openvpn/server/vpn-router.conf \
|
||||
/etc/openvpn/server/vpn-router-ldap.conf \
|
||||
/etc/vpn-router/vpn-router.json
|
||||
|
||||
# Key material is not package state: /etc/vpn-router/pki, the WireGuard
|
||||
# key pair and everything under /etc/swanctl are left in place.
|
||||
# Key material and users are not package state: /etc/vpn-router/pki,
|
||||
# /etc/vpn-router/ldap, the user directory in slapd, the WireGuard key
|
||||
# pair and everything under /etc/swanctl are left in place.
|
||||
rmdir --ignore-fail-on-non-empty /etc/vpn-router 2>/dev/null || true
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -11,15 +11,17 @@ case "$1" in
|
||||
swanctl --terminate --ike road-warrior >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# This package enabled wg-quick@wg0, so it takes it down again. Use the
|
||||
# debhelper wrappers rather than systemctl, so the script behaves on a
|
||||
# machine without systemd.
|
||||
if command -v deb-systemd-invoke >/dev/null 2>&1; then
|
||||
deb-systemd-invoke stop wg-quick@wg0.service >/dev/null 2>&1 || true
|
||||
fi
|
||||
if command -v deb-systemd-helper >/dev/null 2>&1; then
|
||||
deb-systemd-helper disable wg-quick@wg0.service >/dev/null 2>&1 || true
|
||||
fi
|
||||
# This package enabled wg-quick@wg0 and openvpn-server@vpn-router, so
|
||||
# it takes them down again. Use the debhelper wrappers rather than
|
||||
# systemctl, so the script behaves on a machine without systemd.
|
||||
for unit in wg-quick@wg0.service openvpn-server@vpn-router.service; do
|
||||
if command -v deb-systemd-invoke >/dev/null 2>&1; then
|
||||
deb-systemd-invoke stop "$unit" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if command -v deb-systemd-helper >/dev/null 2>&1; then
|
||||
deb-systemd-helper disable "$unit" >/dev/null 2>&1 || true
|
||||
fi
|
||||
done
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ Restart=on-failure
|
||||
RestartSec=15
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/run/vpn-router /etc/vpn-router/pki /etc/swanctl/x509crl
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/python3
|
||||
"""Accept an OpenVPN login only when the user name is the common name of the client certificate.
|
||||
|
||||
Run by the OpenVPN server as auth-user-pass-verify with via-env, next to the LDAP plugin that
|
||||
checks the password, so a user's certificate cannot be used with another user's password.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
username = os.environ.get('username', '')
|
||||
sys.exit(0 if username and username == os.environ.get('common_name') else 1)
|
||||
@@ -20,7 +20,10 @@ 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')
|
||||
@@ -36,6 +39,7 @@ 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'),
|
||||
('# ROUTER FORWARD RULES START', '# ROUTER FORWARD RULES END'),
|
||||
@@ -121,7 +125,8 @@ 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 filter_block(ctx):
|
||||
@@ -146,14 +151,30 @@ 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']:
|
||||
@@ -302,6 +323,36 @@ def manage_wireguard(ctx, changed):
|
||||
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):
|
||||
"""Let the platform module apply its own state, if it defines any."""
|
||||
module = vpnrouter.platform_module(cfg)
|
||||
@@ -353,6 +404,8 @@ def refresh_web(cfg, ctx):
|
||||
'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)),
|
||||
'users': bool(vpnrouter.p2s_ready(ctx) or vpnrouter.openvpn_ready(ctx)),
|
||||
'configuration': masked,
|
||||
'network': {
|
||||
'external_interface': ctx['wan_iface'],
|
||||
@@ -400,8 +453,10 @@ def main():
|
||||
elif rules_changed:
|
||||
run(['ufw', 'reload'], check=False)
|
||||
|
||||
manage_directory(ctx)
|
||||
manage_strongswan(ctx, changed)
|
||||
manage_wireguard(ctx, changed)
|
||||
manage_openvpn(ctx, changed)
|
||||
|
||||
if 'resolved' in changed:
|
||||
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:
|
||||
sys.path.insert(0, str(LIB_DIR))
|
||||
|
||||
import vpnrouter_ldap
|
||||
import vpnrouter_platforms
|
||||
|
||||
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'
|
||||
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')
|
||||
@@ -47,6 +51,7 @@ SCHEMA = {
|
||||
'remote': {'addrs': [], 'id': '', 'cidrs': [], 'psk': '', 'psk_file': ''},
|
||||
'p2s': {'enabled': False, 'address_pool': '', 'ca_name': 'VPN Router CA'},
|
||||
'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820},
|
||||
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
|
||||
'docker': {'mode': 'auto', 'exposed_ports': []},
|
||||
'web': {'enabled': True, 'port': 8080, 'jwt_secret': ''},
|
||||
}
|
||||
@@ -56,6 +61,14 @@ 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})?$')
|
||||
|
||||
@@ -209,6 +222,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')
|
||||
|
||||
@@ -232,6 +252,15 @@ 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')
|
||||
@@ -438,6 +467,8 @@ 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
|
||||
@@ -477,6 +508,23 @@ 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'),
|
||||
}
|
||||
@@ -504,6 +552,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)),
|
||||
@@ -593,11 +645,13 @@ def _simple_ca(*args):
|
||||
|
||||
|
||||
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)
|
||||
os.chmod(PKI_DIR, 0o700)
|
||||
|
||||
if any(PKI_DIR.iterdir()):
|
||||
if (PKI_DIR / 'ca_key.pem').is_file() and not (PKI_DIR / 'crl.pem').is_file():
|
||||
_simple_ca('make-crl', '--ca-dir', str(PKI_DIR), '--days', str(CRL_DAYS))
|
||||
return
|
||||
if not ctx['p2s_ca_name']:
|
||||
warn('p2s.ca_name is empty, not creating a local CA')
|
||||
@@ -608,6 +662,7 @@ def provision_pki(ctx):
|
||||
_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'])
|
||||
_simple_ca('make-crl', '--ca-dir', str(PKI_DIR), '--days', str(CRL_DAYS))
|
||||
|
||||
|
||||
def distribute_pki(ctx):
|
||||
@@ -635,7 +690,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()
|
||||
@@ -653,7 +708,7 @@ def configure(cfg=None, local_id_override=None):
|
||||
|
||||
changed = set()
|
||||
|
||||
if p2s_ready(ctx):
|
||||
if p2s_ready(ctx) or openvpn_ready(ctx):
|
||||
provision_pki(ctx)
|
||||
if distribute_pki(ctx):
|
||||
changed.add('swanctl')
|
||||
@@ -669,13 +724,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):
|
||||
@@ -683,4 +739,14 @@ def configure(cfg=None, local_id_override=None):
|
||||
elif remove(WG_CONF):
|
||||
changed.add('wireguard')
|
||||
|
||||
if openvpn_ready(ctx):
|
||||
if render(env, ctx, 'openvpn-server.conf.j2', OPENVPN_CONF, 0o600):
|
||||
changed.add('openvpn')
|
||||
if render(env, ctx, 'openvpn-ldap.conf.j2', OPENVPN_LDAP, 0o600):
|
||||
changed.add('openvpn')
|
||||
else:
|
||||
for path in (OPENVPN_CONF, OPENVPN_LDAP):
|
||||
if remove(path):
|
||||
changed.add('openvpn')
|
||||
|
||||
return ctx, changed
|
||||
@@ -0,0 +1,182 @@
|
||||
"""The router's user directory in the local slapd.
|
||||
|
||||
The package keeps its own database, dc=vpn-router,dc=local in /var/lib/ldap/vpn-router,
|
||||
next to the one slapd's installation creates. Root manages it through the local socket
|
||||
with SASL EXTERNAL: the database's root DN is root's peer-credential DN, so there is no
|
||||
administrator password. Users are changed by the user-manager service account, which the
|
||||
ACLs allow to write ou=users and to set, never read, passwords.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
import pwd
|
||||
import re
|
||||
import secrets
|
||||
|
||||
import ldap
|
||||
import ldap.modlist
|
||||
import ldap.sasl
|
||||
|
||||
URI = 'ldapi:///'
|
||||
BASE = 'dc=vpn-router,dc=local'
|
||||
USERS = f'ou=users,{BASE}'
|
||||
SERVICES = f'ou=services,{BASE}'
|
||||
POLICIES = f'ou=policies,{BASE}'
|
||||
DEFAULT_POLICY = f'cn=default,{POLICIES}'
|
||||
MANAGER = f'cn=user-manager,{SERVICES}'
|
||||
ROOT = 'gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth'
|
||||
|
||||
DB_DIRECTORY = pathlib.Path('/var/lib/ldap/vpn-router')
|
||||
SECRET_DIR = pathlib.Path('/etc/vpn-router/ldap')
|
||||
MANAGER_SECRET = SECRET_DIR / 'user-manager.secret'
|
||||
SLAPD_DEFAULTS = pathlib.Path('/etc/default/slapd')
|
||||
SLAPD_SERVICES = 'SLAPD_SERVICES="ldapi:///"'
|
||||
|
||||
MODULES = ('argon2', 'ppolicy')
|
||||
PASSWORD_HASH = '{ARGON2}'
|
||||
|
||||
#: The ACLs of the database, in order.
|
||||
ACCESS = (
|
||||
f'to attrs=userPassword by dn.exact="{MANAGER}" =wx by self =wx by anonymous auth by * none',
|
||||
f'to dn.subtree="{USERS}" by dn.exact="{MANAGER}" manage by * read',
|
||||
'to * by * read',
|
||||
)
|
||||
|
||||
#: The index of an ordered cn=config value: {0}, {1}, ...
|
||||
ORDER = re.compile(rb'^\{\d+\}')
|
||||
|
||||
|
||||
def unordered(values):
|
||||
"""Values of an ordered cn=config attribute without their {n} prefixes."""
|
||||
return [ORDER.sub(b'', value).decode() for value in values]
|
||||
|
||||
|
||||
def root():
|
||||
"""A connection to slapd as root, through the local socket."""
|
||||
connection = ldap.initialize(URI)
|
||||
connection.sasl_interactive_bind_s('', ldap.sasl.external())
|
||||
return connection
|
||||
|
||||
|
||||
def manager():
|
||||
"""A connection to slapd as the user-manager service account."""
|
||||
connection = ldap.initialize(URI)
|
||||
connection.simple_bind_s(MANAGER, MANAGER_SECRET.read_text(encoding='utf-8'))
|
||||
return connection
|
||||
|
||||
|
||||
def ensure_services():
|
||||
"""Make slapd listen on the local socket only. Returns True when the setting changed."""
|
||||
text = SLAPD_DEFAULTS.read_text(encoding='utf-8')
|
||||
lines = text.splitlines(keepends=True)
|
||||
updated = ''.join(SLAPD_SERVICES + '\n' if line.startswith('SLAPD_SERVICES=') else line for line in lines)
|
||||
if not any(line.startswith('SLAPD_SERVICES=') for line in lines):
|
||||
updated = text.rstrip('\n') + '\n' + SLAPD_SERVICES + '\n'
|
||||
if updated == text:
|
||||
return False
|
||||
SLAPD_DEFAULTS.write_text(updated, encoding='utf-8')
|
||||
return True
|
||||
|
||||
|
||||
def _ensure_modules(connection):
|
||||
dn, attributes = connection.search_s('cn=config', ldap.SCOPE_ONELEVEL, '(objectClass=olcModuleList)', ['olcModuleLoad'])[0]
|
||||
loaded = unordered(attributes.get('olcModuleLoad', []))
|
||||
missing = [name for name in MODULES if name not in loaded and f'{name}.la' not in loaded]
|
||||
if missing:
|
||||
connection.modify_s(dn, [(ldap.MOD_ADD, 'olcModuleLoad', [name.encode() for name in missing])])
|
||||
|
||||
|
||||
def _ensure_password_hash(connection):
|
||||
dn = 'olcDatabase={-1}frontend,cn=config'
|
||||
current = connection.search_s(dn, ldap.SCOPE_BASE, attrlist=['olcPasswordHash'])[0][1].get('olcPasswordHash', [])
|
||||
if current != [PASSWORD_HASH.encode()]:
|
||||
connection.modify_s(dn, [(ldap.MOD_REPLACE, 'olcPasswordHash', [PASSWORD_HASH.encode()])])
|
||||
|
||||
|
||||
def _ensure_database(connection):
|
||||
"""The DN of the package's database in cn=config, created when missing; its ACLs are kept as ACCESS."""
|
||||
found = connection.search_s('cn=config', ldap.SCOPE_ONELEVEL, f'(olcSuffix={BASE})', ['olcAccess'])
|
||||
if not found:
|
||||
DB_DIRECTORY.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
openldap = pwd.getpwnam('openldap')
|
||||
os.chown(DB_DIRECTORY, openldap.pw_uid, openldap.pw_gid)
|
||||
connection.add_s('olcDatabase=mdb,cn=config', ldap.modlist.addModlist({
|
||||
'objectClass': [b'olcDatabaseConfig', b'olcMdbConfig'],
|
||||
'olcDatabase': [b'mdb'],
|
||||
'olcSuffix': [BASE.encode()],
|
||||
'olcDbDirectory': [str(DB_DIRECTORY).encode()],
|
||||
'olcRootDN': [ROOT.encode()],
|
||||
'olcDbIndex': [b'objectClass eq', b'uid eq'],
|
||||
'olcAccess': [f'{{{index}}}{rule}'.encode() for index, rule in enumerate(ACCESS)],
|
||||
}))
|
||||
found = connection.search_s('cn=config', ldap.SCOPE_ONELEVEL, f'(olcSuffix={BASE})', ['olcAccess'])
|
||||
dn, attributes = found[0]
|
||||
if unordered(attributes.get('olcAccess', [])) != list(ACCESS):
|
||||
connection.modify_s(dn, [(ldap.MOD_REPLACE, 'olcAccess', [f'{{{index}}}{rule}'.encode() for index, rule in enumerate(ACCESS)])])
|
||||
return dn
|
||||
|
||||
|
||||
def _ensure_overlay(connection, database):
|
||||
if not connection.search_s(database, ldap.SCOPE_ONELEVEL, '(objectClass=olcPPolicyConfig)', ['1.1']):
|
||||
connection.add_s(f'olcOverlay=ppolicy,{database}', ldap.modlist.addModlist({
|
||||
'objectClass': [b'olcOverlayConfig', b'olcPPolicyConfig'],
|
||||
'olcOverlay': [b'ppolicy'],
|
||||
'olcPPolicyDefault': [DEFAULT_POLICY.encode()],
|
||||
}))
|
||||
|
||||
|
||||
def _ensure_entry(connection, dn, attributes):
|
||||
"""Add the entry when it is missing, else set the given attributes that differ."""
|
||||
try:
|
||||
current = connection.search_s(dn, ldap.SCOPE_BASE, attrlist=list(attributes))[0][1]
|
||||
except ldap.NO_SUCH_OBJECT:
|
||||
connection.add_s(dn, ldap.modlist.addModlist(attributes))
|
||||
return
|
||||
changes = [(ldap.MOD_REPLACE, name, values) for name, values in attributes.items()
|
||||
if name != 'objectClass' and sorted(current.get(name, [])) != sorted(values)]
|
||||
if changes:
|
||||
connection.modify_s(dn, changes)
|
||||
|
||||
|
||||
def _ensure_manager(connection):
|
||||
SECRET_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
os.chmod(SECRET_DIR, 0o700)
|
||||
created = False
|
||||
try:
|
||||
connection.search_s(MANAGER, ldap.SCOPE_BASE, attrlist=['1.1'])
|
||||
except ldap.NO_SUCH_OBJECT:
|
||||
connection.add_s(MANAGER, ldap.modlist.addModlist({
|
||||
'objectClass': [b'organizationalRole', b'simpleSecurityObject'],
|
||||
'cn': [b'user-manager'],
|
||||
'userPassword': [b'{CRYPT}!'],
|
||||
}))
|
||||
created = True
|
||||
if created or not MANAGER_SECRET.is_file():
|
||||
password = secrets.token_urlsafe(32)
|
||||
connection.passwd_s(MANAGER, None, password)
|
||||
MANAGER_SECRET.touch(mode=0o600)
|
||||
MANAGER_SECRET.chmod(0o600)
|
||||
MANAGER_SECRET.write_text(password, encoding='utf-8')
|
||||
|
||||
|
||||
def provision():
|
||||
"""Create or bring up to date the modules, database, ACLs, password policy, entries and service account."""
|
||||
connection = root()
|
||||
try:
|
||||
_ensure_modules(connection)
|
||||
_ensure_password_hash(connection)
|
||||
database = _ensure_database(connection)
|
||||
_ensure_overlay(connection, database)
|
||||
_ensure_entry(connection, BASE, {'objectClass': [b'dcObject', b'organization'], 'dc': [b'vpn-router'], 'o': [b'vpn-router']})
|
||||
for unit in (USERS, SERVICES, POLICIES):
|
||||
_ensure_entry(connection, unit, {'objectClass': [b'organizationalUnit'], 'ou': [unit.split(',')[0][3:].encode()]})
|
||||
_ensure_entry(connection, DEFAULT_POLICY, {
|
||||
'objectClass': [b'organizationalRole', b'pwdPolicy'],
|
||||
'cn': [b'default'],
|
||||
'pwdAttribute': [b'userPassword'],
|
||||
'pwdLockout': [b'TRUE'],
|
||||
'pwdMaxFailure': [b'0'],
|
||||
})
|
||||
_ensure_manager(connection)
|
||||
finally:
|
||||
connection.unbind_s()
|
||||
@@ -1,19 +1,24 @@
|
||||
#!/usr/bin/python3
|
||||
"""Serve the status of the router on its internal address.
|
||||
|
||||
Run by vpn-router-web.service. It needs nothing but the Python standard library.
|
||||
Run by vpn-router-web.service. It needs nothing but the Python standard library; users are
|
||||
managed by running vpn-router-user.
|
||||
|
||||
Pages:
|
||||
/ the status for people: plain text for curl and wget, a styled page
|
||||
(Tailwind CSS from a CDN) for a browser
|
||||
/status the status as JSON
|
||||
/config the configuration without its secrets, and what the router resolved and
|
||||
runs, as JSON; needs an HS256 JWT in an Authorization: Bearer header
|
||||
that is signed with web.jwt_secret and expires within ten minutes; not
|
||||
served (404) while web.jwt_secret is not set
|
||||
runs, as JSON
|
||||
/users the users of the P2S systems: list, add, set the password, disable, enable,
|
||||
remove, and the OpenVPN client profile (see users())
|
||||
|
||||
/config and /users need an HS256 JWT in an Authorization: Bearer header that is signed
|
||||
with web.jwt_secret and expires within ten minutes; they are not served (404) while
|
||||
web.jwt_secret is not set.
|
||||
|
||||
/ and /status show only the status: whether the services run, the package version, whether
|
||||
Docker is installed, and the state of site-to-site, road-warrior (IPsec P2S) 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.
|
||||
|
||||
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'
|
||||
|
||||
#: Seconds after which the status page in a browser reloads itself.
|
||||
REFRESH_SECONDS = 60
|
||||
|
||||
USER_TOOL = '/usr/sbin/vpn-router-user'
|
||||
OPENVPN_STATUS = '/run/openvpn-server/status-vpn-router.log'
|
||||
|
||||
#: Seconds a user command may take; adding a user creates an RSA key.
|
||||
USER_TIMEOUT = 120
|
||||
|
||||
#: The largest request body accepted, in bytes.
|
||||
BODY_LIMIT = 4096
|
||||
|
||||
#: The HTTP status for each exit status of vpn-router-user; any other is 500.
|
||||
USER_STATUS = {0: 200, 2: 400, 3: 404, 4: 409}
|
||||
|
||||
_cache = {'at': 0.0, 'data': None}
|
||||
_lock = threading.Lock()
|
||||
|
||||
@@ -153,6 +173,15 @@ def wireguard_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):
|
||||
"""The status document. state says which features the router has."""
|
||||
units = {'vpn-router-setup': unit_state('vpn-router-setup.service')}
|
||||
@@ -160,6 +189,10 @@ def collect(state):
|
||||
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')
|
||||
if state.get('users'):
|
||||
units['slapd'] = unit_state('slapd.service')
|
||||
|
||||
status = {
|
||||
'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')}
|
||||
if state.get('wireguard'):
|
||||
status['wireguard'] = {'peers': wireguard_peers()}
|
||||
if state.get('openvpn'):
|
||||
status['openvpn'] = {'clients': openvpn_clients()}
|
||||
return status
|
||||
|
||||
|
||||
@@ -278,6 +313,9 @@ def text_of(status):
|
||||
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:
|
||||
@@ -293,25 +331,39 @@ def text_of(status):
|
||||
|
||||
def html_of(status):
|
||||
"""The status for a browser, as a page styled with Tailwind CSS."""
|
||||
colour = 'bg-emerald-600' if status['status'] == 'ok' else 'bg-amber-600'
|
||||
body = ''.join(f'<li>{escape(line)}</li>' for line in text_of(status).splitlines()[4:] if line)
|
||||
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">
|
||||
<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">
|
||||
<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>
|
||||
<ul class="space-y-2 px-6 py-6 font-mono text-sm">{body}</ul>
|
||||
<p class="px-6 pb-6 text-xs text-slate-500">{escape(status['time'])} - <a class="underline" href="/status">JSON</a></p>
|
||||
<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>
|
||||
@@ -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):
|
||||
protocol_version = 'HTTP/1.1'
|
||||
|
||||
@@ -329,19 +437,31 @@ class Handler(BaseHTTPRequestHandler):
|
||||
_cache['at'] = time.monotonic()
|
||||
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):
|
||||
path = self.path.split('?', 1)[0]
|
||||
extra = []
|
||||
if path == '/config':
|
||||
secret = self.server.state.get('jwt_secret')
|
||||
header = self.headers.get('Authorization', '')
|
||||
if not secret:
|
||||
body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
|
||||
elif not header.startswith('Bearer ') or verify(header[7:].strip(), secret) is None:
|
||||
body, kind, code = b'Unauthorized\n', 'text/plain; charset=utf-8', 401
|
||||
extra.append(('WWW-Authenticate', 'Bearer'))
|
||||
else:
|
||||
body, kind, code = json.dumps(details(self.server.state), indent=2).encode() + b'\n', 'application/json', 200
|
||||
# A body that is not read must not be taken for the next request.
|
||||
if self.command not in ('GET', 'HEAD'):
|
||||
self.close_connection = True
|
||||
protected =path == '/config' or path == '/users' or path.startswith('/users/')
|
||||
if protected and not self.server.state.get('jwt_secret'):
|
||||
body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
|
||||
elif protected and not self.authorized():
|
||||
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(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':
|
||||
body, kind, code = json.dumps(self.status(), indent=2).encode() + b'\n', 'application/json', 200
|
||||
elif path == '/':
|
||||
@@ -363,12 +483,34 @@ class Handler(BaseHTTPRequestHandler):
|
||||
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))
|
||||
|
||||
|
||||
Executable
+225
@@ -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",
|
||||
"listen_port": 51820
|
||||
},
|
||||
"openvpn": {
|
||||
"enabled": false,
|
||||
"address_pool": "172.16.1.0/24",
|
||||
"port": 1194,
|
||||
"protocol": "udp"
|
||||
},
|
||||
"docker": {
|
||||
"mode": "auto",
|
||||
"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
|
||||
Reference in new issue
Block a user