Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af3127d1e8 |
No files matched your search
@@ -401,3 +401,56 @@ a `SystemExit` carrying its message, so setup records it.
|
||||
Consequences: The diagnostics need nothing the setup provides and cannot collide with the real
|
||||
services. After the first success they never run again, also not after an upgrade or a reboot.
|
||||
The page shows log lines to whoever reaches the internal network during the first setup.
|
||||
|
||||
## ADR-18: Manager-only accounts are a second kind of user; the package can create the first admin
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: The Router Manager needs a first administrator before anyone has added users, as other
|
||||
packages create a default user from their debconf answers. Such an account, and others that only
|
||||
manage the router, have no person behind them and need no VPN access. A VPN user is an
|
||||
`inetOrgPerson`, which requires a last name and a common name, and its certificate needs the
|
||||
names. The Manager is configured before the router's directory exists during an installation.
|
||||
|
||||
Decision: A user is one of two kinds in `ou=users`, fixed when it is added: a VPN user, an
|
||||
`inetOrgPerson` with the three person attributes and a certificate, or a Manager-only account,
|
||||
an `account`, `simpleSecurityObject` and `vpnRouterAccount` (ADR-19) with the name, the password,
|
||||
an optional e-mail address for notifications, and no certificate. `add` with all three person
|
||||
attributes adds a VPN user and without the first and the last name a Manager-only account; one
|
||||
name without the rest is refused. The CLI and the API add both kinds; the Manager's dialog
|
||||
requires the names unless Manager access only is chosen, which offers the e-mail address only.
|
||||
Answers carry `manager_only`, and `update` changes only the e-mail address of a Manager-only
|
||||
account. The Manager's package asks `admin_username` and `admin_password`; with both
|
||||
it writes them to a root-only file, clears the answers, and `vpn-router-setup` creates the account
|
||||
after it provisions the directory, adds it to `admins` (creating the group when missing) and
|
||||
removes the file. Without a name nothing is created. `vpnrouter_users` knows only the directory,
|
||||
so setup can use it before PostgreSQL's driver can be loaded; `web` and `vpn-router-user` revoke
|
||||
refresh tokens after the operations of `vpnrouter_auth.REVOKING`.
|
||||
|
||||
Consequences: A Manager-only account cannot become a VPN user, as LDAP cannot change an entry's
|
||||
structural class and its password hash cannot be copied; it is removed and added again instead.
|
||||
It has no certificate, so OpenVPN and road-warrior access refuse it. The administrator's password
|
||||
is on disk only until setup has run.
|
||||
|
||||
## ADR-19: The package has an LDAP schema of its own under a UUID OID
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: A Manager-only account (ADR-18) is an `account`, which allows no `mail`, yet notifications
|
||||
need an address for it; no standard auxiliary class allows `mail` except `extensibleObject`, which
|
||||
allows every attribute. Authorization scopes for users of both kinds may follow. An attribute or
|
||||
class of its own needs an OID, and the package has no registered arc.
|
||||
|
||||
Decision: The package defines its schema, `cn=vpn-router,cn=schema,cn=config`, which
|
||||
`vpnrouter_ldap.provision` adds when missing and keeps as `SCHEMA_ATTRIBUTE_TYPES` and
|
||||
`SCHEMA_OBJECT_CLASSES`. Its OID is `2.25.98477352595735834532404256531620013001`, made from a
|
||||
random UUID under the arc 2.25 (ITU-T X.667), which needs no registration; attribute types are
|
||||
under `.1` and object classes under `.2`. The first version has the attribute `vpnRouterScope`
|
||||
(multi-valued, case-insensitive strings) and the auxiliary class `vpnRouterAccount`,
|
||||
`MAY ( mail $ vpnRouterScope )`. Manager-only accounts have the class from their creation, so an
|
||||
e-mail address can be added later without changing their classes; VPN users get it once scopes are
|
||||
used.
|
||||
|
||||
Consequences: The schema check stays strict. New attributes extend `vpnRouterAccount`, and their
|
||||
OIDs continue the arcs. Nothing reads `vpnRouterScope` yet. The OID must never be reused for
|
||||
anything else.
|
||||
@@ -324,7 +324,12 @@ is switched on:
|
||||
- its own database, `dc=vpn-router,dc=local` in `/var/lib/ldap/vpn-router`, next to the one the
|
||||
slapd package creates; root manages it through the local socket, with no administrator
|
||||
password;
|
||||
- `ou=users` holds one `inetOrgPerson` per user, named by `uid`;
|
||||
- `ou=users` holds one entry per user, named by `uid`: an `inetOrgPerson` for a VPN user, an
|
||||
`account` for a Manager-only account (see Managing users);
|
||||
- the package's own schema, `cn=vpn-router,cn=schema,cn=config`, under the OID
|
||||
`2.25.98477352595735834532404256531620013001` of the UUID arc: the auxiliary class
|
||||
`vpnRouterAccount` with `MAY ( mail $ vpnRouterScope )`, which Manager-only accounts have, and
|
||||
the attribute `vpnRouterScope`, authorization scopes that nothing reads yet;
|
||||
- `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
|
||||
@@ -346,9 +351,17 @@ The directory is kept on purge.
|
||||
|
||||
### Managing users
|
||||
|
||||
`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`.
|
||||
`vpn-router-user` manages the users. A user is a directory entry of one of two kinds, fixed when it
|
||||
is added:
|
||||
|
||||
- a VPN user, an `inetOrgPerson` 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`;
|
||||
- a Manager-only account, an `account`, `simpleSecurityObject` and `vpnRouterAccount` with the
|
||||
name, the password and an optional e-mail address, for notifications: no names and no
|
||||
certificate, so no VPN access. It can sign in to `/auth` and, as a member of `admins`, manage
|
||||
the router. `add` without the first and the last name adds one; its names cannot be set later.
|
||||
|
||||
Passwords and TOTP secrets are read from the first line of standard input. The answer is text: a
|
||||
table of the users, the client profile, a TOTP secret or a short message, and a failure is a
|
||||
message on standard error.
|
||||
@@ -358,6 +371,7 @@ after a command its options.
|
||||
|
||||
```sh
|
||||
echo 's3cr3t-pass' | vpn-router-user add alice --first-name Alice --last-name Smith --email alice@example.com
|
||||
echo 'm4nager-pass' | vpn-router-user add ops
|
||||
vpn-router-user update alice --email a.smith@example.com
|
||||
vpn-router-user list
|
||||
vpn-router-user profile alice > alice.ovpn
|
||||
@@ -374,8 +388,9 @@ vpn-router-user otp-remove alice
|
||||
|
||||
| Command | Effect |
|
||||
| --- | --- |
|
||||
| `list` | The users with their names and e-mail addresses, whether each is enabled or locked, has a certificate and is enrolled for TOTP |
|
||||
| `add <name> --first-name F --last-name L --email E` | Adds the user and issues the certificate; all three are required |
|
||||
| `list` | The users with their kind, names and e-mail addresses, whether each is enabled or locked, has a certificate and is enrolled for TOTP |
|
||||
| `add <name> --first-name F --last-name L --email E` | Adds a VPN user and issues the certificate |
|
||||
| `add <name> [--email E]` | Adds a Manager-only account, with the e-mail address when given; one name without the other two options is refused |
|
||||
| `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 `/auth`, and lets the user in again |
|
||||
@@ -390,9 +405,17 @@ A user name is 1 to 32 lowercase letters, digits and hyphens, starting with a le
|
||||
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; a TOTP secret is 32 base32
|
||||
characters, as `otp-new` makes it, and a code 6 digits. Once enrolled, a user logs in to OpenVPN
|
||||
and `/auth` with the password followed by a current code. 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.
|
||||
and `/auth` with the password followed by a current code. In the answers `manager_only` tells the
|
||||
kind, and a Manager-only account has its names as `null`; `update` changes only its e-mail
|
||||
address, and `--email ''` removes it. Changing a user's password, disabling, unlocking or removing the user also revokes its
|
||||
refresh tokens of `/auth`. 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.
|
||||
|
||||
When the Router Manager is installed with an administrator (its debconf questions
|
||||
`vpn-router-manager/admin_username` and `admin_password`), its package leaves the answers in
|
||||
`/etc/vpn-router-manager/admin.json` (root only). `vpn-router-setup` then creates that user as a
|
||||
Manager-only account, adds it to the group `admins`, which it creates when missing, and removes
|
||||
the file.
|
||||
|
||||
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.
|
||||
@@ -590,9 +613,9 @@ and answer bodies are JSON; a request body is at most 4096 bytes.
|
||||
|
||||
| Request | Body | Answer |
|
||||
| --- | --- | --- |
|
||||
| `GET /users` | | `{"users": [{"name", "enabled", "locked", "certificate", "otp", "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 |
|
||||
| `GET /users` | | `{"users": [{"name", "manager_only", "enabled", "locked", "certificate", "otp", "first_name", "last_name", "email"}, ...]}` |
|
||||
| `POST /users` | `{"name", "password", "first_name", "last_name", "email"}` for a VPN user; `{"name", "password", "email"?}` for a Manager-only account | 201 and the user |
|
||||
| `PUT /users/<name>` | any of `{"first_name", "last_name", "email"}`; for a Manager-only account only `{"email"}`, `""` removing it | the user |
|
||||
| `PUT /users/<name>/password` | `{"password": ...}` | the user |
|
||||
| `POST /users/<name>/disable` | | the user |
|
||||
| `POST /users/<name>/enable` | | the user |
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
vpn-router (0.10.0-1) unstable; urgency=medium
|
||||
|
||||
feature: Manager-only accounts, and the Router Manager's first administrator.
|
||||
|
||||
* vpn-router-user add without the person options, and POST /users without
|
||||
them, add a Manager-only account: a name and a password, no names, no
|
||||
e-mail address and no certificate, so no VPN access. Giving only some of
|
||||
the three is refused. The answers show the kind as manager_only, list
|
||||
shows a KIND column, and update refuses a Manager-only account.
|
||||
* vpn-router-setup creates the administrator the Router Manager's package
|
||||
leaves in /etc/vpn-router-manager/admin.json as a Manager-only account in
|
||||
the group admins, which it creates when missing, and removes the file.
|
||||
* The directory has the package's own schema, with the auxiliary class
|
||||
vpnRouterAccount and the attribute vpnRouterScope. A Manager-only account
|
||||
can have an e-mail address: add --email, update --email ('' removes it),
|
||||
and email in POST and PUT /users.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 08:00:00 +0200
|
||||
|
||||
vpn-router (0.9.2-1) unstable; urgency=medium
|
||||
|
||||
fix: the web service starts once libpq can be found.
|
||||
|
||||
@@ -27,8 +27,11 @@ import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
|
||||
|
||||
import ldap
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_ldap
|
||||
import vpnrouter_users
|
||||
from vpnrouter import fail
|
||||
|
||||
BEFORE_RULES = pathlib.Path('/etc/ufw/before.rules')
|
||||
@@ -49,6 +52,10 @@ BOOTSTRAP_PORTS = (17080, 17081)
|
||||
#: The Router Manager, vpn-router-manager, when it is installed, and its settings.
|
||||
MANAGER = pathlib.Path('/usr/lib/vpn-router-manager/manager')
|
||||
MANAGER_CONFIG = pathlib.Path('/etc/vpn-router-manager/manager.json')
|
||||
|
||||
#: The default admin of the Router Manager, {"username", "password"}, which its package writes from
|
||||
#: its debconf answers; setup creates the account and removes the file.
|
||||
MANAGER_ADMIN = pathlib.Path('/etc/vpn-router-manager/admin.json')
|
||||
MANAGER_PORT = 7080
|
||||
|
||||
FILTER_ANCHOR = '# End required lines'
|
||||
@@ -384,6 +391,31 @@ def manage_directory():
|
||||
return ''
|
||||
|
||||
|
||||
def manage_admin():
|
||||
"""Create the Router Manager's default admin from MANAGER_ADMIN, then remove the file; the problem, or ''.
|
||||
|
||||
The admin is a Manager-only account and a member of the admins group, which is created with it
|
||||
when it does not exist.
|
||||
"""
|
||||
if not MANAGER_ADMIN.exists():
|
||||
return ''
|
||||
try:
|
||||
answers = json.loads(MANAGER_ADMIN.read_text(encoding='utf-8'))
|
||||
name, password = answers['username'], answers['password']
|
||||
with vpnrouter_users.session() as connection:
|
||||
vpnrouter_users.add(connection, name, password, {})
|
||||
try:
|
||||
vpnrouter_users.group_member_add(connection, vpnrouter_users.ADMIN_GROUP, name)
|
||||
except vpnrouter_users.Failure as failure:
|
||||
if failure.status != 3:
|
||||
raise
|
||||
vpnrouter_users.group_add(connection, vpnrouter_users.ADMIN_GROUP, 'Router administrators', [name])
|
||||
MANAGER_ADMIN.unlink()
|
||||
except (OSError, ValueError, KeyError, TypeError, ldap.LDAPError, vpnrouter_users.Failure) as exc:
|
||||
return f'the Router Manager admin cannot be created from {MANAGER_ADMIN}: {exc}'
|
||||
return ''
|
||||
|
||||
|
||||
def manage_openvpn(ctx, changed):
|
||||
"""Run the OpenVPN server when it is configured, and stop it when it is not."""
|
||||
unit = 'openvpn-server@vpn-router'
|
||||
@@ -532,9 +564,14 @@ def main():
|
||||
progress('running', 'WireGuard')
|
||||
manage_wireguard(ctx, changed)
|
||||
|
||||
# The tunnels are up before the directory, whose problem ends setup only after every other step.
|
||||
progress('running', 'user directory')
|
||||
# The tunnels are up before the directory and the admin, whose problem ends setup only after every other step.
|
||||
failed_step = 'user directory'
|
||||
progress('running', failed_step)
|
||||
problem = manage_directory()
|
||||
if not problem:
|
||||
failed_step = 'Router Manager admin'
|
||||
progress('running', failed_step)
|
||||
problem = manage_admin()
|
||||
progress('running', 'OpenVPN')
|
||||
manage_openvpn(ctx, changed)
|
||||
|
||||
@@ -549,7 +586,7 @@ def main():
|
||||
progress('running', 'web service state')
|
||||
refresh_web(cfg, ctx)
|
||||
if problem:
|
||||
progress('failed', 'user directory', problem)
|
||||
progress('failed', failed_step, problem)
|
||||
print(f'vpn-router: error: {problem}', file=sys.stderr)
|
||||
return 1
|
||||
progress('done', 'done')
|
||||
|
||||
@@ -30,6 +30,9 @@ DATABASE_SECRET = pathlib.Path('/etc/vpn-router/database.secret')
|
||||
#: The role and the database provision() creates when web.database is not set.
|
||||
DATABASE = 'vpn-router'
|
||||
|
||||
#: The user operations of vpnrouter_users after which the user's refresh tokens are revoked.
|
||||
REVOKING = ('passwd', 'disable', 'unlock', 'remove')
|
||||
|
||||
#: Seconds an access token of /auth and a refresh token are valid.
|
||||
ACCESS_SECONDS = 300
|
||||
REFRESH_SECONDS = 8 * 3600
|
||||
|
||||
@@ -50,6 +50,21 @@ LOCKOUT_SECONDS = 900
|
||||
#: The overlays of the database, in the order they are configured.
|
||||
OVERLAYS = ('ppolicy', 'otp')
|
||||
|
||||
#: The package's own schema, under an OID of the UUID arc 2.25 (ITU-T X.667): attribute types
|
||||
#: under .1 and object classes under .2. vpnRouterAccount, auxiliary, adds properties of the
|
||||
#: package to a user of either kind: mail, for a Manager-only account, and vpnRouterScope,
|
||||
#: authorization scopes that nothing reads yet.
|
||||
SCHEMA_OID = '2.25.98477352595735834532404256531620013001'
|
||||
SCHEMA_NAME = 'vpn-router'
|
||||
SCHEMA_ATTRIBUTE_TYPES = (
|
||||
f"( {SCHEMA_OID}.1.1 NAME 'vpnRouterScope' DESC 'VPN router: an authorization scope of the account' "
|
||||
"EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} )",
|
||||
)
|
||||
SCHEMA_OBJECT_CLASSES = (
|
||||
f"( {SCHEMA_OID}.2.1 NAME 'vpnRouterAccount' DESC 'VPN router: properties of a user or a Manager-only account' "
|
||||
"SUP top AUXILIARY MAY ( mail $ vpnRouterScope ) )",
|
||||
)
|
||||
|
||||
#: The TOTP parameters: digits, the OID of HMAC-SHA1, seconds per time step, and the time steps accepted before and after now.
|
||||
TOTP_DIGITS = 6
|
||||
TOTP_ALGORITHM = '1.2.840.113549.2.7'
|
||||
@@ -109,6 +124,23 @@ def _ensure_modules(connection):
|
||||
connection.modify_s(dn, [(ldap.MOD_ADD, 'olcModuleLoad', [name.encode() for name in missing])])
|
||||
|
||||
|
||||
def _ensure_schema(connection):
|
||||
"""Add the package's schema to cn=config when missing, else set its definitions that differ."""
|
||||
found = connection.search_s('cn=schema,cn=config', ldap.SCOPE_ONELEVEL, f'(cn=*{SCHEMA_NAME})',
|
||||
['olcAttributeTypes', 'olcObjectClasses'])
|
||||
attributes = {'olcAttributeTypes': [value.encode() for value in SCHEMA_ATTRIBUTE_TYPES],
|
||||
'olcObjectClasses': [value.encode() for value in SCHEMA_OBJECT_CLASSES]}
|
||||
if not found:
|
||||
connection.add_s(f'cn={SCHEMA_NAME},cn=schema,cn=config', ldap.modlist.addModlist(
|
||||
dict(attributes, objectClass=[b'olcSchemaConfig'], cn=[SCHEMA_NAME.encode()])))
|
||||
return
|
||||
dn, current = found[0]
|
||||
changes = [(ldap.MOD_REPLACE, name, values) for name, values in attributes.items()
|
||||
if unordered(current.get(name, [])) != [value.decode() for value in values]]
|
||||
if changes:
|
||||
connection.modify_s(dn, changes)
|
||||
|
||||
|
||||
def _ensure_password_hash(connection):
|
||||
dn = 'olcDatabase={-1}frontend,cn=config'
|
||||
current = connection.search_s(dn, ldap.SCOPE_BASE, attrlist=['olcPasswordHash'])[0][1].get('olcPasswordHash', [])
|
||||
@@ -188,10 +220,11 @@ def _ensure_manager(connection):
|
||||
|
||||
|
||||
def provision():
|
||||
"""Create or bring up to date the modules, database, ACLs, password policy, entries and service account."""
|
||||
"""Create or bring up to date the modules, the package's schema, database, ACLs, password policy, entries and service account."""
|
||||
connection = root()
|
||||
try:
|
||||
_ensure_modules(connection)
|
||||
_ensure_schema(connection)
|
||||
_ensure_password_hash(connection)
|
||||
database = _ensure_database(connection)
|
||||
_ensure_overlays(connection, database)
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
"""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 user is an entry in ou=users of the router's directory, of one of two kinds, fixed when it is
|
||||
added. A VPN user is an inetOrgPerson 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. A Manager-only account is an account,
|
||||
simpleSecurityObject and vpnRouterAccount (the package's schema) with a name, a password and
|
||||
optionally an e-mail address, no names, no certificate and so no VPN access; it is added when
|
||||
neither the first nor the last name is given, and can sign in to the web service. 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
|
||||
@@ -19,8 +23,8 @@ code of it. slapd's otp overlay then takes the password followed by a code at ev
|
||||
A user is disabled when pwdAccountLockedTime holds LOCKED, and locked when the password
|
||||
policy has set it after failed binds, for vpnrouter_ldap.LOCKOUT_SECONDS; unlock ends that.
|
||||
login() checks a user's password and code with a bind. Members of ADMIN_GROUP manage the
|
||||
router through the web service. Changing the password, disabling, unlocking and removing a
|
||||
user revoke its refresh tokens.
|
||||
router through the web service. The module knows only the directory; the callers revoke a
|
||||
user's refresh tokens after the operations of vpnrouter_auth.REVOKING.
|
||||
|
||||
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
|
||||
@@ -44,7 +48,6 @@ import ldap.modlist
|
||||
import pyotp
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_auth
|
||||
import vpnrouter_ldap
|
||||
|
||||
#: A user name is one lowercase DNS label, as it is also the common name of the certificate.
|
||||
@@ -59,7 +62,10 @@ 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', 'oathTOTPToken']
|
||||
ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken']
|
||||
#: The object classes of a VPN user and of a Manager-only account.
|
||||
VPN_USER_CLASSES = [b'inetOrgPerson']
|
||||
MANAGER_ONLY_CLASSES = [b'account', b'simpleSecurityObject', b'vpnRouterAccount']
|
||||
#: The readable attributes of a TOTP token; oathSecret is never readable.
|
||||
TOKEN_ATTRIBUTES = ['oathTOTPToken', 'oathTOTPParams', 'oathSecretTime', 'oathTOTPLastTimeStep', 'oathTOTPTimeStepDrift',
|
||||
'oathLastLogin', 'oathLastFailure', 'oathFailureCount']
|
||||
@@ -172,12 +178,18 @@ def locked_since(value):
|
||||
return 0
|
||||
|
||||
|
||||
def manager_only(attributes):
|
||||
"""Whether the entry is a Manager-only account."""
|
||||
return b'account' in (value.lower() for value in attributes.get('objectClass', []))
|
||||
|
||||
|
||||
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()
|
||||
lock = attributes.get('pwdAccountLockedTime', [None])[0]
|
||||
user = {
|
||||
'name': name,
|
||||
'manager_only': manager_only(attributes),
|
||||
'enabled': lock != LOCKED,
|
||||
'locked': lock not in (None, LOCKED) and locked_since(lock) > time.time() - vpnrouter_ldap.LOCKOUT_SECONDS,
|
||||
'certificate': certificate(name)[0].is_file(),
|
||||
@@ -196,38 +208,62 @@ def common_name(attributes, name):
|
||||
|
||||
|
||||
def list_users(connection):
|
||||
found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL, '(objectClass=inetOrgPerson)', ATTRIBUTES)
|
||||
found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL, '(|(objectClass=inetOrgPerson)(objectClass=account))',
|
||||
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."""
|
||||
"""Add a user: a VPN user with its certificate when values gives all three person attributes, a Manager-only account
|
||||
with at most the e-mail address when it gives neither name."""
|
||||
check_name(name)
|
||||
details = person(values, required=True)
|
||||
details = person(values, required=False)
|
||||
vpn_user = 'givenName' in details or 'sn' in details
|
||||
if vpn_user and len(details) != len(PERSON):
|
||||
raise Failure('give first_name, last_name and email for a VPN user, or at most email for a Manager-only account', 2)
|
||||
check_password(password)
|
||||
if not (vpnrouter.PKI_DIR / 'ca_key.pem').is_file():
|
||||
if vpn_user and 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()]}
|
||||
if vpn_user:
|
||||
record = {'objectClass': VPN_USER_CLASSES, 'uid': [name.encode()], 'cn': [common_name(details, name).encode()]}
|
||||
else:
|
||||
# simpleSecurityObject requires a userPassword; passwd_s sets the real one at once.
|
||||
record = {'objectClass': MANAGER_ONLY_CLASSES, 'uid': [name.encode()], 'userPassword': [b'{CRYPT}!']}
|
||||
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)
|
||||
if vpn_user:
|
||||
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."""
|
||||
"""Change the person attributes values gives; the cn follows the first and the last name.
|
||||
|
||||
A Manager-only account has only the e-mail address, which '' removes.
|
||||
"""
|
||||
current = entry(connection, name)
|
||||
if manager_only(current):
|
||||
if values.get('first_name') is not None or values.get('last_name') is not None:
|
||||
raise Failure(f'user {name} is a Manager-only account, which has no first or last name', 2)
|
||||
email = values.get('email')
|
||||
if email is None:
|
||||
raise Failure('give email', 2)
|
||||
if isinstance(email, str) and email.strip() == '':
|
||||
if 'mail' in current:
|
||||
connection.modify_s(dn_of(name), [(ldap.MOD_DELETE, 'mail', None)])
|
||||
else:
|
||||
connection.modify_s(dn_of(name), [(ldap.MOD_REPLACE, 'mail', [person({'email': email}, required=False)['mail'].encode()])])
|
||||
return summary(entry(connection, 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()]
|
||||
@@ -240,14 +276,12 @@ def passwd(connection, name, password):
|
||||
check_password(password)
|
||||
entry(connection, name)
|
||||
connection.passwd_s(dn_of(name), None, password)
|
||||
vpnrouter_auth.revoke(vpnrouter.load_config(), name)
|
||||
return summary(entry(connection, name))
|
||||
|
||||
|
||||
def disable(connection, name):
|
||||
if entry(connection, name).get('pwdAccountLockedTime') != [LOCKED]:
|
||||
connection.modify_s(dn_of(name), [(ldap.MOD_REPLACE, 'pwdAccountLockedTime', [LOCKED])])
|
||||
vpnrouter_auth.revoke(vpnrouter.load_config(), name)
|
||||
return summary(entry(connection, name))
|
||||
|
||||
|
||||
@@ -266,7 +300,6 @@ def unlock(connection, name):
|
||||
changes.append((ldap.MOD_DELETE, 'pwdAccountLockedTime', None))
|
||||
if changes:
|
||||
connection.modify_s(dn_of(name), changes)
|
||||
vpnrouter_auth.revoke(vpnrouter.load_config(), name)
|
||||
return summary(entry(connection, name))
|
||||
|
||||
|
||||
@@ -361,7 +394,6 @@ def remove(connection, name):
|
||||
cert.unlink(missing_ok=True)
|
||||
key.unlink(missing_ok=True)
|
||||
connection.delete_s(dn_of(name))
|
||||
vpnrouter_auth.revoke(vpnrouter.load_config(), name)
|
||||
return {'name': name, 'removed': True}
|
||||
|
||||
|
||||
|
||||
@@ -399,7 +399,8 @@ def users(state, method, path, body):
|
||||
"""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", "first_name", "last_name", "email"}
|
||||
POST /users add a user: {"name", "password", "first_name", "last_name", "email"};
|
||||
without the last three a Manager-only account
|
||||
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
|
||||
@@ -431,11 +432,16 @@ def users(state, method, path, body):
|
||||
('PUT', 3, 'otp'): lambda connection: vpnrouter_users.otp_enrol(connection, name, body.get('secret'), body.get('code')),
|
||||
('DELETE', 3, 'otp'): lambda connection: vpnrouter_users.otp_remove(connection, name),
|
||||
}
|
||||
# The vpnrouter_users operation of each route that may revoke refresh tokens.
|
||||
operations = {('PUT', 3, 'password'): 'passwd', ('POST', 3, 'disable'): 'disable', ('POST', 3, 'unlock'): 'unlock',
|
||||
('DELETE', 2, None): 'remove'}
|
||||
if route not in actions:
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
try:
|
||||
with vpnrouter_users.session() as connection:
|
||||
document = actions[route](connection)
|
||||
if operations.get(route) in vpnrouter_auth.REVOKING:
|
||||
vpnrouter_auth.revoke(vpnrouter.load_config(), name)
|
||||
except vpnrouter_users.Failure as failure:
|
||||
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
|
||||
@@ -10,6 +10,7 @@ user exists, 1 anything else. -h or --help shows the commands and their options.
|
||||
|
||||
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] add <name> [--email E] (a Manager-only account; 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>
|
||||
@@ -30,6 +31,7 @@ sys.path.insert(0, '/usr/lib/vpn-router')
|
||||
|
||||
import ldap
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_auth
|
||||
import vpnrouter_users
|
||||
from vpnrouter_users import Failure
|
||||
@@ -37,8 +39,10 @@ from vpnrouter_users import Failure
|
||||
#: 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',
|
||||
'add': 'add a VPN user with the three person options and issue the certificate, or without the names a '
|
||||
'Manager-only account, with an optional e-mail address; the password is read from standard input',
|
||||
'update': "change the first name, the last name or the e-mail address; a Manager-only account has only the "
|
||||
"e-mail address, which '' removes",
|
||||
'passwd': 'set the password, read from standard input',
|
||||
'disable': 'lock the user out of OpenVPN',
|
||||
'enable': 'let the user in again',
|
||||
@@ -71,8 +75,7 @@ def parse(argv):
|
||||
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])
|
||||
sub.add_argument(f'--{key.replace("_", "-")}', dest=key, metavar='TEXT', help=PERSON_HELP[key])
|
||||
if command == 'otp-enrol':
|
||||
sub.add_argument('code', help='a code of the secret from the authenticator')
|
||||
return parser.parse_args(argv)
|
||||
@@ -111,8 +114,9 @@ def text_of(answer):
|
||||
users = answer['users'] if 'users' in answer else [answer]
|
||||
if not users:
|
||||
return 'no users'
|
||||
head = ('USER', 'STATE', 'CERTIFICATE', 'OTP', 'FIRST NAME', 'LAST NAME', 'E-MAIL')
|
||||
rows = [(user['name'], 'disabled' if not user['enabled'] else 'locked' if user['locked'] else 'enabled',
|
||||
head = ('USER', 'KIND', 'STATE', 'CERTIFICATE', 'OTP', 'FIRST NAME', 'LAST NAME', 'E-MAIL')
|
||||
rows = [(user['name'], 'manager' if user['manager_only'] else 'vpn',
|
||||
'disabled' if not user['enabled'] else 'locked' if user['locked'] else 'enabled',
|
||||
'issued' if user['certificate'] else 'missing',
|
||||
'enrolled' if user['otp'] else '-', user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-')
|
||||
for user in users]
|
||||
@@ -131,6 +135,8 @@ def main(argv):
|
||||
secret = password() if options.command in ('add', 'passwd', 'otp-enrol') else None
|
||||
with vpnrouter_users.session() as connection:
|
||||
answer = run(connection, options, secret)
|
||||
if options.command in vpnrouter_auth.REVOKING:
|
||||
vpnrouter_auth.revoke(vpnrouter.load_config(), options.name)
|
||||
except Failure as failure:
|
||||
message, status = str(failure), failure.status
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
|
||||
Reference in new issue
Block a user