Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
770332728e | ||
|
|
e14bbe2ba1 | ||
|
|
dabe4e8365 | ||
|
|
ea8ca97417 | ||
|
|
77bdbcb058 | ||
|
|
cb33b3bfa1 | ||
|
|
81b7f4cac6 | ||
|
|
8cac21845a | ||
|
|
d1d565b1d9 | ||
|
|
e675540ce2 | ||
|
|
90db6d46a2 | ||
|
|
91cc64d6a6 | ||
|
|
a1f314d8c1 | ||
|
|
7d8892b8c4 | ||
|
|
4491377d9b | ||
|
|
f1d9c3bc36 | ||
|
|
015fba50ca | ||
|
|
9255d56276 | ||
|
|
07dbe91d0c | ||
|
|
ec93bc6a2d | ||
|
|
2e1eb89f52 | ||
|
|
1825e1efb1 | ||
|
|
d648d294ec | ||
|
|
a245ec26cd | ||
|
|
7404ee88c4 | ||
|
|
448ce05b3a | ||
|
|
e11c88f0de | ||
|
|
c19b195ea0 | ||
|
|
8d3469d10a | ||
|
|
fe45272f7c | ||
|
|
7d1f639508 | ||
|
|
959ca0cd32 | ||
|
|
1cef4c044f | ||
|
|
25c151841b | ||
|
|
68ebd541b2 |
No files matched your search
@@ -98,8 +98,8 @@ Context: OpenVPN needs user accounts with passwords, and the users are meant to
|
||||
system of the router, including IKEv2 EAP-TLS and WireGuard, which an OIDC provider cannot
|
||||
authenticate. Everything the router runs comes from the Ubuntu archive. slapd, its Python module
|
||||
`python3-ldap` and the OpenVPN plugin `openvpn-auth-ldap` are all in the archive; the plugin
|
||||
checks a password by binding as the user, so the hash scheme is slapd's alone. An OIDC provider
|
||||
can read the directory through its LDAP connector.
|
||||
checks a password by binding as the user, so the hash scheme is slapd's alone. The router's own
|
||||
OpenID Provider (ADR-37) signs the directory's users in to other systems.
|
||||
|
||||
Decision: The users are entries in slapd. The package keeps its own database,
|
||||
`dc=vpn-router,dc=local` in `/var/lib/ldap/vpn-router` (the path the slapd AppArmor profile
|
||||
@@ -121,25 +121,30 @@ and it is backed up with `slapcat`, not by copying files.
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: The router CA issues client certificates for IKEv2. OpenVPN can check a certificate, a
|
||||
password, or both. With both, a certificate alone and a password alone are not enough, but
|
||||
`openvpn-auth-ldap` checks only the password of the user name given at login, which need not be
|
||||
the user of the certificate.
|
||||
Context: The P2S issuing CA (ADR-27) issues the users' client certificates, for IKEv2 and
|
||||
OpenVPN. OpenVPN can check a certificate, a password, or both. With both, a certificate alone and
|
||||
a password alone are not enough, but `openvpn-auth-ldap` checks only the password of the user name
|
||||
given at login, which need not be the user of the certificate. OpenVPN trusts every chain to a
|
||||
CA of its `ca` file and has no option that accepts one intermediate CA only, while the root also
|
||||
signs the site-to-site and the general-purpose CAs.
|
||||
|
||||
Decision: OpenVPN checks the certificate against the CA and the CRL, the password with
|
||||
`openvpn-auth-ldap`, and runs `openvpn-same-user` as `auth-user-pass-verify` with `via-env`,
|
||||
which accepts the login only when the user name equals the certificate's common name. OpenVPN
|
||||
calls both and needs both to pass. A user's certificate has the user name as its common name, is
|
||||
issued when the user is added and revoked when the user is removed. The CRL is created with the
|
||||
CA, so that `crl-verify` has a file from the start, and is valid for ten years, because the router
|
||||
renews it only when it revokes a certificate. The server uses the router's own certificate, whose
|
||||
common name is `wan.local_fqdn`; the client profile checks that name (`verify-x509-name`), since
|
||||
the CA's certificates carry both the server and the client purpose.
|
||||
Decision: OpenVPN checks the certificate against the root and the P2S CA and their CRLs, which
|
||||
it needs for every CA of the chain, the password with `openvpn-auth-ldap`, and runs
|
||||
`openvpn-same-user` as `auth-user-pass-verify` with `via-env`, which accepts the login only when
|
||||
the user name equals the certificate's common name. `openvpn-issuer`, as `tls-verify`, accepts
|
||||
the certificate at depth 1 only when its SHA-256 fingerprint, which OpenVPN gives the script,
|
||||
is the P2S CA's. OpenVPN calls all of them and needs all to pass. A user's certificate has the
|
||||
user name as its common name, is issued with the template `vpn-user`, client authentication
|
||||
only, when the user is added, and revoked when the user is removed. The server uses the router's
|
||||
VPN server certificate, whose common name is `wan.local_fqdn`; the client profile checks that
|
||||
name (`verify-x509-name`).
|
||||
|
||||
Consequences: Disabling a user locks the password, which stops OpenVPN but not IKEv2, which
|
||||
checks certificates only; removing a user revokes the certificate for both. A user's
|
||||
certificate expires after the CA's default of a year and is renewed by removing and adding the
|
||||
user again.
|
||||
checks certificates only; removing a user revokes the certificate for both. A certificate of
|
||||
another issuing CA with a user's name does not reach the password check. A user's certificate
|
||||
expires after the days of `vpn-user` and is renewed by removing and adding the user again. An
|
||||
expired root CRL stops every OpenVPN client, so an offline root needs its CRL renewed in time
|
||||
(ADR-28).
|
||||
|
||||
## ADR-6: The web service and vpn-router-user share one module for users
|
||||
|
||||
@@ -147,9 +152,9 @@ Status: Accepted
|
||||
|
||||
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`.
|
||||
passwords and person attributes, and both change the directory and the PKI, whose CRL numbers and
|
||||
CRLs two changes must not write at the same time. The web unit is sandboxed with
|
||||
`ProtectSystem=strict`.
|
||||
|
||||
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
|
||||
@@ -157,11 +162,14 @@ lock and the connection of the user manager account. The web service imports it
|
||||
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, its `RuntimeDirectory`, which systemd creates
|
||||
before the service starts, also before setup has run, and keeps when it stops, as setup's state
|
||||
files are there too; `/etc/vpn-router/pki` for the certificates and the CA's state, and
|
||||
`/etc/swanctl/x509crl` for the published CRL.
|
||||
files are there too, and `/etc/swanctl/x509crl`, `/etc/openvpn/server` and
|
||||
`/var/lib/vpn-router/pki-publish` for the copies of the CRLs a revocation signs; the PKI itself is
|
||||
in the directory (ADR-32). The PKI module, `vpnrouter_pki.py`, works the same way for `/ca`,
|
||||
`vpn-router-ca` and `vpn-router-setup`, and its lock is the users' lock, as adding and removing
|
||||
a user issue and revoke a certificate.
|
||||
|
||||
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
|
||||
Consequences: The rules exist once. A request starts no process but `swanctl --load-creds` after
|
||||
a revocation, 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 tokens of ADR-3, so only members of `admins`
|
||||
and the holders of an admin token manage users.
|
||||
|
||||
@@ -245,12 +253,13 @@ Context: Users are prepared before a P2S system is switched on, and the Router M
|
||||
them on any configured router, with or without one.
|
||||
|
||||
Decision: Once a platform is chosen, `vpn-router-setup` always provisions the user directory in
|
||||
slapd and the CA with its CRL; the server certificate follows once `wan.local_fqdn` is set.
|
||||
`/users` and `vpn-router-user` rely only on the directory and the CA. Only the OpenVPN client
|
||||
profile needs OpenVPN to be enabled.
|
||||
slapd and the PKI (ADR-27) with its CRLs; the router's certificates follow once `wan.local_fqdn`
|
||||
is set. `/users` and `vpn-router-user` rely only on the directory and the P2S CA. Only the
|
||||
OpenVPN client 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
|
||||
Consequences: slapd always holds the directory and the CAs exist on a configured router, even
|
||||
when no P2S system uses them; with an offline root the P2S CA, and so the users' certificates,
|
||||
wait for its certificate. An operator who wants a root CA of their own must place it before
|
||||
choosing the platform.
|
||||
|
||||
## ADR-11: Groups are groupOfNames entries that never become empty
|
||||
@@ -465,11 +474,14 @@ Decision: The package defines its schema, `cn=vpn-router,cn=schema,cn=config`, w
|
||||
`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.
|
||||
under `.1` and object classes under `.2`. It has the attribute `vpnRouterScope` (multi-valued,
|
||||
case-insensitive strings) and the auxiliary class `vpnRouterAccount`,
|
||||
`MAY ( mail $ vpnRouterScope $ displayName $ description )`; `displayName` and `description`, the
|
||||
standard attributes, are the display name and the description of a service account, which
|
||||
`account` cannot hold. 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.
|
||||
`vpnrouter_ldap.provision` replaces the definitions in `cn=config` that differ, so an extended class
|
||||
reaches an existing directory at the next setup run.
|
||||
|
||||
Consequences: The schema check stays strict. New attributes extend `vpnRouterAccount`, and their
|
||||
OIDs continue the arcs. The scope `service` marks a service account (ADR-23). The OID must never
|
||||
@@ -558,7 +570,7 @@ purge. A view shows only what its level keeps, so a shorter retention empties th
|
||||
view. A month view needs the hourly rows and a year view the monthly rows. `/metrics` has only the
|
||||
last sample; history is not scraped.
|
||||
|
||||
## ADR-23: /metrics takes HTTP Basic authentication of an internal service account that setup creates
|
||||
## ADR-23: /metrics takes HTTP Basic authentication of an internal service account the router manages
|
||||
|
||||
Status: Accepted
|
||||
|
||||
@@ -567,20 +579,23 @@ the router's tokens, which last minutes (ADR-3). The scraper should not be able
|
||||
API or the Manager, and its password should be checked and locked out like any other. The account
|
||||
belongs to the package's metrics, not to the people using the router.
|
||||
|
||||
Decision: A service account is a Manager-only account (ADR-18) with `vpnRouterScope` `service`.
|
||||
Administrators add service accounts with `vpn-router-user add --service` and the web service's
|
||||
`/service-accounts`, for services of their own that check passwords against the directory.
|
||||
Decision: A service account is a Manager-only account (ADR-18) with `vpnRouterScope` `service`,
|
||||
which another system signs in with to use a router service. Administrators add service accounts
|
||||
with `vpn-router-user add --service` and the web service's `/service-accounts`.
|
||||
`/service-accounts` is a path of its own, apart from `/users`, which does not list or serve them:
|
||||
a service account has no names, certificate, profile or TOTP, and is not a person an
|
||||
administrator manages with the users. The metrics service's is the internal one, created by
|
||||
`vpn-router-setup`: while metrics are enabled it adds the account `metrics.service_account` names,
|
||||
`metrics` by default, with a random password it writes to `/etc/vpn-router/metrics.secret` (root
|
||||
only), and sets a new random password, ending a lockout, when the file is missing or no longer
|
||||
holds the password. `/service-accounts` marks it as `internal` and only lists it: every other
|
||||
request on it, and putting it in or taking it out of a group at `/groups`, gets 403, whatever the
|
||||
token. Setup
|
||||
changes it through the directory's user-manager account, not the API, and root may still with
|
||||
`vpn-router-user`. `/auth` refuses every service account with the message of a wrong password. `/metrics` accepts HTTP Basic only for that account, and
|
||||
administrator manages with the users. A service account has a display name and a description
|
||||
besides its name, so an administrator can tell what each is for. Internal service accounts are
|
||||
those the router creates and manages for its own services (`vpnrouter_users.internal_accounts`),
|
||||
with every property, display name and description included, so the system, not an
|
||||
administrator, owns them; at present that is the metrics service's, which setup names Metrics. `/service-accounts` marks
|
||||
them as `internal` and only lists them: every other request on one, and putting one in or taking
|
||||
one out of a group at `/groups`, gets 403, whatever the token. The router changes them through the
|
||||
directory's user-manager account, not the API, and root may still with `vpn-router-user`. For the
|
||||
metrics service, `vpn-router-setup` adds the account `metrics.service_account` names, `metrics` by
|
||||
default, while metrics are enabled, with a random password it writes to
|
||||
`/etc/vpn-router/metrics.secret` (root only), and sets a new random password, ending a lockout,
|
||||
when the file is missing or no longer holds the password. `/auth` refuses every service account with the message of a wrong password. `/metrics` accepts HTTP Basic only for that account, and
|
||||
only when it is a service account; the password is checked with a bind as the account, so the
|
||||
password policy's lockout applies (ADR-14). TLS is Caddy's, on `metrics.https_port` (ADR-21);
|
||||
plain HTTP is served on the interfaces `metrics.listen` names, `internal` by default.
|
||||
@@ -600,18 +615,18 @@ upgrade restarts the process that starts it: the manager's postinst restarts
|
||||
`vpn-router-manager.service`, and the router's restarts `vpn-router-web` and `vpn-router-setup`. A
|
||||
child of the manager would be stopped with the manager's cgroup in the middle of dpkg, leaving
|
||||
packages half configured, and the manager's unit cannot run apt at all, since `ProtectSystem=strict`
|
||||
makes `/usr` and `/var/lib/dpkg` read-only. The package lists are updated by a cron job, not by the
|
||||
manager.
|
||||
makes `/usr` and `/var/lib/dpkg` read-only. Ubuntu's `apt-daily` timer updates the package lists
|
||||
twice a day, so a release newer than its last run is not seen until the next one.
|
||||
|
||||
Decision: The manager starts the helper `/usr/lib/vpn-router-manager/upgrade` with `systemd-run` as
|
||||
the transient unit `vpn-router-manager-upgrade`, of its own cgroup and without a sandbox, and does
|
||||
not wait for it. The helper:
|
||||
|
||||
- finishes an interrupted dpkg run with `dpkg --configure -a`;
|
||||
- runs `apt-get install --only-upgrade` of the two packages, non-interactive, keeping changed configuration files, with `needrestart` suspended, and waiting for the dpkg lock that the cron job may hold;
|
||||
- runs `apt-get install --only-upgrade` of the two packages, non-interactive, keeping changed configuration files, with `needrestart` suspended, and waiting for the dpkg lock that `apt-daily` or `unattended-upgrades` may hold;
|
||||
- records the state and the result in `/var/lib/vpn-router-manager/upgrade.json`, replaced by a rename, and the output in `upgrade.log`.
|
||||
|
||||
A restarted manager reads them again. A state of running without an active unit is shown as interrupted. The manager reboots the router the same way, as a transient timer `vpn-router-manager-reboot` 5 seconds later, so the answer reaches the browser first. Only members of `admins`, as the router's `/account` answers, may start either.
|
||||
A restarted manager reads them again. A state of running without an active unit is shown as interrupted. The manager reboots the router the same way, as a transient timer `vpn-router-manager-reboot` 5 seconds later, so the answer reaches the browser first. It checks for updates the same way too: Check update runs `apt-get update` in the transient unit `vpn-router-manager-check`, which touches apt's update stamp when it succeeds, so an admin need not wait for the `apt-daily` timer. Only members of `admins`, as the router's `/account` answers, may start any of them.
|
||||
|
||||
Consequences: The fixed unit name allows one upgrade at a time. The page loses the manager and the
|
||||
router's web service for a while during an upgrade and reads again until they answer. Sessions
|
||||
@@ -673,3 +688,497 @@ one connection, dropping the others. A change through the API is answered before
|
||||
applied it, so a configuration that setup cannot apply shows in the setup state, not in the
|
||||
answer. A connection with a key file keeps it until a key is given through the API, which then
|
||||
replaces the file.
|
||||
|
||||
## ADR-26: Backups are a staged, consistent copy, archived locally with tar and zstd or sent to a Proxmox Backup Server
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: An image backup of the VM, such as Azure Backup, restores the whole disk. It cannot be
|
||||
exported as a file, read on another system, or restored onto a new VM. The state that matters is
|
||||
small, but it is spread over several places: `vpn-router.json` and the secrets, the PKI, the
|
||||
WireGuard keys and peers, the firewall rules, the slapd directory and, optionally, the
|
||||
PostgreSQL database. Only part of it can be made again: the CA, the password hashes and the TOTP
|
||||
secrets cannot. The API and the command-line tools change the configuration, the PKI and the
|
||||
directory while the router runs.
|
||||
|
||||
Decision: `vpn-router-backup run`, started by a timer on `backup.schedule`, stages a copy of that
|
||||
state in one directory and then archives or sends it.
|
||||
|
||||
- **Consistency:** the files are copied and the directory is written with `slapcat` while the
|
||||
configuration lock and the users lock are held, the locks every change of the API and the tools
|
||||
takes, so a user, their certificate and the CRL are copied together. `slapcat` reads the MDB
|
||||
database consistently while slapd runs (ADR-4), and `pg_dump` reads a snapshot. Files that
|
||||
`vpn-router-setup` regenerates are left out.
|
||||
- **Local target:** a tar archive compressed with zstd, both from the Ubuntu archive, so a local
|
||||
backup needs nothing else and the archive can be read on any Linux system. A `pxar` archive
|
||||
would need Proxmox's client and is not compressed.
|
||||
- **Retention:** local archives are pruned by the rules of Proxmox Backup Server's prune, so the
|
||||
settings mean the same for both targets and can be checked with Proxmox's prune simulator.
|
||||
Settings finer than the schedule do not apply.
|
||||
- **Cloud copy:** rclone, from the Ubuntu archive, copies the archives to any storage it
|
||||
supports. The remote is defined in `/etc/vpn-router/rclone.conf` with `rclone config`, since
|
||||
each backend has its own settings. `vpn-router.json` names only the remote, and the
|
||||
configuration file is part of the backup. `sync` makes retention apply to the remote as well;
|
||||
`copy` leaves it to the storage's lifecycle rules. Only this host's archives are included, so a
|
||||
shared remote path is safe.
|
||||
- **Proxmox Backup Server:** the static `proxmox-backup-client` sends the staged directory as one
|
||||
`pxar` archive of a host snapshot. Deduplication makes frequent backups cheap. The server's
|
||||
prune jobs keep the snapshots, so the token needs only `DatastoreBackup`, which cannot delete.
|
||||
The client is a suggestion, not a dependency, because it comes from Proxmox's repository, which
|
||||
an Ubuntu system does not know. Without it, only a PBS backup fails.
|
||||
- **Restore:** only on the command line, as root. It replaces the directory and the database, so
|
||||
it stops their services, keeps what it replaces and lets `vpn-router-setup` rebuild the rest.
|
||||
The API offers no restore, so a stolen admin session cannot replace the router's keys.
|
||||
|
||||
Consequences: A backup holds every secret of the router, including the keys of the issuing CAs
|
||||
and the root key while it is on the router, in plain form
|
||||
unless a PBS key file encrypts it, so where it is kept must be as safe as the router. A restore
|
||||
onto another VM brings back the old interface names and addresses, which may need changing. The
|
||||
timer's schedules are systemd's shorthands, so all backups of one schedule start at the same
|
||||
moment. A change of the users between the end of the copy and the next backup is in no backup
|
||||
until then.
|
||||
|
||||
## ADR-27: A two-level CA with dedicated issuing CAs, whose root may be offline
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: One self-signed CA signed every certificate of the router with the same server and
|
||||
client purposes, so a certificate for any use was also a VPN client certificate, and the key
|
||||
that signed everything had to stay on the router. The router needs CAs for the VPN clients, for
|
||||
site-to-site certificates and for the administrator's own uses, and a root whose key can be kept
|
||||
elsewhere. The `simple-ca` script handled one level and ran as a separate process with its own
|
||||
state file.
|
||||
|
||||
Decision: The root signs only issuing CAs, path length 1; the issuing CAs, path length 0, sign
|
||||
the certificates. `p2s` signs the router's VPN server certificate and the users' certificates,
|
||||
`s2s` the site-to-site certificates, and the CAs of `pki.issuing_cas` what their templates
|
||||
allow. A template fixes the key, the lifetime, the key usages, the extended key usages, the kind
|
||||
of common name and the subject alternative names a certificate may have; `vpn-server` and
|
||||
`vpn-user` are issued by the router only, so no certificate for another use comes from the P2S
|
||||
CA. The module `vpnrouter_pki.py`, with `python3-cryptography` from the Ubuntu archive, builds
|
||||
the keys, certificates, CSRs and CRLs in the processes that need them, as the user module does
|
||||
(ADR-6); the settings are the `pki` section of `vpn-router.json`, and the CAs and the
|
||||
certificates are kept in the router's directory (ADR-32). Keeping the root key off the router is recommended, not required: while it is
|
||||
on the router a new issuing CA is signed at once, and while it is not the CA gets its key and a
|
||||
CSR, signed offline and imported with `vpn-router-ca` or `/ca`. The root key itself crosses
|
||||
neither the API nor the Manager. For a certificate of something other than the router, the router
|
||||
signs the requester's CSR, or makes the key and returns it once in a PKCS#12 file, keeping only
|
||||
the certificate, so a key it gave away cannot leak from it later. The PKCS#12 file uses 3DES and
|
||||
SHA-1 MACs, the encryption the key stores of Windows and macOS import. strongSwan reads only the
|
||||
first certificate of a PEM file, so each CA has a file of its own in `/etc/swanctl/x509ca`;
|
||||
road-warrior and site-to-site name their CA in `cacerts`, which restricts the trust chain to it.
|
||||
|
||||
Consequences: The P2S CA's certificates are accepted by road-warrior and OpenVPN only, the site-to-site CA's by
|
||||
site-to-site only. With an offline root, new issuing CAs and the root CRL need the offline step,
|
||||
and a router whose P2S CA waits has no VPN server certificate and so no P2S access. The router
|
||||
keeps the users' keys, as the OpenVPN profile embeds them.
|
||||
|
||||
## ADR-28: CA certificates and CRLs are published at configurable endpoints, and CRLs are short-lived
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: Clients outside the router, such as Windows and servers using the general-purpose CAs,
|
||||
find the issuer's certificate and the CRL through the AIA and CRL distribution point extensions,
|
||||
and cache a CRL until its next update. The router's CRLs were valid for ten years and published
|
||||
nowhere. Caddy serves files itself, and the web service already answers on the status port.
|
||||
|
||||
Decision: `pki.publish` lists the endpoints, in the order the URLs appear in the certificates:
|
||||
plain HTTP on the status port, served by the web service; plain HTTP on port 80, HTTPS on the
|
||||
status HTTPS port and HTTPS on port 443, served by Caddy's `file_server`. The files are DER, as
|
||||
RFC 5280 asks of these URLs, copied to `/var/lib/vpn-router/pki-publish`, outside the 0700 PKI
|
||||
directory, so that Caddy's user reads them. Ports 80 and 443 serve only the CA files and are opened
|
||||
on both interfaces when chosen. The CRLs of the issuing CAs are valid for `pki.crl_days`, a week
|
||||
by default, signed again at every revocation and by a daily timer once half their validity has
|
||||
passed; the root CRL has its own `pki.root_crl_days`, half a year by default, so an offline root
|
||||
needs its CRL signed twice a year, not weekly.
|
||||
|
||||
Consequences: A client that caches a CRL sees a revocation within a week. The URLs are fixed in a
|
||||
certificate when it is signed, so a change of the endpoints or of `wan.local_fqdn` applies to
|
||||
certificates signed afterwards. HTTPS URLs are offered for clients that cannot use plain HTTP,
|
||||
although a client checking the revocation of the HTTPS server's own certificate cannot use them;
|
||||
Caddy's certificate is Let's Encrypt's, so the router's endpoints do not depend on themselves.
|
||||
|
||||
## ADR-29: Site-to-site connections authenticate with a pre-shared key or certificates
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: Site-to-site connections used pre-shared keys only. Peers that are routers of this
|
||||
package, or have a PKI of their own, can authenticate with certificates, which leaves no shared
|
||||
secret to distribute. Azure's VPN gateway authenticates site-to-site connections with pre-shared
|
||||
keys only.
|
||||
|
||||
Decision: A connection's `auth` is `psk`, the default, or `certificate`. With `certificate` the
|
||||
router presents one site-to-site certificate of its `s2s` CA, whose names are `wan.local_fqdn` and
|
||||
the local identities the connections with `certificate` use, the public or the internal address of
|
||||
their `local_id_mode`, and no address no connection presents, such as an internal address the peers
|
||||
never see; the peer's
|
||||
certificate must name the connection's `id` and be issued by the router's `s2s` CA or, when the
|
||||
connection has `remote_ca`, by that CA, its PEM certificate kept in the connection and copied to
|
||||
`/etc/swanctl/x509ca/s2s-<name>.pem`. A peer without a PKI gets its certificate from the router.
|
||||
|
||||
Consequences: The connection Azure's gateway needs stays `psk`, so the platform writes nothing
|
||||
new. The router's site-to-site certificate is issued again when the set of names it needs changes,
|
||||
such as when a connection's `local_id_mode` or its address changes. The
|
||||
router does not check a remote CA's revocations unless strongSwan can fetch the CRL its
|
||||
certificates name.
|
||||
|
||||
## ADR-30: A general-purpose issuing CA can back an ACME server of Caddy
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: Servers that use a general-purpose CA need their certificates renewed, which ACME clients
|
||||
automate. Caddy, which the router runs for HTTPS, has an ACME server built on a CA of its `pki`
|
||||
app. Caddy 2.6.2 of the Ubuntu archive needs the key file of that CA's root, never renews an
|
||||
intermediate given to it, keeps no CRL and takes only the CA in the `acme_server` directive,
|
||||
issuing certificates valid for 12 hours. Its ACME handler answers every path under `/acme/`.
|
||||
|
||||
Decision: An issuing CA of `pki.issuing_cas` with `acme` makes Caddy serve an ACME server for it
|
||||
at `/acme/<name>/` of the HTTPS status port, each in a `handle` block of its own. The issuing CA's
|
||||
certificate and key are given to Caddy as both the root and the intermediate of a `pki` CA, copied
|
||||
to `/etc/caddy/vpn-router-pki/<name>/` for Caddy's group, so that the router's root key never
|
||||
reaches Caddy and may be offline.
|
||||
|
||||
Consequences: The certificates Caddy issues chain to the router's root through the issuing CA, are
|
||||
not in the router's inventory, follow no template and are not revoked; their short lifetime takes
|
||||
the place of revocation. An issuing CA waiting for the offline root has no ACME server.
|
||||
|
||||
## ADR-31: vpn-router-setup installs Caddy from the Ubuntu archive or from Caddy's repository
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: Caddy 2.6.2 of the Ubuntu archive lacks later features, such as a pki root without its key
|
||||
and the settings of the ACME server. Caddy's own repository has current versions of the package of
|
||||
the same name. A dependency on `caddy` installs the archive's package with `vpn-router`. apt cannot
|
||||
run inside `vpn-router-setup` when the package's installation runs setup, as dpkg holds its lock.
|
||||
|
||||
Decision: `vpn-router` only suggests `caddy`. While HTTPS is on, setup has
|
||||
`vpn-router-caddy.service` install it from `https.caddy_source`: `ubuntu`, or `upstream`, for
|
||||
which the unit writes the deb822 source and the key it downloads over HTTPS from Caddy's
|
||||
repository. The unit waits for dpkg's lock and runs setup again when Caddy is in place.
|
||||
`vpn-router-caddy use-upstream` switches an installed router. Upstream's versions sort above the
|
||||
archive's, so once the repository is configured apt keeps upstream's Caddy without a pin, and
|
||||
`ubuntu` has no effect; nothing downgrades Caddy on its own, as a downgrade may break the
|
||||
configuration and is the administrator's decision. Only the CLI switches the source; the Router
|
||||
Manager shows which one is in effect.
|
||||
|
||||
Consequences: The first setup run with HTTPS starts the installation and serves HTTPS on the run
|
||||
that follows it. The key of Caddy's repository is trusted as downloaded over HTTPS, the way Caddy's
|
||||
installation instructions do it. Going back to the archive's Caddy is a manual downgrade.
|
||||
|
||||
## ADR-32: The PKI is kept in the router's directory
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: The CAs, their keys and CRLs, the inventory of the certificates issued and their
|
||||
revocations, and the router's and the users' certificates and keys need a store. Files with a
|
||||
JSON state file cannot be searched, and are copied apart from the users the certificates belong
|
||||
to. The router runs slapd, which holds the users and is provisioned on every
|
||||
configured router (ADR-10), and PostgreSQL, which holds the refresh tokens and the metrics, may
|
||||
be another server (`web.database`) and is in a backup only with `backup.metrics`. RFC 4523
|
||||
defines `pkiCA` with `cACertificate` and `certificateRevocationList`, and `userCertificate`, which
|
||||
OpenLDAP's core schema includes.
|
||||
|
||||
Decision: The PKI is held in `ou=pki` of the router's directory. A CA is an entry of the package's
|
||||
class `vpnRouterAuthority` with `cACertificate` and `certificateRevocationList`, its key, its CSR,
|
||||
its CRL number, the source of the root CRL and the time it was retired; a certificate is an entry
|
||||
of `vpnRouterCertificate` named by its serial number under its CA, with `userCertificate`, its
|
||||
kind, template, names, expiry and revocation, and the key of a router or user certificate. A
|
||||
user's certificate is also the `userCertificate` of the user's entry. An ACL lets no account read
|
||||
`vpnRouterPrivateKey`; only root, the database's root DN, does, through the local socket. A
|
||||
retired CA keeps its entry, renamed, so its certificates stay in the inventory. A root supplied
|
||||
from outside, by cloud-init or by hand, is placed as files before the first setup with a platform,
|
||||
which imports and removes them. strongSwan, OpenVPN, Caddy and the system's trust store read files,
|
||||
so setup and every revocation write them from the directory. Setup provisions the directory before
|
||||
the PKI; without the directory it leaves the PKI and its files as they are and configures the rest.
|
||||
|
||||
Consequences: `slapcat` writes the users and the PKI into every backup in one consistent copy, and a
|
||||
restore brings both back together. The PKI depends on slapd, as the users already do, and not on
|
||||
PostgreSQL. The directory holds the CA keys, so it is as sensitive as the CA itself. The schema
|
||||
grows by the package's PKI attributes and classes, which setup adds to slapd's configuration.
|
||||
|
||||
## ADR-33: The web service opens a shell for the Router Manager over a WebSocket
|
||||
|
||||
Context: The Router Manager's Shell page gives an administrator a terminal on the router in the
|
||||
browser, with xterm.js, the terminal of VS Code. A terminal needs a full-duplex channel, which
|
||||
xterm.js expects as a WebSocket. The web service runs in a sandbox (`NoNewPrivileges`,
|
||||
`ProtectSystem=strict`, `ProtectHome`) that a shell must not inherit, and as root, which a shell
|
||||
in a browser should not be. A stolen session cookie or access token alone must not give a shell.
|
||||
|
||||
Decision: `/shell` of the web service is off until `web.shell` is set on the router; the Router
|
||||
Manager cannot set it. The shell runs as the console account of the signed-in user (ADR-34), so
|
||||
it is never root and each administrator's shell is the administrator's own; a user without
|
||||
console login gets none. `POST /shell` signs the user in again with the password
|
||||
and TOTP code and gives a one-time ticket valid 30 seconds; a WebSocket on `GET /shell` with that
|
||||
ticket gets the shell. Asking for the password and code at every opening of the page is tiresome,
|
||||
so within `web.shell_auth_timeout` minutes of the user's last sign-in or keystroke a ticket is given
|
||||
without them; the times are kept in the web service's memory only, so a restart asks again, and 0
|
||||
keeps the sign-in at every opening. The WebSocket of RFC 6455 is implemented on the standard library inside the
|
||||
web service's `http.server`, server side only, without extensions. `systemd-run --pty` starts the
|
||||
shell in a transient unit with a PAM login session, outside the sandbox, and `setsid --ctty`
|
||||
makes the terminal its controlling terminal so window sizes reach the shell. A shell idle for
|
||||
`web.shell_idle_minutes` is closed, and the journal records every opening, end and refusal. The Router
|
||||
Manager checks the session and the WebSocket's origin, and relays the bytes to the router after
|
||||
both handshakes.
|
||||
|
||||
Consequences: Who can sign in as a member of `admins` and knows the password and the current code
|
||||
can get a shell when the router allows one and the user's console login is on; a session alone
|
||||
cannot. The ticket travels in the URL and appears in request logs, used up. The shell's sessions
|
||||
show up as `vpn-router-shell-<id>` units and logind sessions of the console account. The shell's
|
||||
traffic is as protected as the Router Manager's: HTTPS to Caddy, plain HTTP on the internal
|
||||
network to the web service.
|
||||
|
||||
## ADR-34: Console login maps an administrator to a POSIX account
|
||||
|
||||
Context: A shell, SSH and sudo need a POSIX account. One shared system account for every
|
||||
administrator hides who did what and shares one set of rights. The administrators are users of
|
||||
the router's directory, with their passwords, TOTP tokens and lockout there; a second set of
|
||||
local accounts would duplicate them. slapd listens on its local socket only. SSSD authenticates
|
||||
only over TLS or LDAPS and has no `ldapi://`; nslcd talks `ldapi://` and checks a password by
|
||||
binding as the user.
|
||||
|
||||
Decision: Console login is a property of a member of `admins`, `vpnRouterConsoleLogin`, that
|
||||
only root on the router sets, as for the group `admins`. It runs the console as an existing local
|
||||
account (`vpnRouterConsoleAccount`), or as the user's own POSIX account: the user's entry becomes a
|
||||
`posixAccount` with a uid from 20000 up and the local group `vpn-router-console` as its group.
|
||||
nslcd serves the POSIX accounts with console login on, and PAM checks their passwords with a bind,
|
||||
so slapd's otp overlay and password policy apply as for the Router Manager: the password followed
|
||||
by the code, and the lockout. Members of `vpn-router-console` get sudo with that password. SSH
|
||||
accepts it only while `ssh.ldap_login` is set, in a `Match Group` drop-in of sshd. Switching console
|
||||
login off keeps the `posixAccount`, hidden by nslcd's filter, so the uid and the home stay the
|
||||
user's; leaving `admins` switches it off. A console reaches root through sudo, so a password alone
|
||||
must not open one: console login needs a TOTP token. Switching it on is refused without one, and
|
||||
the token is checked at use rather than only when it is switched on, because an administrator can
|
||||
remove it later: the shell's ticket and every minute of an open shell check it, nslcd's
|
||||
`pam_authz_search` requires `oathTOTPUser`, and removing the token ends the sessions of the user's
|
||||
own POSIX account.
|
||||
|
||||
Consequences: Each administrator's shell, SSH session and sudo run as the administrator, logged
|
||||
under the administrator's name. The directory becomes part of the system's name service through
|
||||
nslcd, and the system knows only the accounts with console login on. The user types the code
|
||||
after the password at the SSH and sudo prompts. An administrator whose console login was switched
|
||||
on before the token was required is refused until enrolling. Homes are created at the first login and
|
||||
are not removed with the user. A password is changed through the directory, not with `passwd`.
|
||||
|
||||
## ADR-35: WireGuard peers are the devices of the VPN users, kept in the directory
|
||||
|
||||
Context: The WireGuard endpoint could be switched on from the Router Manager, but its peers were
|
||||
written by hand into `wg0.conf`, so nobody could connect without SSH. A peer is a P2S client like
|
||||
an OpenVPN or IKEv2 user, and one person may have several devices.
|
||||
|
||||
Decision: A VPN user's entry holds the user's WireGuard peers in the multi-valued attribute
|
||||
`vpnRouterWireGuardPeer`, one value `<public key> <IP>[,<IP>...]` per device; the router never
|
||||
sees a private key. `PUT /users/<name>` replaces a user's peers after checking the keys, which
|
||||
are unique, and the IPs, which do not overlap another peer's or cover the router's address. Setup
|
||||
reads the peers of the users who are not disabled from the directory and writes them as a marked
|
||||
block of `[Peer]` sections into `wg0.conf`; sections outside the block, added by hand, stay. A
|
||||
change of the peers, and disabling, enabling or removing a user with peers, restart setup. The
|
||||
Router Manager adds and removes peers on the WireGuard tab of Remote access management, where the
|
||||
endpoint is, choosing the user for each.
|
||||
|
||||
Consequences: Peers follow their users: disabling a user drops the user's devices, and the
|
||||
directory's backup holds them. A peer cannot exist without a VPN user. A change restarts
|
||||
`wg-quick@wg0` through setup, which briefly interrupts the other peers.
|
||||
|
||||
## ADR-36: Passkeys sign in to the Router Manager and the OpenID Provider, verified by the router with python3-fido2
|
||||
|
||||
Context: Typing the password and a TOTP code at every sign-in to the Router Manager is tedious.
|
||||
WebAuthn needs a browser, HTTPS and a domain name; OpenVPN, IKEv2, WireGuard, SSH and sudo have no
|
||||
browser, and the sudo PAM module for security keys needs the key at the router. The router signs
|
||||
users in (`/auth`) and keeps them in its directory. The standard library cannot read CBOR or COSE
|
||||
keys; Ubuntu packages Yubico's python-fido2 in noble (1.1) and resolute (2.0), whose server API is
|
||||
the same once the JSON mapping of WebAuthn Level 3 is switched on, which 2.0 always uses.
|
||||
|
||||
Decision: A passkey is a discoverable credential with user verification of the relying party
|
||||
`wan.local_fqdn`, with the user name as its user handle, so the sign-in needs no name. The router
|
||||
registers and verifies passkeys with python3-fido2 in `vpnrouter_passkeys`; the origin must be
|
||||
HTTPS on that name, which python-fido2 checks. Each passkey is a JSON value of the multi-valued
|
||||
`vpnRouterPasskey` on the user's entry, with the public key only. Registration needs the current
|
||||
password and code, so a session alone cannot add a lasting way in; removal needs neither. A
|
||||
passkey sign-in gives the same tokens as `/auth`; as it binds no password, the router checks that
|
||||
the user is enabled, not locked and not a service account. The begun steps are kept in the web
|
||||
service's memory for 2 minutes. The browser converts the options and answers with
|
||||
`PublicKeyCredential.parseCreationOptionsFromJSON`, `parseRequestOptionsFromJSON` and `toJSON`.
|
||||
The OpenID Provider's sign-in page (ADR-37) takes a passkey the same way.
|
||||
|
||||
Consequences: Passkeys are a convenience for the Router Manager and the OpenID Provider; the
|
||||
password and code stay for everything else, the shell included. A changed `wan.local_fqdn` orphans every passkey. The
|
||||
Router Manager on another name, or in development on `localhost`, cannot use them. Failed passkey
|
||||
sign-ins do not count towards the directory's lockout, but the sign-in calls are rate limited.
|
||||
Browsers without the JSON methods of WebAuthn Level 3 cannot add or use passkeys.
|
||||
|
||||
## ADR-37: The web service is an OpenID Provider built on python3-oauthlib; its clients are service accounts
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: Other systems, such as dashboards, should sign the router's users in with the directory's
|
||||
passwords, TOTP codes and passkeys, without a second user store. The Ubuntu archive has no
|
||||
packaged identity provider. `python3-oauthlib` and `python3-jwt` are in main for noble and
|
||||
resolute; oauthlib implements the OpenID Connect flows and leaves the checks, the storage and the
|
||||
ID token's signature to the application. `python3-authlib` and `python3-jwcrypto` are in universe
|
||||
only. The web service already checks passwords and passkeys, rate limits sign-ins and keeps
|
||||
tokens in PostgreSQL, and Caddy serves it over HTTPS on `wan.local_fqdn`. ADR-3's HS256 key must
|
||||
not leave the router, but a relying party verifies an ID token itself.
|
||||
|
||||
Decision: The web service is the OpenID Provider, in `vpnrouter_oidc`, while `oidc.enabled` is
|
||||
true; its issuer is its HTTPS site, `https://<wan.local_fqdn>:<https.web_port>`, without the port
|
||||
on 443, and it answers the provider's paths only on `127.0.0.1`, where Caddy passes them. Only
|
||||
the authorization code flow with PKCE S256 and the refresh token grant are allowed. A client is a
|
||||
service account with redirect URIs, kept with the account in the new attributes
|
||||
`vpnRouterRedirectUri`, `vpnRouterPostLogoutRedirectUri` and `vpnRouterAllowedGroup` of
|
||||
`vpnRouterAccount`: its name is the client ID and its password the secret, checked with a bind.
|
||||
Allowed groups limit who may sign in; without them every user may. A registered client is
|
||||
trusted, so there is no consent page. The sign-in page is the web service's own, a Jinja2
|
||||
template with the password, the code and a passkey, so the provider works without the Router
|
||||
Manager. ID tokens are RS256, signed with an RSA key the web service makes on its first run
|
||||
(`/etc/vpn-router/oidc.key`), published as a JWK named by its RFC 7638 thumbprint; `sub` is the
|
||||
entry's `entryUUID`. Codes, access and refresh tokens and sessions are kept as their SHA-256 in
|
||||
the database of the refresh tokens, defined in `/usr/share/vpn-router/sql/oidc.sql` with
|
||||
procedures and functions the module calls; a process loads the file at its first connection. The
|
||||
operations that revoke the refresh tokens of `/auth` also revoke them, and every use of a code or
|
||||
token checks the user and the client's allowed groups again.
|
||||
|
||||
Consequences: The package depends on `python3-oauthlib` and `python3-jwt`. The provider needs
|
||||
HTTPS, a public name and the web service. Clients are managed like service accounts, by the CLI,
|
||||
`/service-accounts` and the Router Manager; a group a client allows cannot be removed, and renaming
|
||||
it renames it in the clients. The signing key does not rotate; replacing it invalidates every ID
|
||||
token issued. Wrong client secrets lock the client like any account. The implicit and hybrid
|
||||
flows, dynamic client registration and public clients without a secret are not offered.
|
||||
|
||||
## ADR-38: Bash completions are files per command in bash-completion's completions directory
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: The `vpn-*` commands have many subcommands, options and names (users, groups, issuing
|
||||
CAs, templates, settings) that are hard to remember. bash-completion's README recommends its
|
||||
`completionsdir`, `/usr/share/bash-completion/completions` on Ubuntu, whose files it loads on the
|
||||
first Tab of their command, and calls the eagerly loaded `/etc/bash_completion.d` not
|
||||
recommended; it prefers the name `<command>.bash`, which noble's 2.11 and resolute's 2.16 both
|
||||
look up.
|
||||
|
||||
Decision: The package installs `<command>.bash` for each `vpn-*` command into
|
||||
`/usr/share/bash-completion/completions` and recommends `bash-completion`. The completions read
|
||||
the names they offer from the commands themselves (`--json list`) or the package's Python
|
||||
modules (the schema, the configuration, the PKI's templates and reasons), not from copies, and
|
||||
send errors to `/dev/null`, so a user who may not read them gets the fixed words only.
|
||||
|
||||
Consequences: Completions follow the code without being edited for each new setting or name. A
|
||||
Tab that offers names runs a command or Python, which takes a fraction of a second. The files
|
||||
are not conffiles; an administrator overrides one in `/etc/bash_completion.d` or the user's own
|
||||
completions directory.
|
||||
|
||||
## ADR-39: /logs shows the last lines of listed units and files, capped per request
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: The Router Manager should show the router's logs without SSH. The web service runs as
|
||||
root, so a path or a unit taken from a request could expose any file or journal on the router,
|
||||
and a log of gigabytes cannot be sent whole to a browser.
|
||||
|
||||
Decision: `/logs` serves the journal of a fixed list of units, the package's own and the services
|
||||
it runs, or the whole journal, read with `journalctl`, and the files `web.log_files` lists,
|
||||
which `vpn-config-edit` sets; a unit or a path outside the lists is refused. A request answers
|
||||
at most 1000 lines, 200 by default, and a file is read from its last MiB only. The answer is
|
||||
read at each request; nothing is followed or kept.
|
||||
|
||||
Consequences: Only administrators read logs, and only those listed. Showing a new file needs a
|
||||
configuration change, not code. Long histories stay with SSH and `journalctl`.
|
||||
|
||||
## ADR-40: Static web sites are served by the router's Caddy and defined in their own PostgreSQL database
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: The router should host static web sites, each on its own port, with its own TLS and host
|
||||
names told apart by SNI. Caddy already runs as the router's HTTPS proxy, configured by setup from a
|
||||
template, and serves several hosts on one port by SNI. The configuration file holds the router's
|
||||
settings; sites are many rows that change often and are edited by people who are not the router's
|
||||
administrators. A separate role per database keeps a feature's data apart from the refresh tokens
|
||||
and metrics.
|
||||
|
||||
Decision: A site is a row of the `sites` table in the database `vpn-router-sites`, created by the
|
||||
web service like `vpn-router` with its own role and password file, and changed through
|
||||
`vpnrouter_sites` only, by `/sites`, `vpn-router-site` and the Router Manager; the table and its
|
||||
procedures are in `/usr/share/vpn-router/sql/sites.sql`. `sites.enabled` and the upload limit stay
|
||||
in the configuration file. Setup reads the enabled sites at each run and adds a block per site to
|
||||
the Caddyfile, served from `/var/lib/vpn-router/sites/<name>`, with TLS none, Let's Encrypt, an
|
||||
issuing CA of `pki.issuing_cas` through Caddy's internal issuer, or a certificate and key kept
|
||||
with the site; it opens each site's port on the interfaces of its `listen`. Caddy runs while HTTPS
|
||||
is ready or a site is served. A site may not take a port of the router's own services or
|
||||
`wan.local_fqdn`; sites sharing a port need host names, the same `listen` and the same kind of
|
||||
TLS. A site's `editors` group may change its files besides the admins; renaming the group renames
|
||||
it in the sites, and a group a site has cannot be removed.
|
||||
|
||||
Consequences: Sites need no change of the configuration file and are backed up as a database dump
|
||||
with their folders. A site of Let's Encrypt needs its names to resolve to the router and port 80
|
||||
open, which setup opens on the external interface. While the database cannot be read, setup leaves
|
||||
the Caddyfile as it is. A site's folder is kept when the site is removed, unless asked otherwise.
|
||||
|
||||
## ADR-41: Site files are uploaded through the web service and WebDAV served by rclone
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: Content must reach a site's folder. Caddy's WebDAV handler is a third-party module that
|
||||
neither Ubuntu's nor Caddy's packages include; a custom build would leave apt. S3 servers that use
|
||||
a plain folder are rclone 1.65 and later (`serve s3`; noble has 1.60) and versitygw, which has no
|
||||
package. Azurite keeps blobs in its own format, which Caddy cannot serve. rclone 1.60 in the
|
||||
archive serves WebDAV with an auth proxy program and combines folders into one tree. The web
|
||||
service and the Router Manager pass small JSON bodies only.
|
||||
|
||||
Decision: The web service serves a site's files under `/sites/<name>/files/`: listing, download,
|
||||
upload of a raw body streamed to disk up to `sites.upload_max_mb`, extraction of a zip archive
|
||||
that refuses absolute paths, `..`, links and oversize content before writing, folders, renaming
|
||||
and removal, every path resolved inside the site's folder. While `sites.webdav` is true,
|
||||
`vpn-router-webdav.service` runs `rclone serve webdav` on `127.0.0.1:7446` behind Caddy at
|
||||
`https://<wan.local_fqdn>:<sites.webdav_port>`; its auth proxy, `webdav-auth`, binds as the user
|
||||
and gives an admin all sites and any other user the sites of its editors groups, one folder each.
|
||||
rclone and PowerDNS are installed on demand from the archive by `vpn-router-install@<feature>`,
|
||||
like Caddy.
|
||||
|
||||
Consequences: Uploads and WebDAV use the directory's passwords, a TOTP code appended for an
|
||||
enrolled user. rclone writes a WebDAV upload to its cache first and to the folder a few seconds
|
||||
later. S3 and Azure Blob clients are not served.
|
||||
|
||||
## ADR-42: The name server is PowerDNS with its PostgreSQL backend
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: Sites get subdomains of a selected domain, delegated to the router, without a registrar
|
||||
change per site. Ubuntu has BIND, Knot and PowerDNS; only PowerDNS's gpgsql backend reads records
|
||||
from PostgreSQL, so changes are rows, without zone files or reloads. systemd-resolved's stub holds
|
||||
127.0.0.53 and 127.0.0.54, and the remote access clients' resolver answered on the external
|
||||
addresses, the addresses an authoritative server needs. The clients reach the internal address
|
||||
just as well, through the tunnel when it is in `local.cidrs` (ADR-43).
|
||||
|
||||
Decision: While `dns.enabled` is true, setup runs `pdns.service` with
|
||||
`/etc/powerdns/pdns.d/vpn-router.conf`, written before the packages are installed, on the
|
||||
addresses of `dns.listen`, with the gpgsql backend on the database `vpn-router-dns` and PowerDNS's
|
||||
own schema. It writes one zone, `dns.zone`, through the procedure `dns_apply` of
|
||||
`/usr/share/vpn-router/sql/dns.sql`: SOA, NS, A and AAAA records of `dns.addresses` for the apex,
|
||||
the name server and every site host name in the zone, and manual records administrators add; the
|
||||
serial grows only when the records change. The remote access clients' resolver answers on the
|
||||
addresses of the new setting `p2s.dns_listen`, by default the internal one; a `dns.listen` that
|
||||
shares an interface with it is refused while P2S or OpenVPN is enabled.
|
||||
|
||||
Consequences: The zone follows the sites at each setup run. One zone only, without DNSSEC or
|
||||
transfers; the delegation is made at the parent zone by hand.
|
||||
|
||||
## ADR-43: The remote access clients' resolver is dnsmasq, not systemd-resolved
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: IKEv2 and OpenVPN clients were given the router as their DNS server, answered by
|
||||
systemd-resolved through a `DNSStubListenerExtra` drop-in. resolved synthesizes records for the
|
||||
machine's own host name, all its addresses, the Docker bridge and the tunnel addresses included,
|
||||
and for `_gateway` and `localhost`, on every stub, and systemd 255 has no setting that turns this
|
||||
off. Its extra stub also took port 53 of the addresses PowerDNS needs. dnsmasq-base is in main,
|
||||
a single binary without a service of its own, and forwards to one upstream server.
|
||||
|
||||
Decision: `vpn-router-resolver.service` runs dnsmasq, bound to the addresses of `p2s.dns_listen`
|
||||
only (`bind-interfaces`), forwarding to resolved's stub on 127.0.0.53, without `/etc/hosts` and
|
||||
without forwarding names that have no dot (`domain-needed`). resolved keeps its loopback stubs
|
||||
only and stays the router's own resolver, with the upstream servers of the platform. Setup writes
|
||||
`/etc/vpn-router/dnsmasq.conf` while IKEv2, OpenVPN or WireGuard runs, opens port 53 there for
|
||||
their client networks, and removes the earlier drop-in.
|
||||
|
||||
Consequences: Clients resolve the names resolved resolves, Azure's internal names included, but
|
||||
not the router's synthesized ones. The package depends on dnsmasq-base. PowerDNS and the clients'
|
||||
resolver share no address.
|
||||
@@ -1,3 +1,525 @@
|
||||
vpn-router (0.31.3-1) unstable; urgency=medium
|
||||
|
||||
fix: WireGuard clients may use the router's DNS resolver, which runs while WireGuard does, and /remote-access names its addresses for their configuration.
|
||||
|
||||
* The firewall opens port 53 of the remote access clients' resolver for
|
||||
the WireGuard network, as it does for the IKEv2 and OpenVPN pools.
|
||||
* The resolver runs while WireGuard is the only remote access enabled.
|
||||
* GET /remote-access answers the resolver's addresses as
|
||||
wireguard_server.dns.
|
||||
* dns.listen may not share an interface with p2s.dns_listen while
|
||||
WireGuard is enabled either.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 12:00:00 +0100
|
||||
|
||||
vpn-router (0.31.2-1) unstable; urgency=medium
|
||||
|
||||
fix: dnsmasq answers the remote access clients' DNS queries, so they no longer get the router's own host name with all its addresses from systemd-resolved.
|
||||
|
||||
* vpn-router-resolver.service runs dnsmasq on the addresses of
|
||||
p2s.dns_listen, forwarding to systemd-resolved's stub on 127.0.0.53;
|
||||
names without a dot are not forwarded. The package depends on
|
||||
dnsmasq-base.
|
||||
* systemd-resolved keeps its loopback stubs only; setup removes the
|
||||
drop-in that made it listen for the clients.
|
||||
* /logs shows the journal of vpn-router-resolver, vpn-router-webdav and
|
||||
pdns.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 11:00:00 +0100
|
||||
|
||||
vpn-router (0.31.1-1) unstable; urgency=medium
|
||||
|
||||
fix: the remote access clients' resolver answers on the internal address by default, chosen by p2s.dns_listen, so the name server can take port 53 of the external addresses.
|
||||
|
||||
* The new setting p2s.dns_listen chooses the addresses of the resolver the
|
||||
road-warrior and OpenVPN clients are given: internal, the default,
|
||||
external or both. The firewall opens port 53 for the client pools on
|
||||
those addresses only.
|
||||
* dns.listen is refused only when it shares an interface with
|
||||
p2s.dns_listen while IKEv2 or OpenVPN is enabled.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 23:00:00 +0100
|
||||
|
||||
vpn-router (0.31.0-1) unstable; urgency=medium
|
||||
|
||||
feature: Caddy serves static web sites from folders on the router, defined in their own database, with files uploaded through the web service or WebDAV, and an optional PowerDNS name server answers for a zone of the sites.
|
||||
|
||||
* The new database vpn-router-sites holds the web sites: host names,
|
||||
port, interfaces, TLS (none, Let's Encrypt, an issuing CA or a
|
||||
certificate and key), index file, single-page fallback and the group
|
||||
whose members change the files. sites.enabled serves them from
|
||||
/var/lib/vpn-router/sites/<name>; setup writes their blocks into the
|
||||
Caddyfile and opens their ports, and Caddy also runs without HTTPS of
|
||||
the router while a site is served.
|
||||
* vpn-router-site lists, shows, adds, changes and removes the sites, and
|
||||
bash completes it.
|
||||
* /sites manages the sites and the settings of the sites section, and
|
||||
under /sites/<name>/files/ lists, downloads, uploads, extracts zip
|
||||
archives into, creates, renames and removes the files of a site, up to
|
||||
sites.upload_max_mb; members of the site's editors group may use its
|
||||
files.
|
||||
* sites.webdav runs rclone's WebDAV server of the sites' folders behind
|
||||
Caddy on sites.webdav_port, signing users in with the directory and
|
||||
showing each the sites it may change.
|
||||
* dns.enabled runs PowerDNS with its PostgreSQL backend in the new
|
||||
database vpn-router-dns for dns.zone on the addresses of dns.listen:
|
||||
SOA, NS, the A and AAAA records of dns.addresses for the zone, the
|
||||
name server and the sites' host names in the zone, and manual records
|
||||
added with /dns.
|
||||
* rclone and PowerDNS are installed from the Ubuntu archive by
|
||||
vpn-router-install@<feature>.service when their feature is on.
|
||||
* Backups hold the sites' folders and the dumps of both databases; a
|
||||
restore puts them back. Purge removes the databases, their roles and
|
||||
password files, and the settings of PowerDNS.
|
||||
* Renaming a group renames it in the sites; a group a site has cannot be
|
||||
removed.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 21:00:00 +0100
|
||||
|
||||
vpn-router (0.30.0-1) unstable; urgency=medium
|
||||
|
||||
feature: the web service serves the last lines of the journal of the router's units and of the log files web.log_files lists, at /logs.
|
||||
|
||||
* GET /logs lists the units whose journal and the files whose lines it
|
||||
serves; GET /logs/journal?unit=&lines= and /logs/file?path=&lines=
|
||||
answer the last lines, 200 by default and at most 1000, a file read
|
||||
from its last MiB. Units and files outside the lists are refused.
|
||||
* The new setting web.log_files lists the absolute paths of the log
|
||||
files, at most 32.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 19:00:00 +0100
|
||||
|
||||
vpn-router (0.29.0-1) unstable; urgency=medium
|
||||
|
||||
feature: vpn-config-edit set and remove take --restart, which applies the change with vpn-router-setup and waits for it.
|
||||
|
||||
* vpn-config-edit set --restart and remove --restart restart
|
||||
vpn-router-setup after writing the configuration and wait until it has
|
||||
applied the change; a failed run exits with status 1 and points to its
|
||||
journal. Bash completes --restart.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 17:00:00 +0100
|
||||
|
||||
vpn-router (0.28.0-1) unstable; urgency=medium
|
||||
|
||||
feature: bash completes the vpn-* commands, their options and the names they take.
|
||||
|
||||
* Bash completes the commands, options and arguments of vpn-router-user,
|
||||
vpn-router-group, vpn-router-token, vpn-config-edit, vpn-router-backup,
|
||||
vpn-router-ca and vpn-router-caddy, from
|
||||
/usr/share/bash-completion/completions; for root also the users,
|
||||
groups, issuing CAs, templates, settings and site-to-site connections.
|
||||
* The package recommends bash-completion.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 16:00:00 +0100
|
||||
|
||||
vpn-router (0.27.0-1) unstable; urgency=medium
|
||||
|
||||
feature: the web service is an OpenID Provider, which signs the directory's users in to other systems, the clients being service accounts with redirect URIs.
|
||||
|
||||
* oidc.enabled turns on the OpenID Provider at the issuer
|
||||
https://<wan.local_fqdn>:<https.web_port>, with discovery, JWKS,
|
||||
authorize, token, userinfo and RP-initiated logout under /oidc/,
|
||||
answered only through Caddy. The authorization code flow with PKCE
|
||||
S256 and refresh tokens are offered; ID tokens are RS256, signed with
|
||||
/etc/vpn-router/oidc.key, which the web service makes on its first run.
|
||||
* Users sign in on the provider's page with the password and the TOTP
|
||||
code or with a passkey; the session lasts 8 hours. The scopes openid,
|
||||
profile, email and groups give sub (the entry's entryUUID), the names,
|
||||
the e-mail address and the user's groups.
|
||||
* A service account with redirect URIs is a client: its name is the
|
||||
client ID and its password the secret. Its redirect URIs,
|
||||
post-logout redirect URIs and allowed groups are set with
|
||||
vpn-router-user add/update --redirect-uri, --post-logout-redirect-uri
|
||||
and --allowed-group, and in /service-accounts as redirect_uris,
|
||||
post_logout_redirect_uris and allowed_groups; GET /service-accounts
|
||||
also answers the issuer. vpn-router-user list shows them.
|
||||
* Changing a user's password, disabling, unlocking or removing the user
|
||||
also revokes the user's codes, tokens and sessions of the provider.
|
||||
* Renaming a group renames it in the clients that allow it, and a group
|
||||
a client allows cannot be removed.
|
||||
* The package depends on python3-oauthlib and python3-jwt.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 14:00:00 +0100
|
||||
|
||||
vpn-router (0.26.2-1) unstable; urgency=medium
|
||||
|
||||
fix: a service account may be named by a GUID in lowercase, as the Router Manager proposes for a new one.
|
||||
|
||||
* vpn-router-user add --service and POST /service-accounts take a GUID
|
||||
in lowercase as the name, besides a user name; users and groups keep
|
||||
the rules of user names.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 10:00:00 +0100
|
||||
|
||||
vpn-router (0.26.1-1) unstable; urgency=medium
|
||||
|
||||
fix: the package installs vpnrouter_passkeys, without which the web service and vpn-router-user do not start.
|
||||
|
||||
* /usr/lib/vpn-router/vpnrouter_passkeys.py is part of the package.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 08:45:00 +0100
|
||||
|
||||
vpn-router (0.26.0-1) unstable; urgency=medium
|
||||
|
||||
feature: users sign in to the Router Manager with passkeys, and console login needs a TOTP token, so a user without one is refused the shell, SSH and sudo.
|
||||
|
||||
* Console login is switched on only for a user enrolled for TOTP. A user
|
||||
whose console login is on without a token is refused POST /shell, the
|
||||
shell's WebSocket, SSH and sudo; an open shell ends within a minute of
|
||||
the token's removal or of console login being switched off, and
|
||||
removing the token ends the sessions of the user's own POSIX account.
|
||||
* POST /auth/passkey and /auth/passkey/complete sign a user in with a
|
||||
passkey of wan.local_fqdn, giving the tokens of /auth; POST /auth also
|
||||
answers the user's name.
|
||||
* POST, PUT /account/passkeys and DELETE /account/passkeys/<id> add, with
|
||||
the current password and code, and remove the user's passkeys, at most
|
||||
10, kept in the new attribute vpnRouterPasskey; DELETE
|
||||
/users/<name>/passkeys and vpn-router-user passkeys-remove remove all
|
||||
passkeys of a user.
|
||||
* Users have passkeys, [{"id", "label", "created"}], in /users and
|
||||
/account; vpn-router-user list shows their number and a console login
|
||||
refused for want of a TOTP token.
|
||||
* The package depends on python3-fido2.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 03 Nov 2026 08:00:00 +0100
|
||||
|
||||
vpn-router (0.25.4-1) unstable; urgency=medium
|
||||
|
||||
fix: a shell opens without the password and code again while the user's last use of a shell is within web.shell_auth_timeout minutes.
|
||||
|
||||
* New setting web.shell_auth_timeout, 15 minutes by default, 0 to 1440:
|
||||
POST /shell without a password gives a ticket while the user's last
|
||||
sign-in of POST /shell or keystroke in a shell is within it, and a
|
||||
null ticket otherwise; 0 asks for the password and code every time.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 03 Nov 2026 06:00:00 +0100
|
||||
|
||||
vpn-router (0.25.3-1) unstable; urgency=medium
|
||||
|
||||
fix: /config gives the state of caddy.service with the installed Caddy, for the Router Manager's Web page.
|
||||
|
||||
* caddy of /config is {"version", "source", "service"}.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 03 Nov 2026 05:00:00 +0100
|
||||
|
||||
vpn-router (0.25.2-1) unstable; urgency=medium
|
||||
|
||||
fix: wireguard.address must be the router's own address in its network, not the network or broadcast address, from which the peers' addresses are told apart.
|
||||
|
||||
* A wireguard.address that is the network or the broadcast address of a
|
||||
network larger than /31, such as 192.168.232.0/24, is refused with the
|
||||
first address of the network as the example.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 03 Nov 2026 02:30:00 +0100
|
||||
|
||||
vpn-router (0.25.1-1) unstable; urgency=medium
|
||||
|
||||
fix: /remote-access gives the router's WireGuard public key, endpoint and allowed IPs, which a peer's configuration needs.
|
||||
|
||||
* GET /remote-access answers wireguard_server: {"public_key",
|
||||
"endpoint", "allowed_ips"}.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 03 Nov 2026 01:30:00 +0100
|
||||
|
||||
vpn-router (0.25.0-1) unstable; urgency=medium
|
||||
|
||||
feature: a VPN user has WireGuard peers, each a device with its public key and IPs, kept in the directory and written into wg0.conf by setup.
|
||||
|
||||
* PUT /users/<name> takes wireguard_peers, [{"public_key", "ips"}],
|
||||
which replaces the user's peers; keys are unique and IPs may not
|
||||
overlap another peer's or cover the router's WireGuard address.
|
||||
* GET /users answers each user's wireguard_peers and the WireGuard
|
||||
endpoint's enabled and address.
|
||||
* Setup writes a [Peer] section into wg0.conf for each peer of a user
|
||||
who is not disabled, in a marked block, and keeps the peers added by
|
||||
hand; changing a user's peers, disabling, enabling or removing a user
|
||||
with peers applies at once.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 03 Nov 2026 00:00:00 +0100
|
||||
|
||||
vpn-router (0.24.1-1) unstable; urgency=medium
|
||||
|
||||
fix: the status gives the number of connected OpenVPN clients at most 5 seconds old instead of up to a minute.
|
||||
|
||||
* The OpenVPN server writes its status file every 5 seconds, so a
|
||||
refresh of the status shows clients that connected or left since.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 23:30:00 +0100
|
||||
|
||||
vpn-router (0.24.0-1) unstable; urgency=medium
|
||||
|
||||
feature: /remote-access of the web service shows and changes the settings of IKEv2 road-warrior, OpenVPN and WireGuard access and whether each runs, for the Router Manager's Remote access page.
|
||||
|
||||
* GET /remote-access answers the p2s, openvpn and wireguard sections,
|
||||
which of them setup runs, and wan.local_fqdn; PUT
|
||||
/remote-access/<system> changes one section and restarts setup.
|
||||
* p2s.enabled and openvpn.enabled need their address_pool, and
|
||||
wireguard.enabled its address.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 23:00:00 +0100
|
||||
|
||||
vpn-router (0.23.2-1) unstable; urgency=medium
|
||||
|
||||
fix: the shell of a user's own POSIX account opens on the first setup that sets up console login, and the journal records each refused shell with the reason.
|
||||
|
||||
* Setup restarts vpn-router-web when it adds ldap to the name service
|
||||
switch, so the web service finds the POSIX accounts of the directory.
|
||||
* The journal of vpn-router-web records each refused ticket and shell,
|
||||
with the user, the address and the reason.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 22:00:00 +0100
|
||||
|
||||
vpn-router (0.23.1-1) unstable; urgency=medium
|
||||
|
||||
fix: the shell runs as the console account of each member of admins, a local account or the user's own POSIX account in the directory, which also signs in with SSH and sudo, instead of one web.shell_user.
|
||||
|
||||
* vpn-router-user console <name> --posix | --account LOCAL | --off, and
|
||||
PUT /users/<name>/console with a token of vpn-router-token, switch a
|
||||
member of admins' console login; leaving admins switches it off.
|
||||
* The user's own POSIX account is served by nslcd from the directory,
|
||||
with a uid from 20000, the group vpn-router-console, which may use
|
||||
sudo, and a home created at the first login; its password is the
|
||||
directory password, followed by the code once enrolled for TOTP.
|
||||
* ssh.ldap_login (false) lets sshd accept that password.
|
||||
* POST /shell and the shell need the user's console login; web.shell_user
|
||||
is gone.
|
||||
* Depends on nslcd, libnss-ldapd, libpam-ldapd and sudo.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 21:00:00 +0100
|
||||
|
||||
vpn-router (0.23.0-1) unstable; urgency=medium
|
||||
|
||||
feature: web.shell lets /shell of the web service open a login shell of web.shell_user over a WebSocket for the Router Manager's Shell page, after the user signs in again.
|
||||
|
||||
* web.shell (false), web.shell_user and web.shell_idle_minutes (15).
|
||||
* POST /shell takes the password and the TOTP code and answers a
|
||||
one-time ticket; GET /shell with it is a WebSocket carrying the
|
||||
terminal, closed after web.shell_idle_minutes without a keystroke.
|
||||
* The journal of vpn-router-web records each shell opened and ended.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 20:00:00 +0100
|
||||
|
||||
vpn-router (0.22.1-1) unstable; urgency=medium
|
||||
|
||||
fix: the status gives the number of site-to-site connections set up and established, not each connection by name.
|
||||
|
||||
* site_to_site of the status document is {"connections", "established"},
|
||||
and the status page shows "Site to site: N of M established".
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 18:00:00 +0100
|
||||
|
||||
vpn-router (0.22.0-1) unstable; urgency=medium
|
||||
|
||||
feature: pki.extended_key_usages adds custom extended key usages by their OIDs, which templates give by name, and a template's policies put certificate policy OIDs into its certificates.
|
||||
|
||||
* pki.extended_key_usages maps a name to the OID of a custom extended
|
||||
key usage; a template's extended_key_usage takes the built-in and the
|
||||
custom names.
|
||||
* The template option policies lists certificate policy OIDs, written
|
||||
into a Certificate Policies extension.
|
||||
* GET /ca answers extended_key_usages, each usage with its OID and
|
||||
whether it is built in.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 14:00:00 +0100
|
||||
|
||||
vpn-router (0.21.5-1) unstable; urgency=medium
|
||||
|
||||
fix: /status/full of the status web service gives an authenticated client the whole status, whatever web.status_content says.
|
||||
|
||||
* /status/full takes the token of /config and answers the status
|
||||
document as web.status_content full shows it.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 12:00:00 +0100
|
||||
|
||||
vpn-router (0.21.4-1) unstable; urgency=medium
|
||||
|
||||
fix: vpn-router-setup, vpn-router-ca and /ca read a CA's key and CRL from the directory each as itself, instead of failing on a CA with a CRL.
|
||||
|
||||
* A CA's CRL was read as its key and its key as its CRL, so every
|
||||
operation on a CA with a CRL failed with "Could not deserialize key
|
||||
data".
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 10:00:00 +0100
|
||||
|
||||
vpn-router (0.21.3-1) unstable; urgency=medium
|
||||
|
||||
fix: the PKI is kept in ou=pki of the router's directory instead of files under /etc/vpn-router/pki, and the router's site-to-site certificate names only the identities its connections present, and /ca/bundle gives the certificate bundle of a CA.
|
||||
|
||||
* The CAs with their certificates, keys, CSRs and CRLs, and every
|
||||
certificate issued with its revocation, are entries of the
|
||||
package's schema in ou=pki; a user's certificate is also the
|
||||
userCertificate of the user's entry, and only root reads a key.
|
||||
* slapcat puts the PKI into every backup with the users.
|
||||
* A root CA supplied as /etc/vpn-router/pki/root/cert.pem and key.pem
|
||||
before the first setup is imported into the directory, and the files
|
||||
are removed; vpn-router-ca root import-cert is gone.
|
||||
* Setup provisions the directory before the configuration; without it,
|
||||
the PKI and its files are left as they are.
|
||||
* The router's site-to-site certificate names wan.local_fqdn and the
|
||||
public or internal address a connection with auth certificate uses
|
||||
as its identity, not every address of the router, and is issued
|
||||
again when those names change.
|
||||
* GET /ca/bundle/<name> of the status web service answers the PEM
|
||||
certificates a client trusts a CA with: the root alone, or an issuing
|
||||
CA followed by the root.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 01 Nov 2026 10:00:00 +0100
|
||||
|
||||
vpn-router (0.21.2-1) unstable; urgency=medium
|
||||
|
||||
fix: pki.root_key_warning silences the warning of vpn-router-ca status and /ca about the root key on the router.
|
||||
|
||||
* pki.root_key_warning, true by default, set to false silences the
|
||||
warning that the root key is on the router.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 31 Oct 2026 10:00:00 +0100
|
||||
|
||||
vpn-router (0.21.1-1) unstable; urgency=medium
|
||||
|
||||
fix: vpn-router-ca status and /ca warn about a CRL the router signs only once vpn-router-pki.timer has not renewed it in time.
|
||||
|
||||
* A CRL whose CA key is on the router is reported once past half of its
|
||||
validity, when the timer should have signed it again; a root CRL
|
||||
signed offline is still reported 30 days before it expires.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 30 Oct 2026 10:00:00 +0100
|
||||
|
||||
vpn-router (0.21.0-1) unstable; urgency=medium
|
||||
|
||||
feature: the router has a two-level CA with an optional offline root, issuing CAs for P2S, site-to-site and general use, templates, published certificates and CRLs and an ACME server, site-to-site connections authenticate with certificates, and setup installs Caddy from Ubuntu or Caddy's repository.
|
||||
|
||||
* The root CA signs the issuing CAs p2s, which issues the router's VPN
|
||||
server certificate and the users' certificates, s2s, which issues
|
||||
the site-to-site certificates, and the general-purpose CAs of
|
||||
pki.issuing_cas; road-warrior and OpenVPN accept certificates of p2s
|
||||
only.
|
||||
* The pki section of vpn-router.json sets the names, lifetimes and
|
||||
key type of the CAs, the validity of the CRLs, where certificates
|
||||
and CRLs are published, the general-purpose CAs and the certificate
|
||||
templates; p2s.ca_name and the vpn-router/p2s_ca_name question are
|
||||
replaced by pki.root_name and vpn-router/pki_root_name.
|
||||
* Without the root key on the router, new issuing CAs wait with a CSR
|
||||
for an offline signature; vpn-router-ca exports, removes and imports
|
||||
the root key, imports the signed certificates and a root CRL, issues
|
||||
certificates from a CSR or into a PFX file, lists and revokes them.
|
||||
* Certificates name their issuer's certificate and CRL at the
|
||||
endpoints of pki.publish: the status port over HTTP, or Caddy on port
|
||||
80, on the HTTPS status port or on port 443; the CRLs are signed
|
||||
again at every revocation and by vpn-router-pki.timer.
|
||||
* A general-purpose CA with acme backs an ACME server of Caddy.
|
||||
* The router renews its own certificates before they expire.
|
||||
* A site-to-site connection with auth certificate authenticates with
|
||||
the router's site-to-site certificate and trusts the s2s CA or the
|
||||
connection's remote_ca.
|
||||
* /ca of the status web service shows and changes the PKI; /pki/
|
||||
serves the published certificates and CRLs; /config shows the
|
||||
installed Caddy and its source; request bodies may be 64 KiB.
|
||||
* vpn-router no longer depends on caddy: vpn-router-setup installs it
|
||||
from https.caddy_source, the Ubuntu archive or Caddy's repository,
|
||||
and vpn-router-caddy use-upstream switches an installed router to
|
||||
Caddy's repository for good.
|
||||
* The package depends on python3-cryptography instead of openssl, and
|
||||
simple-ca is gone.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Thu, 29 Oct 2026 10:00:00 +0100
|
||||
|
||||
vpn-router (0.20.0-1) unstable; urgency=medium
|
||||
|
||||
feature: the router makes scheduled backups to a local folder, copied to the cloud with rclone, or to a Proxmox Backup Server, and vpn-router-backup restores them.
|
||||
|
||||
* The backup section of vpn-router.json switches on backups and sets
|
||||
the schedule (hourly, daily or weekly), the target (local or pbs),
|
||||
the folder, the retention of local archives, whether the metrics
|
||||
database is included, the rclone remote and mode, and the Proxmox
|
||||
Backup Server repository, token secret, fingerprint, namespace and
|
||||
key file.
|
||||
* A backup holds /etc/vpn-router, /etc/wireguard, the ufw rules, the
|
||||
Router Manager's settings, PSK files outside /etc/vpn-router, the
|
||||
user directory as slapcat writes it and, optionally, the database as
|
||||
pg_dump writes it, copied while the configuration and the users are
|
||||
locked.
|
||||
* Local backups are tar archives compressed with zstd. Archives the
|
||||
retention settings no longer keep are deleted by the rules of Proxmox
|
||||
Backup Server's prune, and rclone can sync or copy the archives to a
|
||||
remote of /etc/vpn-router/rclone.conf.
|
||||
* Backups to a Proxmox Backup Server are made with
|
||||
proxmox-backup-client-static, which the package suggests; the server
|
||||
keeps them.
|
||||
* vpn-router-backup runs a backup, lists the backups and restores a
|
||||
local archive or a snapshot; vpn-router-backup.timer runs it on the
|
||||
schedule.
|
||||
* /backup of the status web service shows and changes the backup
|
||||
settings, shows the last run, the local archives and the next run,
|
||||
and starts a backup.
|
||||
* The package depends on zstd and suggests rclone and
|
||||
proxmox-backup-client-static.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Wed, 28 Oct 2026 10:00:00 +0100
|
||||
|
||||
vpn-router (0.19.1-3) unstable; urgency=medium
|
||||
|
||||
update: ADR-24 names Ubuntu's apt-daily timer, not a cron job, as what updates the package lists.
|
||||
|
||||
* ADR-24 says that Ubuntu's apt-daily timer updates the package lists
|
||||
twice a day, and that apt-daily or unattended-upgrades may hold the
|
||||
dpkg lock the upgrade waits for.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 10:00:00 +0100
|
||||
|
||||
vpn-router (0.19.1-2) unstable; urgency=medium
|
||||
|
||||
update: ADR-24 says that the Router Manager checks for updates on demand.
|
||||
|
||||
* ADR-24 describes Check update of the Router Manager, which updates
|
||||
apt's package lists in the transient unit vpn-router-manager-check.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 09:00:00 +0100
|
||||
|
||||
vpn-router (0.19.1-1) unstable; urgency=medium
|
||||
|
||||
fix: /metrics/history gives the availability of each site-to-site connection as the percentage of time it was up in slots of the view.
|
||||
|
||||
* site_to_site_up of /metrics/history divides the view into slots of 5
|
||||
minutes for an hour, 1 hour for a day, 1 day for a month and 1 month
|
||||
for a year, and gives for each slot and connection the percentage of
|
||||
the samples in which the connection was up.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 21:00:00 +0100
|
||||
|
||||
vpn-router (0.19.0-1) unstable; urgency=medium
|
||||
|
||||
feature: service accounts have a display name and a description, which the router sets for its internal ones.
|
||||
|
||||
* vpnRouterAccount of the package's schema also takes displayName and
|
||||
description; vpn-router-setup updates the schema of an existing
|
||||
directory.
|
||||
* /service-accounts shows display_name and description, takes them when
|
||||
adding a service account and changes them with PUT; "" removes one.
|
||||
/users shows them as null.
|
||||
* vpn-router-user add --service and update take --display-name and
|
||||
--description.
|
||||
* vpn-router-setup gives the metrics service's internal account the
|
||||
display name Metrics and its description, and sets them again when
|
||||
they differ.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 20:00:00 +0100
|
||||
|
||||
vpn-router (0.18.3-1) unstable; urgency=medium
|
||||
|
||||
fix: internal service accounts are the router's own, of which the metrics service's is the one at present, and the documentation says what a service account is.
|
||||
|
||||
* /service-accounts, /groups and the internal field of the answers
|
||||
take every internal service account, the accounts the router creates
|
||||
and manages for its own services, instead of only the metrics
|
||||
service's; the 403 answers say that the router manages them.
|
||||
* The README, the ADR and the users module describe a service account
|
||||
as the account another system, such as an OpenTelemetry Collector
|
||||
reading /metrics, signs in with, and the internal ones as managed by
|
||||
the router.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 19:00:00 +0100
|
||||
|
||||
vpn-router (0.18.2-1) unstable; urgency=medium
|
||||
|
||||
fix: service accounts are managed at /service-accounts, not /users, and the internal one of the metrics service is only listed.
|
||||
|
||||
@@ -51,6 +51,7 @@ db_input high vpn-router/remote_addrs || true
|
||||
db_input high vpn-router/remote_id || true
|
||||
db_input high vpn-router/remote_cidrs || true
|
||||
db_input high vpn-router/psk || true
|
||||
db_input high vpn-router/pki_root_name || true
|
||||
db_input high vpn-router/p2s_enabled || true
|
||||
db_input high vpn-router/wg_enabled || true
|
||||
db_go || true
|
||||
@@ -58,7 +59,6 @@ db_go || true
|
||||
db_get vpn-router/p2s_enabled
|
||||
if [ "$RET" = "true" ]; then
|
||||
db_input high vpn-router/p2s_address_pool || true
|
||||
db_input high vpn-router/p2s_ca_name || true
|
||||
db_go || true
|
||||
fi
|
||||
|
||||
|
||||
@@ -17,29 +17,52 @@ Depends: ${misc:Depends},
|
||||
ufw,
|
||||
debconf,
|
||||
ucf,
|
||||
openssl,
|
||||
python3,
|
||||
python3-cryptography,
|
||||
python3-jinja2,
|
||||
python3-ldap,
|
||||
python3-pyotp,
|
||||
python3-fido2,
|
||||
python3-jwt,
|
||||
python3-oauthlib,
|
||||
python3-psycopg,
|
||||
python3-psutil,
|
||||
python3-prometheus-client,
|
||||
postgresql,
|
||||
slapd,
|
||||
nslcd,
|
||||
libnss-ldapd,
|
||||
libpam-ldapd,
|
||||
sudo,
|
||||
openvpn,
|
||||
openvpn-auth-ldap,
|
||||
iproute2,
|
||||
systemd-resolved,
|
||||
caddy
|
||||
dnsmasq-base,
|
||||
zstd
|
||||
Recommends: bash-completion
|
||||
Suggests: caddy,
|
||||
rclone,
|
||||
pdns-server,
|
||||
pdns-backend-pgsql,
|
||||
proxmox-backup-client-static
|
||||
Description: Linux VPN router with IPSec, road-warrior, OpenVPN and WireGuard support
|
||||
Configures a Linux host or virtual machine as a VPN router: site-to-site
|
||||
IKEv2 IPSec (strongSwan swanctl), optional road-warrior access using EAP-TLS,
|
||||
optional OpenVPN with a certificate and a password per user, kept in a local
|
||||
LDAP directory, optional WireGuard, and the routing, NAT, MSS clamping and
|
||||
firewall rules a router needs. It keeps metrics of the router in PostgreSQL
|
||||
and serves them for an OpenTelemetry Collector. Nothing in the package is
|
||||
specific to a cloud provider.
|
||||
IKEv2 IPSec (strongSwan swanctl) with pre-shared keys or certificates,
|
||||
optional road-warrior access using EAP-TLS, optional OpenVPN with a
|
||||
certificate and a password per user, kept in a local LDAP directory, optional
|
||||
WireGuard, and the routing, NAT, MSS clamping and firewall rules a router
|
||||
needs. Its two-level CA, whose root may be kept offline, has issuing CAs for
|
||||
the VPN clients, for site-to-site and for general use, and publishes their
|
||||
certificates and CRLs. Caddy, installed by the package from the Ubuntu
|
||||
archive or from Caddy's repository, serves HTTPS and static web sites, whose
|
||||
files are uploaded through the web service or WebDAV, an optional PowerDNS
|
||||
name server answers for a zone of the sites, and an optional OpenID
|
||||
Provider signs the directory's users in to other systems. It keeps metrics of the
|
||||
router in PostgreSQL and serves them for an OpenTelemetry Collector, and
|
||||
makes scheduled backups to a local folder, copied to the cloud with rclone,
|
||||
or to a Proxmox Backup Server. Nothing in the package is specific to a cloud
|
||||
provider.
|
||||
.
|
||||
Settings live in /etc/vpn-router/vpn-router.json, which is created on first
|
||||
install and owned by the administrator afterwards. Editing it and restarting
|
||||
|
||||
@@ -2,3 +2,4 @@ etc/vpn-router
|
||||
usr/lib/vpn-router
|
||||
usr/lib/vpn-router/vpnrouter_platforms
|
||||
usr/share/vpn-router/templates
|
||||
var/lib/vpn-router/sites
|
||||
@@ -4,15 +4,27 @@ 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_serve.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_users.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_passkeys.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_auth.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_oidc.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_metrics.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_status.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_backup.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_pki.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_sites.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_dns.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/install-packages usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/webdav-auth usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/openvpn-same-user usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/openvpn-issuer usr/lib/vpn-router/
|
||||
src/usr/sbin/vpn-router-user usr/sbin/
|
||||
src/usr/sbin/vpn-router-group usr/sbin/
|
||||
src/usr/sbin/vpn-router-token usr/sbin/
|
||||
src/usr/sbin/vpn-config-edit usr/sbin/
|
||||
src/usr/lib/vpn-router/simple-ca usr/lib/vpn-router/
|
||||
src/usr/sbin/vpn-router-backup usr/sbin/
|
||||
src/usr/sbin/vpn-router-ca usr/sbin/
|
||||
src/usr/sbin/vpn-router-caddy usr/sbin/
|
||||
src/usr/sbin/vpn-router-site usr/sbin/
|
||||
src/usr/lib/vpn-router/configure usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/docker-rules usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/setup usr/lib/vpn-router/
|
||||
@@ -23,5 +35,7 @@ src/usr/lib/vpn-router/wait-for-libpq usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/generate-config usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_platforms/*.py usr/lib/vpn-router/vpnrouter_platforms/
|
||||
src/usr/share/vpn-router/templates/* usr/share/vpn-router/templates/
|
||||
src/usr/share/vpn-router/sql/* usr/share/vpn-router/sql/
|
||||
src/usr/share/bash-completion/completions/*.bash usr/share/bash-completion/completions/
|
||||
src/usr/lib/systemd/system/caddy.service.d/vpn-router.conf usr/lib/systemd/system/caddy.service.d/
|
||||
src/usr/share/doc/vpn-router/vpn-router.json.example usr/share/doc/vpn-router/
|
||||
@@ -31,7 +31,7 @@ case "$1" in
|
||||
answer vpn-router/psk; VPN_ROUTER_PSK="$ANSWER"
|
||||
answer vpn-router/p2s_enabled; VPN_ROUTER_P2S_ENABLED="$ANSWER"
|
||||
answer vpn-router/p2s_address_pool; VPN_ROUTER_P2S_ADDRESS_POOL="$ANSWER"
|
||||
answer vpn-router/p2s_ca_name; VPN_ROUTER_P2S_CA_NAME="$ANSWER"
|
||||
answer vpn-router/pki_root_name; VPN_ROUTER_PKI_ROOT_NAME="$ANSWER"
|
||||
answer vpn-router/wg_enabled; VPN_ROUTER_WG_ENABLED="$ANSWER"
|
||||
answer vpn-router/wg_address; VPN_ROUTER_WG_ADDRESS="$ANSWER"
|
||||
answer vpn-router/wg_listen_port; VPN_ROUTER_WG_LISTEN_PORT="$ANSWER"
|
||||
@@ -44,7 +44,7 @@ case "$1" in
|
||||
VPN_ROUTER_REMOTE_ADDRS VPN_ROUTER_REMOTE_ID \
|
||||
VPN_ROUTER_REMOTE_CIDRS VPN_ROUTER_PSK \
|
||||
VPN_ROUTER_P2S_ENABLED VPN_ROUTER_P2S_ADDRESS_POOL \
|
||||
VPN_ROUTER_P2S_CA_NAME VPN_ROUTER_WG_ENABLED \
|
||||
VPN_ROUTER_PKI_ROOT_NAME VPN_ROUTER_WG_ENABLED \
|
||||
VPN_ROUTER_WG_ADDRESS VPN_ROUTER_WG_LISTEN_PORT
|
||||
|
||||
# --- Hand a candidate configuration to ucf ---
|
||||
|
||||
@@ -11,10 +11,10 @@ strip_ufw_blocks() {
|
||||
|
||||
tmp="$(mktemp)"
|
||||
awk '
|
||||
/^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|BOOTSTRAP|MANAGER|HTTPS|METRICS|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES START$/ {
|
||||
/^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|BOOTSTRAP|MANAGER|HTTPS|METRICS|SITES|DNS|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES START$/ {
|
||||
skip = 1; pending = 0; next
|
||||
}
|
||||
/^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|BOOTSTRAP|MANAGER|HTTPS|METRICS|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES END$/ {
|
||||
/^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|BOOTSTRAP|MANAGER|HTTPS|METRICS|SITES|DNS|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES END$/ {
|
||||
skip = 0; next
|
||||
}
|
||||
skip { next }
|
||||
@@ -46,15 +46,28 @@ case "$1" in
|
||||
rm -f /etc/swanctl/conf.d/site-to-site.conf \
|
||||
/etc/swanctl/conf.d/road-warrior.conf \
|
||||
/etc/systemd/resolved.conf.d/p2s-forwarder.conf \
|
||||
/etc/vpn-router/dnsmasq.conf \
|
||||
/etc/wireguard/wg0.conf \
|
||||
/etc/openvpn/server/vpn-router.conf \
|
||||
/etc/openvpn/server/vpn-router-ldap.conf \
|
||||
/etc/caddy/vpn-router.Caddyfile \
|
||||
/etc/powerdns/pdns.d/vpn-router.conf \
|
||||
/etc/vpn-router/vpn-router.json \
|
||||
/etc/vpn-router/web.key \
|
||||
/etc/vpn-router/oidc.key \
|
||||
/etc/vpn-router/metrics.secret \
|
||||
/var/lib/vpn-router/setup.done
|
||||
rmdir --ignore-fail-on-non-empty /var/lib/vpn-router 2>/dev/null || true
|
||||
/etc/systemd/system/vpn-router-backup.timer.d/schedule.conf \
|
||||
/etc/sudoers.d/vpn-router \
|
||||
/etc/ssh/sshd_config.d/vpn-router.conf \
|
||||
/var/lib/vpn-router/setup.done \
|
||||
/var/lib/vpn-router/backup.json
|
||||
rmdir --ignore-fail-on-non-empty /etc/systemd/system/vpn-router-backup.timer.d 2>/dev/null || true
|
||||
# The name service no longer looks up the console accounts of the router's directory.
|
||||
sed -i '/^passwd:/s/ ldap\b//' /etc/nsswitch.conf
|
||||
rm -rf /var/lib/vpn-router/backup/staging /var/lib/vpn-router/backup/restore \
|
||||
/var/lib/vpn-router/pki-publish /etc/caddy/vpn-router-pki /etc/caddy/vpn-router-sites \
|
||||
/etc/openvpn/server/vpn-router-ca.pem /etc/openvpn/server/vpn-router-crl.pem
|
||||
rmdir --ignore-fail-on-non-empty /var/lib/vpn-router/backup /var/lib/vpn-router 2>/dev/null || true
|
||||
|
||||
# The refresh tokens' database and role, when the package created them.
|
||||
if [ -f /etc/vpn-router/database.secret ] && command -v psql >/dev/null 2>&1; then
|
||||
@@ -63,9 +76,23 @@ case "$1" in
|
||||
fi
|
||||
rm -f /etc/vpn-router/database.secret
|
||||
|
||||
# Key material and users are not package state: /etc/vpn-router/pki,
|
||||
# /etc/vpn-router/ldap, the user directory in slapd, the WireGuard key
|
||||
# pair and everything under /etc/swanctl are left in place.
|
||||
# The databases and roles of the web sites and of the name server, when the package created them.
|
||||
for database in vpn-router-sites vpn-router-dns; do
|
||||
secret="/etc/vpn-router/${database#vpn-router-}-database.secret"
|
||||
if [ -f "$secret" ] && command -v psql >/dev/null 2>&1; then
|
||||
runuser -u postgres -- psql -X -q -c "DROP DATABASE IF EXISTS \"$database\"" || true
|
||||
runuser -u postgres -- psql -X -q -c "DROP ROLE IF EXISTS \"$database\"" || true
|
||||
fi
|
||||
rm -f "$secret"
|
||||
done
|
||||
|
||||
# Key material, users and content are not package state: /etc/vpn-router/pki,
|
||||
# /etc/vpn-router/ldap, /etc/vpn-router/rclone.conf, the user directory
|
||||
# in slapd, the WireGuard key pair, everything under /etc/swanctl, the
|
||||
# files of the web sites in /var/lib/vpn-router/sites, the backups and
|
||||
# what a restore replaced are left in place. Caddy, rclone and PowerDNS
|
||||
# stay installed, so Caddy's repository, which vpn-router-caddy
|
||||
# configures, stays too.
|
||||
rmdir --ignore-fail-on-non-empty /etc/vpn-router 2>/dev/null || true
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -17,10 +17,11 @@ case "$1" in
|
||||
fi
|
||||
|
||||
# vpn-router-setup enabled wg-quick@wg0, openvpn-server@vpn-router,
|
||||
# caddy and vpn-router-metrics, so the package takes them down again.
|
||||
# Use the debhelper wrappers rather than systemctl, so the script
|
||||
# behaves on a machine without systemd.
|
||||
for unit in wg-quick@wg0.service openvpn-server@vpn-router.service caddy.service vpn-router-metrics.service; do
|
||||
# caddy, vpn-router-metrics, the backup timer and the PKI timer, so the
|
||||
# package takes them down again. Use the debhelper wrappers rather than
|
||||
# systemctl, so the script behaves on a machine without systemd.
|
||||
for unit in wg-quick@wg0.service openvpn-server@vpn-router.service caddy.service vpn-router-metrics.service \
|
||||
vpn-router-backup.timer vpn-router-pki.timer; do
|
||||
if command -v deb-systemd-invoke >/dev/null 2>&1; then
|
||||
deb-systemd-invoke stop "$unit" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
@@ -8,3 +8,9 @@ override_dh_installsystemd:
|
||||
dh_installsystemd --name=vpn-router-web --no-start
|
||||
dh_installsystemd --name=vpn-router-metrics --no-enable --no-start
|
||||
dh_installsystemd --name=vpn-router-bootstrap --no-enable --no-start
|
||||
dh_installsystemd --name=vpn-router-backup --no-enable --no-start
|
||||
dh_installsystemd --name=vpn-router-pki --no-enable --no-start
|
||||
dh_installsystemd --name=vpn-router-caddy --no-enable --no-start
|
||||
dh_installsystemd --name=vpn-router-webdav --no-enable --no-start
|
||||
dh_installsystemd --name=vpn-router-resolver --no-enable --no-start
|
||||
dh_installsystemd --name=vpn-router-install@ --no-enable --no-start
|
||||
@@ -124,8 +124,7 @@ Type: boolean
|
||||
Default: false
|
||||
Description: Enable road-warrior (P2S) access?
|
||||
If enabled, this router accepts IKEv2 EAP-TLS connections from individual
|
||||
clients. Certificates are taken from /etc/vpn-router/pki, and a local
|
||||
certificate authority is created there if that directory is empty.
|
||||
clients, with certificates of the router's P2S issuing CA.
|
||||
|
||||
Template: vpn-router/p2s_address_pool
|
||||
Type: string
|
||||
@@ -133,12 +132,12 @@ Default:
|
||||
Description: Road-warrior address pool:
|
||||
CIDR block assigned to road-warrior clients (for example 172.16.0.0/24).
|
||||
|
||||
Template: vpn-router/p2s_ca_name
|
||||
Template: vpn-router/pki_root_name
|
||||
Type: string
|
||||
Default: VPN Router CA
|
||||
Description: Road-warrior CA name:
|
||||
Common name for the certificate authority created in /etc/vpn-router/pki
|
||||
when that directory is empty. Ignored when certificates are supplied.
|
||||
Default: VPN Router Root CA
|
||||
Description: Root CA name:
|
||||
Common name of the root certificate authority the router creates in
|
||||
/etc/vpn-router/pki/root. Ignored when a root certificate is placed there.
|
||||
|
||||
Template: vpn-router/wg_enabled
|
||||
Type: boolean
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=VPN Router backup
|
||||
Documentation=file:/usr/share/doc/vpn-router/README.md
|
||||
After=network-online.target slapd.service postgresql.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/sbin/vpn-router-backup run
|
||||
Nice=10
|
||||
IOSchedulingClass=idle
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=VPN Router backup schedule
|
||||
Documentation=file:/usr/share/doc/vpn-router/README.md
|
||||
|
||||
[Timer]
|
||||
OnCalendar=daily
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=VPN Router Caddy installation
|
||||
Documentation=file:/usr/share/doc/vpn-router/README.md
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/sbin/vpn-router-caddy apply
|
||||
TimeoutStartSec=20min
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=VPN Router installation of the packages of %i
|
||||
Documentation=file:/usr/share/doc/vpn-router/README.md
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/lib/vpn-router/install-packages %i
|
||||
TimeoutStartSec=20min
|
||||
@@ -0,0 +1,7 @@
|
||||
[Unit]
|
||||
Description=VPN Router PKI maintenance
|
||||
Documentation=file:/usr/share/doc/vpn-router/README.md
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/sbin/vpn-router-ca maintain
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=VPN Router PKI maintenance schedule
|
||||
Documentation=file:/usr/share/doc/vpn-router/README.md
|
||||
|
||||
[Timer]
|
||||
OnCalendar=daily
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=VPN Router DNS resolver of the remote access clients
|
||||
Documentation=file:/usr/share/doc/vpn-router/README.md
|
||||
After=network-online.target systemd-resolved.service
|
||||
Wants=network-online.target
|
||||
ConditionPathExists=/etc/vpn-router/dnsmasq.conf
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/sbin/dnsmasq --keep-in-foreground --pid-file= --log-facility=- --conf-file=/etc/vpn-router/dnsmasq.conf
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -15,7 +15,7 @@ NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
RuntimeDirectory=vpn-router
|
||||
RuntimeDirectoryPreserve=yes
|
||||
ReadWritePaths=-/etc/vpn-router -/etc/swanctl/x509crl
|
||||
ReadWritePaths=-/etc/vpn-router -/etc/swanctl/x509crl -/etc/openvpn/server -/var/lib/vpn-router/pki-publish -/var/lib/vpn-router/sites
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
[Unit]
|
||||
Description=VPN Router WebDAV of the web sites' folders
|
||||
Documentation=file:/usr/share/doc/vpn-router/README.md
|
||||
After=network-online.target slapd.service postgresql.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/rclone serve webdav --addr 127.0.0.1:7446 --auth-proxy /usr/lib/vpn-router/webdav-auth --config /dev/null --cache-dir /var/cache/vpn-router-webdav --vfs-cache-mode writes --umask 022
|
||||
Restart=on-failure
|
||||
RestartSec=15
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/var/lib/vpn-router/sites
|
||||
CacheDirectory=vpn-router-webdav
|
||||
RuntimeDirectory=vpn-router-webdav
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -50,7 +50,7 @@ ANSWERS = {
|
||||
'VPN_ROUTER_PSK': ((*FIRST, 'psk'),),
|
||||
'VPN_ROUTER_P2S_ENABLED': (('p2s', 'enabled'),),
|
||||
'VPN_ROUTER_P2S_ADDRESS_POOL': (('p2s', 'address_pool'),),
|
||||
'VPN_ROUTER_P2S_CA_NAME': (('p2s', 'ca_name'),),
|
||||
'VPN_ROUTER_PKI_ROOT_NAME': (('pki', 'root_name'),),
|
||||
'VPN_ROUTER_WG_ENABLED': (('wireguard', 'enabled'),),
|
||||
'VPN_ROUTER_WG_ADDRESS': (('wireguard', 'address'),),
|
||||
'VPN_ROUTER_WG_LISTEN_PORT': (('wireguard', 'listen_port'),),
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/python3
|
||||
"""Install the packages of a feature from the Ubuntu archive, then restart vpn-router-setup without waiting.
|
||||
|
||||
Run by vpn-router-install@<feature>.service, which vpn-router-setup starts when the feature is on and its packages are
|
||||
missing: webdav (rclone) and dns (PowerDNS with its gpgsql backend). Recommended packages are left out, so PowerDNS
|
||||
comes without its bind backend. A failure is a message on standard error and the exit status 1.
|
||||
|
||||
install-packages FEATURE
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
#: The packages of each feature.
|
||||
FEATURES = {'webdav': ['rclone'], 'dns': ['pdns-server', 'pdns-backend-pgsql']}
|
||||
|
||||
#: apt-get, waiting up to ten minutes for dpkg's lock, which a package installation that runs setup holds.
|
||||
APT = ['apt-get', '-o', 'DPkg::Lock::Timeout=600']
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 2 or sys.argv[1] not in FEATURES:
|
||||
sys.exit(f'usage: install-packages {"|".join(FEATURES)}')
|
||||
environment = dict(os.environ, DEBIAN_FRONTEND='noninteractive')
|
||||
for args in ([*APT, 'update'], [*APT, '-y', '--no-install-recommends', '-o', 'Dpkg::Options::=--force-confdef',
|
||||
'-o', 'Dpkg::Options::=--force-confold', 'install', *FEATURES[sys.argv[1]]]):
|
||||
print(f'$ {" ".join(args)}', flush=True)
|
||||
if subprocess.run(args, env=environment).returncode != 0:
|
||||
sys.exit(f'install-packages: {args[0]} failed')
|
||||
subprocess.run(['systemctl', 'restart', '--no-block', 'vpn-router-setup.service'])
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/python3
|
||||
"""Accept an OpenVPN client certificate only when the P2S issuing CA signed it.
|
||||
|
||||
Run by the OpenVPN server as tls-verify for each certificate of the client's chain, with its depth and subject as the
|
||||
arguments. At depth 1, the CA that signed the client's certificate, the SHA-256 fingerprint OpenVPN gives in
|
||||
tls_digest_sha256_1 must be that of P2S_CA; the other depths are accepted.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import ssl
|
||||
import sys
|
||||
|
||||
P2S_CA = '/etc/swanctl/x509ca/p2s.pem'
|
||||
|
||||
if len(sys.argv) < 2 or sys.argv[1] != '1':
|
||||
sys.exit(0)
|
||||
try:
|
||||
with open(P2S_CA, encoding='ascii') as handle:
|
||||
expected = hashlib.sha256(ssl.PEM_cert_to_DER_cert(handle.read())).hexdigest()
|
||||
except (OSError, ValueError):
|
||||
sys.exit(1)
|
||||
given = os.environ.get('tls_digest_sha256_1', '').replace(':', '').lower()
|
||||
sys.exit(0 if given == expected else 1)
|
||||
@@ -7,7 +7,7 @@ and service reloads. Idempotent - a second run changes nothing.
|
||||
|
||||
Until a run has succeeded (SETUP_DONE), every run starts the bootstrap
|
||||
services, BOOTSTRAP_UNITS, which show the progress and the failure recorded in
|
||||
SETUP_STATE on BOOTSTRAP_PORTS, and the firewall opens those ports on the
|
||||
SETUP_STATE on vpnrouter.BOOTSTRAP_PORTS, and the firewall opens those ports on the
|
||||
internal interface. The first successful run writes SETUP_DONE, closes the
|
||||
ports and stops the bootstrap services; a failed run leaves them running.
|
||||
|
||||
@@ -22,6 +22,7 @@ import os
|
||||
import pathlib
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -32,9 +33,13 @@ sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
|
||||
import ldap
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_auth
|
||||
import vpnrouter_dns
|
||||
import vpnrouter_ldap
|
||||
import vpnrouter_pki
|
||||
import vpnrouter_sites
|
||||
import vpnrouter_users
|
||||
from vpnrouter import fail
|
||||
from vpnrouter import fail, warn
|
||||
|
||||
BEFORE_RULES = pathlib.Path('/etc/ufw/before.rules')
|
||||
UFW_DEFAULTS = pathlib.Path('/etc/default/ufw')
|
||||
@@ -47,28 +52,56 @@ SETUP_STATE = pathlib.Path('/run/vpn-router/setup.json')
|
||||
#: Written once a run has succeeded; until then setup runs the bootstrap services and opens their ports.
|
||||
SETUP_DONE = pathlib.Path('/var/lib/vpn-router/setup.done')
|
||||
|
||||
#: The units of the bootstrap services, and their ports: the Router Manager's and the router's.
|
||||
#: The units of the bootstrap services, whose ports are vpnrouter.BOOTSTRAP_PORTS.
|
||||
BOOTSTRAP_UNITS = ('vpn-router-bootstrap.service', 'vpn-router-manager-bootstrap.service')
|
||||
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
|
||||
|
||||
#: The password of the metrics service account, which setup makes and keeps (root only).
|
||||
METRICS_SECRET = pathlib.Path('/etc/vpn-router/metrics.secret')
|
||||
|
||||
#: The schedule of the backup timer, written from backup.schedule.
|
||||
BACKUP_SCHEDULE = pathlib.Path('/etc/systemd/system/vpn-router-backup.timer.d/schedule.conf')
|
||||
|
||||
#: The configuration of Caddy, the HTTPS proxy, which the package's drop-in for caddy.service names;
|
||||
#: Caddy runs as the user and group CADDY_GROUP, which the caddy package creates.
|
||||
CADDYFILE = pathlib.Path('/etc/caddy/vpn-router.Caddyfile')
|
||||
CADDY_UNIT = 'caddy.service'
|
||||
CADDY_GROUP = 'caddy'
|
||||
|
||||
#: The unit that installs Caddy from https.caddy_source with vpn-router-caddy, outside setup.
|
||||
CADDY_INSTALL_UNIT = 'vpn-router-caddy.service'
|
||||
|
||||
#: The certificates and keys of the issuing CAs behind Caddy's ACME server and the sites of tls ca, <name>/cert.pem and
|
||||
#: key.pem.
|
||||
CADDY_PKI_DIR = pathlib.Path('/etc/caddy/vpn-router-pki')
|
||||
|
||||
#: The certificates and keys of the sites of tls file, <site>/cert.pem and key.pem.
|
||||
CADDY_SITES_DIR = pathlib.Path('/etc/caddy/vpn-router-sites')
|
||||
|
||||
#: The unit of dnsmasq, the resolver of the remote access clients.
|
||||
RESOLVER_UNIT = 'vpn-router-resolver.service'
|
||||
|
||||
#: The unit of rclone's WebDAV server of the sites' folders.
|
||||
WEBDAV_UNIT = 'vpn-router-webdav.service'
|
||||
|
||||
#: The unit that installs the packages of a feature with install-packages, outside setup; {} is the feature.
|
||||
INSTALL_UNIT = 'vpn-router-install@{}.service'
|
||||
|
||||
#: The timer of vpn-router-ca maintain, which keeps the CRLs fresh.
|
||||
PKI_TIMER = 'vpn-router-pki.timer'
|
||||
|
||||
#: The system's side of console login (vpnrouter_users.console): nslcd's configuration, which serves the posixAccounts
|
||||
#: of the directory with console login on, the name service switch, the sudo rule of their group, and the drop-in of
|
||||
#: sshd that lets them sign in with their password while ssh.ldap_login is true.
|
||||
NSLCD_CONF = pathlib.Path('/etc/nslcd.conf')
|
||||
NSSWITCH = pathlib.Path('/etc/nsswitch.conf')
|
||||
SUDOERS = pathlib.Path('/etc/sudoers.d/vpn-router')
|
||||
SSHD_DROPIN = pathlib.Path('/etc/ssh/sshd_config.d/vpn-router.conf')
|
||||
PAM_SESSION = pathlib.Path('/etc/pam.d/common-session')
|
||||
|
||||
FILTER_ANCHOR = '# End required lines'
|
||||
|
||||
#: Configuration options left out wherever the configuration is shown.
|
||||
@@ -84,6 +117,8 @@ BLOCKS = (
|
||||
('# MANAGER RULES START', '# MANAGER RULES END'),
|
||||
('# HTTPS RULES START', '# HTTPS RULES END'),
|
||||
('# METRICS RULES START', '# METRICS RULES END'),
|
||||
('# SITES RULES START', '# SITES RULES END'),
|
||||
('# DNS RULES START', '# DNS RULES END'),
|
||||
('# ROUTER FORWARD RULES START', '# ROUTER FORWARD RULES END'),
|
||||
('# ROUTER NAT RULES START', '# ROUTER NAT RULES END'),
|
||||
('# ROUTER MSS RULES START', '# ROUTER MSS RULES END'),
|
||||
@@ -171,15 +206,6 @@ def is_configured(ctx):
|
||||
vpnrouter.openvpn_ready(ctx)))
|
||||
|
||||
|
||||
def manager_port():
|
||||
"""The port of the Router Manager: port in its settings, or MANAGER_PORT when it is not set or not a port."""
|
||||
try:
|
||||
port = json.loads(MANAGER_CONFIG.read_text(encoding='utf-8')).get('port')
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return MANAGER_PORT
|
||||
return port if isinstance(port, int) and not isinstance(port, bool) and 1 <= port <= 65535 else MANAGER_PORT
|
||||
|
||||
|
||||
def filter_block(ctx):
|
||||
lines = [
|
||||
'',
|
||||
@@ -217,11 +243,13 @@ def filter_block(ctx):
|
||||
lines += ['# OPENVPN RULES END']
|
||||
|
||||
pools = [pool for ready, pool in ((vpnrouter.p2s_ready(ctx), ctx['p2s_address_pool']),
|
||||
(vpnrouter.openvpn_ready(ctx), ctx['openvpn_pool'])) if ready]
|
||||
if pools and ctx['local_addr_list']:
|
||||
(vpnrouter.openvpn_ready(ctx), ctx['openvpn_pool']),
|
||||
(vpnrouter.wg_ready(ctx), ctx['wg_network'])) if ready]
|
||||
# The remote access clients' resolver answers on the addresses of p2s.dns_listen.
|
||||
if pools and ctx['p2s_resolver_addrs']:
|
||||
lines += ['', '# P2S DNS RULES START']
|
||||
for pool in pools:
|
||||
for address in ctx['local_addr_list']:
|
||||
for address in ctx['p2s_resolver_addrs']:
|
||||
lines += [
|
||||
f'-A ufw-before-input -s {pool} -d {address} -p udp --dport 53 -j ACCEPT',
|
||||
f'-A ufw-before-input -s {pool} -d {address} -p tcp --dport 53 -j ACCEPT',
|
||||
@@ -242,12 +270,13 @@ def filter_block(ctx):
|
||||
lines += [
|
||||
'',
|
||||
'# BOOTSTRAP RULES START',
|
||||
*(f'-A ufw-before-input -i {ctx["int_iface"]} -p tcp --dport {port} -j ACCEPT' for port in BOOTSTRAP_PORTS),
|
||||
*(f'-A ufw-before-input -i {ctx["int_iface"]} -p tcp --dport {port} -j ACCEPT'
|
||||
for port in vpnrouter.BOOTSTRAP_PORTS),
|
||||
'# BOOTSTRAP RULES END',
|
||||
]
|
||||
|
||||
if MANAGER.exists() and interfaces:
|
||||
port = manager_port()
|
||||
if vpnrouter.MANAGER.exists() and interfaces:
|
||||
port = vpnrouter.manager_port()
|
||||
lines += [
|
||||
'',
|
||||
'# MANAGER RULES START',
|
||||
@@ -263,20 +292,52 @@ def filter_block(ctx):
|
||||
'# METRICS RULES END',
|
||||
]
|
||||
|
||||
# Let's Encrypt reaches the HTTP challenge on the external interface; the HTTPS ports are open on both.
|
||||
# Let's Encrypt reaches the HTTP challenge on the external interface, and the CA files published on port 80 are
|
||||
# read on both; the HTTPS ports are open on both.
|
||||
if vpnrouter.https_ready(ctx) and interfaces:
|
||||
ports = [port for wanted, port in ((ctx['web_enabled'], ctx['https_web_port']),
|
||||
(MANAGER.exists(), ctx['https_manager_port']),
|
||||
(vpnrouter.metrics_ready(ctx), ctx['metrics_https_port'])) if wanted]
|
||||
publish = ctx['pki_publish']
|
||||
ports = [port for wanted, port in ((ctx['web_enabled'] or 'https' in publish or ctx['pki_acme'],
|
||||
ctx['https_web_port']),
|
||||
(vpnrouter.MANAGER.exists(), ctx['https_manager_port']),
|
||||
(vpnrouter.metrics_ready(ctx), ctx['metrics_https_port']),
|
||||
(vpnrouter.webdav_ready(ctx), ctx['webdav_port']),
|
||||
('http-80' in publish, vpnrouter.ACME_HTTP_PORT),
|
||||
('https-default' in publish, vpnrouter.HTTPS_DEFAULT_PORT)) if wanted]
|
||||
lines += [
|
||||
'',
|
||||
'# HTTPS RULES START',
|
||||
*([f'-A ufw-before-input -i {ctx["wan_iface"]} -p tcp --dport {vpnrouter.ACME_HTTP_PORT} -j ACCEPT']
|
||||
if ctx['wan_iface'] else []),
|
||||
if ctx['wan_iface'] and 'http-80' not in publish else []),
|
||||
*(f'-A ufw-before-input -i {name} -p tcp --dport {port} -j ACCEPT' for name in interfaces for port in ports),
|
||||
'# HTTPS RULES END',
|
||||
]
|
||||
|
||||
# Each site's port is open on the interfaces of its listen; a site with a certificate of Let's Encrypt needs the HTTP
|
||||
# challenge on the external interface.
|
||||
by_listen = {'internal': [ctx['int_iface']], 'external': [ctx['wan_iface']],
|
||||
'both': [ctx['int_iface'], ctx['wan_iface']]}
|
||||
sites = ctx['sites'] or []
|
||||
rules = sorted({(name, site['port']) for site in sites for name in by_listen[site['listen']] if name})
|
||||
if ctx['wan_iface'] and any(site['tls'] == 'acme' for site in sites):
|
||||
rules = sorted(set(rules) | {(ctx['wan_iface'], vpnrouter.ACME_HTTP_PORT)})
|
||||
if rules:
|
||||
lines += [
|
||||
'',
|
||||
'# SITES RULES START',
|
||||
*(f'-A ufw-before-input -i {name} -p tcp --dport {port} -j ACCEPT' for name, port in rules),
|
||||
'# SITES RULES END',
|
||||
]
|
||||
|
||||
dns_interfaces = [name for name in by_listen[ctx['dns_listen']] if name] if vpnrouter.dns_ready(ctx) else []
|
||||
if dns_interfaces:
|
||||
lines += [
|
||||
'',
|
||||
'# DNS RULES START',
|
||||
*(f'-A ufw-before-input -i {name} -p {protocol} --dport 53 -j ACCEPT'
|
||||
for name in dns_interfaces for protocol in ('udp', 'tcp')),
|
||||
'# DNS RULES END',
|
||||
]
|
||||
|
||||
if vpnrouter.routing_ready(ctx):
|
||||
lines += ['', '# ROUTER FORWARD RULES START']
|
||||
for subnet in ctx['local_cidr_list']:
|
||||
@@ -458,14 +519,16 @@ def manage_admin():
|
||||
def manage_metrics_account(ctx):
|
||||
"""Keep the service account metrics.service_account names, whose password is in METRICS_SECRET; the problem, or ''.
|
||||
|
||||
While metrics are enabled, setup owns the account: it adds it as a service account with a random password when
|
||||
it is missing, and gives it a new random password, ending any lockout, when METRICS_SECRET is missing or does not
|
||||
hold its password. A disabled account is left as it is. An account of that name that is not a service account is
|
||||
left alone and is a problem.
|
||||
While metrics are enabled, setup owns the account, an internal service account: it adds it as a service account
|
||||
with a random password and the labels of vpnrouter_users.internal_accounts when it is missing, sets those labels
|
||||
when they differ, and gives it a new random password, ending any lockout, when METRICS_SECRET is missing or does
|
||||
not hold its password. The password of a disabled account is left as it is. An account of that name that is not a
|
||||
service account is left alone and is a problem.
|
||||
"""
|
||||
if not vpnrouter.metrics_ready(ctx):
|
||||
return ''
|
||||
name = ctx['metrics_service_account']
|
||||
wanted = vpnrouter_users.internal_accounts().get(name, {})
|
||||
try:
|
||||
vpnrouter_users.check_name(name)
|
||||
try:
|
||||
@@ -481,6 +544,8 @@ def manage_metrics_account(ctx):
|
||||
current = None
|
||||
if current is not None and not vpnrouter_users.service(current):
|
||||
return f'metrics.service_account: {name} is a user, not a service account'
|
||||
if current is not None and any(vpnrouter_users.summary(current)[key] != value for key, value in wanted.items()):
|
||||
vpnrouter_users.update_service(connection, name, wanted)
|
||||
# An administrator who disabled the account meant it; its password is left as it is.
|
||||
if current is not None and not vpnrouter_users.summary(current)['enabled']:
|
||||
return ''
|
||||
@@ -497,7 +562,7 @@ def manage_metrics_account(ctx):
|
||||
METRICS_SECRET.chmod(0o600)
|
||||
METRICS_SECRET.write_text(password + '\n', encoding='utf-8')
|
||||
if current is None:
|
||||
vpnrouter_users.add(connection, name, password, {}, service=True)
|
||||
vpnrouter_users.add(connection, name, password, wanted, service=True)
|
||||
else:
|
||||
vpnrouter_users.passwd(connection, name, password)
|
||||
vpnrouter_users.unlock(connection, name)
|
||||
@@ -506,6 +571,93 @@ def manage_metrics_account(ctx):
|
||||
return ''
|
||||
|
||||
|
||||
def write_if_changed(path, text, mode=0o644):
|
||||
"""Write the file when its content differs; whether it was written."""
|
||||
try:
|
||||
if path.read_text(encoding='utf-8') == text:
|
||||
return False
|
||||
except OSError:
|
||||
pass
|
||||
path.write_text(text, encoding='utf-8')
|
||||
path.chmod(mode)
|
||||
return True
|
||||
|
||||
|
||||
def manage_console(cfg):
|
||||
"""Make the system serve console login; the problem, or ''.
|
||||
|
||||
The local group vpnrouter_ldap.CONSOLE_GROUP is created when missing, and sudo lets its members run any command
|
||||
with their password. nslcd serves the directory's posixAccounts with console login on through the local socket,
|
||||
and checks their passwords by binding as the user; the passwd line of the name service switch gains ldap, which
|
||||
restarts the web service, and PAM creates a home at the first login. While ssh.ldap_login is true, sshd accepts the password of the group's members;
|
||||
a drop-in sshd -t refuses is removed again.
|
||||
"""
|
||||
group = vpnrouter_ldap.CONSOLE_GROUP
|
||||
try:
|
||||
grp.getgrnam(group)
|
||||
except KeyError:
|
||||
result = run(['groupadd', '--system', group], check=False)
|
||||
if result.returncode != 0:
|
||||
return f'the group {group} cannot be created: {result.stderr.strip()}'
|
||||
|
||||
rule = f'%{group} ALL=(ALL:ALL) ALL\n'
|
||||
try:
|
||||
if not SUDOERS.is_file() or SUDOERS.read_text(encoding='utf-8') != rule:
|
||||
staged = SUDOERS.with_name('.vpn-router.new')
|
||||
staged.write_text(rule, encoding='utf-8')
|
||||
staged.chmod(0o440)
|
||||
checked = run(['visudo', '-cf', str(staged)], check=False)
|
||||
if checked.returncode != 0:
|
||||
staged.unlink()
|
||||
return f'the sudo rule of {group} is refused: {checked.stdout.strip()} {checked.stderr.strip()}'
|
||||
staged.replace(SUDOERS)
|
||||
|
||||
uri = 'ldapi://' + vpnrouter_ldap.SLAPD_SOCKET.replace('/', '%2f') + '/'
|
||||
nslcd = '\n'.join([
|
||||
'uid nslcd',
|
||||
'gid nslcd',
|
||||
f'uri {uri}',
|
||||
f'base {vpnrouter_ldap.BASE}',
|
||||
'filter passwd (&(objectClass=posixAccount)(vpnRouterConsoleLogin=TRUE))',
|
||||
f'nss_min_uid {vpnrouter_ldap.CONSOLE_UID_MIN}',
|
||||
'pam_authz_search (&(objectClass=posixAccount)(objectClass=oathTOTPUser)(uid=$username)(vpnRouterConsoleLogin=TRUE))',
|
||||
'pam_password_prohibit_message The password is changed in the Router Manager or with vpn-router-user.',
|
||||
'',
|
||||
])
|
||||
if write_if_changed(NSLCD_CONF, nslcd, 0o640) or not unit_is_active('nslcd'):
|
||||
os.chown(NSLCD_CONF, 0, grp.getgrnam('nslcd').gr_gid)
|
||||
run(['systemctl', 'enable', 'nslcd'], check=False)
|
||||
run(['systemctl', 'restart', 'nslcd'], check=False)
|
||||
|
||||
lines = NSSWITCH.read_text(encoding='utf-8').splitlines(keepends=True)
|
||||
switched = [line.rstrip('\n') + ' ldap\n' if line.startswith('passwd:') and 'ldap' not in line.split() else line
|
||||
for line in lines]
|
||||
if switched != lines:
|
||||
NSSWITCH.write_text(''.join(switched), encoding='utf-8')
|
||||
# A running process keeps the name service switch it read first, so the web service's shell would not find
|
||||
# the console accounts of the directory until it restarts.
|
||||
run(['systemctl', 'try-restart', 'vpn-router-web.service'], check=False)
|
||||
|
||||
if 'pam_mkhomedir' not in PAM_SESSION.read_text(encoding='utf-8'):
|
||||
run(['pam-auth-update', '--enable', 'mkhomedir'], check=False)
|
||||
|
||||
if cfg['ssh']['ldap_login']:
|
||||
changed = write_if_changed(SSHD_DROPIN, f'Match Group {group}\n PasswordAuthentication yes\n')
|
||||
else:
|
||||
changed = SSHD_DROPIN.exists()
|
||||
SSHD_DROPIN.unlink(missing_ok=True)
|
||||
if changed:
|
||||
checked = run(['sshd', '-t'], check=False)
|
||||
if checked.returncode != 0:
|
||||
SSHD_DROPIN.unlink(missing_ok=True)
|
||||
return f'sshd refuses {SSHD_DROPIN}: {checked.stderr.strip()}'
|
||||
if unit_is_active('ssh'):
|
||||
run(['systemctl', 'reload', 'ssh'], check=False)
|
||||
except (OSError, KeyError) as exc:
|
||||
return f'console login cannot be set up: {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'
|
||||
@@ -521,33 +673,197 @@ def manage_openvpn(ctx, changed):
|
||||
run(['systemctl', 'restart', unit], check=False)
|
||||
|
||||
|
||||
def manage_caddy(ctx):
|
||||
"""Run Caddy, the HTTPS proxy of the web service, the Router Manager and the metrics service, while HTTPS is ready, and stop it when not.
|
||||
def caddy_address(host, port, tls):
|
||||
"""The address of a site block of the Caddyfile: the host, an IPv6 address in brackets, and the port, with http://
|
||||
for a plain HTTP site; no host answers any name."""
|
||||
host = f'[{host}]' if ':' in host else host
|
||||
return f'{"http://" if tls == "none" else ""}{host}:{port}'
|
||||
|
||||
CADDYFILE serves the web service on https.web_port, while the manager is installed the
|
||||
manager on https.manager_port, and while metrics are enabled the metrics service on
|
||||
metrics.https_port, all for wan.local_fqdn with a certificate from Let's Encrypt, and all
|
||||
calling the service on its loopback address.
|
||||
|
||||
def manage_resolver(changed):
|
||||
"""Run dnsmasq, the resolver of the remote access clients, while configure wrote its configuration, restarting it when
|
||||
that changed, and stop it when not. It starts after systemd-resolved has left the addresses it takes."""
|
||||
if not vpnrouter.RESOLVER_CONF.exists():
|
||||
if unit_is_active(RESOLVER_UNIT):
|
||||
run(['systemctl', 'disable', '--now', RESOLVER_UNIT], check=False)
|
||||
return
|
||||
if not unit_is_active(RESOLVER_UNIT):
|
||||
run(['systemctl', 'enable', '--now', RESOLVER_UNIT], check=False)
|
||||
elif 'resolver' in changed:
|
||||
run(['systemctl', 'restart', RESOLVER_UNIT], check=False)
|
||||
|
||||
|
||||
def manage_caddy(cfg, ctx):
|
||||
"""Run Caddy, the HTTPS proxy of the router's services and the server of the web sites, while either is wanted, and
|
||||
stop it when not.
|
||||
|
||||
While HTTPS is ready, CADDYFILE serves the web service on https.web_port, while the manager is installed the
|
||||
manager on https.manager_port, while metrics are enabled the metrics service on metrics.https_port, and while
|
||||
WebDAV is ready WebDAV on sites.webdav_port, all for wan.local_fqdn with a certificate from Let's Encrypt, and all
|
||||
calling the service on its loopback address. It also serves the CA files of vpnrouter_pki.PUBLISH_DIR at /pki/ on
|
||||
the endpoints of pki.publish that are Caddy's, and an ACME server at /acme/<name>/ on https.web_port for each
|
||||
issuing CA with acme and a certificate.
|
||||
|
||||
It serves each site of ctx's sites from its folder, with the TLS of the site: an issuing CA of tls ca issues its
|
||||
certificates, and the certificate and key of tls file are written to CADDY_SITES_DIR/<name>. The certificates and
|
||||
keys of the issuing CAs of both are copied to CADDY_PKI_DIR. While the sites cannot be read (ctx's sites is None),
|
||||
the Caddyfile is left as it is.
|
||||
|
||||
Caddy is installed by CADDY_INSTALL_UNIT, outside setup, when it is missing or when
|
||||
https.caddy_source is upstream and Caddy's repository is not configured; the unit restarts
|
||||
setup, whose next run serves with it.
|
||||
"""
|
||||
if not vpnrouter.https_ready(ctx):
|
||||
sites = ctx['sites']
|
||||
if not vpnrouter.https_ready(ctx) and not sites:
|
||||
if sites is None:
|
||||
return
|
||||
vpnrouter.remove(CADDYFILE)
|
||||
if unit_is_active(CADDY_UNIT):
|
||||
run(['systemctl', 'disable', '--now', CADDY_UNIT], check=False)
|
||||
return
|
||||
|
||||
source = cfg['https']['caddy_source']
|
||||
if shutil.which('caddy') is None or (source == 'upstream' and not vpnrouter.CADDY_APT_SOURCE.is_file()):
|
||||
run(['systemctl', 'start', '--no-block', CADDY_INSTALL_UNIT], check=False)
|
||||
warn(f'Caddy is installed from the {source} source by {CADDY_INSTALL_UNIT}, which runs setup again')
|
||||
return
|
||||
if source == 'ubuntu' and vpnrouter.CADDY_APT_SOURCE.is_file():
|
||||
warn("https.caddy_source ubuntu is ignored: Caddy's repository is configured, and Caddy stays upstream's")
|
||||
|
||||
if sites is None:
|
||||
warn('the web sites cannot be read; the Caddyfile is left as it is')
|
||||
if not unit_is_active(CADDY_UNIT) and CADDYFILE.exists():
|
||||
run(['systemctl', 'enable', '--now', CADDY_UNIT], check=False)
|
||||
return
|
||||
try:
|
||||
group = grp.getgrnam(CADDY_GROUP).gr_gid
|
||||
except KeyError:
|
||||
fail(f'the group {CADDY_GROUP} does not exist; is the caddy package installed?')
|
||||
caddy_ctx = dict(ctx, manager_port=manager_port() if MANAGER.exists() else 0)
|
||||
https = vpnrouter.https_ready(ctx)
|
||||
wanted = list(dict.fromkeys([*(ctx['pki_acme'] if https else []),
|
||||
*(site['ca'] for site in sites if site['tls'] == 'ca')]))
|
||||
try:
|
||||
credentials = {name: vpnrouter_pki.authority_files(name) for name in wanted}
|
||||
except ldap.LDAPError as exc:
|
||||
warn(f'the ACME servers and the sites of issuing CAs are left out: the PKI cannot be read: {exc}')
|
||||
credentials = {}
|
||||
authorities = [name for name, files in credentials.items() if files is not None]
|
||||
for site in sites:
|
||||
if site['tls'] == 'ca' and site['ca'] not in authorities:
|
||||
warn(f'site {site["name"]} is left out: issuing CA {site["ca"]} has no certificate')
|
||||
sites = [site for site in sites if site['tls'] != 'ca' or site['ca'] in authorities]
|
||||
copied = False
|
||||
for folder in (CADDY_PKI_DIR.iterdir() if CADDY_PKI_DIR.is_dir() else []):
|
||||
if folder.name not in authorities:
|
||||
shutil.rmtree(folder)
|
||||
for name in authorities:
|
||||
folder = CADDY_PKI_DIR / name
|
||||
for path in (CADDY_PKI_DIR, folder):
|
||||
path.mkdir(mode=0o750, exist_ok=True)
|
||||
os.chown(path, 0, group)
|
||||
for file, data in zip(('cert.pem', 'key.pem'), credentials[name]):
|
||||
copied |= vpnrouter_pki.write(folder / file, data, 0o640)
|
||||
os.chown(folder / file, 0, group)
|
||||
files = {site['name']: site for site in sites if site['tls'] == 'file'}
|
||||
for folder in (CADDY_SITES_DIR.iterdir() if CADDY_SITES_DIR.is_dir() else []):
|
||||
if folder.name not in files:
|
||||
shutil.rmtree(folder)
|
||||
for name, site in files.items():
|
||||
folder = CADDY_SITES_DIR / name
|
||||
for path in (CADDY_SITES_DIR, folder):
|
||||
path.mkdir(mode=0o750, exist_ok=True)
|
||||
os.chown(path, 0, group)
|
||||
for file, data in (('cert.pem', site['certificate']), ('key.pem', site['private_key'])):
|
||||
copied |= vpnrouter_pki.write(folder / file, data.strip().encode() + b'\n', 0o640)
|
||||
os.chown(folder / file, 0, group)
|
||||
vpnrouter_sites.ensure_folders(site['name'] for site in sites)
|
||||
names = vpnrouter.issuing_cas(cfg)
|
||||
caddy_ctx = dict(ctx, https=https, manager_port=vpnrouter.manager_port() if vpnrouter.MANAGER.exists() else 0,
|
||||
pki_publish_dir=str(vpnrouter_pki.PUBLISH_DIR),
|
||||
pki_cas=[{'id': name, 'name': names[name]['name'].strip(),
|
||||
'cert': str(CADDY_PKI_DIR / name / 'cert.pem'),
|
||||
'key': str(CADDY_PKI_DIR / name / 'key.pem')} for name in authorities],
|
||||
acme_cas=[name for name in authorities if https and name in ctx['pki_acme']],
|
||||
webdav=vpnrouter.webdav_ready(ctx), webdav_loopback_port=vpnrouter.WEBDAV_LOOPBACK_PORT,
|
||||
sites=[{'name': site['name'], 'tls': site['tls'], 'ca': site['ca'], 'spa': site['spa'],
|
||||
'index': site['index_file'], 'root': str(vpnrouter_sites.SITES_DIR / site['name']),
|
||||
'addresses': [caddy_address(host, site['port'], site['tls']) for host in site['hosts']]
|
||||
or [caddy_address('', site['port'], site['tls'])],
|
||||
'cert': str(CADDY_SITES_DIR / site['name'] / 'cert.pem'),
|
||||
'key': str(CADDY_SITES_DIR / site['name'] / 'key.pem')} for site in sites])
|
||||
changed = vpnrouter.render(vpnrouter._jinja_env(), caddy_ctx, 'Caddyfile.j2', CADDYFILE, 0o640)
|
||||
os.chown(CADDYFILE, 0, group)
|
||||
if not unit_is_active(CADDY_UNIT):
|
||||
run(['systemctl', 'enable', '--now', CADDY_UNIT], check=False)
|
||||
elif changed:
|
||||
elif changed or copied:
|
||||
run(['systemctl', 'reload', CADDY_UNIT], check=False)
|
||||
|
||||
|
||||
def install_packages(feature):
|
||||
"""Start the installation of the packages of a feature by INSTALL_UNIT, which runs setup again, and say so."""
|
||||
unit = INSTALL_UNIT.format(feature)
|
||||
run(['systemctl', 'start', '--no-block', unit], check=False)
|
||||
warn(f'the packages of {feature} are installed by {unit}, which runs setup again')
|
||||
|
||||
|
||||
def manage_webdav(ctx):
|
||||
"""Run rclone's WebDAV server of the sites' folders while WebDAV is ready, and stop it when not; rclone is installed
|
||||
when it is missing."""
|
||||
if not vpnrouter.webdav_ready(ctx):
|
||||
if unit_is_active(WEBDAV_UNIT):
|
||||
run(['systemctl', 'disable', '--now', WEBDAV_UNIT], check=False)
|
||||
return
|
||||
if shutil.which('rclone') is None:
|
||||
install_packages('webdav')
|
||||
return
|
||||
vpnrouter_sites.ensure_folders([])
|
||||
run(['systemctl', 'enable', '--now', WEBDAV_UNIT], check=False)
|
||||
|
||||
|
||||
def manage_dns(cfg, ctx):
|
||||
"""Run PowerDNS, the name server of dns.zone, on the addresses of dns.listen while DNS is enabled, and stop it when
|
||||
not; its packages are installed when they are missing.
|
||||
|
||||
The settings, vpnrouter_dns.PDNS_CONF, are written before the packages are installed, so the server their
|
||||
installation starts answers on those addresses only, beside systemd-resolved's stub on 127.0.0.53. The zone gets
|
||||
the host names of the enabled sites, and keeps those it has while the sites cannot be read.
|
||||
"""
|
||||
if not vpnrouter.dns_ready(ctx):
|
||||
if unit_is_active(vpnrouter_dns.UNIT):
|
||||
run(['systemctl', 'disable', '--now', vpnrouter_dns.UNIT], check=False)
|
||||
vpnrouter.remove(vpnrouter_dns.PDNS_CONF)
|
||||
if vpnrouter_dns.DATABASE_SECRET.is_file():
|
||||
try:
|
||||
vpnrouter_dns.apply(cfg, [])
|
||||
except vpnrouter_dns.DatabaseError as exc:
|
||||
warn(str(exc))
|
||||
return
|
||||
addresses = {'internal': [ctx['int_addr']], 'external': ctx['local_addr_list'],
|
||||
'both': [ctx['int_addr'], *ctx['local_addr_list']]}[ctx['dns_listen']]
|
||||
addresses = [address for address in addresses if address]
|
||||
if not addresses:
|
||||
warn(f'the name server does not run: the interfaces of dns.listen {ctx["dns_listen"]} have no address')
|
||||
return
|
||||
try:
|
||||
vpnrouter_auth.provision_database(vpnrouter_dns.DATABASE, vpnrouter_dns.DATABASE_SECRET)
|
||||
vpnrouter_dns.PDNS_CONF.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
changed = write_if_changed(vpnrouter_dns.PDNS_CONF, vpnrouter_dns.config_text(addresses), 0o640)
|
||||
if not vpnrouter_dns.PDNS_SERVER.exists() or not vpnrouter_dns.PDNS_SCHEMA.exists():
|
||||
install_packages('dns')
|
||||
return
|
||||
os.chown(vpnrouter_dns.PDNS_CONF, 0, grp.getgrnam(vpnrouter_dns.GROUP).gr_gid)
|
||||
vpnrouter_dns.provision()
|
||||
if ctx['sites'] is not None:
|
||||
vpnrouter_dns.apply(cfg, [host for site in ctx['sites'] for host in site['hosts']])
|
||||
except (OSError, KeyError, vpnrouter_dns.DatabaseError) as exc:
|
||||
warn(f'the name server cannot be set up: {exc}')
|
||||
return
|
||||
if not unit_is_active(vpnrouter_dns.UNIT):
|
||||
run(['systemctl', 'enable', '--now', vpnrouter_dns.UNIT], check=False)
|
||||
elif changed:
|
||||
run(['systemctl', 'restart', vpnrouter_dns.UNIT], check=False)
|
||||
|
||||
|
||||
def manage_metrics(ctx):
|
||||
"""Run the metrics service while metrics are enabled, and stop it when not.
|
||||
|
||||
@@ -562,6 +878,27 @@ def manage_metrics(ctx):
|
||||
run(['systemctl', 'restart', unit], check=False)
|
||||
|
||||
|
||||
def manage_backup(cfg):
|
||||
"""Run the backup timer on backup.schedule while backups are enabled, and stop it when not.
|
||||
|
||||
The schedule is a drop-in of the timer, BACKUP_SCHEDULE, which replaces the OnCalendar of the shipped unit.
|
||||
"""
|
||||
unit = 'vpn-router-backup.timer'
|
||||
if not cfg['backup']['enabled']:
|
||||
run(['systemctl', 'disable', '--now', unit], check=False)
|
||||
if BACKUP_SCHEDULE.exists():
|
||||
BACKUP_SCHEDULE.unlink()
|
||||
run(['systemctl', 'daemon-reload'], check=False)
|
||||
return
|
||||
text = f'[Timer]\nOnCalendar=\nOnCalendar={cfg["backup"]["schedule"]}\n'
|
||||
if not BACKUP_SCHEDULE.exists() or BACKUP_SCHEDULE.read_text(encoding='utf-8') != text:
|
||||
BACKUP_SCHEDULE.parent.mkdir(parents=True, exist_ok=True)
|
||||
BACKUP_SCHEDULE.write_text(text, encoding='utf-8')
|
||||
run(['systemctl', 'daemon-reload'], check=False)
|
||||
run(['systemctl', 'restart', unit], check=False)
|
||||
run(['systemctl', 'enable', '--now', unit], check=False)
|
||||
|
||||
|
||||
def run_platform_setup(cfg, ctx):
|
||||
"""Let the platform module apply its own state, if it defines any."""
|
||||
module = vpnrouter.platform_module(cfg)
|
||||
@@ -617,6 +954,7 @@ def refresh_web(cfg, ctx):
|
||||
'internal_address': ctx['int_addr'],
|
||||
'internal_gateway': ctx['int_gateway_ip'],
|
||||
'local_cidrs': ctx['local_cidr_list'],
|
||||
'client_dns': ctx['p2s_resolver_addrs'] if vpnrouter.RESOLVER_CONF.exists() else [],
|
||||
'site_to_site': [{
|
||||
'name': connection['name'],
|
||||
'local_identity': connection['local_id'],
|
||||
@@ -662,14 +1000,19 @@ def main():
|
||||
start_bootstrap()
|
||||
progress('running', 'configuration')
|
||||
cfg = vpnrouter.load_config()
|
||||
ctx, changed = vpnrouter.configure(cfg)
|
||||
|
||||
if ctx is None:
|
||||
if vpnrouter.deferred(cfg):
|
||||
print('vpn-router: platform is none, configuration deferred; '
|
||||
f'set general.platform in {vpnrouter.CONFIG_FILE}')
|
||||
progress('deferred', 'configuration', f'general.platform is none; set it in {vpnrouter.CONFIG_FILE}')
|
||||
return 0
|
||||
|
||||
# The directory holds the PKI, which the configuration needs; its problem ends setup only after every other step.
|
||||
failed_step = 'user directory'
|
||||
progress('running', failed_step)
|
||||
problem = manage_directory()
|
||||
progress('running', 'configuration')
|
||||
ctx, changed = vpnrouter.configure(cfg)
|
||||
|
||||
progress('running', 'routes')
|
||||
if vpnrouter.routing_ready(ctx):
|
||||
apply_routes(ctx)
|
||||
@@ -680,6 +1023,16 @@ def main():
|
||||
ctx, more = vpnrouter.configure(cfg, public_ip=address)
|
||||
changed |= more
|
||||
|
||||
# The enabled sites while sites are on, None while they cannot be read.
|
||||
ctx['sites'] = []
|
||||
if cfg['sites']['enabled']:
|
||||
progress('running', 'web sites')
|
||||
try:
|
||||
ctx['sites'] = vpnrouter_sites.served()
|
||||
except vpnrouter_sites.DatabaseError as exc:
|
||||
warn(str(exc))
|
||||
ctx['sites'] = None
|
||||
|
||||
progress('running', 'WireGuard keys')
|
||||
setup_wireguard(ctx)
|
||||
|
||||
@@ -701,10 +1054,6 @@ def main():
|
||||
progress('running', 'WireGuard')
|
||||
manage_wireguard(ctx, changed)
|
||||
|
||||
# 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)
|
||||
@@ -713,14 +1062,26 @@ def main():
|
||||
failed_step = 'metrics service account'
|
||||
progress('running', failed_step)
|
||||
problem = manage_metrics_account(ctx)
|
||||
if not problem:
|
||||
failed_step = 'console login'
|
||||
progress('running', failed_step)
|
||||
problem = manage_console(cfg)
|
||||
progress('running', 'OpenVPN')
|
||||
manage_openvpn(ctx, changed)
|
||||
progress('running', 'HTTPS proxy')
|
||||
manage_caddy(ctx)
|
||||
manage_caddy(cfg, ctx)
|
||||
progress('running', 'WebDAV')
|
||||
manage_webdav(ctx)
|
||||
progress('running', 'name server')
|
||||
manage_dns(cfg, ctx)
|
||||
progress('running', 'PKI timer')
|
||||
run(['systemctl', 'enable', '--now', PKI_TIMER], check=False)
|
||||
|
||||
if 'resolved' in changed:
|
||||
progress('running', 'resolver')
|
||||
run(['systemctl', 'restart', 'systemd-resolved'], check=False)
|
||||
progress('running', 'resolver of the remote access clients')
|
||||
manage_resolver(changed)
|
||||
|
||||
progress('running', 'platform module')
|
||||
run_platform_setup(cfg, ctx)
|
||||
@@ -730,6 +1091,8 @@ def main():
|
||||
refresh_web(cfg, ctx)
|
||||
progress('running', 'metrics service')
|
||||
manage_metrics(ctx)
|
||||
progress('running', 'backup timer')
|
||||
manage_backup(cfg)
|
||||
if problem:
|
||||
progress('failed', failed_step, problem)
|
||||
print(f'vpn-router: error: {problem}', file=sys.stderr)
|
||||
|
||||
@@ -1,611 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# MIT License
|
||||
#
|
||||
# Copyright (c) 2026 Sławomir Koszewski
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included in all
|
||||
# copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
|
||||
# This module requires Python 3.8+ and OpenSSL to be installed on the system.
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
OPENSSL = "openssl"
|
||||
|
||||
|
||||
class Config:
|
||||
FILE = "simple-ca.json"
|
||||
|
||||
def __init__(self, ca_dir: str):
|
||||
self._path = os.path.join(ca_dir, self.FILE)
|
||||
self._data: dict = {}
|
||||
if not os.path.isfile(self._path):
|
||||
return
|
||||
try:
|
||||
with open(self._path, "r") as f:
|
||||
self._data = json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
print(f"WARNING: could not read {self._path}: {e}", file=sys.stderr)
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self._data.get(key, default)
|
||||
|
||||
def update(self, patch: dict):
|
||||
file_missing = not os.path.isfile(self._path)
|
||||
changed = any(self._data.get(k) != v for k, v in patch.items())
|
||||
if file_missing or changed:
|
||||
self._data.update(patch)
|
||||
self._save()
|
||||
|
||||
def append_history(self, ca_key: str, entry: dict):
|
||||
history = self._data.get("history", {})
|
||||
history.setdefault(ca_key, []).append(entry)
|
||||
self._data["history"] = history
|
||||
self._save()
|
||||
|
||||
def revoke_in_history(self, ca_key: str, serial: str, revoked_at: str):
|
||||
"""Mark a certificate as revoked. Returns True if newly revoked,
|
||||
False if already revoked, None if serial not found."""
|
||||
history = self._data.get("history", {})
|
||||
for entry in history.get(ca_key, []):
|
||||
if entry.get("serial") == serial:
|
||||
if "revoked" in entry:
|
||||
return False
|
||||
entry["revoked"] = revoked_at
|
||||
self._data["history"] = history
|
||||
self._save()
|
||||
return True
|
||||
return None
|
||||
|
||||
def _save(self):
|
||||
with open(self._path, "w") as f:
|
||||
json.dump(self._data, f, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
|
||||
_config: Config
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _err(msg):
|
||||
print(f"ERROR: {msg}", file=sys.stderr)
|
||||
|
||||
|
||||
def _rebuild_ca_bundle(ca_dir):
|
||||
bundle_path = os.path.join(ca_dir, "ca_bundle.pem")
|
||||
parts = []
|
||||
root = os.path.join(ca_dir, "ca_cert.pem")
|
||||
if os.path.isfile(root):
|
||||
with open(root, "rb") as f:
|
||||
parts.append(f.read())
|
||||
for name in sorted(_config.get("subordinates", [])):
|
||||
sub_cert = os.path.join(ca_dir, name, "ca_cert.pem")
|
||||
if os.path.isfile(sub_cert):
|
||||
with open(sub_cert, "rb") as f:
|
||||
parts.append(f.read())
|
||||
with open(bundle_path, "wb") as f:
|
||||
f.write(b"".join(parts))
|
||||
|
||||
|
||||
def _read_serial(cert_path) -> str:
|
||||
result = subprocess.run(
|
||||
[OPENSSL, "x509", "-in", cert_path, "-noout", "-serial"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
return result.stdout.strip().split("=", 1)[-1]
|
||||
|
||||
|
||||
def _read_expiry(cert_path) -> str:
|
||||
"""Return the certificate notAfter date as an ISO 8601 UTC string."""
|
||||
result = subprocess.run(
|
||||
[OPENSSL, "x509", "-in", cert_path, "-noout", "-enddate"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
raw = result.stdout.strip().split("=", 1)[-1]
|
||||
raw = " ".join(raw.split()) # normalize whitespace ("May 4" -> "May 4")
|
||||
dt = datetime.datetime.strptime(raw, "%b %d %H:%M:%S %Y GMT")
|
||||
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _iso_to_asn1(iso: str) -> str:
|
||||
"""Convert "2026-05-24T14:28:10Z" -> "260524142810Z" for OpenSSL index.txt."""
|
||||
dt = datetime.datetime.strptime(iso, "%Y-%m-%dT%H:%M:%SZ")
|
||||
return dt.strftime("%y%m%d%H%M%SZ")
|
||||
|
||||
|
||||
_IP_RE = re.compile(r"^[0-9]{1,3}(\.[0-9]{1,3}){3}$")
|
||||
_DNS_RE = re.compile(r"^[a-z0-9-]+(\.[a-z0-9-]+)*$")
|
||||
|
||||
|
||||
def _is_ip(value):
|
||||
return bool(_IP_RE.match(value))
|
||||
|
||||
|
||||
def _is_dns(value):
|
||||
return bool(_DNS_RE.match(value))
|
||||
|
||||
|
||||
def _pipe(cmd1, cmd2):
|
||||
p1 = subprocess.Popen(cmd1, stdout=subprocess.PIPE)
|
||||
p2 = subprocess.Popen(cmd2, stdin=p1.stdout)
|
||||
p1.stdout.close()
|
||||
p2.communicate()
|
||||
p1.wait()
|
||||
return p1.returncode == 0 and p2.returncode == 0
|
||||
|
||||
|
||||
def make_ca(ca_dir, ca_name, days=3650, issuing_ca=None, ca_publish_base_url=None):
|
||||
if issuing_ca == "ca":
|
||||
_err("--issuing-ca cannot be 'ca' as it is reserved for the root CA.")
|
||||
return False
|
||||
|
||||
if not ca_dir or not os.path.isdir(ca_dir):
|
||||
_err(f"Certificate directory {ca_dir} does not exist.")
|
||||
return False
|
||||
|
||||
if not ca_name:
|
||||
_err("CA name is required.")
|
||||
return False
|
||||
|
||||
root_ca_cert_path = os.path.join(ca_dir, "ca_cert.pem")
|
||||
root_ca_key_path = os.path.join(ca_dir, "ca_key.pem")
|
||||
|
||||
if not os.path.isfile(root_ca_cert_path) or not os.path.isfile(root_ca_key_path):
|
||||
if issuing_ca:
|
||||
_err(
|
||||
f"Cannot create issuing CA '{ca_name}' without existing root CA "
|
||||
"certificate and key. Please create the root CA first."
|
||||
)
|
||||
return False
|
||||
|
||||
print(f"Generating CA certificate '{ca_name}' and key...")
|
||||
# Path length constraint of 1: allows one level of issuing CAs.
|
||||
cmd = [
|
||||
OPENSSL, "req",
|
||||
"-x509",
|
||||
"-newkey", "rsa:4096",
|
||||
"-keyout", root_ca_key_path,
|
||||
"-out", root_ca_cert_path,
|
||||
"-days", str(days),
|
||||
"-noenc",
|
||||
"-subj", f"/CN={ca_name}",
|
||||
"-text",
|
||||
"-addext", "basicConstraints=critical,CA:TRUE,pathlen:1",
|
||||
"-addext", "keyUsage=critical,keyCertSign,cRLSign",
|
||||
]
|
||||
if subprocess.run(cmd).returncode != 0:
|
||||
_err("Failed to generate CA certificate and key.")
|
||||
return False
|
||||
|
||||
_rebuild_ca_bundle(ca_dir)
|
||||
patch = {"name": ca_name, "created": _now()}
|
||||
if ca_publish_base_url:
|
||||
patch["ca_publish_base_url"] = ca_publish_base_url
|
||||
_config.update(patch)
|
||||
return True
|
||||
|
||||
issuing_ca_dir = os.path.join(ca_dir, issuing_ca)
|
||||
issuing_ca_cert = os.path.join(issuing_ca_dir, "ca_cert.pem")
|
||||
issuing_ca_key = os.path.join(issuing_ca_dir, "ca_key.pem")
|
||||
|
||||
if not os.path.isfile(issuing_ca_cert) or not os.path.isfile(issuing_ca_key):
|
||||
print(f"Generating issuing CA certificate '{ca_name}' and key...")
|
||||
os.makedirs(issuing_ca_dir, exist_ok=True)
|
||||
req_cmd = [
|
||||
OPENSSL, "req",
|
||||
"-newkey", "rsa:4096",
|
||||
"-keyout", issuing_ca_key,
|
||||
"-noenc",
|
||||
"-subj", f"/CN={ca_name}",
|
||||
"-addext", "basicConstraints=critical,CA:TRUE,pathlen:0",
|
||||
"-addext", "keyUsage=critical,keyCertSign,cRLSign",
|
||||
]
|
||||
if ca_publish_base_url:
|
||||
req_cmd += [
|
||||
"-addext", f"authorityInfoAccess=caIssuers;URI:{ca_publish_base_url}/ca_cert.crt",
|
||||
"-addext", f"crlDistributionPoints=URI:{ca_publish_base_url}/crl.pem",
|
||||
]
|
||||
x509_cmd = [
|
||||
OPENSSL, "x509",
|
||||
"-req",
|
||||
"-CA", root_ca_cert_path,
|
||||
"-CAkey", root_ca_key_path,
|
||||
"-copy_extensions", "copyall",
|
||||
"-days", str(days),
|
||||
"-text",
|
||||
"-out", issuing_ca_cert,
|
||||
]
|
||||
if not _pipe(req_cmd, x509_cmd):
|
||||
_err("Failed to generate issuing CA certificate and key.")
|
||||
return False
|
||||
|
||||
patch = {"ca_publish_base_url": ca_publish_base_url} if ca_publish_base_url else {}
|
||||
subs = _config.get("subordinates", [])
|
||||
if issuing_ca not in subs:
|
||||
patch["subordinates"] = subs + [issuing_ca]
|
||||
_config.update(patch)
|
||||
_rebuild_ca_bundle(ca_dir)
|
||||
return True
|
||||
|
||||
|
||||
def make_cert(cert_subject_name, sans=None, ca_dir=None, cert_dir=None,
|
||||
issuing_ca=None, days=365, ca_publish_base_url=None):
|
||||
if issuing_ca == "ca":
|
||||
_err("--issuing-ca cannot be 'ca' as it is reserved for the root CA.")
|
||||
return False
|
||||
|
||||
if not ca_dir or not os.path.isdir(ca_dir):
|
||||
_err(f"CA directory {ca_dir} does not exist.")
|
||||
return False
|
||||
|
||||
if not cert_subject_name:
|
||||
_err("Subject name is required.")
|
||||
return False
|
||||
|
||||
if not _is_dns(cert_subject_name):
|
||||
_err(f"Invalid subject name '{cert_subject_name}'. Must be a valid DNS name.")
|
||||
return False
|
||||
|
||||
signing_dir = os.path.join(ca_dir, issuing_ca) if issuing_ca else ca_dir
|
||||
cert_dir = cert_dir or signing_dir
|
||||
|
||||
if not os.path.isdir(cert_dir):
|
||||
_err(f"Certificate directory {cert_dir} does not exist.")
|
||||
return False
|
||||
ca_cert_path = os.path.join(signing_dir, "ca_cert.pem")
|
||||
ca_key_path = os.path.join(signing_dir, "ca_key.pem")
|
||||
if not os.path.isfile(ca_cert_path) or not os.path.isfile(ca_key_path):
|
||||
_err(
|
||||
f"Signing CA certificate and key not found in {signing_dir}. "
|
||||
"Please set up a signing CA first."
|
||||
)
|
||||
return False
|
||||
|
||||
aia_url = cdp_url = ""
|
||||
if ca_publish_base_url:
|
||||
if issuing_ca:
|
||||
aia_url = f"{ca_publish_base_url}/{issuing_ca}/ca_cert.crt"
|
||||
cdp_url = f"{ca_publish_base_url}/{issuing_ca}/crl.pem"
|
||||
else:
|
||||
aia_url = f"{ca_publish_base_url}/ca_cert.crt"
|
||||
cdp_url = f"{ca_publish_base_url}/crl.pem"
|
||||
|
||||
cert_name = cert_subject_name.split(".", 1)[0]
|
||||
|
||||
san_entries = [f"DNS:{cert_subject_name}"]
|
||||
for entry in sans or []:
|
||||
if _is_ip(entry):
|
||||
san_entries.append(f"IP:{entry}")
|
||||
elif _is_dns(entry):
|
||||
san_entries.append(f"DNS:{entry}")
|
||||
else:
|
||||
_err(f"Invalid SAN entry '{entry}'")
|
||||
return False
|
||||
|
||||
sans_ext = "subjectAltName=" + ",".join(san_entries)
|
||||
|
||||
print(f"Generating server certificate for '{cert_subject_name}' with SANs:")
|
||||
for san in san_entries:
|
||||
print(f" - {san}")
|
||||
|
||||
cert_out = os.path.join(cert_dir, f"{cert_name}_cert.pem")
|
||||
key_out = os.path.join(cert_dir, f"{cert_name}_key.pem")
|
||||
|
||||
if not os.path.isfile(cert_out) or not os.path.isfile(key_out):
|
||||
print("Generating server certificate and key...")
|
||||
req_cmd = [
|
||||
OPENSSL, "req",
|
||||
"-newkey", "rsa:4096",
|
||||
"-keyout", key_out,
|
||||
"-noenc",
|
||||
"-subj", f"/CN={cert_subject_name}",
|
||||
"-addext", "basicConstraints=critical,CA:FALSE",
|
||||
"-addext", "keyUsage=critical,digitalSignature,keyEncipherment",
|
||||
"-addext", "extendedKeyUsage=serverAuth,clientAuth",
|
||||
"-addext", sans_ext,
|
||||
]
|
||||
if aia_url:
|
||||
req_cmd += ["-addext", f"authorityInfoAccess=caIssuers;URI:{aia_url}"]
|
||||
if cdp_url:
|
||||
req_cmd += ["-addext", f"crlDistributionPoints=URI:{cdp_url}"]
|
||||
x509_cmd = [
|
||||
OPENSSL, "x509",
|
||||
"-req",
|
||||
"-CA", ca_cert_path,
|
||||
"-CAkey", ca_key_path,
|
||||
"-copy_extensions", "copyall",
|
||||
"-days", str(days),
|
||||
"-text",
|
||||
"-out", cert_out,
|
||||
]
|
||||
if not _pipe(req_cmd, x509_cmd):
|
||||
_err("Failed to generate server certificate and key.")
|
||||
return False
|
||||
|
||||
_config.append_history(issuing_ca or "ca", {
|
||||
"name": cert_subject_name,
|
||||
"serial": _read_serial(cert_out),
|
||||
"created": _now(),
|
||||
"expires": _read_expiry(cert_out),
|
||||
})
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def make_pfx(cert_path, ca_dir, issuing_ca=None, password=None, apple_openssl=False):
|
||||
if issuing_ca == "ca":
|
||||
_err("--issuing-ca cannot be 'ca' as it is reserved for the root CA.")
|
||||
return False
|
||||
|
||||
cert_dir = os.path.dirname(cert_path)
|
||||
cert_basename = os.path.basename(cert_path)
|
||||
cert_name = cert_basename[:-len("_cert.pem")] if cert_basename.endswith("_cert.pem") else cert_basename
|
||||
key_path = os.path.join(cert_dir, f"{cert_name}_key.pem")
|
||||
|
||||
if not cert_dir or not os.path.isdir(cert_dir):
|
||||
_err(f"Certificate directory {cert_dir} does not exist.")
|
||||
return False
|
||||
|
||||
if not ca_dir or not os.path.isdir(ca_dir):
|
||||
_err(f"CA directory {ca_dir} does not exist.")
|
||||
return False
|
||||
|
||||
if not os.path.isfile(cert_path) or not os.path.isfile(key_path):
|
||||
_err("Server certificate or key not found.")
|
||||
return False
|
||||
|
||||
root_ca_cert_path = os.path.join(ca_dir, "ca_cert.pem")
|
||||
root_ca_key_path = os.path.join(ca_dir, "ca_key.pem")
|
||||
if not os.path.isfile(root_ca_cert_path) or not os.path.isfile(root_ca_key_path):
|
||||
_err(f"CA certificate or key not found in {ca_dir}.")
|
||||
return False
|
||||
|
||||
if issuing_ca:
|
||||
issuing_ca_cert_path = os.path.join(ca_dir, issuing_ca, "ca_cert.pem")
|
||||
if not os.path.isfile(issuing_ca_cert_path):
|
||||
_err(f"Issuing CA certificate not found: {issuing_ca_cert_path}.")
|
||||
return False
|
||||
|
||||
if not password:
|
||||
password = "changeit"
|
||||
|
||||
pfx_path = os.path.join(cert_dir, f"{cert_name}.pfx")
|
||||
if os.path.isfile(pfx_path):
|
||||
print("PKCS#12 (PFX) file already exists, aborting generation.")
|
||||
return False
|
||||
|
||||
print("Generating PKCS#12 (PFX) file...", end="")
|
||||
|
||||
chain_bytes = b""
|
||||
with open(root_ca_cert_path, "rb") as f:
|
||||
chain_bytes += f.read()
|
||||
if issuing_ca:
|
||||
with open(issuing_ca_cert_path, "rb") as f:
|
||||
chain_bytes += f.read()
|
||||
|
||||
chain_fd, chain_file = tempfile.mkstemp()
|
||||
try:
|
||||
with os.fdopen(chain_fd, "wb") as f:
|
||||
f.write(chain_bytes)
|
||||
cmd = [
|
||||
"/usr/bin/openssl" if apple_openssl else OPENSSL, "pkcs12",
|
||||
"-export", "-out", pfx_path,
|
||||
"-inkey", key_path,
|
||||
"-in", cert_path,
|
||||
"-certfile", chain_file,
|
||||
"-password", f"pass:{password}",
|
||||
]
|
||||
if subprocess.run(cmd).returncode != 0:
|
||||
_err("Failed to generate PKCS#12 (PFX) file.")
|
||||
return False
|
||||
finally:
|
||||
if os.path.exists(chain_file):
|
||||
os.remove(chain_file)
|
||||
|
||||
print("done.")
|
||||
return True
|
||||
|
||||
|
||||
def make_crl(ca_dir, issuing_ca=None, days=30):
|
||||
signing_dir = os.path.join(ca_dir, issuing_ca) if issuing_ca else ca_dir
|
||||
ca_cert = os.path.join(signing_dir, "ca_cert.pem")
|
||||
ca_key = os.path.join(signing_dir, "ca_key.pem")
|
||||
|
||||
if not os.path.isfile(ca_cert) or not os.path.isfile(ca_key):
|
||||
_err(f"CA certificate or key not found in {signing_dir}.")
|
||||
return False
|
||||
|
||||
crl_path = os.path.join(signing_dir, "crl.pem")
|
||||
|
||||
history_key = issuing_ca or "ca"
|
||||
revoked_entries = [
|
||||
e for e in _config.get("history", {}).get(history_key, [])
|
||||
if "revoked" in e and "expires" in e
|
||||
]
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index_txt = os.path.join(tmp, "index.txt")
|
||||
crlnumber = os.path.join(tmp, "crlnumber")
|
||||
cnf_path = os.path.join(tmp, "openssl.cnf")
|
||||
|
||||
with open(index_txt, "w") as f:
|
||||
for e in revoked_entries:
|
||||
expires_asn1 = _iso_to_asn1(e["expires"])
|
||||
revoked_asn1 = _iso_to_asn1(e["revoked"])
|
||||
f.write(f"R\t{expires_asn1}\t{revoked_asn1}\t{e['serial']}\tunknown\t/CN={e['name']}\n")
|
||||
with open(crlnumber, "w") as f:
|
||||
f.write("01\n")
|
||||
with open(cnf_path, "w") as f:
|
||||
f.write(
|
||||
"[ ca ]\ndefault_ca = CA_default\n\n"
|
||||
"[ CA_default ]\n"
|
||||
f"database = {index_txt}\n"
|
||||
f"crlnumber = {crlnumber}\n"
|
||||
f"certificate = {ca_cert}\n"
|
||||
f"private_key = {ca_key}\n"
|
||||
f"default_crl_days = {days}\n"
|
||||
"default_md = sha256\n\n"
|
||||
"[ crl_ext ]\n"
|
||||
"authorityKeyIdentifier = keyid:always\n"
|
||||
)
|
||||
|
||||
if subprocess.run([OPENSSL, "ca", "-gencrl", "-config", cnf_path, "-out", crl_path]).returncode != 0:
|
||||
_err("Failed to generate CRL.")
|
||||
return False
|
||||
|
||||
print(f"CRL written to {crl_path}")
|
||||
return True
|
||||
|
||||
|
||||
def revoke_cert(cert_path, ca_dir, issuing_ca=None):
|
||||
if not os.path.isfile(cert_path):
|
||||
_err(f"Certificate not found: {cert_path}")
|
||||
return False
|
||||
|
||||
signing_dir = os.path.join(ca_dir, issuing_ca) if issuing_ca else ca_dir
|
||||
if not os.path.isdir(signing_dir):
|
||||
_err(f"CA directory not found: {signing_dir}")
|
||||
return False
|
||||
|
||||
ca_key = issuing_ca or "ca"
|
||||
serial = _read_serial(cert_path)
|
||||
result = _config.revoke_in_history(ca_key, serial, _now())
|
||||
|
||||
if result is None:
|
||||
_err(f"Certificate with serial {serial} not found in history for CA '{ca_key}'.")
|
||||
return False
|
||||
if result is False:
|
||||
print(f"Certificate {cert_path} (serial {serial}) is already revoked.")
|
||||
return True
|
||||
|
||||
print(f"Certificate {cert_path} (serial {serial}) marked as revoked.")
|
||||
return True
|
||||
|
||||
|
||||
def _build_parser():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Simple CA for creating and managing test certificates."
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
p_ca = sub.add_parser("make-ca", help="Create a root or issuing CA.")
|
||||
p_ca.add_argument("--days", type=int, default=None, help="Validity period in days (default: 3650)")
|
||||
p_ca.add_argument("--issuing-ca", default=None, help="Specify the issuing CA")
|
||||
p_ca.add_argument("--ca-publish-base-url", default=None,
|
||||
help="Base URL for AIA and CRL distribution point extensions")
|
||||
p_ca.add_argument("--ca-dir", help="Directory to store the CA files")
|
||||
p_ca.add_argument("--openssl", default=None, metavar="PATH",
|
||||
help=f"Path to the openssl binary (default: {OPENSSL})")
|
||||
p_ca.add_argument("ca_name", help="Name of the CA")
|
||||
|
||||
p_cert = sub.add_parser("make-cert", help="Create a server/client certificate.")
|
||||
p_cert.add_argument("--ca-dir", help="Directory of the CA")
|
||||
p_cert.add_argument("--issuing-ca", default=None, help="Specify the issuing CA")
|
||||
p_cert.add_argument("--days", type=int, default=None, help="Validity period in days (default: 365)")
|
||||
p_cert.add_argument("--cert-dir", help="Directory to store the certificate files")
|
||||
p_cert.add_argument("--openssl", default=None, metavar="PATH",
|
||||
help=f"Path to the openssl binary (default: {OPENSSL})")
|
||||
p_cert.add_argument("subject_name", help="Subject name for the certificate")
|
||||
p_cert.add_argument("sans", nargs="*", help="Subject Alternative Names (SANs) for the certificate")
|
||||
|
||||
p_pfx = sub.add_parser("make-pfx", help="Create a PKCS#12 (PFX) bundle.")
|
||||
p_pfx.add_argument("--issuing-ca", default=None, help="Specify the issuing CA")
|
||||
p_pfx.add_argument("--ca-dir", help="Directory of the CA")
|
||||
p_pfx.add_argument("--password", help="Password for the PFX file")
|
||||
p_pfx.add_argument("--apple-openssl", action="store_true", default=False,
|
||||
help="Use Apple's bundled /usr/bin/openssl for PKCS12 generation")
|
||||
p_pfx.add_argument("path", help="Path to the certificate file")
|
||||
|
||||
p_crl = sub.add_parser("make-crl", help="Generate a CRL for a CA.")
|
||||
p_crl.add_argument("--ca-dir", help="Directory of the CA")
|
||||
p_crl.add_argument("--issuing-ca", default=None, help="Generate CRL for this issuing CA")
|
||||
p_crl.add_argument("--days", type=int, default=None, help="CRL validity in days (default: 30)")
|
||||
|
||||
p_rev = sub.add_parser("revoke-cert", help="Revoke a certificate.")
|
||||
p_rev.add_argument("--ca-dir", help="Directory of the CA")
|
||||
p_rev.add_argument("--issuing-ca", default=None, help="Issuing CA that signed the certificate")
|
||||
p_rev.add_argument("cert_path", help="Path to the certificate file to revoke")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
global OPENSSL, _config
|
||||
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
ca_dir = args.ca_dir or os.environ.get("SIMPLE_CA_DIR") or os.getcwd()
|
||||
|
||||
_config = Config(ca_dir)
|
||||
|
||||
OPENSSL = getattr(args, "openssl", None) or _config.get("openssl", OPENSSL)
|
||||
issuing_ca = args.issuing_ca or _config.get("issuing_ca")
|
||||
ca_publish_base_url = getattr(args, "ca_publish_base_url", None) or _config.get("ca_publish_base_url")
|
||||
|
||||
days_cfg = _config.get("days", {})
|
||||
|
||||
if args.command == "make-ca":
|
||||
days = args.days or days_cfg.get("ca", 3650)
|
||||
ok = make_ca(
|
||||
ca_dir, args.ca_name,
|
||||
days=days,
|
||||
issuing_ca=issuing_ca,
|
||||
ca_publish_base_url=ca_publish_base_url,
|
||||
)
|
||||
elif args.command == "make-cert":
|
||||
days = args.days or days_cfg.get("cert", 365)
|
||||
ok = make_cert(
|
||||
args.subject_name,
|
||||
sans=args.sans,
|
||||
ca_dir=ca_dir,
|
||||
cert_dir=getattr(args, "cert_dir", None),
|
||||
issuing_ca=issuing_ca,
|
||||
days=days,
|
||||
ca_publish_base_url=ca_publish_base_url,
|
||||
)
|
||||
elif args.command == "make-pfx":
|
||||
ok = make_pfx(
|
||||
args.path, ca_dir,
|
||||
issuing_ca=issuing_ca,
|
||||
password=args.password,
|
||||
apple_openssl=args.apple_openssl,
|
||||
)
|
||||
elif args.command == "make-crl":
|
||||
days = args.days or days_cfg.get("crl", 30)
|
||||
ok = make_crl(ca_dir, issuing_ca=issuing_ca, days=days)
|
||||
elif args.command == "revoke-cert":
|
||||
ok = revoke_cert(args.cert_path, ca_dir, issuing_ca=issuing_ca)
|
||||
else:
|
||||
parser.error(f"Unknown command: {args.command}")
|
||||
ok = False
|
||||
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -12,41 +12,42 @@ import os
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
|
||||
import jinja2
|
||||
import ldap
|
||||
|
||||
LIB_DIR = pathlib.Path(__file__).resolve().parent
|
||||
if str(LIB_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(LIB_DIR))
|
||||
|
||||
import vpnrouter_ldap
|
||||
import vpnrouter_pki
|
||||
import vpnrouter_platforms
|
||||
import vpnrouter_status
|
||||
from vpnrouter_net import DEFAULT_LISTEN, LISTEN_CHOICES, LOOPBACK, ConfigError, detect_interfaces, interface_addresses, ip_output
|
||||
|
||||
CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.json')
|
||||
CONFIG_LOCK = pathlib.Path('/run/vpn-router/config.lock')
|
||||
TEMPLATE_DIR = pathlib.Path('/usr/share/vpn-router/templates')
|
||||
PKI_DIR = pathlib.Path('/etc/vpn-router/pki')
|
||||
SIMPLE_CA = LIB_DIR / 'simple-ca'
|
||||
|
||||
SWANCTL_CONF_D = pathlib.Path('/etc/swanctl/conf.d')
|
||||
SITE_TO_SITE = SWANCTL_CONF_D / 'site-to-site.conf'
|
||||
ROAD_WARRIOR = SWANCTL_CONF_D / 'road-warrior.conf'
|
||||
#: The drop-in of systemd-resolved that earlier versions wrote for the remote access clients; removed when found.
|
||||
P2S_FORWARDER = pathlib.Path('/etc/systemd/resolved.conf.d/p2s-forwarder.conf')
|
||||
#: The configuration of dnsmasq, the resolver of the remote access clients (vpn-router-resolver.service).
|
||||
RESOLVER_CONF = pathlib.Path('/etc/vpn-router/dnsmasq.conf')
|
||||
WG_CONF = pathlib.Path('/etc/wireguard/wg0.conf')
|
||||
#: The router's WireGuard public key, which setup writes with its private key.
|
||||
WG_PUBLIC_KEY = pathlib.Path('/etc/wireguard/wg0.pub')
|
||||
#: The lines around the [Peer] sections of the users' WireGuard peers in WG_CONF, which setup writes from the directory.
|
||||
WG_PEERS_BEGIN = "# BEGIN WireGuard peers of the users of the router's directory"
|
||||
WG_PEERS_END = "# END WireGuard peers of the users of the router's directory"
|
||||
OPENVPN_CONF = pathlib.Path('/etc/openvpn/server/vpn-router.conf')
|
||||
OPENVPN_LDAP = pathlib.Path('/etc/openvpn/server/vpn-router-ldap.conf')
|
||||
USER_CERT_DIR = PKI_DIR / 'users'
|
||||
|
||||
CA_CERT_DEST = pathlib.Path('/etc/swanctl/x509ca/ca.pem')
|
||||
SERVER_CERT = pathlib.Path('/etc/swanctl/x509/server.pem')
|
||||
SERVER_KEY = pathlib.Path('/etc/swanctl/private/server.key')
|
||||
CRL_DEST = pathlib.Path('/etc/swanctl/x509crl/crl.pem')
|
||||
TRUST_ANCHOR = pathlib.Path('/usr/local/share/ca-certificates/vpn-router-ca.crt')
|
||||
|
||||
# section -> option -> default; the type of the default is the required type.
|
||||
SCHEMA = {
|
||||
@@ -55,21 +56,39 @@ SCHEMA = {
|
||||
'wan': {'local_fqdn': ''},
|
||||
'local': {'cidrs': [], 'int_addr': '', 'int_gateway_ip': ''},
|
||||
'site_to_site': {},
|
||||
'p2s': {'enabled': False, 'address_pool': '', 'ca_name': 'VPN Router CA'},
|
||||
'p2s': {'enabled': False, 'address_pool': '', 'dns_listen': 'internal'},
|
||||
'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820},
|
||||
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
|
||||
'docker': {'mode': 'auto', 'exposed_ports': []},
|
||||
'web': {'enabled': True, 'listen': DEFAULT_LISTEN, 'port': 7081, 'database': '', 'trusted_proxies': [],
|
||||
'status_content': 'basic'},
|
||||
'https': {'enabled': True, 'email': '', 'web_port': 7444, 'manager_port': 7443},
|
||||
'status_content': 'basic', 'shell': False, 'shell_idle_minutes': 15, 'shell_auth_timeout': 15,
|
||||
'log_files': []},
|
||||
'ssh': {'ldap_login': False},
|
||||
'https': {'enabled': True, 'email': '', 'web_port': 7444, 'manager_port': 7443, 'caddy_source': 'ubuntu'},
|
||||
'oidc': {'enabled': False},
|
||||
'sites': {'enabled': False, 'upload_max_mb': 512, 'webdav': False, 'webdav_port': 7445},
|
||||
'dns': {'enabled': False, 'zone': '', 'nameserver': '', 'addresses': [], 'listen': 'external', 'ttl': 300},
|
||||
'pki': {'root_name': 'VPN Router Root CA', 'root_days': 7300, 'ca_days': 3650, 'key': 'rsa-4096',
|
||||
'crl_days': 7, 'root_crl_days': 180, 'publish': ['http'], 'p2s_name': 'VPN Router P2S CA',
|
||||
's2s_name': 'VPN Router Site-to-Site CA', 'issuing_cas': {}, 'templates': {},
|
||||
'extended_key_usages': {}, 'root_key_warning': True},
|
||||
'metrics': {'enabled': True, 'listen': 'internal', 'port': 9464, 'https_port': 9465, 'service_account': 'metrics',
|
||||
'otlp_endpoint': '', 'otlp_username': '', 'otlp_password': '',
|
||||
'keep_details': '24 hours', 'keep_hourly': '1 month', 'keep_daily': '3 months',
|
||||
'keep_monthly': '1 year', 'keep_yearly': '10 years'},
|
||||
'backup': {'enabled': False, 'schedule': 'daily', 'target': 'local', 'path': '/var/backups/vpn-router',
|
||||
'keep_hourly': 24, 'keep_daily': 7, 'keep_weekly': 4, 'keep_monthly': 12, 'keep_yearly': 0,
|
||||
'metrics': False, 'rclone_remote': '', 'rclone_mode': 'sync',
|
||||
'pbs_repository': '', 'pbs_password': '', 'pbs_fingerprint': '', 'pbs_namespace': '',
|
||||
'pbs_keyfile': ''},
|
||||
}
|
||||
|
||||
#: site_to_site maps a connection name to its settings: option -> default, the type of the default being the required type.
|
||||
CONNECTION = {'addrs': [], 'id': '', 'cidrs': [], 'local_cidrs': [], 'local_id_mode': 'fqdn', 'psk': '', 'psk_file': ''}
|
||||
CONNECTION = {'addrs': [], 'id': '', 'cidrs': [], 'local_cidrs': [], 'local_id_mode': 'fqdn', 'auth': 'psk', 'psk': '',
|
||||
'psk_file': '', 'remote_ca': ''}
|
||||
|
||||
#: How a site_to_site connection authenticates: a pre-shared key, or certificates.
|
||||
CONNECTION_AUTH = ('psk', 'certificate')
|
||||
|
||||
#: The name of a site_to_site connection, which also names its swanctl connection, child and secret.
|
||||
CONNECTION_NAME = re.compile(r'^[a-z][a-z0-9-]{0,30}$')
|
||||
@@ -83,12 +102,138 @@ METRICS_KEEP = ('keep_details', 'keep_hourly', 'keep_daily', 'keep_monthly', 'ke
|
||||
#: A retention period as PostgreSQL reads an interval: a count and a unit.
|
||||
METRICS_INTERVAL = re.compile(r'^[1-9]\d{0,4} (hour|day|month|year)s?$')
|
||||
|
||||
#: How often backups run, as systemd OnCalendar shorthands, from the most to the least often.
|
||||
BACKUP_SCHEDULES = ('hourly', 'daily', 'weekly')
|
||||
|
||||
#: Where backups go: an archive in backup.path, or a Proxmox Backup Server.
|
||||
BACKUP_TARGETS = ('local', 'pbs')
|
||||
|
||||
#: How rclone follows backup.path in the cloud: sync mirrors it, copy deletes nothing remotely.
|
||||
RCLONE_MODES = ('sync', 'copy')
|
||||
|
||||
#: The retention settings of local backups, from the hourly to the yearly ones.
|
||||
BACKUP_KEEP = ('keep_hourly', 'keep_daily', 'keep_weekly', 'keep_monthly', 'keep_yearly')
|
||||
|
||||
#: A remote of rclone.conf and an optional path: remote:path.
|
||||
RCLONE_REMOTE = re.compile(r'^[\w.][\w. -]*:\S*$')
|
||||
|
||||
#: A PBS repository: [[auth-id@]server[:port]:]datastore.
|
||||
PBS_REPOSITORY = re.compile(r'^(\S+:)?[A-Za-z0-9_][A-Za-z0-9._-]*$')
|
||||
|
||||
#: The SHA-256 fingerprint of a server certificate, as PBS shows it.
|
||||
PBS_FINGERPRINT = re.compile(r'^([0-9A-Fa-f]{2}:){31}[0-9A-Fa-f]{2}$')
|
||||
|
||||
#: A PBS namespace: up to seven names joined by /.
|
||||
PBS_NAMESPACE = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9._-]*(/[A-Za-z0-9_][A-Za-z0-9._-]*){0,6}$')
|
||||
|
||||
#: The port of Let's Encrypt's HTTP challenge, which Caddy answers while HTTPS is on.
|
||||
ACME_HTTP_PORT = 80
|
||||
|
||||
#: The default port of HTTPS, where Caddy publishes the CA files when pki.publish has https-default.
|
||||
HTTPS_DEFAULT_PORT = 443
|
||||
|
||||
#: The most files web.log_files lists.
|
||||
LOG_FILES_MAX = 32
|
||||
|
||||
#: The port rclone serves WebDAV on, on the loopback address, behind Caddy.
|
||||
WEBDAV_LOOPBACK_PORT = 7446
|
||||
|
||||
#: The largest sites.upload_max_mb, 10 GiB.
|
||||
UPLOAD_MAX_MB = 10240
|
||||
|
||||
#: The shortest and the longest dns.ttl, in seconds.
|
||||
DNS_TTL_RANGE = (60, 86400)
|
||||
|
||||
#: A DNS name without a wildcard, in lowercase, of dns.zone and dns.nameserver.
|
||||
ZONE_NAME = re.compile(r'^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$')
|
||||
|
||||
#: The ports of the bootstrap services: the Router Manager's and the router's.
|
||||
BOOTSTRAP_PORTS = (17080, 17081)
|
||||
|
||||
#: The Router Manager, vpn-router-manager, when it is installed, its settings and its port when they set none.
|
||||
MANAGER = pathlib.Path('/usr/lib/vpn-router-manager/manager')
|
||||
MANAGER_CONFIG = pathlib.Path('/etc/vpn-router-manager/manager.json')
|
||||
MANAGER_PORT = 7080
|
||||
|
||||
#: Where vpn-router-setup installs Caddy from, https.caddy_source: the Ubuntu archive or Caddy's own repository.
|
||||
CADDY_SOURCES = ('ubuntu', 'upstream')
|
||||
|
||||
#: Caddy's own repository, which https.caddy_source upstream configures: the source, its signing key and their URLs.
|
||||
CADDY_APT_SOURCE = pathlib.Path('/etc/apt/sources.list.d/caddy-stable.sources')
|
||||
CADDY_KEYRING = pathlib.Path('/etc/apt/keyrings/caddy-stable.asc')
|
||||
CADDY_KEY_URL = 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key'
|
||||
CADDY_REPOSITORY = 'https://dl.cloudsmith.io/public/caddy/stable/deb/debian'
|
||||
|
||||
#: The key types of the CAs, pki.key, and of the templates.
|
||||
PKI_KEYS = ('rsa-2048', 'rsa-3072', 'rsa-4096', 'ec-p256', 'ec-p384')
|
||||
|
||||
#: Where the CA certificates and CRLs are published, pki.publish, as the URLs in the certificates list them.
|
||||
PKI_PUBLISH = ('http', 'http-80', 'https', 'https-default')
|
||||
|
||||
#: The key usages a template may give, as the arguments of cryptography's KeyUsage.
|
||||
KEY_USAGES = ('digital_signature', 'content_commitment', 'key_encipherment', 'data_encipherment', 'key_agreement')
|
||||
|
||||
#: The built-in extended key usages a template may give, name -> OID; pki.extended_key_usages adds custom ones.
|
||||
EXTENDED_KEY_USAGES = {
|
||||
'server_auth': '1.3.6.1.5.5.7.3.1',
|
||||
'client_auth': '1.3.6.1.5.5.7.3.2',
|
||||
'code_signing': '1.3.6.1.5.5.7.3.3',
|
||||
'email_protection': '1.3.6.1.5.5.7.3.4',
|
||||
'time_stamping': '1.3.6.1.5.5.7.3.8',
|
||||
'ocsp_signing': '1.3.6.1.5.5.7.3.9',
|
||||
'ipsec_ike': '1.3.6.1.5.5.7.3.17',
|
||||
'ike_intermediate': '1.3.6.1.5.5.8.2.2',
|
||||
}
|
||||
|
||||
#: The name of a custom extended key usage of pki.extended_key_usages.
|
||||
EKU_NAME = re.compile(r'^[a-z][a-z0-9_-]{0,30}$')
|
||||
|
||||
#: An object identifier in dotted form, of a custom extended key usage or a certificate policy.
|
||||
OID = re.compile(r'^[0-2](\.(0|[1-9][0-9]*)){1,63}$')
|
||||
|
||||
#: What the common name of a template's certificates is: a DNS name, any name, or an e-mail address.
|
||||
TEMPLATE_SUBJECTS = ('dns', 'name', 'email')
|
||||
|
||||
#: The subject alternative names a template's certificates may have.
|
||||
SAN_TYPES = ('dns', 'ip', 'email')
|
||||
|
||||
#: A certificate template: option -> default, the type of the default being the required type.
|
||||
TEMPLATE = {'key': 'rsa-3072', 'days': 365, 'key_usage': ['digital_signature'], 'extended_key_usage': [],
|
||||
'subject': 'name', 'sans': [], 'policies': []}
|
||||
|
||||
#: The built-in templates; pki.templates changes their options and adds templates of its own.
|
||||
PKI_TEMPLATES = {
|
||||
'vpn-server': {'key_usage': ['digital_signature', 'key_encipherment'],
|
||||
'extended_key_usage': ['server_auth', 'ike_intermediate'], 'subject': 'dns', 'sans': ['dns', 'ip']},
|
||||
'vpn-user': {'extended_key_usage': ['client_auth'], 'subject': 'name', 'sans': ['dns', 'email']},
|
||||
'site-to-site': {'key_usage': ['digital_signature', 'key_encipherment'],
|
||||
'extended_key_usage': ['server_auth', 'client_auth'], 'subject': 'dns', 'sans': ['dns', 'ip']},
|
||||
'server': {'key_usage': ['digital_signature', 'key_encipherment'], 'extended_key_usage': ['server_auth'],
|
||||
'subject': 'dns', 'sans': ['dns', 'ip']},
|
||||
'client': {'extended_key_usage': ['client_auth'], 'subject': 'name', 'sans': ['dns', 'email']},
|
||||
}
|
||||
|
||||
#: The templates only the router issues: its VPN server certificate and the users' certificates.
|
||||
ROUTER_TEMPLATES = ('vpn-server', 'vpn-user')
|
||||
|
||||
#: The built-in issuing CAs: name -> the pki option of the common name and the templates the CA issues.
|
||||
BUILTIN_CAS = {'p2s': ('p2s_name', ['vpn-server', 'vpn-user']), 's2s': ('s2s_name', ['site-to-site'])}
|
||||
|
||||
#: A general-purpose issuing CA of pki.issuing_cas: option -> default, the type of the default being the required type.
|
||||
ISSUING_CA = {'name': '', 'templates': [], 'acme': False}
|
||||
|
||||
#: The name of an issuing CA or a template; an issuing CA's name also names its files.
|
||||
PKI_NAME = re.compile(r'^[a-z][a-z0-9-]{0,30}$')
|
||||
|
||||
#: The longest common name of a CA, in characters.
|
||||
CA_NAME_MAX = 64
|
||||
|
||||
#: What / and /status of the web service show, web.status_content, from the least to the most.
|
||||
STATUS_CONTENTS = ('basic', 'standard', 'full')
|
||||
|
||||
#: The longest web.shell_idle_minutes and web.shell_auth_timeout, a day.
|
||||
SHELL_IDLE_MAX = 1440
|
||||
|
||||
DOCKER_MODES = ('auto', 'on', 'off')
|
||||
|
||||
OPENVPN_PROTOCOLS = ('udp', 'tcp')
|
||||
@@ -96,9 +241,6 @@ OPENVPN_PROTOCOLS = ('udp', 'tcp')
|
||||
#: The tun device of the OpenVPN server.
|
||||
OPENVPN_DEVICE = 'ovpn0'
|
||||
|
||||
#: Days a certificate revocation list is valid; the router only reads its own lists.
|
||||
CRL_DAYS = 3650
|
||||
|
||||
#: A port or a port range as iptables multiport takes it: 443 or 8000:8100.
|
||||
DOCKER_PORT = re.compile(r'^\d{1,5}(:\d{1,5})?$')
|
||||
|
||||
@@ -308,6 +450,10 @@ def validate(cfg):
|
||||
f'{prefix}.local_id_mode: {id_mode!r} is not one of {", ".join(ID_MODES)}')
|
||||
_check_networks(_clean(connection['cidrs']), f'{prefix}.cidrs')
|
||||
_check_networks(_clean(connection['local_cidrs']), f'{prefix}.local_cidrs')
|
||||
if connection['auth'] not in CONNECTION_AUTH:
|
||||
raise ConfigError(f'{prefix}.auth: {connection["auth"]!r} is not one of {", ".join(CONNECTION_AUTH)}')
|
||||
if connection['remote_ca'].strip() and not vpnrouter_pki.is_ca_certificate(connection['remote_ca']):
|
||||
raise ConfigError(f'{prefix}.remote_ca is not the PEM certificate of a CA')
|
||||
|
||||
for option, value in identities:
|
||||
if value.startswith('@'):
|
||||
@@ -325,6 +471,9 @@ def validate(cfg):
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise ConfigError(f'openvpn.address_pool: {openvpn_pool!r} is not an IPv4 network')
|
||||
for system, option in (('p2s', 'address_pool'), ('openvpn', 'address_pool'), ('wireguard', 'address')):
|
||||
if _get(cfg, system, 'enabled') and not _get(cfg, system, option):
|
||||
raise ConfigError(f'{system}.enabled needs {system}.{option}')
|
||||
_check_address(_get(cfg, 'local', 'int_addr'), 'local.int_addr')
|
||||
_check_address(_get(cfg, 'local', 'int_gateway_ip'), 'local.int_gateway_ip')
|
||||
|
||||
@@ -339,10 +488,16 @@ def validate(cfg):
|
||||
wg_address = _get(cfg, 'wireguard', 'address')
|
||||
if wg_address:
|
||||
try:
|
||||
ipaddress.ip_interface(wg_address)
|
||||
interface = ipaddress.ip_interface(wg_address)
|
||||
except ValueError:
|
||||
raise ConfigError(
|
||||
f'wireguard.address: {wg_address!r} is not an address with prefix')
|
||||
# The router's own address on wg0, from which the peers' addresses of the same network are told apart.
|
||||
network = interface.network
|
||||
if network.num_addresses > 2 and interface.ip in (network.network_address, network.broadcast_address):
|
||||
raise ConfigError(
|
||||
f'wireguard.address: {wg_address} is the network or broadcast address of {network}; give the '
|
||||
f'router\'s own address in it, such as {next(network.hosts())}/{network.prefixlen}')
|
||||
|
||||
port = _get(cfg, 'wireguard', 'listen_port')
|
||||
if not 1 <= port <= 65535:
|
||||
@@ -371,6 +526,13 @@ def validate(cfg):
|
||||
raise ConfigError(
|
||||
f'web.status_content: {status_content!r} is not one of {", ".join(STATUS_CONTENTS)}')
|
||||
|
||||
shell_idle = _get(cfg, 'web', 'shell_idle_minutes')
|
||||
if not 1 <= shell_idle <= SHELL_IDLE_MAX:
|
||||
raise ConfigError(f'web.shell_idle_minutes: {shell_idle} is not between 1 and {SHELL_IDLE_MAX}')
|
||||
shell_auth = _get(cfg, 'web', 'shell_auth_timeout')
|
||||
if not 0 <= shell_auth <= SHELL_IDLE_MAX:
|
||||
raise ConfigError(f'web.shell_auth_timeout: {shell_auth} is not between 0 and {SHELL_IDLE_MAX}')
|
||||
|
||||
https_ports = []
|
||||
for option in ('web_port', 'manager_port'):
|
||||
port = _get(cfg, 'https', option)
|
||||
@@ -385,15 +547,150 @@ def validate(cfg):
|
||||
for item in _get_list(cfg, 'web', 'trusted_proxies'):
|
||||
_check_address(item, 'web.trusted_proxies')
|
||||
|
||||
log_files = _get_list(cfg, 'web', 'log_files')
|
||||
if len(log_files) > LOG_FILES_MAX:
|
||||
raise ConfigError(f'web.log_files: at most {LOG_FILES_MAX} files')
|
||||
for item in log_files:
|
||||
if not item.startswith('/') or '/../' in f'{item}/':
|
||||
raise ConfigError(f'web.log_files: {item!r} is not an absolute path')
|
||||
|
||||
caddy_source = _get(cfg, 'https', 'caddy_source')
|
||||
if caddy_source not in CADDY_SOURCES:
|
||||
raise ConfigError(f'https.caddy_source: {caddy_source!r} is not one of {", ".join(CADDY_SOURCES)}')
|
||||
|
||||
if _get(cfg, 'oidc', 'enabled') and not (_get(cfg, 'web', 'enabled') and _get(cfg, 'https', 'enabled')
|
||||
and _get(cfg, 'wan', 'local_fqdn')):
|
||||
raise ConfigError('oidc.enabled: the OpenID Provider is the web service served by Caddy at '
|
||||
'https://<wan.local_fqdn>:<https.web_port>, which needs web.enabled, https.enabled and '
|
||||
'wan.local_fqdn')
|
||||
|
||||
upload_max = _get(cfg, 'sites', 'upload_max_mb')
|
||||
if not 1 <= upload_max <= UPLOAD_MAX_MB:
|
||||
raise ConfigError(f'sites.upload_max_mb: {upload_max} is not between 1 and {UPLOAD_MAX_MB}')
|
||||
if _get(cfg, 'sites', 'webdav') and not (_get(cfg, 'https', 'enabled') and _get(cfg, 'wan', 'local_fqdn')):
|
||||
raise ConfigError('sites.webdav: WebDAV is served by Caddy at https://<wan.local_fqdn>:<sites.webdav_port>, '
|
||||
'which needs https.enabled and wan.local_fqdn')
|
||||
|
||||
dns = cfg['dns']
|
||||
if dns['enabled']:
|
||||
for option in ('zone', 'nameserver'):
|
||||
if not ZONE_NAME.match(dns[option].strip()):
|
||||
raise ConfigError(f'dns.{option}: {dns[option]!r} is not a DNS name in lowercase')
|
||||
if not _get_list(cfg, 'dns', 'addresses'):
|
||||
raise ConfigError('dns.enabled needs dns.addresses, the public addresses of the zone')
|
||||
for item in _get_list(cfg, 'dns', 'addresses'):
|
||||
_check_address(item, 'dns.addresses')
|
||||
if dns['listen'] not in LISTEN_CHOICES:
|
||||
raise ConfigError(f'dns.listen: {dns["listen"]!r} is not one of {", ".join(LISTEN_CHOICES)}')
|
||||
if not DNS_TTL_RANGE[0] <= dns['ttl'] <= DNS_TTL_RANGE[1]:
|
||||
raise ConfigError(f'dns.ttl: {dns["ttl"]} is not between {DNS_TTL_RANGE[0]} and {DNS_TTL_RANGE[1]} seconds')
|
||||
resolver_listen = _get(cfg, 'p2s', 'dns_listen')
|
||||
if resolver_listen not in LISTEN_CHOICES:
|
||||
raise ConfigError(f'p2s.dns_listen: {resolver_listen!r} is not one of {", ".join(LISTEN_CHOICES)}')
|
||||
sides = {'internal': {'internal'}, 'external': {'external'}, 'both': {'internal', 'external'}}
|
||||
remote_access = any(_get(cfg, system, 'enabled') for system in ('p2s', 'openvpn', 'wireguard'))
|
||||
if dns['enabled'] and remote_access and sides[dns['listen']] & sides[resolver_listen]:
|
||||
raise ConfigError(f'dns.listen: port 53 of the {" and ".join(sorted(sides[dns["listen"]] & sides[resolver_listen]))} '
|
||||
f'addresses is the resolver of the remote access clients (p2s.dns_listen {resolver_listen}) '
|
||||
'while p2s.enabled, openvpn.enabled or wireguard.enabled is true')
|
||||
|
||||
for option in ('root_name', 'p2s_name', 's2s_name'):
|
||||
value = _get(cfg, 'pki', option)
|
||||
if not value or len(value) > CA_NAME_MAX or not value.isprintable():
|
||||
raise ConfigError(f'pki.{option} must have 1 to {CA_NAME_MAX} printable characters')
|
||||
for option in ('root_days', 'ca_days', 'crl_days', 'root_crl_days'):
|
||||
if not 1 <= _get(cfg, 'pki', option) <= 36500:
|
||||
raise ConfigError(f'pki.{option} must be 1 to 36500 days')
|
||||
if _get(cfg, 'pki', 'key') not in PKI_KEYS:
|
||||
raise ConfigError(f'pki.key: {_get(cfg, "pki", "key")!r} is not one of {", ".join(PKI_KEYS)}')
|
||||
publish = _get_list(cfg, 'pki', 'publish')
|
||||
for item in publish:
|
||||
if item not in PKI_PUBLISH:
|
||||
raise ConfigError(f'pki.publish: {item!r} is not one of {", ".join(PKI_PUBLISH)}')
|
||||
if len(set(publish)) != len(publish):
|
||||
raise ConfigError('pki.publish names an endpoint twice')
|
||||
if publish and not _get(cfg, 'wan', 'local_fqdn'):
|
||||
raise ConfigError('pki.publish needs wan.local_fqdn, the name in the published URLs')
|
||||
if 'http' in publish and not _get(cfg, 'web', 'enabled'):
|
||||
raise ConfigError('pki.publish: http is served by the web service, which needs web.enabled')
|
||||
if set(publish) - {'http'} and not _get(cfg, 'https', 'enabled'):
|
||||
raise ConfigError('pki.publish: http-80, https and https-default are served by Caddy, which needs https.enabled')
|
||||
for name, oid in cfg['pki']['extended_key_usages'].items():
|
||||
prefix = f'pki.extended_key_usages.{name}'
|
||||
if not EKU_NAME.match(name) or name in EXTENDED_KEY_USAGES:
|
||||
raise ConfigError(f'{prefix}: an extended key usage name starts with a lowercase letter, has at most 31 '
|
||||
'lowercase letters, digits, hyphens and underscores, and is not a built-in one')
|
||||
if not isinstance(oid, str) or not OID.match(oid):
|
||||
raise ConfigError(f'{prefix} must be an OID in dotted form, such as 1.3.6.1.4.1.311.20.2.2')
|
||||
usages = extended_key_usages(cfg)
|
||||
for name, template in cfg['pki']['templates'].items():
|
||||
prefix = f'pki.templates.{name}'
|
||||
if not PKI_NAME.match(name):
|
||||
raise ConfigError(f'{prefix}: a template name starts with a lowercase letter and has at most 31 lowercase '
|
||||
'letters, digits and hyphens')
|
||||
if not isinstance(template, dict):
|
||||
raise ConfigError(f'{prefix} must be {TYPE_NAMES[dict]}')
|
||||
_check_options(template, TEMPLATE, prefix)
|
||||
templates = pki_templates(cfg)
|
||||
for name, template in templates.items():
|
||||
prefix = f'pki.templates.{name}'
|
||||
if template['key'] not in PKI_KEYS:
|
||||
raise ConfigError(f'{prefix}.key: {template["key"]!r} is not one of {", ".join(PKI_KEYS)}')
|
||||
if not 1 <= template['days'] <= 36500:
|
||||
raise ConfigError(f'{prefix}.days must be 1 to 36500 days')
|
||||
for option, allowed in (('key_usage', KEY_USAGES), ('extended_key_usage', tuple(usages)),
|
||||
('sans', SAN_TYPES)):
|
||||
for item in template[option]:
|
||||
if item not in allowed:
|
||||
raise ConfigError(f'{prefix}.{option}: {item!r} is not one of {", ".join(allowed)}')
|
||||
for item in template['policies']:
|
||||
if not OID.match(item):
|
||||
raise ConfigError(f'{prefix}.policies: {item!r} is not an OID in dotted form, such as 1.3.6.1.4.1.99999.1')
|
||||
if not template['key_usage']:
|
||||
raise ConfigError(f'{prefix}.key_usage needs at least one usage')
|
||||
if template['subject'] not in TEMPLATE_SUBJECTS:
|
||||
raise ConfigError(f'{prefix}.subject: {template["subject"]!r} is not one of {", ".join(TEMPLATE_SUBJECTS)}')
|
||||
for name, authority in cfg['pki']['issuing_cas'].items():
|
||||
prefix = f'pki.issuing_cas.{name}'
|
||||
if not PKI_NAME.match(name) or name in BUILTIN_CAS or name == 'root':
|
||||
raise ConfigError(f'{prefix}: an issuing CA name starts with a lowercase letter and has at most 31 '
|
||||
f'lowercase letters, digits and hyphens, and is not root, {" or ".join(BUILTIN_CAS)}')
|
||||
if not isinstance(authority, dict):
|
||||
raise ConfigError(f'{prefix} must be {TYPE_NAMES[dict]}')
|
||||
_check_options(authority, ISSUING_CA, prefix)
|
||||
for name, authority in issuing_cas(cfg).items():
|
||||
prefix = f'pki.issuing_cas.{name}'
|
||||
if authority['builtin']:
|
||||
continue
|
||||
common_name = authority['name'].strip()
|
||||
if not common_name or len(common_name) > CA_NAME_MAX or not common_name.isprintable():
|
||||
raise ConfigError(f'{prefix}.name must have 1 to {CA_NAME_MAX} printable characters')
|
||||
if not authority['templates']:
|
||||
raise ConfigError(f'{prefix}.templates needs at least one template')
|
||||
for template in authority['templates']:
|
||||
if template not in templates:
|
||||
raise ConfigError(f'{prefix}.templates: {template!r} is not a template')
|
||||
if template in ROUTER_TEMPLATES:
|
||||
raise ConfigError(f'{prefix}.templates: {template} is issued by the P2S CA only')
|
||||
if authority['acme'] and not _get(cfg, 'https', 'enabled'):
|
||||
raise ConfigError(f'{prefix}.acme: the ACME server is Caddy\'s, which needs https.enabled')
|
||||
|
||||
taken = {web_port: 'web.port', ACME_HTTP_PORT: 'the HTTP challenge',
|
||||
https_ports[0]: 'https.web_port', https_ports[1]: 'https.manager_port'}
|
||||
for option in ('port', 'https_port'):
|
||||
port = _get(cfg, 'metrics', option)
|
||||
if 'https-default' in publish:
|
||||
if HTTPS_DEFAULT_PORT in taken:
|
||||
raise ConfigError(f'pki.publish: https-default takes port {HTTPS_DEFAULT_PORT}, the port of '
|
||||
f'{taken[HTTPS_DEFAULT_PORT]}')
|
||||
if _get(cfg, 'openvpn', 'protocol') == 'tcp' and _get(cfg, 'openvpn', 'port') == HTTPS_DEFAULT_PORT:
|
||||
raise ConfigError(f'pki.publish: https-default takes port {HTTPS_DEFAULT_PORT}, the port of openvpn.port')
|
||||
taken[HTTPS_DEFAULT_PORT] = 'pki.publish https-default'
|
||||
for section_name, option in (('metrics', 'port'), ('metrics', 'https_port'), ('sites', 'webdav_port')):
|
||||
port = _get(cfg, section_name, option)
|
||||
if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
|
||||
raise ConfigError(f'metrics.{option}: {port!r} is not a valid port')
|
||||
raise ConfigError(f'{section_name}.{option}: {port!r} is not a valid port')
|
||||
if port in taken:
|
||||
raise ConfigError(f'metrics.{option}: {port} is the port of {taken[port]}')
|
||||
taken[port] = f'metrics.{option}'
|
||||
raise ConfigError(f'{section_name}.{option}: {port} is the port of {taken[port]}')
|
||||
taken[port] = f'{section_name}.{option}'
|
||||
|
||||
metrics_listen = _get(cfg, 'metrics', 'listen')
|
||||
if metrics_listen not in LISTEN_CHOICES:
|
||||
@@ -412,6 +709,46 @@ def validate(cfg):
|
||||
raise ConfigError(
|
||||
f'metrics.{option}: {keep!r} is not a count and a unit, such as "24 hours" or "3 months"')
|
||||
|
||||
schedule = _get(cfg, 'backup', 'schedule')
|
||||
if schedule not in BACKUP_SCHEDULES:
|
||||
raise ConfigError(
|
||||
f'backup.schedule: {schedule!r} is not one of {", ".join(BACKUP_SCHEDULES)}')
|
||||
target = _get(cfg, 'backup', 'target')
|
||||
if target not in BACKUP_TARGETS:
|
||||
raise ConfigError(f'backup.target: {target!r} is not one of {", ".join(BACKUP_TARGETS)}')
|
||||
for option in ('path', 'pbs_keyfile'):
|
||||
value = _get(cfg, 'backup', option)
|
||||
if value and not value.startswith('/'):
|
||||
raise ConfigError(f'backup.{option}: {value!r} is not an absolute path')
|
||||
if not _get(cfg, 'backup', 'path'):
|
||||
raise ConfigError('backup.path is required')
|
||||
for option in BACKUP_KEEP:
|
||||
if _get(cfg, 'backup', option) < 0:
|
||||
raise ConfigError(f'backup.{option} must not be negative')
|
||||
if target == 'local' and not any(_get(cfg, 'backup', option) for option in backup_keeps(schedule)):
|
||||
raise ConfigError(
|
||||
f'backup: with the {schedule} schedule, at least one of {", ".join(backup_keeps(schedule))} '
|
||||
'is above 0')
|
||||
remote = _get(cfg, 'backup', 'rclone_remote')
|
||||
if remote and not RCLONE_REMOTE.match(remote):
|
||||
raise ConfigError(f'backup.rclone_remote: {remote!r} is not a remote and a path, such as azure:backups/router')
|
||||
rclone_mode = _get(cfg, 'backup', 'rclone_mode')
|
||||
if rclone_mode not in RCLONE_MODES:
|
||||
raise ConfigError(f'backup.rclone_mode: {rclone_mode!r} is not one of {", ".join(RCLONE_MODES)}')
|
||||
if target == 'pbs':
|
||||
repository = _get(cfg, 'backup', 'pbs_repository')
|
||||
if not repository or not _get(cfg, 'backup', 'pbs_password'):
|
||||
raise ConfigError('backup.pbs_repository and backup.pbs_password are required with the pbs target')
|
||||
if not PBS_REPOSITORY.match(repository):
|
||||
raise ConfigError(
|
||||
f'backup.pbs_repository: {repository!r} is not of the form user@realm!token@host:datastore')
|
||||
fingerprint = _get(cfg, 'backup', 'pbs_fingerprint')
|
||||
if fingerprint and not PBS_FINGERPRINT.match(fingerprint):
|
||||
raise ConfigError(f'backup.pbs_fingerprint: {fingerprint!r} is not 32 hexadecimal pairs joined by colons')
|
||||
namespace = _get(cfg, 'backup', 'pbs_namespace')
|
||||
if namespace and not PBS_NAMESPACE.match(namespace):
|
||||
raise ConfigError(f'backup.pbs_namespace: {namespace!r} is not up to seven names joined by /')
|
||||
|
||||
docker_mode = _get(cfg, 'docker', 'mode')
|
||||
if docker_mode not in DOCKER_MODES:
|
||||
raise ConfigError(
|
||||
@@ -427,7 +764,8 @@ def validate(cfg):
|
||||
weight += 2 if ':' in item else 1
|
||||
first, _, last = item.partition(':')
|
||||
if _get(cfg, 'https', 'enabled'):
|
||||
for port in (ACME_HTTP_PORT, *https_ports):
|
||||
caddy_ports = (ACME_HTTP_PORT, *https_ports, *([HTTPS_DEFAULT_PORT] if 'https-default' in publish else []))
|
||||
for port in caddy_ports:
|
||||
if int(first) <= port <= int(last or first):
|
||||
raise ConfigError(
|
||||
f'docker.exposed_ports: {item!r} takes port {port}, which Caddy serves while '
|
||||
@@ -438,6 +776,64 @@ def validate(cfg):
|
||||
'a range counting as two')
|
||||
|
||||
|
||||
def backup_keeps(schedule):
|
||||
"""The retention settings of local backups that apply to the schedule: none finer than it."""
|
||||
return BACKUP_KEEP[BACKUP_SCHEDULES.index(schedule):]
|
||||
|
||||
|
||||
def pki_templates(cfg):
|
||||
"""The certificate templates: the built-in ones (PKI_TEMPLATES) and those of pki.templates, each filled from TEMPLATE."""
|
||||
custom = cfg['pki']['templates']
|
||||
return {name: {**TEMPLATE, **PKI_TEMPLATES.get(name, {}),
|
||||
**(custom.get(name) if isinstance(custom.get(name), dict) else {})}
|
||||
for name in [*PKI_TEMPLATES, *(name for name in custom if name not in PKI_TEMPLATES)]}
|
||||
|
||||
|
||||
def extended_key_usages(cfg):
|
||||
"""The extended key usages templates may give, name -> OID: the built-in ones (EXTENDED_KEY_USAGES) and those of
|
||||
pki.extended_key_usages."""
|
||||
custom = cfg['pki']['extended_key_usages']
|
||||
return {**EXTENDED_KEY_USAGES, **{name: oid for name, oid in custom.items() if isinstance(oid, str)}}
|
||||
|
||||
|
||||
def issuing_cas(cfg):
|
||||
"""The issuing CAs, name -> {name, templates, acme, builtin}: p2s and s2s (BUILTIN_CAS) with their common names from
|
||||
the pki section, then those of pki.issuing_cas, each filled from ISSUING_CA."""
|
||||
found = {name: {'name': _get(cfg, 'pki', option), 'templates': list(templates), 'acme': False, 'builtin': True}
|
||||
for name, (option, templates) in BUILTIN_CAS.items()}
|
||||
for name, options in cfg['pki']['issuing_cas'].items():
|
||||
if isinstance(options, dict):
|
||||
found[name] = {**ISSUING_CA, **options, 'builtin': False}
|
||||
return found
|
||||
|
||||
|
||||
def pki_base_urls(cfg):
|
||||
"""The base URLs, ending in /, under which the CA certificates and CRLs are published, in the order of pki.publish."""
|
||||
fqdn = _get(cfg, 'wan', 'local_fqdn')
|
||||
bases = {
|
||||
'http': f'http://{fqdn}:{_get(cfg, "web", "port")}/pki/',
|
||||
'http-80': f'http://{fqdn}/pki/',
|
||||
'https': f'https://{fqdn}:{_get(cfg, "https", "web_port")}/pki/',
|
||||
'https-default': f'https://{fqdn}/pki/',
|
||||
}
|
||||
return [bases[item] for item in _get_list(cfg, 'pki', 'publish') if item in bases] if fqdn else []
|
||||
|
||||
|
||||
def manager_port():
|
||||
"""The port of the Router Manager: port in its settings, or MANAGER_PORT when it is not set or not a port."""
|
||||
try:
|
||||
port = json.loads(MANAGER_CONFIG.read_text(encoding='utf-8')).get('port')
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return MANAGER_PORT
|
||||
return port if isinstance(port, int) and not isinstance(port, bool) and 1 <= port <= 65535 else MANAGER_PORT
|
||||
|
||||
|
||||
def oidc_issuer(cfg):
|
||||
"""The issuer of the OpenID Provider: the web service's HTTPS site, without the port when it is HTTPS_DEFAULT_PORT."""
|
||||
port = _get(cfg, 'https', 'web_port')
|
||||
return f'https://{_get(cfg, "wan", "local_fqdn")}' + ('' if port == HTTPS_DEFAULT_PORT else f':{port}')
|
||||
|
||||
|
||||
def resolve_psk(connection, name):
|
||||
"""Return the pre-shared key of the site_to_site connection: psk_file wins, else psk; '' when none is configured."""
|
||||
psk_file = connection['psk_file'].strip()
|
||||
@@ -546,12 +942,17 @@ def build_context(cfg, public_ip=None):
|
||||
openvpn_pool = _get(cfg, 'openvpn', 'address_pool')
|
||||
openvpn_network = ipaddress.ip_network(openvpn_pool) if openvpn_pool else None
|
||||
identities = {'fqdn': local_fqdn, 'public_ip': public_ip or '', 'internal_ip': int_addr}
|
||||
# The addresses the remote access clients' resolver answers on, which p2s.dns_listen chooses.
|
||||
resolver_addrs = [address for address in {'internal': [int_addr], 'external': local_addrs,
|
||||
'both': [int_addr, *local_addrs]}[_get(cfg, 'p2s', 'dns_listen')]
|
||||
if address]
|
||||
|
||||
connections = []
|
||||
for name, connection in cfg['site_to_site'].items():
|
||||
id_mode = connection['local_id_mode'].strip() or 'fqdn'
|
||||
remote_cidrs = _clean(connection['cidrs'])
|
||||
connection_cidrs = _clean(connection['local_cidrs'])
|
||||
auth = connection['auth']
|
||||
entry = {
|
||||
'name': name,
|
||||
'remote_addrs': ', '.join(_clean(connection['addrs'])),
|
||||
@@ -562,10 +963,13 @@ def build_context(cfg, public_ip=None):
|
||||
'local_cidr_list': connection_cidrs,
|
||||
'local_id_mode': id_mode,
|
||||
'local_id': identities.get(id_mode, ''),
|
||||
'psk': resolve_psk(connection, name),
|
||||
'auth': auth,
|
||||
'psk': resolve_psk(connection, name) if auth == 'psk' else '',
|
||||
'remote_cacert': vpnrouter_pki.remote_ca_file(name) if connection['remote_ca'].strip()
|
||||
else vpnrouter_pki.SWANCTL_CA_FILES['s2s'],
|
||||
}
|
||||
entry['ready'] = bool(local_addrs and connection_cidrs and all(
|
||||
entry[key] for key in ('remote_addrs', 'remote_id', 'remote_cidrs', 'local_id', 'psk')))
|
||||
required = ('remote_addrs', 'remote_id', 'remote_cidrs', 'local_id', *(('psk',) if auth == 'psk' else ()))
|
||||
entry['ready'] = bool(local_addrs and connection_cidrs and all(entry[key] for key in required))
|
||||
connections.append(entry)
|
||||
|
||||
return {
|
||||
@@ -584,11 +988,16 @@ def build_context(cfg, public_ip=None):
|
||||
'connections': connections,
|
||||
'p2s_enabled': _get(cfg, 'p2s', 'enabled'),
|
||||
'p2s_address_pool': _get(cfg, 'p2s', 'address_pool'),
|
||||
'p2s_ca_name': _get(cfg, 'p2s', 'ca_name'),
|
||||
'p2s_dns': _join(local_addrs, _split_list(extras['EXTRA_P2S_DNS'])),
|
||||
'public_ip': public_ip or '',
|
||||
'p2s_resolver_addrs': resolver_addrs,
|
||||
'p2s_dns': _join(resolver_addrs, _split_list(extras['EXTRA_P2S_DNS'])),
|
||||
'wg_enabled': _get(cfg, 'wireguard', 'enabled'),
|
||||
'wg_address': _get(cfg, 'wireguard', 'address'),
|
||||
'wg_network': str(ipaddress.ip_interface(_get(cfg, 'wireguard', 'address')).network)
|
||||
if _get(cfg, 'wireguard', 'address') else '',
|
||||
'wg_listen_port': _get(cfg, 'wireguard', 'listen_port'),
|
||||
'wg_peers_begin': WG_PEERS_BEGIN,
|
||||
'wg_peers_end': WG_PEERS_END,
|
||||
'openvpn_enabled': _get(cfg, 'openvpn', 'enabled'),
|
||||
'openvpn_pool': openvpn_pool,
|
||||
'openvpn_network': str(openvpn_network.network_address) if openvpn_network else '',
|
||||
@@ -599,11 +1008,14 @@ def build_context(cfg, public_ip=None):
|
||||
'openvpn_protocol': _get(cfg, 'openvpn', 'protocol'),
|
||||
'openvpn_device': OPENVPN_DEVICE,
|
||||
'openvpn_ldap_conf': str(OPENVPN_LDAP),
|
||||
'p2s_dns_list': _split_list(_join(local_addrs, _split_list(extras['EXTRA_P2S_DNS']))),
|
||||
'ca_cert': str(CA_CERT_DEST),
|
||||
'server_cert': str(SERVER_CERT),
|
||||
'server_key': str(SERVER_KEY),
|
||||
'crl': str(CRL_DEST),
|
||||
'openvpn_status': vpnrouter_status.OPENVPN_STATUS,
|
||||
'openvpn_status_seconds': vpnrouter_status.OPENVPN_STATUS_SECONDS,
|
||||
'p2s_dns_list': _split_list(_join(resolver_addrs, _split_list(extras['EXTRA_P2S_DNS']))),
|
||||
'openvpn_ca': str(vpnrouter_pki.OPENVPN_CA),
|
||||
'openvpn_crl': str(vpnrouter_pki.OPENVPN_CRL),
|
||||
'server_cert': str(vpnrouter_pki.VPN_SERVER_CERT),
|
||||
'server_key': str(vpnrouter_pki.VPN_SERVER_KEY),
|
||||
'server_certificate': False,
|
||||
'ldap_uri': vpnrouter_ldap.URI,
|
||||
'ldap_users': vpnrouter_ldap.USERS,
|
||||
'web_enabled': _get(cfg, 'web', 'enabled'),
|
||||
@@ -612,10 +1024,17 @@ def build_context(cfg, public_ip=None):
|
||||
'https_email': _get(cfg, 'https', 'email'),
|
||||
'https_web_port': _get(cfg, 'https', 'web_port'),
|
||||
'https_manager_port': _get(cfg, 'https', 'manager_port'),
|
||||
'pki_publish': _get_list(cfg, 'pki', 'publish'),
|
||||
'pki_acme': [name for name, authority in issuing_cas(cfg).items() if authority['acme']],
|
||||
'metrics_enabled': _get(cfg, 'metrics', 'enabled'),
|
||||
'metrics_port': _get(cfg, 'metrics', 'port'),
|
||||
'metrics_https_port': _get(cfg, 'metrics', 'https_port'),
|
||||
'metrics_service_account': _get(cfg, 'metrics', 'service_account'),
|
||||
'sites_enabled': _get(cfg, 'sites', 'enabled'),
|
||||
'webdav_enabled': _get(cfg, 'sites', 'webdav'),
|
||||
'webdav_port': _get(cfg, 'sites', 'webdav_port'),
|
||||
'dns_enabled': _get(cfg, 'dns', 'enabled'),
|
||||
'dns_listen': _get(cfg, 'dns', 'listen'),
|
||||
'loopback': LOOPBACK,
|
||||
}
|
||||
|
||||
@@ -632,7 +1051,8 @@ def routing_ready(ctx):
|
||||
|
||||
|
||||
def p2s_ready(ctx):
|
||||
return bool(ctx['p2s_enabled'] and ctx['p2s_address_pool'] and ctx['local_fqdn'])
|
||||
"""Road-warrior access needs its settings and the router's VPN server certificate, which the P2S CA issues."""
|
||||
return bool(ctx['p2s_enabled'] and ctx['p2s_address_pool'] and ctx['local_fqdn'] and ctx['server_certificate'])
|
||||
|
||||
|
||||
def wg_ready(ctx):
|
||||
@@ -640,7 +1060,8 @@ def wg_ready(ctx):
|
||||
|
||||
|
||||
def openvpn_ready(ctx):
|
||||
return bool(ctx['openvpn_enabled'] and ctx['openvpn_pool'] and ctx['local_fqdn'])
|
||||
"""OpenVPN needs its settings and the router's VPN server certificate, which the P2S CA issues."""
|
||||
return bool(ctx['openvpn_enabled'] and ctx['openvpn_pool'] and ctx['local_fqdn'] and ctx['server_certificate'])
|
||||
|
||||
|
||||
def https_ready(ctx):
|
||||
@@ -653,6 +1074,16 @@ def metrics_ready(ctx):
|
||||
return bool(ctx['metrics_enabled'])
|
||||
|
||||
|
||||
def webdav_ready(ctx):
|
||||
"""WebDAV of the sites' folders is served by Caddy, on the router's own name, while it is on and HTTPS is ready."""
|
||||
return bool(ctx['webdav_enabled'] and https_ready(ctx))
|
||||
|
||||
|
||||
def dns_ready(ctx):
|
||||
"""The name server answers for dns.zone while dns.enabled is true."""
|
||||
return bool(ctx['dns_enabled'])
|
||||
|
||||
|
||||
def _jinja_env():
|
||||
return jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(str(TEMPLATE_DIR)),
|
||||
@@ -676,27 +1107,38 @@ def render(env, ctx, template_name, dest, mode):
|
||||
|
||||
|
||||
def existing_peers(path):
|
||||
"""Return the [Peer] sections of an existing WireGuard config.
|
||||
"""The block of the users' peers and the [Peer] sections added by hand of an existing WireGuard config, each ''
|
||||
without one.
|
||||
|
||||
Peers are added by the operator by hand, so re-rendering the [Interface]
|
||||
section must not discard them.
|
||||
The block lies between WG_PEERS_BEGIN and WG_PEERS_END; the [Peer] sections outside it are the operator's, which
|
||||
re-rendering the file keeps.
|
||||
"""
|
||||
path = pathlib.Path(path)
|
||||
if not path.is_file():
|
||||
return ''
|
||||
lines = path.read_text(encoding='utf-8').splitlines(keepends=True)
|
||||
return '', ''
|
||||
text = path.read_text(encoding='utf-8')
|
||||
block = ''
|
||||
begin, end = text.find(WG_PEERS_BEGIN), text.find(WG_PEERS_END)
|
||||
if begin != -1 and end > begin:
|
||||
block = text[begin:end + len(WG_PEERS_END)]
|
||||
text = text[:begin] + text[end + len(WG_PEERS_END):]
|
||||
lines = text.splitlines(keepends=True)
|
||||
for index, line in enumerate(lines):
|
||||
if line.strip().lower() == '[peer]':
|
||||
return ''.join(lines[index:])
|
||||
return ''
|
||||
return block, ''.join(lines[index:])
|
||||
return block, ''
|
||||
|
||||
|
||||
def render_wg_conf(env, ctx, dest=WG_CONF, mode=0o600):
|
||||
"""Render wg0.conf, carrying any operator-defined peers across."""
|
||||
content = env.get_template('wg0.conf.j2').render(ctx)
|
||||
peers = existing_peers(dest)
|
||||
if peers:
|
||||
content = content.rstrip('\n') + '\n\n' + peers
|
||||
"""Render wg0.conf: the [Interface], the block of the users' peers of ctx's wg_peers, the block the file holds while
|
||||
wg_peers is None, and the peers the operator added by hand, carried across."""
|
||||
content = env.get_template('wg0.conf.j2').render(ctx).rstrip('\n') + '\n'
|
||||
block, own = existing_peers(dest)
|
||||
if ctx['wg_peers'] is not None:
|
||||
block = env.get_template('wg0-peers.conf.j2').render(ctx).strip('\n') if ctx['wg_peers'] else ''
|
||||
for part in (block, own):
|
||||
if part.strip():
|
||||
content += '\n' + part.strip('\n') + '\n'
|
||||
|
||||
dest = pathlib.Path(dest)
|
||||
if dest.exists() and dest.read_text(encoding='utf-8') == content:
|
||||
@@ -731,67 +1173,11 @@ def install_file(src, dest, mode):
|
||||
return True
|
||||
|
||||
|
||||
def _simple_ca(*args):
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(SIMPLE_CA), *args],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
fail(f'simple-ca {" ".join(args)} failed: {result.stderr.strip()}')
|
||||
return result.stdout
|
||||
|
||||
|
||||
def provision_pki(ctx):
|
||||
"""Create the CA and CRL when the PKI directory is empty, and the server certificate once wan.local_fqdn is set.
|
||||
|
||||
A CA of the router's own (ca_key.pem present) without a CRL gets one, and without a
|
||||
certificate for wan.local_fqdn gets one too; nothing present is overwritten.
|
||||
"""
|
||||
PKI_DIR.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(PKI_DIR, 0o700)
|
||||
if not SIMPLE_CA.is_file():
|
||||
fail(f'{SIMPLE_CA} is missing')
|
||||
|
||||
if not any(PKI_DIR.iterdir()):
|
||||
if not ctx['p2s_ca_name']:
|
||||
warn('p2s.ca_name is empty, not creating a local CA')
|
||||
return
|
||||
_simple_ca('make-ca', '--ca-dir', str(PKI_DIR), ctx['p2s_ca_name'])
|
||||
if not (PKI_DIR / 'ca_key.pem').is_file():
|
||||
return
|
||||
if not (PKI_DIR / 'crl.pem').is_file():
|
||||
_simple_ca('make-crl', '--ca-dir', str(PKI_DIR), '--days', str(CRL_DAYS))
|
||||
label = ctx['local_fqdn'].split('.', 1)[0] if ctx['local_fqdn'] else ''
|
||||
if label and not (PKI_DIR / f'{label}_cert.pem').is_file():
|
||||
_simple_ca('make-cert', '--ca-dir', str(PKI_DIR), ctx['local_fqdn'], ctx['local_fqdn'])
|
||||
|
||||
|
||||
def distribute_pki(ctx):
|
||||
"""Copy PKI material to each consumer. Returns True when anything changed."""
|
||||
label = ctx['local_fqdn'].split('.', 1)[0] if ctx['local_fqdn'] else ''
|
||||
bundle = PKI_DIR / 'ca_bundle.pem'
|
||||
ca_src = bundle if bundle.is_file() else PKI_DIR / 'ca_cert.pem'
|
||||
|
||||
changed = False
|
||||
changed |= install_file(ca_src, CA_CERT_DEST, 0o644)
|
||||
if label:
|
||||
changed |= install_file(PKI_DIR / f'{label}_cert.pem', SERVER_CERT, 0o644)
|
||||
changed |= install_file(PKI_DIR / f'{label}_key.pem', SERVER_KEY, 0o600)
|
||||
changed |= install_file(PKI_DIR / 'crl.pem', CRL_DEST, 0o644)
|
||||
|
||||
if install_file(PKI_DIR / 'ca_cert.pem', TRUST_ANCHOR, 0o644):
|
||||
changed = True
|
||||
if shutil.which('update-ca-certificates'):
|
||||
subprocess.run(['update-ca-certificates'],
|
||||
capture_output=True, text=True)
|
||||
return changed
|
||||
|
||||
|
||||
def configure(cfg=None, public_ip=None):
|
||||
"""Render configuration files and provision PKI.
|
||||
|
||||
Returns (ctx, changed) where changed is a set naming what needs reloading:
|
||||
'swanctl', 'resolved', 'wireguard', 'openvpn'.
|
||||
'swanctl', 'resolved', 'resolver', 'wireguard', 'openvpn'.
|
||||
"""
|
||||
if cfg is None:
|
||||
cfg = load_config()
|
||||
@@ -809,9 +1195,30 @@ def configure(cfg=None, public_ip=None):
|
||||
|
||||
changed = set()
|
||||
|
||||
provision_pki(ctx)
|
||||
if distribute_pki(ctx):
|
||||
changed.add('swanctl')
|
||||
# Without the directory, which holds the PKI, the copies of the last run stay and what needs none is configured.
|
||||
with vpnrouter_pki.locked():
|
||||
try:
|
||||
notices = vpnrouter_pki.provision(cfg, ctx)
|
||||
if vpnrouter_pki.distribute(cfg):
|
||||
changed.add('swanctl')
|
||||
except vpnrouter_pki.Failure as failure:
|
||||
fail(str(failure))
|
||||
except ldap.LDAPError as exc:
|
||||
notices = [f'the PKI is left as it is: the directory cannot be reached: {exc}']
|
||||
for notice in notices:
|
||||
warn(notice)
|
||||
# The WireGuard peers of the users; None keeps those of wg0.conf while the directory cannot be reached.
|
||||
ctx['wg_peers'] = None
|
||||
if wg_ready(ctx):
|
||||
try:
|
||||
ctx['wg_peers'] = vpnrouter_ldap.wireguard_peers()
|
||||
except ldap.LDAPError as exc:
|
||||
warn(f'the WireGuard peers of the users are left as they are: the directory cannot be reached: {exc}')
|
||||
# What needs the router's own certificates.
|
||||
ctx['server_certificate'] = vpnrouter_pki.VPN_SERVER_CERT.is_file()
|
||||
for connection in ctx['connections']:
|
||||
if connection['auth'] == 'certificate':
|
||||
connection['ready'] = connection['ready'] and vpnrouter_pki.S2S_CERT.is_file()
|
||||
|
||||
env = _jinja_env()
|
||||
|
||||
@@ -827,11 +1234,15 @@ def configure(cfg=None, public_ip=None):
|
||||
elif remove(ROAD_WARRIOR):
|
||||
changed.add('swanctl')
|
||||
|
||||
if p2s_ready(ctx) or openvpn_ready(ctx):
|
||||
if render(env, ctx, 'p2s-forwarder.conf.j2', P2S_FORWARDER, 0o644):
|
||||
changed.add('resolved')
|
||||
elif remove(P2S_FORWARDER):
|
||||
# The resolver of the remote access clients is dnsmasq on the addresses of p2s.dns_listen; systemd-resolved keeps
|
||||
# its loopback stub only, so its records of the router's own name stay on the router.
|
||||
if remove(P2S_FORWARDER):
|
||||
changed.add('resolved')
|
||||
if (p2s_ready(ctx) or openvpn_ready(ctx) or wg_ready(ctx)) and ctx['p2s_resolver_addrs']:
|
||||
if render(env, ctx, 'dnsmasq.conf.j2', RESOLVER_CONF, 0o644):
|
||||
changed.add('resolver')
|
||||
elif remove(RESOLVER_CONF):
|
||||
changed.add('resolver')
|
||||
|
||||
if wg_ready(ctx):
|
||||
if render_wg_conf(env, ctx):
|
||||
|
||||
@@ -10,6 +10,9 @@ Every failure of the database raises DatabaseError, the only database exception
|
||||
Refresh tokens are random strings kept in PostgreSQL as their SHA-256, in the database of
|
||||
web.database, by default the vpn-router database on this host that provision() creates on the
|
||||
web service's first run, with the password in DATABASE_SECRET. A refresh token is used once: refreshing replaces it.
|
||||
The same database holds the state of the OpenID Provider (vpnrouter_oidc), whose tables and procedures OIDC_SQL
|
||||
defines; the first connection of a process loads it. revoke() removes a user's refresh tokens and OpenID Provider
|
||||
codes, tokens and sessions.
|
||||
"""
|
||||
|
||||
import base64
|
||||
@@ -26,6 +29,7 @@ import psycopg
|
||||
|
||||
WEB_KEY = pathlib.Path('/etc/vpn-router/web.key')
|
||||
DATABASE_SECRET = pathlib.Path('/etc/vpn-router/database.secret')
|
||||
OIDC_SQL = pathlib.Path('/usr/share/vpn-router/sql/oidc.sql')
|
||||
|
||||
#: The role and the database provision() creates when web.database is not set.
|
||||
DATABASE = 'vpn-router'
|
||||
@@ -45,6 +49,9 @@ LEEWAY = 60
|
||||
|
||||
SCHEMA = 'CREATE TABLE IF NOT EXISTS refresh_tokens (hash text PRIMARY KEY, name text NOT NULL, expires timestamptz NOT NULL)'
|
||||
|
||||
#: Whether this process has loaded OIDC_SQL.
|
||||
_oidc_loaded = False
|
||||
|
||||
|
||||
class DatabaseError(Exception):
|
||||
"""The database of the refresh tokens failed."""
|
||||
@@ -120,21 +127,36 @@ def admin_token(seconds):
|
||||
return {'token': sign({'admin': True, 'iat': now, 'exp': now + seconds}, secret), 'expires': now + seconds}
|
||||
|
||||
|
||||
def local_connection(name, secret):
|
||||
"""The connection string of a database provision_database() created; its password, of URL-safe characters, is
|
||||
written unquoted."""
|
||||
password = secret.read_text(encoding='utf-8').strip()
|
||||
return f'host=localhost dbname={name} user={name} password={password}'
|
||||
|
||||
|
||||
def connection_string(cfg):
|
||||
"""web.database, or the database provision() creates; its password, of URL-safe characters, is written unquoted."""
|
||||
"""web.database, or the database provision() creates."""
|
||||
configured = cfg['web']['database'].strip()
|
||||
if configured:
|
||||
return configured
|
||||
password = DATABASE_SECRET.read_text(encoding='utf-8').strip()
|
||||
return f'host=localhost dbname={DATABASE} user={DATABASE} password={password}'
|
||||
return local_connection(DATABASE, DATABASE_SECRET)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def database(cfg):
|
||||
"""A connection to the database of the refresh tokens, with their table, closed at the end; DatabaseError on any failure."""
|
||||
"""A connection to the database of the refresh tokens, with their table and the OpenID Provider's, closed at the end;
|
||||
DatabaseError on any failure."""
|
||||
global _oidc_loaded
|
||||
try:
|
||||
oidc = None if _oidc_loaded else OIDC_SQL.read_text(encoding='utf-8')
|
||||
except OSError as exc:
|
||||
raise DatabaseError(f'{OIDC_SQL} cannot be read: {exc}') from exc
|
||||
try:
|
||||
with psycopg.connect(connection_string(cfg), autocommit=True) as connection:
|
||||
connection.execute(SCHEMA)
|
||||
if oidc is not None:
|
||||
connection.execute(oidc)
|
||||
_oidc_loaded = True
|
||||
yield connection
|
||||
except psycopg.Error as exc:
|
||||
raise DatabaseError(f'the token database failed: {exc}') from exc
|
||||
@@ -173,9 +195,10 @@ def take(cfg, token):
|
||||
|
||||
|
||||
def revoke(cfg, name):
|
||||
"""Remove all refresh tokens of the user."""
|
||||
"""Remove all refresh tokens of the user, and its codes, tokens and sessions of the OpenID Provider."""
|
||||
with database(cfg) as connection:
|
||||
connection.execute('DELETE FROM refresh_tokens WHERE name = %s', (name,))
|
||||
connection.execute('CALL oidc_revoke(%s)', (name,))
|
||||
|
||||
|
||||
def psql(sql, name):
|
||||
@@ -187,21 +210,33 @@ def psql(sql, name):
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def provision(cfg):
|
||||
"""Create the role, its password file and the database when web.database is not set and they are missing.
|
||||
def provision_database(name, secret):
|
||||
"""Create the role of the name, its password file secret and its database of the same name when they are missing.
|
||||
|
||||
The password, of URL-safe characters, reaches psql on standard input, not on its command line.
|
||||
"""
|
||||
if cfg['web']['database'].strip():
|
||||
return
|
||||
if not psql("SELECT 1 FROM pg_roles WHERE rolname = :'v1'", DATABASE):
|
||||
DATABASE_SECRET.unlink(missing_ok=True)
|
||||
psql("CREATE ROLE :\"v1\" LOGIN", DATABASE)
|
||||
if not DATABASE_SECRET.is_file():
|
||||
if not psql("SELECT 1 FROM pg_roles WHERE rolname = :'v1'", name):
|
||||
secret.unlink(missing_ok=True)
|
||||
psql("CREATE ROLE :\"v1\" LOGIN", name)
|
||||
if not secret.is_file():
|
||||
password = secrets.token_urlsafe(32)
|
||||
psql(f"ALTER ROLE :\"v1\" PASSWORD '{password}'", DATABASE)
|
||||
DATABASE_SECRET.touch(mode=0o600)
|
||||
DATABASE_SECRET.chmod(0o600)
|
||||
DATABASE_SECRET.write_text(password + '\n', encoding='utf-8')
|
||||
if not psql("SELECT 1 FROM pg_database WHERE datname = :'v1'", DATABASE):
|
||||
psql("CREATE DATABASE :\"v1\" OWNER :\"v1\"", DATABASE)
|
||||
psql(f"ALTER ROLE :\"v1\" PASSWORD '{password}'", name)
|
||||
secret.touch(mode=0o600)
|
||||
secret.chmod(0o600)
|
||||
secret.write_text(password + '\n', encoding='utf-8')
|
||||
if not psql("SELECT 1 FROM pg_database WHERE datname = :'v1'", name):
|
||||
psql("CREATE DATABASE :\"v1\" OWNER :\"v1\"", name)
|
||||
|
||||
|
||||
def reset_password(name, secret):
|
||||
"""Give the role of the name the password of its file secret, when both exist, as after a restore."""
|
||||
if secret.is_file() and psql("SELECT 1 FROM pg_roles WHERE rolname = :'v1'", name):
|
||||
password = secret.read_text(encoding='utf-8').strip()
|
||||
psql(f"ALTER ROLE :\"v1\" PASSWORD '{password}'", name)
|
||||
|
||||
|
||||
def provision(cfg):
|
||||
"""Create the role, its password file and the database of the refresh tokens when web.database is not set and they
|
||||
are missing."""
|
||||
if not cfg['web']['database'].strip():
|
||||
provision_database(DATABASE, DATABASE_SECRET)
|
||||
@@ -0,0 +1,426 @@
|
||||
"""The backups of the router, for vpn-router-backup and the web service.
|
||||
|
||||
A backup is a consistent copy of the state that setup cannot make again, staged in STAGING: the
|
||||
trees of TREES, the folders of the web sites among them, the files of site_to_site psk_file outside them under files/,
|
||||
the directory as slapcat writes it (ldap/vpn-router.ldif), with backup.metrics the database as pg_dump writes it
|
||||
(postgresql/vpn-router.dump), the databases of the web sites and of the name server (DUMPS) once they exist, and
|
||||
manifest.json. The trees and the directory are copied while
|
||||
the configuration lock and the users lock are held, so the configuration, the PKI and the users
|
||||
match; pg_dump reads a snapshot of its own.
|
||||
|
||||
backup.target local writes the staging directory as a tar archive compressed with zstd,
|
||||
ARCHIVE, to backup.path, removes the archives of this host that the retention settings of the
|
||||
schedule no longer keep, and with backup.rclone_remote syncs or copies the archives of this host
|
||||
to the remote of RCLONE_CONFIG. backup.target pbs sends the staging directory to a Proxmox Backup
|
||||
Server as the archive router.pxar of the snapshot host/<host>/<time>; the server keeps them.
|
||||
|
||||
A run holds RUN_LOCK and records itself in STATE: result running, ok, warning (the backup is
|
||||
made, the cloud copy failed) or failed, with the times, the target, the archive or snapshot,
|
||||
its size, the error and the cloud copy. restore() puts a backup back and runs setup.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import datetime
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_auth
|
||||
import vpnrouter_dns
|
||||
import vpnrouter_ldap
|
||||
import vpnrouter_sites
|
||||
import vpnrouter_users
|
||||
|
||||
STATE = pathlib.Path('/var/lib/vpn-router/backup.json')
|
||||
WORK_DIR = pathlib.Path('/var/lib/vpn-router/backup')
|
||||
STAGING = WORK_DIR / 'staging'
|
||||
RESTORE_DIR = WORK_DIR / 'restore'
|
||||
#: What a restore replaces is kept here, in a directory named after the time of the restore.
|
||||
BEFORE_RESTORE = WORK_DIR / 'before-restore'
|
||||
RUN_LOCK = pathlib.Path('/run/vpn-router/backup.lock')
|
||||
RCLONE_CONFIG = pathlib.Path('/etc/vpn-router/rclone.conf')
|
||||
PBS_CLIENT = pathlib.Path('/usr/bin/proxmox-backup-client')
|
||||
UNIT = 'vpn-router-backup.service'
|
||||
TIMER = 'vpn-router-backup.timer'
|
||||
|
||||
#: The state a backup copies: directories and files, each where it is on the system.
|
||||
TREES = ('/etc/vpn-router', '/etc/wireguard', '/etc/ufw', '/etc/default/ufw', '/etc/vpn-router-manager',
|
||||
str(vpnrouter_sites.SITES_DIR))
|
||||
|
||||
#: The databases a backup always dumps, each while it exists: the dump -> the database and its password file.
|
||||
DUMPS = {'postgresql/vpn-router-sites.dump': (vpnrouter_sites.DATABASE, vpnrouter_sites.DATABASE_SECRET),
|
||||
'postgresql/vpn-router-dns.dump': (vpnrouter_dns.DATABASE, vpnrouter_dns.DATABASE_SECRET)}
|
||||
|
||||
#: The name of a local archive: the host and the UTC time of the backup.
|
||||
ARCHIVE = re.compile(r'^vpn-router-(?P<host>[A-Za-z0-9][A-Za-z0-9.-]*)-(?P<stamp>\d{8}T\d{6}Z)\.tar\.zst$')
|
||||
STAMP = '%Y%m%dT%H%M%SZ'
|
||||
|
||||
LDIF = 'ldap/vpn-router.ldif'
|
||||
DUMP = 'postgresql/vpn-router.dump'
|
||||
PBS_ARCHIVE = 'router.pxar'
|
||||
|
||||
#: The period of each retention setting, as strftime formats of local time; weeks are ISO weeks.
|
||||
PERIODS = {'keep_hourly': '%Y-%m-%d %H', 'keep_daily': '%Y-%m-%d', 'keep_weekly': '%G-%V', 'keep_monthly': '%Y-%m',
|
||||
'keep_yearly': '%Y'}
|
||||
|
||||
#: The units a restore stops while it replaces the directory and the database, and the ones it restarts after setup.
|
||||
RESTORE_STOPS = ('vpn-router-web.service', 'vpn-router-metrics.service', 'vpn-router-webdav.service', 'pdns.service',
|
||||
'slapd.service')
|
||||
RESTORE_RESTARTS = ('wg-quick@wg0.service', 'openvpn-server@vpn-router.service', 'strongswan.service',
|
||||
'vpn-router-manager.service')
|
||||
|
||||
|
||||
class Failure(Exception):
|
||||
"""A backup or a restore that cannot be done."""
|
||||
|
||||
|
||||
def now():
|
||||
"""The local time in ISO 8601, to the second, with its offset."""
|
||||
return datetime.datetime.now().astimezone().isoformat(timespec='seconds')
|
||||
|
||||
|
||||
def host():
|
||||
"""The name of this host, which names its archives and its PBS backup group."""
|
||||
return socket.gethostname().split('.')[0]
|
||||
|
||||
|
||||
def command(args, **kwargs):
|
||||
"""Run a command; Failure with its standard error when it fails."""
|
||||
try:
|
||||
result = subprocess.run(args, capture_output=True, text=True, **kwargs)
|
||||
except OSError as exc:
|
||||
raise Failure(f'{args[0]}: {exc.strerror}')
|
||||
if result.returncode != 0:
|
||||
raise Failure(f'{" ".join(args[:2])} failed: {result.stderr.strip() or result.stdout.strip()}')
|
||||
return result.stdout
|
||||
|
||||
|
||||
def write_state(state):
|
||||
"""Write STATE in one step."""
|
||||
STATE.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor, name = tempfile.mkstemp(dir=STATE.parent, prefix='.backup.json.')
|
||||
with os.fdopen(descriptor, 'w', encoding='utf-8') as handle:
|
||||
handle.write(json.dumps(state, indent=2) + '\n')
|
||||
os.chmod(name, 0o600)
|
||||
os.replace(name, STATE)
|
||||
|
||||
|
||||
def running():
|
||||
"""Whether a run holds RUN_LOCK."""
|
||||
try:
|
||||
with open(RUN_LOCK, encoding='utf-8') as handle:
|
||||
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except BlockingIOError:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def state():
|
||||
"""The state of the last run, {} when there was none; a run that ended without recording it is interrupted."""
|
||||
try:
|
||||
recorded = json.loads(STATE.read_text(encoding='utf-8'))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
if recorded.get('result') == 'running' and not running():
|
||||
recorded['result'] = 'interrupted'
|
||||
return recorded
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def run_lock():
|
||||
"""Hold RUN_LOCK; Failure when another run holds it."""
|
||||
RUN_LOCK.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(RUN_LOCK, 'a', encoding='utf-8') as handle:
|
||||
try:
|
||||
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
raise Failure('another backup or restore is running')
|
||||
yield
|
||||
|
||||
|
||||
def archives(path):
|
||||
"""The archives of this host in path, newest first, as [{name, time, size}]; time is the UTC time of the backup."""
|
||||
found = []
|
||||
try:
|
||||
entries = list(os.scandir(path))
|
||||
except OSError:
|
||||
return found
|
||||
for entry in entries:
|
||||
match = ARCHIVE.match(entry.name)
|
||||
if match and match['host'] == host() and entry.is_file():
|
||||
when = datetime.datetime.strptime(match['stamp'], STAMP).replace(tzinfo=datetime.timezone.utc)
|
||||
found.append({'name': entry.name, 'time': when.isoformat(), 'size': entry.stat().st_size})
|
||||
return sorted(found, key=lambda item: item['time'], reverse=True)
|
||||
|
||||
|
||||
def kept(found, keeps):
|
||||
"""The names of the archives the retention settings keep, by the rules of Proxmox Backup Server's prune.
|
||||
|
||||
found is newest first. The settings apply in the order of PERIODS: each keeps the newest archive of each of its
|
||||
last N periods that have archives and no archive a former setting kept, and the older archives of those periods
|
||||
are removed, so no later setting keeps them.
|
||||
"""
|
||||
marks = {}
|
||||
for option, period in PERIODS.items():
|
||||
count = keeps.get(option, 0)
|
||||
if not count:
|
||||
continue
|
||||
periods = {moment(item).strftime(period) for item in found if marks.get(item['name']) == 'keep'}
|
||||
included = set()
|
||||
for item in found:
|
||||
if item['name'] in marks:
|
||||
continue
|
||||
key = moment(item).strftime(period)
|
||||
if key in periods:
|
||||
continue
|
||||
if key not in included:
|
||||
if len(included) >= count:
|
||||
break
|
||||
included.add(key)
|
||||
marks[item['name']] = 'keep'
|
||||
else:
|
||||
marks[item['name']] = 'remove'
|
||||
return {name for name, mark in marks.items() if mark == 'keep'}
|
||||
|
||||
|
||||
def moment(item):
|
||||
"""The local time of an archive of archives()."""
|
||||
return datetime.datetime.fromisoformat(item['time']).astimezone()
|
||||
|
||||
|
||||
def psk_files(cfg):
|
||||
"""The psk_file of each site_to_site connection outside the trees of TREES."""
|
||||
files = []
|
||||
for connection in cfg['site_to_site'].values():
|
||||
name = connection['psk_file'].strip()
|
||||
if name and not any(name == tree or name.startswith(tree + '/') for tree in TREES) and os.path.isfile(name):
|
||||
files.append(name)
|
||||
return files
|
||||
|
||||
|
||||
def database_args(cfg):
|
||||
"""The command prefix and the database argument of pg_dump and pg_restore: postgres and vpn-router, or web.database."""
|
||||
configured = cfg['web']['database'].strip()
|
||||
if configured:
|
||||
return [], [f'--dbname={configured}']
|
||||
return ['runuser', '-u', 'postgres', '--'], [f'--dbname={vpnrouter_auth.DATABASE}']
|
||||
|
||||
|
||||
def stage(cfg):
|
||||
"""Copy what a backup holds to STAGING, emptied first, and return the manifest."""
|
||||
shutil.rmtree(STAGING, ignore_errors=True)
|
||||
STAGING.mkdir(parents=True, mode=0o700)
|
||||
trees = [tree for tree in TREES if os.path.exists(tree)]
|
||||
files = psk_files(cfg)
|
||||
vpnrouter_users.LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with vpnrouter.config_lock(), open(vpnrouter_users.LOCK_FILE, 'w') as users:
|
||||
fcntl.flock(users, fcntl.LOCK_EX)
|
||||
command(['cp', '-a', '--parents', *trees, str(STAGING)])
|
||||
if files:
|
||||
(STAGING / 'files').mkdir()
|
||||
command(['cp', '-a', '--parents', *files, str(STAGING / 'files')])
|
||||
(STAGING / LDIF).parent.mkdir()
|
||||
command(['slapcat', '-b', vpnrouter_ldap.BASE, '-l', str(STAGING / LDIF)])
|
||||
items = trees + [f'files{name}' for name in files] + [LDIF]
|
||||
dumps = {**({DUMP: database_args(cfg)} if cfg['backup']['metrics'] else {}),
|
||||
**{item: (['runuser', '-u', 'postgres', '--'], [f'--dbname={name}'])
|
||||
for item, (name, secret) in DUMPS.items() if secret.is_file()}}
|
||||
for item, (prefix, database) in dumps.items():
|
||||
(STAGING / item).parent.mkdir(exist_ok=True)
|
||||
with open(STAGING / item, 'wb') as dump:
|
||||
result = subprocess.run([*prefix, 'pg_dump', '-Fc', *database], stdout=dump, stderr=subprocess.PIPE)
|
||||
if result.returncode != 0:
|
||||
raise Failure(f'pg_dump for {item} failed: {result.stderr.decode(errors="replace").strip()}')
|
||||
items.append(item)
|
||||
version = subprocess.run(['dpkg-query', '-W', '-f=${Version}', 'vpn-router'], capture_output=True, text=True)
|
||||
manifest = {'host': host(), 'time': now(), 'version': version.stdout.strip() or None,
|
||||
'schedule': cfg['backup']['schedule'], 'items': items}
|
||||
(STAGING / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n', encoding='utf-8')
|
||||
return manifest
|
||||
|
||||
|
||||
def pbs_environment(cfg):
|
||||
"""The environment of proxmox-backup-client: the repository, the token secret and the fingerprint of backup."""
|
||||
if not PBS_CLIENT.exists():
|
||||
raise Failure(f'{PBS_CLIENT} does not exist; install proxmox-backup-client-static')
|
||||
backup = cfg['backup']
|
||||
environment = dict(os.environ, PBS_REPOSITORY=backup['pbs_repository'], PBS_PASSWORD=backup['pbs_password'])
|
||||
if backup['pbs_fingerprint']:
|
||||
environment['PBS_FINGERPRINT'] = backup['pbs_fingerprint']
|
||||
return environment
|
||||
|
||||
|
||||
def pbs_options(cfg):
|
||||
"""The namespace and key file options of proxmox-backup-client from backup."""
|
||||
options = []
|
||||
if cfg['backup']['pbs_namespace']:
|
||||
options += ['--ns', cfg['backup']['pbs_namespace']]
|
||||
if cfg['backup']['pbs_keyfile']:
|
||||
options += ['--keyfile', cfg['backup']['pbs_keyfile']]
|
||||
return options
|
||||
|
||||
|
||||
def cloud(cfg):
|
||||
"""Sync or copy the archives of this host in backup.path to backup.rclone_remote; Failure when it fails."""
|
||||
backup = cfg['backup']
|
||||
rclone = shutil.which('rclone')
|
||||
if not rclone:
|
||||
raise Failure('rclone is not installed')
|
||||
if not RCLONE_CONFIG.is_file():
|
||||
raise Failure(f'{RCLONE_CONFIG} does not exist; make the remote with rclone config --config {RCLONE_CONFIG}')
|
||||
command([rclone, backup['rclone_mode'], backup['path'], backup['rclone_remote'], '--config', str(RCLONE_CONFIG),
|
||||
'--include', f'vpn-router-{host()}-*.tar.zst'])
|
||||
|
||||
|
||||
def run():
|
||||
"""Make a backup to backup.target as the configuration says, recording it in STATE; the recorded state."""
|
||||
cfg = vpnrouter.load_config()
|
||||
backup = cfg['backup']
|
||||
started = time.time()
|
||||
record = {'result': 'running', 'started': now(), 'finished': None, 'target': backup['target'], 'archive': None,
|
||||
'snapshot': None, 'size': None, 'removed': [], 'error': None, 'cloud': None}
|
||||
with run_lock():
|
||||
write_state(record)
|
||||
try:
|
||||
stage(cfg)
|
||||
if backup['target'] == 'pbs':
|
||||
epoch = int(started)
|
||||
command([str(PBS_CLIENT), 'backup', f'{PBS_ARCHIVE}:{STAGING}', '--backup-type', 'host',
|
||||
'--backup-id', host(), '--backup-time', str(epoch), *pbs_options(cfg)],
|
||||
env=pbs_environment(cfg))
|
||||
stamp = datetime.datetime.fromtimestamp(epoch, datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
record['snapshot'] = f'host/{host()}/{stamp}'
|
||||
record['size'] = sum(path.stat().st_size for path in STAGING.rglob('*') if path.is_file())
|
||||
else:
|
||||
path = pathlib.Path(backup['path'])
|
||||
path.mkdir(parents=True, mode=0o700, exist_ok=True)
|
||||
name = f'vpn-router-{host()}-{time.strftime(STAMP, time.gmtime(started))}.tar.zst'
|
||||
partial = path / f'{name}.partial'
|
||||
command(['tar', '--zstd', '-cf', str(partial), '-C', str(STAGING), '.'])
|
||||
partial.chmod(0o600)
|
||||
partial.rename(path / name)
|
||||
record['archive'] = name
|
||||
record['size'] = (path / name).stat().st_size
|
||||
found = archives(path)
|
||||
keep = kept(found, {option: backup[option] for option in vpnrouter.backup_keeps(backup['schedule'])})
|
||||
for item in found:
|
||||
if item['name'] not in keep:
|
||||
(path / item['name']).unlink()
|
||||
record['removed'].append(item['name'])
|
||||
if backup['rclone_remote']:
|
||||
record['cloud'] = {'remote': backup['rclone_remote'], 'mode': backup['rclone_mode'],
|
||||
'result': 'ok', 'error': None}
|
||||
try:
|
||||
cloud(cfg)
|
||||
except Failure as exc:
|
||||
record['cloud'].update(result='failed', error=str(exc))
|
||||
record['result'] = 'warning' if record['cloud'] and record['cloud']['result'] != 'ok' else 'ok'
|
||||
except (Failure, OSError) as exc:
|
||||
record['result'] = 'failed'
|
||||
record['error'] = str(exc)
|
||||
finally:
|
||||
shutil.rmtree(STAGING, ignore_errors=True)
|
||||
record['finished'] = now()
|
||||
write_state(record)
|
||||
return record
|
||||
|
||||
|
||||
def snapshots(cfg):
|
||||
"""The PBS snapshots of this host, as proxmox-backup-client lists them."""
|
||||
namespace = cfg['backup']['pbs_namespace']
|
||||
return command([str(PBS_CLIENT), 'snapshot', 'list', f'host/{host()}', *(['--ns', namespace] if namespace else [])],
|
||||
env=pbs_environment(cfg))
|
||||
|
||||
|
||||
def extract(cfg, source, pbs):
|
||||
"""Unpack a local archive, or with pbs fetch a snapshot, to RESTORE_DIR, emptied first; the manifest."""
|
||||
shutil.rmtree(RESTORE_DIR, ignore_errors=True)
|
||||
RESTORE_DIR.mkdir(parents=True, mode=0o700)
|
||||
if pbs:
|
||||
command([str(PBS_CLIENT), 'restore', source, PBS_ARCHIVE, str(RESTORE_DIR), *pbs_options(cfg)],
|
||||
env=pbs_environment(cfg))
|
||||
else:
|
||||
command(['tar', '--zstd', '-xf', source, '-C', str(RESTORE_DIR)])
|
||||
try:
|
||||
return json.loads((RESTORE_DIR / 'manifest.json').read_text(encoding='utf-8'))
|
||||
except (OSError, ValueError):
|
||||
raise Failure(f'{source} is not a backup of vpn-router-backup: it has no manifest.json')
|
||||
|
||||
|
||||
def put_back(relative, target, saved):
|
||||
"""Move target to saved, keeping its path, and copy the staged relative path of RESTORE_DIR in its place."""
|
||||
target = pathlib.Path(target)
|
||||
if target.exists() or target.is_symlink():
|
||||
keep = saved / target.relative_to('/')
|
||||
keep.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(target, keep)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
command(['cp', '-a', str(RESTORE_DIR / relative), str(target)])
|
||||
|
||||
|
||||
def restore(source, pbs, confirm):
|
||||
"""Put the backup back: a local archive, or with pbs a snapshot; confirm(manifest) is asked before any change.
|
||||
|
||||
What is replaced is kept in BEFORE_RESTORE. The services of the directory and the database are stopped while they
|
||||
are replaced, and setup applies the configuration afterwards. Returns the directory of what was replaced, or None
|
||||
when confirm declined.
|
||||
"""
|
||||
cfg = vpnrouter.load_config()
|
||||
with run_lock():
|
||||
try:
|
||||
manifest = extract(cfg, source, pbs)
|
||||
if not confirm(manifest):
|
||||
return None
|
||||
saved = BEFORE_RESTORE / time.strftime(STAMP, time.gmtime())
|
||||
saved.mkdir(parents=True, mode=0o700)
|
||||
for unit in RESTORE_STOPS:
|
||||
subprocess.run(['systemctl', 'stop', unit], capture_output=True)
|
||||
for item in manifest['items']:
|
||||
if item.startswith('/'):
|
||||
put_back(item.lstrip('/'), item, saved)
|
||||
elif item.startswith('files/'):
|
||||
put_back(item, item[len('files'):], saved)
|
||||
if LDIF in manifest['items']:
|
||||
directory = vpnrouter_ldap.DB_DIRECTORY
|
||||
if directory.exists():
|
||||
shutil.move(directory, saved / 'ldap')
|
||||
directory.mkdir(mode=0o700)
|
||||
command(['slapadd', '-b', vpnrouter_ldap.BASE, '-l', str(RESTORE_DIR / LDIF)])
|
||||
command(['chown', '-R', 'openldap:openldap', str(directory)])
|
||||
subprocess.run(['systemctl', 'start', 'slapd.service'], capture_output=True)
|
||||
restored = vpnrouter.load_config()
|
||||
if not restored['web']['database'].strip():
|
||||
vpnrouter_auth.reset_password(vpnrouter_auth.DATABASE, vpnrouter_auth.DATABASE_SECRET)
|
||||
vpnrouter_auth.provision(restored)
|
||||
dumps = {DUMP: database_args(restored)}
|
||||
for item, (name, secret) in DUMPS.items():
|
||||
if item in manifest['items']:
|
||||
vpnrouter_auth.reset_password(name, secret)
|
||||
vpnrouter_auth.provision_database(name, secret)
|
||||
dumps[item] = (['runuser', '-u', 'postgres', '--'], [f'--dbname={name}'])
|
||||
for item, (prefix, database) in dumps.items():
|
||||
if item not in manifest['items']:
|
||||
continue
|
||||
with open(RESTORE_DIR / item, 'rb') as dump:
|
||||
result = subprocess.run([*prefix, 'pg_restore', '--clean', '--if-exists', *database], stdin=dump,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
||||
if result.returncode != 0:
|
||||
raise Failure(f'pg_restore of {item} failed: {result.stderr.decode(errors="replace").strip()}')
|
||||
subprocess.run(['systemctl', 'start', 'vpn-router-web.service'], capture_output=True)
|
||||
command(['systemctl', 'restart', 'vpn-router-setup.service'])
|
||||
for unit in RESTORE_RESTARTS:
|
||||
subprocess.run(['systemctl', 'try-restart', unit], capture_output=True)
|
||||
subprocess.run(['ufw', 'reload'], capture_output=True)
|
||||
return saved
|
||||
finally:
|
||||
shutil.rmtree(RESTORE_DIR, ignore_errors=True)
|
||||
@@ -0,0 +1,268 @@
|
||||
"""The router's authoritative name server, PowerDNS with its gpgsql backend, for vpn-router-setup and the web service.
|
||||
|
||||
The name server answers for one zone, dns.zone, on the addresses of the interfaces of dns.listen. Its records are in
|
||||
the vpn-router-dns database, which provision() creates with its role and the password in DATABASE_SECRET, and fills with
|
||||
the schema of pdns-backend-pgsql, PDNS_SCHEMA; DNS_SQL defines the router's own tables and procedures, and the first
|
||||
connection of a process loads it. apply() writes the zone: its SOA, its NS, dns.nameserver, A and AAAA records of
|
||||
dns.addresses for the zone's apex, for the name server when it is in the zone, and for every host name of an enabled
|
||||
site in the zone, and the records an administrator added (manual records), but those of a name and type the records
|
||||
before them have. The serial of the SOA grows only when the records change.
|
||||
|
||||
Every failure of the database raises DatabaseError; a record that cannot be added raises Failure.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import ipaddress
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
import vpnrouter_auth
|
||||
|
||||
DATABASE = 'vpn-router-dns'
|
||||
DATABASE_SECRET = pathlib.Path('/etc/vpn-router/dns-database.secret')
|
||||
DNS_SQL = pathlib.Path('/usr/share/vpn-router/sql/dns.sql')
|
||||
|
||||
#: The schema of PowerDNS's gpgsql backend, which pdns-backend-pgsql installs.
|
||||
PDNS_SCHEMA = pathlib.Path('/usr/share/pdns-backend-pgsql/schema/schema.pgsql.sql')
|
||||
|
||||
#: The settings of PowerDNS that vpn-router-setup writes, read after /etc/powerdns/pdns.conf; the server, its unit and
|
||||
#: the group it runs as.
|
||||
PDNS_CONF = pathlib.Path('/etc/powerdns/pdns.d/vpn-router.conf')
|
||||
PDNS_SERVER = pathlib.Path('/usr/sbin/pdns_server')
|
||||
UNIT = 'pdns.service'
|
||||
GROUP = 'pdns'
|
||||
|
||||
#: The types of the manual records.
|
||||
TYPES = ('A', 'AAAA', 'CNAME', 'TXT', 'MX', 'SRV', 'CAA')
|
||||
|
||||
#: The most manual records.
|
||||
MANUAL_MAX = 1000
|
||||
|
||||
#: The refresh, retry and expire times of the SOA, in seconds.
|
||||
SOA_TIMES = '10800 3600 604800'
|
||||
|
||||
#: A label of a record's name: letters, digits, hyphens and underscores, or * as the first label.
|
||||
LABEL = re.compile(r'^[a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9_])?$')
|
||||
|
||||
#: The content of an MX, SRV and CAA record, and a DNS name a record points to.
|
||||
MX = re.compile(r'^(\d{1,5}) (\S+)$')
|
||||
SRV = re.compile(r'^(\d{1,5}) (\d{1,5}) (\d{1,5}) (\S+)$')
|
||||
CAA = re.compile(r'^(0|128) (issue|issuewild|iodef) "[^"\\]*"$')
|
||||
TARGET = re.compile(r'^[a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9_])?(\.[a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9_])?)*$')
|
||||
|
||||
#: The longest content of a TXT record, in characters.
|
||||
TXT_MAX = 2048
|
||||
|
||||
#: Whether this process has loaded DNS_SQL.
|
||||
_loaded = False
|
||||
|
||||
|
||||
class DatabaseError(Exception):
|
||||
"""The database of the name server failed."""
|
||||
|
||||
|
||||
class Failure(Exception):
|
||||
"""A record that cannot be added or removed; status is 2 invalid, 3 no such record."""
|
||||
|
||||
def __init__(self, message, status=2):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
||||
|
||||
def provision():
|
||||
"""Create the role, its password file and the database of the name server when they are missing, and PowerDNS's
|
||||
tables in it; DatabaseError while pdns-backend-pgsql is not installed."""
|
||||
vpnrouter_auth.provision_database(DATABASE, DATABASE_SECRET)
|
||||
try:
|
||||
with psycopg.connect(vpnrouter_auth.local_connection(DATABASE, DATABASE_SECRET), autocommit=True) as connection:
|
||||
if connection.execute("SELECT to_regclass('public.domains')").fetchone()[0] is None:
|
||||
connection.execute(PDNS_SCHEMA.read_text(encoding='utf-8'))
|
||||
except OSError as exc:
|
||||
raise DatabaseError(f'the schema of PowerDNS cannot be read: {exc}') from exc
|
||||
except psycopg.Error as exc:
|
||||
raise DatabaseError(f'the database of the name server failed: {exc}') from exc
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def database():
|
||||
"""A connection to the database of the name server, with the router's tables and procedures, closed at the end;
|
||||
DatabaseError on any failure, also before provision() made it."""
|
||||
global _loaded
|
||||
try:
|
||||
schema = None if _loaded else DNS_SQL.read_text(encoding='utf-8')
|
||||
target = vpnrouter_auth.local_connection(DATABASE, DATABASE_SECRET)
|
||||
except OSError as exc:
|
||||
raise DatabaseError(f'the database of the name server is not ready: {exc}') from exc
|
||||
try:
|
||||
with psycopg.connect(target, autocommit=True, row_factory=dict_row) as connection:
|
||||
if schema is not None:
|
||||
connection.execute(schema)
|
||||
_loaded = True
|
||||
yield connection
|
||||
except psycopg.Error as exc:
|
||||
raise DatabaseError(f'the database of the name server failed: {exc}') from exc
|
||||
|
||||
|
||||
def in_zone(zone, name):
|
||||
return name == zone or name.endswith(f'.{zone}')
|
||||
|
||||
|
||||
def absolute(zone, name):
|
||||
"""The record name in the zone: @ or '' is the zone itself, a name ending in a dot is taken as it is, any other is
|
||||
relative to the zone; Failure for a name outside the zone or not a DNS name."""
|
||||
if not isinstance(name, str):
|
||||
raise Failure('name must be a string')
|
||||
name = name.strip().lower()
|
||||
if name in ('', '@'):
|
||||
return zone
|
||||
full = name[:-1] if name.endswith('.') else f'{name}.{zone}'
|
||||
labels = full.split('.')
|
||||
if not in_zone(zone, full) or not all(LABEL.match(label) or (index == 0 and label == '*')
|
||||
for index, label in enumerate(labels)):
|
||||
raise Failure(f'{name!r} is not a name in {zone}')
|
||||
return full
|
||||
|
||||
|
||||
def check_record(zone, record):
|
||||
"""The manual record {name, type, content, ttl?} with its name made absolute and its content checked; Failure when
|
||||
it is not valid. A TXT content not in quotes is quoted."""
|
||||
if not isinstance(record, dict):
|
||||
raise Failure('a record is an object of name, type, content and ttl')
|
||||
name = absolute(zone, record.get('name'))
|
||||
kind = record.get('type')
|
||||
content = record.get('content')
|
||||
ttl = record.get('ttl')
|
||||
if kind not in TYPES:
|
||||
raise Failure(f'type must be one of {", ".join(TYPES)}')
|
||||
if not isinstance(content, str) or not content.strip():
|
||||
raise Failure('content must be a string')
|
||||
if ttl is not None and (isinstance(ttl, bool) or not isinstance(ttl, int) or not 60 <= ttl <= 86400):
|
||||
raise Failure('ttl must be 60 to 86400 seconds')
|
||||
content = content.strip()
|
||||
if kind in ('A', 'AAAA'):
|
||||
try:
|
||||
address = ipaddress.ip_address(content)
|
||||
except ValueError:
|
||||
address = None
|
||||
if address is None or address.version != (4 if kind == 'A' else 6):
|
||||
raise Failure(f'{content!r} is not an IPv{4 if kind == "A" else 6} address')
|
||||
content = str(address)
|
||||
elif kind == 'CNAME':
|
||||
if name == zone:
|
||||
raise Failure('the zone itself cannot have a CNAME record')
|
||||
content = content.lower().rstrip('.')
|
||||
if not TARGET.match(content):
|
||||
raise Failure(f'{content!r} is not a DNS name')
|
||||
elif kind == 'TXT':
|
||||
if not (content.startswith('"') and content.endswith('"') and len(content) > 1):
|
||||
content = '"' + content.replace('\\', '\\\\').replace('"', '\\"') + '"'
|
||||
if len(content) > TXT_MAX or not content.isprintable():
|
||||
raise Failure(f'the text must have at most {TXT_MAX} printable characters')
|
||||
elif kind in ('MX', 'SRV'):
|
||||
match = (MX if kind == 'MX' else SRV).match(content.lower())
|
||||
if not match or any(int(value) > 65535 for value in match.groups()[:-1]) or \
|
||||
not TARGET.match(match.groups()[-1].rstrip('.')):
|
||||
raise Failure(f'{kind} content is ' + ('"<preference> <host>"' if kind == 'MX'
|
||||
else '"<priority> <weight> <port> <host>"'))
|
||||
content = ' '.join([*match.groups()[:-1], match.groups()[-1].rstrip('.')])
|
||||
elif not CAA.match(content):
|
||||
raise Failure('CAA content is "<0 or 128> <issue, issuewild or iodef> \\"<value>\\""')
|
||||
return {'name': name, 'type': kind, 'content': content, 'ttl': ttl}
|
||||
|
||||
|
||||
def manual():
|
||||
"""The manual records."""
|
||||
with database() as connection:
|
||||
return connection.execute('SELECT * FROM dns_manual_list()').fetchall()
|
||||
|
||||
|
||||
def add(cfg, record):
|
||||
"""Add the manual record, or set the TTL of the same one; the record."""
|
||||
zone = cfg['dns']['zone'].strip()
|
||||
if not zone:
|
||||
raise Failure('dns.zone is not set')
|
||||
record = check_record(zone, record)
|
||||
record['ttl'] = record['ttl'] or cfg['dns']['ttl']
|
||||
with database() as connection:
|
||||
records = connection.execute('SELECT * FROM dns_manual_list()').fetchall()
|
||||
same = [row for row in records if (row['name'], row['type']) == (record['name'], record['type'])]
|
||||
others = [row for row in records if row['name'] == record['name'] and row['type'] != record['type']]
|
||||
if len(records) >= MANUAL_MAX and record not in records:
|
||||
raise Failure(f'the zone has {MANUAL_MAX} manual records')
|
||||
if record['type'] == 'CNAME' and (others or [row for row in same if row['content'] != record['content']]):
|
||||
raise Failure(f'{record["name"]} has other records; a CNAME record must be its only record')
|
||||
if any(row['type'] == 'CNAME' for row in others):
|
||||
raise Failure(f'{record["name"]} has a CNAME record, which must be its only record')
|
||||
connection.execute('CALL dns_manual_put(%s, %s, %s, %s)',
|
||||
(record['name'], record['type'], record['content'], record['ttl']))
|
||||
return record
|
||||
|
||||
|
||||
def remove(cfg, name, kind, content):
|
||||
"""Remove the manual records of the name and type, only the one of the content when it is given."""
|
||||
zone = cfg['dns']['zone'].strip()
|
||||
full = absolute(zone, name)
|
||||
with database() as connection:
|
||||
found = [row for row in connection.execute('SELECT * FROM dns_manual_list()').fetchall()
|
||||
if (row['name'], row['type']) == (full, kind) and content in (None, row['content'])]
|
||||
if not found:
|
||||
raise Failure(f'no {kind} record of {full}', 3)
|
||||
connection.execute('CALL dns_manual_remove(%s, %s, %s)', (full, kind, content))
|
||||
return {'name': full, 'type': kind, 'removed': len(found)}
|
||||
|
||||
|
||||
def served(zone):
|
||||
"""The records of the zone as the name server answers them, [] when it serves none."""
|
||||
with database() as connection:
|
||||
return connection.execute('SELECT * FROM dns_served(%s)', (zone,)).fetchall()
|
||||
|
||||
|
||||
def records(cfg, hosts, manual_records):
|
||||
"""The records of the zone besides its SOA, as (name, type, content, ttl): its NS, the A and AAAA records of
|
||||
dns.addresses for the apex, the name server and each host name in the zone, and the manual records."""
|
||||
dns = cfg['dns']
|
||||
zone, nameserver, ttl = dns['zone'].strip(), dns['nameserver'].strip(), dns['ttl']
|
||||
addresses = [ipaddress.ip_address(item.strip()) for item in dns['addresses'] if item.strip()]
|
||||
found = [(zone, 'NS', nameserver, ttl)]
|
||||
for name in dict.fromkeys([zone, *([nameserver] if in_zone(zone, nameserver) else []),
|
||||
*(host for host in hosts if in_zone(zone, host))]):
|
||||
found += [(name, 'A' if address.version == 4 else 'AAAA', str(address), ttl) for address in addresses]
|
||||
taken = {(name, kind) for name, kind, _, _ in found}
|
||||
found += [(row['name'], row['type'], row['content'], row['ttl']) for row in manual_records
|
||||
if (row['name'], row['type']) not in taken]
|
||||
return list(dict.fromkeys(found))
|
||||
|
||||
|
||||
def apply(cfg, hosts):
|
||||
"""Make the name server serve dns.zone with records(), or no zone while dns.enabled is false."""
|
||||
with database() as connection:
|
||||
if not cfg['dns']['enabled']:
|
||||
connection.execute('CALL dns_clear()')
|
||||
return
|
||||
dns = cfg['dns']
|
||||
zone = dns['zone'].strip()
|
||||
rows = records(cfg, hosts, connection.execute('SELECT * FROM dns_manual_list()').fetchall())
|
||||
soa = f'{dns["nameserver"].strip()} hostmaster.{zone} {{serial}} {SOA_TIMES} {dns["ttl"]}'
|
||||
connection.execute('CALL dns_apply(%s, %s, %s, %s, %s, %s, %s)',
|
||||
(zone, soa, dns['ttl'], [row[0] for row in rows], [row[1] for row in rows],
|
||||
[row[2] for row in rows], [row[3] for row in rows]))
|
||||
|
||||
|
||||
def config_text(addresses):
|
||||
"""The settings of PowerDNS: the gpgsql backend on the database of the name server, and the addresses it answers
|
||||
on."""
|
||||
password = DATABASE_SECRET.read_text(encoding='utf-8').strip()
|
||||
return '\n'.join([
|
||||
'# Written by vpn-router-setup from /etc/vpn-router/vpn-router.json; edits are overwritten.',
|
||||
'launch=gpgsql',
|
||||
'gpgsql-host=localhost',
|
||||
f'gpgsql-dbname={DATABASE}',
|
||||
f'gpgsql-user={DATABASE}',
|
||||
f'gpgsql-password={password}',
|
||||
f'local-address={", ".join(addresses)}',
|
||||
'local-port=53',
|
||||
'',
|
||||
])
|
||||
@@ -11,6 +11,13 @@ The password policy locks an account for LOCKOUT_SECONDS after LOCKOUT_FAILURES
|
||||
a successful bind clears the count. slapd's otp overlay is configured on the database, so a
|
||||
bind of a user with a TOTP token takes the password followed by a current code.
|
||||
cn=totp,ou=policies holds the TOTP parameters the token of every user points to.
|
||||
|
||||
A user with console login on its own account is also a posixAccount, with a uidNumber from
|
||||
CONSOLE_UID_MIN and the gidNumber of the local group CONSOLE_GROUP; nslcd serves those with
|
||||
vpnRouterConsoleLogin TRUE to the system over the local socket.
|
||||
|
||||
ou=pki holds the router's PKI (vpnrouter_pki): an entry per CA, and under it an entry per
|
||||
certificate the CA issued. Private keys are readable by root alone, the database's root DN.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -24,11 +31,14 @@ import ldap.modlist
|
||||
import ldap.sasl
|
||||
|
||||
URI = 'ldapi:///'
|
||||
#: The local socket ldapi:/// names, which slapd listens on.
|
||||
SLAPD_SOCKET = '/var/run/slapd/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}'
|
||||
PKI = f'ou=pki,{BASE}'
|
||||
DEFAULT_POLICY = f'cn=default,{POLICIES}'
|
||||
TOTP_PARAMS = f'cn=totp,{POLICIES}'
|
||||
MANAGER = f'cn=user-manager,{SERVICES}'
|
||||
@@ -52,17 +62,68 @@ 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, of which service marks a service account.
|
||||
#: package to a user of either kind: mail, for a Manager-only account, vpnRouterScope,
|
||||
#: authorization scopes, of which service marks a service account, displayName and
|
||||
#: description, the labels of a service account, and vpnRouterConsoleLogin and
|
||||
#: vpnRouterConsoleAccount, whether the user may use a console and the local account it runs as,
|
||||
#: without which it runs as the user's own posixAccount, and vpnRouterWireGuardPeer, each a
|
||||
#: WireGuard peer of the user as "<public key> <IP>[,<IP>...]", and vpnRouterPasskey, each a
|
||||
#: passkey of the user as JSON (vpnrouter_passkeys). A service account with vpnRouterRedirectUri
|
||||
#: is a client of the OpenID Provider (vpnrouter_oidc): the URIs it is sent back to after a sign-in
|
||||
#: and, vpnRouterPostLogoutRedirectUri, after a sign-out, and vpnRouterAllowedGroup, the DNs of the
|
||||
#: groups whose members may sign in to it. vpnRouterAuthority is a CA of the PKI: its
|
||||
#: certificate and CRL (cACertificate and certificateRevocationList of RFC 4523), its key, the
|
||||
#: CSR it waits with for the offline root, the number of its last CRL, where the CRL came from,
|
||||
#: and when it was retired. vpnRouterCertificate is a certificate a CA issued, named by its
|
||||
#: serial number: the certificate, the key the router keeps for its own and the users'
|
||||
#: certificates, its kind, template, names and expiry, and its revocation.
|
||||
SCHEMA_OID = '2.25.98477352595735834532404256531620013001'
|
||||
SCHEMA_NAME = 'vpn-router'
|
||||
_STRING = 'EQUALITY caseIgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} SINGLE-VALUE'
|
||||
_TIME = 'EQUALITY generalizedTimeMatch ORDERING generalizedTimeOrderingMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 SINGLE-VALUE'
|
||||
_OCTETS = 'EQUALITY octetStringMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.40 SINGLE-VALUE'
|
||||
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} )",
|
||||
f"( {SCHEMA_OID}.1.2 NAME 'vpnRouterPrivateKey' DESC 'VPN router: a private key, PEM' {_OCTETS} )",
|
||||
f"( {SCHEMA_OID}.1.3 NAME 'vpnRouterCertificateRequest' DESC 'VPN router: the CSR of a CA, PEM' {_OCTETS} )",
|
||||
f"( {SCHEMA_OID}.1.4 NAME 'vpnRouterCRLNumber' DESC 'VPN router: the number of the last CRL of a CA' "
|
||||
"EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE )",
|
||||
f"( {SCHEMA_OID}.1.5 NAME 'vpnRouterCRLSource' DESC 'VPN router: signed or uploaded, where the CRL of a CA came from' {_STRING} )",
|
||||
f"( {SCHEMA_OID}.1.6 NAME 'vpnRouterRetired' DESC 'VPN router: when a CA was retired' {_TIME} )",
|
||||
f"( {SCHEMA_OID}.1.7 NAME 'vpnRouterKind' DESC 'VPN router: ca, router, user or external, the kind of a certificate' {_STRING} )",
|
||||
f"( {SCHEMA_OID}.1.8 NAME 'vpnRouterTemplate' DESC 'VPN router: the template of a certificate' {_STRING} )",
|
||||
f"( {SCHEMA_OID}.1.9 NAME 'vpnRouterSubjectAltName' DESC 'VPN router: a subject alternative name of a certificate' "
|
||||
"EQUALITY caseIgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} )",
|
||||
f"( {SCHEMA_OID}.1.10 NAME 'vpnRouterNotAfter' DESC 'VPN router: when a certificate expires' {_TIME} )",
|
||||
f"( {SCHEMA_OID}.1.11 NAME 'vpnRouterRevoked' DESC 'VPN router: when a certificate was revoked' {_TIME} )",
|
||||
f"( {SCHEMA_OID}.1.12 NAME 'vpnRouterRevocationReason' DESC 'VPN router: why a certificate was revoked' {_STRING} )",
|
||||
f"( {SCHEMA_OID}.1.13 NAME 'vpnRouterConsoleLogin' DESC 'VPN router: whether the user may use a console' "
|
||||
"EQUALITY booleanMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 SINGLE-VALUE )",
|
||||
f"( {SCHEMA_OID}.1.14 NAME 'vpnRouterConsoleAccount' DESC 'VPN router: the local account the console of the user runs as' "
|
||||
f"{_STRING} )",
|
||||
f"( {SCHEMA_OID}.1.15 NAME 'vpnRouterWireGuardPeer' DESC 'VPN router: a WireGuard peer of the user, its public key and IPs' "
|
||||
"EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1024} )",
|
||||
f"( {SCHEMA_OID}.1.16 NAME 'vpnRouterPasskey' DESC 'VPN router: a passkey of the user, its credential as JSON' "
|
||||
"EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{4096} )",
|
||||
f"( {SCHEMA_OID}.1.17 NAME 'vpnRouterRedirectUri' DESC 'VPN router: a redirect URI of an OpenID Connect client' "
|
||||
"EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1024} )",
|
||||
f"( {SCHEMA_OID}.1.18 NAME 'vpnRouterPostLogoutRedirectUri' DESC 'VPN router: a post-logout redirect URI of an "
|
||||
"OpenID Connect client' EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1024} )",
|
||||
f"( {SCHEMA_OID}.1.19 NAME 'vpnRouterAllowedGroup' DESC 'VPN router: a group whose members may sign in to an "
|
||||
"OpenID Connect client' EQUALITY distinguishedNameMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 )",
|
||||
)
|
||||
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 ) )",
|
||||
"SUP top AUXILIARY MAY ( mail $ vpnRouterScope $ displayName $ description $ vpnRouterConsoleLogin $ "
|
||||
"vpnRouterConsoleAccount $ vpnRouterWireGuardPeer $ vpnRouterPasskey $ vpnRouterRedirectUri $ "
|
||||
"vpnRouterPostLogoutRedirectUri $ vpnRouterAllowedGroup ) )",
|
||||
f"( {SCHEMA_OID}.2.2 NAME 'vpnRouterAuthority' DESC 'VPN router: a CA of the PKI' SUP top STRUCTURAL MUST cn "
|
||||
"MAY ( cACertificate $ certificateRevocationList $ vpnRouterPrivateKey $ vpnRouterCertificateRequest $ "
|
||||
"vpnRouterCRLNumber $ vpnRouterCRLSource $ vpnRouterRetired ) )",
|
||||
f"( {SCHEMA_OID}.2.3 NAME 'vpnRouterCertificate' DESC 'VPN router: a certificate a CA of the PKI issued' SUP top "
|
||||
"STRUCTURAL MUST serialNumber MAY ( cn $ userCertificate $ vpnRouterPrivateKey $ vpnRouterKind $ vpnRouterTemplate $ "
|
||||
"vpnRouterSubjectAltName $ vpnRouterNotAfter $ vpnRouterRevoked $ vpnRouterRevocationReason ) )",
|
||||
)
|
||||
|
||||
#: The TOTP parameters: digits, the OID of HMAC-SHA1, seconds per time step, and the time steps accepted before and after now.
|
||||
@@ -71,8 +132,22 @@ TOTP_ALGORITHM = '1.2.840.113549.2.7'
|
||||
TOTP_PERIOD = 30
|
||||
TOTP_WINDOW = 1
|
||||
|
||||
#: The value of pwdAccountLockedTime that disables a user until it is enabled again.
|
||||
DISABLED = '000001010000Z'
|
||||
|
||||
#: The indexes of the database.
|
||||
INDEXES = ('objectClass eq', 'uid eq', 'uidNumber eq', 'vpnRouterConsoleLogin eq')
|
||||
|
||||
#: The POSIX accounts of console login (vpnrouter_users.console): the lowest uidNumber, which nslcd's nss_min_uid
|
||||
#: matches, the local group that is their primary group and grants sudo, the shell and the parent of their homes.
|
||||
CONSOLE_UID_MIN = 20000
|
||||
CONSOLE_GROUP = 'vpn-router-console'
|
||||
CONSOLE_SHELL = '/bin/bash'
|
||||
CONSOLE_HOMES = '/home'
|
||||
|
||||
#: The ACLs of the database, in order.
|
||||
ACCESS = (
|
||||
'to attrs=vpnRouterPrivateKey by * none',
|
||||
f'to attrs=userPassword by dn.exact="{MANAGER}" =wx by self =wx by anonymous auth by * none',
|
||||
f'to attrs=oathSecret by dn.exact="{MANAGER}" =w by * none',
|
||||
f'to dn.subtree="{USERS}" by dn.exact="{MANAGER}" manage by * read',
|
||||
@@ -125,20 +200,27 @@ def _ensure_modules(connection):
|
||||
|
||||
|
||||
def _ensure_schema(connection):
|
||||
"""Add the package's schema to cn=config when missing, else set its definitions that differ."""
|
||||
"""Add the package's schema to cn=config when missing, else add the definitions it lacks; a definition that differs
|
||||
from the one of the same OID replaces the whole list of its kind."""
|
||||
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]}
|
||||
attributes = {'olcAttributeTypes': list(SCHEMA_ATTRIBUTE_TYPES), 'olcObjectClasses': list(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()])))
|
||||
dict({name: [value.encode() for value in values] for name, values in attributes.items()},
|
||||
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)
|
||||
for name, wanted in attributes.items():
|
||||
present = unordered(current.get(name, []))
|
||||
oids = {value.split()[1] for value in present}
|
||||
missing = [value for value in wanted if value not in present]
|
||||
if not missing:
|
||||
continue
|
||||
if any(value.split()[1] in oids for value in missing):
|
||||
connection.modify_s(dn, [(ldap.MOD_REPLACE, name, [value.encode() for value in wanted])])
|
||||
else:
|
||||
connection.modify_s(dn, [(ldap.MOD_ADD, name, [value.encode() for value in missing])])
|
||||
|
||||
|
||||
def _ensure_password_hash(connection):
|
||||
@@ -149,8 +231,9 @@ def _ensure_password_hash(connection):
|
||||
|
||||
|
||||
def _ensure_database(connection):
|
||||
"""The DN of the package's database in cn=config, created when missing; its ACLs are kept as ACCESS."""
|
||||
found = connection.search_s('cn=config', ldap.SCOPE_ONELEVEL, f'(olcSuffix={BASE})', ['olcAccess'])
|
||||
"""The DN of the package's database in cn=config, created when missing; its ACLs are kept as ACCESS, and the
|
||||
indexes of INDEXES it lacks are added, which slapd builds online."""
|
||||
found = connection.search_s('cn=config', ldap.SCOPE_ONELEVEL, f'(olcSuffix={BASE})', ['olcAccess', 'olcDbIndex'])
|
||||
if not found:
|
||||
DB_DIRECTORY.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
openldap = pwd.getpwnam('openldap')
|
||||
@@ -161,13 +244,17 @@ def _ensure_database(connection):
|
||||
'olcSuffix': [BASE.encode()],
|
||||
'olcDbDirectory': [str(DB_DIRECTORY).encode()],
|
||||
'olcRootDN': [ROOT.encode()],
|
||||
'olcDbIndex': [b'objectClass eq', b'uid eq'],
|
||||
'olcDbIndex': [index.encode() for index in INDEXES],
|
||||
'olcAccess': [f'{{{index}}}{rule}'.encode() for index, rule in enumerate(ACCESS)],
|
||||
}))
|
||||
found = connection.search_s('cn=config', ldap.SCOPE_ONELEVEL, f'(olcSuffix={BASE})', ['olcAccess'])
|
||||
found = connection.search_s('cn=config', ldap.SCOPE_ONELEVEL, f'(olcSuffix={BASE})', ['olcAccess', 'olcDbIndex'])
|
||||
dn, attributes = found[0]
|
||||
if unordered(attributes.get('olcAccess', [])) != list(ACCESS):
|
||||
connection.modify_s(dn, [(ldap.MOD_REPLACE, 'olcAccess', [f'{{{index}}}{rule}'.encode() for index, rule in enumerate(ACCESS)])])
|
||||
present = [value.decode() for value in attributes.get('olcDbIndex', [])]
|
||||
missing = [index for index in INDEXES if index not in present]
|
||||
if missing:
|
||||
connection.modify_s(dn, [(ldap.MOD_ADD, 'olcDbIndex', [index.encode() for index in missing])])
|
||||
return dn
|
||||
|
||||
|
||||
@@ -219,6 +306,23 @@ def _ensure_manager(connection):
|
||||
MANAGER_SECRET.write_text(password, encoding='utf-8')
|
||||
|
||||
|
||||
def wireguard_peers():
|
||||
"""The WireGuard peers of the users who are not disabled, read as root: [(user, public key, [IP, ...])], sorted."""
|
||||
connection = root()
|
||||
try:
|
||||
found = connection.search_s(USERS, ldap.SCOPE_ONELEVEL,
|
||||
f'(&(vpnRouterWireGuardPeer=*)(!(pwdAccountLockedTime={DISABLED})))',
|
||||
['uid', 'vpnRouterWireGuardPeer'])
|
||||
finally:
|
||||
connection.unbind_s()
|
||||
peers = []
|
||||
for _, attributes in found:
|
||||
for value in attributes['vpnRouterWireGuardPeer']:
|
||||
key, _, ips = value.decode().partition(' ')
|
||||
peers.append((attributes['uid'][0].decode(), key, ips.split(',')))
|
||||
return sorted(peers)
|
||||
|
||||
|
||||
def provision():
|
||||
"""Create or bring up to date the modules, the package's schema, database, ACLs, password policy, entries and service account."""
|
||||
connection = root()
|
||||
@@ -229,7 +333,7 @@ def provision():
|
||||
database = _ensure_database(connection)
|
||||
_ensure_overlays(connection, database)
|
||||
_ensure_entry(connection, BASE, {'objectClass': [b'dcObject', b'organization'], 'dc': [b'vpn-router'], 'o': [b'vpn-router']})
|
||||
for unit in (USERS, GROUPS, SERVICES, POLICIES):
|
||||
for unit in (USERS, GROUPS, SERVICES, POLICIES, PKI):
|
||||
_ensure_entry(connection, unit, {'objectClass': [b'organizationalUnit'], 'ou': [unit.split(',')[0][3:].encode()]})
|
||||
_ensure_entry(connection, DEFAULT_POLICY, {
|
||||
'objectClass': [b'organizationalRole', b'pwdPolicy'],
|
||||
|
||||
@@ -44,6 +44,15 @@ LEVELS = (
|
||||
#: The kinds of VPN clients of metrics_vpn.
|
||||
VPN_KINDS = ('p2s', 'openvpn', 'wireguard')
|
||||
|
||||
#: The time slots of the site-to-site availability of each view of history(): the summary level read (None: the
|
||||
#: details), the slot as PostgreSQL starts it from time, and its length.
|
||||
UP_SLOTS = {
|
||||
'hour': (None, "date_bin('5 minutes', time, timestamptz '2000-01-01 00:00:00+00')", '5 minutes'),
|
||||
'day': (None, "date_trunc('hour', time, 'UTC')", '1 hour'),
|
||||
'month': ('hourly', "date_trunc('day', time, 'UTC')", '1 day'),
|
||||
'year': ('monthly', "date_trunc('month', time, 'UTC')", '1 month'),
|
||||
}
|
||||
|
||||
#: The views of history(): the summary level read (None: the details), the bucket and the span.
|
||||
RANGES = {
|
||||
'hour': (None, '1 minute', '1 hour'),
|
||||
@@ -148,8 +157,13 @@ def history(connection, view):
|
||||
each interface to its rx and tx, clients, which maps each kind of VPN client to its clients,
|
||||
and site_to_site, which maps each site-to-site connection to the values of
|
||||
metrics_site_to_site; a value without data is None.
|
||||
|
||||
site_to_site_up is the availability of each site-to-site connection in the time slots of UP_SLOTS, oldest first:
|
||||
for each slot its start in Unix seconds and, per connection, the percentage of its samples in which the connection
|
||||
was up, 0 to 100; the summaries' averages of up give it for the month and the year.
|
||||
"""
|
||||
level, bucket, span = RANGES[view]
|
||||
up_level, up_start, up_slot = UP_SLOTS[view]
|
||||
|
||||
def rows(table):
|
||||
keys, values = TABLES[table]
|
||||
@@ -182,4 +196,12 @@ def history(connection, view):
|
||||
for row in rows('metrics_site_to_site'):
|
||||
point(row[0])['site_to_site'][row[1]] = pairs(row[2:], TABLES['metrics_site_to_site'][1])
|
||||
empty = {value: {'avg': None, 'max': None} for value in TABLES['metrics_system'][1]}
|
||||
return {'range': view, 'bucket': bucket, 'points': [{**empty, **points[start]} for start in sorted(points)]}
|
||||
|
||||
slots = {}
|
||||
for start, name, percent in connection.execute(
|
||||
f"SELECT extract(epoch FROM {up_start})::bigint AS slot, connection, avg({'up' if up_level is None else 'up_avg'}) * 100 "
|
||||
f"FROM {'metrics_site_to_site' if up_level is None else f'metrics_site_to_site_{up_level}'} "
|
||||
f"WHERE time >= now() - interval '{span}' GROUP BY slot, connection ORDER BY slot").fetchall():
|
||||
slots.setdefault(start, {'time': start, 'connections': {}})['connections'][name] = percent
|
||||
return {'range': view, 'bucket': bucket, 'points': [{**empty, **points[start]} for start in sorted(points)],
|
||||
'site_to_site_up': {'slot': up_slot, 'points': [slots[start] for start in sorted(slots)]}}
|
||||
@@ -0,0 +1,584 @@
|
||||
"""The router's OpenID Provider, for the web service, with python3-oauthlib and python3-jwt.
|
||||
|
||||
While oidc.enabled is true, the web service answers OpenID Connect at the issuer vpnrouter.oidc_issuer, its HTTPS site
|
||||
that Caddy serves; the web service answers it only on the loopback address, where Caddy passes the requests. The users
|
||||
of the router's directory sign in, with the password and the TOTP code or with a passkey (vpnrouter_passkeys), to its
|
||||
clients, the service accounts with redirect URIs (vpnrouter_users.client_settings): a client's ID is its name and its
|
||||
secret its password, sent with HTTP Basic authentication or in the body of the token request. A client with allowed
|
||||
groups admits only their members; a service account never signs in as a user.
|
||||
|
||||
GET /.well-known/openid-configuration the discovery document
|
||||
GET /oidc/jwks the public key the ID tokens are signed with
|
||||
GET, POST /oidc/authorize the authorization code flow, PKCE with S256 required; the sign-in page while
|
||||
the browser has no session, or with prompt=login or an older one than max_age
|
||||
POST /oidc/login the sign-in page's form: the name, the password and the code, or a passkey
|
||||
POST /oidc/passkey begin a sign-in with a passkey: {"id", "options"}
|
||||
POST /oidc/token exchange a code or a refresh token for tokens
|
||||
GET, POST /oidc/userinfo the claims of the user an access token was issued for
|
||||
GET /oidc/logout end the session, then go to a post-logout redirect URI of the client
|
||||
|
||||
ID tokens are RS256 JWTs signed with KEY, an RSA key the web service makes on its first run on the router, named by
|
||||
the RFC 7638 thumbprint of its public key. sub is the entryUUID of the user's entry, so a user added again under the
|
||||
same name is another subject. The scopes: openid, required; profile, preferred_username and the names; email; groups,
|
||||
the names of the user's groups; their claims are in the ID token and the userinfo answer. A code is valid
|
||||
CODE_SECONDS, an access token and an ID token ACCESS_SECONDS, a refresh token REFRESH_SECONDS, used once. A session,
|
||||
the cookie COOKIE of the issuer's /oidc/ path, lasts SESSION_SECONDS. Codes, tokens and sessions are kept in the
|
||||
database of vpnrouter_auth as their SHA-256 (vpnrouter_auth.OIDC_SQL); vpnrouter_auth.revoke removes a user's. A
|
||||
client is registered by an administrator, so a user is not asked for consent.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import collections
|
||||
import contextlib
|
||||
import datetime
|
||||
import hashlib
|
||||
import http.cookies
|
||||
import json
|
||||
import pathlib
|
||||
import secrets
|
||||
import time
|
||||
import types
|
||||
import urllib.parse
|
||||
|
||||
import jinja2
|
||||
import jwt
|
||||
import ldap
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from oauthlib.common import add_params_to_uri
|
||||
from oauthlib.oauth2 import AccessDeniedError, FatalClientError, InvalidRequestError, OAuth2Error
|
||||
from oauthlib.openid import RequestValidator, Server
|
||||
from oauthlib.openid.connect.core.exceptions import LoginRequired
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_auth
|
||||
import vpnrouter_ldap
|
||||
import vpnrouter_passkeys
|
||||
import vpnrouter_users
|
||||
|
||||
KEY = pathlib.Path('/etc/vpn-router/oidc.key')
|
||||
KEY_BITS = 3072
|
||||
|
||||
CODE_SECONDS = 60
|
||||
ACCESS_SECONDS = 300
|
||||
REFRESH_SECONDS = 8 * 3600
|
||||
SESSION_SECONDS = 8 * 3600
|
||||
|
||||
COOKIE = 'vpn_router_oidc'
|
||||
|
||||
#: The claims of each scope but openid, which gives sub.
|
||||
SCOPE_CLAIMS = {
|
||||
'profile': ('preferred_username', 'name', 'given_name', 'family_name'),
|
||||
'email': ('email',),
|
||||
'groups': ('groups',),
|
||||
}
|
||||
SCOPES = ('openid', *SCOPE_CLAIMS)
|
||||
|
||||
#: The authentication methods (RFC 8176) of a sign-in with a password, with a password and a TOTP code, and with a
|
||||
#: passkey, which verifies the user.
|
||||
PASSWORD_AMR = ['pwd']
|
||||
OTP_AMR = ['pwd', 'otp']
|
||||
PASSKEY_AMR = ['hwk', 'user']
|
||||
|
||||
TEMPLATE = 'oidc-page.html.j2'
|
||||
|
||||
#: The headers of the pages: no framing, and nothing loaded but from the page itself and the router.
|
||||
PAGE_HEADERS = [('Content-Security-Policy', "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; "
|
||||
"connect-src 'self'; frame-ancestors 'none'")]
|
||||
|
||||
#: The headers of oauthlib's answers that the web service sets itself.
|
||||
OWN_HEADERS = ('content-type', 'cache-control', 'content-length')
|
||||
|
||||
#: A user's sign-in at the OpenID Provider: the user name, when, a datetime, and how (amr).
|
||||
Session = collections.namedtuple('Session', 'name auth_time amr')
|
||||
|
||||
_pages = jinja2.Environment(loader=jinja2.FileSystemLoader(str(vpnrouter.TEMPLATE_DIR)), autoescape=True,
|
||||
undefined=jinja2.StrictUndefined)
|
||||
|
||||
|
||||
def ensure_key():
|
||||
"""Make the signing key when it is missing."""
|
||||
if KEY.is_file() and KEY.stat().st_size:
|
||||
return
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=KEY_BITS)
|
||||
KEY.parent.mkdir(parents=True, exist_ok=True)
|
||||
KEY.touch(mode=0o600)
|
||||
KEY.chmod(0o600)
|
||||
KEY.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption()))
|
||||
|
||||
|
||||
def private_key():
|
||||
return serialization.load_pem_private_key(KEY.read_bytes(), None)
|
||||
|
||||
|
||||
def public_jwk(key):
|
||||
"""The JWK of the key's public key, its kid the RFC 7638 thumbprint."""
|
||||
numbers = key.public_key().public_numbers()
|
||||
jwk = {'e': numbers.e, 'kty': 'RSA', 'n': numbers.n}
|
||||
jwk = {name: vpnrouter_auth.b64encode(value.to_bytes((value.bit_length() + 7) // 8, 'big')) if isinstance(value, int)
|
||||
else value for name, value in jwk.items()}
|
||||
thumbprint = hashlib.sha256(json.dumps(jwk, sort_keys=True, separators=(',', ':')).encode()).digest()
|
||||
return {**jwk, 'kid': vpnrouter_auth.b64encode(thumbprint), 'use': 'sig', 'alg': 'RS256'}
|
||||
|
||||
|
||||
def sign(claims):
|
||||
key = private_key()
|
||||
return jwt.encode(claims, key, algorithm='RS256', headers={'kid': public_jwk(key)['kid']})
|
||||
|
||||
|
||||
def discovery(issuer):
|
||||
"""The discovery document of OpenID Connect Discovery 1.0."""
|
||||
return {
|
||||
'issuer': issuer,
|
||||
'authorization_endpoint': f'{issuer}/oidc/authorize',
|
||||
'token_endpoint': f'{issuer}/oidc/token',
|
||||
'userinfo_endpoint': f'{issuer}/oidc/userinfo',
|
||||
'jwks_uri': f'{issuer}/oidc/jwks',
|
||||
'end_session_endpoint': f'{issuer}/oidc/logout',
|
||||
'scopes_supported': list(SCOPES),
|
||||
'response_types_supported': ['code'],
|
||||
'response_modes_supported': ['query'],
|
||||
'grant_types_supported': ['authorization_code', 'refresh_token'],
|
||||
'subject_types_supported': ['public'],
|
||||
'id_token_signing_alg_values_supported': ['RS256'],
|
||||
'token_endpoint_auth_methods_supported': ['client_secret_basic', 'client_secret_post'],
|
||||
'code_challenge_methods_supported': ['S256'],
|
||||
'claims_supported': ['iss', 'sub', 'aud', 'exp', 'iat', 'auth_time', 'nonce', 'amr', 'at_hash',
|
||||
*(claim for claims in SCOPE_CLAIMS.values() for claim in claims)],
|
||||
}
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def directory():
|
||||
"""A connection to the router's directory as the user manager account, for reading."""
|
||||
connection = vpnrouter_ldap.manager()
|
||||
try:
|
||||
yield connection
|
||||
finally:
|
||||
connection.unbind_s()
|
||||
|
||||
|
||||
def client_of(client_id):
|
||||
"""The client of the ID, a namespace of its vpnrouter_users.summary with client_id, when it is an enabled service
|
||||
account with redirect URIs; else None."""
|
||||
if not isinstance(client_id, str):
|
||||
return None
|
||||
with directory() as connection:
|
||||
try:
|
||||
user = vpnrouter_users.summary(vpnrouter_users.entry(connection, client_id))
|
||||
except vpnrouter_users.Failure:
|
||||
return None
|
||||
if not user['service'] or not user['enabled'] or not user['redirect_uris']:
|
||||
return None
|
||||
return types.SimpleNamespace(client_id=client_id, **user)
|
||||
|
||||
|
||||
def subject(name, client):
|
||||
"""The claims of every scope of the user, with sub, when the user may sign in to the client: an enabled, unlocked
|
||||
user who is not a service account, a member of one of the client's allowed groups when it has any; else None."""
|
||||
with directory() as connection:
|
||||
try:
|
||||
attributes = vpnrouter_users.entry(connection, name)
|
||||
except vpnrouter_users.Failure:
|
||||
return None
|
||||
user = vpnrouter_users.summary(attributes)
|
||||
groups = sorted(vpnrouter_users.groups_of(connection, name))
|
||||
uuid = connection.search_s(vpnrouter_users.dn_of(name), ldap.SCOPE_BASE, attrlist=['entryUUID'])[0][1]['entryUUID']
|
||||
if user['service'] or not user['enabled'] or user['locked']:
|
||||
return None
|
||||
if client.allowed_groups and not set(client.allowed_groups) & set(groups):
|
||||
return None
|
||||
full = ' '.join(part for part in (user['first_name'], user['last_name']) if part)
|
||||
claims = {'sub': uuid[0].decode(), 'preferred_username': name, 'name': full or None, 'given_name': user['first_name'],
|
||||
'family_name': user['last_name'], 'email': user['email'], 'groups': groups}
|
||||
return {claim: value for claim, value in claims.items() if value is not None}
|
||||
|
||||
|
||||
def claims_of(person, scopes):
|
||||
"""The claims of the scopes of a subject's claims (subject)."""
|
||||
return {'sub': person['sub'], **{claim: person[claim] for scope in scopes for claim in SCOPE_CLAIMS.get(scope, ())
|
||||
if claim in person}}
|
||||
|
||||
|
||||
def cookie_value(cookies):
|
||||
"""The value of the session cookie in a Cookie header, or ''."""
|
||||
try:
|
||||
morsel = http.cookies.SimpleCookie(cookies or '').get(COOKIE)
|
||||
except http.cookies.CookieError:
|
||||
return ''
|
||||
return morsel.value if morsel is not None else ''
|
||||
|
||||
|
||||
def session_of(cfg, cookies):
|
||||
"""The unexpired Session the session cookie in a Cookie header names, or None."""
|
||||
value = cookie_value(cookies)
|
||||
if not value:
|
||||
return None
|
||||
with vpnrouter_auth.database(cfg) as connection:
|
||||
row = connection.execute('SELECT * FROM oidc_session_get(%s)', (vpnrouter_auth.digest(value),)).fetchone()
|
||||
return Session(*row) if row else None
|
||||
|
||||
|
||||
class Validator(RequestValidator):
|
||||
"""The checks and the storage of oauthlib's server for the router's clients, users and database.
|
||||
|
||||
Only the authorization code grant with PKCE S256 and the refresh token grant are allowed. The values of a code, a
|
||||
token or a session found are kept on the request as oidc_code, oidc_scopes and oidc_person.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg):
|
||||
super().__init__()
|
||||
self.cfg = cfg
|
||||
|
||||
def fetch(self, sql, values):
|
||||
with vpnrouter_auth.database(self.cfg) as connection:
|
||||
return connection.execute(sql, values).fetchone()
|
||||
|
||||
def call(self, *statements):
|
||||
with vpnrouter_auth.database(self.cfg) as connection:
|
||||
for sql, values in statements:
|
||||
connection.execute(sql, values)
|
||||
|
||||
def session(self, request):
|
||||
return session_of(self.cfg, request.headers.get('Cookie'))
|
||||
|
||||
# Clients.
|
||||
|
||||
def validate_client_id(self, client_id, request, *args, **kwargs):
|
||||
request.client = client_of(client_id)
|
||||
return request.client is not None
|
||||
|
||||
def validate_redirect_uri(self, client_id, redirect_uri, request, *args, **kwargs):
|
||||
return redirect_uri in request.client.redirect_uris
|
||||
|
||||
def get_default_redirect_uri(self, client_id, request, *args, **kwargs):
|
||||
return None
|
||||
|
||||
def validate_response_type(self, client_id, response_type, client, request, *args, **kwargs):
|
||||
return response_type == 'code'
|
||||
|
||||
def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs):
|
||||
return 'openid' in scopes and set(scopes) <= set(SCOPES)
|
||||
|
||||
def get_default_scopes(self, client_id, request, *args, **kwargs):
|
||||
return ['openid']
|
||||
|
||||
def is_pkce_required(self, client_id, request):
|
||||
return True
|
||||
|
||||
def authenticate_client(self, request, *args, **kwargs):
|
||||
"""Check the client's secret, the password of its service account, given with HTTP Basic authentication, its
|
||||
ID and secret form-encoded (RFC 6749 2.3.1), or in the body."""
|
||||
name, secret = request.client_id, request.client_secret
|
||||
header = request.headers.get('Authorization', '')
|
||||
if header[:6].lower() == 'basic ':
|
||||
try:
|
||||
name, _, secret = base64.b64decode(header[6:].strip(), validate=True).decode().partition(':')
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return False
|
||||
name, secret = urllib.parse.unquote_plus(name), urllib.parse.unquote_plus(secret)
|
||||
client = client_of(name)
|
||||
if client is None:
|
||||
return False
|
||||
try:
|
||||
vpnrouter_users.login(name, secret, None)
|
||||
except vpnrouter_users.Failure:
|
||||
return False
|
||||
request.client = client
|
||||
return True
|
||||
|
||||
def authenticate_client_id(self, client_id, request, *args, **kwargs):
|
||||
return False
|
||||
|
||||
def validate_grant_type(self, client_id, grant_type, client, request, *args, **kwargs):
|
||||
return grant_type in ('authorization_code', 'refresh_token')
|
||||
|
||||
def validate_user(self, username, password, client, request, *args, **kwargs):
|
||||
return False
|
||||
|
||||
# Codes.
|
||||
|
||||
def save_authorization_code(self, client_id, code, request, *args, **kwargs):
|
||||
self.call(('CALL oidc_code_save(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)',
|
||||
(vpnrouter_auth.digest(code['code']), client_id, request.user, request.redirect_uri, request.scopes,
|
||||
request.nonce, request.code_challenge, request.code_challenge_method, request.auth_time, request.amr,
|
||||
CODE_SECONDS)))
|
||||
|
||||
def code(self, code):
|
||||
row = self.fetch('SELECT * FROM oidc_code_get(%s)', (vpnrouter_auth.digest(code),)) if isinstance(code, str) else None
|
||||
return dict(zip(('client_id', 'name', 'redirect_uri', 'scopes', 'nonce', 'code_challenge', 'code_challenge_method',
|
||||
'auth_time', 'amr'), row)) if row else None
|
||||
|
||||
def get_authorization_code_scopes(self, client_id, code, redirect_uri, request):
|
||||
found = self.code(code)
|
||||
return found['scopes'] if found else []
|
||||
|
||||
def validate_code(self, client_id, code, client, request, *args, **kwargs):
|
||||
found = self.code(code)
|
||||
if found is None or found['client_id'] != client_id:
|
||||
return False
|
||||
person = subject(found['name'], client)
|
||||
if person is None:
|
||||
return False
|
||||
request.user, request.scopes, request.auth_time, request.amr = found['name'], found['scopes'], found['auth_time'], found['amr']
|
||||
request.oidc_code, request.oidc_person = found, person
|
||||
return True
|
||||
|
||||
def get_code_challenge(self, code, request):
|
||||
return request.oidc_code['code_challenge']
|
||||
|
||||
def get_code_challenge_method(self, code, request):
|
||||
return request.oidc_code['code_challenge_method']
|
||||
|
||||
def confirm_redirect_uri(self, client_id, code, redirect_uri, client, request, *args, **kwargs):
|
||||
return redirect_uri == request.oidc_code['redirect_uri']
|
||||
|
||||
def get_authorization_code_nonce(self, client_id, code, redirect_uri, request):
|
||||
return request.oidc_code['nonce']
|
||||
|
||||
def invalidate_authorization_code(self, client_id, code, request, *args, **kwargs):
|
||||
self.call(('CALL oidc_code_delete(%s)', (vpnrouter_auth.digest(code),)))
|
||||
|
||||
# Tokens.
|
||||
|
||||
def token(self, token, kind):
|
||||
row = self.fetch('SELECT * FROM oidc_token_get(%s, %s)', (vpnrouter_auth.digest(token), kind)) \
|
||||
if isinstance(token, str) and token else None
|
||||
return dict(zip(('client_id', 'name', 'scopes', 'auth_time', 'amr'), row)) if row else None
|
||||
|
||||
def save_bearer_token(self, token, request, *args, **kwargs):
|
||||
"""Keep the access token and the refresh token; a refresh token that was exchanged is used up."""
|
||||
values = (request.client.client_id, request.user, request.scopes, request.auth_time, request.amr)
|
||||
statements = [('CALL oidc_token_save(%s, %s, %s, %s, %s, %s, %s, %s)',
|
||||
(vpnrouter_auth.digest(token['access_token']), 'access', *values, ACCESS_SECONDS))]
|
||||
if token.get('refresh_token'):
|
||||
statements.append(('CALL oidc_token_save(%s, %s, %s, %s, %s, %s, %s, %s)',
|
||||
(vpnrouter_auth.digest(token['refresh_token']), 'refresh', *values, REFRESH_SECONDS)))
|
||||
if request.grant_type == 'refresh_token':
|
||||
statements.append(('CALL oidc_token_delete(%s)', (vpnrouter_auth.digest(request.refresh_token),)))
|
||||
self.call(*statements)
|
||||
|
||||
def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs):
|
||||
found = self.token(refresh_token, 'refresh')
|
||||
if found is None or found['client_id'] != client.client_id:
|
||||
return False
|
||||
person = subject(found['name'], client)
|
||||
if person is None:
|
||||
return False
|
||||
request.user, request.auth_time, request.amr = found['name'], found['auth_time'], found['amr']
|
||||
request.oidc_scopes, request.oidc_person = found['scopes'], person
|
||||
return True
|
||||
|
||||
def get_original_scopes(self, refresh_token, request, *args, **kwargs):
|
||||
return request.oidc_scopes
|
||||
|
||||
def validate_bearer_token(self, token, scopes, request):
|
||||
found = self.token(token, 'access')
|
||||
if found is None or not set(scopes) <= set(found['scopes']):
|
||||
return False
|
||||
client = client_of(found['client_id'])
|
||||
person = subject(found['name'], client) if client else None
|
||||
if person is None:
|
||||
return False
|
||||
request.client, request.user, request.oidc_scopes, request.oidc_person = client, found['name'], found['scopes'], person
|
||||
return True
|
||||
|
||||
def get_userinfo_claims(self, request):
|
||||
return claims_of(request.oidc_person, request.oidc_scopes)
|
||||
|
||||
# OpenID Connect.
|
||||
|
||||
def finalize_id_token(self, id_token, token, token_handler, request):
|
||||
now = int(time.time())
|
||||
id_token.update(claims_of(request.oidc_person, request.scopes))
|
||||
id_token.update({'iss': vpnrouter.oidc_issuer(self.cfg), 'exp': now + ACCESS_SECONDS,
|
||||
'auth_time': int(request.auth_time.timestamp()), 'amr': request.amr})
|
||||
return sign(id_token)
|
||||
|
||||
def validate_silent_login(self, request):
|
||||
session = self.session(request)
|
||||
return session is not None and subject(session.name, request.client) is not None
|
||||
|
||||
def validate_silent_authorization(self, request):
|
||||
return True
|
||||
|
||||
def validate_user_match(self, id_token_hint, scopes, claims, request):
|
||||
"""Whether the user of the session, when there is one, is the subject of an ID token the router signed."""
|
||||
session = self.session(request) if id_token_hint else None
|
||||
if session is None:
|
||||
return True
|
||||
try:
|
||||
hint = jwt.decode(id_token_hint, private_key().public_key(), algorithms=['RS256'],
|
||||
options={'verify_exp': False, 'verify_aud': False})
|
||||
except jwt.InvalidTokenError:
|
||||
return False
|
||||
person = subject(session.name, request.client)
|
||||
return person is not None and hint.get('sub') == person['sub']
|
||||
|
||||
|
||||
def page(status, cfg, title, message='', error='', form=False, request='', name='', headers=()):
|
||||
"""An HTML page of TEMPLATE: a message, an error, and with form the sign-in form for the authorization request."""
|
||||
body = _pages.get_template(TEMPLATE).render(title=title, message=message, error=error, form=form, request=request,
|
||||
name=name, issuer=vpnrouter.oidc_issuer(cfg))
|
||||
return status, 'text/html; charset=utf-8', body.encode(), [*PAGE_HEADERS, *headers]
|
||||
|
||||
|
||||
def document(status, value, headers=()):
|
||||
return status, 'application/json', json.dumps(value, indent=2).encode() + b'\n', list(headers)
|
||||
|
||||
|
||||
def relayed(headers, body, status):
|
||||
"""An answer of oauthlib's server, without the headers the web service sets."""
|
||||
kind = next((value for name, value in headers.items() if name.lower() == 'content-type'), 'application/json')
|
||||
return status, kind, (body or '').encode(), [(name, value) for name, value in headers.items()
|
||||
if name.lower() not in OWN_HEADERS]
|
||||
|
||||
|
||||
def redirect(location, headers=()):
|
||||
return 302, 'text/plain; charset=utf-8', b'', [('Location', location), *headers]
|
||||
|
||||
|
||||
def cookie(value, seconds):
|
||||
return 'Set-Cookie', f'{COOKIE}={value}; Path=/oidc/; Max-Age={seconds}; Secure; HttpOnly; SameSite=Lax'
|
||||
|
||||
|
||||
def authorize(server, cfg, params, headers, fresh=None, name='', error=''):
|
||||
"""Answer an authorization request, the query or the form params: the sign-in page while the browser has no
|
||||
session, or with prompt=login or max_age older than the session, else a redirect to the client with a code.
|
||||
|
||||
fresh is the session of a sign-in just made, which counts whatever prompt and max_age say; the redirect then sets
|
||||
its cookie, fresh.cookie. error is why a sign-in failed: the sign-in page shows it, filled with the name.
|
||||
"""
|
||||
uri = f'{vpnrouter.oidc_issuer(cfg)}/oidc/authorize?{params}'
|
||||
try:
|
||||
scopes, info = server.validate_authorization_request(uri, 'GET', None, headers)
|
||||
except FatalClientError as error:
|
||||
return page(error.status_code, cfg, 'The sign-in request is refused', error=error.description or error.error)
|
||||
except OAuth2Error as error:
|
||||
if not error.redirect_uri:
|
||||
return page(error.status_code, cfg, 'The sign-in request is refused', error=error.description or error.error)
|
||||
return redirect(error.in_uri(error.redirect_uri))
|
||||
request = info['request']
|
||||
title = f'Sign in to {request.client.display_name or request.client.client_id}'
|
||||
if info.get('code_challenge_method') != 'S256':
|
||||
return redirect(InvalidRequestError(description='PKCE with code_challenge_method S256 is required',
|
||||
request=request).in_uri(request.redirect_uri))
|
||||
if error:
|
||||
return page(401, cfg, title, error=error, form=True, request=params, name=name)
|
||||
session = fresh.session if fresh else session_of(cfg, headers.get('Cookie'))
|
||||
max_age = getattr(request, 'max_age', None)
|
||||
stale = session is not None and isinstance(max_age, str) and max_age.isdigit() and \
|
||||
time.time() - session.auth_time.timestamp() > int(max_age)
|
||||
prompt = info.get('prompt') or set()
|
||||
if session is None or (not fresh and ('login' in prompt or stale)):
|
||||
if 'none' in prompt:
|
||||
return redirect(LoginRequired(request=request).in_uri(request.redirect_uri))
|
||||
return page(200, cfg, title, form=True, request=params, name=name)
|
||||
if subject(session.name, request.client) is None:
|
||||
if fresh:
|
||||
return redirect(AccessDeniedError(description=f'{session.name} may not sign in to this client',
|
||||
request=request).in_uri(request.redirect_uri), [fresh.cookie])
|
||||
return page(403, cfg, title, error=f'{session.name} may not sign in here; sign in as another user.', form=True,
|
||||
request=params)
|
||||
answer, _, _ = server.create_authorization_response(uri, 'GET', None, headers, scopes, credentials={
|
||||
'user': session.name, 'auth_time': session.auth_time, 'amr': session.amr})
|
||||
return redirect(answer['Location'], [fresh.cookie] if fresh else [])
|
||||
|
||||
|
||||
def login(server, cfg, form, headers):
|
||||
"""Sign a user in with the sign-in page's form, start the session and go on with its authorization request."""
|
||||
params = form.get('request', '')
|
||||
name = form.get('name', '').strip()
|
||||
try:
|
||||
if form.get('passkey_id'):
|
||||
with vpnrouter_users.session() as connection:
|
||||
name = vpnrouter_passkeys.login_complete(connection, form['passkey_id'], json.loads(form.get('credential', '')))
|
||||
amr = PASSKEY_AMR
|
||||
else:
|
||||
code = form.get('code', '').strip()
|
||||
vpnrouter_users.login(name, form.get('password', ''), code or None)
|
||||
with directory() as connection:
|
||||
if vpnrouter_users.service(vpnrouter_users.entry(connection, name)):
|
||||
raise vpnrouter_users.Failure('the name, the password or the code is wrong', 5)
|
||||
amr = OTP_AMR if code else PASSWORD_AMR
|
||||
except vpnrouter_users.Failure as failure:
|
||||
return authorize(server, cfg, params, headers, name=name, error=str(failure))
|
||||
except ValueError:
|
||||
return authorize(server, cfg, params, headers, name=name, error='the passkey is not accepted')
|
||||
value = secrets.token_urlsafe(32)
|
||||
with vpnrouter_auth.database(cfg) as connection:
|
||||
connection.execute('CALL oidc_session_save(%s, %s, %s, %s)', (vpnrouter_auth.digest(value), name, amr, SESSION_SECONDS))
|
||||
fresh = types.SimpleNamespace(session=Session(name, datetime.datetime.now(datetime.timezone.utc), amr),
|
||||
cookie=cookie(value, SESSION_SECONDS))
|
||||
return authorize(server, cfg, params, headers, fresh=fresh)
|
||||
|
||||
|
||||
def logout(cfg, query, headers):
|
||||
"""End the session the cookie names, then go to post_logout_redirect_uri, with state, when it is a post-logout
|
||||
redirect URI of the client that client_id or the audience of id_token_hint names; else show that the user is
|
||||
signed out."""
|
||||
values = dict(urllib.parse.parse_qsl(query))
|
||||
value = cookie_value(headers.get('Cookie'))
|
||||
if value:
|
||||
with vpnrouter_auth.database(cfg) as connection:
|
||||
connection.execute('CALL oidc_session_delete(%s)', (vpnrouter_auth.digest(value),))
|
||||
client_id = values.get('client_id')
|
||||
if not client_id and values.get('id_token_hint'):
|
||||
try:
|
||||
audience = jwt.decode(values['id_token_hint'], private_key().public_key(), algorithms=['RS256'],
|
||||
options={'verify_exp': False, 'verify_aud': False}).get('aud')
|
||||
except jwt.InvalidTokenError:
|
||||
audience = None
|
||||
client_id = audience[0] if isinstance(audience, list) and audience else audience
|
||||
client = client_of(client_id)
|
||||
target = values.get('post_logout_redirect_uri')
|
||||
expired = cookie('', 0)
|
||||
if client is not None and target in client.post_logout_redirect_uris:
|
||||
return redirect(add_params_to_uri(target, [('state', values['state'])]) if values.get('state') else target, [expired])
|
||||
return page(200, cfg, 'Signed out', message='You are signed out of the router.', headers=[expired])
|
||||
|
||||
|
||||
def answer(method, target, headers, body):
|
||||
"""Answer a request of the OpenID Provider: the HTTP status, the content type, the body and [(header, value)]; 404
|
||||
while oidc.enabled is false. target is the path with the query, headers a mapping of the request's headers and
|
||||
body its bytes; HEAD is answered as GET."""
|
||||
path, _, query = target.partition('?')
|
||||
method = 'GET' if method == 'HEAD' else method
|
||||
try:
|
||||
cfg = vpnrouter.load_config()
|
||||
if not cfg['oidc']['enabled']:
|
||||
return 404, 'text/plain; charset=utf-8', b'Not found\n', []
|
||||
issuer = vpnrouter.oidc_issuer(cfg)
|
||||
headers = dict(headers.items())
|
||||
text = body.decode('utf-8', 'replace') if body else ''
|
||||
server = Server(Validator(cfg), token_expires_in=ACCESS_SECONDS)
|
||||
if (method, path) == ('GET', '/.well-known/openid-configuration'):
|
||||
return document(200, discovery(issuer))
|
||||
if (method, path) == ('GET', '/oidc/jwks'):
|
||||
return document(200, {'keys': [public_jwk(private_key())]})
|
||||
if path == '/oidc/authorize' and method in ('GET', 'POST'):
|
||||
return authorize(server, cfg, query if method == 'GET' else text, headers)
|
||||
if (method, path) == ('POST', '/oidc/login'):
|
||||
return login(server, cfg, dict(urllib.parse.parse_qsl(text)), headers)
|
||||
if (method, path) == ('POST', '/oidc/passkey'):
|
||||
return document(200, vpnrouter_passkeys.login_begin())
|
||||
if (method, path) == ('POST', '/oidc/token'):
|
||||
return relayed(*server.create_token_response(f'{issuer}{path}', 'POST', text, headers))
|
||||
if path == '/oidc/userinfo' and method in ('GET', 'POST'):
|
||||
return relayed(*server.create_userinfo_response(f'{issuer}{target}', method, text or None, headers))
|
||||
if (method, path) == ('GET', '/oidc/logout'):
|
||||
return logout(cfg, query, headers)
|
||||
return 404, 'text/plain; charset=utf-8', b'Not found\n', []
|
||||
except OAuth2Error as error:
|
||||
return error.status_code, 'application/json', error.json.encode(), list(error.headers.items())
|
||||
except vpnrouter_users.Failure as failure:
|
||||
return document(400 if failure.status == 2 else 500, {'error': str(failure)})
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
return document(500, {'error': f'the OpenID Provider failed: {exc}'})
|
||||
except vpnrouter_auth.DatabaseError as exc:
|
||||
return document(500, {'error': str(exc)})
|
||||
except SystemExit as exc:
|
||||
return document(500, {'error': str(exc.code)})
|
||||
except ValueError as exc:
|
||||
return document(400, {'error': 'invalid_request', 'error_description': str(exc)})
|
||||
@@ -0,0 +1,174 @@
|
||||
"""The passkeys of the router's users, for signing in to the Router Manager and the OpenID Provider (vpnrouter_oidc),
|
||||
with python3-fido2.
|
||||
|
||||
A passkey is a discoverable WebAuthn credential with user verification, of the relying party
|
||||
wan.local_fqdn, so the browser offers it at the HTTPS addresses on that name.
|
||||
Its user handle is the user name. Each passkey of a user is a value of vpnRouterPasskey on the
|
||||
user entry: a JSON object {"id", "credential", "label", "created"}, id the credential ID and
|
||||
credential the attested credential data (with the public key), both base64url. A user has at
|
||||
most PASSKEYS_MAX passkeys; service accounts have none.
|
||||
|
||||
Registration and sign-in are two steps each: begin answers the options for the browser and a
|
||||
pending id, whose state the web service keeps in memory for PENDING_SECONDS; complete takes the
|
||||
id and the browser's answer. A request that cannot be done raises vpnrouter_users.Failure, with
|
||||
the statuses of that module; a sign-in that fails gives status 5 whatever the reason.
|
||||
"""
|
||||
|
||||
import json
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
|
||||
import fido2.features
|
||||
import ldap
|
||||
from fido2.server import Fido2Server
|
||||
from fido2.utils import websafe_decode, websafe_encode
|
||||
from fido2.webauthn import (AttestedCredentialData, PublicKeyCredentialRpEntity, PublicKeyCredentialUserEntity,
|
||||
ResidentKeyRequirement, UserVerificationRequirement)
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_users
|
||||
|
||||
# The options and the browser's answers are in the JSON mapping of WebAuthn Level 3; python3-fido2 2.0 always uses it.
|
||||
_json_mapping = getattr(fido2.features, 'webauthn_json_mapping', None)
|
||||
if _json_mapping is not None:
|
||||
_json_mapping.enabled = True
|
||||
|
||||
#: The most passkeys of one user.
|
||||
PASSKEYS_MAX = 10
|
||||
#: The longest label of a passkey, in characters.
|
||||
LABEL_MAX = 64
|
||||
#: Seconds a begun registration or sign-in may be completed.
|
||||
PENDING_SECONDS = 120
|
||||
#: The most begun registrations and sign-ins kept at once.
|
||||
PENDING_MAX = 1000
|
||||
|
||||
#: The begun registrations and sign-ins: pending id -> (kind, user name or None, state, monotonic time of expiry).
|
||||
_pending = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def server():
|
||||
"""The Fido2Server of the relying party wan.local_fqdn; passkeys need the name."""
|
||||
fqdn = vpnrouter.load_config()['wan']['local_fqdn'].strip()
|
||||
if not fqdn:
|
||||
raise vpnrouter_users.Failure('passkeys need wan.local_fqdn, the name the Router Manager is reached at', 2)
|
||||
return Fido2Server(rp=PublicKeyCredentialRpEntity(name=fqdn, id=fqdn))
|
||||
|
||||
|
||||
def keep(kind, name, state):
|
||||
"""Keep the state of a begun step and answer its pending id."""
|
||||
pending = secrets.token_urlsafe(24)
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
for old in [key for key, (*_, expires) in _pending.items() if expires <= now]:
|
||||
del _pending[old]
|
||||
if len(_pending) >= PENDING_MAX:
|
||||
raise vpnrouter_users.Failure('too many passkey requests at once; retry in a moment')
|
||||
_pending[pending] = (kind, name, state, now + PENDING_SECONDS)
|
||||
return pending
|
||||
|
||||
|
||||
def take(kind, name, pending):
|
||||
"""The state of a begun step of the kind and user, used up; None when it is unknown or expired."""
|
||||
with _lock:
|
||||
found = _pending.pop(pending, None) if isinstance(pending, str) else None
|
||||
if found is None or found[0] != kind or found[1] != name or found[3] <= time.monotonic():
|
||||
return None
|
||||
return found[2]
|
||||
|
||||
|
||||
def credentials(attributes):
|
||||
"""The attested credential data of the passkeys of an entry."""
|
||||
return [AttestedCredentialData(websafe_decode(passkey['credential']))
|
||||
for _, passkey in vpnrouter_users.passkeys_of(attributes)]
|
||||
|
||||
|
||||
def register_begin(connection, name):
|
||||
"""Begin the registration of a passkey of the user: {"id", "options"}, options those of navigator.credentials.create."""
|
||||
current = vpnrouter_users.entry(connection, name)
|
||||
if vpnrouter_users.service(current):
|
||||
raise vpnrouter_users.Failure(f'{name} is a service account, which has no passkeys', 2)
|
||||
if len(vpnrouter_users.passkeys_of(current)) >= PASSKEYS_MAX:
|
||||
raise vpnrouter_users.Failure(f'user {name} has {PASSKEYS_MAX} passkeys, the most; remove one first', 2)
|
||||
options, state = server().register_begin(
|
||||
PublicKeyCredentialUserEntity(id=name.encode(), name=name, display_name=name),
|
||||
credentials(current),
|
||||
resident_key_requirement=ResidentKeyRequirement.REQUIRED,
|
||||
user_verification=UserVerificationRequirement.REQUIRED,
|
||||
)
|
||||
return {'id': keep('register', name, state), 'options': dict(options)}
|
||||
|
||||
|
||||
def register_complete(connection, name, pending, response, label):
|
||||
"""Store the passkey the browser created for a begun registration, with its label; the user (vpnrouter_users.summary)."""
|
||||
state = take('register', name, pending)
|
||||
if state is None:
|
||||
raise vpnrouter_users.Failure('the registration is unknown or expired; begin it again', 2)
|
||||
if label is None or (isinstance(label, str) and not label.strip()):
|
||||
label = 'Passkey'
|
||||
if not isinstance(label, str) or len(label.strip()) > LABEL_MAX:
|
||||
raise vpnrouter_users.Failure(f'the label must be text of at most {LABEL_MAX} characters', 2)
|
||||
if not isinstance(response, dict):
|
||||
raise vpnrouter_users.Failure('credential must be the answer of navigator.credentials.create as JSON', 2)
|
||||
try:
|
||||
data = server().register_complete(state, response).credential_data
|
||||
except (ValueError, KeyError, TypeError) as exc:
|
||||
raise vpnrouter_users.Failure(f'the passkey is refused: {exc}', 2)
|
||||
current = vpnrouter_users.entry(connection, name)
|
||||
value = json.dumps({
|
||||
'id': websafe_encode(data.credential_id),
|
||||
'credential': websafe_encode(bytes(data)),
|
||||
'label': label.strip(),
|
||||
'created': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
|
||||
}, separators=(',', ':'))
|
||||
changes = [] if 'vpnrouteraccount' in vpnrouter_users.classes(current) else [(ldap.MOD_ADD, 'objectClass', [b'vpnRouterAccount'])]
|
||||
changes.append((ldap.MOD_ADD, 'vpnRouterPasskey', [value.encode()]))
|
||||
connection.modify_s(vpnrouter_users.dn_of(name), changes)
|
||||
return vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
|
||||
|
||||
|
||||
def remove(connection, name, passkey_id=None):
|
||||
"""Remove the user's passkey of the id, or all its passkeys without one; the user (vpnrouter_users.summary)."""
|
||||
current = vpnrouter_users.entry(connection, name)
|
||||
values = current.get('vpnRouterPasskey', [])
|
||||
kept = [value for value, passkey in vpnrouter_users.passkeys_of(current)
|
||||
if passkey_id is not None and passkey['id'] != passkey_id]
|
||||
if passkey_id is not None and len(kept) == len(vpnrouter_users.passkeys_of(current)):
|
||||
raise vpnrouter_users.Failure(f'user {name} has no passkey {passkey_id}', 3)
|
||||
if len(kept) != len(values):
|
||||
connection.modify_s(vpnrouter_users.dn_of(name), [(ldap.MOD_REPLACE, 'vpnRouterPasskey', kept)])
|
||||
return vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
|
||||
|
||||
|
||||
def login_begin():
|
||||
"""Begin a sign-in with a passkey: {"id", "options"}, options those of navigator.credentials.get, for any user."""
|
||||
options, state = server().authenticate_begin(user_verification=UserVerificationRequirement.REQUIRED)
|
||||
return {'id': keep('login', None, state), 'options': dict(options)}
|
||||
|
||||
|
||||
def login_complete(connection, pending, response):
|
||||
"""The name of the user whose passkey signed a begun sign-in, when the user is enabled, not locked and not a
|
||||
service account."""
|
||||
refused = vpnrouter_users.Failure('the passkey is not accepted', 5)
|
||||
state = take('login', None, pending)
|
||||
if state is None or not isinstance(response, dict):
|
||||
raise refused
|
||||
try:
|
||||
name = websafe_decode(response['response']['userHandle']).decode()
|
||||
except (KeyError, TypeError, ValueError):
|
||||
raise refused
|
||||
if not vpnrouter_users.NAME.match(name):
|
||||
raise refused
|
||||
try:
|
||||
current = vpnrouter_users.entry(connection, name)
|
||||
except vpnrouter_users.Failure:
|
||||
raise refused
|
||||
user = vpnrouter_users.summary(current)
|
||||
if user['service'] or not user['enabled'] or user['locked']:
|
||||
raise refused
|
||||
try:
|
||||
server().authenticate_complete(state, credentials(current), response)
|
||||
except (ValueError, KeyError, TypeError):
|
||||
raise refused
|
||||
return name
|
||||
File diff suppressed because it is too large.
Load diff
@@ -4,8 +4,8 @@ No platform-specific configuration is currently required.
|
||||
|
||||
The Azure platform resolver 168.63.129.16 is deliberately *not* advertised to
|
||||
road-warrior clients and *not* added to the traffic selectors. Clients are
|
||||
given the router itself as their DNS server, and the router forwards through
|
||||
its own systemd-resolved via the p2s-forwarder drop-in. That resolves
|
||||
given the router's addresses of p2s.dns_listen as their DNS servers, where dnsmasq
|
||||
forwards to the router's own systemd-resolved. That resolves
|
||||
Azure-internal names without routing the wireserver address through the tunnel
|
||||
and without a second resolver in the pool.
|
||||
"""
|
||||
@@ -0,0 +1,439 @@
|
||||
"""The static web sites Caddy serves, for the web service, vpn-router-site, vpn-router-setup and webdav-auth.
|
||||
|
||||
A site is a row of the sites table in the vpn-router-sites database, which the web service creates on its first run
|
||||
(provision) with its role and the password in DATABASE_SECRET; SITES_SQL defines the table and its procedures, and the
|
||||
first connection of a process loads it. A site has a name, which also names its folder SITES_DIR/<name>, the host names
|
||||
Caddy answers on its port (hosts, the names TLS clients send as SNI; none answers any name), the interfaces whose
|
||||
firewall opens the port (listen), and TLS: none (plain HTTP), acme (a certificate of Let's Encrypt), ca (certificates
|
||||
Caddy issues with one of pki.issuing_cas) or file (a certificate and key given with the site). Caddy serves the files of
|
||||
the folder, index_file for a folder, and with spa index_file of the site for any path without a file. editors names
|
||||
the group whose members change the site's files besides the admins, through the web service and WebDAV.
|
||||
|
||||
The sites are served while sites.enabled is true; vpn-router-setup writes Caddy's configuration and opens the ports. A
|
||||
site is changed only through this module, which checks it (check), and its callers then restart vpn-router-setup.
|
||||
|
||||
The files of a site are read and written by path relative to its folder; resolve() refuses a path that leaves it.
|
||||
receive() writes a file from a stream, extract() a zip archive into a folder; both stop at sites.upload_max_mb.
|
||||
|
||||
Every failure of the database raises DatabaseError; a request that cannot be done raises Failure, whose status says
|
||||
why: 2 invalid, 3 no such site, 4 the site exists, 1 anything else.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import datetime
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_auth
|
||||
import vpnrouter_pki
|
||||
import vpnrouter_users
|
||||
|
||||
DATABASE = 'vpn-router-sites'
|
||||
DATABASE_SECRET = pathlib.Path('/etc/vpn-router/sites-database.secret')
|
||||
SITES_SQL = pathlib.Path('/usr/share/vpn-router/sql/sites.sql')
|
||||
|
||||
#: The folders of the sites, SITES_DIR/<name>.
|
||||
SITES_DIR = pathlib.Path('/var/lib/vpn-router/sites')
|
||||
|
||||
#: The name of a site, which also names its folder.
|
||||
NAME = re.compile(r'^[a-z][a-z0-9-]{0,30}$')
|
||||
|
||||
#: A file name of index_file.
|
||||
INDEX = re.compile(r'^[A-Za-z0-9_-][A-Za-z0-9._-]{0,63}$')
|
||||
|
||||
LISTEN = vpnrouter.LISTEN_CHOICES
|
||||
TLS = ('none', 'acme', 'ca', 'file')
|
||||
|
||||
#: The most host names of one site.
|
||||
HOSTS_MAX = 16
|
||||
|
||||
#: The most members a zip archive extract() takes.
|
||||
ARCHIVE_MEMBERS_MAX = 10000
|
||||
|
||||
#: The bytes copied at once from a stream.
|
||||
CHUNK = 65536
|
||||
|
||||
#: The settings of a site and their defaults; the type of a default is the required type.
|
||||
SETTINGS = {'enabled': True, 'hosts': [], 'port': 443, 'listen': 'external', 'tls': 'acme', 'ca': '', 'certificate': '',
|
||||
'private_key': '', 'index_file': 'index.html', 'spa': False, 'editors': ''}
|
||||
|
||||
#: Whether this process has loaded SITES_SQL.
|
||||
_loaded = False
|
||||
|
||||
|
||||
class DatabaseError(Exception):
|
||||
"""The database of the sites failed."""
|
||||
|
||||
|
||||
class Failure(Exception):
|
||||
"""A request that cannot be done; status is 2 invalid, 3 no such site, 4 the site exists, 1 other."""
|
||||
|
||||
def __init__(self, message, status=1):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
||||
|
||||
def provision():
|
||||
"""Create the role, its password file and the database of the sites when they are missing."""
|
||||
vpnrouter_auth.provision_database(DATABASE, DATABASE_SECRET)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def database():
|
||||
"""A connection to the database of the sites, with its table and procedures, closed at the end; DatabaseError on any
|
||||
failure, also before the web service has created the database."""
|
||||
global _loaded
|
||||
try:
|
||||
schema = None if _loaded else SITES_SQL.read_text(encoding='utf-8')
|
||||
target = vpnrouter_auth.local_connection(DATABASE, DATABASE_SECRET)
|
||||
except OSError as exc:
|
||||
raise DatabaseError(f'the database of the sites is not ready: {exc}') from exc
|
||||
try:
|
||||
with psycopg.connect(target, autocommit=True, row_factory=dict_row) as connection:
|
||||
if schema is not None:
|
||||
connection.execute(schema)
|
||||
_loaded = True
|
||||
yield connection
|
||||
except psycopg.Error as exc:
|
||||
raise DatabaseError(f'the database of the sites failed: {exc}') from exc
|
||||
|
||||
|
||||
def shown(row):
|
||||
"""A site as the answers show it: its settings without the private key, with private_key_set and its folder."""
|
||||
site = {key: value for key, value in row.items() if key != 'private_key'}
|
||||
site['private_key_set'] = bool(row.get('private_key'))
|
||||
site['modified'] = row['modified'].isoformat(timespec='seconds')
|
||||
site['directory'] = str(SITES_DIR / row['name'])
|
||||
return site
|
||||
|
||||
|
||||
def listing():
|
||||
"""Every site, as shown()."""
|
||||
with database() as connection:
|
||||
return [shown(row) for row in connection.execute('SELECT * FROM sites_list()').fetchall()]
|
||||
|
||||
|
||||
def served():
|
||||
"""The enabled sites with their private keys."""
|
||||
with database() as connection:
|
||||
return connection.execute('SELECT * FROM sites_served()').fetchall()
|
||||
|
||||
|
||||
def get(connection, name):
|
||||
"""The site with its private key; Failure with status 3 when there is none."""
|
||||
if not isinstance(name, str) or not NAME.match(name):
|
||||
raise Failure(f'no site {name}', 3)
|
||||
row = connection.execute('SELECT * FROM site_get(%s)', (name,)).fetchone()
|
||||
if row is None:
|
||||
raise Failure(f'no site {name}', 3)
|
||||
return row
|
||||
|
||||
|
||||
def editable(groups):
|
||||
"""The names of the sites whose files the members of the groups may change."""
|
||||
with database() as connection:
|
||||
return [row['name'] for row in connection.execute('SELECT * FROM sites_editable(%s)', (list(groups),))]
|
||||
|
||||
|
||||
def sites_of_group(group):
|
||||
"""The names of the sites whose editors are the group; [] before the web service has created the database."""
|
||||
if not DATABASE_SECRET.is_file():
|
||||
return []
|
||||
with database() as connection:
|
||||
return [row['name'] for row in connection.execute('SELECT * FROM sites_of_group(%s)', (group,))]
|
||||
|
||||
|
||||
def rename_group(old, new):
|
||||
"""Give the sites whose editors are the group old its new name."""
|
||||
if DATABASE_SECRET.is_file():
|
||||
with database() as connection:
|
||||
connection.execute('CALL sites_rename_group(%s, %s)', (old, new))
|
||||
|
||||
|
||||
def taken_ports(cfg):
|
||||
"""The TCP ports of the router's own services, port -> what has it, which no site may take."""
|
||||
taken = {22: 'SSH', cfg['web']['port']: 'web.port', cfg['https']['web_port']: 'https.web_port',
|
||||
cfg['https']['manager_port']: 'https.manager_port', cfg['metrics']['port']: 'metrics.port',
|
||||
cfg['metrics']['https_port']: 'metrics.https_port', vpnrouter.manager_port(): 'the Router Manager',
|
||||
cfg['sites']['webdav_port']: 'sites.webdav_port', vpnrouter.WEBDAV_LOOPBACK_PORT: 'rclone\'s WebDAV',
|
||||
53: 'DNS'}
|
||||
for port in vpnrouter.BOOTSTRAP_PORTS:
|
||||
taken[port] = 'the bootstrap services'
|
||||
if cfg['openvpn']['protocol'] == 'tcp':
|
||||
taken[cfg['openvpn']['port']] = 'openvpn.port'
|
||||
for item in cfg['docker']['exposed_ports']:
|
||||
first, _, last = item.strip().partition(':')
|
||||
if first.isdigit() and (not last or last.isdigit()):
|
||||
for port in range(int(first), int(last or first) + 1):
|
||||
taken[port] = 'docker.exposed_ports'
|
||||
return taken
|
||||
|
||||
|
||||
def check_certificate(certificate, private_key, hosts):
|
||||
"""Refuse a PEM certificate chain whose first certificate is not the key's or names none of the hosts."""
|
||||
try:
|
||||
chain = x509.load_pem_x509_certificates(certificate.encode())
|
||||
except ValueError:
|
||||
raise Failure('certificate must be PEM certificates, the site\'s first', 2)
|
||||
try:
|
||||
key = serialization.load_pem_private_key(private_key.encode(), password=None)
|
||||
except (ValueError, TypeError):
|
||||
raise Failure('private_key must be an unencrypted PEM private key', 2)
|
||||
if not vpnrouter_pki.same_key(chain[0].public_key(), key.public_key()):
|
||||
raise Failure('private_key is not the key of the certificate', 2)
|
||||
names = {value.partition(':')[2] for value in vpnrouter_pki.san_values(chain[0]) if not value.startswith('email:')}
|
||||
missing = [host for host in hosts if host not in names and f'*.{host.partition(".")[2]}' not in names]
|
||||
if missing:
|
||||
raise Failure(f'the certificate does not name {", ".join(missing)}', 2)
|
||||
|
||||
|
||||
def check(cfg, sites, site):
|
||||
"""Refuse a site, a dict of SETTINGS with its name, that is invalid or collides with the other sites (sites, rows of
|
||||
the table) or with the router's own ports."""
|
||||
name = site['name']
|
||||
if not isinstance(name, str) or not NAME.match(name):
|
||||
raise Failure('a site name starts with a lowercase letter and has at most 31 lowercase letters, digits and '
|
||||
'hyphens', 2)
|
||||
for option, default in SETTINGS.items():
|
||||
value = site[option]
|
||||
if type(value) is not type(default) or (isinstance(value, list) and not all(isinstance(item, str) for item in value)):
|
||||
raise Failure(f'{option} must be {vpnrouter.TYPE_NAMES[type(default)]}', 2)
|
||||
hosts = site['hosts']
|
||||
if len(hosts) > HOSTS_MAX or len(set(hosts)) != len(hosts):
|
||||
raise Failure(f'hosts must be at most {HOSTS_MAX} different host names', 2)
|
||||
for host in hosts:
|
||||
if not (vpnrouter_pki.DNS_NAME.match(host) or vpnrouter_pki.is_address(host)):
|
||||
raise Failure(f'hosts: {host!r} is not a host name in lowercase or an IP address', 2)
|
||||
if host == cfg['wan']['local_fqdn'].strip():
|
||||
raise Failure(f'hosts: {host} is wan.local_fqdn, the name of the router\'s own services', 2)
|
||||
port = site['port']
|
||||
if not 1 <= port <= 65535:
|
||||
raise Failure(f'port: {port} is not a port', 2)
|
||||
taken = taken_ports(cfg)
|
||||
if port in taken:
|
||||
raise Failure(f'port: {port} is the port of {taken[port]}', 2)
|
||||
if site['listen'] not in LISTEN:
|
||||
raise Failure(f'listen: {site["listen"]!r} is not one of {", ".join(LISTEN)}', 2)
|
||||
tls = site['tls']
|
||||
if tls not in TLS:
|
||||
raise Failure(f'tls: {tls!r} is not one of {", ".join(TLS)}', 2)
|
||||
if tls != 'none' and not hosts:
|
||||
raise Failure(f'tls {tls} needs hosts, the names of the certificate', 2)
|
||||
if tls == 'acme' and any(host.startswith('*.') or vpnrouter_pki.is_address(host) for host in hosts):
|
||||
raise Failure('tls acme takes host names only, without wildcards', 2)
|
||||
if tls == 'ca':
|
||||
authorities = {key: value for key, value in vpnrouter.issuing_cas(cfg).items() if not value['builtin']}
|
||||
if site['ca'] not in authorities:
|
||||
raise Failure(f'ca: {site["ca"]!r} is not one of pki.issuing_cas', 2)
|
||||
if tls == 'file':
|
||||
check_certificate(site['certificate'], site['private_key'], hosts)
|
||||
if not INDEX.match(site['index_file']):
|
||||
raise Failure('index_file must be a file name', 2)
|
||||
if site['editors']:
|
||||
with vpnrouter_users.session() as connection:
|
||||
try:
|
||||
vpnrouter_users.group_entry(connection, site['editors'])
|
||||
except vpnrouter_users.Failure:
|
||||
raise Failure(f'editors: group {site["editors"]} does not exist', 2)
|
||||
if not site['enabled']:
|
||||
return
|
||||
for other in sites:
|
||||
if other['name'] == name or not other['enabled'] or other['port'] != port:
|
||||
continue
|
||||
prefix = f'port {port} is also the port of site {other["name"]}'
|
||||
if (other['tls'] == 'none') != (tls == 'none'):
|
||||
raise Failure(f'{prefix}, which is {"plain HTTP" if other["tls"] == "none" else "HTTPS"}', 2)
|
||||
if other['listen'] != site['listen']:
|
||||
raise Failure(f'{prefix}, which listens on {other["listen"]}', 2)
|
||||
if not hosts or not other['hosts']:
|
||||
raise Failure(f'{prefix}; sites sharing a port need host names', 2)
|
||||
common = sorted(set(hosts) & set(other['hosts']))
|
||||
if common:
|
||||
raise Failure(f'{prefix}, which has {", ".join(common)} too', 2)
|
||||
|
||||
|
||||
def save(cfg, name, values, create):
|
||||
"""Add the site with values, a dict of some of SETTINGS (create), or change those of an existing one; the site as
|
||||
shown(). An absent or empty private_key keeps the current one."""
|
||||
unknown = sorted(set(values) - set(SETTINGS))
|
||||
if unknown:
|
||||
raise Failure(f'unknown settings: {", ".join(unknown)}', 2)
|
||||
with database() as connection:
|
||||
rows = connection.execute('SELECT * FROM sites_list()').fetchall()
|
||||
current = next((row for row in rows if row['name'] == name), None)
|
||||
if create and current is not None:
|
||||
raise Failure(f'site {name} exists', 4)
|
||||
if not create:
|
||||
current = get(connection, name)
|
||||
site = {**SETTINGS, **({key: current[key] for key in SETTINGS} if current else {}), 'name': name}
|
||||
site.update({key: value for key, value in values.items() if key != 'private_key' or value})
|
||||
if isinstance(site['hosts'], list):
|
||||
site['hosts'] = [host.strip().lower() for host in site['hosts'] if isinstance(host, str) and host.strip()]
|
||||
if site['tls'] != 'file':
|
||||
site['certificate'] = site['private_key'] = ''
|
||||
if site['tls'] != 'ca':
|
||||
site['ca'] = ''
|
||||
check(cfg, rows, site)
|
||||
connection.execute('CALL site_put(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)',
|
||||
tuple(site[key] for key in ('name', *SETTINGS)))
|
||||
ensure_folders([name])
|
||||
return shown(get(connection, name))
|
||||
|
||||
|
||||
def remove(name, content):
|
||||
"""Remove the site, and with content its folder."""
|
||||
with database() as connection:
|
||||
get(connection, name)
|
||||
connection.execute('CALL site_remove(%s)', (name,))
|
||||
if content:
|
||||
shutil.rmtree(SITES_DIR / name, ignore_errors=True)
|
||||
return {'name': name, 'removed': True}
|
||||
|
||||
|
||||
def ensure_folders(names):
|
||||
"""Create the folders of the sites that miss one, readable by all."""
|
||||
SITES_DIR.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
for name in names:
|
||||
(SITES_DIR / name).mkdir(mode=0o755, exist_ok=True)
|
||||
|
||||
|
||||
def resolve(name, relative):
|
||||
"""The path of the site's file or folder relative to its folder; Failure with status 2 for a path that leaves it,
|
||||
status 3 when the site has no folder."""
|
||||
if not isinstance(name, str) or not NAME.match(name):
|
||||
raise Failure(f'no site {name}', 3)
|
||||
base = SITES_DIR / name
|
||||
if not base.is_dir():
|
||||
raise Failure(f'site {name} has no folder', 3)
|
||||
if '\0' in relative or any(part in ('.', '..') for part in relative.split('/')):
|
||||
raise Failure(f'{relative!r} is not a path in the site\'s folder', 2)
|
||||
target = pathlib.Path(os.path.realpath(base / relative.strip('/')))
|
||||
if target != base and base not in target.parents:
|
||||
raise Failure(f'{relative!r} is not a path in the site\'s folder', 2)
|
||||
return target
|
||||
|
||||
|
||||
def relative_of(name, path):
|
||||
return str(path.relative_to(SITES_DIR / name))
|
||||
|
||||
|
||||
def entries(name, folder):
|
||||
"""The entries of a folder of the site: [{name, folder, size, modified}], folders first, then by name."""
|
||||
found = []
|
||||
for item in os.scandir(folder):
|
||||
try:
|
||||
info = item.stat(follow_symlinks=False)
|
||||
except OSError:
|
||||
continue
|
||||
found.append({'name': item.name, 'folder': stat.S_ISDIR(info.st_mode),
|
||||
'size': info.st_size if stat.S_ISREG(info.st_mode) else 0,
|
||||
'modified': datetime.datetime.fromtimestamp(info.st_mtime, datetime.timezone.utc)
|
||||
.isoformat(timespec='seconds')})
|
||||
return {'path': relative_of(name, folder) if folder != SITES_DIR / name else '',
|
||||
'entries': sorted(found, key=lambda entry: (not entry['folder'], entry['name']))}
|
||||
|
||||
|
||||
def receive(stream, length, target, limit):
|
||||
"""Write length bytes of the stream to the file target, replacing it in one step; Failure with status 2 over limit
|
||||
bytes. Its folders are created."""
|
||||
if length > limit:
|
||||
raise Failure(f'the file has {length} bytes, more than sites.upload_max_mb allows', 2)
|
||||
if target.is_dir():
|
||||
raise Failure(f'{target.name} is a folder', 2)
|
||||
target.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
descriptor, name = tempfile.mkstemp(dir=target.parent, prefix=f'.{target.name}.')
|
||||
try:
|
||||
with os.fdopen(descriptor, 'wb') as handle:
|
||||
left = length
|
||||
while left:
|
||||
data = stream.read(min(CHUNK, left))
|
||||
if not data:
|
||||
raise Failure('the client sent less than its Content-Length', 2)
|
||||
handle.write(data)
|
||||
left -= len(data)
|
||||
os.chmod(name, 0o644)
|
||||
os.replace(name, target)
|
||||
except BaseException:
|
||||
os.unlink(name)
|
||||
raise
|
||||
|
||||
|
||||
def extract(stream, length, folder, limit):
|
||||
"""Extract the zip archive of length bytes of the stream into the folder, created when missing; the number of files.
|
||||
|
||||
A member with an absolute path, a .. or a link, more than ARCHIVE_MEMBERS_MAX members or more than limit bytes
|
||||
unpacked refuse the whole archive, before anything is written.
|
||||
"""
|
||||
if length > limit:
|
||||
raise Failure(f'the archive has {length} bytes, more than sites.upload_max_mb allows', 2)
|
||||
folder.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryFile(dir=folder) as spool:
|
||||
left = length
|
||||
while left:
|
||||
data = stream.read(min(CHUNK, left))
|
||||
if not data:
|
||||
raise Failure('the client sent less than its Content-Length', 2)
|
||||
spool.write(data)
|
||||
left -= len(data)
|
||||
spool.seek(0)
|
||||
try:
|
||||
archive = zipfile.ZipFile(spool)
|
||||
except zipfile.BadZipFile:
|
||||
raise Failure('the file is not a zip archive', 2)
|
||||
with archive:
|
||||
members = archive.infolist()
|
||||
if len(members) > ARCHIVE_MEMBERS_MAX:
|
||||
raise Failure(f'the archive has more than {ARCHIVE_MEMBERS_MAX} members', 2)
|
||||
if sum(member.file_size for member in members) > limit:
|
||||
raise Failure('the archive unpacks to more than sites.upload_max_mb allows', 2)
|
||||
for member in members:
|
||||
parts = member.filename.replace('\\', '/').split('/')
|
||||
if member.filename.startswith('/') or '..' in parts or '\0' in member.filename or \
|
||||
stat.S_ISLNK(member.external_attr >> 16):
|
||||
raise Failure(f'the archive member {member.filename!r} is a link or leaves the folder', 2)
|
||||
count = 0
|
||||
for member in members:
|
||||
target = folder / member.filename.replace('\\', '/').strip('/')
|
||||
if member.is_dir():
|
||||
target.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
continue
|
||||
target.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
with archive.open(member) as source:
|
||||
receive(source, member.file_size, target, limit)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def make_folder(target):
|
||||
target.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def rename(name, source, relative):
|
||||
"""Move the file or folder source of the site to the path relative to its folder; Failure when that exists."""
|
||||
target = resolve(name, relative)
|
||||
if target.exists():
|
||||
raise Failure(f'{relative} exists', 4)
|
||||
target.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
os.replace(source, target)
|
||||
return target
|
||||
|
||||
|
||||
def delete(name, target):
|
||||
"""Remove the file or folder of the site, but not its folder."""
|
||||
if target == SITES_DIR / name:
|
||||
raise Failure('the site\'s folder itself cannot be removed', 2)
|
||||
if target.is_dir() and not target.is_symlink():
|
||||
shutil.rmtree(target)
|
||||
else:
|
||||
target.unlink()
|
||||
@@ -12,6 +12,9 @@ COMMAND_TIMEOUT = 5
|
||||
|
||||
OPENVPN_STATUS = '/run/openvpn-server/status-vpn-router.log'
|
||||
|
||||
#: Seconds between two writes of OPENVPN_STATUS by the OpenVPN server, whose configuration sets it (OpenVPN's default is 60).
|
||||
OPENVPN_STATUS_SECONDS = 5
|
||||
|
||||
#: Seconds since its last handshake within which a WireGuard peer counts as connected.
|
||||
WIREGUARD_CONNECTED_SECONDS = 180
|
||||
|
||||
|
||||
@@ -2,21 +2,34 @@
|
||||
|
||||
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,
|
||||
password OpenVPN checks, and a client certificate of the P2S issuing CA (vpnrouter_pki) with the
|
||||
user name as its common name, which OpenVPN and IKEv2 accept; the certificate is the entry's
|
||||
userCertificate, and the PKI keeps it with its key. 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. A service
|
||||
account is a Manager-only account with the scope SERVICE_SCOPE in vpnRouterScope, which cannot
|
||||
sign in to the web service; an administrator adds one for a service that checks passwords
|
||||
against the directory. The internal one is the account metrics.service_account names while
|
||||
metrics are enabled, which vpn-router-setup adds and whose password it keeps (metrics_account).
|
||||
account is a Manager-only account with the scope SERVICE_SCOPE in vpnRouterScope, which another
|
||||
system signs in with to use a router service, and which cannot sign in to the web service; it has
|
||||
a display name and a description (labels), in displayName and description of vpnRouterAccount. The
|
||||
internal service accounts are those the router creates and manages for its own services, labels
|
||||
included (internal_accounts); vpn-router-setup keeps the metrics service's.
|
||||
Users exist whether or not a P2S system is enabled.
|
||||
|
||||
A service account with redirect URIs is a client of the OpenID Provider (vpnrouter_oidc), its name the client ID and
|
||||
its password the client secret (client_settings): redirect_uris, where the provider sends a user back after a sign-in,
|
||||
post_logout_redirect_uris, after a sign-out, and allowed_groups, the groups whose members may sign in to it, every
|
||||
user who is not a service account when there are none.
|
||||
|
||||
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.
|
||||
cannot leave it and a user who is the last member of a group cannot be removed. ADMIN_GROUP grants the management of
|
||||
the router, the allowed groups of the OpenID Provider's clients who may sign in to them, and the editors group of a web
|
||||
site who may change its files (vpnrouter_sites); renaming a group renames it in those, and a group a client allows or a
|
||||
site has cannot be removed.
|
||||
|
||||
A VPN user may have WireGuard peers, each a device with its public key and its IPs, in
|
||||
vpnRouterWireGuardPeer; vpn-router-setup writes a [Peer] of wg0.conf for each peer of a user who
|
||||
is not disabled.
|
||||
|
||||
A user may have a TOTP token: the user entry itself is an oathTOTPUser and oathTOTPToken whose
|
||||
oathTOTPToken points to itself, with the secret in oathSecret and the parameters of
|
||||
@@ -24,14 +37,23 @@ vpnrouter_ldap.TOTP_PARAMS. Enrolment stores nothing until a code of the new sec
|
||||
verified: otp_new makes a secret and its otpauth URI, and otp_enrol stores the secret given a
|
||||
code of it. slapd's otp overlay then takes the password followed by a code at every bind.
|
||||
|
||||
A user who is not a service account may have passkeys for signing in to the Router Manager, in
|
||||
vpnRouterPasskey (vpnrouter_passkeys registers and checks them).
|
||||
|
||||
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. The module knows only the directory; the callers revoke a
|
||||
router through the web service.
|
||||
|
||||
A member of ADMIN_GROUP with a TOTP token may have console login (console): the Shell page of the
|
||||
Router Manager, and SSH and sudo with the directory's password followed by the code; without the
|
||||
token the console is refused, also for one whose console login was switched on before. The
|
||||
console runs as a local account, or as the user's own posixAccount, which the user entry becomes
|
||||
and nslcd serves to the system. Leaving ADMIN_GROUP switches it off. 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
|
||||
Every operation runs in a session(), which holds the lock that serializes changes, the PKI's
|
||||
lock too, 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, 5 a
|
||||
login failed, 1 anything else. Answers are dicts with the keys of the JSON answers.
|
||||
"""
|
||||
@@ -40,11 +62,14 @@ import base64
|
||||
import calendar
|
||||
import contextlib
|
||||
import fcntl
|
||||
import pathlib
|
||||
import grp
|
||||
import ipaddress
|
||||
import json
|
||||
import pwd
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
import ldap
|
||||
import ldap.filter
|
||||
@@ -53,9 +78,12 @@ import pyotp
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_ldap
|
||||
import vpnrouter_pki
|
||||
|
||||
#: 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]$')
|
||||
#: The other name of a service account: a GUID in lowercase hexadecimal, as the Router Manager proposes one.
|
||||
GUID = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')
|
||||
PASSWORD_MIN = 8
|
||||
PASSWORD_MAX = 128
|
||||
#: The longest first or last name, in characters.
|
||||
@@ -65,8 +93,22 @@ 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 keys of the labels of a service account and their LDAP attributes.
|
||||
LABELS = (('display_name', 'displayName'), ('description', 'description'))
|
||||
#: The attributes read for a user.
|
||||
ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken', 'vpnRouterScope']
|
||||
ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken', 'vpnRouterScope',
|
||||
'displayName', 'description', 'userCertificate;binary', 'vpnRouterConsoleLogin', 'vpnRouterConsoleAccount',
|
||||
'uidNumber', 'vpnRouterWireGuardPeer', 'vpnRouterPasskey', 'vpnRouterRedirectUri',
|
||||
'vpnRouterPostLogoutRedirectUri', 'vpnRouterAllowedGroup']
|
||||
#: The keys of the URIs of an OpenID Provider client and their LDAP attributes.
|
||||
CLIENT_URIS = (('redirect_uris', 'vpnRouterRedirectUri'), ('post_logout_redirect_uris', 'vpnRouterPostLogoutRedirectUri'))
|
||||
#: The most URIs of each kind and allowed groups of one client, and the longest URI.
|
||||
CLIENT_VALUES_MAX = 16
|
||||
URI_MAX = 1024
|
||||
#: The hosts a client's URI may name over plain HTTP: those of the system it runs on.
|
||||
LOOPBACK_HOSTS = ('localhost', '127.0.0.1', '::1')
|
||||
#: The most WireGuard peers of one user.
|
||||
WIREGUARD_PEERS_MAX = 16
|
||||
#: The vpnRouterScope of a service account.
|
||||
SERVICE_SCOPE = 'service'
|
||||
#: The object classes of a VPN user and of a Manager-only account.
|
||||
@@ -82,12 +124,12 @@ GROUP_ATTRIBUTES = ['cn', 'description', 'member']
|
||||
#: The longest group description, in characters.
|
||||
DESCRIPTION_MAX = 256
|
||||
#: The value of pwdAccountLockedTime that disables an account until it is enabled.
|
||||
LOCKED = b'000001010000Z'
|
||||
LOCKED = vpnrouter_ldap.DISABLED.encode()
|
||||
#: The group whose members manage the router through the web service.
|
||||
ADMIN_GROUP = 'admins'
|
||||
#: The digits of a TOTP code given to login().
|
||||
CODE = re.compile(r'^\d{6}$')
|
||||
LOCK_FILE = pathlib.Path('/run/vpn-router/users.lock')
|
||||
LOCK_FILE = vpnrouter_pki.LOCK_FILE
|
||||
|
||||
|
||||
class Failure(Exception):
|
||||
@@ -111,9 +153,11 @@ def session():
|
||||
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_name(name, service=True):
|
||||
"""Refuse a name that is not a user name (NAME), or, with service, the GUID of a service account either."""
|
||||
if not isinstance(name, str) or not (NAME.match(name) or service and GUID.match(name)):
|
||||
raise Failure('a user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter'
|
||||
+ (', or, for a service account, a GUID in lowercase' if service else ''), 2)
|
||||
|
||||
|
||||
def check_password(value):
|
||||
@@ -147,13 +191,71 @@ def person(values, required):
|
||||
return found
|
||||
|
||||
|
||||
def labels(values):
|
||||
"""The labels of a service account in values ({display_name, description}), checked, as {LDAP attribute: value}.
|
||||
|
||||
A key whose value is None is not given; '' means none. A display name has at most PERSON_NAME_MAX printable
|
||||
characters and a description at most DESCRIPTION_MAX.
|
||||
"""
|
||||
found = {}
|
||||
name = values.get('display_name')
|
||||
if name is not None:
|
||||
if not isinstance(name, str):
|
||||
raise Failure('display_name must be a string', 2)
|
||||
name = name.strip()
|
||||
if len(name) > PERSON_NAME_MAX or any(not character.isprintable() for character in name):
|
||||
raise Failure(f'the display name must have at most {PERSON_NAME_MAX} printable characters', 2)
|
||||
found['displayName'] = name
|
||||
if values.get('description') is not None:
|
||||
found['description'] = check_description(values['description'])
|
||||
return found
|
||||
|
||||
|
||||
def client_settings(connection, values):
|
||||
"""The OpenID Provider client settings of a service account in values ({redirect_uris, post_logout_redirect_uris,
|
||||
allowed_groups}), checked, as {LDAP attribute: [value, ...]}; a key whose value is None is not given, [] means none.
|
||||
|
||||
A URI is absolute, has no fragment and uses https, or http for a host of LOOPBACK_HOSTS; an allowed group exists. Each
|
||||
list has at most CLIENT_VALUES_MAX values.
|
||||
"""
|
||||
found = {}
|
||||
for key, attribute in CLIENT_URIS:
|
||||
uris = values.get(key)
|
||||
if uris is None:
|
||||
continue
|
||||
if not isinstance(uris, list) or len(uris) > CLIENT_VALUES_MAX or not all(isinstance(uri, str) for uri in uris):
|
||||
raise Failure(f'{key} must be a list of at most {CLIENT_VALUES_MAX} URIs', 2)
|
||||
for uri in uris:
|
||||
try:
|
||||
parts = urllib.parse.urlsplit(uri)
|
||||
host = parts.hostname
|
||||
except ValueError:
|
||||
host = None
|
||||
if len(uri) > URI_MAX or not host or parts.fragment or any(character.isspace() for character in uri) or \
|
||||
not (parts.scheme == 'https' or parts.scheme == 'http' and host in LOOPBACK_HOSTS):
|
||||
raise Failure(f'{uri!r} is not an https URI, or an http URI of localhost, without a fragment and of at '
|
||||
f'most {URI_MAX} characters', 2)
|
||||
found[attribute] = [uri.encode() for uri in dict.fromkeys(uris)]
|
||||
allowed = values.get('allowed_groups')
|
||||
if allowed is not None:
|
||||
if not isinstance(allowed, list) or len(allowed) > CLIENT_VALUES_MAX:
|
||||
raise Failure(f'allowed_groups must be a list of at most {CLIENT_VALUES_MAX} group names', 2)
|
||||
for group in allowed:
|
||||
group_entry(connection, group)
|
||||
found['vpnRouterAllowedGroup'] = [group_dn(group).encode() for group in sorted(set(allowed))]
|
||||
return found
|
||||
|
||||
|
||||
def refuse_client_settings(name, values):
|
||||
"""Refuse OpenID Provider client settings for a user who is not a service account."""
|
||||
if any(values.get(key) is not None for key in ('redirect_uris', 'post_logout_redirect_uris', 'allowed_groups')):
|
||||
raise Failure(f'{name} is not a service account; only a service account is a client of the OpenID Provider', 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."""
|
||||
@@ -164,18 +266,6 @@ def entry(connection, name):
|
||||
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 locked_since(value):
|
||||
"""The time a pwdAccountLockedTime value names, in seconds since the epoch, or 0 when it names none."""
|
||||
try:
|
||||
@@ -194,19 +284,27 @@ def service(attributes):
|
||||
return SERVICE_SCOPE.encode() in (value.lower() for value in attributes.get('vpnRouterScope', []))
|
||||
|
||||
|
||||
def metrics_account():
|
||||
"""The internal service account: the one metrics.service_account names while metrics are enabled, else None."""
|
||||
def internal_accounts():
|
||||
"""The internal service accounts, which the router creates and manages for its own services, by name, each with
|
||||
the labels the router gives it ({display_name, description}): the one metrics.service_account names while metrics
|
||||
are enabled."""
|
||||
try:
|
||||
metrics = vpnrouter.load_config()['metrics']
|
||||
except SystemExit:
|
||||
return None
|
||||
return metrics['service_account'] if metrics['enabled'] else None
|
||||
return {}
|
||||
if not metrics['enabled']:
|
||||
return {}
|
||||
return {metrics['service_account']: {
|
||||
'display_name': 'Metrics',
|
||||
'description': 'The account an OpenTelemetry Collector signs in with to read /metrics of the metrics service.',
|
||||
}}
|
||||
|
||||
|
||||
def summary(attributes):
|
||||
"""A user as the answers show it; a person attribute the entry does not have is None.
|
||||
"""A user as the answers show it; a person attribute or a label the entry does not have is None.
|
||||
|
||||
internal is whether it is the internal service account (metrics_account).
|
||||
internal is whether it is an internal service account (internal_accounts). A service account also has its OpenID
|
||||
Provider client settings (client_settings), allowed_groups as group names.
|
||||
"""
|
||||
name = attributes['uid'][0].decode()
|
||||
lock = attributes.get('pwdAccountLockedTime', [None])[0]
|
||||
@@ -214,18 +312,209 @@ def summary(attributes):
|
||||
'name': name,
|
||||
'manager_only': manager_only(attributes),
|
||||
'service': service(attributes),
|
||||
'internal': service(attributes) and name == metrics_account(),
|
||||
'internal': service(attributes) and name in internal_accounts(),
|
||||
'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(),
|
||||
'certificate': 'userCertificate;binary' in attributes,
|
||||
'otp': 'oathTOTPToken' in attributes,
|
||||
'console': console_of(attributes),
|
||||
'wireguard_peers': peers_of(attributes),
|
||||
'passkeys': [{key: passkey[key] for key in ('id', 'label', 'created')} for _, passkey in passkeys_of(attributes)],
|
||||
}
|
||||
for key, attribute in PERSON:
|
||||
for key, attribute in PERSON + LABELS:
|
||||
values = attributes.get(attribute)
|
||||
user[key] = values[0].decode() if values else None
|
||||
if user['service']:
|
||||
for key, attribute in CLIENT_URIS:
|
||||
user[key] = [value.decode() for value in attributes.get(attribute, [])]
|
||||
suffix = f',{vpnrouter_ldap.GROUPS}'
|
||||
user['allowed_groups'] = sorted(value.decode()[3:-len(suffix)] for value in attributes.get('vpnRouterAllowedGroup', [])
|
||||
if value.decode().startswith('cn=') and value.decode().endswith(suffix))
|
||||
return user
|
||||
|
||||
|
||||
def console_of(attributes):
|
||||
"""The console login of an entry as the answers show it: None when it was never set, else {enabled, account, local,
|
||||
uid}: account the system account the console runs as, the local one of vpnRouterConsoleAccount (local true) or the
|
||||
user's own posixAccount, and uid the uidNumber of that posixAccount, None without one."""
|
||||
if 'vpnRouterConsoleLogin' not in attributes:
|
||||
return None
|
||||
local = attributes.get('vpnRouterConsoleAccount')
|
||||
number = attributes.get('uidNumber')
|
||||
return {
|
||||
'enabled': attributes['vpnRouterConsoleLogin'] == [b'TRUE'],
|
||||
'account': local[0].decode() if local else attributes['uid'][0].decode(),
|
||||
'local': bool(local),
|
||||
'uid': int(number[0]) if number else None,
|
||||
}
|
||||
|
||||
|
||||
def peers_of(attributes):
|
||||
"""The WireGuard peers of an entry as the answers show them: [{public_key, ips}], in the order they are stored."""
|
||||
peers = []
|
||||
for value in attributes.get('vpnRouterWireGuardPeer', []):
|
||||
key, _, ips = value.decode().partition(' ')
|
||||
peers.append({'public_key': key, 'ips': ips.split(',')})
|
||||
return peers
|
||||
|
||||
|
||||
def passkeys_of(attributes):
|
||||
"""The passkeys of an entry (vpnrouter_passkeys) with their stored values: [(value, {id, credential, label,
|
||||
created})]; values that cannot be read are left out."""
|
||||
passkeys = []
|
||||
for value in attributes.get('vpnRouterPasskey', []):
|
||||
try:
|
||||
passkey = json.loads(value)
|
||||
except ValueError:
|
||||
continue
|
||||
if isinstance(passkey, dict) and all(isinstance(passkey.get(key), str) for key in ('id', 'credential', 'label', 'created')):
|
||||
passkeys.append((value, passkey))
|
||||
return passkeys
|
||||
|
||||
|
||||
def checked_peers(connection, name, peers):
|
||||
"""The values of vpnRouterWireGuardPeer for the user's WireGuard peers, [{public_key, ips}], when they are valid.
|
||||
|
||||
A public key is 32 bytes in base64, used by no other peer of any user; each peer has at least one IP, an IPv4
|
||||
address or network, which no other peer of any user covers and which does not cover the router's own address of
|
||||
wireguard.address. A user has at most WIREGUARD_PEERS_MAX peers.
|
||||
"""
|
||||
if not isinstance(peers, list) or len(peers) > WIREGUARD_PEERS_MAX:
|
||||
raise Failure(f'wireguard_peers must be a list of at most {WIREGUARD_PEERS_MAX} peers', 2)
|
||||
router = vpnrouter.load_config()['wireguard']['address'].strip()
|
||||
taken = [(other['name'], key, network)
|
||||
for other in list_users(connection)['users'] if other['name'] != name
|
||||
for peer in other['wireguard_peers'] for key in [peer['public_key']]
|
||||
for network in (ipaddress.ip_network(ip) for ip in peer['ips'])]
|
||||
values = []
|
||||
for peer in peers:
|
||||
key = peer.get('public_key') if isinstance(peer, dict) else None
|
||||
ips = peer.get('ips') if isinstance(peer, dict) else None
|
||||
try:
|
||||
if not isinstance(key, str) or len(base64.b64decode(key.strip(), validate=True)) != 32:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise Failure(f'{key!r} is not a WireGuard public key: 32 bytes in base64', 2)
|
||||
key = key.strip()
|
||||
if not isinstance(ips, list) or not ips or not all(isinstance(ip, str) for ip in ips):
|
||||
raise Failure(f'the peer {key} needs at least one IP', 2)
|
||||
networks = []
|
||||
for ip in ips:
|
||||
try:
|
||||
network = ipaddress.ip_network(ip.strip(), strict=True)
|
||||
except ValueError:
|
||||
raise Failure(f'{ip!r} is not an IPv4 address or network of the peer {key}', 2)
|
||||
if network.version != 4:
|
||||
raise Failure(f'{ip!r} of the peer {key} is not IPv4', 2)
|
||||
if router and ipaddress.ip_interface(router).ip in network:
|
||||
raise Failure(f'{network} of the peer {key} covers the router\'s own address {router}', 2)
|
||||
for owner, other_key, other in taken:
|
||||
if other_key == key:
|
||||
raise Failure(f'the key {key} is a peer of {owner}', 4)
|
||||
if network.overlaps(other):
|
||||
raise Failure(f'{network} of the peer {key} overlaps {other} of {owner}', 4)
|
||||
networks.append(network)
|
||||
taken += [(name, key, network) for network in networks]
|
||||
values.append(f'{key} {",".join(str(network) for network in networks)}'.encode())
|
||||
return values
|
||||
|
||||
|
||||
def classes(attributes):
|
||||
"""The object classes of an entry, in lower case."""
|
||||
return {value.decode().lower() for value in attributes.get('objectClass', [])}
|
||||
|
||||
|
||||
def free_uid(connection):
|
||||
"""The lowest uidNumber from vpnrouter_ldap.CONSOLE_UID_MIN above every posixAccount's that no local account has."""
|
||||
found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL, '(uidNumber=*)', ['uidNumber'])
|
||||
number = max([vpnrouter_ldap.CONSOLE_UID_MIN - 1] + [int(attributes['uidNumber'][0]) for _, attributes in found]) + 1
|
||||
while True:
|
||||
try:
|
||||
pwd.getpwuid(number)
|
||||
except KeyError:
|
||||
return number
|
||||
number += 1
|
||||
|
||||
|
||||
def console(connection, name, enabled, account=None):
|
||||
"""Switch the console login of a member of ADMIN_GROUP with a TOTP token on, as the local account given or, without
|
||||
one, as the user's own posixAccount, or off.
|
||||
|
||||
The posixAccount is added the first time and kept when the console login is switched off, so its uidNumber and the
|
||||
home it owns stay the user's. A local account must exist in the system's passwd with a uid from 1000 to below
|
||||
vpnrouter_ldap.CONSOLE_UID_MIN, and not be nobody; the user's own account needs a name no local account has.
|
||||
Switching off ends the sessions of the user's own account.
|
||||
"""
|
||||
current = entry(connection, name)
|
||||
if not isinstance(enabled, bool):
|
||||
raise Failure('enabled must be true or false', 2)
|
||||
if account is not None and (not isinstance(account, str) or not account):
|
||||
raise Failure('account must be the name of a local account', 2)
|
||||
changes = [] if 'vpnrouteraccount' in classes(current) else [(ldap.MOD_ADD, 'objectClass', [b'vpnRouterAccount'])]
|
||||
if not enabled:
|
||||
if 'vpnRouterConsoleLogin' not in current:
|
||||
return summary(current)
|
||||
connection.modify_s(dn_of(name), changes + [(ldap.MOD_REPLACE, 'vpnRouterConsoleLogin', [b'FALSE'])])
|
||||
end_sessions(current)
|
||||
return summary(entry(connection, name))
|
||||
if service(current):
|
||||
raise Failure(f'{name} is a service account, which has no console', 2)
|
||||
if ADMIN_GROUP not in groups_of(connection, name):
|
||||
raise Failure(f'user {name} is not a member of {ADMIN_GROUP}', 2)
|
||||
if 'oathTOTPToken' not in current:
|
||||
raise Failure(f'user {name} has no TOTP token; console login needs one', 2)
|
||||
if account is not None:
|
||||
try:
|
||||
local = pwd.getpwnam(account)
|
||||
except KeyError:
|
||||
raise Failure(f'there is no local account {account}', 2)
|
||||
if not 1000 <= local.pw_uid < vpnrouter_ldap.CONSOLE_UID_MIN or account == 'nobody':
|
||||
raise Failure(f'{account} is not a local user account with a uid from 1000 to {vpnrouter_ldap.CONSOLE_UID_MIN - 1}', 2)
|
||||
changes.append((ldap.MOD_REPLACE, 'vpnRouterConsoleAccount', [account.encode()]))
|
||||
else:
|
||||
changes.append((ldap.MOD_REPLACE, 'vpnRouterConsoleAccount', []))
|
||||
if 'posixaccount' not in classes(current):
|
||||
try:
|
||||
pwd.getpwnam(name)
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
raise Failure(f'a local account {name} exists; give it as the account, or use another user', 4)
|
||||
try:
|
||||
group = grp.getgrnam(vpnrouter_ldap.CONSOLE_GROUP).gr_gid
|
||||
except KeyError:
|
||||
raise Failure(f'the group {vpnrouter_ldap.CONSOLE_GROUP} does not exist yet; vpn-router-setup creates it')
|
||||
changes += [
|
||||
(ldap.MOD_ADD, 'objectClass', [b'posixAccount']),
|
||||
(ldap.MOD_REPLACE, 'uidNumber', [str(free_uid(connection)).encode()]),
|
||||
(ldap.MOD_REPLACE, 'gidNumber', [str(group).encode()]),
|
||||
(ldap.MOD_REPLACE, 'homeDirectory', [f'{vpnrouter_ldap.CONSOLE_HOMES}/{name}'.encode()]),
|
||||
(ldap.MOD_REPLACE, 'loginShell', [vpnrouter_ldap.CONSOLE_SHELL.encode()]),
|
||||
]
|
||||
if manager_only(current):
|
||||
changes.append((ldap.MOD_REPLACE, 'cn', [name.encode()]))
|
||||
changes.append((ldap.MOD_REPLACE, 'vpnRouterConsoleLogin', [b'TRUE']))
|
||||
connection.modify_s(dn_of(name), changes)
|
||||
return summary(entry(connection, name))
|
||||
|
||||
|
||||
def end_sessions(attributes):
|
||||
"""End the login sessions of the user's own posixAccount, when the entry has one; those of a local account stay."""
|
||||
if 'posixaccount' in classes(attributes) and not attributes.get('vpnRouterConsoleAccount'):
|
||||
subprocess.run(['loginctl', 'terminate-user', attributes['uid'][0].decode()], capture_output=True)
|
||||
|
||||
|
||||
def console_off(connection, names):
|
||||
"""Switch the console login off for each of the users that has it on; for those who leave ADMIN_GROUP."""
|
||||
for name in names:
|
||||
try:
|
||||
current = entry(connection, name)
|
||||
except Failure:
|
||||
continue
|
||||
if current.get('vpnRouterConsoleLogin') == [b'TRUE']:
|
||||
console(connection, name, False)
|
||||
|
||||
|
||||
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)
|
||||
@@ -241,17 +530,25 @@ def list_users(connection):
|
||||
def add(connection, name, password, values, service=False):
|
||||
"""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, and, with service, a service account, with at most the
|
||||
e-mail address too."""
|
||||
check_name(name)
|
||||
e-mail address, its labels (labels) and its OpenID Provider client settings (client_settings); only a service
|
||||
account may be named by a GUID."""
|
||||
check_name(name, service)
|
||||
details = person(values, required=False)
|
||||
vpn_user = 'givenName' in details or 'sn' in details
|
||||
if service and vpn_user:
|
||||
raise Failure('a service account has no first or last name', 2)
|
||||
client = {}
|
||||
if service:
|
||||
details.update({attribute: value for attribute, value in labels(values).items() if value})
|
||||
client = {attribute: found for attribute, found in client_settings(connection, values).items() if found}
|
||||
else:
|
||||
refuse_client_settings(name, values)
|
||||
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 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')
|
||||
if vpn_user and not vpnrouter_pki.ready('p2s'):
|
||||
raise Failure('the P2S issuing CA has no certificate yet; vpn-router-setup creates it once a platform is chosen, '
|
||||
'or, while the root CA is offline, once its certificate is imported')
|
||||
if vpn_user:
|
||||
record = {'objectClass': VPN_USER_CLASSES, 'uid': [name.encode()], 'cn': [common_name(details, name).encode()]}
|
||||
else:
|
||||
@@ -260,26 +557,34 @@ def add(connection, name, password, values, service=False):
|
||||
if service:
|
||||
record['vpnRouterScope'] = [SERVICE_SCOPE.encode()]
|
||||
record.update({attribute: [value.encode()] for attribute, value in details.items()})
|
||||
record.update(client)
|
||||
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)
|
||||
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)
|
||||
try:
|
||||
vpnrouter_pki.issue_user(vpnrouter.load_config(), name, details['mail'])
|
||||
except vpnrouter_pki.Failure as failure:
|
||||
raise Failure(f'the certificate cannot be issued: {failure}', failure.status)
|
||||
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 following the first and the last name, and of a VPN user the
|
||||
WireGuard peers, wireguard_peers, which replace the user's peers (checked_peers).
|
||||
|
||||
A Manager-only account has only the e-mail address, which '' removes.
|
||||
A Manager-only account has only the e-mail address, which '' removes; a service account also its labels
|
||||
(update_service).
|
||||
"""
|
||||
current = entry(connection, name)
|
||||
if service(current):
|
||||
return update_service(connection, name, values)
|
||||
refuse_client_settings(name, values)
|
||||
if manager_only(current):
|
||||
if values.get('wireguard_peers') is not None:
|
||||
raise Failure(f'user {name} is a Manager-only account, which has no VPN access and so no WireGuard peers', 2)
|
||||
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')
|
||||
@@ -292,12 +597,43 @@ def update(connection, name, values):
|
||||
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)
|
||||
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()]))
|
||||
peers = values.get('wireguard_peers')
|
||||
if not details and peers is None:
|
||||
raise Failure('give at least one of first_name, last_name, email and wireguard_peers', 2)
|
||||
changes = []
|
||||
if details:
|
||||
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()]))
|
||||
if peers is not None:
|
||||
if 'vpnrouteraccount' not in classes(current):
|
||||
changes.append((ldap.MOD_ADD, 'objectClass', [b'vpnRouterAccount']))
|
||||
changes.append((ldap.MOD_REPLACE, 'vpnRouterWireGuardPeer', checked_peers(connection, name, peers)))
|
||||
connection.modify_s(dn_of(name), changes)
|
||||
if peers is not None:
|
||||
apply_wireguard()
|
||||
return summary(entry(connection, name))
|
||||
|
||||
|
||||
def apply_wireguard():
|
||||
"""Have vpn-router-setup write the WireGuard peers of the users into wg0.conf, without waiting for it."""
|
||||
subprocess.run(['systemctl', 'restart', '--no-block', 'vpn-router-setup.service'], capture_output=True)
|
||||
|
||||
|
||||
def update_service(connection, name, values):
|
||||
"""Change any of the e-mail address, the labels (labels) and the OpenID Provider client settings (client_settings) of
|
||||
a service account that values gives; '' removes the e-mail address or a label, [] the values of a client setting."""
|
||||
if not service(entry(connection, name)):
|
||||
raise Failure(f'{name} is not a service account', 3)
|
||||
changes = [(ldap.MOD_REPLACE, attribute, [value.encode()] if value else []) for attribute, value in labels(values).items()]
|
||||
changes += [(ldap.MOD_REPLACE, attribute, found) for attribute, found in client_settings(connection, values).items()]
|
||||
email = values.get('email')
|
||||
if email is not None:
|
||||
address = '' if isinstance(email, str) and email.strip() == '' else person({'email': email}, required=False)['mail']
|
||||
changes.append((ldap.MOD_REPLACE, 'mail', [address.encode()] if address else []))
|
||||
if not changes:
|
||||
raise Failure('give email, display_name, description, redirect_uris, post_logout_redirect_uris or allowed_groups', 2)
|
||||
connection.modify_s(dn_of(name), changes)
|
||||
return summary(entry(connection, name))
|
||||
|
||||
@@ -310,14 +646,22 @@ def passwd(connection, name, password):
|
||||
|
||||
|
||||
def disable(connection, name):
|
||||
if entry(connection, name).get('pwdAccountLockedTime') != [LOCKED]:
|
||||
"""Lock the user out; the WireGuard peers of a disabled user are left out of wg0.conf."""
|
||||
current = entry(connection, name)
|
||||
if current.get('pwdAccountLockedTime') != [LOCKED]:
|
||||
connection.modify_s(dn_of(name), [(ldap.MOD_REPLACE, 'pwdAccountLockedTime', [LOCKED])])
|
||||
if 'vpnRouterWireGuardPeer' in current:
|
||||
apply_wireguard()
|
||||
return summary(entry(connection, name))
|
||||
|
||||
|
||||
def enable(connection, name):
|
||||
if entry(connection, name).get('pwdAccountLockedTime') == [LOCKED]:
|
||||
"""Let the user in again, with the WireGuard peers."""
|
||||
current = entry(connection, name)
|
||||
if current.get('pwdAccountLockedTime') == [LOCKED]:
|
||||
connection.modify_s(dn_of(name), [(ldap.MOD_DELETE, 'pwdAccountLockedTime', None)])
|
||||
if 'vpnRouterWireGuardPeer' in current:
|
||||
apply_wireguard()
|
||||
return summary(entry(connection, name))
|
||||
|
||||
|
||||
@@ -334,12 +678,13 @@ def unlock(connection, name):
|
||||
|
||||
|
||||
def login(name, password, code):
|
||||
"""Check the user's password, and the TOTP code of an enrolled user, with a bind as the user.
|
||||
"""Check the user's password, and the TOTP code of an enrolled user, with a bind as the user; a service account may be
|
||||
named by a GUID.
|
||||
|
||||
A wrong name, password or code, a locked or a disabled user give the same Failure, status 5.
|
||||
"""
|
||||
refused = Failure('the name, the password or the code is wrong', 5)
|
||||
if not isinstance(name, str) or not NAME.match(name) or not isinstance(password, str) or not password:
|
||||
if not isinstance(name, str) or not (NAME.match(name) or GUID.match(name)) or not isinstance(password, str) or not password:
|
||||
raise refused
|
||||
if code is not None and code != '' and (not isinstance(code, str) or not CODE.match(code)):
|
||||
raise refused
|
||||
@@ -394,48 +739,52 @@ def otp_enrol(connection, name, secret, code):
|
||||
|
||||
|
||||
def otp_remove(connection, name):
|
||||
"""Remove the user's TOTP token, when the user has one."""
|
||||
if 'oathTOTPToken' in entry(connection, name):
|
||||
"""Remove the user's TOTP token, when the user has one, and end the console sessions it needed (end_sessions)."""
|
||||
current = entry(connection, name)
|
||||
if 'oathTOTPToken' in current:
|
||||
token = connection.search_s(dn_of(name), ldap.SCOPE_BASE, attrlist=TOKEN_ATTRIBUTES)[0][1]
|
||||
changes = [(ldap.MOD_DELETE, 'objectClass', [b'oathTOTPUser', b'oathTOTPToken']),
|
||||
(ldap.MOD_DELETE, 'oathSecret', None)]
|
||||
changes += [(ldap.MOD_DELETE, attribute, None) for attribute in token]
|
||||
connection.modify_s(dn_of(name), changes)
|
||||
end_sessions(current)
|
||||
return summary(entry(connection, name))
|
||||
|
||||
|
||||
def remove(connection, name):
|
||||
"""Remove the user from its groups and the directory, and revoke the certificate.
|
||||
"""Remove the user from its groups and the directory, revoke the certificate and end the sessions of its own
|
||||
posixAccount.
|
||||
|
||||
A user who is the last member of a group is not removed.
|
||||
"""
|
||||
entry(connection, name)
|
||||
current = 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)
|
||||
try:
|
||||
vpnrouter_pki.revoke_user(vpnrouter.load_config(), name)
|
||||
except vpnrouter_pki.Failure as failure:
|
||||
raise Failure(f'the certificate cannot be revoked: {failure}', failure.status)
|
||||
connection.delete_s(dn_of(name))
|
||||
end_sessions(current)
|
||||
if 'vpnRouterWireGuardPeer' in current:
|
||||
apply_wireguard()
|
||||
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."""
|
||||
"""The OpenVPN client profile of the user, with the root and the P2S 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():
|
||||
credentials = vpnrouter_pki.user_credentials(name)
|
||||
if credentials is None:
|
||||
raise Failure(f'user {name} has no certificate')
|
||||
cert, key = (value.decode() for value in credentials)
|
||||
fqdn = cfg['wan']['local_fqdn'].strip()
|
||||
text = '\n'.join([
|
||||
'client',
|
||||
@@ -450,9 +799,9 @@ def profile(connection, 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>',
|
||||
'<ca>', extract_pem(vpnrouter_pki.OPENVPN_CA.read_text(encoding='utf-8')), '</ca>',
|
||||
'<cert>', extract_pem(cert), '</cert>',
|
||||
'<key>', key.strip(), '</key>',
|
||||
'',
|
||||
])
|
||||
return {'name': name, 'profile': text}
|
||||
@@ -514,6 +863,13 @@ def groups_of(connection, name):
|
||||
return {attributes['cn'][0].decode(): member_names(attributes) for _, attributes in found}
|
||||
|
||||
|
||||
def allowing(connection, name):
|
||||
"""The names of the OpenID Provider's clients whose allowed groups have the group, sorted."""
|
||||
found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL,
|
||||
f'(vpnRouterAllowedGroup={ldap.filter.escape_filter_chars(group_dn(name))})', ['uid'])
|
||||
return sorted(attributes['uid'][0].decode() 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'])}
|
||||
@@ -539,10 +895,13 @@ def group_add(connection, name, description, members):
|
||||
|
||||
|
||||
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."""
|
||||
"""Rename the group or change its description ('' removes it); at least one must be given. Renaming ADMIN_GROUP
|
||||
switches the console login of its members off; renaming a group a client of the OpenID Provider allows renames it
|
||||
there, and renaming the editors group of web sites renames it in the sites."""
|
||||
import vpnrouter_sites
|
||||
if new_name is None and description is None:
|
||||
raise Failure('give a new name or a description', 2)
|
||||
group_entry(connection, name)
|
||||
members = member_names(group_entry(connection, name))
|
||||
if description is not None:
|
||||
description = check_description(description)
|
||||
change = (ldap.MOD_REPLACE, 'description', [description.encode()]) if description else \
|
||||
@@ -554,13 +913,36 @@ def group_update(connection, name, new_name=None, description=None):
|
||||
connection.rename_s(group_dn(name), f'cn={new_name}', delold=1)
|
||||
except ldap.ALREADY_EXISTS:
|
||||
raise Failure(f'group {new_name} exists', 4)
|
||||
for client in allowing(connection, name):
|
||||
connection.modify_s(dn_of(client), [(ldap.MOD_DELETE, 'vpnRouterAllowedGroup', [group_dn(name).encode()]),
|
||||
(ldap.MOD_ADD, 'vpnRouterAllowedGroup', [group_dn(new_name).encode()])])
|
||||
try:
|
||||
vpnrouter_sites.rename_group(name, new_name)
|
||||
except vpnrouter_sites.DatabaseError as exc:
|
||||
raise Failure(f'group {name} is renamed {new_name}, but not in the web sites: {exc}')
|
||||
if name == ADMIN_GROUP:
|
||||
console_off(connection, members)
|
||||
name = new_name
|
||||
return group_summary(group_entry(connection, name))
|
||||
|
||||
|
||||
def group_remove(connection, name):
|
||||
group_entry(connection, name)
|
||||
"""Remove the group, unless a client of the OpenID Provider allows it or it is the editors group of web sites;
|
||||
removing ADMIN_GROUP switches the console login of its members off."""
|
||||
import vpnrouter_sites
|
||||
members = member_names(group_entry(connection, name))
|
||||
clients = allowing(connection, name)
|
||||
if clients:
|
||||
raise Failure(f'group {name} is an allowed group of the clients {", ".join(clients)}; take it out of them first', 2)
|
||||
try:
|
||||
sites = vpnrouter_sites.sites_of_group(name)
|
||||
except vpnrouter_sites.DatabaseError as exc:
|
||||
raise Failure(f'the web sites cannot be read: {exc}')
|
||||
if sites:
|
||||
raise Failure(f'group {name} is the editors group of the web sites {", ".join(sites)}; change them first', 2)
|
||||
connection.delete_s(group_dn(name))
|
||||
if name == ADMIN_GROUP:
|
||||
console_off(connection, members)
|
||||
return {'name': name, 'removed': True}
|
||||
|
||||
|
||||
@@ -573,7 +955,8 @@ def group_member_add(connection, name, user):
|
||||
|
||||
|
||||
def group_member_remove(connection, name, user):
|
||||
"""Take the user out of the group; the last member cannot leave."""
|
||||
"""Take the user out of the group; the last member cannot leave. Leaving ADMIN_GROUP switches the user's console
|
||||
login off."""
|
||||
check_name(user)
|
||||
members = member_names(group_entry(connection, name))
|
||||
if user not in members:
|
||||
@@ -581,6 +964,8 @@ def group_member_remove(connection, name, user):
|
||||
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()])])
|
||||
if name == ADMIN_GROUP:
|
||||
console_off(connection, [user])
|
||||
return group_summary(group_entry(connection, name))
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/python3
|
||||
"""The auth proxy of rclone's WebDAV server of the web sites' folders (vpn-router-webdav.service).
|
||||
|
||||
rclone runs it for each new user and password with {"user", "pass"} on standard input. The user signs in with the
|
||||
password of the router's directory, followed by the TOTP code for an enrolled user. A member of
|
||||
vpnrouter_users.ADMIN_GROUP sees the folders of all sites, any other user those of the sites whose editors group
|
||||
the user is a member of (vpnrouter_sites.editable), each as a folder named after its site. The answer on standard output
|
||||
is rclone's combine backend of those folders; a failed sign-in, a user with no site or any failure is a message on
|
||||
standard error and the exit status 1, which rclone refuses the user with.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
|
||||
|
||||
import ldap
|
||||
|
||||
import vpnrouter_ldap
|
||||
import vpnrouter_sites
|
||||
import vpnrouter_users
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
request = json.load(sys.stdin)
|
||||
name, password = request['user'], request['pass']
|
||||
vpnrouter_users.login(name, password, None)
|
||||
connection = vpnrouter_ldap.manager()
|
||||
try:
|
||||
groups = vpnrouter_users.groups_of(connection, name)
|
||||
finally:
|
||||
connection.unbind_s()
|
||||
if vpnrouter_users.ADMIN_GROUP in groups:
|
||||
sites = [site['name'] for site in vpnrouter_sites.listing()]
|
||||
else:
|
||||
sites = vpnrouter_sites.editable(groups)
|
||||
except (ValueError, KeyError, TypeError, OSError, ldap.LDAPError, vpnrouter_users.Failure,
|
||||
vpnrouter_sites.DatabaseError) as exc:
|
||||
sys.exit(f'webdav-auth: refused: {exc}')
|
||||
if not sites:
|
||||
sys.exit(f'webdav-auth: refused: {name} may change no site')
|
||||
vpnrouter_sites.ensure_folders(sites)
|
||||
upstreams = ' '.join(f'{site}={vpnrouter_sites.SITES_DIR / site}' for site in sites)
|
||||
json.dump({'type': 'combine', '_root': '', 'upstreams': upstreams}, sys.stdout)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -9,18 +9,21 @@ number, true or false, a list of strings separated by commas (an empty value is
|
||||
or a string. remove takes site_to_site.NAME and removes that connection. The configuration with
|
||||
the change is checked as vpn-router-setup checks it, and only then written with
|
||||
vpnrouter.save_config, holding vpnrouter.config_lock; vpn-router-setup applies it at its next
|
||||
run, which set and remove remind of. A failure is a message on standard error; the exit status
|
||||
is 2 for an invalid request and 1 for any other failure. -h or --help shows the options.
|
||||
run, which set and remove remind of. With --restart they restart vpn-router-setup and wait until
|
||||
it has applied the change. A failure is a message on standard error; the exit status is 2 for an
|
||||
invalid request and 1 for any other failure, a failed run of vpn-router-setup included. -h or
|
||||
--help shows the options.
|
||||
|
||||
vpn-config-edit show
|
||||
vpn-config-edit set SECTION.OPTION VALUE
|
||||
vpn-config-edit set site_to_site.NAME.OPTION VALUE
|
||||
vpn-config-edit remove site_to_site.NAME
|
||||
vpn-config-edit set [--restart] SECTION.OPTION VALUE
|
||||
vpn-config-edit set [--restart] site_to_site.NAME.OPTION VALUE
|
||||
vpn-config-edit remove [--restart] site_to_site.NAME
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '/usr/lib/vpn-router')
|
||||
@@ -86,6 +89,9 @@ def main(argv):
|
||||
setter.add_argument('value', metavar='VALUE', help='its value; a list separated by commas')
|
||||
remover = commands.add_parser('remove', help='remove a site-to-site connection and write the configuration')
|
||||
remover.add_argument('key', metavar='site_to_site.NAME', help='the connection')
|
||||
for changer in (setter, remover):
|
||||
changer.add_argument('--restart', action='store_true',
|
||||
help='restart vpn-router-setup and wait until it has applied the change')
|
||||
options = parser.parse_args(argv)
|
||||
|
||||
if options.command == 'show':
|
||||
@@ -124,7 +130,14 @@ def main(argv):
|
||||
print(f'{options.key} set to {json.dumps(value)} in {vpnrouter.CONFIG_FILE}.')
|
||||
else:
|
||||
print(f'{options.key} removed from {vpnrouter.CONFIG_FILE}.')
|
||||
print('Apply it with: systemctl restart vpn-router-setup')
|
||||
if not options.restart:
|
||||
print('Apply it with: systemctl restart vpn-router-setup, or --restart')
|
||||
return 0
|
||||
print('Restarting vpn-router-setup...', flush=True)
|
||||
if subprocess.run(['systemctl', 'restart', 'vpn-router-setup.service']).returncode != 0:
|
||||
print('vpn-config-edit: vpn-router-setup failed; see journalctl -u vpn-router-setup', file=sys.stderr)
|
||||
return 1
|
||||
print('vpn-router-setup applied it.')
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/python3
|
||||
"""Make, list and restore backups of the router (vpnrouter_backup).
|
||||
|
||||
run makes a backup as backup in /etc/vpn-router/vpn-router.json says, whether or not backups are
|
||||
enabled, and prints its result; vpn-router-backup.service runs it on the schedule of its timer.
|
||||
list prints the local archives of this host in backup.path, newest first, or with the pbs target
|
||||
the snapshots of this host on the Proxmox Backup Server. restore puts back a local archive, or
|
||||
with --pbs a snapshot, such as host/router/2026-10-25T03:00:00Z, after showing what it holds and
|
||||
asking for yes, which --yes gives; what it replaces is kept in
|
||||
/var/lib/vpn-router/backup/before-restore. A failure is a message on standard error; the exit
|
||||
status is 2 for an invalid request and 1 for any other failure. -h or --help shows the options.
|
||||
|
||||
vpn-router-backup run
|
||||
vpn-router-backup list
|
||||
vpn-router-backup restore [--yes] ARCHIVE
|
||||
vpn-router-backup restore [--yes] --pbs SNAPSHOT
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '/usr/lib/vpn-router')
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_backup
|
||||
|
||||
|
||||
class Parser(argparse.ArgumentParser):
|
||||
"""An argument parser whose errors end with status 2."""
|
||||
|
||||
def error(self, message):
|
||||
print(f'{self.prog}: {message}; see {self.prog} --help', file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def confirmed(manifest, yes):
|
||||
"""Whether to restore the backup of the manifest: yes, or the answer yes to the question."""
|
||||
print(f'Backup of {manifest.get("host")} made {manifest.get("time")} by vpn-router {manifest.get("version")}:')
|
||||
for item in manifest.get('items', []):
|
||||
print(f' {item}')
|
||||
if yes:
|
||||
return True
|
||||
return input('Replace the router state with it? Type yes: ').strip() == 'yes'
|
||||
|
||||
|
||||
def main(argv):
|
||||
parser = Parser(prog='vpn-router-backup', description='Make, list and restore backups of the router.')
|
||||
commands = parser.add_subparsers(dest='command', required=True, parser_class=Parser)
|
||||
commands.add_parser('run', help='make a backup now')
|
||||
commands.add_parser('list', help='list the backups of this host')
|
||||
restorer = commands.add_parser('restore', help='put a backup back')
|
||||
restorer.add_argument('source', metavar='ARCHIVE|SNAPSHOT', help='a local archive, or with --pbs a snapshot')
|
||||
restorer.add_argument('--pbs', action='store_true', help='restore a snapshot of the Proxmox Backup Server')
|
||||
restorer.add_argument('--yes', action='store_true', help='do not ask before replacing the router state')
|
||||
options = parser.parse_args(argv)
|
||||
os.umask(0o077)
|
||||
|
||||
try:
|
||||
if options.command == 'run':
|
||||
record = vpnrouter_backup.run()
|
||||
if record['result'] == 'failed':
|
||||
print(f'vpn-router-backup: {record["error"]}', file=sys.stderr)
|
||||
return 1
|
||||
print(f'Backup {record["archive"] or record["snapshot"]} made, {record["size"]} bytes.')
|
||||
for name in record['removed']:
|
||||
print(f'Removed {name}.')
|
||||
if record['cloud']:
|
||||
print(f'Cloud copy to {record["cloud"]["remote"]}: {record["cloud"]["error"] or "ok"}.')
|
||||
return 0
|
||||
cfg = vpnrouter.load_config()
|
||||
if options.command == 'list':
|
||||
if cfg['backup']['target'] == 'pbs':
|
||||
print(vpnrouter_backup.snapshots(cfg), end='')
|
||||
else:
|
||||
for item in vpnrouter_backup.archives(cfg['backup']['path']):
|
||||
print(f'{item["name"]} {item["size"]}')
|
||||
return 0
|
||||
if options.pbs and options.source.startswith('/'):
|
||||
print('vpn-router-backup: a snapshot is type/id/time, such as host/router/2026-10-25T03:00:00Z',
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
saved = vpnrouter_backup.restore(options.source, options.pbs, lambda manifest: confirmed(manifest, options.yes))
|
||||
except vpnrouter_backup.Failure as exc:
|
||||
print(f'vpn-router-backup: {exc}', file=sys.stderr)
|
||||
return 1
|
||||
except OSError as exc:
|
||||
print(f'vpn-router-backup: {exc}', file=sys.stderr)
|
||||
return 1
|
||||
if saved is None:
|
||||
print('Nothing restored.')
|
||||
return 1
|
||||
print(f'Restored; what it replaced is in {saved}.')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
Executable
+212
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/python3
|
||||
"""Manage the router's PKI, which the router's directory holds, from the command line, with vpnrouter_pki.
|
||||
|
||||
status shows the root, the issuing CAs and the warnings; list the certificates the router issued. issue signs a CSR,
|
||||
or makes a key and writes a PFX file whose password is read from the first line of standard input. ca csr prints the
|
||||
CSR of an issuing CA that waits for the offline root, with the openssl extensions to sign it with, and ca import takes
|
||||
the signed certificate. root export-key writes the root key encrypted with the passphrase read from standard input,
|
||||
root remove-key removes it from the router, root import-key puts it back, and root import-crl takes a root CRL signed
|
||||
offline. maintain signs the stale CRLs again; vpn-router-pki.service runs it daily. A change that needs
|
||||
vpn-router-setup restarts it without waiting.
|
||||
|
||||
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 something it names does not exist, 4 the state does not allow it, 1 anything else. -h or --help shows the
|
||||
commands and their options.
|
||||
|
||||
vpn-router-ca [--json] status
|
||||
vpn-router-ca [--json] list [--ca NAME]
|
||||
vpn-router-ca [--json] issue --ca NAME --template NAME --csr FILE
|
||||
vpn-router-ca [--json] issue --ca NAME --template NAME --subject NAME [--san NAME ...] --pfx FILE (password on stdin)
|
||||
vpn-router-ca [--json] revoke SERIAL [--reason REASON]
|
||||
vpn-router-ca [--json] maintain
|
||||
vpn-router-ca [--json] ca csr NAME
|
||||
vpn-router-ca [--json] ca import NAME FILE
|
||||
vpn-router-ca [--json] root export-key --out FILE (passphrase on stdin)
|
||||
vpn-router-ca [--json] root remove-key [--yes]
|
||||
vpn-router-ca [--json] root import-key FILE (passphrase on stdin, an empty line for none)
|
||||
vpn-router-ca [--json] root import-crl FILE
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '/usr/lib/vpn-router')
|
||||
|
||||
import ldap
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_pki
|
||||
from vpnrouter_pki import Failure
|
||||
|
||||
|
||||
class Parser(argparse.ArgumentParser):
|
||||
"""An argument parser whose errors end with status 2."""
|
||||
|
||||
def error(self, message):
|
||||
print(f'{self.prog}: {message}; see {self.prog} --help', file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def secret():
|
||||
"""The first line of standard input."""
|
||||
return sys.stdin.readline().rstrip('\r\n')
|
||||
|
||||
|
||||
def restart_setup():
|
||||
subprocess.run(['systemctl', 'restart', '--no-block', 'vpn-router-setup.service'], capture_output=True, text=True)
|
||||
|
||||
|
||||
def show_status(document):
|
||||
root = document['root']
|
||||
if root is None:
|
||||
print('Root CA: none; vpn-router-setup creates it once a platform is chosen')
|
||||
else:
|
||||
print(f'Root CA: {root["subject"]}, expires {root["expires"]}, key {"on the router" if root["key"] else "offline"}'
|
||||
f', CRL until {root["crl_next_update"] or "none"}')
|
||||
for authority in document['authorities']:
|
||||
line = f'Issuing CA {authority["name"]}: {authority["common_name"]}, {authority["state"]}'
|
||||
if authority['expires']:
|
||||
line += f', expires {authority["expires"]}, CRL until {authority["crl_next_update"] or "none"}'
|
||||
line += f', templates {", ".join(authority["templates"])}'
|
||||
if authority['acme_url']:
|
||||
line += f', ACME {authority["acme_url"]}'
|
||||
print(line)
|
||||
for retired in document['retired']:
|
||||
print(f'Retired CA {retired["name"]}: {retired["retired"]}')
|
||||
for warning in document['warnings']:
|
||||
print(f'Warning: {warning}')
|
||||
|
||||
|
||||
def run(options):
|
||||
"""Do the command of the options; the answer as a document, and a text for people."""
|
||||
cfg = vpnrouter.load_config()
|
||||
if options.command == 'status':
|
||||
document = vpnrouter_pki.status(cfg)
|
||||
return document, None
|
||||
if options.command == 'list':
|
||||
found = [entry for entry in vpnrouter_pki.certificates() if options.ca in (None, entry['ca'])]
|
||||
text = '\n'.join(f'{entry["serial"]} {entry["ca"]} {entry["kind"]} {entry["subject"]} {entry["state"]} '
|
||||
f'{entry["expires"]}' for entry in found)
|
||||
return {'certificates': found}, text
|
||||
with vpnrouter_pki.locked():
|
||||
if options.command == 'issue':
|
||||
if options.csr:
|
||||
document = vpnrouter_pki.issue(cfg, options.ca, options.template,
|
||||
csr=pathlib.Path(options.csr).read_text(encoding='ascii'))
|
||||
return document, document['certificate'] + document['chain']
|
||||
if not options.subject or not options.pfx:
|
||||
raise Failure('give --csr, or --subject and --pfx', 2)
|
||||
document = vpnrouter_pki.issue(cfg, options.ca, options.template, subject=options.subject,
|
||||
sans=options.san or [], password=secret())
|
||||
out = pathlib.Path(options.pfx)
|
||||
with open(out, 'xb') as handle:
|
||||
handle.write(base64.b64decode(document['pfx']))
|
||||
return {'serial': document['serial'], 'pfx': str(out)}, f'Certificate {document["serial"]} written to {out}.'
|
||||
if options.command == 'revoke':
|
||||
document = vpnrouter_pki.revoke(cfg, options.serial, options.reason)
|
||||
if document['reissue']:
|
||||
restart_setup()
|
||||
return document, f'Certificate {document["serial"]} revoked' + \
|
||||
('; vpn-router-setup issues a new one.' if document['reissue'] else '.')
|
||||
if options.command == 'maintain':
|
||||
due = vpnrouter_pki.maintain(cfg)
|
||||
if due:
|
||||
restart_setup()
|
||||
return {'renewal': due}, 'CRLs checked' + ('; vpn-router-setup renews the router certificates.' if due else '.')
|
||||
if options.command == 'ca' and options.action == 'csr':
|
||||
document = vpnrouter_pki.csr(cfg, options.name)
|
||||
return document, document['csr'] + '\n' + document['extensions']
|
||||
if options.command == 'ca':
|
||||
document = vpnrouter_pki.import_ca(cfg, options.name, pathlib.Path(options.file).read_text(encoding='ascii'))
|
||||
restart_setup()
|
||||
return document, f'Issuing CA {options.name} has its certificate, which expires {document["expires"]}.'
|
||||
if options.action == 'export-key':
|
||||
data = vpnrouter_pki.export_root_key(secret())
|
||||
out = pathlib.Path(options.out)
|
||||
with open(out, 'xb') as handle:
|
||||
handle.write(data)
|
||||
return {'exported': str(out)}, f'Root key written to {out}, encrypted.'
|
||||
if options.action == 'remove-key':
|
||||
if not options.yes:
|
||||
print('Remove the root key from the router? Keep an exported copy first. Type yes: ', end='',
|
||||
file=sys.stderr, flush=True)
|
||||
if secret().strip() != 'yes':
|
||||
return {'removed': False}, 'Nothing removed.'
|
||||
vpnrouter_pki.remove_root_key()
|
||||
return {'removed': True}, 'Root key removed; new issuing CAs now wait for an offline signature.'
|
||||
if options.action == 'import-key':
|
||||
vpnrouter_pki.import_root_key(pathlib.Path(options.file).read_bytes(), secret() or None)
|
||||
restart_setup()
|
||||
return {'imported': True}, 'Root key back on the router.'
|
||||
document = vpnrouter_pki.import_root_crl(pathlib.Path(options.file).read_bytes())
|
||||
return document, f'Root CRL taken, valid until {document["next_update"]}.'
|
||||
|
||||
|
||||
def main(argv):
|
||||
parser = Parser(prog='vpn-router-ca', description="Manage the router's PKI.")
|
||||
parser.add_argument('--json', action='store_true', help='answer in JSON')
|
||||
commands = parser.add_subparsers(dest='command', required=True, parser_class=Parser)
|
||||
commands.add_parser('status', help='show the root, the issuing CAs and the warnings')
|
||||
lister = commands.add_parser('list', help='list the certificates the router issued')
|
||||
lister.add_argument('--ca', help='only those of this issuing CA')
|
||||
issuer = commands.add_parser('issue', help='issue a certificate from a CSR, or with a new key into a PFX file')
|
||||
issuer.add_argument('--ca', required=True, help='the issuing CA')
|
||||
issuer.add_argument('--template', required=True, help='the certificate template')
|
||||
issuer.add_argument('--csr', help='the PEM CSR to sign')
|
||||
issuer.add_argument('--subject', help='the common name, without --csr')
|
||||
issuer.add_argument('--san', action='append', help='a subject alternative name, without --csr; repeat for more')
|
||||
issuer.add_argument('--pfx', help='the PFX file to write, without --csr; the password is read from standard input')
|
||||
revoker = commands.add_parser('revoke', help='revoke a certificate the router issued')
|
||||
revoker.add_argument('serial', help='the serial number, hexadecimal')
|
||||
revoker.add_argument('--reason', choices=vpnrouter_pki.REASONS, help='the reason, unspecified by default')
|
||||
commands.add_parser('maintain', help='sign the stale CRLs again')
|
||||
authority = commands.add_parser('ca', help='an issuing CA that waits for the offline root')
|
||||
actions = authority.add_subparsers(dest='action', required=True, parser_class=Parser)
|
||||
actions.add_parser('csr', help='print its CSR and the openssl extensions').add_argument('name')
|
||||
importer = actions.add_parser('import', help='take its certificate signed by the root')
|
||||
importer.add_argument('name')
|
||||
importer.add_argument('file')
|
||||
root = commands.add_parser('root', help='the root CA')
|
||||
root_actions = root.add_subparsers(dest='action', required=True, parser_class=Parser)
|
||||
exporter = root_actions.add_parser('export-key', help='write the root key encrypted with the passphrase from stdin')
|
||||
exporter.add_argument('--out', required=True, help='the file to write')
|
||||
remover = root_actions.add_parser('remove-key', help='remove the root key from the router')
|
||||
remover.add_argument('--yes', action='store_true', help='do not ask')
|
||||
root_actions.add_parser('import-key', help='put the root key back').add_argument('file')
|
||||
root_actions.add_parser('import-crl', help='take a root CRL signed offline').add_argument('file')
|
||||
options = parser.parse_args(argv)
|
||||
os.umask(0o077)
|
||||
|
||||
try:
|
||||
document, text = run(options)
|
||||
except Failure as failure:
|
||||
code, message = failure.status, str(failure)
|
||||
except OSError as exc:
|
||||
code, message = 1, str(exc)
|
||||
except ldap.LDAPError as exc:
|
||||
code, message = 1, f'the directory, which holds the PKI, failed: {exc}'
|
||||
except SystemExit as exc:
|
||||
code, message = 1, str(exc.code)
|
||||
else:
|
||||
if options.json:
|
||||
print(json.dumps(document, indent=2))
|
||||
elif text is None:
|
||||
show_status(document)
|
||||
elif text:
|
||||
print(text.rstrip('\n'))
|
||||
return 0
|
||||
if options.json:
|
||||
print(json.dumps({'error': message}, indent=2))
|
||||
else:
|
||||
print(f'vpn-router-ca: {message}', file=sys.stderr)
|
||||
return code
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
Executable
+151
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/python3
|
||||
"""Install Caddy, the router's HTTPS proxy, from the source https.caddy_source names.
|
||||
|
||||
status shows the configured source, the source in effect and the installed and candidate versions of Caddy. apply
|
||||
installs Caddy when it is missing: from the Ubuntu archive, or with upstream from Caddy's own repository, which it
|
||||
configures first (vpnrouter.CADDY_APT_SOURCE, with the key in vpnrouter.CADDY_KEYRING). When upstream is configured
|
||||
and the repository is not, it configures the repository and replaces the installed Caddy with upstream's. It then
|
||||
restarts vpn-router-setup without waiting, which serves with the new Caddy; vpn-router-caddy.service runs it for
|
||||
vpn-router-setup. use-upstream sets https.caddy_source to upstream and applies it, printing each step.
|
||||
|
||||
Once the repository is configured Caddy stays upstream's: apt keeps the higher upstream version, and ubuntu has no
|
||||
effect. A failure is a message on standard error and the exit status 1. -h or --help shows the commands.
|
||||
|
||||
vpn-router-caddy [--json] status
|
||||
vpn-router-caddy apply
|
||||
vpn-router-caddy use-upstream
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, '/usr/lib/vpn-router')
|
||||
|
||||
import vpnrouter
|
||||
|
||||
#: apt-get, waiting up to ten minutes for dpkg's lock, which a package installation that runs setup holds.
|
||||
APT = ['apt-get', '-o', 'DPkg::Lock::Timeout=600']
|
||||
|
||||
#: The options of apt-get install: no questions, and the configuration files of the installed Caddy kept.
|
||||
INSTALL = ['-y', '-o', 'Dpkg::Options::=--force-confdef', '-o', 'Dpkg::Options::=--force-confold', 'install']
|
||||
|
||||
#: Seconds the key of Caddy's repository may take to download.
|
||||
KEY_TIMEOUT = 60
|
||||
|
||||
|
||||
class Parser(argparse.ArgumentParser):
|
||||
"""An argument parser whose errors end with status 2."""
|
||||
|
||||
def error(self, message):
|
||||
print(f'{self.prog}: {message}; see {self.prog} --help', file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def step(message):
|
||||
print(message, flush=True)
|
||||
|
||||
|
||||
def installed():
|
||||
"""The installed version of caddy, or None when it is not installed."""
|
||||
result = subprocess.run(['dpkg-query', '-W', '-f=${Status}\t${Version}', 'caddy'], capture_output=True, text=True)
|
||||
status, _, version = result.stdout.partition('\t')
|
||||
return version if result.returncode == 0 and status == 'install ok installed' else None
|
||||
|
||||
|
||||
def candidate():
|
||||
"""The version of caddy apt would install, or None when it knows none."""
|
||||
result = subprocess.run(['apt-cache', 'policy', 'caddy'], capture_output=True, text=True)
|
||||
for line in result.stdout.splitlines():
|
||||
if line.strip().startswith('Candidate:'):
|
||||
value = line.split(':', 1)[1].strip()
|
||||
return None if value == '(none)' else value
|
||||
return None
|
||||
|
||||
|
||||
def effective():
|
||||
"""The source Caddy comes from: upstream once its repository is configured, ubuntu otherwise."""
|
||||
return 'upstream' if vpnrouter.CADDY_APT_SOURCE.is_file() else 'ubuntu'
|
||||
|
||||
|
||||
def command(args):
|
||||
"""Run a command with its output shown; SystemExit when it fails."""
|
||||
step(f'$ {" ".join(args)}')
|
||||
result = subprocess.run(args, env=dict(os.environ, DEBIAN_FRONTEND='noninteractive'))
|
||||
if result.returncode != 0:
|
||||
sys.exit(f'vpn-router-caddy: {args[0]} failed with status {result.returncode}')
|
||||
|
||||
|
||||
def apply():
|
||||
"""Install Caddy, or replace it with upstream's, as https.caddy_source asks (see the module)."""
|
||||
source = vpnrouter.load_config()['https']['caddy_source']
|
||||
repository = source == 'upstream' and not vpnrouter.CADDY_APT_SOURCE.is_file()
|
||||
if installed() and not repository:
|
||||
if source == 'ubuntu' and effective() == 'upstream':
|
||||
step("https.caddy_source is ubuntu, but Caddy's repository is configured, so Caddy stays upstream's.")
|
||||
else:
|
||||
step(f'Caddy {installed()} is installed from the {source} source.')
|
||||
return
|
||||
if repository:
|
||||
step(f"Downloading the key of Caddy's repository from {vpnrouter.CADDY_KEY_URL}")
|
||||
with urllib.request.urlopen(vpnrouter.CADDY_KEY_URL, timeout=KEY_TIMEOUT) as response:
|
||||
key = response.read()
|
||||
if not key.lstrip().startswith(b'-----BEGIN PGP PUBLIC KEY BLOCK-----'):
|
||||
sys.exit(f'vpn-router-caddy: {vpnrouter.CADDY_KEY_URL} did not answer with an OpenPGP public key')
|
||||
vpnrouter.CADDY_KEYRING.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
vpnrouter.CADDY_KEYRING.write_bytes(key)
|
||||
os.chmod(vpnrouter.CADDY_KEYRING, 0o644)
|
||||
vpnrouter.CADDY_APT_SOURCE.write_text(
|
||||
f'Types: deb\nURIs: {vpnrouter.CADDY_REPOSITORY}\nSuites: any-version\nComponents: main\n'
|
||||
f'Signed-By: {vpnrouter.CADDY_KEYRING}\n', encoding='utf-8')
|
||||
os.chmod(vpnrouter.CADDY_APT_SOURCE, 0o644)
|
||||
step(f"Caddy's repository is configured in {vpnrouter.CADDY_APT_SOURCE}.")
|
||||
command([*APT, 'update'])
|
||||
command([*APT, *INSTALL, 'caddy'])
|
||||
step(f'Caddy {installed()} is installed from the {effective()} source.')
|
||||
subprocess.run(['systemctl', 'restart', '--no-block', 'vpn-router-setup.service'], capture_output=True, text=True)
|
||||
step('vpn-router-setup restarted.')
|
||||
|
||||
|
||||
def main(argv):
|
||||
parser = Parser(prog='vpn-router-caddy', description='Install Caddy from the source https.caddy_source names.')
|
||||
parser.add_argument('--json', action='store_true', help='answer status in JSON')
|
||||
commands = parser.add_subparsers(dest='command', required=True, parser_class=Parser)
|
||||
commands.add_parser('status', help='show the sources and the versions of Caddy')
|
||||
commands.add_parser('apply', help='install Caddy, or replace it with upstream\'s, as https.caddy_source asks')
|
||||
commands.add_parser('use-upstream', help="switch to Caddy's own repository and replace Caddy with upstream's")
|
||||
options = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
if options.command == 'status':
|
||||
document = {'configured': vpnrouter.load_config()['https']['caddy_source'], 'source': effective(),
|
||||
'installed': installed(), 'candidate': candidate()}
|
||||
if options.json:
|
||||
print(json.dumps(document, indent=2))
|
||||
else:
|
||||
print(f'Configured source: {document["configured"]}\nSource in effect: {document["source"]}\n'
|
||||
f'Installed: {document["installed"] or "no"}\nCandidate: {document["candidate"] or "none"}')
|
||||
return 0
|
||||
if options.command == 'use-upstream':
|
||||
with vpnrouter.config_lock():
|
||||
data = vpnrouter.read_settings()
|
||||
current = data.get('https')
|
||||
data['https'] = {**(current if isinstance(current, dict) else {}), 'caddy_source': 'upstream'}
|
||||
vpnrouter.validate(vpnrouter.with_defaults(data))
|
||||
vpnrouter.save_config(data)
|
||||
step('https.caddy_source is upstream.')
|
||||
apply()
|
||||
except vpnrouter.ConfigError as exc:
|
||||
print(f'vpn-router-caddy: {exc}', file=sys.stderr)
|
||||
return 1
|
||||
except OSError as exc:
|
||||
print(f'vpn-router-caddy: {exc}', file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
Executable
+157
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/python3
|
||||
"""Manage the web sites Caddy serves from the command line, with vpnrouter_sites.
|
||||
|
||||
A site has a name, which also names its folder in /var/lib/vpn-router/sites, host names, a port, the interfaces whose
|
||||
firewall opens it, TLS (none, acme, ca with --ca, or file with --certificate and --key), the file served for a folder,
|
||||
whether any path without a file is served that file of the site (--spa), and the group whose members change its files.
|
||||
A change is applied by restarting vpn-router-setup without waiting for it. 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 site does not exist, 4
|
||||
the site exists, 1 anything else. -h or --help shows the commands and their options.
|
||||
|
||||
vpn-router-site [--json] list
|
||||
vpn-router-site [--json] show <name>
|
||||
vpn-router-site [--json] add <name> [options]
|
||||
vpn-router-site [--json] update <name> [options]
|
||||
vpn-router-site [--json] remove <name> [--content]
|
||||
|
||||
options: [--host H ...] [--no-hosts] [--port P] [--listen internal|external|both] [--tls none|acme|ca|file]
|
||||
[--ca NAME] [--certificate FILE] [--key FILE] [--index FILE] [--spa | --no-spa]
|
||||
[--enabled | --no-enabled] [--editors GROUP]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '/usr/lib/vpn-router')
|
||||
|
||||
import ldap
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_sites
|
||||
from vpnrouter_sites import Failure
|
||||
|
||||
#: The help line of each command.
|
||||
HELP = {
|
||||
'list': 'list the sites',
|
||||
'show': 'show a site',
|
||||
'add': 'add a site',
|
||||
'update': 'change the settings of a site',
|
||||
'remove': 'remove a site; its folder is kept without --content',
|
||||
}
|
||||
|
||||
|
||||
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-site', description='Manage the web sites 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 site name')
|
||||
if command in ('add', 'update'):
|
||||
sub.add_argument('--host', dest='hosts', action='append', metavar='HOST',
|
||||
help='a host name; give it once for each name, replacing the current ones')
|
||||
sub.add_argument('--no-hosts', action='store_true', help='answer any host name')
|
||||
sub.add_argument('--port', type=int, metavar='PORT', help='the TCP port')
|
||||
sub.add_argument('--listen', choices=vpnrouter_sites.LISTEN, help='the interfaces whose firewall opens the port')
|
||||
sub.add_argument('--tls', choices=vpnrouter_sites.TLS, help='where the certificate comes from')
|
||||
sub.add_argument('--ca', metavar='NAME', help='the issuing CA of tls ca')
|
||||
sub.add_argument('--certificate', metavar='FILE', help='the PEM certificate chain of tls file')
|
||||
sub.add_argument('--key', metavar='FILE', help='the PEM private key of tls file')
|
||||
sub.add_argument('--index', metavar='FILE', help='the file served for a folder')
|
||||
sub.add_argument('--spa', action=argparse.BooleanOptionalAction, help='serve the index of the site for any '
|
||||
'path without a file')
|
||||
sub.add_argument('--enabled', action=argparse.BooleanOptionalAction, help='serve the site')
|
||||
sub.add_argument('--editors', metavar='GROUP', help="the group whose members change the files; '' for none")
|
||||
if command == 'remove':
|
||||
sub.add_argument('--content', action='store_true', help='remove the site\'s folder too')
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def values_of(options):
|
||||
"""The settings the options give."""
|
||||
values = {key: getattr(options, option) for key, option in (('port', 'port'), ('listen', 'listen'), ('tls', 'tls'),
|
||||
('ca', 'ca'), ('index_file', 'index'), ('spa', 'spa'),
|
||||
('enabled', 'enabled'), ('editors', 'editors'))
|
||||
if getattr(options, option) is not None}
|
||||
if options.no_hosts:
|
||||
values['hosts'] = []
|
||||
elif options.hosts:
|
||||
values['hosts'] = options.hosts
|
||||
for key, option in (('certificate', 'certificate'), ('private_key', 'key')):
|
||||
path = getattr(options, option)
|
||||
if path is not None:
|
||||
try:
|
||||
with open(path, encoding='utf-8') as handle:
|
||||
values[key] = handle.read()
|
||||
except OSError as exc:
|
||||
raise Failure(f'{path} cannot be read: {exc.strerror}', 2)
|
||||
return values
|
||||
|
||||
|
||||
def run(options):
|
||||
"""The answer of the command the options name, and whether it changed a site."""
|
||||
command = options.command
|
||||
if command == 'list':
|
||||
return {'sites': vpnrouter_sites.listing()}, False
|
||||
if command == 'show':
|
||||
with vpnrouter_sites.database() as connection:
|
||||
return vpnrouter_sites.shown(vpnrouter_sites.get(connection, options.name)), False
|
||||
if command == 'remove':
|
||||
return vpnrouter_sites.remove(options.name, options.content), True
|
||||
cfg = vpnrouter.load_config()
|
||||
return vpnrouter_sites.save(cfg, options.name, values_of(options), command == 'add'), True
|
||||
|
||||
|
||||
def text_of(answer):
|
||||
"""The answer as text for people."""
|
||||
if answer.get('removed'):
|
||||
return f'site {answer["name"]} removed'
|
||||
sites = answer['sites'] if 'sites' in answer else [answer]
|
||||
if not sites:
|
||||
return 'no sites'
|
||||
head = ('SITE', 'ENABLED', 'HOSTS', 'PORT', 'LISTEN', 'TLS', 'EDITORS')
|
||||
rows = [(site['name'], 'yes' if site['enabled'] else 'no', ', '.join(site['hosts']) or '*', str(site['port']),
|
||||
site['listen'], site['tls'] + (f' {site["ca"]}' if site['tls'] == 'ca' else ''), site['editors'] or '-')
|
||||
for site in sites]
|
||||
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
|
||||
answer, changed = run(options)
|
||||
except Failure as failure:
|
||||
message, status = str(failure), failure.status
|
||||
except vpnrouter_sites.DatabaseError as exc:
|
||||
message, status = str(exc), 1
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
message, status = f'the user directory failed: {exc}', 1
|
||||
else:
|
||||
if changed:
|
||||
subprocess.run(['systemctl', 'restart', '--no-block', 'vpn-router-setup.service'])
|
||||
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-site: {message}', file=sys.stderr)
|
||||
return status
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -11,8 +11,16 @@ 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] add <name> --service [--email E] (a service account; password on stdin)
|
||||
vpn-router-user [--json] add <name> --service [--display-name N] [--description D] [--email E]
|
||||
[--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...]
|
||||
(a service account; password on stdin)
|
||||
vpn-router-user [--json] update <name> [--first-name F] [--last-name L] [--email E]
|
||||
vpn-router-user [--json] update <name> [--display-name N] [--description D] [--email E]
|
||||
[--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...]
|
||||
(a service account)
|
||||
|
||||
A service account with a redirect URI is a client of the OpenID Provider; each of the three options may be repeated,
|
||||
and replaces all values of its kind; one '' removes them.
|
||||
vpn-router-user [--json] passwd <name> (password on stdin)
|
||||
vpn-router-user [--json] disable <name>
|
||||
vpn-router-user [--json] enable <name>
|
||||
@@ -22,6 +30,8 @@ user exists, 1 anything else. -h or --help shows the commands and their options.
|
||||
vpn-router-user [--json] otp-new <name>
|
||||
vpn-router-user [--json] otp-enrol <name> <code> (secret on stdin)
|
||||
vpn-router-user [--json] otp-remove <name>
|
||||
vpn-router-user [--json] console <name> (--posix | --account LOCAL | --off)
|
||||
vpn-router-user [--json] passkeys-remove <name>
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -34,6 +44,7 @@ import ldap
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_auth
|
||||
import vpnrouter_passkeys
|
||||
import vpnrouter_users
|
||||
from vpnrouter_users import Failure
|
||||
|
||||
@@ -54,10 +65,22 @@ HELP = {
|
||||
'otp-new': 'make a TOTP secret and its otpauth URI; nothing is stored',
|
||||
'otp-enrol': 'store the TOTP secret read from standard input, when the code is a code of it',
|
||||
'otp-remove': 'remove the TOTP token',
|
||||
'console': "switch the console login of a member of admins with a TOTP token on, as the user's own POSIX account "
|
||||
'in the directory or as a local account, or off',
|
||||
'passkeys-remove': "remove all the user's passkeys",
|
||||
}
|
||||
|
||||
#: The help of the person options.
|
||||
PERSON_HELP = {'first_name': 'the first name', 'last_name': 'the last name', 'email': 'the e-mail address'}
|
||||
PERSON_HELP = {'first_name': 'the first name', 'last_name': 'the last name', 'email': 'the e-mail address',
|
||||
'display_name': "a service account's display name; '' removes it",
|
||||
'description': "a service account's description; '' removes it"}
|
||||
|
||||
#: The OpenID Provider client settings of a service account, their options and help.
|
||||
CLIENT_OPTIONS = (
|
||||
('redirect_uris', 'redirect-uri', 'a redirect URI of the OpenID Provider client'),
|
||||
('post_logout_redirect_uris', 'post-logout-redirect-uri', 'a post-logout redirect URI of the OpenID Provider client'),
|
||||
('allowed_groups', 'allowed-group', 'a group whose members may sign in to the OpenID Provider client'),
|
||||
)
|
||||
|
||||
|
||||
class Parser(argparse.ArgumentParser):
|
||||
@@ -76,12 +99,20 @@ def parse(argv):
|
||||
if command != 'list':
|
||||
sub.add_argument('name', help='the user name')
|
||||
if command in ('add', 'update'):
|
||||
for key, _ in vpnrouter_users.PERSON:
|
||||
for key, _ in vpnrouter_users.PERSON + vpnrouter_users.LABELS:
|
||||
sub.add_argument(f'--{key.replace("_", "-")}', dest=key, metavar='TEXT', help=PERSON_HELP[key])
|
||||
for key, option, text in CLIENT_OPTIONS:
|
||||
sub.add_argument(f'--{option}', dest=key, action='append', metavar='VALUE',
|
||||
help=f"{text}, of a service account; repeat it for more, '' for none")
|
||||
if command == 'add':
|
||||
sub.add_argument('--service', action='store_true', help='add a service account, which cannot sign in to the web service')
|
||||
if command == 'otp-enrol':
|
||||
sub.add_argument('code', help='a code of the secret from the authenticator')
|
||||
if command == 'console':
|
||||
choice = sub.add_mutually_exclusive_group(required=True)
|
||||
choice.add_argument('--posix', action='store_true', help="run the console as the user's own POSIX account")
|
||||
choice.add_argument('--account', metavar='LOCAL', help='run the console as this local account')
|
||||
choice.add_argument('--off', action='store_true', help='switch console login off')
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
@@ -95,7 +126,11 @@ def run(connection, options, secret):
|
||||
command = options.command
|
||||
if command == 'list':
|
||||
return vpnrouter_users.list_users(connection)
|
||||
values = {key: getattr(options, key, None) for key, _ in vpnrouter_users.PERSON}
|
||||
values = {key: getattr(options, key, None) for key, _ in vpnrouter_users.PERSON + vpnrouter_users.LABELS}
|
||||
for key, _, _ in CLIENT_OPTIONS:
|
||||
given = getattr(options, key, None)
|
||||
if given is not None:
|
||||
values[key] = [value for value in given if value]
|
||||
if command == 'add':
|
||||
return vpnrouter_users.add(connection, options.name, secret, values, service=options.service)
|
||||
if command == 'update':
|
||||
@@ -104,9 +139,32 @@ def run(connection, options, secret):
|
||||
return vpnrouter_users.passwd(connection, options.name, secret)
|
||||
if command == 'otp-enrol':
|
||||
return vpnrouter_users.otp_enrol(connection, options.name, secret, options.code)
|
||||
if command == 'console':
|
||||
return vpnrouter_users.console(connection, options.name, not options.off, options.account)
|
||||
if command == 'passkeys-remove':
|
||||
return vpnrouter_passkeys.remove(connection, options.name)
|
||||
return getattr(vpnrouter_users, command.replace('-', '_'))(connection, options.name)
|
||||
|
||||
|
||||
def console_text(user):
|
||||
"""The console login of a user as a cell: -, off, posix (uid N) or local and the account, refused without a TOTP
|
||||
token."""
|
||||
console = user['console']
|
||||
if console is None or not console['enabled']:
|
||||
return '-' if console is None else 'off'
|
||||
text = f'local {console["account"]}' if console['local'] else f'posix (uid {console["uid"]})'
|
||||
return text if user['otp'] else f'{text}, refused: no OTP'
|
||||
|
||||
|
||||
def client_text(user):
|
||||
"""The OpenID Provider client settings of a service account as a cell: -, or the numbers of its URIs and its allowed
|
||||
groups."""
|
||||
if not user.get('redirect_uris'):
|
||||
return '-'
|
||||
groups = ','.join(user['allowed_groups']) or 'all users'
|
||||
return f'{len(user["redirect_uris"])} redirect, {len(user["post_logout_redirect_uris"])} logout, {groups}'
|
||||
|
||||
|
||||
def text_of(answer):
|
||||
"""The answer as text for people."""
|
||||
if 'profile' in answer:
|
||||
@@ -118,11 +176,13 @@ def text_of(answer):
|
||||
users = answer['users'] if 'users' in answer else [answer]
|
||||
if not users:
|
||||
return 'no users'
|
||||
head = ('USER', 'KIND', 'STATE', 'CERTIFICATE', 'OTP', 'FIRST NAME', 'LAST NAME', 'E-MAIL')
|
||||
head = ('USER', 'KIND', 'STATE', 'CERTIFICATE', 'OTP', 'PASSKEYS', 'CONSOLE', 'FIRST NAME', 'LAST NAME', 'E-MAIL',
|
||||
'OIDC CLIENT')
|
||||
rows = [(user['name'], 'internal' if user['internal'] else 'service' if user['service'] else '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 '-')
|
||||
'enrolled' if user['otp'] else '-', str(len(user['passkeys'])) if user['passkeys'] else '-', console_text(user),
|
||||
user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-', client_text(user))
|
||||
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))
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# bash completion for vpn-config-edit -*- shell-script -*-
|
||||
|
||||
# Python of the package that answers from its schema (vpnrouter.SCHEMA, vpnrouter.CONNECTION) and, read as root, the
|
||||
# site-to-site connections of the configuration: settings prints SECTION.OPTION of every setting and
|
||||
# site_to_site.NAME.OPTION of every connection, connections site_to_site.NAME, and values the values of a boolean
|
||||
# setting, $1.
|
||||
_vpn_config_edit_python()
|
||||
{
|
||||
python3 -c '
|
||||
import sys
|
||||
sys.path.insert(0, "/usr/lib/vpn-router")
|
||||
import vpnrouter
|
||||
try:
|
||||
connections = vpnrouter.read_settings().get("site_to_site", {})
|
||||
except (OSError, vpnrouter.ConfigError):
|
||||
connections = {}
|
||||
if sys.argv[1] == "settings":
|
||||
print("\n".join(f"{section}.{option}" for section, options in vpnrouter.SCHEMA.items() for option in options))
|
||||
print("\n".join(f"site_to_site.{name}.{option}" for name in connections for option in vpnrouter.CONNECTION))
|
||||
elif sys.argv[1] == "connections":
|
||||
print("\n".join(f"site_to_site.{name}" for name in connections))
|
||||
else:
|
||||
section, _, option = sys.argv[2].rpartition(".")
|
||||
defaults = vpnrouter.CONNECTION if section.startswith("site_to_site.") else vpnrouter.SCHEMA.get(section, {})
|
||||
if isinstance(defaults.get(option), bool):
|
||||
print("true\nfalse")
|
||||
' "$@" 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_config_edit()
|
||||
{
|
||||
local cur prev words cword split
|
||||
_init_completion -s || return
|
||||
|
||||
# The command is the first word that is not an option; the arguments are the words after it.
|
||||
local i command='' args=()
|
||||
for ((i = 1; i < cword; i++)); do
|
||||
if [[ -z $command ]]; then
|
||||
[[ ${words[i]} != -* ]] && command=${words[i]}
|
||||
elif [[ ${words[i]} != -* ]]; then
|
||||
args+=("${words[i]}")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $cur == -* ]]; then
|
||||
COMPREPLY=($(compgen -W "--help$([[ $command == @(set|remove) ]] && echo ' --restart')" -- "$cur"))
|
||||
return
|
||||
fi
|
||||
|
||||
case $command,${#args[@]} in
|
||||
,*) COMPREPLY=($(compgen -W 'show set remove' -- "$cur")) ;;
|
||||
set,0) COMPREPLY=($(compgen -W "$(_vpn_config_edit_python settings)" -- "$cur")) ;;
|
||||
set,1) COMPREPLY=($(compgen -W "$(_vpn_config_edit_python values "${args[0]}")" -- "$cur")) ;;
|
||||
remove,0) COMPREPLY=($(compgen -W "$(_vpn_config_edit_python connections)" -- "$cur")) ;;
|
||||
esac
|
||||
} &&
|
||||
complete -F _vpn_config_edit vpn-config-edit
|
||||
|
||||
# ex: filetype=sh
|
||||
@@ -0,0 +1,41 @@
|
||||
# bash completion for vpn-router-backup -*- shell-script -*-
|
||||
|
||||
_vpn_router_backup()
|
||||
{
|
||||
local cur prev words cword split
|
||||
_init_completion -s || return
|
||||
|
||||
# The command is the first word that is not an option; the arguments are the words after it.
|
||||
local i command='' args=0 pbs=false
|
||||
for ((i = 1; i < cword; i++)); do
|
||||
if [[ -z $command ]]; then
|
||||
[[ ${words[i]} != -* ]] && command=${words[i]}
|
||||
elif [[ ${words[i]} == --pbs ]]; then
|
||||
pbs=true
|
||||
elif [[ ${words[i]} != -* ]]; then
|
||||
((args++))
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z $command ]]; then
|
||||
if [[ $cur == -* ]]; then
|
||||
COMPREPLY=($(compgen -W '--help' -- "$cur"))
|
||||
else
|
||||
COMPREPLY=($(compgen -W 'run list restore' -- "$cur"))
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ $cur == -* ]]; then
|
||||
COMPREPLY=($(compgen -W "--help$([[ $command == restore ]] && echo ' --pbs --yes')" -- "$cur"))
|
||||
return
|
||||
fi
|
||||
|
||||
# restore takes a local archive, or with --pbs a snapshot name, which vpn-router-backup list shows.
|
||||
if [[ $command == restore ]] && ((args == 0)) && ! $pbs; then
|
||||
_filedir
|
||||
fi
|
||||
} &&
|
||||
complete -F _vpn_router_backup vpn-router-backup
|
||||
|
||||
# ex: filetype=sh
|
||||
@@ -0,0 +1,98 @@
|
||||
# bash completion for vpn-router-ca -*- shell-script -*-
|
||||
|
||||
# Python of the package that prints the revocation reasons (reasons) or, from the configuration read as root, the
|
||||
# issuing CAs (cas) or the certificate templates (templates), which other users do not get.
|
||||
_vpn_router_ca_python()
|
||||
{
|
||||
python3 -c '
|
||||
import sys
|
||||
sys.path.insert(0, "/usr/lib/vpn-router")
|
||||
import vpnrouter
|
||||
import vpnrouter_pki
|
||||
if sys.argv[1] == "reasons":
|
||||
print("\n".join(vpnrouter_pki.REASONS))
|
||||
else:
|
||||
cfg = vpnrouter.load_config()
|
||||
print("\n".join(vpnrouter.issuing_cas(cfg) if sys.argv[1] == "cas" else vpnrouter.pki_templates(cfg)))
|
||||
' "$@" 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_ca()
|
||||
{
|
||||
local cur prev words cword split
|
||||
_init_completion -s || return
|
||||
|
||||
# The command and, for ca and root, the action are the first words that are not options; the arguments are the
|
||||
# words after them but option values.
|
||||
local i command='' action='' args=0
|
||||
for ((i = 1; i < cword; i++)); do
|
||||
case ${words[i]} in
|
||||
--ca | --template | --csr | --subject | --san | --pfx | --reason | --out) ((i++)) ;;
|
||||
-*) ;;
|
||||
*)
|
||||
if [[ -z $command ]]; then
|
||||
command=${words[i]}
|
||||
elif [[ $command == @(ca|root) && -z $action ]]; then
|
||||
action=${words[i]}
|
||||
else
|
||||
((args++))
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case $prev in
|
||||
--ca)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_ca_python cas)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--template)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_ca_python templates)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--reason)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_ca_python reasons)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--csr | --pfx | --out)
|
||||
_filedir
|
||||
return
|
||||
;;
|
||||
--subject | --san)
|
||||
return
|
||||
;;
|
||||
esac
|
||||
$split && return
|
||||
|
||||
if [[ -z $command ]]; then
|
||||
if [[ $cur == -* ]]; then
|
||||
COMPREPLY=($(compgen -W '--json --help' -- "$cur"))
|
||||
else
|
||||
COMPREPLY=($(compgen -W 'status list issue revoke maintain ca root' -- "$cur"))
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ $cur == -* ]]; then
|
||||
local options='--help'
|
||||
case $command$action in
|
||||
list) options+=' --ca' ;;
|
||||
issue) options+=' --ca --template --csr --subject --san --pfx' ;;
|
||||
revoke) options+=' --reason' ;;
|
||||
rootexport-key) options+=' --out' ;;
|
||||
rootremove-key) options+=' --yes' ;;
|
||||
esac
|
||||
COMPREPLY=($(compgen -W "$options" -- "$cur"))
|
||||
return
|
||||
fi
|
||||
|
||||
case $command,$action,$args in
|
||||
ca,,*) COMPREPLY=($(compgen -W 'csr import' -- "$cur")) ;;
|
||||
root,,*) COMPREPLY=($(compgen -W 'export-key remove-key import-key import-crl' -- "$cur")) ;;
|
||||
ca,*,0) COMPREPLY=($(compgen -W "$(_vpn_router_ca_python cas)" -- "$cur")) ;;
|
||||
ca,import,1 | root,import-key,0 | root,import-crl,0) _filedir ;;
|
||||
esac
|
||||
} &&
|
||||
complete -F _vpn_router_ca vpn-router-ca
|
||||
|
||||
# ex: filetype=sh
|
||||
@@ -0,0 +1,24 @@
|
||||
# bash completion for vpn-router-caddy -*- shell-script -*-
|
||||
|
||||
_vpn_router_caddy()
|
||||
{
|
||||
local cur prev words cword
|
||||
_init_completion || return
|
||||
|
||||
# After the command only --help is left.
|
||||
local word
|
||||
for word in "${words[@]:1:cword-1}"; do
|
||||
if [[ $word != -* ]]; then
|
||||
[[ $cur == -* ]] && COMPREPLY=($(compgen -W '--help' -- "$cur"))
|
||||
return
|
||||
fi
|
||||
done
|
||||
if [[ $cur == -* ]]; then
|
||||
COMPREPLY=($(compgen -W '--json --help' -- "$cur"))
|
||||
else
|
||||
COMPREPLY=($(compgen -W 'status apply use-upstream' -- "$cur"))
|
||||
fi
|
||||
} &&
|
||||
complete -F _vpn_router_caddy vpn-router-caddy
|
||||
|
||||
# ex: filetype=sh
|
||||
@@ -0,0 +1,74 @@
|
||||
# bash completion for vpn-router-group -*- shell-script -*-
|
||||
|
||||
# The names of the users and of the groups of the router's directory, read as root; nothing for other users.
|
||||
_vpn_router_group_users()
|
||||
{
|
||||
vpn-router-user --json list 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(user["name"] for user in json.load(sys.stdin)["users"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_group_names()
|
||||
{
|
||||
vpn-router-group --json list 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(group["name"] for group in json.load(sys.stdin)["groups"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_group()
|
||||
{
|
||||
local cur prev words cword split
|
||||
_init_completion -s || return
|
||||
|
||||
# The command is the first word that is not an option; the arguments are the words after it but option values.
|
||||
local i command='' args=0
|
||||
for ((i = 1; i < cword; i++)); do
|
||||
if [[ -z $command ]]; then
|
||||
[[ ${words[i]} != -* ]] && command=${words[i]}
|
||||
continue
|
||||
fi
|
||||
case ${words[i]} in
|
||||
--member | --description | --name) ((i++)) ;;
|
||||
-*) ;;
|
||||
*) ((args++)) ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z $command ]]; then
|
||||
if [[ $cur == -* ]]; then
|
||||
COMPREPLY=($(compgen -W '--json --help' -- "$cur"))
|
||||
else
|
||||
COMPREPLY=($(compgen -W 'list add update remove member-add member-remove' -- "$cur"))
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
case $prev in
|
||||
--member)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_group_users)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--description | --name)
|
||||
return
|
||||
;;
|
||||
esac
|
||||
$split && return
|
||||
|
||||
if [[ $cur == -* ]]; then
|
||||
local options='--help'
|
||||
case $command in
|
||||
add) options+=' --member --description' ;;
|
||||
update) options+=' --name --description' ;;
|
||||
esac
|
||||
COMPREPLY=($(compgen -W "$options" -- "$cur"))
|
||||
return
|
||||
fi
|
||||
|
||||
# The group, which add takes new, then the user of member-add and member-remove.
|
||||
if ((args == 0)) && [[ $command != @(list|add) ]]; then
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_group_names)" -- "$cur"))
|
||||
elif ((args == 1)) && [[ $command == member-@(add|remove) ]]; then
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_group_users)" -- "$cur"))
|
||||
fi
|
||||
} &&
|
||||
complete -F _vpn_router_group vpn-router-group
|
||||
|
||||
# ex: filetype=sh
|
||||
@@ -0,0 +1,95 @@
|
||||
# bash completion for vpn-router-site -*- shell-script -*-
|
||||
|
||||
# The names of the sites, of the groups and of the general-purpose issuing CAs, read as root; nothing for other users.
|
||||
_vpn_router_site_names()
|
||||
{
|
||||
vpn-router-site --json list 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(site["name"] for site in json.load(sys.stdin)["sites"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_site_groups()
|
||||
{
|
||||
vpn-router-group --json list 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(group["name"] for group in json.load(sys.stdin)["groups"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_site_cas()
|
||||
{
|
||||
python3 -c 'import sys; sys.path.insert(0, "/usr/lib/vpn-router"); import vpnrouter
|
||||
print("\n".join(vpnrouter.load_config()["pki"]["issuing_cas"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_site()
|
||||
{
|
||||
local cur prev words cword split
|
||||
_init_completion -s || return
|
||||
|
||||
# The command is the first word that is not an option; the arguments are the words after it but option values.
|
||||
local i command='' args=0
|
||||
for ((i = 1; i < cword; i++)); do
|
||||
if [[ -z $command ]]; then
|
||||
[[ ${words[i]} != -* ]] && command=${words[i]}
|
||||
continue
|
||||
fi
|
||||
case ${words[i]} in
|
||||
--host | --port | --listen | --tls | --ca | --certificate | --key | --index | --editors) ((i++)) ;;
|
||||
-*) ;;
|
||||
*) ((args++)) ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z $command ]]; then
|
||||
if [[ $cur == -* ]]; then
|
||||
COMPREPLY=($(compgen -W '--json --help' -- "$cur"))
|
||||
else
|
||||
COMPREPLY=($(compgen -W 'list show add update remove' -- "$cur"))
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
case $prev in
|
||||
--listen)
|
||||
COMPREPLY=($(compgen -W 'internal external both' -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--tls)
|
||||
COMPREPLY=($(compgen -W 'none acme ca file' -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--ca)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_site_cas)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--editors)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_site_groups)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--certificate | --key)
|
||||
_filedir
|
||||
return
|
||||
;;
|
||||
--host | --port | --index)
|
||||
return
|
||||
;;
|
||||
esac
|
||||
$split && return
|
||||
|
||||
if [[ $cur == -* ]]; then
|
||||
local options='--help'
|
||||
case $command in
|
||||
add | update) options+=' --host --no-hosts --port --listen --tls --ca --certificate --key --index --spa
|
||||
--no-spa --enabled --no-enabled --editors' ;;
|
||||
remove) options+=' --content' ;;
|
||||
esac
|
||||
COMPREPLY=($(compgen -W "$options" -- "$cur"))
|
||||
return
|
||||
fi
|
||||
|
||||
# The site, which add takes new.
|
||||
if ((args == 0)) && [[ $command != @(list|add) ]]; then
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_site_names)" -- "$cur"))
|
||||
fi
|
||||
} &&
|
||||
complete -F _vpn_router_site vpn-router-site
|
||||
|
||||
# ex: filetype=sh
|
||||
@@ -0,0 +1,25 @@
|
||||
# bash completion for vpn-router-token -*- shell-script -*-
|
||||
|
||||
_vpn_router_token()
|
||||
{
|
||||
local cur prev words cword split
|
||||
_init_completion -s || return
|
||||
|
||||
case $prev in
|
||||
--minutes | --hours | --days)
|
||||
return
|
||||
;;
|
||||
esac
|
||||
$split && return
|
||||
|
||||
# The lifetime is one of --minutes, --hours and --days.
|
||||
local options='--json --help' word
|
||||
for word in "${words[@]:1:cword-1}"; do
|
||||
[[ $word == --@(minutes|hours|days)* ]] && break
|
||||
done
|
||||
[[ $word == --@(minutes|hours|days)* ]] || options+=' --minutes --hours --days'
|
||||
COMPREPLY=($(compgen -W "$options" -- "$cur"))
|
||||
} &&
|
||||
complete -F _vpn_router_token vpn-router-token
|
||||
|
||||
# ex: filetype=sh
|
||||
@@ -0,0 +1,84 @@
|
||||
# bash completion for vpn-router-user -*- shell-script -*-
|
||||
|
||||
# The names of the users and of the groups of the router's directory, read as root; nothing for other users.
|
||||
_vpn_router_user_names()
|
||||
{
|
||||
vpn-router-user --json list 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(user["name"] for user in json.load(sys.stdin)["users"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_user_groups()
|
||||
{
|
||||
vpn-router-group --json list 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(group["name"] for group in json.load(sys.stdin)["groups"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_user()
|
||||
{
|
||||
local cur prev words cword split
|
||||
_init_completion -s || return
|
||||
|
||||
local commands='list add update passwd disable enable unlock remove profile otp-new otp-enrol otp-remove console
|
||||
passkeys-remove'
|
||||
|
||||
# The command is the first word that is not an option; the arguments are the words after it but option values.
|
||||
local i command='' args=0
|
||||
for ((i = 1; i < cword; i++)); do
|
||||
if [[ -z $command ]]; then
|
||||
[[ ${words[i]} != -* ]] && command=${words[i]}
|
||||
continue
|
||||
fi
|
||||
case ${words[i]} in
|
||||
--first-name | --last-name | --email | --display-name | --description | --redirect-uri | \
|
||||
--post-logout-redirect-uri | --allowed-group | --account) ((i++)) ;;
|
||||
-*) ;;
|
||||
*) ((args++)) ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z $command ]]; then
|
||||
if [[ $cur == -* ]]; then
|
||||
COMPREPLY=($(compgen -W '--json --help' -- "$cur"))
|
||||
else
|
||||
COMPREPLY=($(compgen -W "$commands" -- "$cur"))
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
case $prev in
|
||||
--allowed-group)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_user_groups)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--account)
|
||||
# The local accounts console --account takes: a uid from 1000 to 19999, not nobody.
|
||||
COMPREPLY=($(compgen -W "$(getent passwd | awk -F: '$3 >= 1000 && $3 < 20000 && $1 != "nobody" { print $1 }')" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--first-name | --last-name | --email | --display-name | --description | --redirect-uri | --post-logout-redirect-uri)
|
||||
return
|
||||
;;
|
||||
esac
|
||||
$split && return
|
||||
|
||||
if [[ $cur == -* ]]; then
|
||||
local options='--help'
|
||||
case $command in
|
||||
add) options+=' --first-name --last-name --email --service --display-name --description --redirect-uri
|
||||
--post-logout-redirect-uri --allowed-group' ;;
|
||||
update) options+=' --first-name --last-name --email --display-name --description --redirect-uri
|
||||
--post-logout-redirect-uri --allowed-group' ;;
|
||||
console) options+=' --posix --account --off' ;;
|
||||
esac
|
||||
COMPREPLY=($(compgen -W "$options" -- "$cur"))
|
||||
return
|
||||
fi
|
||||
|
||||
# The user name, which add takes new; otp-enrol then takes a code.
|
||||
if ((args == 0)) && [[ $command != @(list|add) ]]; then
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_user_names)" -- "$cur"))
|
||||
fi
|
||||
} &&
|
||||
complete -F _vpn_router_user vpn-router-user
|
||||
|
||||
# ex: filetype=sh
|
||||
@@ -22,14 +22,24 @@
|
||||
"cidrs": ["192.168.0.0/24"],
|
||||
"local_cidrs": ["10.0.0.0/24"],
|
||||
"local_id_mode": "fqdn",
|
||||
"auth": "psk",
|
||||
"psk": "change-me",
|
||||
"psk_file": ""
|
||||
"psk_file": "",
|
||||
"remote_ca": ""
|
||||
},
|
||||
"branch": {
|
||||
"addrs": ["branch.example.org"],
|
||||
"id": "branch.example.org",
|
||||
"cidrs": ["192.168.10.0/24"],
|
||||
"local_cidrs": ["10.0.0.0/24"],
|
||||
"local_id_mode": "fqdn",
|
||||
"auth": "certificate"
|
||||
}
|
||||
},
|
||||
"p2s": {
|
||||
"enabled": false,
|
||||
"address_pool": "172.16.0.0/24",
|
||||
"ca_name": "VPN Router CA"
|
||||
"dns_listen": "internal"
|
||||
},
|
||||
"wireguard": {
|
||||
"enabled": false,
|
||||
@@ -52,13 +62,65 @@
|
||||
"port": 7081,
|
||||
"database": "",
|
||||
"trusted_proxies": [],
|
||||
"status_content": "basic"
|
||||
"status_content": "basic",
|
||||
"shell": false,
|
||||
"shell_idle_minutes": 15,
|
||||
"shell_auth_timeout": 15,
|
||||
"log_files": ["/var/log/syslog", "/var/log/auth.log"]
|
||||
},
|
||||
"ssh": {
|
||||
"ldap_login": false
|
||||
},
|
||||
"https": {
|
||||
"enabled": true,
|
||||
"email": "",
|
||||
"web_port": 7444,
|
||||
"manager_port": 7443
|
||||
"manager_port": 7443,
|
||||
"caddy_source": "ubuntu"
|
||||
},
|
||||
"oidc": {
|
||||
"enabled": false
|
||||
},
|
||||
"sites": {
|
||||
"enabled": false,
|
||||
"upload_max_mb": 512,
|
||||
"webdav": false,
|
||||
"webdav_port": 7445
|
||||
},
|
||||
"dns": {
|
||||
"enabled": false,
|
||||
"zone": "",
|
||||
"nameserver": "",
|
||||
"addresses": [],
|
||||
"listen": "external",
|
||||
"ttl": 300
|
||||
},
|
||||
"pki": {
|
||||
"root_name": "VPN Router Root CA",
|
||||
"root_days": 7300,
|
||||
"ca_days": 3650,
|
||||
"key": "rsa-4096",
|
||||
"crl_days": 7,
|
||||
"root_crl_days": 180,
|
||||
"publish": ["http"],
|
||||
"p2s_name": "VPN Router P2S CA",
|
||||
"s2s_name": "VPN Router Site-to-Site CA",
|
||||
"issuing_cas": {
|
||||
"servers": {
|
||||
"name": "VPN Router Server CA",
|
||||
"templates": ["server"],
|
||||
"acme": false
|
||||
}
|
||||
},
|
||||
"templates": {
|
||||
"server": {
|
||||
"days": 397
|
||||
}
|
||||
},
|
||||
"extended_key_usages": {
|
||||
"smartcard_logon": "1.3.6.1.4.1.311.20.2.2"
|
||||
},
|
||||
"root_key_warning": true
|
||||
},
|
||||
"metrics": {
|
||||
"enabled": true,
|
||||
@@ -74,5 +136,24 @@
|
||||
"keep_daily": "3 months",
|
||||
"keep_monthly": "1 year",
|
||||
"keep_yearly": "10 years"
|
||||
},
|
||||
"backup": {
|
||||
"enabled": false,
|
||||
"schedule": "daily",
|
||||
"target": "local",
|
||||
"path": "/var/backups/vpn-router",
|
||||
"keep_hourly": 24,
|
||||
"keep_daily": 7,
|
||||
"keep_weekly": 4,
|
||||
"keep_monthly": 12,
|
||||
"keep_yearly": 0,
|
||||
"metrics": false,
|
||||
"rclone_remote": "",
|
||||
"rclone_mode": "sync",
|
||||
"pbs_repository": "",
|
||||
"pbs_password": "",
|
||||
"pbs_fingerprint": "",
|
||||
"pbs_namespace": "",
|
||||
"pbs_keyfile": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
-- The zone the router's name server (PowerDNS, gpgsql backend) answers for (vpnrouter_dns), in the vpn-router-dns
|
||||
-- database, whose domains and records tables are PowerDNS's own schema.
|
||||
-- The whole file runs as one transaction, serialized by an advisory lock, so that two processes can load it at once.
|
||||
|
||||
SELECT pg_advisory_xact_lock(hashtext('vpn-router dns schema'));
|
||||
|
||||
-- A record an administrator added to the zone, besides those of the zone itself and of the sites.
|
||||
CREATE TABLE IF NOT EXISTS dns_manual_records (
|
||||
name text NOT NULL,
|
||||
type text NOT NULL,
|
||||
content text NOT NULL,
|
||||
ttl integer NOT NULL,
|
||||
PRIMARY KEY (name, type, content)
|
||||
);
|
||||
|
||||
-- The digest of the records last written to the zone and the serial of its SOA.
|
||||
CREATE TABLE IF NOT EXISTS dns_state (
|
||||
zone text PRIMARY KEY,
|
||||
digest text NOT NULL,
|
||||
serial bigint NOT NULL
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION dns_manual_list()
|
||||
RETURNS TABLE (name text, type text, content text, ttl integer)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT name, type, content, ttl FROM dns_manual_records ORDER BY name, type, content;
|
||||
$$;
|
||||
|
||||
-- Add the record, or set its TTL.
|
||||
CREATE OR REPLACE PROCEDURE dns_manual_put(p_name text, p_type text, p_content text, p_ttl integer)
|
||||
LANGUAGE sql AS $$
|
||||
INSERT INTO dns_manual_records VALUES (p_name, p_type, p_content, p_ttl)
|
||||
ON CONFLICT (name, type, content) DO UPDATE SET ttl = p_ttl;
|
||||
$$;
|
||||
|
||||
-- Remove the records of the name and type, all of them without content.
|
||||
CREATE OR REPLACE PROCEDURE dns_manual_remove(p_name text, p_type text, p_content text)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM dns_manual_records WHERE name = p_name AND type = p_type AND (p_content IS NULL OR content = p_content);
|
||||
$$;
|
||||
|
||||
-- Make the zone the only one served and give it the records, its SOA first, whose content has {serial} for the serial.
|
||||
-- The serial grows, to at least the current Unix time, only when the records differ from those last written.
|
||||
CREATE OR REPLACE PROCEDURE dns_apply(p_zone text, p_soa text, p_ttl integer, p_names text[], p_types text[],
|
||||
p_contents text[], p_ttls integer[])
|
||||
LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
v_domain integer;
|
||||
v_serial bigint;
|
||||
v_digest text := md5(concat_ws('|', p_soa, p_ttl, array_to_string(p_names, ','), array_to_string(p_types, ','),
|
||||
array_to_string(p_contents, ','), array_to_string(p_ttls, ',')));
|
||||
BEGIN
|
||||
DELETE FROM domains WHERE name <> p_zone;
|
||||
DELETE FROM dns_state WHERE zone <> p_zone;
|
||||
SELECT id INTO v_domain FROM domains WHERE name = p_zone;
|
||||
IF v_domain IS NULL THEN
|
||||
INSERT INTO domains (name, type) VALUES (p_zone, 'NATIVE') RETURNING id INTO v_domain;
|
||||
ELSIF EXISTS (SELECT 1 FROM dns_state WHERE zone = p_zone AND digest = v_digest) THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
SELECT greatest(coalesce(max(serial) + 1, 1), extract(epoch FROM now())::bigint) INTO v_serial
|
||||
FROM dns_state WHERE zone = p_zone;
|
||||
DELETE FROM records WHERE domain_id = v_domain;
|
||||
INSERT INTO records (domain_id, name, type, content, ttl, prio, disabled, auth)
|
||||
VALUES (v_domain, p_zone, 'SOA', replace(p_soa, '{serial}', v_serial::text), p_ttl, 0, false, true);
|
||||
INSERT INTO records (domain_id, name, type, content, ttl, prio, disabled, auth)
|
||||
SELECT v_domain, r.name, r.type, r.content, r.ttl, 0, false, true
|
||||
FROM unnest(p_names, p_types, p_contents, p_ttls) AS r(name, type, content, ttl);
|
||||
INSERT INTO dns_state VALUES (p_zone, v_digest, v_serial)
|
||||
ON CONFLICT (zone) DO UPDATE SET digest = v_digest, serial = v_serial;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Serve no zone.
|
||||
CREATE OR REPLACE PROCEDURE dns_clear()
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM domains;
|
||||
DELETE FROM dns_state;
|
||||
$$;
|
||||
|
||||
-- The records of the zone as they are served.
|
||||
CREATE OR REPLACE FUNCTION dns_served(p_zone text)
|
||||
RETURNS TABLE (name text, type text, content text, ttl integer)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT r.name, r.type, r.content, r.ttl FROM records r JOIN domains d ON d.id = r.domain_id
|
||||
WHERE d.name = p_zone ORDER BY r.type <> 'SOA', r.name, r.type, r.content;
|
||||
$$;
|
||||
@@ -0,0 +1,113 @@
|
||||
-- The state of the router's OpenID Provider (vpnrouter_oidc) in the database of web.database.
|
||||
-- Codes, tokens and sessions are kept as the SHA-256 of their values; expired rows are purged when new ones are saved.
|
||||
-- The whole file runs as one transaction, serialized by an advisory lock, so that two processes can load it at once.
|
||||
|
||||
SELECT pg_advisory_xact_lock(hashtext('vpn-router oidc schema'));
|
||||
|
||||
-- An authorization code, valid until it is exchanged for tokens or expires.
|
||||
CREATE TABLE IF NOT EXISTS oidc_codes (
|
||||
hash text PRIMARY KEY,
|
||||
client_id text NOT NULL,
|
||||
name text NOT NULL,
|
||||
redirect_uri text NOT NULL,
|
||||
scopes text[] NOT NULL,
|
||||
nonce text,
|
||||
code_challenge text NOT NULL,
|
||||
code_challenge_method text NOT NULL,
|
||||
auth_time timestamptz NOT NULL,
|
||||
amr text[] NOT NULL,
|
||||
expires timestamptz NOT NULL
|
||||
);
|
||||
|
||||
-- An access token or a refresh token (kind) a client was issued for a user.
|
||||
CREATE TABLE IF NOT EXISTS oidc_tokens (
|
||||
hash text PRIMARY KEY,
|
||||
kind text NOT NULL CHECK (kind IN ('access', 'refresh')),
|
||||
client_id text NOT NULL,
|
||||
name text NOT NULL,
|
||||
scopes text[] NOT NULL,
|
||||
auth_time timestamptz NOT NULL,
|
||||
amr text[] NOT NULL,
|
||||
expires timestamptz NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS oidc_tokens_name ON oidc_tokens (name);
|
||||
|
||||
-- A user's sign-in at the OpenID Provider, which the session cookie names.
|
||||
CREATE TABLE IF NOT EXISTS oidc_sessions (
|
||||
hash text PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
auth_time timestamptz NOT NULL,
|
||||
amr text[] NOT NULL,
|
||||
expires timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE OR REPLACE PROCEDURE oidc_code_save(p_hash text, p_client_id text, p_name text, p_redirect_uri text,
|
||||
p_scopes text[], p_nonce text, p_code_challenge text,
|
||||
p_code_challenge_method text, p_auth_time timestamptz, p_amr text[],
|
||||
p_seconds integer)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_codes WHERE expires < now();
|
||||
INSERT INTO oidc_codes VALUES (p_hash, p_client_id, p_name, p_redirect_uri, p_scopes, p_nonce, p_code_challenge,
|
||||
p_code_challenge_method, p_auth_time, p_amr, now() + make_interval(secs => p_seconds));
|
||||
$$;
|
||||
|
||||
-- The unexpired code.
|
||||
CREATE OR REPLACE FUNCTION oidc_code_get(p_hash text)
|
||||
RETURNS TABLE (client_id text, name text, redirect_uri text, scopes text[], nonce text, code_challenge text,
|
||||
code_challenge_method text, auth_time timestamptz, amr text[])
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT client_id, name, redirect_uri, scopes, nonce, code_challenge, code_challenge_method, auth_time, amr
|
||||
FROM oidc_codes WHERE hash = p_hash AND expires > now();
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE oidc_code_delete(p_hash text)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_codes WHERE hash = p_hash;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE oidc_token_save(p_hash text, p_kind text, p_client_id text, p_name text, p_scopes text[],
|
||||
p_auth_time timestamptz, p_amr text[], p_seconds integer)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_tokens WHERE expires < now();
|
||||
INSERT INTO oidc_tokens VALUES (p_hash, p_kind, p_client_id, p_name, p_scopes, p_auth_time, p_amr,
|
||||
now() + make_interval(secs => p_seconds));
|
||||
$$;
|
||||
|
||||
-- The unexpired token of the kind.
|
||||
CREATE OR REPLACE FUNCTION oidc_token_get(p_hash text, p_kind text)
|
||||
RETURNS TABLE (client_id text, name text, scopes text[], auth_time timestamptz, amr text[])
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT client_id, name, scopes, auth_time, amr FROM oidc_tokens
|
||||
WHERE hash = p_hash AND kind = p_kind AND expires > now();
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE oidc_token_delete(p_hash text)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_tokens WHERE hash = p_hash;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE oidc_session_save(p_hash text, p_name text, p_amr text[], p_seconds integer)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_sessions WHERE expires < now();
|
||||
INSERT INTO oidc_sessions VALUES (p_hash, p_name, now(), p_amr, now() + make_interval(secs => p_seconds));
|
||||
$$;
|
||||
|
||||
-- The unexpired session.
|
||||
CREATE OR REPLACE FUNCTION oidc_session_get(p_hash text)
|
||||
RETURNS TABLE (name text, auth_time timestamptz, amr text[])
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT name, auth_time, amr FROM oidc_sessions WHERE hash = p_hash AND expires > now();
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE oidc_session_delete(p_hash text)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_sessions WHERE hash = p_hash;
|
||||
$$;
|
||||
|
||||
-- Remove every code, token and session of the user.
|
||||
CREATE OR REPLACE PROCEDURE oidc_revoke(p_name text)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_codes WHERE name = p_name;
|
||||
DELETE FROM oidc_tokens WHERE name = p_name;
|
||||
DELETE FROM oidc_sessions WHERE name = p_name;
|
||||
$$;
|
||||
@@ -0,0 +1,85 @@
|
||||
-- The web sites Caddy serves (vpnrouter_sites), in the vpn-router-sites database.
|
||||
-- The whole file runs as one transaction, serialized by an advisory lock, so that two processes can load it at once.
|
||||
|
||||
SELECT pg_advisory_xact_lock(hashtext('vpn-router sites schema'));
|
||||
|
||||
-- A static web site: its host names, port, interfaces and TLS, and the group whose members change its files.
|
||||
CREATE TABLE IF NOT EXISTS sites (
|
||||
name text PRIMARY KEY,
|
||||
enabled boolean NOT NULL,
|
||||
hosts text[] NOT NULL,
|
||||
port integer NOT NULL,
|
||||
listen text NOT NULL CHECK (listen IN ('internal', 'external', 'both')),
|
||||
tls text NOT NULL CHECK (tls IN ('none', 'acme', 'ca', 'file')),
|
||||
ca text NOT NULL,
|
||||
certificate text NOT NULL,
|
||||
private_key text NOT NULL,
|
||||
index_file text NOT NULL,
|
||||
spa boolean NOT NULL,
|
||||
editors text NOT NULL,
|
||||
modified timestamptz NOT NULL
|
||||
);
|
||||
|
||||
-- Every site, by name; the private key is left out.
|
||||
CREATE OR REPLACE FUNCTION sites_list()
|
||||
RETURNS TABLE (name text, enabled boolean, hosts text[], port integer, listen text, tls text, ca text,
|
||||
certificate text, index_file text, spa boolean, editors text, modified timestamptz)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, index_file, spa, editors, modified
|
||||
FROM sites ORDER BY name;
|
||||
$$;
|
||||
|
||||
-- The site with its private key.
|
||||
CREATE OR REPLACE FUNCTION site_get(p_name text)
|
||||
RETURNS TABLE (name text, enabled boolean, hosts text[], port integer, listen text, tls text, ca text,
|
||||
certificate text, private_key text, index_file text, spa boolean, editors text, modified timestamptz)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, private_key, index_file, spa, editors, modified
|
||||
FROM sites WHERE name = p_name;
|
||||
$$;
|
||||
|
||||
-- The enabled sites with their private keys, by name, for Caddy's configuration and certificate files.
|
||||
CREATE OR REPLACE FUNCTION sites_served()
|
||||
RETURNS TABLE (name text, enabled boolean, hosts text[], port integer, listen text, tls text, ca text,
|
||||
certificate text, private_key text, index_file text, spa boolean, editors text, modified timestamptz)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, private_key, index_file, spa, editors, modified
|
||||
FROM sites WHERE enabled ORDER BY name;
|
||||
$$;
|
||||
|
||||
-- Add the site, or replace all its settings.
|
||||
CREATE OR REPLACE PROCEDURE site_put(p_name text, p_enabled boolean, p_hosts text[], p_port integer, p_listen text,
|
||||
p_tls text, p_ca text, p_certificate text, p_private_key text, p_index_file text,
|
||||
p_spa boolean, p_editors text)
|
||||
LANGUAGE sql AS $$
|
||||
INSERT INTO sites VALUES (p_name, p_enabled, p_hosts, p_port, p_listen, p_tls, p_ca, p_certificate, p_private_key,
|
||||
p_index_file, p_spa, p_editors, now())
|
||||
ON CONFLICT (name) DO UPDATE SET enabled = p_enabled, hosts = p_hosts, port = p_port, listen = p_listen,
|
||||
tls = p_tls, ca = p_ca, certificate = p_certificate, private_key = p_private_key, index_file = p_index_file,
|
||||
spa = p_spa, editors = p_editors, modified = now();
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE site_remove(p_name text)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM sites WHERE name = p_name;
|
||||
$$;
|
||||
|
||||
-- The names of the sites whose files the members of the groups may change.
|
||||
CREATE OR REPLACE FUNCTION sites_editable(p_groups text[])
|
||||
RETURNS TABLE (name text)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT name FROM sites WHERE editors <> '' AND editors = ANY (p_groups) ORDER BY name;
|
||||
$$;
|
||||
|
||||
-- The names of the sites whose editors are the group.
|
||||
CREATE OR REPLACE FUNCTION sites_of_group(p_group text)
|
||||
RETURNS TABLE (name text)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT name FROM sites WHERE editors = p_group ORDER BY name;
|
||||
$$;
|
||||
|
||||
-- Give the sites of a renamed group its new name.
|
||||
CREATE OR REPLACE PROCEDURE sites_rename_group(p_old text, p_new text)
|
||||
LANGUAGE sql AS $$
|
||||
UPDATE sites SET editors = p_new, modified = now() WHERE editors = p_old;
|
||||
$$;
|
||||
@@ -1,22 +1,107 @@
|
||||
# Written by vpn-router-setup from /etc/vpn-router/vpn-router.json; edits are overwritten.
|
||||
# Written by vpn-router-setup from /etc/vpn-router/vpn-router.json and the web sites; edits are overwritten.
|
||||
{
|
||||
auto_https disable_redirects
|
||||
{%- if https_email %}
|
||||
email {{ https_email }}
|
||||
{%- endif %}
|
||||
{%- if pki_cas %}
|
||||
skip_install_trust
|
||||
pki {
|
||||
{%- for ca in pki_cas %}
|
||||
ca {{ ca.id }} {
|
||||
name "{{ ca.name }}"
|
||||
root {
|
||||
cert {{ ca.cert }}
|
||||
key {{ ca.key }}
|
||||
}
|
||||
intermediate {
|
||||
cert {{ ca.cert }}
|
||||
key {{ ca.key }}
|
||||
}
|
||||
}
|
||||
{%- endfor %}
|
||||
}
|
||||
{%- endif %}
|
||||
}
|
||||
{% if web_enabled %}
|
||||
{% if https and (web_enabled or 'https' in pki_publish or acme_cas) %}
|
||||
{{ local_fqdn }}:{{ https_web_port }} {
|
||||
reverse_proxy {{ loopback }}:{{ web_port }}
|
||||
{%- if 'https' in pki_publish %}
|
||||
handle_path /pki/* {
|
||||
root * {{ pki_publish_dir }}
|
||||
file_server
|
||||
}
|
||||
{%- endif %}
|
||||
{%- for ca in acme_cas %}
|
||||
handle /acme/{{ ca }}/* {
|
||||
acme_server {
|
||||
ca {{ ca }}
|
||||
}
|
||||
}
|
||||
{%- endfor %}
|
||||
handle {
|
||||
{%- if web_enabled %}
|
||||
reverse_proxy {{ loopback }}:{{ web_port }}
|
||||
{%- else %}
|
||||
respond 404
|
||||
{%- endif %}
|
||||
}
|
||||
}
|
||||
{% endif %}
|
||||
{%- if manager_port %}
|
||||
{%- if https and 'http-80' in pki_publish %}
|
||||
http://{{ local_fqdn }} {
|
||||
handle_path /pki/* {
|
||||
root * {{ pki_publish_dir }}
|
||||
file_server
|
||||
}
|
||||
handle {
|
||||
respond 404
|
||||
}
|
||||
}
|
||||
{% endif %}
|
||||
{%- if https and 'https-default' in pki_publish %}
|
||||
{{ local_fqdn }}:443 {
|
||||
handle_path /pki/* {
|
||||
root * {{ pki_publish_dir }}
|
||||
file_server
|
||||
}
|
||||
handle {
|
||||
respond 404
|
||||
}
|
||||
}
|
||||
{% endif %}
|
||||
{%- if https and manager_port %}
|
||||
{{ local_fqdn }}:{{ https_manager_port }} {
|
||||
reverse_proxy {{ loopback }}:{{ manager_port }}
|
||||
}
|
||||
{% endif %}
|
||||
{%- if metrics_enabled %}
|
||||
{%- if https and metrics_enabled %}
|
||||
{{ local_fqdn }}:{{ metrics_https_port }} {
|
||||
reverse_proxy {{ loopback }}:{{ metrics_port }}
|
||||
}
|
||||
{% endif %}
|
||||
{%- if webdav %}
|
||||
{{ local_fqdn }}:{{ webdav_port }} {
|
||||
reverse_proxy {{ loopback }}:{{ webdav_loopback_port }}
|
||||
}
|
||||
{% endif %}
|
||||
{%- for site in sites %}
|
||||
# Site {{ site.name }}
|
||||
{{ site.addresses | join(', ') }} {
|
||||
root * {{ site.root }}
|
||||
{%- if site.tls == 'ca' %}
|
||||
tls {
|
||||
issuer internal {
|
||||
ca {{ site.ca }}
|
||||
}
|
||||
}
|
||||
{%- elif site.tls == 'file' %}
|
||||
tls {{ site.cert }} {{ site.key }}
|
||||
{%- endif %}
|
||||
{%- if site.spa %}
|
||||
try_files {path} /{{ site.index }}
|
||||
{%- endif %}
|
||||
file_server {
|
||||
index {{ site.index }}
|
||||
}
|
||||
}
|
||||
{% endfor %}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Written by vpn-router-setup from /etc/vpn-router/vpn-router.json; edits are overwritten.
|
||||
# The resolver of the remote access clients, on the addresses of p2s.dns_listen, forwarding to systemd-resolved's stub.
|
||||
{% for address in p2s_resolver_addrs -%}
|
||||
listen-address={{ address }}
|
||||
{% endfor -%}
|
||||
bind-interfaces
|
||||
no-resolv
|
||||
server=127.0.0.53
|
||||
domain-needed
|
||||
no-hosts
|
||||
cache-size=1000
|
||||
@@ -0,0 +1,74 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>{{ title }}</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; background: #f4f5f7; color: #1f2937; margin: 0; }
|
||||
main { max-width: 22rem; margin: 4rem auto; background: #fff; padding: 2rem; border-radius: 0.5rem; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); }
|
||||
h1 { font-size: 1.25rem; margin: 0 0 0.25rem; }
|
||||
p { margin: 0 0 1rem; color: #4b5563; }
|
||||
label { display: block; font-size: 0.875rem; margin: 0.75rem 0 0.25rem; }
|
||||
input { box-sizing: border-box; width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 0.25rem; font-size: 1rem; }
|
||||
button { width: 100%; margin-top: 1.25rem; padding: 0.6rem; border: 0; border-radius: 0.25rem; font-size: 1rem; cursor: pointer; background: #1d4ed8; color: #fff; }
|
||||
button.secondary { background: #fff; color: #1d4ed8; border: 1px solid #1d4ed8; margin-top: 0.75rem; }
|
||||
.error { background: #fee2e2; color: #991b1b; padding: 0.5rem 0.75rem; border-radius: 0.25rem; margin-bottom: 1rem; }
|
||||
.issuer { font-size: 0.75rem; color: #6b7280; margin: 1.5rem 0 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>{{ title }}</h1>
|
||||
{%- if message %}
|
||||
<p>{{ message }}</p>
|
||||
{%- endif %}
|
||||
{%- if error %}
|
||||
<div class="error" role="alert">{{ error }}</div>
|
||||
{%- endif %}
|
||||
{%- if form %}
|
||||
<form method="post" action="/oidc/login">
|
||||
<input type="hidden" name="request" value="{{ request }}">
|
||||
<label for="name">Name</label>
|
||||
<input id="name" name="name" value="{{ name }}" autocomplete="username webauthn" autocapitalize="none" spellcheck="false" required autofocus>
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||
<label for="code">Code from the authenticator, when you have one</label>
|
||||
<input id="code" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code">
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
<form id="passkey-form" method="post" action="/oidc/login" hidden>
|
||||
<input type="hidden" name="request" value="{{ request }}">
|
||||
<input type="hidden" name="passkey_id">
|
||||
<input type="hidden" name="credential">
|
||||
</form>
|
||||
<button id="passkey" class="secondary" type="button" hidden>Sign in with a passkey</button>
|
||||
<script>
|
||||
// A passkey sign-in begins at /oidc/passkey and posts the browser's answer to /oidc/login with the authorization request.
|
||||
(function () {
|
||||
var button = document.getElementById('passkey');
|
||||
if (!window.PublicKeyCredential || !PublicKeyCredential.parseRequestOptionsFromJSON) {
|
||||
return;
|
||||
}
|
||||
button.hidden = false;
|
||||
button.addEventListener('click', async function () {
|
||||
button.disabled = true;
|
||||
try {
|
||||
var begun = await (await fetch('/oidc/passkey', { method: 'POST' })).json();
|
||||
var credential = await navigator.credentials.get({ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(begun.options.publicKey) });
|
||||
var form = document.getElementById('passkey-form');
|
||||
form.elements.passkey_id.value = begun.id;
|
||||
form.elements.credential.value = JSON.stringify(credential.toJSON());
|
||||
form.submit();
|
||||
} catch (error) {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{%- endif %}
|
||||
<p class="issuer">{{ issuer }}</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,15 +5,16 @@ dev-type tun
|
||||
topology subnet
|
||||
server {{ openvpn_network }} {{ openvpn_netmask }}
|
||||
|
||||
ca {{ ca_cert }}
|
||||
ca {{ openvpn_ca }}
|
||||
cert {{ server_cert }}
|
||||
key {{ server_key }}
|
||||
crl-verify {{ crl }}
|
||||
crl-verify {{ openvpn_crl }}
|
||||
dh none
|
||||
remote-cert-tls client
|
||||
|
||||
plugin /usr/lib/openvpn/openvpn-auth-ldap.so {{ openvpn_ldap_conf }}
|
||||
script-security 2
|
||||
tls-verify /usr/lib/vpn-router/openvpn-issuer
|
||||
auth-user-pass-verify /usr/lib/vpn-router/openvpn-same-user via-env
|
||||
|
||||
{% for network, netmask in openvpn_routes -%}
|
||||
@@ -23,6 +24,8 @@ push "route {{ network }} {{ netmask }}"
|
||||
push "dhcp-option DNS {{ address }}"
|
||||
{% endfor -%}
|
||||
|
||||
status {{ openvpn_status }} {{ openvpn_status_seconds }}
|
||||
|
||||
keepalive 10 120
|
||||
{% if openvpn_protocol == 'udp' -%}
|
||||
explicit-exit-notify 1
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
[Resolve]
|
||||
{% for address in local_addr_list -%}
|
||||
DNSStubListenerExtra={{ address }}
|
||||
{% endfor -%}
|
||||
@@ -8,13 +8,13 @@ connections {
|
||||
|
||||
local {
|
||||
auth = pubkey
|
||||
certs = server.pem
|
||||
certs = vpn-server.pem
|
||||
id = @{{ local_fqdn }}
|
||||
}
|
||||
|
||||
remote {
|
||||
auth = eap-tls
|
||||
cacerts = ca.pem
|
||||
cacerts = p2s.pem
|
||||
eap_id = %any
|
||||
}
|
||||
|
||||
|
||||
@@ -11,12 +11,22 @@ connections {
|
||||
|
||||
local {
|
||||
id = {{ connection.local_id }}
|
||||
{%- if connection.auth == 'certificate' %}
|
||||
auth = pubkey
|
||||
certs = site-to-site.pem
|
||||
{%- else %}
|
||||
auth = psk
|
||||
{%- endif %}
|
||||
}
|
||||
|
||||
remote {
|
||||
id = {{ connection.remote_id }}
|
||||
{%- if connection.auth == 'certificate' %}
|
||||
auth = pubkey
|
||||
cacerts = {{ connection.remote_cacert }}
|
||||
{%- else %}
|
||||
auth = psk
|
||||
{%- endif %}
|
||||
}
|
||||
|
||||
children {
|
||||
@@ -40,7 +50,7 @@ connections {
|
||||
}
|
||||
|
||||
secrets {
|
||||
{%- for connection in connections if connection.ready %}
|
||||
{%- for connection in connections if connection.ready and connection.auth == 'psk' %}
|
||||
ike-psk-{{ connection.name }} {
|
||||
id-1 = {{ connection.local_id }}
|
||||
id-2 = {{ connection.remote_id }}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{{ wg_peers_begin }}
|
||||
{% for user, key, ips in wg_peers -%}
|
||||
[Peer]
|
||||
# {{ user }}
|
||||
PublicKey = {{ key }}
|
||||
AllowedIPs = {{ ips | join(', ') }}
|
||||
|
||||
{% endfor -%}
|
||||
{{ wg_peers_end }}
|
||||
@@ -3,7 +3,7 @@ data "http" "repo_gpg_key" {
|
||||
}
|
||||
|
||||
locals {
|
||||
supply_pki = var.ca_cert_file != ""
|
||||
supply_pki = var.root_cert_file != ""
|
||||
|
||||
cloud_init_vars = {
|
||||
hostname = var.name
|
||||
@@ -27,7 +27,7 @@ locals {
|
||||
psk = var.psk
|
||||
}
|
||||
}
|
||||
p2s = { enabled = var.p2s_enabled, address_pool = var.p2s_address_pool, ca_name = "VPN Router CA" }
|
||||
p2s = { enabled = var.p2s_enabled, address_pool = var.p2s_address_pool }
|
||||
wireguard = { enabled = var.wireguard_enabled, address = var.wireguard_address, listen_port = var.wireguard_listen_port }
|
||||
}
|
||||
}
|
||||
@@ -102,10 +102,8 @@ resource "azurerm_linux_virtual_machine" "router" {
|
||||
custom_data = base64encode(
|
||||
local.supply_pki
|
||||
? templatefile("${path.module}/../cloud-init-with-pki.yaml.tpl", merge(local.cloud_init_vars, {
|
||||
label = split(".", var.local_fqdn)[0]
|
||||
ca_cert = file(var.ca_cert_file)
|
||||
server_cert = file(var.server_cert_file)
|
||||
server_key = file(var.server_key_file)
|
||||
root_cert = file(var.root_cert_file)
|
||||
root_key = var.root_key_file != "" ? file(var.root_key_file) : ""
|
||||
}))
|
||||
: templatefile("${path.module}/../cloud-init.yaml.tpl", local.cloud_init_vars)
|
||||
)
|
||||
|
||||
@@ -100,20 +100,14 @@ variable "p2s_address_pool" {
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "ca_cert_file" {
|
||||
description = "Path to an existing CA certificate PEM to supply instead of letting the package generate one. Leave empty to auto-generate."
|
||||
variable "root_cert_file" {
|
||||
description = "Path to an existing root CA certificate PEM to supply instead of letting the package create one. Leave empty to create one."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "server_cert_file" {
|
||||
description = "Path to an existing server certificate PEM, paired with ca_cert_file."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "server_key_file" {
|
||||
description = "Path to an existing server private key PEM, paired with ca_cert_file."
|
||||
variable "root_key_file" {
|
||||
description = "Path to the private key PEM of root_cert_file. Leave empty to keep the root offline: the issuing CAs then wait for certificates signed elsewhere."
|
||||
type = string
|
||||
default = ""
|
||||
sensitive = true
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
#cloud-config
|
||||
#
|
||||
# Example: install and configure vpn-router during first boot, supplying an
|
||||
# existing CA and server certificate instead of letting the package generate
|
||||
# one. See cloud-init.yaml.tpl for the variant that generates its own CA.
|
||||
# existing root CA instead of letting the package create one. See
|
||||
# cloud-init.yaml.tpl for the variant that creates its own.
|
||||
#
|
||||
# Template variables are Terraform templatefile() placeholders. Adapt or drop
|
||||
# them for whatever renders this file. <label> is the first component of the
|
||||
# FQDN, for example "router" in router.example.com.
|
||||
# them for whatever renders this file. With root_key empty the root stays
|
||||
# offline: the router's issuing CAs wait for their certificates, signed with
|
||||
# the root elsewhere and imported with vpn-router-ca ca import.
|
||||
|
||||
hostname: ${hostname}
|
||||
fqdn: ${fqdn}
|
||||
@@ -28,21 +29,18 @@ write_files:
|
||||
content: |
|
||||
${jsonencode(config)}
|
||||
|
||||
- path: /etc/vpn-router/pki/ca_cert.pem
|
||||
- path: /etc/vpn-router/pki/root/cert.pem
|
||||
permissions: '0644'
|
||||
owner: root:root
|
||||
content: |
|
||||
${indent(6, trimspace(ca_cert))}
|
||||
- path: /etc/vpn-router/pki/${label}_cert.pem
|
||||
permissions: '0644'
|
||||
owner: root:root
|
||||
content: |
|
||||
${indent(6, trimspace(server_cert))}
|
||||
- path: /etc/vpn-router/pki/${label}_key.pem
|
||||
${indent(6, trimspace(root_cert))}
|
||||
%{ if root_key != "" ~}
|
||||
- path: /etc/vpn-router/pki/root/key.pem
|
||||
permissions: '0600'
|
||||
owner: root:root
|
||||
content: |
|
||||
${indent(6, trimspace(server_key))}
|
||||
${indent(6, trimspace(root_key))}
|
||||
%{ endif ~}
|
||||
|
||||
package_update: true
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
# Template variables are Terraform templatefile() placeholders. Adapt or drop
|
||||
# them for whatever renders this file.
|
||||
#
|
||||
# This variant lets the package generate its own CA on first boot. See
|
||||
# cloud-init-with-pki.yaml.tpl for the variant that supplies existing PKI
|
||||
# material instead.
|
||||
# This variant lets the package create its own CAs on first boot. See
|
||||
# cloud-init-with-pki.yaml.tpl for the variant that supplies an existing root
|
||||
# CA instead.
|
||||
|
||||
hostname: ${hostname}
|
||||
fqdn: ${fqdn}
|
||||
|
||||
@@ -3,7 +3,7 @@ data "http" "repo_gpg_key" {
|
||||
}
|
||||
|
||||
locals {
|
||||
supply_pki = var.ca_cert_file != ""
|
||||
supply_pki = var.root_cert_file != ""
|
||||
|
||||
cloud_init_vars = {
|
||||
hostname = var.name
|
||||
@@ -27,16 +27,14 @@ locals {
|
||||
psk = var.psk
|
||||
}
|
||||
}
|
||||
p2s = { enabled = var.p2s_enabled, address_pool = var.p2s_address_pool, ca_name = "VPN Router CA" }
|
||||
p2s = { enabled = var.p2s_enabled, address_pool = var.p2s_address_pool }
|
||||
wireguard = { enabled = var.wireguard_enabled, address = var.wireguard_address, listen_port = var.wireguard_listen_port }
|
||||
}
|
||||
}
|
||||
|
||||
cloud_init_rendered = local.supply_pki ? templatefile("${path.module}/../cloud-init-with-pki.yaml.tpl", merge(local.cloud_init_vars, {
|
||||
label = split(".", var.local_fqdn)[0]
|
||||
ca_cert = file(var.ca_cert_file)
|
||||
server_cert = file(var.server_cert_file)
|
||||
server_key = file(var.server_key_file)
|
||||
root_cert = file(var.root_cert_file)
|
||||
root_key = var.root_key_file != "" ? file(var.root_key_file) : ""
|
||||
})) : templatefile("${path.module}/../cloud-init.yaml.tpl", local.cloud_init_vars)
|
||||
}
|
||||
|
||||
|
||||
@@ -97,20 +97,14 @@ variable "p2s_address_pool" {
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "ca_cert_file" {
|
||||
description = "Path to an existing CA certificate PEM to supply instead of letting the package generate one. Leave empty to auto-generate."
|
||||
variable "root_cert_file" {
|
||||
description = "Path to an existing root CA certificate PEM to supply instead of letting the package create one. Leave empty to create one."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "server_cert_file" {
|
||||
description = "Path to an existing server certificate PEM, paired with ca_cert_file."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "server_key_file" {
|
||||
description = "Path to an existing server private key PEM, paired with ca_cert_file."
|
||||
variable "root_key_file" {
|
||||
description = "Path to the private key PEM of root_cert_file. Leave empty to keep the root offline: the issuing CAs then wait for certificates signed elsewhere."
|
||||
type = string
|
||||
default = ""
|
||||
sensitive = true
|
||||
|
||||
+5
-3
@@ -26,7 +26,7 @@ REMOTE_CIDRS="${REMOTE_CIDRS:-[\"192.168.0.0/24\"]}"
|
||||
PSK="${PSK:-change-me}"
|
||||
P2S_ENABLED="${P2S_ENABLED:-false}"
|
||||
P2S_ADDRESS_POOL="${P2S_ADDRESS_POOL:-172.16.0.0/24}"
|
||||
P2S_CA_NAME="${P2S_CA_NAME:-VPN Router CA}"
|
||||
PKI_ROOT_NAME="${PKI_ROOT_NAME:-VPN Router Root CA}"
|
||||
WG_ENABLED="${WG_ENABLED:-false}"
|
||||
WG_ADDRESS="${WG_ADDRESS:-}"
|
||||
WG_LISTEN_PORT="${WG_LISTEN_PORT:-51820}"
|
||||
@@ -66,8 +66,10 @@ cat > /etc/vpn-router/vpn-router.json <<EOF
|
||||
},
|
||||
"p2s": {
|
||||
"enabled": ${P2S_ENABLED},
|
||||
"address_pool": "${P2S_ADDRESS_POOL}",
|
||||
"ca_name": "${P2S_CA_NAME}"
|
||||
"address_pool": "${P2S_ADDRESS_POOL}"
|
||||
},
|
||||
"pki": {
|
||||
"root_name": "${PKI_ROOT_NAME}"
|
||||
},
|
||||
"wireguard": {
|
||||
"enabled": ${WG_ENABLED},
|
||||
|
||||
Reference in new issue
Block a user