Compare commits

...
8 Commits
Author SHA1 Message Date
slawek 1cef4c044f fix: /metrics/history gives the availability of each site-to-site connection as the percentage of time it was up in slots of the view.
Release / release (push) Successful in 30s
2026-10-25 18:49:47 +01:00
slawek 25c151841b feature: service accounts have a display name and a description, which the router sets for its internal ones.
Release / release (push) Successful in 20s
2026-10-25 18:34:59 +01:00
slawek 68ebd541b2 fix: internal service accounts are the router's own, of which the metrics service's is the one at present, and the documentation says what a service account is.
Release / release (push) Successful in 13s
2026-10-25 18:27:10 +01:00
slawek f01be41191 fix: service accounts are managed at /service-accounts, not /users, and the internal one of the metrics service is only listed.
Release / release (push) Successful in 12s
2026-10-25 18:18:03 +01:00
slawek d1970b77b7 fix: vpn-router-setup owns only the creation of the metrics service's account, which is marked internal, and administrators add service accounts with vpn-router-user add --service and /users.
Release / release (push) Successful in 32s
2026-10-25 18:00:25 +01:00
slawek bf651da5a6 update: release.py tags a Debian revision other than 1 as x.y.z-n.
Release / release (push) Successful in 19s
2026-10-25 17:32:48 +01:00
slawek 4a5b15a559 feature: site_to_site holds any number of named site-to-site connections, which /site-to-site lists, adds, changes and removes.
Release / release (push) Successful in 12s
2026-10-25 17:14:23 +01:00
slawek a79510a02f feature: /config shows the router's PostgreSQL clusters and their settings, and /postgresql their databases with their sizes.
Release / release (push) Successful in 28s
2026-10-25 15:26:54 +01:00
26 changed files with 1310 additions and 409 deletions

No files matched your search

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