Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d90441bee | ||
|
|
ee1161f534 | ||
|
|
7f85a1a2e5 | ||
|
|
c2c33cfca0 | ||
|
|
f36e2a3b04 | ||
|
|
1e0e375d60 | ||
|
|
dd489e65aa | ||
|
|
3de030b06b | ||
|
|
3c4c5a8c32 | ||
|
|
3ed79c7563 | ||
|
|
3e78d520ae | ||
|
|
d99e4ea425 | ||
|
|
46d3550eab | ||
|
|
3b14b7723e | ||
|
|
5303779559 | ||
|
|
2dc4db440f | ||
|
|
45cea95728 | ||
|
|
dd2dfaaf2b | ||
|
|
1697e1aa38 | ||
|
|
57e8bbcffe | ||
|
|
9ca8fce14e | ||
|
|
74c24d7bc6 | ||
|
|
770332728e | ||
|
|
e14bbe2ba1 | ||
|
|
dabe4e8365 | ||
|
|
ea8ca97417 | ||
|
|
77bdbcb058 | ||
|
|
cb33b3bfa1 | ||
|
|
81b7f4cac6 |
No files matched your search
@@ -46,7 +46,7 @@ and as a Tailwind CSS page for a browser, and `/status` is the same status as JS
|
||||
much as `web.status_content` says, since the router is reachable from the internet and the
|
||||
operator decides how much it tells strangers: `basic`, the default because it tells the least,
|
||||
the overall status and the Client IP, `standard` also the package versions and the number of
|
||||
active and other services, and `full` also each service and the state of the site-to-site, road-warrior, OpenVPN and
|
||||
active and other services, and `full` also each service and the state of the site-to-site, IKEv2, OpenVPN and
|
||||
WireGuard features. The Client IP is the address the request came from, through the trusted
|
||||
proxies, so a person can see the address the router sees. Nothing comes from the configuration.
|
||||
`/status` names its content, so clients such as manage-ui and the Router Manager show what it
|
||||
@@ -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
|
||||
cache, which dpkg refreshes with its deferred `ldconfig` only at the end of an installation, so a
|
||||
service started during the installation cannot import it yet; a failed import cannot be repeated
|
||||
in the same process either, as psycopg caches the lookup. The services therefore import psycopg
|
||||
plainly, and their units wait instead: `ExecStartPre=wait-for-libpq`, a shell script of each
|
||||
in the same process either, as psycopg caches the lookup. The services and `vpn-router-setup`
|
||||
therefore import psycopg plainly, and their units wait instead: `ExecStartPre=wait-for-libpq`, a shell script of each
|
||||
package, waits until `ldconfig -p` lists `libpq.so.5`, within `TimeoutStartSec=5min`. The
|
||||
maintainer scripts start the units with `systemctl --no-block` (debhelper's start is turned off),
|
||||
because a start that waited would hold dpkg before its `ldconfig`.
|
||||
@@ -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
|
||||
structural class and its password hash cannot be copied; it is removed and added again instead.
|
||||
It has no certificate, so OpenVPN and road-warrior access refuse it. The administrator's password
|
||||
It has no certificate, so OpenVPN and IKEv2 access refuse it. The administrator's password
|
||||
is on disk only until setup has run.
|
||||
|
||||
## ADR-19: The package has an LDAP schema of its own under a UUID OID
|
||||
@@ -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
|
||||
address can be added later without changing their classes; VPN users get it once scopes are used.
|
||||
`vpnrouter_ldap.provision` replaces the definitions in `cn=config` that differ, so an extended class
|
||||
reaches an existing directory at the next setup run.
|
||||
reaches an existing directory at the next setup run. The structural class `vpnRouterRole`,
|
||||
`MUST cn MAY ( description $ member )`, is a role of a resource of the OpenID Provider (ADR-37),
|
||||
an entry `cn=<resource>:<role>` of `ou=roles`, which provision makes; the manager account manages
|
||||
that subtree, and every account reads it.
|
||||
|
||||
Consequences: The schema check stays strict. New attributes extend `vpnRouterAccount`, and their
|
||||
OIDs continue the arcs. The scope `service` marks a service account (ADR-23). The OID must never
|
||||
be reused for anything else.
|
||||
Consequences: The schema check stays strict. New attributes of accounts extend
|
||||
`vpnRouterAccount`, and their OIDs continue the arcs. The scope `service` marks a service account
|
||||
(ADR-23). The OID must never be reused for anything else.
|
||||
|
||||
## ADR-20: Users manage their own account through /account, proving who they are again
|
||||
|
||||
@@ -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
|
||||
unchanged. Both services always listen on `127.0.0.1` as well as on the interfaces their
|
||||
`listen` settings name, and trust `X-Forwarded-For` from it, which Caddy sets from the peer and
|
||||
never takes from the client.
|
||||
never takes from the client. Let's Encrypt issues at most 5 certificates a week for the same
|
||||
names, which routers rebuilt for tests reach; `https.acme_staging` sets Caddy's global `acme_ca`
|
||||
to Let's Encrypt's staging directory, which replaces Caddy's default CAs for every site, so no
|
||||
test certificate comes from a production CA. `PUT /https/settings` changes `acme_staging` alone:
|
||||
the other settings of the section switch HTTPS off or move its ports, which would end the
|
||||
connection the Router Manager makes the change on.
|
||||
|
||||
Consequences: HTTPS needs `wan.local_fqdn` in public DNS pointing at the router and port 80
|
||||
reachable from the internet; until then Caddy retries and the HTTPS ports do not answer. The
|
||||
certificate and the ACME account are in `/var/lib/caddy`. Plain HTTP stays as the `listen`
|
||||
certificate and the ACME account are in `/var/lib/caddy`. A router on the staging CA has a
|
||||
certificate no client trusts. Plain HTTP stays as the `listen`
|
||||
settings set it, so `internal` keeps passwords off the internet. Caddy's admin endpoint listens
|
||||
on `localhost:2019`, which `caddy reload` uses. Port 80 and the HTTPS ports are not available
|
||||
to Docker containers while HTTPS is on. The Manager's HTTPS port is a setting of the router, not
|
||||
@@ -655,8 +664,8 @@ one, so its name is the one the tunnel always had.
|
||||
- **Local settings per connection:** each connection has its own `local_cidrs` and
|
||||
`local_id_mode`, as each peer expects its own traffic selectors and identity. `wan.local_fqdn`
|
||||
stays the router's one name, used by the `fqdn` mode, resolved once for the `public_ip` mode,
|
||||
and used by HTTPS, road-warrior and OpenVPN. `local.cidrs` stays the subnets the router
|
||||
routes, masquerades and offers to road-warrior and OpenVPN clients.
|
||||
and used by HTTPS, IKEv2 and OpenVPN. `local.cidrs` stays the subnets the router
|
||||
routes, masquerades and offers to IKEv2 and OpenVPN clients.
|
||||
- **One file:** `vpn-router-setup` writes every connection that is ready to
|
||||
`/etc/swanctl/conf.d/site-to-site.conf`, with the proposals and timers the one tunnel had, and
|
||||
removes the file when none is. The NAT exemptions pair each connection's `local_cidrs` with its
|
||||
@@ -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
|
||||
SHA-1 MACs, the encryption the key stores of Windows and macOS import. strongSwan reads only the
|
||||
first certificate of a PEM file, so each CA has a file of its own in `/etc/swanctl/x509ca`;
|
||||
road-warrior and site-to-site name their CA in `cacerts`, which restricts the trust chain to it.
|
||||
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,
|
||||
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.
|
||||
@@ -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
|
||||
it is never root and each administrator's shell is the administrator's own; a user without
|
||||
console login gets none. `POST /shell` signs the user in again with the password
|
||||
and TOTP code and gives a one-time ticket valid 30 seconds; a WebSocket on `GET /shell` with that
|
||||
ticket gets the shell. Asking for the password and code at every opening of the page is tiresome,
|
||||
and TOTP code, or with one of the user's own passkeys (ADR-36), and gives a one-time ticket valid 30
|
||||
seconds; a WebSocket on `GET /shell` with that ticket gets the shell. A passkey with user
|
||||
verification is a possession and a PIN or biometric in one step and cannot be phished, so it is
|
||||
as good as the password and the code; `POST /shell/passkey` begins a proof bound to the signed-in
|
||||
user and its credentials, and python3-fido2 refuses an answer without the user-verified flag. The
|
||||
TOTP enrolment stays required with a passkey: the PAM login session of the shell, through the
|
||||
`pam_authz_search` of nslcd for a directory account, and sudo in it need it, and loosening that
|
||||
filter would let such a user log in over SSH with the password alone. Asking for the password and code at every opening of the page is tiresome,
|
||||
so within `web.shell_auth_timeout` minutes of the user's last sign-in or keystroke a ticket is given
|
||||
without them; the times are kept in the web service's memory only, so a restart asks again, and 0
|
||||
keeps the sign-in at every opening. The WebSocket of RFC 6455 is implemented on the standard library inside the
|
||||
@@ -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
|
||||
both handshakes.
|
||||
|
||||
Consequences: Who can sign in as a member of `admins` and knows the password and the current code
|
||||
can get a shell when the router allows one and the user's console login is on; a session alone
|
||||
cannot. The ticket travels in the URL and appears in request logs, used up. The shell's sessions
|
||||
Consequences: Who can sign in as a member of `admins` and knows the password and the current code,
|
||||
or holds one of the user's passkeys and unlocks it, can get a shell when the router allows one and
|
||||
the user's console login is on; a session alone cannot. A failed passkey proof is refused but not
|
||||
counted as a failed login, as with the passkey sign-in. The ticket travels in the URL and appears in request logs, used up. The shell's sessions
|
||||
show up as `vpn-router-shell-<id>` units and logind sessions of the console account. The shell's
|
||||
traffic is as protected as the Router Manager's: HTTPS to Caddy, plain HTTP on the internal
|
||||
network to the web service.
|
||||
@@ -1006,40 +1022,377 @@ 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.
|
||||
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
|
||||
|
||||
Context: Other systems, such as dashboards, should sign the router's users in with the directory's
|
||||
passwords, TOTP codes and passkeys, without a second user store. The Ubuntu archive has no
|
||||
packaged identity provider. `python3-oauthlib` and `python3-jwt` are in main for noble and
|
||||
resolute; oauthlib implements the OpenID Connect flows and leaves the checks, the storage and the
|
||||
ID token's signature to the application. `python3-authlib` and `python3-jwcrypto` are in universe
|
||||
only. The web service already checks passwords and passkeys, rate limits sign-ins and keeps
|
||||
tokens in PostgreSQL, and Caddy serves it over HTTPS on `wan.local_fqdn`. ADR-3's HS256 key must
|
||||
not leave the router, but a relying party verifies an ID token itself.
|
||||
Context:
|
||||
|
||||
Decision: The web service is the OpenID Provider, in `vpnrouter_oidc`, while `oidc.enabled` is
|
||||
true; its issuer is its HTTPS site, `https://<wan.local_fqdn>:<https.web_port>`, without the port
|
||||
on 443, and it answers the provider's paths only on `127.0.0.1`, where Caddy passes them. Only
|
||||
the authorization code flow with PKCE S256 and the refresh token grant are allowed. A client is a
|
||||
service account with redirect URIs, kept with the account in the new attributes
|
||||
`vpnRouterRedirectUri`, `vpnRouterPostLogoutRedirectUri` and `vpnRouterAllowedGroup` of
|
||||
`vpnRouterAccount`: its name is the client ID and its password the secret, checked with a bind.
|
||||
Allowed groups limit who may sign in; without them every user may. A registered client is
|
||||
trusted, so there is no consent page. The sign-in page is the web service's own, a Jinja2
|
||||
template with the password, the code and a passkey, so the provider works without the Router
|
||||
Manager. ID tokens are RS256, signed with an RSA key the web service makes on its first run
|
||||
(`/etc/vpn-router/oidc.key`), published as a JWK named by its RFC 7638 thumbprint; `sub` is the
|
||||
entry's `entryUUID`. Codes, access and refresh tokens and sessions are kept as their SHA-256 in
|
||||
the database of the refresh tokens, defined in `/usr/share/vpn-router/sql/oidc.sql` with
|
||||
procedures and functions the module calls; a process loads the file at its first connection. The
|
||||
operations that revoke the refresh tokens of `/auth` also revoke them, and every use of a code or
|
||||
token checks the user and the client's allowed groups again.
|
||||
- **What is needed:** other systems, such as dashboards, should sign the router's users in with the
|
||||
directory's passwords, TOTP codes and passkeys, without a second user store. Programs on hosts
|
||||
without a browser, and jobs without a user, need tokens as well. Services such as an S3 gateway
|
||||
should learn from a token what its holder may do there, as resources of Entra ID do with app
|
||||
roles and scopes.
|
||||
- **What the archive has:** no packaged identity provider. `python3-oauthlib` and `python3-jwt`
|
||||
are in main for noble and resolute; `python3-authlib` and `python3-jwcrypto` are in universe
|
||||
only.
|
||||
- **What oauthlib does:** it implements the OAuth 2.0 and OpenID Connect flows, including the
|
||||
client credentials grant, and leaves the checks, the storage and the signatures to the
|
||||
application. Noble's 3.2.2 has no device authorization grant (RFC 8628). Resolute's 3.3.1 has
|
||||
one, as has the current 4.0.0, but it checks no device code and never answers
|
||||
`authorization_pending` or `slow_down`.
|
||||
- **What the web service has:** it already checks passwords and passkeys, rate limits sign-ins and
|
||||
keeps tokens in PostgreSQL, and Caddy serves it over HTTPS on `wan.local_fqdn`. ADR-3's HS256
|
||||
key must not leave the router, but a relying party or a resource verifies a token itself.
|
||||
|
||||
Consequences: The package depends on `python3-oauthlib` and `python3-jwt`. The provider needs
|
||||
HTTPS, a public name and the web service. Clients are managed like service accounts, by the CLI,
|
||||
`/service-accounts` and the Router Manager; a group a client allows cannot be removed, and renaming
|
||||
it renames it in the clients. The signing key does not rotate; replacing it invalidates every ID
|
||||
token issued. Wrong client secrets lock the client like any account. The implicit and hybrid
|
||||
flows, dynamic client registration and public clients without a secret are not offered.
|
||||
Decision:
|
||||
|
||||
- **Where it runs:** the web service is the OpenID Provider, in `vpnrouter_oidc`, while
|
||||
`oidc.enabled` is true. Its issuer is its HTTPS site, `https://<wan.local_fqdn>:<https.web_port>`,
|
||||
without the port on 443, and it answers the provider's paths only on `127.0.0.1`, where Caddy
|
||||
passes them.
|
||||
- **Clients:**
|
||||
- A client is a service account with redirect URIs or grants, kept with the account in
|
||||
attributes of `vpnRouterAccount`: `vpnRouterRedirectUri`, `vpnRouterPostLogoutRedirectUri`,
|
||||
`vpnRouterAllowedGroup`, `vpnRouterGrantType`, `vpnRouterPublicClient` and
|
||||
`vpnRouterClientScope`.
|
||||
- Its name is the client ID and its password the secret, checked with a bind; a public client has
|
||||
none.
|
||||
- Allowed groups limit who may sign in; without them every user may.
|
||||
- **Grants:**
|
||||
- the authorization code with PKCE S256, which redirect URIs give;
|
||||
- `client_credentials`, for confidential clients only;
|
||||
- the device code;
|
||||
- the refresh token for the first and the last.
|
||||
|
||||
The device grant is the router's own grant class on oauthlib's token endpoint, with its
|
||||
authorization endpoint and pages, so it works the same with every oauthlib release. A loopback
|
||||
redirect URI matches with any port (RFC 8252).
|
||||
- **Consent:** a registered client is trusted, so there is no consent page, except for a device:
|
||||
the user who enters its code is asked to allow it, as RFC 8628 advises against remote phishing.
|
||||
- **The sign-in page:** the web service's own, a Jinja2 template with the password, the code and a
|
||||
passkey, so the provider works without the Router Manager.
|
||||
- **Resources, as Entra ID:**
|
||||
- Every service account is a resource. Its scopes are in `vpnRouterResourceScope`, and its roles
|
||||
are `vpnRouterRole` entries (ADR-19) whose members are groups and accounts.
|
||||
- A token request names its resource with `resource` (RFC 8707) or by the resource's scopes,
|
||||
`<resource>:<scope>`, which the client's scopes must allow.
|
||||
- Without a resource, the tokens are for the client.
|
||||
- The grant keeps the resource, so a code, a device code or a refresh token gives tokens for that
|
||||
resource only.
|
||||
- **Tokens:**
|
||||
- Both kinds are RS256 JWTs, signed with an RSA key the web service makes on its first run
|
||||
(`/etc/vpn-router/oidc.key`), published as a JWK named by its RFC 7638 thumbprint; `sub` is
|
||||
the entry's `entryUUID`.
|
||||
- An ID token is for the client and carries the user's roles of the client.
|
||||
- An access token follows RFC 9068: it is for the resource and carries the holder's roles there
|
||||
and the scopes granted, so a resource needs nothing from the router but the JWKS.
|
||||
- Roles are read at every issue.
|
||||
- **Storage:**
|
||||
- Codes, device codes, access and refresh tokens and sessions are kept as their SHA-256 in the
|
||||
database of the refresh tokens.
|
||||
- They are defined in `/usr/share/vpn-router/sql/oidc.sql`, with the procedures and functions
|
||||
the module calls; a process loads the file at its first connection.
|
||||
- The operations that revoke the refresh tokens of `/auth`, of users and of service accounts,
|
||||
also revoke them, and every use of a code, a device code or a token checks the user and the
|
||||
client's allowed groups again.
|
||||
|
||||
Consequences:
|
||||
|
||||
- **Requirements:** the package depends on `python3-oauthlib` and `python3-jwt`. The provider needs
|
||||
HTTPS, a public name and the web service.
|
||||
- **Management:** clients, resources and their roles are managed like service accounts, by the
|
||||
CLI, `vpn-router-role`, `/service-accounts` and the Router Manager.
|
||||
- **The directory keeps itself consistent:**
|
||||
- a group a client allows cannot be removed, and renaming it renames it in the clients and the
|
||||
roles;
|
||||
- a resource scope a client may ask for cannot be removed.
|
||||
- **Revocation:** an access token, a JWT, is valid at a resource until it expires, 5 minutes after
|
||||
issue, whatever the router revokes. The signing key does not rotate; replacing it invalidates
|
||||
every token issued.
|
||||
- **Lockout:** wrong client secrets lock the client like any account.
|
||||
- **Not offered:** the implicit and hybrid flows, the password grant and dynamic client
|
||||
registration.
|
||||
|
||||
## ADR-38: Bash completions are files per command in bash-completion's completions directory
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: The `vpn-*` commands have many subcommands, options and names (users, groups, issuing
|
||||
CAs, templates, settings) that are hard to remember. bash-completion's README recommends its
|
||||
`completionsdir`, `/usr/share/bash-completion/completions` on Ubuntu, whose files it loads on the
|
||||
first Tab of their command, and calls the eagerly loaded `/etc/bash_completion.d` not
|
||||
recommended; it prefers the name `<command>.bash`, which noble's 2.11 and resolute's 2.16 both
|
||||
look up.
|
||||
|
||||
Decision: The package installs `<command>.bash` for each `vpn-*` command into
|
||||
`/usr/share/bash-completion/completions` and recommends `bash-completion`. The completions read
|
||||
the names they offer from the commands themselves (`--json list`) or the package's Python
|
||||
modules (the schema, the configuration, the PKI's templates and reasons), not from copies, and
|
||||
send errors to `/dev/null`, so a user who may not read them gets the fixed words only.
|
||||
|
||||
Consequences: Completions follow the code without being edited for each new setting or name. A
|
||||
Tab that offers names runs a command or Python, which takes a fraction of a second. The files
|
||||
are not conffiles; an administrator overrides one in `/etc/bash_completion.d` or the user's own
|
||||
completions directory.
|
||||
|
||||
## ADR-39: /logs shows the last lines of listed units and files, capped per request
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: The Router Manager should show the router's logs without SSH. The web service runs as
|
||||
root, so a path or a unit taken from a request could expose any file or journal on the router,
|
||||
and a log of gigabytes cannot be sent whole to a browser.
|
||||
|
||||
Decision: `/logs` serves the journal of a fixed list of units, the package's own and the services
|
||||
it runs, or the whole journal, read with `journalctl`, and the files `web.log_files` lists,
|
||||
which `vpn-config-edit` sets; a unit or a path outside the lists is refused. A request answers
|
||||
at most 1000 lines, 200 by default, and a file is read from its last MiB only. The answer is
|
||||
read at each request; nothing is followed or kept.
|
||||
|
||||
Consequences: Only administrators read logs, and only those listed. Showing a new file needs a
|
||||
configuration change, not code. Long histories stay with SSH and `journalctl`.
|
||||
|
||||
## ADR-40: Static web sites are served by the router's Caddy and defined in their own PostgreSQL database
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: The router should host static web sites, each on its own port, with its own TLS and host
|
||||
names told apart by SNI. Caddy already runs as the router's HTTPS proxy, configured by setup from a
|
||||
template, and serves several hosts on one port by SNI. The configuration file holds the router's
|
||||
settings; sites are many rows that change often and are edited by people who are not the router's
|
||||
administrators. A separate role per database keeps a feature's data apart from the refresh tokens
|
||||
and metrics.
|
||||
|
||||
Decision: A site is a row of the `sites` table in the database `vpn-router-sites`, created by the
|
||||
web service like `vpn-router` with its own role and password file, and changed through
|
||||
`vpnrouter_sites` only, by `/sites`, `vpn-router-site` and the Router Manager; the table and its
|
||||
procedures are in `/usr/share/vpn-router/sql/sites.sql`. `sites.enabled` and the upload limit stay
|
||||
in the configuration file. Setup reads the enabled sites at each run and adds a block per site to
|
||||
the Caddyfile, served from `/var/lib/vpn-router/sites/<name>`, with TLS none, Let's Encrypt, an
|
||||
issuing CA of `pki.issuing_cas` through Caddy's internal issuer, or a certificate and key kept
|
||||
with the site; it opens each site's port on the interfaces of its `listen`. Caddy runs while HTTPS
|
||||
is ready or a site is served. A site may not take a port of the router's own services or
|
||||
`wan.local_fqdn`; sites sharing a port need host names, the same `listen` and the same kind of
|
||||
TLS. A site's `editors` group may change its files besides the admins; renaming the group renames
|
||||
it in the sites, and a group a site has cannot be removed.
|
||||
|
||||
Consequences: Sites need no change of the configuration file and are backed up as a database dump
|
||||
with their folders. A site of Let's Encrypt needs its names to resolve to the router and port 80
|
||||
open, which setup opens on the external interface. While the database cannot be read, setup leaves
|
||||
the Caddyfile as it is. A site's folder is kept when the site is removed, unless asked otherwise.
|
||||
|
||||
## ADR-41: Site files are uploaded through the web service and WebDAV served by rclone
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: Content must reach a site's folder. Caddy's WebDAV handler is a third-party module that
|
||||
neither Ubuntu's nor Caddy's packages include; a custom build would leave apt. S3 servers that use
|
||||
a plain folder are rclone 1.65 and later (`serve s3`; noble has 1.60) and versitygw, which has no
|
||||
package. Azurite keeps blobs in its own format, which Caddy cannot serve. rclone 1.60 in the
|
||||
archive serves WebDAV with an auth proxy program and combines folders into one tree. The web
|
||||
service and the Router Manager pass small JSON bodies only.
|
||||
|
||||
Decision: The web service serves a site's files under `/sites/<name>/files/`: listing, download,
|
||||
upload of a raw body streamed to disk up to `sites.upload_max_mb`, extraction of a zip archive
|
||||
that refuses absolute paths, `..`, links and oversize content before writing, folders, renaming
|
||||
and removal, every path resolved inside the site's folder. While `sites.webdav` is true,
|
||||
`vpn-router-webdav.service` runs `rclone serve webdav` on `127.0.0.1:7446` behind Caddy at
|
||||
`https://<wan.local_fqdn>:<sites.webdav_port>`; its auth proxy, `webdav-auth`, binds as the user
|
||||
and gives an admin all sites and any other user the sites of its editors groups, one folder each.
|
||||
rclone and PowerDNS are installed on demand from the archive by `vpn-router-install@<feature>`,
|
||||
like Caddy.
|
||||
|
||||
Consequences: Uploads and WebDAV use the directory's passwords, a TOTP code appended for an
|
||||
enrolled user. rclone writes a WebDAV upload to its cache first and to the folder a few seconds
|
||||
later. S3 and Azure Blob clients are not served.
|
||||
|
||||
## ADR-42: The name server is PowerDNS with its PostgreSQL backend
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: The router hosts authoritative domains as their primary, with a secondary name server, and
|
||||
a domain can give the sites their records without a registrar change per site. The router's own
|
||||
name, `wan.local_fqdn`, belongs to a domain of its own, wherever that is hosted. Ubuntu
|
||||
has BIND, Knot and PowerDNS; only PowerDNS's gpgsql backend reads records
|
||||
from PostgreSQL, so changes are rows, without zone files or reloads. systemd-resolved's stub holds
|
||||
127.0.0.53 and 127.0.0.54, and the remote access clients' resolver answered on the external
|
||||
addresses, the addresses an authoritative server needs. The clients reach the internal address
|
||||
just as well, through the tunnel when it is in `local.cidrs` (ADR-43).
|
||||
|
||||
Decision: While `dns.enabled` is true, setup runs `pdns.service` with
|
||||
`/etc/powerdns/pdns.d/vpn-router.conf`, written before the packages are installed, on the
|
||||
addresses of `dns.listen`, with the gpgsql backend on the database `vpn-router-dns` and PowerDNS's
|
||||
own schema, and `primary=yes`. The domains and their manual records are rows of the router's tables
|
||||
in that database, like the sites in `vpn-router-sites`, not settings of `vpn-router.json`: they are
|
||||
many and change through the web service. The name servers are settings, the same for every domain:
|
||||
`wan.local_fqdn`, the router's one name, is the primary, and `dns.secondary` the secondary. Setup
|
||||
writes each domain as a MASTER zone through the procedure `dns_apply` of
|
||||
`/usr/share/vpn-router/sql/dns.sql`: SOA with `wan.local_fqdn`, NS of `wan.local_fqdn` and
|
||||
`dns.secondary`, an A or AAAA record of every host name of a site with `dns` `router` whose closest
|
||||
domain it is, with the router's address the site chooses (`wan.public_address`, the internal or the
|
||||
external interface's), and the manual records; the serial grows only when the records change, which
|
||||
makes PowerDNS notify. `wan.local_fqdn` gets no record in the domains: its A record is in the DNS of
|
||||
its own domain, which the router does not manage. A site of `dns` `user` gets no records, as its
|
||||
names are served elsewhere. The addresses `dns.secondary` resolves to at each run go to every
|
||||
domain's `ALSO-NOTIFY` and `ALLOW-AXFR-FROM` metadata, so the global `allow-axfr-ips` stays the
|
||||
loopback default. The public address is a setting, since the router sees only private addresses on
|
||||
its interfaces behind a cloud's NAT and resolving `wan.local_fqdn` would answer from its own DNS
|
||||
when it hosts that name. `dns.nameserver` and `dns.addresses` of an earlier configuration are left
|
||||
out when it is read, so that it loads; a missing `dns.secondary` makes setup warn, not fail, so that
|
||||
a router keeps running until it is set. A host name belongs to its closest domain only, and a
|
||||
domain inside another is delegated from it with NS records, so a name is never answered by two
|
||||
zones. The remote access clients' resolver answers on the addresses of the setting
|
||||
`p2s.dns_listen`, by default the internal one; a `dns.listen` that shares an interface with it is
|
||||
refused while P2S or OpenVPN is enabled.
|
||||
|
||||
Consequences: The domains follow the sites at each setup run. Transfers are authorized by the
|
||||
address the secondary's name resolves to, without TSIG, and there is no DNSSEC; the delegation of
|
||||
each domain, with glue for the name servers inside it, is made at its parent zone by hand. Every
|
||||
domain has the same two name servers.
|
||||
|
||||
## ADR-43: The remote access clients' resolver is dnsmasq, not systemd-resolved
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: IKEv2 and OpenVPN clients were given the router as their DNS server, answered by
|
||||
systemd-resolved through a `DNSStubListenerExtra` drop-in. resolved synthesizes records for the
|
||||
machine's own host name, all its addresses, the Docker bridge and the tunnel addresses included,
|
||||
and for `_gateway` and `localhost`, on every stub, and systemd 255 has no setting that turns this
|
||||
off. Its extra stub also took port 53 of the addresses PowerDNS needs. dnsmasq-base is in main,
|
||||
a single binary without a service of its own, and forwards to one upstream server.
|
||||
|
||||
Decision: `vpn-router-resolver.service` runs dnsmasq, bound to the addresses of `p2s.dns_listen`
|
||||
only (`bind-interfaces`), forwarding to resolved's stub on 127.0.0.53, without `/etc/hosts` and
|
||||
without forwarding names that have no dot (`domain-needed`). resolved keeps its loopback stubs
|
||||
only and stays the router's own resolver, with the upstream servers of the platform. Setup writes
|
||||
`/etc/vpn-router/dnsmasq.conf` while IKEv2, OpenVPN or WireGuard runs, opens port 53 there for
|
||||
their client networks, and removes the earlier drop-in.
|
||||
|
||||
Consequences: Clients resolve the names resolved resolves, Azure's internal names included, but
|
||||
not the router's synthesized ones. The package depends on dnsmasq-base. PowerDNS and the clients'
|
||||
resolver share no address.
|
||||
|
||||
## ADR-44: Apple VPN profiles are unsigned IKEv2 profiles with a PKCS#12 encrypted by a password given per download
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: macOS, iOS and iPadOS connect to the IKEv2 access with their built-in IKEv2 client,
|
||||
configured by a configuration profile. The router keeps each user's certificate and key. Apple's
|
||||
clients read a PKCS#12 only when it is encrypted with 3DES and SHA-1, send one proposal per SA,
|
||||
and show an unsigned profile as unverified.
|
||||
|
||||
Decision: The web service builds the profile with `plistlib` and python3-cryptography: the P2S CA
|
||||
and the root, the user's PKCS#12 encrypted with a password the caller gives with each request,
|
||||
and an IKEv2 payload matching `road-warrior.conf` (EAP-TLS with the certificate, AES-256,
|
||||
SHA2-256, group 14). The profile is not signed. Admins get any user's profile through `/users`,
|
||||
users their own through `/account` with their current password and code.
|
||||
|
||||
Consequences: The password is never stored; the device asks for it when the profile is installed.
|
||||
A profile shows as unverified. A changed proposal of `road-warrior.conf` must be changed in the
|
||||
profile too. WireGuard is not part of it.
|
||||
|
||||
## ADR-45: IKEv2 clients get split DNS domains, p2s.dns_domains
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: The IKEv2 access is a split tunnel. macOS and iOS use the DNS server an IKEv2 VPN
|
||||
pushes only for queries scoped to the tunnel, so names only the router resolves, such as Azure's
|
||||
internal names, fail. RFC 8598 defines INTERNAL_DNS_DOMAIN (attribute 25): the client resolves the
|
||||
listed domains and their subdomains with the VPN's servers. strongSwan's pools take numeric
|
||||
attributes with string values; Apple profiles name such domains in `SupplementalMatchDomains`.
|
||||
|
||||
Decision: The setting `p2s.dns_domains` lists the domains; setup pushes each as attribute 25 of the
|
||||
IKEv2 address pool, and the Apple VPN profile gives them as match domains of the router's resolver,
|
||||
the addresses setup records for the web service.
|
||||
|
||||
Consequences: Clients resolve the listed domains through the tunnel and everything else with their
|
||||
own resolvers. Clients that ignore attribute 25 keep the scoped behaviour; the Apple profile
|
||||
covers Apple devices. A changed list reaches an Apple device with a newly installed profile.
|
||||
|
||||
## ADR-46: vre opens files in the Shell page's editor over a socket checked by the peer's uid
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: Administrators edit the router's files from the Router Manager's Shell page, in an editor
|
||||
of the page rather than a terminal editor. The command that names a file runs in the shell, as the
|
||||
console account. The web service runs as root, sandboxed with `ProtectHome=yes`, so it cannot see
|
||||
the accounts' home folders. Reading and writing as root would also bypass the account's
|
||||
permissions.
|
||||
|
||||
Decision: Each shell has a random session in `VPN_ROUTER_SHELL`. `vpn-router-editor` (`vre`) sends
|
||||
the session and an absolute path to the web service on `/run/vpn-router/editor.sock`, which anyone
|
||||
may connect to. The web service accepts a request only when the peer's uid (`SO_PEERCRED`) is the
|
||||
uid of the session's console account. It then tells the shell's client over the shell's WebSocket,
|
||||
and the page reads and saves the file through `/shell/editor/<session>/<id>`. Files are read and
|
||||
written by `editor-file`, run with `systemd-run --uid=<account> --pipe` outside the sandbox, as the
|
||||
shell is. A save carries the SHA-256 of the content it replaces and is refused when the file
|
||||
changed since. Files open only while their shell lives.
|
||||
|
||||
Consequences: The editor edits only what the console account may; a file only root may write is
|
||||
edited in the terminal. `vre` run under sudo is refused, as its uid is not the console account's.
|
||||
Each read and save starts a transient unit. Files are limited to 1 MiB of UTF-8 text.
|
||||
|
||||
## ADR-47: The web service is the package vpnrouter_web, with one module per family of paths
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: The web service answers about twenty families of paths: the status, the sign-in, the
|
||||
identities, the connections, backups, the PKI, logs, web sites, DNS, metrics and the shell with
|
||||
its editor. Kept in one script, the code of unrelated paths is read and changed together.
|
||||
|
||||
Decision: `/usr/lib/vpn-router/web` only initializes and serves. The package
|
||||
`/usr/lib/vpn-router/vpnrouter_web/` holds the rest, laid out next to `vpnrouter_platforms/`:
|
||||
|
||||
- `handler`: the `Handler` class, which reads a request, checks its token and routes it.
|
||||
- One module per family of paths (`status`, `auth`, `identities`, `connectivity`, `backup`, `pki`,
|
||||
`logs`, `sites`, `dns`, `metrics`, `shell`), each with its constants and state.
|
||||
- `common`: what several of them need.
|
||||
|
||||
A module imports from `common` and from no other route module, except `auth`, which takes the
|
||||
paths of a site's files from `sites`. `handler` imports them all, so the imports have no cycle. A
|
||||
function that needs the request takes the `Handler` as its first parameter instead of being a
|
||||
method.
|
||||
|
||||
Consequences: A change to one family of paths touches one module and the routing in `handler`.
|
||||
State shared between requests, such as the open shells, lives in the module that owns it; other
|
||||
modules change it and never rebind it.
|
||||
|
||||
## ADR-48: Exceptions are handled; no traceback reaches the logs
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: The services log to the journal, which administrators read through the Logs page and
|
||||
`journalctl`. Python prints a traceback for an exception nothing handles: `socketserver` for a
|
||||
failed request, `threading` for a failed thread and the interpreter for the process. A client
|
||||
that closes its connection before the answer is written, as a page does when it is left or
|
||||
reloaded, raises `BrokenPipeError` or `ConnectionResetError` in the handler. A traceback is many
|
||||
lines of internals for one event and hides the lines around it.
|
||||
|
||||
Decision: The code handles the exceptions it can expect where they occur, and answers or logs
|
||||
them with their reason in one line. An exception left unhandled is a defect to be fixed. As the
|
||||
last line of defence, every server replaces the default reports:
|
||||
|
||||
- `vpnrouter_serve.serve`, which the web service and the metrics service run, sets the servers'
|
||||
`handle_error`, `threading.excepthook` and `sys.excepthook`.
|
||||
- The bootstrap server sets its server's `handle_error` and `sys.excepthook`.
|
||||
|
||||
A `ConnectionError` of a client that went away is not logged; any other exception is logged in
|
||||
one line with its type and message.
|
||||
|
||||
Consequences: The logs hold one line per failure, without its stack. A failure that reaches the
|
||||
last line of defence is found by its message and fixed where it occurs, with its own handling.
|
||||
|
||||
## ADR-49: A changelog summary is a paragraph wrapped at 80 columns
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: The summary of an entry of `debian/changelog` is the commit message of its release,
|
||||
which `release.py` reads from the changelog. Summaries are often longer than the 80 columns
|
||||
Debian's tools expect of a changelog line.
|
||||
|
||||
Decision: The summary is the entry's first paragraph, indented by two spaces and wrapped at 80
|
||||
columns. `release.py` joins its lines with single spaces into the commit message.
|
||||
|
||||
Consequences: The commit message is one line however the summary is wrapped, so a summary
|
||||
rewrapped later still matches the commit of its release.
|
||||
@@ -3,17 +3,19 @@
|
||||
A Debian package that configures a Linux host or virtual machine as a VPN router:
|
||||
|
||||
- site-to-site IKEv2 IPSec with pre-shared key or certificate authentication (strongSwan swanctl)
|
||||
- road-warrior (P2S) access using IKEv2 EAP-TLS
|
||||
- IKEv2 access using EAP-TLS
|
||||
- a two-level certificate authority: a root, which may be kept offline, and issuing CAs for the VPN
|
||||
clients, for site-to-site and for general use, with certificate templates, published certificates
|
||||
and CRLs, and an optional ACME server
|
||||
- OpenVPN (P2S) access with a certificate and a password per user, the users kept in a local
|
||||
- OpenVPN access with a certificate and a password per user, the users kept in a local
|
||||
LDAP directory and managed with `vpn-router-user` or the web service
|
||||
- optional WireGuard endpoint
|
||||
- routing and NAT for a protected subnet, TCP MSS clamping, and UFW firewall rules
|
||||
- firewall rules for the ports Docker publishes, when Docker is installed
|
||||
- metrics of the router kept in PostgreSQL, served for an OpenTelemetry Collector and pushed
|
||||
over OTLP
|
||||
- static web sites served by Caddy, uploaded through the web service or WebDAV, and an optional
|
||||
primary name server (PowerDNS) of the router's domains, with a secondary and records for the sites
|
||||
|
||||
Nothing in the package is specific to a cloud provider. It is built on the external and
|
||||
internal NIC names, and a `mode` setting says how much of the rest you are supplying and
|
||||
@@ -106,13 +108,16 @@ must have the type shown below; an unknown section or setting is an error.
|
||||
| `general` | `mode` | string | `manual` | How much you supply: `manual`, `interfaces` or `auto` |
|
||||
| `interfaces` | `external` | string | | Name of the NIC facing the untrusted network |
|
||||
| `interfaces` | `internal` | string | | Name of the NIC facing the protected network |
|
||||
| `wan` | `local_fqdn` | string | | This router's name or IP address: its IKE identity in `fqdn` mode, the name resolved in `public_ip` mode, and the name of HTTPS, road-warrior and OpenVPN |
|
||||
| `local` | `cidrs` | list | | Local subnets routed via the internal side, masqueraded to the external one, and offered to road-warrior and OpenVPN clients |
|
||||
| `wan` | `local_fqdn` | string | | This router's name or IP address: its IKE identity in `fqdn` mode, the name resolved in `public_ip` mode, the name of HTTPS, IKEv2 and OpenVPN, and the primary name server of its domains |
|
||||
| `wan` | `public_address` | string | | The router's public IP address, which its own interfaces do not show behind a cloud's NAT: the address of the records of the sites with `dns_address` `public` |
|
||||
| `local` | `cidrs` | list | | Local subnets routed via the internal side, masqueraded to the external one, and offered to IKEv2 and OpenVPN clients |
|
||||
| `local` | `int_addr` | string | | This host's address on the internal network |
|
||||
| `local` | `int_gateway_ip` | string | | Next hop on the internal side |
|
||||
| `site_to_site` | | object | | The site-to-site connections, each a name and an object of the settings below |
|
||||
| `p2s` | `enabled` | boolean | `false` | Road-warrior access |
|
||||
| `p2s` | `address_pool` | string | | CIDR assigned to road-warrior clients |
|
||||
| `p2s` | `enabled` | boolean | `false` | IKEv2 access |
|
||||
| `p2s` | `address_pool` | string | | CIDR assigned to IKEv2 clients |
|
||||
| `p2s` | `dns_domains` | list | | The domains IKEv2 clients resolve with the router's resolver (INTERNAL_DNS_DOMAIN, RFC 8598, and the match domains of the Apple VPN profile), at most 16 |
|
||||
| `p2s` | `dns_listen` | string | `internal` | The addresses of the resolver the IKEv2, OpenVPN and WireGuard clients are given: `internal`, `external` or `both` |
|
||||
| `wireguard` | `enabled` | boolean | `false` | WireGuard endpoint |
|
||||
| `wireguard` | `address` | string | | The router's own address on `wg0` with the prefix of the peers' network, for example `192.168.200.1/24`; the network and the broadcast address of a network larger than /31 are refused |
|
||||
| `wireguard` | `listen_port` | integer | `51820` | UDP port |
|
||||
@@ -130,14 +135,24 @@ must have the type shown below; an unknown section or setting is an error.
|
||||
| `web` | `status_content` | string | `basic` | What `/` and `/status` show: `basic`, `standard` or `full` (see Status web service) |
|
||||
| `web` | `shell` | boolean | `false` | `/shell` opens a login shell for a member of `admins` with console login (see The shell) |
|
||||
| `web` | `shell_idle_minutes` | integer | `15` | Minutes without a keystroke after which a shell is closed, 1 to 1440 |
|
||||
| `web` | `log_files` | list | | Absolute paths of the log files `/logs` shows, at most 32 (see The /logs API) |
|
||||
| `web` | `shell_auth_timeout` | integer | `15` | Minutes after the last use of a shell in which a new shell opens without signing in again, 0 (always sign in) to 1440 |
|
||||
| `ssh` | `ldap_login` | boolean | `false` | sshd accepts the directory password of the console accounts in the directory (see Console login and SSH) |
|
||||
| `https` | `enabled` | boolean | `true` | Caddy serves the status web service and the Router Manager over HTTPS for `wan.local_fqdn` |
|
||||
| `https` | `email` | string | | The e-mail address of the Let's Encrypt account |
|
||||
| `https` | `web_port` | integer | `7444` | HTTPS port of the status web service |
|
||||
| `https` | `manager_port` | integer | `7443` | HTTPS port of the Router Manager |
|
||||
| `https` | `acme_staging` | boolean | `false` | Caddy takes its certificates from Let's Encrypt's staging environment alone, whose certificates no browser trusts and whose rate limits are higher, for routers rebuilt often (see HTTPS) |
|
||||
| `https` | `caddy_source` | string | `ubuntu` | Where Caddy is installed from: `ubuntu`, the Ubuntu archive, or `upstream`, Caddy's own repository (see HTTPS) |
|
||||
| `oidc` | `enabled` | boolean | `false` | The OpenID Provider at the status web service's HTTPS site, which needs `web.enabled`, `https.enabled` and `wan.local_fqdn` (see OpenID Provider) |
|
||||
| `sites` | `enabled` | boolean | `false` | Caddy serves the web sites (see Web sites) |
|
||||
| `sites` | `upload_max_mb` | integer | `512` | The largest upload of a site's file or zip archive, in MiB, 1 to 10240 |
|
||||
| `sites` | `webdav` | boolean | `false` | WebDAV of the sites' folders, which needs `https.enabled` and `wan.local_fqdn` (see WebDAV) |
|
||||
| `sites` | `webdav_port` | integer | `7445` | HTTPS port of WebDAV |
|
||||
| `dns` | `enabled` | boolean | `false` | The primary name server of the router's domains, `wan.local_fqdn`, which must then be a DNS name (see Name server) |
|
||||
| `dns` | `secondary` | string | | The secondary name server of every domain, a host name other than `wan.local_fqdn`; it may transfer the domains |
|
||||
| `dns` | `listen` | string | `external` | The interfaces the name server answers on: `internal`, `external` or `both` |
|
||||
| `dns` | `ttl` | integer | `300` | TTL of the records, 60 to 86400 seconds |
|
||||
| `pki` | `root_name` | string | `VPN Router Root CA` | Common name of the root CA, when the router creates it |
|
||||
| `pki` | `root_days` | integer | `7300` | Days the root CA's certificate is valid |
|
||||
| `pki` | `ca_days` | integer | `3650` | Days an issuing CA's certificate is valid |
|
||||
@@ -237,9 +252,9 @@ A minimal site-to-site configuration in `auto` mode:
|
||||
The router's IKE identity (`wan.local_fqdn` in `fqdn` mode) and the remote one (`id`) are
|
||||
an FQDN or an IPv4 or IPv6 address. They are written to the strongSwan configuration as given;
|
||||
strongSwan reads an address as an IP identity and a name as an FQDN, so a leading `@` is not needed
|
||||
and is refused. Road-warrior and OpenVPN access need a name in `wan.local_fqdn`.
|
||||
and is refused. IKEv2 and OpenVPN access need a name in `wan.local_fqdn`.
|
||||
|
||||
WireGuard peers are P2S users' devices: a VPN user of the router's directory may have up to 16
|
||||
WireGuard peers are VPN users' devices: a VPN user of the router's directory may have up to 16
|
||||
peers, each with its public key and its IPs, IPv4 addresses or networks, kept in the user's
|
||||
entry (`vpnRouterWireGuardPeer`) and set with `PUT /users/<name>` (`wireguard_peers`) or on the
|
||||
WireGuard tab of the Router Manager's Remote access management. A key is used by one peer only,
|
||||
@@ -343,6 +358,7 @@ vpn-config-edit set web.port 7082
|
||||
vpn-config-edit set web.trusted_proxies 127.0.0.1,10.0.0.4
|
||||
vpn-config-edit set site_to_site.branch.addrs branch.example.net
|
||||
vpn-config-edit remove site_to_site.branch
|
||||
vpn-config-edit set --restart oidc.enabled true
|
||||
```
|
||||
|
||||
`show` prints the file as indented JSON and does not change it. `set` takes a setting as
|
||||
@@ -351,10 +367,29 @@ creates with the defaults when it does not exist, and converts the value to the
|
||||
a whole number, `true` or `false`, a list separated by commas (an empty value is an empty list),
|
||||
or a string; an unknown setting is refused. `remove site_to_site.NAME` removes a connection. It
|
||||
checks the whole configuration as `vpn-router-setup` does, and only then writes the file,
|
||||
indented, with mode 0600, and reminds that `vpn-router-setup` applies it. The status web service
|
||||
indented, with mode 0600, and reminds that `vpn-router-setup` applies it; with `--restart`, `set`
|
||||
and `remove` restart `vpn-router-setup` and wait until it has applied the change, and exit with
|
||||
status 1 when it fails. The status web service
|
||||
writes the file the same way, and a lock keeps the two from overwriting each other's change. A
|
||||
refused request exits with status 2, any other failure with 1.
|
||||
|
||||
## Bash completion
|
||||
|
||||
Bash completes the commands, options and arguments of `vpn-router-user`, `vpn-router-group`,
|
||||
`vpn-router-role`, `vpn-router-token`, `vpn-config-edit`, `vpn-router-backup`, `vpn-router-ca`, `vpn-router-caddy`
|
||||
and `vpn-router-site` with the Tab key. The package installs a file per command in
|
||||
`/usr/share/bash-completion/completions`, which the `bash-completion` package, recommended by
|
||||
`vpn-router`, loads on the first Tab after the command. For root, who can read them, the
|
||||
completions also offer the users and groups of the directory, the settings and site-to-site
|
||||
connections of `vpn-router.json` with `true` and `false` for a boolean setting, the issuing CAs and
|
||||
certificate templates, and the web sites; for other users only the fixed words.
|
||||
|
||||
```sh
|
||||
sudo -i
|
||||
vpn-config-edit set oidc.<Tab>
|
||||
vpn-router-user passwd <Tab>
|
||||
```
|
||||
|
||||
## Applying changes
|
||||
|
||||
```sh
|
||||
@@ -364,11 +399,11 @@ systemctl restart vpn-router-setup
|
||||
The service also runs at boot. With platform `none` it does nothing and says so.
|
||||
Otherwise it regenerates the configuration files, resolves the interfaces and their
|
||||
addresses as `mode` directs, applies routes, generates WireGuard keys and injects
|
||||
firewall rules. It provisions the user directory in `slapd` while OpenVPN or road-warrior
|
||||
firewall rules. It provisions the user directory in `slapd` while OpenVPN or IKEv2
|
||||
access is configured. It then starts `strongswan`, `wg-quick@wg0` and
|
||||
`openvpn-server@vpn-router` if the corresponding feature is configured, reloading or
|
||||
restarting them only when their inputs changed, and
|
||||
restarts `systemd-resolved` only when the road-warrior DNS drop-in changed. Turning a
|
||||
restarts the remote access clients' resolver only when its configuration changed. Turning a
|
||||
feature off in the configuration stops its service and removes its files. Every step is
|
||||
idempotent, so restarting with no changes does nothing.
|
||||
|
||||
@@ -415,7 +450,7 @@ The root signs the issuing CAs only (path length 1), and the issuing CAs sign th
|
||||
(path length 0):
|
||||
|
||||
- `p2s`, named `pki.p2s_name`, signs the router's VPN server certificate, which IKEv2 and OpenVPN
|
||||
present, and the users' certificates. Road-warrior and OpenVPN accept client certificates of
|
||||
present, and the users' certificates. IKEv2 and OpenVPN accept client certificates of
|
||||
this CA only.
|
||||
- `s2s`, named `pki.s2s_name`, signs the router's site-to-site certificate and the certificates
|
||||
of its peers.
|
||||
@@ -601,8 +636,8 @@ certificate and key from `/etc/caddy/vpn-router-pki/<name>/`, readable by its gr
|
||||
With `openvpn.enabled`, an `address_pool` and `wan.local_fqdn` set, `vpn-router-setup` runs
|
||||
`openvpn-server@vpn-router` with `/etc/openvpn/server/vpn-router.conf`. The server uses the
|
||||
router's VPN server certificate, the root and the P2S CA and their CRLs (see Certificates),
|
||||
pushes routes to `local.cidrs` and the router as DNS server, and opens `openvpn.port` on the
|
||||
external interface.
|
||||
pushes routes to `local.cidrs` and the router's addresses of `p2s.dns_listen` as DNS servers, and
|
||||
opens `openvpn.port` on the external interface.
|
||||
|
||||
A client needs both a certificate of the P2S CA and the password of the user the
|
||||
certificate names:
|
||||
@@ -618,7 +653,7 @@ certificate names:
|
||||
### The user directory
|
||||
|
||||
Once a platform is chosen, `vpn-router-setup` keeps a directory of users in the local slapd,
|
||||
whether or not OpenVPN or road-warrior access is enabled, so users can be managed before either
|
||||
whether or not OpenVPN or IKEv2 access is enabled, so users can be managed before either
|
||||
is switched on:
|
||||
|
||||
- its own database, `dc=vpn-router,dc=local` in `/var/lib/ldap/vpn-router`, next to the one the
|
||||
@@ -668,8 +703,9 @@ it is added:
|
||||
- a service account, a Manager-only account whose `vpnRouterScope` is `service`, which another
|
||||
system signs in with to use a router service, such as an OpenTelemetry Collector reading
|
||||
`/metrics`; it cannot sign in to `/auth`. Besides its name, it has a display name and a
|
||||
description, and a service account with redirect URIs is a client of the OpenID Provider (see
|
||||
OpenID Provider). `add --service` adds one, and the web service manages them at `/service-accounts`,
|
||||
description, a service account with redirect URIs or grants is a client of the OpenID Provider,
|
||||
and every one is a resource of it, with its scopes and roles (see OpenID Provider). `add
|
||||
--service` adds one, and the web service manages them at `/service-accounts`,
|
||||
not at `/users`. Internal service accounts are those the router creates and manages for its own
|
||||
services, display name and description included; at present that is the metrics service's, the
|
||||
account `metrics.service_account` names while metrics are enabled (see Metrics), which
|
||||
@@ -707,9 +743,9 @@ vpn-router-user otp-remove alice
|
||||
| `list` | The users with their kind, names and e-mail addresses, whether each is enabled or locked, has a certificate and is enrolled for TOTP, the number of passkeys and the console login |
|
||||
| `add <name> --first-name F --last-name L --email E` | Adds a VPN user and issues the certificate |
|
||||
| `add <name> [--email E]` | Adds a Manager-only account, with the e-mail address when given; one name without the other two options is refused |
|
||||
| `add <name> --service [--display-name N] [--description D] [--email E] [--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...]` | Adds a service account, with its display name, description, e-mail address and OpenID Provider client settings when given; the names are refused |
|
||||
| `add <name> --service [--display-name N] [--description D] [--email E] [--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...] [--grant G ...] [--public-client yes\|no] [--client-scope R:S ...] [--resource-scope S ...]` | Adds a service account, with its display name, description, e-mail address and OpenID Provider settings when given; the names are refused |
|
||||
| `update <name> [--first-name F] [--last-name L] [--email E]` | Changes the names or the e-mail address |
|
||||
| `update <name> [--display-name N] [--description D] [--email E] [--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...]` | Changes the display name, the description, the e-mail address or the OpenID Provider client settings of a service account; `''` removes one; a repeated client option gives all values of its kind |
|
||||
| `update <name> [--display-name N] [--description D] [--email E] [--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...] [--grant G ...] [--public-client yes\|no] [--client-scope R:S ...] [--resource-scope S ...]` | Changes the display name, the description, the e-mail address or the OpenID Provider settings of a service account; `''` removes one; a repeated option gives all values of its kind |
|
||||
| `passwd <name>` | Sets the password |
|
||||
| `disable <name>`, `enable <name>` | Locks the user out of OpenVPN and `/auth`, and lets the user in again |
|
||||
| `unlock <name>` | Ends a lockout after failed logins and clears the failure count; a disabled user stays disabled |
|
||||
@@ -739,9 +775,29 @@ When the Router Manager is installed with an administrator (its debconf question
|
||||
Manager-only account, adds it to the group `admins`, which it creates when missing, and removes
|
||||
the file.
|
||||
|
||||
The certificate is also accepted by road-warrior (IKEv2 EAP-TLS) access, which checks
|
||||
The certificate is also accepted by IKEv2 (EAP-TLS) access, which checks
|
||||
certificates only, so `disable` does not affect it; `remove` revokes the certificate for both.
|
||||
|
||||
### Apple VPN profile
|
||||
|
||||
While `p2s.enabled` is `true` and `wan.local_fqdn` is set, `/users/<name>/apple-profile` and a
|
||||
user's own `/account/apple-profile` give an unsigned Apple configuration profile (`.mobileconfig`)
|
||||
that sets up IKEv2 access in the built-in VPN client of macOS, iOS and iPadOS. It holds:
|
||||
|
||||
- the P2S CA and the root CA, the root installed as a trusted root;
|
||||
- the user's certificate and key as a PKCS#12 encrypted with the password of the request, with
|
||||
3DES and SHA-1, the only encryption Apple's clients read; the device asks for the password when
|
||||
the profile is installed, and the router does not keep it;
|
||||
- an IKEv2 VPN to `wan.local_fqdn`, with the user name as the local identity, the certificate over
|
||||
EAP-TLS, and AES-256, SHA2-256 and group 14 for IKE and ESP, as the router's IKEv2
|
||||
connection (`road-warrior`) takes them;
|
||||
- with `p2s.dns_domains`, the router's resolver for those domains and their subdomains
|
||||
(`SupplementalMatchDomains`). macOS and iOS use the DNS server of a VPN that does not carry all
|
||||
traffic only for its match domains.
|
||||
|
||||
The password is 8 to 128 characters. A user asking for the own profile also gives the current
|
||||
password and, when enrolled, a code.
|
||||
|
||||
### Console login and SSH
|
||||
|
||||
A member of `admins` enrolled for TOTP may have console login: the Router Manager's Shell page
|
||||
@@ -801,9 +857,10 @@ A disabled or locked user is refused a passkey sign-in too.
|
||||
`vpn-router-group` manages the groups. A group has a name, with the rules of a user name, an
|
||||
optional description of at most 256 printable characters, and at least one member, each a user
|
||||
of the directory. The group `admins` grants the management of the router, and the allowed groups
|
||||
of a client of the OpenID Provider admit their members to it; other groups record membership
|
||||
only. Renaming a group renames it in the clients that allow it, and a group a client allows
|
||||
cannot be removed. The output, `--json`, `-h` and `--help` work as for `vpn-router-user`.
|
||||
of a client of the OpenID Provider admit their members to it, and a role of a resource assigned to
|
||||
a group is a role of its members; other groups record membership only. Renaming a group renames it
|
||||
in the clients that allow it and in its roles, a group a client allows cannot be removed, and
|
||||
removing a group takes it out of its roles. The output, `--json`, `-h` and `--help` work as for `vpn-router-user`.
|
||||
|
||||
```sh
|
||||
vpn-router-group add ops --member alice --member bob --description 'Operations team'
|
||||
@@ -838,16 +895,23 @@ deploying.
|
||||
which is how a deferred install is expressed. `generic` is the module to choose when the
|
||||
router should be configured but the platform adds nothing.
|
||||
|
||||
The shipped modules currently add nothing. Road-warrior clients are given the router
|
||||
itself as their DNS server and the router forwards through its own `systemd-resolved`, so
|
||||
no provider resolver has to be advertised or routed through the tunnel on any platform.
|
||||
The shipped modules currently add nothing. IKEv2, OpenVPN and WireGuard clients are given the
|
||||
router's addresses of `p2s.dns_listen` as their DNS servers, by default the internal address,
|
||||
the WireGuard clients in the configuration the Router Manager makes for a device (`DNS =`),
|
||||
where `vpn-router-resolver.service`, dnsmasq with `/etc/vpn-router/dnsmasq.conf`, answers and
|
||||
the firewall opens port 53 for the client pools and the WireGuard network, so no provider resolver has to be advertised or
|
||||
routed through the tunnel on any platform. dnsmasq forwards to `systemd-resolved` on
|
||||
127.0.0.53, which keeps only its loopback stubs, and forwards no name without a dot, so the
|
||||
router's own host name, `_gateway` and `localhost` are not answered to the clients. The clients reach
|
||||
the internal address when it is inside `local.cidrs`, which they route through the tunnel. Port 53
|
||||
of the other addresses stays free for the name server (see Name server).
|
||||
The modules exist as the documented place for platform specifics if that changes.
|
||||
|
||||
A module may define any of the following, and takes the default for anything it omits:
|
||||
|
||||
| Name | Type | Effect |
|
||||
|---|---|---|
|
||||
| `EXTRA_P2S_DNS` | `str` | added to the road-warrior pool `dns` list |
|
||||
| `EXTRA_P2S_DNS` | `str` | added to the `dns` list of the IKEv2 address pool |
|
||||
| `EXTRA_LOCAL_TS` | `str` | added to `local_ts` on both connections |
|
||||
| `apply(ctx)` | function | called once the system is configured, to apply platform-specific state; must be idempotent |
|
||||
|
||||
@@ -880,7 +944,7 @@ configuration: no key, identity, subnet, or address but the client's. They show
|
||||
|---|---|
|
||||
| `basic` (default) | The overall status (`ok` or `degraded`), the time and the Client IP |
|
||||
| `standard` | Also the `vpn-router` version, the `vpn-router-manager` version or that it is not installed, and the number of active services and of services in any other state, without their names |
|
||||
| `full` | Also each service and its state, Docker, how many site-to-site connections are set up and how many of them are established (`site_to_site`: `connections` and `established`, `null` when swanctl cannot be asked), road-warrior, OpenVPN and WireGuard |
|
||||
| `full` | Also each service and its state, Docker, how many site-to-site connections are set up and how many of them are established (`site_to_site`: `connections` and `established`, `null` when swanctl cannot be asked), IKEv2, OpenVPN and WireGuard |
|
||||
|
||||
The Client IP is the address the request came from: the last entry of `X-Forwarded-For` for a
|
||||
request from one of `web.trusted_proxies`, by default the address the request came in on, so
|
||||
@@ -891,6 +955,14 @@ The service first makes its signing key, the OpenID Provider's key and the refre
|
||||
database, retrying every 15 seconds, for example while PostgreSQL is not running yet, and logs
|
||||
each failure; then it listens.
|
||||
|
||||
The service is `/usr/lib/vpn-router/web`, which starts it, and the package
|
||||
`/usr/lib/vpn-router/vpnrouter_web/`, which answers the requests:
|
||||
|
||||
- `handler`: reads each request, checks its token, and passes it to the module of its path.
|
||||
- `common`: what the modules share.
|
||||
- One module per family of paths: `status`, `auth`, `identities`, `connectivity`, `backup`,
|
||||
`pki`, `logs`, `sites`, `dns`, `metrics` and `shell`.
|
||||
|
||||
### Bootstrap diagnostics
|
||||
|
||||
Until a run of `vpn-router-setup` has succeeded, every run starts `vpn-router-bootstrap`, which
|
||||
@@ -915,24 +987,27 @@ curl http://10.0.0.4:17081/
|
||||
| `/status` | The same status as JSON |
|
||||
| `/auth` | Signing in with a user's password and TOTP code, refreshing and signing out |
|
||||
| `/config` | The configuration without its secret options, and what the router resolved and runs, as JSON; needs a token |
|
||||
| `/users` | The users of OpenVPN and road-warrior access; needs a token |
|
||||
| `/users` | The users of OpenVPN and IKEv2 access; needs a token |
|
||||
| `/service-accounts` | The service accounts; needs a token |
|
||||
| `/groups` | The groups of users; needs a token |
|
||||
| `/site-to-site` | The site-to-site connections: list, add, change and remove; needs a token |
|
||||
| `/account` | The account of the signed-in user: its details, its password and its TOTP enrolment; needs a user's token |
|
||||
| `/logs` | The last lines of the journal of the router's units and of the files of `web.log_files`; needs a token |
|
||||
| `/sites` | The web sites and their files; needs a token |
|
||||
| `/dns` | The name server's settings and records; needs a token |
|
||||
| `/.well-known/openid-configuration`, `/oidc/...` | The OpenID Provider, on `127.0.0.1` only, where Caddy passes the requests (see OpenID Provider) |
|
||||
|
||||
The status holds the state of `vpn-router-setup`, `slapd` and each PostgreSQL cluster unit
|
||||
(`postgresql@<version>-<cluster>`, or `postgresql` as `no cluster` without one), of
|
||||
`vpn-router-manager` when that package is installed, of `strongswan` when site-to-site or
|
||||
road-warrior access is configured, of `wg-quick@wg0` when WireGuard is, and of
|
||||
IKEv2 access is configured, of `wg-quick@wg0` when WireGuard is, and of
|
||||
`openvpn-server@vpn-router` when OpenVPN is; the number of connected
|
||||
OpenVPN clients, from the server's status file, which OpenVPN rewrites every 5 seconds; the package version and,
|
||||
as `manager_version`, the installed version of `vpn-router-manager` (`null`, shown as not installed, without it); whether
|
||||
Docker is installed, and when it is, the state of `docker.service` and of `vpn-router-docker`
|
||||
(the Docker rules), which do not change `status`; as `site_to_site`, the number of site-to-site
|
||||
connections that are set up (`connections`) and of those established (`established`, `null` when
|
||||
swanctl cannot be asked), without their names; the number of connected road-warrior (IPsec P2S) clients; and
|
||||
swanctl cannot be asked), without their names; the number of connected IKEv2 clients; and
|
||||
the WireGuard peers with the age of their last handshake and their traffic. `status` is `ok` when
|
||||
all these services are active, and `degraded` otherwise.
|
||||
|
||||
@@ -943,7 +1018,7 @@ curl http://10.0.0.4:7081/status
|
||||
|
||||
### Tokens and signing in
|
||||
|
||||
`/config`, `/users`, `/service-accounts`, `/groups`, `/site-to-site`, `/backup`, `/remote-access`, `/ca`, `/shell` and `/account` need a JWT in an `Authorization: Bearer` header, signed with
|
||||
`/config`, `/metrics/history`, `/metrics/settings`, `/https/settings`, `/users`, `/service-accounts`, `/groups`, `/site-to-site`, `/backup`, `/remote-access`, `/ca`, `/logs`, `/sites`, `/dns`, `/shell` and `/account` need a JWT in an `Authorization: Bearer` header, signed with
|
||||
HS256 by the key the web service makes on its first run, `/etc/vpn-router/web.key` (root only).
|
||||
The key never leaves the router; while it does not exist these paths and `/auth` are not served
|
||||
(404). A missing, expired or wrongly signed token, or one of another algorithm, gets 401 with
|
||||
@@ -1008,8 +1083,9 @@ every service and tunnel while the public status page shows less.
|
||||
### The shell
|
||||
|
||||
While `web.shell` is `true`, `/shell` gives a member of `admins` whose console login is on and
|
||||
who is enrolled for TOTP (see Console login and SSH) a login shell on the router, as the user's console account, for the
|
||||
Router Manager's Shell page. It is off by default and switched on and off on the router only:
|
||||
who is enrolled for TOTP (see Console login and SSH) a login shell on the router, as the user's
|
||||
console account, for the Router Manager's Shell page. It is off by default and switched on and
|
||||
off on the router only:
|
||||
|
||||
```sh
|
||||
vpn-router-user console alice --posix
|
||||
@@ -1018,23 +1094,60 @@ vpn-config-edit set web.shell true
|
||||
|
||||
| Request | Body | Answer |
|
||||
| --- | --- | --- |
|
||||
| `POST /shell` | `{"password", "code"}`; `{}` within `web.shell_auth_timeout` | `{"ticket", "expires_in": 30}` |
|
||||
| `POST /shell/passkey` | `{}` | `{"id", "options"}`, `options` those of `navigator.credentials.get` for the user's own passkeys |
|
||||
| `POST /shell` | `{"password", "code"}`, or `{"passkey": {"id", "credential"}}`; `{}` within `web.shell_auth_timeout` | `{"ticket", "expires_in": 30}` |
|
||||
| `GET /shell?ticket=<ticket>` | a WebSocket handshake | the terminal |
|
||||
|
||||
`POST /shell` needs an access token of `/auth`, not one of `vpn-router-token`, and signs its user
|
||||
in again: a wrong password or code gets 403 and counts as a failed login; it gets 403 too while
|
||||
`web.shell` is `false`, the user's console login is off or the user is not enrolled for TOTP. Without a password it gives a ticket
|
||||
while the user's last sign-in of `POST /shell` or keystroke in a shell is less than
|
||||
`web.shell_auth_timeout` minutes ago, and `{"ticket": null}` otherwise. The ticket opens one
|
||||
in again, with the password and the TOTP code, or with a passkey: `POST /shell/passkey` begins a
|
||||
proof for one of the user's own passkeys with user verification, which the browser answers within
|
||||
two minutes, and `POST /shell` takes its `id` and the browser's answer as `credential`. A passkey
|
||||
with user verification is two factors in one, so it takes the place of the password and the code.
|
||||
A wrong password or code gets 403 and counts as a failed login, and a passkey that is not accepted
|
||||
gets 403. `POST /shell` gets 403 too while `web.shell` is `false`, the user's console login is off
|
||||
or the user is not enrolled for TOTP: also with a passkey, the PAM login session of the shell,
|
||||
`pam_authz_search` of nslcd for a directory account, and sudo need the enrolment. Without a
|
||||
password or a passkey it gives a ticket while the user's last sign-in of
|
||||
`POST /shell` or keystroke in a shell is less than `web.shell_auth_timeout` minutes ago, and
|
||||
`{"ticket": null}` otherwise. The ticket opens one
|
||||
shell for the same user within 30 seconds. Over the WebSocket, binary messages of the client
|
||||
are keystrokes, a text message `{"cols", "rows"}` sets the window size, and binary messages of
|
||||
the router are the terminal's output. The shell is a PAM login session of the console account,
|
||||
run by `systemd-run` in a transient unit `vpn-router-shell-<id>`; sudo works as for SSH. It is closed after `web.shell_idle_minutes` without a keystroke, when the shell
|
||||
exits, when the connection closes, or within a minute of the user's console login being switched
|
||||
off or the TOTP enrolment removed; the router then sends a close frame with the reason. The
|
||||
run by `systemd-run` in a transient unit `vpn-router-shell-<id>`; sudo works as for SSH, with the
|
||||
password and the TOTP code. It is closed after `web.shell_idle_minutes` without a keystroke, when
|
||||
the shell exits, when the connection closes, or within a minute of the user's console login being
|
||||
switched off or the TOTP enrolment removed; the router then sends a close frame with the reason. The
|
||||
journal of `vpn-router-web` records who opened each shell, from which address, in which unit,
|
||||
when it ended and why, and each refused ticket or shell with the reason.
|
||||
|
||||
#### The editor of the shell
|
||||
|
||||
In the shell, `vpn-router-editor FILE...`, or its short name `vre`, opens files in the editor of
|
||||
the Router Manager's Shell page and returns at once:
|
||||
|
||||
```sh
|
||||
vre /etc/vpn-router/vpn-router.json ~/notes.txt
|
||||
```
|
||||
|
||||
- **How the files reach the page:** each shell has its session in `VPN_ROUTER_SHELL`. `vre`
|
||||
names each file, as an absolute path, with that session on the socket
|
||||
`/run/vpn-router/editor.sock`. The web service accepts it only from a process that runs as the
|
||||
shell's console account, and sends the page a text message `{"editor": "open", "session", "id", "path"}`
|
||||
over the shell's WebSocket.
|
||||
- **Reading and saving:** the page uses the routes below, which need the user's access token. The
|
||||
file is read and written as the console account by `/usr/lib/vpn-router/editor-file`, run with
|
||||
`systemd-run --uid --pipe`, so the account's permissions apply. A save writes the file in place,
|
||||
keeping its owner and mode, and creates a file that does not exist.
|
||||
- **Limits:** only regular files of UTF-8 text up to 1 MiB, and at most 20 open files per shell.
|
||||
- **Lifetime:** the files are open while the shell lives. `vre` outside the Router Manager's shell
|
||||
exits with status 2.
|
||||
|
||||
| Request | Body | Answer |
|
||||
| --- | --- | --- |
|
||||
| `GET /shell/editor/<session>/<id>` | | `{"path", "content", "version"}`, `version` the SHA-256 of the content |
|
||||
| `PUT /shell/editor/<session>/<id>` | `{"content", "version"}` | `{"path", "version"}`; 409 when the file no longer has `version` |
|
||||
| `DELETE /shell/editor/<session>/<id>` | | `{"path", "closed": true}` |
|
||||
|
||||
### The /config page
|
||||
|
||||
`/config` shows what `/` and `/status` leave out, for a client such as manage-ui:
|
||||
@@ -1048,6 +1161,7 @@ when it ended and why, and each refused ticket or shell with the reason.
|
||||
| `docker` | `docker.exposed_ports` and the rules of the `VPN-ROUTER-DOCKER` chain |
|
||||
| `postgresql` | `clusters`: each PostgreSQL cluster of `pg_lsclusters` with its version, name, port, status and data directory, and for one that is online its `settings`: `server_version`, `listen_addresses`, `port`, `max_connections`, `shared_buffers`, `effective_cache_size`, `work_mem`, `password_encryption`, `ssl` and `timezone`; `null` when `pg_lsclusters` fails |
|
||||
| `caddy` | The installed Caddy: `version`, `source`, `upstream` once Caddy's repository is configured and `ubuntu` otherwise, and `service`, the state of `caddy.service`; `null` when Caddy is not installed |
|
||||
| `oidc` | The OpenID Provider: `enabled`, and `issuer`, its issuer URL, `null` while it is disabled |
|
||||
|
||||
### The /postgresql page
|
||||
|
||||
@@ -1058,6 +1172,71 @@ router:
|
||||
- **A cluster that is not online:** `databases` and `size` are `null`.
|
||||
- **Collection:** the web service reads it with `psql` as the `postgres` user at each request.
|
||||
|
||||
### The /logs API
|
||||
|
||||
`/logs`, with the same token as `/config`, shows the last lines of the journal and of log files,
|
||||
read at each request:
|
||||
|
||||
| Request | Answer |
|
||||
| --- | --- |
|
||||
| `GET /logs` | `{"units", "files", "max_lines"}`: the units whose journal it shows, the files of `web.log_files` and the most lines one request answers, 1000 |
|
||||
| `GET /logs/journal?unit=<unit>&lines=<n>` | `{"lines": [...]}`: the last lines of the unit's journal, or of the whole journal without `unit`, with ISO times |
|
||||
| `GET /logs/file?path=<path>&lines=<n>` | `{"lines": [...]}`: the last lines of a file `web.log_files` lists |
|
||||
|
||||
The units are the package's own (`vpn-router-setup`, `-web`, `-metrics`, `-pki`, `-backup`,
|
||||
`-caddy`, `-resolver`, `-webdav`), `vpn-router-manager`, `caddy`, `slapd`, `nslcd`, `postgresql`,
|
||||
`strongswan`, `wg-quick@wg0`, `openvpn-server@vpn-router`, `ssh` and `pdns`. `lines` is 1 to 1000, 200 without it. A
|
||||
file is read from at most its last MiB, so very long lines give fewer lines. A unit or a file not
|
||||
in the lists is 404, as is a listed file that does not exist.
|
||||
|
||||
```sh
|
||||
vpn-config-edit set web.log_files /var/log/syslog,/var/log/auth.log
|
||||
```
|
||||
|
||||
### The /sites API
|
||||
|
||||
`/sites`, with the same token as `/config`, manages the web sites, with the module
|
||||
`vpn-router-site` uses too, `/usr/lib/vpn-router/vpnrouter_sites.py`:
|
||||
|
||||
| Request | Answer |
|
||||
| --- | --- |
|
||||
| `GET /sites` | `{"settings", "sites"}`: the `sites` section and the sites, each with its settings but `private_key`, with `private_key_set`, `modified` and `directory` |
|
||||
| `PUT /sites` | Changes any of the settings of the `sites` section |
|
||||
| `POST /sites` | Adds a site: `{"name", ...}` with any of its settings; an existing name is 409 |
|
||||
| `GET /sites/<name>` | The site |
|
||||
| `PUT /sites/<name>` | Changes any of its settings; an empty or absent `private_key` keeps the key |
|
||||
| `DELETE /sites/<name>?content=true` | Removes the site, with `content=true` also its folder |
|
||||
| `GET /sites/<name>/files/<path>` | A folder: `{"path", "entries": [{"name", "folder", "size", "modified"}]}`; a file: the file, as an attachment |
|
||||
| `PUT /sites/<name>/files/<path>` | Writes the body to the file, creating its folders; 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 the file or folder |
|
||||
| `DELETE /sites/<name>/files/<path>` | Removes the file or folder |
|
||||
|
||||
The paths of `/files/` are relative to the site's folder and may not leave it; a `PUT` needs a
|
||||
`Content-Length`. The files of a site may also be used by a member of its `editors` group. Invalid
|
||||
settings are 400, a missing site 404.
|
||||
|
||||
```sh
|
||||
curl -X PUT -H "Authorization: Bearer $TOKEN" --data-binary @site.zip \
|
||||
'https://router.example.com:7444/sites/www/files/?unzip=true'
|
||||
```
|
||||
|
||||
### The /dns API
|
||||
|
||||
`/dns`, with the same token as `/config`:
|
||||
|
||||
| Request | Answer |
|
||||
| --- | --- |
|
||||
| `GET /dns` | `{"settings", "nameserver", "public_address", "installed", "running", "domains", "problem"}`: the `dns` section, `wan.local_fqdn`, the primary name server, `wan.public_address`, whether PowerDNS is installed and runs, the domains, each `{"name", "ttl", "manual", "records"}` with its manual records and the records it serves, and why they cannot be read, `null` when they can |
|
||||
| `PUT /dns` | Changes any of the settings of the `dns` section, and with `public_address` `wan.public_address`; answers `{"settings", "public_address"}` |
|
||||
| `POST /dns/domains` | Adds a domain: `{"name", "ttl"?}`; an existing name is 409 |
|
||||
| `PUT /dns/domains/<domain>` | Changes `ttl` of the domain |
|
||||
| `DELETE /dns/domains/<domain>` | Removes the domain with its manual records |
|
||||
| `POST /dns/domains/<domain>/records` | Adds a manual record: `{"name", "type", "content", "ttl"?}`; `name` is relative to the domain, `@` for the domain itself, or absolute with a final dot, and `ttl` is the domain's without it |
|
||||
| `DELETE /dns/domains/<domain>/records?name=&type=&content=` | Removes the manual records of the name and type, only the one of `content` when it is given |
|
||||
|
||||
A TXT content without quotes is quoted; MX is `<preference> <host>`, SRV `<priority> <weight>
|
||||
<port> <host>` and CAA `<flags> <tag> "<value>"`. A CNAME must be its name's only record.
|
||||
|
||||
### The /users API
|
||||
|
||||
`/users` manages users with the same token as `/config`, with the module `vpn-router-user` uses
|
||||
@@ -1075,6 +1254,7 @@ and answer bodies are JSON; a request body is at most 65536 bytes.
|
||||
| `POST /users/<name>/unlock` | | the user |
|
||||
| `DELETE /users/<name>` | | `{"name", "removed": true}` |
|
||||
| `GET /users/<name>/profile` | | `{"name", "profile"}`, the OpenVPN client profile |
|
||||
| `POST /users/<name>/apple-profile` | `{"password"}` | `{"name", "profile"}`, the Apple configuration profile of IKEv2 access (see Apple VPN profile) |
|
||||
| `POST /users/<name>/otp` | | `{"name", "secret", "uri"}`, a new TOTP secret; nothing is stored |
|
||||
| `PUT /users/<name>/otp` | `{"secret", "code"}` | the user, once the code matches the secret |
|
||||
| `DELETE /users/<name>/otp` | | the user |
|
||||
@@ -1084,7 +1264,7 @@ and answer bodies are JSON; a request body is at most 65536 bytes.
|
||||
A failure is `{"error": ...}` with 400 for an invalid request, 404 for a user that does not
|
||||
exist or is a service account, 409 for a user that exists and 500 otherwise. `/users` does not
|
||||
list service accounts; `/service-accounts` serves them. `/users` works whether or not OpenVPN or
|
||||
road-warrior access is enabled; the profile needs OpenVPN. Passwords, private keys and TOTP
|
||||
IKEv2 access is enabled; the profile needs OpenVPN. Passwords, private keys and TOTP
|
||||
secrets cross the internal network in plain HTTP, like the token.
|
||||
|
||||
```bash
|
||||
@@ -1100,22 +1280,33 @@ curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
|
||||
| Request | Body | Answer |
|
||||
| --- | --- | --- |
|
||||
| `GET /service-accounts` | | `{"service_accounts": [{"name", "display_name", "description", "internal", "enabled", "locked", "email", "redirect_uris", "post_logout_redirect_uris", "allowed_groups", ...}, ...], "oidc": {"enabled", "issuer"}}`, each as `/users` shows a user, and the issuer of the OpenID Provider, `null` while it is disabled |
|
||||
| `POST /service-accounts` | `{"name", "password", "display_name"?, "description"?, "email"?, "redirect_uris"?, "post_logout_redirect_uris"?, "allowed_groups"?}` | 201 and the account |
|
||||
| `PUT /service-accounts/<name>` | any of `{"display_name", "description", "email", "redirect_uris", "post_logout_redirect_uris", "allowed_groups"}`; `""` removes one of the first three, `[]` the values of the others | the account |
|
||||
| `GET /service-accounts` | | `{"service_accounts": [{"name", "display_name", "description", "internal", "enabled", "locked", "email", "redirect_uris", "post_logout_redirect_uris", "allowed_groups", "grants", "public_client", "client_scopes", "resource_scopes", "roles", ...}, ...], "oidc": {"enabled", "issuer"}}`, each as `/users` shows a user, and the issuer of the OpenID Provider, `null` while it is disabled |
|
||||
| `POST /service-accounts` | `{"name", "password", "display_name"?, "description"?, "email"?, "redirect_uris"?, "post_logout_redirect_uris"?, "allowed_groups"?, "grants"?, "public_client"?, "client_scopes"?, "resource_scopes"?, "roles"?}` | 201 and the account |
|
||||
| `PUT /service-accounts/<name>` | 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, `false` the public client | the account |
|
||||
| `PUT /service-accounts/<name>/password` | `{"password": ...}` | the account |
|
||||
| `POST /service-accounts/<name>/disable` | | the account |
|
||||
| `POST /service-accounts/<name>/enable` | | the account |
|
||||
| `POST /service-accounts/<name>/unlock` | | the account |
|
||||
| `DELETE /service-accounts/<name>` | | `{"name", "removed": true}` |
|
||||
|
||||
A display name has at most 64 printable characters and a description at most 256. The client
|
||||
settings are lists of at most 16 values: URIs of `https`, or of `http` for `localhost`,
|
||||
`127.0.0.1` or `::1`, without a fragment and of at most 1024 characters, and names of existing
|
||||
groups (see OpenID Provider). The internal
|
||||
service accounts, `internal` true, are only listed: every other request on one gets 403, as the
|
||||
router manages them, their display name and description included. A failure is otherwise as for
|
||||
`/users`, with 404 also for a name that is not a service account.
|
||||
A display name has at most 64 printable characters and a description at most 256. The settings of
|
||||
the OpenID Provider (see OpenID Provider) are:
|
||||
|
||||
- **URIs and allowed groups:** lists of at most 16 values: URIs of `https`, or of `http` for
|
||||
`localhost`, `127.0.0.1` or `::1`, without a fragment and of at most 1024 characters, and names
|
||||
of existing groups;
|
||||
- **`grants`:** `client_credentials` and `device_code`; `public_client`, `true` or `false`;
|
||||
- **`client_scopes`:** at most 16 scopes of resources, `<resource>:<scope>`;
|
||||
- **`resource_scopes`:** at most 16 scope names of lowercase letters, digits, dots, underscores and
|
||||
hyphens;
|
||||
- **`roles`:** `[{"name", "description", "groups", "accounts"}]`, which replaces the account's roles:
|
||||
role names of letters, digits, dots, underscores and hyphens, and existing groups and accounts.
|
||||
|
||||
The internal service accounts, `internal` true, are only listed: every other request on one, and
|
||||
a role assigned to one, gets 403, as the router manages them, their display name and description
|
||||
included. A password change, disabling, unlocking and removing revoke the tokens the account was
|
||||
issued. A failure is otherwise as for `/users`, with 404 also for a name that is not a service
|
||||
account.
|
||||
|
||||
### The /account API
|
||||
|
||||
@@ -1131,6 +1322,8 @@ router manages them, their display name and description included. A failure is o
|
||||
| `POST /account/passkeys` | `{"current_password", "current_code"}` | `{"id", "options"}`: a pending registration, valid 2 minutes, and the options of `navigator.credentials.create` in their JSON form |
|
||||
| `PUT /account/passkeys` | `{"id", "credential", "label"?}`, `credential` the browser's answer as its `toJSON()` gives it; `label` at most 64 characters, `Passkey` without one | the user |
|
||||
| `DELETE /account/passkeys/<id>` | | the user |
|
||||
| `POST /account/profile` | `{"current_password", "current_code"}` | `{"name", "profile"}`, the user's OpenVPN client profile |
|
||||
| `POST /account/apple-profile` | `{"current_password", "current_code", "password"}` | `{"name", "profile"}`, the user's Apple configuration profile of IKEv2 access (see Apple VPN profile) |
|
||||
|
||||
`current_code` is needed only for a user enrolled for TOTP; removing a passkey needs neither. A wrong current password or code gets
|
||||
403 and counts as a failed login; slapd accepts each code once, so a code used to sign in is not
|
||||
@@ -1198,14 +1391,38 @@ The answer of `GET /backup` holds:
|
||||
|
||||
A change is made, checked and applied as for `/site-to-site`.
|
||||
|
||||
### The /metrics/settings API
|
||||
|
||||
`/metrics/settings` shows and changes the settings of the metrics service, with the same token as
|
||||
`/users`.
|
||||
|
||||
| Request | Body | Answer |
|
||||
| --- | --- | --- |
|
||||
| `GET /metrics/settings` | | `{"settings"}`: the `metrics` section without `otlp_password`, with `otlp_password_set` |
|
||||
| `PUT /metrics/settings` | any of the settings of the `metrics` section; an empty or absent `otlp_password` keeps the secret, and an empty `otlp_username` removes it | `{"settings"}` |
|
||||
|
||||
A change is made, checked and applied as for `/site-to-site`.
|
||||
|
||||
### The /https/settings API
|
||||
|
||||
`/https/settings` shows the settings of HTTPS and changes the CA of Caddy's certificates, with the
|
||||
same token as `/users`.
|
||||
|
||||
| Request | Body | Answer |
|
||||
| --- | --- | --- |
|
||||
| `GET /https/settings` | | `{"settings"}`: the `https` section |
|
||||
| `PUT /https/settings` | `{"acme_staging"}`; any other setting of the `https` section is 400, as it is changed on the router with `vpn-config-edit` | `{"settings"}` |
|
||||
|
||||
A change is made, checked and applied as for `/site-to-site`.
|
||||
|
||||
### The /remote-access API
|
||||
|
||||
`/remote-access` shows and changes the remote access systems, IKEv2 road-warrior (`p2s`),
|
||||
`/remote-access` shows and changes the remote access systems, IKEv2 (`p2s`),
|
||||
OpenVPN (`openvpn`) and WireGuard (`wireguard`), with the same token as `/users`.
|
||||
|
||||
| Request | Body | Answer |
|
||||
| --- | --- | --- |
|
||||
| `GET /remote-access` | | `{"p2s", "openvpn", "wireguard", "running", "local_fqdn", "wireguard_server"}`; `wireguard_server` is `{"public_key", "endpoint", "allowed_ips"}`: the router's WireGuard public key (`null` before setup made it), `<wan.local_fqdn>:<wireguard.listen_port>` (`null` without a name) and the networks a peer reaches through the tunnel, the router's local CIDRs and the WireGuard network |
|
||||
| `GET /remote-access` | | `{"p2s", "openvpn", "wireguard", "running", "local_fqdn", "wireguard_server"}`; `wireguard_server` is `{"public_key", "endpoint", "allowed_ips", "dns"}`: the router's WireGuard public key (`null` before setup made it), `<wan.local_fqdn>:<wireguard.listen_port>` (`null` without a name), the networks a peer reaches through the tunnel, the router's local CIDRs and the WireGuard network, and the addresses of the remote access clients' resolver (`[]` while it does not run) |
|
||||
| `PUT /remote-access/<system>` | any of the settings of the section `p2s`, `openvpn` or `wireguard` | the section |
|
||||
|
||||
`running` tells for each system whether setup runs it. IKEv2 and OpenVPN run once enabled with an
|
||||
@@ -1265,14 +1482,27 @@ while metrics are enabled the metrics service on `metrics.https_port` (9465), al
|
||||
and renews the certificate from Let's Encrypt with the HTTP challenge, so `wan.local_fqdn` must
|
||||
resolve to the router's public address and port 80 must be reachable from the internet; the
|
||||
certificate and the ACME account are kept in `/var/lib/caddy`. Port 80 answers the challenge only;
|
||||
it does not redirect to HTTPS.
|
||||
it does not redirect to HTTPS. Caddy's default CAs are Let's Encrypt and ZeroSSL, which it takes
|
||||
when Let's Encrypt refuses, such as for its rate limit of 5 certificates a week for the same name.
|
||||
|
||||
While `https.acme_staging` is `true`, Caddy's only CA is Let's Encrypt's staging environment, for
|
||||
routers that are rebuilt often. Its rate limits are far higher, but no browser or client trusts
|
||||
its certificates, so they are for tests only. The setting applies to every certificate Caddy
|
||||
obtains from an ACME CA, those of the web sites with `tls` `acme` included.
|
||||
|
||||
```sh
|
||||
vpn-config-edit set --restart https.acme_staging true
|
||||
```
|
||||
|
||||
The Router Manager switches it on the Overview, and `PUT /https/settings` changes it (see The
|
||||
/https/settings API).
|
||||
|
||||
`vpn-router-setup` writes Caddy's configuration to `/etc/caddy/vpn-router.Caddyfile` (root, group
|
||||
`caddy`, the group Caddy runs as, mode 0640), which the
|
||||
package's drop-in `/usr/lib/systemd/system/caddy.service.d/vpn-router.conf` makes `caddy.service`
|
||||
read; `caddy.service` does not start before the file exists, and `/etc/caddy/Caddyfile` is not
|
||||
used. Setup starts Caddy, reloads it when the file changes, and stops it and removes the file when
|
||||
HTTPS is off. In the firewall it opens port 80 on the external interface and the HTTPS ports on the
|
||||
HTTPS is off and no web site is served (see Web sites). In the firewall it opens port 80 on the external interface and the HTTPS ports on the
|
||||
internal and the external interface, and ports 80 and 443 on both when `pki.publish` has `http-80`
|
||||
and `https-default`, on which Caddy serves only the CA files (see Certificates). Caddy calls both services on `127.0.0.1`, where they listen
|
||||
whatever `web.listen` and the manager's `listen` say, and passes the client's address in
|
||||
@@ -1322,30 +1552,40 @@ answers it only on `127.0.0.1`, where Caddy passes the requests, so the provider
|
||||
over plain HTTP. The setting needs `web.enabled`, `https.enabled` and `wan.local_fqdn`.
|
||||
|
||||
```sh
|
||||
vpn-config-edit set oidc.enabled true
|
||||
systemctl restart vpn-router-setup
|
||||
vpn-config-edit set --restart oidc.enabled true
|
||||
curl https://router.example.com:7444/.well-known/openid-configuration
|
||||
```
|
||||
|
||||
| Endpoint | Purpose |
|
||||
| --- | --- |
|
||||
| `GET /.well-known/openid-configuration` | The discovery document |
|
||||
| `GET /oidc/jwks` | The public key ID tokens are signed with |
|
||||
| `GET /oidc/jwks` | The public key ID tokens and access tokens are signed with |
|
||||
| `GET`, `POST /oidc/authorize` | The authorization code flow; the sign-in page while the browser has no session |
|
||||
| `POST /oidc/token` | Exchanges a code or a refresh token for tokens |
|
||||
| `POST /oidc/token` | Exchanges a code, a refresh token, a device code or a client's credentials for tokens |
|
||||
| `POST /oidc/device_authorization` | Begins the device authorization grant: a device code and a user code |
|
||||
| `GET`, `POST /oidc/device` | The page where a user enters a device's user code, signs in and allows the device |
|
||||
| `GET`, `POST /oidc/userinfo` | The claims of the user of an access token |
|
||||
| `GET /oidc/logout` | Ends the session (RP-initiated logout) |
|
||||
|
||||
A client is a service account with at least one redirect URI: its name is the `client_id` and its
|
||||
**Clients**
|
||||
|
||||
A client is a service account with a redirect URI or a grant. Its name is the `client_id` and its
|
||||
password the `client_secret`, sent with HTTP Basic authentication (`client_secret_basic`) or in
|
||||
the body of the token request (`client_secret_post`). The password is checked with a bind, so 5
|
||||
wrong secrets lock the client for 15 minutes. Its settings are:
|
||||
wrong secrets lock the client for 15 minutes. A public client sends only its `client_id` (`none`).
|
||||
Its settings are:
|
||||
|
||||
- redirect URIs, where the provider sends the browser back with a code, matched exactly; a URI
|
||||
uses `https`, or `http` for `localhost`, `127.0.0.1` or `::1`;
|
||||
- post-logout redirect URIs, where `/oidc/logout` may send the browser afterwards;
|
||||
- allowed groups: only their members may sign in to the client; without any, every user who is
|
||||
not a service account may.
|
||||
- **redirect URIs:** where the provider sends the browser back with a code; they give the client
|
||||
the authorization code grant. A URI uses `https`, or `http` for `localhost`, `127.0.0.1` or
|
||||
`::1`, and is matched exactly, but the port of an `http` one, which a native program chooses
|
||||
when it starts (RFC 8252);
|
||||
- **post-logout redirect URIs:** where `/oidc/logout` may send the browser afterwards;
|
||||
- **allowed groups:** only their members may sign in to the client; without any, every user who
|
||||
is not a service account may;
|
||||
- **grants:** `client_credentials` and `device_code`, besides the authorization code;
|
||||
- **public client:** the client has no secret, as a native program or a CLI, which cannot keep
|
||||
one; it cannot have `client_credentials`;
|
||||
- **client scopes:** the scopes of resources the client may ask for, `<resource>:<scope>`.
|
||||
|
||||
```sh
|
||||
echo 'cl1ent-s3cret' | vpn-router-user add 3f2b8c1e-9a4d-4e6f-8b2a-1c5d7e9f0a3b --service \
|
||||
@@ -1354,34 +1594,257 @@ echo 'cl1ent-s3cret' | vpn-router-user add 3f2b8c1e-9a4d-4e6f-8b2a-1c5d7e9f0a3b
|
||||
vpn-router-user update 3f2b8c1e-9a4d-4e6f-8b2a-1c5d7e9f0a3b --allowed-group ''
|
||||
```
|
||||
|
||||
The flow is the authorization code flow with PKCE, whose `code_challenge_method` must be `S256`;
|
||||
the implicit and hybrid flows, and the password and client credentials grants, are refused. A
|
||||
registered client is trusted, so a user is not asked for consent. A user signs in on the
|
||||
provider's page with the name, the password and, when enrolled, the TOTP code, or with a passkey
|
||||
(see Passkeys); failed sign-ins count towards the lockout, and `POST /oidc/login` takes at most
|
||||
10 calls a minute from one client address, as `/auth` does. A
|
||||
disabled, locked or removed user, a service account, and a user outside the client's allowed
|
||||
groups cannot sign in, and the codes and tokens already issued to such a user stop working. The
|
||||
sign-in starts a session, a cookie of the issuer's `/oidc/` path valid for 8 hours, so later
|
||||
requests of any client go on without the page; `prompt=login` and `max_age` ask for a new sign-in,
|
||||
and `prompt=none` answers `login_required` without a session.
|
||||
**Grants**
|
||||
|
||||
| Grant | Who signs in | Tokens |
|
||||
| --- | --- | --- |
|
||||
| `authorization_code` | A user, in the browser, with PKCE (`S256`) | Access, refresh and ID token |
|
||||
| `urn:ietf:params:oauth:grant-type:device_code` | A user, on another device | Access, refresh and ID token |
|
||||
| `client_credentials` | The service account itself, with its secret | Access token |
|
||||
| `refresh_token` | | A new access and refresh token |
|
||||
|
||||
The implicit and hybrid flows, the password grant and dynamic registration are refused. A
|
||||
registered client is trusted, so a user is not asked for consent, except for a device.
|
||||
|
||||
**Signing in**
|
||||
|
||||
- **The page:** a user signs in on the provider's page with the name, the password and, when
|
||||
enrolled, the TOTP code, or with a passkey (see Passkeys).
|
||||
- **Lockout and rate limit:** failed sign-ins count towards the lockout. `POST /oidc/login`,
|
||||
`/oidc/device` and `/oidc/device_authorization` take at most 10 calls a minute from one client
|
||||
address, as `/auth` does.
|
||||
- **Who may not sign in:** a disabled, locked or removed user, a service account, and a user
|
||||
outside the client's allowed groups. The codes and tokens already issued to such a user stop
|
||||
working.
|
||||
- **The session:** the sign-in starts a cookie of the issuer's `/oidc/` path, valid for 8 hours,
|
||||
so later requests of any client go on without the page. `prompt=login` and `max_age` ask for a
|
||||
new sign-in, and `prompt=none` answers `login_required` without a session.
|
||||
|
||||
**Device authorization (RFC 8628)**
|
||||
|
||||
A client with `device_code`, such as a CLI on a host without a browser, signs a user in like this:
|
||||
|
||||
1. It posts `client_id` (and its secret, unless it is public), `scope` and optionally `resource` to
|
||||
`/oidc/device_authorization`. The answer holds `device_code`, `user_code` (8 letters, shown as
|
||||
`XXXX-XXXX`), `verification_uri`, `verification_uri_complete`, `expires_in` (600 seconds) and
|
||||
`interval` (5 seconds).
|
||||
2. It shows the user the code and the URI, then polls `/oidc/token` with
|
||||
`grant_type=urn:ietf:params:oauth:grant-type:device_code` and `device_code`. Until the user
|
||||
decides, the answer is `authorization_pending`; a poll sooner than the interval answers
|
||||
`slow_down` and adds 5 seconds to it.
|
||||
3. The user opens the URI on any device, enters the code, signs in, and allows or denies the
|
||||
client, the resource and the scopes shown. The next poll answers the tokens, or
|
||||
`access_denied`; an expired code answers `expired_token`.
|
||||
|
||||
```sh
|
||||
curl -d client_id=backup-cli -d 'scope=openid profile' \
|
||||
https://router.example.com:7444/oidc/device_authorization
|
||||
```
|
||||
|
||||
**Client credentials**
|
||||
|
||||
A confidential client with `client_credentials` gets an access token as its own service account,
|
||||
without a user. The scopes are those of a resource, and the profile scopes give the service
|
||||
account's claims; `openid` is refused, and no refresh token is issued.
|
||||
|
||||
```sh
|
||||
curl -u backup-job:'j0b-s3cret' -d grant_type=client_credentials -d resource=s3 -d scope=s3:read \
|
||||
https://router.example.com:7444/oidc/token
|
||||
```
|
||||
|
||||
**Resources, roles and scopes**
|
||||
|
||||
Every service account is also a resource, an application that checks the router's tokens, as an
|
||||
API registration of Entra ID:
|
||||
|
||||
- **Scopes:** the resource scopes the account defines, such as `read`, which clients ask for as
|
||||
`<resource>:<scope>` (`s3:read`) when their client scopes allow them.
|
||||
- **Roles:** named roles, such as `Reader`, each assigned to groups, users and service accounts;
|
||||
`vpn-router-role` manages them.
|
||||
|
||||
```sh
|
||||
echo 's3-s3cret' | vpn-router-user add s3 --service --resource-scope read --resource-scope write
|
||||
vpn-router-role add s3 Reader --description 'Reads every bucket'
|
||||
vpn-router-role member-add s3 Reader --group backup
|
||||
vpn-router-user update backup-job --grant client_credentials --client-scope s3:read
|
||||
```
|
||||
|
||||
A request is for a resource when it names it with `resource` (RFC 8707), its name or
|
||||
`<issuer>/<name>`, or asks for its scopes; more than one resource, or one that is not a
|
||||
resource, is `invalid_target`. A scope the client may not ask for is `invalid_scope`. Without a
|
||||
resource, the tokens are for the client itself. The resource is kept with the code, the device code
|
||||
and the refresh token, and a token request naming another one is `invalid_target`.
|
||||
|
||||
| Command | What it does |
|
||||
| --- | --- |
|
||||
| `vpn-router-role list [<resource>]` | The roles with the groups and accounts they are assigned to |
|
||||
| `vpn-router-role add <resource> <role> [--description D]` | Add a role |
|
||||
| `vpn-router-role remove <resource> <role>` | Remove a role |
|
||||
| `vpn-router-role member-add <resource> <role> --group G\|--account A` | Assign the role |
|
||||
| `vpn-router-role member-remove <resource> <role> --group G\|--account A` | Take the role away |
|
||||
|
||||
A scope a client may ask for cannot be removed from its resource, nor the resource while a client
|
||||
may ask for its scopes. Removing a service account removes its roles and takes it out of the
|
||||
others; removing or renaming a group does the same in the roles.
|
||||
|
||||
**Tokens**
|
||||
|
||||
| Scope | Claims |
|
||||
| --- | --- |
|
||||
| `openid` (required) | `sub`, the `entryUUID` of the user's entry |
|
||||
| `openid` (required but by `client_credentials`, which refuses it) | `sub`, the `entryUUID` of the user's entry |
|
||||
| `profile` | `preferred_username` (the user name), `name`, `given_name`, `family_name` |
|
||||
| `email` | `email` |
|
||||
| `groups` | `groups`, the names of the user's groups |
|
||||
| `<resource>:<scope>` | None; the scope is in the access token's `scope` |
|
||||
|
||||
The claims of the scopes granted are in the ID token and in the answer of `/oidc/userinfo`. ID
|
||||
tokens are RS256 JWTs, valid for 5 minutes, with `auth_time` and `amr` (`pwd`, `pwd` and `otp`, or
|
||||
`hwk` and `user` for a passkey), signed with an RSA key of 3072 bits that the web service makes on
|
||||
its first run, `/etc/vpn-router/oidc.key` (root only), and named in the JWKS by its RFC 7638
|
||||
thumbprint. A code is valid for 1 minute and used once, an access token for 5 minutes, and a
|
||||
refresh token for 8 hours and used once: each refresh answers a new one with a new ID token. The
|
||||
codes, tokens and sessions are kept, as their SHA-256, in the database of the refresh tokens of
|
||||
`/auth`. Changing a user's password, disabling, unlocking or removing the user ends its sessions
|
||||
and revokes its codes and tokens.
|
||||
- **ID tokens:** RS256 JWTs for the client (`aud`), valid for 5 minutes, with the claims of the
|
||||
scopes, `auth_time`, `amr` (`pwd`, `pwd` and `otp`, or `hwk` and `user` for a passkey), and
|
||||
`roles`, the user's roles of the client itself. `/oidc/userinfo` answers the same claims.
|
||||
- **Access tokens:** RS256 JWTs of RFC 9068 (`typ` `at+jwt`), valid for 5 minutes, which a
|
||||
resource verifies with the JWKS. Their claims:
|
||||
- `iss`, `sub`, `iat`, `exp` and `jti`;
|
||||
- `aud`, the resource, or the client without one, and `client_id`, the client;
|
||||
- `scope`, the scopes granted;
|
||||
- `roles`, the roles of the resource the user or the service account holds, directly or by a
|
||||
group;
|
||||
- the claims of the scopes, and of a user `auth_time` and `amr`.
|
||||
- **Roles are read at every issue,** refresh included, so a role taken away is gone from the next
|
||||
token.
|
||||
- **The key:** tokens are signed with an RSA key of 3072 bits that the web service makes on its
|
||||
first run, `/etc/vpn-router/oidc.key` (root only), and named in the JWKS by its RFC 7638
|
||||
thumbprint.
|
||||
- **Lifetimes:** a code is valid for 1 minute and used once, a device code for 10 minutes, and a
|
||||
refresh token for 8 hours and used once: each refresh answers a new one.
|
||||
- **Storage and revocation:** the codes, device codes, tokens and sessions are kept, as their
|
||||
SHA-256, in the database of the refresh tokens of `/auth`. Changing the password of a user or a
|
||||
service account, disabling, unlocking or removing it ends its sessions and revokes its codes and
|
||||
tokens.
|
||||
|
||||
## Web sites
|
||||
|
||||
While `sites.enabled` is `true`, Caddy serves static web sites from folders on the router, each
|
||||
`/var/lib/vpn-router/sites/<name>`. The sites are rows of the database `vpn-router-sites`, which
|
||||
the web service creates on its first run with its own role and the password in
|
||||
`/etc/vpn-router/sites-database.secret`; they are managed with `vpn-router-site`, `/sites` and the
|
||||
Router Manager, each change applied by restarting `vpn-router-setup`. A site has:
|
||||
|
||||
| Setting | Meaning |
|
||||
| --- | --- |
|
||||
| `name` | 1 to 31 lowercase letters, digits and hyphens, starting with a letter; also the name of its folder |
|
||||
| `enabled` | Whether Caddy serves it |
|
||||
| `hosts` | The host names (or IP addresses) Caddy answers on the port, the names TLS clients send as SNI; none answers any name |
|
||||
| `port` | The TCP port, 443 by default |
|
||||
| `listen` | `internal`, `external` (the default) or `both`: the interfaces whose firewall opens the port |
|
||||
| `tls` | `none` (plain HTTP), `acme` (Let's Encrypt, the default), `ca` (certificates of an issuing CA of `pki.issuing_cas`, named by `ca`) or `file` (`certificate` and `private_key`, PEM) |
|
||||
| `index_file` | The file served for a folder, `index.html` by default |
|
||||
| `spa` | Whether a path without a file is served the site's `index_file`, for single-page applications |
|
||||
| `editors` | The group whose members may change the site's files besides the admins |
|
||||
| `dns` | Who serves the records of its host names: `user` (the default), a DNS provider outside the router, or `router`, the router's name server (see Name server) |
|
||||
| `dns_address` | With `dns` `router`, the router's address in the records: `public` (the default, `wan.public_address`), `internal` or `external` |
|
||||
|
||||
A site may not take a port of the router's own services (SSH, `web.port`, the HTTPS ports, the
|
||||
metrics ports, the Router Manager's, `sites.webdav_port`, 7446, 53, the bootstrap ports, a TCP
|
||||
`openvpn.port` and `docker.exposed_ports`). It may use `wan.local_fqdn` as a host name, on a port
|
||||
of its own, such as 443, which the router's services leave free; its record is then kept where the
|
||||
domain of `wan.local_fqdn` is, so `dns` `router` takes neither it, nor wildcards or addresses, and
|
||||
`public` needs `wan.public_address`. Enabled sites on one port need host
|
||||
names of their own, the same `listen` and the same kind of TLS (plain HTTP or HTTPS). A site of
|
||||
`acme` needs host names, without wildcards or addresses, that resolve to the router, and port 80,
|
||||
which setup opens on the external interface. The certificate of `file` must be the key's and name
|
||||
the hosts; it is written to `/etc/caddy/vpn-router-sites/<name>` (root, group `caddy`, 0640). An
|
||||
issuing CA of `ca` is given to Caddy as the ACME servers' CAs are, in `/etc/caddy/vpn-router-pki`;
|
||||
its certificates are trusted by clients that trust the router's root. Caddy runs while HTTPS is
|
||||
ready or a site is served; while the sites cannot be read, setup leaves the Caddyfile as it is.
|
||||
|
||||
```sh
|
||||
vpn-config-edit set --restart sites.enabled true
|
||||
vpn-router-site add www --host www.example.com --host example.com --spa
|
||||
vpn-router-site add intranet --host intranet.example.lan --port 8443 --listen internal --tls ca --ca web \
|
||||
--editors web-team
|
||||
vpn-router-site update www --no-enabled
|
||||
vpn-router-site remove intranet --content
|
||||
```
|
||||
|
||||
`vpn-router-site remove` keeps the folder without `--content`. Renaming a group renames it in the
|
||||
sites; a group a site has cannot be removed.
|
||||
|
||||
### Site files
|
||||
|
||||
The files of a site are read and written through the web service (see The /sites API) by the
|
||||
admins and the members of the site's `editors` group, and through WebDAV. An upload is at most
|
||||
`sites.upload_max_mb` MiB (512 by default, at most 10240); a zip archive is extracted into a
|
||||
folder, and one with an absolute path, a `..`, a link, more than 10000 members or more content than
|
||||
the limit is refused before anything is written. Files are written with mode 0644 and folders with
|
||||
0755, so Caddy reads them.
|
||||
|
||||
### WebDAV
|
||||
|
||||
While `sites.webdav` is `true` and HTTPS is ready, `vpn-router-setup` runs
|
||||
`vpn-router-webdav.service`, `rclone serve webdav` on `127.0.0.1:7446`, which Caddy serves at
|
||||
`https://<wan.local_fqdn>:<sites.webdav_port>` (7445 by default) and the firewall opens on both
|
||||
interfaces. A user signs in with the name and password of the router's directory, followed by the
|
||||
TOTP code for an enrolled user; an admin sees a folder for every site, any other user the folders of
|
||||
the sites whose `editors` group the user is a member of, and no folder means no access. rclone writes
|
||||
an upload to its cache in `/var/cache/vpn-router-webdav` and to the site's folder a few seconds
|
||||
later. rclone is installed from the Ubuntu archive by `vpn-router-install@webdav.service` when it is
|
||||
missing.
|
||||
|
||||
```sh
|
||||
vpn-config-edit set --restart sites.webdav true
|
||||
```
|
||||
|
||||
### Name server
|
||||
|
||||
While `dns.enabled` is `true`, `vpn-router-setup` runs PowerDNS (`pdns.service`) as the
|
||||
primary name server of the router's domains, on port 53 of the addresses of `dns.listen`
|
||||
(`internal`, `external`, the default, or `both`), which the firewall opens for UDP and TCP. The
|
||||
domains and records are in the database `vpn-router-dns` (password in `/etc/vpn-router/dns-database.secret`),
|
||||
read by PowerDNS's gpgsql backend; setup writes the settings
|
||||
`/etc/powerdns/pdns.d/vpn-router.conf` (root, group `pdns`, 0640) and installs `pdns-server` and
|
||||
`pdns-backend-pgsql` from the Ubuntu archive with `vpn-router-install@dns.service` when they are
|
||||
missing, without the recommended bind backend.
|
||||
|
||||
| Setting | Meaning |
|
||||
| --- | --- |
|
||||
| `wan.local_fqdn` | The primary name server of every domain: the SOA's primary and the first NS |
|
||||
| `dns.secondary` | The secondary name server of every domain, the second NS; the addresses it resolves to are notified of every change and may transfer the domains (AXFR) |
|
||||
| `dns.ttl` | The TTL of the records and the SOA's negative TTL of a domain without its own, 60 to 86400 seconds, 300 by default |
|
||||
|
||||
A domain the router hosts, for example `example.com`, with `wan.local_fqdn` `router.other.net`,
|
||||
`dns.secondary` `ns2.provider.net` and a site of `www.example.com` and `example.com` with `dns`
|
||||
`router` and `dns_address` `public` (`wan.public_address` `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
|
||||
```
|
||||
|
||||
The domains are added with `/dns` or the Router Manager, each with:
|
||||
|
||||
- **TTL**: the TTL of its records, `dns.ttl` when not set;
|
||||
- **Manual records** (A, AAAA, CNAME, TXT, MX, SRV, CAA), served too, but not those of a name and
|
||||
type the domain's own records have.
|
||||
|
||||
The host names of an enabled site with `dns` `router` get an A or AAAA record of its `dns_address`
|
||||
in the closest domain they are in; a site with `dns` `user` gets none, and a host name in no domain
|
||||
of the router gets none either. `wan.local_fqdn` gets no record in the domains: its A record is kept
|
||||
by the DNS of its own domain, wherever that is. A domain inside another domain of the router is
|
||||
delegated from it with NS records. Setup writes the domains at each run, and after each change
|
||||
through `/dns`; the SOA's serial grows only when the records change. While IKEv2, OpenVPN or
|
||||
WireGuard is enabled, dnsmasq answers their clients on port 53 of the addresses of
|
||||
`p2s.dns_listen`, by default the internal address, so `dns.listen` must not share an interface with
|
||||
it. The parent zone of each domain must delegate it to `wan.local_fqdn` and `dns.secondary`, with
|
||||
glue records for those of them inside the domain. While `dns.secondary` is not set, the domains have
|
||||
`wan.local_fqdn` as their only name server, and setup warns.
|
||||
|
||||
```sh
|
||||
vpn-config-edit set dns.secondary ns2.provider.net
|
||||
vpn-config-edit set wan.public_address 203.0.113.10
|
||||
vpn-config-edit set --restart dns.enabled true
|
||||
```
|
||||
|
||||
## Metrics
|
||||
|
||||
@@ -1393,7 +1856,7 @@ takes a sample of the router with `psutil`:
|
||||
- the free and the total space of `/`;
|
||||
- the bytes received and sent per second on the external and the internal interface;
|
||||
- the 1, 5 and 15 minute load averages;
|
||||
- the VPN clients of each enabled VPN system: road-warrior (IPsec P2S) and OpenVPN clients, and
|
||||
- the VPN clients of each enabled VPN system: IKEv2 and OpenVPN clients, and
|
||||
WireGuard peers with a handshake in the last 3 minutes;
|
||||
- for each site-to-site connection that is set up: whether it is up (1 or 0), the bytes and the
|
||||
packets per second received and sent through its child SAs since the previous sample, and the
|
||||
@@ -1511,6 +1974,8 @@ A backup holds the state that `vpn-router-setup` cannot make again:
|
||||
| `files/<path>` | Each `psk_file` outside `/etc/vpn-router` |
|
||||
| `ldap/vpn-router.ldif` | The user directory, written by `slapcat`: users, groups, service accounts, password hashes and TOTP secrets, and the PKI: the CAs and their keys, the root key while it is on the router, and the certificates issued, with the keys of the router's and the users' certificates |
|
||||
| `postgresql/vpn-router.dump` | With `backup.metrics`, the database of the metrics and the refresh tokens, written by `pg_dump -Fc` |
|
||||
| `/var/lib/vpn-router/sites` | The folders of the web sites |
|
||||
| `postgresql/vpn-router-sites.dump`, `postgresql/vpn-router-dns.dump` | The databases of the web sites and of the name server, once they exist |
|
||||
| `manifest.json` | The host, the time, the version of the package, the schedule and the items |
|
||||
|
||||
The files and the directory are copied while the locks of the configuration and of the users are held, so no change of the API or the command-line tools falls between them; `pg_dump` reads a snapshot of its own. The copy is staged in `/var/lib/vpn-router/backup/staging`, which is removed after each run. The backup holds every secret of the router, so keep it as safe as the router itself.
|
||||
@@ -1576,10 +2041,10 @@ Each run is recorded in `/var/lib/vpn-router/backup.json`, which `/backup` and t
|
||||
|
||||
`restore` is only on the command line. It shows what the backup holds and asks for `yes`, which `--yes` gives. It then:
|
||||
|
||||
1. stops the web service, the metrics service and slapd;
|
||||
1. stops the web service, the metrics service, WebDAV, PowerDNS and slapd;
|
||||
2. puts back the files;
|
||||
3. loads the directory with `slapadd` into an empty `/var/lib/ldap/vpn-router`;
|
||||
4. sets the database role's password to the restored `database.secret`, and restores the database with `pg_restore --clean` when the backup holds it;
|
||||
4. sets the database roles' passwords to the restored password files, and restores each database the backup holds with `pg_restore --clean`;
|
||||
5. restarts `vpn-router-setup`, which regenerates everything else, followed by WireGuard, OpenVPN, strongSwan and the Router Manager, and reloads ufw.
|
||||
|
||||
What it replaces is kept in `/var/lib/vpn-router/backup/before-restore/<UTC time>`. To restore onto a new VM, install the package first and let `vpn-router-setup` run once. The restored configuration names the interfaces of the old VM, so change them with `vpn-config-edit` if they differ.
|
||||
@@ -1598,23 +2063,25 @@ interfaces it listens on.
|
||||
|
||||
`apt-get remove` stops `vpn-router-docker`, which removes the Docker rules. It terminates the
|
||||
tunnels and takes `wg-quick@wg0`, `openvpn-server@vpn-router`, `caddy`, `vpn-router-metrics`,
|
||||
`vpn-router-backup.timer` and `vpn-router-pki.timer` down, but leaves the
|
||||
`vpn-router-webdav`, `vpn-router-backup.timer` and `vpn-router-pki.timer` down, but leaves the
|
||||
configuration in place. `strongswan` is left enabled, since it is a shared service that
|
||||
may be in use by something else.
|
||||
|
||||
`apt-get purge` additionally removes the generated files and the firewall rules, restoring
|
||||
`/etc/ufw/before.rules` to its original content, and hands `vpn-router.json` back to `ucf`
|
||||
before deleting it so a later reinstall starts clean. It also removes the web service's
|
||||
signing key and the OpenID Provider's, the password file of the metrics service account, and the database of the refresh
|
||||
tokens and the metrics, its role and their password
|
||||
file when the package created them, and Caddy's configuration `/etc/caddy/vpn-router.Caddyfile`; the certificate in
|
||||
signing key and the OpenID Provider's, the password file of the metrics service account, the database of the refresh
|
||||
tokens and the metrics and those of the web sites and the name server, their roles and their password
|
||||
files when the package created them, Caddy's configuration `/etc/caddy/vpn-router.Caddyfile` and PowerDNS's
|
||||
`/etc/powerdns/pdns.d/vpn-router.conf`; the certificate in
|
||||
`/var/lib/caddy` belongs to the `caddy` package. It removes the backup timer's schedule, the
|
||||
record of the last backup, the published CA files, the copies of the ACME CAs in
|
||||
`/etc/caddy/vpn-router-pki` and OpenVPN's CA and CRL files. Caddy stays installed, and with it the
|
||||
source of Caddy's repository. Key material, users and backups are never deleted:
|
||||
record of the last backup, the published CA files, the copies of the CAs in
|
||||
`/etc/caddy/vpn-router-pki`, the sites' certificates in `/etc/caddy/vpn-router-sites` and OpenVPN's CA and CRL files.
|
||||
Caddy, rclone and PowerDNS stay installed, and with Caddy the
|
||||
source of its repository. Key material, users, content and backups are never deleted:
|
||||
`/etc/vpn-router/ldap`, `/etc/vpn-router/rclone.conf`, the user directory in slapd with the PKI, the WireGuard
|
||||
key pair, everything under `/etc/swanctl`, the archives in `backup.path` and
|
||||
`/var/lib/vpn-router/backup/before-restore` are left alone.
|
||||
key pair, everything under `/etc/swanctl`, the folders of the web sites in `/var/lib/vpn-router/sites`, the archives in
|
||||
`backup.path` and `/var/lib/vpn-router/backup/before-restore` are left alone.
|
||||
|
||||
## Building
|
||||
|
||||
@@ -1624,4 +2091,6 @@ cd debian-package
|
||||
```
|
||||
|
||||
The build runs in a container and writes the package to `debian-package/out/`.
|
||||
`./publish.sh` uploads it to the configured repository.
|
||||
`./publish.sh` uploads it to the component `main` of the distributions `noble` and `resolute` of
|
||||
the configured repository, skipping a distribution that has the version already;
|
||||
`DISTRIBUTIONS` names others.
|
||||
+509
-39
@@ -1,6 +1,425 @@
|
||||
vpn-router (0.39.1-1) unstable; urgency=medium
|
||||
|
||||
fix: the web service starts again after its first start with 0.39.0, and the
|
||||
name server's domains can be changed from any process.
|
||||
|
||||
* The schemas of the web sites and of the name server can be applied any
|
||||
number of times, so the web service no longer keeps retrying with
|
||||
'function "site_put" already exists' and never listening, and
|
||||
vpn-router-site and setup no longer fail the same way.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 06 Nov 2026 20:16:57 +0100
|
||||
|
||||
vpn-router (0.39.0-2) unstable; urgency=medium
|
||||
|
||||
update: vpn-router is published in the koszewscy package registry.
|
||||
|
||||
* publish.sh uploads to the owner koszewscy unless OWNER names another.
|
||||
* repo_url of the Azure and GCP examples defaults to
|
||||
https://gitea.koszewscy.waw.pl/api/packages/koszewscy/debian.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 06 Nov 2026 20:08:29 +0100
|
||||
|
||||
vpn-router (0.39.0-1) unstable; urgency=medium
|
||||
|
||||
feature: the name server's domains have wan.local_fqdn and dns.secondary as
|
||||
their name servers, and a site with dns router gets records of the router's
|
||||
public, internal or external address.
|
||||
|
||||
* The SOA and the first NS of every domain are wan.local_fqdn, and the
|
||||
second NS is dns.secondary, a host name, whose addresses are notified
|
||||
of every change and may transfer the domains; dns.nameserver is gone.
|
||||
* wan.local_fqdn gets no record in the domains; its record is kept in
|
||||
the DNS of its own domain.
|
||||
* A site has dns, user (the default), served elsewhere, or router, whose
|
||||
host names get an A or AAAA record in their closest domain, with
|
||||
dns_address public (wan.public_address, new), internal or external.
|
||||
dns.addresses, the domains' site records switch and the per-domain
|
||||
secondaries with their routes are gone; dns.nameserver and
|
||||
dns.addresses of an earlier configuration are left out when read.
|
||||
* A site may use wan.local_fqdn as a host name on a port of its own.
|
||||
* GET /dns answers nameserver, wan.local_fqdn, and public_address,
|
||||
which PUT /dns changes.
|
||||
* The summaries of the changelog entries are wrapped at 80 columns, and
|
||||
release.py joins the lines of a summary into the commit message.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 30 Oct 2026 21:30:01 +0100
|
||||
|
||||
vpn-router (0.38.1-1) unstable; urgency=medium
|
||||
|
||||
fix: /https/settings shows the https section and switches https.acme_staging,
|
||||
for the Router Manager.
|
||||
|
||||
* GET /https/settings shows the https section, and PUT /https/settings
|
||||
changes acme_staging; the other settings of the section stay with
|
||||
vpn-config-edit.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 30 Oct 2026 02:43:22 +0100
|
||||
|
||||
vpn-router (0.38.0-1) unstable; urgency=medium
|
||||
|
||||
feature: https.acme_staging takes Caddy's certificates from Let's Encrypt's
|
||||
staging environment.
|
||||
|
||||
* While https.acme_staging is true, Caddy's only CA is Let's Encrypt's
|
||||
staging environment, for the router's services and the web sites
|
||||
with tls acme; its certificates are not trusted by browsers.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 30 Oct 2026 02:34:01 +0100
|
||||
|
||||
vpn-router (0.37.3-1) unstable; urgency=medium
|
||||
|
||||
fix: an answer whose client went away ends quietly, and a shell whose client
|
||||
left before it opened is stopped.
|
||||
|
||||
* The web service, the metrics service and the bootstrap server stop
|
||||
writing an answer when its client closes the connection, and a site
|
||||
file whose download is left no longer gets a second answer.
|
||||
* A shell whose client closed the connection before it opened is
|
||||
stopped and its end is logged, instead of running on.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 30 Oct 2026 02:03:26 +0100
|
||||
|
||||
vpn-router (0.37.2-1) unstable; urgency=medium
|
||||
|
||||
fix: the web service, the metrics service and the bootstrap server log a
|
||||
failure in one line instead of a traceback, and nothing when a client goes
|
||||
away.
|
||||
|
||||
* A request whose client closed the connection is no longer logged.
|
||||
* Any other failure of a request, a thread or the service is logged in
|
||||
one line with the exception's type and message.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 30 Oct 2026 01:59:19 +0100
|
||||
|
||||
vpn-router (0.37.1-1) unstable; urgency=medium
|
||||
|
||||
fix: /config answers the OpenID Provider's state and issuer.
|
||||
|
||||
* /config has oidc: enabled, and issuer, the issuer URL, null while the
|
||||
provider is disabled.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 30 Oct 2026 01:53:55 +0100
|
||||
|
||||
vpn-router (0.37.0-1) unstable; urgency=medium
|
||||
|
||||
feature: the OpenID Provider has the client credentials and device
|
||||
authorization grants, public clients, and service accounts as resources with
|
||||
roles and scopes in JWT access tokens.
|
||||
|
||||
* A client may have the grants client_credentials, by which a
|
||||
confidential client gets an access token as its own service account,
|
||||
and the device code (RFC 8628): POST /oidc/device_authorization gives
|
||||
a device code and a user code, the user enters the code on
|
||||
/oidc/device, signs in and allows or denies the device, and the device
|
||||
polls /oidc/token. A public client has no secret.
|
||||
* An http loopback redirect URI matches with any port.
|
||||
* Every service account is a resource: it defines scopes, which clients
|
||||
ask for as <resource>:<scope> when their client scopes allow them, and
|
||||
roles, assigned to groups, users and service accounts. A token request
|
||||
names its resource with resource (RFC 8707) or by its scopes.
|
||||
* Access tokens are RS256 JWTs of RFC 9068 for the resource, or the
|
||||
client, with client_id, scope, roles and the claims of the scopes; ID
|
||||
tokens and /oidc/userinfo carry the user's roles of the client.
|
||||
* vpn-router-user takes --grant, --public-client, --client-scope and
|
||||
--resource-scope; the new vpn-router-role manages the roles; and
|
||||
/service-accounts takes and answers grants, public_client,
|
||||
client_scopes, resource_scopes and roles.
|
||||
* Changing the password of a service account, disabling, unlocking or
|
||||
removing it through /service-accounts revokes its tokens.
|
||||
* POST /oidc/device and /oidc/device_authorization are rate limited as
|
||||
/oidc/login is.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 30 Oct 2026 00:57:11 +0100
|
||||
|
||||
vpn-router (0.36.0-1) unstable; urgency=medium
|
||||
|
||||
feature: a shell opens with one of the user's passkeys in place of the
|
||||
password and the TOTP code.
|
||||
|
||||
* POST /shell/passkey begins a proof for one of the signed-in user's own
|
||||
passkeys, with user verification; POST /shell takes the browser's
|
||||
answer as {"passkey": {"id", "credential"}} in place of the password
|
||||
and the code, and gives the ticket of the shell.
|
||||
* A passkey that is not accepted is refused with 403. The shell still
|
||||
needs the user's TOTP enrolment, which its login session and sudo in
|
||||
it need.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Thu, 29 Oct 2026 23:09:43 +0100
|
||||
|
||||
vpn-router (0.35.2-2) unstable; urgency=medium
|
||||
|
||||
update: the package is published for Ubuntu 26.04 (resolute) as well as 24.04
|
||||
(noble).
|
||||
|
||||
* publish.sh uploads the package to the distributions noble and resolute
|
||||
of the repository, component main, skipping a distribution that has
|
||||
the version already; DISTRIBUTIONS names others. A router on Ubuntu
|
||||
26.04 installs vpn-router from the distribution resolute.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Thu, 29 Oct 2026 21:41:32 +0100
|
||||
|
||||
vpn-router (0.35.2-1) unstable; urgency=medium
|
||||
|
||||
fix: the web service answers /, /status and /status/full again while IKEv2
|
||||
runs.
|
||||
|
||||
* The status of IKEv2 clients is collected again; the requests that
|
||||
collect the status were dropped with a NameError since 0.35.1.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Thu, 29 Oct 2026 19:03:16 +0100
|
||||
|
||||
vpn-router (0.35.1-1) unstable; urgency=medium
|
||||
|
||||
update: the router web service is split into the package vpnrouter_web, with
|
||||
one module per family of paths.
|
||||
|
||||
* /usr/lib/vpn-router/web initializes and serves; the package
|
||||
/usr/lib/vpn-router/vpnrouter_web/ answers the requests, with a module
|
||||
for the status, the sign-in, the identities, the connections, backups,
|
||||
the PKI, logs, web sites, DNS, metrics and the shell. The paths, the
|
||||
answers and the log messages are the same.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Thu, 29 Oct 2026 18:20:50 +0100
|
||||
|
||||
vpn-router (0.35.0-1) unstable; urgency=medium
|
||||
|
||||
feature: vre opens files from the Router Manager's shell in the editor of its
|
||||
Shell page.
|
||||
|
||||
* vpn-router-editor FILE..., or vre, in the shell of the Router Manager
|
||||
opens the files in the editor of the Shell page and returns at once;
|
||||
outside that shell it exits with status 2.
|
||||
* GET, PUT and DELETE /shell/editor/<session>/<id> read, save and close
|
||||
a file opened so, as the shell's console account; a save is refused
|
||||
with 409 when the file changed since it was read.
|
||||
* The editor takes regular files of UTF-8 text up to 1 MiB, at most 20
|
||||
per shell, while the shell lives.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Thu, 29 Oct 2026 18:02:27 +0100
|
||||
|
||||
vpn-router (0.34.0-1) unstable; urgency=medium
|
||||
|
||||
feature: the web service shows and changes the settings of the metrics service
|
||||
at /metrics/settings, and OpenVPN starts on a new router, as setup installs
|
||||
CRLs that revoke nothing.
|
||||
|
||||
* GET and PUT /metrics/settings show and change the metrics section;
|
||||
otlp_password is never shown, an empty one keeps it, and an empty
|
||||
otlp_username removes it.
|
||||
* A CRL without revoked certificates is installed for strongSwan and
|
||||
OpenVPN.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Wed, 28 Oct 2026 22:00:00 +0100
|
||||
|
||||
vpn-router (0.33.3-1) unstable; urgency=medium
|
||||
|
||||
fix: vpn-router-setup runs on a new installation once libpq can be found.
|
||||
|
||||
* The vpn-router-setup unit waits, with wait-for-libpq, until libpq is in
|
||||
the linker cache before it runs setup.
|
||||
* The package starts vpn-router-setup without waiting for it.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Wed, 28 Oct 2026 21:00:00 +0100
|
||||
|
||||
vpn-router (0.33.2-1) unstable; urgency=medium
|
||||
|
||||
fix: the router's IPsec remote access is called IKEv2 instead of road-warrior.
|
||||
|
||||
* The README, the package description, the installer's questions, the
|
||||
status page, the messages of the web service and the examples say
|
||||
IKEv2 access and IKEv2 clients; the strongSwan connection keeps its
|
||||
name, road-warrior.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 23:00:00 +0100
|
||||
|
||||
vpn-router (0.33.1-1) unstable; urgency=medium
|
||||
|
||||
fix: the name server is the primary of any number of domains, each with
|
||||
secondary name servers and a switch for the records of the sites.
|
||||
|
||||
* The domains, their secondaries and manual records are kept in the
|
||||
vpn-router-dns database and changed through /dns/domains; dns.zone is
|
||||
removed.
|
||||
* Each domain is served as a primary zone: the router and its
|
||||
secondaries are its NS, and each secondary's address is notified of
|
||||
changes and may transfer the zone.
|
||||
* A domain with site records gives the host names of the enabled sites
|
||||
in it A and AAAA records of dns.addresses; a domain inside another is
|
||||
delegated from it.
|
||||
* Manual records belong to a domain; those of the single zone are
|
||||
dropped.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 20:00:00 +0100
|
||||
|
||||
vpn-router (0.33.0-1) unstable; urgency=medium
|
||||
|
||||
feature: road-warrior clients resolve the domains of the new setting
|
||||
p2s.dns_domains with the router's resolver.
|
||||
|
||||
* p2s.dns_domains lists up to 16 domains; setup pushes them to IKEv2
|
||||
clients as INTERNAL_DNS_DOMAIN (RFC 8598, attribute 25) of the
|
||||
road-warrior pool.
|
||||
* The Apple VPN profile names them as match domains of the router's
|
||||
resolver, which macOS and iOS use for those domains.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 18:00:00 +0100
|
||||
|
||||
vpn-router (0.32.1-1) unstable; urgency=medium
|
||||
|
||||
fix: a VPN user downloads the own OpenVPN client profile through /account,
|
||||
with the current password and code.
|
||||
|
||||
* POST /account/profile answers the user's OpenVPN client profile; it
|
||||
needs the current password and, when enrolled, a code.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 16:00:00 +0100
|
||||
|
||||
vpn-router (0.32.0-1) unstable; urgency=medium
|
||||
|
||||
feature: the web service gives Apple VPN profiles that set up IKEv2
|
||||
road-warrior access on macOS, iOS and iPadOS.
|
||||
|
||||
* POST /users/<name>/apple-profile and POST /account/apple-profile answer
|
||||
an unsigned .mobileconfig with the P2S CA and the root CA, the user's
|
||||
certificate and key as a PKCS#12 encrypted with the password of the
|
||||
request, and an IKEv2 VPN to wan.local_fqdn with EAP-TLS and the
|
||||
proposals of the road-warrior connection.
|
||||
* A user's own profile needs the current password and, when enrolled, a
|
||||
code.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 14:00:00 +0100
|
||||
|
||||
vpn-router (0.31.3-1) unstable; urgency=medium
|
||||
|
||||
fix: WireGuard clients may use the router's DNS resolver, which runs while
|
||||
WireGuard does, and /remote-access names its addresses for their
|
||||
configuration.
|
||||
|
||||
* The firewall opens port 53 of the remote access clients' resolver for
|
||||
the WireGuard network, as it does for the IKEv2 and OpenVPN pools.
|
||||
* The resolver runs while WireGuard is the only remote access enabled.
|
||||
* GET /remote-access answers the resolver's addresses as
|
||||
wireguard_server.dns.
|
||||
* dns.listen may not share an interface with p2s.dns_listen while
|
||||
WireGuard is enabled either.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 12:00:00 +0100
|
||||
|
||||
vpn-router (0.31.2-1) unstable; urgency=medium
|
||||
|
||||
fix: dnsmasq answers the remote access clients' DNS queries, so they no longer
|
||||
get the router's own host name with all its addresses from systemd-resolved.
|
||||
|
||||
* vpn-router-resolver.service runs dnsmasq on the addresses of
|
||||
p2s.dns_listen, forwarding to systemd-resolved's stub on 127.0.0.53;
|
||||
names without a dot are not forwarded. The package depends on
|
||||
dnsmasq-base.
|
||||
* systemd-resolved keeps its loopback stubs only; setup removes the
|
||||
drop-in that made it listen for the clients.
|
||||
* /logs shows the journal of vpn-router-resolver, vpn-router-webdav and
|
||||
pdns.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 11:00:00 +0100
|
||||
|
||||
vpn-router (0.31.1-1) unstable; urgency=medium
|
||||
|
||||
fix: the remote access clients' resolver answers on the internal address by
|
||||
default, chosen by p2s.dns_listen, so the name server can take port 53 of the
|
||||
external addresses.
|
||||
|
||||
* The new setting p2s.dns_listen chooses the addresses of the resolver the
|
||||
road-warrior and OpenVPN clients are given: internal, the default,
|
||||
external or both. The firewall opens port 53 for the client pools on
|
||||
those addresses only.
|
||||
* dns.listen is refused only when it shares an interface with
|
||||
p2s.dns_listen while IKEv2 or OpenVPN is enabled.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 23:00:00 +0100
|
||||
|
||||
vpn-router (0.31.0-1) unstable; urgency=medium
|
||||
|
||||
feature: Caddy serves static web sites from folders on the router, defined in
|
||||
their own database, with files uploaded through the web service or WebDAV, and
|
||||
an optional PowerDNS name server answers for a zone of the sites.
|
||||
|
||||
* The new database vpn-router-sites holds the web sites: host names,
|
||||
port, interfaces, TLS (none, Let's Encrypt, an issuing CA or a
|
||||
certificate and key), index file, single-page fallback and the group
|
||||
whose members change the files. sites.enabled serves them from
|
||||
/var/lib/vpn-router/sites/<name>; setup writes their blocks into the
|
||||
Caddyfile and opens their ports, and Caddy also runs without HTTPS of
|
||||
the router while a site is served.
|
||||
* vpn-router-site lists, shows, adds, changes and removes the sites, and
|
||||
bash completes it.
|
||||
* /sites manages the sites and the settings of the sites section, and
|
||||
under /sites/<name>/files/ lists, downloads, uploads, extracts zip
|
||||
archives into, creates, renames and removes the files of a site, up to
|
||||
sites.upload_max_mb; members of the site's editors group may use its
|
||||
files.
|
||||
* sites.webdav runs rclone's WebDAV server of the sites' folders behind
|
||||
Caddy on sites.webdav_port, signing users in with the directory and
|
||||
showing each the sites it may change.
|
||||
* dns.enabled runs PowerDNS with its PostgreSQL backend in the new
|
||||
database vpn-router-dns for dns.zone on the addresses of dns.listen:
|
||||
SOA, NS, the A and AAAA records of dns.addresses for the zone, the
|
||||
name server and the sites' host names in the zone, and manual records
|
||||
added with /dns.
|
||||
* rclone and PowerDNS are installed from the Ubuntu archive by
|
||||
vpn-router-install@<feature>.service when their feature is on.
|
||||
* Backups hold the sites' folders and the dumps of both databases; a
|
||||
restore puts them back. Purge removes the databases, their roles and
|
||||
password files, and the settings of PowerDNS.
|
||||
* Renaming a group renames it in the sites; a group a site has cannot be
|
||||
removed.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 21:00:00 +0100
|
||||
|
||||
vpn-router (0.30.0-1) unstable; urgency=medium
|
||||
|
||||
feature: the web service serves the last lines of the journal of the router's
|
||||
units and of the log files web.log_files lists, at /logs.
|
||||
|
||||
* GET /logs lists the units whose journal and the files whose lines it
|
||||
serves; GET /logs/journal?unit=&lines= and /logs/file?path=&lines=
|
||||
answer the last lines, 200 by default and at most 1000, a file read
|
||||
from its last MiB. Units and files outside the lists are refused.
|
||||
* The new setting web.log_files lists the absolute paths of the log
|
||||
files, at most 32.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 19:00:00 +0100
|
||||
|
||||
vpn-router (0.29.0-1) unstable; urgency=medium
|
||||
|
||||
feature: vpn-config-edit set and remove take --restart, which applies the
|
||||
change with vpn-router-setup and waits for it.
|
||||
|
||||
* vpn-config-edit set --restart and remove --restart restart
|
||||
vpn-router-setup after writing the configuration and wait until it has
|
||||
applied the change; a failed run exits with status 1 and points to its
|
||||
journal. Bash completes --restart.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 17:00:00 +0100
|
||||
|
||||
vpn-router (0.28.0-1) unstable; urgency=medium
|
||||
|
||||
feature: bash completes the vpn-* commands, their options and the names they
|
||||
take.
|
||||
|
||||
* Bash completes the commands, options and arguments of vpn-router-user,
|
||||
vpn-router-group, vpn-router-token, vpn-config-edit, vpn-router-backup,
|
||||
vpn-router-ca and vpn-router-caddy, from
|
||||
/usr/share/bash-completion/completions; for root also the users,
|
||||
groups, issuing CAs, templates, settings and site-to-site connections.
|
||||
* The package recommends bash-completion.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 16:00:00 +0100
|
||||
|
||||
vpn-router (0.27.0-1) unstable; urgency=medium
|
||||
|
||||
feature: the web service is an OpenID Provider, which signs the directory's users in to other systems, the clients being service accounts with redirect URIs.
|
||||
feature: the web service is an OpenID Provider, which signs the directory's
|
||||
users in to other systems, the clients being service accounts with redirect
|
||||
URIs.
|
||||
|
||||
* oidc.enabled turns on the OpenID Provider at the issuer
|
||||
https://<wan.local_fqdn>:<https.web_port>, with discovery, JWKS,
|
||||
@@ -29,7 +448,8 @@ vpn-router (0.27.0-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
|
||||
in lowercase as the name, besides a user name; users and groups keep
|
||||
@@ -39,7 +459,8 @@ vpn-router (0.26.2-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.
|
||||
|
||||
@@ -47,7 +468,8 @@ vpn-router (0.26.1-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
|
||||
whose console login is on without a token is refused POST /shell, the
|
||||
@@ -71,7 +493,8 @@ vpn-router (0.26.0-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:
|
||||
POST /shell without a password gives a ticket while the user's last
|
||||
@@ -82,7 +505,8 @@ vpn-router (0.25.4-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"}.
|
||||
|
||||
@@ -90,7 +514,9 @@ vpn-router (0.25.3-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
|
||||
network larger than /31, such as 192.168.232.0/24, is refused with the
|
||||
@@ -100,7 +526,8 @@ vpn-router (0.25.2-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",
|
||||
"endpoint", "allowed_ips"}.
|
||||
@@ -109,7 +536,8 @@ vpn-router (0.25.1-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"}],
|
||||
which replaces the user's peers; keys are unique and IPs may not
|
||||
@@ -125,7 +553,8 @@ vpn-router (0.25.0-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
|
||||
refresh of the status shows clients that connected or left since.
|
||||
@@ -134,7 +563,9 @@ vpn-router (0.24.1-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,
|
||||
which of them setup runs, and wan.local_fqdn; PUT
|
||||
@@ -146,7 +577,9 @@ vpn-router (0.24.0-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
|
||||
switch, so the web service finds the POSIX accounts of the directory.
|
||||
@@ -157,7 +590,9 @@ vpn-router (0.23.2-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
|
||||
PUT /users/<name>/console with a token of vpn-router-token, switch a
|
||||
@@ -175,7 +610,9 @@ vpn-router (0.23.1-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).
|
||||
* POST /shell takes the password and the TOTP code and answers a
|
||||
@@ -187,7 +624,8 @@ vpn-router (0.23.0-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"},
|
||||
and the status page shows "Site to site: N of M established".
|
||||
@@ -196,7 +634,9 @@ vpn-router (0.22.1-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
|
||||
key usage; a template's extended_key_usage takes the built-in and the
|
||||
@@ -210,7 +650,8 @@ vpn-router (0.22.0-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
|
||||
document as web.status_content full shows it.
|
||||
@@ -219,7 +660,8 @@ vpn-router (0.21.5-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
|
||||
operation on a CA with a CRL failed with "Could not deserialize key
|
||||
@@ -229,7 +671,10 @@ vpn-router (0.21.4-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
|
||||
certificate issued with its revocation, are entries of the
|
||||
@@ -253,7 +698,8 @@ vpn-router (0.21.3-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
|
||||
warning that the root key is on the router.
|
||||
@@ -262,7 +708,8 @@ vpn-router (0.21.2-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
|
||||
validity, when the timer should have signed it again; a root CRL
|
||||
@@ -272,7 +719,10 @@ vpn-router (0.21.1-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
|
||||
server certificate and the users' certificates, s2s, which issues
|
||||
@@ -311,7 +761,9 @@ vpn-router (0.21.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 schedule (hourly, daily or weekly), the target (local or pbs),
|
||||
@@ -344,7 +796,8 @@ vpn-router (0.20.0-1) 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
|
||||
twice a day, and that apt-daily or unattended-upgrades may hold the
|
||||
@@ -363,7 +816,8 @@ vpn-router (0.19.1-2) 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
|
||||
minutes for an hour, 1 hour for a day, 1 day for a month and 1 month
|
||||
@@ -374,7 +828,8 @@ vpn-router (0.19.1-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
|
||||
description; vpn-router-setup updates the schema of an existing
|
||||
@@ -392,7 +847,9 @@ vpn-router (0.19.0-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
|
||||
take every internal service account, the accounts the router creates
|
||||
@@ -407,7 +864,8 @@ vpn-router (0.18.3-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, adds one with a password and an optional e-mail
|
||||
@@ -424,7 +882,9 @@ vpn-router (0.18.2-1) unstable; urgency=medium
|
||||
|
||||
vpn-router (0.18.1-1) unstable; urgency=medium
|
||||
|
||||
fix: vpn-router-setup owns only the creation of the metrics service's account, which is marked internal, and administrators add service accounts with vpn-router-user add --service and /users.
|
||||
fix: vpn-router-setup owns only the creation of the metrics service's account,
|
||||
which is marked internal, and administrators add service accounts with
|
||||
vpn-router-user add --service and /users.
|
||||
|
||||
* POST /users takes "service": true and adds a service account, which
|
||||
cannot sign in to /auth; vpn-router-user add takes --service.
|
||||
@@ -448,7 +908,8 @@ vpn-router (0.18.0-2) unstable; urgency=medium
|
||||
|
||||
vpn-router (0.18.0-1) unstable; urgency=medium
|
||||
|
||||
feature: site_to_site holds any number of named site-to-site connections, which /site-to-site lists, adds, changes and removes.
|
||||
feature: site_to_site holds any number of named site-to-site connections,
|
||||
which /site-to-site lists, adds, changes and removes.
|
||||
|
||||
* site_to_site maps a connection name to addrs, id, cidrs, local_cidrs,
|
||||
local_id_mode, psk and psk_file; it replaces remote and
|
||||
@@ -488,7 +949,8 @@ vpn-router (0.18.0-1) unstable; urgency=medium
|
||||
|
||||
vpn-router (0.17.0-1) unstable; urgency=medium
|
||||
|
||||
feature: /config shows the router's PostgreSQL clusters and their settings, and /postgresql their databases with their sizes.
|
||||
feature: /config shows the router's PostgreSQL clusters and their settings,
|
||||
and /postgresql their databases with their sizes.
|
||||
|
||||
* /config has a postgresql section: each cluster of pg_lsclusters with
|
||||
its version, name, port, status and data directory, and the main
|
||||
@@ -500,7 +962,8 @@ vpn-router (0.17.0-1) unstable; urgency=medium
|
||||
|
||||
vpn-router (0.16.0-1) unstable; urgency=medium
|
||||
|
||||
feature: the router keeps its metrics in PostgreSQL, serves them for an OpenTelemetry Collector and pushes them over OTLP.
|
||||
feature: the router keeps its metrics in PostgreSQL, serves them for an
|
||||
OpenTelemetry Collector and pushes them over OTLP.
|
||||
|
||||
* vpn-router-metrics takes a sample every minute: CPU, memory, free
|
||||
space of /, bytes per second on the external and the internal
|
||||
@@ -552,7 +1015,8 @@ vpn-router (0.14.1-1) unstable; urgency=medium
|
||||
|
||||
vpn-router (0.14.0-1) unstable; urgency=medium
|
||||
|
||||
feature: the status page shows the Client IP, and web.status_content sets how much it shows.
|
||||
feature: the status page shows the Client IP, and web.status_content sets how
|
||||
much it shows.
|
||||
|
||||
* / and /status show the Client IP, the address the request came from:
|
||||
the last X-Forwarded-For entry for a request from a trusted proxy, so
|
||||
@@ -567,7 +1031,8 @@ vpn-router (0.14.0-1) unstable; urgency=medium
|
||||
|
||||
vpn-router (0.13.0-1) unstable; urgency=medium
|
||||
|
||||
feature: Caddy serves the status page and the Router Manager over HTTPS with a certificate from Let's Encrypt.
|
||||
feature: Caddy serves the status page and the Router Manager over HTTPS with a
|
||||
certificate from Let's Encrypt.
|
||||
|
||||
* The package depends on caddy. While https.enabled is true, the
|
||||
default, and wan.local_fqdn is set, vpn-router-setup writes
|
||||
@@ -590,7 +1055,8 @@ vpn-router (0.13.0-1) unstable; urgency=medium
|
||||
|
||||
vpn-router (0.12.3-1) unstable; urgency=medium
|
||||
|
||||
fix: the web service listens on the internal address, the external address or both.
|
||||
fix: the web service listens on the internal address, the external address or
|
||||
both.
|
||||
|
||||
* web.listen in vpn-router.json sets the interfaces the web service
|
||||
listens on: internal, external or both, the default. It is read
|
||||
@@ -676,7 +1142,8 @@ vpn-router (0.9.2-1) unstable; urgency=medium
|
||||
|
||||
vpn-router (0.9.1-1) unstable; urgency=medium
|
||||
|
||||
fix: the web service waits for the PostgreSQL driver instead of failing at start.
|
||||
fix: the web service waits for the PostgreSQL driver instead of failing at
|
||||
start.
|
||||
|
||||
* The web service imports psycopg as the first step of its
|
||||
initialization, retrying every 15 seconds and logging the failure while
|
||||
@@ -706,7 +1173,8 @@ vpn-router (0.9.0-1) unstable; urgency=medium
|
||||
|
||||
vpn-router (0.8.0-1) unstable; urgency=medium
|
||||
|
||||
feature: the web service creates its key and database itself and shows its starting phase.
|
||||
feature: the web service creates its key and database itself and shows its
|
||||
starting phase.
|
||||
|
||||
* vpn-router-web makes its signing key and creates the refresh tokens'
|
||||
database on its first run, retrying every 15 seconds until PostgreSQL
|
||||
@@ -721,7 +1189,8 @@ vpn-router (0.8.0-1) unstable; urgency=medium
|
||||
|
||||
vpn-router (0.7.1-1) unstable; urgency=medium
|
||||
|
||||
fix: a failing user directory or token database no longer leaves the tunnels down.
|
||||
fix: a failing user directory or token database no longer leaves the tunnels
|
||||
down.
|
||||
|
||||
* vpn-router-setup brings up strongSwan and WireGuard before it provisions
|
||||
the user directory and the token database. A failure of either no
|
||||
@@ -731,7 +1200,8 @@ vpn-router (0.7.1-1) unstable; urgency=medium
|
||||
|
||||
vpn-router (0.7.0-1) unstable; urgency=medium
|
||||
|
||||
feature: users sign in to the web service, and only members of admins manage the router.
|
||||
feature: users sign in to the web service, and only members of admins manage
|
||||
the router.
|
||||
|
||||
* POST /auth signs a user in with the password, and the TOTP code for an
|
||||
enrolled user, and answers a 5 minute access token and an 8 hour refresh
|
||||
|
||||
@@ -38,20 +38,26 @@ Depends: ${misc:Depends},
|
||||
openvpn-auth-ldap,
|
||||
iproute2,
|
||||
systemd-resolved,
|
||||
dnsmasq-base,
|
||||
zstd
|
||||
Recommends: bash-completion
|
||||
Suggests: caddy,
|
||||
rclone,
|
||||
pdns-server,
|
||||
pdns-backend-pgsql,
|
||||
proxmox-backup-client-static
|
||||
Description: Linux VPN router with IPSec, road-warrior, OpenVPN and WireGuard support
|
||||
Description: Linux VPN router with IPSec, IKEv2, OpenVPN and WireGuard support
|
||||
Configures a Linux host or virtual machine as a VPN router: site-to-site
|
||||
IKEv2 IPSec (strongSwan swanctl) 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
|
||||
WireGuard, and the routing, NAT, MSS clamping and firewall rules a router
|
||||
needs. Its two-level CA, whose root may be kept offline, has issuing CAs for
|
||||
the VPN clients, for site-to-site and for general use, and publishes their
|
||||
certificates and CRLs. Caddy, installed by the package from the Ubuntu
|
||||
archive or from Caddy's repository, serves HTTPS, and an optional OpenID
|
||||
archive or from Caddy's repository, serves HTTPS and static web sites, whose
|
||||
files are uploaded through the web service or WebDAV, an optional PowerDNS
|
||||
name server answers for a zone of the sites, and an optional OpenID
|
||||
Provider signs the directory's users in to other systems. It keeps metrics of the
|
||||
router in PostgreSQL and serves them for an OpenTelemetry Collector, and
|
||||
makes scheduled backups to a local folder, copied to the cloud with rclone,
|
||||
|
||||
@@ -2,3 +2,4 @@ etc/vpn-router
|
||||
usr/lib/vpn-router
|
||||
usr/lib/vpn-router/vpnrouter_platforms
|
||||
usr/share/vpn-router/templates
|
||||
var/lib/vpn-router/sites
|
||||
@@ -11,19 +11,28 @@ src/usr/lib/vpn-router/vpnrouter_metrics.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_status.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_backup.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_pki.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_sites.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_dns.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/install-packages usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/webdav-auth usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/openvpn-same-user usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/openvpn-issuer usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/editor-file usr/lib/vpn-router/
|
||||
src/usr/bin/vpn-router-editor usr/bin/
|
||||
src/usr/sbin/vpn-router-user usr/sbin/
|
||||
src/usr/sbin/vpn-router-group usr/sbin/
|
||||
src/usr/sbin/vpn-router-role usr/sbin/
|
||||
src/usr/sbin/vpn-router-token usr/sbin/
|
||||
src/usr/sbin/vpn-config-edit usr/sbin/
|
||||
src/usr/sbin/vpn-router-backup usr/sbin/
|
||||
src/usr/sbin/vpn-router-ca usr/sbin/
|
||||
src/usr/sbin/vpn-router-caddy usr/sbin/
|
||||
src/usr/sbin/vpn-router-site usr/sbin/
|
||||
src/usr/lib/vpn-router/configure usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/docker-rules usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/setup usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/web usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_web/*.py usr/lib/vpn-router/vpnrouter_web/
|
||||
src/usr/lib/vpn-router/metrics usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/bootstrap usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/wait-for-libpq usr/lib/vpn-router/
|
||||
@@ -31,5 +40,6 @@ src/usr/lib/vpn-router/generate-config usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_platforms/*.py usr/lib/vpn-router/vpnrouter_platforms/
|
||||
src/usr/share/vpn-router/templates/* usr/share/vpn-router/templates/
|
||||
src/usr/share/vpn-router/sql/* usr/share/vpn-router/sql/
|
||||
src/usr/share/bash-completion/completions/*.bash usr/share/bash-completion/completions/
|
||||
src/usr/lib/systemd/system/caddy.service.d/vpn-router.conf usr/lib/systemd/system/caddy.service.d/
|
||||
src/usr/share/doc/vpn-router/vpn-router.json.example usr/share/doc/vpn-router/
|
||||
@@ -0,0 +1 @@
|
||||
usr/bin/vpn-router-editor usr/bin/vre
|
||||
@@ -71,9 +71,11 @@ case "$1" in
|
||||
# setting the forward policy all belong to vpn-router-setup, which does
|
||||
# them only once the configuration says this host is a router.
|
||||
|
||||
# The web service waits for libpq in the linker cache, which dpkg
|
||||
# refreshes after this script, so it is started without waiting for it.
|
||||
# The setup and web services wait for libpq in the linker cache, which
|
||||
# dpkg refreshes after this script, so they are started without waiting
|
||||
# for them.
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl --no-block restart vpn-router-setup.service >/dev/null 2>&1 || true
|
||||
systemctl --no-block restart vpn-router-web.service >/dev/null 2>&1 || true
|
||||
fi
|
||||
;;
|
||||
|
||||
@@ -11,10 +11,10 @@ strip_ufw_blocks() {
|
||||
|
||||
tmp="$(mktemp)"
|
||||
awk '
|
||||
/^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|BOOTSTRAP|MANAGER|HTTPS|METRICS|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES START$/ {
|
||||
/^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|BOOTSTRAP|MANAGER|HTTPS|METRICS|SITES|DNS|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES START$/ {
|
||||
skip = 1; pending = 0; next
|
||||
}
|
||||
/^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|BOOTSTRAP|MANAGER|HTTPS|METRICS|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES END$/ {
|
||||
/^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|BOOTSTRAP|MANAGER|HTTPS|METRICS|SITES|DNS|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES END$/ {
|
||||
skip = 0; next
|
||||
}
|
||||
skip { next }
|
||||
@@ -46,10 +46,12 @@ case "$1" in
|
||||
rm -f /etc/swanctl/conf.d/site-to-site.conf \
|
||||
/etc/swanctl/conf.d/road-warrior.conf \
|
||||
/etc/systemd/resolved.conf.d/p2s-forwarder.conf \
|
||||
/etc/vpn-router/dnsmasq.conf \
|
||||
/etc/wireguard/wg0.conf \
|
||||
/etc/openvpn/server/vpn-router.conf \
|
||||
/etc/openvpn/server/vpn-router-ldap.conf \
|
||||
/etc/caddy/vpn-router.Caddyfile \
|
||||
/etc/powerdns/pdns.d/vpn-router.conf \
|
||||
/etc/vpn-router/vpn-router.json \
|
||||
/etc/vpn-router/web.key \
|
||||
/etc/vpn-router/oidc.key \
|
||||
@@ -63,7 +65,7 @@ case "$1" in
|
||||
# The name service no longer looks up the console accounts of the router's directory.
|
||||
sed -i '/^passwd:/s/ ldap\b//' /etc/nsswitch.conf
|
||||
rm -rf /var/lib/vpn-router/backup/staging /var/lib/vpn-router/backup/restore \
|
||||
/var/lib/vpn-router/pki-publish /etc/caddy/vpn-router-pki \
|
||||
/var/lib/vpn-router/pki-publish /etc/caddy/vpn-router-pki /etc/caddy/vpn-router-sites \
|
||||
/etc/openvpn/server/vpn-router-ca.pem /etc/openvpn/server/vpn-router-crl.pem
|
||||
rmdir --ignore-fail-on-non-empty /var/lib/vpn-router/backup /var/lib/vpn-router 2>/dev/null || true
|
||||
|
||||
@@ -74,12 +76,23 @@ case "$1" in
|
||||
fi
|
||||
rm -f /etc/vpn-router/database.secret
|
||||
|
||||
# Key material and users are not package state: /etc/vpn-router/pki,
|
||||
# The databases and roles of the web sites and of the name server, when the package created them.
|
||||
for database in vpn-router-sites vpn-router-dns; do
|
||||
secret="/etc/vpn-router/${database#vpn-router-}-database.secret"
|
||||
if [ -f "$secret" ] && command -v psql >/dev/null 2>&1; then
|
||||
runuser -u postgres -- psql -X -q -c "DROP DATABASE IF EXISTS \"$database\"" || true
|
||||
runuser -u postgres -- psql -X -q -c "DROP ROLE IF EXISTS \"$database\"" || true
|
||||
fi
|
||||
rm -f "$secret"
|
||||
done
|
||||
|
||||
# Key material, users and content are not package state: /etc/vpn-router/pki,
|
||||
# /etc/vpn-router/ldap, /etc/vpn-router/rclone.conf, the user directory
|
||||
# in slapd, the WireGuard key pair, everything under /etc/swanctl, the
|
||||
# backups and what a restore replaced are left in place. Caddy stays
|
||||
# installed, so its repository, which vpn-router-caddy configures, stays
|
||||
# too.
|
||||
# files of the web sites in /var/lib/vpn-router/sites, the backups and
|
||||
# what a restore replaced are left in place. Caddy, rclone and PowerDNS
|
||||
# stay installed, so Caddy's repository, which vpn-router-caddy
|
||||
# configures, stays too.
|
||||
rmdir --ignore-fail-on-non-empty /etc/vpn-router 2>/dev/null || true
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
dh $@
|
||||
|
||||
override_dh_installsystemd:
|
||||
dh_installsystemd --name=vpn-router-setup
|
||||
dh_installsystemd --name=vpn-router-setup --no-start
|
||||
dh_installsystemd --name=vpn-router-docker
|
||||
dh_installsystemd --name=vpn-router-web --no-start
|
||||
dh_installsystemd --name=vpn-router-metrics --no-enable --no-start
|
||||
@@ -11,3 +11,6 @@ override_dh_installsystemd:
|
||||
dh_installsystemd --name=vpn-router-backup --no-enable --no-start
|
||||
dh_installsystemd --name=vpn-router-pki --no-enable --no-start
|
||||
dh_installsystemd --name=vpn-router-caddy --no-enable --no-start
|
||||
dh_installsystemd --name=vpn-router-webdav --no-enable --no-start
|
||||
dh_installsystemd --name=vpn-router-resolver --no-enable --no-start
|
||||
dh_installsystemd --name=vpn-router-install@ --no-enable --no-start
|
||||
@@ -4,7 +4,7 @@ Choices: none, generic, azure, gcp
|
||||
Default: none
|
||||
Description: Platform:
|
||||
Which platform this router runs on. The choice loads platform-specific
|
||||
additions, such as the provider DNS resolver for road-warrior clients.
|
||||
additions, such as the provider DNS resolver for IKEv2 clients.
|
||||
.
|
||||
none defers configuration entirely: the package installs its files and
|
||||
changes nothing on the machine. Choose it when the router will be configured
|
||||
@@ -58,7 +58,7 @@ Type: string
|
||||
Default:
|
||||
Description: Local router FQDN:
|
||||
Fully-qualified domain name of this router (for example
|
||||
router.example.com). Used as the road-warrior server identity and
|
||||
router.example.com). Used as the IKEv2 server identity and
|
||||
certificate common name.
|
||||
|
||||
Template: vpn-router/local_id_mode
|
||||
@@ -122,15 +122,15 @@ Description: Pre-shared key (PSK):
|
||||
Template: vpn-router/p2s_enabled
|
||||
Type: boolean
|
||||
Default: false
|
||||
Description: Enable road-warrior (P2S) access?
|
||||
Description: Enable IKEv2 access?
|
||||
If enabled, this router accepts IKEv2 EAP-TLS connections from individual
|
||||
clients, with certificates of the router's P2S issuing CA.
|
||||
|
||||
Template: vpn-router/p2s_address_pool
|
||||
Type: string
|
||||
Default:
|
||||
Description: Road-warrior address pool:
|
||||
CIDR block assigned to road-warrior clients (for example 172.16.0.0/24).
|
||||
Description: IKEv2 address pool:
|
||||
CIDR block assigned to IKEv2 clients (for example 172.16.0.0/24).
|
||||
|
||||
Template: vpn-router/pki_root_name
|
||||
Type: string
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=VPN Router installation of the packages of %i
|
||||
Documentation=file:/usr/share/doc/vpn-router/README.md
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/lib/vpn-router/install-packages %i
|
||||
TimeoutStartSec=20min
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=VPN Router DNS resolver of the remote access clients
|
||||
Documentation=file:/usr/share/doc/vpn-router/README.md
|
||||
After=network-online.target systemd-resolved.service
|
||||
Wants=network-online.target
|
||||
ConditionPathExists=/etc/vpn-router/dnsmasq.conf
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/sbin/dnsmasq --keep-in-foreground --pid-file= --log-facility=- --conf-file=/etc/vpn-router/dnsmasq.conf
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -6,6 +6,7 @@ After=network-online.target docker.service postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStartPre=/usr/lib/vpn-router/wait-for-libpq
|
||||
ExecStart=/usr/lib/vpn-router/setup
|
||||
RemainAfterExit=yes
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
RuntimeDirectory=vpn-router
|
||||
RuntimeDirectoryPreserve=yes
|
||||
ReadWritePaths=-/etc/vpn-router -/etc/swanctl/x509crl -/etc/openvpn/server -/var/lib/vpn-router/pki-publish
|
||||
ReadWritePaths=-/etc/vpn-router -/etc/swanctl/x509crl -/etc/openvpn/server -/var/lib/vpn-router/pki-publish -/var/lib/vpn-router/sites
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
[Unit]
|
||||
Description=VPN Router WebDAV of the web sites' folders
|
||||
Documentation=file:/usr/share/doc/vpn-router/README.md
|
||||
After=network-online.target slapd.service postgresql.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/rclone serve webdav --addr 127.0.0.1:7446 --auth-proxy /usr/lib/vpn-router/webdav-auth --config /dev/null --cache-dir /var/cache/vpn-router-webdav --vfs-cache-mode writes --umask 022
|
||||
Restart=on-failure
|
||||
RestartSec=15
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/var/lib/vpn-router/sites
|
||||
CacheDirectory=vpn-router-webdav
|
||||
RuntimeDirectory=vpn-router-webdav
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
+16
-11
@@ -8,9 +8,11 @@ case "$(uname -s)" in
|
||||
*) printf 'ERROR: unsupported platform %s\n' "$(uname -s)" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
OWNER="${OWNER:-slawek}"
|
||||
OWNER="${OWNER:-koszewscy}"
|
||||
SERVER_URL="${SERVER_URL:-https://gitea.koszewscy.waw.pl}"
|
||||
PACKAGES_TOKEN="${PACKAGES_TOKEN:-$(cat ~/.gitea-packages-token)}"
|
||||
# The Ubuntu releases whose distribution of the repository gets the package, in its component main.
|
||||
DISTRIBUTIONS="${DISTRIBUTIONS:-noble resolute}"
|
||||
|
||||
# Reads the changelog copied into the builder image, so no bind mount is needed.
|
||||
changelog_field() {
|
||||
@@ -27,14 +29,17 @@ if [ ! -f "$DEB" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
STATUS="$(curl -s -o /dev/null -w '%{http_code}' --user "$OWNER:$PACKAGES_TOKEN" \
|
||||
"$SERVER_URL/api/v1/packages/$OWNER/debian/$PACKAGE/$VERSION")"
|
||||
case "$STATUS" in
|
||||
200) printf '%s %s is already published, skipping.\n' "$PACKAGE" "$VERSION"; exit 0 ;;
|
||||
404) ;;
|
||||
*) printf 'ERROR: version check returned HTTP %s.\n' "$STATUS" >&2; exit 1 ;;
|
||||
esac
|
||||
BODY="$(mktemp)"
|
||||
trap 'rm -f "$BODY"' EXIT
|
||||
|
||||
curl --fail-with-body --user "$OWNER:$PACKAGES_TOKEN" \
|
||||
--upload-file "$DEB" \
|
||||
"$SERVER_URL/api/packages/$OWNER/debian/pool/noble/main/upload"
|
||||
# Gitea answers 409 for a version that is in the distribution already.
|
||||
for DISTRIBUTION in $DISTRIBUTIONS; do
|
||||
STATUS="$(curl -s -o "$BODY" -w '%{http_code}' --user "$OWNER:$PACKAGES_TOKEN" \
|
||||
--upload-file "$DEB" \
|
||||
"$SERVER_URL/api/packages/$OWNER/debian/pool/$DISTRIBUTION/main/upload")"
|
||||
case "$STATUS" in
|
||||
2??) printf '%s %s is published in %s/main.\n' "$PACKAGE" "$VERSION" "$DISTRIBUTION" ;;
|
||||
409) printf '%s %s is already in %s/main, skipping.\n' "$PACKAGE" "$VERSION" "$DISTRIBUTION" ;;
|
||||
*) printf 'ERROR: the upload to %s/main returned HTTP %s: %s\n' "$DISTRIBUTION" "$STATUS" "$(cat "$BODY")" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/python3
|
||||
"""Open files in the editor of the Router Manager's Shell page, from the shell of that page; vre is its short name.
|
||||
|
||||
Each file is named to the router's web service over SOCKET with the shell's session,
|
||||
$VPN_ROUTER_SHELL, which the web service sets in the shells it starts; the Shell page then shows
|
||||
the file in a tab of its editor, where it is read and saved. The command returns at once. A
|
||||
relative path is taken from the current directory, and a file that does not exist is created when
|
||||
it is saved. The exit status is 0 when every file was opened, 1 when the web service refused one,
|
||||
with its reason, and 2 outside the Router Manager's shell or for an invalid request. -h or --help
|
||||
shows the usage.
|
||||
|
||||
vre FILE...
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
|
||||
#: The socket of the router's web service that opens files in the editor of a shell.
|
||||
SOCKET = '/run/vpn-router/editor.sock'
|
||||
|
||||
#: Seconds to wait for the web service's answer.
|
||||
TIMEOUT = 10
|
||||
|
||||
|
||||
class Parser(argparse.ArgumentParser):
|
||||
"""An argument parser whose errors end with status 2."""
|
||||
|
||||
def error(self, message):
|
||||
print(f'{self.prog}: {message}; see {self.prog} --help', file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def opened(session, path):
|
||||
"""Ask the web service to open the file in the editor of the session; None, or the reason it refused."""
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
||||
connection.settimeout(TIMEOUT)
|
||||
connection.connect(SOCKET)
|
||||
connection.sendall(json.dumps({'session': session, 'path': path}).encode() + b'\n')
|
||||
answer = json.loads(connection.makefile('rb').readline() or b'{}')
|
||||
return None if answer.get('ok') is True else str(answer.get('error', 'no answer'))
|
||||
|
||||
|
||||
def main(argv):
|
||||
prog = os.path.basename(sys.argv[0])
|
||||
parser = Parser(prog=prog, description="Open files in the editor of the Router Manager's Shell page.")
|
||||
parser.add_argument('files', nargs='+', metavar='FILE', help='a file to open')
|
||||
options = parser.parse_args(argv)
|
||||
session = os.environ.get('VPN_ROUTER_SHELL', '')
|
||||
if not session:
|
||||
print(f'{prog}: {prog} works only in the shell of the Router Manager', file=sys.stderr)
|
||||
return 2
|
||||
status = 0
|
||||
for name in options.files:
|
||||
path = os.path.abspath(os.path.expanduser(name))
|
||||
try:
|
||||
reason = opened(session, path)
|
||||
except (OSError, ValueError) as exc:
|
||||
reason = f'the web service cannot be reached: {exc}'
|
||||
if reason:
|
||||
print(f'{prog}: {path}: {reason}', file=sys.stderr)
|
||||
status = 1
|
||||
else:
|
||||
print(f'Opened {path} in the editor of the Shell page.')
|
||||
return status
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -8,6 +8,7 @@ the logs.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
PORT = 17081
|
||||
@@ -42,14 +43,28 @@ def report():
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
body = report().encode()
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'text/plain; charset=utf-8')
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
self.send_header('Refresh', '10')
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
try:
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'text/plain; charset=utf-8')
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
self.send_header('Refresh', '10')
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
except ConnectionError:
|
||||
# The client went away before the answer was written.
|
||||
self.close_connection = True
|
||||
|
||||
|
||||
def failed(request, client_address):
|
||||
"""Log a failed request in one line, and nothing when the client went away."""
|
||||
failure = sys.exc_info()[1]
|
||||
if not isinstance(failure, ConnectionError):
|
||||
print(f'a request from {client_address[0]} failed: {type(failure).__name__}: {failure}', file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
ThreadingHTTPServer(('', PORT), Handler).serve_forever()
|
||||
sys.excepthook = lambda kind, value, _: print(f'failed: {kind.__name__}: {value}', file=sys.stderr, flush=True)
|
||||
server = ThreadingHTTPServer(('', PORT), Handler)
|
||||
server.handle_error = failed
|
||||
server.serve_forever()
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/python3
|
||||
"""Read or write a file of the editor of the Router Manager's Shell page, as the user it runs as.
|
||||
|
||||
The web service runs it with systemd-run --uid=<console account> --pipe, outside its sandbox, so the
|
||||
file is read and written with the rights of the shell's user. The version of a content is the
|
||||
hexadecimal SHA-256 of its bytes; a missing file has the version of the empty content.
|
||||
|
||||
editor-file read PATH print the content of the file; nothing for a missing file
|
||||
editor-file write PATH VERSION replace the content with standard input when the file still has
|
||||
VERSION, writing in place, so its owner, mode and links stay;
|
||||
a missing file is created
|
||||
|
||||
Only regular files of at most MAX_BYTES bytes of UTF-8 text are read and written. The exit status
|
||||
is 0 on success, 2 for a file that is refused, with the reason on standard error, 3 when the file
|
||||
does not have VERSION any more, and 1 for any other failure.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
#: The largest file the editor opens, in bytes.
|
||||
MAX_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
"""A file the editor does not open or write; status is the exit status."""
|
||||
|
||||
def __init__(self, message, status=2):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
||||
|
||||
def text(data, path):
|
||||
"""The bytes when they are UTF-8 text of at most MAX_BYTES; Refused otherwise."""
|
||||
if len(data) > MAX_BYTES:
|
||||
raise Refused(f'{path} is larger than {MAX_BYTES // 1024} KiB')
|
||||
try:
|
||||
data.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
raise Refused(f'{path} is not UTF-8 text')
|
||||
return data
|
||||
|
||||
|
||||
def current(path):
|
||||
"""The content of the file, b'' when it does not exist; Refused for a file that is not a regular one."""
|
||||
if not os.path.lexists(path):
|
||||
return b''
|
||||
if not os.path.isfile(path):
|
||||
raise Refused(f'{path} is not a regular file')
|
||||
with open(path, 'rb') as handle:
|
||||
return text(handle.read(MAX_BYTES + 1), path)
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) < 2 or (argv[0], len(argv)) not in (('read', 2), ('write', 3)):
|
||||
print('usage: editor-file read PATH | editor-file write PATH VERSION', file=sys.stderr)
|
||||
return 2
|
||||
path = argv[1]
|
||||
try:
|
||||
if argv[0] == 'read':
|
||||
sys.stdout.buffer.write(current(path))
|
||||
return 0
|
||||
content = text(sys.stdin.buffer.read(MAX_BYTES + 1), path)
|
||||
if hashlib.sha256(current(path)).hexdigest() != argv[2]:
|
||||
raise Refused(f'{path} changed since it was read', 3)
|
||||
with open(path, 'r+b' if os.path.lexists(path) else 'xb') as handle:
|
||||
handle.write(content)
|
||||
handle.truncate()
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
except Refused as refused:
|
||||
print(refused, file=sys.stderr)
|
||||
return refused.status
|
||||
except OSError as exc:
|
||||
print(f'{exc.filename or path}: {exc.strerror}', file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/python3
|
||||
"""Install the packages of a feature from the Ubuntu archive, then restart vpn-router-setup without waiting.
|
||||
|
||||
Run by vpn-router-install@<feature>.service, which vpn-router-setup starts when the feature is on and its packages are
|
||||
missing: webdav (rclone) and dns (PowerDNS with its gpgsql backend). Recommended packages are left out, so PowerDNS
|
||||
comes without its bind backend. A failure is a message on standard error and the exit status 1.
|
||||
|
||||
install-packages FEATURE
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
#: The packages of each feature.
|
||||
FEATURES = {'webdav': ['rclone'], 'dns': ['pdns-server', 'pdns-backend-pgsql']}
|
||||
|
||||
#: apt-get, waiting up to ten minutes for dpkg's lock, which a package installation that runs setup holds.
|
||||
APT = ['apt-get', '-o', 'DPkg::Lock::Timeout=600']
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 2 or sys.argv[1] not in FEATURES:
|
||||
sys.exit(f'usage: install-packages {"|".join(FEATURES)}')
|
||||
environment = dict(os.environ, DEBIAN_FRONTEND='noninteractive')
|
||||
for args in ([*APT, 'update'], [*APT, '-y', '--no-install-recommends', '-o', 'Dpkg::Options::=--force-confdef',
|
||||
'-o', 'Dpkg::Options::=--force-confold', 'install', *FEATURES[sys.argv[1]]]):
|
||||
print(f'$ {" ".join(args)}', flush=True)
|
||||
if subprocess.run(args, env=environment).returncode != 0:
|
||||
sys.exit(f'install-packages: {args[0]} failed')
|
||||
subprocess.run(['systemctl', 'restart', '--no-block', 'vpn-router-setup.service'])
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -7,7 +7,7 @@ disables the unit.
|
||||
Every SAMPLE_SECONDS, on the minute, a sample is taken with psutil: the CPU utilization since the
|
||||
previous sample, the memory used and in total, the free and the total space of /, the bytes
|
||||
received and sent per second on the external and the internal interface, and the load averages;
|
||||
and the VPN clients of each enabled VPN system (vpnrouter_status): road-warrior (p2s) and
|
||||
and the VPN clients of each enabled VPN system (vpnrouter_status): IKEv2 (p2s) and
|
||||
OpenVPN clients and WireGuard peers with a recent handshake; and of each site-to-site connection
|
||||
that is set up, whether it is up, the bytes and packets per second through its child SAs since
|
||||
the previous sample, and how long its IKE SA has been up. The interfaces, the enabled systems and
|
||||
@@ -405,15 +405,19 @@ class Handler(BaseHTTPRequestHandler):
|
||||
registry = CollectorRegistry()
|
||||
registry.register(Sample(current))
|
||||
body, kind, code = generate_latest(registry), CONTENT_TYPE_LATEST, 200
|
||||
self.send_response(code)
|
||||
self.send_header('Content-Type', kind)
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
for name, value in extra:
|
||||
self.send_header(name, value)
|
||||
self.end_headers()
|
||||
if send_body:
|
||||
self.wfile.write(body)
|
||||
try:
|
||||
self.send_response(code)
|
||||
self.send_header('Content-Type', kind)
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
for name, value in extra:
|
||||
self.send_header(name, value)
|
||||
self.end_headers()
|
||||
if send_body:
|
||||
self.wfile.write(body)
|
||||
except ConnectionError:
|
||||
# The client went away before the answer was written.
|
||||
self.close_connection = True
|
||||
|
||||
def do_GET(self):
|
||||
self.answer(True)
|
||||
|
||||
@@ -7,7 +7,7 @@ and service reloads. Idempotent - a second run changes nothing.
|
||||
|
||||
Until a run has succeeded (SETUP_DONE), every run starts the bootstrap
|
||||
services, BOOTSTRAP_UNITS, which show the progress and the failure recorded in
|
||||
SETUP_STATE on BOOTSTRAP_PORTS, and the firewall opens those ports on the
|
||||
SETUP_STATE on vpnrouter.BOOTSTRAP_PORTS, and the firewall opens those ports on the
|
||||
internal interface. The first successful run writes SETUP_DONE, closes the
|
||||
ports and stops the bootstrap services; a failed run leaves them running.
|
||||
|
||||
@@ -33,15 +33,18 @@ sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
|
||||
import ldap
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_auth
|
||||
import vpnrouter_dns
|
||||
import vpnrouter_ldap
|
||||
import vpnrouter_pki
|
||||
import vpnrouter_sites
|
||||
import vpnrouter_users
|
||||
from vpnrouter import fail, warn
|
||||
|
||||
BEFORE_RULES = pathlib.Path('/etc/ufw/before.rules')
|
||||
UFW_DEFAULTS = pathlib.Path('/etc/default/ufw')
|
||||
WG_DIR = pathlib.Path('/etc/wireguard')
|
||||
WEB_STATE = pathlib.Path('/run/vpn-router/web.json')
|
||||
WEB_STATE = vpnrouter.WEB_STATE
|
||||
|
||||
#: The state, the current step and the failure of the last run, which the bootstrap services show.
|
||||
SETUP_STATE = pathlib.Path('/run/vpn-router/setup.json')
|
||||
@@ -49,18 +52,12 @@ SETUP_STATE = pathlib.Path('/run/vpn-router/setup.json')
|
||||
#: Written once a run has succeeded; until then setup runs the bootstrap services and opens their ports.
|
||||
SETUP_DONE = pathlib.Path('/var/lib/vpn-router/setup.done')
|
||||
|
||||
#: The units of the bootstrap services, and their ports: the Router Manager's and the router's.
|
||||
#: The units of the bootstrap services, whose ports are vpnrouter.BOOTSTRAP_PORTS.
|
||||
BOOTSTRAP_UNITS = ('vpn-router-bootstrap.service', 'vpn-router-manager-bootstrap.service')
|
||||
BOOTSTRAP_PORTS = (17080, 17081)
|
||||
|
||||
#: The Router Manager, vpn-router-manager, when it is installed, and its settings.
|
||||
MANAGER = pathlib.Path('/usr/lib/vpn-router-manager/manager')
|
||||
MANAGER_CONFIG = pathlib.Path('/etc/vpn-router-manager/manager.json')
|
||||
|
||||
#: The default admin of the Router Manager, {"username", "password"}, which its package writes from
|
||||
#: its debconf answers; setup creates the account and removes the file.
|
||||
MANAGER_ADMIN = pathlib.Path('/etc/vpn-router-manager/admin.json')
|
||||
MANAGER_PORT = 7080
|
||||
|
||||
#: The password of the metrics service account, which setup makes and keeps (root only).
|
||||
METRICS_SECRET = pathlib.Path('/etc/vpn-router/metrics.secret')
|
||||
@@ -77,9 +74,22 @@ CADDY_GROUP = 'caddy'
|
||||
#: The unit that installs Caddy from https.caddy_source with vpn-router-caddy, outside setup.
|
||||
CADDY_INSTALL_UNIT = 'vpn-router-caddy.service'
|
||||
|
||||
#: The certificates and keys of the issuing CAs behind Caddy's ACME server, <name>/cert.pem and key.pem.
|
||||
#: The certificates and keys of the issuing CAs behind Caddy's ACME server and the sites of tls ca, <name>/cert.pem and
|
||||
#: key.pem.
|
||||
CADDY_PKI_DIR = pathlib.Path('/etc/caddy/vpn-router-pki')
|
||||
|
||||
#: The certificates and keys of the sites of tls file, <site>/cert.pem and key.pem.
|
||||
CADDY_SITES_DIR = pathlib.Path('/etc/caddy/vpn-router-sites')
|
||||
|
||||
#: The unit of dnsmasq, the resolver of the remote access clients.
|
||||
RESOLVER_UNIT = 'vpn-router-resolver.service'
|
||||
|
||||
#: The unit of rclone's WebDAV server of the sites' folders.
|
||||
WEBDAV_UNIT = 'vpn-router-webdav.service'
|
||||
|
||||
#: The unit that installs the packages of a feature with install-packages, outside setup; {} is the feature.
|
||||
INSTALL_UNIT = 'vpn-router-install@{}.service'
|
||||
|
||||
#: The timer of vpn-router-ca maintain, which keeps the CRLs fresh.
|
||||
PKI_TIMER = 'vpn-router-pki.timer'
|
||||
|
||||
@@ -107,6 +117,8 @@ BLOCKS = (
|
||||
('# MANAGER RULES START', '# MANAGER RULES END'),
|
||||
('# HTTPS RULES START', '# HTTPS RULES END'),
|
||||
('# METRICS RULES START', '# METRICS RULES END'),
|
||||
('# SITES RULES START', '# SITES RULES END'),
|
||||
('# DNS RULES START', '# DNS RULES END'),
|
||||
('# ROUTER FORWARD RULES START', '# ROUTER FORWARD RULES END'),
|
||||
('# ROUTER NAT RULES START', '# ROUTER NAT RULES END'),
|
||||
('# ROUTER MSS RULES START', '# ROUTER MSS RULES END'),
|
||||
@@ -194,15 +206,6 @@ def is_configured(ctx):
|
||||
vpnrouter.openvpn_ready(ctx)))
|
||||
|
||||
|
||||
def manager_port():
|
||||
"""The port of the Router Manager: port in its settings, or MANAGER_PORT when it is not set or not a port."""
|
||||
try:
|
||||
port = json.loads(MANAGER_CONFIG.read_text(encoding='utf-8')).get('port')
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return MANAGER_PORT
|
||||
return port if isinstance(port, int) and not isinstance(port, bool) and 1 <= port <= 65535 else MANAGER_PORT
|
||||
|
||||
|
||||
def filter_block(ctx):
|
||||
lines = [
|
||||
'',
|
||||
@@ -240,11 +243,13 @@ def filter_block(ctx):
|
||||
lines += ['# OPENVPN RULES END']
|
||||
|
||||
pools = [pool for ready, pool in ((vpnrouter.p2s_ready(ctx), ctx['p2s_address_pool']),
|
||||
(vpnrouter.openvpn_ready(ctx), ctx['openvpn_pool'])) if ready]
|
||||
if pools and ctx['local_addr_list']:
|
||||
(vpnrouter.openvpn_ready(ctx), ctx['openvpn_pool']),
|
||||
(vpnrouter.wg_ready(ctx), ctx['wg_network'])) if ready]
|
||||
# The remote access clients' resolver answers on the addresses of p2s.dns_listen.
|
||||
if pools and ctx['p2s_resolver_addrs']:
|
||||
lines += ['', '# P2S DNS RULES START']
|
||||
for pool in pools:
|
||||
for address in ctx['local_addr_list']:
|
||||
for address in ctx['p2s_resolver_addrs']:
|
||||
lines += [
|
||||
f'-A ufw-before-input -s {pool} -d {address} -p udp --dport 53 -j ACCEPT',
|
||||
f'-A ufw-before-input -s {pool} -d {address} -p tcp --dport 53 -j ACCEPT',
|
||||
@@ -265,12 +270,13 @@ def filter_block(ctx):
|
||||
lines += [
|
||||
'',
|
||||
'# BOOTSTRAP RULES START',
|
||||
*(f'-A ufw-before-input -i {ctx["int_iface"]} -p tcp --dport {port} -j ACCEPT' for port in BOOTSTRAP_PORTS),
|
||||
*(f'-A ufw-before-input -i {ctx["int_iface"]} -p tcp --dport {port} -j ACCEPT'
|
||||
for port in vpnrouter.BOOTSTRAP_PORTS),
|
||||
'# BOOTSTRAP RULES END',
|
||||
]
|
||||
|
||||
if MANAGER.exists() and interfaces:
|
||||
port = manager_port()
|
||||
if vpnrouter.MANAGER.exists() and interfaces:
|
||||
port = vpnrouter.manager_port()
|
||||
lines += [
|
||||
'',
|
||||
'# MANAGER RULES START',
|
||||
@@ -292,8 +298,9 @@ def filter_block(ctx):
|
||||
publish = ctx['pki_publish']
|
||||
ports = [port for wanted, port in ((ctx['web_enabled'] or 'https' in publish or ctx['pki_acme'],
|
||||
ctx['https_web_port']),
|
||||
(MANAGER.exists(), ctx['https_manager_port']),
|
||||
(vpnrouter.MANAGER.exists(), ctx['https_manager_port']),
|
||||
(vpnrouter.metrics_ready(ctx), ctx['metrics_https_port']),
|
||||
(vpnrouter.webdav_ready(ctx), ctx['webdav_port']),
|
||||
('http-80' in publish, vpnrouter.ACME_HTTP_PORT),
|
||||
('https-default' in publish, vpnrouter.HTTPS_DEFAULT_PORT)) if wanted]
|
||||
lines += [
|
||||
@@ -305,6 +312,32 @@ def filter_block(ctx):
|
||||
'# HTTPS RULES END',
|
||||
]
|
||||
|
||||
# Each site's port is open on the interfaces of its listen; a site with a certificate of Let's Encrypt needs the HTTP
|
||||
# challenge on the external interface.
|
||||
by_listen = {'internal': [ctx['int_iface']], 'external': [ctx['wan_iface']],
|
||||
'both': [ctx['int_iface'], ctx['wan_iface']]}
|
||||
sites = ctx['sites'] or []
|
||||
rules = sorted({(name, site['port']) for site in sites for name in by_listen[site['listen']] if name})
|
||||
if ctx['wan_iface'] and any(site['tls'] == 'acme' for site in sites):
|
||||
rules = sorted(set(rules) | {(ctx['wan_iface'], vpnrouter.ACME_HTTP_PORT)})
|
||||
if rules:
|
||||
lines += [
|
||||
'',
|
||||
'# SITES RULES START',
|
||||
*(f'-A ufw-before-input -i {name} -p tcp --dport {port} -j ACCEPT' for name, port in rules),
|
||||
'# SITES RULES END',
|
||||
]
|
||||
|
||||
dns_interfaces = [name for name in by_listen[ctx['dns_listen']] if name] if vpnrouter.dns_ready(ctx) else []
|
||||
if dns_interfaces:
|
||||
lines += [
|
||||
'',
|
||||
'# DNS RULES START',
|
||||
*(f'-A ufw-before-input -i {name} -p {protocol} --dport 53 -j ACCEPT'
|
||||
for name in dns_interfaces for protocol in ('udp', 'tcp')),
|
||||
'# DNS RULES END',
|
||||
]
|
||||
|
||||
if vpnrouter.routing_ready(ctx):
|
||||
lines += ['', '# ROUTER FORWARD RULES START']
|
||||
for subnet in ctx['local_cidr_list']:
|
||||
@@ -640,22 +673,50 @@ def manage_openvpn(ctx, changed):
|
||||
run(['systemctl', 'restart', unit], check=False)
|
||||
|
||||
|
||||
def manage_caddy(cfg, ctx):
|
||||
"""Run Caddy, the HTTPS proxy of the web service, the Router Manager and the metrics service, while HTTPS is ready, and stop it when not.
|
||||
def caddy_address(host, port, tls):
|
||||
"""The address of a site block of the Caddyfile: the host, an IPv6 address in brackets, and the port, with http://
|
||||
for a plain HTTP site; no host answers any name."""
|
||||
host = f'[{host}]' if ':' in host else host
|
||||
return f'{"http://" if tls == "none" else ""}{host}:{port}'
|
||||
|
||||
CADDYFILE serves the web service on https.web_port, while the manager is installed the
|
||||
manager on https.manager_port, and while metrics are enabled the metrics service on
|
||||
metrics.https_port, all for wan.local_fqdn with a certificate from Let's Encrypt, and all
|
||||
calling the service on its loopback address. It also serves the CA files of
|
||||
vpnrouter_pki.PUBLISH_DIR at /pki/ on the endpoints of pki.publish that are Caddy's, and an
|
||||
ACME server at /acme/<name>/ on https.web_port for each issuing CA with acme and a
|
||||
certificate, whose certificate and key are copied to CADDY_PKI_DIR.
|
||||
|
||||
def manage_resolver(changed):
|
||||
"""Run dnsmasq, the resolver of the remote access clients, while configure wrote its configuration, restarting it when
|
||||
that changed, and stop it when not. It starts after systemd-resolved has left the addresses it takes."""
|
||||
if not vpnrouter.RESOLVER_CONF.exists():
|
||||
if unit_is_active(RESOLVER_UNIT):
|
||||
run(['systemctl', 'disable', '--now', RESOLVER_UNIT], check=False)
|
||||
return
|
||||
if not unit_is_active(RESOLVER_UNIT):
|
||||
run(['systemctl', 'enable', '--now', RESOLVER_UNIT], check=False)
|
||||
elif 'resolver' in changed:
|
||||
run(['systemctl', 'restart', RESOLVER_UNIT], check=False)
|
||||
|
||||
|
||||
def manage_caddy(cfg, ctx):
|
||||
"""Run Caddy, the HTTPS proxy of the router's services and the server of the web sites, while either is wanted, and
|
||||
stop it when not.
|
||||
|
||||
While HTTPS is ready, CADDYFILE serves the web service on https.web_port, while the manager is installed the
|
||||
manager on https.manager_port, while metrics are enabled the metrics service on metrics.https_port, and while
|
||||
WebDAV is ready WebDAV on sites.webdav_port, all for wan.local_fqdn with a certificate from Let's Encrypt, and all
|
||||
calling the service on its loopback address. It also serves the CA files of vpnrouter_pki.PUBLISH_DIR at /pki/ on
|
||||
the endpoints of pki.publish that are Caddy's, and an ACME server at /acme/<name>/ on https.web_port for each
|
||||
issuing CA with acme and a certificate.
|
||||
|
||||
It serves each site of ctx's sites from its folder, with the TLS of the site: an issuing CA of tls ca issues its
|
||||
certificates, and the certificate and key of tls file are written to CADDY_SITES_DIR/<name>. The certificates and
|
||||
keys of the issuing CAs of both are copied to CADDY_PKI_DIR. While the sites cannot be read (ctx's sites is None),
|
||||
the Caddyfile is left as it is.
|
||||
|
||||
Caddy is installed by CADDY_INSTALL_UNIT, outside setup, when it is missing or when
|
||||
https.caddy_source is upstream and Caddy's repository is not configured; the unit restarts
|
||||
setup, whose next run serves with it.
|
||||
"""
|
||||
if not vpnrouter.https_ready(ctx):
|
||||
sites = ctx['sites']
|
||||
if not vpnrouter.https_ready(ctx) and not sites:
|
||||
if sites is None:
|
||||
return
|
||||
vpnrouter.remove(CADDYFILE)
|
||||
if unit_is_active(CADDY_UNIT):
|
||||
run(['systemctl', 'disable', '--now', CADDY_UNIT], check=False)
|
||||
@@ -669,21 +730,33 @@ def manage_caddy(cfg, ctx):
|
||||
if source == 'ubuntu' and vpnrouter.CADDY_APT_SOURCE.is_file():
|
||||
warn("https.caddy_source ubuntu is ignored: Caddy's repository is configured, and Caddy stays upstream's")
|
||||
|
||||
if sites is None:
|
||||
warn('the web sites cannot be read; the Caddyfile is left as it is')
|
||||
if not unit_is_active(CADDY_UNIT) and CADDYFILE.exists():
|
||||
run(['systemctl', 'enable', '--now', CADDY_UNIT], check=False)
|
||||
return
|
||||
try:
|
||||
group = grp.getgrnam(CADDY_GROUP).gr_gid
|
||||
except KeyError:
|
||||
fail(f'the group {CADDY_GROUP} does not exist; is the caddy package installed?')
|
||||
https = vpnrouter.https_ready(ctx)
|
||||
wanted = list(dict.fromkeys([*(ctx['pki_acme'] if https else []),
|
||||
*(site['ca'] for site in sites if site['tls'] == 'ca')]))
|
||||
try:
|
||||
credentials = {name: vpnrouter_pki.authority_files(name) for name in ctx['pki_acme']}
|
||||
credentials = {name: vpnrouter_pki.authority_files(name) for name in wanted}
|
||||
except ldap.LDAPError as exc:
|
||||
warn(f'the ACME servers are left out: the PKI cannot be read: {exc}')
|
||||
warn(f'the ACME servers and the sites of issuing CAs are left out: the PKI cannot be read: {exc}')
|
||||
credentials = {}
|
||||
acme = [name for name, files in credentials.items() if files is not None]
|
||||
authorities = [name for name, files in credentials.items() if files is not None]
|
||||
for site in sites:
|
||||
if site['tls'] == 'ca' and site['ca'] not in authorities:
|
||||
warn(f'site {site["name"]} is left out: issuing CA {site["ca"]} has no certificate')
|
||||
sites = [site for site in sites if site['tls'] != 'ca' or site['ca'] in authorities]
|
||||
copied = False
|
||||
for folder in (CADDY_PKI_DIR.iterdir() if CADDY_PKI_DIR.is_dir() else []):
|
||||
if folder.name not in acme:
|
||||
if folder.name not in authorities:
|
||||
shutil.rmtree(folder)
|
||||
for name in acme:
|
||||
for name in authorities:
|
||||
folder = CADDY_PKI_DIR / name
|
||||
for path in (CADDY_PKI_DIR, folder):
|
||||
path.mkdir(mode=0o750, exist_ok=True)
|
||||
@@ -691,12 +764,33 @@ def manage_caddy(cfg, ctx):
|
||||
for file, data in zip(('cert.pem', 'key.pem'), credentials[name]):
|
||||
copied |= vpnrouter_pki.write(folder / file, data, 0o640)
|
||||
os.chown(folder / file, 0, group)
|
||||
authorities = vpnrouter.issuing_cas(cfg)
|
||||
caddy_ctx = dict(ctx, manager_port=manager_port() if MANAGER.exists() else 0,
|
||||
files = {site['name']: site for site in sites if site['tls'] == 'file'}
|
||||
for folder in (CADDY_SITES_DIR.iterdir() if CADDY_SITES_DIR.is_dir() else []):
|
||||
if folder.name not in files:
|
||||
shutil.rmtree(folder)
|
||||
for name, site in files.items():
|
||||
folder = CADDY_SITES_DIR / name
|
||||
for path in (CADDY_SITES_DIR, folder):
|
||||
path.mkdir(mode=0o750, exist_ok=True)
|
||||
os.chown(path, 0, group)
|
||||
for file, data in (('cert.pem', site['certificate']), ('key.pem', site['private_key'])):
|
||||
copied |= vpnrouter_pki.write(folder / file, data.strip().encode() + b'\n', 0o640)
|
||||
os.chown(folder / file, 0, group)
|
||||
vpnrouter_sites.ensure_folders(site['name'] for site in sites)
|
||||
names = vpnrouter.issuing_cas(cfg)
|
||||
caddy_ctx = dict(ctx, https=https, manager_port=vpnrouter.manager_port() if vpnrouter.MANAGER.exists() else 0,
|
||||
pki_publish_dir=str(vpnrouter_pki.PUBLISH_DIR),
|
||||
acme_cas=[{'id': name, 'name': authorities[name]['name'].strip(),
|
||||
'cert': str(CADDY_PKI_DIR / name / 'cert.pem'),
|
||||
'key': str(CADDY_PKI_DIR / name / 'key.pem')} for name in acme])
|
||||
pki_cas=[{'id': name, 'name': names[name]['name'].strip(),
|
||||
'cert': str(CADDY_PKI_DIR / name / 'cert.pem'),
|
||||
'key': str(CADDY_PKI_DIR / name / 'key.pem')} for name in authorities],
|
||||
acme_cas=[name for name in authorities if https and name in ctx['pki_acme']],
|
||||
webdav=vpnrouter.webdav_ready(ctx), webdav_loopback_port=vpnrouter.WEBDAV_LOOPBACK_PORT,
|
||||
sites=[{'name': site['name'], 'tls': site['tls'], 'ca': site['ca'], 'spa': site['spa'],
|
||||
'index': site['index_file'], 'root': str(vpnrouter_sites.SITES_DIR / site['name']),
|
||||
'addresses': [caddy_address(host, site['port'], site['tls']) for host in site['hosts']]
|
||||
or [caddy_address('', site['port'], site['tls'])],
|
||||
'cert': str(CADDY_SITES_DIR / site['name'] / 'cert.pem'),
|
||||
'key': str(CADDY_SITES_DIR / site['name'] / 'key.pem')} for site in sites])
|
||||
changed = vpnrouter.render(vpnrouter._jinja_env(), caddy_ctx, 'Caddyfile.j2', CADDYFILE, 0o640)
|
||||
os.chown(CADDYFILE, 0, group)
|
||||
if not unit_is_active(CADDY_UNIT):
|
||||
@@ -705,6 +799,91 @@ def manage_caddy(cfg, ctx):
|
||||
run(['systemctl', 'reload', CADDY_UNIT], check=False)
|
||||
|
||||
|
||||
def install_packages(feature):
|
||||
"""Start the installation of the packages of a feature by INSTALL_UNIT, which runs setup again, and say so."""
|
||||
unit = INSTALL_UNIT.format(feature)
|
||||
run(['systemctl', 'start', '--no-block', unit], check=False)
|
||||
warn(f'the packages of {feature} are installed by {unit}, which runs setup again')
|
||||
|
||||
|
||||
def manage_webdav(ctx):
|
||||
"""Run rclone's WebDAV server of the sites' folders while WebDAV is ready, and stop it when not; rclone is installed
|
||||
when it is missing."""
|
||||
if not vpnrouter.webdav_ready(ctx):
|
||||
if unit_is_active(WEBDAV_UNIT):
|
||||
run(['systemctl', 'disable', '--now', WEBDAV_UNIT], check=False)
|
||||
return
|
||||
if shutil.which('rclone') is None:
|
||||
install_packages('webdav')
|
||||
return
|
||||
vpnrouter_sites.ensure_folders([])
|
||||
run(['systemctl', 'enable', '--now', WEBDAV_UNIT], check=False)
|
||||
|
||||
|
||||
def site_addresses(cfg, ctx):
|
||||
"""The pairs of a host name and the router's address of dns_address of each enabled site with dns router: public,
|
||||
wan.public_address; internal, the internal interface's; external, the external interface's first. A site whose
|
||||
address is not known gets none, with a warning."""
|
||||
addresses = {'public': cfg['wan']['public_address'].strip(), 'internal': ctx['int_addr'],
|
||||
'external': next(iter(ctx['local_addr_list']), '')}
|
||||
pairs = []
|
||||
for site in ctx['sites']:
|
||||
if site['dns'] != 'router':
|
||||
continue
|
||||
address = addresses[site['dns_address']]
|
||||
if not address:
|
||||
warn(f'site {site["name"]}: the router has no {site["dns_address"]} address, so its host names get no '
|
||||
'records')
|
||||
continue
|
||||
pairs += [(host, address) for host in site['hosts']]
|
||||
return pairs
|
||||
|
||||
|
||||
def manage_dns(cfg, ctx):
|
||||
"""Run PowerDNS, the primary name server of the domains of vpnrouter_dns, on the addresses of dns.listen while DNS
|
||||
is enabled, and stop it when not; its packages are installed when they are missing.
|
||||
|
||||
The settings, vpnrouter_dns.PDNS_CONF, are written before the packages are installed, so the server their
|
||||
installation starts answers on those addresses only, beside systemd-resolved's stub on 127.0.0.53. The host names
|
||||
of the enabled sites with dns router get records of the router's address they choose (site_addresses), and the
|
||||
domains keep those they have while the sites cannot be read.
|
||||
"""
|
||||
if not vpnrouter.dns_ready(ctx):
|
||||
if unit_is_active(vpnrouter_dns.UNIT):
|
||||
run(['systemctl', 'disable', '--now', vpnrouter_dns.UNIT], check=False)
|
||||
vpnrouter.remove(vpnrouter_dns.PDNS_CONF)
|
||||
if vpnrouter_dns.DATABASE_SECRET.is_file():
|
||||
try:
|
||||
vpnrouter_dns.apply(cfg, [])
|
||||
except vpnrouter_dns.DatabaseError as exc:
|
||||
warn(str(exc))
|
||||
return
|
||||
addresses = {'internal': [ctx['int_addr']], 'external': ctx['local_addr_list'],
|
||||
'both': [ctx['int_addr'], *ctx['local_addr_list']]}[ctx['dns_listen']]
|
||||
addresses = [address for address in addresses if address]
|
||||
if not addresses:
|
||||
warn(f'the name server does not run: the interfaces of dns.listen {ctx["dns_listen"]} have no address')
|
||||
return
|
||||
try:
|
||||
vpnrouter_auth.provision_database(vpnrouter_dns.DATABASE, vpnrouter_dns.DATABASE_SECRET)
|
||||
vpnrouter_dns.PDNS_CONF.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
changed = write_if_changed(vpnrouter_dns.PDNS_CONF, vpnrouter_dns.config_text(addresses), 0o640)
|
||||
if not vpnrouter_dns.PDNS_SERVER.exists() or not vpnrouter_dns.PDNS_SCHEMA.exists():
|
||||
install_packages('dns')
|
||||
return
|
||||
os.chown(vpnrouter_dns.PDNS_CONF, 0, grp.getgrnam(vpnrouter_dns.GROUP).gr_gid)
|
||||
vpnrouter_dns.provision()
|
||||
if ctx['sites'] is not None:
|
||||
vpnrouter_dns.apply(cfg, site_addresses(cfg, ctx))
|
||||
except (OSError, KeyError, vpnrouter_dns.DatabaseError) as exc:
|
||||
warn(f'the name server cannot be set up: {exc}')
|
||||
return
|
||||
if not unit_is_active(vpnrouter_dns.UNIT):
|
||||
run(['systemctl', 'enable', '--now', vpnrouter_dns.UNIT], check=False)
|
||||
elif changed:
|
||||
run(['systemctl', 'restart', vpnrouter_dns.UNIT], check=False)
|
||||
|
||||
|
||||
def manage_metrics(ctx):
|
||||
"""Run the metrics service while metrics are enabled, and stop it when not.
|
||||
|
||||
@@ -795,6 +974,7 @@ def refresh_web(cfg, ctx):
|
||||
'internal_address': ctx['int_addr'],
|
||||
'internal_gateway': ctx['int_gateway_ip'],
|
||||
'local_cidrs': ctx['local_cidr_list'],
|
||||
'client_dns': ctx['p2s_resolver_addrs'] if vpnrouter.RESOLVER_CONF.exists() else [],
|
||||
'site_to_site': [{
|
||||
'name': connection['name'],
|
||||
'local_identity': connection['local_id'],
|
||||
@@ -863,6 +1043,16 @@ def main():
|
||||
ctx, more = vpnrouter.configure(cfg, public_ip=address)
|
||||
changed |= more
|
||||
|
||||
# The enabled sites while sites are on, None while they cannot be read.
|
||||
ctx['sites'] = []
|
||||
if cfg['sites']['enabled']:
|
||||
progress('running', 'web sites')
|
||||
try:
|
||||
ctx['sites'] = vpnrouter_sites.served()
|
||||
except vpnrouter_sites.DatabaseError as exc:
|
||||
warn(str(exc))
|
||||
ctx['sites'] = None
|
||||
|
||||
progress('running', 'WireGuard keys')
|
||||
setup_wireguard(ctx)
|
||||
|
||||
@@ -900,12 +1090,18 @@ def main():
|
||||
manage_openvpn(ctx, changed)
|
||||
progress('running', 'HTTPS proxy')
|
||||
manage_caddy(cfg, ctx)
|
||||
progress('running', 'WebDAV')
|
||||
manage_webdav(ctx)
|
||||
progress('running', 'name server')
|
||||
manage_dns(cfg, ctx)
|
||||
progress('running', 'PKI timer')
|
||||
run(['systemctl', 'enable', '--now', PKI_TIMER], check=False)
|
||||
|
||||
if 'resolved' in changed:
|
||||
progress('running', 'resolver')
|
||||
run(['systemctl', 'restart', 'systemd-resolved'], check=False)
|
||||
progress('running', 'resolver of the remote access clients')
|
||||
manage_resolver(changed)
|
||||
|
||||
progress('running', 'platform module')
|
||||
run_platform_setup(cfg, ctx)
|
||||
|
||||
@@ -31,12 +31,17 @@ from vpnrouter_net import DEFAULT_LISTEN, LISTEN_CHOICES, LOOPBACK, ConfigError,
|
||||
|
||||
CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.json')
|
||||
CONFIG_LOCK = pathlib.Path('/run/vpn-router/config.lock')
|
||||
#: What setup resolved and runs, for the web service (setup's refresh_web).
|
||||
WEB_STATE = pathlib.Path('/run/vpn-router/web.json')
|
||||
TEMPLATE_DIR = pathlib.Path('/usr/share/vpn-router/templates')
|
||||
|
||||
SWANCTL_CONF_D = pathlib.Path('/etc/swanctl/conf.d')
|
||||
SITE_TO_SITE = SWANCTL_CONF_D / 'site-to-site.conf'
|
||||
ROAD_WARRIOR = SWANCTL_CONF_D / 'road-warrior.conf'
|
||||
#: The drop-in of systemd-resolved that earlier versions wrote for the remote access clients; removed when found.
|
||||
P2S_FORWARDER = pathlib.Path('/etc/systemd/resolved.conf.d/p2s-forwarder.conf')
|
||||
#: The configuration of dnsmasq, the resolver of the remote access clients (vpn-router-resolver.service).
|
||||
RESOLVER_CONF = pathlib.Path('/etc/vpn-router/dnsmasq.conf')
|
||||
WG_CONF = pathlib.Path('/etc/wireguard/wg0.conf')
|
||||
#: The router's WireGuard public key, which setup writes with its private key.
|
||||
WG_PUBLIC_KEY = pathlib.Path('/etc/wireguard/wg0.pub')
|
||||
@@ -50,18 +55,22 @@ OPENVPN_LDAP = pathlib.Path('/etc/openvpn/server/vpn-router-ldap.conf')
|
||||
SCHEMA = {
|
||||
'general': {'platform': 'none', 'mode': 'manual'},
|
||||
'interfaces': {'external': '', 'internal': ''},
|
||||
'wan': {'local_fqdn': ''},
|
||||
'wan': {'local_fqdn': '', 'public_address': ''},
|
||||
'local': {'cidrs': [], 'int_addr': '', 'int_gateway_ip': ''},
|
||||
'site_to_site': {},
|
||||
'p2s': {'enabled': False, 'address_pool': ''},
|
||||
'p2s': {'enabled': False, 'address_pool': '', 'dns_listen': 'internal', 'dns_domains': []},
|
||||
'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820},
|
||||
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
|
||||
'docker': {'mode': 'auto', 'exposed_ports': []},
|
||||
'web': {'enabled': True, 'listen': DEFAULT_LISTEN, 'port': 7081, 'database': '', 'trusted_proxies': [],
|
||||
'status_content': 'basic', 'shell': False, 'shell_idle_minutes': 15, 'shell_auth_timeout': 15},
|
||||
'status_content': 'basic', 'shell': False, 'shell_idle_minutes': 15, 'shell_auth_timeout': 15,
|
||||
'log_files': []},
|
||||
'ssh': {'ldap_login': False},
|
||||
'https': {'enabled': True, 'email': '', 'web_port': 7444, 'manager_port': 7443, 'caddy_source': 'ubuntu'},
|
||||
'https': {'enabled': True, 'email': '', 'web_port': 7444, 'manager_port': 7443, 'caddy_source': 'ubuntu',
|
||||
'acme_staging': False},
|
||||
'oidc': {'enabled': False},
|
||||
'sites': {'enabled': False, 'upload_max_mb': 512, 'webdav': False, 'webdav_port': 7445},
|
||||
'dns': {'enabled': False, 'secondary': '', 'listen': 'external', 'ttl': 300},
|
||||
'pki': {'root_name': 'VPN Router Root CA', 'root_days': 7300, 'ca_days': 3650, 'key': 'rsa-4096',
|
||||
'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': {},
|
||||
@@ -126,9 +135,43 @@ ACME_HTTP_PORT = 80
|
||||
#: The default port of HTTPS, where Caddy publishes the CA files when pki.publish has https-default.
|
||||
HTTPS_DEFAULT_PORT = 443
|
||||
|
||||
#: The most files web.log_files lists.
|
||||
LOG_FILES_MAX = 32
|
||||
|
||||
#: The port rclone serves WebDAV on, on the loopback address, behind Caddy.
|
||||
WEBDAV_LOOPBACK_PORT = 7446
|
||||
|
||||
#: The largest sites.upload_max_mb, 10 GiB.
|
||||
UPLOAD_MAX_MB = 10240
|
||||
|
||||
#: Settings that no longer exist, section -> options, left out when the file is read so that a file of an earlier
|
||||
#: version loads: the name server is wan.local_fqdn and a site chooses the address of its records.
|
||||
RETIRED = {'dns': ('nameserver', 'addresses')}
|
||||
|
||||
#: The shortest and the longest dns.ttl, in seconds.
|
||||
DNS_TTL_RANGE = (60, 86400)
|
||||
|
||||
#: A DNS name without a wildcard, in lowercase, of the name servers and the domains of the name server.
|
||||
ZONE_NAME = re.compile(r'^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$')
|
||||
|
||||
#: A domain of p2s.dns_domains, one label or more, and the most domains.
|
||||
DNS_DOMAIN = re.compile(r'^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$')
|
||||
DNS_DOMAINS_MAX = 16
|
||||
|
||||
#: The ports of the bootstrap services: the Router Manager's and the router's.
|
||||
BOOTSTRAP_PORTS = (17080, 17081)
|
||||
|
||||
#: The Router Manager, vpn-router-manager, when it is installed, its settings and its port when they set none.
|
||||
MANAGER = pathlib.Path('/usr/lib/vpn-router-manager/manager')
|
||||
MANAGER_CONFIG = pathlib.Path('/etc/vpn-router-manager/manager.json')
|
||||
MANAGER_PORT = 7080
|
||||
|
||||
#: Where vpn-router-setup installs Caddy from, https.caddy_source: the Ubuntu archive or Caddy's own repository.
|
||||
CADDY_SOURCES = ('ubuntu', 'upstream')
|
||||
|
||||
#: The ACME directory of Let's Encrypt's staging environment, Caddy's only CA while https.acme_staging is true.
|
||||
ACME_STAGING_CA = 'https://acme-staging-v02.api.letsencrypt.org/directory'
|
||||
|
||||
#: Caddy's own repository, which https.caddy_source upstream configures: the source, its signing key and their URLs.
|
||||
CADDY_APT_SOURCE = pathlib.Path('/etc/apt/sources.list.d/caddy-stable.sources')
|
||||
CADDY_KEYRING = pathlib.Path('/etc/apt/keyrings/caddy-stable.asc')
|
||||
@@ -261,6 +304,9 @@ def read_settings(path=None):
|
||||
for section, options in data.items():
|
||||
if not isinstance(options, dict):
|
||||
raise ConfigError(f'cannot parse {path}: {section} is not an object')
|
||||
for section, options in RETIRED.items():
|
||||
for option in options:
|
||||
data.get(section, {}).pop(option, None)
|
||||
return data
|
||||
|
||||
|
||||
@@ -518,6 +564,13 @@ def validate(cfg):
|
||||
for item in _get_list(cfg, 'web', 'trusted_proxies'):
|
||||
_check_address(item, 'web.trusted_proxies')
|
||||
|
||||
log_files = _get_list(cfg, 'web', 'log_files')
|
||||
if len(log_files) > LOG_FILES_MAX:
|
||||
raise ConfigError(f'web.log_files: at most {LOG_FILES_MAX} files')
|
||||
for item in log_files:
|
||||
if not item.startswith('/') or '/../' in f'{item}/':
|
||||
raise ConfigError(f'web.log_files: {item!r} is not an absolute path')
|
||||
|
||||
caddy_source = _get(cfg, 'https', 'caddy_source')
|
||||
if caddy_source not in CADDY_SOURCES:
|
||||
raise ConfigError(f'https.caddy_source: {caddy_source!r} is not one of {", ".join(CADDY_SOURCES)}')
|
||||
@@ -528,6 +581,44 @@ def validate(cfg):
|
||||
'https://<wan.local_fqdn>:<https.web_port>, which needs web.enabled, https.enabled and '
|
||||
'wan.local_fqdn')
|
||||
|
||||
upload_max = _get(cfg, 'sites', 'upload_max_mb')
|
||||
if not 1 <= upload_max <= UPLOAD_MAX_MB:
|
||||
raise ConfigError(f'sites.upload_max_mb: {upload_max} is not between 1 and {UPLOAD_MAX_MB}')
|
||||
if _get(cfg, 'sites', 'webdav') and not (_get(cfg, 'https', 'enabled') and _get(cfg, 'wan', 'local_fqdn')):
|
||||
raise ConfigError('sites.webdav: WebDAV is served by Caddy at https://<wan.local_fqdn>:<sites.webdav_port>, '
|
||||
'which needs https.enabled and wan.local_fqdn')
|
||||
|
||||
_check_address(_get(cfg, 'wan', 'public_address'), 'wan.public_address')
|
||||
dns = cfg['dns']
|
||||
if dns['enabled'] and not (ZONE_NAME.match(_get(cfg, 'wan', 'local_fqdn'))
|
||||
and not re.search(r'\.[0-9]+$', _get(cfg, 'wan', 'local_fqdn'))):
|
||||
raise ConfigError('dns.enabled needs wan.local_fqdn, a DNS name in lowercase: the primary name server of the '
|
||||
'domains')
|
||||
secondary = _get(cfg, 'dns', 'secondary')
|
||||
if secondary and not ZONE_NAME.match(secondary):
|
||||
raise ConfigError(f'dns.secondary: {secondary!r} is not a DNS name in lowercase')
|
||||
if secondary and secondary == _get(cfg, 'wan', 'local_fqdn'):
|
||||
raise ConfigError('dns.secondary: the secondary name server is not wan.local_fqdn, the primary')
|
||||
if dns['listen'] not in LISTEN_CHOICES:
|
||||
raise ConfigError(f'dns.listen: {dns["listen"]!r} is not one of {", ".join(LISTEN_CHOICES)}')
|
||||
if not DNS_TTL_RANGE[0] <= dns['ttl'] <= DNS_TTL_RANGE[1]:
|
||||
raise ConfigError(f'dns.ttl: {dns["ttl"]} is not between {DNS_TTL_RANGE[0]} and {DNS_TTL_RANGE[1]} seconds')
|
||||
dns_domains = _get_list(cfg, 'p2s', 'dns_domains')
|
||||
if len(dns_domains) > DNS_DOMAINS_MAX or len(set(dns_domains)) != len(dns_domains):
|
||||
raise ConfigError(f'p2s.dns_domains: at most {DNS_DOMAINS_MAX} different domains')
|
||||
for item in dns_domains:
|
||||
if not DNS_DOMAIN.match(item):
|
||||
raise ConfigError(f'p2s.dns_domains: {item!r} is not a DNS name in lowercase')
|
||||
resolver_listen = _get(cfg, 'p2s', 'dns_listen')
|
||||
if resolver_listen not in LISTEN_CHOICES:
|
||||
raise ConfigError(f'p2s.dns_listen: {resolver_listen!r} is not one of {", ".join(LISTEN_CHOICES)}')
|
||||
sides = {'internal': {'internal'}, 'external': {'external'}, 'both': {'internal', 'external'}}
|
||||
remote_access = any(_get(cfg, system, 'enabled') for system in ('p2s', 'openvpn', 'wireguard'))
|
||||
if dns['enabled'] and remote_access and sides[dns['listen']] & sides[resolver_listen]:
|
||||
raise ConfigError(f'dns.listen: port 53 of the {" and ".join(sorted(sides[dns["listen"]] & sides[resolver_listen]))} '
|
||||
f'addresses is the resolver of the remote access clients (p2s.dns_listen {resolver_listen}) '
|
||||
'while p2s.enabled, openvpn.enabled or wireguard.enabled is true')
|
||||
|
||||
for option in ('root_name', 'p2s_name', 's2s_name'):
|
||||
value = _get(cfg, 'pki', option)
|
||||
if not value or len(value) > CA_NAME_MAX or not value.isprintable():
|
||||
@@ -618,13 +709,13 @@ def validate(cfg):
|
||||
if _get(cfg, 'openvpn', 'protocol') == 'tcp' and _get(cfg, 'openvpn', 'port') == HTTPS_DEFAULT_PORT:
|
||||
raise ConfigError(f'pki.publish: https-default takes port {HTTPS_DEFAULT_PORT}, the port of openvpn.port')
|
||||
taken[HTTPS_DEFAULT_PORT] = 'pki.publish https-default'
|
||||
for option in ('port', 'https_port'):
|
||||
port = _get(cfg, 'metrics', option)
|
||||
for section_name, option in (('metrics', 'port'), ('metrics', 'https_port'), ('sites', 'webdav_port')):
|
||||
port = _get(cfg, section_name, option)
|
||||
if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
|
||||
raise ConfigError(f'metrics.{option}: {port!r} is not a valid port')
|
||||
raise ConfigError(f'{section_name}.{option}: {port!r} is not a valid port')
|
||||
if port in taken:
|
||||
raise ConfigError(f'metrics.{option}: {port} is the port of {taken[port]}')
|
||||
taken[port] = f'metrics.{option}'
|
||||
raise ConfigError(f'{section_name}.{option}: {port} is the port of {taken[port]}')
|
||||
taken[port] = f'{section_name}.{option}'
|
||||
|
||||
metrics_listen = _get(cfg, 'metrics', 'listen')
|
||||
if metrics_listen not in LISTEN_CHOICES:
|
||||
@@ -753,6 +844,15 @@ def pki_base_urls(cfg):
|
||||
return [bases[item] for item in _get_list(cfg, 'pki', 'publish') if item in bases] if fqdn else []
|
||||
|
||||
|
||||
def manager_port():
|
||||
"""The port of the Router Manager: port in its settings, or MANAGER_PORT when it is not set or not a port."""
|
||||
try:
|
||||
port = json.loads(MANAGER_CONFIG.read_text(encoding='utf-8')).get('port')
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return MANAGER_PORT
|
||||
return port if isinstance(port, int) and not isinstance(port, bool) and 1 <= port <= 65535 else MANAGER_PORT
|
||||
|
||||
|
||||
def oidc_issuer(cfg):
|
||||
"""The issuer of the OpenID Provider: the web service's HTTPS site, without the port when it is HTTPS_DEFAULT_PORT."""
|
||||
port = _get(cfg, 'https', 'web_port')
|
||||
@@ -867,6 +967,10 @@ def build_context(cfg, public_ip=None):
|
||||
openvpn_pool = _get(cfg, 'openvpn', 'address_pool')
|
||||
openvpn_network = ipaddress.ip_network(openvpn_pool) if openvpn_pool else None
|
||||
identities = {'fqdn': local_fqdn, 'public_ip': public_ip or '', 'internal_ip': int_addr}
|
||||
# The addresses the remote access clients' resolver answers on, which p2s.dns_listen chooses.
|
||||
resolver_addrs = [address for address in {'internal': [int_addr], 'external': local_addrs,
|
||||
'both': [int_addr, *local_addrs]}[_get(cfg, 'p2s', 'dns_listen')]
|
||||
if address]
|
||||
|
||||
connections = []
|
||||
for name, connection in cfg['site_to_site'].items():
|
||||
@@ -910,9 +1014,13 @@ def build_context(cfg, public_ip=None):
|
||||
'p2s_enabled': _get(cfg, 'p2s', 'enabled'),
|
||||
'p2s_address_pool': _get(cfg, 'p2s', 'address_pool'),
|
||||
'public_ip': public_ip or '',
|
||||
'p2s_dns': _join(local_addrs, _split_list(extras['EXTRA_P2S_DNS'])),
|
||||
'p2s_resolver_addrs': resolver_addrs,
|
||||
'p2s_dns_domains': _get_list(cfg, 'p2s', 'dns_domains'),
|
||||
'p2s_dns': _join(resolver_addrs, _split_list(extras['EXTRA_P2S_DNS'])),
|
||||
'wg_enabled': _get(cfg, 'wireguard', 'enabled'),
|
||||
'wg_address': _get(cfg, 'wireguard', 'address'),
|
||||
'wg_network': str(ipaddress.ip_interface(_get(cfg, 'wireguard', 'address')).network)
|
||||
if _get(cfg, 'wireguard', 'address') else '',
|
||||
'wg_listen_port': _get(cfg, 'wireguard', 'listen_port'),
|
||||
'wg_peers_begin': WG_PEERS_BEGIN,
|
||||
'wg_peers_end': WG_PEERS_END,
|
||||
@@ -928,7 +1036,7 @@ def build_context(cfg, public_ip=None):
|
||||
'openvpn_ldap_conf': str(OPENVPN_LDAP),
|
||||
'openvpn_status': vpnrouter_status.OPENVPN_STATUS,
|
||||
'openvpn_status_seconds': vpnrouter_status.OPENVPN_STATUS_SECONDS,
|
||||
'p2s_dns_list': _split_list(_join(local_addrs, _split_list(extras['EXTRA_P2S_DNS']))),
|
||||
'p2s_dns_list': _split_list(_join(resolver_addrs, _split_list(extras['EXTRA_P2S_DNS']))),
|
||||
'openvpn_ca': str(vpnrouter_pki.OPENVPN_CA),
|
||||
'openvpn_crl': str(vpnrouter_pki.OPENVPN_CRL),
|
||||
'server_cert': str(vpnrouter_pki.VPN_SERVER_CERT),
|
||||
@@ -940,6 +1048,7 @@ def build_context(cfg, public_ip=None):
|
||||
'web_port': _get(cfg, 'web', 'port'),
|
||||
'https_enabled': _get(cfg, 'https', 'enabled'),
|
||||
'https_email': _get(cfg, 'https', 'email'),
|
||||
'https_acme_ca': ACME_STAGING_CA if _get(cfg, 'https', 'acme_staging') else '',
|
||||
'https_web_port': _get(cfg, 'https', 'web_port'),
|
||||
'https_manager_port': _get(cfg, 'https', 'manager_port'),
|
||||
'pki_publish': _get_list(cfg, 'pki', 'publish'),
|
||||
@@ -948,6 +1057,11 @@ def build_context(cfg, public_ip=None):
|
||||
'metrics_port': _get(cfg, 'metrics', 'port'),
|
||||
'metrics_https_port': _get(cfg, 'metrics', 'https_port'),
|
||||
'metrics_service_account': _get(cfg, 'metrics', 'service_account'),
|
||||
'sites_enabled': _get(cfg, 'sites', 'enabled'),
|
||||
'webdav_enabled': _get(cfg, 'sites', 'webdav'),
|
||||
'webdav_port': _get(cfg, 'sites', 'webdav_port'),
|
||||
'dns_enabled': _get(cfg, 'dns', 'enabled'),
|
||||
'dns_listen': _get(cfg, 'dns', 'listen'),
|
||||
'loopback': LOOPBACK,
|
||||
}
|
||||
|
||||
@@ -964,7 +1078,7 @@ def routing_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'])
|
||||
|
||||
|
||||
@@ -987,6 +1101,16 @@ def metrics_ready(ctx):
|
||||
return bool(ctx['metrics_enabled'])
|
||||
|
||||
|
||||
def webdav_ready(ctx):
|
||||
"""WebDAV of the sites' folders is served by Caddy, on the router's own name, while it is on and HTTPS is ready."""
|
||||
return bool(ctx['webdav_enabled'] and https_ready(ctx))
|
||||
|
||||
|
||||
def dns_ready(ctx):
|
||||
"""The name server answers for its domains while dns.enabled is true."""
|
||||
return bool(ctx['dns_enabled'])
|
||||
|
||||
|
||||
def _jinja_env():
|
||||
return jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(str(TEMPLATE_DIR)),
|
||||
@@ -1080,7 +1204,7 @@ def configure(cfg=None, public_ip=None):
|
||||
"""Render configuration files and provision PKI.
|
||||
|
||||
Returns (ctx, changed) where changed is a set naming what needs reloading:
|
||||
'swanctl', 'resolved', 'wireguard', 'openvpn'.
|
||||
'swanctl', 'resolved', 'resolver', 'wireguard', 'openvpn'.
|
||||
"""
|
||||
if cfg is None:
|
||||
cfg = load_config()
|
||||
@@ -1137,11 +1261,15 @@ def configure(cfg=None, public_ip=None):
|
||||
elif remove(ROAD_WARRIOR):
|
||||
changed.add('swanctl')
|
||||
|
||||
if p2s_ready(ctx) or openvpn_ready(ctx):
|
||||
if render(env, ctx, 'p2s-forwarder.conf.j2', P2S_FORWARDER, 0o644):
|
||||
changed.add('resolved')
|
||||
elif remove(P2S_FORWARDER):
|
||||
# The resolver of the remote access clients is dnsmasq on the addresses of p2s.dns_listen; systemd-resolved keeps
|
||||
# its loopback stub only, so its records of the router's own name stay on the router.
|
||||
if remove(P2S_FORWARDER):
|
||||
changed.add('resolved')
|
||||
if (p2s_ready(ctx) or openvpn_ready(ctx) or wg_ready(ctx)) and ctx['p2s_resolver_addrs']:
|
||||
if render(env, ctx, 'dnsmasq.conf.j2', RESOLVER_CONF, 0o644):
|
||||
changed.add('resolver')
|
||||
elif remove(RESOLVER_CONF):
|
||||
changed.add('resolver')
|
||||
|
||||
if wg_ready(ctx):
|
||||
if render_wg_conf(env, ctx):
|
||||
|
||||
@@ -127,13 +127,19 @@ def admin_token(seconds):
|
||||
return {'token': sign({'admin': True, 'iat': now, 'exp': now + seconds}, secret), 'expires': now + seconds}
|
||||
|
||||
|
||||
def local_connection(name, secret):
|
||||
"""The connection string of a database provision_database() created; its password, of URL-safe characters, is
|
||||
written unquoted."""
|
||||
password = secret.read_text(encoding='utf-8').strip()
|
||||
return f'host=localhost dbname={name} user={name} password={password}'
|
||||
|
||||
|
||||
def connection_string(cfg):
|
||||
"""web.database, or the database provision() creates; its password, of URL-safe characters, is written unquoted."""
|
||||
"""web.database, or the database provision() creates."""
|
||||
configured = cfg['web']['database'].strip()
|
||||
if configured:
|
||||
return configured
|
||||
password = DATABASE_SECRET.read_text(encoding='utf-8').strip()
|
||||
return f'host=localhost dbname={DATABASE} user={DATABASE} password={password}'
|
||||
return local_connection(DATABASE, DATABASE_SECRET)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
@@ -204,21 +210,33 @@ def psql(sql, name):
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def provision(cfg):
|
||||
"""Create the role, its password file and the database when web.database is not set and they are missing.
|
||||
def provision_database(name, secret):
|
||||
"""Create the role of the name, its password file secret and its database of the same name when they are missing.
|
||||
|
||||
The password, of URL-safe characters, reaches psql on standard input, not on its command line.
|
||||
"""
|
||||
if cfg['web']['database'].strip():
|
||||
return
|
||||
if not psql("SELECT 1 FROM pg_roles WHERE rolname = :'v1'", DATABASE):
|
||||
DATABASE_SECRET.unlink(missing_ok=True)
|
||||
psql("CREATE ROLE :\"v1\" LOGIN", DATABASE)
|
||||
if not DATABASE_SECRET.is_file():
|
||||
if not psql("SELECT 1 FROM pg_roles WHERE rolname = :'v1'", name):
|
||||
secret.unlink(missing_ok=True)
|
||||
psql("CREATE ROLE :\"v1\" LOGIN", name)
|
||||
if not secret.is_file():
|
||||
password = secrets.token_urlsafe(32)
|
||||
psql(f"ALTER ROLE :\"v1\" PASSWORD '{password}'", DATABASE)
|
||||
DATABASE_SECRET.touch(mode=0o600)
|
||||
DATABASE_SECRET.chmod(0o600)
|
||||
DATABASE_SECRET.write_text(password + '\n', encoding='utf-8')
|
||||
if not psql("SELECT 1 FROM pg_database WHERE datname = :'v1'", DATABASE):
|
||||
psql("CREATE DATABASE :\"v1\" OWNER :\"v1\"", DATABASE)
|
||||
psql(f"ALTER ROLE :\"v1\" PASSWORD '{password}'", name)
|
||||
secret.touch(mode=0o600)
|
||||
secret.chmod(0o600)
|
||||
secret.write_text(password + '\n', encoding='utf-8')
|
||||
if not psql("SELECT 1 FROM pg_database WHERE datname = :'v1'", name):
|
||||
psql("CREATE DATABASE :\"v1\" OWNER :\"v1\"", name)
|
||||
|
||||
|
||||
def reset_password(name, secret):
|
||||
"""Give the role of the name the password of its file secret, when both exist, as after a restore."""
|
||||
if secret.is_file() and psql("SELECT 1 FROM pg_roles WHERE rolname = :'v1'", name):
|
||||
password = secret.read_text(encoding='utf-8').strip()
|
||||
psql(f"ALTER ROLE :\"v1\" PASSWORD '{password}'", name)
|
||||
|
||||
|
||||
def provision(cfg):
|
||||
"""Create the role, its password file and the database of the refresh tokens when web.database is not set and they
|
||||
are missing."""
|
||||
if not cfg['web']['database'].strip():
|
||||
provision_database(DATABASE, DATABASE_SECRET)
|
||||
@@ -1,9 +1,10 @@
|
||||
"""The backups of the router, for vpn-router-backup and the web service.
|
||||
|
||||
A backup is a consistent copy of the state that setup cannot make again, staged in STAGING: the
|
||||
trees of TREES, the files of site_to_site psk_file outside them under files/, the directory as
|
||||
slapcat writes it (ldap/vpn-router.ldif), with backup.metrics the database as pg_dump writes it
|
||||
(postgresql/vpn-router.dump), and manifest.json. The trees and the directory are copied while
|
||||
trees of TREES, the folders of the web sites among them, the files of site_to_site psk_file outside them under files/,
|
||||
the directory as slapcat writes it (ldap/vpn-router.ldif), with backup.metrics the database as pg_dump writes it
|
||||
(postgresql/vpn-router.dump), the databases of the web sites and of the name server (DUMPS) once they exist, and
|
||||
manifest.json. The trees and the directory are copied while
|
||||
the configuration lock and the users lock are held, so the configuration, the PKI and the users
|
||||
match; pg_dump reads a snapshot of its own.
|
||||
|
||||
@@ -33,7 +34,9 @@ import time
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_auth
|
||||
import vpnrouter_dns
|
||||
import vpnrouter_ldap
|
||||
import vpnrouter_sites
|
||||
import vpnrouter_users
|
||||
|
||||
STATE = pathlib.Path('/var/lib/vpn-router/backup.json')
|
||||
@@ -49,7 +52,12 @@ UNIT = 'vpn-router-backup.service'
|
||||
TIMER = 'vpn-router-backup.timer'
|
||||
|
||||
#: The state a backup copies: directories and files, each where it is on the system.
|
||||
TREES = ('/etc/vpn-router', '/etc/wireguard', '/etc/ufw', '/etc/default/ufw', '/etc/vpn-router-manager')
|
||||
TREES = ('/etc/vpn-router', '/etc/wireguard', '/etc/ufw', '/etc/default/ufw', '/etc/vpn-router-manager',
|
||||
str(vpnrouter_sites.SITES_DIR))
|
||||
|
||||
#: The databases a backup always dumps, each while it exists: the dump -> the database and its password file.
|
||||
DUMPS = {'postgresql/vpn-router-sites.dump': (vpnrouter_sites.DATABASE, vpnrouter_sites.DATABASE_SECRET),
|
||||
'postgresql/vpn-router-dns.dump': (vpnrouter_dns.DATABASE, vpnrouter_dns.DATABASE_SECRET)}
|
||||
|
||||
#: The name of a local archive: the host and the UTC time of the backup.
|
||||
ARCHIVE = re.compile(r'^vpn-router-(?P<host>[A-Za-z0-9][A-Za-z0-9.-]*)-(?P<stamp>\d{8}T\d{6}Z)\.tar\.zst$')
|
||||
@@ -64,7 +72,8 @@ PERIODS = {'keep_hourly': '%Y-%m-%d %H', 'keep_daily': '%Y-%m-%d', 'keep_weekly'
|
||||
'keep_yearly': '%Y'}
|
||||
|
||||
#: The units a restore stops while it replaces the directory and the database, and the ones it restarts after setup.
|
||||
RESTORE_STOPS = ('vpn-router-web.service', 'vpn-router-metrics.service', 'slapd.service')
|
||||
RESTORE_STOPS = ('vpn-router-web.service', 'vpn-router-metrics.service', 'vpn-router-webdav.service', 'pdns.service',
|
||||
'slapd.service')
|
||||
RESTORE_RESTARTS = ('wg-quick@wg0.service', 'openvpn-server@vpn-router.service', 'strongswan.service',
|
||||
'vpn-router-manager.service')
|
||||
|
||||
@@ -223,14 +232,16 @@ def stage(cfg):
|
||||
(STAGING / LDIF).parent.mkdir()
|
||||
command(['slapcat', '-b', vpnrouter_ldap.BASE, '-l', str(STAGING / LDIF)])
|
||||
items = trees + [f'files{name}' for name in files] + [LDIF]
|
||||
if cfg['backup']['metrics']:
|
||||
(STAGING / DUMP).parent.mkdir()
|
||||
prefix, database = database_args(cfg)
|
||||
with open(STAGING / DUMP, 'wb') as dump:
|
||||
dumps = {**({DUMP: database_args(cfg)} if cfg['backup']['metrics'] else {}),
|
||||
**{item: (['runuser', '-u', 'postgres', '--'], [f'--dbname={name}'])
|
||||
for item, (name, secret) in DUMPS.items() if secret.is_file()}}
|
||||
for item, (prefix, database) in dumps.items():
|
||||
(STAGING / item).parent.mkdir(exist_ok=True)
|
||||
with open(STAGING / item, 'wb') as dump:
|
||||
result = subprocess.run([*prefix, 'pg_dump', '-Fc', *database], stdout=dump, stderr=subprocess.PIPE)
|
||||
if result.returncode != 0:
|
||||
raise Failure(f'pg_dump failed: {result.stderr.decode(errors="replace").strip()}')
|
||||
items.append(DUMP)
|
||||
raise Failure(f'pg_dump for {item} failed: {result.stderr.decode(errors="replace").strip()}')
|
||||
items.append(item)
|
||||
version = subprocess.run(['dpkg-query', '-W', '-f=${Version}', 'vpn-router'], capture_output=True, text=True)
|
||||
manifest = {'host': host(), 'time': now(), 'version': version.stdout.strip() or None,
|
||||
'schedule': cfg['backup']['schedule'], 'items': items}
|
||||
@@ -389,19 +400,22 @@ def restore(source, pbs, confirm):
|
||||
subprocess.run(['systemctl', 'start', 'slapd.service'], capture_output=True)
|
||||
restored = vpnrouter.load_config()
|
||||
if not restored['web']['database'].strip():
|
||||
secret = vpnrouter_auth.DATABASE_SECRET
|
||||
if secret.is_file() and vpnrouter_auth.psql("SELECT 1 FROM pg_roles WHERE rolname = :'v1'",
|
||||
vpnrouter_auth.DATABASE):
|
||||
password = secret.read_text(encoding='utf-8').strip()
|
||||
vpnrouter_auth.psql(f"ALTER ROLE :\"v1\" PASSWORD '{password}'", vpnrouter_auth.DATABASE)
|
||||
vpnrouter_auth.reset_password(vpnrouter_auth.DATABASE, vpnrouter_auth.DATABASE_SECRET)
|
||||
vpnrouter_auth.provision(restored)
|
||||
if DUMP in manifest['items']:
|
||||
prefix, database = database_args(restored)
|
||||
with open(RESTORE_DIR / DUMP, 'rb') as dump:
|
||||
dumps = {DUMP: database_args(restored)}
|
||||
for item, (name, secret) in DUMPS.items():
|
||||
if item in manifest['items']:
|
||||
vpnrouter_auth.reset_password(name, secret)
|
||||
vpnrouter_auth.provision_database(name, secret)
|
||||
dumps[item] = (['runuser', '-u', 'postgres', '--'], [f'--dbname={name}'])
|
||||
for item, (prefix, database) in dumps.items():
|
||||
if item not in manifest['items']:
|
||||
continue
|
||||
with open(RESTORE_DIR / item, 'rb') as dump:
|
||||
result = subprocess.run([*prefix, 'pg_restore', '--clean', '--if-exists', *database], stdin=dump,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
||||
if result.returncode != 0:
|
||||
raise Failure(f'pg_restore failed: {result.stderr.decode(errors="replace").strip()}')
|
||||
raise Failure(f'pg_restore of {item} failed: {result.stderr.decode(errors="replace").strip()}')
|
||||
subprocess.run(['systemctl', 'start', 'vpn-router-web.service'], capture_output=True)
|
||||
command(['systemctl', 'restart', 'vpn-router-setup.service'])
|
||||
for unit in RESTORE_RESTARTS:
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
"""The router's authoritative name server, PowerDNS with its gpgsql backend, for vpn-router-setup and the web service.
|
||||
|
||||
The name server is the primary of its domains and answers on the addresses of the interfaces of dns.listen. The
|
||||
domains and the records an administrator added (manual records) are in the vpn-router-dns database, which
|
||||
provision() creates with its role and the password in DATABASE_SECRET, and fills with the schema of
|
||||
pdns-backend-pgsql, PDNS_SCHEMA; DNS_SQL defines the router's own tables and procedures, and the first connection of a
|
||||
process loads it.
|
||||
|
||||
apply() writes every domain as a primary zone: its SOA with wan.local_fqdn as the primary name server, its NS,
|
||||
wan.local_fqdn and dns.secondary, the NS of the domains directly below it, an A or AAAA record for every host name of
|
||||
an enabled site with dns router whose closest domain it is, with the router's address the site chooses, and the manual
|
||||
records, but those of a name and type the records before them have. wan.local_fqdn gets no record in the zones: its
|
||||
record is kept where its own domain is. The addresses dns.secondary resolves to are notified of every change
|
||||
(ALSO-NOTIFY) and may transfer the zones (ALLOW-AXFR-FROM); the serial of the SOA grows only when the records change.
|
||||
|
||||
Every failure of the database raises DatabaseError; a request that cannot be done raises Failure.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import ipaddress
|
||||
import pathlib
|
||||
import re
|
||||
import socket
|
||||
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_auth
|
||||
|
||||
DATABASE = 'vpn-router-dns'
|
||||
DATABASE_SECRET = pathlib.Path('/etc/vpn-router/dns-database.secret')
|
||||
DNS_SQL = pathlib.Path('/usr/share/vpn-router/sql/dns.sql')
|
||||
|
||||
#: The schema of PowerDNS's gpgsql backend, which pdns-backend-pgsql installs.
|
||||
PDNS_SCHEMA = pathlib.Path('/usr/share/pdns-backend-pgsql/schema/schema.pgsql.sql')
|
||||
|
||||
#: The settings of PowerDNS that vpn-router-setup writes, read after /etc/powerdns/pdns.conf; the server, its unit and
|
||||
#: the group it runs as.
|
||||
PDNS_CONF = pathlib.Path('/etc/powerdns/pdns.d/vpn-router.conf')
|
||||
PDNS_SERVER = pathlib.Path('/usr/sbin/pdns_server')
|
||||
UNIT = 'pdns.service'
|
||||
GROUP = 'pdns'
|
||||
|
||||
#: The types of the manual records.
|
||||
TYPES = ('A', 'AAAA', 'CNAME', 'TXT', 'MX', 'SRV', 'CAA')
|
||||
|
||||
#: The most domains, and manual records of a domain.
|
||||
DOMAINS_MAX = 100
|
||||
MANUAL_MAX = 1000
|
||||
|
||||
#: The refresh, retry and expire times of the SOA, in seconds.
|
||||
SOA_TIMES = '10800 3600 604800'
|
||||
|
||||
#: A label of a record's name: letters, digits, hyphens and underscores, or * as the first label.
|
||||
LABEL = re.compile(r'^[a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9_])?$')
|
||||
|
||||
#: The content of an MX, SRV and CAA record, and a DNS name a record points to.
|
||||
MX = re.compile(r'^(\d{1,5}) (\S+)$')
|
||||
SRV = re.compile(r'^(\d{1,5}) (\d{1,5}) (\d{1,5}) (\S+)$')
|
||||
CAA = re.compile(r'^(0|128) (issue|issuewild|iodef) "[^"\\]*"$')
|
||||
TARGET = re.compile(r'^[a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9_])?(\.[a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9_])?)*$')
|
||||
|
||||
#: The longest content of a TXT record, in characters.
|
||||
TXT_MAX = 2048
|
||||
|
||||
#: Whether this process has loaded DNS_SQL.
|
||||
_loaded = False
|
||||
|
||||
|
||||
class DatabaseError(Exception):
|
||||
"""The database of the name server failed."""
|
||||
|
||||
|
||||
class Failure(Exception):
|
||||
"""A request that cannot be done; status is 2 invalid, 3 no such domain or record, 4 the domain exists."""
|
||||
|
||||
def __init__(self, message, status=2):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
||||
|
||||
def provision():
|
||||
"""Create the role, its password file and the database of the name server when they are missing, and PowerDNS's
|
||||
tables in it; DatabaseError while pdns-backend-pgsql is not installed."""
|
||||
vpnrouter_auth.provision_database(DATABASE, DATABASE_SECRET)
|
||||
try:
|
||||
with psycopg.connect(vpnrouter_auth.local_connection(DATABASE, DATABASE_SECRET), autocommit=True) as connection:
|
||||
if connection.execute("SELECT to_regclass('public.domains')").fetchone()[0] is None:
|
||||
connection.execute(PDNS_SCHEMA.read_text(encoding='utf-8'))
|
||||
except OSError as exc:
|
||||
raise DatabaseError(f'the schema of PowerDNS cannot be read: {exc}') from exc
|
||||
except psycopg.Error as exc:
|
||||
raise DatabaseError(f'the database of the name server failed: {exc}') from exc
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def database():
|
||||
"""A connection to the database of the name server, with the router's tables and procedures, closed at the end;
|
||||
DatabaseError on any failure, also before provision() made it."""
|
||||
global _loaded
|
||||
try:
|
||||
schema = None if _loaded else DNS_SQL.read_text(encoding='utf-8')
|
||||
target = vpnrouter_auth.local_connection(DATABASE, DATABASE_SECRET)
|
||||
except OSError as exc:
|
||||
raise DatabaseError(f'the database of the name server is not ready: {exc}') from exc
|
||||
try:
|
||||
with psycopg.connect(target, autocommit=True, row_factory=dict_row) as connection:
|
||||
if schema is not None:
|
||||
connection.execute(schema)
|
||||
_loaded = True
|
||||
yield connection
|
||||
except psycopg.Error as exc:
|
||||
raise DatabaseError(f'the database of the name server failed: {exc}') from exc
|
||||
|
||||
|
||||
def in_zone(zone, name):
|
||||
return name == zone or name.endswith(f'.{zone}')
|
||||
|
||||
|
||||
def absolute(zone, name):
|
||||
"""The record name in the zone: @ or '' is the zone itself, a name ending in a dot is taken as it is, any other is
|
||||
relative to the zone; Failure for a name outside the zone or not a DNS name."""
|
||||
if not isinstance(name, str):
|
||||
raise Failure('name must be a string')
|
||||
name = name.strip().lower()
|
||||
if name in ('', '@'):
|
||||
return zone
|
||||
full = name[:-1] if name.endswith('.') else f'{name}.{zone}'
|
||||
labels = full.split('.')
|
||||
if not in_zone(zone, full) or not all(LABEL.match(label) or (index == 0 and label == '*')
|
||||
for index, label in enumerate(labels)):
|
||||
raise Failure(f'{name!r} is not a name in {zone}')
|
||||
return full
|
||||
|
||||
|
||||
def check_record(zone, record):
|
||||
"""The manual record {name, type, content, ttl?} with its name made absolute and its content checked; Failure when
|
||||
it is not valid. A TXT content not in quotes is quoted."""
|
||||
if not isinstance(record, dict):
|
||||
raise Failure('a record is an object of name, type, content and ttl')
|
||||
name = absolute(zone, record.get('name'))
|
||||
kind = record.get('type')
|
||||
content = record.get('content')
|
||||
ttl = record.get('ttl')
|
||||
if kind not in TYPES:
|
||||
raise Failure(f'type must be one of {", ".join(TYPES)}')
|
||||
if not isinstance(content, str) or not content.strip():
|
||||
raise Failure('content must be a string')
|
||||
if ttl is not None and (isinstance(ttl, bool) or not isinstance(ttl, int) or not 60 <= ttl <= 86400):
|
||||
raise Failure('ttl must be 60 to 86400 seconds')
|
||||
content = content.strip()
|
||||
if kind in ('A', 'AAAA'):
|
||||
try:
|
||||
address = ipaddress.ip_address(content)
|
||||
except ValueError:
|
||||
address = None
|
||||
if address is None or address.version != (4 if kind == 'A' else 6):
|
||||
raise Failure(f'{content!r} is not an IPv{4 if kind == "A" else 6} address')
|
||||
content = str(address)
|
||||
elif kind == 'CNAME':
|
||||
if name == zone:
|
||||
raise Failure('the zone itself cannot have a CNAME record')
|
||||
content = content.lower().rstrip('.')
|
||||
if not TARGET.match(content):
|
||||
raise Failure(f'{content!r} is not a DNS name')
|
||||
elif kind == 'TXT':
|
||||
if not (content.startswith('"') and content.endswith('"') and len(content) > 1):
|
||||
content = '"' + content.replace('\\', '\\\\').replace('"', '\\"') + '"'
|
||||
if len(content) > TXT_MAX or not content.isprintable():
|
||||
raise Failure(f'the text must have at most {TXT_MAX} printable characters')
|
||||
elif kind in ('MX', 'SRV'):
|
||||
match = (MX if kind == 'MX' else SRV).match(content.lower())
|
||||
if not match or any(int(value) > 65535 for value in match.groups()[:-1]) or \
|
||||
not TARGET.match(match.groups()[-1].rstrip('.')):
|
||||
raise Failure(f'{kind} content is ' + ('"<preference> <host>"' if kind == 'MX'
|
||||
else '"<priority> <weight> <port> <host>"'))
|
||||
content = ' '.join([*match.groups()[:-1], match.groups()[-1].rstrip('.')])
|
||||
elif not CAA.match(content):
|
||||
raise Failure('CAA content is "<0 or 128> <issue, issuewild or iodef> \\"<value>\\""')
|
||||
return {'name': name, 'type': kind, 'content': content, 'ttl': ttl}
|
||||
|
||||
|
||||
def closest(zones, name):
|
||||
"""The longest of the zones the name is in, None when it is in none."""
|
||||
return max((zone for zone in zones if in_zone(zone, name)), key=len, default=None)
|
||||
|
||||
|
||||
def check_domain(name):
|
||||
"""The domain name in lowercase without a final dot; Failure when it is not a DNS name."""
|
||||
if not isinstance(name, str) or not vpnrouter.ZONE_NAME.match(name.strip().lower().rstrip('.')):
|
||||
raise Failure(f'{name!r} is not a domain name')
|
||||
return name.strip().lower().rstrip('.')
|
||||
|
||||
|
||||
def check_ttl(ttl):
|
||||
if ttl is not None and (isinstance(ttl, bool) or not isinstance(ttl, int) or
|
||||
not vpnrouter.DNS_TTL_RANGE[0] <= ttl <= vpnrouter.DNS_TTL_RANGE[1]):
|
||||
raise Failure(f'ttl must be {vpnrouter.DNS_TTL_RANGE[0]} to {vpnrouter.DNS_TTL_RANGE[1]} seconds')
|
||||
return ttl
|
||||
|
||||
|
||||
def domain(connection, name):
|
||||
"""The domain; Failure with status 3 when there is none."""
|
||||
row = next((row for row in connection.execute('SELECT * FROM dns_domains_list()').fetchall()
|
||||
if row['name'] == name), None)
|
||||
if row is None:
|
||||
raise Failure(f'no domain {name}', 3)
|
||||
return row
|
||||
|
||||
|
||||
def listing():
|
||||
"""Every domain with its manual records and the records the name server answers for it."""
|
||||
with database() as connection:
|
||||
manual = connection.execute('SELECT * FROM dns_records_list()').fetchall()
|
||||
return [{**row,
|
||||
'manual': [{key: item[key] for key in ('name', 'type', 'content', 'ttl')}
|
||||
for item in manual if item['domain'] == row['name']],
|
||||
'records': connection.execute('SELECT * FROM dns_served(%s)', (row['name'],)).fetchall()}
|
||||
for row in connection.execute('SELECT * FROM dns_domains_list()').fetchall()]
|
||||
|
||||
|
||||
def domain_add(body):
|
||||
"""Add the domain {name, ttl?}; the domain."""
|
||||
if not isinstance(body, dict) or not set(body) <= {'name', 'ttl'}:
|
||||
raise Failure('a domain is an object of name and ttl')
|
||||
name, ttl = check_domain(body.get('name')), check_ttl(body.get('ttl'))
|
||||
with database() as connection:
|
||||
names = [row['name'] for row in connection.execute('SELECT * FROM dns_domains_list()').fetchall()]
|
||||
if name in names:
|
||||
raise Failure(f'the domain {name} exists', 4)
|
||||
if len(names) >= DOMAINS_MAX:
|
||||
raise Failure(f'the name server has {DOMAINS_MAX} domains')
|
||||
connection.execute('CALL dns_domain_put(%s, %s)', (name, ttl))
|
||||
return {'name': name, 'ttl': ttl}
|
||||
|
||||
|
||||
def domain_change(name, body):
|
||||
"""Change the ttl of the domain; the domain."""
|
||||
if not isinstance(body, dict) or not set(body) <= {'ttl'}:
|
||||
raise Failure('a change of a domain is an object of ttl')
|
||||
with database() as connection:
|
||||
row = domain(connection, name)
|
||||
ttl = check_ttl(body.get('ttl', row['ttl']))
|
||||
connection.execute('CALL dns_domain_put(%s, %s)', (name, ttl))
|
||||
return {'name': name, 'ttl': ttl}
|
||||
|
||||
|
||||
def domain_remove(name):
|
||||
"""Remove the domain with its manual records."""
|
||||
with database() as connection:
|
||||
domain(connection, name)
|
||||
connection.execute('CALL dns_domain_remove(%s)', (name,))
|
||||
return {'name': name}
|
||||
|
||||
|
||||
def secondary_addresses(cfg):
|
||||
"""The addresses dns.secondary resolves to, which are notified of the zones' changes and may transfer them; none,
|
||||
with a warning, while it is not set or does not resolve."""
|
||||
secondary = cfg['dns']['secondary'].strip()
|
||||
if not secondary:
|
||||
vpnrouter.warn('dns.secondary is not set: the domains have no secondary name server')
|
||||
return []
|
||||
try:
|
||||
found = socket.getaddrinfo(secondary, 53, proto=socket.IPPROTO_UDP)
|
||||
except OSError as exc:
|
||||
vpnrouter.warn(f'dns.secondary {secondary} does not resolve, so it may not transfer the domains: {exc}')
|
||||
return []
|
||||
return list(dict.fromkeys(str(ipaddress.ip_address(item[4][0])) for item in found))
|
||||
|
||||
|
||||
def add(cfg, name, record):
|
||||
"""Add the manual record to the domain, or set the TTL of the same one; the record. A name in a domain below it
|
||||
belongs to that domain."""
|
||||
with database() as connection:
|
||||
row = domain(connection, name)
|
||||
record = check_record(name, record)
|
||||
record['ttl'] = record['ttl'] or row['ttl'] or cfg['dns']['ttl']
|
||||
zones = [item['name'] for item in connection.execute('SELECT * FROM dns_domains_list()').fetchall()]
|
||||
owner = closest(zones, record['name'])
|
||||
if owner != name:
|
||||
raise Failure(f'{record["name"]} is in the domain {owner}')
|
||||
records = [item for item in connection.execute('SELECT * FROM dns_records_list()').fetchall()
|
||||
if item['domain'] == name]
|
||||
same = [item for item in records if (item['name'], item['type']) == (record['name'], record['type'])]
|
||||
others = [item for item in records if item['name'] == record['name'] and item['type'] != record['type']]
|
||||
if len(records) >= MANUAL_MAX and not same:
|
||||
raise Failure(f'the domain {name} has {MANUAL_MAX} manual records')
|
||||
if record['type'] == 'CNAME' and (others or [item for item in same if item['content'] != record['content']]):
|
||||
raise Failure(f'{record["name"]} has other records; a CNAME record must be its only record')
|
||||
if any(item['type'] == 'CNAME' for item in others):
|
||||
raise Failure(f'{record["name"]} has a CNAME record, which must be its only record')
|
||||
connection.execute('CALL dns_record_put(%s, %s, %s, %s, %s)',
|
||||
(name, record['name'], record['type'], record['content'], record['ttl']))
|
||||
return record
|
||||
|
||||
|
||||
def remove(name, record_name, kind, content):
|
||||
"""Remove the manual records of the name and type in the domain, only the one of the content when it is given."""
|
||||
full = absolute(name, record_name)
|
||||
with database() as connection:
|
||||
domain(connection, name)
|
||||
found = [row for row in connection.execute('SELECT * FROM dns_records_list()').fetchall()
|
||||
if (row['domain'], row['name'], row['type']) == (name, full, kind)
|
||||
and content in (None, row['content'])]
|
||||
if not found:
|
||||
raise Failure(f'no {kind} record of {full}', 3)
|
||||
connection.execute('CALL dns_record_remove(%s, %s, %s, %s)', (name, full, kind, content))
|
||||
return {'name': full, 'type': kind, 'removed': len(found)}
|
||||
|
||||
|
||||
def records(cfg, row, zones, hosts, manual_records):
|
||||
"""The records of the domain row besides its SOA, as (name, type, content, ttl): the NS of the domain and of the
|
||||
domains directly below it, wan.local_fqdn and dns.secondary, the A or AAAA record of each host name of hosts, pairs
|
||||
of a host name and an address, whose closest domain it is, and its manual records."""
|
||||
dns = cfg['dns']
|
||||
zone, ttl = row['name'], row['ttl'] or dns['ttl']
|
||||
servers = [cfg['wan']['local_fqdn'].strip(), *([dns['secondary'].strip()] if dns['secondary'].strip() else [])]
|
||||
children = [name for name in zones
|
||||
if name != zone and closest([item for item in zones if item != name], name) == zone]
|
||||
found = [(name, 'NS', server, ttl) for name in [zone, *children] for server in servers]
|
||||
for host, address in hosts:
|
||||
if closest(zones, host) == zone:
|
||||
address = ipaddress.ip_address(address)
|
||||
found.append((host, 'A' if address.version == 4 else 'AAAA', str(address), ttl))
|
||||
taken = {(name, kind) for name, kind, _, _ in found}
|
||||
found += [(item['name'], item['type'], item['content'], item['ttl']) for item in manual_records
|
||||
if (item['name'], item['type']) not in taken]
|
||||
return list(dict.fromkeys(found))
|
||||
|
||||
|
||||
def apply(cfg, hosts):
|
||||
"""Make the name server the primary of every domain with records(), hosts being the pairs of a host name and an
|
||||
address of the sites with dns router, and of none while dns.enabled is false."""
|
||||
with database() as connection:
|
||||
if not cfg['dns']['enabled']:
|
||||
connection.execute('CALL dns_retain(%s::text[])', ([],))
|
||||
return
|
||||
dns = cfg['dns']
|
||||
domains = connection.execute('SELECT * FROM dns_domains_list()').fetchall()
|
||||
zones = [row['name'] for row in domains]
|
||||
for host, _ in hosts:
|
||||
if closest(zones, host) is None:
|
||||
vpnrouter.warn(f'{host} is in none of the domains, so the name server has no record of it')
|
||||
manual = {}
|
||||
for item in connection.execute('SELECT * FROM dns_records_list()').fetchall():
|
||||
manual.setdefault(item['domain'], []).append(item)
|
||||
transfers = secondary_addresses(cfg) if domains else []
|
||||
for row in domains:
|
||||
zone, ttl = row['name'], row['ttl'] or dns['ttl']
|
||||
rows = records(cfg, row, zones, hosts, manual.get(zone, []))
|
||||
soa = f'{cfg["wan"]["local_fqdn"].strip()} hostmaster.{zone} {{serial}} {SOA_TIMES} {ttl}'
|
||||
connection.execute('CALL dns_apply(%s, %s, %s, %s::text[], %s::text[], %s::text[], %s::integer[], '
|
||||
'%s::text[])',
|
||||
(zone, soa, ttl, [item[0] for item in rows], [item[1] for item in rows],
|
||||
[item[2] for item in rows], [item[3] for item in rows], transfers))
|
||||
connection.execute('CALL dns_retain(%s::text[])', (zones,))
|
||||
|
||||
|
||||
def config_text(addresses):
|
||||
"""The settings of PowerDNS: the gpgsql backend on the database of the name server, primary operation, and the
|
||||
addresses it answers on."""
|
||||
password = DATABASE_SECRET.read_text(encoding='utf-8').strip()
|
||||
return '\n'.join([
|
||||
'# Written by vpn-router-setup from /etc/vpn-router/vpn-router.json; edits are overwritten.',
|
||||
'launch=gpgsql',
|
||||
'gpgsql-host=localhost',
|
||||
f'gpgsql-dbname={DATABASE}',
|
||||
f'gpgsql-user={DATABASE}',
|
||||
f'gpgsql-password={password}',
|
||||
'primary=yes',
|
||||
f'local-address={", ".join(addresses)}',
|
||||
'local-port=53',
|
||||
'',
|
||||
])
|
||||
@@ -18,6 +18,9 @@ vpnRouterConsoleLogin TRUE to the system over the local socket.
|
||||
|
||||
ou=pki holds the router's PKI (vpnrouter_pki): an entry per CA, and under it an entry per
|
||||
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
|
||||
@@ -39,6 +42,7 @@ GROUPS = f'ou=groups,{BASE}'
|
||||
SERVICES = f'ou=services,{BASE}'
|
||||
POLICIES = f'ou=policies,{BASE}'
|
||||
PKI = f'ou=pki,{BASE}'
|
||||
ROLES = f'ou=roles,{BASE}'
|
||||
DEFAULT_POLICY = f'cn=default,{POLICIES}'
|
||||
TOTP_PARAMS = f'cn=totp,{POLICIES}'
|
||||
MANAGER = f'cn=user-manager,{SERVICES}'
|
||||
@@ -71,7 +75,12 @@ OVERLAYS = ('ppolicy', 'otp')
|
||||
#: passkey of the user as JSON (vpnrouter_passkeys). A service account with vpnRouterRedirectUri
|
||||
#: is a client of the OpenID Provider (vpnrouter_oidc): the URIs it is sent back to after a sign-in
|
||||
#: and, vpnRouterPostLogoutRedirectUri, after a sign-out, and vpnRouterAllowedGroup, the DNs of the
|
||||
#: groups whose members may sign in to it. vpnRouterAuthority is a CA of the PKI: its
|
||||
#: groups whose members may sign in to it; vpnRouterGrantType, the further grants it may use,
|
||||
#: vpnRouterPublicClient, whether it has no secret, and vpnRouterClientScope, the scopes of
|
||||
#: resources it may ask for, as <resource>:<scope>. A service account with vpnRouterResourceScope
|
||||
#: is a resource of the OpenID Provider with those scopes. vpnRouterRole is a role of a resource,
|
||||
#: named cn=<resource>:<role>, with the DNs of the groups and accounts it is assigned to in
|
||||
#: member. vpnRouterAuthority is a CA of the PKI: its
|
||||
#: certificate and CRL (cACertificate and certificateRevocationList of RFC 4523), its key, the
|
||||
#: CSR it waits with for the offline root, the number of its last CRL, where the CRL came from,
|
||||
#: and when it was retired. vpnRouterCertificate is a certificate a CA issued, named by its
|
||||
@@ -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} )",
|
||||
f"( {SCHEMA_OID}.1.19 NAME 'vpnRouterAllowedGroup' DESC 'VPN router: a group whose members may sign in to an "
|
||||
"OpenID Connect client' EQUALITY distinguishedNameMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 )",
|
||||
f"( {SCHEMA_OID}.1.20 NAME 'vpnRouterGrantType' DESC 'VPN router: a grant an OpenID Connect client may use besides "
|
||||
"the authorization code' EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{64} )",
|
||||
f"( {SCHEMA_OID}.1.21 NAME 'vpnRouterPublicClient' DESC 'VPN router: whether an OpenID Connect client has no secret' "
|
||||
"EQUALITY booleanMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 SINGLE-VALUE )",
|
||||
f"( {SCHEMA_OID}.1.22 NAME 'vpnRouterClientScope' DESC 'VPN router: a scope of a resource an OpenID Connect client "
|
||||
"may ask for' EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{128} )",
|
||||
f"( {SCHEMA_OID}.1.23 NAME 'vpnRouterResourceScope' DESC 'VPN router: a scope of a resource of the OpenID Provider' "
|
||||
"EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{64} )",
|
||||
)
|
||||
SCHEMA_OBJECT_CLASSES = (
|
||||
f"( {SCHEMA_OID}.2.1 NAME 'vpnRouterAccount' DESC 'VPN router: properties of a user or a Manager-only account' "
|
||||
"SUP top AUXILIARY MAY ( mail $ vpnRouterScope $ displayName $ description $ vpnRouterConsoleLogin $ "
|
||||
"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 "
|
||||
"MAY ( cACertificate $ certificateRevocationList $ vpnRouterPrivateKey $ vpnRouterCertificateRequest $ "
|
||||
"vpnRouterCRLNumber $ vpnRouterCRLSource $ vpnRouterRetired ) )",
|
||||
f"( {SCHEMA_OID}.2.3 NAME 'vpnRouterCertificate' DESC 'VPN router: a certificate a CA of the PKI issued' SUP top "
|
||||
"STRUCTURAL MUST serialNumber MAY ( cn $ userCertificate $ vpnRouterPrivateKey $ vpnRouterKind $ vpnRouterTemplate $ "
|
||||
"vpnRouterSubjectAltName $ vpnRouterNotAfter $ vpnRouterRevoked $ vpnRouterRevocationReason ) )",
|
||||
f"( {SCHEMA_OID}.2.4 NAME 'vpnRouterRole' DESC 'VPN router: a role of a resource of the OpenID Provider' SUP top "
|
||||
"STRUCTURAL MUST cn MAY ( description $ member ) )",
|
||||
)
|
||||
|
||||
#: The TOTP parameters: digits, the OID of HMAC-SHA1, seconds per time step, and the time steps accepted before and after now.
|
||||
@@ -152,6 +172,7 @@ ACCESS = (
|
||||
f'to attrs=oathSecret by dn.exact="{MANAGER}" =w by * none',
|
||||
f'to dn.subtree="{USERS}" by dn.exact="{MANAGER}" manage by * read',
|
||||
f'to dn.subtree="{GROUPS}" by dn.exact="{MANAGER}" manage by * read',
|
||||
f'to dn.subtree="{ROLES}" by dn.exact="{MANAGER}" manage by * read',
|
||||
'to * by * read',
|
||||
)
|
||||
|
||||
@@ -333,7 +354,7 @@ def provision():
|
||||
database = _ensure_database(connection)
|
||||
_ensure_overlays(connection, database)
|
||||
_ensure_entry(connection, BASE, {'objectClass': [b'dcObject', b'organization'], 'dc': [b'vpn-router'], 'o': [b'vpn-router']})
|
||||
for unit in (USERS, GROUPS, SERVICES, POLICIES, 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, DEFAULT_POLICY, {
|
||||
'objectClass': [b'organizationalRole', b'pwdPolicy'],
|
||||
|
||||
@@ -3,28 +3,45 @@
|
||||
While oidc.enabled is true, the web service answers OpenID Connect at the issuer vpnrouter.oidc_issuer, its HTTPS site
|
||||
that Caddy serves; the web service answers it only on the loopback address, where Caddy passes the requests. The users
|
||||
of the router's directory sign in, with the password and the TOTP code or with a passkey (vpnrouter_passkeys), to its
|
||||
clients, the service accounts with redirect URIs (vpnrouter_users.client_settings): a client's ID is its name and its
|
||||
secret its password, sent with HTTP Basic authentication or in the body of the token request. A client with allowed
|
||||
groups admits only their members; a service account never signs in as a user.
|
||||
clients, the service accounts with redirect URIs or grants (vpnrouter_users.client_settings): a client's ID is its name
|
||||
and its secret its password, sent with HTTP Basic authentication or in the body of the token request; a public client
|
||||
sends only its ID. A client with allowed groups admits only their members; a service account never signs in as a user.
|
||||
|
||||
GET /.well-known/openid-configuration the discovery document
|
||||
GET /oidc/jwks the public key the ID tokens 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
|
||||
the browser has no session, or with prompt=login or an older one than max_age
|
||||
POST /oidc/login the sign-in page's form: the name, the password and the code, or a passkey
|
||||
POST /oidc/passkey begin a sign-in with a passkey: {"id", "options"}
|
||||
POST /oidc/token exchange a code or a refresh token for tokens
|
||||
POST /oidc/token exchange a code, a refresh token or a device code for tokens, or a client's
|
||||
credentials for an access token
|
||||
POST /oidc/device_authorization begin the device authorization grant (RFC 8628): a device code and a user code
|
||||
GET, POST /oidc/device the page where the user enters the user code, signs in and is asked to allow
|
||||
the device
|
||||
POST /oidc/device/decide allow or deny the device
|
||||
GET, POST /oidc/userinfo the claims of the user an access token was issued for
|
||||
GET /oidc/logout end the session, then go to a post-logout redirect URI of the client
|
||||
|
||||
ID tokens are RS256 JWTs signed with KEY, an RSA key the web service makes on its first run on the router, named by
|
||||
the RFC 7638 thumbprint of its public key. sub is the entryUUID of the user's entry, so a user added again under the
|
||||
same name is another subject. The scopes: openid, required; profile, preferred_username and the names; email; groups,
|
||||
the names of the user's groups; their claims are in the ID token and the userinfo answer. A code is valid
|
||||
CODE_SECONDS, an access token and an ID token ACCESS_SECONDS, a refresh token REFRESH_SECONDS, used once. A session,
|
||||
the cookie COOKIE of the issuer's /oidc/ path, lasts SESSION_SECONDS. Codes, tokens and sessions are kept in the
|
||||
database of vpnrouter_auth as their SHA-256 (vpnrouter_auth.OIDC_SQL); vpnrouter_auth.revoke removes a user's. A
|
||||
client is registered by an administrator, so a user is not asked for consent.
|
||||
The grants: authorization_code for a client with redirect URIs, client_credentials, by which a confidential client
|
||||
gets an access token as itself, and the device code, as the client's grants give them, each with refresh_token but
|
||||
client_credentials. A client asks for tokens for a resource, a service account, with resource (RFC 8707), its name or
|
||||
<issuer>/<name>, or by the resource's scopes it asks for, <resource>:<scope>, of its client scopes; without one the
|
||||
tokens are for the client itself.
|
||||
|
||||
ID tokens are RS256 JWTs signed with KEY, an RSA key the web service makes on its first run on the router, named by the
|
||||
RFC 7638 thumbprint of its public key, for the client. Access tokens are RS256 JWTs of RFC 9068 with the same key, for
|
||||
the resource or the client: iss, sub, aud, client_id, iat, exp, jti, scope, roles, the claims of the scopes, and of a
|
||||
user auth_time and amr. sub is the entryUUID of the user's entry, so a user added again under the same name is another
|
||||
subject. The scopes: openid, required but with client_credentials, which refuses it; profile, preferred_username and
|
||||
the names; email; groups, the names of the user's groups; their claims are in the ID token, the access token and the
|
||||
userinfo answer; and the scopes of the resources. roles are the roles of the resource assigned to the user or service
|
||||
account or a group of it (vpnrouter_users.roles_held), read at every issue; the ID token and the userinfo answer have
|
||||
those of the client. A code is valid CODE_SECONDS, an access token and an ID token ACCESS_SECONDS, a refresh token
|
||||
REFRESH_SECONDS, used once, and a device code DEVICE_SECONDS, polled at most every DEVICE_INTERVAL seconds, 5 more after
|
||||
each poll that came too soon. A session, the cookie COOKIE of the issuer's /oidc/ path, lasts SESSION_SECONDS. Codes,
|
||||
tokens, device codes and sessions are kept in the database of vpnrouter_auth as their SHA-256
|
||||
(vpnrouter_auth.OIDC_SQL); vpnrouter_auth.revoke removes a user's or a service account's. A client is registered by an
|
||||
administrator, so a user is not asked for consent, but for a device, which the user allows on the router's page.
|
||||
"""
|
||||
|
||||
import base64
|
||||
@@ -45,8 +62,13 @@ import jwt
|
||||
import ldap
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from oauthlib.common import add_params_to_uri
|
||||
from oauthlib.oauth2 import AccessDeniedError, FatalClientError, InvalidRequestError, OAuth2Error
|
||||
from oauthlib.common import Request, add_params_to_uri
|
||||
from oauthlib.oauth2 import (AccessDeniedError, FatalClientError, InvalidClientError, InvalidGrantError,
|
||||
InvalidRequestError, InvalidScopeError, OAuth2Error, UnauthorizedClientError,
|
||||
UnsupportedGrantTypeError)
|
||||
from oauthlib.oauth2.rfc6749 import utils
|
||||
from oauthlib.oauth2.rfc6749.grant_types.base import GrantTypeBase
|
||||
from oauthlib.oauth2.rfc6749.tokens import random_token_generator
|
||||
from oauthlib.openid import RequestValidator, Server
|
||||
from oauthlib.openid.connect.core.exceptions import LoginRequired
|
||||
|
||||
@@ -63,6 +85,14 @@ CODE_SECONDS = 60
|
||||
ACCESS_SECONDS = 300
|
||||
REFRESH_SECONDS = 8 * 3600
|
||||
SESSION_SECONDS = 8 * 3600
|
||||
DEVICE_SECONDS = 600
|
||||
DEVICE_INTERVAL = 5
|
||||
|
||||
#: The grant type of the device authorization grant.
|
||||
DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'
|
||||
#: A user code: USER_CODE_LENGTH of these letters, shown in two halves.
|
||||
USER_CODE_LETTERS = 'BCDFGHJKLMNPQRSTVWXZ'
|
||||
USER_CODE_LENGTH = 8
|
||||
|
||||
COOKIE = 'vpn_router_oidc'
|
||||
|
||||
@@ -75,10 +105,11 @@ 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
|
||||
#: passkey, which verifies the user.
|
||||
#: passkey, which verifies the user; a service account's client credentials are its password.
|
||||
PASSWORD_AMR = ['pwd']
|
||||
OTP_AMR = ['pwd', 'otp']
|
||||
PASSKEY_AMR = ['hwk', 'user']
|
||||
SERVICE_AMR = ['pwd']
|
||||
|
||||
TEMPLATE = 'oidc-page.html.j2'
|
||||
|
||||
@@ -96,6 +127,26 @@ _pages = jinja2.Environment(loader=jinja2.FileSystemLoader(str(vpnrouter.TEMPLAT
|
||||
undefined=jinja2.StrictUndefined)
|
||||
|
||||
|
||||
class InvalidTarget(OAuth2Error):
|
||||
"""A resource that is unknown, or more than one (RFC 8707)."""
|
||||
error = 'invalid_target'
|
||||
|
||||
|
||||
class AuthorizationPending(OAuth2Error):
|
||||
"""A device code the user has not yet allowed or denied (RFC 8628)."""
|
||||
error = 'authorization_pending'
|
||||
|
||||
|
||||
class SlowDown(OAuth2Error):
|
||||
"""A device code polled sooner than its interval (RFC 8628)."""
|
||||
error = 'slow_down'
|
||||
|
||||
|
||||
class ExpiredToken(OAuth2Error):
|
||||
"""A device code that has expired (RFC 8628)."""
|
||||
error = 'expired_token'
|
||||
|
||||
|
||||
def ensure_key():
|
||||
"""Make the signing key when it is missing."""
|
||||
if KEY.is_file() and KEY.stat().st_size:
|
||||
@@ -122,30 +173,33 @@ def public_jwk(key):
|
||||
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()
|
||||
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):
|
||||
"""The discovery document of OpenID Connect Discovery 1.0."""
|
||||
def discovery(issuer, resource_scopes):
|
||||
"""The discovery document of OpenID Connect Discovery 1.0, with the device authorization endpoint of RFC 8628;
|
||||
resource_scopes are the scopes of the resources."""
|
||||
return {
|
||||
'issuer': issuer,
|
||||
'authorization_endpoint': f'{issuer}/oidc/authorize',
|
||||
'token_endpoint': f'{issuer}/oidc/token',
|
||||
'device_authorization_endpoint': f'{issuer}/oidc/device_authorization',
|
||||
'userinfo_endpoint': f'{issuer}/oidc/userinfo',
|
||||
'jwks_uri': f'{issuer}/oidc/jwks',
|
||||
'end_session_endpoint': f'{issuer}/oidc/logout',
|
||||
'scopes_supported': list(SCOPES),
|
||||
'scopes_supported': [*SCOPES, *resource_scopes],
|
||||
'response_types_supported': ['code'],
|
||||
'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'],
|
||||
'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'],
|
||||
'claims_supported': ['iss', 'sub', 'aud', 'exp', 'iat', 'auth_time', 'nonce', 'amr', 'at_hash',
|
||||
*(claim for claims in SCOPE_CLAIMS.values() for claim in claims)],
|
||||
'claims_supported': ['iss', 'sub', 'aud', 'exp', 'iat', 'auth_time', 'nonce', 'amr', 'at_hash', 'client_id',
|
||||
'scope', 'roles', *(claim for claims in SCOPE_CLAIMS.values() for claim in claims)],
|
||||
}
|
||||
|
||||
|
||||
@@ -159,19 +213,52 @@ def directory():
|
||||
connection.unbind_s()
|
||||
|
||||
|
||||
def client_of(client_id):
|
||||
"""The client of the ID, a namespace of its vpnrouter_users.summary with client_id, when it is an enabled service
|
||||
account with redirect URIs; else None."""
|
||||
if not isinstance(client_id, str):
|
||||
def account_of(name):
|
||||
"""A namespace of the vpnrouter_users.summary of the service account of the name, else None."""
|
||||
if not isinstance(name, str):
|
||||
return None
|
||||
with directory() as connection:
|
||||
try:
|
||||
user = vpnrouter_users.summary(vpnrouter_users.entry(connection, client_id))
|
||||
user = vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
|
||||
except vpnrouter_users.Failure:
|
||||
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 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):
|
||||
@@ -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."""
|
||||
with directory() as connection:
|
||||
try:
|
||||
attributes = vpnrouter_users.entry(connection, name)
|
||||
user, claims = account_claims(connection, name)
|
||||
except vpnrouter_users.Failure:
|
||||
return None
|
||||
user = vpnrouter_users.summary(attributes)
|
||||
groups = sorted(vpnrouter_users.groups_of(connection, name))
|
||||
uuid = connection.search_s(vpnrouter_users.dn_of(name), ldap.SCOPE_BASE, attrlist=['entryUUID'])[0][1]['entryUUID']
|
||||
if user['service'] or not user['enabled'] or user['locked']:
|
||||
return None
|
||||
if client.allowed_groups and not set(client.allowed_groups) & set(groups):
|
||||
if client.allowed_groups and not set(client.allowed_groups) & set(claims['groups']):
|
||||
return None
|
||||
full = ' '.join(part for part in (user['first_name'], user['last_name']) if part)
|
||||
claims = {'sub': uuid[0].decode(), 'preferred_username': name, 'name': full or None, 'given_name': user['first_name'],
|
||||
'family_name': user['last_name'], 'email': user['email'], 'groups': groups}
|
||||
return {claim: value for claim, value in claims.items() if value is not None}
|
||||
return claims
|
||||
|
||||
|
||||
def service_subject(name):
|
||||
"""The claims of every scope of the service account, with sub."""
|
||||
with directory() as connection:
|
||||
return account_claims(connection, name)[1]
|
||||
|
||||
|
||||
def roles_at(resource, name):
|
||||
"""The roles of the resource the user or service account holds."""
|
||||
with directory() as connection:
|
||||
return vpnrouter_users.roles_held(connection, resource, name)
|
||||
|
||||
|
||||
def claims_of(person, scopes):
|
||||
@@ -201,6 +294,46 @@ def claims_of(person, scopes):
|
||||
if claim in person}}
|
||||
|
||||
|
||||
def target_of(issuer, resource, scopes):
|
||||
"""The name of the resource a request with the resource parameter, None when absent, and the scopes names, None
|
||||
for none; InvalidTarget when they name more than one or one that is not a resource."""
|
||||
names = {scope.partition(':')[0] for scope in scopes if scope not in SCOPES}
|
||||
if resource is not None:
|
||||
names.add(resource[len(issuer) + 1:] if isinstance(resource, str) and resource.startswith(f'{issuer}/') else resource)
|
||||
if not names:
|
||||
return None
|
||||
if len(names) > 1:
|
||||
raise InvalidTarget(description='the resource and the scopes name more than one resource')
|
||||
name = names.pop()
|
||||
if resource_of(name) is None:
|
||||
raise InvalidTarget(description=f'{name} is not a resource')
|
||||
return name
|
||||
|
||||
|
||||
def scopes_allowed(client, scopes, target, grant_type):
|
||||
"""Whether the client may ask for the scopes: openid, required but by client_credentials, which refuses it, the
|
||||
other scopes of SCOPES, and those of its client scopes that belong to the target."""
|
||||
for scope in scopes:
|
||||
if scope not in SCOPES and (scope not in client.client_scopes or scope.partition(':')[0] != target):
|
||||
return False
|
||||
return ('openid' not in scopes) if grant_type == 'client_credentials' else ('openid' in scopes)
|
||||
|
||||
|
||||
def same_redirect(registered, given):
|
||||
"""Whether the redirect URI given is the registered one; an http URI of a loopback host may have any port (RFC 8252
|
||||
7.3)."""
|
||||
if registered == given:
|
||||
return True
|
||||
try:
|
||||
wanted, found = urllib.parse.urlsplit(registered), urllib.parse.urlsplit(given)
|
||||
# A port that is not a number makes .port raise ValueError.
|
||||
wanted.port, found.port
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
return False
|
||||
return wanted.scheme == 'http' and wanted.hostname in vpnrouter_users.LOOPBACK_HOSTS and not found.fragment and \
|
||||
(wanted.scheme, wanted.hostname, wanted.path, wanted.query) == (found.scheme, found.hostname, found.path, found.query)
|
||||
|
||||
|
||||
def cookie_value(cookies):
|
||||
"""The value of the session cookie in a Cookie header, or ''."""
|
||||
try:
|
||||
@@ -220,11 +353,31 @@ def session_of(cfg, cookies):
|
||||
return Session(*row) if row else None
|
||||
|
||||
|
||||
def access_token(cfg, request):
|
||||
"""The access token of a token request: a JWT of RFC 9068 for the request's target, or the client."""
|
||||
now = int(time.time())
|
||||
client_id = request.client.client_id
|
||||
service = request.grant_type == 'client_credentials'
|
||||
name = client_id if service else request.user
|
||||
person = service_subject(client_id) if service else request.oidc_person
|
||||
target = getattr(request, 'oidc_target', None)
|
||||
claims = {**claims_of(person, request.scopes), 'iss': vpnrouter.oidc_issuer(cfg), 'aud': target or client_id,
|
||||
'client_id': client_id, 'iat': now, 'exp': now + ACCESS_SECONDS, 'jti': secrets.token_urlsafe(16),
|
||||
'scope': ' '.join(request.scopes)}
|
||||
roles = roles_at(target or client_id, name)
|
||||
if roles:
|
||||
claims['roles'] = roles
|
||||
if not service:
|
||||
claims.update({'auth_time': int(request.auth_time.timestamp()), 'amr': request.amr})
|
||||
return sign(claims, {'typ': 'at+jwt'})
|
||||
|
||||
|
||||
class Validator(RequestValidator):
|
||||
"""The checks and the storage of oauthlib's server for the router's clients, users and database.
|
||||
|
||||
Only the authorization code grant with PKCE S256 and the refresh token grant are allowed. The values of a code, a
|
||||
token or a session found are kept on the request as oidc_code, oidc_scopes and oidc_person.
|
||||
The grants are those of validate_grant_type, with PKCE S256 for the authorization code. The values of a code, a
|
||||
token, a device code or a session found are kept on the request as oidc_code, oidc_scopes and oidc_person, the
|
||||
resource the tokens are for as oidc_target.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg):
|
||||
@@ -243,6 +396,14 @@ class Validator(RequestValidator):
|
||||
def session(self, request):
|
||||
return session_of(self.cfg, request.headers.get('Cookie'))
|
||||
|
||||
def check_target(self, request, stored):
|
||||
"""Refuse a resource parameter that names another resource than the one the grant was made for."""
|
||||
resource = getattr(request, 'resource', None)
|
||||
if 'resource' in request.duplicate_params or \
|
||||
resource is not None and target_of(vpnrouter.oidc_issuer(self.cfg), resource, []) != stored:
|
||||
raise InvalidTarget(description='the grant was made for another resource', request=request)
|
||||
request.oidc_target = stored
|
||||
|
||||
# Clients.
|
||||
|
||||
def validate_client_id(self, client_id, request, *args, **kwargs):
|
||||
@@ -250,7 +411,7 @@ class Validator(RequestValidator):
|
||||
return request.client is not None
|
||||
|
||||
def validate_redirect_uri(self, client_id, redirect_uri, request, *args, **kwargs):
|
||||
return redirect_uri in request.client.redirect_uris
|
||||
return any(same_redirect(uri, redirect_uri) for uri in request.client.redirect_uris)
|
||||
|
||||
def get_default_redirect_uri(self, client_id, request, *args, **kwargs):
|
||||
return None
|
||||
@@ -259,17 +420,29 @@ class Validator(RequestValidator):
|
||||
return response_type == 'code'
|
||||
|
||||
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):
|
||||
return ['openid']
|
||||
return [] if request.grant_type == 'client_credentials' else ['openid']
|
||||
|
||||
def is_pkce_required(self, client_id, request):
|
||||
return True
|
||||
|
||||
def client_authentication_required(self, request, *args, **kwargs):
|
||||
"""Whether the client must sign the request with its secret: unless it is a public client named by client_id."""
|
||||
client = client_of(request.client_id)
|
||||
return client is None or not client.public_client
|
||||
|
||||
def authenticate_client(self, request, *args, **kwargs):
|
||||
"""Check the client's secret, the password of its service account, given with HTTP Basic authentication, its
|
||||
ID and secret form-encoded (RFC 6749 2.3.1), or in the body."""
|
||||
ID and secret form-encoded (RFC 6749 2.3.1), or in the body; a public client has none."""
|
||||
name, secret = request.client_id, request.client_secret
|
||||
header = request.headers.get('Authorization', '')
|
||||
if header[:6].lower() == 'basic ':
|
||||
@@ -279,7 +452,7 @@ class Validator(RequestValidator):
|
||||
return False
|
||||
name, secret = urllib.parse.unquote_plus(name), urllib.parse.unquote_plus(secret)
|
||||
client = client_of(name)
|
||||
if client is None:
|
||||
if client is None or client.public_client:
|
||||
return False
|
||||
try:
|
||||
vpnrouter_users.login(name, secret, None)
|
||||
@@ -289,10 +462,24 @@ class Validator(RequestValidator):
|
||||
return True
|
||||
|
||||
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):
|
||||
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):
|
||||
return False
|
||||
@@ -300,15 +487,15 @@ class Validator(RequestValidator):
|
||||
# Codes.
|
||||
|
||||
def save_authorization_code(self, client_id, code, request, *args, **kwargs):
|
||||
self.call(('CALL oidc_code_save(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)',
|
||||
self.call(('CALL oidc_code_save(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)',
|
||||
(vpnrouter_auth.digest(code['code']), client_id, request.user, request.redirect_uri, request.scopes,
|
||||
request.nonce, request.code_challenge, request.code_challenge_method, request.auth_time, request.amr,
|
||||
CODE_SECONDS)))
|
||||
getattr(request, 'oidc_target', None), CODE_SECONDS)))
|
||||
|
||||
def code(self, code):
|
||||
row = self.fetch('SELECT * FROM oidc_code_get(%s)', (vpnrouter_auth.digest(code),)) if isinstance(code, str) else None
|
||||
return dict(zip(('client_id', 'name', 'redirect_uri', 'scopes', 'nonce', 'code_challenge', 'code_challenge_method',
|
||||
'auth_time', 'amr'), 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):
|
||||
found = self.code(code)
|
||||
@@ -321,6 +508,7 @@ class Validator(RequestValidator):
|
||||
person = subject(found['name'], client)
|
||||
if person is None:
|
||||
return False
|
||||
self.check_target(request, found['resource'])
|
||||
request.user, request.scopes, request.auth_time, request.amr = found['name'], found['scopes'], found['auth_time'], found['amr']
|
||||
request.oidc_code, request.oidc_person = found, person
|
||||
return True
|
||||
@@ -340,20 +528,53 @@ class Validator(RequestValidator):
|
||||
def invalidate_authorization_code(self, client_id, code, request, *args, **kwargs):
|
||||
self.call(('CALL oidc_code_delete(%s)', (vpnrouter_auth.digest(code),)))
|
||||
|
||||
# Device codes.
|
||||
|
||||
def poll_device(self, code, request):
|
||||
"""Poll the device code for the request's client: with the user's sign-in on the request when the user allowed
|
||||
it, else the error RFC 8628 gives."""
|
||||
row = self.fetch('SELECT * FROM oidc_device_poll(%s)', (vpnrouter_auth.digest(code),)) \
|
||||
if isinstance(code, str) and code else None
|
||||
found = dict(zip(('client_id', 'scopes', 'resource', 'state', 'name', 'auth_time', 'amr', 'expired', 'too_soon'),
|
||||
row)) if row else None
|
||||
if found is None or found['client_id'] != request.client.client_id:
|
||||
raise InvalidGrantError(description='the device code is unknown', request=request)
|
||||
if found['expired']:
|
||||
raise ExpiredToken(description='the device code has expired', request=request)
|
||||
if found['state'] == 'pending':
|
||||
raise (SlowDown if found['too_soon'] else AuthorizationPending)(request=request)
|
||||
if found['state'] == 'denied':
|
||||
self.call(('CALL oidc_device_delete(%s)', (vpnrouter_auth.digest(code),)))
|
||||
raise AccessDeniedError(description='the user denied the device', request=request)
|
||||
person = subject(found['name'], request.client)
|
||||
if person is None:
|
||||
raise AccessDeniedError(description=f'{found["name"]} may not sign in to this client', request=request)
|
||||
self.check_target(request, found['resource'])
|
||||
request.user, request.scopes, request.auth_time, request.amr = found['name'], found['scopes'], found['auth_time'], found['amr']
|
||||
request.oidc_person = person
|
||||
|
||||
def delete_device(self, code):
|
||||
self.call(('CALL oidc_device_delete(%s)', (vpnrouter_auth.digest(code),)))
|
||||
|
||||
# Tokens.
|
||||
|
||||
def token(self, token, kind):
|
||||
row = self.fetch('SELECT * FROM oidc_token_get(%s, %s)', (vpnrouter_auth.digest(token), kind)) \
|
||||
if isinstance(token, str) and token else None
|
||||
return dict(zip(('client_id', 'name', 'scopes', 'auth_time', 'amr'), 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):
|
||||
"""Keep the access token and the refresh token; a refresh token that was exchanged is used up."""
|
||||
values = (request.client.client_id, request.user, request.scopes, request.auth_time, request.amr)
|
||||
statements = [('CALL oidc_token_save(%s, %s, %s, %s, %s, %s, %s, %s)',
|
||||
"""Keep the access token and the refresh token; a refresh token that was exchanged is used up. A token of
|
||||
client_credentials is kept for its service account."""
|
||||
if request.grant_type == 'client_credentials':
|
||||
name, auth_time, amr = request.client.client_id, datetime.datetime.now(datetime.timezone.utc), SERVICE_AMR
|
||||
else:
|
||||
name, auth_time, amr = request.user, request.auth_time, request.amr
|
||||
values = (request.client.client_id, name, request.scopes, auth_time, amr, getattr(request, 'oidc_target', None))
|
||||
statements = [('CALL oidc_token_save(%s, %s, %s, %s, %s, %s, %s, %s, %s)',
|
||||
(vpnrouter_auth.digest(token['access_token']), 'access', *values, ACCESS_SECONDS))]
|
||||
if token.get('refresh_token'):
|
||||
statements.append(('CALL oidc_token_save(%s, %s, %s, %s, %s, %s, %s, %s)',
|
||||
statements.append(('CALL oidc_token_save(%s, %s, %s, %s, %s, %s, %s, %s, %s)',
|
||||
(vpnrouter_auth.digest(token['refresh_token']), 'refresh', *values, REFRESH_SECONDS)))
|
||||
if request.grant_type == 'refresh_token':
|
||||
statements.append(('CALL oidc_token_delete(%s)', (vpnrouter_auth.digest(request.refresh_token),)))
|
||||
@@ -366,6 +587,7 @@ class Validator(RequestValidator):
|
||||
person = subject(found['name'], client)
|
||||
if person is None:
|
||||
return False
|
||||
self.check_target(request, found['resource'])
|
||||
request.user, request.auth_time, request.amr = found['name'], found['auth_time'], found['amr']
|
||||
request.oidc_scopes, request.oidc_person = found['scopes'], person
|
||||
return True
|
||||
@@ -385,7 +607,9 @@ class Validator(RequestValidator):
|
||||
return True
|
||||
|
||||
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.
|
||||
|
||||
@@ -394,6 +618,9 @@ class Validator(RequestValidator):
|
||||
id_token.update(claims_of(request.oidc_person, request.scopes))
|
||||
id_token.update({'iss': vpnrouter.oidc_issuer(self.cfg), 'exp': now + ACCESS_SECONDS,
|
||||
'auth_time': int(request.auth_time.timestamp()), 'amr': request.amr})
|
||||
roles = roles_at(request.client.client_id, request.user)
|
||||
if roles:
|
||||
id_token['roles'] = roles
|
||||
return sign(id_token)
|
||||
|
||||
def validate_silent_login(self, request):
|
||||
@@ -417,10 +644,100 @@ class Validator(RequestValidator):
|
||||
return person is not None and hint.get('sub') == person['sub']
|
||||
|
||||
|
||||
def page(status, cfg, title, message='', error='', form=False, request='', name='', headers=()):
|
||||
"""An HTML page of TEMPLATE: a message, an error, and with form the sign-in form for the authorization request."""
|
||||
class DeviceCodeGrant(GrantTypeBase):
|
||||
"""The token request of the device authorization grant (RFC 8628): a device code the user allowed gives an access
|
||||
token, a refresh token and, with openid, an ID token; until then the request is answered authorization_pending or
|
||||
slow_down."""
|
||||
|
||||
def create_token_response(self, request, token_handler):
|
||||
headers = self._get_default_headers()
|
||||
try:
|
||||
self.validate_token_request(request)
|
||||
except OAuth2Error as error:
|
||||
headers.update(error.headers)
|
||||
return headers, error.json, error.status_code
|
||||
token = token_handler.create_token(request, refresh_token=True)
|
||||
if 'openid' in request.scopes:
|
||||
token['id_token'] = self.request_validator.finalize_id_token(
|
||||
{'aud': request.client.client_id, 'iat': int(time.time())}, token, token_handler, request)
|
||||
self.request_validator.save_token(token, request)
|
||||
self.request_validator.delete_device(request.device_code)
|
||||
return headers, json.dumps(token), 200
|
||||
|
||||
def validate_token_request(self, request):
|
||||
if request.grant_type != DEVICE_GRANT:
|
||||
raise UnsupportedGrantTypeError(request=request)
|
||||
for param in ('grant_type', 'device_code'):
|
||||
if param in request.duplicate_params:
|
||||
raise InvalidRequestError(description=f'Duplicate {param} parameter.', request=request)
|
||||
request.device_code = getattr(request, 'device_code', None)
|
||||
if not request.device_code:
|
||||
raise InvalidRequestError(description='Missing device_code parameter.', request=request)
|
||||
if self.request_validator.client_authentication_required(request):
|
||||
if not self.request_validator.authenticate_client(request):
|
||||
raise InvalidClientError(request=request)
|
||||
elif not self.request_validator.authenticate_client_id(request.client_id, request):
|
||||
raise InvalidClientError(request=request)
|
||||
self.validate_grant_type(request)
|
||||
self.request_validator.poll_device(request.device_code, request)
|
||||
|
||||
|
||||
def user_code_of(value):
|
||||
"""The user code a user typed, without the dash and spaces, in capitals; '' when it is not one."""
|
||||
code = ''.join(character for character in (value or '').upper() if character.isalnum())
|
||||
return code if len(code) == USER_CODE_LENGTH and all(character in USER_CODE_LETTERS for character in code) else ''
|
||||
|
||||
|
||||
def device_authorization(validator, cfg, text, headers):
|
||||
"""Answer a device authorization request (RFC 8628 3.1): the client, public by client_id or confidential with its
|
||||
secret, with the device_code grant, the scopes and the resource; the device code, the user code and the
|
||||
verification URIs."""
|
||||
issuer = vpnrouter.oidc_issuer(cfg)
|
||||
request = Request(f'{issuer}/oidc/device_authorization', 'POST', text, headers)
|
||||
for param in ('scope', 'resource'):
|
||||
if param in request.duplicate_params:
|
||||
raise InvalidRequestError(description=f'Duplicate {param} parameter.', request=request)
|
||||
if validator.client_authentication_required(request):
|
||||
if not validator.authenticate_client(request):
|
||||
raise InvalidClientError(request=request)
|
||||
elif not validator.authenticate_client_id(request.client_id, request):
|
||||
raise InvalidClientError(request=request)
|
||||
if not validator.validate_grant_type(request.client.client_id, DEVICE_GRANT, request.client, request):
|
||||
raise UnauthorizedClientError(description='the client may not use the device authorization grant', request=request)
|
||||
scopes = utils.scope_to_list(request.scope) or ['openid']
|
||||
target = target_of(issuer, getattr(request, 'resource', None), scopes)
|
||||
if not scopes_allowed(request.client, scopes, target, DEVICE_GRANT):
|
||||
raise InvalidScopeError(description='the client may not ask for these scopes; openid is required', request=request)
|
||||
device_code = secrets.token_urlsafe(32)
|
||||
user_code = ''.join(secrets.choice(USER_CODE_LETTERS) for _ in range(USER_CODE_LENGTH))
|
||||
with vpnrouter_auth.database(cfg) as connection:
|
||||
connection.execute('CALL oidc_device_save(%s, %s, %s, %s, %s, %s, %s)',
|
||||
(vpnrouter_auth.digest(device_code), vpnrouter_auth.digest(user_code), request.client.client_id,
|
||||
scopes, target, DEVICE_INTERVAL, DEVICE_SECONDS))
|
||||
shown = f'{user_code[:USER_CODE_LENGTH // 2]}-{user_code[USER_CODE_LENGTH // 2:]}'
|
||||
return document(200, {'device_code': device_code, 'user_code': shown, 'verification_uri': f'{issuer}/oidc/device',
|
||||
'verification_uri_complete': f'{issuer}/oidc/device?' + urllib.parse.urlencode({'user_code': shown}),
|
||||
'expires_in': DEVICE_SECONDS, 'interval': DEVICE_INTERVAL})
|
||||
|
||||
|
||||
def device_of(cfg, user_code):
|
||||
"""The waiting device code of a user code, {client_id, scopes, resource}, or None."""
|
||||
code = user_code_of(user_code)
|
||||
if not code:
|
||||
return None
|
||||
with vpnrouter_auth.database(cfg) as connection:
|
||||
row = connection.execute('SELECT * FROM oidc_device_find(%s)', (vpnrouter_auth.digest(code),)).fetchone()
|
||||
return dict(zip(('client_id', 'scopes', 'resource'), row)) if row else None
|
||||
|
||||
|
||||
def page(status, cfg, title, message='', error='', form=False, request='', name='', headers=(), action='/oidc/login',
|
||||
device=False, user_code='', decide=False, items=()):
|
||||
"""An HTML page of TEMPLATE: a message, an error and items; with form the sign-in form, posted to action with the
|
||||
authorization request, or with device the user code field, with or without the sign-in fields; with decide the
|
||||
Allow and Deny buttons of the device of the user code."""
|
||||
body = _pages.get_template(TEMPLATE).render(title=title, message=message, error=error, form=form, request=request,
|
||||
name=name, issuer=vpnrouter.oidc_issuer(cfg))
|
||||
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]
|
||||
|
||||
|
||||
@@ -443,6 +760,30 @@ def cookie(value, seconds):
|
||||
return 'Set-Cookie', f'{COOKIE}={value}; Path=/oidc/; Max-Age={seconds}; Secure; HttpOnly; SameSite=Lax'
|
||||
|
||||
|
||||
def sign_in(form):
|
||||
"""The name and the amr of a sign-in with the form's name, password and code, or its passkey; Failure when it is
|
||||
refused, ValueError when the passkey's answer is not JSON."""
|
||||
if form.get('passkey_id'):
|
||||
with vpnrouter_users.session() as connection:
|
||||
name = vpnrouter_passkeys.login_complete(connection, form['passkey_id'], json.loads(form.get('credential', '')))
|
||||
return name, PASSKEY_AMR
|
||||
name, code = form.get('name', '').strip(), form.get('code', '').strip()
|
||||
vpnrouter_users.login(name, form.get('password', ''), code or None)
|
||||
with directory() as connection:
|
||||
if vpnrouter_users.service(vpnrouter_users.entry(connection, name)):
|
||||
raise vpnrouter_users.Failure('the name, the password or the code is wrong', 5)
|
||||
return name, OTP_AMR if code else PASSWORD_AMR
|
||||
|
||||
|
||||
def start_session(cfg, name, amr):
|
||||
"""Start a session of the user: a namespace of the Session and the header that sets its cookie."""
|
||||
value = secrets.token_urlsafe(32)
|
||||
with vpnrouter_auth.database(cfg) as connection:
|
||||
connection.execute('CALL oidc_session_save(%s, %s, %s, %s)', (vpnrouter_auth.digest(value), name, amr, SESSION_SECONDS))
|
||||
return types.SimpleNamespace(session=Session(name, datetime.datetime.now(datetime.timezone.utc), amr),
|
||||
cookie=cookie(value, SESSION_SECONDS))
|
||||
|
||||
|
||||
def authorize(server, cfg, params, headers, fresh=None, name='', error=''):
|
||||
"""Answer an authorization request, the query or the form params: the sign-in page while the browser has no
|
||||
session, or with prompt=login or max_age older than the session, else a redirect to the client with a code.
|
||||
@@ -491,27 +832,72 @@ def login(server, cfg, form, headers):
|
||||
params = form.get('request', '')
|
||||
name = form.get('name', '').strip()
|
||||
try:
|
||||
if form.get('passkey_id'):
|
||||
with vpnrouter_users.session() as connection:
|
||||
name = vpnrouter_passkeys.login_complete(connection, form['passkey_id'], json.loads(form.get('credential', '')))
|
||||
amr = PASSKEY_AMR
|
||||
else:
|
||||
code = form.get('code', '').strip()
|
||||
vpnrouter_users.login(name, form.get('password', ''), code or None)
|
||||
with directory() as connection:
|
||||
if vpnrouter_users.service(vpnrouter_users.entry(connection, name)):
|
||||
raise vpnrouter_users.Failure('the name, the password or the code is wrong', 5)
|
||||
amr = OTP_AMR if code else PASSWORD_AMR
|
||||
name, amr = sign_in(form)
|
||||
except vpnrouter_users.Failure as failure:
|
||||
return authorize(server, cfg, params, headers, name=name, error=str(failure))
|
||||
except ValueError:
|
||||
return authorize(server, cfg, params, headers, name=name, error='the passkey is not accepted')
|
||||
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:
|
||||
connection.execute('CALL oidc_session_save(%s, %s, %s, %s)', (vpnrouter_auth.digest(value), name, amr, SESSION_SECONDS))
|
||||
fresh = types.SimpleNamespace(session=Session(name, datetime.datetime.now(datetime.timezone.utc), amr),
|
||||
cookie=cookie(value, SESSION_SECONDS))
|
||||
return authorize(server, cfg, params, headers, fresh=fresh)
|
||||
decided = connection.execute('SELECT oidc_device_decide(%s, %s, %s, %s, %s)',
|
||||
(vpnrouter_auth.digest(user_code_of(form['user_code'])), session.name,
|
||||
session.auth_time, session.amr, allowed)).fetchone()[0]
|
||||
if not decided:
|
||||
return device_page(cfg, 400, signed_in=True, error='the code is wrong or has expired')
|
||||
shown = client.display_name or client.client_id
|
||||
if allowed:
|
||||
return page(200, cfg, 'The device is signed in', message=f'{shown} is signed in as {session.name}. You can return '
|
||||
'to the device.')
|
||||
return page(200, cfg, 'The device is denied', message=f'{shown} is not signed in.')
|
||||
|
||||
|
||||
def logout(cfg, query, headers):
|
||||
@@ -552,9 +938,12 @@ def answer(method, target, headers, body):
|
||||
issuer = vpnrouter.oidc_issuer(cfg)
|
||||
headers = dict(headers.items())
|
||||
text = body.decode('utf-8', 'replace') if body else ''
|
||||
server = Server(Validator(cfg), token_expires_in=ACCESS_SECONDS)
|
||||
validator = Validator(cfg)
|
||||
server = Server(validator, token_expires_in=ACCESS_SECONDS, token_generator=lambda request: access_token(cfg, request),
|
||||
refresh_token_generator=random_token_generator)
|
||||
server.grant_types[DEVICE_GRANT] = DeviceCodeGrant(validator)
|
||||
if (method, path) == ('GET', '/.well-known/openid-configuration'):
|
||||
return document(200, discovery(issuer))
|
||||
return document(200, discovery(issuer, resource_scopes()))
|
||||
if (method, path) == ('GET', '/oidc/jwks'):
|
||||
return document(200, {'keys': [public_jwk(private_key())]})
|
||||
if path == '/oidc/authorize' and method in ('GET', 'POST'):
|
||||
@@ -565,6 +954,15 @@ def answer(method, target, headers, body):
|
||||
return document(200, vpnrouter_passkeys.login_begin())
|
||||
if (method, path) == ('POST', '/oidc/token'):
|
||||
return relayed(*server.create_token_response(f'{issuer}{path}', 'POST', text, headers))
|
||||
if (method, path) == ('POST', '/oidc/device_authorization'):
|
||||
return device_authorization(validator, cfg, text, headers)
|
||||
if (method, path) == ('GET', '/oidc/device'):
|
||||
signed_in = session_of(cfg, headers.get('Cookie')) is not None
|
||||
return device_page(cfg, signed_in=signed_in, user_code=dict(urllib.parse.parse_qsl(query)).get('user_code', ''))
|
||||
if (method, path) == ('POST', '/oidc/device'):
|
||||
return device(cfg, dict(urllib.parse.parse_qsl(text)), headers)
|
||||
if (method, path) == ('POST', '/oidc/device/decide'):
|
||||
return decide(cfg, dict(urllib.parse.parse_qsl(text)), headers)
|
||||
if path == '/oidc/userinfo' and method in ('GET', 'POST'):
|
||||
return relayed(*server.create_userinfo_response(f'{issuer}{target}', method, text or None, headers))
|
||||
if (method, path) == ('GET', '/oidc/logout'):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""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
|
||||
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
|
||||
most PASSKEYS_MAX passkeys; service accounts have none.
|
||||
|
||||
Registration and sign-in are two steps each: begin answers the options for the browser and a
|
||||
pending id, whose state the web service keeps in memory for PENDING_SECONDS; complete takes the
|
||||
id and the browser's answer. A request that cannot be done raises vpnrouter_users.Failure, with
|
||||
the statuses of that module; a sign-in that fails gives status 5 whatever the reason.
|
||||
Registration, sign-in and the proof of a signed-in user for a shell are two steps each: begin
|
||||
answers the options for the browser and a pending id, whose state the web service keeps in memory
|
||||
for PENDING_SECONDS; complete takes the id and the browser's answer. A request that cannot be done
|
||||
raises vpnrouter_users.Failure, with the statuses of that module; a sign-in or a proof that fails
|
||||
gives status 5 whatever the reason.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -172,3 +173,30 @@ def login_complete(connection, pending, response):
|
||||
except (ValueError, KeyError, TypeError):
|
||||
raise refused
|
||||
return name
|
||||
|
||||
|
||||
def shell_begin(connection, name):
|
||||
"""Begin the proof of the signed-in user for a shell: {"id", "options"}, options those of navigator.credentials.get
|
||||
for the user's own passkeys, with user verification."""
|
||||
own = credentials(vpnrouter_users.entry(connection, name))
|
||||
if not own:
|
||||
raise vpnrouter_users.Failure(f'{name} has no passkey; add one on the Account page', 2)
|
||||
options, state = server().authenticate_begin(own, user_verification=UserVerificationRequirement.REQUIRED)
|
||||
return {'id': keep('shell', name, state), 'options': dict(options)}
|
||||
|
||||
|
||||
def shell_complete(connection, name, pending, response):
|
||||
"""Check the browser's answer to a begun proof of the user for a shell: one of the user's own passkeys, with the
|
||||
user verified, while the user is enabled and not locked."""
|
||||
refused = vpnrouter_users.Failure('the passkey is not accepted', 5)
|
||||
state = take('shell', name, pending)
|
||||
if state is None or not isinstance(response, dict):
|
||||
raise refused
|
||||
current = vpnrouter_users.entry(connection, name)
|
||||
user = vpnrouter_users.summary(current)
|
||||
if not user['enabled'] or user['locked']:
|
||||
raise refused
|
||||
try:
|
||||
server().authenticate_complete(state, credentials(current), response)
|
||||
except (ValueError, KeyError, TypeError):
|
||||
raise refused
|
||||
@@ -722,7 +722,7 @@ def install_crls(connection):
|
||||
crls = {}
|
||||
for name, file in SWANCTL_CA_FILES.items():
|
||||
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)
|
||||
if crls['root'] and crls['p2s']:
|
||||
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:
|
||||
|
||||
EXTRA_P2S_DNS str, added to the road-warrior pool 'dns' list
|
||||
EXTRA_P2S_DNS str, added to the 'dns' list of the IKEv2 address pool
|
||||
EXTRA_LOCAL_TS str, added to 'local_ts' on both connections
|
||||
apply(ctx) called once the system has been configured, to apply
|
||||
platform-specific state. Must be idempotent. ctx is the
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
No platform-specific configuration is currently required.
|
||||
|
||||
The Azure platform resolver 168.63.129.16 is deliberately *not* advertised to
|
||||
road-warrior clients and *not* added to the traffic selectors. Clients are
|
||||
given the router itself as their DNS server, and the router forwards through
|
||||
its own systemd-resolved via the p2s-forwarder drop-in. That resolves
|
||||
IKEv2 clients and *not* added to the traffic selectors. Clients are
|
||||
given the router's addresses of p2s.dns_listen as their DNS servers, where dnsmasq
|
||||
forwards to the router's own systemd-resolved. That resolves
|
||||
Azure-internal names without routing the wireserver address through the tunnel
|
||||
and without a second resolver in the pool.
|
||||
"""
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
No platform-specific configuration is currently required.
|
||||
|
||||
As on Azure, road-warrior clients are given the router itself as their DNS
|
||||
As on Azure, IKEv2 clients are given the router itself as their DNS
|
||||
server and the router forwards through its own systemd-resolved, so the
|
||||
metadata resolver does not belong in the pool or in the traffic selectors.
|
||||
"""
|
||||
@@ -4,6 +4,7 @@ Only the Python standard library is used, so the bootstrap server, which must ru
|
||||
is broken, can use it.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from http.server import ThreadingHTTPServer
|
||||
@@ -14,9 +15,21 @@ def serve(wanted, handler, log, interval):
|
||||
|
||||
wanted answers ([(address, port), ...], '') or ([], the reason not to listen). A server whose
|
||||
place is no longer wanted is stopped, and a new one listens at each new place; a place that
|
||||
cannot be bound is tried again the next round. Every new message is logged with log. Does
|
||||
not return.
|
||||
cannot be bound is tried again the next round. Every new message is logged with log. A request
|
||||
whose client went away is not logged; any other exception a request, a thread or the process
|
||||
does not handle is logged with log in one line, without a traceback. Does not return.
|
||||
"""
|
||||
def failed(request, client_address):
|
||||
failure = sys.exc_info()[1]
|
||||
if not isinstance(failure, ConnectionError):
|
||||
log(f'a request from {client_address[0]} failed: {type(failure).__name__}: {failure}')
|
||||
|
||||
def thread_failed(hook):
|
||||
if not issubclass(hook.exc_type, (SystemExit, ConnectionError)):
|
||||
log(f'{hook.thread.name if hook.thread else "a thread"} failed: {hook.exc_type.__name__}: {hook.exc_value}')
|
||||
|
||||
threading.excepthook = thread_failed
|
||||
sys.excepthook = lambda kind, value, _: log(f'failed: {kind.__name__}: {value}')
|
||||
servers, reported = {}, None
|
||||
while True:
|
||||
targets, reason = wanted()
|
||||
@@ -34,6 +47,7 @@ def serve(wanted, handler, log, interval):
|
||||
except OSError as exc:
|
||||
messages.append(f'cannot listen on {target[0]}:{target[1]}: {exc}')
|
||||
continue
|
||||
server.handle_error = failed
|
||||
thread = threading.Thread(target=server.serve_forever, name=f'server {target[0]}', daemon=True)
|
||||
thread.start()
|
||||
servers[target] = (server, thread)
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
"""The static web sites Caddy serves, for the web service, vpn-router-site, vpn-router-setup and webdav-auth.
|
||||
|
||||
A site is a row of the sites table in the vpn-router-sites database, which the web service creates on its first run
|
||||
(provision) with its role and the password in DATABASE_SECRET; SITES_SQL defines the table and its procedures, and the
|
||||
first connection of a process loads it. A site has a name, which also names its folder SITES_DIR/<name>, the host names
|
||||
Caddy answers on its port (hosts, the names TLS clients send as SNI; none answers any name), the interfaces whose
|
||||
firewall opens the port (listen), and TLS: none (plain HTTP), acme (a certificate of Let's Encrypt), ca (certificates
|
||||
Caddy issues with one of pki.issuing_cas) or file (a certificate and key given with the site). Caddy serves the files of
|
||||
the folder, index_file for a folder, and with spa index_file of the site for any path without a file. editors names
|
||||
the group whose members change the site's files besides the admins, through the web service and WebDAV. dns says who
|
||||
serves the records of its host names: user, a DNS provider outside the router, or router, the router's name server,
|
||||
which gives each of them in the closest of its domains an A or AAAA record of the router's address of dns_address:
|
||||
public (wan.public_address), internal or external. A site may use wan.local_fqdn, on a port of its own; its record
|
||||
stays where the domain of wan.local_fqdn is.
|
||||
|
||||
The sites are served while sites.enabled is true; vpn-router-setup writes Caddy's configuration and opens the ports. A
|
||||
site is changed only through this module, which checks it (check), and its callers then restart vpn-router-setup.
|
||||
|
||||
The files of a site are read and written by path relative to its folder; resolve() refuses a path that leaves it.
|
||||
receive() writes a file from a stream, extract() a zip archive into a folder; both stop at sites.upload_max_mb.
|
||||
|
||||
Every failure of the database raises DatabaseError; a request that cannot be done raises Failure, whose status says
|
||||
why: 2 invalid, 3 no such site, 4 the site exists, 1 anything else.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import datetime
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_auth
|
||||
import vpnrouter_pki
|
||||
import vpnrouter_users
|
||||
|
||||
DATABASE = 'vpn-router-sites'
|
||||
DATABASE_SECRET = pathlib.Path('/etc/vpn-router/sites-database.secret')
|
||||
SITES_SQL = pathlib.Path('/usr/share/vpn-router/sql/sites.sql')
|
||||
|
||||
#: The folders of the sites, SITES_DIR/<name>.
|
||||
SITES_DIR = pathlib.Path('/var/lib/vpn-router/sites')
|
||||
|
||||
#: The name of a site, which also names its folder.
|
||||
NAME = re.compile(r'^[a-z][a-z0-9-]{0,30}$')
|
||||
|
||||
#: A file name of index_file.
|
||||
INDEX = re.compile(r'^[A-Za-z0-9_-][A-Za-z0-9._-]{0,63}$')
|
||||
|
||||
LISTEN = vpnrouter.LISTEN_CHOICES
|
||||
TLS = ('none', 'acme', 'ca', 'file')
|
||||
|
||||
#: Who serves the records of a site's host names: user, elsewhere, or router, the router's name server, with the
|
||||
#: router's address of DNS_ADDRESSES.
|
||||
DNS = ('user', 'router')
|
||||
DNS_ADDRESSES = ('public', 'internal', 'external')
|
||||
|
||||
#: The most host names of one site.
|
||||
HOSTS_MAX = 16
|
||||
|
||||
#: The most members a zip archive extract() takes.
|
||||
ARCHIVE_MEMBERS_MAX = 10000
|
||||
|
||||
#: The bytes copied at once from a stream.
|
||||
CHUNK = 65536
|
||||
|
||||
#: The settings of a site and their defaults; the type of a default is the required type.
|
||||
SETTINGS = {'enabled': True, 'hosts': [], 'port': 443, 'listen': 'external', 'tls': 'acme', 'ca': '', 'certificate': '',
|
||||
'private_key': '', 'index_file': 'index.html', 'spa': False, 'editors': '', 'dns': 'user',
|
||||
'dns_address': 'public'}
|
||||
|
||||
#: Whether this process has loaded SITES_SQL.
|
||||
_loaded = False
|
||||
|
||||
|
||||
class DatabaseError(Exception):
|
||||
"""The database of the sites failed."""
|
||||
|
||||
|
||||
class Failure(Exception):
|
||||
"""A request that cannot be done; status is 2 invalid, 3 no such site, 4 the site exists, 1 other."""
|
||||
|
||||
def __init__(self, message, status=1):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
||||
|
||||
def provision():
|
||||
"""Create the role, its password file and the database of the sites when they are missing."""
|
||||
vpnrouter_auth.provision_database(DATABASE, DATABASE_SECRET)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def database():
|
||||
"""A connection to the database of the sites, with its table and procedures, closed at the end; DatabaseError on any
|
||||
failure, also before the web service has created the database."""
|
||||
global _loaded
|
||||
try:
|
||||
schema = None if _loaded else SITES_SQL.read_text(encoding='utf-8')
|
||||
target = vpnrouter_auth.local_connection(DATABASE, DATABASE_SECRET)
|
||||
except OSError as exc:
|
||||
raise DatabaseError(f'the database of the sites is not ready: {exc}') from exc
|
||||
try:
|
||||
with psycopg.connect(target, autocommit=True, row_factory=dict_row) as connection:
|
||||
if schema is not None:
|
||||
connection.execute(schema)
|
||||
_loaded = True
|
||||
yield connection
|
||||
except psycopg.Error as exc:
|
||||
raise DatabaseError(f'the database of the sites failed: {exc}') from exc
|
||||
|
||||
|
||||
def shown(row):
|
||||
"""A site as the answers show it: its settings without the private key, with private_key_set and its folder."""
|
||||
site = {key: value for key, value in row.items() if key != 'private_key'}
|
||||
site['private_key_set'] = bool(row.get('private_key'))
|
||||
site['modified'] = row['modified'].isoformat(timespec='seconds')
|
||||
site['directory'] = str(SITES_DIR / row['name'])
|
||||
return site
|
||||
|
||||
|
||||
def listing():
|
||||
"""Every site, as shown()."""
|
||||
with database() as connection:
|
||||
return [shown(row) for row in connection.execute('SELECT * FROM sites_list()').fetchall()]
|
||||
|
||||
|
||||
def served():
|
||||
"""The enabled sites with their private keys."""
|
||||
with database() as connection:
|
||||
return connection.execute('SELECT * FROM sites_served()').fetchall()
|
||||
|
||||
|
||||
def get(connection, name):
|
||||
"""The site with its private key; Failure with status 3 when there is none."""
|
||||
if not isinstance(name, str) or not NAME.match(name):
|
||||
raise Failure(f'no site {name}', 3)
|
||||
row = connection.execute('SELECT * FROM site_get(%s)', (name,)).fetchone()
|
||||
if row is None:
|
||||
raise Failure(f'no site {name}', 3)
|
||||
return row
|
||||
|
||||
|
||||
def editable(groups):
|
||||
"""The names of the sites whose files the members of the groups may change."""
|
||||
with database() as connection:
|
||||
return [row['name'] for row in connection.execute('SELECT * FROM sites_editable(%s)', (list(groups),))]
|
||||
|
||||
|
||||
def sites_of_group(group):
|
||||
"""The names of the sites whose editors are the group; [] before the web service has created the database."""
|
||||
if not DATABASE_SECRET.is_file():
|
||||
return []
|
||||
with database() as connection:
|
||||
return [row['name'] for row in connection.execute('SELECT * FROM sites_of_group(%s)', (group,))]
|
||||
|
||||
|
||||
def rename_group(old, new):
|
||||
"""Give the sites whose editors are the group old its new name."""
|
||||
if DATABASE_SECRET.is_file():
|
||||
with database() as connection:
|
||||
connection.execute('CALL sites_rename_group(%s, %s)', (old, new))
|
||||
|
||||
|
||||
def taken_ports(cfg):
|
||||
"""The TCP ports of the router's own services, port -> what has it, which no site may take."""
|
||||
taken = {22: 'SSH', cfg['web']['port']: 'web.port', cfg['https']['web_port']: 'https.web_port',
|
||||
cfg['https']['manager_port']: 'https.manager_port', cfg['metrics']['port']: 'metrics.port',
|
||||
cfg['metrics']['https_port']: 'metrics.https_port', vpnrouter.manager_port(): 'the Router Manager',
|
||||
cfg['sites']['webdav_port']: 'sites.webdav_port', vpnrouter.WEBDAV_LOOPBACK_PORT: 'rclone\'s WebDAV',
|
||||
53: 'DNS'}
|
||||
for port in vpnrouter.BOOTSTRAP_PORTS:
|
||||
taken[port] = 'the bootstrap services'
|
||||
if cfg['openvpn']['protocol'] == 'tcp':
|
||||
taken[cfg['openvpn']['port']] = 'openvpn.port'
|
||||
for item in cfg['docker']['exposed_ports']:
|
||||
first, _, last = item.strip().partition(':')
|
||||
if first.isdigit() and (not last or last.isdigit()):
|
||||
for port in range(int(first), int(last or first) + 1):
|
||||
taken[port] = 'docker.exposed_ports'
|
||||
return taken
|
||||
|
||||
|
||||
def check_certificate(certificate, private_key, hosts):
|
||||
"""Refuse a PEM certificate chain whose first certificate is not the key's or names none of the hosts."""
|
||||
try:
|
||||
chain = x509.load_pem_x509_certificates(certificate.encode())
|
||||
except ValueError:
|
||||
raise Failure('certificate must be PEM certificates, the site\'s first', 2)
|
||||
try:
|
||||
key = serialization.load_pem_private_key(private_key.encode(), password=None)
|
||||
except (ValueError, TypeError):
|
||||
raise Failure('private_key must be an unencrypted PEM private key', 2)
|
||||
if not vpnrouter_pki.same_key(chain[0].public_key(), key.public_key()):
|
||||
raise Failure('private_key is not the key of the certificate', 2)
|
||||
names = {value.partition(':')[2] for value in vpnrouter_pki.san_values(chain[0]) if not value.startswith('email:')}
|
||||
missing = [host for host in hosts if host not in names and f'*.{host.partition(".")[2]}' not in names]
|
||||
if missing:
|
||||
raise Failure(f'the certificate does not name {", ".join(missing)}', 2)
|
||||
|
||||
|
||||
def check(cfg, sites, site):
|
||||
"""Refuse a site, a dict of SETTINGS with its name, that is invalid or collides with the other sites (sites, rows of
|
||||
the table) or with the router's own ports."""
|
||||
name = site['name']
|
||||
if not isinstance(name, str) or not NAME.match(name):
|
||||
raise Failure('a site name starts with a lowercase letter and has at most 31 lowercase letters, digits and '
|
||||
'hyphens', 2)
|
||||
for option, default in SETTINGS.items():
|
||||
value = site[option]
|
||||
if type(value) is not type(default) or (isinstance(value, list) and not all(isinstance(item, str) for item in value)):
|
||||
raise Failure(f'{option} must be {vpnrouter.TYPE_NAMES[type(default)]}', 2)
|
||||
hosts = site['hosts']
|
||||
if len(hosts) > HOSTS_MAX or len(set(hosts)) != len(hosts):
|
||||
raise Failure(f'hosts must be at most {HOSTS_MAX} different host names', 2)
|
||||
for host in hosts:
|
||||
if not (vpnrouter_pki.DNS_NAME.match(host) or vpnrouter_pki.is_address(host)):
|
||||
raise Failure(f'hosts: {host!r} is not a host name in lowercase or an IP address', 2)
|
||||
if site['dns'] not in DNS:
|
||||
raise Failure(f'dns: {site["dns"]!r} is not one of {", ".join(DNS)}', 2)
|
||||
if site['dns_address'] not in DNS_ADDRESSES:
|
||||
raise Failure(f'dns_address: {site["dns_address"]!r} is not one of {", ".join(DNS_ADDRESSES)}', 2)
|
||||
if site['dns'] == 'router':
|
||||
local_fqdn = cfg['wan']['local_fqdn'].strip()
|
||||
if any(vpnrouter_pki.is_address(host) or host.startswith('*.') or host == local_fqdn for host in hosts):
|
||||
raise Failure('dns router takes host names only, without wildcards and other than wan.local_fqdn, whose '
|
||||
'record is kept where its domain is', 2)
|
||||
if site['dns_address'] == 'public' and not cfg['wan']['public_address'].strip():
|
||||
raise Failure('dns_address public needs wan.public_address', 2)
|
||||
port = site['port']
|
||||
if not 1 <= port <= 65535:
|
||||
raise Failure(f'port: {port} is not a port', 2)
|
||||
taken = taken_ports(cfg)
|
||||
if port in taken:
|
||||
raise Failure(f'port: {port} is the port of {taken[port]}', 2)
|
||||
if site['listen'] not in LISTEN:
|
||||
raise Failure(f'listen: {site["listen"]!r} is not one of {", ".join(LISTEN)}', 2)
|
||||
tls = site['tls']
|
||||
if tls not in TLS:
|
||||
raise Failure(f'tls: {tls!r} is not one of {", ".join(TLS)}', 2)
|
||||
if tls != 'none' and not hosts:
|
||||
raise Failure(f'tls {tls} needs hosts, the names of the certificate', 2)
|
||||
if tls == 'acme' and any(host.startswith('*.') or vpnrouter_pki.is_address(host) for host in hosts):
|
||||
raise Failure('tls acme takes host names only, without wildcards', 2)
|
||||
if tls == 'ca':
|
||||
authorities = {key: value for key, value in vpnrouter.issuing_cas(cfg).items() if not value['builtin']}
|
||||
if site['ca'] not in authorities:
|
||||
raise Failure(f'ca: {site["ca"]!r} is not one of pki.issuing_cas', 2)
|
||||
if tls == 'file':
|
||||
check_certificate(site['certificate'], site['private_key'], hosts)
|
||||
if not INDEX.match(site['index_file']):
|
||||
raise Failure('index_file must be a file name', 2)
|
||||
if site['editors']:
|
||||
with vpnrouter_users.session() as connection:
|
||||
try:
|
||||
vpnrouter_users.group_entry(connection, site['editors'])
|
||||
except vpnrouter_users.Failure:
|
||||
raise Failure(f'editors: group {site["editors"]} does not exist', 2)
|
||||
if not site['enabled']:
|
||||
return
|
||||
for other in sites:
|
||||
if other['name'] == name or not other['enabled'] or other['port'] != port:
|
||||
continue
|
||||
prefix = f'port {port} is also the port of site {other["name"]}'
|
||||
if (other['tls'] == 'none') != (tls == 'none'):
|
||||
raise Failure(f'{prefix}, which is {"plain HTTP" if other["tls"] == "none" else "HTTPS"}', 2)
|
||||
if other['listen'] != site['listen']:
|
||||
raise Failure(f'{prefix}, which listens on {other["listen"]}', 2)
|
||||
if not hosts or not other['hosts']:
|
||||
raise Failure(f'{prefix}; sites sharing a port need host names', 2)
|
||||
common = sorted(set(hosts) & set(other['hosts']))
|
||||
if common:
|
||||
raise Failure(f'{prefix}, which has {", ".join(common)} too', 2)
|
||||
|
||||
|
||||
def save(cfg, name, values, create):
|
||||
"""Add the site with values, a dict of some of SETTINGS (create), or change those of an existing one; the site as
|
||||
shown(). An absent or empty private_key keeps the current one."""
|
||||
unknown = sorted(set(values) - set(SETTINGS))
|
||||
if unknown:
|
||||
raise Failure(f'unknown settings: {", ".join(unknown)}', 2)
|
||||
with database() as connection:
|
||||
rows = connection.execute('SELECT * FROM sites_list()').fetchall()
|
||||
current = next((row for row in rows if row['name'] == name), None)
|
||||
if create and current is not None:
|
||||
raise Failure(f'site {name} exists', 4)
|
||||
if not create:
|
||||
current = get(connection, name)
|
||||
site = {**SETTINGS, **({key: current[key] for key in SETTINGS} if current else {}), 'name': name}
|
||||
site.update({key: value for key, value in values.items() if key != 'private_key' or value})
|
||||
if isinstance(site['hosts'], list):
|
||||
site['hosts'] = [host.strip().lower() for host in site['hosts'] if isinstance(host, str) and host.strip()]
|
||||
if site['tls'] != 'file':
|
||||
site['certificate'] = site['private_key'] = ''
|
||||
if site['tls'] != 'ca':
|
||||
site['ca'] = ''
|
||||
check(cfg, rows, site)
|
||||
connection.execute(f'CALL site_put({", ".join(["%s"] * (len(SETTINGS) + 1))})',
|
||||
tuple(site[key] for key in ('name', *SETTINGS)))
|
||||
ensure_folders([name])
|
||||
return shown(get(connection, name))
|
||||
|
||||
|
||||
def remove(name, content):
|
||||
"""Remove the site, and with content its folder."""
|
||||
with database() as connection:
|
||||
get(connection, name)
|
||||
connection.execute('CALL site_remove(%s)', (name,))
|
||||
if content:
|
||||
shutil.rmtree(SITES_DIR / name, ignore_errors=True)
|
||||
return {'name': name, 'removed': True}
|
||||
|
||||
|
||||
def ensure_folders(names):
|
||||
"""Create the folders of the sites that miss one, readable by all."""
|
||||
SITES_DIR.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
for name in names:
|
||||
(SITES_DIR / name).mkdir(mode=0o755, exist_ok=True)
|
||||
|
||||
|
||||
def resolve(name, relative):
|
||||
"""The path of the site's file or folder relative to its folder; Failure with status 2 for a path that leaves it,
|
||||
status 3 when the site has no folder."""
|
||||
if not isinstance(name, str) or not NAME.match(name):
|
||||
raise Failure(f'no site {name}', 3)
|
||||
base = SITES_DIR / name
|
||||
if not base.is_dir():
|
||||
raise Failure(f'site {name} has no folder', 3)
|
||||
if '\0' in relative or any(part in ('.', '..') for part in relative.split('/')):
|
||||
raise Failure(f'{relative!r} is not a path in the site\'s folder', 2)
|
||||
target = pathlib.Path(os.path.realpath(base / relative.strip('/')))
|
||||
if target != base and base not in target.parents:
|
||||
raise Failure(f'{relative!r} is not a path in the site\'s folder', 2)
|
||||
return target
|
||||
|
||||
|
||||
def relative_of(name, path):
|
||||
return str(path.relative_to(SITES_DIR / name))
|
||||
|
||||
|
||||
def entries(name, folder):
|
||||
"""The entries of a folder of the site: [{name, folder, size, modified}], folders first, then by name."""
|
||||
found = []
|
||||
for item in os.scandir(folder):
|
||||
try:
|
||||
info = item.stat(follow_symlinks=False)
|
||||
except OSError:
|
||||
continue
|
||||
found.append({'name': item.name, 'folder': stat.S_ISDIR(info.st_mode),
|
||||
'size': info.st_size if stat.S_ISREG(info.st_mode) else 0,
|
||||
'modified': datetime.datetime.fromtimestamp(info.st_mtime, datetime.timezone.utc)
|
||||
.isoformat(timespec='seconds')})
|
||||
return {'path': relative_of(name, folder) if folder != SITES_DIR / name else '',
|
||||
'entries': sorted(found, key=lambda entry: (not entry['folder'], entry['name']))}
|
||||
|
||||
|
||||
def receive(stream, length, target, limit):
|
||||
"""Write length bytes of the stream to the file target, replacing it in one step; Failure with status 2 over limit
|
||||
bytes. Its folders are created."""
|
||||
if length > limit:
|
||||
raise Failure(f'the file has {length} bytes, more than sites.upload_max_mb allows', 2)
|
||||
if target.is_dir():
|
||||
raise Failure(f'{target.name} is a folder', 2)
|
||||
target.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
descriptor, name = tempfile.mkstemp(dir=target.parent, prefix=f'.{target.name}.')
|
||||
try:
|
||||
with os.fdopen(descriptor, 'wb') as handle:
|
||||
left = length
|
||||
while left:
|
||||
data = stream.read(min(CHUNK, left))
|
||||
if not data:
|
||||
raise Failure('the client sent less than its Content-Length', 2)
|
||||
handle.write(data)
|
||||
left -= len(data)
|
||||
os.chmod(name, 0o644)
|
||||
os.replace(name, target)
|
||||
except BaseException:
|
||||
os.unlink(name)
|
||||
raise
|
||||
|
||||
|
||||
def extract(stream, length, folder, limit):
|
||||
"""Extract the zip archive of length bytes of the stream into the folder, created when missing; the number of files.
|
||||
|
||||
A member with an absolute path, a .. or a link, more than ARCHIVE_MEMBERS_MAX members or more than limit bytes
|
||||
unpacked refuse the whole archive, before anything is written.
|
||||
"""
|
||||
if length > limit:
|
||||
raise Failure(f'the archive has {length} bytes, more than sites.upload_max_mb allows', 2)
|
||||
folder.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryFile(dir=folder) as spool:
|
||||
left = length
|
||||
while left:
|
||||
data = stream.read(min(CHUNK, left))
|
||||
if not data:
|
||||
raise Failure('the client sent less than its Content-Length', 2)
|
||||
spool.write(data)
|
||||
left -= len(data)
|
||||
spool.seek(0)
|
||||
try:
|
||||
archive = zipfile.ZipFile(spool)
|
||||
except zipfile.BadZipFile:
|
||||
raise Failure('the file is not a zip archive', 2)
|
||||
with archive:
|
||||
members = archive.infolist()
|
||||
if len(members) > ARCHIVE_MEMBERS_MAX:
|
||||
raise Failure(f'the archive has more than {ARCHIVE_MEMBERS_MAX} members', 2)
|
||||
if sum(member.file_size for member in members) > limit:
|
||||
raise Failure('the archive unpacks to more than sites.upload_max_mb allows', 2)
|
||||
for member in members:
|
||||
parts = member.filename.replace('\\', '/').split('/')
|
||||
if member.filename.startswith('/') or '..' in parts or '\0' in member.filename or \
|
||||
stat.S_ISLNK(member.external_attr >> 16):
|
||||
raise Failure(f'the archive member {member.filename!r} is a link or leaves the folder', 2)
|
||||
count = 0
|
||||
for member in members:
|
||||
target = folder / member.filename.replace('\\', '/').strip('/')
|
||||
if member.is_dir():
|
||||
target.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
continue
|
||||
target.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
with archive.open(member) as source:
|
||||
receive(source, member.file_size, target, limit)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def make_folder(target):
|
||||
target.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def rename(name, source, relative):
|
||||
"""Move the file or folder source of the site to the path relative to its folder; Failure when that exists."""
|
||||
target = resolve(name, relative)
|
||||
if target.exists():
|
||||
raise Failure(f'{relative} exists', 4)
|
||||
target.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
os.replace(source, target)
|
||||
return target
|
||||
|
||||
|
||||
def delete(name, target):
|
||||
"""Remove the file or folder of the site, but not its folder."""
|
||||
if target == SITES_DIR / name:
|
||||
raise Failure('the site\'s folder itself cannot be removed', 2)
|
||||
if target.is_dir() and not target.is_symlink():
|
||||
shutil.rmtree(target)
|
||||
else:
|
||||
target.unlink()
|
||||
@@ -15,16 +15,25 @@ internal service accounts are those the router creates and manages for its own s
|
||||
included (internal_accounts); vpn-router-setup keeps the metrics service's.
|
||||
Users exist whether or not a P2S system is enabled.
|
||||
|
||||
A service account with redirect URIs is a client of the OpenID Provider (vpnrouter_oidc), its name the client ID and
|
||||
its password the client secret (client_settings): redirect_uris, where the provider sends a user back after a sign-in,
|
||||
post_logout_redirect_uris, after a sign-out, and allowed_groups, the groups whose members may sign in to it, every
|
||||
user who is not a service account when there are none.
|
||||
A service account with redirect URIs or grants is a client of the OpenID Provider (vpnrouter_oidc), its name the
|
||||
client ID and its password the client secret (client_settings): redirect_uris, where the provider sends a user back
|
||||
after a sign-in, post_logout_redirect_uris, after a sign-out, allowed_groups, the groups whose members may sign in to it,
|
||||
every user who is not a service account when there are none, grants, those of GRANTS it may use besides the
|
||||
authorization code, public_client, whether it signs no request with its secret, which a client with client_credentials
|
||||
must, and client_scopes, the scopes of resources it may ask for, as <resource>:<scope>.
|
||||
|
||||
A service account is also a resource of the OpenID Provider, the audience of the tokens issued for it: resource_scopes
|
||||
are the scopes it defines, and its roles (roles_of_resource) are entries cn=<resource>:<role> of vpnrouter_ldap.ROLES
|
||||
whose members are the groups and accounts the role is assigned to. A scope a client may ask for cannot be removed, nor
|
||||
the resource while a client may ask for its scopes; removing an account removes its roles and takes it out of the
|
||||
others, and removing or renaming a group does the same in the roles.
|
||||
|
||||
A group is a groupOfNames in ou=groups with a name, which follows the rules of user names, an
|
||||
optional description and at least one member; a group cannot be empty, so the last member
|
||||
cannot leave it and a user who is the last member of a group cannot be removed. ADMIN_GROUP grants the management of
|
||||
the router, and the allowed groups of the OpenID Provider's clients who may sign in to them; renaming a group renames it
|
||||
in those, and a group a client allows cannot be removed.
|
||||
the router, the allowed groups of the OpenID Provider's clients who may sign in to them, and the editors group of a web
|
||||
site who may change its files (vpnrouter_sites); renaming a group renames it in those, and a group a client allows or a
|
||||
site has cannot be removed.
|
||||
|
||||
A VPN user may have WireGuard peers, each a device with its public key and its IPs, in
|
||||
vpnRouterWireGuardPeer; vpn-router-setup writes a [Peer] of wg0.conf for each peer of a user who
|
||||
@@ -64,16 +73,22 @@ import fcntl
|
||||
import grp
|
||||
import ipaddress
|
||||
import json
|
||||
import plistlib
|
||||
import pwd
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.parse
|
||||
import uuid
|
||||
|
||||
import ldap
|
||||
import ldap.filter
|
||||
import ldap.modlist
|
||||
import pyotp
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec, rsa
|
||||
from cryptography.hazmat.primitives.serialization import pkcs12
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_ldap
|
||||
@@ -98,9 +113,20 @@ LABELS = (('display_name', 'displayName'), ('description', 'description'))
|
||||
ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken', 'vpnRouterScope',
|
||||
'displayName', 'description', 'userCertificate;binary', 'vpnRouterConsoleLogin', 'vpnRouterConsoleAccount',
|
||||
'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.
|
||||
CLIENT_URIS = (('redirect_uris', 'vpnRouterRedirectUri'), ('post_logout_redirect_uris', 'vpnRouterPostLogoutRedirectUri'))
|
||||
#: The keys of the OpenID Provider settings of a service account (client_settings, and roles of set_roles).
|
||||
CLIENT_KEYS = ('redirect_uris', 'post_logout_redirect_uris', 'allowed_groups', 'grants', 'public_client', 'client_scopes',
|
||||
'resource_scopes', 'roles')
|
||||
#: The most URIs of each kind and allowed groups of one client, and the longest URI.
|
||||
CLIENT_VALUES_MAX = 16
|
||||
URI_MAX = 1024
|
||||
@@ -211,11 +237,13 @@ def labels(values):
|
||||
|
||||
|
||||
def client_settings(connection, values):
|
||||
"""The OpenID Provider client settings of a service account in values ({redirect_uris, post_logout_redirect_uris,
|
||||
allowed_groups}), checked, as {LDAP attribute: [value, ...]}; a key whose value is None is not given, [] means none.
|
||||
"""The OpenID Provider settings of a service account in values ({redirect_uris, post_logout_redirect_uris,
|
||||
allowed_groups, grants, public_client, client_scopes, resource_scopes}), checked, as {LDAP attribute: [value, ...]};
|
||||
a key whose value is None is not given, [] or false means none.
|
||||
|
||||
A URI is absolute, has no fragment and uses https, or http for a host of LOOPBACK_HOSTS; an allowed group exists. Each
|
||||
list has at most CLIENT_VALUES_MAX values.
|
||||
A URI is absolute, has no fragment and uses https, or http for a host of LOOPBACK_HOSTS; an allowed group exists; a
|
||||
grant is one of GRANTS; a client scope is a scope of a resource, <resource>:<scope>; a resource scope is a
|
||||
SCOPE_NAME. Each list has at most CLIENT_VALUES_MAX values.
|
||||
"""
|
||||
found = {}
|
||||
for key, attribute in CLIENT_URIS:
|
||||
@@ -242,13 +270,68 @@ def client_settings(connection, values):
|
||||
for group in allowed:
|
||||
group_entry(connection, group)
|
||||
found['vpnRouterAllowedGroup'] = [group_dn(group).encode() for group in sorted(set(allowed))]
|
||||
grants = values.get('grants')
|
||||
if grants is not None:
|
||||
if not isinstance(grants, list) or not all(isinstance(grant, str) and grant in GRANTS for grant in grants):
|
||||
raise Failure(f'grants must be a list of {", ".join(GRANTS)}', 2)
|
||||
found['vpnRouterGrantType'] = [grant.encode() for grant in GRANTS if grant in grants]
|
||||
public = values.get('public_client')
|
||||
if public is not None:
|
||||
if not isinstance(public, bool):
|
||||
raise Failure('public_client must be true or false', 2)
|
||||
found['vpnRouterPublicClient'] = [b'TRUE'] if public else []
|
||||
scopes = values.get('client_scopes')
|
||||
if scopes is not None:
|
||||
if not isinstance(scopes, list) or len(scopes) > CLIENT_VALUES_MAX or not all(isinstance(scope, str) for scope in scopes):
|
||||
raise Failure(f'client_scopes must be a list of at most {CLIENT_VALUES_MAX} scopes', 2)
|
||||
for scope in scopes:
|
||||
resource, _, name = scope.partition(':')
|
||||
if name not in resource_scopes(connection, resource):
|
||||
raise Failure(f'{scope!r} is not a scope of a resource, <resource>:<scope>', 2)
|
||||
found['vpnRouterClientScope'] = [scope.encode() for scope in sorted(set(scopes))]
|
||||
defined = values.get('resource_scopes')
|
||||
if defined is not None:
|
||||
if not isinstance(defined, list) or len(defined) > CLIENT_VALUES_MAX or \
|
||||
not all(isinstance(scope, str) and SCOPE_NAME.match(scope) for scope in defined):
|
||||
raise Failure(f'resource_scopes must be a list of at most {CLIENT_VALUES_MAX} scope names: lowercase letters, '
|
||||
'digits, dots, underscores and hyphens, starting with a letter, at most 64', 2)
|
||||
found['vpnRouterResourceScope'] = [scope.encode() for scope in sorted(set(defined))]
|
||||
return found
|
||||
|
||||
|
||||
def refuse_client_settings(name, values):
|
||||
"""Refuse OpenID Provider client settings for a user who is not a service account."""
|
||||
if any(values.get(key) is not None for key in ('redirect_uris', 'post_logout_redirect_uris', 'allowed_groups')):
|
||||
raise Failure(f'{name} is not a service account; only a service account is a client of the OpenID Provider', 2)
|
||||
"""Refuse OpenID Provider settings for a user who is not a service account."""
|
||||
if any(values.get(key) is not None for key in CLIENT_KEYS):
|
||||
raise Failure(f'{name} is not a service account; only a service account is a client or a resource of the OpenID '
|
||||
'Provider', 2)
|
||||
|
||||
|
||||
def check_client(name, current, changes):
|
||||
"""Refuse the client settings an entry's attributes would have with changes ({LDAP attribute: [value, ...]}) when the
|
||||
client is public and has client_credentials, which needs its secret."""
|
||||
merged = {**current, **changes}
|
||||
if merged.get('vpnRouterPublicClient') == [b'TRUE'] and b'client_credentials' in merged.get('vpnRouterGrantType', []):
|
||||
raise Failure(f'{name}: a public client has no secret, so it cannot use client_credentials', 2)
|
||||
|
||||
|
||||
def resource_scopes(connection, name):
|
||||
"""The scopes the service account defines as a resource, [] when the name is not that of a service account."""
|
||||
if not isinstance(name, str) or not (NAME.match(name) or GUID.match(name)):
|
||||
return []
|
||||
try:
|
||||
attributes = entry(connection, name)
|
||||
except Failure:
|
||||
return []
|
||||
return [value.decode() for value in attributes.get('vpnRouterResourceScope', [])] if service(attributes) else []
|
||||
|
||||
|
||||
def asking(connection, scopes):
|
||||
"""The names of the clients whose client scopes have any of the scopes, <resource>:<scope>, sorted."""
|
||||
if not scopes:
|
||||
return []
|
||||
condition = ''.join(f'(vpnRouterClientScope={ldap.filter.escape_filter_chars(scope)})' for scope in scopes)
|
||||
found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL, f'(|{condition})', ['uid'])
|
||||
return sorted(attributes['uid'][0].decode() for _, attributes in found)
|
||||
|
||||
|
||||
def dn_of(name):
|
||||
@@ -303,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.
|
||||
|
||||
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()
|
||||
lock = attributes.get('pwdAccountLockedTime', [None])[0]
|
||||
@@ -329,9 +412,18 @@ def summary(attributes):
|
||||
suffix = f',{vpnrouter_ldap.GROUPS}'
|
||||
user['allowed_groups'] = sorted(value.decode()[3:-len(suffix)] for value in attributes.get('vpnRouterAllowedGroup', [])
|
||||
if value.decode().startswith('cn=') and value.decode().endswith(suffix))
|
||||
user['grants'] = [value.decode() for value in attributes.get('vpnRouterGrantType', [])]
|
||||
user['public_client'] = attributes.get('vpnRouterPublicClient') == [b'TRUE']
|
||||
user['client_scopes'] = [value.decode() for value in attributes.get('vpnRouterClientScope', [])]
|
||||
user['resource_scopes'] = [value.decode() for value in attributes.get('vpnRouterResourceScope', [])]
|
||||
return user
|
||||
|
||||
|
||||
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):
|
||||
"""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
|
||||
@@ -529,8 +621,8 @@ def list_users(connection):
|
||||
def add(connection, name, password, values, service=False):
|
||||
"""Add a user: a VPN user with its certificate when values gives all three person attributes, a Manager-only account
|
||||
with at most the e-mail address when it gives neither name, and, with service, a service account, with at most the
|
||||
e-mail address, its labels (labels) and its OpenID Provider client settings (client_settings); only a service
|
||||
account may be named by a GUID."""
|
||||
e-mail address, its labels (labels), its OpenID Provider settings (client_settings) and its roles (set_roles); only
|
||||
a service account may be named by a GUID."""
|
||||
check_name(name, service)
|
||||
details = person(values, required=False)
|
||||
vpn_user = 'givenName' in details or 'sn' in details
|
||||
@@ -540,6 +632,9 @@ def add(connection, name, password, values, service=False):
|
||||
if service:
|
||||
details.update({attribute: value for attribute, value in labels(values).items() if value})
|
||||
client = {attribute: found for attribute, found in client_settings(connection, values).items() if found}
|
||||
check_client(name, {}, client)
|
||||
if values.get('roles') is not None:
|
||||
checked_roles(connection, values['roles'])
|
||||
else:
|
||||
refuse_client_settings(name, values)
|
||||
if vpn_user and len(details) != len(PERSON):
|
||||
@@ -567,6 +662,10 @@ def add(connection, name, password, values, service=False):
|
||||
vpnrouter_pki.issue_user(vpnrouter.load_config(), name, details['mail'])
|
||||
except vpnrouter_pki.Failure as failure:
|
||||
raise Failure(f'the certificate cannot be issued: {failure}', failure.status)
|
||||
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))
|
||||
|
||||
|
||||
@@ -621,20 +720,37 @@ def apply_wireguard():
|
||||
|
||||
|
||||
def update_service(connection, name, values):
|
||||
"""Change any of the e-mail address, the labels (labels) and the OpenID Provider client settings (client_settings) of
|
||||
a service account that values gives; '' removes the e-mail address or a label, [] the values of a client setting."""
|
||||
if not service(entry(connection, name)):
|
||||
"""Change any of the e-mail address, the labels (labels), the OpenID Provider settings (client_settings) and the
|
||||
roles (set_roles) of a service account that values gives; '' removes the e-mail address or a label, [] the values
|
||||
of a setting. A resource scope a client may ask for is not removed."""
|
||||
current = entry(connection, name)
|
||||
if not service(current):
|
||||
raise Failure(f'{name} is not a service account', 3)
|
||||
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, 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')
|
||||
if email is not None:
|
||||
address = '' if isinstance(email, str) and email.strip() == '' else person({'email': email}, required=False)['mail']
|
||||
changes.append((ldap.MOD_REPLACE, 'mail', [address.encode()] if address else []))
|
||||
if not changes:
|
||||
raise Failure('give email, display_name, description, redirect_uris, post_logout_redirect_uris or allowed_groups', 2)
|
||||
connection.modify_s(dn_of(name), changes)
|
||||
return summary(entry(connection, name))
|
||||
if not changes and roles is None:
|
||||
raise Failure(f'give email, display_name, description or {", ".join(CLIENT_KEYS)}', 2)
|
||||
if changes:
|
||||
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):
|
||||
@@ -754,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
|
||||
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)
|
||||
groups = groups_of(connection, name)
|
||||
alone = sorted(group for group, members in groups.items() if members == [name])
|
||||
if alone:
|
||||
raise Failure(f'user {name} is the last member of {", ".join(alone)}; remove the group first', 2)
|
||||
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:
|
||||
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:
|
||||
vpnrouter_pki.revoke_user(vpnrouter.load_config(), name)
|
||||
except vpnrouter_pki.Failure as failure:
|
||||
@@ -806,6 +931,80 @@ def profile(connection, name):
|
||||
return {'name': name, 'profile': text}
|
||||
|
||||
|
||||
def apple_profile(connection, name, password):
|
||||
"""The Apple configuration profile (.mobileconfig, unsigned) of the user's IKEv2 access: the root CA and
|
||||
the P2S CA, the user's certificate and key as a PKCS#12 encrypted with the password, and the VPN.
|
||||
|
||||
The connection matches road-warrior.conf: the router's wan.local_fqdn as address and identity, the user name, a
|
||||
DNS name of the certificate, as the local identity, the certificate over EAP-TLS, and AES-256, SHA2-256 and group 14
|
||||
for IKE and ESP. With p2s.dns_domains, the device resolves those domains with the router's resolver. The PKCS#12 uses 3DES and SHA-1, the only encryption Apple's clients read; they ask for the
|
||||
password when the profile is installed.
|
||||
"""
|
||||
entry(connection, name)
|
||||
if not isinstance(password, str) or not PASSWORD_MIN <= len(password) <= PASSWORD_MAX:
|
||||
raise Failure(f'the password of the profile must have {PASSWORD_MIN} to {PASSWORD_MAX} characters', 2)
|
||||
cfg = vpnrouter.load_config()
|
||||
fqdn = cfg['wan']['local_fqdn'].strip()
|
||||
if not cfg['p2s']['enabled'] or not fqdn:
|
||||
raise Failure('IKEv2 access is not enabled', 2)
|
||||
credentials = vpnrouter_pki.user_credentials(name)
|
||||
if credentials is None:
|
||||
raise Failure(f'user {name} has no certificate')
|
||||
try:
|
||||
chain = x509.load_pem_x509_certificates(vpnrouter_pki.bundle('p2s')['pem'].encode())
|
||||
except vpnrouter_pki.Failure as failure:
|
||||
raise Failure(str(failure), 4)
|
||||
cert = x509.load_pem_x509_certificate(credentials[0])
|
||||
key = serialization.load_pem_private_key(credentials[1], password=None)
|
||||
encryption = (serialization.PrivateFormat.PKCS12.encryption_builder().kdf_rounds(2048)
|
||||
.key_cert_algorithm(pkcs12.PBES.PBESv1SHA1And3KeyTripleDESCBC).hmac_hash(hashes.SHA1())
|
||||
.build(password.encode()))
|
||||
bundle = pkcs12.serialize_key_and_certificates(name.encode(), key, cert, None, encryption)
|
||||
certificate_type = ('RSA' if isinstance(key, rsa.RSAPrivateKey)
|
||||
else 'ECDSA384' if isinstance(key, ec.EllipticCurvePrivateKey) and key.curve.key_size == 384
|
||||
else 'ECDSA256')
|
||||
identifier = f'{".".join(reversed(fqdn.split(".")))}.vpn-router.{name}'
|
||||
ids = {kind: str(uuid.uuid5(uuid.NAMESPACE_URL, f'{identifier}.{kind}')) for kind in ('profile', 'pkcs12', 'vpn')}
|
||||
proposal = {'EncryptionAlgorithm': 'AES-256', 'IntegrityAlgorithm': 'SHA2-256', 'DiffieHellmanGroup': 14,
|
||||
'LifeTimeInMinutes': 60}
|
||||
payloads = [{'PayloadType': 'com.apple.security.root' if index == len(chain) - 1 else 'com.apple.security.pkcs1',
|
||||
'PayloadVersion': 1, 'PayloadIdentifier': f'{identifier}.ca{index}',
|
||||
'PayloadUUID': str(uuid.uuid5(uuid.NAMESPACE_URL, f'{identifier}.ca{index}')),
|
||||
'PayloadDisplayName': vpnrouter_pki.common_name(item),
|
||||
'PayloadContent': item.public_bytes(serialization.Encoding.DER)}
|
||||
for index, item in enumerate(chain)]
|
||||
payloads.append({'PayloadType': 'com.apple.security.pkcs12', 'PayloadVersion': 1,
|
||||
'PayloadIdentifier': f'{identifier}.pkcs12', 'PayloadUUID': ids['pkcs12'],
|
||||
'PayloadDisplayName': f'{name} ({fqdn})', 'PayloadContent': bundle})
|
||||
# The router's resolver, which setup records, answers the names of p2s.dns_domains; the others stay with the device's.
|
||||
try:
|
||||
servers = json.loads(vpnrouter.WEB_STATE.read_text(encoding='utf-8'))['network']['client_dns']
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
servers = []
|
||||
domains = [item.strip() for item in cfg['p2s']['dns_domains'] if item.strip()]
|
||||
dns = {'DNS': {'ServerAddresses': servers, 'SupplementalMatchDomains': domains}} if servers and domains else {}
|
||||
payloads.append({
|
||||
**dns,
|
||||
'PayloadType': 'com.apple.vpn.managed', 'PayloadVersion': 1, 'PayloadIdentifier': f'{identifier}.vpn',
|
||||
'PayloadUUID': ids['vpn'], 'PayloadDisplayName': f'VPN Router {fqdn}', 'UserDefinedName': f'VPN Router {fqdn}',
|
||||
'VPNType': 'IKEv2',
|
||||
'IKEv2': {
|
||||
'RemoteAddress': fqdn, 'RemoteIdentifier': fqdn, 'LocalIdentifier': name,
|
||||
'AuthenticationMethod': 'Certificate', 'ExtendedAuthEnabled': 1, 'PayloadCertificateUUID': ids['pkcs12'],
|
||||
'CertificateType': certificate_type,
|
||||
'ServerCertificateIssuerCommonName': vpnrouter_pki.common_name(chain[0]), 'ServerCertificateCommonName': fqdn,
|
||||
'IKESecurityAssociationParameters': {**proposal, 'LifeTimeInMinutes': 480},
|
||||
'ChildSecurityAssociationParameters': proposal,
|
||||
'EnablePFS': 0, 'DeadPeerDetectionRate': 'Medium', 'DisableMOBIKE': 0, 'DisableRedirect': 0,
|
||||
},
|
||||
})
|
||||
document = {'PayloadType': 'Configuration', 'PayloadVersion': 1, 'PayloadIdentifier': identifier,
|
||||
'PayloadUUID': ids['profile'], 'PayloadDisplayName': f'VPN Router {fqdn} ({name})',
|
||||
'PayloadDescription': f'IKEv2 access of {name} to {fqdn}', 'PayloadRemovalDisallowed': False,
|
||||
'PayloadContent': payloads}
|
||||
return {'name': name, 'profile': plistlib.dumps(document).decode()}
|
||||
|
||||
|
||||
def group_dn(name):
|
||||
return f'cn={name},{vpnrouter_ldap.GROUPS}'
|
||||
|
||||
@@ -896,7 +1095,8 @@ def group_add(connection, name, description, members):
|
||||
def group_update(connection, name, new_name=None, description=None):
|
||||
"""Rename the group or change its description ('' removes it); at least one must be given. Renaming ADMIN_GROUP
|
||||
switches the console login of its members off; renaming a group a client of the OpenID Provider allows renames it
|
||||
there."""
|
||||
there, as in the roles it is assigned, and renaming the editors group of web sites renames it in the sites."""
|
||||
import vpnrouter_sites
|
||||
if new_name is None and description is None:
|
||||
raise Failure('give a new name or a description', 2)
|
||||
members = member_names(group_entry(connection, name))
|
||||
@@ -914,6 +1114,13 @@ def group_update(connection, name, new_name=None, description=None):
|
||||
for client in allowing(connection, name):
|
||||
connection.modify_s(dn_of(client), [(ldap.MOD_DELETE, 'vpnRouterAllowedGroup', [group_dn(name).encode()]),
|
||||
(ldap.MOD_ADD, 'vpnRouterAllowedGroup', [group_dn(new_name).encode()])])
|
||||
for dn in assigned(connection, group_dn(name)):
|
||||
connection.modify_s(dn, [(ldap.MOD_DELETE, 'member', [group_dn(name).encode()]),
|
||||
(ldap.MOD_ADD, 'member', [group_dn(new_name).encode()])])
|
||||
try:
|
||||
vpnrouter_sites.rename_group(name, new_name)
|
||||
except vpnrouter_sites.DatabaseError as exc:
|
||||
raise Failure(f'group {name} is renamed {new_name}, but not in the web sites: {exc}')
|
||||
if name == ADMIN_GROUP:
|
||||
console_off(connection, members)
|
||||
name = new_name
|
||||
@@ -921,13 +1128,21 @@ def group_update(connection, name, new_name=None, description=None):
|
||||
|
||||
|
||||
def group_remove(connection, name):
|
||||
"""Remove the group, unless a client of the OpenID Provider allows it; removing ADMIN_GROUP switches the console login
|
||||
of its members off."""
|
||||
"""Remove the group, unless a client of the OpenID Provider allows it or it is the editors group of web sites, and
|
||||
take it out of the roles it is assigned; removing ADMIN_GROUP switches the console login of its members off."""
|
||||
import vpnrouter_sites
|
||||
members = member_names(group_entry(connection, name))
|
||||
clients = allowing(connection, name)
|
||||
if clients:
|
||||
raise Failure(f'group {name} is an allowed group of the clients {", ".join(clients)}; take it out of them first', 2)
|
||||
try:
|
||||
sites = vpnrouter_sites.sites_of_group(name)
|
||||
except vpnrouter_sites.DatabaseError as exc:
|
||||
raise Failure(f'the web sites cannot be read: {exc}')
|
||||
if sites:
|
||||
raise Failure(f'group {name} is the editors group of the web sites {", ".join(sites)}; change them first', 2)
|
||||
connection.delete_s(group_dn(name))
|
||||
leave_roles(connection, group_dn(name))
|
||||
if name == ADMIN_GROUP:
|
||||
console_off(connection, members)
|
||||
return {'name': name, 'removed': True}
|
||||
@@ -956,6 +1171,186 @@ def group_member_remove(connection, name, user):
|
||||
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):
|
||||
"""The PEM blocks of the certificates in a file that also holds their text form."""
|
||||
begin, end = '-----BEGIN CERTIFICATE-----', '-----END CERTIFICATE-----'
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""The router's web service, run by vpn-router-web.service; the entry point is /usr/lib/vpn-router/web.
|
||||
|
||||
Pages:
|
||||
/ the status for people: plain text for curl and wget, a styled page
|
||||
(Tailwind CSS from a CDN) for a browser
|
||||
/status the status as JSON
|
||||
/status/full
|
||||
the whole status as JSON, whatever web.status_content says, for the Router Manager
|
||||
/config the configuration without its secrets, and what the router resolved and
|
||||
runs, with the installed Caddy and where it comes from, as JSON
|
||||
/users the users in the router's directory, whether or not a P2S system is enabled: list,
|
||||
add, change, set the password, disable, enable, remove, the OpenVPN client
|
||||
profile, and TOTP enrolment (see users()); service accounts are not among them
|
||||
/service-accounts
|
||||
the service accounts: list, add, change the e-mail address, set the password,
|
||||
disable, enable, unlock and remove; the internal ones are only listed (see
|
||||
service_accounts())
|
||||
/groups the groups of the router's directory: list, add, rename or describe, remove, and
|
||||
add and take out members (see groups())
|
||||
/account the account of the signed-in user: its details, its password, its TOTP
|
||||
enrolment and its passkeys (see account())
|
||||
/site-to-site
|
||||
the site-to-site connections of the configuration: list, add, change and remove,
|
||||
applied by vpn-router-setup (see site_to_site())
|
||||
/backup the backup settings, the last run and the local archives: show, change and run
|
||||
now (see backup())
|
||||
/remote-access
|
||||
the settings of IKEv2, OpenVPN and WireGuard access and whether each
|
||||
runs: show and change, applied by vpn-router-setup (see remote_access())
|
||||
/ca the PKI: its settings, the root and issuing CAs, the certificates issued; change the
|
||||
settings, issue and revoke certificates, and take the certificate of an issuing CA
|
||||
and a root CRL signed offline (see ca())
|
||||
/pki/<name>.crt, /pki/<name>.crl
|
||||
the DER certificate and CRL of the root (root) and of each issuing CA, which the
|
||||
certificates name in their AIA and CRL distribution points; no authentication
|
||||
/shell while web.shell is true, a login shell of the user's console account on a terminal, for
|
||||
a member of admins whose console login is on and who has a TOTP token: POST signs the user in again,
|
||||
with the password and the TOTP code or with a passkey whose proof POST /shell/passkey begins,
|
||||
unless the user used a shell within web.shell_auth_timeout minutes, for a one-time ticket
|
||||
(see shell_ticket() and shell_passkey()), GET with it is a WebSocket
|
||||
that carries the terminal (see Handler.shell()); /shell/editor/<session>/<id> reads, saves and
|
||||
closes a file the shell opened with vpn-router-editor (vre) over EDITOR_SOCKET (see
|
||||
shell_editor() and editor_request())
|
||||
/auth sign-in with a user's password and TOTP code or with a passkey, refresh and sign-out
|
||||
(see auth())
|
||||
/metrics/history
|
||||
the metrics the metrics service keeps, for a view: hour, day, month or year (see
|
||||
metrics_history())
|
||||
/metrics/settings
|
||||
the settings of the metrics service: show and change, applied by vpn-router-setup (see
|
||||
metrics_settings())
|
||||
/logs the last lines of the journal of the router's units and of the files web.log_files lists
|
||||
(see logs())
|
||||
/sites the web sites Caddy serves: the settings of sites, list, add, change and remove (see sites()),
|
||||
and the files of each: list, download, upload, extract a zip archive, create a folder, rename and
|
||||
remove (see Handler.site_files()); a member of a site's editors group may use the site's files
|
||||
/dns the primary name server of the router's domains: its settings, and the domains: add, change and
|
||||
remove, each with the records it serves and its manual records (see dns())
|
||||
/postgresql
|
||||
the databases of each PostgreSQL cluster on the router with their sizes, as JSON (see
|
||||
postgresql_databases()); /config has the clusters' settings
|
||||
/.well-known/openid-configuration, /oidc/...
|
||||
while oidc.enabled is true, the OpenID Provider (vpnrouter_oidc), answered only on the
|
||||
loopback address, where Caddy passes the requests of the HTTPS site; POST /oidc/login counts
|
||||
towards the limit of /auth calls
|
||||
|
||||
/status/full, /config, /metrics/history, /metrics/settings, /postgresql, /users, /service-accounts, /groups, /site-to-site, /backup, /remote-access, /ca, /logs, /sites, /dns, /shell and /account need an HS256 JWT in an Authorization: Bearer header signed
|
||||
with vpnrouter_auth.WEB_KEY (see vpnrouter_auth.verify). A token of /auth names a user in sub;
|
||||
/account serves that user, and the other paths need a member of vpnrouter_users.ADMIN_GROUP. A
|
||||
token of vpn-router-token has the admin claim and names no user; it may call every path but
|
||||
/account, and only such a token may change ADMIN_GROUP: create, rename to it, change, remove it,
|
||||
add or take out its members, and remove one of its members, and switch a user's console login.
|
||||
These paths and /auth are not
|
||||
served (404) while the key does not exist. /auth but its sign-out and the beginning of a passkey
|
||||
sign-in is limited to AUTH_LIMIT calls per client
|
||||
address in AUTH_WINDOW seconds; the address of a call from one of web.trusted_proxies, by
|
||||
default the address the call came in on, is the last entry of its X-Forwarded-For header.
|
||||
|
||||
/ and /status show only the status, as much as web.status_content says (see shown): basic, the
|
||||
default, the overall status, the time and the client's address (its address as for the rate limit
|
||||
below); standard also the package version, the installed version of vpn-router-manager
|
||||
(manager_version, null when it is not installed) and the number of active and other services; full
|
||||
whether each service runs, whether Docker is installed, how many of the site-to-site connections are established
|
||||
(site_to_site: connections and established, null when swanctl cannot be asked), and IKEv2, OpenVPN
|
||||
and WireGuard. No configuration data, key or connection name is shown, and no address but the client's.
|
||||
|
||||
Modules, one per family of paths, with what they share in common:
|
||||
common the paths of the configuration and of setup's state, the body limit, the HTTP statuses of the
|
||||
errors of the router's modules, reading the configuration and the state, changing a section, log
|
||||
status /, /status, /status/full, /config and /postgresql, with the cache of the status
|
||||
auth /auth, its rate limit, and the rules of who may call a protected path (refusal)
|
||||
identities /users, /service-accounts, /groups and /account
|
||||
connectivity /site-to-site and /remote-access
|
||||
backup /backup
|
||||
pki /ca, and the files /pki/ serves
|
||||
logs /logs
|
||||
sites /sites, with the files of each site
|
||||
dns /dns
|
||||
metrics /metrics/history and /metrics/settings
|
||||
shell /shell, its WebSocket, and the editor of the shell with its socket
|
||||
handler Handler, which reads a request, checks its token and passes it to the module of its path
|
||||
"""
|
||||
@@ -0,0 +1,130 @@
|
||||
"""/auth, the sign-in, refresh and sign-out of the web service, its rate limit per client address, and the rules of
|
||||
who may call a protected path (refusal)."""
|
||||
|
||||
import collections
|
||||
import threading
|
||||
import time
|
||||
|
||||
import ldap
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_auth
|
||||
import vpnrouter_passkeys
|
||||
import vpnrouter_sites
|
||||
import vpnrouter_users
|
||||
|
||||
from .common import USER_STATUS
|
||||
from .sites import SITE_FILES
|
||||
|
||||
#: The calls of /auth allowed per client address in AUTH_WINDOW seconds.
|
||||
AUTH_LIMIT = 10
|
||||
|
||||
AUTH_WINDOW = 60
|
||||
|
||||
#: The times of the recent /auth calls of each client address.
|
||||
_auth_calls = collections.defaultdict(collections.deque)
|
||||
|
||||
_auth_lock = threading.Lock()
|
||||
|
||||
|
||||
def changes_admins(connection, method, parts, body):
|
||||
"""Whether a /users, /service-accounts or /groups request creates, renames to, changes or removes ADMIN_GROUP, or
|
||||
removes a member of it."""
|
||||
admins = vpnrouter_users.ADMIN_GROUP
|
||||
if parts[0] == 'groups':
|
||||
if method == 'POST' and len(parts) == 1:
|
||||
return body.get('name') == admins
|
||||
if method != 'GET' and len(parts) > 1 and parts[1] == admins:
|
||||
return True
|
||||
return method == 'PUT' and len(parts) == 2 and body.get('name') == admins
|
||||
if parts[0] in ('users', 'service-accounts') and method == 'DELETE' and len(parts) == 2:
|
||||
return admins in vpnrouter_users.groups_of(connection, parts[1])
|
||||
return False
|
||||
|
||||
|
||||
def refusal(claims, method, path, body):
|
||||
"""Why the holder of the claims may not make the request to a protected path, or '' when it may.
|
||||
|
||||
The files of a site may also be read and changed by a member of the site's editors group.
|
||||
"""
|
||||
name = claims.get('sub')
|
||||
if path == '/account' or path.startswith('/account/'):
|
||||
return '' if isinstance(name, str) else 'the token names no user'
|
||||
if claims.get('admin') is True:
|
||||
return ''
|
||||
if not isinstance(name, str):
|
||||
return 'the token names no user'
|
||||
admins = vpnrouter_users.ADMIN_GROUP
|
||||
with vpnrouter_users.session() as connection:
|
||||
if not vpnrouter_users.is_admin(connection, name):
|
||||
files = SITE_FILES.match(path)
|
||||
if files and files.group(1) in vpnrouter_sites.editable(vpnrouter_users.groups_of(connection, name)):
|
||||
return ''
|
||||
return f'user {name} is not a member of {admins}'
|
||||
parts = path.strip('/').split('/')
|
||||
if changes_admins(connection, method, parts, body if isinstance(body, dict) else {}):
|
||||
return f'only a token of vpn-router-token may change the group {admins} or remove its members'
|
||||
if parts[0] == 'users' and len(parts) == 3 and parts[2] == 'console' and method != 'GET':
|
||||
return 'only a token of vpn-router-token may change console login'
|
||||
return ''
|
||||
|
||||
|
||||
def auth(path, body):
|
||||
"""Answer a POST to /auth with vpnrouter_users and vpnrouter_auth: the HTTP status and the JSON document.
|
||||
|
||||
POST /auth sign in: {"name", "password", "code"?}; the tokens and the user's name
|
||||
POST /auth/passkey begin a sign-in with a passkey: {"id", "options"} for navigator.credentials.get
|
||||
POST /auth/passkey/complete
|
||||
{"id", "credential"}, credential the browser's answer; the tokens and the user's name
|
||||
POST /auth/refresh {"refresh_token"}; new tokens, the refresh token used up
|
||||
POST /auth/logout {"refresh_token"}; the refresh token revoked
|
||||
"""
|
||||
if not isinstance(body, dict):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
try:
|
||||
cfg = vpnrouter.load_config()
|
||||
if path == '/auth':
|
||||
vpnrouter_users.login(body.get('name'), body.get('password'), body.get('code'))
|
||||
with vpnrouter_users.session() as connection:
|
||||
if vpnrouter_users.service(vpnrouter_users.entry(connection, body['name'])):
|
||||
raise vpnrouter_users.Failure('the name, the password or the code is wrong', 5)
|
||||
return 200, {**vpnrouter_auth.issue(cfg, body['name']), 'name': body['name']}
|
||||
if path == '/auth/passkey':
|
||||
return 200, vpnrouter_passkeys.login_begin()
|
||||
if path == '/auth/passkey/complete':
|
||||
with vpnrouter_users.session() as connection:
|
||||
name = vpnrouter_passkeys.login_complete(connection, body.get('id'), body.get('credential'))
|
||||
return 200, {**vpnrouter_auth.issue(cfg, name), 'name': name}
|
||||
name = vpnrouter_auth.take(cfg, body.get('refresh_token'))
|
||||
if path == '/auth/logout':
|
||||
return 200, {'revoked': name is not None}
|
||||
if name is None:
|
||||
return 401, {'error': 'the refresh token is not valid'}
|
||||
with vpnrouter_users.session() as connection:
|
||||
user = vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
|
||||
if not user['enabled'] or user['locked']:
|
||||
return 401, {'error': f'user {name} is disabled or locked'}
|
||||
return 200, vpnrouter_auth.issue(cfg, name)
|
||||
except vpnrouter_users.Failure as failure:
|
||||
return (401 if failure.status == 3 else USER_STATUS.get(failure.status, 500)), {'error': str(failure)}
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
return 500, {'error': f'the user directory failed: {exc}'}
|
||||
except vpnrouter_auth.DatabaseError as exc:
|
||||
return 500, {'error': str(exc)}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
|
||||
|
||||
def auth_wait(address):
|
||||
"""Seconds until the client address may call /auth again, 0 when it may now; a call that may is counted."""
|
||||
now = time.monotonic()
|
||||
with _auth_lock:
|
||||
for idle in [key for key, times in _auth_calls.items() if times[-1] <= now - AUTH_WINDOW]:
|
||||
del _auth_calls[idle]
|
||||
calls = _auth_calls[address]
|
||||
while calls and calls[0] <= now - AUTH_WINDOW:
|
||||
calls.popleft()
|
||||
if len(calls) >= AUTH_LIMIT:
|
||||
return int(calls[0] + AUTH_WINDOW - now) + 1
|
||||
calls.append(now)
|
||||
return 0
|
||||
@@ -0,0 +1,84 @@
|
||||
"""/backup: the backup settings, the last run and the local archives, and a run now."""
|
||||
|
||||
import configparser
|
||||
import shutil
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_backup
|
||||
from vpnrouter_status import run
|
||||
|
||||
|
||||
def backup_settings(settings):
|
||||
"""The backup section as /backup shows it: its settings but pbs_password, with pbs_password_set."""
|
||||
return {**{option: value for option, value in settings.items() if option != 'pbs_password'},
|
||||
'pbs_password_set': bool(settings['pbs_password'])}
|
||||
|
||||
|
||||
def rclone_remotes():
|
||||
"""The names of the remotes of vpnrouter_backup.RCLONE_CONFIG; [] when it cannot be read, as when it is encrypted."""
|
||||
parser = configparser.ConfigParser(interpolation=None)
|
||||
try:
|
||||
parser.read(vpnrouter_backup.RCLONE_CONFIG, encoding='utf-8')
|
||||
except (OSError, configparser.Error):
|
||||
return []
|
||||
return parser.sections()
|
||||
|
||||
|
||||
def backup(method, path, body):
|
||||
"""Answer a /backup request: the HTTP status and the JSON document.
|
||||
|
||||
GET /backup {"settings", "state", "archives", "next", "client", "rclone"}: the backup section without
|
||||
pbs_password, with pbs_password_set; the last run (vpnrouter_backup.state), null before the
|
||||
first; the local archives of this host, newest first, [{name, time, size}], [] with the pbs
|
||||
target; the next run of the timer as systemd shows it, null while backups are disabled;
|
||||
whether proxmox-backup-client is installed; and {"installed", "config", "remotes"} of rclone
|
||||
PUT /backup change any of the settings of the backup section; an empty or absent pbs_password keeps the
|
||||
current secret
|
||||
POST /backup/run start a backup now, without waiting for it; 409 while one runs
|
||||
|
||||
A change is made as /site-to-site makes one, and applied by restarting vpn-router-setup without waiting for it.
|
||||
"""
|
||||
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
body = body or {}
|
||||
if (method, path) == ('POST', '/backup/run'):
|
||||
if vpnrouter_backup.running():
|
||||
return 409, {'error': 'a backup is running'}
|
||||
result = run('systemctl', 'start', '--no-block', vpnrouter_backup.UNIT)
|
||||
if result is None or result.returncode != 0:
|
||||
return 500, {'error': f'the backup cannot be started: {result.stderr.strip() if result else "no systemctl"}'}
|
||||
return 202, {'started': True}
|
||||
if (method, path) not in (('GET', '/backup'), ('PUT', '/backup')):
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
try:
|
||||
if method == 'GET':
|
||||
settings = vpnrouter.load_config()['backup']
|
||||
timer = run('systemctl', 'show', '-p', 'NextElapseUSecRealtime', '--value', vpnrouter_backup.TIMER)
|
||||
next_run = timer.stdout.strip() if timer and timer.returncode == 0 else ''
|
||||
return 200, {
|
||||
'settings': backup_settings(settings),
|
||||
'state': vpnrouter_backup.state() or None,
|
||||
'archives': vpnrouter_backup.archives(settings['path']) if settings['target'] == 'local' else [],
|
||||
'next': next_run if settings['enabled'] and next_run not in ('', 'n/a') else None,
|
||||
'client': vpnrouter_backup.PBS_CLIENT.exists(),
|
||||
'rclone': {'installed': shutil.which('rclone') is not None,
|
||||
'config': vpnrouter_backup.RCLONE_CONFIG.is_file(), 'remotes': rclone_remotes()},
|
||||
}
|
||||
settings = {option: body[option] for option in vpnrouter.SCHEMA['backup'] if option in body}
|
||||
if not settings.get('pbs_password'):
|
||||
settings.pop('pbs_password', None)
|
||||
with vpnrouter.config_lock():
|
||||
data = vpnrouter.read_settings()
|
||||
current = data.get('backup')
|
||||
data['backup'] = {**(current if isinstance(current, dict) else {}), **settings}
|
||||
cfg = vpnrouter.with_defaults(data)
|
||||
vpnrouter.validate(cfg)
|
||||
vpnrouter.save_config(data)
|
||||
except vpnrouter.ConfigError as exc:
|
||||
return 400, {'error': str(exc)}
|
||||
except OSError as exc:
|
||||
return 500, {'error': f'the configuration cannot be written: {exc}'}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
|
||||
return 200, {'settings': backup_settings(cfg['backup'])}
|
||||
@@ -0,0 +1,85 @@
|
||||
"""What the modules of the web service share: the paths of the configuration and of the state vpn-router-setup writes,
|
||||
the limit of request bodies, the HTTP statuses of the failures of the router's modules, reading the configuration and
|
||||
the state, changing a section of the configuration, the state of a unit, and the log."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_auth
|
||||
from vpnrouter_status import run
|
||||
|
||||
STATE_FILE = '/run/vpn-router/web.json'
|
||||
|
||||
CONFIG_FILE = '/etc/vpn-router/vpn-router.json'
|
||||
|
||||
#: The largest request body accepted, in bytes; a CSR or a certificate chain in PEM is a few kilobytes.
|
||||
BODY_LIMIT = 65536
|
||||
|
||||
#: The HTTP status for each status of a vpnrouter_users.Failure; any other is 500.
|
||||
USER_STATUS = {2: 400, 3: 404, 4: 409, 5: 401}
|
||||
|
||||
#: The HTTP status for each status of a vpnrouter_pki.Failure; any other is 500.
|
||||
PKI_STATUS = {2: 400, 3: 404, 4: 409}
|
||||
|
||||
#: The HTTP status for each status of a vpnrouter_sites.Failure; any other is 500.
|
||||
SITE_STATUS = {2: 400, 3: 404, 4: 409}
|
||||
|
||||
#: The bytes in a MiB, the unit of sites.upload_max_mb.
|
||||
MIB = 1024 * 1024
|
||||
|
||||
|
||||
def read_json(path):
|
||||
"""The object in a JSON file, or {} when the file is missing or holds no object."""
|
||||
try:
|
||||
with open(path, encoding='utf-8') as handle:
|
||||
data = json.load(handle)
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def section(configuration, name):
|
||||
"""A section of the configuration, or {} when it is missing or not an object."""
|
||||
value = configuration.get(name)
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def current():
|
||||
"""What a request needs, read now: the features and facts setup resolved, and the signing key.
|
||||
|
||||
Before setup has run the router has no features, and without a key the protected paths and
|
||||
/auth are not served.
|
||||
"""
|
||||
return dict(read_json(STATE_FILE), key=vpnrouter_auth.key())
|
||||
|
||||
|
||||
def unit_state(unit):
|
||||
result = run('systemctl', 'is-active', unit)
|
||||
return result.stdout.strip() if result and result.stdout.strip() else 'unknown'
|
||||
|
||||
|
||||
def section_change(name, body):
|
||||
"""Change any of the settings of the configuration's section as /backup changes its own; the section, or the HTTP
|
||||
status and the error document as a tuple."""
|
||||
settings = {option: body[option] for option in vpnrouter.SCHEMA[name] if option in body}
|
||||
try:
|
||||
with vpnrouter.config_lock():
|
||||
data = vpnrouter.read_settings()
|
||||
current = data.get(name)
|
||||
data[name] = {**(current if isinstance(current, dict) else {}), **settings}
|
||||
cfg = vpnrouter.with_defaults(data)
|
||||
vpnrouter.validate(cfg)
|
||||
vpnrouter.save_config(data)
|
||||
except vpnrouter.ConfigError as exc:
|
||||
return 400, {'error': str(exc)}
|
||||
except OSError as exc:
|
||||
return 500, {'error': f'the configuration cannot be written: {exc}'}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
|
||||
return cfg[name]
|
||||
|
||||
|
||||
def log(message):
|
||||
print(f'vpn-router-web: {message}', file=sys.stderr, flush=True)
|
||||
@@ -0,0 +1,155 @@
|
||||
"""The connections of the router: /site-to-site and /remote-access, the IKEv2, OpenVPN and WireGuard access."""
|
||||
|
||||
import ipaddress
|
||||
|
||||
import vpnrouter
|
||||
from vpnrouter_status import established_each, run
|
||||
|
||||
from .common import section
|
||||
|
||||
#: The settings of a site_to_site connection that /site-to-site adds and changes.
|
||||
CONNECTION_SETTINGS = ('addrs', 'id', 'cidrs', 'local_cidrs', 'local_id_mode', 'auth', 'psk', 'remote_ca')
|
||||
|
||||
#: The sections of the remote access systems /remote-access changes: IKEv2, OpenVPN and WireGuard.
|
||||
REMOTE_ACCESS = ('p2s', 'openvpn', 'wireguard')
|
||||
|
||||
|
||||
def connection_of(name, connection, count):
|
||||
"""A site_to_site connection as /site-to-site shows it: its settings but psk, psk_set and established.
|
||||
|
||||
count is the number of its established IKE SAs, None when it is not known.
|
||||
"""
|
||||
return {'name': name, **{option: value for option, value in connection.items() if option != 'psk'},
|
||||
'psk_set': bool(connection['psk']), 'established': None if count is None else count > 0}
|
||||
|
||||
|
||||
def site_to_site(method, path, body):
|
||||
"""Answer a /site-to-site request: the HTTP status and the JSON document.
|
||||
|
||||
GET /site-to-site the connections, {"connections": [...]}: each with its settings but psk, with
|
||||
psk_set, whether psk holds a key, and established, whether its tunnel is up,
|
||||
null when swanctl cannot be asked
|
||||
POST /site-to-site add a connection: {"name", "addrs", "id", "cidrs", "local_cidrs", "local_id_mode",
|
||||
"auth", "psk", "remote_ca"}; psk is required with auth psk, the default, and an
|
||||
existing name is 409
|
||||
PUT /site-to-site/<name> change any of the settings of POST but name; an empty or absent psk keeps the
|
||||
current key, and a psk given replaces psk_file
|
||||
DELETE /site-to-site/<name> remove the connection
|
||||
|
||||
A change is made holding vpnrouter.config_lock, checked as vpn-router-setup checks the configuration (400 with
|
||||
the reason), written with vpnrouter.save_config, and applied by restarting vpn-router-setup without waiting for it.
|
||||
"""
|
||||
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
body = body or {}
|
||||
parts = path.strip('/').split('/')
|
||||
name = parts[1] if len(parts) == 2 else None
|
||||
if (method, len(parts)) not in (('GET', 1), ('POST', 1), ('PUT', 2), ('DELETE', 2)):
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
try:
|
||||
if method == 'GET':
|
||||
connections = vpnrouter.load_config()['site_to_site']
|
||||
counts = established_each(connections)
|
||||
return 200, {'connections': [connection_of(key, value, counts[key]) for key, value in connections.items()]}
|
||||
settings = {option: body[option] for option in CONNECTION_SETTINGS if option in body}
|
||||
with vpnrouter.config_lock():
|
||||
data = vpnrouter.read_settings()
|
||||
connections = data.setdefault('site_to_site', {})
|
||||
if method == 'POST':
|
||||
name = body.get('name')
|
||||
if not isinstance(name, str) or not name:
|
||||
return 400, {'error': 'name is required'}
|
||||
if name in connections:
|
||||
return 409, {'error': f'connection {name} exists'}
|
||||
if settings.get('auth', 'psk') == 'psk' and (not isinstance(settings.get('psk'), str) or not settings['psk']):
|
||||
return 400, {'error': 'psk is required with auth psk'}
|
||||
connections[name] = settings
|
||||
elif name not in connections:
|
||||
return 404, {'error': f'no connection {name}'}
|
||||
elif method == 'PUT':
|
||||
connection = dict(connections[name]) if isinstance(connections[name], dict) else {}
|
||||
if settings.get('psk'):
|
||||
connection.pop('psk_file', None)
|
||||
else:
|
||||
settings.pop('psk', None)
|
||||
connections[name] = {**connection, **settings}
|
||||
else:
|
||||
del connections[name]
|
||||
cfg = vpnrouter.with_defaults(data)
|
||||
vpnrouter.validate(cfg)
|
||||
vpnrouter.save_config(data)
|
||||
except vpnrouter.ConfigError as exc:
|
||||
return 400, {'error': str(exc)}
|
||||
except OSError as exc:
|
||||
return 500, {'error': f'the configuration cannot be written: {exc}'}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
|
||||
if method == 'DELETE':
|
||||
return 200, {'name': name, 'removed': True}
|
||||
return (201 if method == 'POST' else 200), connection_of(name, cfg['site_to_site'][name], None)
|
||||
|
||||
|
||||
def wireguard_server(cfg, state):
|
||||
"""What a WireGuard peer's configuration needs of the router: {public_key, endpoint, allowed_ips, dns}; public_key is
|
||||
null before setup made the key pair, endpoint, <wan.local_fqdn>:<wireguard.listen_port>, null without a name,
|
||||
allowed_ips the networks reached through the tunnel, the router's local CIDRs and the WireGuard network, and dns the
|
||||
addresses of the remote access clients' resolver, [] while it does not run."""
|
||||
try:
|
||||
public_key = vpnrouter.WG_PUBLIC_KEY.read_text(encoding='utf-8').strip() or None
|
||||
except OSError:
|
||||
public_key = None
|
||||
fqdn = cfg['wan']['local_fqdn'].strip()
|
||||
address = cfg['wireguard']['address'].strip()
|
||||
networks = section(state, 'network').get('local_cidrs')
|
||||
allowed = [cidr for cidr in networks if isinstance(cidr, str)] if isinstance(networks, list) else []
|
||||
if address:
|
||||
allowed.append(str(ipaddress.ip_interface(address).network))
|
||||
dns = section(state, 'network').get('client_dns')
|
||||
return {'public_key': public_key, 'endpoint': f'{fqdn}:{cfg["wireguard"]["listen_port"]}' if fqdn else None,
|
||||
'allowed_ips': list(dict.fromkeys(allowed)),
|
||||
'dns': [address for address in dns if isinstance(address, str)] if isinstance(dns, list) else []}
|
||||
|
||||
|
||||
def remote_access(state, method, path, body):
|
||||
"""Answer a /remote-access request: the HTTP status and the JSON document.
|
||||
|
||||
GET /remote-access {"p2s", "openvpn", "wireguard", "running", "local_fqdn", "wireguard_server"}: the
|
||||
settings of the three remote access systems (REMOTE_ACCESS), whether setup runs
|
||||
each (running: {p2s, openvpn, wireguard}), wan.local_fqdn, which IKEv2 and OpenVPN
|
||||
need with the router's VPN server certificate, and what a WireGuard peer's
|
||||
configuration needs of the router (wireguard_server)
|
||||
PUT /remote-access/<system> change any of the settings of the system's section; answers the section
|
||||
|
||||
A change is made as /site-to-site makes one, and applied by restarting vpn-router-setup without waiting for it.
|
||||
"""
|
||||
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
body = body or {}
|
||||
parts = path.strip('/').split('/')
|
||||
try:
|
||||
if (method, len(parts)) == ('GET', 1):
|
||||
cfg = vpnrouter.load_config()
|
||||
return 200, {**{system: cfg[system] for system in REMOTE_ACCESS},
|
||||
'running': {system: bool(state.get(system)) for system in REMOTE_ACCESS},
|
||||
'local_fqdn': cfg['wan']['local_fqdn'],
|
||||
'wireguard_server': wireguard_server(cfg, state)}
|
||||
if method != 'PUT' or len(parts) != 2 or parts[1] not in REMOTE_ACCESS:
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
system = parts[1]
|
||||
settings = {option: body[option] for option in vpnrouter.SCHEMA[system] if option in body}
|
||||
with vpnrouter.config_lock():
|
||||
data = vpnrouter.read_settings()
|
||||
current = data.get(system)
|
||||
data[system] = {**(current if isinstance(current, dict) else {}), **settings}
|
||||
cfg = vpnrouter.with_defaults(data)
|
||||
vpnrouter.validate(cfg)
|
||||
vpnrouter.save_config(data)
|
||||
except vpnrouter.ConfigError as exc:
|
||||
return 400, {'error': str(exc)}
|
||||
except OSError as exc:
|
||||
return 500, {'error': f'the configuration cannot be written: {exc}'}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
|
||||
return 200, cfg[system]
|
||||
@@ -0,0 +1,84 @@
|
||||
"""/dns: the primary name server of the router's domains."""
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_dns
|
||||
from vpnrouter_status import run
|
||||
|
||||
from .common import section_change, unit_state
|
||||
|
||||
|
||||
def dns(method, path, query, body):
|
||||
"""Answer a /dns request with vpnrouter_dns: the HTTP status and the JSON document.
|
||||
|
||||
GET /dns {"settings", "nameserver", "public_address", "installed", "running", "domains",
|
||||
"problem"}: the dns section of the configuration, wan.local_fqdn, the primary name server
|
||||
of the domains, wan.public_address, the address of the records of the sites with
|
||||
dns_address public, whether PowerDNS is installed and runs, the domains, each {"name",
|
||||
"ttl", "manual", "records"} with its manual records and the records the name server
|
||||
serves, and why the domains cannot be read, null when they can
|
||||
PUT /dns change any of the settings of the dns section, and with "public_address"
|
||||
wan.public_address; {"settings", "public_address"}
|
||||
POST /dns/domains add a domain: {"name", "ttl"?}; ttl is dns.ttl when null or absent; an existing name is
|
||||
409
|
||||
PUT /dns/domains/<domain>
|
||||
change "ttl" of the domain
|
||||
DELETE /dns/domains/<domain>
|
||||
remove the domain with its manual records
|
||||
POST /dns/domains/<domain>/records
|
||||
add a manual record: {"name", "type", "content", "ttl"?}; name is relative to the domain,
|
||||
@ for the domain itself, or absolute with a final dot; ttl is the domain's without it
|
||||
DELETE /dns/domains/<domain>/records
|
||||
remove the manual records of name= and type=, with content= only that one
|
||||
|
||||
A change is applied by restarting vpn-router-setup without waiting for it.
|
||||
"""
|
||||
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
body = body or {}
|
||||
parts = path.strip('/').split('/')
|
||||
route = (method, *parts[:2], *parts[3:4])
|
||||
try:
|
||||
cfg = vpnrouter.load_config()
|
||||
if route == ('GET', 'dns'):
|
||||
domains, problem = [], None
|
||||
try:
|
||||
domains = vpnrouter_dns.listing()
|
||||
except vpnrouter_dns.DatabaseError as exc:
|
||||
problem = str(exc)
|
||||
return 200, {'settings': cfg['dns'], 'nameserver': cfg['wan']['local_fqdn'].strip(),
|
||||
'public_address': cfg['wan']['public_address'].strip(),
|
||||
'installed': vpnrouter_dns.PDNS_SERVER.exists(),
|
||||
'running': unit_state(vpnrouter_dns.UNIT) == 'active', 'domains': domains,
|
||||
'problem': problem}
|
||||
if route == ('PUT', 'dns'):
|
||||
wan = cfg['wan']
|
||||
if 'public_address' in body:
|
||||
wan = section_change('wan', {'public_address': body.pop('public_address')})
|
||||
if isinstance(wan, tuple):
|
||||
return wan
|
||||
changed = section_change('dns', body)
|
||||
if isinstance(changed, tuple):
|
||||
return changed
|
||||
return 200, {'settings': changed, 'public_address': wan['public_address'].strip()}
|
||||
if route == ('POST', 'dns', 'domains') and len(parts) == 2:
|
||||
document, code = vpnrouter_dns.domain_add(body), 201
|
||||
elif route == ('PUT', 'dns', 'domains') and len(parts) == 3:
|
||||
document, code = vpnrouter_dns.domain_change(parts[2], body), 200
|
||||
elif route == ('DELETE', 'dns', 'domains') and len(parts) == 3:
|
||||
document, code = vpnrouter_dns.domain_remove(parts[2]), 200
|
||||
elif route == ('POST', 'dns', 'domains', 'records') and len(parts) == 4:
|
||||
document, code = vpnrouter_dns.add(cfg, parts[2], body), 201
|
||||
elif route == ('DELETE', 'dns', 'domains', 'records') and len(parts) == 4:
|
||||
content = query.get('content', [None])[0]
|
||||
document = vpnrouter_dns.remove(parts[2], query.get('name', [''])[0], query.get('type', [''])[0], content)
|
||||
code = 200
|
||||
else:
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
except vpnrouter_dns.Failure as failure:
|
||||
return {3: 404, 4: 409}.get(failure.status, 400), {'error': str(failure)}
|
||||
except vpnrouter_dns.DatabaseError as exc:
|
||||
return 500, {'error': str(exc)}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
|
||||
return code, document
|
||||
@@ -0,0 +1,249 @@
|
||||
"""The request handler of the web service: it reads a request, checks its token and passes it to the module of its
|
||||
path (answer)."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import urllib.parse
|
||||
from http.server import BaseHTTPRequestHandler
|
||||
|
||||
import ldap
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_auth
|
||||
import vpnrouter_net
|
||||
import vpnrouter_oidc
|
||||
import vpnrouter_pki
|
||||
import vpnrouter_sites
|
||||
|
||||
from .auth import auth, auth_wait, refusal
|
||||
from .backup import backup
|
||||
from .common import BODY_LIMIT, CONFIG_FILE, current, log, read_json, section
|
||||
from .connectivity import remote_access, site_to_site
|
||||
from .dns import dns
|
||||
from .identities import account, groups, service_accounts, users
|
||||
from .logs import logs
|
||||
from .https import https_settings
|
||||
from .metrics import metrics_history, metrics_settings
|
||||
from .pki import PUBLISHED, ca
|
||||
from .shell import EDITOR_BODY_LIMIT, shell, shell_editor, shell_passkey, shell_ticket
|
||||
from .sites import SITE_FILES, site_files, sites
|
||||
from .status import cached, details, html_of, postgresql_databases, shown, text_of
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = 'HTTP/1.1'
|
||||
|
||||
def claims(self, secret):
|
||||
"""The claims of the request's token signed with the secret, or None without an acceptable one."""
|
||||
header = self.headers.get('Authorization', '')
|
||||
return vpnrouter_auth.verify(header[7:].strip(), secret) if header.startswith('Bearer ') else None
|
||||
|
||||
def client(self):
|
||||
"""The client address: the last X-Forwarded-For entry when the peer is a trusted proxy, else the peer."""
|
||||
peer = self.client_address[0]
|
||||
trusted = section(read_json(CONFIG_FILE), 'web').get('trusted_proxies') or [self.server.server_address[0]]
|
||||
forwarded = self.headers.get('X-Forwarded-For', '')
|
||||
if isinstance(trusted, list) and peer in trusted and forwarded.strip():
|
||||
return forwarded.split(',')[-1].strip()
|
||||
return peer
|
||||
|
||||
def refusal(self, claims, path, payload):
|
||||
"""The HTTP status and the message that refuse a request to a protected path, or None when it may be made."""
|
||||
try:
|
||||
reason = refusal(claims, self.command, path, payload)
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
return 500, f'the user directory failed: {exc}'
|
||||
except vpnrouter_sites.DatabaseError as exc:
|
||||
return 500, str(exc)
|
||||
return (403, reason) if reason else None
|
||||
|
||||
def answer(self, send_body):
|
||||
path = self.path.split('?', 1)[0]
|
||||
extra = []
|
||||
self.principal = None
|
||||
# A body that is not read must not be taken for the next request.
|
||||
if self.command not in ('GET', 'HEAD'):
|
||||
self.close_connection = True
|
||||
directory = next((name for name in ('users', 'service-accounts', 'groups', 'account', 'site-to-site', 'backup',
|
||||
'ca', 'shell', 'remote-access', 'logs', 'sites', 'dns')
|
||||
if path == f'/{name}' or path.startswith(f'/{name}/')), None)
|
||||
protected = path in ('/status/full', '/config', '/metrics/history', '/metrics/settings', '/https/settings',
|
||||
'/postgresql') or \
|
||||
directory is not None
|
||||
signing = path in ('/auth', '/auth/passkey', '/auth/passkey/complete', '/auth/refresh', '/auth/logout')
|
||||
oidc = (path == '/.well-known/openid-configuration' or path.startswith('/oidc/')) and \
|
||||
self.server.server_address[0] == vpnrouter_net.LOOPBACK
|
||||
files = SITE_FILES.match(path) if directory == 'sites' else None
|
||||
query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)
|
||||
state = current() if protected or signing else {}
|
||||
claims = self.claims(state['key']) if protected and state['key'] else None
|
||||
# The body of a file upload is read as a stream, after the request is allowed.
|
||||
payload = self.request_body(EDITOR_BODY_LIMIT if path.startswith('/shell/editor/') else BODY_LIMIT) \
|
||||
if (protected or signing) and self.command not in ('GET', 'HEAD') and not (files and self.command == 'PUT') \
|
||||
else None
|
||||
if claims is not None:
|
||||
self.principal = 'admin' if claims.get('admin') is True else claims.get('sub')
|
||||
if (protected or signing) and not state['key']:
|
||||
body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
|
||||
elif signing and self.command != 'POST':
|
||||
body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405
|
||||
elif signing:
|
||||
wait = auth_wait(self.client()) if path not in ('/auth/logout', '/auth/passkey') else 0
|
||||
if wait:
|
||||
code, document = 429, {'error': f'too many sign-in calls; retry in {wait} seconds'}
|
||||
extra.append(('Retry-After', str(wait)))
|
||||
else:
|
||||
code, document = auth(path, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif protected and claims is None:
|
||||
body, kind, code = b'Unauthorized\n', 'text/plain; charset=utf-8', 401
|
||||
extra.append(('WWW-Authenticate', 'Bearer'))
|
||||
elif protected and (refused := self.refusal(claims, path, payload)):
|
||||
code, kind = refused[0], 'application/json'
|
||||
body = json.dumps({'error': refused[1]}, indent=2).encode() + b'\n'
|
||||
elif path == '/status/full' and self.command in ('GET', 'HEAD'):
|
||||
document = shown(cached(), 'full', self.client())
|
||||
body, kind, code = json.dumps(document, indent=2).encode() + b'\n', 'application/json', 200
|
||||
elif path == '/config' and self.command in ('GET', 'HEAD'):
|
||||
body, kind, code = json.dumps(details(state), indent=2).encode() + b'\n', 'application/json', 200
|
||||
elif path == '/postgresql' and self.command in ('GET', 'HEAD'):
|
||||
body, kind, code = json.dumps(postgresql_databases(), indent=2).encode() + b'\n', 'application/json', 200
|
||||
elif path == '/metrics/history' and self.command in ('GET', 'HEAD'):
|
||||
query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)
|
||||
code, document = metrics_history(query.get('range', ['hour'])[0])
|
||||
body, kind = json.dumps(document).encode() + b'\n', 'application/json'
|
||||
elif path == '/metrics/settings':
|
||||
code, document = metrics_settings(self.command, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif path == '/https/settings':
|
||||
code, document = https_settings(self.command, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif directory == 'shell' and (path, self.command) == ('/shell', 'GET'):
|
||||
return shell(self, claims.get('sub'))
|
||||
elif directory == 'shell' and path.startswith('/shell/editor/'):
|
||||
code, document = shell_editor(claims.get('sub'), self.command, path.strip('/').split('/'), payload)
|
||||
body, kind = json.dumps(document).encode() + b'\n', 'application/json'
|
||||
elif directory == 'shell':
|
||||
code, document = shell_ticket(claims.get('sub'), payload) if (path, self.command) == ('/shell', 'POST') \
|
||||
else shell_passkey(claims.get('sub')) if (path, self.command) == ('/shell/passkey', 'POST') \
|
||||
else (404, {'error': f'no route {self.command} {path}'})
|
||||
if code != 200:
|
||||
log(f'shell ticket of {claims.get("sub")} from {self.client()} refused: {document["error"]}')
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif directory == 'account':
|
||||
code, document = account(claims['sub'], self.command, path, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif directory == 'site-to-site':
|
||||
code, document = site_to_site(self.command, path, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif directory == 'remote-access':
|
||||
code, document = remote_access(state, self.command, path, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif directory == 'backup':
|
||||
code, document = backup(self.command, path, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif directory == 'ca':
|
||||
code, document = ca(self.command, path, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif directory == 'logs':
|
||||
code, document = logs(self.command, path, query)
|
||||
body, kind = json.dumps(document).encode() + b'\n', 'application/json'
|
||||
elif files:
|
||||
answered = site_files(self, files.group(1), files.group(2) or '', query, payload, send_body)
|
||||
if answered is None:
|
||||
return
|
||||
code, document = answered
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif directory == 'sites':
|
||||
code, document = sites(self.command, path, query, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif directory == 'dns':
|
||||
code, document = dns(self.command, path, query, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif directory == 'service-accounts':
|
||||
code, document = service_accounts(self.command, path, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif directory is not None:
|
||||
code, document = (users if directory == 'users' else groups)(state, self.command, path, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif oidc:
|
||||
wait = auth_wait(self.client()) if self.command == 'POST' and path in (
|
||||
'/oidc/login', '/oidc/device', '/oidc/device_authorization') else 0
|
||||
if wait:
|
||||
code, kind = 429, 'application/json'
|
||||
body = json.dumps({'error': f'too many sign-in calls; retry in {wait} seconds'}).encode() + b'\n'
|
||||
extra.append(('Retry-After', str(wait)))
|
||||
else:
|
||||
code, kind, body, headers = vpnrouter_oidc.answer(self.command, self.path, self.headers, self.raw_body())
|
||||
extra += headers
|
||||
elif self.command not in ('GET', 'HEAD'):
|
||||
body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405
|
||||
elif path.startswith('/pki/') and vpnrouter.PKI_NAME.match(path[5:-4]) and path[-4:] in PUBLISHED and \
|
||||
(vpnrouter_pki.PUBLISH_DIR / path[5:]).is_file():
|
||||
body, kind, code = (vpnrouter_pki.PUBLISH_DIR / path[5:]).read_bytes(), PUBLISHED[path[-4:]], 200
|
||||
elif path in ('/status', '/'):
|
||||
document = shown(cached(), section(read_json(CONFIG_FILE), 'web').get('status_content'), self.client())
|
||||
agent = self.headers.get('User-Agent', '').lower()
|
||||
if path == '/status':
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif 'curl' in agent or 'wget' in agent:
|
||||
body, kind = text_of(document).encode(), 'text/plain; charset=utf-8'
|
||||
else:
|
||||
body, kind = html_of(document).encode(), 'text/html; charset=utf-8'
|
||||
code = 200
|
||||
else:
|
||||
body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
|
||||
try:
|
||||
self.send_response(code)
|
||||
self.send_header('Content-Type', kind)
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
for name, value in extra:
|
||||
self.send_header(name, value)
|
||||
self.end_headers()
|
||||
if send_body:
|
||||
self.wfile.write(body)
|
||||
except ConnectionError:
|
||||
# The client went away before the answer was written.
|
||||
self.close_connection = True
|
||||
|
||||
def raw_body(self):
|
||||
"""The request body's bytes, b'' without one or over BODY_LIMIT."""
|
||||
try:
|
||||
size = int(self.headers.get('Content-Length') or 0)
|
||||
except ValueError:
|
||||
return b''
|
||||
return self.rfile.read(size) if 0 < size <= BODY_LIMIT else b''
|
||||
|
||||
def request_body(self, limit=BODY_LIMIT):
|
||||
"""The request body as JSON, None when there is none, or the string 'invalid', also when it is larger than
|
||||
limit bytes."""
|
||||
length = self.headers.get('Content-Length')
|
||||
if not length:
|
||||
return None
|
||||
try:
|
||||
size = int(length)
|
||||
if not 0 <= size <= limit:
|
||||
return 'invalid'
|
||||
return json.loads(self.rfile.read(size) or b'null')
|
||||
except ValueError:
|
||||
return 'invalid'
|
||||
|
||||
def do_GET(self):
|
||||
self.answer(True)
|
||||
|
||||
def do_HEAD(self):
|
||||
self.answer(False)
|
||||
|
||||
def do_POST(self):
|
||||
self.answer(True)
|
||||
|
||||
def do_PUT(self):
|
||||
self.answer(True)
|
||||
|
||||
def do_DELETE(self):
|
||||
self.answer(True)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
principal = getattr(self, 'principal', None) or '-'
|
||||
sys.stderr.write('%s - %s [%s] %s\n' % (self.client_address[0], principal, self.log_date_time_string(), format % args))
|
||||
@@ -0,0 +1,33 @@
|
||||
"""/https/settings: the settings of HTTPS, of which the API changes the CA of Caddy's certificates."""
|
||||
|
||||
import vpnrouter
|
||||
|
||||
from .common import section_change
|
||||
|
||||
#: The settings of the https section PUT /https/settings changes.
|
||||
CHANGEABLE = ('acme_staging',)
|
||||
|
||||
|
||||
def https_settings(method, body):
|
||||
"""Answer a /https/settings request: the HTTP status and the JSON document.
|
||||
|
||||
GET /https/settings {"settings"}: the https section
|
||||
PUT /https/settings change acme_staging; the other settings of the section are changed on the router with
|
||||
vpn-config-edit, and a body naming one is 400
|
||||
|
||||
A change is applied by restarting vpn-router-setup without waiting for it.
|
||||
"""
|
||||
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
if method in ('GET', 'HEAD'):
|
||||
try:
|
||||
return 200, {'settings': vpnrouter.load_config()['https']}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
if method != 'PUT':
|
||||
return 404, {'error': f'no route {method} /https/settings'}
|
||||
others = sorted(set(body or {}) - set(CHANGEABLE))
|
||||
if others:
|
||||
return 400, {'error': f'https.{", https.".join(others)}: changed on the router with vpn-config-edit only'}
|
||||
changed = section_change('https', body or {})
|
||||
return changed if isinstance(changed, tuple) else (200, {'settings': changed})
|
||||
@@ -0,0 +1,299 @@
|
||||
"""The identities of the router's directory: /users, /service-accounts, /groups and the signed-in user's /account."""
|
||||
|
||||
import urllib.parse
|
||||
|
||||
import ldap
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_auth
|
||||
import vpnrouter_passkeys
|
||||
import vpnrouter_users
|
||||
|
||||
from .common import USER_STATUS
|
||||
|
||||
|
||||
def users(state, method, path, body):
|
||||
"""Answer a /users request with vpnrouter_users: the HTTP status and the JSON document.
|
||||
|
||||
GET /users the users, and {"enabled", "address"} of wireguard, whose network their
|
||||
WireGuard peers take their addresses from
|
||||
POST /users add a user: {"name", "password", "first_name", "last_name", "email"};
|
||||
without the names a Manager-only account
|
||||
PUT /users/<name> change any of {"first_name", "last_name", "email", "wireguard_peers"};
|
||||
wireguard_peers, [{"public_key", "ips"}], replaces the user's peers
|
||||
PUT /users/<name>/password set the password: {"password": ...}
|
||||
POST /users/<name>/disable lock the user out of OpenVPN
|
||||
POST /users/<name>/enable let the user in again
|
||||
POST /users/<name>/unlock end a lockout after failed logins
|
||||
DELETE /users/<name> remove the user and revoke the certificate
|
||||
GET /users/<name>/profile the OpenVPN client profile
|
||||
POST /users/<name>/apple-profile
|
||||
the Apple configuration profile of IKEv2 access: {"password"}, which
|
||||
encrypts the user's key in it; {"name", "profile"}
|
||||
POST /users/<name>/otp a new TOTP secret and its otpauth URI; nothing is stored
|
||||
PUT /users/<name>/otp store a TOTP secret: {"secret", "code"}, code a code of the secret
|
||||
DELETE /users/<name>/otp remove the TOTP token
|
||||
DELETE /users/<name>/passkeys remove all the user's passkeys
|
||||
PUT /users/<name>/console switch console login on or off: {"enabled", "account"?}, account a local account,
|
||||
without which the user's own posixAccount; only a token of vpn-router-token
|
||||
|
||||
Service accounts are served by /service-accounts: they are not listed, and a request naming one is 404.
|
||||
"""
|
||||
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
body = body or {}
|
||||
parts = path.strip('/').split('/')
|
||||
route = (method, len(parts), parts[2] if len(parts) == 3 else None)
|
||||
name = parts[1] if len(parts) > 1 else None
|
||||
actions = {
|
||||
('GET', 1, None): lambda connection: {
|
||||
'users': [user for user in vpnrouter_users.list_users(connection)['users'] if not user['service']],
|
||||
'wireguard': {option: vpnrouter.load_config()['wireguard'][option] for option in ('enabled', 'address')}},
|
||||
('POST', 1, None): lambda connection: vpnrouter_users.add(connection, body.get('name'), body.get('password'), body),
|
||||
('PUT', 2, None): lambda connection: vpnrouter_users.update(connection, name, body),
|
||||
('PUT', 3, 'password'): lambda connection: vpnrouter_users.passwd(connection, name, body.get('password')),
|
||||
('POST', 3, 'disable'): lambda connection: vpnrouter_users.disable(connection, name),
|
||||
('POST', 3, 'enable'): lambda connection: vpnrouter_users.enable(connection, name),
|
||||
('POST', 3, 'unlock'): lambda connection: vpnrouter_users.unlock(connection, name),
|
||||
('DELETE', 2, None): lambda connection: vpnrouter_users.remove(connection, name),
|
||||
('GET', 3, 'profile'): lambda connection: vpnrouter_users.profile(connection, name),
|
||||
('POST', 3, 'apple-profile'): lambda connection: vpnrouter_users.apple_profile(connection, name,
|
||||
body.get('password')),
|
||||
('POST', 3, 'otp'): lambda connection: vpnrouter_users.otp_new(connection, name),
|
||||
('PUT', 3, 'otp'): lambda connection: vpnrouter_users.otp_enrol(connection, name, body.get('secret'), body.get('code')),
|
||||
('DELETE', 3, 'otp'): lambda connection: vpnrouter_users.otp_remove(connection, name),
|
||||
('DELETE', 3, 'passkeys'): lambda connection: vpnrouter_passkeys.remove(connection, name),
|
||||
('PUT', 3, 'console'): lambda connection: vpnrouter_users.console(connection, name, body.get('enabled'), body.get('account')),
|
||||
}
|
||||
# The vpnrouter_users operation of each route that may revoke refresh tokens.
|
||||
operations = {('PUT', 3, 'password'): 'passwd', ('POST', 3, 'disable'): 'disable', ('POST', 3, 'unlock'): 'unlock',
|
||||
('DELETE', 2, None): 'remove'}
|
||||
if route not in actions:
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
try:
|
||||
with vpnrouter_users.session() as connection:
|
||||
if name is not None and vpnrouter_users.service(vpnrouter_users.entry(connection, name)):
|
||||
raise vpnrouter_users.Failure(f'{name} is a service account; see /service-accounts', 3)
|
||||
document = actions[route](connection)
|
||||
if operations.get(route) in vpnrouter_auth.REVOKING:
|
||||
vpnrouter_auth.revoke(vpnrouter.load_config(), name)
|
||||
except vpnrouter_users.Failure as failure:
|
||||
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
return 500, {'error': f'the user directory failed: {exc}'}
|
||||
except vpnrouter_auth.DatabaseError as exc:
|
||||
return 500, {'error': str(exc)}
|
||||
except SystemExit as exc:
|
||||
# vpnrouter ends with SystemExit, carrying the reason, on a configuration it cannot read.
|
||||
return 500, {'error': str(exc.code)}
|
||||
return (201 if route == ('POST', 1, None) else 200), document
|
||||
|
||||
|
||||
def oidc_settings():
|
||||
"""{"enabled", "issuer"} of the OpenID Provider, issuer null while it is disabled."""
|
||||
cfg = vpnrouter.load_config()
|
||||
enabled = cfg['oidc']['enabled']
|
||||
return {'enabled': enabled, 'issuer': vpnrouter.oidc_issuer(cfg) if enabled else None}
|
||||
|
||||
|
||||
def service_accounts(method, path, body):
|
||||
"""Answer a /service-accounts request with vpnrouter_users: the HTTP status and the JSON document.
|
||||
|
||||
GET /service-accounts the service accounts, each with its roles: {"service_accounts": [...],
|
||||
"oidc": {"enabled", "issuer"}}, issuer that of the OpenID Provider, null
|
||||
while it is disabled
|
||||
POST /service-accounts add one: {"name", "password", "display_name"?, "description"?, "email"?,
|
||||
"redirect_uris"?, "post_logout_redirect_uris"?, "allowed_groups"?,
|
||||
"grants"?, "public_client"?, "client_scopes"?, "resource_scopes"?,
|
||||
"roles"?}
|
||||
PUT /service-accounts/<name> change any of {"display_name", "description", "email", "redirect_uris",
|
||||
"post_logout_redirect_uris", "allowed_groups", "grants",
|
||||
"public_client", "client_scopes", "resource_scopes", "roles"}; ""
|
||||
removes one of the first three, [] the values of the lists
|
||||
PUT /service-accounts/<name>/password set the password: {"password": ...}
|
||||
POST /service-accounts/<name>/disable lock it out
|
||||
POST /service-accounts/<name>/enable let it in again
|
||||
POST /service-accounts/<name>/unlock end a lockout after failed logins
|
||||
DELETE /service-accounts/<name> remove it
|
||||
|
||||
A service account with redirect URIs or grants is a client of the OpenID Provider, and every service account a
|
||||
resource (vpnrouter_users.client_settings); roles, [{"name", "description", "groups", "accounts"}], replaces its
|
||||
roles (vpnrouter_users.set_roles). A name that is not a service account is 404. The internal service accounts
|
||||
(vpnrouter_users.internal_accounts), which the router manages, are only listed: every other request on one, and a
|
||||
role assigned to one, is refused with 403. A password change, disabling, unlocking and removing revoke the tokens
|
||||
the account was issued.
|
||||
"""
|
||||
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
body = body or {}
|
||||
parts = path.strip('/').split('/')
|
||||
route = (method, len(parts), parts[2] if len(parts) == 3 else None)
|
||||
name = parts[1] if len(parts) > 1 else None
|
||||
settings = {key: body.get(key) for key in ('email', 'display_name', 'description', *vpnrouter_users.CLIENT_KEYS)}
|
||||
actions = {
|
||||
('GET', 1, None): lambda connection: {
|
||||
'service_accounts': [vpnrouter_users.with_roles(connection, user)
|
||||
for user in vpnrouter_users.list_users(connection)['users'] if user['service']],
|
||||
'oidc': oidc_settings()},
|
||||
('POST', 1, None): lambda connection: vpnrouter_users.add(connection, body.get('name'), body.get('password'), settings,
|
||||
service=True),
|
||||
('PUT', 2, None): lambda connection: vpnrouter_users.update_service(connection, name, settings),
|
||||
('PUT', 3, 'password'): lambda connection: vpnrouter_users.passwd(connection, name, body.get('password')),
|
||||
('POST', 3, 'disable'): lambda connection: vpnrouter_users.disable(connection, name),
|
||||
('POST', 3, 'enable'): lambda connection: vpnrouter_users.enable(connection, name),
|
||||
('POST', 3, 'unlock'): lambda connection: vpnrouter_users.unlock(connection, name),
|
||||
('DELETE', 2, None): lambda connection: vpnrouter_users.remove(connection, name),
|
||||
}
|
||||
# The vpnrouter_users operation of each route that may revoke tokens.
|
||||
operations = {('PUT', 3, 'password'): 'passwd', ('POST', 3, 'disable'): 'disable', ('POST', 3, 'unlock'): 'unlock',
|
||||
('DELETE', 2, None): 'remove'}
|
||||
if route not in actions:
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
internal = vpnrouter_users.internal_accounts()
|
||||
if name is not None and name in internal:
|
||||
return 403, {'error': f'{name} is an internal service account, which the router manages'}
|
||||
roles = settings['roles'] if isinstance(settings['roles'], list) else []
|
||||
assigned = sorted(set(internal).intersection(account for role in roles if isinstance(role, dict)
|
||||
for account in (role.get('accounts') or [])
|
||||
if isinstance(account, str)))
|
||||
if assigned:
|
||||
return 403, {'error': f'{", ".join(assigned)}: internal service accounts, which the router manages, are not '
|
||||
'assigned roles'}
|
||||
try:
|
||||
with vpnrouter_users.session() as connection:
|
||||
if name is not None and not vpnrouter_users.service(vpnrouter_users.entry(connection, name)):
|
||||
raise vpnrouter_users.Failure(f'{name} is not a service account', 3)
|
||||
document = actions[route](connection)
|
||||
if operations.get(route) in vpnrouter_auth.REVOKING:
|
||||
vpnrouter_auth.revoke(vpnrouter.load_config(), name)
|
||||
except vpnrouter_users.Failure as failure:
|
||||
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
return 500, {'error': f'the user directory failed: {exc}'}
|
||||
except vpnrouter_auth.DatabaseError as exc:
|
||||
return 500, {'error': str(exc)}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
return (201 if route == ('POST', 1, None) else 200), document
|
||||
|
||||
|
||||
def groups(state, method, path, body):
|
||||
"""Answer a /groups request with vpnrouter_users: the HTTP status and the JSON document.
|
||||
|
||||
GET /groups the groups with their members
|
||||
POST /groups add a group: {"name", "members": [...], "description"?}
|
||||
PUT /groups/<name> rename it or change the description: {"name"?, "description"?}
|
||||
DELETE /groups/<name> remove the group; its members are kept
|
||||
POST /groups/<name>/members add a member: {"user": ...}
|
||||
DELETE /groups/<name>/members/<user> take a member out; the last member cannot leave
|
||||
|
||||
An internal service account (vpnrouter_users.internal_accounts) is not put in or taken out of a group: such a
|
||||
request is refused with 403.
|
||||
"""
|
||||
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
body = body or {}
|
||||
parts = path.strip('/').split('/')
|
||||
name = parts[1] if len(parts) > 1 else None
|
||||
route = (method, len(parts), parts[2] if len(parts) > 2 else None)
|
||||
members = body.get('members') if route == ('POST', 1, None) else [body.get('user')] if route == ('POST', 3, 'members') \
|
||||
else [parts[3]] if route == ('DELETE', 4, 'members') else []
|
||||
internal = sorted(set(vpnrouter_users.internal_accounts()).intersection(
|
||||
member for member in (members if isinstance(members, list) else []) if isinstance(member, str)))
|
||||
if internal:
|
||||
return 403, {'error': f'{", ".join(internal)}: internal service accounts, which the router manages, are not group members'}
|
||||
actions = {
|
||||
('GET', 1, None): lambda connection: vpnrouter_users.list_groups(connection),
|
||||
('POST', 1, None): lambda connection: vpnrouter_users.group_add(
|
||||
connection, body.get('name'), body.get('description'), body.get('members')),
|
||||
('PUT', 2, None): lambda connection: vpnrouter_users.group_update(
|
||||
connection, name, body.get('name'), body.get('description')),
|
||||
('DELETE', 2, None): lambda connection: vpnrouter_users.group_remove(connection, name),
|
||||
('POST', 3, 'members'): lambda connection: vpnrouter_users.group_member_add(connection, name, body.get('user')),
|
||||
('DELETE', 4, 'members'): lambda connection: vpnrouter_users.group_member_remove(connection, name, parts[3]),
|
||||
}
|
||||
if route not in actions:
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
try:
|
||||
with vpnrouter_users.session() as connection:
|
||||
document = actions[route](connection)
|
||||
except vpnrouter_users.Failure as failure:
|
||||
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
return 500, {'error': f'the user directory failed: {exc}'}
|
||||
return (201 if route == ('POST', 1, None) else 200), document
|
||||
|
||||
|
||||
def account(name, method, path, body):
|
||||
"""Answer an /account request of the user named by the token with vpnrouter_users: the HTTP status and the JSON document.
|
||||
|
||||
GET /account the user, with admin, whether the user is a member of ADMIN_GROUP
|
||||
PUT /account/password set the password: {"current_password", "current_code"?, "password"}
|
||||
POST /account/otp a new TOTP secret and its otpauth URI; nothing is stored
|
||||
PUT /account/otp store a TOTP secret: {"current_password", "current_code"?, "secret", "code"},
|
||||
code a code of the secret
|
||||
POST /account/passkeys begin the registration of a passkey: {"current_password", "current_code"?};
|
||||
{"id", "options"} for navigator.credentials.create
|
||||
PUT /account/passkeys store the passkey: {"id", "credential", "label"?}, credential the browser's answer
|
||||
DELETE /account/passkeys/<id>
|
||||
remove a passkey
|
||||
POST /account/profile the user's OpenVPN client profile: {"current_password", "current_code"?}; {"name",
|
||||
"profile"}
|
||||
POST /account/apple-profile
|
||||
the user's Apple configuration profile of IKEv2 access:
|
||||
{"current_password", "current_code"?, "password"}, password encrypting the user's key in
|
||||
it; {"name", "profile"}
|
||||
|
||||
A change but the removal of a passkey, and the profiles, need the current password and, for an enrolled user, a
|
||||
current code; a wrong
|
||||
one is 403 and counts as a failed login. A password change revokes the user's refresh tokens.
|
||||
"""
|
||||
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
body = body or {}
|
||||
route = (method, path.rstrip('/'))
|
||||
removing = method == 'DELETE' and path.startswith('/account/passkeys/') and len(path.strip('/').split('/')) == 3
|
||||
if not removing and route not in (('GET', '/account'), ('PUT', '/account/password'), ('POST', '/account/otp'),
|
||||
('PUT', '/account/otp'), ('POST', '/account/passkeys'), ('PUT', '/account/passkeys'),
|
||||
('POST', '/account/profile'), ('POST', '/account/apple-profile')):
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
try:
|
||||
if route == ('PUT', '/account/password'):
|
||||
vpnrouter_users.check_password(body.get('password'))
|
||||
if route in (('PUT', '/account/password'), ('PUT', '/account/otp'), ('POST', '/account/passkeys'),
|
||||
('POST', '/account/profile'), ('POST', '/account/apple-profile')):
|
||||
vpnrouter_users.login(name, body.get('current_password'), body.get('current_code'))
|
||||
with vpnrouter_users.session() as connection:
|
||||
if route == ('GET', '/account'):
|
||||
document = {**vpnrouter_users.summary(vpnrouter_users.entry(connection, name)),
|
||||
'admin': vpnrouter_users.is_admin(connection, name)}
|
||||
elif route == ('PUT', '/account/password'):
|
||||
document = vpnrouter_users.passwd(connection, name, body['password'])
|
||||
elif route == ('POST', '/account/otp'):
|
||||
document = vpnrouter_users.otp_new(connection, name)
|
||||
elif route == ('POST', '/account/profile'):
|
||||
document = vpnrouter_users.profile(connection, name)
|
||||
elif route == ('POST', '/account/apple-profile'):
|
||||
document = vpnrouter_users.apple_profile(connection, name, body.get('password'))
|
||||
elif route == ('POST', '/account/passkeys'):
|
||||
document = vpnrouter_passkeys.register_begin(connection, name)
|
||||
elif route == ('PUT', '/account/passkeys'):
|
||||
document = vpnrouter_passkeys.register_complete(connection, name, body.get('id'), body.get('credential'),
|
||||
body.get('label'))
|
||||
elif removing:
|
||||
document = vpnrouter_passkeys.remove(connection, name, urllib.parse.unquote(path.strip('/').split('/')[2]))
|
||||
else:
|
||||
document = vpnrouter_users.otp_enrol(connection, name, body.get('secret'), body.get('code'))
|
||||
if route == ('PUT', '/account/password'):
|
||||
vpnrouter_auth.revoke(vpnrouter.load_config(), name)
|
||||
except vpnrouter_users.Failure as failure:
|
||||
if failure.status == 5:
|
||||
return 403, {'error': 'the current password or code is wrong'}
|
||||
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
return 500, {'error': f'the user directory failed: {exc}'}
|
||||
except vpnrouter_auth.DatabaseError as exc:
|
||||
return 500, {'error': str(exc)}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
return 200, document
|
||||
@@ -0,0 +1,75 @@
|
||||
"""/logs: the last lines of the journal of the router's units and of the files web.log_files lists."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import vpnrouter
|
||||
|
||||
#: The units whose journal /logs/journal shows, besides the whole journal: the package's own and the services it runs.
|
||||
JOURNAL_UNITS = ('vpn-router-setup', 'vpn-router-web', 'vpn-router-metrics', 'vpn-router-pki', 'vpn-router-backup',
|
||||
'vpn-router-caddy', 'vpn-router-manager', 'caddy', 'slapd', 'nslcd', 'postgresql', 'strongswan',
|
||||
'wg-quick@wg0', 'openvpn-server@vpn-router', 'ssh', 'vpn-router-resolver', 'vpn-router-webdav', 'pdns')
|
||||
|
||||
#: The most lines /logs answers, the number it answers without lines, and the most bytes it reads from the end of a file.
|
||||
LOG_LINES_MAX = 1000
|
||||
|
||||
LOG_LINES_DEFAULT = 200
|
||||
|
||||
LOG_BYTES_MAX = 1024 * 1024
|
||||
|
||||
|
||||
def tail(path, count):
|
||||
"""The last count lines of a file, read from at most LOG_BYTES_MAX bytes at its end, invalid UTF-8 replaced."""
|
||||
with open(path, 'rb') as handle:
|
||||
size = handle.seek(0, os.SEEK_END)
|
||||
start = max(0, size - LOG_BYTES_MAX)
|
||||
handle.seek(start)
|
||||
lines = handle.read().decode('utf-8', 'replace').splitlines()
|
||||
# A read that starts inside the file starts inside a line, which is left out.
|
||||
return (lines[1:] if start and lines else lines)[-count:]
|
||||
|
||||
|
||||
def logs(method, path, query):
|
||||
"""Answer a /logs request: the HTTP status and the JSON document.
|
||||
|
||||
GET /logs {"units": JOURNAL_UNITS, "files": web.log_files, "max_lines": LOG_LINES_MAX}
|
||||
GET /logs/journal {"lines": [...]}: the last lines of the journal, of the unit unit= names, one of
|
||||
JOURNAL_UNITS, or of the whole journal without it
|
||||
GET /logs/file {"lines": [...]}: the last lines of the file path= names, one of web.log_files
|
||||
|
||||
lines= is the number of lines, 1 to LOG_LINES_MAX, LOG_LINES_DEFAULT without it.
|
||||
"""
|
||||
if method not in ('GET', 'HEAD'):
|
||||
return 405, {'error': f'{method} is not allowed on /logs'}
|
||||
try:
|
||||
files = [item.strip() for item in vpnrouter.load_config()['web']['log_files'] if item.strip()]
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
if path == '/logs':
|
||||
return 200, {'units': list(JOURNAL_UNITS), 'files': files, 'max_lines': LOG_LINES_MAX}
|
||||
text = query.get('lines', [str(LOG_LINES_DEFAULT)])[0]
|
||||
if not text.isdigit() or not 1 <= int(text) <= LOG_LINES_MAX:
|
||||
return 400, {'error': f'lines must be a whole number from 1 to {LOG_LINES_MAX}'}
|
||||
count = int(text)
|
||||
if path == '/logs/journal':
|
||||
unit = query.get('unit', [''])[0]
|
||||
if unit and unit not in JOURNAL_UNITS:
|
||||
return 404, {'error': f'{unit} is not one of the units whose journal /logs shows'}
|
||||
try:
|
||||
result = subprocess.run(['journalctl', '--no-pager', '--quiet', '--output=short-iso', f'--lines={count}',
|
||||
*([f'--unit={unit}.service'] if unit else [])], capture_output=True, text=True,
|
||||
errors='replace', timeout=30)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
return 500, {'error': f'journalctl cannot be run: {exc}'}
|
||||
if result.returncode != 0:
|
||||
return 500, {'error': result.stderr.strip() or f'journalctl ended with status {result.returncode}'}
|
||||
return 200, {'lines': result.stdout.splitlines()[-count:]}
|
||||
if path == '/logs/file':
|
||||
name = query.get('path', [''])[0]
|
||||
if name not in files:
|
||||
return 404, {'error': f'{name!r} is not one of web.log_files'}
|
||||
try:
|
||||
return 200, {'lines': tail(name, count)}
|
||||
except OSError as exc:
|
||||
return (404 if isinstance(exc, FileNotFoundError) else 500), {'error': f'{name} cannot be read: {exc.strerror}'}
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
@@ -0,0 +1,55 @@
|
||||
"""/metrics/history and /metrics/settings: the metrics the metrics service keeps, and its settings."""
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_metrics
|
||||
|
||||
from .common import section_change
|
||||
|
||||
|
||||
def metrics_history(view):
|
||||
"""Answer GET /metrics/history?range=<view> with vpnrouter_metrics.history: the HTTP status and the JSON document.
|
||||
|
||||
view is hour, the default, day, month or year (vpnrouter_metrics.RANGES).
|
||||
"""
|
||||
if view not in vpnrouter_metrics.RANGES:
|
||||
return 400, {'error': f'range must be one of {", ".join(vpnrouter_metrics.RANGES)}'}
|
||||
try:
|
||||
with vpnrouter_metrics.database(vpnrouter.load_config()) as connection:
|
||||
return 200, vpnrouter_metrics.history(connection, view)
|
||||
except vpnrouter_metrics.DatabaseError as exc:
|
||||
return 500, {'error': str(exc)}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
|
||||
|
||||
def metrics_shown(settings):
|
||||
"""The metrics section as /metrics/settings shows it: its settings but otlp_password, with otlp_password_set."""
|
||||
return {**{option: value for option, value in settings.items() if option != 'otlp_password'},
|
||||
'otlp_password_set': bool(settings['otlp_password'])}
|
||||
|
||||
|
||||
def metrics_settings(method, body):
|
||||
"""Answer a /metrics/settings request: the HTTP status and the JSON document.
|
||||
|
||||
GET /metrics/settings {"settings"}: the metrics section without otlp_password, with otlp_password_set
|
||||
PUT /metrics/settings change any of the settings of the metrics section; an empty or absent otlp_password keeps
|
||||
the current secret, and an empty otlp_username removes it
|
||||
|
||||
A change is applied by restarting vpn-router-setup without waiting for it.
|
||||
"""
|
||||
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
if method in ('GET', 'HEAD'):
|
||||
try:
|
||||
return 200, {'settings': metrics_shown(vpnrouter.load_config()['metrics'])}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
if method != 'PUT':
|
||||
return 404, {'error': f'no route {method} /metrics/settings'}
|
||||
body = dict(body or {})
|
||||
if not body.get('otlp_password'):
|
||||
body.pop('otlp_password', None)
|
||||
if body.get('otlp_username') == '':
|
||||
body['otlp_password'] = ''
|
||||
changed = section_change('metrics', body)
|
||||
return changed if isinstance(changed, tuple) else (200, {'settings': metrics_shown(changed)})
|
||||
@@ -0,0 +1,90 @@
|
||||
"""The PKI: /ca, and the certificates and CRLs /pki/ serves without authentication."""
|
||||
|
||||
import ldap
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_pki
|
||||
from vpnrouter_status import run
|
||||
|
||||
from .common import PKI_STATUS
|
||||
|
||||
#: A file /pki/ serves: the certificate or the CRL of a CA, and its content type.
|
||||
PUBLISHED = {'.crt': 'application/pkix-cert', '.crl': 'application/pkix-crl'}
|
||||
|
||||
|
||||
def ca(method, path, body):
|
||||
"""Answer a /ca request with vpnrouter_pki: the HTTP status and the JSON document.
|
||||
|
||||
GET /ca {"settings", "templates", "extended_key_usages", "status",
|
||||
"certificates"}: the pki section, the templates with the built-in
|
||||
ones, the extended key usages, each {oid, builtin}, the root, the
|
||||
issuing CAs, the retired CAs and the warnings (vpnrouter_pki.status),
|
||||
and the certificates issued, newest first
|
||||
PUT /ca change any of the settings of the pki section
|
||||
POST /ca/certificates issue a certificate: {"ca", "template", "csr"} answers {"serial",
|
||||
"certificate", "chain"}; {"ca", "template", "subject", "sans",
|
||||
"password"} answers {"serial", "pfx"}, a PKCS#12 file in base64
|
||||
POST /ca/certificates/<serial>/revoke revoke a certificate: {"reason"?}
|
||||
GET /ca/bundle/<name> the PEM certificates a client trusts the CA with: {"name", "pem"}; for
|
||||
root the root alone, for an issuing CA the issuing CA and the root
|
||||
GET /ca/issuing/<name>/csr the CSR of an issuing CA that waits for the offline root, with the
|
||||
openssl extensions to sign it with: {"name", "csr", "extensions"}
|
||||
PUT /ca/issuing/<name>/certificate take its certificate signed by the root: {"certificate"}
|
||||
PUT /ca/root/crl take a root CRL signed offline: {"crl"}
|
||||
|
||||
A change of the settings is made as /site-to-site makes one; it, a revoked router certificate and an imported
|
||||
issuing CA certificate are applied by restarting vpn-router-setup without waiting for it.
|
||||
"""
|
||||
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
body = body or {}
|
||||
parts = path.strip('/').split('/')
|
||||
route = (method, *parts[1:])
|
||||
try:
|
||||
cfg = vpnrouter.load_config()
|
||||
if len(route) == 3 and route[:2] == ('GET', 'bundle'):
|
||||
return 200, vpnrouter_pki.bundle(route[2])
|
||||
if route == ('GET',):
|
||||
return 200, {'settings': cfg['pki'], 'templates': vpnrouter.pki_templates(cfg),
|
||||
'extended_key_usages': {name: {'oid': oid, 'builtin': name in vpnrouter.EXTENDED_KEY_USAGES}
|
||||
for name, oid in vpnrouter.extended_key_usages(cfg).items()},
|
||||
'status': vpnrouter_pki.status(cfg), 'certificates': vpnrouter_pki.certificates()}
|
||||
if route == ('PUT',):
|
||||
settings = {option: body[option] for option in vpnrouter.SCHEMA['pki'] if option in body}
|
||||
with vpnrouter.config_lock():
|
||||
data = vpnrouter.read_settings()
|
||||
current = data.get('pki')
|
||||
data['pki'] = {**(current if isinstance(current, dict) else {}), **settings}
|
||||
cfg = vpnrouter.with_defaults(data)
|
||||
vpnrouter.validate(cfg)
|
||||
vpnrouter.save_config(data)
|
||||
run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
|
||||
return 200, {'settings': cfg['pki'], 'templates': vpnrouter.pki_templates(cfg)}
|
||||
with vpnrouter_pki.locked():
|
||||
if route == ('POST', 'certificates'):
|
||||
return 201, vpnrouter_pki.issue(cfg, body.get('ca'), body.get('template'), csr=body.get('csr'),
|
||||
subject=body.get('subject'), sans=body.get('sans'),
|
||||
password=body.get('password'))
|
||||
if len(route) == 4 and route[:2] == ('POST', 'certificates') and route[3] == 'revoke':
|
||||
document = vpnrouter_pki.revoke(cfg, route[2], body.get('reason'))
|
||||
elif len(route) == 4 and route[:2] == ('GET', 'issuing') and route[3] == 'csr':
|
||||
return 200, vpnrouter_pki.csr(cfg, route[2])
|
||||
elif len(route) == 4 and route[:2] == ('PUT', 'issuing') and route[3] == 'certificate':
|
||||
document = vpnrouter_pki.import_ca(cfg, route[2], body.get('certificate'))
|
||||
elif route == ('PUT', 'root', 'crl'):
|
||||
return 200, vpnrouter_pki.import_root_crl(body.get('crl'))
|
||||
else:
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
except vpnrouter.ConfigError as exc:
|
||||
return 400, {'error': str(exc)}
|
||||
except vpnrouter_pki.Failure as failure:
|
||||
return PKI_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||
except ldap.LDAPError as exc:
|
||||
return 500, {'error': f'the directory, which holds the PKI, failed: {exc}'}
|
||||
except OSError as exc:
|
||||
return 500, {'error': f'the PKI cannot be changed: {exc}'}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
if route[1] == 'issuing' or document.get('reissue'):
|
||||
run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
|
||||
return 200, document
|
||||
@@ -0,0 +1,512 @@
|
||||
"""/shell: the ticket (shell_ticket), with the passkey proof it may take (shell_passkey), the WebSocket of the terminal
|
||||
(shell), and the editor of the shell: the socket vpn-router-editor names files on (editor_listen) and /shell/editor/...
|
||||
(shell_editor)."""
|
||||
|
||||
import base64
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import pwd
|
||||
import secrets
|
||||
import select
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import termios
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
import ldap
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_passkeys
|
||||
import vpnrouter_users
|
||||
from vpnrouter_status import run
|
||||
|
||||
from .common import BODY_LIMIT, MIB, USER_STATUS, log
|
||||
|
||||
#: Seconds a ticket of POST /shell may open a shell.
|
||||
SHELL_TICKET_SECONDS = 30
|
||||
|
||||
#: Seconds between the checks of an open shell's user, which end the shell once its console is refused.
|
||||
SHELL_CHECK_SECONDS = 60
|
||||
|
||||
#: The largest WebSocket message accepted from a client, in bytes.
|
||||
MESSAGE_LIMIT = 65536
|
||||
|
||||
#: The socket vpn-router-editor (vre) names files on to open them in the editor of a shell (see editor_listen()).
|
||||
EDITOR_SOCKET = '/run/vpn-router/editor.sock'
|
||||
|
||||
#: The helper that reads and writes a file of the editor as the shell's user.
|
||||
EDITOR_FILE = '/usr/lib/vpn-router/editor-file'
|
||||
|
||||
#: The largest file the editor opens, in bytes, as editor-file allows; a body of PUT /shell/editor/... may hold its
|
||||
#: content escaped in JSON.
|
||||
EDITOR_MAX_BYTES = MIB
|
||||
|
||||
EDITOR_BODY_LIMIT = 2 * EDITOR_MAX_BYTES + BODY_LIMIT
|
||||
|
||||
#: The most files a shell has open in its editor at once.
|
||||
EDITOR_FILES_MAX = 20
|
||||
|
||||
#: Seconds a reader of the editor socket and a run of editor-file may take.
|
||||
EDITOR_TIMEOUT = 30
|
||||
|
||||
#: The GUID of RFC 6455 that the Sec-WebSocket-Accept of a handshake is made with.
|
||||
WEBSOCKET_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
||||
|
||||
#: WebSocket opcodes.
|
||||
TEXT, BINARY, CLOSE, PING, PONG = 1, 2, 8, 9, 10
|
||||
|
||||
#: The unused tickets of POST /shell: ticket -> (user, monotonic time of expiry).
|
||||
_shell_tickets = {}
|
||||
|
||||
_shell_lock = threading.Lock()
|
||||
|
||||
#: The last use of a shell of each user, a sign-in of POST /shell or a keystroke: user -> monotonic time.
|
||||
_shell_used = {}
|
||||
|
||||
#: The open shells by their session, which each shell has in VPN_ROUTER_SHELL: session -> {"user", "account", "uid",
|
||||
#: "send", the function that sends a WebSocket frame to its client, "files": {file id: path}}.
|
||||
_shell_sessions = {}
|
||||
|
||||
|
||||
def shell_ticket(name, body):
|
||||
"""Answer POST /shell: the HTTP status and the JSON document.
|
||||
|
||||
The user named by the token signs in again with {"password", "code"}, its password and TOTP code, or with
|
||||
{"passkey": {"id", "credential"}}, the answer of the browser to a proof begun by POST /shell/passkey (see
|
||||
shell_passkey) with one of its own passkeys and the user verified. A wrong password or code counts as a failed
|
||||
login; it and a passkey that is not accepted are 403. Without either, the user is not asked to sign in while its
|
||||
last use of a shell (see _shell_used) is less than web.shell_auth_timeout minutes ago. The answer is {"ticket",
|
||||
"expires_in"}: a ticket that opens one shell (GET /shell) for the same user within SHELL_TICKET_SECONDS, or a null
|
||||
ticket when the user must sign in. 403 while web.shell is false, the user's console login is not on or the user has
|
||||
no TOTP token.
|
||||
"""
|
||||
if not isinstance(name, str):
|
||||
return 403, {'error': 'the shell needs a token of a signed-in user'}
|
||||
if not isinstance(body, dict):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
try:
|
||||
options = vpnrouter.load_config()['web']
|
||||
if not options['shell']:
|
||||
return 403, {'error': 'the shell is disabled; vpn-config-edit set web.shell true enables it'}
|
||||
refusal = console_account(name)[1]
|
||||
if refusal:
|
||||
return 403, {'error': refusal}
|
||||
if 'passkey' in body:
|
||||
passkey = body['passkey'] if isinstance(body['passkey'], dict) else {}
|
||||
with vpnrouter_users.session() as connection:
|
||||
vpnrouter_passkeys.shell_complete(connection, name, passkey.get('id'), passkey.get('credential'))
|
||||
elif 'password' in body:
|
||||
vpnrouter_users.login(name, body.get('password'), body.get('code'))
|
||||
else:
|
||||
with _shell_lock:
|
||||
used = _shell_used.get(name)
|
||||
if used is None or time.monotonic() - used >= options['shell_auth_timeout'] * 60:
|
||||
return 200, {'ticket': None, 'expires_in': 0}
|
||||
except vpnrouter_users.Failure as failure:
|
||||
if failure.status == 5:
|
||||
return 403, {'error': 'the passkey is not accepted' if 'passkey' in body else 'the password or the code is wrong'}
|
||||
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
return 500, {'error': f'the user directory failed: {exc}'}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
ticket = secrets.token_urlsafe(32)
|
||||
now = time.monotonic()
|
||||
with _shell_lock:
|
||||
for unused in [key for key, (_, expires) in _shell_tickets.items() if expires <= now]:
|
||||
del _shell_tickets[unused]
|
||||
_shell_tickets[ticket] = (name, now + SHELL_TICKET_SECONDS)
|
||||
_shell_used[name] = now
|
||||
return 200, {'ticket': ticket, 'expires_in': SHELL_TICKET_SECONDS}
|
||||
|
||||
|
||||
def shell_passkey(name):
|
||||
"""Answer POST /shell/passkey: begin the proof of the signed-in user with one of its own passkeys for POST /shell,
|
||||
{"id", "options"}, options those of navigator.credentials.get; the HTTP status and the JSON document. 403 while
|
||||
the user may not use a shell (console_account), 400 for a user without a passkey."""
|
||||
if not isinstance(name, str):
|
||||
return 403, {'error': 'the shell needs a token of a signed-in user'}
|
||||
try:
|
||||
if not vpnrouter.load_config()['web']['shell']:
|
||||
return 403, {'error': 'the shell is disabled; vpn-config-edit set web.shell true enables it'}
|
||||
refusal = console_account(name)[1]
|
||||
if refusal:
|
||||
return 403, {'error': refusal}
|
||||
with vpnrouter_users.session() as connection:
|
||||
return 200, vpnrouter_passkeys.shell_begin(connection, name)
|
||||
except vpnrouter_users.Failure as failure:
|
||||
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
return 500, {'error': f'the user directory failed: {exc}'}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
|
||||
|
||||
def console_account(name):
|
||||
"""The system account the console of the user runs as and None, or None and the reason the user may not use a
|
||||
console: its console login is off, or it has no TOTP token, which the PAM login session of the shell and sudo need
|
||||
also when a passkey opens the shell."""
|
||||
with vpnrouter_users.session() as connection:
|
||||
user = vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
|
||||
if not user['console'] or not user['console']['enabled']:
|
||||
return None, f'console login is not enabled for {name}; vpn-router-user console sets it'
|
||||
if not user['otp']:
|
||||
return None, f'{name} has no TOTP token; the shell needs one, enrolled on the Account page'
|
||||
return user['console']['account'], None
|
||||
|
||||
|
||||
def take_ticket(name, ticket):
|
||||
"""Whether the ticket of POST /shell is unexpired and was given to the user; it is used up either way."""
|
||||
with _shell_lock:
|
||||
holder, expires = _shell_tickets.pop(ticket, (None, 0.0))
|
||||
return holder == name and expires > time.monotonic()
|
||||
|
||||
|
||||
def shell_command(account, unit, session):
|
||||
"""The command that runs the login shell of the system user account in the transient unit, on its terminal, with
|
||||
the shell's session in VPN_ROUTER_SHELL for vpn-router-editor.
|
||||
|
||||
setsid makes the terminal the controlling one of systemd-run, which passes the window size on; systemd-run starts
|
||||
the shell outside the web service's sandbox, in a PAM login session of the user.
|
||||
"""
|
||||
return ['setsid', '--ctty', 'systemd-run', '--quiet', '--collect', '--pty', '--send-sighup', f'--unit={unit}',
|
||||
f'--uid={account.pw_name}', '--property=PAMName=login', f'--working-directory={account.pw_dir}',
|
||||
'--setenv=TERM=xterm-256color', f'--setenv=VPN_ROUTER_SHELL={session}', account.pw_shell or '/bin/sh', '-l']
|
||||
|
||||
|
||||
def editor_file(account, action, path, content=b'', version=''):
|
||||
"""Run editor-file as the system user account outside the sandbox: read the file, or write content to it when it
|
||||
still has version. The exit status of editor-file and its output, or its message on failure."""
|
||||
command = ['systemd-run', '--quiet', '--collect', '--pipe', f'--uid={account}', EDITOR_FILE, action, path]
|
||||
try:
|
||||
result = subprocess.run(command + ([version] if action == 'write' else []), input=content, capture_output=True,
|
||||
timeout=EDITOR_TIMEOUT)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
return 1, f'editor-file cannot be run: {exc}'.encode()
|
||||
return result.returncode, result.stdout if result.returncode == 0 else result.stderr.strip() or \
|
||||
f'editor-file ended with status {result.returncode}'.encode()
|
||||
|
||||
|
||||
def shell_editor(name, method, parts, body):
|
||||
"""Answer a request of a file open in the editor of one of the user's shells: the HTTP status and the JSON document.
|
||||
|
||||
GET /shell/editor/<session>/<id> {"path", "content", "version"}, version the SHA-256 of the content
|
||||
PUT /shell/editor/<session>/<id> {"content", "version"} writes the content when the file still has version,
|
||||
else 409; answers {"path", "version"}
|
||||
DELETE /shell/editor/<session>/<id> closes the file
|
||||
|
||||
The file is read and written as the shell's console account (editor_file). 404 for a session that is not a shell of
|
||||
the user or a file it does not have open.
|
||||
"""
|
||||
if len(parts) != 4:
|
||||
return 404, {'error': f'no route {method} /{"/".join(parts)}'}
|
||||
with _shell_lock:
|
||||
session = _shell_sessions.get(parts[2])
|
||||
path = session['files'].get(parts[3]) if session and session['user'] == name else None
|
||||
account = session['account'] if path else None
|
||||
if path is None:
|
||||
return 404, {'error': 'no such file is open in a shell of yours'}
|
||||
if method == 'DELETE':
|
||||
with _shell_lock:
|
||||
session['files'].pop(parts[3], None)
|
||||
return 200, {'path': path, 'closed': True}
|
||||
if method == 'GET':
|
||||
status, output = editor_file(account, 'read', path)
|
||||
if status != 0:
|
||||
return (400 if status == 2 else 500), {'error': output.decode(errors='replace')}
|
||||
return 200, {'path': path, 'content': output.decode(), 'version': hashlib.sha256(output).hexdigest()}
|
||||
if method != 'PUT':
|
||||
return 405, {'error': f'{method} is not allowed on a file of the editor'}
|
||||
if not isinstance(body, dict) or not isinstance(body.get('content'), str) or not isinstance(body.get('version'), str):
|
||||
return 400, {'error': 'the body must be {"content", "version"}'}
|
||||
content = body['content'].encode()
|
||||
if len(content) > EDITOR_MAX_BYTES:
|
||||
return 413, {'error': f'the content is larger than {EDITOR_MAX_BYTES // 1024} KiB'}
|
||||
status, output = editor_file(account, 'write', path, content, body['version'])
|
||||
if status != 0:
|
||||
return {2: 400, 3: 409}.get(status, 500), {'error': output.decode(errors='replace')}
|
||||
log(f'editor of {name} saved {path} as {account}')
|
||||
return 200, {'path': path, 'version': hashlib.sha256(content).hexdigest()}
|
||||
|
||||
|
||||
def editor_request(connection):
|
||||
"""Answer one request of vpn-router-editor on the editor socket: {"session", "path"} on a line opens the file in the
|
||||
editor of the shell of the session, whose client is sent {"editor": "open", "session", "id", "path"} as a text
|
||||
message.
|
||||
|
||||
The peer must run as the shell's console account and the path must be absolute. The answer is {"ok": true} or
|
||||
{"error"} on a line.
|
||||
"""
|
||||
try:
|
||||
connection.settimeout(EDITOR_TIMEOUT)
|
||||
_, uid, _ = struct.unpack('iII', connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED,
|
||||
struct.calcsize('iII')))
|
||||
request = json.loads(connection.makefile('rb').readline(4096) or b'null')
|
||||
if not isinstance(request, dict) or not isinstance(request.get('session'), str) or \
|
||||
not isinstance(request.get('path'), str):
|
||||
answer = {'error': 'the request must be {"session", "path"}'}
|
||||
elif not os.path.isabs(request['path']):
|
||||
answer = {'error': 'the path must be absolute'}
|
||||
else:
|
||||
with _shell_lock:
|
||||
session = _shell_sessions.get(request['session'])
|
||||
if session is None or session['uid'] != uid:
|
||||
answer = {'error': 'no shell of the Router Manager has this session; vre works only in its shell'}
|
||||
elif len(session['files']) >= EDITOR_FILES_MAX:
|
||||
answer = {'error': f'the editor has {EDITOR_FILES_MAX} files open; close one first'}
|
||||
else:
|
||||
file_id = secrets.token_urlsafe(8)
|
||||
session['files'][file_id] = request['path']
|
||||
answer = None
|
||||
if answer is None:
|
||||
session['send'](TEXT, json.dumps({'editor': 'open', 'session': request['session'], 'id': file_id,
|
||||
'path': request['path']}).encode())
|
||||
log(f'editor of {session["user"]} opened {request["path"]}')
|
||||
answer = {'ok': True}
|
||||
connection.sendall(json.dumps(answer).encode() + b'\n')
|
||||
except (OSError, ValueError, struct.error):
|
||||
pass
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def editor_listen():
|
||||
"""Listen on EDITOR_SOCKET, which any local user may connect to, answering each connection in a thread with
|
||||
editor_request; the socket of an earlier run is replaced. Does not return; a failure to listen is logged and the
|
||||
editor stays closed."""
|
||||
try:
|
||||
if os.path.lexists(EDITOR_SOCKET):
|
||||
os.unlink(EDITOR_SOCKET)
|
||||
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
listener.bind(EDITOR_SOCKET)
|
||||
os.chmod(EDITOR_SOCKET, 0o666)
|
||||
listener.listen(16)
|
||||
except OSError as exc:
|
||||
log(f'the editor socket {EDITOR_SOCKET} cannot be opened: {exc}')
|
||||
return
|
||||
while True:
|
||||
try:
|
||||
connection, _ = listener.accept()
|
||||
except OSError:
|
||||
continue
|
||||
threading.Thread(target=editor_request, args=(connection,), name='editor request', daemon=True).start()
|
||||
|
||||
|
||||
def frame(opcode, payload):
|
||||
"""A final, unmasked WebSocket frame of a server."""
|
||||
size = len(payload)
|
||||
if size < 126:
|
||||
length = bytes([size])
|
||||
elif size < 65536:
|
||||
length = bytes([126]) + size.to_bytes(2, 'big')
|
||||
else:
|
||||
length = bytes([127]) + size.to_bytes(8, 'big')
|
||||
return bytes([0x80 | opcode]) + length + payload
|
||||
|
||||
|
||||
def exact(handler, size):
|
||||
"""The next size bytes from the client of the Handler handler; ConnectionError when it closes first."""
|
||||
data = handler.rfile.read(size)
|
||||
if len(data) != size:
|
||||
raise ConnectionError('the client closed the connection')
|
||||
return data
|
||||
|
||||
|
||||
def message(handler, send):
|
||||
"""The opcode and the payload of the next WebSocket data message of the client of the Handler handler, or CLOSE
|
||||
and its payload.
|
||||
|
||||
Fragments are joined and unmasked; a ping is answered with send, a pong ignored. ValueError for an unmasked
|
||||
frame or a message over MESSAGE_LIMIT.
|
||||
"""
|
||||
opcode, payload = None, b''
|
||||
while True:
|
||||
head = exact(handler, 2)
|
||||
code, size = head[0] & 0x0F, head[1] & 0x7F
|
||||
if not head[1] & 0x80:
|
||||
raise ValueError('a frame of the client is not masked')
|
||||
if size == 126:
|
||||
size = int.from_bytes(exact(handler, 2), 'big')
|
||||
elif size == 127:
|
||||
size = int.from_bytes(exact(handler, 8), 'big')
|
||||
if len(payload) + size > MESSAGE_LIMIT:
|
||||
raise ValueError(f'a message is larger than {MESSAGE_LIMIT} bytes')
|
||||
mask = exact(handler, 4) * (size // 4 + 1)
|
||||
data = bytes(byte ^ key for byte, key in zip(exact(handler, size), mask))
|
||||
if code == PING:
|
||||
send(PONG, data)
|
||||
continue
|
||||
if code == PONG:
|
||||
continue
|
||||
if code == CLOSE:
|
||||
return CLOSE, data
|
||||
opcode = code if opcode is None else opcode
|
||||
payload += data
|
||||
if head[0] & 0x80:
|
||||
return opcode, payload
|
||||
|
||||
|
||||
def shell(handler, name):
|
||||
"""Answer GET /shell?ticket=<ticket>, a WebSocket handshake the Handler handler received, with a login shell of the
|
||||
user's console account (vpnrouter_users.console) on a terminal.
|
||||
|
||||
The ticket must come from POST /shell for the user named by the token. Binary messages of the client are its
|
||||
keystrokes, a text message {"cols", "rows"} sets the window size, and binary messages of the server are the
|
||||
terminal's output; a text message of the server {"editor": "open", "session", "id", "path"} opens a file the
|
||||
shell named with vpn-router-editor (editor_request), which the client reads and saves with
|
||||
/shell/editor/<session>/<id> (shell_editor) while the shell lives. The shell ends after web.shell_idle_minutes
|
||||
without a keystroke, when it exits, when the client closes the connection, or when a check of the user (SHELL_CHECK_SECONDS) finds its console login off or
|
||||
its TOTP token gone; the server then closes it with the reason. Each opening, end and refusal is logged
|
||||
with its reason.
|
||||
"""
|
||||
def refuse(code, message):
|
||||
log(f'shell of {name} from {handler.client()} refused: {message}')
|
||||
body = json.dumps({'error': message}).encode() + b'\n'
|
||||
try:
|
||||
handler.send_response(code)
|
||||
handler.send_header('Content-Type', 'application/json')
|
||||
handler.send_header('Content-Length', str(len(body)))
|
||||
handler.end_headers()
|
||||
handler.wfile.write(body)
|
||||
except ConnectionError:
|
||||
handler.close_connection = True
|
||||
|
||||
key = handler.headers.get('Sec-WebSocket-Key', '').strip()
|
||||
if handler.headers.get('Upgrade', '').lower() != 'websocket' or 'upgrade' not in handler.headers.get('Connection', '').lower() \
|
||||
or handler.headers.get('Sec-WebSocket-Version') != '13' or not key:
|
||||
return refuse(400, 'GET /shell must be a WebSocket handshake')
|
||||
query = urllib.parse.parse_qs(urllib.parse.urlsplit(handler.path).query)
|
||||
if not take_ticket(name, query.get('ticket', [''])[0]):
|
||||
return refuse(403, 'the ticket is not valid; POST /shell gives one')
|
||||
try:
|
||||
options = vpnrouter.load_config()['web']
|
||||
except SystemExit as exc:
|
||||
return refuse(500, str(exc.code))
|
||||
if not options['shell']:
|
||||
return refuse(403, 'the shell is disabled; vpn-config-edit set web.shell true enables it')
|
||||
try:
|
||||
login, refusal = console_account(name)
|
||||
except (vpnrouter_users.Failure, ldap.LDAPError, OSError) as exc:
|
||||
return refuse(500, f'the user directory failed: {exc}')
|
||||
if refusal:
|
||||
return refuse(403, refusal)
|
||||
try:
|
||||
account = pwd.getpwnam(login)
|
||||
except KeyError:
|
||||
return refuse(500, f'the system does not know the account {login}; is nslcd running?')
|
||||
unit = f'vpn-router-shell-{secrets.token_hex(4)}'
|
||||
session = secrets.token_urlsafe(24)
|
||||
master, terminal = os.openpty()
|
||||
try:
|
||||
process = subprocess.Popen(shell_command(account, unit, session), stdin=terminal, stdout=terminal, stderr=terminal,
|
||||
env={'PATH': '/usr/sbin:/usr/bin:/sbin:/bin'})
|
||||
except OSError as exc:
|
||||
os.close(master)
|
||||
return refuse(500, f'the shell cannot be started: {exc}')
|
||||
finally:
|
||||
os.close(terminal)
|
||||
accept = base64.b64encode(hashlib.sha1((key + WEBSOCKET_GUID).encode()).digest()).decode()
|
||||
handler.close_connection = True
|
||||
try:
|
||||
handler.send_response(101, 'Switching Protocols')
|
||||
handler.send_header('Upgrade', 'websocket')
|
||||
handler.send_header('Connection', 'Upgrade')
|
||||
handler.send_header('Sec-WebSocket-Accept', accept)
|
||||
handler.end_headers()
|
||||
except ConnectionError:
|
||||
# The client went away before the shell opened; the shell started for it is stopped.
|
||||
run('systemctl', 'stop', unit)
|
||||
os.close(master)
|
||||
process.kill()
|
||||
process.wait()
|
||||
log(f'shell of {name} from {handler.client()} ended before it opened: the client closed the connection')
|
||||
return
|
||||
client, started, idle = handler.client(), time.monotonic(), options['shell_idle_minutes'] * 60
|
||||
log(f'shell of {name} from {client} opened as {account.pw_name} in {unit}')
|
||||
|
||||
lock, ended, typed = threading.Lock(), threading.Event(), [started]
|
||||
|
||||
def send(opcode, payload):
|
||||
with lock:
|
||||
handler.wfile.write(frame(opcode, payload))
|
||||
|
||||
with _shell_lock:
|
||||
_shell_sessions[session] = {'user': name, 'account': account.pw_name, 'uid': account.pw_uid, 'send': send,
|
||||
'files': {}}
|
||||
|
||||
def receive():
|
||||
"""Pass the client's keystrokes and window sizes to the terminal until it closes the connection."""
|
||||
try:
|
||||
while True:
|
||||
opcode, payload = message(handler, send)
|
||||
if opcode == CLOSE:
|
||||
break
|
||||
if opcode == BINARY:
|
||||
os.write(master, payload)
|
||||
typed[0] = time.monotonic()
|
||||
with _shell_lock:
|
||||
_shell_used[name] = typed[0]
|
||||
elif opcode == TEXT:
|
||||
size = json.loads(payload)
|
||||
fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack('HHHH', int(size['rows']), int(size['cols']), 0, 0))
|
||||
except (OSError, ValueError, KeyError, TypeError, struct.error):
|
||||
pass
|
||||
finally:
|
||||
ended.set()
|
||||
|
||||
reader = threading.Thread(target=receive, name=f'{unit} input', daemon=True)
|
||||
reader.start()
|
||||
reason = 'the shell ended'
|
||||
checked = started
|
||||
try:
|
||||
while not ended.is_set():
|
||||
left = typed[0] + idle - time.monotonic()
|
||||
if left <= 0:
|
||||
reason = f'no keystroke for {options["shell_idle_minutes"]} minutes'
|
||||
break
|
||||
# The user's console login and TOTP token are checked again every SHELL_CHECK_SECONDS.
|
||||
if time.monotonic() - checked >= SHELL_CHECK_SECONDS:
|
||||
checked = time.monotonic()
|
||||
try:
|
||||
refusal = console_account(name)[1]
|
||||
except (vpnrouter_users.Failure, ldap.LDAPError, OSError) as exc:
|
||||
refusal = f'the user directory failed: {exc}'
|
||||
if refusal:
|
||||
reason = refusal
|
||||
break
|
||||
if select.select([master], [], [], min(left, 1))[0]:
|
||||
try:
|
||||
output = os.read(master, 65536)
|
||||
except OSError:
|
||||
output = b''
|
||||
if not output:
|
||||
break
|
||||
send(BINARY, output)
|
||||
else:
|
||||
reason = 'the client closed the connection'
|
||||
except OSError:
|
||||
reason = 'the connection failed'
|
||||
with _shell_lock:
|
||||
_shell_sessions.pop(session, None)
|
||||
run('systemctl', 'stop', unit)
|
||||
try:
|
||||
send(CLOSE, (1000).to_bytes(2, 'big') + reason.encode()[:120])
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
handler.connection.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
reader.join()
|
||||
os.close(master)
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
log(f'shell of {name} from {client} in {unit} ended after {int(time.monotonic() - started)} seconds: {reason}')
|
||||
@@ -0,0 +1,153 @@
|
||||
"""/sites: the settings of the web sites Caddy serves, the sites, and the files of each (site_files)."""
|
||||
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import urllib.parse
|
||||
|
||||
import ldap
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_sites
|
||||
from vpnrouter_status import run
|
||||
|
||||
from .common import MIB, SITE_STATUS, section_change
|
||||
|
||||
#: A path of a site's files: the site and the path in its folder.
|
||||
SITE_FILES = re.compile(r'^/sites/([^/]+)/files(?:/(.*))?$')
|
||||
|
||||
|
||||
def sites(method, path, query, body):
|
||||
"""Answer a /sites request but one of a site's files with vpnrouter_sites: the HTTP status and the JSON document.
|
||||
|
||||
GET /sites {"settings", "sites"}: the sites section of the configuration and the sites, each with its
|
||||
settings but private_key, with private_key_set, modified and its folder
|
||||
PUT /sites change any of the settings of the sites section
|
||||
POST /sites add a site: {"name", and any of "enabled", "hosts", "port", "listen", "tls", "ca",
|
||||
"certificate", "private_key", "index_file", "spa", "editors", "dns",
|
||||
"dns_address"}; an existing name is 409
|
||||
GET /sites/<name> the site
|
||||
PUT /sites/<name> change any of the settings of POST but name; an empty or absent private_key keeps the key
|
||||
DELETE /sites/<name> remove the site, with content=true also its folder
|
||||
|
||||
A change is applied by restarting vpn-router-setup without waiting for it.
|
||||
"""
|
||||
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
body = body or {}
|
||||
parts = path.strip('/').split('/')
|
||||
route = (method, len(parts))
|
||||
try:
|
||||
cfg = vpnrouter.load_config()
|
||||
if route == ('GET', 1):
|
||||
return 200, {'settings': cfg['sites'], 'sites': vpnrouter_sites.listing()}
|
||||
if route == ('PUT', 1):
|
||||
changed = section_change('sites', body)
|
||||
return changed if isinstance(changed, tuple) else (200, {'settings': changed})
|
||||
if route == ('POST', 1):
|
||||
name = body.get('name')
|
||||
document = vpnrouter_sites.save(cfg, name, {key: value for key, value in body.items() if key != 'name'},
|
||||
True)
|
||||
code = 201
|
||||
elif route == ('GET', 2):
|
||||
with vpnrouter_sites.database() as connection:
|
||||
return 200, vpnrouter_sites.shown(vpnrouter_sites.get(connection, parts[1]))
|
||||
elif route == ('PUT', 2):
|
||||
document, code = vpnrouter_sites.save(cfg, parts[1], body, False), 200
|
||||
elif route == ('DELETE', 2):
|
||||
document = vpnrouter_sites.remove(parts[1], query.get('content', [''])[0] == 'true')
|
||||
code = 200
|
||||
else:
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
except vpnrouter_sites.Failure as failure:
|
||||
return SITE_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||
except vpnrouter_sites.DatabaseError as exc:
|
||||
return 500, {'error': str(exc)}
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
return 500, {'error': f'the site cannot be changed: {exc}'}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
|
||||
return code, document
|
||||
|
||||
|
||||
def site_file_action(name, target, body):
|
||||
"""Answer a POST to a site's file or folder: {"folder": true} creates the folder, {"rename": "<path>"} moves the file
|
||||
or folder to the path in the site's folder; the HTTP status and the JSON document."""
|
||||
if not isinstance(body, dict):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
try:
|
||||
if body.get('folder') is True:
|
||||
vpnrouter_sites.make_folder(target)
|
||||
return 201, {'path': vpnrouter_sites.relative_of(name, target), 'folder': True}
|
||||
if isinstance(body.get('rename'), str):
|
||||
moved = vpnrouter_sites.rename(name, target, body['rename'])
|
||||
return 200, {'path': vpnrouter_sites.relative_of(name, moved)}
|
||||
except vpnrouter_sites.Failure as failure:
|
||||
return SITE_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||
except OSError as exc:
|
||||
return 500, {'error': f'{exc.filename or target.name}: {exc.strerror}'}
|
||||
return 400, {'error': 'give {"folder": true} or {"rename": "<path>"}'}
|
||||
|
||||
|
||||
def site_files(handler, name, relative, query, payload, send_body):
|
||||
"""Answer a request of a site's files the Handler handler received: the HTTP status and the JSON document, or None
|
||||
once a file was sent.
|
||||
|
||||
GET /sites/<name>/files/<path> a folder: {"path", "entries": [{name, folder, size, modified}]}; a file:
|
||||
the file itself, as an attachment
|
||||
PUT /sites/<name>/files/<path> write the body to the file, its folders created; with unzip=true the body
|
||||
is a zip archive extracted into the folder of the path: {"path", "files"}
|
||||
POST /sites/<name>/files/<path> {"folder": true} creates the folder, {"rename": "<path>"} moves it
|
||||
DELETE /sites/<name>/files/<path> remove the file or folder
|
||||
|
||||
The body of a PUT needs a Content-Length of at most sites.upload_max_mb MiB.
|
||||
"""
|
||||
try:
|
||||
target = vpnrouter_sites.resolve(name, urllib.parse.unquote(relative))
|
||||
if handler.command in ('GET', 'HEAD'):
|
||||
if target.is_dir():
|
||||
return 200, vpnrouter_sites.entries(name, target)
|
||||
if not target.is_file():
|
||||
return 404, {'error': f'{relative} does not exist'}
|
||||
with open(target, 'rb') as handle:
|
||||
size = os.fstat(handle.fileno()).st_size
|
||||
try:
|
||||
handler.send_response(200)
|
||||
handler.send_header('Content-Type', mimetypes.guess_type(target.name)[0] or 'application/octet-stream')
|
||||
handler.send_header('Content-Length', str(size))
|
||||
handler.send_header('Content-Disposition',
|
||||
f"attachment; filename*=UTF-8''{urllib.parse.quote(target.name)}")
|
||||
handler.send_header('Cache-Control', 'no-store')
|
||||
handler.end_headers()
|
||||
if send_body:
|
||||
shutil.copyfileobj(handle, handler.wfile, vpnrouter_sites.CHUNK)
|
||||
except ConnectionError:
|
||||
# The client went away before the file was sent.
|
||||
handler.close_connection = True
|
||||
return None
|
||||
if handler.command == 'PUT':
|
||||
length = handler.headers.get('Content-Length')
|
||||
if not length or not length.isdigit():
|
||||
return 411, {'error': 'the body needs a Content-Length'}
|
||||
limit = vpnrouter.load_config()['sites']['upload_max_mb'] * MIB
|
||||
if query.get('unzip', [''])[0] == 'true':
|
||||
count = vpnrouter_sites.extract(handler.rfile, int(length), target, limit)
|
||||
return 200, {'path': vpnrouter_sites.relative_of(name, target), 'files': count}
|
||||
vpnrouter_sites.receive(handler.rfile, int(length), target, limit)
|
||||
return 200, {'path': vpnrouter_sites.relative_of(name, target), 'files': 1}
|
||||
if handler.command == 'POST':
|
||||
return site_file_action(name, target, payload)
|
||||
if handler.command == 'DELETE':
|
||||
if not target.exists() and not target.is_symlink():
|
||||
return 404, {'error': f'{relative} does not exist'}
|
||||
vpnrouter_sites.delete(name, target)
|
||||
return 200, {'path': vpnrouter_sites.relative_of(name, target), 'removed': True}
|
||||
except vpnrouter_sites.Failure as failure:
|
||||
return SITE_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||
except OSError as exc:
|
||||
return 500, {'error': f'{exc.filename or relative}: {exc.strerror}'}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
return 405, {'error': f'{handler.command} is not allowed on a site\'s files'}
|
||||
@@ -0,0 +1,325 @@
|
||||
"""The status of the router: / for people, /status, /status/full, /config and /postgresql, with the cache of the
|
||||
status that the requests of CACHE_SECONDS share (cached)."""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from html import escape
|
||||
|
||||
import vpnrouter
|
||||
from vpnrouter_status import established, established_each, openvpn_clients, run, swanctl, wireguard_peers
|
||||
|
||||
from .common import current, unit_state
|
||||
|
||||
#: Seconds a collected status is answered again.
|
||||
CACHE_SECONDS = 2
|
||||
|
||||
TAILWIND = 'https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4'
|
||||
|
||||
#: Seconds after which the status page in a browser reloads itself.
|
||||
REFRESH_SECONDS = 60
|
||||
|
||||
#: The PostgreSQL settings /config shows for each cluster.
|
||||
POSTGRESQL_SETTINGS = ('server_version', 'listen_addresses', 'port', 'max_connections', 'shared_buffers',
|
||||
'effective_cache_size', 'work_mem', 'password_encryption', 'ssl', 'timezone')
|
||||
|
||||
_cache = {'at': 0.0, 'data': None}
|
||||
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def package_version(package='vpn-router'):
|
||||
"""The installed version of the package, or None when it is not installed."""
|
||||
result = run('dpkg-query', '-W', '-f=${Version}', package)
|
||||
return (result.stdout.strip() or None) if result and result.returncode == 0 else None
|
||||
|
||||
|
||||
def docker_status():
|
||||
"""Whether Docker is installed, and the state of its service and of the Docker rules when it is."""
|
||||
result = run('systemctl', 'list-unit-files', '--no-legend', 'docker.service')
|
||||
if result is None or result.returncode != 0 or not result.stdout.strip():
|
||||
return {'installed': False}
|
||||
return {
|
||||
'installed': True,
|
||||
'service': unit_state('docker.service'),
|
||||
'rules': unit_state('vpn-router-docker.service'),
|
||||
}
|
||||
|
||||
|
||||
def postgresql_clusters():
|
||||
"""The state of each PostgreSQL cluster unit (postgresql@<version>-<name>), or {'postgresql': 'no cluster'} without one.
|
||||
|
||||
postgresql.service, which only runs /bin/true, is not reported.
|
||||
"""
|
||||
lines = lines_of('systemctl', 'list-units', 'postgresql@*.service', '--all', '--plain', '--no-legend') or []
|
||||
names = [line.split()[0].removesuffix('.service') for line in lines if line.strip()]
|
||||
return {name: unit_state(f'{name}.service') for name in names} or {'postgresql': 'no cluster'}
|
||||
|
||||
|
||||
def collect(state):
|
||||
"""The status document. state says which features the router has."""
|
||||
units = {'vpn-router-setup': unit_state('vpn-router-setup.service'), 'slapd': unit_state('slapd.service'),
|
||||
**postgresql_clusters()}
|
||||
manager_version = package_version('vpn-router-manager')
|
||||
if manager_version:
|
||||
units['vpn-router-manager'] = unit_state('vpn-router-manager.service')
|
||||
if state.get('site_to_site') or state.get('p2s'):
|
||||
units['strongswan'] = unit_state('strongswan.service')
|
||||
if state.get('wireguard'):
|
||||
units['wg-quick@wg0'] = unit_state('wg-quick@wg0.service')
|
||||
if state.get('openvpn'):
|
||||
units['openvpn-server@vpn-router'] = unit_state('openvpn-server@vpn-router.service')
|
||||
|
||||
status = {
|
||||
'status': 'ok' if all(value == 'active' for value in units.values()) else 'degraded',
|
||||
'version': package_version(),
|
||||
'manager_version': manager_version,
|
||||
'time': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
|
||||
'units': units,
|
||||
'docker': docker_status(),
|
||||
}
|
||||
names = state.get('site_to_site')
|
||||
if isinstance(names, list) and names:
|
||||
counts = established_each(names).values()
|
||||
status['site_to_site'] = {'connections': len(names),
|
||||
'established': None if None in counts else sum(1 for count in counts if count)}
|
||||
if state.get('p2s'):
|
||||
status['p2s'] = {'clients': established('road-warrior')}
|
||||
if state.get('wireguard'):
|
||||
status['wireguard'] = {'peers': wireguard_peers()}
|
||||
if state.get('openvpn'):
|
||||
status['openvpn'] = {'clients': openvpn_clients()}
|
||||
return status
|
||||
|
||||
|
||||
def shown(status, content, client):
|
||||
"""The status document as web.status_content shows it to the client at its address.
|
||||
|
||||
Every content has status, time and client_ip, the client's address, and content, which one it is: basic nothing
|
||||
more; standard adds version, manager_version and services, the number of active units and of units in every
|
||||
other state; full adds the whole status document of collect. Content that is not one of
|
||||
vpnrouter.STATUS_CONTENTS, or not set, is basic.
|
||||
"""
|
||||
if content not in vpnrouter.STATUS_CONTENTS:
|
||||
content = 'basic'
|
||||
if content == 'full':
|
||||
return {**status, 'content': content, 'client_ip': client}
|
||||
document = {'status': status['status'], 'content': content, 'time': status['time'], 'client_ip': client}
|
||||
if content == 'standard':
|
||||
active = sum(1 for value in status['units'].values() if value == 'active')
|
||||
document.update(version=status['version'], manager_version=status['manager_version'],
|
||||
services={'active': active, 'failed': len(status['units']) - active})
|
||||
return document
|
||||
|
||||
|
||||
def lines_of(*args):
|
||||
"""The output lines of a command, or None when it fails."""
|
||||
result = run(*args)
|
||||
return result.stdout.splitlines() if result and result.returncode == 0 else None
|
||||
|
||||
|
||||
def routes():
|
||||
"""The IPv4 routes as ip -j prints them, or None when ip fails."""
|
||||
result = run('ip', '-j', '-4', 'route', 'show')
|
||||
if result is None or result.returncode != 0:
|
||||
return None
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def wireguard_details():
|
||||
"""The interface and the peers of wg0, with endpoints and allowed IPs; no private or preshared key."""
|
||||
result = run('wg', 'show', 'wg0', 'dump')
|
||||
if result is None or result.returncode != 0:
|
||||
return None
|
||||
rows = [line.split('\t') for line in result.stdout.splitlines()]
|
||||
now = int(time.time())
|
||||
return {
|
||||
'public_key': rows[0][1] if rows and len(rows[0]) > 2 else None,
|
||||
'listen_port': int(rows[0][2]) if rows and len(rows[0]) > 2 else None,
|
||||
'peers': [{
|
||||
'public_key': row[0],
|
||||
'endpoint': None if row[2] == '(none)' else row[2],
|
||||
'allowed_ips': row[3].split(','),
|
||||
'handshake_seconds_ago': now - int(row[4]) if int(row[4]) else None,
|
||||
'received_bytes': int(row[5]),
|
||||
'sent_bytes': int(row[6]),
|
||||
} for row in rows[1:] if len(row) >= 7],
|
||||
}
|
||||
|
||||
|
||||
def postgresql_query(cluster, sql):
|
||||
"""The JSON value an SQL statement answers on a cluster (version/name), run by psql as postgres, or None when it fails."""
|
||||
result = run('runuser', '-u', 'postgres', '--', 'psql', '--cluster', cluster, '-X', '-q', '-t', '-A',
|
||||
'-v', 'ON_ERROR_STOP=1', '-d', 'postgres', '-c', sql)
|
||||
if result is None or result.returncode != 0:
|
||||
return None
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def postgresql_clusters_of():
|
||||
"""The PostgreSQL clusters of pg_lsclusters, each with version, name, port, status and data_directory, or None when it fails."""
|
||||
lines = lines_of('pg_lsclusters', '--no-header')
|
||||
if lines is None:
|
||||
return None
|
||||
clusters = []
|
||||
for line in lines:
|
||||
fields = line.split()
|
||||
if len(fields) >= 6:
|
||||
clusters.append({'version': fields[0], 'name': fields[1], 'port': int(fields[2]) if fields[2].isdigit() else None,
|
||||
'status': fields[3], 'data_directory': fields[5]})
|
||||
return clusters
|
||||
|
||||
|
||||
def postgresql_configuration():
|
||||
"""The PostgreSQL clusters on the router with the settings of POSTGRESQL_SETTINGS of each one that is online (None
|
||||
for one that is not), or None when pg_lsclusters fails."""
|
||||
clusters = postgresql_clusters_of()
|
||||
if clusters is None:
|
||||
return None
|
||||
names = ', '.join(f"'{name}'" for name in POSTGRESQL_SETTINGS)
|
||||
for cluster in clusters:
|
||||
cluster['settings'] = postgresql_query(
|
||||
f"{cluster['version']}/{cluster['name']}",
|
||||
f'SELECT json_object_agg(name, current_setting(name) ORDER BY name) FROM pg_settings WHERE name IN ({names})',
|
||||
) if cluster['status'].startswith('online') else None
|
||||
return {'clusters': clusters}
|
||||
|
||||
|
||||
def postgresql_databases():
|
||||
"""What /postgresql shows: the PostgreSQL clusters on the router, each with its databases (name, owner, encoding and
|
||||
size in bytes) and their total size, None for a cluster that is not online; clusters is None when pg_lsclusters fails."""
|
||||
clusters = postgresql_clusters_of()
|
||||
if clusters is None:
|
||||
return {'clusters': None}
|
||||
for cluster in clusters:
|
||||
databases = postgresql_query(
|
||||
f"{cluster['version']}/{cluster['name']}",
|
||||
"SELECT coalesce(json_agg(json_build_object('name', datname, 'owner', pg_get_userbyid(datdba), "
|
||||
"'encoding', pg_encoding_to_char(encoding), 'size', pg_database_size(oid)) ORDER BY datname), '[]') "
|
||||
'FROM pg_database',
|
||||
) if cluster['status'].startswith('online') else None
|
||||
cluster['databases'] = databases
|
||||
cluster['size'] = sum(database['size'] for database in databases) if isinstance(databases, list) else None
|
||||
return {'clusters': clusters}
|
||||
|
||||
|
||||
def details(state):
|
||||
"""What /config shows: the configuration without its secrets, and what the router resolved and runs; caddy is the
|
||||
installed Caddy, {version, source, service}, service the state of caddy.service, null when it is not installed; oidc
|
||||
is {enabled, issuer} of the OpenID Provider, issuer null while it is disabled."""
|
||||
configuration = state.get('configuration', {})
|
||||
caddy = package_version('caddy')
|
||||
oidc = configuration.get('oidc', {}).get('enabled') is True
|
||||
return {
|
||||
'configuration': configuration,
|
||||
'network': dict(state.get('network', {}), routes=routes()),
|
||||
'ipsec': {
|
||||
'connections': swanctl('--list-conns'),
|
||||
'sas': swanctl('--list-sas'),
|
||||
},
|
||||
'wireguard': wireguard_details(),
|
||||
'docker': {
|
||||
'exposed_ports': configuration.get('docker', {}).get('exposed_ports', []),
|
||||
'rules': lines_of('iptables', '-S', 'VPN-ROUTER-DOCKER'),
|
||||
},
|
||||
'postgresql': postgresql_configuration(),
|
||||
# Caddy is upstream's once its repository is configured (vpn-router-caddy).
|
||||
'caddy': {'version': caddy, 'source': 'upstream' if vpnrouter.CADDY_APT_SOURCE.is_file() else 'ubuntu',
|
||||
'service': unit_state('caddy.service')} if caddy else None,
|
||||
'oidc': {'enabled': oidc, 'issuer': vpnrouter.oidc_issuer(configuration) if oidc else None},
|
||||
}
|
||||
|
||||
|
||||
def text_of(status):
|
||||
"""The status document of shown for people, as plain text: a header, a blank line, then name: value lines."""
|
||||
lines = ['VPN Router', f'Status: {status["status"]}']
|
||||
if 'version' in status:
|
||||
lines.append(f'Version: {status["version"] or "unknown"}')
|
||||
lines += ['', f'Client IP: {status["client_ip"]}']
|
||||
if 'manager_version' in status:
|
||||
lines.append(f'Router Manager: {status["manager_version"] or "not installed"}')
|
||||
if 'services' in status:
|
||||
lines.append(f'Services: {status["services"]["active"]} active, {status["services"]["failed"]} failed')
|
||||
lines += [f'{name}: {value}' for name, value in status.get('units', {}).items()]
|
||||
if 'docker' in status:
|
||||
docker = status['docker']
|
||||
lines.append('Docker: not installed' if not docker['installed']
|
||||
else f'Docker: installed, service {docker["service"]}, rules {docker["rules"]}')
|
||||
if 'site_to_site' in status:
|
||||
established, connections = status['site_to_site']['established'], status['site_to_site']['connections']
|
||||
lines.append(f'Site to site: {"unknown" if established is None else established} of {connections} established')
|
||||
if 'p2s' in status:
|
||||
clients = status['p2s']['clients']
|
||||
lines.append(f'IKEv2: {"unknown" if clients is None else f"{clients} connected"}')
|
||||
if 'openvpn' in status:
|
||||
clients = status['openvpn']['clients']
|
||||
lines.append(f'OpenVPN: {"unknown" if clients is None else f"{clients} connected"}')
|
||||
if 'wireguard' in status:
|
||||
peers = status['wireguard']['peers']
|
||||
if peers is None:
|
||||
lines.append('WireGuard: down')
|
||||
else:
|
||||
lines.append(f'WireGuard: {len(peers)} peers')
|
||||
for number, peer in enumerate(peers, 1):
|
||||
age = peer['handshake_seconds_ago']
|
||||
lines.append(f' peer {number}: {"no handshake yet" if age is None else f"handshake {age} s ago"}, '
|
||||
f'received {peer["received_bytes"]} B, sent {peer["sent_bytes"]} B')
|
||||
return '\n'.join(lines) + '\n'
|
||||
|
||||
|
||||
def html_of(status):
|
||||
"""The status document of shown for a browser, as a page styled with Tailwind CSS."""
|
||||
colour = 'bg-emerald-600 dark:bg-emerald-700' if status['status'] == 'ok' else 'bg-amber-600 dark:bg-amber-700'
|
||||
version = f', version {status["version"] or "unknown"}' if 'version' in status else ''
|
||||
# Each "name: value" line after the header of the text is a row; indented lines, such as WireGuard peers, are indented here too.
|
||||
rows = []
|
||||
for line in text_of(status).split('\n\n', 1)[1].splitlines():
|
||||
if not line:
|
||||
continue
|
||||
name, _, value = line.partition(': ')
|
||||
indent = ' pl-8' if line.startswith(' ') else ''
|
||||
rows.append(f'<tr class="border-t border-slate-200 first:border-t-0 dark:border-slate-700">'
|
||||
f'<th class="py-2 pr-4{indent} text-left font-medium text-slate-600 dark:text-slate-300">{escape(name.strip())}</th>'
|
||||
f'<td class="py-2">{escape(value)}</td></tr>')
|
||||
body = ''.join(rows)
|
||||
return f"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta http-equiv="refresh" content="{REFRESH_SECONDS}">
|
||||
<title>VPN Router</title>
|
||||
<script src="{TAILWIND}"></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-100 text-slate-900 dark:bg-slate-900 dark:text-slate-100">
|
||||
<div class="mx-auto max-w-2xl px-4 py-10">
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-800">
|
||||
<div class="{colour} px-6 py-4 text-white">
|
||||
<h1 class="text-xl font-semibold">VPN Router</h1>
|
||||
<p class="mt-1 text-sm">Status: {escape(status['status'] + version)}</p>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<table class="w-full text-sm"><tbody>{body}</tbody></table>
|
||||
</div>
|
||||
<p class="px-6 pb-6 text-xs text-slate-500 dark:text-slate-400">{escape(status['time'])} - <a class="underline" href="/status">JSON</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def cached():
|
||||
"""The status of collect, collected again when it is older than CACHE_SECONDS."""
|
||||
with _lock:
|
||||
if _cache['data'] is None or time.monotonic() - _cache['at'] > CACHE_SECONDS:
|
||||
_cache['data'] = collect(current())
|
||||
_cache['at'] = time.monotonic()
|
||||
return _cache['data']
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/bin/sh
|
||||
# Wait until libpq is in the dynamic linker cache, where psycopg looks it up.
|
||||
# ExecStartPre= of vpn-router-web.service; TimeoutStartSec= of the unit bounds the wait.
|
||||
# ExecStartPre= of vpn-router-setup.service, vpn-router-web.service and vpn-router-metrics.service;
|
||||
# TimeoutStartSec= of the unit bounds the wait.
|
||||
|
||||
reported=
|
||||
until ldconfig -p | grep -q '^[[:space:]]*libpq\.so\.5 '; do
|
||||
|
||||
Regular → Executable
+15
-1586
File diff suppressed because it is too large.
Load diff
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/python3
|
||||
"""The auth proxy of rclone's WebDAV server of the web sites' folders (vpn-router-webdav.service).
|
||||
|
||||
rclone runs it for each new user and password with {"user", "pass"} on standard input. The user signs in with the
|
||||
password of the router's directory, followed by the TOTP code for an enrolled user. A member of
|
||||
vpnrouter_users.ADMIN_GROUP sees the folders of all sites, any other user those of the sites whose editors group
|
||||
the user is a member of (vpnrouter_sites.editable), each as a folder named after its site. The answer on standard output
|
||||
is rclone's combine backend of those folders; a failed sign-in, a user with no site or any failure is a message on
|
||||
standard error and the exit status 1, which rclone refuses the user with.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
|
||||
|
||||
import ldap
|
||||
|
||||
import vpnrouter_ldap
|
||||
import vpnrouter_sites
|
||||
import vpnrouter_users
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
request = json.load(sys.stdin)
|
||||
name, password = request['user'], request['pass']
|
||||
vpnrouter_users.login(name, password, None)
|
||||
connection = vpnrouter_ldap.manager()
|
||||
try:
|
||||
groups = vpnrouter_users.groups_of(connection, name)
|
||||
finally:
|
||||
connection.unbind_s()
|
||||
if vpnrouter_users.ADMIN_GROUP in groups:
|
||||
sites = [site['name'] for site in vpnrouter_sites.listing()]
|
||||
else:
|
||||
sites = vpnrouter_sites.editable(groups)
|
||||
except (ValueError, KeyError, TypeError, OSError, ldap.LDAPError, vpnrouter_users.Failure,
|
||||
vpnrouter_sites.DatabaseError) as exc:
|
||||
sys.exit(f'webdav-auth: refused: {exc}')
|
||||
if not sites:
|
||||
sys.exit(f'webdav-auth: refused: {name} may change no site')
|
||||
vpnrouter_sites.ensure_folders(sites)
|
||||
upstreams = ' '.join(f'{site}={vpnrouter_sites.SITES_DIR / site}' for site in sites)
|
||||
json.dump({'type': 'combine', '_root': '', 'upstreams': upstreams}, sys.stdout)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -9,18 +9,21 @@ number, true or false, a list of strings separated by commas (an empty value is
|
||||
or a string. remove takes site_to_site.NAME and removes that connection. The configuration with
|
||||
the change is checked as vpn-router-setup checks it, and only then written with
|
||||
vpnrouter.save_config, holding vpnrouter.config_lock; vpn-router-setup applies it at its next
|
||||
run, which set and remove remind of. A failure is a message on standard error; the exit status
|
||||
is 2 for an invalid request and 1 for any other failure. -h or --help shows the options.
|
||||
run, which set and remove remind of. With --restart they restart vpn-router-setup and wait until
|
||||
it has applied the change. A failure is a message on standard error; the exit status is 2 for an
|
||||
invalid request and 1 for any other failure, a failed run of vpn-router-setup included. -h or
|
||||
--help shows the options.
|
||||
|
||||
vpn-config-edit show
|
||||
vpn-config-edit set SECTION.OPTION VALUE
|
||||
vpn-config-edit set site_to_site.NAME.OPTION VALUE
|
||||
vpn-config-edit remove site_to_site.NAME
|
||||
vpn-config-edit set [--restart] SECTION.OPTION VALUE
|
||||
vpn-config-edit set [--restart] site_to_site.NAME.OPTION VALUE
|
||||
vpn-config-edit remove [--restart] site_to_site.NAME
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '/usr/lib/vpn-router')
|
||||
@@ -86,6 +89,9 @@ def main(argv):
|
||||
setter.add_argument('value', metavar='VALUE', help='its value; a list separated by commas')
|
||||
remover = commands.add_parser('remove', help='remove a site-to-site connection and write the configuration')
|
||||
remover.add_argument('key', metavar='site_to_site.NAME', help='the connection')
|
||||
for changer in (setter, remover):
|
||||
changer.add_argument('--restart', action='store_true',
|
||||
help='restart vpn-router-setup and wait until it has applied the change')
|
||||
options = parser.parse_args(argv)
|
||||
|
||||
if options.command == 'show':
|
||||
@@ -124,7 +130,14 @@ def main(argv):
|
||||
print(f'{options.key} set to {json.dumps(value)} in {vpnrouter.CONFIG_FILE}.')
|
||||
else:
|
||||
print(f'{options.key} removed from {vpnrouter.CONFIG_FILE}.')
|
||||
print('Apply it with: systemctl restart vpn-router-setup')
|
||||
if not options.restart:
|
||||
print('Apply it with: systemctl restart vpn-router-setup, or --restart')
|
||||
return 0
|
||||
print('Restarting vpn-router-setup...', flush=True)
|
||||
if subprocess.run(['systemctl', 'restart', 'vpn-router-setup.service']).returncode != 0:
|
||||
print('vpn-config-edit: vpn-router-setup failed; see journalctl -u vpn-router-setup', file=sys.stderr)
|
||||
return 1
|
||||
print('vpn-router-setup applied it.')
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Executable
+121
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/python3
|
||||
"""Manage the roles of the resources of the router's OpenID Provider from the command line, with vpnrouter_users.
|
||||
|
||||
A resource is a service account; a role of it has a name, an optional description, and the groups and accounts it is
|
||||
assigned to, whose tokens for the resource carry it in their roles claim. The answer is text for people on standard
|
||||
output, and a failure is a message on standard error. With --json the answer is JSON on standard output, and a failure
|
||||
is {"error": ...} there too. The exit status says what failed: 2 the request is invalid, 3 the resource, the role, the
|
||||
group or the account does not exist, 4 the role exists, 1 anything else. -h or --help shows the commands and their
|
||||
options.
|
||||
|
||||
vpn-router-role [--json] list [<resource>]
|
||||
vpn-router-role [--json] add <resource> <role> [--description D]
|
||||
vpn-router-role [--json] remove <resource> <role>
|
||||
vpn-router-role [--json] member-add <resource> <role> (--group G | --account A)
|
||||
vpn-router-role [--json] member-remove <resource> <role> (--group G | --account A)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '/usr/lib/vpn-router')
|
||||
|
||||
import ldap
|
||||
|
||||
import vpnrouter_users
|
||||
from vpnrouter_users import Failure
|
||||
|
||||
#: The help line of each command.
|
||||
HELP = {
|
||||
'list': 'list the roles of every resource, or of one, with the groups and accounts they are assigned to',
|
||||
'add': 'add a role to a resource',
|
||||
'remove': 'remove a role of a resource',
|
||||
'member-add': 'assign the role to a group or an account',
|
||||
'member-remove': 'take the role from a group or an account',
|
||||
}
|
||||
|
||||
|
||||
class Parser(argparse.ArgumentParser):
|
||||
"""An argument parser whose errors are Failures with status 2, so that --json answers them as JSON."""
|
||||
|
||||
def error(self, message):
|
||||
raise Failure(f'{message}; see {self.prog} --help', 2)
|
||||
|
||||
|
||||
def parse(argv):
|
||||
parser = Parser(prog='vpn-router-role', description='Manage the roles of the resources of the OpenID Provider.')
|
||||
parser.add_argument('--json', action='store_true', help='answer in JSON')
|
||||
commands = parser.add_subparsers(dest='command', required=True, metavar='command', parser_class=Parser)
|
||||
for command, text in HELP.items():
|
||||
sub = commands.add_parser(command, help=text, description=text[0].upper() + text[1:] + '.')
|
||||
if command == 'list':
|
||||
sub.add_argument('resource', nargs='?', help='the service account whose roles are listed')
|
||||
continue
|
||||
sub.add_argument('resource', help='the service account the role belongs to')
|
||||
sub.add_argument('role', help='the role name')
|
||||
if command == 'add':
|
||||
sub.add_argument('--description', metavar='TEXT', help='the description')
|
||||
if command in ('member-add', 'member-remove'):
|
||||
member = sub.add_mutually_exclusive_group(required=True)
|
||||
member.add_argument('--group', metavar='GROUP', help='the group')
|
||||
member.add_argument('--account', metavar='USER', help='the user or service account')
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def run(connection, options):
|
||||
"""The answer of the command the options name."""
|
||||
command = options.command
|
||||
if command == 'list':
|
||||
if options.resource is not None:
|
||||
vpnrouter_users.check_resource(connection, options.resource)
|
||||
return vpnrouter_users.list_roles(connection, options.resource)
|
||||
if command == 'add':
|
||||
role = vpnrouter_users.role_add(connection, options.resource, options.role, options.description)
|
||||
elif command == 'remove':
|
||||
return vpnrouter_users.role_remove(connection, options.resource, options.role)
|
||||
elif command == 'member-add':
|
||||
role = vpnrouter_users.role_member_add(connection, options.resource, options.role, options.group, options.account)
|
||||
else:
|
||||
role = vpnrouter_users.role_member_remove(connection, options.resource, options.role, options.group, options.account)
|
||||
return {'resource': options.resource, **role}
|
||||
|
||||
|
||||
def text_of(answer):
|
||||
"""The answer as text for people."""
|
||||
if answer.get('removed'):
|
||||
return f'role {answer["name"]} of {answer["resource"]} removed'
|
||||
roles = answer['roles'] if 'roles' in answer else [answer]
|
||||
if not roles:
|
||||
return 'no roles'
|
||||
head = ('RESOURCE', 'ROLE', 'GROUPS', 'ACCOUNTS', 'DESCRIPTION')
|
||||
rows = [(role['resource'], role['name'], ', '.join(role['groups']) or '-', ', '.join(role['accounts']) or '-',
|
||||
role['description'] or '-') for role in roles]
|
||||
widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))]
|
||||
return '\n'.join(' '.join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip() for row in (head, *rows))
|
||||
|
||||
|
||||
def main(argv):
|
||||
# A request that cannot be parsed is answered in JSON when --json is among the arguments.
|
||||
as_json = '--json' in argv
|
||||
try:
|
||||
options = parse(argv)
|
||||
as_json = options.json
|
||||
with vpnrouter_users.session() as connection:
|
||||
answer = run(connection, options)
|
||||
except Failure as failure:
|
||||
message, status = str(failure), failure.status
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
message, status = f'the user directory failed: {exc}', 1
|
||||
else:
|
||||
print(json.dumps(answer) if as_json else text_of(answer))
|
||||
return 0
|
||||
if as_json:
|
||||
print(json.dumps({'error': message}))
|
||||
else:
|
||||
print(f'vpn-router-role: {message}', file=sys.stderr)
|
||||
return status
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
Executable
+165
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/python3
|
||||
"""Manage the web sites Caddy serves from the command line, with vpnrouter_sites.
|
||||
|
||||
A site has a name, which also names its folder in /var/lib/vpn-router/sites, host names, a port, the interfaces whose
|
||||
firewall opens it, TLS (none, acme, ca with --ca, or file with --certificate and --key), the file served for a folder,
|
||||
whether any path without a file is served that file of the site (--spa), the group whose members change its files, and
|
||||
who serves the records of its host names (--dns user, elsewhere, or router, the router's name server, with the router's
|
||||
address of --dns-address).
|
||||
A change is applied by restarting vpn-router-setup without waiting for it. The answer is text for people on standard
|
||||
output, and a failure is a message on standard error. With --json the answer is JSON on standard output, and a failure
|
||||
is {"error": ...} there too. The exit status says what failed: 2 the request is invalid, 3 the site does not exist, 4
|
||||
the site exists, 1 anything else. -h or --help shows the commands and their options.
|
||||
|
||||
vpn-router-site [--json] list
|
||||
vpn-router-site [--json] show <name>
|
||||
vpn-router-site [--json] add <name> [options]
|
||||
vpn-router-site [--json] update <name> [options]
|
||||
vpn-router-site [--json] remove <name> [--content]
|
||||
|
||||
options: [--host H ...] [--no-hosts] [--port P] [--listen internal|external|both] [--tls none|acme|ca|file]
|
||||
[--ca NAME] [--certificate FILE] [--key FILE] [--index FILE] [--spa | --no-spa]
|
||||
[--enabled | --no-enabled] [--editors GROUP] [--dns user|router]
|
||||
[--dns-address public|internal|external]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '/usr/lib/vpn-router')
|
||||
|
||||
import ldap
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_sites
|
||||
from vpnrouter_sites import Failure
|
||||
|
||||
#: The help line of each command.
|
||||
HELP = {
|
||||
'list': 'list the sites',
|
||||
'show': 'show a site',
|
||||
'add': 'add a site',
|
||||
'update': 'change the settings of a site',
|
||||
'remove': 'remove a site; its folder is kept without --content',
|
||||
}
|
||||
|
||||
|
||||
class Parser(argparse.ArgumentParser):
|
||||
"""An argument parser whose errors are Failures with status 2, so that --json answers them as JSON."""
|
||||
|
||||
def error(self, message):
|
||||
raise Failure(f'{message}; see {self.prog} --help', 2)
|
||||
|
||||
|
||||
def parse(argv):
|
||||
parser = Parser(prog='vpn-router-site', description='Manage the web sites of the router.')
|
||||
parser.add_argument('--json', action='store_true', help='answer in JSON')
|
||||
commands = parser.add_subparsers(dest='command', required=True, metavar='command', parser_class=Parser)
|
||||
for command, text in HELP.items():
|
||||
sub = commands.add_parser(command, help=text, description=text[0].upper() + text[1:] + '.')
|
||||
if command != 'list':
|
||||
sub.add_argument('name', help='the site name')
|
||||
if command in ('add', 'update'):
|
||||
sub.add_argument('--host', dest='hosts', action='append', metavar='HOST',
|
||||
help='a host name; give it once for each name, replacing the current ones')
|
||||
sub.add_argument('--no-hosts', action='store_true', help='answer any host name')
|
||||
sub.add_argument('--port', type=int, metavar='PORT', help='the TCP port')
|
||||
sub.add_argument('--listen', choices=vpnrouter_sites.LISTEN, help='the interfaces whose firewall opens the port')
|
||||
sub.add_argument('--tls', choices=vpnrouter_sites.TLS, help='where the certificate comes from')
|
||||
sub.add_argument('--ca', metavar='NAME', help='the issuing CA of tls ca')
|
||||
sub.add_argument('--certificate', metavar='FILE', help='the PEM certificate chain of tls file')
|
||||
sub.add_argument('--key', metavar='FILE', help='the PEM private key of tls file')
|
||||
sub.add_argument('--index', metavar='FILE', help='the file served for a folder')
|
||||
sub.add_argument('--spa', action=argparse.BooleanOptionalAction, help='serve the index of the site for any '
|
||||
'path without a file')
|
||||
sub.add_argument('--enabled', action=argparse.BooleanOptionalAction, help='serve the site')
|
||||
sub.add_argument('--editors', metavar='GROUP', help="the group whose members change the files; '' for none")
|
||||
sub.add_argument('--dns', choices=vpnrouter_sites.DNS, help='who serves the records of the host names')
|
||||
sub.add_argument('--dns-address', choices=vpnrouter_sites.DNS_ADDRESSES,
|
||||
help="the router's address in the records of dns router")
|
||||
if command == 'remove':
|
||||
sub.add_argument('--content', action='store_true', help='remove the site\'s folder too')
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def values_of(options):
|
||||
"""The settings the options give."""
|
||||
values = {key: getattr(options, option) for key, option in (('port', 'port'), ('listen', 'listen'), ('tls', 'tls'),
|
||||
('ca', 'ca'), ('index_file', 'index'), ('spa', 'spa'),
|
||||
('enabled', 'enabled'), ('editors', 'editors'),
|
||||
('dns', 'dns'), ('dns_address', 'dns_address'))
|
||||
if getattr(options, option) is not None}
|
||||
if options.no_hosts:
|
||||
values['hosts'] = []
|
||||
elif options.hosts:
|
||||
values['hosts'] = options.hosts
|
||||
for key, option in (('certificate', 'certificate'), ('private_key', 'key')):
|
||||
path = getattr(options, option)
|
||||
if path is not None:
|
||||
try:
|
||||
with open(path, encoding='utf-8') as handle:
|
||||
values[key] = handle.read()
|
||||
except OSError as exc:
|
||||
raise Failure(f'{path} cannot be read: {exc.strerror}', 2)
|
||||
return values
|
||||
|
||||
|
||||
def run(options):
|
||||
"""The answer of the command the options name, and whether it changed a site."""
|
||||
command = options.command
|
||||
if command == 'list':
|
||||
return {'sites': vpnrouter_sites.listing()}, False
|
||||
if command == 'show':
|
||||
with vpnrouter_sites.database() as connection:
|
||||
return vpnrouter_sites.shown(vpnrouter_sites.get(connection, options.name)), False
|
||||
if command == 'remove':
|
||||
return vpnrouter_sites.remove(options.name, options.content), True
|
||||
cfg = vpnrouter.load_config()
|
||||
return vpnrouter_sites.save(cfg, options.name, values_of(options), command == 'add'), True
|
||||
|
||||
|
||||
def text_of(answer):
|
||||
"""The answer as text for people."""
|
||||
if answer.get('removed'):
|
||||
return f'site {answer["name"]} removed'
|
||||
sites = answer['sites'] if 'sites' in answer else [answer]
|
||||
if not sites:
|
||||
return 'no sites'
|
||||
head = ('SITE', 'ENABLED', 'HOSTS', 'PORT', 'LISTEN', 'TLS', 'EDITORS', 'DNS')
|
||||
rows = [(site['name'], 'yes' if site['enabled'] else 'no', ', '.join(site['hosts']) or '*', str(site['port']),
|
||||
site['listen'], site['tls'] + (f' {site["ca"]}' if site['tls'] == 'ca' else ''), site['editors'] or '-',
|
||||
site['dns'] + (f' {site["dns_address"]}' if site['dns'] == 'router' else ''))
|
||||
for site in sites]
|
||||
widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))]
|
||||
return '\n'.join(' '.join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip() for row in (head, *rows))
|
||||
|
||||
|
||||
def main(argv):
|
||||
# A request that cannot be parsed is answered in JSON when --json is among the arguments.
|
||||
as_json = '--json' in argv
|
||||
try:
|
||||
options = parse(argv)
|
||||
as_json = options.json
|
||||
answer, changed = run(options)
|
||||
except Failure as failure:
|
||||
message, status = str(failure), failure.status
|
||||
except vpnrouter_sites.DatabaseError as exc:
|
||||
message, status = str(exc), 1
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
message, status = f'the user directory failed: {exc}', 1
|
||||
else:
|
||||
if changed:
|
||||
subprocess.run(['systemctl', 'restart', '--no-block', 'vpn-router-setup.service'])
|
||||
print(json.dumps(answer) if as_json else text_of(answer))
|
||||
return 0
|
||||
if as_json:
|
||||
print(json.dumps({'error': message}))
|
||||
else:
|
||||
print(f'vpn-router-site: {message}', file=sys.stderr)
|
||||
return status
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -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> --service [--display-name N] [--description D] [--email E]
|
||||
[--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...]
|
||||
[--grant G ...] [--public-client yes|no] [--client-scope R:S ...] [--resource-scope S ...]
|
||||
(a service account; password on stdin)
|
||||
vpn-router-user [--json] update <name> [--first-name F] [--last-name L] [--email E]
|
||||
vpn-router-user [--json] update <name> [--display-name N] [--description D] [--email E]
|
||||
[--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...]
|
||||
[--grant G ...] [--public-client yes|no] [--client-scope R:S ...] [--resource-scope S ...]
|
||||
(a service account)
|
||||
|
||||
A service account with a redirect URI is a client of the OpenID Provider; each of the three options may be repeated,
|
||||
and replaces all values of its kind; one '' removes them.
|
||||
A service account with a redirect URI or a grant (client_credentials, device_code) is a client of the OpenID Provider,
|
||||
and one with a resource scope a resource; each of the repeatable options replaces all values of its kind, and one ''
|
||||
removes them. vpn-router-role manages the roles of a resource.
|
||||
vpn-router-user [--json] passwd <name> (password on stdin)
|
||||
vpn-router-user [--json] disable <name>
|
||||
vpn-router-user [--json] enable <name>
|
||||
@@ -80,6 +83,9 @@ CLIENT_OPTIONS = (
|
||||
('redirect_uris', 'redirect-uri', 'a redirect URI of the OpenID Provider client'),
|
||||
('post_logout_redirect_uris', 'post-logout-redirect-uri', 'a post-logout redirect URI of the OpenID Provider client'),
|
||||
('allowed_groups', 'allowed-group', 'a group whose members may sign in to the OpenID Provider client'),
|
||||
('grants', 'grant', 'a grant the client may use besides the authorization code: client_credentials or device_code'),
|
||||
('client_scopes', 'client-scope', 'a scope of a resource the client may ask for, <resource>:<scope>'),
|
||||
('resource_scopes', 'resource-scope', 'a scope the service account defines as a resource'),
|
||||
)
|
||||
|
||||
|
||||
@@ -104,6 +110,8 @@ def parse(argv):
|
||||
for key, option, text in CLIENT_OPTIONS:
|
||||
sub.add_argument(f'--{option}', dest=key, action='append', metavar='VALUE',
|
||||
help=f"{text}, of a service account; repeat it for more, '' for none")
|
||||
sub.add_argument('--public-client', choices=('yes', 'no'),
|
||||
help='whether the client of a service account is public, without a secret')
|
||||
if command == 'add':
|
||||
sub.add_argument('--service', action='store_true', help='add a service account, which cannot sign in to the web service')
|
||||
if command == 'otp-enrol':
|
||||
@@ -131,6 +139,8 @@ def run(connection, options, secret):
|
||||
given = getattr(options, key, None)
|
||||
if given is not None:
|
||||
values[key] = [value for value in given if value]
|
||||
if getattr(options, 'public_client', None) is not None:
|
||||
values['public_client'] = options.public_client == 'yes'
|
||||
if command == 'add':
|
||||
return vpnrouter_users.add(connection, options.name, secret, values, service=options.service)
|
||||
if command == 'update':
|
||||
@@ -157,12 +167,20 @@ def console_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
|
||||
groups."""
|
||||
if not user.get('redirect_uris'):
|
||||
return '-'
|
||||
groups = ','.join(user['allowed_groups']) or 'all users'
|
||||
return f'{len(user["redirect_uris"])} redirect, {len(user["post_logout_redirect_uris"])} logout, {groups}'
|
||||
"""The OpenID Provider settings of a service account as a cell: -, or the numbers of its URIs, its allowed groups,
|
||||
grants, public, its client scopes and its resource scopes."""
|
||||
parts = []
|
||||
if user.get('redirect_uris'):
|
||||
groups = ','.join(user['allowed_groups']) or 'all users'
|
||||
parts.append(f'{len(user["redirect_uris"])} redirect, {len(user["post_logout_redirect_uris"])} logout, {groups}')
|
||||
parts += user.get('grants') or []
|
||||
if user.get('public_client'):
|
||||
parts.append('public')
|
||||
if user.get('client_scopes'):
|
||||
parts.append('asks ' + ','.join(user['client_scopes']))
|
||||
if user.get('resource_scopes'):
|
||||
parts.append('defines ' + ','.join(user['resource_scopes']))
|
||||
return '; '.join(parts) or '-'
|
||||
|
||||
|
||||
def text_of(answer):
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# bash completion for vpn-config-edit -*- shell-script -*-
|
||||
|
||||
# Python of the package that answers from its schema (vpnrouter.SCHEMA, vpnrouter.CONNECTION) and, read as root, the
|
||||
# site-to-site connections of the configuration: settings prints SECTION.OPTION of every setting and
|
||||
# site_to_site.NAME.OPTION of every connection, connections site_to_site.NAME, and values the values of a boolean
|
||||
# setting, $1.
|
||||
_vpn_config_edit_python()
|
||||
{
|
||||
python3 -c '
|
||||
import sys
|
||||
sys.path.insert(0, "/usr/lib/vpn-router")
|
||||
import vpnrouter
|
||||
try:
|
||||
connections = vpnrouter.read_settings().get("site_to_site", {})
|
||||
except (OSError, vpnrouter.ConfigError):
|
||||
connections = {}
|
||||
if sys.argv[1] == "settings":
|
||||
print("\n".join(f"{section}.{option}" for section, options in vpnrouter.SCHEMA.items() for option in options))
|
||||
print("\n".join(f"site_to_site.{name}.{option}" for name in connections for option in vpnrouter.CONNECTION))
|
||||
elif sys.argv[1] == "connections":
|
||||
print("\n".join(f"site_to_site.{name}" for name in connections))
|
||||
else:
|
||||
section, _, option = sys.argv[2].rpartition(".")
|
||||
defaults = vpnrouter.CONNECTION if section.startswith("site_to_site.") else vpnrouter.SCHEMA.get(section, {})
|
||||
if isinstance(defaults.get(option), bool):
|
||||
print("true\nfalse")
|
||||
' "$@" 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_config_edit()
|
||||
{
|
||||
local cur prev words cword split
|
||||
_init_completion -s || return
|
||||
|
||||
# The command is the first word that is not an option; the arguments are the words after it.
|
||||
local i command='' args=()
|
||||
for ((i = 1; i < cword; i++)); do
|
||||
if [[ -z $command ]]; then
|
||||
[[ ${words[i]} != -* ]] && command=${words[i]}
|
||||
elif [[ ${words[i]} != -* ]]; then
|
||||
args+=("${words[i]}")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $cur == -* ]]; then
|
||||
COMPREPLY=($(compgen -W "--help$([[ $command == @(set|remove) ]] && echo ' --restart')" -- "$cur"))
|
||||
return
|
||||
fi
|
||||
|
||||
case $command,${#args[@]} in
|
||||
,*) COMPREPLY=($(compgen -W 'show set remove' -- "$cur")) ;;
|
||||
set,0) COMPREPLY=($(compgen -W "$(_vpn_config_edit_python settings)" -- "$cur")) ;;
|
||||
set,1) COMPREPLY=($(compgen -W "$(_vpn_config_edit_python values "${args[0]}")" -- "$cur")) ;;
|
||||
remove,0) COMPREPLY=($(compgen -W "$(_vpn_config_edit_python connections)" -- "$cur")) ;;
|
||||
esac
|
||||
} &&
|
||||
complete -F _vpn_config_edit vpn-config-edit
|
||||
|
||||
# ex: filetype=sh
|
||||
@@ -0,0 +1,41 @@
|
||||
# bash completion for vpn-router-backup -*- shell-script -*-
|
||||
|
||||
_vpn_router_backup()
|
||||
{
|
||||
local cur prev words cword split
|
||||
_init_completion -s || return
|
||||
|
||||
# The command is the first word that is not an option; the arguments are the words after it.
|
||||
local i command='' args=0 pbs=false
|
||||
for ((i = 1; i < cword; i++)); do
|
||||
if [[ -z $command ]]; then
|
||||
[[ ${words[i]} != -* ]] && command=${words[i]}
|
||||
elif [[ ${words[i]} == --pbs ]]; then
|
||||
pbs=true
|
||||
elif [[ ${words[i]} != -* ]]; then
|
||||
((args++))
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z $command ]]; then
|
||||
if [[ $cur == -* ]]; then
|
||||
COMPREPLY=($(compgen -W '--help' -- "$cur"))
|
||||
else
|
||||
COMPREPLY=($(compgen -W 'run list restore' -- "$cur"))
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ $cur == -* ]]; then
|
||||
COMPREPLY=($(compgen -W "--help$([[ $command == restore ]] && echo ' --pbs --yes')" -- "$cur"))
|
||||
return
|
||||
fi
|
||||
|
||||
# restore takes a local archive, or with --pbs a snapshot name, which vpn-router-backup list shows.
|
||||
if [[ $command == restore ]] && ((args == 0)) && ! $pbs; then
|
||||
_filedir
|
||||
fi
|
||||
} &&
|
||||
complete -F _vpn_router_backup vpn-router-backup
|
||||
|
||||
# ex: filetype=sh
|
||||
@@ -0,0 +1,98 @@
|
||||
# bash completion for vpn-router-ca -*- shell-script -*-
|
||||
|
||||
# Python of the package that prints the revocation reasons (reasons) or, from the configuration read as root, the
|
||||
# issuing CAs (cas) or the certificate templates (templates), which other users do not get.
|
||||
_vpn_router_ca_python()
|
||||
{
|
||||
python3 -c '
|
||||
import sys
|
||||
sys.path.insert(0, "/usr/lib/vpn-router")
|
||||
import vpnrouter
|
||||
import vpnrouter_pki
|
||||
if sys.argv[1] == "reasons":
|
||||
print("\n".join(vpnrouter_pki.REASONS))
|
||||
else:
|
||||
cfg = vpnrouter.load_config()
|
||||
print("\n".join(vpnrouter.issuing_cas(cfg) if sys.argv[1] == "cas" else vpnrouter.pki_templates(cfg)))
|
||||
' "$@" 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_ca()
|
||||
{
|
||||
local cur prev words cword split
|
||||
_init_completion -s || return
|
||||
|
||||
# The command and, for ca and root, the action are the first words that are not options; the arguments are the
|
||||
# words after them but option values.
|
||||
local i command='' action='' args=0
|
||||
for ((i = 1; i < cword; i++)); do
|
||||
case ${words[i]} in
|
||||
--ca | --template | --csr | --subject | --san | --pfx | --reason | --out) ((i++)) ;;
|
||||
-*) ;;
|
||||
*)
|
||||
if [[ -z $command ]]; then
|
||||
command=${words[i]}
|
||||
elif [[ $command == @(ca|root) && -z $action ]]; then
|
||||
action=${words[i]}
|
||||
else
|
||||
((args++))
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case $prev in
|
||||
--ca)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_ca_python cas)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--template)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_ca_python templates)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--reason)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_ca_python reasons)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--csr | --pfx | --out)
|
||||
_filedir
|
||||
return
|
||||
;;
|
||||
--subject | --san)
|
||||
return
|
||||
;;
|
||||
esac
|
||||
$split && return
|
||||
|
||||
if [[ -z $command ]]; then
|
||||
if [[ $cur == -* ]]; then
|
||||
COMPREPLY=($(compgen -W '--json --help' -- "$cur"))
|
||||
else
|
||||
COMPREPLY=($(compgen -W 'status list issue revoke maintain ca root' -- "$cur"))
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ $cur == -* ]]; then
|
||||
local options='--help'
|
||||
case $command$action in
|
||||
list) options+=' --ca' ;;
|
||||
issue) options+=' --ca --template --csr --subject --san --pfx' ;;
|
||||
revoke) options+=' --reason' ;;
|
||||
rootexport-key) options+=' --out' ;;
|
||||
rootremove-key) options+=' --yes' ;;
|
||||
esac
|
||||
COMPREPLY=($(compgen -W "$options" -- "$cur"))
|
||||
return
|
||||
fi
|
||||
|
||||
case $command,$action,$args in
|
||||
ca,,*) COMPREPLY=($(compgen -W 'csr import' -- "$cur")) ;;
|
||||
root,,*) COMPREPLY=($(compgen -W 'export-key remove-key import-key import-crl' -- "$cur")) ;;
|
||||
ca,*,0) COMPREPLY=($(compgen -W "$(_vpn_router_ca_python cas)" -- "$cur")) ;;
|
||||
ca,import,1 | root,import-key,0 | root,import-crl,0) _filedir ;;
|
||||
esac
|
||||
} &&
|
||||
complete -F _vpn_router_ca vpn-router-ca
|
||||
|
||||
# ex: filetype=sh
|
||||
@@ -0,0 +1,24 @@
|
||||
# bash completion for vpn-router-caddy -*- shell-script -*-
|
||||
|
||||
_vpn_router_caddy()
|
||||
{
|
||||
local cur prev words cword
|
||||
_init_completion || return
|
||||
|
||||
# After the command only --help is left.
|
||||
local word
|
||||
for word in "${words[@]:1:cword-1}"; do
|
||||
if [[ $word != -* ]]; then
|
||||
[[ $cur == -* ]] && COMPREPLY=($(compgen -W '--help' -- "$cur"))
|
||||
return
|
||||
fi
|
||||
done
|
||||
if [[ $cur == -* ]]; then
|
||||
COMPREPLY=($(compgen -W '--json --help' -- "$cur"))
|
||||
else
|
||||
COMPREPLY=($(compgen -W 'status apply use-upstream' -- "$cur"))
|
||||
fi
|
||||
} &&
|
||||
complete -F _vpn_router_caddy vpn-router-caddy
|
||||
|
||||
# ex: filetype=sh
|
||||
@@ -0,0 +1,74 @@
|
||||
# bash completion for vpn-router-group -*- shell-script -*-
|
||||
|
||||
# The names of the users and of the groups of the router's directory, read as root; nothing for other users.
|
||||
_vpn_router_group_users()
|
||||
{
|
||||
vpn-router-user --json list 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(user["name"] for user in json.load(sys.stdin)["users"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_group_names()
|
||||
{
|
||||
vpn-router-group --json list 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(group["name"] for group in json.load(sys.stdin)["groups"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_group()
|
||||
{
|
||||
local cur prev words cword split
|
||||
_init_completion -s || return
|
||||
|
||||
# The command is the first word that is not an option; the arguments are the words after it but option values.
|
||||
local i command='' args=0
|
||||
for ((i = 1; i < cword; i++)); do
|
||||
if [[ -z $command ]]; then
|
||||
[[ ${words[i]} != -* ]] && command=${words[i]}
|
||||
continue
|
||||
fi
|
||||
case ${words[i]} in
|
||||
--member | --description | --name) ((i++)) ;;
|
||||
-*) ;;
|
||||
*) ((args++)) ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z $command ]]; then
|
||||
if [[ $cur == -* ]]; then
|
||||
COMPREPLY=($(compgen -W '--json --help' -- "$cur"))
|
||||
else
|
||||
COMPREPLY=($(compgen -W 'list add update remove member-add member-remove' -- "$cur"))
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
case $prev in
|
||||
--member)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_group_users)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--description | --name)
|
||||
return
|
||||
;;
|
||||
esac
|
||||
$split && return
|
||||
|
||||
if [[ $cur == -* ]]; then
|
||||
local options='--help'
|
||||
case $command in
|
||||
add) options+=' --member --description' ;;
|
||||
update) options+=' --name --description' ;;
|
||||
esac
|
||||
COMPREPLY=($(compgen -W "$options" -- "$cur"))
|
||||
return
|
||||
fi
|
||||
|
||||
# The group, which add takes new, then the user of member-add and member-remove.
|
||||
if ((args == 0)) && [[ $command != @(list|add) ]]; then
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_group_names)" -- "$cur"))
|
||||
elif ((args == 1)) && [[ $command == member-@(add|remove) ]]; then
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_group_users)" -- "$cur"))
|
||||
fi
|
||||
} &&
|
||||
complete -F _vpn_router_group vpn-router-group
|
||||
|
||||
# ex: filetype=sh
|
||||
@@ -0,0 +1,94 @@
|
||||
# bash completion for vpn-router-role -*- shell-script -*-
|
||||
|
||||
# The service accounts, the users, the groups and the roles of a resource of the router's directory, read as root;
|
||||
# nothing for other users.
|
||||
_vpn_router_role_resources()
|
||||
{
|
||||
vpn-router-user --json list 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(user["name"] for user in json.load(sys.stdin)["users"] if user["service"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_role_accounts()
|
||||
{
|
||||
vpn-router-user --json list 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(user["name"] for user in json.load(sys.stdin)["users"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_role_groups()
|
||||
{
|
||||
vpn-router-group --json list 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(group["name"] for group in json.load(sys.stdin)["groups"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_role_names()
|
||||
{
|
||||
vpn-router-role --json list "$1" 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(role["name"] for role in json.load(sys.stdin)["roles"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_role()
|
||||
{
|
||||
local cur prev words cword split
|
||||
_init_completion -s || return
|
||||
|
||||
# The command is the first word that is not an option; the arguments are the words after it but option values.
|
||||
local i command='' args=0 resource=''
|
||||
for ((i = 1; i < cword; i++)); do
|
||||
if [[ -z $command ]]; then
|
||||
[[ ${words[i]} != -* ]] && command=${words[i]}
|
||||
continue
|
||||
fi
|
||||
case ${words[i]} in
|
||||
--description | --group | --account) ((i++)) ;;
|
||||
-*) ;;
|
||||
*)
|
||||
((args == 0)) && resource=${words[i]}
|
||||
((args++))
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z $command ]]; then
|
||||
if [[ $cur == -* ]]; then
|
||||
COMPREPLY=($(compgen -W '--json --help' -- "$cur"))
|
||||
else
|
||||
COMPREPLY=($(compgen -W 'list add remove member-add member-remove' -- "$cur"))
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
case $prev in
|
||||
--group)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_role_groups)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--account)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_role_accounts)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--description)
|
||||
return
|
||||
;;
|
||||
esac
|
||||
$split && return
|
||||
|
||||
if [[ $cur == -* ]]; then
|
||||
local options='--help'
|
||||
case $command in
|
||||
add) options+=' --description' ;;
|
||||
member-add | member-remove) options+=' --group --account' ;;
|
||||
esac
|
||||
COMPREPLY=($(compgen -W "$options" -- "$cur"))
|
||||
return
|
||||
fi
|
||||
|
||||
# The resource, then the role, which add takes new.
|
||||
if ((args == 0)); then
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_role_resources)" -- "$cur"))
|
||||
elif ((args == 1)) && [[ $command != @(list|add) ]]; then
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_role_names "$resource")" -- "$cur"))
|
||||
fi
|
||||
} &&
|
||||
complete -F _vpn_router_role vpn-router-role
|
||||
|
||||
# ex: filetype=sh
|
||||
@@ -0,0 +1,103 @@
|
||||
# bash completion for vpn-router-site -*- shell-script -*-
|
||||
|
||||
# The names of the sites, of the groups and of the general-purpose issuing CAs, read as root; nothing for other users.
|
||||
_vpn_router_site_names()
|
||||
{
|
||||
vpn-router-site --json list 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(site["name"] for site in json.load(sys.stdin)["sites"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_site_groups()
|
||||
{
|
||||
vpn-router-group --json list 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(group["name"] for group in json.load(sys.stdin)["groups"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_site_cas()
|
||||
{
|
||||
python3 -c 'import sys; sys.path.insert(0, "/usr/lib/vpn-router"); import vpnrouter
|
||||
print("\n".join(vpnrouter.load_config()["pki"]["issuing_cas"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_site()
|
||||
{
|
||||
local cur prev words cword split
|
||||
_init_completion -s || return
|
||||
|
||||
# The command is the first word that is not an option; the arguments are the words after it but option values.
|
||||
local i command='' args=0
|
||||
for ((i = 1; i < cword; i++)); do
|
||||
if [[ -z $command ]]; then
|
||||
[[ ${words[i]} != -* ]] && command=${words[i]}
|
||||
continue
|
||||
fi
|
||||
case ${words[i]} in
|
||||
--host | --port | --listen | --tls | --ca | --certificate | --key | --index | --editors) ((i++)) ;;
|
||||
-*) ;;
|
||||
*) ((args++)) ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z $command ]]; then
|
||||
if [[ $cur == -* ]]; then
|
||||
COMPREPLY=($(compgen -W '--json --help' -- "$cur"))
|
||||
else
|
||||
COMPREPLY=($(compgen -W 'list show add update remove' -- "$cur"))
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
case $prev in
|
||||
--listen)
|
||||
COMPREPLY=($(compgen -W 'internal external both' -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--tls)
|
||||
COMPREPLY=($(compgen -W 'none acme ca file' -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--ca)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_site_cas)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--editors)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_site_groups)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--dns)
|
||||
COMPREPLY=($(compgen -W 'user router' -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--dns-address)
|
||||
COMPREPLY=($(compgen -W 'public internal external' -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--certificate | --key)
|
||||
_filedir
|
||||
return
|
||||
;;
|
||||
--host | --port | --index)
|
||||
return
|
||||
;;
|
||||
esac
|
||||
$split && return
|
||||
|
||||
if [[ $cur == -* ]]; then
|
||||
local options='--help'
|
||||
case $command in
|
||||
add | update) options+=' --host --no-hosts --port --listen --tls --ca --certificate --key --index --spa
|
||||
--no-spa --enabled --no-enabled --editors --dns --dns-address' ;;
|
||||
remove) options+=' --content' ;;
|
||||
esac
|
||||
COMPREPLY=($(compgen -W "$options" -- "$cur"))
|
||||
return
|
||||
fi
|
||||
|
||||
# The site, which add takes new.
|
||||
if ((args == 0)) && [[ $command != @(list|add) ]]; then
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_site_names)" -- "$cur"))
|
||||
fi
|
||||
} &&
|
||||
complete -F _vpn_router_site vpn-router-site
|
||||
|
||||
# ex: filetype=sh
|
||||
@@ -0,0 +1,25 @@
|
||||
# bash completion for vpn-router-token -*- shell-script -*-
|
||||
|
||||
_vpn_router_token()
|
||||
{
|
||||
local cur prev words cword split
|
||||
_init_completion -s || return
|
||||
|
||||
case $prev in
|
||||
--minutes | --hours | --days)
|
||||
return
|
||||
;;
|
||||
esac
|
||||
$split && return
|
||||
|
||||
# The lifetime is one of --minutes, --hours and --days.
|
||||
local options='--json --help' word
|
||||
for word in "${words[@]:1:cword-1}"; do
|
||||
[[ $word == --@(minutes|hours|days)* ]] && break
|
||||
done
|
||||
[[ $word == --@(minutes|hours|days)* ]] || options+=' --minutes --hours --days'
|
||||
COMPREPLY=($(compgen -W "$options" -- "$cur"))
|
||||
} &&
|
||||
complete -F _vpn_router_token vpn-router-token
|
||||
|
||||
# ex: filetype=sh
|
||||
@@ -0,0 +1,108 @@
|
||||
# bash completion for vpn-router-user -*- shell-script -*-
|
||||
|
||||
# The names of the users and of the groups of the router's directory, read as root; nothing for other users.
|
||||
_vpn_router_user_names()
|
||||
{
|
||||
vpn-router-user --json list 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(user["name"] for user in json.load(sys.stdin)["users"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_user_groups()
|
||||
{
|
||||
vpn-router-group --json list 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(group["name"] for group in json.load(sys.stdin)["groups"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
# The scopes of the resources, <resource>:<scope>, read as root.
|
||||
_vpn_router_user_scopes()
|
||||
{
|
||||
vpn-router-user --json list 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(user["name"] + ":" + scope for user in json.load(sys.stdin)["users"]
|
||||
for scope in user.get("resource_scopes", [])))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_user()
|
||||
{
|
||||
local cur prev words cword split
|
||||
_init_completion -s || return
|
||||
|
||||
local commands='list add update passwd disable enable unlock remove profile otp-new otp-enrol otp-remove console
|
||||
passkeys-remove'
|
||||
|
||||
# The command is the first word that is not an option; the arguments are the words after it but option values.
|
||||
local i command='' args=0
|
||||
for ((i = 1; i < cword; i++)); do
|
||||
if [[ -z $command ]]; then
|
||||
[[ ${words[i]} != -* ]] && command=${words[i]}
|
||||
continue
|
||||
fi
|
||||
case ${words[i]} in
|
||||
--first-name | --last-name | --email | --display-name | --description | --redirect-uri | \
|
||||
--post-logout-redirect-uri | --allowed-group | --account | --grant | --public-client | --client-scope | \
|
||||
--resource-scope) ((i++)) ;;
|
||||
-*) ;;
|
||||
*) ((args++)) ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z $command ]]; then
|
||||
if [[ $cur == -* ]]; then
|
||||
COMPREPLY=($(compgen -W '--json --help' -- "$cur"))
|
||||
else
|
||||
COMPREPLY=($(compgen -W "$commands" -- "$cur"))
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
case $prev in
|
||||
--allowed-group)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_user_groups)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--grant)
|
||||
COMPREPLY=($(compgen -W 'client_credentials device_code' -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--public-client)
|
||||
COMPREPLY=($(compgen -W 'yes no' -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--client-scope)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_user_scopes)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--resource-scope)
|
||||
return
|
||||
;;
|
||||
--account)
|
||||
# The local accounts console --account takes: a uid from 1000 to 19999, not nobody.
|
||||
COMPREPLY=($(compgen -W "$(getent passwd | awk -F: '$3 >= 1000 && $3 < 20000 && $1 != "nobody" { print $1 }')" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--first-name | --last-name | --email | --display-name | --description | --redirect-uri | --post-logout-redirect-uri)
|
||||
return
|
||||
;;
|
||||
esac
|
||||
$split && return
|
||||
|
||||
if [[ $cur == -* ]]; then
|
||||
local options='--help'
|
||||
case $command in
|
||||
add) options+=' --first-name --last-name --email --service --display-name --description --redirect-uri
|
||||
--post-logout-redirect-uri --allowed-group --grant --public-client --client-scope --resource-scope' ;;
|
||||
update) options+=' --first-name --last-name --email --display-name --description --redirect-uri
|
||||
--post-logout-redirect-uri --allowed-group --grant --public-client --client-scope --resource-scope' ;;
|
||||
console) options+=' --posix --account --off' ;;
|
||||
esac
|
||||
COMPREPLY=($(compgen -W "$options" -- "$cur"))
|
||||
return
|
||||
fi
|
||||
|
||||
# The user name, which add takes new; otp-enrol then takes a code.
|
||||
if ((args == 0)) && [[ $command != @(list|add) ]]; then
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_user_names)" -- "$cur"))
|
||||
fi
|
||||
} &&
|
||||
complete -F _vpn_router_user vpn-router-user
|
||||
|
||||
# ex: filetype=sh
|
||||
@@ -8,7 +8,8 @@
|
||||
"internal": "eth1"
|
||||
},
|
||||
"wan": {
|
||||
"local_fqdn": "router.example.com"
|
||||
"local_fqdn": "router.example.com",
|
||||
"public_address": ""
|
||||
},
|
||||
"local": {
|
||||
"cidrs": ["10.0.0.0/24"],
|
||||
@@ -38,7 +39,8 @@
|
||||
},
|
||||
"p2s": {
|
||||
"enabled": false,
|
||||
"address_pool": "172.16.0.0/24"
|
||||
"address_pool": "172.16.0.0/24",
|
||||
"dns_listen": "internal"
|
||||
},
|
||||
"wireguard": {
|
||||
"enabled": false,
|
||||
@@ -64,7 +66,8 @@
|
||||
"status_content": "basic",
|
||||
"shell": false,
|
||||
"shell_idle_minutes": 15,
|
||||
"shell_auth_timeout": 15
|
||||
"shell_auth_timeout": 15,
|
||||
"log_files": ["/var/log/syslog", "/var/log/auth.log"]
|
||||
},
|
||||
"ssh": {
|
||||
"ldap_login": false
|
||||
@@ -79,6 +82,18 @@
|
||||
"oidc": {
|
||||
"enabled": false
|
||||
},
|
||||
"sites": {
|
||||
"enabled": false,
|
||||
"upload_max_mb": 512,
|
||||
"webdav": false,
|
||||
"webdav_port": 7445
|
||||
},
|
||||
"dns": {
|
||||
"enabled": false,
|
||||
"secondary": "",
|
||||
"listen": "external",
|
||||
"ttl": 300
|
||||
},
|
||||
"pki": {
|
||||
"root_name": "VPN Router Root CA",
|
||||
"root_days": 7300,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
-- The domains the router's name server (PowerDNS, gpgsql backend) is the primary of (vpnrouter_dns), in the
|
||||
-- vpn-router-dns database, whose domains, records and domainmetadata tables are PowerDNS's own schema.
|
||||
-- The whole file runs as one transaction, serialized by an advisory lock, so that two processes can load it at once.
|
||||
|
||||
SELECT pg_advisory_xact_lock(hashtext('vpn-router dns schema'));
|
||||
|
||||
DROP TABLE IF EXISTS dns_manual_records;
|
||||
|
||||
-- The secondaries of each domain and the domain's switch of site records: the secondary is dns.secondary, the same
|
||||
-- for every domain, and each site says whether the router serves its records.
|
||||
DROP PROCEDURE IF EXISTS dns_secondary_put(text, text, inet);
|
||||
DROP PROCEDURE IF EXISTS dns_secondary_remove(text, text);
|
||||
DROP FUNCTION IF EXISTS dns_secondaries_list();
|
||||
DROP TABLE IF EXISTS dns_secondaries;
|
||||
DROP FUNCTION IF EXISTS dns_domains_list();
|
||||
DROP PROCEDURE IF EXISTS dns_domain_put(text, boolean, integer);
|
||||
|
||||
-- A domain the router serves as its primary name server; ttl is the TTL of its records, null for dns.ttl.
|
||||
CREATE TABLE IF NOT EXISTS dns_domains (
|
||||
name text PRIMARY KEY,
|
||||
ttl integer
|
||||
);
|
||||
ALTER TABLE dns_domains DROP COLUMN IF EXISTS sites;
|
||||
|
||||
-- A record an administrator added to a domain, besides those of the domain itself and of the sites.
|
||||
CREATE TABLE IF NOT EXISTS dns_records (
|
||||
domain text NOT NULL REFERENCES dns_domains (name) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
type text NOT NULL,
|
||||
content text NOT NULL,
|
||||
ttl integer NOT NULL,
|
||||
PRIMARY KEY (domain, name, type, content)
|
||||
);
|
||||
|
||||
-- The digest of the records last written to a zone and the serial of its SOA.
|
||||
CREATE TABLE IF NOT EXISTS dns_state (
|
||||
zone text PRIMARY KEY,
|
||||
digest text NOT NULL,
|
||||
serial bigint NOT NULL
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION dns_domains_list()
|
||||
RETURNS TABLE (name text, ttl integer)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT name, ttl FROM dns_domains ORDER BY name;
|
||||
$$;
|
||||
|
||||
-- Add the domain, or set its TTL.
|
||||
CREATE OR REPLACE PROCEDURE dns_domain_put(p_name text, p_ttl integer)
|
||||
LANGUAGE sql AS $$
|
||||
INSERT INTO dns_domains (name, ttl) VALUES (p_name, p_ttl)
|
||||
ON CONFLICT (name) DO UPDATE SET ttl = p_ttl;
|
||||
$$;
|
||||
|
||||
-- Remove the domain with its records.
|
||||
CREATE OR REPLACE PROCEDURE dns_domain_remove(p_name text)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM dns_domains WHERE name = p_name;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION dns_records_list()
|
||||
RETURNS TABLE (domain text, name text, type text, content text, ttl integer)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT domain, name, type, content, ttl FROM dns_records ORDER BY domain, name, type, content;
|
||||
$$;
|
||||
|
||||
-- Add the record to the domain, or set its TTL.
|
||||
CREATE OR REPLACE PROCEDURE dns_record_put(p_domain text, p_name text, p_type text, p_content text, p_ttl integer)
|
||||
LANGUAGE sql AS $$
|
||||
INSERT INTO dns_records VALUES (p_domain, p_name, p_type, p_content, p_ttl)
|
||||
ON CONFLICT (domain, name, type, content) DO UPDATE SET ttl = p_ttl;
|
||||
$$;
|
||||
|
||||
-- Remove the records of the name and type in the domain, all of them without content.
|
||||
CREATE OR REPLACE PROCEDURE dns_record_remove(p_domain text, p_name text, p_type text, p_content text)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM dns_records
|
||||
WHERE domain = p_domain AND name = p_name AND type = p_type AND (p_content IS NULL OR content = p_content);
|
||||
$$;
|
||||
|
||||
-- Serve the zone as its primary and give it the records, its SOA first, whose content has {serial} for the serial,
|
||||
-- and the addresses of its secondary, which are notified of its changes and may transfer it. The serial grows, to
|
||||
-- at least the current Unix time, only when the records differ from those last written.
|
||||
CREATE OR REPLACE PROCEDURE dns_apply(p_zone text, p_soa text, p_ttl integer, p_names text[], p_types text[],
|
||||
p_contents text[], p_ttls integer[], p_secondaries text[])
|
||||
LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
v_domain integer;
|
||||
v_serial bigint;
|
||||
v_digest text := md5(concat_ws('|', p_soa, p_ttl, array_to_string(p_names, ','), array_to_string(p_types, ','),
|
||||
array_to_string(p_contents, ','), array_to_string(p_ttls, ',')));
|
||||
BEGIN
|
||||
SELECT id INTO v_domain FROM domains WHERE name = p_zone;
|
||||
IF v_domain IS NULL THEN
|
||||
INSERT INTO domains (name, type) VALUES (p_zone, 'MASTER') RETURNING id INTO v_domain;
|
||||
ELSE
|
||||
UPDATE domains SET type = 'MASTER' WHERE id = v_domain AND type <> 'MASTER';
|
||||
END IF;
|
||||
DELETE FROM domainmetadata WHERE domain_id = v_domain AND kind IN ('ALSO-NOTIFY', 'ALLOW-AXFR-FROM');
|
||||
INSERT INTO domainmetadata (domain_id, kind, content)
|
||||
SELECT v_domain, kind, address FROM unnest(p_secondaries) AS address,
|
||||
unnest(ARRAY['ALSO-NOTIFY', 'ALLOW-AXFR-FROM']) AS kind;
|
||||
IF EXISTS (SELECT 1 FROM dns_state WHERE zone = p_zone AND digest = v_digest) THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
SELECT greatest(coalesce(max(serial) + 1, 1), extract(epoch FROM now())::bigint) INTO v_serial
|
||||
FROM dns_state WHERE zone = p_zone;
|
||||
DELETE FROM records WHERE domain_id = v_domain;
|
||||
INSERT INTO records (domain_id, name, type, content, ttl, prio, disabled, auth)
|
||||
VALUES (v_domain, p_zone, 'SOA', replace(p_soa, '{serial}', v_serial::text), p_ttl, 0, false, true);
|
||||
INSERT INTO records (domain_id, name, type, content, ttl, prio, disabled, auth)
|
||||
SELECT v_domain, r.name, r.type, r.content, r.ttl, 0, false, true
|
||||
FROM unnest(p_names, p_types, p_contents, p_ttls) AS r(name, type, content, ttl);
|
||||
INSERT INTO dns_state VALUES (p_zone, v_digest, v_serial)
|
||||
ON CONFLICT (zone) DO UPDATE SET digest = v_digest, serial = v_serial;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Serve only the zones of the list, none for an empty one.
|
||||
CREATE OR REPLACE PROCEDURE dns_retain(p_zones text[])
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM domains WHERE name <> ALL (p_zones);
|
||||
DELETE FROM dns_state WHERE zone <> ALL (p_zones);
|
||||
$$;
|
||||
|
||||
-- The records of the zone as they are served.
|
||||
CREATE OR REPLACE FUNCTION dns_served(p_zone text)
|
||||
RETURNS TABLE (name text, type text, content text, ttl integer)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT r.name, r.type, r.content, r.ttl FROM records r JOIN domains d ON d.id = r.domain_id
|
||||
WHERE d.name = p_zone ORDER BY r.type <> 'SOA', r.name, r.type, r.content;
|
||||
$$;
|
||||
@@ -1,10 +1,12 @@
|
||||
-- The state of the router's OpenID Provider (vpnrouter_oidc) in the database of web.database.
|
||||
-- Codes, tokens and sessions are kept as the SHA-256 of their values; expired rows are purged when new ones are saved.
|
||||
-- The whole file runs as one transaction, serialized by an advisory lock, so that two processes can load it at once.
|
||||
-- Codes, tokens, device codes and sessions are kept as the SHA-256 of their values; expired rows are purged when new
|
||||
-- ones are saved. The whole file runs as one transaction, serialized by an advisory lock, so that two processes can
|
||||
-- load it at once.
|
||||
|
||||
SELECT pg_advisory_xact_lock(hashtext('vpn-router oidc schema'));
|
||||
|
||||
-- An authorization code, valid until it is exchanged for tokens or expires.
|
||||
-- An authorization code, valid until it is exchanged for tokens or expires; resource is the resource the tokens are
|
||||
-- for, null for the client itself.
|
||||
CREATE TABLE IF NOT EXISTS oidc_codes (
|
||||
hash text PRIMARY KEY,
|
||||
client_id text NOT NULL,
|
||||
@@ -16,10 +18,13 @@ CREATE TABLE IF NOT EXISTS oidc_codes (
|
||||
code_challenge_method text NOT NULL,
|
||||
auth_time timestamptz 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 (
|
||||
hash text PRIMARY KEY,
|
||||
kind text NOT NULL CHECK (kind IN ('access', 'refresh')),
|
||||
@@ -28,9 +33,30 @@ CREATE TABLE IF NOT EXISTS oidc_tokens (
|
||||
scopes text[] NOT NULL,
|
||||
auth_time timestamptz NOT NULL,
|
||||
amr text[] NOT NULL,
|
||||
expires timestamptz NOT NULL,
|
||||
resource text
|
||||
);
|
||||
ALTER TABLE oidc_tokens ADD COLUMN IF NOT EXISTS resource text;
|
||||
CREATE INDEX IF NOT EXISTS oidc_tokens_name ON oidc_tokens (name);
|
||||
|
||||
-- A device code of the device authorization grant and its user code (user_hash): pending until the user allows or
|
||||
-- denies it, then the user, when and how they signed in; poll_seconds is the least time between two polls, polled
|
||||
-- the last poll.
|
||||
CREATE TABLE IF NOT EXISTS oidc_devices (
|
||||
hash text PRIMARY KEY,
|
||||
user_hash text NOT NULL UNIQUE,
|
||||
client_id text NOT NULL,
|
||||
scopes text[] NOT NULL,
|
||||
resource text,
|
||||
state text NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'allowed', 'denied')),
|
||||
name text,
|
||||
auth_time timestamptz,
|
||||
amr text[],
|
||||
poll_seconds integer NOT NULL,
|
||||
polled timestamptz,
|
||||
expires timestamptz NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS oidc_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.
|
||||
CREATE TABLE IF NOT EXISTS oidc_sessions (
|
||||
@@ -41,22 +67,30 @@ CREATE TABLE IF NOT EXISTS oidc_sessions (
|
||||
expires timestamptz NOT NULL
|
||||
);
|
||||
|
||||
-- The functions and procedures whose parameters or results differ from those of the same name.
|
||||
DROP PROCEDURE IF EXISTS oidc_code_save(text, text, text, text, text[], text, text, text, timestamptz, text[], integer);
|
||||
DROP FUNCTION IF EXISTS oidc_code_get(text);
|
||||
DROP PROCEDURE IF EXISTS oidc_token_save(text, text, text, text, text[], timestamptz, text[], integer);
|
||||
DROP FUNCTION IF EXISTS oidc_token_get(text, text);
|
||||
|
||||
CREATE OR REPLACE PROCEDURE oidc_code_save(p_hash text, p_client_id text, p_name text, p_redirect_uri text,
|
||||
p_scopes text[], p_nonce text, p_code_challenge text,
|
||||
p_code_challenge_method text, p_auth_time timestamptz, p_amr text[],
|
||||
p_seconds integer)
|
||||
p_resource text, p_seconds integer)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_codes WHERE expires < now();
|
||||
INSERT INTO oidc_codes VALUES (p_hash, p_client_id, p_name, p_redirect_uri, p_scopes, p_nonce, p_code_challenge,
|
||||
p_code_challenge_method, p_auth_time, p_amr, now() + make_interval(secs => p_seconds));
|
||||
INSERT INTO oidc_codes (hash, client_id, name, redirect_uri, scopes, nonce, code_challenge, code_challenge_method,
|
||||
auth_time, amr, resource, expires)
|
||||
VALUES (p_hash, p_client_id, p_name, p_redirect_uri, p_scopes, p_nonce, p_code_challenge, p_code_challenge_method,
|
||||
p_auth_time, p_amr, p_resource, now() + make_interval(secs => p_seconds));
|
||||
$$;
|
||||
|
||||
-- The unexpired code.
|
||||
CREATE OR REPLACE FUNCTION oidc_code_get(p_hash text)
|
||||
RETURNS TABLE (client_id text, name text, redirect_uri text, scopes text[], nonce text, code_challenge text,
|
||||
code_challenge_method text, auth_time timestamptz, amr text[])
|
||||
code_challenge_method text, auth_time timestamptz, amr text[], resource text)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT client_id, name, redirect_uri, scopes, nonce, code_challenge, code_challenge_method, auth_time, amr
|
||||
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();
|
||||
$$;
|
||||
|
||||
@@ -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[],
|
||||
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 $$
|
||||
DELETE FROM oidc_tokens WHERE expires < now();
|
||||
INSERT INTO oidc_tokens VALUES (p_hash, p_kind, p_client_id, p_name, p_scopes, p_auth_time, p_amr,
|
||||
now() + make_interval(secs => p_seconds));
|
||||
INSERT INTO oidc_tokens (hash, kind, client_id, name, scopes, auth_time, amr, resource, expires)
|
||||
VALUES (p_hash, p_kind, p_client_id, p_name, p_scopes, p_auth_time, p_amr, p_resource,
|
||||
now() + make_interval(secs => p_seconds));
|
||||
$$;
|
||||
|
||||
-- The unexpired token of the kind.
|
||||
CREATE OR REPLACE FUNCTION oidc_token_get(p_hash text, p_kind text)
|
||||
RETURNS TABLE (client_id text, name text, scopes text[], auth_time timestamptz, amr text[])
|
||||
RETURNS TABLE (client_id text, name text, scopes text[], auth_time timestamptz, amr text[], resource text)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT client_id, name, scopes, auth_time, amr 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();
|
||||
$$;
|
||||
|
||||
@@ -86,6 +121,59 @@ LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_tokens WHERE hash = p_hash;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE oidc_device_save(p_hash text, p_user_hash text, p_client_id text, p_scopes text[],
|
||||
p_resource text, p_poll_seconds integer, p_seconds integer)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_devices WHERE expires < now();
|
||||
INSERT INTO oidc_devices (hash, user_hash, client_id, scopes, resource, poll_seconds, expires)
|
||||
VALUES (p_hash, p_user_hash, p_client_id, p_scopes, p_resource, p_poll_seconds,
|
||||
now() + make_interval(secs => p_seconds));
|
||||
$$;
|
||||
|
||||
-- The unexpired device code of the user code while it waits for the user.
|
||||
CREATE OR REPLACE FUNCTION oidc_device_find(p_user_hash text)
|
||||
RETURNS TABLE (client_id text, scopes text[], resource text)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT client_id, scopes, resource FROM oidc_devices
|
||||
WHERE user_hash = p_user_hash AND state = 'pending' AND expires > now();
|
||||
$$;
|
||||
|
||||
-- Allow or deny the waiting device code of the user code as the user; whether there was one.
|
||||
CREATE OR REPLACE FUNCTION oidc_device_decide(p_user_hash text, p_name text, p_auth_time timestamptz, p_amr text[],
|
||||
p_allowed boolean)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql AS $$
|
||||
WITH decided AS (
|
||||
UPDATE oidc_devices SET state = CASE WHEN p_allowed THEN 'allowed' ELSE 'denied' END, name = p_name,
|
||||
auth_time = p_auth_time, amr = p_amr
|
||||
WHERE user_hash = p_user_hash AND state = 'pending' AND expires > now()
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT count(*) > 0 FROM decided;
|
||||
$$;
|
||||
|
||||
-- A poll of the device code: the row, whether it has expired, and whether it came sooner than poll_seconds after the
|
||||
-- last one, which adds 5 seconds to poll_seconds.
|
||||
CREATE OR REPLACE FUNCTION oidc_device_poll(p_hash text)
|
||||
RETURNS TABLE (client_id text, scopes text[], resource text, state text, name text, auth_time timestamptz, amr text[],
|
||||
expired boolean, too_soon boolean)
|
||||
LANGUAGE sql AS $$
|
||||
UPDATE oidc_devices AS device
|
||||
SET polled = now(),
|
||||
poll_seconds = device.poll_seconds +
|
||||
CASE WHEN device.polled + make_interval(secs => device.poll_seconds) > now() THEN 5 ELSE 0 END
|
||||
FROM (SELECT hash, polled, poll_seconds FROM oidc_devices WHERE hash = p_hash) AS previous
|
||||
WHERE device.hash = previous.hash
|
||||
RETURNING device.client_id, device.scopes, device.resource, device.state, device.name, device.auth_time, device.amr,
|
||||
device.expires <= now(),
|
||||
previous.polled IS NOT NULL AND previous.polled + make_interval(secs => previous.poll_seconds) > now();
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE oidc_device_delete(p_hash text)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_devices WHERE hash = p_hash;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE oidc_session_save(p_hash text, p_name text, p_amr text[], p_seconds integer)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_sessions WHERE expires < now();
|
||||
@@ -104,10 +192,11 @@ LANGUAGE sql AS $$
|
||||
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)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_codes WHERE name = p_name;
|
||||
DELETE FROM oidc_tokens WHERE name = p_name;
|
||||
DELETE FROM oidc_devices WHERE name = p_name;
|
||||
DELETE FROM oidc_sessions WHERE name = p_name;
|
||||
$$;
|
||||
@@ -0,0 +1,103 @@
|
||||
-- The web sites Caddy serves (vpnrouter_sites), in the vpn-router-sites database.
|
||||
-- The whole file runs as one transaction, serialized by an advisory lock, so that two processes can load it at once.
|
||||
|
||||
SELECT pg_advisory_xact_lock(hashtext('vpn-router sites schema'));
|
||||
|
||||
-- A static web site: its host names, port, interfaces and TLS, the group whose members change its files, and its
|
||||
-- DNS: user, the router serves no records of its host names, or router, its name server serves A or AAAA records of
|
||||
-- them with the router's public, internal or external address (dns_address).
|
||||
CREATE TABLE IF NOT EXISTS sites (
|
||||
name text PRIMARY KEY,
|
||||
enabled boolean NOT NULL,
|
||||
hosts text[] NOT NULL,
|
||||
port integer NOT NULL,
|
||||
listen text NOT NULL CHECK (listen IN ('internal', 'external', 'both')),
|
||||
tls text NOT NULL CHECK (tls IN ('none', 'acme', 'ca', 'file')),
|
||||
ca text NOT NULL,
|
||||
certificate text NOT NULL,
|
||||
private_key text NOT NULL,
|
||||
index_file text NOT NULL,
|
||||
spa boolean NOT NULL,
|
||||
editors text NOT NULL,
|
||||
modified timestamptz NOT NULL
|
||||
);
|
||||
ALTER TABLE sites ADD COLUMN IF NOT EXISTS dns text NOT NULL DEFAULT 'user' CHECK (dns IN ('user', 'router'));
|
||||
ALTER TABLE sites ADD COLUMN IF NOT EXISTS dns_address text NOT NULL DEFAULT 'public'
|
||||
CHECK (dns_address IN ('public', 'internal', 'external'));
|
||||
|
||||
-- The functions whose columns and the procedure whose arguments changed with dns and dns_address.
|
||||
DROP FUNCTION IF EXISTS sites_list();
|
||||
DROP FUNCTION IF EXISTS site_get(text);
|
||||
DROP FUNCTION IF EXISTS sites_served();
|
||||
DROP PROCEDURE IF EXISTS site_put(text, boolean, text[], integer, text, text, text, text, text, text, boolean, text);
|
||||
|
||||
-- Every site, by name; the private key is left out.
|
||||
CREATE OR REPLACE FUNCTION sites_list()
|
||||
RETURNS TABLE (name text, enabled boolean, hosts text[], port integer, listen text, tls text, ca text,
|
||||
certificate text, index_file text, spa boolean, editors text, dns text, dns_address text,
|
||||
modified timestamptz)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, index_file, spa, editors, dns, dns_address, modified
|
||||
FROM sites ORDER BY name;
|
||||
$$;
|
||||
|
||||
-- The site with its private key.
|
||||
CREATE OR REPLACE FUNCTION site_get(p_name text)
|
||||
RETURNS TABLE (name text, enabled boolean, hosts text[], port integer, listen text, tls text, ca text,
|
||||
certificate text, private_key text, index_file text, spa boolean, editors text, dns text,
|
||||
dns_address text, modified timestamptz)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, private_key, index_file, spa, editors, dns,
|
||||
dns_address, modified
|
||||
FROM sites WHERE name = p_name;
|
||||
$$;
|
||||
|
||||
-- The enabled sites with their private keys, by name, for Caddy's configuration and certificate files.
|
||||
CREATE OR REPLACE FUNCTION sites_served()
|
||||
RETURNS TABLE (name text, enabled boolean, hosts text[], port integer, listen text, tls text, ca text,
|
||||
certificate text, private_key text, index_file text, spa boolean, editors text, dns text,
|
||||
dns_address text, modified timestamptz)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, private_key, index_file, spa, editors, dns,
|
||||
dns_address, modified
|
||||
FROM sites WHERE enabled ORDER BY name;
|
||||
$$;
|
||||
|
||||
-- Add the site, or replace all its settings.
|
||||
CREATE OR REPLACE PROCEDURE site_put(p_name text, p_enabled boolean, p_hosts text[], p_port integer, p_listen text,
|
||||
p_tls text, p_ca text, p_certificate text, p_private_key text, p_index_file text,
|
||||
p_spa boolean, p_editors text, p_dns text, p_dns_address text)
|
||||
LANGUAGE sql AS $$
|
||||
INSERT INTO sites (name, enabled, hosts, port, listen, tls, ca, certificate, private_key, index_file, spa, editors,
|
||||
dns, dns_address, modified)
|
||||
VALUES (p_name, p_enabled, p_hosts, p_port, p_listen, p_tls, p_ca, p_certificate, p_private_key, p_index_file,
|
||||
p_spa, p_editors, p_dns, p_dns_address, now())
|
||||
ON CONFLICT (name) DO UPDATE SET enabled = p_enabled, hosts = p_hosts, port = p_port, listen = p_listen,
|
||||
tls = p_tls, ca = p_ca, certificate = p_certificate, private_key = p_private_key, index_file = p_index_file,
|
||||
spa = p_spa, editors = p_editors, dns = p_dns, dns_address = p_dns_address, modified = now();
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE site_remove(p_name text)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM sites WHERE name = p_name;
|
||||
$$;
|
||||
|
||||
-- The names of the sites whose files the members of the groups may change.
|
||||
CREATE OR REPLACE FUNCTION sites_editable(p_groups text[])
|
||||
RETURNS TABLE (name text)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT name FROM sites WHERE editors <> '' AND editors = ANY (p_groups) ORDER BY name;
|
||||
$$;
|
||||
|
||||
-- The names of the sites whose editors are the group.
|
||||
CREATE OR REPLACE FUNCTION sites_of_group(p_group text)
|
||||
RETURNS TABLE (name text)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT name FROM sites WHERE editors = p_group ORDER BY name;
|
||||
$$;
|
||||
|
||||
-- Give the sites of a renamed group its new name.
|
||||
CREATE OR REPLACE PROCEDURE sites_rename_group(p_old text, p_new text)
|
||||
LANGUAGE sql AS $$
|
||||
UPDATE sites SET editors = p_new, modified = now() WHERE editors = p_old;
|
||||
$$;
|
||||
@@ -1,13 +1,16 @@
|
||||
# Written by vpn-router-setup from /etc/vpn-router/vpn-router.json; edits are overwritten.
|
||||
# Written by vpn-router-setup from /etc/vpn-router/vpn-router.json and the web sites; edits are overwritten.
|
||||
{
|
||||
auto_https disable_redirects
|
||||
{%- if https_email %}
|
||||
email {{ https_email }}
|
||||
{%- endif %}
|
||||
{%- if acme_cas %}
|
||||
{%- if https_acme_ca %}
|
||||
acme_ca {{ https_acme_ca }}
|
||||
{%- endif %}
|
||||
{%- if pki_cas %}
|
||||
skip_install_trust
|
||||
pki {
|
||||
{%- for ca in acme_cas %}
|
||||
{%- for ca in pki_cas %}
|
||||
ca {{ ca.id }} {
|
||||
name "{{ ca.name }}"
|
||||
root {
|
||||
@@ -23,7 +26,7 @@
|
||||
}
|
||||
{%- endif %}
|
||||
}
|
||||
{% if web_enabled or 'https' in pki_publish or acme_cas %}
|
||||
{% if https and (web_enabled or 'https' in pki_publish or acme_cas) %}
|
||||
{{ local_fqdn }}:{{ https_web_port }} {
|
||||
{%- if 'https' in pki_publish %}
|
||||
handle_path /pki/* {
|
||||
@@ -32,9 +35,9 @@
|
||||
}
|
||||
{%- endif %}
|
||||
{%- for ca in acme_cas %}
|
||||
handle /acme/{{ ca.id }}/* {
|
||||
handle /acme/{{ ca }}/* {
|
||||
acme_server {
|
||||
ca {{ ca.id }}
|
||||
ca {{ ca }}
|
||||
}
|
||||
}
|
||||
{%- endfor %}
|
||||
@@ -47,7 +50,7 @@
|
||||
}
|
||||
}
|
||||
{% endif %}
|
||||
{%- if 'http-80' in pki_publish %}
|
||||
{%- if https and 'http-80' in pki_publish %}
|
||||
http://{{ local_fqdn }} {
|
||||
handle_path /pki/* {
|
||||
root * {{ pki_publish_dir }}
|
||||
@@ -58,7 +61,7 @@ http://{{ local_fqdn }} {
|
||||
}
|
||||
}
|
||||
{% endif %}
|
||||
{%- if 'https-default' in pki_publish %}
|
||||
{%- if https and 'https-default' in pki_publish %}
|
||||
{{ local_fqdn }}:443 {
|
||||
handle_path /pki/* {
|
||||
root * {{ pki_publish_dir }}
|
||||
@@ -69,13 +72,39 @@ http://{{ local_fqdn }} {
|
||||
}
|
||||
}
|
||||
{% endif %}
|
||||
{%- if manager_port %}
|
||||
{%- if https and manager_port %}
|
||||
{{ local_fqdn }}:{{ https_manager_port }} {
|
||||
reverse_proxy {{ loopback }}:{{ manager_port }}
|
||||
}
|
||||
{% endif %}
|
||||
{%- if metrics_enabled %}
|
||||
{%- if https and metrics_enabled %}
|
||||
{{ local_fqdn }}:{{ metrics_https_port }} {
|
||||
reverse_proxy {{ loopback }}:{{ metrics_port }}
|
||||
}
|
||||
{% endif %}
|
||||
{%- if webdav %}
|
||||
{{ local_fqdn }}:{{ webdav_port }} {
|
||||
reverse_proxy {{ loopback }}:{{ webdav_loopback_port }}
|
||||
}
|
||||
{% endif %}
|
||||
{%- for site in sites %}
|
||||
# Site {{ site.name }}
|
||||
{{ site.addresses | join(', ') }} {
|
||||
root * {{ site.root }}
|
||||
{%- if site.tls == 'ca' %}
|
||||
tls {
|
||||
issuer internal {
|
||||
ca {{ site.ca }}
|
||||
}
|
||||
}
|
||||
{%- elif site.tls == 'file' %}
|
||||
tls {{ site.cert }} {{ site.key }}
|
||||
{%- endif %}
|
||||
{%- if site.spa %}
|
||||
try_files {path} /{{ site.index }}
|
||||
{%- endif %}
|
||||
file_server {
|
||||
index {{ site.index }}
|
||||
}
|
||||
}
|
||||
{% endfor %}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Written by vpn-router-setup from /etc/vpn-router/vpn-router.json; edits are overwritten.
|
||||
# The resolver of the remote access clients, on the addresses of p2s.dns_listen, forwarding to systemd-resolved's stub.
|
||||
{% for address in p2s_resolver_addrs -%}
|
||||
listen-address={{ address }}
|
||||
{% endfor -%}
|
||||
bind-interfaces
|
||||
no-resolv
|
||||
server=127.0.0.53
|
||||
domain-needed
|
||||
no-hosts
|
||||
cache-size=1000
|
||||
@@ -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.secondary { background: #fff; color: #1d4ed8; border: 1px solid #1d4ed8; margin-top: 0.75rem; }
|
||||
.error { background: #fee2e2; color: #991b1b; padding: 0.5rem 0.75rem; border-radius: 0.25rem; margin-bottom: 1rem; }
|
||||
ul { margin: 0 0 1rem; padding-left: 1.25rem; color: #4b5563; }
|
||||
#user_code { text-transform: uppercase; letter-spacing: 0.15em; font-family: ui-monospace, monospace; }
|
||||
.issuer { font-size: 0.75rem; color: #6b7280; margin: 1.5rem 0 0; }
|
||||
</style>
|
||||
</head>
|
||||
@@ -27,25 +29,52 @@ button.secondary { background: #fff; color: #1d4ed8; border: 1px solid #1d4ed8;
|
||||
{%- if error %}
|
||||
<div class="error" role="alert">{{ error }}</div>
|
||||
{%- endif %}
|
||||
{%- if form %}
|
||||
<form method="post" action="/oidc/login">
|
||||
{%- if items %}
|
||||
<ul>
|
||||
{%- for item in items %}
|
||||
<li>{{ item }}</li>
|
||||
{%- endfor %}
|
||||
</ul>
|
||||
{%- endif %}
|
||||
{%- if decide %}
|
||||
<form method="post" action="/oidc/device/decide">
|
||||
<input type="hidden" name="user_code" value="{{ user_code }}">
|
||||
<button type="submit" name="decision" value="allow">Allow</button>
|
||||
<button class="secondary" type="submit" name="decision" value="deny">Deny</button>
|
||||
</form>
|
||||
{%- endif %}
|
||||
{%- if form or device %}
|
||||
<form id="sign-in" method="post" action="{{ action }}">
|
||||
{%- if device %}
|
||||
<label for="user_code">Code shown on the device</label>
|
||||
<input id="user_code" name="user_code" value="{{ user_code }}" autocapitalize="characters" autocomplete="off" spellcheck="false" required{% if not form %} autofocus{% endif %}>
|
||||
{%- else %}
|
||||
<input type="hidden" name="request" value="{{ request }}">
|
||||
{%- endif %}
|
||||
{%- if form %}
|
||||
<label for="name">Name</label>
|
||||
<input id="name" name="name" value="{{ name }}" autocomplete="username webauthn" autocapitalize="none" spellcheck="false" required 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>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||
<label for="code">Code from the authenticator, when you have one</label>
|
||||
<input id="code" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code">
|
||||
<button type="submit">Sign in</button>
|
||||
{%- else %}
|
||||
<button type="submit">Continue</button>
|
||||
{%- endif %}
|
||||
</form>
|
||||
<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="user_code">
|
||||
<input type="hidden" name="passkey_id">
|
||||
<input type="hidden" name="credential">
|
||||
</form>
|
||||
<button id="passkey" class="secondary" type="button" hidden>Sign in with a passkey</button>
|
||||
<script>
|
||||
// A passkey sign-in begins at /oidc/passkey and posts the browser's answer to /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 () {
|
||||
var button = document.getElementById('passkey');
|
||||
if (!window.PublicKeyCredential || !PublicKeyCredential.parseRequestOptionsFromJSON) {
|
||||
@@ -53,11 +82,16 @@ button.secondary { background: #fff; color: #1d4ed8; border: 1px solid #1d4ed8;
|
||||
}
|
||||
button.hidden = false;
|
||||
button.addEventListener('click', async function () {
|
||||
var code = document.getElementById('user_code');
|
||||
if (code && !code.reportValidity()) {
|
||||
return;
|
||||
}
|
||||
button.disabled = true;
|
||||
try {
|
||||
var begun = await (await fetch('/oidc/passkey', { method: 'POST' })).json();
|
||||
var credential = await navigator.credentials.get({ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(begun.options.publicKey) });
|
||||
var form = document.getElementById('passkey-form');
|
||||
form.elements.user_code.value = code ? code.value : '';
|
||||
form.elements.passkey_id.value = begun.id;
|
||||
form.elements.credential.value = JSON.stringify(credential.toJSON());
|
||||
form.submit();
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
[Resolve]
|
||||
{% for address in local_addr_list -%}
|
||||
DNSStubListenerExtra={{ address }}
|
||||
{% endfor -%}
|
||||
@@ -39,5 +39,9 @@ pools {
|
||||
rw-pool {
|
||||
addrs = {{ p2s_address_pool }}
|
||||
dns = {{ p2s_dns }}
|
||||
{%- if p2s_dns_domains %}
|
||||
# INTERNAL_DNS_DOMAIN (RFC 8598): the domains the clients resolve with the DNS servers above.
|
||||
25 = {{ p2s_dns_domains | join(', ') }}
|
||||
{%- endif %}
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ variable "vm_size" {
|
||||
}
|
||||
|
||||
variable "local_fqdn" {
|
||||
description = "FQDN of the router, used as the road-warrior/IKE identity and, when dns_zone_id is set, as the name of the A record created for it."
|
||||
description = "FQDN of the router, used as the IKE identity and, when dns_zone_id is set, as the name of the A record created for it."
|
||||
type = string
|
||||
}
|
||||
|
||||
@@ -89,13 +89,13 @@ variable "psk" {
|
||||
}
|
||||
|
||||
variable "p2s_enabled" {
|
||||
description = "Enable road-warrior (P2S) access."
|
||||
description = "Enable IKEv2 access."
|
||||
type = bool
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "p2s_address_pool" {
|
||||
description = "CIDR block assigned to road-warrior clients."
|
||||
description = "CIDR block assigned to IKEv2 clients."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
@@ -140,7 +140,7 @@ variable "deploy_workload_vm" {
|
||||
variable "repo_url" {
|
||||
description = "Base URL of the Debian package repository the router pulls vpn-router from."
|
||||
type = string
|
||||
default = "https://gitea.koszewscy.waw.pl/api/packages/slawek/debian"
|
||||
default = "https://gitea.koszewscy.waw.pl/api/packages/koszewscy/debian"
|
||||
}
|
||||
|
||||
variable "ubuntu_codename" {
|
||||
|
||||
@@ -37,7 +37,7 @@ variable "machine_type" {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -86,13 +86,13 @@ variable "psk" {
|
||||
}
|
||||
|
||||
variable "p2s_enabled" {
|
||||
description = "Enable road-warrior (P2S) access."
|
||||
description = "Enable IKEv2 access."
|
||||
type = bool
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "p2s_address_pool" {
|
||||
description = "CIDR block assigned to road-warrior clients."
|
||||
description = "CIDR block assigned to IKEv2 clients."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
@@ -137,7 +137,7 @@ variable "deploy_workload_vm" {
|
||||
variable "repo_url" {
|
||||
description = "Base URL of the Debian package repository the router pulls vpn-router from."
|
||||
type = string
|
||||
default = "https://gitea.koszewscy.waw.pl/api/packages/slawek/debian"
|
||||
default = "https://gitea.koszewscy.waw.pl/api/packages/koszewscy/debian"
|
||||
}
|
||||
|
||||
variable "ubuntu_codename" {
|
||||
|
||||
+18
-11
@@ -32,19 +32,26 @@ def git(*args):
|
||||
|
||||
|
||||
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,
|
||||
with a blank line between it and the header and a blank line after it.
|
||||
The summary is the entry's first paragraph: lines indented by exactly two spaces, the
|
||||
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()
|
||||
if len(lines) < 4 or lines[1].strip() or lines[3].strip():
|
||||
if len(lines) < 4 or lines[1].strip():
|
||||
return None
|
||||
line = lines[2]
|
||||
text = line.strip()
|
||||
if not line.startswith(' ') or line.startswith(' ') or not text or text.startswith('*') or line.startswith(' -- '):
|
||||
paragraph = []
|
||||
for line in lines[2:]:
|
||||
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 text
|
||||
if not paragraph or paragraph[0].startswith('*'):
|
||||
return None
|
||||
return ' '.join(paragraph)
|
||||
|
||||
|
||||
def version():
|
||||
@@ -61,7 +68,7 @@ def version():
|
||||
def main():
|
||||
global DRY_RUN
|
||||
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('--no-push', action='store_true', help='commit and tag, but do not push')
|
||||
args = parser.parse_args()
|
||||
@@ -69,8 +76,8 @@ def main():
|
||||
|
||||
message = args.message or summary()
|
||||
if not message:
|
||||
sys.exit(f'no commit message: pass -m, or start the newest entry of {CHANGELOG} with a summary line '
|
||||
'without "*", indented by two spaces, with a blank line above and below it')
|
||||
sys.exit(f'no commit message: pass -m, or start the newest entry of {CHANGELOG} with a summary paragraph '
|
||||
'not starting with "*", indented by two spaces, with a blank line above and below it')
|
||||
tag = version()
|
||||
if subprocess.run(['git', 'rev-parse', '-q', '--verify', f'refs/tags/{tag}'], cwd=ROOT,
|
||||
capture_output=True).returncode == 0:
|
||||
|
||||
Reference in new issue
Block a user