From 08693f7e681f311988292be5f944ef41fd5ebc69 Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Sat, 24 Oct 2026 01:47:50 +0200 Subject: [PATCH] feature: users have a first name, a last name and an e-mail address. --- ADR.md | 84 ++-- README.md | 97 ++++- debian-package/debian/changelog | 25 ++ debian-package/debian/install | 2 + .../src/usr/lib/vpn-router/vpnrouter_ldap.py | 8 +- .../src/usr/lib/vpn-router/vpnrouter_users.py | 410 ++++++++++++++++++ debian-package/src/usr/lib/vpn-router/web | 132 +++--- debian-package/src/usr/sbin/vpn-router-group | 120 +++++ debian-package/src/usr/sbin/vpn-router-user | 284 ++++-------- 9 files changed, 872 insertions(+), 290 deletions(-) create mode 100644 debian-package/src/usr/lib/vpn-router/vpnrouter_users.py create mode 100755 debian-package/src/usr/sbin/vpn-router-group diff --git a/ADR.md b/ADR.md index 9482403..a07422c 100644 --- a/ADR.md +++ b/ADR.md @@ -27,7 +27,7 @@ port is reachable from the external interface only when it is in `exposed_ports` in `DOCKER-USER` are not touched, because the package owns only its own chain. Restarting Docker restores the rules. The rules cover IPv4 only. -## ADR-2: A status web service of the standard library, bound to the internal address +## ADR-2: A small status web service, bound to the internal address Status: Accepted @@ -35,15 +35,15 @@ Context: The router has no way to see its state without a shell. A check from th network, by a person or by a monitor, needs a page or an endpoint. The router is an internet-facing host, so what the service shows and where it listens matter. -Decision: The `vpn-router-web` unit runs one Python script that uses only the standard library, in -the way of a small test server. It listens on the address of the internal interface and on +Decision: The `vpn-router-web` unit runs one Python script on the standard library's +`http.server`, with the package's own modules and the Python packages of the Ubuntu archive that +the package depends on, and no web framework. It listens on the address of the internal interface and on `web.port`, 7081 by default, and is on unless `web.enabled` is `false` (ADR-7). `/` is for people, in plain text for `curl` and `wget` and as a Tailwind CSS page for a browser, and `/status` is the same status as JSON. It shows the state of the services, the package version and the state of the site-to-site, road-warrior, OpenVPN and WireGuard features, and nothing from the configuration. `vpn-router-setup` opens the port on the internal interface in UFW and writes -the features and the facts it resolves to `/run/vpn-router/web.json`, so the service does not -need the package's configuration code, which needs Jinja2. +the features and the facts it resolves to `/run/vpn-router/web.json`. Consequences: Nothing outside the internal network can reach it, since it listens on the internal address only and the firewall opens the port on the internal interface only. `/` and `/status` @@ -57,14 +57,14 @@ browser on a network without internet shows the unstyled page, and `curl` is una Status: Accepted Context: A client such as the Router Manager needs the configuration and the resolved state of -the router, which `/` and `/status` must not show. The service has only the standard library, -listens on plain HTTP on the internal network, and its configuration file is readable by root -only. +the router, which `/` and `/status` must not show. The service listens on plain HTTP on the +internal network, and its configuration file is readable by root only. The platform that deploys +the router generates one secret and gives it to the router's configuration and to the client. Decision: `/config` needs a JWT in an `Authorization: Bearer` header. The service verifies HS256 tokens with `hmac` and `hashlib`, with the secret `web.jwt_secret`, and accepts no other -algorithm, because verifying RS256 or ES256 needs a library outside the standard library. The -token must carry `exp`, not more than ten minutes ahead, so that the client makes a fresh token for +algorithm, because a shared secret is what both sides have; RS256 or ES256 would need a key pair +and its distribution. The token must carry `exp`, not more than ten minutes ahead, so that the client makes a fresh token for each use and a leaked one expires. Without a secret the page is not served. The configuration shown has no secret options (ADR-8). @@ -125,24 +125,27 @@ checks certificates only; removing a user revokes the certificate for both. A us 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 +## ADR-6: The web service and vpn-router-user share one module for users 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`. +Context: Users are managed from the command line, by an operator at a terminal, and through +`/users` of the web service, by the Router Manager. Both must apply the same rules to names, +passwords and person attributes, and both change the directory and the CA. `simple-ca` keeps +the CA's state in one file, so two changes must not run at the same time. The web unit is +sandboxed with `ProtectSystem=strict`. -Decision: `/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. +Decision: `vpnrouter_users.py` holds the user operations, the checks and a session that takes a +lock and the connection of the user manager account. The web service imports it and maps a +`Failure` to the HTTP status; `vpn-router-user` imports it and adds the argument parsing and +the output, text for people and JSON with `--json`. The web unit allows writing the paths the +operations need: `/run/vpn-router` for the lock, `/etc/vpn-router/pki` for the certificates and +the CA's state, and `/etc/swanctl/x509crl` for the published CRL. -Consequences: The web service 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. +Consequences: The rules exist once. A request starts no process, and a password never leaves +the process that received it. The web service imports `python3-ldap` and the package's +configuration module. The API is protected by the token of `/config`, so whoever has the secret +can manage users. ## ADR-7: The web service starts on its own and reads its inputs at each request @@ -154,13 +157,13 @@ whatever state setup is in, also before setup has run and while it fails. The in Decision: The service depends on nothing to start and does not end on its own. A discovery thread works out, every 30 seconds, the internal address from `vpn-router.json` as -`general.mode` directs, with the discovery code in `vpnrouter_net.py`, which uses only the -standard library and which `vpnrouter.py` uses too, and `web.port` and `web.enabled` from the -same file. It runs the HTTP server in a thread of its own at that address and port, stops it and -starts a new one when they change, and runs none while the service is disabled, the address is -not known or it cannot be bound. Each request reads the token secret from `vpn-router.json` and -the features and resolved facts from `web.json`, which setup writes, because they need the -configuration code; while that file is missing the router shows no features. The unit is not +`general.mode` directs, with the discovery code in `vpnrouter_net.py`, which `vpnrouter.py` uses +too, and `web.port` and `web.enabled` from the same file. It runs the HTTP server in a thread of +its own at that address and port, stops it and starts a new one when they change, and runs none +while the service is disabled, the address is not known or it cannot be bound. Each request +reads the token secret from `vpn-router.json` and the features and resolved facts from +`web.json`, which setup writes when it applies the configuration, so the service shows what setup +applied; while that file is missing the router shows no features. The unit is not ordered after `vpn-router-setup`, and setup does not restart it. Consequences: The status page answers as soon as the internal address exists, whatever state @@ -223,3 +226,24 @@ profile needs OpenVPN to be enabled. Consequences: slapd always holds the directory and the CA always exists on a configured router, even when no P2S system uses them. An operator who wants a CA of their own must place it before choosing the platform. + +## ADR-11: Groups are groupOfNames entries that never become empty + +Status: Accepted + +Context: Users are to be organised in groups. Groups record membership only; nothing on the +router reads them. LDAP clients that read groups, such as `openvpn-auth-ldap` and the LDAP +connectors of OIDC providers, expect `groupOfNames` with member DNs, and OpenLDAP's core schema +requires a `groupOfNames` to have at least one `member`. + +Decision: A group is `cn=,ou=groups,dc=vpn-router,dc=local`, a `groupOfNames` with the +DNs of its members and an optional `description`. A group is created with at least one member, +and taking out its last member is refused; such a group is removed instead. Removing a user +takes it out of its groups and is refused while the user is the last member of one. The group +operations are in `vpnrouter_users.py` with the user operations, so they share the session and +its lock; `vpn-router-group` is a tool of its own beside `vpn-router-user`, and the web service +serves them under `/groups`. Group names follow the rules of user names. + +Consequences: A group always satisfies the schema without a placeholder member, and an empty +group cannot exist. Removing a user or a member can need another step first. A later use of +groups, such as allowing OpenVPN only to some, can read them as they are. diff --git a/README.md b/README.md index a0f4ddd..6c619ad 100644 --- a/README.md +++ b/README.md @@ -322,8 +322,10 @@ is switched on: 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 +- `ou=groups` holds one `groupOfNames` per group, named by `cn`, with its members' DNs in + `member` and an optional `description`; +- `cn=user-manager,ou=services` is the account that changes users and groups; it may write + `ou=users` and `ou=groups` and set passwords, never read them, and its password is in `/etc/vpn-router/ldap/user-manager.secret` (root only); - passwords are stored as ARGON2 hashes, and a disabled user is locked with the password policy (`pwdAccountLockedTime`); @@ -333,14 +335,21 @@ The directory is kept on purge. ### Managing users -`vpn-router-user` manages the users. A user is the directory entry and a client certificate -with the user name as its common name, in `/etc/vpn-router/pki/users`. Passwords are read from -the first line of standard input. The answer is JSON. +`vpn-router-user` manages the users. A user is the directory entry, with a first name, a last +name and an e-mail address (`givenName`, `sn` and `mail`; `cn` is the first and the last name), +and a client certificate with the user name as its common name, in `/etc/vpn-router/pki/users`. +Passwords are read from the first line of standard input. The answer is text: a table of the +users, the client profile or a short message, and a failure is a message on standard error. +`--json`, before the command, gives the answer as JSON on standard output, and a failure as +`{"error": ...}` there too; the web service uses it. `-h` or `--help` shows the commands, and +after a command its options. ```sh -echo 's3cr3t-pass' | vpn-router-user add alice +echo 's3cr3t-pass' | vpn-router-user add alice --first-name Alice --last-name Smith --email alice@example.com +vpn-router-user update alice --email a.smith@example.com vpn-router-user list -vpn-router-user profile alice +vpn-router-user profile alice > alice.ovpn +vpn-router-user --json list echo 'n3w-pass' | vpn-router-user passwd alice vpn-router-user disable alice vpn-router-user enable alice @@ -349,20 +358,52 @@ vpn-router-user remove alice | Command | Effect | | --- | --- | -| `list` | The users, whether each is enabled and has a certificate | -| `add ` | Adds the user and issues the certificate | +| `list` | The users with their names and e-mail addresses, whether each is enabled and has a certificate | +| `add --first-name F --last-name L --email E` | Adds the user and issues the certificate; all three are required | +| `update [--first-name F] [--last-name L] [--email E]` | Changes the names or the e-mail address | | `passwd ` | Sets the password | | `disable `, `enable ` | Locks the user out of OpenVPN, and lets the user in again | -| `remove ` | Revokes the certificate, renews the CRL and removes the user | +| `remove ` | Takes the user out of its groups, revokes the certificate, renews the CRL and removes the user; refused while the user is the last member of a group | | `profile ` | 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, +password has 8 to 128 characters; a first or last name has 1 to 64 printable characters; an +e-mail address has one `@`, no spaces and a dot in its domain. In the answers a user added +without the names or the e-mail address has them as `null`. The exit status is 0 on success, 2 for an invalid request, 3 when the user does not exist, 4 when the user exists and 1 for any other failure. The certificate is also accepted by road-warrior (IKEv2 EAP-TLS) access, which checks certificates only, so `disable` does not affect it; `remove` revokes the certificate for both. +### Managing groups + +`vpn-router-group` manages the groups. A group has a name, with the rules of a user name, an +optional description of at most 256 printable characters, and at least one member, each a user +of the directory. Groups record membership only; nothing on the router reads them. The output, +`--json`, `-h` and `--help` work as for `vpn-router-user`. + +```sh +vpn-router-group add ops --member alice --member bob --description 'Operations team' +vpn-router-group list +vpn-router-group update ops --name netops +vpn-router-group update netops --description '' +vpn-router-group member-add netops carol +vpn-router-group member-remove netops bob +vpn-router-group remove netops +``` + +| Command | Effect | +| --- | --- | +| `list` | The groups with their members and descriptions | +| `add --member U [--member U ...] [--description D]` | Adds the group with its members | +| `update [--name N] [--description D]` | Renames the group or sets its description; `''` removes it | +| `remove ` | Removes the group; its members are kept | +| `member-add ` | Adds the user to the group | +| `member-remove ` | Takes the user out of the group; refused for the last member | + +The exit status is 0 on success, 2 for an invalid request, 3 when the group or the user does +not exist, 4 when the group exists and 1 for any other failure. + ## Platform modules Platform-specific values do not belong in the core, so they are Python modules in @@ -407,8 +448,8 @@ modules shipped with the package; the configuration file accepts any module inst ## Status web service The `vpn-router-web` service shows the state of the router on its internal address and -`web.port` (7081 by default). It uses only the Python standard library, serves HTTP without -authentication, and shows no configuration: no key, identity, address or subnet. +`web.port` (7081 by default). Its pages `/` and `/status` need no authentication and show no +configuration: no key, identity, address or subnet. | Page | Content | | --- | --- | @@ -416,6 +457,7 @@ authentication, and shows no configuration: no key, identity, address or subnet. | `/status` | The same status as JSON | | `/config` | The configuration without its secret options, and what the router resolved and runs, as JSON; needs a token | | `/users` | The users of OpenVPN and road-warrior access; needs a token | +| `/groups` | The groups of users; needs a token | The status holds the state of `vpn-router-setup` and `slapd`, of `strongswan` when site-to-site or road-warrior access is configured, of `wg-quick@wg0` when WireGuard is, and of @@ -471,13 +513,15 @@ curl -H "Authorization: Bearer $TOKEN" http://10.0.0.4:7081/config ### The /users API -`/users` manages users with the same token as `/config`, by running `vpn-router-user`. Request +`/users` manages users with the same token as `/config`, with the module `vpn-router-user` uses +too, `/usr/lib/vpn-router/vpnrouter_users.py`, so both apply the same rules. Request and answer bodies are JSON; a request body is at most 4096 bytes. | Request | Body | Answer | | --- | --- | --- | -| `GET /users` | | `{"users": [{"name", "enabled", "certificate"}, ...]}` | -| `POST /users` | `{"name": ..., "password": ...}` | 201 and the user | +| `GET /users` | | `{"users": [{"name", "enabled", "certificate", "first_name", "last_name", "email"}, ...]}` | +| `POST /users` | `{"name", "password", "first_name", "last_name", "email"}`, all required | 201 and the user | +| `PUT /users/` | any of `{"first_name", "last_name", "email"}` | the user | | `PUT /users//password` | `{"password": ...}` | the user | | `POST /users//disable` | | the user | | `POST /users//enable` | | the user | @@ -491,9 +535,28 @@ 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:7081/users + -d '{"name": "alice", "password": "s3cr3t-pass", "first_name": "Alice", "last_name": "Smith", "email": "alice@example.com"}' \ + http://10.0.0.4:7081/users ``` +### The /groups API + +`/groups` manages groups with the same token and the same module as `/users`. + +| Request | Body | Answer | +| --- | --- | --- | +| `GET /groups` | | `{"groups": [{"name", "description", "members": [...]}, ...]}` | +| `POST /groups` | `{"name", "members": [...]}`, and `"description"` | 201 and the group | +| `PUT /groups/` | any of `{"name", "description"}`; `""` removes the description | the group | +| `DELETE /groups/` | | `{"name", "removed": true}` | +| `POST /groups//members` | `{"user": ...}` | the group | +| `DELETE /groups//members/` | | the group | + +A failure is `{"error": ...}` with 400 for an invalid request, among them taking out the last +member, 404 for a group or user that does not exist, 409 for a group that exists and 500 +otherwise. `DELETE /users/` is refused with 400 while the user is the last member of a +group. + The service starts on its own, without waiting for `vpn-router-setup`, and keeps running. Every 30 seconds it works out the internal address from `vpn-router.json` as `general.mode` directs, and reads `web.port` and `web.enabled`; it listens there, moves when the address or the port diff --git a/debian-package/debian/changelog b/debian-package/debian/changelog index a5868dd..df97455 100644 --- a/debian-package/debian/changelog +++ b/debian-package/debian/changelog @@ -1,3 +1,28 @@ +vpn-router (0.5.7-1) unstable; urgency=medium + + feature: users have a first name, a last name and an e-mail address. + + * vpn-router-user add needs --first-name, --last-name and --email, kept as + givenName, sn and mail, with cn set to the first and the last name. + vpn-router-user update changes them, and list and every answer show + them as first_name, last_name and email. + * POST /users needs first_name, last_name and email, and PUT + /users/ changes them. + * vpn-router-user answers in text: a table of the users, the client + profile or a short message, with failures on standard error. --json + gives the JSON answers. -h and --help show the commands and their + options. + * The web service manages users with vpnrouter_users, the module + vpn-router-user uses, instead of running vpn-router-user for each + request. + * Groups of users, kept as groupOfNames entries in ou=groups with a + description and at least one member. vpn-router-group lists, adds, + renames, describes and removes them and adds and takes out members, and + the web service serves the same under /groups. Removing a user takes it + out of its groups and is refused while it is the last member of one. + + -- Slawomir Koszewski Sat, 24 Oct 2026 19:00:00 +0200 + vpn-router (0.5.6-1) unstable; urgency=medium update: the status web service listens on port 7081 by default. diff --git a/debian-package/debian/install b/debian-package/debian/install index b07a169..3b109ff 100644 --- a/debian-package/debian/install +++ b/debian-package/debian/install @@ -2,8 +2,10 @@ src/etc/sysctl.d/99-vpn-router.conf etc/sysctl.d/ src/usr/lib/vpn-router/vpnrouter.py usr/lib/vpn-router/ src/usr/lib/vpn-router/vpnrouter_ldap.py usr/lib/vpn-router/ src/usr/lib/vpn-router/vpnrouter_net.py usr/lib/vpn-router/ +src/usr/lib/vpn-router/vpnrouter_users.py usr/lib/vpn-router/ src/usr/lib/vpn-router/openvpn-same-user usr/lib/vpn-router/ src/usr/sbin/vpn-router-user usr/sbin/ +src/usr/sbin/vpn-router-group usr/sbin/ src/usr/lib/vpn-router/simple-ca usr/lib/vpn-router/ src/usr/lib/vpn-router/configure usr/lib/vpn-router/ src/usr/lib/vpn-router/docker-rules usr/lib/vpn-router/ diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py index 4ede478..46f400d 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py @@ -3,8 +3,8 @@ 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. +administrator password. Users and groups are changed by the user-manager service account, +which the ACLs allow to write ou=users and ou=groups and to set, never read, passwords. """ import os @@ -20,6 +20,7 @@ import ldap.sasl URI = 'ldapi:///' BASE = 'dc=vpn-router,dc=local' USERS = f'ou=users,{BASE}' +GROUPS = f'ou=groups,{BASE}' SERVICES = f'ou=services,{BASE}' POLICIES = f'ou=policies,{BASE}' DEFAULT_POLICY = f'cn=default,{POLICIES}' @@ -39,6 +40,7 @@ PASSWORD_HASH = '{ARGON2}' ACCESS = ( f'to attrs=userPassword by dn.exact="{MANAGER}" =wx by self =wx by anonymous auth by * none', f'to dn.subtree="{USERS}" by dn.exact="{MANAGER}" manage by * read', + f'to dn.subtree="{GROUPS}" by dn.exact="{MANAGER}" manage by * read', 'to * by * read', ) @@ -168,7 +170,7 @@ def provision(): 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): + for unit in (USERS, GROUPS, SERVICES, POLICIES): _ensure_entry(connection, unit, {'objectClass': [b'organizationalUnit'], 'ou': [unit.split(',')[0][3:].encode()]}) _ensure_entry(connection, DEFAULT_POLICY, { 'objectClass': [b'organizationalRole', b'pwdPolicy'], diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py new file mode 100644 index 0000000..7c6d689 --- /dev/null +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py @@ -0,0 +1,410 @@ +"""The users and groups of the router's directory, for vpn-router-user, vpn-router-group and the web service. + +A user is an entry in the router's directory, with a first name, a last name and an e-mail +address, whose password OpenVPN checks, and a client certificate of the router CA with the +user name as its common name, which OpenVPN and IKEv2 accept. Users exist whether or not a +P2S system is enabled. + +A group is a groupOfNames in ou=groups with a name, which follows the rules of user names, an +optional description and at least one member; a group cannot be empty, so the last member +cannot leave it and a user who is the last member of a group cannot be removed. Groups only +record membership; nothing on the router reads them. + +Every operation runs in a session(), which holds the lock that serializes changes and the +connection of the user manager account. A request that cannot be done raises Failure, whose +status says why: 2 the request is invalid, 3 the user does not exist, 4 the user exists, 1 +anything else. Answers are dicts with the keys of the JSON answers. +""" + +import contextlib +import fcntl +import pathlib +import re +import subprocess +import sys + +import ldap +import ldap.filter +import ldap.modlist + +import vpnrouter +import vpnrouter_ldap + +#: A user name is one lowercase DNS label, as it is also the common name of the certificate. +NAME = re.compile(r'^[a-z][a-z0-9-]{0,30}[a-z0-9]$|^[a-z]$') +PASSWORD_MIN = 8 +PASSWORD_MAX = 128 +#: The longest first or last name, in characters. +PERSON_NAME_MAX = 64 +#: An e-mail address: one @, no spaces, and a dot in the domain. +EMAIL = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$') +EMAIL_MAX = 254 +#: The keys of the person attributes and their LDAP attributes. +PERSON = (('first_name', 'givenName'), ('last_name', 'sn'), ('email', 'mail')) +#: The attributes read for a user. +ATTRIBUTES = ['uid', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail'] +#: The attributes read for a group. +GROUP_ATTRIBUTES = ['cn', 'description', 'member'] +#: The longest group description, in characters. +DESCRIPTION_MAX = 256 +#: The value of pwdAccountLockedTime that locks an account until it is unlocked. +LOCKED = b'000001010000Z' +LOCK_FILE = pathlib.Path('/run/vpn-router/users.lock') + + +class Failure(Exception): + """A request that cannot be done; status is 2 invalid, 3 no such user, 4 the user exists, 1 other.""" + + def __init__(self, message, status=1): + super().__init__(message) + self.status = status + + +@contextlib.contextmanager +def session(): + """The connection of the user manager account, used under the lock that serializes changes.""" + LOCK_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(LOCK_FILE, 'w') as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + connection = vpnrouter_ldap.manager() + try: + yield connection + finally: + connection.unbind_s() + + +def check_name(name): + if not isinstance(name, str) or not NAME.match(name): + raise Failure('a user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter', 2) + + +def check_password(value): + if not isinstance(value, str) or '\n' in value or '\r' in value: + raise Failure('the password must be a string of one line', 2) + if not PASSWORD_MIN <= len(value) <= PASSWORD_MAX: + raise Failure(f'the password must have {PASSWORD_MIN} to {PASSWORD_MAX} characters', 2) + + +def person(values, required): + """The person attributes of values ({first_name, last_name, email}), checked, as {LDAP attribute: value}. + + A key whose value is None is not given; with required, all three must be given. + """ + found = {} + for key, attribute in PERSON: + value = values.get(key) + if value is None: + if required: + raise Failure(f'{key} is required', 2) + continue + if not isinstance(value, str): + raise Failure(f'{key} must be a string', 2) + value = value.strip() + if key == 'email': + if len(value) > EMAIL_MAX or not EMAIL.match(value): + raise Failure(f'{value!r} is not an e-mail address', 2) + elif not value or len(value) > PERSON_NAME_MAX or any(not character.isprintable() for character in value): + raise Failure(f'the {key.replace("_", " ")} must have 1 to {PERSON_NAME_MAX} printable characters', 2) + found[attribute] = value + return found + + +def dn_of(name): + return f'uid={name},{vpnrouter_ldap.USERS}' + + +def certificate(name): + return vpnrouter.USER_CERT_DIR / f'{name}_cert.pem', vpnrouter.USER_CERT_DIR / f'{name}_key.pem' + + +def entry(connection, name): + """The attributes of the user's entry; Failure with status 3 when there is none.""" + check_name(name) + try: + return connection.search_s(dn_of(name), ldap.SCOPE_BASE, attrlist=ATTRIBUTES)[0][1] + except ldap.NO_SUCH_OBJECT: + raise Failure(f'user {name} does not exist', 3) + + +def simple_ca(*args): + result = subprocess.run([sys.executable, str(vpnrouter.SIMPLE_CA), *args], capture_output=True, text=True) + if result.returncode != 0: + raise Failure(f'simple-ca {args[0]} failed: {result.stderr.strip() or result.stdout.strip()}') + + +def publish_crl(): + """Give strongSwan the CRL after a revocation; OpenVPN reads it at every connection.""" + if vpnrouter.install_file(vpnrouter.PKI_DIR / 'crl.pem', vpnrouter.CRL_DEST, 0o644): + subprocess.run(['swanctl', '--load-creds'], capture_output=True, text=True) + + +def summary(attributes): + """A user as the answers show it; a person attribute the entry does not have is None.""" + name = attributes['uid'][0].decode() + user = { + 'name': name, + 'enabled': 'pwdAccountLockedTime' not in attributes, + 'certificate': certificate(name)[0].is_file(), + } + for key, attribute in PERSON: + values = attributes.get(attribute) + user[key] = values[0].decode() if values else None + return user + + +def common_name(attributes, name): + """The cn of an entry: the first and the last name, or the user name without them.""" + full = ' '.join(value for value in (attributes.get('givenName'), attributes.get('sn')) if value) + return full or name + + +def list_users(connection): + found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL, '(objectClass=inetOrgPerson)', ATTRIBUTES) + return {'users': sorted((summary(attributes) for _, attributes in found), key=lambda user: user['name'])} + + +def add(connection, name, password, values): + """Add a user with the person attributes of values, all three required, and issue the certificate.""" + check_name(name) + details = person(values, required=True) + check_password(password) + if not (vpnrouter.PKI_DIR / 'ca_key.pem').is_file(): + raise Failure('the router CA does not exist; vpn-router-setup creates it once a platform is chosen') + record = {'objectClass': [b'inetOrgPerson'], 'uid': [name.encode()], + 'cn': [common_name(details, name).encode()]} + record.update({attribute: [value.encode()] for attribute, value in details.items()}) + try: + connection.add_s(dn_of(name), ldap.modlist.addModlist(record)) + except ldap.ALREADY_EXISTS: + raise Failure(f'user {name} exists', 4) + connection.passwd_s(dn_of(name), None, password) + vpnrouter.USER_CERT_DIR.mkdir(mode=0o700, parents=True, exist_ok=True) + for path in certificate(name): + path.unlink(missing_ok=True) + simple_ca('make-cert', '--ca-dir', str(vpnrouter.PKI_DIR), '--cert-dir', str(vpnrouter.USER_CERT_DIR), name) + return summary(entry(connection, name)) + + +def update(connection, name, values): + """Change the person attributes values gives; the cn follows the first and the last name.""" + details = person(values, required=False) + if not details: + raise Failure('give at least one of first_name, last_name and email', 2) + current = entry(connection, name) + names = {attribute: current[attribute][0].decode() for attribute in ('givenName', 'sn') if current.get(attribute)} + names.update({attribute: details[attribute] for attribute in ('givenName', 'sn') if attribute in details}) + changes = [(ldap.MOD_REPLACE, attribute, [value.encode()]) for attribute, value in details.items()] + changes.append((ldap.MOD_REPLACE, 'cn', [common_name(names, name).encode()])) + connection.modify_s(dn_of(name), changes) + return summary(entry(connection, name)) + + +def passwd(connection, name, password): + check_password(password) + entry(connection, name) + connection.passwd_s(dn_of(name), None, password) + return summary(entry(connection, name)) + + +def disable(connection, name): + if 'pwdAccountLockedTime' not in entry(connection, name): + connection.modify_s(dn_of(name), [(ldap.MOD_REPLACE, 'pwdAccountLockedTime', [LOCKED])]) + return summary(entry(connection, name)) + + +def enable(connection, name): + if 'pwdAccountLockedTime' in entry(connection, name): + connection.modify_s(dn_of(name), [(ldap.MOD_DELETE, 'pwdAccountLockedTime', None)]) + return summary(entry(connection, name)) + + +def remove(connection, name): + """Remove the user from its groups and the directory, and revoke the certificate. + + A user who is the last member of a group is not removed. + """ + entry(connection, name) + groups = groups_of(connection, name) + alone = sorted(group for group, members in groups.items() if members == [name]) + if alone: + raise Failure(f'user {name} is the last member of {", ".join(alone)}; remove the group first', 2) + for group in groups: + connection.modify_s(group_dn(group), [(ldap.MOD_DELETE, 'member', [dn_of(name).encode()])]) + cert, key = certificate(name) + if cert.is_file(): + simple_ca('revoke-cert', '--ca-dir', str(vpnrouter.PKI_DIR), str(cert)) + simple_ca('make-crl', '--ca-dir', str(vpnrouter.PKI_DIR), '--days', str(vpnrouter.CRL_DAYS)) + publish_crl() + cert.unlink(missing_ok=True) + key.unlink(missing_ok=True) + connection.delete_s(dn_of(name)) + return {'name': name, 'removed': True} + + +def profile(connection, name): + """The OpenVPN client profile of the user, with the CA, the certificate and the key inline.""" + entry(connection, name) + cfg = vpnrouter.load_config() + if not cfg['openvpn']['enabled']: + raise Failure('OpenVPN is not enabled', 2) + cert, key = certificate(name) + if not cert.is_file() or not key.is_file(): + raise Failure(f'user {name} has no certificate') + fqdn = cfg['wan']['local_fqdn'].strip() + text = '\n'.join([ + 'client', + 'dev tun', + f'proto {cfg["openvpn"]["protocol"]}', + f'remote {fqdn} {cfg["openvpn"]["port"]}', + 'nobind', + 'persist-key', + 'persist-tun', + 'remote-cert-tls server', + f'verify-x509-name {fqdn} name', + 'auth-user-pass', + 'auth-nocache', + 'verb 3', + '', extract_pem(vpnrouter.CA_CERT_DEST.read_text(encoding='utf-8')), '', + '', extract_pem(cert.read_text(encoding='utf-8')), '', + '', key.read_text(encoding='utf-8').strip(), '', + '', + ]) + return {'name': name, 'profile': text} + + +def group_dn(name): + return f'cn={name},{vpnrouter_ldap.GROUPS}' + + +def check_group_name(name): + if not isinstance(name, str) or not NAME.match(name): + raise Failure('a group name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter', 2) + + +def check_description(value): + """The description, stripped; '' means none.""" + if not isinstance(value, str): + raise Failure('the description must be a string', 2) + value = value.strip() + if len(value) > DESCRIPTION_MAX or any(not character.isprintable() for character in value): + raise Failure(f'the description must have at most {DESCRIPTION_MAX} printable characters', 2) + return value + + +def member_names(attributes): + """The user names of a group's member DNs, sorted.""" + names = [] + for value in attributes.get('member', []): + dn = value.decode() + if dn.startswith('uid=') and dn.endswith(f',{vpnrouter_ldap.USERS}'): + names.append(dn[4:-len(f',{vpnrouter_ldap.USERS}')]) + return sorted(names) + + +def group_summary(attributes): + """A group as the answers show it.""" + values = attributes.get('description') + return { + 'name': attributes['cn'][0].decode(), + 'description': values[0].decode() if values else '', + 'members': member_names(attributes), + } + + +def group_entry(connection, name): + """The attributes of the group's entry; Failure with status 3 when there is none.""" + check_group_name(name) + try: + return connection.search_s(group_dn(name), ldap.SCOPE_BASE, attrlist=GROUP_ATTRIBUTES)[0][1] + except ldap.NO_SUCH_OBJECT: + raise Failure(f'group {name} does not exist', 3) + + +def groups_of(connection, name): + """The groups the user is a member of, as {group name: its member names}.""" + found = connection.search_s(vpnrouter_ldap.GROUPS, ldap.SCOPE_ONELEVEL, + f'(&(objectClass=groupOfNames)(member={ldap.filter.escape_filter_chars(dn_of(name))}))', + GROUP_ATTRIBUTES) + return {attributes['cn'][0].decode(): member_names(attributes) for _, attributes in found} + + +def list_groups(connection): + found = connection.search_s(vpnrouter_ldap.GROUPS, ldap.SCOPE_ONELEVEL, '(objectClass=groupOfNames)', GROUP_ATTRIBUTES) + return {'groups': sorted((group_summary(attributes) for _, attributes in found), key=lambda group: group['name'])} + + +def group_add(connection, name, description, members): + """Add a group with a description ('' for none) and at least one member, each an existing user.""" + check_group_name(name) + description = check_description(description if description is not None else '') + if not isinstance(members, list) or not members: + raise Failure('a group needs at least one member', 2) + for member in members: + entry(connection, member) + record = {'objectClass': [b'groupOfNames'], 'cn': [name.encode()], + 'member': [dn_of(member).encode() for member in sorted(set(members))]} + if description: + record['description'] = [description.encode()] + try: + connection.add_s(group_dn(name), ldap.modlist.addModlist(record)) + except ldap.ALREADY_EXISTS: + raise Failure(f'group {name} exists', 4) + return group_summary(group_entry(connection, name)) + + +def group_update(connection, name, new_name=None, description=None): + """Rename the group or change its description ('' removes it); at least one must be given.""" + if new_name is None and description is None: + raise Failure('give a new name or a description', 2) + group_entry(connection, name) + if description is not None: + description = check_description(description) + change = (ldap.MOD_REPLACE, 'description', [description.encode()]) if description else \ + (ldap.MOD_REPLACE, 'description', []) + connection.modify_s(group_dn(name), [change]) + if new_name is not None and new_name != name: + check_group_name(new_name) + try: + connection.rename_s(group_dn(name), f'cn={new_name}', delold=1) + except ldap.ALREADY_EXISTS: + raise Failure(f'group {new_name} exists', 4) + name = new_name + return group_summary(group_entry(connection, name)) + + +def group_remove(connection, name): + group_entry(connection, name) + connection.delete_s(group_dn(name)) + return {'name': name, 'removed': True} + + +def group_member_add(connection, name, user): + attributes = group_entry(connection, name) + entry(connection, user) + if user not in member_names(attributes): + connection.modify_s(group_dn(name), [(ldap.MOD_ADD, 'member', [dn_of(user).encode()])]) + return group_summary(group_entry(connection, name)) + + +def group_member_remove(connection, name, user): + """Take the user out of the group; the last member cannot leave.""" + check_name(user) + members = member_names(group_entry(connection, name)) + if user not in members: + raise Failure(f'user {user} is not a member of group {name}', 3) + if members == [user]: + raise Failure(f'user {user} is the last member of group {name}; remove the group instead', 2) + connection.modify_s(group_dn(name), [(ldap.MOD_DELETE, 'member', [dn_of(user).encode()])]) + return group_summary(group_entry(connection, name)) + + +def extract_pem(text): + """The PEM blocks of the certificates in a file that also holds their text form.""" + begin, end = '-----BEGIN CERTIFICATE-----', '-----END CERTIFICATE-----' + blocks = [] + start = text.find(begin) + while start != -1: + stop = text.index(end, start) + len(end) + blocks.append(text[start:stop]) + start = text.find(begin, stop) + return '\n'.join(blocks) diff --git a/debian-package/src/usr/lib/vpn-router/web b/debian-package/src/usr/lib/vpn-router/web index b186a11..d18bff5 100644 --- a/debian-package/src/usr/lib/vpn-router/web +++ b/debian-package/src/usr/lib/vpn-router/web @@ -1,8 +1,8 @@ #!/usr/bin/python3 """Serve the status of the router on its internal address. -Run by vpn-router-web.service. It needs nothing but the Python standard library; users are -managed by running vpn-router-user. +Run by vpn-router-web.service. Users are managed with vpnrouter_users, the module that +vpn-router-user uses too. Pages: / the status for people: plain text for curl and wget, a styled page @@ -10,10 +10,13 @@ Pages: /status the status as JSON /config the configuration without its secrets, and what the router resolved and runs, as JSON - /users the users in the router's directory, whether or not a P2S system is enabled: list, add, set the password, disable, enable, - remove, and the OpenVPN client profile (see users()) + /users the users in the router's directory, whether or not a P2S system is enabled: list, + add, change, set the password, disable, enable, remove, and the OpenVPN client + profile (see users()) + /groups the groups of the router's directory: list, add, rename or describe, remove, and + add and take out members (see groups()) -/config and /users need an HS256 JWT in an Authorization: Bearer header that is signed +/config, /users and /groups need an HS256 JWT in an Authorization: Bearer header that is signed with web.jwt_secret and expires within ten minutes; they are not served (404) while web.jwt_secret is not set. @@ -46,7 +49,10 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) +import ldap + import vpnrouter_net +import vpnrouter_users STATE_FILE = '/run/vpn-router/web.json' CONFIG_FILE = '/etc/vpn-router/vpn-router.json' @@ -70,17 +76,13 @@ 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} +#: The HTTP status for each status of a vpnrouter_users.Failure; any other is 500. +USER_STATUS = {2: 400, 3: 404, 4: 409} _cache = {'at': 0.0, 'data': None} _lock = threading.Lock() @@ -409,24 +411,12 @@ 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. + """Answer a /users request with vpnrouter_users: the HTTP status and the JSON document. GET /users the users - POST /users add a user: {"name": ..., "password": ...} + POST /users add a user: {"name", "password", "first_name", "last_name", "email"} + PUT /users/ change any of {"first_name", "last_name", "email"} PUT /users//password set the password: {"password": ...} POST /users//disable lock the user out of OpenVPN POST /users//enable let the user in again @@ -436,31 +426,70 @@ def users(state, method, path, body): 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}'} + name = parts[1] if len(parts) > 1 else None + actions = { + ('GET', 1, None): lambda connection: vpnrouter_users.list_users(connection), + ('POST', 1, None): lambda connection: vpnrouter_users.add(connection, body.get('name'), body.get('password'), body), + ('PUT', 2, None): lambda connection: vpnrouter_users.update(connection, name, body), + ('PUT', 3, 'password'): lambda connection: vpnrouter_users.passwd(connection, name, body.get('password')), + ('POST', 3, 'disable'): lambda connection: vpnrouter_users.disable(connection, name), + ('POST', 3, 'enable'): lambda connection: vpnrouter_users.enable(connection, name), + ('DELETE', 2, None): lambda connection: vpnrouter_users.remove(connection, name), + ('GET', 3, 'profile'): lambda connection: vpnrouter_users.profile(connection, name), + } + if route not in actions: + return 404, {'error': f'no route {method} {path}'} + try: + with vpnrouter_users.session() as connection: + document = actions[route](connection) + except vpnrouter_users.Failure as failure: + return USER_STATUS.get(failure.status, 500), {'error': str(failure)} + except (ldap.LDAPError, OSError) as exc: + return 500, {'error': f'the user directory failed: {exc}'} + except SystemExit: + # vpnrouter ends with SystemExit on a configuration it cannot read; it has printed the reason. + return 500, {'error': 'the router configuration cannot be read'} + return (201 if route == ('POST', 1, None) else 200), document + + +def groups(state, method, path, body): + """Answer a /groups request with vpnrouter_users: the HTTP status and the JSON document. + + GET /groups the groups with their members + POST /groups add a group: {"name", "members": [...], "description"?} + PUT /groups/ rename it or change the description: {"name"?, "description"?} + DELETE /groups/ remove the group; its members are kept + POST /groups//members add a member: {"user": ...} + DELETE /groups//members/ take a member out; the last member cannot leave + """ + if body == 'invalid' or (body is not None and not isinstance(body, dict)): + return 400, {'error': 'the body must be a JSON object'} + body = body or {} + parts = path.strip('/').split('/') + name = parts[1] if len(parts) > 1 else None + route = (method, len(parts), parts[2] if len(parts) > 2 else None) + actions = { + ('GET', 1, None): lambda connection: vpnrouter_users.list_groups(connection), + ('POST', 1, None): lambda connection: vpnrouter_users.group_add( + connection, body.get('name'), body.get('description'), body.get('members')), + ('PUT', 2, None): lambda connection: vpnrouter_users.group_update( + connection, name, body.get('name'), body.get('description')), + ('DELETE', 2, None): lambda connection: vpnrouter_users.group_remove(connection, name), + ('POST', 3, 'members'): lambda connection: vpnrouter_users.group_member_add(connection, name, body.get('user')), + ('DELETE', 4, 'members'): lambda connection: vpnrouter_users.group_member_remove(connection, name, parts[3]), + } + if route not in actions: + return 404, {'error': f'no route {method} {path}'} + try: + with vpnrouter_users.session() as connection: + document = actions[route](connection) + except vpnrouter_users.Failure as failure: + return USER_STATUS.get(failure.status, 500), {'error': str(failure)} + except (ldap.LDAPError, OSError) as exc: + return 500, {'error': f'the user directory failed: {exc}'} + return (201 if route == ('POST', 1, None) else 200), document class Handler(BaseHTTPRequestHandler): @@ -484,7 +513,8 @@ class Handler(BaseHTTPRequestHandler): # 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/') + directory = next((name for name in ('users', 'groups') if path == f'/{name}' or path.startswith(f'/{name}/')), None) + protected = path == '/config' or directory is not None state = current() if protected else {} if protected and not state['jwt_secret']: body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404 @@ -494,7 +524,7 @@ class Handler(BaseHTTPRequestHandler): elif path == '/config' and self.command in ('GET', 'HEAD'): body, kind, code = json.dumps(details(state), indent=2).encode() + b'\n', 'application/json', 200 elif protected and path != '/config': - code, document = users(state, self.command, path, self.request_body()) + code, document = (users if directory == 'users' else groups)(state, self.command, path, self.request_body()) body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json' elif self.command not in ('GET', 'HEAD'): body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405 diff --git a/debian-package/src/usr/sbin/vpn-router-group b/debian-package/src/usr/sbin/vpn-router-group new file mode 100755 index 0000000..02164f2 --- /dev/null +++ b/debian-package/src/usr/sbin/vpn-router-group @@ -0,0 +1,120 @@ +#!/usr/bin/python3 +"""Manage the groups of the router's directory from the command line, with vpnrouter_users. + +A group has a name, an optional description and at least one member, each a user of the +directory. The answer is text for people on standard output, and a failure is a message on +standard error. With --json the answer is JSON on standard output, and a failure is +{"error": ...} there too. The exit status says what failed: 2 the request is invalid, 3 the +group or the user does not exist, 4 the group exists, 1 anything else. -h or --help shows the +commands and their options. + + vpn-router-group [--json] list + vpn-router-group [--json] add --member U [--member U ...] [--description D] + vpn-router-group [--json] update [--name N] [--description D] + vpn-router-group [--json] remove + vpn-router-group [--json] member-add + vpn-router-group [--json] member-remove +""" + +import argparse +import json +import sys + +sys.path.insert(0, '/usr/lib/vpn-router') + +import ldap + +import vpnrouter_users +from vpnrouter_users import Failure + +#: The help line of each command. +HELP = { + 'list': 'list the groups with their members', + 'add': 'add a group with at least one member', + 'update': 'rename the group or change its description', + 'remove': 'remove the group; its members are kept', + 'member-add': 'add a user to the group', + 'member-remove': 'take a user out of the group; the last member cannot leave', +} + + +class Parser(argparse.ArgumentParser): + """An argument parser whose errors are Failures with status 2, so that --json answers them as JSON.""" + + def error(self, message): + raise Failure(f'{message}; see {self.prog} --help', 2) + + +def parse(argv): + parser = Parser(prog='vpn-router-group', description='Manage the groups of the router.') + parser.add_argument('--json', action='store_true', help='answer in JSON') + commands = parser.add_subparsers(dest='command', required=True, metavar='command', parser_class=Parser) + for command, text in HELP.items(): + sub = commands.add_parser(command, help=text, description=text[0].upper() + text[1:] + '.') + if command != 'list': + sub.add_argument('name', help='the group name') + if command == 'add': + sub.add_argument('--member', dest='members', action='append', required=True, metavar='USER', + help='a member; give it once for each member') + sub.add_argument('--description', metavar='TEXT', help='the description') + if command == 'update': + sub.add_argument('--name', dest='new_name', metavar='NAME', help='the new group name') + sub.add_argument('--description', metavar='TEXT', help="the description; '' removes it") + if command in ('member-add', 'member-remove'): + sub.add_argument('user', help='the user name') + return parser.parse_args(argv) + + +def run(connection, options): + """The answer of the command the options name.""" + command = options.command + if command == 'list': + return vpnrouter_users.list_groups(connection) + if command == 'add': + return vpnrouter_users.group_add(connection, options.name, options.description, options.members) + if command == 'update': + return vpnrouter_users.group_update(connection, options.name, options.new_name, options.description) + if command == 'remove': + return vpnrouter_users.group_remove(connection, options.name) + if command == 'member-add': + return vpnrouter_users.group_member_add(connection, options.name, options.user) + return vpnrouter_users.group_member_remove(connection, options.name, options.user) + + +def text_of(answer): + """The answer as text for people.""" + if answer.get('removed'): + return f'group {answer["name"]} removed' + groups = answer['groups'] if 'groups' in answer else [answer] + if not groups: + return 'no groups' + head = ('GROUP', 'MEMBERS', 'DESCRIPTION') + rows = [(group['name'], ', '.join(group['members']), group['description'] or '-') for group in groups] + widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))] + return '\n'.join(' '.join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip() for row in (head, *rows)) + + +def main(argv): + # A request that cannot be parsed is answered in JSON when --json is among the arguments. + as_json = '--json' in argv + try: + options = parse(argv) + as_json = options.json + with vpnrouter_users.session() as connection: + answer = run(connection, options) + except Failure as failure: + message, status = str(failure), failure.status + except (ldap.LDAPError, OSError) as exc: + message, status = f'the user directory failed: {exc}', 1 + else: + print(json.dumps(answer) if as_json else text_of(answer)) + return 0 + if as_json: + print(json.dumps({'error': message})) + else: + print(f'vpn-router-group: {message}', file=sys.stderr) + return status + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:])) diff --git a/debian-package/src/usr/sbin/vpn-router-user b/debian-package/src/usr/sbin/vpn-router-user index 36c31af..396a19b 100755 --- a/debian-package/src/usr/sbin/vpn-router-user +++ b/debian-package/src/usr/sbin/vpn-router-user @@ -1,224 +1,130 @@ #!/usr/bin/python3 -"""Manage the users of the router's directory, whether or not a P2S system is enabled. +"""Manage the users of the router's directory from the command line, with vpnrouter_users. -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. +Passwords are read from the first line of standard input, never from the command line. The +answer is text for people on standard output, and a failure is a message on standard error. +With --json the answer is JSON on standard output, and a failure is {"error": ...} there too. +The exit status says what failed: 2 the request is invalid, 3 the user does not exist, 4 the +user exists, 1 anything else. -h or --help shows the commands and their options. - vpn-router-user list - vpn-router-user add (password on stdin) - vpn-router-user passwd (password on stdin) - vpn-router-user disable - vpn-router-user enable - vpn-router-user remove - vpn-router-user profile + vpn-router-user [--json] list + vpn-router-user [--json] add --first-name F --last-name L --email E (password on stdin) + vpn-router-user [--json] update [--first-name F] [--last-name L] [--email E] + vpn-router-user [--json] passwd (password on stdin) + vpn-router-user [--json] disable + vpn-router-user [--json] enable + vpn-router-user [--json] remove + vpn-router-user [--json] profile """ -import fcntl +import argparse 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 +import vpnrouter_users +from vpnrouter_users import Failure -#: 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') +#: The help line of each command. +HELP = { + 'list': 'list the users', + 'add': 'add a user and issue the certificate; the password is read from standard input', + 'update': 'change the first name, the last name or the e-mail address', + 'passwd': 'set the password, read from standard input', + 'disable': 'lock the user out of OpenVPN', + 'enable': 'let the user in again', + 'remove': 'revoke the certificate and remove the user', + 'profile': 'print the OpenVPN client profile', +} + +#: The help of the person options. +PERSON_HELP = {'first_name': 'the first name', 'last_name': 'the last name', 'email': 'the e-mail address'} -class Failure(Exception): - """A request that cannot be done; status is the exit status.""" +class Parser(argparse.ArgumentParser): + """An argument parser whose errors are Failures with status 2, so that --json answers them as JSON.""" - def __init__(self, message, status=1): - super().__init__(message) - self.status = status + def error(self, message): + raise Failure(f'{message}; see {self.prog} --help', 2) -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 parse(argv): + parser = Parser(prog='vpn-router-user', description='Manage the users of the router.') + parser.add_argument('--json', action='store_true', help='answer in JSON') + commands = parser.add_subparsers(dest='command', required=True, metavar='command', parser_class=Parser) + for command, text in HELP.items(): + sub = commands.add_parser(command, help=text, description=text[0].upper() + text[1:] + '.') + if command != 'list': + sub.add_argument('name', help='the user name') + if command in ('add', 'update'): + for key, _ in vpnrouter_users.PERSON: + sub.add_argument(f'--{key.replace("_", "-")}', dest=key, metavar='TEXT', required=command == 'add', + help=PERSON_HELP[key]) + return parser.parse_args(argv) def password(): """The password from the first line of standard input.""" - 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 + return sys.stdin.readline().rstrip('\n') -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 run(connection, options, secret): + """The answer of the command the options name; secret is the password of add and passwd.""" + command = options.command + if command == 'list': + return vpnrouter_users.list_users(connection) + values = {key: getattr(options, key, None) for key, _ in vpnrouter_users.PERSON} + if command == 'add': + return vpnrouter_users.add(connection, options.name, secret, values) + if command == 'update': + return vpnrouter_users.update(connection, options.name, values) + if command == 'passwd': + return vpnrouter_users.passwd(connection, options.name, secret) + return getattr(vpnrouter_users, command)(connection, options.name) -def publish_crl(): - """Give strongSwan the CRL after a revocation; OpenVPN reads it at every connection.""" - if vpnrouter.install_file(vpnrouter.PKI_DIR / 'crl.pem', vpnrouter.CRL_DEST, 0o644): - subprocess.run(['swanctl', '--load-creds'], capture_output=True, text=True) - - -def summary(attributes): - name = attributes['uid'][0].decode() - return { - 'name': name, - 'enabled': 'pwdAccountLockedTime' not in attributes, - 'certificate': certificate(name)[0].is_file(), - } - - -def list_users(connection): - found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL, '(objectClass=inetOrgPerson)', - ['uid', 'pwdAccountLockedTime']) - return {'users': sorted((summary(attributes) for _, attributes in found), key=lambda user: user['name'])} - - -def add(connection, name): - secret = password() - if not (vpnrouter.PKI_DIR / 'ca_key.pem').is_file(): - raise Failure('the router CA does not exist; vpn-router-setup creates it once a platform is chosen') - try: - connection.add_s(dn_of(name), ldap.modlist.addModlist({ - 'objectClass': [b'inetOrgPerson'], 'uid': [name.encode()], 'cn': [name.encode()], 'sn': [name.encode()], - })) - except ldap.ALREADY_EXISTS: - raise Failure(f'user {name} exists', 4) - connection.passwd_s(dn_of(name), None, secret) - vpnrouter.USER_CERT_DIR.mkdir(mode=0o700, parents=True, exist_ok=True) - for path in certificate(name): - path.unlink(missing_ok=True) - simple_ca('make-cert', '--ca-dir', str(vpnrouter.PKI_DIR), '--cert-dir', str(vpnrouter.USER_CERT_DIR), name) - return summary(entry(connection, name)) - - -def passwd(connection, name): - secret = password() - entry(connection, name) - connection.passwd_s(dn_of(name), None, secret) - return summary(entry(connection, name)) - - -def disable(connection, name): - if 'pwdAccountLockedTime' not in entry(connection, name): - connection.modify_s(dn_of(name), [(ldap.MOD_REPLACE, 'pwdAccountLockedTime', [LOCKED])]) - return summary(entry(connection, name)) - - -def enable(connection, name): - if 'pwdAccountLockedTime' in entry(connection, name): - connection.modify_s(dn_of(name), [(ldap.MOD_DELETE, 'pwdAccountLockedTime', None)]) - return summary(entry(connection, name)) - - -def remove(connection, name): - entry(connection, name) - cert, key = certificate(name) - if cert.is_file(): - simple_ca('revoke-cert', '--ca-dir', str(vpnrouter.PKI_DIR), str(cert)) - simple_ca('make-crl', '--ca-dir', str(vpnrouter.PKI_DIR), '--days', str(vpnrouter.CRL_DAYS)) - publish_crl() - cert.unlink(missing_ok=True) - key.unlink(missing_ok=True) - connection.delete_s(dn_of(name)) - return {'name': name, 'removed': True} - - -def profile(connection, name): - """The OpenVPN client profile of the user, with the CA, the certificate and the key inline.""" - entry(connection, name) - cfg = vpnrouter.load_config() - if not cfg['openvpn']['enabled']: - raise Failure('OpenVPN is not enabled', 2) - cert, key = certificate(name) - if not cert.is_file() or not key.is_file(): - raise Failure(f'user {name} has no certificate') - fqdn = cfg['wan']['local_fqdn'].strip() - text = '\n'.join([ - 'client', - 'dev tun', - f'proto {cfg["openvpn"]["protocol"]}', - f'remote {fqdn} {cfg["openvpn"]["port"]}', - 'nobind', - 'persist-key', - 'persist-tun', - 'remote-cert-tls server', - f'verify-x509-name {fqdn} name', - 'auth-user-pass', - 'auth-nocache', - 'verb 3', - '', extract_pem(vpnrouter.CA_CERT_DEST.read_text(encoding='utf-8')), '', - '', extract_pem(cert.read_text(encoding='utf-8')), '', - '', key.read_text(encoding='utf-8').strip(), '', - '', - ]) - 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 text_of(answer): + """The answer as text for people.""" + if 'profile' in answer: + return answer['profile'].rstrip('\n') + if answer.get('removed'): + return f'user {answer["name"]} removed' + users = answer['users'] if 'users' in answer else [answer] + if not users: + return 'no users' + head = ('USER', 'STATE', 'CERTIFICATE', 'FIRST NAME', 'LAST NAME', 'E-MAIL') + rows = [(user['name'], 'enabled' if user['enabled'] else 'disabled', 'issued' if user['certificate'] else 'missing', + user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-') for user in users] + widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))] + return '\n'.join(' '.join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip() for row in (head, *rows)) def main(argv): + # A request that cannot be parsed is answered in JSON when --json is among the arguments. + as_json = '--json' in argv try: - 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 ', 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() + options = parse(argv) + as_json = options.json + if options.command != 'list': + vpnrouter_users.check_name(options.name) + secret = password() if options.command in ('add', 'passwd') else None + with vpnrouter_users.session() as connection: + answer = run(connection, options, secret) except Failure as failure: - print(json.dumps({'error': str(failure)})) - return failure.status + message, status = str(failure), 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 + message, status = f'the user directory failed: {exc}', 1 + else: + print(json.dumps(answer) if as_json else text_of(answer)) + return 0 + if as_json: + print(json.dumps({'error': message})) + else: + print(f'vpn-router-user: {message}', file=sys.stderr) + return status if __name__ == '__main__':