From 4a5b15a559917d2b8e19911627d7a15b0e4eb1e1 Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Sun, 25 Oct 2026 17:14:23 +0100 Subject: [PATCH] feature: site_to_site holds any number of named site-to-site connections, which /site-to-site lists, adds, changes and removes. --- ADR.md | 98 ++++++- README.md | 135 +++++++--- debian-package/debian/changelog | 40 +++ debian-package/debian/postrm | 2 +- debian-package/debian/prerm | 7 +- debian-package/debian/templates | 17 +- .../src/usr/lib/vpn-router/generate-config | 83 +++--- debian-package/src/usr/lib/vpn-router/metrics | 66 ++++- debian-package/src/usr/lib/vpn-router/setup | 44 ++-- .../src/usr/lib/vpn-router/vpnrouter.py | 240 ++++++++++++------ .../usr/lib/vpn-router/vpnrouter_metrics.py | 30 ++- .../usr/lib/vpn-router/vpnrouter_status.py | 42 ++- debian-package/src/usr/lib/vpn-router/web | 105 +++++++- debian-package/src/usr/sbin/vpn-config-edit | 115 +++++---- .../doc/vpn-router/vpn-router.json.example | 19 +- .../vpn-router/templates/remote-site.conf.j2 | 46 ---- .../vpn-router/templates/site-to-site.conf.j2 | 50 ++++ examples/azure/router.tf | 18 +- examples/azure/routes.tf | 1 + examples/gcp/router.tf | 18 +- examples/gcp/routes.tf | 1 + examples/install.sh | 17 +- 22 files changed, 843 insertions(+), 351 deletions(-) delete mode 100644 debian-package/src/usr/share/vpn-router/templates/remote-site.conf.j2 create mode 100644 debian-package/src/usr/share/vpn-router/templates/site-to-site.conf.j2 diff --git a/ADR.md b/ADR.md index 5defe5a..c1b664b 100644 --- a/ADR.md +++ b/ADR.md @@ -199,10 +199,15 @@ that a secret is set tells a reader more than it needs. Decision: The configuration that `vpn-router-setup` writes for the web service has no secret options at all. They are recognised by name: `psk`, `database`, whose connection string may hold -a password, and options with `secret`, `password`, `token` or a `_key` ending in the name. +a password, and options with `secret`, `password`, `token` or a `_key` ending in the name. The +names are checked at every level, so the settings of each site-to-site connection lose their +`psk` too; an object is kept whatever its name, so a connection named like a secret is still +shown. -Consequences: No secret reaches the web service's state file or any answer of the API. A client -cannot tell whether a secret is set. A new secret option needs a name that matches. +Consequences: No secret reaches the web service's state file or any answer of the API. Through +`/config` a client cannot tell whether a secret is set; `/site-to-site` says whether each +connection has a key (ADR-25), as an admin managing connections needs to know. A new secret +option needs a name that matches. ## ADR-9: The Router Manager is a separate package with a small Python server @@ -536,8 +541,8 @@ receiver. The router already runs PostgreSQL for the refresh tokens (ADR-15). Decision: A service of its own, `vpn-router-metrics`, takes a sample every minute with `psutil` (`python3-psutil`) and the VPN readers it shares with the web service (`vpnrouter_status`). It -keeps the samples in the router's database, in three detail tables (`metrics_system`, -`metrics_network`, `metrics_vpn`). Each detail table has hourly, daily, monthly and yearly summary +keeps the samples in the router's database, in four detail tables (`metrics_system`, +`metrics_network`, `metrics_vpn`, `metrics_site_to_site`, see ADR-25). Each detail table has hourly, daily, monthly and yearly summary tables, with the average and the maximum of each value per period, written when the clock passes a period from the level below with `INSERT ... ON CONFLICT DO NOTHING`, so a stopped service catches up. Garbage collection runs on start and once a day, not with every write, and deletes the rows @@ -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 disabled. A scraper on the external interface over plain HTTP sends the password in the clear, so `metrics.listen` stays `internal` unless HTTPS is used. + +## ADR-24: The Router Manager upgrades the router's packages in a transient unit outside its own + +Status: Accepted + +Context: An admin should upgrade `vpn-router` and `vpn-router-manager` from the Router Manager. The +upgrade restarts the process that starts it: the manager's postinst restarts +`vpn-router-manager.service`, and the router's restarts `vpn-router-web` and `vpn-router-setup`. A +child of the manager would be stopped with the manager's cgroup in the middle of dpkg, leaving +packages half configured, and the manager's unit cannot run apt at all, since `ProtectSystem=strict` +makes `/usr` and `/var/lib/dpkg` read-only. The package lists are updated by a cron job, not by the +manager. + +Decision: The manager starts the helper `/usr/lib/vpn-router-manager/upgrade` with `systemd-run` as +the transient unit `vpn-router-manager-upgrade`, of its own cgroup and without a sandbox, and does +not wait for it. The helper: + +- finishes an interrupted dpkg run with `dpkg --configure -a`; +- runs `apt-get install --only-upgrade` of the two packages, non-interactive, keeping changed configuration files, with `needrestart` suspended, and waiting for the dpkg lock that the cron job may hold; +- records the state and the result in `/var/lib/vpn-router-manager/upgrade.json`, replaced by a rename, and the output in `upgrade.log`. + +A restarted manager reads them again. A state of running without an active unit is shown as interrupted. The manager reboots the router the same way, as a transient timer `vpn-router-manager-reboot` 5 seconds later, so the answer reaches the browser first. Only members of `admins`, as the router's `/account` answers, may start either. + +Consequences: The fixed unit name allows one upgrade at a time. The page loses the manager and the +router's web service for a while during an upgrade and reads again until they answer. Sessions +survive in PostgreSQL. The manager can upgrade only the system it runs on, so a manager on another +host upgrades that host's packages, not the router's. The operating system's other packages are +left to unattended-upgrades. + +## ADR-25: Site-to-site connections are a map of named connections, edited through the API and applied by setup + +Status: Accepted + +Context: The router had one IPsec site-to-site tunnel, the `remote` section, run as the swanctl +connection `remote-site`, with the local identity mode in `wan` and the local subnets in `local`. +A router often links more than one site, each with its own peer, subnets and key, and an admin +should add them without a shell. The install answers and the Cloud Platform describe one +connection, and the platform rewrites `vpn-router.json` when it rebuilds the VM. + +Decision: `site_to_site` maps a connection name to its settings: `addrs`, `id`, `cidrs`, +`local_cidrs`, `local_id_mode`, `psk` and `psk_file`. `remote` and `wan.local_id_mode` are gone, +without a fallback for the old format. The name, a lowercase letter followed by at most 30 +lowercase letters, digits and hyphens, names the swanctl connection, its child and its secret +`ike-psk-`, 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. diff --git a/README.md b/README.md index 44fa9d7..6b18f5f 100644 --- a/README.md +++ b/README.md @@ -103,16 +103,11 @@ must have the type shown below; an unknown section or setting is an error. | `general` | `mode` | string | `manual` | How much you supply: `manual`, `interfaces` or `auto` | | `interfaces` | `external` | string | | Name of the NIC facing the untrusted network | | `interfaces` | `internal` | string | | Name of the NIC facing the protected network | -| `wan` | `local_fqdn` | string | | This router's name or IP address, used as its IKE identity in `fqdn` mode | -| `wan` | `local_id_mode` | string | `fqdn` | IKE identity source: `fqdn`, `public_ip` or `internal_ip` | -| `local` | `cidrs` | list | | Local subnets advertised into the tunnel | +| `wan` | `local_fqdn` | string | | This router's name or IP address: its IKE identity in `fqdn` mode, the name resolved in `public_ip` mode, and the name of HTTPS, road-warrior and OpenVPN | +| `local` | `cidrs` | list | | Local subnets routed via the internal side, masqueraded to the external one, and offered to road-warrior and OpenVPN clients | | `local` | `int_addr` | string | | This host's address on the internal network | | `local` | `int_gateway_ip` | string | | Next hop on the internal side | -| `remote` | `addrs` | list | | Remote gateway addresses or FQDNs | -| `remote` | `id` | string | | Remote IKE identity, an FQDN or an IPv4 or IPv6 address | -| `remote` | `cidrs` | list | | Remote subnets reachable through the tunnel | -| `remote` | `psk` | string | | Pre-shared key | -| `remote` | `psk_file` | string | | Path to a file holding the key; wins over `psk` | +| `site_to_site` | | object | | The site-to-site connections, each a name and an object of the settings below | | `p2s` | `enabled` | boolean | `false` | Road-warrior access | | `p2s` | `address_pool` | string | | CIDR assigned to road-warrior clients | | `p2s` | `ca_name` | string | `VPN Router CA` | Common name for a locally created CA | @@ -149,6 +144,29 @@ must have the type shown below; an unknown section or setting is an error. | `metrics` | `keep_monthly` | string | `1 year` | How long the monthly summaries are kept | | `metrics` | `keep_yearly` | string | `10 years` | How long the yearly summaries are kept | +### Site-to-site connections + +`site_to_site` maps a connection name to its settings. A name starts with a lowercase letter +and has at most 31 lowercase letters, digits and hyphens; it names the strongSwan connection, +its child and its secret (`ike-psk-`). The install answers fill the connection +`remote-site`; more are added in the file, with `vpn-config-edit`, or through `/site-to-site` +of the status web service, which the Router Manager uses. + +| Setting | Type | Default | Meaning | +|---|---|---|---| +| `addrs` | list | | Remote gateway addresses or FQDNs | +| `id` | string | | Remote IKE identity, an FQDN or an IPv4 or IPv6 address | +| `cidrs` | list | | Remote subnets reachable through the tunnel | +| `local_cidrs` | list | | Local subnets advertised into the tunnel | +| `local_id_mode` | string | `fqdn` | The router's IKE identity: `fqdn` is `wan.local_fqdn`, `public_ip` the address it resolves to, `internal_ip` the internal address | +| `psk` | string | | Pre-shared key | +| `psk_file` | string | | Path to a file holding the key; wins over `psk` | + +A connection is set up once all of `addrs`, `id`, `cidrs`, `local_cidrs`, the key and its +local identity are known; all such connections are written to +`/etc/swanctl/conf.d/site-to-site.conf`. Traffic from a connection's `local_cidrs` to its +`cidrs` is not masqueraded. + A minimal site-to-site configuration in `auto` mode: ```json @@ -156,16 +174,19 @@ A minimal site-to-site configuration in `auto` mode: "general": { "platform": "generic", "mode": "auto" }, "wan": { "local_fqdn": "router.example.com" }, "local": { "cidrs": ["10.0.0.0/24"] }, - "remote": { - "addrs": ["peer.example.net"], - "id": "peer.example.net", - "cidrs": ["192.168.0.0/24"], - "psk": "s3cr3t" + "site_to_site": { + "remote-site": { + "addrs": ["peer.example.net"], + "id": "peer.example.net", + "cidrs": ["192.168.0.0/24"], + "local_cidrs": ["10.0.0.0/24"], + "psk": "s3cr3t" + } } } ``` -The router's IKE identity (`wan.local_fqdn` in `fqdn` mode) and the remote one (`remote.id`) are +The router's IKE identity (`wan.local_fqdn` in `fqdn` mode) and the remote one (`id`) are an FQDN or an IPv4 or IPv6 address. They are written to the strongSwan configuration as given; strongSwan reads an address as an IP identity and a name as an FQDN, so a leading `@` is not needed and is refused. Road-warrior and OpenVPN access need a name in `wan.local_fqdn`. @@ -264,13 +285,18 @@ vpn-config-edit show vpn-config-edit set web.status_content standard vpn-config-edit set web.port 7082 vpn-config-edit set web.trusted_proxies 127.0.0.1,10.0.0.4 +vpn-config-edit set site_to_site.branch.addrs branch.example.net +vpn-config-edit remove site_to_site.branch ``` `show` prints the file as indented JSON and does not change it. `set` takes a setting as -`section.option` and converts the value to the setting's type: a whole number, `true` or -`false`, a list separated by commas (an empty value is an empty list), or a string; an unknown -setting is refused. It checks the whole configuration as `vpn-router-setup` does, and only then -writes the file, indented, with mode 0600, and reminds that `vpn-router-setup` applies it. A +`section.option`, or as `site_to_site.NAME.option` for a site-to-site connection, which it +creates with the defaults when it does not exist, and converts the value to the setting's type: +a whole number, `true` or `false`, a list separated by commas (an empty value is an empty list), +or a string; an unknown setting is refused. `remove site_to_site.NAME` removes a connection. It +checks the whole configuration as `vpn-router-setup` does, and only then writes the file, +indented, with mode 0600, and reminds that `vpn-router-setup` applies it. The status web service +writes the file the same way, and a lock keeps the two from overwriting each other's change. A refused request exits with status 2, any other failure with 1. ## Applying changes @@ -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 | | `standard` | Also the `vpn-router` version, the `vpn-router-manager` version or that it is not installed, and the number of active services and of services in any other state, without their names | -| `full` | Also each service and its state, Docker, and the state of site-to-site, road-warrior, OpenVPN and WireGuard | +| `full` | Also each service and its state, Docker, and the state of each site-to-site connection, road-warrior, OpenVPN and WireGuard | The Client IP is the address the request came from: the last entry of `X-Forwarded-For` for a request from one of `web.trusted_proxies`, by default the address the request came in on, so @@ -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 | | `/users` | The users of OpenVPN and road-warrior access; needs a token | | `/groups` | The groups of users; needs a token | +| `/site-to-site` | The site-to-site connections: list, add, change and remove; needs a token | | `/account` | The account of the signed-in user: its details, its password and its TOTP enrolment; needs a user's token | The status holds the state of `vpn-router-setup`, `slapd` and each PostgreSQL cluster unit @@ -591,8 +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, as `manager_version`, the installed version of `vpn-router-manager` (`null`, shown as not installed, without it); whether Docker is installed, and when it is, the state of `docker.service` and of `vpn-router-docker` -(the Docker rules), which do not change `status`; whether the -site-to-site tunnel is established; the number of connected road-warrior (IPsec P2S) clients; and +(the Docker rules), which do not change `status`; as `site_to_site.connections`, whether each +site-to-site connection that is set up is established (`null` when swanctl cannot be asked); the number of connected road-warrior (IPsec P2S) clients; and the WireGuard peers with the age of their last handshake and their traffic. `status` is `ok` when all these services are active, and `degraded` otherwise. @@ -603,7 +630,7 @@ curl http://10.0.0.4:7081/status ### Tokens and signing in -`/config`, `/users`, `/groups` and `/account` need a JWT in an `Authorization: Bearer` header, signed with +`/config`, `/users`, `/groups`, `/site-to-site` and `/account` need a JWT in an `Authorization: Bearer` header, signed with HS256 by the key the web service makes on its first run, `/etc/vpn-router/web.key` (root only). The key never leaves the router; while it does not exist these paths and `/auth` are not served (404). A missing, expired or wrongly signed token, or one of another algorithm, gets 401 with @@ -661,8 +688,8 @@ curl -H "Authorization: Bearer $TOKEN" http://10.0.0.4:7081/config | Key | Content | | --- | --- | -| `configuration` | `vpn-router.json` with the defaults applied, without its secret options (`psk`, `database`, and any option named like a secret, password, token or key) | -| `network` | The external and internal interface, their addresses, the internal gateway, the local and remote IKE identities and subnets, and the IPv4 routes as `ip -j -4 route` prints them | +| `configuration` | `vpn-router.json` with the defaults applied, without its secret options at any level (`psk`, `database`, and any option named like a secret, password, token or key) | +| `network` | The external and internal interface, their addresses, the internal gateway, `local.cidrs`, `site_to_site`: each connection with `name`, `local_identity`, `remote_identity`, `local_cidrs` and `remote_cidrs`, and the IPv4 routes as `ip -j -4 route` prints them | | `ipsec` | `connections` and `sas`: one object per connection and per IKE SA, read from `swanctl --list-conns --pretty` and `swanctl --list-sas --pretty`, with the connection's name under `name` and swanctl's keys and string values | | `wireguard` | The public key and port of `wg0`, and each peer's public key, endpoint, allowed IPs, handshake age and traffic; no private or preshared key | | `docker` | `docker.exposed_ports` and the rules of the `VPN-ROUTER-DOCKER` chain | @@ -745,6 +772,26 @@ member, 404 for a group or user that does not exist, 409 for a group that exists otherwise. `DELETE /users/` is refused with 400 while the user is the last member of a group. +### The /site-to-site API + +`/site-to-site` manages the site-to-site connections of `vpn-router.json` with the same token as +`/users`. + +| Request | Body | Answer | +| --- | --- | --- | +| `GET /site-to-site` | | `{"connections": [{"name", "addrs", "id", "cidrs", "local_cidrs", "local_id_mode", "psk_file", "psk_set", "established"}, ...]}` | +| `POST /site-to-site` | `{"name", "addrs", "id", "cidrs", "local_cidrs", "local_id_mode", "psk"}`; `psk` is required | 201 and the connection | +| `PUT /site-to-site/` | 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", "removed": true}` | + +A connection is shown without its key: `psk_set` says whether `psk` holds one, and `established` +whether its tunnel is up, `null` when swanctl cannot be asked. Each change is made under the lock +`vpn-config-edit` takes, checked as `vpn-router-setup` checks the configuration, written to +`vpn-router.json`, and applied by restarting `vpn-router-setup` without waiting for it. A failure +is `{"error": ...}` with 400 for an invalid request or configuration, with the reason, 404 for a +connection that does not exist, 409 for a name that exists and 500 otherwise. Keys cross the +internal network in plain HTTP, like the token. + The service starts on its own, without waiting for `vpn-router-setup`, and keeps running. Every 30 seconds it reads `web.listen`, `web.port` and `web.enabled` from `vpn-router.json` and works out the addresses of the interfaces `web.listen` names as `general.mode` directs; it listens on @@ -792,30 +839,37 @@ takes a sample of the router with `psutil`: - the free and the total space of `/`; - the bytes received and sent per second on the external and the internal interface; - the 1, 5 and 15 minute load averages; -- the VPN clients of each enabled VPN system: road-warrior (IPsec P2S) and OpenVPN clients, - WireGuard peers with a handshake in the last 3 minutes, and the site-to-site tunnel, 1 while it is - up. +- the VPN clients of each enabled VPN system: road-warrior (IPsec P2S) and OpenVPN clients, and + WireGuard peers with a handshake in the last 3 minutes; +- for each site-to-site connection that is set up: whether it is up (1 or 0), the bytes and the + packets per second received and sent through its child SAs since the previous sample, and the + seconds its IKE SA has been up. The child SAs' counters start again at a rekey; a counter lower + than in the previous sample counts from zero. ### History The samples are kept in the router's PostgreSQL database, the one of the refresh tokens -(`web.database`), in three detail tables, one row per minute, and each is summarized hourly, daily, -monthly and yearly. A summary row has the start of its period, in UTC, and the average and the -maximum of each value: +(`web.database`), in four detail tables, one row per minute, and per interface, kind of client or +site-to-site connection, and each is summarized hourly, daily, monthly and yearly. A summary row +has the start of its period, in UTC, and the average and the maximum of each value; the average +of a connection's `up` is the part of the period it was up: | Level | Tables | Kept | |---|---|---| -| Details | `metrics_system`, `metrics_network`, `metrics_vpn` | `metrics.keep_details` | -| Hourly | `metrics_system_hourly`, `metrics_network_hourly`, `metrics_vpn_hourly` | `metrics.keep_hourly` | -| Daily | `metrics_system_daily`, `metrics_network_daily`, `metrics_vpn_daily` | `metrics.keep_daily` | -| Monthly | `metrics_system_monthly`, `metrics_network_monthly`, `metrics_vpn_monthly` | `metrics.keep_monthly` | -| Yearly | `metrics_system_yearly`, `metrics_network_yearly`, `metrics_vpn_yearly` | `metrics.keep_yearly` | +| Details | `metrics_system`, `metrics_network`, `metrics_vpn`, `metrics_site_to_site` | `metrics.keep_details` | +| Hourly | `metrics_system_hourly`, `metrics_network_hourly`, `metrics_vpn_hourly`, `metrics_site_to_site_hourly` | `metrics.keep_hourly` | +| Daily | `metrics_system_daily`, `metrics_network_daily`, `metrics_vpn_daily`, `metrics_site_to_site_daily` | `metrics.keep_daily` | +| Monthly | `metrics_system_monthly`, `metrics_network_monthly`, `metrics_vpn_monthly`, `metrics_site_to_site_monthly` | `metrics.keep_monthly` | +| Yearly | `metrics_system_yearly`, `metrics_network_yearly`, `metrics_vpn_yearly`, `metrics_site_to_site_yearly` | `metrics.keep_yearly` | + +`metrics_site_to_site` has the columns `connection`, `up`, `rx`, `tx`, `packets_rx`, `packets_tx` +and `uptime`. How it works: - **Summaries:** a period is summarized once it is complete, from the level below. A stopped service catches up on the periods it missed, as long as their rows are still kept. - **Garbage collection:** runs on start and once a day. It deletes the rows older than their retention, a count and a unit such as `24 hours`, `3 months` or `10 years`. -- **History API:** `GET /metrics/history?range=hour|day|month|year` of the status web service, protected like `/config`, answers the views of the Router Manager's Metrics tab: +- **History API:** `GET /metrics/history?range=hour|day|month|year` of the status web service, protected like `/config`, answers the views of the Router Manager's Metrics tab. Each point has the system values, `network` by interface, `clients` by kind and `site_to_site` by connection, each value as its `avg` and `max`: | Range | Bucket | Source | |---|---|---| @@ -834,7 +888,8 @@ How it works: - `system_filesystem_usage_bytes` and `system_filesystem_utilization`; - `system_network_io_bytes_total`; - `system_cpu_load_average_1m`, `_5m` and `_15m`; - - `vpn_router_clients`. + - `vpn_router_clients`, by `kind`: `p2s`, `openvpn` and `wireguard`; + - by `connection`, for each site-to-site connection: `vpn_router_site_to_site_up`, `vpn_router_site_to_site_uptime_seconds`, and, by `direction` too, `vpn_router_site_to_site_io_bytes_per_second` and `vpn_router_site_to_site_packets_per_second`. The rates are gauges, since the child SAs' counters start again at each rekey. - **Listening:** on the interfaces `metrics.listen` names, `internal` by default, at `metrics.port`, and on `127.0.0.1`. While HTTPS is on, Caddy serves it at `metrics.https_port` for `wan.local_fqdn`. `vpn-router-setup` opens both ports on the internal and the external interface. - **Authentication:** HTTP Basic as the service account `metrics.service_account` names, `metrics` by default. The password is checked with a bind to the user directory, so 5 failed attempts lock the account as for any user. - **The account:** `vpn-router-setup` owns it while metrics are enabled. @@ -864,15 +919,15 @@ receivers: When `metrics.otlp_endpoint` is set, every sample is also pushed there as an OTLP/HTTP JSON request. -- **Content:** the semantic convention names (`system.cpu.utilization`, `system.network.io` and so on), and the resource attributes `service.name` `vpn-router` and `host.name`. +- **Content:** the semantic convention names (`system.cpu.utilization`, `system.network.io` and so on), `vpn_router.clients`, the gauges `vpn_router.site_to_site.up`, `.uptime`, `.io` and `.packets` with the attribute `connection`, and the resource attributes `service.name` `vpn-router` and `host.name`. - **Authentication:** HTTP Basic with `metrics.otlp_username` and `metrics.otlp_password` when they are set. - **Failures:** a failed push is logged and not repeated; the next sample is pushed a minute later. ## Router Manager The `vpn-router-manager` package, built in the repository `linux-cloud-router-manager`, is a web -application that shows the state of the router and manages its users through the status web -service. It can be installed on the router or on another Linux system. While it is installed on +application that shows the state of the router and manages its users and site-to-site +connections through the status web service. It can be installed on the router or on another Linux system. While it is installed on the router, `vpn-router-setup` opens its port (`port` in `/etc/vpn-router-manager/manager.json`, 7080 by default, HTTP; HTTPS is Caddy's, on `https.manager_port`) on the internal and the external interface; restart `vpn-router-setup` after changing it. The manager's `listen` chooses the diff --git a/debian-package/debian/changelog b/debian-package/debian/changelog index 0508e63..50643c5 100644 --- a/debian-package/debian/changelog +++ b/debian-package/debian/changelog @@ -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-; 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 Sun, 25 Oct 2026 23:00:00 +0100 + vpn-router (0.17.0-1) unstable; urgency=medium feature: /config shows the router's PostgreSQL clusters and their settings, and /postgresql their databases with their sizes. diff --git a/debian-package/debian/postrm b/debian-package/debian/postrm index 1e3a6b0..09ccebc 100755 --- a/debian-package/debian/postrm +++ b/debian-package/debian/postrm @@ -43,7 +43,7 @@ case "$1" in ucfr --purge vpn-router /etc/vpn-router/vpn-router.json fi - rm -f /etc/swanctl/conf.d/remote-site.conf \ + rm -f /etc/swanctl/conf.d/site-to-site.conf \ /etc/swanctl/conf.d/road-warrior.conf \ /etc/systemd/resolved.conf.d/p2s-forwarder.conf \ /etc/wireguard/wg0.conf \ diff --git a/debian-package/debian/prerm b/debian-package/debian/prerm index 721d034..9e0c23a 100755 --- a/debian-package/debian/prerm +++ b/debian-package/debian/prerm @@ -6,8 +6,13 @@ case "$1" in # Stop offering the tunnels, but leave strongSwan itself enabled: it is # a shared service and may be in use by something other than this # package. The connection files are removed on purge. + # The site-to-site connections are the names of site_to_site in vpn-router.json. if [ -d /run/systemd/system ]; then - swanctl --terminate --ike remote-site >/dev/null 2>&1 || true + connections="$(python3 -c 'import json, sys; print(" ".join(json.load(open(sys.argv[1])).get("site_to_site", {})))' \ + /etc/vpn-router/vpn-router.json 2>/dev/null || true)" + for connection in $connections; do + swanctl --terminate --ike "$connection" >/dev/null 2>&1 || true + done swanctl --terminate --ike road-warrior >/dev/null 2>&1 || true fi diff --git a/debian-package/debian/templates b/debian-package/debian/templates index e4be953..013fa08 100644 --- a/debian-package/debian/templates +++ b/debian-package/debian/templates @@ -66,7 +66,8 @@ Type: select Choices: fqdn, public_ip, internal_ip Default: fqdn Description: IKE local identity mode: - How to derive the IKE identity advertised to the remote site: + How to derive the IKE identity the first site-to-site connection + advertises to the remote site: . fqdn - use the FQDN, which must match what the peer expects. . @@ -78,8 +79,9 @@ Template: vpn-router/local_cidrs Type: string Default: Description: Local subnet CIDR(s): - Comma-separated list of local subnet CIDRs to advertise into the - site-to-site tunnel (for example 10.0.0.0/24 or 10.0.0.0/24,10.0.1.0/24). + Comma-separated list of local subnet CIDRs routed by this router and + advertised into the first site-to-site connection (for example + 10.0.0.0/24 or 10.0.0.0/24,10.0.1.0/24). Template: vpn-router/int_gateway_ip Type: string @@ -94,25 +96,26 @@ Type: string Default: Description: Remote site WAN IP address(es): Comma-separated list of remote gateway addresses or FQDNs for the - site-to-site IPSec tunnel. + first site-to-site connection. Template: vpn-router/remote_id Type: string Default: Description: Remote site IKE identity: - IKE identity of the remote peer, without a leading @. + IKE identity of the remote peer of the first site-to-site connection, + without a leading @. Template: vpn-router/remote_cidrs Type: string Default: Description: Remote subnet CIDR(s): Comma-separated list of remote subnet CIDRs reachable through the - site-to-site tunnel (for example 192.168.0.0/24). + first site-to-site connection (for example 192.168.0.0/24). Template: vpn-router/psk Type: password Description: Pre-shared key (PSK): - Pre-shared key for the site-to-site IKEv2 tunnel. Must match the value + Pre-shared key of the first site-to-site connection. Must match the value configured on the remote peer. Stored in the configuration file and cleared from the debconf database after installation. diff --git a/debian-package/src/usr/lib/vpn-router/generate-config b/debian-package/src/usr/lib/vpn-router/generate-config index 4282f49..e133e7e 100755 --- a/debian-package/src/usr/lib/vpn-router/generate-config +++ b/debian-package/src/usr/lib/vpn-router/generate-config @@ -18,7 +18,9 @@ on the next dpkg-reconfigure. Answers arrive as VPN_ROUTER_* environment variables and are converted to the type of the setting they fill: comma-separated lists become arrays, "true" and -"false" become booleans and digits become integers. +"false" become booleans and digits become integers. The site-to-site answers +fill the connection vpnrouter.FIRST_CONNECTION of site_to_site; the local +subnets fill both its local_cidrs and local.cidrs. """ import json @@ -29,27 +31,29 @@ sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) import vpnrouter -# environment variable -> (section, option) +FIRST = ('site_to_site', vpnrouter.FIRST_CONNECTION) + +# environment variable -> the settings it fills, each (section, option) or (site_to_site, name, option) ANSWERS = { - 'VPN_ROUTER_PLATFORM': ('general', 'platform'), - 'VPN_ROUTER_MODE': ('general', 'mode'), - 'VPN_ROUTER_INT_ADDR': ('local', 'int_addr'), - 'VPN_ROUTER_EXTERNAL_INTERFACE': ('interfaces', 'external'), - 'VPN_ROUTER_INTERNAL_INTERFACE': ('interfaces', 'internal'), - 'VPN_ROUTER_LOCAL_FQDN': ('wan', 'local_fqdn'), - 'VPN_ROUTER_LOCAL_ID_MODE': ('wan', 'local_id_mode'), - 'VPN_ROUTER_LOCAL_CIDRS': ('local', 'cidrs'), - 'VPN_ROUTER_INT_GATEWAY_IP': ('local', 'int_gateway_ip'), - 'VPN_ROUTER_REMOTE_ADDRS': ('remote', 'addrs'), - 'VPN_ROUTER_REMOTE_ID': ('remote', 'id'), - 'VPN_ROUTER_REMOTE_CIDRS': ('remote', 'cidrs'), - 'VPN_ROUTER_PSK': ('remote', 'psk'), - 'VPN_ROUTER_P2S_ENABLED': ('p2s', 'enabled'), - 'VPN_ROUTER_P2S_ADDRESS_POOL': ('p2s', 'address_pool'), - 'VPN_ROUTER_P2S_CA_NAME': ('p2s', 'ca_name'), - 'VPN_ROUTER_WG_ENABLED': ('wireguard', 'enabled'), - 'VPN_ROUTER_WG_ADDRESS': ('wireguard', 'address'), - 'VPN_ROUTER_WG_LISTEN_PORT': ('wireguard', 'listen_port'), + 'VPN_ROUTER_PLATFORM': (('general', 'platform'),), + 'VPN_ROUTER_MODE': (('general', 'mode'),), + 'VPN_ROUTER_INT_ADDR': (('local', 'int_addr'),), + 'VPN_ROUTER_EXTERNAL_INTERFACE': (('interfaces', 'external'),), + 'VPN_ROUTER_INTERNAL_INTERFACE': (('interfaces', 'internal'),), + 'VPN_ROUTER_LOCAL_FQDN': (('wan', 'local_fqdn'),), + 'VPN_ROUTER_LOCAL_ID_MODE': ((*FIRST, 'local_id_mode'),), + 'VPN_ROUTER_LOCAL_CIDRS': (('local', 'cidrs'), (*FIRST, 'local_cidrs')), + 'VPN_ROUTER_INT_GATEWAY_IP': (('local', 'int_gateway_ip'),), + 'VPN_ROUTER_REMOTE_ADDRS': ((*FIRST, 'addrs'),), + 'VPN_ROUTER_REMOTE_ID': ((*FIRST, 'id'),), + 'VPN_ROUTER_REMOTE_CIDRS': ((*FIRST, 'cidrs'),), + 'VPN_ROUTER_PSK': ((*FIRST, 'psk'),), + 'VPN_ROUTER_P2S_ENABLED': (('p2s', 'enabled'),), + 'VPN_ROUTER_P2S_ADDRESS_POOL': (('p2s', 'address_pool'),), + 'VPN_ROUTER_P2S_CA_NAME': (('p2s', 'ca_name'),), + 'VPN_ROUTER_WG_ENABLED': (('wireguard', 'enabled'),), + 'VPN_ROUTER_WG_ADDRESS': (('wireguard', 'address'),), + 'VPN_ROUTER_WG_LISTEN_PORT': (('wireguard', 'listen_port'),), } @@ -74,23 +78,28 @@ def main(): vpnrouter.fail(f'cannot parse {vpnrouter.CONFIG_FILE}: the top level is not an object') answered = False - for variable, (section, option) in ANSWERS.items(): - value = os.environ.get(variable) - if not value: + for variable, paths in ANSWERS.items(): + answer = os.environ.get(variable) + if not answer: continue - # A value that does not convert is kept as a string for validate() to report. - default = vpnrouter.SCHEMA[section][option] - if isinstance(default, bool): - value = value == 'true' - elif isinstance(default, int): - value = int(value) if value.isdigit() else value - elif isinstance(default, list): - value = [item.strip() for item in value.split(',') if item.strip()] - if not isinstance(cfg.get(section, {}), dict): - vpnrouter.fail(f'cannot parse {vpnrouter.CONFIG_FILE}: {section} is not an object') - if cfg.setdefault(section, {}).get(option) != value: - cfg[section][option] = value - answered = True + for path in paths: + # A value that does not convert is kept as a string for validate() to report. + default = vpnrouter.CONNECTION[path[2]] if path[0] == 'site_to_site' else vpnrouter.SCHEMA[path[0]][path[1]] + value = answer + if isinstance(default, bool): + value = value == 'true' + elif isinstance(default, int): + value = int(value) if value.isdigit() else value + elif isinstance(default, list): + value = [item.strip() for item in value.split(',') if item.strip()] + target = cfg + for key in path[:-1]: + if not isinstance(target.setdefault(key, {}), dict): + vpnrouter.fail(f'cannot parse {vpnrouter.CONFIG_FILE}: {".".join(path[:-1])} is not an object') + target = target[key] + if target.get(path[-1]) != value: + target[path[-1]] = value + answered = True # Without a change the candidate is the current file byte for byte, as ucf compares checksums. with open(output, 'w', encoding='utf-8') as handle: diff --git a/debian-package/src/usr/lib/vpn-router/metrics b/debian-package/src/usr/lib/vpn-router/metrics index 8d29b40..667f21e 100755 --- a/debian-package/src/usr/lib/vpn-router/metrics +++ b/debian-package/src/usr/lib/vpn-router/metrics @@ -8,9 +8,10 @@ Every SAMPLE_SECONDS, on the minute, a sample is taken with psutil: the CPU util previous sample, the memory used and in total, the free and the total space of /, the bytes received and sent per second on the external and the internal interface, and the load averages; and the VPN clients of each enabled VPN system (vpnrouter_status): road-warrior (p2s) and -OpenVPN clients, WireGuard peers with a recent handshake, and the site-to-site tunnel, 1 while it -is up. The interfaces and the enabled systems are those vpn-router-setup resolved, read from -/run/vpn-router/web.json. +OpenVPN clients and WireGuard peers with a recent handshake; and of each site-to-site connection +that is set up, whether it is up, the bytes and packets per second through its child SAs since +the previous sample, and how long its IKE SA has been up. The interfaces, the enabled systems and +the connections are those vpn-router-setup resolved, read from /run/vpn-router/web.json. The sample is written to the detail tables of vpnrouter_metrics. When the clock has passed into a new hour, day, month or year, the complete periods are summarized, and once a day, after the @@ -105,9 +106,6 @@ def clients(state): counts['openvpn'] = vpnrouter_status.openvpn_clients() if state.get('wireguard'): counts['wireguard'] = vpnrouter_status.wireguard_connected() - if state.get('site_to_site'): - count = vpnrouter_status.established('remote-site') - counts['site_to_site'] = None if count is None else int(count > 0) return counts @@ -121,6 +119,8 @@ def sample(previous): memory = psutil.virtual_memory() disk = psutil.disk_usage(MOUNTPOINT) load1, load5, load15 = os.getloadavg() + connections = state.get('site_to_site') + tunnels = vpnrouter_status.site_to_site_tunnels(connections) if isinstance(connections, list) and connections else {} now = time.time() current = { 'time': now, @@ -138,13 +138,26 @@ def sample(previous): 'counters': {name: (counters[name].bytes_recv, counters[name].bytes_sent) for name in names if name in counters}, 'clients': clients(state), 'rates': {}, + 'tunnels': tunnels or {}, + 'site_to_site': {}, } - if previous is not None: - elapsed = current['monotonic'] - previous['monotonic'] + elapsed = current['monotonic'] - previous['monotonic'] if previous is not None else 0 + if elapsed > 0: for name, (received, sent) in current['counters'].items(): - if name in previous['counters'] and elapsed > 0: + if name in previous['counters']: before_received, before_sent = previous['counters'][name] current['rates'][name] = (max(received - before_received, 0) / elapsed, max(sent - before_sent, 0) / elapsed) + # A connection's rates need its counters of the previous sample; a counter that went down was rekeyed and counts from zero. + for name, tunnel in current['tunnels'].items(): + before = previous['tunnels'].get(name) if elapsed > 0 else None + values = {'up': tunnel['up'], 'uptime': tunnel['uptime']} + for value, counter in (('rx', 'bytes_in'), ('tx', 'bytes_out'), ('packets_rx', 'packets_in'), ('packets_tx', 'packets_out')): + if before is None: + values[value] = None + else: + count = tunnel[counter] + values[value] = (count - before[counter] if count >= before[counter] else count) / elapsed + current['site_to_site'][name] = values return current @@ -162,7 +175,7 @@ def store(cfg, current, done): """ now = periods(datetime.datetime.now(datetime.timezone.utc)) with vpnrouter_metrics.database(cfg) as connection: - vpnrouter_metrics.write(connection, current['system'], current['rates'], current['clients']) + vpnrouter_metrics.write(connection, current['system'], current['rates'], current['clients'], current['site_to_site']) for level, _, _ in vpnrouter_metrics.LEVELS: if done.get(level) != now[level]: vpnrouter_metrics.summarize(connection, level) @@ -200,6 +213,17 @@ def otlp_request(current, started): gauge('system.cpu.load_average.5m', '{thread}', [(system['load5'], {})]), gauge('system.cpu.load_average.15m', '{thread}', [(system['load15'], {})]), gauge('vpn_router.clients', '{client}', [(count, {'kind': kind}) for kind, count in current['clients'].items()]), + gauge('vpn_router.site_to_site.up', '1', [(values['up'], {'connection': name}) + for name, values in current['site_to_site'].items()]), + gauge('vpn_router.site_to_site.uptime', 's', [(values['uptime'], {'connection': name}) + for name, values in current['site_to_site'].items()]), + gauge('vpn_router.site_to_site.io', 'By/s', [(values[value], {'connection': name, 'direction': direction}) + for name, values in current['site_to_site'].items() + for value, direction in (('rx', 'receive'), ('tx', 'transmit'))]), + gauge('vpn_router.site_to_site.packets', '{packet}/s', + [(values[value], {'connection': name, 'direction': direction}) + for name, values in current['site_to_site'].items() + for value, direction in (('packets_rx', 'receive'), ('packets_tx', 'transmit'))]), {'name': 'system.network.io', 'unit': 'By', 'sum': { 'aggregationTemporality': 2, 'isMonotonic': True, 'dataPoints': [ {'startTimeUnixNano': start, 'timeUnixNano': stamp, 'asInt': str(value), @@ -295,12 +319,30 @@ class Sample: network.add_metric([name, 'receive'], received) network.add_metric([name, 'transmit'], sent) yield network - vpn = GaugeMetricFamily('vpn_router_clients', 'Connected VPN clients; site_to_site is 1 while the tunnel is up', - labels=['kind']) + vpn = GaugeMetricFamily('vpn_router_clients', 'Connected VPN clients', labels=['kind']) for kind, count in self.current['clients'].items(): if count is not None: vpn.add_metric([kind], count) yield vpn + tunnels = self.current['site_to_site'] + up = GaugeMetricFamily('vpn_router_site_to_site_up', 'Whether the site-to-site connection is established, 1 or 0', + labels=['connection']) + uptime = GaugeMetricFamily('vpn_router_site_to_site_uptime_seconds', 'Seconds the IKE SA of the site-to-site connection has been up', + labels=['connection']) + io = GaugeMetricFamily('vpn_router_site_to_site_io_bytes_per_second', + 'Bytes per second received and sent through the site-to-site connection', labels=['connection', 'direction']) + packets = GaugeMetricFamily('vpn_router_site_to_site_packets_per_second', + 'Packets per second received and sent through the site-to-site connection', + labels=['connection', 'direction']) + for name, values in tunnels.items(): + up.add_metric([name], values['up']) + if values['uptime'] is not None: + uptime.add_metric([name], values['uptime']) + for family, received, sent in ((io, 'rx', 'tx'), (packets, 'packets_rx', 'packets_tx')): + for value, direction in ((received, 'receive'), (sent, 'transmit')): + if values[value] is not None: + family.add_metric([name, direction], values[value]) + yield from (up, uptime, io, packets) def authorized(header, account): diff --git a/debian-package/src/usr/lib/vpn-router/setup b/debian-package/src/usr/lib/vpn-router/setup index 004bf0e..29170d4 100755 --- a/debian-package/src/usr/lib/vpn-router/setup +++ b/debian-package/src/usr/lib/vpn-router/setup @@ -294,7 +294,6 @@ def nat_and_mangle_block(ctx): if vpnrouter.routing_ready(ctx): subnets = ctx['local_cidr_list'] - remote = ctx['remote_cidr_list'] lines += [ '', '# ROUTER NAT RULES START', @@ -302,11 +301,14 @@ def nat_and_mangle_block(ctx): ':POSTROUTING ACCEPT [0:0]', '-F POSTROUTING', ] - # Every RETURN precedes every MASQUERADE, so tunnel-bound traffic - # escapes NAT whichever local subnet it came from. - for subnet in subnets: - for cidr in remote: - lines.append(f'-A POSTROUTING -s {subnet} -d {cidr} -j RETURN') + # Every RETURN precedes every MASQUERADE, so traffic bound for a tunnel + # escapes NAT; each ready connection pairs its local and remote subnets. + for connection in ctx['connections']: + if not connection['ready']: + continue + for subnet in connection['local_cidr_list']: + for cidr in connection['remote_cidr_list']: + lines.append(f'-A POSTROUTING -s {subnet} -d {cidr} -j RETURN') for subnet in subnets: lines.append( f'-A POSTROUTING -s {subnet} -o {ctx["wan_iface"]} -j MASQUERADE') @@ -591,31 +593,37 @@ def refresh_web(cfg, ctx): the configuration without its secret options and the facts resolved from the system, which it cannot work out with the standard library alone. The file is readable by root only. The service follows a changed address or - port itself. + port itself. Secret options are left out at every level, so also from each + site-to-site connection. """ - public = { - section: {option: value for option, value in options.items() if not SECRET_OPTION.search(option)} - for section, options in cfg.items() - } + def public(options): + return {option: public(value) if isinstance(value, dict) else value + for option, value in options.items() + if isinstance(value, dict) or not SECRET_OPTION.search(option)} + WEB_STATE.parent.mkdir(parents=True, exist_ok=True) WEB_STATE.touch(mode=0o600) WEB_STATE.chmod(0o600) WEB_STATE.write_text(json.dumps({ - 'site_to_site': bool(vpnrouter.s2s_ready(ctx)), + 'site_to_site': [connection['name'] for connection in ctx['connections'] if connection['ready']], 'p2s': bool(vpnrouter.p2s_ready(ctx)), 'wireguard': bool(vpnrouter.wg_ready(ctx)), 'openvpn': bool(vpnrouter.openvpn_ready(ctx)), - 'configuration': public, + 'configuration': public(cfg), 'network': { 'external_interface': ctx['wan_iface'], 'internal_interface': ctx['int_iface'], 'external_addresses': ctx['local_addr_list'], 'internal_address': ctx['int_addr'], 'internal_gateway': ctx['int_gateway_ip'], - 'local_identity': ctx['local_id'], - 'remote_identity': ctx['remote_id'], 'local_cidrs': ctx['local_cidr_list'], - 'remote_cidrs': ctx['remote_cidr_list'], + 'site_to_site': [{ + 'name': connection['name'], + 'local_identity': connection['local_id'], + 'remote_identity': connection['remote_id'], + 'local_cidrs': connection['local_cidr_list'], + 'remote_cidrs': connection['remote_cidr_list'], + } for connection in ctx['connections']], }, }) + '\n', encoding='utf-8') @@ -666,10 +674,10 @@ def main(): if vpnrouter.routing_ready(ctx): apply_routes(ctx) - if ctx['local_id_mode'] == 'public_ip' and ctx['local_fqdn']: + if ctx['local_fqdn'] and any(connection['local_id_mode'] == 'public_ip' for connection in ctx['connections']): progress('running', 'public address') address = resolve_public_ip(ctx['local_fqdn']) - ctx, more = vpnrouter.configure(cfg, local_id_override=address) + ctx, more = vpnrouter.configure(cfg, public_ip=address) changed |= more progress('running', 'WireGuard keys') diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter.py b/debian-package/src/usr/lib/vpn-router/vpnrouter.py index 9680393..21809f9 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter.py @@ -4,6 +4,8 @@ Everything derivable from the configuration file alone, with no running network required. Used by both 'configure' and 'setup'. """ +import contextlib +import fcntl import ipaddress import json import os @@ -12,6 +14,7 @@ import re import shutil import subprocess import sys +import tempfile import urllib.parse import jinja2 @@ -25,12 +28,13 @@ import vpnrouter_platforms from vpnrouter_net import DEFAULT_LISTEN, LISTEN_CHOICES, LOOPBACK, ConfigError, detect_interfaces, interface_addresses, ip_output CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.json') +CONFIG_LOCK = pathlib.Path('/run/vpn-router/config.lock') TEMPLATE_DIR = pathlib.Path('/usr/share/vpn-router/templates') PKI_DIR = pathlib.Path('/etc/vpn-router/pki') SIMPLE_CA = LIB_DIR / 'simple-ca' SWANCTL_CONF_D = pathlib.Path('/etc/swanctl/conf.d') -REMOTE_SITE = SWANCTL_CONF_D / 'remote-site.conf' +SITE_TO_SITE = SWANCTL_CONF_D / 'site-to-site.conf' ROAD_WARRIOR = SWANCTL_CONF_D / 'road-warrior.conf' P2S_FORWARDER = pathlib.Path('/etc/systemd/resolved.conf.d/p2s-forwarder.conf') WG_CONF = pathlib.Path('/etc/wireguard/wg0.conf') @@ -48,9 +52,9 @@ TRUST_ANCHOR = pathlib.Path('/usr/local/share/ca-certificates/vpn-router-ca.cr SCHEMA = { 'general': {'platform': 'none', 'mode': 'manual'}, 'interfaces': {'external': '', 'internal': ''}, - 'wan': {'local_fqdn': '', 'local_id_mode': 'fqdn'}, + 'wan': {'local_fqdn': ''}, 'local': {'cidrs': [], 'int_addr': '', 'int_gateway_ip': ''}, - 'remote': {'addrs': [], 'id': '', 'cidrs': [], 'psk': '', 'psk_file': ''}, + 'site_to_site': {}, 'p2s': {'enabled': False, 'address_pool': '', 'ca_name': 'VPN Router CA'}, 'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820}, 'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'}, @@ -64,6 +68,15 @@ SCHEMA = { 'keep_monthly': '1 year', 'keep_yearly': '10 years'}, } +#: site_to_site maps a connection name to its settings: option -> default, the type of the default being the required type. +CONNECTION = {'addrs': [], 'id': '', 'cidrs': [], 'local_cidrs': [], 'local_id_mode': 'fqdn', 'psk': '', 'psk_file': ''} + +#: The name of a site_to_site connection, which also names its swanctl connection, child and secret. +CONNECTION_NAME = re.compile(r'^[a-z][a-z0-9-]{0,30}$') + +#: The site_to_site connection the install answers fill. +FIRST_CONNECTION = 'remote-site' + #: The retention settings of the metrics, from the details to the yearly summaries. METRICS_KEEP = ('keep_details', 'keep_hourly', 'keep_daily', 'keep_monthly', 'keep_yearly') @@ -93,7 +106,7 @@ DOCKER_PORT = re.compile(r'^\d{1,5}(:\d{1,5})?$') DOCKER_PORT_LIMIT = 15 TYPE_NAMES = {str: 'a string', list: 'a list of strings', bool: 'a boolean', - int: 'an integer'} + int: 'an integer', dict: 'an object'} ID_MODES = ('fqdn', 'public_ip', 'internal_ip') @@ -118,29 +131,76 @@ def fail(message): sys.exit(f'vpn-router: error: {message}') -def load_config(path=None): - """Read the configuration, filling in defaults for anything absent. +def read_settings(path=None): + """The settings in the configuration file, without the defaults; {} when the file does not exist. - The result maps section -> option -> value. Sections and options not in - SCHEMA are kept so that validate() can report them. + Raises ConfigError when the file is not JSON or not an object of sections. + """ + path = pathlib.Path(path) if path else CONFIG_FILE + try: + data = json.loads(path.read_text(encoding='utf-8')) + except FileNotFoundError: + return {} + except ValueError as exc: + raise ConfigError(f'cannot parse {path}: {exc}') + if not isinstance(data, dict): + raise ConfigError(f'cannot parse {path}: the top level is not an object') + for section, options in data.items(): + if not isinstance(options, dict): + raise ConfigError(f'cannot parse {path}: {section} is not an object') + return data + + +def with_defaults(data): + """The configuration of the settings of read_settings, with the defaults for anything absent. + + The result maps section -> option -> value, and site_to_site maps each connection name to + its settings filled from CONNECTION. Sections, options and connections that are not + objects are kept so that validate() can report them. + """ + cfg = {section: dict(options) for section, options in SCHEMA.items()} + for section, options in data.items(): + cfg.setdefault(section, {}).update(options) + cfg['site_to_site'] = {name: {**CONNECTION, **options} if isinstance(options, dict) else options + for name, options in cfg['site_to_site'].items()} + return cfg + + +def load_config(path=None): + """Read the configuration, filling in defaults for anything absent (see with_defaults). The path is resolved when called, not when this function is defined, so CONFIG_FILE stays overridable. """ - path = pathlib.Path(path) if path else CONFIG_FILE - cfg = {section: dict(options) for section, options in SCHEMA.items()} - if path.exists(): - try: - data = json.loads(path.read_text(encoding='utf-8')) - except ValueError as exc: - fail(f'cannot parse {path}: {exc}') - if not isinstance(data, dict): - fail(f'cannot parse {path}: the top level is not an object') - for section, options in data.items(): - if not isinstance(options, dict): - fail(f'cannot parse {path}: {section} is not an object') - cfg.setdefault(section, {}).update(options) - return cfg + try: + return with_defaults(read_settings(path)) + except ConfigError as exc: + fail(str(exc)) + + +@contextlib.contextmanager +def config_lock(): + """Hold the lock of the configuration file from reading it to writing it with save_config.""" + CONFIG_LOCK.parent.mkdir(parents=True, exist_ok=True) + with open(CONFIG_LOCK, 'a', encoding='utf-8') as handle: + fcntl.flock(handle, fcntl.LOCK_EX) + yield + + +def save_config(data): + """Write the settings to the configuration file, indented, with mode 0600, replacing it in one step. + + The caller holds config_lock. + """ + descriptor, name = tempfile.mkstemp(dir=CONFIG_FILE.parent, prefix='.vpn-router.json.') + try: + with os.fdopen(descriptor, 'w', encoding='utf-8') as handle: + handle.write(json.dumps(data, indent=2) + '\n') + os.chmod(name, 0o600) + os.replace(name, CONFIG_FILE) + except OSError: + os.unlink(name) + raise def _split_list(value): @@ -152,23 +212,37 @@ def _get(cfg, section, option): return value.strip() if isinstance(value, str) else value +def _clean(values): + return [item.strip() for item in values if item.strip()] + + def _get_list(cfg, section, option): - return [item.strip() for item in cfg[section][option] if item.strip()] + return _clean(cfg[section][option]) + + +def _check_options(options, defaults, prefix): + for option, value in options.items(): + if option not in defaults: + raise ConfigError(f'{prefix}.{option}: unknown setting') + expected = type(defaults[option]) + if type(value) is not expected or ( + expected is list and + not all(isinstance(item, str) for item in value)): + raise ConfigError( + f'{prefix}.{option} must be {TYPE_NAMES[expected]}') def _check_types(cfg): for section, options in cfg.items(): if section not in SCHEMA: raise ConfigError(f'{section}: unknown section') - for option, value in options.items(): - if option not in SCHEMA[section]: - raise ConfigError(f'{section}.{option}: unknown setting') - expected = type(SCHEMA[section][option]) - if type(value) is not expected or ( - expected is list and - not all(isinstance(item, str) for item in value)): - raise ConfigError( - f'{section}.{option} must be {TYPE_NAMES[expected]}') + if section != 'site_to_site': + _check_options(options, SCHEMA[section], section) + continue + for name, connection in options.items(): + if not isinstance(connection, dict): + raise ConfigError(f'site_to_site.{name} must be {TYPE_NAMES[dict]}') + _check_options(connection, CONNECTION, f'site_to_site.{name}') def _check_networks(values, name): @@ -220,19 +294,28 @@ def validate(cfg): raise ConfigError( f'{section}.{option} is required in mode {mode or "manual"}') - for section, option in (('wan', 'local_fqdn'), ('remote', 'id')): - if str(_get(cfg, section, option)).startswith('@'): + identities = [('wan.local_fqdn', _get(cfg, 'wan', 'local_fqdn'))] + for name, connection in cfg['site_to_site'].items(): + prefix = f'site_to_site.{name}' + if not CONNECTION_NAME.match(name): raise ConfigError( - f'{section}.{option}: remove the leading @; strongSwan reads an ' + f'{prefix}: a connection name starts with a lowercase letter and has at most 31 ' + 'lowercase letters, digits and hyphens') + identities.append((f'{prefix}.id', connection['id'].strip())) + id_mode = connection['local_id_mode'].strip() + if id_mode and id_mode not in ID_MODES: + raise ConfigError( + f'{prefix}.local_id_mode: {id_mode!r} is not one of {", ".join(ID_MODES)}') + _check_networks(_clean(connection['cidrs']), f'{prefix}.cidrs') + _check_networks(_clean(connection['local_cidrs']), f'{prefix}.local_cidrs') + + for option, value in identities: + if value.startswith('@'): + raise ConfigError( + f'{option}: remove the leading @; strongSwan reads an ' 'address as an IP identity and a name as an FQDN') - id_mode = _get(cfg, 'wan', 'local_id_mode') - if id_mode and id_mode not in ID_MODES: - raise ConfigError( - f'wan.local_id_mode: {id_mode!r} is not one of {", ".join(ID_MODES)}') - _check_networks(_get_list(cfg, 'local', 'cidrs'), 'local.cidrs') - _check_networks(_get_list(cfg, 'remote', 'cidrs'), 'remote.cidrs') pool = _get(cfg, 'p2s', 'address_pool') _check_networks([pool] if pool else [], 'p2s.address_pool') openvpn_pool = _get(cfg, 'openvpn', 'address_pool') @@ -355,15 +438,15 @@ def validate(cfg): 'a range counting as two') -def resolve_psk(cfg): - """Return the pre-shared key, or '' when none is configured.""" - psk_file = _get(cfg, 'remote', 'psk_file') +def resolve_psk(connection, name): + """Return the pre-shared key of the site_to_site connection: psk_file wins, else psk; '' when none is configured.""" + psk_file = connection['psk_file'].strip() if psk_file: path = pathlib.Path(psk_file) if not path.is_file(): - raise ConfigError(f'remote.psk_file: {psk_file} does not exist') + raise ConfigError(f'site_to_site.{name}.psk_file: {psk_file} does not exist') return path.read_text(encoding='utf-8').strip() - return cfg['remote']['psk'] + return connection['psk'] def platform_module(cfg): @@ -447,29 +530,43 @@ def resolve_interfaces(cfg): return ext_iface, int_iface, local_addrs, int_addr, gateway -def build_context(cfg, local_id_override=None): - """Assemble the template context from the configuration.""" +def build_context(cfg, public_ip=None): + """Assemble the template context from the configuration. + + public_ip is the address wan.local_fqdn resolves to, the local identity of the + site_to_site connections in mode public_ip; setup resolves it once the network is up and + renders again, and without it such a connection is not ready. + """ extras = vpnrouter_platforms.context(platform_module(cfg)) ext_iface, int_iface, local_addrs, int_addr, gateway = resolve_interfaces(cfg) local_fqdn = _get(cfg, 'wan', 'local_fqdn') - id_mode = _get(cfg, 'wan', 'local_id_mode') or 'fqdn' local_cidrs = _get_list(cfg, 'local', 'cidrs') - remote_cidrs = _get_list(cfg, 'remote', 'cidrs') openvpn_pool = _get(cfg, 'openvpn', 'address_pool') openvpn_network = ipaddress.ip_network(openvpn_pool) if openvpn_pool else None + identities = {'fqdn': local_fqdn, 'public_ip': public_ip or '', 'internal_ip': int_addr} - if local_id_override: - local_id = local_id_override - elif id_mode == 'fqdn': - local_id = local_fqdn - elif id_mode == 'internal_ip': - local_id = int_addr - else: - # public_ip is resolved once the network is up, and the caller then - # re-renders with local_id_override set. - local_id = '' + connections = [] + for name, connection in cfg['site_to_site'].items(): + id_mode = connection['local_id_mode'].strip() or 'fqdn' + remote_cidrs = _clean(connection['cidrs']) + connection_cidrs = _clean(connection['local_cidrs']) + entry = { + 'name': name, + 'remote_addrs': ', '.join(_clean(connection['addrs'])), + 'remote_id': connection['id'].strip(), + 'remote_cidrs': ', '.join(remote_cidrs), + 'remote_cidr_list': remote_cidrs, + 'local_ts': _join(connection_cidrs, _split_list(extras['EXTRA_LOCAL_TS'])), + 'local_cidr_list': connection_cidrs, + 'local_id_mode': id_mode, + 'local_id': identities.get(id_mode, ''), + 'psk': resolve_psk(connection, name), + } + entry['ready'] = bool(local_addrs and connection_cidrs and all( + entry[key] for key in ('remote_addrs', 'remote_id', 'remote_cidrs', 'local_id', 'psk'))) + connections.append(entry) return { 'platform': _get(cfg, 'general', 'platform') or PLATFORM_NONE, @@ -479,18 +576,12 @@ def build_context(cfg, local_id_override=None): 'local_addrs': ', '.join(local_addrs), 'local_addr_list': local_addrs, 'local_fqdn': local_fqdn, - 'local_id_mode': id_mode, - 'local_id': local_id, 'local_cidrs': ', '.join(local_cidrs), 'local_cidr_list': local_cidrs, 'local_ts': _join(local_cidrs, _split_list(extras['EXTRA_LOCAL_TS'])), 'int_addr': int_addr, 'int_gateway_ip': gateway, - 'remote_addrs': ', '.join(_get_list(cfg, 'remote', 'addrs')), - 'remote_id': _get(cfg, 'remote', 'id'), - 'remote_cidrs': ', '.join(remote_cidrs), - 'remote_cidr_list': remote_cidrs, - 'psk': resolve_psk(cfg), + 'connections': connections, 'p2s_enabled': _get(cfg, 'p2s', 'enabled'), 'p2s_address_pool': _get(cfg, 'p2s', 'address_pool'), 'p2s_ca_name': _get(cfg, 'p2s', 'ca_name'), @@ -530,11 +621,8 @@ def build_context(cfg, local_id_override=None): def s2s_ready(ctx): - return all([ - ctx['local_addrs'], ctx['local_fqdn'], ctx['local_cidrs'], - ctx['remote_addrs'], ctx['remote_id'], ctx['remote_cidrs'], - ctx['psk'], ctx['local_id'], - ]) + """True when any site_to_site connection is ready.""" + return any(connection['ready'] for connection in ctx['connections']) def routing_ready(ctx): @@ -699,7 +787,7 @@ def distribute_pki(ctx): return changed -def configure(cfg=None, local_id_override=None): +def configure(cfg=None, public_ip=None): """Render configuration files and provision PKI. Returns (ctx, changed) where changed is a set naming what needs reloading: @@ -715,7 +803,7 @@ def configure(cfg=None, local_id_override=None): try: validate(cfg) - ctx = build_context(cfg, local_id_override=local_id_override) + ctx = build_context(cfg, public_ip=public_ip) except ConfigError as exc: fail(str(exc)) @@ -728,9 +816,9 @@ def configure(cfg=None, local_id_override=None): env = _jinja_env() if s2s_ready(ctx): - if render(env, ctx, 'remote-site.conf.j2', REMOTE_SITE, 0o600): + if render(env, ctx, 'site-to-site.conf.j2', SITE_TO_SITE, 0o600): changed.add('swanctl') - elif remove(REMOTE_SITE): + elif remove(SITE_TO_SITE): changed.add('swanctl') if p2s_ready(ctx): diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_metrics.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_metrics.py index efab224..2bc733f 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_metrics.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_metrics.py @@ -1,8 +1,10 @@ """The metrics of the router in PostgreSQL, for the metrics service and the web service. -The metrics service writes one detail row per minute to the three detail tables, of TABLES: +The metrics service writes one detail row per minute to the four detail tables, of TABLES: metrics_system (CPU, memory, disk and load averages), metrics_network (bytes per second -received and sent per interface) and metrics_vpn (clients per kind). Each detail table has four +received and sent per interface), metrics_vpn (clients per kind) and metrics_site_to_site (per +site-to-site connection: up, 1 or 0, bytes and packets per second received and sent, and the +seconds its IKE SA has been up). Each detail table has four summary tables, of LEVELS: _hourly from the details,
_daily from the hourly,
_monthly from the daily and
_yearly from the monthly rows. A summary row has the start of its period, in UTC, and the average and the maximum of each value over the period: of @@ -28,6 +30,7 @@ TABLES = { 'metrics_system': ((), ('cpu', 'memory_used', 'memory_total', 'disk_free', 'disk_total', 'load1', 'load5', 'load15')), 'metrics_network': (('interface',), ('rx', 'tx')), 'metrics_vpn': (('kind',), ('clients',)), + 'metrics_site_to_site': (('connection',), ('up', 'rx', 'tx', 'packets_rx', 'packets_tx', 'uptime')), } #: The summary levels, from the finest: the suffix of the tables, the period of date_trunc and the retention setting. @@ -38,8 +41,8 @@ LEVELS = ( ('yearly', 'year', 'keep_yearly'), ) -#: The kinds of VPN clients of metrics_vpn; site_to_site is 1 while the tunnel is up. -VPN_KINDS = ('p2s', 'openvpn', 'wireguard', 'site_to_site') +#: The kinds of VPN clients of metrics_vpn. +VPN_KINDS = ('p2s', 'openvpn', 'wireguard') #: The views of history(): the summary level read (None: the details), the bucket and the span. RANGES = { @@ -82,11 +85,12 @@ def database(cfg): raise DatabaseError(f'the metrics database failed: {exc}') from exc -def write(connection, system, network, vpn): +def write(connection, system, network, vpn, site_to_site): """Write a sample as the detail rows of the current minute. system maps the values of metrics_system to numbers; network maps an interface to its rx and - tx; vpn maps a kind of VPN_KINDS to its clients. A value that is None is written as NULL. + tx; vpn maps a kind of VPN_KINDS to its clients; site_to_site maps a connection to the values + of metrics_site_to_site. A value that is None is written as NULL. """ with connection.transaction(): minute = connection.execute("SELECT date_trunc('minute', now(), 'UTC')").fetchone()[0] @@ -100,6 +104,11 @@ def write(connection, system, network, vpn): for kind, clients in vpn.items(): connection.execute('INSERT INTO metrics_vpn (time, kind, clients) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING', (minute, kind, clients)) + names = TABLES['metrics_site_to_site'][1] + for name, values in site_to_site.items(): + connection.execute(f"INSERT INTO metrics_site_to_site (time, connection, {', '.join(names)}) " + f"VALUES (%s, %s{', %s' * len(names)}) ON CONFLICT DO NOTHING", + (minute, name, *(values.get(value) for value in names))) def summarize(connection, level): @@ -136,8 +145,9 @@ def history(connection, view): """The points of a view of RANGES, oldest first, each with the start of its bucket in Unix seconds. A point has the average and the maximum of each value of metrics_system, network, which maps - each interface to its rx and tx, and clients, which maps each kind of VPN client to its - clients; a value without data is None. + each interface to its rx and tx, clients, which maps each kind of VPN client to its clients, + and site_to_site, which maps each site-to-site connection to the values of + metrics_site_to_site; a value without data is None. """ level, bucket, span = RANGES[view] @@ -161,7 +171,7 @@ def history(connection, view): points = {} def point(start): - return points.setdefault(start, {'time': start, 'network': {}, 'clients': {}}) + return points.setdefault(start, {'time': start, 'network': {}, 'clients': {}, 'site_to_site': {}}) for row in rows('metrics_system'): point(row[0]).update(pairs(row[1:], TABLES['metrics_system'][1])) @@ -169,5 +179,7 @@ def history(connection, view): point(row[0])['network'][row[1]] = pairs(row[2:], TABLES['metrics_network'][1]) for row in rows('metrics_vpn'): point(row[0])['clients'][row[1]] = pairs(row[2:], TABLES['metrics_vpn'][1])['clients'] + for row in rows('metrics_site_to_site'): + point(row[0])['site_to_site'][row[1]] = pairs(row[2:], TABLES['metrics_site_to_site'][1]) empty = {value: {'avg': None, 'max': None} for value in TABLES['metrics_system'][1]} return {'range': view, 'bucket': bucket, 'points': [{**empty, **points[start]} for start in sorted(points)]} diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_status.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_status.py index ef0edbe..4c73054 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_status.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_status.py @@ -1,7 +1,8 @@ """What the router's VPN systems report, for the web service and the metrics service. Each reader asks the running system once and answers None when it cannot be asked: swanctl for -the IKE SAs, wg for the WireGuard peers and the OpenVPN server's status file for its clients. +the IKE SAs and the state and traffic of the site-to-site connections, wg for the WireGuard +peers and the OpenVPN server's status file for its clients. """ import subprocess @@ -65,12 +66,45 @@ def swanctl(command): return [dict(body, name=name) for event in events for name, body in event.items()] -def established(connection): - """The number of established IKE SAs of the connection, or None when swanctl cannot be asked.""" +def established_each(connections): + """The number of established IKE SAs of each of the connections by name, None for each when swanctl cannot be asked.""" + sas = swanctl('--list-sas') + return {name: None if sas is None else sum(1 for sa in sas if sa['name'] == name and sa.get('state') == 'ESTABLISHED') + for name in connections} + + +def _count(text): + """A counter swanctl prints as a string, as an integer; 0 when it is not a whole number.""" + return int(text) if isinstance(text, str) and text.isdigit() else 0 + + +def site_to_site_tunnels(connections): + """The state of each of the connections by name, or None when swanctl cannot be asked. + + Each has up, 1 while it has an established IKE SA and 0 otherwise; uptime, the seconds since + its oldest established IKE SA was established, None while it is down; and bytes_in, + bytes_out, packets_in and packets_out, the sums over the child SAs of its established IKE + SAs. The sums start again from zero when a child SA is rekeyed. + """ sas = swanctl('--list-sas') if sas is None: return None - return sum(1 for sa in sas if sa['name'] == connection and sa.get('state') == 'ESTABLISHED') + tunnels = {} + for name in connections: + up = [sa for sa in sas if sa['name'] == name and sa.get('state') == 'ESTABLISHED'] + children = [child for sa in up for child in (sa.get('child-sas') or {}).values() if isinstance(child, dict)] + tunnels[name] = { + 'up': 1 if up else 0, + 'uptime': max((_count(sa.get('established')) for sa in up), default=None), + **{key.replace('-', '_'): sum(_count(child.get(key)) for child in children) + for key in ('bytes-in', 'bytes-out', 'packets-in', 'packets-out')}, + } + return tunnels + + +def established(connection): + """The number of established IKE SAs of the connection, or None when swanctl cannot be asked.""" + return established_each([connection])[connection] def wireguard_peers(): diff --git a/debian-package/src/usr/lib/vpn-router/web b/debian-package/src/usr/lib/vpn-router/web index f6a0333..e5bf93e 100644 --- a/debian-package/src/usr/lib/vpn-router/web +++ b/debian-package/src/usr/lib/vpn-router/web @@ -17,6 +17,9 @@ Pages: add and take out members (see groups()) /account the account of the signed-in user: its details, its password and its TOTP enrolment (see account()) + /site-to-site + the site-to-site connections of the configuration: list, add, change and remove, + applied by vpn-router-setup (see site_to_site()) /auth sign-in with a user's password and TOTP code, refresh and sign-out (see auth()) /metrics/history the metrics the metrics service keeps, for a view: hour, day, month or year (see @@ -25,7 +28,7 @@ Pages: the databases of each PostgreSQL cluster on the router with their sizes, as JSON (see postgresql_databases()); /config has the clusters' settings -/config, /metrics/history, /postgresql, /users, /groups and /account need an HS256 JWT in an Authorization: Bearer header signed +/config, /metrics/history, /postgresql, /users, /groups, /site-to-site and /account need an HS256 JWT in an Authorization: Bearer header signed with vpnrouter_auth.WEB_KEY (see vpnrouter_auth.verify). A token of /auth names a user in sub; /account serves that user, and the other paths need a member of vpnrouter_users.ADMIN_GROUP. A token of vpn-router-token has the admin claim and names no user; it may call every path but @@ -39,7 +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 below); standard also the package version, the installed version of vpn-router-manager (manager_version, null when it is not installed) and the number of active and other services; full -whether each service runs, whether Docker is installed, and the state of site-to-site, road-warrior +whether each service runs, whether Docker is installed, and the state of each site-to-site connection, road-warrior (IPsec P2S), OpenVPN and WireGuard. No configuration data or key is shown, and no address but the client's. @@ -80,7 +83,7 @@ import vpnrouter_metrics import vpnrouter_net import vpnrouter_serve import vpnrouter_users -from vpnrouter_status import established, openvpn_clients, run, swanctl, wireguard_peers +from vpnrouter_status import established, established_each, openvpn_clients, run, swanctl, wireguard_peers STATE_FILE = '/run/vpn-router/web.json' CONFIG_FILE = '/etc/vpn-router/vpn-router.json' @@ -111,6 +114,9 @@ POSTGRESQL_SETTINGS = ('server_version', 'listen_addresses', 'port', 'max_connec #: The largest request body accepted, in bytes. BODY_LIMIT = 4096 +#: The settings of a site_to_site connection that /site-to-site adds and changes. +CONNECTION_SETTINGS = ('addrs', 'id', 'cidrs', 'local_cidrs', 'local_id_mode', 'psk') + #: The HTTP status for each status of a vpnrouter_users.Failure; any other is 500. USER_STATUS = {2: 400, 3: 404, 4: 409, 5: 401} @@ -202,9 +208,10 @@ def collect(state): 'units': units, 'docker': docker_status(), } - if state.get('site_to_site'): - count = established('remote-site') - status['site_to_site'] = {'established': None if count is None else count > 0} + names = state.get('site_to_site') + if isinstance(names, list) and names: + status['site_to_site'] = {'connections': {name: None if count is None else count > 0 + for name, count in established_each(names).items()}} if state.get('p2s'): status['p2s'] = {'clients': established('road-warrior')} if state.get('wireguard'): @@ -365,9 +372,8 @@ def text_of(status): docker = status['docker'] lines.append('Docker: not installed' if not docker['installed'] else f'Docker: installed, service {docker["service"]}, rules {docker["rules"]}') - if 'site_to_site' in status: - value = status['site_to_site']['established'] - lines.append(f'Site to site: {"unknown" if value is None else "established" if value else "not established"}') + for name, value in status.get('site_to_site', {}).get('connections', {}).items(): + lines.append(f'Site to site {name}: {"unknown" if value is None else "established" if value else "not established"}') if 'p2s' in status: clients = status['p2s']['clients'] lines.append(f'Road-warrior (IPsec P2S): {"unknown" if clients is None else f"{clients} connected"}') @@ -527,6 +533,81 @@ def groups(state, method, path, body): return (201 if route == ('POST', 1, None) else 200), document +def connection_of(name, connection, count): + """A site_to_site connection as /site-to-site shows it: its settings but psk, psk_set and established. + + count is the number of its established IKE SAs, None when it is not known. + """ + return {'name': name, **{option: value for option, value in connection.items() if option != 'psk'}, + 'psk_set': bool(connection['psk']), 'established': None if count is None else count > 0} + + +def site_to_site(method, path, body): + """Answer a /site-to-site request: the HTTP status and the JSON document. + + GET /site-to-site the connections, {"connections": [...]}: each with its settings but psk, with + psk_set, whether psk holds a key, and established, whether its tunnel is up, + null when swanctl cannot be asked + POST /site-to-site add a connection: {"name", "addrs", "id", "cidrs", "local_cidrs", "local_id_mode", + "psk"}; psk is required, and an existing name is 409 + PUT /site-to-site/ 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/ remove the connection + + A change is made holding vpnrouter.config_lock, checked as vpn-router-setup checks the configuration (400 with + the reason), written with vpnrouter.save_config, and applied by restarting vpn-router-setup without waiting for it. + """ + if body == 'invalid' or (body is not None and not isinstance(body, dict)): + return 400, {'error': 'the body must be a JSON object'} + body = body or {} + parts = path.strip('/').split('/') + name = parts[1] if len(parts) == 2 else None + if (method, len(parts)) not in (('GET', 1), ('POST', 1), ('PUT', 2), ('DELETE', 2)): + return 404, {'error': f'no route {method} {path}'} + try: + if method == 'GET': + connections = vpnrouter.load_config()['site_to_site'] + counts = established_each(connections) + return 200, {'connections': [connection_of(key, value, counts[key]) for key, value in connections.items()]} + settings = {option: body[option] for option in CONNECTION_SETTINGS if option in body} + with vpnrouter.config_lock(): + data = vpnrouter.read_settings() + connections = data.setdefault('site_to_site', {}) + if method == 'POST': + name = body.get('name') + if not isinstance(name, str) or not name: + return 400, {'error': 'name is required'} + if name in connections: + return 409, {'error': f'connection {name} exists'} + if not isinstance(settings.get('psk'), str) or not settings['psk']: + return 400, {'error': 'psk is required'} + connections[name] = settings + elif name not in connections: + return 404, {'error': f'no connection {name}'} + elif method == 'PUT': + connection = dict(connections[name]) if isinstance(connections[name], dict) else {} + if settings.get('psk'): + connection.pop('psk_file', None) + else: + settings.pop('psk', None) + connections[name] = {**connection, **settings} + else: + del connections[name] + cfg = vpnrouter.with_defaults(data) + vpnrouter.validate(cfg) + vpnrouter.save_config(data) + except vpnrouter.ConfigError as exc: + return 400, {'error': str(exc)} + except OSError as exc: + return 500, {'error': f'the configuration cannot be written: {exc}'} + except SystemExit as exc: + return 500, {'error': str(exc.code)} + run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service') + if method == 'DELETE': + return 200, {'name': name, 'removed': True} + return (201 if method == 'POST' else 200), connection_of(name, cfg['site_to_site'][name], None) + + def account(name, method, path, body): """Answer an /account request of the user named by the token with vpnrouter_users: the HTTP status and the JSON document. @@ -714,7 +795,8 @@ class Handler(BaseHTTPRequestHandler): # A body that is not read must not be taken for the next request. if self.command not in ('GET', 'HEAD'): self.close_connection = True - directory = next((name for name in ('users', 'groups', 'account') if path == f'/{name}' or path.startswith(f'/{name}/')), None) + directory = next((name for name in ('users', 'groups', 'account', 'site-to-site') + if path == f'/{name}' or path.startswith(f'/{name}/')), None) protected = path in ('/config', '/metrics/history', '/postgresql') or directory is not None signing = path in ('/auth', '/auth/refresh', '/auth/logout') state = current() if protected or signing else {} @@ -751,6 +833,9 @@ class Handler(BaseHTTPRequestHandler): elif directory == 'account': code, document = account(claims['sub'], self.command, path, payload) body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json' + elif directory == 'site-to-site': + code, document = site_to_site(self.command, path, payload) + body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json' elif directory is not None: code, document = (users if directory == 'users' else groups)(state, self.command, path, payload) body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json' diff --git a/debian-package/src/usr/sbin/vpn-config-edit b/debian-package/src/usr/sbin/vpn-config-edit index 06a5ccf..42d126a 100755 --- a/debian-package/src/usr/sbin/vpn-config-edit +++ b/debian-package/src/usr/sbin/vpn-config-edit @@ -2,23 +2,26 @@ """Show and change the router's configuration, /etc/vpn-router/vpn-router.json. show prints the file as indented JSON; the file is not changed. set takes a setting as -section.option, such as web.port, and a value, which it converts to the type the package's -schema (vpnrouter.SCHEMA) gives the setting: a whole number, true or false, a list of strings -separated by commas (an empty value is an empty list), or a string. The configuration with the -new value is checked as vpn-router-setup checks it, and only then written, indented, with mode -0600; vpn-router-setup applies it at its next run, which set reminds of. A failure is a message -on standard error; the exit status is 2 for an invalid request and 1 for any other failure. --h or --help shows the options. +section.option, such as web.port, or as site_to_site.NAME.option for a site-to-site connection, +which it creates with the defaults (vpnrouter.CONNECTION) when it does not exist, and a value, +which it converts to the type the package's schema (vpnrouter.SCHEMA) gives the setting: a whole +number, true or false, a list of strings separated by commas (an empty value is an empty list), +or a string. remove takes site_to_site.NAME and removes that connection. The configuration with +the change is checked as vpn-router-setup checks it, and only then written with +vpnrouter.save_config, holding vpnrouter.config_lock; vpn-router-setup applies it at its next +run, which set and remove remind of. A failure is a message on standard error; the exit status +is 2 for an invalid request and 1 for any other failure. -h or --help shows the options. vpn-config-edit show vpn-config-edit set SECTION.OPTION VALUE + vpn-config-edit set site_to_site.NAME.OPTION VALUE + vpn-config-edit remove site_to_site.NAME """ import argparse +import copy import json -import os import sys -import tempfile sys.path.insert(0, '/usr/lib/vpn-router') @@ -43,48 +46,33 @@ class Parser(argparse.ArgumentParser): def read(): """The settings in the configuration file, without the defaults; {} when the file does not exist.""" try: - data = json.loads(vpnrouter.CONFIG_FILE.read_text(encoding='utf-8')) - except FileNotFoundError: - return {} - except ValueError as exc: - raise Failure(f'cannot parse {vpnrouter.CONFIG_FILE}: {exc}') - if not isinstance(data, dict) or not all(isinstance(options, dict) for options in data.values()): - raise Failure(f'cannot parse {vpnrouter.CONFIG_FILE}: it is not an object of sections') - return data + return vpnrouter.read_settings() + except vpnrouter.ConfigError as exc: + raise Failure(str(exc)) def converted(key, text): - """The value of text for the setting key, section.option, of the type the schema gives it.""" - section, _, option = key.partition('.') - if section not in vpnrouter.SCHEMA or option not in vpnrouter.SCHEMA[section]: - raise Failure(f'{key}: unknown setting; settings are section.option, such as web.port', 2) - kind = type(vpnrouter.SCHEMA[section][option]) + """The path of the setting key, section.option or site_to_site.NAME.option, and text as the type the schema gives it.""" + path = key.split('.') + if path[0] == 'site_to_site' and len(path) == 3 and path[2] in vpnrouter.CONNECTION: + kind = type(vpnrouter.CONNECTION[path[2]]) + elif path[0] != 'site_to_site' and len(path) == 2 and path[0] in vpnrouter.SCHEMA and path[1] in vpnrouter.SCHEMA[path[0]]: + kind = type(vpnrouter.SCHEMA[path[0]][path[1]]) + else: + raise Failure(f'{key}: unknown setting; settings are section.option, such as web.port, ' + 'or site_to_site.NAME.option', 2) if kind is bool: if text not in ('true', 'false'): raise Failure(f'{key} takes true or false', 2) - return text == 'true' + return path, text == 'true' if kind is int: try: - return int(text) + return path, int(text) except ValueError: raise Failure(f'{key} takes a whole number', 2) if kind is list: - return [item.strip() for item in text.split(',') if item.strip()] - return text - - -def write(data): - """Write the settings, indented, with mode 0600, replacing the file in one step.""" - directory = vpnrouter.CONFIG_FILE.parent - descriptor, name = tempfile.mkstemp(dir=directory, prefix='.vpn-router.json.') - try: - with os.fdopen(descriptor, 'w', encoding='utf-8') as handle: - handle.write(json.dumps(data, indent=2) + '\n') - os.chmod(name, 0o600) - os.replace(name, vpnrouter.CONFIG_FILE) - except OSError: - os.unlink(name) - raise + return path, [item.strip() for item in text.split(',') if item.strip()] + return path, text def main(argv): @@ -93,34 +81,49 @@ def main(argv): commands = parser.add_subparsers(dest='command', required=True, parser_class=Parser) commands.add_parser('show', help='print the configuration as indented JSON') setter = commands.add_parser('set', help='set a setting and write the configuration') - setter.add_argument('key', metavar='SECTION.OPTION', help='the setting, such as web.port') + setter.add_argument('key', metavar='SECTION.OPTION', + help='the setting, such as web.port or site_to_site.NAME.option') setter.add_argument('value', metavar='VALUE', help='its value; a list separated by commas') + remover = commands.add_parser('remove', help='remove a site-to-site connection and write the configuration') + remover.add_argument('key', metavar='site_to_site.NAME', help='the connection') options = parser.parse_args(argv) - data = read() if options.command == 'show': - print(json.dumps(data, indent=2)) + print(json.dumps(read(), indent=2)) return 0 - section, _, option = options.key.partition('.') - value = converted(options.key, options.value) - data.setdefault(section, {})[option] = value - # The configuration as vpn-router-setup reads it: the defaults with the file's settings. - candidate = {name: dict(defaults) for name, defaults in vpnrouter.SCHEMA.items()} - for name, settings in data.items(): - candidate.setdefault(name, {}).update(settings) - try: - vpnrouter.validate(candidate) - except vpnrouter.ConfigError as exc: - raise Failure(str(exc), 2) - write(data) + with vpnrouter.config_lock(): + data = read() + if options.command == 'set': + path, value = converted(options.key, options.value) + if path[0] == 'site_to_site': + connections = data.setdefault('site_to_site', {}) + connection = connections.setdefault(path[1], copy.deepcopy(vpnrouter.CONNECTION)) + if not isinstance(connection, dict): + raise Failure(f'site_to_site.{path[1]} is not an object') + connection[path[2]] = value + else: + data.setdefault(path[0], {})[path[1]] = value + else: + section, _, name = options.key.partition('.') + if section != 'site_to_site' or not name or name not in data.get('site_to_site', {}): + raise Failure(f'{options.key}: no such site-to-site connection', 2) + del data['site_to_site'][name] + try: + vpnrouter.validate(vpnrouter.with_defaults(data)) + except vpnrouter.ConfigError as exc: + raise Failure(str(exc), 2) + vpnrouter.save_config(data) except Failure as failure: print(f'vpn-config-edit: {failure}', file=sys.stderr) return failure.status except OSError as exc: print(f'vpn-config-edit: {exc}', file=sys.stderr) return 1 - print(f'{options.key} set to {json.dumps(value)} in {vpnrouter.CONFIG_FILE}.') + if options.command == 'set': + print(f'{options.key} set to {json.dumps(value)} in {vpnrouter.CONFIG_FILE}.') + else: + print(f'{options.key} removed from {vpnrouter.CONFIG_FILE}.') print('Apply it with: systemctl restart vpn-router-setup') return 0 diff --git a/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example b/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example index e8b2422..173f35d 100644 --- a/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example +++ b/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example @@ -8,20 +8,23 @@ "internal": "eth1" }, "wan": { - "local_fqdn": "router.example.com", - "local_id_mode": "fqdn" + "local_fqdn": "router.example.com" }, "local": { "cidrs": ["10.0.0.0/24"], "int_addr": "10.1.1.4", "int_gateway_ip": "10.1.1.1" }, - "remote": { - "addrs": ["peer.example.net"], - "id": "peer.example.net", - "cidrs": ["192.168.0.0/24"], - "psk": "change-me", - "psk_file": "" + "site_to_site": { + "remote-site": { + "addrs": ["peer.example.net"], + "id": "peer.example.net", + "cidrs": ["192.168.0.0/24"], + "local_cidrs": ["10.0.0.0/24"], + "local_id_mode": "fqdn", + "psk": "change-me", + "psk_file": "" + } }, "p2s": { "enabled": false, diff --git a/debian-package/src/usr/share/vpn-router/templates/remote-site.conf.j2 b/debian-package/src/usr/share/vpn-router/templates/remote-site.conf.j2 deleted file mode 100644 index df9c24e..0000000 --- a/debian-package/src/usr/share/vpn-router/templates/remote-site.conf.j2 +++ /dev/null @@ -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 }}" - } -} diff --git a/debian-package/src/usr/share/vpn-router/templates/site-to-site.conf.j2 b/debian-package/src/usr/share/vpn-router/templates/site-to-site.conf.j2 new file mode 100644 index 0000000..cc62ea8 --- /dev/null +++ b/debian-package/src/usr/share/vpn-router/templates/site-to-site.conf.j2 @@ -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 %} +} diff --git a/examples/azure/router.tf b/examples/azure/router.tf index 1bd31cd..7e8d6de 100644 --- a/examples/azure/router.tf +++ b/examples/azure/router.tf @@ -15,13 +15,17 @@ locals { # Contents of /etc/vpn-router/vpn-router.json. config = { general = { platform = "azure", mode = "auto" } - wan = { local_fqdn = var.local_fqdn, local_id_mode = var.local_id_mode } - local = { cidrs = [for c in split(",", var.local_cidrs) : trimspace(c) if trimspace(c) != ""] } - remote = { - addrs = [for a in split(",", var.remote_addrs) : trimspace(a) if trimspace(a) != ""] - id = var.remote_id - cidrs = local.remote_cidr_list - psk = var.psk + wan = { local_fqdn = var.local_fqdn } + local = { cidrs = local.local_cidr_list } + site_to_site = { + "remote-site" = { + addrs = [for a in split(",", var.remote_addrs) : trimspace(a) if trimspace(a) != ""] + id = var.remote_id + cidrs = local.remote_cidr_list + local_cidrs = local.local_cidr_list + local_id_mode = var.local_id_mode + psk = var.psk + } } p2s = { enabled = var.p2s_enabled, address_pool = var.p2s_address_pool, ca_name = "VPN Router CA" } wireguard = { enabled = var.wireguard_enabled, address = var.wireguard_address, listen_port = var.wireguard_listen_port } diff --git a/examples/azure/routes.tf b/examples/azure/routes.tf index 410eb94..085c387 100644 --- a/examples/azure/routes.tf +++ b/examples/azure/routes.tf @@ -1,5 +1,6 @@ locals { remote_cidr_list = [for c in split(",", var.remote_cidrs) : trimspace(c) if trimspace(c) != ""] + local_cidr_list = [for c in split(",", var.local_cidrs) : trimspace(c) if trimspace(c) != ""] } resource "azurerm_route_table" "router" { diff --git a/examples/gcp/router.tf b/examples/gcp/router.tf index 65e54ac..3525941 100644 --- a/examples/gcp/router.tf +++ b/examples/gcp/router.tf @@ -15,13 +15,17 @@ locals { # Contents of /etc/vpn-router/vpn-router.json. config = { general = { platform = "gcp", mode = "auto" } - wan = { local_fqdn = var.local_fqdn, local_id_mode = var.local_id_mode } - local = { cidrs = [for c in split(",", var.local_cidrs) : trimspace(c) if trimspace(c) != ""] } - remote = { - addrs = [for a in split(",", var.remote_addrs) : trimspace(a) if trimspace(a) != ""] - id = var.remote_id - cidrs = local.remote_cidr_list - psk = var.psk + wan = { local_fqdn = var.local_fqdn } + local = { cidrs = local.local_cidr_list } + site_to_site = { + "remote-site" = { + addrs = [for a in split(",", var.remote_addrs) : trimspace(a) if trimspace(a) != ""] + id = var.remote_id + cidrs = local.remote_cidr_list + local_cidrs = local.local_cidr_list + local_id_mode = var.local_id_mode + psk = var.psk + } } p2s = { enabled = var.p2s_enabled, address_pool = var.p2s_address_pool, ca_name = "VPN Router CA" } wireguard = { enabled = var.wireguard_enabled, address = var.wireguard_address, listen_port = var.wireguard_listen_port } diff --git a/examples/gcp/routes.tf b/examples/gcp/routes.tf index 905688f..d3684b6 100644 --- a/examples/gcp/routes.tf +++ b/examples/gcp/routes.tf @@ -1,5 +1,6 @@ locals { remote_cidr_list = [for c in split(",", var.remote_cidrs) : trimspace(c) if trimspace(c) != ""] + local_cidr_list = [for c in split(",", var.local_cidrs) : trimspace(c) if trimspace(c) != ""] } resource "google_compute_route" "to_remote" { diff --git a/examples/install.sh b/examples/install.sh index 6f2a199..e189801 100755 --- a/examples/install.sh +++ b/examples/install.sh @@ -47,19 +47,22 @@ cat > /etc/vpn-router/vpn-router.json <