feature: users have a first name, a last name and an e-mail address.
Release / release (push) Successful in 11s
Release / release (push) Successful in 11s
This commit is contained in:
1 parent
b9003bacb9
commit
08693f7e68
9 files changed
+870
-288
No files matched your search
@@ -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=<name>,ou=groups,dc=vpn-router,dc=local`, a `groupOfNames` with the
|
||||
DNs of its members and an optional `description`. A group is created with at least one member,
|
||||
and taking out its last member is refused; such a group is removed instead. Removing a user
|
||||
takes it out of its groups and is refused while the user is the last member of one. The group
|
||||
operations are in `vpnrouter_users.py` with the user operations, so they share the session and
|
||||
its lock; `vpn-router-group` is a tool of its own beside `vpn-router-user`, and the web service
|
||||
serves them under `/groups`. Group names follow the rules of user names.
|
||||
|
||||
Consequences: A group always satisfies the schema without a placeholder member, and an empty
|
||||
group cannot exist. Removing a user or a member can need another step first. A later use of
|
||||
groups, such as allowing OpenVPN only to some, can read them as they are.
|
||||
@@ -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 <name>` | 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 <name> --first-name F --last-name L --email E` | Adds the user and issues the certificate; all three are required |
|
||||
| `update <name> [--first-name F] [--last-name L] [--email E]` | Changes the names or the e-mail address |
|
||||
| `passwd <name>` | Sets the password |
|
||||
| `disable <name>`, `enable <name>` | Locks the user out of OpenVPN, and lets the user in again |
|
||||
| `remove <name>` | Revokes the certificate, renews the CRL and removes the user |
|
||||
| `remove <name>` | Takes the user out of its groups, revokes the certificate, renews the CRL and removes the user; refused while the user is the last member of a group |
|
||||
| `profile <name>` | The OpenVPN client profile, with the CA, the certificate and the key inline |
|
||||
|
||||
A user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter; a
|
||||
password has 8 to 128 characters. 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 <name> --member U [--member U ...] [--description D]` | Adds the group with its members |
|
||||
| `update <name> [--name N] [--description D]` | Renames the group or sets its description; `''` removes it |
|
||||
| `remove <name>` | Removes the group; its members are kept |
|
||||
| `member-add <name> <user>` | Adds the user to the group |
|
||||
| `member-remove <name> <user>` | Takes the user out of the group; refused for the last member |
|
||||
|
||||
The exit status is 0 on success, 2 for an invalid request, 3 when the group or the user does
|
||||
not exist, 4 when the group exists and 1 for any other failure.
|
||||
|
||||
## Platform modules
|
||||
|
||||
Platform-specific values do not belong in the core, so they are Python modules in
|
||||
@@ -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/<name>` | any of `{"first_name", "last_name", "email"}` | the user |
|
||||
| `PUT /users/<name>/password` | `{"password": ...}` | the user |
|
||||
| `POST /users/<name>/disable` | | the user |
|
||||
| `POST /users/<name>/enable` | | the user |
|
||||
@@ -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/<name>` | any of `{"name", "description"}`; `""` removes the description | the group |
|
||||
| `DELETE /groups/<name>` | | `{"name", "removed": true}` |
|
||||
| `POST /groups/<name>/members` | `{"user": ...}` | the group |
|
||||
| `DELETE /groups/<name>/members/<user>` | | the group |
|
||||
|
||||
A failure is `{"error": ...}` with 400 for an invalid request, among them taking out the last
|
||||
member, 404 for a group or user that does not exist, 409 for a group that exists and 500
|
||||
otherwise. `DELETE /users/<name>` is refused with 400 while the user is the last member of a
|
||||
group.
|
||||
|
||||
The service starts on its own, without waiting for `vpn-router-setup`, and keeps running. Every
|
||||
30 seconds it works out the internal address from `vpn-router.json` as `general.mode` directs,
|
||||
and reads `web.port` and `web.enabled`; it listens there, moves when the address or the port
|
||||
|
||||
@@ -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/<name> changes them.
|
||||
* vpn-router-user answers in text: a table of the users, the client
|
||||
profile or a short message, with failures on standard error. --json
|
||||
gives the JSON answers. -h and --help show the commands and their
|
||||
options.
|
||||
* The web service manages users with vpnrouter_users, the module
|
||||
vpn-router-user uses, instead of running vpn-router-user for each
|
||||
request.
|
||||
* Groups of users, kept as groupOfNames entries in ou=groups with a
|
||||
description and at least one member. vpn-router-group lists, adds,
|
||||
renames, describes and removes them and adds and takes out members, and
|
||||
the web service serves the same under /groups. Removing a user takes it
|
||||
out of its groups and is refused while it is the last member of one.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 19:00:00 +0200
|
||||
|
||||
vpn-router (0.5.6-1) unstable; urgency=medium
|
||||
|
||||
update: the status web service listens on port 7081 by default.
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
"""The users and groups of the router's directory, for vpn-router-user, vpn-router-group and the web service.
|
||||
|
||||
A user is an entry in the router's directory, with a first name, a last name and an e-mail
|
||||
address, whose password OpenVPN checks, and a client certificate of the router CA with the
|
||||
user name as its common name, which OpenVPN and IKEv2 accept. Users exist whether or not a
|
||||
P2S system is enabled.
|
||||
|
||||
A group is a groupOfNames in ou=groups with a name, which follows the rules of user names, an
|
||||
optional description and at least one member; a group cannot be empty, so the last member
|
||||
cannot leave it and a user who is the last member of a group cannot be removed. Groups only
|
||||
record membership; nothing on the router reads them.
|
||||
|
||||
Every operation runs in a session(), which holds the lock that serializes changes and the
|
||||
connection of the user manager account. A request that cannot be done raises Failure, whose
|
||||
status says why: 2 the request is invalid, 3 the user does not exist, 4 the user exists, 1
|
||||
anything else. Answers are dicts with the keys of the JSON answers.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import fcntl
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import ldap
|
||||
import ldap.filter
|
||||
import ldap.modlist
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_ldap
|
||||
|
||||
#: A user name is one lowercase DNS label, as it is also the common name of the certificate.
|
||||
NAME = re.compile(r'^[a-z][a-z0-9-]{0,30}[a-z0-9]$|^[a-z]$')
|
||||
PASSWORD_MIN = 8
|
||||
PASSWORD_MAX = 128
|
||||
#: The longest first or last name, in characters.
|
||||
PERSON_NAME_MAX = 64
|
||||
#: An e-mail address: one @, no spaces, and a dot in the domain.
|
||||
EMAIL = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
|
||||
EMAIL_MAX = 254
|
||||
#: The keys of the person attributes and their LDAP attributes.
|
||||
PERSON = (('first_name', 'givenName'), ('last_name', 'sn'), ('email', 'mail'))
|
||||
#: The attributes read for a user.
|
||||
ATTRIBUTES = ['uid', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail']
|
||||
#: The attributes read for a group.
|
||||
GROUP_ATTRIBUTES = ['cn', 'description', 'member']
|
||||
#: The longest group description, in characters.
|
||||
DESCRIPTION_MAX = 256
|
||||
#: The value of pwdAccountLockedTime that locks an account until it is unlocked.
|
||||
LOCKED = b'000001010000Z'
|
||||
LOCK_FILE = pathlib.Path('/run/vpn-router/users.lock')
|
||||
|
||||
|
||||
class Failure(Exception):
|
||||
"""A request that cannot be done; status is 2 invalid, 3 no such user, 4 the user exists, 1 other."""
|
||||
|
||||
def __init__(self, message, status=1):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def session():
|
||||
"""The connection of the user manager account, used under the lock that serializes changes."""
|
||||
LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(LOCK_FILE, 'w') as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
connection = vpnrouter_ldap.manager()
|
||||
try:
|
||||
yield connection
|
||||
finally:
|
||||
connection.unbind_s()
|
||||
|
||||
|
||||
def check_name(name):
|
||||
if not isinstance(name, str) or not NAME.match(name):
|
||||
raise Failure('a user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter', 2)
|
||||
|
||||
|
||||
def check_password(value):
|
||||
if not isinstance(value, str) or '\n' in value or '\r' in value:
|
||||
raise Failure('the password must be a string of one line', 2)
|
||||
if not PASSWORD_MIN <= len(value) <= PASSWORD_MAX:
|
||||
raise Failure(f'the password must have {PASSWORD_MIN} to {PASSWORD_MAX} characters', 2)
|
||||
|
||||
|
||||
def person(values, required):
|
||||
"""The person attributes of values ({first_name, last_name, email}), checked, as {LDAP attribute: value}.
|
||||
|
||||
A key whose value is None is not given; with required, all three must be given.
|
||||
"""
|
||||
found = {}
|
||||
for key, attribute in PERSON:
|
||||
value = values.get(key)
|
||||
if value is None:
|
||||
if required:
|
||||
raise Failure(f'{key} is required', 2)
|
||||
continue
|
||||
if not isinstance(value, str):
|
||||
raise Failure(f'{key} must be a string', 2)
|
||||
value = value.strip()
|
||||
if key == 'email':
|
||||
if len(value) > EMAIL_MAX or not EMAIL.match(value):
|
||||
raise Failure(f'{value!r} is not an e-mail address', 2)
|
||||
elif not value or len(value) > PERSON_NAME_MAX or any(not character.isprintable() for character in value):
|
||||
raise Failure(f'the {key.replace("_", " ")} must have 1 to {PERSON_NAME_MAX} printable characters', 2)
|
||||
found[attribute] = value
|
||||
return found
|
||||
|
||||
|
||||
def dn_of(name):
|
||||
return f'uid={name},{vpnrouter_ldap.USERS}'
|
||||
|
||||
|
||||
def certificate(name):
|
||||
return vpnrouter.USER_CERT_DIR / f'{name}_cert.pem', vpnrouter.USER_CERT_DIR / f'{name}_key.pem'
|
||||
|
||||
|
||||
def entry(connection, name):
|
||||
"""The attributes of the user's entry; Failure with status 3 when there is none."""
|
||||
check_name(name)
|
||||
try:
|
||||
return connection.search_s(dn_of(name), ldap.SCOPE_BASE, attrlist=ATTRIBUTES)[0][1]
|
||||
except ldap.NO_SUCH_OBJECT:
|
||||
raise Failure(f'user {name} does not exist', 3)
|
||||
|
||||
|
||||
def simple_ca(*args):
|
||||
result = subprocess.run([sys.executable, str(vpnrouter.SIMPLE_CA), *args], capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise Failure(f'simple-ca {args[0]} failed: {result.stderr.strip() or result.stdout.strip()}')
|
||||
|
||||
|
||||
def publish_crl():
|
||||
"""Give strongSwan the CRL after a revocation; OpenVPN reads it at every connection."""
|
||||
if vpnrouter.install_file(vpnrouter.PKI_DIR / 'crl.pem', vpnrouter.CRL_DEST, 0o644):
|
||||
subprocess.run(['swanctl', '--load-creds'], capture_output=True, text=True)
|
||||
|
||||
|
||||
def summary(attributes):
|
||||
"""A user as the answers show it; a person attribute the entry does not have is None."""
|
||||
name = attributes['uid'][0].decode()
|
||||
user = {
|
||||
'name': name,
|
||||
'enabled': 'pwdAccountLockedTime' not in attributes,
|
||||
'certificate': certificate(name)[0].is_file(),
|
||||
}
|
||||
for key, attribute in PERSON:
|
||||
values = attributes.get(attribute)
|
||||
user[key] = values[0].decode() if values else None
|
||||
return user
|
||||
|
||||
|
||||
def common_name(attributes, name):
|
||||
"""The cn of an entry: the first and the last name, or the user name without them."""
|
||||
full = ' '.join(value for value in (attributes.get('givenName'), attributes.get('sn')) if value)
|
||||
return full or name
|
||||
|
||||
|
||||
def list_users(connection):
|
||||
found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL, '(objectClass=inetOrgPerson)', ATTRIBUTES)
|
||||
return {'users': sorted((summary(attributes) for _, attributes in found), key=lambda user: user['name'])}
|
||||
|
||||
|
||||
def add(connection, name, password, values):
|
||||
"""Add a user with the person attributes of values, all three required, and issue the certificate."""
|
||||
check_name(name)
|
||||
details = person(values, required=True)
|
||||
check_password(password)
|
||||
if not (vpnrouter.PKI_DIR / 'ca_key.pem').is_file():
|
||||
raise Failure('the router CA does not exist; vpn-router-setup creates it once a platform is chosen')
|
||||
record = {'objectClass': [b'inetOrgPerson'], 'uid': [name.encode()],
|
||||
'cn': [common_name(details, name).encode()]}
|
||||
record.update({attribute: [value.encode()] for attribute, value in details.items()})
|
||||
try:
|
||||
connection.add_s(dn_of(name), ldap.modlist.addModlist(record))
|
||||
except ldap.ALREADY_EXISTS:
|
||||
raise Failure(f'user {name} exists', 4)
|
||||
connection.passwd_s(dn_of(name), None, password)
|
||||
vpnrouter.USER_CERT_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
for path in certificate(name):
|
||||
path.unlink(missing_ok=True)
|
||||
simple_ca('make-cert', '--ca-dir', str(vpnrouter.PKI_DIR), '--cert-dir', str(vpnrouter.USER_CERT_DIR), name)
|
||||
return summary(entry(connection, name))
|
||||
|
||||
|
||||
def update(connection, name, values):
|
||||
"""Change the person attributes values gives; the cn follows the first and the last name."""
|
||||
details = person(values, required=False)
|
||||
if not details:
|
||||
raise Failure('give at least one of first_name, last_name and email', 2)
|
||||
current = entry(connection, name)
|
||||
names = {attribute: current[attribute][0].decode() for attribute in ('givenName', 'sn') if current.get(attribute)}
|
||||
names.update({attribute: details[attribute] for attribute in ('givenName', 'sn') if attribute in details})
|
||||
changes = [(ldap.MOD_REPLACE, attribute, [value.encode()]) for attribute, value in details.items()]
|
||||
changes.append((ldap.MOD_REPLACE, 'cn', [common_name(names, name).encode()]))
|
||||
connection.modify_s(dn_of(name), changes)
|
||||
return summary(entry(connection, name))
|
||||
|
||||
|
||||
def passwd(connection, name, password):
|
||||
check_password(password)
|
||||
entry(connection, name)
|
||||
connection.passwd_s(dn_of(name), None, password)
|
||||
return summary(entry(connection, name))
|
||||
|
||||
|
||||
def disable(connection, name):
|
||||
if 'pwdAccountLockedTime' not in entry(connection, name):
|
||||
connection.modify_s(dn_of(name), [(ldap.MOD_REPLACE, 'pwdAccountLockedTime', [LOCKED])])
|
||||
return summary(entry(connection, name))
|
||||
|
||||
|
||||
def enable(connection, name):
|
||||
if 'pwdAccountLockedTime' in entry(connection, name):
|
||||
connection.modify_s(dn_of(name), [(ldap.MOD_DELETE, 'pwdAccountLockedTime', None)])
|
||||
return summary(entry(connection, name))
|
||||
|
||||
|
||||
def remove(connection, name):
|
||||
"""Remove the user from its groups and the directory, and revoke the certificate.
|
||||
|
||||
A user who is the last member of a group is not removed.
|
||||
"""
|
||||
entry(connection, name)
|
||||
groups = groups_of(connection, name)
|
||||
alone = sorted(group for group, members in groups.items() if members == [name])
|
||||
if alone:
|
||||
raise Failure(f'user {name} is the last member of {", ".join(alone)}; remove the group first', 2)
|
||||
for group in groups:
|
||||
connection.modify_s(group_dn(group), [(ldap.MOD_DELETE, 'member', [dn_of(name).encode()])])
|
||||
cert, key = certificate(name)
|
||||
if cert.is_file():
|
||||
simple_ca('revoke-cert', '--ca-dir', str(vpnrouter.PKI_DIR), str(cert))
|
||||
simple_ca('make-crl', '--ca-dir', str(vpnrouter.PKI_DIR), '--days', str(vpnrouter.CRL_DAYS))
|
||||
publish_crl()
|
||||
cert.unlink(missing_ok=True)
|
||||
key.unlink(missing_ok=True)
|
||||
connection.delete_s(dn_of(name))
|
||||
return {'name': name, 'removed': True}
|
||||
|
||||
|
||||
def profile(connection, name):
|
||||
"""The OpenVPN client profile of the user, with the CA, the certificate and the key inline."""
|
||||
entry(connection, name)
|
||||
cfg = vpnrouter.load_config()
|
||||
if not cfg['openvpn']['enabled']:
|
||||
raise Failure('OpenVPN is not enabled', 2)
|
||||
cert, key = certificate(name)
|
||||
if not cert.is_file() or not key.is_file():
|
||||
raise Failure(f'user {name} has no certificate')
|
||||
fqdn = cfg['wan']['local_fqdn'].strip()
|
||||
text = '\n'.join([
|
||||
'client',
|
||||
'dev tun',
|
||||
f'proto {cfg["openvpn"]["protocol"]}',
|
||||
f'remote {fqdn} {cfg["openvpn"]["port"]}',
|
||||
'nobind',
|
||||
'persist-key',
|
||||
'persist-tun',
|
||||
'remote-cert-tls server',
|
||||
f'verify-x509-name {fqdn} name',
|
||||
'auth-user-pass',
|
||||
'auth-nocache',
|
||||
'verb 3',
|
||||
'<ca>', extract_pem(vpnrouter.CA_CERT_DEST.read_text(encoding='utf-8')), '</ca>',
|
||||
'<cert>', extract_pem(cert.read_text(encoding='utf-8')), '</cert>',
|
||||
'<key>', key.read_text(encoding='utf-8').strip(), '</key>',
|
||||
'',
|
||||
])
|
||||
return {'name': name, 'profile': text}
|
||||
|
||||
|
||||
def group_dn(name):
|
||||
return f'cn={name},{vpnrouter_ldap.GROUPS}'
|
||||
|
||||
|
||||
def check_group_name(name):
|
||||
if not isinstance(name, str) or not NAME.match(name):
|
||||
raise Failure('a group name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter', 2)
|
||||
|
||||
|
||||
def check_description(value):
|
||||
"""The description, stripped; '' means none."""
|
||||
if not isinstance(value, str):
|
||||
raise Failure('the description must be a string', 2)
|
||||
value = value.strip()
|
||||
if len(value) > DESCRIPTION_MAX or any(not character.isprintable() for character in value):
|
||||
raise Failure(f'the description must have at most {DESCRIPTION_MAX} printable characters', 2)
|
||||
return value
|
||||
|
||||
|
||||
def member_names(attributes):
|
||||
"""The user names of a group's member DNs, sorted."""
|
||||
names = []
|
||||
for value in attributes.get('member', []):
|
||||
dn = value.decode()
|
||||
if dn.startswith('uid=') and dn.endswith(f',{vpnrouter_ldap.USERS}'):
|
||||
names.append(dn[4:-len(f',{vpnrouter_ldap.USERS}')])
|
||||
return sorted(names)
|
||||
|
||||
|
||||
def group_summary(attributes):
|
||||
"""A group as the answers show it."""
|
||||
values = attributes.get('description')
|
||||
return {
|
||||
'name': attributes['cn'][0].decode(),
|
||||
'description': values[0].decode() if values else '',
|
||||
'members': member_names(attributes),
|
||||
}
|
||||
|
||||
|
||||
def group_entry(connection, name):
|
||||
"""The attributes of the group's entry; Failure with status 3 when there is none."""
|
||||
check_group_name(name)
|
||||
try:
|
||||
return connection.search_s(group_dn(name), ldap.SCOPE_BASE, attrlist=GROUP_ATTRIBUTES)[0][1]
|
||||
except ldap.NO_SUCH_OBJECT:
|
||||
raise Failure(f'group {name} does not exist', 3)
|
||||
|
||||
|
||||
def groups_of(connection, name):
|
||||
"""The groups the user is a member of, as {group name: its member names}."""
|
||||
found = connection.search_s(vpnrouter_ldap.GROUPS, ldap.SCOPE_ONELEVEL,
|
||||
f'(&(objectClass=groupOfNames)(member={ldap.filter.escape_filter_chars(dn_of(name))}))',
|
||||
GROUP_ATTRIBUTES)
|
||||
return {attributes['cn'][0].decode(): member_names(attributes) for _, attributes in found}
|
||||
|
||||
|
||||
def list_groups(connection):
|
||||
found = connection.search_s(vpnrouter_ldap.GROUPS, ldap.SCOPE_ONELEVEL, '(objectClass=groupOfNames)', GROUP_ATTRIBUTES)
|
||||
return {'groups': sorted((group_summary(attributes) for _, attributes in found), key=lambda group: group['name'])}
|
||||
|
||||
|
||||
def group_add(connection, name, description, members):
|
||||
"""Add a group with a description ('' for none) and at least one member, each an existing user."""
|
||||
check_group_name(name)
|
||||
description = check_description(description if description is not None else '')
|
||||
if not isinstance(members, list) or not members:
|
||||
raise Failure('a group needs at least one member', 2)
|
||||
for member in members:
|
||||
entry(connection, member)
|
||||
record = {'objectClass': [b'groupOfNames'], 'cn': [name.encode()],
|
||||
'member': [dn_of(member).encode() for member in sorted(set(members))]}
|
||||
if description:
|
||||
record['description'] = [description.encode()]
|
||||
try:
|
||||
connection.add_s(group_dn(name), ldap.modlist.addModlist(record))
|
||||
except ldap.ALREADY_EXISTS:
|
||||
raise Failure(f'group {name} exists', 4)
|
||||
return group_summary(group_entry(connection, name))
|
||||
|
||||
|
||||
def group_update(connection, name, new_name=None, description=None):
|
||||
"""Rename the group or change its description ('' removes it); at least one must be given."""
|
||||
if new_name is None and description is None:
|
||||
raise Failure('give a new name or a description', 2)
|
||||
group_entry(connection, name)
|
||||
if description is not None:
|
||||
description = check_description(description)
|
||||
change = (ldap.MOD_REPLACE, 'description', [description.encode()]) if description else \
|
||||
(ldap.MOD_REPLACE, 'description', [])
|
||||
connection.modify_s(group_dn(name), [change])
|
||||
if new_name is not None and new_name != name:
|
||||
check_group_name(new_name)
|
||||
try:
|
||||
connection.rename_s(group_dn(name), f'cn={new_name}', delold=1)
|
||||
except ldap.ALREADY_EXISTS:
|
||||
raise Failure(f'group {new_name} exists', 4)
|
||||
name = new_name
|
||||
return group_summary(group_entry(connection, name))
|
||||
|
||||
|
||||
def group_remove(connection, name):
|
||||
group_entry(connection, name)
|
||||
connection.delete_s(group_dn(name))
|
||||
return {'name': name, 'removed': True}
|
||||
|
||||
|
||||
def group_member_add(connection, name, user):
|
||||
attributes = group_entry(connection, name)
|
||||
entry(connection, user)
|
||||
if user not in member_names(attributes):
|
||||
connection.modify_s(group_dn(name), [(ldap.MOD_ADD, 'member', [dn_of(user).encode()])])
|
||||
return group_summary(group_entry(connection, name))
|
||||
|
||||
|
||||
def group_member_remove(connection, name, user):
|
||||
"""Take the user out of the group; the last member cannot leave."""
|
||||
check_name(user)
|
||||
members = member_names(group_entry(connection, name))
|
||||
if user not in members:
|
||||
raise Failure(f'user {user} is not a member of group {name}', 3)
|
||||
if members == [user]:
|
||||
raise Failure(f'user {user} is the last member of group {name}; remove the group instead', 2)
|
||||
connection.modify_s(group_dn(name), [(ldap.MOD_DELETE, 'member', [dn_of(user).encode()])])
|
||||
return group_summary(group_entry(connection, name))
|
||||
|
||||
|
||||
def extract_pem(text):
|
||||
"""The PEM blocks of the certificates in a file that also holds their text form."""
|
||||
begin, end = '-----BEGIN CERTIFICATE-----', '-----END CERTIFICATE-----'
|
||||
blocks = []
|
||||
start = text.find(begin)
|
||||
while start != -1:
|
||||
stop = text.index(end, start) + len(end)
|
||||
blocks.append(text[start:stop])
|
||||
start = text.find(begin, stop)
|
||||
return '\n'.join(blocks)
|
||||
@@ -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/<name> change any of {"first_name", "last_name", "email"}
|
||||
PUT /users/<name>/password set the password: {"password": ...}
|
||||
POST /users/<name>/disable lock the user out of OpenVPN
|
||||
POST /users/<name>/enable let the user in again
|
||||
@@ -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])
|
||||
name = parts[1] if len(parts) > 1 else None
|
||||
actions = {
|
||||
('GET', 1, None): lambda connection: vpnrouter_users.list_users(connection),
|
||||
('POST', 1, None): lambda connection: vpnrouter_users.add(connection, body.get('name'), body.get('password'), body),
|
||||
('PUT', 2, None): lambda connection: vpnrouter_users.update(connection, name, body),
|
||||
('PUT', 3, 'password'): lambda connection: vpnrouter_users.passwd(connection, name, body.get('password')),
|
||||
('POST', 3, 'disable'): lambda connection: vpnrouter_users.disable(connection, name),
|
||||
('POST', 3, 'enable'): lambda connection: vpnrouter_users.enable(connection, name),
|
||||
('DELETE', 2, None): lambda connection: vpnrouter_users.remove(connection, name),
|
||||
('GET', 3, 'profile'): lambda connection: vpnrouter_users.profile(connection, name),
|
||||
}
|
||||
if route not in actions:
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
try:
|
||||
with vpnrouter_users.session() as connection:
|
||||
document = actions[route](connection)
|
||||
except vpnrouter_users.Failure as failure:
|
||||
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
return 500, {'error': f'the user directory failed: {exc}'}
|
||||
except SystemExit:
|
||||
# vpnrouter ends with SystemExit on a configuration it cannot read; it has printed the reason.
|
||||
return 500, {'error': 'the router configuration cannot be read'}
|
||||
return (201 if route == ('POST', 1, None) else 200), document
|
||||
|
||||
|
||||
def groups(state, method, path, body):
|
||||
"""Answer a /groups request with vpnrouter_users: the HTTP status and the JSON document.
|
||||
|
||||
GET /groups the groups with their members
|
||||
POST /groups add a group: {"name", "members": [...], "description"?}
|
||||
PUT /groups/<name> rename it or change the description: {"name"?, "description"?}
|
||||
DELETE /groups/<name> remove the group; its members are kept
|
||||
POST /groups/<name>/members add a member: {"user": ...}
|
||||
DELETE /groups/<name>/members/<user> take a member out; the last member cannot leave
|
||||
"""
|
||||
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
body = body or {}
|
||||
parts = path.strip('/').split('/')
|
||||
name = parts[1] if len(parts) > 1 else None
|
||||
route = (method, len(parts), parts[2] if len(parts) > 2 else None)
|
||||
actions = {
|
||||
('GET', 1, None): lambda connection: vpnrouter_users.list_groups(connection),
|
||||
('POST', 1, None): lambda connection: vpnrouter_users.group_add(
|
||||
connection, body.get('name'), body.get('description'), body.get('members')),
|
||||
('PUT', 2, None): lambda connection: vpnrouter_users.group_update(
|
||||
connection, name, body.get('name'), body.get('description')),
|
||||
('DELETE', 2, None): lambda connection: vpnrouter_users.group_remove(connection, name),
|
||||
('POST', 3, 'members'): lambda connection: vpnrouter_users.group_member_add(connection, name, body.get('user')),
|
||||
('DELETE', 4, 'members'): lambda connection: vpnrouter_users.group_member_remove(connection, name, parts[3]),
|
||||
}
|
||||
if route not in actions:
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
try:
|
||||
with vpnrouter_users.session() as connection:
|
||||
document = actions[route](connection)
|
||||
except vpnrouter_users.Failure as failure:
|
||||
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
return 500, {'error': f'the user directory failed: {exc}'}
|
||||
return (201 if route == ('POST', 1, None) else 200), document
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
@@ -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
|
||||
|
||||
Executable
+120
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/python3
|
||||
"""Manage the groups of the router's directory from the command line, with vpnrouter_users.
|
||||
|
||||
A group has a name, an optional description and at least one member, each a user of the
|
||||
directory. The answer is text for people on standard output, and a failure is a message on
|
||||
standard error. With --json the answer is JSON on standard output, and a failure is
|
||||
{"error": ...} there too. The exit status says what failed: 2 the request is invalid, 3 the
|
||||
group or the user does not exist, 4 the group exists, 1 anything else. -h or --help shows the
|
||||
commands and their options.
|
||||
|
||||
vpn-router-group [--json] list
|
||||
vpn-router-group [--json] add <name> --member U [--member U ...] [--description D]
|
||||
vpn-router-group [--json] update <name> [--name N] [--description D]
|
||||
vpn-router-group [--json] remove <name>
|
||||
vpn-router-group [--json] member-add <name> <user>
|
||||
vpn-router-group [--json] member-remove <name> <user>
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '/usr/lib/vpn-router')
|
||||
|
||||
import ldap
|
||||
|
||||
import vpnrouter_users
|
||||
from vpnrouter_users import Failure
|
||||
|
||||
#: The help line of each command.
|
||||
HELP = {
|
||||
'list': 'list the groups with their members',
|
||||
'add': 'add a group with at least one member',
|
||||
'update': 'rename the group or change its description',
|
||||
'remove': 'remove the group; its members are kept',
|
||||
'member-add': 'add a user to the group',
|
||||
'member-remove': 'take a user out of the group; the last member cannot leave',
|
||||
}
|
||||
|
||||
|
||||
class Parser(argparse.ArgumentParser):
|
||||
"""An argument parser whose errors are Failures with status 2, so that --json answers them as JSON."""
|
||||
|
||||
def error(self, message):
|
||||
raise Failure(f'{message}; see {self.prog} --help', 2)
|
||||
|
||||
|
||||
def parse(argv):
|
||||
parser = Parser(prog='vpn-router-group', description='Manage the groups of the router.')
|
||||
parser.add_argument('--json', action='store_true', help='answer in JSON')
|
||||
commands = parser.add_subparsers(dest='command', required=True, metavar='command', parser_class=Parser)
|
||||
for command, text in HELP.items():
|
||||
sub = commands.add_parser(command, help=text, description=text[0].upper() + text[1:] + '.')
|
||||
if command != 'list':
|
||||
sub.add_argument('name', help='the group name')
|
||||
if command == 'add':
|
||||
sub.add_argument('--member', dest='members', action='append', required=True, metavar='USER',
|
||||
help='a member; give it once for each member')
|
||||
sub.add_argument('--description', metavar='TEXT', help='the description')
|
||||
if command == 'update':
|
||||
sub.add_argument('--name', dest='new_name', metavar='NAME', help='the new group name')
|
||||
sub.add_argument('--description', metavar='TEXT', help="the description; '' removes it")
|
||||
if command in ('member-add', 'member-remove'):
|
||||
sub.add_argument('user', help='the user name')
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def run(connection, options):
|
||||
"""The answer of the command the options name."""
|
||||
command = options.command
|
||||
if command == 'list':
|
||||
return vpnrouter_users.list_groups(connection)
|
||||
if command == 'add':
|
||||
return vpnrouter_users.group_add(connection, options.name, options.description, options.members)
|
||||
if command == 'update':
|
||||
return vpnrouter_users.group_update(connection, options.name, options.new_name, options.description)
|
||||
if command == 'remove':
|
||||
return vpnrouter_users.group_remove(connection, options.name)
|
||||
if command == 'member-add':
|
||||
return vpnrouter_users.group_member_add(connection, options.name, options.user)
|
||||
return vpnrouter_users.group_member_remove(connection, options.name, options.user)
|
||||
|
||||
|
||||
def text_of(answer):
|
||||
"""The answer as text for people."""
|
||||
if answer.get('removed'):
|
||||
return f'group {answer["name"]} removed'
|
||||
groups = answer['groups'] if 'groups' in answer else [answer]
|
||||
if not groups:
|
||||
return 'no groups'
|
||||
head = ('GROUP', 'MEMBERS', 'DESCRIPTION')
|
||||
rows = [(group['name'], ', '.join(group['members']), group['description'] or '-') for group in groups]
|
||||
widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))]
|
||||
return '\n'.join(' '.join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip() for row in (head, *rows))
|
||||
|
||||
|
||||
def main(argv):
|
||||
# A request that cannot be parsed is answered in JSON when --json is among the arguments.
|
||||
as_json = '--json' in argv
|
||||
try:
|
||||
options = parse(argv)
|
||||
as_json = options.json
|
||||
with vpnrouter_users.session() as connection:
|
||||
answer = run(connection, options)
|
||||
except Failure as failure:
|
||||
message, status = str(failure), failure.status
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
message, status = f'the user directory failed: {exc}', 1
|
||||
else:
|
||||
print(json.dumps(answer) if as_json else text_of(answer))
|
||||
return 0
|
||||
if as_json:
|
||||
print(json.dumps({'error': message}))
|
||||
else:
|
||||
print(f'vpn-router-group: {message}', file=sys.stderr)
|
||||
return status
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -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 <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>
|
||||
vpn-router-user [--json] list
|
||||
vpn-router-user [--json] add <name> --first-name F --last-name L --email E (password on stdin)
|
||||
vpn-router-user [--json] update <name> [--first-name F] [--last-name L] [--email E]
|
||||
vpn-router-user [--json] passwd <name> (password on stdin)
|
||||
vpn-router-user [--json] disable <name>
|
||||
vpn-router-user [--json] enable <name>
|
||||
vpn-router-user [--json] remove <name>
|
||||
vpn-router-user [--json] profile <name>
|
||||
"""
|
||||
|
||||
import 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',
|
||||
'<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 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 <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()
|
||||
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))
|
||||
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__':
|
||||
|
||||
Reference in new issue
Block a user