Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f36e2a3b04 | ||
|
|
1e0e375d60 | ||
|
|
dd489e65aa | ||
|
|
3de030b06b | ||
|
|
3c4c5a8c32 | ||
|
|
3ed79c7563 | ||
|
|
3e78d520ae | ||
|
|
d99e4ea425 | ||
|
|
46d3550eab | ||
|
|
3b14b7723e | ||
|
|
5303779559 | ||
|
|
2dc4db440f | ||
|
|
45cea95728 | ||
|
|
dd2dfaaf2b | ||
|
|
1697e1aa38 | ||
|
|
57e8bbcffe | ||
|
|
9ca8fce14e | ||
|
|
74c24d7bc6 | ||
|
|
770332728e | ||
|
|
e14bbe2ba1 | ||
|
|
dabe4e8365 | ||
|
|
ea8ca97417 | ||
|
|
77bdbcb058 | ||
|
|
cb33b3bfa1 | ||
|
|
81b7f4cac6 | ||
|
|
8cac21845a | ||
|
|
d1d565b1d9 | ||
|
|
e675540ce2 | ||
|
|
90db6d46a2 | ||
|
|
91cc64d6a6 |
No files matched your search
@@ -46,7 +46,7 @@ and as a Tailwind CSS page for a browser, and `/status` is the same status as JS
|
||||
much as `web.status_content` says, since the router is reachable from the internet and the
|
||||
operator decides how much it tells strangers: `basic`, the default because it tells the least,
|
||||
the overall status and the Client IP, `standard` also the package versions and the number of
|
||||
active and other services, and `full` also each service and the state of the site-to-site, road-warrior, OpenVPN and
|
||||
active and other services, and `full` also each service and the state of the site-to-site, IKEv2, OpenVPN and
|
||||
WireGuard features. The Client IP is the address the request came from, through the trusted
|
||||
proxies, so a person can see the address the router sees. Nothing comes from the configuration.
|
||||
`/status` names its content, so clients such as manage-ui and the Router Manager show what it
|
||||
@@ -98,8 +98,8 @@ Context: OpenVPN needs user accounts with passwords, and the users are meant to
|
||||
system of the router, including IKEv2 EAP-TLS and WireGuard, which an OIDC provider cannot
|
||||
authenticate. Everything the router runs comes from the Ubuntu archive. slapd, its Python module
|
||||
`python3-ldap` and the OpenVPN plugin `openvpn-auth-ldap` are all in the archive; the plugin
|
||||
checks a password by binding as the user, so the hash scheme is slapd's alone. An OIDC provider
|
||||
can read the directory through its LDAP connector.
|
||||
checks a password by binding as the user, so the hash scheme is slapd's alone. The router's own
|
||||
OpenID Provider (ADR-37) signs the directory's users in to other systems.
|
||||
|
||||
Decision: The users are entries in slapd. The package keeps its own database,
|
||||
`dc=vpn-router,dc=local` in `/var/lib/ldap/vpn-router` (the path the slapd AppArmor profile
|
||||
@@ -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,15 @@ 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.
|
||||
|
||||
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 +662,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 +773,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 +915,17 @@ 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. The WebSocket of RFC 6455 is implemented on the standard library inside the
|
||||
and TOTP code, or with one of the user's own passkeys (ADR-36), and gives a one-time ticket valid 30
|
||||
seconds; a WebSocket on `GET /shell` with that ticket gets the shell. A passkey with user
|
||||
verification is a possession and a PIN or biometric in one step and cannot be phished, so it is
|
||||
as good as the password and the code; `POST /shell/passkey` begins a proof bound to the signed-in
|
||||
user and its credentials, and python3-fido2 refuses an answer without the user-verified flag. The
|
||||
TOTP enrolment stays required with a passkey: the PAM login session of the shell, through the
|
||||
`pam_authz_search` of nslcd for a directory account, and sudo in it need it, and loosening that
|
||||
filter would let such a user log in over SSH with the password alone. Asking for the password and code at every opening of the page is tiresome,
|
||||
so within `web.shell_auth_timeout` minutes of the user's last sign-in or keystroke a ticket is given
|
||||
without them; the times are kept in the web service's memory only, so a restart asks again, and 0
|
||||
keeps the sign-in at every opening. The WebSocket of RFC 6455 is implemented on the standard library inside the
|
||||
web service's `http.server`, server side only, without extensions. `systemd-run --pty` starts the
|
||||
shell in a transient unit with a PAM login session, outside the sandbox, and `setsid --ctty`
|
||||
makes the terminal its controlling terminal so window sizes reach the shell. A shell idle for
|
||||
@@ -917,9 +933,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.
|
||||
@@ -942,12 +959,18 @@ so slapd's otp overlay and password policy apply as for the Router Manager: the
|
||||
by the code, and the lockout. Members of `vpn-router-console` get sudo with that password. SSH
|
||||
accepts it only while `ssh.ldap_login` is set, in a `Match Group` drop-in of sshd. Switching console
|
||||
login off keeps the `posixAccount`, hidden by nslcd's filter, so the uid and the home stay the
|
||||
user's; leaving `admins` switches it off.
|
||||
user's; leaving `admins` switches it off. A console reaches root through sudo, so a password alone
|
||||
must not open one: console login needs a TOTP token. Switching it on is refused without one, and
|
||||
the token is checked at use rather than only when it is switched on, because an administrator can
|
||||
remove it later: the shell's ticket and every minute of an open shell check it, nslcd's
|
||||
`pam_authz_search` requires `oathTOTPUser`, and removing the token ends the sessions of the user's
|
||||
own POSIX account.
|
||||
|
||||
Consequences: Each administrator's shell, SSH session and sudo run as the administrator, logged
|
||||
under the administrator's name. The directory becomes part of the system's name service through
|
||||
nslcd, and the system knows only the accounts with console login on. A user enrolled for TOTP types
|
||||
the code after the password at the SSH and sudo prompts. Homes are created at the first login and
|
||||
nslcd, and the system knows only the accounts with console login on. The user types the code
|
||||
after the password at the SSH and sudo prompts. An administrator whose console login was switched
|
||||
on before the token was required is refused until enrolling. Homes are created at the first login and
|
||||
are not removed with the user. A password is changed through the directory, not with `passwd`.
|
||||
|
||||
## ADR-35: WireGuard peers are the devices of the VPN users, kept in the directory
|
||||
@@ -969,3 +992,382 @@ endpoint is, choosing the user for each.
|
||||
Consequences: Peers follow their users: disabling a user drops the user's devices, and the
|
||||
directory's backup holds them. A peer cannot exist without a VPN user. A change restarts
|
||||
`wg-quick@wg0` through setup, which briefly interrupts the other peers.
|
||||
|
||||
## ADR-36: Passkeys sign in to the Router Manager and the OpenID Provider, verified by the router with python3-fido2
|
||||
|
||||
Context: Typing the password and a TOTP code at every sign-in to the Router Manager is tedious.
|
||||
WebAuthn needs a browser, HTTPS and a domain name; OpenVPN, IKEv2, WireGuard, SSH and sudo have no
|
||||
browser, and the sudo PAM module for security keys needs the key at the router. The router signs
|
||||
users in (`/auth`) and keeps them in its directory. The standard library cannot read CBOR or COSE
|
||||
keys; Ubuntu packages Yubico's python-fido2 in noble (1.1) and resolute (2.0), whose server API is
|
||||
the same once the JSON mapping of WebAuthn Level 3 is switched on, which 2.0 always uses.
|
||||
|
||||
Decision: A passkey is a discoverable credential with user verification of the relying party
|
||||
`wan.local_fqdn`, with the user name as its user handle, so the sign-in needs no name. The router
|
||||
registers and verifies passkeys with python3-fido2 in `vpnrouter_passkeys`; the origin must be
|
||||
HTTPS on that name, which python-fido2 checks. Each passkey is a JSON value of the multi-valued
|
||||
`vpnRouterPasskey` on the user's entry, with the public key only. Registration needs the current
|
||||
password and code, so a session alone cannot add a lasting way in; removal needs neither. A
|
||||
passkey sign-in gives the same tokens as `/auth`; as it binds no password, the router checks that
|
||||
the user is enabled, not locked and not a service account. The begun steps are kept in the web
|
||||
service's memory for 2 minutes. The browser converts the options and answers with
|
||||
`PublicKeyCredential.parseCreationOptionsFromJSON`, `parseRequestOptionsFromJSON` and `toJSON`.
|
||||
The OpenID Provider's sign-in page (ADR-37) takes a passkey the same way.
|
||||
|
||||
Consequences: Passkeys are a convenience for the Router Manager and the OpenID Provider; the
|
||||
password and code stay for everything else, the shell included. A changed `wan.local_fqdn` orphans every passkey. The
|
||||
Router Manager on another name, or in development on `localhost`, cannot use them. Failed passkey
|
||||
sign-ins do not count towards the directory's lockout, but the sign-in calls are rate limited.
|
||||
Browsers without the JSON methods of WebAuthn Level 3 cannot add or use passkeys.
|
||||
|
||||
## ADR-37: The web service is an OpenID Provider built on python3-oauthlib; its clients and resources are service accounts
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context:
|
||||
|
||||
- **What is needed:** other systems, such as dashboards, should sign the router's users in with the
|
||||
directory's passwords, TOTP codes and passkeys, without a second user store. Programs on hosts
|
||||
without a browser, and jobs without a user, need tokens as well. Services such as an S3 gateway
|
||||
should learn from a token what its holder may do there, as resources of Entra ID do with app
|
||||
roles and scopes.
|
||||
- **What the archive has:** no packaged identity provider. `python3-oauthlib` and `python3-jwt`
|
||||
are in main for noble and resolute; `python3-authlib` and `python3-jwcrypto` are in universe
|
||||
only.
|
||||
- **What oauthlib does:** it implements the OAuth 2.0 and OpenID Connect flows, including the
|
||||
client credentials grant, and leaves the checks, the storage and the signatures to the
|
||||
application. Noble's 3.2.2 has no device authorization grant (RFC 8628). Resolute's 3.3.1 has
|
||||
one, as has the current 4.0.0, but it checks no device code and never answers
|
||||
`authorization_pending` or `slow_down`.
|
||||
- **What the web service has:** it already checks passwords and passkeys, rate limits sign-ins and
|
||||
keeps tokens in PostgreSQL, and Caddy serves it over HTTPS on `wan.local_fqdn`. ADR-3's HS256
|
||||
key must not leave the router, but a relying party or a resource verifies a token itself.
|
||||
|
||||
Decision:
|
||||
|
||||
- **Where it runs:** the web service is the OpenID Provider, in `vpnrouter_oidc`, while
|
||||
`oidc.enabled` is true. Its issuer is its HTTPS site, `https://<wan.local_fqdn>:<https.web_port>`,
|
||||
without the port on 443, and it answers the provider's paths only on `127.0.0.1`, where Caddy
|
||||
passes them.
|
||||
- **Clients:**
|
||||
- A client is a service account with redirect URIs or grants, kept with the account in
|
||||
attributes of `vpnRouterAccount`: `vpnRouterRedirectUri`, `vpnRouterPostLogoutRedirectUri`,
|
||||
`vpnRouterAllowedGroup`, `vpnRouterGrantType`, `vpnRouterPublicClient` and
|
||||
`vpnRouterClientScope`.
|
||||
- Its name is the client ID and its password the secret, checked with a bind; a public client has
|
||||
none.
|
||||
- Allowed groups limit who may sign in; without them every user may.
|
||||
- **Grants:**
|
||||
- the authorization code with PKCE S256, which redirect URIs give;
|
||||
- `client_credentials`, for confidential clients only;
|
||||
- the device code;
|
||||
- the refresh token for the first and the last.
|
||||
|
||||
The device grant is the router's own grant class on oauthlib's token endpoint, with its
|
||||
authorization endpoint and pages, so it works the same with every oauthlib release. A loopback
|
||||
redirect URI matches with any port (RFC 8252).
|
||||
- **Consent:** a registered client is trusted, so there is no consent page, except for a device:
|
||||
the user who enters its code is asked to allow it, as RFC 8628 advises against remote phishing.
|
||||
- **The sign-in page:** the web service's own, a Jinja2 template with the password, the code and a
|
||||
passkey, so the provider works without the Router Manager.
|
||||
- **Resources, as Entra ID:**
|
||||
- Every service account is a resource. Its scopes are in `vpnRouterResourceScope`, and its roles
|
||||
are `vpnRouterRole` entries (ADR-19) whose members are groups and accounts.
|
||||
- A token request names its resource with `resource` (RFC 8707) or by the resource's scopes,
|
||||
`<resource>:<scope>`, which the client's scopes must allow.
|
||||
- Without a resource, the tokens are for the client.
|
||||
- The grant keeps the resource, so a code, a device code or a refresh token gives tokens for that
|
||||
resource only.
|
||||
- **Tokens:**
|
||||
- Both kinds are RS256 JWTs, signed with an RSA key the web service makes on its first run
|
||||
(`/etc/vpn-router/oidc.key`), published as a JWK named by its RFC 7638 thumbprint; `sub` is
|
||||
the entry's `entryUUID`.
|
||||
- An ID token is for the client and carries the user's roles of the client.
|
||||
- An access token follows RFC 9068: it is for the resource and carries the holder's roles there
|
||||
and the scopes granted, so a resource needs nothing from the router but the JWKS.
|
||||
- Roles are read at every issue.
|
||||
- **Storage:**
|
||||
- Codes, device codes, access and refresh tokens and sessions are kept as their SHA-256 in the
|
||||
database of the refresh tokens.
|
||||
- They are defined in `/usr/share/vpn-router/sql/oidc.sql`, with the procedures and functions
|
||||
the module calls; a process loads the file at its first connection.
|
||||
- The operations that revoke the refresh tokens of `/auth`, of users and of service accounts,
|
||||
also revoke them, and every use of a code, a device code or a token checks the user and the
|
||||
client's allowed groups again.
|
||||
|
||||
Consequences:
|
||||
|
||||
- **Requirements:** the package depends on `python3-oauthlib` and `python3-jwt`. The provider needs
|
||||
HTTPS, a public name and the web service.
|
||||
- **Management:** clients, resources and their roles are managed like service accounts, by the
|
||||
CLI, `vpn-router-role`, `/service-accounts` and the Router Manager.
|
||||
- **The directory keeps itself consistent:**
|
||||
- a group a client allows cannot be removed, and renaming it renames it in the clients and the
|
||||
roles;
|
||||
- a resource scope a client may ask for cannot be removed.
|
||||
- **Revocation:** an access token, a JWT, is valid at a resource until it expires, 5 minutes after
|
||||
issue, whatever the router revokes. The signing key does not rotate; replacing it invalidates
|
||||
every token issued.
|
||||
- **Lockout:** wrong client secrets lock the client like any account.
|
||||
- **Not offered:** the implicit and hybrid flows, the password grant and dynamic client
|
||||
registration.
|
||||
|
||||
## ADR-38: Bash completions are files per command in bash-completion's completions directory
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: The `vpn-*` commands have many subcommands, options and names (users, groups, issuing
|
||||
CAs, templates, settings) that are hard to remember. bash-completion's README recommends its
|
||||
`completionsdir`, `/usr/share/bash-completion/completions` on Ubuntu, whose files it loads on the
|
||||
first Tab of their command, and calls the eagerly loaded `/etc/bash_completion.d` not
|
||||
recommended; it prefers the name `<command>.bash`, which noble's 2.11 and resolute's 2.16 both
|
||||
look up.
|
||||
|
||||
Decision: The package installs `<command>.bash` for each `vpn-*` command into
|
||||
`/usr/share/bash-completion/completions` and recommends `bash-completion`. The completions read
|
||||
the names they offer from the commands themselves (`--json list`) or the package's Python
|
||||
modules (the schema, the configuration, the PKI's templates and reasons), not from copies, and
|
||||
send errors to `/dev/null`, so a user who may not read them gets the fixed words only.
|
||||
|
||||
Consequences: Completions follow the code without being edited for each new setting or name. A
|
||||
Tab that offers names runs a command or Python, which takes a fraction of a second. The files
|
||||
are not conffiles; an administrator overrides one in `/etc/bash_completion.d` or the user's own
|
||||
completions directory.
|
||||
|
||||
## ADR-39: /logs shows the last lines of listed units and files, capped per request
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: The Router Manager should show the router's logs without SSH. The web service runs as
|
||||
root, so a path or a unit taken from a request could expose any file or journal on the router,
|
||||
and a log of gigabytes cannot be sent whole to a browser.
|
||||
|
||||
Decision: `/logs` serves the journal of a fixed list of units, the package's own and the services
|
||||
it runs, or the whole journal, read with `journalctl`, and the files `web.log_files` lists,
|
||||
which `vpn-config-edit` sets; a unit or a path outside the lists is refused. A request answers
|
||||
at most 1000 lines, 200 by default, and a file is read from its last MiB only. The answer is
|
||||
read at each request; nothing is followed or kept.
|
||||
|
||||
Consequences: Only administrators read logs, and only those listed. Showing a new file needs a
|
||||
configuration change, not code. Long histories stay with SSH and `journalctl`.
|
||||
|
||||
## ADR-40: Static web sites are served by the router's Caddy and defined in their own PostgreSQL database
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: The router should host static web sites, each on its own port, with its own TLS and host
|
||||
names told apart by SNI. Caddy already runs as the router's HTTPS proxy, configured by setup from a
|
||||
template, and serves several hosts on one port by SNI. The configuration file holds the router's
|
||||
settings; sites are many rows that change often and are edited by people who are not the router's
|
||||
administrators. A separate role per database keeps a feature's data apart from the refresh tokens
|
||||
and metrics.
|
||||
|
||||
Decision: A site is a row of the `sites` table in the database `vpn-router-sites`, created by the
|
||||
web service like `vpn-router` with its own role and password file, and changed through
|
||||
`vpnrouter_sites` only, by `/sites`, `vpn-router-site` and the Router Manager; the table and its
|
||||
procedures are in `/usr/share/vpn-router/sql/sites.sql`. `sites.enabled` and the upload limit stay
|
||||
in the configuration file. Setup reads the enabled sites at each run and adds a block per site to
|
||||
the Caddyfile, served from `/var/lib/vpn-router/sites/<name>`, with TLS none, Let's Encrypt, an
|
||||
issuing CA of `pki.issuing_cas` through Caddy's internal issuer, or a certificate and key kept
|
||||
with the site; it opens each site's port on the interfaces of its `listen`. Caddy runs while HTTPS
|
||||
is ready or a site is served. A site may not take a port of the router's own services or
|
||||
`wan.local_fqdn`; sites sharing a port need host names, the same `listen` and the same kind of
|
||||
TLS. A site's `editors` group may change its files besides the admins; renaming the group renames
|
||||
it in the sites, and a group a site has cannot be removed.
|
||||
|
||||
Consequences: Sites need no change of the configuration file and are backed up as a database dump
|
||||
with their folders. A site of Let's Encrypt needs its names to resolve to the router and port 80
|
||||
open, which setup opens on the external interface. While the database cannot be read, setup leaves
|
||||
the Caddyfile as it is. A site's folder is kept when the site is removed, unless asked otherwise.
|
||||
|
||||
## ADR-41: Site files are uploaded through the web service and WebDAV served by rclone
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: Content must reach a site's folder. Caddy's WebDAV handler is a third-party module that
|
||||
neither Ubuntu's nor Caddy's packages include; a custom build would leave apt. S3 servers that use
|
||||
a plain folder are rclone 1.65 and later (`serve s3`; noble has 1.60) and versitygw, which has no
|
||||
package. Azurite keeps blobs in its own format, which Caddy cannot serve. rclone 1.60 in the
|
||||
archive serves WebDAV with an auth proxy program and combines folders into one tree. The web
|
||||
service and the Router Manager pass small JSON bodies only.
|
||||
|
||||
Decision: The web service serves a site's files under `/sites/<name>/files/`: listing, download,
|
||||
upload of a raw body streamed to disk up to `sites.upload_max_mb`, extraction of a zip archive
|
||||
that refuses absolute paths, `..`, links and oversize content before writing, folders, renaming
|
||||
and removal, every path resolved inside the site's folder. While `sites.webdav` is true,
|
||||
`vpn-router-webdav.service` runs `rclone serve webdav` on `127.0.0.1:7446` behind Caddy at
|
||||
`https://<wan.local_fqdn>:<sites.webdav_port>`; its auth proxy, `webdav-auth`, binds as the user
|
||||
and gives an admin all sites and any other user the sites of its editors groups, one folder each.
|
||||
rclone and PowerDNS are installed on demand from the archive by `vpn-router-install@<feature>`,
|
||||
like Caddy.
|
||||
|
||||
Consequences: Uploads and WebDAV use the directory's passwords, a TOTP code appended for an
|
||||
enrolled user. rclone writes a WebDAV upload to its cache first and to the folder a few seconds
|
||||
later. S3 and Azure Blob clients are not served.
|
||||
|
||||
## ADR-42: The name server is PowerDNS with its PostgreSQL backend
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: The router hosts authoritative domains as their primary, with optional secondary name
|
||||
servers, and a domain can give the sites their records without a registrar change per site. Ubuntu
|
||||
has BIND, Knot and PowerDNS; only PowerDNS's gpgsql backend reads records
|
||||
from PostgreSQL, so changes are rows, without zone files or reloads. systemd-resolved's stub holds
|
||||
127.0.0.53 and 127.0.0.54, and the remote access clients' resolver answered on the external
|
||||
addresses, the addresses an authoritative server needs. The clients reach the internal address
|
||||
just as well, through the tunnel when it is in `local.cidrs` (ADR-43).
|
||||
|
||||
Decision: While `dns.enabled` is true, setup runs `pdns.service` with
|
||||
`/etc/powerdns/pdns.d/vpn-router.conf`, written before the packages are installed, on the
|
||||
addresses of `dns.listen`, with the gpgsql backend on the database `vpn-router-dns` and PowerDNS's
|
||||
own schema, and `primary=yes`. The domains, their secondaries and 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. `dns.nameserver` and
|
||||
`dns.addresses` stay settings, the router's one identity as the primary NS of every domain. Setup
|
||||
writes each domain as a MASTER zone through the procedure `dns_apply` of
|
||||
`/usr/share/vpn-router/sql/dns.sql`: SOA, NS of the router and the secondaries, A and AAAA records
|
||||
of `dns.addresses` for the apex, the name server and, with the domain's site records switch, every
|
||||
site host name whose closest domain it is, and the manual records; the serial grows only when the
|
||||
records change, which makes PowerDNS notify. A secondary is a name, its NS, and an address, which
|
||||
goes to the domain's `ALSO-NOTIFY` and `ALLOW-AXFR-FROM` metadata, so the global
|
||||
`allow-axfr-ips` stays the loopback default. 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 address,
|
||||
without TSIG, and there is no DNSSEC; the delegation of each domain is made at its parent zone by
|
||||
hand.
|
||||
|
||||
## ADR-43: The remote access clients' resolver is dnsmasq, not systemd-resolved
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: IKEv2 and OpenVPN clients were given the router as their DNS server, answered by
|
||||
systemd-resolved through a `DNSStubListenerExtra` drop-in. resolved synthesizes records for the
|
||||
machine's own host name, all its addresses, the Docker bridge and the tunnel addresses included,
|
||||
and for `_gateway` and `localhost`, on every stub, and systemd 255 has no setting that turns this
|
||||
off. Its extra stub also took port 53 of the addresses PowerDNS needs. dnsmasq-base is in main,
|
||||
a single binary without a service of its own, and forwards to one upstream server.
|
||||
|
||||
Decision: `vpn-router-resolver.service` runs dnsmasq, bound to the addresses of `p2s.dns_listen`
|
||||
only (`bind-interfaces`), forwarding to resolved's stub on 127.0.0.53, without `/etc/hosts` and
|
||||
without forwarding names that have no dot (`domain-needed`). resolved keeps its loopback stubs
|
||||
only and stays the router's own resolver, with the upstream servers of the platform. Setup writes
|
||||
`/etc/vpn-router/dnsmasq.conf` while IKEv2, OpenVPN or WireGuard runs, opens port 53 there for
|
||||
their client networks, and removes the earlier drop-in.
|
||||
|
||||
Consequences: Clients resolve the names resolved resolves, Azure's internal names included, but
|
||||
not the router's synthesized ones. The package depends on dnsmasq-base. PowerDNS and the clients'
|
||||
resolver share no address.
|
||||
|
||||
## 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.
|
||||
@@ -1,3 +1,417 @@
|
||||
vpn-router (0.38.0-1) unstable; urgency=medium
|
||||
|
||||
feature: https.acme_staging takes Caddy's certificates from Let's Encrypt's staging environment.
|
||||
|
||||
* While https.acme_staging is true, Caddy's only CA is Let's Encrypt's
|
||||
staging environment, for the router's services and the web sites
|
||||
with tls acme; its certificates are not trusted by browsers.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 30 Oct 2026 02:34:01 +0100
|
||||
|
||||
vpn-router (0.37.3-1) unstable; urgency=medium
|
||||
|
||||
fix: an answer whose client went away ends quietly, and a shell whose client left before it opened is stopped.
|
||||
|
||||
* The web service, the metrics service and the bootstrap server stop
|
||||
writing an answer when its client closes the connection, and a site
|
||||
file whose download is left no longer gets a second answer.
|
||||
* A shell whose client closed the connection before it opened is
|
||||
stopped and its end is logged, instead of running on.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 30 Oct 2026 02:03:26 +0100
|
||||
|
||||
vpn-router (0.37.2-1) unstable; urgency=medium
|
||||
|
||||
fix: the web service, the metrics service and the bootstrap server log a failure in one line instead of a traceback, and nothing when a client goes away.
|
||||
|
||||
* A request whose client closed the connection is no longer logged.
|
||||
* Any other failure of a request, a thread or the service is logged in
|
||||
one line with the exception's type and message.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 30 Oct 2026 01:59:19 +0100
|
||||
|
||||
vpn-router (0.37.1-1) unstable; urgency=medium
|
||||
|
||||
fix: /config answers the OpenID Provider's state and issuer.
|
||||
|
||||
* /config has oidc: enabled, and issuer, the issuer URL, null while the
|
||||
provider is disabled.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 30 Oct 2026 01:53:55 +0100
|
||||
|
||||
vpn-router (0.37.0-1) unstable; urgency=medium
|
||||
|
||||
feature: the OpenID Provider has the client credentials and device authorization grants, public clients, and service accounts as resources with roles and scopes in JWT access tokens.
|
||||
|
||||
* A client may have the grants client_credentials, by which a
|
||||
confidential client gets an access token as its own service account,
|
||||
and the device code (RFC 8628): POST /oidc/device_authorization gives
|
||||
a device code and a user code, the user enters the code on
|
||||
/oidc/device, signs in and allows or denies the device, and the device
|
||||
polls /oidc/token. A public client has no secret.
|
||||
* An http loopback redirect URI matches with any port.
|
||||
* Every service account is a resource: it defines scopes, which clients
|
||||
ask for as <resource>:<scope> when their client scopes allow them, and
|
||||
roles, assigned to groups, users and service accounts. A token request
|
||||
names its resource with resource (RFC 8707) or by its scopes.
|
||||
* Access tokens are RS256 JWTs of RFC 9068 for the resource, or the
|
||||
client, with client_id, scope, roles and the claims of the scopes; ID
|
||||
tokens and /oidc/userinfo carry the user's roles of the client.
|
||||
* vpn-router-user takes --grant, --public-client, --client-scope and
|
||||
--resource-scope; the new vpn-router-role manages the roles; and
|
||||
/service-accounts takes and answers grants, public_client,
|
||||
client_scopes, resource_scopes and roles.
|
||||
* Changing the password of a service account, disabling, unlocking or
|
||||
removing it through /service-accounts revokes its tokens.
|
||||
* POST /oidc/device and /oidc/device_authorization are rate limited as
|
||||
/oidc/login is.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 30 Oct 2026 00:57:11 +0100
|
||||
|
||||
vpn-router (0.36.0-1) unstable; urgency=medium
|
||||
|
||||
feature: a shell opens with one of the user's passkeys in place of the password and the TOTP code.
|
||||
|
||||
* POST /shell/passkey begins a proof for one of the signed-in user's own
|
||||
passkeys, with user verification; POST /shell takes the browser's
|
||||
answer as {"passkey": {"id", "credential"}} in place of the password
|
||||
and the code, and gives the ticket of the shell.
|
||||
* A passkey that is not accepted is refused with 403. The shell still
|
||||
needs the user's TOTP enrolment, which its login session and sudo in
|
||||
it need.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Thu, 29 Oct 2026 23:09:43 +0100
|
||||
|
||||
vpn-router (0.35.2-2) unstable; urgency=medium
|
||||
|
||||
update: the package is published for Ubuntu 26.04 (resolute) as well as 24.04 (noble).
|
||||
|
||||
* publish.sh uploads the package to the distributions noble and resolute
|
||||
of the repository, component main, skipping a distribution that has
|
||||
the version already; DISTRIBUTIONS names others. A router on Ubuntu
|
||||
26.04 installs vpn-router from the distribution resolute.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Thu, 29 Oct 2026 21:41:32 +0100
|
||||
|
||||
vpn-router (0.35.2-1) unstable; urgency=medium
|
||||
|
||||
fix: the web service answers /, /status and /status/full again while IKEv2 runs.
|
||||
|
||||
* The status of IKEv2 clients is collected again; the requests that
|
||||
collect the status were dropped with a NameError since 0.35.1.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Thu, 29 Oct 2026 19:03:16 +0100
|
||||
|
||||
vpn-router (0.35.1-1) unstable; urgency=medium
|
||||
|
||||
update: the router web service is split into the package vpnrouter_web, with one module per family of paths.
|
||||
|
||||
* /usr/lib/vpn-router/web initializes and serves; the package
|
||||
/usr/lib/vpn-router/vpnrouter_web/ answers the requests, with a module
|
||||
for the status, the sign-in, the identities, the connections, backups,
|
||||
the PKI, logs, web sites, DNS, metrics and the shell. The paths, the
|
||||
answers and the log messages are the same.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Thu, 29 Oct 2026 18:20:50 +0100
|
||||
|
||||
vpn-router (0.35.0-1) unstable; urgency=medium
|
||||
|
||||
feature: vre opens files from the Router Manager's shell in the editor of its Shell page.
|
||||
|
||||
* vpn-router-editor FILE..., or vre, in the shell of the Router Manager
|
||||
opens the files in the editor of the Shell page and returns at once;
|
||||
outside that shell it exits with status 2.
|
||||
* GET, PUT and DELETE /shell/editor/<session>/<id> read, save and close
|
||||
a file opened so, as the shell's console account; a save is refused
|
||||
with 409 when the file changed since it was read.
|
||||
* The editor takes regular files of UTF-8 text up to 1 MiB, at most 20
|
||||
per shell, while the shell lives.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Thu, 29 Oct 2026 18:02:27 +0100
|
||||
|
||||
vpn-router (0.34.0-1) unstable; urgency=medium
|
||||
|
||||
feature: the web service shows and changes the settings of the metrics service at /metrics/settings, and OpenVPN starts on a new router, as setup installs CRLs that revoke nothing.
|
||||
|
||||
* GET and PUT /metrics/settings show and change the metrics section;
|
||||
otlp_password is never shown, an empty one keeps it, and an empty
|
||||
otlp_username removes it.
|
||||
* A CRL without revoked certificates is installed for strongSwan and
|
||||
OpenVPN.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Wed, 28 Oct 2026 22:00:00 +0100
|
||||
|
||||
vpn-router (0.33.3-1) unstable; urgency=medium
|
||||
|
||||
fix: vpn-router-setup runs on a new installation once libpq can be found.
|
||||
|
||||
* The vpn-router-setup unit waits, with wait-for-libpq, until libpq is in
|
||||
the linker cache before it runs setup.
|
||||
* The package starts vpn-router-setup without waiting for it.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Wed, 28 Oct 2026 21:00:00 +0100
|
||||
|
||||
vpn-router (0.33.2-1) unstable; urgency=medium
|
||||
|
||||
fix: the router's IPsec remote access is called IKEv2 instead of road-warrior.
|
||||
|
||||
* The README, the package description, the installer's questions, the
|
||||
status page, the messages of the web service and the examples say
|
||||
IKEv2 access and IKEv2 clients; the strongSwan connection keeps its
|
||||
name, road-warrior.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 23:00:00 +0100
|
||||
|
||||
vpn-router (0.33.1-1) unstable; urgency=medium
|
||||
|
||||
fix: the name server is the primary of any number of domains, each with secondary name servers and a switch for the records of the sites.
|
||||
|
||||
* The domains, their secondaries and manual records are kept in the
|
||||
vpn-router-dns database and changed through /dns/domains; dns.zone is
|
||||
removed.
|
||||
* Each domain is served as a primary zone: the router and its
|
||||
secondaries are its NS, and each secondary's address is notified of
|
||||
changes and may transfer the zone.
|
||||
* A domain with site records gives the host names of the enabled sites
|
||||
in it A and AAAA records of dns.addresses; a domain inside another is
|
||||
delegated from it.
|
||||
* Manual records belong to a domain; those of the single zone are
|
||||
dropped.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 20:00:00 +0100
|
||||
|
||||
vpn-router (0.33.0-1) unstable; urgency=medium
|
||||
|
||||
feature: road-warrior clients resolve the domains of the new setting p2s.dns_domains with the router's resolver.
|
||||
|
||||
* p2s.dns_domains lists up to 16 domains; setup pushes them to IKEv2
|
||||
clients as INTERNAL_DNS_DOMAIN (RFC 8598, attribute 25) of the
|
||||
road-warrior pool.
|
||||
* The Apple VPN profile names them as match domains of the router's
|
||||
resolver, which macOS and iOS use for those domains.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 18:00:00 +0100
|
||||
|
||||
vpn-router (0.32.1-1) unstable; urgency=medium
|
||||
|
||||
fix: a VPN user downloads the own OpenVPN client profile through /account, with the current password and code.
|
||||
|
||||
* POST /account/profile answers the user's OpenVPN client profile; it
|
||||
needs the current password and, when enrolled, a code.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 16:00:00 +0100
|
||||
|
||||
vpn-router (0.32.0-1) unstable; urgency=medium
|
||||
|
||||
feature: the web service gives Apple VPN profiles that set up IKEv2 road-warrior access on macOS, iOS and iPadOS.
|
||||
|
||||
* POST /users/<name>/apple-profile and POST /account/apple-profile answer
|
||||
an unsigned .mobileconfig with the P2S CA and the root CA, the user's
|
||||
certificate and key as a PKCS#12 encrypted with the password of the
|
||||
request, and an IKEv2 VPN to wan.local_fqdn with EAP-TLS and the
|
||||
proposals of the road-warrior connection.
|
||||
* A user's own profile needs the current password and, when enrolled, a
|
||||
code.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 14:00:00 +0100
|
||||
|
||||
vpn-router (0.31.3-1) unstable; urgency=medium
|
||||
|
||||
fix: WireGuard clients may use the router's DNS resolver, which runs while WireGuard does, and /remote-access names its addresses for their configuration.
|
||||
|
||||
* The firewall opens port 53 of the remote access clients' resolver for
|
||||
the WireGuard network, as it does for the IKEv2 and OpenVPN pools.
|
||||
* The resolver runs while WireGuard is the only remote access enabled.
|
||||
* GET /remote-access answers the resolver's addresses as
|
||||
wireguard_server.dns.
|
||||
* dns.listen may not share an interface with p2s.dns_listen while
|
||||
WireGuard is enabled either.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 12:00:00 +0100
|
||||
|
||||
vpn-router (0.31.2-1) unstable; urgency=medium
|
||||
|
||||
fix: dnsmasq answers the remote access clients' DNS queries, so they no longer get the router's own host name with all its addresses from systemd-resolved.
|
||||
|
||||
* vpn-router-resolver.service runs dnsmasq on the addresses of
|
||||
p2s.dns_listen, forwarding to systemd-resolved's stub on 127.0.0.53;
|
||||
names without a dot are not forwarded. The package depends on
|
||||
dnsmasq-base.
|
||||
* systemd-resolved keeps its loopback stubs only; setup removes the
|
||||
drop-in that made it listen for the clients.
|
||||
* /logs shows the journal of vpn-router-resolver, vpn-router-webdav and
|
||||
pdns.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 11:00:00 +0100
|
||||
|
||||
vpn-router (0.31.1-1) unstable; urgency=medium
|
||||
|
||||
fix: the remote access clients' resolver answers on the internal address by default, chosen by p2s.dns_listen, so the name server can take port 53 of the external addresses.
|
||||
|
||||
* The new setting p2s.dns_listen chooses the addresses of the resolver the
|
||||
road-warrior and OpenVPN clients are given: internal, the default,
|
||||
external or both. The firewall opens port 53 for the client pools on
|
||||
those addresses only.
|
||||
* dns.listen is refused only when it shares an interface with
|
||||
p2s.dns_listen while IKEv2 or OpenVPN is enabled.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 23:00:00 +0100
|
||||
|
||||
vpn-router (0.31.0-1) unstable; urgency=medium
|
||||
|
||||
feature: Caddy serves static web sites from folders on the router, defined in their own database, with files uploaded through the web service or WebDAV, and an optional PowerDNS name server answers for a zone of the sites.
|
||||
|
||||
* The new database vpn-router-sites holds the web sites: host names,
|
||||
port, interfaces, TLS (none, Let's Encrypt, an issuing CA or a
|
||||
certificate and key), index file, single-page fallback and the group
|
||||
whose members change the files. sites.enabled serves them from
|
||||
/var/lib/vpn-router/sites/<name>; setup writes their blocks into the
|
||||
Caddyfile and opens their ports, and Caddy also runs without HTTPS of
|
||||
the router while a site is served.
|
||||
* vpn-router-site lists, shows, adds, changes and removes the sites, and
|
||||
bash completes it.
|
||||
* /sites manages the sites and the settings of the sites section, and
|
||||
under /sites/<name>/files/ lists, downloads, uploads, extracts zip
|
||||
archives into, creates, renames and removes the files of a site, up to
|
||||
sites.upload_max_mb; members of the site's editors group may use its
|
||||
files.
|
||||
* sites.webdav runs rclone's WebDAV server of the sites' folders behind
|
||||
Caddy on sites.webdav_port, signing users in with the directory and
|
||||
showing each the sites it may change.
|
||||
* dns.enabled runs PowerDNS with its PostgreSQL backend in the new
|
||||
database vpn-router-dns for dns.zone on the addresses of dns.listen:
|
||||
SOA, NS, the A and AAAA records of dns.addresses for the zone, the
|
||||
name server and the sites' host names in the zone, and manual records
|
||||
added with /dns.
|
||||
* rclone and PowerDNS are installed from the Ubuntu archive by
|
||||
vpn-router-install@<feature>.service when their feature is on.
|
||||
* Backups hold the sites' folders and the dumps of both databases; a
|
||||
restore puts them back. Purge removes the databases, their roles and
|
||||
password files, and the settings of PowerDNS.
|
||||
* Renaming a group renames it in the sites; a group a site has cannot be
|
||||
removed.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 21:00:00 +0100
|
||||
|
||||
vpn-router (0.30.0-1) unstable; urgency=medium
|
||||
|
||||
feature: the web service serves the last lines of the journal of the router's units and of the log files web.log_files lists, at /logs.
|
||||
|
||||
* GET /logs lists the units whose journal and the files whose lines it
|
||||
serves; GET /logs/journal?unit=&lines= and /logs/file?path=&lines=
|
||||
answer the last lines, 200 by default and at most 1000, a file read
|
||||
from its last MiB. Units and files outside the lists are refused.
|
||||
* The new setting web.log_files lists the absolute paths of the log
|
||||
files, at most 32.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 19:00:00 +0100
|
||||
|
||||
vpn-router (0.29.0-1) unstable; urgency=medium
|
||||
|
||||
feature: vpn-config-edit set and remove take --restart, which applies the change with vpn-router-setup and waits for it.
|
||||
|
||||
* vpn-config-edit set --restart and remove --restart restart
|
||||
vpn-router-setup after writing the configuration and wait until it has
|
||||
applied the change; a failed run exits with status 1 and points to its
|
||||
journal. Bash completes --restart.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 17:00:00 +0100
|
||||
|
||||
vpn-router (0.28.0-1) unstable; urgency=medium
|
||||
|
||||
feature: bash completes the vpn-* commands, their options and the names they take.
|
||||
|
||||
* Bash completes the commands, options and arguments of vpn-router-user,
|
||||
vpn-router-group, vpn-router-token, vpn-config-edit, vpn-router-backup,
|
||||
vpn-router-ca and vpn-router-caddy, from
|
||||
/usr/share/bash-completion/completions; for root also the users,
|
||||
groups, issuing CAs, templates, settings and site-to-site connections.
|
||||
* The package recommends bash-completion.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 16:00:00 +0100
|
||||
|
||||
vpn-router (0.27.0-1) unstable; urgency=medium
|
||||
|
||||
feature: the web service is an OpenID Provider, which signs the directory's users in to other systems, the clients being service accounts with redirect URIs.
|
||||
|
||||
* oidc.enabled turns on the OpenID Provider at the issuer
|
||||
https://<wan.local_fqdn>:<https.web_port>, with discovery, JWKS,
|
||||
authorize, token, userinfo and RP-initiated logout under /oidc/,
|
||||
answered only through Caddy. The authorization code flow with PKCE
|
||||
S256 and refresh tokens are offered; ID tokens are RS256, signed with
|
||||
/etc/vpn-router/oidc.key, which the web service makes on its first run.
|
||||
* Users sign in on the provider's page with the password and the TOTP
|
||||
code or with a passkey; the session lasts 8 hours. The scopes openid,
|
||||
profile, email and groups give sub (the entry's entryUUID), the names,
|
||||
the e-mail address and the user's groups.
|
||||
* A service account with redirect URIs is a client: its name is the
|
||||
client ID and its password the secret. Its redirect URIs,
|
||||
post-logout redirect URIs and allowed groups are set with
|
||||
vpn-router-user add/update --redirect-uri, --post-logout-redirect-uri
|
||||
and --allowed-group, and in /service-accounts as redirect_uris,
|
||||
post_logout_redirect_uris and allowed_groups; GET /service-accounts
|
||||
also answers the issuer. vpn-router-user list shows them.
|
||||
* Changing a user's password, disabling, unlocking or removing the user
|
||||
also revokes the user's codes, tokens and sessions of the provider.
|
||||
* Renaming a group renames it in the clients that allow it, and a group
|
||||
a client allows cannot be removed.
|
||||
* The package depends on python3-oauthlib and python3-jwt.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 14:00:00 +0100
|
||||
|
||||
vpn-router (0.26.2-1) unstable; urgency=medium
|
||||
|
||||
fix: a service account may be named by a GUID in lowercase, as the Router Manager proposes for a new one.
|
||||
|
||||
* vpn-router-user add --service and POST /service-accounts take a GUID
|
||||
in lowercase as the name, besides a user name; users and groups keep
|
||||
the rules of user names.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 10:00:00 +0100
|
||||
|
||||
vpn-router (0.26.1-1) unstable; urgency=medium
|
||||
|
||||
fix: the package installs vpnrouter_passkeys, without which the web service and vpn-router-user do not start.
|
||||
|
||||
* /usr/lib/vpn-router/vpnrouter_passkeys.py is part of the package.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 08:45:00 +0100
|
||||
|
||||
vpn-router (0.26.0-1) unstable; urgency=medium
|
||||
|
||||
feature: users sign in to the Router Manager with passkeys, and console login needs a TOTP token, so a user without one is refused the shell, SSH and sudo.
|
||||
|
||||
* Console login is switched on only for a user enrolled for TOTP. A user
|
||||
whose console login is on without a token is refused POST /shell, the
|
||||
shell's WebSocket, SSH and sudo; an open shell ends within a minute of
|
||||
the token's removal or of console login being switched off, and
|
||||
removing the token ends the sessions of the user's own POSIX account.
|
||||
* POST /auth/passkey and /auth/passkey/complete sign a user in with a
|
||||
passkey of wan.local_fqdn, giving the tokens of /auth; POST /auth also
|
||||
answers the user's name.
|
||||
* POST, PUT /account/passkeys and DELETE /account/passkeys/<id> add, with
|
||||
the current password and code, and remove the user's passkeys, at most
|
||||
10, kept in the new attribute vpnRouterPasskey; DELETE
|
||||
/users/<name>/passkeys and vpn-router-user passkeys-remove remove all
|
||||
passkeys of a user.
|
||||
* Users have passkeys, [{"id", "label", "created"}], in /users and
|
||||
/account; vpn-router-user list shows their number and a console login
|
||||
refused for want of a TOTP token.
|
||||
* The package depends on python3-fido2.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 03 Nov 2026 08:00:00 +0100
|
||||
|
||||
vpn-router (0.25.4-1) unstable; urgency=medium
|
||||
|
||||
fix: a shell opens without the password and code again while the user's last use of a shell is within web.shell_auth_timeout minutes.
|
||||
|
||||
* New setting web.shell_auth_timeout, 15 minutes by default, 0 to 1440:
|
||||
POST /shell without a password gives a ticket while the user's last
|
||||
sign-in of POST /shell or keystroke in a shell is within it, and a
|
||||
null ticket otherwise; 0 asks for the password and code every time.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 03 Nov 2026 06:00:00 +0100
|
||||
|
||||
vpn-router (0.25.3-1) unstable; urgency=medium
|
||||
|
||||
fix: /config gives the state of caddy.service with the installed Caddy, for the Router Manager's Web page.
|
||||
|
||||
@@ -22,6 +22,9 @@ Depends: ${misc:Depends},
|
||||
python3-jinja2,
|
||||
python3-ldap,
|
||||
python3-pyotp,
|
||||
python3-fido2,
|
||||
python3-jwt,
|
||||
python3-oauthlib,
|
||||
python3-psycopg,
|
||||
python3-psutil,
|
||||
python3-prometheus-client,
|
||||
@@ -35,20 +38,27 @@ 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. It keeps metrics of the
|
||||
archive or from Caddy's repository, serves HTTPS and static web sites, whose
|
||||
files are uploaded through the web service or WebDAV, an optional PowerDNS
|
||||
name server answers for a zone of the sites, and an optional OpenID
|
||||
Provider signs the directory's users in to other systems. It keeps metrics of the
|
||||
router in PostgreSQL and serves them for an OpenTelemetry Collector, and
|
||||
makes scheduled backups to a local folder, copied to the cloud with rclone,
|
||||
or to a Proxmox Backup Server. Nothing in the package is specific to a cloud
|
||||
|
||||
@@ -2,3 +2,4 @@ etc/vpn-router
|
||||
usr/lib/vpn-router
|
||||
usr/lib/vpn-router/vpnrouter_platforms
|
||||
usr/share/vpn-router/templates
|
||||
var/lib/vpn-router/sites
|
||||
@@ -4,29 +4,42 @@ src/usr/lib/vpn-router/vpnrouter_ldap.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_net.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_serve.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_users.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_passkeys.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_auth.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_oidc.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_metrics.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_status.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_backup.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_pki.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_sites.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_dns.py usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/install-packages usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/webdav-auth usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/openvpn-same-user usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/openvpn-issuer usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/editor-file usr/lib/vpn-router/
|
||||
src/usr/bin/vpn-router-editor usr/bin/
|
||||
src/usr/sbin/vpn-router-user usr/sbin/
|
||||
src/usr/sbin/vpn-router-group usr/sbin/
|
||||
src/usr/sbin/vpn-router-role usr/sbin/
|
||||
src/usr/sbin/vpn-router-token usr/sbin/
|
||||
src/usr/sbin/vpn-config-edit usr/sbin/
|
||||
src/usr/sbin/vpn-router-backup usr/sbin/
|
||||
src/usr/sbin/vpn-router-ca usr/sbin/
|
||||
src/usr/sbin/vpn-router-caddy usr/sbin/
|
||||
src/usr/sbin/vpn-router-site usr/sbin/
|
||||
src/usr/lib/vpn-router/configure usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/docker-rules usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/setup usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/web usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_web/*.py usr/lib/vpn-router/vpnrouter_web/
|
||||
src/usr/lib/vpn-router/metrics usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/bootstrap usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/wait-for-libpq usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/generate-config usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_platforms/*.py usr/lib/vpn-router/vpnrouter_platforms/
|
||||
src/usr/share/vpn-router/templates/* usr/share/vpn-router/templates/
|
||||
src/usr/share/vpn-router/sql/* usr/share/vpn-router/sql/
|
||||
src/usr/share/bash-completion/completions/*.bash usr/share/bash-completion/completions/
|
||||
src/usr/lib/systemd/system/caddy.service.d/vpn-router.conf usr/lib/systemd/system/caddy.service.d/
|
||||
src/usr/share/doc/vpn-router/vpn-router.json.example usr/share/doc/vpn-router/
|
||||
@@ -0,0 +1 @@
|
||||
usr/bin/vpn-router-editor usr/bin/vre
|
||||
@@ -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,12 +46,15 @@ case "$1" in
|
||||
rm -f /etc/swanctl/conf.d/site-to-site.conf \
|
||||
/etc/swanctl/conf.d/road-warrior.conf \
|
||||
/etc/systemd/resolved.conf.d/p2s-forwarder.conf \
|
||||
/etc/vpn-router/dnsmasq.conf \
|
||||
/etc/wireguard/wg0.conf \
|
||||
/etc/openvpn/server/vpn-router.conf \
|
||||
/etc/openvpn/server/vpn-router-ldap.conf \
|
||||
/etc/caddy/vpn-router.Caddyfile \
|
||||
/etc/powerdns/pdns.d/vpn-router.conf \
|
||||
/etc/vpn-router/vpn-router.json \
|
||||
/etc/vpn-router/web.key \
|
||||
/etc/vpn-router/oidc.key \
|
||||
/etc/vpn-router/metrics.secret \
|
||||
/etc/systemd/system/vpn-router-backup.timer.d/schedule.conf \
|
||||
/etc/sudoers.d/vpn-router \
|
||||
@@ -62,7 +65,7 @@ case "$1" in
|
||||
# The name service no longer looks up the console accounts of the router's directory.
|
||||
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
|
||||
|
||||
@@ -73,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
|
||||
+15
-10
@@ -11,6 +11,8 @@ esac
|
||||
OWNER="${OWNER:-slawek}"
|
||||
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']:
|
||||
@@ -587,7 +620,7 @@ def manage_console(cfg):
|
||||
f'base {vpnrouter_ldap.BASE}',
|
||||
'filter passwd (&(objectClass=posixAccount)(vpnRouterConsoleLogin=TRUE))',
|
||||
f'nss_min_uid {vpnrouter_ldap.CONSOLE_UID_MIN}',
|
||||
'pam_authz_search (&(objectClass=posixAccount)(uid=$username)(vpnRouterConsoleLogin=TRUE))',
|
||||
'pam_authz_search (&(objectClass=posixAccount)(objectClass=oathTOTPUser)(uid=$username)(vpnRouterConsoleLogin=TRUE))',
|
||||
'pam_password_prohibit_message The password is changed in the Router Manager or with vpn-router-user.',
|
||||
'',
|
||||
])
|
||||
@@ -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,71 @@ 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 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 domains
|
||||
with site records get the host names of the enabled sites, and 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, [host for site in ctx['sites'] for host in site['hosts']])
|
||||
except (OSError, KeyError, vpnrouter_dns.DatabaseError) as exc:
|
||||
warn(f'the name server cannot be set up: {exc}')
|
||||
return
|
||||
if not unit_is_active(vpnrouter_dns.UNIT):
|
||||
run(['systemctl', 'enable', '--now', vpnrouter_dns.UNIT], check=False)
|
||||
elif changed:
|
||||
run(['systemctl', 'restart', vpnrouter_dns.UNIT], check=False)
|
||||
|
||||
|
||||
def manage_metrics(ctx):
|
||||
"""Run the metrics service while metrics are enabled, and stop it when not.
|
||||
|
||||
@@ -795,6 +954,7 @@ def refresh_web(cfg, ctx):
|
||||
'internal_address': ctx['int_addr'],
|
||||
'internal_gateway': ctx['int_gateway_ip'],
|
||||
'local_cidrs': ctx['local_cidr_list'],
|
||||
'client_dns': ctx['p2s_resolver_addrs'] if vpnrouter.RESOLVER_CONF.exists() else [],
|
||||
'site_to_site': [{
|
||||
'name': connection['name'],
|
||||
'local_identity': connection['local_id'],
|
||||
@@ -863,6 +1023,16 @@ def main():
|
||||
ctx, more = vpnrouter.configure(cfg, public_ip=address)
|
||||
changed |= more
|
||||
|
||||
# The enabled sites while sites are on, None while they cannot be read.
|
||||
ctx['sites'] = []
|
||||
if cfg['sites']['enabled']:
|
||||
progress('running', 'web sites')
|
||||
try:
|
||||
ctx['sites'] = vpnrouter_sites.served()
|
||||
except vpnrouter_sites.DatabaseError as exc:
|
||||
warn(str(exc))
|
||||
ctx['sites'] = None
|
||||
|
||||
progress('running', 'WireGuard keys')
|
||||
setup_wireguard(ctx)
|
||||
|
||||
@@ -900,12 +1070,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')
|
||||
@@ -53,14 +58,19 @@ SCHEMA = {
|
||||
'wan': {'local_fqdn': ''},
|
||||
'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},
|
||||
'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, 'nameserver': '', 'addresses': [], 'listen': 'external', 'ttl': 300},
|
||||
'pki': {'root_name': 'VPN Router Root CA', 'root_days': 7300, 'ca_days': 3650, 'key': 'rsa-4096',
|
||||
'crl_days': 7, 'root_crl_days': 180, 'publish': ['http'], 'p2s_name': 'VPN Router P2S CA',
|
||||
's2s_name': 'VPN Router Site-to-Site CA', 'issuing_cas': {}, 'templates': {},
|
||||
@@ -125,9 +135,39 @@ ACME_HTTP_PORT = 80
|
||||
#: The default port of HTTPS, where Caddy publishes the CA files when pki.publish has https-default.
|
||||
HTTPS_DEFAULT_PORT = 443
|
||||
|
||||
#: The most files web.log_files lists.
|
||||
LOG_FILES_MAX = 32
|
||||
|
||||
#: The port rclone serves WebDAV on, on the loopback address, behind Caddy.
|
||||
WEBDAV_LOOPBACK_PORT = 7446
|
||||
|
||||
#: The largest sites.upload_max_mb, 10 GiB.
|
||||
UPLOAD_MAX_MB = 10240
|
||||
|
||||
#: The shortest and the longest dns.ttl, in seconds.
|
||||
DNS_TTL_RANGE = (60, 86400)
|
||||
|
||||
#: A DNS name without a wildcard, in lowercase, of dns.nameserver 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')
|
||||
@@ -201,7 +241,7 @@ CA_NAME_MAX = 64
|
||||
#: What / and /status of the web service show, web.status_content, from the least to the most.
|
||||
STATUS_CONTENTS = ('basic', 'standard', 'full')
|
||||
|
||||
#: The longest web.shell_idle_minutes, a day.
|
||||
#: The longest web.shell_idle_minutes and web.shell_auth_timeout, a day.
|
||||
SHELL_IDLE_MAX = 1440
|
||||
|
||||
DOCKER_MODES = ('auto', 'on', 'off')
|
||||
@@ -499,6 +539,9 @@ def validate(cfg):
|
||||
shell_idle = _get(cfg, 'web', 'shell_idle_minutes')
|
||||
if not 1 <= shell_idle <= SHELL_IDLE_MAX:
|
||||
raise ConfigError(f'web.shell_idle_minutes: {shell_idle} is not between 1 and {SHELL_IDLE_MAX}')
|
||||
shell_auth = _get(cfg, 'web', 'shell_auth_timeout')
|
||||
if not 0 <= shell_auth <= SHELL_IDLE_MAX:
|
||||
raise ConfigError(f'web.shell_auth_timeout: {shell_auth} is not between 0 and {SHELL_IDLE_MAX}')
|
||||
|
||||
https_ports = []
|
||||
for option in ('web_port', 'manager_port'):
|
||||
@@ -514,10 +557,58 @@ def validate(cfg):
|
||||
for item in _get_list(cfg, 'web', 'trusted_proxies'):
|
||||
_check_address(item, 'web.trusted_proxies')
|
||||
|
||||
log_files = _get_list(cfg, 'web', 'log_files')
|
||||
if len(log_files) > LOG_FILES_MAX:
|
||||
raise ConfigError(f'web.log_files: at most {LOG_FILES_MAX} files')
|
||||
for item in log_files:
|
||||
if not item.startswith('/') or '/../' in f'{item}/':
|
||||
raise ConfigError(f'web.log_files: {item!r} is not an absolute path')
|
||||
|
||||
caddy_source = _get(cfg, 'https', 'caddy_source')
|
||||
if caddy_source not in CADDY_SOURCES:
|
||||
raise ConfigError(f'https.caddy_source: {caddy_source!r} is not one of {", ".join(CADDY_SOURCES)}')
|
||||
|
||||
if _get(cfg, 'oidc', 'enabled') and not (_get(cfg, 'web', 'enabled') and _get(cfg, 'https', 'enabled')
|
||||
and _get(cfg, 'wan', 'local_fqdn')):
|
||||
raise ConfigError('oidc.enabled: the OpenID Provider is the web service served by Caddy at '
|
||||
'https://<wan.local_fqdn>:<https.web_port>, which needs web.enabled, https.enabled and '
|
||||
'wan.local_fqdn')
|
||||
|
||||
upload_max = _get(cfg, 'sites', 'upload_max_mb')
|
||||
if not 1 <= upload_max <= UPLOAD_MAX_MB:
|
||||
raise ConfigError(f'sites.upload_max_mb: {upload_max} is not between 1 and {UPLOAD_MAX_MB}')
|
||||
if _get(cfg, 'sites', 'webdav') and not (_get(cfg, 'https', 'enabled') and _get(cfg, 'wan', 'local_fqdn')):
|
||||
raise ConfigError('sites.webdav: WebDAV is served by Caddy at https://<wan.local_fqdn>:<sites.webdav_port>, '
|
||||
'which needs https.enabled and wan.local_fqdn')
|
||||
|
||||
dns = cfg['dns']
|
||||
if dns['enabled']:
|
||||
if not ZONE_NAME.match(dns['nameserver'].strip()):
|
||||
raise ConfigError(f'dns.nameserver: {dns["nameserver"]!r} is not a DNS name in lowercase')
|
||||
if not _get_list(cfg, 'dns', 'addresses'):
|
||||
raise ConfigError('dns.enabled needs dns.addresses, the public addresses of the name server and the sites')
|
||||
for item in _get_list(cfg, 'dns', 'addresses'):
|
||||
_check_address(item, 'dns.addresses')
|
||||
if dns['listen'] not in LISTEN_CHOICES:
|
||||
raise ConfigError(f'dns.listen: {dns["listen"]!r} is not one of {", ".join(LISTEN_CHOICES)}')
|
||||
if not DNS_TTL_RANGE[0] <= dns['ttl'] <= DNS_TTL_RANGE[1]:
|
||||
raise ConfigError(f'dns.ttl: {dns["ttl"]} is not between {DNS_TTL_RANGE[0]} and {DNS_TTL_RANGE[1]} seconds')
|
||||
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():
|
||||
@@ -608,13 +699,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:
|
||||
@@ -743,6 +834,21 @@ 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')
|
||||
return f'https://{_get(cfg, "wan", "local_fqdn")}' + ('' if port == HTTPS_DEFAULT_PORT else f':{port}')
|
||||
|
||||
|
||||
def resolve_psk(connection, name):
|
||||
"""Return the pre-shared key of the site_to_site connection: psk_file wins, else psk; '' when none is configured."""
|
||||
psk_file = connection['psk_file'].strip()
|
||||
@@ -851,6 +957,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():
|
||||
@@ -894,9 +1004,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,
|
||||
@@ -912,7 +1026,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),
|
||||
@@ -924,6 +1038,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'),
|
||||
@@ -932,6 +1047,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,
|
||||
}
|
||||
|
||||
@@ -948,7 +1068,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'])
|
||||
|
||||
|
||||
@@ -971,6 +1091,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)),
|
||||
@@ -1064,7 +1194,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()
|
||||
@@ -1121,11 +1251,15 @@ def configure(cfg=None, public_ip=None):
|
||||
elif remove(ROAD_WARRIOR):
|
||||
changed.add('swanctl')
|
||||
|
||||
if p2s_ready(ctx) or openvpn_ready(ctx):
|
||||
if render(env, ctx, 'p2s-forwarder.conf.j2', P2S_FORWARDER, 0o644):
|
||||
changed.add('resolved')
|
||||
elif remove(P2S_FORWARDER):
|
||||
# The resolver of the remote access clients is dnsmasq on the addresses of p2s.dns_listen; systemd-resolved keeps
|
||||
# its loopback stub only, so its records of the router's own name stay on the router.
|
||||
if remove(P2S_FORWARDER):
|
||||
changed.add('resolved')
|
||||
if (p2s_ready(ctx) or openvpn_ready(ctx) or wg_ready(ctx)) and ctx['p2s_resolver_addrs']:
|
||||
if render(env, ctx, 'dnsmasq.conf.j2', RESOLVER_CONF, 0o644):
|
||||
changed.add('resolver')
|
||||
elif remove(RESOLVER_CONF):
|
||||
changed.add('resolver')
|
||||
|
||||
if wg_ready(ctx):
|
||||
if render_wg_conf(env, ctx):
|
||||
|
||||
@@ -10,6 +10,9 @@ Every failure of the database raises DatabaseError, the only database exception
|
||||
Refresh tokens are random strings kept in PostgreSQL as their SHA-256, in the database of
|
||||
web.database, by default the vpn-router database on this host that provision() creates on the
|
||||
web service's first run, with the password in DATABASE_SECRET. A refresh token is used once: refreshing replaces it.
|
||||
The same database holds the state of the OpenID Provider (vpnrouter_oidc), whose tables and procedures OIDC_SQL
|
||||
defines; the first connection of a process loads it. revoke() removes a user's refresh tokens and OpenID Provider
|
||||
codes, tokens and sessions.
|
||||
"""
|
||||
|
||||
import base64
|
||||
@@ -26,6 +29,7 @@ import psycopg
|
||||
|
||||
WEB_KEY = pathlib.Path('/etc/vpn-router/web.key')
|
||||
DATABASE_SECRET = pathlib.Path('/etc/vpn-router/database.secret')
|
||||
OIDC_SQL = pathlib.Path('/usr/share/vpn-router/sql/oidc.sql')
|
||||
|
||||
#: The role and the database provision() creates when web.database is not set.
|
||||
DATABASE = 'vpn-router'
|
||||
@@ -45,6 +49,9 @@ LEEWAY = 60
|
||||
|
||||
SCHEMA = 'CREATE TABLE IF NOT EXISTS refresh_tokens (hash text PRIMARY KEY, name text NOT NULL, expires timestamptz NOT NULL)'
|
||||
|
||||
#: Whether this process has loaded OIDC_SQL.
|
||||
_oidc_loaded = False
|
||||
|
||||
|
||||
class DatabaseError(Exception):
|
||||
"""The database of the refresh tokens failed."""
|
||||
@@ -120,21 +127,36 @@ def admin_token(seconds):
|
||||
return {'token': sign({'admin': True, 'iat': now, 'exp': now + seconds}, secret), 'expires': now + seconds}
|
||||
|
||||
|
||||
def local_connection(name, secret):
|
||||
"""The connection string of a database provision_database() created; its password, of URL-safe characters, is
|
||||
written unquoted."""
|
||||
password = secret.read_text(encoding='utf-8').strip()
|
||||
return f'host=localhost dbname={name} user={name} password={password}'
|
||||
|
||||
|
||||
def connection_string(cfg):
|
||||
"""web.database, or the database provision() creates; its password, of URL-safe characters, is written unquoted."""
|
||||
"""web.database, or the database provision() creates."""
|
||||
configured = cfg['web']['database'].strip()
|
||||
if configured:
|
||||
return configured
|
||||
password = DATABASE_SECRET.read_text(encoding='utf-8').strip()
|
||||
return f'host=localhost dbname={DATABASE} user={DATABASE} password={password}'
|
||||
return local_connection(DATABASE, DATABASE_SECRET)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def database(cfg):
|
||||
"""A connection to the database of the refresh tokens, with their table, closed at the end; DatabaseError on any failure."""
|
||||
"""A connection to the database of the refresh tokens, with their table and the OpenID Provider's, closed at the end;
|
||||
DatabaseError on any failure."""
|
||||
global _oidc_loaded
|
||||
try:
|
||||
oidc = None if _oidc_loaded else OIDC_SQL.read_text(encoding='utf-8')
|
||||
except OSError as exc:
|
||||
raise DatabaseError(f'{OIDC_SQL} cannot be read: {exc}') from exc
|
||||
try:
|
||||
with psycopg.connect(connection_string(cfg), autocommit=True) as connection:
|
||||
connection.execute(SCHEMA)
|
||||
if oidc is not None:
|
||||
connection.execute(oidc)
|
||||
_oidc_loaded = True
|
||||
yield connection
|
||||
except psycopg.Error as exc:
|
||||
raise DatabaseError(f'the token database failed: {exc}') from exc
|
||||
@@ -173,9 +195,10 @@ def take(cfg, token):
|
||||
|
||||
|
||||
def revoke(cfg, name):
|
||||
"""Remove all refresh tokens of the user."""
|
||||
"""Remove all refresh tokens of the user, and its codes, tokens and sessions of the OpenID Provider."""
|
||||
with database(cfg) as connection:
|
||||
connection.execute('DELETE FROM refresh_tokens WHERE name = %s', (name,))
|
||||
connection.execute('CALL oidc_revoke(%s)', (name,))
|
||||
|
||||
|
||||
def psql(sql, name):
|
||||
@@ -187,21 +210,33 @@ def psql(sql, name):
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def provision(cfg):
|
||||
"""Create the role, its password file and the database when web.database is not set and they are missing.
|
||||
def provision_database(name, secret):
|
||||
"""Create the role of the name, its password file secret and its database of the same name when they are missing.
|
||||
|
||||
The password, of URL-safe characters, reaches psql on standard input, not on its command line.
|
||||
"""
|
||||
if cfg['web']['database'].strip():
|
||||
return
|
||||
if not psql("SELECT 1 FROM pg_roles WHERE rolname = :'v1'", DATABASE):
|
||||
DATABASE_SECRET.unlink(missing_ok=True)
|
||||
psql("CREATE ROLE :\"v1\" LOGIN", DATABASE)
|
||||
if not DATABASE_SECRET.is_file():
|
||||
if not psql("SELECT 1 FROM pg_roles WHERE rolname = :'v1'", name):
|
||||
secret.unlink(missing_ok=True)
|
||||
psql("CREATE ROLE :\"v1\" LOGIN", name)
|
||||
if not secret.is_file():
|
||||
password = secrets.token_urlsafe(32)
|
||||
psql(f"ALTER ROLE :\"v1\" PASSWORD '{password}'", DATABASE)
|
||||
DATABASE_SECRET.touch(mode=0o600)
|
||||
DATABASE_SECRET.chmod(0o600)
|
||||
DATABASE_SECRET.write_text(password + '\n', encoding='utf-8')
|
||||
if not psql("SELECT 1 FROM pg_database WHERE datname = :'v1'", DATABASE):
|
||||
psql("CREATE DATABASE :\"v1\" OWNER :\"v1\"", DATABASE)
|
||||
psql(f"ALTER ROLE :\"v1\" PASSWORD '{password}'", name)
|
||||
secret.touch(mode=0o600)
|
||||
secret.chmod(0o600)
|
||||
secret.write_text(password + '\n', encoding='utf-8')
|
||||
if not psql("SELECT 1 FROM pg_database WHERE datname = :'v1'", name):
|
||||
psql("CREATE DATABASE :\"v1\" OWNER :\"v1\"", name)
|
||||
|
||||
|
||||
def reset_password(name, secret):
|
||||
"""Give the role of the name the password of its file secret, when both exist, as after a restore."""
|
||||
if secret.is_file() and psql("SELECT 1 FROM pg_roles WHERE rolname = :'v1'", name):
|
||||
password = secret.read_text(encoding='utf-8').strip()
|
||||
psql(f"ALTER ROLE :\"v1\" PASSWORD '{password}'", name)
|
||||
|
||||
|
||||
def provision(cfg):
|
||||
"""Create the role, its password file and the database of the refresh tokens when web.database is not set and they
|
||||
are missing."""
|
||||
if not cfg['web']['database'].strip():
|
||||
provision_database(DATABASE, DATABASE_SECRET)
|
||||
@@ -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,401 @@
|
||||
"""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, their secondary name servers 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 dns.nameserver, its NS, dns.nameserver and the name
|
||||
servers of its secondaries, the NS of the domains directly below it, A and AAAA records of dns.addresses for its apex,
|
||||
for those name servers inside it that are the router, and, when the domain has site records, for every host name of an
|
||||
enabled site whose closest domain it is, and the manual records, but those of a name and type the records before them
|
||||
have. A secondary's address is notified of every change (ALSO-NOTIFY) and may transfer the zone (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 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, secondaries of a domain, and manual records of a domain.
|
||||
DOMAINS_MAX = 100
|
||||
SECONDARIES_MAX = 8
|
||||
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, secondary 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 secondaries, its manual records and the records the name server answers for it."""
|
||||
with database() as connection:
|
||||
secondaries = connection.execute('SELECT * FROM dns_secondaries_list()').fetchall()
|
||||
manual = connection.execute('SELECT * FROM dns_records_list()').fetchall()
|
||||
return [{**row,
|
||||
'secondaries': [{key: item[key] for key in ('nameserver', 'address')}
|
||||
for item in secondaries if item['domain'] == row['name']],
|
||||
'manual': [{key: item[key] for key in ('name', 'type', 'content', 'ttl')}
|
||||
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, sites, ttl?}; the domain."""
|
||||
if not isinstance(body, dict) or not isinstance(body.get('sites', False), bool):
|
||||
raise Failure('a domain is an object of name, sites and ttl')
|
||||
name, sites, ttl = check_domain(body.get('name')), body.get('sites', False), 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, %s)', (name, sites, ttl))
|
||||
return {'name': name, 'sites': sites, 'ttl': ttl}
|
||||
|
||||
|
||||
def domain_change(name, body):
|
||||
"""Change the sites and ttl of the domain; the domain."""
|
||||
if not isinstance(body, dict) or not set(body) <= {'sites', 'ttl'} or \
|
||||
not isinstance(body.get('sites', False), bool):
|
||||
raise Failure('a change of a domain is an object of sites and ttl')
|
||||
with database() as connection:
|
||||
row = domain(connection, name)
|
||||
sites, ttl = body.get('sites', row['sites']), check_ttl(body.get('ttl', row['ttl']))
|
||||
connection.execute('CALL dns_domain_put(%s, %s, %s)', (name, sites, ttl))
|
||||
return {'name': name, 'sites': sites, 'ttl': ttl}
|
||||
|
||||
|
||||
def domain_remove(name):
|
||||
"""Remove the domain with its secondaries and manual records."""
|
||||
with database() as connection:
|
||||
domain(connection, name)
|
||||
connection.execute('CALL dns_domain_remove(%s)', (name,))
|
||||
return {'name': name}
|
||||
|
||||
|
||||
def secondary_add(name, body):
|
||||
"""Add the secondary name server {nameserver, address} of the domain, or set its address; the secondary."""
|
||||
if not isinstance(body, dict):
|
||||
raise Failure('a secondary is an object of nameserver and address')
|
||||
nameserver = body.get('nameserver')
|
||||
if not isinstance(nameserver, str) or not vpnrouter.ZONE_NAME.match(nameserver.strip().lower().rstrip('.')):
|
||||
raise Failure(f'{nameserver!r} is not a name server name')
|
||||
nameserver = nameserver.strip().lower().rstrip('.')
|
||||
try:
|
||||
address = str(ipaddress.ip_address(str(body.get('address', '')).strip()))
|
||||
except ValueError as exc:
|
||||
raise Failure(f'{body.get("address")!r} is not an IP address') from exc
|
||||
with database() as connection:
|
||||
domain(connection, name)
|
||||
others = [row for row in connection.execute('SELECT * FROM dns_secondaries_list()').fetchall()
|
||||
if row['domain'] == name and row['nameserver'] != nameserver]
|
||||
if len(others) >= SECONDARIES_MAX:
|
||||
raise Failure(f'the domain {name} has {SECONDARIES_MAX} secondaries')
|
||||
connection.execute('CALL dns_secondary_put(%s, %s, %s)', (name, nameserver, address))
|
||||
return {'domain': name, 'nameserver': nameserver, 'address': address}
|
||||
|
||||
|
||||
def secondary_remove(name, nameserver):
|
||||
"""Remove the secondary name server of the domain."""
|
||||
with database() as connection:
|
||||
domain(connection, name)
|
||||
if not any(row['domain'] == name and row['nameserver'] == nameserver
|
||||
for row in connection.execute('SELECT * FROM dns_secondaries_list()').fetchall()):
|
||||
raise Failure(f'no secondary {nameserver} of {name}', 3)
|
||||
connection.execute('CALL dns_secondary_remove(%s, %s)', (name, nameserver))
|
||||
return {'domain': name, 'nameserver': nameserver}
|
||||
|
||||
|
||||
def add(cfg, name, record):
|
||||
"""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, secondaries, 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, the A and AAAA records of dns.addresses for its apex, dns.nameserver when it is inside
|
||||
and, with site records, each host name whose closest domain it is, the glue of the secondaries inside it, and its
|
||||
manual records. secondaries maps a domain to its secondaries."""
|
||||
dns = cfg['dns']
|
||||
zone, nameserver, ttl = row['name'], dns['nameserver'].strip(), row['ttl'] or dns['ttl']
|
||||
addresses = [ipaddress.ip_address(item.strip()) for item in dns['addresses'] if item.strip()]
|
||||
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 [nameserver, *(item['nameserver'] for item in secondaries.get(name, []))]]
|
||||
for name in dict.fromkeys([zone, *([nameserver] if in_zone(zone, nameserver) else []),
|
||||
*(host for host in hosts if row['sites'] and closest(zones, host) == zone)]):
|
||||
found += [(name, 'A' if address.version == 4 else 'AAAA', str(address), ttl) for address in addresses]
|
||||
for item in (item for name in [zone, *children] for item in secondaries.get(name, [])):
|
||||
if in_zone(zone, item['nameserver']) and item['nameserver'] != nameserver:
|
||||
address = ipaddress.ip_address(item['address'])
|
||||
found.append((item['nameserver'], 'A' if address.version == 4 else 'AAAA', str(address), ttl))
|
||||
taken = {(name, kind) for name, kind, _, _ in found}
|
||||
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(), 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]
|
||||
secondaries, manual = {}, {}
|
||||
for item in connection.execute('SELECT * FROM dns_secondaries_list()').fetchall():
|
||||
secondaries.setdefault(item['domain'], []).append(item)
|
||||
for item in connection.execute('SELECT * FROM dns_records_list()').fetchall():
|
||||
manual.setdefault(item['domain'], []).append(item)
|
||||
for row in domains:
|
||||
zone, ttl = row['name'], row['ttl'] or dns['ttl']
|
||||
rows = records(cfg, row, zones, secondaries, hosts, manual.get(zone, []))
|
||||
soa = f'{dns["nameserver"].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],
|
||||
[item['address'] for item in secondaries.get(zone, [])]))
|
||||
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}'
|
||||
@@ -67,7 +71,16 @@ OVERLAYS = ('ppolicy', 'otp')
|
||||
#: description, the labels of a service account, and vpnRouterConsoleLogin and
|
||||
#: vpnRouterConsoleAccount, whether the user may use a console and the local account it runs as,
|
||||
#: without which it runs as the user's own posixAccount, and vpnRouterWireGuardPeer, each a
|
||||
#: WireGuard peer of the user as "<public key> <IP>[,<IP>...]". vpnRouterAuthority is a CA of the PKI: its
|
||||
#: WireGuard peer of the user as "<public key> <IP>[,<IP>...]", and vpnRouterPasskey, each a
|
||||
#: passkey of the user as JSON (vpnrouter_passkeys). A service account with vpnRouterRedirectUri
|
||||
#: is a client of the OpenID Provider (vpnrouter_oidc): the URIs it is sent back to after a sign-in
|
||||
#: and, vpnRouterPostLogoutRedirectUri, after a sign-out, and vpnRouterAllowedGroup, the DNs of the
|
||||
#: groups whose members may sign in to it; vpnRouterGrantType, the further grants it may use,
|
||||
#: vpnRouterPublicClient, whether it has no secret, and vpnRouterClientScope, the scopes of
|
||||
#: resources it may ask for, as <resource>:<scope>. A service account with vpnRouterResourceScope
|
||||
#: is a resource of the OpenID Provider with those scopes. vpnRouterRole is a role of a resource,
|
||||
#: named cn=<resource>:<role>, with the DNs of the groups and accounts it is assigned to in
|
||||
#: member. vpnRouterAuthority is a CA of the PKI: its
|
||||
#: certificate and CRL (cACertificate and certificateRevocationList of RFC 4523), its key, the
|
||||
#: CSR it waits with for the offline root, the number of its last CRL, where the CRL came from,
|
||||
#: and when it was retired. vpnRouterCertificate is a certificate a CA issued, named by its
|
||||
@@ -100,17 +113,37 @@ SCHEMA_ATTRIBUTE_TYPES = (
|
||||
f"{_STRING} )",
|
||||
f"( {SCHEMA_OID}.1.15 NAME 'vpnRouterWireGuardPeer' DESC 'VPN router: a WireGuard peer of the user, its public key and IPs' "
|
||||
"EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1024} )",
|
||||
f"( {SCHEMA_OID}.1.16 NAME 'vpnRouterPasskey' DESC 'VPN router: a passkey of the user, its credential as JSON' "
|
||||
"EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{4096} )",
|
||||
f"( {SCHEMA_OID}.1.17 NAME 'vpnRouterRedirectUri' DESC 'VPN router: a redirect URI of an OpenID Connect client' "
|
||||
"EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1024} )",
|
||||
f"( {SCHEMA_OID}.1.18 NAME 'vpnRouterPostLogoutRedirectUri' DESC 'VPN router: a post-logout redirect URI of an "
|
||||
"OpenID Connect client' EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1024} )",
|
||||
f"( {SCHEMA_OID}.1.19 NAME 'vpnRouterAllowedGroup' DESC 'VPN router: a group whose members may sign in to an "
|
||||
"OpenID Connect client' EQUALITY distinguishedNameMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 )",
|
||||
f"( {SCHEMA_OID}.1.20 NAME 'vpnRouterGrantType' DESC 'VPN router: a grant an OpenID Connect client may use besides "
|
||||
"the authorization code' EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{64} )",
|
||||
f"( {SCHEMA_OID}.1.21 NAME 'vpnRouterPublicClient' DESC 'VPN router: whether an OpenID Connect client has no secret' "
|
||||
"EQUALITY booleanMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 SINGLE-VALUE )",
|
||||
f"( {SCHEMA_OID}.1.22 NAME 'vpnRouterClientScope' DESC 'VPN router: a scope of a resource an OpenID Connect client "
|
||||
"may ask for' EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{128} )",
|
||||
f"( {SCHEMA_OID}.1.23 NAME 'vpnRouterResourceScope' DESC 'VPN router: a scope of a resource of the OpenID Provider' "
|
||||
"EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{64} )",
|
||||
)
|
||||
SCHEMA_OBJECT_CLASSES = (
|
||||
f"( {SCHEMA_OID}.2.1 NAME 'vpnRouterAccount' DESC 'VPN router: properties of a user or a Manager-only account' "
|
||||
"SUP top AUXILIARY MAY ( mail $ vpnRouterScope $ displayName $ description $ vpnRouterConsoleLogin $ "
|
||||
"vpnRouterConsoleAccount $ vpnRouterWireGuardPeer ) )",
|
||||
"vpnRouterConsoleAccount $ vpnRouterWireGuardPeer $ vpnRouterPasskey $ vpnRouterRedirectUri $ "
|
||||
"vpnRouterPostLogoutRedirectUri $ vpnRouterAllowedGroup $ vpnRouterGrantType $ vpnRouterPublicClient $ "
|
||||
"vpnRouterClientScope $ vpnRouterResourceScope ) )",
|
||||
f"( {SCHEMA_OID}.2.2 NAME 'vpnRouterAuthority' DESC 'VPN router: a CA of the PKI' SUP top STRUCTURAL MUST cn "
|
||||
"MAY ( cACertificate $ certificateRevocationList $ vpnRouterPrivateKey $ vpnRouterCertificateRequest $ "
|
||||
"vpnRouterCRLNumber $ vpnRouterCRLSource $ vpnRouterRetired ) )",
|
||||
f"( {SCHEMA_OID}.2.3 NAME 'vpnRouterCertificate' DESC 'VPN router: a certificate a CA of the PKI issued' SUP top "
|
||||
"STRUCTURAL MUST serialNumber MAY ( cn $ userCertificate $ vpnRouterPrivateKey $ vpnRouterKind $ vpnRouterTemplate $ "
|
||||
"vpnRouterSubjectAltName $ vpnRouterNotAfter $ vpnRouterRevoked $ vpnRouterRevocationReason ) )",
|
||||
f"( {SCHEMA_OID}.2.4 NAME 'vpnRouterRole' DESC 'VPN router: a role of a resource of the OpenID Provider' SUP top "
|
||||
"STRUCTURAL MUST cn MAY ( description $ member ) )",
|
||||
)
|
||||
|
||||
#: The TOTP parameters: digits, the OID of HMAC-SHA1, seconds per time step, and the time steps accepted before and after now.
|
||||
@@ -139,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',
|
||||
)
|
||||
|
||||
@@ -320,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'],
|
||||
|
||||
@@ -0,0 +1,982 @@
|
||||
"""The router's OpenID Provider, for the web service, with python3-oauthlib and python3-jwt.
|
||||
|
||||
While oidc.enabled is true, the web service answers OpenID Connect at the issuer vpnrouter.oidc_issuer, its HTTPS site
|
||||
that Caddy serves; the web service answers it only on the loopback address, where Caddy passes the requests. The users
|
||||
of the router's directory sign in, with the password and the TOTP code or with a passkey (vpnrouter_passkeys), to its
|
||||
clients, the service accounts with redirect URIs or grants (vpnrouter_users.client_settings): a client's ID is its name
|
||||
and its secret its password, sent with HTTP Basic authentication or in the body of the token request; a public client
|
||||
sends only its ID. A client with allowed groups admits only their members; a service account never signs in as a user.
|
||||
|
||||
GET /.well-known/openid-configuration the discovery document
|
||||
GET /oidc/jwks the public key the ID tokens and the access tokens are signed with
|
||||
GET, POST /oidc/authorize the authorization code flow, PKCE with S256 required; the sign-in page while
|
||||
the browser has no session, or with prompt=login or an older one than max_age
|
||||
POST /oidc/login the sign-in page's form: the name, the password and the code, or a passkey
|
||||
POST /oidc/passkey begin a sign-in with a passkey: {"id", "options"}
|
||||
POST /oidc/token exchange a code, a refresh token or a device code for tokens, or a client's
|
||||
credentials for an access token
|
||||
POST /oidc/device_authorization begin the device authorization grant (RFC 8628): a device code and a user code
|
||||
GET, POST /oidc/device the page where the user enters the user code, signs in and is asked to allow
|
||||
the device
|
||||
POST /oidc/device/decide allow or deny the device
|
||||
GET, POST /oidc/userinfo the claims of the user an access token was issued for
|
||||
GET /oidc/logout end the session, then go to a post-logout redirect URI of the client
|
||||
|
||||
The grants: authorization_code for a client with redirect URIs, client_credentials, by which a confidential client
|
||||
gets an access token as itself, and the device code, as the client's grants give them, each with refresh_token but
|
||||
client_credentials. A client asks for tokens for a resource, a service account, with resource (RFC 8707), its name or
|
||||
<issuer>/<name>, or by the resource's scopes it asks for, <resource>:<scope>, of its client scopes; without one the
|
||||
tokens are for the client itself.
|
||||
|
||||
ID tokens are RS256 JWTs signed with KEY, an RSA key the web service makes on its first run on the router, named by the
|
||||
RFC 7638 thumbprint of its public key, for the client. Access tokens are RS256 JWTs of RFC 9068 with the same key, for
|
||||
the resource or the client: iss, sub, aud, client_id, iat, exp, jti, scope, roles, the claims of the scopes, and of a
|
||||
user auth_time and amr. sub is the entryUUID of the user's entry, so a user added again under the same name is another
|
||||
subject. The scopes: openid, required but with client_credentials, which refuses it; profile, preferred_username and
|
||||
the names; email; groups, the names of the user's groups; their claims are in the ID token, the access token and the
|
||||
userinfo answer; and the scopes of the resources. roles are the roles of the resource assigned to the user or service
|
||||
account or a group of it (vpnrouter_users.roles_held), read at every issue; the ID token and the userinfo answer have
|
||||
those of the client. A code is valid CODE_SECONDS, an access token and an ID token ACCESS_SECONDS, a refresh token
|
||||
REFRESH_SECONDS, used once, and a device code DEVICE_SECONDS, polled at most every DEVICE_INTERVAL seconds, 5 more after
|
||||
each poll that came too soon. A session, the cookie COOKIE of the issuer's /oidc/ path, lasts SESSION_SECONDS. Codes,
|
||||
tokens, device codes and sessions are kept in the database of vpnrouter_auth as their SHA-256
|
||||
(vpnrouter_auth.OIDC_SQL); vpnrouter_auth.revoke removes a user's or a service account's. A client is registered by an
|
||||
administrator, so a user is not asked for consent, but for a device, which the user allows on the router's page.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import collections
|
||||
import contextlib
|
||||
import datetime
|
||||
import hashlib
|
||||
import http.cookies
|
||||
import json
|
||||
import pathlib
|
||||
import secrets
|
||||
import time
|
||||
import types
|
||||
import urllib.parse
|
||||
|
||||
import jinja2
|
||||
import jwt
|
||||
import ldap
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from oauthlib.common import Request, add_params_to_uri
|
||||
from oauthlib.oauth2 import (AccessDeniedError, FatalClientError, InvalidClientError, InvalidGrantError,
|
||||
InvalidRequestError, InvalidScopeError, OAuth2Error, UnauthorizedClientError,
|
||||
UnsupportedGrantTypeError)
|
||||
from oauthlib.oauth2.rfc6749 import utils
|
||||
from oauthlib.oauth2.rfc6749.grant_types.base import GrantTypeBase
|
||||
from oauthlib.oauth2.rfc6749.tokens import random_token_generator
|
||||
from oauthlib.openid import RequestValidator, Server
|
||||
from oauthlib.openid.connect.core.exceptions import LoginRequired
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_auth
|
||||
import vpnrouter_ldap
|
||||
import vpnrouter_passkeys
|
||||
import vpnrouter_users
|
||||
|
||||
KEY = pathlib.Path('/etc/vpn-router/oidc.key')
|
||||
KEY_BITS = 3072
|
||||
|
||||
CODE_SECONDS = 60
|
||||
ACCESS_SECONDS = 300
|
||||
REFRESH_SECONDS = 8 * 3600
|
||||
SESSION_SECONDS = 8 * 3600
|
||||
DEVICE_SECONDS = 600
|
||||
DEVICE_INTERVAL = 5
|
||||
|
||||
#: The grant type of the device authorization grant.
|
||||
DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'
|
||||
#: A user code: USER_CODE_LENGTH of these letters, shown in two halves.
|
||||
USER_CODE_LETTERS = 'BCDFGHJKLMNPQRSTVWXZ'
|
||||
USER_CODE_LENGTH = 8
|
||||
|
||||
COOKIE = 'vpn_router_oidc'
|
||||
|
||||
#: The claims of each scope but openid, which gives sub.
|
||||
SCOPE_CLAIMS = {
|
||||
'profile': ('preferred_username', 'name', 'given_name', 'family_name'),
|
||||
'email': ('email',),
|
||||
'groups': ('groups',),
|
||||
}
|
||||
SCOPES = ('openid', *SCOPE_CLAIMS)
|
||||
|
||||
#: The authentication methods (RFC 8176) of a sign-in with a password, with a password and a TOTP code, and with a
|
||||
#: passkey, which verifies the user; a service account's client credentials are its password.
|
||||
PASSWORD_AMR = ['pwd']
|
||||
OTP_AMR = ['pwd', 'otp']
|
||||
PASSKEY_AMR = ['hwk', 'user']
|
||||
SERVICE_AMR = ['pwd']
|
||||
|
||||
TEMPLATE = 'oidc-page.html.j2'
|
||||
|
||||
#: The headers of the pages: no framing, and nothing loaded but from the page itself and the router.
|
||||
PAGE_HEADERS = [('Content-Security-Policy', "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; "
|
||||
"connect-src 'self'; frame-ancestors 'none'")]
|
||||
|
||||
#: The headers of oauthlib's answers that the web service sets itself.
|
||||
OWN_HEADERS = ('content-type', 'cache-control', 'content-length')
|
||||
|
||||
#: A user's sign-in at the OpenID Provider: the user name, when, a datetime, and how (amr).
|
||||
Session = collections.namedtuple('Session', 'name auth_time amr')
|
||||
|
||||
_pages = jinja2.Environment(loader=jinja2.FileSystemLoader(str(vpnrouter.TEMPLATE_DIR)), autoescape=True,
|
||||
undefined=jinja2.StrictUndefined)
|
||||
|
||||
|
||||
class InvalidTarget(OAuth2Error):
|
||||
"""A resource that is unknown, or more than one (RFC 8707)."""
|
||||
error = 'invalid_target'
|
||||
|
||||
|
||||
class AuthorizationPending(OAuth2Error):
|
||||
"""A device code the user has not yet allowed or denied (RFC 8628)."""
|
||||
error = 'authorization_pending'
|
||||
|
||||
|
||||
class SlowDown(OAuth2Error):
|
||||
"""A device code polled sooner than its interval (RFC 8628)."""
|
||||
error = 'slow_down'
|
||||
|
||||
|
||||
class ExpiredToken(OAuth2Error):
|
||||
"""A device code that has expired (RFC 8628)."""
|
||||
error = 'expired_token'
|
||||
|
||||
|
||||
def ensure_key():
|
||||
"""Make the signing key when it is missing."""
|
||||
if KEY.is_file() and KEY.stat().st_size:
|
||||
return
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=KEY_BITS)
|
||||
KEY.parent.mkdir(parents=True, exist_ok=True)
|
||||
KEY.touch(mode=0o600)
|
||||
KEY.chmod(0o600)
|
||||
KEY.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption()))
|
||||
|
||||
|
||||
def private_key():
|
||||
return serialization.load_pem_private_key(KEY.read_bytes(), None)
|
||||
|
||||
|
||||
def public_jwk(key):
|
||||
"""The JWK of the key's public key, its kid the RFC 7638 thumbprint."""
|
||||
numbers = key.public_key().public_numbers()
|
||||
jwk = {'e': numbers.e, 'kty': 'RSA', 'n': numbers.n}
|
||||
jwk = {name: vpnrouter_auth.b64encode(value.to_bytes((value.bit_length() + 7) // 8, 'big')) if isinstance(value, int)
|
||||
else value for name, value in jwk.items()}
|
||||
thumbprint = hashlib.sha256(json.dumps(jwk, sort_keys=True, separators=(',', ':')).encode()).digest()
|
||||
return {**jwk, 'kid': vpnrouter_auth.b64encode(thumbprint), 'use': 'sig', 'alg': 'RS256'}
|
||||
|
||||
|
||||
def sign(claims, headers=None):
|
||||
"""The claims as an RS256 JWT with the key's kid and the headers given."""
|
||||
key = private_key()
|
||||
return jwt.encode(claims, key, algorithm='RS256', headers={'kid': public_jwk(key)['kid'], **(headers or {})})
|
||||
|
||||
|
||||
def discovery(issuer, resource_scopes):
|
||||
"""The discovery document of OpenID Connect Discovery 1.0, with the device authorization endpoint of RFC 8628;
|
||||
resource_scopes are the scopes of the resources."""
|
||||
return {
|
||||
'issuer': issuer,
|
||||
'authorization_endpoint': f'{issuer}/oidc/authorize',
|
||||
'token_endpoint': f'{issuer}/oidc/token',
|
||||
'device_authorization_endpoint': f'{issuer}/oidc/device_authorization',
|
||||
'userinfo_endpoint': f'{issuer}/oidc/userinfo',
|
||||
'jwks_uri': f'{issuer}/oidc/jwks',
|
||||
'end_session_endpoint': f'{issuer}/oidc/logout',
|
||||
'scopes_supported': [*SCOPES, *resource_scopes],
|
||||
'response_types_supported': ['code'],
|
||||
'response_modes_supported': ['query'],
|
||||
'grant_types_supported': ['authorization_code', 'refresh_token', 'client_credentials', DEVICE_GRANT],
|
||||
'subject_types_supported': ['public'],
|
||||
'id_token_signing_alg_values_supported': ['RS256'],
|
||||
'token_endpoint_auth_methods_supported': ['client_secret_basic', 'client_secret_post', 'none'],
|
||||
'code_challenge_methods_supported': ['S256'],
|
||||
'claims_supported': ['iss', 'sub', 'aud', 'exp', 'iat', 'auth_time', 'nonce', 'amr', 'at_hash', 'client_id',
|
||||
'scope', 'roles', *(claim for claims in SCOPE_CLAIMS.values() for claim in claims)],
|
||||
}
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def directory():
|
||||
"""A connection to the router's directory as the user manager account, for reading."""
|
||||
connection = vpnrouter_ldap.manager()
|
||||
try:
|
||||
yield connection
|
||||
finally:
|
||||
connection.unbind_s()
|
||||
|
||||
|
||||
def account_of(name):
|
||||
"""A namespace of the vpnrouter_users.summary of the service account of the name, else None."""
|
||||
if not isinstance(name, str):
|
||||
return None
|
||||
with directory() as connection:
|
||||
try:
|
||||
user = vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
|
||||
except vpnrouter_users.Failure:
|
||||
return None
|
||||
return types.SimpleNamespace(**user) if user['service'] else None
|
||||
|
||||
|
||||
def client_of(client_id):
|
||||
"""The client of the ID, a namespace of its vpnrouter_users.summary with client_id, when it is an enabled service
|
||||
account with redirect URIs or grants; else None."""
|
||||
account = account_of(client_id)
|
||||
if account is None or not account.enabled or not (account.redirect_uris or account.grants):
|
||||
return None
|
||||
account.client_id = client_id
|
||||
return account
|
||||
|
||||
|
||||
def resource_of(name):
|
||||
"""The resource of the name, a namespace of its vpnrouter_users.summary, when it is an enabled service account that
|
||||
is not internal; else None."""
|
||||
account = account_of(name)
|
||||
return account if account is not None and account.enabled and not account.internal else None
|
||||
|
||||
|
||||
def resource_scopes():
|
||||
"""The scopes of every resource, <resource>:<scope>, sorted."""
|
||||
with directory() as connection:
|
||||
users = vpnrouter_users.list_users(connection)['users']
|
||||
return sorted(f'{user["name"]}:{scope}' for user in users
|
||||
if user['service'] and user['enabled'] and not user['internal'] for scope in user['resource_scopes'])
|
||||
|
||||
|
||||
def account_claims(connection, name):
|
||||
"""The summary of the entry of the name and its claims of every scope, with sub; Failure when there is none."""
|
||||
user = vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
|
||||
groups = sorted(vpnrouter_users.groups_of(connection, name))
|
||||
uuid = connection.search_s(vpnrouter_users.dn_of(name), ldap.SCOPE_BASE, attrlist=['entryUUID'])[0][1]['entryUUID']
|
||||
full = ' '.join(part for part in (user['first_name'], user['last_name']) if part)
|
||||
claims = {'sub': uuid[0].decode(), 'preferred_username': name, 'name': full or None, 'given_name': user['first_name'],
|
||||
'family_name': user['last_name'], 'email': user['email'], 'groups': groups}
|
||||
return user, {claim: value for claim, value in claims.items() if value is not None}
|
||||
|
||||
|
||||
def subject(name, client):
|
||||
"""The claims of every scope of the user, with sub, when the user may sign in to the client: an enabled, unlocked
|
||||
user who is not a service account, a member of one of the client's allowed groups when it has any; else None."""
|
||||
with directory() as connection:
|
||||
try:
|
||||
user, claims = account_claims(connection, name)
|
||||
except vpnrouter_users.Failure:
|
||||
return None
|
||||
if user['service'] or not user['enabled'] or user['locked']:
|
||||
return None
|
||||
if client.allowed_groups and not set(client.allowed_groups) & set(claims['groups']):
|
||||
return None
|
||||
return claims
|
||||
|
||||
|
||||
def service_subject(name):
|
||||
"""The claims of every scope of the service account, with sub."""
|
||||
with directory() as connection:
|
||||
return account_claims(connection, name)[1]
|
||||
|
||||
|
||||
def roles_at(resource, name):
|
||||
"""The roles of the resource the user or service account holds."""
|
||||
with directory() as connection:
|
||||
return vpnrouter_users.roles_held(connection, resource, name)
|
||||
|
||||
|
||||
def claims_of(person, scopes):
|
||||
"""The claims of the scopes of a subject's claims (subject)."""
|
||||
return {'sub': person['sub'], **{claim: person[claim] for scope in scopes for claim in SCOPE_CLAIMS.get(scope, ())
|
||||
if claim in person}}
|
||||
|
||||
|
||||
def target_of(issuer, resource, scopes):
|
||||
"""The name of the resource a request with the resource parameter, None when absent, and the scopes names, None
|
||||
for none; InvalidTarget when they name more than one or one that is not a resource."""
|
||||
names = {scope.partition(':')[0] for scope in scopes if scope not in SCOPES}
|
||||
if resource is not None:
|
||||
names.add(resource[len(issuer) + 1:] if isinstance(resource, str) and resource.startswith(f'{issuer}/') else resource)
|
||||
if not names:
|
||||
return None
|
||||
if len(names) > 1:
|
||||
raise InvalidTarget(description='the resource and the scopes name more than one resource')
|
||||
name = names.pop()
|
||||
if resource_of(name) is None:
|
||||
raise InvalidTarget(description=f'{name} is not a resource')
|
||||
return name
|
||||
|
||||
|
||||
def scopes_allowed(client, scopes, target, grant_type):
|
||||
"""Whether the client may ask for the scopes: openid, required but by client_credentials, which refuses it, the
|
||||
other scopes of SCOPES, and those of its client scopes that belong to the target."""
|
||||
for scope in scopes:
|
||||
if scope not in SCOPES and (scope not in client.client_scopes or scope.partition(':')[0] != target):
|
||||
return False
|
||||
return ('openid' not in scopes) if grant_type == 'client_credentials' else ('openid' in scopes)
|
||||
|
||||
|
||||
def same_redirect(registered, given):
|
||||
"""Whether the redirect URI given is the registered one; an http URI of a loopback host may have any port (RFC 8252
|
||||
7.3)."""
|
||||
if registered == given:
|
||||
return True
|
||||
try:
|
||||
wanted, found = urllib.parse.urlsplit(registered), urllib.parse.urlsplit(given)
|
||||
# A port that is not a number makes .port raise ValueError.
|
||||
wanted.port, found.port
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
return False
|
||||
return wanted.scheme == 'http' and wanted.hostname in vpnrouter_users.LOOPBACK_HOSTS and not found.fragment and \
|
||||
(wanted.scheme, wanted.hostname, wanted.path, wanted.query) == (found.scheme, found.hostname, found.path, found.query)
|
||||
|
||||
|
||||
def cookie_value(cookies):
|
||||
"""The value of the session cookie in a Cookie header, or ''."""
|
||||
try:
|
||||
morsel = http.cookies.SimpleCookie(cookies or '').get(COOKIE)
|
||||
except http.cookies.CookieError:
|
||||
return ''
|
||||
return morsel.value if morsel is not None else ''
|
||||
|
||||
|
||||
def session_of(cfg, cookies):
|
||||
"""The unexpired Session the session cookie in a Cookie header names, or None."""
|
||||
value = cookie_value(cookies)
|
||||
if not value:
|
||||
return None
|
||||
with vpnrouter_auth.database(cfg) as connection:
|
||||
row = connection.execute('SELECT * FROM oidc_session_get(%s)', (vpnrouter_auth.digest(value),)).fetchone()
|
||||
return Session(*row) if row else None
|
||||
|
||||
|
||||
def access_token(cfg, request):
|
||||
"""The access token of a token request: a JWT of RFC 9068 for the request's target, or the client."""
|
||||
now = int(time.time())
|
||||
client_id = request.client.client_id
|
||||
service = request.grant_type == 'client_credentials'
|
||||
name = client_id if service else request.user
|
||||
person = service_subject(client_id) if service else request.oidc_person
|
||||
target = getattr(request, 'oidc_target', None)
|
||||
claims = {**claims_of(person, request.scopes), 'iss': vpnrouter.oidc_issuer(cfg), 'aud': target or client_id,
|
||||
'client_id': client_id, 'iat': now, 'exp': now + ACCESS_SECONDS, 'jti': secrets.token_urlsafe(16),
|
||||
'scope': ' '.join(request.scopes)}
|
||||
roles = roles_at(target or client_id, name)
|
||||
if roles:
|
||||
claims['roles'] = roles
|
||||
if not service:
|
||||
claims.update({'auth_time': int(request.auth_time.timestamp()), 'amr': request.amr})
|
||||
return sign(claims, {'typ': 'at+jwt'})
|
||||
|
||||
|
||||
class Validator(RequestValidator):
|
||||
"""The checks and the storage of oauthlib's server for the router's clients, users and database.
|
||||
|
||||
The grants are those of validate_grant_type, with PKCE S256 for the authorization code. The values of a code, a
|
||||
token, a device code or a session found are kept on the request as oidc_code, oidc_scopes and oidc_person, the
|
||||
resource the tokens are for as oidc_target.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg):
|
||||
super().__init__()
|
||||
self.cfg = cfg
|
||||
|
||||
def fetch(self, sql, values):
|
||||
with vpnrouter_auth.database(self.cfg) as connection:
|
||||
return connection.execute(sql, values).fetchone()
|
||||
|
||||
def call(self, *statements):
|
||||
with vpnrouter_auth.database(self.cfg) as connection:
|
||||
for sql, values in statements:
|
||||
connection.execute(sql, values)
|
||||
|
||||
def session(self, request):
|
||||
return session_of(self.cfg, request.headers.get('Cookie'))
|
||||
|
||||
def check_target(self, request, stored):
|
||||
"""Refuse a resource parameter that names another resource than the one the grant was made for."""
|
||||
resource = getattr(request, 'resource', None)
|
||||
if 'resource' in request.duplicate_params or \
|
||||
resource is not None and target_of(vpnrouter.oidc_issuer(self.cfg), resource, []) != stored:
|
||||
raise InvalidTarget(description='the grant was made for another resource', request=request)
|
||||
request.oidc_target = stored
|
||||
|
||||
# Clients.
|
||||
|
||||
def validate_client_id(self, client_id, request, *args, **kwargs):
|
||||
request.client = client_of(client_id)
|
||||
return request.client is not None
|
||||
|
||||
def validate_redirect_uri(self, client_id, redirect_uri, request, *args, **kwargs):
|
||||
return any(same_redirect(uri, redirect_uri) for uri in request.client.redirect_uris)
|
||||
|
||||
def get_default_redirect_uri(self, client_id, request, *args, **kwargs):
|
||||
return None
|
||||
|
||||
def validate_response_type(self, client_id, response_type, client, request, *args, **kwargs):
|
||||
return response_type == 'code'
|
||||
|
||||
def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs):
|
||||
"""Whether the client may ask for the scopes; the resource they are for is kept as oidc_target."""
|
||||
if 'resource' in request.duplicate_params:
|
||||
raise InvalidTarget(description='give one resource', request=request)
|
||||
try:
|
||||
request.oidc_target = target_of(vpnrouter.oidc_issuer(self.cfg), getattr(request, 'resource', None), scopes)
|
||||
except InvalidTarget as error:
|
||||
raise InvalidTarget(description=error.description, request=request)
|
||||
return scopes_allowed(client, scopes, request.oidc_target, request.grant_type)
|
||||
|
||||
def get_default_scopes(self, client_id, request, *args, **kwargs):
|
||||
return [] if request.grant_type == 'client_credentials' else ['openid']
|
||||
|
||||
def is_pkce_required(self, client_id, request):
|
||||
return True
|
||||
|
||||
def client_authentication_required(self, request, *args, **kwargs):
|
||||
"""Whether the client must sign the request with its secret: unless it is a public client named by client_id."""
|
||||
client = client_of(request.client_id)
|
||||
return client is None or not client.public_client
|
||||
|
||||
def authenticate_client(self, request, *args, **kwargs):
|
||||
"""Check the client's secret, the password of its service account, given with HTTP Basic authentication, its
|
||||
ID and secret form-encoded (RFC 6749 2.3.1), or in the body; a public client has none."""
|
||||
name, secret = request.client_id, request.client_secret
|
||||
header = request.headers.get('Authorization', '')
|
||||
if header[:6].lower() == 'basic ':
|
||||
try:
|
||||
name, _, secret = base64.b64decode(header[6:].strip(), validate=True).decode().partition(':')
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return False
|
||||
name, secret = urllib.parse.unquote_plus(name), urllib.parse.unquote_plus(secret)
|
||||
client = client_of(name)
|
||||
if client is None or client.public_client:
|
||||
return False
|
||||
try:
|
||||
vpnrouter_users.login(name, secret, None)
|
||||
except vpnrouter_users.Failure:
|
||||
return False
|
||||
request.client = client
|
||||
return True
|
||||
|
||||
def authenticate_client_id(self, client_id, request, *args, **kwargs):
|
||||
"""Accept a public client by its ID."""
|
||||
client = client_of(client_id)
|
||||
if client is None or not client.public_client:
|
||||
return False
|
||||
request.client = client
|
||||
return True
|
||||
|
||||
def validate_grant_type(self, client_id, grant_type, client, request, *args, **kwargs):
|
||||
"""The grants of the client: authorization_code with redirect URIs, client_credentials but for a public client,
|
||||
the device code, and refresh_token with either of the first and the last."""
|
||||
grants = set(client.grants) - ({'client_credentials'} if client.public_client else set())
|
||||
if client.redirect_uris:
|
||||
grants.add('authorization_code')
|
||||
if 'device_code' in grants:
|
||||
grants.add(DEVICE_GRANT)
|
||||
if grants & {'authorization_code', DEVICE_GRANT}:
|
||||
grants.add('refresh_token')
|
||||
return grant_type in grants
|
||||
|
||||
def validate_user(self, username, password, client, request, *args, **kwargs):
|
||||
return False
|
||||
|
||||
# Codes.
|
||||
|
||||
def save_authorization_code(self, client_id, code, request, *args, **kwargs):
|
||||
self.call(('CALL oidc_code_save(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)',
|
||||
(vpnrouter_auth.digest(code['code']), client_id, request.user, request.redirect_uri, request.scopes,
|
||||
request.nonce, request.code_challenge, request.code_challenge_method, request.auth_time, request.amr,
|
||||
getattr(request, 'oidc_target', None), CODE_SECONDS)))
|
||||
|
||||
def code(self, code):
|
||||
row = self.fetch('SELECT * FROM oidc_code_get(%s)', (vpnrouter_auth.digest(code),)) if isinstance(code, str) else None
|
||||
return dict(zip(('client_id', 'name', 'redirect_uri', 'scopes', 'nonce', 'code_challenge', 'code_challenge_method',
|
||||
'auth_time', 'amr', 'resource'), row)) if row else None
|
||||
|
||||
def get_authorization_code_scopes(self, client_id, code, redirect_uri, request):
|
||||
found = self.code(code)
|
||||
return found['scopes'] if found else []
|
||||
|
||||
def validate_code(self, client_id, code, client, request, *args, **kwargs):
|
||||
found = self.code(code)
|
||||
if found is None or found['client_id'] != client_id:
|
||||
return False
|
||||
person = subject(found['name'], client)
|
||||
if person is None:
|
||||
return False
|
||||
self.check_target(request, found['resource'])
|
||||
request.user, request.scopes, request.auth_time, request.amr = found['name'], found['scopes'], found['auth_time'], found['amr']
|
||||
request.oidc_code, request.oidc_person = found, person
|
||||
return True
|
||||
|
||||
def get_code_challenge(self, code, request):
|
||||
return request.oidc_code['code_challenge']
|
||||
|
||||
def get_code_challenge_method(self, code, request):
|
||||
return request.oidc_code['code_challenge_method']
|
||||
|
||||
def confirm_redirect_uri(self, client_id, code, redirect_uri, client, request, *args, **kwargs):
|
||||
return redirect_uri == request.oidc_code['redirect_uri']
|
||||
|
||||
def get_authorization_code_nonce(self, client_id, code, redirect_uri, request):
|
||||
return request.oidc_code['nonce']
|
||||
|
||||
def invalidate_authorization_code(self, client_id, code, request, *args, **kwargs):
|
||||
self.call(('CALL oidc_code_delete(%s)', (vpnrouter_auth.digest(code),)))
|
||||
|
||||
# Device codes.
|
||||
|
||||
def poll_device(self, code, request):
|
||||
"""Poll the device code for the request's client: with the user's sign-in on the request when the user allowed
|
||||
it, else the error RFC 8628 gives."""
|
||||
row = self.fetch('SELECT * FROM oidc_device_poll(%s)', (vpnrouter_auth.digest(code),)) \
|
||||
if isinstance(code, str) and code else None
|
||||
found = dict(zip(('client_id', 'scopes', 'resource', 'state', 'name', 'auth_time', 'amr', 'expired', 'too_soon'),
|
||||
row)) if row else None
|
||||
if found is None or found['client_id'] != request.client.client_id:
|
||||
raise InvalidGrantError(description='the device code is unknown', request=request)
|
||||
if found['expired']:
|
||||
raise ExpiredToken(description='the device code has expired', request=request)
|
||||
if found['state'] == 'pending':
|
||||
raise (SlowDown if found['too_soon'] else AuthorizationPending)(request=request)
|
||||
if found['state'] == 'denied':
|
||||
self.call(('CALL oidc_device_delete(%s)', (vpnrouter_auth.digest(code),)))
|
||||
raise AccessDeniedError(description='the user denied the device', request=request)
|
||||
person = subject(found['name'], request.client)
|
||||
if person is None:
|
||||
raise AccessDeniedError(description=f'{found["name"]} may not sign in to this client', request=request)
|
||||
self.check_target(request, found['resource'])
|
||||
request.user, request.scopes, request.auth_time, request.amr = found['name'], found['scopes'], found['auth_time'], found['amr']
|
||||
request.oidc_person = person
|
||||
|
||||
def delete_device(self, code):
|
||||
self.call(('CALL oidc_device_delete(%s)', (vpnrouter_auth.digest(code),)))
|
||||
|
||||
# Tokens.
|
||||
|
||||
def token(self, token, kind):
|
||||
row = self.fetch('SELECT * FROM oidc_token_get(%s, %s)', (vpnrouter_auth.digest(token), kind)) \
|
||||
if isinstance(token, str) and token else None
|
||||
return dict(zip(('client_id', 'name', 'scopes', 'auth_time', 'amr', 'resource'), row)) if row else None
|
||||
|
||||
def save_bearer_token(self, token, request, *args, **kwargs):
|
||||
"""Keep the access token and the refresh token; a refresh token that was exchanged is used up. A token of
|
||||
client_credentials is kept for its service account."""
|
||||
if request.grant_type == 'client_credentials':
|
||||
name, auth_time, amr = request.client.client_id, datetime.datetime.now(datetime.timezone.utc), SERVICE_AMR
|
||||
else:
|
||||
name, auth_time, amr = request.user, request.auth_time, request.amr
|
||||
values = (request.client.client_id, name, request.scopes, auth_time, amr, getattr(request, 'oidc_target', None))
|
||||
statements = [('CALL oidc_token_save(%s, %s, %s, %s, %s, %s, %s, %s, %s)',
|
||||
(vpnrouter_auth.digest(token['access_token']), 'access', *values, ACCESS_SECONDS))]
|
||||
if token.get('refresh_token'):
|
||||
statements.append(('CALL oidc_token_save(%s, %s, %s, %s, %s, %s, %s, %s, %s)',
|
||||
(vpnrouter_auth.digest(token['refresh_token']), 'refresh', *values, REFRESH_SECONDS)))
|
||||
if request.grant_type == 'refresh_token':
|
||||
statements.append(('CALL oidc_token_delete(%s)', (vpnrouter_auth.digest(request.refresh_token),)))
|
||||
self.call(*statements)
|
||||
|
||||
def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs):
|
||||
found = self.token(refresh_token, 'refresh')
|
||||
if found is None or found['client_id'] != client.client_id:
|
||||
return False
|
||||
person = subject(found['name'], client)
|
||||
if person is None:
|
||||
return False
|
||||
self.check_target(request, found['resource'])
|
||||
request.user, request.auth_time, request.amr = found['name'], found['auth_time'], found['amr']
|
||||
request.oidc_scopes, request.oidc_person = found['scopes'], person
|
||||
return True
|
||||
|
||||
def get_original_scopes(self, refresh_token, request, *args, **kwargs):
|
||||
return request.oidc_scopes
|
||||
|
||||
def validate_bearer_token(self, token, scopes, request):
|
||||
found = self.token(token, 'access')
|
||||
if found is None or not set(scopes) <= set(found['scopes']):
|
||||
return False
|
||||
client = client_of(found['client_id'])
|
||||
person = subject(found['name'], client) if client else None
|
||||
if person is None:
|
||||
return False
|
||||
request.client, request.user, request.oidc_scopes, request.oidc_person = client, found['name'], found['scopes'], person
|
||||
return True
|
||||
|
||||
def get_userinfo_claims(self, request):
|
||||
claims = claims_of(request.oidc_person, request.oidc_scopes)
|
||||
roles = roles_at(request.client.client_id, request.user)
|
||||
return {**claims, 'roles': roles} if roles else claims
|
||||
|
||||
# OpenID Connect.
|
||||
|
||||
def finalize_id_token(self, id_token, token, token_handler, request):
|
||||
now = int(time.time())
|
||||
id_token.update(claims_of(request.oidc_person, request.scopes))
|
||||
id_token.update({'iss': vpnrouter.oidc_issuer(self.cfg), 'exp': now + ACCESS_SECONDS,
|
||||
'auth_time': int(request.auth_time.timestamp()), 'amr': request.amr})
|
||||
roles = roles_at(request.client.client_id, request.user)
|
||||
if roles:
|
||||
id_token['roles'] = roles
|
||||
return sign(id_token)
|
||||
|
||||
def validate_silent_login(self, request):
|
||||
session = self.session(request)
|
||||
return session is not None and subject(session.name, request.client) is not None
|
||||
|
||||
def validate_silent_authorization(self, request):
|
||||
return True
|
||||
|
||||
def validate_user_match(self, id_token_hint, scopes, claims, request):
|
||||
"""Whether the user of the session, when there is one, is the subject of an ID token the router signed."""
|
||||
session = self.session(request) if id_token_hint else None
|
||||
if session is None:
|
||||
return True
|
||||
try:
|
||||
hint = jwt.decode(id_token_hint, private_key().public_key(), algorithms=['RS256'],
|
||||
options={'verify_exp': False, 'verify_aud': False})
|
||||
except jwt.InvalidTokenError:
|
||||
return False
|
||||
person = subject(session.name, request.client)
|
||||
return person is not None and hint.get('sub') == person['sub']
|
||||
|
||||
|
||||
class DeviceCodeGrant(GrantTypeBase):
|
||||
"""The token request of the device authorization grant (RFC 8628): a device code the user allowed gives an access
|
||||
token, a refresh token and, with openid, an ID token; until then the request is answered authorization_pending or
|
||||
slow_down."""
|
||||
|
||||
def create_token_response(self, request, token_handler):
|
||||
headers = self._get_default_headers()
|
||||
try:
|
||||
self.validate_token_request(request)
|
||||
except OAuth2Error as error:
|
||||
headers.update(error.headers)
|
||||
return headers, error.json, error.status_code
|
||||
token = token_handler.create_token(request, refresh_token=True)
|
||||
if 'openid' in request.scopes:
|
||||
token['id_token'] = self.request_validator.finalize_id_token(
|
||||
{'aud': request.client.client_id, 'iat': int(time.time())}, token, token_handler, request)
|
||||
self.request_validator.save_token(token, request)
|
||||
self.request_validator.delete_device(request.device_code)
|
||||
return headers, json.dumps(token), 200
|
||||
|
||||
def validate_token_request(self, request):
|
||||
if request.grant_type != DEVICE_GRANT:
|
||||
raise UnsupportedGrantTypeError(request=request)
|
||||
for param in ('grant_type', 'device_code'):
|
||||
if param in request.duplicate_params:
|
||||
raise InvalidRequestError(description=f'Duplicate {param} parameter.', request=request)
|
||||
request.device_code = getattr(request, 'device_code', None)
|
||||
if not request.device_code:
|
||||
raise InvalidRequestError(description='Missing device_code parameter.', request=request)
|
||||
if self.request_validator.client_authentication_required(request):
|
||||
if not self.request_validator.authenticate_client(request):
|
||||
raise InvalidClientError(request=request)
|
||||
elif not self.request_validator.authenticate_client_id(request.client_id, request):
|
||||
raise InvalidClientError(request=request)
|
||||
self.validate_grant_type(request)
|
||||
self.request_validator.poll_device(request.device_code, request)
|
||||
|
||||
|
||||
def user_code_of(value):
|
||||
"""The user code a user typed, without the dash and spaces, in capitals; '' when it is not one."""
|
||||
code = ''.join(character for character in (value or '').upper() if character.isalnum())
|
||||
return code if len(code) == USER_CODE_LENGTH and all(character in USER_CODE_LETTERS for character in code) else ''
|
||||
|
||||
|
||||
def device_authorization(validator, cfg, text, headers):
|
||||
"""Answer a device authorization request (RFC 8628 3.1): the client, public by client_id or confidential with its
|
||||
secret, with the device_code grant, the scopes and the resource; the device code, the user code and the
|
||||
verification URIs."""
|
||||
issuer = vpnrouter.oidc_issuer(cfg)
|
||||
request = Request(f'{issuer}/oidc/device_authorization', 'POST', text, headers)
|
||||
for param in ('scope', 'resource'):
|
||||
if param in request.duplicate_params:
|
||||
raise InvalidRequestError(description=f'Duplicate {param} parameter.', request=request)
|
||||
if validator.client_authentication_required(request):
|
||||
if not validator.authenticate_client(request):
|
||||
raise InvalidClientError(request=request)
|
||||
elif not validator.authenticate_client_id(request.client_id, request):
|
||||
raise InvalidClientError(request=request)
|
||||
if not validator.validate_grant_type(request.client.client_id, DEVICE_GRANT, request.client, request):
|
||||
raise UnauthorizedClientError(description='the client may not use the device authorization grant', request=request)
|
||||
scopes = utils.scope_to_list(request.scope) or ['openid']
|
||||
target = target_of(issuer, getattr(request, 'resource', None), scopes)
|
||||
if not scopes_allowed(request.client, scopes, target, DEVICE_GRANT):
|
||||
raise InvalidScopeError(description='the client may not ask for these scopes; openid is required', request=request)
|
||||
device_code = secrets.token_urlsafe(32)
|
||||
user_code = ''.join(secrets.choice(USER_CODE_LETTERS) for _ in range(USER_CODE_LENGTH))
|
||||
with vpnrouter_auth.database(cfg) as connection:
|
||||
connection.execute('CALL oidc_device_save(%s, %s, %s, %s, %s, %s, %s)',
|
||||
(vpnrouter_auth.digest(device_code), vpnrouter_auth.digest(user_code), request.client.client_id,
|
||||
scopes, target, DEVICE_INTERVAL, DEVICE_SECONDS))
|
||||
shown = f'{user_code[:USER_CODE_LENGTH // 2]}-{user_code[USER_CODE_LENGTH // 2:]}'
|
||||
return document(200, {'device_code': device_code, 'user_code': shown, 'verification_uri': f'{issuer}/oidc/device',
|
||||
'verification_uri_complete': f'{issuer}/oidc/device?' + urllib.parse.urlencode({'user_code': shown}),
|
||||
'expires_in': DEVICE_SECONDS, 'interval': DEVICE_INTERVAL})
|
||||
|
||||
|
||||
def device_of(cfg, user_code):
|
||||
"""The waiting device code of a user code, {client_id, scopes, resource}, or None."""
|
||||
code = user_code_of(user_code)
|
||||
if not code:
|
||||
return None
|
||||
with vpnrouter_auth.database(cfg) as connection:
|
||||
row = connection.execute('SELECT * FROM oidc_device_find(%s)', (vpnrouter_auth.digest(code),)).fetchone()
|
||||
return dict(zip(('client_id', 'scopes', 'resource'), row)) if row else None
|
||||
|
||||
|
||||
def page(status, cfg, title, message='', error='', form=False, request='', name='', headers=(), action='/oidc/login',
|
||||
device=False, user_code='', decide=False, items=()):
|
||||
"""An HTML page of TEMPLATE: a message, an error and items; with form the sign-in form, posted to action with the
|
||||
authorization request, or with device the user code field, with or without the sign-in fields; with decide the
|
||||
Allow and Deny buttons of the device of the user code."""
|
||||
body = _pages.get_template(TEMPLATE).render(title=title, message=message, error=error, form=form, request=request,
|
||||
name=name, issuer=vpnrouter.oidc_issuer(cfg), action=action, device=device,
|
||||
user_code=user_code, decide=decide, items=list(items))
|
||||
return status, 'text/html; charset=utf-8', body.encode(), [*PAGE_HEADERS, *headers]
|
||||
|
||||
|
||||
def document(status, value, headers=()):
|
||||
return status, 'application/json', json.dumps(value, indent=2).encode() + b'\n', list(headers)
|
||||
|
||||
|
||||
def relayed(headers, body, status):
|
||||
"""An answer of oauthlib's server, without the headers the web service sets."""
|
||||
kind = next((value for name, value in headers.items() if name.lower() == 'content-type'), 'application/json')
|
||||
return status, kind, (body or '').encode(), [(name, value) for name, value in headers.items()
|
||||
if name.lower() not in OWN_HEADERS]
|
||||
|
||||
|
||||
def redirect(location, headers=()):
|
||||
return 302, 'text/plain; charset=utf-8', b'', [('Location', location), *headers]
|
||||
|
||||
|
||||
def cookie(value, seconds):
|
||||
return 'Set-Cookie', f'{COOKIE}={value}; Path=/oidc/; Max-Age={seconds}; Secure; HttpOnly; SameSite=Lax'
|
||||
|
||||
|
||||
def sign_in(form):
|
||||
"""The name and the amr of a sign-in with the form's name, password and code, or its passkey; Failure when it is
|
||||
refused, ValueError when the passkey's answer is not JSON."""
|
||||
if form.get('passkey_id'):
|
||||
with vpnrouter_users.session() as connection:
|
||||
name = vpnrouter_passkeys.login_complete(connection, form['passkey_id'], json.loads(form.get('credential', '')))
|
||||
return name, PASSKEY_AMR
|
||||
name, code = form.get('name', '').strip(), form.get('code', '').strip()
|
||||
vpnrouter_users.login(name, form.get('password', ''), code or None)
|
||||
with directory() as connection:
|
||||
if vpnrouter_users.service(vpnrouter_users.entry(connection, name)):
|
||||
raise vpnrouter_users.Failure('the name, the password or the code is wrong', 5)
|
||||
return name, OTP_AMR if code else PASSWORD_AMR
|
||||
|
||||
|
||||
def start_session(cfg, name, amr):
|
||||
"""Start a session of the user: a namespace of the Session and the header that sets its cookie."""
|
||||
value = secrets.token_urlsafe(32)
|
||||
with vpnrouter_auth.database(cfg) as connection:
|
||||
connection.execute('CALL oidc_session_save(%s, %s, %s, %s)', (vpnrouter_auth.digest(value), name, amr, SESSION_SECONDS))
|
||||
return types.SimpleNamespace(session=Session(name, datetime.datetime.now(datetime.timezone.utc), amr),
|
||||
cookie=cookie(value, SESSION_SECONDS))
|
||||
|
||||
|
||||
def authorize(server, cfg, params, headers, fresh=None, name='', error=''):
|
||||
"""Answer an authorization request, the query or the form params: the sign-in page while the browser has no
|
||||
session, or with prompt=login or max_age older than the session, else a redirect to the client with a code.
|
||||
|
||||
fresh is the session of a sign-in just made, which counts whatever prompt and max_age say; the redirect then sets
|
||||
its cookie, fresh.cookie. error is why a sign-in failed: the sign-in page shows it, filled with the name.
|
||||
"""
|
||||
uri = f'{vpnrouter.oidc_issuer(cfg)}/oidc/authorize?{params}'
|
||||
try:
|
||||
scopes, info = server.validate_authorization_request(uri, 'GET', None, headers)
|
||||
except FatalClientError as error:
|
||||
return page(error.status_code, cfg, 'The sign-in request is refused', error=error.description or error.error)
|
||||
except OAuth2Error as error:
|
||||
if not error.redirect_uri:
|
||||
return page(error.status_code, cfg, 'The sign-in request is refused', error=error.description or error.error)
|
||||
return redirect(error.in_uri(error.redirect_uri))
|
||||
request = info['request']
|
||||
title = f'Sign in to {request.client.display_name or request.client.client_id}'
|
||||
if info.get('code_challenge_method') != 'S256':
|
||||
return redirect(InvalidRequestError(description='PKCE with code_challenge_method S256 is required',
|
||||
request=request).in_uri(request.redirect_uri))
|
||||
if error:
|
||||
return page(401, cfg, title, error=error, form=True, request=params, name=name)
|
||||
session = fresh.session if fresh else session_of(cfg, headers.get('Cookie'))
|
||||
max_age = getattr(request, 'max_age', None)
|
||||
stale = session is not None and isinstance(max_age, str) and max_age.isdigit() and \
|
||||
time.time() - session.auth_time.timestamp() > int(max_age)
|
||||
prompt = info.get('prompt') or set()
|
||||
if session is None or (not fresh and ('login' in prompt or stale)):
|
||||
if 'none' in prompt:
|
||||
return redirect(LoginRequired(request=request).in_uri(request.redirect_uri))
|
||||
return page(200, cfg, title, form=True, request=params, name=name)
|
||||
if subject(session.name, request.client) is None:
|
||||
if fresh:
|
||||
return redirect(AccessDeniedError(description=f'{session.name} may not sign in to this client',
|
||||
request=request).in_uri(request.redirect_uri), [fresh.cookie])
|
||||
return page(403, cfg, title, error=f'{session.name} may not sign in here; sign in as another user.', form=True,
|
||||
request=params)
|
||||
answer, _, _ = server.create_authorization_response(uri, 'GET', None, headers, scopes, credentials={
|
||||
'user': session.name, 'auth_time': session.auth_time, 'amr': session.amr})
|
||||
return redirect(answer['Location'], [fresh.cookie] if fresh else [])
|
||||
|
||||
|
||||
def login(server, cfg, form, headers):
|
||||
"""Sign a user in with the sign-in page's form, start the session and go on with its authorization request."""
|
||||
params = form.get('request', '')
|
||||
name = form.get('name', '').strip()
|
||||
try:
|
||||
name, amr = sign_in(form)
|
||||
except vpnrouter_users.Failure as failure:
|
||||
return authorize(server, cfg, params, headers, name=name, error=str(failure))
|
||||
except ValueError:
|
||||
return authorize(server, cfg, params, headers, name=name, error='the passkey is not accepted')
|
||||
return authorize(server, cfg, params, headers, fresh=start_session(cfg, name, amr))
|
||||
|
||||
|
||||
def device_page(cfg, status=200, signed_in=False, user_code='', name='', error='', headers=()):
|
||||
"""The page where the user enters the user code of a device, with the sign-in fields unless signed_in."""
|
||||
return page(status, cfg, 'Sign in on a device', message='Enter the code the device shows.', error=error,
|
||||
form=not signed_in, name=name, headers=headers, action='/oidc/device', device=True, user_code=user_code)
|
||||
|
||||
|
||||
def device(cfg, form, headers):
|
||||
"""The device page's form: sign in when the browser has no session, then ask the user to allow the device of the
|
||||
user code."""
|
||||
user_code, fresh = form.get('user_code', ''), None
|
||||
session = session_of(cfg, headers.get('Cookie'))
|
||||
if session is None:
|
||||
try:
|
||||
name, amr = sign_in(form)
|
||||
except vpnrouter_users.Failure as failure:
|
||||
return device_page(cfg, 401, user_code=user_code, name=form.get('name', '').strip(), error=str(failure))
|
||||
except ValueError:
|
||||
return device_page(cfg, 401, user_code=user_code, name=form.get('name', '').strip(),
|
||||
error='the passkey is not accepted')
|
||||
fresh = start_session(cfg, name, amr)
|
||||
session = fresh.session
|
||||
extra = [fresh.cookie] if fresh else []
|
||||
found = device_of(cfg, user_code)
|
||||
client = client_of(found['client_id']) if found else None
|
||||
if client is None:
|
||||
return device_page(cfg, 400, signed_in=True, user_code=user_code, error='the code is wrong or has expired',
|
||||
headers=extra)
|
||||
shown = client.display_name or client.client_id
|
||||
if subject(session.name, client) is None:
|
||||
return device_page(cfg, 403, signed_in=True, user_code=user_code, headers=extra,
|
||||
error=f'{session.name} may not sign in to {shown}')
|
||||
items = ([f'resource: {found["resource"]}'] if found['resource'] else []) + [f'scope: {scope}' for scope in found['scopes']]
|
||||
return page(200, cfg, f'Allow {shown}?', message=f'{shown} asks to sign in as {session.name} on a device.',
|
||||
items=items, decide=True, user_code=user_code_of(user_code), headers=extra)
|
||||
|
||||
|
||||
def decide(cfg, form, headers):
|
||||
"""Allow or deny the device of the user code as the user of the session."""
|
||||
session = session_of(cfg, headers.get('Cookie'))
|
||||
if session is None:
|
||||
return device_page(cfg, 401, user_code=form.get('user_code', ''), error='sign in first')
|
||||
found = device_of(cfg, form.get('user_code', ''))
|
||||
client = client_of(found['client_id']) if found else None
|
||||
if client is None or subject(session.name, client) is None:
|
||||
return device_page(cfg, 400, signed_in=True, user_code=form.get('user_code', ''),
|
||||
error='the code is wrong or has expired')
|
||||
allowed = form.get('decision') == 'allow'
|
||||
with vpnrouter_auth.database(cfg) as connection:
|
||||
decided = connection.execute('SELECT oidc_device_decide(%s, %s, %s, %s, %s)',
|
||||
(vpnrouter_auth.digest(user_code_of(form['user_code'])), session.name,
|
||||
session.auth_time, session.amr, allowed)).fetchone()[0]
|
||||
if not decided:
|
||||
return device_page(cfg, 400, signed_in=True, error='the code is wrong or has expired')
|
||||
shown = client.display_name or client.client_id
|
||||
if allowed:
|
||||
return page(200, cfg, 'The device is signed in', message=f'{shown} is signed in as {session.name}. You can return '
|
||||
'to the device.')
|
||||
return page(200, cfg, 'The device is denied', message=f'{shown} is not signed in.')
|
||||
|
||||
|
||||
def logout(cfg, query, headers):
|
||||
"""End the session the cookie names, then go to post_logout_redirect_uri, with state, when it is a post-logout
|
||||
redirect URI of the client that client_id or the audience of id_token_hint names; else show that the user is
|
||||
signed out."""
|
||||
values = dict(urllib.parse.parse_qsl(query))
|
||||
value = cookie_value(headers.get('Cookie'))
|
||||
if value:
|
||||
with vpnrouter_auth.database(cfg) as connection:
|
||||
connection.execute('CALL oidc_session_delete(%s)', (vpnrouter_auth.digest(value),))
|
||||
client_id = values.get('client_id')
|
||||
if not client_id and values.get('id_token_hint'):
|
||||
try:
|
||||
audience = jwt.decode(values['id_token_hint'], private_key().public_key(), algorithms=['RS256'],
|
||||
options={'verify_exp': False, 'verify_aud': False}).get('aud')
|
||||
except jwt.InvalidTokenError:
|
||||
audience = None
|
||||
client_id = audience[0] if isinstance(audience, list) and audience else audience
|
||||
client = client_of(client_id)
|
||||
target = values.get('post_logout_redirect_uri')
|
||||
expired = cookie('', 0)
|
||||
if client is not None and target in client.post_logout_redirect_uris:
|
||||
return redirect(add_params_to_uri(target, [('state', values['state'])]) if values.get('state') else target, [expired])
|
||||
return page(200, cfg, 'Signed out', message='You are signed out of the router.', headers=[expired])
|
||||
|
||||
|
||||
def answer(method, target, headers, body):
|
||||
"""Answer a request of the OpenID Provider: the HTTP status, the content type, the body and [(header, value)]; 404
|
||||
while oidc.enabled is false. target is the path with the query, headers a mapping of the request's headers and
|
||||
body its bytes; HEAD is answered as GET."""
|
||||
path, _, query = target.partition('?')
|
||||
method = 'GET' if method == 'HEAD' else method
|
||||
try:
|
||||
cfg = vpnrouter.load_config()
|
||||
if not cfg['oidc']['enabled']:
|
||||
return 404, 'text/plain; charset=utf-8', b'Not found\n', []
|
||||
issuer = vpnrouter.oidc_issuer(cfg)
|
||||
headers = dict(headers.items())
|
||||
text = body.decode('utf-8', 'replace') if body else ''
|
||||
validator = Validator(cfg)
|
||||
server = Server(validator, token_expires_in=ACCESS_SECONDS, token_generator=lambda request: access_token(cfg, request),
|
||||
refresh_token_generator=random_token_generator)
|
||||
server.grant_types[DEVICE_GRANT] = DeviceCodeGrant(validator)
|
||||
if (method, path) == ('GET', '/.well-known/openid-configuration'):
|
||||
return document(200, discovery(issuer, resource_scopes()))
|
||||
if (method, path) == ('GET', '/oidc/jwks'):
|
||||
return document(200, {'keys': [public_jwk(private_key())]})
|
||||
if path == '/oidc/authorize' and method in ('GET', 'POST'):
|
||||
return authorize(server, cfg, query if method == 'GET' else text, headers)
|
||||
if (method, path) == ('POST', '/oidc/login'):
|
||||
return login(server, cfg, dict(urllib.parse.parse_qsl(text)), headers)
|
||||
if (method, path) == ('POST', '/oidc/passkey'):
|
||||
return document(200, vpnrouter_passkeys.login_begin())
|
||||
if (method, path) == ('POST', '/oidc/token'):
|
||||
return relayed(*server.create_token_response(f'{issuer}{path}', 'POST', text, headers))
|
||||
if (method, path) == ('POST', '/oidc/device_authorization'):
|
||||
return device_authorization(validator, cfg, text, headers)
|
||||
if (method, path) == ('GET', '/oidc/device'):
|
||||
signed_in = session_of(cfg, headers.get('Cookie')) is not None
|
||||
return device_page(cfg, signed_in=signed_in, user_code=dict(urllib.parse.parse_qsl(query)).get('user_code', ''))
|
||||
if (method, path) == ('POST', '/oidc/device'):
|
||||
return device(cfg, dict(urllib.parse.parse_qsl(text)), headers)
|
||||
if (method, path) == ('POST', '/oidc/device/decide'):
|
||||
return decide(cfg, dict(urllib.parse.parse_qsl(text)), headers)
|
||||
if path == '/oidc/userinfo' and method in ('GET', 'POST'):
|
||||
return relayed(*server.create_userinfo_response(f'{issuer}{target}', method, text or None, headers))
|
||||
if (method, path) == ('GET', '/oidc/logout'):
|
||||
return logout(cfg, query, headers)
|
||||
return 404, 'text/plain; charset=utf-8', b'Not found\n', []
|
||||
except OAuth2Error as error:
|
||||
return error.status_code, 'application/json', error.json.encode(), list(error.headers.items())
|
||||
except vpnrouter_users.Failure as failure:
|
||||
return document(400 if failure.status == 2 else 500, {'error': str(failure)})
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
return document(500, {'error': f'the OpenID Provider failed: {exc}'})
|
||||
except vpnrouter_auth.DatabaseError as exc:
|
||||
return document(500, {'error': str(exc)})
|
||||
except SystemExit as exc:
|
||||
return document(500, {'error': str(exc.code)})
|
||||
except ValueError as exc:
|
||||
return document(400, {'error': 'invalid_request', 'error_description': str(exc)})
|
||||
@@ -0,0 +1,202 @@
|
||||
"""The passkeys of the router's users, for signing in to the Router Manager and the OpenID Provider (vpnrouter_oidc),
|
||||
and for opening a shell, with python3-fido2.
|
||||
|
||||
A passkey is a discoverable WebAuthn credential with user verification, of the relying party
|
||||
wan.local_fqdn, so the browser offers it at the HTTPS addresses on that name.
|
||||
Its user handle is the user name. Each passkey of a user is a value of vpnRouterPasskey on the
|
||||
user entry: a JSON object {"id", "credential", "label", "created"}, id the credential ID and
|
||||
credential the attested credential data (with the public key), both base64url. A user has at
|
||||
most PASSKEYS_MAX passkeys; service accounts have none.
|
||||
|
||||
Registration, sign-in and the proof of a signed-in user for a shell are two steps each: begin
|
||||
answers the options for the browser and a pending id, whose state the web service keeps in memory
|
||||
for PENDING_SECONDS; complete takes the id and the browser's answer. A request that cannot be done
|
||||
raises vpnrouter_users.Failure, with the statuses of that module; a sign-in or a proof that fails
|
||||
gives status 5 whatever the reason.
|
||||
"""
|
||||
|
||||
import json
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
|
||||
import fido2.features
|
||||
import ldap
|
||||
from fido2.server import Fido2Server
|
||||
from fido2.utils import websafe_decode, websafe_encode
|
||||
from fido2.webauthn import (AttestedCredentialData, PublicKeyCredentialRpEntity, PublicKeyCredentialUserEntity,
|
||||
ResidentKeyRequirement, UserVerificationRequirement)
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_users
|
||||
|
||||
# The options and the browser's answers are in the JSON mapping of WebAuthn Level 3; python3-fido2 2.0 always uses it.
|
||||
_json_mapping = getattr(fido2.features, 'webauthn_json_mapping', None)
|
||||
if _json_mapping is not None:
|
||||
_json_mapping.enabled = True
|
||||
|
||||
#: The most passkeys of one user.
|
||||
PASSKEYS_MAX = 10
|
||||
#: The longest label of a passkey, in characters.
|
||||
LABEL_MAX = 64
|
||||
#: Seconds a begun registration or sign-in may be completed.
|
||||
PENDING_SECONDS = 120
|
||||
#: The most begun registrations and sign-ins kept at once.
|
||||
PENDING_MAX = 1000
|
||||
|
||||
#: The begun registrations and sign-ins: pending id -> (kind, user name or None, state, monotonic time of expiry).
|
||||
_pending = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def server():
|
||||
"""The Fido2Server of the relying party wan.local_fqdn; passkeys need the name."""
|
||||
fqdn = vpnrouter.load_config()['wan']['local_fqdn'].strip()
|
||||
if not fqdn:
|
||||
raise vpnrouter_users.Failure('passkeys need wan.local_fqdn, the name the Router Manager is reached at', 2)
|
||||
return Fido2Server(rp=PublicKeyCredentialRpEntity(name=fqdn, id=fqdn))
|
||||
|
||||
|
||||
def keep(kind, name, state):
|
||||
"""Keep the state of a begun step and answer its pending id."""
|
||||
pending = secrets.token_urlsafe(24)
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
for old in [key for key, (*_, expires) in _pending.items() if expires <= now]:
|
||||
del _pending[old]
|
||||
if len(_pending) >= PENDING_MAX:
|
||||
raise vpnrouter_users.Failure('too many passkey requests at once; retry in a moment')
|
||||
_pending[pending] = (kind, name, state, now + PENDING_SECONDS)
|
||||
return pending
|
||||
|
||||
|
||||
def take(kind, name, pending):
|
||||
"""The state of a begun step of the kind and user, used up; None when it is unknown or expired."""
|
||||
with _lock:
|
||||
found = _pending.pop(pending, None) if isinstance(pending, str) else None
|
||||
if found is None or found[0] != kind or found[1] != name or found[3] <= time.monotonic():
|
||||
return None
|
||||
return found[2]
|
||||
|
||||
|
||||
def credentials(attributes):
|
||||
"""The attested credential data of the passkeys of an entry."""
|
||||
return [AttestedCredentialData(websafe_decode(passkey['credential']))
|
||||
for _, passkey in vpnrouter_users.passkeys_of(attributes)]
|
||||
|
||||
|
||||
def register_begin(connection, name):
|
||||
"""Begin the registration of a passkey of the user: {"id", "options"}, options those of navigator.credentials.create."""
|
||||
current = vpnrouter_users.entry(connection, name)
|
||||
if vpnrouter_users.service(current):
|
||||
raise vpnrouter_users.Failure(f'{name} is a service account, which has no passkeys', 2)
|
||||
if len(vpnrouter_users.passkeys_of(current)) >= PASSKEYS_MAX:
|
||||
raise vpnrouter_users.Failure(f'user {name} has {PASSKEYS_MAX} passkeys, the most; remove one first', 2)
|
||||
options, state = server().register_begin(
|
||||
PublicKeyCredentialUserEntity(id=name.encode(), name=name, display_name=name),
|
||||
credentials(current),
|
||||
resident_key_requirement=ResidentKeyRequirement.REQUIRED,
|
||||
user_verification=UserVerificationRequirement.REQUIRED,
|
||||
)
|
||||
return {'id': keep('register', name, state), 'options': dict(options)}
|
||||
|
||||
|
||||
def register_complete(connection, name, pending, response, label):
|
||||
"""Store the passkey the browser created for a begun registration, with its label; the user (vpnrouter_users.summary)."""
|
||||
state = take('register', name, pending)
|
||||
if state is None:
|
||||
raise vpnrouter_users.Failure('the registration is unknown or expired; begin it again', 2)
|
||||
if label is None or (isinstance(label, str) and not label.strip()):
|
||||
label = 'Passkey'
|
||||
if not isinstance(label, str) or len(label.strip()) > LABEL_MAX:
|
||||
raise vpnrouter_users.Failure(f'the label must be text of at most {LABEL_MAX} characters', 2)
|
||||
if not isinstance(response, dict):
|
||||
raise vpnrouter_users.Failure('credential must be the answer of navigator.credentials.create as JSON', 2)
|
||||
try:
|
||||
data = server().register_complete(state, response).credential_data
|
||||
except (ValueError, KeyError, TypeError) as exc:
|
||||
raise vpnrouter_users.Failure(f'the passkey is refused: {exc}', 2)
|
||||
current = vpnrouter_users.entry(connection, name)
|
||||
value = json.dumps({
|
||||
'id': websafe_encode(data.credential_id),
|
||||
'credential': websafe_encode(bytes(data)),
|
||||
'label': label.strip(),
|
||||
'created': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
|
||||
}, separators=(',', ':'))
|
||||
changes = [] if 'vpnrouteraccount' in vpnrouter_users.classes(current) else [(ldap.MOD_ADD, 'objectClass', [b'vpnRouterAccount'])]
|
||||
changes.append((ldap.MOD_ADD, 'vpnRouterPasskey', [value.encode()]))
|
||||
connection.modify_s(vpnrouter_users.dn_of(name), changes)
|
||||
return vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
|
||||
|
||||
|
||||
def remove(connection, name, passkey_id=None):
|
||||
"""Remove the user's passkey of the id, or all its passkeys without one; the user (vpnrouter_users.summary)."""
|
||||
current = vpnrouter_users.entry(connection, name)
|
||||
values = current.get('vpnRouterPasskey', [])
|
||||
kept = [value for value, passkey in vpnrouter_users.passkeys_of(current)
|
||||
if passkey_id is not None and passkey['id'] != passkey_id]
|
||||
if passkey_id is not None and len(kept) == len(vpnrouter_users.passkeys_of(current)):
|
||||
raise vpnrouter_users.Failure(f'user {name} has no passkey {passkey_id}', 3)
|
||||
if len(kept) != len(values):
|
||||
connection.modify_s(vpnrouter_users.dn_of(name), [(ldap.MOD_REPLACE, 'vpnRouterPasskey', kept)])
|
||||
return vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
|
||||
|
||||
|
||||
def login_begin():
|
||||
"""Begin a sign-in with a passkey: {"id", "options"}, options those of navigator.credentials.get, for any user."""
|
||||
options, state = server().authenticate_begin(user_verification=UserVerificationRequirement.REQUIRED)
|
||||
return {'id': keep('login', None, state), 'options': dict(options)}
|
||||
|
||||
|
||||
def login_complete(connection, pending, response):
|
||||
"""The name of the user whose passkey signed a begun sign-in, when the user is enabled, not locked and not a
|
||||
service account."""
|
||||
refused = vpnrouter_users.Failure('the passkey is not accepted', 5)
|
||||
state = take('login', None, pending)
|
||||
if state is None or not isinstance(response, dict):
|
||||
raise refused
|
||||
try:
|
||||
name = websafe_decode(response['response']['userHandle']).decode()
|
||||
except (KeyError, TypeError, ValueError):
|
||||
raise refused
|
||||
if not vpnrouter_users.NAME.match(name):
|
||||
raise refused
|
||||
try:
|
||||
current = vpnrouter_users.entry(connection, name)
|
||||
except vpnrouter_users.Failure:
|
||||
raise refused
|
||||
user = vpnrouter_users.summary(current)
|
||||
if user['service'] or not user['enabled'] or user['locked']:
|
||||
raise refused
|
||||
try:
|
||||
server().authenticate_complete(state, credentials(current), response)
|
||||
except (ValueError, KeyError, TypeError):
|
||||
raise refused
|
||||
return name
|
||||
|
||||
|
||||
def shell_begin(connection, name):
|
||||
"""Begin the proof of the signed-in user for a shell: {"id", "options"}, options those of navigator.credentials.get
|
||||
for the user's own passkeys, with user verification."""
|
||||
own = credentials(vpnrouter_users.entry(connection, name))
|
||||
if not own:
|
||||
raise vpnrouter_users.Failure(f'{name} has no passkey; add one on the Account page', 2)
|
||||
options, state = server().authenticate_begin(own, user_verification=UserVerificationRequirement.REQUIRED)
|
||||
return {'id': keep('shell', name, state), 'options': dict(options)}
|
||||
|
||||
|
||||
def shell_complete(connection, name, pending, response):
|
||||
"""Check the browser's answer to a begun proof of the user for a shell: one of the user's own passkeys, with the
|
||||
user verified, while the user is enabled and not locked."""
|
||||
refused = vpnrouter_users.Failure('the passkey is not accepted', 5)
|
||||
state = take('shell', name, pending)
|
||||
if state is None or not isinstance(response, dict):
|
||||
raise refused
|
||||
current = vpnrouter_users.entry(connection, name)
|
||||
user = vpnrouter_users.summary(current)
|
||||
if not user['enabled'] or user['locked']:
|
||||
raise refused
|
||||
try:
|
||||
server().authenticate_complete(state, credentials(current), response)
|
||||
except (ValueError, KeyError, TypeError):
|
||||
raise refused
|
||||
@@ -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,439 @@
|
||||
"""The static web sites Caddy serves, for the web service, vpn-router-site, vpn-router-setup and webdav-auth.
|
||||
|
||||
A site is a row of the sites table in the vpn-router-sites database, which the web service creates on its first run
|
||||
(provision) with its role and the password in DATABASE_SECRET; SITES_SQL defines the table and its procedures, and the
|
||||
first connection of a process loads it. A site has a name, which also names its folder SITES_DIR/<name>, the host names
|
||||
Caddy answers on its port (hosts, the names TLS clients send as SNI; none answers any name), the interfaces whose
|
||||
firewall opens the port (listen), and TLS: none (plain HTTP), acme (a certificate of Let's Encrypt), ca (certificates
|
||||
Caddy issues with one of pki.issuing_cas) or file (a certificate and key given with the site). Caddy serves the files of
|
||||
the folder, index_file for a folder, and with spa index_file of the site for any path without a file. editors names
|
||||
the group whose members change the site's files besides the admins, through the web service and WebDAV.
|
||||
|
||||
The sites are served while sites.enabled is true; vpn-router-setup writes Caddy's configuration and opens the ports. A
|
||||
site is changed only through this module, which checks it (check), and its callers then restart vpn-router-setup.
|
||||
|
||||
The files of a site are read and written by path relative to its folder; resolve() refuses a path that leaves it.
|
||||
receive() writes a file from a stream, extract() a zip archive into a folder; both stop at sites.upload_max_mb.
|
||||
|
||||
Every failure of the database raises DatabaseError; a request that cannot be done raises Failure, whose status says
|
||||
why: 2 invalid, 3 no such site, 4 the site exists, 1 anything else.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import datetime
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_auth
|
||||
import vpnrouter_pki
|
||||
import vpnrouter_users
|
||||
|
||||
DATABASE = 'vpn-router-sites'
|
||||
DATABASE_SECRET = pathlib.Path('/etc/vpn-router/sites-database.secret')
|
||||
SITES_SQL = pathlib.Path('/usr/share/vpn-router/sql/sites.sql')
|
||||
|
||||
#: The folders of the sites, SITES_DIR/<name>.
|
||||
SITES_DIR = pathlib.Path('/var/lib/vpn-router/sites')
|
||||
|
||||
#: The name of a site, which also names its folder.
|
||||
NAME = re.compile(r'^[a-z][a-z0-9-]{0,30}$')
|
||||
|
||||
#: A file name of index_file.
|
||||
INDEX = re.compile(r'^[A-Za-z0-9_-][A-Za-z0-9._-]{0,63}$')
|
||||
|
||||
LISTEN = vpnrouter.LISTEN_CHOICES
|
||||
TLS = ('none', 'acme', 'ca', 'file')
|
||||
|
||||
#: The most host names of one site.
|
||||
HOSTS_MAX = 16
|
||||
|
||||
#: The most members a zip archive extract() takes.
|
||||
ARCHIVE_MEMBERS_MAX = 10000
|
||||
|
||||
#: The bytes copied at once from a stream.
|
||||
CHUNK = 65536
|
||||
|
||||
#: The settings of a site and their defaults; the type of a default is the required type.
|
||||
SETTINGS = {'enabled': True, 'hosts': [], 'port': 443, 'listen': 'external', 'tls': 'acme', 'ca': '', 'certificate': '',
|
||||
'private_key': '', 'index_file': 'index.html', 'spa': False, 'editors': ''}
|
||||
|
||||
#: Whether this process has loaded SITES_SQL.
|
||||
_loaded = False
|
||||
|
||||
|
||||
class DatabaseError(Exception):
|
||||
"""The database of the sites failed."""
|
||||
|
||||
|
||||
class Failure(Exception):
|
||||
"""A request that cannot be done; status is 2 invalid, 3 no such site, 4 the site exists, 1 other."""
|
||||
|
||||
def __init__(self, message, status=1):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
||||
|
||||
def provision():
|
||||
"""Create the role, its password file and the database of the sites when they are missing."""
|
||||
vpnrouter_auth.provision_database(DATABASE, DATABASE_SECRET)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def database():
|
||||
"""A connection to the database of the sites, with its table and procedures, closed at the end; DatabaseError on any
|
||||
failure, also before the web service has created the database."""
|
||||
global _loaded
|
||||
try:
|
||||
schema = None if _loaded else SITES_SQL.read_text(encoding='utf-8')
|
||||
target = vpnrouter_auth.local_connection(DATABASE, DATABASE_SECRET)
|
||||
except OSError as exc:
|
||||
raise DatabaseError(f'the database of the sites is not ready: {exc}') from exc
|
||||
try:
|
||||
with psycopg.connect(target, autocommit=True, row_factory=dict_row) as connection:
|
||||
if schema is not None:
|
||||
connection.execute(schema)
|
||||
_loaded = True
|
||||
yield connection
|
||||
except psycopg.Error as exc:
|
||||
raise DatabaseError(f'the database of the sites failed: {exc}') from exc
|
||||
|
||||
|
||||
def shown(row):
|
||||
"""A site as the answers show it: its settings without the private key, with private_key_set and its folder."""
|
||||
site = {key: value for key, value in row.items() if key != 'private_key'}
|
||||
site['private_key_set'] = bool(row.get('private_key'))
|
||||
site['modified'] = row['modified'].isoformat(timespec='seconds')
|
||||
site['directory'] = str(SITES_DIR / row['name'])
|
||||
return site
|
||||
|
||||
|
||||
def listing():
|
||||
"""Every site, as shown()."""
|
||||
with database() as connection:
|
||||
return [shown(row) for row in connection.execute('SELECT * FROM sites_list()').fetchall()]
|
||||
|
||||
|
||||
def served():
|
||||
"""The enabled sites with their private keys."""
|
||||
with database() as connection:
|
||||
return connection.execute('SELECT * FROM sites_served()').fetchall()
|
||||
|
||||
|
||||
def get(connection, name):
|
||||
"""The site with its private key; Failure with status 3 when there is none."""
|
||||
if not isinstance(name, str) or not NAME.match(name):
|
||||
raise Failure(f'no site {name}', 3)
|
||||
row = connection.execute('SELECT * FROM site_get(%s)', (name,)).fetchone()
|
||||
if row is None:
|
||||
raise Failure(f'no site {name}', 3)
|
||||
return row
|
||||
|
||||
|
||||
def editable(groups):
|
||||
"""The names of the sites whose files the members of the groups may change."""
|
||||
with database() as connection:
|
||||
return [row['name'] for row in connection.execute('SELECT * FROM sites_editable(%s)', (list(groups),))]
|
||||
|
||||
|
||||
def sites_of_group(group):
|
||||
"""The names of the sites whose editors are the group; [] before the web service has created the database."""
|
||||
if not DATABASE_SECRET.is_file():
|
||||
return []
|
||||
with database() as connection:
|
||||
return [row['name'] for row in connection.execute('SELECT * FROM sites_of_group(%s)', (group,))]
|
||||
|
||||
|
||||
def rename_group(old, new):
|
||||
"""Give the sites whose editors are the group old its new name."""
|
||||
if DATABASE_SECRET.is_file():
|
||||
with database() as connection:
|
||||
connection.execute('CALL sites_rename_group(%s, %s)', (old, new))
|
||||
|
||||
|
||||
def taken_ports(cfg):
|
||||
"""The TCP ports of the router's own services, port -> what has it, which no site may take."""
|
||||
taken = {22: 'SSH', cfg['web']['port']: 'web.port', cfg['https']['web_port']: 'https.web_port',
|
||||
cfg['https']['manager_port']: 'https.manager_port', cfg['metrics']['port']: 'metrics.port',
|
||||
cfg['metrics']['https_port']: 'metrics.https_port', vpnrouter.manager_port(): 'the Router Manager',
|
||||
cfg['sites']['webdav_port']: 'sites.webdav_port', vpnrouter.WEBDAV_LOOPBACK_PORT: 'rclone\'s WebDAV',
|
||||
53: 'DNS'}
|
||||
for port in vpnrouter.BOOTSTRAP_PORTS:
|
||||
taken[port] = 'the bootstrap services'
|
||||
if cfg['openvpn']['protocol'] == 'tcp':
|
||||
taken[cfg['openvpn']['port']] = 'openvpn.port'
|
||||
for item in cfg['docker']['exposed_ports']:
|
||||
first, _, last = item.strip().partition(':')
|
||||
if first.isdigit() and (not last or last.isdigit()):
|
||||
for port in range(int(first), int(last or first) + 1):
|
||||
taken[port] = 'docker.exposed_ports'
|
||||
return taken
|
||||
|
||||
|
||||
def check_certificate(certificate, private_key, hosts):
|
||||
"""Refuse a PEM certificate chain whose first certificate is not the key's or names none of the hosts."""
|
||||
try:
|
||||
chain = x509.load_pem_x509_certificates(certificate.encode())
|
||||
except ValueError:
|
||||
raise Failure('certificate must be PEM certificates, the site\'s first', 2)
|
||||
try:
|
||||
key = serialization.load_pem_private_key(private_key.encode(), password=None)
|
||||
except (ValueError, TypeError):
|
||||
raise Failure('private_key must be an unencrypted PEM private key', 2)
|
||||
if not vpnrouter_pki.same_key(chain[0].public_key(), key.public_key()):
|
||||
raise Failure('private_key is not the key of the certificate', 2)
|
||||
names = {value.partition(':')[2] for value in vpnrouter_pki.san_values(chain[0]) if not value.startswith('email:')}
|
||||
missing = [host for host in hosts if host not in names and f'*.{host.partition(".")[2]}' not in names]
|
||||
if missing:
|
||||
raise Failure(f'the certificate does not name {", ".join(missing)}', 2)
|
||||
|
||||
|
||||
def check(cfg, sites, site):
|
||||
"""Refuse a site, a dict of SETTINGS with its name, that is invalid or collides with the other sites (sites, rows of
|
||||
the table) or with the router's own ports."""
|
||||
name = site['name']
|
||||
if not isinstance(name, str) or not NAME.match(name):
|
||||
raise Failure('a site name starts with a lowercase letter and has at most 31 lowercase letters, digits and '
|
||||
'hyphens', 2)
|
||||
for option, default in SETTINGS.items():
|
||||
value = site[option]
|
||||
if type(value) is not type(default) or (isinstance(value, list) and not all(isinstance(item, str) for item in value)):
|
||||
raise Failure(f'{option} must be {vpnrouter.TYPE_NAMES[type(default)]}', 2)
|
||||
hosts = site['hosts']
|
||||
if len(hosts) > HOSTS_MAX or len(set(hosts)) != len(hosts):
|
||||
raise Failure(f'hosts must be at most {HOSTS_MAX} different host names', 2)
|
||||
for host in hosts:
|
||||
if not (vpnrouter_pki.DNS_NAME.match(host) or vpnrouter_pki.is_address(host)):
|
||||
raise Failure(f'hosts: {host!r} is not a host name in lowercase or an IP address', 2)
|
||||
if host == cfg['wan']['local_fqdn'].strip():
|
||||
raise Failure(f'hosts: {host} is wan.local_fqdn, the name of the router\'s own services', 2)
|
||||
port = site['port']
|
||||
if not 1 <= port <= 65535:
|
||||
raise Failure(f'port: {port} is not a port', 2)
|
||||
taken = taken_ports(cfg)
|
||||
if port in taken:
|
||||
raise Failure(f'port: {port} is the port of {taken[port]}', 2)
|
||||
if site['listen'] not in LISTEN:
|
||||
raise Failure(f'listen: {site["listen"]!r} is not one of {", ".join(LISTEN)}', 2)
|
||||
tls = site['tls']
|
||||
if tls not in TLS:
|
||||
raise Failure(f'tls: {tls!r} is not one of {", ".join(TLS)}', 2)
|
||||
if tls != 'none' and not hosts:
|
||||
raise Failure(f'tls {tls} needs hosts, the names of the certificate', 2)
|
||||
if tls == 'acme' and any(host.startswith('*.') or vpnrouter_pki.is_address(host) for host in hosts):
|
||||
raise Failure('tls acme takes host names only, without wildcards', 2)
|
||||
if tls == 'ca':
|
||||
authorities = {key: value for key, value in vpnrouter.issuing_cas(cfg).items() if not value['builtin']}
|
||||
if site['ca'] not in authorities:
|
||||
raise Failure(f'ca: {site["ca"]!r} is not one of pki.issuing_cas', 2)
|
||||
if tls == 'file':
|
||||
check_certificate(site['certificate'], site['private_key'], hosts)
|
||||
if not INDEX.match(site['index_file']):
|
||||
raise Failure('index_file must be a file name', 2)
|
||||
if site['editors']:
|
||||
with vpnrouter_users.session() as connection:
|
||||
try:
|
||||
vpnrouter_users.group_entry(connection, site['editors'])
|
||||
except vpnrouter_users.Failure:
|
||||
raise Failure(f'editors: group {site["editors"]} does not exist', 2)
|
||||
if not site['enabled']:
|
||||
return
|
||||
for other in sites:
|
||||
if other['name'] == name or not other['enabled'] or other['port'] != port:
|
||||
continue
|
||||
prefix = f'port {port} is also the port of site {other["name"]}'
|
||||
if (other['tls'] == 'none') != (tls == 'none'):
|
||||
raise Failure(f'{prefix}, which is {"plain HTTP" if other["tls"] == "none" else "HTTPS"}', 2)
|
||||
if other['listen'] != site['listen']:
|
||||
raise Failure(f'{prefix}, which listens on {other["listen"]}', 2)
|
||||
if not hosts or not other['hosts']:
|
||||
raise Failure(f'{prefix}; sites sharing a port need host names', 2)
|
||||
common = sorted(set(hosts) & set(other['hosts']))
|
||||
if common:
|
||||
raise Failure(f'{prefix}, which has {", ".join(common)} too', 2)
|
||||
|
||||
|
||||
def save(cfg, name, values, create):
|
||||
"""Add the site with values, a dict of some of SETTINGS (create), or change those of an existing one; the site as
|
||||
shown(). An absent or empty private_key keeps the current one."""
|
||||
unknown = sorted(set(values) - set(SETTINGS))
|
||||
if unknown:
|
||||
raise Failure(f'unknown settings: {", ".join(unknown)}', 2)
|
||||
with database() as connection:
|
||||
rows = connection.execute('SELECT * FROM sites_list()').fetchall()
|
||||
current = next((row for row in rows if row['name'] == name), None)
|
||||
if create and current is not None:
|
||||
raise Failure(f'site {name} exists', 4)
|
||||
if not create:
|
||||
current = get(connection, name)
|
||||
site = {**SETTINGS, **({key: current[key] for key in SETTINGS} if current else {}), 'name': name}
|
||||
site.update({key: value for key, value in values.items() if key != 'private_key' or value})
|
||||
if isinstance(site['hosts'], list):
|
||||
site['hosts'] = [host.strip().lower() for host in site['hosts'] if isinstance(host, str) and host.strip()]
|
||||
if site['tls'] != 'file':
|
||||
site['certificate'] = site['private_key'] = ''
|
||||
if site['tls'] != 'ca':
|
||||
site['ca'] = ''
|
||||
check(cfg, rows, site)
|
||||
connection.execute('CALL site_put(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)',
|
||||
tuple(site[key] for key in ('name', *SETTINGS)))
|
||||
ensure_folders([name])
|
||||
return shown(get(connection, name))
|
||||
|
||||
|
||||
def remove(name, content):
|
||||
"""Remove the site, and with content its folder."""
|
||||
with database() as connection:
|
||||
get(connection, name)
|
||||
connection.execute('CALL site_remove(%s)', (name,))
|
||||
if content:
|
||||
shutil.rmtree(SITES_DIR / name, ignore_errors=True)
|
||||
return {'name': name, 'removed': True}
|
||||
|
||||
|
||||
def ensure_folders(names):
|
||||
"""Create the folders of the sites that miss one, readable by all."""
|
||||
SITES_DIR.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
for name in names:
|
||||
(SITES_DIR / name).mkdir(mode=0o755, exist_ok=True)
|
||||
|
||||
|
||||
def resolve(name, relative):
|
||||
"""The path of the site's file or folder relative to its folder; Failure with status 2 for a path that leaves it,
|
||||
status 3 when the site has no folder."""
|
||||
if not isinstance(name, str) or not NAME.match(name):
|
||||
raise Failure(f'no site {name}', 3)
|
||||
base = SITES_DIR / name
|
||||
if not base.is_dir():
|
||||
raise Failure(f'site {name} has no folder', 3)
|
||||
if '\0' in relative or any(part in ('.', '..') for part in relative.split('/')):
|
||||
raise Failure(f'{relative!r} is not a path in the site\'s folder', 2)
|
||||
target = pathlib.Path(os.path.realpath(base / relative.strip('/')))
|
||||
if target != base and base not in target.parents:
|
||||
raise Failure(f'{relative!r} is not a path in the site\'s folder', 2)
|
||||
return target
|
||||
|
||||
|
||||
def relative_of(name, path):
|
||||
return str(path.relative_to(SITES_DIR / name))
|
||||
|
||||
|
||||
def entries(name, folder):
|
||||
"""The entries of a folder of the site: [{name, folder, size, modified}], folders first, then by name."""
|
||||
found = []
|
||||
for item in os.scandir(folder):
|
||||
try:
|
||||
info = item.stat(follow_symlinks=False)
|
||||
except OSError:
|
||||
continue
|
||||
found.append({'name': item.name, 'folder': stat.S_ISDIR(info.st_mode),
|
||||
'size': info.st_size if stat.S_ISREG(info.st_mode) else 0,
|
||||
'modified': datetime.datetime.fromtimestamp(info.st_mtime, datetime.timezone.utc)
|
||||
.isoformat(timespec='seconds')})
|
||||
return {'path': relative_of(name, folder) if folder != SITES_DIR / name else '',
|
||||
'entries': sorted(found, key=lambda entry: (not entry['folder'], entry['name']))}
|
||||
|
||||
|
||||
def receive(stream, length, target, limit):
|
||||
"""Write length bytes of the stream to the file target, replacing it in one step; Failure with status 2 over limit
|
||||
bytes. Its folders are created."""
|
||||
if length > limit:
|
||||
raise Failure(f'the file has {length} bytes, more than sites.upload_max_mb allows', 2)
|
||||
if target.is_dir():
|
||||
raise Failure(f'{target.name} is a folder', 2)
|
||||
target.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
descriptor, name = tempfile.mkstemp(dir=target.parent, prefix=f'.{target.name}.')
|
||||
try:
|
||||
with os.fdopen(descriptor, 'wb') as handle:
|
||||
left = length
|
||||
while left:
|
||||
data = stream.read(min(CHUNK, left))
|
||||
if not data:
|
||||
raise Failure('the client sent less than its Content-Length', 2)
|
||||
handle.write(data)
|
||||
left -= len(data)
|
||||
os.chmod(name, 0o644)
|
||||
os.replace(name, target)
|
||||
except BaseException:
|
||||
os.unlink(name)
|
||||
raise
|
||||
|
||||
|
||||
def extract(stream, length, folder, limit):
|
||||
"""Extract the zip archive of length bytes of the stream into the folder, created when missing; the number of files.
|
||||
|
||||
A member with an absolute path, a .. or a link, more than ARCHIVE_MEMBERS_MAX members or more than limit bytes
|
||||
unpacked refuse the whole archive, before anything is written.
|
||||
"""
|
||||
if length > limit:
|
||||
raise Failure(f'the archive has {length} bytes, more than sites.upload_max_mb allows', 2)
|
||||
folder.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryFile(dir=folder) as spool:
|
||||
left = length
|
||||
while left:
|
||||
data = stream.read(min(CHUNK, left))
|
||||
if not data:
|
||||
raise Failure('the client sent less than its Content-Length', 2)
|
||||
spool.write(data)
|
||||
left -= len(data)
|
||||
spool.seek(0)
|
||||
try:
|
||||
archive = zipfile.ZipFile(spool)
|
||||
except zipfile.BadZipFile:
|
||||
raise Failure('the file is not a zip archive', 2)
|
||||
with archive:
|
||||
members = archive.infolist()
|
||||
if len(members) > ARCHIVE_MEMBERS_MAX:
|
||||
raise Failure(f'the archive has more than {ARCHIVE_MEMBERS_MAX} members', 2)
|
||||
if sum(member.file_size for member in members) > limit:
|
||||
raise Failure('the archive unpacks to more than sites.upload_max_mb allows', 2)
|
||||
for member in members:
|
||||
parts = member.filename.replace('\\', '/').split('/')
|
||||
if member.filename.startswith('/') or '..' in parts or '\0' in member.filename or \
|
||||
stat.S_ISLNK(member.external_attr >> 16):
|
||||
raise Failure(f'the archive member {member.filename!r} is a link or leaves the folder', 2)
|
||||
count = 0
|
||||
for member in members:
|
||||
target = folder / member.filename.replace('\\', '/').strip('/')
|
||||
if member.is_dir():
|
||||
target.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
continue
|
||||
target.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
with archive.open(member) as source:
|
||||
receive(source, member.file_size, target, limit)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def make_folder(target):
|
||||
target.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def rename(name, source, relative):
|
||||
"""Move the file or folder source of the site to the path relative to its folder; Failure when that exists."""
|
||||
target = resolve(name, relative)
|
||||
if target.exists():
|
||||
raise Failure(f'{relative} exists', 4)
|
||||
target.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
os.replace(source, target)
|
||||
return target
|
||||
|
||||
|
||||
def delete(name, target):
|
||||
"""Remove the file or folder of the site, but not its folder."""
|
||||
if target == SITES_DIR / name:
|
||||
raise Failure('the site\'s folder itself cannot be removed', 2)
|
||||
if target.is_dir() and not target.is_symlink():
|
||||
shutil.rmtree(target)
|
||||
else:
|
||||
target.unlink()
|
||||
@@ -15,10 +15,25 @@ internal service accounts are those the router creates and manages for its own s
|
||||
included (internal_accounts); vpn-router-setup keeps the metrics service's.
|
||||
Users exist whether or not a P2S system is enabled.
|
||||
|
||||
A service account with redirect URIs or grants is a client of the OpenID Provider (vpnrouter_oidc), its name the
|
||||
client ID and its password the client secret (client_settings): redirect_uris, where the provider sends a user back
|
||||
after a sign-in, post_logout_redirect_uris, after a sign-out, allowed_groups, the groups whose members may sign in to it,
|
||||
every user who is not a service account when there are none, grants, those of GRANTS it may use besides the
|
||||
authorization code, public_client, whether it signs no request with its secret, which a client with client_credentials
|
||||
must, and client_scopes, the scopes of resources it may ask for, as <resource>:<scope>.
|
||||
|
||||
A service account is also a resource of the OpenID Provider, the audience of the tokens issued for it: resource_scopes
|
||||
are the scopes it defines, and its roles (roles_of_resource) are entries cn=<resource>:<role> of vpnrouter_ldap.ROLES
|
||||
whose members are the groups and accounts the role is assigned to. A scope a client may ask for cannot be removed, nor
|
||||
the resource while a client may ask for its scopes; removing an account removes its roles and takes it out of the
|
||||
others, and removing or renaming a group does the same in the roles.
|
||||
|
||||
A group is a groupOfNames in ou=groups with a name, which follows the rules of user names, an
|
||||
optional description and at least one member; a group cannot be empty, so the last member
|
||||
cannot leave it and a user who is the last member of a group cannot be removed. Groups only
|
||||
record membership; nothing on the router reads them.
|
||||
cannot leave it and a user who is the last member of a group cannot be removed. ADMIN_GROUP grants the management of
|
||||
the router, the allowed groups of the OpenID Provider's clients who may sign in to them, and the editors group of a web
|
||||
site who may change its files (vpnrouter_sites); renaming a group renames it in those, and a group a client allows or a
|
||||
site has cannot be removed.
|
||||
|
||||
A VPN user may have WireGuard peers, each a device with its public key and its IPs, in
|
||||
vpnRouterWireGuardPeer; vpn-router-setup writes a [Peer] of wg0.conf for each peer of a user who
|
||||
@@ -30,13 +45,17 @@ vpnrouter_ldap.TOTP_PARAMS. Enrolment stores nothing until a code of the new sec
|
||||
verified: otp_new makes a secret and its otpauth URI, and otp_enrol stores the secret given a
|
||||
code of it. slapd's otp overlay then takes the password followed by a code at every bind.
|
||||
|
||||
A user who is not a service account may have passkeys for signing in to the Router Manager, in
|
||||
vpnRouterPasskey (vpnrouter_passkeys registers and checks them).
|
||||
|
||||
A user is disabled when pwdAccountLockedTime holds LOCKED, and locked when the password
|
||||
policy has set it after failed binds, for vpnrouter_ldap.LOCKOUT_SECONDS; unlock ends that.
|
||||
login() checks a user's password and code with a bind. Members of ADMIN_GROUP manage the
|
||||
router through the web service.
|
||||
|
||||
A member of ADMIN_GROUP may have console login (console): the Shell page of the Router Manager,
|
||||
and SSH and sudo with the directory's password, followed by the code for an enrolled user. The
|
||||
A member of ADMIN_GROUP with a TOTP token may have console login (console): the Shell page of the
|
||||
Router Manager, and SSH and sudo with the directory's password followed by the code; without the
|
||||
token the console is refused, also for one whose console login was switched on before. The
|
||||
console runs as a local account, or as the user's own posixAccount, which the user entry becomes
|
||||
and nslcd serves to the system. Leaving ADMIN_GROUP switches it off. The module knows only the directory; the callers revoke a
|
||||
user's refresh tokens after the operations of vpnrouter_auth.REVOKING.
|
||||
@@ -53,15 +72,23 @@ import contextlib
|
||||
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
|
||||
@@ -69,6 +96,8 @@ import vpnrouter_pki
|
||||
|
||||
#: A user name is one lowercase DNS label, as it is also the common name of the certificate.
|
||||
NAME = re.compile(r'^[a-z][a-z0-9-]{0,30}[a-z0-9]$|^[a-z]$')
|
||||
#: The other name of a service account: a GUID in lowercase hexadecimal, as the Router Manager proposes one.
|
||||
GUID = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')
|
||||
PASSWORD_MIN = 8
|
||||
PASSWORD_MAX = 128
|
||||
#: The longest first or last name, in characters.
|
||||
@@ -83,7 +112,26 @@ LABELS = (('display_name', 'displayName'), ('description', 'description'))
|
||||
#: The attributes read for a user.
|
||||
ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken', 'vpnRouterScope',
|
||||
'displayName', 'description', 'userCertificate;binary', 'vpnRouterConsoleLogin', 'vpnRouterConsoleAccount',
|
||||
'uidNumber', 'vpnRouterWireGuardPeer']
|
||||
'uidNumber', 'vpnRouterWireGuardPeer', 'vpnRouterPasskey', 'vpnRouterRedirectUri',
|
||||
'vpnRouterPostLogoutRedirectUri', 'vpnRouterAllowedGroup', 'vpnRouterGrantType', 'vpnRouterPublicClient',
|
||||
'vpnRouterClientScope', 'vpnRouterResourceScope']
|
||||
#: The grants an OpenID Provider client may have besides the authorization code, which its redirect URIs give it.
|
||||
GRANTS = ('client_credentials', 'device_code')
|
||||
#: The name of a role of a resource, and of a scope a resource defines.
|
||||
ROLE_NAME = re.compile(r'^[A-Za-z][A-Za-z0-9._-]{0,63}$')
|
||||
SCOPE_NAME = re.compile(r'^[a-z][a-z0-9._-]{0,63}$')
|
||||
#: The attributes read for a role.
|
||||
ROLE_ATTRIBUTES = ['cn', 'description', 'member']
|
||||
#: The keys of the URIs of an OpenID Provider client and their LDAP attributes.
|
||||
CLIENT_URIS = (('redirect_uris', 'vpnRouterRedirectUri'), ('post_logout_redirect_uris', 'vpnRouterPostLogoutRedirectUri'))
|
||||
#: The keys of the OpenID Provider settings of a service account (client_settings, and roles of set_roles).
|
||||
CLIENT_KEYS = ('redirect_uris', 'post_logout_redirect_uris', 'allowed_groups', 'grants', 'public_client', 'client_scopes',
|
||||
'resource_scopes', 'roles')
|
||||
#: The most URIs of each kind and allowed groups of one client, and the longest URI.
|
||||
CLIENT_VALUES_MAX = 16
|
||||
URI_MAX = 1024
|
||||
#: The hosts a client's URI may name over plain HTTP: those of the system it runs on.
|
||||
LOOPBACK_HOSTS = ('localhost', '127.0.0.1', '::1')
|
||||
#: The most WireGuard peers of one user.
|
||||
WIREGUARD_PEERS_MAX = 16
|
||||
#: The vpnRouterScope of a service account.
|
||||
@@ -130,9 +178,11 @@ def session():
|
||||
connection.unbind_s()
|
||||
|
||||
|
||||
def check_name(name):
|
||||
if not isinstance(name, str) or not NAME.match(name):
|
||||
raise Failure('a user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter', 2)
|
||||
def check_name(name, service=True):
|
||||
"""Refuse a name that is not a user name (NAME), or, with service, the GUID of a service account either."""
|
||||
if not isinstance(name, str) or not (NAME.match(name) or service and GUID.match(name)):
|
||||
raise Failure('a user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter'
|
||||
+ (', or, for a service account, a GUID in lowercase' if service else ''), 2)
|
||||
|
||||
|
||||
def check_password(value):
|
||||
@@ -186,6 +236,104 @@ def labels(values):
|
||||
return found
|
||||
|
||||
|
||||
def client_settings(connection, values):
|
||||
"""The OpenID Provider settings of a service account in values ({redirect_uris, post_logout_redirect_uris,
|
||||
allowed_groups, grants, public_client, client_scopes, resource_scopes}), checked, as {LDAP attribute: [value, ...]};
|
||||
a key whose value is None is not given, [] or false means none.
|
||||
|
||||
A URI is absolute, has no fragment and uses https, or http for a host of LOOPBACK_HOSTS; an allowed group exists; a
|
||||
grant is one of GRANTS; a client scope is a scope of a resource, <resource>:<scope>; a resource scope is a
|
||||
SCOPE_NAME. Each list has at most CLIENT_VALUES_MAX values.
|
||||
"""
|
||||
found = {}
|
||||
for key, attribute in CLIENT_URIS:
|
||||
uris = values.get(key)
|
||||
if uris is None:
|
||||
continue
|
||||
if not isinstance(uris, list) or len(uris) > CLIENT_VALUES_MAX or not all(isinstance(uri, str) for uri in uris):
|
||||
raise Failure(f'{key} must be a list of at most {CLIENT_VALUES_MAX} URIs', 2)
|
||||
for uri in uris:
|
||||
try:
|
||||
parts = urllib.parse.urlsplit(uri)
|
||||
host = parts.hostname
|
||||
except ValueError:
|
||||
host = None
|
||||
if len(uri) > URI_MAX or not host or parts.fragment or any(character.isspace() for character in uri) or \
|
||||
not (parts.scheme == 'https' or parts.scheme == 'http' and host in LOOPBACK_HOSTS):
|
||||
raise Failure(f'{uri!r} is not an https URI, or an http URI of localhost, without a fragment and of at '
|
||||
f'most {URI_MAX} characters', 2)
|
||||
found[attribute] = [uri.encode() for uri in dict.fromkeys(uris)]
|
||||
allowed = values.get('allowed_groups')
|
||||
if allowed is not None:
|
||||
if not isinstance(allowed, list) or len(allowed) > CLIENT_VALUES_MAX:
|
||||
raise Failure(f'allowed_groups must be a list of at most {CLIENT_VALUES_MAX} group names', 2)
|
||||
for group in allowed:
|
||||
group_entry(connection, group)
|
||||
found['vpnRouterAllowedGroup'] = [group_dn(group).encode() for group in sorted(set(allowed))]
|
||||
grants = values.get('grants')
|
||||
if grants is not None:
|
||||
if not isinstance(grants, list) or not all(isinstance(grant, str) and grant in GRANTS for grant in grants):
|
||||
raise Failure(f'grants must be a list of {", ".join(GRANTS)}', 2)
|
||||
found['vpnRouterGrantType'] = [grant.encode() for grant in GRANTS if grant in grants]
|
||||
public = values.get('public_client')
|
||||
if public is not None:
|
||||
if not isinstance(public, bool):
|
||||
raise Failure('public_client must be true or false', 2)
|
||||
found['vpnRouterPublicClient'] = [b'TRUE'] if public else []
|
||||
scopes = values.get('client_scopes')
|
||||
if scopes is not None:
|
||||
if not isinstance(scopes, list) or len(scopes) > CLIENT_VALUES_MAX or not all(isinstance(scope, str) for scope in scopes):
|
||||
raise Failure(f'client_scopes must be a list of at most {CLIENT_VALUES_MAX} scopes', 2)
|
||||
for scope in scopes:
|
||||
resource, _, name = scope.partition(':')
|
||||
if name not in resource_scopes(connection, resource):
|
||||
raise Failure(f'{scope!r} is not a scope of a resource, <resource>:<scope>', 2)
|
||||
found['vpnRouterClientScope'] = [scope.encode() for scope in sorted(set(scopes))]
|
||||
defined = values.get('resource_scopes')
|
||||
if defined is not None:
|
||||
if not isinstance(defined, list) or len(defined) > CLIENT_VALUES_MAX or \
|
||||
not all(isinstance(scope, str) and SCOPE_NAME.match(scope) for scope in defined):
|
||||
raise Failure(f'resource_scopes must be a list of at most {CLIENT_VALUES_MAX} scope names: lowercase letters, '
|
||||
'digits, dots, underscores and hyphens, starting with a letter, at most 64', 2)
|
||||
found['vpnRouterResourceScope'] = [scope.encode() for scope in sorted(set(defined))]
|
||||
return found
|
||||
|
||||
|
||||
def refuse_client_settings(name, values):
|
||||
"""Refuse OpenID Provider settings for a user who is not a service account."""
|
||||
if any(values.get(key) is not None for key in CLIENT_KEYS):
|
||||
raise Failure(f'{name} is not a service account; only a service account is a client or a resource of the OpenID '
|
||||
'Provider', 2)
|
||||
|
||||
|
||||
def check_client(name, current, changes):
|
||||
"""Refuse the client settings an entry's attributes would have with changes ({LDAP attribute: [value, ...]}) when the
|
||||
client is public and has client_credentials, which needs its secret."""
|
||||
merged = {**current, **changes}
|
||||
if merged.get('vpnRouterPublicClient') == [b'TRUE'] and b'client_credentials' in merged.get('vpnRouterGrantType', []):
|
||||
raise Failure(f'{name}: a public client has no secret, so it cannot use client_credentials', 2)
|
||||
|
||||
|
||||
def resource_scopes(connection, name):
|
||||
"""The scopes the service account defines as a resource, [] when the name is not that of a service account."""
|
||||
if not isinstance(name, str) or not (NAME.match(name) or GUID.match(name)):
|
||||
return []
|
||||
try:
|
||||
attributes = entry(connection, name)
|
||||
except Failure:
|
||||
return []
|
||||
return [value.decode() for value in attributes.get('vpnRouterResourceScope', [])] if service(attributes) else []
|
||||
|
||||
|
||||
def asking(connection, scopes):
|
||||
"""The names of the clients whose client scopes have any of the scopes, <resource>:<scope>, sorted."""
|
||||
if not scopes:
|
||||
return []
|
||||
condition = ''.join(f'(vpnRouterClientScope={ldap.filter.escape_filter_chars(scope)})' for scope in scopes)
|
||||
found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL, f'(|{condition})', ['uid'])
|
||||
return sorted(attributes['uid'][0].decode() for _, attributes in found)
|
||||
|
||||
|
||||
def dn_of(name):
|
||||
return f'uid={name},{vpnrouter_ldap.USERS}'
|
||||
|
||||
@@ -237,7 +385,8 @@ def internal_accounts():
|
||||
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).
|
||||
internal is whether it is an internal service account (internal_accounts). A service account also has its OpenID
|
||||
Provider settings (client_settings), allowed_groups as group names; with_roles adds its roles.
|
||||
"""
|
||||
name = attributes['uid'][0].decode()
|
||||
lock = attributes.get('pwdAccountLockedTime', [None])[0]
|
||||
@@ -252,13 +401,29 @@ def summary(attributes):
|
||||
'otp': 'oathTOTPToken' in attributes,
|
||||
'console': console_of(attributes),
|
||||
'wireguard_peers': peers_of(attributes),
|
||||
'passkeys': [{key: passkey[key] for key in ('id', 'label', 'created')} for _, passkey in passkeys_of(attributes)],
|
||||
}
|
||||
for key, attribute in PERSON + LABELS:
|
||||
values = attributes.get(attribute)
|
||||
user[key] = values[0].decode() if values else None
|
||||
if user['service']:
|
||||
for key, attribute in CLIENT_URIS:
|
||||
user[key] = [value.decode() for value in attributes.get(attribute, [])]
|
||||
suffix = f',{vpnrouter_ldap.GROUPS}'
|
||||
user['allowed_groups'] = sorted(value.decode()[3:-len(suffix)] for value in attributes.get('vpnRouterAllowedGroup', [])
|
||||
if value.decode().startswith('cn=') and value.decode().endswith(suffix))
|
||||
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
|
||||
@@ -284,6 +449,20 @@ def peers_of(attributes):
|
||||
return peers
|
||||
|
||||
|
||||
def passkeys_of(attributes):
|
||||
"""The passkeys of an entry (vpnrouter_passkeys) with their stored values: [(value, {id, credential, label,
|
||||
created})]; values that cannot be read are left out."""
|
||||
passkeys = []
|
||||
for value in attributes.get('vpnRouterPasskey', []):
|
||||
try:
|
||||
passkey = json.loads(value)
|
||||
except ValueError:
|
||||
continue
|
||||
if isinstance(passkey, dict) and all(isinstance(passkey.get(key), str) for key in ('id', 'credential', 'label', 'created')):
|
||||
passkeys.append((value, passkey))
|
||||
return passkeys
|
||||
|
||||
|
||||
def checked_peers(connection, name, peers):
|
||||
"""The values of vpnRouterWireGuardPeer for the user's WireGuard peers, [{public_key, ips}], when they are valid.
|
||||
|
||||
@@ -349,8 +528,8 @@ def free_uid(connection):
|
||||
|
||||
|
||||
def console(connection, name, enabled, account=None):
|
||||
"""Switch the console login of a member of ADMIN_GROUP on, as the local account given or, without one, as the
|
||||
user's own posixAccount, or off.
|
||||
"""Switch the console login of a member of ADMIN_GROUP with a TOTP token on, as the local account given or, without
|
||||
one, as the user's own posixAccount, or off.
|
||||
|
||||
The posixAccount is added the first time and kept when the console login is switched off, so its uidNumber and the
|
||||
home it owns stay the user's. A local account must exist in the system's passwd with a uid from 1000 to below
|
||||
@@ -373,6 +552,8 @@ def console(connection, name, enabled, account=None):
|
||||
raise Failure(f'{name} is a service account, which has no console', 2)
|
||||
if ADMIN_GROUP not in groups_of(connection, name):
|
||||
raise Failure(f'user {name} is not a member of {ADMIN_GROUP}', 2)
|
||||
if 'oathTOTPToken' not in current:
|
||||
raise Failure(f'user {name} has no TOTP token; console login needs one', 2)
|
||||
if account is not None:
|
||||
try:
|
||||
local = pwd.getpwnam(account)
|
||||
@@ -440,14 +621,22 @@ 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 and its labels (labels)."""
|
||||
check_name(name)
|
||||
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
|
||||
if service and vpn_user:
|
||||
raise Failure('a service account has no first or last name', 2)
|
||||
client = {}
|
||||
if service:
|
||||
details.update({attribute: value for attribute, value in labels(values).items() if value})
|
||||
client = {attribute: found for attribute, found in client_settings(connection, values).items() if found}
|
||||
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):
|
||||
raise Failure('give first_name, last_name and email for a VPN user, or at most email for a Manager-only account', 2)
|
||||
check_password(password)
|
||||
@@ -462,6 +651,7 @@ def add(connection, name, password, values, service=False):
|
||||
if service:
|
||||
record['vpnRouterScope'] = [SERVICE_SCOPE.encode()]
|
||||
record.update({attribute: [value.encode()] for attribute, value in details.items()})
|
||||
record.update(client)
|
||||
try:
|
||||
connection.add_s(dn_of(name), ldap.modlist.addModlist(record))
|
||||
except ldap.ALREADY_EXISTS:
|
||||
@@ -472,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))
|
||||
|
||||
|
||||
@@ -485,6 +679,7 @@ def update(connection, name, values):
|
||||
current = entry(connection, name)
|
||||
if service(current):
|
||||
return update_service(connection, name, values)
|
||||
refuse_client_settings(name, values)
|
||||
if manager_only(current):
|
||||
if values.get('wireguard_peers') is not None:
|
||||
raise Failure(f'user {name} is a Manager-only account, which has no VPN access and so no WireGuard peers', 2)
|
||||
@@ -525,18 +720,37 @@ def apply_wireguard():
|
||||
|
||||
|
||||
def update_service(connection, name, values):
|
||||
"""Change any of the e-mail address and the labels (labels) of a service account that values gives; '' removes one."""
|
||||
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 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 or description', 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):
|
||||
@@ -579,12 +793,13 @@ def unlock(connection, name):
|
||||
|
||||
|
||||
def login(name, password, code):
|
||||
"""Check the user's password, and the TOTP code of an enrolled user, with a bind as the user.
|
||||
"""Check the user's password, and the TOTP code of an enrolled user, with a bind as the user; a service account may be
|
||||
named by a GUID.
|
||||
|
||||
A wrong name, password or code, a locked or a disabled user give the same Failure, status 5.
|
||||
"""
|
||||
refused = Failure('the name, the password or the code is wrong', 5)
|
||||
if not isinstance(name, str) or not NAME.match(name) or not isinstance(password, str) or not password:
|
||||
if not isinstance(name, str) or not (NAME.match(name) or GUID.match(name)) or not isinstance(password, str) or not password:
|
||||
raise refused
|
||||
if code is not None and code != '' and (not isinstance(code, str) or not CODE.match(code)):
|
||||
raise refused
|
||||
@@ -639,13 +854,15 @@ def otp_enrol(connection, name, secret, code):
|
||||
|
||||
|
||||
def otp_remove(connection, name):
|
||||
"""Remove the user's TOTP token, when the user has one."""
|
||||
if 'oathTOTPToken' in entry(connection, name):
|
||||
"""Remove the user's TOTP token, when the user has one, and end the console sessions it needed (end_sessions)."""
|
||||
current = entry(connection, name)
|
||||
if 'oathTOTPToken' in current:
|
||||
token = connection.search_s(dn_of(name), ldap.SCOPE_BASE, attrlist=TOKEN_ATTRIBUTES)[0][1]
|
||||
changes = [(ldap.MOD_DELETE, 'objectClass', [b'oathTOTPUser', b'oathTOTPToken']),
|
||||
(ldap.MOD_DELETE, 'oathSecret', None)]
|
||||
changes += [(ldap.MOD_DELETE, attribute, None) for attribute in token]
|
||||
connection.modify_s(dn_of(name), changes)
|
||||
end_sessions(current)
|
||||
return summary(entry(connection, name))
|
||||
|
||||
|
||||
@@ -653,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:
|
||||
@@ -705,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}'
|
||||
|
||||
@@ -761,6 +1061,13 @@ def groups_of(connection, name):
|
||||
return {attributes['cn'][0].decode(): member_names(attributes) for _, attributes in found}
|
||||
|
||||
|
||||
def allowing(connection, name):
|
||||
"""The names of the OpenID Provider's clients whose allowed groups have the group, sorted."""
|
||||
found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL,
|
||||
f'(vpnRouterAllowedGroup={ldap.filter.escape_filter_chars(group_dn(name))})', ['uid'])
|
||||
return sorted(attributes['uid'][0].decode() for _, attributes in found)
|
||||
|
||||
|
||||
def list_groups(connection):
|
||||
found = connection.search_s(vpnrouter_ldap.GROUPS, ldap.SCOPE_ONELEVEL, '(objectClass=groupOfNames)', GROUP_ATTRIBUTES)
|
||||
return {'groups': sorted((group_summary(attributes) for _, attributes in found), key=lambda group: group['name'])}
|
||||
@@ -787,7 +1094,9 @@ 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."""
|
||||
switches the console login of its members off; renaming a group a client of the OpenID Provider allows renames it
|
||||
there, as in the roles it is assigned, and renaming the editors group of web sites renames it in the sites."""
|
||||
import vpnrouter_sites
|
||||
if new_name is None and description is None:
|
||||
raise Failure('give a new name or a description', 2)
|
||||
members = member_names(group_entry(connection, name))
|
||||
@@ -802,6 +1111,16 @@ def group_update(connection, name, new_name=None, description=None):
|
||||
connection.rename_s(group_dn(name), f'cn={new_name}', delold=1)
|
||||
except ldap.ALREADY_EXISTS:
|
||||
raise Failure(f'group {new_name} exists', 4)
|
||||
for client in allowing(connection, name):
|
||||
connection.modify_s(dn_of(client), [(ldap.MOD_DELETE, 'vpnRouterAllowedGroup', [group_dn(name).encode()]),
|
||||
(ldap.MOD_ADD, 'vpnRouterAllowedGroup', [group_dn(new_name).encode()])])
|
||||
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
|
||||
@@ -809,9 +1128,21 @@ def group_update(connection, name, new_name=None, description=None):
|
||||
|
||||
|
||||
def group_remove(connection, name):
|
||||
"""Remove the group; removing ADMIN_GROUP switches the console login of its members off."""
|
||||
"""Remove the group, unless a client of the OpenID Provider allows it or it is the editors group of web sites, and
|
||||
take it out of the roles it is assigned; removing ADMIN_GROUP switches the console login of its members off."""
|
||||
import vpnrouter_sites
|
||||
members = member_names(group_entry(connection, name))
|
||||
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}
|
||||
@@ -840,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 its secondaries, 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,81 @@
|
||||
"""/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", "installed", "running", "domains", "problem"}: the dns section of the
|
||||
configuration, whether PowerDNS is installed and runs, the domains, each {"name", "sites",
|
||||
"ttl", "secondaries", "manual", "records"} with its secondaries {"nameserver", "address"},
|
||||
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
|
||||
POST /dns/domains add a domain: {"name", "sites"?, "ttl"?}; sites gives the host names of the enabled sites
|
||||
in it records; ttl is dns.ttl when null or absent; an existing name is 409
|
||||
PUT /dns/domains/<domain>
|
||||
change "sites" or "ttl" of the domain
|
||||
DELETE /dns/domains/<domain>
|
||||
remove the domain with its secondaries and manual records
|
||||
POST /dns/domains/<domain>/secondaries
|
||||
add a secondary name server of the domain, or change its address: {"nameserver", "address"}
|
||||
DELETE /dns/domains/<domain>/secondaries/<nameserver>
|
||||
remove the secondary name server
|
||||
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'], 'installed': vpnrouter_dns.PDNS_SERVER.exists(),
|
||||
'running': unit_state(vpnrouter_dns.UNIT) == 'active', 'domains': domains,
|
||||
'problem': problem}
|
||||
if route == ('PUT', 'dns'):
|
||||
changed = section_change('dns', body)
|
||||
return changed if isinstance(changed, tuple) else (200, {'settings': changed})
|
||||
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', 'secondaries') and len(parts) == 4:
|
||||
document, code = vpnrouter_dns.secondary_add(parts[2], body), 201
|
||||
elif route == ('DELETE', 'dns', 'domains', 'secondaries') and len(parts) == 5:
|
||||
document, code = vpnrouter_dns.secondary_remove(parts[2], parts[4]), 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,244 @@
|
||||
"""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 .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', '/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 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,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,152 @@
|
||||
"""/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"}; an existing name is 409
|
||||
GET /sites/<name> the site
|
||||
PUT /sites/<name> change any of the settings of POST but name; an empty or absent private_key keeps the key
|
||||
DELETE /sites/<name> remove the site, with content=true also its folder
|
||||
|
||||
A change is applied by restarting vpn-router-setup without waiting for it.
|
||||
"""
|
||||
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
body = body or {}
|
||||
parts = path.strip('/').split('/')
|
||||
route = (method, len(parts))
|
||||
try:
|
||||
cfg = vpnrouter.load_config()
|
||||
if route == ('GET', 1):
|
||||
return 200, {'settings': cfg['sites'], 'sites': vpnrouter_sites.listing()}
|
||||
if route == ('PUT', 1):
|
||||
changed = section_change('sites', body)
|
||||
return changed if isinstance(changed, tuple) else (200, {'settings': changed})
|
||||
if route == ('POST', 1):
|
||||
name = body.get('name')
|
||||
document = vpnrouter_sites.save(cfg, name, {key: value for key, value in body.items() if key != 'name'},
|
||||
True)
|
||||
code = 201
|
||||
elif route == ('GET', 2):
|
||||
with vpnrouter_sites.database() as connection:
|
||||
return 200, vpnrouter_sites.shown(vpnrouter_sites.get(connection, parts[1]))
|
||||
elif route == ('PUT', 2):
|
||||
document, code = vpnrouter_sites.save(cfg, parts[1], body, False), 200
|
||||
elif route == ('DELETE', 2):
|
||||
document = vpnrouter_sites.remove(parts[1], query.get('content', [''])[0] == 'true')
|
||||
code = 200
|
||||
else:
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
except vpnrouter_sites.Failure as failure:
|
||||
return SITE_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||
except vpnrouter_sites.DatabaseError as exc:
|
||||
return 500, {'error': str(exc)}
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
return 500, {'error': f'the site cannot be changed: {exc}'}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
|
||||
return code, document
|
||||
|
||||
|
||||
def site_file_action(name, target, body):
|
||||
"""Answer a POST to a site's file or folder: {"folder": true} creates the folder, {"rename": "<path>"} moves the file
|
||||
or folder to the path in the site's folder; the HTTP status and the JSON document."""
|
||||
if not isinstance(body, dict):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
try:
|
||||
if body.get('folder') is True:
|
||||
vpnrouter_sites.make_folder(target)
|
||||
return 201, {'path': vpnrouter_sites.relative_of(name, target), 'folder': True}
|
||||
if isinstance(body.get('rename'), str):
|
||||
moved = vpnrouter_sites.rename(name, target, body['rename'])
|
||||
return 200, {'path': vpnrouter_sites.relative_of(name, moved)}
|
||||
except vpnrouter_sites.Failure as failure:
|
||||
return SITE_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||
except OSError as exc:
|
||||
return 500, {'error': f'{exc.filename or target.name}: {exc.strerror}'}
|
||||
return 400, {'error': 'give {"folder": true} or {"rename": "<path>"}'}
|
||||
|
||||
|
||||
def site_files(handler, name, relative, query, payload, send_body):
|
||||
"""Answer a request of a site's files the Handler handler received: the HTTP status and the JSON document, or None
|
||||
once a file was sent.
|
||||
|
||||
GET /sites/<name>/files/<path> a folder: {"path", "entries": [{name, folder, size, modified}]}; a file:
|
||||
the file itself, as an attachment
|
||||
PUT /sites/<name>/files/<path> write the body to the file, its folders created; with unzip=true the body
|
||||
is a zip archive extracted into the folder of the path: {"path", "files"}
|
||||
POST /sites/<name>/files/<path> {"folder": true} creates the folder, {"rename": "<path>"} moves it
|
||||
DELETE /sites/<name>/files/<path> remove the file or folder
|
||||
|
||||
The body of a PUT needs a Content-Length of at most sites.upload_max_mb MiB.
|
||||
"""
|
||||
try:
|
||||
target = vpnrouter_sites.resolve(name, urllib.parse.unquote(relative))
|
||||
if handler.command in ('GET', 'HEAD'):
|
||||
if target.is_dir():
|
||||
return 200, vpnrouter_sites.entries(name, target)
|
||||
if not target.is_file():
|
||||
return 404, {'error': f'{relative} does not exist'}
|
||||
with open(target, 'rb') as handle:
|
||||
size = os.fstat(handle.fileno()).st_size
|
||||
try:
|
||||
handler.send_response(200)
|
||||
handler.send_header('Content-Type', mimetypes.guess_type(target.name)[0] or 'application/octet-stream')
|
||||
handler.send_header('Content-Length', str(size))
|
||||
handler.send_header('Content-Disposition',
|
||||
f"attachment; filename*=UTF-8''{urllib.parse.quote(target.name)}")
|
||||
handler.send_header('Cache-Control', 'no-store')
|
||||
handler.end_headers()
|
||||
if send_body:
|
||||
shutil.copyfileobj(handle, handler.wfile, vpnrouter_sites.CHUNK)
|
||||
except ConnectionError:
|
||||
# The client went away before the file was sent.
|
||||
handler.close_connection = True
|
||||
return None
|
||||
if handler.command == 'PUT':
|
||||
length = handler.headers.get('Content-Length')
|
||||
if not length or not length.isdigit():
|
||||
return 411, {'error': 'the body needs a Content-Length'}
|
||||
limit = vpnrouter.load_config()['sites']['upload_max_mb'] * MIB
|
||||
if query.get('unzip', [''])[0] == 'true':
|
||||
count = vpnrouter_sites.extract(handler.rfile, int(length), target, limit)
|
||||
return 200, {'path': vpnrouter_sites.relative_of(name, target), 'files': count}
|
||||
vpnrouter_sites.receive(handler.rfile, int(length), target, limit)
|
||||
return 200, {'path': vpnrouter_sites.relative_of(name, target), 'files': 1}
|
||||
if handler.command == 'POST':
|
||||
return site_file_action(name, target, payload)
|
||||
if handler.command == 'DELETE':
|
||||
if not target.exists() and not target.is_symlink():
|
||||
return 404, {'error': f'{relative} does not exist'}
|
||||
vpnrouter_sites.delete(name, target)
|
||||
return 200, {'path': vpnrouter_sites.relative_of(name, target), 'removed': True}
|
||||
except vpnrouter_sites.Failure as failure:
|
||||
return SITE_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||
except OSError as exc:
|
||||
return 500, {'error': f'{exc.filename or relative}: {exc.strerror}'}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
return 405, {'error': f'{handler.command} is not allowed on a site\'s files'}
|
||||
@@ -0,0 +1,325 @@
|
||||
"""The status of the router: / for people, /status, /status/full, /config and /postgresql, with the cache of the
|
||||
status that the requests of CACHE_SECONDS share (cached)."""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from html import escape
|
||||
|
||||
import vpnrouter
|
||||
from vpnrouter_status import established, established_each, openvpn_clients, run, swanctl, wireguard_peers
|
||||
|
||||
from .common import current, unit_state
|
||||
|
||||
#: Seconds a collected status is answered again.
|
||||
CACHE_SECONDS = 2
|
||||
|
||||
TAILWIND = 'https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4'
|
||||
|
||||
#: Seconds after which the status page in a browser reloads itself.
|
||||
REFRESH_SECONDS = 60
|
||||
|
||||
#: The PostgreSQL settings /config shows for each cluster.
|
||||
POSTGRESQL_SETTINGS = ('server_version', 'listen_addresses', 'port', 'max_connections', 'shared_buffers',
|
||||
'effective_cache_size', 'work_mem', 'password_encryption', 'ssl', 'timezone')
|
||||
|
||||
_cache = {'at': 0.0, 'data': None}
|
||||
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def package_version(package='vpn-router'):
|
||||
"""The installed version of the package, or None when it is not installed."""
|
||||
result = run('dpkg-query', '-W', '-f=${Version}', package)
|
||||
return (result.stdout.strip() or None) if result and result.returncode == 0 else None
|
||||
|
||||
|
||||
def docker_status():
|
||||
"""Whether Docker is installed, and the state of its service and of the Docker rules when it is."""
|
||||
result = run('systemctl', 'list-unit-files', '--no-legend', 'docker.service')
|
||||
if result is None or result.returncode != 0 or not result.stdout.strip():
|
||||
return {'installed': False}
|
||||
return {
|
||||
'installed': True,
|
||||
'service': unit_state('docker.service'),
|
||||
'rules': unit_state('vpn-router-docker.service'),
|
||||
}
|
||||
|
||||
|
||||
def postgresql_clusters():
|
||||
"""The state of each PostgreSQL cluster unit (postgresql@<version>-<name>), or {'postgresql': 'no cluster'} without one.
|
||||
|
||||
postgresql.service, which only runs /bin/true, is not reported.
|
||||
"""
|
||||
lines = lines_of('systemctl', 'list-units', 'postgresql@*.service', '--all', '--plain', '--no-legend') or []
|
||||
names = [line.split()[0].removesuffix('.service') for line in lines if line.strip()]
|
||||
return {name: unit_state(f'{name}.service') for name in names} or {'postgresql': 'no cluster'}
|
||||
|
||||
|
||||
def collect(state):
|
||||
"""The status document. state says which features the router has."""
|
||||
units = {'vpn-router-setup': unit_state('vpn-router-setup.service'), 'slapd': unit_state('slapd.service'),
|
||||
**postgresql_clusters()}
|
||||
manager_version = package_version('vpn-router-manager')
|
||||
if manager_version:
|
||||
units['vpn-router-manager'] = unit_state('vpn-router-manager.service')
|
||||
if state.get('site_to_site') or state.get('p2s'):
|
||||
units['strongswan'] = unit_state('strongswan.service')
|
||||
if state.get('wireguard'):
|
||||
units['wg-quick@wg0'] = unit_state('wg-quick@wg0.service')
|
||||
if state.get('openvpn'):
|
||||
units['openvpn-server@vpn-router'] = unit_state('openvpn-server@vpn-router.service')
|
||||
|
||||
status = {
|
||||
'status': 'ok' if all(value == 'active' for value in units.values()) else 'degraded',
|
||||
'version': package_version(),
|
||||
'manager_version': manager_version,
|
||||
'time': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
|
||||
'units': units,
|
||||
'docker': docker_status(),
|
||||
}
|
||||
names = state.get('site_to_site')
|
||||
if isinstance(names, list) and names:
|
||||
counts = established_each(names).values()
|
||||
status['site_to_site'] = {'connections': len(names),
|
||||
'established': None if None in counts else sum(1 for count in counts if count)}
|
||||
if state.get('p2s'):
|
||||
status['p2s'] = {'clients': established('road-warrior')}
|
||||
if state.get('wireguard'):
|
||||
status['wireguard'] = {'peers': wireguard_peers()}
|
||||
if state.get('openvpn'):
|
||||
status['openvpn'] = {'clients': openvpn_clients()}
|
||||
return status
|
||||
|
||||
|
||||
def shown(status, content, client):
|
||||
"""The status document as web.status_content shows it to the client at its address.
|
||||
|
||||
Every content has status, time and client_ip, the client's address, and content, which one it is: basic nothing
|
||||
more; standard adds version, manager_version and services, the number of active units and of units in every
|
||||
other state; full adds the whole status document of collect. Content that is not one of
|
||||
vpnrouter.STATUS_CONTENTS, or not set, is basic.
|
||||
"""
|
||||
if content not in vpnrouter.STATUS_CONTENTS:
|
||||
content = 'basic'
|
||||
if content == 'full':
|
||||
return {**status, 'content': content, 'client_ip': client}
|
||||
document = {'status': status['status'], 'content': content, 'time': status['time'], 'client_ip': client}
|
||||
if content == 'standard':
|
||||
active = sum(1 for value in status['units'].values() if value == 'active')
|
||||
document.update(version=status['version'], manager_version=status['manager_version'],
|
||||
services={'active': active, 'failed': len(status['units']) - active})
|
||||
return document
|
||||
|
||||
|
||||
def lines_of(*args):
|
||||
"""The output lines of a command, or None when it fails."""
|
||||
result = run(*args)
|
||||
return result.stdout.splitlines() if result and result.returncode == 0 else None
|
||||
|
||||
|
||||
def routes():
|
||||
"""The IPv4 routes as ip -j prints them, or None when ip fails."""
|
||||
result = run('ip', '-j', '-4', 'route', 'show')
|
||||
if result is None or result.returncode != 0:
|
||||
return None
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def wireguard_details():
|
||||
"""The interface and the peers of wg0, with endpoints and allowed IPs; no private or preshared key."""
|
||||
result = run('wg', 'show', 'wg0', 'dump')
|
||||
if result is None or result.returncode != 0:
|
||||
return None
|
||||
rows = [line.split('\t') for line in result.stdout.splitlines()]
|
||||
now = int(time.time())
|
||||
return {
|
||||
'public_key': rows[0][1] if rows and len(rows[0]) > 2 else None,
|
||||
'listen_port': int(rows[0][2]) if rows and len(rows[0]) > 2 else None,
|
||||
'peers': [{
|
||||
'public_key': row[0],
|
||||
'endpoint': None if row[2] == '(none)' else row[2],
|
||||
'allowed_ips': row[3].split(','),
|
||||
'handshake_seconds_ago': now - int(row[4]) if int(row[4]) else None,
|
||||
'received_bytes': int(row[5]),
|
||||
'sent_bytes': int(row[6]),
|
||||
} for row in rows[1:] if len(row) >= 7],
|
||||
}
|
||||
|
||||
|
||||
def postgresql_query(cluster, sql):
|
||||
"""The JSON value an SQL statement answers on a cluster (version/name), run by psql as postgres, or None when it fails."""
|
||||
result = run('runuser', '-u', 'postgres', '--', 'psql', '--cluster', cluster, '-X', '-q', '-t', '-A',
|
||||
'-v', 'ON_ERROR_STOP=1', '-d', 'postgres', '-c', sql)
|
||||
if result is None or result.returncode != 0:
|
||||
return None
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def postgresql_clusters_of():
|
||||
"""The PostgreSQL clusters of pg_lsclusters, each with version, name, port, status and data_directory, or None when it fails."""
|
||||
lines = lines_of('pg_lsclusters', '--no-header')
|
||||
if lines is None:
|
||||
return None
|
||||
clusters = []
|
||||
for line in lines:
|
||||
fields = line.split()
|
||||
if len(fields) >= 6:
|
||||
clusters.append({'version': fields[0], 'name': fields[1], 'port': int(fields[2]) if fields[2].isdigit() else None,
|
||||
'status': fields[3], 'data_directory': fields[5]})
|
||||
return clusters
|
||||
|
||||
|
||||
def postgresql_configuration():
|
||||
"""The PostgreSQL clusters on the router with the settings of POSTGRESQL_SETTINGS of each one that is online (None
|
||||
for one that is not), or None when pg_lsclusters fails."""
|
||||
clusters = postgresql_clusters_of()
|
||||
if clusters is None:
|
||||
return None
|
||||
names = ', '.join(f"'{name}'" for name in POSTGRESQL_SETTINGS)
|
||||
for cluster in clusters:
|
||||
cluster['settings'] = postgresql_query(
|
||||
f"{cluster['version']}/{cluster['name']}",
|
||||
f'SELECT json_object_agg(name, current_setting(name) ORDER BY name) FROM pg_settings WHERE name IN ({names})',
|
||||
) if cluster['status'].startswith('online') else None
|
||||
return {'clusters': clusters}
|
||||
|
||||
|
||||
def postgresql_databases():
|
||||
"""What /postgresql shows: the PostgreSQL clusters on the router, each with its databases (name, owner, encoding and
|
||||
size in bytes) and their total size, None for a cluster that is not online; clusters is None when pg_lsclusters fails."""
|
||||
clusters = postgresql_clusters_of()
|
||||
if clusters is None:
|
||||
return {'clusters': None}
|
||||
for cluster in clusters:
|
||||
databases = postgresql_query(
|
||||
f"{cluster['version']}/{cluster['name']}",
|
||||
"SELECT coalesce(json_agg(json_build_object('name', datname, 'owner', pg_get_userbyid(datdba), "
|
||||
"'encoding', pg_encoding_to_char(encoding), 'size', pg_database_size(oid)) ORDER BY datname), '[]') "
|
||||
'FROM pg_database',
|
||||
) if cluster['status'].startswith('online') else None
|
||||
cluster['databases'] = databases
|
||||
cluster['size'] = sum(database['size'] for database in databases) if isinstance(databases, list) else None
|
||||
return {'clusters': clusters}
|
||||
|
||||
|
||||
def details(state):
|
||||
"""What /config shows: the configuration without its secrets, and what the router resolved and runs; caddy is the
|
||||
installed Caddy, {version, source, service}, service the state of caddy.service, null when it is not installed; oidc
|
||||
is {enabled, issuer} of the OpenID Provider, issuer null while it is disabled."""
|
||||
configuration = state.get('configuration', {})
|
||||
caddy = package_version('caddy')
|
||||
oidc = configuration.get('oidc', {}).get('enabled') is True
|
||||
return {
|
||||
'configuration': configuration,
|
||||
'network': dict(state.get('network', {}), routes=routes()),
|
||||
'ipsec': {
|
||||
'connections': swanctl('--list-conns'),
|
||||
'sas': swanctl('--list-sas'),
|
||||
},
|
||||
'wireguard': wireguard_details(),
|
||||
'docker': {
|
||||
'exposed_ports': configuration.get('docker', {}).get('exposed_ports', []),
|
||||
'rules': lines_of('iptables', '-S', 'VPN-ROUTER-DOCKER'),
|
||||
},
|
||||
'postgresql': postgresql_configuration(),
|
||||
# Caddy is upstream's once its repository is configured (vpn-router-caddy).
|
||||
'caddy': {'version': caddy, 'source': 'upstream' if vpnrouter.CADDY_APT_SOURCE.is_file() else 'ubuntu',
|
||||
'service': unit_state('caddy.service')} if caddy else None,
|
||||
'oidc': {'enabled': oidc, 'issuer': vpnrouter.oidc_issuer(configuration) if oidc else None},
|
||||
}
|
||||
|
||||
|
||||
def text_of(status):
|
||||
"""The status document of shown for people, as plain text: a header, a blank line, then name: value lines."""
|
||||
lines = ['VPN Router', f'Status: {status["status"]}']
|
||||
if 'version' in status:
|
||||
lines.append(f'Version: {status["version"] or "unknown"}')
|
||||
lines += ['', f'Client IP: {status["client_ip"]}']
|
||||
if 'manager_version' in status:
|
||||
lines.append(f'Router Manager: {status["manager_version"] or "not installed"}')
|
||||
if 'services' in status:
|
||||
lines.append(f'Services: {status["services"]["active"]} active, {status["services"]["failed"]} failed')
|
||||
lines += [f'{name}: {value}' for name, value in status.get('units', {}).items()]
|
||||
if 'docker' in status:
|
||||
docker = status['docker']
|
||||
lines.append('Docker: not installed' if not docker['installed']
|
||||
else f'Docker: installed, service {docker["service"]}, rules {docker["rules"]}')
|
||||
if 'site_to_site' in status:
|
||||
established, connections = status['site_to_site']['established'], status['site_to_site']['connections']
|
||||
lines.append(f'Site to site: {"unknown" if established is None else established} of {connections} established')
|
||||
if 'p2s' in status:
|
||||
clients = status['p2s']['clients']
|
||||
lines.append(f'IKEv2: {"unknown" if clients is None else f"{clients} connected"}')
|
||||
if 'openvpn' in status:
|
||||
clients = status['openvpn']['clients']
|
||||
lines.append(f'OpenVPN: {"unknown" if clients is None else f"{clients} connected"}')
|
||||
if 'wireguard' in status:
|
||||
peers = status['wireguard']['peers']
|
||||
if peers is None:
|
||||
lines.append('WireGuard: down')
|
||||
else:
|
||||
lines.append(f'WireGuard: {len(peers)} peers')
|
||||
for number, peer in enumerate(peers, 1):
|
||||
age = peer['handshake_seconds_ago']
|
||||
lines.append(f' peer {number}: {"no handshake yet" if age is None else f"handshake {age} s ago"}, '
|
||||
f'received {peer["received_bytes"]} B, sent {peer["sent_bytes"]} B')
|
||||
return '\n'.join(lines) + '\n'
|
||||
|
||||
|
||||
def html_of(status):
|
||||
"""The status document of shown for a browser, as a page styled with Tailwind CSS."""
|
||||
colour = 'bg-emerald-600 dark:bg-emerald-700' if status['status'] == 'ok' else 'bg-amber-600 dark:bg-amber-700'
|
||||
version = f', version {status["version"] or "unknown"}' if 'version' in status else ''
|
||||
# Each "name: value" line after the header of the text is a row; indented lines, such as WireGuard peers, are indented here too.
|
||||
rows = []
|
||||
for line in text_of(status).split('\n\n', 1)[1].splitlines():
|
||||
if not line:
|
||||
continue
|
||||
name, _, value = line.partition(': ')
|
||||
indent = ' pl-8' if line.startswith(' ') else ''
|
||||
rows.append(f'<tr class="border-t border-slate-200 first:border-t-0 dark:border-slate-700">'
|
||||
f'<th class="py-2 pr-4{indent} text-left font-medium text-slate-600 dark:text-slate-300">{escape(name.strip())}</th>'
|
||||
f'<td class="py-2">{escape(value)}</td></tr>')
|
||||
body = ''.join(rows)
|
||||
return f"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta http-equiv="refresh" content="{REFRESH_SECONDS}">
|
||||
<title>VPN Router</title>
|
||||
<script src="{TAILWIND}"></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-100 text-slate-900 dark:bg-slate-900 dark:text-slate-100">
|
||||
<div class="mx-auto max-w-2xl px-4 py-10">
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-800">
|
||||
<div class="{colour} px-6 py-4 text-white">
|
||||
<h1 class="text-xl font-semibold">VPN Router</h1>
|
||||
<p class="mt-1 text-sm">Status: {escape(status['status'] + version)}</p>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<table class="w-full text-sm"><tbody>{body}</tbody></table>
|
||||
</div>
|
||||
<p class="px-6 pb-6 text-xs text-slate-500 dark:text-slate-400">{escape(status['time'])} - <a class="underline" href="/status">JSON</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def cached():
|
||||
"""The status of collect, collected again when it is older than CACHE_SECONDS."""
|
||||
with _lock:
|
||||
if _cache['data'] is None or time.monotonic() - _cache['at'] > CACHE_SECONDS:
|
||||
_cache['data'] = collect(current())
|
||||
_cache['at'] = time.monotonic()
|
||||
return _cache['data']
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/bin/sh
|
||||
# Wait until libpq is in the dynamic linker cache, where psycopg looks it up.
|
||||
# ExecStartPre= of vpn-router-web.service; TimeoutStartSec= of the unit bounds the wait.
|
||||
# ExecStartPre= of vpn-router-setup.service, vpn-router-web.service and vpn-router-metrics.service;
|
||||
# TimeoutStartSec= of the unit bounds the wait.
|
||||
|
||||
reported=
|
||||
until ldconfig -p | grep -q '^[[:space:]]*libpq\.so\.5 '; do
|
||||
|
||||
Regular → Executable
+19
-1482
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
+157
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/python3
|
||||
"""Manage the web sites Caddy serves from the command line, with vpnrouter_sites.
|
||||
|
||||
A site has a name, which also names its folder in /var/lib/vpn-router/sites, host names, a port, the interfaces whose
|
||||
firewall opens it, TLS (none, acme, ca with --ca, or file with --certificate and --key), the file served for a folder,
|
||||
whether any path without a file is served that file of the site (--spa), and the group whose members change its files.
|
||||
A change is applied by restarting vpn-router-setup without waiting for it. The answer is text for people on standard
|
||||
output, and a failure is a message on standard error. With --json the answer is JSON on standard output, and a failure
|
||||
is {"error": ...} there too. The exit status says what failed: 2 the request is invalid, 3 the site does not exist, 4
|
||||
the site exists, 1 anything else. -h or --help shows the commands and their options.
|
||||
|
||||
vpn-router-site [--json] list
|
||||
vpn-router-site [--json] show <name>
|
||||
vpn-router-site [--json] add <name> [options]
|
||||
vpn-router-site [--json] update <name> [options]
|
||||
vpn-router-site [--json] remove <name> [--content]
|
||||
|
||||
options: [--host H ...] [--no-hosts] [--port P] [--listen internal|external|both] [--tls none|acme|ca|file]
|
||||
[--ca NAME] [--certificate FILE] [--key FILE] [--index FILE] [--spa | --no-spa]
|
||||
[--enabled | --no-enabled] [--editors GROUP]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '/usr/lib/vpn-router')
|
||||
|
||||
import ldap
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_sites
|
||||
from vpnrouter_sites import Failure
|
||||
|
||||
#: The help line of each command.
|
||||
HELP = {
|
||||
'list': 'list the sites',
|
||||
'show': 'show a site',
|
||||
'add': 'add a site',
|
||||
'update': 'change the settings of a site',
|
||||
'remove': 'remove a site; its folder is kept without --content',
|
||||
}
|
||||
|
||||
|
||||
class Parser(argparse.ArgumentParser):
|
||||
"""An argument parser whose errors are Failures with status 2, so that --json answers them as JSON."""
|
||||
|
||||
def error(self, message):
|
||||
raise Failure(f'{message}; see {self.prog} --help', 2)
|
||||
|
||||
|
||||
def parse(argv):
|
||||
parser = Parser(prog='vpn-router-site', description='Manage the web sites of the router.')
|
||||
parser.add_argument('--json', action='store_true', help='answer in JSON')
|
||||
commands = parser.add_subparsers(dest='command', required=True, metavar='command', parser_class=Parser)
|
||||
for command, text in HELP.items():
|
||||
sub = commands.add_parser(command, help=text, description=text[0].upper() + text[1:] + '.')
|
||||
if command != 'list':
|
||||
sub.add_argument('name', help='the site name')
|
||||
if command in ('add', 'update'):
|
||||
sub.add_argument('--host', dest='hosts', action='append', metavar='HOST',
|
||||
help='a host name; give it once for each name, replacing the current ones')
|
||||
sub.add_argument('--no-hosts', action='store_true', help='answer any host name')
|
||||
sub.add_argument('--port', type=int, metavar='PORT', help='the TCP port')
|
||||
sub.add_argument('--listen', choices=vpnrouter_sites.LISTEN, help='the interfaces whose firewall opens the port')
|
||||
sub.add_argument('--tls', choices=vpnrouter_sites.TLS, help='where the certificate comes from')
|
||||
sub.add_argument('--ca', metavar='NAME', help='the issuing CA of tls ca')
|
||||
sub.add_argument('--certificate', metavar='FILE', help='the PEM certificate chain of tls file')
|
||||
sub.add_argument('--key', metavar='FILE', help='the PEM private key of tls file')
|
||||
sub.add_argument('--index', metavar='FILE', help='the file served for a folder')
|
||||
sub.add_argument('--spa', action=argparse.BooleanOptionalAction, help='serve the index of the site for any '
|
||||
'path without a file')
|
||||
sub.add_argument('--enabled', action=argparse.BooleanOptionalAction, help='serve the site')
|
||||
sub.add_argument('--editors', metavar='GROUP', help="the group whose members change the files; '' for none")
|
||||
if command == 'remove':
|
||||
sub.add_argument('--content', action='store_true', help='remove the site\'s folder too')
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def values_of(options):
|
||||
"""The settings the options give."""
|
||||
values = {key: getattr(options, option) for key, option in (('port', 'port'), ('listen', 'listen'), ('tls', 'tls'),
|
||||
('ca', 'ca'), ('index_file', 'index'), ('spa', 'spa'),
|
||||
('enabled', 'enabled'), ('editors', 'editors'))
|
||||
if getattr(options, option) is not None}
|
||||
if options.no_hosts:
|
||||
values['hosts'] = []
|
||||
elif options.hosts:
|
||||
values['hosts'] = options.hosts
|
||||
for key, option in (('certificate', 'certificate'), ('private_key', 'key')):
|
||||
path = getattr(options, option)
|
||||
if path is not None:
|
||||
try:
|
||||
with open(path, encoding='utf-8') as handle:
|
||||
values[key] = handle.read()
|
||||
except OSError as exc:
|
||||
raise Failure(f'{path} cannot be read: {exc.strerror}', 2)
|
||||
return values
|
||||
|
||||
|
||||
def run(options):
|
||||
"""The answer of the command the options name, and whether it changed a site."""
|
||||
command = options.command
|
||||
if command == 'list':
|
||||
return {'sites': vpnrouter_sites.listing()}, False
|
||||
if command == 'show':
|
||||
with vpnrouter_sites.database() as connection:
|
||||
return vpnrouter_sites.shown(vpnrouter_sites.get(connection, options.name)), False
|
||||
if command == 'remove':
|
||||
return vpnrouter_sites.remove(options.name, options.content), True
|
||||
cfg = vpnrouter.load_config()
|
||||
return vpnrouter_sites.save(cfg, options.name, values_of(options), command == 'add'), True
|
||||
|
||||
|
||||
def text_of(answer):
|
||||
"""The answer as text for people."""
|
||||
if answer.get('removed'):
|
||||
return f'site {answer["name"]} removed'
|
||||
sites = answer['sites'] if 'sites' in answer else [answer]
|
||||
if not sites:
|
||||
return 'no sites'
|
||||
head = ('SITE', 'ENABLED', 'HOSTS', 'PORT', 'LISTEN', 'TLS', 'EDITORS')
|
||||
rows = [(site['name'], 'yes' if site['enabled'] else 'no', ', '.join(site['hosts']) or '*', str(site['port']),
|
||||
site['listen'], site['tls'] + (f' {site["ca"]}' if site['tls'] == 'ca' else ''), site['editors'] or '-')
|
||||
for site in sites]
|
||||
widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))]
|
||||
return '\n'.join(' '.join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip() for row in (head, *rows))
|
||||
|
||||
|
||||
def main(argv):
|
||||
# A request that cannot be parsed is answered in JSON when --json is among the arguments.
|
||||
as_json = '--json' in argv
|
||||
try:
|
||||
options = parse(argv)
|
||||
as_json = options.json
|
||||
answer, changed = run(options)
|
||||
except Failure as failure:
|
||||
message, status = str(failure), failure.status
|
||||
except vpnrouter_sites.DatabaseError as exc:
|
||||
message, status = str(exc), 1
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
message, status = f'the user directory failed: {exc}', 1
|
||||
else:
|
||||
if changed:
|
||||
subprocess.run(['systemctl', 'restart', '--no-block', 'vpn-router-setup.service'])
|
||||
print(json.dumps(answer) if as_json else text_of(answer))
|
||||
return 0
|
||||
if as_json:
|
||||
print(json.dumps({'error': message}))
|
||||
else:
|
||||
print(f'vpn-router-site: {message}', file=sys.stderr)
|
||||
return status
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -12,9 +12,18 @@ user exists, 1 anything else. -h or --help shows the commands and their options.
|
||||
vpn-router-user [--json] add <name> --first-name F --last-name L --email E (password on stdin)
|
||||
vpn-router-user [--json] add <name> [--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] (a service account)
|
||||
vpn-router-user [--json] update <name> [--display-name N] [--description D] [--email E]
|
||||
[--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...]
|
||||
[--grant G ...] [--public-client yes|no] [--client-scope R:S ...] [--resource-scope S ...]
|
||||
(a service account)
|
||||
|
||||
A service account with a redirect URI or a grant (client_credentials, device_code) is a client of the OpenID Provider,
|
||||
and one with a resource scope a resource; each of the repeatable options replaces all values of its kind, and one ''
|
||||
removes them. vpn-router-role manages the roles of a resource.
|
||||
vpn-router-user [--json] passwd <name> (password on stdin)
|
||||
vpn-router-user [--json] disable <name>
|
||||
vpn-router-user [--json] enable <name>
|
||||
@@ -25,6 +34,7 @@ user exists, 1 anything else. -h or --help shows the commands and their options.
|
||||
vpn-router-user [--json] otp-enrol <name> <code> (secret on stdin)
|
||||
vpn-router-user [--json] otp-remove <name>
|
||||
vpn-router-user [--json] console <name> (--posix | --account LOCAL | --off)
|
||||
vpn-router-user [--json] passkeys-remove <name>
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -37,6 +47,7 @@ import ldap
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_auth
|
||||
import vpnrouter_passkeys
|
||||
import vpnrouter_users
|
||||
from vpnrouter_users import Failure
|
||||
|
||||
@@ -57,8 +68,9 @@ HELP = {
|
||||
'otp-new': 'make a TOTP secret and its otpauth URI; nothing is stored',
|
||||
'otp-enrol': 'store the TOTP secret read from standard input, when the code is a code of it',
|
||||
'otp-remove': 'remove the TOTP token',
|
||||
'console': "switch the console login of a member of admins on, as the user's own POSIX account in the directory "
|
||||
'or as a local account, or off',
|
||||
'console': "switch the console login of a member of admins with a TOTP token on, as the user's own POSIX account "
|
||||
'in the directory or as a local account, or off',
|
||||
'passkeys-remove': "remove all the user's passkeys",
|
||||
}
|
||||
|
||||
#: The help of the person options.
|
||||
@@ -66,6 +78,16 @@ PERSON_HELP = {'first_name': 'the first name', 'last_name': 'the last name', 'em
|
||||
'display_name': "a service account's display name; '' removes it",
|
||||
'description': "a service account's description; '' removes it"}
|
||||
|
||||
#: The OpenID Provider client settings of a service account, their options and help.
|
||||
CLIENT_OPTIONS = (
|
||||
('redirect_uris', 'redirect-uri', 'a redirect URI of the OpenID Provider client'),
|
||||
('post_logout_redirect_uris', 'post-logout-redirect-uri', 'a post-logout redirect URI of the OpenID Provider client'),
|
||||
('allowed_groups', 'allowed-group', 'a group whose members may sign in to the OpenID Provider client'),
|
||||
('grants', 'grant', 'a grant the client may use besides the authorization code: client_credentials or device_code'),
|
||||
('client_scopes', 'client-scope', 'a scope of a resource the client may ask for, <resource>:<scope>'),
|
||||
('resource_scopes', 'resource-scope', 'a scope the service account defines as a resource'),
|
||||
)
|
||||
|
||||
|
||||
class Parser(argparse.ArgumentParser):
|
||||
"""An argument parser whose errors are Failures with status 2, so that --json answers them as JSON."""
|
||||
@@ -85,6 +107,11 @@ def parse(argv):
|
||||
if command in ('add', 'update'):
|
||||
for key, _ in vpnrouter_users.PERSON + vpnrouter_users.LABELS:
|
||||
sub.add_argument(f'--{key.replace("_", "-")}', dest=key, metavar='TEXT', help=PERSON_HELP[key])
|
||||
for key, option, text in CLIENT_OPTIONS:
|
||||
sub.add_argument(f'--{option}', dest=key, action='append', metavar='VALUE',
|
||||
help=f"{text}, of a service account; repeat it for more, '' for none")
|
||||
sub.add_argument('--public-client', choices=('yes', 'no'),
|
||||
help='whether the client of a service account is public, without a secret')
|
||||
if command == 'add':
|
||||
sub.add_argument('--service', action='store_true', help='add a service account, which cannot sign in to the web service')
|
||||
if command == 'otp-enrol':
|
||||
@@ -108,6 +135,12 @@ def run(connection, options, secret):
|
||||
if command == 'list':
|
||||
return vpnrouter_users.list_users(connection)
|
||||
values = {key: getattr(options, key, None) for key, _ in vpnrouter_users.PERSON + vpnrouter_users.LABELS}
|
||||
for key, _, _ in CLIENT_OPTIONS:
|
||||
given = getattr(options, key, None)
|
||||
if given is not None:
|
||||
values[key] = [value for value in given if value]
|
||||
if getattr(options, 'public_client', None) is not None:
|
||||
values['public_client'] = options.public_client == 'yes'
|
||||
if command == 'add':
|
||||
return vpnrouter_users.add(connection, options.name, secret, values, service=options.service)
|
||||
if command == 'update':
|
||||
@@ -118,14 +151,36 @@ def run(connection, options, secret):
|
||||
return vpnrouter_users.otp_enrol(connection, options.name, secret, options.code)
|
||||
if command == 'console':
|
||||
return vpnrouter_users.console(connection, options.name, not options.off, options.account)
|
||||
if command == 'passkeys-remove':
|
||||
return vpnrouter_passkeys.remove(connection, options.name)
|
||||
return getattr(vpnrouter_users, command.replace('-', '_'))(connection, options.name)
|
||||
|
||||
|
||||
def console_text(console):
|
||||
"""The console login of a user as a cell: -, off, posix (uid N) or local and the account."""
|
||||
def console_text(user):
|
||||
"""The console login of a user as a cell: -, off, posix (uid N) or local and the account, refused without a TOTP
|
||||
token."""
|
||||
console = user['console']
|
||||
if console is None or not console['enabled']:
|
||||
return '-' if console is None else 'off'
|
||||
return f'local {console["account"]}' if console['local'] else f'posix (uid {console["uid"]})'
|
||||
text = f'local {console["account"]}' if console['local'] else f'posix (uid {console["uid"]})'
|
||||
return text if user['otp'] else f'{text}, refused: no OTP'
|
||||
|
||||
|
||||
def client_text(user):
|
||||
"""The OpenID Provider settings of a service account as a cell: -, or the numbers of its URIs, its allowed groups,
|
||||
grants, public, its client scopes and its resource scopes."""
|
||||
parts = []
|
||||
if user.get('redirect_uris'):
|
||||
groups = ','.join(user['allowed_groups']) or 'all users'
|
||||
parts.append(f'{len(user["redirect_uris"])} redirect, {len(user["post_logout_redirect_uris"])} logout, {groups}')
|
||||
parts += user.get('grants') or []
|
||||
if user.get('public_client'):
|
||||
parts.append('public')
|
||||
if user.get('client_scopes'):
|
||||
parts.append('asks ' + ','.join(user['client_scopes']))
|
||||
if user.get('resource_scopes'):
|
||||
parts.append('defines ' + ','.join(user['resource_scopes']))
|
||||
return '; '.join(parts) or '-'
|
||||
|
||||
|
||||
def text_of(answer):
|
||||
@@ -139,12 +194,13 @@ def text_of(answer):
|
||||
users = answer['users'] if 'users' in answer else [answer]
|
||||
if not users:
|
||||
return 'no users'
|
||||
head = ('USER', 'KIND', 'STATE', 'CERTIFICATE', 'OTP', 'CONSOLE', 'FIRST NAME', 'LAST NAME', 'E-MAIL')
|
||||
head = ('USER', 'KIND', 'STATE', 'CERTIFICATE', 'OTP', 'PASSKEYS', 'CONSOLE', 'FIRST NAME', 'LAST NAME', 'E-MAIL',
|
||||
'OIDC CLIENT')
|
||||
rows = [(user['name'], 'internal' if user['internal'] else 'service' if user['service'] else 'manager' if user['manager_only'] else 'vpn',
|
||||
'disabled' if not user['enabled'] else 'locked' if user['locked'] else 'enabled',
|
||||
'issued' if user['certificate'] else 'missing',
|
||||
'enrolled' if user['otp'] else '-', console_text(user['console']),
|
||||
user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-')
|
||||
'enrolled' if user['otp'] else '-', str(len(user['passkeys'])) if user['passkeys'] else '-', console_text(user),
|
||||
user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-', client_text(user))
|
||||
for user in users]
|
||||
widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))]
|
||||
return '\n'.join(' '.join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip() for row in (head, *rows))
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# bash completion for vpn-config-edit -*- shell-script -*-
|
||||
|
||||
# Python of the package that answers from its schema (vpnrouter.SCHEMA, vpnrouter.CONNECTION) and, read as root, the
|
||||
# site-to-site connections of the configuration: settings prints SECTION.OPTION of every setting and
|
||||
# site_to_site.NAME.OPTION of every connection, connections site_to_site.NAME, and values the values of a boolean
|
||||
# setting, $1.
|
||||
_vpn_config_edit_python()
|
||||
{
|
||||
python3 -c '
|
||||
import sys
|
||||
sys.path.insert(0, "/usr/lib/vpn-router")
|
||||
import vpnrouter
|
||||
try:
|
||||
connections = vpnrouter.read_settings().get("site_to_site", {})
|
||||
except (OSError, vpnrouter.ConfigError):
|
||||
connections = {}
|
||||
if sys.argv[1] == "settings":
|
||||
print("\n".join(f"{section}.{option}" for section, options in vpnrouter.SCHEMA.items() for option in options))
|
||||
print("\n".join(f"site_to_site.{name}.{option}" for name in connections for option in vpnrouter.CONNECTION))
|
||||
elif sys.argv[1] == "connections":
|
||||
print("\n".join(f"site_to_site.{name}" for name in connections))
|
||||
else:
|
||||
section, _, option = sys.argv[2].rpartition(".")
|
||||
defaults = vpnrouter.CONNECTION if section.startswith("site_to_site.") else vpnrouter.SCHEMA.get(section, {})
|
||||
if isinstance(defaults.get(option), bool):
|
||||
print("true\nfalse")
|
||||
' "$@" 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_config_edit()
|
||||
{
|
||||
local cur prev words cword split
|
||||
_init_completion -s || return
|
||||
|
||||
# The command is the first word that is not an option; the arguments are the words after it.
|
||||
local i command='' args=()
|
||||
for ((i = 1; i < cword; i++)); do
|
||||
if [[ -z $command ]]; then
|
||||
[[ ${words[i]} != -* ]] && command=${words[i]}
|
||||
elif [[ ${words[i]} != -* ]]; then
|
||||
args+=("${words[i]}")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $cur == -* ]]; then
|
||||
COMPREPLY=($(compgen -W "--help$([[ $command == @(set|remove) ]] && echo ' --restart')" -- "$cur"))
|
||||
return
|
||||
fi
|
||||
|
||||
case $command,${#args[@]} in
|
||||
,*) COMPREPLY=($(compgen -W 'show set remove' -- "$cur")) ;;
|
||||
set,0) COMPREPLY=($(compgen -W "$(_vpn_config_edit_python settings)" -- "$cur")) ;;
|
||||
set,1) COMPREPLY=($(compgen -W "$(_vpn_config_edit_python values "${args[0]}")" -- "$cur")) ;;
|
||||
remove,0) COMPREPLY=($(compgen -W "$(_vpn_config_edit_python connections)" -- "$cur")) ;;
|
||||
esac
|
||||
} &&
|
||||
complete -F _vpn_config_edit vpn-config-edit
|
||||
|
||||
# ex: filetype=sh
|
||||
@@ -0,0 +1,41 @@
|
||||
# bash completion for vpn-router-backup -*- shell-script -*-
|
||||
|
||||
_vpn_router_backup()
|
||||
{
|
||||
local cur prev words cword split
|
||||
_init_completion -s || return
|
||||
|
||||
# The command is the first word that is not an option; the arguments are the words after it.
|
||||
local i command='' args=0 pbs=false
|
||||
for ((i = 1; i < cword; i++)); do
|
||||
if [[ -z $command ]]; then
|
||||
[[ ${words[i]} != -* ]] && command=${words[i]}
|
||||
elif [[ ${words[i]} == --pbs ]]; then
|
||||
pbs=true
|
||||
elif [[ ${words[i]} != -* ]]; then
|
||||
((args++))
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z $command ]]; then
|
||||
if [[ $cur == -* ]]; then
|
||||
COMPREPLY=($(compgen -W '--help' -- "$cur"))
|
||||
else
|
||||
COMPREPLY=($(compgen -W 'run list restore' -- "$cur"))
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ $cur == -* ]]; then
|
||||
COMPREPLY=($(compgen -W "--help$([[ $command == restore ]] && echo ' --pbs --yes')" -- "$cur"))
|
||||
return
|
||||
fi
|
||||
|
||||
# restore takes a local archive, or with --pbs a snapshot name, which vpn-router-backup list shows.
|
||||
if [[ $command == restore ]] && ((args == 0)) && ! $pbs; then
|
||||
_filedir
|
||||
fi
|
||||
} &&
|
||||
complete -F _vpn_router_backup vpn-router-backup
|
||||
|
||||
# ex: filetype=sh
|
||||
@@ -0,0 +1,98 @@
|
||||
# bash completion for vpn-router-ca -*- shell-script -*-
|
||||
|
||||
# Python of the package that prints the revocation reasons (reasons) or, from the configuration read as root, the
|
||||
# issuing CAs (cas) or the certificate templates (templates), which other users do not get.
|
||||
_vpn_router_ca_python()
|
||||
{
|
||||
python3 -c '
|
||||
import sys
|
||||
sys.path.insert(0, "/usr/lib/vpn-router")
|
||||
import vpnrouter
|
||||
import vpnrouter_pki
|
||||
if sys.argv[1] == "reasons":
|
||||
print("\n".join(vpnrouter_pki.REASONS))
|
||||
else:
|
||||
cfg = vpnrouter.load_config()
|
||||
print("\n".join(vpnrouter.issuing_cas(cfg) if sys.argv[1] == "cas" else vpnrouter.pki_templates(cfg)))
|
||||
' "$@" 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_ca()
|
||||
{
|
||||
local cur prev words cword split
|
||||
_init_completion -s || return
|
||||
|
||||
# The command and, for ca and root, the action are the first words that are not options; the arguments are the
|
||||
# words after them but option values.
|
||||
local i command='' action='' args=0
|
||||
for ((i = 1; i < cword; i++)); do
|
||||
case ${words[i]} in
|
||||
--ca | --template | --csr | --subject | --san | --pfx | --reason | --out) ((i++)) ;;
|
||||
-*) ;;
|
||||
*)
|
||||
if [[ -z $command ]]; then
|
||||
command=${words[i]}
|
||||
elif [[ $command == @(ca|root) && -z $action ]]; then
|
||||
action=${words[i]}
|
||||
else
|
||||
((args++))
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case $prev in
|
||||
--ca)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_ca_python cas)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--template)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_ca_python templates)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--reason)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_ca_python reasons)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--csr | --pfx | --out)
|
||||
_filedir
|
||||
return
|
||||
;;
|
||||
--subject | --san)
|
||||
return
|
||||
;;
|
||||
esac
|
||||
$split && return
|
||||
|
||||
if [[ -z $command ]]; then
|
||||
if [[ $cur == -* ]]; then
|
||||
COMPREPLY=($(compgen -W '--json --help' -- "$cur"))
|
||||
else
|
||||
COMPREPLY=($(compgen -W 'status list issue revoke maintain ca root' -- "$cur"))
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ $cur == -* ]]; then
|
||||
local options='--help'
|
||||
case $command$action in
|
||||
list) options+=' --ca' ;;
|
||||
issue) options+=' --ca --template --csr --subject --san --pfx' ;;
|
||||
revoke) options+=' --reason' ;;
|
||||
rootexport-key) options+=' --out' ;;
|
||||
rootremove-key) options+=' --yes' ;;
|
||||
esac
|
||||
COMPREPLY=($(compgen -W "$options" -- "$cur"))
|
||||
return
|
||||
fi
|
||||
|
||||
case $command,$action,$args in
|
||||
ca,,*) COMPREPLY=($(compgen -W 'csr import' -- "$cur")) ;;
|
||||
root,,*) COMPREPLY=($(compgen -W 'export-key remove-key import-key import-crl' -- "$cur")) ;;
|
||||
ca,*,0) COMPREPLY=($(compgen -W "$(_vpn_router_ca_python cas)" -- "$cur")) ;;
|
||||
ca,import,1 | root,import-key,0 | root,import-crl,0) _filedir ;;
|
||||
esac
|
||||
} &&
|
||||
complete -F _vpn_router_ca vpn-router-ca
|
||||
|
||||
# ex: filetype=sh
|
||||
@@ -0,0 +1,24 @@
|
||||
# bash completion for vpn-router-caddy -*- shell-script -*-
|
||||
|
||||
_vpn_router_caddy()
|
||||
{
|
||||
local cur prev words cword
|
||||
_init_completion || return
|
||||
|
||||
# After the command only --help is left.
|
||||
local word
|
||||
for word in "${words[@]:1:cword-1}"; do
|
||||
if [[ $word != -* ]]; then
|
||||
[[ $cur == -* ]] && COMPREPLY=($(compgen -W '--help' -- "$cur"))
|
||||
return
|
||||
fi
|
||||
done
|
||||
if [[ $cur == -* ]]; then
|
||||
COMPREPLY=($(compgen -W '--json --help' -- "$cur"))
|
||||
else
|
||||
COMPREPLY=($(compgen -W 'status apply use-upstream' -- "$cur"))
|
||||
fi
|
||||
} &&
|
||||
complete -F _vpn_router_caddy vpn-router-caddy
|
||||
|
||||
# ex: filetype=sh
|
||||
@@ -0,0 +1,74 @@
|
||||
# bash completion for vpn-router-group -*- shell-script -*-
|
||||
|
||||
# The names of the users and of the groups of the router's directory, read as root; nothing for other users.
|
||||
_vpn_router_group_users()
|
||||
{
|
||||
vpn-router-user --json list 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(user["name"] for user in json.load(sys.stdin)["users"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_group_names()
|
||||
{
|
||||
vpn-router-group --json list 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(group["name"] for group in json.load(sys.stdin)["groups"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_group()
|
||||
{
|
||||
local cur prev words cword split
|
||||
_init_completion -s || return
|
||||
|
||||
# The command is the first word that is not an option; the arguments are the words after it but option values.
|
||||
local i command='' args=0
|
||||
for ((i = 1; i < cword; i++)); do
|
||||
if [[ -z $command ]]; then
|
||||
[[ ${words[i]} != -* ]] && command=${words[i]}
|
||||
continue
|
||||
fi
|
||||
case ${words[i]} in
|
||||
--member | --description | --name) ((i++)) ;;
|
||||
-*) ;;
|
||||
*) ((args++)) ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z $command ]]; then
|
||||
if [[ $cur == -* ]]; then
|
||||
COMPREPLY=($(compgen -W '--json --help' -- "$cur"))
|
||||
else
|
||||
COMPREPLY=($(compgen -W 'list add update remove member-add member-remove' -- "$cur"))
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
case $prev in
|
||||
--member)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_group_users)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--description | --name)
|
||||
return
|
||||
;;
|
||||
esac
|
||||
$split && return
|
||||
|
||||
if [[ $cur == -* ]]; then
|
||||
local options='--help'
|
||||
case $command in
|
||||
add) options+=' --member --description' ;;
|
||||
update) options+=' --name --description' ;;
|
||||
esac
|
||||
COMPREPLY=($(compgen -W "$options" -- "$cur"))
|
||||
return
|
||||
fi
|
||||
|
||||
# The group, which add takes new, then the user of member-add and member-remove.
|
||||
if ((args == 0)) && [[ $command != @(list|add) ]]; then
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_group_names)" -- "$cur"))
|
||||
elif ((args == 1)) && [[ $command == member-@(add|remove) ]]; then
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_group_users)" -- "$cur"))
|
||||
fi
|
||||
} &&
|
||||
complete -F _vpn_router_group vpn-router-group
|
||||
|
||||
# ex: filetype=sh
|
||||
@@ -0,0 +1,94 @@
|
||||
# bash completion for vpn-router-role -*- shell-script -*-
|
||||
|
||||
# The service accounts, the users, the groups and the roles of a resource of the router's directory, read as root;
|
||||
# nothing for other users.
|
||||
_vpn_router_role_resources()
|
||||
{
|
||||
vpn-router-user --json list 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(user["name"] for user in json.load(sys.stdin)["users"] if user["service"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_role_accounts()
|
||||
{
|
||||
vpn-router-user --json list 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(user["name"] for user in json.load(sys.stdin)["users"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_role_groups()
|
||||
{
|
||||
vpn-router-group --json list 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(group["name"] for group in json.load(sys.stdin)["groups"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_role_names()
|
||||
{
|
||||
vpn-router-role --json list "$1" 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(role["name"] for role in json.load(sys.stdin)["roles"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_role()
|
||||
{
|
||||
local cur prev words cword split
|
||||
_init_completion -s || return
|
||||
|
||||
# The command is the first word that is not an option; the arguments are the words after it but option values.
|
||||
local i command='' args=0 resource=''
|
||||
for ((i = 1; i < cword; i++)); do
|
||||
if [[ -z $command ]]; then
|
||||
[[ ${words[i]} != -* ]] && command=${words[i]}
|
||||
continue
|
||||
fi
|
||||
case ${words[i]} in
|
||||
--description | --group | --account) ((i++)) ;;
|
||||
-*) ;;
|
||||
*)
|
||||
((args == 0)) && resource=${words[i]}
|
||||
((args++))
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z $command ]]; then
|
||||
if [[ $cur == -* ]]; then
|
||||
COMPREPLY=($(compgen -W '--json --help' -- "$cur"))
|
||||
else
|
||||
COMPREPLY=($(compgen -W 'list add remove member-add member-remove' -- "$cur"))
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
case $prev in
|
||||
--group)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_role_groups)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--account)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_role_accounts)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--description)
|
||||
return
|
||||
;;
|
||||
esac
|
||||
$split && return
|
||||
|
||||
if [[ $cur == -* ]]; then
|
||||
local options='--help'
|
||||
case $command in
|
||||
add) options+=' --description' ;;
|
||||
member-add | member-remove) options+=' --group --account' ;;
|
||||
esac
|
||||
COMPREPLY=($(compgen -W "$options" -- "$cur"))
|
||||
return
|
||||
fi
|
||||
|
||||
# The resource, then the role, which add takes new.
|
||||
if ((args == 0)); then
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_role_resources)" -- "$cur"))
|
||||
elif ((args == 1)) && [[ $command != @(list|add) ]]; then
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_role_names "$resource")" -- "$cur"))
|
||||
fi
|
||||
} &&
|
||||
complete -F _vpn_router_role vpn-router-role
|
||||
|
||||
# ex: filetype=sh
|
||||
@@ -0,0 +1,95 @@
|
||||
# bash completion for vpn-router-site -*- shell-script -*-
|
||||
|
||||
# The names of the sites, of the groups and of the general-purpose issuing CAs, read as root; nothing for other users.
|
||||
_vpn_router_site_names()
|
||||
{
|
||||
vpn-router-site --json list 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(site["name"] for site in json.load(sys.stdin)["sites"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_site_groups()
|
||||
{
|
||||
vpn-router-group --json list 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(group["name"] for group in json.load(sys.stdin)["groups"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_site_cas()
|
||||
{
|
||||
python3 -c 'import sys; sys.path.insert(0, "/usr/lib/vpn-router"); import vpnrouter
|
||||
print("\n".join(vpnrouter.load_config()["pki"]["issuing_cas"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_site()
|
||||
{
|
||||
local cur prev words cword split
|
||||
_init_completion -s || return
|
||||
|
||||
# The command is the first word that is not an option; the arguments are the words after it but option values.
|
||||
local i command='' args=0
|
||||
for ((i = 1; i < cword; i++)); do
|
||||
if [[ -z $command ]]; then
|
||||
[[ ${words[i]} != -* ]] && command=${words[i]}
|
||||
continue
|
||||
fi
|
||||
case ${words[i]} in
|
||||
--host | --port | --listen | --tls | --ca | --certificate | --key | --index | --editors) ((i++)) ;;
|
||||
-*) ;;
|
||||
*) ((args++)) ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z $command ]]; then
|
||||
if [[ $cur == -* ]]; then
|
||||
COMPREPLY=($(compgen -W '--json --help' -- "$cur"))
|
||||
else
|
||||
COMPREPLY=($(compgen -W 'list show add update remove' -- "$cur"))
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
case $prev in
|
||||
--listen)
|
||||
COMPREPLY=($(compgen -W 'internal external both' -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--tls)
|
||||
COMPREPLY=($(compgen -W 'none acme ca file' -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--ca)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_site_cas)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--editors)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_site_groups)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--certificate | --key)
|
||||
_filedir
|
||||
return
|
||||
;;
|
||||
--host | --port | --index)
|
||||
return
|
||||
;;
|
||||
esac
|
||||
$split && return
|
||||
|
||||
if [[ $cur == -* ]]; then
|
||||
local options='--help'
|
||||
case $command in
|
||||
add | update) options+=' --host --no-hosts --port --listen --tls --ca --certificate --key --index --spa
|
||||
--no-spa --enabled --no-enabled --editors' ;;
|
||||
remove) options+=' --content' ;;
|
||||
esac
|
||||
COMPREPLY=($(compgen -W "$options" -- "$cur"))
|
||||
return
|
||||
fi
|
||||
|
||||
# The site, which add takes new.
|
||||
if ((args == 0)) && [[ $command != @(list|add) ]]; then
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_site_names)" -- "$cur"))
|
||||
fi
|
||||
} &&
|
||||
complete -F _vpn_router_site vpn-router-site
|
||||
|
||||
# ex: filetype=sh
|
||||
@@ -0,0 +1,25 @@
|
||||
# bash completion for vpn-router-token -*- shell-script -*-
|
||||
|
||||
_vpn_router_token()
|
||||
{
|
||||
local cur prev words cword split
|
||||
_init_completion -s || return
|
||||
|
||||
case $prev in
|
||||
--minutes | --hours | --days)
|
||||
return
|
||||
;;
|
||||
esac
|
||||
$split && return
|
||||
|
||||
# The lifetime is one of --minutes, --hours and --days.
|
||||
local options='--json --help' word
|
||||
for word in "${words[@]:1:cword-1}"; do
|
||||
[[ $word == --@(minutes|hours|days)* ]] && break
|
||||
done
|
||||
[[ $word == --@(minutes|hours|days)* ]] || options+=' --minutes --hours --days'
|
||||
COMPREPLY=($(compgen -W "$options" -- "$cur"))
|
||||
} &&
|
||||
complete -F _vpn_router_token vpn-router-token
|
||||
|
||||
# ex: filetype=sh
|
||||
@@ -0,0 +1,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
|
||||
@@ -38,7 +38,8 @@
|
||||
},
|
||||
"p2s": {
|
||||
"enabled": false,
|
||||
"address_pool": "172.16.0.0/24"
|
||||
"address_pool": "172.16.0.0/24",
|
||||
"dns_listen": "internal"
|
||||
},
|
||||
"wireguard": {
|
||||
"enabled": false,
|
||||
@@ -63,7 +64,9 @@
|
||||
"trusted_proxies": [],
|
||||
"status_content": "basic",
|
||||
"shell": false,
|
||||
"shell_idle_minutes": 15
|
||||
"shell_idle_minutes": 15,
|
||||
"shell_auth_timeout": 15,
|
||||
"log_files": ["/var/log/syslog", "/var/log/auth.log"]
|
||||
},
|
||||
"ssh": {
|
||||
"ldap_login": false
|
||||
@@ -75,6 +78,22 @@
|
||||
"manager_port": 7443,
|
||||
"caddy_source": "ubuntu"
|
||||
},
|
||||
"oidc": {
|
||||
"enabled": false
|
||||
},
|
||||
"sites": {
|
||||
"enabled": false,
|
||||
"upload_max_mb": 512,
|
||||
"webdav": false,
|
||||
"webdav_port": 7445
|
||||
},
|
||||
"dns": {
|
||||
"enabled": false,
|
||||
"nameserver": "",
|
||||
"addresses": [],
|
||||
"listen": "external",
|
||||
"ttl": 300
|
||||
},
|
||||
"pki": {
|
||||
"root_name": "VPN Router Root CA",
|
||||
"root_days": 7300,
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
-- 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;
|
||||
|
||||
-- A domain the router serves as its primary name server; sites says whether the host names of the enabled sites in
|
||||
-- it get records, ttl is the TTL of its records, null for dns.ttl.
|
||||
CREATE TABLE IF NOT EXISTS dns_domains (
|
||||
name text PRIMARY KEY,
|
||||
sites boolean NOT NULL,
|
||||
ttl integer
|
||||
);
|
||||
|
||||
-- A secondary name server of a domain: its NS name and the address it transfers the domain from.
|
||||
CREATE TABLE IF NOT EXISTS dns_secondaries (
|
||||
domain text NOT NULL REFERENCES dns_domains (name) ON DELETE CASCADE,
|
||||
nameserver text NOT NULL,
|
||||
address inet NOT NULL,
|
||||
PRIMARY KEY (domain, nameserver)
|
||||
);
|
||||
|
||||
-- A record an administrator added to a domain, besides those of the domain itself and of the sites.
|
||||
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, sites boolean, ttl integer)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT name, sites, ttl FROM dns_domains ORDER BY name;
|
||||
$$;
|
||||
|
||||
-- Add the domain, or set its sites and TTL.
|
||||
CREATE OR REPLACE PROCEDURE dns_domain_put(p_name text, p_sites boolean, p_ttl integer)
|
||||
LANGUAGE sql AS $$
|
||||
INSERT INTO dns_domains VALUES (p_name, p_sites, p_ttl)
|
||||
ON CONFLICT (name) DO UPDATE SET sites = p_sites, ttl = p_ttl;
|
||||
$$;
|
||||
|
||||
-- Remove the domain with its secondaries and 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_secondaries_list()
|
||||
RETURNS TABLE (domain text, nameserver text, address text)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT domain, nameserver, host(address) FROM dns_secondaries ORDER BY domain, nameserver;
|
||||
$$;
|
||||
|
||||
-- Add the secondary of the domain, or set its address.
|
||||
CREATE OR REPLACE PROCEDURE dns_secondary_put(p_domain text, p_nameserver text, p_address inet)
|
||||
LANGUAGE sql AS $$
|
||||
INSERT INTO dns_secondaries VALUES (p_domain, p_nameserver, p_address)
|
||||
ON CONFLICT (domain, nameserver) DO UPDATE SET address = p_address;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE dns_secondary_remove(p_domain text, p_nameserver text)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM dns_secondaries WHERE domain = p_domain AND nameserver = p_nameserver;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION dns_records_list()
|
||||
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 secondaries, which are notified of its changes and may transfer it. The serial grows, to
|
||||
-- at least the current Unix time, only when the records differ from those last written.
|
||||
CREATE OR REPLACE PROCEDURE dns_apply(p_zone text, p_soa text, p_ttl integer, p_names text[], p_types text[],
|
||||
p_contents text[], p_ttls integer[], p_secondaries text[])
|
||||
LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
v_domain integer;
|
||||
v_serial bigint;
|
||||
v_digest text := md5(concat_ws('|', p_soa, p_ttl, array_to_string(p_names, ','), array_to_string(p_types, ','),
|
||||
array_to_string(p_contents, ','), array_to_string(p_ttls, ',')));
|
||||
BEGIN
|
||||
SELECT id INTO v_domain FROM domains WHERE name = p_zone;
|
||||
IF v_domain IS NULL THEN
|
||||
INSERT INTO domains (name, type) VALUES (p_zone, 'MASTER') RETURNING id INTO v_domain;
|
||||
ELSE
|
||||
UPDATE domains SET type = 'MASTER' WHERE id = v_domain AND type <> 'MASTER';
|
||||
END IF;
|
||||
DELETE FROM domainmetadata WHERE domain_id = v_domain AND kind IN ('ALSO-NOTIFY', 'ALLOW-AXFR-FROM');
|
||||
INSERT INTO domainmetadata (domain_id, kind, content)
|
||||
SELECT v_domain, kind, address FROM unnest(p_secondaries) AS address,
|
||||
unnest(ARRAY['ALSO-NOTIFY', 'ALLOW-AXFR-FROM']) AS kind;
|
||||
IF EXISTS (SELECT 1 FROM dns_state WHERE zone = p_zone AND digest = v_digest) THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
SELECT greatest(coalesce(max(serial) + 1, 1), extract(epoch FROM now())::bigint) INTO v_serial
|
||||
FROM dns_state WHERE zone = p_zone;
|
||||
DELETE FROM records WHERE domain_id = v_domain;
|
||||
INSERT INTO records (domain_id, name, type, content, ttl, prio, disabled, auth)
|
||||
VALUES (v_domain, p_zone, 'SOA', replace(p_soa, '{serial}', v_serial::text), p_ttl, 0, false, true);
|
||||
INSERT INTO records (domain_id, name, type, content, ttl, prio, disabled, auth)
|
||||
SELECT v_domain, r.name, r.type, r.content, r.ttl, 0, false, true
|
||||
FROM unnest(p_names, p_types, p_contents, p_ttls) AS r(name, type, content, ttl);
|
||||
INSERT INTO dns_state VALUES (p_zone, v_digest, v_serial)
|
||||
ON CONFLICT (zone) DO UPDATE SET digest = v_digest, serial = v_serial;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Serve only the zones of the list, none for an empty one.
|
||||
CREATE OR REPLACE PROCEDURE dns_retain(p_zones text[])
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM domains WHERE name <> ALL (p_zones);
|
||||
DELETE FROM dns_state WHERE zone <> ALL (p_zones);
|
||||
$$;
|
||||
|
||||
-- The records of the zone as they are served.
|
||||
CREATE OR REPLACE FUNCTION dns_served(p_zone text)
|
||||
RETURNS TABLE (name text, type text, content text, ttl integer)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT r.name, r.type, r.content, r.ttl FROM records r JOIN domains d ON d.id = r.domain_id
|
||||
WHERE d.name = p_zone ORDER BY r.type <> 'SOA', r.name, r.type, r.content;
|
||||
$$;
|
||||
@@ -0,0 +1,202 @@
|
||||
-- The state of the router's OpenID Provider (vpnrouter_oidc) in the database of web.database.
|
||||
-- Codes, tokens, device codes and sessions are kept as the SHA-256 of their values; expired rows are purged when new
|
||||
-- ones are saved. The whole file runs as one transaction, serialized by an advisory lock, so that two processes can
|
||||
-- load it at once.
|
||||
|
||||
SELECT pg_advisory_xact_lock(hashtext('vpn-router oidc schema'));
|
||||
|
||||
-- An authorization code, valid until it is exchanged for tokens or expires; resource is the resource the tokens are
|
||||
-- for, null for the client itself.
|
||||
CREATE TABLE IF NOT EXISTS oidc_codes (
|
||||
hash text PRIMARY KEY,
|
||||
client_id text NOT NULL,
|
||||
name text NOT NULL,
|
||||
redirect_uri text NOT NULL,
|
||||
scopes text[] NOT NULL,
|
||||
nonce text,
|
||||
code_challenge text NOT NULL,
|
||||
code_challenge_method text NOT NULL,
|
||||
auth_time timestamptz NOT NULL,
|
||||
amr text[] NOT NULL,
|
||||
expires timestamptz NOT NULL,
|
||||
resource text
|
||||
);
|
||||
ALTER TABLE oidc_codes ADD COLUMN IF NOT EXISTS resource text;
|
||||
|
||||
-- An access token or a refresh token (kind) a client was issued for a user, or for itself as a service account with
|
||||
-- client credentials; resource as in oidc_codes.
|
||||
CREATE TABLE IF NOT EXISTS oidc_tokens (
|
||||
hash text PRIMARY KEY,
|
||||
kind text NOT NULL CHECK (kind IN ('access', 'refresh')),
|
||||
client_id text NOT NULL,
|
||||
name text NOT NULL,
|
||||
scopes text[] NOT NULL,
|
||||
auth_time timestamptz NOT NULL,
|
||||
amr text[] NOT NULL,
|
||||
expires timestamptz NOT NULL,
|
||||
resource text
|
||||
);
|
||||
ALTER TABLE oidc_tokens ADD COLUMN IF NOT EXISTS resource text;
|
||||
CREATE INDEX IF NOT EXISTS oidc_tokens_name ON oidc_tokens (name);
|
||||
|
||||
-- A device code of the device authorization grant and its user code (user_hash): pending until the user allows or
|
||||
-- denies it, then the user, when and how they signed in; poll_seconds is the least time between two polls, polled
|
||||
-- the last poll.
|
||||
CREATE TABLE IF NOT EXISTS oidc_devices (
|
||||
hash text PRIMARY KEY,
|
||||
user_hash text NOT NULL UNIQUE,
|
||||
client_id text NOT NULL,
|
||||
scopes text[] NOT NULL,
|
||||
resource text,
|
||||
state text NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'allowed', 'denied')),
|
||||
name text,
|
||||
auth_time timestamptz,
|
||||
amr text[],
|
||||
poll_seconds integer NOT NULL,
|
||||
polled timestamptz,
|
||||
expires timestamptz NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS oidc_devices_name ON oidc_devices (name);
|
||||
|
||||
-- A user's sign-in at the OpenID Provider, which the session cookie names.
|
||||
CREATE TABLE IF NOT EXISTS oidc_sessions (
|
||||
hash text PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
auth_time timestamptz NOT NULL,
|
||||
amr text[] NOT NULL,
|
||||
expires timestamptz NOT NULL
|
||||
);
|
||||
|
||||
-- The functions and procedures whose parameters or results differ from those of the same name.
|
||||
DROP PROCEDURE IF EXISTS oidc_code_save(text, text, text, text, text[], text, text, text, timestamptz, text[], integer);
|
||||
DROP FUNCTION IF EXISTS oidc_code_get(text);
|
||||
DROP PROCEDURE IF EXISTS oidc_token_save(text, text, text, text, text[], timestamptz, text[], integer);
|
||||
DROP FUNCTION IF EXISTS oidc_token_get(text, text);
|
||||
|
||||
CREATE OR REPLACE PROCEDURE oidc_code_save(p_hash text, p_client_id text, p_name text, p_redirect_uri text,
|
||||
p_scopes text[], p_nonce text, p_code_challenge text,
|
||||
p_code_challenge_method text, p_auth_time timestamptz, p_amr text[],
|
||||
p_resource text, p_seconds integer)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_codes WHERE expires < now();
|
||||
INSERT INTO oidc_codes (hash, client_id, name, redirect_uri, scopes, nonce, code_challenge, code_challenge_method,
|
||||
auth_time, amr, resource, expires)
|
||||
VALUES (p_hash, p_client_id, p_name, p_redirect_uri, p_scopes, p_nonce, p_code_challenge, p_code_challenge_method,
|
||||
p_auth_time, p_amr, p_resource, now() + make_interval(secs => p_seconds));
|
||||
$$;
|
||||
|
||||
-- The unexpired code.
|
||||
CREATE OR REPLACE FUNCTION oidc_code_get(p_hash text)
|
||||
RETURNS TABLE (client_id text, name text, redirect_uri text, scopes text[], nonce text, code_challenge text,
|
||||
code_challenge_method text, auth_time timestamptz, amr text[], resource text)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT client_id, name, redirect_uri, scopes, nonce, code_challenge, code_challenge_method, auth_time, amr, resource
|
||||
FROM oidc_codes WHERE hash = p_hash AND expires > now();
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE oidc_code_delete(p_hash text)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_codes WHERE hash = p_hash;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE oidc_token_save(p_hash text, p_kind text, p_client_id text, p_name text, p_scopes text[],
|
||||
p_auth_time timestamptz, p_amr text[], p_resource text, p_seconds integer)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_tokens WHERE expires < now();
|
||||
INSERT INTO oidc_tokens (hash, kind, client_id, name, scopes, auth_time, amr, resource, expires)
|
||||
VALUES (p_hash, p_kind, p_client_id, p_name, p_scopes, p_auth_time, p_amr, p_resource,
|
||||
now() + make_interval(secs => p_seconds));
|
||||
$$;
|
||||
|
||||
-- The unexpired token of the kind.
|
||||
CREATE OR REPLACE FUNCTION oidc_token_get(p_hash text, p_kind text)
|
||||
RETURNS TABLE (client_id text, name text, scopes text[], auth_time timestamptz, amr text[], resource text)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT client_id, name, scopes, auth_time, amr, resource FROM oidc_tokens
|
||||
WHERE hash = p_hash AND kind = p_kind AND expires > now();
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE oidc_token_delete(p_hash text)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_tokens WHERE hash = p_hash;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE oidc_device_save(p_hash text, p_user_hash text, p_client_id text, p_scopes text[],
|
||||
p_resource text, p_poll_seconds integer, p_seconds integer)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_devices WHERE expires < now();
|
||||
INSERT INTO oidc_devices (hash, user_hash, client_id, scopes, resource, poll_seconds, expires)
|
||||
VALUES (p_hash, p_user_hash, p_client_id, p_scopes, p_resource, p_poll_seconds,
|
||||
now() + make_interval(secs => p_seconds));
|
||||
$$;
|
||||
|
||||
-- The unexpired device code of the user code while it waits for the user.
|
||||
CREATE OR REPLACE FUNCTION oidc_device_find(p_user_hash text)
|
||||
RETURNS TABLE (client_id text, scopes text[], resource text)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT client_id, scopes, resource FROM oidc_devices
|
||||
WHERE user_hash = p_user_hash AND state = 'pending' AND expires > now();
|
||||
$$;
|
||||
|
||||
-- Allow or deny the waiting device code of the user code as the user; whether there was one.
|
||||
CREATE OR REPLACE FUNCTION oidc_device_decide(p_user_hash text, p_name text, p_auth_time timestamptz, p_amr text[],
|
||||
p_allowed boolean)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql AS $$
|
||||
WITH decided AS (
|
||||
UPDATE oidc_devices SET state = CASE WHEN p_allowed THEN 'allowed' ELSE 'denied' END, name = p_name,
|
||||
auth_time = p_auth_time, amr = p_amr
|
||||
WHERE user_hash = p_user_hash AND state = 'pending' AND expires > now()
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT count(*) > 0 FROM decided;
|
||||
$$;
|
||||
|
||||
-- A poll of the device code: the row, whether it has expired, and whether it came sooner than poll_seconds after the
|
||||
-- last one, which adds 5 seconds to poll_seconds.
|
||||
CREATE OR REPLACE FUNCTION oidc_device_poll(p_hash text)
|
||||
RETURNS TABLE (client_id text, scopes text[], resource text, state text, name text, auth_time timestamptz, amr text[],
|
||||
expired boolean, too_soon boolean)
|
||||
LANGUAGE sql AS $$
|
||||
UPDATE oidc_devices AS device
|
||||
SET polled = now(),
|
||||
poll_seconds = device.poll_seconds +
|
||||
CASE WHEN device.polled + make_interval(secs => device.poll_seconds) > now() THEN 5 ELSE 0 END
|
||||
FROM (SELECT hash, polled, poll_seconds FROM oidc_devices WHERE hash = p_hash) AS previous
|
||||
WHERE device.hash = previous.hash
|
||||
RETURNING device.client_id, device.scopes, device.resource, device.state, device.name, device.auth_time, device.amr,
|
||||
device.expires <= now(),
|
||||
previous.polled IS NOT NULL AND previous.polled + make_interval(secs => previous.poll_seconds) > now();
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE oidc_device_delete(p_hash text)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_devices WHERE hash = p_hash;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE oidc_session_save(p_hash text, p_name text, p_amr text[], p_seconds integer)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_sessions WHERE expires < now();
|
||||
INSERT INTO oidc_sessions VALUES (p_hash, p_name, now(), p_amr, now() + make_interval(secs => p_seconds));
|
||||
$$;
|
||||
|
||||
-- The unexpired session.
|
||||
CREATE OR REPLACE FUNCTION oidc_session_get(p_hash text)
|
||||
RETURNS TABLE (name text, auth_time timestamptz, amr text[])
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT name, auth_time, amr FROM oidc_sessions WHERE hash = p_hash AND expires > now();
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE oidc_session_delete(p_hash text)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_sessions WHERE hash = p_hash;
|
||||
$$;
|
||||
|
||||
-- Remove every code, token, device code and session of the user or service account.
|
||||
CREATE OR REPLACE PROCEDURE oidc_revoke(p_name text)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_codes WHERE name = p_name;
|
||||
DELETE FROM oidc_tokens WHERE name = p_name;
|
||||
DELETE FROM oidc_devices WHERE name = p_name;
|
||||
DELETE FROM oidc_sessions WHERE name = p_name;
|
||||
$$;
|
||||
@@ -0,0 +1,85 @@
|
||||
-- The web sites Caddy serves (vpnrouter_sites), in the vpn-router-sites database.
|
||||
-- The whole file runs as one transaction, serialized by an advisory lock, so that two processes can load it at once.
|
||||
|
||||
SELECT pg_advisory_xact_lock(hashtext('vpn-router sites schema'));
|
||||
|
||||
-- A static web site: its host names, port, interfaces and TLS, and the group whose members change its files.
|
||||
CREATE TABLE IF NOT EXISTS sites (
|
||||
name text PRIMARY KEY,
|
||||
enabled boolean NOT NULL,
|
||||
hosts text[] NOT NULL,
|
||||
port integer NOT NULL,
|
||||
listen text NOT NULL CHECK (listen IN ('internal', 'external', 'both')),
|
||||
tls text NOT NULL CHECK (tls IN ('none', 'acme', 'ca', 'file')),
|
||||
ca text NOT NULL,
|
||||
certificate text NOT NULL,
|
||||
private_key text NOT NULL,
|
||||
index_file text NOT NULL,
|
||||
spa boolean NOT NULL,
|
||||
editors text NOT NULL,
|
||||
modified timestamptz NOT NULL
|
||||
);
|
||||
|
||||
-- Every site, by name; the private key is left out.
|
||||
CREATE OR REPLACE FUNCTION sites_list()
|
||||
RETURNS TABLE (name text, enabled boolean, hosts text[], port integer, listen text, tls text, ca text,
|
||||
certificate text, index_file text, spa boolean, editors text, modified timestamptz)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, index_file, spa, editors, modified
|
||||
FROM sites ORDER BY name;
|
||||
$$;
|
||||
|
||||
-- The site with its private key.
|
||||
CREATE OR REPLACE FUNCTION site_get(p_name text)
|
||||
RETURNS TABLE (name text, enabled boolean, hosts text[], port integer, listen text, tls text, ca text,
|
||||
certificate text, private_key text, index_file text, spa boolean, editors text, modified timestamptz)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, private_key, index_file, spa, editors, modified
|
||||
FROM sites WHERE name = p_name;
|
||||
$$;
|
||||
|
||||
-- The enabled sites with their private keys, by name, for Caddy's configuration and certificate files.
|
||||
CREATE OR REPLACE FUNCTION sites_served()
|
||||
RETURNS TABLE (name text, enabled boolean, hosts text[], port integer, listen text, tls text, ca text,
|
||||
certificate text, private_key text, index_file text, spa boolean, editors text, modified timestamptz)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, private_key, index_file, spa, editors, modified
|
||||
FROM sites WHERE enabled ORDER BY name;
|
||||
$$;
|
||||
|
||||
-- Add the site, or replace all its settings.
|
||||
CREATE OR REPLACE PROCEDURE site_put(p_name text, p_enabled boolean, p_hosts text[], p_port integer, p_listen text,
|
||||
p_tls text, p_ca text, p_certificate text, p_private_key text, p_index_file text,
|
||||
p_spa boolean, p_editors text)
|
||||
LANGUAGE sql AS $$
|
||||
INSERT INTO sites VALUES (p_name, p_enabled, p_hosts, p_port, p_listen, p_tls, p_ca, p_certificate, p_private_key,
|
||||
p_index_file, p_spa, p_editors, now())
|
||||
ON CONFLICT (name) DO UPDATE SET enabled = p_enabled, hosts = p_hosts, port = p_port, listen = p_listen,
|
||||
tls = p_tls, ca = p_ca, certificate = p_certificate, private_key = p_private_key, index_file = p_index_file,
|
||||
spa = p_spa, editors = p_editors, modified = now();
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE site_remove(p_name text)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM sites WHERE name = p_name;
|
||||
$$;
|
||||
|
||||
-- The names of the sites whose files the members of the groups may change.
|
||||
CREATE OR REPLACE FUNCTION sites_editable(p_groups text[])
|
||||
RETURNS TABLE (name text)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT name FROM sites WHERE editors <> '' AND editors = ANY (p_groups) ORDER BY name;
|
||||
$$;
|
||||
|
||||
-- The names of the sites whose editors are the group.
|
||||
CREATE OR REPLACE FUNCTION sites_of_group(p_group text)
|
||||
RETURNS TABLE (name text)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT name FROM sites WHERE editors = p_group ORDER BY name;
|
||||
$$;
|
||||
|
||||
-- Give the sites of a renamed group its new name.
|
||||
CREATE OR REPLACE PROCEDURE sites_rename_group(p_old text, p_new text)
|
||||
LANGUAGE sql AS $$
|
||||
UPDATE sites SET editors = p_new, modified = now() WHERE editors = p_old;
|
||||
$$;
|
||||
@@ -1,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
|
||||
@@ -0,0 +1,108 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>{{ title }}</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; background: #f4f5f7; color: #1f2937; margin: 0; }
|
||||
main { max-width: 22rem; margin: 4rem auto; background: #fff; padding: 2rem; border-radius: 0.5rem; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); }
|
||||
h1 { font-size: 1.25rem; margin: 0 0 0.25rem; }
|
||||
p { margin: 0 0 1rem; color: #4b5563; }
|
||||
label { display: block; font-size: 0.875rem; margin: 0.75rem 0 0.25rem; }
|
||||
input { box-sizing: border-box; width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 0.25rem; font-size: 1rem; }
|
||||
button { width: 100%; margin-top: 1.25rem; padding: 0.6rem; border: 0; border-radius: 0.25rem; font-size: 1rem; cursor: pointer; background: #1d4ed8; color: #fff; }
|
||||
button.secondary { background: #fff; color: #1d4ed8; border: 1px solid #1d4ed8; margin-top: 0.75rem; }
|
||||
.error { background: #fee2e2; color: #991b1b; padding: 0.5rem 0.75rem; border-radius: 0.25rem; margin-bottom: 1rem; }
|
||||
ul { margin: 0 0 1rem; padding-left: 1.25rem; color: #4b5563; }
|
||||
#user_code { text-transform: uppercase; letter-spacing: 0.15em; font-family: ui-monospace, monospace; }
|
||||
.issuer { font-size: 0.75rem; color: #6b7280; margin: 1.5rem 0 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>{{ title }}</h1>
|
||||
{%- if message %}
|
||||
<p>{{ message }}</p>
|
||||
{%- endif %}
|
||||
{%- if error %}
|
||||
<div class="error" role="alert">{{ error }}</div>
|
||||
{%- endif %}
|
||||
{%- if items %}
|
||||
<ul>
|
||||
{%- for item in items %}
|
||||
<li>{{ item }}</li>
|
||||
{%- endfor %}
|
||||
</ul>
|
||||
{%- endif %}
|
||||
{%- if decide %}
|
||||
<form method="post" action="/oidc/device/decide">
|
||||
<input type="hidden" name="user_code" value="{{ user_code }}">
|
||||
<button type="submit" name="decision" value="allow">Allow</button>
|
||||
<button class="secondary" type="submit" name="decision" value="deny">Deny</button>
|
||||
</form>
|
||||
{%- endif %}
|
||||
{%- if form or device %}
|
||||
<form id="sign-in" method="post" action="{{ action }}">
|
||||
{%- if device %}
|
||||
<label for="user_code">Code shown on the device</label>
|
||||
<input id="user_code" name="user_code" value="{{ user_code }}" autocapitalize="characters" autocomplete="off" spellcheck="false" required{% if not form %} autofocus{% endif %}>
|
||||
{%- else %}
|
||||
<input type="hidden" name="request" value="{{ request }}">
|
||||
{%- endif %}
|
||||
{%- if form %}
|
||||
<label for="name">Name</label>
|
||||
<input id="name" name="name" value="{{ name }}" autocomplete="username webauthn" autocapitalize="none" spellcheck="false" required{% if not device %} autofocus{% endif %}>
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||
<label for="code">Code from the authenticator, when you have one</label>
|
||||
<input id="code" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code">
|
||||
<button type="submit">Sign in</button>
|
||||
{%- else %}
|
||||
<button type="submit">Continue</button>
|
||||
{%- endif %}
|
||||
</form>
|
||||
{%- endif %}
|
||||
{%- if form %}
|
||||
<form id="passkey-form" method="post" action="{{ action }}" hidden>
|
||||
<input type="hidden" name="request" value="{{ request }}">
|
||||
<input type="hidden" name="user_code">
|
||||
<input type="hidden" name="passkey_id">
|
||||
<input type="hidden" name="credential">
|
||||
</form>
|
||||
<button id="passkey" class="secondary" type="button" hidden>Sign in with a passkey</button>
|
||||
<script>
|
||||
// A passkey sign-in begins at /oidc/passkey and posts the browser's answer to the form's action with the authorization
|
||||
// request, or with the user code the sign-in form holds.
|
||||
(function () {
|
||||
var button = document.getElementById('passkey');
|
||||
if (!window.PublicKeyCredential || !PublicKeyCredential.parseRequestOptionsFromJSON) {
|
||||
return;
|
||||
}
|
||||
button.hidden = false;
|
||||
button.addEventListener('click', async function () {
|
||||
var code = document.getElementById('user_code');
|
||||
if (code && !code.reportValidity()) {
|
||||
return;
|
||||
}
|
||||
button.disabled = true;
|
||||
try {
|
||||
var begun = await (await fetch('/oidc/passkey', { method: 'POST' })).json();
|
||||
var credential = await navigator.credentials.get({ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(begun.options.publicKey) });
|
||||
var form = document.getElementById('passkey-form');
|
||||
form.elements.user_code.value = code ? code.value : '';
|
||||
form.elements.passkey_id.value = begun.id;
|
||||
form.elements.credential.value = JSON.stringify(credential.toJSON());
|
||||
form.submit();
|
||||
} catch (error) {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{%- endif %}
|
||||
<p class="issuer">{{ issuer }}</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,4 +0,0 @@
|
||||
[Resolve]
|
||||
{% for address in local_addr_list -%}
|
||||
DNSStubListenerExtra={{ address }}
|
||||
{% endfor -%}
|
||||
@@ -39,5 +39,9 @@ pools {
|
||||
rw-pool {
|
||||
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 = ""
|
||||
}
|
||||
|
||||
@@ -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 = ""
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user