Compare commits

..
1 Commits
22 changed files with 843 additions and 351 deletions

No files matched your search

+93 -5
View File
@@ -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 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 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 Consequences: No secret reaches the web service's state file or any answer of the API. Through
cannot tell whether a secret is set. A new secret option needs a name that matches. `/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 ## 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` 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 (`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`, keeps the samples in the router's database, in four detail tables (`metrics_system`,
`metrics_network`, `metrics_vpn`). Each detail table has hourly, daily, monthly and yearly summary `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 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 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 up. Garbage collection runs on start and once a day, not with every write, and deletes the rows
@@ -577,3 +582,86 @@ the router; deleting the file and running setup changes it. A user who already h
taken over; setup fails until `metrics.service_account` names another. A disabled account stays 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 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. `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.
+95 -40
View 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` | | `general` | `mode` | string | `manual` | How much you supply: `manual`, `interfaces` or `auto` |
| `interfaces` | `external` | string | | Name of the NIC facing the untrusted network | | `interfaces` | `external` | string | | Name of the NIC facing the untrusted network |
| `interfaces` | `internal` | string | | Name of the NIC facing the protected 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_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 |
| `wan` | `local_id_mode` | string | `fqdn` | IKE identity source: `fqdn`, `public_ip` or `internal_ip` | | `local` | `cidrs` | list | | Local subnets routed via the internal side, masqueraded to the external one, and offered to road-warrior and OpenVPN clients |
| `local` | `cidrs` | list | | Local subnets advertised into the tunnel |
| `local` | `int_addr` | string | | This host's address on the internal network | | `local` | `int_addr` | string | | This host's address on the internal network |
| `local` | `int_gateway_ip` | string | | Next hop on the internal side | | `local` | `int_gateway_ip` | string | | Next hop on the internal side |
| `remote` | `addrs` | list | | Remote gateway addresses or FQDNs | | `site_to_site` | | object | | The site-to-site connections, each a name and an object of the settings below |
| `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` |
| `p2s` | `enabled` | boolean | `false` | Road-warrior access | | `p2s` | `enabled` | boolean | `false` | Road-warrior access |
| `p2s` | `address_pool` | string | | CIDR assigned to road-warrior clients | | `p2s` | `address_pool` | string | | CIDR assigned to road-warrior clients |
| `p2s` | `ca_name` | string | `VPN Router CA` | Common name for a locally created CA | | `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_monthly` | string | `1 year` | How long the monthly summaries are kept |
| `metrics` | `keep_yearly` | string | `10 years` | How long the yearly 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: A minimal site-to-site configuration in `auto` mode:
```json ```json
@@ -156,16 +174,19 @@ A minimal site-to-site configuration in `auto` mode:
"general": { "platform": "generic", "mode": "auto" }, "general": { "platform": "generic", "mode": "auto" },
"wan": { "local_fqdn": "router.example.com" }, "wan": { "local_fqdn": "router.example.com" },
"local": { "cidrs": ["10.0.0.0/24"] }, "local": { "cidrs": ["10.0.0.0/24"] },
"remote": { "site_to_site": {
"addrs": ["peer.example.net"], "remote-site": {
"id": "peer.example.net", "addrs": ["peer.example.net"],
"cidrs": ["192.168.0.0/24"], "id": "peer.example.net",
"psk": "s3cr3t" "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; 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 strongSwan reads an address as an IP identity and a name as an FQDN, so a leading `@` is not needed
and is refused. Road-warrior and OpenVPN access need a name in `wan.local_fqdn`. and is refused. 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.status_content standard
vpn-config-edit set web.port 7082 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 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 `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 `section.option`, or as `site_to_site.NAME.option` for a site-to-site connection, which it
`false`, a list separated by commas (an empty value is an empty list), or a string; an unknown creates with the defaults when it does not exist, and converts the value to the setting's type:
setting is refused. It checks the whole configuration as `vpn-router-setup` does, and only then a whole number, `true` or `false`, a list separated by commas (an empty value is an empty list),
writes the file, indented, with mode 0600, and reminds that `vpn-router-setup` applies it. A 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. refused request exits with status 2, any other failure with 1.
## Applying changes ## Applying changes
@@ -545,7 +571,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 | | `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 | | `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 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 request from one of `web.trusted_proxies`, by default the address the request came in on, so
@@ -581,6 +607,7 @@ curl http://10.0.0.4:17081/
| `/config` | The configuration without its secret options, and what the router resolved and runs, as JSON; needs a token | | `/config` | The configuration without its secret options, and what the router resolved and runs, as JSON; needs a token |
| `/users` | The users of OpenVPN and road-warrior access; needs a token | | `/users` | The users of OpenVPN and road-warrior access; needs a token |
| `/groups` | The groups of users; 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 | | `/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 The status holds the state of `vpn-router-setup`, `slapd` and each PostgreSQL cluster unit
@@ -591,8 +618,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, 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 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` 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 (the Docker rules), which do not change `status`; as `site_to_site.connections`, whether each
site-to-site tunnel is established; the number of connected road-warrior (IPsec P2S) clients; and 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 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. all these services are active, and `degraded` otherwise.
@@ -603,7 +630,7 @@ curl http://10.0.0.4:7081/status
### Tokens and signing in ### Tokens and signing in
`/config`, `/users`, `/groups` and `/account` need a JWT in an `Authorization: Bearer` header, signed with `/config`, `/users`, `/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). 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 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 (404). A missing, expired or wrongly signed token, or one of another algorithm, gets 401 with
@@ -661,8 +688,8 @@ curl -H "Authorization: Bearer $TOKEN" http://10.0.0.4:7081/config
| Key | Content | | 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) | | `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, the local and remote IKE identities and subnets, and the IPv4 routes as `ip -j -4 route` prints them | | `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 | | `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 | | `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 | | `docker` | `docker.exposed_ports` and the rules of the `VPN-ROUTER-DOCKER` chain |
@@ -745,6 +772,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 otherwise. `DELETE /users/<name>` is refused with 400 while the user is the last member of a
group. 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 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 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 out the addresses of the interfaces `web.listen` names as `general.mode` directs; it listens on
@@ -792,30 +839,37 @@ takes a sample of the router with `psutil`:
- the free and the total space of `/`; - the free and the total space of `/`;
- the bytes received and sent per second on the external and the internal interface; - the bytes received and sent per second on the external and the internal interface;
- the 1, 5 and 15 minute load averages; - the 1, 5 and 15 minute load averages;
- the VPN clients of each enabled VPN system: road-warrior (IPsec P2S) and OpenVPN clients, - 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, and the site-to-site tunnel, 1 while it is WireGuard peers with a handshake in the last 3 minutes;
up. - 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 ### History
The samples are kept in the router's PostgreSQL database, the one of the refresh tokens 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, (`web.database`), in four detail tables, one row per minute, and per interface, kind of client or
monthly and yearly. A summary row has the start of its period, in UTC, and the average and the site-to-site connection, and each is summarized hourly, daily, monthly and yearly. A summary row
maximum of each value: 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 | | Level | Tables | Kept |
|---|---|---| |---|---|---|
| Details | `metrics_system`, `metrics_network`, `metrics_vpn` | `metrics.keep_details` | | 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.keep_hourly` | | 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.keep_daily` | | 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.keep_monthly` | | 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.keep_yearly` | | 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: 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. - **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`. - **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 | | Range | Bucket | Source |
|---|---|---| |---|---|---|
@@ -834,7 +888,8 @@ How it works:
- `system_filesystem_usage_bytes` and `system_filesystem_utilization`; - `system_filesystem_usage_bytes` and `system_filesystem_utilization`;
- `system_network_io_bytes_total`; - `system_network_io_bytes_total`;
- `system_cpu_load_average_1m`, `_5m` and `_15m`; - `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. - **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. - **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. - **The account:** `vpn-router-setup` owns it while metrics are enabled.
@@ -864,15 +919,15 @@ receivers:
When `metrics.otlp_endpoint` is set, every sample is also pushed there as an OTLP/HTTP JSON request. 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. - **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. - **Failures:** a failed push is logged and not repeated; the next sample is pushed a minute later.
## Router Manager ## Router Manager
The `vpn-router-manager` package, built in the repository `linux-cloud-router-manager`, is a web 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 application that shows the state of the router and manages its users and site-to-site
service. It can be installed on the router or on another Linux system. While it is installed on 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`, 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 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 interface; restart `vpn-router-setup` after changing it. The manager's `listen` chooses the
+40
View File
@@ -1,3 +1,43 @@
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 vpn-router (0.17.0-1) unstable; urgency=medium
feature: /config shows the router's PostgreSQL clusters and their settings, and /postgresql their databases with their sizes. feature: /config shows the router's PostgreSQL clusters and their settings, and /postgresql their databases with their sizes.
+1 -1
View File
@@ -43,7 +43,7 @@ case "$1" in
ucfr --purge vpn-router /etc/vpn-router/vpn-router.json ucfr --purge vpn-router /etc/vpn-router/vpn-router.json
fi 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/swanctl/conf.d/road-warrior.conf \
/etc/systemd/resolved.conf.d/p2s-forwarder.conf \ /etc/systemd/resolved.conf.d/p2s-forwarder.conf \
/etc/wireguard/wg0.conf \ /etc/wireguard/wg0.conf \
+6 -1
View File
@@ -6,8 +6,13 @@ case "$1" in
# Stop offering the tunnels, but leave strongSwan itself enabled: it is # Stop offering the tunnels, but leave strongSwan itself enabled: it is
# a shared service and may be in use by something other than this # a shared service and may be in use by something other than this
# package. The connection files are removed on purge. # 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 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 swanctl --terminate --ike road-warrior >/dev/null 2>&1 || true
fi fi
+10 -7
View File
@@ -66,7 +66,8 @@ Type: select
Choices: fqdn, public_ip, internal_ip Choices: fqdn, public_ip, internal_ip
Default: fqdn Default: fqdn
Description: IKE local identity mode: 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. fqdn - use the FQDN, which must match what the peer expects.
. .
@@ -78,8 +79,9 @@ Template: vpn-router/local_cidrs
Type: string Type: string
Default: Default:
Description: Local subnet CIDR(s): Description: Local subnet CIDR(s):
Comma-separated list of local subnet CIDRs to advertise into the Comma-separated list of local subnet CIDRs routed by this router and
site-to-site tunnel (for example 10.0.0.0/24 or 10.0.0.0/24,10.0.1.0/24). 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 Template: vpn-router/int_gateway_ip
Type: string Type: string
@@ -94,25 +96,26 @@ Type: string
Default: Default:
Description: Remote site WAN IP address(es): Description: Remote site WAN IP address(es):
Comma-separated list of remote gateway addresses or FQDNs for the 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 Template: vpn-router/remote_id
Type: string Type: string
Default: Default:
Description: Remote site IKE identity: 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 Template: vpn-router/remote_cidrs
Type: string Type: string
Default: Default:
Description: Remote subnet CIDR(s): Description: Remote subnet CIDR(s):
Comma-separated list of remote subnet CIDRs reachable through the 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 Template: vpn-router/psk
Type: password Type: password
Description: Pre-shared key (PSK): 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 configured on the remote peer. Stored in the configuration file and cleared
from the debconf database after installation. 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 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 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 import json
@@ -29,27 +31,29 @@ sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
import vpnrouter 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 = { ANSWERS = {
'VPN_ROUTER_PLATFORM': ('general', 'platform'), 'VPN_ROUTER_PLATFORM': (('general', 'platform'),),
'VPN_ROUTER_MODE': ('general', 'mode'), 'VPN_ROUTER_MODE': (('general', 'mode'),),
'VPN_ROUTER_INT_ADDR': ('local', 'int_addr'), 'VPN_ROUTER_INT_ADDR': (('local', 'int_addr'),),
'VPN_ROUTER_EXTERNAL_INTERFACE': ('interfaces', 'external'), 'VPN_ROUTER_EXTERNAL_INTERFACE': (('interfaces', 'external'),),
'VPN_ROUTER_INTERNAL_INTERFACE': ('interfaces', 'internal'), 'VPN_ROUTER_INTERNAL_INTERFACE': (('interfaces', 'internal'),),
'VPN_ROUTER_LOCAL_FQDN': ('wan', 'local_fqdn'), 'VPN_ROUTER_LOCAL_FQDN': (('wan', 'local_fqdn'),),
'VPN_ROUTER_LOCAL_ID_MODE': ('wan', 'local_id_mode'), 'VPN_ROUTER_LOCAL_ID_MODE': ((*FIRST, 'local_id_mode'),),
'VPN_ROUTER_LOCAL_CIDRS': ('local', 'cidrs'), 'VPN_ROUTER_LOCAL_CIDRS': (('local', 'cidrs'), (*FIRST, 'local_cidrs')),
'VPN_ROUTER_INT_GATEWAY_IP': ('local', 'int_gateway_ip'), 'VPN_ROUTER_INT_GATEWAY_IP': (('local', 'int_gateway_ip'),),
'VPN_ROUTER_REMOTE_ADDRS': ('remote', 'addrs'), 'VPN_ROUTER_REMOTE_ADDRS': ((*FIRST, 'addrs'),),
'VPN_ROUTER_REMOTE_ID': ('remote', 'id'), 'VPN_ROUTER_REMOTE_ID': ((*FIRST, 'id'),),
'VPN_ROUTER_REMOTE_CIDRS': ('remote', 'cidrs'), 'VPN_ROUTER_REMOTE_CIDRS': ((*FIRST, 'cidrs'),),
'VPN_ROUTER_PSK': ('remote', 'psk'), 'VPN_ROUTER_PSK': ((*FIRST, 'psk'),),
'VPN_ROUTER_P2S_ENABLED': ('p2s', 'enabled'), 'VPN_ROUTER_P2S_ENABLED': (('p2s', 'enabled'),),
'VPN_ROUTER_P2S_ADDRESS_POOL': ('p2s', 'address_pool'), 'VPN_ROUTER_P2S_ADDRESS_POOL': (('p2s', 'address_pool'),),
'VPN_ROUTER_P2S_CA_NAME': ('p2s', 'ca_name'), 'VPN_ROUTER_P2S_CA_NAME': (('p2s', 'ca_name'),),
'VPN_ROUTER_WG_ENABLED': ('wireguard', 'enabled'), 'VPN_ROUTER_WG_ENABLED': (('wireguard', 'enabled'),),
'VPN_ROUTER_WG_ADDRESS': ('wireguard', 'address'), 'VPN_ROUTER_WG_ADDRESS': (('wireguard', 'address'),),
'VPN_ROUTER_WG_LISTEN_PORT': ('wireguard', 'listen_port'), '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') vpnrouter.fail(f'cannot parse {vpnrouter.CONFIG_FILE}: the top level is not an object')
answered = False answered = False
for variable, (section, option) in ANSWERS.items(): for variable, paths in ANSWERS.items():
value = os.environ.get(variable) answer = os.environ.get(variable)
if not value: if not answer:
continue continue
# A value that does not convert is kept as a string for validate() to report. for path in paths:
default = vpnrouter.SCHEMA[section][option] # A value that does not convert is kept as a string for validate() to report.
if isinstance(default, bool): default = vpnrouter.CONNECTION[path[2]] if path[0] == 'site_to_site' else vpnrouter.SCHEMA[path[0]][path[1]]
value = value == 'true' value = answer
elif isinstance(default, int): if isinstance(default, bool):
value = int(value) if value.isdigit() else value value = value == 'true'
elif isinstance(default, list): elif isinstance(default, int):
value = [item.strip() for item in value.split(',') if item.strip()] value = int(value) if value.isdigit() else value
if not isinstance(cfg.get(section, {}), dict): elif isinstance(default, list):
vpnrouter.fail(f'cannot parse {vpnrouter.CONFIG_FILE}: {section} is not an object') value = [item.strip() for item in value.split(',') if item.strip()]
if cfg.setdefault(section, {}).get(option) != value: target = cfg
cfg[section][option] = value for key in path[:-1]:
answered = True 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. # 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: with open(output, 'w', encoding='utf-8') as handle:
+54 -12
View File
@@ -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 previous sample, the memory used and in total, the free and the total space of /, the bytes
received and sent per second on the external and the internal interface, and the load averages; received and sent per second on the external and the internal interface, and the load averages;
and the VPN clients of each enabled VPN system (vpnrouter_status): road-warrior (p2s) and and the VPN clients of each enabled VPN system (vpnrouter_status): road-warrior (p2s) and
OpenVPN clients, WireGuard peers with a recent handshake, and the site-to-site tunnel, 1 while it OpenVPN clients and WireGuard peers with a recent handshake; and of each site-to-site connection
is up. The interfaces and the enabled systems are those vpn-router-setup resolved, read from that is set up, whether it is up, the bytes and packets per second through its child SAs since
/run/vpn-router/web.json. 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 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 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() counts['openvpn'] = vpnrouter_status.openvpn_clients()
if state.get('wireguard'): if state.get('wireguard'):
counts['wireguard'] = vpnrouter_status.wireguard_connected() 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 return counts
@@ -121,6 +119,8 @@ def sample(previous):
memory = psutil.virtual_memory() memory = psutil.virtual_memory()
disk = psutil.disk_usage(MOUNTPOINT) disk = psutil.disk_usage(MOUNTPOINT)
load1, load5, load15 = os.getloadavg() 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() now = time.time()
current = { current = {
'time': now, '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}, 'counters': {name: (counters[name].bytes_recv, counters[name].bytes_sent) for name in names if name in counters},
'clients': clients(state), 'clients': clients(state),
'rates': {}, 'rates': {},
'tunnels': tunnels or {},
'site_to_site': {},
} }
if previous is not None: elapsed = current['monotonic'] - previous['monotonic'] if previous is not None else 0
elapsed = current['monotonic'] - previous['monotonic'] if elapsed > 0:
for name, (received, sent) in current['counters'].items(): 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] before_received, before_sent = previous['counters'][name]
current['rates'][name] = (max(received - before_received, 0) / elapsed, max(sent - before_sent, 0) / elapsed) 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 return current
@@ -162,7 +175,7 @@ def store(cfg, current, done):
""" """
now = periods(datetime.datetime.now(datetime.timezone.utc)) now = periods(datetime.datetime.now(datetime.timezone.utc))
with vpnrouter_metrics.database(cfg) as connection: 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: for level, _, _ in vpnrouter_metrics.LEVELS:
if done.get(level) != now[level]: if done.get(level) != now[level]:
vpnrouter_metrics.summarize(connection, 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.5m', '{thread}', [(system['load5'], {})]),
gauge('system.cpu.load_average.15m', '{thread}', [(system['load15'], {})]), 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.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': { {'name': 'system.network.io', 'unit': 'By', 'sum': {
'aggregationTemporality': 2, 'isMonotonic': True, 'dataPoints': [ 'aggregationTemporality': 2, 'isMonotonic': True, 'dataPoints': [
{'startTimeUnixNano': start, 'timeUnixNano': stamp, 'asInt': str(value), {'startTimeUnixNano': start, 'timeUnixNano': stamp, 'asInt': str(value),
@@ -295,12 +319,30 @@ class Sample:
network.add_metric([name, 'receive'], received) network.add_metric([name, 'receive'], received)
network.add_metric([name, 'transmit'], sent) network.add_metric([name, 'transmit'], sent)
yield network yield network
vpn = GaugeMetricFamily('vpn_router_clients', 'Connected VPN clients; site_to_site is 1 while the tunnel is up', vpn = GaugeMetricFamily('vpn_router_clients', 'Connected VPN clients', labels=['kind'])
labels=['kind'])
for kind, count in self.current['clients'].items(): for kind, count in self.current['clients'].items():
if count is not None: if count is not None:
vpn.add_metric([kind], count) vpn.add_metric([kind], count)
yield vpn 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): def authorized(header, account):
+26 -18
View File
@@ -294,7 +294,6 @@ def nat_and_mangle_block(ctx):
if vpnrouter.routing_ready(ctx): if vpnrouter.routing_ready(ctx):
subnets = ctx['local_cidr_list'] subnets = ctx['local_cidr_list']
remote = ctx['remote_cidr_list']
lines += [ lines += [
'', '',
'# ROUTER NAT RULES START', '# ROUTER NAT RULES START',
@@ -302,11 +301,14 @@ def nat_and_mangle_block(ctx):
':POSTROUTING ACCEPT [0:0]', ':POSTROUTING ACCEPT [0:0]',
'-F POSTROUTING', '-F POSTROUTING',
] ]
# Every RETURN precedes every MASQUERADE, so tunnel-bound traffic # Every RETURN precedes every MASQUERADE, so traffic bound for a tunnel
# escapes NAT whichever local subnet it came from. # escapes NAT; each ready connection pairs its local and remote subnets.
for subnet in subnets: for connection in ctx['connections']:
for cidr in remote: if not connection['ready']:
lines.append(f'-A POSTROUTING -s {subnet} -d {cidr} -j RETURN') 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: for subnet in subnets:
lines.append( lines.append(
f'-A POSTROUTING -s {subnet} -o {ctx["wan_iface"]} -j MASQUERADE') 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 configuration without its secret options and the facts resolved from
the system, which it cannot work out with the standard library alone. The 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 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 = { def public(options):
section: {option: value for option, value in options.items() if not SECRET_OPTION.search(option)} return {option: public(value) if isinstance(value, dict) else value
for section, options in cfg.items() 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.parent.mkdir(parents=True, exist_ok=True)
WEB_STATE.touch(mode=0o600) WEB_STATE.touch(mode=0o600)
WEB_STATE.chmod(0o600) WEB_STATE.chmod(0o600)
WEB_STATE.write_text(json.dumps({ 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)), 'p2s': bool(vpnrouter.p2s_ready(ctx)),
'wireguard': bool(vpnrouter.wg_ready(ctx)), 'wireguard': bool(vpnrouter.wg_ready(ctx)),
'openvpn': bool(vpnrouter.openvpn_ready(ctx)), 'openvpn': bool(vpnrouter.openvpn_ready(ctx)),
'configuration': public, 'configuration': public(cfg),
'network': { 'network': {
'external_interface': ctx['wan_iface'], 'external_interface': ctx['wan_iface'],
'internal_interface': ctx['int_iface'], 'internal_interface': ctx['int_iface'],
'external_addresses': ctx['local_addr_list'], 'external_addresses': ctx['local_addr_list'],
'internal_address': ctx['int_addr'], 'internal_address': ctx['int_addr'],
'internal_gateway': ctx['int_gateway_ip'], 'internal_gateway': ctx['int_gateway_ip'],
'local_identity': ctx['local_id'],
'remote_identity': ctx['remote_id'],
'local_cidrs': ctx['local_cidr_list'], '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') }) + '\n', encoding='utf-8')
@@ -666,10 +674,10 @@ def main():
if vpnrouter.routing_ready(ctx): if vpnrouter.routing_ready(ctx):
apply_routes(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') progress('running', 'public address')
address = resolve_public_ip(ctx['local_fqdn']) 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 changed |= more
progress('running', 'WireGuard keys') 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'. network required. Used by both 'configure' and 'setup'.
""" """
import contextlib
import fcntl
import ipaddress import ipaddress
import json import json
import os import os
@@ -12,6 +14,7 @@ import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
import tempfile
import urllib.parse import urllib.parse
import jinja2 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 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_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') TEMPLATE_DIR = pathlib.Path('/usr/share/vpn-router/templates')
PKI_DIR = pathlib.Path('/etc/vpn-router/pki') PKI_DIR = pathlib.Path('/etc/vpn-router/pki')
SIMPLE_CA = LIB_DIR / 'simple-ca' SIMPLE_CA = LIB_DIR / 'simple-ca'
SWANCTL_CONF_D = pathlib.Path('/etc/swanctl/conf.d') 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' ROAD_WARRIOR = SWANCTL_CONF_D / 'road-warrior.conf'
P2S_FORWARDER = pathlib.Path('/etc/systemd/resolved.conf.d/p2s-forwarder.conf') P2S_FORWARDER = pathlib.Path('/etc/systemd/resolved.conf.d/p2s-forwarder.conf')
WG_CONF = pathlib.Path('/etc/wireguard/wg0.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 = { SCHEMA = {
'general': {'platform': 'none', 'mode': 'manual'}, 'general': {'platform': 'none', 'mode': 'manual'},
'interfaces': {'external': '', 'internal': ''}, 'interfaces': {'external': '', 'internal': ''},
'wan': {'local_fqdn': '', 'local_id_mode': 'fqdn'}, 'wan': {'local_fqdn': ''},
'local': {'cidrs': [], 'int_addr': '', 'int_gateway_ip': ''}, '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'}, 'p2s': {'enabled': False, 'address_pool': '', 'ca_name': 'VPN Router CA'},
'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820}, 'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820},
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'}, 'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
@@ -64,6 +68,15 @@ SCHEMA = {
'keep_monthly': '1 year', 'keep_yearly': '10 years'}, '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. #: 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') 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 DOCKER_PORT_LIMIT = 15
TYPE_NAMES = {str: 'a string', list: 'a list of strings', bool: 'a boolean', 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') ID_MODES = ('fqdn', 'public_ip', 'internal_ip')
@@ -118,29 +131,76 @@ def fail(message):
sys.exit(f'vpn-router: error: {message}') sys.exit(f'vpn-router: error: {message}')
def load_config(path=None): def read_settings(path=None):
"""Read the configuration, filling in defaults for anything absent. """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 Raises ConfigError when the file is not JSON or not an object of sections.
SCHEMA are kept so that validate() can report them. """
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 The path is resolved when called, not when this function is defined, so
CONFIG_FILE stays overridable. CONFIG_FILE stays overridable.
""" """
path = pathlib.Path(path) if path else CONFIG_FILE try:
cfg = {section: dict(options) for section, options in SCHEMA.items()} return with_defaults(read_settings(path))
if path.exists(): except ConfigError as exc:
try: fail(str(exc))
data = json.loads(path.read_text(encoding='utf-8'))
except ValueError as exc:
fail(f'cannot parse {path}: {exc}') @contextlib.contextmanager
if not isinstance(data, dict): def config_lock():
fail(f'cannot parse {path}: the top level is not an object') """Hold the lock of the configuration file from reading it to writing it with save_config."""
for section, options in data.items(): CONFIG_LOCK.parent.mkdir(parents=True, exist_ok=True)
if not isinstance(options, dict): with open(CONFIG_LOCK, 'a', encoding='utf-8') as handle:
fail(f'cannot parse {path}: {section} is not an object') fcntl.flock(handle, fcntl.LOCK_EX)
cfg.setdefault(section, {}).update(options) yield
return cfg
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): def _split_list(value):
@@ -152,23 +212,37 @@ def _get(cfg, section, option):
return value.strip() if isinstance(value, str) else value 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): 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): def _check_types(cfg):
for section, options in cfg.items(): for section, options in cfg.items():
if section not in SCHEMA: if section not in SCHEMA:
raise ConfigError(f'{section}: unknown section') raise ConfigError(f'{section}: unknown section')
for option, value in options.items(): if section != 'site_to_site':
if option not in SCHEMA[section]: _check_options(options, SCHEMA[section], section)
raise ConfigError(f'{section}.{option}: unknown setting') continue
expected = type(SCHEMA[section][option]) for name, connection in options.items():
if type(value) is not expected or ( if not isinstance(connection, dict):
expected is list and raise ConfigError(f'site_to_site.{name} must be {TYPE_NAMES[dict]}')
not all(isinstance(item, str) for item in value)): _check_options(connection, CONNECTION, f'site_to_site.{name}')
raise ConfigError(
f'{section}.{option} must be {TYPE_NAMES[expected]}')
def _check_networks(values, name): def _check_networks(values, name):
@@ -220,19 +294,28 @@ def validate(cfg):
raise ConfigError( raise ConfigError(
f'{section}.{option} is required in mode {mode or "manual"}') f'{section}.{option} is required in mode {mode or "manual"}')
for section, option in (('wan', 'local_fqdn'), ('remote', 'id')): identities = [('wan.local_fqdn', _get(cfg, 'wan', 'local_fqdn'))]
if str(_get(cfg, section, option)).startswith('@'): for name, connection in cfg['site_to_site'].items():
prefix = f'site_to_site.{name}'
if not CONNECTION_NAME.match(name):
raise ConfigError( 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') '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, 'local', 'cidrs'), 'local.cidrs')
_check_networks(_get_list(cfg, 'remote', 'cidrs'), 'remote.cidrs')
pool = _get(cfg, 'p2s', 'address_pool') pool = _get(cfg, 'p2s', 'address_pool')
_check_networks([pool] if pool else [], 'p2s.address_pool') _check_networks([pool] if pool else [], 'p2s.address_pool')
openvpn_pool = _get(cfg, 'openvpn', 'address_pool') openvpn_pool = _get(cfg, 'openvpn', 'address_pool')
@@ -355,15 +438,15 @@ def validate(cfg):
'a range counting as two') 'a range counting as two')
def resolve_psk(cfg): def resolve_psk(connection, name):
"""Return the pre-shared key, or '' when none is configured.""" """Return the pre-shared key of the site_to_site connection: psk_file wins, else psk; '' when none is configured."""
psk_file = _get(cfg, 'remote', 'psk_file') psk_file = connection['psk_file'].strip()
if psk_file: if psk_file:
path = pathlib.Path(psk_file) path = pathlib.Path(psk_file)
if not path.is_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 path.read_text(encoding='utf-8').strip()
return cfg['remote']['psk'] return connection['psk']
def platform_module(cfg): def platform_module(cfg):
@@ -447,29 +530,43 @@ def resolve_interfaces(cfg):
return ext_iface, int_iface, local_addrs, int_addr, gateway return ext_iface, int_iface, local_addrs, int_addr, gateway
def build_context(cfg, local_id_override=None): def build_context(cfg, public_ip=None):
"""Assemble the template context from the configuration.""" """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)) extras = vpnrouter_platforms.context(platform_module(cfg))
ext_iface, int_iface, local_addrs, int_addr, gateway = resolve_interfaces(cfg) ext_iface, int_iface, local_addrs, int_addr, gateway = resolve_interfaces(cfg)
local_fqdn = _get(cfg, 'wan', 'local_fqdn') local_fqdn = _get(cfg, 'wan', 'local_fqdn')
id_mode = _get(cfg, 'wan', 'local_id_mode') or 'fqdn'
local_cidrs = _get_list(cfg, 'local', 'cidrs') local_cidrs = _get_list(cfg, 'local', 'cidrs')
remote_cidrs = _get_list(cfg, 'remote', 'cidrs')
openvpn_pool = _get(cfg, 'openvpn', 'address_pool') openvpn_pool = _get(cfg, 'openvpn', 'address_pool')
openvpn_network = ipaddress.ip_network(openvpn_pool) if openvpn_pool else None openvpn_network = ipaddress.ip_network(openvpn_pool) if openvpn_pool else None
identities = {'fqdn': local_fqdn, 'public_ip': public_ip or '', 'internal_ip': int_addr}
if local_id_override: connections = []
local_id = local_id_override for name, connection in cfg['site_to_site'].items():
elif id_mode == 'fqdn': id_mode = connection['local_id_mode'].strip() or 'fqdn'
local_id = local_fqdn remote_cidrs = _clean(connection['cidrs'])
elif id_mode == 'internal_ip': connection_cidrs = _clean(connection['local_cidrs'])
local_id = int_addr entry = {
else: 'name': name,
# public_ip is resolved once the network is up, and the caller then 'remote_addrs': ', '.join(_clean(connection['addrs'])),
# re-renders with local_id_override set. 'remote_id': connection['id'].strip(),
local_id = '' '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 { return {
'platform': _get(cfg, 'general', 'platform') or PLATFORM_NONE, '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_addrs': ', '.join(local_addrs),
'local_addr_list': local_addrs, 'local_addr_list': local_addrs,
'local_fqdn': local_fqdn, 'local_fqdn': local_fqdn,
'local_id_mode': id_mode,
'local_id': local_id,
'local_cidrs': ', '.join(local_cidrs), 'local_cidrs': ', '.join(local_cidrs),
'local_cidr_list': local_cidrs, 'local_cidr_list': local_cidrs,
'local_ts': _join(local_cidrs, _split_list(extras['EXTRA_LOCAL_TS'])), 'local_ts': _join(local_cidrs, _split_list(extras['EXTRA_LOCAL_TS'])),
'int_addr': int_addr, 'int_addr': int_addr,
'int_gateway_ip': gateway, 'int_gateway_ip': gateway,
'remote_addrs': ', '.join(_get_list(cfg, 'remote', 'addrs')), 'connections': connections,
'remote_id': _get(cfg, 'remote', 'id'),
'remote_cidrs': ', '.join(remote_cidrs),
'remote_cidr_list': remote_cidrs,
'psk': resolve_psk(cfg),
'p2s_enabled': _get(cfg, 'p2s', 'enabled'), 'p2s_enabled': _get(cfg, 'p2s', 'enabled'),
'p2s_address_pool': _get(cfg, 'p2s', 'address_pool'), 'p2s_address_pool': _get(cfg, 'p2s', 'address_pool'),
'p2s_ca_name': _get(cfg, 'p2s', 'ca_name'), 'p2s_ca_name': _get(cfg, 'p2s', 'ca_name'),
@@ -530,11 +621,8 @@ def build_context(cfg, local_id_override=None):
def s2s_ready(ctx): def s2s_ready(ctx):
return all([ """True when any site_to_site connection is ready."""
ctx['local_addrs'], ctx['local_fqdn'], ctx['local_cidrs'], return any(connection['ready'] for connection in ctx['connections'])
ctx['remote_addrs'], ctx['remote_id'], ctx['remote_cidrs'],
ctx['psk'], ctx['local_id'],
])
def routing_ready(ctx): def routing_ready(ctx):
@@ -699,7 +787,7 @@ def distribute_pki(ctx):
return changed return changed
def configure(cfg=None, local_id_override=None): def configure(cfg=None, public_ip=None):
"""Render configuration files and provision PKI. """Render configuration files and provision PKI.
Returns (ctx, changed) where changed is a set naming what needs reloading: Returns (ctx, changed) where changed is a set naming what needs reloading:
@@ -715,7 +803,7 @@ def configure(cfg=None, local_id_override=None):
try: try:
validate(cfg) validate(cfg)
ctx = build_context(cfg, local_id_override=local_id_override) ctx = build_context(cfg, public_ip=public_ip)
except ConfigError as exc: except ConfigError as exc:
fail(str(exc)) fail(str(exc))
@@ -728,9 +816,9 @@ def configure(cfg=None, local_id_override=None):
env = _jinja_env() env = _jinja_env()
if s2s_ready(ctx): 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') changed.add('swanctl')
elif remove(REMOTE_SITE): elif remove(SITE_TO_SITE):
changed.add('swanctl') changed.add('swanctl')
if p2s_ready(ctx): 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 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 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, 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 <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 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_system': ((), ('cpu', 'memory_used', 'memory_total', 'disk_free', 'disk_total', 'load1', 'load5', 'load15')),
'metrics_network': (('interface',), ('rx', 'tx')), 'metrics_network': (('interface',), ('rx', 'tx')),
'metrics_vpn': (('kind',), ('clients',)), '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. #: 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'), ('yearly', 'year', 'keep_yearly'),
) )
#: The kinds of VPN clients of metrics_vpn; site_to_site is 1 while the tunnel is up. #: The kinds of VPN clients of metrics_vpn.
VPN_KINDS = ('p2s', 'openvpn', 'wireguard', 'site_to_site') VPN_KINDS = ('p2s', 'openvpn', 'wireguard')
#: The views of history(): the summary level read (None: the details), the bucket and the span. #: The views of history(): the summary level read (None: the details), the bucket and the span.
RANGES = { RANGES = {
@@ -82,11 +85,12 @@ def database(cfg):
raise DatabaseError(f'the metrics database failed: {exc}') from exc 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. """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 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(): with connection.transaction():
minute = connection.execute("SELECT date_trunc('minute', now(), 'UTC')").fetchone()[0] 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(): for kind, clients in vpn.items():
connection.execute('INSERT INTO metrics_vpn (time, kind, clients) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING', connection.execute('INSERT INTO metrics_vpn (time, kind, clients) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING',
(minute, kind, clients)) (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): 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. """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 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 each interface to its rx and tx, clients, which maps each kind of VPN client to its clients,
clients; a value without data is None. 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] level, bucket, span = RANGES[view]
@@ -161,7 +171,7 @@ def history(connection, view):
points = {} points = {}
def point(start): 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'): for row in rows('metrics_system'):
point(row[0]).update(pairs(row[1:], TABLES['metrics_system'][1])) 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]) point(row[0])['network'][row[1]] = pairs(row[2:], TABLES['metrics_network'][1])
for row in rows('metrics_vpn'): for row in rows('metrics_vpn'):
point(row[0])['clients'][row[1]] = pairs(row[2:], TABLES['metrics_vpn'][1])['clients'] 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]} 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)]} 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. """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 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 import subprocess
@@ -65,12 +66,45 @@ def swanctl(command):
return [dict(body, name=name) for event in events for name, body in event.items()] return [dict(body, name=name) for event in events for name, body in event.items()]
def established(connection): def established_each(connections):
"""The number of established IKE SAs of the connection, or None when swanctl cannot be asked.""" """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') sas = swanctl('--list-sas')
if sas is None: if sas is None:
return 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(): def wireguard_peers():
+95 -10
View File
@@ -17,6 +17,9 @@ Pages:
add and take out members (see groups()) add and take out members (see groups())
/account the account of the signed-in user: its details, its password and its TOTP /account the account of the signed-in user: its details, its password and its TOTP
enrolment (see account()) 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()) /auth sign-in with a user's password and TOTP code, refresh and sign-out (see auth())
/metrics/history /metrics/history
the metrics the metrics service keeps, for a view: hour, day, month or year (see the metrics the metrics service keeps, for a view: hour, day, month or year (see
@@ -25,7 +28,7 @@ Pages:
the databases of each PostgreSQL cluster on the router with their sizes, as JSON (see the databases of each PostgreSQL cluster on the router with their sizes, as JSON (see
postgresql_databases()); /config has the clusters' settings 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, /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; 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 /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 token of vpn-router-token has the admin claim and names no user; it may call every path but
@@ -39,7 +42,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 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 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 (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 (IPsec P2S), OpenVPN and WireGuard. No configuration data or key is shown, and no address but the
client's. client's.
@@ -80,7 +83,7 @@ import vpnrouter_metrics
import vpnrouter_net import vpnrouter_net
import vpnrouter_serve import vpnrouter_serve
import vpnrouter_users 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' STATE_FILE = '/run/vpn-router/web.json'
CONFIG_FILE = '/etc/vpn-router/vpn-router.json' CONFIG_FILE = '/etc/vpn-router/vpn-router.json'
@@ -111,6 +114,9 @@ POSTGRESQL_SETTINGS = ('server_version', 'listen_addresses', 'port', 'max_connec
#: The largest request body accepted, in bytes. #: The largest request body accepted, in bytes.
BODY_LIMIT = 4096 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. #: 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} USER_STATUS = {2: 400, 3: 404, 4: 409, 5: 401}
@@ -202,9 +208,10 @@ def collect(state):
'units': units, 'units': units,
'docker': docker_status(), 'docker': docker_status(),
} }
if state.get('site_to_site'): names = state.get('site_to_site')
count = established('remote-site') if isinstance(names, list) and names:
status['site_to_site'] = {'established': None if count is None else count > 0} 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'): if state.get('p2s'):
status['p2s'] = {'clients': established('road-warrior')} status['p2s'] = {'clients': established('road-warrior')}
if state.get('wireguard'): if state.get('wireguard'):
@@ -365,9 +372,8 @@ def text_of(status):
docker = status['docker'] docker = status['docker']
lines.append('Docker: not installed' if not docker['installed'] lines.append('Docker: not installed' if not docker['installed']
else f'Docker: installed, service {docker["service"]}, rules {docker["rules"]}') else f'Docker: installed, service {docker["service"]}, rules {docker["rules"]}')
if 'site_to_site' in status: for name, value in status.get('site_to_site', {}).get('connections', {}).items():
value = status['site_to_site']['established'] lines.append(f'Site to site {name}: {"unknown" if value is None else "established" if value else "not established"}')
lines.append(f'Site to site: {"unknown" if value is None else "established" if value else "not established"}')
if 'p2s' in status: if 'p2s' in status:
clients = status['p2s']['clients'] clients = status['p2s']['clients']
lines.append(f'Road-warrior (IPsec P2S): {"unknown" if clients is None else f"{clients} connected"}') lines.append(f'Road-warrior (IPsec P2S): {"unknown" if clients is None else f"{clients} connected"}')
@@ -527,6 +533,81 @@ def groups(state, method, path, body):
return (201 if route == ('POST', 1, None) else 200), document 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): 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. """Answer an /account request of the user named by the token with vpnrouter_users: the HTTP status and the JSON document.
@@ -714,7 +795,8 @@ class Handler(BaseHTTPRequestHandler):
# A body that is not read must not be taken for the next request. # A body that is not read must not be taken for the next request.
if self.command not in ('GET', 'HEAD'): if self.command not in ('GET', 'HEAD'):
self.close_connection = True 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', '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 protected = path in ('/config', '/metrics/history', '/postgresql') or directory is not None
signing = path in ('/auth', '/auth/refresh', '/auth/logout') signing = path in ('/auth', '/auth/refresh', '/auth/logout')
state = current() if protected or signing else {} state = current() if protected or signing else {}
@@ -751,6 +833,9 @@ class Handler(BaseHTTPRequestHandler):
elif directory == 'account': elif directory == 'account':
code, document = account(claims['sub'], self.command, path, payload) code, document = account(claims['sub'], self.command, path, payload)
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json' 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 is not None: elif directory is not None:
code, document = (users if directory == 'users' else groups)(state, self.command, path, payload) 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' body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
+59 -56
View File
@@ -2,23 +2,26 @@
"""Show and change the router's configuration, /etc/vpn-router/vpn-router.json. """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 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 section.option, such as web.port, or as site_to_site.NAME.option for a site-to-site connection,
schema (vpnrouter.SCHEMA) gives the setting: a whole number, true or false, a list of strings which it creates with the defaults (vpnrouter.CONNECTION) when it does not exist, and a value,
separated by commas (an empty value is an empty list), or a string. The configuration with the which it converts to the type the package's schema (vpnrouter.SCHEMA) gives the setting: a whole
new value is checked as vpn-router-setup checks it, and only then written, indented, with mode number, true or false, a list of strings separated by commas (an empty value is an empty list),
0600; vpn-router-setup applies it at its next run, which set reminds of. A failure is a message or a string. remove takes site_to_site.NAME and removes that connection. The configuration with
on standard error; the exit status is 2 for an invalid request and 1 for any other failure. the change is checked as vpn-router-setup checks it, and only then written with
-h or --help shows the options. 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 show
vpn-config-edit set SECTION.OPTION VALUE 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 argparse
import copy
import json import json
import os
import sys import sys
import tempfile
sys.path.insert(0, '/usr/lib/vpn-router') sys.path.insert(0, '/usr/lib/vpn-router')
@@ -43,48 +46,33 @@ class Parser(argparse.ArgumentParser):
def read(): def read():
"""The settings in the configuration file, without the defaults; {} when the file does not exist.""" """The settings in the configuration file, without the defaults; {} when the file does not exist."""
try: try:
data = json.loads(vpnrouter.CONFIG_FILE.read_text(encoding='utf-8')) return vpnrouter.read_settings()
except FileNotFoundError: except vpnrouter.ConfigError as exc:
return {} raise Failure(str(exc))
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
def converted(key, text): def converted(key, text):
"""The value of text for the setting key, section.option, of the type the schema gives it.""" """The path of the setting key, section.option or site_to_site.NAME.option, and text as the type the schema gives it."""
section, _, option = key.partition('.') path = key.split('.')
if section not in vpnrouter.SCHEMA or option not in vpnrouter.SCHEMA[section]: if path[0] == 'site_to_site' and len(path) == 3 and path[2] in vpnrouter.CONNECTION:
raise Failure(f'{key}: unknown setting; settings are section.option, such as web.port', 2) kind = type(vpnrouter.CONNECTION[path[2]])
kind = type(vpnrouter.SCHEMA[section][option]) 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 kind is bool:
if text not in ('true', 'false'): if text not in ('true', 'false'):
raise Failure(f'{key} takes true or false', 2) raise Failure(f'{key} takes true or false', 2)
return text == 'true' return path, text == 'true'
if kind is int: if kind is int:
try: try:
return int(text) return path, int(text)
except ValueError: except ValueError:
raise Failure(f'{key} takes a whole number', 2) raise Failure(f'{key} takes a whole number', 2)
if kind is list: if kind is list:
return [item.strip() for item in text.split(',') if item.strip()] return path, [item.strip() for item in text.split(',') if item.strip()]
return text return path, 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
def main(argv): def main(argv):
@@ -93,34 +81,49 @@ def main(argv):
commands = parser.add_subparsers(dest='command', required=True, parser_class=Parser) commands = parser.add_subparsers(dest='command', required=True, parser_class=Parser)
commands.add_parser('show', help='print the configuration as indented JSON') 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 = 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') 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) options = parser.parse_args(argv)
data = read()
if options.command == 'show': if options.command == 'show':
print(json.dumps(data, indent=2)) print(json.dumps(read(), indent=2))
return 0 return 0
section, _, option = options.key.partition('.') with vpnrouter.config_lock():
value = converted(options.key, options.value) data = read()
data.setdefault(section, {})[option] = value if options.command == 'set':
# The configuration as vpn-router-setup reads it: the defaults with the file's settings. path, value = converted(options.key, options.value)
candidate = {name: dict(defaults) for name, defaults in vpnrouter.SCHEMA.items()} if path[0] == 'site_to_site':
for name, settings in data.items(): connections = data.setdefault('site_to_site', {})
candidate.setdefault(name, {}).update(settings) connection = connections.setdefault(path[1], copy.deepcopy(vpnrouter.CONNECTION))
try: if not isinstance(connection, dict):
vpnrouter.validate(candidate) raise Failure(f'site_to_site.{path[1]} is not an object')
except vpnrouter.ConfigError as exc: connection[path[2]] = value
raise Failure(str(exc), 2) else:
write(data) 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: except Failure as failure:
print(f'vpn-config-edit: {failure}', file=sys.stderr) print(f'vpn-config-edit: {failure}', file=sys.stderr)
return failure.status return failure.status
except OSError as exc: except OSError as exc:
print(f'vpn-config-edit: {exc}', file=sys.stderr) print(f'vpn-config-edit: {exc}', file=sys.stderr)
return 1 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') print('Apply it with: systemctl restart vpn-router-setup')
return 0 return 0
@@ -8,20 +8,23 @@
"internal": "eth1" "internal": "eth1"
}, },
"wan": { "wan": {
"local_fqdn": "router.example.com", "local_fqdn": "router.example.com"
"local_id_mode": "fqdn"
}, },
"local": { "local": {
"cidrs": ["10.0.0.0/24"], "cidrs": ["10.0.0.0/24"],
"int_addr": "10.1.1.4", "int_addr": "10.1.1.4",
"int_gateway_ip": "10.1.1.1" "int_gateway_ip": "10.1.1.1"
}, },
"remote": { "site_to_site": {
"addrs": ["peer.example.net"], "remote-site": {
"id": "peer.example.net", "addrs": ["peer.example.net"],
"cidrs": ["192.168.0.0/24"], "id": "peer.example.net",
"psk": "change-me", "cidrs": ["192.168.0.0/24"],
"psk_file": "" "local_cidrs": ["10.0.0.0/24"],
"local_id_mode": "fqdn",
"psk": "change-me",
"psk_file": ""
}
}, },
"p2s": { "p2s": {
"enabled": false, "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 %}
}
+11 -7
View File
@@ -15,13 +15,17 @@ locals {
# Contents of /etc/vpn-router/vpn-router.json. # Contents of /etc/vpn-router/vpn-router.json.
config = { config = {
general = { platform = "azure", mode = "auto" } general = { platform = "azure", mode = "auto" }
wan = { local_fqdn = var.local_fqdn, local_id_mode = var.local_id_mode } wan = { local_fqdn = var.local_fqdn }
local = { cidrs = [for c in split(",", var.local_cidrs) : trimspace(c) if trimspace(c) != ""] } local = { cidrs = local.local_cidr_list }
remote = { site_to_site = {
addrs = [for a in split(",", var.remote_addrs) : trimspace(a) if trimspace(a) != ""] "remote-site" = {
id = var.remote_id addrs = [for a in split(",", var.remote_addrs) : trimspace(a) if trimspace(a) != ""]
cidrs = local.remote_cidr_list id = var.remote_id
psk = var.psk 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" } 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 } wireguard = { enabled = var.wireguard_enabled, address = var.wireguard_address, listen_port = var.wireguard_listen_port }
+1
View File
@@ -1,5 +1,6 @@
locals { locals {
remote_cidr_list = [for c in split(",", var.remote_cidrs) : trimspace(c) if trimspace(c) != ""] 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" { resource "azurerm_route_table" "router" {
+11 -7
View File
@@ -15,13 +15,17 @@ locals {
# Contents of /etc/vpn-router/vpn-router.json. # Contents of /etc/vpn-router/vpn-router.json.
config = { config = {
general = { platform = "gcp", mode = "auto" } general = { platform = "gcp", mode = "auto" }
wan = { local_fqdn = var.local_fqdn, local_id_mode = var.local_id_mode } wan = { local_fqdn = var.local_fqdn }
local = { cidrs = [for c in split(",", var.local_cidrs) : trimspace(c) if trimspace(c) != ""] } local = { cidrs = local.local_cidr_list }
remote = { site_to_site = {
addrs = [for a in split(",", var.remote_addrs) : trimspace(a) if trimspace(a) != ""] "remote-site" = {
id = var.remote_id addrs = [for a in split(",", var.remote_addrs) : trimspace(a) if trimspace(a) != ""]
cidrs = local.remote_cidr_list id = var.remote_id
psk = var.psk 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" } 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 } wireguard = { enabled = var.wireguard_enabled, address = var.wireguard_address, listen_port = var.wireguard_listen_port }
+1
View File
@@ -1,5 +1,6 @@
locals { locals {
remote_cidr_list = [for c in split(",", var.remote_cidrs) : trimspace(c) if trimspace(c) != ""] 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" { resource "google_compute_route" "to_remote" {
+10 -7
View File
@@ -47,19 +47,22 @@ cat > /etc/vpn-router/vpn-router.json <<EOF
"internal": "${INTERNAL_INTERFACE}" "internal": "${INTERNAL_INTERFACE}"
}, },
"wan": { "wan": {
"local_fqdn": "${LOCAL_FQDN}", "local_fqdn": "${LOCAL_FQDN}"
"local_id_mode": "${LOCAL_ID_MODE}"
}, },
"local": { "local": {
"cidrs": ${LOCAL_CIDRS}, "cidrs": ${LOCAL_CIDRS},
"int_addr": "${INT_ADDR}", "int_addr": "${INT_ADDR}",
"int_gateway_ip": "${INT_GATEWAY_IP}" "int_gateway_ip": "${INT_GATEWAY_IP}"
}, },
"remote": { "site_to_site": {
"addrs": ${REMOTE_ADDRS}, "remote-site": {
"id": "${REMOTE_ID}", "addrs": ${REMOTE_ADDRS},
"cidrs": ${REMOTE_CIDRS}, "id": "${REMOTE_ID}",
"psk_file": "/etc/vpn-router/psk" "cidrs": ${REMOTE_CIDRS},
"local_cidrs": ${LOCAL_CIDRS},
"local_id_mode": "${LOCAL_ID_MODE}",
"psk_file": "/etc/vpn-router/psk"
}
}, },
"p2s": { "p2s": {
"enabled": ${P2S_ENABLED}, "enabled": ${P2S_ENABLED},