Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d90441bee | ||
|
|
ee1161f534 | ||
|
|
7f85a1a2e5 | ||
|
|
c2c33cfca0 | ||
|
|
f36e2a3b04 | ||
|
|
1e0e375d60 | ||
|
|
dd489e65aa | ||
|
|
3de030b06b | ||
|
|
3c4c5a8c32 | ||
|
|
3ed79c7563 | ||
|
|
3e78d520ae | ||
|
|
d99e4ea425 | ||
|
|
46d3550eab | ||
|
|
3b14b7723e | ||
|
|
5303779559 | ||
|
|
2dc4db440f | ||
|
|
45cea95728 | ||
|
|
dd2dfaaf2b | ||
|
|
1697e1aa38 | ||
|
|
57e8bbcffe | ||
|
|
9ca8fce14e | ||
|
|
74c24d7bc6 | ||
|
|
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
@@ -46,7 +46,7 @@ and as a Tailwind CSS page for a browser, and `/status` is the same status as JS
|
||||
much as `web.status_content` says, since the router is reachable from the internet and the
|
||||
operator decides how much it tells strangers: `basic`, the default because it tells the least,
|
||||
the overall status and the Client IP, `standard` also the package versions and the number of
|
||||
active and other services, and `full` also each service and the state of the site-to-site, road-warrior, OpenVPN and
|
||||
active and other services, and `full` also each service and the state of the site-to-site, IKEv2, OpenVPN and
|
||||
WireGuard features. The Client IP is the address the request came from, through the trusted
|
||||
proxies, so a person can see the address the router sees. Nothing comes from the configuration.
|
||||
`/status` names its content, so clients such as manage-ui and the Router Manager show what it
|
||||
@@ -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
|
||||
@@ -385,8 +394,8 @@ failure, then listens. `vpnrouter_auth` turns every failure of the database into
|
||||
psycopg (Ubuntu ships only its pure-Python implementation) finds libpq through the dynamic linker
|
||||
cache, which dpkg refreshes with its deferred `ldconfig` only at the end of an installation, so a
|
||||
service started during the installation cannot import it yet; a failed import cannot be repeated
|
||||
in the same process either, as psycopg caches the lookup. The services therefore import psycopg
|
||||
plainly, and their units wait instead: `ExecStartPre=wait-for-libpq`, a shell script of each
|
||||
in the same process either, as psycopg caches the lookup. The services and `vpn-router-setup`
|
||||
therefore import psycopg plainly, and their units wait instead: `ExecStartPre=wait-for-libpq`, a shell script of each
|
||||
package, waits until `ldconfig -p` lists `libpq.so.5`, within `TimeoutStartSec=5min`. The
|
||||
maintainer scripts start the units with `systemctl --no-block` (debhelper's start is turned off),
|
||||
because a start that waited would hold dpkg before its `ldconfig`.
|
||||
@@ -449,7 +458,7 @@ refresh tokens after the operations of `vpnrouter_auth.REVOKING`.
|
||||
|
||||
Consequences: A Manager-only account cannot become a VPN user, as LDAP cannot change an entry's
|
||||
structural class and its password hash cannot be copied; it is removed and added again instead.
|
||||
It has no certificate, so OpenVPN and road-warrior access refuse it. The administrator's password
|
||||
It has no certificate, so OpenVPN and IKEv2 access refuse it. The administrator's password
|
||||
is on disk only until setup has run.
|
||||
|
||||
## ADR-19: The package has an LDAP schema of its own under a UUID OID
|
||||
@@ -465,15 +474,21 @@ 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. The structural class `vpnRouterRole`,
|
||||
`MUST cn MAY ( description $ member )`, is a role of a resource of the OpenID Provider (ADR-37),
|
||||
an entry `cn=<resource>:<role>` of `ou=roles`, which provision makes; the manager account manages
|
||||
that subtree, and every account reads it.
|
||||
|
||||
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
|
||||
be reused for anything else.
|
||||
Consequences: The schema check stays strict. New attributes of accounts extend
|
||||
`vpnRouterAccount`, and their OIDs continue the arcs. The scope `service` marks a service account
|
||||
(ADR-23). The OID must never be reused for anything else.
|
||||
|
||||
## ADR-20: Users manage their own account through /account, proving who they are again
|
||||
|
||||
@@ -519,11 +534,17 @@ package's drop-in for `caddy.service` points it at that file and keeps it from s
|
||||
the file exists, so `/etc/caddy/Caddyfile`, a configuration file of the `caddy` package, is left
|
||||
unchanged. Both services always listen on `127.0.0.1` as well as on the interfaces their
|
||||
`listen` settings name, and trust `X-Forwarded-For` from it, which Caddy sets from the peer and
|
||||
never takes from the client.
|
||||
never takes from the client. Let's Encrypt issues at most 5 certificates a week for the same
|
||||
names, which routers rebuilt for tests reach; `https.acme_staging` sets Caddy's global `acme_ca`
|
||||
to Let's Encrypt's staging directory, which replaces Caddy's default CAs for every site, so no
|
||||
test certificate comes from a production CA. `PUT /https/settings` changes `acme_staging` alone:
|
||||
the other settings of the section switch HTTPS off or move its ports, which would end the
|
||||
connection the Router Manager makes the change on.
|
||||
|
||||
Consequences: HTTPS needs `wan.local_fqdn` in public DNS pointing at the router and port 80
|
||||
reachable from the internet; until then Caddy retries and the HTTPS ports do not answer. The
|
||||
certificate and the ACME account are in `/var/lib/caddy`. Plain HTTP stays as the `listen`
|
||||
certificate and the ACME account are in `/var/lib/caddy`. A router on the staging CA has a
|
||||
certificate no client trusts. Plain HTTP stays as the `listen`
|
||||
settings set it, so `internal` keeps passwords off the internet. Caddy's admin endpoint listens
|
||||
on `localhost:2019`, which `caddy reload` uses. Port 80 and the HTTPS ports are not available
|
||||
to Docker containers while HTTPS is on. The Manager's HTTPS port is a setting of the router, not
|
||||
@@ -558,7 +579,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 +588,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 +624,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
|
||||
@@ -640,8 +664,8 @@ one, so its name is the one the tunnel always had.
|
||||
- **Local settings per connection:** each connection has its own `local_cidrs` and
|
||||
`local_id_mode`, as each peer expects its own traffic selectors and identity. `wan.local_fqdn`
|
||||
stays the router's one name, used by the `fqdn` mode, resolved once for the `public_ip` mode,
|
||||
and used by HTTPS, road-warrior and OpenVPN. `local.cidrs` stays the subnets the router
|
||||
routes, masquerades and offers to road-warrior and OpenVPN clients.
|
||||
and used by HTTPS, IKEv2 and OpenVPN. `local.cidrs` stays the subnets the router
|
||||
routes, masquerades and offers to IKEv2 and OpenVPN clients.
|
||||
- **One file:** `vpn-router-setup` writes every connection that is ready to
|
||||
`/etc/swanctl/conf.d/site-to-site.conf`, with the proposals and timers the one tunnel had, and
|
||||
removes the file when none is. The NAT exemptions pair each connection's `local_cidrs` with its
|
||||
@@ -673,3 +697,702 @@ 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`;
|
||||
IKEv2 access 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 IKEv2 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, or with one of the user's own passkeys (ADR-36), and gives a one-time ticket valid 30
|
||||
seconds; a WebSocket on `GET /shell` with that ticket gets the shell. A passkey with user
|
||||
verification is a possession and a PIN or biometric in one step and cannot be phished, so it is
|
||||
as good as the password and the code; `POST /shell/passkey` begins a proof bound to the signed-in
|
||||
user and its credentials, and python3-fido2 refuses an answer without the user-verified flag. The
|
||||
TOTP enrolment stays required with a passkey: the PAM login session of the shell, through the
|
||||
`pam_authz_search` of nslcd for a directory account, and sudo in it need it, and loosening that
|
||||
filter would let such a user log in over SSH with the password alone. 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,
|
||||
or holds one of the user's passkeys and unlocks it, can get a shell when the router allows one and
|
||||
the user's console login is on; a session alone cannot. A failed passkey proof is refused but not
|
||||
counted as a failed login, as with the passkey sign-in. 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 and resources are service accounts
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context:
|
||||
|
||||
- **What is needed:** 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. Programs on hosts
|
||||
without a browser, and jobs without a user, need tokens as well. Services such as an S3 gateway
|
||||
should learn from a token what its holder may do there, as resources of Entra ID do with app
|
||||
roles and scopes.
|
||||
- **What the archive has:** no packaged identity provider. `python3-oauthlib` and `python3-jwt`
|
||||
are in main for noble and resolute; `python3-authlib` and `python3-jwcrypto` are in universe
|
||||
only.
|
||||
- **What oauthlib does:** it implements the OAuth 2.0 and OpenID Connect flows, including the
|
||||
client credentials grant, and leaves the checks, the storage and the signatures to the
|
||||
application. Noble's 3.2.2 has no device authorization grant (RFC 8628). Resolute's 3.3.1 has
|
||||
one, as has the current 4.0.0, but it checks no device code and never answers
|
||||
`authorization_pending` or `slow_down`.
|
||||
- **What the web service has:** it 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 or a resource verifies a token itself.
|
||||
|
||||
Decision:
|
||||
|
||||
- **Where it runs:** 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.
|
||||
- **Clients:**
|
||||
- A client is a service account with redirect URIs or grants, kept with the account in
|
||||
attributes of `vpnRouterAccount`: `vpnRouterRedirectUri`, `vpnRouterPostLogoutRedirectUri`,
|
||||
`vpnRouterAllowedGroup`, `vpnRouterGrantType`, `vpnRouterPublicClient` and
|
||||
`vpnRouterClientScope`.
|
||||
- Its name is the client ID and its password the secret, checked with a bind; a public client has
|
||||
none.
|
||||
- Allowed groups limit who may sign in; without them every user may.
|
||||
- **Grants:**
|
||||
- the authorization code with PKCE S256, which redirect URIs give;
|
||||
- `client_credentials`, for confidential clients only;
|
||||
- the device code;
|
||||
- the refresh token for the first and the last.
|
||||
|
||||
The device grant is the router's own grant class on oauthlib's token endpoint, with its
|
||||
authorization endpoint and pages, so it works the same with every oauthlib release. A loopback
|
||||
redirect URI matches with any port (RFC 8252).
|
||||
- **Consent:** a registered client is trusted, so there is no consent page, except for a device:
|
||||
the user who enters its code is asked to allow it, as RFC 8628 advises against remote phishing.
|
||||
- **The sign-in page:** the web service's own, a Jinja2 template with the password, the code and a
|
||||
passkey, so the provider works without the Router Manager.
|
||||
- **Resources, as Entra ID:**
|
||||
- Every service account is a resource. Its scopes are in `vpnRouterResourceScope`, and its roles
|
||||
are `vpnRouterRole` entries (ADR-19) whose members are groups and accounts.
|
||||
- A token request names its resource with `resource` (RFC 8707) or by the resource's scopes,
|
||||
`<resource>:<scope>`, which the client's scopes must allow.
|
||||
- Without a resource, the tokens are for the client.
|
||||
- The grant keeps the resource, so a code, a device code or a refresh token gives tokens for that
|
||||
resource only.
|
||||
- **Tokens:**
|
||||
- Both kinds are RS256 JWTs, 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`.
|
||||
- An ID token is for the client and carries the user's roles of the client.
|
||||
- An access token follows RFC 9068: it is for the resource and carries the holder's roles there
|
||||
and the scopes granted, so a resource needs nothing from the router but the JWKS.
|
||||
- Roles are read at every issue.
|
||||
- **Storage:**
|
||||
- Codes, device codes, access and refresh tokens and sessions are kept as their SHA-256 in the
|
||||
database of the refresh tokens.
|
||||
- They are defined in `/usr/share/vpn-router/sql/oidc.sql`, with the procedures and functions
|
||||
the module calls; a process loads the file at its first connection.
|
||||
- The operations that revoke the refresh tokens of `/auth`, of users and of service accounts,
|
||||
also revoke them, and every use of a code, a device code or a token checks the user and the
|
||||
client's allowed groups again.
|
||||
|
||||
Consequences:
|
||||
|
||||
- **Requirements:** the package depends on `python3-oauthlib` and `python3-jwt`. The provider needs
|
||||
HTTPS, a public name and the web service.
|
||||
- **Management:** clients, resources and their roles are managed like service accounts, by the
|
||||
CLI, `vpn-router-role`, `/service-accounts` and the Router Manager.
|
||||
- **The directory keeps itself consistent:**
|
||||
- a group a client allows cannot be removed, and renaming it renames it in the clients and the
|
||||
roles;
|
||||
- a resource scope a client may ask for cannot be removed.
|
||||
- **Revocation:** an access token, a JWT, is valid at a resource until it expires, 5 minutes after
|
||||
issue, whatever the router revokes. The signing key does not rotate; replacing it invalidates
|
||||
every token issued.
|
||||
- **Lockout:** wrong client secrets lock the client like any account.
|
||||
- **Not offered:** the implicit and hybrid flows, the password grant and dynamic client
|
||||
registration.
|
||||
|
||||
## 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: The router hosts authoritative domains as their primary, with a secondary name server, and
|
||||
a domain can give the sites their records without a registrar change per site. The router's own
|
||||
name, `wan.local_fqdn`, belongs to a domain of its own, wherever that is hosted. 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, and `primary=yes`. The domains and their manual records are rows of the router's tables
|
||||
in that database, like the sites in `vpn-router-sites`, not settings of `vpn-router.json`: they are
|
||||
many and change through the web service. The name servers are settings, the same for every domain:
|
||||
`wan.local_fqdn`, the router's one name, is the primary, and `dns.secondary` the secondary. Setup
|
||||
writes each domain as a MASTER zone through the procedure `dns_apply` of
|
||||
`/usr/share/vpn-router/sql/dns.sql`: SOA with `wan.local_fqdn`, NS of `wan.local_fqdn` and
|
||||
`dns.secondary`, an A or AAAA record of every host name of a site with `dns` `router` whose closest
|
||||
domain it is, with the router's address the site chooses (`wan.public_address`, the internal or the
|
||||
external interface's), and the manual records; the serial grows only when the records change, which
|
||||
makes PowerDNS notify. `wan.local_fqdn` gets no record in the domains: its A record is in the DNS of
|
||||
its own domain, which the router does not manage. A site of `dns` `user` gets no records, as its
|
||||
names are served elsewhere. The addresses `dns.secondary` resolves to at each run go to every
|
||||
domain's `ALSO-NOTIFY` and `ALLOW-AXFR-FROM` metadata, so the global `allow-axfr-ips` stays the
|
||||
loopback default. The public address is a setting, since the router sees only private addresses on
|
||||
its interfaces behind a cloud's NAT and resolving `wan.local_fqdn` would answer from its own DNS
|
||||
when it hosts that name. `dns.nameserver` and `dns.addresses` of an earlier configuration are left
|
||||
out when it is read, so that it loads; a missing `dns.secondary` makes setup warn, not fail, so that
|
||||
a router keeps running until it is set. A host name belongs to its closest domain only, and a
|
||||
domain inside another is delegated from it with NS records, so a name is never answered by two
|
||||
zones. The remote access clients' resolver answers on the addresses of the 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 domains follow the sites at each setup run. Transfers are authorized by the
|
||||
address the secondary's name resolves to, without TSIG, and there is no DNSSEC; the delegation of
|
||||
each domain, with glue for the name servers inside it, is made at its parent zone by hand. Every
|
||||
domain has the same two name servers.
|
||||
|
||||
## 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.
|
||||
|
||||
## ADR-44: Apple VPN profiles are unsigned IKEv2 profiles with a PKCS#12 encrypted by a password given per download
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: macOS, iOS and iPadOS connect to the IKEv2 access with their built-in IKEv2 client,
|
||||
configured by a configuration profile. The router keeps each user's certificate and key. Apple's
|
||||
clients read a PKCS#12 only when it is encrypted with 3DES and SHA-1, send one proposal per SA,
|
||||
and show an unsigned profile as unverified.
|
||||
|
||||
Decision: The web service builds the profile with `plistlib` and python3-cryptography: the P2S CA
|
||||
and the root, the user's PKCS#12 encrypted with a password the caller gives with each request,
|
||||
and an IKEv2 payload matching `road-warrior.conf` (EAP-TLS with the certificate, AES-256,
|
||||
SHA2-256, group 14). The profile is not signed. Admins get any user's profile through `/users`,
|
||||
users their own through `/account` with their current password and code.
|
||||
|
||||
Consequences: The password is never stored; the device asks for it when the profile is installed.
|
||||
A profile shows as unverified. A changed proposal of `road-warrior.conf` must be changed in the
|
||||
profile too. WireGuard is not part of it.
|
||||
|
||||
## ADR-45: IKEv2 clients get split DNS domains, p2s.dns_domains
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: The IKEv2 access is a split tunnel. macOS and iOS use the DNS server an IKEv2 VPN
|
||||
pushes only for queries scoped to the tunnel, so names only the router resolves, such as Azure's
|
||||
internal names, fail. RFC 8598 defines INTERNAL_DNS_DOMAIN (attribute 25): the client resolves the
|
||||
listed domains and their subdomains with the VPN's servers. strongSwan's pools take numeric
|
||||
attributes with string values; Apple profiles name such domains in `SupplementalMatchDomains`.
|
||||
|
||||
Decision: The setting `p2s.dns_domains` lists the domains; setup pushes each as attribute 25 of the
|
||||
IKEv2 address pool, and the Apple VPN profile gives them as match domains of the router's resolver,
|
||||
the addresses setup records for the web service.
|
||||
|
||||
Consequences: Clients resolve the listed domains through the tunnel and everything else with their
|
||||
own resolvers. Clients that ignore attribute 25 keep the scoped behaviour; the Apple profile
|
||||
covers Apple devices. A changed list reaches an Apple device with a newly installed profile.
|
||||
|
||||
## ADR-46: vre opens files in the Shell page's editor over a socket checked by the peer's uid
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: Administrators edit the router's files from the Router Manager's Shell page, in an editor
|
||||
of the page rather than a terminal editor. The command that names a file runs in the shell, as the
|
||||
console account. The web service runs as root, sandboxed with `ProtectHome=yes`, so it cannot see
|
||||
the accounts' home folders. Reading and writing as root would also bypass the account's
|
||||
permissions.
|
||||
|
||||
Decision: Each shell has a random session in `VPN_ROUTER_SHELL`. `vpn-router-editor` (`vre`) sends
|
||||
the session and an absolute path to the web service on `/run/vpn-router/editor.sock`, which anyone
|
||||
may connect to. The web service accepts a request only when the peer's uid (`SO_PEERCRED`) is the
|
||||
uid of the session's console account. It then tells the shell's client over the shell's WebSocket,
|
||||
and the page reads and saves the file through `/shell/editor/<session>/<id>`. Files are read and
|
||||
written by `editor-file`, run with `systemd-run --uid=<account> --pipe` outside the sandbox, as the
|
||||
shell is. A save carries the SHA-256 of the content it replaces and is refused when the file
|
||||
changed since. Files open only while their shell lives.
|
||||
|
||||
Consequences: The editor edits only what the console account may; a file only root may write is
|
||||
edited in the terminal. `vre` run under sudo is refused, as its uid is not the console account's.
|
||||
Each read and save starts a transient unit. Files are limited to 1 MiB of UTF-8 text.
|
||||
|
||||
## ADR-47: The web service is the package vpnrouter_web, with one module per family of paths
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: The web service answers about twenty families of paths: the status, the sign-in, the
|
||||
identities, the connections, backups, the PKI, logs, web sites, DNS, metrics and the shell with
|
||||
its editor. Kept in one script, the code of unrelated paths is read and changed together.
|
||||
|
||||
Decision: `/usr/lib/vpn-router/web` only initializes and serves. The package
|
||||
`/usr/lib/vpn-router/vpnrouter_web/` holds the rest, laid out next to `vpnrouter_platforms/`:
|
||||
|
||||
- `handler`: the `Handler` class, which reads a request, checks its token and routes it.
|
||||
- One module per family of paths (`status`, `auth`, `identities`, `connectivity`, `backup`, `pki`,
|
||||
`logs`, `sites`, `dns`, `metrics`, `shell`), each with its constants and state.
|
||||
- `common`: what several of them need.
|
||||
|
||||
A module imports from `common` and from no other route module, except `auth`, which takes the
|
||||
paths of a site's files from `sites`. `handler` imports them all, so the imports have no cycle. A
|
||||
function that needs the request takes the `Handler` as its first parameter instead of being a
|
||||
method.
|
||||
|
||||
Consequences: A change to one family of paths touches one module and the routing in `handler`.
|
||||
State shared between requests, such as the open shells, lives in the module that owns it; other
|
||||
modules change it and never rebind it.
|
||||
|
||||
## ADR-48: Exceptions are handled; no traceback reaches the logs
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: The services log to the journal, which administrators read through the Logs page and
|
||||
`journalctl`. Python prints a traceback for an exception nothing handles: `socketserver` for a
|
||||
failed request, `threading` for a failed thread and the interpreter for the process. A client
|
||||
that closes its connection before the answer is written, as a page does when it is left or
|
||||
reloaded, raises `BrokenPipeError` or `ConnectionResetError` in the handler. A traceback is many
|
||||
lines of internals for one event and hides the lines around it.
|
||||
|
||||
Decision: The code handles the exceptions it can expect where they occur, and answers or logs
|
||||
them with their reason in one line. An exception left unhandled is a defect to be fixed. As the
|
||||
last line of defence, every server replaces the default reports:
|
||||
|
||||
- `vpnrouter_serve.serve`, which the web service and the metrics service run, sets the servers'
|
||||
`handle_error`, `threading.excepthook` and `sys.excepthook`.
|
||||
- The bootstrap server sets its server's `handle_error` and `sys.excepthook`.
|
||||
|
||||
A `ConnectionError` of a client that went away is not logged; any other exception is logged in
|
||||
one line with its type and message.
|
||||
|
||||
Consequences: The logs hold one line per failure, without its stack. A failure that reaches the
|
||||
last line of defence is found by its message and fixed where it occurs, with its own handling.
|
||||
|
||||
## ADR-49: A changelog summary is a paragraph wrapped at 80 columns
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: The summary of an entry of `debian/changelog` is the commit message of its release,
|
||||
which `release.py` reads from the changelog. Summaries are often longer than the 80 columns
|
||||
Debian's tools expect of a changelog line.
|
||||
|
||||
Decision: The summary is the entry's first paragraph, indented by two spaces and wrapped at 80
|
||||
columns. `release.py` joins its lines with single spaces into the commit message.
|
||||
|
||||
Consequences: The commit message is one line however the summary is wrapped, so a summary
|
||||
rewrapped later still matches the commit of its release.
|
||||
+889
-12
@@ -1,6 +1,871 @@
|
||||
vpn-router (0.39.1-1) unstable; urgency=medium
|
||||
|
||||
fix: the web service starts again after its first start with 0.39.0, and the
|
||||
name server's domains can be changed from any process.
|
||||
|
||||
* The schemas of the web sites and of the name server can be applied any
|
||||
number of times, so the web service no longer keeps retrying with
|
||||
'function "site_put" already exists' and never listening, and
|
||||
vpn-router-site and setup no longer fail the same way.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 06 Nov 2026 20:16:57 +0100
|
||||
|
||||
vpn-router (0.39.0-2) unstable; urgency=medium
|
||||
|
||||
update: vpn-router is published in the koszewscy package registry.
|
||||
|
||||
* publish.sh uploads to the owner koszewscy unless OWNER names another.
|
||||
* repo_url of the Azure and GCP examples defaults to
|
||||
https://gitea.koszewscy.waw.pl/api/packages/koszewscy/debian.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 06 Nov 2026 20:08:29 +0100
|
||||
|
||||
vpn-router (0.39.0-1) unstable; urgency=medium
|
||||
|
||||
feature: the name server's domains have wan.local_fqdn and dns.secondary as
|
||||
their name servers, and a site with dns router gets records of the router's
|
||||
public, internal or external address.
|
||||
|
||||
* The SOA and the first NS of every domain are wan.local_fqdn, and the
|
||||
second NS is dns.secondary, a host name, whose addresses are notified
|
||||
of every change and may transfer the domains; dns.nameserver is gone.
|
||||
* wan.local_fqdn gets no record in the domains; its record is kept in
|
||||
the DNS of its own domain.
|
||||
* A site has dns, user (the default), served elsewhere, or router, whose
|
||||
host names get an A or AAAA record in their closest domain, with
|
||||
dns_address public (wan.public_address, new), internal or external.
|
||||
dns.addresses, the domains' site records switch and the per-domain
|
||||
secondaries with their routes are gone; dns.nameserver and
|
||||
dns.addresses of an earlier configuration are left out when read.
|
||||
* A site may use wan.local_fqdn as a host name on a port of its own.
|
||||
* GET /dns answers nameserver, wan.local_fqdn, and public_address,
|
||||
which PUT /dns changes.
|
||||
* The summaries of the changelog entries are wrapped at 80 columns, and
|
||||
release.py joins the lines of a summary into the commit message.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 30 Oct 2026 21:30:01 +0100
|
||||
|
||||
vpn-router (0.38.1-1) unstable; urgency=medium
|
||||
|
||||
fix: /https/settings shows the https section and switches https.acme_staging,
|
||||
for the Router Manager.
|
||||
|
||||
* GET /https/settings shows the https section, and PUT /https/settings
|
||||
changes acme_staging; the other settings of the section stay with
|
||||
vpn-config-edit.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 30 Oct 2026 02:43:22 +0100
|
||||
|
||||
vpn-router (0.38.0-1) unstable; urgency=medium
|
||||
|
||||
feature: https.acme_staging takes Caddy's certificates from Let's Encrypt's
|
||||
staging environment.
|
||||
|
||||
* While https.acme_staging is true, Caddy's only CA is Let's Encrypt's
|
||||
staging environment, for the router's services and the web sites
|
||||
with tls acme; its certificates are not trusted by browsers.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 30 Oct 2026 02:34:01 +0100
|
||||
|
||||
vpn-router (0.37.3-1) unstable; urgency=medium
|
||||
|
||||
fix: an answer whose client went away ends quietly, and a shell whose client
|
||||
left before it opened is stopped.
|
||||
|
||||
* The web service, the metrics service and the bootstrap server stop
|
||||
writing an answer when its client closes the connection, and a site
|
||||
file whose download is left no longer gets a second answer.
|
||||
* A shell whose client closed the connection before it opened is
|
||||
stopped and its end is logged, instead of running on.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 30 Oct 2026 02:03:26 +0100
|
||||
|
||||
vpn-router (0.37.2-1) unstable; urgency=medium
|
||||
|
||||
fix: the web service, the metrics service and the bootstrap server log a
|
||||
failure in one line instead of a traceback, and nothing when a client goes
|
||||
away.
|
||||
|
||||
* A request whose client closed the connection is no longer logged.
|
||||
* Any other failure of a request, a thread or the service is logged in
|
||||
one line with the exception's type and message.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 30 Oct 2026 01:59:19 +0100
|
||||
|
||||
vpn-router (0.37.1-1) unstable; urgency=medium
|
||||
|
||||
fix: /config answers the OpenID Provider's state and issuer.
|
||||
|
||||
* /config has oidc: enabled, and issuer, the issuer URL, null while the
|
||||
provider is disabled.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 30 Oct 2026 01:53:55 +0100
|
||||
|
||||
vpn-router (0.37.0-1) unstable; urgency=medium
|
||||
|
||||
feature: the OpenID Provider has the client credentials and device
|
||||
authorization grants, public clients, and service accounts as resources with
|
||||
roles and scopes in JWT access tokens.
|
||||
|
||||
* A client may have the grants client_credentials, by which a
|
||||
confidential client gets an access token as its own service account,
|
||||
and the device code (RFC 8628): POST /oidc/device_authorization gives
|
||||
a device code and a user code, the user enters the code on
|
||||
/oidc/device, signs in and allows or denies the device, and the device
|
||||
polls /oidc/token. A public client has no secret.
|
||||
* An http loopback redirect URI matches with any port.
|
||||
* Every service account is a resource: it defines scopes, which clients
|
||||
ask for as <resource>:<scope> when their client scopes allow them, and
|
||||
roles, assigned to groups, users and service accounts. A token request
|
||||
names its resource with resource (RFC 8707) or by its scopes.
|
||||
* Access tokens are RS256 JWTs of RFC 9068 for the resource, or the
|
||||
client, with client_id, scope, roles and the claims of the scopes; ID
|
||||
tokens and /oidc/userinfo carry the user's roles of the client.
|
||||
* vpn-router-user takes --grant, --public-client, --client-scope and
|
||||
--resource-scope; the new vpn-router-role manages the roles; and
|
||||
/service-accounts takes and answers grants, public_client,
|
||||
client_scopes, resource_scopes and roles.
|
||||
* Changing the password of a service account, disabling, unlocking or
|
||||
removing it through /service-accounts revokes its tokens.
|
||||
* POST /oidc/device and /oidc/device_authorization are rate limited as
|
||||
/oidc/login is.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 30 Oct 2026 00:57:11 +0100
|
||||
|
||||
vpn-router (0.36.0-1) unstable; urgency=medium
|
||||
|
||||
feature: a shell opens with one of the user's passkeys in place of the
|
||||
password and the TOTP code.
|
||||
|
||||
* POST /shell/passkey begins a proof for one of the signed-in user's own
|
||||
passkeys, with user verification; POST /shell takes the browser's
|
||||
answer as {"passkey": {"id", "credential"}} in place of the password
|
||||
and the code, and gives the ticket of the shell.
|
||||
* A passkey that is not accepted is refused with 403. The shell still
|
||||
needs the user's TOTP enrolment, which its login session and sudo in
|
||||
it need.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Thu, 29 Oct 2026 23:09:43 +0100
|
||||
|
||||
vpn-router (0.35.2-2) unstable; urgency=medium
|
||||
|
||||
update: the package is published for Ubuntu 26.04 (resolute) as well as 24.04
|
||||
(noble).
|
||||
|
||||
* publish.sh uploads the package to the distributions noble and resolute
|
||||
of the repository, component main, skipping a distribution that has
|
||||
the version already; DISTRIBUTIONS names others. A router on Ubuntu
|
||||
26.04 installs vpn-router from the distribution resolute.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Thu, 29 Oct 2026 21:41:32 +0100
|
||||
|
||||
vpn-router (0.35.2-1) unstable; urgency=medium
|
||||
|
||||
fix: the web service answers /, /status and /status/full again while IKEv2
|
||||
runs.
|
||||
|
||||
* The status of IKEv2 clients is collected again; the requests that
|
||||
collect the status were dropped with a NameError since 0.35.1.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Thu, 29 Oct 2026 19:03:16 +0100
|
||||
|
||||
vpn-router (0.35.1-1) unstable; urgency=medium
|
||||
|
||||
update: the router web service is split into the package vpnrouter_web, with
|
||||
one module per family of paths.
|
||||
|
||||
* /usr/lib/vpn-router/web initializes and serves; the package
|
||||
/usr/lib/vpn-router/vpnrouter_web/ answers the requests, with a module
|
||||
for the status, the sign-in, the identities, the connections, backups,
|
||||
the PKI, logs, web sites, DNS, metrics and the shell. The paths, the
|
||||
answers and the log messages are the same.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Thu, 29 Oct 2026 18:20:50 +0100
|
||||
|
||||
vpn-router (0.35.0-1) unstable; urgency=medium
|
||||
|
||||
feature: vre opens files from the Router Manager's shell in the editor of its
|
||||
Shell page.
|
||||
|
||||
* vpn-router-editor FILE..., or vre, in the shell of the Router Manager
|
||||
opens the files in the editor of the Shell page and returns at once;
|
||||
outside that shell it exits with status 2.
|
||||
* GET, PUT and DELETE /shell/editor/<session>/<id> read, save and close
|
||||
a file opened so, as the shell's console account; a save is refused
|
||||
with 409 when the file changed since it was read.
|
||||
* The editor takes regular files of UTF-8 text up to 1 MiB, at most 20
|
||||
per shell, while the shell lives.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Thu, 29 Oct 2026 18:02:27 +0100
|
||||
|
||||
vpn-router (0.34.0-1) unstable; urgency=medium
|
||||
|
||||
feature: the web service shows and changes the settings of the metrics service
|
||||
at /metrics/settings, and OpenVPN starts on a new router, as setup installs
|
||||
CRLs that revoke nothing.
|
||||
|
||||
* GET and PUT /metrics/settings show and change the metrics section;
|
||||
otlp_password is never shown, an empty one keeps it, and an empty
|
||||
otlp_username removes it.
|
||||
* A CRL without revoked certificates is installed for strongSwan and
|
||||
OpenVPN.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Wed, 28 Oct 2026 22:00:00 +0100
|
||||
|
||||
vpn-router (0.33.3-1) unstable; urgency=medium
|
||||
|
||||
fix: vpn-router-setup runs on a new installation once libpq can be found.
|
||||
|
||||
* The vpn-router-setup unit waits, with wait-for-libpq, until libpq is in
|
||||
the linker cache before it runs setup.
|
||||
* The package starts vpn-router-setup without waiting for it.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Wed, 28 Oct 2026 21:00:00 +0100
|
||||
|
||||
vpn-router (0.33.2-1) unstable; urgency=medium
|
||||
|
||||
fix: the router's IPsec remote access is called IKEv2 instead of road-warrior.
|
||||
|
||||
* The README, the package description, the installer's questions, the
|
||||
status page, the messages of the web service and the examples say
|
||||
IKEv2 access and IKEv2 clients; the strongSwan connection keeps its
|
||||
name, road-warrior.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 23:00:00 +0100
|
||||
|
||||
vpn-router (0.33.1-1) unstable; urgency=medium
|
||||
|
||||
fix: the name server is the primary of any number of domains, each with
|
||||
secondary name servers and a switch for the records of the sites.
|
||||
|
||||
* The domains, their secondaries and manual records are kept in the
|
||||
vpn-router-dns database and changed through /dns/domains; dns.zone is
|
||||
removed.
|
||||
* Each domain is served as a primary zone: the router and its
|
||||
secondaries are its NS, and each secondary's address is notified of
|
||||
changes and may transfer the zone.
|
||||
* A domain with site records gives the host names of the enabled sites
|
||||
in it A and AAAA records of dns.addresses; a domain inside another is
|
||||
delegated from it.
|
||||
* Manual records belong to a domain; those of the single zone are
|
||||
dropped.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 20:00:00 +0100
|
||||
|
||||
vpn-router (0.33.0-1) unstable; urgency=medium
|
||||
|
||||
feature: road-warrior clients resolve the domains of the new setting
|
||||
p2s.dns_domains with the router's resolver.
|
||||
|
||||
* p2s.dns_domains lists up to 16 domains; setup pushes them to IKEv2
|
||||
clients as INTERNAL_DNS_DOMAIN (RFC 8598, attribute 25) of the
|
||||
road-warrior pool.
|
||||
* The Apple VPN profile names them as match domains of the router's
|
||||
resolver, which macOS and iOS use for those domains.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 18:00:00 +0100
|
||||
|
||||
vpn-router (0.32.1-1) unstable; urgency=medium
|
||||
|
||||
fix: a VPN user downloads the own OpenVPN client profile through /account,
|
||||
with the current password and code.
|
||||
|
||||
* POST /account/profile answers the user's OpenVPN client profile; it
|
||||
needs the current password and, when enrolled, a code.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 16:00:00 +0100
|
||||
|
||||
vpn-router (0.32.0-1) unstable; urgency=medium
|
||||
|
||||
feature: the web service gives Apple VPN profiles that set up IKEv2
|
||||
road-warrior access on macOS, iOS and iPadOS.
|
||||
|
||||
* POST /users/<name>/apple-profile and POST /account/apple-profile answer
|
||||
an unsigned .mobileconfig with the P2S CA and the root CA, the user's
|
||||
certificate and key as a PKCS#12 encrypted with the password of the
|
||||
request, and an IKEv2 VPN to wan.local_fqdn with EAP-TLS and the
|
||||
proposals of the road-warrior connection.
|
||||
* A user's own profile needs the current password and, when enrolled, a
|
||||
code.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 14:00:00 +0100
|
||||
|
||||
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.
|
||||
fix: service accounts are managed at /service-accounts, not /users, and the
|
||||
internal one of the metrics service is only listed.
|
||||
|
||||
* /service-accounts, for members of admins and admin tokens, lists the
|
||||
service accounts, adds one with a password and an optional e-mail
|
||||
@@ -17,7 +882,9 @@ vpn-router (0.18.2-1) unstable; urgency=medium
|
||||
|
||||
vpn-router (0.18.1-1) unstable; urgency=medium
|
||||
|
||||
fix: vpn-router-setup owns only the creation of the metrics service's account, which is marked internal, and administrators add service accounts with vpn-router-user add --service and /users.
|
||||
fix: vpn-router-setup owns only the creation of the metrics service's account,
|
||||
which is marked internal, and administrators add service accounts with
|
||||
vpn-router-user add --service and /users.
|
||||
|
||||
* POST /users takes "service": true and adds a service account, which
|
||||
cannot sign in to /auth; vpn-router-user add takes --service.
|
||||
@@ -41,7 +908,8 @@ vpn-router (0.18.0-2) unstable; urgency=medium
|
||||
|
||||
vpn-router (0.18.0-1) unstable; urgency=medium
|
||||
|
||||
feature: site_to_site holds any number of named site-to-site connections, which /site-to-site lists, adds, changes and removes.
|
||||
feature: site_to_site holds any number of named site-to-site connections,
|
||||
which /site-to-site lists, adds, changes and removes.
|
||||
|
||||
* site_to_site maps a connection name to addrs, id, cidrs, local_cidrs,
|
||||
local_id_mode, psk and psk_file; it replaces remote and
|
||||
@@ -81,7 +949,8 @@ vpn-router (0.18.0-1) unstable; urgency=medium
|
||||
|
||||
vpn-router (0.17.0-1) unstable; urgency=medium
|
||||
|
||||
feature: /config shows the router's PostgreSQL clusters and their settings, and /postgresql their databases with their sizes.
|
||||
feature: /config shows the router's PostgreSQL clusters and their settings,
|
||||
and /postgresql their databases with their sizes.
|
||||
|
||||
* /config has a postgresql section: each cluster of pg_lsclusters with
|
||||
its version, name, port, status and data directory, and the main
|
||||
@@ -93,7 +962,8 @@ vpn-router (0.17.0-1) unstable; urgency=medium
|
||||
|
||||
vpn-router (0.16.0-1) unstable; urgency=medium
|
||||
|
||||
feature: the router keeps its metrics in PostgreSQL, serves them for an OpenTelemetry Collector and pushes them over OTLP.
|
||||
feature: the router keeps its metrics in PostgreSQL, serves them for an
|
||||
OpenTelemetry Collector and pushes them over OTLP.
|
||||
|
||||
* vpn-router-metrics takes a sample every minute: CPU, memory, free
|
||||
space of /, bytes per second on the external and the internal
|
||||
@@ -145,7 +1015,8 @@ vpn-router (0.14.1-1) unstable; urgency=medium
|
||||
|
||||
vpn-router (0.14.0-1) unstable; urgency=medium
|
||||
|
||||
feature: the status page shows the Client IP, and web.status_content sets how much it shows.
|
||||
feature: the status page shows the Client IP, and web.status_content sets how
|
||||
much it shows.
|
||||
|
||||
* / and /status show the Client IP, the address the request came from:
|
||||
the last X-Forwarded-For entry for a request from a trusted proxy, so
|
||||
@@ -160,7 +1031,8 @@ vpn-router (0.14.0-1) unstable; urgency=medium
|
||||
|
||||
vpn-router (0.13.0-1) unstable; urgency=medium
|
||||
|
||||
feature: Caddy serves the status page and the Router Manager over HTTPS with a certificate from Let's Encrypt.
|
||||
feature: Caddy serves the status page and the Router Manager over HTTPS with a
|
||||
certificate from Let's Encrypt.
|
||||
|
||||
* The package depends on caddy. While https.enabled is true, the
|
||||
default, and wan.local_fqdn is set, vpn-router-setup writes
|
||||
@@ -183,7 +1055,8 @@ vpn-router (0.13.0-1) unstable; urgency=medium
|
||||
|
||||
vpn-router (0.12.3-1) unstable; urgency=medium
|
||||
|
||||
fix: the web service listens on the internal address, the external address or both.
|
||||
fix: the web service listens on the internal address, the external address or
|
||||
both.
|
||||
|
||||
* web.listen in vpn-router.json sets the interfaces the web service
|
||||
listens on: internal, external or both, the default. It is read
|
||||
@@ -269,7 +1142,8 @@ vpn-router (0.9.2-1) unstable; urgency=medium
|
||||
|
||||
vpn-router (0.9.1-1) unstable; urgency=medium
|
||||
|
||||
fix: the web service waits for the PostgreSQL driver instead of failing at start.
|
||||
fix: the web service waits for the PostgreSQL driver instead of failing at
|
||||
start.
|
||||
|
||||
* The web service imports psycopg as the first step of its
|
||||
initialization, retrying every 15 seconds and logging the failure while
|
||||
@@ -299,7 +1173,8 @@ vpn-router (0.9.0-1) unstable; urgency=medium
|
||||
|
||||
vpn-router (0.8.0-1) unstable; urgency=medium
|
||||
|
||||
feature: the web service creates its key and database itself and shows its starting phase.
|
||||
feature: the web service creates its key and database itself and shows its
|
||||
starting phase.
|
||||
|
||||
* vpn-router-web makes its signing key and creates the refresh tokens'
|
||||
database on its first run, retrying every 15 seconds until PostgreSQL
|
||||
@@ -314,7 +1189,8 @@ vpn-router (0.8.0-1) unstable; urgency=medium
|
||||
|
||||
vpn-router (0.7.1-1) unstable; urgency=medium
|
||||
|
||||
fix: a failing user directory or token database no longer leaves the tunnels down.
|
||||
fix: a failing user directory or token database no longer leaves the tunnels
|
||||
down.
|
||||
|
||||
* vpn-router-setup brings up strongSwan and WireGuard before it provisions
|
||||
the user directory and the token database. A failure of either no
|
||||
@@ -324,7 +1200,8 @@ vpn-router (0.7.1-1) unstable; urgency=medium
|
||||
|
||||
vpn-router (0.7.0-1) unstable; urgency=medium
|
||||
|
||||
feature: users sign in to the web service, and only members of admins manage the router.
|
||||
feature: users sign in to the web service, and only members of admins manage
|
||||
the router.
|
||||
|
||||
* POST /auth signs a user in with the password, and the TOTP code for an
|
||||
enrolled user, and answers a 5 minute access token and an 8 hour refresh
|
||||
|
||||
@@ -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
|
||||
Description: Linux VPN router with IPSec, road-warrior, OpenVPN and WireGuard support
|
||||
dnsmasq-base,
|
||||
zstd
|
||||
Recommends: bash-completion
|
||||
Suggests: caddy,
|
||||
rclone,
|
||||
pdns-server,
|
||||
pdns-backend-pgsql,
|
||||
proxmox-backup-client-static
|
||||
Description: Linux VPN router with IPSec, IKEv2, 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 IKEv2 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,24 +4,42 @@ 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/lib/vpn-router/editor-file usr/lib/vpn-router/
|
||||
src/usr/bin/vpn-router-editor usr/bin/
|
||||
src/usr/sbin/vpn-router-user usr/sbin/
|
||||
src/usr/sbin/vpn-router-group usr/sbin/
|
||||
src/usr/sbin/vpn-router-role 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/
|
||||
src/usr/lib/vpn-router/web usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_web/*.py usr/lib/vpn-router/vpnrouter_web/
|
||||
src/usr/lib/vpn-router/metrics usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/bootstrap usr/lib/vpn-router/
|
||||
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/
|
||||
@@ -0,0 +1 @@
|
||||
usr/bin/vpn-router-editor usr/bin/vre
|
||||
@@ -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 ---
|
||||
@@ -71,9 +71,11 @@ case "$1" in
|
||||
# setting the forward policy all belong to vpn-router-setup, which does
|
||||
# them only once the configuration says this host is a router.
|
||||
|
||||
# The web service waits for libpq in the linker cache, which dpkg
|
||||
# refreshes after this script, so it is started without waiting for it.
|
||||
# The setup and web services wait for libpq in the linker cache, which
|
||||
# dpkg refreshes after this script, so they are started without waiting
|
||||
# for them.
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl --no-block restart vpn-router-setup.service >/dev/null 2>&1 || true
|
||||
systemctl --no-block restart vpn-router-web.service >/dev/null 2>&1 || true
|
||||
fi
|
||||
;;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,8 +3,14 @@
|
||||
dh $@
|
||||
|
||||
override_dh_installsystemd:
|
||||
dh_installsystemd --name=vpn-router-setup
|
||||
dh_installsystemd --name=vpn-router-setup --no-start
|
||||
dh_installsystemd --name=vpn-router-docker
|
||||
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
|
||||
@@ -4,7 +4,7 @@ Choices: none, generic, azure, gcp
|
||||
Default: none
|
||||
Description: Platform:
|
||||
Which platform this router runs on. The choice loads platform-specific
|
||||
additions, such as the provider DNS resolver for road-warrior clients.
|
||||
additions, such as the provider DNS resolver for IKEv2 clients.
|
||||
.
|
||||
none defers configuration entirely: the package installs its files and
|
||||
changes nothing on the machine. Choose it when the router will be configured
|
||||
@@ -58,7 +58,7 @@ Type: string
|
||||
Default:
|
||||
Description: Local router FQDN:
|
||||
Fully-qualified domain name of this router (for example
|
||||
router.example.com). Used as the road-warrior server identity and
|
||||
router.example.com). Used as the IKEv2 server identity and
|
||||
certificate common name.
|
||||
|
||||
Template: vpn-router/local_id_mode
|
||||
@@ -122,23 +122,22 @@ Description: Pre-shared key (PSK):
|
||||
Template: vpn-router/p2s_enabled
|
||||
Type: boolean
|
||||
Default: false
|
||||
Description: Enable road-warrior (P2S) access?
|
||||
Description: Enable IKEv2 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
|
||||
Default:
|
||||
Description: Road-warrior address pool:
|
||||
CIDR block assigned to road-warrior clients (for example 172.16.0.0/24).
|
||||
Description: IKEv2 address pool:
|
||||
CIDR block assigned to IKEv2 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
|
||||
@@ -6,6 +6,7 @@ After=network-online.target docker.service postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStartPre=/usr/lib/vpn-router/wait-for-libpq
|
||||
ExecStart=/usr/lib/vpn-router/setup
|
||||
RemainAfterExit=yes
|
||||
|
||||
|
||||
@@ -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
|
||||
+16
-11
@@ -8,9 +8,11 @@ case "$(uname -s)" in
|
||||
*) printf 'ERROR: unsupported platform %s\n' "$(uname -s)" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
OWNER="${OWNER:-slawek}"
|
||||
OWNER="${OWNER:-koszewscy}"
|
||||
SERVER_URL="${SERVER_URL:-https://gitea.koszewscy.waw.pl}"
|
||||
PACKAGES_TOKEN="${PACKAGES_TOKEN:-$(cat ~/.gitea-packages-token)}"
|
||||
# The Ubuntu releases whose distribution of the repository gets the package, in its component main.
|
||||
DISTRIBUTIONS="${DISTRIBUTIONS:-noble resolute}"
|
||||
|
||||
# Reads the changelog copied into the builder image, so no bind mount is needed.
|
||||
changelog_field() {
|
||||
@@ -27,14 +29,17 @@ if [ ! -f "$DEB" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
STATUS="$(curl -s -o /dev/null -w '%{http_code}' --user "$OWNER:$PACKAGES_TOKEN" \
|
||||
"$SERVER_URL/api/v1/packages/$OWNER/debian/$PACKAGE/$VERSION")"
|
||||
case "$STATUS" in
|
||||
200) printf '%s %s is already published, skipping.\n' "$PACKAGE" "$VERSION"; exit 0 ;;
|
||||
404) ;;
|
||||
*) printf 'ERROR: version check returned HTTP %s.\n' "$STATUS" >&2; exit 1 ;;
|
||||
esac
|
||||
BODY="$(mktemp)"
|
||||
trap 'rm -f "$BODY"' EXIT
|
||||
|
||||
curl --fail-with-body --user "$OWNER:$PACKAGES_TOKEN" \
|
||||
--upload-file "$DEB" \
|
||||
"$SERVER_URL/api/packages/$OWNER/debian/pool/noble/main/upload"
|
||||
# Gitea answers 409 for a version that is in the distribution already.
|
||||
for DISTRIBUTION in $DISTRIBUTIONS; do
|
||||
STATUS="$(curl -s -o "$BODY" -w '%{http_code}' --user "$OWNER:$PACKAGES_TOKEN" \
|
||||
--upload-file "$DEB" \
|
||||
"$SERVER_URL/api/packages/$OWNER/debian/pool/$DISTRIBUTION/main/upload")"
|
||||
case "$STATUS" in
|
||||
2??) printf '%s %s is published in %s/main.\n' "$PACKAGE" "$VERSION" "$DISTRIBUTION" ;;
|
||||
409) printf '%s %s is already in %s/main, skipping.\n' "$PACKAGE" "$VERSION" "$DISTRIBUTION" ;;
|
||||
*) printf 'ERROR: the upload to %s/main returned HTTP %s: %s\n' "$DISTRIBUTION" "$STATUS" "$(cat "$BODY")" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/python3
|
||||
"""Open files in the editor of the Router Manager's Shell page, from the shell of that page; vre is its short name.
|
||||
|
||||
Each file is named to the router's web service over SOCKET with the shell's session,
|
||||
$VPN_ROUTER_SHELL, which the web service sets in the shells it starts; the Shell page then shows
|
||||
the file in a tab of its editor, where it is read and saved. The command returns at once. A
|
||||
relative path is taken from the current directory, and a file that does not exist is created when
|
||||
it is saved. The exit status is 0 when every file was opened, 1 when the web service refused one,
|
||||
with its reason, and 2 outside the Router Manager's shell or for an invalid request. -h or --help
|
||||
shows the usage.
|
||||
|
||||
vre FILE...
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
|
||||
#: The socket of the router's web service that opens files in the editor of a shell.
|
||||
SOCKET = '/run/vpn-router/editor.sock'
|
||||
|
||||
#: Seconds to wait for the web service's answer.
|
||||
TIMEOUT = 10
|
||||
|
||||
|
||||
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 opened(session, path):
|
||||
"""Ask the web service to open the file in the editor of the session; None, or the reason it refused."""
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
||||
connection.settimeout(TIMEOUT)
|
||||
connection.connect(SOCKET)
|
||||
connection.sendall(json.dumps({'session': session, 'path': path}).encode() + b'\n')
|
||||
answer = json.loads(connection.makefile('rb').readline() or b'{}')
|
||||
return None if answer.get('ok') is True else str(answer.get('error', 'no answer'))
|
||||
|
||||
|
||||
def main(argv):
|
||||
prog = os.path.basename(sys.argv[0])
|
||||
parser = Parser(prog=prog, description="Open files in the editor of the Router Manager's Shell page.")
|
||||
parser.add_argument('files', nargs='+', metavar='FILE', help='a file to open')
|
||||
options = parser.parse_args(argv)
|
||||
session = os.environ.get('VPN_ROUTER_SHELL', '')
|
||||
if not session:
|
||||
print(f'{prog}: {prog} works only in the shell of the Router Manager', file=sys.stderr)
|
||||
return 2
|
||||
status = 0
|
||||
for name in options.files:
|
||||
path = os.path.abspath(os.path.expanduser(name))
|
||||
try:
|
||||
reason = opened(session, path)
|
||||
except (OSError, ValueError) as exc:
|
||||
reason = f'the web service cannot be reached: {exc}'
|
||||
if reason:
|
||||
print(f'{prog}: {path}: {reason}', file=sys.stderr)
|
||||
status = 1
|
||||
else:
|
||||
print(f'Opened {path} in the editor of the Shell page.')
|
||||
return status
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -8,6 +8,7 @@ the logs.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
PORT = 17081
|
||||
@@ -42,14 +43,28 @@ def report():
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
body = report().encode()
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'text/plain; charset=utf-8')
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
self.send_header('Refresh', '10')
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
try:
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'text/plain; charset=utf-8')
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
self.send_header('Refresh', '10')
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
except ConnectionError:
|
||||
# The client went away before the answer was written.
|
||||
self.close_connection = True
|
||||
|
||||
|
||||
def failed(request, client_address):
|
||||
"""Log a failed request in one line, and nothing when the client went away."""
|
||||
failure = sys.exc_info()[1]
|
||||
if not isinstance(failure, ConnectionError):
|
||||
print(f'a request from {client_address[0]} failed: {type(failure).__name__}: {failure}', file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
ThreadingHTTPServer(('', PORT), Handler).serve_forever()
|
||||
sys.excepthook = lambda kind, value, _: print(f'failed: {kind.__name__}: {value}', file=sys.stderr, flush=True)
|
||||
server = ThreadingHTTPServer(('', PORT), Handler)
|
||||
server.handle_error = failed
|
||||
server.serve_forever()
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/python3
|
||||
"""Read or write a file of the editor of the Router Manager's Shell page, as the user it runs as.
|
||||
|
||||
The web service runs it with systemd-run --uid=<console account> --pipe, outside its sandbox, so the
|
||||
file is read and written with the rights of the shell's user. The version of a content is the
|
||||
hexadecimal SHA-256 of its bytes; a missing file has the version of the empty content.
|
||||
|
||||
editor-file read PATH print the content of the file; nothing for a missing file
|
||||
editor-file write PATH VERSION replace the content with standard input when the file still has
|
||||
VERSION, writing in place, so its owner, mode and links stay;
|
||||
a missing file is created
|
||||
|
||||
Only regular files of at most MAX_BYTES bytes of UTF-8 text are read and written. The exit status
|
||||
is 0 on success, 2 for a file that is refused, with the reason on standard error, 3 when the file
|
||||
does not have VERSION any more, and 1 for any other failure.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
#: The largest file the editor opens, in bytes.
|
||||
MAX_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
"""A file the editor does not open or write; status is the exit status."""
|
||||
|
||||
def __init__(self, message, status=2):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
||||
|
||||
def text(data, path):
|
||||
"""The bytes when they are UTF-8 text of at most MAX_BYTES; Refused otherwise."""
|
||||
if len(data) > MAX_BYTES:
|
||||
raise Refused(f'{path} is larger than {MAX_BYTES // 1024} KiB')
|
||||
try:
|
||||
data.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
raise Refused(f'{path} is not UTF-8 text')
|
||||
return data
|
||||
|
||||
|
||||
def current(path):
|
||||
"""The content of the file, b'' when it does not exist; Refused for a file that is not a regular one."""
|
||||
if not os.path.lexists(path):
|
||||
return b''
|
||||
if not os.path.isfile(path):
|
||||
raise Refused(f'{path} is not a regular file')
|
||||
with open(path, 'rb') as handle:
|
||||
return text(handle.read(MAX_BYTES + 1), path)
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) < 2 or (argv[0], len(argv)) not in (('read', 2), ('write', 3)):
|
||||
print('usage: editor-file read PATH | editor-file write PATH VERSION', file=sys.stderr)
|
||||
return 2
|
||||
path = argv[1]
|
||||
try:
|
||||
if argv[0] == 'read':
|
||||
sys.stdout.buffer.write(current(path))
|
||||
return 0
|
||||
content = text(sys.stdin.buffer.read(MAX_BYTES + 1), path)
|
||||
if hashlib.sha256(current(path)).hexdigest() != argv[2]:
|
||||
raise Refused(f'{path} changed since it was read', 3)
|
||||
with open(path, 'r+b' if os.path.lexists(path) else 'xb') as handle:
|
||||
handle.write(content)
|
||||
handle.truncate()
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
except Refused as refused:
|
||||
print(refused, file=sys.stderr)
|
||||
return refused.status
|
||||
except OSError as exc:
|
||||
print(f'{exc.filename or path}: {exc.strerror}', file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -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())
|
||||
@@ -7,7 +7,7 @@ disables the unit.
|
||||
Every SAMPLE_SECONDS, on the minute, a sample is taken with psutil: the CPU utilization since the
|
||||
previous sample, the memory used and in total, the free and the total space of /, the bytes
|
||||
received and sent per second on the external and the internal interface, and the load averages;
|
||||
and the VPN clients of each enabled VPN system (vpnrouter_status): road-warrior (p2s) and
|
||||
and the VPN clients of each enabled VPN system (vpnrouter_status): IKEv2 (p2s) and
|
||||
OpenVPN clients and WireGuard peers with a recent handshake; and of each site-to-site connection
|
||||
that is set up, whether it is up, the bytes and packets per second through its child SAs since
|
||||
the previous sample, and how long its IKE SA has been up. The interfaces, the enabled systems and
|
||||
@@ -405,15 +405,19 @@ class Handler(BaseHTTPRequestHandler):
|
||||
registry = CollectorRegistry()
|
||||
registry.register(Sample(current))
|
||||
body, kind, code = generate_latest(registry), CONTENT_TYPE_LATEST, 200
|
||||
self.send_response(code)
|
||||
self.send_header('Content-Type', kind)
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
for name, value in extra:
|
||||
self.send_header(name, value)
|
||||
self.end_headers()
|
||||
if send_body:
|
||||
self.wfile.write(body)
|
||||
try:
|
||||
self.send_response(code)
|
||||
self.send_header('Content-Type', kind)
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
for name, value in extra:
|
||||
self.send_header(name, value)
|
||||
self.end_headers()
|
||||
if send_body:
|
||||
self.wfile.write(body)
|
||||
except ConnectionError:
|
||||
# The client went away before the answer was written.
|
||||
self.close_connection = True
|
||||
|
||||
def do_GET(self):
|
||||
self.answer(True)
|
||||
|
||||
+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,14 +33,18 @@ 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')
|
||||
WG_DIR = pathlib.Path('/etc/wireguard')
|
||||
WEB_STATE = pathlib.Path('/run/vpn-router/web.json')
|
||||
WEB_STATE = vpnrouter.WEB_STATE
|
||||
|
||||
#: The state, the current step and the failure of the last run, which the bootstrap services show.
|
||||
SETUP_STATE = pathlib.Path('/run/vpn-router/setup.json')
|
||||
@@ -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,217 @@ 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 site_addresses(cfg, ctx):
|
||||
"""The pairs of a host name and the router's address of dns_address of each enabled site with dns router: public,
|
||||
wan.public_address; internal, the internal interface's; external, the external interface's first. A site whose
|
||||
address is not known gets none, with a warning."""
|
||||
addresses = {'public': cfg['wan']['public_address'].strip(), 'internal': ctx['int_addr'],
|
||||
'external': next(iter(ctx['local_addr_list']), '')}
|
||||
pairs = []
|
||||
for site in ctx['sites']:
|
||||
if site['dns'] != 'router':
|
||||
continue
|
||||
address = addresses[site['dns_address']]
|
||||
if not address:
|
||||
warn(f'site {site["name"]}: the router has no {site["dns_address"]} address, so its host names get no '
|
||||
'records')
|
||||
continue
|
||||
pairs += [(host, address) for host in site['hosts']]
|
||||
return pairs
|
||||
|
||||
|
||||
def manage_dns(cfg, ctx):
|
||||
"""Run PowerDNS, the primary name server of the domains of vpnrouter_dns, 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 host names
|
||||
of the enabled sites with dns router get records of the router's address they choose (site_addresses), and the
|
||||
domains keep those they have 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, site_addresses(cfg, ctx))
|
||||
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 +898,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 +974,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 +1020,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 +1043,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 +1074,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 +1082,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 +1111,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,64 +12,86 @@ 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')
|
||||
#: What setup resolved and runs, for the web service (setup's refresh_web).
|
||||
WEB_STATE = pathlib.Path('/run/vpn-router/web.json')
|
||||
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 = {
|
||||
'general': {'platform': 'none', 'mode': 'manual'},
|
||||
'interfaces': {'external': '', 'internal': ''},
|
||||
'wan': {'local_fqdn': ''},
|
||||
'wan': {'local_fqdn': '', 'public_address': ''},
|
||||
'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', 'dns_domains': []},
|
||||
'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',
|
||||
'acme_staging': False},
|
||||
'oidc': {'enabled': False},
|
||||
'sites': {'enabled': False, 'upload_max_mb': 512, 'webdav': False, 'webdav_port': 7445},
|
||||
'dns': {'enabled': False, 'secondary': '', '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 +105,149 @@ 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
|
||||
|
||||
#: Settings that no longer exist, section -> options, left out when the file is read so that a file of an earlier
|
||||
#: version loads: the name server is wan.local_fqdn and a site chooses the address of its records.
|
||||
RETIRED = {'dns': ('nameserver', 'addresses')}
|
||||
|
||||
#: The shortest and the longest dns.ttl, in seconds.
|
||||
DNS_TTL_RANGE = (60, 86400)
|
||||
|
||||
#: A DNS name without a wildcard, in lowercase, of the name servers and the domains of the name server.
|
||||
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])?)+$')
|
||||
|
||||
#: A domain of p2s.dns_domains, one label or more, and the most domains.
|
||||
DNS_DOMAIN = 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])?)*$')
|
||||
DNS_DOMAINS_MAX = 16
|
||||
|
||||
#: 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')
|
||||
|
||||
#: The ACME directory of Let's Encrypt's staging environment, Caddy's only CA while https.acme_staging is true.
|
||||
ACME_STAGING_CA = 'https://acme-staging-v02.api.letsencrypt.org/directory'
|
||||
|
||||
#: 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 +255,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})?$')
|
||||
|
||||
@@ -148,6 +304,9 @@ def read_settings(path=None):
|
||||
for section, options in data.items():
|
||||
if not isinstance(options, dict):
|
||||
raise ConfigError(f'cannot parse {path}: {section} is not an object')
|
||||
for section, options in RETIRED.items():
|
||||
for option in options:
|
||||
data.get(section, {}).pop(option, None)
|
||||
return data
|
||||
|
||||
|
||||
@@ -308,6 +467,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 +488,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 +505,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 +543,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 +564,158 @@ 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')
|
||||
|
||||
_check_address(_get(cfg, 'wan', 'public_address'), 'wan.public_address')
|
||||
dns = cfg['dns']
|
||||
if dns['enabled'] and not (ZONE_NAME.match(_get(cfg, 'wan', 'local_fqdn'))
|
||||
and not re.search(r'\.[0-9]+$', _get(cfg, 'wan', 'local_fqdn'))):
|
||||
raise ConfigError('dns.enabled needs wan.local_fqdn, a DNS name in lowercase: the primary name server of the '
|
||||
'domains')
|
||||
secondary = _get(cfg, 'dns', 'secondary')
|
||||
if secondary and not ZONE_NAME.match(secondary):
|
||||
raise ConfigError(f'dns.secondary: {secondary!r} is not a DNS name in lowercase')
|
||||
if secondary and secondary == _get(cfg, 'wan', 'local_fqdn'):
|
||||
raise ConfigError('dns.secondary: the secondary name server is not wan.local_fqdn, the primary')
|
||||
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')
|
||||
dns_domains = _get_list(cfg, 'p2s', 'dns_domains')
|
||||
if len(dns_domains) > DNS_DOMAINS_MAX or len(set(dns_domains)) != len(dns_domains):
|
||||
raise ConfigError(f'p2s.dns_domains: at most {DNS_DOMAINS_MAX} different domains')
|
||||
for item in dns_domains:
|
||||
if not DNS_DOMAIN.match(item):
|
||||
raise ConfigError(f'p2s.dns_domains: {item!r} is not a DNS name in lowercase')
|
||||
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 +734,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 +789,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 +801,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 +967,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 +988,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 +1013,17 @@ 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_domains': _get_list(cfg, 'p2s', 'dns_domains'),
|
||||
'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,23 +1034,34 @@ 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'),
|
||||
'web_port': _get(cfg, 'web', 'port'),
|
||||
'https_enabled': _get(cfg, 'https', 'enabled'),
|
||||
'https_email': _get(cfg, 'https', 'email'),
|
||||
'https_acme_ca': ACME_STAGING_CA if _get(cfg, 'https', 'acme_staging') else '',
|
||||
'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 +1078,8 @@ def routing_ready(ctx):
|
||||
|
||||
|
||||
def p2s_ready(ctx):
|
||||
return bool(ctx['p2s_enabled'] and ctx['p2s_address_pool'] and ctx['local_fqdn'])
|
||||
"""IKEv2 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 +1087,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 +1101,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 its domains while dns.enabled is true."""
|
||||
return bool(ctx['dns_enabled'])
|
||||
|
||||
|
||||
def _jinja_env():
|
||||
return jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(str(TEMPLATE_DIR)),
|
||||
@@ -676,27 +1134,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 +1200,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 +1222,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 +1261,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,375 @@
|
||||
"""The router's authoritative name server, PowerDNS with its gpgsql backend, for vpn-router-setup and the web service.
|
||||
|
||||
The name server is the primary of its domains and answers on the addresses of the interfaces of dns.listen. The
|
||||
domains and the records an administrator added (manual 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 every domain as a primary zone: its SOA with wan.local_fqdn as the primary name server, its NS,
|
||||
wan.local_fqdn and dns.secondary, the NS of the domains directly below it, an A or AAAA record for every host name of
|
||||
an enabled site with dns router whose closest domain it is, with the router's address the site chooses, and the manual
|
||||
records, but those of a name and type the records before them have. wan.local_fqdn gets no record in the zones: its
|
||||
record is kept where its own domain is. The addresses dns.secondary resolves to are notified of every change
|
||||
(ALSO-NOTIFY) and may transfer the zones (ALLOW-AXFR-FROM); the serial of the SOA grows only when the records change.
|
||||
|
||||
Every failure of the database raises DatabaseError; a request that cannot be done raises Failure.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import ipaddress
|
||||
import pathlib
|
||||
import re
|
||||
import socket
|
||||
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
import vpnrouter
|
||||
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 domains, and manual records of a domain.
|
||||
DOMAINS_MAX = 100
|
||||
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 request that cannot be done; status is 2 invalid, 3 no such domain or record, 4 the domain exists."""
|
||||
|
||||
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 closest(zones, name):
|
||||
"""The longest of the zones the name is in, None when it is in none."""
|
||||
return max((zone for zone in zones if in_zone(zone, name)), key=len, default=None)
|
||||
|
||||
|
||||
def check_domain(name):
|
||||
"""The domain name in lowercase without a final dot; Failure when it is not a DNS name."""
|
||||
if not isinstance(name, str) or not vpnrouter.ZONE_NAME.match(name.strip().lower().rstrip('.')):
|
||||
raise Failure(f'{name!r} is not a domain name')
|
||||
return name.strip().lower().rstrip('.')
|
||||
|
||||
|
||||
def check_ttl(ttl):
|
||||
if ttl is not None and (isinstance(ttl, bool) or not isinstance(ttl, int) or
|
||||
not vpnrouter.DNS_TTL_RANGE[0] <= ttl <= vpnrouter.DNS_TTL_RANGE[1]):
|
||||
raise Failure(f'ttl must be {vpnrouter.DNS_TTL_RANGE[0]} to {vpnrouter.DNS_TTL_RANGE[1]} seconds')
|
||||
return ttl
|
||||
|
||||
|
||||
def domain(connection, name):
|
||||
"""The domain; Failure with status 3 when there is none."""
|
||||
row = next((row for row in connection.execute('SELECT * FROM dns_domains_list()').fetchall()
|
||||
if row['name'] == name), None)
|
||||
if row is None:
|
||||
raise Failure(f'no domain {name}', 3)
|
||||
return row
|
||||
|
||||
|
||||
def listing():
|
||||
"""Every domain with its manual records and the records the name server answers for it."""
|
||||
with database() as connection:
|
||||
manual = connection.execute('SELECT * FROM dns_records_list()').fetchall()
|
||||
return [{**row,
|
||||
'manual': [{key: item[key] for key in ('name', 'type', 'content', 'ttl')}
|
||||
for item in manual if item['domain'] == row['name']],
|
||||
'records': connection.execute('SELECT * FROM dns_served(%s)', (row['name'],)).fetchall()}
|
||||
for row in connection.execute('SELECT * FROM dns_domains_list()').fetchall()]
|
||||
|
||||
|
||||
def domain_add(body):
|
||||
"""Add the domain {name, ttl?}; the domain."""
|
||||
if not isinstance(body, dict) or not set(body) <= {'name', 'ttl'}:
|
||||
raise Failure('a domain is an object of name and ttl')
|
||||
name, ttl = check_domain(body.get('name')), check_ttl(body.get('ttl'))
|
||||
with database() as connection:
|
||||
names = [row['name'] for row in connection.execute('SELECT * FROM dns_domains_list()').fetchall()]
|
||||
if name in names:
|
||||
raise Failure(f'the domain {name} exists', 4)
|
||||
if len(names) >= DOMAINS_MAX:
|
||||
raise Failure(f'the name server has {DOMAINS_MAX} domains')
|
||||
connection.execute('CALL dns_domain_put(%s, %s)', (name, ttl))
|
||||
return {'name': name, 'ttl': ttl}
|
||||
|
||||
|
||||
def domain_change(name, body):
|
||||
"""Change the ttl of the domain; the domain."""
|
||||
if not isinstance(body, dict) or not set(body) <= {'ttl'}:
|
||||
raise Failure('a change of a domain is an object of ttl')
|
||||
with database() as connection:
|
||||
row = domain(connection, name)
|
||||
ttl = check_ttl(body.get('ttl', row['ttl']))
|
||||
connection.execute('CALL dns_domain_put(%s, %s)', (name, ttl))
|
||||
return {'name': name, 'ttl': ttl}
|
||||
|
||||
|
||||
def domain_remove(name):
|
||||
"""Remove the domain with its manual records."""
|
||||
with database() as connection:
|
||||
domain(connection, name)
|
||||
connection.execute('CALL dns_domain_remove(%s)', (name,))
|
||||
return {'name': name}
|
||||
|
||||
|
||||
def secondary_addresses(cfg):
|
||||
"""The addresses dns.secondary resolves to, which are notified of the zones' changes and may transfer them; none,
|
||||
with a warning, while it is not set or does not resolve."""
|
||||
secondary = cfg['dns']['secondary'].strip()
|
||||
if not secondary:
|
||||
vpnrouter.warn('dns.secondary is not set: the domains have no secondary name server')
|
||||
return []
|
||||
try:
|
||||
found = socket.getaddrinfo(secondary, 53, proto=socket.IPPROTO_UDP)
|
||||
except OSError as exc:
|
||||
vpnrouter.warn(f'dns.secondary {secondary} does not resolve, so it may not transfer the domains: {exc}')
|
||||
return []
|
||||
return list(dict.fromkeys(str(ipaddress.ip_address(item[4][0])) for item in found))
|
||||
|
||||
|
||||
def add(cfg, name, record):
|
||||
"""Add the manual record to the domain, or set the TTL of the same one; the record. A name in a domain below it
|
||||
belongs to that domain."""
|
||||
with database() as connection:
|
||||
row = domain(connection, name)
|
||||
record = check_record(name, record)
|
||||
record['ttl'] = record['ttl'] or row['ttl'] or cfg['dns']['ttl']
|
||||
zones = [item['name'] for item in connection.execute('SELECT * FROM dns_domains_list()').fetchall()]
|
||||
owner = closest(zones, record['name'])
|
||||
if owner != name:
|
||||
raise Failure(f'{record["name"]} is in the domain {owner}')
|
||||
records = [item for item in connection.execute('SELECT * FROM dns_records_list()').fetchall()
|
||||
if item['domain'] == name]
|
||||
same = [item for item in records if (item['name'], item['type']) == (record['name'], record['type'])]
|
||||
others = [item for item in records if item['name'] == record['name'] and item['type'] != record['type']]
|
||||
if len(records) >= MANUAL_MAX and not same:
|
||||
raise Failure(f'the domain {name} has {MANUAL_MAX} manual records')
|
||||
if record['type'] == 'CNAME' and (others or [item for item in same if item['content'] != record['content']]):
|
||||
raise Failure(f'{record["name"]} has other records; a CNAME record must be its only record')
|
||||
if any(item['type'] == 'CNAME' for item in others):
|
||||
raise Failure(f'{record["name"]} has a CNAME record, which must be its only record')
|
||||
connection.execute('CALL dns_record_put(%s, %s, %s, %s, %s)',
|
||||
(name, record['name'], record['type'], record['content'], record['ttl']))
|
||||
return record
|
||||
|
||||
|
||||
def remove(name, record_name, kind, content):
|
||||
"""Remove the manual records of the name and type in the domain, only the one of the content when it is given."""
|
||||
full = absolute(name, record_name)
|
||||
with database() as connection:
|
||||
domain(connection, name)
|
||||
found = [row for row in connection.execute('SELECT * FROM dns_records_list()').fetchall()
|
||||
if (row['domain'], row['name'], row['type']) == (name, full, kind)
|
||||
and content in (None, row['content'])]
|
||||
if not found:
|
||||
raise Failure(f'no {kind} record of {full}', 3)
|
||||
connection.execute('CALL dns_record_remove(%s, %s, %s, %s)', (name, full, kind, content))
|
||||
return {'name': full, 'type': kind, 'removed': len(found)}
|
||||
|
||||
|
||||
def records(cfg, row, zones, hosts, manual_records):
|
||||
"""The records of the domain row besides its SOA, as (name, type, content, ttl): the NS of the domain and of the
|
||||
domains directly below it, wan.local_fqdn and dns.secondary, the A or AAAA record of each host name of hosts, pairs
|
||||
of a host name and an address, whose closest domain it is, and its manual records."""
|
||||
dns = cfg['dns']
|
||||
zone, ttl = row['name'], row['ttl'] or dns['ttl']
|
||||
servers = [cfg['wan']['local_fqdn'].strip(), *([dns['secondary'].strip()] if dns['secondary'].strip() else [])]
|
||||
children = [name for name in zones
|
||||
if name != zone and closest([item for item in zones if item != name], name) == zone]
|
||||
found = [(name, 'NS', server, ttl) for name in [zone, *children] for server in servers]
|
||||
for host, address in hosts:
|
||||
if closest(zones, host) == zone:
|
||||
address = ipaddress.ip_address(address)
|
||||
found.append((host, 'A' if address.version == 4 else 'AAAA', str(address), ttl))
|
||||
taken = {(name, kind) for name, kind, _, _ in found}
|
||||
found += [(item['name'], item['type'], item['content'], item['ttl']) for item in manual_records
|
||||
if (item['name'], item['type']) not in taken]
|
||||
return list(dict.fromkeys(found))
|
||||
|
||||
|
||||
def apply(cfg, hosts):
|
||||
"""Make the name server the primary of every domain with records(), hosts being the pairs of a host name and an
|
||||
address of the sites with dns router, and of none while dns.enabled is false."""
|
||||
with database() as connection:
|
||||
if not cfg['dns']['enabled']:
|
||||
connection.execute('CALL dns_retain(%s::text[])', ([],))
|
||||
return
|
||||
dns = cfg['dns']
|
||||
domains = connection.execute('SELECT * FROM dns_domains_list()').fetchall()
|
||||
zones = [row['name'] for row in domains]
|
||||
for host, _ in hosts:
|
||||
if closest(zones, host) is None:
|
||||
vpnrouter.warn(f'{host} is in none of the domains, so the name server has no record of it')
|
||||
manual = {}
|
||||
for item in connection.execute('SELECT * FROM dns_records_list()').fetchall():
|
||||
manual.setdefault(item['domain'], []).append(item)
|
||||
transfers = secondary_addresses(cfg) if domains else []
|
||||
for row in domains:
|
||||
zone, ttl = row['name'], row['ttl'] or dns['ttl']
|
||||
rows = records(cfg, row, zones, hosts, manual.get(zone, []))
|
||||
soa = f'{cfg["wan"]["local_fqdn"].strip()} hostmaster.{zone} {{serial}} {SOA_TIMES} {ttl}'
|
||||
connection.execute('CALL dns_apply(%s, %s, %s, %s::text[], %s::text[], %s::text[], %s::integer[], '
|
||||
'%s::text[])',
|
||||
(zone, soa, ttl, [item[0] for item in rows], [item[1] for item in rows],
|
||||
[item[2] for item in rows], [item[3] for item in rows], transfers))
|
||||
connection.execute('CALL dns_retain(%s::text[])', (zones,))
|
||||
|
||||
|
||||
def config_text(addresses):
|
||||
"""The settings of PowerDNS: the gpgsql backend on the database of the name server, primary operation, 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}',
|
||||
'primary=yes',
|
||||
f'local-address={", ".join(addresses)}',
|
||||
'local-port=53',
|
||||
'',
|
||||
])
|
||||
@@ -11,6 +11,16 @@ 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.
|
||||
|
||||
ou=roles holds the roles of the resources of the OpenID Provider: an entry per role,
|
||||
cn=<resource>:<role>, whose members are the groups, users and service accounts it is assigned to.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -24,11 +34,15 @@ 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}'
|
||||
ROLES = f'ou=roles,{BASE}'
|
||||
DEFAULT_POLICY = f'cn=default,{POLICIES}'
|
||||
TOTP_PARAMS = f'cn=totp,{POLICIES}'
|
||||
MANAGER = f'cn=user-manager,{SERVICES}'
|
||||
@@ -52,17 +66,84 @@ 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; vpnRouterGrantType, the further grants it may use,
|
||||
#: vpnRouterPublicClient, whether it has no secret, and vpnRouterClientScope, the scopes of
|
||||
#: resources it may ask for, as <resource>:<scope>. A service account with vpnRouterResourceScope
|
||||
#: is a resource of the OpenID Provider with those scopes. vpnRouterRole is a role of a resource,
|
||||
#: named cn=<resource>:<role>, with the DNs of the groups and accounts it is assigned to in
|
||||
#: member. 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 )",
|
||||
f"( {SCHEMA_OID}.1.20 NAME 'vpnRouterGrantType' DESC 'VPN router: a grant an OpenID Connect client may use besides "
|
||||
"the authorization code' EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{64} )",
|
||||
f"( {SCHEMA_OID}.1.21 NAME 'vpnRouterPublicClient' DESC 'VPN router: whether an OpenID Connect client has no secret' "
|
||||
"EQUALITY booleanMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 SINGLE-VALUE )",
|
||||
f"( {SCHEMA_OID}.1.22 NAME 'vpnRouterClientScope' DESC 'VPN router: a scope of a resource an OpenID Connect client "
|
||||
"may ask for' EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{128} )",
|
||||
f"( {SCHEMA_OID}.1.23 NAME 'vpnRouterResourceScope' DESC 'VPN router: a scope of a resource of the OpenID Provider' "
|
||||
"EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{64} )",
|
||||
)
|
||||
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 $ vpnRouterGrantType $ vpnRouterPublicClient $ "
|
||||
"vpnRouterClientScope $ vpnRouterResourceScope ) )",
|
||||
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 ) )",
|
||||
f"( {SCHEMA_OID}.2.4 NAME 'vpnRouterRole' DESC 'VPN router: a role of a resource of the OpenID Provider' SUP top "
|
||||
"STRUCTURAL MUST cn MAY ( description $ member ) )",
|
||||
)
|
||||
|
||||
#: The TOTP parameters: digits, the OID of HMAC-SHA1, seconds per time step, and the time steps accepted before and after now.
|
||||
@@ -71,12 +152,27 @@ 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',
|
||||
f'to dn.subtree="{GROUPS}" by dn.exact="{MANAGER}" manage by * read',
|
||||
f'to dn.subtree="{ROLES}" by dn.exact="{MANAGER}" manage by * read',
|
||||
'to * by * read',
|
||||
)
|
||||
|
||||
@@ -125,20 +221,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 +252,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 +265,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 +327,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 +354,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, ROLES):
|
||||
_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,982 @@
|
||||
"""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 or grants (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 public client
|
||||
sends only its ID. 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 and the access 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, a refresh token or a device code for tokens, or a client's
|
||||
credentials for an access token
|
||||
POST /oidc/device_authorization begin the device authorization grant (RFC 8628): a device code and a user code
|
||||
GET, POST /oidc/device the page where the user enters the user code, signs in and is asked to allow
|
||||
the device
|
||||
POST /oidc/device/decide allow or deny the device
|
||||
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
|
||||
|
||||
The grants: authorization_code for a client with redirect URIs, client_credentials, by which a confidential client
|
||||
gets an access token as itself, and the device code, as the client's grants give them, each with refresh_token but
|
||||
client_credentials. A client asks for tokens for a resource, a service account, with resource (RFC 8707), its name or
|
||||
<issuer>/<name>, or by the resource's scopes it asks for, <resource>:<scope>, of its client scopes; without one the
|
||||
tokens are for the client itself.
|
||||
|
||||
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, for the client. Access tokens are RS256 JWTs of RFC 9068 with the same key, for
|
||||
the resource or the client: iss, sub, aud, client_id, iat, exp, jti, scope, roles, the claims of the scopes, and of a
|
||||
user auth_time and amr. 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 but with client_credentials, which refuses it; profile, preferred_username and
|
||||
the names; email; groups, the names of the user's groups; their claims are in the ID token, the access token and the
|
||||
userinfo answer; and the scopes of the resources. roles are the roles of the resource assigned to the user or service
|
||||
account or a group of it (vpnrouter_users.roles_held), read at every issue; the ID token and the userinfo answer have
|
||||
those of the client. A code is valid CODE_SECONDS, an access token and an ID token ACCESS_SECONDS, a refresh token
|
||||
REFRESH_SECONDS, used once, and a device code DEVICE_SECONDS, polled at most every DEVICE_INTERVAL seconds, 5 more after
|
||||
each poll that came too soon. A session, the cookie COOKIE of the issuer's /oidc/ path, lasts SESSION_SECONDS. Codes,
|
||||
tokens, device codes 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 or a service account's. A client is registered by an
|
||||
administrator, so a user is not asked for consent, but for a device, which the user allows on the router's page.
|
||||
"""
|
||||
|
||||
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 Request, add_params_to_uri
|
||||
from oauthlib.oauth2 import (AccessDeniedError, FatalClientError, InvalidClientError, InvalidGrantError,
|
||||
InvalidRequestError, InvalidScopeError, OAuth2Error, UnauthorizedClientError,
|
||||
UnsupportedGrantTypeError)
|
||||
from oauthlib.oauth2.rfc6749 import utils
|
||||
from oauthlib.oauth2.rfc6749.grant_types.base import GrantTypeBase
|
||||
from oauthlib.oauth2.rfc6749.tokens import random_token_generator
|
||||
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
|
||||
DEVICE_SECONDS = 600
|
||||
DEVICE_INTERVAL = 5
|
||||
|
||||
#: The grant type of the device authorization grant.
|
||||
DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'
|
||||
#: A user code: USER_CODE_LENGTH of these letters, shown in two halves.
|
||||
USER_CODE_LETTERS = 'BCDFGHJKLMNPQRSTVWXZ'
|
||||
USER_CODE_LENGTH = 8
|
||||
|
||||
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; a service account's client credentials are its password.
|
||||
PASSWORD_AMR = ['pwd']
|
||||
OTP_AMR = ['pwd', 'otp']
|
||||
PASSKEY_AMR = ['hwk', 'user']
|
||||
SERVICE_AMR = ['pwd']
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class InvalidTarget(OAuth2Error):
|
||||
"""A resource that is unknown, or more than one (RFC 8707)."""
|
||||
error = 'invalid_target'
|
||||
|
||||
|
||||
class AuthorizationPending(OAuth2Error):
|
||||
"""A device code the user has not yet allowed or denied (RFC 8628)."""
|
||||
error = 'authorization_pending'
|
||||
|
||||
|
||||
class SlowDown(OAuth2Error):
|
||||
"""A device code polled sooner than its interval (RFC 8628)."""
|
||||
error = 'slow_down'
|
||||
|
||||
|
||||
class ExpiredToken(OAuth2Error):
|
||||
"""A device code that has expired (RFC 8628)."""
|
||||
error = 'expired_token'
|
||||
|
||||
|
||||
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, headers=None):
|
||||
"""The claims as an RS256 JWT with the key's kid and the headers given."""
|
||||
key = private_key()
|
||||
return jwt.encode(claims, key, algorithm='RS256', headers={'kid': public_jwk(key)['kid'], **(headers or {})})
|
||||
|
||||
|
||||
def discovery(issuer, resource_scopes):
|
||||
"""The discovery document of OpenID Connect Discovery 1.0, with the device authorization endpoint of RFC 8628;
|
||||
resource_scopes are the scopes of the resources."""
|
||||
return {
|
||||
'issuer': issuer,
|
||||
'authorization_endpoint': f'{issuer}/oidc/authorize',
|
||||
'token_endpoint': f'{issuer}/oidc/token',
|
||||
'device_authorization_endpoint': f'{issuer}/oidc/device_authorization',
|
||||
'userinfo_endpoint': f'{issuer}/oidc/userinfo',
|
||||
'jwks_uri': f'{issuer}/oidc/jwks',
|
||||
'end_session_endpoint': f'{issuer}/oidc/logout',
|
||||
'scopes_supported': [*SCOPES, *resource_scopes],
|
||||
'response_types_supported': ['code'],
|
||||
'response_modes_supported': ['query'],
|
||||
'grant_types_supported': ['authorization_code', 'refresh_token', 'client_credentials', DEVICE_GRANT],
|
||||
'subject_types_supported': ['public'],
|
||||
'id_token_signing_alg_values_supported': ['RS256'],
|
||||
'token_endpoint_auth_methods_supported': ['client_secret_basic', 'client_secret_post', 'none'],
|
||||
'code_challenge_methods_supported': ['S256'],
|
||||
'claims_supported': ['iss', 'sub', 'aud', 'exp', 'iat', 'auth_time', 'nonce', 'amr', 'at_hash', 'client_id',
|
||||
'scope', 'roles', *(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 account_of(name):
|
||||
"""A namespace of the vpnrouter_users.summary of the service account of the name, else None."""
|
||||
if not isinstance(name, str):
|
||||
return None
|
||||
with directory() as connection:
|
||||
try:
|
||||
user = vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
|
||||
except vpnrouter_users.Failure:
|
||||
return None
|
||||
return types.SimpleNamespace(**user) if user['service'] else None
|
||||
|
||||
|
||||
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 or grants; else None."""
|
||||
account = account_of(client_id)
|
||||
if account is None or not account.enabled or not (account.redirect_uris or account.grants):
|
||||
return None
|
||||
account.client_id = client_id
|
||||
return account
|
||||
|
||||
|
||||
def resource_of(name):
|
||||
"""The resource of the name, a namespace of its vpnrouter_users.summary, when it is an enabled service account that
|
||||
is not internal; else None."""
|
||||
account = account_of(name)
|
||||
return account if account is not None and account.enabled and not account.internal else None
|
||||
|
||||
|
||||
def resource_scopes():
|
||||
"""The scopes of every resource, <resource>:<scope>, sorted."""
|
||||
with directory() as connection:
|
||||
users = vpnrouter_users.list_users(connection)['users']
|
||||
return sorted(f'{user["name"]}:{scope}' for user in users
|
||||
if user['service'] and user['enabled'] and not user['internal'] for scope in user['resource_scopes'])
|
||||
|
||||
|
||||
def account_claims(connection, name):
|
||||
"""The summary of the entry of the name and its claims of every scope, with sub; Failure when there is none."""
|
||||
user = vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
|
||||
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']
|
||||
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 user, {claim: value for claim, value in claims.items() if value is not None}
|
||||
|
||||
|
||||
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:
|
||||
user, claims = account_claims(connection, name)
|
||||
except vpnrouter_users.Failure:
|
||||
return None
|
||||
if user['service'] or not user['enabled'] or user['locked']:
|
||||
return None
|
||||
if client.allowed_groups and not set(client.allowed_groups) & set(claims['groups']):
|
||||
return None
|
||||
return claims
|
||||
|
||||
|
||||
def service_subject(name):
|
||||
"""The claims of every scope of the service account, with sub."""
|
||||
with directory() as connection:
|
||||
return account_claims(connection, name)[1]
|
||||
|
||||
|
||||
def roles_at(resource, name):
|
||||
"""The roles of the resource the user or service account holds."""
|
||||
with directory() as connection:
|
||||
return vpnrouter_users.roles_held(connection, resource, name)
|
||||
|
||||
|
||||
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 target_of(issuer, resource, scopes):
|
||||
"""The name of the resource a request with the resource parameter, None when absent, and the scopes names, None
|
||||
for none; InvalidTarget when they name more than one or one that is not a resource."""
|
||||
names = {scope.partition(':')[0] for scope in scopes if scope not in SCOPES}
|
||||
if resource is not None:
|
||||
names.add(resource[len(issuer) + 1:] if isinstance(resource, str) and resource.startswith(f'{issuer}/') else resource)
|
||||
if not names:
|
||||
return None
|
||||
if len(names) > 1:
|
||||
raise InvalidTarget(description='the resource and the scopes name more than one resource')
|
||||
name = names.pop()
|
||||
if resource_of(name) is None:
|
||||
raise InvalidTarget(description=f'{name} is not a resource')
|
||||
return name
|
||||
|
||||
|
||||
def scopes_allowed(client, scopes, target, grant_type):
|
||||
"""Whether the client may ask for the scopes: openid, required but by client_credentials, which refuses it, the
|
||||
other scopes of SCOPES, and those of its client scopes that belong to the target."""
|
||||
for scope in scopes:
|
||||
if scope not in SCOPES and (scope not in client.client_scopes or scope.partition(':')[0] != target):
|
||||
return False
|
||||
return ('openid' not in scopes) if grant_type == 'client_credentials' else ('openid' in scopes)
|
||||
|
||||
|
||||
def same_redirect(registered, given):
|
||||
"""Whether the redirect URI given is the registered one; an http URI of a loopback host may have any port (RFC 8252
|
||||
7.3)."""
|
||||
if registered == given:
|
||||
return True
|
||||
try:
|
||||
wanted, found = urllib.parse.urlsplit(registered), urllib.parse.urlsplit(given)
|
||||
# A port that is not a number makes .port raise ValueError.
|
||||
wanted.port, found.port
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
return False
|
||||
return wanted.scheme == 'http' and wanted.hostname in vpnrouter_users.LOOPBACK_HOSTS and not found.fragment and \
|
||||
(wanted.scheme, wanted.hostname, wanted.path, wanted.query) == (found.scheme, found.hostname, found.path, found.query)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def access_token(cfg, request):
|
||||
"""The access token of a token request: a JWT of RFC 9068 for the request's target, or the client."""
|
||||
now = int(time.time())
|
||||
client_id = request.client.client_id
|
||||
service = request.grant_type == 'client_credentials'
|
||||
name = client_id if service else request.user
|
||||
person = service_subject(client_id) if service else request.oidc_person
|
||||
target = getattr(request, 'oidc_target', None)
|
||||
claims = {**claims_of(person, request.scopes), 'iss': vpnrouter.oidc_issuer(cfg), 'aud': target or client_id,
|
||||
'client_id': client_id, 'iat': now, 'exp': now + ACCESS_SECONDS, 'jti': secrets.token_urlsafe(16),
|
||||
'scope': ' '.join(request.scopes)}
|
||||
roles = roles_at(target or client_id, name)
|
||||
if roles:
|
||||
claims['roles'] = roles
|
||||
if not service:
|
||||
claims.update({'auth_time': int(request.auth_time.timestamp()), 'amr': request.amr})
|
||||
return sign(claims, {'typ': 'at+jwt'})
|
||||
|
||||
|
||||
class Validator(RequestValidator):
|
||||
"""The checks and the storage of oauthlib's server for the router's clients, users and database.
|
||||
|
||||
The grants are those of validate_grant_type, with PKCE S256 for the authorization code. The values of a code, a
|
||||
token, a device code or a session found are kept on the request as oidc_code, oidc_scopes and oidc_person, the
|
||||
resource the tokens are for as oidc_target.
|
||||
"""
|
||||
|
||||
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'))
|
||||
|
||||
def check_target(self, request, stored):
|
||||
"""Refuse a resource parameter that names another resource than the one the grant was made for."""
|
||||
resource = getattr(request, 'resource', None)
|
||||
if 'resource' in request.duplicate_params or \
|
||||
resource is not None and target_of(vpnrouter.oidc_issuer(self.cfg), resource, []) != stored:
|
||||
raise InvalidTarget(description='the grant was made for another resource', request=request)
|
||||
request.oidc_target = stored
|
||||
|
||||
# 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 any(same_redirect(uri, redirect_uri) for 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):
|
||||
"""Whether the client may ask for the scopes; the resource they are for is kept as oidc_target."""
|
||||
if 'resource' in request.duplicate_params:
|
||||
raise InvalidTarget(description='give one resource', request=request)
|
||||
try:
|
||||
request.oidc_target = target_of(vpnrouter.oidc_issuer(self.cfg), getattr(request, 'resource', None), scopes)
|
||||
except InvalidTarget as error:
|
||||
raise InvalidTarget(description=error.description, request=request)
|
||||
return scopes_allowed(client, scopes, request.oidc_target, request.grant_type)
|
||||
|
||||
def get_default_scopes(self, client_id, request, *args, **kwargs):
|
||||
return [] if request.grant_type == 'client_credentials' else ['openid']
|
||||
|
||||
def is_pkce_required(self, client_id, request):
|
||||
return True
|
||||
|
||||
def client_authentication_required(self, request, *args, **kwargs):
|
||||
"""Whether the client must sign the request with its secret: unless it is a public client named by client_id."""
|
||||
client = client_of(request.client_id)
|
||||
return client is None or not client.public_client
|
||||
|
||||
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; a public client has none."""
|
||||
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 or client.public_client:
|
||||
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):
|
||||
"""Accept a public client by its ID."""
|
||||
client = client_of(client_id)
|
||||
if client is None or not client.public_client:
|
||||
return False
|
||||
request.client = client
|
||||
return True
|
||||
|
||||
def validate_grant_type(self, client_id, grant_type, client, request, *args, **kwargs):
|
||||
"""The grants of the client: authorization_code with redirect URIs, client_credentials but for a public client,
|
||||
the device code, and refresh_token with either of the first and the last."""
|
||||
grants = set(client.grants) - ({'client_credentials'} if client.public_client else set())
|
||||
if client.redirect_uris:
|
||||
grants.add('authorization_code')
|
||||
if 'device_code' in grants:
|
||||
grants.add(DEVICE_GRANT)
|
||||
if grants & {'authorization_code', DEVICE_GRANT}:
|
||||
grants.add('refresh_token')
|
||||
return grant_type in grants
|
||||
|
||||
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, %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,
|
||||
getattr(request, 'oidc_target', None), 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', 'resource'), 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
|
||||
self.check_target(request, found['resource'])
|
||||
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),)))
|
||||
|
||||
# Device codes.
|
||||
|
||||
def poll_device(self, code, request):
|
||||
"""Poll the device code for the request's client: with the user's sign-in on the request when the user allowed
|
||||
it, else the error RFC 8628 gives."""
|
||||
row = self.fetch('SELECT * FROM oidc_device_poll(%s)', (vpnrouter_auth.digest(code),)) \
|
||||
if isinstance(code, str) and code else None
|
||||
found = dict(zip(('client_id', 'scopes', 'resource', 'state', 'name', 'auth_time', 'amr', 'expired', 'too_soon'),
|
||||
row)) if row else None
|
||||
if found is None or found['client_id'] != request.client.client_id:
|
||||
raise InvalidGrantError(description='the device code is unknown', request=request)
|
||||
if found['expired']:
|
||||
raise ExpiredToken(description='the device code has expired', request=request)
|
||||
if found['state'] == 'pending':
|
||||
raise (SlowDown if found['too_soon'] else AuthorizationPending)(request=request)
|
||||
if found['state'] == 'denied':
|
||||
self.call(('CALL oidc_device_delete(%s)', (vpnrouter_auth.digest(code),)))
|
||||
raise AccessDeniedError(description='the user denied the device', request=request)
|
||||
person = subject(found['name'], request.client)
|
||||
if person is None:
|
||||
raise AccessDeniedError(description=f'{found["name"]} may not sign in to this client', request=request)
|
||||
self.check_target(request, found['resource'])
|
||||
request.user, request.scopes, request.auth_time, request.amr = found['name'], found['scopes'], found['auth_time'], found['amr']
|
||||
request.oidc_person = person
|
||||
|
||||
def delete_device(self, code):
|
||||
self.call(('CALL oidc_device_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', 'resource'), 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. A token of
|
||||
client_credentials is kept for its service account."""
|
||||
if request.grant_type == 'client_credentials':
|
||||
name, auth_time, amr = request.client.client_id, datetime.datetime.now(datetime.timezone.utc), SERVICE_AMR
|
||||
else:
|
||||
name, auth_time, amr = request.user, request.auth_time, request.amr
|
||||
values = (request.client.client_id, name, request.scopes, auth_time, amr, getattr(request, 'oidc_target', None))
|
||||
statements = [('CALL oidc_token_save(%s, %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, %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
|
||||
self.check_target(request, found['resource'])
|
||||
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):
|
||||
claims = claims_of(request.oidc_person, request.oidc_scopes)
|
||||
roles = roles_at(request.client.client_id, request.user)
|
||||
return {**claims, 'roles': roles} if roles else claims
|
||||
|
||||
# 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})
|
||||
roles = roles_at(request.client.client_id, request.user)
|
||||
if roles:
|
||||
id_token['roles'] = roles
|
||||
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']
|
||||
|
||||
|
||||
class DeviceCodeGrant(GrantTypeBase):
|
||||
"""The token request of the device authorization grant (RFC 8628): a device code the user allowed gives an access
|
||||
token, a refresh token and, with openid, an ID token; until then the request is answered authorization_pending or
|
||||
slow_down."""
|
||||
|
||||
def create_token_response(self, request, token_handler):
|
||||
headers = self._get_default_headers()
|
||||
try:
|
||||
self.validate_token_request(request)
|
||||
except OAuth2Error as error:
|
||||
headers.update(error.headers)
|
||||
return headers, error.json, error.status_code
|
||||
token = token_handler.create_token(request, refresh_token=True)
|
||||
if 'openid' in request.scopes:
|
||||
token['id_token'] = self.request_validator.finalize_id_token(
|
||||
{'aud': request.client.client_id, 'iat': int(time.time())}, token, token_handler, request)
|
||||
self.request_validator.save_token(token, request)
|
||||
self.request_validator.delete_device(request.device_code)
|
||||
return headers, json.dumps(token), 200
|
||||
|
||||
def validate_token_request(self, request):
|
||||
if request.grant_type != DEVICE_GRANT:
|
||||
raise UnsupportedGrantTypeError(request=request)
|
||||
for param in ('grant_type', 'device_code'):
|
||||
if param in request.duplicate_params:
|
||||
raise InvalidRequestError(description=f'Duplicate {param} parameter.', request=request)
|
||||
request.device_code = getattr(request, 'device_code', None)
|
||||
if not request.device_code:
|
||||
raise InvalidRequestError(description='Missing device_code parameter.', request=request)
|
||||
if self.request_validator.client_authentication_required(request):
|
||||
if not self.request_validator.authenticate_client(request):
|
||||
raise InvalidClientError(request=request)
|
||||
elif not self.request_validator.authenticate_client_id(request.client_id, request):
|
||||
raise InvalidClientError(request=request)
|
||||
self.validate_grant_type(request)
|
||||
self.request_validator.poll_device(request.device_code, request)
|
||||
|
||||
|
||||
def user_code_of(value):
|
||||
"""The user code a user typed, without the dash and spaces, in capitals; '' when it is not one."""
|
||||
code = ''.join(character for character in (value or '').upper() if character.isalnum())
|
||||
return code if len(code) == USER_CODE_LENGTH and all(character in USER_CODE_LETTERS for character in code) else ''
|
||||
|
||||
|
||||
def device_authorization(validator, cfg, text, headers):
|
||||
"""Answer a device authorization request (RFC 8628 3.1): the client, public by client_id or confidential with its
|
||||
secret, with the device_code grant, the scopes and the resource; the device code, the user code and the
|
||||
verification URIs."""
|
||||
issuer = vpnrouter.oidc_issuer(cfg)
|
||||
request = Request(f'{issuer}/oidc/device_authorization', 'POST', text, headers)
|
||||
for param in ('scope', 'resource'):
|
||||
if param in request.duplicate_params:
|
||||
raise InvalidRequestError(description=f'Duplicate {param} parameter.', request=request)
|
||||
if validator.client_authentication_required(request):
|
||||
if not validator.authenticate_client(request):
|
||||
raise InvalidClientError(request=request)
|
||||
elif not validator.authenticate_client_id(request.client_id, request):
|
||||
raise InvalidClientError(request=request)
|
||||
if not validator.validate_grant_type(request.client.client_id, DEVICE_GRANT, request.client, request):
|
||||
raise UnauthorizedClientError(description='the client may not use the device authorization grant', request=request)
|
||||
scopes = utils.scope_to_list(request.scope) or ['openid']
|
||||
target = target_of(issuer, getattr(request, 'resource', None), scopes)
|
||||
if not scopes_allowed(request.client, scopes, target, DEVICE_GRANT):
|
||||
raise InvalidScopeError(description='the client may not ask for these scopes; openid is required', request=request)
|
||||
device_code = secrets.token_urlsafe(32)
|
||||
user_code = ''.join(secrets.choice(USER_CODE_LETTERS) for _ in range(USER_CODE_LENGTH))
|
||||
with vpnrouter_auth.database(cfg) as connection:
|
||||
connection.execute('CALL oidc_device_save(%s, %s, %s, %s, %s, %s, %s)',
|
||||
(vpnrouter_auth.digest(device_code), vpnrouter_auth.digest(user_code), request.client.client_id,
|
||||
scopes, target, DEVICE_INTERVAL, DEVICE_SECONDS))
|
||||
shown = f'{user_code[:USER_CODE_LENGTH // 2]}-{user_code[USER_CODE_LENGTH // 2:]}'
|
||||
return document(200, {'device_code': device_code, 'user_code': shown, 'verification_uri': f'{issuer}/oidc/device',
|
||||
'verification_uri_complete': f'{issuer}/oidc/device?' + urllib.parse.urlencode({'user_code': shown}),
|
||||
'expires_in': DEVICE_SECONDS, 'interval': DEVICE_INTERVAL})
|
||||
|
||||
|
||||
def device_of(cfg, user_code):
|
||||
"""The waiting device code of a user code, {client_id, scopes, resource}, or None."""
|
||||
code = user_code_of(user_code)
|
||||
if not code:
|
||||
return None
|
||||
with vpnrouter_auth.database(cfg) as connection:
|
||||
row = connection.execute('SELECT * FROM oidc_device_find(%s)', (vpnrouter_auth.digest(code),)).fetchone()
|
||||
return dict(zip(('client_id', 'scopes', 'resource'), row)) if row else None
|
||||
|
||||
|
||||
def page(status, cfg, title, message='', error='', form=False, request='', name='', headers=(), action='/oidc/login',
|
||||
device=False, user_code='', decide=False, items=()):
|
||||
"""An HTML page of TEMPLATE: a message, an error and items; with form the sign-in form, posted to action with the
|
||||
authorization request, or with device the user code field, with or without the sign-in fields; with decide the
|
||||
Allow and Deny buttons of the device of the user code."""
|
||||
body = _pages.get_template(TEMPLATE).render(title=title, message=message, error=error, form=form, request=request,
|
||||
name=name, issuer=vpnrouter.oidc_issuer(cfg), action=action, device=device,
|
||||
user_code=user_code, decide=decide, items=list(items))
|
||||
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 sign_in(form):
|
||||
"""The name and the amr of a sign-in with the form's name, password and code, or its passkey; Failure when it is
|
||||
refused, ValueError when the passkey's answer is not JSON."""
|
||||
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', '')))
|
||||
return name, PASSKEY_AMR
|
||||
name, code = form.get('name', '').strip(), 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)
|
||||
return name, OTP_AMR if code else PASSWORD_AMR
|
||||
|
||||
|
||||
def start_session(cfg, name, amr):
|
||||
"""Start a session of the user: a namespace of the Session and the header that sets its cookie."""
|
||||
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))
|
||||
return types.SimpleNamespace(session=Session(name, datetime.datetime.now(datetime.timezone.utc), amr),
|
||||
cookie=cookie(value, SESSION_SECONDS))
|
||||
|
||||
|
||||
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:
|
||||
name, amr = sign_in(form)
|
||||
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')
|
||||
return authorize(server, cfg, params, headers, fresh=start_session(cfg, name, amr))
|
||||
|
||||
|
||||
def device_page(cfg, status=200, signed_in=False, user_code='', name='', error='', headers=()):
|
||||
"""The page where the user enters the user code of a device, with the sign-in fields unless signed_in."""
|
||||
return page(status, cfg, 'Sign in on a device', message='Enter the code the device shows.', error=error,
|
||||
form=not signed_in, name=name, headers=headers, action='/oidc/device', device=True, user_code=user_code)
|
||||
|
||||
|
||||
def device(cfg, form, headers):
|
||||
"""The device page's form: sign in when the browser has no session, then ask the user to allow the device of the
|
||||
user code."""
|
||||
user_code, fresh = form.get('user_code', ''), None
|
||||
session = session_of(cfg, headers.get('Cookie'))
|
||||
if session is None:
|
||||
try:
|
||||
name, amr = sign_in(form)
|
||||
except vpnrouter_users.Failure as failure:
|
||||
return device_page(cfg, 401, user_code=user_code, name=form.get('name', '').strip(), error=str(failure))
|
||||
except ValueError:
|
||||
return device_page(cfg, 401, user_code=user_code, name=form.get('name', '').strip(),
|
||||
error='the passkey is not accepted')
|
||||
fresh = start_session(cfg, name, amr)
|
||||
session = fresh.session
|
||||
extra = [fresh.cookie] if fresh else []
|
||||
found = device_of(cfg, user_code)
|
||||
client = client_of(found['client_id']) if found else None
|
||||
if client is None:
|
||||
return device_page(cfg, 400, signed_in=True, user_code=user_code, error='the code is wrong or has expired',
|
||||
headers=extra)
|
||||
shown = client.display_name or client.client_id
|
||||
if subject(session.name, client) is None:
|
||||
return device_page(cfg, 403, signed_in=True, user_code=user_code, headers=extra,
|
||||
error=f'{session.name} may not sign in to {shown}')
|
||||
items = ([f'resource: {found["resource"]}'] if found['resource'] else []) + [f'scope: {scope}' for scope in found['scopes']]
|
||||
return page(200, cfg, f'Allow {shown}?', message=f'{shown} asks to sign in as {session.name} on a device.',
|
||||
items=items, decide=True, user_code=user_code_of(user_code), headers=extra)
|
||||
|
||||
|
||||
def decide(cfg, form, headers):
|
||||
"""Allow or deny the device of the user code as the user of the session."""
|
||||
session = session_of(cfg, headers.get('Cookie'))
|
||||
if session is None:
|
||||
return device_page(cfg, 401, user_code=form.get('user_code', ''), error='sign in first')
|
||||
found = device_of(cfg, form.get('user_code', ''))
|
||||
client = client_of(found['client_id']) if found else None
|
||||
if client is None or subject(session.name, client) is None:
|
||||
return device_page(cfg, 400, signed_in=True, user_code=form.get('user_code', ''),
|
||||
error='the code is wrong or has expired')
|
||||
allowed = form.get('decision') == 'allow'
|
||||
with vpnrouter_auth.database(cfg) as connection:
|
||||
decided = connection.execute('SELECT oidc_device_decide(%s, %s, %s, %s, %s)',
|
||||
(vpnrouter_auth.digest(user_code_of(form['user_code'])), session.name,
|
||||
session.auth_time, session.amr, allowed)).fetchone()[0]
|
||||
if not decided:
|
||||
return device_page(cfg, 400, signed_in=True, error='the code is wrong or has expired')
|
||||
shown = client.display_name or client.client_id
|
||||
if allowed:
|
||||
return page(200, cfg, 'The device is signed in', message=f'{shown} is signed in as {session.name}. You can return '
|
||||
'to the device.')
|
||||
return page(200, cfg, 'The device is denied', message=f'{shown} is not signed in.')
|
||||
|
||||
|
||||
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 ''
|
||||
validator = Validator(cfg)
|
||||
server = Server(validator, token_expires_in=ACCESS_SECONDS, token_generator=lambda request: access_token(cfg, request),
|
||||
refresh_token_generator=random_token_generator)
|
||||
server.grant_types[DEVICE_GRANT] = DeviceCodeGrant(validator)
|
||||
if (method, path) == ('GET', '/.well-known/openid-configuration'):
|
||||
return document(200, discovery(issuer, resource_scopes()))
|
||||
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 (method, path) == ('POST', '/oidc/device_authorization'):
|
||||
return device_authorization(validator, cfg, text, headers)
|
||||
if (method, path) == ('GET', '/oidc/device'):
|
||||
signed_in = session_of(cfg, headers.get('Cookie')) is not None
|
||||
return device_page(cfg, signed_in=signed_in, user_code=dict(urllib.parse.parse_qsl(query)).get('user_code', ''))
|
||||
if (method, path) == ('POST', '/oidc/device'):
|
||||
return device(cfg, dict(urllib.parse.parse_qsl(text)), headers)
|
||||
if (method, path) == ('POST', '/oidc/device/decide'):
|
||||
return decide(cfg, dict(urllib.parse.parse_qsl(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,202 @@
|
||||
"""The passkeys of the router's users, for signing in to the Router Manager and the OpenID Provider (vpnrouter_oidc),
|
||||
and for opening a shell, 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, sign-in and the proof of a signed-in user for a shell 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 or a proof 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
|
||||
|
||||
|
||||
def shell_begin(connection, name):
|
||||
"""Begin the proof of the signed-in user for a shell: {"id", "options"}, options those of navigator.credentials.get
|
||||
for the user's own passkeys, with user verification."""
|
||||
own = credentials(vpnrouter_users.entry(connection, name))
|
||||
if not own:
|
||||
raise vpnrouter_users.Failure(f'{name} has no passkey; add one on the Account page', 2)
|
||||
options, state = server().authenticate_begin(own, user_verification=UserVerificationRequirement.REQUIRED)
|
||||
return {'id': keep('shell', name, state), 'options': dict(options)}
|
||||
|
||||
|
||||
def shell_complete(connection, name, pending, response):
|
||||
"""Check the browser's answer to a begun proof of the user for a shell: one of the user's own passkeys, with the
|
||||
user verified, while the user is enabled and not locked."""
|
||||
refused = vpnrouter_users.Failure('the passkey is not accepted', 5)
|
||||
state = take('shell', name, pending)
|
||||
if state is None or not isinstance(response, dict):
|
||||
raise refused
|
||||
current = vpnrouter_users.entry(connection, name)
|
||||
user = vpnrouter_users.summary(current)
|
||||
if not user['enabled'] or user['locked']:
|
||||
raise refused
|
||||
try:
|
||||
server().authenticate_complete(state, credentials(current), response)
|
||||
except (ValueError, KeyError, TypeError):
|
||||
raise refused
|
||||
File diff suppressed because it is too large.
Load diff
@@ -10,7 +10,7 @@ adding one file here. Nothing in the core has to be edited.
|
||||
|
||||
A module may define any of:
|
||||
|
||||
EXTRA_P2S_DNS str, added to the road-warrior pool 'dns' list
|
||||
EXTRA_P2S_DNS str, added to the 'dns' list of the IKEv2 address pool
|
||||
EXTRA_LOCAL_TS str, added to 'local_ts' on both connections
|
||||
apply(ctx) called once the system has been configured, to apply
|
||||
platform-specific state. Must be idempotent. ctx is the
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
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
|
||||
IKEv2 clients and *not* added to the traffic selectors. Clients are
|
||||
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.
|
||||
"""
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
No platform-specific configuration is currently required.
|
||||
|
||||
As on Azure, road-warrior clients are given the router itself as their DNS
|
||||
As on Azure, IKEv2 clients are given the router itself as their DNS
|
||||
server and the router forwards through its own systemd-resolved, so the
|
||||
metadata resolver does not belong in the pool or in the traffic selectors.
|
||||
"""
|
||||
@@ -4,6 +4,7 @@ Only the Python standard library is used, so the bootstrap server, which must ru
|
||||
is broken, can use it.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from http.server import ThreadingHTTPServer
|
||||
@@ -14,9 +15,21 @@ def serve(wanted, handler, log, interval):
|
||||
|
||||
wanted answers ([(address, port), ...], '') or ([], the reason not to listen). A server whose
|
||||
place is no longer wanted is stopped, and a new one listens at each new place; a place that
|
||||
cannot be bound is tried again the next round. Every new message is logged with log. Does
|
||||
not return.
|
||||
cannot be bound is tried again the next round. Every new message is logged with log. A request
|
||||
whose client went away is not logged; any other exception a request, a thread or the process
|
||||
does not handle is logged with log in one line, without a traceback. Does not return.
|
||||
"""
|
||||
def failed(request, client_address):
|
||||
failure = sys.exc_info()[1]
|
||||
if not isinstance(failure, ConnectionError):
|
||||
log(f'a request from {client_address[0]} failed: {type(failure).__name__}: {failure}')
|
||||
|
||||
def thread_failed(hook):
|
||||
if not issubclass(hook.exc_type, (SystemExit, ConnectionError)):
|
||||
log(f'{hook.thread.name if hook.thread else "a thread"} failed: {hook.exc_type.__name__}: {hook.exc_value}')
|
||||
|
||||
threading.excepthook = thread_failed
|
||||
sys.excepthook = lambda kind, value, _: log(f'failed: {kind.__name__}: {value}')
|
||||
servers, reported = {}, None
|
||||
while True:
|
||||
targets, reason = wanted()
|
||||
@@ -34,6 +47,7 @@ def serve(wanted, handler, log, interval):
|
||||
except OSError as exc:
|
||||
messages.append(f'cannot listen on {target[0]}:{target[1]}: {exc}')
|
||||
continue
|
||||
server.handle_error = failed
|
||||
thread = threading.Thread(target=server.serve_forever, name=f'server {target[0]}', daemon=True)
|
||||
thread.start()
|
||||
servers[target] = (server, thread)
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
"""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. dns says who
|
||||
serves the records of its host names: user, a DNS provider outside the router, or router, the router's name server,
|
||||
which gives each of them in the closest of its domains an A or AAAA record of the router's address of dns_address:
|
||||
public (wan.public_address), internal or external. A site may use wan.local_fqdn, on a port of its own; its record
|
||||
stays where the domain of wan.local_fqdn is.
|
||||
|
||||
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')
|
||||
|
||||
#: Who serves the records of a site's host names: user, elsewhere, or router, the router's name server, with the
|
||||
#: router's address of DNS_ADDRESSES.
|
||||
DNS = ('user', 'router')
|
||||
DNS_ADDRESSES = ('public', 'internal', 'external')
|
||||
|
||||
#: 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': '', 'dns': 'user',
|
||||
'dns_address': 'public'}
|
||||
|
||||
#: 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 site['dns'] not in DNS:
|
||||
raise Failure(f'dns: {site["dns"]!r} is not one of {", ".join(DNS)}', 2)
|
||||
if site['dns_address'] not in DNS_ADDRESSES:
|
||||
raise Failure(f'dns_address: {site["dns_address"]!r} is not one of {", ".join(DNS_ADDRESSES)}', 2)
|
||||
if site['dns'] == 'router':
|
||||
local_fqdn = cfg['wan']['local_fqdn'].strip()
|
||||
if any(vpnrouter_pki.is_address(host) or host.startswith('*.') or host == local_fqdn for host in hosts):
|
||||
raise Failure('dns router takes host names only, without wildcards and other than wan.local_fqdn, whose '
|
||||
'record is kept where its domain is', 2)
|
||||
if site['dns_address'] == 'public' and not cfg['wan']['public_address'].strip():
|
||||
raise Failure('dns_address public needs wan.public_address', 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(f'CALL site_put({", ".join(["%s"] * (len(SETTINGS) + 1))})',
|
||||
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
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,102 @@
|
||||
"""The router's web service, run by vpn-router-web.service; the entry point is /usr/lib/vpn-router/web.
|
||||
|
||||
Pages:
|
||||
/ the status for people: plain text for curl and wget, a styled page
|
||||
(Tailwind CSS from a CDN) for a browser
|
||||
/status the status as JSON
|
||||
/status/full
|
||||
the whole status as JSON, whatever web.status_content says, for the Router Manager
|
||||
/config the configuration without its secrets, and what the router resolved and
|
||||
runs, with the installed Caddy and where it comes from, as JSON
|
||||
/users the users in the router's directory, whether or not a P2S system is enabled: list,
|
||||
add, change, set the password, disable, enable, remove, the OpenVPN client
|
||||
profile, and TOTP enrolment (see users()); service accounts are not among them
|
||||
/service-accounts
|
||||
the service accounts: list, add, change the e-mail address, set the password,
|
||||
disable, enable, unlock and remove; the internal ones are only listed (see
|
||||
service_accounts())
|
||||
/groups the groups of the router's directory: list, add, rename or describe, remove, and
|
||||
add and take out members (see groups())
|
||||
/account the account of the signed-in user: its details, its password, its TOTP
|
||||
enrolment and its passkeys (see account())
|
||||
/site-to-site
|
||||
the site-to-site connections of the configuration: list, add, change and remove,
|
||||
applied by vpn-router-setup (see site_to_site())
|
||||
/backup the backup settings, the last run and the local archives: show, change and run
|
||||
now (see backup())
|
||||
/remote-access
|
||||
the settings of IKEv2, OpenVPN and WireGuard access and whether each
|
||||
runs: show and change, applied by vpn-router-setup (see remote_access())
|
||||
/ca the PKI: its settings, the root and issuing CAs, the certificates issued; change the
|
||||
settings, issue and revoke certificates, and take the certificate of an issuing CA
|
||||
and a root CRL signed offline (see ca())
|
||||
/pki/<name>.crt, /pki/<name>.crl
|
||||
the DER certificate and CRL of the root (root) and of each issuing CA, which the
|
||||
certificates name in their AIA and CRL distribution points; no authentication
|
||||
/shell while web.shell is true, a login shell of the user's console account on a terminal, for
|
||||
a member of admins whose console login is on and who has a TOTP token: POST signs the user in again,
|
||||
with the password and the TOTP code or with a passkey whose proof POST /shell/passkey begins,
|
||||
unless the user used a shell within web.shell_auth_timeout minutes, for a one-time ticket
|
||||
(see shell_ticket() and shell_passkey()), GET with it is a WebSocket
|
||||
that carries the terminal (see Handler.shell()); /shell/editor/<session>/<id> reads, saves and
|
||||
closes a file the shell opened with vpn-router-editor (vre) over EDITOR_SOCKET (see
|
||||
shell_editor() and editor_request())
|
||||
/auth sign-in with a user's password and TOTP code or with a passkey, refresh and sign-out
|
||||
(see auth())
|
||||
/metrics/history
|
||||
the metrics the metrics service keeps, for a view: hour, day, month or year (see
|
||||
metrics_history())
|
||||
/metrics/settings
|
||||
the settings of the metrics service: show and change, applied by vpn-router-setup (see
|
||||
metrics_settings())
|
||||
/logs the last lines of the journal of the router's units and of the files web.log_files lists
|
||||
(see logs())
|
||||
/sites the web sites Caddy serves: the settings of sites, list, add, change and remove (see sites()),
|
||||
and the files of each: list, download, upload, extract a zip archive, create a folder, rename and
|
||||
remove (see Handler.site_files()); a member of a site's editors group may use the site's files
|
||||
/dns the primary name server of the router's domains: its settings, and the domains: add, change and
|
||||
remove, each with the records it serves and its manual records (see dns())
|
||||
/postgresql
|
||||
the databases of each PostgreSQL cluster on the router with their sizes, as JSON (see
|
||||
postgresql_databases()); /config has the clusters' settings
|
||||
/.well-known/openid-configuration, /oidc/...
|
||||
while oidc.enabled is true, the OpenID Provider (vpnrouter_oidc), answered only on the
|
||||
loopback address, where Caddy passes the requests of the HTTPS site; POST /oidc/login counts
|
||||
towards the limit of /auth calls
|
||||
|
||||
/status/full, /config, /metrics/history, /metrics/settings, /postgresql, /users, /service-accounts, /groups, /site-to-site, /backup, /remote-access, /ca, /logs, /sites, /dns, /shell and /account need an HS256 JWT in an Authorization: Bearer header signed
|
||||
with vpnrouter_auth.WEB_KEY (see vpnrouter_auth.verify). A token of /auth names a user in sub;
|
||||
/account serves that user, and the other paths need a member of vpnrouter_users.ADMIN_GROUP. A
|
||||
token of vpn-router-token has the admin claim and names no user; it may call every path but
|
||||
/account, and only such a token may change ADMIN_GROUP: create, rename to it, change, remove it,
|
||||
add or take out its members, and remove one of its members, and switch a user's console login.
|
||||
These paths and /auth are not
|
||||
served (404) while the key does not exist. /auth but its sign-out and the beginning of a passkey
|
||||
sign-in is limited to AUTH_LIMIT calls per client
|
||||
address in AUTH_WINDOW seconds; the address of a call from one of web.trusted_proxies, by
|
||||
default the address the call came in on, is the last entry of its X-Forwarded-For header.
|
||||
|
||||
/ and /status show only the status, as much as web.status_content says (see shown): basic, the
|
||||
default, the overall status, the time and the client's address (its address as for the rate limit
|
||||
below); standard also the package version, the installed version of vpn-router-manager
|
||||
(manager_version, null when it is not installed) and the number of active and other services; full
|
||||
whether each service runs, whether Docker is installed, how many of the site-to-site connections are established
|
||||
(site_to_site: connections and established, null when swanctl cannot be asked), and IKEv2, OpenVPN
|
||||
and WireGuard. No configuration data, key or connection name is shown, and no address but the client's.
|
||||
|
||||
Modules, one per family of paths, with what they share in common:
|
||||
common the paths of the configuration and of setup's state, the body limit, the HTTP statuses of the
|
||||
errors of the router's modules, reading the configuration and the state, changing a section, log
|
||||
status /, /status, /status/full, /config and /postgresql, with the cache of the status
|
||||
auth /auth, its rate limit, and the rules of who may call a protected path (refusal)
|
||||
identities /users, /service-accounts, /groups and /account
|
||||
connectivity /site-to-site and /remote-access
|
||||
backup /backup
|
||||
pki /ca, and the files /pki/ serves
|
||||
logs /logs
|
||||
sites /sites, with the files of each site
|
||||
dns /dns
|
||||
metrics /metrics/history and /metrics/settings
|
||||
shell /shell, its WebSocket, and the editor of the shell with its socket
|
||||
handler Handler, which reads a request, checks its token and passes it to the module of its path
|
||||
"""
|
||||
@@ -0,0 +1,130 @@
|
||||
"""/auth, the sign-in, refresh and sign-out of the web service, its rate limit per client address, and the rules of
|
||||
who may call a protected path (refusal)."""
|
||||
|
||||
import collections
|
||||
import threading
|
||||
import time
|
||||
|
||||
import ldap
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_auth
|
||||
import vpnrouter_passkeys
|
||||
import vpnrouter_sites
|
||||
import vpnrouter_users
|
||||
|
||||
from .common import USER_STATUS
|
||||
from .sites import SITE_FILES
|
||||
|
||||
#: The calls of /auth allowed per client address in AUTH_WINDOW seconds.
|
||||
AUTH_LIMIT = 10
|
||||
|
||||
AUTH_WINDOW = 60
|
||||
|
||||
#: The times of the recent /auth calls of each client address.
|
||||
_auth_calls = collections.defaultdict(collections.deque)
|
||||
|
||||
_auth_lock = threading.Lock()
|
||||
|
||||
|
||||
def changes_admins(connection, method, parts, body):
|
||||
"""Whether a /users, /service-accounts or /groups request creates, renames to, changes or removes ADMIN_GROUP, or
|
||||
removes a member of it."""
|
||||
admins = vpnrouter_users.ADMIN_GROUP
|
||||
if parts[0] == 'groups':
|
||||
if method == 'POST' and len(parts) == 1:
|
||||
return body.get('name') == admins
|
||||
if method != 'GET' and len(parts) > 1 and parts[1] == admins:
|
||||
return True
|
||||
return method == 'PUT' and len(parts) == 2 and body.get('name') == admins
|
||||
if parts[0] in ('users', 'service-accounts') and method == 'DELETE' and len(parts) == 2:
|
||||
return admins in vpnrouter_users.groups_of(connection, parts[1])
|
||||
return False
|
||||
|
||||
|
||||
def refusal(claims, method, path, body):
|
||||
"""Why the holder of the claims may not make the request to a protected path, or '' when it may.
|
||||
|
||||
The files of a site may also be read and changed by a member of the site's editors group.
|
||||
"""
|
||||
name = claims.get('sub')
|
||||
if path == '/account' or path.startswith('/account/'):
|
||||
return '' if isinstance(name, str) else 'the token names no user'
|
||||
if claims.get('admin') is True:
|
||||
return ''
|
||||
if not isinstance(name, str):
|
||||
return 'the token names no user'
|
||||
admins = vpnrouter_users.ADMIN_GROUP
|
||||
with vpnrouter_users.session() as connection:
|
||||
if not vpnrouter_users.is_admin(connection, name):
|
||||
files = SITE_FILES.match(path)
|
||||
if files and files.group(1) in vpnrouter_sites.editable(vpnrouter_users.groups_of(connection, name)):
|
||||
return ''
|
||||
return f'user {name} is not a member of {admins}'
|
||||
parts = path.strip('/').split('/')
|
||||
if changes_admins(connection, method, parts, body if isinstance(body, dict) else {}):
|
||||
return f'only a token of vpn-router-token may change the group {admins} or remove its members'
|
||||
if parts[0] == 'users' and len(parts) == 3 and parts[2] == 'console' and method != 'GET':
|
||||
return 'only a token of vpn-router-token may change console login'
|
||||
return ''
|
||||
|
||||
|
||||
def auth(path, body):
|
||||
"""Answer a POST to /auth with vpnrouter_users and vpnrouter_auth: the HTTP status and the JSON document.
|
||||
|
||||
POST /auth sign in: {"name", "password", "code"?}; the tokens and the user's name
|
||||
POST /auth/passkey begin a sign-in with a passkey: {"id", "options"} for navigator.credentials.get
|
||||
POST /auth/passkey/complete
|
||||
{"id", "credential"}, credential the browser's answer; the tokens and the user's name
|
||||
POST /auth/refresh {"refresh_token"}; new tokens, the refresh token used up
|
||||
POST /auth/logout {"refresh_token"}; the refresh token revoked
|
||||
"""
|
||||
if not isinstance(body, dict):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
try:
|
||||
cfg = vpnrouter.load_config()
|
||||
if path == '/auth':
|
||||
vpnrouter_users.login(body.get('name'), body.get('password'), body.get('code'))
|
||||
with vpnrouter_users.session() as connection:
|
||||
if vpnrouter_users.service(vpnrouter_users.entry(connection, body['name'])):
|
||||
raise vpnrouter_users.Failure('the name, the password or the code is wrong', 5)
|
||||
return 200, {**vpnrouter_auth.issue(cfg, body['name']), 'name': body['name']}
|
||||
if path == '/auth/passkey':
|
||||
return 200, vpnrouter_passkeys.login_begin()
|
||||
if path == '/auth/passkey/complete':
|
||||
with vpnrouter_users.session() as connection:
|
||||
name = vpnrouter_passkeys.login_complete(connection, body.get('id'), body.get('credential'))
|
||||
return 200, {**vpnrouter_auth.issue(cfg, name), 'name': name}
|
||||
name = vpnrouter_auth.take(cfg, body.get('refresh_token'))
|
||||
if path == '/auth/logout':
|
||||
return 200, {'revoked': name is not None}
|
||||
if name is None:
|
||||
return 401, {'error': 'the refresh token is not valid'}
|
||||
with vpnrouter_users.session() as connection:
|
||||
user = vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
|
||||
if not user['enabled'] or user['locked']:
|
||||
return 401, {'error': f'user {name} is disabled or locked'}
|
||||
return 200, vpnrouter_auth.issue(cfg, name)
|
||||
except vpnrouter_users.Failure as failure:
|
||||
return (401 if failure.status == 3 else USER_STATUS.get(failure.status, 500)), {'error': str(failure)}
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
return 500, {'error': f'the user directory failed: {exc}'}
|
||||
except vpnrouter_auth.DatabaseError as exc:
|
||||
return 500, {'error': str(exc)}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
|
||||
|
||||
def auth_wait(address):
|
||||
"""Seconds until the client address may call /auth again, 0 when it may now; a call that may is counted."""
|
||||
now = time.monotonic()
|
||||
with _auth_lock:
|
||||
for idle in [key for key, times in _auth_calls.items() if times[-1] <= now - AUTH_WINDOW]:
|
||||
del _auth_calls[idle]
|
||||
calls = _auth_calls[address]
|
||||
while calls and calls[0] <= now - AUTH_WINDOW:
|
||||
calls.popleft()
|
||||
if len(calls) >= AUTH_LIMIT:
|
||||
return int(calls[0] + AUTH_WINDOW - now) + 1
|
||||
calls.append(now)
|
||||
return 0
|
||||
@@ -0,0 +1,84 @@
|
||||
"""/backup: the backup settings, the last run and the local archives, and a run now."""
|
||||
|
||||
import configparser
|
||||
import shutil
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_backup
|
||||
from vpnrouter_status import run
|
||||
|
||||
|
||||
def backup_settings(settings):
|
||||
"""The backup section as /backup shows it: its settings but pbs_password, with pbs_password_set."""
|
||||
return {**{option: value for option, value in settings.items() if option != 'pbs_password'},
|
||||
'pbs_password_set': bool(settings['pbs_password'])}
|
||||
|
||||
|
||||
def rclone_remotes():
|
||||
"""The names of the remotes of vpnrouter_backup.RCLONE_CONFIG; [] when it cannot be read, as when it is encrypted."""
|
||||
parser = configparser.ConfigParser(interpolation=None)
|
||||
try:
|
||||
parser.read(vpnrouter_backup.RCLONE_CONFIG, encoding='utf-8')
|
||||
except (OSError, configparser.Error):
|
||||
return []
|
||||
return parser.sections()
|
||||
|
||||
|
||||
def backup(method, path, body):
|
||||
"""Answer a /backup request: the HTTP status and the JSON document.
|
||||
|
||||
GET /backup {"settings", "state", "archives", "next", "client", "rclone"}: the backup section without
|
||||
pbs_password, with pbs_password_set; the last run (vpnrouter_backup.state), null before the
|
||||
first; the local archives of this host, newest first, [{name, time, size}], [] with the pbs
|
||||
target; the next run of the timer as systemd shows it, null while backups are disabled;
|
||||
whether proxmox-backup-client is installed; and {"installed", "config", "remotes"} of rclone
|
||||
PUT /backup change any of the settings of the backup section; an empty or absent pbs_password keeps the
|
||||
current secret
|
||||
POST /backup/run start a backup now, without waiting for it; 409 while one runs
|
||||
|
||||
A change is made as /site-to-site makes one, and applied by restarting vpn-router-setup without waiting for it.
|
||||
"""
|
||||
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
body = body or {}
|
||||
if (method, path) == ('POST', '/backup/run'):
|
||||
if vpnrouter_backup.running():
|
||||
return 409, {'error': 'a backup is running'}
|
||||
result = run('systemctl', 'start', '--no-block', vpnrouter_backup.UNIT)
|
||||
if result is None or result.returncode != 0:
|
||||
return 500, {'error': f'the backup cannot be started: {result.stderr.strip() if result else "no systemctl"}'}
|
||||
return 202, {'started': True}
|
||||
if (method, path) not in (('GET', '/backup'), ('PUT', '/backup')):
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
try:
|
||||
if method == 'GET':
|
||||
settings = vpnrouter.load_config()['backup']
|
||||
timer = run('systemctl', 'show', '-p', 'NextElapseUSecRealtime', '--value', vpnrouter_backup.TIMER)
|
||||
next_run = timer.stdout.strip() if timer and timer.returncode == 0 else ''
|
||||
return 200, {
|
||||
'settings': backup_settings(settings),
|
||||
'state': vpnrouter_backup.state() or None,
|
||||
'archives': vpnrouter_backup.archives(settings['path']) if settings['target'] == 'local' else [],
|
||||
'next': next_run if settings['enabled'] and next_run not in ('', 'n/a') else None,
|
||||
'client': vpnrouter_backup.PBS_CLIENT.exists(),
|
||||
'rclone': {'installed': shutil.which('rclone') is not None,
|
||||
'config': vpnrouter_backup.RCLONE_CONFIG.is_file(), 'remotes': rclone_remotes()},
|
||||
}
|
||||
settings = {option: body[option] for option in vpnrouter.SCHEMA['backup'] if option in body}
|
||||
if not settings.get('pbs_password'):
|
||||
settings.pop('pbs_password', None)
|
||||
with vpnrouter.config_lock():
|
||||
data = vpnrouter.read_settings()
|
||||
current = data.get('backup')
|
||||
data['backup'] = {**(current if isinstance(current, dict) else {}), **settings}
|
||||
cfg = vpnrouter.with_defaults(data)
|
||||
vpnrouter.validate(cfg)
|
||||
vpnrouter.save_config(data)
|
||||
except vpnrouter.ConfigError as exc:
|
||||
return 400, {'error': str(exc)}
|
||||
except OSError as exc:
|
||||
return 500, {'error': f'the configuration cannot be written: {exc}'}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
|
||||
return 200, {'settings': backup_settings(cfg['backup'])}
|
||||
@@ -0,0 +1,85 @@
|
||||
"""What the modules of the web service share: the paths of the configuration and of the state vpn-router-setup writes,
|
||||
the limit of request bodies, the HTTP statuses of the failures of the router's modules, reading the configuration and
|
||||
the state, changing a section of the configuration, the state of a unit, and the log."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_auth
|
||||
from vpnrouter_status import run
|
||||
|
||||
STATE_FILE = '/run/vpn-router/web.json'
|
||||
|
||||
CONFIG_FILE = '/etc/vpn-router/vpn-router.json'
|
||||
|
||||
#: The largest request body accepted, in bytes; a CSR or a certificate chain in PEM is a few kilobytes.
|
||||
BODY_LIMIT = 65536
|
||||
|
||||
#: The HTTP status for each status of a vpnrouter_users.Failure; any other is 500.
|
||||
USER_STATUS = {2: 400, 3: 404, 4: 409, 5: 401}
|
||||
|
||||
#: The HTTP status for each status of a vpnrouter_pki.Failure; any other is 500.
|
||||
PKI_STATUS = {2: 400, 3: 404, 4: 409}
|
||||
|
||||
#: The HTTP status for each status of a vpnrouter_sites.Failure; any other is 500.
|
||||
SITE_STATUS = {2: 400, 3: 404, 4: 409}
|
||||
|
||||
#: The bytes in a MiB, the unit of sites.upload_max_mb.
|
||||
MIB = 1024 * 1024
|
||||
|
||||
|
||||
def read_json(path):
|
||||
"""The object in a JSON file, or {} when the file is missing or holds no object."""
|
||||
try:
|
||||
with open(path, encoding='utf-8') as handle:
|
||||
data = json.load(handle)
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def section(configuration, name):
|
||||
"""A section of the configuration, or {} when it is missing or not an object."""
|
||||
value = configuration.get(name)
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def current():
|
||||
"""What a request needs, read now: the features and facts setup resolved, and the signing key.
|
||||
|
||||
Before setup has run the router has no features, and without a key the protected paths and
|
||||
/auth are not served.
|
||||
"""
|
||||
return dict(read_json(STATE_FILE), key=vpnrouter_auth.key())
|
||||
|
||||
|
||||
def unit_state(unit):
|
||||
result = run('systemctl', 'is-active', unit)
|
||||
return result.stdout.strip() if result and result.stdout.strip() else 'unknown'
|
||||
|
||||
|
||||
def section_change(name, body):
|
||||
"""Change any of the settings of the configuration's section as /backup changes its own; the section, or the HTTP
|
||||
status and the error document as a tuple."""
|
||||
settings = {option: body[option] for option in vpnrouter.SCHEMA[name] if option in body}
|
||||
try:
|
||||
with vpnrouter.config_lock():
|
||||
data = vpnrouter.read_settings()
|
||||
current = data.get(name)
|
||||
data[name] = {**(current if isinstance(current, dict) else {}), **settings}
|
||||
cfg = vpnrouter.with_defaults(data)
|
||||
vpnrouter.validate(cfg)
|
||||
vpnrouter.save_config(data)
|
||||
except vpnrouter.ConfigError as exc:
|
||||
return 400, {'error': str(exc)}
|
||||
except OSError as exc:
|
||||
return 500, {'error': f'the configuration cannot be written: {exc}'}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
|
||||
return cfg[name]
|
||||
|
||||
|
||||
def log(message):
|
||||
print(f'vpn-router-web: {message}', file=sys.stderr, flush=True)
|
||||
@@ -0,0 +1,155 @@
|
||||
"""The connections of the router: /site-to-site and /remote-access, the IKEv2, OpenVPN and WireGuard access."""
|
||||
|
||||
import ipaddress
|
||||
|
||||
import vpnrouter
|
||||
from vpnrouter_status import established_each, run
|
||||
|
||||
from .common import section
|
||||
|
||||
#: The settings of a site_to_site connection that /site-to-site adds and changes.
|
||||
CONNECTION_SETTINGS = ('addrs', 'id', 'cidrs', 'local_cidrs', 'local_id_mode', 'auth', 'psk', 'remote_ca')
|
||||
|
||||
#: The sections of the remote access systems /remote-access changes: IKEv2, OpenVPN and WireGuard.
|
||||
REMOTE_ACCESS = ('p2s', 'openvpn', 'wireguard')
|
||||
|
||||
|
||||
def connection_of(name, connection, count):
|
||||
"""A site_to_site connection as /site-to-site shows it: its settings but psk, psk_set and established.
|
||||
|
||||
count is the number of its established IKE SAs, None when it is not known.
|
||||
"""
|
||||
return {'name': name, **{option: value for option, value in connection.items() if option != 'psk'},
|
||||
'psk_set': bool(connection['psk']), 'established': None if count is None else count > 0}
|
||||
|
||||
|
||||
def site_to_site(method, path, body):
|
||||
"""Answer a /site-to-site request: the HTTP status and the JSON document.
|
||||
|
||||
GET /site-to-site the connections, {"connections": [...]}: each with its settings but psk, with
|
||||
psk_set, whether psk holds a key, and established, whether its tunnel is up,
|
||||
null when swanctl cannot be asked
|
||||
POST /site-to-site add a connection: {"name", "addrs", "id", "cidrs", "local_cidrs", "local_id_mode",
|
||||
"auth", "psk", "remote_ca"}; psk is required with auth psk, the default, and an
|
||||
existing name is 409
|
||||
PUT /site-to-site/<name> change any of the settings of POST but name; an empty or absent psk keeps the
|
||||
current key, and a psk given replaces psk_file
|
||||
DELETE /site-to-site/<name> remove the connection
|
||||
|
||||
A change is made holding vpnrouter.config_lock, checked as vpn-router-setup checks the configuration (400 with
|
||||
the reason), written with vpnrouter.save_config, and applied by restarting vpn-router-setup without waiting for it.
|
||||
"""
|
||||
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
body = body or {}
|
||||
parts = path.strip('/').split('/')
|
||||
name = parts[1] if len(parts) == 2 else None
|
||||
if (method, len(parts)) not in (('GET', 1), ('POST', 1), ('PUT', 2), ('DELETE', 2)):
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
try:
|
||||
if method == 'GET':
|
||||
connections = vpnrouter.load_config()['site_to_site']
|
||||
counts = established_each(connections)
|
||||
return 200, {'connections': [connection_of(key, value, counts[key]) for key, value in connections.items()]}
|
||||
settings = {option: body[option] for option in CONNECTION_SETTINGS if option in body}
|
||||
with vpnrouter.config_lock():
|
||||
data = vpnrouter.read_settings()
|
||||
connections = data.setdefault('site_to_site', {})
|
||||
if method == 'POST':
|
||||
name = body.get('name')
|
||||
if not isinstance(name, str) or not name:
|
||||
return 400, {'error': 'name is required'}
|
||||
if name in connections:
|
||||
return 409, {'error': f'connection {name} exists'}
|
||||
if settings.get('auth', 'psk') == 'psk' and (not isinstance(settings.get('psk'), str) or not settings['psk']):
|
||||
return 400, {'error': 'psk is required with auth psk'}
|
||||
connections[name] = settings
|
||||
elif name not in connections:
|
||||
return 404, {'error': f'no connection {name}'}
|
||||
elif method == 'PUT':
|
||||
connection = dict(connections[name]) if isinstance(connections[name], dict) else {}
|
||||
if settings.get('psk'):
|
||||
connection.pop('psk_file', None)
|
||||
else:
|
||||
settings.pop('psk', None)
|
||||
connections[name] = {**connection, **settings}
|
||||
else:
|
||||
del connections[name]
|
||||
cfg = vpnrouter.with_defaults(data)
|
||||
vpnrouter.validate(cfg)
|
||||
vpnrouter.save_config(data)
|
||||
except vpnrouter.ConfigError as exc:
|
||||
return 400, {'error': str(exc)}
|
||||
except OSError as exc:
|
||||
return 500, {'error': f'the configuration cannot be written: {exc}'}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
|
||||
if method == 'DELETE':
|
||||
return 200, {'name': name, 'removed': True}
|
||||
return (201 if method == 'POST' else 200), connection_of(name, cfg['site_to_site'][name], None)
|
||||
|
||||
|
||||
def wireguard_server(cfg, state):
|
||||
"""What a WireGuard peer's configuration needs of the router: {public_key, endpoint, allowed_ips, dns}; public_key is
|
||||
null before setup made the key pair, endpoint, <wan.local_fqdn>:<wireguard.listen_port>, null without a name,
|
||||
allowed_ips the networks reached through the tunnel, the router's local CIDRs and the WireGuard network, and dns the
|
||||
addresses of the remote access clients' resolver, [] while it does not run."""
|
||||
try:
|
||||
public_key = vpnrouter.WG_PUBLIC_KEY.read_text(encoding='utf-8').strip() or None
|
||||
except OSError:
|
||||
public_key = None
|
||||
fqdn = cfg['wan']['local_fqdn'].strip()
|
||||
address = cfg['wireguard']['address'].strip()
|
||||
networks = section(state, 'network').get('local_cidrs')
|
||||
allowed = [cidr for cidr in networks if isinstance(cidr, str)] if isinstance(networks, list) else []
|
||||
if address:
|
||||
allowed.append(str(ipaddress.ip_interface(address).network))
|
||||
dns = section(state, 'network').get('client_dns')
|
||||
return {'public_key': public_key, 'endpoint': f'{fqdn}:{cfg["wireguard"]["listen_port"]}' if fqdn else None,
|
||||
'allowed_ips': list(dict.fromkeys(allowed)),
|
||||
'dns': [address for address in dns if isinstance(address, str)] if isinstance(dns, list) else []}
|
||||
|
||||
|
||||
def remote_access(state, method, path, body):
|
||||
"""Answer a /remote-access request: the HTTP status and the JSON document.
|
||||
|
||||
GET /remote-access {"p2s", "openvpn", "wireguard", "running", "local_fqdn", "wireguard_server"}: the
|
||||
settings of the three remote access systems (REMOTE_ACCESS), whether setup runs
|
||||
each (running: {p2s, openvpn, wireguard}), wan.local_fqdn, which IKEv2 and OpenVPN
|
||||
need with the router's VPN server certificate, and what a WireGuard peer's
|
||||
configuration needs of the router (wireguard_server)
|
||||
PUT /remote-access/<system> change any of the settings of the system's section; answers the section
|
||||
|
||||
A change is made as /site-to-site makes one, and applied by restarting vpn-router-setup without waiting for it.
|
||||
"""
|
||||
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
body = body or {}
|
||||
parts = path.strip('/').split('/')
|
||||
try:
|
||||
if (method, len(parts)) == ('GET', 1):
|
||||
cfg = vpnrouter.load_config()
|
||||
return 200, {**{system: cfg[system] for system in REMOTE_ACCESS},
|
||||
'running': {system: bool(state.get(system)) for system in REMOTE_ACCESS},
|
||||
'local_fqdn': cfg['wan']['local_fqdn'],
|
||||
'wireguard_server': wireguard_server(cfg, state)}
|
||||
if method != 'PUT' or len(parts) != 2 or parts[1] not in REMOTE_ACCESS:
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
system = parts[1]
|
||||
settings = {option: body[option] for option in vpnrouter.SCHEMA[system] if option in body}
|
||||
with vpnrouter.config_lock():
|
||||
data = vpnrouter.read_settings()
|
||||
current = data.get(system)
|
||||
data[system] = {**(current if isinstance(current, dict) else {}), **settings}
|
||||
cfg = vpnrouter.with_defaults(data)
|
||||
vpnrouter.validate(cfg)
|
||||
vpnrouter.save_config(data)
|
||||
except vpnrouter.ConfigError as exc:
|
||||
return 400, {'error': str(exc)}
|
||||
except OSError as exc:
|
||||
return 500, {'error': f'the configuration cannot be written: {exc}'}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
|
||||
return 200, cfg[system]
|
||||
@@ -0,0 +1,84 @@
|
||||
"""/dns: the primary name server of the router's domains."""
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_dns
|
||||
from vpnrouter_status import run
|
||||
|
||||
from .common import section_change, unit_state
|
||||
|
||||
|
||||
def dns(method, path, query, body):
|
||||
"""Answer a /dns request with vpnrouter_dns: the HTTP status and the JSON document.
|
||||
|
||||
GET /dns {"settings", "nameserver", "public_address", "installed", "running", "domains",
|
||||
"problem"}: the dns section of the configuration, wan.local_fqdn, the primary name server
|
||||
of the domains, wan.public_address, the address of the records of the sites with
|
||||
dns_address public, whether PowerDNS is installed and runs, the domains, each {"name",
|
||||
"ttl", "manual", "records"} with its manual records and the records the name server
|
||||
serves, and why the domains cannot be read, null when they can
|
||||
PUT /dns change any of the settings of the dns section, and with "public_address"
|
||||
wan.public_address; {"settings", "public_address"}
|
||||
POST /dns/domains add a domain: {"name", "ttl"?}; ttl is dns.ttl when null or absent; an existing name is
|
||||
409
|
||||
PUT /dns/domains/<domain>
|
||||
change "ttl" of the domain
|
||||
DELETE /dns/domains/<domain>
|
||||
remove the domain with its manual records
|
||||
POST /dns/domains/<domain>/records
|
||||
add a manual record: {"name", "type", "content", "ttl"?}; name is relative to the domain,
|
||||
@ for the domain itself, or absolute with a final dot; ttl is the domain's without it
|
||||
DELETE /dns/domains/<domain>/records
|
||||
remove the manual records of name= and type=, with content= only that one
|
||||
|
||||
A change is applied by restarting vpn-router-setup without waiting for it.
|
||||
"""
|
||||
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
body = body or {}
|
||||
parts = path.strip('/').split('/')
|
||||
route = (method, *parts[:2], *parts[3:4])
|
||||
try:
|
||||
cfg = vpnrouter.load_config()
|
||||
if route == ('GET', 'dns'):
|
||||
domains, problem = [], None
|
||||
try:
|
||||
domains = vpnrouter_dns.listing()
|
||||
except vpnrouter_dns.DatabaseError as exc:
|
||||
problem = str(exc)
|
||||
return 200, {'settings': cfg['dns'], 'nameserver': cfg['wan']['local_fqdn'].strip(),
|
||||
'public_address': cfg['wan']['public_address'].strip(),
|
||||
'installed': vpnrouter_dns.PDNS_SERVER.exists(),
|
||||
'running': unit_state(vpnrouter_dns.UNIT) == 'active', 'domains': domains,
|
||||
'problem': problem}
|
||||
if route == ('PUT', 'dns'):
|
||||
wan = cfg['wan']
|
||||
if 'public_address' in body:
|
||||
wan = section_change('wan', {'public_address': body.pop('public_address')})
|
||||
if isinstance(wan, tuple):
|
||||
return wan
|
||||
changed = section_change('dns', body)
|
||||
if isinstance(changed, tuple):
|
||||
return changed
|
||||
return 200, {'settings': changed, 'public_address': wan['public_address'].strip()}
|
||||
if route == ('POST', 'dns', 'domains') and len(parts) == 2:
|
||||
document, code = vpnrouter_dns.domain_add(body), 201
|
||||
elif route == ('PUT', 'dns', 'domains') and len(parts) == 3:
|
||||
document, code = vpnrouter_dns.domain_change(parts[2], body), 200
|
||||
elif route == ('DELETE', 'dns', 'domains') and len(parts) == 3:
|
||||
document, code = vpnrouter_dns.domain_remove(parts[2]), 200
|
||||
elif route == ('POST', 'dns', 'domains', 'records') and len(parts) == 4:
|
||||
document, code = vpnrouter_dns.add(cfg, parts[2], body), 201
|
||||
elif route == ('DELETE', 'dns', 'domains', 'records') and len(parts) == 4:
|
||||
content = query.get('content', [None])[0]
|
||||
document = vpnrouter_dns.remove(parts[2], query.get('name', [''])[0], query.get('type', [''])[0], content)
|
||||
code = 200
|
||||
else:
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
except vpnrouter_dns.Failure as failure:
|
||||
return {3: 404, 4: 409}.get(failure.status, 400), {'error': str(failure)}
|
||||
except vpnrouter_dns.DatabaseError as exc:
|
||||
return 500, {'error': str(exc)}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
|
||||
return code, document
|
||||
@@ -0,0 +1,249 @@
|
||||
"""The request handler of the web service: it reads a request, checks its token and passes it to the module of its
|
||||
path (answer)."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import urllib.parse
|
||||
from http.server import BaseHTTPRequestHandler
|
||||
|
||||
import ldap
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_auth
|
||||
import vpnrouter_net
|
||||
import vpnrouter_oidc
|
||||
import vpnrouter_pki
|
||||
import vpnrouter_sites
|
||||
|
||||
from .auth import auth, auth_wait, refusal
|
||||
from .backup import backup
|
||||
from .common import BODY_LIMIT, CONFIG_FILE, current, log, read_json, section
|
||||
from .connectivity import remote_access, site_to_site
|
||||
from .dns import dns
|
||||
from .identities import account, groups, service_accounts, users
|
||||
from .logs import logs
|
||||
from .https import https_settings
|
||||
from .metrics import metrics_history, metrics_settings
|
||||
from .pki import PUBLISHED, ca
|
||||
from .shell import EDITOR_BODY_LIMIT, shell, shell_editor, shell_passkey, shell_ticket
|
||||
from .sites import SITE_FILES, site_files, sites
|
||||
from .status import cached, details, html_of, postgresql_databases, shown, text_of
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = 'HTTP/1.1'
|
||||
|
||||
def claims(self, secret):
|
||||
"""The claims of the request's token signed with the secret, or None without an acceptable one."""
|
||||
header = self.headers.get('Authorization', '')
|
||||
return vpnrouter_auth.verify(header[7:].strip(), secret) if header.startswith('Bearer ') else None
|
||||
|
||||
def client(self):
|
||||
"""The client address: the last X-Forwarded-For entry when the peer is a trusted proxy, else the peer."""
|
||||
peer = self.client_address[0]
|
||||
trusted = section(read_json(CONFIG_FILE), 'web').get('trusted_proxies') or [self.server.server_address[0]]
|
||||
forwarded = self.headers.get('X-Forwarded-For', '')
|
||||
if isinstance(trusted, list) and peer in trusted and forwarded.strip():
|
||||
return forwarded.split(',')[-1].strip()
|
||||
return peer
|
||||
|
||||
def refusal(self, claims, path, payload):
|
||||
"""The HTTP status and the message that refuse a request to a protected path, or None when it may be made."""
|
||||
try:
|
||||
reason = refusal(claims, self.command, path, payload)
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
return 500, f'the user directory failed: {exc}'
|
||||
except vpnrouter_sites.DatabaseError as exc:
|
||||
return 500, str(exc)
|
||||
return (403, reason) if reason else None
|
||||
|
||||
def answer(self, send_body):
|
||||
path = self.path.split('?', 1)[0]
|
||||
extra = []
|
||||
self.principal = None
|
||||
# A body that is not read must not be taken for the next request.
|
||||
if self.command not in ('GET', 'HEAD'):
|
||||
self.close_connection = True
|
||||
directory = next((name for name in ('users', 'service-accounts', 'groups', 'account', 'site-to-site', 'backup',
|
||||
'ca', 'shell', 'remote-access', 'logs', 'sites', 'dns')
|
||||
if path == f'/{name}' or path.startswith(f'/{name}/')), None)
|
||||
protected = path in ('/status/full', '/config', '/metrics/history', '/metrics/settings', '/https/settings',
|
||||
'/postgresql') or \
|
||||
directory is not None
|
||||
signing = path in ('/auth', '/auth/passkey', '/auth/passkey/complete', '/auth/refresh', '/auth/logout')
|
||||
oidc = (path == '/.well-known/openid-configuration' or path.startswith('/oidc/')) and \
|
||||
self.server.server_address[0] == vpnrouter_net.LOOPBACK
|
||||
files = SITE_FILES.match(path) if directory == 'sites' else None
|
||||
query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)
|
||||
state = current() if protected or signing else {}
|
||||
claims = self.claims(state['key']) if protected and state['key'] else None
|
||||
# The body of a file upload is read as a stream, after the request is allowed.
|
||||
payload = self.request_body(EDITOR_BODY_LIMIT if path.startswith('/shell/editor/') else BODY_LIMIT) \
|
||||
if (protected or signing) and self.command not in ('GET', 'HEAD') and not (files and self.command == 'PUT') \
|
||||
else None
|
||||
if claims is not None:
|
||||
self.principal = 'admin' if claims.get('admin') is True else claims.get('sub')
|
||||
if (protected or signing) and not state['key']:
|
||||
body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
|
||||
elif signing and self.command != 'POST':
|
||||
body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405
|
||||
elif signing:
|
||||
wait = auth_wait(self.client()) if path not in ('/auth/logout', '/auth/passkey') else 0
|
||||
if wait:
|
||||
code, document = 429, {'error': f'too many sign-in calls; retry in {wait} seconds'}
|
||||
extra.append(('Retry-After', str(wait)))
|
||||
else:
|
||||
code, document = auth(path, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif protected and claims is None:
|
||||
body, kind, code = b'Unauthorized\n', 'text/plain; charset=utf-8', 401
|
||||
extra.append(('WWW-Authenticate', 'Bearer'))
|
||||
elif protected and (refused := self.refusal(claims, path, payload)):
|
||||
code, kind = refused[0], 'application/json'
|
||||
body = json.dumps({'error': refused[1]}, indent=2).encode() + b'\n'
|
||||
elif path == '/status/full' and self.command in ('GET', 'HEAD'):
|
||||
document = shown(cached(), 'full', self.client())
|
||||
body, kind, code = json.dumps(document, indent=2).encode() + b'\n', 'application/json', 200
|
||||
elif path == '/config' and self.command in ('GET', 'HEAD'):
|
||||
body, kind, code = json.dumps(details(state), indent=2).encode() + b'\n', 'application/json', 200
|
||||
elif path == '/postgresql' and self.command in ('GET', 'HEAD'):
|
||||
body, kind, code = json.dumps(postgresql_databases(), indent=2).encode() + b'\n', 'application/json', 200
|
||||
elif path == '/metrics/history' and self.command in ('GET', 'HEAD'):
|
||||
query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)
|
||||
code, document = metrics_history(query.get('range', ['hour'])[0])
|
||||
body, kind = json.dumps(document).encode() + b'\n', 'application/json'
|
||||
elif path == '/metrics/settings':
|
||||
code, document = metrics_settings(self.command, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif path == '/https/settings':
|
||||
code, document = https_settings(self.command, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif directory == 'shell' and (path, self.command) == ('/shell', 'GET'):
|
||||
return shell(self, claims.get('sub'))
|
||||
elif directory == 'shell' and path.startswith('/shell/editor/'):
|
||||
code, document = shell_editor(claims.get('sub'), self.command, path.strip('/').split('/'), payload)
|
||||
body, kind = json.dumps(document).encode() + b'\n', 'application/json'
|
||||
elif directory == 'shell':
|
||||
code, document = shell_ticket(claims.get('sub'), payload) if (path, self.command) == ('/shell', 'POST') \
|
||||
else shell_passkey(claims.get('sub')) if (path, self.command) == ('/shell/passkey', 'POST') \
|
||||
else (404, {'error': f'no route {self.command} {path}'})
|
||||
if code != 200:
|
||||
log(f'shell ticket of {claims.get("sub")} from {self.client()} refused: {document["error"]}')
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif directory == 'account':
|
||||
code, document = account(claims['sub'], self.command, path, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif directory == 'site-to-site':
|
||||
code, document = site_to_site(self.command, path, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif directory == 'remote-access':
|
||||
code, document = remote_access(state, self.command, path, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif directory == 'backup':
|
||||
code, document = backup(self.command, path, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif directory == 'ca':
|
||||
code, document = ca(self.command, path, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif directory == 'logs':
|
||||
code, document = logs(self.command, path, query)
|
||||
body, kind = json.dumps(document).encode() + b'\n', 'application/json'
|
||||
elif files:
|
||||
answered = site_files(self, files.group(1), files.group(2) or '', query, payload, send_body)
|
||||
if answered is None:
|
||||
return
|
||||
code, document = answered
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif directory == 'sites':
|
||||
code, document = sites(self.command, path, query, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif directory == 'dns':
|
||||
code, document = dns(self.command, path, query, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif directory == 'service-accounts':
|
||||
code, document = service_accounts(self.command, path, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif directory is not None:
|
||||
code, document = (users if directory == 'users' else groups)(state, self.command, path, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif oidc:
|
||||
wait = auth_wait(self.client()) if self.command == 'POST' and path in (
|
||||
'/oidc/login', '/oidc/device', '/oidc/device_authorization') else 0
|
||||
if wait:
|
||||
code, kind = 429, 'application/json'
|
||||
body = json.dumps({'error': f'too many sign-in calls; retry in {wait} seconds'}).encode() + b'\n'
|
||||
extra.append(('Retry-After', str(wait)))
|
||||
else:
|
||||
code, kind, body, headers = vpnrouter_oidc.answer(self.command, self.path, self.headers, self.raw_body())
|
||||
extra += headers
|
||||
elif self.command not in ('GET', 'HEAD'):
|
||||
body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405
|
||||
elif path.startswith('/pki/') and vpnrouter.PKI_NAME.match(path[5:-4]) and path[-4:] in PUBLISHED and \
|
||||
(vpnrouter_pki.PUBLISH_DIR / path[5:]).is_file():
|
||||
body, kind, code = (vpnrouter_pki.PUBLISH_DIR / path[5:]).read_bytes(), PUBLISHED[path[-4:]], 200
|
||||
elif path in ('/status', '/'):
|
||||
document = shown(cached(), section(read_json(CONFIG_FILE), 'web').get('status_content'), self.client())
|
||||
agent = self.headers.get('User-Agent', '').lower()
|
||||
if path == '/status':
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif 'curl' in agent or 'wget' in agent:
|
||||
body, kind = text_of(document).encode(), 'text/plain; charset=utf-8'
|
||||
else:
|
||||
body, kind = html_of(document).encode(), 'text/html; charset=utf-8'
|
||||
code = 200
|
||||
else:
|
||||
body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
|
||||
try:
|
||||
self.send_response(code)
|
||||
self.send_header('Content-Type', kind)
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
for name, value in extra:
|
||||
self.send_header(name, value)
|
||||
self.end_headers()
|
||||
if send_body:
|
||||
self.wfile.write(body)
|
||||
except ConnectionError:
|
||||
# The client went away before the answer was written.
|
||||
self.close_connection = True
|
||||
|
||||
def raw_body(self):
|
||||
"""The request body's bytes, b'' without one or over BODY_LIMIT."""
|
||||
try:
|
||||
size = int(self.headers.get('Content-Length') or 0)
|
||||
except ValueError:
|
||||
return b''
|
||||
return self.rfile.read(size) if 0 < size <= BODY_LIMIT else b''
|
||||
|
||||
def request_body(self, limit=BODY_LIMIT):
|
||||
"""The request body as JSON, None when there is none, or the string 'invalid', also when it is larger than
|
||||
limit bytes."""
|
||||
length = self.headers.get('Content-Length')
|
||||
if not length:
|
||||
return None
|
||||
try:
|
||||
size = int(length)
|
||||
if not 0 <= size <= limit:
|
||||
return 'invalid'
|
||||
return json.loads(self.rfile.read(size) or b'null')
|
||||
except ValueError:
|
||||
return 'invalid'
|
||||
|
||||
def do_GET(self):
|
||||
self.answer(True)
|
||||
|
||||
def do_HEAD(self):
|
||||
self.answer(False)
|
||||
|
||||
def do_POST(self):
|
||||
self.answer(True)
|
||||
|
||||
def do_PUT(self):
|
||||
self.answer(True)
|
||||
|
||||
def do_DELETE(self):
|
||||
self.answer(True)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
principal = getattr(self, 'principal', None) or '-'
|
||||
sys.stderr.write('%s - %s [%s] %s\n' % (self.client_address[0], principal, self.log_date_time_string(), format % args))
|
||||
@@ -0,0 +1,33 @@
|
||||
"""/https/settings: the settings of HTTPS, of which the API changes the CA of Caddy's certificates."""
|
||||
|
||||
import vpnrouter
|
||||
|
||||
from .common import section_change
|
||||
|
||||
#: The settings of the https section PUT /https/settings changes.
|
||||
CHANGEABLE = ('acme_staging',)
|
||||
|
||||
|
||||
def https_settings(method, body):
|
||||
"""Answer a /https/settings request: the HTTP status and the JSON document.
|
||||
|
||||
GET /https/settings {"settings"}: the https section
|
||||
PUT /https/settings change acme_staging; the other settings of the section are changed on the router with
|
||||
vpn-config-edit, and a body naming one is 400
|
||||
|
||||
A change is applied by restarting vpn-router-setup without waiting for it.
|
||||
"""
|
||||
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
if method in ('GET', 'HEAD'):
|
||||
try:
|
||||
return 200, {'settings': vpnrouter.load_config()['https']}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
if method != 'PUT':
|
||||
return 404, {'error': f'no route {method} /https/settings'}
|
||||
others = sorted(set(body or {}) - set(CHANGEABLE))
|
||||
if others:
|
||||
return 400, {'error': f'https.{", https.".join(others)}: changed on the router with vpn-config-edit only'}
|
||||
changed = section_change('https', body or {})
|
||||
return changed if isinstance(changed, tuple) else (200, {'settings': changed})
|
||||
@@ -0,0 +1,299 @@
|
||||
"""The identities of the router's directory: /users, /service-accounts, /groups and the signed-in user's /account."""
|
||||
|
||||
import urllib.parse
|
||||
|
||||
import ldap
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_auth
|
||||
import vpnrouter_passkeys
|
||||
import vpnrouter_users
|
||||
|
||||
from .common import USER_STATUS
|
||||
|
||||
|
||||
def users(state, method, path, body):
|
||||
"""Answer a /users request with vpnrouter_users: the HTTP status and the JSON document.
|
||||
|
||||
GET /users the users, and {"enabled", "address"} of wireguard, whose network their
|
||||
WireGuard peers take their addresses from
|
||||
POST /users add a user: {"name", "password", "first_name", "last_name", "email"};
|
||||
without the names a Manager-only account
|
||||
PUT /users/<name> change any of {"first_name", "last_name", "email", "wireguard_peers"};
|
||||
wireguard_peers, [{"public_key", "ips"}], replaces the user's peers
|
||||
PUT /users/<name>/password set the password: {"password": ...}
|
||||
POST /users/<name>/disable lock the user out of OpenVPN
|
||||
POST /users/<name>/enable let the user in again
|
||||
POST /users/<name>/unlock end a lockout after failed logins
|
||||
DELETE /users/<name> remove the user and revoke the certificate
|
||||
GET /users/<name>/profile the OpenVPN client profile
|
||||
POST /users/<name>/apple-profile
|
||||
the Apple configuration profile of IKEv2 access: {"password"}, which
|
||||
encrypts the user's key in it; {"name", "profile"}
|
||||
POST /users/<name>/otp a new TOTP secret and its otpauth URI; nothing is stored
|
||||
PUT /users/<name>/otp store a TOTP secret: {"secret", "code"}, code a code of the secret
|
||||
DELETE /users/<name>/otp remove the TOTP token
|
||||
DELETE /users/<name>/passkeys remove all the user's passkeys
|
||||
PUT /users/<name>/console switch console login on or off: {"enabled", "account"?}, account a local account,
|
||||
without which the user's own posixAccount; only a token of vpn-router-token
|
||||
|
||||
Service accounts are served by /service-accounts: they are not listed, and a request naming one is 404.
|
||||
"""
|
||||
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
body = body or {}
|
||||
parts = path.strip('/').split('/')
|
||||
route = (method, len(parts), parts[2] if len(parts) == 3 else None)
|
||||
name = parts[1] if len(parts) > 1 else None
|
||||
actions = {
|
||||
('GET', 1, None): lambda connection: {
|
||||
'users': [user for user in vpnrouter_users.list_users(connection)['users'] if not user['service']],
|
||||
'wireguard': {option: vpnrouter.load_config()['wireguard'][option] for option in ('enabled', 'address')}},
|
||||
('POST', 1, None): lambda connection: vpnrouter_users.add(connection, body.get('name'), body.get('password'), body),
|
||||
('PUT', 2, None): lambda connection: vpnrouter_users.update(connection, name, body),
|
||||
('PUT', 3, 'password'): lambda connection: vpnrouter_users.passwd(connection, name, body.get('password')),
|
||||
('POST', 3, 'disable'): lambda connection: vpnrouter_users.disable(connection, name),
|
||||
('POST', 3, 'enable'): lambda connection: vpnrouter_users.enable(connection, name),
|
||||
('POST', 3, 'unlock'): lambda connection: vpnrouter_users.unlock(connection, name),
|
||||
('DELETE', 2, None): lambda connection: vpnrouter_users.remove(connection, name),
|
||||
('GET', 3, 'profile'): lambda connection: vpnrouter_users.profile(connection, name),
|
||||
('POST', 3, 'apple-profile'): lambda connection: vpnrouter_users.apple_profile(connection, name,
|
||||
body.get('password')),
|
||||
('POST', 3, 'otp'): lambda connection: vpnrouter_users.otp_new(connection, name),
|
||||
('PUT', 3, 'otp'): lambda connection: vpnrouter_users.otp_enrol(connection, name, body.get('secret'), body.get('code')),
|
||||
('DELETE', 3, 'otp'): lambda connection: vpnrouter_users.otp_remove(connection, name),
|
||||
('DELETE', 3, 'passkeys'): lambda connection: vpnrouter_passkeys.remove(connection, name),
|
||||
('PUT', 3, 'console'): lambda connection: vpnrouter_users.console(connection, name, body.get('enabled'), body.get('account')),
|
||||
}
|
||||
# The vpnrouter_users operation of each route that may revoke refresh tokens.
|
||||
operations = {('PUT', 3, 'password'): 'passwd', ('POST', 3, 'disable'): 'disable', ('POST', 3, 'unlock'): 'unlock',
|
||||
('DELETE', 2, None): 'remove'}
|
||||
if route not in actions:
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
try:
|
||||
with vpnrouter_users.session() as connection:
|
||||
if name is not None and vpnrouter_users.service(vpnrouter_users.entry(connection, name)):
|
||||
raise vpnrouter_users.Failure(f'{name} is a service account; see /service-accounts', 3)
|
||||
document = actions[route](connection)
|
||||
if operations.get(route) in vpnrouter_auth.REVOKING:
|
||||
vpnrouter_auth.revoke(vpnrouter.load_config(), name)
|
||||
except vpnrouter_users.Failure as failure:
|
||||
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
return 500, {'error': f'the user directory failed: {exc}'}
|
||||
except vpnrouter_auth.DatabaseError as exc:
|
||||
return 500, {'error': str(exc)}
|
||||
except SystemExit as exc:
|
||||
# vpnrouter ends with SystemExit, carrying the reason, on a configuration it cannot read.
|
||||
return 500, {'error': str(exc.code)}
|
||||
return (201 if route == ('POST', 1, None) else 200), document
|
||||
|
||||
|
||||
def oidc_settings():
|
||||
"""{"enabled", "issuer"} of the OpenID Provider, issuer null while it is disabled."""
|
||||
cfg = vpnrouter.load_config()
|
||||
enabled = cfg['oidc']['enabled']
|
||||
return {'enabled': enabled, 'issuer': vpnrouter.oidc_issuer(cfg) if enabled else None}
|
||||
|
||||
|
||||
def service_accounts(method, path, body):
|
||||
"""Answer a /service-accounts request with vpnrouter_users: the HTTP status and the JSON document.
|
||||
|
||||
GET /service-accounts the service accounts, each with its roles: {"service_accounts": [...],
|
||||
"oidc": {"enabled", "issuer"}}, issuer that of the OpenID Provider, null
|
||||
while it is disabled
|
||||
POST /service-accounts add one: {"name", "password", "display_name"?, "description"?, "email"?,
|
||||
"redirect_uris"?, "post_logout_redirect_uris"?, "allowed_groups"?,
|
||||
"grants"?, "public_client"?, "client_scopes"?, "resource_scopes"?,
|
||||
"roles"?}
|
||||
PUT /service-accounts/<name> change any of {"display_name", "description", "email", "redirect_uris",
|
||||
"post_logout_redirect_uris", "allowed_groups", "grants",
|
||||
"public_client", "client_scopes", "resource_scopes", "roles"}; ""
|
||||
removes one of the first three, [] the values of the lists
|
||||
PUT /service-accounts/<name>/password set the password: {"password": ...}
|
||||
POST /service-accounts/<name>/disable lock it out
|
||||
POST /service-accounts/<name>/enable let it in again
|
||||
POST /service-accounts/<name>/unlock end a lockout after failed logins
|
||||
DELETE /service-accounts/<name> remove it
|
||||
|
||||
A service account with redirect URIs or grants is a client of the OpenID Provider, and every service account a
|
||||
resource (vpnrouter_users.client_settings); roles, [{"name", "description", "groups", "accounts"}], replaces its
|
||||
roles (vpnrouter_users.set_roles). A name that is not a service account is 404. The internal service accounts
|
||||
(vpnrouter_users.internal_accounts), which the router manages, are only listed: every other request on one, and a
|
||||
role assigned to one, is refused with 403. A password change, disabling, unlocking and removing revoke the tokens
|
||||
the account was issued.
|
||||
"""
|
||||
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
body = body or {}
|
||||
parts = path.strip('/').split('/')
|
||||
route = (method, len(parts), parts[2] if len(parts) == 3 else None)
|
||||
name = parts[1] if len(parts) > 1 else None
|
||||
settings = {key: body.get(key) for key in ('email', 'display_name', 'description', *vpnrouter_users.CLIENT_KEYS)}
|
||||
actions = {
|
||||
('GET', 1, None): lambda connection: {
|
||||
'service_accounts': [vpnrouter_users.with_roles(connection, user)
|
||||
for user in vpnrouter_users.list_users(connection)['users'] if user['service']],
|
||||
'oidc': oidc_settings()},
|
||||
('POST', 1, None): lambda connection: vpnrouter_users.add(connection, body.get('name'), body.get('password'), settings,
|
||||
service=True),
|
||||
('PUT', 2, None): lambda connection: vpnrouter_users.update_service(connection, name, settings),
|
||||
('PUT', 3, 'password'): lambda connection: vpnrouter_users.passwd(connection, name, body.get('password')),
|
||||
('POST', 3, 'disable'): lambda connection: vpnrouter_users.disable(connection, name),
|
||||
('POST', 3, 'enable'): lambda connection: vpnrouter_users.enable(connection, name),
|
||||
('POST', 3, 'unlock'): lambda connection: vpnrouter_users.unlock(connection, name),
|
||||
('DELETE', 2, None): lambda connection: vpnrouter_users.remove(connection, name),
|
||||
}
|
||||
# The vpnrouter_users operation of each route that may revoke tokens.
|
||||
operations = {('PUT', 3, 'password'): 'passwd', ('POST', 3, 'disable'): 'disable', ('POST', 3, 'unlock'): 'unlock',
|
||||
('DELETE', 2, None): 'remove'}
|
||||
if route not in actions:
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
internal = vpnrouter_users.internal_accounts()
|
||||
if name is not None and name in internal:
|
||||
return 403, {'error': f'{name} is an internal service account, which the router manages'}
|
||||
roles = settings['roles'] if isinstance(settings['roles'], list) else []
|
||||
assigned = sorted(set(internal).intersection(account for role in roles if isinstance(role, dict)
|
||||
for account in (role.get('accounts') or [])
|
||||
if isinstance(account, str)))
|
||||
if assigned:
|
||||
return 403, {'error': f'{", ".join(assigned)}: internal service accounts, which the router manages, are not '
|
||||
'assigned roles'}
|
||||
try:
|
||||
with vpnrouter_users.session() as connection:
|
||||
if name is not None and not vpnrouter_users.service(vpnrouter_users.entry(connection, name)):
|
||||
raise vpnrouter_users.Failure(f'{name} is not a service account', 3)
|
||||
document = actions[route](connection)
|
||||
if operations.get(route) in vpnrouter_auth.REVOKING:
|
||||
vpnrouter_auth.revoke(vpnrouter.load_config(), name)
|
||||
except vpnrouter_users.Failure as failure:
|
||||
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
return 500, {'error': f'the user directory failed: {exc}'}
|
||||
except vpnrouter_auth.DatabaseError as exc:
|
||||
return 500, {'error': str(exc)}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
return (201 if route == ('POST', 1, None) else 200), document
|
||||
|
||||
|
||||
def groups(state, method, path, body):
|
||||
"""Answer a /groups request with vpnrouter_users: the HTTP status and the JSON document.
|
||||
|
||||
GET /groups the groups with their members
|
||||
POST /groups add a group: {"name", "members": [...], "description"?}
|
||||
PUT /groups/<name> rename it or change the description: {"name"?, "description"?}
|
||||
DELETE /groups/<name> remove the group; its members are kept
|
||||
POST /groups/<name>/members add a member: {"user": ...}
|
||||
DELETE /groups/<name>/members/<user> take a member out; the last member cannot leave
|
||||
|
||||
An internal service account (vpnrouter_users.internal_accounts) is not put in or taken out of a group: such a
|
||||
request is refused with 403.
|
||||
"""
|
||||
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
body = body or {}
|
||||
parts = path.strip('/').split('/')
|
||||
name = parts[1] if len(parts) > 1 else None
|
||||
route = (method, len(parts), parts[2] if len(parts) > 2 else None)
|
||||
members = body.get('members') if route == ('POST', 1, None) else [body.get('user')] if route == ('POST', 3, 'members') \
|
||||
else [parts[3]] if route == ('DELETE', 4, 'members') else []
|
||||
internal = sorted(set(vpnrouter_users.internal_accounts()).intersection(
|
||||
member for member in (members if isinstance(members, list) else []) if isinstance(member, str)))
|
||||
if internal:
|
||||
return 403, {'error': f'{", ".join(internal)}: internal service accounts, which the router manages, are not group members'}
|
||||
actions = {
|
||||
('GET', 1, None): lambda connection: vpnrouter_users.list_groups(connection),
|
||||
('POST', 1, None): lambda connection: vpnrouter_users.group_add(
|
||||
connection, body.get('name'), body.get('description'), body.get('members')),
|
||||
('PUT', 2, None): lambda connection: vpnrouter_users.group_update(
|
||||
connection, name, body.get('name'), body.get('description')),
|
||||
('DELETE', 2, None): lambda connection: vpnrouter_users.group_remove(connection, name),
|
||||
('POST', 3, 'members'): lambda connection: vpnrouter_users.group_member_add(connection, name, body.get('user')),
|
||||
('DELETE', 4, 'members'): lambda connection: vpnrouter_users.group_member_remove(connection, name, parts[3]),
|
||||
}
|
||||
if route not in actions:
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
try:
|
||||
with vpnrouter_users.session() as connection:
|
||||
document = actions[route](connection)
|
||||
except vpnrouter_users.Failure as failure:
|
||||
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
return 500, {'error': f'the user directory failed: {exc}'}
|
||||
return (201 if route == ('POST', 1, None) else 200), document
|
||||
|
||||
|
||||
def account(name, method, path, body):
|
||||
"""Answer an /account request of the user named by the token with vpnrouter_users: the HTTP status and the JSON document.
|
||||
|
||||
GET /account the user, with admin, whether the user is a member of ADMIN_GROUP
|
||||
PUT /account/password set the password: {"current_password", "current_code"?, "password"}
|
||||
POST /account/otp a new TOTP secret and its otpauth URI; nothing is stored
|
||||
PUT /account/otp store a TOTP secret: {"current_password", "current_code"?, "secret", "code"},
|
||||
code a code of the secret
|
||||
POST /account/passkeys begin the registration of a passkey: {"current_password", "current_code"?};
|
||||
{"id", "options"} for navigator.credentials.create
|
||||
PUT /account/passkeys store the passkey: {"id", "credential", "label"?}, credential the browser's answer
|
||||
DELETE /account/passkeys/<id>
|
||||
remove a passkey
|
||||
POST /account/profile the user's OpenVPN client profile: {"current_password", "current_code"?}; {"name",
|
||||
"profile"}
|
||||
POST /account/apple-profile
|
||||
the user's Apple configuration profile of IKEv2 access:
|
||||
{"current_password", "current_code"?, "password"}, password encrypting the user's key in
|
||||
it; {"name", "profile"}
|
||||
|
||||
A change but the removal of a passkey, and the profiles, need the current password and, for an enrolled user, a
|
||||
current code; a wrong
|
||||
one is 403 and counts as a failed login. A password change revokes the user's refresh tokens.
|
||||
"""
|
||||
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
body = body or {}
|
||||
route = (method, path.rstrip('/'))
|
||||
removing = method == 'DELETE' and path.startswith('/account/passkeys/') and len(path.strip('/').split('/')) == 3
|
||||
if not removing and route not in (('GET', '/account'), ('PUT', '/account/password'), ('POST', '/account/otp'),
|
||||
('PUT', '/account/otp'), ('POST', '/account/passkeys'), ('PUT', '/account/passkeys'),
|
||||
('POST', '/account/profile'), ('POST', '/account/apple-profile')):
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
try:
|
||||
if route == ('PUT', '/account/password'):
|
||||
vpnrouter_users.check_password(body.get('password'))
|
||||
if route in (('PUT', '/account/password'), ('PUT', '/account/otp'), ('POST', '/account/passkeys'),
|
||||
('POST', '/account/profile'), ('POST', '/account/apple-profile')):
|
||||
vpnrouter_users.login(name, body.get('current_password'), body.get('current_code'))
|
||||
with vpnrouter_users.session() as connection:
|
||||
if route == ('GET', '/account'):
|
||||
document = {**vpnrouter_users.summary(vpnrouter_users.entry(connection, name)),
|
||||
'admin': vpnrouter_users.is_admin(connection, name)}
|
||||
elif route == ('PUT', '/account/password'):
|
||||
document = vpnrouter_users.passwd(connection, name, body['password'])
|
||||
elif route == ('POST', '/account/otp'):
|
||||
document = vpnrouter_users.otp_new(connection, name)
|
||||
elif route == ('POST', '/account/profile'):
|
||||
document = vpnrouter_users.profile(connection, name)
|
||||
elif route == ('POST', '/account/apple-profile'):
|
||||
document = vpnrouter_users.apple_profile(connection, name, body.get('password'))
|
||||
elif route == ('POST', '/account/passkeys'):
|
||||
document = vpnrouter_passkeys.register_begin(connection, name)
|
||||
elif route == ('PUT', '/account/passkeys'):
|
||||
document = vpnrouter_passkeys.register_complete(connection, name, body.get('id'), body.get('credential'),
|
||||
body.get('label'))
|
||||
elif removing:
|
||||
document = vpnrouter_passkeys.remove(connection, name, urllib.parse.unquote(path.strip('/').split('/')[2]))
|
||||
else:
|
||||
document = vpnrouter_users.otp_enrol(connection, name, body.get('secret'), body.get('code'))
|
||||
if route == ('PUT', '/account/password'):
|
||||
vpnrouter_auth.revoke(vpnrouter.load_config(), name)
|
||||
except vpnrouter_users.Failure as failure:
|
||||
if failure.status == 5:
|
||||
return 403, {'error': 'the current password or code is wrong'}
|
||||
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
return 500, {'error': f'the user directory failed: {exc}'}
|
||||
except vpnrouter_auth.DatabaseError as exc:
|
||||
return 500, {'error': str(exc)}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
return 200, document
|
||||
@@ -0,0 +1,75 @@
|
||||
"""/logs: the last lines of the journal of the router's units and of the files web.log_files lists."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import vpnrouter
|
||||
|
||||
#: The units whose journal /logs/journal shows, besides the whole journal: the package's own and the services it runs.
|
||||
JOURNAL_UNITS = ('vpn-router-setup', 'vpn-router-web', 'vpn-router-metrics', 'vpn-router-pki', 'vpn-router-backup',
|
||||
'vpn-router-caddy', 'vpn-router-manager', 'caddy', 'slapd', 'nslcd', 'postgresql', 'strongswan',
|
||||
'wg-quick@wg0', 'openvpn-server@vpn-router', 'ssh', 'vpn-router-resolver', 'vpn-router-webdav', 'pdns')
|
||||
|
||||
#: The most lines /logs answers, the number it answers without lines, and the most bytes it reads from the end of a file.
|
||||
LOG_LINES_MAX = 1000
|
||||
|
||||
LOG_LINES_DEFAULT = 200
|
||||
|
||||
LOG_BYTES_MAX = 1024 * 1024
|
||||
|
||||
|
||||
def tail(path, count):
|
||||
"""The last count lines of a file, read from at most LOG_BYTES_MAX bytes at its end, invalid UTF-8 replaced."""
|
||||
with open(path, 'rb') as handle:
|
||||
size = handle.seek(0, os.SEEK_END)
|
||||
start = max(0, size - LOG_BYTES_MAX)
|
||||
handle.seek(start)
|
||||
lines = handle.read().decode('utf-8', 'replace').splitlines()
|
||||
# A read that starts inside the file starts inside a line, which is left out.
|
||||
return (lines[1:] if start and lines else lines)[-count:]
|
||||
|
||||
|
||||
def logs(method, path, query):
|
||||
"""Answer a /logs request: the HTTP status and the JSON document.
|
||||
|
||||
GET /logs {"units": JOURNAL_UNITS, "files": web.log_files, "max_lines": LOG_LINES_MAX}
|
||||
GET /logs/journal {"lines": [...]}: the last lines of the journal, of the unit unit= names, one of
|
||||
JOURNAL_UNITS, or of the whole journal without it
|
||||
GET /logs/file {"lines": [...]}: the last lines of the file path= names, one of web.log_files
|
||||
|
||||
lines= is the number of lines, 1 to LOG_LINES_MAX, LOG_LINES_DEFAULT without it.
|
||||
"""
|
||||
if method not in ('GET', 'HEAD'):
|
||||
return 405, {'error': f'{method} is not allowed on /logs'}
|
||||
try:
|
||||
files = [item.strip() for item in vpnrouter.load_config()['web']['log_files'] if item.strip()]
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
if path == '/logs':
|
||||
return 200, {'units': list(JOURNAL_UNITS), 'files': files, 'max_lines': LOG_LINES_MAX}
|
||||
text = query.get('lines', [str(LOG_LINES_DEFAULT)])[0]
|
||||
if not text.isdigit() or not 1 <= int(text) <= LOG_LINES_MAX:
|
||||
return 400, {'error': f'lines must be a whole number from 1 to {LOG_LINES_MAX}'}
|
||||
count = int(text)
|
||||
if path == '/logs/journal':
|
||||
unit = query.get('unit', [''])[0]
|
||||
if unit and unit not in JOURNAL_UNITS:
|
||||
return 404, {'error': f'{unit} is not one of the units whose journal /logs shows'}
|
||||
try:
|
||||
result = subprocess.run(['journalctl', '--no-pager', '--quiet', '--output=short-iso', f'--lines={count}',
|
||||
*([f'--unit={unit}.service'] if unit else [])], capture_output=True, text=True,
|
||||
errors='replace', timeout=30)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
return 500, {'error': f'journalctl cannot be run: {exc}'}
|
||||
if result.returncode != 0:
|
||||
return 500, {'error': result.stderr.strip() or f'journalctl ended with status {result.returncode}'}
|
||||
return 200, {'lines': result.stdout.splitlines()[-count:]}
|
||||
if path == '/logs/file':
|
||||
name = query.get('path', [''])[0]
|
||||
if name not in files:
|
||||
return 404, {'error': f'{name!r} is not one of web.log_files'}
|
||||
try:
|
||||
return 200, {'lines': tail(name, count)}
|
||||
except OSError as exc:
|
||||
return (404 if isinstance(exc, FileNotFoundError) else 500), {'error': f'{name} cannot be read: {exc.strerror}'}
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
@@ -0,0 +1,55 @@
|
||||
"""/metrics/history and /metrics/settings: the metrics the metrics service keeps, and its settings."""
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_metrics
|
||||
|
||||
from .common import section_change
|
||||
|
||||
|
||||
def metrics_history(view):
|
||||
"""Answer GET /metrics/history?range=<view> with vpnrouter_metrics.history: the HTTP status and the JSON document.
|
||||
|
||||
view is hour, the default, day, month or year (vpnrouter_metrics.RANGES).
|
||||
"""
|
||||
if view not in vpnrouter_metrics.RANGES:
|
||||
return 400, {'error': f'range must be one of {", ".join(vpnrouter_metrics.RANGES)}'}
|
||||
try:
|
||||
with vpnrouter_metrics.database(vpnrouter.load_config()) as connection:
|
||||
return 200, vpnrouter_metrics.history(connection, view)
|
||||
except vpnrouter_metrics.DatabaseError as exc:
|
||||
return 500, {'error': str(exc)}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
|
||||
|
||||
def metrics_shown(settings):
|
||||
"""The metrics section as /metrics/settings shows it: its settings but otlp_password, with otlp_password_set."""
|
||||
return {**{option: value for option, value in settings.items() if option != 'otlp_password'},
|
||||
'otlp_password_set': bool(settings['otlp_password'])}
|
||||
|
||||
|
||||
def metrics_settings(method, body):
|
||||
"""Answer a /metrics/settings request: the HTTP status and the JSON document.
|
||||
|
||||
GET /metrics/settings {"settings"}: the metrics section without otlp_password, with otlp_password_set
|
||||
PUT /metrics/settings change any of the settings of the metrics section; an empty or absent otlp_password keeps
|
||||
the current secret, and an empty otlp_username removes it
|
||||
|
||||
A change is applied by restarting vpn-router-setup without waiting for it.
|
||||
"""
|
||||
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
if method in ('GET', 'HEAD'):
|
||||
try:
|
||||
return 200, {'settings': metrics_shown(vpnrouter.load_config()['metrics'])}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
if method != 'PUT':
|
||||
return 404, {'error': f'no route {method} /metrics/settings'}
|
||||
body = dict(body or {})
|
||||
if not body.get('otlp_password'):
|
||||
body.pop('otlp_password', None)
|
||||
if body.get('otlp_username') == '':
|
||||
body['otlp_password'] = ''
|
||||
changed = section_change('metrics', body)
|
||||
return changed if isinstance(changed, tuple) else (200, {'settings': metrics_shown(changed)})
|
||||
@@ -0,0 +1,90 @@
|
||||
"""The PKI: /ca, and the certificates and CRLs /pki/ serves without authentication."""
|
||||
|
||||
import ldap
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_pki
|
||||
from vpnrouter_status import run
|
||||
|
||||
from .common import PKI_STATUS
|
||||
|
||||
#: A file /pki/ serves: the certificate or the CRL of a CA, and its content type.
|
||||
PUBLISHED = {'.crt': 'application/pkix-cert', '.crl': 'application/pkix-crl'}
|
||||
|
||||
|
||||
def ca(method, path, body):
|
||||
"""Answer a /ca request with vpnrouter_pki: the HTTP status and the JSON document.
|
||||
|
||||
GET /ca {"settings", "templates", "extended_key_usages", "status",
|
||||
"certificates"}: the pki section, the templates with the built-in
|
||||
ones, the extended key usages, each {oid, builtin}, the root, the
|
||||
issuing CAs, the retired CAs and the warnings (vpnrouter_pki.status),
|
||||
and the certificates issued, newest first
|
||||
PUT /ca change any of the settings of the pki section
|
||||
POST /ca/certificates issue a certificate: {"ca", "template", "csr"} answers {"serial",
|
||||
"certificate", "chain"}; {"ca", "template", "subject", "sans",
|
||||
"password"} answers {"serial", "pfx"}, a PKCS#12 file in base64
|
||||
POST /ca/certificates/<serial>/revoke revoke a certificate: {"reason"?}
|
||||
GET /ca/bundle/<name> the PEM certificates a client trusts the CA with: {"name", "pem"}; for
|
||||
root the root alone, for an issuing CA the issuing CA and the root
|
||||
GET /ca/issuing/<name>/csr the CSR of an issuing CA that waits for the offline root, with the
|
||||
openssl extensions to sign it with: {"name", "csr", "extensions"}
|
||||
PUT /ca/issuing/<name>/certificate take its certificate signed by the root: {"certificate"}
|
||||
PUT /ca/root/crl take a root CRL signed offline: {"crl"}
|
||||
|
||||
A change of the settings is made as /site-to-site makes one; it, a revoked router certificate and an imported
|
||||
issuing CA certificate are applied by restarting vpn-router-setup without waiting for it.
|
||||
"""
|
||||
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
body = body or {}
|
||||
parts = path.strip('/').split('/')
|
||||
route = (method, *parts[1:])
|
||||
try:
|
||||
cfg = vpnrouter.load_config()
|
||||
if len(route) == 3 and route[:2] == ('GET', 'bundle'):
|
||||
return 200, vpnrouter_pki.bundle(route[2])
|
||||
if route == ('GET',):
|
||||
return 200, {'settings': cfg['pki'], 'templates': vpnrouter.pki_templates(cfg),
|
||||
'extended_key_usages': {name: {'oid': oid, 'builtin': name in vpnrouter.EXTENDED_KEY_USAGES}
|
||||
for name, oid in vpnrouter.extended_key_usages(cfg).items()},
|
||||
'status': vpnrouter_pki.status(cfg), 'certificates': vpnrouter_pki.certificates()}
|
||||
if route == ('PUT',):
|
||||
settings = {option: body[option] for option in vpnrouter.SCHEMA['pki'] if option in body}
|
||||
with vpnrouter.config_lock():
|
||||
data = vpnrouter.read_settings()
|
||||
current = data.get('pki')
|
||||
data['pki'] = {**(current if isinstance(current, dict) else {}), **settings}
|
||||
cfg = vpnrouter.with_defaults(data)
|
||||
vpnrouter.validate(cfg)
|
||||
vpnrouter.save_config(data)
|
||||
run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
|
||||
return 200, {'settings': cfg['pki'], 'templates': vpnrouter.pki_templates(cfg)}
|
||||
with vpnrouter_pki.locked():
|
||||
if route == ('POST', 'certificates'):
|
||||
return 201, vpnrouter_pki.issue(cfg, body.get('ca'), body.get('template'), csr=body.get('csr'),
|
||||
subject=body.get('subject'), sans=body.get('sans'),
|
||||
password=body.get('password'))
|
||||
if len(route) == 4 and route[:2] == ('POST', 'certificates') and route[3] == 'revoke':
|
||||
document = vpnrouter_pki.revoke(cfg, route[2], body.get('reason'))
|
||||
elif len(route) == 4 and route[:2] == ('GET', 'issuing') and route[3] == 'csr':
|
||||
return 200, vpnrouter_pki.csr(cfg, route[2])
|
||||
elif len(route) == 4 and route[:2] == ('PUT', 'issuing') and route[3] == 'certificate':
|
||||
document = vpnrouter_pki.import_ca(cfg, route[2], body.get('certificate'))
|
||||
elif route == ('PUT', 'root', 'crl'):
|
||||
return 200, vpnrouter_pki.import_root_crl(body.get('crl'))
|
||||
else:
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
except vpnrouter.ConfigError as exc:
|
||||
return 400, {'error': str(exc)}
|
||||
except vpnrouter_pki.Failure as failure:
|
||||
return PKI_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||
except ldap.LDAPError as exc:
|
||||
return 500, {'error': f'the directory, which holds the PKI, failed: {exc}'}
|
||||
except OSError as exc:
|
||||
return 500, {'error': f'the PKI cannot be changed: {exc}'}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
if route[1] == 'issuing' or document.get('reissue'):
|
||||
run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
|
||||
return 200, document
|
||||
@@ -0,0 +1,512 @@
|
||||
"""/shell: the ticket (shell_ticket), with the passkey proof it may take (shell_passkey), the WebSocket of the terminal
|
||||
(shell), and the editor of the shell: the socket vpn-router-editor names files on (editor_listen) and /shell/editor/...
|
||||
(shell_editor)."""
|
||||
|
||||
import base64
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import pwd
|
||||
import secrets
|
||||
import select
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import termios
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
import ldap
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_passkeys
|
||||
import vpnrouter_users
|
||||
from vpnrouter_status import run
|
||||
|
||||
from .common import BODY_LIMIT, MIB, USER_STATUS, log
|
||||
|
||||
#: Seconds a ticket of POST /shell may open a shell.
|
||||
SHELL_TICKET_SECONDS = 30
|
||||
|
||||
#: Seconds between the checks of an open shell's user, which end the shell once its console is refused.
|
||||
SHELL_CHECK_SECONDS = 60
|
||||
|
||||
#: The largest WebSocket message accepted from a client, in bytes.
|
||||
MESSAGE_LIMIT = 65536
|
||||
|
||||
#: The socket vpn-router-editor (vre) names files on to open them in the editor of a shell (see editor_listen()).
|
||||
EDITOR_SOCKET = '/run/vpn-router/editor.sock'
|
||||
|
||||
#: The helper that reads and writes a file of the editor as the shell's user.
|
||||
EDITOR_FILE = '/usr/lib/vpn-router/editor-file'
|
||||
|
||||
#: The largest file the editor opens, in bytes, as editor-file allows; a body of PUT /shell/editor/... may hold its
|
||||
#: content escaped in JSON.
|
||||
EDITOR_MAX_BYTES = MIB
|
||||
|
||||
EDITOR_BODY_LIMIT = 2 * EDITOR_MAX_BYTES + BODY_LIMIT
|
||||
|
||||
#: The most files a shell has open in its editor at once.
|
||||
EDITOR_FILES_MAX = 20
|
||||
|
||||
#: Seconds a reader of the editor socket and a run of editor-file may take.
|
||||
EDITOR_TIMEOUT = 30
|
||||
|
||||
#: The GUID of RFC 6455 that the Sec-WebSocket-Accept of a handshake is made with.
|
||||
WEBSOCKET_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
||||
|
||||
#: WebSocket opcodes.
|
||||
TEXT, BINARY, CLOSE, PING, PONG = 1, 2, 8, 9, 10
|
||||
|
||||
#: The unused tickets of POST /shell: ticket -> (user, monotonic time of expiry).
|
||||
_shell_tickets = {}
|
||||
|
||||
_shell_lock = threading.Lock()
|
||||
|
||||
#: The last use of a shell of each user, a sign-in of POST /shell or a keystroke: user -> monotonic time.
|
||||
_shell_used = {}
|
||||
|
||||
#: The open shells by their session, which each shell has in VPN_ROUTER_SHELL: session -> {"user", "account", "uid",
|
||||
#: "send", the function that sends a WebSocket frame to its client, "files": {file id: path}}.
|
||||
_shell_sessions = {}
|
||||
|
||||
|
||||
def shell_ticket(name, body):
|
||||
"""Answer POST /shell: the HTTP status and the JSON document.
|
||||
|
||||
The user named by the token signs in again with {"password", "code"}, its password and TOTP code, or with
|
||||
{"passkey": {"id", "credential"}}, the answer of the browser to a proof begun by POST /shell/passkey (see
|
||||
shell_passkey) with one of its own passkeys and the user verified. A wrong password or code counts as a failed
|
||||
login; it and a passkey that is not accepted are 403. Without either, the user is not asked to sign in while its
|
||||
last use of a shell (see _shell_used) is less than web.shell_auth_timeout minutes ago. The answer is {"ticket",
|
||||
"expires_in"}: a ticket that opens one shell (GET /shell) for the same user within SHELL_TICKET_SECONDS, or a null
|
||||
ticket when the user must sign in. 403 while web.shell is false, the user's console login is not on or the user has
|
||||
no TOTP token.
|
||||
"""
|
||||
if not isinstance(name, str):
|
||||
return 403, {'error': 'the shell needs a token of a signed-in user'}
|
||||
if not isinstance(body, dict):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
try:
|
||||
options = vpnrouter.load_config()['web']
|
||||
if not options['shell']:
|
||||
return 403, {'error': 'the shell is disabled; vpn-config-edit set web.shell true enables it'}
|
||||
refusal = console_account(name)[1]
|
||||
if refusal:
|
||||
return 403, {'error': refusal}
|
||||
if 'passkey' in body:
|
||||
passkey = body['passkey'] if isinstance(body['passkey'], dict) else {}
|
||||
with vpnrouter_users.session() as connection:
|
||||
vpnrouter_passkeys.shell_complete(connection, name, passkey.get('id'), passkey.get('credential'))
|
||||
elif 'password' in body:
|
||||
vpnrouter_users.login(name, body.get('password'), body.get('code'))
|
||||
else:
|
||||
with _shell_lock:
|
||||
used = _shell_used.get(name)
|
||||
if used is None or time.monotonic() - used >= options['shell_auth_timeout'] * 60:
|
||||
return 200, {'ticket': None, 'expires_in': 0}
|
||||
except vpnrouter_users.Failure as failure:
|
||||
if failure.status == 5:
|
||||
return 403, {'error': 'the passkey is not accepted' if 'passkey' in body else 'the password or the code is wrong'}
|
||||
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
return 500, {'error': f'the user directory failed: {exc}'}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
ticket = secrets.token_urlsafe(32)
|
||||
now = time.monotonic()
|
||||
with _shell_lock:
|
||||
for unused in [key for key, (_, expires) in _shell_tickets.items() if expires <= now]:
|
||||
del _shell_tickets[unused]
|
||||
_shell_tickets[ticket] = (name, now + SHELL_TICKET_SECONDS)
|
||||
_shell_used[name] = now
|
||||
return 200, {'ticket': ticket, 'expires_in': SHELL_TICKET_SECONDS}
|
||||
|
||||
|
||||
def shell_passkey(name):
|
||||
"""Answer POST /shell/passkey: begin the proof of the signed-in user with one of its own passkeys for POST /shell,
|
||||
{"id", "options"}, options those of navigator.credentials.get; the HTTP status and the JSON document. 403 while
|
||||
the user may not use a shell (console_account), 400 for a user without a passkey."""
|
||||
if not isinstance(name, str):
|
||||
return 403, {'error': 'the shell needs a token of a signed-in user'}
|
||||
try:
|
||||
if not vpnrouter.load_config()['web']['shell']:
|
||||
return 403, {'error': 'the shell is disabled; vpn-config-edit set web.shell true enables it'}
|
||||
refusal = console_account(name)[1]
|
||||
if refusal:
|
||||
return 403, {'error': refusal}
|
||||
with vpnrouter_users.session() as connection:
|
||||
return 200, vpnrouter_passkeys.shell_begin(connection, name)
|
||||
except vpnrouter_users.Failure as failure:
|
||||
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
return 500, {'error': f'the user directory failed: {exc}'}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
|
||||
|
||||
def console_account(name):
|
||||
"""The system account the console of the user runs as and None, or None and the reason the user may not use a
|
||||
console: its console login is off, or it has no TOTP token, which the PAM login session of the shell and sudo need
|
||||
also when a passkey opens the shell."""
|
||||
with vpnrouter_users.session() as connection:
|
||||
user = vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
|
||||
if not user['console'] or not user['console']['enabled']:
|
||||
return None, f'console login is not enabled for {name}; vpn-router-user console sets it'
|
||||
if not user['otp']:
|
||||
return None, f'{name} has no TOTP token; the shell needs one, enrolled on the Account page'
|
||||
return user['console']['account'], None
|
||||
|
||||
|
||||
def take_ticket(name, ticket):
|
||||
"""Whether the ticket of POST /shell is unexpired and was given to the user; it is used up either way."""
|
||||
with _shell_lock:
|
||||
holder, expires = _shell_tickets.pop(ticket, (None, 0.0))
|
||||
return holder == name and expires > time.monotonic()
|
||||
|
||||
|
||||
def shell_command(account, unit, session):
|
||||
"""The command that runs the login shell of the system user account in the transient unit, on its terminal, with
|
||||
the shell's session in VPN_ROUTER_SHELL for vpn-router-editor.
|
||||
|
||||
setsid makes the terminal the controlling one of systemd-run, which passes the window size on; systemd-run starts
|
||||
the shell outside the web service's sandbox, in a PAM login session of the user.
|
||||
"""
|
||||
return ['setsid', '--ctty', 'systemd-run', '--quiet', '--collect', '--pty', '--send-sighup', f'--unit={unit}',
|
||||
f'--uid={account.pw_name}', '--property=PAMName=login', f'--working-directory={account.pw_dir}',
|
||||
'--setenv=TERM=xterm-256color', f'--setenv=VPN_ROUTER_SHELL={session}', account.pw_shell or '/bin/sh', '-l']
|
||||
|
||||
|
||||
def editor_file(account, action, path, content=b'', version=''):
|
||||
"""Run editor-file as the system user account outside the sandbox: read the file, or write content to it when it
|
||||
still has version. The exit status of editor-file and its output, or its message on failure."""
|
||||
command = ['systemd-run', '--quiet', '--collect', '--pipe', f'--uid={account}', EDITOR_FILE, action, path]
|
||||
try:
|
||||
result = subprocess.run(command + ([version] if action == 'write' else []), input=content, capture_output=True,
|
||||
timeout=EDITOR_TIMEOUT)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
return 1, f'editor-file cannot be run: {exc}'.encode()
|
||||
return result.returncode, result.stdout if result.returncode == 0 else result.stderr.strip() or \
|
||||
f'editor-file ended with status {result.returncode}'.encode()
|
||||
|
||||
|
||||
def shell_editor(name, method, parts, body):
|
||||
"""Answer a request of a file open in the editor of one of the user's shells: the HTTP status and the JSON document.
|
||||
|
||||
GET /shell/editor/<session>/<id> {"path", "content", "version"}, version the SHA-256 of the content
|
||||
PUT /shell/editor/<session>/<id> {"content", "version"} writes the content when the file still has version,
|
||||
else 409; answers {"path", "version"}
|
||||
DELETE /shell/editor/<session>/<id> closes the file
|
||||
|
||||
The file is read and written as the shell's console account (editor_file). 404 for a session that is not a shell of
|
||||
the user or a file it does not have open.
|
||||
"""
|
||||
if len(parts) != 4:
|
||||
return 404, {'error': f'no route {method} /{"/".join(parts)}'}
|
||||
with _shell_lock:
|
||||
session = _shell_sessions.get(parts[2])
|
||||
path = session['files'].get(parts[3]) if session and session['user'] == name else None
|
||||
account = session['account'] if path else None
|
||||
if path is None:
|
||||
return 404, {'error': 'no such file is open in a shell of yours'}
|
||||
if method == 'DELETE':
|
||||
with _shell_lock:
|
||||
session['files'].pop(parts[3], None)
|
||||
return 200, {'path': path, 'closed': True}
|
||||
if method == 'GET':
|
||||
status, output = editor_file(account, 'read', path)
|
||||
if status != 0:
|
||||
return (400 if status == 2 else 500), {'error': output.decode(errors='replace')}
|
||||
return 200, {'path': path, 'content': output.decode(), 'version': hashlib.sha256(output).hexdigest()}
|
||||
if method != 'PUT':
|
||||
return 405, {'error': f'{method} is not allowed on a file of the editor'}
|
||||
if not isinstance(body, dict) or not isinstance(body.get('content'), str) or not isinstance(body.get('version'), str):
|
||||
return 400, {'error': 'the body must be {"content", "version"}'}
|
||||
content = body['content'].encode()
|
||||
if len(content) > EDITOR_MAX_BYTES:
|
||||
return 413, {'error': f'the content is larger than {EDITOR_MAX_BYTES // 1024} KiB'}
|
||||
status, output = editor_file(account, 'write', path, content, body['version'])
|
||||
if status != 0:
|
||||
return {2: 400, 3: 409}.get(status, 500), {'error': output.decode(errors='replace')}
|
||||
log(f'editor of {name} saved {path} as {account}')
|
||||
return 200, {'path': path, 'version': hashlib.sha256(content).hexdigest()}
|
||||
|
||||
|
||||
def editor_request(connection):
|
||||
"""Answer one request of vpn-router-editor on the editor socket: {"session", "path"} on a line opens the file in the
|
||||
editor of the shell of the session, whose client is sent {"editor": "open", "session", "id", "path"} as a text
|
||||
message.
|
||||
|
||||
The peer must run as the shell's console account and the path must be absolute. The answer is {"ok": true} or
|
||||
{"error"} on a line.
|
||||
"""
|
||||
try:
|
||||
connection.settimeout(EDITOR_TIMEOUT)
|
||||
_, uid, _ = struct.unpack('iII', connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED,
|
||||
struct.calcsize('iII')))
|
||||
request = json.loads(connection.makefile('rb').readline(4096) or b'null')
|
||||
if not isinstance(request, dict) or not isinstance(request.get('session'), str) or \
|
||||
not isinstance(request.get('path'), str):
|
||||
answer = {'error': 'the request must be {"session", "path"}'}
|
||||
elif not os.path.isabs(request['path']):
|
||||
answer = {'error': 'the path must be absolute'}
|
||||
else:
|
||||
with _shell_lock:
|
||||
session = _shell_sessions.get(request['session'])
|
||||
if session is None or session['uid'] != uid:
|
||||
answer = {'error': 'no shell of the Router Manager has this session; vre works only in its shell'}
|
||||
elif len(session['files']) >= EDITOR_FILES_MAX:
|
||||
answer = {'error': f'the editor has {EDITOR_FILES_MAX} files open; close one first'}
|
||||
else:
|
||||
file_id = secrets.token_urlsafe(8)
|
||||
session['files'][file_id] = request['path']
|
||||
answer = None
|
||||
if answer is None:
|
||||
session['send'](TEXT, json.dumps({'editor': 'open', 'session': request['session'], 'id': file_id,
|
||||
'path': request['path']}).encode())
|
||||
log(f'editor of {session["user"]} opened {request["path"]}')
|
||||
answer = {'ok': True}
|
||||
connection.sendall(json.dumps(answer).encode() + b'\n')
|
||||
except (OSError, ValueError, struct.error):
|
||||
pass
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def editor_listen():
|
||||
"""Listen on EDITOR_SOCKET, which any local user may connect to, answering each connection in a thread with
|
||||
editor_request; the socket of an earlier run is replaced. Does not return; a failure to listen is logged and the
|
||||
editor stays closed."""
|
||||
try:
|
||||
if os.path.lexists(EDITOR_SOCKET):
|
||||
os.unlink(EDITOR_SOCKET)
|
||||
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
listener.bind(EDITOR_SOCKET)
|
||||
os.chmod(EDITOR_SOCKET, 0o666)
|
||||
listener.listen(16)
|
||||
except OSError as exc:
|
||||
log(f'the editor socket {EDITOR_SOCKET} cannot be opened: {exc}')
|
||||
return
|
||||
while True:
|
||||
try:
|
||||
connection, _ = listener.accept()
|
||||
except OSError:
|
||||
continue
|
||||
threading.Thread(target=editor_request, args=(connection,), name='editor request', daemon=True).start()
|
||||
|
||||
|
||||
def frame(opcode, payload):
|
||||
"""A final, unmasked WebSocket frame of a server."""
|
||||
size = len(payload)
|
||||
if size < 126:
|
||||
length = bytes([size])
|
||||
elif size < 65536:
|
||||
length = bytes([126]) + size.to_bytes(2, 'big')
|
||||
else:
|
||||
length = bytes([127]) + size.to_bytes(8, 'big')
|
||||
return bytes([0x80 | opcode]) + length + payload
|
||||
|
||||
|
||||
def exact(handler, size):
|
||||
"""The next size bytes from the client of the Handler handler; ConnectionError when it closes first."""
|
||||
data = handler.rfile.read(size)
|
||||
if len(data) != size:
|
||||
raise ConnectionError('the client closed the connection')
|
||||
return data
|
||||
|
||||
|
||||
def message(handler, send):
|
||||
"""The opcode and the payload of the next WebSocket data message of the client of the Handler handler, or CLOSE
|
||||
and its payload.
|
||||
|
||||
Fragments are joined and unmasked; a ping is answered with send, a pong ignored. ValueError for an unmasked
|
||||
frame or a message over MESSAGE_LIMIT.
|
||||
"""
|
||||
opcode, payload = None, b''
|
||||
while True:
|
||||
head = exact(handler, 2)
|
||||
code, size = head[0] & 0x0F, head[1] & 0x7F
|
||||
if not head[1] & 0x80:
|
||||
raise ValueError('a frame of the client is not masked')
|
||||
if size == 126:
|
||||
size = int.from_bytes(exact(handler, 2), 'big')
|
||||
elif size == 127:
|
||||
size = int.from_bytes(exact(handler, 8), 'big')
|
||||
if len(payload) + size > MESSAGE_LIMIT:
|
||||
raise ValueError(f'a message is larger than {MESSAGE_LIMIT} bytes')
|
||||
mask = exact(handler, 4) * (size // 4 + 1)
|
||||
data = bytes(byte ^ key for byte, key in zip(exact(handler, size), mask))
|
||||
if code == PING:
|
||||
send(PONG, data)
|
||||
continue
|
||||
if code == PONG:
|
||||
continue
|
||||
if code == CLOSE:
|
||||
return CLOSE, data
|
||||
opcode = code if opcode is None else opcode
|
||||
payload += data
|
||||
if head[0] & 0x80:
|
||||
return opcode, payload
|
||||
|
||||
|
||||
def shell(handler, name):
|
||||
"""Answer GET /shell?ticket=<ticket>, a WebSocket handshake the Handler handler received, with a login shell of the
|
||||
user's console account (vpnrouter_users.console) on a terminal.
|
||||
|
||||
The ticket must come from POST /shell for the user named by the token. Binary messages of the client are its
|
||||
keystrokes, a text message {"cols", "rows"} sets the window size, and binary messages of the server are the
|
||||
terminal's output; a text message of the server {"editor": "open", "session", "id", "path"} opens a file the
|
||||
shell named with vpn-router-editor (editor_request), which the client reads and saves with
|
||||
/shell/editor/<session>/<id> (shell_editor) while the shell lives. The shell ends after web.shell_idle_minutes
|
||||
without a keystroke, when it exits, when the client closes the connection, or when a check of the user (SHELL_CHECK_SECONDS) finds its console login off or
|
||||
its TOTP token gone; the server then closes it with the reason. Each opening, end and refusal is logged
|
||||
with its reason.
|
||||
"""
|
||||
def refuse(code, message):
|
||||
log(f'shell of {name} from {handler.client()} refused: {message}')
|
||||
body = json.dumps({'error': message}).encode() + b'\n'
|
||||
try:
|
||||
handler.send_response(code)
|
||||
handler.send_header('Content-Type', 'application/json')
|
||||
handler.send_header('Content-Length', str(len(body)))
|
||||
handler.end_headers()
|
||||
handler.wfile.write(body)
|
||||
except ConnectionError:
|
||||
handler.close_connection = True
|
||||
|
||||
key = handler.headers.get('Sec-WebSocket-Key', '').strip()
|
||||
if handler.headers.get('Upgrade', '').lower() != 'websocket' or 'upgrade' not in handler.headers.get('Connection', '').lower() \
|
||||
or handler.headers.get('Sec-WebSocket-Version') != '13' or not key:
|
||||
return refuse(400, 'GET /shell must be a WebSocket handshake')
|
||||
query = urllib.parse.parse_qs(urllib.parse.urlsplit(handler.path).query)
|
||||
if not take_ticket(name, query.get('ticket', [''])[0]):
|
||||
return refuse(403, 'the ticket is not valid; POST /shell gives one')
|
||||
try:
|
||||
options = vpnrouter.load_config()['web']
|
||||
except SystemExit as exc:
|
||||
return refuse(500, str(exc.code))
|
||||
if not options['shell']:
|
||||
return refuse(403, 'the shell is disabled; vpn-config-edit set web.shell true enables it')
|
||||
try:
|
||||
login, refusal = console_account(name)
|
||||
except (vpnrouter_users.Failure, ldap.LDAPError, OSError) as exc:
|
||||
return refuse(500, f'the user directory failed: {exc}')
|
||||
if refusal:
|
||||
return refuse(403, refusal)
|
||||
try:
|
||||
account = pwd.getpwnam(login)
|
||||
except KeyError:
|
||||
return refuse(500, f'the system does not know the account {login}; is nslcd running?')
|
||||
unit = f'vpn-router-shell-{secrets.token_hex(4)}'
|
||||
session = secrets.token_urlsafe(24)
|
||||
master, terminal = os.openpty()
|
||||
try:
|
||||
process = subprocess.Popen(shell_command(account, unit, session), stdin=terminal, stdout=terminal, stderr=terminal,
|
||||
env={'PATH': '/usr/sbin:/usr/bin:/sbin:/bin'})
|
||||
except OSError as exc:
|
||||
os.close(master)
|
||||
return refuse(500, f'the shell cannot be started: {exc}')
|
||||
finally:
|
||||
os.close(terminal)
|
||||
accept = base64.b64encode(hashlib.sha1((key + WEBSOCKET_GUID).encode()).digest()).decode()
|
||||
handler.close_connection = True
|
||||
try:
|
||||
handler.send_response(101, 'Switching Protocols')
|
||||
handler.send_header('Upgrade', 'websocket')
|
||||
handler.send_header('Connection', 'Upgrade')
|
||||
handler.send_header('Sec-WebSocket-Accept', accept)
|
||||
handler.end_headers()
|
||||
except ConnectionError:
|
||||
# The client went away before the shell opened; the shell started for it is stopped.
|
||||
run('systemctl', 'stop', unit)
|
||||
os.close(master)
|
||||
process.kill()
|
||||
process.wait()
|
||||
log(f'shell of {name} from {handler.client()} ended before it opened: the client closed the connection')
|
||||
return
|
||||
client, started, idle = handler.client(), time.monotonic(), options['shell_idle_minutes'] * 60
|
||||
log(f'shell of {name} from {client} opened as {account.pw_name} in {unit}')
|
||||
|
||||
lock, ended, typed = threading.Lock(), threading.Event(), [started]
|
||||
|
||||
def send(opcode, payload):
|
||||
with lock:
|
||||
handler.wfile.write(frame(opcode, payload))
|
||||
|
||||
with _shell_lock:
|
||||
_shell_sessions[session] = {'user': name, 'account': account.pw_name, 'uid': account.pw_uid, 'send': send,
|
||||
'files': {}}
|
||||
|
||||
def receive():
|
||||
"""Pass the client's keystrokes and window sizes to the terminal until it closes the connection."""
|
||||
try:
|
||||
while True:
|
||||
opcode, payload = message(handler, send)
|
||||
if opcode == CLOSE:
|
||||
break
|
||||
if opcode == BINARY:
|
||||
os.write(master, payload)
|
||||
typed[0] = time.monotonic()
|
||||
with _shell_lock:
|
||||
_shell_used[name] = typed[0]
|
||||
elif opcode == TEXT:
|
||||
size = json.loads(payload)
|
||||
fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack('HHHH', int(size['rows']), int(size['cols']), 0, 0))
|
||||
except (OSError, ValueError, KeyError, TypeError, struct.error):
|
||||
pass
|
||||
finally:
|
||||
ended.set()
|
||||
|
||||
reader = threading.Thread(target=receive, name=f'{unit} input', daemon=True)
|
||||
reader.start()
|
||||
reason = 'the shell ended'
|
||||
checked = started
|
||||
try:
|
||||
while not ended.is_set():
|
||||
left = typed[0] + idle - time.monotonic()
|
||||
if left <= 0:
|
||||
reason = f'no keystroke for {options["shell_idle_minutes"]} minutes'
|
||||
break
|
||||
# The user's console login and TOTP token are checked again every SHELL_CHECK_SECONDS.
|
||||
if time.monotonic() - checked >= SHELL_CHECK_SECONDS:
|
||||
checked = time.monotonic()
|
||||
try:
|
||||
refusal = console_account(name)[1]
|
||||
except (vpnrouter_users.Failure, ldap.LDAPError, OSError) as exc:
|
||||
refusal = f'the user directory failed: {exc}'
|
||||
if refusal:
|
||||
reason = refusal
|
||||
break
|
||||
if select.select([master], [], [], min(left, 1))[0]:
|
||||
try:
|
||||
output = os.read(master, 65536)
|
||||
except OSError:
|
||||
output = b''
|
||||
if not output:
|
||||
break
|
||||
send(BINARY, output)
|
||||
else:
|
||||
reason = 'the client closed the connection'
|
||||
except OSError:
|
||||
reason = 'the connection failed'
|
||||
with _shell_lock:
|
||||
_shell_sessions.pop(session, None)
|
||||
run('systemctl', 'stop', unit)
|
||||
try:
|
||||
send(CLOSE, (1000).to_bytes(2, 'big') + reason.encode()[:120])
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
handler.connection.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
reader.join()
|
||||
os.close(master)
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
log(f'shell of {name} from {client} in {unit} ended after {int(time.monotonic() - started)} seconds: {reason}')
|
||||
@@ -0,0 +1,153 @@
|
||||
"""/sites: the settings of the web sites Caddy serves, the sites, and the files of each (site_files)."""
|
||||
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import urllib.parse
|
||||
|
||||
import ldap
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_sites
|
||||
from vpnrouter_status import run
|
||||
|
||||
from .common import MIB, SITE_STATUS, section_change
|
||||
|
||||
#: A path of a site's files: the site and the path in its folder.
|
||||
SITE_FILES = re.compile(r'^/sites/([^/]+)/files(?:/(.*))?$')
|
||||
|
||||
|
||||
def sites(method, path, query, body):
|
||||
"""Answer a /sites request but one of a site's files with vpnrouter_sites: the HTTP status and the JSON document.
|
||||
|
||||
GET /sites {"settings", "sites"}: the sites section of the configuration and the sites, each with its
|
||||
settings but private_key, with private_key_set, modified and its folder
|
||||
PUT /sites change any of the settings of the sites section
|
||||
POST /sites add a site: {"name", and any of "enabled", "hosts", "port", "listen", "tls", "ca",
|
||||
"certificate", "private_key", "index_file", "spa", "editors", "dns",
|
||||
"dns_address"}; an existing name is 409
|
||||
GET /sites/<name> the site
|
||||
PUT /sites/<name> change any of the settings of POST but name; an empty or absent private_key keeps the key
|
||||
DELETE /sites/<name> remove the site, with content=true also its folder
|
||||
|
||||
A change is applied by restarting vpn-router-setup without waiting for it.
|
||||
"""
|
||||
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
body = body or {}
|
||||
parts = path.strip('/').split('/')
|
||||
route = (method, len(parts))
|
||||
try:
|
||||
cfg = vpnrouter.load_config()
|
||||
if route == ('GET', 1):
|
||||
return 200, {'settings': cfg['sites'], 'sites': vpnrouter_sites.listing()}
|
||||
if route == ('PUT', 1):
|
||||
changed = section_change('sites', body)
|
||||
return changed if isinstance(changed, tuple) else (200, {'settings': changed})
|
||||
if route == ('POST', 1):
|
||||
name = body.get('name')
|
||||
document = vpnrouter_sites.save(cfg, name, {key: value for key, value in body.items() if key != 'name'},
|
||||
True)
|
||||
code = 201
|
||||
elif route == ('GET', 2):
|
||||
with vpnrouter_sites.database() as connection:
|
||||
return 200, vpnrouter_sites.shown(vpnrouter_sites.get(connection, parts[1]))
|
||||
elif route == ('PUT', 2):
|
||||
document, code = vpnrouter_sites.save(cfg, parts[1], body, False), 200
|
||||
elif route == ('DELETE', 2):
|
||||
document = vpnrouter_sites.remove(parts[1], query.get('content', [''])[0] == 'true')
|
||||
code = 200
|
||||
else:
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
except vpnrouter_sites.Failure as failure:
|
||||
return SITE_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||
except vpnrouter_sites.DatabaseError as exc:
|
||||
return 500, {'error': str(exc)}
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
return 500, {'error': f'the site cannot be changed: {exc}'}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
|
||||
return code, document
|
||||
|
||||
|
||||
def site_file_action(name, target, body):
|
||||
"""Answer a POST to a site's file or folder: {"folder": true} creates the folder, {"rename": "<path>"} moves the file
|
||||
or folder to the path in the site's folder; the HTTP status and the JSON document."""
|
||||
if not isinstance(body, dict):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
try:
|
||||
if body.get('folder') is True:
|
||||
vpnrouter_sites.make_folder(target)
|
||||
return 201, {'path': vpnrouter_sites.relative_of(name, target), 'folder': True}
|
||||
if isinstance(body.get('rename'), str):
|
||||
moved = vpnrouter_sites.rename(name, target, body['rename'])
|
||||
return 200, {'path': vpnrouter_sites.relative_of(name, moved)}
|
||||
except vpnrouter_sites.Failure as failure:
|
||||
return SITE_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||
except OSError as exc:
|
||||
return 500, {'error': f'{exc.filename or target.name}: {exc.strerror}'}
|
||||
return 400, {'error': 'give {"folder": true} or {"rename": "<path>"}'}
|
||||
|
||||
|
||||
def site_files(handler, name, relative, query, payload, send_body):
|
||||
"""Answer a request of a site's files the Handler handler received: the HTTP status and the JSON document, or None
|
||||
once a file was sent.
|
||||
|
||||
GET /sites/<name>/files/<path> a folder: {"path", "entries": [{name, folder, size, modified}]}; a file:
|
||||
the file itself, as an attachment
|
||||
PUT /sites/<name>/files/<path> write the body to the file, its folders created; with unzip=true the body
|
||||
is a zip archive extracted into the folder of the path: {"path", "files"}
|
||||
POST /sites/<name>/files/<path> {"folder": true} creates the folder, {"rename": "<path>"} moves it
|
||||
DELETE /sites/<name>/files/<path> remove the file or folder
|
||||
|
||||
The body of a PUT needs a Content-Length of at most sites.upload_max_mb MiB.
|
||||
"""
|
||||
try:
|
||||
target = vpnrouter_sites.resolve(name, urllib.parse.unquote(relative))
|
||||
if handler.command in ('GET', 'HEAD'):
|
||||
if target.is_dir():
|
||||
return 200, vpnrouter_sites.entries(name, target)
|
||||
if not target.is_file():
|
||||
return 404, {'error': f'{relative} does not exist'}
|
||||
with open(target, 'rb') as handle:
|
||||
size = os.fstat(handle.fileno()).st_size
|
||||
try:
|
||||
handler.send_response(200)
|
||||
handler.send_header('Content-Type', mimetypes.guess_type(target.name)[0] or 'application/octet-stream')
|
||||
handler.send_header('Content-Length', str(size))
|
||||
handler.send_header('Content-Disposition',
|
||||
f"attachment; filename*=UTF-8''{urllib.parse.quote(target.name)}")
|
||||
handler.send_header('Cache-Control', 'no-store')
|
||||
handler.end_headers()
|
||||
if send_body:
|
||||
shutil.copyfileobj(handle, handler.wfile, vpnrouter_sites.CHUNK)
|
||||
except ConnectionError:
|
||||
# The client went away before the file was sent.
|
||||
handler.close_connection = True
|
||||
return None
|
||||
if handler.command == 'PUT':
|
||||
length = handler.headers.get('Content-Length')
|
||||
if not length or not length.isdigit():
|
||||
return 411, {'error': 'the body needs a Content-Length'}
|
||||
limit = vpnrouter.load_config()['sites']['upload_max_mb'] * MIB
|
||||
if query.get('unzip', [''])[0] == 'true':
|
||||
count = vpnrouter_sites.extract(handler.rfile, int(length), target, limit)
|
||||
return 200, {'path': vpnrouter_sites.relative_of(name, target), 'files': count}
|
||||
vpnrouter_sites.receive(handler.rfile, int(length), target, limit)
|
||||
return 200, {'path': vpnrouter_sites.relative_of(name, target), 'files': 1}
|
||||
if handler.command == 'POST':
|
||||
return site_file_action(name, target, payload)
|
||||
if handler.command == 'DELETE':
|
||||
if not target.exists() and not target.is_symlink():
|
||||
return 404, {'error': f'{relative} does not exist'}
|
||||
vpnrouter_sites.delete(name, target)
|
||||
return 200, {'path': vpnrouter_sites.relative_of(name, target), 'removed': True}
|
||||
except vpnrouter_sites.Failure as failure:
|
||||
return SITE_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||
except OSError as exc:
|
||||
return 500, {'error': f'{exc.filename or relative}: {exc.strerror}'}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
return 405, {'error': f'{handler.command} is not allowed on a site\'s files'}
|
||||
@@ -0,0 +1,325 @@
|
||||
"""The status of the router: / for people, /status, /status/full, /config and /postgresql, with the cache of the
|
||||
status that the requests of CACHE_SECONDS share (cached)."""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from html import escape
|
||||
|
||||
import vpnrouter
|
||||
from vpnrouter_status import established, established_each, openvpn_clients, run, swanctl, wireguard_peers
|
||||
|
||||
from .common import current, unit_state
|
||||
|
||||
#: Seconds a collected status is answered again.
|
||||
CACHE_SECONDS = 2
|
||||
|
||||
TAILWIND = 'https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4'
|
||||
|
||||
#: Seconds after which the status page in a browser reloads itself.
|
||||
REFRESH_SECONDS = 60
|
||||
|
||||
#: The PostgreSQL settings /config shows for each cluster.
|
||||
POSTGRESQL_SETTINGS = ('server_version', 'listen_addresses', 'port', 'max_connections', 'shared_buffers',
|
||||
'effective_cache_size', 'work_mem', 'password_encryption', 'ssl', 'timezone')
|
||||
|
||||
_cache = {'at': 0.0, 'data': None}
|
||||
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def package_version(package='vpn-router'):
|
||||
"""The installed version of the package, or None when it is not installed."""
|
||||
result = run('dpkg-query', '-W', '-f=${Version}', package)
|
||||
return (result.stdout.strip() or None) if result and result.returncode == 0 else None
|
||||
|
||||
|
||||
def docker_status():
|
||||
"""Whether Docker is installed, and the state of its service and of the Docker rules when it is."""
|
||||
result = run('systemctl', 'list-unit-files', '--no-legend', 'docker.service')
|
||||
if result is None or result.returncode != 0 or not result.stdout.strip():
|
||||
return {'installed': False}
|
||||
return {
|
||||
'installed': True,
|
||||
'service': unit_state('docker.service'),
|
||||
'rules': unit_state('vpn-router-docker.service'),
|
||||
}
|
||||
|
||||
|
||||
def postgresql_clusters():
|
||||
"""The state of each PostgreSQL cluster unit (postgresql@<version>-<name>), or {'postgresql': 'no cluster'} without one.
|
||||
|
||||
postgresql.service, which only runs /bin/true, is not reported.
|
||||
"""
|
||||
lines = lines_of('systemctl', 'list-units', 'postgresql@*.service', '--all', '--plain', '--no-legend') or []
|
||||
names = [line.split()[0].removesuffix('.service') for line in lines if line.strip()]
|
||||
return {name: unit_state(f'{name}.service') for name in names} or {'postgresql': 'no cluster'}
|
||||
|
||||
|
||||
def collect(state):
|
||||
"""The status document. state says which features the router has."""
|
||||
units = {'vpn-router-setup': unit_state('vpn-router-setup.service'), 'slapd': unit_state('slapd.service'),
|
||||
**postgresql_clusters()}
|
||||
manager_version = package_version('vpn-router-manager')
|
||||
if manager_version:
|
||||
units['vpn-router-manager'] = unit_state('vpn-router-manager.service')
|
||||
if state.get('site_to_site') or state.get('p2s'):
|
||||
units['strongswan'] = unit_state('strongswan.service')
|
||||
if state.get('wireguard'):
|
||||
units['wg-quick@wg0'] = unit_state('wg-quick@wg0.service')
|
||||
if state.get('openvpn'):
|
||||
units['openvpn-server@vpn-router'] = unit_state('openvpn-server@vpn-router.service')
|
||||
|
||||
status = {
|
||||
'status': 'ok' if all(value == 'active' for value in units.values()) else 'degraded',
|
||||
'version': package_version(),
|
||||
'manager_version': manager_version,
|
||||
'time': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
|
||||
'units': units,
|
||||
'docker': docker_status(),
|
||||
}
|
||||
names = state.get('site_to_site')
|
||||
if isinstance(names, list) and names:
|
||||
counts = established_each(names).values()
|
||||
status['site_to_site'] = {'connections': len(names),
|
||||
'established': None if None in counts else sum(1 for count in counts if count)}
|
||||
if state.get('p2s'):
|
||||
status['p2s'] = {'clients': established('road-warrior')}
|
||||
if state.get('wireguard'):
|
||||
status['wireguard'] = {'peers': wireguard_peers()}
|
||||
if state.get('openvpn'):
|
||||
status['openvpn'] = {'clients': openvpn_clients()}
|
||||
return status
|
||||
|
||||
|
||||
def shown(status, content, client):
|
||||
"""The status document as web.status_content shows it to the client at its address.
|
||||
|
||||
Every content has status, time and client_ip, the client's address, and content, which one it is: basic nothing
|
||||
more; standard adds version, manager_version and services, the number of active units and of units in every
|
||||
other state; full adds the whole status document of collect. Content that is not one of
|
||||
vpnrouter.STATUS_CONTENTS, or not set, is basic.
|
||||
"""
|
||||
if content not in vpnrouter.STATUS_CONTENTS:
|
||||
content = 'basic'
|
||||
if content == 'full':
|
||||
return {**status, 'content': content, 'client_ip': client}
|
||||
document = {'status': status['status'], 'content': content, 'time': status['time'], 'client_ip': client}
|
||||
if content == 'standard':
|
||||
active = sum(1 for value in status['units'].values() if value == 'active')
|
||||
document.update(version=status['version'], manager_version=status['manager_version'],
|
||||
services={'active': active, 'failed': len(status['units']) - active})
|
||||
return document
|
||||
|
||||
|
||||
def lines_of(*args):
|
||||
"""The output lines of a command, or None when it fails."""
|
||||
result = run(*args)
|
||||
return result.stdout.splitlines() if result and result.returncode == 0 else None
|
||||
|
||||
|
||||
def routes():
|
||||
"""The IPv4 routes as ip -j prints them, or None when ip fails."""
|
||||
result = run('ip', '-j', '-4', 'route', 'show')
|
||||
if result is None or result.returncode != 0:
|
||||
return None
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def wireguard_details():
|
||||
"""The interface and the peers of wg0, with endpoints and allowed IPs; no private or preshared key."""
|
||||
result = run('wg', 'show', 'wg0', 'dump')
|
||||
if result is None or result.returncode != 0:
|
||||
return None
|
||||
rows = [line.split('\t') for line in result.stdout.splitlines()]
|
||||
now = int(time.time())
|
||||
return {
|
||||
'public_key': rows[0][1] if rows and len(rows[0]) > 2 else None,
|
||||
'listen_port': int(rows[0][2]) if rows and len(rows[0]) > 2 else None,
|
||||
'peers': [{
|
||||
'public_key': row[0],
|
||||
'endpoint': None if row[2] == '(none)' else row[2],
|
||||
'allowed_ips': row[3].split(','),
|
||||
'handshake_seconds_ago': now - int(row[4]) if int(row[4]) else None,
|
||||
'received_bytes': int(row[5]),
|
||||
'sent_bytes': int(row[6]),
|
||||
} for row in rows[1:] if len(row) >= 7],
|
||||
}
|
||||
|
||||
|
||||
def postgresql_query(cluster, sql):
|
||||
"""The JSON value an SQL statement answers on a cluster (version/name), run by psql as postgres, or None when it fails."""
|
||||
result = run('runuser', '-u', 'postgres', '--', 'psql', '--cluster', cluster, '-X', '-q', '-t', '-A',
|
||||
'-v', 'ON_ERROR_STOP=1', '-d', 'postgres', '-c', sql)
|
||||
if result is None or result.returncode != 0:
|
||||
return None
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def postgresql_clusters_of():
|
||||
"""The PostgreSQL clusters of pg_lsclusters, each with version, name, port, status and data_directory, or None when it fails."""
|
||||
lines = lines_of('pg_lsclusters', '--no-header')
|
||||
if lines is None:
|
||||
return None
|
||||
clusters = []
|
||||
for line in lines:
|
||||
fields = line.split()
|
||||
if len(fields) >= 6:
|
||||
clusters.append({'version': fields[0], 'name': fields[1], 'port': int(fields[2]) if fields[2].isdigit() else None,
|
||||
'status': fields[3], 'data_directory': fields[5]})
|
||||
return clusters
|
||||
|
||||
|
||||
def postgresql_configuration():
|
||||
"""The PostgreSQL clusters on the router with the settings of POSTGRESQL_SETTINGS of each one that is online (None
|
||||
for one that is not), or None when pg_lsclusters fails."""
|
||||
clusters = postgresql_clusters_of()
|
||||
if clusters is None:
|
||||
return None
|
||||
names = ', '.join(f"'{name}'" for name in POSTGRESQL_SETTINGS)
|
||||
for cluster in clusters:
|
||||
cluster['settings'] = postgresql_query(
|
||||
f"{cluster['version']}/{cluster['name']}",
|
||||
f'SELECT json_object_agg(name, current_setting(name) ORDER BY name) FROM pg_settings WHERE name IN ({names})',
|
||||
) if cluster['status'].startswith('online') else None
|
||||
return {'clusters': clusters}
|
||||
|
||||
|
||||
def postgresql_databases():
|
||||
"""What /postgresql shows: the PostgreSQL clusters on the router, each with its databases (name, owner, encoding and
|
||||
size in bytes) and their total size, None for a cluster that is not online; clusters is None when pg_lsclusters fails."""
|
||||
clusters = postgresql_clusters_of()
|
||||
if clusters is None:
|
||||
return {'clusters': None}
|
||||
for cluster in clusters:
|
||||
databases = postgresql_query(
|
||||
f"{cluster['version']}/{cluster['name']}",
|
||||
"SELECT coalesce(json_agg(json_build_object('name', datname, 'owner', pg_get_userbyid(datdba), "
|
||||
"'encoding', pg_encoding_to_char(encoding), 'size', pg_database_size(oid)) ORDER BY datname), '[]') "
|
||||
'FROM pg_database',
|
||||
) if cluster['status'].startswith('online') else None
|
||||
cluster['databases'] = databases
|
||||
cluster['size'] = sum(database['size'] for database in databases) if isinstance(databases, list) else None
|
||||
return {'clusters': clusters}
|
||||
|
||||
|
||||
def details(state):
|
||||
"""What /config shows: the configuration without its secrets, and what the router resolved and runs; caddy is the
|
||||
installed Caddy, {version, source, service}, service the state of caddy.service, null when it is not installed; oidc
|
||||
is {enabled, issuer} of the OpenID Provider, issuer null while it is disabled."""
|
||||
configuration = state.get('configuration', {})
|
||||
caddy = package_version('caddy')
|
||||
oidc = configuration.get('oidc', {}).get('enabled') is True
|
||||
return {
|
||||
'configuration': configuration,
|
||||
'network': dict(state.get('network', {}), routes=routes()),
|
||||
'ipsec': {
|
||||
'connections': swanctl('--list-conns'),
|
||||
'sas': swanctl('--list-sas'),
|
||||
},
|
||||
'wireguard': wireguard_details(),
|
||||
'docker': {
|
||||
'exposed_ports': configuration.get('docker', {}).get('exposed_ports', []),
|
||||
'rules': lines_of('iptables', '-S', 'VPN-ROUTER-DOCKER'),
|
||||
},
|
||||
'postgresql': postgresql_configuration(),
|
||||
# Caddy is upstream's once its repository is configured (vpn-router-caddy).
|
||||
'caddy': {'version': caddy, 'source': 'upstream' if vpnrouter.CADDY_APT_SOURCE.is_file() else 'ubuntu',
|
||||
'service': unit_state('caddy.service')} if caddy else None,
|
||||
'oidc': {'enabled': oidc, 'issuer': vpnrouter.oidc_issuer(configuration) if oidc else None},
|
||||
}
|
||||
|
||||
|
||||
def text_of(status):
|
||||
"""The status document of shown for people, as plain text: a header, a blank line, then name: value lines."""
|
||||
lines = ['VPN Router', f'Status: {status["status"]}']
|
||||
if 'version' in status:
|
||||
lines.append(f'Version: {status["version"] or "unknown"}')
|
||||
lines += ['', f'Client IP: {status["client_ip"]}']
|
||||
if 'manager_version' in status:
|
||||
lines.append(f'Router Manager: {status["manager_version"] or "not installed"}')
|
||||
if 'services' in status:
|
||||
lines.append(f'Services: {status["services"]["active"]} active, {status["services"]["failed"]} failed')
|
||||
lines += [f'{name}: {value}' for name, value in status.get('units', {}).items()]
|
||||
if 'docker' in status:
|
||||
docker = status['docker']
|
||||
lines.append('Docker: not installed' if not docker['installed']
|
||||
else f'Docker: installed, service {docker["service"]}, rules {docker["rules"]}')
|
||||
if 'site_to_site' in status:
|
||||
established, connections = status['site_to_site']['established'], status['site_to_site']['connections']
|
||||
lines.append(f'Site to site: {"unknown" if established is None else established} of {connections} established')
|
||||
if 'p2s' in status:
|
||||
clients = status['p2s']['clients']
|
||||
lines.append(f'IKEv2: {"unknown" if clients is None else f"{clients} connected"}')
|
||||
if 'openvpn' in status:
|
||||
clients = status['openvpn']['clients']
|
||||
lines.append(f'OpenVPN: {"unknown" if clients is None else f"{clients} connected"}')
|
||||
if 'wireguard' in status:
|
||||
peers = status['wireguard']['peers']
|
||||
if peers is None:
|
||||
lines.append('WireGuard: down')
|
||||
else:
|
||||
lines.append(f'WireGuard: {len(peers)} peers')
|
||||
for number, peer in enumerate(peers, 1):
|
||||
age = peer['handshake_seconds_ago']
|
||||
lines.append(f' peer {number}: {"no handshake yet" if age is None else f"handshake {age} s ago"}, '
|
||||
f'received {peer["received_bytes"]} B, sent {peer["sent_bytes"]} B')
|
||||
return '\n'.join(lines) + '\n'
|
||||
|
||||
|
||||
def html_of(status):
|
||||
"""The status document of shown for a browser, as a page styled with Tailwind CSS."""
|
||||
colour = 'bg-emerald-600 dark:bg-emerald-700' if status['status'] == 'ok' else 'bg-amber-600 dark:bg-amber-700'
|
||||
version = f', version {status["version"] or "unknown"}' if 'version' in status else ''
|
||||
# Each "name: value" line after the header of the text is a row; indented lines, such as WireGuard peers, are indented here too.
|
||||
rows = []
|
||||
for line in text_of(status).split('\n\n', 1)[1].splitlines():
|
||||
if not line:
|
||||
continue
|
||||
name, _, value = line.partition(': ')
|
||||
indent = ' pl-8' if line.startswith(' ') else ''
|
||||
rows.append(f'<tr class="border-t border-slate-200 first:border-t-0 dark:border-slate-700">'
|
||||
f'<th class="py-2 pr-4{indent} text-left font-medium text-slate-600 dark:text-slate-300">{escape(name.strip())}</th>'
|
||||
f'<td class="py-2">{escape(value)}</td></tr>')
|
||||
body = ''.join(rows)
|
||||
return f"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta http-equiv="refresh" content="{REFRESH_SECONDS}">
|
||||
<title>VPN Router</title>
|
||||
<script src="{TAILWIND}"></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-100 text-slate-900 dark:bg-slate-900 dark:text-slate-100">
|
||||
<div class="mx-auto max-w-2xl px-4 py-10">
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-800">
|
||||
<div class="{colour} px-6 py-4 text-white">
|
||||
<h1 class="text-xl font-semibold">VPN Router</h1>
|
||||
<p class="mt-1 text-sm">Status: {escape(status['status'] + version)}</p>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<table class="w-full text-sm"><tbody>{body}</tbody></table>
|
||||
</div>
|
||||
<p class="px-6 pb-6 text-xs text-slate-500 dark:text-slate-400">{escape(status['time'])} - <a class="underline" href="/status">JSON</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def cached():
|
||||
"""The status of collect, collected again when it is older than CACHE_SECONDS."""
|
||||
with _lock:
|
||||
if _cache['data'] is None or time.monotonic() - _cache['at'] > CACHE_SECONDS:
|
||||
_cache['data'] = collect(current())
|
||||
_cache['at'] = time.monotonic()
|
||||
return _cache['data']
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/bin/sh
|
||||
# Wait until libpq is in the dynamic linker cache, where psycopg looks it up.
|
||||
# ExecStartPre= of vpn-router-web.service; TimeoutStartSec= of the unit bounds the wait.
|
||||
# ExecStartPre= of vpn-router-setup.service, vpn-router-web.service and vpn-router-metrics.service;
|
||||
# TimeoutStartSec= of the unit bounds the wait.
|
||||
|
||||
reported=
|
||||
until ldconfig -p | grep -q '^[[:space:]]*libpq\.so\.5 '; do
|
||||
|
||||
Regular → Executable
+19
-931
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
+121
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/python3
|
||||
"""Manage the roles of the resources of the router's OpenID Provider from the command line, with vpnrouter_users.
|
||||
|
||||
A resource is a service account; a role of it has a name, an optional description, and the groups and accounts it is
|
||||
assigned to, whose tokens for the resource carry it in their roles claim. 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 resource, the role, the
|
||||
group or the account does not exist, 4 the role exists, 1 anything else. -h or --help shows the commands and their
|
||||
options.
|
||||
|
||||
vpn-router-role [--json] list [<resource>]
|
||||
vpn-router-role [--json] add <resource> <role> [--description D]
|
||||
vpn-router-role [--json] remove <resource> <role>
|
||||
vpn-router-role [--json] member-add <resource> <role> (--group G | --account A)
|
||||
vpn-router-role [--json] member-remove <resource> <role> (--group G | --account A)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '/usr/lib/vpn-router')
|
||||
|
||||
import ldap
|
||||
|
||||
import vpnrouter_users
|
||||
from vpnrouter_users import Failure
|
||||
|
||||
#: The help line of each command.
|
||||
HELP = {
|
||||
'list': 'list the roles of every resource, or of one, with the groups and accounts they are assigned to',
|
||||
'add': 'add a role to a resource',
|
||||
'remove': 'remove a role of a resource',
|
||||
'member-add': 'assign the role to a group or an account',
|
||||
'member-remove': 'take the role from a group or an account',
|
||||
}
|
||||
|
||||
|
||||
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-role', description='Manage the roles of the resources of the OpenID Provider.')
|
||||
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('resource', nargs='?', help='the service account whose roles are listed')
|
||||
continue
|
||||
sub.add_argument('resource', help='the service account the role belongs to')
|
||||
sub.add_argument('role', help='the role name')
|
||||
if command == 'add':
|
||||
sub.add_argument('--description', metavar='TEXT', help='the description')
|
||||
if command in ('member-add', 'member-remove'):
|
||||
member = sub.add_mutually_exclusive_group(required=True)
|
||||
member.add_argument('--group', metavar='GROUP', help='the group')
|
||||
member.add_argument('--account', metavar='USER', help='the user or service account')
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def run(connection, options):
|
||||
"""The answer of the command the options name."""
|
||||
command = options.command
|
||||
if command == 'list':
|
||||
if options.resource is not None:
|
||||
vpnrouter_users.check_resource(connection, options.resource)
|
||||
return vpnrouter_users.list_roles(connection, options.resource)
|
||||
if command == 'add':
|
||||
role = vpnrouter_users.role_add(connection, options.resource, options.role, options.description)
|
||||
elif command == 'remove':
|
||||
return vpnrouter_users.role_remove(connection, options.resource, options.role)
|
||||
elif command == 'member-add':
|
||||
role = vpnrouter_users.role_member_add(connection, options.resource, options.role, options.group, options.account)
|
||||
else:
|
||||
role = vpnrouter_users.role_member_remove(connection, options.resource, options.role, options.group, options.account)
|
||||
return {'resource': options.resource, **role}
|
||||
|
||||
|
||||
def text_of(answer):
|
||||
"""The answer as text for people."""
|
||||
if answer.get('removed'):
|
||||
return f'role {answer["name"]} of {answer["resource"]} removed'
|
||||
roles = answer['roles'] if 'roles' in answer else [answer]
|
||||
if not roles:
|
||||
return 'no roles'
|
||||
head = ('RESOURCE', 'ROLE', 'GROUPS', 'ACCOUNTS', 'DESCRIPTION')
|
||||
rows = [(role['resource'], role['name'], ', '.join(role['groups']) or '-', ', '.join(role['accounts']) or '-',
|
||||
role['description'] or '-') for role in roles]
|
||||
widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))]
|
||||
return '\n'.join(' '.join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip() for row in (head, *rows))
|
||||
|
||||
|
||||
def main(argv):
|
||||
# A request that cannot be parsed is answered in JSON when --json is among the arguments.
|
||||
as_json = '--json' in argv
|
||||
try:
|
||||
options = parse(argv)
|
||||
as_json = options.json
|
||||
with vpnrouter_users.session() as connection:
|
||||
answer = run(connection, options)
|
||||
except Failure as failure:
|
||||
message, status = str(failure), failure.status
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
message, status = f'the user directory failed: {exc}', 1
|
||||
else:
|
||||
print(json.dumps(answer) if as_json else text_of(answer))
|
||||
return 0
|
||||
if as_json:
|
||||
print(json.dumps({'error': message}))
|
||||
else:
|
||||
print(f'vpn-router-role: {message}', file=sys.stderr)
|
||||
return status
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
Executable
+165
@@ -0,0 +1,165 @@
|
||||
#!/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), the group whose members change its files, and
|
||||
who serves the records of its host names (--dns user, elsewhere, or router, the router's name server, with the router's
|
||||
address of --dns-address).
|
||||
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] [--dns user|router]
|
||||
[--dns-address public|internal|external]
|
||||
"""
|
||||
|
||||
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")
|
||||
sub.add_argument('--dns', choices=vpnrouter_sites.DNS, help='who serves the records of the host names')
|
||||
sub.add_argument('--dns-address', choices=vpnrouter_sites.DNS_ADDRESSES,
|
||||
help="the router's address in the records of dns router")
|
||||
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'),
|
||||
('dns', 'dns'), ('dns_address', 'dns_address'))
|
||||
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', 'DNS')
|
||||
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 '-',
|
||||
site['dns'] + (f' {site["dns_address"]}' if site['dns'] == 'router' else ''))
|
||||
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,19 @@ 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 ...]
|
||||
[--grant G ...] [--public-client yes|no] [--client-scope R:S ...] [--resource-scope S ...]
|
||||
(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 ...]
|
||||
[--grant G ...] [--public-client yes|no] [--client-scope R:S ...] [--resource-scope S ...]
|
||||
(a service account)
|
||||
|
||||
A service account with a redirect URI or a grant (client_credentials, device_code) is a client of the OpenID Provider,
|
||||
and one with a resource scope a resource; each of the repeatable options replaces all values of its kind, and one ''
|
||||
removes them. vpn-router-role manages the roles of a resource.
|
||||
vpn-router-user [--json] passwd <name> (password on stdin)
|
||||
vpn-router-user [--json] disable <name>
|
||||
vpn-router-user [--json] enable <name>
|
||||
@@ -22,6 +33,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 +47,7 @@ import ldap
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_auth
|
||||
import vpnrouter_passkeys
|
||||
import vpnrouter_users
|
||||
from vpnrouter_users import Failure
|
||||
|
||||
@@ -54,10 +68,25 @@ 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'),
|
||||
('grants', 'grant', 'a grant the client may use besides the authorization code: client_credentials or device_code'),
|
||||
('client_scopes', 'client-scope', 'a scope of a resource the client may ask for, <resource>:<scope>'),
|
||||
('resource_scopes', 'resource-scope', 'a scope the service account defines as a resource'),
|
||||
)
|
||||
|
||||
|
||||
class Parser(argparse.ArgumentParser):
|
||||
@@ -76,12 +105,22 @@ 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")
|
||||
sub.add_argument('--public-client', choices=('yes', 'no'),
|
||||
help='whether the client of a service account is public, without a secret')
|
||||
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 +134,13 @@ 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 getattr(options, 'public_client', None) is not None:
|
||||
values['public_client'] = options.public_client == 'yes'
|
||||
if command == 'add':
|
||||
return vpnrouter_users.add(connection, options.name, secret, values, service=options.service)
|
||||
if command == 'update':
|
||||
@@ -104,9 +149,40 @@ 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 settings of a service account as a cell: -, or the numbers of its URIs, its allowed groups,
|
||||
grants, public, its client scopes and its resource scopes."""
|
||||
parts = []
|
||||
if user.get('redirect_uris'):
|
||||
groups = ','.join(user['allowed_groups']) or 'all users'
|
||||
parts.append(f'{len(user["redirect_uris"])} redirect, {len(user["post_logout_redirect_uris"])} logout, {groups}')
|
||||
parts += user.get('grants') or []
|
||||
if user.get('public_client'):
|
||||
parts.append('public')
|
||||
if user.get('client_scopes'):
|
||||
parts.append('asks ' + ','.join(user['client_scopes']))
|
||||
if user.get('resource_scopes'):
|
||||
parts.append('defines ' + ','.join(user['resource_scopes']))
|
||||
return '; '.join(parts) or '-'
|
||||
|
||||
|
||||
def text_of(answer):
|
||||
"""The answer as text for people."""
|
||||
if 'profile' in answer:
|
||||
@@ -118,11 +194,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,94 @@
|
||||
# bash completion for vpn-router-role -*- shell-script -*-
|
||||
|
||||
# The service accounts, the users, the groups and the roles of a resource of the router's directory, read as root;
|
||||
# nothing for other users.
|
||||
_vpn_router_role_resources()
|
||||
{
|
||||
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"] if user["service"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_role_accounts()
|
||||
{
|
||||
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_role_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_role_names()
|
||||
{
|
||||
vpn-router-role --json list "$1" 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(role["name"] for role in json.load(sys.stdin)["roles"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_role()
|
||||
{
|
||||
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 resource=''
|
||||
for ((i = 1; i < cword; i++)); do
|
||||
if [[ -z $command ]]; then
|
||||
[[ ${words[i]} != -* ]] && command=${words[i]}
|
||||
continue
|
||||
fi
|
||||
case ${words[i]} in
|
||||
--description | --group | --account) ((i++)) ;;
|
||||
-*) ;;
|
||||
*)
|
||||
((args == 0)) && resource=${words[i]}
|
||||
((args++))
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z $command ]]; then
|
||||
if [[ $cur == -* ]]; then
|
||||
COMPREPLY=($(compgen -W '--json --help' -- "$cur"))
|
||||
else
|
||||
COMPREPLY=($(compgen -W 'list add remove member-add member-remove' -- "$cur"))
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
case $prev in
|
||||
--group)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_role_groups)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--account)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_role_accounts)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--description)
|
||||
return
|
||||
;;
|
||||
esac
|
||||
$split && return
|
||||
|
||||
if [[ $cur == -* ]]; then
|
||||
local options='--help'
|
||||
case $command in
|
||||
add) options+=' --description' ;;
|
||||
member-add | member-remove) options+=' --group --account' ;;
|
||||
esac
|
||||
COMPREPLY=($(compgen -W "$options" -- "$cur"))
|
||||
return
|
||||
fi
|
||||
|
||||
# The resource, then the role, which add takes new.
|
||||
if ((args == 0)); then
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_role_resources)" -- "$cur"))
|
||||
elif ((args == 1)) && [[ $command != @(list|add) ]]; then
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_role_names "$resource")" -- "$cur"))
|
||||
fi
|
||||
} &&
|
||||
complete -F _vpn_router_role vpn-router-role
|
||||
|
||||
# ex: filetype=sh
|
||||
@@ -0,0 +1,103 @@
|
||||
# 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
|
||||
;;
|
||||
--dns)
|
||||
COMPREPLY=($(compgen -W 'user router' -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--dns-address)
|
||||
COMPREPLY=($(compgen -W 'public internal external' -- "$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 --dns --dns-address' ;;
|
||||
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,108 @@
|
||||
# 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
|
||||
}
|
||||
|
||||
# The scopes of the resources, <resource>:<scope>, read as root.
|
||||
_vpn_router_user_scopes()
|
||||
{
|
||||
vpn-router-user --json list 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(user["name"] + ":" + scope for user in json.load(sys.stdin)["users"]
|
||||
for scope in user.get("resource_scopes", [])))' 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 | --grant | --public-client | --client-scope | \
|
||||
--resource-scope) ((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
|
||||
;;
|
||||
--grant)
|
||||
COMPREPLY=($(compgen -W 'client_credentials device_code' -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--public-client)
|
||||
COMPREPLY=($(compgen -W 'yes no' -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--client-scope)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_user_scopes)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--resource-scope)
|
||||
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 --grant --public-client --client-scope --resource-scope' ;;
|
||||
update) options+=' --first-name --last-name --email --display-name --description --redirect-uri
|
||||
--post-logout-redirect-uri --allowed-group --grant --public-client --client-scope --resource-scope' ;;
|
||||
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
|
||||
@@ -8,7 +8,8 @@
|
||||
"internal": "eth1"
|
||||
},
|
||||
"wan": {
|
||||
"local_fqdn": "router.example.com"
|
||||
"local_fqdn": "router.example.com",
|
||||
"public_address": ""
|
||||
},
|
||||
"local": {
|
||||
"cidrs": ["10.0.0.0/24"],
|
||||
@@ -22,14 +23,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 +63,63 @@
|
||||
"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,
|
||||
"secondary": "",
|
||||
"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 +135,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,132 @@
|
||||
-- The domains the router's name server (PowerDNS, gpgsql backend) is the primary of (vpnrouter_dns), in the
|
||||
-- vpn-router-dns database, whose domains, records and domainmetadata 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'));
|
||||
|
||||
DROP TABLE IF EXISTS dns_manual_records;
|
||||
|
||||
-- The secondaries of each domain and the domain's switch of site records: the secondary is dns.secondary, the same
|
||||
-- for every domain, and each site says whether the router serves its records.
|
||||
DROP PROCEDURE IF EXISTS dns_secondary_put(text, text, inet);
|
||||
DROP PROCEDURE IF EXISTS dns_secondary_remove(text, text);
|
||||
DROP FUNCTION IF EXISTS dns_secondaries_list();
|
||||
DROP TABLE IF EXISTS dns_secondaries;
|
||||
DROP FUNCTION IF EXISTS dns_domains_list();
|
||||
DROP PROCEDURE IF EXISTS dns_domain_put(text, boolean, integer);
|
||||
|
||||
-- A domain the router serves as its primary name server; ttl is the TTL of its records, null for dns.ttl.
|
||||
CREATE TABLE IF NOT EXISTS dns_domains (
|
||||
name text PRIMARY KEY,
|
||||
ttl integer
|
||||
);
|
||||
ALTER TABLE dns_domains DROP COLUMN IF EXISTS sites;
|
||||
|
||||
-- A record an administrator added to a domain, besides those of the domain itself and of the sites.
|
||||
CREATE TABLE IF NOT EXISTS dns_records (
|
||||
domain text NOT NULL REFERENCES dns_domains (name) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
type text NOT NULL,
|
||||
content text NOT NULL,
|
||||
ttl integer NOT NULL,
|
||||
PRIMARY KEY (domain, name, type, content)
|
||||
);
|
||||
|
||||
-- The digest of the records last written to a 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_domains_list()
|
||||
RETURNS TABLE (name text, ttl integer)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT name, ttl FROM dns_domains ORDER BY name;
|
||||
$$;
|
||||
|
||||
-- Add the domain, or set its TTL.
|
||||
CREATE OR REPLACE PROCEDURE dns_domain_put(p_name text, p_ttl integer)
|
||||
LANGUAGE sql AS $$
|
||||
INSERT INTO dns_domains (name, ttl) VALUES (p_name, p_ttl)
|
||||
ON CONFLICT (name) DO UPDATE SET ttl = p_ttl;
|
||||
$$;
|
||||
|
||||
-- Remove the domain with its records.
|
||||
CREATE OR REPLACE PROCEDURE dns_domain_remove(p_name text)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM dns_domains WHERE name = p_name;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION dns_records_list()
|
||||
RETURNS TABLE (domain text, name text, type text, content text, ttl integer)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT domain, name, type, content, ttl FROM dns_records ORDER BY domain, name, type, content;
|
||||
$$;
|
||||
|
||||
-- Add the record to the domain, or set its TTL.
|
||||
CREATE OR REPLACE PROCEDURE dns_record_put(p_domain text, p_name text, p_type text, p_content text, p_ttl integer)
|
||||
LANGUAGE sql AS $$
|
||||
INSERT INTO dns_records VALUES (p_domain, p_name, p_type, p_content, p_ttl)
|
||||
ON CONFLICT (domain, name, type, content) DO UPDATE SET ttl = p_ttl;
|
||||
$$;
|
||||
|
||||
-- Remove the records of the name and type in the domain, all of them without content.
|
||||
CREATE OR REPLACE PROCEDURE dns_record_remove(p_domain text, p_name text, p_type text, p_content text)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM dns_records
|
||||
WHERE domain = p_domain AND name = p_name AND type = p_type AND (p_content IS NULL OR content = p_content);
|
||||
$$;
|
||||
|
||||
-- Serve the zone as its primary and give it the records, its SOA first, whose content has {serial} for the serial,
|
||||
-- and the addresses of its secondary, which are notified of its changes and may transfer it. 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[], p_secondaries text[])
|
||||
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
|
||||
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, 'MASTER') RETURNING id INTO v_domain;
|
||||
ELSE
|
||||
UPDATE domains SET type = 'MASTER' WHERE id = v_domain AND type <> 'MASTER';
|
||||
END IF;
|
||||
DELETE FROM domainmetadata WHERE domain_id = v_domain AND kind IN ('ALSO-NOTIFY', 'ALLOW-AXFR-FROM');
|
||||
INSERT INTO domainmetadata (domain_id, kind, content)
|
||||
SELECT v_domain, kind, address FROM unnest(p_secondaries) AS address,
|
||||
unnest(ARRAY['ALSO-NOTIFY', 'ALLOW-AXFR-FROM']) AS kind;
|
||||
IF 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 only the zones of the list, none for an empty one.
|
||||
CREATE OR REPLACE PROCEDURE dns_retain(p_zones text[])
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM domains WHERE name <> ALL (p_zones);
|
||||
DELETE FROM dns_state WHERE zone <> ALL (p_zones);
|
||||
$$;
|
||||
|
||||
-- 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,202 @@
|
||||
-- The state of the router's OpenID Provider (vpnrouter_oidc) in the database of web.database.
|
||||
-- Codes, tokens, device codes 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; resource is the resource the tokens are
|
||||
-- for, null for the client itself.
|
||||
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,
|
||||
resource text
|
||||
);
|
||||
ALTER TABLE oidc_codes ADD COLUMN IF NOT EXISTS resource text;
|
||||
|
||||
-- An access token or a refresh token (kind) a client was issued for a user, or for itself as a service account with
|
||||
-- client credentials; resource as in oidc_codes.
|
||||
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,
|
||||
resource text
|
||||
);
|
||||
ALTER TABLE oidc_tokens ADD COLUMN IF NOT EXISTS resource text;
|
||||
CREATE INDEX IF NOT EXISTS oidc_tokens_name ON oidc_tokens (name);
|
||||
|
||||
-- A device code of the device authorization grant and its user code (user_hash): pending until the user allows or
|
||||
-- denies it, then the user, when and how they signed in; poll_seconds is the least time between two polls, polled
|
||||
-- the last poll.
|
||||
CREATE TABLE IF NOT EXISTS oidc_devices (
|
||||
hash text PRIMARY KEY,
|
||||
user_hash text NOT NULL UNIQUE,
|
||||
client_id text NOT NULL,
|
||||
scopes text[] NOT NULL,
|
||||
resource text,
|
||||
state text NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'allowed', 'denied')),
|
||||
name text,
|
||||
auth_time timestamptz,
|
||||
amr text[],
|
||||
poll_seconds integer NOT NULL,
|
||||
polled timestamptz,
|
||||
expires timestamptz NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS oidc_devices_name ON oidc_devices (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
|
||||
);
|
||||
|
||||
-- The functions and procedures whose parameters or results differ from those of the same name.
|
||||
DROP PROCEDURE IF EXISTS oidc_code_save(text, text, text, text, text[], text, text, text, timestamptz, text[], integer);
|
||||
DROP FUNCTION IF EXISTS oidc_code_get(text);
|
||||
DROP PROCEDURE IF EXISTS oidc_token_save(text, text, text, text, text[], timestamptz, text[], integer);
|
||||
DROP FUNCTION IF EXISTS oidc_token_get(text, text);
|
||||
|
||||
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_resource text, p_seconds integer)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_codes WHERE expires < now();
|
||||
INSERT INTO oidc_codes (hash, client_id, name, redirect_uri, scopes, nonce, code_challenge, code_challenge_method,
|
||||
auth_time, amr, resource, expires)
|
||||
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, p_resource, 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[], resource text)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT client_id, name, redirect_uri, scopes, nonce, code_challenge, code_challenge_method, auth_time, amr, resource
|
||||
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_resource text, p_seconds integer)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_tokens WHERE expires < now();
|
||||
INSERT INTO oidc_tokens (hash, kind, client_id, name, scopes, auth_time, amr, resource, expires)
|
||||
VALUES (p_hash, p_kind, p_client_id, p_name, p_scopes, p_auth_time, p_amr, p_resource,
|
||||
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[], resource text)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT client_id, name, scopes, auth_time, amr, resource 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_device_save(p_hash text, p_user_hash text, p_client_id text, p_scopes text[],
|
||||
p_resource text, p_poll_seconds integer, p_seconds integer)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_devices WHERE expires < now();
|
||||
INSERT INTO oidc_devices (hash, user_hash, client_id, scopes, resource, poll_seconds, expires)
|
||||
VALUES (p_hash, p_user_hash, p_client_id, p_scopes, p_resource, p_poll_seconds,
|
||||
now() + make_interval(secs => p_seconds));
|
||||
$$;
|
||||
|
||||
-- The unexpired device code of the user code while it waits for the user.
|
||||
CREATE OR REPLACE FUNCTION oidc_device_find(p_user_hash text)
|
||||
RETURNS TABLE (client_id text, scopes text[], resource text)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT client_id, scopes, resource FROM oidc_devices
|
||||
WHERE user_hash = p_user_hash AND state = 'pending' AND expires > now();
|
||||
$$;
|
||||
|
||||
-- Allow or deny the waiting device code of the user code as the user; whether there was one.
|
||||
CREATE OR REPLACE FUNCTION oidc_device_decide(p_user_hash text, p_name text, p_auth_time timestamptz, p_amr text[],
|
||||
p_allowed boolean)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql AS $$
|
||||
WITH decided AS (
|
||||
UPDATE oidc_devices SET state = CASE WHEN p_allowed THEN 'allowed' ELSE 'denied' END, name = p_name,
|
||||
auth_time = p_auth_time, amr = p_amr
|
||||
WHERE user_hash = p_user_hash AND state = 'pending' AND expires > now()
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT count(*) > 0 FROM decided;
|
||||
$$;
|
||||
|
||||
-- A poll of the device code: the row, whether it has expired, and whether it came sooner than poll_seconds after the
|
||||
-- last one, which adds 5 seconds to poll_seconds.
|
||||
CREATE OR REPLACE FUNCTION oidc_device_poll(p_hash text)
|
||||
RETURNS TABLE (client_id text, scopes text[], resource text, state text, name text, auth_time timestamptz, amr text[],
|
||||
expired boolean, too_soon boolean)
|
||||
LANGUAGE sql AS $$
|
||||
UPDATE oidc_devices AS device
|
||||
SET polled = now(),
|
||||
poll_seconds = device.poll_seconds +
|
||||
CASE WHEN device.polled + make_interval(secs => device.poll_seconds) > now() THEN 5 ELSE 0 END
|
||||
FROM (SELECT hash, polled, poll_seconds FROM oidc_devices WHERE hash = p_hash) AS previous
|
||||
WHERE device.hash = previous.hash
|
||||
RETURNING device.client_id, device.scopes, device.resource, device.state, device.name, device.auth_time, device.amr,
|
||||
device.expires <= now(),
|
||||
previous.polled IS NOT NULL AND previous.polled + make_interval(secs => previous.poll_seconds) > now();
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE oidc_device_delete(p_hash text)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_devices 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, device code and session of the user or service account.
|
||||
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_devices WHERE name = p_name;
|
||||
DELETE FROM oidc_sessions WHERE name = p_name;
|
||||
$$;
|
||||
@@ -0,0 +1,103 @@
|
||||
-- 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, the group whose members change its files, and its
|
||||
-- DNS: user, the router serves no records of its host names, or router, its name server serves A or AAAA records of
|
||||
-- them with the router's public, internal or external address (dns_address).
|
||||
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
|
||||
);
|
||||
ALTER TABLE sites ADD COLUMN IF NOT EXISTS dns text NOT NULL DEFAULT 'user' CHECK (dns IN ('user', 'router'));
|
||||
ALTER TABLE sites ADD COLUMN IF NOT EXISTS dns_address text NOT NULL DEFAULT 'public'
|
||||
CHECK (dns_address IN ('public', 'internal', 'external'));
|
||||
|
||||
-- The functions whose columns and the procedure whose arguments changed with dns and dns_address.
|
||||
DROP FUNCTION IF EXISTS sites_list();
|
||||
DROP FUNCTION IF EXISTS site_get(text);
|
||||
DROP FUNCTION IF EXISTS sites_served();
|
||||
DROP PROCEDURE IF EXISTS site_put(text, boolean, text[], integer, text, text, text, text, text, text, boolean, text);
|
||||
|
||||
-- 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, dns text, dns_address text,
|
||||
modified timestamptz)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, index_file, spa, editors, dns, dns_address, 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, dns text,
|
||||
dns_address text, modified timestamptz)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, private_key, index_file, spa, editors, dns,
|
||||
dns_address, 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, dns text,
|
||||
dns_address text, modified timestamptz)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, private_key, index_file, spa, editors, dns,
|
||||
dns_address, 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, p_dns text, p_dns_address text)
|
||||
LANGUAGE sql AS $$
|
||||
INSERT INTO sites (name, enabled, hosts, port, listen, tls, ca, certificate, private_key, index_file, spa, editors,
|
||||
dns, dns_address, modified)
|
||||
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, p_dns, p_dns_address, 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, dns = p_dns, dns_address = p_dns_address, 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,110 @@
|
||||
# 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 https_acme_ca %}
|
||||
acme_ca {{ https_acme_ca }}
|
||||
{%- 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,108 @@
|
||||
<!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; }
|
||||
ul { margin: 0 0 1rem; padding-left: 1.25rem; color: #4b5563; }
|
||||
#user_code { text-transform: uppercase; letter-spacing: 0.15em; font-family: ui-monospace, monospace; }
|
||||
.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 items %}
|
||||
<ul>
|
||||
{%- for item in items %}
|
||||
<li>{{ item }}</li>
|
||||
{%- endfor %}
|
||||
</ul>
|
||||
{%- endif %}
|
||||
{%- if decide %}
|
||||
<form method="post" action="/oidc/device/decide">
|
||||
<input type="hidden" name="user_code" value="{{ user_code }}">
|
||||
<button type="submit" name="decision" value="allow">Allow</button>
|
||||
<button class="secondary" type="submit" name="decision" value="deny">Deny</button>
|
||||
</form>
|
||||
{%- endif %}
|
||||
{%- if form or device %}
|
||||
<form id="sign-in" method="post" action="{{ action }}">
|
||||
{%- if device %}
|
||||
<label for="user_code">Code shown on the device</label>
|
||||
<input id="user_code" name="user_code" value="{{ user_code }}" autocapitalize="characters" autocomplete="off" spellcheck="false" required{% if not form %} autofocus{% endif %}>
|
||||
{%- else %}
|
||||
<input type="hidden" name="request" value="{{ request }}">
|
||||
{%- endif %}
|
||||
{%- if form %}
|
||||
<label for="name">Name</label>
|
||||
<input id="name" name="name" value="{{ name }}" autocomplete="username webauthn" autocapitalize="none" spellcheck="false" required{% if not device %} autofocus{% endif %}>
|
||||
<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>
|
||||
{%- else %}
|
||||
<button type="submit">Continue</button>
|
||||
{%- endif %}
|
||||
</form>
|
||||
{%- endif %}
|
||||
{%- if form %}
|
||||
<form id="passkey-form" method="post" action="{{ action }}" hidden>
|
||||
<input type="hidden" name="request" value="{{ request }}">
|
||||
<input type="hidden" name="user_code">
|
||||
<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 the form's action with the authorization
|
||||
// request, or with the user code the sign-in form holds.
|
||||
(function () {
|
||||
var button = document.getElementById('passkey');
|
||||
if (!window.PublicKeyCredential || !PublicKeyCredential.parseRequestOptionsFromJSON) {
|
||||
return;
|
||||
}
|
||||
button.hidden = false;
|
||||
button.addEventListener('click', async function () {
|
||||
var code = document.getElementById('user_code');
|
||||
if (code && !code.reportValidity()) {
|
||||
return;
|
||||
}
|
||||
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.user_code.value = code ? code.value : '';
|
||||
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
|
||||
}
|
||||
|
||||
@@ -39,5 +39,9 @@ pools {
|
||||
rw-pool {
|
||||
addrs = {{ p2s_address_pool }}
|
||||
dns = {{ p2s_dns }}
|
||||
{%- if p2s_dns_domains %}
|
||||
# INTERNAL_DNS_DOMAIN (RFC 8598): the domains the clients resolve with the DNS servers above.
|
||||
25 = {{ p2s_dns_domains | join(', ') }}
|
||||
{%- endif %}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -45,7 +45,7 @@ variable "vm_size" {
|
||||
}
|
||||
|
||||
variable "local_fqdn" {
|
||||
description = "FQDN of the router, used as the road-warrior/IKE identity and, when dns_zone_id is set, as the name of the A record created for it."
|
||||
description = "FQDN of the router, used as the IKE identity and, when dns_zone_id is set, as the name of the A record created for it."
|
||||
type = string
|
||||
}
|
||||
|
||||
@@ -89,31 +89,25 @@ variable "psk" {
|
||||
}
|
||||
|
||||
variable "p2s_enabled" {
|
||||
description = "Enable road-warrior (P2S) access."
|
||||
description = "Enable IKEv2 access."
|
||||
type = bool
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "p2s_address_pool" {
|
||||
description = "CIDR block assigned to road-warrior clients."
|
||||
description = "CIDR block assigned to IKEv2 clients."
|
||||
type = string
|
||||
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
|
||||
@@ -146,7 +140,7 @@ variable "deploy_workload_vm" {
|
||||
variable "repo_url" {
|
||||
description = "Base URL of the Debian package repository the router pulls vpn-router from."
|
||||
type = string
|
||||
default = "https://gitea.koszewscy.waw.pl/api/packages/slawek/debian"
|
||||
default = "https://gitea.koszewscy.waw.pl/api/packages/koszewscy/debian"
|
||||
}
|
||||
|
||||
variable "ubuntu_codename" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user