Compare commits

..
4 Commits
21 changed files with 583 additions and 303 deletions

No files matched your search

+41 -16
View File
@@ -537,7 +537,9 @@ unchanged. Both services always listen on `127.0.0.1` as well as on the interfac
never takes from the client. Let's Encrypt issues at most 5 certificates a week for the same
names, which routers rebuilt for tests reach; `https.acme_staging` sets Caddy's global `acme_ca`
to Let's Encrypt's staging directory, which replaces Caddy's default CAs for every site, so no
test certificate comes from a production CA.
test certificate comes from a production CA. `PUT /https/settings` changes `acme_staging` alone:
the other settings of the section switch HTTPS off or move its ports, which would end the
connection the Router Manager makes the change on.
Consequences: HTTPS needs `wan.local_fqdn` in public DNS pointing at the router and port 80
reachable from the internet; until then Caddy retries and the HTTPS ports do not answer. The
@@ -1209,8 +1211,9 @@ later. S3 and Azure Blob clients are not served.
Status: Accepted
Context: The router hosts authoritative domains as their primary, with optional secondary name
servers, and a domain can give the sites their records without a registrar change per site. Ubuntu
Context: The router hosts authoritative domains as their primary, with a secondary name server, and
a domain can give the sites their records without a registrar change per site. The router's own
name, `wan.local_fqdn`, belongs to a domain of its own, wherever that is hosted. Ubuntu
has BIND, Knot and PowerDNS; only PowerDNS's gpgsql backend reads records
from PostgreSQL, so changes are rows, without zone files or reloads. systemd-resolved's stub holds
127.0.0.53 and 127.0.0.54, and the remote access clients' resolver answered on the external
@@ -1220,25 +1223,33 @@ just as well, through the tunnel when it is in `local.cidrs` (ADR-43).
Decision: While `dns.enabled` is true, setup runs `pdns.service` with
`/etc/powerdns/pdns.d/vpn-router.conf`, written before the packages are installed, on the
addresses of `dns.listen`, with the gpgsql backend on the database `vpn-router-dns` and PowerDNS's
own schema, and `primary=yes`. The domains, their secondaries and manual records are rows of the
router's tables in that database, like the sites in `vpn-router-sites`, not settings of
`vpn-router.json`: they are many and change through the web service. `dns.nameserver` and
`dns.addresses` stay settings, the router's one identity as the primary NS of every domain. Setup
own schema, and `primary=yes`. The domains and their manual records are rows of the router's tables
in that database, like the sites in `vpn-router-sites`, not settings of `vpn-router.json`: they are
many and change through the web service. The name servers are settings, the same for every domain:
`wan.local_fqdn`, the router's one name, is the primary, and `dns.secondary` the secondary. Setup
writes each domain as a MASTER zone through the procedure `dns_apply` of
`/usr/share/vpn-router/sql/dns.sql`: SOA, NS of the router and the secondaries, A and AAAA records
of `dns.addresses` for the apex, the name server and, with the domain's site records switch, every
site host name whose closest domain it is, and the manual records; the serial grows only when the
records change, which makes PowerDNS notify. A secondary is a name, its NS, and an address, which
goes to the domain's `ALSO-NOTIFY` and `ALLOW-AXFR-FROM` metadata, so the global
`allow-axfr-ips` stays the loopback default. A host name belongs to its closest domain only, and a
`/usr/share/vpn-router/sql/dns.sql`: SOA with `wan.local_fqdn`, NS of `wan.local_fqdn` and
`dns.secondary`, an A or AAAA record of every host name of a site with `dns` `router` whose closest
domain it is, with the router's address the site chooses (`wan.public_address`, the internal or the
external interface's), and the manual records; the serial grows only when the records change, which
makes PowerDNS notify. `wan.local_fqdn` gets no record in the domains: its A record is in the DNS of
its own domain, which the router does not manage. A site of `dns` `user` gets no records, as its
names are served elsewhere. The addresses `dns.secondary` resolves to at each run go to every
domain's `ALSO-NOTIFY` and `ALLOW-AXFR-FROM` metadata, so the global `allow-axfr-ips` stays the
loopback default. The public address is a setting, since the router sees only private addresses on
its interfaces behind a cloud's NAT and resolving `wan.local_fqdn` would answer from its own DNS
when it hosts that name. `dns.nameserver` and `dns.addresses` of an earlier configuration are left
out when it is read, so that it loads; a missing `dns.secondary` makes setup warn, not fail, so that
a router keeps running until it is set. A host name belongs to its closest domain only, and a
domain inside another is delegated from it with NS records, so a name is never answered by two
zones. The remote access clients' resolver answers on the addresses of the setting
`p2s.dns_listen`, by default the internal one; a `dns.listen` that shares an interface with it is
refused while P2S or OpenVPN is enabled.
Consequences: The domains follow the sites at each setup run. Transfers are authorized by address,
without TSIG, and there is no DNSSEC; the delegation of each domain is made at its parent zone by
hand.
Consequences: The domains follow the sites at each setup run. Transfers are authorized by the
address the secondary's name resolves to, without TSIG, and there is no DNSSEC; the delegation of
each domain, with glue for the name servers inside it, is made at its parent zone by hand. Every
domain has the same two name servers.
## ADR-43: The remote access clients' resolver is dnsmasq, not systemd-resolved
@@ -1371,3 +1382,17 @@ one line with its type and message.
Consequences: The logs hold one line per failure, without its stack. A failure that reaches the
last line of defence is found by its message and fixed where it occurs, with its own handling.
## ADR-49: A changelog summary is a paragraph wrapped at 80 columns
Status: Accepted
Context: The summary of an entry of `debian/changelog` is the commit message of its release,
which `release.py` reads from the changelog. Summaries are often longer than the 80 columns
Debian's tools expect of a changelog line.
Decision: The summary is the entry's first paragraph, indented by two spaces and wrapped at 80
columns. `release.py` joins its lines with single spaces into the commit message.
Consequences: The commit message is one line however the summary is wrapped, so a summary
rewrapped later still matches the commit of its release.
+59 -30
View File
@@ -15,7 +15,7 @@ A Debian package that configures a Linux host or virtual machine as a VPN router
- metrics of the router kept in PostgreSQL, served for an OpenTelemetry Collector and pushed
over OTLP
- static web sites served by Caddy, uploaded through the web service or WebDAV, and an optional
primary name server (PowerDNS) of the router's domains, with secondaries and records for the sites
primary name server (PowerDNS) of the router's domains, with a secondary and records for the sites
Nothing in the package is specific to a cloud provider. It is built on the external and
internal NIC names, and a `mode` setting says how much of the rest you are supplying and
@@ -108,7 +108,8 @@ must have the type shown below; an unknown section or setting is an error.
| `general` | `mode` | string | `manual` | How much you supply: `manual`, `interfaces` or `auto` |
| `interfaces` | `external` | string | | Name of the NIC facing the untrusted network |
| `interfaces` | `internal` | string | | Name of the NIC facing the protected network |
| `wan` | `local_fqdn` | string | | This router's name or IP address: its IKE identity in `fqdn` mode, the name resolved in `public_ip` mode, and the name of HTTPS, IKEv2 and OpenVPN |
| `wan` | `local_fqdn` | string | | This router's name or IP address: its IKE identity in `fqdn` mode, the name resolved in `public_ip` mode, the name of HTTPS, IKEv2 and OpenVPN, and the primary name server of its domains |
| `wan` | `public_address` | string | | The router's public IP address, which its own interfaces do not show behind a cloud's NAT: the address of the records of the sites with `dns_address` `public` |
| `local` | `cidrs` | list | | Local subnets routed via the internal side, masqueraded to the external one, and offered to IKEv2 and OpenVPN clients |
| `local` | `int_addr` | string | | This host's address on the internal network |
| `local` | `int_gateway_ip` | string | | Next hop on the internal side |
@@ -148,9 +149,8 @@ must have the type shown below; an unknown section or setting is an error.
| `sites` | `upload_max_mb` | integer | `512` | The largest upload of a site's file or zip archive, in MiB, 1 to 10240 |
| `sites` | `webdav` | boolean | `false` | WebDAV of the sites' folders, which needs `https.enabled` and `wan.local_fqdn` (see WebDAV) |
| `sites` | `webdav_port` | integer | `7445` | HTTPS port of WebDAV |
| `dns` | `enabled` | boolean | `false` | The primary name server of the router's domains, which needs `dns.nameserver` and `dns.addresses` (see Name server) |
| `dns` | `nameserver` | string | | The name server's host name, the primary NS of every domain |
| `dns` | `addresses` | list | | The public addresses of the name server, the domains' apexes and the sites' host names |
| `dns` | `enabled` | boolean | `false` | The primary name server of the router's domains, `wan.local_fqdn`, which must then be a DNS name (see Name server) |
| `dns` | `secondary` | string | | The secondary name server of every domain, a host name other than `wan.local_fqdn`; it may transfer the domains |
| `dns` | `listen` | string | `external` | The interfaces the name server answers on: `internal`, `external` or `both` |
| `dns` | `ttl` | integer | `300` | TTL of the records, 60 to 86400 seconds |
| `pki` | `root_name` | string | `VPN Router Root CA` | Common name of the root CA, when the router creates it |
@@ -1018,7 +1018,7 @@ curl http://10.0.0.4:7081/status
### Tokens and signing in
`/config`, `/metrics/history`, `/metrics/settings`, `/users`, `/service-accounts`, `/groups`, `/site-to-site`, `/backup`, `/remote-access`, `/ca`, `/logs`, `/sites`, `/dns`, `/shell` and `/account` need a JWT in an `Authorization: Bearer` header, signed with
`/config`, `/metrics/history`, `/metrics/settings`, `/https/settings`, `/users`, `/service-accounts`, `/groups`, `/site-to-site`, `/backup`, `/remote-access`, `/ca`, `/logs`, `/sites`, `/dns`, `/shell` and `/account` need a JWT in an `Authorization: Bearer` header, signed with
HS256 by the key the web service makes on its first run, `/etc/vpn-router/web.key` (root only).
The key never leaves the router; while it does not exist these paths and `/auth` are not served
(404). A missing, expired or wrongly signed token, or one of another algorithm, gets 401 with
@@ -1226,13 +1226,11 @@ curl -X PUT -H "Authorization: Bearer $TOKEN" --data-binary @site.zip \
| Request | Answer |
| --- | --- |
| `GET /dns` | `{"settings", "installed", "running", "domains", "problem"}`: the `dns` section, whether PowerDNS is installed and runs, the domains, each `{"name", "sites", "ttl", "secondaries", "manual", "records"}` with its secondaries `{"nameserver", "address"}`, its manual records and the records it serves, and why they cannot be read, `null` when they can |
| `PUT /dns` | Changes any of the settings of the `dns` section |
| `POST /dns/domains` | Adds a domain: `{"name", "sites"?, "ttl"?}`; an existing name is 409 |
| `PUT /dns/domains/<domain>` | Changes `sites` or `ttl` of the domain |
| `DELETE /dns/domains/<domain>` | Removes the domain with its secondaries and manual records |
| `POST /dns/domains/<domain>/secondaries` | Adds a secondary name server, or changes its address: `{"nameserver", "address"}` |
| `DELETE /dns/domains/<domain>/secondaries/<nameserver>` | Removes the secondary name server |
| `GET /dns` | `{"settings", "nameserver", "public_address", "installed", "running", "domains", "problem"}`: the `dns` section, `wan.local_fqdn`, the primary name server, `wan.public_address`, whether PowerDNS is installed and runs, the domains, each `{"name", "ttl", "manual", "records"}` with its manual records and the records it serves, and why they cannot be read, `null` when they can |
| `PUT /dns` | Changes any of the settings of the `dns` section, and with `public_address` `wan.public_address`; answers `{"settings", "public_address"}` |
| `POST /dns/domains` | Adds a domain: `{"name", "ttl"?}`; an existing name is 409 |
| `PUT /dns/domains/<domain>` | Changes `ttl` of the domain |
| `DELETE /dns/domains/<domain>` | Removes the domain with its manual records |
| `POST /dns/domains/<domain>/records` | Adds a manual record: `{"name", "type", "content", "ttl"?}`; `name` is relative to the domain, `@` for the domain itself, or absolute with a final dot, and `ttl` is the domain's without it |
| `DELETE /dns/domains/<domain>/records?name=&type=&content=` | Removes the manual records of the name and type, only the one of `content` when it is given |
@@ -1405,6 +1403,18 @@ A change is made, checked and applied as for `/site-to-site`.
A change is made, checked and applied as for `/site-to-site`.
### The /https/settings API
`/https/settings` shows the settings of HTTPS and changes the CA of Caddy's certificates, with the
same token as `/users`.
| Request | Body | Answer |
| --- | --- | --- |
| `GET /https/settings` | | `{"settings"}`: the `https` section |
| `PUT /https/settings` | `{"acme_staging"}`; any other setting of the `https` section is 400, as it is changed on the router with `vpn-config-edit` | `{"settings"}` |
A change is made, checked and applied as for `/site-to-site`.
### The /remote-access API
`/remote-access` shows and changes the remote access systems, IKEv2 (`p2s`),
@@ -1484,6 +1494,9 @@ obtains from an ACME CA, those of the web sites with `tls` `acme` included.
vpn-config-edit set --restart https.acme_staging true
```
The Router Manager switches it on the Overview, and `PUT /https/settings` changes it (see The
/https/settings API).
`vpn-router-setup` writes Caddy's configuration to `/etc/caddy/vpn-router.Caddyfile` (root, group
`caddy`, the group Caddy runs as, mode 0640), which the
package's drop-in `/usr/lib/systemd/system/caddy.service.d/vpn-router.conf` makes `caddy.service`
@@ -1726,10 +1739,15 @@ Router Manager, each change applied by restarting `vpn-router-setup`. A site has
| `index_file` | The file served for a folder, `index.html` by default |
| `spa` | Whether a path without a file is served the site's `index_file`, for single-page applications |
| `editors` | The group whose members may change the site's files besides the admins |
| `dns` | Who serves the records of its host names: `user` (the default), a DNS provider outside the router, or `router`, the router's name server (see Name server) |
| `dns_address` | With `dns` `router`, the router's address in the records: `public` (the default, `wan.public_address`), `internal` or `external` |
A site may not take a port of the router's own services (SSH, `web.port`, the HTTPS ports, the
metrics ports, the Router Manager's, `sites.webdav_port`, 7446, 53, the bootstrap ports, a TCP
`openvpn.port` and `docker.exposed_ports`) or `wan.local_fqdn`. Enabled sites on one port need host
`openvpn.port` and `docker.exposed_ports`). It may use `wan.local_fqdn` as a host name, on a port
of its own, such as 443, which the router's services leave free; its record is then kept where the
domain of `wan.local_fqdn` is, so `dns` `router` takes neither it, nor wildcards or addresses, and
`public` needs `wan.public_address`. Enabled sites on one port need host
names of their own, the same `listen` and the same kind of TLS (plain HTTP or HTTPS). A site of
`acme` needs host names, without wildcards or addresses, that resolve to the router, and port 80,
which setup opens on the external interface. The certificate of `file` must be the key's and name
@@ -1788,32 +1806,43 @@ missing, without the recommended bind backend.
| Setting | Meaning |
| --- | --- |
| `dns.nameserver` | The name server's host name, the primary NS and the SOA's primary of every domain |
| `dns.addresses` | The public addresses of the domains' apexes, of the name server in a domain it is inside, and of the sites' host names |
| `wan.local_fqdn` | The primary name server of every domain: the SOA's primary and the first NS |
| `dns.secondary` | The secondary name server of every domain, the second NS; the addresses it resolves to are notified of every change and may transfer the domains (AXFR) |
| `dns.ttl` | The TTL of the records and the SOA's negative TTL of a domain without its own, 60 to 86400 seconds, 300 by default |
A domain the router hosts, for example `example.com`, with `wan.local_fqdn` `router.other.net`,
`dns.secondary` `ns2.provider.net` and a site of `www.example.com` and `example.com` with `dns`
`router` and `dns_address` `public` (`wan.public_address` `203.0.113.10`):
```text
example.com. SOA router.other.net. hostmaster.example.com. <serial> 10800 3600 604800 300
example.com. NS router.other.net.
example.com. NS ns2.provider.net.
example.com. A 203.0.113.10
www.example.com. A 203.0.113.10
```
The domains are added with `/dns` or the Router Manager, each with:
- **Site records**: every host name of an enabled site gets A and AAAA records of `dns.addresses`
in the closest domain it is in, when that domain has site records;
- **TTL**: the TTL of its records, `dns.ttl` when not set;
- **Secondaries**: name servers, each a host name, which becomes an NS of the domain, and the
address it transfers the domain from. The router notifies that address of every change and
allows it zone transfers (AXFR); a secondary's name inside the domain gets an A or AAAA record of
that address;
- **Manual records** (A, AAAA, CNAME, TXT, MX, SRV, CAA), served too, but not those of a name and
type the domain's own records have.
A domain inside another domain of the router is delegated from it with NS records. Setup writes the
domains at each run, and after each change through `/dns`; the SOA's serial grows only when the
records change. While IKEv2, OpenVPN or WireGuard is enabled, dnsmasq answers their clients on port 53
of the addresses of `p2s.dns_listen`, by default the internal address, so `dns.listen` must not
share an interface with it. The parent zone of each domain must delegate it to `dns.nameserver` and
the secondaries, with glue records for name servers inside the domain.
The host names of an enabled site with `dns` `router` get an A or AAAA record of its `dns_address`
in the closest domain they are in; a site with `dns` `user` gets none, and a host name in no domain
of the router gets none either. `wan.local_fqdn` gets no record in the domains: its A record is kept
by the DNS of its own domain, wherever that is. A domain inside another domain of the router is
delegated from it with NS records. Setup writes the domains at each run, and after each change
through `/dns`; the SOA's serial grows only when the records change. While IKEv2, OpenVPN or
WireGuard is enabled, dnsmasq answers their clients on port 53 of the addresses of
`p2s.dns_listen`, by default the internal address, so `dns.listen` must not share an interface with
it. The parent zone of each domain must delegate it to `wan.local_fqdn` and `dns.secondary`, with
glue records for those of them inside the domain. While `dns.secondary` is not set, the domains have
`wan.local_fqdn` as their only name server, and setup warns.
```sh
vpn-config-edit set dns.nameserver ns1.example.com
vpn-config-edit set dns.addresses 203.0.113.10
vpn-config-edit set dns.secondary ns2.provider.net
vpn-config-edit set wan.public_address 203.0.113.10
vpn-config-edit set --restart dns.enabled true
```
+198 -60
View File
@@ -1,6 +1,65 @@
vpn-router (0.39.1-1) unstable; urgency=medium
fix: the web service starts again after its first start with 0.39.0, and the
name server's domains can be changed from any process.
* The schemas of the web sites and of the name server can be applied any
number of times, so the web service no longer keeps retrying with
'function "site_put" already exists' and never listening, and
vpn-router-site and setup no longer fail the same way.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 06 Nov 2026 20:16:57 +0100
vpn-router (0.39.0-2) unstable; urgency=medium
update: vpn-router is published in the koszewscy package registry.
* publish.sh uploads to the owner koszewscy unless OWNER names another.
* repo_url of the Azure and GCP examples defaults to
https://gitea.koszewscy.waw.pl/api/packages/koszewscy/debian.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 06 Nov 2026 20:08:29 +0100
vpn-router (0.39.0-1) unstable; urgency=medium
feature: the name server's domains have wan.local_fqdn and dns.secondary as
their name servers, and a site with dns router gets records of the router's
public, internal or external address.
* The SOA and the first NS of every domain are wan.local_fqdn, and the
second NS is dns.secondary, a host name, whose addresses are notified
of every change and may transfer the domains; dns.nameserver is gone.
* wan.local_fqdn gets no record in the domains; its record is kept in
the DNS of its own domain.
* A site has dns, user (the default), served elsewhere, or router, whose
host names get an A or AAAA record in their closest domain, with
dns_address public (wan.public_address, new), internal or external.
dns.addresses, the domains' site records switch and the per-domain
secondaries with their routes are gone; dns.nameserver and
dns.addresses of an earlier configuration are left out when read.
* A site may use wan.local_fqdn as a host name on a port of its own.
* GET /dns answers nameserver, wan.local_fqdn, and public_address,
which PUT /dns changes.
* The summaries of the changelog entries are wrapped at 80 columns, and
release.py joins the lines of a summary into the commit message.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 30 Oct 2026 21:30:01 +0100
vpn-router (0.38.1-1) unstable; urgency=medium
fix: /https/settings shows the https section and switches https.acme_staging,
for the Router Manager.
* GET /https/settings shows the https section, and PUT /https/settings
changes acme_staging; the other settings of the section stay with
vpn-config-edit.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 30 Oct 2026 02:43:22 +0100
vpn-router (0.38.0-1) unstable; urgency=medium
feature: https.acme_staging takes Caddy's certificates from Let's Encrypt's staging environment.
feature: https.acme_staging takes Caddy's certificates from Let's Encrypt's
staging environment.
* While https.acme_staging is true, Caddy's only CA is Let's Encrypt's
staging environment, for the router's services and the web sites
@@ -10,7 +69,8 @@ vpn-router (0.38.0-1) unstable; urgency=medium
vpn-router (0.37.3-1) unstable; urgency=medium
fix: an answer whose client went away ends quietly, and a shell whose client left before it opened is stopped.
fix: an answer whose client went away ends quietly, and a shell whose client
left before it opened is stopped.
* The web service, the metrics service and the bootstrap server stop
writing an answer when its client closes the connection, and a site
@@ -22,7 +82,9 @@ vpn-router (0.37.3-1) unstable; urgency=medium
vpn-router (0.37.2-1) unstable; urgency=medium
fix: the web service, the metrics service and the bootstrap server log a failure in one line instead of a traceback, and nothing when a client goes away.
fix: the web service, the metrics service and the bootstrap server log a
failure in one line instead of a traceback, and nothing when a client goes
away.
* A request whose client closed the connection is no longer logged.
* Any other failure of a request, a thread or the service is logged in
@@ -41,7 +103,9 @@ vpn-router (0.37.1-1) unstable; urgency=medium
vpn-router (0.37.0-1) unstable; urgency=medium
feature: the OpenID Provider has the client credentials and device authorization grants, public clients, and service accounts as resources with roles and scopes in JWT access tokens.
feature: the OpenID Provider has the client credentials and device
authorization grants, public clients, and service accounts as resources with
roles and scopes in JWT access tokens.
* A client may have the grants client_credentials, by which a
confidential client gets an access token as its own service account,
@@ -70,7 +134,8 @@ vpn-router (0.37.0-1) unstable; urgency=medium
vpn-router (0.36.0-1) unstable; urgency=medium
feature: a shell opens with one of the user's passkeys in place of the password and the TOTP code.
feature: a shell opens with one of the user's passkeys in place of the
password and the TOTP code.
* POST /shell/passkey begins a proof for one of the signed-in user's own
passkeys, with user verification; POST /shell takes the browser's
@@ -84,7 +149,8 @@ vpn-router (0.36.0-1) unstable; urgency=medium
vpn-router (0.35.2-2) unstable; urgency=medium
update: the package is published for Ubuntu 26.04 (resolute) as well as 24.04 (noble).
update: the package is published for Ubuntu 26.04 (resolute) as well as 24.04
(noble).
* publish.sh uploads the package to the distributions noble and resolute
of the repository, component main, skipping a distribution that has
@@ -95,7 +161,8 @@ vpn-router (0.35.2-2) unstable; urgency=medium
vpn-router (0.35.2-1) unstable; urgency=medium
fix: the web service answers /, /status and /status/full again while IKEv2 runs.
fix: the web service answers /, /status and /status/full again while IKEv2
runs.
* The status of IKEv2 clients is collected again; the requests that
collect the status were dropped with a NameError since 0.35.1.
@@ -104,7 +171,8 @@ vpn-router (0.35.2-1) unstable; urgency=medium
vpn-router (0.35.1-1) unstable; urgency=medium
update: the router web service is split into the package vpnrouter_web, with one module per family of paths.
update: the router web service is split into the package vpnrouter_web, with
one module per family of paths.
* /usr/lib/vpn-router/web initializes and serves; the package
/usr/lib/vpn-router/vpnrouter_web/ answers the requests, with a module
@@ -116,7 +184,8 @@ vpn-router (0.35.1-1) unstable; urgency=medium
vpn-router (0.35.0-1) unstable; urgency=medium
feature: vre opens files from the Router Manager's shell in the editor of its Shell page.
feature: vre opens files from the Router Manager's shell in the editor of its
Shell page.
* vpn-router-editor FILE..., or vre, in the shell of the Router Manager
opens the files in the editor of the Shell page and returns at once;
@@ -131,7 +200,9 @@ vpn-router (0.35.0-1) unstable; urgency=medium
vpn-router (0.34.0-1) unstable; urgency=medium
feature: the web service shows and changes the settings of the metrics service at /metrics/settings, and OpenVPN starts on a new router, as setup installs CRLs that revoke nothing.
feature: the web service shows and changes the settings of the metrics service
at /metrics/settings, and OpenVPN starts on a new router, as setup installs
CRLs that revoke nothing.
* GET and PUT /metrics/settings show and change the metrics section;
otlp_password is never shown, an empty one keeps it, and an empty
@@ -164,7 +235,8 @@ vpn-router (0.33.2-1) unstable; urgency=medium
vpn-router (0.33.1-1) unstable; urgency=medium
fix: the name server is the primary of any number of domains, each with secondary name servers and a switch for the records of the sites.
fix: the name server is the primary of any number of domains, each with
secondary name servers and a switch for the records of the sites.
* The domains, their secondaries and manual records are kept in the
vpn-router-dns database and changed through /dns/domains; dns.zone is
@@ -182,7 +254,8 @@ vpn-router (0.33.1-1) unstable; urgency=medium
vpn-router (0.33.0-1) unstable; urgency=medium
feature: road-warrior clients resolve the domains of the new setting p2s.dns_domains with the router's resolver.
feature: road-warrior clients resolve the domains of the new setting
p2s.dns_domains with the router's resolver.
* p2s.dns_domains lists up to 16 domains; setup pushes them to IKEv2
clients as INTERNAL_DNS_DOMAIN (RFC 8598, attribute 25) of the
@@ -194,7 +267,8 @@ vpn-router (0.33.0-1) unstable; urgency=medium
vpn-router (0.32.1-1) unstable; urgency=medium
fix: a VPN user downloads the own OpenVPN client profile through /account, with the current password and code.
fix: a VPN user downloads the own OpenVPN client profile through /account,
with the current password and code.
* POST /account/profile answers the user's OpenVPN client profile; it
needs the current password and, when enrolled, a code.
@@ -203,7 +277,8 @@ vpn-router (0.32.1-1) unstable; urgency=medium
vpn-router (0.32.0-1) unstable; urgency=medium
feature: the web service gives Apple VPN profiles that set up IKEv2 road-warrior access on macOS, iOS and iPadOS.
feature: the web service gives Apple VPN profiles that set up IKEv2
road-warrior access on macOS, iOS and iPadOS.
* POST /users/<name>/apple-profile and POST /account/apple-profile answer
an unsigned .mobileconfig with the P2S CA and the root CA, the user's
@@ -217,7 +292,9 @@ vpn-router (0.32.0-1) unstable; urgency=medium
vpn-router (0.31.3-1) unstable; urgency=medium
fix: WireGuard clients may use the router's DNS resolver, which runs while WireGuard does, and /remote-access names its addresses for their configuration.
fix: WireGuard clients may use the router's DNS resolver, which runs while
WireGuard does, and /remote-access names its addresses for their
configuration.
* The firewall opens port 53 of the remote access clients' resolver for
the WireGuard network, as it does for the IKEv2 and OpenVPN pools.
@@ -231,7 +308,8 @@ vpn-router (0.31.3-1) unstable; urgency=medium
vpn-router (0.31.2-1) unstable; urgency=medium
fix: dnsmasq answers the remote access clients' DNS queries, so they no longer get the router's own host name with all its addresses from systemd-resolved.
fix: dnsmasq answers the remote access clients' DNS queries, so they no longer
get the router's own host name with all its addresses from systemd-resolved.
* vpn-router-resolver.service runs dnsmasq on the addresses of
p2s.dns_listen, forwarding to systemd-resolved's stub on 127.0.0.53;
@@ -246,7 +324,9 @@ vpn-router (0.31.2-1) unstable; urgency=medium
vpn-router (0.31.1-1) unstable; urgency=medium
fix: the remote access clients' resolver answers on the internal address by default, chosen by p2s.dns_listen, so the name server can take port 53 of the external addresses.
fix: the remote access clients' resolver answers on the internal address by
default, chosen by p2s.dns_listen, so the name server can take port 53 of the
external addresses.
* The new setting p2s.dns_listen chooses the addresses of the resolver the
road-warrior and OpenVPN clients are given: internal, the default,
@@ -259,7 +339,9 @@ vpn-router (0.31.1-1) unstable; urgency=medium
vpn-router (0.31.0-1) unstable; urgency=medium
feature: Caddy serves static web sites from folders on the router, defined in their own database, with files uploaded through the web service or WebDAV, and an optional PowerDNS name server answers for a zone of the sites.
feature: Caddy serves static web sites from folders on the router, defined in
their own database, with files uploaded through the web service or WebDAV, and
an optional PowerDNS name server answers for a zone of the sites.
* The new database vpn-router-sites holds the web sites: host names,
port, interfaces, TLS (none, Let's Encrypt, an issuing CA or a
@@ -295,7 +377,8 @@ vpn-router (0.31.0-1) unstable; urgency=medium
vpn-router (0.30.0-1) unstable; urgency=medium
feature: the web service serves the last lines of the journal of the router's units and of the log files web.log_files lists, at /logs.
feature: the web service serves the last lines of the journal of the router's
units and of the log files web.log_files lists, at /logs.
* GET /logs lists the units whose journal and the files whose lines it
serves; GET /logs/journal?unit=&lines= and /logs/file?path=&lines=
@@ -308,7 +391,8 @@ vpn-router (0.30.0-1) unstable; urgency=medium
vpn-router (0.29.0-1) unstable; urgency=medium
feature: vpn-config-edit set and remove take --restart, which applies the change with vpn-router-setup and waits for it.
feature: vpn-config-edit set and remove take --restart, which applies the
change with vpn-router-setup and waits for it.
* vpn-config-edit set --restart and remove --restart restart
vpn-router-setup after writing the configuration and wait until it has
@@ -319,7 +403,8 @@ vpn-router (0.29.0-1) unstable; urgency=medium
vpn-router (0.28.0-1) unstable; urgency=medium
feature: bash completes the vpn-* commands, their options and the names they take.
feature: bash completes the vpn-* commands, their options and the names they
take.
* Bash completes the commands, options and arguments of vpn-router-user,
vpn-router-group, vpn-router-token, vpn-config-edit, vpn-router-backup,
@@ -332,7 +417,9 @@ vpn-router (0.28.0-1) unstable; urgency=medium
vpn-router (0.27.0-1) unstable; urgency=medium
feature: the web service is an OpenID Provider, which signs the directory's users in to other systems, the clients being service accounts with redirect URIs.
feature: the web service is an OpenID Provider, which signs the directory's
users in to other systems, the clients being service accounts with redirect
URIs.
* oidc.enabled turns on the OpenID Provider at the issuer
https://<wan.local_fqdn>:<https.web_port>, with discovery, JWKS,
@@ -361,7 +448,8 @@ vpn-router (0.27.0-1) unstable; urgency=medium
vpn-router (0.26.2-1) unstable; urgency=medium
fix: a service account may be named by a GUID in lowercase, as the Router Manager proposes for a new one.
fix: a service account may be named by a GUID in lowercase, as the Router
Manager proposes for a new one.
* vpn-router-user add --service and POST /service-accounts take a GUID
in lowercase as the name, besides a user name; users and groups keep
@@ -371,7 +459,8 @@ vpn-router (0.26.2-1) unstable; urgency=medium
vpn-router (0.26.1-1) unstable; urgency=medium
fix: the package installs vpnrouter_passkeys, without which the web service and vpn-router-user do not start.
fix: the package installs vpnrouter_passkeys, without which the web service
and vpn-router-user do not start.
* /usr/lib/vpn-router/vpnrouter_passkeys.py is part of the package.
@@ -379,7 +468,8 @@ vpn-router (0.26.1-1) unstable; urgency=medium
vpn-router (0.26.0-1) unstable; urgency=medium
feature: users sign in to the Router Manager with passkeys, and console login needs a TOTP token, so a user without one is refused the shell, SSH and sudo.
feature: users sign in to the Router Manager with passkeys, and console login
needs a TOTP token, so a user without one is refused the shell, SSH and sudo.
* Console login is switched on only for a user enrolled for TOTP. A user
whose console login is on without a token is refused POST /shell, the
@@ -403,7 +493,8 @@ vpn-router (0.26.0-1) unstable; urgency=medium
vpn-router (0.25.4-1) unstable; urgency=medium
fix: a shell opens without the password and code again while the user's last use of a shell is within web.shell_auth_timeout minutes.
fix: a shell opens without the password and code again while the user's last
use of a shell is within web.shell_auth_timeout minutes.
* New setting web.shell_auth_timeout, 15 minutes by default, 0 to 1440:
POST /shell without a password gives a ticket while the user's last
@@ -414,7 +505,8 @@ vpn-router (0.25.4-1) unstable; urgency=medium
vpn-router (0.25.3-1) unstable; urgency=medium
fix: /config gives the state of caddy.service with the installed Caddy, for the Router Manager's Web page.
fix: /config gives the state of caddy.service with the installed Caddy, for
the Router Manager's Web page.
* caddy of /config is {"version", "source", "service"}.
@@ -422,7 +514,9 @@ vpn-router (0.25.3-1) unstable; urgency=medium
vpn-router (0.25.2-1) unstable; urgency=medium
fix: wireguard.address must be the router's own address in its network, not the network or broadcast address, from which the peers' addresses are told apart.
fix: wireguard.address must be the router's own address in its network, not
the network or broadcast address, from which the peers' addresses are told
apart.
* A wireguard.address that is the network or the broadcast address of a
network larger than /31, such as 192.168.232.0/24, is refused with the
@@ -432,7 +526,8 @@ vpn-router (0.25.2-1) unstable; urgency=medium
vpn-router (0.25.1-1) unstable; urgency=medium
fix: /remote-access gives the router's WireGuard public key, endpoint and allowed IPs, which a peer's configuration needs.
fix: /remote-access gives the router's WireGuard public key, endpoint and
allowed IPs, which a peer's configuration needs.
* GET /remote-access answers wireguard_server: {"public_key",
"endpoint", "allowed_ips"}.
@@ -441,7 +536,8 @@ vpn-router (0.25.1-1) unstable; urgency=medium
vpn-router (0.25.0-1) unstable; urgency=medium
feature: a VPN user has WireGuard peers, each a device with its public key and IPs, kept in the directory and written into wg0.conf by setup.
feature: a VPN user has WireGuard peers, each a device with its public key and
IPs, kept in the directory and written into wg0.conf by setup.
* PUT /users/<name> takes wireguard_peers, [{"public_key", "ips"}],
which replaces the user's peers; keys are unique and IPs may not
@@ -457,7 +553,8 @@ vpn-router (0.25.0-1) unstable; urgency=medium
vpn-router (0.24.1-1) unstable; urgency=medium
fix: the status gives the number of connected OpenVPN clients at most 5 seconds old instead of up to a minute.
fix: the status gives the number of connected OpenVPN clients at most 5
seconds old instead of up to a minute.
* The OpenVPN server writes its status file every 5 seconds, so a
refresh of the status shows clients that connected or left since.
@@ -466,7 +563,9 @@ vpn-router (0.24.1-1) unstable; urgency=medium
vpn-router (0.24.0-1) unstable; urgency=medium
feature: /remote-access of the web service shows and changes the settings of IKEv2 road-warrior, OpenVPN and WireGuard access and whether each runs, for the Router Manager's Remote access page.
feature: /remote-access of the web service shows and changes the settings of
IKEv2 road-warrior, OpenVPN and WireGuard access and whether each runs, for
the Router Manager's Remote access page.
* GET /remote-access answers the p2s, openvpn and wireguard sections,
which of them setup runs, and wan.local_fqdn; PUT
@@ -478,7 +577,9 @@ vpn-router (0.24.0-1) unstable; urgency=medium
vpn-router (0.23.2-1) unstable; urgency=medium
fix: the shell of a user's own POSIX account opens on the first setup that sets up console login, and the journal records each refused shell with the reason.
fix: the shell of a user's own POSIX account opens on the first setup that
sets up console login, and the journal records each refused shell with the
reason.
* Setup restarts vpn-router-web when it adds ldap to the name service
switch, so the web service finds the POSIX accounts of the directory.
@@ -489,7 +590,9 @@ vpn-router (0.23.2-1) unstable; urgency=medium
vpn-router (0.23.1-1) unstable; urgency=medium
fix: the shell runs as the console account of each member of admins, a local account or the user's own POSIX account in the directory, which also signs in with SSH and sudo, instead of one web.shell_user.
fix: the shell runs as the console account of each member of admins, a local
account or the user's own POSIX account in the directory, which also signs in
with SSH and sudo, instead of one web.shell_user.
* vpn-router-user console <name> --posix | --account LOCAL | --off, and
PUT /users/<name>/console with a token of vpn-router-token, switch a
@@ -507,7 +610,9 @@ vpn-router (0.23.1-1) unstable; urgency=medium
vpn-router (0.23.0-1) unstable; urgency=medium
feature: web.shell lets /shell of the web service open a login shell of web.shell_user over a WebSocket for the Router Manager's Shell page, after the user signs in again.
feature: web.shell lets /shell of the web service open a login shell of
web.shell_user over a WebSocket for the Router Manager's Shell page, after the
user signs in again.
* web.shell (false), web.shell_user and web.shell_idle_minutes (15).
* POST /shell takes the password and the TOTP code and answers a
@@ -519,7 +624,8 @@ vpn-router (0.23.0-1) unstable; urgency=medium
vpn-router (0.22.1-1) unstable; urgency=medium
fix: the status gives the number of site-to-site connections set up and established, not each connection by name.
fix: the status gives the number of site-to-site connections set up and
established, not each connection by name.
* site_to_site of the status document is {"connections", "established"},
and the status page shows "Site to site: N of M established".
@@ -528,7 +634,9 @@ vpn-router (0.22.1-1) unstable; urgency=medium
vpn-router (0.22.0-1) unstable; urgency=medium
feature: pki.extended_key_usages adds custom extended key usages by their OIDs, which templates give by name, and a template's policies put certificate policy OIDs into its certificates.
feature: pki.extended_key_usages adds custom extended key usages by their
OIDs, which templates give by name, and a template's policies put certificate
policy OIDs into its certificates.
* pki.extended_key_usages maps a name to the OID of a custom extended
key usage; a template's extended_key_usage takes the built-in and the
@@ -542,7 +650,8 @@ vpn-router (0.22.0-1) unstable; urgency=medium
vpn-router (0.21.5-1) unstable; urgency=medium
fix: /status/full of the status web service gives an authenticated client the whole status, whatever web.status_content says.
fix: /status/full of the status web service gives an authenticated client the
whole status, whatever web.status_content says.
* /status/full takes the token of /config and answers the status
document as web.status_content full shows it.
@@ -551,7 +660,8 @@ vpn-router (0.21.5-1) unstable; urgency=medium
vpn-router (0.21.4-1) unstable; urgency=medium
fix: vpn-router-setup, vpn-router-ca and /ca read a CA's key and CRL from the directory each as itself, instead of failing on a CA with a CRL.
fix: vpn-router-setup, vpn-router-ca and /ca read a CA's key and CRL from the
directory each as itself, instead of failing on a CA with a CRL.
* A CA's CRL was read as its key and its key as its CRL, so every
operation on a CA with a CRL failed with "Could not deserialize key
@@ -561,7 +671,10 @@ vpn-router (0.21.4-1) unstable; urgency=medium
vpn-router (0.21.3-1) unstable; urgency=medium
fix: the PKI is kept in ou=pki of the router's directory instead of files under /etc/vpn-router/pki, and the router's site-to-site certificate names only the identities its connections present, and /ca/bundle gives the certificate bundle of a CA.
fix: the PKI is kept in ou=pki of the router's directory instead of files
under /etc/vpn-router/pki, and the router's site-to-site certificate names
only the identities its connections present, and /ca/bundle gives the
certificate bundle of a CA.
* The CAs with their certificates, keys, CSRs and CRLs, and every
certificate issued with its revocation, are entries of the
@@ -585,7 +698,8 @@ vpn-router (0.21.3-1) unstable; urgency=medium
vpn-router (0.21.2-1) unstable; urgency=medium
fix: pki.root_key_warning silences the warning of vpn-router-ca status and /ca about the root key on the router.
fix: pki.root_key_warning silences the warning of vpn-router-ca status and /ca
about the root key on the router.
* pki.root_key_warning, true by default, set to false silences the
warning that the root key is on the router.
@@ -594,7 +708,8 @@ vpn-router (0.21.2-1) unstable; urgency=medium
vpn-router (0.21.1-1) unstable; urgency=medium
fix: vpn-router-ca status and /ca warn about a CRL the router signs only once vpn-router-pki.timer has not renewed it in time.
fix: vpn-router-ca status and /ca warn about a CRL the router signs only once
vpn-router-pki.timer has not renewed it in time.
* A CRL whose CA key is on the router is reported once past half of its
validity, when the timer should have signed it again; a root CRL
@@ -604,7 +719,10 @@ vpn-router (0.21.1-1) unstable; urgency=medium
vpn-router (0.21.0-1) unstable; urgency=medium
feature: the router has a two-level CA with an optional offline root, issuing CAs for P2S, site-to-site and general use, templates, published certificates and CRLs and an ACME server, site-to-site connections authenticate with certificates, and setup installs Caddy from Ubuntu or Caddy's repository.
feature: the router has a two-level CA with an optional offline root, issuing
CAs for P2S, site-to-site and general use, templates, published certificates
and CRLs and an ACME server, site-to-site connections authenticate with
certificates, and setup installs Caddy from Ubuntu or Caddy's repository.
* The root CA signs the issuing CAs p2s, which issues the router's VPN
server certificate and the users' certificates, s2s, which issues
@@ -643,7 +761,9 @@ vpn-router (0.21.0-1) unstable; urgency=medium
vpn-router (0.20.0-1) unstable; urgency=medium
feature: the router makes scheduled backups to a local folder, copied to the cloud with rclone, or to a Proxmox Backup Server, and vpn-router-backup restores them.
feature: the router makes scheduled backups to a local folder, copied to the
cloud with rclone, or to a Proxmox Backup Server, and vpn-router-backup
restores them.
* The backup section of vpn-router.json switches on backups and sets
the schedule (hourly, daily or weekly), the target (local or pbs),
@@ -676,7 +796,8 @@ vpn-router (0.20.0-1) unstable; urgency=medium
vpn-router (0.19.1-3) unstable; urgency=medium
update: ADR-24 names Ubuntu's apt-daily timer, not a cron job, as what updates the package lists.
update: ADR-24 names Ubuntu's apt-daily timer, not a cron job, as what updates
the package lists.
* ADR-24 says that Ubuntu's apt-daily timer updates the package lists
twice a day, and that apt-daily or unattended-upgrades may hold the
@@ -695,7 +816,8 @@ vpn-router (0.19.1-2) unstable; urgency=medium
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.
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
@@ -706,7 +828,8 @@ vpn-router (0.19.1-1) unstable; urgency=medium
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.
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
@@ -724,7 +847,9 @@ vpn-router (0.19.0-1) unstable; urgency=medium
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.
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
@@ -739,7 +864,8 @@ vpn-router (0.18.3-1) unstable; urgency=medium
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.
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
@@ -756,7 +882,9 @@ vpn-router (0.18.2-1) unstable; urgency=medium
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.
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.
@@ -780,7 +908,8 @@ vpn-router (0.18.0-2) unstable; urgency=medium
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.
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
@@ -820,7 +949,8 @@ vpn-router (0.18.0-1) unstable; urgency=medium
vpn-router (0.17.0-1) unstable; urgency=medium
feature: /config shows the router's PostgreSQL clusters and their settings, and /postgresql their databases with their sizes.
feature: /config shows the router's PostgreSQL clusters and their settings,
and /postgresql their databases with their sizes.
* /config has a postgresql section: each cluster of pg_lsclusters with
its version, name, port, status and data directory, and the main
@@ -832,7 +962,8 @@ vpn-router (0.17.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.
* vpn-router-metrics takes a sample every minute: CPU, memory, free
space of /, bytes per second on the external and the internal
@@ -884,7 +1015,8 @@ vpn-router (0.14.1-1) unstable; urgency=medium
vpn-router (0.14.0-1) unstable; urgency=medium
feature: the status page shows the Client IP, and web.status_content sets how much it shows.
feature: the status page shows the Client IP, and web.status_content sets how
much it shows.
* / and /status show the Client IP, the address the request came from:
the last X-Forwarded-For entry for a request from a trusted proxy, so
@@ -899,7 +1031,8 @@ vpn-router (0.14.0-1) unstable; urgency=medium
vpn-router (0.13.0-1) unstable; urgency=medium
feature: Caddy serves the status page and the Router Manager over HTTPS with a certificate from Let's Encrypt.
feature: Caddy serves the status page and the Router Manager over HTTPS with a
certificate from Let's Encrypt.
* The package depends on caddy. While https.enabled is true, the
default, and wan.local_fqdn is set, vpn-router-setup writes
@@ -922,7 +1055,8 @@ vpn-router (0.13.0-1) unstable; urgency=medium
vpn-router (0.12.3-1) unstable; urgency=medium
fix: the web service listens on the internal address, the external address or both.
fix: the web service listens on the internal address, the external address or
both.
* web.listen in vpn-router.json sets the interfaces the web service
listens on: internal, external or both, the default. It is read
@@ -1008,7 +1142,8 @@ vpn-router (0.9.2-1) unstable; urgency=medium
vpn-router (0.9.1-1) unstable; urgency=medium
fix: the web service waits for the PostgreSQL driver instead of failing at start.
fix: the web service waits for the PostgreSQL driver instead of failing at
start.
* The web service imports psycopg as the first step of its
initialization, retrying every 15 seconds and logging the failure while
@@ -1038,7 +1173,8 @@ vpn-router (0.9.0-1) unstable; urgency=medium
vpn-router (0.8.0-1) unstable; urgency=medium
feature: the web service creates its key and database itself and shows its starting phase.
feature: the web service creates its key and database itself and shows its
starting phase.
* vpn-router-web makes its signing key and creates the refresh tokens'
database on its first run, retrying every 15 seconds until PostgreSQL
@@ -1053,7 +1189,8 @@ vpn-router (0.8.0-1) unstable; urgency=medium
vpn-router (0.7.1-1) unstable; urgency=medium
fix: a failing user directory or token database no longer leaves the tunnels down.
fix: a failing user directory or token database no longer leaves the tunnels
down.
* vpn-router-setup brings up strongSwan and WireGuard before it provisions
the user directory and the token database. A failure of either no
@@ -1063,7 +1200,8 @@ vpn-router (0.7.1-1) unstable; urgency=medium
vpn-router (0.7.0-1) unstable; urgency=medium
feature: users sign in to the web service, and only members of admins manage the router.
feature: users sign in to the web service, and only members of admins manage
the router.
* POST /auth signs a user in with the password, and the TOTP code for an
enrolled user, and answers a 5 minute access token and an 8 hour refresh
+1 -1
View File
@@ -8,7 +8,7 @@ case "$(uname -s)" in
*) printf 'ERROR: unsupported platform %s\n' "$(uname -s)" >&2; exit 1 ;;
esac
OWNER="${OWNER:-slawek}"
OWNER="${OWNER:-koszewscy}"
SERVER_URL="${SERVER_URL:-https://gitea.koszewscy.waw.pl}"
PACKAGES_TOKEN="${PACKAGES_TOKEN:-$(cat ~/.gitea-packages-token)}"
# The Ubuntu releases whose distribution of the repository gets the package, in its component main.
+23 -3
View File
@@ -820,13 +820,33 @@ def manage_webdav(ctx):
run(['systemctl', 'enable', '--now', WEBDAV_UNIT], check=False)
def site_addresses(cfg, ctx):
"""The pairs of a host name and the router's address of dns_address of each enabled site with dns router: public,
wan.public_address; internal, the internal interface's; external, the external interface's first. A site whose
address is not known gets none, with a warning."""
addresses = {'public': cfg['wan']['public_address'].strip(), 'internal': ctx['int_addr'],
'external': next(iter(ctx['local_addr_list']), '')}
pairs = []
for site in ctx['sites']:
if site['dns'] != 'router':
continue
address = addresses[site['dns_address']]
if not address:
warn(f'site {site["name"]}: the router has no {site["dns_address"]} address, so its host names get no '
'records')
continue
pairs += [(host, address) for host in site['hosts']]
return pairs
def manage_dns(cfg, ctx):
"""Run PowerDNS, the primary name server of the domains of vpnrouter_dns, on the addresses of dns.listen while DNS
is enabled, and stop it when not; its packages are installed when they are missing.
The settings, vpnrouter_dns.PDNS_CONF, are written before the packages are installed, so the server their
installation starts answers on those addresses only, beside systemd-resolved's stub on 127.0.0.53. The domains
with site records get the host names of the enabled sites, and keep those they have while the sites cannot be read.
installation starts answers on those addresses only, beside systemd-resolved's stub on 127.0.0.53. The host names
of the enabled sites with dns router get records of the router's address they choose (site_addresses), and the
domains keep those they have while the sites cannot be read.
"""
if not vpnrouter.dns_ready(ctx):
if unit_is_active(vpnrouter_dns.UNIT):
@@ -854,7 +874,7 @@ def manage_dns(cfg, ctx):
os.chown(vpnrouter_dns.PDNS_CONF, 0, grp.getgrnam(vpnrouter_dns.GROUP).gr_gid)
vpnrouter_dns.provision()
if ctx['sites'] is not None:
vpnrouter_dns.apply(cfg, [host for site in ctx['sites'] for host in site['hosts']])
vpnrouter_dns.apply(cfg, site_addresses(cfg, ctx))
except (OSError, KeyError, vpnrouter_dns.DatabaseError) as exc:
warn(f'the name server cannot be set up: {exc}')
return
@@ -55,7 +55,7 @@ OPENVPN_LDAP = pathlib.Path('/etc/openvpn/server/vpn-router-ldap.conf')
SCHEMA = {
'general': {'platform': 'none', 'mode': 'manual'},
'interfaces': {'external': '', 'internal': ''},
'wan': {'local_fqdn': ''},
'wan': {'local_fqdn': '', 'public_address': ''},
'local': {'cidrs': [], 'int_addr': '', 'int_gateway_ip': ''},
'site_to_site': {},
'p2s': {'enabled': False, 'address_pool': '', 'dns_listen': 'internal', 'dns_domains': []},
@@ -70,7 +70,7 @@ SCHEMA = {
'acme_staging': False},
'oidc': {'enabled': False},
'sites': {'enabled': False, 'upload_max_mb': 512, 'webdav': False, 'webdav_port': 7445},
'dns': {'enabled': False, 'nameserver': '', 'addresses': [], 'listen': 'external', 'ttl': 300},
'dns': {'enabled': False, 'secondary': '', 'listen': 'external', 'ttl': 300},
'pki': {'root_name': 'VPN Router Root CA', 'root_days': 7300, 'ca_days': 3650, 'key': 'rsa-4096',
'crl_days': 7, 'root_crl_days': 180, 'publish': ['http'], 'p2s_name': 'VPN Router P2S CA',
's2s_name': 'VPN Router Site-to-Site CA', 'issuing_cas': {}, 'templates': {},
@@ -144,10 +144,14 @@ WEBDAV_LOOPBACK_PORT = 7446
#: The largest sites.upload_max_mb, 10 GiB.
UPLOAD_MAX_MB = 10240
#: Settings that no longer exist, section -> options, left out when the file is read so that a file of an earlier
#: version loads: the name server is wan.local_fqdn and a site chooses the address of its records.
RETIRED = {'dns': ('nameserver', 'addresses')}
#: The shortest and the longest dns.ttl, in seconds.
DNS_TTL_RANGE = (60, 86400)
#: A DNS name without a wildcard, in lowercase, of dns.nameserver and the domains of the name server.
#: A DNS name without a wildcard, in lowercase, of the name servers and the domains of the name server.
ZONE_NAME = re.compile(r'^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$')
#: A domain of p2s.dns_domains, one label or more, and the most domains.
@@ -300,6 +304,9 @@ def read_settings(path=None):
for section, options in data.items():
if not isinstance(options, dict):
raise ConfigError(f'cannot parse {path}: {section} is not an object')
for section, options in RETIRED.items():
for option in options:
data.get(section, {}).pop(option, None)
return data
@@ -581,14 +588,17 @@ def validate(cfg):
raise ConfigError('sites.webdav: WebDAV is served by Caddy at https://<wan.local_fqdn>:<sites.webdav_port>, '
'which needs https.enabled and wan.local_fqdn')
_check_address(_get(cfg, 'wan', 'public_address'), 'wan.public_address')
dns = cfg['dns']
if dns['enabled']:
if not ZONE_NAME.match(dns['nameserver'].strip()):
raise ConfigError(f'dns.nameserver: {dns["nameserver"]!r} is not a DNS name in lowercase')
if not _get_list(cfg, 'dns', 'addresses'):
raise ConfigError('dns.enabled needs dns.addresses, the public addresses of the name server and the sites')
for item in _get_list(cfg, 'dns', 'addresses'):
_check_address(item, 'dns.addresses')
if dns['enabled'] and not (ZONE_NAME.match(_get(cfg, 'wan', 'local_fqdn'))
and not re.search(r'\.[0-9]+$', _get(cfg, 'wan', 'local_fqdn'))):
raise ConfigError('dns.enabled needs wan.local_fqdn, a DNS name in lowercase: the primary name server of the '
'domains')
secondary = _get(cfg, 'dns', 'secondary')
if secondary and not ZONE_NAME.match(secondary):
raise ConfigError(f'dns.secondary: {secondary!r} is not a DNS name in lowercase')
if secondary and secondary == _get(cfg, 'wan', 'local_fqdn'):
raise ConfigError('dns.secondary: the secondary name server is not wan.local_fqdn, the primary')
if dns['listen'] not in LISTEN_CHOICES:
raise ConfigError(f'dns.listen: {dns["listen"]!r} is not one of {", ".join(LISTEN_CHOICES)}')
if not DNS_TTL_RANGE[0] <= dns['ttl'] <= DNS_TTL_RANGE[1]:
@@ -1,17 +1,17 @@
"""The router's authoritative name server, PowerDNS with its gpgsql backend, for vpn-router-setup and the web service.
The name server is the primary of its domains and answers on the addresses of the interfaces of dns.listen. The
domains, their secondary name servers and the records an administrator added (manual records) are in the
vpn-router-dns database, which provision() creates with its role and the password in DATABASE_SECRET, and fills with
the schema of pdns-backend-pgsql, PDNS_SCHEMA; DNS_SQL defines the router's own tables and procedures, and the first
connection of a process loads it.
domains and the records an administrator added (manual records) are in the vpn-router-dns database, which
provision() creates with its role and the password in DATABASE_SECRET, and fills with the schema of
pdns-backend-pgsql, PDNS_SCHEMA; DNS_SQL defines the router's own tables and procedures, and the first connection of a
process loads it.
apply() writes every domain as a primary zone: its SOA with dns.nameserver, its NS, dns.nameserver and the name
servers of its secondaries, the NS of the domains directly below it, A and AAAA records of dns.addresses for its apex,
for those name servers inside it that are the router, and, when the domain has site records, for every host name of an
enabled site whose closest domain it is, and the manual records, but those of a name and type the records before them
have. A secondary's address is notified of every change (ALSO-NOTIFY) and may transfer the zone (ALLOW-AXFR-FROM); the
serial of the SOA grows only when the records change.
apply() writes every domain as a primary zone: its SOA with wan.local_fqdn as the primary name server, its NS,
wan.local_fqdn and dns.secondary, the NS of the domains directly below it, an A or AAAA record for every host name of
an enabled site with dns router whose closest domain it is, with the router's address the site chooses, and the manual
records, but those of a name and type the records before them have. wan.local_fqdn gets no record in the zones: its
record is kept where its own domain is. The addresses dns.secondary resolves to are notified of every change
(ALSO-NOTIFY) and may transfer the zones (ALLOW-AXFR-FROM); the serial of the SOA grows only when the records change.
Every failure of the database raises DatabaseError; a request that cannot be done raises Failure.
"""
@@ -20,6 +20,7 @@ import contextlib
import ipaddress
import pathlib
import re
import socket
import psycopg
from psycopg.rows import dict_row
@@ -44,9 +45,8 @@ GROUP = 'pdns'
#: The types of the manual records.
TYPES = ('A', 'AAAA', 'CNAME', 'TXT', 'MX', 'SRV', 'CAA')
#: The most domains, secondaries of a domain, and manual records of a domain.
#: The most domains, and manual records of a domain.
DOMAINS_MAX = 100
SECONDARIES_MAX = 8
MANUAL_MAX = 1000
#: The refresh, retry and expire times of the SOA, in seconds.
@@ -73,8 +73,7 @@ class DatabaseError(Exception):
class Failure(Exception):
"""A request that cannot be done; status is 2 invalid, 3 no such domain, secondary or record, 4 the domain
exists."""
"""A request that cannot be done; status is 2 invalid, 3 no such domain or record, 4 the domain exists."""
def __init__(self, message, status=2):
super().__init__(message)
@@ -211,13 +210,10 @@ def domain(connection, name):
def listing():
"""Every domain with its secondaries, its manual records and the records the name server answers for it."""
"""Every domain with its manual records and the records the name server answers for it."""
with database() as connection:
secondaries = connection.execute('SELECT * FROM dns_secondaries_list()').fetchall()
manual = connection.execute('SELECT * FROM dns_records_list()').fetchall()
return [{**row,
'secondaries': [{key: item[key] for key in ('nameserver', 'address')}
for item in secondaries if item['domain'] == row['name']],
'manual': [{key: item[key] for key in ('name', 'type', 'content', 'ttl')}
for item in manual if item['domain'] == row['name']],
'records': connection.execute('SELECT * FROM dns_served(%s)', (row['name'],)).fetchall()}
@@ -225,71 +221,52 @@ def listing():
def domain_add(body):
"""Add the domain {name, sites, ttl?}; the domain."""
if not isinstance(body, dict) or not isinstance(body.get('sites', False), bool):
raise Failure('a domain is an object of name, sites and ttl')
name, sites, ttl = check_domain(body.get('name')), body.get('sites', False), check_ttl(body.get('ttl'))
"""Add the domain {name, ttl?}; the domain."""
if not isinstance(body, dict) or not set(body) <= {'name', 'ttl'}:
raise Failure('a domain is an object of name and ttl')
name, ttl = check_domain(body.get('name')), check_ttl(body.get('ttl'))
with database() as connection:
names = [row['name'] for row in connection.execute('SELECT * FROM dns_domains_list()').fetchall()]
if name in names:
raise Failure(f'the domain {name} exists', 4)
if len(names) >= DOMAINS_MAX:
raise Failure(f'the name server has {DOMAINS_MAX} domains')
connection.execute('CALL dns_domain_put(%s, %s, %s)', (name, sites, ttl))
return {'name': name, 'sites': sites, 'ttl': ttl}
connection.execute('CALL dns_domain_put(%s, %s)', (name, ttl))
return {'name': name, 'ttl': ttl}
def domain_change(name, body):
"""Change the sites and ttl of the domain; the domain."""
if not isinstance(body, dict) or not set(body) <= {'sites', 'ttl'} or \
not isinstance(body.get('sites', False), bool):
raise Failure('a change of a domain is an object of sites and ttl')
"""Change the ttl of the domain; the domain."""
if not isinstance(body, dict) or not set(body) <= {'ttl'}:
raise Failure('a change of a domain is an object of ttl')
with database() as connection:
row = domain(connection, name)
sites, ttl = body.get('sites', row['sites']), check_ttl(body.get('ttl', row['ttl']))
connection.execute('CALL dns_domain_put(%s, %s, %s)', (name, sites, ttl))
return {'name': name, 'sites': sites, 'ttl': ttl}
ttl = check_ttl(body.get('ttl', row['ttl']))
connection.execute('CALL dns_domain_put(%s, %s)', (name, ttl))
return {'name': name, 'ttl': ttl}
def domain_remove(name):
"""Remove the domain with its secondaries and manual records."""
"""Remove the domain with its manual records."""
with database() as connection:
domain(connection, name)
connection.execute('CALL dns_domain_remove(%s)', (name,))
return {'name': name}
def secondary_add(name, body):
"""Add the secondary name server {nameserver, address} of the domain, or set its address; the secondary."""
if not isinstance(body, dict):
raise Failure('a secondary is an object of nameserver and address')
nameserver = body.get('nameserver')
if not isinstance(nameserver, str) or not vpnrouter.ZONE_NAME.match(nameserver.strip().lower().rstrip('.')):
raise Failure(f'{nameserver!r} is not a name server name')
nameserver = nameserver.strip().lower().rstrip('.')
def secondary_addresses(cfg):
"""The addresses dns.secondary resolves to, which are notified of the zones' changes and may transfer them; none,
with a warning, while it is not set or does not resolve."""
secondary = cfg['dns']['secondary'].strip()
if not secondary:
vpnrouter.warn('dns.secondary is not set: the domains have no secondary name server')
return []
try:
address = str(ipaddress.ip_address(str(body.get('address', '')).strip()))
except ValueError as exc:
raise Failure(f'{body.get("address")!r} is not an IP address') from exc
with database() as connection:
domain(connection, name)
others = [row for row in connection.execute('SELECT * FROM dns_secondaries_list()').fetchall()
if row['domain'] == name and row['nameserver'] != nameserver]
if len(others) >= SECONDARIES_MAX:
raise Failure(f'the domain {name} has {SECONDARIES_MAX} secondaries')
connection.execute('CALL dns_secondary_put(%s, %s, %s)', (name, nameserver, address))
return {'domain': name, 'nameserver': nameserver, 'address': address}
def secondary_remove(name, nameserver):
"""Remove the secondary name server of the domain."""
with database() as connection:
domain(connection, name)
if not any(row['domain'] == name and row['nameserver'] == nameserver
for row in connection.execute('SELECT * FROM dns_secondaries_list()').fetchall()):
raise Failure(f'no secondary {nameserver} of {name}', 3)
connection.execute('CALL dns_secondary_remove(%s, %s)', (name, nameserver))
return {'domain': name, 'nameserver': nameserver}
found = socket.getaddrinfo(secondary, 53, proto=socket.IPPROTO_UDP)
except OSError as exc:
vpnrouter.warn(f'dns.secondary {secondary} does not resolve, so it may not transfer the domains: {exc}')
return []
return list(dict.fromkeys(str(ipaddress.ip_address(item[4][0])) for item in found))
def add(cfg, name, record):
@@ -332,25 +309,20 @@ def remove(name, record_name, kind, content):
return {'name': full, 'type': kind, 'removed': len(found)}
def records(cfg, row, zones, secondaries, hosts, manual_records):
def records(cfg, row, zones, hosts, manual_records):
"""The records of the domain row besides its SOA, as (name, type, content, ttl): the NS of the domain and of the
domains directly below it, the A and AAAA records of dns.addresses for its apex, dns.nameserver when it is inside
and, with site records, each host name whose closest domain it is, the glue of the secondaries inside it, and its
manual records. secondaries maps a domain to its secondaries."""
domains directly below it, wan.local_fqdn and dns.secondary, the A or AAAA record of each host name of hosts, pairs
of a host name and an address, whose closest domain it is, and its manual records."""
dns = cfg['dns']
zone, nameserver, ttl = row['name'], dns['nameserver'].strip(), row['ttl'] or dns['ttl']
addresses = [ipaddress.ip_address(item.strip()) for item in dns['addresses'] if item.strip()]
zone, ttl = row['name'], row['ttl'] or dns['ttl']
servers = [cfg['wan']['local_fqdn'].strip(), *([dns['secondary'].strip()] if dns['secondary'].strip() else [])]
children = [name for name in zones
if name != zone and closest([item for item in zones if item != name], name) == zone]
found = [(name, 'NS', server, ttl) for name in [zone, *children]
for server in [nameserver, *(item['nameserver'] for item in secondaries.get(name, []))]]
for name in dict.fromkeys([zone, *([nameserver] if in_zone(zone, nameserver) else []),
*(host for host in hosts if row['sites'] and closest(zones, host) == zone)]):
found += [(name, 'A' if address.version == 4 else 'AAAA', str(address), ttl) for address in addresses]
for item in (item for name in [zone, *children] for item in secondaries.get(name, [])):
if in_zone(zone, item['nameserver']) and item['nameserver'] != nameserver:
address = ipaddress.ip_address(item['address'])
found.append((item['nameserver'], 'A' if address.version == 4 else 'AAAA', str(address), ttl))
found = [(name, 'NS', server, ttl) for name in [zone, *children] for server in servers]
for host, address in hosts:
if closest(zones, host) == zone:
address = ipaddress.ip_address(address)
found.append((host, 'A' if address.version == 4 else 'AAAA', str(address), ttl))
taken = {(name, kind) for name, kind, _, _ in found}
found += [(item['name'], item['type'], item['content'], item['ttl']) for item in manual_records
if (item['name'], item['type']) not in taken]
@@ -358,7 +330,8 @@ def records(cfg, row, zones, secondaries, hosts, manual_records):
def apply(cfg, hosts):
"""Make the name server the primary of every domain with records(), and of none while dns.enabled is false."""
"""Make the name server the primary of every domain with records(), hosts being the pairs of a host name and an
address of the sites with dns router, and of none while dns.enabled is false."""
with database() as connection:
if not cfg['dns']['enabled']:
connection.execute('CALL dns_retain(%s::text[])', ([],))
@@ -366,20 +339,21 @@ def apply(cfg, hosts):
dns = cfg['dns']
domains = connection.execute('SELECT * FROM dns_domains_list()').fetchall()
zones = [row['name'] for row in domains]
secondaries, manual = {}, {}
for item in connection.execute('SELECT * FROM dns_secondaries_list()').fetchall():
secondaries.setdefault(item['domain'], []).append(item)
for host, _ in hosts:
if closest(zones, host) is None:
vpnrouter.warn(f'{host} is in none of the domains, so the name server has no record of it')
manual = {}
for item in connection.execute('SELECT * FROM dns_records_list()').fetchall():
manual.setdefault(item['domain'], []).append(item)
transfers = secondary_addresses(cfg) if domains else []
for row in domains:
zone, ttl = row['name'], row['ttl'] or dns['ttl']
rows = records(cfg, row, zones, secondaries, hosts, manual.get(zone, []))
soa = f'{dns["nameserver"].strip()} hostmaster.{zone} {{serial}} {SOA_TIMES} {ttl}'
rows = records(cfg, row, zones, hosts, manual.get(zone, []))
soa = f'{cfg["wan"]["local_fqdn"].strip()} hostmaster.{zone} {{serial}} {SOA_TIMES} {ttl}'
connection.execute('CALL dns_apply(%s, %s, %s, %s::text[], %s::text[], %s::text[], %s::integer[], '
'%s::text[])',
(zone, soa, ttl, [item[0] for item in rows], [item[1] for item in rows],
[item[2] for item in rows], [item[3] for item in rows],
[item['address'] for item in secondaries.get(zone, [])]))
[item[2] for item in rows], [item[3] for item in rows], transfers))
connection.execute('CALL dns_retain(%s::text[])', (zones,))
@@ -7,7 +7,11 @@ Caddy answers on its port (hosts, the names TLS clients send as SNI; none answer
firewall opens the port (listen), and TLS: none (plain HTTP), acme (a certificate of Let's Encrypt), ca (certificates
Caddy issues with one of pki.issuing_cas) or file (a certificate and key given with the site). Caddy serves the files of
the folder, index_file for a folder, and with spa index_file of the site for any path without a file. editors names
the group whose members change the site's files besides the admins, through the web service and WebDAV.
the group whose members change the site's files besides the admins, through the web service and WebDAV. dns says who
serves the records of its host names: user, a DNS provider outside the router, or router, the router's name server,
which gives each of them in the closest of its domains an A or AAAA record of the router's address of dns_address:
public (wan.public_address), internal or external. A site may use wan.local_fqdn, on a port of its own; its record
stays where the domain of wan.local_fqdn is.
The sites are served while sites.enabled is true; vpn-router-setup writes Caddy's configuration and opens the ports. A
site is changed only through this module, which checks it (check), and its callers then restart vpn-router-setup.
@@ -55,6 +59,11 @@ INDEX = re.compile(r'^[A-Za-z0-9_-][A-Za-z0-9._-]{0,63}$')
LISTEN = vpnrouter.LISTEN_CHOICES
TLS = ('none', 'acme', 'ca', 'file')
#: Who serves the records of a site's host names: user, elsewhere, or router, the router's name server, with the
#: router's address of DNS_ADDRESSES.
DNS = ('user', 'router')
DNS_ADDRESSES = ('public', 'internal', 'external')
#: The most host names of one site.
HOSTS_MAX = 16
@@ -66,7 +75,8 @@ CHUNK = 65536
#: The settings of a site and their defaults; the type of a default is the required type.
SETTINGS = {'enabled': True, 'hosts': [], 'port': 443, 'listen': 'external', 'tls': 'acme', 'ca': '', 'certificate': '',
'private_key': '', 'index_file': 'index.html', 'spa': False, 'editors': ''}
'private_key': '', 'index_file': 'index.html', 'spa': False, 'editors': '', 'dns': 'user',
'dns_address': 'public'}
#: Whether this process has loaded SITES_SQL.
_loaded = False
@@ -215,8 +225,17 @@ def check(cfg, sites, site):
for host in hosts:
if not (vpnrouter_pki.DNS_NAME.match(host) or vpnrouter_pki.is_address(host)):
raise Failure(f'hosts: {host!r} is not a host name in lowercase or an IP address', 2)
if host == cfg['wan']['local_fqdn'].strip():
raise Failure(f'hosts: {host} is wan.local_fqdn, the name of the router\'s own services', 2)
if site['dns'] not in DNS:
raise Failure(f'dns: {site["dns"]!r} is not one of {", ".join(DNS)}', 2)
if site['dns_address'] not in DNS_ADDRESSES:
raise Failure(f'dns_address: {site["dns_address"]!r} is not one of {", ".join(DNS_ADDRESSES)}', 2)
if site['dns'] == 'router':
local_fqdn = cfg['wan']['local_fqdn'].strip()
if any(vpnrouter_pki.is_address(host) or host.startswith('*.') or host == local_fqdn for host in hosts):
raise Failure('dns router takes host names only, without wildcards and other than wan.local_fqdn, whose '
'record is kept where its domain is', 2)
if site['dns_address'] == 'public' and not cfg['wan']['public_address'].strip():
raise Failure('dns_address public needs wan.public_address', 2)
port = site['port']
if not 1 <= port <= 65535:
raise Failure(f'port: {port} is not a port', 2)
@@ -285,7 +304,7 @@ def save(cfg, name, values, create):
if site['tls'] != 'ca':
site['ca'] = ''
check(cfg, rows, site)
connection.execute('CALL site_put(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)',
connection.execute(f'CALL site_put({", ".join(["%s"] * (len(SETTINGS) + 1))})',
tuple(site[key] for key in ('name', *SETTINGS)))
ensure_folders([name])
return shown(get(connection, name))
@@ -55,7 +55,7 @@ Pages:
and the files of each: list, download, upload, extract a zip archive, create a folder, rename and
remove (see Handler.site_files()); a member of a site's editors group may use the site's files
/dns the primary name server of the router's domains: its settings, and the domains: add, change and
remove, each with its secondaries, the records it serves, and its manual records (see dns())
remove, each with the records it serves and its manual records (see dns())
/postgresql
the databases of each PostgreSQL cluster on the router with their sizes, as JSON (see
postgresql_databases()); /config has the clusters' settings
@@ -10,22 +10,20 @@ from .common import section_change, unit_state
def dns(method, path, query, body):
"""Answer a /dns request with vpnrouter_dns: the HTTP status and the JSON document.
GET /dns {"settings", "installed", "running", "domains", "problem"}: the dns section of the
configuration, whether PowerDNS is installed and runs, the domains, each {"name", "sites",
"ttl", "secondaries", "manual", "records"} with its secondaries {"nameserver", "address"},
its manual records and the records the name server serves, and why the domains cannot be
read, null when they can
PUT /dns change any of the settings of the dns section
POST /dns/domains add a domain: {"name", "sites"?, "ttl"?}; sites gives the host names of the enabled sites
in it records; ttl is dns.ttl when null or absent; an existing name is 409
GET /dns {"settings", "nameserver", "public_address", "installed", "running", "domains",
"problem"}: the dns section of the configuration, wan.local_fqdn, the primary name server
of the domains, wan.public_address, the address of the records of the sites with
dns_address public, whether PowerDNS is installed and runs, the domains, each {"name",
"ttl", "manual", "records"} with its manual records and the records the name server
serves, and why the domains cannot be read, null when they can
PUT /dns change any of the settings of the dns section, and with "public_address"
wan.public_address; {"settings", "public_address"}
POST /dns/domains add a domain: {"name", "ttl"?}; ttl is dns.ttl when null or absent; an existing name is
409
PUT /dns/domains/<domain>
change "sites" or "ttl" of the domain
change "ttl" of the domain
DELETE /dns/domains/<domain>
remove the domain with its secondaries and manual records
POST /dns/domains/<domain>/secondaries
add a secondary name server of the domain, or change its address: {"nameserver", "address"}
DELETE /dns/domains/<domain>/secondaries/<nameserver>
remove the secondary name server
remove the domain with its manual records
POST /dns/domains/<domain>/records
add a manual record: {"name", "type", "content", "ttl"?}; name is relative to the domain,
@ for the domain itself, or absolute with a final dot; ttl is the domain's without it
@@ -47,22 +45,27 @@ def dns(method, path, query, body):
domains = vpnrouter_dns.listing()
except vpnrouter_dns.DatabaseError as exc:
problem = str(exc)
return 200, {'settings': cfg['dns'], 'installed': vpnrouter_dns.PDNS_SERVER.exists(),
return 200, {'settings': cfg['dns'], 'nameserver': cfg['wan']['local_fqdn'].strip(),
'public_address': cfg['wan']['public_address'].strip(),
'installed': vpnrouter_dns.PDNS_SERVER.exists(),
'running': unit_state(vpnrouter_dns.UNIT) == 'active', 'domains': domains,
'problem': problem}
if route == ('PUT', 'dns'):
wan = cfg['wan']
if 'public_address' in body:
wan = section_change('wan', {'public_address': body.pop('public_address')})
if isinstance(wan, tuple):
return wan
changed = section_change('dns', body)
return changed if isinstance(changed, tuple) else (200, {'settings': changed})
if isinstance(changed, tuple):
return changed
return 200, {'settings': changed, 'public_address': wan['public_address'].strip()}
if route == ('POST', 'dns', 'domains') and len(parts) == 2:
document, code = vpnrouter_dns.domain_add(body), 201
elif route == ('PUT', 'dns', 'domains') and len(parts) == 3:
document, code = vpnrouter_dns.domain_change(parts[2], body), 200
elif route == ('DELETE', 'dns', 'domains') and len(parts) == 3:
document, code = vpnrouter_dns.domain_remove(parts[2]), 200
elif route == ('POST', 'dns', 'domains', 'secondaries') and len(parts) == 4:
document, code = vpnrouter_dns.secondary_add(parts[2], body), 201
elif route == ('DELETE', 'dns', 'domains', 'secondaries') and len(parts) == 5:
document, code = vpnrouter_dns.secondary_remove(parts[2], parts[4]), 200
elif route == ('POST', 'dns', 'domains', 'records') and len(parts) == 4:
document, code = vpnrouter_dns.add(cfg, parts[2], body), 201
elif route == ('DELETE', 'dns', 'domains', 'records') and len(parts) == 4:
@@ -22,6 +22,7 @@ from .connectivity import remote_access, site_to_site
from .dns import dns
from .identities import account, groups, service_accounts, users
from .logs import logs
from .https import https_settings
from .metrics import metrics_history, metrics_settings
from .pki import PUBLISHED, ca
from .shell import EDITOR_BODY_LIMIT, shell, shell_editor, shell_passkey, shell_ticket
@@ -66,7 +67,8 @@ class Handler(BaseHTTPRequestHandler):
directory = next((name for name in ('users', 'service-accounts', 'groups', 'account', 'site-to-site', 'backup',
'ca', 'shell', 'remote-access', 'logs', 'sites', 'dns')
if path == f'/{name}' or path.startswith(f'/{name}/')), None)
protected = path in ('/status/full', '/config', '/metrics/history', '/metrics/settings', '/postgresql') or \
protected = path in ('/status/full', '/config', '/metrics/history', '/metrics/settings', '/https/settings',
'/postgresql') or \
directory is not None
signing = path in ('/auth', '/auth/passkey', '/auth/passkey/complete', '/auth/refresh', '/auth/logout')
oidc = (path == '/.well-known/openid-configuration' or path.startswith('/oidc/')) and \
@@ -113,6 +115,9 @@ class Handler(BaseHTTPRequestHandler):
elif path == '/metrics/settings':
code, document = metrics_settings(self.command, payload)
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif path == '/https/settings':
code, document = https_settings(self.command, payload)
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif directory == 'shell' and (path, self.command) == ('/shell', 'GET'):
return shell(self, claims.get('sub'))
elif directory == 'shell' and path.startswith('/shell/editor/'):
@@ -0,0 +1,33 @@
"""/https/settings: the settings of HTTPS, of which the API changes the CA of Caddy's certificates."""
import vpnrouter
from .common import section_change
#: The settings of the https section PUT /https/settings changes.
CHANGEABLE = ('acme_staging',)
def https_settings(method, body):
"""Answer a /https/settings request: the HTTP status and the JSON document.
GET /https/settings {"settings"}: the https section
PUT /https/settings change acme_staging; the other settings of the section are changed on the router with
vpn-config-edit, and a body naming one is 400
A change is applied by restarting vpn-router-setup without waiting for it.
"""
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
return 400, {'error': 'the body must be a JSON object'}
if method in ('GET', 'HEAD'):
try:
return 200, {'settings': vpnrouter.load_config()['https']}
except SystemExit as exc:
return 500, {'error': str(exc.code)}
if method != 'PUT':
return 404, {'error': f'no route {method} /https/settings'}
others = sorted(set(body or {}) - set(CHANGEABLE))
if others:
return 400, {'error': f'https.{", https.".join(others)}: changed on the router with vpn-config-edit only'}
changed = section_change('https', body or {})
return changed if isinstance(changed, tuple) else (200, {'settings': changed})
@@ -25,7 +25,8 @@ def sites(method, path, query, body):
settings but private_key, with private_key_set, modified and its folder
PUT /sites change any of the settings of the sites section
POST /sites add a site: {"name", and any of "enabled", "hosts", "port", "listen", "tls", "ca",
"certificate", "private_key", "index_file", "spa", "editors"}; an existing name is 409
"certificate", "private_key", "index_file", "spa", "editors", "dns",
"dns_address"}; an existing name is 409
GET /sites/<name> the site
PUT /sites/<name> change any of the settings of POST but name; an empty or absent private_key keeps the key
DELETE /sites/<name> remove the site, with content=true also its folder
+13 -5
View File
@@ -3,7 +3,9 @@
A site has a name, which also names its folder in /var/lib/vpn-router/sites, host names, a port, the interfaces whose
firewall opens it, TLS (none, acme, ca with --ca, or file with --certificate and --key), the file served for a folder,
whether any path without a file is served that file of the site (--spa), and the group whose members change its files.
whether any path without a file is served that file of the site (--spa), the group whose members change its files, and
who serves the records of its host names (--dns user, elsewhere, or router, the router's name server, with the router's
address of --dns-address).
A change is applied by restarting vpn-router-setup without waiting for it. The answer is text for people on standard
output, and a failure is a message on standard error. With --json the answer is JSON on standard output, and a failure
is {"error": ...} there too. The exit status says what failed: 2 the request is invalid, 3 the site does not exist, 4
@@ -17,7 +19,8 @@ the site exists, 1 anything else. -h or --help shows the commands and their opti
options: [--host H ...] [--no-hosts] [--port P] [--listen internal|external|both] [--tls none|acme|ca|file]
[--ca NAME] [--certificate FILE] [--key FILE] [--index FILE] [--spa | --no-spa]
[--enabled | --no-enabled] [--editors GROUP]
[--enabled | --no-enabled] [--editors GROUP] [--dns user|router]
[--dns-address public|internal|external]
"""
import argparse
@@ -73,6 +76,9 @@ def parse(argv):
'path without a file')
sub.add_argument('--enabled', action=argparse.BooleanOptionalAction, help='serve the site')
sub.add_argument('--editors', metavar='GROUP', help="the group whose members change the files; '' for none")
sub.add_argument('--dns', choices=vpnrouter_sites.DNS, help='who serves the records of the host names')
sub.add_argument('--dns-address', choices=vpnrouter_sites.DNS_ADDRESSES,
help="the router's address in the records of dns router")
if command == 'remove':
sub.add_argument('--content', action='store_true', help='remove the site\'s folder too')
return parser.parse_args(argv)
@@ -82,7 +88,8 @@ def values_of(options):
"""The settings the options give."""
values = {key: getattr(options, option) for key, option in (('port', 'port'), ('listen', 'listen'), ('tls', 'tls'),
('ca', 'ca'), ('index_file', 'index'), ('spa', 'spa'),
('enabled', 'enabled'), ('editors', 'editors'))
('enabled', 'enabled'), ('editors', 'editors'),
('dns', 'dns'), ('dns_address', 'dns_address'))
if getattr(options, option) is not None}
if options.no_hosts:
values['hosts'] = []
@@ -120,9 +127,10 @@ def text_of(answer):
sites = answer['sites'] if 'sites' in answer else [answer]
if not sites:
return 'no sites'
head = ('SITE', 'ENABLED', 'HOSTS', 'PORT', 'LISTEN', 'TLS', 'EDITORS')
head = ('SITE', 'ENABLED', 'HOSTS', 'PORT', 'LISTEN', 'TLS', 'EDITORS', 'DNS')
rows = [(site['name'], 'yes' if site['enabled'] else 'no', ', '.join(site['hosts']) or '*', str(site['port']),
site['listen'], site['tls'] + (f' {site["ca"]}' if site['tls'] == 'ca' else ''), site['editors'] or '-')
site['listen'], site['tls'] + (f' {site["ca"]}' if site['tls'] == 'ca' else ''), site['editors'] or '-',
site['dns'] + (f' {site["dns_address"]}' if site['dns'] == 'router' else ''))
for site in sites]
widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))]
return '\n'.join(' '.join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip() for row in (head, *rows))
@@ -64,6 +64,14 @@ _vpn_router_site()
COMPREPLY=($(compgen -W "$(_vpn_router_site_groups)" -- "$cur"))
return
;;
--dns)
COMPREPLY=($(compgen -W 'user router' -- "$cur"))
return
;;
--dns-address)
COMPREPLY=($(compgen -W 'public internal external' -- "$cur"))
return
;;
--certificate | --key)
_filedir
return
@@ -78,7 +86,7 @@ _vpn_router_site()
local options='--help'
case $command in
add | update) options+=' --host --no-hosts --port --listen --tls --ca --certificate --key --index --spa
--no-spa --enabled --no-enabled --editors' ;;
--no-spa --enabled --no-enabled --editors --dns --dns-address' ;;
remove) options+=' --content' ;;
esac
COMPREPLY=($(compgen -W "$options" -- "$cur"))
@@ -8,7 +8,8 @@
"internal": "eth1"
},
"wan": {
"local_fqdn": "router.example.com"
"local_fqdn": "router.example.com",
"public_address": ""
},
"local": {
"cidrs": ["10.0.0.0/24"],
@@ -89,8 +90,7 @@
},
"dns": {
"enabled": false,
"nameserver": "",
"addresses": [],
"secondary": "",
"listen": "external",
"ttl": 300
},
@@ -6,21 +6,21 @@ SELECT pg_advisory_xact_lock(hashtext('vpn-router dns schema'));
DROP TABLE IF EXISTS dns_manual_records;
-- A domain the router serves as its primary name server; sites says whether the host names of the enabled sites in
-- it get records, ttl is the TTL of its records, null for dns.ttl.
-- The secondaries of each domain and the domain's switch of site records: the secondary is dns.secondary, the same
-- for every domain, and each site says whether the router serves its records.
DROP PROCEDURE IF EXISTS dns_secondary_put(text, text, inet);
DROP PROCEDURE IF EXISTS dns_secondary_remove(text, text);
DROP FUNCTION IF EXISTS dns_secondaries_list();
DROP TABLE IF EXISTS dns_secondaries;
DROP FUNCTION IF EXISTS dns_domains_list();
DROP PROCEDURE IF EXISTS dns_domain_put(text, boolean, integer);
-- A domain the router serves as its primary name server; ttl is the TTL of its records, null for dns.ttl.
CREATE TABLE IF NOT EXISTS dns_domains (
name text PRIMARY KEY,
sites boolean NOT NULL,
ttl integer
);
-- A secondary name server of a domain: its NS name and the address it transfers the domain from.
CREATE TABLE IF NOT EXISTS dns_secondaries (
domain text NOT NULL REFERENCES dns_domains (name) ON DELETE CASCADE,
nameserver text NOT NULL,
address inet NOT NULL,
PRIMARY KEY (domain, nameserver)
);
ALTER TABLE dns_domains DROP COLUMN IF EXISTS sites;
-- A record an administrator added to a domain, besides those of the domain itself and of the sites.
CREATE TABLE IF NOT EXISTS dns_records (
@@ -40,42 +40,24 @@ CREATE TABLE IF NOT EXISTS dns_state (
);
CREATE OR REPLACE FUNCTION dns_domains_list()
RETURNS TABLE (name text, sites boolean, ttl integer)
RETURNS TABLE (name text, ttl integer)
LANGUAGE sql STABLE AS $$
SELECT name, sites, ttl FROM dns_domains ORDER BY name;
SELECT name, ttl FROM dns_domains ORDER BY name;
$$;
-- Add the domain, or set its sites and TTL.
CREATE OR REPLACE PROCEDURE dns_domain_put(p_name text, p_sites boolean, p_ttl integer)
-- Add the domain, or set its TTL.
CREATE OR REPLACE PROCEDURE dns_domain_put(p_name text, p_ttl integer)
LANGUAGE sql AS $$
INSERT INTO dns_domains VALUES (p_name, p_sites, p_ttl)
ON CONFLICT (name) DO UPDATE SET sites = p_sites, ttl = p_ttl;
INSERT INTO dns_domains (name, ttl) VALUES (p_name, p_ttl)
ON CONFLICT (name) DO UPDATE SET ttl = p_ttl;
$$;
-- Remove the domain with its secondaries and records.
-- Remove the domain with its records.
CREATE OR REPLACE PROCEDURE dns_domain_remove(p_name text)
LANGUAGE sql AS $$
DELETE FROM dns_domains WHERE name = p_name;
$$;
CREATE OR REPLACE FUNCTION dns_secondaries_list()
RETURNS TABLE (domain text, nameserver text, address text)
LANGUAGE sql STABLE AS $$
SELECT domain, nameserver, host(address) FROM dns_secondaries ORDER BY domain, nameserver;
$$;
-- Add the secondary of the domain, or set its address.
CREATE OR REPLACE PROCEDURE dns_secondary_put(p_domain text, p_nameserver text, p_address inet)
LANGUAGE sql AS $$
INSERT INTO dns_secondaries VALUES (p_domain, p_nameserver, p_address)
ON CONFLICT (domain, nameserver) DO UPDATE SET address = p_address;
$$;
CREATE OR REPLACE PROCEDURE dns_secondary_remove(p_domain text, p_nameserver text)
LANGUAGE sql AS $$
DELETE FROM dns_secondaries WHERE domain = p_domain AND nameserver = p_nameserver;
$$;
CREATE OR REPLACE FUNCTION dns_records_list()
RETURNS TABLE (domain text, name text, type text, content text, ttl integer)
LANGUAGE sql STABLE AS $$
@@ -97,7 +79,7 @@ LANGUAGE sql AS $$
$$;
-- Serve the zone as its primary and give it the records, its SOA first, whose content has {serial} for the serial,
-- and the addresses of its secondaries, which are notified of its changes and may transfer it. The serial grows, to
-- and the addresses of its secondary, which are notified of its changes and may transfer it. The serial grows, to
-- at least the current Unix time, only when the records differ from those last written.
CREATE OR REPLACE PROCEDURE dns_apply(p_zone text, p_soa text, p_ttl integer, p_names text[], p_types text[],
p_contents text[], p_ttls integer[], p_secondaries text[])
@@ -3,7 +3,9 @@
SELECT pg_advisory_xact_lock(hashtext('vpn-router sites schema'));
-- A static web site: its host names, port, interfaces and TLS, and the group whose members change its files.
-- A static web site: its host names, port, interfaces and TLS, the group whose members change its files, and its
-- DNS: user, the router serves no records of its host names, or router, its name server serves A or AAAA records of
-- them with the router's public, internal or external address (dns_address).
CREATE TABLE IF NOT EXISTS sites (
name text PRIMARY KEY,
enabled boolean NOT NULL,
@@ -19,44 +21,60 @@ CREATE TABLE IF NOT EXISTS sites (
editors text NOT NULL,
modified timestamptz NOT NULL
);
ALTER TABLE sites ADD COLUMN IF NOT EXISTS dns text NOT NULL DEFAULT 'user' CHECK (dns IN ('user', 'router'));
ALTER TABLE sites ADD COLUMN IF NOT EXISTS dns_address text NOT NULL DEFAULT 'public'
CHECK (dns_address IN ('public', 'internal', 'external'));
-- The functions whose columns and the procedure whose arguments changed with dns and dns_address.
DROP FUNCTION IF EXISTS sites_list();
DROP FUNCTION IF EXISTS site_get(text);
DROP FUNCTION IF EXISTS sites_served();
DROP PROCEDURE IF EXISTS site_put(text, boolean, text[], integer, text, text, text, text, text, text, boolean, text);
-- Every site, by name; the private key is left out.
CREATE OR REPLACE FUNCTION sites_list()
RETURNS TABLE (name text, enabled boolean, hosts text[], port integer, listen text, tls text, ca text,
certificate text, index_file text, spa boolean, editors text, modified timestamptz)
certificate text, index_file text, spa boolean, editors text, dns text, dns_address text,
modified timestamptz)
LANGUAGE sql STABLE AS $$
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, index_file, spa, editors, modified
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, index_file, spa, editors, dns, dns_address, modified
FROM sites ORDER BY name;
$$;
-- The site with its private key.
CREATE OR REPLACE FUNCTION site_get(p_name text)
RETURNS TABLE (name text, enabled boolean, hosts text[], port integer, listen text, tls text, ca text,
certificate text, private_key text, index_file text, spa boolean, editors text, modified timestamptz)
certificate text, private_key text, index_file text, spa boolean, editors text, dns text,
dns_address text, modified timestamptz)
LANGUAGE sql STABLE AS $$
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, private_key, index_file, spa, editors, modified
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, private_key, index_file, spa, editors, dns,
dns_address, modified
FROM sites WHERE name = p_name;
$$;
-- The enabled sites with their private keys, by name, for Caddy's configuration and certificate files.
CREATE OR REPLACE FUNCTION sites_served()
RETURNS TABLE (name text, enabled boolean, hosts text[], port integer, listen text, tls text, ca text,
certificate text, private_key text, index_file text, spa boolean, editors text, modified timestamptz)
certificate text, private_key text, index_file text, spa boolean, editors text, dns text,
dns_address text, modified timestamptz)
LANGUAGE sql STABLE AS $$
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, private_key, index_file, spa, editors, modified
SELECT name, enabled, hosts, port, listen, tls, ca, certificate, private_key, index_file, spa, editors, dns,
dns_address, modified
FROM sites WHERE enabled ORDER BY name;
$$;
-- Add the site, or replace all its settings.
CREATE OR REPLACE PROCEDURE site_put(p_name text, p_enabled boolean, p_hosts text[], p_port integer, p_listen text,
p_tls text, p_ca text, p_certificate text, p_private_key text, p_index_file text,
p_spa boolean, p_editors text)
p_spa boolean, p_editors text, p_dns text, p_dns_address text)
LANGUAGE sql AS $$
INSERT INTO sites VALUES (p_name, p_enabled, p_hosts, p_port, p_listen, p_tls, p_ca, p_certificate, p_private_key,
p_index_file, p_spa, p_editors, now())
INSERT INTO sites (name, enabled, hosts, port, listen, tls, ca, certificate, private_key, index_file, spa, editors,
dns, dns_address, modified)
VALUES (p_name, p_enabled, p_hosts, p_port, p_listen, p_tls, p_ca, p_certificate, p_private_key, p_index_file,
p_spa, p_editors, p_dns, p_dns_address, now())
ON CONFLICT (name) DO UPDATE SET enabled = p_enabled, hosts = p_hosts, port = p_port, listen = p_listen,
tls = p_tls, ca = p_ca, certificate = p_certificate, private_key = p_private_key, index_file = p_index_file,
spa = p_spa, editors = p_editors, modified = now();
spa = p_spa, editors = p_editors, dns = p_dns, dns_address = p_dns_address, modified = now();
$$;
CREATE OR REPLACE PROCEDURE site_remove(p_name text)
+1 -1
View File
@@ -140,7 +140,7 @@ variable "deploy_workload_vm" {
variable "repo_url" {
description = "Base URL of the Debian package repository the router pulls vpn-router from."
type = string
default = "https://gitea.koszewscy.waw.pl/api/packages/slawek/debian"
default = "https://gitea.koszewscy.waw.pl/api/packages/koszewscy/debian"
}
variable "ubuntu_codename" {
+1 -1
View File
@@ -137,7 +137,7 @@ variable "deploy_workload_vm" {
variable "repo_url" {
description = "Base URL of the Debian package repository the router pulls vpn-router from."
type = string
default = "https://gitea.koszewscy.waw.pl/api/packages/slawek/debian"
default = "https://gitea.koszewscy.waw.pl/api/packages/koszewscy/debian"
}
variable "ubuntu_codename" {
+18 -11
View File
@@ -32,19 +32,26 @@ def git(*args):
def summary():
"""The summary line of the first changelog entry, or None when it has none.
"""The summary of the first changelog entry joined into one line, or None when it has none.
The summary is the entry's first text line: indented by two spaces, not a "*" item,
with a blank line between it and the header and a blank line after it.
The summary is the entry's first paragraph: lines indented by exactly two spaces, the
first not a "*" item, with a blank line between it and the header and a blank line after it.
"""
lines = CHANGELOG.read_text(encoding='utf-8').splitlines()
if len(lines) < 4 or lines[1].strip() or lines[3].strip():
if len(lines) < 4 or lines[1].strip():
return None
line = lines[2]
text = line.strip()
if not line.startswith(' ') or line.startswith(' ') or not text or text.startswith('*') or line.startswith(' -- '):
paragraph = []
for line in lines[2:]:
if not line.strip():
break
if not line.startswith(' ') or line.startswith(' ') or line.startswith(' -- '):
return None
paragraph.append(line.strip())
else:
return None
return text
if not paragraph or paragraph[0].startswith('*'):
return None
return ' '.join(paragraph)
def version():
@@ -61,7 +68,7 @@ def version():
def main():
global DRY_RUN
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument('-m', '--message', help='commit message; default: the summary line of the newest changelog entry, a line without "*" between blank lines right after its header')
parser.add_argument('-m', '--message', help='commit message; default: the summary of the newest changelog entry, a paragraph not starting with "*" between blank lines right after its header')
parser.add_argument('-n', '--dry-run', action='store_true', help='print the git commands instead of running them')
parser.add_argument('--no-push', action='store_true', help='commit and tag, but do not push')
args = parser.parse_args()
@@ -69,8 +76,8 @@ def main():
message = args.message or summary()
if not message:
sys.exit(f'no commit message: pass -m, or start the newest entry of {CHANGELOG} with a summary line '
'without "*", indented by two spaces, with a blank line above and below it')
sys.exit(f'no commit message: pass -m, or start the newest entry of {CHANGELOG} with a summary paragraph '
'not starting with "*", indented by two spaces, with a blank line above and below it')
tag = version()
if subprocess.run(['git', 'rev-parse', '-q', '--verify', f'refs/tags/{tag}'], cwd=ROOT,
capture_output=True).returncode == 0: