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 |
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
|
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,
|
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
|
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
|
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.
|
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
|
`/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
|
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
|
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
|
`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
|
checks a password by binding as the user, so the hash scheme is slapd's alone. The router's own
|
||||||
can read the directory through its LDAP connector.
|
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,
|
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
|
`dc=vpn-router,dc=local` in `/var/lib/ldap/vpn-router` (the path the slapd AppArmor profile
|
||||||
@@ -394,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
|
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
|
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
|
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
|
in the same process either, as psycopg caches the lookup. The services and `vpn-router-setup`
|
||||||
plainly, and their units wait instead: `ExecStartPre=wait-for-libpq`, a shell script of each
|
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
|
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),
|
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`.
|
because a start that waited would hold dpkg before its `ldconfig`.
|
||||||
@@ -458,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
|
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.
|
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.
|
is on disk only until setup has run.
|
||||||
|
|
||||||
## ADR-19: The package has an LDAP schema of its own under a UUID OID
|
## ADR-19: The package has an LDAP schema of its own under a UUID OID
|
||||||
@@ -481,11 +481,14 @@ standard attributes, are the display name and the description of a service accou
|
|||||||
`account` cannot hold. Manager-only accounts have the class from their creation, so an e-mail
|
`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.
|
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
|
`vpnrouter_ldap.provision` replaces the definitions in `cn=config` that differ, so an extended class
|
||||||
reaches an existing directory at the next setup run.
|
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
|
Consequences: The schema check stays strict. New attributes of accounts extend
|
||||||
OIDs continue the arcs. The scope `service` marks a service account (ADR-23). The OID must never
|
`vpnRouterAccount`, and their OIDs continue the arcs. The scope `service` marks a service account
|
||||||
be reused for anything else.
|
(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
|
## ADR-20: Users manage their own account through /account, proving who they are again
|
||||||
|
|
||||||
@@ -531,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
|
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
|
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
|
`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
|
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
|
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
|
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
|
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
|
to Docker containers while HTTPS is on. The Manager's HTTPS port is a setting of the router, not
|
||||||
@@ -655,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 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`
|
`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,
|
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
|
and used by HTTPS, IKEv2 and OpenVPN. `local.cidrs` stays the subnets the router
|
||||||
routes, masquerades and offers to road-warrior and OpenVPN clients.
|
routes, masquerades and offers to IKEv2 and OpenVPN clients.
|
||||||
- **One file:** `vpn-router-setup` writes every connection that is ready to
|
- **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
|
`/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
|
removes the file when none is. The NAT exemptions pair each connection's `local_cidrs` with its
|
||||||
@@ -766,9 +775,9 @@ signs the requester's CSR, or makes the key and returns it once in a PKCS#12 fil
|
|||||||
the certificate, so a key it gave away cannot leak from it later. The PKCS#12 file uses 3DES and
|
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
|
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`;
|
first certificate of a PEM file, so each CA has a file of its own in `/etc/swanctl/x509ca`;
|
||||||
road-warrior and site-to-site name their CA in `cacerts`, which restricts the trust chain to it.
|
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 road-warrior and OpenVPN only, the site-to-site CA's by
|
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,
|
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
|
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.
|
keeps the users' keys, as the OpenVPN profile embeds them.
|
||||||
@@ -908,8 +917,14 @@ Decision: `/shell` of the web service is off until `web.shell` is set on the rou
|
|||||||
Manager cannot set it. The shell runs as the console account of the signed-in user (ADR-34), so
|
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
|
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
|
console login gets none. `POST /shell` signs the user in again with the password
|
||||||
and TOTP code and gives a one-time ticket valid 30 seconds; a WebSocket on `GET /shell` with that
|
and TOTP code, or with one of the user's own passkeys (ADR-36), and gives a one-time ticket valid 30
|
||||||
ticket gets the shell. Asking for the password and code at every opening of the page is tiresome,
|
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
|
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
|
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
|
keeps the sign-in at every opening. The WebSocket of RFC 6455 is implemented on the standard library inside the
|
||||||
@@ -920,9 +935,10 @@ makes the terminal its controlling terminal so window sizes reach the shell. A s
|
|||||||
Manager checks the session and the WebSocket's origin, and relays the bytes to the router after
|
Manager checks the session and the WebSocket's origin, and relays the bytes to the router after
|
||||||
both handshakes.
|
both handshakes.
|
||||||
|
|
||||||
Consequences: Who can sign in as a member of `admins` and knows the password and the current code
|
Consequences: Who can sign in as a member of `admins` and knows the password and the current code,
|
||||||
can get a shell when the router allows one and the user's console login is on; a session alone
|
or holds one of the user's passkeys and unlocks it, can get a shell when the router allows one and
|
||||||
cannot. The ticket travels in the URL and appears in request logs, used up. The shell's sessions
|
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
|
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
|
traffic is as protected as the Router Manager's: HTTPS to Caddy, plain HTTP on the internal
|
||||||
network to the web service.
|
network to the web service.
|
||||||
@@ -979,7 +995,7 @@ Consequences: Peers follow their users: disabling a user drops the user's device
|
|||||||
directory's backup holds them. A peer cannot exist without a VPN user. A change restarts
|
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.
|
`wg-quick@wg0` through setup, which briefly interrupts the other peers.
|
||||||
|
|
||||||
## ADR-36: Passkeys sign in to the Router Manager only, verified by the router with python3-fido2
|
## 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.
|
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
|
WebAuthn needs a browser, HTTPS and a domain name; OpenVPN, IKEv2, WireGuard, SSH and sudo have no
|
||||||
@@ -998,9 +1014,385 @@ passkey sign-in gives the same tokens as `/auth`; as it binds no password, the r
|
|||||||
the user is enabled, not locked and not a service account. The begun steps are kept in the web
|
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
|
service's memory for 2 minutes. The browser converts the options and answers with
|
||||||
`PublicKeyCredential.parseCreationOptionsFromJSON`, `parseRequestOptionsFromJSON` and `toJSON`.
|
`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; the password and code stay for
|
Consequences: Passkeys are a convenience for the Router Manager and the OpenID Provider; the
|
||||||
everything else, the shell included. A changed `wan.local_fqdn` orphans every passkey. 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
|
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.
|
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.
|
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.
|
||||||
+546
-37
@@ -1,6 +1,466 @@
|
|||||||
|
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
|
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.
|
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.
|
* /usr/lib/vpn-router/vpnrouter_passkeys.py is part of the package.
|
||||||
|
|
||||||
@@ -8,7 +468,8 @@ vpn-router (0.26.1-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.26.0-1) unstable; urgency=medium
|
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.
|
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
|
* 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
|
whose console login is on without a token is refused POST /shell, the
|
||||||
@@ -32,7 +493,8 @@ vpn-router (0.26.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.25.4-1) unstable; urgency=medium
|
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.
|
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:
|
* 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
|
POST /shell without a password gives a ticket while the user's last
|
||||||
@@ -43,7 +505,8 @@ vpn-router (0.25.4-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.25.3-1) unstable; urgency=medium
|
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.
|
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"}.
|
* caddy of /config is {"version", "source", "service"}.
|
||||||
|
|
||||||
@@ -51,7 +514,9 @@ vpn-router (0.25.3-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.25.2-1) unstable; urgency=medium
|
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.
|
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
|
* 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
|
network larger than /31, such as 192.168.232.0/24, is refused with the
|
||||||
@@ -61,7 +526,8 @@ vpn-router (0.25.2-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.25.1-1) unstable; urgency=medium
|
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.
|
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",
|
* GET /remote-access answers wireguard_server: {"public_key",
|
||||||
"endpoint", "allowed_ips"}.
|
"endpoint", "allowed_ips"}.
|
||||||
@@ -70,7 +536,8 @@ vpn-router (0.25.1-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.25.0-1) unstable; urgency=medium
|
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.
|
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"}],
|
* PUT /users/<name> takes wireguard_peers, [{"public_key", "ips"}],
|
||||||
which replaces the user's peers; keys are unique and IPs may not
|
which replaces the user's peers; keys are unique and IPs may not
|
||||||
@@ -86,7 +553,8 @@ vpn-router (0.25.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.24.1-1) unstable; urgency=medium
|
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.
|
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
|
* The OpenVPN server writes its status file every 5 seconds, so a
|
||||||
refresh of the status shows clients that connected or left since.
|
refresh of the status shows clients that connected or left since.
|
||||||
@@ -95,7 +563,9 @@ vpn-router (0.24.1-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.24.0-1) unstable; urgency=medium
|
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.
|
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,
|
* GET /remote-access answers the p2s, openvpn and wireguard sections,
|
||||||
which of them setup runs, and wan.local_fqdn; PUT
|
which of them setup runs, and wan.local_fqdn; PUT
|
||||||
@@ -107,7 +577,9 @@ vpn-router (0.24.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.23.2-1) unstable; urgency=medium
|
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.
|
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
|
* 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.
|
switch, so the web service finds the POSIX accounts of the directory.
|
||||||
@@ -118,7 +590,9 @@ vpn-router (0.23.2-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.23.1-1) unstable; urgency=medium
|
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.
|
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
|
* vpn-router-user console <name> --posix | --account LOCAL | --off, and
|
||||||
PUT /users/<name>/console with a token of vpn-router-token, switch a
|
PUT /users/<name>/console with a token of vpn-router-token, switch a
|
||||||
@@ -136,7 +610,9 @@ vpn-router (0.23.1-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.23.0-1) unstable; urgency=medium
|
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.
|
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).
|
* web.shell (false), web.shell_user and web.shell_idle_minutes (15).
|
||||||
* POST /shell takes the password and the TOTP code and answers a
|
* POST /shell takes the password and the TOTP code and answers a
|
||||||
@@ -148,7 +624,8 @@ vpn-router (0.23.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.22.1-1) unstable; urgency=medium
|
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.
|
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"},
|
* site_to_site of the status document is {"connections", "established"},
|
||||||
and the status page shows "Site to site: N of M established".
|
and the status page shows "Site to site: N of M established".
|
||||||
@@ -157,7 +634,9 @@ vpn-router (0.22.1-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.22.0-1) unstable; urgency=medium
|
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.
|
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
|
* 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
|
key usage; a template's extended_key_usage takes the built-in and the
|
||||||
@@ -171,7 +650,8 @@ vpn-router (0.22.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.21.5-1) unstable; urgency=medium
|
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.
|
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
|
* /status/full takes the token of /config and answers the status
|
||||||
document as web.status_content full shows it.
|
document as web.status_content full shows it.
|
||||||
@@ -180,7 +660,8 @@ vpn-router (0.21.5-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.21.4-1) unstable; urgency=medium
|
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.
|
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
|
* 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
|
operation on a CA with a CRL failed with "Could not deserialize key
|
||||||
@@ -190,7 +671,10 @@ vpn-router (0.21.4-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.21.3-1) unstable; urgency=medium
|
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.
|
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
|
* The CAs with their certificates, keys, CSRs and CRLs, and every
|
||||||
certificate issued with its revocation, are entries of the
|
certificate issued with its revocation, are entries of the
|
||||||
@@ -214,7 +698,8 @@ vpn-router (0.21.3-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.21.2-1) unstable; urgency=medium
|
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.
|
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
|
* pki.root_key_warning, true by default, set to false silences the
|
||||||
warning that the root key is on the router.
|
warning that the root key is on the router.
|
||||||
@@ -223,7 +708,8 @@ vpn-router (0.21.2-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.21.1-1) unstable; urgency=medium
|
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.
|
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
|
* 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
|
validity, when the timer should have signed it again; a root CRL
|
||||||
@@ -233,7 +719,10 @@ vpn-router (0.21.1-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.21.0-1) unstable; urgency=medium
|
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.
|
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
|
* The root CA signs the issuing CAs p2s, which issues the router's VPN
|
||||||
server certificate and the users' certificates, s2s, which issues
|
server certificate and the users' certificates, s2s, which issues
|
||||||
@@ -272,7 +761,9 @@ vpn-router (0.21.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.20.0-1) unstable; urgency=medium
|
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.
|
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 backup section of vpn-router.json switches on backups and sets
|
||||||
the schedule (hourly, daily or weekly), the target (local or pbs),
|
the schedule (hourly, daily or weekly), the target (local or pbs),
|
||||||
@@ -305,7 +796,8 @@ vpn-router (0.20.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.19.1-3) unstable; urgency=medium
|
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.
|
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
|
* 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
|
twice a day, and that apt-daily or unattended-upgrades may hold the
|
||||||
@@ -324,7 +816,8 @@ vpn-router (0.19.1-2) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.19.1-1) unstable; urgency=medium
|
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.
|
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
|
* 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
|
minutes for an hour, 1 hour for a day, 1 day for a month and 1 month
|
||||||
@@ -335,7 +828,8 @@ vpn-router (0.19.1-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.19.0-1) unstable; urgency=medium
|
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.
|
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
|
* vpnRouterAccount of the package's schema also takes displayName and
|
||||||
description; vpn-router-setup updates the schema of an existing
|
description; vpn-router-setup updates the schema of an existing
|
||||||
@@ -353,7 +847,9 @@ vpn-router (0.19.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.18.3-1) unstable; urgency=medium
|
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.
|
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
|
* /service-accounts, /groups and the internal field of the answers
|
||||||
take every internal service account, the accounts the router creates
|
take every internal service account, the accounts the router creates
|
||||||
@@ -368,7 +864,8 @@ vpn-router (0.18.3-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.18.2-1) unstable; urgency=medium
|
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, for members of admins and admin tokens, lists the
|
||||||
service accounts, adds one with a password and an optional e-mail
|
service accounts, adds one with a password and an optional e-mail
|
||||||
@@ -385,7 +882,9 @@ vpn-router (0.18.2-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.18.1-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
|
* POST /users takes "service": true and adds a service account, which
|
||||||
cannot sign in to /auth; vpn-router-user add takes --service.
|
cannot sign in to /auth; vpn-router-user add takes --service.
|
||||||
@@ -409,7 +908,8 @@ vpn-router (0.18.0-2) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.18.0-1) 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,
|
* site_to_site maps a connection name to addrs, id, cidrs, local_cidrs,
|
||||||
local_id_mode, psk and psk_file; it replaces remote and
|
local_id_mode, psk and psk_file; it replaces remote and
|
||||||
@@ -449,7 +949,8 @@ vpn-router (0.18.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.17.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
|
* /config has a postgresql section: each cluster of pg_lsclusters with
|
||||||
its version, name, port, status and data directory, and the main
|
its version, name, port, status and data directory, and the main
|
||||||
@@ -461,7 +962,8 @@ vpn-router (0.17.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.16.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
|
* vpn-router-metrics takes a sample every minute: CPU, memory, free
|
||||||
space of /, bytes per second on the external and the internal
|
space of /, bytes per second on the external and the internal
|
||||||
@@ -513,7 +1015,8 @@ vpn-router (0.14.1-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.14.0-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:
|
* / 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
|
the last X-Forwarded-For entry for a request from a trusted proxy, so
|
||||||
@@ -528,7 +1031,8 @@ vpn-router (0.14.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.13.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
|
* The package depends on caddy. While https.enabled is true, the
|
||||||
default, and wan.local_fqdn is set, vpn-router-setup writes
|
default, and wan.local_fqdn is set, vpn-router-setup writes
|
||||||
@@ -551,7 +1055,8 @@ vpn-router (0.13.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.12.3-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
|
* web.listen in vpn-router.json sets the interfaces the web service
|
||||||
listens on: internal, external or both, the default. It is read
|
listens on: internal, external or both, the default. It is read
|
||||||
@@ -637,7 +1142,8 @@ vpn-router (0.9.2-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.9.1-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
|
* The web service imports psycopg as the first step of its
|
||||||
initialization, retrying every 15 seconds and logging the failure while
|
initialization, retrying every 15 seconds and logging the failure while
|
||||||
@@ -667,7 +1173,8 @@ vpn-router (0.9.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.8.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'
|
* vpn-router-web makes its signing key and creates the refresh tokens'
|
||||||
database on its first run, retrying every 15 seconds until PostgreSQL
|
database on its first run, retrying every 15 seconds until PostgreSQL
|
||||||
@@ -682,7 +1189,8 @@ vpn-router (0.8.0-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.7.1-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
|
* vpn-router-setup brings up strongSwan and WireGuard before it provisions
|
||||||
the user directory and the token database. A failure of either no
|
the user directory and the token database. A failure of either no
|
||||||
@@ -692,7 +1200,8 @@ vpn-router (0.7.1-1) unstable; urgency=medium
|
|||||||
|
|
||||||
vpn-router (0.7.0-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
|
* 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
|
enrolled user, and answers a 5 minute access token and an 8 hour refresh
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ Depends: ${misc:Depends},
|
|||||||
python3-ldap,
|
python3-ldap,
|
||||||
python3-pyotp,
|
python3-pyotp,
|
||||||
python3-fido2,
|
python3-fido2,
|
||||||
|
python3-jwt,
|
||||||
|
python3-oauthlib,
|
||||||
python3-psycopg,
|
python3-psycopg,
|
||||||
python3-psutil,
|
python3-psutil,
|
||||||
python3-prometheus-client,
|
python3-prometheus-client,
|
||||||
@@ -36,20 +38,27 @@ Depends: ${misc:Depends},
|
|||||||
openvpn-auth-ldap,
|
openvpn-auth-ldap,
|
||||||
iproute2,
|
iproute2,
|
||||||
systemd-resolved,
|
systemd-resolved,
|
||||||
|
dnsmasq-base,
|
||||||
zstd
|
zstd
|
||||||
|
Recommends: bash-completion
|
||||||
Suggests: caddy,
|
Suggests: caddy,
|
||||||
rclone,
|
rclone,
|
||||||
|
pdns-server,
|
||||||
|
pdns-backend-pgsql,
|
||||||
proxmox-backup-client-static
|
proxmox-backup-client-static
|
||||||
Description: Linux VPN router with IPSec, road-warrior, OpenVPN and WireGuard support
|
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
|
Configures a Linux host or virtual machine as a VPN router: site-to-site
|
||||||
IKEv2 IPSec (strongSwan swanctl) with pre-shared keys or certificates,
|
IKEv2 IPSec (strongSwan swanctl) with pre-shared keys or certificates,
|
||||||
optional road-warrior access using EAP-TLS, optional OpenVPN with a
|
optional IKEv2 access using EAP-TLS, optional OpenVPN with a
|
||||||
certificate and a password per user, kept in a local LDAP directory, optional
|
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
|
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
|
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
|
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
|
certificates and CRLs. Caddy, installed by the package from the Ubuntu
|
||||||
archive or from Caddy's repository, serves HTTPS. It keeps metrics of the
|
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
|
router in PostgreSQL and serves them for an OpenTelemetry Collector, and
|
||||||
makes scheduled backups to a local folder, copied to the cloud with rclone,
|
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
|
or to a Proxmox Backup Server. Nothing in the package is specific to a cloud
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ etc/vpn-router
|
|||||||
usr/lib/vpn-router
|
usr/lib/vpn-router
|
||||||
usr/lib/vpn-router/vpnrouter_platforms
|
usr/lib/vpn-router/vpnrouter_platforms
|
||||||
usr/share/vpn-router/templates
|
usr/share/vpn-router/templates
|
||||||
|
var/lib/vpn-router/sites
|
||||||
@@ -6,28 +6,40 @@ 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_users.py usr/lib/vpn-router/
|
||||||
src/usr/lib/vpn-router/vpnrouter_passkeys.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_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_metrics.py usr/lib/vpn-router/
|
||||||
src/usr/lib/vpn-router/vpnrouter_status.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_backup.py usr/lib/vpn-router/
|
||||||
src/usr/lib/vpn-router/vpnrouter_pki.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-same-user usr/lib/vpn-router/
|
||||||
src/usr/lib/vpn-router/openvpn-issuer 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-user usr/sbin/
|
||||||
src/usr/sbin/vpn-router-group 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-router-token usr/sbin/
|
||||||
src/usr/sbin/vpn-config-edit usr/sbin/
|
src/usr/sbin/vpn-config-edit usr/sbin/
|
||||||
src/usr/sbin/vpn-router-backup usr/sbin/
|
src/usr/sbin/vpn-router-backup usr/sbin/
|
||||||
src/usr/sbin/vpn-router-ca usr/sbin/
|
src/usr/sbin/vpn-router-ca usr/sbin/
|
||||||
src/usr/sbin/vpn-router-caddy 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/configure usr/lib/vpn-router/
|
||||||
src/usr/lib/vpn-router/docker-rules 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/setup usr/lib/vpn-router/
|
||||||
src/usr/lib/vpn-router/web 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/metrics usr/lib/vpn-router/
|
||||||
src/usr/lib/vpn-router/bootstrap 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/wait-for-libpq usr/lib/vpn-router/
|
||||||
src/usr/lib/vpn-router/generate-config 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/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/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/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/
|
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
|
||||||
@@ -71,9 +71,11 @@ case "$1" in
|
|||||||
# setting the forward policy all belong to vpn-router-setup, which does
|
# setting the forward policy all belong to vpn-router-setup, which does
|
||||||
# them only once the configuration says this host is a router.
|
# them only once the configuration says this host is a router.
|
||||||
|
|
||||||
# The web service waits for libpq in the linker cache, which dpkg
|
# The setup and web services wait for libpq in the linker cache, which
|
||||||
# refreshes after this script, so it is started without waiting for it.
|
# dpkg refreshes after this script, so they are started without waiting
|
||||||
|
# for them.
|
||||||
if [ -d /run/systemd/system ]; then
|
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
|
systemctl --no-block restart vpn-router-web.service >/dev/null 2>&1 || true
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ strip_ufw_blocks() {
|
|||||||
|
|
||||||
tmp="$(mktemp)"
|
tmp="$(mktemp)"
|
||||||
awk '
|
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
|
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 = 0; next
|
||||||
}
|
}
|
||||||
skip { next }
|
skip { next }
|
||||||
@@ -46,12 +46,15 @@ case "$1" in
|
|||||||
rm -f /etc/swanctl/conf.d/site-to-site.conf \
|
rm -f /etc/swanctl/conf.d/site-to-site.conf \
|
||||||
/etc/swanctl/conf.d/road-warrior.conf \
|
/etc/swanctl/conf.d/road-warrior.conf \
|
||||||
/etc/systemd/resolved.conf.d/p2s-forwarder.conf \
|
/etc/systemd/resolved.conf.d/p2s-forwarder.conf \
|
||||||
|
/etc/vpn-router/dnsmasq.conf \
|
||||||
/etc/wireguard/wg0.conf \
|
/etc/wireguard/wg0.conf \
|
||||||
/etc/openvpn/server/vpn-router.conf \
|
/etc/openvpn/server/vpn-router.conf \
|
||||||
/etc/openvpn/server/vpn-router-ldap.conf \
|
/etc/openvpn/server/vpn-router-ldap.conf \
|
||||||
/etc/caddy/vpn-router.Caddyfile \
|
/etc/caddy/vpn-router.Caddyfile \
|
||||||
|
/etc/powerdns/pdns.d/vpn-router.conf \
|
||||||
/etc/vpn-router/vpn-router.json \
|
/etc/vpn-router/vpn-router.json \
|
||||||
/etc/vpn-router/web.key \
|
/etc/vpn-router/web.key \
|
||||||
|
/etc/vpn-router/oidc.key \
|
||||||
/etc/vpn-router/metrics.secret \
|
/etc/vpn-router/metrics.secret \
|
||||||
/etc/systemd/system/vpn-router-backup.timer.d/schedule.conf \
|
/etc/systemd/system/vpn-router-backup.timer.d/schedule.conf \
|
||||||
/etc/sudoers.d/vpn-router \
|
/etc/sudoers.d/vpn-router \
|
||||||
@@ -62,7 +65,7 @@ case "$1" in
|
|||||||
# The name service no longer looks up the console accounts of the router's directory.
|
# The name service no longer looks up the console accounts of the router's directory.
|
||||||
sed -i '/^passwd:/s/ ldap\b//' /etc/nsswitch.conf
|
sed -i '/^passwd:/s/ ldap\b//' /etc/nsswitch.conf
|
||||||
rm -rf /var/lib/vpn-router/backup/staging /var/lib/vpn-router/backup/restore \
|
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 \
|
/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
|
/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
|
rmdir --ignore-fail-on-non-empty /var/lib/vpn-router/backup /var/lib/vpn-router 2>/dev/null || true
|
||||||
|
|
||||||
@@ -73,12 +76,23 @@ case "$1" in
|
|||||||
fi
|
fi
|
||||||
rm -f /etc/vpn-router/database.secret
|
rm -f /etc/vpn-router/database.secret
|
||||||
|
|
||||||
# Key material and users are not package state: /etc/vpn-router/pki,
|
# 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
|
# /etc/vpn-router/ldap, /etc/vpn-router/rclone.conf, the user directory
|
||||||
# in slapd, the WireGuard key pair, everything under /etc/swanctl, the
|
# in slapd, the WireGuard key pair, everything under /etc/swanctl, the
|
||||||
# backups and what a restore replaced are left in place. Caddy stays
|
# files of the web sites in /var/lib/vpn-router/sites, the backups and
|
||||||
# installed, so its repository, which vpn-router-caddy configures, stays
|
# what a restore replaced are left in place. Caddy, rclone and PowerDNS
|
||||||
# too.
|
# 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
|
rmdir --ignore-fail-on-non-empty /etc/vpn-router 2>/dev/null || true
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
dh $@
|
dh $@
|
||||||
|
|
||||||
override_dh_installsystemd:
|
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-docker
|
||||||
dh_installsystemd --name=vpn-router-web --no-start
|
dh_installsystemd --name=vpn-router-web --no-start
|
||||||
dh_installsystemd --name=vpn-router-metrics --no-enable --no-start
|
dh_installsystemd --name=vpn-router-metrics --no-enable --no-start
|
||||||
@@ -11,3 +11,6 @@ override_dh_installsystemd:
|
|||||||
dh_installsystemd --name=vpn-router-backup --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-pki --no-enable --no-start
|
||||||
dh_installsystemd --name=vpn-router-caddy --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
|
Default: none
|
||||||
Description: Platform:
|
Description: Platform:
|
||||||
Which platform this router runs on. The choice loads platform-specific
|
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
|
none defers configuration entirely: the package installs its files and
|
||||||
changes nothing on the machine. Choose it when the router will be configured
|
changes nothing on the machine. Choose it when the router will be configured
|
||||||
@@ -58,7 +58,7 @@ Type: string
|
|||||||
Default:
|
Default:
|
||||||
Description: Local router FQDN:
|
Description: Local router FQDN:
|
||||||
Fully-qualified domain name of this router (for example
|
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.
|
certificate common name.
|
||||||
|
|
||||||
Template: vpn-router/local_id_mode
|
Template: vpn-router/local_id_mode
|
||||||
@@ -122,15 +122,15 @@ Description: Pre-shared key (PSK):
|
|||||||
Template: vpn-router/p2s_enabled
|
Template: vpn-router/p2s_enabled
|
||||||
Type: boolean
|
Type: boolean
|
||||||
Default: false
|
Default: false
|
||||||
Description: Enable road-warrior (P2S) access?
|
Description: Enable IKEv2 access?
|
||||||
If enabled, this router accepts IKEv2 EAP-TLS connections from individual
|
If enabled, this router accepts IKEv2 EAP-TLS connections from individual
|
||||||
clients, with certificates of the router's P2S issuing CA.
|
clients, with certificates of the router's P2S issuing CA.
|
||||||
|
|
||||||
Template: vpn-router/p2s_address_pool
|
Template: vpn-router/p2s_address_pool
|
||||||
Type: string
|
Type: string
|
||||||
Default:
|
Default:
|
||||||
Description: Road-warrior address pool:
|
Description: IKEv2 address pool:
|
||||||
CIDR block assigned to road-warrior clients (for example 172.16.0.0/24).
|
CIDR block assigned to IKEv2 clients (for example 172.16.0.0/24).
|
||||||
|
|
||||||
Template: vpn-router/pki_root_name
|
Template: vpn-router/pki_root_name
|
||||||
Type: string
|
Type: string
|
||||||
|
|||||||
@@ -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,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]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
|
ExecStartPre=/usr/lib/vpn-router/wait-for-libpq
|
||||||
ExecStart=/usr/lib/vpn-router/setup
|
ExecStart=/usr/lib/vpn-router/setup
|
||||||
RemainAfterExit=yes
|
RemainAfterExit=yes
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ NoNewPrivileges=yes
|
|||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
RuntimeDirectory=vpn-router
|
RuntimeDirectory=vpn-router
|
||||||
RuntimeDirectoryPreserve=yes
|
RuntimeDirectoryPreserve=yes
|
||||||
ReadWritePaths=-/etc/vpn-router -/etc/swanctl/x509crl -/etc/openvpn/server -/var/lib/vpn-router/pki-publish
|
ReadWritePaths=-/etc/vpn-router -/etc/swanctl/x509crl -/etc/openvpn/server -/var/lib/vpn-router/pki-publish -/var/lib/vpn-router/sites
|
||||||
ProtectHome=yes
|
ProtectHome=yes
|
||||||
PrivateTmp=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 ;;
|
*) printf 'ERROR: unsupported platform %s\n' "$(uname -s)" >&2; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
OWNER="${OWNER:-slawek}"
|
OWNER="${OWNER:-koszewscy}"
|
||||||
SERVER_URL="${SERVER_URL:-https://gitea.koszewscy.waw.pl}"
|
SERVER_URL="${SERVER_URL:-https://gitea.koszewscy.waw.pl}"
|
||||||
PACKAGES_TOKEN="${PACKAGES_TOKEN:-$(cat ~/.gitea-packages-token)}"
|
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.
|
# Reads the changelog copied into the builder image, so no bind mount is needed.
|
||||||
changelog_field() {
|
changelog_field() {
|
||||||
@@ -27,14 +29,17 @@ if [ ! -f "$DEB" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
STATUS="$(curl -s -o /dev/null -w '%{http_code}' --user "$OWNER:$PACKAGES_TOKEN" \
|
BODY="$(mktemp)"
|
||||||
"$SERVER_URL/api/v1/packages/$OWNER/debian/$PACKAGE/$VERSION")"
|
trap 'rm -f "$BODY"' EXIT
|
||||||
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
|
|
||||||
|
|
||||||
curl --fail-with-body --user "$OWNER:$PACKAGES_TOKEN" \
|
# Gitea answers 409 for a version that is in the distribution already.
|
||||||
--upload-file "$DEB" \
|
for DISTRIBUTION in $DISTRIBUTIONS; do
|
||||||
"$SERVER_URL/api/packages/$OWNER/debian/pool/noble/main/upload"
|
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 subprocess
|
||||||
|
import sys
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
PORT = 17081
|
PORT = 17081
|
||||||
@@ -42,14 +43,28 @@ def report():
|
|||||||
class Handler(BaseHTTPRequestHandler):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
body = report().encode()
|
body = report().encode()
|
||||||
self.send_response(200)
|
try:
|
||||||
self.send_header('Content-Type', 'text/plain; charset=utf-8')
|
self.send_response(200)
|
||||||
self.send_header('Content-Length', str(len(body)))
|
self.send_header('Content-Type', 'text/plain; charset=utf-8')
|
||||||
self.send_header('Cache-Control', 'no-store')
|
self.send_header('Content-Length', str(len(body)))
|
||||||
self.send_header('Refresh', '10')
|
self.send_header('Cache-Control', 'no-store')
|
||||||
self.end_headers()
|
self.send_header('Refresh', '10')
|
||||||
self.wfile.write(body)
|
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__':
|
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:]))
|
||||||
+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
|
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
|
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;
|
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
|
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
|
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
|
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 = CollectorRegistry()
|
||||||
registry.register(Sample(current))
|
registry.register(Sample(current))
|
||||||
body, kind, code = generate_latest(registry), CONTENT_TYPE_LATEST, 200
|
body, kind, code = generate_latest(registry), CONTENT_TYPE_LATEST, 200
|
||||||
self.send_response(code)
|
try:
|
||||||
self.send_header('Content-Type', kind)
|
self.send_response(code)
|
||||||
self.send_header('Content-Length', str(len(body)))
|
self.send_header('Content-Type', kind)
|
||||||
self.send_header('Cache-Control', 'no-store')
|
self.send_header('Content-Length', str(len(body)))
|
||||||
for name, value in extra:
|
self.send_header('Cache-Control', 'no-store')
|
||||||
self.send_header(name, value)
|
for name, value in extra:
|
||||||
self.end_headers()
|
self.send_header(name, value)
|
||||||
if send_body:
|
self.end_headers()
|
||||||
self.wfile.write(body)
|
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):
|
def do_GET(self):
|
||||||
self.answer(True)
|
self.answer(True)
|
||||||
|
|||||||
@@ -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
|
Until a run has succeeded (SETUP_DONE), every run starts the bootstrap
|
||||||
services, BOOTSTRAP_UNITS, which show the progress and the failure recorded in
|
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
|
internal interface. The first successful run writes SETUP_DONE, closes the
|
||||||
ports and stops the bootstrap services; a failed run leaves them running.
|
ports and stops the bootstrap services; a failed run leaves them running.
|
||||||
|
|
||||||
@@ -33,15 +33,18 @@ sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
|
|||||||
import ldap
|
import ldap
|
||||||
|
|
||||||
import vpnrouter
|
import vpnrouter
|
||||||
|
import vpnrouter_auth
|
||||||
|
import vpnrouter_dns
|
||||||
import vpnrouter_ldap
|
import vpnrouter_ldap
|
||||||
import vpnrouter_pki
|
import vpnrouter_pki
|
||||||
|
import vpnrouter_sites
|
||||||
import vpnrouter_users
|
import vpnrouter_users
|
||||||
from vpnrouter import fail, warn
|
from vpnrouter import fail, warn
|
||||||
|
|
||||||
BEFORE_RULES = pathlib.Path('/etc/ufw/before.rules')
|
BEFORE_RULES = pathlib.Path('/etc/ufw/before.rules')
|
||||||
UFW_DEFAULTS = pathlib.Path('/etc/default/ufw')
|
UFW_DEFAULTS = pathlib.Path('/etc/default/ufw')
|
||||||
WG_DIR = pathlib.Path('/etc/wireguard')
|
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.
|
#: 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')
|
SETUP_STATE = pathlib.Path('/run/vpn-router/setup.json')
|
||||||
@@ -49,18 +52,12 @@ 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.
|
#: 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')
|
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_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
|
#: 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.
|
#: its debconf answers; setup creates the account and removes the file.
|
||||||
MANAGER_ADMIN = pathlib.Path('/etc/vpn-router-manager/admin.json')
|
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).
|
#: The password of the metrics service account, which setup makes and keeps (root only).
|
||||||
METRICS_SECRET = pathlib.Path('/etc/vpn-router/metrics.secret')
|
METRICS_SECRET = pathlib.Path('/etc/vpn-router/metrics.secret')
|
||||||
@@ -77,9 +74,22 @@ CADDY_GROUP = 'caddy'
|
|||||||
#: The unit that installs Caddy from https.caddy_source with vpn-router-caddy, outside setup.
|
#: The unit that installs Caddy from https.caddy_source with vpn-router-caddy, outside setup.
|
||||||
CADDY_INSTALL_UNIT = 'vpn-router-caddy.service'
|
CADDY_INSTALL_UNIT = 'vpn-router-caddy.service'
|
||||||
|
|
||||||
#: The certificates and keys of the issuing CAs behind Caddy's ACME server, <name>/cert.pem and key.pem.
|
#: 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')
|
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.
|
#: The timer of vpn-router-ca maintain, which keeps the CRLs fresh.
|
||||||
PKI_TIMER = 'vpn-router-pki.timer'
|
PKI_TIMER = 'vpn-router-pki.timer'
|
||||||
|
|
||||||
@@ -107,6 +117,8 @@ BLOCKS = (
|
|||||||
('# MANAGER RULES START', '# MANAGER RULES END'),
|
('# MANAGER RULES START', '# MANAGER RULES END'),
|
||||||
('# HTTPS RULES START', '# HTTPS RULES END'),
|
('# HTTPS RULES START', '# HTTPS RULES END'),
|
||||||
('# METRICS RULES START', '# METRICS 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 FORWARD RULES START', '# ROUTER FORWARD RULES END'),
|
||||||
('# ROUTER NAT RULES START', '# ROUTER NAT RULES END'),
|
('# ROUTER NAT RULES START', '# ROUTER NAT RULES END'),
|
||||||
('# ROUTER MSS RULES START', '# ROUTER MSS RULES END'),
|
('# ROUTER MSS RULES START', '# ROUTER MSS RULES END'),
|
||||||
@@ -194,15 +206,6 @@ def is_configured(ctx):
|
|||||||
vpnrouter.openvpn_ready(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):
|
def filter_block(ctx):
|
||||||
lines = [
|
lines = [
|
||||||
'',
|
'',
|
||||||
@@ -240,11 +243,13 @@ def filter_block(ctx):
|
|||||||
lines += ['# OPENVPN RULES END']
|
lines += ['# OPENVPN RULES END']
|
||||||
|
|
||||||
pools = [pool for ready, pool in ((vpnrouter.p2s_ready(ctx), ctx['p2s_address_pool']),
|
pools = [pool for ready, pool in ((vpnrouter.p2s_ready(ctx), ctx['p2s_address_pool']),
|
||||||
(vpnrouter.openvpn_ready(ctx), ctx['openvpn_pool'])) if ready]
|
(vpnrouter.openvpn_ready(ctx), ctx['openvpn_pool']),
|
||||||
if pools and ctx['local_addr_list']:
|
(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']
|
lines += ['', '# P2S DNS RULES START']
|
||||||
for pool in pools:
|
for pool in pools:
|
||||||
for address in ctx['local_addr_list']:
|
for address in ctx['p2s_resolver_addrs']:
|
||||||
lines += [
|
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 udp --dport 53 -j ACCEPT',
|
||||||
f'-A ufw-before-input -s {pool} -d {address} -p tcp --dport 53 -j ACCEPT',
|
f'-A ufw-before-input -s {pool} -d {address} -p tcp --dport 53 -j ACCEPT',
|
||||||
@@ -265,12 +270,13 @@ def filter_block(ctx):
|
|||||||
lines += [
|
lines += [
|
||||||
'',
|
'',
|
||||||
'# BOOTSTRAP RULES START',
|
'# 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',
|
'# BOOTSTRAP RULES END',
|
||||||
]
|
]
|
||||||
|
|
||||||
if MANAGER.exists() and interfaces:
|
if vpnrouter.MANAGER.exists() and interfaces:
|
||||||
port = manager_port()
|
port = vpnrouter.manager_port()
|
||||||
lines += [
|
lines += [
|
||||||
'',
|
'',
|
||||||
'# MANAGER RULES START',
|
'# MANAGER RULES START',
|
||||||
@@ -292,8 +298,9 @@ def filter_block(ctx):
|
|||||||
publish = ctx['pki_publish']
|
publish = ctx['pki_publish']
|
||||||
ports = [port for wanted, port in ((ctx['web_enabled'] or 'https' in publish or ctx['pki_acme'],
|
ports = [port for wanted, port in ((ctx['web_enabled'] or 'https' in publish or ctx['pki_acme'],
|
||||||
ctx['https_web_port']),
|
ctx['https_web_port']),
|
||||||
(MANAGER.exists(), ctx['https_manager_port']),
|
(vpnrouter.MANAGER.exists(), ctx['https_manager_port']),
|
||||||
(vpnrouter.metrics_ready(ctx), ctx['metrics_https_port']),
|
(vpnrouter.metrics_ready(ctx), ctx['metrics_https_port']),
|
||||||
|
(vpnrouter.webdav_ready(ctx), ctx['webdav_port']),
|
||||||
('http-80' in publish, vpnrouter.ACME_HTTP_PORT),
|
('http-80' in publish, vpnrouter.ACME_HTTP_PORT),
|
||||||
('https-default' in publish, vpnrouter.HTTPS_DEFAULT_PORT)) if wanted]
|
('https-default' in publish, vpnrouter.HTTPS_DEFAULT_PORT)) if wanted]
|
||||||
lines += [
|
lines += [
|
||||||
@@ -305,6 +312,32 @@ def filter_block(ctx):
|
|||||||
'# HTTPS RULES END',
|
'# 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):
|
if vpnrouter.routing_ready(ctx):
|
||||||
lines += ['', '# ROUTER FORWARD RULES START']
|
lines += ['', '# ROUTER FORWARD RULES START']
|
||||||
for subnet in ctx['local_cidr_list']:
|
for subnet in ctx['local_cidr_list']:
|
||||||
@@ -640,22 +673,50 @@ def manage_openvpn(ctx, changed):
|
|||||||
run(['systemctl', 'restart', unit], check=False)
|
run(['systemctl', 'restart', unit], check=False)
|
||||||
|
|
||||||
|
|
||||||
def manage_caddy(cfg, ctx):
|
def caddy_address(host, port, tls):
|
||||||
"""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.
|
"""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
|
def manage_resolver(changed):
|
||||||
metrics.https_port, all for wan.local_fqdn with a certificate from Let's Encrypt, and all
|
"""Run dnsmasq, the resolver of the remote access clients, while configure wrote its configuration, restarting it when
|
||||||
calling the service on its loopback address. It also serves the CA files of
|
that changed, and stop it when not. It starts after systemd-resolved has left the addresses it takes."""
|
||||||
vpnrouter_pki.PUBLISH_DIR at /pki/ on the endpoints of pki.publish that are Caddy's, and an
|
if not vpnrouter.RESOLVER_CONF.exists():
|
||||||
ACME server at /acme/<name>/ on https.web_port for each issuing CA with acme and a
|
if unit_is_active(RESOLVER_UNIT):
|
||||||
certificate, whose certificate and key are copied to CADDY_PKI_DIR.
|
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
|
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
|
https.caddy_source is upstream and Caddy's repository is not configured; the unit restarts
|
||||||
setup, whose next run serves with it.
|
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)
|
vpnrouter.remove(CADDYFILE)
|
||||||
if unit_is_active(CADDY_UNIT):
|
if unit_is_active(CADDY_UNIT):
|
||||||
run(['systemctl', 'disable', '--now', CADDY_UNIT], check=False)
|
run(['systemctl', 'disable', '--now', CADDY_UNIT], check=False)
|
||||||
@@ -669,21 +730,33 @@ def manage_caddy(cfg, ctx):
|
|||||||
if source == 'ubuntu' and vpnrouter.CADDY_APT_SOURCE.is_file():
|
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")
|
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:
|
try:
|
||||||
group = grp.getgrnam(CADDY_GROUP).gr_gid
|
group = grp.getgrnam(CADDY_GROUP).gr_gid
|
||||||
except KeyError:
|
except KeyError:
|
||||||
fail(f'the group {CADDY_GROUP} does not exist; is the caddy package installed?')
|
fail(f'the group {CADDY_GROUP} does not exist; is the caddy package installed?')
|
||||||
|
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:
|
try:
|
||||||
credentials = {name: vpnrouter_pki.authority_files(name) for name in ctx['pki_acme']}
|
credentials = {name: vpnrouter_pki.authority_files(name) for name in wanted}
|
||||||
except ldap.LDAPError as exc:
|
except ldap.LDAPError as exc:
|
||||||
warn(f'the ACME servers are left out: the PKI cannot be read: {exc}')
|
warn(f'the ACME servers and the sites of issuing CAs are left out: the PKI cannot be read: {exc}')
|
||||||
credentials = {}
|
credentials = {}
|
||||||
acme = [name for name, files in credentials.items() if files is not None]
|
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
|
copied = False
|
||||||
for folder in (CADDY_PKI_DIR.iterdir() if CADDY_PKI_DIR.is_dir() else []):
|
for folder in (CADDY_PKI_DIR.iterdir() if CADDY_PKI_DIR.is_dir() else []):
|
||||||
if folder.name not in acme:
|
if folder.name not in authorities:
|
||||||
shutil.rmtree(folder)
|
shutil.rmtree(folder)
|
||||||
for name in acme:
|
for name in authorities:
|
||||||
folder = CADDY_PKI_DIR / name
|
folder = CADDY_PKI_DIR / name
|
||||||
for path in (CADDY_PKI_DIR, folder):
|
for path in (CADDY_PKI_DIR, folder):
|
||||||
path.mkdir(mode=0o750, exist_ok=True)
|
path.mkdir(mode=0o750, exist_ok=True)
|
||||||
@@ -691,12 +764,33 @@ def manage_caddy(cfg, ctx):
|
|||||||
for file, data in zip(('cert.pem', 'key.pem'), credentials[name]):
|
for file, data in zip(('cert.pem', 'key.pem'), credentials[name]):
|
||||||
copied |= vpnrouter_pki.write(folder / file, data, 0o640)
|
copied |= vpnrouter_pki.write(folder / file, data, 0o640)
|
||||||
os.chown(folder / file, 0, group)
|
os.chown(folder / file, 0, group)
|
||||||
authorities = vpnrouter.issuing_cas(cfg)
|
files = {site['name']: site for site in sites if site['tls'] == 'file'}
|
||||||
caddy_ctx = dict(ctx, manager_port=manager_port() if MANAGER.exists() else 0,
|
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_publish_dir=str(vpnrouter_pki.PUBLISH_DIR),
|
||||||
acme_cas=[{'id': name, 'name': authorities[name]['name'].strip(),
|
pki_cas=[{'id': name, 'name': names[name]['name'].strip(),
|
||||||
'cert': str(CADDY_PKI_DIR / name / 'cert.pem'),
|
'cert': str(CADDY_PKI_DIR / name / 'cert.pem'),
|
||||||
'key': str(CADDY_PKI_DIR / name / 'key.pem')} for name in acme])
|
'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)
|
changed = vpnrouter.render(vpnrouter._jinja_env(), caddy_ctx, 'Caddyfile.j2', CADDYFILE, 0o640)
|
||||||
os.chown(CADDYFILE, 0, group)
|
os.chown(CADDYFILE, 0, group)
|
||||||
if not unit_is_active(CADDY_UNIT):
|
if not unit_is_active(CADDY_UNIT):
|
||||||
@@ -705,6 +799,91 @@ def manage_caddy(cfg, ctx):
|
|||||||
run(['systemctl', 'reload', CADDY_UNIT], check=False)
|
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):
|
def manage_metrics(ctx):
|
||||||
"""Run the metrics service while metrics are enabled, and stop it when not.
|
"""Run the metrics service while metrics are enabled, and stop it when not.
|
||||||
|
|
||||||
@@ -795,6 +974,7 @@ def refresh_web(cfg, ctx):
|
|||||||
'internal_address': ctx['int_addr'],
|
'internal_address': ctx['int_addr'],
|
||||||
'internal_gateway': ctx['int_gateway_ip'],
|
'internal_gateway': ctx['int_gateway_ip'],
|
||||||
'local_cidrs': ctx['local_cidr_list'],
|
'local_cidrs': ctx['local_cidr_list'],
|
||||||
|
'client_dns': ctx['p2s_resolver_addrs'] if vpnrouter.RESOLVER_CONF.exists() else [],
|
||||||
'site_to_site': [{
|
'site_to_site': [{
|
||||||
'name': connection['name'],
|
'name': connection['name'],
|
||||||
'local_identity': connection['local_id'],
|
'local_identity': connection['local_id'],
|
||||||
@@ -863,6 +1043,16 @@ def main():
|
|||||||
ctx, more = vpnrouter.configure(cfg, public_ip=address)
|
ctx, more = vpnrouter.configure(cfg, public_ip=address)
|
||||||
changed |= more
|
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')
|
progress('running', 'WireGuard keys')
|
||||||
setup_wireguard(ctx)
|
setup_wireguard(ctx)
|
||||||
|
|
||||||
@@ -900,12 +1090,18 @@ def main():
|
|||||||
manage_openvpn(ctx, changed)
|
manage_openvpn(ctx, changed)
|
||||||
progress('running', 'HTTPS proxy')
|
progress('running', 'HTTPS proxy')
|
||||||
manage_caddy(cfg, ctx)
|
manage_caddy(cfg, ctx)
|
||||||
|
progress('running', 'WebDAV')
|
||||||
|
manage_webdav(ctx)
|
||||||
|
progress('running', 'name server')
|
||||||
|
manage_dns(cfg, ctx)
|
||||||
progress('running', 'PKI timer')
|
progress('running', 'PKI timer')
|
||||||
run(['systemctl', 'enable', '--now', PKI_TIMER], check=False)
|
run(['systemctl', 'enable', '--now', PKI_TIMER], check=False)
|
||||||
|
|
||||||
if 'resolved' in changed:
|
if 'resolved' in changed:
|
||||||
progress('running', 'resolver')
|
progress('running', 'resolver')
|
||||||
run(['systemctl', 'restart', 'systemd-resolved'], check=False)
|
run(['systemctl', 'restart', 'systemd-resolved'], check=False)
|
||||||
|
progress('running', 'resolver of the remote access clients')
|
||||||
|
manage_resolver(changed)
|
||||||
|
|
||||||
progress('running', 'platform module')
|
progress('running', 'platform module')
|
||||||
run_platform_setup(cfg, ctx)
|
run_platform_setup(cfg, ctx)
|
||||||
|
|||||||
@@ -31,12 +31,17 @@ from vpnrouter_net import DEFAULT_LISTEN, LISTEN_CHOICES, LOOPBACK, ConfigError,
|
|||||||
|
|
||||||
CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.json')
|
CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.json')
|
||||||
CONFIG_LOCK = pathlib.Path('/run/vpn-router/config.lock')
|
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')
|
TEMPLATE_DIR = pathlib.Path('/usr/share/vpn-router/templates')
|
||||||
|
|
||||||
SWANCTL_CONF_D = pathlib.Path('/etc/swanctl/conf.d')
|
SWANCTL_CONF_D = pathlib.Path('/etc/swanctl/conf.d')
|
||||||
SITE_TO_SITE = SWANCTL_CONF_D / 'site-to-site.conf'
|
SITE_TO_SITE = SWANCTL_CONF_D / 'site-to-site.conf'
|
||||||
ROAD_WARRIOR = SWANCTL_CONF_D / 'road-warrior.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')
|
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')
|
WG_CONF = pathlib.Path('/etc/wireguard/wg0.conf')
|
||||||
#: The router's WireGuard public key, which setup writes with its private key.
|
#: The router's WireGuard public key, which setup writes with its private key.
|
||||||
WG_PUBLIC_KEY = pathlib.Path('/etc/wireguard/wg0.pub')
|
WG_PUBLIC_KEY = pathlib.Path('/etc/wireguard/wg0.pub')
|
||||||
@@ -50,17 +55,22 @@ OPENVPN_LDAP = pathlib.Path('/etc/openvpn/server/vpn-router-ldap.conf')
|
|||||||
SCHEMA = {
|
SCHEMA = {
|
||||||
'general': {'platform': 'none', 'mode': 'manual'},
|
'general': {'platform': 'none', 'mode': 'manual'},
|
||||||
'interfaces': {'external': '', 'internal': ''},
|
'interfaces': {'external': '', 'internal': ''},
|
||||||
'wan': {'local_fqdn': ''},
|
'wan': {'local_fqdn': '', 'public_address': ''},
|
||||||
'local': {'cidrs': [], 'int_addr': '', 'int_gateway_ip': ''},
|
'local': {'cidrs': [], 'int_addr': '', 'int_gateway_ip': ''},
|
||||||
'site_to_site': {},
|
'site_to_site': {},
|
||||||
'p2s': {'enabled': False, 'address_pool': ''},
|
'p2s': {'enabled': False, 'address_pool': '', 'dns_listen': 'internal', 'dns_domains': []},
|
||||||
'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820},
|
'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820},
|
||||||
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
|
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
|
||||||
'docker': {'mode': 'auto', 'exposed_ports': []},
|
'docker': {'mode': 'auto', 'exposed_ports': []},
|
||||||
'web': {'enabled': True, 'listen': DEFAULT_LISTEN, 'port': 7081, 'database': '', 'trusted_proxies': [],
|
'web': {'enabled': True, 'listen': DEFAULT_LISTEN, 'port': 7081, 'database': '', 'trusted_proxies': [],
|
||||||
'status_content': 'basic', 'shell': False, 'shell_idle_minutes': 15, 'shell_auth_timeout': 15},
|
'status_content': 'basic', 'shell': False, 'shell_idle_minutes': 15, 'shell_auth_timeout': 15,
|
||||||
|
'log_files': []},
|
||||||
'ssh': {'ldap_login': False},
|
'ssh': {'ldap_login': False},
|
||||||
'https': {'enabled': True, 'email': '', 'web_port': 7444, 'manager_port': 7443, 'caddy_source': 'ubuntu'},
|
'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',
|
'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',
|
'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': {},
|
's2s_name': 'VPN Router Site-to-Site CA', 'issuing_cas': {}, 'templates': {},
|
||||||
@@ -125,9 +135,43 @@ ACME_HTTP_PORT = 80
|
|||||||
#: The default port of HTTPS, where Caddy publishes the CA files when pki.publish has https-default.
|
#: The default port of HTTPS, where Caddy publishes the CA files when pki.publish has https-default.
|
||||||
HTTPS_DEFAULT_PORT = 443
|
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.
|
#: Where vpn-router-setup installs Caddy from, https.caddy_source: the Ubuntu archive or Caddy's own repository.
|
||||||
CADDY_SOURCES = ('ubuntu', 'upstream')
|
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'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_APT_SOURCE = pathlib.Path('/etc/apt/sources.list.d/caddy-stable.sources')
|
||||||
CADDY_KEYRING = pathlib.Path('/etc/apt/keyrings/caddy-stable.asc')
|
CADDY_KEYRING = pathlib.Path('/etc/apt/keyrings/caddy-stable.asc')
|
||||||
@@ -260,6 +304,9 @@ def read_settings(path=None):
|
|||||||
for section, options in data.items():
|
for section, options in data.items():
|
||||||
if not isinstance(options, dict):
|
if not isinstance(options, dict):
|
||||||
raise ConfigError(f'cannot parse {path}: {section} is not an object')
|
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
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -517,10 +564,61 @@ def validate(cfg):
|
|||||||
for item in _get_list(cfg, 'web', 'trusted_proxies'):
|
for item in _get_list(cfg, 'web', 'trusted_proxies'):
|
||||||
_check_address(item, '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')
|
caddy_source = _get(cfg, 'https', 'caddy_source')
|
||||||
if caddy_source not in CADDY_SOURCES:
|
if caddy_source not in CADDY_SOURCES:
|
||||||
raise ConfigError(f'https.caddy_source: {caddy_source!r} is not one of {", ".join(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'):
|
for option in ('root_name', 'p2s_name', 's2s_name'):
|
||||||
value = _get(cfg, 'pki', option)
|
value = _get(cfg, 'pki', option)
|
||||||
if not value or len(value) > CA_NAME_MAX or not value.isprintable():
|
if not value or len(value) > CA_NAME_MAX or not value.isprintable():
|
||||||
@@ -611,13 +709,13 @@ def validate(cfg):
|
|||||||
if _get(cfg, 'openvpn', 'protocol') == 'tcp' and _get(cfg, 'openvpn', 'port') == 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')
|
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'
|
taken[HTTPS_DEFAULT_PORT] = 'pki.publish https-default'
|
||||||
for option in ('port', 'https_port'):
|
for section_name, option in (('metrics', 'port'), ('metrics', 'https_port'), ('sites', 'webdav_port')):
|
||||||
port = _get(cfg, 'metrics', option)
|
port = _get(cfg, section_name, option)
|
||||||
if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
|
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:
|
if port in taken:
|
||||||
raise ConfigError(f'metrics.{option}: {port} is the port of {taken[port]}')
|
raise ConfigError(f'{section_name}.{option}: {port} is the port of {taken[port]}')
|
||||||
taken[port] = f'metrics.{option}'
|
taken[port] = f'{section_name}.{option}'
|
||||||
|
|
||||||
metrics_listen = _get(cfg, 'metrics', 'listen')
|
metrics_listen = _get(cfg, 'metrics', 'listen')
|
||||||
if metrics_listen not in LISTEN_CHOICES:
|
if metrics_listen not in LISTEN_CHOICES:
|
||||||
@@ -746,6 +844,21 @@ def pki_base_urls(cfg):
|
|||||||
return [bases[item] for item in _get_list(cfg, 'pki', 'publish') if item in bases] if fqdn else []
|
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):
|
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."""
|
"""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()
|
psk_file = connection['psk_file'].strip()
|
||||||
@@ -854,6 +967,10 @@ def build_context(cfg, public_ip=None):
|
|||||||
openvpn_pool = _get(cfg, 'openvpn', 'address_pool')
|
openvpn_pool = _get(cfg, 'openvpn', 'address_pool')
|
||||||
openvpn_network = ipaddress.ip_network(openvpn_pool) if openvpn_pool else None
|
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}
|
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 = []
|
connections = []
|
||||||
for name, connection in cfg['site_to_site'].items():
|
for name, connection in cfg['site_to_site'].items():
|
||||||
@@ -897,9 +1014,13 @@ def build_context(cfg, public_ip=None):
|
|||||||
'p2s_enabled': _get(cfg, 'p2s', 'enabled'),
|
'p2s_enabled': _get(cfg, 'p2s', 'enabled'),
|
||||||
'p2s_address_pool': _get(cfg, 'p2s', 'address_pool'),
|
'p2s_address_pool': _get(cfg, 'p2s', 'address_pool'),
|
||||||
'public_ip': public_ip or '',
|
'public_ip': public_ip or '',
|
||||||
'p2s_dns': _join(local_addrs, _split_list(extras['EXTRA_P2S_DNS'])),
|
'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_enabled': _get(cfg, 'wireguard', 'enabled'),
|
||||||
'wg_address': _get(cfg, 'wireguard', 'address'),
|
'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_listen_port': _get(cfg, 'wireguard', 'listen_port'),
|
||||||
'wg_peers_begin': WG_PEERS_BEGIN,
|
'wg_peers_begin': WG_PEERS_BEGIN,
|
||||||
'wg_peers_end': WG_PEERS_END,
|
'wg_peers_end': WG_PEERS_END,
|
||||||
@@ -915,7 +1036,7 @@ def build_context(cfg, public_ip=None):
|
|||||||
'openvpn_ldap_conf': str(OPENVPN_LDAP),
|
'openvpn_ldap_conf': str(OPENVPN_LDAP),
|
||||||
'openvpn_status': vpnrouter_status.OPENVPN_STATUS,
|
'openvpn_status': vpnrouter_status.OPENVPN_STATUS,
|
||||||
'openvpn_status_seconds': vpnrouter_status.OPENVPN_STATUS_SECONDS,
|
'openvpn_status_seconds': vpnrouter_status.OPENVPN_STATUS_SECONDS,
|
||||||
'p2s_dns_list': _split_list(_join(local_addrs, _split_list(extras['EXTRA_P2S_DNS']))),
|
'p2s_dns_list': _split_list(_join(resolver_addrs, _split_list(extras['EXTRA_P2S_DNS']))),
|
||||||
'openvpn_ca': str(vpnrouter_pki.OPENVPN_CA),
|
'openvpn_ca': str(vpnrouter_pki.OPENVPN_CA),
|
||||||
'openvpn_crl': str(vpnrouter_pki.OPENVPN_CRL),
|
'openvpn_crl': str(vpnrouter_pki.OPENVPN_CRL),
|
||||||
'server_cert': str(vpnrouter_pki.VPN_SERVER_CERT),
|
'server_cert': str(vpnrouter_pki.VPN_SERVER_CERT),
|
||||||
@@ -927,6 +1048,7 @@ def build_context(cfg, public_ip=None):
|
|||||||
'web_port': _get(cfg, 'web', 'port'),
|
'web_port': _get(cfg, 'web', 'port'),
|
||||||
'https_enabled': _get(cfg, 'https', 'enabled'),
|
'https_enabled': _get(cfg, 'https', 'enabled'),
|
||||||
'https_email': _get(cfg, 'https', 'email'),
|
'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_web_port': _get(cfg, 'https', 'web_port'),
|
||||||
'https_manager_port': _get(cfg, 'https', 'manager_port'),
|
'https_manager_port': _get(cfg, 'https', 'manager_port'),
|
||||||
'pki_publish': _get_list(cfg, 'pki', 'publish'),
|
'pki_publish': _get_list(cfg, 'pki', 'publish'),
|
||||||
@@ -935,6 +1057,11 @@ def build_context(cfg, public_ip=None):
|
|||||||
'metrics_port': _get(cfg, 'metrics', 'port'),
|
'metrics_port': _get(cfg, 'metrics', 'port'),
|
||||||
'metrics_https_port': _get(cfg, 'metrics', 'https_port'),
|
'metrics_https_port': _get(cfg, 'metrics', 'https_port'),
|
||||||
'metrics_service_account': _get(cfg, 'metrics', 'service_account'),
|
'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,
|
'loopback': LOOPBACK,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -951,7 +1078,7 @@ def routing_ready(ctx):
|
|||||||
|
|
||||||
|
|
||||||
def p2s_ready(ctx):
|
def p2s_ready(ctx):
|
||||||
"""Road-warrior access needs its settings and the router's VPN server certificate, which the P2S CA issues."""
|
"""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'])
|
return bool(ctx['p2s_enabled'] and ctx['p2s_address_pool'] and ctx['local_fqdn'] and ctx['server_certificate'])
|
||||||
|
|
||||||
|
|
||||||
@@ -974,6 +1101,16 @@ def metrics_ready(ctx):
|
|||||||
return bool(ctx['metrics_enabled'])
|
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():
|
def _jinja_env():
|
||||||
return jinja2.Environment(
|
return jinja2.Environment(
|
||||||
loader=jinja2.FileSystemLoader(str(TEMPLATE_DIR)),
|
loader=jinja2.FileSystemLoader(str(TEMPLATE_DIR)),
|
||||||
@@ -1067,7 +1204,7 @@ def configure(cfg=None, public_ip=None):
|
|||||||
"""Render configuration files and provision PKI.
|
"""Render configuration files and provision PKI.
|
||||||
|
|
||||||
Returns (ctx, changed) where changed is a set naming what needs reloading:
|
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:
|
if cfg is None:
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
@@ -1124,11 +1261,15 @@ def configure(cfg=None, public_ip=None):
|
|||||||
elif remove(ROAD_WARRIOR):
|
elif remove(ROAD_WARRIOR):
|
||||||
changed.add('swanctl')
|
changed.add('swanctl')
|
||||||
|
|
||||||
if p2s_ready(ctx) or openvpn_ready(ctx):
|
# The resolver of the remote access clients is dnsmasq on the addresses of p2s.dns_listen; systemd-resolved keeps
|
||||||
if render(env, ctx, 'p2s-forwarder.conf.j2', P2S_FORWARDER, 0o644):
|
# its loopback stub only, so its records of the router's own name stay on the router.
|
||||||
changed.add('resolved')
|
if remove(P2S_FORWARDER):
|
||||||
elif remove(P2S_FORWARDER):
|
|
||||||
changed.add('resolved')
|
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 wg_ready(ctx):
|
||||||
if render_wg_conf(env, 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
|
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.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.
|
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
|
import base64
|
||||||
@@ -26,6 +29,7 @@ import psycopg
|
|||||||
|
|
||||||
WEB_KEY = pathlib.Path('/etc/vpn-router/web.key')
|
WEB_KEY = pathlib.Path('/etc/vpn-router/web.key')
|
||||||
DATABASE_SECRET = pathlib.Path('/etc/vpn-router/database.secret')
|
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.
|
#: The role and the database provision() creates when web.database is not set.
|
||||||
DATABASE = 'vpn-router'
|
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)'
|
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):
|
class DatabaseError(Exception):
|
||||||
"""The database of the refresh tokens failed."""
|
"""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}
|
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):
|
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()
|
configured = cfg['web']['database'].strip()
|
||||||
if configured:
|
if configured:
|
||||||
return configured
|
return configured
|
||||||
password = DATABASE_SECRET.read_text(encoding='utf-8').strip()
|
return local_connection(DATABASE, DATABASE_SECRET)
|
||||||
return f'host=localhost dbname={DATABASE} user={DATABASE} password={password}'
|
|
||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def database(cfg):
|
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:
|
try:
|
||||||
with psycopg.connect(connection_string(cfg), autocommit=True) as connection:
|
with psycopg.connect(connection_string(cfg), autocommit=True) as connection:
|
||||||
connection.execute(SCHEMA)
|
connection.execute(SCHEMA)
|
||||||
|
if oidc is not None:
|
||||||
|
connection.execute(oidc)
|
||||||
|
_oidc_loaded = True
|
||||||
yield connection
|
yield connection
|
||||||
except psycopg.Error as exc:
|
except psycopg.Error as exc:
|
||||||
raise DatabaseError(f'the token database failed: {exc}') from exc
|
raise DatabaseError(f'the token database failed: {exc}') from exc
|
||||||
@@ -173,9 +195,10 @@ def take(cfg, token):
|
|||||||
|
|
||||||
|
|
||||||
def revoke(cfg, name):
|
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:
|
with database(cfg) as connection:
|
||||||
connection.execute('DELETE FROM refresh_tokens WHERE name = %s', (name,))
|
connection.execute('DELETE FROM refresh_tokens WHERE name = %s', (name,))
|
||||||
|
connection.execute('CALL oidc_revoke(%s)', (name,))
|
||||||
|
|
||||||
|
|
||||||
def psql(sql, name):
|
def psql(sql, name):
|
||||||
@@ -187,21 +210,33 @@ def psql(sql, name):
|
|||||||
return result.stdout.strip()
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
def provision(cfg):
|
def provision_database(name, secret):
|
||||||
"""Create the role, its password file and the database when web.database is not set and they are missing.
|
"""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.
|
The password, of URL-safe characters, reaches psql on standard input, not on its command line.
|
||||||
"""
|
"""
|
||||||
if cfg['web']['database'].strip():
|
if not psql("SELECT 1 FROM pg_roles WHERE rolname = :'v1'", name):
|
||||||
return
|
secret.unlink(missing_ok=True)
|
||||||
if not psql("SELECT 1 FROM pg_roles WHERE rolname = :'v1'", DATABASE):
|
psql("CREATE ROLE :\"v1\" LOGIN", name)
|
||||||
DATABASE_SECRET.unlink(missing_ok=True)
|
if not secret.is_file():
|
||||||
psql("CREATE ROLE :\"v1\" LOGIN", DATABASE)
|
|
||||||
if not DATABASE_SECRET.is_file():
|
|
||||||
password = secrets.token_urlsafe(32)
|
password = secrets.token_urlsafe(32)
|
||||||
psql(f"ALTER ROLE :\"v1\" PASSWORD '{password}'", DATABASE)
|
psql(f"ALTER ROLE :\"v1\" PASSWORD '{password}'", name)
|
||||||
DATABASE_SECRET.touch(mode=0o600)
|
secret.touch(mode=0o600)
|
||||||
DATABASE_SECRET.chmod(0o600)
|
secret.chmod(0o600)
|
||||||
DATABASE_SECRET.write_text(password + '\n', encoding='utf-8')
|
secret.write_text(password + '\n', encoding='utf-8')
|
||||||
if not psql("SELECT 1 FROM pg_database WHERE datname = :'v1'", DATABASE):
|
if not psql("SELECT 1 FROM pg_database WHERE datname = :'v1'", name):
|
||||||
psql("CREATE DATABASE :\"v1\" OWNER :\"v1\"", DATABASE)
|
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)
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
"""The backups of the router, for vpn-router-backup and the web service.
|
"""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
|
A backup is a consistent copy of the state that setup cannot make again, staged in STAGING: the
|
||||||
trees of TREES, the files of site_to_site psk_file outside them under files/, the directory as
|
trees of TREES, the folders of the web sites among them, the files of site_to_site psk_file outside them under files/,
|
||||||
slapcat writes it (ldap/vpn-router.ldif), with backup.metrics the database as pg_dump writes it
|
the directory as slapcat writes it (ldap/vpn-router.ldif), with backup.metrics the database as pg_dump writes it
|
||||||
(postgresql/vpn-router.dump), and manifest.json. The trees and the directory are copied while
|
(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
|
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.
|
match; pg_dump reads a snapshot of its own.
|
||||||
|
|
||||||
@@ -33,7 +34,9 @@ import time
|
|||||||
|
|
||||||
import vpnrouter
|
import vpnrouter
|
||||||
import vpnrouter_auth
|
import vpnrouter_auth
|
||||||
|
import vpnrouter_dns
|
||||||
import vpnrouter_ldap
|
import vpnrouter_ldap
|
||||||
|
import vpnrouter_sites
|
||||||
import vpnrouter_users
|
import vpnrouter_users
|
||||||
|
|
||||||
STATE = pathlib.Path('/var/lib/vpn-router/backup.json')
|
STATE = pathlib.Path('/var/lib/vpn-router/backup.json')
|
||||||
@@ -49,7 +52,12 @@ UNIT = 'vpn-router-backup.service'
|
|||||||
TIMER = 'vpn-router-backup.timer'
|
TIMER = 'vpn-router-backup.timer'
|
||||||
|
|
||||||
#: The state a backup copies: directories and files, each where it is on the system.
|
#: 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')
|
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.
|
#: 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$')
|
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$')
|
||||||
@@ -64,7 +72,8 @@ PERIODS = {'keep_hourly': '%Y-%m-%d %H', 'keep_daily': '%Y-%m-%d', 'keep_weekly'
|
|||||||
'keep_yearly': '%Y'}
|
'keep_yearly': '%Y'}
|
||||||
|
|
||||||
#: The units a restore stops while it replaces the directory and the database, and the ones it restarts after setup.
|
#: 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', 'slapd.service')
|
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',
|
RESTORE_RESTARTS = ('wg-quick@wg0.service', 'openvpn-server@vpn-router.service', 'strongswan.service',
|
||||||
'vpn-router-manager.service')
|
'vpn-router-manager.service')
|
||||||
|
|
||||||
@@ -223,14 +232,16 @@ def stage(cfg):
|
|||||||
(STAGING / LDIF).parent.mkdir()
|
(STAGING / LDIF).parent.mkdir()
|
||||||
command(['slapcat', '-b', vpnrouter_ldap.BASE, '-l', str(STAGING / LDIF)])
|
command(['slapcat', '-b', vpnrouter_ldap.BASE, '-l', str(STAGING / LDIF)])
|
||||||
items = trees + [f'files{name}' for name in files] + [LDIF]
|
items = trees + [f'files{name}' for name in files] + [LDIF]
|
||||||
if cfg['backup']['metrics']:
|
dumps = {**({DUMP: database_args(cfg)} if cfg['backup']['metrics'] else {}),
|
||||||
(STAGING / DUMP).parent.mkdir()
|
**{item: (['runuser', '-u', 'postgres', '--'], [f'--dbname={name}'])
|
||||||
prefix, database = database_args(cfg)
|
for item, (name, secret) in DUMPS.items() if secret.is_file()}}
|
||||||
with open(STAGING / DUMP, 'wb') as dump:
|
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)
|
result = subprocess.run([*prefix, 'pg_dump', '-Fc', *database], stdout=dump, stderr=subprocess.PIPE)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise Failure(f'pg_dump failed: {result.stderr.decode(errors="replace").strip()}')
|
raise Failure(f'pg_dump for {item} failed: {result.stderr.decode(errors="replace").strip()}')
|
||||||
items.append(DUMP)
|
items.append(item)
|
||||||
version = subprocess.run(['dpkg-query', '-W', '-f=${Version}', 'vpn-router'], capture_output=True, text=True)
|
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,
|
manifest = {'host': host(), 'time': now(), 'version': version.stdout.strip() or None,
|
||||||
'schedule': cfg['backup']['schedule'], 'items': items}
|
'schedule': cfg['backup']['schedule'], 'items': items}
|
||||||
@@ -389,19 +400,22 @@ def restore(source, pbs, confirm):
|
|||||||
subprocess.run(['systemctl', 'start', 'slapd.service'], capture_output=True)
|
subprocess.run(['systemctl', 'start', 'slapd.service'], capture_output=True)
|
||||||
restored = vpnrouter.load_config()
|
restored = vpnrouter.load_config()
|
||||||
if not restored['web']['database'].strip():
|
if not restored['web']['database'].strip():
|
||||||
secret = vpnrouter_auth.DATABASE_SECRET
|
vpnrouter_auth.reset_password(vpnrouter_auth.DATABASE, vpnrouter_auth.DATABASE_SECRET)
|
||||||
if secret.is_file() and vpnrouter_auth.psql("SELECT 1 FROM pg_roles WHERE rolname = :'v1'",
|
|
||||||
vpnrouter_auth.DATABASE):
|
|
||||||
password = secret.read_text(encoding='utf-8').strip()
|
|
||||||
vpnrouter_auth.psql(f"ALTER ROLE :\"v1\" PASSWORD '{password}'", vpnrouter_auth.DATABASE)
|
|
||||||
vpnrouter_auth.provision(restored)
|
vpnrouter_auth.provision(restored)
|
||||||
if DUMP in manifest['items']:
|
dumps = {DUMP: database_args(restored)}
|
||||||
prefix, database = database_args(restored)
|
for item, (name, secret) in DUMPS.items():
|
||||||
with open(RESTORE_DIR / DUMP, 'rb') as dump:
|
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,
|
result = subprocess.run([*prefix, 'pg_restore', '--clean', '--if-exists', *database], stdin=dump,
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise Failure(f'pg_restore failed: {result.stderr.decode(errors="replace").strip()}')
|
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)
|
subprocess.run(['systemctl', 'start', 'vpn-router-web.service'], capture_output=True)
|
||||||
command(['systemctl', 'restart', 'vpn-router-setup.service'])
|
command(['systemctl', 'restart', 'vpn-router-setup.service'])
|
||||||
for unit in RESTORE_RESTARTS:
|
for unit in RESTORE_RESTARTS:
|
||||||
|
|||||||
@@ -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',
|
||||||
|
'',
|
||||||
|
])
|
||||||
@@ -18,6 +18,9 @@ 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
|
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.
|
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
|
import os
|
||||||
@@ -39,6 +42,7 @@ GROUPS = f'ou=groups,{BASE}'
|
|||||||
SERVICES = f'ou=services,{BASE}'
|
SERVICES = f'ou=services,{BASE}'
|
||||||
POLICIES = f'ou=policies,{BASE}'
|
POLICIES = f'ou=policies,{BASE}'
|
||||||
PKI = f'ou=pki,{BASE}'
|
PKI = f'ou=pki,{BASE}'
|
||||||
|
ROLES = f'ou=roles,{BASE}'
|
||||||
DEFAULT_POLICY = f'cn=default,{POLICIES}'
|
DEFAULT_POLICY = f'cn=default,{POLICIES}'
|
||||||
TOTP_PARAMS = f'cn=totp,{POLICIES}'
|
TOTP_PARAMS = f'cn=totp,{POLICIES}'
|
||||||
MANAGER = f'cn=user-manager,{SERVICES}'
|
MANAGER = f'cn=user-manager,{SERVICES}'
|
||||||
@@ -68,7 +72,15 @@ OVERLAYS = ('ppolicy', 'otp')
|
|||||||
#: vpnRouterConsoleAccount, whether the user may use a console and the local account it runs as,
|
#: 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
|
#: 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
|
#: WireGuard peer of the user as "<public key> <IP>[,<IP>...]", and vpnRouterPasskey, each a
|
||||||
#: passkey of the user as JSON (vpnrouter_passkeys). vpnRouterAuthority is a CA of the PKI: its
|
#: 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
|
#: 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,
|
#: 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
|
#: and when it was retired. vpnRouterCertificate is a certificate a CA issued, named by its
|
||||||
@@ -103,17 +115,35 @@ SCHEMA_ATTRIBUTE_TYPES = (
|
|||||||
"EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1024} )",
|
"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' "
|
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} )",
|
"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 = (
|
SCHEMA_OBJECT_CLASSES = (
|
||||||
f"( {SCHEMA_OID}.2.1 NAME 'vpnRouterAccount' DESC 'VPN router: properties of a user or a Manager-only account' "
|
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 $ displayName $ description $ vpnRouterConsoleLogin $ "
|
"SUP top AUXILIARY MAY ( mail $ vpnRouterScope $ displayName $ description $ vpnRouterConsoleLogin $ "
|
||||||
"vpnRouterConsoleAccount $ vpnRouterWireGuardPeer $ vpnRouterPasskey ) )",
|
"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 "
|
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 $ "
|
"MAY ( cACertificate $ certificateRevocationList $ vpnRouterPrivateKey $ vpnRouterCertificateRequest $ "
|
||||||
"vpnRouterCRLNumber $ vpnRouterCRLSource $ vpnRouterRetired ) )",
|
"vpnRouterCRLNumber $ vpnRouterCRLSource $ vpnRouterRetired ) )",
|
||||||
f"( {SCHEMA_OID}.2.3 NAME 'vpnRouterCertificate' DESC 'VPN router: a certificate a CA of the PKI issued' SUP top "
|
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 $ "
|
"STRUCTURAL MUST serialNumber MAY ( cn $ userCertificate $ vpnRouterPrivateKey $ vpnRouterKind $ vpnRouterTemplate $ "
|
||||||
"vpnRouterSubjectAltName $ vpnRouterNotAfter $ vpnRouterRevoked $ vpnRouterRevocationReason ) )",
|
"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.
|
#: The TOTP parameters: digits, the OID of HMAC-SHA1, seconds per time step, and the time steps accepted before and after now.
|
||||||
@@ -142,6 +172,7 @@ ACCESS = (
|
|||||||
f'to attrs=oathSecret by dn.exact="{MANAGER}" =w 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="{USERS}" by dn.exact="{MANAGER}" manage by * read',
|
||||||
f'to dn.subtree="{GROUPS}" 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',
|
'to * by * read',
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -323,7 +354,7 @@ def provision():
|
|||||||
database = _ensure_database(connection)
|
database = _ensure_database(connection)
|
||||||
_ensure_overlays(connection, database)
|
_ensure_overlays(connection, database)
|
||||||
_ensure_entry(connection, BASE, {'objectClass': [b'dcObject', b'organization'], 'dc': [b'vpn-router'], 'o': [b'vpn-router']})
|
_ensure_entry(connection, BASE, {'objectClass': [b'dcObject', b'organization'], 'dc': [b'vpn-router'], 'o': [b'vpn-router']})
|
||||||
for unit in (USERS, GROUPS, SERVICES, POLICIES, PKI):
|
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, unit, {'objectClass': [b'organizationalUnit'], 'ou': [unit.split(',')[0][3:].encode()]})
|
||||||
_ensure_entry(connection, DEFAULT_POLICY, {
|
_ensure_entry(connection, DEFAULT_POLICY, {
|
||||||
'objectClass': [b'organizationalRole', b'pwdPolicy'],
|
'objectClass': [b'organizationalRole', b'pwdPolicy'],
|
||||||
|
|||||||
@@ -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)})
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
"""The passkeys of the router's users, for signing in to the Router Manager, with python3-fido2.
|
"""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
|
A passkey is a discoverable WebAuthn credential with user verification, of the relying party
|
||||||
wan.local_fqdn, so the browser offers it at the Router Manager's address on that name over HTTPS.
|
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
|
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
|
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
|
credential the attested credential data (with the public key), both base64url. A user has at
|
||||||
most PASSKEYS_MAX passkeys; service accounts have none.
|
most PASSKEYS_MAX passkeys; service accounts have none.
|
||||||
|
|
||||||
Registration and sign-in are two steps each: begin answers the options for the browser and a
|
Registration, sign-in and the proof of a signed-in user for a shell are two steps each: begin
|
||||||
pending id, whose state the web service keeps in memory for PENDING_SECONDS; complete takes the
|
answers the options for the browser and a pending id, whose state the web service keeps in memory
|
||||||
id and the browser's answer. A request that cannot be done raises vpnrouter_users.Failure, with
|
for PENDING_SECONDS; complete takes the id and the browser's answer. A request that cannot be done
|
||||||
the statuses of that module; a sign-in that fails gives status 5 whatever the reason.
|
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 json
|
||||||
@@ -171,3 +173,30 @@ def login_complete(connection, pending, response):
|
|||||||
except (ValueError, KeyError, TypeError):
|
except (ValueError, KeyError, TypeError):
|
||||||
raise refused
|
raise refused
|
||||||
return name
|
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
|
||||||
@@ -722,7 +722,7 @@ def install_crls(connection):
|
|||||||
crls = {}
|
crls = {}
|
||||||
for name, file in SWANCTL_CA_FILES.items():
|
for name, file in SWANCTL_CA_FILES.items():
|
||||||
entry = authority(connection, name)
|
entry = authority(connection, name)
|
||||||
crls[name] = entry['crl'].public_bytes(serialization.Encoding.PEM) if entry and entry['crl'] else None
|
crls[name] = entry['crl'].public_bytes(serialization.Encoding.PEM) if entry and entry['crl'] is not None else None
|
||||||
changed |= write(X509CRL_DIR / file, crls[name], 0o644) if crls[name] else remove(X509CRL_DIR / file)
|
changed |= write(X509CRL_DIR / file, crls[name], 0o644) if crls[name] else remove(X509CRL_DIR / file)
|
||||||
if crls['root'] and crls['p2s']:
|
if crls['root'] and crls['p2s']:
|
||||||
changed |= write(OPENVPN_CRL, crls['root'] + crls['p2s'], 0o644)
|
changed |= write(OPENVPN_CRL, crls['root'] + crls['p2s'], 0o644)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ adding one file here. Nothing in the core has to be edited.
|
|||||||
|
|
||||||
A module may define any of:
|
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
|
EXTRA_LOCAL_TS str, added to 'local_ts' on both connections
|
||||||
apply(ctx) called once the system has been configured, to apply
|
apply(ctx) called once the system has been configured, to apply
|
||||||
platform-specific state. Must be idempotent. ctx is the
|
platform-specific state. Must be idempotent. ctx is the
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
No platform-specific configuration is currently required.
|
No platform-specific configuration is currently required.
|
||||||
|
|
||||||
The Azure platform resolver 168.63.129.16 is deliberately *not* advertised to
|
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
|
IKEv2 clients and *not* added to the traffic selectors. Clients are
|
||||||
given the router itself as their DNS server, and the router forwards through
|
given the router's addresses of p2s.dns_listen as their DNS servers, where dnsmasq
|
||||||
its own systemd-resolved via the p2s-forwarder drop-in. That resolves
|
forwards to the router's own systemd-resolved. That resolves
|
||||||
Azure-internal names without routing the wireserver address through the tunnel
|
Azure-internal names without routing the wireserver address through the tunnel
|
||||||
and without a second resolver in the pool.
|
and without a second resolver in the pool.
|
||||||
"""
|
"""
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
No platform-specific configuration is currently required.
|
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
|
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.
|
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.
|
is broken, can use it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from http.server import ThreadingHTTPServer
|
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
|
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
|
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
|
cannot be bound is tried again the next round. Every new message is logged with log. A request
|
||||||
not return.
|
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
|
servers, reported = {}, None
|
||||||
while True:
|
while True:
|
||||||
targets, reason = wanted()
|
targets, reason = wanted()
|
||||||
@@ -34,6 +47,7 @@ def serve(wanted, handler, log, interval):
|
|||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
messages.append(f'cannot listen on {target[0]}:{target[1]}: {exc}')
|
messages.append(f'cannot listen on {target[0]}:{target[1]}: {exc}')
|
||||||
continue
|
continue
|
||||||
|
server.handle_error = failed
|
||||||
thread = threading.Thread(target=server.serve_forever, name=f'server {target[0]}', daemon=True)
|
thread = threading.Thread(target=server.serve_forever, name=f'server {target[0]}', daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
servers[target] = (server, thread)
|
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()
|
||||||
@@ -15,10 +15,25 @@ internal service accounts are those the router creates and manages for its own s
|
|||||||
included (internal_accounts); vpn-router-setup keeps the metrics service's.
|
included (internal_accounts); vpn-router-setup keeps the metrics service's.
|
||||||
Users exist whether or not a P2S system is enabled.
|
Users exist whether or not a P2S system is enabled.
|
||||||
|
|
||||||
|
A service account with redirect URIs or grants is a client of the OpenID Provider (vpnrouter_oidc), its name the
|
||||||
|
client ID and its password the client secret (client_settings): redirect_uris, where the provider sends a user back
|
||||||
|
after a sign-in, post_logout_redirect_uris, after a sign-out, allowed_groups, the groups whose members may sign in to it,
|
||||||
|
every user who is not a service account when there are none, grants, those of GRANTS it may use besides the
|
||||||
|
authorization code, public_client, whether it signs no request with its secret, which a client with client_credentials
|
||||||
|
must, and client_scopes, the scopes of resources it may ask for, as <resource>:<scope>.
|
||||||
|
|
||||||
|
A service account is also a resource of the OpenID Provider, the audience of the tokens issued for it: resource_scopes
|
||||||
|
are the scopes it defines, and its roles (roles_of_resource) are entries cn=<resource>:<role> of vpnrouter_ldap.ROLES
|
||||||
|
whose members are the groups and accounts the role is assigned to. A scope a client may ask for cannot be removed, nor
|
||||||
|
the resource while a client may ask for its scopes; removing an account removes its roles and takes it out of the
|
||||||
|
others, and removing or renaming a group does the same in the roles.
|
||||||
|
|
||||||
A group is a groupOfNames in ou=groups with a name, which follows the rules of user names, an
|
A group is a groupOfNames in ou=groups with a name, which follows the rules of user names, an
|
||||||
optional description and at least one member; a group cannot be empty, so the last member
|
optional description and at least one member; a group cannot be empty, so the last member
|
||||||
cannot leave it and a user who is the last member of a group cannot be removed. Groups only
|
cannot leave it and a user who is the last member of a group cannot be removed. ADMIN_GROUP grants the management of
|
||||||
record membership; nothing on the router reads them.
|
the router, the allowed groups of the OpenID Provider's clients who may sign in to them, and the editors group of a web
|
||||||
|
site who may change its files (vpnrouter_sites); renaming a group renames it in those, and a group a client allows or a
|
||||||
|
site has cannot be removed.
|
||||||
|
|
||||||
A VPN user may have WireGuard peers, each a device with its public key and its IPs, in
|
A VPN user may have WireGuard peers, each a device with its public key and its IPs, in
|
||||||
vpnRouterWireGuardPeer; vpn-router-setup writes a [Peer] of wg0.conf for each peer of a user who
|
vpnRouterWireGuardPeer; vpn-router-setup writes a [Peer] of wg0.conf for each peer of a user who
|
||||||
@@ -58,15 +73,22 @@ import fcntl
|
|||||||
import grp
|
import grp
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
|
import plistlib
|
||||||
import pwd
|
import pwd
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import uuid
|
||||||
|
|
||||||
import ldap
|
import ldap
|
||||||
import ldap.filter
|
import ldap.filter
|
||||||
import ldap.modlist
|
import ldap.modlist
|
||||||
import pyotp
|
import pyotp
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec, rsa
|
||||||
|
from cryptography.hazmat.primitives.serialization import pkcs12
|
||||||
|
|
||||||
import vpnrouter
|
import vpnrouter
|
||||||
import vpnrouter_ldap
|
import vpnrouter_ldap
|
||||||
@@ -74,6 +96,8 @@ import vpnrouter_pki
|
|||||||
|
|
||||||
#: A user name is one lowercase DNS label, as it is also the common name of the certificate.
|
#: A user name is one lowercase DNS label, as it is also the common name of the certificate.
|
||||||
NAME = re.compile(r'^[a-z][a-z0-9-]{0,30}[a-z0-9]$|^[a-z]$')
|
NAME = re.compile(r'^[a-z][a-z0-9-]{0,30}[a-z0-9]$|^[a-z]$')
|
||||||
|
#: The other name of a service account: a GUID in lowercase hexadecimal, as the Router Manager proposes one.
|
||||||
|
GUID = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')
|
||||||
PASSWORD_MIN = 8
|
PASSWORD_MIN = 8
|
||||||
PASSWORD_MAX = 128
|
PASSWORD_MAX = 128
|
||||||
#: The longest first or last name, in characters.
|
#: The longest first or last name, in characters.
|
||||||
@@ -88,7 +112,26 @@ LABELS = (('display_name', 'displayName'), ('description', 'description'))
|
|||||||
#: The attributes read for a user.
|
#: The attributes read for a user.
|
||||||
ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken', 'vpnRouterScope',
|
ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken', 'vpnRouterScope',
|
||||||
'displayName', 'description', 'userCertificate;binary', 'vpnRouterConsoleLogin', 'vpnRouterConsoleAccount',
|
'displayName', 'description', 'userCertificate;binary', 'vpnRouterConsoleLogin', 'vpnRouterConsoleAccount',
|
||||||
'uidNumber', 'vpnRouterWireGuardPeer', 'vpnRouterPasskey']
|
'uidNumber', 'vpnRouterWireGuardPeer', 'vpnRouterPasskey', 'vpnRouterRedirectUri',
|
||||||
|
'vpnRouterPostLogoutRedirectUri', 'vpnRouterAllowedGroup', 'vpnRouterGrantType', 'vpnRouterPublicClient',
|
||||||
|
'vpnRouterClientScope', 'vpnRouterResourceScope']
|
||||||
|
#: The grants an OpenID Provider client may have besides the authorization code, which its redirect URIs give it.
|
||||||
|
GRANTS = ('client_credentials', 'device_code')
|
||||||
|
#: The name of a role of a resource, and of a scope a resource defines.
|
||||||
|
ROLE_NAME = re.compile(r'^[A-Za-z][A-Za-z0-9._-]{0,63}$')
|
||||||
|
SCOPE_NAME = re.compile(r'^[a-z][a-z0-9._-]{0,63}$')
|
||||||
|
#: The attributes read for a role.
|
||||||
|
ROLE_ATTRIBUTES = ['cn', 'description', 'member']
|
||||||
|
#: The keys of the URIs of an OpenID Provider client and their LDAP attributes.
|
||||||
|
CLIENT_URIS = (('redirect_uris', 'vpnRouterRedirectUri'), ('post_logout_redirect_uris', 'vpnRouterPostLogoutRedirectUri'))
|
||||||
|
#: The keys of the OpenID Provider settings of a service account (client_settings, and roles of set_roles).
|
||||||
|
CLIENT_KEYS = ('redirect_uris', 'post_logout_redirect_uris', 'allowed_groups', 'grants', 'public_client', 'client_scopes',
|
||||||
|
'resource_scopes', 'roles')
|
||||||
|
#: The most URIs of each kind and allowed groups of one client, and the longest URI.
|
||||||
|
CLIENT_VALUES_MAX = 16
|
||||||
|
URI_MAX = 1024
|
||||||
|
#: The hosts a client's URI may name over plain HTTP: those of the system it runs on.
|
||||||
|
LOOPBACK_HOSTS = ('localhost', '127.0.0.1', '::1')
|
||||||
#: The most WireGuard peers of one user.
|
#: The most WireGuard peers of one user.
|
||||||
WIREGUARD_PEERS_MAX = 16
|
WIREGUARD_PEERS_MAX = 16
|
||||||
#: The vpnRouterScope of a service account.
|
#: The vpnRouterScope of a service account.
|
||||||
@@ -135,9 +178,11 @@ def session():
|
|||||||
connection.unbind_s()
|
connection.unbind_s()
|
||||||
|
|
||||||
|
|
||||||
def check_name(name):
|
def check_name(name, service=True):
|
||||||
if not isinstance(name, str) or not NAME.match(name):
|
"""Refuse a name that is not a user name (NAME), or, with service, the GUID of a service account either."""
|
||||||
raise Failure('a user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter', 2)
|
if not isinstance(name, str) or not (NAME.match(name) or service and GUID.match(name)):
|
||||||
|
raise Failure('a user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter'
|
||||||
|
+ (', or, for a service account, a GUID in lowercase' if service else ''), 2)
|
||||||
|
|
||||||
|
|
||||||
def check_password(value):
|
def check_password(value):
|
||||||
@@ -191,6 +236,104 @@ def labels(values):
|
|||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def client_settings(connection, values):
|
||||||
|
"""The OpenID Provider settings of a service account in values ({redirect_uris, post_logout_redirect_uris,
|
||||||
|
allowed_groups, grants, public_client, client_scopes, resource_scopes}), checked, as {LDAP attribute: [value, ...]};
|
||||||
|
a key whose value is None is not given, [] or false means none.
|
||||||
|
|
||||||
|
A URI is absolute, has no fragment and uses https, or http for a host of LOOPBACK_HOSTS; an allowed group exists; a
|
||||||
|
grant is one of GRANTS; a client scope is a scope of a resource, <resource>:<scope>; a resource scope is a
|
||||||
|
SCOPE_NAME. Each list has at most CLIENT_VALUES_MAX values.
|
||||||
|
"""
|
||||||
|
found = {}
|
||||||
|
for key, attribute in CLIENT_URIS:
|
||||||
|
uris = values.get(key)
|
||||||
|
if uris is None:
|
||||||
|
continue
|
||||||
|
if not isinstance(uris, list) or len(uris) > CLIENT_VALUES_MAX or not all(isinstance(uri, str) for uri in uris):
|
||||||
|
raise Failure(f'{key} must be a list of at most {CLIENT_VALUES_MAX} URIs', 2)
|
||||||
|
for uri in uris:
|
||||||
|
try:
|
||||||
|
parts = urllib.parse.urlsplit(uri)
|
||||||
|
host = parts.hostname
|
||||||
|
except ValueError:
|
||||||
|
host = None
|
||||||
|
if len(uri) > URI_MAX or not host or parts.fragment or any(character.isspace() for character in uri) or \
|
||||||
|
not (parts.scheme == 'https' or parts.scheme == 'http' and host in LOOPBACK_HOSTS):
|
||||||
|
raise Failure(f'{uri!r} is not an https URI, or an http URI of localhost, without a fragment and of at '
|
||||||
|
f'most {URI_MAX} characters', 2)
|
||||||
|
found[attribute] = [uri.encode() for uri in dict.fromkeys(uris)]
|
||||||
|
allowed = values.get('allowed_groups')
|
||||||
|
if allowed is not None:
|
||||||
|
if not isinstance(allowed, list) or len(allowed) > CLIENT_VALUES_MAX:
|
||||||
|
raise Failure(f'allowed_groups must be a list of at most {CLIENT_VALUES_MAX} group names', 2)
|
||||||
|
for group in allowed:
|
||||||
|
group_entry(connection, group)
|
||||||
|
found['vpnRouterAllowedGroup'] = [group_dn(group).encode() for group in sorted(set(allowed))]
|
||||||
|
grants = values.get('grants')
|
||||||
|
if grants is not None:
|
||||||
|
if not isinstance(grants, list) or not all(isinstance(grant, str) and grant in GRANTS for grant in grants):
|
||||||
|
raise Failure(f'grants must be a list of {", ".join(GRANTS)}', 2)
|
||||||
|
found['vpnRouterGrantType'] = [grant.encode() for grant in GRANTS if grant in grants]
|
||||||
|
public = values.get('public_client')
|
||||||
|
if public is not None:
|
||||||
|
if not isinstance(public, bool):
|
||||||
|
raise Failure('public_client must be true or false', 2)
|
||||||
|
found['vpnRouterPublicClient'] = [b'TRUE'] if public else []
|
||||||
|
scopes = values.get('client_scopes')
|
||||||
|
if scopes is not None:
|
||||||
|
if not isinstance(scopes, list) or len(scopes) > CLIENT_VALUES_MAX or not all(isinstance(scope, str) for scope in scopes):
|
||||||
|
raise Failure(f'client_scopes must be a list of at most {CLIENT_VALUES_MAX} scopes', 2)
|
||||||
|
for scope in scopes:
|
||||||
|
resource, _, name = scope.partition(':')
|
||||||
|
if name not in resource_scopes(connection, resource):
|
||||||
|
raise Failure(f'{scope!r} is not a scope of a resource, <resource>:<scope>', 2)
|
||||||
|
found['vpnRouterClientScope'] = [scope.encode() for scope in sorted(set(scopes))]
|
||||||
|
defined = values.get('resource_scopes')
|
||||||
|
if defined is not None:
|
||||||
|
if not isinstance(defined, list) or len(defined) > CLIENT_VALUES_MAX or \
|
||||||
|
not all(isinstance(scope, str) and SCOPE_NAME.match(scope) for scope in defined):
|
||||||
|
raise Failure(f'resource_scopes must be a list of at most {CLIENT_VALUES_MAX} scope names: lowercase letters, '
|
||||||
|
'digits, dots, underscores and hyphens, starting with a letter, at most 64', 2)
|
||||||
|
found['vpnRouterResourceScope'] = [scope.encode() for scope in sorted(set(defined))]
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def refuse_client_settings(name, values):
|
||||||
|
"""Refuse OpenID Provider settings for a user who is not a service account."""
|
||||||
|
if any(values.get(key) is not None for key in CLIENT_KEYS):
|
||||||
|
raise Failure(f'{name} is not a service account; only a service account is a client or a resource of the OpenID '
|
||||||
|
'Provider', 2)
|
||||||
|
|
||||||
|
|
||||||
|
def check_client(name, current, changes):
|
||||||
|
"""Refuse the client settings an entry's attributes would have with changes ({LDAP attribute: [value, ...]}) when the
|
||||||
|
client is public and has client_credentials, which needs its secret."""
|
||||||
|
merged = {**current, **changes}
|
||||||
|
if merged.get('vpnRouterPublicClient') == [b'TRUE'] and b'client_credentials' in merged.get('vpnRouterGrantType', []):
|
||||||
|
raise Failure(f'{name}: a public client has no secret, so it cannot use client_credentials', 2)
|
||||||
|
|
||||||
|
|
||||||
|
def resource_scopes(connection, name):
|
||||||
|
"""The scopes the service account defines as a resource, [] when the name is not that of a service account."""
|
||||||
|
if not isinstance(name, str) or not (NAME.match(name) or GUID.match(name)):
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
attributes = entry(connection, name)
|
||||||
|
except Failure:
|
||||||
|
return []
|
||||||
|
return [value.decode() for value in attributes.get('vpnRouterResourceScope', [])] if service(attributes) else []
|
||||||
|
|
||||||
|
|
||||||
|
def asking(connection, scopes):
|
||||||
|
"""The names of the clients whose client scopes have any of the scopes, <resource>:<scope>, sorted."""
|
||||||
|
if not scopes:
|
||||||
|
return []
|
||||||
|
condition = ''.join(f'(vpnRouterClientScope={ldap.filter.escape_filter_chars(scope)})' for scope in scopes)
|
||||||
|
found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL, f'(|{condition})', ['uid'])
|
||||||
|
return sorted(attributes['uid'][0].decode() for _, attributes in found)
|
||||||
|
|
||||||
|
|
||||||
def dn_of(name):
|
def dn_of(name):
|
||||||
return f'uid={name},{vpnrouter_ldap.USERS}'
|
return f'uid={name},{vpnrouter_ldap.USERS}'
|
||||||
|
|
||||||
@@ -242,7 +385,8 @@ def internal_accounts():
|
|||||||
def summary(attributes):
|
def summary(attributes):
|
||||||
"""A user as the answers show it; a person attribute or a label the entry does not have is None.
|
"""A user as the answers show it; a person attribute or a label the entry does not have is None.
|
||||||
|
|
||||||
internal is whether it is an internal service account (internal_accounts).
|
internal is whether it is an internal service account (internal_accounts). A service account also has its OpenID
|
||||||
|
Provider settings (client_settings), allowed_groups as group names; with_roles adds its roles.
|
||||||
"""
|
"""
|
||||||
name = attributes['uid'][0].decode()
|
name = attributes['uid'][0].decode()
|
||||||
lock = attributes.get('pwdAccountLockedTime', [None])[0]
|
lock = attributes.get('pwdAccountLockedTime', [None])[0]
|
||||||
@@ -262,9 +406,24 @@ def summary(attributes):
|
|||||||
for key, attribute in PERSON + LABELS:
|
for key, attribute in PERSON + LABELS:
|
||||||
values = attributes.get(attribute)
|
values = attributes.get(attribute)
|
||||||
user[key] = values[0].decode() if values else None
|
user[key] = values[0].decode() if values else None
|
||||||
|
if user['service']:
|
||||||
|
for key, attribute in CLIENT_URIS:
|
||||||
|
user[key] = [value.decode() for value in attributes.get(attribute, [])]
|
||||||
|
suffix = f',{vpnrouter_ldap.GROUPS}'
|
||||||
|
user['allowed_groups'] = sorted(value.decode()[3:-len(suffix)] for value in attributes.get('vpnRouterAllowedGroup', [])
|
||||||
|
if value.decode().startswith('cn=') and value.decode().endswith(suffix))
|
||||||
|
user['grants'] = [value.decode() for value in attributes.get('vpnRouterGrantType', [])]
|
||||||
|
user['public_client'] = attributes.get('vpnRouterPublicClient') == [b'TRUE']
|
||||||
|
user['client_scopes'] = [value.decode() for value in attributes.get('vpnRouterClientScope', [])]
|
||||||
|
user['resource_scopes'] = [value.decode() for value in attributes.get('vpnRouterResourceScope', [])]
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def with_roles(connection, user):
|
||||||
|
"""The summary of a service account with roles, its roles as a resource (roles_of_resource)."""
|
||||||
|
return {**user, 'roles': roles_of_resource(connection, user['name'])}
|
||||||
|
|
||||||
|
|
||||||
def console_of(attributes):
|
def console_of(attributes):
|
||||||
"""The console login of an entry as the answers show it: None when it was never set, else {enabled, account, local,
|
"""The console login of an entry as the answers show it: None when it was never set, else {enabled, account, local,
|
||||||
uid}: account the system account the console runs as, the local one of vpnRouterConsoleAccount (local true) or the
|
uid}: account the system account the console runs as, the local one of vpnRouterConsoleAccount (local true) or the
|
||||||
@@ -462,14 +621,22 @@ def list_users(connection):
|
|||||||
def add(connection, name, password, values, service=False):
|
def add(connection, name, password, values, service=False):
|
||||||
"""Add a user: a VPN user with its certificate when values gives all three person attributes, a Manager-only account
|
"""Add a user: a VPN user with its certificate when values gives all three person attributes, a Manager-only account
|
||||||
with at most the e-mail address when it gives neither name, and, with service, a service account, with at most the
|
with at most the e-mail address when it gives neither name, and, with service, a service account, with at most the
|
||||||
e-mail address and its labels (labels)."""
|
e-mail address, its labels (labels), its OpenID Provider settings (client_settings) and its roles (set_roles); only
|
||||||
check_name(name)
|
a service account may be named by a GUID."""
|
||||||
|
check_name(name, service)
|
||||||
details = person(values, required=False)
|
details = person(values, required=False)
|
||||||
vpn_user = 'givenName' in details or 'sn' in details
|
vpn_user = 'givenName' in details or 'sn' in details
|
||||||
if service and vpn_user:
|
if service and vpn_user:
|
||||||
raise Failure('a service account has no first or last name', 2)
|
raise Failure('a service account has no first or last name', 2)
|
||||||
|
client = {}
|
||||||
if service:
|
if service:
|
||||||
details.update({attribute: value for attribute, value in labels(values).items() if value})
|
details.update({attribute: value for attribute, value in labels(values).items() if value})
|
||||||
|
client = {attribute: found for attribute, found in client_settings(connection, values).items() if found}
|
||||||
|
check_client(name, {}, client)
|
||||||
|
if values.get('roles') is not None:
|
||||||
|
checked_roles(connection, values['roles'])
|
||||||
|
else:
|
||||||
|
refuse_client_settings(name, values)
|
||||||
if vpn_user and len(details) != len(PERSON):
|
if vpn_user and len(details) != len(PERSON):
|
||||||
raise Failure('give first_name, last_name and email for a VPN user, or at most email for a Manager-only account', 2)
|
raise Failure('give first_name, last_name and email for a VPN user, or at most email for a Manager-only account', 2)
|
||||||
check_password(password)
|
check_password(password)
|
||||||
@@ -484,6 +651,7 @@ def add(connection, name, password, values, service=False):
|
|||||||
if service:
|
if service:
|
||||||
record['vpnRouterScope'] = [SERVICE_SCOPE.encode()]
|
record['vpnRouterScope'] = [SERVICE_SCOPE.encode()]
|
||||||
record.update({attribute: [value.encode()] for attribute, value in details.items()})
|
record.update({attribute: [value.encode()] for attribute, value in details.items()})
|
||||||
|
record.update(client)
|
||||||
try:
|
try:
|
||||||
connection.add_s(dn_of(name), ldap.modlist.addModlist(record))
|
connection.add_s(dn_of(name), ldap.modlist.addModlist(record))
|
||||||
except ldap.ALREADY_EXISTS:
|
except ldap.ALREADY_EXISTS:
|
||||||
@@ -494,6 +662,10 @@ def add(connection, name, password, values, service=False):
|
|||||||
vpnrouter_pki.issue_user(vpnrouter.load_config(), name, details['mail'])
|
vpnrouter_pki.issue_user(vpnrouter.load_config(), name, details['mail'])
|
||||||
except vpnrouter_pki.Failure as failure:
|
except vpnrouter_pki.Failure as failure:
|
||||||
raise Failure(f'the certificate cannot be issued: {failure}', failure.status)
|
raise Failure(f'the certificate cannot be issued: {failure}', failure.status)
|
||||||
|
if service:
|
||||||
|
if values.get('roles') is not None:
|
||||||
|
set_roles(connection, name, values['roles'])
|
||||||
|
return with_roles(connection, summary(entry(connection, name)))
|
||||||
return summary(entry(connection, name))
|
return summary(entry(connection, name))
|
||||||
|
|
||||||
|
|
||||||
@@ -507,6 +679,7 @@ def update(connection, name, values):
|
|||||||
current = entry(connection, name)
|
current = entry(connection, name)
|
||||||
if service(current):
|
if service(current):
|
||||||
return update_service(connection, name, values)
|
return update_service(connection, name, values)
|
||||||
|
refuse_client_settings(name, values)
|
||||||
if manager_only(current):
|
if manager_only(current):
|
||||||
if values.get('wireguard_peers') is not None:
|
if values.get('wireguard_peers') is not None:
|
||||||
raise Failure(f'user {name} is a Manager-only account, which has no VPN access and so no WireGuard peers', 2)
|
raise Failure(f'user {name} is a Manager-only account, which has no VPN access and so no WireGuard peers', 2)
|
||||||
@@ -547,18 +720,37 @@ def apply_wireguard():
|
|||||||
|
|
||||||
|
|
||||||
def update_service(connection, name, values):
|
def update_service(connection, name, values):
|
||||||
"""Change any of the e-mail address and the labels (labels) of a service account that values gives; '' removes one."""
|
"""Change any of the e-mail address, the labels (labels), the OpenID Provider settings (client_settings) and the
|
||||||
if not service(entry(connection, name)):
|
roles (set_roles) of a service account that values gives; '' removes the e-mail address or a label, [] the values
|
||||||
|
of a setting. A resource scope a client may ask for is not removed."""
|
||||||
|
current = entry(connection, name)
|
||||||
|
if not service(current):
|
||||||
raise Failure(f'{name} is not a service account', 3)
|
raise Failure(f'{name} is not a service account', 3)
|
||||||
|
settings = client_settings(connection, values)
|
||||||
|
check_client(name, current, settings)
|
||||||
|
if 'vpnRouterResourceScope' in settings:
|
||||||
|
removed = {value.decode() for value in current.get('vpnRouterResourceScope', [])} - \
|
||||||
|
{value.decode() for value in settings['vpnRouterResourceScope']}
|
||||||
|
clients = asking(connection, [f'{name}:{scope}' for scope in sorted(removed)])
|
||||||
|
if clients:
|
||||||
|
raise Failure(f'the clients {", ".join(clients)} may ask for the scopes of {name} being removed; take them out '
|
||||||
|
'of their client scopes first', 2)
|
||||||
|
roles = values.get('roles')
|
||||||
|
if roles is not None:
|
||||||
|
checked_roles(connection, roles)
|
||||||
changes = [(ldap.MOD_REPLACE, attribute, [value.encode()] if value else []) for attribute, value in labels(values).items()]
|
changes = [(ldap.MOD_REPLACE, attribute, [value.encode()] if value else []) for attribute, value in labels(values).items()]
|
||||||
|
changes += [(ldap.MOD_REPLACE, attribute, found) for attribute, found in settings.items()]
|
||||||
email = values.get('email')
|
email = values.get('email')
|
||||||
if email is not None:
|
if email is not None:
|
||||||
address = '' if isinstance(email, str) and email.strip() == '' else person({'email': email}, required=False)['mail']
|
address = '' if isinstance(email, str) and email.strip() == '' else person({'email': email}, required=False)['mail']
|
||||||
changes.append((ldap.MOD_REPLACE, 'mail', [address.encode()] if address else []))
|
changes.append((ldap.MOD_REPLACE, 'mail', [address.encode()] if address else []))
|
||||||
if not changes:
|
if not changes and roles is None:
|
||||||
raise Failure('give email, display_name or description', 2)
|
raise Failure(f'give email, display_name, description or {", ".join(CLIENT_KEYS)}', 2)
|
||||||
connection.modify_s(dn_of(name), changes)
|
if changes:
|
||||||
return summary(entry(connection, name))
|
connection.modify_s(dn_of(name), changes)
|
||||||
|
if roles is not None:
|
||||||
|
set_roles(connection, name, roles)
|
||||||
|
return with_roles(connection, summary(entry(connection, name)))
|
||||||
|
|
||||||
|
|
||||||
def passwd(connection, name, password):
|
def passwd(connection, name, password):
|
||||||
@@ -601,12 +793,13 @@ def unlock(connection, name):
|
|||||||
|
|
||||||
|
|
||||||
def login(name, password, code):
|
def login(name, password, code):
|
||||||
"""Check the user's password, and the TOTP code of an enrolled user, with a bind as the user.
|
"""Check the user's password, and the TOTP code of an enrolled user, with a bind as the user; a service account may be
|
||||||
|
named by a GUID.
|
||||||
|
|
||||||
A wrong name, password or code, a locked or a disabled user give the same Failure, status 5.
|
A wrong name, password or code, a locked or a disabled user give the same Failure, status 5.
|
||||||
"""
|
"""
|
||||||
refused = Failure('the name, the password or the code is wrong', 5)
|
refused = Failure('the name, the password or the code is wrong', 5)
|
||||||
if not isinstance(name, str) or not NAME.match(name) or not isinstance(password, str) or not password:
|
if not isinstance(name, str) or not (NAME.match(name) or GUID.match(name)) or not isinstance(password, str) or not password:
|
||||||
raise refused
|
raise refused
|
||||||
if code is not None and code != '' and (not isinstance(code, str) or not CODE.match(code)):
|
if code is not None and code != '' and (not isinstance(code, str) or not CODE.match(code)):
|
||||||
raise refused
|
raise refused
|
||||||
@@ -677,15 +870,24 @@ def remove(connection, name):
|
|||||||
"""Remove the user from its groups and the directory, revoke the certificate and end the sessions of its own
|
"""Remove the user from its groups and the directory, revoke the certificate and end the sessions of its own
|
||||||
posixAccount.
|
posixAccount.
|
||||||
|
|
||||||
A user who is the last member of a group is not removed.
|
A user who is the last member of a group is not removed, nor a resource whose scopes a client may ask for. The roles
|
||||||
|
of a removed service account are removed, and the account is taken out of the other roles.
|
||||||
"""
|
"""
|
||||||
current = entry(connection, name)
|
current = entry(connection, name)
|
||||||
groups = groups_of(connection, name)
|
groups = groups_of(connection, name)
|
||||||
alone = sorted(group for group, members in groups.items() if members == [name])
|
alone = sorted(group for group, members in groups.items() if members == [name])
|
||||||
if alone:
|
if alone:
|
||||||
raise Failure(f'user {name} is the last member of {", ".join(alone)}; remove the group first', 2)
|
raise Failure(f'user {name} is the last member of {", ".join(alone)}; remove the group first', 2)
|
||||||
|
clients = sorted(set(asking(connection, [f'{name}:{scope.decode()}' for scope in current.get('vpnRouterResourceScope', [])]))
|
||||||
|
- {name})
|
||||||
|
if clients:
|
||||||
|
raise Failure(f'the clients {", ".join(clients)} may ask for the scopes of {name}; take them out of their client '
|
||||||
|
'scopes first', 2)
|
||||||
for group in groups:
|
for group in groups:
|
||||||
connection.modify_s(group_dn(group), [(ldap.MOD_DELETE, 'member', [dn_of(name).encode()])])
|
connection.modify_s(group_dn(group), [(ldap.MOD_DELETE, 'member', [dn_of(name).encode()])])
|
||||||
|
for role in roles_of_resource(connection, name):
|
||||||
|
connection.delete_s(role_dn(name, role['name']))
|
||||||
|
leave_roles(connection, dn_of(name))
|
||||||
try:
|
try:
|
||||||
vpnrouter_pki.revoke_user(vpnrouter.load_config(), name)
|
vpnrouter_pki.revoke_user(vpnrouter.load_config(), name)
|
||||||
except vpnrouter_pki.Failure as failure:
|
except vpnrouter_pki.Failure as failure:
|
||||||
@@ -729,6 +931,80 @@ def profile(connection, name):
|
|||||||
return {'name': name, 'profile': text}
|
return {'name': name, 'profile': text}
|
||||||
|
|
||||||
|
|
||||||
|
def apple_profile(connection, name, password):
|
||||||
|
"""The Apple configuration profile (.mobileconfig, unsigned) of the user's IKEv2 access: the root CA and
|
||||||
|
the P2S CA, the user's certificate and key as a PKCS#12 encrypted with the password, and the VPN.
|
||||||
|
|
||||||
|
The connection matches road-warrior.conf: the router's wan.local_fqdn as address and identity, the user name, a
|
||||||
|
DNS name of the certificate, as the local identity, the certificate over EAP-TLS, and AES-256, SHA2-256 and group 14
|
||||||
|
for IKE and ESP. With p2s.dns_domains, the device resolves those domains with the router's resolver. The PKCS#12 uses 3DES and SHA-1, the only encryption Apple's clients read; they ask for the
|
||||||
|
password when the profile is installed.
|
||||||
|
"""
|
||||||
|
entry(connection, name)
|
||||||
|
if not isinstance(password, str) or not PASSWORD_MIN <= len(password) <= PASSWORD_MAX:
|
||||||
|
raise Failure(f'the password of the profile must have {PASSWORD_MIN} to {PASSWORD_MAX} characters', 2)
|
||||||
|
cfg = vpnrouter.load_config()
|
||||||
|
fqdn = cfg['wan']['local_fqdn'].strip()
|
||||||
|
if not cfg['p2s']['enabled'] or not fqdn:
|
||||||
|
raise Failure('IKEv2 access is not enabled', 2)
|
||||||
|
credentials = vpnrouter_pki.user_credentials(name)
|
||||||
|
if credentials is None:
|
||||||
|
raise Failure(f'user {name} has no certificate')
|
||||||
|
try:
|
||||||
|
chain = x509.load_pem_x509_certificates(vpnrouter_pki.bundle('p2s')['pem'].encode())
|
||||||
|
except vpnrouter_pki.Failure as failure:
|
||||||
|
raise Failure(str(failure), 4)
|
||||||
|
cert = x509.load_pem_x509_certificate(credentials[0])
|
||||||
|
key = serialization.load_pem_private_key(credentials[1], password=None)
|
||||||
|
encryption = (serialization.PrivateFormat.PKCS12.encryption_builder().kdf_rounds(2048)
|
||||||
|
.key_cert_algorithm(pkcs12.PBES.PBESv1SHA1And3KeyTripleDESCBC).hmac_hash(hashes.SHA1())
|
||||||
|
.build(password.encode()))
|
||||||
|
bundle = pkcs12.serialize_key_and_certificates(name.encode(), key, cert, None, encryption)
|
||||||
|
certificate_type = ('RSA' if isinstance(key, rsa.RSAPrivateKey)
|
||||||
|
else 'ECDSA384' if isinstance(key, ec.EllipticCurvePrivateKey) and key.curve.key_size == 384
|
||||||
|
else 'ECDSA256')
|
||||||
|
identifier = f'{".".join(reversed(fqdn.split(".")))}.vpn-router.{name}'
|
||||||
|
ids = {kind: str(uuid.uuid5(uuid.NAMESPACE_URL, f'{identifier}.{kind}')) for kind in ('profile', 'pkcs12', 'vpn')}
|
||||||
|
proposal = {'EncryptionAlgorithm': 'AES-256', 'IntegrityAlgorithm': 'SHA2-256', 'DiffieHellmanGroup': 14,
|
||||||
|
'LifeTimeInMinutes': 60}
|
||||||
|
payloads = [{'PayloadType': 'com.apple.security.root' if index == len(chain) - 1 else 'com.apple.security.pkcs1',
|
||||||
|
'PayloadVersion': 1, 'PayloadIdentifier': f'{identifier}.ca{index}',
|
||||||
|
'PayloadUUID': str(uuid.uuid5(uuid.NAMESPACE_URL, f'{identifier}.ca{index}')),
|
||||||
|
'PayloadDisplayName': vpnrouter_pki.common_name(item),
|
||||||
|
'PayloadContent': item.public_bytes(serialization.Encoding.DER)}
|
||||||
|
for index, item in enumerate(chain)]
|
||||||
|
payloads.append({'PayloadType': 'com.apple.security.pkcs12', 'PayloadVersion': 1,
|
||||||
|
'PayloadIdentifier': f'{identifier}.pkcs12', 'PayloadUUID': ids['pkcs12'],
|
||||||
|
'PayloadDisplayName': f'{name} ({fqdn})', 'PayloadContent': bundle})
|
||||||
|
# The router's resolver, which setup records, answers the names of p2s.dns_domains; the others stay with the device's.
|
||||||
|
try:
|
||||||
|
servers = json.loads(vpnrouter.WEB_STATE.read_text(encoding='utf-8'))['network']['client_dns']
|
||||||
|
except (OSError, ValueError, KeyError, TypeError):
|
||||||
|
servers = []
|
||||||
|
domains = [item.strip() for item in cfg['p2s']['dns_domains'] if item.strip()]
|
||||||
|
dns = {'DNS': {'ServerAddresses': servers, 'SupplementalMatchDomains': domains}} if servers and domains else {}
|
||||||
|
payloads.append({
|
||||||
|
**dns,
|
||||||
|
'PayloadType': 'com.apple.vpn.managed', 'PayloadVersion': 1, 'PayloadIdentifier': f'{identifier}.vpn',
|
||||||
|
'PayloadUUID': ids['vpn'], 'PayloadDisplayName': f'VPN Router {fqdn}', 'UserDefinedName': f'VPN Router {fqdn}',
|
||||||
|
'VPNType': 'IKEv2',
|
||||||
|
'IKEv2': {
|
||||||
|
'RemoteAddress': fqdn, 'RemoteIdentifier': fqdn, 'LocalIdentifier': name,
|
||||||
|
'AuthenticationMethod': 'Certificate', 'ExtendedAuthEnabled': 1, 'PayloadCertificateUUID': ids['pkcs12'],
|
||||||
|
'CertificateType': certificate_type,
|
||||||
|
'ServerCertificateIssuerCommonName': vpnrouter_pki.common_name(chain[0]), 'ServerCertificateCommonName': fqdn,
|
||||||
|
'IKESecurityAssociationParameters': {**proposal, 'LifeTimeInMinutes': 480},
|
||||||
|
'ChildSecurityAssociationParameters': proposal,
|
||||||
|
'EnablePFS': 0, 'DeadPeerDetectionRate': 'Medium', 'DisableMOBIKE': 0, 'DisableRedirect': 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
document = {'PayloadType': 'Configuration', 'PayloadVersion': 1, 'PayloadIdentifier': identifier,
|
||||||
|
'PayloadUUID': ids['profile'], 'PayloadDisplayName': f'VPN Router {fqdn} ({name})',
|
||||||
|
'PayloadDescription': f'IKEv2 access of {name} to {fqdn}', 'PayloadRemovalDisallowed': False,
|
||||||
|
'PayloadContent': payloads}
|
||||||
|
return {'name': name, 'profile': plistlib.dumps(document).decode()}
|
||||||
|
|
||||||
|
|
||||||
def group_dn(name):
|
def group_dn(name):
|
||||||
return f'cn={name},{vpnrouter_ldap.GROUPS}'
|
return f'cn={name},{vpnrouter_ldap.GROUPS}'
|
||||||
|
|
||||||
@@ -785,6 +1061,13 @@ def groups_of(connection, name):
|
|||||||
return {attributes['cn'][0].decode(): member_names(attributes) for _, attributes in found}
|
return {attributes['cn'][0].decode(): member_names(attributes) for _, attributes in found}
|
||||||
|
|
||||||
|
|
||||||
|
def allowing(connection, name):
|
||||||
|
"""The names of the OpenID Provider's clients whose allowed groups have the group, sorted."""
|
||||||
|
found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL,
|
||||||
|
f'(vpnRouterAllowedGroup={ldap.filter.escape_filter_chars(group_dn(name))})', ['uid'])
|
||||||
|
return sorted(attributes['uid'][0].decode() for _, attributes in found)
|
||||||
|
|
||||||
|
|
||||||
def list_groups(connection):
|
def list_groups(connection):
|
||||||
found = connection.search_s(vpnrouter_ldap.GROUPS, ldap.SCOPE_ONELEVEL, '(objectClass=groupOfNames)', GROUP_ATTRIBUTES)
|
found = connection.search_s(vpnrouter_ldap.GROUPS, ldap.SCOPE_ONELEVEL, '(objectClass=groupOfNames)', GROUP_ATTRIBUTES)
|
||||||
return {'groups': sorted((group_summary(attributes) for _, attributes in found), key=lambda group: group['name'])}
|
return {'groups': sorted((group_summary(attributes) for _, attributes in found), key=lambda group: group['name'])}
|
||||||
@@ -811,7 +1094,9 @@ def group_add(connection, name, description, members):
|
|||||||
|
|
||||||
def group_update(connection, name, new_name=None, description=None):
|
def group_update(connection, name, new_name=None, description=None):
|
||||||
"""Rename the group or change its description ('' removes it); at least one must be given. Renaming ADMIN_GROUP
|
"""Rename the group or change its description ('' removes it); at least one must be given. Renaming ADMIN_GROUP
|
||||||
switches the console login of its members off."""
|
switches the console login of its members off; renaming a group a client of the OpenID Provider allows renames it
|
||||||
|
there, as in the roles it is assigned, and renaming the editors group of web sites renames it in the sites."""
|
||||||
|
import vpnrouter_sites
|
||||||
if new_name is None and description is None:
|
if new_name is None and description is None:
|
||||||
raise Failure('give a new name or a description', 2)
|
raise Failure('give a new name or a description', 2)
|
||||||
members = member_names(group_entry(connection, name))
|
members = member_names(group_entry(connection, name))
|
||||||
@@ -826,6 +1111,16 @@ def group_update(connection, name, new_name=None, description=None):
|
|||||||
connection.rename_s(group_dn(name), f'cn={new_name}', delold=1)
|
connection.rename_s(group_dn(name), f'cn={new_name}', delold=1)
|
||||||
except ldap.ALREADY_EXISTS:
|
except ldap.ALREADY_EXISTS:
|
||||||
raise Failure(f'group {new_name} exists', 4)
|
raise Failure(f'group {new_name} exists', 4)
|
||||||
|
for client in allowing(connection, name):
|
||||||
|
connection.modify_s(dn_of(client), [(ldap.MOD_DELETE, 'vpnRouterAllowedGroup', [group_dn(name).encode()]),
|
||||||
|
(ldap.MOD_ADD, 'vpnRouterAllowedGroup', [group_dn(new_name).encode()])])
|
||||||
|
for dn in assigned(connection, group_dn(name)):
|
||||||
|
connection.modify_s(dn, [(ldap.MOD_DELETE, 'member', [group_dn(name).encode()]),
|
||||||
|
(ldap.MOD_ADD, 'member', [group_dn(new_name).encode()])])
|
||||||
|
try:
|
||||||
|
vpnrouter_sites.rename_group(name, new_name)
|
||||||
|
except vpnrouter_sites.DatabaseError as exc:
|
||||||
|
raise Failure(f'group {name} is renamed {new_name}, but not in the web sites: {exc}')
|
||||||
if name == ADMIN_GROUP:
|
if name == ADMIN_GROUP:
|
||||||
console_off(connection, members)
|
console_off(connection, members)
|
||||||
name = new_name
|
name = new_name
|
||||||
@@ -833,9 +1128,21 @@ def group_update(connection, name, new_name=None, description=None):
|
|||||||
|
|
||||||
|
|
||||||
def group_remove(connection, name):
|
def group_remove(connection, name):
|
||||||
"""Remove the group; removing ADMIN_GROUP switches the console login of its members off."""
|
"""Remove the group, unless a client of the OpenID Provider allows it or it is the editors group of web sites, and
|
||||||
|
take it out of the roles it is assigned; removing ADMIN_GROUP switches the console login of its members off."""
|
||||||
|
import vpnrouter_sites
|
||||||
members = member_names(group_entry(connection, name))
|
members = member_names(group_entry(connection, name))
|
||||||
|
clients = allowing(connection, name)
|
||||||
|
if clients:
|
||||||
|
raise Failure(f'group {name} is an allowed group of the clients {", ".join(clients)}; take it out of them first', 2)
|
||||||
|
try:
|
||||||
|
sites = vpnrouter_sites.sites_of_group(name)
|
||||||
|
except vpnrouter_sites.DatabaseError as exc:
|
||||||
|
raise Failure(f'the web sites cannot be read: {exc}')
|
||||||
|
if sites:
|
||||||
|
raise Failure(f'group {name} is the editors group of the web sites {", ".join(sites)}; change them first', 2)
|
||||||
connection.delete_s(group_dn(name))
|
connection.delete_s(group_dn(name))
|
||||||
|
leave_roles(connection, group_dn(name))
|
||||||
if name == ADMIN_GROUP:
|
if name == ADMIN_GROUP:
|
||||||
console_off(connection, members)
|
console_off(connection, members)
|
||||||
return {'name': name, 'removed': True}
|
return {'name': name, 'removed': True}
|
||||||
@@ -864,6 +1171,186 @@ def group_member_remove(connection, name, user):
|
|||||||
return group_summary(group_entry(connection, name))
|
return group_summary(group_entry(connection, name))
|
||||||
|
|
||||||
|
|
||||||
|
def role_dn(resource, role):
|
||||||
|
return f'cn={resource}:{role},{vpnrouter_ldap.ROLES}'
|
||||||
|
|
||||||
|
|
||||||
|
def check_resource(connection, resource):
|
||||||
|
"""Refuse a name that is not that of a service account."""
|
||||||
|
if not service(entry(connection, resource)):
|
||||||
|
raise Failure(f'{resource} is not a service account; only a service account is a resource of the OpenID Provider', 2)
|
||||||
|
|
||||||
|
|
||||||
|
def check_role_name(role):
|
||||||
|
if not isinstance(role, str) or not ROLE_NAME.match(role):
|
||||||
|
raise Failure('a role name is 1 to 64 letters, digits, dots, underscores and hyphens, starting with a letter', 2)
|
||||||
|
|
||||||
|
|
||||||
|
def role_summary(attributes):
|
||||||
|
"""A role as the answers show it: {name, description, groups, accounts}, the groups and accounts it is assigned to."""
|
||||||
|
groups, accounts = [], []
|
||||||
|
for value in attributes.get('member', []):
|
||||||
|
dn = value.decode()
|
||||||
|
if dn.startswith('cn=') and dn.endswith(f',{vpnrouter_ldap.GROUPS}'):
|
||||||
|
groups.append(dn[3:-len(f',{vpnrouter_ldap.GROUPS}')])
|
||||||
|
elif dn.startswith('uid=') and dn.endswith(f',{vpnrouter_ldap.USERS}'):
|
||||||
|
accounts.append(dn[4:-len(f',{vpnrouter_ldap.USERS}')])
|
||||||
|
values = attributes.get('description')
|
||||||
|
return {'name': attributes['cn'][0].decode().partition(':')[2], 'description': values[0].decode() if values else '',
|
||||||
|
'groups': sorted(groups), 'accounts': sorted(accounts)}
|
||||||
|
|
||||||
|
|
||||||
|
def role_search(connection, resource, condition=''):
|
||||||
|
"""The entries of the resource's roles that also match the LDAP filter condition, as [(DN, attributes)]."""
|
||||||
|
prefix = ldap.filter.escape_filter_chars(f'{resource}:')
|
||||||
|
found = connection.search_s(vpnrouter_ldap.ROLES, ldap.SCOPE_ONELEVEL,
|
||||||
|
f'(&(objectClass=vpnRouterRole)(cn={prefix}*){condition})', ROLE_ATTRIBUTES)
|
||||||
|
return [(dn, attributes) for dn, attributes in found if attributes['cn'][0].decode().startswith(f'{resource}:')]
|
||||||
|
|
||||||
|
|
||||||
|
def roles_of_resource(connection, resource):
|
||||||
|
"""The roles of the resource, sorted by name."""
|
||||||
|
return sorted((role_summary(attributes) for _, attributes in role_search(connection, resource)),
|
||||||
|
key=lambda role: role['name'])
|
||||||
|
|
||||||
|
|
||||||
|
def role_entry(connection, resource, role):
|
||||||
|
"""The attributes of the role's entry; Failure with status 3 when there is none."""
|
||||||
|
check_role_name(role)
|
||||||
|
try:
|
||||||
|
return connection.search_s(role_dn(resource, role), ldap.SCOPE_BASE, attrlist=ROLE_ATTRIBUTES)[0][1]
|
||||||
|
except ldap.NO_SUCH_OBJECT:
|
||||||
|
raise Failure(f'{resource} has no role {role}', 3)
|
||||||
|
|
||||||
|
|
||||||
|
def role_add(connection, resource, role, description=''):
|
||||||
|
"""Add a role to the resource, with a description ('' for none) and no members."""
|
||||||
|
check_resource(connection, resource)
|
||||||
|
check_role_name(role)
|
||||||
|
description = check_description(description if description is not None else '')
|
||||||
|
record = {'objectClass': [b'vpnRouterRole'], 'cn': [f'{resource}:{role}'.encode()]}
|
||||||
|
if description:
|
||||||
|
record['description'] = [description.encode()]
|
||||||
|
try:
|
||||||
|
connection.add_s(role_dn(resource, role), ldap.modlist.addModlist(record))
|
||||||
|
except ldap.ALREADY_EXISTS:
|
||||||
|
raise Failure(f'{resource} has a role {role}', 4)
|
||||||
|
return role_summary(role_entry(connection, resource, role))
|
||||||
|
|
||||||
|
|
||||||
|
def role_remove(connection, resource, role):
|
||||||
|
role_entry(connection, resource, role)
|
||||||
|
connection.delete_s(role_dn(resource, role))
|
||||||
|
return {'resource': resource, 'name': role, 'removed': True}
|
||||||
|
|
||||||
|
|
||||||
|
def member_dn(connection, group=None, account=None):
|
||||||
|
"""The DN of the existing group or account a role is assigned to; exactly one is given."""
|
||||||
|
if (group is None) == (account is None):
|
||||||
|
raise Failure('give a group or an account', 2)
|
||||||
|
if group is not None:
|
||||||
|
group_entry(connection, group)
|
||||||
|
return group_dn(group)
|
||||||
|
entry(connection, account)
|
||||||
|
return dn_of(account)
|
||||||
|
|
||||||
|
|
||||||
|
def role_member_add(connection, resource, role, group=None, account=None):
|
||||||
|
"""Assign the role to the group or the account."""
|
||||||
|
attributes = role_entry(connection, resource, role)
|
||||||
|
dn = member_dn(connection, group, account)
|
||||||
|
if dn.encode() not in attributes.get('member', []):
|
||||||
|
connection.modify_s(role_dn(resource, role), [(ldap.MOD_ADD, 'member', [dn.encode()])])
|
||||||
|
return role_summary(role_entry(connection, resource, role))
|
||||||
|
|
||||||
|
|
||||||
|
def role_member_remove(connection, resource, role, group=None, account=None):
|
||||||
|
"""Take the role from the group or the account."""
|
||||||
|
attributes = role_entry(connection, resource, role)
|
||||||
|
if (group is None) == (account is None):
|
||||||
|
raise Failure('give a group or an account', 2)
|
||||||
|
dn = group_dn(group) if group is not None else dn_of(account)
|
||||||
|
if dn.encode() not in attributes.get('member', []):
|
||||||
|
raise Failure(f'role {role} of {resource} is not assigned to {group or account}', 3)
|
||||||
|
connection.modify_s(role_dn(resource, role), [(ldap.MOD_DELETE, 'member', [dn.encode()])])
|
||||||
|
return role_summary(role_entry(connection, resource, role))
|
||||||
|
|
||||||
|
|
||||||
|
def checked_roles(connection, roles):
|
||||||
|
"""The roles of set_roles, checked: [{name, description, groups, accounts}], names unique, every group and account
|
||||||
|
existing; description, groups and accounts may be left out."""
|
||||||
|
if not isinstance(roles, list) or not all(isinstance(role, dict) for role in roles):
|
||||||
|
raise Failure('roles must be a list of {"name", "description", "groups", "accounts"}', 2)
|
||||||
|
checked = []
|
||||||
|
for role in roles:
|
||||||
|
check_role_name(role.get('name'))
|
||||||
|
groups, accounts = role.get('groups') or [], role.get('accounts') or []
|
||||||
|
if not isinstance(groups, list) or not isinstance(accounts, list):
|
||||||
|
raise Failure(f'the groups and the accounts of role {role["name"]} must be lists of names', 2)
|
||||||
|
for group in groups:
|
||||||
|
group_entry(connection, group)
|
||||||
|
for account in accounts:
|
||||||
|
entry(connection, account)
|
||||||
|
checked.append({'name': role['name'], 'description': check_description(role.get('description') or ''),
|
||||||
|
'members': sorted({*(group_dn(group) for group in groups), *(dn_of(account) for account in accounts)})})
|
||||||
|
if len({role['name'] for role in checked}) != len(checked):
|
||||||
|
raise Failure('each role name may appear once', 2)
|
||||||
|
return checked
|
||||||
|
|
||||||
|
|
||||||
|
def set_roles(connection, resource, roles):
|
||||||
|
"""Make the resource's roles those of roles (checked_roles): roles left out are removed, new ones added, and the
|
||||||
|
others get the description and the members given."""
|
||||||
|
check_resource(connection, resource)
|
||||||
|
wanted = {role['name']: role for role in checked_roles(connection, roles)}
|
||||||
|
for role in roles_of_resource(connection, resource):
|
||||||
|
if role['name'] not in wanted:
|
||||||
|
connection.delete_s(role_dn(resource, role['name']))
|
||||||
|
present = {role['name'] for role in roles_of_resource(connection, resource)}
|
||||||
|
for name, role in wanted.items():
|
||||||
|
members = [dn.encode() for dn in role['members']]
|
||||||
|
description = [role['description'].encode()] if role['description'] else []
|
||||||
|
if name in present:
|
||||||
|
connection.modify_s(role_dn(resource, name), [(ldap.MOD_REPLACE, 'description', description),
|
||||||
|
(ldap.MOD_REPLACE, 'member', members)])
|
||||||
|
continue
|
||||||
|
record = {'objectClass': [b'vpnRouterRole'], 'cn': [f'{resource}:{name}'.encode()]}
|
||||||
|
if description:
|
||||||
|
record['description'] = description
|
||||||
|
if members:
|
||||||
|
record['member'] = members
|
||||||
|
connection.add_s(role_dn(resource, name), ldap.modlist.addModlist(record))
|
||||||
|
return roles_of_resource(connection, resource)
|
||||||
|
|
||||||
|
|
||||||
|
def assigned(connection, dn):
|
||||||
|
"""The DNs of the roles assigned to the group or account of the DN."""
|
||||||
|
found = connection.search_s(vpnrouter_ldap.ROLES, ldap.SCOPE_ONELEVEL,
|
||||||
|
f'(&(objectClass=vpnRouterRole)(member={ldap.filter.escape_filter_chars(dn)}))', ['1.1'])
|
||||||
|
return [role for role, _ in found]
|
||||||
|
|
||||||
|
|
||||||
|
def leave_roles(connection, dn):
|
||||||
|
"""Take the group or account of the DN out of every role."""
|
||||||
|
for role in assigned(connection, dn):
|
||||||
|
connection.modify_s(role, [(ldap.MOD_DELETE, 'member', [dn.encode()])])
|
||||||
|
|
||||||
|
|
||||||
|
def list_roles(connection, resource=None):
|
||||||
|
"""The roles of every resource, or of one, as {"roles": [{resource, name, description, groups, accounts}]}."""
|
||||||
|
found = connection.search_s(vpnrouter_ldap.ROLES, ldap.SCOPE_ONELEVEL, '(objectClass=vpnRouterRole)', ROLE_ATTRIBUTES)
|
||||||
|
roles = [{'resource': attributes['cn'][0].decode().partition(':')[0], **role_summary(attributes)} for _, attributes in found]
|
||||||
|
return {'roles': sorted((role for role in roles if resource is None or role['resource'] == resource),
|
||||||
|
key=lambda role: (role['resource'], role['name']))}
|
||||||
|
|
||||||
|
|
||||||
|
def roles_held(connection, resource, name):
|
||||||
|
"""The names of the resource's roles assigned to the account or to a group it is a member of, sorted."""
|
||||||
|
members = [dn_of(name), *(group_dn(group) for group in groups_of(connection, name))]
|
||||||
|
condition = '(|' + ''.join(f'(member={ldap.filter.escape_filter_chars(dn)})' for dn in members) + ')'
|
||||||
|
return sorted(role_summary(attributes)['name'] for _, attributes in role_search(connection, resource, condition))
|
||||||
|
|
||||||
|
|
||||||
def extract_pem(text):
|
def extract_pem(text):
|
||||||
"""The PEM blocks of the certificates in a file that also holds their text form."""
|
"""The PEM blocks of the certificates in a file that also holds their text form."""
|
||||||
begin, end = '-----BEGIN CERTIFICATE-----', '-----END CERTIFICATE-----'
|
begin, end = '-----BEGIN CERTIFICATE-----', '-----END CERTIFICATE-----'
|
||||||
|
|||||||
@@ -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
|
#!/bin/sh
|
||||||
# Wait until libpq is in the dynamic linker cache, where psycopg looks it up.
|
# 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=
|
reported=
|
||||||
until ldconfig -p | grep -q '^[[:space:]]*libpq\.so\.5 '; do
|
until ldconfig -p | grep -q '^[[:space:]]*libpq\.so\.5 '; do
|
||||||
|
|||||||
Regular → Executable
+19
-1547
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
|
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
|
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
|
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
|
run, which set and remove remind of. With --restart they restart vpn-router-setup and wait until
|
||||||
is 2 for an invalid request and 1 for any other failure. -h or --help shows the options.
|
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 show
|
||||||
vpn-config-edit set SECTION.OPTION VALUE
|
vpn-config-edit set [--restart] SECTION.OPTION VALUE
|
||||||
vpn-config-edit set site_to_site.NAME.OPTION VALUE
|
vpn-config-edit set [--restart] site_to_site.NAME.OPTION VALUE
|
||||||
vpn-config-edit remove site_to_site.NAME
|
vpn-config-edit remove [--restart] site_to_site.NAME
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, '/usr/lib/vpn-router')
|
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')
|
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 = 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')
|
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)
|
options = parser.parse_args(argv)
|
||||||
|
|
||||||
if options.command == 'show':
|
if options.command == 'show':
|
||||||
@@ -124,7 +130,14 @@ def main(argv):
|
|||||||
print(f'{options.key} set to {json.dumps(value)} in {vpnrouter.CONFIG_FILE}.')
|
print(f'{options.key} set to {json.dumps(value)} in {vpnrouter.CONFIG_FILE}.')
|
||||||
else:
|
else:
|
||||||
print(f'{options.key} removed from {vpnrouter.CONFIG_FILE}.')
|
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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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:]))
|
||||||
@@ -12,9 +12,18 @@ user exists, 1 anything else. -h or --help shows the commands and their options.
|
|||||||
vpn-router-user [--json] add <name> --first-name F --last-name L --email E (password on stdin)
|
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> [--email E] (a Manager-only account; password on stdin)
|
||||||
vpn-router-user [--json] add <name> --service [--display-name N] [--description D] [--email E]
|
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)
|
(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> [--first-name F] [--last-name L] [--email E]
|
||||||
vpn-router-user [--json] update <name> [--display-name N] [--description D] [--email E] (a service account)
|
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] passwd <name> (password on stdin)
|
||||||
vpn-router-user [--json] disable <name>
|
vpn-router-user [--json] disable <name>
|
||||||
vpn-router-user [--json] enable <name>
|
vpn-router-user [--json] enable <name>
|
||||||
@@ -69,6 +78,16 @@ PERSON_HELP = {'first_name': 'the first name', 'last_name': 'the last name', 'em
|
|||||||
'display_name': "a service account's display name; '' removes it",
|
'display_name': "a service account's display name; '' removes it",
|
||||||
'description': "a service account's description; '' 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):
|
class Parser(argparse.ArgumentParser):
|
||||||
"""An argument parser whose errors are Failures with status 2, so that --json answers them as JSON."""
|
"""An argument parser whose errors are Failures with status 2, so that --json answers them as JSON."""
|
||||||
@@ -88,6 +107,11 @@ def parse(argv):
|
|||||||
if command in ('add', 'update'):
|
if command in ('add', 'update'):
|
||||||
for key, _ in vpnrouter_users.PERSON + vpnrouter_users.LABELS:
|
for key, _ in vpnrouter_users.PERSON + vpnrouter_users.LABELS:
|
||||||
sub.add_argument(f'--{key.replace("_", "-")}', dest=key, metavar='TEXT', help=PERSON_HELP[key])
|
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':
|
if command == 'add':
|
||||||
sub.add_argument('--service', action='store_true', help='add a service account, which cannot sign in to the web service')
|
sub.add_argument('--service', action='store_true', help='add a service account, which cannot sign in to the web service')
|
||||||
if command == 'otp-enrol':
|
if command == 'otp-enrol':
|
||||||
@@ -111,6 +135,12 @@ def run(connection, options, secret):
|
|||||||
if command == 'list':
|
if command == 'list':
|
||||||
return vpnrouter_users.list_users(connection)
|
return vpnrouter_users.list_users(connection)
|
||||||
values = {key: getattr(options, key, None) for key, _ in vpnrouter_users.PERSON + vpnrouter_users.LABELS}
|
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':
|
if command == 'add':
|
||||||
return vpnrouter_users.add(connection, options.name, secret, values, service=options.service)
|
return vpnrouter_users.add(connection, options.name, secret, values, service=options.service)
|
||||||
if command == 'update':
|
if command == 'update':
|
||||||
@@ -136,6 +166,23 @@ def console_text(user):
|
|||||||
return text if user['otp'] else f'{text}, refused: no OTP'
|
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):
|
def text_of(answer):
|
||||||
"""The answer as text for people."""
|
"""The answer as text for people."""
|
||||||
if 'profile' in answer:
|
if 'profile' in answer:
|
||||||
@@ -147,12 +194,13 @@ def text_of(answer):
|
|||||||
users = answer['users'] if 'users' in answer else [answer]
|
users = answer['users'] if 'users' in answer else [answer]
|
||||||
if not users:
|
if not users:
|
||||||
return 'no users'
|
return 'no users'
|
||||||
head = ('USER', 'KIND', 'STATE', 'CERTIFICATE', 'OTP', 'PASSKEYS', 'CONSOLE', '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',
|
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',
|
'disabled' if not user['enabled'] else 'locked' if user['locked'] else 'enabled',
|
||||||
'issued' if user['certificate'] else 'missing',
|
'issued' if user['certificate'] else 'missing',
|
||||||
'enrolled' if user['otp'] else '-', str(len(user['passkeys'])) if user['passkeys'] else '-', console_text(user),
|
'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 '-')
|
user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-', client_text(user))
|
||||||
for user in users]
|
for user in users]
|
||||||
widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))]
|
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))
|
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"
|
"internal": "eth1"
|
||||||
},
|
},
|
||||||
"wan": {
|
"wan": {
|
||||||
"local_fqdn": "router.example.com"
|
"local_fqdn": "router.example.com",
|
||||||
|
"public_address": ""
|
||||||
},
|
},
|
||||||
"local": {
|
"local": {
|
||||||
"cidrs": ["10.0.0.0/24"],
|
"cidrs": ["10.0.0.0/24"],
|
||||||
@@ -38,7 +39,8 @@
|
|||||||
},
|
},
|
||||||
"p2s": {
|
"p2s": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"address_pool": "172.16.0.0/24"
|
"address_pool": "172.16.0.0/24",
|
||||||
|
"dns_listen": "internal"
|
||||||
},
|
},
|
||||||
"wireguard": {
|
"wireguard": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
@@ -64,7 +66,8 @@
|
|||||||
"status_content": "basic",
|
"status_content": "basic",
|
||||||
"shell": false,
|
"shell": false,
|
||||||
"shell_idle_minutes": 15,
|
"shell_idle_minutes": 15,
|
||||||
"shell_auth_timeout": 15
|
"shell_auth_timeout": 15,
|
||||||
|
"log_files": ["/var/log/syslog", "/var/log/auth.log"]
|
||||||
},
|
},
|
||||||
"ssh": {
|
"ssh": {
|
||||||
"ldap_login": false
|
"ldap_login": false
|
||||||
@@ -76,6 +79,21 @@
|
|||||||
"manager_port": 7443,
|
"manager_port": 7443,
|
||||||
"caddy_source": "ubuntu"
|
"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": {
|
"pki": {
|
||||||
"root_name": "VPN Router Root CA",
|
"root_name": "VPN Router Root CA",
|
||||||
"root_days": 7300,
|
"root_days": 7300,
|
||||||
|
|||||||
@@ -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,13 +1,16 @@
|
|||||||
# 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
|
auto_https disable_redirects
|
||||||
{%- if https_email %}
|
{%- if https_email %}
|
||||||
email {{ https_email }}
|
email {{ https_email }}
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
{%- if acme_cas %}
|
{%- if https_acme_ca %}
|
||||||
|
acme_ca {{ https_acme_ca }}
|
||||||
|
{%- endif %}
|
||||||
|
{%- if pki_cas %}
|
||||||
skip_install_trust
|
skip_install_trust
|
||||||
pki {
|
pki {
|
||||||
{%- for ca in acme_cas %}
|
{%- for ca in pki_cas %}
|
||||||
ca {{ ca.id }} {
|
ca {{ ca.id }} {
|
||||||
name "{{ ca.name }}"
|
name "{{ ca.name }}"
|
||||||
root {
|
root {
|
||||||
@@ -23,7 +26,7 @@
|
|||||||
}
|
}
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
}
|
}
|
||||||
{% if web_enabled or 'https' in pki_publish or acme_cas %}
|
{% if https and (web_enabled or 'https' in pki_publish or acme_cas) %}
|
||||||
{{ local_fqdn }}:{{ https_web_port }} {
|
{{ local_fqdn }}:{{ https_web_port }} {
|
||||||
{%- if 'https' in pki_publish %}
|
{%- if 'https' in pki_publish %}
|
||||||
handle_path /pki/* {
|
handle_path /pki/* {
|
||||||
@@ -32,9 +35,9 @@
|
|||||||
}
|
}
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
{%- for ca in acme_cas %}
|
{%- for ca in acme_cas %}
|
||||||
handle /acme/{{ ca.id }}/* {
|
handle /acme/{{ ca }}/* {
|
||||||
acme_server {
|
acme_server {
|
||||||
ca {{ ca.id }}
|
ca {{ ca }}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
@@ -47,7 +50,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{%- if 'http-80' in pki_publish %}
|
{%- if https and 'http-80' in pki_publish %}
|
||||||
http://{{ local_fqdn }} {
|
http://{{ local_fqdn }} {
|
||||||
handle_path /pki/* {
|
handle_path /pki/* {
|
||||||
root * {{ pki_publish_dir }}
|
root * {{ pki_publish_dir }}
|
||||||
@@ -58,7 +61,7 @@ http://{{ local_fqdn }} {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{%- if 'https-default' in pki_publish %}
|
{%- if https and 'https-default' in pki_publish %}
|
||||||
{{ local_fqdn }}:443 {
|
{{ local_fqdn }}:443 {
|
||||||
handle_path /pki/* {
|
handle_path /pki/* {
|
||||||
root * {{ pki_publish_dir }}
|
root * {{ pki_publish_dir }}
|
||||||
@@ -69,13 +72,39 @@ http://{{ local_fqdn }} {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{%- if manager_port %}
|
{%- if https and manager_port %}
|
||||||
{{ local_fqdn }}:{{ https_manager_port }} {
|
{{ local_fqdn }}:{{ https_manager_port }} {
|
||||||
reverse_proxy {{ loopback }}:{{ manager_port }}
|
reverse_proxy {{ loopback }}:{{ manager_port }}
|
||||||
}
|
}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{%- if metrics_enabled %}
|
{%- if https and metrics_enabled %}
|
||||||
{{ local_fqdn }}:{{ metrics_https_port }} {
|
{{ local_fqdn }}:{{ metrics_https_port }} {
|
||||||
reverse_proxy {{ loopback }}:{{ metrics_port }}
|
reverse_proxy {{ loopback }}:{{ metrics_port }}
|
||||||
}
|
}
|
||||||
{% endif %}
|
{% 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>
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
[Resolve]
|
|
||||||
{% for address in local_addr_list -%}
|
|
||||||
DNSStubListenerExtra={{ address }}
|
|
||||||
{% endfor -%}
|
|
||||||
@@ -39,5 +39,9 @@ pools {
|
|||||||
rw-pool {
|
rw-pool {
|
||||||
addrs = {{ p2s_address_pool }}
|
addrs = {{ p2s_address_pool }}
|
||||||
dns = {{ p2s_dns }}
|
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 %}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,7 +45,7 @@ variable "vm_size" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
variable "local_fqdn" {
|
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
|
type = string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,13 +89,13 @@ variable "psk" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
variable "p2s_enabled" {
|
variable "p2s_enabled" {
|
||||||
description = "Enable road-warrior (P2S) access."
|
description = "Enable IKEv2 access."
|
||||||
type = bool
|
type = bool
|
||||||
default = false
|
default = false
|
||||||
}
|
}
|
||||||
|
|
||||||
variable "p2s_address_pool" {
|
variable "p2s_address_pool" {
|
||||||
description = "CIDR block assigned to road-warrior clients."
|
description = "CIDR block assigned to IKEv2 clients."
|
||||||
type = string
|
type = string
|
||||||
default = ""
|
default = ""
|
||||||
}
|
}
|
||||||
@@ -140,7 +140,7 @@ variable "deploy_workload_vm" {
|
|||||||
variable "repo_url" {
|
variable "repo_url" {
|
||||||
description = "Base URL of the Debian package repository the router pulls vpn-router from."
|
description = "Base URL of the Debian package repository the router pulls vpn-router from."
|
||||||
type = string
|
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" {
|
variable "ubuntu_codename" {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ variable "machine_type" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
variable "local_fqdn" {
|
variable "local_fqdn" {
|
||||||
description = "FQDN of the router, used as the road-warrior/IKE identity and, when dns_managed_zone 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_managed_zone is set, as the name of the A record created for it."
|
||||||
type = string
|
type = string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,13 +86,13 @@ variable "psk" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
variable "p2s_enabled" {
|
variable "p2s_enabled" {
|
||||||
description = "Enable road-warrior (P2S) access."
|
description = "Enable IKEv2 access."
|
||||||
type = bool
|
type = bool
|
||||||
default = false
|
default = false
|
||||||
}
|
}
|
||||||
|
|
||||||
variable "p2s_address_pool" {
|
variable "p2s_address_pool" {
|
||||||
description = "CIDR block assigned to road-warrior clients."
|
description = "CIDR block assigned to IKEv2 clients."
|
||||||
type = string
|
type = string
|
||||||
default = ""
|
default = ""
|
||||||
}
|
}
|
||||||
@@ -137,7 +137,7 @@ variable "deploy_workload_vm" {
|
|||||||
variable "repo_url" {
|
variable "repo_url" {
|
||||||
description = "Base URL of the Debian package repository the router pulls vpn-router from."
|
description = "Base URL of the Debian package repository the router pulls vpn-router from."
|
||||||
type = string
|
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" {
|
variable "ubuntu_codename" {
|
||||||
|
|||||||
+18
-11
@@ -32,19 +32,26 @@ def git(*args):
|
|||||||
|
|
||||||
|
|
||||||
def summary():
|
def summary():
|
||||||
"""The summary line of the first changelog entry, or None when it has none.
|
"""The summary of the first changelog entry joined into one line, or None when it has none.
|
||||||
|
|
||||||
The summary is the entry's first text line: indented by two spaces, not a "*" item,
|
The summary is the entry's first paragraph: lines indented by exactly two spaces, the
|
||||||
with a blank line between it and the header and a blank line after it.
|
first not a "*" item, with a blank line between it and the header and a blank line after it.
|
||||||
"""
|
"""
|
||||||
lines = CHANGELOG.read_text(encoding='utf-8').splitlines()
|
lines = CHANGELOG.read_text(encoding='utf-8').splitlines()
|
||||||
if len(lines) < 4 or lines[1].strip() or lines[3].strip():
|
if len(lines) < 4 or lines[1].strip():
|
||||||
return None
|
return None
|
||||||
line = lines[2]
|
paragraph = []
|
||||||
text = line.strip()
|
for line in lines[2:]:
|
||||||
if not line.startswith(' ') or line.startswith(' ') or not text or text.startswith('*') or line.startswith(' -- '):
|
if not line.strip():
|
||||||
|
break
|
||||||
|
if not line.startswith(' ') or line.startswith(' ') or line.startswith(' -- '):
|
||||||
|
return None
|
||||||
|
paragraph.append(line.strip())
|
||||||
|
else:
|
||||||
return None
|
return None
|
||||||
return text
|
if not paragraph or paragraph[0].startswith('*'):
|
||||||
|
return None
|
||||||
|
return ' '.join(paragraph)
|
||||||
|
|
||||||
|
|
||||||
def version():
|
def version():
|
||||||
@@ -61,7 +68,7 @@ def version():
|
|||||||
def main():
|
def main():
|
||||||
global DRY_RUN
|
global DRY_RUN
|
||||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
parser.add_argument('-m', '--message', help='commit message; default: the summary line of the newest changelog entry, a line without "*" between blank lines right after its header')
|
parser.add_argument('-m', '--message', help='commit message; default: the summary of the newest changelog entry, a paragraph not starting with "*" between blank lines right after its header')
|
||||||
parser.add_argument('-n', '--dry-run', action='store_true', help='print the git commands instead of running them')
|
parser.add_argument('-n', '--dry-run', action='store_true', help='print the git commands instead of running them')
|
||||||
parser.add_argument('--no-push', action='store_true', help='commit and tag, but do not push')
|
parser.add_argument('--no-push', action='store_true', help='commit and tag, but do not push')
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
@@ -69,8 +76,8 @@ def main():
|
|||||||
|
|
||||||
message = args.message or summary()
|
message = args.message or summary()
|
||||||
if not message:
|
if not message:
|
||||||
sys.exit(f'no commit message: pass -m, or start the newest entry of {CHANGELOG} with a summary line '
|
sys.exit(f'no commit message: pass -m, or start the newest entry of {CHANGELOG} with a summary paragraph '
|
||||||
'without "*", indented by two spaces, with a blank line above and below it')
|
'not starting with "*", indented by two spaces, with a blank line above and below it')
|
||||||
tag = version()
|
tag = version()
|
||||||
if subprocess.run(['git', 'rev-parse', '-q', '--verify', f'refs/tags/{tag}'], cwd=ROOT,
|
if subprocess.run(['git', 'rev-parse', '-q', '--verify', f'refs/tags/{tag}'], cwd=ROOT,
|
||||||
capture_output=True).returncode == 0:
|
capture_output=True).returncode == 0:
|
||||||
|
|||||||
Reference in new issue
Block a user