Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68ebd541b2 | ||
|
|
f01be41191 | ||
|
|
d1970b77b7 | ||
|
|
bf651da5a6 | ||
|
|
4a5b15a559 |
No files matched your search
@@ -199,10 +199,15 @@ that a secret is set tells a reader more than it needs.
|
||||
|
||||
Decision: The configuration that `vpn-router-setup` writes for the web service has no secret
|
||||
options at all. They are recognised by name: `psk`, `database`, whose connection string may hold
|
||||
a password, and options with `secret`, `password`, `token` or a `_key` ending in the name.
|
||||
a password, and options with `secret`, `password`, `token` or a `_key` ending in the name. The
|
||||
names are checked at every level, so the settings of each site-to-site connection lose their
|
||||
`psk` too; an object is kept whatever its name, so a connection named like a secret is still
|
||||
shown.
|
||||
|
||||
Consequences: No secret reaches the web service's state file or any answer of the API. A client
|
||||
cannot tell whether a secret is set. A new secret option needs a name that matches.
|
||||
Consequences: No secret reaches the web service's state file or any answer of the API. Through
|
||||
`/config` a client cannot tell whether a secret is set; `/site-to-site` says whether each
|
||||
connection has a key (ADR-25), as an admin managing connections needs to know. A new secret
|
||||
option needs a name that matches.
|
||||
|
||||
## ADR-9: The Router Manager is a separate package with a small Python server
|
||||
|
||||
@@ -536,8 +541,8 @@ receiver. The router already runs PostgreSQL for the refresh tokens (ADR-15).
|
||||
|
||||
Decision: A service of its own, `vpn-router-metrics`, takes a sample every minute with `psutil`
|
||||
(`python3-psutil`) and the VPN readers it shares with the web service (`vpnrouter_status`). It
|
||||
keeps the samples in the router's database, in three detail tables (`metrics_system`,
|
||||
`metrics_network`, `metrics_vpn`). Each detail table has hourly, daily, monthly and yearly summary
|
||||
keeps the samples in the router's database, in four detail tables (`metrics_system`,
|
||||
`metrics_network`, `metrics_vpn`, `metrics_site_to_site`, see ADR-25). Each detail table has hourly, daily, monthly and yearly summary
|
||||
tables, with the average and the maximum of each value per period, written when the clock passes a
|
||||
period from the level below with `INSERT ... ON CONFLICT DO NOTHING`, so a stopped service catches
|
||||
up. Garbage collection runs on start and once a day, not with every write, and deletes the rows
|
||||
@@ -553,7 +558,7 @@ purge. A view shows only what its level keeps, so a shorter retention empties th
|
||||
view. A month view needs the hourly rows and a year view the monthly rows. `/metrics` has only the
|
||||
last sample; history is not scraped.
|
||||
|
||||
## ADR-23: /metrics takes HTTP Basic authentication of a service account that setup owns
|
||||
## ADR-23: /metrics takes HTTP Basic authentication of an internal service account the router manages
|
||||
|
||||
Status: Accepted
|
||||
|
||||
@@ -562,18 +567,110 @@ the router's tokens, which last minutes (ADR-3). The scraper should not be able
|
||||
API or the Manager, and its password should be checked and locked out like any other. The account
|
||||
belongs to the package's metrics, not to the people using the router.
|
||||
|
||||
Decision: A service account is a Manager-only account (ADR-18) with `vpnRouterScope` `service`.
|
||||
`vpn-router-setup` owns it: while metrics are enabled it adds the account `metrics.service_account`
|
||||
names, `metrics` by default, with a random password it writes to `/etc/vpn-router/metrics.secret`
|
||||
(root only), and sets a new random password, ending a lockout, when the file is missing or no
|
||||
longer holds the password. `vpn-router-user` and `/users` cannot add one. `/auth` refuses it
|
||||
with the message of a wrong password. `/metrics` accepts HTTP Basic only for that account, and
|
||||
Decision: A service account is a Manager-only account (ADR-18) with `vpnRouterScope` `service`,
|
||||
which another system signs in with to use a router service. Administrators add service accounts
|
||||
with `vpn-router-user add --service` and the web service's `/service-accounts`.
|
||||
`/service-accounts` is a path of its own, apart from `/users`, which does not list or serve them:
|
||||
a service account has no names, certificate, profile or TOTP, and is not a person an
|
||||
administrator manages with the users. Internal service accounts are those the router creates and
|
||||
manages for its own services (`vpnrouter_users.internal_accounts`), so the system, not an
|
||||
administrator, owns them; at present that is the metrics service's. `/service-accounts` marks
|
||||
them as `internal` and only lists them: every other request on one, and putting one in or taking
|
||||
one out of a group at `/groups`, gets 403, whatever the token. The router changes them through the
|
||||
directory's user-manager account, not the API, and root may still with `vpn-router-user`. For the
|
||||
metrics service, `vpn-router-setup` adds the account `metrics.service_account` names, `metrics` by
|
||||
default, while metrics are enabled, with a random password it writes to
|
||||
`/etc/vpn-router/metrics.secret` (root only), and sets a new random password, ending a lockout,
|
||||
when the file is missing or no longer holds the password. `/auth` refuses every service account with the message of a wrong password. `/metrics` accepts HTTP Basic only for that account, and
|
||||
only when it is a service account; the password is checked with a bind as the account, so the
|
||||
password policy's lockout applies (ADR-14). TLS is Caddy's, on `metrics.https_port` (ADR-21);
|
||||
plain HTTP is served on the interfaces `metrics.listen` names, `internal` by default.
|
||||
|
||||
Consequences: Every scrape binds to slapd once. The collector's operator copies the password from
|
||||
the router; deleting the file and running setup changes it. A user who already has the name is not
|
||||
taken over; setup fails until `metrics.service_account` names another. A disabled account stays
|
||||
disabled. A scraper on the external interface over plain HTTP sends the password in the clear, so
|
||||
taken over; setup fails until `metrics.service_account` names another. An account root disabled
|
||||
stays disabled. A scraper on the external interface over plain HTTP sends the password in the clear, so
|
||||
`metrics.listen` stays `internal` unless HTTPS is used.
|
||||
|
||||
## ADR-24: The Router Manager upgrades the router's packages in a transient unit outside its own
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: An admin should upgrade `vpn-router` and `vpn-router-manager` from the Router Manager. The
|
||||
upgrade restarts the process that starts it: the manager's postinst restarts
|
||||
`vpn-router-manager.service`, and the router's restarts `vpn-router-web` and `vpn-router-setup`. A
|
||||
child of the manager would be stopped with the manager's cgroup in the middle of dpkg, leaving
|
||||
packages half configured, and the manager's unit cannot run apt at all, since `ProtectSystem=strict`
|
||||
makes `/usr` and `/var/lib/dpkg` read-only. The package lists are updated by a cron job, not by the
|
||||
manager.
|
||||
|
||||
Decision: The manager starts the helper `/usr/lib/vpn-router-manager/upgrade` with `systemd-run` as
|
||||
the transient unit `vpn-router-manager-upgrade`, of its own cgroup and without a sandbox, and does
|
||||
not wait for it. The helper:
|
||||
|
||||
- finishes an interrupted dpkg run with `dpkg --configure -a`;
|
||||
- runs `apt-get install --only-upgrade` of the two packages, non-interactive, keeping changed configuration files, with `needrestart` suspended, and waiting for the dpkg lock that the cron job may hold;
|
||||
- records the state and the result in `/var/lib/vpn-router-manager/upgrade.json`, replaced by a rename, and the output in `upgrade.log`.
|
||||
|
||||
A restarted manager reads them again. A state of running without an active unit is shown as interrupted. The manager reboots the router the same way, as a transient timer `vpn-router-manager-reboot` 5 seconds later, so the answer reaches the browser first. Only members of `admins`, as the router's `/account` answers, may start either.
|
||||
|
||||
Consequences: The fixed unit name allows one upgrade at a time. The page loses the manager and the
|
||||
router's web service for a while during an upgrade and reads again until they answer. Sessions
|
||||
survive in PostgreSQL. The manager can upgrade only the system it runs on, so a manager on another
|
||||
host upgrades that host's packages, not the router's. The operating system's other packages are
|
||||
left to unattended-upgrades.
|
||||
|
||||
## ADR-25: Site-to-site connections are a map of named connections, edited through the API and applied by setup
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: The router had one IPsec site-to-site tunnel, the `remote` section, run as the swanctl
|
||||
connection `remote-site`, with the local identity mode in `wan` and the local subnets in `local`.
|
||||
A router often links more than one site, each with its own peer, subnets and key, and an admin
|
||||
should add them without a shell. The install answers and the Cloud Platform describe one
|
||||
connection, and the platform rewrites `vpn-router.json` when it rebuilds the VM.
|
||||
|
||||
Decision: `site_to_site` maps a connection name to its settings: `addrs`, `id`, `cidrs`,
|
||||
`local_cidrs`, `local_id_mode`, `psk` and `psk_file`. `remote` and `wan.local_id_mode` are gone,
|
||||
without a fallback for the old format. The name, a lowercase letter followed by at most 30
|
||||
lowercase letters, digits and hyphens, names the swanctl connection, its child and its secret
|
||||
`ike-psk-<name>`, so it is a valid swanctl section name and stays the same while the connection
|
||||
lives. The install answers and the Cloud Platform write the connection `remote-site`, the first
|
||||
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.
|
||||
- **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
|
||||
`cidrs`.
|
||||
- **Editing:** the status web service serves `/site-to-site` to members of `admins` and admin
|
||||
tokens, like `/users`. It never returns a key, only whether one is set, and takes a new key only
|
||||
when one is given. `vpn-config-edit` edits the same map. Both read, check with the validation
|
||||
of `vpn-router-setup` and write `vpn-router.json` holding one lock, replacing the file in one
|
||||
step, so neither loses the other's change.
|
||||
- **Applying:** the web service restarts `vpn-router-setup` without waiting for it, the one
|
||||
place that renders, loads and opens the firewall, so a change through the API takes the same
|
||||
path as a change by hand.
|
||||
- **Metrics per connection:** the metrics service keeps, per connection, whether it is up, the
|
||||
bytes and packets per second through its child SAs and the seconds its IKE SA has been up, in
|
||||
a detail table of its own, `metrics_site_to_site`, keyed by the connection's name, with the
|
||||
summaries and retention of the other tables (ADR-22). One row per connection keeps the values
|
||||
of a tunnel together and lets the set of connections change without changing the schema;
|
||||
`metrics_vpn` counts only clients. swanctl's child SA counters start again at a rekey, so the
|
||||
service keeps rates computed between two samples, treating a lower counter as counted from
|
||||
zero, and `/metrics` and the OTLP push serve them as gauges rather than counters that would
|
||||
appear to reset.
|
||||
|
||||
Consequences: A configuration with `remote` is refused, so the package and a configuration in
|
||||
the new format go together. The status shows each connection. A rate across a rekey misses the
|
||||
traffic of the old child SA since the previous sample. A renamed connection starts a new series.
|
||||
A connection added through the API lives in
|
||||
`vpn-router.json` only; the Cloud Platform's rebuild of the VM writes the file again with its
|
||||
one connection, dropping the others. A change through the API is answered before setup has
|
||||
applied it, so a configuration that setup cannot apply shows in the setup state, not in the
|
||||
answer. A connection with a key file keeps it until a key is given through the API, which then
|
||||
replaces the file.
|
||||
@@ -103,16 +103,11 @@ must have the type shown below; an unknown section or setting is an error.
|
||||
| `general` | `mode` | string | `manual` | How much you supply: `manual`, `interfaces` or `auto` |
|
||||
| `interfaces` | `external` | string | | Name of the NIC facing the untrusted network |
|
||||
| `interfaces` | `internal` | string | | Name of the NIC facing the protected network |
|
||||
| `wan` | `local_fqdn` | string | | This router's name or IP address, used as its IKE identity in `fqdn` mode |
|
||||
| `wan` | `local_id_mode` | string | `fqdn` | IKE identity source: `fqdn`, `public_ip` or `internal_ip` |
|
||||
| `local` | `cidrs` | list | | Local subnets advertised into the tunnel |
|
||||
| `wan` | `local_fqdn` | string | | This router's name or IP address: its IKE identity in `fqdn` mode, the name resolved in `public_ip` mode, and the name of HTTPS, road-warrior and OpenVPN |
|
||||
| `local` | `cidrs` | list | | Local subnets routed via the internal side, masqueraded to the external one, and offered to road-warrior and OpenVPN clients |
|
||||
| `local` | `int_addr` | string | | This host's address on the internal network |
|
||||
| `local` | `int_gateway_ip` | string | | Next hop on the internal side |
|
||||
| `remote` | `addrs` | list | | Remote gateway addresses or FQDNs |
|
||||
| `remote` | `id` | string | | Remote IKE identity, an FQDN or an IPv4 or IPv6 address |
|
||||
| `remote` | `cidrs` | list | | Remote subnets reachable through the tunnel |
|
||||
| `remote` | `psk` | string | | Pre-shared key |
|
||||
| `remote` | `psk_file` | string | | Path to a file holding the key; wins over `psk` |
|
||||
| `site_to_site` | | object | | The site-to-site connections, each a name and an object of the settings below |
|
||||
| `p2s` | `enabled` | boolean | `false` | Road-warrior access |
|
||||
| `p2s` | `address_pool` | string | | CIDR assigned to road-warrior clients |
|
||||
| `p2s` | `ca_name` | string | `VPN Router CA` | Common name for a locally created CA |
|
||||
@@ -149,6 +144,29 @@ must have the type shown below; an unknown section or setting is an error.
|
||||
| `metrics` | `keep_monthly` | string | `1 year` | How long the monthly summaries are kept |
|
||||
| `metrics` | `keep_yearly` | string | `10 years` | How long the yearly summaries are kept |
|
||||
|
||||
### Site-to-site connections
|
||||
|
||||
`site_to_site` maps a connection name to its settings. A name starts with a lowercase letter
|
||||
and has at most 31 lowercase letters, digits and hyphens; it names the strongSwan connection,
|
||||
its child and its secret (`ike-psk-<name>`). The install answers fill the connection
|
||||
`remote-site`; more are added in the file, with `vpn-config-edit`, or through `/site-to-site`
|
||||
of the status web service, which the Router Manager uses.
|
||||
|
||||
| Setting | Type | Default | Meaning |
|
||||
|---|---|---|---|
|
||||
| `addrs` | list | | Remote gateway addresses or FQDNs |
|
||||
| `id` | string | | Remote IKE identity, an FQDN or an IPv4 or IPv6 address |
|
||||
| `cidrs` | list | | Remote subnets reachable through the tunnel |
|
||||
| `local_cidrs` | list | | Local subnets advertised into the tunnel |
|
||||
| `local_id_mode` | string | `fqdn` | The router's IKE identity: `fqdn` is `wan.local_fqdn`, `public_ip` the address it resolves to, `internal_ip` the internal address |
|
||||
| `psk` | string | | Pre-shared key |
|
||||
| `psk_file` | string | | Path to a file holding the key; wins over `psk` |
|
||||
|
||||
A connection is set up once all of `addrs`, `id`, `cidrs`, `local_cidrs`, the key and its
|
||||
local identity are known; all such connections are written to
|
||||
`/etc/swanctl/conf.d/site-to-site.conf`. Traffic from a connection's `local_cidrs` to its
|
||||
`cidrs` is not masqueraded.
|
||||
|
||||
A minimal site-to-site configuration in `auto` mode:
|
||||
|
||||
```json
|
||||
@@ -156,16 +174,19 @@ A minimal site-to-site configuration in `auto` mode:
|
||||
"general": { "platform": "generic", "mode": "auto" },
|
||||
"wan": { "local_fqdn": "router.example.com" },
|
||||
"local": { "cidrs": ["10.0.0.0/24"] },
|
||||
"remote": {
|
||||
"addrs": ["peer.example.net"],
|
||||
"id": "peer.example.net",
|
||||
"cidrs": ["192.168.0.0/24"],
|
||||
"psk": "s3cr3t"
|
||||
"site_to_site": {
|
||||
"remote-site": {
|
||||
"addrs": ["peer.example.net"],
|
||||
"id": "peer.example.net",
|
||||
"cidrs": ["192.168.0.0/24"],
|
||||
"local_cidrs": ["10.0.0.0/24"],
|
||||
"psk": "s3cr3t"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The router's IKE identity (`wan.local_fqdn` in `fqdn` mode) and the remote one (`remote.id`) are
|
||||
The router's IKE identity (`wan.local_fqdn` in `fqdn` mode) and the remote one (`id`) are
|
||||
an FQDN or an IPv4 or IPv6 address. They are written to the strongSwan configuration as given;
|
||||
strongSwan reads an address as an IP identity and a name as an FQDN, so a leading `@` is not needed
|
||||
and is refused. Road-warrior and OpenVPN access need a name in `wan.local_fqdn`.
|
||||
@@ -264,13 +285,18 @@ vpn-config-edit show
|
||||
vpn-config-edit set web.status_content standard
|
||||
vpn-config-edit set web.port 7082
|
||||
vpn-config-edit set web.trusted_proxies 127.0.0.1,10.0.0.4
|
||||
vpn-config-edit set site_to_site.branch.addrs branch.example.net
|
||||
vpn-config-edit remove site_to_site.branch
|
||||
```
|
||||
|
||||
`show` prints the file as indented JSON and does not change it. `set` takes a setting as
|
||||
`section.option` and converts the value to the setting's type: a whole number, `true` or
|
||||
`false`, a list separated by commas (an empty value is an empty list), or a string; an unknown
|
||||
setting is refused. It checks the whole configuration as `vpn-router-setup` does, and only then
|
||||
writes the file, indented, with mode 0600, and reminds that `vpn-router-setup` applies it. A
|
||||
`section.option`, or as `site_to_site.NAME.option` for a site-to-site connection, which it
|
||||
creates with the defaults when it does not exist, and converts the value to the setting's type:
|
||||
a whole number, `true` or `false`, a list separated by commas (an empty value is an empty list),
|
||||
or a string; an unknown setting is refused. `remove site_to_site.NAME` removes a connection. It
|
||||
checks the whole configuration as `vpn-router-setup` does, and only then writes the file,
|
||||
indented, with mode 0600, and reminds that `vpn-router-setup` applies it. The status web service
|
||||
writes the file the same way, and a lock keeps the two from overwriting each other's change. A
|
||||
refused request exits with status 2, any other failure with 1.
|
||||
|
||||
## Applying changes
|
||||
@@ -401,9 +427,14 @@ it is added:
|
||||
name, the password and an optional e-mail address, for notifications: no names and no
|
||||
certificate, so no VPN access. It can sign in to `/auth` and, as a member of `admins`, manage
|
||||
the router. `add` without the first and the last name adds one; its names cannot be set later;
|
||||
- a service account, a Manager-only account whose `vpnRouterScope` is `service`, which
|
||||
`vpn-router-setup` adds and keeps for the metrics service (see Metrics); it cannot sign in to
|
||||
`/auth`, and `vpn-router-user` and `/users` cannot add one.
|
||||
- a service account, a Manager-only account whose `vpnRouterScope` is `service`, which another
|
||||
system signs in with to use a router service, such as an OpenTelemetry Collector reading
|
||||
`/metrics`; it cannot sign in to `/auth`. `add --service` adds one, and the web service manages
|
||||
them at `/service-accounts`, not at `/users`. Internal service accounts are those the router
|
||||
creates and manages for its own services; at present that is the metrics service's, the account
|
||||
`metrics.service_account` names while metrics are enabled (see Metrics). The API only lists
|
||||
them: `/service-accounts` refuses every other request on one, and `/groups` refuses to put one
|
||||
in or take one out of a group, with 403.
|
||||
|
||||
Passwords and TOTP secrets are read from the first line of standard input. The answer is text: a
|
||||
table of the users, the client profile, a TOTP secret or a short message, and a failure is a
|
||||
@@ -415,6 +446,7 @@ after a command its options.
|
||||
```sh
|
||||
echo 's3cr3t-pass' | vpn-router-user add alice --first-name Alice --last-name Smith --email alice@example.com
|
||||
echo 'm4nager-pass' | vpn-router-user add ops
|
||||
echo 's3rvice-pass' | vpn-router-user add backup --service
|
||||
vpn-router-user update alice --email a.smith@example.com
|
||||
vpn-router-user list
|
||||
vpn-router-user profile alice > alice.ovpn
|
||||
@@ -434,6 +466,7 @@ vpn-router-user otp-remove alice
|
||||
| `list` | The users with their kind, names and e-mail addresses, whether each is enabled or locked, has a certificate and is enrolled for TOTP |
|
||||
| `add <name> --first-name F --last-name L --email E` | Adds a VPN user and issues the certificate |
|
||||
| `add <name> [--email E]` | Adds a Manager-only account, with the e-mail address when given; one name without the other two options is refused |
|
||||
| `add <name> --service [--email E]` | Adds a service account, with the e-mail address when given; the names are refused |
|
||||
| `update <name> [--first-name F] [--last-name L] [--email E]` | Changes the names or the e-mail address |
|
||||
| `passwd <name>` | Sets the password |
|
||||
| `disable <name>`, `enable <name>` | Locks the user out of OpenVPN and `/auth`, and lets the user in again |
|
||||
@@ -448,8 +481,8 @@ A user name is 1 to 32 lowercase letters, digits and hyphens, starting with a le
|
||||
password has 8 to 128 characters; a first or last name has 1 to 64 printable characters; an
|
||||
e-mail address has one `@`, no spaces and a dot in its domain; a TOTP secret is 32 base32
|
||||
characters, as `otp-new` makes it, and a code 6 digits. Once enrolled, a user logs in to OpenVPN
|
||||
and `/auth` with the password followed by a current code. In the answers `manager_only` tells the
|
||||
kind, and a Manager-only account has its names as `null`; `update` changes only its e-mail
|
||||
and `/auth` with the password followed by a current code. In the answers `manager_only`, `service`
|
||||
and `internal` tell the kind, and a Manager-only account has its names as `null`; `update` changes only its e-mail
|
||||
address, and `--email ''` removes it. Changing a user's password, disabling, unlocking or removing the user also revokes its
|
||||
refresh tokens of `/auth`. The exit status is 0 on success, 2 for an invalid request, 3 when the
|
||||
user does not exist, 4 when the user exists and 1 for any other failure.
|
||||
@@ -545,7 +578,7 @@ configuration: no key, identity, subnet, or address but the client's. They show
|
||||
|---|---|
|
||||
| `basic` (default) | The overall status (`ok` or `degraded`), the time and the Client IP |
|
||||
| `standard` | Also the `vpn-router` version, the `vpn-router-manager` version or that it is not installed, and the number of active services and of services in any other state, without their names |
|
||||
| `full` | Also each service and its state, Docker, and the state of site-to-site, road-warrior, OpenVPN and WireGuard |
|
||||
| `full` | Also each service and its state, Docker, and the state of each site-to-site connection, road-warrior, OpenVPN and WireGuard |
|
||||
|
||||
The Client IP is the address the request came from: the last entry of `X-Forwarded-For` for a
|
||||
request from one of `web.trusted_proxies`, by default the address the request came in on, so
|
||||
@@ -580,7 +613,9 @@ curl http://10.0.0.4:17081/
|
||||
| `/auth` | Signing in with a user's password and TOTP code, refreshing and signing out |
|
||||
| `/config` | The configuration without its secret options, and what the router resolved and runs, as JSON; needs a token |
|
||||
| `/users` | The users of OpenVPN and road-warrior access; needs a token |
|
||||
| `/service-accounts` | The service accounts; needs a token |
|
||||
| `/groups` | The groups of users; needs a token |
|
||||
| `/site-to-site` | The site-to-site connections: list, add, change and remove; needs a token |
|
||||
| `/account` | The account of the signed-in user: its details, its password and its TOTP enrolment; needs a user's token |
|
||||
|
||||
The status holds the state of `vpn-router-setup`, `slapd` and each PostgreSQL cluster unit
|
||||
@@ -591,8 +626,8 @@ road-warrior access is configured, of `wg-quick@wg0` when WireGuard is, and of
|
||||
OpenVPN clients, from the server's status file, which OpenVPN rewrites every minute; the package version and,
|
||||
as `manager_version`, the installed version of `vpn-router-manager` (`null`, shown as not installed, without it); whether
|
||||
Docker is installed, and when it is, the state of `docker.service` and of `vpn-router-docker`
|
||||
(the Docker rules), which do not change `status`; whether the
|
||||
site-to-site tunnel is established; the number of connected road-warrior (IPsec P2S) clients; and
|
||||
(the Docker rules), which do not change `status`; as `site_to_site.connections`, whether each
|
||||
site-to-site connection that is set up is established (`null` when swanctl cannot be asked); the number of connected road-warrior (IPsec P2S) clients; and
|
||||
the WireGuard peers with the age of their last handshake and their traffic. `status` is `ok` when
|
||||
all these services are active, and `degraded` otherwise.
|
||||
|
||||
@@ -603,7 +638,7 @@ curl http://10.0.0.4:7081/status
|
||||
|
||||
### Tokens and signing in
|
||||
|
||||
`/config`, `/users`, `/groups` and `/account` need a JWT in an `Authorization: Bearer` header, signed with
|
||||
`/config`, `/users`, `/service-accounts`, `/groups`, `/site-to-site` and `/account` need a JWT in an `Authorization: Bearer` header, signed with
|
||||
HS256 by the key the web service makes on its first run, `/etc/vpn-router/web.key` (root only).
|
||||
The key never leaves the router; while it does not exist these paths and `/auth` are not served
|
||||
(404). A missing, expired or wrongly signed token, or one of another algorithm, gets 401 with
|
||||
@@ -661,8 +696,8 @@ curl -H "Authorization: Bearer $TOKEN" http://10.0.0.4:7081/config
|
||||
|
||||
| Key | Content |
|
||||
| --- | --- |
|
||||
| `configuration` | `vpn-router.json` with the defaults applied, without its secret options (`psk`, `database`, and any option named like a secret, password, token or key) |
|
||||
| `network` | The external and internal interface, their addresses, the internal gateway, the local and remote IKE identities and subnets, and the IPv4 routes as `ip -j -4 route` prints them |
|
||||
| `configuration` | `vpn-router.json` with the defaults applied, without its secret options at any level (`psk`, `database`, and any option named like a secret, password, token or key) |
|
||||
| `network` | The external and internal interface, their addresses, the internal gateway, `local.cidrs`, `site_to_site`: each connection with `name`, `local_identity`, `remote_identity`, `local_cidrs` and `remote_cidrs`, and the IPv4 routes as `ip -j -4 route` prints them |
|
||||
| `ipsec` | `connections` and `sas`: one object per connection and per IKE SA, read from `swanctl --list-conns --pretty` and `swanctl --list-sas --pretty`, with the connection's name under `name` and swanctl's keys and string values |
|
||||
| `wireguard` | The public key and port of `wg0`, and each peer's public key, endpoint, allowed IPs, handshake age and traffic; no private or preshared key |
|
||||
| `docker` | `docker.exposed_ports` and the rules of the `VPN-ROUTER-DOCKER` chain |
|
||||
@@ -685,7 +720,7 @@ and answer bodies are JSON; a request body is at most 4096 bytes.
|
||||
|
||||
| Request | Body | Answer |
|
||||
| --- | --- | --- |
|
||||
| `GET /users` | | `{"users": [{"name", "manager_only", "enabled", "locked", "certificate", "otp", "first_name", "last_name", "email"}, ...]}` |
|
||||
| `GET /users` | | `{"users": [{"name", "manager_only", "service", "internal", "enabled", "locked", "certificate", "otp", "first_name", "last_name", "email"}, ...]}` |
|
||||
| `POST /users` | `{"name", "password", "first_name", "last_name", "email"}` for a VPN user; `{"name", "password", "email"?}` for a Manager-only account | 201 and the user |
|
||||
| `PUT /users/<name>` | any of `{"first_name", "last_name", "email"}`; for a Manager-only account only `{"email"}`, `""` removing it | the user |
|
||||
| `PUT /users/<name>/password` | `{"password": ...}` | the user |
|
||||
@@ -699,7 +734,8 @@ and answer bodies are JSON; a request body is at most 4096 bytes.
|
||||
| `DELETE /users/<name>/otp` | | the user |
|
||||
|
||||
A failure is `{"error": ...}` with 400 for an invalid request, 404 for a user that does not
|
||||
exist, 409 for a user that exists and 500 otherwise. `/users` works whether or not OpenVPN or
|
||||
exist or is a service account, 409 for a user that exists and 500 otherwise. `/users` does not
|
||||
list service accounts; `/service-accounts` serves them. `/users` works whether or not OpenVPN or
|
||||
road-warrior access is enabled; the profile needs OpenVPN. Passwords, private keys and TOTP
|
||||
secrets cross the internal network in plain HTTP, like the token.
|
||||
|
||||
@@ -709,6 +745,26 @@ curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
http://10.0.0.4:7081/users
|
||||
```
|
||||
|
||||
### The /service-accounts API
|
||||
|
||||
`/service-accounts` manages the service accounts with the same token and the same module as
|
||||
`/users`.
|
||||
|
||||
| Request | Body | Answer |
|
||||
| --- | --- | --- |
|
||||
| `GET /service-accounts` | | `{"service_accounts": [{"name", "internal", "enabled", "locked", "email", ...}, ...]}`, each as `/users` shows a user |
|
||||
| `POST /service-accounts` | `{"name", "password", "email"?}` | 201 and the account |
|
||||
| `PUT /service-accounts/<name>` | `{"email"}`; `""` removes it | the account |
|
||||
| `PUT /service-accounts/<name>/password` | `{"password": ...}` | the account |
|
||||
| `POST /service-accounts/<name>/disable` | | the account |
|
||||
| `POST /service-accounts/<name>/enable` | | the account |
|
||||
| `POST /service-accounts/<name>/unlock` | | the account |
|
||||
| `DELETE /service-accounts/<name>` | | `{"name", "removed": true}` |
|
||||
|
||||
The internal service accounts, `internal` true, are only listed: every other request on one gets
|
||||
403, as the router manages them. A failure is otherwise as for `/users`, with 404 also for a
|
||||
name that is not a service account.
|
||||
|
||||
### The /account API
|
||||
|
||||
`/account` serves the user the access token names, whether or not the user is a member of
|
||||
@@ -745,6 +801,26 @@ member, 404 for a group or user that does not exist, 409 for a group that exists
|
||||
otherwise. `DELETE /users/<name>` is refused with 400 while the user is the last member of a
|
||||
group.
|
||||
|
||||
### The /site-to-site API
|
||||
|
||||
`/site-to-site` manages the site-to-site connections of `vpn-router.json` with the same token as
|
||||
`/users`.
|
||||
|
||||
| Request | Body | Answer |
|
||||
| --- | --- | --- |
|
||||
| `GET /site-to-site` | | `{"connections": [{"name", "addrs", "id", "cidrs", "local_cidrs", "local_id_mode", "psk_file", "psk_set", "established"}, ...]}` |
|
||||
| `POST /site-to-site` | `{"name", "addrs", "id", "cidrs", "local_cidrs", "local_id_mode", "psk"}`; `psk` is required | 201 and the connection |
|
||||
| `PUT /site-to-site/<name>` | any of the settings of `POST` but `name`; an empty or absent `psk` keeps the key, a `psk` given replaces `psk_file` | the connection |
|
||||
| `DELETE /site-to-site/<name>` | | `{"name", "removed": true}` |
|
||||
|
||||
A connection is shown without its key: `psk_set` says whether `psk` holds one, and `established`
|
||||
whether its tunnel is up, `null` when swanctl cannot be asked. Each change is made under the lock
|
||||
`vpn-config-edit` takes, checked as `vpn-router-setup` checks the configuration, written to
|
||||
`vpn-router.json`, and applied by restarting `vpn-router-setup` without waiting for it. A failure
|
||||
is `{"error": ...}` with 400 for an invalid request or configuration, with the reason, 404 for a
|
||||
connection that does not exist, 409 for a name that exists and 500 otherwise. Keys cross the
|
||||
internal network in plain HTTP, like the token.
|
||||
|
||||
The service starts on its own, without waiting for `vpn-router-setup`, and keeps running. Every
|
||||
30 seconds it reads `web.listen`, `web.port` and `web.enabled` from `vpn-router.json` and works
|
||||
out the addresses of the interfaces `web.listen` names as `general.mode` directs; it listens on
|
||||
@@ -792,30 +868,37 @@ takes a sample of the router with `psutil`:
|
||||
- the free and the total space of `/`;
|
||||
- the bytes received and sent per second on the external and the internal interface;
|
||||
- the 1, 5 and 15 minute load averages;
|
||||
- the VPN clients of each enabled VPN system: road-warrior (IPsec P2S) and OpenVPN clients,
|
||||
WireGuard peers with a handshake in the last 3 minutes, and the site-to-site tunnel, 1 while it is
|
||||
up.
|
||||
- the VPN clients of each enabled VPN system: road-warrior (IPsec P2S) and OpenVPN clients, and
|
||||
WireGuard peers with a handshake in the last 3 minutes;
|
||||
- for each site-to-site connection that is set up: whether it is up (1 or 0), the bytes and the
|
||||
packets per second received and sent through its child SAs since the previous sample, and the
|
||||
seconds its IKE SA has been up. The child SAs' counters start again at a rekey; a counter lower
|
||||
than in the previous sample counts from zero.
|
||||
|
||||
### History
|
||||
|
||||
The samples are kept in the router's PostgreSQL database, the one of the refresh tokens
|
||||
(`web.database`), in three detail tables, one row per minute, and each is summarized hourly, daily,
|
||||
monthly and yearly. A summary row has the start of its period, in UTC, and the average and the
|
||||
maximum of each value:
|
||||
(`web.database`), in four detail tables, one row per minute, and per interface, kind of client or
|
||||
site-to-site connection, and each is summarized hourly, daily, monthly and yearly. A summary row
|
||||
has the start of its period, in UTC, and the average and the maximum of each value; the average
|
||||
of a connection's `up` is the part of the period it was up:
|
||||
|
||||
| Level | Tables | Kept |
|
||||
|---|---|---|
|
||||
| Details | `metrics_system`, `metrics_network`, `metrics_vpn` | `metrics.keep_details` |
|
||||
| Hourly | `metrics_system_hourly`, `metrics_network_hourly`, `metrics_vpn_hourly` | `metrics.keep_hourly` |
|
||||
| Daily | `metrics_system_daily`, `metrics_network_daily`, `metrics_vpn_daily` | `metrics.keep_daily` |
|
||||
| Monthly | `metrics_system_monthly`, `metrics_network_monthly`, `metrics_vpn_monthly` | `metrics.keep_monthly` |
|
||||
| Yearly | `metrics_system_yearly`, `metrics_network_yearly`, `metrics_vpn_yearly` | `metrics.keep_yearly` |
|
||||
| Details | `metrics_system`, `metrics_network`, `metrics_vpn`, `metrics_site_to_site` | `metrics.keep_details` |
|
||||
| Hourly | `metrics_system_hourly`, `metrics_network_hourly`, `metrics_vpn_hourly`, `metrics_site_to_site_hourly` | `metrics.keep_hourly` |
|
||||
| Daily | `metrics_system_daily`, `metrics_network_daily`, `metrics_vpn_daily`, `metrics_site_to_site_daily` | `metrics.keep_daily` |
|
||||
| Monthly | `metrics_system_monthly`, `metrics_network_monthly`, `metrics_vpn_monthly`, `metrics_site_to_site_monthly` | `metrics.keep_monthly` |
|
||||
| Yearly | `metrics_system_yearly`, `metrics_network_yearly`, `metrics_vpn_yearly`, `metrics_site_to_site_yearly` | `metrics.keep_yearly` |
|
||||
|
||||
`metrics_site_to_site` has the columns `connection`, `up`, `rx`, `tx`, `packets_rx`, `packets_tx`
|
||||
and `uptime`.
|
||||
|
||||
How it works:
|
||||
|
||||
- **Summaries:** a period is summarized once it is complete, from the level below. A stopped service catches up on the periods it missed, as long as their rows are still kept.
|
||||
- **Garbage collection:** runs on start and once a day. It deletes the rows older than their retention, a count and a unit such as `24 hours`, `3 months` or `10 years`.
|
||||
- **History API:** `GET /metrics/history?range=hour|day|month|year` of the status web service, protected like `/config`, answers the views of the Router Manager's Metrics tab:
|
||||
- **History API:** `GET /metrics/history?range=hour|day|month|year` of the status web service, protected like `/config`, answers the views of the Router Manager's Metrics tab. Each point has the system values, `network` by interface, `clients` by kind and `site_to_site` by connection, each value as its `avg` and `max`:
|
||||
|
||||
| Range | Bucket | Source |
|
||||
|---|---|---|
|
||||
@@ -834,7 +917,8 @@ How it works:
|
||||
- `system_filesystem_usage_bytes` and `system_filesystem_utilization`;
|
||||
- `system_network_io_bytes_total`;
|
||||
- `system_cpu_load_average_1m`, `_5m` and `_15m`;
|
||||
- `vpn_router_clients`.
|
||||
- `vpn_router_clients`, by `kind`: `p2s`, `openvpn` and `wireguard`;
|
||||
- by `connection`, for each site-to-site connection: `vpn_router_site_to_site_up`, `vpn_router_site_to_site_uptime_seconds`, and, by `direction` too, `vpn_router_site_to_site_io_bytes_per_second` and `vpn_router_site_to_site_packets_per_second`. The rates are gauges, since the child SAs' counters start again at each rekey.
|
||||
- **Listening:** on the interfaces `metrics.listen` names, `internal` by default, at `metrics.port`, and on `127.0.0.1`. While HTTPS is on, Caddy serves it at `metrics.https_port` for `wan.local_fqdn`. `vpn-router-setup` opens both ports on the internal and the external interface.
|
||||
- **Authentication:** HTTP Basic as the service account `metrics.service_account` names, `metrics` by default. The password is checked with a bind to the user directory, so 5 failed attempts lock the account as for any user.
|
||||
- **The account:** `vpn-router-setup` owns it while metrics are enabled.
|
||||
@@ -864,15 +948,15 @@ receivers:
|
||||
|
||||
When `metrics.otlp_endpoint` is set, every sample is also pushed there as an OTLP/HTTP JSON request.
|
||||
|
||||
- **Content:** the semantic convention names (`system.cpu.utilization`, `system.network.io` and so on), and the resource attributes `service.name` `vpn-router` and `host.name`.
|
||||
- **Content:** the semantic convention names (`system.cpu.utilization`, `system.network.io` and so on), `vpn_router.clients`, the gauges `vpn_router.site_to_site.up`, `.uptime`, `.io` and `.packets` with the attribute `connection`, and the resource attributes `service.name` `vpn-router` and `host.name`.
|
||||
- **Authentication:** HTTP Basic with `metrics.otlp_username` and `metrics.otlp_password` when they are set.
|
||||
- **Failures:** a failed push is logged and not repeated; the next sample is pushed a minute later.
|
||||
|
||||
## Router Manager
|
||||
|
||||
The `vpn-router-manager` package, built in the repository `linux-cloud-router-manager`, is a web
|
||||
application that shows the state of the router and manages its users through the status web
|
||||
service. It can be installed on the router or on another Linux system. While it is installed on
|
||||
application that shows the state of the router and manages its users and site-to-site
|
||||
connections through the status web service. It can be installed on the router or on another Linux system. While it is installed on
|
||||
the router, `vpn-router-setup` opens its port (`port` in `/etc/vpn-router-manager/manager.json`,
|
||||
7080 by default, HTTP; HTTPS is Caddy's, on `https.manager_port`) on the internal and the external
|
||||
interface; restart `vpn-router-setup` after changing it. The manager's `listen` chooses the
|
||||
|
||||
@@ -1,3 +1,99 @@
|
||||
vpn-router (0.18.3-1) unstable; urgency=medium
|
||||
|
||||
fix: internal service accounts are the router's own, of which the metrics service's is the one at present, and the documentation says what a service account is.
|
||||
|
||||
* /service-accounts, /groups and the internal field of the answers
|
||||
take every internal service account, the accounts the router creates
|
||||
and manages for its own services, instead of only the metrics
|
||||
service's; the 403 answers say that the router manages them.
|
||||
* The README, the ADR and the users module describe a service account
|
||||
as the account another system, such as an OpenTelemetry Collector
|
||||
reading /metrics, signs in with, and the internal ones as managed by
|
||||
the router.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 19:00:00 +0100
|
||||
|
||||
vpn-router (0.18.2-1) unstable; urgency=medium
|
||||
|
||||
fix: service accounts are managed at /service-accounts, not /users, and the internal one of the metrics service is only listed.
|
||||
|
||||
* /service-accounts, for members of admins and admin tokens, lists the
|
||||
service accounts, adds one with a password and an optional e-mail
|
||||
address, changes the e-mail address, sets the password, disables,
|
||||
enables, unlocks and removes them.
|
||||
* /users no longer lists service accounts or takes "service" on POST,
|
||||
and answers 404 to a request naming one.
|
||||
* /service-accounts answers 403 to every request but the list on the
|
||||
account metrics.service_account names while metrics are enabled, and
|
||||
/groups to putting it in or taking it out of a group;
|
||||
vpn-router-setup and root's vpn-router-user still change it.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 18:00:00 +0100
|
||||
|
||||
vpn-router (0.18.1-1) unstable; urgency=medium
|
||||
|
||||
fix: vpn-router-setup owns only the creation of the metrics service's account, which is marked internal, and administrators add service accounts with vpn-router-user add --service and /users.
|
||||
|
||||
* POST /users takes "service": true and adds a service account, which
|
||||
cannot sign in to /auth; vpn-router-user add takes --service.
|
||||
* The users of /users and vpn-router-user have internal, true for the
|
||||
account metrics.service_account names while metrics are enabled;
|
||||
vpn-router-user list shows its kind as internal.
|
||||
* PUT /users/<name>/password refuses the internal service account,
|
||||
whose password vpn-router-setup keeps.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 17:00:00 +0100
|
||||
|
||||
vpn-router (0.18.0-2) unstable; urgency=medium
|
||||
|
||||
update: release.py tags a Debian revision other than 1 as x.y.z-n.
|
||||
|
||||
* release.py tags the newest entry x.y.z for the Debian revision 1 and
|
||||
x.y.z-n for a later revision, so a packaging change gets a release
|
||||
of its own.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 14:00:00 +0100
|
||||
|
||||
vpn-router (0.18.0-1) unstable; urgency=medium
|
||||
|
||||
feature: site_to_site holds any number of named site-to-site connections, which /site-to-site lists, adds, changes and removes.
|
||||
|
||||
* site_to_site maps a connection name to addrs, id, cidrs, local_cidrs,
|
||||
local_id_mode, psk and psk_file; it replaces remote and
|
||||
wan.local_id_mode, and a configuration with them is refused.
|
||||
* The install answers fill the connection remote-site; the local subnets
|
||||
fill both its local_cidrs and local.cidrs.
|
||||
* Every ready connection is written to
|
||||
/etc/swanctl/conf.d/site-to-site.conf as the swanctl connection and
|
||||
child of its name with the secret ike-psk-<name>; remote-site.conf is
|
||||
no longer written.
|
||||
* Traffic from each connection's local_cidrs to its cidrs is not
|
||||
masqueraded.
|
||||
* /site-to-site, for members of admins and admin tokens, lists the
|
||||
connections without their keys, adds, changes and removes them, and
|
||||
restarts vpn-router-setup to apply the change.
|
||||
* vpn-config-edit sets site_to_site.NAME.option, creating the
|
||||
connection, and removes site_to_site.NAME; it and the web service
|
||||
write vpn-router.json under one lock.
|
||||
* The status shows site_to_site.connections, whether each connection is
|
||||
established.
|
||||
* The metrics service keeps, per site-to-site connection, whether it is
|
||||
up, the bytes and packets per second received and sent, and the
|
||||
seconds its IKE SA has been up, in metrics_site_to_site and its
|
||||
summaries; /metrics/history has them in site_to_site of each point.
|
||||
* /metrics serves vpn_router_site_to_site_up,
|
||||
vpn_router_site_to_site_uptime_seconds,
|
||||
vpn_router_site_to_site_io_bytes_per_second and
|
||||
vpn_router_site_to_site_packets_per_second by connection, and the
|
||||
OTLP push the vpn_router.site_to_site gauges; vpn_router_clients and
|
||||
metrics_vpn no longer have the kind site_to_site.
|
||||
* /config shows each connection in network.site_to_site, and no psk at
|
||||
any level of the configuration.
|
||||
* Removing the package terminates every site-to-site connection; purging
|
||||
removes site-to-site.conf.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 23:00:00 +0100
|
||||
|
||||
vpn-router (0.17.0-1) unstable; urgency=medium
|
||||
|
||||
feature: /config shows the router's PostgreSQL clusters and their settings, and /postgresql their databases with their sizes.
|
||||
|
||||
@@ -43,7 +43,7 @@ case "$1" in
|
||||
ucfr --purge vpn-router /etc/vpn-router/vpn-router.json
|
||||
fi
|
||||
|
||||
rm -f /etc/swanctl/conf.d/remote-site.conf \
|
||||
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/wireguard/wg0.conf \
|
||||
|
||||
@@ -6,8 +6,13 @@ case "$1" in
|
||||
# Stop offering the tunnels, but leave strongSwan itself enabled: it is
|
||||
# a shared service and may be in use by something other than this
|
||||
# package. The connection files are removed on purge.
|
||||
# The site-to-site connections are the names of site_to_site in vpn-router.json.
|
||||
if [ -d /run/systemd/system ]; then
|
||||
swanctl --terminate --ike remote-site >/dev/null 2>&1 || true
|
||||
connections="$(python3 -c 'import json, sys; print(" ".join(json.load(open(sys.argv[1])).get("site_to_site", {})))' \
|
||||
/etc/vpn-router/vpn-router.json 2>/dev/null || true)"
|
||||
for connection in $connections; do
|
||||
swanctl --terminate --ike "$connection" >/dev/null 2>&1 || true
|
||||
done
|
||||
swanctl --terminate --ike road-warrior >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
|
||||
@@ -66,7 +66,8 @@ Type: select
|
||||
Choices: fqdn, public_ip, internal_ip
|
||||
Default: fqdn
|
||||
Description: IKE local identity mode:
|
||||
How to derive the IKE identity advertised to the remote site:
|
||||
How to derive the IKE identity the first site-to-site connection
|
||||
advertises to the remote site:
|
||||
.
|
||||
fqdn - use the FQDN, which must match what the peer expects.
|
||||
.
|
||||
@@ -78,8 +79,9 @@ Template: vpn-router/local_cidrs
|
||||
Type: string
|
||||
Default:
|
||||
Description: Local subnet CIDR(s):
|
||||
Comma-separated list of local subnet CIDRs to advertise into the
|
||||
site-to-site tunnel (for example 10.0.0.0/24 or 10.0.0.0/24,10.0.1.0/24).
|
||||
Comma-separated list of local subnet CIDRs routed by this router and
|
||||
advertised into the first site-to-site connection (for example
|
||||
10.0.0.0/24 or 10.0.0.0/24,10.0.1.0/24).
|
||||
|
||||
Template: vpn-router/int_gateway_ip
|
||||
Type: string
|
||||
@@ -94,25 +96,26 @@ Type: string
|
||||
Default:
|
||||
Description: Remote site WAN IP address(es):
|
||||
Comma-separated list of remote gateway addresses or FQDNs for the
|
||||
site-to-site IPSec tunnel.
|
||||
first site-to-site connection.
|
||||
|
||||
Template: vpn-router/remote_id
|
||||
Type: string
|
||||
Default:
|
||||
Description: Remote site IKE identity:
|
||||
IKE identity of the remote peer, without a leading @.
|
||||
IKE identity of the remote peer of the first site-to-site connection,
|
||||
without a leading @.
|
||||
|
||||
Template: vpn-router/remote_cidrs
|
||||
Type: string
|
||||
Default:
|
||||
Description: Remote subnet CIDR(s):
|
||||
Comma-separated list of remote subnet CIDRs reachable through the
|
||||
site-to-site tunnel (for example 192.168.0.0/24).
|
||||
first site-to-site connection (for example 192.168.0.0/24).
|
||||
|
||||
Template: vpn-router/psk
|
||||
Type: password
|
||||
Description: Pre-shared key (PSK):
|
||||
Pre-shared key for the site-to-site IKEv2 tunnel. Must match the value
|
||||
Pre-shared key of the first site-to-site connection. Must match the value
|
||||
configured on the remote peer. Stored in the configuration file and cleared
|
||||
from the debconf database after installation.
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@ on the next dpkg-reconfigure.
|
||||
|
||||
Answers arrive as VPN_ROUTER_* environment variables and are converted to the
|
||||
type of the setting they fill: comma-separated lists become arrays, "true" and
|
||||
"false" become booleans and digits become integers.
|
||||
"false" become booleans and digits become integers. The site-to-site answers
|
||||
fill the connection vpnrouter.FIRST_CONNECTION of site_to_site; the local
|
||||
subnets fill both its local_cidrs and local.cidrs.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -29,27 +31,29 @@ sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
|
||||
|
||||
import vpnrouter
|
||||
|
||||
# environment variable -> (section, option)
|
||||
FIRST = ('site_to_site', vpnrouter.FIRST_CONNECTION)
|
||||
|
||||
# environment variable -> the settings it fills, each (section, option) or (site_to_site, name, option)
|
||||
ANSWERS = {
|
||||
'VPN_ROUTER_PLATFORM': ('general', 'platform'),
|
||||
'VPN_ROUTER_MODE': ('general', 'mode'),
|
||||
'VPN_ROUTER_INT_ADDR': ('local', 'int_addr'),
|
||||
'VPN_ROUTER_EXTERNAL_INTERFACE': ('interfaces', 'external'),
|
||||
'VPN_ROUTER_INTERNAL_INTERFACE': ('interfaces', 'internal'),
|
||||
'VPN_ROUTER_LOCAL_FQDN': ('wan', 'local_fqdn'),
|
||||
'VPN_ROUTER_LOCAL_ID_MODE': ('wan', 'local_id_mode'),
|
||||
'VPN_ROUTER_LOCAL_CIDRS': ('local', 'cidrs'),
|
||||
'VPN_ROUTER_INT_GATEWAY_IP': ('local', 'int_gateway_ip'),
|
||||
'VPN_ROUTER_REMOTE_ADDRS': ('remote', 'addrs'),
|
||||
'VPN_ROUTER_REMOTE_ID': ('remote', 'id'),
|
||||
'VPN_ROUTER_REMOTE_CIDRS': ('remote', 'cidrs'),
|
||||
'VPN_ROUTER_PSK': ('remote', 'psk'),
|
||||
'VPN_ROUTER_P2S_ENABLED': ('p2s', 'enabled'),
|
||||
'VPN_ROUTER_P2S_ADDRESS_POOL': ('p2s', 'address_pool'),
|
||||
'VPN_ROUTER_P2S_CA_NAME': ('p2s', 'ca_name'),
|
||||
'VPN_ROUTER_WG_ENABLED': ('wireguard', 'enabled'),
|
||||
'VPN_ROUTER_WG_ADDRESS': ('wireguard', 'address'),
|
||||
'VPN_ROUTER_WG_LISTEN_PORT': ('wireguard', 'listen_port'),
|
||||
'VPN_ROUTER_PLATFORM': (('general', 'platform'),),
|
||||
'VPN_ROUTER_MODE': (('general', 'mode'),),
|
||||
'VPN_ROUTER_INT_ADDR': (('local', 'int_addr'),),
|
||||
'VPN_ROUTER_EXTERNAL_INTERFACE': (('interfaces', 'external'),),
|
||||
'VPN_ROUTER_INTERNAL_INTERFACE': (('interfaces', 'internal'),),
|
||||
'VPN_ROUTER_LOCAL_FQDN': (('wan', 'local_fqdn'),),
|
||||
'VPN_ROUTER_LOCAL_ID_MODE': ((*FIRST, 'local_id_mode'),),
|
||||
'VPN_ROUTER_LOCAL_CIDRS': (('local', 'cidrs'), (*FIRST, 'local_cidrs')),
|
||||
'VPN_ROUTER_INT_GATEWAY_IP': (('local', 'int_gateway_ip'),),
|
||||
'VPN_ROUTER_REMOTE_ADDRS': ((*FIRST, 'addrs'),),
|
||||
'VPN_ROUTER_REMOTE_ID': ((*FIRST, 'id'),),
|
||||
'VPN_ROUTER_REMOTE_CIDRS': ((*FIRST, 'cidrs'),),
|
||||
'VPN_ROUTER_PSK': ((*FIRST, 'psk'),),
|
||||
'VPN_ROUTER_P2S_ENABLED': (('p2s', 'enabled'),),
|
||||
'VPN_ROUTER_P2S_ADDRESS_POOL': (('p2s', 'address_pool'),),
|
||||
'VPN_ROUTER_P2S_CA_NAME': (('p2s', 'ca_name'),),
|
||||
'VPN_ROUTER_WG_ENABLED': (('wireguard', 'enabled'),),
|
||||
'VPN_ROUTER_WG_ADDRESS': (('wireguard', 'address'),),
|
||||
'VPN_ROUTER_WG_LISTEN_PORT': (('wireguard', 'listen_port'),),
|
||||
}
|
||||
|
||||
|
||||
@@ -74,23 +78,28 @@ def main():
|
||||
vpnrouter.fail(f'cannot parse {vpnrouter.CONFIG_FILE}: the top level is not an object')
|
||||
|
||||
answered = False
|
||||
for variable, (section, option) in ANSWERS.items():
|
||||
value = os.environ.get(variable)
|
||||
if not value:
|
||||
for variable, paths in ANSWERS.items():
|
||||
answer = os.environ.get(variable)
|
||||
if not answer:
|
||||
continue
|
||||
# A value that does not convert is kept as a string for validate() to report.
|
||||
default = vpnrouter.SCHEMA[section][option]
|
||||
if isinstance(default, bool):
|
||||
value = value == 'true'
|
||||
elif isinstance(default, int):
|
||||
value = int(value) if value.isdigit() else value
|
||||
elif isinstance(default, list):
|
||||
value = [item.strip() for item in value.split(',') if item.strip()]
|
||||
if not isinstance(cfg.get(section, {}), dict):
|
||||
vpnrouter.fail(f'cannot parse {vpnrouter.CONFIG_FILE}: {section} is not an object')
|
||||
if cfg.setdefault(section, {}).get(option) != value:
|
||||
cfg[section][option] = value
|
||||
answered = True
|
||||
for path in paths:
|
||||
# A value that does not convert is kept as a string for validate() to report.
|
||||
default = vpnrouter.CONNECTION[path[2]] if path[0] == 'site_to_site' else vpnrouter.SCHEMA[path[0]][path[1]]
|
||||
value = answer
|
||||
if isinstance(default, bool):
|
||||
value = value == 'true'
|
||||
elif isinstance(default, int):
|
||||
value = int(value) if value.isdigit() else value
|
||||
elif isinstance(default, list):
|
||||
value = [item.strip() for item in value.split(',') if item.strip()]
|
||||
target = cfg
|
||||
for key in path[:-1]:
|
||||
if not isinstance(target.setdefault(key, {}), dict):
|
||||
vpnrouter.fail(f'cannot parse {vpnrouter.CONFIG_FILE}: {".".join(path[:-1])} is not an object')
|
||||
target = target[key]
|
||||
if target.get(path[-1]) != value:
|
||||
target[path[-1]] = value
|
||||
answered = True
|
||||
|
||||
# Without a change the candidate is the current file byte for byte, as ucf compares checksums.
|
||||
with open(output, 'w', encoding='utf-8') as handle:
|
||||
|
||||
@@ -8,9 +8,10 @@ Every SAMPLE_SECONDS, on the minute, a sample is taken with psutil: the CPU util
|
||||
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
|
||||
OpenVPN clients, WireGuard peers with a recent handshake, and the site-to-site tunnel, 1 while it
|
||||
is up. The interfaces and the enabled systems are those vpn-router-setup resolved, read from
|
||||
/run/vpn-router/web.json.
|
||||
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
|
||||
the connections are those vpn-router-setup resolved, read from /run/vpn-router/web.json.
|
||||
|
||||
The sample is written to the detail tables of vpnrouter_metrics. When the clock has passed into
|
||||
a new hour, day, month or year, the complete periods are summarized, and once a day, after the
|
||||
@@ -105,9 +106,6 @@ def clients(state):
|
||||
counts['openvpn'] = vpnrouter_status.openvpn_clients()
|
||||
if state.get('wireguard'):
|
||||
counts['wireguard'] = vpnrouter_status.wireguard_connected()
|
||||
if state.get('site_to_site'):
|
||||
count = vpnrouter_status.established('remote-site')
|
||||
counts['site_to_site'] = None if count is None else int(count > 0)
|
||||
return counts
|
||||
|
||||
|
||||
@@ -121,6 +119,8 @@ def sample(previous):
|
||||
memory = psutil.virtual_memory()
|
||||
disk = psutil.disk_usage(MOUNTPOINT)
|
||||
load1, load5, load15 = os.getloadavg()
|
||||
connections = state.get('site_to_site')
|
||||
tunnels = vpnrouter_status.site_to_site_tunnels(connections) if isinstance(connections, list) and connections else {}
|
||||
now = time.time()
|
||||
current = {
|
||||
'time': now,
|
||||
@@ -138,13 +138,26 @@ def sample(previous):
|
||||
'counters': {name: (counters[name].bytes_recv, counters[name].bytes_sent) for name in names if name in counters},
|
||||
'clients': clients(state),
|
||||
'rates': {},
|
||||
'tunnels': tunnels or {},
|
||||
'site_to_site': {},
|
||||
}
|
||||
if previous is not None:
|
||||
elapsed = current['monotonic'] - previous['monotonic']
|
||||
elapsed = current['monotonic'] - previous['monotonic'] if previous is not None else 0
|
||||
if elapsed > 0:
|
||||
for name, (received, sent) in current['counters'].items():
|
||||
if name in previous['counters'] and elapsed > 0:
|
||||
if name in previous['counters']:
|
||||
before_received, before_sent = previous['counters'][name]
|
||||
current['rates'][name] = (max(received - before_received, 0) / elapsed, max(sent - before_sent, 0) / elapsed)
|
||||
# A connection's rates need its counters of the previous sample; a counter that went down was rekeyed and counts from zero.
|
||||
for name, tunnel in current['tunnels'].items():
|
||||
before = previous['tunnels'].get(name) if elapsed > 0 else None
|
||||
values = {'up': tunnel['up'], 'uptime': tunnel['uptime']}
|
||||
for value, counter in (('rx', 'bytes_in'), ('tx', 'bytes_out'), ('packets_rx', 'packets_in'), ('packets_tx', 'packets_out')):
|
||||
if before is None:
|
||||
values[value] = None
|
||||
else:
|
||||
count = tunnel[counter]
|
||||
values[value] = (count - before[counter] if count >= before[counter] else count) / elapsed
|
||||
current['site_to_site'][name] = values
|
||||
return current
|
||||
|
||||
|
||||
@@ -162,7 +175,7 @@ def store(cfg, current, done):
|
||||
"""
|
||||
now = periods(datetime.datetime.now(datetime.timezone.utc))
|
||||
with vpnrouter_metrics.database(cfg) as connection:
|
||||
vpnrouter_metrics.write(connection, current['system'], current['rates'], current['clients'])
|
||||
vpnrouter_metrics.write(connection, current['system'], current['rates'], current['clients'], current['site_to_site'])
|
||||
for level, _, _ in vpnrouter_metrics.LEVELS:
|
||||
if done.get(level) != now[level]:
|
||||
vpnrouter_metrics.summarize(connection, level)
|
||||
@@ -200,6 +213,17 @@ def otlp_request(current, started):
|
||||
gauge('system.cpu.load_average.5m', '{thread}', [(system['load5'], {})]),
|
||||
gauge('system.cpu.load_average.15m', '{thread}', [(system['load15'], {})]),
|
||||
gauge('vpn_router.clients', '{client}', [(count, {'kind': kind}) for kind, count in current['clients'].items()]),
|
||||
gauge('vpn_router.site_to_site.up', '1', [(values['up'], {'connection': name})
|
||||
for name, values in current['site_to_site'].items()]),
|
||||
gauge('vpn_router.site_to_site.uptime', 's', [(values['uptime'], {'connection': name})
|
||||
for name, values in current['site_to_site'].items()]),
|
||||
gauge('vpn_router.site_to_site.io', 'By/s', [(values[value], {'connection': name, 'direction': direction})
|
||||
for name, values in current['site_to_site'].items()
|
||||
for value, direction in (('rx', 'receive'), ('tx', 'transmit'))]),
|
||||
gauge('vpn_router.site_to_site.packets', '{packet}/s',
|
||||
[(values[value], {'connection': name, 'direction': direction})
|
||||
for name, values in current['site_to_site'].items()
|
||||
for value, direction in (('packets_rx', 'receive'), ('packets_tx', 'transmit'))]),
|
||||
{'name': 'system.network.io', 'unit': 'By', 'sum': {
|
||||
'aggregationTemporality': 2, 'isMonotonic': True, 'dataPoints': [
|
||||
{'startTimeUnixNano': start, 'timeUnixNano': stamp, 'asInt': str(value),
|
||||
@@ -295,12 +319,30 @@ class Sample:
|
||||
network.add_metric([name, 'receive'], received)
|
||||
network.add_metric([name, 'transmit'], sent)
|
||||
yield network
|
||||
vpn = GaugeMetricFamily('vpn_router_clients', 'Connected VPN clients; site_to_site is 1 while the tunnel is up',
|
||||
labels=['kind'])
|
||||
vpn = GaugeMetricFamily('vpn_router_clients', 'Connected VPN clients', labels=['kind'])
|
||||
for kind, count in self.current['clients'].items():
|
||||
if count is not None:
|
||||
vpn.add_metric([kind], count)
|
||||
yield vpn
|
||||
tunnels = self.current['site_to_site']
|
||||
up = GaugeMetricFamily('vpn_router_site_to_site_up', 'Whether the site-to-site connection is established, 1 or 0',
|
||||
labels=['connection'])
|
||||
uptime = GaugeMetricFamily('vpn_router_site_to_site_uptime_seconds', 'Seconds the IKE SA of the site-to-site connection has been up',
|
||||
labels=['connection'])
|
||||
io = GaugeMetricFamily('vpn_router_site_to_site_io_bytes_per_second',
|
||||
'Bytes per second received and sent through the site-to-site connection', labels=['connection', 'direction'])
|
||||
packets = GaugeMetricFamily('vpn_router_site_to_site_packets_per_second',
|
||||
'Packets per second received and sent through the site-to-site connection',
|
||||
labels=['connection', 'direction'])
|
||||
for name, values in tunnels.items():
|
||||
up.add_metric([name], values['up'])
|
||||
if values['uptime'] is not None:
|
||||
uptime.add_metric([name], values['uptime'])
|
||||
for family, received, sent in ((io, 'rx', 'tx'), (packets, 'packets_rx', 'packets_tx')):
|
||||
for value, direction in ((received, 'receive'), (sent, 'transmit')):
|
||||
if values[value] is not None:
|
||||
family.add_metric([name, direction], values[value])
|
||||
yield from (up, uptime, io, packets)
|
||||
|
||||
|
||||
def authorized(header, account):
|
||||
|
||||
@@ -294,7 +294,6 @@ def nat_and_mangle_block(ctx):
|
||||
|
||||
if vpnrouter.routing_ready(ctx):
|
||||
subnets = ctx['local_cidr_list']
|
||||
remote = ctx['remote_cidr_list']
|
||||
lines += [
|
||||
'',
|
||||
'# ROUTER NAT RULES START',
|
||||
@@ -302,11 +301,14 @@ def nat_and_mangle_block(ctx):
|
||||
':POSTROUTING ACCEPT [0:0]',
|
||||
'-F POSTROUTING',
|
||||
]
|
||||
# Every RETURN precedes every MASQUERADE, so tunnel-bound traffic
|
||||
# escapes NAT whichever local subnet it came from.
|
||||
for subnet in subnets:
|
||||
for cidr in remote:
|
||||
lines.append(f'-A POSTROUTING -s {subnet} -d {cidr} -j RETURN')
|
||||
# Every RETURN precedes every MASQUERADE, so traffic bound for a tunnel
|
||||
# escapes NAT; each ready connection pairs its local and remote subnets.
|
||||
for connection in ctx['connections']:
|
||||
if not connection['ready']:
|
||||
continue
|
||||
for subnet in connection['local_cidr_list']:
|
||||
for cidr in connection['remote_cidr_list']:
|
||||
lines.append(f'-A POSTROUTING -s {subnet} -d {cidr} -j RETURN')
|
||||
for subnet in subnets:
|
||||
lines.append(
|
||||
f'-A POSTROUTING -s {subnet} -o {ctx["wan_iface"]} -j MASQUERADE')
|
||||
@@ -591,31 +593,37 @@ def refresh_web(cfg, ctx):
|
||||
the configuration without its secret options and the facts resolved from
|
||||
the system, which it cannot work out with the standard library alone. The
|
||||
file is readable by root only. The service follows a changed address or
|
||||
port itself.
|
||||
port itself. Secret options are left out at every level, so also from each
|
||||
site-to-site connection.
|
||||
"""
|
||||
public = {
|
||||
section: {option: value for option, value in options.items() if not SECRET_OPTION.search(option)}
|
||||
for section, options in cfg.items()
|
||||
}
|
||||
def public(options):
|
||||
return {option: public(value) if isinstance(value, dict) else value
|
||||
for option, value in options.items()
|
||||
if isinstance(value, dict) or not SECRET_OPTION.search(option)}
|
||||
|
||||
WEB_STATE.parent.mkdir(parents=True, exist_ok=True)
|
||||
WEB_STATE.touch(mode=0o600)
|
||||
WEB_STATE.chmod(0o600)
|
||||
WEB_STATE.write_text(json.dumps({
|
||||
'site_to_site': bool(vpnrouter.s2s_ready(ctx)),
|
||||
'site_to_site': [connection['name'] for connection in ctx['connections'] if connection['ready']],
|
||||
'p2s': bool(vpnrouter.p2s_ready(ctx)),
|
||||
'wireguard': bool(vpnrouter.wg_ready(ctx)),
|
||||
'openvpn': bool(vpnrouter.openvpn_ready(ctx)),
|
||||
'configuration': public,
|
||||
'configuration': public(cfg),
|
||||
'network': {
|
||||
'external_interface': ctx['wan_iface'],
|
||||
'internal_interface': ctx['int_iface'],
|
||||
'external_addresses': ctx['local_addr_list'],
|
||||
'internal_address': ctx['int_addr'],
|
||||
'internal_gateway': ctx['int_gateway_ip'],
|
||||
'local_identity': ctx['local_id'],
|
||||
'remote_identity': ctx['remote_id'],
|
||||
'local_cidrs': ctx['local_cidr_list'],
|
||||
'remote_cidrs': ctx['remote_cidr_list'],
|
||||
'site_to_site': [{
|
||||
'name': connection['name'],
|
||||
'local_identity': connection['local_id'],
|
||||
'remote_identity': connection['remote_id'],
|
||||
'local_cidrs': connection['local_cidr_list'],
|
||||
'remote_cidrs': connection['remote_cidr_list'],
|
||||
} for connection in ctx['connections']],
|
||||
},
|
||||
}) + '\n', encoding='utf-8')
|
||||
|
||||
@@ -666,10 +674,10 @@ def main():
|
||||
if vpnrouter.routing_ready(ctx):
|
||||
apply_routes(ctx)
|
||||
|
||||
if ctx['local_id_mode'] == 'public_ip' and ctx['local_fqdn']:
|
||||
if ctx['local_fqdn'] and any(connection['local_id_mode'] == 'public_ip' for connection in ctx['connections']):
|
||||
progress('running', 'public address')
|
||||
address = resolve_public_ip(ctx['local_fqdn'])
|
||||
ctx, more = vpnrouter.configure(cfg, local_id_override=address)
|
||||
ctx, more = vpnrouter.configure(cfg, public_ip=address)
|
||||
changed |= more
|
||||
|
||||
progress('running', 'WireGuard keys')
|
||||
|
||||
@@ -4,6 +4,8 @@ Everything derivable from the configuration file alone, with no running
|
||||
network required. Used by both 'configure' and 'setup'.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import fcntl
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
@@ -12,6 +14,7 @@ import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
|
||||
import jinja2
|
||||
@@ -25,12 +28,13 @@ import vpnrouter_platforms
|
||||
from vpnrouter_net import DEFAULT_LISTEN, LISTEN_CHOICES, LOOPBACK, ConfigError, detect_interfaces, interface_addresses, ip_output
|
||||
|
||||
CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.json')
|
||||
CONFIG_LOCK = pathlib.Path('/run/vpn-router/config.lock')
|
||||
TEMPLATE_DIR = pathlib.Path('/usr/share/vpn-router/templates')
|
||||
PKI_DIR = pathlib.Path('/etc/vpn-router/pki')
|
||||
SIMPLE_CA = LIB_DIR / 'simple-ca'
|
||||
|
||||
SWANCTL_CONF_D = pathlib.Path('/etc/swanctl/conf.d')
|
||||
REMOTE_SITE = SWANCTL_CONF_D / 'remote-site.conf'
|
||||
SITE_TO_SITE = SWANCTL_CONF_D / 'site-to-site.conf'
|
||||
ROAD_WARRIOR = SWANCTL_CONF_D / 'road-warrior.conf'
|
||||
P2S_FORWARDER = pathlib.Path('/etc/systemd/resolved.conf.d/p2s-forwarder.conf')
|
||||
WG_CONF = pathlib.Path('/etc/wireguard/wg0.conf')
|
||||
@@ -48,9 +52,9 @@ TRUST_ANCHOR = pathlib.Path('/usr/local/share/ca-certificates/vpn-router-ca.cr
|
||||
SCHEMA = {
|
||||
'general': {'platform': 'none', 'mode': 'manual'},
|
||||
'interfaces': {'external': '', 'internal': ''},
|
||||
'wan': {'local_fqdn': '', 'local_id_mode': 'fqdn'},
|
||||
'wan': {'local_fqdn': ''},
|
||||
'local': {'cidrs': [], 'int_addr': '', 'int_gateway_ip': ''},
|
||||
'remote': {'addrs': [], 'id': '', 'cidrs': [], 'psk': '', 'psk_file': ''},
|
||||
'site_to_site': {},
|
||||
'p2s': {'enabled': False, 'address_pool': '', 'ca_name': 'VPN Router CA'},
|
||||
'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820},
|
||||
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
|
||||
@@ -64,6 +68,15 @@ SCHEMA = {
|
||||
'keep_monthly': '1 year', 'keep_yearly': '10 years'},
|
||||
}
|
||||
|
||||
#: site_to_site maps a connection name to its settings: option -> default, the type of the default being the required type.
|
||||
CONNECTION = {'addrs': [], 'id': '', 'cidrs': [], 'local_cidrs': [], 'local_id_mode': 'fqdn', 'psk': '', 'psk_file': ''}
|
||||
|
||||
#: The name of a site_to_site connection, which also names its swanctl connection, child and secret.
|
||||
CONNECTION_NAME = re.compile(r'^[a-z][a-z0-9-]{0,30}$')
|
||||
|
||||
#: The site_to_site connection the install answers fill.
|
||||
FIRST_CONNECTION = 'remote-site'
|
||||
|
||||
#: The retention settings of the metrics, from the details to the yearly summaries.
|
||||
METRICS_KEEP = ('keep_details', 'keep_hourly', 'keep_daily', 'keep_monthly', 'keep_yearly')
|
||||
|
||||
@@ -93,7 +106,7 @@ DOCKER_PORT = re.compile(r'^\d{1,5}(:\d{1,5})?$')
|
||||
DOCKER_PORT_LIMIT = 15
|
||||
|
||||
TYPE_NAMES = {str: 'a string', list: 'a list of strings', bool: 'a boolean',
|
||||
int: 'an integer'}
|
||||
int: 'an integer', dict: 'an object'}
|
||||
|
||||
ID_MODES = ('fqdn', 'public_ip', 'internal_ip')
|
||||
|
||||
@@ -118,29 +131,76 @@ def fail(message):
|
||||
sys.exit(f'vpn-router: error: {message}')
|
||||
|
||||
|
||||
def load_config(path=None):
|
||||
"""Read the configuration, filling in defaults for anything absent.
|
||||
def read_settings(path=None):
|
||||
"""The settings in the configuration file, without the defaults; {} when the file does not exist.
|
||||
|
||||
The result maps section -> option -> value. Sections and options not in
|
||||
SCHEMA are kept so that validate() can report them.
|
||||
Raises ConfigError when the file is not JSON or not an object of sections.
|
||||
"""
|
||||
path = pathlib.Path(path) if path else CONFIG_FILE
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding='utf-8'))
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except ValueError as exc:
|
||||
raise ConfigError(f'cannot parse {path}: {exc}')
|
||||
if not isinstance(data, dict):
|
||||
raise ConfigError(f'cannot parse {path}: the top level is not an object')
|
||||
for section, options in data.items():
|
||||
if not isinstance(options, dict):
|
||||
raise ConfigError(f'cannot parse {path}: {section} is not an object')
|
||||
return data
|
||||
|
||||
|
||||
def with_defaults(data):
|
||||
"""The configuration of the settings of read_settings, with the defaults for anything absent.
|
||||
|
||||
The result maps section -> option -> value, and site_to_site maps each connection name to
|
||||
its settings filled from CONNECTION. Sections, options and connections that are not
|
||||
objects are kept so that validate() can report them.
|
||||
"""
|
||||
cfg = {section: dict(options) for section, options in SCHEMA.items()}
|
||||
for section, options in data.items():
|
||||
cfg.setdefault(section, {}).update(options)
|
||||
cfg['site_to_site'] = {name: {**CONNECTION, **options} if isinstance(options, dict) else options
|
||||
for name, options in cfg['site_to_site'].items()}
|
||||
return cfg
|
||||
|
||||
|
||||
def load_config(path=None):
|
||||
"""Read the configuration, filling in defaults for anything absent (see with_defaults).
|
||||
|
||||
The path is resolved when called, not when this function is defined, so
|
||||
CONFIG_FILE stays overridable.
|
||||
"""
|
||||
path = pathlib.Path(path) if path else CONFIG_FILE
|
||||
cfg = {section: dict(options) for section, options in SCHEMA.items()}
|
||||
if path.exists():
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding='utf-8'))
|
||||
except ValueError as exc:
|
||||
fail(f'cannot parse {path}: {exc}')
|
||||
if not isinstance(data, dict):
|
||||
fail(f'cannot parse {path}: the top level is not an object')
|
||||
for section, options in data.items():
|
||||
if not isinstance(options, dict):
|
||||
fail(f'cannot parse {path}: {section} is not an object')
|
||||
cfg.setdefault(section, {}).update(options)
|
||||
return cfg
|
||||
try:
|
||||
return with_defaults(read_settings(path))
|
||||
except ConfigError as exc:
|
||||
fail(str(exc))
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def config_lock():
|
||||
"""Hold the lock of the configuration file from reading it to writing it with save_config."""
|
||||
CONFIG_LOCK.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(CONFIG_LOCK, 'a', encoding='utf-8') as handle:
|
||||
fcntl.flock(handle, fcntl.LOCK_EX)
|
||||
yield
|
||||
|
||||
|
||||
def save_config(data):
|
||||
"""Write the settings to the configuration file, indented, with mode 0600, replacing it in one step.
|
||||
|
||||
The caller holds config_lock.
|
||||
"""
|
||||
descriptor, name = tempfile.mkstemp(dir=CONFIG_FILE.parent, prefix='.vpn-router.json.')
|
||||
try:
|
||||
with os.fdopen(descriptor, 'w', encoding='utf-8') as handle:
|
||||
handle.write(json.dumps(data, indent=2) + '\n')
|
||||
os.chmod(name, 0o600)
|
||||
os.replace(name, CONFIG_FILE)
|
||||
except OSError:
|
||||
os.unlink(name)
|
||||
raise
|
||||
|
||||
|
||||
def _split_list(value):
|
||||
@@ -152,23 +212,37 @@ def _get(cfg, section, option):
|
||||
return value.strip() if isinstance(value, str) else value
|
||||
|
||||
|
||||
def _clean(values):
|
||||
return [item.strip() for item in values if item.strip()]
|
||||
|
||||
|
||||
def _get_list(cfg, section, option):
|
||||
return [item.strip() for item in cfg[section][option] if item.strip()]
|
||||
return _clean(cfg[section][option])
|
||||
|
||||
|
||||
def _check_options(options, defaults, prefix):
|
||||
for option, value in options.items():
|
||||
if option not in defaults:
|
||||
raise ConfigError(f'{prefix}.{option}: unknown setting')
|
||||
expected = type(defaults[option])
|
||||
if type(value) is not expected or (
|
||||
expected is list and
|
||||
not all(isinstance(item, str) for item in value)):
|
||||
raise ConfigError(
|
||||
f'{prefix}.{option} must be {TYPE_NAMES[expected]}')
|
||||
|
||||
|
||||
def _check_types(cfg):
|
||||
for section, options in cfg.items():
|
||||
if section not in SCHEMA:
|
||||
raise ConfigError(f'{section}: unknown section')
|
||||
for option, value in options.items():
|
||||
if option not in SCHEMA[section]:
|
||||
raise ConfigError(f'{section}.{option}: unknown setting')
|
||||
expected = type(SCHEMA[section][option])
|
||||
if type(value) is not expected or (
|
||||
expected is list and
|
||||
not all(isinstance(item, str) for item in value)):
|
||||
raise ConfigError(
|
||||
f'{section}.{option} must be {TYPE_NAMES[expected]}')
|
||||
if section != 'site_to_site':
|
||||
_check_options(options, SCHEMA[section], section)
|
||||
continue
|
||||
for name, connection in options.items():
|
||||
if not isinstance(connection, dict):
|
||||
raise ConfigError(f'site_to_site.{name} must be {TYPE_NAMES[dict]}')
|
||||
_check_options(connection, CONNECTION, f'site_to_site.{name}')
|
||||
|
||||
|
||||
def _check_networks(values, name):
|
||||
@@ -220,19 +294,28 @@ def validate(cfg):
|
||||
raise ConfigError(
|
||||
f'{section}.{option} is required in mode {mode or "manual"}')
|
||||
|
||||
for section, option in (('wan', 'local_fqdn'), ('remote', 'id')):
|
||||
if str(_get(cfg, section, option)).startswith('@'):
|
||||
identities = [('wan.local_fqdn', _get(cfg, 'wan', 'local_fqdn'))]
|
||||
for name, connection in cfg['site_to_site'].items():
|
||||
prefix = f'site_to_site.{name}'
|
||||
if not CONNECTION_NAME.match(name):
|
||||
raise ConfigError(
|
||||
f'{section}.{option}: remove the leading @; strongSwan reads an '
|
||||
f'{prefix}: a connection name starts with a lowercase letter and has at most 31 '
|
||||
'lowercase letters, digits and hyphens')
|
||||
identities.append((f'{prefix}.id', connection['id'].strip()))
|
||||
id_mode = connection['local_id_mode'].strip()
|
||||
if id_mode and id_mode not in ID_MODES:
|
||||
raise ConfigError(
|
||||
f'{prefix}.local_id_mode: {id_mode!r} is not one of {", ".join(ID_MODES)}')
|
||||
_check_networks(_clean(connection['cidrs']), f'{prefix}.cidrs')
|
||||
_check_networks(_clean(connection['local_cidrs']), f'{prefix}.local_cidrs')
|
||||
|
||||
for option, value in identities:
|
||||
if value.startswith('@'):
|
||||
raise ConfigError(
|
||||
f'{option}: remove the leading @; strongSwan reads an '
|
||||
'address as an IP identity and a name as an FQDN')
|
||||
|
||||
id_mode = _get(cfg, 'wan', 'local_id_mode')
|
||||
if id_mode and id_mode not in ID_MODES:
|
||||
raise ConfigError(
|
||||
f'wan.local_id_mode: {id_mode!r} is not one of {", ".join(ID_MODES)}')
|
||||
|
||||
_check_networks(_get_list(cfg, 'local', 'cidrs'), 'local.cidrs')
|
||||
_check_networks(_get_list(cfg, 'remote', 'cidrs'), 'remote.cidrs')
|
||||
pool = _get(cfg, 'p2s', 'address_pool')
|
||||
_check_networks([pool] if pool else [], 'p2s.address_pool')
|
||||
openvpn_pool = _get(cfg, 'openvpn', 'address_pool')
|
||||
@@ -355,15 +438,15 @@ def validate(cfg):
|
||||
'a range counting as two')
|
||||
|
||||
|
||||
def resolve_psk(cfg):
|
||||
"""Return the pre-shared key, or '' when none is configured."""
|
||||
psk_file = _get(cfg, 'remote', 'psk_file')
|
||||
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()
|
||||
if psk_file:
|
||||
path = pathlib.Path(psk_file)
|
||||
if not path.is_file():
|
||||
raise ConfigError(f'remote.psk_file: {psk_file} does not exist')
|
||||
raise ConfigError(f'site_to_site.{name}.psk_file: {psk_file} does not exist')
|
||||
return path.read_text(encoding='utf-8').strip()
|
||||
return cfg['remote']['psk']
|
||||
return connection['psk']
|
||||
|
||||
|
||||
def platform_module(cfg):
|
||||
@@ -447,29 +530,43 @@ def resolve_interfaces(cfg):
|
||||
return ext_iface, int_iface, local_addrs, int_addr, gateway
|
||||
|
||||
|
||||
def build_context(cfg, local_id_override=None):
|
||||
"""Assemble the template context from the configuration."""
|
||||
def build_context(cfg, public_ip=None):
|
||||
"""Assemble the template context from the configuration.
|
||||
|
||||
public_ip is the address wan.local_fqdn resolves to, the local identity of the
|
||||
site_to_site connections in mode public_ip; setup resolves it once the network is up and
|
||||
renders again, and without it such a connection is not ready.
|
||||
"""
|
||||
extras = vpnrouter_platforms.context(platform_module(cfg))
|
||||
|
||||
ext_iface, int_iface, local_addrs, int_addr, gateway = resolve_interfaces(cfg)
|
||||
|
||||
local_fqdn = _get(cfg, 'wan', 'local_fqdn')
|
||||
id_mode = _get(cfg, 'wan', 'local_id_mode') or 'fqdn'
|
||||
local_cidrs = _get_list(cfg, 'local', 'cidrs')
|
||||
remote_cidrs = _get_list(cfg, 'remote', 'cidrs')
|
||||
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}
|
||||
|
||||
if local_id_override:
|
||||
local_id = local_id_override
|
||||
elif id_mode == 'fqdn':
|
||||
local_id = local_fqdn
|
||||
elif id_mode == 'internal_ip':
|
||||
local_id = int_addr
|
||||
else:
|
||||
# public_ip is resolved once the network is up, and the caller then
|
||||
# re-renders with local_id_override set.
|
||||
local_id = ''
|
||||
connections = []
|
||||
for name, connection in cfg['site_to_site'].items():
|
||||
id_mode = connection['local_id_mode'].strip() or 'fqdn'
|
||||
remote_cidrs = _clean(connection['cidrs'])
|
||||
connection_cidrs = _clean(connection['local_cidrs'])
|
||||
entry = {
|
||||
'name': name,
|
||||
'remote_addrs': ', '.join(_clean(connection['addrs'])),
|
||||
'remote_id': connection['id'].strip(),
|
||||
'remote_cidrs': ', '.join(remote_cidrs),
|
||||
'remote_cidr_list': remote_cidrs,
|
||||
'local_ts': _join(connection_cidrs, _split_list(extras['EXTRA_LOCAL_TS'])),
|
||||
'local_cidr_list': connection_cidrs,
|
||||
'local_id_mode': id_mode,
|
||||
'local_id': identities.get(id_mode, ''),
|
||||
'psk': resolve_psk(connection, name),
|
||||
}
|
||||
entry['ready'] = bool(local_addrs and connection_cidrs and all(
|
||||
entry[key] for key in ('remote_addrs', 'remote_id', 'remote_cidrs', 'local_id', 'psk')))
|
||||
connections.append(entry)
|
||||
|
||||
return {
|
||||
'platform': _get(cfg, 'general', 'platform') or PLATFORM_NONE,
|
||||
@@ -479,18 +576,12 @@ def build_context(cfg, local_id_override=None):
|
||||
'local_addrs': ', '.join(local_addrs),
|
||||
'local_addr_list': local_addrs,
|
||||
'local_fqdn': local_fqdn,
|
||||
'local_id_mode': id_mode,
|
||||
'local_id': local_id,
|
||||
'local_cidrs': ', '.join(local_cidrs),
|
||||
'local_cidr_list': local_cidrs,
|
||||
'local_ts': _join(local_cidrs, _split_list(extras['EXTRA_LOCAL_TS'])),
|
||||
'int_addr': int_addr,
|
||||
'int_gateway_ip': gateway,
|
||||
'remote_addrs': ', '.join(_get_list(cfg, 'remote', 'addrs')),
|
||||
'remote_id': _get(cfg, 'remote', 'id'),
|
||||
'remote_cidrs': ', '.join(remote_cidrs),
|
||||
'remote_cidr_list': remote_cidrs,
|
||||
'psk': resolve_psk(cfg),
|
||||
'connections': connections,
|
||||
'p2s_enabled': _get(cfg, 'p2s', 'enabled'),
|
||||
'p2s_address_pool': _get(cfg, 'p2s', 'address_pool'),
|
||||
'p2s_ca_name': _get(cfg, 'p2s', 'ca_name'),
|
||||
@@ -530,11 +621,8 @@ def build_context(cfg, local_id_override=None):
|
||||
|
||||
|
||||
def s2s_ready(ctx):
|
||||
return all([
|
||||
ctx['local_addrs'], ctx['local_fqdn'], ctx['local_cidrs'],
|
||||
ctx['remote_addrs'], ctx['remote_id'], ctx['remote_cidrs'],
|
||||
ctx['psk'], ctx['local_id'],
|
||||
])
|
||||
"""True when any site_to_site connection is ready."""
|
||||
return any(connection['ready'] for connection in ctx['connections'])
|
||||
|
||||
|
||||
def routing_ready(ctx):
|
||||
@@ -699,7 +787,7 @@ def distribute_pki(ctx):
|
||||
return changed
|
||||
|
||||
|
||||
def configure(cfg=None, local_id_override=None):
|
||||
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:
|
||||
@@ -715,7 +803,7 @@ def configure(cfg=None, local_id_override=None):
|
||||
|
||||
try:
|
||||
validate(cfg)
|
||||
ctx = build_context(cfg, local_id_override=local_id_override)
|
||||
ctx = build_context(cfg, public_ip=public_ip)
|
||||
except ConfigError as exc:
|
||||
fail(str(exc))
|
||||
|
||||
@@ -728,9 +816,9 @@ def configure(cfg=None, local_id_override=None):
|
||||
env = _jinja_env()
|
||||
|
||||
if s2s_ready(ctx):
|
||||
if render(env, ctx, 'remote-site.conf.j2', REMOTE_SITE, 0o600):
|
||||
if render(env, ctx, 'site-to-site.conf.j2', SITE_TO_SITE, 0o600):
|
||||
changed.add('swanctl')
|
||||
elif remove(REMOTE_SITE):
|
||||
elif remove(SITE_TO_SITE):
|
||||
changed.add('swanctl')
|
||||
|
||||
if p2s_ready(ctx):
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""The metrics of the router in PostgreSQL, for the metrics service and the web service.
|
||||
|
||||
The metrics service writes one detail row per minute to the three detail tables, of TABLES:
|
||||
The metrics service writes one detail row per minute to the four detail tables, of TABLES:
|
||||
metrics_system (CPU, memory, disk and load averages), metrics_network (bytes per second
|
||||
received and sent per interface) and metrics_vpn (clients per kind). Each detail table has four
|
||||
received and sent per interface), metrics_vpn (clients per kind) and metrics_site_to_site (per
|
||||
site-to-site connection: up, 1 or 0, bytes and packets per second received and sent, and the
|
||||
seconds its IKE SA has been up). Each detail table has four
|
||||
summary tables, of LEVELS: <table>_hourly from the details, <table>_daily from the hourly,
|
||||
<table>_monthly from the daily and <table>_yearly from the monthly rows. A summary row has the
|
||||
start of its period, in UTC, and the average and the maximum of each value over the period: of
|
||||
@@ -28,6 +30,7 @@ TABLES = {
|
||||
'metrics_system': ((), ('cpu', 'memory_used', 'memory_total', 'disk_free', 'disk_total', 'load1', 'load5', 'load15')),
|
||||
'metrics_network': (('interface',), ('rx', 'tx')),
|
||||
'metrics_vpn': (('kind',), ('clients',)),
|
||||
'metrics_site_to_site': (('connection',), ('up', 'rx', 'tx', 'packets_rx', 'packets_tx', 'uptime')),
|
||||
}
|
||||
|
||||
#: The summary levels, from the finest: the suffix of the tables, the period of date_trunc and the retention setting.
|
||||
@@ -38,8 +41,8 @@ LEVELS = (
|
||||
('yearly', 'year', 'keep_yearly'),
|
||||
)
|
||||
|
||||
#: The kinds of VPN clients of metrics_vpn; site_to_site is 1 while the tunnel is up.
|
||||
VPN_KINDS = ('p2s', 'openvpn', 'wireguard', 'site_to_site')
|
||||
#: The kinds of VPN clients of metrics_vpn.
|
||||
VPN_KINDS = ('p2s', 'openvpn', 'wireguard')
|
||||
|
||||
#: The views of history(): the summary level read (None: the details), the bucket and the span.
|
||||
RANGES = {
|
||||
@@ -82,11 +85,12 @@ def database(cfg):
|
||||
raise DatabaseError(f'the metrics database failed: {exc}') from exc
|
||||
|
||||
|
||||
def write(connection, system, network, vpn):
|
||||
def write(connection, system, network, vpn, site_to_site):
|
||||
"""Write a sample as the detail rows of the current minute.
|
||||
|
||||
system maps the values of metrics_system to numbers; network maps an interface to its rx and
|
||||
tx; vpn maps a kind of VPN_KINDS to its clients. A value that is None is written as NULL.
|
||||
tx; vpn maps a kind of VPN_KINDS to its clients; site_to_site maps a connection to the values
|
||||
of metrics_site_to_site. A value that is None is written as NULL.
|
||||
"""
|
||||
with connection.transaction():
|
||||
minute = connection.execute("SELECT date_trunc('minute', now(), 'UTC')").fetchone()[0]
|
||||
@@ -100,6 +104,11 @@ def write(connection, system, network, vpn):
|
||||
for kind, clients in vpn.items():
|
||||
connection.execute('INSERT INTO metrics_vpn (time, kind, clients) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING',
|
||||
(minute, kind, clients))
|
||||
names = TABLES['metrics_site_to_site'][1]
|
||||
for name, values in site_to_site.items():
|
||||
connection.execute(f"INSERT INTO metrics_site_to_site (time, connection, {', '.join(names)}) "
|
||||
f"VALUES (%s, %s{', %s' * len(names)}) ON CONFLICT DO NOTHING",
|
||||
(minute, name, *(values.get(value) for value in names)))
|
||||
|
||||
|
||||
def summarize(connection, level):
|
||||
@@ -136,8 +145,9 @@ def history(connection, view):
|
||||
"""The points of a view of RANGES, oldest first, each with the start of its bucket in Unix seconds.
|
||||
|
||||
A point has the average and the maximum of each value of metrics_system, network, which maps
|
||||
each interface to its rx and tx, and clients, which maps each kind of VPN client to its
|
||||
clients; a value without data is None.
|
||||
each interface to its rx and tx, clients, which maps each kind of VPN client to its clients,
|
||||
and site_to_site, which maps each site-to-site connection to the values of
|
||||
metrics_site_to_site; a value without data is None.
|
||||
"""
|
||||
level, bucket, span = RANGES[view]
|
||||
|
||||
@@ -161,7 +171,7 @@ def history(connection, view):
|
||||
points = {}
|
||||
|
||||
def point(start):
|
||||
return points.setdefault(start, {'time': start, 'network': {}, 'clients': {}})
|
||||
return points.setdefault(start, {'time': start, 'network': {}, 'clients': {}, 'site_to_site': {}})
|
||||
|
||||
for row in rows('metrics_system'):
|
||||
point(row[0]).update(pairs(row[1:], TABLES['metrics_system'][1]))
|
||||
@@ -169,5 +179,7 @@ def history(connection, view):
|
||||
point(row[0])['network'][row[1]] = pairs(row[2:], TABLES['metrics_network'][1])
|
||||
for row in rows('metrics_vpn'):
|
||||
point(row[0])['clients'][row[1]] = pairs(row[2:], TABLES['metrics_vpn'][1])['clients']
|
||||
for row in rows('metrics_site_to_site'):
|
||||
point(row[0])['site_to_site'][row[1]] = pairs(row[2:], TABLES['metrics_site_to_site'][1])
|
||||
empty = {value: {'avg': None, 'max': None} for value in TABLES['metrics_system'][1]}
|
||||
return {'range': view, 'bucket': bucket, 'points': [{**empty, **points[start]} for start in sorted(points)]}
|
||||
@@ -1,7 +1,8 @@
|
||||
"""What the router's VPN systems report, for the web service and the metrics service.
|
||||
|
||||
Each reader asks the running system once and answers None when it cannot be asked: swanctl for
|
||||
the IKE SAs, wg for the WireGuard peers and the OpenVPN server's status file for its clients.
|
||||
the IKE SAs and the state and traffic of the site-to-site connections, wg for the WireGuard
|
||||
peers and the OpenVPN server's status file for its clients.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
@@ -65,12 +66,45 @@ def swanctl(command):
|
||||
return [dict(body, name=name) for event in events for name, body in event.items()]
|
||||
|
||||
|
||||
def established(connection):
|
||||
"""The number of established IKE SAs of the connection, or None when swanctl cannot be asked."""
|
||||
def established_each(connections):
|
||||
"""The number of established IKE SAs of each of the connections by name, None for each when swanctl cannot be asked."""
|
||||
sas = swanctl('--list-sas')
|
||||
return {name: None if sas is None else sum(1 for sa in sas if sa['name'] == name and sa.get('state') == 'ESTABLISHED')
|
||||
for name in connections}
|
||||
|
||||
|
||||
def _count(text):
|
||||
"""A counter swanctl prints as a string, as an integer; 0 when it is not a whole number."""
|
||||
return int(text) if isinstance(text, str) and text.isdigit() else 0
|
||||
|
||||
|
||||
def site_to_site_tunnels(connections):
|
||||
"""The state of each of the connections by name, or None when swanctl cannot be asked.
|
||||
|
||||
Each has up, 1 while it has an established IKE SA and 0 otherwise; uptime, the seconds since
|
||||
its oldest established IKE SA was established, None while it is down; and bytes_in,
|
||||
bytes_out, packets_in and packets_out, the sums over the child SAs of its established IKE
|
||||
SAs. The sums start again from zero when a child SA is rekeyed.
|
||||
"""
|
||||
sas = swanctl('--list-sas')
|
||||
if sas is None:
|
||||
return None
|
||||
return sum(1 for sa in sas if sa['name'] == connection and sa.get('state') == 'ESTABLISHED')
|
||||
tunnels = {}
|
||||
for name in connections:
|
||||
up = [sa for sa in sas if sa['name'] == name and sa.get('state') == 'ESTABLISHED']
|
||||
children = [child for sa in up for child in (sa.get('child-sas') or {}).values() if isinstance(child, dict)]
|
||||
tunnels[name] = {
|
||||
'up': 1 if up else 0,
|
||||
'uptime': max((_count(sa.get('established')) for sa in up), default=None),
|
||||
**{key.replace('-', '_'): sum(_count(child.get(key)) for child in children)
|
||||
for key in ('bytes-in', 'bytes-out', 'packets-in', 'packets-out')},
|
||||
}
|
||||
return tunnels
|
||||
|
||||
|
||||
def established(connection):
|
||||
"""The number of established IKE SAs of the connection, or None when swanctl cannot be asked."""
|
||||
return established_each([connection])[connection]
|
||||
|
||||
|
||||
def wireguard_peers():
|
||||
|
||||
@@ -7,9 +7,11 @@ common name, which OpenVPN and IKEv2 accept. A Manager-only account is an accoun
|
||||
simpleSecurityObject and vpnRouterAccount (the package's schema) with a name, a password and
|
||||
optionally an e-mail address, no names, no certificate and so no VPN access; it is added when
|
||||
neither the first nor the last name is given, and can sign in to the web service. A service
|
||||
account is a Manager-only account with the scope SERVICE_SCOPE in vpnRouterScope, which
|
||||
vpn-router-setup adds and keeps for the metrics service, as metrics.service_account names it; it
|
||||
cannot sign in to the web service. Users exist whether or not a P2S system is enabled.
|
||||
account is a Manager-only account with the scope SERVICE_SCOPE in vpnRouterScope, which another
|
||||
system signs in with to use a router service, and which cannot sign in to the web service. The
|
||||
internal service accounts are those the router creates and manages for its own services
|
||||
(internal_accounts); vpn-router-setup keeps the metrics service's.
|
||||
Users exist whether or not a P2S system is enabled.
|
||||
|
||||
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
|
||||
@@ -192,14 +194,28 @@ def service(attributes):
|
||||
return SERVICE_SCOPE.encode() in (value.lower() for value in attributes.get('vpnRouterScope', []))
|
||||
|
||||
|
||||
def internal_accounts():
|
||||
"""The names of the internal service accounts, which the router creates and manages for its own services: the one
|
||||
metrics.service_account names while metrics are enabled."""
|
||||
try:
|
||||
metrics = vpnrouter.load_config()['metrics']
|
||||
except SystemExit:
|
||||
return set()
|
||||
return {metrics['service_account']} if metrics['enabled'] else set()
|
||||
|
||||
|
||||
def summary(attributes):
|
||||
"""A user as the answers show it; a person attribute the entry does not have is None."""
|
||||
"""A user as the answers show it; a person attribute the entry does not have is None.
|
||||
|
||||
internal is whether it is an internal service account (internal_accounts).
|
||||
"""
|
||||
name = attributes['uid'][0].decode()
|
||||
lock = attributes.get('pwdAccountLockedTime', [None])[0]
|
||||
user = {
|
||||
'name': name,
|
||||
'manager_only': manager_only(attributes),
|
||||
'service': service(attributes),
|
||||
'internal': service(attributes) and name in internal_accounts(),
|
||||
'enabled': lock != LOCKED,
|
||||
'locked': lock not in (None, LOCKED) and locked_since(lock) > time.time() - vpnrouter_ldap.LOCKOUT_SECONDS,
|
||||
'certificate': certificate(name)[0].is_file(),
|
||||
@@ -225,8 +241,8 @@ def list_users(connection):
|
||||
|
||||
def add(connection, name, password, values, service=False):
|
||||
"""Add a user: a VPN user with its certificate when values gives all three person attributes, a Manager-only account
|
||||
with at most the e-mail address when it gives neither name, and, with service, which only vpn-router-setup uses, a
|
||||
service account, with at most the e-mail address too."""
|
||||
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 too."""
|
||||
check_name(name)
|
||||
details = person(values, required=False)
|
||||
vpn_user = 'givenName' in details or 'sn' in details
|
||||
|
||||
@@ -12,11 +12,18 @@ Pages:
|
||||
runs, 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())
|
||||
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 and its TOTP
|
||||
enrolment (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())
|
||||
/auth sign-in with a user's password and TOTP code, refresh and sign-out (see auth())
|
||||
/metrics/history
|
||||
the metrics the metrics service keeps, for a view: hour, day, month or year (see
|
||||
@@ -25,7 +32,7 @@ Pages:
|
||||
the databases of each PostgreSQL cluster on the router with their sizes, as JSON (see
|
||||
postgresql_databases()); /config has the clusters' settings
|
||||
|
||||
/config, /metrics/history, /postgresql, /users, /groups and /account need an HS256 JWT in an Authorization: Bearer header signed
|
||||
/config, /metrics/history, /postgresql, /users, /service-accounts, /groups, /site-to-site 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
|
||||
@@ -39,7 +46,7 @@ default the address the call came in on, is the last entry of its X-Forwarded-Fo
|
||||
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, and the state of site-to-site, road-warrior
|
||||
whether each service runs, whether Docker is installed, and the state of each site-to-site connection, road-warrior
|
||||
(IPsec P2S), OpenVPN and WireGuard. No configuration data or key is shown, and no address but the
|
||||
client's.
|
||||
|
||||
@@ -80,7 +87,7 @@ import vpnrouter_metrics
|
||||
import vpnrouter_net
|
||||
import vpnrouter_serve
|
||||
import vpnrouter_users
|
||||
from vpnrouter_status import established, openvpn_clients, run, swanctl, wireguard_peers
|
||||
from vpnrouter_status import established, established_each, openvpn_clients, run, swanctl, wireguard_peers
|
||||
|
||||
STATE_FILE = '/run/vpn-router/web.json'
|
||||
CONFIG_FILE = '/etc/vpn-router/vpn-router.json'
|
||||
@@ -111,6 +118,9 @@ POSTGRESQL_SETTINGS = ('server_version', 'listen_addresses', 'port', 'max_connec
|
||||
#: The largest request body accepted, in bytes.
|
||||
BODY_LIMIT = 4096
|
||||
|
||||
#: 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', 'psk')
|
||||
|
||||
#: 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}
|
||||
|
||||
@@ -202,9 +212,10 @@ def collect(state):
|
||||
'units': units,
|
||||
'docker': docker_status(),
|
||||
}
|
||||
if state.get('site_to_site'):
|
||||
count = established('remote-site')
|
||||
status['site_to_site'] = {'established': None if count is None else count > 0}
|
||||
names = state.get('site_to_site')
|
||||
if isinstance(names, list) and names:
|
||||
status['site_to_site'] = {'connections': {name: None if count is None else count > 0
|
||||
for name, count in established_each(names).items()}}
|
||||
if state.get('p2s'):
|
||||
status['p2s'] = {'clients': established('road-warrior')}
|
||||
if state.get('wireguard'):
|
||||
@@ -365,9 +376,8 @@ def text_of(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:
|
||||
value = status['site_to_site']['established']
|
||||
lines.append(f'Site to site: {"unknown" if value is None else "established" if value else "not established"}')
|
||||
for name, value in status.get('site_to_site', {}).get('connections', {}).items():
|
||||
lines.append(f'Site to site {name}: {"unknown" if value is None else "established" if value else "not established"}')
|
||||
if 'p2s' in status:
|
||||
clients = status['p2s']['clients']
|
||||
lines.append(f'Road-warrior (IPsec P2S): {"unknown" if clients is None else f"{clients} connected"}')
|
||||
@@ -446,6 +456,8 @@ def users(state, method, path, body):
|
||||
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
|
||||
|
||||
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'}
|
||||
@@ -454,7 +466,8 @@ def users(state, method, path, body):
|
||||
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: vpnrouter_users.list_users(connection),
|
||||
('GET', 1, None): lambda connection: {
|
||||
'users': [user for user in vpnrouter_users.list_users(connection)['users'] if not user['service']]},
|
||||
('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')),
|
||||
@@ -474,6 +487,8 @@ def users(state, method, path, body):
|
||||
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)
|
||||
@@ -489,6 +504,56 @@ def users(state, method, path, body):
|
||||
return (201 if route == ('POST', 1, None) else 200), document
|
||||
|
||||
|
||||
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: {"service_accounts": [...]}
|
||||
POST /service-accounts add one: {"name", "password", "email"?}
|
||||
PUT /service-accounts/<name> change the e-mail address: {"email"}; "" removes it
|
||||
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 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 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('/')
|
||||
route = (method, len(parts), parts[2] if len(parts) == 3 else None)
|
||||
name = parts[1] if len(parts) > 1 else None
|
||||
email = {'email': body.get('email')}
|
||||
actions = {
|
||||
('GET', 1, None): lambda connection: {
|
||||
'service_accounts': [user for user in vpnrouter_users.list_users(connection)['users'] if user['service']]},
|
||||
('POST', 1, None): lambda connection: vpnrouter_users.add(connection, body.get('name'), body.get('password'), email,
|
||||
service=True),
|
||||
('PUT', 2, None): lambda connection: vpnrouter_users.update(connection, name, email),
|
||||
('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),
|
||||
}
|
||||
if route not in actions:
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
if name is not None and name in vpnrouter_users.internal_accounts():
|
||||
return 403, {'error': f'{name} is an internal service account, which the router manages'}
|
||||
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)
|
||||
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 groups(state, method, path, body):
|
||||
"""Answer a /groups request with vpnrouter_users: the HTTP status and the JSON document.
|
||||
|
||||
@@ -498,6 +563,9 @@ def groups(state, method, path, body):
|
||||
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'}
|
||||
@@ -505,6 +573,12 @@ def groups(state, method, path, body):
|
||||
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(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(
|
||||
@@ -527,6 +601,81 @@ def groups(state, method, path, body):
|
||||
return (201 if route == ('POST', 1, None) else 200), document
|
||||
|
||||
|
||||
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",
|
||||
"psk"}; psk is required, 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 not isinstance(settings.get('psk'), str) or not settings['psk']:
|
||||
return 400, {'error': 'psk is required'}
|
||||
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 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.
|
||||
|
||||
@@ -592,7 +741,8 @@ def metrics_history(view):
|
||||
|
||||
|
||||
def changes_admins(connection, method, parts, body):
|
||||
"""Whether a /users or /groups request creates, renames to, changes or removes ADMIN_GROUP, or removes a member of it."""
|
||||
"""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:
|
||||
@@ -600,7 +750,7 @@ def changes_admins(connection, method, parts, body):
|
||||
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] == 'users' and method == 'DELETE' and len(parts) == 2:
|
||||
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
|
||||
|
||||
@@ -714,7 +864,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
# 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', 'groups', 'account') if path == f'/{name}' or path.startswith(f'/{name}/')), None)
|
||||
directory = next((name for name in ('users', 'service-accounts', 'groups', 'account', 'site-to-site')
|
||||
if path == f'/{name}' or path.startswith(f'/{name}/')), None)
|
||||
protected = path in ('/config', '/metrics/history', '/postgresql') or directory is not None
|
||||
signing = path in ('/auth', '/auth/refresh', '/auth/logout')
|
||||
state = current() if protected or signing else {}
|
||||
@@ -751,6 +902,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
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 == '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'
|
||||
|
||||
@@ -2,23 +2,26 @@
|
||||
"""Show and change the router's configuration, /etc/vpn-router/vpn-router.json.
|
||||
|
||||
show prints the file as indented JSON; the file is not changed. set takes a setting as
|
||||
section.option, such as web.port, and a value, which it converts to the type the package's
|
||||
schema (vpnrouter.SCHEMA) gives the setting: a whole number, true or false, a list of strings
|
||||
separated by commas (an empty value is an empty list), or a string. The configuration with the
|
||||
new value is checked as vpn-router-setup checks it, and only then written, indented, with mode
|
||||
0600; vpn-router-setup applies it at its next run, which set reminds 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.
|
||||
section.option, such as web.port, or as site_to_site.NAME.option for a site-to-site connection,
|
||||
which it creates with the defaults (vpnrouter.CONNECTION) when it does not exist, and a value,
|
||||
which it converts to the type the package's schema (vpnrouter.SCHEMA) gives the setting: a whole
|
||||
number, true or false, a list of strings separated by commas (an empty value is an empty list),
|
||||
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.
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.path.insert(0, '/usr/lib/vpn-router')
|
||||
|
||||
@@ -43,48 +46,33 @@ class Parser(argparse.ArgumentParser):
|
||||
def read():
|
||||
"""The settings in the configuration file, without the defaults; {} when the file does not exist."""
|
||||
try:
|
||||
data = json.loads(vpnrouter.CONFIG_FILE.read_text(encoding='utf-8'))
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except ValueError as exc:
|
||||
raise Failure(f'cannot parse {vpnrouter.CONFIG_FILE}: {exc}')
|
||||
if not isinstance(data, dict) or not all(isinstance(options, dict) for options in data.values()):
|
||||
raise Failure(f'cannot parse {vpnrouter.CONFIG_FILE}: it is not an object of sections')
|
||||
return data
|
||||
return vpnrouter.read_settings()
|
||||
except vpnrouter.ConfigError as exc:
|
||||
raise Failure(str(exc))
|
||||
|
||||
|
||||
def converted(key, text):
|
||||
"""The value of text for the setting key, section.option, of the type the schema gives it."""
|
||||
section, _, option = key.partition('.')
|
||||
if section not in vpnrouter.SCHEMA or option not in vpnrouter.SCHEMA[section]:
|
||||
raise Failure(f'{key}: unknown setting; settings are section.option, such as web.port', 2)
|
||||
kind = type(vpnrouter.SCHEMA[section][option])
|
||||
"""The path of the setting key, section.option or site_to_site.NAME.option, and text as the type the schema gives it."""
|
||||
path = key.split('.')
|
||||
if path[0] == 'site_to_site' and len(path) == 3 and path[2] in vpnrouter.CONNECTION:
|
||||
kind = type(vpnrouter.CONNECTION[path[2]])
|
||||
elif path[0] != 'site_to_site' and len(path) == 2 and path[0] in vpnrouter.SCHEMA and path[1] in vpnrouter.SCHEMA[path[0]]:
|
||||
kind = type(vpnrouter.SCHEMA[path[0]][path[1]])
|
||||
else:
|
||||
raise Failure(f'{key}: unknown setting; settings are section.option, such as web.port, '
|
||||
'or site_to_site.NAME.option', 2)
|
||||
if kind is bool:
|
||||
if text not in ('true', 'false'):
|
||||
raise Failure(f'{key} takes true or false', 2)
|
||||
return text == 'true'
|
||||
return path, text == 'true'
|
||||
if kind is int:
|
||||
try:
|
||||
return int(text)
|
||||
return path, int(text)
|
||||
except ValueError:
|
||||
raise Failure(f'{key} takes a whole number', 2)
|
||||
if kind is list:
|
||||
return [item.strip() for item in text.split(',') if item.strip()]
|
||||
return text
|
||||
|
||||
|
||||
def write(data):
|
||||
"""Write the settings, indented, with mode 0600, replacing the file in one step."""
|
||||
directory = vpnrouter.CONFIG_FILE.parent
|
||||
descriptor, name = tempfile.mkstemp(dir=directory, prefix='.vpn-router.json.')
|
||||
try:
|
||||
with os.fdopen(descriptor, 'w', encoding='utf-8') as handle:
|
||||
handle.write(json.dumps(data, indent=2) + '\n')
|
||||
os.chmod(name, 0o600)
|
||||
os.replace(name, vpnrouter.CONFIG_FILE)
|
||||
except OSError:
|
||||
os.unlink(name)
|
||||
raise
|
||||
return path, [item.strip() for item in text.split(',') if item.strip()]
|
||||
return path, text
|
||||
|
||||
|
||||
def main(argv):
|
||||
@@ -93,34 +81,49 @@ def main(argv):
|
||||
commands = parser.add_subparsers(dest='command', required=True, parser_class=Parser)
|
||||
commands.add_parser('show', help='print the configuration as indented JSON')
|
||||
setter = commands.add_parser('set', help='set a setting and write the configuration')
|
||||
setter.add_argument('key', metavar='SECTION.OPTION', help='the setting, such as web.port')
|
||||
setter.add_argument('key', metavar='SECTION.OPTION',
|
||||
help='the setting, such as web.port or site_to_site.NAME.option')
|
||||
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')
|
||||
options = parser.parse_args(argv)
|
||||
|
||||
data = read()
|
||||
if options.command == 'show':
|
||||
print(json.dumps(data, indent=2))
|
||||
print(json.dumps(read(), indent=2))
|
||||
return 0
|
||||
|
||||
section, _, option = options.key.partition('.')
|
||||
value = converted(options.key, options.value)
|
||||
data.setdefault(section, {})[option] = value
|
||||
# The configuration as vpn-router-setup reads it: the defaults with the file's settings.
|
||||
candidate = {name: dict(defaults) for name, defaults in vpnrouter.SCHEMA.items()}
|
||||
for name, settings in data.items():
|
||||
candidate.setdefault(name, {}).update(settings)
|
||||
try:
|
||||
vpnrouter.validate(candidate)
|
||||
except vpnrouter.ConfigError as exc:
|
||||
raise Failure(str(exc), 2)
|
||||
write(data)
|
||||
with vpnrouter.config_lock():
|
||||
data = read()
|
||||
if options.command == 'set':
|
||||
path, value = converted(options.key, options.value)
|
||||
if path[0] == 'site_to_site':
|
||||
connections = data.setdefault('site_to_site', {})
|
||||
connection = connections.setdefault(path[1], copy.deepcopy(vpnrouter.CONNECTION))
|
||||
if not isinstance(connection, dict):
|
||||
raise Failure(f'site_to_site.{path[1]} is not an object')
|
||||
connection[path[2]] = value
|
||||
else:
|
||||
data.setdefault(path[0], {})[path[1]] = value
|
||||
else:
|
||||
section, _, name = options.key.partition('.')
|
||||
if section != 'site_to_site' or not name or name not in data.get('site_to_site', {}):
|
||||
raise Failure(f'{options.key}: no such site-to-site connection', 2)
|
||||
del data['site_to_site'][name]
|
||||
try:
|
||||
vpnrouter.validate(vpnrouter.with_defaults(data))
|
||||
except vpnrouter.ConfigError as exc:
|
||||
raise Failure(str(exc), 2)
|
||||
vpnrouter.save_config(data)
|
||||
except Failure as failure:
|
||||
print(f'vpn-config-edit: {failure}', file=sys.stderr)
|
||||
return failure.status
|
||||
except OSError as exc:
|
||||
print(f'vpn-config-edit: {exc}', file=sys.stderr)
|
||||
return 1
|
||||
print(f'{options.key} set to {json.dumps(value)} in {vpnrouter.CONFIG_FILE}.')
|
||||
if options.command == 'set':
|
||||
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')
|
||||
return 0
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ user exists, 1 anything else. -h or --help shows the commands and their options.
|
||||
vpn-router-user [--json] list
|
||||
vpn-router-user [--json] add <name> --first-name F --last-name L --email E (password on stdin)
|
||||
vpn-router-user [--json] add <name> [--email E] (a Manager-only account; password on stdin)
|
||||
vpn-router-user [--json] add <name> --service [--email E] (a service account; password on stdin)
|
||||
vpn-router-user [--json] update <name> [--first-name F] [--last-name L] [--email E]
|
||||
vpn-router-user [--json] passwd <name> (password on stdin)
|
||||
vpn-router-user [--json] disable <name>
|
||||
@@ -40,7 +41,8 @@ from vpnrouter_users import Failure
|
||||
HELP = {
|
||||
'list': 'list the users',
|
||||
'add': 'add a VPN user with the three person options and issue the certificate, or without the names a '
|
||||
'Manager-only account, with an optional e-mail address; the password is read from standard input',
|
||||
'Manager-only account, or with --service a service account, each with an optional e-mail address; the '
|
||||
'password is read from standard input',
|
||||
'update': "change the first name, the last name or the e-mail address; a Manager-only account has only the "
|
||||
"e-mail address, which '' removes",
|
||||
'passwd': 'set the password, read from standard input',
|
||||
@@ -76,6 +78,8 @@ def parse(argv):
|
||||
if command in ('add', 'update'):
|
||||
for key, _ in vpnrouter_users.PERSON:
|
||||
sub.add_argument(f'--{key.replace("_", "-")}', dest=key, metavar='TEXT', help=PERSON_HELP[key])
|
||||
if command == 'add':
|
||||
sub.add_argument('--service', action='store_true', help='add a service account, which cannot sign in to the web service')
|
||||
if command == 'otp-enrol':
|
||||
sub.add_argument('code', help='a code of the secret from the authenticator')
|
||||
return parser.parse_args(argv)
|
||||
@@ -93,7 +97,7 @@ def run(connection, options, secret):
|
||||
return vpnrouter_users.list_users(connection)
|
||||
values = {key: getattr(options, key, None) for key, _ in vpnrouter_users.PERSON}
|
||||
if command == 'add':
|
||||
return vpnrouter_users.add(connection, options.name, secret, values)
|
||||
return vpnrouter_users.add(connection, options.name, secret, values, service=options.service)
|
||||
if command == 'update':
|
||||
return vpnrouter_users.update(connection, options.name, values)
|
||||
if command == 'passwd':
|
||||
@@ -115,7 +119,7 @@ def text_of(answer):
|
||||
if not users:
|
||||
return 'no users'
|
||||
head = ('USER', 'KIND', 'STATE', 'CERTIFICATE', 'OTP', 'FIRST NAME', 'LAST NAME', 'E-MAIL')
|
||||
rows = [(user['name'], 'service' if user['service'] else 'manager' if user['manager_only'] else 'vpn',
|
||||
rows = [(user['name'], 'internal' if user['internal'] else 'service' if user['service'] else 'manager' if user['manager_only'] else 'vpn',
|
||||
'disabled' if not user['enabled'] else 'locked' if user['locked'] else 'enabled',
|
||||
'issued' if user['certificate'] else 'missing',
|
||||
'enrolled' if user['otp'] else '-', user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-')
|
||||
|
||||
@@ -8,20 +8,23 @@
|
||||
"internal": "eth1"
|
||||
},
|
||||
"wan": {
|
||||
"local_fqdn": "router.example.com",
|
||||
"local_id_mode": "fqdn"
|
||||
"local_fqdn": "router.example.com"
|
||||
},
|
||||
"local": {
|
||||
"cidrs": ["10.0.0.0/24"],
|
||||
"int_addr": "10.1.1.4",
|
||||
"int_gateway_ip": "10.1.1.1"
|
||||
},
|
||||
"remote": {
|
||||
"addrs": ["peer.example.net"],
|
||||
"id": "peer.example.net",
|
||||
"cidrs": ["192.168.0.0/24"],
|
||||
"psk": "change-me",
|
||||
"psk_file": ""
|
||||
"site_to_site": {
|
||||
"remote-site": {
|
||||
"addrs": ["peer.example.net"],
|
||||
"id": "peer.example.net",
|
||||
"cidrs": ["192.168.0.0/24"],
|
||||
"local_cidrs": ["10.0.0.0/24"],
|
||||
"local_id_mode": "fqdn",
|
||||
"psk": "change-me",
|
||||
"psk_file": ""
|
||||
}
|
||||
},
|
||||
"p2s": {
|
||||
"enabled": false,
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
connections {
|
||||
remote-site {
|
||||
version = 2
|
||||
proposals = aes256-sha256-modp2048,aes256-sha1-modp1024
|
||||
reauth_time = 28800
|
||||
unique = replace
|
||||
|
||||
local_addrs = {{ local_addrs }}
|
||||
remote_addrs = {{ remote_addrs }}
|
||||
|
||||
local {
|
||||
id = {{ local_id }}
|
||||
auth = psk
|
||||
}
|
||||
|
||||
remote {
|
||||
id = {{ remote_id }}
|
||||
auth = psk
|
||||
}
|
||||
|
||||
children {
|
||||
site2site {
|
||||
mode = tunnel
|
||||
local_ts = {{ local_ts }}
|
||||
remote_ts = {{ remote_cidrs }}
|
||||
esp_proposals = aes256-sha256,aes256-sha1
|
||||
life_time = 3600
|
||||
dpd_action = restart
|
||||
start_action = start
|
||||
close_action = none
|
||||
}
|
||||
}
|
||||
|
||||
rekey_time = 10800
|
||||
dpd_delay = 10
|
||||
dpd_timeout = 120
|
||||
}
|
||||
}
|
||||
|
||||
secrets {
|
||||
ike-psk {
|
||||
id-1 = {{ local_id }}
|
||||
id-2 = {{ remote_id }}
|
||||
secret = "{{ psk }}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
connections {
|
||||
{%- for connection in connections if connection.ready %}
|
||||
{{ connection.name }} {
|
||||
version = 2
|
||||
proposals = aes256-sha256-modp2048,aes256-sha1-modp1024
|
||||
reauth_time = 28800
|
||||
unique = replace
|
||||
|
||||
local_addrs = {{ local_addrs }}
|
||||
remote_addrs = {{ connection.remote_addrs }}
|
||||
|
||||
local {
|
||||
id = {{ connection.local_id }}
|
||||
auth = psk
|
||||
}
|
||||
|
||||
remote {
|
||||
id = {{ connection.remote_id }}
|
||||
auth = psk
|
||||
}
|
||||
|
||||
children {
|
||||
{{ connection.name }} {
|
||||
mode = tunnel
|
||||
local_ts = {{ connection.local_ts }}
|
||||
remote_ts = {{ connection.remote_cidrs }}
|
||||
esp_proposals = aes256-sha256,aes256-sha1
|
||||
life_time = 3600
|
||||
dpd_action = restart
|
||||
start_action = start
|
||||
close_action = none
|
||||
}
|
||||
}
|
||||
|
||||
rekey_time = 10800
|
||||
dpd_delay = 10
|
||||
dpd_timeout = 120
|
||||
}
|
||||
{%- endfor %}
|
||||
}
|
||||
|
||||
secrets {
|
||||
{%- for connection in connections if connection.ready %}
|
||||
ike-psk-{{ connection.name }} {
|
||||
id-1 = {{ connection.local_id }}
|
||||
id-2 = {{ connection.remote_id }}
|
||||
secret = "{{ connection.psk }}"
|
||||
}
|
||||
{%- endfor %}
|
||||
}
|
||||
@@ -15,13 +15,17 @@ locals {
|
||||
# Contents of /etc/vpn-router/vpn-router.json.
|
||||
config = {
|
||||
general = { platform = "azure", mode = "auto" }
|
||||
wan = { local_fqdn = var.local_fqdn, local_id_mode = var.local_id_mode }
|
||||
local = { cidrs = [for c in split(",", var.local_cidrs) : trimspace(c) if trimspace(c) != ""] }
|
||||
remote = {
|
||||
addrs = [for a in split(",", var.remote_addrs) : trimspace(a) if trimspace(a) != ""]
|
||||
id = var.remote_id
|
||||
cidrs = local.remote_cidr_list
|
||||
psk = var.psk
|
||||
wan = { local_fqdn = var.local_fqdn }
|
||||
local = { cidrs = local.local_cidr_list }
|
||||
site_to_site = {
|
||||
"remote-site" = {
|
||||
addrs = [for a in split(",", var.remote_addrs) : trimspace(a) if trimspace(a) != ""]
|
||||
id = var.remote_id
|
||||
cidrs = local.remote_cidr_list
|
||||
local_cidrs = local.local_cidr_list
|
||||
local_id_mode = var.local_id_mode
|
||||
psk = var.psk
|
||||
}
|
||||
}
|
||||
p2s = { enabled = var.p2s_enabled, address_pool = var.p2s_address_pool, ca_name = "VPN Router CA" }
|
||||
wireguard = { enabled = var.wireguard_enabled, address = var.wireguard_address, listen_port = var.wireguard_listen_port }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
locals {
|
||||
remote_cidr_list = [for c in split(",", var.remote_cidrs) : trimspace(c) if trimspace(c) != ""]
|
||||
local_cidr_list = [for c in split(",", var.local_cidrs) : trimspace(c) if trimspace(c) != ""]
|
||||
}
|
||||
|
||||
resource "azurerm_route_table" "router" {
|
||||
|
||||
+11
-7
@@ -15,13 +15,17 @@ locals {
|
||||
# Contents of /etc/vpn-router/vpn-router.json.
|
||||
config = {
|
||||
general = { platform = "gcp", mode = "auto" }
|
||||
wan = { local_fqdn = var.local_fqdn, local_id_mode = var.local_id_mode }
|
||||
local = { cidrs = [for c in split(",", var.local_cidrs) : trimspace(c) if trimspace(c) != ""] }
|
||||
remote = {
|
||||
addrs = [for a in split(",", var.remote_addrs) : trimspace(a) if trimspace(a) != ""]
|
||||
id = var.remote_id
|
||||
cidrs = local.remote_cidr_list
|
||||
psk = var.psk
|
||||
wan = { local_fqdn = var.local_fqdn }
|
||||
local = { cidrs = local.local_cidr_list }
|
||||
site_to_site = {
|
||||
"remote-site" = {
|
||||
addrs = [for a in split(",", var.remote_addrs) : trimspace(a) if trimspace(a) != ""]
|
||||
id = var.remote_id
|
||||
cidrs = local.remote_cidr_list
|
||||
local_cidrs = local.local_cidr_list
|
||||
local_id_mode = var.local_id_mode
|
||||
psk = var.psk
|
||||
}
|
||||
}
|
||||
p2s = { enabled = var.p2s_enabled, address_pool = var.p2s_address_pool, ca_name = "VPN Router CA" }
|
||||
wireguard = { enabled = var.wireguard_enabled, address = var.wireguard_address, listen_port = var.wireguard_listen_port }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
locals {
|
||||
remote_cidr_list = [for c in split(",", var.remote_cidrs) : trimspace(c) if trimspace(c) != ""]
|
||||
local_cidr_list = [for c in split(",", var.local_cidrs) : trimspace(c) if trimspace(c) != ""]
|
||||
}
|
||||
|
||||
resource "google_compute_route" "to_remote" {
|
||||
|
||||
+10
-7
@@ -47,19 +47,22 @@ cat > /etc/vpn-router/vpn-router.json <<EOF
|
||||
"internal": "${INTERNAL_INTERFACE}"
|
||||
},
|
||||
"wan": {
|
||||
"local_fqdn": "${LOCAL_FQDN}",
|
||||
"local_id_mode": "${LOCAL_ID_MODE}"
|
||||
"local_fqdn": "${LOCAL_FQDN}"
|
||||
},
|
||||
"local": {
|
||||
"cidrs": ${LOCAL_CIDRS},
|
||||
"int_addr": "${INT_ADDR}",
|
||||
"int_gateway_ip": "${INT_GATEWAY_IP}"
|
||||
},
|
||||
"remote": {
|
||||
"addrs": ${REMOTE_ADDRS},
|
||||
"id": "${REMOTE_ID}",
|
||||
"cidrs": ${REMOTE_CIDRS},
|
||||
"psk_file": "/etc/vpn-router/psk"
|
||||
"site_to_site": {
|
||||
"remote-site": {
|
||||
"addrs": ${REMOTE_ADDRS},
|
||||
"id": "${REMOTE_ID}",
|
||||
"cidrs": ${REMOTE_CIDRS},
|
||||
"local_cidrs": ${LOCAL_CIDRS},
|
||||
"local_id_mode": "${LOCAL_ID_MODE}",
|
||||
"psk_file": "/etc/vpn-router/psk"
|
||||
}
|
||||
},
|
||||
"p2s": {
|
||||
"enabled": ${P2S_ENABLED},
|
||||
|
||||
+6
-5
@@ -1,9 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Commit everything, tag the version of the newest changelog entry and push main with the tags.
|
||||
|
||||
The tag is the upstream version of the first entry of debian-package/debian/changelog,
|
||||
without the Debian revision: vpn-router (0.5.1-1) is tagged 0.5.1, which starts the
|
||||
release workflow.
|
||||
The tag is the version of the first entry of debian-package/debian/changelog, without the
|
||||
Debian revision 1: vpn-router (0.5.1-1) is tagged 0.5.1 and (0.5.1-2) 0.5.1-2. The tag
|
||||
starts the release workflow.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -48,13 +48,14 @@ def summary():
|
||||
|
||||
|
||||
def version():
|
||||
"""The upstream version of the first changelog entry."""
|
||||
"""The tag of the first changelog entry: its version, without the Debian revision when that is 1."""
|
||||
first = CHANGELOG.read_text(encoding='utf-8').splitlines()[0]
|
||||
start, end = first.find('('), first.find(')')
|
||||
if start == -1 or end < start:
|
||||
sys.exit(f'{CHANGELOG}: the first line has no version: {first}')
|
||||
full = first[start + 1:end]
|
||||
return full.rsplit('-', 1)[0] if '-' in full else full
|
||||
upstream, _, revision = full.rpartition('-')
|
||||
return upstream if upstream and revision == '1' else full
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
Reference in new issue
Block a user