Compare commits

...
19 Commits
Author SHA1 Message Date
slawek 8efe21bfa6 feature: the package installs the Router Manager, which is no longer a package of its own.
Release / release (push) Failing after 4s
2026-11-14 12:52:44 +01:00
slawek 6d90441bee 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.
Release / release (push) Successful in 14s
2026-11-06 20:17:26 +01:00
slawek ee1161f534 update: vpn-router is published in the koszewscy package registry.
Release / release (push) Successful in 15s
2026-11-06 20:09:06 +01:00
slawek 7f85a1a2e5 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.
Release / release (push) Successful in 35s
2026-11-06 19:06:48 +01:00
slawek c2c33cfca0 fix: /https/settings shows the https section and switches https.acme_staging, for the Router Manager.
Release / release (push) Successful in 56s
2026-10-30 02:46:07 +01:00
slawek f36e2a3b04 feature: https.acme_staging takes Caddy's certificates from Let's Encrypt's staging environment.
Release / release (push) Successful in 14s
2026-10-30 02:35:21 +01:00
slawek 1e0e375d60 fix: an answer whose client went away ends quietly, and a shell whose client left before it opened is stopped.
Release / release (push) Successful in 15s
2026-10-30 02:03:58 +01:00
slawek dd489e65aa 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.
Release / release (push) Successful in 17s
2026-10-30 02:01:27 +01:00
slawek 3de030b06b fix: /config answers the OpenID Provider's state and issuer.
Release / release (push) Successful in 15s
2026-10-30 01:54:50 +01:00
slawek 3c4c5a8c32 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.
Release / release (push) Successful in 17s
2026-10-30 01:01:45 +01:00
slawek 3ed79c7563 feature: a shell opens with one of the user's passkeys in place of the password and the TOTP code.
Release / release (push) Successful in 17s
2026-10-29 23:13:11 +01:00
slawek 3e78d520ae update: the package is published for Ubuntu 26.04 (resolute) as well as 24.04 (noble).
Release / release (push) Successful in 33s
2026-10-29 21:42:15 +01:00
slawek d99e4ea425 fix: the web service answers /, /status and /status/full again while IKEv2 runs.
Release / release (push) Successful in 22s
2026-10-29 19:04:11 +01:00
slawek 46d3550eab update: the router web service is split into the package vpnrouter_web, with one module per family of paths.
Release / release (push) Successful in 14s
2026-10-29 18:52:51 +01:00
slawek 3b14b7723e feature: vre opens files from the Router Manager's shell in the editor of its Shell page.
Release / release (push) Successful in 17s
2026-10-29 18:10:58 +01:00
slawek 5303779559 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.
Release / release (push) Failing after 14m34s
2026-10-28 22:31:27 +01:00
slawek 2dc4db440f fix: OpenVPN starts on a new router, as setup installs CRLs that revoke nothing.
Release / release (push) Failing after 15m31s
2026-10-28 22:00:37 +01:00
slawek 45cea95728 fix: vpn-router-setup runs on a new installation once libpq can be found.
Release / release (push) Failing after 14m15s
2026-10-28 21:41:52 +01:00
slawek dd2dfaaf2b fix: the router's IPsec remote access is called IKEv2 instead of road-warrior.
Release / release (push) Successful in 53s
2026-10-27 08:46:36 +01:00
172 changed files with 22508 additions and 2662 deletions

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
.git
.claude
.vscode
out
tmp
examples
web/node_modules
web/dist
.venv
dev.json
**/__pycache__
+4
View File
@@ -12,3 +12,7 @@ tmp
*.tfstate *.tfstate
*.tfstate.* *.tfstate.*
*.auto.tfvars *.auto.tfvars
node_modules
web/dist
.venv
dev.json
+386 -75
View File
@@ -46,10 +46,10 @@ 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 Cloud Manager and the Router Manager show what it
has. `vpn-router-setup` opens the has. `vpn-router-setup` opens the
port on both interfaces in UFW, so the choice of `web.listen` is made by the service alone, and port on both interfaces in UFW, so the choice of `web.listen` is made by the service alone, and
writes the features and the facts it resolves to `/run/vpn-router/web.json`. writes the features and the facts it resolves to `/run/vpn-router/web.json`.
@@ -227,7 +227,7 @@ no CORS headers, so a browser cannot call it directly, and tokens kept in the pa
by its scripts. by its scripts.
Decision: `vpn-router-manager` is a Debian package of its own, built in the repository Decision: `vpn-router-manager` is a Debian package of its own, built in the repository
`linux-cloud-router-manager`. A React and MUI application in the style of manage-ui is built `linux-cloud-router-manager`. A React and MUI application in the style of Cloud Manager is built
with Vite when the package is built and shipped as static files. A Python server, using the with Vite when the package is built and shipped as static files. A Python server, using the
Ubuntu packages `python3-requests` and `python3-psycopg`, serves them, signs users in through the Ubuntu packages `python3-requests` and `python3-psycopg`, serves them, signs users in through the
router's `/auth`, keeps their tokens in a server-side session in PostgreSQL behind an router's `/auth`, keeps their tokens in a server-side session in PostgreSQL behind an
@@ -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
@@ -641,7 +650,7 @@ Status: Accepted
Context: The router had one IPsec site-to-site tunnel, the `remote` section, run as the swanctl Context: The router had one IPsec site-to-site tunnel, the `remote` section, run as the swanctl
connection `remote-site`, with the local identity mode in `wan` and the local subnets in `local`. connection `remote-site`, with the local identity mode in `wan` and the local subnets in `local`.
A router often links more than one site, each with its own peer, subnets and key, and an admin A router often links more than one site, each with its own peer, subnets and key, and an admin
should add them without a shell. The install answers and the Cloud Platform describe one should add them without a shell. The install answers and Cloud Manager describe one
connection, and the platform rewrites `vpn-router.json` when it rebuilds the VM. connection, and the platform rewrites `vpn-router.json` when it rebuilds the VM.
Decision: `site_to_site` maps a connection name to its settings: `addrs`, `id`, `cidrs`, Decision: `site_to_site` maps a connection name to its settings: `addrs`, `id`, `cidrs`,
@@ -649,14 +658,14 @@ Decision: `site_to_site` maps a connection name to its settings: `addrs`, `id`,
without a fallback for the old format. The name, a lowercase letter followed by at most 30 without a fallback for the old format. The name, a lowercase letter followed by at most 30
lowercase letters, digits and hyphens, names the swanctl connection, its child and its secret lowercase letters, digits and hyphens, names the swanctl connection, its child and its secret
`ike-psk-<name>`, so it is a valid swanctl section name and stays the same while the connection `ike-psk-<name>`, so it is a valid swanctl section name and stays the same while the connection
lives. The install answers and the Cloud Platform write the connection `remote-site`, the first lives. The install answers and Cloud Manager write the connection `remote-site`, the first
one, so its name is the one the tunnel always had. 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
@@ -683,7 +692,7 @@ Consequences: A configuration with `remote` is refused, so the package and a con
the new format go together. The status shows each connection. A rate across a rekey misses the the new format go together. The status shows each connection. A rate across a rekey misses the
traffic of the old child SA since the previous sample. A renamed connection starts a new series. traffic of the old child SA since the previous sample. A renamed connection starts a new series.
A connection added through the API lives in A connection added through the API lives in
`vpn-router.json` only; the Cloud Platform's rebuild of the VM writes the file again with its `vpn-router.json` only; Cloud Manager's rebuild of the VM writes the file again with its
one connection, dropping the others. A change through the API is answered before setup has one connection, dropping the others. A change through the API is answered before setup has
applied it, so a configuration that setup cannot apply shows in the setup state, not in the applied it, so a configuration that setup cannot apply shows in the setup state, not in the
answer. A connection with a key file keeps it until a key is given through the API, which then answer. A connection with a key file keeps it until a key is given through the API, which then
@@ -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.
@@ -1006,43 +1022,97 @@ Router Manager on another name, or in development on `localhost`, cannot use the
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 are service accounts ## ADR-37: The web service is an OpenID Provider built on python3-oauthlib; its clients and resources are service accounts
Status: Accepted Status: Accepted
Context: Other systems, such as dashboards, should sign the router's users in with the directory's Context:
passwords, TOTP codes and passkeys, without a second user store. The Ubuntu archive has no
packaged identity provider. `python3-oauthlib` and `python3-jwt` are in main for noble and
resolute; oauthlib implements the OpenID Connect flows and leaves the checks, the storage and the
ID token's signature to the application. `python3-authlib` and `python3-jwcrypto` are in universe
only. The web service already checks passwords and passkeys, rate limits sign-ins and keeps
tokens in PostgreSQL, and Caddy serves it over HTTPS on `wan.local_fqdn`. ADR-3's HS256 key must
not leave the router, but a relying party verifies an ID token itself.
Decision: The web service is the OpenID Provider, in `vpnrouter_oidc`, while `oidc.enabled` is - **What is needed:** other systems, such as dashboards, should sign the router's users in with the
true; its issuer is its HTTPS site, `https://<wan.local_fqdn>:<https.web_port>`, without the port directory's passwords, TOTP codes and passkeys, without a second user store. Programs on hosts
on 443, and it answers the provider's paths only on `127.0.0.1`, where Caddy passes them. Only without a browser, and jobs without a user, need tokens as well. Services such as an S3 gateway
the authorization code flow with PKCE S256 and the refresh token grant are allowed. A client is a should learn from a token what its holder may do there, as resources of Entra ID do with app
service account with redirect URIs, kept with the account in the new attributes roles and scopes.
`vpnRouterRedirectUri`, `vpnRouterPostLogoutRedirectUri` and `vpnRouterAllowedGroup` of - **What the archive has:** no packaged identity provider. `python3-oauthlib` and `python3-jwt`
`vpnRouterAccount`: its name is the client ID and its password the secret, checked with a bind. are in main for noble and resolute; `python3-authlib` and `python3-jwcrypto` are in universe
Allowed groups limit who may sign in; without them every user may. A registered client is only.
trusted, so there is no consent page. The sign-in page is the web service's own, a Jinja2 - **What oauthlib does:** it implements the OAuth 2.0 and OpenID Connect flows, including the
template with the password, the code and a passkey, so the provider works without the Router client credentials grant, and leaves the checks, the storage and the signatures to the
Manager. ID tokens are RS256, signed with an RSA key the web service makes on its first run application. Noble's 3.2.2 has no device authorization grant (RFC 8628). Resolute's 3.3.1 has
(`/etc/vpn-router/oidc.key`), published as a JWK named by its RFC 7638 thumbprint; `sub` is the one, as has the current 4.0.0, but it checks no device code and never answers
entry's `entryUUID`. Codes, access and refresh tokens and sessions are kept as their SHA-256 in `authorization_pending` or `slow_down`.
the database of the refresh tokens, defined in `/usr/share/vpn-router/sql/oidc.sql` with - **What the web service has:** it already checks passwords and passkeys, rate limits sign-ins and
procedures and functions the module calls; a process loads the file at its first connection. The keeps tokens in PostgreSQL, and Caddy serves it over HTTPS on `wan.local_fqdn`. ADR-3's HS256
operations that revoke the refresh tokens of `/auth` also revoke them, and every use of a code or key must not leave the router, but a relying party or a resource verifies a token itself.
token checks the user and the client's allowed groups again.
Consequences: The package depends on `python3-oauthlib` and `python3-jwt`. The provider needs Decision:
HTTPS, a public name and the web service. Clients are managed like service accounts, by the CLI,
`/service-accounts` and the Router Manager; a group a client allows cannot be removed, and renaming - **Where it runs:** the web service is the OpenID Provider, in `vpnrouter_oidc`, while
it renames it in the clients. The signing key does not rotate; replacing it invalidates every ID `oidc.enabled` is true. Its issuer is its HTTPS site, `https://<wan.local_fqdn>:<https.web_port>`,
token issued. Wrong client secrets lock the client like any account. The implicit and hybrid without the port on 443, and it answers the provider's paths only on `127.0.0.1`, where Caddy
flows, dynamic client registration and public clients without a secret are not offered. 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 ## ADR-38: Bash completions are files per command in bash-completion's completions directory
@@ -1141,8 +1211,9 @@ later. S3 and Azure Blob clients are not served.
Status: Accepted Status: Accepted
Context: The router hosts authoritative domains as their primary, with optional secondary name Context: The router hosts authoritative domains as their primary, with a secondary name server, and
servers, and a domain can give the sites their records without a registrar change per site. Ubuntu 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 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 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 127.0.0.53 and 127.0.0.54, and the remote access clients' resolver answered on the external
@@ -1152,25 +1223,33 @@ 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 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 `/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 addresses of `dns.listen`, with the gpgsql backend on the database `vpn-router-dns` and PowerDNS's
own schema, and `primary=yes`. The domains, their secondaries and manual records are rows of the own schema, and `primary=yes`. The domains and their manual records are rows of the router's tables
router's tables in that database, like the sites in `vpn-router-sites`, not settings of in that database, like the sites in `vpn-router-sites`, not settings of `vpn-router.json`: they are
`vpn-router.json`: they are many and change through the web service. `dns.nameserver` and many and change through the web service. The name servers are settings, the same for every domain:
`dns.addresses` stay settings, the router's one identity as the primary NS of every domain. Setup `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 writes each domain as a MASTER zone through the procedure `dns_apply` of
`/usr/share/vpn-router/sql/dns.sql`: SOA, NS of the router and the secondaries, A and AAAA records `/usr/share/vpn-router/sql/dns.sql`: SOA with `wan.local_fqdn`, NS of `wan.local_fqdn` and
of `dns.addresses` for the apex, the name server and, with the domain's site records switch, every `dns.secondary`, an A or AAAA record of every host name of a site with `dns` `router` whose closest
site host name whose closest domain it is, and the manual records; the serial grows only when the domain it is, with the router's address the site chooses (`wan.public_address`, the internal or the
records change, which makes PowerDNS notify. A secondary is a name, its NS, and an address, which external interface's), and the manual records; the serial grows only when the records change, which
goes to the domain's `ALSO-NOTIFY` and `ALLOW-AXFR-FROM` metadata, so the global makes PowerDNS notify. `wan.local_fqdn` gets no record in the domains: its A record is in the DNS of
`allow-axfr-ips` stays the loopback default. A host name belongs to its closest domain only, and a 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 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 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 `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. refused while P2S or OpenVPN is enabled.
Consequences: The domains follow the sites at each setup run. Transfers are authorized by address, Consequences: The domains follow the sites at each setup run. Transfers are authorized by the
without TSIG, and there is no DNSSEC; the delegation of each domain is made at its parent zone by address the secondary's name resolves to, without TSIG, and there is no DNSSEC; the delegation of
hand. 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 ## ADR-43: The remote access clients' resolver is dnsmasq, not systemd-resolved
@@ -1198,7 +1277,7 @@ resolver share no address.
Status: Accepted Status: Accepted
Context: macOS, iOS and iPadOS connect to the road-warrior access with their built-in IKEv2 client, 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 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, 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. and show an unsigned profile as unverified.
@@ -1213,20 +1292,252 @@ Consequences: The password is never stored; the device asks for it when the prof
A profile shows as unverified. A changed proposal of `road-warrior.conf` must be changed in the 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. profile too. WireGuard is not part of it.
## ADR-45: Road-warrior clients get split DNS domains, p2s.dns_domains ## ADR-45: IKEv2 clients get split DNS domains, p2s.dns_domains
Status: Accepted Status: Accepted
Context: The road-warrior access is a split tunnel. macOS and iOS use the DNS server an IKEv2 VPN 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 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 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 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`. 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 Decision: The setting `p2s.dns_domains` lists the domains; setup pushes each as attribute 25 of the
road-warrior pool, and the Apple VPN profile gives them as match domains of the router's resolver, 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. the addresses setup records for the web service.
Consequences: Clients resolve the listed domains through the tunnel and everything else with their 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 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. 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`.
- `vpnrouter_manager.serve`, which the Router Manager runs, does the same.
- 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.
## ADR-50: The help is Markdown rendered in the browser, in a panel that pushes the page
Status: Accepted
Context: The Router Manager's pages were explained only in its README. Help should be written as
plain files next to the code, shipped with the package, readable only after signing in, and able
to show pictures. serve-markdown renders Markdown in the browser with marked, DOMPurify and
github-markdown-css. Google Cloud's console shows its Learn panel on the right and moves the page
left, so the help never covers what it explains.
Decision: The help is the Markdown files of `docs/` (plain Markdown, no MDX), with SVG and PNG
pictures, installed to `/usr/share/vpn-router/manager/docs` and served by the manager at
`/docs/<file>` behind the session, with no-sniff and a CSP that keeps an SVG from running
scripts. The browser renders them with marked (GFM) and DOMPurify, styled with
github-markdown-css in the Manager's light or dark scheme, without syntax highlighting or
diagrams. The help icon in the header opens a persistent drawer on the right whose width the page
gives up; it opens at the topic of the page and tab shown, and links between topics stay in the
panel.
Consequences: Help is edited as Markdown and released with the package. The Manager depends on
marked, DOMPurify and github-markdown-css. A page without a topic of its own shows the index. The
help is not reachable without a session.
## ADR-51: The files of web sites pass the manager streamed, outside the JSON proxy
Status: Accepted
Context: The Web Sites page uploads and downloads the files of the router's web sites, up to the
router's `sites.upload_max_mb`, by default 512 MiB. The manager's proxy reads a request body of at
most 64 KiB into memory, sends it as JSON and holds the whole answer; the API helper of the page
parses every answer as JSON. Encoding files in JSON would grow them by a third and still need the
whole file in memory.
Decision: `GET`, `HEAD` and `PUT` of `/api/sites/<name>/files/...` take their own route in the
manager: the request body is passed to the router as it is read, with its `Content-Type` and
`Content-Length`, and the router's answer is passed back as it arrives, with its `Content-Type`,
`Content-Length` and `Content-Disposition`. The page uploads with `XMLHttpRequest`, whose upload
events give the progress `fetch` does not, sending the file as the raw body, a zip archive with
`?unzip=true`, and downloads with `fetch` into a blob. Folders, renaming and removal stay JSON
calls of the usual proxy.
Consequences: Large files cross the manager without filling its memory. An upload needs a
`Content-Length`, which browsers send for a file. Dropped folders are not uploaded as trees; a zip
archive carries a tree.
## ADR-52: On a workstation, the router's URL is the Terraform output router_web_url
Status: Accepted
Context: With `terraform_dir`, the manager built `http://<router_private_ip_addresses.int>:7081`.
The port of the router's web service is `web.port` and the interfaces it listens on are `web.listen`,
which the platform sets from its variables; the manager that `dev.sh` runs on a workstation cannot read them.
Decision: `router_url` is the output `router_web_url` of the platform, which derives the URL from
the same variables that configure the router.
Consequences: The manager follows the router's port and interfaces without settings of its own; a
state written before the platform had the output gives no `router_url` until the next deploy.
## ADR-53: The system's packages are upgraded with apt-get upgrade, apart from the router's package
Status: Accepted
Context: The Overview tab upgrades only `vpn-router`. The router's
operating system needs its other packages upgraded too, from the same page. An upgrade restarts
services, the manager among them, so it cannot run inside the manager's unit.
Decision: The System tab lists what `apt-get -s upgrade` would upgrade and runs `apt-get upgrade
-y` through the same helper as the router's package, with `--system`, in its own transient unit
and with its own state and log files, so each tab shows its own last upgrade. `upgrade`, not
`dist-upgrade` or `full-upgrade`, so nothing is installed or removed; a package that needs a new
dependency is kept back. The manager refuses an upgrade while the other runs; the check for
updates is the same `apt-get update` for both.
Consequences: Kept-back packages, a new kernel series among them, stay until upgraded on the
router. An upgrade of the system also upgrades the router's package when it is newer, which
the Overview tab's state does not record.
## ADR-54: The Shell page's editor is Monaco, opened by vre and loaded on demand
Status: Accepted
Context: Administrators edit files on the router from the Shell page. The page needs no file
browser: the shell names the files, as `$EDITOR` would. Monaco is VS Code's editor component and
is published as the ESM package `monaco-editor`. Its full entry point brings the language services
of CSS, HTML, JSON and TypeScript, each with a worker, and its bundle is several megabytes.
Decision: The router's `vre` names a file in a text message over the shell's WebSocket, which the
manager relays unchanged. The page reads and saves the file through the JSON proxy, whose body
limit is raised for `/api/shell/editor/` alone to hold a file of up to 1 MiB escaped in JSON.
Monaco 0.57 is bundled from npm through its tree-shakeable entry points: `monaco-editor/editor`
with all editor features and all language definitions, and only the editor worker. It is a chunk
of its own, imported when the first file opens, and Vite's preload helper has a chunk of its own,
so the entry does not load Monaco.
Consequences: The editor colours the syntax of every language Monaco defines, but has no
validation, completion or formatting by language services. The page loads Monaco, about 1.1 MB
compressed, the first time a file opens.
## ADR-55: index.html sets the color scheme before the first paint
Status: Accepted
Context: Each page of the manager is a document of its own, so a page switch loads `index.html`
again. MUI sets the color scheme class on `<html>` and the page background from JavaScript, once
React renders, which waits for the bundle and for `GET /info`. Until then the browser paints its
default white page.
Decision: An inline script in the head of `index.html` reads the mode MUI stores in localStorage
(`mui-mode`, `system` when not stored), as MUI's `InitColorSchemeScript` does, and adds the class
`light` or `dark` to `<html>`. An inline style gives `html.dark` the dark color scheme and MUI's
default dark background, `#121212`. The page has no meta color-scheme, which would follow the
system against a stored light or dark choice.
Consequences: A page in the dark theme is dark from its first paint. The script depends on the
storage key and class names of MUI's defaults, which `colorSchemeSelector: 'class'` uses.
## ADR-56: The Router Manager is part of the vpn-router package
Status: Accepted
Context: The Router Manager runs only on the router: it imports `vpnrouter_net`, `vpn-router-setup`
opens its ports and creates its first administrator, Caddy proxies it and the status reports it.
As a package of its own it would pin the router's version and the router its, and a change of the
router's API would need two repositories and two changelogs.
Decision: One package, `vpn-router`, installs the manager with the router. The manager's programs
are in `/usr/lib/vpn-router`, its application and help in `/usr/share/vpn-router/manager`, its
settings and secrets in `/etc/vpn-router` (`manager.json`, `manager-database.secret`,
`manager-admin.json`) and the state of its upgrades in `/var/lib/vpn-router`. The unit
`vpn-router-manager.service`, its transient units and its database keep their names. The debconf
questions of its first administrator are `vpn-router/admin_username` and `admin_password`. The
router's bootstrap page shows the manager, and the manager has no bootstrap service of its own.
The package carries no migration from the separate package: it has no relation to
`vpn-router-manager`, and dpkg refuses to install it beside that package, since both have
`vpn-router-manager.service`. `dev.sh` still runs the manager on a workstation, with
`terraform_dir` and `bind`.
Consequences: A router with the separate package is moved to this one by hand: remove
`vpn-router-manager` without purging it, move its files to the new paths, then install
`vpn-router`. A backup made before the merge restores the manager's files to
`/etc/vpn-router-manager`, where the manager no longer reads them. The repository holds the
manager's `web/` and `docs/`, and its build runs from the root.
+12 -1
View File
@@ -1,4 +1,13 @@
ARG NODE_VERSION=24
ARG UBUNTU_VERSION=24.04 ARG UBUNTU_VERSION=24.04
FROM node:${NODE_VERSION}-bookworm-slim AS web
WORKDIR /web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
FROM ubuntu:${UBUNTU_VERSION} FROM ubuntu:${UBUNTU_VERSION}
ARG HOST_UID ARG HOST_UID
ARG HOST_GID ARG HOST_GID
@@ -12,7 +21,9 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /build WORKDIR /build
COPY . src/ COPY debian-package/ src/
COPY docs/ src/docs/
COPY --from=web /web/dist src/web/dist/
RUN cd src && dpkg-buildpackage -us -uc -b RUN cd src && dpkg-buildpackage -us -uc -b
RUN lintian --no-tag-display-limit /build/*.changes || true RUN lintian --no-tag-display-limit /build/*.changes || true
+758 -144
View File
File diff suppressed because it is too large. Load diff
Executable
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
set -e
# CONTAINER_RUNTIME from the environment, for example docker with DOCKER_CONTEXT naming a remote engine;
# otherwise Apple container CLI on macOS and Docker CE on Linux.
if [ -z "${CONTAINER_RUNTIME:-}" ]; then
case "$(uname -s)" in
Darwin) CONTAINER_RUNTIME=container ;;
Linux) CONTAINER_RUNTIME=docker ;;
*) printf 'ERROR: unsupported platform %s\n' "$(uname -s)" >&2; exit 1 ;;
esac
fi
mkdir -p out
$CONTAINER_RUNTIME build --build-arg HOST_UID=$(id -u) --build-arg HOST_GID=$(id -g) -t vpn-router-builder "$@" .
# Artifacts are streamed out as a tar archive, so no bind mount is needed.
$CONTAINER_RUNTIME run --rm vpn-router-builder tar -cf - -C /out . | tar -xf - -C out
echo "Build artifacts written to out/"
-17
View File
@@ -1,17 +0,0 @@
#!/bin/sh
set -e
# Apple container CLI on macOS, Docker CE on Linux.
case "$(uname -s)" in
Darwin) CONTAINER_RUNTIME=container ;;
Linux) CONTAINER_RUNTIME=docker ;;
*) printf 'ERROR: unsupported platform %s\n' "$(uname -s)" >&2; exit 1 ;;
esac
mkdir -p out
$CONTAINER_RUNTIME build --build-arg HOST_UID=$(id -u) --build-arg HOST_GID=$(id -g) -t vpn-router-builder "$@" .
# Artifacts are streamed out as a tar archive, so no bind mount is needed.
$CONTAINER_RUNTIME run --rm vpn-router-builder tar -cf - -C /out . | tar -xf - -C out
echo "Build artifacts written to out/"
+384 -50
View File
@@ -1,6 +1,274 @@
vpn-router (0.40.0-1) unstable; urgency=medium
feature: the package installs the Router Manager, which is no longer a
package of its own.
* The Router Manager, vpn-router-manager.service, is installed and started
with the router; its programs are in /usr/lib/vpn-router, its application
and help in /usr/share/vpn-router/manager.
* Its settings are /etc/vpn-router/manager.json, its database password
/etc/vpn-router/manager-database.secret and the state of its upgrades
/var/lib/vpn-router/upgrade.json and system-upgrade.json.
* The debconf questions vpn-router/admin_username and admin_password give
its first administrator, written to /etc/vpn-router/manager-admin.json.
* The Overview tab and the upgrade helper know the package vpn-router
alone.
* The bootstrap page on port 17081 shows the manager's service and log;
port 17080 and vpn-router-manager-bootstrap are gone.
* The status has no manager_version; the manager's unit is always among
the services.
* The package cannot be installed beside the package vpn-router-manager,
which has to be removed first.
* A purge also removes the manager's settings, its database and role, and
the record of its upgrades.
* build.sh, publish.sh and the Dockerfile are in the repository root, the
build also builds the application in web/, and CONTAINER_RUNTIME chooses
the container runtime.
* The README and the ADR call the product that deploys the router Cloud
Manager, also where they named manage-ui, and the docstring of the GCP
platform names Google Cloud.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 13 Nov 2026 20:15:15 +0100
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 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. 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 * The domains, their secondaries and manual records are kept in the
vpn-router-dns database and changed through /dns/domains; dns.zone is vpn-router-dns database and changed through /dns/domains; dns.zone is
@@ -18,7 +286,8 @@ vpn-router (0.33.1-1) unstable; urgency=medium
vpn-router (0.33.0-1) unstable; urgency=medium 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. 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 * p2s.dns_domains lists up to 16 domains; setup pushes them to IKEv2
clients as INTERNAL_DNS_DOMAIN (RFC 8598, attribute 25) of the clients as INTERNAL_DNS_DOMAIN (RFC 8598, attribute 25) of the
@@ -30,7 +299,8 @@ vpn-router (0.33.0-1) unstable; urgency=medium
vpn-router (0.32.1-1) unstable; urgency=medium 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. 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 * POST /account/profile answers the user's OpenVPN client profile; it
needs the current password and, when enrolled, a code. needs the current password and, when enrolled, a code.
@@ -39,7 +309,8 @@ vpn-router (0.32.1-1) unstable; urgency=medium
vpn-router (0.32.0-1) unstable; urgency=medium 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. 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 * 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 an unsigned .mobileconfig with the P2S CA and the root CA, the user's
@@ -53,7 +324,9 @@ vpn-router (0.32.0-1) unstable; urgency=medium
vpn-router (0.31.3-1) unstable; urgency=medium 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. 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 firewall opens port 53 of the remote access clients' resolver for
the WireGuard network, as it does for the IKEv2 and OpenVPN pools. the WireGuard network, as it does for the IKEv2 and OpenVPN pools.
@@ -67,7 +340,8 @@ vpn-router (0.31.3-1) unstable; urgency=medium
vpn-router (0.31.2-1) unstable; urgency=medium 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. 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 * vpn-router-resolver.service runs dnsmasq on the addresses of
p2s.dns_listen, forwarding to systemd-resolved's stub on 127.0.0.53; p2s.dns_listen, forwarding to systemd-resolved's stub on 127.0.0.53;
@@ -82,7 +356,9 @@ vpn-router (0.31.2-1) unstable; urgency=medium
vpn-router (0.31.1-1) unstable; urgency=medium 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. 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 * The new setting p2s.dns_listen chooses the addresses of the resolver the
road-warrior and OpenVPN clients are given: internal, the default, road-warrior and OpenVPN clients are given: internal, the default,
@@ -95,7 +371,9 @@ vpn-router (0.31.1-1) unstable; urgency=medium
vpn-router (0.31.0-1) unstable; urgency=medium 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. 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, * The new database vpn-router-sites holds the web sites: host names,
port, interfaces, TLS (none, Let's Encrypt, an issuing CA or a port, interfaces, TLS (none, Let's Encrypt, an issuing CA or a
@@ -131,7 +409,8 @@ vpn-router (0.31.0-1) unstable; urgency=medium
vpn-router (0.30.0-1) unstable; urgency=medium 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. 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 * GET /logs lists the units whose journal and the files whose lines it
serves; GET /logs/journal?unit=&lines= and /logs/file?path=&lines= serves; GET /logs/journal?unit=&lines= and /logs/file?path=&lines=
@@ -144,7 +423,8 @@ vpn-router (0.30.0-1) unstable; urgency=medium
vpn-router (0.29.0-1) unstable; urgency=medium 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. 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-config-edit set --restart and remove --restart restart
vpn-router-setup after writing the configuration and wait until it has vpn-router-setup after writing the configuration and wait until it has
@@ -155,7 +435,8 @@ vpn-router (0.29.0-1) unstable; urgency=medium
vpn-router (0.28.0-1) unstable; urgency=medium vpn-router (0.28.0-1) unstable; urgency=medium
feature: bash completes the vpn-* commands, their options and the names they take. feature: bash completes the vpn-* commands, their options and the names they
take.
* Bash completes the commands, options and arguments of vpn-router-user, * 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-group, vpn-router-token, vpn-config-edit, vpn-router-backup,
@@ -168,7 +449,9 @@ vpn-router (0.28.0-1) unstable; urgency=medium
vpn-router (0.27.0-1) unstable; urgency=medium 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. 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 * oidc.enabled turns on the OpenID Provider at the issuer
https://<wan.local_fqdn>:<https.web_port>, with discovery, JWKS, https://<wan.local_fqdn>:<https.web_port>, with discovery, JWKS,
@@ -197,7 +480,8 @@ vpn-router (0.27.0-1) unstable; urgency=medium
vpn-router (0.26.2-1) unstable; urgency=medium 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. 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 * 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 in lowercase as the name, besides a user name; users and groups keep
@@ -207,7 +491,8 @@ vpn-router (0.26.2-1) unstable; urgency=medium
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.
@@ -215,7 +500,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
@@ -239,7 +525,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
@@ -250,7 +537,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"}.
@@ -258,7 +546,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
@@ -268,7 +558,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"}.
@@ -277,7 +568,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
@@ -293,7 +585,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.
@@ -302,7 +595,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
@@ -314,7 +609,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.
@@ -325,7 +622,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
@@ -343,7 +642,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
@@ -355,7 +656,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".
@@ -364,7 +666,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
@@ -378,7 +682,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.
@@ -387,7 +692,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
@@ -397,7 +703,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
@@ -421,7 +730,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.
@@ -430,7 +740,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
@@ -440,7 +751,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
@@ -479,7 +793,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),
@@ -512,7 +828,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
@@ -531,7 +848,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
@@ -542,7 +860,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
@@ -560,7 +879,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
@@ -575,7 +896,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
@@ -592,7 +914,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.
@@ -616,7 +940,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
@@ -656,7 +981,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
@@ -668,7 +994,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
@@ -720,7 +1047,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
@@ -735,7 +1063,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
@@ -758,7 +1087,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
@@ -844,7 +1174,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
@@ -874,7 +1205,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
@@ -889,7 +1221,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
@@ -899,7 +1232,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
+10
View File
@@ -12,6 +12,16 @@ fi
# preseeded completes without prompting. The defaults are platform "none" and # preseeded completes without prompting. The defaults are platform "none" and
# mode "manual", which together mean: install the files, configure nothing. # mode "manual", which together mean: install the files, configure nothing.
# The Router Manager's first administrator, asked whatever the platform is; the password only with a name.
db_input medium vpn-router/admin_username || true
db_go || true
db_get vpn-router/admin_username
if [ -n "$RET" ]; then
db_input medium vpn-router/admin_password || true
db_go || true
fi
db_input high vpn-router/platform || true db_input high vpn-router/platform || true
db_go || true db_go || true
+8 -2
View File
@@ -28,6 +28,7 @@ Depends: ${misc:Depends},
python3-psycopg, python3-psycopg,
python3-psutil, python3-psutil,
python3-prometheus-client, python3-prometheus-client,
python3-requests,
postgresql, postgresql,
slapd, slapd,
nslcd, nslcd,
@@ -46,10 +47,10 @@ Suggests: caddy,
pdns-server, pdns-server,
pdns-backend-pgsql, 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, WireGuard and a web manager
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
@@ -64,6 +65,11 @@ Description: Linux VPN router with IPSec, road-warrior, OpenVPN and WireGuard su
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
provider. provider.
. .
The Router Manager, a web application served by the router, shows the
router's state and manages its users, connections, certificates, web sites,
name server, backups and packages. Users sign in through the router's web
service; their sessions are kept in PostgreSQL.
.
Settings live in /etc/vpn-router/vpn-router.json, which is created on first Settings live in /etc/vpn-router/vpn-router.json, which is created on first
install and owned by the administrator afterwards. Editing it and restarting install and owned by the administrator afterwards. Editing it and restarting
vpn-router-setup applies the change; the same service reconciles the system vpn-router-setup applies the change; the same service reconciles the system
+1
View File
@@ -2,4 +2,5 @@ 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
usr/share/vpn-router/manager/web
var/lib/vpn-router/sites var/lib/vpn-router/sites
+10
View File
@@ -17,8 +17,11 @@ 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/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/
@@ -29,13 +32,20 @@ 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/manager usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_manager.py usr/lib/vpn-router/
src/usr/lib/vpn-router/upgrade 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/vpn-router/sql/* usr/share/vpn-router/sql/
src/usr/share/bash-completion/completions/*.bash usr/share/bash-completion/completions/ 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/
src/usr/share/doc/vpn-router/manager.json.example usr/share/doc/vpn-router/
web/dist/* usr/share/vpn-router/manager/web/
docs/* usr/share/vpn-router/manager/docs/
+1
View File
@@ -0,0 +1 @@
usr/bin/vpn-router-editor usr/bin/vre
+21 -2
View File
@@ -63,6 +63,22 @@ case "$1" in
# not blank the key on the next dpkg-reconfigure. # not blank the key on the next dpkg-reconfigure.
db_set vpn-router/psk "" db_set vpn-router/psk ""
# The Router Manager's administrator account for vpn-router-setup to create, when a name and a
# password were given; the answers are cleared so that a later run does not ask for it again.
db_get vpn-router/admin_username
ADMIN_USERNAME="$RET"
db_get vpn-router/admin_password
ADMIN_PASSWORD="$RET"
if [ -n "$ADMIN_USERNAME" ] && [ -n "$ADMIN_PASSWORD" ]; then
(umask 077; ADMIN_USERNAME="$ADMIN_USERNAME" ADMIN_PASSWORD="$ADMIN_PASSWORD" python3 -c \
'import json, os, sys; json.dump({"username": os.environ["ADMIN_USERNAME"], "password": os.environ["ADMIN_PASSWORD"]}, sys.stdout)' \
> /etc/vpn-router/manager-admin.json)
elif [ -n "$ADMIN_USERNAME" ]; then
echo 'vpn-router: no administrator password was given; no Router Manager administrator is created' >&2
fi
db_set vpn-router/admin_username ""
db_set vpn-router/admin_password ""
# Apply the sysctl drop-in shipped by this package so it takes effect # Apply the sysctl drop-in shipped by this package so it takes effect
# without waiting for a reboot. # without waiting for a reboot.
sysctl --system >/dev/null sysctl --system >/dev/null
@@ -71,10 +87,13 @@ 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, web and manager services wait for libpq in the linker cache,
# refreshes after this script, so it is started without waiting for it. # which 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
systemctl --no-block restart vpn-router-manager.service >/dev/null 2>&1 || true
fi fi
;; ;;
esac esac
+9 -2
View File
@@ -56,6 +56,12 @@ case "$1" in
/etc/vpn-router/web.key \ /etc/vpn-router/web.key \
/etc/vpn-router/oidc.key \ /etc/vpn-router/oidc.key \
/etc/vpn-router/metrics.secret \ /etc/vpn-router/metrics.secret \
/etc/vpn-router/manager.json \
/etc/vpn-router/manager-admin.json \
/var/lib/vpn-router/upgrade.json \
/var/lib/vpn-router/upgrade.log \
/var/lib/vpn-router/system-upgrade.json \
/var/lib/vpn-router/system-upgrade.log \
/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 \
/etc/ssh/sshd_config.d/vpn-router.conf \ /etc/ssh/sshd_config.d/vpn-router.conf \
@@ -76,8 +82,9 @@ case "$1" in
fi fi
rm -f /etc/vpn-router/database.secret rm -f /etc/vpn-router/database.secret
# The databases and roles of the web sites and of the name server, when the package created them. # The databases and roles of the web sites, of the name server and of the Router Manager's sessions,
for database in vpn-router-sites vpn-router-dns; do # when the package created them.
for database in vpn-router-sites vpn-router-dns vpn-router-manager; do
secret="/etc/vpn-router/${database#vpn-router-}-database.secret" secret="/etc/vpn-router/${database#vpn-router-}-database.secret"
if [ -f "$secret" ] && command -v psql >/dev/null 2>&1; then 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 DATABASE IF EXISTS \"$database\"" || true
+5 -1
View File
@@ -2,8 +2,12 @@
%: %:
dh $@ dh $@
override_dh_auto_build:
test -f web/dist/index.html
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-manager --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
+23 -5
View File
@@ -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
@@ -13,6 +13,24 @@ Description: Platform:
. .
generic configures the router with no platform-specific additions. generic configures the router with no platform-specific additions.
Template: vpn-router/admin_username
Type: string
Default:
Description: Name of the Router Manager's administrator account:
The Router Manager can have a first administrator: a Manager-only account
of the router's user directory, a member of the group admins, which may
sign in to the Router Manager but has no VPN access. vpn-router-setup
creates it.
.
Leave this empty to create no account and no group.
Template: vpn-router/admin_password
Type: password
Description: Password of the administrator account:
The password of the administrator account, 8 to 128 characters. It is
passed to vpn-router-setup in a file readable by root only, which is removed
once the account exists, and not kept by debconf.
Template: vpn-router/mode Template: vpn-router/mode
Type: select Type: select
Choices: manual, interfaces, auto Choices: manual, interfaces, auto
@@ -58,7 +76,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 +140,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,21 @@
[Unit]
Description=VPN Router Manager web application
Documentation=file:/usr/share/doc/vpn-router/README.md
After=network-online.target postgresql.service
Wants=network-online.target
[Service]
Type=simple
ExecStartPre=/usr/lib/vpn-router/wait-for-libpq
ExecStart=/usr/bin/python3 /usr/lib/vpn-router/manager
TimeoutStartSec=5min
Restart=on-failure
RestartSec=15
NoNewPrivileges=yes
ProtectSystem=strict
ReadWritePaths=-/etc/vpn-router
ProtectHome=yes
PrivateTmp=yes
[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
-40
View File
@@ -1,40 +0,0 @@
#!/bin/sh
set -e
# Apple container CLI on macOS, Docker CE on Linux.
case "$(uname -s)" in
Darwin) CONTAINER_RUNTIME=container ;;
Linux) CONTAINER_RUNTIME=docker ;;
*) printf 'ERROR: unsupported platform %s\n' "$(uname -s)" >&2; exit 1 ;;
esac
OWNER="${OWNER:-slawek}"
SERVER_URL="${SERVER_URL:-https://gitea.koszewscy.waw.pl}"
PACKAGES_TOKEN="${PACKAGES_TOKEN:-$(cat ~/.gitea-packages-token)}"
# Reads the changelog copied into the builder image, so no bind mount is needed.
changelog_field() {
$CONTAINER_RUNTIME run --rm vpn-router-builder \
dpkg-parsechangelog --show-field "$1" -l /build/src/debian/changelog
}
VERSION="$(changelog_field Version)"
PACKAGE="$(changelog_field Source)"
DEB="out/${PACKAGE}_${VERSION}_all.deb"
if [ ! -f "$DEB" ]; then
printf 'ERROR: %s does not exist. Run ./build.sh first.\n' "$DEB" >&2
exit 1
fi
STATUS="$(curl -s -o /dev/null -w '%{http_code}' --user "$OWNER:$PACKAGES_TOKEN" \
"$SERVER_URL/api/v1/packages/$OWNER/debian/$PACKAGE/$VERSION")"
case "$STATUS" in
200) printf '%s %s is already published, skipping.\n' "$PACKAGE" "$VERSION"; exit 0 ;;
404) ;;
*) printf 'ERROR: version check returned HTTP %s.\n' "$STATUS" >&2; exit 1 ;;
esac
curl --fail-with-body --user "$OWNER:$PACKAGES_TOKEN" \
--upload-file "$DEB" \
"$SERVER_URL/api/packages/$OWNER/debian/pool/noble/main/upload"
+71
View File
@@ -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:]))
+27 -10
View File
@@ -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
@@ -15,14 +16,16 @@ PORT = 17081
#: The title of each section and its command. #: The title of each section and its command.
COMMANDS = ( COMMANDS = (
('Setup state', ['cat', '/run/vpn-router/setup.json']), ('Setup state', ['cat', '/run/vpn-router/setup.json']),
('Services', ['systemctl', 'is-active', 'vpn-router-setup', 'vpn-router-web', 'slapd', 'postgresql', ('Services', ['systemctl', 'is-active', 'vpn-router-setup', 'vpn-router-web', 'vpn-router-manager', 'slapd',
'strongswan', 'openvpn-server@vpn-router', 'wg-quick@wg0']), 'postgresql', 'strongswan', 'openvpn-server@vpn-router', 'wg-quick@wg0']),
('Failed units', ['systemctl', '--failed', '--no-legend', '--plain']), ('Failed units', ['systemctl', '--failed', '--no-legend', '--plain']),
('Addresses', ['ip', '-brief', 'address']), ('Addresses', ['ip', '-brief', 'address']),
('Routes', ['ip', 'route']), ('Routes', ['ip', 'route']),
('cloud-init', ['cloud-init', 'status', '--long']), ('cloud-init', ['cloud-init', 'status', '--long']),
('vpn-router-setup log', ['journalctl', '--unit', 'vpn-router-setup', '--lines', '60', '--no-pager', '--output', 'short-iso']), ('vpn-router-setup log', ['journalctl', '--unit', 'vpn-router-setup', '--lines', '60', '--no-pager', '--output', 'short-iso']),
('vpn-router-web log', ['journalctl', '--unit', 'vpn-router-web', '--lines', '30', '--no-pager', '--output', 'short-iso']), ('vpn-router-web log', ['journalctl', '--unit', 'vpn-router-web', '--lines', '30', '--no-pager', '--output', 'short-iso']),
('vpn-router-manager log', ['journalctl', '--unit', 'vpn-router-manager', '--lines', '30', '--no-pager', '--output',
'short-iso']),
) )
@@ -42,14 +45,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
View File
@@ -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:]))
+903
View File
@@ -0,0 +1,903 @@
#!/usr/bin/python3
"""Serve the Router Manager: the web application and its API, which passes calls to the router.
Run by vpn-router-manager.service. It uses python3-requests to call the router and
python3-psycopg to keep the sessions; its settings and its listening loop are in
vpnrouter_manager.
Pages:
/api/info the version of this package and the router it manages, as JSON
/api/session POST signs in through the router's /auth with {"name", "password", "code"?},
GET answers the signed-in user, DELETE signs out
/api/session/passkey
POST begins a sign-in with a passkey at the router's /auth/passkey;
/api/session/passkey/complete POST signs in through /auth/passkey/complete
/api/updates GET the installed and the candidate version of vpn-router
from apt (see updates()), POST updates apt's package lists (see start_check());
needs a session, POST a member of admins
/api/upgrade GET the state and the output of the upgrade of that package (see upgrade_state()),
POST starts it (see start_upgrade()); needs a session, POST a member of admins
/api/system-updates
GET the packages of the system apt-get upgrade would upgrade, with their installed
and candidate versions (see system_updates()); needs a session
/api/system-upgrade
GET the state and the output of the upgrade of the system's packages, POST starts
it (see start_upgrade()); needs a session, POST a member of admins
/api/reboot POST reboots the system (see start_reboot()); needs a member of admins
/api/shell a WebSocket handshake goes to the router's /shell, and the WebSocket is relayed
(see Handler.relay()); POST passes as every other path does
/api/<path> the router's web service at /<path>, with the access token of the session;
the method, the body and the answer pass unchanged; the files of a web site,
/api/sites/<name>/files/..., pass streamed for GET, HEAD and PUT (see Handler.transfer())
/docs/<file> a file of the help in DOCS_DIR, Markdown, SVG or PNG (see doc_file()); needs a session
/<file> the web application from WEB_DIR; a path that names no file gives index.html
A session is a row of the table sessions in PostgreSQL, found by the SHA-256 of a random id in
the HttpOnly cookie COOKIE; it holds the user's access and refresh tokens and ends with the
refresh token. An access token that expires within REFRESH_MARGIN seconds is renewed through
the router's /auth/refresh before a call; a refused renewal ends the session. /api/<path>
without a session answers 401. Sign-in calls carry the browser's address in X-Forwarded-For,
which the router reads for its rate limit when the manager is one of its trusted proxies.
Without router_url the API answers 503.
The manager first initializes, without listening: on the router, unless the settings name a
database, it creates its role, password and database when they are missing; then it connects to
the sessions database and creates its table, retrying every INIT_RETRY_SECONDS until it succeeds
and logging each failure. Then it listens. Its unit starts it only once wait-for-libpq has found
libpq in the linker cache, where psycopg looks it up.
The service needs nothing else to start and does not end on its own. Every DISCOVERY_SECONDS it
works out where to listen, bind or the interfaces listen names, and runs a server at each address
(vpnrouter_manager.serve). Every request
reads the settings when it is made.
"""
import argparse
import hashlib
import http.cookies
import json
import mimetypes
import os
import re
import secrets
import signal
import socket
import ssl
import subprocess
import sys
import threading
import time
import urllib.parse
from http.server import BaseHTTPRequestHandler
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
import psycopg
import requests
import urllib3
import vpnrouter_manager
from vpnrouter_manager import DATABASE, DATABASE_SECRET, ROUTER_CONFIG_FILE
#: The address a proxy on this host calls from.
LOOPBACK = '127.0.0.1'
CONFIG_FILE = vpnrouter_manager.CONFIG_FILE
WEB_DIR = '/usr/share/vpn-router/manager/web'
DOCS_DIR = '/usr/share/vpn-router/manager/docs'
#: The files of DOCS_DIR /docs serves, by extension, and their content types.
DOC_TYPES = {'.md': 'text/markdown; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png'}
#: The headers of every /docs answer: no type guessing, and an SVG opened on its own runs no script.
DOC_HEADERS = (('X-Content-Type-Options', 'nosniff'), ('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'"))
#: The cookie that holds the session id.
COOKIE = 'vpn_router_manager_session'
#: Seconds before its expiry an access token is renewed.
REFRESH_MARGIN = 30
#: Seconds between two attempts of the initialization.
INIT_RETRY_SECONDS = 15
#: The packages /api/updates reports.
PACKAGES = ('vpn-router',)
#: Touched by apt at every successful update of its package lists.
APT_UPDATED = '/var/lib/apt/periodic/update-success-stamp'
#: The transient units of the upgrade, of the reboot and of the update of the package lists, outside the manager's unit.
UPGRADE_UNIT = 'vpn-router-manager-upgrade'
SYSTEM_UPGRADE_UNIT = 'vpn-router-manager-system-upgrade'
REBOOT_UNIT = 'vpn-router-manager-reboot'
CHECK_UNIT = 'vpn-router-manager-check'
#: Seconds the update of the package lists waits for apt's lock, which Ubuntu's apt-daily or unattended-upgrades may hold.
CHECK_LOCK_SECONDS = 300
#: The upgrade helper, and the state and the output it records of the router's package and of the system's.
UPGRADE = '/usr/lib/vpn-router/upgrade'
UPGRADE_STATE = '/var/lib/vpn-router/upgrade.json'
UPGRADE_LOG = '/var/lib/vpn-router/upgrade.log'
SYSTEM_UPGRADE_STATE = '/var/lib/vpn-router/system-upgrade.json'
SYSTEM_UPGRADE_LOG = '/var/lib/vpn-router/system-upgrade.log'
#: A package apt-get -s upgrade would upgrade: Inst <name> [<installed>] (<candidate> <release> [<architecture>]).
SIMULATED = re.compile(r'^Inst (\S+) (?:\[([^\]]*)\] )?\((\S+)')
#: The last lines of the upgrade's output /api/upgrade answers.
LOG_LINES = 200
#: Written by packages that need a reboot, with the packages in REBOOT_REQUIRED.pkgs.
REBOOT_REQUIRED = '/var/run/reboot-required'
#: Seconds between the answer of /api/reboot and the reboot.
REBOOT_DELAY = 5
SCHEMA = ('CREATE TABLE IF NOT EXISTS sessions (id_hash text PRIMARY KEY, name text NOT NULL, access_token text NOT NULL, '
'access_expires timestamptz NOT NULL, refresh_token text NOT NULL, expires timestamptz NOT NULL)')
#: Seconds between two discoveries of where the service should listen.
DISCOVERY_SECONDS = 30
#: Seconds a call to the router may take; adding a user creates an RSA key.
ROUTER_TIMEOUT = 130
#: The largest request body passed to the router, in bytes; a CSR or a certificate chain in PEM is a few kilobytes.
BODY_LIMIT = 65536
#: The largest body of a save of the Shell page's editor, /api/shell/editor/...: a file of up to 1 MiB, as the router
#: takes, escaped in JSON.
EDITOR_BODY_LIMIT = 2 * 1024 * 1024 + BODY_LIMIT
#: The paths of a web site's files, whose GET, HEAD and PUT pass streamed (see Handler.transfer()).
SITE_FILES = re.compile(r'^/api/sites/[^/]+/files(/.*)?$')
def settings():
return vpnrouter_manager.settings(CONFIG_FILE)
def package_version():
try:
result = subprocess.run(['dpkg-query', '-W', '-f=${Version}', 'vpn-router'],
capture_output=True, text=True, timeout=5)
except (OSError, subprocess.TimeoutExpired):
return None
return result.stdout.strip() if result.returncode == 0 else None
def updates():
"""The installed and the candidate version of each of PACKAGES, from apt-cache policy, and whether an update is available.
The candidate is the version apt would install from the sources it is configured with, as of
its last update of the package lists, whose time checked gives (APT_UPDATED, None when it is
not known); a version apt does not know is None. checking is whether start_check's update of
the package lists runs. Where apt cannot be asked, available is False and the packages are empty.
"""
checking = unit_active(CHECK_UNIT)
try:
result = subprocess.run(['apt-cache', 'policy', *PACKAGES], capture_output=True, text=True, timeout=30)
except (OSError, subprocess.TimeoutExpired):
return {'available': False, 'checked': None, 'checking': checking, 'packages': []}
try:
checked = time.strftime('%Y-%m-%dT%H:%M:%S%z', time.localtime(os.path.getmtime(APT_UPDATED)))
except OSError:
checked = None
found = {}
package = None
for line in result.stdout.splitlines():
if line and not line[0].isspace() and line.endswith(':'):
package = found.setdefault(line[:-1], {'name': line[:-1], 'installed': None, 'candidate': None})
elif package is not None and line.strip().startswith(('Installed:', 'Candidate:')):
key, _, value = line.strip().partition(': ')
package[key.lower()] = None if value == '(none)' else value
packages = [dict(found.get(name, {'name': name, 'installed': None, 'candidate': None})) for name in PACKAGES]
for package in packages:
package['update'] = package['installed'] is not None and package['candidate'] not in (None, package['installed'])
return {'available': True, 'checked': checked, 'checking': checking, 'packages': packages}
def system_updates():
"""The packages of the system apt-get upgrade would upgrade, from its simulation (apt-get -s), as updates() answers
its packages, each with update true, sorted by name; the other keys are those of updates().
The simulation takes no lock and changes nothing; it answers what the package lists of apt's last update give.
"""
checking = unit_active(CHECK_UNIT)
try:
result = subprocess.run(['apt-get', '-s', '-q', 'upgrade'], capture_output=True, text=True, timeout=60,
env=dict(os.environ, LC_ALL='C'))
except (OSError, subprocess.TimeoutExpired):
return {'available': False, 'checked': None, 'checking': checking, 'packages': []}
if result.returncode != 0:
return {'available': False, 'checked': None, 'checking': checking, 'packages': []}
try:
checked = time.strftime('%Y-%m-%dT%H:%M:%S%z', time.localtime(os.path.getmtime(APT_UPDATED)))
except OSError:
checked = None
found = {}
for line in result.stdout.splitlines():
match = SIMULATED.match(line)
if match:
found[match[1]] = {'name': match[1], 'installed': match[2], 'candidate': match[3], 'update': True}
return {'available': True, 'checked': checked, 'checking': checking, 'packages': [found[name] for name in sorted(found)]}
def unit_active(unit):
"""Whether a unit of systemd is active or activating; False where systemctl cannot be asked."""
try:
result = subprocess.run(['systemctl', 'is-active', f'{unit}.service', f'{unit}.timer'],
capture_output=True, text=True, timeout=5)
except (OSError, subprocess.TimeoutExpired):
return False
return any(line.strip() in ('active', 'activating') for line in result.stdout.splitlines())
def upgrade_state(system=False):
"""The state of the upgrade of the router's package, as GET /api/upgrade answers it, or with system of the system's
packages, as GET /api/system-upgrade answers it.
state is idle before the first upgrade, running, succeeded or failed as the helper recorded it, and interrupted when
it recorded running but its unit is not active, as after a reboot during the upgrade; the other keys of the helper's
state follow, and log holds the last LOG_LINES lines of its output. reboot_required and reboot_packages tell whether
a package asks for a reboot, and rebooting whether one is about to happen.
"""
state_file, log_file = (SYSTEM_UPGRADE_STATE, SYSTEM_UPGRADE_LOG) if system else (UPGRADE_STATE, UPGRADE_LOG)
try:
with open(state_file, encoding='utf-8') as handle:
recorded = json.load(handle)
except (OSError, ValueError):
recorded = {}
if not isinstance(recorded, dict):
recorded = {}
running = unit_active(SYSTEM_UPGRADE_UNIT if system else UPGRADE_UNIT)
state = recorded.get('state', 'idle')
if running:
state = 'running'
elif state == 'running':
state = 'interrupted'
try:
with open(log_file, encoding='utf-8', errors='replace') as handle:
log = handle.read().splitlines()[-LOG_LINES:]
except OSError:
log = []
try:
with open(f'{REBOOT_REQUIRED}.pkgs', encoding='utf-8') as handle:
reboot_packages = sorted({line.strip() for line in handle if line.strip()})
except OSError:
reboot_packages = []
return {
'state': state,
'user': recorded.get('user'),
'started': recorded.get('started'),
'finished': recorded.get('finished'),
'exit_status': recorded.get('exit_status'),
'before': recorded.get('before'),
'after': recorded.get('after'),
'log': log,
'reboot_required': os.path.exists(REBOOT_REQUIRED),
'reboot_packages': reboot_packages,
'rebooting': unit_active(REBOOT_UNIT),
}
def transient(*args):
"""Start a transient unit with systemd-run and the arguments; the HTTP status and the message of a refusal, or None."""
try:
result = subprocess.run(['systemd-run', '--quiet', '--collect', *args], capture_output=True, text=True, timeout=15)
except (OSError, subprocess.TimeoutExpired) as exc:
return 503, f'systemd-run cannot be run here: {exc}'
if result.returncode != 0:
message = result.stderr.strip() or f'systemd-run ended with status {result.returncode}'
return (409 if 'already' in message.lower() else 500), message
return None
def busy():
"""Whether an upgrade, of the router's package or of the system's, or a reboot runs."""
return unit_active(UPGRADE_UNIT) or unit_active(SYSTEM_UPGRADE_UNIT) or unit_active(REBOOT_UNIT)
def start_upgrade(name, system=False):
"""Start the upgrade of the router's package in the transient unit UPGRADE_UNIT, or with system of the system's
packages in SYSTEM_UPGRADE_UNIT, outside this service, so the restarts it makes do not end it.
The HTTP status and the JSON document: 202 when it started, 409 while an upgrade or a reboot runs, 503 where apt
cannot be used.
"""
if busy():
return 409, {'error': 'an upgrade or a reboot is already running'}
if not os.path.exists('/usr/bin/apt-get'):
return 503, {'error': 'apt is not available here'}
if system:
refused = transient(f'--unit={SYSTEM_UPGRADE_UNIT}', '--description=VPN Router system upgrade',
'--property=Type=exec', '--no-block', '/usr/bin/python3', UPGRADE, '--system', name)
else:
refused = transient(f'--unit={UPGRADE_UNIT}', '--description=VPN Router package upgrade', '--property=Type=exec',
'--no-block', '/usr/bin/python3', UPGRADE, name)
if refused:
return refused[0], {'error': refused[1]}
return 202, {'started': True}
def start_check():
"""Update apt's package lists in the transient unit CHECK_UNIT, as root outside this service's sandbox, and touch
APT_UPDATED when it succeeds, so updates() answers the new candidates and the time of the check.
The HTTP status and the JSON document: 202 when it started, 409 while an update of the lists, an upgrade or a
reboot runs, 503 where apt cannot be used.
"""
if unit_active(CHECK_UNIT) or busy():
return 409, {'error': 'a check for updates, an upgrade or a reboot is already running'}
if not os.path.exists('/usr/bin/apt-get'):
return 503, {'error': 'apt is not available here'}
refused = transient(f'--unit={CHECK_UNIT}', '--description=VPN Router package lists update', '--property=Type=oneshot',
f'--property=ExecStartPost=/usr/bin/touch {APT_UPDATED}', '--no-block',
'/usr/bin/apt-get', '-q', '-o', f'DPkg::Lock::Timeout={CHECK_LOCK_SECONDS}', 'update')
if refused:
return refused[0], {'error': refused[1]}
return 202, {'checking': True}
def start_reboot():
"""Reboot the system REBOOT_DELAY seconds from now, so the answer reaches the browser first.
The HTTP status and the JSON document: 202 when the reboot is scheduled, 409 while an upgrade or a reboot runs.
"""
if busy():
return 409, {'error': 'an upgrade or a reboot is already running'}
refused = transient(f'--unit={REBOOT_UNIT}', '--description=VPN Router reboot', f'--on-active={REBOOT_DELAY}',
'/usr/bin/systemctl', 'reboot')
if refused:
return refused[0], {'error': refused[1]}
return 202, {'rebooting': True}
def admin(current, access, client):
"""Whether the signed-in user is a member of the router's admins, as the router's /account answers."""
response = router_call(current, 'GET', '/account', None, {'Authorization': f'Bearer {access}', 'X-Forwarded-For': client})
try:
return response.ok and response.json().get('admin') is True
except ValueError:
return False
def error(code, message):
return code, 'application/json', json.dumps({'error': message}).encode() + b'\n'
class Unavailable(Exception):
"""A setting the API needs is missing, or the router or the database cannot be reached; the HTTP status and message."""
def __init__(self, code, message):
super().__init__(message)
self.code = code
def router_call(current, method, path, body=None, headers=None):
"""The router's answer to a call; Unavailable when it cannot be reached."""
if not current['router_url']:
raise Unavailable(503, f'no router is configured; set router_url in {CONFIG_FILE}')
headers = dict(headers or {}, Accept='application/json')
if body is not None:
headers['Content-Type'] = 'application/json'
try:
return requests.request(method, current['router_url'] + path, data=body, headers=headers,
timeout=ROUTER_TIMEOUT, allow_redirects=False)
except requests.RequestException as failure:
raise Unavailable(502, f'the router at {current["router_url"]} cannot be reached: {failure}')
def answer_of(response):
"""The HTTP status, the content type and the body to pass on for an answer of the router."""
kind = response.headers.get('Content-Type', '')
if response.ok or kind.startswith('application/json'):
return response.status_code, kind or 'application/json', response.content
return error(response.status_code, f'the router answered {response.status_code}: {response.text.strip()}')
def database(current):
"""A connection to the sessions database, with its table; Unavailable when it is not configured or fails."""
if not current['database']:
raise Unavailable(503, f'no session database is configured; set database in {CONFIG_FILE}')
try:
connection = psycopg.connect(current['database'], autocommit=True)
connection.execute(SCHEMA)
except psycopg.Error as failure:
raise Unavailable(503, f'the session database cannot be used: {failure}')
return connection
def psql(sql):
"""The output of an SQL statement, read from standard input, run by psql as postgres with DATABASE as the variable v1."""
result = subprocess.run(['runuser', '-u', 'postgres', '--', 'psql', '-X', '-q', '-t', '-A', '-v', 'ON_ERROR_STOP=1',
'-v', f'v1={DATABASE}'], input=sql, capture_output=True, text=True)
if result.returncode != 0:
raise OSError(f'psql failed: {result.stderr.strip()}')
return result.stdout.strip()
def provision():
"""Create the role, its password file and the database on the router, each when missing.
The password, of URL-safe characters, reaches psql on standard input, not on its command line.
"""
if not psql("SELECT 1 FROM pg_roles WHERE rolname = :'v1'"):
if os.path.exists(DATABASE_SECRET):
os.unlink(DATABASE_SECRET)
psql('CREATE ROLE :"v1" LOGIN')
if not os.path.exists(DATABASE_SECRET):
password = secrets.token_urlsafe(32)
psql(f"ALTER ROLE :\"v1\" PASSWORD '{password}'")
os.makedirs(os.path.dirname(DATABASE_SECRET), exist_ok=True)
descriptor = os.open(DATABASE_SECRET, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(descriptor, 'w', encoding='utf-8') as handle:
handle.write(password + '\n')
if not psql("SELECT 1 FROM pg_database WHERE datname = :'v1'"):
psql('CREATE DATABASE :"v1" OWNER :"v1"')
def initialize():
"""Create the sessions database on the router when it is missing and connect to it, retrying every INIT_RETRY_SECONDS."""
reported = None
while True:
phase = 'reading the settings'
try:
configured = vpnrouter_manager.text_setting(vpnrouter_manager.read_json(CONFIG_FILE), 'database')
if not configured and os.path.exists(ROUTER_CONFIG_FILE):
phase = 'creating the sessions database'
provision()
phase = 'connecting to the sessions database'
database(settings()).close()
except (OSError, Unavailable) as exc:
failure = f'{phase} failed: {exc}'
if failure != reported:
log(f'{failure}; retrying every {INIT_RETRY_SECONDS} seconds')
reported = failure
time.sleep(INIT_RETRY_SECONDS)
continue
log('ready')
return
def digest(session_id):
return hashlib.sha256(session_id.encode()).hexdigest()
def tokens_of(response):
"""The tokens of a 200 answer of /auth or /auth/refresh, or None when it holds none."""
try:
document = response.json()
values = (document['access_token'], int(document['expires_in']), document['refresh_token'],
int(document['refresh_expires_in']))
except (ValueError, KeyError, TypeError):
return None
return values if response.status_code == 200 and all(values) else None
def sign_in(current, path, body, client):
"""Sign in through the router's path, /auth or /auth/passkey/complete: the HTTP status, the content type, the body
and the session id, or None."""
response = router_call(current, 'POST', path, body, {'X-Forwarded-For': client})
tokens = tokens_of(response)
if tokens is None:
return (*answer_of(response), None)
try:
name = response.json()['name']
except (ValueError, KeyError, TypeError):
return (*error(502, 'the router named no user'), None)
session_id = secrets.token_urlsafe(32)
access, access_seconds, refresh, refresh_seconds = tokens
with database(current) as connection:
connection.execute('DELETE FROM sessions WHERE expires < now()')
connection.execute('INSERT INTO sessions VALUES (%s, %s, %s, now() + make_interval(secs => %s), %s, '
'now() + make_interval(secs => %s))',
(digest(session_id), name, access, access_seconds, refresh, refresh_seconds))
return 200, 'application/json', json.dumps({'name': name}).encode() + b'\n', session_id
def session(current, session_id, client):
"""The user name and a current access token of the session, renewed when it expires soon, or None."""
if not session_id:
return None
with database(current) as connection:
with connection.transaction():
row = connection.execute('SELECT name, access_token, access_expires < now() + make_interval(secs => %s), '
'refresh_token FROM sessions WHERE id_hash = %s AND expires > now() FOR UPDATE',
(REFRESH_MARGIN, digest(session_id))).fetchone()
if row is None:
return None
name, access, stale, refresh = row
if not stale:
return name, access
response = router_call(current, 'POST', '/auth/refresh', json.dumps({'refresh_token': refresh}).encode(),
{'X-Forwarded-For': client})
tokens = tokens_of(response)
if tokens is None:
connection.execute('DELETE FROM sessions WHERE id_hash = %s', (digest(session_id),))
return None
access, access_seconds, refresh, refresh_seconds = tokens
connection.execute('UPDATE sessions SET access_token = %s, access_expires = now() + make_interval(secs => %s), '
'refresh_token = %s, expires = now() + make_interval(secs => %s) WHERE id_hash = %s',
(access, access_seconds, refresh, refresh_seconds, digest(session_id)))
return name, access
def sign_out(current, session_id):
"""End the session and revoke its refresh token at the router."""
if not session_id:
return
with database(current) as connection:
row = connection.execute('DELETE FROM sessions WHERE id_hash = %s RETURNING refresh_token',
(digest(session_id),)).fetchone()
if row:
router_call(current, 'POST', '/auth/logout', json.dumps({'refresh_token': row[0]}).encode())
def static(path):
"""A file of the web application; a path that names no file gives index.html, so the application routes it."""
root = os.path.realpath(WEB_DIR)
name = os.path.realpath(os.path.join(root, path.lstrip('/')))
if not name.startswith(root + os.sep) or not os.path.isfile(name):
name = os.path.join(root, 'index.html')
try:
with open(name, 'rb') as handle:
data = handle.read()
except OSError:
return 404, 'text/plain; charset=utf-8', b'Not found\n'
return 200, mimetypes.guess_type(name)[0] or 'application/octet-stream', data
def doc_file(path, root=DOCS_DIR):
"""The file of the help that a /docs/<file> path names and its content type, or None: a file inside root whose
extension DOC_TYPES lists."""
root = os.path.realpath(root)
name = os.path.realpath(os.path.join(root, urllib.parse.unquote(path).lstrip('/')))
kind = DOC_TYPES.get(os.path.splitext(name)[1].lower())
if kind is None or not name.startswith(root + os.sep) or not os.path.isfile(name):
return None
return name, kind
class Stream:
"""A request body of length bytes read from a stream as requests sends it, which takes its length from len()."""
def __init__(self, stream, length):
self.stream = stream
self.length = length
self.left = length
def __len__(self):
return self.length
def read(self, size=-1):
size = self.left if size is None or size < 0 else min(size, self.left)
data = self.stream.read(size) if size else b''
self.left -= len(data)
return data
class Handler(BaseHTTPRequestHandler):
protocol_version = 'HTTP/1.1'
def session_id(self):
"""The session id of the request's cookie, or ''."""
cookies = http.cookies.SimpleCookie()
try:
cookies.load(self.headers.get('Cookie', ''))
except http.cookies.CookieError:
return ''
return cookies[COOKIE].value if COOKIE in cookies else ''
def proxied(self):
"""True for a call from a proxy on this host, such as the router's HTTPS proxy, which names the client."""
return self.client_address[0] == LOOPBACK and 'X-Forwarded-For' in self.headers
def client(self):
"""The browser's address: the last X-Forwarded-For entry of a proxied call, else the peer."""
if self.proxied():
return self.headers['X-Forwarded-For'].split(',')[-1].strip()
return self.client_address[0]
def api(self, path, route, payload):
"""Answer an /api/ request: the HTTP status, the content type, the body and the Set-Cookie value or None."""
current = settings()
client = self.client()
session_id = self.session_id()
if route == '/api/session/passkey' and self.command == 'POST':
return (*answer_of(router_call(current, 'POST', '/auth/passkey', b'{}', {'X-Forwarded-For': client})), None)
if route in ('/api/session', '/api/session/passkey/complete') and self.command == 'POST':
code, kind, body, created = sign_in(current, '/auth' if route == '/api/session' else '/auth/passkey/complete',
payload, client)
if created is None:
return code, kind, body, None
return code, kind, body, f'{COOKIE}={created}; HttpOnly; SameSite=Strict; Path=/'
if route == '/api/session' and self.command == 'DELETE':
sign_out(current, session_id)
return 200, 'application/json', b'{"signed_out": true}\n', f'{COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0'
found = session(current, session_id, client)
if found is None:
return (*error(401, 'not signed in'), None)
name, access = found
if route == '/api/session':
return 200, 'application/json', json.dumps({'name': name}).encode() + b'\n', None
if route == '/api/updates' and self.command in ('GET', 'HEAD'):
return 200, 'application/json', json.dumps(updates()).encode() + b'\n', None
if route == '/api/updates' and self.command == 'POST':
if not admin(current, access, client):
return (*error(403, f'user {name} is not a member of admins'), None)
code, document = start_check()
return code, 'application/json', json.dumps(document).encode() + b'\n', None
if route == '/api/system-updates' and self.command in ('GET', 'HEAD'):
return 200, 'application/json', json.dumps(system_updates()).encode() + b'\n', None
if route in ('/api/upgrade', '/api/system-upgrade') and self.command in ('GET', 'HEAD'):
return 200, 'application/json', json.dumps(upgrade_state(route == '/api/system-upgrade')).encode() + b'\n', None
if route in ('/api/upgrade', '/api/system-upgrade', '/api/reboot') and self.command == 'POST':
if not admin(current, access, client):
return (*error(403, f'user {name} is not a member of admins'), None)
code, document = start_reboot() if route == '/api/reboot' else start_upgrade(name, route == '/api/system-upgrade')
return code, 'application/json', json.dumps(document).encode() + b'\n', None
return (*answer_of(router_call(current, self.command, path[4:], payload,
{'Authorization': f'Bearer {access}', 'X-Forwarded-For': client})), None)
def relay(self, path):
"""Pass the WebSocket handshake of /api/shell to the router's /shell with the session's access token, then
relay the bytes both ways until either side closes; the HTTP status, the content type and the body of a
refusal, or None after a relayed WebSocket.
The handshake must come from a page of this host (Origin), and asks the router for no extension, so the
frames pass unchanged.
"""
current = settings()
client = self.client()
if urllib.parse.urlsplit(self.headers.get('Origin', '')).netloc != self.headers.get('Host', ''):
return error(403, 'the shell must be opened from a page of the Router Manager')
found = session(current, self.session_id(), client)
if found is None:
return error(401, 'not signed in')
if not current['router_url']:
raise Unavailable(503, f'no router is configured; set router_url in {CONFIG_FILE}')
url = urllib.parse.urlsplit(current['router_url'] + path[4:])
secure = url.scheme == 'https'
request = '\r\n'.join([
f'GET {url.path}?{url.query} HTTP/1.1', f'Host: {url.netloc}', 'Upgrade: websocket', 'Connection: Upgrade',
f'Sec-WebSocket-Key: {self.headers.get("Sec-WebSocket-Key", "")}',
f'Sec-WebSocket-Version: {self.headers.get("Sec-WebSocket-Version", "")}',
f'Authorization: Bearer {found[1]}', f'X-Forwarded-For: {client}', '', ''])
try:
upstream = socket.create_connection((url.hostname, url.port or (443 if secure else 80)), timeout=ROUTER_TIMEOUT)
if secure:
upstream = ssl.create_default_context().wrap_socket(upstream, server_hostname=url.hostname)
upstream.sendall(request.encode())
reader = upstream.makefile('rb')
code = int(reader.readline().split(b' ', 2)[1])
headers = {}
while (line := reader.readline().strip()):
name, _, value = line.decode('latin-1').partition(':')
headers[name.strip().lower()] = value.strip()
if code != 101:
body = reader.read(int(headers.get('content-length', '0')))
upstream.close()
return code, headers.get('content-type', 'application/json'), body
upstream.settimeout(None)
except (OSError, ValueError, IndexError) as failure:
raise Unavailable(502, f'the router at {current["router_url"]} cannot open a shell: {failure}')
self.close_connection = True
try:
self.send_response(101, 'Switching Protocols')
self.send_header('Upgrade', 'websocket')
self.send_header('Connection', 'Upgrade')
self.send_header('Sec-WebSocket-Accept', headers.get('sec-websocket-accept', ''))
self.end_headers()
except ConnectionError:
# The client went away before the shell was relayed; the router ends the shell when upstream closes.
upstream.close()
return None
def pump(source, target):
try:
while data := source.read1(65536):
target.sendall(data)
except OSError:
pass
finally:
for end in (upstream, self.connection):
try:
end.shutdown(socket.SHUT_RDWR)
except OSError:
pass
thread = threading.Thread(target=pump, args=(reader, self.connection), name='shell output', daemon=True)
thread.start()
pump(self.rfile, upstream)
thread.join()
upstream.close()
return None
def transfer(self, path, send_body):
"""Pass a GET, HEAD or PUT of a web site's files to the router with the session's access token, streaming the
request body and the answer; the HTTP status, the content type and the body of a refusal, or None once the
router's answer was passed on.
The body of a PUT goes to the router as it arrives, with its Content-Type and Content-Length, so it is not held
to BODY_LIMIT; the answer passes with its Content-Type, Content-Length and Content-Disposition.
"""
current = settings()
client = self.client()
found = session(current, self.session_id(), client)
if found is None:
return error(401, 'not signed in')
if not current['router_url']:
raise Unavailable(503, f'no router is configured; set router_url in {CONFIG_FILE}')
headers = {'Authorization': f'Bearer {found[1]}', 'X-Forwarded-For': client}
body = None
if self.command == 'PUT':
length = self.headers.get('Content-Length', '')
if not length.isdigit():
return error(411, 'the body needs a Content-Length')
headers['Content-Type'] = self.headers.get('Content-Type') or 'application/octet-stream'
body = Stream(self.rfile, int(length))
try:
response = requests.request(self.command, current['router_url'] + path[4:], data=body, headers=headers,
timeout=ROUTER_TIMEOUT, allow_redirects=False, stream=True)
except requests.RequestException as failure:
raise Unavailable(502, f'the router at {current["router_url"]} cannot be reached: {failure}')
with response:
try:
self.send_response(response.status_code)
for name in ('Content-Type', 'Content-Length', 'Content-Disposition'):
if name in response.headers:
self.send_header(name, response.headers[name])
self.send_header('Cache-Control', 'no-store')
self.end_headers()
if send_body:
for chunk in response.raw.stream(65536, decode_content=False):
self.wfile.write(chunk)
except ConnectionError:
# The client went away before the answer was passed on.
self.close_connection = True
except urllib3.exceptions.HTTPError as failure:
# The router's answer broke off after its headers were passed on.
self.close_connection = True
log(f'the router\'s answer to {self.command} {path.split("?", 1)[0]} broke off: {failure}')
return None
def doc(self, path):
"""Answer GET /docs/<path> with a file of the help (doc_file): the HTTP status, the content type and the body; 401
without a session, 404 for a path that names no such file."""
try:
if session(settings(), self.session_id(), self.client()) is None:
return error(401, 'not signed in')
except Unavailable as failure:
return error(failure.code, str(failure))
except psycopg.Error as failure:
return error(503, f'the session database failed: {failure}')
found = doc_file(path)
if found is None:
return 404, 'text/plain; charset=utf-8', b'Not found\n'
try:
with open(found[0], 'rb') as handle:
return 200, found[1], handle.read()
except OSError:
return 404, 'text/plain; charset=utf-8', b'Not found\n'
def answer(self, send_body):
path = self.path.split('#', 1)[0]
route = path.split('?', 1)[0]
cookie = 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
if route == '/api/info':
https = self.proxied() and self.headers.get('X-Forwarded-Proto') == 'https'
status = vpnrouter_manager.status_url(CONFIG_FILE, self.headers.get('Host', ''), self.server.server_address[0], https)
body = json.dumps({'version': package_version(), 'status_url': status or None}).encode()
code, kind = 200, 'application/json'
elif route == '/api/shell' and self.command == 'GET' and self.headers.get('Upgrade', '').lower() == 'websocket':
try:
refused = self.relay(path)
except Unavailable as failure:
refused = error(failure.code, str(failure))
except psycopg.Error as failure:
refused = error(503, f'the session database failed: {failure}')
if refused is None:
return
code, kind, body = refused
elif SITE_FILES.match(route) and self.command in ('GET', 'HEAD', 'PUT'):
try:
refused = self.transfer(path, send_body)
except Unavailable as failure:
refused = error(failure.code, str(failure))
except psycopg.Error as failure:
refused = error(503, f'the session database failed: {failure}')
if refused is None:
return
code, kind, body = refused
elif route.startswith('/api/'):
limit = EDITOR_BODY_LIMIT if route.startswith('/api/shell/editor/') else BODY_LIMIT
payload = self.request_body(limit)
if payload is False:
code, kind, body = error(413, f'the body is larger than {limit} bytes')
else:
try:
code, kind, body, cookie = self.api(path, route, payload)
except Unavailable as failure:
code, kind, body = error(failure.code, str(failure))
except psycopg.Error as failure:
code, kind, body = error(503, f'the session database failed: {failure}')
elif route.startswith('/docs/') and self.command in ('GET', 'HEAD'):
code, kind, body = self.doc(route[len('/docs/'):])
elif self.command in ('GET', 'HEAD'):
code, kind, body = static(route)
else:
code, kind, body = 405, 'text/plain; charset=utf-8', b'Method not allowed\n'
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' if route.startswith('/api/') or not route.startswith('/assets/') else 'max-age=31536000, immutable')
if cookie:
self.send_header('Set-Cookie', cookie)
for name, value in DOC_HEADERS if route.startswith('/docs/') else ():
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 request_body(self, limit=BODY_LIMIT):
"""The request body, None when there is none, or False when it is larger than limit bytes."""
length = self.headers.get('Content-Length')
if not length:
return None
try:
size = int(length)
except ValueError:
return False
if not 0 <= size <= limit:
return False
return self.rfile.read(size)
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):
sys.stderr.write('%s - - [%s] %s\n' % (self.client(), self.log_date_time_string(), format % args))
def log(message):
print(f'vpn-router-manager: {message}', file=sys.stderr, flush=True)
def main():
global CONFIG_FILE
parser = argparse.ArgumentParser(description='Serve the VPN Router Manager.')
parser.add_argument('--config', default=CONFIG_FILE, help=f'the settings file (default: {CONFIG_FILE})')
CONFIG_FILE = parser.parse_args().config
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
initialize()
try:
vpnrouter_manager.serve(lambda: vpnrouter_manager.listen(CONFIG_FILE), Handler, log, DISCOVERY_SECONDS)
except KeyboardInterrupt:
pass
return 0
if __name__ == '__main__':
sys.exit(main())
+14 -10
View File
@@ -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)
+38 -18
View File
@@ -6,10 +6,10 @@ the live system: routes, DNS-derived identity, WireGuard keys, firewall rules
and service reloads. Idempotent - a second run changes nothing. 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 service, BOOTSTRAP_UNITS, which shows the progress and the failure recorded in
SETUP_STATE on vpnrouter.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 service; a failed run leaves it running.
Interface names come from the configuration and are never guessed. The Interface names come from the configuration and are never guessed. The
firewall and NAT rules are written against them, so choosing the wrong one firewall and NAT rules are written against them, so choosing the wrong one
@@ -46,18 +46,18 @@ UFW_DEFAULTS = pathlib.Path('/etc/default/ufw')
WG_DIR = pathlib.Path('/etc/wireguard') WG_DIR = pathlib.Path('/etc/wireguard')
WEB_STATE = vpnrouter.WEB_STATE 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 service shows.
SETUP_STATE = pathlib.Path('/run/vpn-router/setup.json') 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 service and opens its 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, whose ports are vpnrouter.BOOTSTRAP_PORTS. #: The unit of the bootstrap service, whose ports are vpnrouter.BOOTSTRAP_PORTS.
BOOTSTRAP_UNITS = ('vpn-router-bootstrap.service', 'vpn-router-manager-bootstrap.service') BOOTSTRAP_UNITS = ('vpn-router-bootstrap.service',)
#: The default admin of the Router Manager, {"username", "password"}, which its package writes from #: The default admin of the Router Manager, {"username", "password"}, which the package's postinst 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')
#: 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')
@@ -275,7 +275,7 @@ def filter_block(ctx):
'# BOOTSTRAP RULES END', '# BOOTSTRAP RULES END',
] ]
if vpnrouter.MANAGER.exists() and interfaces: if interfaces:
port = vpnrouter.manager_port() port = vpnrouter.manager_port()
lines += [ lines += [
'', '',
@@ -298,7 +298,7 @@ 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']),
(vpnrouter.MANAGER.exists(), ctx['https_manager_port']), (True, 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']), (vpnrouter.webdav_ready(ctx), ctx['webdav_port']),
('http-80' in publish, vpnrouter.ACME_HTTP_PORT), ('http-80' in publish, vpnrouter.ACME_HTTP_PORT),
@@ -697,8 +697,8 @@ 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 """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. stop it when not.
While HTTPS is ready, CADDYFILE serves the web service on https.web_port, while the manager is installed the While HTTPS is ready, CADDYFILE serves the web service on https.web_port, the manager on
manager on https.manager_port, while metrics are enabled the metrics service on metrics.https_port, and while 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 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 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 the endpoints of pki.publish that are Caddy's, and an ACME server at /acme/<name>/ on https.web_port for each
@@ -778,7 +778,7 @@ def manage_caddy(cfg, ctx):
os.chown(folder / file, 0, group) os.chown(folder / file, 0, group)
vpnrouter_sites.ensure_folders(site['name'] for site in sites) vpnrouter_sites.ensure_folders(site['name'] for site in sites)
names = vpnrouter.issuing_cas(cfg) names = vpnrouter.issuing_cas(cfg)
caddy_ctx = dict(ctx, https=https, manager_port=vpnrouter.manager_port() if vpnrouter.MANAGER.exists() else 0, caddy_ctx = dict(ctx, https=https, manager_port=vpnrouter.manager_port(),
pki_publish_dir=str(vpnrouter_pki.PUBLISH_DIR), pki_publish_dir=str(vpnrouter_pki.PUBLISH_DIR),
pki_cas=[{'id': name, 'name': names[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'),
@@ -820,13 +820,33 @@ def manage_webdav(ctx):
run(['systemctl', 'enable', '--now', WEBDAV_UNIT], check=False) 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): 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 """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. 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 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 domains installation starts answers on those addresses only, beside systemd-resolved's stub on 127.0.0.53. The host names
with site records get the host names of the enabled sites, and keep those they have while the sites cannot be read. 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 not vpnrouter.dns_ready(ctx):
if unit_is_active(vpnrouter_dns.UNIT): if unit_is_active(vpnrouter_dns.UNIT):
@@ -854,7 +874,7 @@ def manage_dns(cfg, ctx):
os.chown(vpnrouter_dns.PDNS_CONF, 0, grp.getgrnam(vpnrouter_dns.GROUP).gr_gid) os.chown(vpnrouter_dns.PDNS_CONF, 0, grp.getgrnam(vpnrouter_dns.GROUP).gr_gid)
vpnrouter_dns.provision() vpnrouter_dns.provision()
if ctx['sites'] is not None: if ctx['sites'] is not None:
vpnrouter_dns.apply(cfg, [host for site in ctx['sites'] for host in site['hosts']]) vpnrouter_dns.apply(cfg, site_addresses(cfg, ctx))
except (OSError, KeyError, vpnrouter_dns.DatabaseError) as exc: except (OSError, KeyError, vpnrouter_dns.DatabaseError) as exc:
warn(f'the name server cannot be set up: {exc}') warn(f'the name server cannot be set up: {exc}')
return return
@@ -978,14 +998,14 @@ def progress(state, step, error=None):
def start_bootstrap(): def start_bootstrap():
"""Start the bootstrap services while no run has succeeded; a unit that is not installed is skipped.""" """Start the bootstrap service while no run has succeeded."""
if not SETUP_DONE.exists(): if not SETUP_DONE.exists():
for unit in BOOTSTRAP_UNITS: for unit in BOOTSTRAP_UNITS:
run(['systemctl', 'start', '--no-block', unit], check=False) run(['systemctl', 'start', '--no-block', unit], check=False)
def end_bootstrap(ctx): def end_bootstrap(ctx):
"""After the first successful run: write SETUP_DONE, close the bootstrap ports and stop the bootstrap services.""" """After the first successful run: write SETUP_DONE, close the bootstrap ports and stop the bootstrap service."""
if SETUP_DONE.exists(): if SETUP_DONE.exists():
return return
SETUP_DONE.parent.mkdir(parents=True, exist_ok=True) SETUP_DONE.parent.mkdir(parents=True, exist_ok=True)
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/python3
"""Upgrade the router's package, vpn-router, or with --system every package of the system, recording the state and
the output.
Started by the Router Manager as the transient unit vpn-router-manager-upgrade, or with --system
vpn-router-manager-system-upgrade (systemd-run), so it runs outside the manager's unit: the
restarts of the manager and of the router's web service that the package's scripts make do not
stop it. Usage: upgrade [--system] <user>, the user who started it.
The state file, STATE_FILE or with --system SYSTEM_STATE_FILE, holds the state, running while it
runs, then succeeded or failed, with the user, the start and the end, the exit status of apt-get
and the installed versions of PACKAGES before and after; it is replaced by a rename, so a reader
never sees half of it. The log file, LOG_FILE or SYSTEM_LOG_FILE, holds the output of dpkg and
apt-get, from the start of the run. A dpkg run that was interrupted is finished first
(dpkg --configure -a). Then apt-get install --only-upgrade PACKAGES, or with --system apt-get
upgrade, which upgrades the installed packages without installing or removing any. apt-get waits
up to LOCK_SECONDS for the dpkg lock, which Ubuntu's apt-daily or unattended-upgrades may hold,
takes the installed version of a changed configuration file and asks nothing.
"""
import json
import os
import subprocess
import sys
import time
STATE_DIR = '/var/lib/vpn-router'
STATE_FILE = os.path.join(STATE_DIR, 'upgrade.json')
LOG_FILE = os.path.join(STATE_DIR, 'upgrade.log')
SYSTEM_STATE_FILE = os.path.join(STATE_DIR, 'system-upgrade.json')
SYSTEM_LOG_FILE = os.path.join(STATE_DIR, 'system-upgrade.log')
PACKAGES = ('vpn-router',)
#: Seconds apt-get waits for the dpkg lock.
LOCK_SECONDS = 300
#: The environment of dpkg and apt-get: no questions, the installed configuration files kept, and needrestart left out,
#: as the package's scripts restart their own services.
ENVIRONMENT = {
'DEBIAN_FRONTEND': 'noninteractive',
'UCF_FORCE_CONFFOLD': '1',
'APT_LISTCHANGES_FRONTEND': 'none',
'NEEDRESTART_SUSPEND': '1',
}
def now():
return time.strftime('%Y-%m-%dT%H:%M:%S%z')
def versions():
"""The installed version of each of PACKAGES, None for one that is not installed."""
found = {}
for package in PACKAGES:
result = subprocess.run(['dpkg-query', '-W', '-f=${Version}', package], capture_output=True, text=True)
found[package] = (result.stdout.strip() or None) if result.returncode == 0 else None
return found
def save(state, path):
"""Write the state to the file of path by a rename."""
temporary = path + '.new'
with open(temporary, 'w', encoding='utf-8') as handle:
json.dump(state, handle)
handle.write('\n')
os.chmod(temporary, 0o644)
os.replace(temporary, path)
def main(argv):
system = argv[:1] == ['--system']
if system:
argv = argv[1:]
if len(argv) != 1:
print('usage: upgrade [--system] <user>', file=sys.stderr)
return 2
state_file, log_file = (SYSTEM_STATE_FILE, SYSTEM_LOG_FILE) if system else (STATE_FILE, LOG_FILE)
os.makedirs(STATE_DIR, exist_ok=True)
state = {'state': 'running', 'user': argv[0], 'started': now(), 'finished': None, 'exit_status': None,
'before': versions(), 'after': None}
save(state, state_file)
environment = dict(os.environ, **ENVIRONMENT)
with open(log_file, 'w', encoding='utf-8') as log:
os.chmod(log_file, 0o644)
status = 1
for command in (
['dpkg', '--configure', '-a'],
['apt-get', '-y', '-q', '-o', f'DPkg::Lock::Timeout={LOCK_SECONDS}', '-o', 'Dpkg::Options::=--force-confdef',
'-o', 'Dpkg::Options::=--force-confold', *(['upgrade'] if system else ['install', '--only-upgrade', *PACKAGES])],
):
log.write(f'$ {" ".join(command)}\n')
log.flush()
try:
status = subprocess.run(command, stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT, env=environment).returncode
except OSError as exc:
log.write(f'{exc}\n')
status = 1
if status != 0:
break
log.write(f'exit status {status}\n')
state.update(state='succeeded' if status == 0 else 'failed', finished=now(), exit_status=status, after=versions())
save(state, state_file)
return status
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))
@@ -55,7 +55,7 @@ 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': '', 'dns_listen': 'internal', 'dns_domains': []}, 'p2s': {'enabled': False, 'address_pool': '', 'dns_listen': 'internal', 'dns_domains': []},
@@ -66,10 +66,11 @@ SCHEMA = {
'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': []}, '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}, 'oidc': {'enabled': False},
'sites': {'enabled': False, 'upload_max_mb': 512, 'webdav': False, 'webdav_port': 7445}, 'sites': {'enabled': False, 'upload_max_mb': 512, 'webdav': False, 'webdav_port': 7445},
'dns': {'enabled': False, 'nameserver': '', 'addresses': [], 'listen': 'external', 'ttl': 300}, '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': {},
@@ -143,27 +144,33 @@ WEBDAV_LOOPBACK_PORT = 7446
#: The largest sites.upload_max_mb, 10 GiB. #: The largest sites.upload_max_mb, 10 GiB.
UPLOAD_MAX_MB = 10240 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. #: The shortest and the longest dns.ttl, in seconds.
DNS_TTL_RANGE = (60, 86400) DNS_TTL_RANGE = (60, 86400)
#: A DNS name without a wildcard, in lowercase, of dns.nameserver and the domains of the name server. #: 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])?)+$') 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. #: 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_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 DNS_DOMAINS_MAX = 16
#: The ports of the bootstrap services: the Router Manager's and the router's. #: The ports of the bootstrap service.
BOOTSTRAP_PORTS = (17080, 17081) BOOTSTRAP_PORTS = (17081,)
#: The Router Manager, vpn-router-manager, when it is installed, its settings and its port when they set none. #: The settings of the Router Manager, and its port when they set none.
MANAGER = pathlib.Path('/usr/lib/vpn-router-manager/manager') MANAGER_CONFIG = pathlib.Path('/etc/vpn-router/manager.json')
MANAGER_CONFIG = pathlib.Path('/etc/vpn-router-manager/manager.json')
MANAGER_PORT = 7080 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')
@@ -296,6 +303,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
@@ -577,14 +587,17 @@ def validate(cfg):
raise ConfigError('sites.webdav: WebDAV is served by Caddy at https://<wan.local_fqdn>:<sites.webdav_port>, ' 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') 'which needs https.enabled and wan.local_fqdn')
_check_address(_get(cfg, 'wan', 'public_address'), 'wan.public_address')
dns = cfg['dns'] dns = cfg['dns']
if dns['enabled']: if dns['enabled'] and not (ZONE_NAME.match(_get(cfg, 'wan', 'local_fqdn'))
if not ZONE_NAME.match(dns['nameserver'].strip()): and not re.search(r'\.[0-9]+$', _get(cfg, 'wan', 'local_fqdn'))):
raise ConfigError(f'dns.nameserver: {dns["nameserver"]!r} is not a DNS name in lowercase') raise ConfigError('dns.enabled needs wan.local_fqdn, a DNS name in lowercase: the primary name server of the '
if not _get_list(cfg, 'dns', 'addresses'): 'domains')
raise ConfigError('dns.enabled needs dns.addresses, the public addresses of the name server and the sites') secondary = _get(cfg, 'dns', 'secondary')
for item in _get_list(cfg, 'dns', 'addresses'): if secondary and not ZONE_NAME.match(secondary):
_check_address(item, 'dns.addresses') 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: if dns['listen'] not in LISTEN_CHOICES:
raise ConfigError(f'dns.listen: {dns["listen"]!r} is not one of {", ".join(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]: if not DNS_TTL_RANGE[0] <= dns['ttl'] <= DNS_TTL_RANGE[1]:
@@ -1034,6 +1047,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'),
@@ -1063,7 +1077,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'])
@@ -52,8 +52,7 @@ 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', str(vpnrouter_sites.SITES_DIR))
str(vpnrouter_sites.SITES_DIR))
#: The databases a backup always dumps, each while it exists: the dump -> the database and its password file. #: 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), DUMPS = {'postgresql/vpn-router-sites.dump': (vpnrouter_sites.DATABASE, vpnrouter_sites.DATABASE_SECRET),
@@ -1,17 +1,17 @@
"""The router's authoritative name server, PowerDNS with its gpgsql backend, for vpn-router-setup and the web service. """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 The name server is the primary of its domains and answers on the addresses of the interfaces of dns.listen. The
domains, their secondary name servers and the records an administrator added (manual records) are in the domains and the records an administrator added (manual records) are in the vpn-router-dns database, which
vpn-router-dns database, which provision() creates with its role and the password in DATABASE_SECRET, and fills with provision() creates with its role and the password in DATABASE_SECRET, and fills with the schema of
the schema of pdns-backend-pgsql, PDNS_SCHEMA; DNS_SQL defines the router's own tables and procedures, and the first pdns-backend-pgsql, PDNS_SCHEMA; DNS_SQL defines the router's own tables and procedures, and the first connection of a
connection of a process loads it. process loads it.
apply() writes every domain as a primary zone: its SOA with dns.nameserver, its NS, dns.nameserver and the name apply() writes every domain as a primary zone: its SOA with wan.local_fqdn as the primary name server, its NS,
servers of its secondaries, the NS of the domains directly below it, A and AAAA records of dns.addresses for its apex, wan.local_fqdn and dns.secondary, the NS of the domains directly below it, an A or AAAA record for every host name of
for those name servers inside it that are the router, and, when the domain has site records, for every host name of an an enabled site with dns router whose closest domain it is, with the router's address the site chooses, and the manual
enabled site whose closest domain it is, and the manual records, but those of a name and type the records before them records, but those of a name and type the records before them have. wan.local_fqdn gets no record in the zones: its
have. A secondary's address is notified of every change (ALSO-NOTIFY) and may transfer the zone (ALLOW-AXFR-FROM); the record is kept where its own domain is. The addresses dns.secondary resolves to are notified of every change
serial of the SOA grows only when the records 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. Every failure of the database raises DatabaseError; a request that cannot be done raises Failure.
""" """
@@ -20,6 +20,7 @@ import contextlib
import ipaddress import ipaddress
import pathlib import pathlib
import re import re
import socket
import psycopg import psycopg
from psycopg.rows import dict_row from psycopg.rows import dict_row
@@ -44,9 +45,8 @@ GROUP = 'pdns'
#: The types of the manual records. #: The types of the manual records.
TYPES = ('A', 'AAAA', 'CNAME', 'TXT', 'MX', 'SRV', 'CAA') TYPES = ('A', 'AAAA', 'CNAME', 'TXT', 'MX', 'SRV', 'CAA')
#: The most domains, secondaries of a domain, and manual records of a domain. #: The most domains, and manual records of a domain.
DOMAINS_MAX = 100 DOMAINS_MAX = 100
SECONDARIES_MAX = 8
MANUAL_MAX = 1000 MANUAL_MAX = 1000
#: The refresh, retry and expire times of the SOA, in seconds. #: The refresh, retry and expire times of the SOA, in seconds.
@@ -73,8 +73,7 @@ class DatabaseError(Exception):
class Failure(Exception): class Failure(Exception):
"""A request that cannot be done; status is 2 invalid, 3 no such domain, secondary or record, 4 the domain """A request that cannot be done; status is 2 invalid, 3 no such domain or record, 4 the domain exists."""
exists."""
def __init__(self, message, status=2): def __init__(self, message, status=2):
super().__init__(message) super().__init__(message)
@@ -211,13 +210,10 @@ def domain(connection, name):
def listing(): def listing():
"""Every domain with its secondaries, its manual records and the records the name server answers for it.""" """Every domain with its manual records and the records the name server answers for it."""
with database() as connection: with database() as connection:
secondaries = connection.execute('SELECT * FROM dns_secondaries_list()').fetchall()
manual = connection.execute('SELECT * FROM dns_records_list()').fetchall() manual = connection.execute('SELECT * FROM dns_records_list()').fetchall()
return [{**row, return [{**row,
'secondaries': [{key: item[key] for key in ('nameserver', 'address')}
for item in secondaries if item['domain'] == row['name']],
'manual': [{key: item[key] for key in ('name', 'type', 'content', 'ttl')} 'manual': [{key: item[key] for key in ('name', 'type', 'content', 'ttl')}
for item in manual if item['domain'] == row['name']], for item in manual if item['domain'] == row['name']],
'records': connection.execute('SELECT * FROM dns_served(%s)', (row['name'],)).fetchall()} 'records': connection.execute('SELECT * FROM dns_served(%s)', (row['name'],)).fetchall()}
@@ -225,71 +221,52 @@ def listing():
def domain_add(body): def domain_add(body):
"""Add the domain {name, sites, ttl?}; the domain.""" """Add the domain {name, ttl?}; the domain."""
if not isinstance(body, dict) or not isinstance(body.get('sites', False), bool): if not isinstance(body, dict) or not set(body) <= {'name', 'ttl'}:
raise Failure('a domain is an object of name, sites and ttl') raise Failure('a domain is an object of name and ttl')
name, sites, ttl = check_domain(body.get('name')), body.get('sites', False), check_ttl(body.get('ttl')) name, ttl = check_domain(body.get('name')), check_ttl(body.get('ttl'))
with database() as connection: with database() as connection:
names = [row['name'] for row in connection.execute('SELECT * FROM dns_domains_list()').fetchall()] names = [row['name'] for row in connection.execute('SELECT * FROM dns_domains_list()').fetchall()]
if name in names: if name in names:
raise Failure(f'the domain {name} exists', 4) raise Failure(f'the domain {name} exists', 4)
if len(names) >= DOMAINS_MAX: if len(names) >= DOMAINS_MAX:
raise Failure(f'the name server has {DOMAINS_MAX} domains') raise Failure(f'the name server has {DOMAINS_MAX} domains')
connection.execute('CALL dns_domain_put(%s, %s, %s)', (name, sites, ttl)) connection.execute('CALL dns_domain_put(%s, %s)', (name, ttl))
return {'name': name, 'sites': sites, 'ttl': ttl} return {'name': name, 'ttl': ttl}
def domain_change(name, body): def domain_change(name, body):
"""Change the sites and ttl of the domain; the domain.""" """Change the ttl of the domain; the domain."""
if not isinstance(body, dict) or not set(body) <= {'sites', 'ttl'} or \ if not isinstance(body, dict) or not set(body) <= {'ttl'}:
not isinstance(body.get('sites', False), bool): raise Failure('a change of a domain is an object of ttl')
raise Failure('a change of a domain is an object of sites and ttl')
with database() as connection: with database() as connection:
row = domain(connection, name) row = domain(connection, name)
sites, ttl = body.get('sites', row['sites']), check_ttl(body.get('ttl', row['ttl'])) ttl = check_ttl(body.get('ttl', row['ttl']))
connection.execute('CALL dns_domain_put(%s, %s, %s)', (name, sites, ttl)) connection.execute('CALL dns_domain_put(%s, %s)', (name, ttl))
return {'name': name, 'sites': sites, 'ttl': ttl} return {'name': name, 'ttl': ttl}
def domain_remove(name): def domain_remove(name):
"""Remove the domain with its secondaries and manual records.""" """Remove the domain with its manual records."""
with database() as connection: with database() as connection:
domain(connection, name) domain(connection, name)
connection.execute('CALL dns_domain_remove(%s)', (name,)) connection.execute('CALL dns_domain_remove(%s)', (name,))
return {'name': name} return {'name': name}
def secondary_add(name, body): def secondary_addresses(cfg):
"""Add the secondary name server {nameserver, address} of the domain, or set its address; the secondary.""" """The addresses dns.secondary resolves to, which are notified of the zones' changes and may transfer them; none,
if not isinstance(body, dict): with a warning, while it is not set or does not resolve."""
raise Failure('a secondary is an object of nameserver and address') secondary = cfg['dns']['secondary'].strip()
nameserver = body.get('nameserver') if not secondary:
if not isinstance(nameserver, str) or not vpnrouter.ZONE_NAME.match(nameserver.strip().lower().rstrip('.')): vpnrouter.warn('dns.secondary is not set: the domains have no secondary name server')
raise Failure(f'{nameserver!r} is not a name server name') return []
nameserver = nameserver.strip().lower().rstrip('.')
try: try:
address = str(ipaddress.ip_address(str(body.get('address', '')).strip())) found = socket.getaddrinfo(secondary, 53, proto=socket.IPPROTO_UDP)
except ValueError as exc: except OSError as exc:
raise Failure(f'{body.get("address")!r} is not an IP address') from exc vpnrouter.warn(f'dns.secondary {secondary} does not resolve, so it may not transfer the domains: {exc}')
with database() as connection: return []
domain(connection, name) return list(dict.fromkeys(str(ipaddress.ip_address(item[4][0])) for item in found))
others = [row for row in connection.execute('SELECT * FROM dns_secondaries_list()').fetchall()
if row['domain'] == name and row['nameserver'] != nameserver]
if len(others) >= SECONDARIES_MAX:
raise Failure(f'the domain {name} has {SECONDARIES_MAX} secondaries')
connection.execute('CALL dns_secondary_put(%s, %s, %s)', (name, nameserver, address))
return {'domain': name, 'nameserver': nameserver, 'address': address}
def secondary_remove(name, nameserver):
"""Remove the secondary name server of the domain."""
with database() as connection:
domain(connection, name)
if not any(row['domain'] == name and row['nameserver'] == nameserver
for row in connection.execute('SELECT * FROM dns_secondaries_list()').fetchall()):
raise Failure(f'no secondary {nameserver} of {name}', 3)
connection.execute('CALL dns_secondary_remove(%s, %s)', (name, nameserver))
return {'domain': name, 'nameserver': nameserver}
def add(cfg, name, record): def add(cfg, name, record):
@@ -332,25 +309,20 @@ def remove(name, record_name, kind, content):
return {'name': full, 'type': kind, 'removed': len(found)} return {'name': full, 'type': kind, 'removed': len(found)}
def records(cfg, row, zones, secondaries, hosts, manual_records): 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 """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, the A and AAAA records of dns.addresses for its apex, dns.nameserver when it is inside domains directly below it, wan.local_fqdn and dns.secondary, the A or AAAA record of each host name of hosts, pairs
and, with site records, each host name whose closest domain it is, the glue of the secondaries inside it, and its of a host name and an address, whose closest domain it is, and its manual records."""
manual records. secondaries maps a domain to its secondaries."""
dns = cfg['dns'] dns = cfg['dns']
zone, nameserver, ttl = row['name'], dns['nameserver'].strip(), row['ttl'] or dns['ttl'] zone, ttl = row['name'], row['ttl'] or dns['ttl']
addresses = [ipaddress.ip_address(item.strip()) for item in dns['addresses'] if item.strip()] servers = [cfg['wan']['local_fqdn'].strip(), *([dns['secondary'].strip()] if dns['secondary'].strip() else [])]
children = [name for name in zones children = [name for name in zones
if name != zone and closest([item for item in zones if item != name], name) == zone] 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] found = [(name, 'NS', server, ttl) for name in [zone, *children] for server in servers]
for server in [nameserver, *(item['nameserver'] for item in secondaries.get(name, []))]] for host, address in hosts:
for name in dict.fromkeys([zone, *([nameserver] if in_zone(zone, nameserver) else []), if closest(zones, host) == zone:
*(host for host in hosts if row['sites'] and closest(zones, host) == zone)]): address = ipaddress.ip_address(address)
found += [(name, 'A' if address.version == 4 else 'AAAA', str(address), ttl) for address in addresses] found.append((host, 'A' if address.version == 4 else 'AAAA', str(address), ttl))
for item in (item for name in [zone, *children] for item in secondaries.get(name, [])):
if in_zone(zone, item['nameserver']) and item['nameserver'] != nameserver:
address = ipaddress.ip_address(item['address'])
found.append((item['nameserver'], 'A' if address.version == 4 else 'AAAA', str(address), ttl))
taken = {(name, kind) for name, kind, _, _ in found} taken = {(name, kind) for name, kind, _, _ in found}
found += [(item['name'], item['type'], item['content'], item['ttl']) for item in manual_records found += [(item['name'], item['type'], item['content'], item['ttl']) for item in manual_records
if (item['name'], item['type']) not in taken] if (item['name'], item['type']) not in taken]
@@ -358,7 +330,8 @@ def records(cfg, row, zones, secondaries, hosts, manual_records):
def apply(cfg, hosts): def apply(cfg, hosts):
"""Make the name server the primary of every domain with records(), and of none while dns.enabled is false.""" """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: with database() as connection:
if not cfg['dns']['enabled']: if not cfg['dns']['enabled']:
connection.execute('CALL dns_retain(%s::text[])', ([],)) connection.execute('CALL dns_retain(%s::text[])', ([],))
@@ -366,20 +339,21 @@ def apply(cfg, hosts):
dns = cfg['dns'] dns = cfg['dns']
domains = connection.execute('SELECT * FROM dns_domains_list()').fetchall() domains = connection.execute('SELECT * FROM dns_domains_list()').fetchall()
zones = [row['name'] for row in domains] zones = [row['name'] for row in domains]
secondaries, manual = {}, {} for host, _ in hosts:
for item in connection.execute('SELECT * FROM dns_secondaries_list()').fetchall(): if closest(zones, host) is None:
secondaries.setdefault(item['domain'], []).append(item) 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(): for item in connection.execute('SELECT * FROM dns_records_list()').fetchall():
manual.setdefault(item['domain'], []).append(item) manual.setdefault(item['domain'], []).append(item)
transfers = secondary_addresses(cfg) if domains else []
for row in domains: for row in domains:
zone, ttl = row['name'], row['ttl'] or dns['ttl'] zone, ttl = row['name'], row['ttl'] or dns['ttl']
rows = records(cfg, row, zones, secondaries, hosts, manual.get(zone, [])) rows = records(cfg, row, zones, hosts, manual.get(zone, []))
soa = f'{dns["nameserver"].strip()} hostmaster.{zone} {{serial}} {SOA_TIMES} {ttl}' 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[], ' connection.execute('CALL dns_apply(%s, %s, %s, %s::text[], %s::text[], %s::text[], %s::integer[], '
'%s::text[])', '%s::text[])',
(zone, soa, ttl, [item[0] for item in rows], [item[1] for item in rows], (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], [item[2] for item in rows], [item[3] for item in rows], transfers))
[item['address'] for item in secondaries.get(zone, [])]))
connection.execute('CALL dns_retain(%s::text[])', (zones,)) connection.execute('CALL dns_retain(%s::text[])', (zones,))
@@ -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}'
@@ -71,7 +75,12 @@ OVERLAYS = ('ppolicy', 'otp')
#: passkey of the user as JSON (vpnrouter_passkeys). A service account with vpnRouterRedirectUri #: 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 #: 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 #: and, vpnRouterPostLogoutRedirectUri, after a sign-out, and vpnRouterAllowedGroup, the DNs of the
#: groups whose members may sign in to it. vpnRouterAuthority is a CA of the PKI: its #: 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
@@ -112,18 +121,29 @@ SCHEMA_ATTRIBUTE_TYPES = (
"OpenID Connect client' EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1024} )", "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 " 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 )", "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 $ vpnRouterRedirectUri $ " "vpnRouterConsoleAccount $ vpnRouterWireGuardPeer $ vpnRouterPasskey $ vpnRouterRedirectUri $ "
"vpnRouterPostLogoutRedirectUri $ vpnRouterAllowedGroup ) )", "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.
@@ -152,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',
) )
@@ -333,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,212 @@
"""The settings of the Router Manager and its listening loop.
The settings file (CONFIG_FILE unless another is given) sets, all optional: router_url, database,
listen, bind, port, terraform_dir and iac_tool. bind is the one address to listen on. Without
router_url, terraform_dir names the Terraform root module whose output router_web_url gives the
URL of the router's web service, read with iac_tool, terraform (the default) or tofu; dev.sh uses
it to run the manager on a workstation. A setting that is not given comes from the router's
configuration, ROUTER_CONFIG_FILE: without bind the manager listens on the addresses of the
interfaces listen names, internal, external or both (the default), and on the loopback address,
where the router's HTTPS proxy calls it, router_url is the first address and web.port of the
router's web service, and database is the DATABASE database on this host, with the password in
DATABASE_SECRET.
"""
import json
import os
import subprocess
import sys
import threading
import time
import urllib.parse
from http.server import ThreadingHTTPServer
import vpnrouter_net
CONFIG_FILE = '/etc/vpn-router/manager.json'
ROUTER_CONFIG_FILE = '/etc/vpn-router/vpn-router.json'
DATABASE_SECRET = '/etc/vpn-router/manager-database.secret'
#: The role and the database the manager creates on the router.
DATABASE = 'vpn-router-manager'
#: The port when port is not set.
DEFAULT_PORT = 7080
#: The port of the router's web service when web.port is not set.
ROUTER_PORT = 7081
#: The HTTPS port of the router's web service, on its HTTPS proxy, when https.web_port is not set.
ROUTER_HTTPS_PORT = 7444
#: Seconds the Terraform outputs are kept; terraform output is not cheap.
OUTPUTS_SECONDS = 30
_outputs = {}
_outputs_lock = threading.Lock()
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 text_setting(configuration, name):
"""A string setting, or '' when it is missing or not a string."""
value = configuration.get(name)
return value.strip() if isinstance(value, str) else ''
def port_setting(value, default):
"""A port number, or the default when the value is not one."""
return value if isinstance(value, int) and not isinstance(value, bool) and 1 <= value <= 65535 else default
def terraform_outputs(directory, tool):
"""The outputs of the Terraform root directory read with the tool, terraform or tofu, kept for OUTPUTS_SECONDS, or
{} when they cannot be read."""
with _outputs_lock:
cached = _outputs.get(directory)
if cached and time.monotonic() - cached[0] < OUTPUTS_SECONDS:
return cached[1]
try:
result = subprocess.run([tool, f'-chdir={directory}', 'output', '-json', '-no-color'],
capture_output=True, text=True, timeout=60,
env=dict(os.environ, TF_IN_AUTOMATION='1'))
outputs = json.loads(result.stdout) if result.returncode == 0 else {}
except (OSError, subprocess.TimeoutExpired, ValueError):
outputs = {}
if not isinstance(outputs, dict):
outputs = {}
_outputs[directory] = (time.monotonic(), outputs)
return outputs
def output_value(outputs, name):
"""The value of a Terraform output, or None when it is missing."""
output = outputs.get(name)
return output.get('value') if isinstance(output, dict) else None
def listen(config_file):
"""Where the manager listens, read now: ([(address, port), ...], ''), or ([], the reason) while no address is known."""
configuration = read_json(config_file)
bind = text_setting(configuration, 'bind')
port = port_setting(configuration.get('port'), DEFAULT_PORT)
if bind:
return [(bind, port)], ''
addresses = vpnrouter_net.listen_addresses(read_json(ROUTER_CONFIG_FILE), text_setting(configuration, 'listen'))
if not addresses:
return [], "the router's addresses to listen on are not known yet"
return [(address, port) for address in [*addresses, vpnrouter_net.LOOPBACK]], ''
def settings(config_file):
"""The settings read now: router_url and database, with the router's defaults for those not set."""
configuration = read_json(config_file)
result = {name: text_setting(configuration, name) for name in ('router_url', 'database')}
directory = text_setting(configuration, 'terraform_dir')
if directory and not result['router_url']:
tool = 'tofu' if text_setting(configuration, 'iac_tool') == 'tofu' else 'terraform'
url = output_value(terraform_outputs(os.path.expanduser(directory), tool), 'router_web_url')
if isinstance(url, str):
result['router_url'] = url
if os.path.exists(ROUTER_CONFIG_FILE):
router = read_json(ROUTER_CONFIG_FILE)
if not result['database']:
try:
with open(DATABASE_SECRET, encoding='utf-8') as handle:
password = handle.read().strip()
except OSError:
password = ''
# The password, of URL-safe characters, is written unquoted.
if password:
result['database'] = f'host=localhost dbname={DATABASE} user={DATABASE} password={password}'
if not result['router_url']:
targets, _ = vpnrouter_net.web_listen(router, ROUTER_PORT)
if targets:
result['router_url'] = f'http://{targets[0][0]}:{targets[0][1]}'
result['router_url'] = result['router_url'].rstrip('/')
return result
def status_url(config_file, host, address, https):
"""The URL of the router's status page for a browser that reached the manager as host on its address.
On the router, while neither router_url nor terraform_dir is set, it has host, without its
port, so the link works by whatever name or address the browser used: with https, a call
through the router's HTTPS proxy, https and https.web_port; else, while the router's web
service listens on address, http and web.port. Otherwise it is router_url, or '' without one.
"""
configuration = read_json(config_file)
try:
hostname = urllib.parse.urlsplit(f'//{host}').hostname
except ValueError:
hostname = None
if (hostname and os.path.exists(ROUTER_CONFIG_FILE)
and not text_setting(configuration, 'router_url') and not text_setting(configuration, 'terraform_dir')):
name = f'[{hostname}]' if ':' in hostname else hostname
router = read_json(ROUTER_CONFIG_FILE)
if https:
options = router.get('https') if isinstance(router.get('https'), dict) else {}
return f'https://{name}:{port_setting(options.get("web_port"), ROUTER_HTTPS_PORT)}'
targets, _ = vpnrouter_net.web_listen(router, ROUTER_PORT)
ports = [port for listened, port in targets if listened == address]
if ports:
return f'http://{name}:{ports[0]}'
return settings(config_file)['router_url']
def serve(wanted, handler, log, interval):
"""Work out where to listen every interval seconds with wanted, and run an HTTP server of handler at each place.
wanted answers ([(address, port), ...], '') or ([], the reason not to listen). A server whose
place is no longer wanted is stopped, and a new one listens at each new place; a place that
cannot be bound is tried again the next round. Every new message is logged with log. A request
whose client went away is not logged; any other exception a request, a thread or the process
does not handle is logged with log in one line, without a traceback. Does not return.
"""
def failed(request, client_address):
failure = sys.exc_info()[1]
if not isinstance(failure, ConnectionError):
log(f'a request from {client_address[0]} failed: {type(failure).__name__}: {failure}')
def thread_failed(hook):
if not issubclass(hook.exc_type, (SystemExit, ConnectionError)):
log(f'{hook.thread.name if hook.thread else "a thread"} failed: {hook.exc_type.__name__}: {hook.exc_value}')
threading.excepthook = thread_failed
sys.excepthook = lambda kind, value, _: log(f'failed: {kind.__name__}: {value}')
servers, reported = {}, None
while True:
targets, reason = wanted()
for target in [target for target in servers if target not in targets]:
server, thread = servers.pop(target)
server.shutdown()
server.server_close()
thread.join()
messages = [reason] if reason else []
for target in targets:
if target in servers:
continue
try:
server = ThreadingHTTPServer(target, handler)
except OSError as exc:
messages.append(f'cannot listen on {target[0]}:{target[1]}: {exc}')
continue
server.handle_error = failed
thread = threading.Thread(target=server.serve_forever, name=f'server {target[0]}', daemon=True)
thread.start()
servers[target] = (server, thread)
if servers:
messages.append('serving on ' + ', '.join(f'http://{address}:{port}' for address, port in servers))
message = '; '.join(messages)
if message and message != reported:
log(message)
reported = message
time.sleep(interval)
@@ -3,28 +3,45 @@
While oidc.enabled is true, the web service answers OpenID Connect at the issuer vpnrouter.oidc_issuer, its HTTPS site 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 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 of the router's directory sign in, with the password and the TOTP code or with a passkey (vpnrouter_passkeys), to its
clients, the service accounts with redirect URIs (vpnrouter_users.client_settings): a client's ID is its name and its clients, the service accounts with redirect URIs or grants (vpnrouter_users.client_settings): a client's ID is its name
secret its password, sent with HTTP Basic authentication or in the body of the token request. A client with allowed and its secret its password, sent with HTTP Basic authentication or in the body of the token request; a public client
groups admits only their members; a service account never signs in as a user. 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 /.well-known/openid-configuration the discovery document
GET /oidc/jwks the public key the ID tokens are signed with 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 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 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/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/passkey begin a sign-in with a passkey: {"id", "options"}
POST /oidc/token exchange a code or a refresh token for tokens 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, 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 GET /oidc/logout end the session, then go to a post-logout redirect URI of the client
ID tokens are RS256 JWTs signed with KEY, an RSA key the web service makes on its first run on the router, named by The grants: authorization_code for a client with redirect URIs, client_credentials, by which a confidential client
the RFC 7638 thumbprint of its public key. sub is the entryUUID of the user's entry, so a user added again under the gets an access token as itself, and the device code, as the client's grants give them, each with refresh_token but
same name is another subject. The scopes: openid, required; profile, preferred_username and the names; email; groups, client_credentials. A client asks for tokens for a resource, a service account, with resource (RFC 8707), its name or
the names of the user's groups; their claims are in the ID token and the userinfo answer. A code is valid <issuer>/<name>, or by the resource's scopes it asks for, <resource>:<scope>, of its client scopes; without one the
CODE_SECONDS, an access token and an ID token ACCESS_SECONDS, a refresh token REFRESH_SECONDS, used once. A session, tokens are for the client itself.
the cookie COOKIE of the issuer's /oidc/ path, lasts SESSION_SECONDS. Codes, tokens and sessions are kept in the
database of vpnrouter_auth as their SHA-256 (vpnrouter_auth.OIDC_SQL); vpnrouter_auth.revoke removes a user's. A 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
client is registered by an administrator, so a user is not asked for consent. 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 base64
@@ -45,8 +62,13 @@ import jwt
import ldap import ldap
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric import rsa
from oauthlib.common import add_params_to_uri from oauthlib.common import Request, add_params_to_uri
from oauthlib.oauth2 import AccessDeniedError, FatalClientError, InvalidRequestError, OAuth2Error 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 import RequestValidator, Server
from oauthlib.openid.connect.core.exceptions import LoginRequired from oauthlib.openid.connect.core.exceptions import LoginRequired
@@ -63,6 +85,14 @@ CODE_SECONDS = 60
ACCESS_SECONDS = 300 ACCESS_SECONDS = 300
REFRESH_SECONDS = 8 * 3600 REFRESH_SECONDS = 8 * 3600
SESSION_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' COOKIE = 'vpn_router_oidc'
@@ -75,10 +105,11 @@ SCOPE_CLAIMS = {
SCOPES = ('openid', *SCOPE_CLAIMS) 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 #: 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. #: passkey, which verifies the user; a service account's client credentials are its password.
PASSWORD_AMR = ['pwd'] PASSWORD_AMR = ['pwd']
OTP_AMR = ['pwd', 'otp'] OTP_AMR = ['pwd', 'otp']
PASSKEY_AMR = ['hwk', 'user'] PASSKEY_AMR = ['hwk', 'user']
SERVICE_AMR = ['pwd']
TEMPLATE = 'oidc-page.html.j2' TEMPLATE = 'oidc-page.html.j2'
@@ -96,6 +127,26 @@ _pages = jinja2.Environment(loader=jinja2.FileSystemLoader(str(vpnrouter.TEMPLAT
undefined=jinja2.StrictUndefined) 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(): def ensure_key():
"""Make the signing key when it is missing.""" """Make the signing key when it is missing."""
if KEY.is_file() and KEY.stat().st_size: if KEY.is_file() and KEY.stat().st_size:
@@ -122,30 +173,33 @@ def public_jwk(key):
return {**jwk, 'kid': vpnrouter_auth.b64encode(thumbprint), 'use': 'sig', 'alg': 'RS256'} return {**jwk, 'kid': vpnrouter_auth.b64encode(thumbprint), 'use': 'sig', 'alg': 'RS256'}
def sign(claims): def sign(claims, headers=None):
"""The claims as an RS256 JWT with the key's kid and the headers given."""
key = private_key() key = private_key()
return jwt.encode(claims, key, algorithm='RS256', headers={'kid': public_jwk(key)['kid']}) return jwt.encode(claims, key, algorithm='RS256', headers={'kid': public_jwk(key)['kid'], **(headers or {})})
def discovery(issuer): def discovery(issuer, resource_scopes):
"""The discovery document of OpenID Connect Discovery 1.0.""" """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 { return {
'issuer': issuer, 'issuer': issuer,
'authorization_endpoint': f'{issuer}/oidc/authorize', 'authorization_endpoint': f'{issuer}/oidc/authorize',
'token_endpoint': f'{issuer}/oidc/token', 'token_endpoint': f'{issuer}/oidc/token',
'device_authorization_endpoint': f'{issuer}/oidc/device_authorization',
'userinfo_endpoint': f'{issuer}/oidc/userinfo', 'userinfo_endpoint': f'{issuer}/oidc/userinfo',
'jwks_uri': f'{issuer}/oidc/jwks', 'jwks_uri': f'{issuer}/oidc/jwks',
'end_session_endpoint': f'{issuer}/oidc/logout', 'end_session_endpoint': f'{issuer}/oidc/logout',
'scopes_supported': list(SCOPES), 'scopes_supported': [*SCOPES, *resource_scopes],
'response_types_supported': ['code'], 'response_types_supported': ['code'],
'response_modes_supported': ['query'], 'response_modes_supported': ['query'],
'grant_types_supported': ['authorization_code', 'refresh_token'], 'grant_types_supported': ['authorization_code', 'refresh_token', 'client_credentials', DEVICE_GRANT],
'subject_types_supported': ['public'], 'subject_types_supported': ['public'],
'id_token_signing_alg_values_supported': ['RS256'], 'id_token_signing_alg_values_supported': ['RS256'],
'token_endpoint_auth_methods_supported': ['client_secret_basic', 'client_secret_post'], 'token_endpoint_auth_methods_supported': ['client_secret_basic', 'client_secret_post', 'none'],
'code_challenge_methods_supported': ['S256'], 'code_challenge_methods_supported': ['S256'],
'claims_supported': ['iss', 'sub', 'aud', 'exp', 'iat', 'auth_time', 'nonce', 'amr', 'at_hash', 'claims_supported': ['iss', 'sub', 'aud', 'exp', 'iat', 'auth_time', 'nonce', 'amr', 'at_hash', 'client_id',
*(claim for claims in SCOPE_CLAIMS.values() for claim in claims)], 'scope', 'roles', *(claim for claims in SCOPE_CLAIMS.values() for claim in claims)],
} }
@@ -159,19 +213,52 @@ def directory():
connection.unbind_s() connection.unbind_s()
def client_of(client_id): def account_of(name):
"""The client of the ID, a namespace of its vpnrouter_users.summary with client_id, when it is an enabled service """A namespace of the vpnrouter_users.summary of the service account of the name, else None."""
account with redirect URIs; else None.""" if not isinstance(name, str):
if not isinstance(client_id, str):
return None return None
with directory() as connection: with directory() as connection:
try: try:
user = vpnrouter_users.summary(vpnrouter_users.entry(connection, client_id)) user = vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
except vpnrouter_users.Failure: except vpnrouter_users.Failure:
return None return None
if not user['service'] or not user['enabled'] or not user['redirect_uris']: 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 return None
return types.SimpleNamespace(client_id=client_id, **user) 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): def subject(name, client):
@@ -179,20 +266,26 @@ def subject(name, client):
user who is not a service account, a member of one of the client's allowed groups when it has any; else None.""" 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: with directory() as connection:
try: try:
attributes = vpnrouter_users.entry(connection, name) user, claims = account_claims(connection, name)
except vpnrouter_users.Failure: except vpnrouter_users.Failure:
return None return None
user = vpnrouter_users.summary(attributes)
groups = sorted(vpnrouter_users.groups_of(connection, name))
uuid = connection.search_s(vpnrouter_users.dn_of(name), ldap.SCOPE_BASE, attrlist=['entryUUID'])[0][1]['entryUUID']
if user['service'] or not user['enabled'] or user['locked']: if user['service'] or not user['enabled'] or user['locked']:
return None return None
if client.allowed_groups and not set(client.allowed_groups) & set(groups): if client.allowed_groups and not set(client.allowed_groups) & set(claims['groups']):
return None return None
full = ' '.join(part for part in (user['first_name'], user['last_name']) if part) return claims
claims = {'sub': uuid[0].decode(), 'preferred_username': name, 'name': full or None, 'given_name': user['first_name'],
'family_name': user['last_name'], 'email': user['email'], 'groups': groups}
return {claim: value for claim, value in claims.items() if value is not None} def 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): def claims_of(person, scopes):
@@ -201,6 +294,46 @@ def claims_of(person, scopes):
if claim in person}} 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): def cookie_value(cookies):
"""The value of the session cookie in a Cookie header, or ''.""" """The value of the session cookie in a Cookie header, or ''."""
try: try:
@@ -220,11 +353,31 @@ def session_of(cfg, cookies):
return Session(*row) if row else None 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): class Validator(RequestValidator):
"""The checks and the storage of oauthlib's server for the router's clients, users and database. """The checks and the storage of oauthlib's server for the router's clients, users and database.
Only the authorization code grant with PKCE S256 and the refresh token grant are allowed. The values of a code, a The grants are those of validate_grant_type, with PKCE S256 for the authorization code. The values of a code, a
token or a session found are kept on the request as oidc_code, oidc_scopes and oidc_person. 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): def __init__(self, cfg):
@@ -243,6 +396,14 @@ class Validator(RequestValidator):
def session(self, request): def session(self, request):
return session_of(self.cfg, request.headers.get('Cookie')) 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. # Clients.
def validate_client_id(self, client_id, request, *args, **kwargs): def validate_client_id(self, client_id, request, *args, **kwargs):
@@ -250,7 +411,7 @@ class Validator(RequestValidator):
return request.client is not None return request.client is not None
def validate_redirect_uri(self, client_id, redirect_uri, request, *args, **kwargs): def validate_redirect_uri(self, client_id, redirect_uri, request, *args, **kwargs):
return redirect_uri in request.client.redirect_uris 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): def get_default_redirect_uri(self, client_id, request, *args, **kwargs):
return None return None
@@ -259,17 +420,29 @@ class Validator(RequestValidator):
return response_type == 'code' return response_type == 'code'
def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs): def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs):
return 'openid' in scopes and set(scopes) <= set(SCOPES) """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): def get_default_scopes(self, client_id, request, *args, **kwargs):
return ['openid'] return [] if request.grant_type == 'client_credentials' else ['openid']
def is_pkce_required(self, client_id, request): def is_pkce_required(self, client_id, request):
return True 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): def authenticate_client(self, request, *args, **kwargs):
"""Check the client's secret, the password of its service account, given with HTTP Basic authentication, its """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.""" 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 name, secret = request.client_id, request.client_secret
header = request.headers.get('Authorization', '') header = request.headers.get('Authorization', '')
if header[:6].lower() == 'basic ': if header[:6].lower() == 'basic ':
@@ -279,7 +452,7 @@ class Validator(RequestValidator):
return False return False
name, secret = urllib.parse.unquote_plus(name), urllib.parse.unquote_plus(secret) name, secret = urllib.parse.unquote_plus(name), urllib.parse.unquote_plus(secret)
client = client_of(name) client = client_of(name)
if client is None: if client is None or client.public_client:
return False return False
try: try:
vpnrouter_users.login(name, secret, None) vpnrouter_users.login(name, secret, None)
@@ -289,10 +462,24 @@ class Validator(RequestValidator):
return True return True
def authenticate_client_id(self, client_id, request, *args, **kwargs): def authenticate_client_id(self, client_id, request, *args, **kwargs):
return False """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): def validate_grant_type(self, client_id, grant_type, client, request, *args, **kwargs):
return grant_type in ('authorization_code', 'refresh_token') """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): def validate_user(self, username, password, client, request, *args, **kwargs):
return False return False
@@ -300,15 +487,15 @@ class Validator(RequestValidator):
# Codes. # Codes.
def save_authorization_code(self, client_id, code, request, *args, **kwargs): 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)', 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, (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, request.nonce, request.code_challenge, request.code_challenge_method, request.auth_time, request.amr,
CODE_SECONDS))) getattr(request, 'oidc_target', None), CODE_SECONDS)))
def code(self, code): def code(self, code):
row = self.fetch('SELECT * FROM oidc_code_get(%s)', (vpnrouter_auth.digest(code),)) if isinstance(code, str) else None 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', return dict(zip(('client_id', 'name', 'redirect_uri', 'scopes', 'nonce', 'code_challenge', 'code_challenge_method',
'auth_time', 'amr'), row)) if row else None 'auth_time', 'amr', 'resource'), row)) if row else None
def get_authorization_code_scopes(self, client_id, code, redirect_uri, request): def get_authorization_code_scopes(self, client_id, code, redirect_uri, request):
found = self.code(code) found = self.code(code)
@@ -321,6 +508,7 @@ class Validator(RequestValidator):
person = subject(found['name'], client) person = subject(found['name'], client)
if person is None: if person is None:
return False 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.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 request.oidc_code, request.oidc_person = found, person
return True return True
@@ -340,20 +528,53 @@ class Validator(RequestValidator):
def invalidate_authorization_code(self, client_id, code, request, *args, **kwargs): def invalidate_authorization_code(self, client_id, code, request, *args, **kwargs):
self.call(('CALL oidc_code_delete(%s)', (vpnrouter_auth.digest(code),))) 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. # Tokens.
def token(self, token, kind): def token(self, token, kind):
row = self.fetch('SELECT * FROM oidc_token_get(%s, %s)', (vpnrouter_auth.digest(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 if isinstance(token, str) and token else None
return dict(zip(('client_id', 'name', 'scopes', 'auth_time', 'amr'), row)) if row 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): 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.""" """Keep the access token and the refresh token; a refresh token that was exchanged is used up. A token of
values = (request.client.client_id, request.user, request.scopes, request.auth_time, request.amr) client_credentials is kept for its service account."""
statements = [('CALL oidc_token_save(%s, %s, %s, %s, %s, %s, %s, %s)', 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))] (vpnrouter_auth.digest(token['access_token']), 'access', *values, ACCESS_SECONDS))]
if token.get('refresh_token'): if token.get('refresh_token'):
statements.append(('CALL oidc_token_save(%s, %s, %s, %s, %s, %s, %s, %s)', 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))) (vpnrouter_auth.digest(token['refresh_token']), 'refresh', *values, REFRESH_SECONDS)))
if request.grant_type == 'refresh_token': if request.grant_type == 'refresh_token':
statements.append(('CALL oidc_token_delete(%s)', (vpnrouter_auth.digest(request.refresh_token),))) statements.append(('CALL oidc_token_delete(%s)', (vpnrouter_auth.digest(request.refresh_token),)))
@@ -366,6 +587,7 @@ class Validator(RequestValidator):
person = subject(found['name'], client) person = subject(found['name'], client)
if person is None: if person is None:
return False return False
self.check_target(request, found['resource'])
request.user, request.auth_time, request.amr = found['name'], found['auth_time'], found['amr'] request.user, request.auth_time, request.amr = found['name'], found['auth_time'], found['amr']
request.oidc_scopes, request.oidc_person = found['scopes'], person request.oidc_scopes, request.oidc_person = found['scopes'], person
return True return True
@@ -385,7 +607,9 @@ class Validator(RequestValidator):
return True return True
def get_userinfo_claims(self, request): def get_userinfo_claims(self, request):
return claims_of(request.oidc_person, request.oidc_scopes) 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. # OpenID Connect.
@@ -394,6 +618,9 @@ class Validator(RequestValidator):
id_token.update(claims_of(request.oidc_person, request.scopes)) id_token.update(claims_of(request.oidc_person, request.scopes))
id_token.update({'iss': vpnrouter.oidc_issuer(self.cfg), 'exp': now + ACCESS_SECONDS, id_token.update({'iss': vpnrouter.oidc_issuer(self.cfg), 'exp': now + ACCESS_SECONDS,
'auth_time': int(request.auth_time.timestamp()), 'amr': request.amr}) '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) return sign(id_token)
def validate_silent_login(self, request): def validate_silent_login(self, request):
@@ -417,10 +644,100 @@ class Validator(RequestValidator):
return person is not None and hint.get('sub') == person['sub'] return person is not None and hint.get('sub') == person['sub']
def page(status, cfg, title, message='', error='', form=False, request='', name='', headers=()): class DeviceCodeGrant(GrantTypeBase):
"""An HTML page of TEMPLATE: a message, an error, and with form the sign-in form for the authorization request.""" """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, body = _pages.get_template(TEMPLATE).render(title=title, message=message, error=error, form=form, request=request,
name=name, issuer=vpnrouter.oidc_issuer(cfg)) 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] return status, 'text/html; charset=utf-8', body.encode(), [*PAGE_HEADERS, *headers]
@@ -443,6 +760,30 @@ def cookie(value, seconds):
return 'Set-Cookie', f'{COOKIE}={value}; Path=/oidc/; Max-Age={seconds}; Secure; HttpOnly; SameSite=Lax' 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=''): 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 """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. session, or with prompt=login or max_age older than the session, else a redirect to the client with a code.
@@ -491,27 +832,72 @@ def login(server, cfg, form, headers):
params = form.get('request', '') params = form.get('request', '')
name = form.get('name', '').strip() name = form.get('name', '').strip()
try: try:
if form.get('passkey_id'): name, amr = sign_in(form)
with vpnrouter_users.session() as connection:
name = vpnrouter_passkeys.login_complete(connection, form['passkey_id'], json.loads(form.get('credential', '')))
amr = PASSKEY_AMR
else:
code = form.get('code', '').strip()
vpnrouter_users.login(name, form.get('password', ''), code or None)
with directory() as connection:
if vpnrouter_users.service(vpnrouter_users.entry(connection, name)):
raise vpnrouter_users.Failure('the name, the password or the code is wrong', 5)
amr = OTP_AMR if code else PASSWORD_AMR
except vpnrouter_users.Failure as failure: except vpnrouter_users.Failure as failure:
return authorize(server, cfg, params, headers, name=name, error=str(failure)) return authorize(server, cfg, params, headers, name=name, error=str(failure))
except ValueError: except ValueError:
return authorize(server, cfg, params, headers, name=name, error='the passkey is not accepted') return authorize(server, cfg, params, headers, name=name, error='the passkey is not accepted')
value = secrets.token_urlsafe(32) 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: 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)) decided = connection.execute('SELECT oidc_device_decide(%s, %s, %s, %s, %s)',
fresh = types.SimpleNamespace(session=Session(name, datetime.datetime.now(datetime.timezone.utc), amr), (vpnrouter_auth.digest(user_code_of(form['user_code'])), session.name,
cookie=cookie(value, SESSION_SECONDS)) session.auth_time, session.amr, allowed)).fetchone()[0]
return authorize(server, cfg, params, headers, fresh=fresh) 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): def logout(cfg, query, headers):
@@ -552,9 +938,12 @@ def answer(method, target, headers, body):
issuer = vpnrouter.oidc_issuer(cfg) issuer = vpnrouter.oidc_issuer(cfg)
headers = dict(headers.items()) headers = dict(headers.items())
text = body.decode('utf-8', 'replace') if body else '' text = body.decode('utf-8', 'replace') if body else ''
server = Server(Validator(cfg), token_expires_in=ACCESS_SECONDS) 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'): if (method, path) == ('GET', '/.well-known/openid-configuration'):
return document(200, discovery(issuer)) return document(200, discovery(issuer, resource_scopes()))
if (method, path) == ('GET', '/oidc/jwks'): if (method, path) == ('GET', '/oidc/jwks'):
return document(200, {'keys': [public_jwk(private_key())]}) return document(200, {'keys': [public_jwk(private_key())]})
if path == '/oidc/authorize' and method in ('GET', 'POST'): if path == '/oidc/authorize' and method in ('GET', 'POST'):
@@ -565,6 +954,15 @@ def answer(method, target, headers, body):
return document(200, vpnrouter_passkeys.login_begin()) return document(200, vpnrouter_passkeys.login_begin())
if (method, path) == ('POST', '/oidc/token'): if (method, path) == ('POST', '/oidc/token'):
return relayed(*server.create_token_response(f'{issuer}{path}', 'POST', text, headers)) 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'): if path == '/oidc/userinfo' and method in ('GET', 'POST'):
return relayed(*server.create_userinfo_response(f'{issuer}{target}', method, text or None, headers)) return relayed(*server.create_userinfo_response(f'{issuer}{target}', method, text or None, headers))
if (method, path) == ('GET', '/oidc/logout'): if (method, path) == ('GET', '/oidc/logout'):
@@ -1,5 +1,5 @@
"""The passkeys of the router's users, for signing in to the Router Manager and the OpenID Provider (vpnrouter_oidc), """The passkeys of the router's users, for signing in to the Router Manager and the OpenID Provider (vpnrouter_oidc),
with python3-fido2. 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 HTTPS addresses on that name. wan.local_fqdn, so the browser offers it at the HTTPS addresses on that name.
@@ -8,10 +8,11 @@ user entry: a JSON object {"id", "credential", "label", "created"}, id the crede
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
@@ -172,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,7 +3,7 @@
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's addresses of p2s.dns_listen as their DNS servers, where dnsmasq given the router's addresses of p2s.dns_listen as their DNS servers, where dnsmasq
forwards to the router's own systemd-resolved. That resolves 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
@@ -1,8 +1,8 @@
"""Google Cloud Platform. """Google Cloud.
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)
@@ -7,7 +7,11 @@ Caddy answers on its port (hosts, the names TLS clients send as SNI; none answer
firewall opens the port (listen), and TLS: none (plain HTTP), acme (a certificate of Let's Encrypt), ca (certificates 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 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 folder, index_file for a folder, and with spa index_file of the site for any path without a file. editors names
the group whose members change the site's files besides the admins, through the web service and WebDAV. the 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 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. site is changed only through this module, which checks it (check), and its callers then restart vpn-router-setup.
@@ -55,6 +59,11 @@ INDEX = re.compile(r'^[A-Za-z0-9_-][A-Za-z0-9._-]{0,63}$')
LISTEN = vpnrouter.LISTEN_CHOICES LISTEN = vpnrouter.LISTEN_CHOICES
TLS = ('none', 'acme', 'ca', 'file') 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. #: The most host names of one site.
HOSTS_MAX = 16 HOSTS_MAX = 16
@@ -66,7 +75,8 @@ CHUNK = 65536
#: The settings of a site and their defaults; the type of a default is the required type. #: 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': '', SETTINGS = {'enabled': True, 'hosts': [], 'port': 443, 'listen': 'external', 'tls': 'acme', 'ca': '', 'certificate': '',
'private_key': '', 'index_file': 'index.html', 'spa': False, 'editors': ''} 'private_key': '', 'index_file': 'index.html', 'spa': False, 'editors': '', 'dns': 'user',
'dns_address': 'public'}
#: Whether this process has loaded SITES_SQL. #: Whether this process has loaded SITES_SQL.
_loaded = False _loaded = False
@@ -169,7 +179,7 @@ def taken_ports(cfg):
cfg['sites']['webdav_port']: 'sites.webdav_port', vpnrouter.WEBDAV_LOOPBACK_PORT: 'rclone\'s WebDAV', cfg['sites']['webdav_port']: 'sites.webdav_port', vpnrouter.WEBDAV_LOOPBACK_PORT: 'rclone\'s WebDAV',
53: 'DNS'} 53: 'DNS'}
for port in vpnrouter.BOOTSTRAP_PORTS: for port in vpnrouter.BOOTSTRAP_PORTS:
taken[port] = 'the bootstrap services' taken[port] = 'the bootstrap service'
if cfg['openvpn']['protocol'] == 'tcp': if cfg['openvpn']['protocol'] == 'tcp':
taken[cfg['openvpn']['port']] = 'openvpn.port' taken[cfg['openvpn']['port']] = 'openvpn.port'
for item in cfg['docker']['exposed_ports']: for item in cfg['docker']['exposed_ports']:
@@ -215,8 +225,17 @@ def check(cfg, sites, site):
for host in hosts: for host in hosts:
if not (vpnrouter_pki.DNS_NAME.match(host) or vpnrouter_pki.is_address(host)): 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) raise Failure(f'hosts: {host!r} is not a host name in lowercase or an IP address', 2)
if host == cfg['wan']['local_fqdn'].strip(): if site['dns'] not in DNS:
raise Failure(f'hosts: {host} is wan.local_fqdn, the name of the router\'s own services', 2) 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'] port = site['port']
if not 1 <= port <= 65535: if not 1 <= port <= 65535:
raise Failure(f'port: {port} is not a port', 2) raise Failure(f'port: {port} is not a port', 2)
@@ -285,7 +304,7 @@ def save(cfg, name, values, create):
if site['tls'] != 'ca': if site['tls'] != 'ca':
site['ca'] = '' site['ca'] = ''
check(cfg, rows, site) check(cfg, rows, site)
connection.execute('CALL site_put(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)', connection.execute(f'CALL site_put({", ".join(["%s"] * (len(SETTINGS) + 1))})',
tuple(site[key] for key in ('name', *SETTINGS))) tuple(site[key] for key in ('name', *SETTINGS)))
ensure_folders([name]) ensure_folders([name])
return shown(get(connection, name)) return shown(get(connection, name))
@@ -15,10 +15,18 @@ 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 is a client of the OpenID Provider (vpnrouter_oidc), its name the client ID and A service account with redirect URIs or grants is a client of the OpenID Provider (vpnrouter_oidc), its name the
its password the client secret (client_settings): redirect_uris, where the provider sends a user back after a sign-in, client ID and its password the client secret (client_settings): redirect_uris, where the provider sends a user back
post_logout_redirect_uris, after a sign-out, and allowed_groups, the groups whose members may sign in to it, every after a sign-in, post_logout_redirect_uris, after a sign-out, allowed_groups, the groups whose members may sign in to it,
user who is not a service account when there are none. 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
@@ -105,9 +113,20 @@ LABELS = (('display_name', 'displayName'), ('description', 'description'))
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', 'vpnRouterRedirectUri', 'uidNumber', 'vpnRouterWireGuardPeer', 'vpnRouterPasskey', 'vpnRouterRedirectUri',
'vpnRouterPostLogoutRedirectUri', 'vpnRouterAllowedGroup'] '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. #: The keys of the URIs of an OpenID Provider client and their LDAP attributes.
CLIENT_URIS = (('redirect_uris', 'vpnRouterRedirectUri'), ('post_logout_redirect_uris', 'vpnRouterPostLogoutRedirectUri')) 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. #: The most URIs of each kind and allowed groups of one client, and the longest URI.
CLIENT_VALUES_MAX = 16 CLIENT_VALUES_MAX = 16
URI_MAX = 1024 URI_MAX = 1024
@@ -218,11 +237,13 @@ def labels(values):
def client_settings(connection, values): def client_settings(connection, values):
"""The OpenID Provider client settings of a service account in values ({redirect_uris, post_logout_redirect_uris, """The OpenID Provider settings of a service account in values ({redirect_uris, post_logout_redirect_uris,
allowed_groups}), checked, as {LDAP attribute: [value, ...]}; a key whose value is None is not given, [] means none. 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. Each A URI is absolute, has no fragment and uses https, or http for a host of LOOPBACK_HOSTS; an allowed group exists; a
list has at most CLIENT_VALUES_MAX values. 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 = {} found = {}
for key, attribute in CLIENT_URIS: for key, attribute in CLIENT_URIS:
@@ -249,13 +270,68 @@ def client_settings(connection, values):
for group in allowed: for group in allowed:
group_entry(connection, group) group_entry(connection, group)
found['vpnRouterAllowedGroup'] = [group_dn(group).encode() for group in sorted(set(allowed))] 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 return found
def refuse_client_settings(name, values): def refuse_client_settings(name, values):
"""Refuse OpenID Provider client settings for a user who is not a service account.""" """Refuse OpenID Provider settings for a user who is not a service account."""
if any(values.get(key) is not None for key in ('redirect_uris', 'post_logout_redirect_uris', 'allowed_groups')): 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 of the OpenID Provider', 2) 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):
@@ -310,7 +386,7 @@ 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). A service account also has its OpenID internal is whether it is an internal service account (internal_accounts). A service account also has its OpenID
Provider client settings (client_settings), allowed_groups as group names. 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]
@@ -336,9 +412,18 @@ def summary(attributes):
suffix = f',{vpnrouter_ldap.GROUPS}' suffix = f',{vpnrouter_ldap.GROUPS}'
user['allowed_groups'] = sorted(value.decode()[3:-len(suffix)] for value in attributes.get('vpnRouterAllowedGroup', []) 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)) 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
@@ -536,8 +621,8 @@ 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, its labels (labels) and its OpenID Provider client settings (client_settings); only a service e-mail address, its labels (labels), its OpenID Provider settings (client_settings) and its roles (set_roles); only
account may be named by a GUID.""" a service account may be named by a GUID."""
check_name(name, service) 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
@@ -547,6 +632,9 @@ def add(connection, name, password, values, service=False):
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} 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: else:
refuse_client_settings(name, values) refuse_client_settings(name, values)
if vpn_user and len(details) != len(PERSON): if vpn_user and len(details) != len(PERSON):
@@ -574,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))
@@ -628,20 +720,37 @@ def apply_wireguard():
def update_service(connection, name, values): def update_service(connection, name, values):
"""Change any of the e-mail address, the labels (labels) and the OpenID Provider client settings (client_settings) of """Change any of the e-mail address, the labels (labels), the OpenID Provider settings (client_settings) and the
a service account that values gives; '' removes the e-mail address or a label, [] the values of a client setting.""" roles (set_roles) of a service account that values gives; '' removes the e-mail address or a label, [] the values
if not service(entry(connection, name)): 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 client_settings(connection, 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, description, redirect_uris, post_logout_redirect_uris or allowed_groups', 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):
@@ -761,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:
@@ -814,7 +932,7 @@ def profile(connection, name):
def apple_profile(connection, name, password): def apple_profile(connection, name, password):
"""The Apple configuration profile (.mobileconfig, unsigned) of the user's IKEv2 road-warrior access: the root CA and """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 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 The connection matches road-warrior.conf: the router's wan.local_fqdn as address and identity, the user name, a
@@ -828,7 +946,7 @@ def apple_profile(connection, name, password):
cfg = vpnrouter.load_config() cfg = vpnrouter.load_config()
fqdn = cfg['wan']['local_fqdn'].strip() fqdn = cfg['wan']['local_fqdn'].strip()
if not cfg['p2s']['enabled'] or not fqdn: if not cfg['p2s']['enabled'] or not fqdn:
raise Failure('IKEv2 road-warrior access is not enabled', 2) raise Failure('IKEv2 access is not enabled', 2)
credentials = vpnrouter_pki.user_credentials(name) credentials = vpnrouter_pki.user_credentials(name)
if credentials is None: if credentials is None:
raise Failure(f'user {name} has no certificate') raise Failure(f'user {name} has no certificate')
@@ -977,7 +1095,7 @@ 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; renaming a group a client of the OpenID Provider allows renames it switches the console login of its members off; renaming a group a client of the OpenID Provider allows renames it
there, and renaming the editors group of web sites renames it in the sites.""" there, as in the roles it is assigned, and renaming the editors group of web sites renames it in the sites."""
import vpnrouter_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)
@@ -996,6 +1114,9 @@ def group_update(connection, name, new_name=None, description=None):
for client in allowing(connection, name): for client in allowing(connection, name):
connection.modify_s(dn_of(client), [(ldap.MOD_DELETE, 'vpnRouterAllowedGroup', [group_dn(name).encode()]), connection.modify_s(dn_of(client), [(ldap.MOD_DELETE, 'vpnRouterAllowedGroup', [group_dn(name).encode()]),
(ldap.MOD_ADD, 'vpnRouterAllowedGroup', [group_dn(new_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: try:
vpnrouter_sites.rename_group(name, new_name) vpnrouter_sites.rename_group(name, new_name)
except vpnrouter_sites.DatabaseError as exc: except vpnrouter_sites.DatabaseError as exc:
@@ -1007,8 +1128,8 @@ def group_update(connection, name, new_name=None, description=None):
def group_remove(connection, name): def group_remove(connection, name):
"""Remove the group, unless a client of the OpenID Provider allows it or it is the editors group of web sites; """Remove the group, unless a client of the OpenID Provider allows it or it is the editors group of web sites, and
removing ADMIN_GROUP switches the console login of its members off.""" take it out of the roles it is assigned; removing ADMIN_GROUP switches the console login of its members off."""
import vpnrouter_sites import vpnrouter_sites
members = member_names(group_entry(connection, name)) members = member_names(group_entry(connection, name))
clients = allowing(connection, name) clients = allowing(connection, name)
@@ -1021,6 +1142,7 @@ def group_remove(connection, name):
if sites: if sites:
raise Failure(f'group {name} is the editors group of the web sites {", ".join(sites)}; change them first', 2) 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}
@@ -1049,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,101 @@
"""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 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,319 @@
"""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'),
'vpn-router-manager': unit_state('vpn-router-manager.service'), **postgresql_clusters()}
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(),
'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 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'],
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 '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, vpn-router-metrics.service and
# vpn-router-manager.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
+5 -1940
View File
File diff suppressed because it is too large. Load diff
+121
View File
@@ -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:]))
+13 -5
View File
@@ -3,7 +3,9 @@
A site has a name, which also names its folder in /var/lib/vpn-router/sites, host names, a port, the interfaces whose 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, firewall opens it, TLS (none, acme, ca with --ca, or file with --certificate and --key), the file served for a folder,
whether any path without a file is served that file of the site (--spa), and the group whose members change its files. 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 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 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 is {"error": ...} there too. The exit status says what failed: 2 the request is invalid, 3 the site does not exist, 4
@@ -17,7 +19,8 @@ the site exists, 1 anything else. -h or --help shows the commands and their opti
options: [--host H ...] [--no-hosts] [--port P] [--listen internal|external|both] [--tls none|acme|ca|file] 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] [--ca NAME] [--certificate FILE] [--key FILE] [--index FILE] [--spa | --no-spa]
[--enabled | --no-enabled] [--editors GROUP] [--enabled | --no-enabled] [--editors GROUP] [--dns user|router]
[--dns-address public|internal|external]
""" """
import argparse import argparse
@@ -73,6 +76,9 @@ def parse(argv):
'path without a file') 'path without a file')
sub.add_argument('--enabled', action=argparse.BooleanOptionalAction, help='serve the site') 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('--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': if command == 'remove':
sub.add_argument('--content', action='store_true', help='remove the site\'s folder too') sub.add_argument('--content', action='store_true', help='remove the site\'s folder too')
return parser.parse_args(argv) return parser.parse_args(argv)
@@ -82,7 +88,8 @@ def values_of(options):
"""The settings the options give.""" """The settings the options give."""
values = {key: getattr(options, option) for key, option in (('port', 'port'), ('listen', 'listen'), ('tls', 'tls'), values = {key: getattr(options, option) for key, option in (('port', 'port'), ('listen', 'listen'), ('tls', 'tls'),
('ca', 'ca'), ('index_file', 'index'), ('spa', 'spa'), ('ca', 'ca'), ('index_file', 'index'), ('spa', 'spa'),
('enabled', 'enabled'), ('editors', 'editors')) ('enabled', 'enabled'), ('editors', 'editors'),
('dns', 'dns'), ('dns_address', 'dns_address'))
if getattr(options, option) is not None} if getattr(options, option) is not None}
if options.no_hosts: if options.no_hosts:
values['hosts'] = [] values['hosts'] = []
@@ -120,9 +127,10 @@ def text_of(answer):
sites = answer['sites'] if 'sites' in answer else [answer] sites = answer['sites'] if 'sites' in answer else [answer]
if not sites: if not sites:
return 'no sites' return 'no sites'
head = ('SITE', 'ENABLED', 'HOSTS', 'PORT', 'LISTEN', 'TLS', 'EDITORS') 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']), 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['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] for site in sites]
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))
+26 -8
View File
@@ -13,14 +13,17 @@ user exists, 1 anything else. -h or --help shows the commands and their options.
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 ...] [--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] vpn-router-user [--json] update <name> [--display-name N] [--description D] [--email E]
[--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...] [--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)
A service account with a redirect URI is a client of the OpenID Provider; each of the three options may be repeated, A service account with a redirect URI or a grant (client_credentials, device_code) is a client of the OpenID Provider,
and replaces all values of its kind; one '' removes them. 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>
@@ -80,6 +83,9 @@ CLIENT_OPTIONS = (
('redirect_uris', 'redirect-uri', 'a redirect URI of the OpenID Provider client'), ('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'), ('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'), ('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'),
) )
@@ -104,6 +110,8 @@ def parse(argv):
for key, option, text in CLIENT_OPTIONS: for key, option, text in CLIENT_OPTIONS:
sub.add_argument(f'--{option}', dest=key, action='append', metavar='VALUE', sub.add_argument(f'--{option}', dest=key, action='append', metavar='VALUE',
help=f"{text}, of a service account; repeat it for more, '' for none") 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':
@@ -131,6 +139,8 @@ def run(connection, options, secret):
given = getattr(options, key, None) given = getattr(options, key, None)
if given is not None: if given is not None:
values[key] = [value for value in given if value] 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':
@@ -157,12 +167,20 @@ def console_text(user):
def client_text(user): def client_text(user):
"""The OpenID Provider client settings of a service account as a cell: -, or the numbers of its URIs and its allowed """The OpenID Provider settings of a service account as a cell: -, or the numbers of its URIs, its allowed groups,
groups.""" grants, public, its client scopes and its resource scopes."""
if not user.get('redirect_uris'): parts = []
return '-' if user.get('redirect_uris'):
groups = ','.join(user['allowed_groups']) or 'all users' groups = ','.join(user['allowed_groups']) or 'all users'
return f'{len(user["redirect_uris"])} redirect, {len(user["post_logout_redirect_uris"])} logout, {groups}' 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):
@@ -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
@@ -64,6 +64,14 @@ _vpn_router_site()
COMPREPLY=($(compgen -W "$(_vpn_router_site_groups)" -- "$cur")) COMPREPLY=($(compgen -W "$(_vpn_router_site_groups)" -- "$cur"))
return return
;; ;;
--dns)
COMPREPLY=($(compgen -W 'user router' -- "$cur"))
return
;;
--dns-address)
COMPREPLY=($(compgen -W 'public internal external' -- "$cur"))
return
;;
--certificate | --key) --certificate | --key)
_filedir _filedir
return return
@@ -78,7 +86,7 @@ _vpn_router_site()
local options='--help' local options='--help'
case $command in case $command in
add | update) options+=' --host --no-hosts --port --listen --tls --ca --certificate --key --index --spa add | update) options+=' --host --no-hosts --port --listen --tls --ca --certificate --key --index --spa
--no-spa --enabled --no-enabled --editors' ;; --no-spa --enabled --no-enabled --editors --dns --dns-address' ;;
remove) options+=' --content' ;; remove) options+=' --content' ;;
esac esac
COMPREPLY=($(compgen -W "$options" -- "$cur")) COMPREPLY=($(compgen -W "$options" -- "$cur"))
@@ -13,6 +13,14 @@ _vpn_router_user_groups()
python3 -c 'import json, sys; print("\n".join(group["name"] for group in json.load(sys.stdin)["groups"]))' 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() _vpn_router_user()
{ {
local cur prev words cword split local cur prev words cword split
@@ -30,7 +38,8 @@ _vpn_router_user()
fi fi
case ${words[i]} in case ${words[i]} in
--first-name | --last-name | --email | --display-name | --description | --redirect-uri | \ --first-name | --last-name | --email | --display-name | --description | --redirect-uri | \
--post-logout-redirect-uri | --allowed-group | --account) ((i++)) ;; --post-logout-redirect-uri | --allowed-group | --account | --grant | --public-client | --client-scope | \
--resource-scope) ((i++)) ;;
-*) ;; -*) ;;
*) ((args++)) ;; *) ((args++)) ;;
esac esac
@@ -50,6 +59,21 @@ _vpn_router_user()
COMPREPLY=($(compgen -W "$(_vpn_router_user_groups)" -- "$cur")) COMPREPLY=($(compgen -W "$(_vpn_router_user_groups)" -- "$cur"))
return 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) --account)
# The local accounts console --account takes: a uid from 1000 to 19999, not nobody. # 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")) COMPREPLY=($(compgen -W "$(getent passwd | awk -F: '$3 >= 1000 && $3 < 20000 && $1 != "nobody" { print $1 }')" -- "$cur"))
@@ -65,9 +89,9 @@ _vpn_router_user()
local options='--help' local options='--help'
case $command in case $command in
add) options+=' --first-name --last-name --email --service --display-name --description --redirect-uri add) options+=' --first-name --last-name --email --service --display-name --description --redirect-uri
--post-logout-redirect-uri --allowed-group' ;; --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 update) options+=' --first-name --last-name --email --display-name --description --redirect-uri
--post-logout-redirect-uri --allowed-group' ;; --post-logout-redirect-uri --allowed-group --grant --public-client --client-scope --resource-scope' ;;
console) options+=' --posix --account --off' ;; console) options+=' --posix --account --off' ;;
esac esac
COMPREPLY=($(compgen -W "$options" -- "$cur")) COMPREPLY=($(compgen -W "$options" -- "$cur"))
@@ -0,0 +1,6 @@
{
"router_url": "http://10.0.0.4:7081",
"database": "host=localhost dbname=vpn-router-manager user=vpn-router-manager password=change-me",
"listen": "both",
"port": 7080
}
@@ -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"],
@@ -89,8 +90,7 @@
}, },
"dns": { "dns": {
"enabled": false, "enabled": false,
"nameserver": "", "secondary": "",
"addresses": [],
"listen": "external", "listen": "external",
"ttl": 300 "ttl": 300
}, },
@@ -6,21 +6,21 @@ SELECT pg_advisory_xact_lock(hashtext('vpn-router dns schema'));
DROP TABLE IF EXISTS dns_manual_records; DROP TABLE IF EXISTS dns_manual_records;
-- A domain the router serves as its primary name server; sites says whether the host names of the enabled sites in -- The secondaries of each domain and the domain's switch of site records: the secondary is dns.secondary, the same
-- it get records, ttl is the TTL of its records, null for dns.ttl. -- 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 ( CREATE TABLE IF NOT EXISTS dns_domains (
name text PRIMARY KEY, name text PRIMARY KEY,
sites boolean NOT NULL,
ttl integer ttl integer
); );
ALTER TABLE dns_domains DROP COLUMN IF EXISTS sites;
-- A secondary name server of a domain: its NS name and the address it transfers the domain from.
CREATE TABLE IF NOT EXISTS dns_secondaries (
domain text NOT NULL REFERENCES dns_domains (name) ON DELETE CASCADE,
nameserver text NOT NULL,
address inet NOT NULL,
PRIMARY KEY (domain, nameserver)
);
-- A record an administrator added to a domain, besides those of the domain itself and of the 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 ( CREATE TABLE IF NOT EXISTS dns_records (
@@ -40,42 +40,24 @@ CREATE TABLE IF NOT EXISTS dns_state (
); );
CREATE OR REPLACE FUNCTION dns_domains_list() CREATE OR REPLACE FUNCTION dns_domains_list()
RETURNS TABLE (name text, sites boolean, ttl integer) RETURNS TABLE (name text, ttl integer)
LANGUAGE sql STABLE AS $$ LANGUAGE sql STABLE AS $$
SELECT name, sites, ttl FROM dns_domains ORDER BY name; SELECT name, ttl FROM dns_domains ORDER BY name;
$$; $$;
-- Add the domain, or set its sites and TTL. -- Add the domain, or set its TTL.
CREATE OR REPLACE PROCEDURE dns_domain_put(p_name text, p_sites boolean, p_ttl integer) CREATE OR REPLACE PROCEDURE dns_domain_put(p_name text, p_ttl integer)
LANGUAGE sql AS $$ LANGUAGE sql AS $$
INSERT INTO dns_domains VALUES (p_name, p_sites, p_ttl) INSERT INTO dns_domains (name, ttl) VALUES (p_name, p_ttl)
ON CONFLICT (name) DO UPDATE SET sites = p_sites, ttl = p_ttl; ON CONFLICT (name) DO UPDATE SET ttl = p_ttl;
$$; $$;
-- Remove the domain with its secondaries and records. -- Remove the domain with its records.
CREATE OR REPLACE PROCEDURE dns_domain_remove(p_name text) CREATE OR REPLACE PROCEDURE dns_domain_remove(p_name text)
LANGUAGE sql AS $$ LANGUAGE sql AS $$
DELETE FROM dns_domains WHERE name = p_name; DELETE FROM dns_domains WHERE name = p_name;
$$; $$;
CREATE OR REPLACE FUNCTION dns_secondaries_list()
RETURNS TABLE (domain text, nameserver text, address text)
LANGUAGE sql STABLE AS $$
SELECT domain, nameserver, host(address) FROM dns_secondaries ORDER BY domain, nameserver;
$$;
-- Add the secondary of the domain, or set its address.
CREATE OR REPLACE PROCEDURE dns_secondary_put(p_domain text, p_nameserver text, p_address inet)
LANGUAGE sql AS $$
INSERT INTO dns_secondaries VALUES (p_domain, p_nameserver, p_address)
ON CONFLICT (domain, nameserver) DO UPDATE SET address = p_address;
$$;
CREATE OR REPLACE PROCEDURE dns_secondary_remove(p_domain text, p_nameserver text)
LANGUAGE sql AS $$
DELETE FROM dns_secondaries WHERE domain = p_domain AND nameserver = p_nameserver;
$$;
CREATE OR REPLACE FUNCTION dns_records_list() CREATE OR REPLACE FUNCTION dns_records_list()
RETURNS TABLE (domain text, name text, type text, content text, ttl integer) RETURNS TABLE (domain text, name text, type text, content text, ttl integer)
LANGUAGE sql STABLE AS $$ LANGUAGE sql STABLE AS $$
@@ -97,7 +79,7 @@ LANGUAGE sql AS $$
$$; $$;
-- Serve the zone as its primary and give it the records, its SOA first, whose content has {serial} for the serial, -- 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 secondaries, which are notified of its changes and may transfer it. The serial grows, to -- 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. -- 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[], 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[]) p_contents text[], p_ttls integer[], p_secondaries text[])
@@ -1,10 +1,12 @@
-- The state of the router's OpenID Provider (vpnrouter_oidc) in the database of web.database. -- The state of the router's OpenID Provider (vpnrouter_oidc) in the database of web.database.
-- Codes, tokens and sessions are kept as the SHA-256 of their values; expired rows are purged when new ones are saved. -- Codes, tokens, device codes and sessions are kept as the SHA-256 of their values; expired rows are purged when new
-- The whole file runs as one transaction, serialized by an advisory lock, so that two processes can load it at once. -- 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')); SELECT pg_advisory_xact_lock(hashtext('vpn-router oidc schema'));
-- An authorization code, valid until it is exchanged for tokens or expires. -- 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 ( CREATE TABLE IF NOT EXISTS oidc_codes (
hash text PRIMARY KEY, hash text PRIMARY KEY,
client_id text NOT NULL, client_id text NOT NULL,
@@ -16,10 +18,13 @@ CREATE TABLE IF NOT EXISTS oidc_codes (
code_challenge_method text NOT NULL, code_challenge_method text NOT NULL,
auth_time timestamptz NOT NULL, auth_time timestamptz NOT NULL,
amr text[] NOT NULL, amr text[] NOT NULL,
expires timestamptz 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. -- 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 ( CREATE TABLE IF NOT EXISTS oidc_tokens (
hash text PRIMARY KEY, hash text PRIMARY KEY,
kind text NOT NULL CHECK (kind IN ('access', 'refresh')), kind text NOT NULL CHECK (kind IN ('access', 'refresh')),
@@ -28,9 +33,30 @@ CREATE TABLE IF NOT EXISTS oidc_tokens (
scopes text[] NOT NULL, scopes text[] NOT NULL,
auth_time timestamptz NOT NULL, auth_time timestamptz NOT NULL,
amr text[] 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 expires timestamptz NOT NULL
); );
CREATE INDEX IF NOT EXISTS oidc_tokens_name ON oidc_tokens (name); 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. -- A user's sign-in at the OpenID Provider, which the session cookie names.
CREATE TABLE IF NOT EXISTS oidc_sessions ( CREATE TABLE IF NOT EXISTS oidc_sessions (
@@ -41,22 +67,30 @@ CREATE TABLE IF NOT EXISTS oidc_sessions (
expires timestamptz 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, 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_scopes text[], p_nonce text, p_code_challenge text,
p_code_challenge_method text, p_auth_time timestamptz, p_amr text[], p_code_challenge_method text, p_auth_time timestamptz, p_amr text[],
p_seconds integer) p_resource text, p_seconds integer)
LANGUAGE sql AS $$ LANGUAGE sql AS $$
DELETE FROM oidc_codes WHERE expires < now(); DELETE FROM oidc_codes WHERE expires < now();
INSERT INTO oidc_codes VALUES (p_hash, p_client_id, p_name, p_redirect_uri, p_scopes, p_nonce, p_code_challenge, INSERT INTO oidc_codes (hash, client_id, name, redirect_uri, scopes, nonce, code_challenge, code_challenge_method,
p_code_challenge_method, p_auth_time, p_amr, now() + make_interval(secs => p_seconds)); 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. -- The unexpired code.
CREATE OR REPLACE FUNCTION oidc_code_get(p_hash text) 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, 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[]) code_challenge_method text, auth_time timestamptz, amr text[], resource text)
LANGUAGE sql STABLE AS $$ LANGUAGE sql STABLE AS $$
SELECT client_id, name, redirect_uri, scopes, nonce, code_challenge, code_challenge_method, auth_time, amr 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(); FROM oidc_codes WHERE hash = p_hash AND expires > now();
$$; $$;
@@ -66,18 +100,19 @@ LANGUAGE sql AS $$
$$; $$;
CREATE OR REPLACE PROCEDURE oidc_token_save(p_hash text, p_kind text, p_client_id text, p_name text, p_scopes text[], CREATE OR REPLACE PROCEDURE oidc_token_save(p_hash text, p_kind text, p_client_id text, p_name text, p_scopes text[],
p_auth_time timestamptz, p_amr text[], p_seconds integer) p_auth_time timestamptz, p_amr text[], p_resource text, p_seconds integer)
LANGUAGE sql AS $$ LANGUAGE sql AS $$
DELETE FROM oidc_tokens WHERE expires < now(); DELETE FROM oidc_tokens WHERE expires < now();
INSERT INTO oidc_tokens VALUES (p_hash, p_kind, p_client_id, p_name, p_scopes, p_auth_time, p_amr, INSERT INTO oidc_tokens (hash, kind, client_id, name, scopes, auth_time, amr, resource, expires)
now() + make_interval(secs => p_seconds)); 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. -- The unexpired token of the kind.
CREATE OR REPLACE FUNCTION oidc_token_get(p_hash text, p_kind text) 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[]) RETURNS TABLE (client_id text, name text, scopes text[], auth_time timestamptz, amr text[], resource text)
LANGUAGE sql STABLE AS $$ LANGUAGE sql STABLE AS $$
SELECT client_id, name, scopes, auth_time, amr FROM oidc_tokens SELECT client_id, name, scopes, auth_time, amr, resource FROM oidc_tokens
WHERE hash = p_hash AND kind = p_kind AND expires > now(); WHERE hash = p_hash AND kind = p_kind AND expires > now();
$$; $$;
@@ -86,6 +121,59 @@ LANGUAGE sql AS $$
DELETE FROM oidc_tokens WHERE hash = p_hash; 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) CREATE OR REPLACE PROCEDURE oidc_session_save(p_hash text, p_name text, p_amr text[], p_seconds integer)
LANGUAGE sql AS $$ LANGUAGE sql AS $$
DELETE FROM oidc_sessions WHERE expires < now(); DELETE FROM oidc_sessions WHERE expires < now();
@@ -104,10 +192,11 @@ LANGUAGE sql AS $$
DELETE FROM oidc_sessions WHERE hash = p_hash; DELETE FROM oidc_sessions WHERE hash = p_hash;
$$; $$;
-- Remove every code, token and session of the user. -- Remove every code, token, device code and session of the user or service account.
CREATE OR REPLACE PROCEDURE oidc_revoke(p_name text) CREATE OR REPLACE PROCEDURE oidc_revoke(p_name text)
LANGUAGE sql AS $$ LANGUAGE sql AS $$
DELETE FROM oidc_codes WHERE name = p_name; DELETE FROM oidc_codes WHERE name = p_name;
DELETE FROM oidc_tokens 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; DELETE FROM oidc_sessions WHERE name = p_name;
$$; $$;
@@ -3,7 +3,9 @@
SELECT pg_advisory_xact_lock(hashtext('vpn-router sites schema')); SELECT pg_advisory_xact_lock(hashtext('vpn-router sites schema'));
-- A static web site: its host names, port, interfaces and TLS, and the group whose members change its files. -- 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 ( CREATE TABLE IF NOT EXISTS sites (
name text PRIMARY KEY, name text PRIMARY KEY,
enabled boolean NOT NULL, enabled boolean NOT NULL,
@@ -19,44 +21,60 @@ CREATE TABLE IF NOT EXISTS sites (
editors text NOT NULL, editors text NOT NULL,
modified timestamptz 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. -- Every site, by name; the private key is left out.
CREATE OR REPLACE FUNCTION sites_list() CREATE OR REPLACE FUNCTION sites_list()
RETURNS TABLE (name text, enabled boolean, hosts text[], port integer, listen text, tls text, ca text, RETURNS TABLE (name text, enabled boolean, hosts text[], port integer, listen text, tls text, ca text,
certificate text, index_file text, spa boolean, editors text, modified timestamptz) certificate text, index_file text, spa boolean, editors text, dns text, dns_address text,
modified timestamptz)
LANGUAGE sql STABLE AS $$ LANGUAGE sql STABLE AS $$
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, index_file, spa, editors, modified SELECT name, enabled, hosts, port, listen, tls, ca, certificate, index_file, spa, editors, dns, dns_address, modified
FROM sites ORDER BY name; FROM sites ORDER BY name;
$$; $$;
-- The site with its private key. -- The site with its private key.
CREATE OR REPLACE FUNCTION site_get(p_name text) 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, RETURNS TABLE (name text, enabled boolean, hosts text[], port integer, listen text, tls text, ca text,
certificate text, private_key text, index_file text, spa boolean, editors text, modified timestamptz) certificate text, private_key text, index_file text, spa boolean, editors text, dns text,
dns_address text, modified timestamptz)
LANGUAGE sql STABLE AS $$ LANGUAGE sql STABLE AS $$
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, private_key, index_file, spa, editors, modified 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; FROM sites WHERE name = p_name;
$$; $$;
-- The enabled sites with their private keys, by name, for Caddy's configuration and certificate files. -- The enabled sites with their private keys, by name, for Caddy's configuration and certificate files.
CREATE OR REPLACE FUNCTION sites_served() CREATE OR REPLACE FUNCTION sites_served()
RETURNS TABLE (name text, enabled boolean, hosts text[], port integer, listen text, tls text, ca text, RETURNS TABLE (name text, enabled boolean, hosts text[], port integer, listen text, tls text, ca text,
certificate text, private_key text, index_file text, spa boolean, editors text, modified timestamptz) certificate text, private_key text, index_file text, spa boolean, editors text, dns text,
dns_address text, modified timestamptz)
LANGUAGE sql STABLE AS $$ LANGUAGE sql STABLE AS $$
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, private_key, index_file, spa, editors, modified 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; FROM sites WHERE enabled ORDER BY name;
$$; $$;
-- Add the site, or replace all its settings. -- 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, 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_tls text, p_ca text, p_certificate text, p_private_key text, p_index_file text,
p_spa boolean, p_editors text) p_spa boolean, p_editors text, p_dns text, p_dns_address text)
LANGUAGE sql AS $$ LANGUAGE sql AS $$
INSERT INTO sites VALUES (p_name, p_enabled, p_hosts, p_port, p_listen, p_tls, p_ca, p_certificate, p_private_key, INSERT INTO sites (name, enabled, hosts, port, listen, tls, ca, certificate, private_key, index_file, spa, editors,
p_index_file, p_spa, p_editors, now()) 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, 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, tls = p_tls, ca = p_ca, certificate = p_certificate, private_key = p_private_key, index_file = p_index_file,
spa = p_spa, editors = p_editors, modified = now(); 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) CREATE OR REPLACE PROCEDURE site_remove(p_name text)
@@ -4,6 +4,9 @@
{%- if https_email %} {%- if https_email %}
email {{ https_email }} email {{ https_email }}
{%- endif %} {%- endif %}
{%- if https_acme_ca %}
acme_ca {{ https_acme_ca }}
{%- endif %}
{%- if pki_cas %} {%- if pki_cas %}
skip_install_trust skip_install_trust
pki { pki {
@@ -69,7 +72,7 @@ http://{{ local_fqdn }} {
} }
} }
{% endif %} {% endif %}
{%- if https and manager_port %} {%- if https %}
{{ local_fqdn }}:{{ https_manager_port }} { {{ local_fqdn }}:{{ https_manager_port }} {
reverse_proxy {{ loopback }}:{{ manager_port }} reverse_proxy {{ loopback }}:{{ manager_port }}
} }
@@ -15,6 +15,8 @@ input { box-sizing: border-box; width: 100%; padding: 0.5rem; border: 1px solid
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 { 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; } 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; } .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; } .issuer { font-size: 0.75rem; color: #6b7280; margin: 1.5rem 0 0; }
</style> </style>
</head> </head>
@@ -27,25 +29,52 @@ button.secondary { background: #fff; color: #1d4ed8; border: 1px solid #1d4ed8;
{%- if error %} {%- if error %}
<div class="error" role="alert">{{ error }}</div> <div class="error" role="alert">{{ error }}</div>
{%- endif %} {%- endif %}
{%- if form %} {%- if items %}
<form method="post" action="/oidc/login"> <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 }}"> <input type="hidden" name="request" value="{{ request }}">
{%- endif %}
{%- if form %}
<label for="name">Name</label> <label for="name">Name</label>
<input id="name" name="name" value="{{ name }}" autocomplete="username webauthn" autocapitalize="none" spellcheck="false" required autofocus> <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> <label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required> <input id="password" name="password" type="password" autocomplete="current-password" required>
<label for="code">Code from the authenticator, when you have one</label> <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"> <input id="code" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code">
<button type="submit">Sign in</button> <button type="submit">Sign in</button>
{%- else %}
<button type="submit">Continue</button>
{%- endif %}
</form> </form>
<form id="passkey-form" method="post" action="/oidc/login" hidden> {%- endif %}
{%- if form %}
<form id="passkey-form" method="post" action="{{ action }}" hidden>
<input type="hidden" name="request" value="{{ request }}"> <input type="hidden" name="request" value="{{ request }}">
<input type="hidden" name="user_code">
<input type="hidden" name="passkey_id"> <input type="hidden" name="passkey_id">
<input type="hidden" name="credential"> <input type="hidden" name="credential">
</form> </form>
<button id="passkey" class="secondary" type="button" hidden>Sign in with a passkey</button> <button id="passkey" class="secondary" type="button" hidden>Sign in with a passkey</button>
<script> <script>
// A passkey sign-in begins at /oidc/passkey and posts the browser's answer to /oidc/login with the authorization request. // 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 () { (function () {
var button = document.getElementById('passkey'); var button = document.getElementById('passkey');
if (!window.PublicKeyCredential || !PublicKeyCredential.parseRequestOptionsFromJSON) { if (!window.PublicKeyCredential || !PublicKeyCredential.parseRequestOptionsFromJSON) {
@@ -53,11 +82,16 @@ button.secondary { background: #fff; color: #1d4ed8; border: 1px solid #1d4ed8;
} }
button.hidden = false; button.hidden = false;
button.addEventListener('click', async function () { button.addEventListener('click', async function () {
var code = document.getElementById('user_code');
if (code && !code.reportValidity()) {
return;
}
button.disabled = true; button.disabled = true;
try { try {
var begun = await (await fetch('/oidc/passkey', { method: 'POST' })).json(); var begun = await (await fetch('/oidc/passkey', { method: 'POST' })).json();
var credential = await navigator.credentials.get({ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(begun.options.publicKey) }); var credential = await navigator.credentials.get({ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(begun.options.publicKey) });
var form = document.getElementById('passkey-form'); var form = document.getElementById('passkey-form');
form.elements.user_code.value = code ? code.value : '';
form.elements.passkey_id.value = begun.id; form.elements.passkey_id.value = begun.id;
form.elements.credential.value = JSON.stringify(credential.toJSON()); form.elements.credential.value = JSON.stringify(credential.toJSON());
form.submit(); form.submit();
Executable
+65
View File
@@ -0,0 +1,65 @@
#!/bin/sh
# Run the manager and the Vite development server on this workstation.
# Usage: ./dev.sh <platform config.json> [PostgreSQL connection string]
# The router's address comes from the outputs of the platform's root module, module_root of that
# config.json, relative to its directory, read with its iac_tool; the sessions are kept in the database of the connection
# string, or without one in PGDATABASE (else the login name) with libpq's PG* variables for the
# rest. The manager reads the generated dev.json, whose bind is 127.0.0.1, and listens on 127.0.0.1:7080; its Python packages
# are installed in .venv. The application is served at http://localhost:5180 and passes /api to the
# manager.
set -e
if [ $# -lt 1 ] || [ $# -gt 2 ]; then
printf 'Usage: %s <platform config.json> [PostgreSQL connection string]\n' "$0" >&2
exit 2
fi
DATABASE="${2:-dbname=${PGDATABASE:-$(id -un)}}"
if [ ! -f "$1" ]; then
printf 'ERROR: %s does not exist.\n' "$1" >&2
exit 2
fi
ROOT="$(cd "$(dirname "$0")" && pwd)"
SETTINGS="$ROOT/dev.json"
PLATFORM_CONFIG="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"
MODULE_ROOT="$(python3 - "$PLATFORM_CONFIG" <<'EOF'
import json, os, sys
path = sys.argv[1]
root = json.load(open(path, encoding='utf-8')).get('module_root')
if not isinstance(root, str) or not root.strip() or os.path.isabs(root):
sys.exit(f'module_root in {path} must be a directory relative to {os.path.dirname(path)}')
print(os.path.normpath(os.path.join(os.path.dirname(path), root)))
EOF
)"
# The tool that reads the outputs is the platform's, iac_tool of its config.json: tofu or terraform.
IAC_TOOL="$(python3 -c 'import json, sys; print(json.load(open(sys.argv[1], encoding="utf-8")).get("iac_tool") or "terraform")' "$PLATFORM_CONFIG")"
if [ ! -d "$MODULE_ROOT" ]; then
printf 'ERROR: module_root %s is not a directory.\n' "$MODULE_ROOT" >&2
exit 2
fi
python3 - "$SETTINGS" "$MODULE_ROOT" "$DATABASE" "$IAC_TOOL" <<'EOF'
import json, sys
with open(sys.argv[1], 'w', encoding='utf-8') as handle:
json.dump({'terraform_dir': sys.argv[2], 'database': sys.argv[3], 'iac_tool': sys.argv[4], 'bind': '127.0.0.1'}, handle)
handle.write('\n')
EOF
if [ ! -x "$ROOT/.venv/bin/python" ]; then
python3 -m venv "$ROOT/.venv"
fi
if ! "$ROOT/.venv/bin/python" -c 'import psycopg, requests' 2>/dev/null; then
"$ROOT/.venv/bin/pip" install --quiet 'psycopg[binary]' requests
fi
cd "$ROOT/web"
if [ ! -d node_modules ] || [ package-lock.json -nt node_modules ]; then
npm install
fi
"$ROOT/.venv/bin/python" "$ROOT/debian-package/src/usr/lib/vpn-router/manager" --config "$SETTINGS" &
MANAGER=$!
trap 'kill $MANAGER 2>/dev/null' EXIT INT TERM
echo "Platform module: $MODULE_ROOT"
VITE_MANAGER=http://127.0.0.1:7080 npm run dev
+30
View File
@@ -0,0 +1,30 @@
# Account
Your own account: your name, kind, names, e-mail address, whether you are an administrator and
whether you are enrolled for one-time passwords. The Account and the Passkeys panel are side by
side, with One-time passwords and, for VPN users, VPN profiles below them.
## Actions
Each change but removing a passkey asks for your current password and, when you are enrolled, a
code not already used to sign in.
- **Change password** (Account panel): opens a dialog for the current and the new password; the
change signs you out.
- **Enrol** or **replace** an authenticator app: scan the QR code, then enter a code of the app.
Removing the enrolment is left to an administrator.
- **Add a passkey:** the browser or your password manager creates a passkey for the router's name;
each passkey has a label and the time it was added, and a remove icon.
- **VPN profiles** (VPN users), each after your current password and, when enrolled, a code:
- **Download the OpenVPN profile:** a `.ovpn` file for an OpenVPN client, which signs in with
your name and password.
- **Download the Apple VPN profile (IKEv2):** a `.mobileconfig` that sets up the router's IKEv2
VPN on a Mac, iPhone or iPad. It also asks for a profile password, which the device asks for
when you install the profile in System Settings (Mac) or Settings (iPhone, iPad).
## Passkeys
**Sign in with a passkey** on the sign-in page uses a passkey the browser offers, without the
name, the password or the code. Passkeys work on the Manager's HTTPS address on the router's name
only. They sign in to the Manager and to the router's OpenID Provider, and open a shell on the
[Shell](shell.md) page in place of the password and code.
+25
View File
@@ -0,0 +1,25 @@
# Backup
The router's backups: to a local folder, copied to the cloud with rclone, or to a Proxmox Backup
Server.
## Sections
- **Settings:** whether scheduled backups run, the schedule (hourly, daily or weekly), the target
and whether the metrics database is included. A local folder also shows the folder, the archives
kept and the cloud copy; a Proxmox Backup Server the repository, whether the token secret is
set, the fingerprint, the namespace and the encryption key file.
- **Last run:** its result, times, archive or snapshot, size, cloud copy and error, and the next
run.
- **Local archives:** with their times and sizes, searched by name and time, 10, 25, 50 or 100 at
a time.
- **Warnings:** the PBS client or rclone the settings need is not installed on the router.
## Actions
- **Edit:** changes the settings in one dialog. Only the retention settings that apply to the
schedule are offered. The rclone remote is one of the router's `/etc/vpn-router/rclone.conf`,
with a path and the mode, sync or copy. The token secret is kept when left empty.
- **Run now:** starts a backup; the tab reads every 3 seconds until it ends.
Restoring is done on the router as root with `vpn-router-backup restore`.
+30
View File
@@ -0,0 +1,30 @@
# Certificates
The router's two-level CA: a root CA and issuing CAs that sign the certificates.
## Above the tabs
- **Issue:** a certificate from a CSR, saved with its chain, or with a key the router makes, saved
as a PFX file protected by a password; the router keeps no copy of the key.
- **Settings:** the names, lifetimes, key type, CRL validity and publishing endpoints.
- **Warnings:** what needs attention, such as a CRL about to expire.
## Tabs
- **Root:** the root CA's subject and expiry, whether its key is on the router (marked with a
warning sign) or offline, and its CRL. **Download root CA** gives its certificate; **Upload CRL**
takes a root CRL signed offline. Below, the certificates of the issuing CAs.
- **Templates:** key, lifetime, subject, usages, alternative names, certificate policies and the
CAs that issue each template. **Add template**, **Edit**, **Remove** for the router's own and
**Reset** for a changed built-in one.
- **Extended key usages:** the usages templates may give, with their OIDs; custom ones are added
with a name and an OID.
- **P2S Issuing CA** and **S2S Issuing CA:** the CAs of the VPN users and of site-to-site
connections. An active CA gives its CA bundle; a pending CA, waiting for the offline root, gives
its CSR and takes the certificate the root signed.
- **Issuing CAs:** general-purpose CAs, each with its templates and an optional ACME server.
**Add issuing CA**, **Edit** and **Remove**; a removed CA is retired and its certificate revoked.
- **Certificates:** each with its kind, template, names, expiry and state; one of another system
or of the router is revoked after a confirmation with a reason.
The root key is exported, removed and imported on the router with `vpn-router-ca`, never here.
+56
View File
@@ -0,0 +1,56 @@
# DNS
The router's name server (PowerDNS), the primary of the router's domains. Every domain has the
same two name servers: the router's FQDN and a secondary name server. The [web sites](sites.md)
whose DNS is Router managed get their records in the domain they are in.
## Settings
- **State:** disabled, running, or not running yet; the router installs PowerDNS when it is
enabled.
- **Primary name server:** the router's FQDN, the SOA and the first NS of every domain. It is the
router's name and is not edited here; its own A record is kept by the DNS of its domain,
wherever that is, not by the router's domains.
- **Secondary name server:** the second NS of every domain, required while the name server is
enabled. The router notifies the addresses it resolves to of every change and allows them to
transfer the domains.
- **Public IP address:** the router's public IP, which the records of the sites with Public IP
take; empty when the router has none or no site uses it.
- **Listen:** the interfaces port 53 is open on. While IKEv2 or OpenVPN remote access is enabled,
it must not share an interface with the clients' resolver, by default on the internal one.
- **TTL:** how long resolvers keep the answers of a domain without its own TTL.
**Settings** edits them.
## Domains
**Add domain** takes the domain and an optional TTL; the edit button changes the TTL, and the
remove button removes a domain with its manual records. A domain inside another domain of the
router is delegated from it.
A domain the router hosts, for example `example.com`, with the router's FQDN `router.other.net`,
the secondary `ns2.provider.net`, and a Router managed site of `www.example.com` and
`example.com` with Public IP `203.0.113.10`:
```text
example.com. SOA router.other.net. hostmaster.example.com. <serial> 10800 3600 604800 300
example.com. NS router.other.net.
example.com. NS ns2.provider.net.
example.com. A 203.0.113.10
www.example.com. A 203.0.113.10
```
Selecting a domain shows:
- **Records served:** what the name server answers, including the SOA and NS records.
- **Manual records:** the records added with **Add record**: A, AAAA, CNAME, TXT, MX, SRV or CAA.
A name is relative to the domain, `@` for the domain itself. A record of a name and type the
domain already has from its name servers or its sites is not served.
Changes are served after the router applies them, a few seconds later; **Refresh** shows them.
## Delegation
The parent of each domain must delegate it: NS records naming the router's FQDN and the secondary
name server, and, for a name server inside the domain, its A record (a glue record), at the
registrar or DNS provider of the parent.
+11
View File
@@ -0,0 +1,11 @@
# Docker
Whether Docker is installed on the router, the state of `docker.service` and of the router's
Docker rules, the ports exposed on the external interface, and the rules of the
`VPN-ROUTER-DOCKER` chain.
Exposed ports are set on the router as root, for example:
```sh
vpn-config-edit set --restart docker.exposed_ports 443,8000:8100
```
+18
View File
@@ -0,0 +1,18 @@
# Groups
The groups of the router's directory, with their descriptions and members.
- **Add** and **Edit:** the name, the description and the members in one dialog. A group has at
least one member: a user or a service account, but not an internal one.
- **Remove:** removes the group; its members are kept, and it is taken out of the roles assigned
to it. A group that a client of the [OpenID Provider](service-accounts.md#openid-provider) allows
cannot be removed.
## Groups the router reads
- **admins:** its members use all pages of the Manager. Only root on the router changes it, with
`vpn-router-group` or a token of `vpn-router-token`.
- **Allowed groups** of an OpenID Connect client: only their members may sign in to that client.
Renaming a group renames it in those clients.
- **Roles** of a resource of the OpenID Provider assigned to a group are roles of its members, in
the tokens for that resource. Renaming a group renames it in those roles.
+36
View File
@@ -0,0 +1,36 @@
# VPN Router Manager Documentation
The VPN Router Manager shows the state of a router running the `vpn-router` package and manages
its users, connections, certificates and services. Members of the router's group `admins` see all
pages; any other user sees only their own account.
The help opens at the topic of the page you are on. Links between topics open in this panel;
Back returns to the previous topic and the house icon to this one.
## Pages
| Page | Topics |
| --- | --- |
| Router | [Overview](router.md), [Metrics](metrics.md), [Logs](logs.md), [Backup](backup.md), [System](system.md) |
| Identities | [Users](users.md), [Groups](groups.md), [Service accounts](service-accounts.md) |
| Connectivity | [Site-to-site connections](site-to-site.md) |
| Remote Access | [IKEv2, OpenVPN and WireGuard](remote-access.md) |
| Certificates | [Certificates](certificates.md) |
| Web Sites | [Sites](sites.md), [Files](site-files.md), [DNS](dns.md) |
| Docker | [Docker](docker.md) |
| Header | [Shell](shell.md), [Account](account.md) |
## Header
- **Page menu** (right end): switches between the pages; its button names the page shown.
- **Help** (question mark, left of the page menu): opens and closes this panel; the page moves left
while it is open.
- **Shell** (terminal icon, administrators): a shell on the router; pressed on the Shell page, it
returns to the page it was opened from.
- **Your name**: opens your account; pressed there, it returns to the page it was opened from.
- **Sign out**, **Settings** (gear, but not on the account page) and **Theme** (light, dark or the system's).
## Settings
The gear, hidden on the sign-in and the account page, sets how often the Overview and the Metrics tab read the router on their own: manually, or
every 5, 10 or 30 seconds, 1, 5 or 15 minutes. The browser keeps the choice; Defaults sets it back.
+26
View File
@@ -0,0 +1,26 @@
# Logs
The last lines of the router's logs.
## Sub-tabs
- **Journal:** the systemd journal of one of the router's units, or of all units.
- **Files:** a log file the router lists in its setting `web.log_files`. Files are added on the
router as root:
```sh
vpn-config-edit set web.log_files /var/log/syslog,/var/log/auth.log
```
## Controls
- **Unit** or **File:** the log shown.
- **Lines:** 100, 200, 500 or 1000 of the last lines; the router answers at most 1000 at a time,
and reads at most the last MiB of a file.
- **Follow:** reads the log every 2 seconds and keeps the view at its last line, as `tail -f`
does. Refresh is off meanwhile.
- **Refresh:** reads the log again. A log is also read when the unit, the file or the number of
lines changes.
Older lines stay on the router: `journalctl` and the files themselves, over SSH or the
[Shell](shell.md).
+22
View File
@@ -0,0 +1,22 @@
# Metrics
Charts of the router over the last hour, day, month or year, read every minute by default (see
[Settings](index.md#settings)).
## Charts
- CPU, memory, free disk space in bytes and in percent, and the load averages.
- Bytes per second of each interface, and the VPN clients.
- Per site-to-site connection: **Site-to-site up**, **Site-to-site traffic** and
**Site-to-site packets**.
## Controls
- **Hour, Day, Month, Year:** the period shown.
- **Average, Maximum:** how each point sums up its time slot. Site-to-site up is always the
percentage of the slot the connection was up: 5 minutes for an hour, 1 hour for a day, 1 day
for a month and 1 month for a year.
- **Legend:** a click on a line's legend item hides or shows the line, for example the 1 and 5
minute load averages to see the 15 minute one. The browser keeps the hidden lines.
- The charts fold, hide, move and change width as the [Router](router.md#layout) panels do, with
a layout of their own.
+41
View File
@@ -0,0 +1,41 @@
# Remote Access
A tab each for IKEv2, OpenVPN and WireGuard access: the settings, whether the router
runs the system, why an enabled one does not run yet, and the connected clients.
## Edit
Enables or disables the system and sets its address pool (for WireGuard the address of `wg0`) and
its port, with OpenVPN's protocol. The router applies a change in the background.
IKEv2 and OpenVPN also need the router's `wan.local_fqdn` and its VPN server certificate. A cloud
firewall in front of the router, such as an Azure network security group, must allow the OpenVPN
and WireGuard ports itself.
## IKEv2
The IPsec security associations, child SAs, connections and connection children of IKEv2 access.
**DNS domains** (Edit, one per line): the domains, with their subdomains, the clients resolve with
the router's DNS, such as `internal.cloudapp.net`; other names stay with the clients' own DNS. The
router pushes them over IKEv2, and the Apple VPN profile names them, so a Mac, iPhone or iPad needs
a newly downloaded profile after a change.
## OpenVPN
Users connect with the client profile downloaded on the [Users](users.md) tab, and sign in with
their password and, when enrolled, the code.
## WireGuard
- **Connections:** each peer with its user, IPs, endpoint, handshake and traffic.
- **Peers:** the devices of the VPN users, each with its user, public key and IPs.
- **Add peer:** the user, the device's public key and the IPs, which start as the next free
address of the `wg0` network. The router checks that keys are unique and IPs do not overlap.
- The eye opens the device's configuration; the download gives `<user>-<n>.conf`, with a
placeholder for the device's private key, which the router never has.
- The remove button removes a peer after a confirmation.
- **Server:** the router's public key with a copy icon, the endpoint and the allowed IPs a device
needs; **Show config** opens a device configuration with placeholders.
- In the configuration dialogs a switch leaves out the `[Interface]` and `PrivateKey` lines, which
WireGuard for Windows fills in for a new empty tunnel.
+51
View File
@@ -0,0 +1,51 @@
# Overview
The Overview tab shows the state of the router, read every 10 seconds by default (see
[Settings](index.md#settings)). The updates of the other packages of the system are on the
[System](system.md) tab.
## Panels
- **Status:** the overall status, ok or degraded, on its title bar; the installed version of
`vpn-router` and, when the repository has a newer one, that version;
IKEv2, OpenVPN and WireGuard; the status web service and its port; HTTPS and the name it is for,
with a chip while the certificates come from Let's Encrypt's staging CA and a settings button
(see HTTPS settings below); and Caddy, its HTTPS proxy, with its version, where it comes from (Ubuntu or upstream) and the
state of `caddy.service`.
- **Services:** the state of each of the router's systemd services and of Caddy; the chip on the
title bar tells whether the router's services are active.
- **Addresses:** links to the status page over HTTPS and over plain HTTP, and to the Router
Manager over HTTPS, each opening in a new tab; and the issuer and the discovery URL of the
router's OpenID Provider, each with a copy icon, or off while the provider is disabled (see
[Service accounts](service-accounts.md#openid-provider)).
- **Backup:** the result of the last run, the next run and Run now (see [Backup](backup.md)).
- **PostgreSQL:** each cluster with its main settings and its databases with their sizes.
- **Configuration:** the sections of the router's configuration the other pages do not show,
among them the status web service (`web`) and HTTPS (`https`).
Caddy is switched to upstream's packages on the router as root with
`vpn-router-caddy use-upstream`.
## HTTPS settings
The settings button of the HTTPS row switches **Let's Encrypt staging CA**. While it is on, Caddy
takes every certificate, the web sites' included, from Let's Encrypt's staging environment, whose
rate limits are far higher but whose certificates no browser trusts: it is for routers that are
rebuilt often, for tests only. While it is off, Caddy takes its certificates from Let's Encrypt,
and from ZeroSSL when Let's Encrypt refuses, such as for its limit of 5 certificates a week for the
same name. Saving runs the router's setup again, which applies the change. The other HTTPS
settings are changed on the router with `vpn-config-edit`.
## Updates
- **Check update** asks the router's package repository for newer versions.
- **Upgrade** appears when one is found; it installs the new packages after a confirmation and
shows the upgrade's output. A reboot is offered when a package asks for one.
- Nothing is read while a check, an upgrade or a reboot runs.
## Layout
- A panel folds by its title bar, and every panel but Status hides by its close icon; **Panels**
shows the hidden ones again.
- **Edit layout** moves panels by dragging and switches each between one column and both.
- The browser keeps the layout.
+68
View File
@@ -0,0 +1,68 @@
# Service accounts
Accounts that other systems sign in with to use the router's services, such as an OpenTelemetry
Collector reading the router's metrics. They cannot sign in to the Manager.
## Columns
- **Name:** a GUID, which Add proposes, or a user name.
- **Kind:** a service account, or **Internal**: the router creates and manages an internal one for
its own services, so it has no actions here.
- **OpenID Provider:**
- as a client: the number of redirect URIs and the allowed groups, the grants, `public`, and the
scopes it asks for;
- as a resource: its scopes and roles;
- `-` for none.
## Actions
- **Add** and **Edit:** the display name, the description, an optional e-mail address and the
OpenID Provider settings, as a client and as a resource; Add also takes the password.
- **Password**, **Disable**, **Enable**, **Unlock** after failed logins, and **Remove**.
## OpenID Provider
The router can sign its users in to other systems with OpenID Connect. Its issuer and discovery
URL are on the Addresses panel of the [Overview](router.md), each with a copy icon, and while it is
enabled the title of the list has the number of its clients. It is turned on on the router as
root:
```sh
vpn-config-edit set --restart oidc.enabled true
```
### As a client
A service account with a redirect URI or a grant is a client:
- **Client ID:** the account's name.
- **Client secret:** the account's password.
- **Redirect URIs:** where the provider sends the browser back after a sign-in; `https`, or
`http` for `localhost`, on any port. One per line. They give the client the authorization code
flow with PKCE (S256).
- **Post-logout redirect URIs:** where the browser may go after signing out. One per line.
- **Allowed groups:** only their members may sign in to the client; without any, every user may.
- **Client credentials:** the client gets a token as its own service account, with its secret and
without a user, for jobs and scripts.
- **Device authorization:** a program without a browser shows a code. The user opens the router's
device page, enters the code, signs in, and allows or denies the program.
- **Public client (no secret):** for programs that cannot keep a secret, such as a CLI; it cannot
use client credentials.
- **Client scopes:** the scopes of resources the client may ask for, as `<resource>:<scope>`.
Users sign in on the router's page with their password and code, or with a passkey.
### As a resource
Every service account is also a resource: an application that checks the router's tokens, such
as an S3 gateway. A client asks for a token for it by its name, or by asking for its scopes.
- **Scopes:** what clients may ask to do there, such as `read`, asked for as `<name>:read`. A
scope a client may ask for cannot be removed.
- **Roles:** names such as `Reader`, each assigned to groups, users and service accounts. A token
for the resource carries, in `roles`, the roles its user or service account holds, directly or
through a group.
The tokens are signed JWTs, which the resource checks with the provider's keys. The access token
names the resource as its audience, and carries the scopes granted and the roles. The ID token,
for the client, carries the user's roles of the client itself.
+51
View File
@@ -0,0 +1,51 @@
# Shell
A login shell on the router, as your console account: your own POSIX account of the directory, or
a local account root chose.
- **Opening** asks for your password and code again, unless you used a shell within the router's
`web.shell_auth_timeout` minutes. **Open with a passkey** takes one of your passkeys instead,
unlocked with your device's PIN or biometrics; it shows when you have a passkey (see
[Account](account.md)).
- **Closing:** the router ends the shell after `web.shell_idle_minutes` without a keystroke; the
page shows the reason and keeps the terminal readable.
## Editor
`vre FILE...` (or `vpn-router-editor FILE...`) in the shell opens the files in the editor of this
page and returns at once:
```sh
vre /etc/hosts ~/notes.txt
```
- **Tabs:** each file has a tab with its name, and its path as the tooltip. A dot marks unsaved
changes, and the cross closes the tab, asking first when there are unsaved changes.
- **Editor icon** (left of the shell icon): shown while a file is open. It switches between the
terminal and the editor; the shell keeps running.
- **Minimap button** (left of Save): shows or hides the minimap at the right edge of the editor.
This browser remembers the choice.
- **Save** (or Ctrl+S): writes the file on the router, as your console account. When the file
changed on the router after it was read, **Reload** takes the router's version and **Overwrite**
writes yours.
- **Limits:** text files of up to 1 MiB, at most 20 at a time. A file that does not exist is
created when saved. A file your console account may not write cannot be saved; edit it in the
terminal.
- **When files close:** when the shell ends, every file closes and the icon goes away. `vre`
works only in this shell.
## Requirements
- Membership of `admins`, enrolment for one-time passwords (see [Account](account.md)), also when
you open the shell with a passkey, as the router's login session and `sudo` need it, and console
login, which root switches on the router:
```sh
vpn-router-user console <name> --posix
```
- The router offers the shell only while its `web.shell` is `true`:
```sh
vpn-config-edit set web.shell true
```
+19
View File
@@ -0,0 +1,19 @@
# Site files
The files of one web site, a folder at a time. **Site** chooses the site; the path above the list
leads back up to the site's folder, as does `..` in the list.
## Actions
- **Upload:** sends the chosen files into the folder shown. Files dropped on the list are
uploaded the same way.
- **Extract zip archives:** while it is on, an uploaded `.zip` file is extracted into the folder
shown instead of being stored.
- **New folder**, **Rename** and **Remove**; removing a folder removes everything in it.
- A file's name or **Download** saves it.
An upload shows its progress. A file larger than the largest upload of the
[Web sites](sites.md) settings is not sent. A zip archive with a path that leaves the folder, a
link, more than 10000 entries or more content than the largest upload is refused as a whole.
Caddy serves a folder's index file, `index.html` unless the site names another.
+30
View File
@@ -0,0 +1,30 @@
# Site-to-site connections
The Connectivity page: the router's IPsec site-to-site connections and its network.
## Status tab
Each connection the router set up, with the local and the remote identity, the subnets it
resolved and how long it has been up, and their IPsec security associations, child SAs,
connections and connection children. IKEv2 access connections are on the IKEv2 tab of
[Remote Access](remote-access.md).
## Network tab
The external and the internal interface with their addresses, the internal gateway, the local
CIDRs and the IPv4 routes. The panels fold, hide, move and change width; the browser keeps their
layout.
## Connections tab
Each connection with its remote peer, remote identity, remote and local CIDRs, local identity mode,
authentication and whether the tunnel is established.
- **Add**, **Edit** and **Remove** (after a confirmation).
- **Pre-shared key:** typed as a password; required while the connection has none and kept when
left empty; never shown.
- **Certificates:** the PEM certificate of a remote CA is optional; without it the peer's
certificate must come from the router's site-to-site CA.
The router applies a change in the background by running its setup again. The connection the
router was installed with is `remote-site`.
+53
View File
@@ -0,0 +1,53 @@
# Web sites
Static web sites that Caddy serves on the router, each from its own folder,
`/var/lib/vpn-router/sites/<name>`. The files of a site are on the [Files](site-files.md) tab, and
the name server of their domain on the [DNS](dns.md) tab.
## Settings
- **State:** whether the sites are served.
- **Largest upload:** the largest file or zip archive the Files tab and the router's API take.
- **WebDAV:** whether the folders are served over WebDAV, and its URL with a copy icon.
**Settings** edits them.
## Columns
- **URLs:** the addresses of the site, one per host name; a site without host names answers any
name on its port.
- **Port** and **Listen:** the TCP port, and the interfaces its firewall opens: internal,
external or both.
- **TLS:** None (plain HTTP), Let's Encrypt, an issuing CA of the Certificates page, or a
certificate and key given with the site.
- **Editors:** the group whose members may change the files besides the admins.
- **DNS:** who serves the records of the host names. **User managed:** a DNS provider outside the
router. **Router managed:** the router's name server gives each host name an A or AAAA record in
its domain on the [DNS](dns.md) tab, with the router's **Public IP** (the Public IP address of
the DNS settings), **Internal interface IP** or **External interface IP**. A host name in none of
the router's domains gets no record.
A host name may be the router's FQDN, on a port of the site's own, such as 443, which the router's
own services leave free. Such a site is User managed: the record of the router's FQDN stays with
the DNS of its domain.
## Actions
- **Add** and **Edit:** the settings of a site; the name is fixed once added.
- **Files** opens the site on the Files tab.
- **Disable**, **Enable**, and **Remove**, which keeps the folder unless asked to remove it.
## Rules
- The router's own ports, such as 22, 7081, 7443, 7444 and 7445, cannot be taken by a site.
- Sites sharing a port need host names of their own, the same Listen and the same kind of TLS.
- Let's Encrypt needs host names that resolve to the router from the internet, and port 80.
- A certificate and key must belong together and name the host names.
- An issuing CA's certificates are trusted by browsers that trust the router's root CA.
## WebDAV
With WebDAV on, a WebDAV client signs in at the WebDAV URL with a user name and password of the
router, followed by the TOTP code for an enrolled user. Admins see a folder for every site, other
users the folders of the sites whose editors group they are members of. An upload reaches the site
a few seconds after it is written.
+17
View File
@@ -0,0 +1,17 @@
# System
The System tab manages the router's operating system.
## System updates
- The panel lists the packages `apt-get upgrade` would upgrade, each with its installed and its
new version, as of apt's last update of its package lists.
- **Check update** updates apt's package lists and lists the packages again.
- **Upgrade** appears when a package can be upgraded; after a confirmation it runs
`apt-get upgrade -y` on the router and shows its output. It upgrades the installed packages
only: none is installed or removed, and a package that needs a new one is kept back.
- Services restart during the upgrade; when `vpn-router` is upgraded, the page may not
answer for a while, and **Reload page** loads its new version.
- A reboot is offered when a package asks for one, as after a new kernel.
- Only one upgrade runs at a time: this one or the upgrade of `vpn-router` on the
[Overview](router.md) tab.
+34
View File
@@ -0,0 +1,34 @@
# Users
The users of the router's directory, without the [service accounts](service-accounts.md).
## Kinds of users
- **VPN user:** a first name, a last name, an e-mail address and a certificate; signs in to
OpenVPN, IKEv2 and WireGuard, and to the Manager.
- **Manager-only account:** a name, a password and an optional e-mail address; no certificate and
no VPN access.
## Columns
- **OTP:** whether the user is enrolled for one-time passwords (TOTP).
- **Passkeys:** how many passkeys the user signs in to the Manager with.
- **Console:** for a member of `admins`, the console login: off, the user's own POSIX account with
its uid, or the local account it runs as; marked as refused while the user is not enrolled for
TOTP. Root switches it on the router with `vpn-router-user console`.
## Actions
- **Add:** a VPN user, or with Manager access only a Manager-only account.
- **Edit:** the names and the e-mail address of a VPN user, the e-mail address of a Manager-only
account.
- **Password**, **Disable**, **Enable**, **Unlock** after failed logins, and **Remove**.
- **Profile:** downloads the OpenVPN client profile, while the router runs OpenVPN.
- **Apple VPN profile** (Apple icon): downloads a `.mobileconfig` that sets up the router's IKEv2
VPN on a Mac, iPhone or iPad, while the router runs IKEv2. The password you type protects the
user's key in the profile; the device asks for it when the profile is installed.
- **OTP:** enrols an authenticator app with a QR code, stored once a code of the app is entered,
or removes the enrolment.
- **Remove passkeys:** removes all the user's passkeys.
Five wrong passwords lock a user for 15 minutes; Unlock ends that sooner.
+4 -4
View File
@@ -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" {
+4 -4
View File
@@ -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" {
Executable
+48
View File
@@ -0,0 +1,48 @@
#!/bin/sh
set -e
# CONTAINER_RUNTIME from the environment, for example docker with DOCKER_CONTEXT naming a remote engine;
# otherwise Apple container CLI on macOS and Docker CE on Linux.
if [ -z "${CONTAINER_RUNTIME:-}" ]; then
case "$(uname -s)" in
Darwin) CONTAINER_RUNTIME=container ;;
Linux) CONTAINER_RUNTIME=docker ;;
*) printf 'ERROR: unsupported platform %s\n' "$(uname -s)" >&2; exit 1 ;;
esac
fi
OWNER="${OWNER:-koszewscy}"
SERVER_URL="${SERVER_URL:-https://gitea.koszewscy.waw.pl}"
PACKAGES_TOKEN="${PACKAGES_TOKEN:-$(cat ~/.gitea-packages-token)}"
# The Ubuntu releases whose distribution of the repository gets the package, in its component main.
DISTRIBUTIONS="${DISTRIBUTIONS:-noble resolute}"
# Reads the changelog copied into the builder image, so no bind mount is needed.
changelog_field() {
$CONTAINER_RUNTIME run --rm vpn-router-builder \
dpkg-parsechangelog --show-field "$1" -l /build/src/debian/changelog
}
VERSION="$(changelog_field Version)"
PACKAGE="$(changelog_field Source)"
DEB="out/${PACKAGE}_${VERSION}_all.deb"
if [ ! -f "$DEB" ]; then
printf 'ERROR: %s does not exist. Run ./build.sh first.\n' "$DEB" >&2
exit 1
fi
BODY="$(mktemp)"
trap 'rm -f "$BODY"' EXIT
# Gitea answers 409 for a version that is in the distribution already.
for DISTRIBUTION in $DISTRIBUTIONS; do
STATUS="$(curl -s -o "$BODY" -w '%{http_code}' --user "$OWNER:$PACKAGES_TOKEN" \
--upload-file "$DEB" \
"$SERVER_URL/api/packages/$OWNER/debian/pool/$DISTRIBUTION/main/upload")"
case "$STATUS" in
2??) printf '%s %s is published in %s/main.\n' "$PACKAGE" "$VERSION" "$DISTRIBUTION" ;;
409) printf '%s %s is already in %s/main, skipping.\n' "$PACKAGE" "$VERSION" "$DISTRIBUTION" ;;
*) printf 'ERROR: the upload to %s/main returned HTTP %s: %s\n' "$DISTRIBUTION" "$STATUS" "$(cat "$BODY")" >&2; exit 1 ;;
esac
done
+18 -11
View File
@@ -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:
+8
View File
@@ -0,0 +1,8 @@
{
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
},
"ignorePatterns": ["dist"]
}
+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VPN Router Manager</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<style>
html.light { color-scheme: light; }
html.dark { color-scheme: dark; background-color: #121212; }
</style>
<script>
// Sets the stored MUI color scheme on <html> before the first paint.
(function () {
try {
var mode = localStorage.getItem('mui-mode') || 'system';
var dark = mode === 'dark' || (mode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList.add(dark ? 'dark' : 'light');
} catch (e) {}
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3240
View File
File diff suppressed because it is too large. Load diff
+40
View File
@@ -0,0 +1,40 @@
{
"name": "vpn-router-manager-web",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fontsource/roboto": "^5.3.0",
"@mui/icons-material": "^9.5.0",
"@mui/material": "^9.5.0",
"@mui/x-charts": "^9.16.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"dompurify": "^3.4.17",
"github-markdown-css": "^5.9.0",
"marked": "^18.1.1",
"monaco-editor": "^0.57.0",
"qrcode.react": "^4.2.0",
"react": "^19.3.1",
"react-dom": "^19.3.1"
},
"devDependencies": {
"@types/node": "^26.6.5",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2",
"oxlint": "^1.89.0",
"typescript": "^7.0.2",
"vite": "^8.3.5"
}
}
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="shield" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#e8c25a" />
<stop offset="1" stop-color="#8b4513" />
</linearGradient>
</defs>
<path fill="url(#shield)" d="M32 4 L56 13 V30 C56 45 46 55 32 60 C18 55 8 45 8 30 V13 Z" />
<path fill="none" stroke="#ffffff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" d="M20 26 H44 M38 20 L44 26 L38 32 M44 40 H20 M26 34 L20 40 L26 46" />
</svg>

After

Width:  |  Height:  |  Size: 530 B

+236
View File
@@ -0,0 +1,236 @@
import Apple from '@mui/icons-material/Apple'
import Delete from '@mui/icons-material/Delete'
import Download from '@mui/icons-material/Download'
import Key from '@mui/icons-material/Key'
import Password from '@mui/icons-material/Password'
import {
Alert, Box, Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Stack, Table, TableBody, TableCell,
TableHead, TableRow, TextField, Tooltip, Typography,
} from '@mui/material'
import { useEffect, useState, type FormEvent } from 'react'
import OtpDialog from './OtpDialog.tsx'
import PasskeyDialog from './PasskeyDialog.tsx'
import ProfileDialog from './ProfileDialog.tsx'
import { Section } from './RouterPanel.tsx'
import { ACCOUNT_SHAPE, CODE_PATTERN, USER_SHAPE, request, type Account, type User } from './api.ts'
/**
* The account of the signed-in user, from the router's /account, in two columns: the details with Change password,
* which opens the password dialog, and the passkeys in the first row, the enrolment for one-time passwords and the
* OpenVPN and the Apple VPN profile of a user with a certificate in the second. Each change but the removal of a
* passkey takes the current password and, for an enrolled user, a current code. A changed password ends the session,
* through onPasswordChanged.
*/
export default function AccountPanel(props: { onPasswordChanged: () => void }) {
const [account, setAccount] = useState<Account | null>(null)
const [error, setError] = useState<string | null>(null)
const [enrolling, setEnrolling] = useState(false)
const [currentPassword, setCurrentPassword] = useState('')
const [currentCode, setCurrentCode] = useState('')
const [password, setPassword] = useState('')
const [again, setAgain] = useState('')
const [saving, setSaving] = useState(false)
const [passwordError, setPasswordError] = useState<string | null>(null)
const [changingPassword, setChangingPassword] = useState(false)
const [addingPasskey, setAddingPasskey] = useState(false)
const [profile, setProfile] = useState<'openvpn' | 'apple' | null>(null)
const [passkeyError, setPasskeyError] = useState<string | null>(null)
useEffect(() => {
request<Account>('GET', '/account', ACCOUNT_SHAPE)
.then(setAccount)
.catch((caught: Error) => setError(caught.message))
}, [])
if (account === null) {
return error ? <Alert severity="error">{error}</Alert> : <CircularProgress />
}
const againProblem = again !== '' && again !== password ? 'The passwords differ.' : ''
const blocked = currentPassword === '' || (account.otp && !CODE_PATTERN.test(currentCode)) || password === '' || again !== password
async function changePassword(event: FormEvent) {
event.preventDefault()
if (account === null || blocked) {
return
}
setSaving(true)
setPasswordError(null)
try {
const proof = account.otp ? { current_password: currentPassword, current_code: currentCode } : { current_password: currentPassword }
await request('PUT', '/account/password', USER_SHAPE, { ...proof, password })
props.onPasswordChanged()
} catch (caught) {
setPasswordError((caught as Error).message)
setSaving(false)
}
}
/** Closes the password dialog, unless a change is being saved, and empties its fields. */
function closePasswordDialog() {
if (saving) {
return
}
setChangingPassword(false)
setCurrentPassword('')
setCurrentCode('')
setPassword('')
setAgain('')
setPasswordError(null)
}
async function removePasskey(id: string) {
if (account === null) {
return
}
setPasskeyError(null)
try {
setAccount({ ...(await request<User>('DELETE', `/account/passkeys/${encodeURIComponent(id)}`, USER_SHAPE)), admin: account.admin })
} catch (caught) {
setPasskeyError((caught as Error).message)
}
}
const rows: [string, string][] = [
['User name', account.name],
['Kind', account.manager_only ? 'Manager-only account' : 'VPN user'],
...(account.manager_only ? [] : [['First name', account.first_name ?? '-'], ['Last name', account.last_name ?? '-']] as [string, string][]),
['E-mail address', account.email ?? '-'],
['Router administrator', account.admin ? 'yes' : 'no'],
['One-time passwords', account.otp ? 'enrolled' : 'not enrolled'],
]
return (
<Box sx={{ display: 'grid', gap: 2, gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, alignItems: 'start' }}>
<Section title="Account">
<Box component="dl" sx={{ display: 'grid', gridTemplateColumns: 'max-content 1fr', columnGap: 2, rowGap: 0.5, m: 0 }}>
{rows.map(([label, value]) => (
<Box key={label} sx={{ display: 'contents' }}>
<Typography component="dt" variant="body2" color="text.secondary">{label}</Typography>
<Typography component="dd" variant="body2" sx={{ m: 0 }}>{value}</Typography>
</Box>
))}
</Box>
<Button variant="outlined" startIcon={<Password />} sx={{ mt: 2 }} onClick={() => setChangingPassword(true)}>Change password</Button>
</Section>
<Section title="Passkeys">
<Stack sx={{ gap: 2, alignItems: 'flex-start' }}>
{passkeyError && <Alert severity="error" sx={{ whiteSpace: 'pre-line', alignSelf: 'stretch' }}>{passkeyError}</Alert>}
{account.passkeys.length === 0
? <Typography variant="body2" color="text.secondary">No passkeys.</Typography>
: (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Label</TableCell>
<TableCell>Added</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{account.passkeys.map((passkey) => (
<TableRow key={passkey.id}>
<TableCell>{passkey.label}</TableCell>
<TableCell sx={{ whiteSpace: 'nowrap' }}>{new Date(passkey.created).toLocaleString()}</TableCell>
<TableCell align="right">
<Tooltip title="Remove">
<IconButton size="small" color="error" onClick={() => void removePasskey(passkey.id)}><Delete fontSize="small" /></IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<Button variant="outlined" startIcon={<Key />} onClick={() => setAddingPasskey(true)}>Add a passkey</Button>
</Stack>
</Section>
<Section title="One-time passwords">
<Stack sx={{ gap: 2, alignItems: 'flex-start' }}>
<Typography variant="body2" color="text.secondary">
{account.otp
? 'Signing in takes a code of the authenticator app. Enrolling again replaces the authenticator; an administrator removes the enrolment.'
: 'Enrol an authenticator app; signing in then takes a code of it.'}
</Typography>
<Button variant="outlined" onClick={() => setEnrolling(true)}>{account.otp ? 'Replace the authenticator' : 'Enrol'}</Button>
</Stack>
</Section>
{account.certificate && (
<Section title="VPN profiles">
<Stack sx={{ gap: 2, alignItems: 'flex-start' }}>
<Button variant="outlined" startIcon={<Download />} onClick={() => setProfile('openvpn')}>Download the OpenVPN profile</Button>
<Button variant="outlined" startIcon={<Apple />} onClick={() => setProfile('apple')}>Download the Apple VPN profile (IKEv2)</Button>
</Stack>
</Section>
)}
<Dialog open={changingPassword} onClose={closePasswordDialog} fullWidth maxWidth="xs">
<form onSubmit={changePassword}>
<DialogTitle>Change password</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, pt: 1 }}>
{passwordError && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{passwordError}</Alert>}
<Typography variant="body2" color="text.secondary">Changing the password signs you out.</Typography>
<TextField label="Current password" type="password" value={currentPassword} autoComplete="current-password" autoFocus onChange={(event) => setCurrentPassword(event.target.value)} />
{account.otp && (
<TextField
label="Code"
value={currentCode}
autoComplete="one-time-code"
helperText="A code not used to sign in."
slotProps={{ htmlInput: { inputMode: 'numeric', maxLength: 6 } }}
onChange={(event) => setCurrentCode(event.target.value.trim())}
/>
)}
<TextField label="New password" type="password" value={password} autoComplete="new-password" onChange={(event) => setPassword(event.target.value)} />
<TextField
label="New password again"
type="password"
value={again}
autoComplete="new-password"
error={againProblem !== ''}
helperText={againProblem}
onChange={(event) => setAgain(event.target.value)}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={closePasswordDialog}>Cancel</Button>
<Button type="submit" variant="contained" disabled={saving || blocked}>Change password</Button>
</DialogActions>
</form>
</Dialog>
{profile && (
<ProfileDialog
kind={profile}
name={account.name}
path={profile === 'apple' ? '/account/apple-profile' : '/account/profile'}
own
otp={account.otp}
onClose={() => setProfile(null)}
/>
)}
{addingPasskey && (
<PasskeyDialog
otp={account.otp}
onCancel={() => setAddingPasskey(false)}
onSaved={(user) => {
setAccount({ ...user, admin: account.admin })
setAddingPasskey(false)
}}
/>
)}
{enrolling && (
<OtpDialog
name={account.name}
path="/account/otp"
proof={{ otp: account.otp }}
onCancel={() => setEnrolling(false)}
onSave={async (secret, code, proof) => {
setAccount({ ...(await request<User>('PUT', '/account/otp', USER_SHAPE, { ...proof, secret, code })), admin: account.admin })
setEnrolling(false)
}}
/>
)}
</Box>
)
}
Loaded 100 of 172 files, more files were not shown because too many files have changed in this diff. Show more