Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d90441bee | ||
|
|
ee1161f534 | ||
|
|
7f85a1a2e5 | ||
|
|
c2c33cfca0 | ||
|
|
f36e2a3b04 | ||
|
|
1e0e375d60 | ||
|
|
dd489e65aa |
No files matched your search
@@ -534,11 +534,17 @@ package's drop-in for `caddy.service` points it at that file and keeps it from s
|
||||
the file exists, so `/etc/caddy/Caddyfile`, a configuration file of the `caddy` package, is left
|
||||
unchanged. Both services always listen on `127.0.0.1` as well as on the interfaces their
|
||||
`listen` settings name, and trust `X-Forwarded-For` from it, which Caddy sets from the peer and
|
||||
never takes from the client.
|
||||
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. `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
|
||||
certificate and the ACME account are in `/var/lib/caddy`. Plain HTTP stays as the `listen`
|
||||
certificate and the ACME account are in `/var/lib/caddy`. A router on the staging CA has a
|
||||
certificate no client trusts. Plain HTTP stays as the `listen`
|
||||
settings set it, so `internal` keeps passwords off the internet. Caddy's admin endpoint listens
|
||||
on `localhost:2019`, which `caddy reload` uses. Port 80 and the HTTPS ports are not available
|
||||
to Docker containers while HTTPS is on. The Manager's HTTPS port is a setting of the router, not
|
||||
@@ -1205,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
|
||||
@@ -1216,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
|
||||
|
||||
@@ -1342,3 +1357,42 @@ method.
|
||||
Consequences: A change to one family of paths touches one module and the routing in `handler`.
|
||||
State shared between requests, such as the open shells, lives in the module that owns it; other
|
||||
modules change it and never rebind it.
|
||||
|
||||
## ADR-48: Exceptions are handled; no traceback reaches the logs
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: The services log to the journal, which administrators read through the Logs page and
|
||||
`journalctl`. Python prints a traceback for an exception nothing handles: `socketserver` for a
|
||||
failed request, `threading` for a failed thread and the interpreter for the process. A client
|
||||
that closes its connection before the answer is written, as a page does when it is left or
|
||||
reloaded, raises `BrokenPipeError` or `ConnectionResetError` in the handler. A traceback is many
|
||||
lines of internals for one event and hides the lines around it.
|
||||
|
||||
Decision: The code handles the exceptions it can expect where they occur, and answers or logs
|
||||
them with their reason in one line. An exception left unhandled is a defect to be fixed. As the
|
||||
last line of defence, every server replaces the default reports:
|
||||
|
||||
- `vpnrouter_serve.serve`, which the web service and the metrics service run, sets the servers'
|
||||
`handle_error`, `threading.excepthook` and `sys.excepthook`.
|
||||
- The bootstrap server sets its server's `handle_error` and `sys.excepthook`.
|
||||
|
||||
A `ConnectionError` of a client that went away is not logged; any other exception is logged in
|
||||
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.
|
||||
@@ -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 |
|
||||
@@ -141,15 +142,15 @@ must have the type shown below; an unknown section or setting is an error.
|
||||
| `https` | `email` | string | | The e-mail address of the Let's Encrypt account |
|
||||
| `https` | `web_port` | integer | `7444` | HTTPS port of the status web service |
|
||||
| `https` | `manager_port` | integer | `7443` | HTTPS port of the Router Manager |
|
||||
| `https` | `acme_staging` | boolean | `false` | Caddy takes its certificates from Let's Encrypt's staging environment alone, whose certificates no browser trusts and whose rate limits are higher, for routers rebuilt often (see HTTPS) |
|
||||
| `https` | `caddy_source` | string | `ubuntu` | Where Caddy is installed from: `ubuntu`, the Ubuntu archive, or `upstream`, Caddy's own repository (see HTTPS) |
|
||||
| `oidc` | `enabled` | boolean | `false` | The OpenID Provider at the status web service's HTTPS site, which needs `web.enabled`, `https.enabled` and `wan.local_fqdn` (see OpenID Provider) |
|
||||
| `sites` | `enabled` | boolean | `false` | Caddy serves the web sites (see Web sites) |
|
||||
| `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 |
|
||||
@@ -1017,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
|
||||
@@ -1225,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 |
|
||||
|
||||
@@ -1404,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`),
|
||||
@@ -1471,7 +1482,20 @@ while metrics are enabled the metrics service on `metrics.https_port` (9465), al
|
||||
and renews the certificate from Let's Encrypt with the HTTP challenge, so `wan.local_fqdn` must
|
||||
resolve to the router's public address and port 80 must be reachable from the internet; the
|
||||
certificate and the ACME account are kept in `/var/lib/caddy`. Port 80 answers the challenge only;
|
||||
it does not redirect to HTTPS.
|
||||
it does not redirect to HTTPS. Caddy's default CAs are Let's Encrypt and ZeroSSL, which it takes
|
||||
when Let's Encrypt refuses, such as for its rate limit of 5 certificates a week for the same name.
|
||||
|
||||
While `https.acme_staging` is `true`, Caddy's only CA is Let's Encrypt's staging environment, for
|
||||
routers that are rebuilt often. Its rate limits are far higher, but no browser or client trusts
|
||||
its certificates, so they are for tests only. The setting applies to every certificate Caddy
|
||||
obtains from an ACME CA, those of the web sites with `tls` `acme` included.
|
||||
|
||||
```sh
|
||||
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
|
||||
@@ -1715,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
|
||||
@@ -1777,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
|
||||
```
|
||||
|
||||
|
||||
+227
-57
@@ -1,3 +1,97 @@
|
||||
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.
|
||||
|
||||
* 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
|
||||
with tls acme; its certificates are not trusted by browsers.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 30 Oct 2026 02:34:01 +0100
|
||||
|
||||
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.
|
||||
|
||||
* The web service, the metrics service and the bootstrap server stop
|
||||
writing an answer when its client closes the connection, and a site
|
||||
file whose download is left no longer gets a second answer.
|
||||
* A shell whose client closed the connection before it opened is
|
||||
stopped and its end is logged, instead of running on.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 30 Oct 2026 02:03:26 +0100
|
||||
|
||||
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.
|
||||
|
||||
* 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
|
||||
one line with the exception's type and message.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 30 Oct 2026 01:59:19 +0100
|
||||
|
||||
vpn-router (0.37.1-1) unstable; urgency=medium
|
||||
|
||||
fix: /config answers the OpenID Provider's state and issuer.
|
||||
@@ -9,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,
|
||||
@@ -38,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
|
||||
@@ -52,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
|
||||
@@ -63,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.
|
||||
@@ -72,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
|
||||
@@ -84,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;
|
||||
@@ -99,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
|
||||
@@ -132,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
|
||||
@@ -150,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
|
||||
@@ -162,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.
|
||||
@@ -171,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
|
||||
@@ -185,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.
|
||||
@@ -199,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;
|
||||
@@ -214,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,
|
||||
@@ -227,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
|
||||
@@ -263,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=
|
||||
@@ -276,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
|
||||
@@ -287,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,
|
||||
@@ -300,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,
|
||||
@@ -329,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
|
||||
@@ -339,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.
|
||||
|
||||
@@ -347,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
|
||||
@@ -371,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
|
||||
@@ -382,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"}.
|
||||
|
||||
@@ -390,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
|
||||
@@ -400,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"}.
|
||||
@@ -409,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
|
||||
@@ -425,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.
|
||||
@@ -434,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
|
||||
@@ -446,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.
|
||||
@@ -457,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
|
||||
@@ -475,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
|
||||
@@ -487,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".
|
||||
@@ -496,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
|
||||
@@ -510,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.
|
||||
@@ -519,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
|
||||
@@ -529,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
|
||||
@@ -553,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.
|
||||
@@ -562,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
|
||||
@@ -572,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
|
||||
@@ -611,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),
|
||||
@@ -644,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
|
||||
@@ -663,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
|
||||
@@ -674,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
|
||||
@@ -692,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
|
||||
@@ -707,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
|
||||
@@ -724,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.
|
||||
@@ -748,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
|
||||
@@ -788,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
|
||||
@@ -800,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
|
||||
@@ -852,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
|
||||
@@ -867,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
|
||||
@@ -890,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
|
||||
@@ -976,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
|
||||
@@ -1006,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
|
||||
@@ -1021,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
|
||||
@@ -1031,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -8,6 +8,7 @@ the logs.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
PORT = 17081
|
||||
@@ -42,14 +43,28 @@ def report():
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
body = report().encode()
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'text/plain; charset=utf-8')
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
self.send_header('Refresh', '10')
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
try:
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'text/plain; charset=utf-8')
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
self.send_header('Refresh', '10')
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
except ConnectionError:
|
||||
# The client went away before the answer was written.
|
||||
self.close_connection = True
|
||||
|
||||
|
||||
def failed(request, client_address):
|
||||
"""Log a failed request in one line, and nothing when the client went away."""
|
||||
failure = sys.exc_info()[1]
|
||||
if not isinstance(failure, ConnectionError):
|
||||
print(f'a request from {client_address[0]} failed: {type(failure).__name__}: {failure}', file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
ThreadingHTTPServer(('', PORT), Handler).serve_forever()
|
||||
sys.excepthook = lambda kind, value, _: print(f'failed: {kind.__name__}: {value}', file=sys.stderr, flush=True)
|
||||
server = ThreadingHTTPServer(('', PORT), Handler)
|
||||
server.handle_error = failed
|
||||
server.serve_forever()
|
||||
@@ -405,15 +405,19 @@ class Handler(BaseHTTPRequestHandler):
|
||||
registry = CollectorRegistry()
|
||||
registry.register(Sample(current))
|
||||
body, kind, code = generate_latest(registry), CONTENT_TYPE_LATEST, 200
|
||||
self.send_response(code)
|
||||
self.send_header('Content-Type', kind)
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
for name, value in extra:
|
||||
self.send_header(name, value)
|
||||
self.end_headers()
|
||||
if send_body:
|
||||
self.wfile.write(body)
|
||||
try:
|
||||
self.send_response(code)
|
||||
self.send_header('Content-Type', kind)
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
for name, value in extra:
|
||||
self.send_header(name, value)
|
||||
self.end_headers()
|
||||
if send_body:
|
||||
self.wfile.write(body)
|
||||
except ConnectionError:
|
||||
# The client went away before the answer was written.
|
||||
self.close_connection = True
|
||||
|
||||
def do_GET(self):
|
||||
self.answer(True)
|
||||
|
||||
@@ -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': []},
|
||||
@@ -66,10 +66,11 @@ SCHEMA = {
|
||||
'status_content': 'basic', 'shell': False, 'shell_idle_minutes': 15, 'shell_auth_timeout': 15,
|
||||
'log_files': []},
|
||||
'ssh': {'ldap_login': False},
|
||||
'https': {'enabled': True, 'email': '', 'web_port': 7444, 'manager_port': 7443, 'caddy_source': 'ubuntu'},
|
||||
'https': {'enabled': True, 'email': '', 'web_port': 7444, 'manager_port': 7443, 'caddy_source': 'ubuntu',
|
||||
'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': {},
|
||||
@@ -143,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.
|
||||
@@ -164,6 +169,9 @@ MANAGER_PORT = 7080
|
||||
#: Where vpn-router-setup installs Caddy from, https.caddy_source: the Ubuntu archive or Caddy's own repository.
|
||||
CADDY_SOURCES = ('ubuntu', 'upstream')
|
||||
|
||||
#: The ACME directory of Let's Encrypt's staging environment, Caddy's only CA while https.acme_staging is true.
|
||||
ACME_STAGING_CA = 'https://acme-staging-v02.api.letsencrypt.org/directory'
|
||||
|
||||
#: Caddy's own repository, which https.caddy_source upstream configures: the source, its signing key and their URLs.
|
||||
CADDY_APT_SOURCE = pathlib.Path('/etc/apt/sources.list.d/caddy-stable.sources')
|
||||
CADDY_KEYRING = pathlib.Path('/etc/apt/keyrings/caddy-stable.asc')
|
||||
@@ -296,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
|
||||
|
||||
|
||||
@@ -577,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]:
|
||||
@@ -1034,6 +1048,7 @@ def build_context(cfg, public_ip=None):
|
||||
'web_port': _get(cfg, 'web', 'port'),
|
||||
'https_enabled': _get(cfg, 'https', 'enabled'),
|
||||
'https_email': _get(cfg, 'https', 'email'),
|
||||
'https_acme_ca': ACME_STAGING_CA if _get(cfg, 'https', 'acme_staging') else '',
|
||||
'https_web_port': _get(cfg, 'https', 'web_port'),
|
||||
'https_manager_port': _get(cfg, 'https', 'manager_port'),
|
||||
'pki_publish': _get_list(cfg, 'pki', 'publish'),
|
||||
|
||||
@@ -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,))
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ Only the Python standard library is used, so the bootstrap server, which must ru
|
||||
is broken, can use it.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from http.server import ThreadingHTTPServer
|
||||
@@ -14,9 +15,21 @@ def serve(wanted, handler, log, interval):
|
||||
|
||||
wanted answers ([(address, port), ...], '') or ([], the reason not to listen). A server whose
|
||||
place is no longer wanted is stopped, and a new one listens at each new place; a place that
|
||||
cannot be bound is tried again the next round. Every new message is logged with log. Does
|
||||
not return.
|
||||
cannot be bound is tried again the next round. Every new message is logged with log. A request
|
||||
whose client went away is not logged; any other exception a request, a thread or the process
|
||||
does not handle is logged with log in one line, without a traceback. Does not return.
|
||||
"""
|
||||
def failed(request, client_address):
|
||||
failure = sys.exc_info()[1]
|
||||
if not isinstance(failure, ConnectionError):
|
||||
log(f'a request from {client_address[0]} failed: {type(failure).__name__}: {failure}')
|
||||
|
||||
def thread_failed(hook):
|
||||
if not issubclass(hook.exc_type, (SystemExit, ConnectionError)):
|
||||
log(f'{hook.thread.name if hook.thread else "a thread"} failed: {hook.exc_type.__name__}: {hook.exc_value}')
|
||||
|
||||
threading.excepthook = thread_failed
|
||||
sys.excepthook = lambda kind, value, _: log(f'failed: {kind.__name__}: {value}')
|
||||
servers, reported = {}, None
|
||||
while True:
|
||||
targets, reason = wanted()
|
||||
@@ -34,6 +47,7 @@ def serve(wanted, handler, log, interval):
|
||||
except OSError as exc:
|
||||
messages.append(f'cannot listen on {target[0]}:{target[1]}: {exc}')
|
||||
continue
|
||||
server.handle_error = failed
|
||||
thread = threading.Thread(target=server.serve_forever, name=f'server {target[0]}', daemon=True)
|
||||
thread.start()
|
||||
servers[target] = (server, thread)
|
||||
|
||||
@@ -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/'):
|
||||
@@ -188,15 +193,19 @@ class Handler(BaseHTTPRequestHandler):
|
||||
code = 200
|
||||
else:
|
||||
body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
|
||||
self.send_response(code)
|
||||
self.send_header('Content-Type', kind)
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
for name, value in extra:
|
||||
self.send_header(name, value)
|
||||
self.end_headers()
|
||||
if send_body:
|
||||
self.wfile.write(body)
|
||||
try:
|
||||
self.send_response(code)
|
||||
self.send_header('Content-Type', kind)
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
for name, value in extra:
|
||||
self.send_header(name, value)
|
||||
self.end_headers()
|
||||
if send_body:
|
||||
self.wfile.write(body)
|
||||
except ConnectionError:
|
||||
# The client went away before the answer was written.
|
||||
self.close_connection = True
|
||||
|
||||
def raw_body(self):
|
||||
"""The request body's bytes, b'' without one or over BODY_LIMIT."""
|
||||
|
||||
@@ -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})
|
||||
@@ -367,11 +367,14 @@ def shell(handler, name):
|
||||
def refuse(code, message):
|
||||
log(f'shell of {name} from {handler.client()} refused: {message}')
|
||||
body = json.dumps({'error': message}).encode() + b'\n'
|
||||
handler.send_response(code)
|
||||
handler.send_header('Content-Type', 'application/json')
|
||||
handler.send_header('Content-Length', str(len(body)))
|
||||
handler.end_headers()
|
||||
handler.wfile.write(body)
|
||||
try:
|
||||
handler.send_response(code)
|
||||
handler.send_header('Content-Type', 'application/json')
|
||||
handler.send_header('Content-Length', str(len(body)))
|
||||
handler.end_headers()
|
||||
handler.wfile.write(body)
|
||||
except ConnectionError:
|
||||
handler.close_connection = True
|
||||
|
||||
key = handler.headers.get('Sec-WebSocket-Key', '').strip()
|
||||
if handler.headers.get('Upgrade', '').lower() != 'websocket' or 'upgrade' not in handler.headers.get('Connection', '').lower() \
|
||||
@@ -408,12 +411,21 @@ def shell(handler, name):
|
||||
finally:
|
||||
os.close(terminal)
|
||||
accept = base64.b64encode(hashlib.sha1((key + WEBSOCKET_GUID).encode()).digest()).decode()
|
||||
handler.send_response(101, 'Switching Protocols')
|
||||
handler.send_header('Upgrade', 'websocket')
|
||||
handler.send_header('Connection', 'Upgrade')
|
||||
handler.send_header('Sec-WebSocket-Accept', accept)
|
||||
handler.end_headers()
|
||||
handler.close_connection = True
|
||||
try:
|
||||
handler.send_response(101, 'Switching Protocols')
|
||||
handler.send_header('Upgrade', 'websocket')
|
||||
handler.send_header('Connection', 'Upgrade')
|
||||
handler.send_header('Sec-WebSocket-Accept', accept)
|
||||
handler.end_headers()
|
||||
except ConnectionError:
|
||||
# The client went away before the shell opened; the shell started for it is stopped.
|
||||
run('systemctl', 'stop', unit)
|
||||
os.close(master)
|
||||
process.kill()
|
||||
process.wait()
|
||||
log(f'shell of {name} from {handler.client()} ended before it opened: the client closed the connection')
|
||||
return
|
||||
client, started, idle = handler.client(), time.monotonic(), options['shell_idle_minutes'] * 60
|
||||
log(f'shell of {name} from {client} opened as {account.pw_name} in {unit}')
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -112,15 +113,19 @@ def site_files(handler, name, relative, query, payload, send_body):
|
||||
return 404, {'error': f'{relative} does not exist'}
|
||||
with open(target, 'rb') as handle:
|
||||
size = os.fstat(handle.fileno()).st_size
|
||||
handler.send_response(200)
|
||||
handler.send_header('Content-Type', mimetypes.guess_type(target.name)[0] or 'application/octet-stream')
|
||||
handler.send_header('Content-Length', str(size))
|
||||
handler.send_header('Content-Disposition',
|
||||
f"attachment; filename*=UTF-8''{urllib.parse.quote(target.name)}")
|
||||
handler.send_header('Cache-Control', 'no-store')
|
||||
handler.end_headers()
|
||||
if send_body:
|
||||
shutil.copyfileobj(handle, handler.wfile, vpnrouter_sites.CHUNK)
|
||||
try:
|
||||
handler.send_response(200)
|
||||
handler.send_header('Content-Type', mimetypes.guess_type(target.name)[0] or 'application/octet-stream')
|
||||
handler.send_header('Content-Length', str(size))
|
||||
handler.send_header('Content-Disposition',
|
||||
f"attachment; filename*=UTF-8''{urllib.parse.quote(target.name)}")
|
||||
handler.send_header('Cache-Control', 'no-store')
|
||||
handler.end_headers()
|
||||
if send_body:
|
||||
shutil.copyfileobj(handle, handler.wfile, vpnrouter_sites.CHUNK)
|
||||
except ConnectionError:
|
||||
# The client went away before the file was sent.
|
||||
handler.close_connection = True
|
||||
return None
|
||||
if handler.command == 'PUT':
|
||||
length = handler.headers.get('Content-Length')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
{%- if https_email %}
|
||||
email {{ https_email }}
|
||||
{%- endif %}
|
||||
{%- if https_acme_ca %}
|
||||
acme_ca {{ https_acme_ca }}
|
||||
{%- endif %}
|
||||
{%- if pki_cas %}
|
||||
skip_install_trust
|
||||
pki {
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user