Compare commits
16
Commits
v0.4.1-rc.2
...
v0.5.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3b054cc86 | ||
|
|
c27a6c10bd | ||
|
|
d33f573782 | ||
|
|
13ffd8cad4 | ||
|
|
67ae0091b3 | ||
|
|
c7e5bbb628 | ||
|
|
740f3cc328 | ||
|
|
5fcf4082c9 | ||
|
|
1cec6cc393 | ||
|
|
921bdd6c95 | ||
|
|
12820b17bb | ||
|
|
ead749dd3d | ||
|
|
66dcf53684 | ||
|
|
ebd3a3dfbe | ||
|
|
fd0cbb47a9 | ||
|
|
057433c198 |
No files matched your search
@@ -3,3 +3,5 @@
|
|||||||
*.db
|
*.db
|
||||||
*.db-wal
|
*.db-wal
|
||||||
*.db-shm
|
*.db-shm
|
||||||
|
/jarl
|
||||||
|
/jarld
|
||||||
+114
@@ -6,8 +6,73 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.5.2] - 2026-10-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Mail reverse DNS check: the addresses of the public zones' MX hosts and
|
||||||
|
SPF records are looked up as the internet sees them, every 6 hours: a
|
||||||
|
PTR record whose name resolves back to the address, which Gmail and
|
||||||
|
Outlook require. It says who runs the reverse zone (your provider) and
|
||||||
|
what to set there, and notes when Jarl has its own reverse zone for the
|
||||||
|
address, which only your clients see. The Exposure page (Check now),
|
||||||
|
`jarl exposure mail` and Prometheus `jarl_mail_reverse_dns_ok`.
|
||||||
|
- Added by Jarl: the Server options page, `jarl option generated` and
|
||||||
|
`GET /options/generated` list what Jarl writes into named.conf by
|
||||||
|
itself (keys.conf, the statistics channel and logging, blocking's
|
||||||
|
response-policy, the view keys), why, and what controls each.
|
||||||
|
- `stats.rate_limit_log` (default true): `false` stops Jarl adding named's
|
||||||
|
`query-errors` category to its log channel when rate limiting is set.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Option values with a list of three or more elements (allow-query,
|
||||||
|
forwarders, listen-on, ...) are shown one element per line.
|
||||||
|
|
||||||
|
## [0.5.1] - 2026-10-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Encrypted DNS health: jarld checks the DoT and DoH listeners every 10
|
||||||
|
minutes as a client would (the certificate is for the host name,
|
||||||
|
current and the one Jarl last wrote; a question gets an answer) and
|
||||||
|
says what is wrong: nothing listening, a firewall, the wrong or an
|
||||||
|
older certificate. On the Encrypted DNS page (Check now), the
|
||||||
|
dashboard, `jarl encrypted-dns health` and Prometheus
|
||||||
|
`jarl_encrypted_dns_up`.
|
||||||
|
- Comparing a zone's views: the Compare views tab and `jarl zone diff`
|
||||||
|
show the names and types that differ between the views that have a
|
||||||
|
zone, linked or not, with "Use this version" to share one view's
|
||||||
|
version. `GET /zones/{zone}/compare`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `--local` (and `"local": true`) works on zones that aren't linked too:
|
||||||
|
their records are local anyway. `--shared` there is still refused.
|
||||||
|
- When `certificates.dir` changes, jarld points the `tls` statements
|
||||||
|
encrypted DNS wrote at the new place when it starts; named kept the old
|
||||||
|
copies before, which renewals no longer updated.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Sharing a view's version of a name that was shared already (Use this
|
||||||
|
version on the shared side) didn't replace another view's override; it
|
||||||
|
said there was nothing to do.
|
||||||
|
|
||||||
|
## [0.5.0] - 2026-10-12
|
||||||
|
|
||||||
### Upgrading from 0.4
|
### Upgrading from 0.4
|
||||||
|
|
||||||
|
- The database gets three new versions (internet exposure, certificates,
|
||||||
|
linked zones), applied when jarld starts; a 0.4 jarld can't open it
|
||||||
|
afterwards, so take a backup first (`sudo jarld backup`). Backups made
|
||||||
|
by 0.4 restore into this version. Tested: 0.4.0 packages with views,
|
||||||
|
zones, records, blocking and statistics, upgraded in place and
|
||||||
|
restored from a 0.4.0 backup.
|
||||||
|
|
||||||
|
- Statistics per view start with this version: earlier figures are for
|
||||||
|
all views together.
|
||||||
|
|
||||||
- New exposure warnings (docs/exposure.md) appear for views reachable from
|
- New exposure warnings (docs/exposure.md) appear for views reachable from
|
||||||
the internet, e.g. "no rate-limit" or "version visible" on an external
|
the internet, e.g. "no rate-limit" or "version visible" on an external
|
||||||
view: fix them from the Exposure page, or acknowledge them. A change
|
view: fix them from the Exposure page, or acknowledge them. A change
|
||||||
@@ -18,8 +83,25 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
parser doesn't know them. Remove the ranges first if you need to
|
parser doesn't know them. Remove the ranges first if you need to
|
||||||
downgrade.
|
downgrade.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The web UI's top bar had grown too long, and the user name, Users and
|
||||||
|
Log out ended up on a second line on the left. The server's
|
||||||
|
configuration and security pages (Server options, ACLs, TSIG keys,
|
||||||
|
Exposure, Certificates, Encrypted DNS, Audit log) are now in a
|
||||||
|
**Server** menu, and Account, Users and Log out in a menu under your
|
||||||
|
name. On narrow screens the search box and your name stay together on
|
||||||
|
the right.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- "update PTR" when editing a record (`jarl record update ID --ptr`) did
|
||||||
|
nothing unless the name or address changed too, and the web UI didn't
|
||||||
|
even send it alone: a reverse zone created after the record never got
|
||||||
|
its PTR. Alone, it now adds the PTR record if it is missing (or enables
|
||||||
|
it). For records of linked zones, the reverse zones of every linked view
|
||||||
|
count, not only the record's own view.
|
||||||
|
|
||||||
- Option values in the web UI looked different from the editor: a long
|
- Option values in the web UI looked different from the editor: a long
|
||||||
line wrapped back to the left edge, losing its indentation, and the
|
line wrapped back to the left edge, losing its indentation, and the
|
||||||
column changed width when Edit opened. Values now keep their layout
|
column changed width when Edit opened. Values now keep their layout
|
||||||
@@ -40,6 +122,38 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- Statistics per view ([docs/stats.md](docs/stats.md#views)): the
|
||||||
|
Statistics page has a toggle per view, with All and None, remembered in
|
||||||
|
the browser, to see some views only or several together (linked views,
|
||||||
|
say). Answers, result codes, record types, zones, blocking, rate
|
||||||
|
limiting and the top lists follow it; named's own counters stay for the
|
||||||
|
whole server, and the page says so. `jarl stats --view`, API
|
||||||
|
`?views=`. Per-view figures start with this version.
|
||||||
|
- DNS over TLS and HTTPS ([docs/encrypted-dns.md](docs/encrypted-dns.md)):
|
||||||
|
`jarl encrypted-dns setup --hostname NAME --address ADDR` (and the
|
||||||
|
Encrypted DNS page) obtains a certificate for the host name if needed,
|
||||||
|
checks that nothing else listens on port 853 and the DoH port (443 by
|
||||||
|
default) at those addresses, offering a free port, and stages named's
|
||||||
|
`tls` statement and listeners as one change, keeping port 53 where it
|
||||||
|
was, plus the host name's records in your private views (local to the
|
||||||
|
view in a linked zone). The page and `jarl encrypted-dns` say what to
|
||||||
|
give Android, Linux, Firefox, Chrome and Windows. A `tls` statement whose
|
||||||
|
files are missing is refused (`tls_file_missing`), and a certificate
|
||||||
|
named uses can't be deleted.
|
||||||
|
- Linked zones ([docs/views.md](docs/views.md#linked-zones)): zones of the
|
||||||
|
same name in different views can be linked, and Jarl keeps their shared
|
||||||
|
records in step: a change in one view reaches the others in the same
|
||||||
|
changeset (diff, permissions, history and undo cover every view). Each
|
||||||
|
view can still have records of its own (`--local`, "Only here"), which
|
||||||
|
override the shared records of that name and type there, and keeps its
|
||||||
|
own zone options. Linking existing zones changes no answer: what is the
|
||||||
|
same everywhere becomes shared, the rest stays local, and a view's
|
||||||
|
version can be shared later (`--shared`, "Share"). `jarl zone link` and
|
||||||
|
`unlink`, `zone create --link-with VIEW`, `record add|set|update
|
||||||
|
--local|--shared`, a LINK column in `record list`, "Keep them linked"
|
||||||
|
when copying a zone in the web UI, `POST /zones/{zone}/link` and
|
||||||
|
`/unlink`. ACME challenges are written once per link, and `--view all`
|
||||||
|
changes a link once.
|
||||||
- TLS certificates ([docs/certificates.md](docs/certificates.md)): Jarl
|
- TLS certificates ([docs/certificates.md](docs/certificates.md)): Jarl
|
||||||
obtains certificates from Let's Encrypt or any ACME CA (account set up
|
obtains certificates from Let's Encrypt or any ACME CA (account set up
|
||||||
in jarl.yaml under `acme.account`) and renews them itself, from the CA's
|
in jarl.yaml under `acme.account`) and renews them itself, from the CA's
|
||||||
|
|||||||
@@ -97,6 +97,12 @@ error: www.adbraun.lol has a CNAME, so it can't have any other records (found A)
|
|||||||
serves the domain, decided at every renewal. Use them for the web UI
|
serves the domain, decided at every renewal. Use them for the web UI
|
||||||
(HTTPS without a restart) and for named's DNS over TLS and HTTPS (named
|
(HTTPS without a restart) and for named's DNS over TLS and HTTPS (named
|
||||||
rereads them on renewal), or bring your own.
|
rereads them on renewal), or bring your own.
|
||||||
|
* **DNS over TLS and HTTPS** for your own devices in one step: the
|
||||||
|
certificate, named's listeners on the addresses you choose (after
|
||||||
|
checking the ports are free) and the host name in your private views.
|
||||||
|
* **Linked zones for split DNS**: the same zone in several views, with the
|
||||||
|
shared records kept in step by Jarl and a few records of each view's own
|
||||||
|
(an internal address for a public name, say).
|
||||||
* **Adopts an existing server**: imports named.conf and zone files
|
* **Adopts an existing server**: imports named.conf and zone files
|
||||||
(comments included), then switches named over with an automatic
|
(comments included), then switches named over with an automatic
|
||||||
fallback.
|
fallback.
|
||||||
@@ -138,6 +144,7 @@ in `/etc/jarl/jarl.yaml` to reach it from your management network.
|
|||||||
| [docs/views.md](docs/views.md) | Split DNS: adding views to a server that has none |
|
| [docs/views.md](docs/views.md) | Split DNS: adding views to a server that has none |
|
||||||
| [docs/dnssec.md](docs/dnssec.md) | DNSSEC signing, going public with your own name servers, Porkbun |
|
| [docs/dnssec.md](docs/dnssec.md) | DNSSEC signing, going public with your own name servers, Porkbun |
|
||||||
| [docs/acme.md](docs/acme.md) | Let's Encrypt DNS-01 with certbot, acme.sh, lego and Traefik |
|
| [docs/acme.md](docs/acme.md) | Let's Encrypt DNS-01 with certbot, acme.sh, lego and Traefik |
|
||||||
|
| [docs/encrypted-dns.md](docs/encrypted-dns.md) | DNS over TLS and HTTPS for your clients: setup, ports, client settings |
|
||||||
| [docs/certificates.md](docs/certificates.md) | TLS certificates Jarl obtains and renews for its web UI and named's DNS over TLS/HTTPS |
|
| [docs/certificates.md](docs/certificates.md) | TLS certificates Jarl obtains and renews for its web UI and named's DNS over TLS/HTTPS |
|
||||||
| [docs/stats.md](docs/stats.md) | Query statistics: enabling, what is collected, retention |
|
| [docs/stats.md](docs/stats.md) | Query statistics: enabling, what is collected, retention |
|
||||||
| [docs/exposure.md](docs/exposure.md) | Internet exposure: which views are public, hardening checks, response rate limiting |
|
| [docs/exposure.md](docs/exposure.md) | Internet exposure: which views are public, hardening checks, response rate limiting |
|
||||||
@@ -193,11 +200,11 @@ review step.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Version 0.4.0 is the current release, and 0.4.1 is in testing
|
Version 0.5.2 is the current release. It adds Prometheus metrics,
|
||||||
(0.4.1-rc.2): it adds Prometheus metrics, address ranges in ACLs and
|
address ranges in ACLs and other address match lists, safer changeset
|
||||||
other address match lists, safer changeset handling, internet exposure
|
handling, internet exposure checks with response rate limiting, TLS
|
||||||
checks with response rate limiting, and TLS certificates Jarl obtains and
|
certificates Jarl obtains and renews itself, linked zones for split DNS,
|
||||||
renews itself. 0.4.0 added blocking (blocklists and
|
DNS over TLS and HTTPS, and statistics per view. 0.4.0 added blocking (blocklists and
|
||||||
your own rules, like Pi-hole, done by named, with different blocking for
|
your own rules, like Pi-hole, done by named, with different blocking for
|
||||||
different devices), disabling zones, server statistics and view keys, on
|
different devices), disabling zones, server statistics and view keys, on
|
||||||
top of 0.3.0's disabled records, script-friendly CLI output and client
|
top of 0.3.0's disabled records, script-friendly CLI output and client
|
||||||
|
|||||||
+1
-1
@@ -83,7 +83,7 @@ func acmeZones(c *client.Client, name, view string) ([]acme.Zone, error) {
|
|||||||
}
|
}
|
||||||
refs := make([]acme.Zone, len(zones))
|
refs := make([]acme.Zone, len(zones))
|
||||||
for i, z := range zones {
|
for i, z := range zones {
|
||||||
refs[i] = acme.Zone{Name: z.Name, View: z.View}
|
refs[i] = acme.Zone{Name: z.Name, View: z.View, Link: z.LinkGroup}
|
||||||
}
|
}
|
||||||
out := acme.Zones(name, refs, view)
|
out := acme.Zones(name, refs, view)
|
||||||
if len(out) == 0 {
|
if len(out) == 0 {
|
||||||
|
|||||||
+29
-1
@@ -721,7 +721,35 @@ func optionCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
scopeArgs(describe)
|
scopeArgs(describe)
|
||||||
describe.Flags().StringVar(&zoneType, "zone-type", "primary", "with --zone: the zone type")
|
describe.Flags().StringVar(&zoneType, "zone-type", "primary", "with --zone: the zone type")
|
||||||
cmd.AddCommand(list, set, unset, describe)
|
generated := &cobra.Command{
|
||||||
|
Use: "generated",
|
||||||
|
Short: "What Jarl adds to named.conf by itself, why, and what controls it",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var gen []struct {
|
||||||
|
Where string `json:"where"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Why string `json:"why"`
|
||||||
|
Setting string `json:"setting"`
|
||||||
|
}
|
||||||
|
if err := c.Get(ctx(), "/options/generated", nil, &gen); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return output(gen, func(w io.Writer) {
|
||||||
|
for i, g := range gen {
|
||||||
|
if i > 0 {
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "# %s: %s\n# controlled by: %s\n%s\n", g.Where, g.Why, g.Setting, g.Text)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.AddCommand(list, set, unset, describe, generated)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type encEndpoint struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type encStatus struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Certificate string `json:"certificate"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
DoTHost string `json:"dot_host"`
|
||||||
|
DoHURL string `json:"doh_url"`
|
||||||
|
DoT []encEndpoint `json:"dot"`
|
||||||
|
DoH []encEndpoint `json:"doh"`
|
||||||
|
Other bool `json:"other"`
|
||||||
|
Health *encHealth `json:"health"`
|
||||||
|
Addresses []struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Private bool `json:"private"`
|
||||||
|
} `json:"addresses"`
|
||||||
|
Views []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Public bool `json:"public"`
|
||||||
|
HasZone bool `json:"has_zone"`
|
||||||
|
} `json:"views"`
|
||||||
|
Certificates []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Names []string `json:"names"`
|
||||||
|
Domains []string `json:"domains"`
|
||||||
|
DNS bool `json:"dns"`
|
||||||
|
Obtained bool `json:"obtained"`
|
||||||
|
} `json:"certificates"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type encConflict struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
What string `json:"what"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func endpoints(es []encEndpoint) string {
|
||||||
|
var out []string
|
||||||
|
for _, e := range es {
|
||||||
|
out = append(out, fmt.Sprintf("%s port %d", e.Address, e.Port))
|
||||||
|
}
|
||||||
|
return strings.Join(out, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func encryptedDNSCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "encrypted-dns",
|
||||||
|
Aliases: []string{"encdns"},
|
||||||
|
Short: "DNS over TLS and HTTPS for your clients (docs/encrypted-dns.md)",
|
||||||
|
Long: `Shows whether named serves DNS over TLS (DoT) and DNS over HTTPS (DoH),
|
||||||
|
where, and what to give clients. 'setup' sets it up: a certificate for the
|
||||||
|
host name (obtained if needed), named's tls statement and listeners on the
|
||||||
|
addresses you choose, and the host name's address records in your private
|
||||||
|
views.`,
|
||||||
|
Example: ` jarl encrypted-dns
|
||||||
|
jarl encrypted-dns setup --hostname dns.adbraun.lol --address 10.0.10.5 --address 2a02:8013:7f3d:10::5
|
||||||
|
jarl encrypted-dns setup --hostname dns.adbraun.lol --address 10.0.10.5 --no-doh
|
||||||
|
jarl encrypted-dns check --address 10.0.10.5 --doh-port 443
|
||||||
|
jarl encrypted-dns disable`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var s encStatus
|
||||||
|
if err := c.Get(ctx(), "/encrypted-dns", nil, &s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return output(s, func(w io.Writer) {
|
||||||
|
if !s.Enabled {
|
||||||
|
fmt.Fprintln(w, "Encrypted DNS:\toff (jarl encrypted-dns setup --hostname NAME --address ADDR)")
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(w, "Certificate:\t%s\n", s.Certificate)
|
||||||
|
if len(s.DoT) > 0 {
|
||||||
|
fmt.Fprintf(w, "DNS over TLS:\t%s\n", endpoints(s.DoT))
|
||||||
|
}
|
||||||
|
if len(s.DoH) > 0 {
|
||||||
|
fmt.Fprintf(w, "DNS over HTTPS:\t%s\n", endpoints(s.DoH))
|
||||||
|
}
|
||||||
|
if s.DoTHost != "" {
|
||||||
|
fmt.Fprintf(w, "For DoT clients:\t%s (Android: Private DNS hostname; Linux: DNS=ADDRESS#%s, DNSOverTLS=yes)\n", s.DoTHost, s.Hostname)
|
||||||
|
}
|
||||||
|
if s.DoHURL != "" {
|
||||||
|
fmt.Fprintf(w, "For DoH clients:\t%s (Firefox, Chrome, Windows 11)\n", s.DoHURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.Enabled {
|
||||||
|
printHealth(w, s.Health)
|
||||||
|
}
|
||||||
|
if s.Other {
|
||||||
|
fmt.Fprintln(w, "Note:\tnamed has DoT or DoH listeners written by hand too; setup leaves them alone")
|
||||||
|
}
|
||||||
|
var addrs []string
|
||||||
|
for _, a := range s.Addresses {
|
||||||
|
t := a.Address
|
||||||
|
if !a.Private {
|
||||||
|
t += " (public)"
|
||||||
|
}
|
||||||
|
addrs = append(addrs, t)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "This server's addresses:\t%s\n", strings.Join(addrs, ", "))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
var host, certName string
|
||||||
|
var addrs, views []string
|
||||||
|
var noDoT, noDoH bool
|
||||||
|
var dohPort, dotPort int
|
||||||
|
setup := &cobra.Command{
|
||||||
|
Use: "setup --hostname NAME --address ADDR...",
|
||||||
|
Short: "Set up DNS over TLS and HTTPS (obtaining a certificate if needed)",
|
||||||
|
Long: `Sets up DoT (port 853) and DoH (port 443, or --doh-port) on the addresses
|
||||||
|
given, which should be this server's addresses on your own networks:
|
||||||
|
|
||||||
|
- the certificate: --certificate, or one with the dns use whose names
|
||||||
|
cover the host name, or a new one Jarl obtains for it (and waits for);
|
||||||
|
- a check that nothing else listens on those ports there;
|
||||||
|
- named's tls statement and listen-on statements (and an explicit
|
||||||
|
listen-on { any; } when there was none, as the first listen-on would
|
||||||
|
otherwise stop named listening on port 53 everywhere);
|
||||||
|
- the host name's A and AAAA records, in the private views that have its
|
||||||
|
zone (or --view), local to the view when the zone is linked.
|
||||||
|
|
||||||
|
It is one change, staged or applied like any other. Running it again
|
||||||
|
replaces the earlier setup.`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
host = strings.ToLower(strings.TrimSuffix(host, "."))
|
||||||
|
if host == "" {
|
||||||
|
return errors.New("give --hostname, the name clients connect to (e.g. dns.adbraun.lol)")
|
||||||
|
}
|
||||||
|
var s encStatus
|
||||||
|
if err := c.Get(ctx(), "/encrypted-dns", q("hostname", host), &s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(addrs) == 0 {
|
||||||
|
var list []string
|
||||||
|
for _, a := range s.Addresses {
|
||||||
|
list = append(list, a.Address)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("give --address (this server's addresses on your own networks); it has %s", strings.Join(list, ", "))
|
||||||
|
}
|
||||||
|
// The certificate.
|
||||||
|
if certName == "" {
|
||||||
|
for _, ci := range s.Certificates {
|
||||||
|
if coversName(append(ci.Names, ci.Domains...), host) {
|
||||||
|
certName = ci.Name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if certName == "" {
|
||||||
|
certName = strings.SplitN(host, ".", 2)[0]
|
||||||
|
fmt.Printf("Creating certificate %s for %s\n", certName, host)
|
||||||
|
var r struct {
|
||||||
|
Certificate certInfo `json:"certificate"`
|
||||||
|
}
|
||||||
|
if err := c.Do(ctx(), "POST", "/certificates", nil, map[string]any{"name": certName, "domains": []string{host}, "uses": []string{"dns"}}, &r); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := waitIssue(c, certName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var ci certInfo
|
||||||
|
if err := c.Get(ctx(), certPath(certName), nil, &ci); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !contains(ci.Uses, "dns") {
|
||||||
|
fmt.Printf("Adding the dns use to certificate %s\n", certName)
|
||||||
|
if err := c.Do(ctx(), "PATCH", certPath(certName), nil, map[string]any{"uses": append(ci.Uses, "dns")}, nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ci.NotAfter == nil {
|
||||||
|
if ci.IssuingSince == nil {
|
||||||
|
if err := c.Do(ctx(), "POST", certPath(certName)+"/issue", nil, map[string]any{}, nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := waitIssue(c, certName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body := map[string]any{"hostname": host, "certificate": certName, "addresses": addrs, "dot": !noDoT, "doh": !noDoH}
|
||||||
|
if cmd.Flags().Changed("doh-port") {
|
||||||
|
body["doh_port"] = dohPort
|
||||||
|
}
|
||||||
|
if cmd.Flags().Changed("dot-port") {
|
||||||
|
body["dot_port"] = dotPort
|
||||||
|
}
|
||||||
|
// Nothing else may listen there.
|
||||||
|
var chk struct {
|
||||||
|
Conflicts []encConflict `json:"conflicts"`
|
||||||
|
FreeDoHPort int `json:"free_doh_port"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
if err := c.Do(ctx(), "POST", "/encrypted-dns/check", nil, body, &chk); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(chk.Conflicts) > 0 {
|
||||||
|
var msgs []string
|
||||||
|
for _, cf := range chk.Conflicts {
|
||||||
|
msgs = append(msgs, fmt.Sprintf("%s listens on %s port %d", cf.What, cf.Address, cf.Port))
|
||||||
|
}
|
||||||
|
hint := ""
|
||||||
|
if chk.FreeDoHPort != 0 && !noDoH {
|
||||||
|
hint = fmt.Sprintf("; for DoH, port %d is free there (--doh-port %d)", chk.FreeDoHPort, chk.FreeDoHPort)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s%s", strings.Join(msgs, "; "), hint)
|
||||||
|
}
|
||||||
|
if chk.Error != "" && !flagQuiet {
|
||||||
|
fmt.Println("Note:", chk.Error)
|
||||||
|
}
|
||||||
|
if len(views) == 0 {
|
||||||
|
for _, v := range s.Views {
|
||||||
|
if !v.Public && v.HasZone {
|
||||||
|
views = append(views, v.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body["views"] = views
|
||||||
|
resp, err := c.Mutate(ctx(), "POST", "/encrypted-dns", nil, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
f := setup.Flags()
|
||||||
|
f.StringVar(&host, "hostname", "", "the name clients connect to (the certificate's)")
|
||||||
|
f.StringVar(&certName, "certificate", "", "the Jarl certificate to serve (default: one for the host name, obtained if there is none)")
|
||||||
|
f.StringArrayVar(&addrs, "address", nil, "this server's address to listen on (repeat for several)")
|
||||||
|
f.BoolVar(&noDoT, "no-dot", false, "no DNS over TLS")
|
||||||
|
f.BoolVar(&noDoH, "no-doh", false, "no DNS over HTTPS")
|
||||||
|
f.IntVar(&dohPort, "doh-port", 443, "port for DNS over HTTPS")
|
||||||
|
f.IntVar(&dotPort, "dot-port", 853, "port for DNS over TLS (clients expect 853)")
|
||||||
|
f.StringArrayVar(&views, "view", nil, "views to add the host name's records to (default: the private views that have its zone)")
|
||||||
|
|
||||||
|
var cAddrs []string
|
||||||
|
var cDoH int
|
||||||
|
check := &cobra.Command{
|
||||||
|
Use: "check --address ADDR...",
|
||||||
|
Short: "See whether something already listens on the DoT and DoH ports",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var r struct {
|
||||||
|
Conflicts []encConflict `json:"conflicts"`
|
||||||
|
FreeDoHPort int `json:"free_doh_port"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
if err := c.Do(ctx(), "POST", "/encrypted-dns/check", nil, map[string]any{"addresses": cAddrs, "dot": true, "doh": true, "doh_port": cDoH}, &r); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return output(r, func(w io.Writer) {
|
||||||
|
if r.Error != "" {
|
||||||
|
fmt.Fprintln(w, r.Error)
|
||||||
|
}
|
||||||
|
if len(r.Conflicts) == 0 {
|
||||||
|
fmt.Fprintln(w, "The ports are free.")
|
||||||
|
}
|
||||||
|
for _, cf := range r.Conflicts {
|
||||||
|
fmt.Fprintf(w, "%s port %d:\t%s listens there\n", cf.Address, cf.Port, cf.What)
|
||||||
|
}
|
||||||
|
if r.FreeDoHPort != 0 {
|
||||||
|
fmt.Fprintf(w, "Free for DoH:\tport %d\n", r.FreeDoHPort)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
check.Flags().StringArrayVar(&cAddrs, "address", nil, "address to check (repeat for several)")
|
||||||
|
check.Flags().IntVar(&cDoH, "doh-port", 443, "port for DNS over HTTPS")
|
||||||
|
|
||||||
|
disable := &cobra.Command{
|
||||||
|
Use: "disable",
|
||||||
|
Short: "Turn DNS over TLS and HTTPS off (the certificate and records stay)",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := c.Mutate(ctx(), "DELETE", "/encrypted-dns", url.Values{}, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
health := &cobra.Command{
|
||||||
|
Use: "health",
|
||||||
|
Short: "Check the DoT and DoH listeners now, as a client would",
|
||||||
|
Long: `Connects to each listener, checks the certificate it serves (for the
|
||||||
|
host name, current, the one Jarl last wrote) and asks a question. jarld
|
||||||
|
also does this every 10 minutes; the dashboard and Prometheus
|
||||||
|
(jarl_encrypted_dns_up) show the result. Exits 1 when a listener doesn't
|
||||||
|
work.`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var h encHealth
|
||||||
|
if err := c.Do(ctx(), "POST", "/encrypted-dns/health", nil, map[string]any{}, &h); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := output(h, func(w io.Writer) { printHealth(w, &h) }); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ok := true
|
||||||
|
for _, p := range h.Probes {
|
||||||
|
ok = ok && p.OK
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return errors.New("encrypted DNS isn't working everywhere")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.AddCommand(setup, check, disable, health)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// coversName reports whether certificate names cover host (a wildcard
|
||||||
|
// covers one label).
|
||||||
|
func coversName(names []string, host string) bool {
|
||||||
|
for _, n := range names {
|
||||||
|
n = strings.ToLower(strings.TrimSuffix(n, "."))
|
||||||
|
if n == host {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if base, ok := strings.CutPrefix(n, "*."); ok {
|
||||||
|
if _, rest, ok := strings.Cut(host, "."); ok && rest == base {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(list []string, s string) bool {
|
||||||
|
for _, x := range list {
|
||||||
|
if x == s {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type encHealth struct {
|
||||||
|
Checked time.Time `json:"checked"`
|
||||||
|
Probes []struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Problem string `json:"problem"`
|
||||||
|
Warning string `json:"warning"`
|
||||||
|
RCode string `json:"rcode"`
|
||||||
|
Ms int64 `json:"ms"`
|
||||||
|
} `json:"probes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func printHealth(w io.Writer, h *encHealth) bool {
|
||||||
|
if h == nil {
|
||||||
|
fmt.Fprintln(w, "Health:\tnot checked yet (jarl encrypted-dns health)")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ok := true
|
||||||
|
for _, p := range h.Probes {
|
||||||
|
kind := map[string]string{"dot": "DoT", "doh": "DoH"}[p.Kind]
|
||||||
|
state := fmt.Sprintf("working (%s in %d ms)", p.RCode, p.Ms)
|
||||||
|
if !p.OK {
|
||||||
|
state, ok = "NOT WORKING: "+p.Problem, false
|
||||||
|
} else if p.Warning != "" {
|
||||||
|
state += "; " + p.Warning
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s %s port %d:\t%s\n", kind, p.Address, p.Port, state)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "Checked:\t%s\n", h.Checked.Local().Format("2006-01-02 15:04:05"))
|
||||||
|
return ok
|
||||||
|
}
|
||||||
+81
-1
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -42,6 +43,54 @@ type exposureReport struct {
|
|||||||
Count int64
|
Count int64
|
||||||
} `json:"names"`
|
} `json:"names"`
|
||||||
} `json:"rate_limiting"`
|
} `json:"rate_limiting"`
|
||||||
|
Mail *mailReport `json:"mail"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type mailReport struct {
|
||||||
|
Checked string `json:"checked"`
|
||||||
|
Results []struct {
|
||||||
|
Zone string `json:"zone"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Roles []string `json:"roles"`
|
||||||
|
PTR []string `json:"ptr"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Level string `json:"level"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
ManagedBy string `json:"managed_by"`
|
||||||
|
LocalZone string `json:"local_zone"`
|
||||||
|
} `json:"results"`
|
||||||
|
Errors []string `json:"errors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func printMail(w io.Writer, m *mailReport) {
|
||||||
|
if len(m.Results) == 0 && len(m.Errors) == 0 {
|
||||||
|
fmt.Fprintln(w, "No mail addresses: no public zone has an MX record or SPF addresses.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w, "ZONE\tADDRESS\tFOR\tPTR\tSTATUS")
|
||||||
|
for _, r := range m.Results {
|
||||||
|
ptr := strings.Join(r.PTR, ",")
|
||||||
|
if ptr == "" {
|
||||||
|
ptr = "-"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Zone, r.Address, strings.Join(r.Roles, ", "), ptr, r.Level)
|
||||||
|
if r.Level != "ok" {
|
||||||
|
fmt.Fprintf(w, " %s\n", r.Message)
|
||||||
|
}
|
||||||
|
if r.LocalZone != "" {
|
||||||
|
fmt.Fprintf(w, " Jarl's reverse zone %s only answers inside: receivers ask %s\n", r.LocalZone, orOwner(r.ManagedBy))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, e := range m.Errors {
|
||||||
|
fmt.Fprintf(w, "error: %s\n", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func orOwner(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return "the address's owner"
|
||||||
|
}
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// exposureScope turns "server" (or "") into the server, anything else
|
// exposureScope turns "server" (or "") into the server, anything else
|
||||||
@@ -115,6 +164,10 @@ fix Jarl can stage for each.`,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if r.Mail != nil {
|
||||||
|
fmt.Fprintln(w, "\nMAIL REVERSE DNS")
|
||||||
|
printMail(w, r.Mail)
|
||||||
|
}
|
||||||
if a := r.RateLimiting; a != nil && len(a.Counts) > 0 {
|
if a := r.RateLimiting; a != nil && len(a.Counts) > 0 {
|
||||||
fmt.Fprintf(w, "\nRATE LIMITING, LAST %s\n", a.Since)
|
fmt.Fprintf(w, "\nRATE LIMITING, LAST %s\n", a.Since)
|
||||||
for _, e := range a.Counts {
|
for _, e := range a.Counts {
|
||||||
@@ -186,6 +239,33 @@ fix Jarl can stage for each.`,
|
|||||||
return report(resp)
|
return report(resp)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.AddCommand(fix, ack, unack)
|
mail := &cobra.Command{
|
||||||
|
Use: "mail",
|
||||||
|
Short: "Check the reverse DNS of the addresses that send and receive your mail, now",
|
||||||
|
Long: `Finds the addresses of the public zones' MX hosts and SPF records and checks,
|
||||||
|
through public resolvers, that each has a PTR record whose name resolves
|
||||||
|
back to it, as Gmail and Outlook do. Exits 1 when one doesn't.`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var m mailReport
|
||||||
|
if err := c.Do(ctx(), "POST", "/exposure/mail", nil, map[string]any{}, &m); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := output(m, func(w io.Writer) { printMail(w, &m) }); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, r := range m.Results {
|
||||||
|
if !r.OK {
|
||||||
|
return errors.New("some mail addresses have no working reverse DNS")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.AddCommand(fix, ack, unack, mail)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
+1
-1
@@ -259,7 +259,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
|
|||||||
}
|
}
|
||||||
add("dns", zoneCmd(), recordCmd(), viewCmd(), aclCmd(), tsigCmd(), optionCmd(), settingsCmd(), blockCmd(), blocklistCmd(), acmeCmd(), searchCmd(), statsCmd())
|
add("dns", zoneCmd(), recordCmd(), viewCmd(), aclCmd(), tsigCmd(), optionCmd(), settingsCmd(), blockCmd(), blocklistCmd(), acmeCmd(), searchCmd(), statsCmd())
|
||||||
add("changes", applyCmd(), changesetCmd(), historyCmd())
|
add("changes", applyCmd(), changesetCmd(), historyCmd())
|
||||||
add("admin", statusCmd(), exposureCmd(), certCmd(), whoamiCmd(), userCmd(), tokenCmd(), auditCmd(), checkCmd(), resyncCmd(), configCmd())
|
add("admin", statusCmd(), exposureCmd(), certCmd(), encryptedDNSCmd(), whoamiCmd(), userCmd(), tokenCmd(), auditCmd(), checkCmd(), resyncCmd(), configCmd())
|
||||||
setupCompletion(root)
|
setupCompletion(root)
|
||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-11
@@ -12,34 +12,60 @@ import (
|
|||||||
// zoneViews expands a --view value for a record command: "" or one view
|
// zoneViews expands a --view value for a record command: "" or one view
|
||||||
// as it is, "a,b" to those views, and "all" to every view that has the
|
// as it is, "a,b" to those views, and "all" to every view that has the
|
||||||
// zone.
|
// zone.
|
||||||
|
// localFanOut is set by commands making local changes (--local), which
|
||||||
|
// go to every view named even when the zones are linked.
|
||||||
|
var localFanOut bool
|
||||||
|
|
||||||
func zoneViews(c *client.Client, zone, view string) ([]string, error) {
|
func zoneViews(c *client.Client, zone, view string) ([]string, error) {
|
||||||
if view != "all" && !strings.Contains(view, ",") {
|
if view != "all" && !strings.Contains(view, ",") {
|
||||||
return []string{view}, nil
|
return []string{view}, nil
|
||||||
}
|
}
|
||||||
if view != "all" {
|
|
||||||
var out []string
|
|
||||||
for _, v := range strings.Split(view, ",") {
|
|
||||||
if v = strings.TrimSpace(v); v != "" {
|
|
||||||
out = append(out, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
var zones []api.Zone
|
var zones []api.Zone
|
||||||
if err := c.Get(ctx(), "/zones", nil, &zones); err != nil {
|
if err := c.Get(ctx(), "/zones", nil, &zones); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
want := strings.ToLower(strings.TrimSuffix(zone, "."))
|
want := strings.ToLower(strings.TrimSuffix(zone, "."))
|
||||||
|
link := map[string]int64{}
|
||||||
var out []string
|
var out []string
|
||||||
for _, z := range zones {
|
for _, z := range zones {
|
||||||
if strings.ToLower(strings.TrimSuffix(z.Display, ".")) == want && z.View != "" {
|
if strings.ToLower(strings.TrimSuffix(z.Display, ".")) == want && z.View != "" {
|
||||||
out = append(out, z.View)
|
link[z.View] = z.LinkGroup
|
||||||
|
if view == "all" {
|
||||||
|
out = append(out, z.View)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if view != "all" {
|
||||||
|
for _, v := range strings.Split(view, ",") {
|
||||||
|
if v = strings.TrimSpace(v); v != "" {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(out) == 0 {
|
if len(out) == 0 {
|
||||||
return nil, fmt.Errorf("zone %s is in no view", zone)
|
return nil, fmt.Errorf("zone %s is in no view", zone)
|
||||||
}
|
}
|
||||||
return out, nil
|
// Linked zones (docs/views.md#linked-zones) get a shared change once:
|
||||||
|
// Jarl copies it to the others. A local change goes to each.
|
||||||
|
if localFanOut {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
var dedup []string
|
||||||
|
first := map[int64]string{}
|
||||||
|
for _, v := range out {
|
||||||
|
g := link[v]
|
||||||
|
if g != 0 {
|
||||||
|
if f, ok := first[g]; ok {
|
||||||
|
if !flagQuiet {
|
||||||
|
fmt.Fprintf(os.Stderr, "view %s: linked with %s, so it gets the change from there\n", v, f)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
first[g] = v
|
||||||
|
}
|
||||||
|
dedup = append(dedup, v)
|
||||||
|
}
|
||||||
|
return dedup, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// perView runs a record command once per view. With several views, the
|
// perView runs a record command once per view. With several views, the
|
||||||
|
|||||||
+37
-9
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
|
"net/url"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -21,8 +22,22 @@ func statsCmd() *cobra.Command {
|
|||||||
Long: `Query statistics collected from named (see docs/stats.md). They must be
|
Long: `Query statistics collected from named (see docs/stats.md). They must be
|
||||||
enabled in /etc/jarl/jarl.yaml (stats.enabled: true).
|
enabled in /etc/jarl/jarl.yaml (stats.enabled: true).
|
||||||
|
|
||||||
Ranges are given with --since: 90m, 24h, 7d, 2w. Use -o json or -o yaml to
|
Ranges are given with --since: 90m, 24h, 7d, 2w. --view limits the figures
|
||||||
export the data.`,
|
to some views (repeat it, or separate with commas): answers from named's
|
||||||
|
response log, per view; named's own counters stay for the whole server.
|
||||||
|
Use -o json or -o yaml to export the data.`,
|
||||||
|
}
|
||||||
|
var statsViews []string
|
||||||
|
cmd.PersistentFlags().StringSliceVar(&statsViews, "view", nil, "only these views (repeat, or comma separated)")
|
||||||
|
// withViews adds the --view filter to a query.
|
||||||
|
withViews := func(v url.Values) url.Values {
|
||||||
|
if len(statsViews) > 0 {
|
||||||
|
if v == nil {
|
||||||
|
v = url.Values{}
|
||||||
|
}
|
||||||
|
v.Set("views", strings.Join(statsViews, ","))
|
||||||
|
}
|
||||||
|
return v
|
||||||
}
|
}
|
||||||
// Each command has its own --since variable: their defaults differ, and
|
// Each command has its own --since variable: their defaults differ, and
|
||||||
// a shared variable would take the last default for all of them.
|
// a shared variable would take the last default for all of them.
|
||||||
@@ -77,14 +92,27 @@ export the data.`,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var s stats.Summary
|
var s stats.Summary
|
||||||
if err := c.Get(ctx(), "/stats/summary", q("since", summarySince, "top", strconv.Itoa(topN)), &s); err != nil {
|
if err := c.Get(ctx(), "/stats/summary", withViews(q("since", summarySince, "top", strconv.Itoa(topN))), &s); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return output(s, func(w io.Writer) {
|
return output(s, func(w io.Writer) {
|
||||||
fmt.Fprintf(w, "Range:\t%s to %s\n", s.From.Local().Format("2006-01-02 15:04"), s.To.Local().Format("2006-01-02 15:04"))
|
fmt.Fprintf(w, "Range:\t%s to %s\n", s.From.Local().Format("2006-01-02 15:04"), s.To.Local().Format("2006-01-02 15:04"))
|
||||||
fmt.Fprintf(w, "Queries:\t%d (%.2f per second on average)\n", s.Queries, s.AvgQPS)
|
if len(s.FilterViews) > 0 {
|
||||||
|
since := ""
|
||||||
|
if s.ViewsSince != nil {
|
||||||
|
since = ", per view since " + s.ViewsSince.Local().Format("2006-01-02 15:04")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "Views:\t%s (from the response log%s; named's own counters are left out)\n", strings.Join(s.FilterViews, ", "), since)
|
||||||
|
fmt.Fprintf(w, "Answers:\t%d (%.2f per second on average)\n", s.Queries, s.AvgQPS)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(w, "Queries:\t%d (%.2f per second on average)\n", s.Queries, s.AvgQPS)
|
||||||
|
}
|
||||||
if s.Peak != nil {
|
if s.Peak != nil {
|
||||||
fmt.Fprintf(w, "Busiest %s:\t%s with %d queries\n", s.Peak.Per, s.Peak.Time.Local().Format("2006-01-02 15:04"), s.Peak.Queries)
|
what := "queries"
|
||||||
|
if len(s.FilterViews) > 0 {
|
||||||
|
what = "answers"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "Busiest %s:\t%s with %d %s\n", s.Peak.Per, s.Peak.Time.Local().Format("2006-01-02 15:04"), s.Peak.Queries, what)
|
||||||
}
|
}
|
||||||
if s.CacheHitRatio != nil {
|
if s.CacheHitRatio != nil {
|
||||||
fmt.Fprintf(w, "Cache hit ratio:\t%.1f%%\n", *s.CacheHitRatio*100)
|
fmt.Fprintf(w, "Cache hit ratio:\t%.1f%%\n", *s.CacheHitRatio*100)
|
||||||
@@ -157,7 +185,7 @@ export the data.`,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var es []stats.Entry
|
var es []stats.Entry
|
||||||
if err := c.Get(ctx(), "/stats/top", q("kind", kind, "since", topSince, "limit", strconv.Itoa(limit)), &es); err != nil {
|
if err := c.Get(ctx(), "/stats/top", withViews(q("kind", kind, "since", topSince, "limit", strconv.Itoa(limit))), &es); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return output(es, func(w io.Writer) {
|
return output(es, func(w io.Writer) {
|
||||||
@@ -207,7 +235,7 @@ export the data.`,
|
|||||||
qv.Add("key", k)
|
qv.Add("key", k)
|
||||||
}
|
}
|
||||||
var tl stats.Timeline
|
var tl stats.Timeline
|
||||||
if err := c.Get(ctx(), "/stats/timeline", qv, &tl); err != nil {
|
if err := c.Get(ctx(), "/stats/timeline", withViews(qv), &tl); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return output(tl, func(w io.Writer) {
|
return output(tl, func(w io.Writer) {
|
||||||
@@ -256,7 +284,7 @@ export the data.`,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var hm stats.Heatmap
|
var hm stats.Heatmap
|
||||||
if err := c.Get(ctx(), "/stats/heatmap", q("since", heatmapSince, "key", hmKey), &hm); err != nil {
|
if err := c.Get(ctx(), "/stats/heatmap", withViews(q("since", heatmapSince, "key", hmKey)), &hm); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return output(hm, func(w io.Writer) {
|
return output(hm, func(w io.Writer) {
|
||||||
@@ -308,7 +336,7 @@ export the data.`,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var es []stats.Entry
|
var es []stats.Entry
|
||||||
if err := c.Get(ctx(), "/stats/breakdown", q("group", args[0], "since", breakdownSince), &es); err != nil {
|
if err := c.Get(ctx(), "/stats/breakdown", withViews(q("group", args[0], "since", breakdownSince)), &es); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return output(es, func(w io.Writer) {
|
return output(es, func(w io.Writer) {
|
||||||
|
|||||||
+200
-5
@@ -93,6 +93,9 @@ func zoneCmd() *cobra.Command {
|
|||||||
if z.InView != "" {
|
if z.InView != "" {
|
||||||
fmt.Fprintf(w, "In view:\t%s\n", z.InView)
|
fmt.Fprintf(w, "In view:\t%s\n", z.InView)
|
||||||
}
|
}
|
||||||
|
if len(z.LinkedWith) > 0 {
|
||||||
|
fmt.Fprintf(w, "Linked with:\t%s (shared records are kept in step; 'jarl record list' shows which are local)\n", strings.Join(z.LinkedWith, ", "))
|
||||||
|
}
|
||||||
if z.ZoneFile != "" {
|
if z.ZoneFile != "" {
|
||||||
fmt.Fprintf(w, "File:\t%s\n", z.ZoneFile)
|
fmt.Fprintf(w, "File:\t%s\n", z.ZoneFile)
|
||||||
}
|
}
|
||||||
@@ -105,7 +108,7 @@ func zoneCmd() *cobra.Command {
|
|||||||
show.Flags().StringVar(&view, "view", "", "view of the zone (when the name exists in several views)")
|
show.Flags().StringVar(&view, "view", "", "view of the zone (when the name exists in several views)")
|
||||||
|
|
||||||
var ns, records, primaries, forwarders, opts []string
|
var ns, records, primaries, forwarders, opts []string
|
||||||
var contact, comment, cloneFrom, file, inView, forward string
|
var contact, comment, cloneFrom, file, inView, forward, linkWith string
|
||||||
var ttl uint32
|
var ttl uint32
|
||||||
create := &cobra.Command{
|
create := &cobra.Command{
|
||||||
Use: "create NAME",
|
Use: "create NAME",
|
||||||
@@ -120,7 +123,8 @@ and a network between octet boundaries, like a /22, gets one zone per /24
|
|||||||
jarl zone create 10.0.8.0/22 # 8-11.0.10.in-addr.arpa
|
jarl zone create 10.0.8.0/22 # 8-11.0.10.in-addr.arpa
|
||||||
jarl zone create partner.example --type secondary --primary 192.0.2.53
|
jarl zone create partner.example --type secondary --primary 192.0.2.53
|
||||||
jarl zone create consul --type forward --forwarder "127.0.0.1 port 8600"
|
jarl zone create consul --type forward --forwarder "127.0.0.1 port 8600"
|
||||||
jarl zone create example.com --view external --clone-from example.com --clone-view internal`,
|
jarl zone create example.com --view external --clone-from example.com --clone-view internal
|
||||||
|
jarl zone create example.com --view vps --link-with external # linked: shared records kept in step`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
c, err := newClient()
|
c, err := newClient()
|
||||||
@@ -149,6 +153,9 @@ and a network between octet boundaries, like a /22, gets one zone per /24
|
|||||||
if inView != "" {
|
if inView != "" {
|
||||||
body["in_view"] = inView
|
body["in_view"] = inView
|
||||||
}
|
}
|
||||||
|
if linkWith != "" {
|
||||||
|
body["link_with"] = linkWith
|
||||||
|
}
|
||||||
var recs []map[string]any
|
var recs []map[string]any
|
||||||
for _, r := range records {
|
for _, r := range records {
|
||||||
f := strings.Fields(r)
|
f := strings.Fields(r)
|
||||||
@@ -219,6 +226,7 @@ and a network between octet boundaries, like a /22, gets one zone per /24
|
|||||||
create.Flags().StringVar(&cloneView, "clone-view", "", "view of --clone-from")
|
create.Flags().StringVar(&cloneView, "clone-view", "", "view of --clone-from")
|
||||||
create.Flags().StringVar(&file, "file", "", "zone file (hint and redirect zones)")
|
create.Flags().StringVar(&file, "file", "", "zone file (hint and redirect zones)")
|
||||||
create.Flags().StringVar(&inView, "in-view", "", "for in-view zones: the view to share the zone from")
|
create.Flags().StringVar(&inView, "in-view", "", "for in-view zones: the view to share the zone from")
|
||||||
|
create.Flags().StringVar(&linkWith, "link-with", "", "link with the zone of the same name in this view: copy its shared records and keep them in step (docs/views.md#linked-zones)")
|
||||||
create.Flags().StringVar(&comment, "comment", "", "comment")
|
create.Flags().StringVar(&comment, "comment", "", "comment")
|
||||||
|
|
||||||
var refresh, retry, expire, minimum, serial uint32
|
var refresh, retry, expire, minimum, serial uint32
|
||||||
@@ -416,7 +424,7 @@ equal to the file: records not in the file are deleted.`,
|
|||||||
importCmd.Flags().BoolVar(&replace, "replace", false, "delete records that are not in the file")
|
importCmd.Flags().BoolVar(&replace, "replace", false, "delete records that are not in the file")
|
||||||
importCmd.Flags().BoolVar(&useSOA, "use-soa", false, "also take SOA timers and $TTL from the file")
|
importCmd.Flags().BoolVar(&useSOA, "use-soa", false, "also take SOA timers and $TTL from the file")
|
||||||
|
|
||||||
cmd.AddCommand(list, show, create, update, del, toggle(true), toggle(false), fileCmd, importCmd, zoneSerialsCmd(), zoneDelegationCmd(), zoneDNSSECCmd(), zoneRegistrarCmd())
|
cmd.AddCommand(list, show, create, update, del, toggle(true), toggle(false), fileCmd, importCmd, zoneSerialsCmd(), zoneDelegationCmd(), zoneDNSSECCmd(), zoneRegistrarCmd(), zoneLinkCmd(true), zoneLinkCmd(false), zoneDiffCmd())
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,8 +464,25 @@ const viewsFlagHelp = "view of the zone; several (internal,external) or \"all\"
|
|||||||
func recordCmd() *cobra.Command {
|
func recordCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{Use: "record", Aliases: []string{"records", "rr"}, Short: "Manage records in primary zones"}
|
cmd := &cobra.Command{Use: "record", Aliases: []string{"records", "rr"}, Short: "Manage records in primary zones"}
|
||||||
var view, name, typ, search, comment, ttlArg string
|
var view, name, typ, search, comment, ttlArg string
|
||||||
var ptr, missingOK, yes, onlyDisabled, wide bool
|
var ptr, missingOK, yes, onlyDisabled, wide, local, shared bool
|
||||||
var id int64
|
var id int64
|
||||||
|
// linkFlags adds --local and --shared (linked zones) to a command.
|
||||||
|
linkFlags := func(c *cobra.Command, what string) {
|
||||||
|
c.Flags().BoolVar(&local, "local", false, "linked zones: "+what+" in this view only (overriding the shared records of this name and type here)")
|
||||||
|
c.Flags().BoolVar(&shared, "shared", false, "linked zones: "+what+" in every linked view (this version becomes every view's)")
|
||||||
|
}
|
||||||
|
linkBody := func(body map[string]any) error {
|
||||||
|
switch {
|
||||||
|
case local && shared:
|
||||||
|
return fmt.Errorf("give --local or --shared, not both")
|
||||||
|
case local:
|
||||||
|
body["local"] = true
|
||||||
|
localFanOut = true
|
||||||
|
case shared:
|
||||||
|
body["local"] = false
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
list := &cobra.Command{
|
list := &cobra.Command{
|
||||||
Use: "list ZONE",
|
Use: "list ZONE",
|
||||||
@@ -479,8 +504,15 @@ func recordCmd() *cobra.Command {
|
|||||||
if err := c.Get(ctx(), path+"/records", qv, &recs); err != nil {
|
if err := c.Get(ctx(), path+"/records", qv, &recs); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
linked := false
|
||||||
|
for _, r := range recs {
|
||||||
|
linked = linked || r.Linked != ""
|
||||||
|
}
|
||||||
return output(recs, func(w io.Writer) {
|
return output(recs, func(w io.Writer) {
|
||||||
rows := [][]string{{"ID", "NAME", "TTL", "TYPE", "DATA", "COMMENT"}}
|
rows := [][]string{{"ID", "NAME", "TTL", "TYPE", "DATA", "COMMENT"}}
|
||||||
|
if linked {
|
||||||
|
rows[0] = []string{"ID", "NAME", "TTL", "TYPE", "LINK", "DATA", "COMMENT"}
|
||||||
|
}
|
||||||
for _, r := range recs {
|
for _, r := range recs {
|
||||||
ttl := fmt.Sprint(r.EffectiveTTL)
|
ttl := fmt.Sprint(r.EffectiveTTL)
|
||||||
if r.TTL == nil {
|
if r.TTL == nil {
|
||||||
@@ -490,10 +522,18 @@ func recordCmd() *cobra.Command {
|
|||||||
if r.Disabled {
|
if r.Disabled {
|
||||||
comment = strings.TrimSpace("[disabled] " + comment)
|
comment = strings.TrimSpace("[disabled] " + comment)
|
||||||
}
|
}
|
||||||
|
if linked {
|
||||||
|
rows = append(rows, []string{fmt.Sprint(r.ID), r.RelativeName, ttl, r.Type, r.Linked, r.Data, comment})
|
||||||
|
continue
|
||||||
|
}
|
||||||
rows = append(rows, []string{fmt.Sprint(r.ID), r.RelativeName, ttl, r.Type, r.Data, comment})
|
rows = append(rows, []string{fmt.Sprint(r.ID), r.RelativeName, ttl, r.Type, r.Data, comment})
|
||||||
}
|
}
|
||||||
if !wide {
|
if !wide {
|
||||||
fitColumn(rows, 4)
|
col := 4
|
||||||
|
if linked {
|
||||||
|
col = 5
|
||||||
|
}
|
||||||
|
fitColumn(rows, col)
|
||||||
}
|
}
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
fmt.Fprintln(w, strings.Join(row, "\t"))
|
fmt.Fprintln(w, strings.Join(row, "\t"))
|
||||||
@@ -535,6 +575,9 @@ and splitting are automatic).
|
|||||||
if ptr {
|
if ptr {
|
||||||
body["ptr"] = true
|
body["ptr"] = true
|
||||||
}
|
}
|
||||||
|
if err := linkBody(body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
views, err := zoneViews(c, args[0], view)
|
views, err := zoneViews(c, args[0], view)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -549,6 +592,7 @@ and splitting are automatic).
|
|||||||
add.Flags().StringVar(&ttlArg, "ttl", "", "TTL in seconds (default: the RRset's or the zone's)")
|
add.Flags().StringVar(&ttlArg, "ttl", "", "TTL in seconds (default: the RRset's or the zone's)")
|
||||||
add.Flags().StringVar(&comment, "comment", "", "comment")
|
add.Flags().StringVar(&comment, "comment", "", "comment")
|
||||||
add.Flags().BoolVar(&ptr, "ptr", false, "also add the PTR record (A/AAAA)")
|
add.Flags().BoolVar(&ptr, "ptr", false, "also add the PTR record (A/AAAA)")
|
||||||
|
linkFlags(add, "add it")
|
||||||
|
|
||||||
var data string
|
var data string
|
||||||
update := &cobra.Command{
|
update := &cobra.Command{
|
||||||
@@ -582,6 +626,9 @@ and splitting are automatic).
|
|||||||
if ptr {
|
if ptr {
|
||||||
body["ptr"] = true
|
body["ptr"] = true
|
||||||
}
|
}
|
||||||
|
if err := linkBody(body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if len(body) == 0 {
|
if len(body) == 0 {
|
||||||
return fmt.Errorf("nothing to change (see --help)")
|
return fmt.Errorf("nothing to change (see --help)")
|
||||||
}
|
}
|
||||||
@@ -598,6 +645,8 @@ and splitting are automatic).
|
|||||||
update.Flags().StringVar(&ttlArg, "ttl", "", `new TTL, or "default" for the zone default`)
|
update.Flags().StringVar(&ttlArg, "ttl", "", `new TTL, or "default" for the zone default`)
|
||||||
update.Flags().StringVar(&comment, "comment", "", "new comment")
|
update.Flags().StringVar(&comment, "comment", "", "new comment")
|
||||||
update.Flags().BoolVar(&ptr, "ptr", false, "move the PTR record along (A/AAAA)")
|
update.Flags().BoolVar(&ptr, "ptr", false, "move the PTR record along (A/AAAA)")
|
||||||
|
update.Flags().BoolVar(&local, "local", false, "linked zones: make this name and type local to the record's view (an override)")
|
||||||
|
update.Flags().BoolVar(&shared, "shared", false, "linked zones: share this name and type with the linked views; this view's version becomes every view's")
|
||||||
|
|
||||||
del := &cobra.Command{
|
del := &cobra.Command{
|
||||||
Use: "delete ZONE NAME TYPE [DATA...] | delete --id ID",
|
Use: "delete ZONE NAME TYPE [DATA...] | delete --id ID",
|
||||||
@@ -678,6 +727,9 @@ spaces.
|
|||||||
if comment != "" {
|
if comment != "" {
|
||||||
body["comment"] = comment
|
body["comment"] = comment
|
||||||
}
|
}
|
||||||
|
if err := linkBody(body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
views, err := zoneViews(c, args[0], view)
|
views, err := zoneViews(c, args[0], view)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -691,6 +743,7 @@ spaces.
|
|||||||
set.Flags().StringVar(&view, "view", "", viewsFlagHelp)
|
set.Flags().StringVar(&view, "view", "", viewsFlagHelp)
|
||||||
set.Flags().StringVar(&ttlArg, "ttl", "", "TTL for the RRset")
|
set.Flags().StringVar(&ttlArg, "ttl", "", "TTL for the RRset")
|
||||||
set.Flags().StringVar(&comment, "comment", "", "comment for new records")
|
set.Flags().StringVar(&comment, "comment", "", "comment for new records")
|
||||||
|
linkFlags(set, "set it")
|
||||||
|
|
||||||
toggle := func(disable bool) *cobra.Command {
|
toggle := func(disable bool) *cobra.Command {
|
||||||
verb := "enable"
|
verb := "enable"
|
||||||
@@ -829,3 +882,145 @@ func fitColumn(rows [][]string, col int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// zoneLinkCmd is 'zone link' and 'zone unlink' (docs/views.md#linked-zones).
|
||||||
|
func zoneLinkCmd(link bool) *cobra.Command {
|
||||||
|
var view string
|
||||||
|
var with []string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "link ZONE --view VIEW [--with VIEW...]",
|
||||||
|
Short: "Link zones of the same name in several views: shared records are kept in step",
|
||||||
|
Long: `Links a zone with the zones of the same name in other views (--with, or
|
||||||
|
every view that has one). Jarl then keeps their shared records in step: a
|
||||||
|
change to one reaches the others in the same changeset. Each view can
|
||||||
|
still have records of its own (record add/set --local), which override
|
||||||
|
the shared records of that name and type there, and every zone keeps its
|
||||||
|
own options, so views can still differ in who they answer and how.
|
||||||
|
|
||||||
|
Linking never changes what named answers: a name and type with the same
|
||||||
|
records in every view becomes shared, and one that differs stays local to
|
||||||
|
each view. Share one later from the view whose version is right:
|
||||||
|
jarl record update ID --shared.`,
|
||||||
|
Example: ` jarl zone link adbraun.lol --view external --with vps
|
||||||
|
jarl zone create adbraun.lol --view vps --link-with external # a new zone, linked from the start
|
||||||
|
jarl zone unlink adbraun.lol --view vps`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path, qv := zonePath(args[0], view)
|
||||||
|
verb := "unlink"
|
||||||
|
var body any
|
||||||
|
if link {
|
||||||
|
verb = "link"
|
||||||
|
body = map[string]any{"with": with}
|
||||||
|
}
|
||||||
|
resp, err := c.Mutate(ctx(), "POST", path+"/"+verb, qv, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&view, "view", "", "view of the zone")
|
||||||
|
if link {
|
||||||
|
cmd.Flags().StringSliceVar(&with, "with", nil, "views to link with (default: every view with a zone of that name)")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
cmd.Use = "unlink ZONE --view VIEW"
|
||||||
|
cmd.Short = "Take a zone out of its link: its records stay, all local"
|
||||||
|
cmd.Long = `Takes a zone out of its link. Its records stay as they are, all local to
|
||||||
|
its view, and the other zones of the link keep theirs. A link left with one
|
||||||
|
zone is dissolved.`
|
||||||
|
cmd.Example = " jarl zone unlink adbraun.lol --view vps"
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// zoneDiffCmd is 'zone diff': a zone's records side by side in the views
|
||||||
|
// that have it.
|
||||||
|
func zoneDiffCmd() *cobra.Command {
|
||||||
|
var views []string
|
||||||
|
var all bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "diff ZONE [--view V --view W] [--all]",
|
||||||
|
Short: "Show what differs between the views that have a zone",
|
||||||
|
Long: `Lines up a zone's records in the views that have it (or the views
|
||||||
|
given), one row per name and type, and shows the ones that differ: a
|
||||||
|
local override in a linked zone, a record one view lacks. With --all, the
|
||||||
|
ones that are the same too. In a linked zone, 'jarl record update ID
|
||||||
|
--shared' makes a view's version every view's.`,
|
||||||
|
Example: ` jarl zone diff adbraun.lol
|
||||||
|
jarl zone diff adbraun.lol --view internal --view external --all`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path, qv := zonePath(args[0], "")
|
||||||
|
if len(views) > 0 {
|
||||||
|
qv.Set("views", strings.Join(views, ","))
|
||||||
|
}
|
||||||
|
if all {
|
||||||
|
qv.Set("all", "1")
|
||||||
|
}
|
||||||
|
var r struct {
|
||||||
|
Zone string `json:"zone"`
|
||||||
|
Views []string `json:"views"`
|
||||||
|
Linked bool `json:"linked"`
|
||||||
|
Same int `json:"same"`
|
||||||
|
Rows []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Same bool `json:"same"`
|
||||||
|
Views []struct {
|
||||||
|
Records []string `json:"records"`
|
||||||
|
IDs []int64 `json:"ids"`
|
||||||
|
Linked string `json:"linked"`
|
||||||
|
} `json:"views"`
|
||||||
|
} `json:"rows"`
|
||||||
|
}
|
||||||
|
if err := c.Get(ctx(), path+"/compare", qv, &r); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return output(r, func(w io.Writer) {
|
||||||
|
head := []string{"NAME", "TYPE"}
|
||||||
|
for _, v := range r.Views {
|
||||||
|
head = append(head, strings.ToUpper(v))
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w, strings.Join(head, "\t"))
|
||||||
|
for _, row := range r.Rows {
|
||||||
|
cols := []string{row.Name, row.Type}
|
||||||
|
for _, vs := range row.Views {
|
||||||
|
cell := "-"
|
||||||
|
if len(vs.Records) > 0 {
|
||||||
|
cell = strings.Join(vs.Records, ", ")
|
||||||
|
if vs.Linked == "local" {
|
||||||
|
cell += " [local, id " + fmt.Sprint(vs.IDs[0]) + "]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cols = append(cols, cell)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w, strings.Join(cols, "\t"))
|
||||||
|
}
|
||||||
|
link := "not linked (jarl zone link " + r.Zone + ")"
|
||||||
|
if r.Linked {
|
||||||
|
link = "linked"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "\n%d names and types differ, %d are the same; %s\n", len(r.Rows)-boolCount(all, r.Same), r.Same, link)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringArrayVar(&views, "view", nil, "a view to compare (repeat; default: every view with the zone)")
|
||||||
|
cmd.Flags().BoolVar(&all, "all", false, "also show what is the same everywhere")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolCount(b bool, n int) int {
|
||||||
|
if b {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
+4
-1
@@ -188,6 +188,7 @@ func renderExtras(cfg *config.Config) render.Extras {
|
|||||||
h := &render.StatsHooks{Channel: netip.MustParseAddrPort(cfg.Stats.StatisticsChannel)}
|
h := &render.StatsHooks{Channel: netip.MustParseAddrPort(cfg.Stats.StatisticsChannel)}
|
||||||
if cfg.Stats.QueryLog {
|
if cfg.Stats.QueryLog {
|
||||||
h.LogFile, h.LogVersions, h.LogSize = cfg.Stats.LogFile, cfg.Stats.LogVersions, cfg.Stats.LogSize
|
h.LogFile, h.LogVersions, h.LogSize = cfg.Stats.LogFile, cfg.Stats.LogVersions, cfg.Stats.LogSize
|
||||||
|
h.NoRateLimitLog = !cfg.Stats.RateLimitLog
|
||||||
}
|
}
|
||||||
x.Stats = h
|
x.Stats = h
|
||||||
}
|
}
|
||||||
@@ -333,7 +334,9 @@ func serveCmd() *cobra.Command {
|
|||||||
TOTPRequire: a.cfg.TOTP.Require, TOTPIssuer: a.cfg.TOTP.IssuerName(),
|
TOTPRequire: a.cfg.TOTP.Require, TOTPIssuer: a.cfg.TOTP.IssuerName(),
|
||||||
Metrics: server.Metrics{Enabled: a.cfg.Metrics.Enabled, Listen: a.cfg.Metrics.Listen,
|
Metrics: server.Metrics{Enabled: a.cfg.Metrics.Enabled, Listen: a.cfg.Metrics.Listen,
|
||||||
Allow: a.cfg.Metrics.AllowPrefixes(), Token: a.cfg.Metrics.Token},
|
Allow: a.cfg.Metrics.AllowPrefixes(), Token: a.cfg.Metrics.Token},
|
||||||
Certs: cm,
|
Certs: cm,
|
||||||
|
Resolvers: a.cfg.ACME.Resolvers,
|
||||||
|
RateLimitLog: a.cfg.Stats.Enabled && a.cfg.Stats.QueryLog && a.cfg.Stats.RateLimitLog,
|
||||||
HTTPReq: server.HTTPReq{
|
HTTPReq: server.HTTPReq{
|
||||||
Enabled: a.cfg.ACME.HTTPReq.Enabled,
|
Enabled: a.cfg.ACME.HTTPReq.Enabled,
|
||||||
AllowPlainHTTP: !a.cfg.ACME.HTTPReq.RequireHTTPS,
|
AllowPlainHTTP: !a.cfg.ACME.HTTPReq.RequireHTTPS,
|
||||||
|
|||||||
+27
-1
@@ -342,6 +342,22 @@ registrar_error`.
|
|||||||
|
|
||||||
### Records
|
### Records
|
||||||
|
|
||||||
|
In a linked zone ([views.md](views.md#linked-zones)), records carry
|
||||||
|
`shared_id` and `linked` (`shared` or `local`). Creating, updating
|
||||||
|
(`PATCH /records/{id}`) and setting (`PUT .../rrsets/...`) take `"local":
|
||||||
|
true` (this view only, an override) or `false` (shared; on an update or a
|
||||||
|
set it makes this view's version every view's). Zones carry `link_group`
|
||||||
|
and `linked_with` (the other views).
|
||||||
|
|
||||||
|
| Method | Path | Body |
|
||||||
|
|--------|------|------|
|
||||||
|
| POST | `/zones/{zone}/link` | `{"with": ["external"]}` (default: every view with that zone). Names and types that are the same everywhere become shared, the rest stay local |
|
||||||
|
| POST | `/zones/{zone}/unlink` | Takes the zone out of its link; its records stay, all local |
|
||||||
|
| GET | `/zones/{zone}/compare` | The zone across the views that have it (`?views=a,b`; `?all=1` also the same rows): `views`, `linked`, `same` (count) and `rows` (name, type, `same`, per view `records`, `ids`, `linked`). The zone may be named without a view |
|
||||||
|
|
||||||
|
`POST /zones` takes `"link_with": "VIEW"` to create a zone linked with
|
||||||
|
that view's zone of the same name.
|
||||||
|
|
||||||
| Method | Path | Body / query |
|
| Method | Path | Body / query |
|
||||||
|--------|------|--------------|
|
|--------|------|--------------|
|
||||||
| GET | `/zones/{zone}/records` | `?name=`, `?type=`, `?q=`, `?disabled=1` (only disabled records) or `0` (only enabled ones) |
|
| GET | `/zones/{zone}/records` | `?name=`, `?type=`, `?q=`, `?disabled=1` (only disabled records) or `0` (only enabled ones) |
|
||||||
@@ -454,7 +470,8 @@ listing with `?changeset=` marks them `pending`.
|
|||||||
|
|
||||||
| Method | Path | Body / query |
|
| Method | Path | Body / query |
|
||||||
|--------|------|--------------|
|
|--------|------|--------------|
|
||||||
| GET | `/options` | `?scope=options\|view\|zone\|toplevel&scope_id=ID` (none: everything you may see) |
|
| GET | `/options` | `?scope=options\|view\|zone\|toplevel&scope_id=ID` (none: everything you may see). `formatted` lays out a block over several lines, a list of three or more one element per line |
|
||||||
|
| GET | `/options/generated` | What Jarl adds to named.conf by itself: `where`, `text`, `why`, `setting` (what turns it on or off) |
|
||||||
| POST | `/options` | `{"scope": "zone", "scope_id": 11, "name": "allow-transfer", "value": "{ key xfer; }", "comment": "", "position": 0}` |
|
| POST | `/options` | `{"scope": "zone", "scope_id": 11, "name": "allow-transfer", "value": "{ key xfer; }", "comment": "", "position": 0}` |
|
||||||
| GET / PATCH / DELETE | `/options/{id}` | |
|
| GET / PATCH / DELETE | `/options/{id}` | |
|
||||||
| GET / PATCH | `/settings` | `{"nameservers": [...], "rname": "...", "default_ttl": 3600, "refresh": ..., "retry": ..., "expire": ..., "negative_ttl": ..., "serial_policy": "date"}` |
|
| GET / PATCH | `/settings` | `{"nameservers": [...], "rname": "...", "default_ttl": 3600, "refresh": ..., "retry": ..., "expire": ..., "negative_ttl": ..., "serial_policy": "date"}` |
|
||||||
@@ -524,6 +541,10 @@ in it and its records.
|
|||||||
|
|
||||||
### Query statistics
|
### Query statistics
|
||||||
|
|
||||||
|
Every statistics endpoint below takes `views=internal,vps` to limit the
|
||||||
|
figures to those views ([stats.md](stats.md#views)); a breakdown that only
|
||||||
|
exists for the whole server then answers `400 not_per_view`.
|
||||||
|
|
||||||
Needs `stats:read`. When statistics are disabled, every endpoint but
|
Needs `stats:read`. When statistics are disabled, every endpoint but
|
||||||
`status` answers 404 `stats_disabled`. See [stats.md](stats.md).
|
`status` answers 404 `stats_disabled`. See [stats.md](stats.md).
|
||||||
|
|
||||||
@@ -541,6 +562,11 @@ Needs `stats:read`. When statistics are disabled, every endpoint but
|
|||||||
curl -sf -H "$AUTH" "$API/stats/top?kind=nxdomain&since=7d&limit=10" | jq -r '.[] | "\(.count)\t\(.key)"'
|
curl -sf -H "$AUTH" "$API/stats/top?kind=nxdomain&since=7d&limit=10" | jq -r '.[] | "\(.count)\t\(.key)"'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Encrypted DNS
|
||||||
|
|
||||||
|
See [encrypted-dns.md](encrypted-dns.md#api): `GET`, `POST` and `DELETE
|
||||||
|
/encrypted-dns`, `POST /encrypted-dns/check`.
|
||||||
|
|
||||||
### Certificates
|
### Certificates
|
||||||
|
|
||||||
Administrators only. See [certificates.md](certificates.md#api).
|
Administrators only. See [certificates.md](certificates.md#api).
|
||||||
|
|||||||
@@ -183,16 +183,20 @@ configuration names them, Jarl runs `rndc reconfig`, and named serves the
|
|||||||
new certificate at once (checked with BIND 9.20: DoT keeps working, with
|
new certificate at once (checked with BIND 9.20: DoT keeps working, with
|
||||||
the new certificate).
|
the new certificate).
|
||||||
|
|
||||||
Until Jarl sets up DNS over TLS and HTTPS for you, add it by hand:
|
`jarl encrypted-dns setup` sets up DNS over TLS and HTTPS with it
|
||||||
|
([encrypted-dns.md](encrypted-dns.md)). By hand, a `tls` statement names
|
||||||
|
the files:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
jarl option set tls 'dot { cert-file "/etc/bind/jarl/tls/ns1/fullchain.pem"; key-file "/etc/bind/jarl/tls/ns1/privkey.pem"; }' --toplevel
|
jarl option set tls 'dot { cert-file "/etc/bind/jarl/tls/ns1/fullchain.pem"; key-file "/etc/bind/jarl/tls/ns1/privkey.pem"; }' --toplevel
|
||||||
jarl option set listen-on 'port 853 tls dot { 10.0.10.5; }' --add # DoT
|
jarl option set listen-on 'port 853 tls dot { 10.0.10.5; }' --add
|
||||||
jarl option set listen-on 'port 443 tls dot http default { 10.0.10.5; }' --add # DoH at /dns-query
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Listen on internal addresses only, unless you mean to run a public
|
A certificate named uses can't be deleted or lose its `dns` use until
|
||||||
resolver ([exposure.md](exposure.md)).
|
named no longer names its files. If you change `certificates.dir`, jarld
|
||||||
|
writes the certificates to the new place when it starts and points the
|
||||||
|
`tls` statements encrypted DNS wrote there (a hand-written `tls`
|
||||||
|
statement you change yourself).
|
||||||
|
|
||||||
## A certificate you renew yourself
|
## A certificate you renew yourself
|
||||||
|
|
||||||
|
|||||||
+33
-1
@@ -211,6 +211,19 @@ router, answers for the rest of the /24. IPv4 networks smaller than a /24
|
|||||||
are deleted. Try it with `--dry-run` first. `--option "NAME VALUE"` on
|
are deleted. Try it with `--dry-run` first. `--option "NAME VALUE"` on
|
||||||
`zone create` sets any zone option in the same step.
|
`zone create` sets any zone option in the same step.
|
||||||
|
|
||||||
|
Linked zones ([views.md](views.md#linked-zones)) keep their shared
|
||||||
|
records in step across views:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl zone link adbraun.lol --view internal --with external
|
||||||
|
jarl zone create adbraun.lol --view vps --link-with external
|
||||||
|
jarl zone unlink adbraun.lol --view vps
|
||||||
|
jarl record add adbraun.lol nas A 10.0.10.20 --view internal --local # this view only
|
||||||
|
jarl record set adbraun.lol www A 10.0.10.5 --view internal --local # override the shared www A here
|
||||||
|
jarl record update 1234 --local | --shared
|
||||||
|
jarl zone diff adbraun.lol [--view V --view W] [--all] # what differs between the views
|
||||||
|
```
|
||||||
|
|
||||||
When a zone exists in several views, add `--view NAME` to commands that
|
When a zone exists in several views, add `--view NAME` to commands that
|
||||||
name it.
|
name it.
|
||||||
|
|
||||||
@@ -295,7 +308,9 @@ adds the quotes and splits long values into 255-byte strings. In reverse zones t
|
|||||||
host name (a bare `nas` is refused, since it would mean
|
host name (a bare `nas` is refused, since it would mean
|
||||||
`nas.0.10.10.in-addr.arpa.`). `--ptr`
|
`nas.0.10.10.in-addr.arpa.`). `--ptr`
|
||||||
keeps the PTR record in the reverse zone in step (when that zone is managed
|
keeps the PTR record in the reverse zone in step (when that zone is managed
|
||||||
by Jarl). Every record type BIND supports can be used. Jarl knows the
|
by Jarl); `jarl record update ID --ptr` with nothing else adds the PTR record
|
||||||
|
if it is missing, e.g. after creating the reverse zone. In linked zones, the
|
||||||
|
reverse zones of every linked view count. Every record type BIND supports can be used. Jarl knows the
|
||||||
syntax of the common ones and checks them strictly, and the rest are
|
syntax of the common ones and checks them strictly, and the rest are
|
||||||
validated by parsing.
|
validated by parsing.
|
||||||
|
|
||||||
@@ -390,6 +405,7 @@ jarl exposure # public views, findings,
|
|||||||
jarl exposure fix external exposure_rate_limit # rate limiting, log-only first
|
jarl exposure fix external exposure_rate_limit # rate limiting, log-only first
|
||||||
jarl exposure fix external exposure_rate_limit_log_only # then start enforcing
|
jarl exposure fix external exposure_rate_limit_log_only # then start enforcing
|
||||||
jarl exposure fix server exposure_version
|
jarl exposure fix server exposure_version
|
||||||
|
jarl exposure mail # reverse DNS of the addresses that send and receive mail
|
||||||
jarl exposure ack external security_open_resolver --reason "public resolver, monitored"
|
jarl exposure ack external security_open_resolver --reason "public resolver, monitored"
|
||||||
jarl exposure unack external security_open_resolver
|
jarl exposure unack external security_open_resolver
|
||||||
jarl view update external --exposure public|private|auto
|
jarl view update external --exposure public|private|auto
|
||||||
@@ -397,6 +413,18 @@ jarl view update external --exposure public|private|auto
|
|||||||
|
|
||||||
See [exposure.md](exposure.md).
|
See [exposure.md](exposure.md).
|
||||||
|
|
||||||
|
## Encrypted DNS
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl encrypted-dns # what is set up, what to give clients
|
||||||
|
jarl encrypted-dns setup --hostname dns.adbraun.lol --address 10.0.10.5 [--address IPv6] [--doh-port 8443] [--no-doh]
|
||||||
|
jarl encrypted-dns check --address 10.0.10.5 --doh-port 443
|
||||||
|
jarl encrypted-dns health # check the listeners now, as a client would
|
||||||
|
jarl encrypted-dns disable
|
||||||
|
```
|
||||||
|
|
||||||
|
See [encrypted-dns.md](encrypted-dns.md).
|
||||||
|
|
||||||
## Certificates
|
## Certificates
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -449,6 +477,7 @@ jarl option set logging '{ channel q { file "/var/log/named/query.log" versions
|
|||||||
jarl option unset forwarders
|
jarl option unset forwarders
|
||||||
jarl option describe # every option allowed at this scope
|
jarl option describe # every option allowed at this scope
|
||||||
jarl option describe allow-query-cache # grammar and ARM description
|
jarl option describe allow-query-cache # grammar and ARM description
|
||||||
|
jarl option generated # what Jarl adds to named.conf by itself, and why
|
||||||
```
|
```
|
||||||
|
|
||||||
Options that end in a list (`allow-*`, `forwarders`, `listen-on`, ...) may
|
Options that end in a list (`allow-*`, `forwarders`, `listen-on`, ...) may
|
||||||
@@ -505,6 +534,9 @@ working.
|
|||||||
|
|
||||||
## Query statistics
|
## Query statistics
|
||||||
|
|
||||||
|
`--view internal,vps` (or repeated) limits any `jarl stats` command to those
|
||||||
|
views ([stats.md](stats.md#views)).
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
jarl stats status
|
jarl stats status
|
||||||
jarl stats summary --since 7d
|
jarl stats summary --since 7d
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ overwritten. Jarl records a checksum of every file it writes and reports
|
|||||||
**drift** when a file changes behind its back. It then refuses to overwrite
|
**drift** when a file changes behind its back. It then refuses to overwrite
|
||||||
the file until you say so (`--force`, or `jarl resync --force`).
|
the file until you say so (`--force`, or `jarl resync --force`).
|
||||||
|
|
||||||
|
Some of named.conf comes from Jarl itself rather than from your options:
|
||||||
|
the include of keys.conf, the statistics channel and the logging
|
||||||
|
statistics need, blocking's `response-policy` and policy zones, and the
|
||||||
|
view keys at the front of each view's `match-clients`. The Server options
|
||||||
|
page lists them under **Added by Jarl**, with why each is there and what
|
||||||
|
turns it on or off (`jarl option generated`). Where you have a statement
|
||||||
|
of your own (`logging`, `statistics-channels`), Jarl merges into it.
|
||||||
|
|
||||||
Comments are part of the data model: zones, records, ACLs, views, keys and
|
Comments are part of the data model: zones, records, ACLs, views, keys and
|
||||||
options can all carry a comment, and Jarl writes it into the generated file.
|
options can all carry a comment, and Jarl writes it into the generated file.
|
||||||
Comments in imported zone files are kept.
|
Comments in imported zone files are kept.
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# DNS over TLS and HTTPS
|
||||||
|
|
||||||
|
named (BIND 9.20) can answer over encrypted connections itself: DNS over
|
||||||
|
TLS (DoT, port 853) and DNS over HTTPS (DoH, at `/dns-query`). Your
|
||||||
|
phones, laptops and browsers then ask this server without anyone on the
|
||||||
|
network seeing or changing their queries, and they get exactly what plain
|
||||||
|
DNS gives them: the same views, the same records and the same blocking.
|
||||||
|
|
||||||
|
Jarl sets it up in one step: a certificate for the host name clients
|
||||||
|
connect to (obtained and renewed by Jarl, [certificates.md](certificates.md)),
|
||||||
|
named's `tls` statement and listeners on the addresses you choose, and the
|
||||||
|
host name's address records in your private views.
|
||||||
|
|
||||||
|
In the web UI: **Encrypted DNS** (administrators). In the CLI: `jarl
|
||||||
|
encrypted-dns`. In the API: `/api/v1/encrypted-dns`.
|
||||||
|
|
||||||
|
## Setting it up
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl encrypted-dns setup --hostname dns.adbraun.lol --address 10.0.10.5 --address 2a02:8013:7f3d:10::5
|
||||||
|
```
|
||||||
|
|
||||||
|
This:
|
||||||
|
|
||||||
|
1. **finds or obtains the certificate**: one Jarl manages whose names cover
|
||||||
|
the host name (`--certificate NAME` to pick one), or a new one named
|
||||||
|
after the host name's first label, which Jarl obtains from the CA (the
|
||||||
|
command waits). The certificate gets the `dns` use, so it is written
|
||||||
|
where named can read it, and named rereads it on every renewal.
|
||||||
|
2. **checks the ports**: nothing else may listen on the chosen addresses
|
||||||
|
on port 853 or the DoH port. Jarl looks at the host's listening sockets
|
||||||
|
(it binds nothing) and says what is in the way, e.g. `jarld (Jarl's web
|
||||||
|
UI and API) listens on 10.0.10.5 port 443`, and which port is free
|
||||||
|
instead (`--doh-port 8443`).
|
||||||
|
3. **stages named's configuration**, as one change you can review:
|
||||||
|
|
||||||
|
```
|
||||||
|
tls jarl-dns { cert-file ".../tls/dns/fullchain.pem"; key-file ".../tls/dns/privkey.pem"; protocols { TLSv1.2; TLSv1.3; }; };
|
||||||
|
options {
|
||||||
|
listen-on port 853 tls jarl-dns { 10.0.10.5; };
|
||||||
|
listen-on port 443 tls jarl-dns http default { 10.0.10.5; };
|
||||||
|
listen-on-v6 port 853 tls jarl-dns { 2a02:8013:7f3d:10::5; };
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
named listens on port 53 on every address only while there is no
|
||||||
|
`listen-on` statement at all. The first one, for DoT, would end that, so
|
||||||
|
when you have none the setup adds `listen-on { any; };` (and the same
|
||||||
|
for IPv6) to keep plain DNS where it was.
|
||||||
|
4. **adds the host name's A and AAAA records** with the chosen addresses,
|
||||||
|
in the private views that have a zone for it (`--view` to choose). In a
|
||||||
|
linked zone they are local to the view, so the name doesn't reach your
|
||||||
|
external view.
|
||||||
|
|
||||||
|
Running it again replaces the earlier setup (other addresses, ports, DoT
|
||||||
|
or DoH only with `--no-doh` / `--no-dot`). `jarl encrypted-dns disable`
|
||||||
|
takes the listeners and the `tls` statement out; the certificate and the
|
||||||
|
records stay.
|
||||||
|
|
||||||
|
| Option | Default | |
|
||||||
|
|--|--|--|
|
||||||
|
| `--hostname` | | The name clients connect to; the certificate is for it |
|
||||||
|
| `--address` | | This server's addresses to listen on (repeat). Choose your own networks' addresses: a listener on a public address answers anyone who reaches it, like port 53 there |
|
||||||
|
| `--certificate` | one for the host name, obtained if needed | A certificate Jarl manages |
|
||||||
|
| `--no-dot`, `--no-doh` | both on | |
|
||||||
|
| `--dot-port` | 853 | Clients expect 853 |
|
||||||
|
| `--doh-port` | 443 | Any free port; clients take it in the URL |
|
||||||
|
| `--view` | private views with the zone | Where the host name's records go |
|
||||||
|
|
||||||
|
In the web UI the same form checks the ports as you choose, offers the
|
||||||
|
free port, and obtains the certificate before setting up.
|
||||||
|
|
||||||
|
## Which view clients get
|
||||||
|
|
||||||
|
A DoT or DoH client gets the view its address matches, as over plain DNS:
|
||||||
|
your phone at home gets the internal view, with its records and blocking.
|
||||||
|
Choosing a view by key (`match-clients { key ...; }`) doesn't work over
|
||||||
|
DoT and DoH, as clients don't sign their queries.
|
||||||
|
|
||||||
|
## Clients
|
||||||
|
|
||||||
|
`jarl encrypted-dns` (and the page) shows what to give them:
|
||||||
|
|
||||||
|
```
|
||||||
|
For DoT clients: dns.adbraun.lol
|
||||||
|
For DoH clients: https://dns.adbraun.lol/dns-query
|
||||||
|
```
|
||||||
|
|
||||||
|
| Client | Where |
|
||||||
|
|--|--|
|
||||||
|
| Android 9+ | Settings → Network → Private DNS → Private DNS provider hostname: `dns.adbraun.lol` (DoT) |
|
||||||
|
| Linux (systemd-resolved) | `/etc/systemd/resolved.conf`: `DNS=10.0.10.5#dns.adbraun.lol` and `DNSOverTLS=yes` (or `opportunistic`, which falls back to plain DNS) |
|
||||||
|
| Firefox | Settings → Privacy & Security → DNS over HTTPS → Increased protection → Custom: the DoH URL |
|
||||||
|
| Chrome, Edge | Settings → Privacy and security → Security → Use secure DNS → custom: the DoH URL |
|
||||||
|
| Windows 11 | Settings → Network → your connection → DNS server assignment → Edit: the server's address, DNS over HTTPS on (manual template), the DoH URL |
|
||||||
|
|
||||||
|
Test from a machine on your network:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kdig +tls-ca +tls-host=dns.adbraun.lol @10.0.10.5 example.com
|
||||||
|
dig +https @10.0.10.5 example.com
|
||||||
|
curl -s -H 'accept: application/dns-json' 'https://dns.adbraun.lol/dns-query?name=example.com'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Android away from home.** Private DNS in hostname mode applies on every
|
||||||
|
network. Away from home, your server can't be reached, and Android then
|
||||||
|
resolves nothing at all ("Private DNS server cannot be accessed"). Use a
|
||||||
|
VPN home (WireGuard) when you're out, or switch Private DNS to
|
||||||
|
*Automatic* when you leave. Firefox's *Increased protection* and
|
||||||
|
systemd-resolved's `opportunistic` fall back to normal DNS by themselves.
|
||||||
|
|
||||||
|
Clients check the certificate against the host name, so the name must
|
||||||
|
resolve to this server for them: the setup adds it to your private views.
|
||||||
|
Open the ports in the server's firewall for your own networks only.
|
||||||
|
|
||||||
|
## Health
|
||||||
|
|
||||||
|
jarld checks the listeners itself, a minute after it starts and then
|
||||||
|
every 10 minutes, the way a client would: it connects to each, checks the
|
||||||
|
certificate named serves (for the host name, current, and the one Jarl
|
||||||
|
last wrote, so a renewal named hasn't reread shows up) and asks for the
|
||||||
|
host name. The Encrypted DNS page shows the result per listener (**Check
|
||||||
|
now** checks again), the dashboard a card and the problems, Prometheus
|
||||||
|
`jarl_encrypted_dns_up{kind,address,port}`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl encrypted-dns health # check now; exit 1 when a listener doesn't work
|
||||||
|
```
|
||||||
|
|
||||||
|
What it can say:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| nothing listens on ADDR port P | named couldn't bind the port: look in named's log (ports below 1024 need named to start as root, as the Debian service does) |
|
||||||
|
| no answer within 5s | a firewall on the server |
|
||||||
|
| a certificate for X, not HOST | named serves another certificate |
|
||||||
|
| an older certificate | named hasn't reread the renewed files (`rndc reconfig`) |
|
||||||
|
| not from a CA this server trusts (warning) | a staging or test CA: clients reject it unless they trust it |
|
||||||
|
| REFUSED (warning) | it works, but this server's own address gets a view that doesn't answer for the host name |
|
||||||
|
|
||||||
|
## What Jarl checks
|
||||||
|
|
||||||
|
* A `tls` statement whose files don't exist is an error
|
||||||
|
(`tls_file_missing`): named won't load its configuration without them.
|
||||||
|
* A certificate named uses can't be deleted, nor lose its `dns` use: turn
|
||||||
|
encrypted DNS off first.
|
||||||
|
* Listeners you wrote yourself are left alone; the page notes them.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Method | Path | Body |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/encrypted-dns` | `enabled`, `certificate`, `hostname`, `dot` and `doh` (address and port), `dot_host`, `doh_url`, `listeners`; for the form: `addresses` (this server's, with `private`), `views` (with `public`, and `has_zone` for `?hostname=`), `certificates` |
|
||||||
|
| POST | `/encrypted-dns/check` | `{"addresses", "dot", "doh", "dot_port", "doh_port"}`: `conflicts` (address, port, what) and `free_doh_port` |
|
||||||
|
| POST | `/encrypted-dns` | `{"hostname", "certificate", "addresses", "dot", "doh", "dot_port", "doh_port", "views"}`: staged or applied like any change; 409 while the certificate hasn't been obtained |
|
||||||
|
| DELETE | `/encrypted-dns` | Turns it off |
|
||||||
|
| POST | `/encrypted-dns/health` | Checks the listeners now: `checked`, `probes` (kind, address, port, `ok`, `problem`, `warning`, certificate names, expiry and serial, `trusted`, `rcode`, `ms`). `GET /encrypted-dns` has the last check as `health` |
|
||||||
+36
-2
@@ -107,7 +107,10 @@ is only in its log (category `query-errors`). That is why the activity
|
|||||||
needs query statistics; without them, look at named's log for
|
needs query statistics; without them, look at named's log for
|
||||||
`would rate limit`. When rate limiting is configured and statistics are
|
`would rate limit`. When rate limiting is configured and statistics are
|
||||||
on, Jarl adds `query-errors` to its log channel (keeping named's syslog
|
on, Jarl adds `query-errors` to its log channel (keeping named's syslog
|
||||||
destination for it).
|
destination for it). `stats.rate_limit_log: false` in jarl.yaml leaves
|
||||||
|
`query-errors` alone; the page then can't show what rate limiting does.
|
||||||
|
The Server options page lists this, and everything else Jarl adds to
|
||||||
|
named.conf by itself, under **Added by Jarl** (`jarl option generated`).
|
||||||
|
|
||||||
The statistics also count it: the `rrl` breakdown (`drop`, `slip`,
|
The statistics also count it: the `rrl` breakdown (`drop`, `slip`,
|
||||||
`would-drop`, `would-slip`), the `ratelimited` and `ratelimited_name` top
|
`would-drop`, `would-slip`), the `ratelimited` and `ratelimited_name` top
|
||||||
@@ -160,6 +163,36 @@ external`). Set in the server options, it applies to every view that has
|
|||||||
none of its own, your internal resolver included, which RRL isn't meant
|
none of its own, your internal resolver included, which RRL isn't meant
|
||||||
for.
|
for.
|
||||||
|
|
||||||
|
## Mail
|
||||||
|
|
||||||
|
Receivers such as Gmail and Outlook check the address mail comes from: it
|
||||||
|
needs a PTR record (reverse DNS), and the name it gives must resolve back
|
||||||
|
to the address. Without that, mail is rejected or marked as spam. The PTR
|
||||||
|
records of your public addresses live in the reverse zones of whoever
|
||||||
|
owns the addresses, your hosting provider or ISP: you set them in their
|
||||||
|
panel, not in Jarl. A reverse zone you create in Jarl only answers your
|
||||||
|
own clients (unless the owner delegated it to you).
|
||||||
|
|
||||||
|
So Jarl checks them as the internet sees them, through the public
|
||||||
|
resolvers of `acme.resolvers`: the addresses of every public zone's MX
|
||||||
|
hosts (receiving) and SPF `ip4:`, `ip6:`, `a` and `a:` addresses
|
||||||
|
(sending). The Exposure page's **Mail reverse DNS** panel shows each
|
||||||
|
address, its PTR, who runs its reverse zone (the provider, from the
|
||||||
|
zone's name server) and what to do; jarld checks a minute after it starts
|
||||||
|
and every 6 hours, and logs an address that goes wrong.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ jarl exposure mail
|
||||||
|
ZONE ADDRESS FOR PTR STATUS
|
||||||
|
adbraun.lol 172.235.168.106 SPF ip4, MX mail.adbraun.lol mail.adbraun.lol ok
|
||||||
|
```
|
||||||
|
|
||||||
|
It warns when an address has no PTR record, or when its name doesn't
|
||||||
|
resolve back to it; notes when the name is an alias (CNAME), or isn't the
|
||||||
|
MX name (fine if the mail server uses it in HELO). `jarl exposure mail`
|
||||||
|
exits 1 when an address isn't right; Prometheus has
|
||||||
|
`jarl_mail_reverse_dns_ok{zone,address}`.
|
||||||
|
|
||||||
## Confirming a change that opens a view up
|
## Confirming a change that opens a view up
|
||||||
|
|
||||||
A change that *introduces* an open resolver, open zone transfers, cache
|
A change that *introduces* an open resolver, open zone transfers, cache
|
||||||
@@ -204,7 +237,8 @@ monitoring, response size limits and usually a machine of its own.
|
|||||||
|
|
||||||
| Method | Path | Body |
|
| Method | Path | Body |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| GET | `/exposure` | `scopes` (per view, or the server: `public`, `override`, `reason`, `rate_limit` off/log-only/enforcing, `findings` with `fix` where Jarl has one), `server` (server-wide findings), `rate_limiting` (last 24h, with statistics: `counts`, `networks`, `names`) |
|
| GET | `/exposure` | `scopes` (per view, or the server: `public`, `override`, `reason`, `rate_limit` off/log-only/enforcing, `findings` with `fix` where Jarl has one), `server` (server-wide findings), `rate_limiting` (last 24h, with statistics: `counts`, `networks`, `names`), `rate_limit_log`, `mail` (the last mail check) |
|
||||||
|
| POST | `/exposure/mail` | Checks the mail addresses' reverse DNS now: `checked`, `results` (`zone`, `address`, `roles`, `ptr`, `ok`, `level` ok/info/warning, `message`, `reverse_zone`, `managed_by`, `local_zone`), `errors` |
|
||||||
| POST | `/exposure/fix` | `{"view": "external", "code": "exposure_rate_limit"}` (`view_id` works too; no view: the server). Staged with `?changeset=` like any change |
|
| POST | `/exposure/fix` | `{"view": "external", "code": "exposure_rate_limit"}` (`view_id` works too; no view: the server). Staged with `?changeset=` like any change |
|
||||||
| POST | `/exposure/ack` | `{"view": "external", "code": "security_open_resolver", "reason": "..."}` |
|
| POST | `/exposure/ack` | `{"view": "external", "code": "security_open_resolver", "reason": "..."}` |
|
||||||
| POST | `/exposure/unack` | `{"view": "external", "code": "security_open_resolver"}` |
|
| POST | `/exposure/unack` | `{"view": "external", "code": "security_open_resolver"}` |
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ same values.
|
|||||||
| `jarl_certificate_expiry_timestamp_seconds{certificate,source}` | When each certificate Jarl manages expires ([certificates.md](certificates.md)). |
|
| `jarl_certificate_expiry_timestamp_seconds{certificate,source}` | When each certificate Jarl manages expires ([certificates.md](certificates.md)). |
|
||||||
| `jarl_certificate_failing{certificate,source}` | 1 when obtaining (or reading) the certificate failed the last time, or it has expired. |
|
| `jarl_certificate_failing{certificate,source}` | 1 when obtaining (or reading) the certificate failed the last time, or it has expired. |
|
||||||
| `jarl_certificate_renewal_failures{certificate}` | Failed attempts to obtain a certificate since its last success. |
|
| `jarl_certificate_renewal_failures{certificate}` | Failed attempts to obtain a certificate since its last success. |
|
||||||
|
| `jarl_mail_reverse_dns_ok{zone,address}` | 1 when an address that sends or receives a public zone's mail has a PTR record that resolves back to it, at the last check, every 6 hours ([exposure.md](exposure.md#mail)). |
|
||||||
|
| `jarl_encrypted_dns_up{kind,address,port}` | 1 when the DoT or DoH listener answered with the right, current certificate at the last check ([encrypted-dns.md](encrypted-dns.md#health)). |
|
||||||
|
|
||||||
### Queries (with `stats.enabled`)
|
### Queries (with `stats.enabled`)
|
||||||
|
|
||||||
@@ -232,6 +234,15 @@ groups:
|
|||||||
expr: jarl_certificate_expiry_timestamp_seconds - time() < 10 * 86400
|
expr: jarl_certificate_expiry_timestamp_seconds - time() < 10 * 86400
|
||||||
annotations:
|
annotations:
|
||||||
summary: "Certificate {{ $labels.certificate }} expires within 10 days"
|
summary: "Certificate {{ $labels.certificate }} expires within 10 days"
|
||||||
|
- alert: EncryptedDNSDown
|
||||||
|
expr: jarl_encrypted_dns_up == 0
|
||||||
|
for: 20m
|
||||||
|
annotations:
|
||||||
|
summary: "DNS over {{ $labels.kind }} on {{ $labels.address }} port {{ $labels.port }} isn't working (see the Encrypted DNS page)"
|
||||||
|
- alert: MailReverseDNS
|
||||||
|
expr: jarl_mail_reverse_dns_ok == 0
|
||||||
|
annotations:
|
||||||
|
summary: "{{ $labels.address }} ({{ $labels.zone }} mail) has no reverse DNS that resolves back: receivers may reject its mail"
|
||||||
- alert: BlockingPausedForLong
|
- alert: BlockingPausedForLong
|
||||||
expr: jarl_blocking_paused == 1
|
expr: jarl_blocking_paused == 1
|
||||||
for: 24h
|
for: 24h
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ off again. The statistics database stays until you delete it
|
|||||||
stats:
|
stats:
|
||||||
enabled: false
|
enabled: false
|
||||||
query_log: true # per-query data; false = named's counters only
|
query_log: true # per-query data; false = named's counters only
|
||||||
|
rate_limit_log: true # with a rate-limit: also log what it limits (query-errors)
|
||||||
database: /var/lib/jarl/stats.db
|
database: /var/lib/jarl/stats.db
|
||||||
statistics_channel: 127.0.0.1:8058 # must be a loopback address
|
statistics_channel: 127.0.0.1:8058 # must be a loopback address
|
||||||
log_file: /var/log/named/jarl-responses.log
|
log_file: /var/log/named/jarl-responses.log
|
||||||
@@ -81,6 +82,13 @@ the response log. Logging every answer costs named some CPU and disk I/O,
|
|||||||
which is negligible at homelab volumes. At tens of thousands of queries per
|
which is negligible at homelab volumes. At tens of thousands of queries per
|
||||||
second, consider counters only.
|
second, consider counters only.
|
||||||
|
|
||||||
|
`rate_limit_log` matters only with a `rate-limit` set: Jarl then sends
|
||||||
|
named's `query-errors` category (where rate limiting logs what it limits,
|
||||||
|
or would in log-only mode) to the response log too, and keeps it in
|
||||||
|
syslog. That other query errors come along is the price; `false` stops it,
|
||||||
|
and the Exposure page can't show rate limiting's activity then
|
||||||
|
([exposure.md](exposure.md#response-rate-limiting)).
|
||||||
|
|
||||||
`exclude_clients` drops those clients from the log-derived figures (top
|
`exclude_clients` drops those clients from the log-derived figures (top
|
||||||
lists, views, zones). named's own counters can't be filtered.
|
lists, views, zones). named's own counters can't be filtered.
|
||||||
|
|
||||||
@@ -184,6 +192,26 @@ jarl stats summary --since 30d -o json # export (also -o yaml)
|
|||||||
`operator` roles include it, `dns-editor` doesn't. Statistics show client
|
`operator` roles include it, `dns-editor` doesn't. Statistics show client
|
||||||
addresses and what they look up, so grant it with care.
|
addresses and what they look up, so grant it with care.
|
||||||
|
|
||||||
|
## Views
|
||||||
|
|
||||||
|
With views, the Statistics page has a toggle per view (and **All** and
|
||||||
|
**None**): switch some off to see only the others, for example your
|
||||||
|
internal and VPS views without the internet's noise on the external one,
|
||||||
|
or several linked views together. The choice is remembered in your
|
||||||
|
browser. The CLI takes `--view` on every `jarl stats` command (repeat it,
|
||||||
|
or separate views with commas), and the API `?views=internal,vps`.
|
||||||
|
|
||||||
|
Only what named's response log says can be split by view: answers (shown
|
||||||
|
as *answers* instead of queries), result codes, record types, zones,
|
||||||
|
blocking, rate limiting and the top lists. named's own counters (answer
|
||||||
|
sources, transport, upstream response times, DNSSEC, the cache and the
|
||||||
|
server) are for the whole server, and the page says so; asking for them
|
||||||
|
per view (`jarl stats breakdown answer --view internal`) is refused.
|
||||||
|
|
||||||
|
Per-view figures are kept from the version that added them (Jarl 0.4.1);
|
||||||
|
the page says since when. They take some more room in the statistics
|
||||||
|
database, roughly in proportion to the number of views.
|
||||||
|
|
||||||
## How data is kept
|
## How data is kept
|
||||||
|
|
||||||
- Minute counters are kept for `retention.minute`, hourly counters and top
|
- Minute counters are kept for `retention.minute`, hourly counters and top
|
||||||
|
|||||||
@@ -104,6 +104,26 @@ applied.
|
|||||||
| `dnssec_old_keys` | info | Signing a zone whose key directory still has old keys |
|
| `dnssec_old_keys` | info | Signing a zone whose key directory still has old keys |
|
||||||
| `serial_not_higher` | error | An explicit serial on a signed zone must be above the one named has loaded (automatic serials are raised by themselves) |
|
| `serial_not_higher` | error | An explicit serial on a signed zone must be above the one named has loaded (automatic serials are raised by themselves) |
|
||||||
|
|
||||||
|
## TLS
|
||||||
|
|
||||||
|
| Code | Severity | Check |
|
||||||
|
|------|----------|-------|
|
||||||
|
| `tls_file_missing` | error | The files a top-level `tls` statement names (certificate, key, CA) exist: named doesn't load its configuration without them |
|
||||||
|
|
||||||
|
## Linked zones
|
||||||
|
|
||||||
|
See [views.md](views.md#linked-zones). Jarl keeps linked zones in step
|
||||||
|
itself, so these only show when something got past it (a change staged
|
||||||
|
before the zones were linked, or the database edited by hand).
|
||||||
|
|
||||||
|
| Code | Severity | Check |
|
||||||
|
|------|----------|-------|
|
||||||
|
| `linked_zone_type` | error | Only primary zones can be linked |
|
||||||
|
| `linked_zone_name` | error | Linked zones have the same name |
|
||||||
|
| `linked_mixed` | error | A name and type in a zone is either shared or local, not both |
|
||||||
|
| `linked_out_of_sync` | error | Every view that doesn't override a shared name and type has the same records for it. Link the zones again (`jarl zone link`) to work out what is shared |
|
||||||
|
| `linked_zone_alone` | info | A zone linked with no other zone |
|
||||||
|
|
||||||
## Blocking
|
## Blocking
|
||||||
|
|
||||||
From [blocking.md](blocking.md).
|
From [blocking.md](blocking.md).
|
||||||
|
|||||||
+98
-9
@@ -88,19 +88,27 @@ and [exposure.md](exposure.md) to set up response rate limiting.
|
|||||||
|
|
||||||
### 4. Give the external view its zones
|
### 4. Give the external view its zones
|
||||||
|
|
||||||
**Different data per view (split DNS).** Copy the zone, then change what
|
**Mostly the same, a few differences (split DNS).** Create the external
|
||||||
should differ:
|
copy *linked* ([below](#linked-zones)): Jarl keeps the shared records the
|
||||||
|
same in both views, and each view can have records of its own:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl zone create adbraun.lol --view external --link-with internal
|
||||||
|
jarl record set adbraun.lol www A 203.0.113.20 --view external --local # public address, external only
|
||||||
|
jarl record delete adbraun.lol nas-internal A --view external # gone from both: it was shared
|
||||||
|
```
|
||||||
|
|
||||||
|
Delete records you don't want outside before you link, or make them local
|
||||||
|
to the internal view first (`jarl record update ID --local`): a shared
|
||||||
|
record is in both views.
|
||||||
|
|
||||||
|
**Two separate copies.** `--clone-from` copies the zone once; from then on
|
||||||
|
the two are separate zones, and a record added to one isn't in the other:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
jarl zone create adbraun.lol --view external --clone-from adbraun.lol --clone-view internal
|
jarl zone create adbraun.lol --view external --clone-from adbraun.lol --clone-view internal
|
||||||
jarl record list adbraun.lol --view external
|
|
||||||
jarl record update <id> --data 203.0.113.20 # public address instead of 10.10.0.20
|
|
||||||
jarl record delete adbraun.lol nas-internal A --view external
|
|
||||||
```
|
```
|
||||||
|
|
||||||
From then on the two copies are separate zones. A record added to one isn't
|
|
||||||
in the other, and the serials are separate.
|
|
||||||
|
|
||||||
**The same data in both views.** Use an `in-view` zone. The external view
|
**The same data in both views.** Use an `in-view` zone. The external view
|
||||||
then serves the internal view's zone, so there is one copy to maintain:
|
then serves the internal view's zone, so there is one copy to maintain:
|
||||||
|
|
||||||
@@ -160,6 +168,84 @@ shows one (it needs `tsig:write` and is audited). Key names starting with
|
|||||||
`jarl-view-` are reserved, and the view keys can be used in your own
|
`jarl-view-` are reserved, and the view keys can be used in your own
|
||||||
options (`allow-transfer { key jarl-view-external; }`).
|
options (`allow-transfer { key jarl-view-external; }`).
|
||||||
|
|
||||||
|
## Linked zones
|
||||||
|
|
||||||
|
Most split-horizon zones are the same in every view apart from a few
|
||||||
|
names. Linking zones of the same name in different views lets Jarl keep
|
||||||
|
the shared records in step, while each view can still have records of its
|
||||||
|
own and its own zone options.
|
||||||
|
|
||||||
|
* **Shared records** are in every linked view. Adding, changing,
|
||||||
|
disabling or deleting one in any view does the same in the others, in
|
||||||
|
the same changeset: the diff shows every view, and history, undo and
|
||||||
|
`jarl check` treat them together.
|
||||||
|
* **Local records** are in one view only (`--local`, or **Only here** in
|
||||||
|
the web UI). A local record overrides the shared records of the same name
|
||||||
|
and type in that view: `www A 10.0.10.5` local to internal, while the
|
||||||
|
external view keeps the shared `www A 203.0.113.20`. Delete the local
|
||||||
|
records and the view gets the shared ones back.
|
||||||
|
* A new record joins the set of the same name and type: local if that set
|
||||||
|
is local in its view, shared otherwise.
|
||||||
|
* **Sharing a version** (`jarl record update ID --shared`, **Share** in the
|
||||||
|
web UI) makes that view's records of that name and type the shared
|
||||||
|
version: the other views' records of that name and type are replaced.
|
||||||
|
* Zones stay separate in named, so each keeps its own serial, options
|
||||||
|
(allow-transfer, DNSSEC signing, ...) and file, and the views keep their
|
||||||
|
own `match-clients`, recursion and order.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl zone link adbraun.lol --view internal --with external # link two existing zones
|
||||||
|
jarl zone create adbraun.lol --view vps --link-with external # a new zone, linked from the start
|
||||||
|
jarl record list adbraun.lol --view internal # LINK column: shared or local
|
||||||
|
jarl record add adbraun.lol nas A 10.0.10.20 --view internal --local
|
||||||
|
jarl record update 1234 --local # this name and type: only this view
|
||||||
|
jarl record update 1234 --shared # this view's version, everywhere
|
||||||
|
jarl zone unlink adbraun.lol --view vps # records stay, all local
|
||||||
|
```
|
||||||
|
|
||||||
|
**Linking never changes what named answers.** A name and type with the
|
||||||
|
same records in every view becomes shared; one that differs stays local to
|
||||||
|
each view (Jarl lists them). Share the right version afterwards. Unlinking
|
||||||
|
keeps every record, all local; a link left with one zone is dissolved, as
|
||||||
|
when the other zone is deleted.
|
||||||
|
|
||||||
|
**Comparing the views.** The zone's **Compare views** tab, or `jarl zone
|
||||||
|
diff adbraun.lol`, lines the views up and shows each name and type that
|
||||||
|
differs: a view's own records, or a name one view lacks. **Use this
|
||||||
|
version** (`jarl record update ID --shared`) makes one view's version
|
||||||
|
every view's. It works for zones that aren't linked too, to see what
|
||||||
|
linking them would keep local.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ jarl zone diff adbraun.lol
|
||||||
|
NAME TYPE INTERNAL EXTERNAL
|
||||||
|
nas A 10.0.10.20 [local, id 812] 203.0.113.20 [local, id 790]
|
||||||
|
5 names and types differ, 21 are the same; linked
|
||||||
|
```
|
||||||
|
|
||||||
|
`--local` also works on a zone that isn't linked (its records are local
|
||||||
|
anyway), so scripts don't need to know; `--shared` there is refused.
|
||||||
|
|
||||||
|
**Permissions** are checked in every view a change reaches. A token with
|
||||||
|
a grant for one view only (`views=internal`) can change that view's local
|
||||||
|
records and make overrides, but not a shared record, since that would
|
||||||
|
change the other views too: Jarl says so and suggests a local record. A
|
||||||
|
grant without a view (the usual `zones=adbraun.lol`) covers every view.
|
||||||
|
|
||||||
|
**Importing a zone file** into a linked zone (`jarl zone import`) adds
|
||||||
|
the records like any others: shared, unless the name and type is local
|
||||||
|
there. With `--replace`, shared records missing from the file are deleted
|
||||||
|
in every linked view.
|
||||||
|
|
||||||
|
**ACME challenges** are written once per link, and so reach every linked
|
||||||
|
view; `jarl acme`, lego's httpreq and certificates Jarl obtains all do
|
||||||
|
this.
|
||||||
|
|
||||||
|
**Compared with BIND's `in-view`**, where one view serves another's zone
|
||||||
|
as it is: linked zones can differ per view, keep their own options, and
|
||||||
|
don't depend on the view order (`in-view` can only use a view defined
|
||||||
|
before it).
|
||||||
|
|
||||||
## Living with views
|
## Living with views
|
||||||
|
|
||||||
* Commands that name a zone that exists in several views need `--view`
|
* Commands that name a zone that exists in several views need `--view`
|
||||||
@@ -167,7 +253,10 @@ options (`allow-transfer { key jarl-view-external; }`).
|
|||||||
guess.
|
guess.
|
||||||
* `jarl acme` writes challenge records into **every** view that has the
|
* `jarl acme` writes challenge records into **every** view that has the
|
||||||
zone, so Let's Encrypt (external) and internal clients see the same
|
zone, so Let's Encrypt (external) and internal clients see the same
|
||||||
thing. Use `--view` to limit it.
|
thing (once per link, for linked zones). Use `--view` to limit it.
|
||||||
|
* `--view all` (or a list) on record commands changes each linked zone
|
||||||
|
once: the change reaches the others through the link. With `--local` it
|
||||||
|
goes to each view named.
|
||||||
* `jarl view order internal external` changes the match order, and
|
* `jarl view order internal external` changes the match order, and
|
||||||
`jarl view list` shows it.
|
`jarl view list` shows it.
|
||||||
* Deleting a view is refused while it still has zones, unless you pass
|
* Deleting a view is refused while it still has zones, unless you pass
|
||||||
|
|||||||
+17
-1
@@ -21,6 +21,10 @@ const Comment = "ACME DNS-01 challenge"
|
|||||||
type Zone struct {
|
type Zone struct {
|
||||||
Name string // as Jarl stores it, e.g. "adbraun.lol."
|
Name string // as Jarl stores it, e.g. "adbraun.lol."
|
||||||
View string // "" when views are not used
|
View string // "" when views are not used
|
||||||
|
// Link is the zone's link group (0: not linked). A record written
|
||||||
|
// into one zone of a link reaches the others (docs/views.md#linked-zones),
|
||||||
|
// so only one of them is written to.
|
||||||
|
Link int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zones returns the zones that hold name: the longest matching zone, in
|
// Zones returns the zones that hold name: the longest matching zone, in
|
||||||
@@ -46,7 +50,19 @@ func Zones(name string, zones []Zone, view string) []Zone {
|
|||||||
out = append(out, z)
|
out = append(out, z)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
// One zone per link: Jarl writes the shared record into the others.
|
||||||
|
var dedup []Zone
|
||||||
|
links := map[int64]bool{}
|
||||||
|
for _, z := range out {
|
||||||
|
if z.Link != 0 {
|
||||||
|
if links[z.Link] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
links[z.Link] = true
|
||||||
|
}
|
||||||
|
dedup = append(dedup, z)
|
||||||
|
}
|
||||||
|
return dedup
|
||||||
}
|
}
|
||||||
|
|
||||||
// Requests returns the changes that set (or, with set false, remove) the
|
// Requests returns the changes that set (or, with set false, remove) the
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ type Zone struct {
|
|||||||
// Registrar names the registrar Jarl can manage the domain at
|
// Registrar names the registrar Jarl can manage the domain at
|
||||||
// ("porkbun"), when one is configured for it.
|
// ("porkbun"), when one is configured for it.
|
||||||
Registrar string `json:"registrar,omitempty"`
|
Registrar string `json:"registrar,omitempty"`
|
||||||
|
// LinkedWith are the views of the zones this one is linked with
|
||||||
|
// (docs/views.md#linked-zones).
|
||||||
|
LinkedWith []string `json:"linked_with,omitempty"`
|
||||||
|
// Links are the zones this one is linked with, with their views.
|
||||||
|
Links []ZoneLink `json:"links,omitempty"`
|
||||||
|
// OtherViews are the other views with a primary zone of this name
|
||||||
|
// (linked or not): what GET /zones/{zone}/compare compares.
|
||||||
|
OtherViews []string `json:"other_views,omitempty"`
|
||||||
// Pending is set when listing with a changeset: add, change or delete.
|
// Pending is set when listing with a changeset: add, change or delete.
|
||||||
Pending string `json:"pending,omitempty"`
|
Pending string `json:"pending,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -40,6 +48,8 @@ type Record struct {
|
|||||||
View string `json:"view,omitempty"`
|
View string `json:"view,omitempty"`
|
||||||
RelativeName string `json:"relative_name"`
|
RelativeName string `json:"relative_name"`
|
||||||
EffectiveTTL uint32 `json:"effective_ttl"`
|
EffectiveTTL uint32 `json:"effective_ttl"`
|
||||||
|
// Linked is "shared" or "local" for a record of a linked zone ("" otherwise).
|
||||||
|
Linked string `json:"linked,omitempty"`
|
||||||
// Text is the decoded value of TXT and SPF records.
|
// Text is the decoded value of TXT and SPF records.
|
||||||
Text *string `json:"text,omitempty"`
|
Text *string `json:"text,omitempty"`
|
||||||
// Pending is set when listing with a changeset: add, change, delete,
|
// Pending is set when listing with a changeset: add, change, delete,
|
||||||
@@ -259,6 +269,9 @@ type StatusResponse struct {
|
|||||||
// Certificates sums up the TLS certificates Jarl manages
|
// Certificates sums up the TLS certificates Jarl manages
|
||||||
// (administrators only, when there are any).
|
// (administrators only, when there are any).
|
||||||
Certificates *CertSummary `json:"certificates,omitempty"`
|
Certificates *CertSummary `json:"certificates,omitempty"`
|
||||||
|
// EncryptedDNS is the last check of DNS over TLS and HTTPS, when it
|
||||||
|
// is set up (for those who may see options).
|
||||||
|
EncryptedDNS *EncDNSSummary `json:"encrypted_dns,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CertSummary is what the dashboard reports about certificates.
|
// CertSummary is what the dashboard reports about certificates.
|
||||||
@@ -562,3 +575,17 @@ type BlockRule struct {
|
|||||||
AutoViews bool `json:"auto_views,omitempty"`
|
AutoViews bool `json:"auto_views,omitempty"`
|
||||||
Pending string `json:"pending,omitempty"`
|
Pending string `json:"pending,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ZoneLink is a zone a zone is linked with.
|
||||||
|
type ZoneLink struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
View string `json:"view"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncDNSSummary is what the dashboard says about encrypted DNS.
|
||||||
|
type EncDNSSummary struct {
|
||||||
|
Checked time.Time `json:"checked"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Problems []string `json:"problems"`
|
||||||
|
Warnings []string `json:"warnings"`
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
"github.com/miekg/dns"
|
"github.com/miekg/dns"
|
||||||
|
|
||||||
"jarl/internal/auth"
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/encdns"
|
||||||
"jarl/internal/engine"
|
"jarl/internal/engine"
|
||||||
"jarl/internal/model"
|
"jarl/internal/model"
|
||||||
"jarl/internal/porkbun"
|
"jarl/internal/porkbun"
|
||||||
@@ -488,3 +489,34 @@ func TestCleanLegoError(t *testing.T) {
|
|||||||
t.Errorf("rate limit: %s", got)
|
t.Errorf("rate limit: %s", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When certificates.dir changes, Start writes the certificates to the new
|
||||||
|
// place and points encrypted DNS's tls statement there.
|
||||||
|
func TestDirChangeFollowed(t *testing.T) {
|
||||||
|
e := newEnv(t, false)
|
||||||
|
ctx := context.Background()
|
||||||
|
admin := &auth.Principal{User: &model.User{Username: "admin", Admin: true}}
|
||||||
|
now := time.Now()
|
||||||
|
certFile, keyFile := filepath.Join(e.dir, "c.pem"), filepath.Join(e.dir, "k.pem")
|
||||||
|
cert, key := makeCert(t, []string{"dns.example.com"}, now.Add(-time.Hour), now.Add(60*24*time.Hour), true)
|
||||||
|
os.WriteFile(certFile, cert, 0o600)
|
||||||
|
os.WriteFile(keyFile, key, 0o600)
|
||||||
|
if _, err := e.m.Create(ctx, Input{Name: model.Ptr("dns"), Source: model.Ptr("file"), CertFile: &certFile, KeyFile: &keyFile, Uses: &[]string{"dns"}}, admin); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
e.submit(engine.NewRequest(engine.OpOptionCreate, 0, engine.OptionInput{Scope: model.Ptr(model.ScopeTopLevel), Name: model.Ptr("tls"), Value: model.Ptr(encdns.TLSValue("dns", e.m.cfg.Dir))}))
|
||||||
|
|
||||||
|
moved := filepath.Join(e.dir, "tls-moved")
|
||||||
|
cfg := e.m.cfg
|
||||||
|
cfg.Dir = moved
|
||||||
|
m2 := New(cfg, e.st, e.eng, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
m2.Start(ctx)
|
||||||
|
if _, err := os.Stat(filepath.Join(moved, "dns", ChainFile)); err != nil {
|
||||||
|
t.Fatalf("not written to the new place: %v", err)
|
||||||
|
}
|
||||||
|
for _, o := range e.state().ScopedOptions(model.ScopeTopLevel, 0) {
|
||||||
|
if o.Name == "tls" && !strings.Contains(o.Value, moved+"/dns/fullchain.pem") {
|
||||||
|
t.Errorf("tls statement still points elsewhere: %s", o.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
legolog "github.com/go-acme/lego/v4/log"
|
legolog "github.com/go-acme/lego/v4/log"
|
||||||
|
|
||||||
"jarl/internal/auth"
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/encdns"
|
||||||
"jarl/internal/engine"
|
"jarl/internal/engine"
|
||||||
"jarl/internal/model"
|
"jarl/internal/model"
|
||||||
"jarl/internal/porkbun"
|
"jarl/internal/porkbun"
|
||||||
@@ -336,6 +337,9 @@ func (m *Manager) Update(ctx context.Context, name string, in Input, p *auth.Pri
|
|||||||
if err := m.checkUses(ctx, c); err != nil {
|
if err := m.checkUses(ctx, c); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if before.HasUse(model.CertUseDNS) && !c.HasUse(model.CertUseDNS) && m.namedUses(ctx, m.FilesDir(c.Name)) {
|
||||||
|
return nil, inputErr("named's configuration uses certificate %s (a tls statement names its files), so it keeps the dns use: take it out of named first (jarl encrypted-dns disable)", c.Name)
|
||||||
|
}
|
||||||
if c.Source == model.CertSourceFile && (c.CertFile != before.CertFile || c.KeyFile != before.KeyFile) {
|
if c.Source == model.CertSourceFile && (c.CertFile != before.CertFile || c.KeyFile != before.KeyFile) {
|
||||||
if _, err := m.readFiles(c); err != nil {
|
if _, err := m.readFiles(c); err != nil {
|
||||||
return nil, inputErr("%v", err)
|
return nil, inputErr("%v", err)
|
||||||
@@ -373,6 +377,9 @@ func (m *Manager) Delete(ctx context.Context, name string, p *auth.Principal) er
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if c.HasUse(model.CertUseDNS) && m.namedUses(ctx, m.FilesDir(c.Name)) {
|
||||||
|
return inputErr("named's configuration uses certificate %s (a tls statement names its files): take it out first (jarl encrypted-dns disable, or remove the tls statement)", c.Name)
|
||||||
|
}
|
||||||
if err := m.store.DeleteCertificate(ctx, c.ID); err != nil {
|
if err := m.store.DeleteCertificate(ctx, c.ID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -760,6 +767,40 @@ func (m *Manager) Start(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
m.followDir(ctx, all)
|
||||||
|
}
|
||||||
|
|
||||||
|
// followDir points the tls statements encrypted DNS wrote (jarl-NAME) at
|
||||||
|
// certificates.dir when it has changed: Start has just written the
|
||||||
|
// certificates there, and named would otherwise keep serving the old
|
||||||
|
// copies, which renewals no longer update.
|
||||||
|
func (m *Manager) followDir(ctx context.Context, all []*model.Certificate) {
|
||||||
|
st, err := m.store.LoadState(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var reqs []engine.Request
|
||||||
|
for _, b := range encdns.TLSBlocks(st) {
|
||||||
|
name, ok := strings.CutPrefix(b.Name, encdns.Prefix)
|
||||||
|
if !ok || filepath.Dir(b.CertFile) == m.FilesDir(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
i := slices.IndexFunc(all, func(c *model.Certificate) bool { return c.Name == name })
|
||||||
|
if i < 0 || !all[i].HasUse(model.CertUseDNS) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(m.FilesDir(name), ChainFile)); err != nil {
|
||||||
|
continue // not written there (yet)
|
||||||
|
}
|
||||||
|
reqs = append(reqs, engine.NewRequest(engine.OpOptionUpdate, b.OptionID, engine.OptionInput{Value: model.Ptr(encdns.TLSValue(name, m.cfg.Dir))}))
|
||||||
|
m.log.Info("certificates.dir changed: pointing named at the new files", "tls", b.Name, "was", filepath.Dir(b.CertFile), "now", m.FilesDir(name))
|
||||||
|
}
|
||||||
|
if len(reqs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := m.eng.Submit(ctx, reqs, auth.System, "jarld", "certificates.dir changed: tls statements follow it", engine.ApplyOptions{}); err != nil {
|
||||||
|
m.log.Error("pointing named at the moved certificate files failed; run 'jarl encrypted-dns setup' again", "err", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run renews certificates when they are due and rereads file-source ones
|
// Run renews certificates when they are due and rereads file-source ones
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ func primaryZones(st *model.State) []acme.Zone {
|
|||||||
var out []acme.Zone
|
var out []acme.Zone
|
||||||
for _, z := range st.SortedZones() {
|
for _, z := range st.SortedZones() {
|
||||||
if z.Type == model.ZonePrimary && !z.Disabled {
|
if z.Type == model.ZonePrimary && !z.Disabled {
|
||||||
out = append(out, acme.Zone{Name: z.Name, View: st.ViewName(z)})
|
out = append(out, acme.Zone{Name: z.Name, View: st.ViewName(z), Link: z.LinkGroup})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -364,6 +364,10 @@ type Stats struct {
|
|||||||
// per-zone counts) from named's response log. Without it only named's
|
// per-zone counts) from named's response log. Without it only named's
|
||||||
// own counters are collected.
|
// own counters are collected.
|
||||||
QueryLog bool `yaml:"query_log"`
|
QueryLog bool `yaml:"query_log"`
|
||||||
|
// RateLimitLog sends rate limiting's log lines (named's query-errors
|
||||||
|
// category) to the response log too, so the Exposure page and the
|
||||||
|
// statistics can show what was, or would be, limited.
|
||||||
|
RateLimitLog bool `yaml:"rate_limit_log"`
|
||||||
// Database is the statistics database, kept apart from Jarl's
|
// Database is the statistics database, kept apart from Jarl's
|
||||||
// configuration database.
|
// configuration database.
|
||||||
Database string `yaml:"database"`
|
Database string `yaml:"database"`
|
||||||
@@ -451,6 +455,7 @@ func Default() *Config {
|
|||||||
},
|
},
|
||||||
Stats: Stats{
|
Stats: Stats{
|
||||||
QueryLog: true,
|
QueryLog: true,
|
||||||
|
RateLimitLog: true,
|
||||||
Database: "/var/lib/jarl/stats.db",
|
Database: "/var/lib/jarl/stats.db",
|
||||||
StatisticsChannel: "127.0.0.1:8058",
|
StatisticsChannel: "127.0.0.1:8058",
|
||||||
LogFile: "/var/log/named/jarl-responses.log",
|
LogFile: "/var/log/named/jarl-responses.log",
|
||||||
@@ -736,6 +741,10 @@ stats:
|
|||||||
# Per-query data (top names and clients, per view and zone). false
|
# Per-query data (top names and clients, per view and zone). false
|
||||||
# collects only named's own counters, with no logging overhead.
|
# collects only named's own counters, with no logging overhead.
|
||||||
query_log: true
|
query_log: true
|
||||||
|
# With a rate-limit set, also log what rate limiting limits (or would,
|
||||||
|
# in log-only mode): named's query-errors category, which Jarl then adds
|
||||||
|
# to its log channel (and keeps in syslog). The Exposure page shows it.
|
||||||
|
rate_limit_log: true
|
||||||
database: /var/lib/jarl/stats.db
|
database: /var/lib/jarl/stats.db
|
||||||
statistics_channel: 127.0.0.1:8058
|
statistics_channel: 127.0.0.1:8058
|
||||||
log_file: /var/log/named/jarl-responses.log
|
log_file: /var/log/named/jarl-responses.log
|
||||||
|
|||||||
@@ -0,0 +1,401 @@
|
|||||||
|
// Package encdns sets up DNS over TLS (DoT) and DNS over HTTPS (DoH) in
|
||||||
|
// named (docs/encrypted-dns.md): a tls statement naming a certificate Jarl
|
||||||
|
// manages, and listen-on statements for port 853 (DoT) and the DoH port.
|
||||||
|
// Its state is read from named's configuration, so what the setup stages
|
||||||
|
// stays ordinary, visible configuration.
|
||||||
|
package encdns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"jarl/internal/engine"
|
||||||
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/namedconf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Prefix starts the names of the tls statements the setup writes.
|
||||||
|
const Prefix = "jarl-"
|
||||||
|
|
||||||
|
// DoTPort is the port for DNS over TLS (RFC 7858).
|
||||||
|
const DoTPort = 853
|
||||||
|
|
||||||
|
// Listener is a listen-on or listen-on-v6 statement.
|
||||||
|
type Listener struct {
|
||||||
|
OptionID int64 `json:"option_id"`
|
||||||
|
V6 bool `json:"v6"`
|
||||||
|
Port int `json:"port"` // as written; 0 when it isn't
|
||||||
|
TLS string `json:"tls,omitempty"` // tls statement ("" for plain DNS)
|
||||||
|
HTTP string `json:"http,omitempty"` // http endpoints: DNS over HTTPS
|
||||||
|
Addrs []string `json:"addresses"` // the address match list
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectivePort is the port named listens on: as written, or its
|
||||||
|
// default for the kind of listener.
|
||||||
|
func (l Listener) EffectivePort() int {
|
||||||
|
switch {
|
||||||
|
case l.Port != 0:
|
||||||
|
return l.Port
|
||||||
|
case l.HTTP != "" && l.TLS != "" && l.TLS != "none":
|
||||||
|
return 443
|
||||||
|
case l.HTTP != "":
|
||||||
|
return 80
|
||||||
|
case l.TLS != "":
|
||||||
|
return DoTPort
|
||||||
|
}
|
||||||
|
return 53
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kind is plain, dot or doh.
|
||||||
|
func (l Listener) Kind() string {
|
||||||
|
switch {
|
||||||
|
case l.HTTP != "":
|
||||||
|
return "doh"
|
||||||
|
case l.TLS != "" && l.TLS != "none":
|
||||||
|
return "dot"
|
||||||
|
}
|
||||||
|
return "plain"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Managed reports whether the setup wrote the listener.
|
||||||
|
func (l Listener) Managed() bool { return strings.HasPrefix(l.TLS, Prefix) }
|
||||||
|
|
||||||
|
// Listeners returns the server's listen-on and listen-on-v6 statements.
|
||||||
|
func Listeners(st *model.State) []Listener {
|
||||||
|
var out []Listener
|
||||||
|
for _, o := range st.ScopedOptions(model.ScopeOptions, 0) {
|
||||||
|
if o.Name != "listen-on" && o.Name != "listen-on-v6" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
l, err := parseListener(o.Name, o.Value)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
l.OptionID, l.V6 = o.ID, o.Name == "listen-on-v6"
|
||||||
|
out = append(out, l)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseListener(name, value string) (Listener, error) {
|
||||||
|
var l Listener
|
||||||
|
stmt, err := namedconf.ParseOptionValue(name, value)
|
||||||
|
if err != nil {
|
||||||
|
return l, err
|
||||||
|
}
|
||||||
|
items := stmt.Items[1:]
|
||||||
|
for i := 0; i < len(items); i++ {
|
||||||
|
it := items[i]
|
||||||
|
if it.Block != nil {
|
||||||
|
for _, s := range it.Block.Stmts {
|
||||||
|
l.Addrs = append(l.Addrs, namedconf.Compact(s.Items))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if it.Value == nil || i+1 >= len(items) || items[i+1].Value == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
arg := items[i+1].Value.Text
|
||||||
|
switch strings.ToLower(it.Value.Text) {
|
||||||
|
case "port":
|
||||||
|
l.Port, _ = strconv.Atoi(arg)
|
||||||
|
case "tls":
|
||||||
|
l.TLS = arg
|
||||||
|
case "http":
|
||||||
|
l.HTTP = arg
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return l, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLSBlock is a top-level tls statement.
|
||||||
|
type TLSBlock struct {
|
||||||
|
OptionID int64 `json:"option_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CertFile string `json:"cert_file"`
|
||||||
|
KeyFile string `json:"key_file"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLSBlocks returns the top-level tls statements.
|
||||||
|
func TLSBlocks(st *model.State) []TLSBlock {
|
||||||
|
var out []TLSBlock
|
||||||
|
for _, o := range st.ScopedOptions(model.ScopeTopLevel, 0) {
|
||||||
|
if o.Name != "tls" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stmt, err := namedconf.ParseOptionValue("tls", o.Value)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b := TLSBlock{OptionID: o.ID, Name: stmt.Arg(0)}
|
||||||
|
if blk := stmt.FirstBlock(); blk != nil {
|
||||||
|
for _, s := range blk.Stmts {
|
||||||
|
switch s.Keyword() {
|
||||||
|
case "cert-file":
|
||||||
|
b.CertFile = s.Arg(0)
|
||||||
|
case "key-file":
|
||||||
|
b.KeyFile = s.Arg(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Endpoint is an address and port named serves DoT or DoH on.
|
||||||
|
type Endpoint struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status is what is set up.
|
||||||
|
type Status struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
// TLS is the tls statement the setup wrote, and Certificate the
|
||||||
|
// certificate it names (a Jarl certificate's name, when it is one).
|
||||||
|
TLS *TLSBlock `json:"tls,omitempty"`
|
||||||
|
Certificate string `json:"certificate,omitempty"`
|
||||||
|
DoT []Endpoint `json:"dot"`
|
||||||
|
DoH []Endpoint `json:"doh"`
|
||||||
|
// Listeners are all listen-on statements, the setup's and others.
|
||||||
|
Listeners []Listener `json:"listeners"`
|
||||||
|
// Other are DoT or DoH listeners written by hand.
|
||||||
|
Other bool `json:"other,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatus reads the setup from the configuration. certDir is where Jarl
|
||||||
|
// writes certificates for named (certificates.dir).
|
||||||
|
func GetStatus(st *model.State, certDir string) Status {
|
||||||
|
s := Status{Listeners: Listeners(st), DoT: []Endpoint{}, DoH: []Endpoint{}}
|
||||||
|
if s.Listeners == nil {
|
||||||
|
s.Listeners = []Listener{}
|
||||||
|
}
|
||||||
|
for _, b := range TLSBlocks(st) {
|
||||||
|
if strings.HasPrefix(b.Name, Prefix) {
|
||||||
|
b := b
|
||||||
|
s.TLS = &b
|
||||||
|
if dir := filepath.Dir(b.CertFile); filepath.Dir(dir) == filepath.Clean(certDir) {
|
||||||
|
s.Certificate = filepath.Base(dir)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, l := range s.Listeners {
|
||||||
|
if l.Kind() == "plain" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !l.Managed() {
|
||||||
|
s.Other = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, a := range l.Addrs {
|
||||||
|
e := Endpoint{Address: a, Port: l.EffectivePort()}
|
||||||
|
if l.Kind() == "doh" {
|
||||||
|
s.DoH = append(s.DoH, e)
|
||||||
|
} else {
|
||||||
|
s.DoT = append(s.DoT, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.Enabled = len(s.DoT)+len(s.DoH) > 0
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup is what to set up.
|
||||||
|
type Setup struct {
|
||||||
|
// Hostname is the name clients connect to (the certificate's).
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
// Certificate is the Jarl certificate (with the dns use) to serve.
|
||||||
|
Certificate string `json:"certificate"`
|
||||||
|
// Addresses are the addresses of this server to listen on.
|
||||||
|
Addresses []string `json:"addresses"`
|
||||||
|
DoT bool `json:"dot"`
|
||||||
|
DoH bool `json:"doh"`
|
||||||
|
DoHPort int `json:"doh_port"` // default 443
|
||||||
|
DoTPort int `json:"dot_port"` // default 853 (clients expect it)
|
||||||
|
// Views get the hostname's A/AAAA records (default: none). In a
|
||||||
|
// linked zone they are local to the view.
|
||||||
|
Views []string `json:"views"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plan returns the requests that set up encrypted DNS as s says,
|
||||||
|
// replacing an earlier setup, and notes for the user. certDir is where
|
||||||
|
// the certificate's files are.
|
||||||
|
func Plan(st *model.State, s Setup, certDir string) ([]engine.Request, []string, error) {
|
||||||
|
var notes []string
|
||||||
|
if !s.DoT && !s.DoH {
|
||||||
|
return nil, nil, fmt.Errorf("choose DNS over TLS, DNS over HTTPS or both")
|
||||||
|
}
|
||||||
|
if s.DoHPort == 0 {
|
||||||
|
s.DoHPort = 443
|
||||||
|
}
|
||||||
|
if s.DoTPort == 0 {
|
||||||
|
s.DoTPort = DoTPort
|
||||||
|
}
|
||||||
|
for _, p := range []int{s.DoHPort, s.DoTPort} {
|
||||||
|
if p < 1 || p > 65535 || p == 53 {
|
||||||
|
return nil, nil, fmt.Errorf("port %d: use a free port other than 53", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.DoT && s.DoH && s.DoHPort == s.DoTPort {
|
||||||
|
return nil, nil, fmt.Errorf("DoT and DoH need different ports (both are %d)", s.DoHPort)
|
||||||
|
}
|
||||||
|
if s.Certificate == "" {
|
||||||
|
return nil, nil, fmt.Errorf("choose the certificate to serve")
|
||||||
|
}
|
||||||
|
host := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(s.Hostname), "."))
|
||||||
|
if host == "" || !strings.Contains(host, ".") {
|
||||||
|
return nil, nil, fmt.Errorf("give the host name clients connect to, e.g. dns.example.com")
|
||||||
|
}
|
||||||
|
var v4, v6 []string
|
||||||
|
for _, a := range s.Addresses {
|
||||||
|
ip, err := netip.ParseAddr(strings.TrimSpace(a))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("%q isn't an address of this server", a)
|
||||||
|
}
|
||||||
|
ip = ip.Unmap()
|
||||||
|
if ip.IsUnspecified() {
|
||||||
|
return nil, nil, fmt.Errorf("listen on this server's addresses, not on %s (that would include public ones)", ip)
|
||||||
|
}
|
||||||
|
if ip.Is4() {
|
||||||
|
v4 = append(v4, ip.String())
|
||||||
|
} else {
|
||||||
|
v6 = append(v6, ip.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(v4)+len(v6) == 0 {
|
||||||
|
return nil, nil, fmt.Errorf("choose at least one address to listen on")
|
||||||
|
}
|
||||||
|
tlsName := Prefix + s.Certificate
|
||||||
|
tlsValue := TLSValue(s.Certificate, certDir)
|
||||||
|
|
||||||
|
var reqs []engine.Request
|
||||||
|
// Take out the earlier setup's listeners and tls statements.
|
||||||
|
current := Listeners(st)
|
||||||
|
for _, l := range current {
|
||||||
|
if l.Managed() {
|
||||||
|
reqs = append(reqs, engine.NewRequest(engine.OpOptionDelete, l.OptionID, nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
haveTLS := false
|
||||||
|
for _, b := range TLSBlocks(st) {
|
||||||
|
switch {
|
||||||
|
case b.Name == tlsName:
|
||||||
|
haveTLS = true
|
||||||
|
reqs = append(reqs, engine.NewRequest(engine.OpOptionUpdate, b.OptionID, engine.OptionInput{Value: model.Ptr(tlsValue)}))
|
||||||
|
case strings.HasPrefix(b.Name, Prefix):
|
||||||
|
reqs = append(reqs, engine.NewRequest(engine.OpOptionDelete, b.OptionID, nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !haveTLS {
|
||||||
|
reqs = append(reqs, option(model.ScopeTopLevel, "tls", tlsValue))
|
||||||
|
}
|
||||||
|
// named listens on port 53 everywhere only while there is no
|
||||||
|
// listen-on statement at all: adding the first one for DoT would end
|
||||||
|
// that, so keep it explicitly.
|
||||||
|
plain := func(v6 bool) bool {
|
||||||
|
for _, l := range current {
|
||||||
|
if l.V6 == v6 && !l.Managed() && l.Kind() == "plain" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
add := func(name string, addrs []string) {
|
||||||
|
if len(addrs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list := "{ " + strings.Join(addrs, "; ") + "; }"
|
||||||
|
if !plain(name == "listen-on-v6") {
|
||||||
|
reqs = append(reqs, option(model.ScopeOptions, name, "{ any; }"))
|
||||||
|
notes = append(notes, fmt.Sprintf("named listened on port 53 on every address by default; %s { any; } keeps that, as the first %s statement would otherwise end it", name, name))
|
||||||
|
}
|
||||||
|
if s.DoT {
|
||||||
|
reqs = append(reqs, option(model.ScopeOptions, name, fmt.Sprintf("port %d tls %s %s", s.DoTPort, tlsName, list)))
|
||||||
|
}
|
||||||
|
if s.DoH {
|
||||||
|
reqs = append(reqs, option(model.ScopeOptions, name, fmt.Sprintf("port %d tls %s http default %s", s.DoHPort, tlsName, list)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add("listen-on", v4)
|
||||||
|
add("listen-on-v6", v6)
|
||||||
|
|
||||||
|
// The host name's addresses, in the views asked for.
|
||||||
|
fqdn := host + "."
|
||||||
|
for _, vn := range s.Views {
|
||||||
|
v := st.ViewByName(vn)
|
||||||
|
if v == nil {
|
||||||
|
return nil, nil, fmt.Errorf("view %q does not exist", vn)
|
||||||
|
}
|
||||||
|
z := zoneFor(st, fqdn, v.ID)
|
||||||
|
if z == nil {
|
||||||
|
notes = append(notes, fmt.Sprintf("view %s has no zone for %s, so no address records were added there; clients must find it some other way", v.Name, host))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var local *bool
|
||||||
|
if z.LinkGroup != 0 {
|
||||||
|
local = model.Ptr(true)
|
||||||
|
}
|
||||||
|
for _, set := range []struct {
|
||||||
|
typ string
|
||||||
|
addrs []string
|
||||||
|
}{{"A", v4}, {"AAAA", v6}} {
|
||||||
|
if len(set.addrs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
reqs = append(reqs, engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: fqdn, Type: set.typ, Data: set.addrs, Local: local}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reqs, notes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func option(scope, name, value string) engine.Request {
|
||||||
|
return engine.NewRequest(engine.OpOptionCreate, 0, engine.OptionInput{Scope: model.Ptr(scope), Name: model.Ptr(name), Value: model.Ptr(value)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// zoneFor returns the primary zone in a view that holds name.
|
||||||
|
func zoneFor(st *model.State, name string, viewID int64) *model.Zone {
|
||||||
|
var best *model.Zone
|
||||||
|
for _, z := range st.ZonesInView(viewID) {
|
||||||
|
if z.Type == model.ZonePrimary && (name == z.Name || strings.HasSuffix(name, "."+z.Name)) && (best == nil || len(z.Name) > len(best.Name)) {
|
||||||
|
best = z
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable returns the requests that take the setup out: its listeners and
|
||||||
|
// tls statement. Plain listeners and the host name's records stay.
|
||||||
|
func Disable(st *model.State) []engine.Request {
|
||||||
|
var reqs []engine.Request
|
||||||
|
for _, l := range Listeners(st) {
|
||||||
|
if l.Managed() {
|
||||||
|
reqs = append(reqs, engine.NewRequest(engine.OpOptionDelete, l.OptionID, nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, b := range TLSBlocks(st) {
|
||||||
|
if strings.HasPrefix(b.Name, Prefix) {
|
||||||
|
reqs = append(reqs, engine.NewRequest(engine.OpOptionDelete, b.OptionID, nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reqs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private reports whether an address is private (RFC 1918, ULA, ...).
|
||||||
|
func Private(a netip.Addr) bool {
|
||||||
|
a = a.Unmap()
|
||||||
|
return a.IsPrivate() || a.IsLoopback() || a.IsLinkLocalUnicast() || netip.MustParsePrefix("100.64.0.0/10").Contains(a)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLSValue is the tls statement the setup writes for a certificate whose
|
||||||
|
// files are in certDir/NAME.
|
||||||
|
func TLSValue(cert, certDir string) string {
|
||||||
|
dir := filepath.Join(certDir, cert)
|
||||||
|
return fmt.Sprintf(`%s { cert-file "%s"; key-file "%s"; protocols { TLSv1.2; TLSv1.3; }; }`,
|
||||||
|
Prefix+cert, filepath.Join(dir, "fullchain.pem"), filepath.Join(dir, "privkey.pem"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package encdns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/engine"
|
||||||
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/render"
|
||||||
|
"jarl/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type env struct {
|
||||||
|
t *testing.T
|
||||||
|
eng *engine.Engine
|
||||||
|
st *store.Store
|
||||||
|
certDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEnv(t *testing.T) *env {
|
||||||
|
dir := t.TempDir()
|
||||||
|
st, err := store.Open(filepath.Join(dir, "jarl.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { st.Close() })
|
||||||
|
eng := engine.New(st, nil, engine.Config{
|
||||||
|
Paths: render.Paths{ConfigDir: filepath.Join(dir, "etc"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")},
|
||||||
|
StagingDir: filepath.Join(dir, "staging"), NoReload: true, SkipCheck: true,
|
||||||
|
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
e := &env{t: t, eng: eng, st: st, certDir: filepath.Join(dir, "tls")}
|
||||||
|
os.MkdirAll(filepath.Join(e.certDir, "dns"), 0o755)
|
||||||
|
os.WriteFile(filepath.Join(e.certDir, "dns", "fullchain.pem"), []byte("x"), 0o644)
|
||||||
|
os.WriteFile(filepath.Join(e.certDir, "dns", "privkey.pem"), []byte("x"), 0o600)
|
||||||
|
e.submit(engine.NewRequest(engine.OpSettingsUpdate, 0, engine.SettingsInput{Nameservers: &[]string{"ns1.example.net."}}),
|
||||||
|
engine.NewRequest(engine.OpViewCreate, 0, engine.ViewInput{Name: model.Ptr("internal"), MatchClients: &[]string{"10.0.0.0/8"}}),
|
||||||
|
engine.NewRequest(engine.OpViewCreate, 0, engine.ViewInput{Name: model.Ptr("external"), MatchClients: &[]string{"any"}}))
|
||||||
|
e.submit(engine.NewRequest(engine.OpZoneCreate, 0, engine.ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("internal")}))
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *env) submit(reqs ...engine.Request) *engine.Result {
|
||||||
|
e.t.Helper()
|
||||||
|
res, err := e.eng.Submit(context.Background(), reqs, auth.System, "test", "", engine.ApplyOptions{})
|
||||||
|
if err != nil {
|
||||||
|
e.t.Fatalf("%v (%+v)", err, res)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *env) state() *model.State {
|
||||||
|
st, err := e.st.LoadState(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
e.t.Fatal(err)
|
||||||
|
}
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
func values(st *model.State, scope, name string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, o := range st.ScopedOptions(scope, 0) {
|
||||||
|
if o.Name == name {
|
||||||
|
out = append(out, o.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetup(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
setup := Setup{Hostname: "dns.example.com", Certificate: "dns", Addresses: []string{"10.0.10.5", "2001:db8::5"}, DoT: true, DoH: true, Views: []string{"internal", "external"}}
|
||||||
|
reqs, notes, err := Plan(e.state(), setup, e.certDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
e.submit(reqs...)
|
||||||
|
st := e.state()
|
||||||
|
tls := values(st, model.ScopeTopLevel, "tls")
|
||||||
|
if len(tls) != 1 || !strings.HasPrefix(tls[0], "jarl-dns {") || !strings.Contains(tls[0], filepath.Join(e.certDir, "dns", "fullchain.pem")) {
|
||||||
|
t.Errorf("tls = %q", tls)
|
||||||
|
}
|
||||||
|
// The plain listeners named had by default are kept explicitly.
|
||||||
|
want4 := []string{"{ any; }", "port 853 tls jarl-dns { 10.0.10.5; }", "port 443 tls jarl-dns http default { 10.0.10.5; }"}
|
||||||
|
if got := values(st, model.ScopeOptions, "listen-on"); !slices.Equal(got, want4) {
|
||||||
|
t.Errorf("listen-on = %q, want %q", got, want4)
|
||||||
|
}
|
||||||
|
if got := values(st, model.ScopeOptions, "listen-on-v6"); len(got) != 3 || got[0] != "{ any; }" {
|
||||||
|
t.Errorf("listen-on-v6 = %q", got)
|
||||||
|
}
|
||||||
|
if len(notes) != 3 || !strings.Contains(strings.Join(notes, " "), "external has no zone") {
|
||||||
|
t.Errorf("notes = %q", notes)
|
||||||
|
}
|
||||||
|
// The host name's addresses in the internal view.
|
||||||
|
z := st.FindZone("example.com.", st.ViewByName("internal").ID)
|
||||||
|
var addrs []string
|
||||||
|
for _, r := range st.ZoneRecords(z.ID) {
|
||||||
|
if r.Name == "dns.example.com." {
|
||||||
|
addrs = append(addrs, r.Type+" "+r.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !slices.Equal(addrs, []string{"A 10.0.10.5", "AAAA 2001:db8::5"}) {
|
||||||
|
t.Errorf("records = %q", addrs)
|
||||||
|
}
|
||||||
|
s := GetStatus(st, e.certDir)
|
||||||
|
if !s.Enabled || s.Certificate != "dns" || len(s.DoT) != 2 || len(s.DoH) != 2 || s.DoH[0].Port != 443 || s.Other {
|
||||||
|
t.Errorf("status = %+v", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setting up again replaces the setup: DoT only, one address, and the
|
||||||
|
// plain listeners aren't doubled.
|
||||||
|
reqs, _, err = Plan(st, Setup{Hostname: "dns.example.com", Certificate: "dns", Addresses: []string{"10.0.10.5"}, DoT: true}, e.certDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
e.submit(reqs...)
|
||||||
|
st = e.state()
|
||||||
|
if got := values(st, model.ScopeOptions, "listen-on"); !slices.Equal(got, []string{"{ any; }", "port 853 tls jarl-dns { 10.0.10.5; }"}) {
|
||||||
|
t.Errorf("after the second setup: %q", got)
|
||||||
|
}
|
||||||
|
if got := values(st, model.ScopeOptions, "listen-on-v6"); !slices.Equal(got, []string{"{ any; }"}) {
|
||||||
|
t.Errorf("listen-on-v6 after the second setup: %q", got)
|
||||||
|
}
|
||||||
|
if got := values(st, model.ScopeTopLevel, "tls"); len(got) != 1 {
|
||||||
|
t.Errorf("tls statements: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Off: the setup's listeners and tls statement go, the rest stays.
|
||||||
|
e.submit(Disable(st)...)
|
||||||
|
st = e.state()
|
||||||
|
if got := values(st, model.ScopeOptions, "listen-on"); !slices.Equal(got, []string{"{ any; }"}) || len(values(st, model.ScopeTopLevel, "tls")) != 0 || GetStatus(st, e.certDir).Enabled {
|
||||||
|
t.Errorf("after disabling: listen-on %q tls %q", got, values(st, model.ScopeTopLevel, "tls"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupRefuses(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
st := e.state()
|
||||||
|
for _, s := range []Setup{
|
||||||
|
{Hostname: "dns.example.com", Certificate: "dns", Addresses: []string{"10.0.10.5"}}, // neither DoT nor DoH
|
||||||
|
{Hostname: "dns.example.com", Certificate: "dns", Addresses: []string{"0.0.0.0"}, DoT: true}, // every address
|
||||||
|
{Hostname: "dns.example.com", Certificate: "dns", DoT: true}, // no address
|
||||||
|
{Hostname: "dns.example.com", Certificate: "dns", Addresses: []string{"10.0.10.5"}, DoH: true, DoHPort: 53}, // port 53
|
||||||
|
{Hostname: "dns", Certificate: "dns", Addresses: []string{"10.0.10.5"}, DoT: true}, // not a host name
|
||||||
|
} {
|
||||||
|
if _, _, err := Plan(st, s, e.certDir); err == nil {
|
||||||
|
t.Errorf("accepted %+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A tls statement whose files are missing is refused by validation.
|
||||||
|
_, err := e.eng.Submit(context.Background(), []engine.Request{engine.NewRequest(engine.OpOptionCreate, 0, engine.OptionInput{
|
||||||
|
Scope: model.Ptr(model.ScopeTopLevel), Name: model.Ptr("tls"), Value: model.Ptr(`x { cert-file "/nonexistent/c.pem"; key-file "/nonexistent/k.pem"; }`)})},
|
||||||
|
auth.System, "test", "", engine.ApplyOptions{})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "doesn't exist") {
|
||||||
|
t.Errorf("missing files: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPorts(t *testing.T) {
|
||||||
|
s, err := parseProcAddr("0100007F:0035")
|
||||||
|
if err != nil || s.Addr != netip.MustParseAddr("127.0.0.1") || s.Port != 53 {
|
||||||
|
t.Errorf("v4: %+v %v", s, err)
|
||||||
|
}
|
||||||
|
s, err = parseProcAddr("00000000000000000000000001000000:01BB")
|
||||||
|
if err != nil || s.Addr != netip.MustParseAddr("::1") || s.Port != 443 {
|
||||||
|
t.Errorf("v6: %+v %v", s, err)
|
||||||
|
}
|
||||||
|
e := newEnv(t)
|
||||||
|
old := ListeningSockets
|
||||||
|
t.Cleanup(func() { ListeningSockets = old })
|
||||||
|
ListeningSockets = func() ([]Socket, error) {
|
||||||
|
return []Socket{{netip.MustParseAddr("0.0.0.0"), 443}, {netip.MustParseAddr("10.0.10.5"), 8443}, {netip.MustParseAddr("::"), 853}}, nil
|
||||||
|
}
|
||||||
|
c, err := PortCheck(e.state(), []string{"10.0.10.5"}, []int{853, 443, 8443}, []string{"0.0.0.0:443"})
|
||||||
|
if err != nil || len(c) != 3 || c[1].What != "jarld (Jarl's web UI and API)" || c[2].What != "another program" {
|
||||||
|
t.Errorf("conflicts = %+v %v", c, err)
|
||||||
|
}
|
||||||
|
if p := FreeDoHPort(e.state(), []string{"10.0.10.5"}, nil); p != 4443 {
|
||||||
|
t.Errorf("free DoH port = %d, want 4443", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package encdns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Probe is the outcome of checking one DoT or DoH listener the way a
|
||||||
|
// client would: connect, check the certificate, ask a question.
|
||||||
|
type Probe struct {
|
||||||
|
Kind string `json:"kind"` // dot or doh
|
||||||
|
Address string `json:"address"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
// OK: it answered over TLS with a certificate for the host name that
|
||||||
|
// is current. Problem says what is wrong when not; Warning notes
|
||||||
|
// something that doesn't stop clients from working everywhere.
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Problem string `json:"problem,omitempty"`
|
||||||
|
Warning string `json:"warning,omitempty"`
|
||||||
|
// What the certificate named served says.
|
||||||
|
CertNames []string `json:"cert_names,omitempty"`
|
||||||
|
CertNotAfter *time.Time `json:"cert_not_after,omitempty"`
|
||||||
|
CertSerial string `json:"cert_serial,omitempty"`
|
||||||
|
// Trusted: it chains to a CA this server trusts (not so for a
|
||||||
|
// staging or test CA).
|
||||||
|
Trusted bool `json:"trusted"`
|
||||||
|
RCode string `json:"rcode,omitempty"`
|
||||||
|
Millis int64 `json:"ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health is the last check of every listener.
|
||||||
|
type Health struct {
|
||||||
|
Checked time.Time `json:"checked"`
|
||||||
|
Probes []Probe `json:"probes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OK reports whether every listener works.
|
||||||
|
func (h *Health) OK() bool {
|
||||||
|
for _, p := range h.Probes {
|
||||||
|
if !p.OK {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check probes every DoT and DoH listener of the setup. hostname is what
|
||||||
|
// clients ask for, serial the certificate Jarl last wrote for named ("" if
|
||||||
|
// unknown), roots the CAs to trust (nil: the system's).
|
||||||
|
func Check(ctx context.Context, s Status, hostname, serial string, roots *x509.CertPool) *Health {
|
||||||
|
h := &Health{Checked: time.Now(), Probes: []Probe{}}
|
||||||
|
for _, e := range s.DoT {
|
||||||
|
h.Probes = append(h.Probes, probe(ctx, "dot", e, hostname, serial, roots))
|
||||||
|
}
|
||||||
|
for _, e := range s.DoH {
|
||||||
|
h.Probes = append(h.Probes, probe(ctx, "doh", e, hostname, serial, roots))
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
const probeTimeout = 5 * time.Second
|
||||||
|
|
||||||
|
func probe(ctx context.Context, kind string, e Endpoint, hostname, serial string, roots *x509.CertPool) Probe {
|
||||||
|
p := Probe{Kind: kind, Address: e.Address, Port: e.Port}
|
||||||
|
addr := net.JoinHostPort(e.Address, strconv.Itoa(e.Port))
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, probeTimeout)
|
||||||
|
defer cancel()
|
||||||
|
start := time.Now()
|
||||||
|
q := new(dns.Msg)
|
||||||
|
q.SetQuestion(dns.Fqdn(hostname), dns.TypeA)
|
||||||
|
q.RecursionDesired = true
|
||||||
|
var state tls.ConnectionState
|
||||||
|
var answer *dns.Msg
|
||||||
|
var err error
|
||||||
|
cfg := &tls.Config{ServerName: hostname, InsecureSkipVerify: true, MinVersion: tls.VersionTLS12} // checked below, to say what is wrong
|
||||||
|
if kind == "dot" {
|
||||||
|
cfg.NextProtos = []string{"dot"}
|
||||||
|
state, answer, err = askDoT(ctx, addr, cfg, q)
|
||||||
|
} else {
|
||||||
|
state, answer, err = askDoH(ctx, addr, hostname, e.Port, cfg, q)
|
||||||
|
}
|
||||||
|
p.Millis = time.Since(start).Milliseconds()
|
||||||
|
if len(state.PeerCertificates) > 0 {
|
||||||
|
leaf := state.PeerCertificates[0]
|
||||||
|
p.CertNames, p.CertSerial = leaf.DNSNames, fmt.Sprintf("%x", leaf.SerialNumber)
|
||||||
|
na := leaf.NotAfter
|
||||||
|
p.CertNotAfter = &na
|
||||||
|
inter := x509.NewCertPool()
|
||||||
|
for _, c := range state.PeerCertificates[1:] {
|
||||||
|
inter.AddCert(c)
|
||||||
|
}
|
||||||
|
_, verr := leaf.Verify(x509.VerifyOptions{Roots: roots, Intermediates: inter, DNSName: hostname})
|
||||||
|
p.Trusted = verr == nil
|
||||||
|
switch {
|
||||||
|
case leaf.VerifyHostname(hostname) != nil:
|
||||||
|
p.Problem = fmt.Sprintf("named serves a certificate for %s, not %s: clients reject it", strings.Join(leaf.DNSNames, ", "), hostname)
|
||||||
|
case time.Now().After(leaf.NotAfter):
|
||||||
|
p.Problem = "named serves an expired certificate (until " + leaf.NotAfter.Format(time.DateOnly) + ")"
|
||||||
|
case serial != "" && p.CertSerial != serial:
|
||||||
|
p.Problem = "named still serves an older certificate (serial " + p.CertSerial + ", Jarl has " + serial + "): it hasn't reread the files (rndc reconfig)"
|
||||||
|
case !p.Trusted:
|
||||||
|
p.Warning = "the certificate isn't from a CA this server trusts (a staging or test CA?): clients reject it unless they trust that CA"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
p.Problem = describe(err, e)
|
||||||
|
case p.Problem != "":
|
||||||
|
case answer == nil:
|
||||||
|
p.Problem = "no DNS answer"
|
||||||
|
default:
|
||||||
|
p.RCode = dns.RcodeToString[answer.Rcode]
|
||||||
|
p.OK = true
|
||||||
|
if answer.Rcode == dns.RcodeRefused {
|
||||||
|
p.Warning = strings.TrimPrefix(p.Warning+"; ", "; ") + "it answers, but REFUSED: this server's own address gets a view that doesn't answer for " + hostname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func askDoT(ctx context.Context, addr string, cfg *tls.Config, q *dns.Msg) (tls.ConnectionState, *dns.Msg, error) {
|
||||||
|
d := &tls.Dialer{NetDialer: &net.Dialer{Timeout: probeTimeout}, Config: cfg}
|
||||||
|
conn, err := d.DialContext(ctx, "tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return tls.ConnectionState{}, nil, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
tc := conn.(*tls.Conn)
|
||||||
|
state := tc.ConnectionState()
|
||||||
|
conn.SetDeadline(time.Now().Add(probeTimeout))
|
||||||
|
co := &dns.Conn{Conn: conn}
|
||||||
|
if err := co.WriteMsg(q); err != nil {
|
||||||
|
return state, nil, err
|
||||||
|
}
|
||||||
|
r, err := co.ReadMsg()
|
||||||
|
return state, r, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func askDoH(ctx context.Context, addr, hostname string, port int, cfg *tls.Config, q *dns.Msg) (tls.ConnectionState, *dns.Msg, error) {
|
||||||
|
cfg = cfg.Clone()
|
||||||
|
cfg.NextProtos = []string{"h2", "http/1.1"}
|
||||||
|
tr := &http.Transport{
|
||||||
|
TLSClientConfig: cfg,
|
||||||
|
ForceAttemptHTTP2: true,
|
||||||
|
// Whatever the host name resolves to here: the listener's address.
|
||||||
|
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||||
|
return (&net.Dialer{Timeout: probeTimeout}).DialContext(ctx, network, addr)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
defer tr.CloseIdleConnections()
|
||||||
|
wire, err := q.Pack()
|
||||||
|
if err != nil {
|
||||||
|
return tls.ConnectionState{}, nil, err
|
||||||
|
}
|
||||||
|
url := "https://" + net.JoinHostPort(hostname, strconv.Itoa(port)) + "/dns-query"
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(wire))
|
||||||
|
if err != nil {
|
||||||
|
return tls.ConnectionState{}, nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/dns-message")
|
||||||
|
req.Header.Set("Accept", "application/dns-message")
|
||||||
|
resp, err := (&http.Client{Transport: tr, Timeout: probeTimeout}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return tls.ConnectionState{}, nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var state tls.ConnectionState
|
||||||
|
if resp.TLS != nil {
|
||||||
|
state = *resp.TLS
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||||
|
if err != nil {
|
||||||
|
return state, nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return state, nil, fmt.Errorf("HTTP %d at /dns-query", resp.StatusCode)
|
||||||
|
}
|
||||||
|
r := new(dns.Msg)
|
||||||
|
if err := r.Unpack(body); err != nil {
|
||||||
|
return state, nil, fmt.Errorf("not a DNS answer: %w", err)
|
||||||
|
}
|
||||||
|
return state, r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// describe says what a failed probe means.
|
||||||
|
func describe(err error, e Endpoint) string {
|
||||||
|
var ne net.Error
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, syscall.ECONNREFUSED):
|
||||||
|
return fmt.Sprintf("nothing listens on %s port %d: named may not have been able to bind it (look in named's log; ports below 1024 need named to start as root)", e.Address, e.Port)
|
||||||
|
case errors.As(err, &ne) && ne.Timeout(), errors.Is(err, context.DeadlineExceeded):
|
||||||
|
return fmt.Sprintf("no answer from %s port %d within %s (a firewall on the server?)", e.Address, e.Port, probeTimeout)
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package encdns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"io"
|
||||||
|
"math/big"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testCert(t *testing.T, names ...string) (tls.Certificate, *x509.Certificate) {
|
||||||
|
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
tmpl := &x509.Certificate{SerialNumber: big.NewInt(0xabc123), Subject: pkix.Name{CommonName: names[0]}, DNSNames: names,
|
||||||
|
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour), ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
leaf, _ := x509.ParseCertificate(der)
|
||||||
|
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leaf}, leaf
|
||||||
|
}
|
||||||
|
|
||||||
|
// answer answers every question with 192.0.2.53.
|
||||||
|
var answer = dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) {
|
||||||
|
m := new(dns.Msg)
|
||||||
|
m.SetReply(r)
|
||||||
|
m.Answer = append(m.Answer, &dns.A{Hdr: dns.RR_Header{Name: r.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60}, A: net.ParseIP("192.0.2.53")})
|
||||||
|
w.WriteMsg(m)
|
||||||
|
})
|
||||||
|
|
||||||
|
func listeners(t *testing.T, cert tls.Certificate) (dot, doh Endpoint) {
|
||||||
|
cfg := &tls.Config{Certificates: []tls.Certificate{cert}}
|
||||||
|
ln, err := tls.Listen("tcp", "127.0.0.1:0", cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
srv := &dns.Server{Listener: ln, Net: "tcp-tls", Handler: answer}
|
||||||
|
go srv.ActivateAndServe()
|
||||||
|
t.Cleanup(func() { srv.Shutdown() })
|
||||||
|
hl, err := tls.Listen("tcp", "127.0.0.1:0", cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
hs := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/dns-query" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
q := new(dns.Msg)
|
||||||
|
if q.Unpack(b) != nil {
|
||||||
|
http.Error(w, "bad", 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m := new(dns.Msg)
|
||||||
|
m.SetReply(q)
|
||||||
|
m.Rcode = dns.RcodeSuccess
|
||||||
|
out, _ := m.Pack()
|
||||||
|
w.Header().Set("Content-Type", "application/dns-message")
|
||||||
|
w.Write(out)
|
||||||
|
})}
|
||||||
|
go hs.Serve(hl)
|
||||||
|
t.Cleanup(func() { hs.Close() })
|
||||||
|
port := func(l net.Listener) int { return l.Addr().(*net.TCPAddr).Port }
|
||||||
|
return Endpoint{"127.0.0.1", port(ln)}, Endpoint{"127.0.0.1", port(hl)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealth(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
cert, leaf := testCert(t, "dns.example.com")
|
||||||
|
dot, doh := listeners(t, cert)
|
||||||
|
roots := x509.NewCertPool()
|
||||||
|
roots.AddCert(leaf)
|
||||||
|
st := Status{Enabled: true, DoT: []Endpoint{dot}, DoH: []Endpoint{doh}}
|
||||||
|
|
||||||
|
h := Check(ctx, st, "dns.example.com", "abc123", roots)
|
||||||
|
if !h.OK() || len(h.Probes) != 2 {
|
||||||
|
t.Fatalf("working setup: %+v", h.Probes)
|
||||||
|
}
|
||||||
|
for _, p := range h.Probes {
|
||||||
|
if !p.Trusted || p.RCode != "NOERROR" || p.Warning != "" || p.CertSerial != "abc123" {
|
||||||
|
t.Errorf("%s: %+v", p.Kind, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// named hasn't reread a renewed certificate.
|
||||||
|
if h := Check(ctx, st, "dns.example.com", "def456", roots); h.OK() || !strings.Contains(h.Probes[0].Problem, "older certificate") {
|
||||||
|
t.Errorf("stale: %+v", h.Probes[0])
|
||||||
|
}
|
||||||
|
// The wrong name.
|
||||||
|
if h := Check(ctx, st, "other.example.com", "", roots); h.OK() || !strings.Contains(h.Probes[0].Problem, "not other.example.com") {
|
||||||
|
t.Errorf("wrong name: %+v", h.Probes[0])
|
||||||
|
}
|
||||||
|
// A CA this server doesn't trust: works, with a warning.
|
||||||
|
if h := Check(ctx, st, "dns.example.com", "abc123", x509.NewCertPool()); !h.OK() || !strings.Contains(h.Probes[1].Warning, "staging") {
|
||||||
|
t.Errorf("untrusted: %+v", h.Probes[1])
|
||||||
|
}
|
||||||
|
// Nothing listening.
|
||||||
|
l, _ := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
gone := l.Addr().(*net.TCPAddr).Port
|
||||||
|
l.Close()
|
||||||
|
if h := Check(ctx, Status{Enabled: true, DoT: []Endpoint{{"127.0.0.1", gone}}}, "dns.example.com", "", roots); h.OK() || !strings.Contains(h.Probes[0].Problem, "nothing listens") {
|
||||||
|
t.Errorf("nothing listening: %+v", h.Probes[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package encdns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Socket is a listening TCP socket on this host.
|
||||||
|
type Socket struct {
|
||||||
|
Addr netip.Addr
|
||||||
|
Port int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListeningSockets reads the host's listening TCP sockets from
|
||||||
|
// /proc/net/tcp and tcp6 (Linux; nil elsewhere). It needs no privileges
|
||||||
|
// and binds nothing. Tests replace it.
|
||||||
|
var ListeningSockets = func() ([]Socket, error) {
|
||||||
|
var out []Socket
|
||||||
|
for _, f := range []string{"/proc/net/tcp", "/proc/net/tcp6"} {
|
||||||
|
socks, err := parseProcNet(f)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, socks...)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseProcNet(path string) ([]Socket, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
var out []Socket
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
sc.Scan() // header
|
||||||
|
for sc.Scan() {
|
||||||
|
fields := strings.Fields(sc.Text())
|
||||||
|
if len(fields) < 4 || fields[3] != "0A" { // 0A: LISTEN
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s, err := parseProcAddr(fields[1])
|
||||||
|
if err == nil {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, sc.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseProcAddr parses "0100007F:0035": the address in 32-bit words of
|
||||||
|
// host byte order (little-endian here), then the port.
|
||||||
|
func parseProcAddr(s string) (Socket, error) {
|
||||||
|
ha, hp, ok := strings.Cut(s, ":")
|
||||||
|
if !ok {
|
||||||
|
return Socket{}, fmt.Errorf("bad address %q", s)
|
||||||
|
}
|
||||||
|
b, err := hex.DecodeString(ha)
|
||||||
|
if err != nil || (len(b) != 4 && len(b) != 16) {
|
||||||
|
return Socket{}, fmt.Errorf("bad address %q", s)
|
||||||
|
}
|
||||||
|
for i := 0; i+4 <= len(b); i += 4 {
|
||||||
|
b[i], b[i+1], b[i+2], b[i+3] = b[i+3], b[i+2], b[i+1], b[i]
|
||||||
|
}
|
||||||
|
addr, _ := netip.AddrFromSlice(b)
|
||||||
|
port, err := strconv.ParseUint(hp, 16, 16)
|
||||||
|
if err != nil {
|
||||||
|
return Socket{}, err
|
||||||
|
}
|
||||||
|
return Socket{Addr: addr.Unmap(), Port: int(port)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conflict is something else listening where a listener would go.
|
||||||
|
type Conflict struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
What string `json:"what"` // who it is, when Jarl knows
|
||||||
|
}
|
||||||
|
|
||||||
|
// PortCheck says which of addresses × ports something else listens on
|
||||||
|
// already. named's own listeners (the setup's, now) and jarld's (known
|
||||||
|
// listen addresses) are told apart.
|
||||||
|
func PortCheck(st *model.State, addresses []string, ports []int, jarld []string) ([]Conflict, error) {
|
||||||
|
socks, err := ListeningSockets()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// What named listens on now with DoT or DoH (it will again).
|
||||||
|
ours := map[string]bool{}
|
||||||
|
for _, l := range Listeners(st) {
|
||||||
|
if l.Kind() != "plain" {
|
||||||
|
for _, a := range l.Addrs {
|
||||||
|
ours[a+"#"+strconv.Itoa(l.EffectivePort())] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jarldOn := map[string]bool{}
|
||||||
|
for _, l := range jarld {
|
||||||
|
jarldOn[l] = true
|
||||||
|
}
|
||||||
|
var out []Conflict
|
||||||
|
for _, as := range addresses {
|
||||||
|
a, err := netip.ParseAddr(as)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a = a.Unmap()
|
||||||
|
for _, p := range ports {
|
||||||
|
if ours[a.String()+"#"+strconv.Itoa(p)] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, s := range socks {
|
||||||
|
if s.Port != p || !(s.Addr == a || s.Addr.IsUnspecified() && s.Addr.Is4() == a.Is4() || s.Addr.IsUnspecified() && s.Addr.Is6()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
what := "another program"
|
||||||
|
for l := range jarldOn {
|
||||||
|
if strings.HasSuffix(l, ":"+strconv.Itoa(p)) {
|
||||||
|
what = "jarld (Jarl's web UI and API)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, Conflict{Address: a.String(), Port: p, What: what})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FreeDoHPort returns the first of the usual DoH ports nothing listens on
|
||||||
|
// at the addresses (0 when none is free).
|
||||||
|
func FreeDoHPort(st *model.State, addresses []string, jarld []string) int {
|
||||||
|
for _, p := range []int{443, 8443, 4443, 9443} {
|
||||||
|
if c, err := PortCheck(st, addresses, []int{p}, jarld); err == nil && len(c) == 0 {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ViewSet is what one view has for a name and type.
|
||||||
|
type ViewSet struct {
|
||||||
|
View string `json:"view"`
|
||||||
|
ZoneID int64 `json:"zone_id"`
|
||||||
|
Records []string `json:"records"` // data (with TTL and "(disabled)" when set), sorted
|
||||||
|
IDs []int64 `json:"ids"` // the records, in the same order
|
||||||
|
Linked string `json:"linked,omitempty"` // shared or local, in a linked zone
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompareRow is one name and type across views.
|
||||||
|
type CompareRow struct {
|
||||||
|
Name string `json:"name"` // relative to the zone
|
||||||
|
FQDN string `json:"fqdn"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Same bool `json:"same"`
|
||||||
|
Views []ViewSet `json:"views"` // in the order of the zones given
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompareZones lines up the records of zones of the same name in
|
||||||
|
// different views (docs/views.md#linked-zones), one row per name and type,
|
||||||
|
// sorted like a zone file.
|
||||||
|
func CompareZones(st *model.State, zones []*model.Zone) []CompareRow {
|
||||||
|
type key struct{ name, typ string }
|
||||||
|
sets := make([]map[key][]*model.Record, len(zones))
|
||||||
|
var keys []key
|
||||||
|
seen := map[key]bool{}
|
||||||
|
var all []*model.Record
|
||||||
|
for i, z := range zones {
|
||||||
|
sets[i] = map[key][]*model.Record{}
|
||||||
|
for _, r := range st.ZoneRecords(z.ID) {
|
||||||
|
k := key{r.Name, r.Type}
|
||||||
|
sets[i][k] = append(sets[i][k], r)
|
||||||
|
if !seen[k] {
|
||||||
|
seen[k] = true
|
||||||
|
keys = append(keys, k)
|
||||||
|
all = append(all, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(zones) > 0 {
|
||||||
|
// Zone file order: the apex first, then by name and type.
|
||||||
|
model.SortRecords(all, zones[0])
|
||||||
|
keys = keys[:0]
|
||||||
|
for _, r := range all {
|
||||||
|
keys = append(keys, key{r.Name, r.Type})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out []CompareRow
|
||||||
|
for _, k := range keys {
|
||||||
|
row := CompareRow{FQDN: k.name, Type: k.typ, Same: true}
|
||||||
|
if len(zones) > 0 {
|
||||||
|
row.Name = relName(k.name, zones[0].Name)
|
||||||
|
}
|
||||||
|
var first string
|
||||||
|
for i, z := range zones {
|
||||||
|
vs := ViewSet{View: st.ViewName(z), ZoneID: z.ID, Records: []string{}, IDs: []int64{}}
|
||||||
|
rs := slices.Clone(sets[i][k])
|
||||||
|
slices.SortFunc(rs, func(a, b *model.Record) int { return strings.Compare(strings.ToLower(a.Data), strings.ToLower(b.Data)) })
|
||||||
|
for _, r := range rs {
|
||||||
|
d := r.Data
|
||||||
|
if r.TTL != nil {
|
||||||
|
d = fmt.Sprintf("%s (TTL %d)", d, *r.TTL)
|
||||||
|
}
|
||||||
|
if r.Disabled {
|
||||||
|
d += " (disabled)"
|
||||||
|
}
|
||||||
|
vs.Records = append(vs.Records, d)
|
||||||
|
vs.IDs = append(vs.IDs, r.ID)
|
||||||
|
}
|
||||||
|
if z.LinkGroup != 0 && len(rs) > 0 {
|
||||||
|
vs.Linked = "shared"
|
||||||
|
if rs[0].SharedID == 0 {
|
||||||
|
vs.Linked = "local"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sig := strings.Join(vs.Records, "\n")
|
||||||
|
if i == 0 {
|
||||||
|
first = sig
|
||||||
|
} else if sig != first {
|
||||||
|
row.Same = false
|
||||||
|
}
|
||||||
|
row.Views = append(row.Views, vs)
|
||||||
|
}
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func relName(name, zone string) string {
|
||||||
|
if name == zone {
|
||||||
|
return "@"
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(name, "."+zone)
|
||||||
|
}
|
||||||
@@ -66,7 +66,7 @@ func DiffOps(from, to *model.State) []model.Op {
|
|||||||
n := to.Zones[k]
|
n := to.Zones[k]
|
||||||
s := n.SOA
|
s := n.SOA
|
||||||
in := ZoneInput{Name: &n.Name, ViewID: &n.ViewID, Type: &n.Type, InView: &n.InView, File: &n.File,
|
in := ZoneInput{Name: &n.Name, ViewID: &n.ViewID, Type: &n.Type, InView: &n.InView, File: &n.File,
|
||||||
DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, DNSSECPolicy: &n.DNSSECPolicy, Comment: &n.Comment, Disabled: &n.Disabled}
|
DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, DNSSECPolicy: &n.DNSSECPolicy, Comment: &n.Comment, Disabled: &n.Disabled, LinkGroup: model.Ptr(n.LinkGroup)}
|
||||||
if n.Type == model.ZonePrimary {
|
if n.Type == model.ZonePrimary {
|
||||||
in.SOA = &SOAInput{MName: &s.MName, RName: &s.RName, Serial: &s.Serial, Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, Minimum: &s.Minimum}
|
in.SOA = &SOAInput{MName: &s.MName, RName: &s.RName, Serial: &s.Serial, Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, Minimum: &s.Minimum}
|
||||||
}
|
}
|
||||||
@@ -94,7 +94,7 @@ func DiffOps(from, to *model.State) []model.Op {
|
|||||||
}
|
}
|
||||||
for _, k := range sortedKeys(to.Records) {
|
for _, k := range sortedKeys(to.Records) {
|
||||||
n := to.Records[k]
|
n := to.Records[k]
|
||||||
in := RecordInput{ZoneID: n.ZoneID, Name: &n.Name, Type: &n.Type, Data: &n.Data, Comment: &n.Comment, Disabled: &n.Disabled}
|
in := RecordInput{ZoneID: n.ZoneID, Name: &n.Name, Type: &n.Type, Data: &n.Data, Comment: &n.Comment, Disabled: &n.Disabled, SharedID: model.Ptr(n.SharedID)}
|
||||||
if n.TTL != nil {
|
if n.TTL != nil {
|
||||||
in.TTL = model.Some(*n.TTL)
|
in.TTL = model.Some(*n.TTL)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+232
-21
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -131,7 +132,24 @@ func dateSerial(now time.Time) uint32 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (x *expander) expand(req Request) ([]model.Op, error) {
|
func (x *expander) expand(req Request) ([]model.Op, error) {
|
||||||
|
ops, err := x.expandOne(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
switch req.Op {
|
switch req.Op {
|
||||||
|
case OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace, OpRecordDisable, OpRecordEnable:
|
||||||
|
// Changes to linked zones reach the other zones of the link.
|
||||||
|
return x.linkSync(ops)
|
||||||
|
}
|
||||||
|
return ops, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *expander) expandOne(req Request) ([]model.Op, error) {
|
||||||
|
switch req.Op {
|
||||||
|
case OpZoneLink:
|
||||||
|
return x.zoneLink(req)
|
||||||
|
case OpZoneUnlink:
|
||||||
|
return x.zoneUnlink(req)
|
||||||
case OpZoneCreate:
|
case OpZoneCreate:
|
||||||
return x.zoneCreate(req)
|
return x.zoneCreate(req)
|
||||||
case OpZoneUpdate, OpZoneDelete:
|
case OpZoneUpdate, OpZoneDelete:
|
||||||
@@ -152,7 +170,24 @@ func (x *expander) expand(req Request) ([]model.Op, error) {
|
|||||||
}
|
}
|
||||||
return []model.Op{x.versioned(req, z.Version, "enable zone "+zoneDesc(x.st, z))}, nil
|
return []model.Op{x.versioned(req, z.Version, "enable zone "+zoneDesc(x.st, z))}, nil
|
||||||
}
|
}
|
||||||
return []model.Op{x.versioned(req, z.Version, "zone "+verb+" "+zoneDesc(x.st, z))}, nil
|
ops := []model.Op{x.versioned(req, z.Version, "zone "+verb+" "+zoneDesc(x.st, z))}
|
||||||
|
if req.Op == OpZoneDelete {
|
||||||
|
if rest := x.st.LinkedWith(z); len(rest) == 1 {
|
||||||
|
ops = append(ops, x.unlinkOps(rest[0], "unlink zone "+zoneDesc(x.st, rest[0])+": the zone it was linked with is deleted")...)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var in ZoneInput
|
||||||
|
if err := decode(req.Data, &in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if in.LinkGroup != nil && *in.LinkGroup != z.LinkGroup {
|
||||||
|
return nil, errf(KindBadRequest, "link zones with zone.link and zone.unlink (jarl zone link / unlink)")
|
||||||
|
}
|
||||||
|
if in.LinkWith != "" {
|
||||||
|
return nil, errf(KindBadRequest, "link_with is for new zones; link existing ones with zone.link (jarl zone link)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ops, nil
|
||||||
case OpRecordCreate:
|
case OpRecordCreate:
|
||||||
return x.recordCreate(req)
|
return x.recordCreate(req)
|
||||||
case OpRecordUpdate:
|
case OpRecordUpdate:
|
||||||
@@ -355,7 +390,25 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
|
|||||||
records := in.Records
|
records := in.Records
|
||||||
cloneFrom := in.CloneFrom
|
cloneFrom := in.CloneFrom
|
||||||
zoneOpts := in.Options
|
zoneOpts := in.Options
|
||||||
in.Nameservers, in.Records, in.CloneFrom, in.Options = nil, nil, 0, nil
|
linkWith := in.LinkWith
|
||||||
|
in.Nameservers, in.Records, in.CloneFrom, in.Options, in.LinkWith, in.LinkGroup = nil, nil, 0, nil, "", nil
|
||||||
|
if linkWith != "" {
|
||||||
|
if cloneFrom != 0 {
|
||||||
|
return nil, errf(KindBadRequest, "give clone_from or link_with, not both")
|
||||||
|
}
|
||||||
|
v := x.st.ViewByName(linkWith)
|
||||||
|
if v == nil {
|
||||||
|
return nil, errf(KindNotFound, "view %q does not exist", linkWith)
|
||||||
|
}
|
||||||
|
o := x.st.FindZone(name, v.ID)
|
||||||
|
if o == nil {
|
||||||
|
return nil, errf(KindNotFound, "view %s has no zone %s to link with", v.Name, dnsname.Display(name))
|
||||||
|
}
|
||||||
|
if o.Type != model.ZonePrimary || zt != model.ZonePrimary {
|
||||||
|
return nil, errf(KindBadRequest, "only primary zones can be linked")
|
||||||
|
}
|
||||||
|
cloneFrom = o.ID
|
||||||
|
}
|
||||||
|
|
||||||
set := x.st.Settings
|
set := x.st.Settings
|
||||||
var src *model.Zone
|
var src *model.Zone
|
||||||
@@ -480,9 +533,30 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var linkOps []model.Op
|
||||||
|
if src != nil && linkWith != "" {
|
||||||
|
// The new zone joins src's link, or starts one with src: then
|
||||||
|
// all of src's records become shared.
|
||||||
|
group := src.LinkGroup
|
||||||
|
if group == 0 {
|
||||||
|
group = src.ID
|
||||||
|
linkOps = x.shareAllOps(src, group)
|
||||||
|
}
|
||||||
|
in.LinkGroup = &group
|
||||||
|
}
|
||||||
if src != nil {
|
if src != nil {
|
||||||
for _, r := range x.st.ZoneRecords(src.ID) {
|
for _, r := range x.st.ZoneRecords(src.ID) {
|
||||||
|
if linkWith != "" && src.LinkGroup != 0 && r.SharedID == 0 {
|
||||||
|
continue // local to src's view
|
||||||
|
}
|
||||||
ri := RecordInput{Name: model.Ptr(r.Name), Type: model.Ptr(r.Type), Data: model.Ptr(r.Data), Comment: model.Ptr(r.Comment)}
|
ri := RecordInput{Name: model.Ptr(r.Name), Type: model.Ptr(r.Type), Data: model.Ptr(r.Data), Comment: model.Ptr(r.Comment)}
|
||||||
|
if linkWith != "" {
|
||||||
|
id := r.SharedID
|
||||||
|
if id == 0 {
|
||||||
|
id = r.ID
|
||||||
|
}
|
||||||
|
ri.SharedID = &id
|
||||||
|
}
|
||||||
if r.Disabled {
|
if r.Disabled {
|
||||||
ri.Disabled = model.Ptr(true)
|
ri.Disabled = model.Ptr(true)
|
||||||
}
|
}
|
||||||
@@ -512,10 +586,12 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
|
|||||||
summary += " in view " + v.Name
|
summary += " in view " + v.Name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if src != nil {
|
if src != nil && linkWith != "" {
|
||||||
|
summary += " (linked with " + zoneDesc(x.st, src) + ")"
|
||||||
|
} else if src != nil {
|
||||||
summary += " (copy of " + zoneDesc(x.st, src) + ")"
|
summary += " (copy of " + zoneDesc(x.st, src) + ")"
|
||||||
}
|
}
|
||||||
ops := []model.Op{newOp(OpZoneCreate, ids[0], 0, in, summary)}
|
ops := append(linkOps, newOp(OpZoneCreate, ids[0], 0, in, summary))
|
||||||
for i, zo := range zoneOpts {
|
for i, zo := range zoneOpts {
|
||||||
oi := OptionInput{Scope: model.Ptr(model.ScopeZone), ScopeID: model.Ptr(ids[0]), Name: model.Ptr(zo.Name), Value: model.Ptr(zo.Value)}
|
oi := OptionInput{Scope: model.Ptr(model.ScopeZone), ScopeID: model.Ptr(ids[0]), Name: model.Ptr(zo.Name), Value: model.Ptr(zo.Value)}
|
||||||
if zo.Comment != "" {
|
if zo.Comment != "" {
|
||||||
@@ -567,11 +643,42 @@ func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed) ([]model
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
|
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
|
||||||
}
|
}
|
||||||
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
|
var ops []model.Op
|
||||||
if rz == nil {
|
found := false
|
||||||
x.warnings = append(x.warnings, fmt.Sprintf("no reverse zone for %s in this view, so no PTR record was added", addr))
|
for _, vid := range ptrViews(x.st, z) {
|
||||||
return nil, nil
|
rz, ptrName := reverseZoneFor(x.st, vid, addr)
|
||||||
|
if rz == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
more, err := x.ptrCreateIn(rz, ptrName, owner)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ops = append(ops, more...)
|
||||||
}
|
}
|
||||||
|
if !found {
|
||||||
|
x.warnings = append(x.warnings, fmt.Sprintf("no reverse zone for %s in this view, so no PTR record was added (create one: jarl zone create %s/%d)", addr, addr, addr.BitLen()))
|
||||||
|
}
|
||||||
|
return ops, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ptrViews are the views whose reverse zones get the PTR records of zone
|
||||||
|
// z's address records: z's own, and, for a linked zone, the other views
|
||||||
|
// of the link (the record is in all of them).
|
||||||
|
func ptrViews(st *model.State, z *model.Zone) []int64 {
|
||||||
|
out := []int64{z.ViewID}
|
||||||
|
for _, o := range st.LinkedWith(z) {
|
||||||
|
if !slices.Contains(out, o.ViewID) {
|
||||||
|
out = append(out, o.ViewID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ptrCreateIn returns operations creating the PTR record ptrName -> owner
|
||||||
|
// in reverse zone rz (none when it is there; enabling it when disabled).
|
||||||
|
func (x *expander) ptrCreateIn(rz *model.Zone, ptrName, owner string) ([]model.Op, error) {
|
||||||
for _, r := range x.st.ZoneRecords(rz.ID) {
|
for _, r := range x.st.ZoneRecords(rz.ID) {
|
||||||
if r.Name == ptrName && r.Type == "PTR" {
|
if r.Name == ptrName && r.Type == "PTR" {
|
||||||
if strings.EqualFold(r.Data, owner) {
|
if strings.EqualFold(r.Data, owner) {
|
||||||
@@ -599,10 +706,18 @@ func (x *expander) ptrSetDisabled(z *model.Zone, owner string, p *rr.Parsed, dis
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
|
var ops []model.Op
|
||||||
if rz == nil {
|
for _, vid := range ptrViews(x.st, z) {
|
||||||
return nil
|
rz, ptrName := reverseZoneFor(x.st, vid, addr)
|
||||||
|
if rz == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ops = append(ops, x.ptrSetDisabledIn(rz, ptrName, owner, disabled)...)
|
||||||
}
|
}
|
||||||
|
return ops
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *expander) ptrSetDisabledIn(rz *model.Zone, ptrName, owner string, disabled bool) []model.Op {
|
||||||
var ops []model.Op
|
var ops []model.Op
|
||||||
for _, r := range x.st.ZoneRecords(rz.ID) {
|
for _, r := range x.st.ZoneRecords(rz.ID) {
|
||||||
if r.Name == ptrName && r.Type == "PTR" && strings.EqualFold(r.Data, owner) && r.Disabled != disabled {
|
if r.Name == ptrName && r.Type == "PTR" && strings.EqualFold(r.Data, owner) && r.Disabled != disabled {
|
||||||
@@ -625,12 +740,16 @@ func (x *expander) ptrDelete(z *model.Zone, owner string, p *rr.Parsed) []model.
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
|
var ops []model.Op
|
||||||
if rz == nil {
|
for _, vid := range ptrViews(x.st, z) {
|
||||||
return nil
|
rz, ptrName := reverseZoneFor(x.st, vid, addr)
|
||||||
|
if rz == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
in := RecordInput{ZoneID: rz.ID, Name: model.Ptr(ptrName), Type: model.Ptr("PTR"), Data: model.Ptr(owner), MissingOK: true}
|
||||||
|
ops = append(ops, newOp(OpRecordDelete, 0, 0, in, fmt.Sprintf("delete %s IN PTR %s (if present)", ptrName, owner)))
|
||||||
}
|
}
|
||||||
in := RecordInput{ZoneID: rz.ID, Name: model.Ptr(ptrName), Type: model.Ptr("PTR"), Data: model.Ptr(owner), MissingOK: true}
|
return ops
|
||||||
return []model.Op{newOp(OpRecordDelete, 0, 0, in, fmt.Sprintf("delete %s IN PTR %s (if present)", ptrName, owner))}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseWarnings keeps what parsing noticed (e.g. a name read as absolute).
|
// parseWarnings keeps what parsing noticed (e.g. a name read as absolute).
|
||||||
@@ -666,7 +785,28 @@ func (x *expander) recordCreate(req Request) ([]model.Op, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
ops := []model.Op{newOp(OpRecordCreate, ids[0], 0, in, fmt.Sprintf("add %s%s IN %s %s", owner, ttlText(in.TTL), p.Type, p.Data))}
|
summary := fmt.Sprintf("add %s%s IN %s %s", owner, ttlText(in.TTL), p.Type, p.Data)
|
||||||
|
in.SharedID = nil
|
||||||
|
if z.LinkGroup != 0 {
|
||||||
|
local, err := x.localFor(z, owner, p.Type, in.Local)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if local {
|
||||||
|
in.SharedID = model.Ptr(int64(0))
|
||||||
|
summary += " (local to view " + x.st.ViewName(z) + ")"
|
||||||
|
} else {
|
||||||
|
if x.st.HasLocal(z.ID, owner, p.Type) {
|
||||||
|
return nil, errf(KindConflict, "%s %s is local in view %s; share the set first (jarl record update ID --shared) or add this one as local", dnsname.Display(owner), p.Type, x.st.ViewName(z))
|
||||||
|
}
|
||||||
|
in.SharedID = &ids[0]
|
||||||
|
}
|
||||||
|
} else if in.Local != nil && !*in.Local {
|
||||||
|
return nil, notLinked(x.st, z)
|
||||||
|
}
|
||||||
|
// local on a zone that isn't linked: its records are local anyway.
|
||||||
|
in.Local = nil
|
||||||
|
ops := []model.Op{newOp(OpRecordCreate, ids[0], 0, in, summary)}
|
||||||
if ptr {
|
if ptr {
|
||||||
more, err := x.ptrCreate(z, owner, p)
|
more, err := x.ptrCreate(z, owner, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -690,6 +830,36 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
|||||||
ptr := in.PTR
|
ptr := in.PTR
|
||||||
in.PTR = false
|
in.PTR = false
|
||||||
in.Zone, in.View = "", ""
|
in.Zone, in.View = "", ""
|
||||||
|
if in.SharedID != nil {
|
||||||
|
return nil, errf(KindBadRequest, "shared_id is managed by Jarl; use local: true or false to make a record local to its view or shared")
|
||||||
|
}
|
||||||
|
var pre []model.Op
|
||||||
|
if in.Local != nil {
|
||||||
|
if z.LinkGroup == 0 && !*in.Local {
|
||||||
|
return nil, notLinked(x.st, z)
|
||||||
|
}
|
||||||
|
if want := *in.Local; z.LinkGroup != 0 && want != (r.SharedID == 0) {
|
||||||
|
// The whole set of this name and type changes sides.
|
||||||
|
for _, o := range x.st.RRset(z.ID, r.Name, r.Type) {
|
||||||
|
id := int64(0)
|
||||||
|
what := fmt.Sprintf("make %s %s local to view %s", o.Name, o.Type, x.st.ViewName(z))
|
||||||
|
if !want {
|
||||||
|
id = o.ID
|
||||||
|
what = fmt.Sprintf("share %s %s %s with the linked views", o.Name, o.Type, o.Data)
|
||||||
|
}
|
||||||
|
pre = append(pre, newOp(OpRecordUpdate, o.ID, o.Version, RecordInput{SharedID: model.Ptr(id)}, what))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if z.LinkGroup != 0 && !*in.Local {
|
||||||
|
// Sharing a version makes it every view's, also when it is
|
||||||
|
// shared already and another view overrides it.
|
||||||
|
pre = append(pre, x.dropOverrides(z, r.Name, r.Type)...)
|
||||||
|
}
|
||||||
|
in.Local = nil
|
||||||
|
if string(mustJSON(in)) == "{}" {
|
||||||
|
return pre, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
name, typ, data := r.Name, r.Type, r.Data
|
name, typ, data := r.Name, r.Type, r.Data
|
||||||
if in.Name != nil {
|
if in.Name != nil {
|
||||||
name = *in.Name
|
name = *in.Name
|
||||||
@@ -751,11 +921,20 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
|||||||
case len(changes) > 0:
|
case len(changes) > 0:
|
||||||
summary += ": " + strings.Join(changes, ", ")
|
summary += ": " + strings.Join(changes, ", ")
|
||||||
}
|
}
|
||||||
|
if z.LinkGroup != 0 && r.SharedID != 0 && len(pre) == 0 && (owner != r.Name || p.Type != r.Type) && x.st.HasLocal(z.ID, owner, p.Type) {
|
||||||
|
return nil, errf(KindConflict, "%s %s is local in view %s, so a shared record can't move there", dnsname.Display(owner), p.Type, x.st.ViewName(z))
|
||||||
|
}
|
||||||
base := req.BaseVersion
|
base := req.BaseVersion
|
||||||
if base == 0 {
|
if base == 0 {
|
||||||
base = r.Version
|
base = r.Version
|
||||||
}
|
}
|
||||||
ops := []model.Op{newOp(OpRecordUpdate, r.ID, base, in, summary)}
|
if len(pre) > 0 {
|
||||||
|
base = 0 // the record was just updated by pre
|
||||||
|
}
|
||||||
|
ops := pre
|
||||||
|
if string(mustJSON(in)) != "{}" || !ptr {
|
||||||
|
ops = append(ops, newOp(OpRecordUpdate, r.ID, base, in, summary))
|
||||||
|
}
|
||||||
disabled := r.Disabled
|
disabled := r.Disabled
|
||||||
if in.Disabled != nil {
|
if in.Disabled != nil {
|
||||||
disabled = *in.Disabled
|
disabled = *in.Disabled
|
||||||
@@ -779,6 +958,15 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
|||||||
}
|
}
|
||||||
ops = append(ops, x.ptrSetDisabled(z, owner, p, disabled)...)
|
ops = append(ops, x.ptrSetDisabled(z, owner, p, disabled)...)
|
||||||
}
|
}
|
||||||
|
if ptr && !moved && !toggled && !disabled {
|
||||||
|
// Nothing else changes: make sure the PTR record is there (the
|
||||||
|
// reverse zone may be newer than the record).
|
||||||
|
more, err := x.ptrCreate(z, owner, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ops = append(ops, more...)
|
||||||
|
}
|
||||||
return ops, nil
|
return ops, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -953,17 +1141,35 @@ func (x *expander) rrsetReplace(req Request) ([]model.Op, error) {
|
|||||||
canon = append(canon, p.Data)
|
canon = append(canon, p.Data)
|
||||||
}
|
}
|
||||||
in.ZoneID, in.Zone, in.View, in.Name, in.Type, in.Data = z.ID, "", "", owner, typ, canon
|
in.ZoneID, in.Zone, in.View, in.Name, in.Type, in.Data = z.ID, "", "", owner, typ, canon
|
||||||
|
where := ""
|
||||||
|
var pre []model.Op
|
||||||
|
if z.LinkGroup != 0 {
|
||||||
|
local, err := x.localFor(z, owner, typ, in.Local)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
in.Shared = model.Ptr(!local)
|
||||||
|
if local {
|
||||||
|
where = " (local to view " + x.st.ViewName(z) + ")"
|
||||||
|
} else if in.Local != nil {
|
||||||
|
// Explicitly shared: this version becomes every view's.
|
||||||
|
pre = x.dropOverrides(z, owner, typ)
|
||||||
|
}
|
||||||
|
} else if in.Local != nil && !*in.Local {
|
||||||
|
return nil, notLinked(x.st, z)
|
||||||
|
}
|
||||||
|
in.Local = nil
|
||||||
if len(canon) > 0 {
|
if len(canon) > 0 {
|
||||||
in.NewIDs, err = x.ids(len(canon))
|
in.NewIDs, err = x.ids(len(canon))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
summary := fmt.Sprintf("set %s%s IN %s to [%s]", owner, ttlText(in.TTL), typ, strings.Join(canon, ", "))
|
summary := fmt.Sprintf("set %s%s IN %s to [%s]%s", owner, ttlText(in.TTL), typ, strings.Join(canon, ", "), where)
|
||||||
if len(canon) == 0 {
|
if len(canon) == 0 {
|
||||||
summary = fmt.Sprintf("delete all %s records of %s", typ, owner)
|
summary = fmt.Sprintf("delete all %s records of %s%s", typ, owner, where)
|
||||||
}
|
}
|
||||||
return []model.Op{newOp(OpRRSetReplace, 0, 0, in, summary)}, nil
|
return append(pre, newOp(OpRRSetReplace, 0, 0, in, summary)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *expander) viewCreate(req Request) ([]model.Op, error) {
|
func (x *expander) viewCreate(req Request) ([]model.Op, error) {
|
||||||
@@ -1152,3 +1358,8 @@ func firstNonEmptyStr(a, b string) string {
|
|||||||
}
|
}
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// notLinked refuses sharing a record of a zone that isn't linked.
|
||||||
|
func notLinked(st *model.State, z *model.Zone) error {
|
||||||
|
return errf(KindBadRequest, "zone %s isn't linked with other views, so there is nothing to share its records with (link it first: jarl zone link)", zoneDesc(st, z))
|
||||||
|
}
|
||||||
@@ -55,6 +55,10 @@ const (
|
|||||||
// back, become view.update or settings.update operations.
|
// back, become view.update or settings.update operations.
|
||||||
OpExposureAck = "exposure.ack"
|
OpExposureAck = "exposure.ack"
|
||||||
OpExposureUnack = "exposure.unack"
|
OpExposureUnack = "exposure.unack"
|
||||||
|
// Linked zones (docs/views.md#linked-zones): requests that expand
|
||||||
|
// into zone and record updates.
|
||||||
|
OpZoneLink = "zone.link"
|
||||||
|
OpZoneUnlink = "zone.unlink"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AckInput acknowledges (or un-acknowledges) an exposure finding of a view
|
// AckInput acknowledges (or un-acknowledges) an exposure finding of a view
|
||||||
@@ -131,6 +135,13 @@ type ZoneInput struct {
|
|||||||
Comment *string `json:"comment,omitempty"`
|
Comment *string `json:"comment,omitempty"`
|
||||||
// Disabled keeps the zone in Jarl but out of named's configuration.
|
// Disabled keeps the zone in Jarl but out of named's configuration.
|
||||||
Disabled *bool `json:"disabled,omitempty"`
|
Disabled *bool `json:"disabled,omitempty"`
|
||||||
|
// LinkGroup is the zone's link group (0: not linked). Set by zone.link
|
||||||
|
// and zone.unlink; requests use those.
|
||||||
|
LinkGroup *int64 `json:"link_group,omitempty"`
|
||||||
|
// LinkWith (create only) links the new zone with the zone of the same
|
||||||
|
// name in this view, copying its shared records (or all of its
|
||||||
|
// records, when it isn't linked yet).
|
||||||
|
LinkWith string `json:"link_with,omitempty"`
|
||||||
|
|
||||||
// Create only: name servers for the apex NS records (default: the
|
// Create only: name servers for the apex NS records (default: the
|
||||||
// configured default name servers), additional records, and a zone to
|
// configured default name servers), additional records, and a zone to
|
||||||
@@ -163,10 +174,31 @@ type RecordInput struct {
|
|||||||
// Disabled keeps the record in Jarl but out of the zone.
|
// Disabled keeps the record in Jarl but out of the zone.
|
||||||
Disabled *bool `json:"disabled,omitempty"`
|
Disabled *bool `json:"disabled,omitempty"`
|
||||||
// PTR also creates (or deletes, disables or enables) the matching PTR
|
// PTR also creates (or deletes, disables or enables) the matching PTR
|
||||||
// record in the reverse zone, for A and AAAA records.
|
// record in the reverse zone, for A and AAAA records. On an update that
|
||||||
|
// changes nothing else it adds the PTR record if it is missing. In a
|
||||||
|
// linked zone, every linked view's reverse zone counts.
|
||||||
PTR bool `json:"ptr,omitempty"`
|
PTR bool `json:"ptr,omitempty"`
|
||||||
// MissingOK makes a delete-by-match succeed when nothing matches.
|
// MissingOK makes a delete-by-match succeed when nothing matches.
|
||||||
MissingOK bool `json:"missing_ok,omitempty"`
|
MissingOK bool `json:"missing_ok,omitempty"`
|
||||||
|
// Local (requests, linked zones): true keeps the record in this view
|
||||||
|
// only (an override: the whole name and type becomes local here);
|
||||||
|
// false shares it with the linked zones. Unset: like the records of
|
||||||
|
// the same name and type, shared when there are none.
|
||||||
|
Local *bool `json:"local,omitempty"`
|
||||||
|
// SharedID (stored operations) ties the copies of a shared record
|
||||||
|
// together; 0 makes it local.
|
||||||
|
SharedID *int64 `json:"shared_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkInput links zones of the same name in several views, or (unlink)
|
||||||
|
// takes one out of its link.
|
||||||
|
type LinkInput struct {
|
||||||
|
ZoneID int64 `json:"zone_id,omitempty"`
|
||||||
|
Zone string `json:"zone,omitempty"`
|
||||||
|
View string `json:"view,omitempty"`
|
||||||
|
// With are the views whose zone of the same name to link with (link
|
||||||
|
// only; default: every view that has one).
|
||||||
|
With []string `json:"with,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RRSetInput replaces all records of one name and type.
|
// RRSetInput replaces all records of one name and type.
|
||||||
@@ -181,6 +213,11 @@ type RRSetInput struct {
|
|||||||
Comment *string `json:"comment,omitempty"`
|
Comment *string `json:"comment,omitempty"`
|
||||||
// NewIDs are allocated for records that need to be created.
|
// NewIDs are allocated for records that need to be created.
|
||||||
NewIDs []int64 `json:"new_ids,omitempty"`
|
NewIDs []int64 `json:"new_ids,omitempty"`
|
||||||
|
// Local (requests): like RecordInput.Local, for the whole set.
|
||||||
|
Local *bool `json:"local,omitempty"`
|
||||||
|
// Shared (stored operations, linked zones): the records are shared
|
||||||
|
// (true) or local (false).
|
||||||
|
Shared *bool `json:"shared,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ViewInput creates or updates a view.
|
// ViewInput creates or updates a view.
|
||||||
|
|||||||
@@ -0,0 +1,476 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
// Linked zones (docs/views.md#linked-zones). Zones of the same name in
|
||||||
|
// different views can be linked: their shared records are kept in step,
|
||||||
|
// and each zone can override a name and type with records local to its
|
||||||
|
// view. Every zone keeps its own copy of each shared record (with the
|
||||||
|
// same SharedID), so the rest of Jarl sees ordinary zones.
|
||||||
|
//
|
||||||
|
// Keeping them in step happens when requests are expanded: the request's
|
||||||
|
// operations are applied to a scratch copy, and linkSync adds explicit
|
||||||
|
// operations for the other zones of the link. So a change to a shared
|
||||||
|
// record is one changeset with one operation per zone: the diff, the
|
||||||
|
// permission checks, history and undo all see every zone.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"jarl/internal/dnsname"
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type rrKey struct {
|
||||||
|
zone int64
|
||||||
|
name, typ string
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkSync returns ops followed by the operations that bring the other
|
||||||
|
// zones of each affected link in step.
|
||||||
|
func (x *expander) linkSync(ops []model.Op) ([]model.Op, error) {
|
||||||
|
if !x.st.AnyLinked() {
|
||||||
|
return ops, nil
|
||||||
|
}
|
||||||
|
work := x.st.Clone()
|
||||||
|
c := &applyCtx{p: x.p, now: x.now}
|
||||||
|
for _, op := range ops {
|
||||||
|
if err := applyOp(work, op, c); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys := affectedKeys(x.st, work, ops)
|
||||||
|
var extra []model.Op
|
||||||
|
for _, k := range keys {
|
||||||
|
z := work.Zones[k.zone]
|
||||||
|
if z == nil || z.LinkGroup == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
more, err := x.syncKey(work, z, k.name, k.typ)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, op := range more {
|
||||||
|
if err := applyOp(work, op, c); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
extra = append(extra, more...)
|
||||||
|
}
|
||||||
|
return append(ops, extra...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// affectedKeys lists the (zone, name, type) sets that ops touch, before
|
||||||
|
// and after.
|
||||||
|
func affectedKeys(pre, post *model.State, ops []model.Op) []rrKey {
|
||||||
|
var out []rrKey
|
||||||
|
seen := map[rrKey]bool{}
|
||||||
|
add := func(zone int64, name, typ string) {
|
||||||
|
k := rrKey{zone, name, typ}
|
||||||
|
if zone != 0 && name != "" && !seen[k] {
|
||||||
|
seen[k] = true
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fromRecord := func(id int64) {
|
||||||
|
for _, st := range []*model.State{pre, post} {
|
||||||
|
if r := st.Records[id]; r != nil {
|
||||||
|
add(r.ZoneID, r.Name, r.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, op := range ops {
|
||||||
|
switch op.Kind {
|
||||||
|
case OpRecordCreate, OpRecordUpdate, OpRecordDelete:
|
||||||
|
if op.ID != 0 {
|
||||||
|
fromRecord(op.ID)
|
||||||
|
}
|
||||||
|
if op.Kind == OpRecordDelete && op.ID == 0 {
|
||||||
|
var in RecordInput
|
||||||
|
if decode(op.Data, &in) == nil && in.Name != nil && in.Type != nil {
|
||||||
|
add(in.ZoneID, *in.Name, *in.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case OpRRSetReplace:
|
||||||
|
var in RRSetInput
|
||||||
|
if decode(op.Data, &in) == nil {
|
||||||
|
add(in.ZoneID, in.Name, in.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncKey brings the other zones of z's link in step with z for one name
|
||||||
|
// and type, after z was changed:
|
||||||
|
//
|
||||||
|
// - z overrides it (local records): z's shared copies go; the others
|
||||||
|
// keep theirs.
|
||||||
|
// - z's override was just removed: z gets the shared records back from
|
||||||
|
// a zone that has them.
|
||||||
|
// - otherwise z's shared records are the truth: every zone that doesn't
|
||||||
|
// override the name and type gets the same (created, updated or
|
||||||
|
// deleted to match).
|
||||||
|
func (x *expander) syncKey(st *model.State, z *model.Zone, name, typ string) ([]model.Op, error) {
|
||||||
|
var ops []model.Op
|
||||||
|
where := func(o *model.Zone) string { return zoneDesc(st, o) }
|
||||||
|
if st.HasLocal(z.ID, name, typ) {
|
||||||
|
for _, r := range st.SharedRecords(z.ID, name, typ) {
|
||||||
|
ops = append(ops, newOp(OpRecordDelete, r.ID, r.Version, nil,
|
||||||
|
fmt.Sprintf("linked: %s %s is local in %s now, so its shared %s goes there", name, typ, where(z), r.Data)))
|
||||||
|
}
|
||||||
|
return ops, nil
|
||||||
|
}
|
||||||
|
truth := st.SharedRecords(z.ID, name, typ)
|
||||||
|
members := st.LinkedWith(z)
|
||||||
|
if len(truth) == 0 && x.st.HasLocal(z.ID, name, typ) {
|
||||||
|
// The override was removed: back to the shared records.
|
||||||
|
for _, o := range members {
|
||||||
|
if st.HasLocal(o.ID, name, typ) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
src := st.SharedRecords(o.ID, name, typ)
|
||||||
|
if len(src) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, r := range src {
|
||||||
|
op, err := x.copyRecord(st, z, r, "linked: "+name+" "+typ+" is shared again in "+where(z)+": add "+r.Data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ops = append(ops, op)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return ops, nil
|
||||||
|
}
|
||||||
|
for _, o := range members {
|
||||||
|
if st.HasLocal(o.ID, name, typ) {
|
||||||
|
continue // o overrides this name and type
|
||||||
|
}
|
||||||
|
copies := map[int64]*model.Record{}
|
||||||
|
for _, r := range st.SharedRecords(o.ID, name, typ) {
|
||||||
|
copies[r.SharedID] = r
|
||||||
|
}
|
||||||
|
var more []model.Op
|
||||||
|
for _, t := range truth {
|
||||||
|
cp := copies[t.SharedID]
|
||||||
|
delete(copies, t.SharedID)
|
||||||
|
if cp == nil {
|
||||||
|
op, err := x.copyRecord(st, o, t, fmt.Sprintf("linked: add %s %s %s in %s", name, typ, t.Data, where(o)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
more = append(more, op)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
in, changes := recordDiff(cp, t)
|
||||||
|
if len(changes) > 0 {
|
||||||
|
more = append(more, newOp(OpRecordUpdate, cp.ID, cp.Version, in,
|
||||||
|
fmt.Sprintf("linked: change %s %s %s in %s: %s", name, typ, cp.Data, where(o), strings.Join(changes, ", "))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, id := range sortedKeys(copies) {
|
||||||
|
cp := copies[id]
|
||||||
|
more = append(more, newOp(OpRecordDelete, cp.ID, cp.Version, nil, fmt.Sprintf("linked: delete %s %s %s in %s", name, typ, cp.Data, where(o))))
|
||||||
|
}
|
||||||
|
if len(more) > 0 && !x.p.Can(recordAction(st, o, name, typ, true)) {
|
||||||
|
return nil, errf(KindForbidden, "%s %s is shared with %s, which you may not change; to change it in %s only, make it local to that view (local: true, or --local)",
|
||||||
|
dnsname.Display(name), typ, where(o), where(z))
|
||||||
|
}
|
||||||
|
ops = append(ops, more...)
|
||||||
|
}
|
||||||
|
return ops, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyRecord returns an operation that creates a copy of shared record r
|
||||||
|
// in zone z.
|
||||||
|
func (x *expander) copyRecord(st *model.State, z *model.Zone, r *model.Record, summary string) (model.Op, error) {
|
||||||
|
if !x.p.Can(recordAction(st, z, r.Name, r.Type, true)) {
|
||||||
|
return model.Op{}, errf(KindForbidden, "%s %s is shared with %s, which you may not change", dnsname.Display(r.Name), r.Type, zoneDesc(st, z))
|
||||||
|
}
|
||||||
|
ids, err := x.ids(1)
|
||||||
|
if err != nil {
|
||||||
|
return model.Op{}, err
|
||||||
|
}
|
||||||
|
return newOp(OpRecordCreate, ids[0], 0, recordCopyInput(z.ID, r), summary), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordCopyInput(zoneID int64, r *model.Record) RecordInput {
|
||||||
|
in := RecordInput{ZoneID: zoneID, Name: model.Ptr(r.Name), Type: model.Ptr(r.Type), Data: model.Ptr(r.Data),
|
||||||
|
Comment: model.Ptr(r.Comment), Disabled: model.Ptr(r.Disabled), SharedID: model.Ptr(r.SharedID)}
|
||||||
|
if r.TTL != nil {
|
||||||
|
in.TTL = model.Some(*r.TTL)
|
||||||
|
} else {
|
||||||
|
in.TTL = model.Opt[uint32]{Set: true, Null: true}
|
||||||
|
}
|
||||||
|
return in
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordDiff returns the update that makes cp like t, and what changes.
|
||||||
|
func recordDiff(cp, t *model.Record) (RecordInput, []string) {
|
||||||
|
var in RecordInput
|
||||||
|
var changes []string
|
||||||
|
if cp.Data != t.Data {
|
||||||
|
in.Data = model.Ptr(t.Data)
|
||||||
|
changes = append(changes, "data "+t.Data)
|
||||||
|
}
|
||||||
|
if !sameTTL(cp.TTL, t.TTL) {
|
||||||
|
if t.TTL != nil {
|
||||||
|
in.TTL = model.Some(*t.TTL)
|
||||||
|
changes = append(changes, fmt.Sprintf("ttl %d", *t.TTL))
|
||||||
|
} else {
|
||||||
|
in.TTL = model.Opt[uint32]{Set: true, Null: true}
|
||||||
|
changes = append(changes, "ttl default")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cp.Comment != t.Comment {
|
||||||
|
in.Comment = model.Ptr(t.Comment)
|
||||||
|
changes = append(changes, "comment")
|
||||||
|
}
|
||||||
|
if cp.Disabled != t.Disabled {
|
||||||
|
in.Disabled = model.Ptr(t.Disabled)
|
||||||
|
changes = append(changes, enableWord(t.Disabled)+"d")
|
||||||
|
}
|
||||||
|
return in, changes
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkTarget resolves the zone a link request names.
|
||||||
|
func (x *expander) linkTarget(in LinkInput) (*model.Zone, error) {
|
||||||
|
z, err := x.resolveZone(in.ZoneID, in.Zone, in.View)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if z.Type != model.ZonePrimary {
|
||||||
|
return nil, errf(KindBadRequest, "only primary zones can be linked (%s is %s)", zoneDesc(x.st, z), z.Type)
|
||||||
|
}
|
||||||
|
return z, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// zoneLink links zones of the same name in several views. It never
|
||||||
|
// changes what named answers: a name and type with the same records in
|
||||||
|
// every zone becomes shared; anything that differs stays local to each
|
||||||
|
// view (and can be shared later, from the view whose version is right).
|
||||||
|
func (x *expander) zoneLink(req Request) ([]model.Op, error) {
|
||||||
|
var in LinkInput
|
||||||
|
if err := decode(req.Data, &in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
z, err := x.linkTarget(in)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
members := []*model.Zone{z}
|
||||||
|
add := func(o *model.Zone) {
|
||||||
|
if !slices.ContainsFunc(members, func(m *model.Zone) bool { return m.ID == o.ID }) {
|
||||||
|
members = append(members, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(in.With) == 0 {
|
||||||
|
for _, o := range x.st.ZonesNamed(z.Name) {
|
||||||
|
if o.Type == model.ZonePrimary && o.ID != z.ID {
|
||||||
|
add(o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, vn := range in.With {
|
||||||
|
v := x.st.ViewByName(vn)
|
||||||
|
if v == nil {
|
||||||
|
return nil, errf(KindNotFound, "view %q does not exist", vn)
|
||||||
|
}
|
||||||
|
o := x.st.FindZone(z.Name, v.ID)
|
||||||
|
if o == nil {
|
||||||
|
return nil, errf(KindNotFound, "view %s has no zone %s (create it linked: jarl zone create %s --view %s --link-with %s)", v.Name, dnsname.Display(z.Name), dnsname.Display(z.Name), v.Name, x.st.ViewName(z))
|
||||||
|
}
|
||||||
|
if o.Type != model.ZonePrimary {
|
||||||
|
return nil, errf(KindBadRequest, "only primary zones can be linked (%s is %s)", zoneDesc(x.st, o), o.Type)
|
||||||
|
}
|
||||||
|
add(o)
|
||||||
|
}
|
||||||
|
// Zones already linked with any of them join too.
|
||||||
|
for _, m := range slices.Clone(members) {
|
||||||
|
for _, o := range x.st.LinkMembers(m.LinkGroup) {
|
||||||
|
add(o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(members) < 2 {
|
||||||
|
return nil, errf(KindBadRequest, "zone %s exists in no other view to link it with", dnsname.Display(z.Name))
|
||||||
|
}
|
||||||
|
x.st.SortByView(members)
|
||||||
|
group := members[0].ID
|
||||||
|
for _, m := range members {
|
||||||
|
if m.LinkGroup != 0 {
|
||||||
|
group = m.LinkGroup
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What is the same everywhere?
|
||||||
|
type key struct{ name, typ string }
|
||||||
|
sig := func(rs []*model.Record) string {
|
||||||
|
var parts []string
|
||||||
|
for _, r := range rs {
|
||||||
|
ttl := "-"
|
||||||
|
if r.TTL != nil {
|
||||||
|
ttl = fmt.Sprint(*r.TTL)
|
||||||
|
}
|
||||||
|
parts = append(parts, fmt.Sprintf("%s|%s|%v", strings.ToLower(r.Data), ttl, r.Disabled))
|
||||||
|
}
|
||||||
|
slices.Sort(parts)
|
||||||
|
return strings.Join(parts, "\n")
|
||||||
|
}
|
||||||
|
keys := map[key]bool{}
|
||||||
|
var order []key
|
||||||
|
for _, m := range members {
|
||||||
|
for _, r := range x.st.ZoneRecords(m.ID) {
|
||||||
|
k := key{r.Name, r.Type}
|
||||||
|
if !keys[k] {
|
||||||
|
keys[k] = true
|
||||||
|
order = append(order, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var ops []model.Op
|
||||||
|
var local []string
|
||||||
|
shared := 0
|
||||||
|
for _, k := range order {
|
||||||
|
first := x.st.RRset(members[0].ID, k.name, k.typ)
|
||||||
|
same := len(first) > 0
|
||||||
|
for _, m := range members[1:] {
|
||||||
|
if rs := x.st.RRset(m.ID, k.name, k.typ); len(rs) == 0 || sig(rs) != sig(first) {
|
||||||
|
same = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if same {
|
||||||
|
shared++
|
||||||
|
} else {
|
||||||
|
var in []string
|
||||||
|
for _, m := range members {
|
||||||
|
if len(x.st.RRset(m.ID, k.name, k.typ)) > 0 {
|
||||||
|
in = append(in, x.st.ViewName(m))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
local = append(local, fmt.Sprintf("%s %s (%s)", dnsname.Display(k.name), k.typ, strings.Join(in, ", ")))
|
||||||
|
}
|
||||||
|
// Pair the copies: record i of the first zone's set (by data) with
|
||||||
|
// the same data in the others.
|
||||||
|
byData := func(rs []*model.Record) []*model.Record {
|
||||||
|
out := slices.Clone(rs)
|
||||||
|
slices.SortStableFunc(out, func(a, b *model.Record) int { return strings.Compare(strings.ToLower(a.Data), strings.ToLower(b.Data)) })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
ref := byData(first)
|
||||||
|
for _, m := range members {
|
||||||
|
rs := byData(x.st.RRset(m.ID, k.name, k.typ))
|
||||||
|
for i, r := range rs {
|
||||||
|
want := int64(0)
|
||||||
|
var comment *string
|
||||||
|
if same {
|
||||||
|
want = ref[i].ID
|
||||||
|
if r.Comment != ref[i].Comment && ref[i].Comment != "" {
|
||||||
|
comment = model.Ptr(ref[i].Comment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.SharedID == want && comment == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
in := RecordInput{SharedID: model.Ptr(want), Comment: comment}
|
||||||
|
what := "local to view " + x.st.ViewName(m)
|
||||||
|
if same {
|
||||||
|
what = "shared"
|
||||||
|
}
|
||||||
|
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, in, fmt.Sprintf("%s %s %s: %s", r.Name, r.Type, r.Data, what)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var views []string
|
||||||
|
for _, m := range members {
|
||||||
|
views = append(views, x.st.ViewName(m))
|
||||||
|
}
|
||||||
|
var zoneOps []model.Op
|
||||||
|
for _, m := range members {
|
||||||
|
if m.LinkGroup != group {
|
||||||
|
zoneOps = append(zoneOps, newOp(OpZoneUpdate, m.ID, m.Version, ZoneInput{LinkGroup: model.Ptr(group)},
|
||||||
|
fmt.Sprintf("link zone %s in views %s", zoneDesc(x.st, m), strings.Join(views, ", "))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(local) > 0 {
|
||||||
|
x.warnings = append(x.warnings, fmt.Sprintf("%d names differ between the views and stay local to each: %s. Share one from the view whose version is right (jarl record update ID --shared, or Share in the web UI).", len(local), strings.Join(local, "; ")))
|
||||||
|
}
|
||||||
|
x.warnings = append(x.warnings, fmt.Sprintf("%d names and types are shared between views %s", shared, strings.Join(views, ", ")))
|
||||||
|
return append(zoneOps, ops...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// zoneUnlink takes a zone out of its link: its records stay as they are,
|
||||||
|
// all local. A link left with one zone is dissolved.
|
||||||
|
func (x *expander) zoneUnlink(req Request) ([]model.Op, error) {
|
||||||
|
var in LinkInput
|
||||||
|
if err := decode(req.Data, &in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
z, err := x.resolveZone(in.ZoneID, in.Zone, in.View)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if z.LinkGroup == 0 {
|
||||||
|
return nil, errf(KindConflict, "zone %s isn't linked", zoneDesc(x.st, z))
|
||||||
|
}
|
||||||
|
out := []*model.Zone{z}
|
||||||
|
if rest := x.st.LinkedWith(z); len(rest) == 1 {
|
||||||
|
out = append(out, rest[0])
|
||||||
|
}
|
||||||
|
var ops []model.Op
|
||||||
|
for _, m := range out {
|
||||||
|
ops = append(ops, x.unlinkOps(m, "unlink zone "+zoneDesc(x.st, m))...)
|
||||||
|
}
|
||||||
|
return ops, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unlinkOps returns the operations that take zone m out of its link.
|
||||||
|
func (x *expander) unlinkOps(m *model.Zone, summary string) []model.Op {
|
||||||
|
ops := []model.Op{newOp(OpZoneUpdate, m.ID, m.Version, ZoneInput{LinkGroup: model.Ptr(int64(0))}, summary)}
|
||||||
|
for _, r := range x.st.ZoneRecords(m.ID) {
|
||||||
|
if r.SharedID != 0 {
|
||||||
|
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{SharedID: model.Ptr(int64(0))}, fmt.Sprintf("%s %s %s: no longer shared", r.Name, r.Type, r.Data)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ops
|
||||||
|
}
|
||||||
|
|
||||||
|
// localFor works out whether a new record (or set) in zone z is local:
|
||||||
|
// as asked, or like the records of the same name and type already there.
|
||||||
|
func (x *expander) localFor(z *model.Zone, name, typ string, want *bool) (bool, error) {
|
||||||
|
has := x.st.HasLocal(z.ID, name, typ)
|
||||||
|
if want == nil {
|
||||||
|
return has, nil
|
||||||
|
}
|
||||||
|
return *want, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// shareAllOps returns the operations that start a link with zone z: it
|
||||||
|
// joins group and all its records become shared.
|
||||||
|
func (x *expander) shareAllOps(z *model.Zone, group int64) []model.Op {
|
||||||
|
ops := []model.Op{newOp(OpZoneUpdate, z.ID, z.Version, ZoneInput{LinkGroup: model.Ptr(group)}, "link zone "+zoneDesc(x.st, z))}
|
||||||
|
for _, r := range x.st.ZoneRecords(z.ID) {
|
||||||
|
if r.SharedID == 0 {
|
||||||
|
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{SharedID: model.Ptr(r.ID)}, fmt.Sprintf("%s %s %s: shared", r.Name, r.Type, r.Data)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ops
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropOverrides returns the operations that delete the other linked
|
||||||
|
// zones' local records of a name and type, so the shared version reaches
|
||||||
|
// them (sharing a version makes it every view's).
|
||||||
|
func (x *expander) dropOverrides(z *model.Zone, name, typ string) []model.Op {
|
||||||
|
var ops []model.Op
|
||||||
|
for _, o := range x.st.LinkedWith(z) {
|
||||||
|
for _, r := range x.st.RRset(o.ID, name, typ) {
|
||||||
|
if r.SharedID == 0 {
|
||||||
|
ops = append(ops, newOp(OpRecordDelete, r.ID, r.Version, nil, fmt.Sprintf("linked: %s %s %s in %s is replaced by the shared version", r.Name, r.Type, r.Data, zoneDesc(x.st, o))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ops
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/validate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// linkedEnv has views internal and external and zone example.com in
|
||||||
|
// internal with a few records.
|
||||||
|
func linkedEnv(t *testing.T) *testEnv {
|
||||||
|
env := newEnv(t)
|
||||||
|
env.submit(env.admin, NewRequest(OpSettingsUpdate, 0, SettingsInput{Nameservers: &[]string{"ns1.example.net."}}))
|
||||||
|
env.submit(env.admin,
|
||||||
|
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("internal"), MatchClients: &[]string{"10.0.0.0/8"}}),
|
||||||
|
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("external"), MatchClients: &[]string{"any"}}))
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("internal")}))
|
||||||
|
env.submit(env.admin,
|
||||||
|
recIn("internal", "www", "A", "192.0.2.10"),
|
||||||
|
recIn("internal", "mail", "A", "192.0.2.25"),
|
||||||
|
recIn("internal", "@", "MX", "10 mail"))
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
func recIn(view, name, typ, data string) Request {
|
||||||
|
return NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: view, Name: &name, Type: &typ, Data: &data})
|
||||||
|
}
|
||||||
|
|
||||||
|
// view returns a view's records of example.com as "name type data S|L",
|
||||||
|
// S for shared, L for local.
|
||||||
|
func (env *testEnv) view(name string) []string {
|
||||||
|
env.t.Helper()
|
||||||
|
s := env.state()
|
||||||
|
v := s.ViewByName(name)
|
||||||
|
z := s.FindZone("example.com.", v.ID)
|
||||||
|
var out []string
|
||||||
|
for _, r := range s.ZoneRecords(z.ID) {
|
||||||
|
if r.Type == "NS" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mark := "L"
|
||||||
|
if r.SharedID != 0 {
|
||||||
|
mark = "S"
|
||||||
|
}
|
||||||
|
if r.Disabled {
|
||||||
|
mark += " disabled"
|
||||||
|
}
|
||||||
|
out = append(out, strings.TrimSuffix(r.Name, ".example.com.")+" "+r.Type+" "+r.Data+" "+mark)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (env *testEnv) recordID(view, name, typ string) int64 {
|
||||||
|
env.t.Helper()
|
||||||
|
s := env.state()
|
||||||
|
z := s.FindZone("example.com.", s.ViewByName(view).ID)
|
||||||
|
for _, r := range s.ZoneRecords(z.ID) {
|
||||||
|
if r.Name == name+".example.com." && r.Type == typ {
|
||||||
|
return r.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
env.t.Fatalf("no %s %s in %s", name, typ, view)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func want(t *testing.T, what string, got []string, w ...string) {
|
||||||
|
t.Helper()
|
||||||
|
if !slices.Equal(got, w) {
|
||||||
|
t.Errorf("%s:\n got %q\n want %q", what, got, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkedZones(t *testing.T) {
|
||||||
|
env := linkedEnv(t)
|
||||||
|
// A zone created linked copies everything; both are linked and shared.
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
|
||||||
|
all := []string{"mail A 192.0.2.25 S", "www A 192.0.2.10 S", "example.com. MX 10 mail.example.com. S"}
|
||||||
|
slices.Sort(all)
|
||||||
|
got := env.view("external")
|
||||||
|
slices.Sort(got)
|
||||||
|
want(t, "linked copy", got, all...)
|
||||||
|
s := env.state()
|
||||||
|
if zs := s.ZonesNamed("example.com."); zs[0].LinkGroup == 0 || zs[0].LinkGroup != zs[1].LinkGroup {
|
||||||
|
t.Fatalf("not linked: %+v %+v", zs[0], zs[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adding, changing, disabling and deleting a shared record reaches the other view.
|
||||||
|
env.submit(env.admin, recIn("external", "ftp", "CNAME", "www"))
|
||||||
|
if !slices.Contains(env.view("internal"), "ftp CNAME www.example.com. S") {
|
||||||
|
t.Errorf("shared add didn't reach internal: %q", env.view("internal"))
|
||||||
|
}
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "mail", "A"), RecordInput{Data: model.Ptr("192.0.2.26")}))
|
||||||
|
if !slices.Contains(env.view("external"), "mail A 192.0.2.26 S") {
|
||||||
|
t.Errorf("shared change didn't reach external: %q", env.view("external"))
|
||||||
|
}
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordDisable, 0, RecordInput{Zone: "example.com", View: "external", Name: model.Ptr("ftp"), Type: model.Ptr("CNAME")}))
|
||||||
|
if !slices.Contains(env.view("internal"), "ftp CNAME www.example.com. S disabled") {
|
||||||
|
t.Errorf("disable didn't reach internal: %q", env.view("internal"))
|
||||||
|
}
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordDelete, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("ftp"), Type: model.Ptr("CNAME")}))
|
||||||
|
for _, v := range []string{"internal", "external"} {
|
||||||
|
for _, r := range env.view(v) {
|
||||||
|
if strings.HasPrefix(r, "ftp ") {
|
||||||
|
t.Errorf("delete by match left %s in %s", r, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A local override: www in internal points elsewhere; external keeps
|
||||||
|
// the shared one.
|
||||||
|
res := env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("www"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.10"), Local: model.Ptr(true)}))
|
||||||
|
if !slices.Contains(env.view("internal"), "www A 10.0.0.10 L") || slices.Contains(env.view("internal"), "www A 192.0.2.10 S") {
|
||||||
|
t.Errorf("override: internal %q", env.view("internal"))
|
||||||
|
}
|
||||||
|
if !slices.Contains(env.view("external"), "www A 192.0.2.10 S") {
|
||||||
|
t.Errorf("override changed external: %q", env.view("external"))
|
||||||
|
}
|
||||||
|
_ = res
|
||||||
|
// A new record of an overridden set joins it as local.
|
||||||
|
env.submit(env.admin, recIn("internal", "www", "A", "10.0.0.11"))
|
||||||
|
if !slices.Contains(env.view("internal"), "www A 10.0.0.11 L") || len(env.view("external")) != 3 {
|
||||||
|
t.Errorf("join override: internal %q external %q", env.view("internal"), env.view("external"))
|
||||||
|
}
|
||||||
|
// Changing the shared www in external doesn't touch internal's override.
|
||||||
|
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "external", Name: "www", Type: "A", Data: []string{"192.0.2.11"}}))
|
||||||
|
if !slices.Contains(env.view("external"), "www A 192.0.2.11 S") || slices.Contains(env.view("internal"), "www A 192.0.2.11 S") {
|
||||||
|
t.Errorf("shared set with an override elsewhere: internal %q external %q", env.view("internal"), env.view("external"))
|
||||||
|
}
|
||||||
|
// Removing the override brings the shared records back.
|
||||||
|
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "internal", Name: "www", Type: "A", Data: []string{}}))
|
||||||
|
if !slices.Contains(env.view("internal"), "www A 192.0.2.11 S") {
|
||||||
|
t.Errorf("override removed: internal %q", env.view("internal"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make a shared set local, then share another view's version.
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "mail", "A"), RecordInput{Local: model.Ptr(true)}))
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "mail", "A"), RecordInput{Data: model.Ptr("198.51.100.25")}))
|
||||||
|
if !slices.Contains(env.view("external"), "mail A 198.51.100.25 L") || !slices.Contains(env.view("internal"), "mail A 192.0.2.26 S") {
|
||||||
|
t.Errorf("local edit: internal %q external %q", env.view("internal"), env.view("external"))
|
||||||
|
}
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "mail", "A"), RecordInput{Local: model.Ptr(false)}))
|
||||||
|
if !slices.Contains(env.view("internal"), "mail A 198.51.100.25 S") || slices.Contains(env.view("internal"), "mail A 192.0.2.26 S") {
|
||||||
|
t.Errorf("share external's version: internal %q", env.view("internal"))
|
||||||
|
}
|
||||||
|
if probs := env.e.Validate(env.state()); hasCode(probs, "linked_out_of_sync") || hasCode(probs, "linked_mixed") {
|
||||||
|
t.Errorf("validation: %+v", probs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Undoing a changeset undoes it in both views.
|
||||||
|
r := env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "mail", "A"), RecordInput{Data: model.Ptr("198.51.100.99")}))
|
||||||
|
cs, err := env.e.Revert(env.ctx, r.ChangesetID, env.admin)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := env.e.Apply(env.ctx, cs.ID, env.admin, ApplyOptions{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, v := range []string{"internal", "external"} {
|
||||||
|
if !slices.Contains(env.view(v), "mail A 198.51.100.25 S") {
|
||||||
|
t.Errorf("revert in %s: %q", v, env.view(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlinking keeps every record, all local.
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneUnlink, 0, LinkInput{Zone: "example.com", View: "external"}))
|
||||||
|
for _, z := range env.state().ZonesNamed("example.com.") {
|
||||||
|
if z.LinkGroup != 0 {
|
||||||
|
t.Errorf("still linked: %s", zoneDesc(env.state(), z))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, v := range []string{"internal", "external"} {
|
||||||
|
for _, r := range env.view(v) {
|
||||||
|
if strings.HasSuffix(r, " S") {
|
||||||
|
t.Errorf("still shared after unlink in %s: %s", v, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
env.submit(env.admin, recIn("internal", "solo", "A", "10.0.0.5"))
|
||||||
|
if slices.Contains(env.view("external"), "solo A 10.0.0.5 L") {
|
||||||
|
t.Error("an unlinked zone was changed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linking again works out what is shared: same in both, or not.
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneLink, 0, LinkInput{Zone: "example.com", View: "internal"}))
|
||||||
|
in, ex := env.view("internal"), env.view("external")
|
||||||
|
if !slices.Contains(in, "mail A 198.51.100.25 S") || !slices.Contains(ex, "mail A 198.51.100.25 S") {
|
||||||
|
t.Errorf("relink: same records not shared: internal %q external %q", in, ex)
|
||||||
|
}
|
||||||
|
if !slices.Contains(in, "solo A 10.0.0.5 L") {
|
||||||
|
t.Errorf("relink: internal-only record should be local: %q", in)
|
||||||
|
}
|
||||||
|
if probs := env.e.Validate(env.state()); hasCode(probs, "linked_out_of_sync") || hasCode(probs, "linked_mixed") {
|
||||||
|
t.Errorf("validation after relink: %+v", probs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleting one zone of two dissolves the link.
|
||||||
|
z := env.state().FindZone("example.com.", env.state().ViewByName("external").ID)
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneDelete, z.ID, nil))
|
||||||
|
if zs := env.state().ZonesNamed("example.com."); len(zs) != 1 || zs[0].LinkGroup != 0 {
|
||||||
|
t.Errorf("after deleting external: %+v", zs)
|
||||||
|
}
|
||||||
|
for _, r := range env.view("internal") {
|
||||||
|
if strings.HasSuffix(r, " S") {
|
||||||
|
t.Errorf("shared record left in an unlinked zone: %s", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasCode(probs []validate.Problem, code string) bool {
|
||||||
|
for _, p := range probs {
|
||||||
|
if p.Code == code {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkedPermissions(t *testing.T) {
|
||||||
|
env := linkedEnv(t)
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
|
||||||
|
u := &model.User{Username: "lan", Grants: []model.Grant{{Resource: auth.ResRecords, Access: "write", Views: []string{"internal"}}, {Resource: auth.ResZones, Access: "read"}}}
|
||||||
|
if err := env.st.CreateUser(env.ctx, u); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lan := &auth.Principal{User: u, Method: "token"}
|
||||||
|
// A shared change would reach external, which this user may not change.
|
||||||
|
e := env.submitErr(lan, NewRequest(OpRecordUpdate, env.recordID("internal", "www", "A"), RecordInput{Data: model.Ptr("10.9.9.9")}))
|
||||||
|
if e.Kind != KindForbidden || !strings.Contains(e.Message, "shared with example.com [external]") || !strings.Contains(e.Message, "local") {
|
||||||
|
t.Errorf("shared change: %v", e)
|
||||||
|
}
|
||||||
|
// A local override is theirs to make.
|
||||||
|
env.submit(lan, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("www"), Type: model.Ptr("A"), Data: model.Ptr("10.9.9.9"), Local: model.Ptr(true)}))
|
||||||
|
if !slices.Contains(env.view("internal"), "www A 10.9.9.9 L") || !slices.Contains(env.view("external"), "www A 192.0.2.10 S") {
|
||||||
|
t.Errorf("override by a view-restricted user: internal %q external %q", env.view("internal"), env.view("external"))
|
||||||
|
}
|
||||||
|
// Records of a zone that isn't linked can't be "local".
|
||||||
|
e = env.submitErr(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.org"), View: model.Ptr("internal"), LinkWith: "external"}))
|
||||||
|
if e.Kind != KindNotFound {
|
||||||
|
t.Errorf("link_with a view without the zone: %v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkExisting(t *testing.T) {
|
||||||
|
env := linkedEnv(t)
|
||||||
|
// external was made by hand: the same apart from www.
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external")}))
|
||||||
|
env.submit(env.admin,
|
||||||
|
recIn("external", "www", "A", "203.0.113.10"),
|
||||||
|
recIn("external", "mail", "A", "192.0.2.25"),
|
||||||
|
recIn("external", "@", "MX", "10 mail"))
|
||||||
|
before := []string{}
|
||||||
|
for _, v := range []string{"internal", "external"} {
|
||||||
|
before = append(before, strings.Join(env.view(v), ";"))
|
||||||
|
}
|
||||||
|
res := env.submit(env.admin, NewRequest(OpZoneLink, 0, LinkInput{Zone: "example.com", View: "external", With: []string{"internal"}}))
|
||||||
|
in, ex := env.view("internal"), env.view("external")
|
||||||
|
if !slices.Contains(in, "www A 192.0.2.10 L") || !slices.Contains(ex, "www A 203.0.113.10 L") {
|
||||||
|
t.Errorf("different www should stay local: internal %q external %q", in, ex)
|
||||||
|
}
|
||||||
|
if !slices.Contains(in, "mail A 192.0.2.25 S") || !slices.Contains(ex, "mail A 192.0.2.25 S") {
|
||||||
|
t.Errorf("same mail should be shared: internal %q external %q", in, ex)
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.Join(res.Warnings, " "), "www.example.com A (internal, external)") {
|
||||||
|
t.Errorf("warnings: %q", res.Warnings)
|
||||||
|
}
|
||||||
|
// Linking never changes what named answers: the same data as before.
|
||||||
|
for i, v := range []string{"internal", "external"} {
|
||||||
|
var data []string
|
||||||
|
for _, r := range env.view(v) {
|
||||||
|
data = append(data, strings.TrimSuffix(strings.TrimSuffix(r, " S"), " L"))
|
||||||
|
}
|
||||||
|
var was []string
|
||||||
|
for _, r := range strings.Split(before[i], ";") {
|
||||||
|
was = append(was, strings.TrimSuffix(strings.TrimSuffix(r, " S"), " L"))
|
||||||
|
}
|
||||||
|
want(t, "answers in "+v, data, was...)
|
||||||
|
}
|
||||||
|
// Sharing internal's www replaces external's.
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "www", "A"), RecordInput{Local: model.Ptr(false)}))
|
||||||
|
if !slices.Contains(env.view("external"), "www A 192.0.2.10 S") {
|
||||||
|
t.Errorf("shared from internal: external %q", env.view("external"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --local on a zone that isn't linked is fine (its records are local
|
||||||
|
// anyway); --shared there is refused.
|
||||||
|
func TestLocalOnUnlinkedZone(t *testing.T) {
|
||||||
|
env := linkedEnv(t)
|
||||||
|
local, shared := true, false
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("a"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.1"), Local: &local}))
|
||||||
|
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "internal", Name: "b", Type: "A", Data: []string{"10.0.0.2"}, Local: &local}))
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "a", "A"), RecordInput{Data: model.Ptr("10.0.0.3"), Local: &local}))
|
||||||
|
if v := env.view("internal"); !slices.Contains(v, "a A 10.0.0.3 L") || !slices.Contains(v, "b A 10.0.0.2 L") {
|
||||||
|
t.Errorf("records: %q", v)
|
||||||
|
}
|
||||||
|
if e := env.submitErr(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("c"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.4"), Local: &shared})); !strings.Contains(e.Message, "nothing to share") {
|
||||||
|
t.Errorf("--shared on an unlinked zone: %v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareZones(t *testing.T) {
|
||||||
|
env := linkedEnv(t)
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
|
||||||
|
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "external", Name: "www", Type: "A", Data: []string{"203.0.113.10"}, Local: model.Ptr(true)}))
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("nas"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.20"), Local: model.Ptr(true)}))
|
||||||
|
st := env.state()
|
||||||
|
zones := st.ZonesNamed("example.com.")
|
||||||
|
st.SortByView(zones)
|
||||||
|
diff := map[string][]string{}
|
||||||
|
same := 0
|
||||||
|
for _, row := range CompareZones(st, zones) {
|
||||||
|
if row.Same {
|
||||||
|
same++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var cells []string
|
||||||
|
for _, vs := range row.Views {
|
||||||
|
cells = append(cells, vs.View+"="+strings.Join(vs.Records, ",")+"/"+vs.Linked)
|
||||||
|
}
|
||||||
|
diff[row.Name+" "+row.Type] = cells
|
||||||
|
}
|
||||||
|
want := map[string][]string{
|
||||||
|
"www A": {"internal=192.0.2.10/shared", "external=203.0.113.10/local"},
|
||||||
|
"nas A": {"internal=10.0.0.20/local", "external=/"},
|
||||||
|
}
|
||||||
|
for k, v := range want {
|
||||||
|
if !slices.Equal(diff[k], v) {
|
||||||
|
t.Errorf("%s: %q, want %q", k, diff[k], v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(diff) != 2 || same < 3 {
|
||||||
|
t.Errorf("differ %v, same %d", diff, same)
|
||||||
|
}
|
||||||
|
// "Use this version" on the shared one replaces external's override.
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "www", "A"), RecordInput{Local: model.Ptr(false)}))
|
||||||
|
if v := env.view("external"); !slices.Contains(v, "www A 192.0.2.10 S") || slices.Contains(v, "www A 203.0.113.10 L") {
|
||||||
|
t.Errorf("sharing the shared version: external %q", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
-2
@@ -265,6 +265,9 @@ func setZoneFields(st *model.State, z *model.Zone, in *ZoneInput, creating bool)
|
|||||||
if in.Comment != nil {
|
if in.Comment != nil {
|
||||||
z.Comment = *in.Comment
|
z.Comment = *in.Comment
|
||||||
}
|
}
|
||||||
|
if in.LinkGroup != nil {
|
||||||
|
z.LinkGroup = *in.LinkGroup
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,6 +450,9 @@ func applyRecordCreate(st *model.State, op model.Op, c *applyCtx) error {
|
|||||||
if in.Disabled != nil {
|
if in.Disabled != nil {
|
||||||
r.Disabled = *in.Disabled
|
r.Disabled = *in.Disabled
|
||||||
}
|
}
|
||||||
|
if in.SharedID != nil {
|
||||||
|
r.SharedID = *in.SharedID
|
||||||
|
}
|
||||||
for _, w := range p.Warnings {
|
for _, w := range p.Warnings {
|
||||||
c.warn("%s %s: %s", dnsname.Display(owner), p.Type, w)
|
c.warn("%s %s: %s", dnsname.Display(owner), p.Type, w)
|
||||||
}
|
}
|
||||||
@@ -526,6 +532,9 @@ func applyRecordUpdate(st *model.State, op model.Op, c *applyCtx) error {
|
|||||||
if in.Disabled != nil {
|
if in.Disabled != nil {
|
||||||
r.Disabled = *in.Disabled
|
r.Disabled = *in.Disabled
|
||||||
}
|
}
|
||||||
|
if in.SharedID != nil {
|
||||||
|
r.SharedID = *in.SharedID
|
||||||
|
}
|
||||||
r.Touch(c.user(), c.now)
|
r.Touch(c.user(), c.now)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -662,16 +671,34 @@ func applyRRSetReplace(st *model.State, op model.Op, c *applyCtx) error {
|
|||||||
k := strings.ToLower(r.Data)
|
k := strings.ToLower(r.Data)
|
||||||
if seen[k] && !keep[k] {
|
if seen[k] && !keep[k] {
|
||||||
keep[k] = true
|
keep[k] = true
|
||||||
if (ttlKnown && !sameTTL(r.TTL, ttl)) || r.Disabled {
|
shared := r.SharedID
|
||||||
|
if in.Shared != nil {
|
||||||
|
shared = 0
|
||||||
|
if *in.Shared {
|
||||||
|
shared = r.SharedID
|
||||||
|
if shared == 0 {
|
||||||
|
shared = r.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ttlKnown && !sameTTL(r.TTL, ttl)) || r.Disabled || shared != r.SharedID {
|
||||||
if ttlKnown {
|
if ttlKnown {
|
||||||
r.TTL = copyTTL(ttl)
|
r.TTL = copyTTL(ttl)
|
||||||
}
|
}
|
||||||
r.Disabled = false
|
r.Disabled, r.SharedID = false, shared
|
||||||
r.Touch(c.user(), c.now)
|
r.Touch(c.user(), c.now)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if r.Disabled {
|
if r.Disabled {
|
||||||
|
// Kept, but shared or local like the rest of the set.
|
||||||
|
if in.Shared != nil && (*in.Shared) != (r.SharedID != 0) {
|
||||||
|
r.SharedID = 0
|
||||||
|
if *in.Shared {
|
||||||
|
r.SharedID = r.ID
|
||||||
|
}
|
||||||
|
r.Touch(c.user(), c.now)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
delete(st.Records, id)
|
delete(st.Records, id)
|
||||||
@@ -692,6 +719,9 @@ func applyRRSetReplace(st *model.State, op model.Op, c *applyCtx) error {
|
|||||||
if in.Comment != nil {
|
if in.Comment != nil {
|
||||||
r.Comment = *in.Comment
|
r.Comment = *in.Comment
|
||||||
}
|
}
|
||||||
|
if in.Shared != nil && *in.Shared {
|
||||||
|
r.SharedID = r.ID
|
||||||
|
}
|
||||||
r.Touch(c.user(), c.now)
|
r.Touch(c.user(), c.now)
|
||||||
st.Records[r.ID] = r
|
st.Records[r.ID] = r
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ticking "update PTR" on an unchanged address record adds its PTR when
|
||||||
|
// the reverse zone is there (it was created after the record).
|
||||||
|
func TestUpdatePTRAddsMissing(t *testing.T) {
|
||||||
|
env := newEnv(t)
|
||||||
|
env.submit(env.admin, NewRequest(OpSettingsUpdate, 0, SettingsInput{Nameservers: &[]string{"ns1.example.net."}}))
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com")}))
|
||||||
|
env.submit(env.admin, rec("example.com", "ldn", "A", "139.144.144.154"))
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("139.144.144.154/32")}))
|
||||||
|
var id int64
|
||||||
|
for _, r := range env.state().Records {
|
||||||
|
if r.Name == "ldn.example.com." {
|
||||||
|
id = r.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordUpdate, id, RecordInput{PTR: true}))
|
||||||
|
got := env.records("154.144.144.139.in-addr.arpa.")
|
||||||
|
if !slices.ContainsFunc(got, func(s string) bool { return strings.Contains(s, "PTR ldn.example.com.") }) {
|
||||||
|
t.Errorf("reverse zone = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In linked zones the PTR goes to the reverse zone of whichever linked
|
||||||
|
// view has one.
|
||||||
|
func TestUpdatePTRLinked(t *testing.T) {
|
||||||
|
env := linkedEnv(t)
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
|
||||||
|
env.submit(env.admin, recIn("internal", "ldn", "A", "139.144.144.154"))
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("139.144.144.154/32"), View: model.Ptr("external")}))
|
||||||
|
res := env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "ldn", "A"), RecordInput{PTR: true}))
|
||||||
|
st := env.state()
|
||||||
|
z := st.FindZone("154.144.144.139.in-addr.arpa.", st.ViewByName("external").ID)
|
||||||
|
ok := false
|
||||||
|
for _, r := range st.ZoneRecords(z.ID) {
|
||||||
|
ok = ok || r.Type == "PTR" && r.Data == "ldn.example.com."
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("no PTR in external's reverse zone (warnings %q)", res.Warnings)
|
||||||
|
}
|
||||||
|
// Again: already there, nothing to do.
|
||||||
|
if e := env.submitErr(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "ldn", "A"), RecordInput{PTR: true})); !strings.Contains(e.Message, "nothing to do") {
|
||||||
|
t.Errorf("second time: %v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,3 +66,9 @@ func (e *Engine) ViewKey(ctx context.Context, st *model.State, z *model.Zone) *n
|
|||||||
}
|
}
|
||||||
return &nscheck.Key{Name: render.ViewKeyName(v.Name), Algorithm: render.ViewKeyAlgorithm, Secret: render.ViewKeySecret(x.ViewKeySeed, v.ID)}
|
return &nscheck.Key{Name: render.ViewKeyName(v.Name), Algorithm: render.ViewKeyAlgorithm, Secret: render.ViewKeySecret(x.ViewKeySeed, v.ID)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GeneratedStatements lists what Jarl adds to named.conf by itself
|
||||||
|
// (render.GeneratedStatements).
|
||||||
|
func (e *Engine) GeneratedStatements(ctx context.Context, st *model.State) []render.Generated {
|
||||||
|
return render.GeneratedStatements(st, e.cfg.Paths, e.extras(ctx))
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
// Package mailcheck checks the reverse DNS of the addresses that send and
|
||||||
|
// receive mail for your public zones (docs/exposure.md#mail): receivers
|
||||||
|
// such as Gmail and Outlook reject or mark mail from an address without a
|
||||||
|
// PTR record, or whose PTR name doesn't resolve back to it. Those records
|
||||||
|
// belong to whoever owns the address (your hosting provider or ISP), not
|
||||||
|
// to Jarl, so the check asks the internet and says who to ask.
|
||||||
|
package mailcheck
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
"golang.org/x/net/publicsuffix"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Target is an address mail comes from or goes to, and why it is checked.
|
||||||
|
type Target struct {
|
||||||
|
Zone string `json:"zone"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Roles []string `json:"roles"` // e.g. "MX mail.adbraun.lol", "SPF ip4"
|
||||||
|
Host string `json:"host,omitempty"` // the MX host name, when it is one
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result is the check of one address.
|
||||||
|
type Result struct {
|
||||||
|
Target
|
||||||
|
PTR []string `json:"ptr"` // the names its PTR records give
|
||||||
|
// OK: a PTR name resolves back to the address (forward-confirmed).
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
// Level is ok, info or warning; Message says what to do.
|
||||||
|
Level string `json:"level"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
// ReverseZone and ManagedBy say where the PTR record lives.
|
||||||
|
ReverseZone string `json:"reverse_zone,omitempty"`
|
||||||
|
ManagedBy string `json:"managed_by,omitempty"`
|
||||||
|
// LocalZone is a reverse zone for the address in Jarl ("view/zone"),
|
||||||
|
// which the internet doesn't ask unless the address's owner delegated
|
||||||
|
// it to this server.
|
||||||
|
LocalZone string `json:"local_zone,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report is a whole check.
|
||||||
|
type Report struct {
|
||||||
|
Checked time.Time `json:"checked"`
|
||||||
|
Results []Result `json:"results"`
|
||||||
|
// Errors are lookups that failed (no internet, say).
|
||||||
|
Errors []string `json:"errors,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Targets finds the addresses to check in the public zones: the MX hosts
|
||||||
|
// (receiving) and the SPF record's addresses (sending). Names are looked
|
||||||
|
// up later, through the resolver.
|
||||||
|
func Targets(st *model.State, public func(viewID int64) bool) (addrs []Target, names []Target) {
|
||||||
|
for _, z := range st.SortedZones() {
|
||||||
|
if z.Type != model.ZonePrimary || z.Disabled || !public(z.ViewID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
zone := strings.TrimSuffix(z.Name, ".")
|
||||||
|
for _, r := range st.ZoneRecords(z.ID) {
|
||||||
|
if r.Name != z.Name || r.Disabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch r.Type {
|
||||||
|
case "MX":
|
||||||
|
f := strings.Fields(r.Data)
|
||||||
|
if len(f) == 2 && f[1] != "." {
|
||||||
|
names = append(names, Target{Zone: zone, Host: strings.TrimSuffix(strings.ToLower(f[1]), "."), Roles: []string{"MX " + strings.TrimSuffix(f[1], ".")}})
|
||||||
|
}
|
||||||
|
case "TXT":
|
||||||
|
text := strings.ReplaceAll(strings.ReplaceAll(r.Data, `" "`, ""), `"`, "")
|
||||||
|
if !strings.HasPrefix(strings.ToLower(text), "v=spf1") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, mech := range strings.Fields(text)[1:] {
|
||||||
|
m := strings.ToLower(strings.TrimLeft(mech, "+?~"))
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(m, "ip4:"), strings.HasPrefix(m, "ip6:"):
|
||||||
|
v := m[4:]
|
||||||
|
p, err := netip.ParsePrefix(v)
|
||||||
|
if err != nil {
|
||||||
|
if a, err := netip.ParseAddr(v); err == nil {
|
||||||
|
p = netip.PrefixFrom(a, a.BitLen())
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.IsSingleIP() {
|
||||||
|
addrs = append(addrs, Target{Zone: zone, Address: p.Addr().String(), Roles: []string{"SPF " + m[:3]}})
|
||||||
|
}
|
||||||
|
case m == "a":
|
||||||
|
names = append(names, Target{Zone: zone, Host: zone, Roles: []string{"SPF a"}})
|
||||||
|
case strings.HasPrefix(m, "a:"):
|
||||||
|
names = append(names, Target{Zone: zone, Host: strings.TrimSuffix(m[2:], "."), Roles: []string{"SPF " + m}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return addrs, names
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolver asks recursive resolvers (the public ones of acme.resolvers).
|
||||||
|
type Resolver struct {
|
||||||
|
Servers []string
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Resolver) query(ctx context.Context, name string, qtype uint16) (*dns.Msg, error) {
|
||||||
|
if len(r.Servers) == 0 {
|
||||||
|
return nil, errors.New("no resolvers configured (acme.resolvers)")
|
||||||
|
}
|
||||||
|
m := new(dns.Msg)
|
||||||
|
m.SetQuestion(dns.Fqdn(name), qtype)
|
||||||
|
m.RecursionDesired = true
|
||||||
|
m.SetEdns0(1232, false)
|
||||||
|
t := r.Timeout
|
||||||
|
if t == 0 {
|
||||||
|
t = 5 * time.Second
|
||||||
|
}
|
||||||
|
var last error
|
||||||
|
for _, s := range r.Servers {
|
||||||
|
in, _, err := (&dns.Client{Timeout: t}).ExchangeContext(ctx, m, s)
|
||||||
|
if err == nil && in.Truncated {
|
||||||
|
in, _, err = (&dns.Client{Timeout: t, Net: "tcp"}).ExchangeContext(ctx, m, s)
|
||||||
|
}
|
||||||
|
if err == nil && in.Rcode != dns.RcodeServerFailure && in.Rcode != dns.RcodeRefused {
|
||||||
|
return in, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
last = fmt.Errorf("%s: %w", s, err)
|
||||||
|
} else {
|
||||||
|
last = fmt.Errorf("%s answered %s", s, dns.RcodeToString[in.Rcode])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("looking up %s %s: %w", strings.TrimSuffix(name, "."), dns.TypeToString[qtype], last)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addresses returns a name's A and AAAA addresses (after CNAMEs).
|
||||||
|
func (r Resolver) addresses(ctx context.Context, name string) ([]netip.Addr, bool, error) {
|
||||||
|
var out []netip.Addr
|
||||||
|
alias := false
|
||||||
|
for _, t := range []uint16{dns.TypeA, dns.TypeAAAA} {
|
||||||
|
in, err := r.query(ctx, name, t)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
for _, rr := range in.Answer {
|
||||||
|
switch v := rr.(type) {
|
||||||
|
case *dns.A:
|
||||||
|
a, _ := netip.AddrFromSlice(v.A.To4())
|
||||||
|
out = append(out, a)
|
||||||
|
case *dns.AAAA:
|
||||||
|
a, _ := netip.AddrFromSlice(v.AAAA)
|
||||||
|
out = append(out, a)
|
||||||
|
case *dns.CNAME:
|
||||||
|
alias = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, alias, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check looks up every target's reverse DNS.
|
||||||
|
func Check(ctx context.Context, st *model.State, public func(int64) bool, res Resolver) *Report {
|
||||||
|
rep := &Report{Checked: time.Now(), Results: []Result{}}
|
||||||
|
addrs, names := Targets(st, public)
|
||||||
|
// Names to addresses, as the internet sees them.
|
||||||
|
for _, n := range names {
|
||||||
|
as, _, err := res.addresses(ctx, n.Host)
|
||||||
|
if err != nil {
|
||||||
|
rep.Errors = append(rep.Errors, err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(as) == 0 {
|
||||||
|
rep.Errors = append(rep.Errors, fmt.Sprintf("%s (%s, %s) has no address as the internet sees it: not published yet, or a typo?", n.Host, n.Zone, strings.Join(n.Roles, ", ")))
|
||||||
|
}
|
||||||
|
for _, a := range as {
|
||||||
|
t := n
|
||||||
|
t.Address = a.String()
|
||||||
|
addrs = append(addrs, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// One result per address, with every reason it is checked.
|
||||||
|
merged := map[string]*Target{}
|
||||||
|
var order []string
|
||||||
|
for _, t := range addrs {
|
||||||
|
k := t.Zone + "|" + t.Address
|
||||||
|
if m := merged[k]; m != nil {
|
||||||
|
for _, role := range t.Roles {
|
||||||
|
if !slices.Contains(m.Roles, role) {
|
||||||
|
m.Roles = append(m.Roles, role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.Host == "" {
|
||||||
|
m.Host = t.Host
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t := t
|
||||||
|
merged[k] = &t
|
||||||
|
order = append(order, k)
|
||||||
|
}
|
||||||
|
for _, k := range order {
|
||||||
|
r := checkOne(ctx, *merged[k], res)
|
||||||
|
r.LocalZone = localReverse(st, r.Address, r.ReverseZone)
|
||||||
|
rep.Results = append(rep.Results, r)
|
||||||
|
}
|
||||||
|
return rep
|
||||||
|
}
|
||||||
|
|
||||||
|
// localReverse returns a reverse zone in Jarl that covers the address,
|
||||||
|
// unless it is the zone the internet asks (delegated to this server).
|
||||||
|
func localReverse(st *model.State, addr, internetZone string) string {
|
||||||
|
rev, err := dns.ReverseAddr(addr)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rev = strings.ToLower(rev)
|
||||||
|
best := ""
|
||||||
|
var bz *model.Zone
|
||||||
|
for _, z := range st.SortedZones() {
|
||||||
|
n := strings.ToLower(dns.Fqdn(z.Name))
|
||||||
|
if z.Type == model.ZonePrimary && dns.IsSubDomain(n, rev) && len(n) > len(best) {
|
||||||
|
best, bz = n, z
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bz == nil || strings.EqualFold(strings.TrimSuffix(best, "."), internetZone) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if v := st.ViewName(bz); v != "" {
|
||||||
|
return v + "/" + strings.TrimSuffix(bz.Name, ".")
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(bz.Name, ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkOne(ctx context.Context, t Target, res Resolver) Result {
|
||||||
|
r := Result{Target: t, PTR: []string{}}
|
||||||
|
a, err := netip.ParseAddr(t.Address)
|
||||||
|
if err != nil {
|
||||||
|
r.Level, r.Message = "warning", err.Error()
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
rev, _ := dns.ReverseAddr(a.String())
|
||||||
|
in, err := res.query(ctx, rev, dns.TypePTR)
|
||||||
|
if err != nil {
|
||||||
|
r.Level, r.Message = "warning", err.Error()
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
// Who manages the reverse zone: the SOA in the answer.
|
||||||
|
for _, sec := range [][]dns.RR{in.Ns, in.Answer} {
|
||||||
|
for _, rr := range sec {
|
||||||
|
if soa, ok := rr.(*dns.SOA); ok {
|
||||||
|
r.ReverseZone, r.ManagedBy = strings.TrimSuffix(soa.Hdr.Name, "."), managedBy(soa.Ns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.ReverseZone == "" {
|
||||||
|
if s, err := res.query(ctx, rev, dns.TypeSOA); err == nil {
|
||||||
|
for _, sec := range [][]dns.RR{s.Answer, s.Ns} {
|
||||||
|
for _, rr := range sec {
|
||||||
|
if soa, ok := rr.(*dns.SOA); ok {
|
||||||
|
r.ReverseZone, r.ManagedBy = strings.TrimSuffix(soa.Hdr.Name, "."), managedBy(soa.Ns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, rr := range in.Answer {
|
||||||
|
if p, ok := rr.(*dns.PTR); ok {
|
||||||
|
r.PTR = append(r.PTR, strings.TrimSuffix(strings.ToLower(p.Ptr), "."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
where := "whoever runs the address's reverse zone (your hosting provider or ISP)"
|
||||||
|
if r.ManagedBy != "" {
|
||||||
|
where = "the reverse zone's owner (" + r.ManagedBy + ": your hosting provider's or ISP's panel)"
|
||||||
|
}
|
||||||
|
if len(r.PTR) == 0 {
|
||||||
|
r.Level = "warning"
|
||||||
|
r.Message = fmt.Sprintf("%s has no PTR record: many receivers reject or mark mail from it. Set its reverse DNS to %s at %s", t.Address, suggestName(t), where)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
var notes []string
|
||||||
|
for _, name := range r.PTR {
|
||||||
|
as, alias, err := res.addresses(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
notes = append(notes, err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if slices.Contains(as, a) {
|
||||||
|
r.OK = true
|
||||||
|
if alias {
|
||||||
|
notes = append(notes, name+" is an alias (CNAME): receivers expect the PTR to name a host with its own A/AAAA record")
|
||||||
|
}
|
||||||
|
if t.Host != "" && name != t.Host && strings.HasPrefix(strings.Join(t.Roles, " "), "MX") {
|
||||||
|
notes = append(notes, fmt.Sprintf("the PTR name is %s, not the MX name %s: fine if the mail server says %s in HELO", name, t.Host, name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case !r.OK:
|
||||||
|
r.Level = "warning"
|
||||||
|
r.Message = fmt.Sprintf("the PTR record of %s names %s, which doesn't resolve back to %s: receivers check that it does. Point the PTR at a name with an A/AAAA record for %s (set at %s), or add that record", t.Address, strings.Join(r.PTR, ", "), t.Address, t.Address, where)
|
||||||
|
case len(notes) > 0:
|
||||||
|
r.Level, r.Message = "info", strings.Join(notes, "; ")
|
||||||
|
default:
|
||||||
|
r.Level, r.Message = "ok", fmt.Sprintf("%s → %s → %s", t.Address, strings.Join(r.PTR, ", "), t.Address)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func suggestName(t Target) string {
|
||||||
|
if t.Host != "" {
|
||||||
|
return t.Host
|
||||||
|
}
|
||||||
|
return "the host's name (with an A/AAAA record for it)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// managedBy turns the reverse zone's primary name server into its owner's
|
||||||
|
// domain: ns1.linode.com -> linode.com, dns0.zen.co.uk -> zen.co.uk.
|
||||||
|
func managedBy(ns string) string {
|
||||||
|
ns = strings.TrimSuffix(strings.ToLower(ns), ".")
|
||||||
|
labels := strings.Split(ns, ".")
|
||||||
|
// The registered domain under the ICANN suffix: the list's private
|
||||||
|
// part (members.linode.com) names customers, not the provider.
|
||||||
|
for i := 1; i < len(labels); i++ {
|
||||||
|
if suffix, icann := publicsuffix.PublicSuffix(strings.Join(labels[i:], ".")); icann && suffix == strings.Join(labels[i:], ".") {
|
||||||
|
return strings.Join(labels[i-1:], ".")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ns
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package mailcheck
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeResolver answers from a fixed set of records, as a recursive
|
||||||
|
// resolver would, with the reverse zone's SOA in negative answers.
|
||||||
|
func fakeResolver(t *testing.T, records ...string) string {
|
||||||
|
t.Helper()
|
||||||
|
var rrs []dns.RR
|
||||||
|
for _, s := range records {
|
||||||
|
rr, err := dns.NewRR(s)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rrs = append(rrs, rr)
|
||||||
|
}
|
||||||
|
soa, _ := dns.NewRR("2.0.192.in-addr.arpa. 3600 IN SOA ns1.linode.com. hostmaster.linode.com. 1 3600 900 1209600 300")
|
||||||
|
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
srv := &dns.Server{PacketConn: pc, Handler: dns.HandlerFunc(func(w dns.ResponseWriter, q *dns.Msg) {
|
||||||
|
m := new(dns.Msg)
|
||||||
|
m.SetReply(q)
|
||||||
|
qn := strings.ToLower(q.Question[0].Name)
|
||||||
|
for _, rr := range rrs {
|
||||||
|
if strings.ToLower(rr.Header().Name) == qn && (rr.Header().Rrtype == q.Question[0].Qtype || rr.Header().Rrtype == dns.TypeCNAME) {
|
||||||
|
m.Answer = append(m.Answer, rr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(m.Answer) == 0 && dns.IsSubDomain(soa.Header().Name, qn) {
|
||||||
|
if q.Question[0].Qtype == dns.TypeSOA && qn == soa.Header().Name {
|
||||||
|
m.Answer = append(m.Answer, soa)
|
||||||
|
} else {
|
||||||
|
m.Ns = append(m.Ns, soa)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.WriteMsg(m)
|
||||||
|
})}
|
||||||
|
go srv.ActivateAndServe()
|
||||||
|
t.Cleanup(func() { srv.Shutdown() })
|
||||||
|
return pc.LocalAddr().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheck(t *testing.T) {
|
||||||
|
st := model.NewState()
|
||||||
|
ext := &model.View{ID: 1, Name: "external", MatchClients: []string{"any"}}
|
||||||
|
in := &model.View{ID: 2, Name: "internal", MatchClients: []string{"lan"}}
|
||||||
|
st.Views[1], st.Views[2] = ext, in
|
||||||
|
z := &model.Zone{ID: 10, Name: "example.com.", ViewID: 1, Type: model.ZonePrimary}
|
||||||
|
zi := &model.Zone{ID: 11, Name: "example.com.", ViewID: 2, Type: model.ZonePrimary}
|
||||||
|
rev := &model.Zone{ID: 12, Name: "27.2.0.192.in-addr.arpa.", ViewID: 2, Type: model.ZonePrimary}
|
||||||
|
st.Zones[10], st.Zones[11], st.Zones[12] = z, zi, rev
|
||||||
|
rec := func(id, zone int64, name, typ, data string) {
|
||||||
|
st.Records[id] = &model.Record{ID: id, ZoneID: zone, Name: name, Type: typ, Data: data}
|
||||||
|
}
|
||||||
|
rec(1, 10, "example.com.", "MX", "10 mail.example.com.")
|
||||||
|
rec(2, 10, "example.com.", "TXT", `"v=spf1 mx ip4:192.0.2.26 ip4:192.0.2.27" " ip4:198.51.100.0/24 -all"`)
|
||||||
|
rec(3, 10, "www.example.com.", "MX", "10 ignored.example.com.") // not the apex
|
||||||
|
rec(4, 11, "example.com.", "MX", "10 internal.example.com.") // private view
|
||||||
|
rec(5, 10, "example.com.", "TXT", `"google-site-verification=x"`)
|
||||||
|
rec(6, 10, "example.com.", "MX", "20 backup.example.com.") // not on the internet
|
||||||
|
|
||||||
|
res := Resolver{Servers: []string{fakeResolver(t,
|
||||||
|
"mail.example.com. 300 IN A 192.0.2.25",
|
||||||
|
"25.2.0.192.in-addr.arpa. 300 IN PTR mail.example.com.",
|
||||||
|
"27.2.0.192.in-addr.arpa. 300 IN PTR li-27.members.linode.com.",
|
||||||
|
"li-27.members.linode.com. 300 IN A 192.0.2.99",
|
||||||
|
)}}
|
||||||
|
public := func(id int64) bool { return id == 1 }
|
||||||
|
rep := Check(context.Background(), st, public, res)
|
||||||
|
if len(rep.Errors) != 1 || !strings.Contains(rep.Errors[0], "backup.example.com (example.com, MX backup.example.com) has no address") {
|
||||||
|
t.Fatalf("errors %v", rep.Errors)
|
||||||
|
}
|
||||||
|
got := map[string]Result{}
|
||||||
|
for _, r := range rep.Results {
|
||||||
|
got[r.Address] = r
|
||||||
|
}
|
||||||
|
if len(got) != 3 {
|
||||||
|
t.Fatalf("results %+v", rep.Results)
|
||||||
|
}
|
||||||
|
if r := got["192.0.2.25"]; !r.OK || r.Level != "ok" || r.Roles[0] != "MX mail.example.com" || r.ManagedBy != "" && r.ManagedBy != "linode.com" {
|
||||||
|
t.Errorf("mail: %+v", r)
|
||||||
|
}
|
||||||
|
if r := got["192.0.2.26"]; r.OK || r.Level != "warning" || !strings.Contains(r.Message, "no PTR record") || r.ManagedBy != "linode.com" || r.ReverseZone != "2.0.192.in-addr.arpa" {
|
||||||
|
t.Errorf("no PTR: %+v", r)
|
||||||
|
}
|
||||||
|
if r := got["192.0.2.27"]; r.OK || !strings.Contains(r.Message, "doesn't resolve back") || r.LocalZone != "internal/27.2.0.192.in-addr.arpa" {
|
||||||
|
t.Errorf("not forward-confirmed: %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagedBy(t *testing.T) {
|
||||||
|
for in, want := range map[string]string{"ns1.linode.com.": "linode.com", "dns0.zen.co.uk.": "zen.co.uk", "ns1.members.linode.com.": "linode.com", "localhost.": "localhost"} {
|
||||||
|
if got := managedBy(in); got != want {
|
||||||
|
t.Errorf("managedBy(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Linked zones (docs/views.md#linked-zones): zones of the same name in
|
||||||
|
// different views whose shared records Jarl keeps in step. Each zone
|
||||||
|
// keeps its own copy of every shared record (with the same SharedID), so
|
||||||
|
// named, validation and rendering see ordinary zones. A record with
|
||||||
|
// SharedID 0 in a linked zone is local to its view: an override of the
|
||||||
|
// shared records of the same name and type there.
|
||||||
|
|
||||||
|
// LinkMembers returns the zones of a link group in view order.
|
||||||
|
func (s *State) LinkMembers(group int64) []*Zone {
|
||||||
|
if group == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []*Zone
|
||||||
|
for _, z := range s.Zones {
|
||||||
|
if z.LinkGroup == group {
|
||||||
|
out = append(out, z)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.SortByView(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkedWith returns the other zones z is linked with, in view order.
|
||||||
|
func (s *State) LinkedWith(z *Zone) []*Zone {
|
||||||
|
var out []*Zone
|
||||||
|
for _, o := range s.LinkMembers(z.LinkGroup) {
|
||||||
|
if o.ID != z.ID {
|
||||||
|
out = append(out, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnyLinked reports whether any zone is linked.
|
||||||
|
func (s *State) AnyLinked() bool {
|
||||||
|
for _, z := range s.Zones {
|
||||||
|
if z.LinkGroup != 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// SortByView sorts zones in view order.
|
||||||
|
func (s *State) SortByView(zs []*Zone) {
|
||||||
|
pos := func(z *Zone) int {
|
||||||
|
if v := s.Views[z.ViewID]; v != nil {
|
||||||
|
return v.Position
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
sort.Slice(zs, func(i, j int) bool {
|
||||||
|
if a, b := pos(zs[i]), pos(zs[j]); a != b {
|
||||||
|
return a < b
|
||||||
|
}
|
||||||
|
return zs[i].ID < zs[j].ID
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RRset returns the records of one name and type in a zone, by ID.
|
||||||
|
func (s *State) RRset(zoneID int64, name, typ string) []*Record {
|
||||||
|
var out []*Record
|
||||||
|
for _, r := range s.Records {
|
||||||
|
if r.ZoneID == zoneID && r.Name == name && r.Type == typ {
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.SortFunc(out, func(a, b *Record) int { return int(a.ID - b.ID) })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasLocal reports whether a linked zone overrides the shared records of
|
||||||
|
// one name and type with records of its own.
|
||||||
|
func (s *State) HasLocal(zoneID int64, name, typ string) bool {
|
||||||
|
for _, r := range s.Records {
|
||||||
|
if r.ZoneID == zoneID && r.Name == name && r.Type == typ && r.SharedID == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// SharedRecords returns a zone's shared records of one name and type,
|
||||||
|
// by SharedID.
|
||||||
|
func (s *State) SharedRecords(zoneID int64, name, typ string) []*Record {
|
||||||
|
var out []*Record
|
||||||
|
for _, r := range s.Records {
|
||||||
|
if r.ZoneID == zoneID && r.Name == name && r.Type == typ && r.SharedID != 0 {
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.SortFunc(out, func(a, b *Record) int { return int(a.SharedID - b.SharedID) })
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -166,6 +166,10 @@ type Zone struct {
|
|||||||
// Disabled zones stay in Jarl, records and all, but named doesn't
|
// Disabled zones stay in Jarl, records and all, but named doesn't
|
||||||
// load them: their zone statement is left out of named.conf.
|
// load them: their zone statement is left out of named.conf.
|
||||||
Disabled bool `json:"disabled,omitempty"`
|
Disabled bool `json:"disabled,omitempty"`
|
||||||
|
// LinkGroup links zones of the same name in different views: Jarl
|
||||||
|
// keeps their shared records in step (docs/views.md#linked-zones). 0
|
||||||
|
// for a zone that isn't linked; linked zones share the ID.
|
||||||
|
LinkGroup int64 `json:"link_group,omitempty"`
|
||||||
Meta
|
Meta
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +199,10 @@ type Record struct {
|
|||||||
// Disabled records stay in Jarl but are left out of the zone (the
|
// Disabled records stay in Jarl but are left out of the zone (the
|
||||||
// zone file lists them as comments).
|
// zone file lists them as comments).
|
||||||
Disabled bool `json:"disabled,omitempty"`
|
Disabled bool `json:"disabled,omitempty"`
|
||||||
|
// SharedID ties the copies of a shared record in linked zones
|
||||||
|
// together (the same in every copy). 0 for a record of a zone that
|
||||||
|
// isn't linked, and for a record local to its view in one that is.
|
||||||
|
SharedID int64 `json:"shared_id,omitempty"`
|
||||||
Meta
|
Meta
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -182,3 +182,38 @@ func writeItemsInline(sb *strings.Builder, items []Item) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FormatValueList is FormatValue, except that a value whose block is a
|
||||||
|
// plain list (no nested blocks) of at least min elements is laid out one
|
||||||
|
// element per line, for reading: allow-query, exempt-clients, forwarders.
|
||||||
|
func FormatValueList(name, value string, min int) string {
|
||||||
|
st, err := ParseOptionValue(name, value)
|
||||||
|
if err != nil {
|
||||||
|
return FormatValue(name, value)
|
||||||
|
}
|
||||||
|
items := st.Items[1:]
|
||||||
|
blocks := 0
|
||||||
|
var list *Block
|
||||||
|
for _, it := range items {
|
||||||
|
if it.Block != nil {
|
||||||
|
blocks++
|
||||||
|
list = it.Block
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if blocks != 1 || len(list.Stmts) < min || blockHasBlocks(list) {
|
||||||
|
return FormatValue(name, value)
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, it := range items {
|
||||||
|
if it.Block == nil {
|
||||||
|
sb.WriteString(Compact([]Item{it}) + " ")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sb.WriteString("{\n")
|
||||||
|
for _, s := range list.Stmts {
|
||||||
|
sb.WriteString("\t" + Compact(s.Items) + ";\n")
|
||||||
|
}
|
||||||
|
sb.WriteString("} ")
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(sb.String())
|
||||||
|
}
|
||||||
@@ -16,3 +16,23 @@ func TestFormatValue(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFormatValueList(t *testing.T) {
|
||||||
|
cases := []struct{ name, value, want string }{
|
||||||
|
{"allow-query", "{ homelab; localhost; 10.0.0.0/8; }", "{\n\thomelab;\n\tlocalhost;\n\t10.0.0.0/8;\n}"},
|
||||||
|
{"allow-query", "{ homelab; localhost; }", "{ homelab; localhost; }"}, // short: as it was
|
||||||
|
{"listen-on", "port 853 tls dot { 10.0.0.1; 10.0.0.2; 10.0.0.3; }", "port 853 tls dot {\n\t10.0.0.1;\n\t10.0.0.2;\n\t10.0.0.3;\n}"},
|
||||||
|
{"allow-transfer", "{ !10.0.0.9; key xfer; 10.0.0.0/8; }", "{\n\t!10.0.0.9;\n\tkey xfer;\n\t10.0.0.0/8;\n}"},
|
||||||
|
{"recursion", "yes", "yes"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := FormatValueList(c.name, c.value, 3); got != c.want {
|
||||||
|
t.Errorf("%s %q:\n got %q\nwant %q", c.name, c.value, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A nested block (rate-limit with exempt-clients) keeps FormatValue's layout.
|
||||||
|
v := "{ responses-per-second 10; log-only yes; exempt-clients { 192.0.2.1; 192.0.2.2; 192.0.2.3; }; }"
|
||||||
|
if got := FormatValueList("rate-limit", v, 3); got != FormatValue("rate-limit", v) {
|
||||||
|
t.Errorf("nested: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/namedconf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Generated is a statement Jarl adds to named's configuration by itself,
|
||||||
|
// not from an option you set: why, and what controls it.
|
||||||
|
type Generated struct {
|
||||||
|
Where string `json:"where"` // top level, options, or "view NAME"
|
||||||
|
Text string `json:"text"` // as written in named.conf
|
||||||
|
Why string `json:"why"` // what it is for
|
||||||
|
Setting string `json:"setting"` // what turns it on or off
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeneratedStatements lists what Render adds to named.conf beyond the
|
||||||
|
// options in the database, so nothing there comes as a surprise.
|
||||||
|
func GeneratedStatements(s *model.State, p Paths, x Extras) []Generated {
|
||||||
|
var out []Generated
|
||||||
|
add := func(where, text, why, setting string) {
|
||||||
|
out = append(out, Generated{Where: where, Text: strings.TrimRight(text, "\n"), Why: why, Setting: setting})
|
||||||
|
}
|
||||||
|
add("top level", "include "+namedconf.QuoteString(p.KeysConfig())+";",
|
||||||
|
"TSIG keys (and the view keys) live in their own file, which only named and jarld can read", "always")
|
||||||
|
st := x.Stats
|
||||||
|
logResponses := st != nil && st.LogFile != ""
|
||||||
|
pzs := PolicyZones(s, p)
|
||||||
|
if st != nil {
|
||||||
|
_, user := findTop(s, "statistics-channels")
|
||||||
|
why := "named publishes its counters for jarld's query statistics, on a loopback address only"
|
||||||
|
if user {
|
||||||
|
why += " (added to your statistics-channels statement)"
|
||||||
|
}
|
||||||
|
add("top level", formatStmt(namedconf.NewStmt("statistics-channels", namedconf.NewBlock(st.statsInet())), 0), why, "stats.enabled in jarl.yaml")
|
||||||
|
}
|
||||||
|
if logResponses {
|
||||||
|
b := namedconf.NewBlock(st.logChannel())
|
||||||
|
cat := func(name string, extra ...string) {
|
||||||
|
ch := namedconf.NewBlock(namedconf.NewStmt(StatsChannelName))
|
||||||
|
for _, e := range extra {
|
||||||
|
ch.Stmts = append(ch.Stmts, namedconf.NewStmt(e))
|
||||||
|
}
|
||||||
|
b.Stmts = append(b.Stmts, namedconf.NewStmt("category", name, ch))
|
||||||
|
}
|
||||||
|
why := []string{"responses: every answer, for the statistics' names, clients and views"}
|
||||||
|
cat("responses")
|
||||||
|
if len(pzs) > 0 {
|
||||||
|
cat("rpz")
|
||||||
|
why = append(why, "rpz: what blocking blocked")
|
||||||
|
}
|
||||||
|
if logRateLimit(s, st) {
|
||||||
|
cat("query-errors", "default_syslog")
|
||||||
|
why = append(why, "query-errors: what rate limiting limited, or would in log-only mode, for the Exposure page (also to syslog, as before)")
|
||||||
|
}
|
||||||
|
w := "named logs to a file jarld reads (" + strings.Join(why, "; ") + ")"
|
||||||
|
if _, user := findTop(s, "logging"); user {
|
||||||
|
w += "; merged into your logging statement"
|
||||||
|
}
|
||||||
|
setting := "stats.query_log in jarl.yaml"
|
||||||
|
if logRateLimit(s, st) || hasRateLimit(s) {
|
||||||
|
setting += "; query-errors: stats.rate_limit_log"
|
||||||
|
}
|
||||||
|
add("top level", formatStmt(namedconf.NewStmt("logging", b), 0), w, setting)
|
||||||
|
if s.FindOption(model.ScopeOptions, 0, "responselog") == nil {
|
||||||
|
add("options", "responselog yes;", "named logs answers from the start, for the statistics", "stats.query_log in jarl.yaml")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
views := s.OrderedViews()
|
||||||
|
scopes := []struct {
|
||||||
|
id int64
|
||||||
|
name string
|
||||||
|
}{{0, "options"}}
|
||||||
|
if len(views) > 0 {
|
||||||
|
scopes = scopes[:0]
|
||||||
|
for _, v := range views {
|
||||||
|
scopes = append(scopes, struct {
|
||||||
|
id int64
|
||||||
|
name string
|
||||||
|
}{v.ID, "view " + v.Name})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, sc := range scopes {
|
||||||
|
zs := scopeZones(pzs, sc.id)
|
||||||
|
if len(zs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
w := newConfWriter()
|
||||||
|
writeResponsePolicy(w, zs, logResponses)
|
||||||
|
writePolicyZones(w, s, zs, &Output{}, 0)
|
||||||
|
add(sc.name, dedent(w.sb.String()), "blocking: your rules and blocklists, as response policy zones named loads", "the Blocking page (jarl block, jarl blocklist)")
|
||||||
|
}
|
||||||
|
if viewKeysOn(s, Extras{ViewKeySeed: x.ViewKeySeed}) {
|
||||||
|
for _, v := range views {
|
||||||
|
mc := viewMatchClients(s, x, v)
|
||||||
|
var keys []string
|
||||||
|
for _, e := range mc {
|
||||||
|
if strings.Contains(e, "key ") {
|
||||||
|
keys = append(keys, e+";")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add("view "+v.Name, "match-clients { "+strings.Join(keys, " ")+" ... };",
|
||||||
|
"view keys: a query signed with a view's key gets that view, from any address, and never another view (jarl view key)", "always, when there are views")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func findTop(s *model.State, name string) (*model.Option, bool) {
|
||||||
|
for _, o := range s.ScopedOptions(model.ScopeTopLevel, 0) {
|
||||||
|
if o.Name == name {
|
||||||
|
return o, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasRateLimit(s *model.State) bool {
|
||||||
|
for _, o := range s.Options {
|
||||||
|
if o.Name == "rate-limit" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// logRateLimit reports whether rate limiting's log lines go to the
|
||||||
|
// response log (docs/exposure.md).
|
||||||
|
func logRateLimit(s *model.State, st *StatsHooks) bool {
|
||||||
|
return st != nil && st.LogFile != "" && !st.NoRateLimitLog && hasRateLimit(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dedent removes one leading tab from every line.
|
||||||
|
func dedent(s string) string {
|
||||||
|
lines := strings.Split(s, "\n")
|
||||||
|
for i, l := range lines {
|
||||||
|
lines[i] = strings.TrimPrefix(l, "\t")
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
@@ -274,10 +274,7 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa
|
|||||||
logRewrites := logResponses && len(pzs) > 0
|
logRewrites := logResponses && len(pzs) > 0
|
||||||
// Rate limiting logs what it limits (or would, in log-only mode) as
|
// Rate limiting logs what it limits (or would, in log-only mode) as
|
||||||
// query-errors; the statistics count it (docs/exposure.md).
|
// query-errors; the statistics count it (docs/exposure.md).
|
||||||
logRRL := false
|
logRRL := logRateLimit(s, st)
|
||||||
for _, o := range s.Options {
|
|
||||||
logRRL = logRRL || logResponses && o.Name == "rate-limit"
|
|
||||||
}
|
|
||||||
sawStats, sawLogging := false, false
|
sawStats, sawLogging := false, false
|
||||||
if top := s.ScopedOptions(model.ScopeTopLevel, 0); len(top) > 0 {
|
if top := s.ScopedOptions(model.ScopeTopLevel, 0); len(top) > 0 {
|
||||||
w.write("\n")
|
w.write("\n")
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ type StatsHooks struct {
|
|||||||
LogFile string // response log; "" disables response logging
|
LogFile string // response log; "" disables response logging
|
||||||
LogVersions int
|
LogVersions int
|
||||||
LogSize string
|
LogSize string
|
||||||
|
// NoRateLimitLog keeps rate limiting's log lines (category
|
||||||
|
// query-errors) out of the response log (stats.rate_limit_log: false).
|
||||||
|
NoRateLimitLog bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// StatsChannelName is the logging channel Jarl adds for responses.
|
// StatsChannelName is the logging channel Jarl adds for responses.
|
||||||
|
|||||||
@@ -82,3 +82,45 @@ func TestStatsHooksMerged(t *testing.T) {
|
|||||||
func TestStatsHooksWithNamed(t *testing.T) {
|
func TestStatsHooksWithNamed(t *testing.T) {
|
||||||
checkWithNamed(t, sampleState(t), Extras{Stats: statsHooks()})
|
checkWithNamed(t, sampleState(t), Extras{Stats: statsHooks()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rate limiting's log lines go to the response log unless
|
||||||
|
// stats.rate_limit_log is off; the list of what Jarl adds says so.
|
||||||
|
func TestRateLimitLogAndGenerated(t *testing.T) {
|
||||||
|
s := sampleState(t)
|
||||||
|
for id, o := range s.Options {
|
||||||
|
if o.Name == "logging" {
|
||||||
|
delete(s.Options, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.Options[20000] = &model.Option{ID: 20000, Scope: model.ScopeOptions, Name: "rate-limit", Value: "{ responses-per-second 10; log-only yes; }", Position: 20000}
|
||||||
|
p := Paths{ConfigDir: "/etc/bind/jarl", ZoneDir: "/var/lib/bind/jarl", CacheDir: "/var/cache/bind/jarl"}
|
||||||
|
conf := string(Render(s, p, Extras{Stats: statsHooks()}).File(p.MainConfig()).Content)
|
||||||
|
if !strings.Contains(conf, "category query-errors { jarl_responses; default_syslog; };") {
|
||||||
|
t.Errorf("query-errors not logged:\n%s", conf)
|
||||||
|
}
|
||||||
|
gen := GeneratedStatements(s, p, Extras{Stats: statsHooks()})
|
||||||
|
var logging *Generated
|
||||||
|
for i := range gen {
|
||||||
|
if strings.HasPrefix(gen[i].Text, "logging") {
|
||||||
|
logging = &gen[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if logging == nil || !strings.Contains(logging.Text, "query-errors") || !strings.Contains(logging.Setting, "stats.rate_limit_log") {
|
||||||
|
t.Errorf("generated logging = %+v (all %+v)", logging, gen)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(gen[0].Text, "include ") || gen[0].Where != "top level" {
|
||||||
|
t.Errorf("first generated = %+v", gen[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
h := statsHooks()
|
||||||
|
h.NoRateLimitLog = true
|
||||||
|
conf = string(Render(s, p, Extras{Stats: h}).File(p.MainConfig()).Content)
|
||||||
|
if strings.Contains(conf, "query-errors") || !strings.Contains(conf, "category responses") {
|
||||||
|
t.Errorf("rate_limit_log off but query-errors logged:\n%s", conf)
|
||||||
|
}
|
||||||
|
for _, g := range GeneratedStatements(s, p, Extras{Stats: h}) {
|
||||||
|
if strings.Contains(g.Text, "query-errors") {
|
||||||
|
t.Errorf("listed while off: %+v", g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -158,7 +158,7 @@ func primaryZonesFor(st *model.State, p *auth.Principal) []acme.Zone {
|
|||||||
for _, z := range st.SortedZones() {
|
for _, z := range st.SortedZones() {
|
||||||
vn := st.ViewName(z)
|
vn := st.ViewName(z)
|
||||||
if z.Type == model.ZonePrimary && !z.Disabled && p.CanSeeZone(z.Name, vn) {
|
if z.Type == model.ZonePrimary && !z.Disabled && p.CanSeeZone(z.Name, vn) {
|
||||||
out = append(out, acme.Zone{Name: z.Name, View: vn})
|
out = append(out, acme.Zone{Name: z.Name, View: vn, Link: z.LinkGroup})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
@@ -285,6 +286,20 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
|||||||
resp.TOTP = s.totpOverview(r.Context())
|
resp.TOTP = s.totpOverview(r.Context())
|
||||||
resp.Certificates = s.certSummary(r.Context())
|
resp.Certificates = s.certSummary(r.Context())
|
||||||
}
|
}
|
||||||
|
if p.Can(auth.Action{Resource: auth.ResOptions}) {
|
||||||
|
if h := s.encryptedDNSHealth(); h != nil {
|
||||||
|
sum := &api.EncDNSSummary{Checked: h.Checked, OK: h.OK(), Problems: []string{}, Warnings: []string{}}
|
||||||
|
for _, pr := range h.Probes {
|
||||||
|
where := fmt.Sprintf("%s on %s port %d", map[string]string{"dot": "DNS over TLS", "doh": "DNS over HTTPS"}[pr.Kind], pr.Address, pr.Port)
|
||||||
|
if pr.Problem != "" {
|
||||||
|
sum.Problems = append(sum.Problems, where+": "+pr.Problem)
|
||||||
|
} else if pr.Warning != "" {
|
||||||
|
sum.Warnings = append(sum.Warnings, where+": "+pr.Warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp.EncryptedDNS = sum
|
||||||
|
}
|
||||||
|
}
|
||||||
if p.Can(auth.Action{Resource: auth.ResBlocking}) {
|
if p.Can(auth.Action{Resource: auth.ResBlocking}) {
|
||||||
resp.BlocklistErrors = s.blocklistErrors(r.Context())
|
resp.BlocklistErrors = s.blocklistErrors(r.Context())
|
||||||
if st, err := s.store.LoadState(r.Context()); err == nil {
|
if st, err := s.store.LoadState(r.Context()); err == nil {
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/engine"
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// zoneCompare: GET /zones/{zone}/compare lines up a zone's records across
|
||||||
|
// the views that have it (docs/views.md#linked-zones). ?views=a,b picks the
|
||||||
|
// views (default: every view with a primary zone of that name), ?all=1
|
||||||
|
// includes the names and types that are the same everywhere.
|
||||||
|
func (s *Server) zoneCompare(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
st, err := s.state(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
z, err := s.zoneByPath(r, st, p)
|
||||||
|
if err != nil {
|
||||||
|
// A name in several views is what there is to compare.
|
||||||
|
ref := r.PathValue("zone")
|
||||||
|
if ref == "root" {
|
||||||
|
ref = "."
|
||||||
|
}
|
||||||
|
name, perr := engine.ParseZoneName(ref)
|
||||||
|
if zs := st.ZonesNamed(name); perr != nil || len(zs) < 2 {
|
||||||
|
return err
|
||||||
|
} else {
|
||||||
|
z = zs[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var want []string
|
||||||
|
for _, v := range strings.Split(r.URL.Query().Get("views"), ",") {
|
||||||
|
if v = strings.TrimSpace(v); v != "" {
|
||||||
|
want = append(want, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var zones []*model.Zone
|
||||||
|
for _, o := range st.ZonesNamed(z.Name) {
|
||||||
|
vn := st.ViewName(o)
|
||||||
|
if o.Type != model.ZonePrimary || !p.CanSeeZone(o.Name, vn) || !p.Can(auth.Action{Resource: auth.ResRecords, Zone: o.Name, View: vn}) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(want) > 0 && !containsFold(want, vn) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
zones = append(zones, o)
|
||||||
|
}
|
||||||
|
st.SortByView(zones)
|
||||||
|
if len(zones) < 2 {
|
||||||
|
return badRequest("zone %s is in only one view (that you may read): there is nothing to compare", engine.Display(z.Name))
|
||||||
|
}
|
||||||
|
rows := engine.CompareZones(st, zones)
|
||||||
|
all := queryBool(r, "all")
|
||||||
|
out := struct {
|
||||||
|
Zone string `json:"zone"`
|
||||||
|
Views []string `json:"views"`
|
||||||
|
Linked bool `json:"linked"` // all of them in one link
|
||||||
|
Same int `json:"same"` // names and types the same everywhere
|
||||||
|
Rows []engine.CompareRow `json:"rows"`
|
||||||
|
}{Zone: engine.Display(z.Name), Rows: []engine.CompareRow{}, Linked: true}
|
||||||
|
for _, o := range zones {
|
||||||
|
out.Views = append(out.Views, st.ViewName(o))
|
||||||
|
out.Linked = out.Linked && o.LinkGroup != 0 && o.LinkGroup == zones[0].LinkGroup
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.Same {
|
||||||
|
out.Same++
|
||||||
|
if !all {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Rows = append(out.Rows, row)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsFold(list []string, s string) bool {
|
||||||
|
for _, x := range list {
|
||||||
|
if strings.EqualFold(x, s) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -659,3 +659,22 @@ func (s *Server) search(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
|||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// generatedOptions: GET /options/generated lists what Jarl adds to
|
||||||
|
// named.conf by itself (the statistics channel, its logging, blocking's
|
||||||
|
// response-policy, view keys), with why and what controls it.
|
||||||
|
func (s *Server) generatedOptions(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := require(p, auth.Action{Resource: auth.ResOptions}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, err := s.store.LoadState(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out := s.eng.GeneratedStatements(r.Context(), st)
|
||||||
|
if out == nil {
|
||||||
|
out = []render.Generated{}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/certs"
|
||||||
|
"jarl/internal/encdns"
|
||||||
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/nscheck"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DNS over TLS and HTTPS (docs/encrypted-dns.md).
|
||||||
|
|
||||||
|
type encAddress struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Private bool `json:"private"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type encView struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Public bool `json:"public"`
|
||||||
|
HasZone bool `json:"has_zone,omitempty"` // has a zone for the host name
|
||||||
|
}
|
||||||
|
|
||||||
|
type encCert struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Names []string `json:"names"`
|
||||||
|
Domains []string `json:"domains"`
|
||||||
|
DNS bool `json:"dns"` // has the dns use
|
||||||
|
Status certs.Status `json:"status"`
|
||||||
|
Obtained bool `json:"obtained"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type encStatus struct {
|
||||||
|
encdns.Status
|
||||||
|
// Hostname is the name clients connect to (the certificate's first).
|
||||||
|
Hostname string `json:"hostname,omitempty"`
|
||||||
|
// DoTHost and DoHURL are what to give clients.
|
||||||
|
DoTHost string `json:"dot_host,omitempty"`
|
||||||
|
DoHURL string `json:"doh_url,omitempty"`
|
||||||
|
// Health is the last check of the listeners (every 10 minutes).
|
||||||
|
Health *encdns.Health `json:"health,omitempty"`
|
||||||
|
// For the setup form.
|
||||||
|
Addresses []encAddress `json:"addresses"`
|
||||||
|
Views []encView `json:"views"`
|
||||||
|
Certificates []encCert `json:"certificates"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) encReady(p *auth.Principal, write bool) error {
|
||||||
|
if err := require(p, auth.Action{Resource: auth.ResOptions, Write: write}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s.cfg.Certs == nil {
|
||||||
|
return &httpError{http.StatusNotFound, "disabled", "certificates aren't available in this jarld"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// localAddrs returns this host's addresses (a test may replace it).
|
||||||
|
var localAddrs = nscheck.LocalAddrs
|
||||||
|
|
||||||
|
// encryptedDNS: GET /encrypted-dns.
|
||||||
|
func (s *Server) encryptedDNS(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := s.encReady(p, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, err := s.store.LoadState(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m := s.cfg.Certs
|
||||||
|
out := encStatus{Status: encdns.GetStatus(st, m.FilesDir("")), Addresses: []encAddress{}, Views: []encView{}, Certificates: []encCert{}}
|
||||||
|
all, err := m.List(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, c := range all {
|
||||||
|
out.Certificates = append(out.Certificates, encCert{Name: c.Name, Names: c.Names, Domains: c.Domains, DNS: c.HasUse(model.CertUseDNS),
|
||||||
|
Status: m.StatusOf(c), Obtained: c.NotAfter != nil})
|
||||||
|
if c.Name == out.Certificate {
|
||||||
|
for _, n := range append(c.Names, c.Domains...) {
|
||||||
|
if !strings.HasPrefix(n, "*.") {
|
||||||
|
out.Hostname = n
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.Hostname != "" {
|
||||||
|
if len(out.DoT) > 0 {
|
||||||
|
out.DoTHost = out.Hostname
|
||||||
|
if out.DoT[0].Port != encdns.DoTPort {
|
||||||
|
out.DoTHost += ":" + strconv.Itoa(out.DoT[0].Port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out.DoH) > 0 {
|
||||||
|
out.DoHURL = "https://" + out.Hostname
|
||||||
|
if out.DoH[0].Port != 443 {
|
||||||
|
out.DoHURL += ":" + strconv.Itoa(out.DoH[0].Port)
|
||||||
|
}
|
||||||
|
out.DoHURL += "/dns-query"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.Enabled {
|
||||||
|
out.Health = s.encryptedDNSHealth()
|
||||||
|
}
|
||||||
|
seen := map[netip.Addr]bool{}
|
||||||
|
for _, a := range localAddrs() {
|
||||||
|
a = a.Unmap()
|
||||||
|
if seen[a] || a.IsLinkLocalUnicast() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[a] = true
|
||||||
|
out.Addresses = append(out.Addresses, encAddress{Address: a.String(), Private: encdns.Private(a)})
|
||||||
|
}
|
||||||
|
slices.SortFunc(out.Addresses, func(a, b encAddress) int { return strings.Compare(a.Address, b.Address) })
|
||||||
|
host := strings.TrimSuffix(r.URL.Query().Get("hostname"), ".")
|
||||||
|
if host == "" {
|
||||||
|
host = out.Hostname
|
||||||
|
}
|
||||||
|
for _, x := range s.eng.Exposures(st) {
|
||||||
|
if x.View == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v := encView{Name: x.View, Public: x.Public}
|
||||||
|
if host != "" {
|
||||||
|
if vv := st.ViewByName(x.View); vv != nil {
|
||||||
|
for _, z := range st.ZonesInView(vv.ID) {
|
||||||
|
if z.Type == model.ZonePrimary && (host+"." == z.Name || strings.HasSuffix(host+".", "."+z.Name)) {
|
||||||
|
v.HasZone = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Views = append(out.Views, v)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptedDNSCheck: POST /encrypted-dns/check {"addresses", "dot", "doh",
|
||||||
|
// "doh_port"} says what already listens where the listeners would go.
|
||||||
|
func (s *Server) encryptedDNSCheck(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := s.encReady(p, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var in encdns.Setup
|
||||||
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, err := s.store.LoadState(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if in.DoHPort == 0 {
|
||||||
|
in.DoHPort = 443
|
||||||
|
}
|
||||||
|
if in.DoTPort == 0 {
|
||||||
|
in.DoTPort = encdns.DoTPort
|
||||||
|
}
|
||||||
|
var ports []int
|
||||||
|
if in.DoT {
|
||||||
|
ports = append(ports, in.DoTPort)
|
||||||
|
}
|
||||||
|
if in.DoH {
|
||||||
|
ports = append(ports, in.DoHPort)
|
||||||
|
}
|
||||||
|
conflicts, err := encdns.PortCheck(st, in.Addresses, ports, s.cfg.Listen)
|
||||||
|
out := map[string]any{"conflicts": conflicts}
|
||||||
|
if err != nil {
|
||||||
|
out["error"] = "can't see which ports are in use: " + err.Error()
|
||||||
|
}
|
||||||
|
if conflicts == nil {
|
||||||
|
out["conflicts"] = []encdns.Conflict{}
|
||||||
|
}
|
||||||
|
if in.DoH {
|
||||||
|
out["free_doh_port"] = encdns.FreeDoHPort(st, in.Addresses, s.cfg.Listen)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptedDNSSetup: POST /encrypted-dns stages (or applies) the setup.
|
||||||
|
func (s *Server) encryptedDNSSetup(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := s.encReady(p, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var in encdns.Setup
|
||||||
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c, err := s.cfg.Certs.Get(r.Context(), in.Certificate)
|
||||||
|
if err != nil {
|
||||||
|
return certErr(err)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case !c.HasUse(model.CertUseDNS):
|
||||||
|
return badRequest("certificate %s doesn't have the dns use, so named can't read it: jarl cert update %s --use dns (and keep its other uses)", c.Name, c.Name)
|
||||||
|
case c.NotAfter == nil:
|
||||||
|
return conflict("certificate %s hasn't been obtained yet (%s); set up encrypted DNS once it has", c.Name, s.cfg.Certs.StatusOf(c).Message)
|
||||||
|
case in.Hostname != "" && !certs.Covers(c.Names, []string{in.Hostname}):
|
||||||
|
return badRequest("certificate %s is for %s, not %s: clients would reject it", c.Name, strings.Join(c.Names, ", "), in.Hostname)
|
||||||
|
}
|
||||||
|
st, err := s.store.LoadState(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reqs, notes, err := encdns.Plan(st, in, s.cfg.Certs.FilesDir(""))
|
||||||
|
if err != nil {
|
||||||
|
return badRequest("%v", err)
|
||||||
|
}
|
||||||
|
what := []string{}
|
||||||
|
if in.DoT {
|
||||||
|
what = append(what, "DNS over TLS")
|
||||||
|
}
|
||||||
|
if in.DoH {
|
||||||
|
what = append(what, "DNS over HTTPS")
|
||||||
|
}
|
||||||
|
return s.mutateNotes(w, r, p, "set up "+strings.Join(what, " and ")+" for "+in.Hostname, reqs, nil, notes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptedDNSDisable: DELETE /encrypted-dns.
|
||||||
|
func (s *Server) encryptedDNSDisable(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := s.encReady(p, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, err := s.store.LoadState(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reqs := encdns.Disable(st)
|
||||||
|
if len(reqs) == 0 {
|
||||||
|
return conflict("encrypted DNS isn't set up")
|
||||||
|
}
|
||||||
|
return s.mutate(w, r, p, "turn DNS over TLS and HTTPS off", reqs, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// encTarget returns the host name clients connect to and the serial of
|
||||||
|
// the certificate Jarl last wrote for named.
|
||||||
|
func (s *Server) encTarget(ctx context.Context, st encdns.Status) (host, serial string) {
|
||||||
|
if s.cfg.Certs == nil || st.Certificate == "" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
c, err := s.cfg.Certs.Get(ctx, st.Certificate)
|
||||||
|
if err != nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
for _, n := range append(c.Names, c.Domains...) {
|
||||||
|
if !strings.HasPrefix(n, "*.") {
|
||||||
|
host = n
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return host, c.Serial
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkEncryptedDNS checks the DoT and DoH listeners as a client would and
|
||||||
|
// keeps the result; a change for the worse is logged.
|
||||||
|
func (s *Server) checkEncryptedDNS(ctx context.Context) *encdns.Health {
|
||||||
|
if s.cfg.Certs == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
state, err := s.store.LoadState(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
st := encdns.GetStatus(state, s.cfg.Certs.FilesDir(""))
|
||||||
|
var h *encdns.Health
|
||||||
|
if st.Enabled {
|
||||||
|
if host, serial := s.encTarget(ctx, st); host != "" {
|
||||||
|
h = encdns.Check(ctx, st, host, serial, s.encRoots)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.encHealth.Lock()
|
||||||
|
prev := s.encHealth.h
|
||||||
|
s.encHealth.h = h
|
||||||
|
s.encHealth.Unlock()
|
||||||
|
if h != nil {
|
||||||
|
for _, p := range h.Probes {
|
||||||
|
if p.OK {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
was := false
|
||||||
|
if prev != nil {
|
||||||
|
for _, q := range prev.Probes {
|
||||||
|
was = was || q.Kind == p.Kind && q.Address == p.Address && q.Port == p.Port && !q.OK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !was {
|
||||||
|
s.log.Warn("encrypted DNS isn't working", "kind", p.Kind, "address", p.Address, "port", p.Port, "problem", p.Problem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) encryptedDNSHealth() *encdns.Health {
|
||||||
|
s.encHealth.Lock()
|
||||||
|
defer s.encHealth.Unlock()
|
||||||
|
return s.encHealth.h
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptedDNSCheckNow: POST /encrypted-dns/health checks the listeners now.
|
||||||
|
func (s *Server) encryptedDNSCheckNow(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := s.encReady(p, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
h := s.checkEncryptedDNS(r.Context())
|
||||||
|
if h == nil {
|
||||||
|
return conflict("encrypted DNS isn't set up")
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, h)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"jarl/internal/mailcheck"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -32,6 +33,11 @@ type exposureReport struct {
|
|||||||
// RateLimiting is what rate limiting limited, or would have, in the
|
// RateLimiting is what rate limiting limited, or would have, in the
|
||||||
// last 24 hours (with query statistics on).
|
// last 24 hours (with query statistics on).
|
||||||
RateLimiting *rateLimitActivity `json:"rate_limiting,omitempty"`
|
RateLimiting *rateLimitActivity `json:"rate_limiting,omitempty"`
|
||||||
|
// RateLimitLog is false when stats.rate_limit_log is off: what rate
|
||||||
|
// limiting does isn't counted then.
|
||||||
|
RateLimitLog bool `json:"rate_limit_log"`
|
||||||
|
// Mail is the last check of the mail addresses' reverse DNS.
|
||||||
|
Mail *mailcheck.Report `json:"mail,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type rateLimitActivity struct {
|
type rateLimitActivity struct {
|
||||||
@@ -52,7 +58,7 @@ func (s *Server) exposure(w http.ResponseWriter, r *http.Request, p *auth.Princi
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
problems := s.eng.Validate(st)
|
problems := s.eng.Validate(st)
|
||||||
out := exposureReport{Scopes: []exposureScope{}, Server: []exposureFinding{}}
|
out := exposureReport{Scopes: []exposureScope{}, Server: []exposureFinding{}, RateLimitLog: s.cfg.RateLimitLog}
|
||||||
finding := func(pr validate.Problem, viewID int64) exposureFinding {
|
finding := func(pr validate.Problem, viewID int64) exposureFinding {
|
||||||
_, desc := engine.ExposureFix(st, viewID, pr.Code)
|
_, desc := engine.ExposureFix(st, viewID, pr.Code)
|
||||||
return exposureFinding{Problem: pr, Fix: desc}
|
return exposureFinding{Problem: pr, Fix: desc}
|
||||||
@@ -96,6 +102,7 @@ func (s *Server) exposure(w http.ResponseWriter, r *http.Request, p *auth.Princi
|
|||||||
a.Names, _ = s.statsQ.Top(ctx, stats.KindRateLimitedName, since, 10)
|
a.Names, _ = s.statsQ.Top(ctx, stats.KindRateLimitedName, since, 10)
|
||||||
out.RateLimiting = a
|
out.RateLimiting = a
|
||||||
}
|
}
|
||||||
|
out.Mail = s.mailReport()
|
||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/mailcheck"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mailCheckEvery is how often jarld checks the reverse DNS of the
|
||||||
|
// addresses that send and receive mail (docs/exposure.md#mail).
|
||||||
|
const mailCheckEvery = 6 * time.Hour
|
||||||
|
|
||||||
|
// checkMail checks the reverse DNS of the public zones' mail addresses and
|
||||||
|
// keeps the report; it logs addresses that became wrong.
|
||||||
|
func (s *Server) checkMail(ctx context.Context) *mailcheck.Report {
|
||||||
|
st, err := s.store.LoadState(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("mail reverse DNS check: loading state", "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
public := map[int64]bool{}
|
||||||
|
for _, x := range s.eng.Exposures(st) {
|
||||||
|
public[x.ViewID] = x.Public
|
||||||
|
}
|
||||||
|
if len(st.Views) == 0 {
|
||||||
|
public[0] = true
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
rep := mailcheck.Check(ctx, st, func(id int64) bool { return public[id] }, mailcheck.Resolver{Servers: s.cfg.Resolvers})
|
||||||
|
s.mail.Lock()
|
||||||
|
prev := s.mail.r
|
||||||
|
s.mail.r = rep
|
||||||
|
s.mail.Unlock()
|
||||||
|
was := map[string]bool{}
|
||||||
|
if prev != nil {
|
||||||
|
for _, r := range prev.Results {
|
||||||
|
was[r.Zone+"|"+r.Address] = r.OK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, r := range rep.Results {
|
||||||
|
if ok, seen := was[r.Zone+"|"+r.Address]; !r.OK && (ok || !seen) {
|
||||||
|
s.log.Warn("mail reverse DNS", "zone", r.Zone, "address", r.Address, "problem", r.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rep
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) mailReport() *mailcheck.Report {
|
||||||
|
s.mail.Lock()
|
||||||
|
defer s.mail.Unlock()
|
||||||
|
return s.mail.r
|
||||||
|
}
|
||||||
|
|
||||||
|
// mailCheckNow: POST /exposure/mail checks now and returns the report.
|
||||||
|
func (s *Server) mailCheckNow(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := require(p, auth.Action{Resource: auth.ResOptions}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rep := s.checkMail(r.Context())
|
||||||
|
if rep == nil {
|
||||||
|
rep = &mailcheck.Report{Checked: time.Now(), Results: []mailcheck.Result{}}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, rep)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mailLoop checks a minute after the start, then every mailCheckEvery.
|
||||||
|
func (s *Server) mailLoop(ctx context.Context) {
|
||||||
|
wait := time.Minute
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(wait):
|
||||||
|
s.checkMail(ctx)
|
||||||
|
wait = mailCheckEvery
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -14,14 +15,24 @@ import (
|
|||||||
"jarl/internal/stats"
|
"jarl/internal/stats"
|
||||||
)
|
)
|
||||||
|
|
||||||
// statsQuerier checks permission and that statistics are enabled.
|
// statsQuerier checks permission and that statistics are enabled. With
|
||||||
func (s *Server) statsQuerier(p *auth.Principal) (*stats.Querier, error) {
|
// ?views=internal,vps the figures are for those views only.
|
||||||
|
func (s *Server) statsQuerier(r *http.Request, p *auth.Principal) (*stats.Querier, error) {
|
||||||
if err := require(p, auth.Action{Resource: auth.ResStats}); err != nil {
|
if err := require(p, auth.Action{Resource: auth.ResStats}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if s.statsQ == nil {
|
if s.statsQ == nil {
|
||||||
return nil, &httpError{http.StatusNotFound, "stats_disabled", "query statistics are disabled; set stats.enabled: true in /etc/jarl/jarl.yaml and restart jarl (see docs/stats.md)"}
|
return nil, &httpError{http.StatusNotFound, "stats_disabled", "query statistics are disabled; set stats.enabled: true in /etc/jarl/jarl.yaml and restart jarl (see docs/stats.md)"}
|
||||||
}
|
}
|
||||||
|
var views []string
|
||||||
|
for _, v := range strings.Split(r.URL.Query().Get("views"), ",") {
|
||||||
|
if v = strings.TrimSpace(v); v != "" {
|
||||||
|
views = append(views, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(views) > 0 {
|
||||||
|
return s.statsQ.ForViews(views), nil
|
||||||
|
}
|
||||||
return s.statsQ, nil
|
return s.statsQ, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +57,7 @@ func (s *Server) statsStatus(w http.ResponseWriter, r *http.Request, p *auth.Pri
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) statsSummary(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) statsSummary(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
q, err := s.statsQuerier(p)
|
q, err := s.statsQuerier(r, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -66,7 +77,7 @@ func (s *Server) statsSummary(w http.ResponseWriter, r *http.Request, p *auth.Pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) statsTop(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) statsTop(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
q, err := s.statsQuerier(p)
|
q, err := s.statsQuerier(r, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -93,7 +104,7 @@ func (s *Server) statsTop(w http.ResponseWriter, r *http.Request, p *auth.Princi
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) statsTimeline(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) statsTimeline(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
q, err := s.statsQuerier(p)
|
q, err := s.statsQuerier(r, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -124,7 +135,7 @@ func (s *Server) statsTimeline(w http.ResponseWriter, r *http.Request, p *auth.P
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) statsHeatmap(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) statsHeatmap(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
q, err := s.statsQuerier(p)
|
q, err := s.statsQuerier(r, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -143,7 +154,7 @@ func (s *Server) statsHeatmap(w http.ResponseWriter, r *http.Request, p *auth.Pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) statsBreakdown(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) statsBreakdown(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
q, err := s.statsQuerier(p)
|
q, err := s.statsQuerier(r, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -152,6 +163,9 @@ func (s *Server) statsBreakdown(w http.ResponseWriter, r *http.Request, p *auth.
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
es, err := q.Breakdown(r.Context(), r.URL.Query().Get("group"), since)
|
es, err := q.Breakdown(r.Context(), r.URL.Query().Get("group"), since)
|
||||||
|
if errors.Is(err, stats.ErrNotPerView) {
|
||||||
|
return &httpError{http.StatusBadRequest, "not_per_view", r.URL.Query().Get("group") + ": " + err.Error()}
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return badRequest("%v", err)
|
return badRequest("%v", err)
|
||||||
}
|
}
|
||||||
@@ -247,7 +261,7 @@ func clientNames(st *model.State, want map[netip.Addr]bool, readable func(*model
|
|||||||
// limits (rndc status), and the space left on the file systems Jarl and
|
// limits (rndc status), and the space left on the file systems Jarl and
|
||||||
// named write to.
|
// named write to.
|
||||||
func (s *Server) statsServer(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) statsServer(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
if _, err := s.statsQuerier(p); err != nil {
|
if _, err := s.statsQuerier(r, p); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
out := map[string]any{"server": s.cfg.Stats.Server()}
|
out := map[string]any{"server": s.cfg.Stats.Server()}
|
||||||
|
|||||||
@@ -157,6 +157,37 @@ func (s *Server) deleteZone(w http.ResponseWriter, r *http.Request, p *auth.Prin
|
|||||||
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpZoneDelete, z.ID, nil)}, nil)
|
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpZoneDelete, z.ID, nil)}, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// zoneLink: POST /zones/{zone}/link {"with": ["external"]} links the zone
|
||||||
|
// with the zones of the same name in those views (default: every view
|
||||||
|
// that has one). zoneUnlink: POST /zones/{zone}/unlink.
|
||||||
|
func (s *Server) zoneLink(link bool) handler {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
st, err := s.state(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
z, err := s.zoneByPath(r, st, p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
in := engine.LinkInput{ZoneID: z.ID}
|
||||||
|
op := engine.OpZoneUnlink
|
||||||
|
if link {
|
||||||
|
op = engine.OpZoneLink
|
||||||
|
var body struct {
|
||||||
|
With []string `json:"with"`
|
||||||
|
}
|
||||||
|
if r.ContentLength != 0 {
|
||||||
|
if err := decodeJSON(r, &body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
in.With = body.With
|
||||||
|
}
|
||||||
|
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(op, 0, in)}, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) zoneFile(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) zoneFile(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
st, err := s.state(r)
|
st, err := s.state(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -462,11 +493,12 @@ func (s *Server) putRRSet(w http.ResponseWriter, r *http.Request, p *auth.Princi
|
|||||||
TTL model.Opt[uint32] `json:"ttl,omitzero"`
|
TTL model.Opt[uint32] `json:"ttl,omitzero"`
|
||||||
Data []string `json:"data"`
|
Data []string `json:"data"`
|
||||||
Comment *string `json:"comment,omitempty"`
|
Comment *string `json:"comment,omitempty"`
|
||||||
|
Local *bool `json:"local,omitempty"`
|
||||||
}
|
}
|
||||||
if err := decodeJSON(r, &in); err != nil {
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
req := engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: r.PathValue("name"), Type: r.PathValue("type"), TTL: in.TTL, Data: in.Data, Comment: in.Comment})
|
req := engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: r.PathValue("name"), Type: r.PathValue("type"), TTL: in.TTL, Data: in.Data, Comment: in.Comment, Local: in.Local})
|
||||||
zid, name, typ := z.ID, r.PathValue("name"), strings.ToUpper(r.PathValue("type"))
|
zid, name, typ := z.ID, r.PathValue("name"), strings.ToUpper(r.PathValue("type"))
|
||||||
return s.mutate(w, r, p, "", []engine.Request{req}, func(st *model.State, _ int64) any {
|
return s.mutate(w, r, p, "", []engine.Request{req}, func(st *model.State, _ int64) any {
|
||||||
z := st.Zones[zid]
|
z := st.Zones[zid]
|
||||||
|
|||||||
@@ -289,6 +289,20 @@ func (s *Server) collectState(ctx context.Context, pw *promWriter, now time.Time
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if h := s.encryptedDNSHealth(); h != nil {
|
||||||
|
for _, pr := range h.Probes {
|
||||||
|
pw.gauge("jarl_encrypted_dns_up", "1 when the DNS over TLS or HTTPS listener answered with the right, current certificate at the last check (every 10 minutes).", b2f(pr.OK),
|
||||||
|
"kind", pr.Kind, "address", pr.Address, "port", strconv.Itoa(pr.Port))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if m := s.mailReport(); m != nil {
|
||||||
|
for _, r := range m.Results {
|
||||||
|
pw.gauge("jarl_mail_reverse_dns_ok", "1 when an address that sends or receives a public zone's mail has a PTR record that resolves back to it, at the last check (every 6 hours).", b2f(r.OK),
|
||||||
|
"zone", r.Zone, "address", r.Address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if tokens, err := s.store.ListTokens(ctx, 0); err == nil {
|
if tokens, err := s.store.ListTokens(ctx, 0); err == nil {
|
||||||
for _, t := range tokens {
|
for _, t := range tokens {
|
||||||
if t.ExpiresAt != nil && t.RevokedAt == nil {
|
if t.ExpiresAt != nil && t.RevokedAt == nil {
|
||||||
|
|||||||
@@ -22,6 +22,17 @@ func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]re
|
|||||||
out.RName = dnsname.EmailFromMailbox(z.SOA.RName)
|
out.RName = dnsname.EmailFromMailbox(z.SOA.RName)
|
||||||
}
|
}
|
||||||
out.Registrar = s.registrarFor(z)
|
out.Registrar = s.registrarFor(z)
|
||||||
|
if z.Type == model.ZonePrimary && z.ViewID != 0 {
|
||||||
|
for _, o := range st.ZonesNamed(z.Name) {
|
||||||
|
if o.ID != z.ID && o.Type == model.ZonePrimary {
|
||||||
|
out.OtherViews = append(out.OtherViews, st.ViewName(o))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, o := range st.LinkedWith(z) {
|
||||||
|
out.LinkedWith = append(out.LinkedWith, st.ViewName(o))
|
||||||
|
out.Links = append(out.Links, api.ZoneLink{ID: o.ID, View: st.ViewName(o)})
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +41,12 @@ func presentRecord(st *model.State, r *model.Record) api.Record {
|
|||||||
out := api.Record{Record: *r}
|
out := api.Record{Record: *r}
|
||||||
if z != nil {
|
if z != nil {
|
||||||
out.Zone, out.View, out.RelativeName, out.EffectiveTTL = z.Name, st.ViewName(z), dnsname.Relative(r.Name, z.Name), r.EffectiveTTL(z)
|
out.Zone, out.View, out.RelativeName, out.EffectiveTTL = z.Name, st.ViewName(z), dnsname.Relative(r.Name, z.Name), r.EffectiveTTL(z)
|
||||||
|
if z.LinkGroup != 0 {
|
||||||
|
out.Linked = "local"
|
||||||
|
if r.SharedID != 0 {
|
||||||
|
out.Linked = "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
if rr.IsTextType(r.Type) {
|
if rr.IsTextType(r.Type) {
|
||||||
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
|
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
|
||||||
if t, ok := rr.TextValue(p.RR); ok {
|
if t, ok := rr.TextValue(p.RR); ok {
|
||||||
@@ -101,7 +118,7 @@ func presentOption(st *model.State, o *model.Option) api.Option {
|
|||||||
if c, ok := catalog.Default().Lookup(scope, o.Name); ok {
|
if c, ok := catalog.Default().Lookup(scope, o.Name); ok {
|
||||||
out.Description, out.Status = c.Description, c.Status
|
out.Description, out.Status = c.Description, c.Status
|
||||||
}
|
}
|
||||||
if f := namedconf.FormatValue(o.Name, o.Value); f != o.Value {
|
if f := namedconf.FormatValueList(o.Name, o.Value, 3); f != o.Value {
|
||||||
out.Formatted = f
|
out.Formatted = f
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ func (s *Server) routes() {
|
|||||||
m.HandleFunc("DELETE "+p+"/zones/{zone}/registrar/ds/{keytag}", s.authed(s.registrarRemoveDS))
|
m.HandleFunc("DELETE "+p+"/zones/{zone}/registrar/ds/{keytag}", s.authed(s.registrarRemoveDS))
|
||||||
m.HandleFunc("PUT "+p+"/zones/{zone}/registrar/ns", s.authed(s.registrarSetNS))
|
m.HandleFunc("PUT "+p+"/zones/{zone}/registrar/ns", s.authed(s.registrarSetNS))
|
||||||
m.HandleFunc("GET "+p+"/zones/{zone}/file", s.authed(s.zoneFile))
|
m.HandleFunc("GET "+p+"/zones/{zone}/file", s.authed(s.zoneFile))
|
||||||
|
m.HandleFunc("GET "+p+"/zones/{zone}/compare", s.authed(s.zoneCompare))
|
||||||
|
m.HandleFunc("POST "+p+"/zones/{zone}/link", s.authed(s.zoneLink(true)))
|
||||||
|
m.HandleFunc("POST "+p+"/zones/{zone}/unlink", s.authed(s.zoneLink(false)))
|
||||||
m.HandleFunc("POST "+p+"/zones/{zone}/import", s.authed(s.importZoneFile))
|
m.HandleFunc("POST "+p+"/zones/{zone}/import", s.authed(s.importZoneFile))
|
||||||
m.HandleFunc("GET "+p+"/zones/{zone}/records", s.authed(s.listRecords))
|
m.HandleFunc("GET "+p+"/zones/{zone}/records", s.authed(s.listRecords))
|
||||||
m.HandleFunc("POST "+p+"/zones/{zone}/records", s.authed(s.createRecord))
|
m.HandleFunc("POST "+p+"/zones/{zone}/records", s.authed(s.createRecord))
|
||||||
@@ -126,6 +129,7 @@ func (s *Server) routes() {
|
|||||||
m.HandleFunc("POST "+p+"/records/{id}/enable", s.authed(s.setRecordDisabled(false)))
|
m.HandleFunc("POST "+p+"/records/{id}/enable", s.authed(s.setRecordDisabled(false)))
|
||||||
// Options and settings
|
// Options and settings
|
||||||
m.HandleFunc("GET "+p+"/options", s.authed(s.listOptions))
|
m.HandleFunc("GET "+p+"/options", s.authed(s.listOptions))
|
||||||
|
m.HandleFunc("GET "+p+"/options/generated", s.authed(s.generatedOptions))
|
||||||
m.HandleFunc("POST "+p+"/options", s.authed(s.createOption))
|
m.HandleFunc("POST "+p+"/options", s.authed(s.createOption))
|
||||||
m.HandleFunc("GET "+p+"/options/{id}", s.authed(s.getOption))
|
m.HandleFunc("GET "+p+"/options/{id}", s.authed(s.getOption))
|
||||||
m.HandleFunc("PATCH "+p+"/options/{id}", s.authed(s.updateOption))
|
m.HandleFunc("PATCH "+p+"/options/{id}", s.authed(s.updateOption))
|
||||||
@@ -170,8 +174,14 @@ func (s *Server) routes() {
|
|||||||
m.HandleFunc("DELETE "+p+"/certificates/{name}", s.authed(s.certDelete))
|
m.HandleFunc("DELETE "+p+"/certificates/{name}", s.authed(s.certDelete))
|
||||||
m.HandleFunc("POST "+p+"/certificates/{name}/issue", s.authed(s.certIssue))
|
m.HandleFunc("POST "+p+"/certificates/{name}/issue", s.authed(s.certIssue))
|
||||||
m.HandleFunc("POST "+p+"/certificates/{name}/check", s.authed(s.certCheck))
|
m.HandleFunc("POST "+p+"/certificates/{name}/check", s.authed(s.certCheck))
|
||||||
|
m.HandleFunc("GET "+p+"/encrypted-dns", s.authed(s.encryptedDNS))
|
||||||
|
m.HandleFunc("POST "+p+"/encrypted-dns", s.authed(s.encryptedDNSSetup))
|
||||||
|
m.HandleFunc("DELETE "+p+"/encrypted-dns", s.authed(s.encryptedDNSDisable))
|
||||||
|
m.HandleFunc("POST "+p+"/encrypted-dns/check", s.authed(s.encryptedDNSCheck))
|
||||||
|
m.HandleFunc("POST "+p+"/encrypted-dns/health", s.authed(s.encryptedDNSCheckNow))
|
||||||
m.HandleFunc("GET "+p+"/exposure", s.authed(s.exposure))
|
m.HandleFunc("GET "+p+"/exposure", s.authed(s.exposure))
|
||||||
m.HandleFunc("POST "+p+"/exposure/fix", s.authed(s.exposureFix))
|
m.HandleFunc("POST "+p+"/exposure/fix", s.authed(s.exposureFix))
|
||||||
|
m.HandleFunc("POST "+p+"/exposure/mail", s.authed(s.mailCheckNow))
|
||||||
m.HandleFunc("POST "+p+"/exposure/ack", s.authed(s.exposureAck(true)))
|
m.HandleFunc("POST "+p+"/exposure/ack", s.authed(s.exposureAck(true)))
|
||||||
m.HandleFunc("POST "+p+"/exposure/unack", s.authed(s.exposureAck(false)))
|
m.HandleFunc("POST "+p+"/exposure/unack", s.authed(s.exposureAck(false)))
|
||||||
m.HandleFunc("GET "+p+"/stats/status", s.authed(s.statsStatus))
|
m.HandleFunc("GET "+p+"/stats/status", s.authed(s.statsStatus))
|
||||||
@@ -251,6 +261,11 @@ func changesetTarget(r *http.Request) (int64, error) {
|
|||||||
// mutate applies (or stages, or previews) requests and writes the
|
// mutate applies (or stages, or previews) requests and writes the
|
||||||
// response. object, if set, returns the resulting object to include.
|
// response. object, if set, returns the resulting object to include.
|
||||||
func (s *Server) mutate(w http.ResponseWriter, r *http.Request, p *auth.Principal, description string, reqs []engine.Request, object func(st *model.State, created int64) any) error {
|
func (s *Server) mutate(w http.ResponseWriter, r *http.Request, p *auth.Principal, description string, reqs []engine.Request, object func(st *model.State, created int64) any) error {
|
||||||
|
return s.mutateNotes(w, r, p, description, reqs, object, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mutateNotes is mutate with notes for the user, shown as warnings.
|
||||||
|
func (s *Server) mutateNotes(w http.ResponseWriter, r *http.Request, p *auth.Principal, description string, reqs []engine.Request, object func(st *model.State, created int64) any, notes []string) error {
|
||||||
csID, err := changesetTarget(r)
|
csID, err := changesetTarget(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -261,7 +276,7 @@ func (s *Server) mutate(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
res.Changeset.Ops = engine.RedactOps(res.Changeset.Ops)
|
res.Changeset.Ops = engine.RedactOps(res.Changeset.Ops)
|
||||||
writeJSON(w, http.StatusAccepted, api.MutationResponse{Staged: true, ChangesetID: csID, Changeset: raw(res.Changeset), Added: engine.RedactOps(res.Added), Warnings: res.Warnings, Problems: raw(res.Problems)})
|
writeJSON(w, http.StatusAccepted, api.MutationResponse{Staged: true, ChangesetID: csID, Changeset: raw(res.Changeset), Added: engine.RedactOps(res.Added), Warnings: append(notes, res.Warnings...), Problems: raw(res.Problems)})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
opts := engine.ApplyOptions{DryRun: queryBool(r, "dry_run"), Force: queryBool(r, "force"), AcceptRisk: queryBool(r, "accept_risk")}
|
opts := engine.ApplyOptions{DryRun: queryBool(r, "dry_run"), Force: queryBool(r, "force"), AcceptRisk: queryBool(r, "accept_risk")}
|
||||||
@@ -270,7 +285,7 @@ func (s *Server) mutate(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
|||||||
s.writeErr(w, r, err, res)
|
s.writeErr(w, r, err, res)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
out := api.MutationResponse{Applied: res.Applied, DryRun: res.DryRun, ChangesetID: res.ChangesetID, Result: raw(res), Warnings: res.Warnings}
|
out := api.MutationResponse{Applied: res.Applied, DryRun: res.DryRun, ChangesetID: res.ChangesetID, Result: raw(res), Warnings: append(notes, res.Warnings...)}
|
||||||
if object != nil && res.Applied {
|
if object != nil && res.Applied {
|
||||||
st, err := s.store.LoadState(r.Context())
|
st, err := s.store.LoadState(r.Context())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package server
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
@@ -21,7 +22,9 @@ import (
|
|||||||
"jarl/internal/api"
|
"jarl/internal/api"
|
||||||
"jarl/internal/auth"
|
"jarl/internal/auth"
|
||||||
"jarl/internal/certs"
|
"jarl/internal/certs"
|
||||||
|
"jarl/internal/encdns"
|
||||||
"jarl/internal/engine"
|
"jarl/internal/engine"
|
||||||
|
"jarl/internal/mailcheck"
|
||||||
"jarl/internal/nscheck"
|
"jarl/internal/nscheck"
|
||||||
"jarl/internal/porkbun"
|
"jarl/internal/porkbun"
|
||||||
"jarl/internal/stats"
|
"jarl/internal/stats"
|
||||||
@@ -61,6 +64,12 @@ type Config struct {
|
|||||||
TOTPIssuer string
|
TOTPIssuer string
|
||||||
// Metrics configures /metrics (metrics.go).
|
// Metrics configures /metrics (metrics.go).
|
||||||
Metrics Metrics
|
Metrics Metrics
|
||||||
|
// RateLimitLog says rate limiting's log lines reach the statistics
|
||||||
|
// (stats.rate_limit_log), for the Exposure page.
|
||||||
|
RateLimitLog bool
|
||||||
|
// Resolvers are public recursive resolvers (acme.resolvers), to see
|
||||||
|
// the mail addresses' reverse DNS as receivers do.
|
||||||
|
Resolvers []string
|
||||||
// Certs manages TLS certificates (nil in tests that don't need it),
|
// Certs manages TLS certificates (nil in tests that don't need it),
|
||||||
// including the one jarld serves (h_certs.go, sniff.go).
|
// including the one jarld serves (h_certs.go, sniff.go).
|
||||||
Certs *certs.Manager
|
Certs *certs.Manager
|
||||||
@@ -90,6 +99,18 @@ type Server struct {
|
|||||||
// state part of /metrics.
|
// state part of /metrics.
|
||||||
started time.Time
|
started time.Time
|
||||||
metricsCache metricsCache
|
metricsCache metricsCache
|
||||||
|
// encHealth is the last check of encrypted DNS's listeners
|
||||||
|
// (h_encdns.go); encRoots the CAs it trusts (nil: the system's).
|
||||||
|
encHealth struct {
|
||||||
|
sync.Mutex
|
||||||
|
h *encdns.Health
|
||||||
|
}
|
||||||
|
encRoots *x509.CertPool
|
||||||
|
// mail is the last check of the mail addresses' reverse DNS (h_mail.go).
|
||||||
|
mail struct {
|
||||||
|
sync.Mutex
|
||||||
|
r *mailcheck.Report
|
||||||
|
}
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
srvs []*http.Server
|
srvs []*http.Server
|
||||||
@@ -226,6 +247,15 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
|
|||||||
ready()
|
ready()
|
||||||
}
|
}
|
||||||
go s.housekeeping(ctx)
|
go s.housekeeping(ctx)
|
||||||
|
go s.mailLoop(ctx)
|
||||||
|
go func() {
|
||||||
|
// Once jarld and named have settled after a start.
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
case <-time.After(30 * time.Second):
|
||||||
|
s.checkEncryptedDNS(ctx)
|
||||||
|
}
|
||||||
|
}()
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
case err := <-errc:
|
case err := <-errc:
|
||||||
@@ -300,6 +330,7 @@ func (s *Server) housekeeping(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
s.limiter.cleanup()
|
s.limiter.cleanup()
|
||||||
s.warnExpiringTokens(ctx, time.Now())
|
s.warnExpiringTokens(ctx, time.Now())
|
||||||
|
s.checkEncryptedDNS(ctx)
|
||||||
if s.certs != nil {
|
if s.certs != nil {
|
||||||
s.certs.reloadIfChanged()
|
s.certs.reloadIfChanged()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,10 @@ func TestStatsAPI(t *testing.T) {
|
|||||||
minute := now.Unix() - now.Unix()%60 - 120
|
minute := now.Unix() - now.Unix()%60 - 120
|
||||||
hour := minute - minute%3600
|
hour := minute - minute%3600
|
||||||
b := &stats.Batch{}
|
b := &stats.Batch{}
|
||||||
vals := map[string]int64{"queries": 50, "rcode:NOERROR": 45, "rcode:NXDOMAIN": 5, "qtype:A": 30, "qtype:AAAA": 20, "zone:example.com": 40, "zone:-": 10}
|
vals := map[string]int64{"queries": 50, "rcode:NOERROR": 45, "rcode:NXDOMAIN": 5, "qtype:A": 30, "qtype:AAAA": 20, "zone:example.com": 40, "zone:-": 10,
|
||||||
|
"view:internal": 30, "view:external": 20, stats.ViewKey("internal", "responses"): 30, stats.ViewKey("external", "responses"): 20,
|
||||||
|
stats.ViewKey("internal", "rcode:NOERROR"): 30, stats.ViewKey("external", "rcode:NOERROR"): 15, stats.ViewKey("external", "rcode:NXDOMAIN"): 5,
|
||||||
|
"answer:authoritative": 40}
|
||||||
b.AddCounters(stats.ResMinute, minute, vals)
|
b.AddCounters(stats.ResMinute, minute, vals)
|
||||||
b.AddCounters(stats.ResHour, hour, vals)
|
b.AddCounters(stats.ResHour, hour, vals)
|
||||||
b.SetTops(stats.ResHour, hour, stats.KindName, []stats.Entry{{Key: "www.example.com", Count: 30}, {Key: "mail.example.com", Count: 10}})
|
b.SetTops(stats.ResHour, hour, stats.KindName, []stats.Entry{{Key: "www.example.com", Count: 30}, {Key: "mail.example.com", Count: 10}})
|
||||||
@@ -47,6 +50,16 @@ func TestStatsAPI(t *testing.T) {
|
|||||||
c := stats.NewCollector(ss, opts, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
c := stats.NewCollector(ss, opts, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
e := newAPIEnv(t, c)
|
e := newAPIEnv(t, c)
|
||||||
|
|
||||||
|
// Some views only.
|
||||||
|
if sum := e.do("GET", "/stats/summary?since=1h&views=external", e.token, nil, 200); sum["queries"].(float64) != 20 || len(sum["rcodes"].([]any)) != 2 || sum["filter_views"] == nil {
|
||||||
|
t.Errorf("external summary = %v", sum)
|
||||||
|
}
|
||||||
|
if b := e.list("/stats/breakdown?group=rcode&since=1h&views=internal,external", e.token); len(b) != 2 || b[0]["count"].(float64) != 45 {
|
||||||
|
t.Errorf("rcodes of both views = %v", b)
|
||||||
|
}
|
||||||
|
if out, status := e.req("GET", "/stats/breakdown?group=answer&since=1h&views=internal", e.token, nil); status != 400 || !strings.Contains(string(out), "not_per_view") {
|
||||||
|
t.Errorf("answers per view = %d %s", status, out)
|
||||||
|
}
|
||||||
if st := e.do("GET", "/stats/status", e.token, nil, 200); st["enabled"] != true {
|
if st := e.do("GET", "/stats/status", e.token, nil, 200); st["enabled"] != true {
|
||||||
t.Errorf("status = %v", st)
|
t.Errorf("status = %v", st)
|
||||||
}
|
}
|
||||||
|
|||||||
+76
-19
@@ -33,6 +33,13 @@ const (
|
|||||||
KindRateLimitedName = "ratelimited_name"
|
KindRateLimitedName = "ratelimited_name"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ViewKey is the key of a counter for one view: per-view copies of the
|
||||||
|
// counters that come from named's response log (docs/stats.md).
|
||||||
|
func ViewKey(view, key string) string { return "@" + view + "/" + key }
|
||||||
|
|
||||||
|
// ViewKind is the kind of a top list for one view.
|
||||||
|
func ViewKind(kind, view string) string { return kind + "@" + view }
|
||||||
|
|
||||||
// Kinds lists the top-list kinds.
|
// Kinds lists the top-list kinds.
|
||||||
var Kinds = []string{KindName, KindClient, KindNXDomain, KindServFail, KindRefused, KindBlocked, KindBlockedClient, KindRateLimited, KindRateLimitedName}
|
var Kinds = []string{KindName, KindClient, KindNXDomain, KindServFail, KindRefused, KindBlocked, KindBlockedClient, KindRateLimited, KindRateLimitedName}
|
||||||
|
|
||||||
@@ -115,6 +122,9 @@ type Collector struct {
|
|||||||
// rewritten holds queries named blocked (client#port|name) until their
|
// rewritten holds queries named blocked (client#port|name) until their
|
||||||
// response line arrives.
|
// response line arrives.
|
||||||
rewritten map[string]bool
|
rewritten map[string]bool
|
||||||
|
// viewsSince is set once per-view counters exist (meta views_since).
|
||||||
|
viewsSince bool
|
||||||
|
viewsSinceAt int64
|
||||||
// proc is the last sample of named's process, server the latest server
|
// proc is the last sample of named's process, server the latest server
|
||||||
// statistics (server.go).
|
// statistics (server.go).
|
||||||
proc *namedProc
|
proc *namedProc
|
||||||
@@ -212,6 +222,9 @@ func (c *Collector) start(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if v, _ := c.store.Meta(ctx, "views_since"); v != "" {
|
||||||
|
c.viewsSince = true
|
||||||
|
}
|
||||||
if v, _ := c.store.Meta(ctx, "first_data"); v != "" {
|
if v, _ := c.store.Meta(ctx, "first_data"); v != "" {
|
||||||
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||||
t := time.Unix(n, 0)
|
t := time.Unix(n, 0)
|
||||||
@@ -227,7 +240,8 @@ func (c *Collector) start(ctx context.Context) {
|
|||||||
if last, err := strconv.ParseInt(merged, 10, 64); err == nil {
|
if last, err := strconv.ParseInt(merged, 10, 64); err == nil {
|
||||||
from := max(last+3600, cur-48*3600)
|
from := max(last+3600, cur-48*3600)
|
||||||
for h := from; h < cur; h += 3600 {
|
for h := from; h < cur; h += 3600 {
|
||||||
for _, k := range Kinds {
|
kinds, _ := c.store.KindsAt(ctx, ResHour, h)
|
||||||
|
for _, k := range kinds {
|
||||||
if es, err := c.store.TopsAt(ctx, ResHour, h, k); err == nil && len(es) > 0 {
|
if es, err := c.store.TopsAt(ctx, ResHour, h, k); err == nil && len(es) > 0 {
|
||||||
b.AddTops(ResDay, c.dayOf(time.Unix(h, 0)), k, es, 3*c.opts.TopN)
|
b.AddTops(ResDay, c.dayOf(time.Unix(h, 0)), k, es, 3*c.opts.TopN)
|
||||||
}
|
}
|
||||||
@@ -235,7 +249,8 @@ func (c *Collector) start(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
b.SetMeta("merged_through", strconv.FormatInt(cur-3600, 10))
|
b.SetMeta("merged_through", strconv.FormatInt(cur-3600, 10))
|
||||||
for _, k := range Kinds {
|
curKinds, _ := c.store.KindsAt(ctx, ResHour, cur)
|
||||||
|
for _, k := range curKinds {
|
||||||
es, err := c.store.TopsAt(ctx, ResHour, cur, k)
|
es, err := c.store.TopsAt(ctx, ResHour, cur, k)
|
||||||
if err != nil || len(es) == 0 {
|
if err != nil || len(es) == 0 {
|
||||||
continue
|
continue
|
||||||
@@ -294,7 +309,7 @@ func (c *Collector) count(minute int64, key string, n int64) {
|
|||||||
c.minutes[minute] = m
|
c.minutes[minute] = m
|
||||||
}
|
}
|
||||||
m[key] += n
|
m[key] += n
|
||||||
if !strings.HasPrefix(key, "g:") && !strings.HasPrefix(key, "gn:") {
|
if !strings.HasPrefix(key, "g:") && !strings.HasPrefix(key, "gn:") && !strings.HasPrefix(key, "@") {
|
||||||
c.totals[key] += n
|
c.totals[key] += n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -365,33 +380,55 @@ func (c *Collector) handleLine(line string) {
|
|||||||
if view == "" {
|
if view == "" {
|
||||||
view = "_default"
|
view = "_default"
|
||||||
}
|
}
|
||||||
|
zone := c.zoneOf(ev.Name, ev.View)
|
||||||
c.count(minute, "responses", 1)
|
c.count(minute, "responses", 1)
|
||||||
c.count(minute, "view:"+view, 1)
|
c.count(minute, "view:"+view, 1)
|
||||||
c.count(minute, "zone:"+c.zoneOf(ev.Name, ev.View), 1)
|
c.count(minute, "zone:"+zone, 1)
|
||||||
|
// The same, per view, for the statistics of chosen views.
|
||||||
|
c.countView(minute, view, "responses", 1)
|
||||||
|
c.countView(minute, view, "zone:"+zone, 1)
|
||||||
|
c.countView(minute, view, "rcode:"+ev.RCode, 1)
|
||||||
|
c.countView(minute, view, "qtype:"+ev.Type, 1)
|
||||||
client := clientKey(ev.Client, c.opts.Anonymize)
|
client := clientKey(ev.Client, c.opts.Anonymize)
|
||||||
hours := c.hours
|
hours := c.hours
|
||||||
if hour < c.liveHour {
|
if hour < c.liveHour {
|
||||||
hours = c.late
|
hours = c.late
|
||||||
}
|
}
|
||||||
hours[hour] = c.hourMaps(hours, hour)
|
hours[hour] = c.hourMaps(hours, hour)
|
||||||
tops := hours[hour]
|
add := func(kind, key string) {
|
||||||
tops[KindName][ev.Name]++
|
hours[hour][kind][key]++
|
||||||
tops[KindClient][client]++
|
m := c.topsMap(hours, hour, ViewKind(kind, view))
|
||||||
switch {
|
m[key]++
|
||||||
case blocked:
|
|
||||||
// Counted by handleRewrite; not a name that doesn't exist.
|
|
||||||
case ev.RCode == "NXDOMAIN":
|
|
||||||
tops[KindNXDomain][ev.Name]++
|
|
||||||
case ev.RCode == "SERVFAIL":
|
|
||||||
tops[KindServFail][ev.Name]++
|
|
||||||
case ev.RCode == "REFUSED":
|
|
||||||
tops[KindRefused][client]++
|
|
||||||
}
|
|
||||||
for _, m := range tops {
|
|
||||||
if len(m) > maxKeys {
|
if len(m) > maxKeys {
|
||||||
shrink(m)
|
shrink(m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
add(KindName, ev.Name)
|
||||||
|
add(KindClient, client)
|
||||||
|
switch {
|
||||||
|
case blocked:
|
||||||
|
// Counted by handleRewrite; not a name that doesn't exist.
|
||||||
|
case ev.RCode == "NXDOMAIN":
|
||||||
|
add(KindNXDomain, ev.Name)
|
||||||
|
case ev.RCode == "SERVFAIL":
|
||||||
|
add(KindServFail, ev.Name)
|
||||||
|
case ev.RCode == "REFUSED":
|
||||||
|
add(KindRefused, client)
|
||||||
|
}
|
||||||
|
for _, k := range Kinds {
|
||||||
|
if m := hours[hour][k]; len(m) > maxKeys {
|
||||||
|
shrink(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// countView counts a per-view copy of a counter.
|
||||||
|
func (c *Collector) countView(minute int64, view, key string, n int64) {
|
||||||
|
if !c.viewsSince {
|
||||||
|
c.viewsSince = true
|
||||||
|
c.viewsSinceAt = minute
|
||||||
|
}
|
||||||
|
c.count(minute, ViewKey(view, key), n)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleRewrite counts a query that blocking answered: "blocked", and
|
// handleRewrite counts a query that blocking answered: "blocked", and
|
||||||
@@ -412,15 +449,24 @@ func (c *Collector) handleRewrite(rw Rewrite) {
|
|||||||
}
|
}
|
||||||
c.rewritten[rw.Client.String()+"#"+rw.Port+"|"+rw.Name] = true
|
c.rewritten[rw.Client.String()+"#"+rw.Port+"|"+rw.Name] = true
|
||||||
minute, hour := c.minuteOf(rw.Time), c.hourOf(rw.Time)
|
minute, hour := c.minuteOf(rw.Time), c.hourOf(rw.Time)
|
||||||
|
view := rw.View
|
||||||
|
if view == "" {
|
||||||
|
view = "_default"
|
||||||
|
}
|
||||||
c.count(minute, "blocked", 1)
|
c.count(minute, "blocked", 1)
|
||||||
c.count(minute, "blocklist:"+policySource(rw.Via), 1)
|
c.count(minute, "blocklist:"+policySource(rw.Via), 1)
|
||||||
|
c.countView(minute, view, "blocked", 1)
|
||||||
|
c.countView(minute, view, "blocklist:"+policySource(rw.Via), 1)
|
||||||
hours := c.hours
|
hours := c.hours
|
||||||
if hour < c.liveHour {
|
if hour < c.liveHour {
|
||||||
hours = c.late
|
hours = c.late
|
||||||
}
|
}
|
||||||
hours[hour] = c.hourMaps(hours, hour)
|
hours[hour] = c.hourMaps(hours, hour)
|
||||||
|
client := clientKey(rw.Client, c.opts.Anonymize)
|
||||||
hours[hour][KindBlocked][rw.Name]++
|
hours[hour][KindBlocked][rw.Name]++
|
||||||
hours[hour][KindBlockedClient][clientKey(rw.Client, c.opts.Anonymize)]++
|
hours[hour][KindBlockedClient][client]++
|
||||||
|
c.topsMap(hours, hour, ViewKind(KindBlocked, view))[rw.Name]++
|
||||||
|
c.topsMap(hours, hour, ViewKind(KindBlockedClient, view))[client]++
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleRateLimit counts a response rate limiting limited, or would have:
|
// handleRateLimit counts a response rate limiting limited, or would have:
|
||||||
@@ -437,7 +483,12 @@ func (c *Collector) handleRateLimit(rl RateLimit) {
|
|||||||
key = "rrl:would-" + rl.Action
|
key = "rrl:would-" + rl.Action
|
||||||
}
|
}
|
||||||
minute, hour := c.minuteOf(rl.Time), c.hourOf(rl.Time)
|
minute, hour := c.minuteOf(rl.Time), c.hourOf(rl.Time)
|
||||||
|
view := rl.View
|
||||||
|
if view == "" {
|
||||||
|
view = "_default"
|
||||||
|
}
|
||||||
c.count(minute, key, 1)
|
c.count(minute, key, 1)
|
||||||
|
c.countView(minute, view, key, 1)
|
||||||
hours := c.hours
|
hours := c.hours
|
||||||
if hour < c.liveHour {
|
if hour < c.liveHour {
|
||||||
hours = c.late
|
hours = c.late
|
||||||
@@ -445,6 +496,8 @@ func (c *Collector) handleRateLimit(rl RateLimit) {
|
|||||||
hours[hour] = c.hourMaps(hours, hour)
|
hours[hour] = c.hourMaps(hours, hour)
|
||||||
hours[hour][KindRateLimited][rl.Prefix]++
|
hours[hour][KindRateLimited][rl.Prefix]++
|
||||||
hours[hour][KindRateLimitedName][rl.Name+" "+rl.Type]++
|
hours[hour][KindRateLimitedName][rl.Name+" "+rl.Type]++
|
||||||
|
c.topsMap(hours, hour, ViewKind(KindRateLimited, view))[rl.Prefix]++
|
||||||
|
c.topsMap(hours, hour, ViewKind(KindRateLimitedName, view))[rl.Name+" "+rl.Type]++
|
||||||
}
|
}
|
||||||
|
|
||||||
// policySource names what a rewrite came from: a blocklist's name, "rules"
|
// policySource names what a rewrite came from: a blocklist's name, "rules"
|
||||||
@@ -547,6 +600,10 @@ func (c *Collector) flush(ctx context.Context, final bool) {
|
|||||||
b.AddCounters(ResHour, c.hourOf(t), vals)
|
b.AddCounters(ResHour, c.hourOf(t), vals)
|
||||||
b.AddCounters(ResDay, c.dayOf(t), vals)
|
b.AddCounters(ResDay, c.dayOf(t), vals)
|
||||||
delete(c.minutes, m)
|
delete(c.minutes, m)
|
||||||
|
if c.viewsSinceAt != 0 {
|
||||||
|
b.SetMeta("views_since", strconv.FormatInt(c.viewsSinceAt, 10))
|
||||||
|
c.viewsSinceAt = 0
|
||||||
|
}
|
||||||
if first && len(vals) > 0 {
|
if first && len(vals) > 0 {
|
||||||
ft := t
|
ft := t
|
||||||
c.status.FirstData = &ft
|
c.status.FirstData = &ft
|
||||||
|
|||||||
+158
-10
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -14,6 +15,110 @@ type Querier struct {
|
|||||||
Store *Store
|
Store *Store
|
||||||
Opts Options
|
Opts Options
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
|
// Views limits the figures to these views (none: every view). Only
|
||||||
|
// what named's response log says can be split by view; named's own
|
||||||
|
// counters (cache, transport, round trips, ...) can't.
|
||||||
|
Views []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForViews returns a querier limited to views (all views when empty).
|
||||||
|
func (q *Querier) ForViews(views []string) *Querier {
|
||||||
|
c := *q
|
||||||
|
c.Views = views
|
||||||
|
return &c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Querier) filtered() bool { return len(q.Views) > 0 }
|
||||||
|
|
||||||
|
// perView reports whether a counter key exists per view, and its name
|
||||||
|
// there ("queries" is counted as responses).
|
||||||
|
func perView(key string) (string, bool) {
|
||||||
|
if key == "queries" || key == "responses" {
|
||||||
|
return "responses", true
|
||||||
|
}
|
||||||
|
if key == "blocked" {
|
||||||
|
return key, true
|
||||||
|
}
|
||||||
|
for _, p := range []string{"rcode:", "qtype:", "zone:", "blocklist:", "rrl:"} {
|
||||||
|
if strings.HasPrefix(key, p) || key+":" == p {
|
||||||
|
return key, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return key, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// PerViewGroups are the Breakdown groups that exist per view.
|
||||||
|
var PerViewGroups = []string{"qtype", "rcode", "view", "zone", "blocklist", "rrl"}
|
||||||
|
|
||||||
|
// ErrNotPerView is returned for figures named only keeps for the whole server.
|
||||||
|
var ErrNotPerView = fmt.Errorf("named keeps this for the whole server only, not per view")
|
||||||
|
|
||||||
|
// sums is Store.sums, limited to the querier's views.
|
||||||
|
func (q *Querier) sums(ctx context.Context, res string, from, to int64, prefix string) (map[string]int64, error) {
|
||||||
|
if !q.filtered() {
|
||||||
|
return q.Store.sums(ctx, res, from, to, prefix)
|
||||||
|
}
|
||||||
|
if prefix == "view:" {
|
||||||
|
all, err := q.Store.sums(ctx, res, from, to, prefix)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := map[string]int64{}
|
||||||
|
for _, v := range q.Views {
|
||||||
|
if n := all["view:"+v]; n != 0 {
|
||||||
|
out["view:"+v] = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if _, ok := perView(prefix); !ok {
|
||||||
|
// Not kept per view (named's own counters): the whole server's.
|
||||||
|
return q.Store.sums(ctx, res, from, to, prefix)
|
||||||
|
}
|
||||||
|
out := map[string]int64{}
|
||||||
|
for _, v := range q.Views {
|
||||||
|
m, err := q.Store.sums(ctx, res, from, to, ViewKey(v, prefix))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for k, n := range m {
|
||||||
|
out[strings.TrimPrefix(k, ViewKey(v, ""))] += n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// series is Store.series, limited to the querier's views.
|
||||||
|
func (q *Querier) series(ctx context.Context, res string, from, to int64, key string) (map[int64]int64, error) {
|
||||||
|
pv, ok := perView(key)
|
||||||
|
if !q.filtered() || !ok {
|
||||||
|
return q.Store.series(ctx, res, from, to, key)
|
||||||
|
}
|
||||||
|
out := map[int64]int64{}
|
||||||
|
for _, v := range q.Views {
|
||||||
|
m, err := q.Store.series(ctx, res, from, to, ViewKey(v, pv))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for ts, n := range m {
|
||||||
|
out[ts] += n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ViewsSince is when per-view figures start (nil before any).
|
||||||
|
func (q *Querier) ViewsSince(ctx context.Context) *time.Time {
|
||||||
|
v, err := q.Store.Meta(ctx, "views_since")
|
||||||
|
if err != nil || v == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(v, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t := time.Unix(n, 0)
|
||||||
|
return &t
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewQuerier returns a querier for a collector's data.
|
// NewQuerier returns a querier for a collector's data.
|
||||||
@@ -105,10 +210,15 @@ func (s *Store) series(ctx context.Context, res string, from, to int64, key stri
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// tops sums top lists in [from, to).
|
// tops sums top lists (of one or more kinds) in [from, to).
|
||||||
func (s *Store) tops(ctx context.Context, res, kind string, from, to int64, limit int) ([]Entry, error) {
|
func (s *Store) tops(ctx context.Context, res string, kinds []string, from, to int64, limit int) ([]Entry, error) {
|
||||||
rows, err := s.db.QueryContext(ctx, `SELECT key, SUM(count) AS n FROM tops WHERE res = ? AND kind = ? AND ts >= ? AND ts < ?
|
args := []any{res}
|
||||||
GROUP BY key ORDER BY n DESC, key LIMIT ?`, res, kind, from, to, limit)
|
for _, k := range kinds {
|
||||||
|
args = append(args, k)
|
||||||
|
}
|
||||||
|
args = append(args, from, to, limit)
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT key, SUM(count) AS n FROM tops WHERE res = ? AND kind IN (`+strings.TrimSuffix(strings.Repeat("?,", len(kinds)), ",")+`) AND ts >= ? AND ts < ?
|
||||||
|
GROUP BY key ORDER BY n DESC, key LIMIT ?`, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -167,6 +277,11 @@ type Summary struct {
|
|||||||
BlockedRatio *float64 `json:"blocked_ratio,omitempty"`
|
BlockedRatio *float64 `json:"blocked_ratio,omitempty"`
|
||||||
Blocklists []Entry `json:"blocklists,omitempty"`
|
Blocklists []Entry `json:"blocklists,omitempty"`
|
||||||
Tops map[string][]Entry `json:"tops,omitempty"`
|
Tops map[string][]Entry `json:"tops,omitempty"`
|
||||||
|
// Views and ViewsSince are set when the figures are for some views
|
||||||
|
// only: queries then counts responses, and named's own counters
|
||||||
|
// (answers, transport, cache) are left out.
|
||||||
|
FilterViews []string `json:"filter_views,omitempty"`
|
||||||
|
ViewsSince *time.Time `json:"views_since,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summary summarizes the last since.
|
// Summary summarizes the last since.
|
||||||
@@ -179,9 +294,32 @@ func (q *Querier) Summary(ctx context.Context, since time.Duration, topLimit int
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if q.filtered() {
|
||||||
|
// The chosen views' counters, under their usual names.
|
||||||
|
views := map[string]int64{}
|
||||||
|
for _, v := range q.Views {
|
||||||
|
views["view:"+v] = all["view:"+v]
|
||||||
|
}
|
||||||
|
picked := map[string]int64{}
|
||||||
|
for k, n := range all {
|
||||||
|
for _, v := range q.Views {
|
||||||
|
if rest, ok := strings.CutPrefix(k, ViewKey(v, "")); ok {
|
||||||
|
picked[rest] += n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k, n := range views {
|
||||||
|
picked[k] = n
|
||||||
|
}
|
||||||
|
picked["queries"] = picked["responses"]
|
||||||
|
all = picked
|
||||||
|
}
|
||||||
s := &Summary{From: time.Unix(from, 0), To: now, Resolution: res, Queries: all["queries"],
|
s := &Summary{From: time.Unix(from, 0), To: now, Resolution: res, Queries: all["queries"],
|
||||||
RCodes: sorted(all, "rcode:"), QTypes: sorted(all, "qtype:"), Views: sorted(all, "view:"), Zones: sorted(all, "zone:"),
|
RCodes: sorted(all, "rcode:"), QTypes: sorted(all, "qtype:"), Views: sorted(all, "view:"), Zones: sorted(all, "zone:"),
|
||||||
Answers: sorted(all, "answer:"), Transport: sorted(all, "transport:")}
|
Answers: sorted(all, "answer:"), Transport: sorted(all, "transport:")}
|
||||||
|
if q.filtered() {
|
||||||
|
s.FilterViews, s.ViewsSince = q.Views, q.ViewsSince(ctx)
|
||||||
|
}
|
||||||
if h, m := all["cache:hits"], all["cache:misses"]; h+m > 0 {
|
if h, m := all["cache:hits"], all["cache:misses"]; h+m > 0 {
|
||||||
r := float64(h) / float64(h+m)
|
r := float64(h) / float64(h+m)
|
||||||
s.CacheHitRatio = &r
|
s.CacheHitRatio = &r
|
||||||
@@ -195,7 +333,7 @@ func (q *Querier) Summary(ctx context.Context, since time.Duration, topLimit int
|
|||||||
s.BlockedRatio = &b
|
s.BlockedRatio = &b
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
per, err := q.Store.series(ctx, res, from, to, "queries")
|
per, err := q.series(ctx, res, from, to, "queries")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -237,7 +375,14 @@ func (q *Querier) Top(ctx context.Context, kind string, since time.Duration, lim
|
|||||||
}
|
}
|
||||||
now := q.Now()
|
now := q.Now()
|
||||||
res := q.topsResolution(since)
|
res := q.topsResolution(since)
|
||||||
return q.Store.tops(ctx, res, kind, q.bucketStart(res, now.Add(-since)), now.Unix()+1, limit)
|
kinds := []string{kind}
|
||||||
|
if q.filtered() {
|
||||||
|
kinds = nil
|
||||||
|
for _, v := range q.Views {
|
||||||
|
kinds = append(kinds, ViewKind(kind, v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return q.Store.tops(ctx, res, kinds, q.bucketStart(res, now.Add(-since)), now.Unix()+1, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Breakdown returns the totals of a counter group (qtype, rcode, view,
|
// Breakdown returns the totals of a counter group (qtype, rcode, view,
|
||||||
@@ -246,9 +391,12 @@ func (q *Querier) Breakdown(ctx context.Context, group string, since time.Durati
|
|||||||
if !slices.Contains(Groups, group) {
|
if !slices.Contains(Groups, group) {
|
||||||
return nil, fmt.Errorf("unknown group %q (use %s)", group, strings.Join(Groups, ", "))
|
return nil, fmt.Errorf("unknown group %q (use %s)", group, strings.Join(Groups, ", "))
|
||||||
}
|
}
|
||||||
|
if q.filtered() && !slices.Contains(PerViewGroups, group) {
|
||||||
|
return nil, ErrNotPerView
|
||||||
|
}
|
||||||
now := q.Now()
|
now := q.Now()
|
||||||
res := q.Resolution(since)
|
res := q.Resolution(since)
|
||||||
m, err := q.Store.sums(ctx, res, q.bucketStart(res, now.Add(-since)), now.Unix()+1, group+":")
|
m, err := q.sums(ctx, res, q.bucketStart(res, now.Add(-since)), now.Unix()+1, group+":")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -329,7 +477,7 @@ func (q *Querier) Timeline(ctx context.Context, since time.Duration, step string
|
|||||||
var expanded []string
|
var expanded []string
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
if g, ok := strings.CutSuffix(k, ":*"); ok {
|
if g, ok := strings.CutSuffix(k, ":*"); ok {
|
||||||
m, err := q.Store.sums(ctx, st.res, from, to, g+":")
|
m, err := q.sums(ctx, st.res, from, to, g+":")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -342,7 +490,7 @@ func (q *Querier) Timeline(ctx context.Context, since time.Duration, step string
|
|||||||
}
|
}
|
||||||
tl := &Timeline{Step: st.name, Times: starts, Series: []*Series{}}
|
tl := &Timeline{Step: st.name, Times: starts, Series: []*Series{}}
|
||||||
bucketed := func(key string) ([]int64, error) {
|
bucketed := func(key string) ([]int64, error) {
|
||||||
raw, err := q.Store.series(ctx, st.res, from, to, key)
|
raw, err := q.series(ctx, st.res, from, to, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -404,7 +552,7 @@ func (q *Querier) Heatmap(ctx context.Context, key string, since time.Duration)
|
|||||||
since = min(since, q.Opts.RetainHour)
|
since = min(since, q.Opts.RetainHour)
|
||||||
now := q.Now()
|
now := q.Now()
|
||||||
from := q.bucketStart(ResHour, now.Add(-since))
|
from := q.bucketStart(ResHour, now.Add(-since))
|
||||||
raw, err := q.Store.series(ctx, ResHour, from, now.Unix()+1, key)
|
raw, err := q.series(ctx, ResHour, from, now.Unix()+1, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,3 +264,22 @@ func (s *Store) TopsAt(ctx context.Context, res string, ts int64, kind string) (
|
|||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KindsAt returns the top-list kinds stored for one bucket (per-view
|
||||||
|
// kinds included).
|
||||||
|
func (s *Store) KindsAt(ctx context.Context, res string, ts int64) ([]string, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT kind FROM tops WHERE res = ? AND ts = ?`, res, ts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var k string
|
||||||
|
if err := rows.Scan(&k); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package stats
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Figures for chosen views: from the response log, per view, and added
|
||||||
|
// up for several.
|
||||||
|
func TestViewFilter(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
loc := time.FixedZone("test", 0)
|
||||||
|
dir := t.TempDir()
|
||||||
|
logFile := filepath.Join(dir, "responses.log")
|
||||||
|
store, err := OpenStore(filepath.Join(dir, "stats.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
named := newFakeNamed(t)
|
||||||
|
clk := &clock{time.Date(2026, 10, 5, 10, 0, 30, 0, loc)}
|
||||||
|
opts := Options{QueryLog: true, LogFile: logFile, StatisticsChannel: "unused", TopN: 10,
|
||||||
|
RetainMinute: 7 * 24 * time.Hour, RetainHour: 90 * 24 * time.Hour, RetainDay: 730 * 24 * time.Hour, Location: loc}
|
||||||
|
zones := func() map[string][]string {
|
||||||
|
return map[string][]string{"internal": {"adbraun.lol"}, "external": {"adbraun.lol"}}
|
||||||
|
}
|
||||||
|
c := NewCollector(store, opts, zones, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
c.poller.URL = named.srv.URL
|
||||||
|
c.now = clk.now
|
||||||
|
c.start(ctx)
|
||||||
|
at := time.Date(2026, 10, 5, 10, 0, 40, 0, loc)
|
||||||
|
lines := []string{
|
||||||
|
logLine(at, "10.10.0.37", "internal", "nas.adbraun.lol", "A", "NOERROR"),
|
||||||
|
logLine(at, "10.10.0.37", "internal", "nas.adbraun.lol", "AAAA", "NOERROR"),
|
||||||
|
logLine(at, "10.10.0.42", "internal", "nope.adbraun.lol", "A", "NXDOMAIN"),
|
||||||
|
logLine(at, "203.0.113.9", "external", "www.adbraun.lol", "A", "NOERROR"),
|
||||||
|
logLine(at, "203.0.113.9", "external", "x.example", "A", "REFUSED"),
|
||||||
|
logLine(at, "192.0.2.5", "vps", "debian.org", "AAAA", "NOERROR"),
|
||||||
|
}
|
||||||
|
f, _ := os.Create(logFile)
|
||||||
|
for _, l := range lines {
|
||||||
|
f.WriteString(l)
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
c.readLog()
|
||||||
|
named.add(6, 1)
|
||||||
|
clk.t = time.Date(2026, 10, 5, 10, 1, 2, 0, loc)
|
||||||
|
c.flush(ctx, false)
|
||||||
|
clk.t = time.Date(2026, 10, 5, 11, 0, 2, 0, loc)
|
||||||
|
c.flush(ctx, false)
|
||||||
|
|
||||||
|
all := &Querier{Store: store, Opts: opts, Now: clk.now}
|
||||||
|
s, err := all.Summary(ctx, 24*time.Hour, 5)
|
||||||
|
if err != nil || s.Queries != 6 || s.FilterViews != nil {
|
||||||
|
t.Fatalf("all views: %+v %v", s, err)
|
||||||
|
}
|
||||||
|
in := all.ForViews([]string{"internal"})
|
||||||
|
s, err = in.Summary(ctx, 24*time.Hour, 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.Queries != 3 || len(s.Views) != 1 || s.Views[0].Key != "internal" || s.ViewsSince == nil {
|
||||||
|
t.Errorf("internal: queries %d views %v since %v", s.Queries, s.Views, s.ViewsSince)
|
||||||
|
}
|
||||||
|
if len(s.RCodes) != 2 || s.RCodes[0] != (Entry{Key: "NOERROR", Count: 2}) || len(s.QTypes) != 2 {
|
||||||
|
t.Errorf("internal rcodes %v qtypes %v", s.RCodes, s.QTypes)
|
||||||
|
}
|
||||||
|
if s.Tops[KindName][0] != (Entry{Key: "nas.adbraun.lol", Count: 2}) || len(s.Tops[KindNXDomain]) != 1 || len(s.Answers) != 0 {
|
||||||
|
t.Errorf("internal tops %v answers %v", s.Tops, s.Answers)
|
||||||
|
}
|
||||||
|
// Linked views, say: two together.
|
||||||
|
two := all.ForViews([]string{"internal", "external"})
|
||||||
|
s, _ = two.Summary(ctx, 24*time.Hour, 5)
|
||||||
|
if s.Queries != 5 || len(s.Views) != 2 {
|
||||||
|
t.Errorf("internal+external: %d %v", s.Queries, s.Views)
|
||||||
|
}
|
||||||
|
if es, _ := two.Top(ctx, KindClient, 24*time.Hour, 5); len(es) != 3 || es[2].Key != "10.10.0.42" {
|
||||||
|
t.Errorf("clients of two views: %v", es)
|
||||||
|
}
|
||||||
|
if es, _ := two.Breakdown(ctx, "rcode", 24*time.Hour); len(es) != 3 {
|
||||||
|
t.Errorf("rcodes of two views: %v", es)
|
||||||
|
}
|
||||||
|
if _, err := two.Breakdown(ctx, "answer", 24*time.Hour); !errors.Is(err, ErrNotPerView) {
|
||||||
|
t.Errorf("answers per view: %v", err)
|
||||||
|
}
|
||||||
|
tl, err := all.ForViews([]string{"vps"}).Timeline(ctx, 2*time.Hour, "1m", []string{"queries", "rcode:*"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
for _, v := range tl.Series[0].Values {
|
||||||
|
total += v
|
||||||
|
}
|
||||||
|
if total != 1 || len(tl.Series) != 2 || tl.Series[1].Key != "rcode:NOERROR" {
|
||||||
|
t.Errorf("vps timeline: %d %+v", total, tl.Series)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -95,14 +95,14 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err = e.QueryContext(ctx, `SELECT id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, dnssec_policy, comment, disabled, version, created_at, updated_at, created_by, updated_by FROM zones`)
|
rows, err = e.QueryContext(ctx, `SELECT id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, dnssec_policy, comment, disabled, link_group, version, created_at, updated_at, created_by, updated_by FROM zones`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
z := &model.Zone{}
|
z := &model.Zone{}
|
||||||
var ca, ua string
|
var ca, ua string
|
||||||
if err := rows.Scan(&z.ID, &z.Name, &z.ViewID, &z.Type, &z.InView, &z.File, &z.SOA.MName, &z.SOA.RName, &z.SOA.Serial, &z.SOA.Refresh, &z.SOA.Retry, &z.SOA.Expire, &z.SOA.Minimum, &z.DefaultTTL, &z.SerialPolicy, &z.DNSSECPolicy, &z.Comment, &z.Disabled, &z.Version, &ca, &ua, &z.CreatedBy, &z.UpdatedBy); err != nil {
|
if err := rows.Scan(&z.ID, &z.Name, &z.ViewID, &z.Type, &z.InView, &z.File, &z.SOA.MName, &z.SOA.RName, &z.SOA.Serial, &z.SOA.Refresh, &z.SOA.Retry, &z.SOA.Expire, &z.SOA.Minimum, &z.DefaultTTL, &z.SerialPolicy, &z.DNSSECPolicy, &z.Comment, &z.Disabled, &z.LinkGroup, &z.Version, &ca, &ua, &z.CreatedBy, &z.UpdatedBy); err != nil {
|
||||||
rows.Close()
|
rows.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -113,7 +113,7 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err = e.QueryContext(ctx, `SELECT id, zone_id, name, type, ttl, data, comment, disabled, version, created_at, updated_at, created_by, updated_by FROM records`)
|
rows, err = e.QueryContext(ctx, `SELECT id, zone_id, name, type, ttl, data, comment, disabled, shared_id, version, created_at, updated_at, created_by, updated_by FROM records`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -121,7 +121,7 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
|
|||||||
r := &model.Record{}
|
r := &model.Record{}
|
||||||
var ttl sql.NullInt64
|
var ttl sql.NullInt64
|
||||||
var ca, ua string
|
var ca, ua string
|
||||||
if err := rows.Scan(&r.ID, &r.ZoneID, &r.Name, &r.Type, &ttl, &r.Data, &r.Comment, &r.Disabled, &r.Version, &ca, &ua, &r.CreatedBy, &r.UpdatedBy); err != nil {
|
if err := rows.Scan(&r.ID, &r.ZoneID, &r.Name, &r.Type, &ttl, &r.Data, &r.Comment, &r.Disabled, &r.SharedID, &r.Version, &ca, &ua, &r.CreatedBy, &r.UpdatedBy); err != nil {
|
||||||
rows.Close()
|
rows.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -288,8 +288,8 @@ func saveState(ctx context.Context, e execer, old, new *model.State) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := diffMap(old.Zones, new.Zones, func(z *model.Zone) error {
|
if err := diffMap(old.Zones, new.Zones, func(z *model.Zone) error {
|
||||||
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO zones (id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, dnssec_policy, comment, disabled, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO zones (id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, dnssec_policy, comment, disabled, link_group, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
z.ID, z.Name, z.ViewID, z.Type, z.InView, z.File, z.SOA.MName, z.SOA.RName, z.SOA.Serial, z.SOA.Refresh, z.SOA.Retry, z.SOA.Expire, z.SOA.Minimum, z.DefaultTTL, z.SerialPolicy, z.DNSSECPolicy, z.Comment, z.Disabled, z.Version, ts(z.CreatedAt), ts(z.UpdatedAt), z.CreatedBy, z.UpdatedBy)
|
z.ID, z.Name, z.ViewID, z.Type, z.InView, z.File, z.SOA.MName, z.SOA.RName, z.SOA.Serial, z.SOA.Refresh, z.SOA.Retry, z.SOA.Expire, z.SOA.Minimum, z.DefaultTTL, z.SerialPolicy, z.DNSSECPolicy, z.Comment, z.Disabled, z.LinkGroup, z.Version, ts(z.CreatedAt), ts(z.UpdatedAt), z.CreatedBy, z.UpdatedBy)
|
||||||
return err
|
return err
|
||||||
}, func(id int64) error { return del(ctx, e, "zones", id) }); err != nil {
|
}, func(id int64) error { return del(ctx, e, "zones", id) }); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -299,8 +299,8 @@ func saveState(ctx context.Context, e execer, old, new *model.State) error {
|
|||||||
if r.TTL != nil {
|
if r.TTL != nil {
|
||||||
ttl = int64(*r.TTL)
|
ttl = int64(*r.TTL)
|
||||||
}
|
}
|
||||||
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO records (id, zone_id, name, type, ttl, data, comment, disabled, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO records (id, zone_id, name, type, ttl, data, comment, disabled, shared_id, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
r.ID, r.ZoneID, r.Name, r.Type, ttl, r.Data, r.Comment, r.Disabled, r.Version, ts(r.CreatedAt), ts(r.UpdatedAt), r.CreatedBy, r.UpdatedBy)
|
r.ID, r.ZoneID, r.Name, r.Type, ttl, r.Data, r.Comment, r.Disabled, r.SharedID, r.Version, ts(r.CreatedAt), ts(r.UpdatedAt), r.CreatedBy, r.UpdatedBy)
|
||||||
return err
|
return err
|
||||||
}, func(id int64) error { return del(ctx, e, "records", id) }); err != nil {
|
}, func(id int64) error { return del(ctx, e, "records", id) }); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -361,6 +361,11 @@ CREATE TABLE certificates (
|
|||||||
created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||||
created_by TEXT NOT NULL DEFAULT '', updated_by TEXT NOT NULL DEFAULT ''
|
created_by TEXT NOT NULL DEFAULT '', updated_by TEXT NOT NULL DEFAULT ''
|
||||||
);
|
);
|
||||||
|
`,
|
||||||
|
// 11: linked zones (docs/views.md#linked-zones).
|
||||||
|
`
|
||||||
|
ALTER TABLE zones ADD COLUMN link_group INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE records ADD COLUMN shared_id INTEGER NOT NULL DEFAULT 0;
|
||||||
`,
|
`,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package validate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"jarl/internal/dnsname"
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// checkLinked checks linked zones (docs/views.md#linked-zones): what can
|
||||||
|
// be linked, and that their shared records are in step. Jarl keeps them
|
||||||
|
// in step itself; they can only drift when a change was staged before the
|
||||||
|
// zones were linked (or by editing the database).
|
||||||
|
func (c *checker) checkLinked() {
|
||||||
|
groups := map[int64][]*model.Zone{}
|
||||||
|
for _, z := range c.s.SortedZones() {
|
||||||
|
if z.LinkGroup != 0 {
|
||||||
|
groups[z.LinkGroup] = append(groups[z.LinkGroup], z)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, g := range sortedGroupIDs(groups) {
|
||||||
|
zs := c.s.LinkMembers(g)
|
||||||
|
label := func(z *model.Zone) string {
|
||||||
|
if v := c.s.ViewName(z); v != "" {
|
||||||
|
return dnsname.Display(z.Name) + " [" + v + "]"
|
||||||
|
}
|
||||||
|
return dnsname.Display(z.Name)
|
||||||
|
}
|
||||||
|
bad := false
|
||||||
|
for _, z := range zs {
|
||||||
|
switch {
|
||||||
|
case z.Type != model.ZonePrimary:
|
||||||
|
c.errf("linked_zone_type", "zone", z.ID, "zone %s is linked, but only primary zones can be: unlink it", label(z))
|
||||||
|
bad = true
|
||||||
|
case z.Name != zs[0].Name:
|
||||||
|
c.errf("linked_zone_name", "zone", z.ID, "zone %s is linked with %s; only zones of the same name can be linked", label(z), label(zs[0]))
|
||||||
|
bad = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(zs) == 1 {
|
||||||
|
c.infof("linked_zone_alone", "zone", zs[0].ID, "zone %s is linked with no other zone; unlink it (jarl zone unlink)", label(zs[0]))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if bad {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Shared and local records of one name and type can't mix in a zone.
|
||||||
|
type key struct{ name, typ string }
|
||||||
|
keys := map[key]bool{}
|
||||||
|
var order []key
|
||||||
|
for _, z := range zs {
|
||||||
|
for _, r := range c.s.ZoneRecords(z.ID) {
|
||||||
|
k := key{r.Name, r.Type}
|
||||||
|
if !keys[k] {
|
||||||
|
keys[k] = true
|
||||||
|
order = append(order, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, k := range order {
|
||||||
|
var truth []*model.Record
|
||||||
|
var truthZone *model.Zone
|
||||||
|
for _, z := range zs {
|
||||||
|
rs := c.s.RRset(z.ID, k.name, k.typ)
|
||||||
|
local, shared := 0, 0
|
||||||
|
for _, r := range rs {
|
||||||
|
if r.SharedID == 0 {
|
||||||
|
local++
|
||||||
|
} else {
|
||||||
|
shared++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if local > 0 && shared > 0 {
|
||||||
|
c.errf("linked_mixed", "zone", z.ID, "%s %s in zone %s has both shared and local records; make the set one or the other", dnsname.Display(k.name), k.typ, label(z)).Zone = z.Name
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if local > 0 {
|
||||||
|
continue // an override
|
||||||
|
}
|
||||||
|
got := c.s.SharedRecords(z.ID, k.name, k.typ)
|
||||||
|
if truthZone == nil {
|
||||||
|
truth, truthZone = got, z
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if diff := sharedDiff(truth, got); diff != "" {
|
||||||
|
p := c.errf("linked_out_of_sync", "zone", z.ID, "linked zones %s and %s differ at %s %s (%s). Jarl keeps them in step, so a change was probably staged before they were linked: link them again (jarl zone link) to work out what is shared", label(truthZone), label(z), dnsname.Display(k.name), k.typ, diff)
|
||||||
|
p.Zone = z.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedGroupIDs(m map[int64][]*model.Zone) []int64 {
|
||||||
|
var out []int64
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
for i := range out {
|
||||||
|
for j := i + 1; j < len(out); j++ {
|
||||||
|
if out[j] < out[i] {
|
||||||
|
out[i], out[j] = out[j], out[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// sharedDiff says how two zones' copies of a shared set differ ("" when
|
||||||
|
// they don't).
|
||||||
|
func sharedDiff(a, b []*model.Record) string {
|
||||||
|
idx := map[int64]*model.Record{}
|
||||||
|
for _, r := range b {
|
||||||
|
idx[r.SharedID] = r
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, r := range a {
|
||||||
|
o := idx[r.SharedID]
|
||||||
|
delete(idx, r.SharedID)
|
||||||
|
switch {
|
||||||
|
case o == nil:
|
||||||
|
out = append(out, "missing "+r.Data)
|
||||||
|
case o.Data != r.Data:
|
||||||
|
out = append(out, r.Data+" vs "+o.Data)
|
||||||
|
case !sameTTLPtr(o.TTL, r.TTL):
|
||||||
|
out = append(out, "TTL of "+r.Data)
|
||||||
|
case o.Disabled != r.Disabled:
|
||||||
|
out = append(out, "disabled "+r.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, r := range idx {
|
||||||
|
out = append(out, "extra "+r.Data)
|
||||||
|
}
|
||||||
|
if len(out) > 3 {
|
||||||
|
out = append(out[:3], fmt.Sprintf("and %d more", len(out)-3))
|
||||||
|
}
|
||||||
|
return strings.Join(out, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameTTLPtr(a, b *uint32) bool {
|
||||||
|
if a == nil || b == nil {
|
||||||
|
return a == nil && b == nil
|
||||||
|
}
|
||||||
|
return *a == *b
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package validate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/namedconf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// checkTLSFiles checks that the files a top-level tls statement names
|
||||||
|
// exist: named fails to load the whole configuration otherwise. (Files
|
||||||
|
// jarld can't look at are left to named.)
|
||||||
|
func (c *checker) checkTLSFiles() {
|
||||||
|
for _, o := range c.s.ScopedOptions(model.ScopeTopLevel, 0) {
|
||||||
|
if o.Name != "tls" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stmt, err := namedconf.ParseOptionValue("tls", o.Value)
|
||||||
|
if err != nil || stmt.FirstBlock() == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, s := range stmt.FirstBlock().Stmts {
|
||||||
|
switch s.Keyword() {
|
||||||
|
case "cert-file", "key-file", "ca-file", "dhparam-file":
|
||||||
|
path := s.Arg(0)
|
||||||
|
if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) {
|
||||||
|
c.errf("tls_file_missing", "option", o.ID, "tls %s: %s %s doesn't exist, and named won't load its configuration without it (a certificate Jarl obtains is written once it has been obtained: jarl cert list)", stmt.Arg(0), s.Keyword(), path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -151,6 +151,8 @@ func State(s *model.State, opts Options) []Problem {
|
|||||||
c.checkExposure()
|
c.checkExposure()
|
||||||
c.checkDNSSEC()
|
c.checkDNSSEC()
|
||||||
c.checkBlocking()
|
c.checkBlocking()
|
||||||
|
c.checkLinked()
|
||||||
|
c.checkTLSFiles()
|
||||||
c.applyAcks()
|
c.applyAcks()
|
||||||
sortProblems(c.problems)
|
sortProblems(c.problems)
|
||||||
return c.problems
|
return c.problems
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import * as stats from "./pages/stats.js";
|
|||||||
import * as blocking from "./pages/blocking.js";
|
import * as blocking from "./pages/blocking.js";
|
||||||
import * as exposure from "./pages/exposure.js";
|
import * as exposure from "./pages/exposure.js";
|
||||||
import * as certificates from "./pages/certificates.js";
|
import * as certificates from "./pages/certificates.js";
|
||||||
|
import * as encdns from "./pages/encdns.js";
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ pattern: /^$/, page: dashboard, nav: "dashboard" },
|
{ pattern: /^$/, page: dashboard, nav: "dashboard" },
|
||||||
@@ -41,22 +42,28 @@ const routes = [
|
|||||||
{ pattern: /^blocking$/, page: blocking, nav: "blocking" },
|
{ pattern: /^blocking$/, page: blocking, nav: "blocking" },
|
||||||
{ pattern: /^exposure$/, page: exposure, nav: "exposure" },
|
{ pattern: /^exposure$/, page: exposure, nav: "exposure" },
|
||||||
{ pattern: /^certificates$/, page: certificates, nav: "certificates" },
|
{ pattern: /^certificates$/, page: certificates, nav: "certificates" },
|
||||||
|
{ pattern: /^encrypted-dns$/, page: encdns, nav: "encdns" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// The top bar: the pages used most, then a menu for the server's
|
||||||
|
// configuration and security. ["key", "#/href", "Label", "admin"?]
|
||||||
const navItems = [
|
const navItems = [
|
||||||
["dashboard", "#/", "Dashboard"],
|
["dashboard", "#/", "Dashboard"],
|
||||||
["stats", "#/stats", "Statistics"],
|
["stats", "#/stats", "Statistics"],
|
||||||
["zones", "#/zones", "Zones"],
|
["zones", "#/zones", "Zones"],
|
||||||
["blocking", "#/blocking", "Blocking"],
|
|
||||||
["views", "#/views", "Views"],
|
["views", "#/views", "Views"],
|
||||||
["exposure", "#/exposure", "Exposure"],
|
["blocking", "#/blocking", "Blocking"],
|
||||||
["certificates", "#/certificates", "Certificates", "admin"],
|
|
||||||
["acls", "#/acls", "ACLs"],
|
|
||||||
["keys", "#/keys", "TSIG keys"],
|
|
||||||
["options", "#/options", "Server options"],
|
|
||||||
["changes", "#/changes", "Changes"],
|
["changes", "#/changes", "Changes"],
|
||||||
["history", "#/history", "History"],
|
["history", "#/history", "History"],
|
||||||
["audit", "#/audit", "Audit log"],
|
{ menu: "Server", items: [
|
||||||
|
["options", "#/options", "Server options"],
|
||||||
|
["acls", "#/acls", "ACLs"],
|
||||||
|
["keys", "#/keys", "TSIG keys"],
|
||||||
|
["exposure", "#/exposure", "Exposure"],
|
||||||
|
["certificates", "#/certificates", "Certificates", "admin"],
|
||||||
|
["encdns", "#/encrypted-dns", "Encrypted DNS", "admin"],
|
||||||
|
["audit", "#/audit", "Audit log"],
|
||||||
|
] },
|
||||||
];
|
];
|
||||||
|
|
||||||
const app = document.getElementById("app");
|
const app = document.getElementById("app");
|
||||||
@@ -165,12 +172,16 @@ function layout() {
|
|||||||
logo(30),
|
logo(30),
|
||||||
wordmark({ rule: false, height: 19, cls: "brand-name" })),
|
wordmark({ rule: false, height: 19, cls: "brand-name" })),
|
||||||
navEl,
|
navEl,
|
||||||
h("div", { class: "spacer" }),
|
// The right-hand group stays together, on the right, also when it
|
||||||
modeEl = h("span"),
|
// has to wrap below the navigation.
|
||||||
h("form", { class: "search", onsubmit: (e) => { e.preventDefault(); if (q.value.trim()) location.hash = "#/search?q=" + encodeURIComponent(q.value.trim()); } }, q),
|
h("div", { class: "header-right" },
|
||||||
h("a", { href: "#/account", class: "user", title: (me.permissions || []).join("\n") }, me.user.username),
|
modeEl = h("span"),
|
||||||
me.admin ? h("a", { href: "#/users" }, "Users") : null,
|
h("form", { class: "search", onsubmit: (e) => { e.preventDefault(); if (q.value.trim()) location.hash = "#/search?q=" + encodeURIComponent(q.value.trim()); } }, q),
|
||||||
h("button", { class: "small", onclick: logout }, "Log out"));
|
menu(h("span", { class: "user", title: (me.permissions || []).join("\n") }, me.user.username), [
|
||||||
|
h("a", { href: "#/account" }, "Account"),
|
||||||
|
me.admin ? h("a", { href: "#/users" }, "Users") : null,
|
||||||
|
h("button", { type: "button", class: "menu-item", onclick: logout }, "Log out"),
|
||||||
|
], { right: true, active: ["account", "users"].includes(current.nav), label: "Account menu" })));
|
||||||
mount(app, h("div", { class: "shell" }, header, barEl, mainEl, footer(me.build)));
|
mount(app, h("div", { class: "shell" }, header, barEl, mainEl, footer(me.build)));
|
||||||
modeHolder = modeEl;
|
modeHolder = modeEl;
|
||||||
renderNav();
|
renderNav();
|
||||||
@@ -193,9 +204,33 @@ function footer(b) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderNav() {
|
function renderNav() {
|
||||||
mount(navEl, navItems.filter((n) => n[3] !== "admin" || state.me.admin).map(([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label)));
|
const allowed = (n) => n[3] !== "admin" || state.me.admin;
|
||||||
|
const link = ([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label);
|
||||||
|
mount(navEl, navItems.map((n) => {
|
||||||
|
if (!n.menu) return allowed(n) ? link(n) : null;
|
||||||
|
const items = n.items.filter(allowed);
|
||||||
|
return menu(n.menu, items.map(link), { active: items.some((i) => i[0] === current.nav), label: n.menu + " menu" });
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// menu is a drop-down: a button and its links. It closes when one is
|
||||||
|
// picked, on a click elsewhere and on Escape.
|
||||||
|
function menu(label, items, opts = {}) {
|
||||||
|
const d = h("details", { class: "menu" + (opts.right ? " right" : "") },
|
||||||
|
h("summary", { class: opts.active ? "active" : "", "aria-label": opts.label || null }, label, h("span", { class: "caret", "aria-hidden": "true" }, "▾")),
|
||||||
|
h("div", { class: "menu-list", role: "menu" }, items.filter(Boolean)));
|
||||||
|
d.addEventListener("click", (e) => { if (e.target.closest(".menu-list a, .menu-list button")) d.open = false; });
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("click", (e) => {
|
||||||
|
for (const d of document.querySelectorAll("details.menu[open]")) if (!d.contains(e.target)) d.open = false;
|
||||||
|
});
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key !== "Escape") return;
|
||||||
|
for (const d of document.querySelectorAll("details.menu[open]")) { d.open = false; d.querySelector("summary").focus(); }
|
||||||
|
});
|
||||||
|
|
||||||
// renderBar shows the pending-changes bar (only while there are pending
|
// renderBar shows the pending-changes bar (only while there are pending
|
||||||
// changes) and the small review/live switch in the header.
|
// changes) and the small review/live switch in the header.
|
||||||
function renderBar() {
|
function renderBar() {
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ export async function render(el) {
|
|||||||
st.backup ? backupCard(st.backup) : null,
|
st.backup ? backupCard(st.backup) : null,
|
||||||
exposure ? exposureCard(exposure) : null,
|
exposure ? exposureCard(exposure) : null,
|
||||||
st.certificates ? certCard(st.certificates) : null,
|
st.certificates ? certCard(st.certificates) : null,
|
||||||
|
st.encrypted_dns ? h("a", { class: "card link", href: "#/encrypted-dns" }, h("div", { class: "label" }, "Encrypted DNS"),
|
||||||
|
h("div", { class: "value" }, st.encrypted_dns.ok ? badge("working", "ok") : badge("not working", "err")),
|
||||||
|
h("div", { class: "sub" }, "checked ", time(st.encrypted_dns.checked))) : null,
|
||||||
);
|
);
|
||||||
const last = s.last_applied;
|
const last = s.last_applied;
|
||||||
const parts = [h("div", { class: "page-head" },
|
const parts = [h("div", { class: "page-head" },
|
||||||
@@ -43,6 +46,9 @@ export async function render(el) {
|
|||||||
h("strong", null, "The last backup failed "), time(b.last_error_at), ": " + b.last_error,
|
h("strong", null, "The last backup failed "), time(b.last_error_at), ": " + b.last_error,
|
||||||
h("div", { class: "small" }, "See 'journalctl -u jarl-backup' on the server.")));
|
h("div", { class: "small" }, "See 'journalctl -u jarl-backup' on the server.")));
|
||||||
}
|
}
|
||||||
|
for (const p of (st.encrypted_dns || {}).problems || []) {
|
||||||
|
parts.push(h("div", { class: "msg err" }, h("strong", null, "Encrypted DNS: "), p, " ", h("a", { href: "#/encrypted-dns" }, "Encrypted DNS")));
|
||||||
|
}
|
||||||
for (const c of (st.certificates || {}).problems || []) {
|
for (const c of (st.certificates || {}).problems || []) {
|
||||||
parts.push(h("div", { class: "msg " + (c.state === "expiring" ? "warn" : "err") },
|
parts.push(h("div", { class: "msg " + (c.state === "expiring" ? "warn" : "err") },
|
||||||
h("strong", null, "Certificate " + c.name + " " + (c.state === "expired" ? "has expired" : c.state === "expiring" ? "expires soon" : "can't be renewed") + ": "), c.message, " ",
|
h("strong", null, "Certificate " + c.name + " " + (c.state === "expired" ? "has expired" : c.state === "expiring" ? "expires soon" : "can't be renewed") + ": "), c.message, " ",
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
// DNS over TLS and HTTPS for your clients (docs/encrypted-dns.md): what is
|
||||||
|
// set up and what to give clients, and a setup that obtains the
|
||||||
|
// certificate if needed, checks the ports and stages named's
|
||||||
|
// configuration like any change.
|
||||||
|
import { h, mount, field, input, badge, toast, errorBox, confirmDialog, copyBox, checkbox, fmtTime, relTime } from "../ui.js";
|
||||||
|
import { api, get, change, describeResult, state } from "../api.js";
|
||||||
|
import { rerender } from "../app.js";
|
||||||
|
|
||||||
|
export async function render(el) {
|
||||||
|
if (!state.me.admin) {
|
||||||
|
mount(el, h("h1", null, "Encrypted DNS"), h("div", { class: "msg warn" }, "Only administrators can set up DNS over TLS and HTTPS."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const s = await get("/encrypted-dns");
|
||||||
|
const parts = [h("div", { class: "page-head" },
|
||||||
|
h("div", { class: "grow" }, h("h1", null, "Encrypted DNS"),
|
||||||
|
h("div", { class: "muted" }, "DNS over TLS (DoT) and DNS over HTTPS (DoH) for your own devices: phones, laptops and browsers ask this server over an encrypted connection, and get the same answers (and blocking) as over plain DNS. Clients get the view their address matches, as with plain DNS.")),
|
||||||
|
s.enabled ? h("div", { class: "actions" }, h("button", { class: "danger", onclick: disable }, "Turn off")) : null)];
|
||||||
|
|
||||||
|
if (s.enabled) parts.push(statusPanel(s));
|
||||||
|
if (s.other) parts.push(h("div", { class: "msg info" }, "named also has DoT or DoH listeners written by hand (Server options → listen-on). The setup leaves them alone."));
|
||||||
|
parts.push(setupPanel(s));
|
||||||
|
mount(el, parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusPanel(s) {
|
||||||
|
const eps = (list) => list.map((e) => e.address + " port " + e.port).join(", ");
|
||||||
|
const rows = [
|
||||||
|
h("dt", null, "Certificate"), h("dd", null, h("a", { href: "#/certificates" }, s.certificate || "?"), s.hostname ? " (" + s.hostname + ")" : null),
|
||||||
|
s.dot.length ? [h("dt", null, "DNS over TLS"), h("dd", { class: "mono" }, eps(s.dot))] : null,
|
||||||
|
s.doh.length ? [h("dt", null, "DNS over HTTPS"), h("dd", { class: "mono" }, eps(s.doh))] : null,
|
||||||
|
];
|
||||||
|
const addr = (s.dot[0] || s.doh[0] || {}).address || "ADDRESS";
|
||||||
|
const dotPortArg = s.dot[0] && s.dot[0].port !== 853 ? " -p " + s.dot[0].port : "";
|
||||||
|
const clients = [];
|
||||||
|
if (s.dot_host) {
|
||||||
|
clients.push(h("h3", null, "DNS over TLS"), copyBox(s.dot_host),
|
||||||
|
h("ul", { class: "small" },
|
||||||
|
h("li", null, h("strong", null, "Android: "), "Settings → Network → Private DNS → hostname: ", h("code", null, s.hostname), ". Away from home Android can't reach this server and then resolves nothing, so use a VPN home (WireGuard) or switch Private DNS to Automatic when you leave."),
|
||||||
|
h("li", null, h("strong", null, "Linux (systemd-resolved): "), h("code", null, "DNS=" + addr + "#" + s.hostname), " and ", h("code", null, "DNSOverTLS=yes"), " in /etc/systemd/resolved.conf (", h("code", null, "opportunistic"), " falls back to plain DNS away from home)."),
|
||||||
|
h("li", null, h("strong", null, "Test: "), h("code", null, "kdig +tls-ca +tls-host=" + s.hostname + dotPortArg + " @" + addr + " example.com"))));
|
||||||
|
}
|
||||||
|
if (s.doh_url) {
|
||||||
|
clients.push(h("h3", null, "DNS over HTTPS"), copyBox(s.doh_url),
|
||||||
|
h("ul", { class: "small" },
|
||||||
|
h("li", null, h("strong", null, "Firefox: "), "Settings → Privacy & Security → DNS over HTTPS → Increased protection (falls back to normal DNS) → Custom: the URL above."),
|
||||||
|
h("li", null, h("strong", null, "Chrome, Edge: "), "Settings → Privacy and security → Security → Use secure DNS → custom: the URL above."),
|
||||||
|
h("li", null, h("strong", null, "Windows 11: "), "Settings → Network → your connection → DNS server assignment → Edit: " + addr + ", DNS over HTTPS on (manual template), the URL above."),
|
||||||
|
h("li", null, h("strong", null, "Test: "), h("code", null, "curl -s -H 'accept: application/dns-json' '" + s.doh_url + "?name=example.com'"), " (or ", h("code", null, "dig +https @" + addr + " example.com"), ")")));
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
h("div", { class: "panel" }, h("h2", null, "Set up ", badge("on", "ok")), h("dl", { class: "kv" }, rows)),
|
||||||
|
healthPanel(s.health),
|
||||||
|
h("div", { class: "panel" }, h("h2", null, "For your clients"),
|
||||||
|
h("p", { class: "muted small" }, "Clients check the certificate against the host name, so it must resolve to this server for them (Jarl added it to the private views you chose). Open the ports in the server's firewall for your networks only."),
|
||||||
|
clients),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupPanel(s) {
|
||||||
|
const host = input({ value: s.hostname || "", placeholder: "dns.adbraun.lol", class: "mono", style: { maxWidth: "320px" } });
|
||||||
|
const certSel = h("select");
|
||||||
|
const dot = checkbox("DNS over TLS", s.enabled ? s.dot.length > 0 : true);
|
||||||
|
const doh = checkbox("DNS over HTTPS", s.enabled ? s.doh.length > 0 : true);
|
||||||
|
const dotPort = input({ type: "number", value: (s.dot[0] || {}).port || 853, style: { width: "90px" } });
|
||||||
|
const dohPort = input({ type: "number", value: (s.doh[0] || {}).port || 443, style: { width: "90px" } });
|
||||||
|
const current = new Set([...s.dot, ...s.doh].map((e) => e.address));
|
||||||
|
const picked = new Set(current);
|
||||||
|
const portOut = h("div");
|
||||||
|
const err = h("div");
|
||||||
|
const progress = h("div", { class: "muted small" });
|
||||||
|
|
||||||
|
// Addresses: one per row, nothing ticked (but the current setup's).
|
||||||
|
const addrRows = s.addresses.map((a) => pickRow(a.address, picked, [a.private ? h("span", { class: "muted" }, "private")
|
||||||
|
: a.address.includes(":") ? h("span", { class: "muted", title: "A global IPv6 address: reachable from the internet unless the firewall stops it" }, "global")
|
||||||
|
: badge("public", "warn")],
|
||||||
|
"Listen on " + a.address, () => checkPorts()));
|
||||||
|
// Views for the host name's records: the private ones that have its zone.
|
||||||
|
const viewPicked = new Set();
|
||||||
|
const viewBody = h("tbody");
|
||||||
|
const drawViews = (views) => {
|
||||||
|
viewPicked.clear();
|
||||||
|
mount(viewBody, views.map((v) => {
|
||||||
|
if (!v.public && v.has_zone) viewPicked.add(v.name);
|
||||||
|
return pickRow(v.name, viewPicked, [v.public ? badge("public", "warn") : h("span", { class: "muted" }, "private"), " ",
|
||||||
|
v.has_zone ? h("span", { class: "muted small" }, "has the zone") : h("span", { class: "muted small" }, "no zone for this name: skipped")], "Add the host name to view " + v.name);
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
drawViews(s.views);
|
||||||
|
|
||||||
|
const fillCerts = () => {
|
||||||
|
const name = host.value.trim().toLowerCase().replace(/\.$/, "");
|
||||||
|
const fit = s.certificates.filter((c) => covers([...(c.names || []), ...(c.domains || [])], name));
|
||||||
|
mount(certSel,
|
||||||
|
fit.map((c) => h("option", { value: c.name, selected: c.name === s.certificate }, c.name + " (" + (c.names || c.domains).join(", ") + (c.obtained ? "" : ", not obtained yet") + ")")),
|
||||||
|
name ? h("option", { value: "" }, "Obtain a new certificate for " + name) : h("option", { value: "" }, "Type the host name first"));
|
||||||
|
};
|
||||||
|
fillCerts();
|
||||||
|
let viewTimer = null;
|
||||||
|
host.addEventListener("input", () => {
|
||||||
|
fillCerts();
|
||||||
|
clearTimeout(viewTimer);
|
||||||
|
viewTimer = setTimeout(async () => {
|
||||||
|
const r = await get("/encrypted-dns", { hostname: host.value.trim() }).catch(() => null);
|
||||||
|
if (r) drawViews(r.views);
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
let checkTimer = null, checkSeq = 0;
|
||||||
|
const checkPorts = () => {
|
||||||
|
clearTimeout(checkTimer);
|
||||||
|
checkTimer = setTimeout(async () => {
|
||||||
|
const seq = ++checkSeq;
|
||||||
|
if (!picked.size) { mount(portOut); return; }
|
||||||
|
const r = await api("POST", "/encrypted-dns/check", { body: body(false) }).catch((e) => ({ error: e.message, conflicts: [] }));
|
||||||
|
if (seq !== checkSeq) return;
|
||||||
|
const msgs = r.conflicts.map((c) => h("div", null, c.what + " listens on " + c.address + " port " + c.port + "."));
|
||||||
|
if (r.conflicts.some((c) => c.port === Number(dohPort.value)) && r.free_doh_port) {
|
||||||
|
msgs.push(h("div", null, "Port " + r.free_doh_port + " is free for DoH: ",
|
||||||
|
h("button", { type: "button", class: "small", onclick: () => { dohPort.value = r.free_doh_port; checkPorts(); } }, "Use " + r.free_doh_port)));
|
||||||
|
}
|
||||||
|
mount(portOut, r.error ? h("div", { class: "msg warn small" }, r.error)
|
||||||
|
: msgs.length ? h("div", { class: "msg err small" }, msgs)
|
||||||
|
: h("div", { class: "msg ok small" }, "The ports are free on the chosen addresses."));
|
||||||
|
}, 250);
|
||||||
|
};
|
||||||
|
for (const x of [dotPort, dohPort]) x.addEventListener("input", checkPorts);
|
||||||
|
for (const x of [dot, doh]) x.input.addEventListener("change", checkPorts);
|
||||||
|
if (picked.size) checkPorts();
|
||||||
|
|
||||||
|
const body = (full) => {
|
||||||
|
const b = { hostname: host.value.trim(), addresses: [...picked], dot: dot.input.checked, doh: doh.input.checked, dot_port: Number(dotPort.value) || 853, doh_port: Number(dohPort.value) || 443 };
|
||||||
|
if (full) { b.certificate = certSel.value; b.views = [...viewPicked]; }
|
||||||
|
return b;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async (btn) => {
|
||||||
|
mount(err);
|
||||||
|
const b = body(true);
|
||||||
|
if (!b.hostname) return mount(err, h("div", { class: "msg err" }, "Give the host name clients connect to."));
|
||||||
|
if (!b.addresses.length) return mount(err, h("div", { class: "msg err" }, "Choose at least one address to listen on."));
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
if (!b.certificate) {
|
||||||
|
// Obtain one for the host name, for named, and wait for it.
|
||||||
|
b.certificate = b.hostname.split(".")[0].replace(/[^a-z0-9_-]/g, "-");
|
||||||
|
mount(progress, "Obtaining a certificate for " + b.hostname + " from the CA…");
|
||||||
|
await api("POST", "/certificates", { body: { name: b.certificate, domains: [b.hostname], uses: ["dns"] } });
|
||||||
|
await waitObtained(b.certificate, progress);
|
||||||
|
} else {
|
||||||
|
const c = await get("/certificates/" + encodeURIComponent(b.certificate));
|
||||||
|
if (!c.uses.includes("dns")) await api("PATCH", "/certificates/" + encodeURIComponent(b.certificate), { body: { uses: [...c.uses, "dns"] } });
|
||||||
|
if (!c.not_after) {
|
||||||
|
if (!c.issuing_since) await api("POST", "/certificates/" + encodeURIComponent(b.certificate) + "/issue", { body: {} });
|
||||||
|
await waitObtained(b.certificate, progress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mount(progress, "Setting up named…");
|
||||||
|
const r = await change("POST", "/encrypted-dns", b);
|
||||||
|
toast(describeResult(r));
|
||||||
|
(r.warnings || []).forEach((w) => toast(w, "warn", 10000));
|
||||||
|
rerender();
|
||||||
|
} catch (e) {
|
||||||
|
mount(progress);
|
||||||
|
mount(err, errorBox(e));
|
||||||
|
} finally { btn.disabled = false; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const go = h("button", { class: "primary", onclick: (e) => submit(e.target) }, s.enabled ? "Change setup" : "Set up");
|
||||||
|
return h("div", { class: "panel" },
|
||||||
|
h("h2", null, s.enabled ? "Change the setup" : "Set up"),
|
||||||
|
h("div", { class: "form-grid" },
|
||||||
|
field("Host name", host, "The name clients connect to. The certificate is for it, and it gets this server's addresses in the views below."),
|
||||||
|
field("Certificate", certSel, "One with this name, or a new one Jarl obtains and renews (docs/certificates.md)."),
|
||||||
|
field("Listen on", h("div", null, h("div", { class: "table-wrap" }, h("table", { class: "pick-list" }, h("tbody", null, addrRows))), portOut),
|
||||||
|
"This server's addresses on your own networks. A public address works too, but then anyone who reaches it can use these listeners, as with port 53 there."),
|
||||||
|
field("Serve", h("div", { class: "enc-ports" }, h("div", null, dot, " on port ", dotPort), h("div", null, doh, " on port ", dohPort, h("span", { class: "muted small" }, " at /dns-query"))),
|
||||||
|
"Clients expect DoT on port 853. DoH can use any free port; 443 is the default, and needs nothing else listening there."),
|
||||||
|
field("Host name in views", h("div", { class: "table-wrap" }, h("table", { class: "pick-list" }, viewBody)),
|
||||||
|
"Where the host name gets the chosen addresses (A and AAAA), local to the view in a linked zone. Clients outside these views can't find it.")),
|
||||||
|
err, progress,
|
||||||
|
h("div", { class: "buttons" }, go));
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickRow is a table row that toggles value in set when clicked.
|
||||||
|
function pickRow(value, set, extra, label, onChange) {
|
||||||
|
const box = h("input", { type: "checkbox", checked: set.has(value), "aria-label": label });
|
||||||
|
const tr = h("tr", { class: "pick" + (set.has(value) ? " selected" : "") }, h("td", null, box), h("td", { class: "mono" }, value), h("td", null, extra));
|
||||||
|
box.addEventListener("change", () => {
|
||||||
|
if (box.checked) set.add(value); else set.delete(value);
|
||||||
|
tr.classList.toggle("selected", box.checked);
|
||||||
|
if (onChange) onChange();
|
||||||
|
});
|
||||||
|
tr.addEventListener("click", (e) => { if (e.target !== box) { box.checked = !box.checked; box.dispatchEvent(new Event("change")); } });
|
||||||
|
return tr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function covers(names, host) {
|
||||||
|
if (!host) return false;
|
||||||
|
return names.some((n) => {
|
||||||
|
n = n.toLowerCase().replace(/\.$/, "");
|
||||||
|
if (n === host) return true;
|
||||||
|
return n.startsWith("*.") && host.split(".").slice(1).join(".") === n.slice(2);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitObtained(name, progress) {
|
||||||
|
const started = Date.now();
|
||||||
|
for (;;) {
|
||||||
|
await new Promise((r) => setTimeout(r, 2500));
|
||||||
|
const c = await get("/certificates/" + encodeURIComponent(name));
|
||||||
|
if (!c.issuing_since) {
|
||||||
|
if (!c.not_after) throw new Error("Obtaining the certificate failed: " + (c.last_error || "see the Certificates page"));
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
mount(progress, "Obtaining a certificate for " + (c.domains || []).join(", ") + " from the CA… " + Math.round((Date.now() - started) / 1000) + "s (the challenges must reach every name server)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disable() {
|
||||||
|
if (!await confirmDialog("Turn encrypted DNS off", "named stops serving DNS over TLS and HTTPS. The certificate and the host name's records stay.", "Turn off", "danger")) return;
|
||||||
|
try {
|
||||||
|
toast(describeResult(await change("DELETE", "/encrypted-dns")));
|
||||||
|
rerender();
|
||||||
|
} catch (e) { toast(e.message, "err", 8000); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// healthPanel shows the last check of the listeners: Jarl connects to each
|
||||||
|
// as a client would, checks the certificate and asks a question.
|
||||||
|
function healthPanel(hl) {
|
||||||
|
const kind = { dot: "DNS over TLS", doh: "DNS over HTTPS" };
|
||||||
|
const out = h("div");
|
||||||
|
const draw = (x) => {
|
||||||
|
if (!x) return mount(out, h("div", { class: "muted" }, "Not checked yet: Jarl checks a minute after it starts, then every 10 minutes."));
|
||||||
|
mount(out, h("ul", { class: "probes" }, x.probes.map((p) => h("li", null,
|
||||||
|
badge(p.ok ? "working" : "not working", p.ok ? "ok" : "err"), " ",
|
||||||
|
h("strong", null, kind[p.kind]), " ", h("span", { class: "mono" }, p.address + " port " + p.port),
|
||||||
|
p.ok ? h("span", { class: "muted small" }, " answered " + p.rcode + " in " + p.ms + " ms" + (p.cert_not_after ? " · certificate until " + fmtTime(p.cert_not_after) : "")) : null,
|
||||||
|
p.problem ? h("div", { class: "msg err small" }, p.problem) : null,
|
||||||
|
p.warning ? h("div", { class: "msg warn small" }, p.warning) : null))),
|
||||||
|
h("div", { class: "muted small" }, "Checked ", relTime(x.checked), "."));
|
||||||
|
};
|
||||||
|
draw(hl);
|
||||||
|
const btn = h("button", { class: "small", onclick: async () => {
|
||||||
|
btn.disabled = true;
|
||||||
|
try { draw(await api("POST", "/encrypted-dns/health", { body: {} })); } catch (e) { toast(e.message, "err", 8000); } finally { btn.disabled = false; }
|
||||||
|
} }, "Check now");
|
||||||
|
return h("div", { class: "panel" }, h("div", { class: "page-head" }, h("h2", { class: "grow" }, "Health"), h("div", { class: "actions" }, btn)),
|
||||||
|
h("p", { class: "muted small" }, "Jarl connects to each listener as a client would: the certificate must be for the host name, current, and the one Jarl last wrote; then it asks for the host name."),
|
||||||
|
out);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
// Internet exposure (docs/exposure.md): which views are reachable from
|
// Internet exposure (docs/exposure.md): which views are reachable from
|
||||||
// the internet, why, and what to harden, with fixes staged like any edit
|
// the internet, why, and what to harden, with fixes staged like any edit
|
||||||
// and findings that can be accepted on purpose.
|
// and findings that can be accepted on purpose.
|
||||||
import { h, mount, table, toast, badge, modal, input, field, errorBox, select, plural } from "../ui.js";
|
import { h, mount, table, toast, badge, modal, input, field, errorBox, select, plural, relTime } from "../ui.js";
|
||||||
import { get, change, describeResult, can } from "../api.js";
|
import { api, get, change, describeResult, can } from "../api.js";
|
||||||
import { rerender } from "../app.js";
|
import { rerender } from "../app.js";
|
||||||
|
|
||||||
const sevKind = { warning: "warn", error: "err", info: "" };
|
const sevKind = { warning: "warn", error: "err", info: "" };
|
||||||
@@ -28,7 +28,11 @@ export async function render(el) {
|
|||||||
parts.push(h("div", { class: "panel" }, h("h2", null, "Findings ", badge(plural(all.filter((f) => f.severity === "warning").length, "warning"), all.some((f) => f.severity === "warning") ? "warn" : "ok")),
|
parts.push(h("div", { class: "panel" }, h("h2", null, "Findings ", badge(plural(all.filter((f) => f.severity === "warning").length, "warning"), all.some((f) => f.severity === "warning") ? "warn" : "ok")),
|
||||||
all.length ? h("div", { class: "findings" }, all.map((f) => findingRow(f, writable))) : h("div", { class: "empty" }, r.scopes.some((s) => s.public) ? "Nothing to harden." : "No view is reachable from the internet, so there is nothing to check.")));
|
all.length ? h("div", { class: "findings" }, all.map((f) => findingRow(f, writable))) : h("div", { class: "empty" }, r.scopes.some((s) => s.public) ? "Nothing to harden." : "No view is reachable from the internet, so there is nothing to check.")));
|
||||||
|
|
||||||
if (r.rate_limiting) parts.push(activity(r.rate_limiting, r.scopes));
|
if (r.rate_limiting && r.rate_limit_log) parts.push(activity(r.rate_limiting, r.scopes));
|
||||||
|
else if (r.rate_limiting && r.scopes.some((s) => s.public && s.rate_limit !== "off")) {
|
||||||
|
parts.push(h("div", { class: "msg info" }, "What rate limiting limits isn't counted: stats.rate_limit_log is off in /etc/jarl/jarl.yaml, so its log lines stay in named's syslog only."));
|
||||||
|
}
|
||||||
|
if (r.scopes.some((s) => s.public)) parts.push(mailPanel(r.mail));
|
||||||
mount(el, parts);
|
mount(el, parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,3 +122,31 @@ function activity(a, scopes) {
|
|||||||
table([{ label: "Client network (limited or would be)", render: (e) => h("span", { class: "mono" }, e.key) }, { label: "Responses", render: (e) => String(e.count) }], a.networks || []),
|
table([{ label: "Client network (limited or would be)", render: (e) => h("span", { class: "mono" }, e.key) }, { label: "Responses", render: (e) => String(e.count) }], a.networks || []),
|
||||||
table([{ label: "Name and type", render: (e) => h("span", { class: "mono" }, e.key) }, { label: "Responses", render: (e) => String(e.count) }], a.names || []))]);
|
table([{ label: "Name and type", render: (e) => h("span", { class: "mono" }, e.key) }, { label: "Responses", render: (e) => String(e.count) }], a.names || []))]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mailPanel: the reverse DNS of the addresses that send and receive the
|
||||||
|
// public zones' mail, as receivers check it (docs/exposure.md#mail).
|
||||||
|
function mailPanel(m) {
|
||||||
|
const out = h("div");
|
||||||
|
const lv = { ok: "ok", info: "", warning: "warn" };
|
||||||
|
const draw = (x) => {
|
||||||
|
if (!x) return mount(out, h("div", { class: "muted" }, "Not checked yet: Jarl checks a minute after it starts, then every 6 hours."));
|
||||||
|
const rows = x.results.length ? table([
|
||||||
|
{ label: "Address", render: (r) => [h("span", { class: "mono" }, r.address), h("div", { class: "muted small" }, r.zone + ": " + r.roles.join(", "))] },
|
||||||
|
{ label: "PTR", render: (r) => r.ptr.length ? h("span", { class: "mono" }, r.ptr.join(", ")) : h("span", { class: "muted" }, "none") },
|
||||||
|
{ label: "Reverse zone run by", render: (r) => h("span", { class: "small" }, r.managed_by || "–") },
|
||||||
|
{ label: "", render: (r) => [badge(r.ok ? (r.level === "info" ? "works, note" : "works") : "problem", lv[r.level]),
|
||||||
|
r.level !== "ok" ? h("div", { class: "small" }, r.message) : null,
|
||||||
|
r.local_zone ? h("div", { class: "muted small" }, "Jarl's reverse zone " + r.local_zone + " only answers your own clients: receivers ask " + (r.managed_by || "the address's owner") + ".") : null] },
|
||||||
|
], x.results) : h("div", { class: "empty" }, "No public zone has an MX record or SPF addresses.");
|
||||||
|
mount(out, rows, (x.errors || []).map((e) => h("div", { class: "msg err small" }, e)),
|
||||||
|
h("div", { class: "muted small" }, "Checked ", relTime(x.checked), "."));
|
||||||
|
};
|
||||||
|
draw(m);
|
||||||
|
const btn = h("button", { class: "small", onclick: async () => {
|
||||||
|
btn.disabled = true;
|
||||||
|
try { draw(await api("POST", "/exposure/mail", { body: {} })); } catch (e) { toast(e.message, "err", 8000); } finally { btn.disabled = false; }
|
||||||
|
} }, "Check now");
|
||||||
|
return h("div", { class: "panel" }, h("div", { class: "page-head" }, h("h2", { class: "grow" }, "Mail reverse DNS"), h("div", { class: "actions" }, btn)),
|
||||||
|
h("p", { class: "muted small" }, "Receivers such as Gmail and Outlook reject or mark mail from an address without a PTR record whose name resolves back to it. These records belong to whoever owns the address (your hosting provider or ISP), so Jarl looks them up as the internet sees them: the addresses of your public zones' MX hosts and SPF records."),
|
||||||
|
out);
|
||||||
|
}
|
||||||
@@ -159,14 +159,32 @@ export async function render(el) {
|
|||||||
const settingsEl = h("div");
|
const settingsEl = h("div");
|
||||||
const serverEl = h("div");
|
const serverEl = h("div");
|
||||||
const topEl = h("div");
|
const topEl = h("div");
|
||||||
|
const genEl = h("div");
|
||||||
mount(el,
|
mount(el,
|
||||||
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Server options"),
|
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Server options"),
|
||||||
h("div", { class: "muted" }, "Global named options, top-level statements such as logging and controls, and Jarl's defaults for new zones."))),
|
h("div", { class: "muted" }, "Global named options, top-level statements such as logging and controls, and Jarl's defaults for new zones."))),
|
||||||
settingsEl, serverEl, topEl);
|
settingsEl, serverEl, topEl, genEl);
|
||||||
settingsForm(settingsEl, settings);
|
settingsForm(settingsEl, settings);
|
||||||
await optionsEditor(serverEl, { scope: "options", catalogScope: "options", title: "options { }", intro: "These apply to the whole server; views and zones can override many of them." });
|
await optionsEditor(serverEl, { scope: "options", catalogScope: "options", title: "options { }", intro: "These apply to the whole server; views and zones can override many of them." });
|
||||||
await optionsEditor(topEl, { scope: "toplevel", catalogScope: "toplevel", title: "Top-level statements",
|
await optionsEditor(topEl, { scope: "toplevel", catalogScope: "toplevel", title: "Top-level statements",
|
||||||
intro: "Statements outside options { }, such as logging, controls, statistics-channels, server, remote-servers, tls or include. Values are named.conf syntax, e.g. { channel ... ; category ... ; } for logging." });
|
intro: "Statements outside options { }, such as logging, controls, statistics-channels, server, remote-servers, tls or include. Values are named.conf syntax, e.g. { channel ... ; category ... ; } for logging." });
|
||||||
|
generatedPanel(genEl, await get("/options/generated").catch(() => []));
|
||||||
|
}
|
||||||
|
|
||||||
|
// generatedPanel lists what Jarl adds to named.conf by itself: read-only,
|
||||||
|
// with why and what turns it on or off.
|
||||||
|
function generatedPanel(el, list) {
|
||||||
|
if (!list.length) return;
|
||||||
|
mount(el, h("div", { class: "panel" },
|
||||||
|
h("h2", null, "Added by Jarl"),
|
||||||
|
h("p", { class: "muted" }, "Jarl writes these into named.conf itself, for its own features. They aren't options you can edit here: each is controlled by the setting shown."),
|
||||||
|
h("div", { class: "table-wrap" }, h("table", null,
|
||||||
|
h("thead", null, h("tr", null, h("th", null, "Where"), h("th", null, "Statement"), h("th", null, "Why"), h("th", null, "Controlled by"))),
|
||||||
|
h("tbody", null, list.map((g) => h("tr", null,
|
||||||
|
h("td", { class: "small" }, g.where),
|
||||||
|
h("td", null, h("pre", { class: "code optval", style: "white-space: pre-wrap; word-break: break-all" }, g.text)),
|
||||||
|
h("td", { class: "small" }, g.why),
|
||||||
|
h("td", { class: "small mono" }, g.setting))))))));
|
||||||
}
|
}
|
||||||
|
|
||||||
function settingsForm(el, s) {
|
function settingsForm(el, s) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { h, mount, fmtTime, relTime, errorBox } from "../ui.js";
|
import { h, mount, fmtTime, relTime, errorBox } from "../ui.js";
|
||||||
import { get } from "../api.js";
|
import { get } from "../api.js";
|
||||||
|
import { rerender } from "../app.js";
|
||||||
import { lineChart, chartCard, legend, timeTable, heatmap, heatmapTable, barList, compact, stepSeconds, endLabelWidth } from "../charts.js";
|
import { lineChart, chartCard, legend, timeTable, heatmap, heatmapTable, barList, compact, stepSeconds, endLabelWidth } from "../charts.js";
|
||||||
|
|
||||||
const ranges = [["1h", "1 hour"], ["6h", "6 hours"], ["24h", "24 hours"], ["7d", "7 days"], ["30d", "30 days"], ["90d", "90 days"]];
|
const ranges = [["1h", "1 hour"], ["6h", "6 hours"], ["24h", "24 hours"], ["7d", "7 days"], ["30d", "30 days"], ["90d", "90 days"]];
|
||||||
@@ -17,6 +18,20 @@ const rttOrder = ["<10ms", "10-100ms", "100-500ms", "500-800ms", "800-1600ms", "
|
|||||||
|
|
||||||
let generation = 0;
|
let generation = 0;
|
||||||
|
|
||||||
|
// The views shown are remembered per browser: the ones switched off, so a
|
||||||
|
// new view shows up switched on.
|
||||||
|
const offKey = "jarl.stats.views.off";
|
||||||
|
function viewsOff() {
|
||||||
|
try { return new Set(JSON.parse(localStorage.getItem(offKey) || "[]")); } catch { return new Set(); }
|
||||||
|
}
|
||||||
|
function saveViewsOff(set) {
|
||||||
|
try { localStorage.setItem(offKey, JSON.stringify([...set])); } catch { /* private window: not remembered */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Breakdowns that exist per view; the others are named's own counters,
|
||||||
|
// for the whole server.
|
||||||
|
const perViewGroups = ["qtype", "rcode", "view", "zone", "blocklist"];
|
||||||
|
|
||||||
export async function render(el, params, query) {
|
export async function render(el, params, query) {
|
||||||
const gen = ++generation;
|
const gen = ++generation;
|
||||||
const want = query && query.get ? query.get("since") : null;
|
const want = query && query.get ? query.get("since") : null;
|
||||||
@@ -33,11 +48,21 @@ export async function render(el, params, query) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const heatSince = rangeSecs[since] < 7 * 86400 ? "7d" : since;
|
const heatSince = rangeSecs[since] < 7 * 86400 ? "7d" : since;
|
||||||
|
// Which views: all of them unless some are switched off.
|
||||||
|
const allViews = status.query_log ? (await get("/views").catch(() => [])).map((v) => v.name) : [];
|
||||||
|
const off = viewsOff();
|
||||||
|
const shown = allViews.filter((v) => !off.has(v));
|
||||||
|
const filtered = allViews.length > 0 && shown.length < allViews.length;
|
||||||
|
const vf = filtered ? { views: shown.join(",") } : {};
|
||||||
|
if (filtered && !shown.length) {
|
||||||
|
mount(el, head, filterRow(since, allViews, off), h("div", { class: "empty" }, "No views chosen: switch one on above."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
let data;
|
let data;
|
||||||
try {
|
try {
|
||||||
data = await loadAll(since, heatSince, status.query_log);
|
data = await loadAll(since, heatSince, status.query_log, vf, filtered);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
mount(el, head, errorBox(e));
|
mount(el, head, filterRow(since, allViews, off), errorBox(e));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (gen !== generation) return; // the user moved on
|
if (gen !== generation) return; // the user moved on
|
||||||
@@ -46,13 +71,17 @@ export async function render(el, params, query) {
|
|||||||
// sudden drop in traffic.
|
// sudden drop in traffic.
|
||||||
const timeline = complete(data.timeline), errors = complete(data.errors);
|
const timeline = complete(data.timeline), errors = complete(data.errors);
|
||||||
const step = stepSeconds[timeline.step];
|
const step = stepSeconds[timeline.step];
|
||||||
const parts = [head, filterRow(since)];
|
const parts = [head, filterRow(since, allViews, off)];
|
||||||
for (const w of status.warnings || []) parts.push(h("div", { class: "msg warn" }, w));
|
for (const w of status.warnings || []) parts.push(h("div", { class: "msg warn" }, w));
|
||||||
|
if (filtered) {
|
||||||
|
parts.push(h("div", { class: "msg info small" }, "Showing view" + (shown.length > 1 ? "s " : " ") + shown.join(", ") + ": answers from named's response log" +
|
||||||
|
(s.views_since ? ", per view since " + fmtTime(s.views_since) : "") + ". named's own figures (answer sources, upstream times, cache, the server) are for the whole server."));
|
||||||
|
}
|
||||||
|
|
||||||
// Headline numbers.
|
// Headline numbers.
|
||||||
const rangeLabel = "in the last " + ranges.find((r) => r[0] === since)[1];
|
const rangeLabel = "in the last " + ranges.find((r) => r[0] === since)[1];
|
||||||
parts.push(h("div", { class: "cards" },
|
parts.push(h("div", { class: "cards" },
|
||||||
tile("Queries", compact(s.queries), rangeLabel),
|
tile(filtered ? "Answers" : "Queries", compact(s.queries), rangeLabel),
|
||||||
tile("Per second", s.avg_qps < 10 ? s.avg_qps.toFixed(2) : Math.round(s.avg_qps).toLocaleString(), "on average"),
|
tile("Per second", s.avg_qps < 10 ? s.avg_qps.toFixed(2) : Math.round(s.avg_qps).toLocaleString(), "on average"),
|
||||||
s.peak ? tile("Busiest " + s.peak.per, compact(s.peak.queries), fmtTime(s.peak.time)) : null,
|
s.peak ? tile("Busiest " + s.peak.per, compact(s.peak.queries), fmtTime(s.peak.time)) : null,
|
||||||
s.nxdomain_ratio != null ? tile("NXDOMAIN", pct(s.nxdomain_ratio), "of queries named no existing name") : null,
|
s.nxdomain_ratio != null ? tile("NXDOMAIN", pct(s.nxdomain_ratio), "of queries named no existing name") : null,
|
||||||
@@ -62,7 +91,7 @@ export async function render(el, params, query) {
|
|||||||
// Volume over time: one series, so no legend box; the title names it.
|
// Volume over time: one series, so no legend box; the title names it.
|
||||||
// With blocking, a second line shows how many of them were blocked.
|
// With blocking, a second line shows how many of them were blocked.
|
||||||
const q = timeline.series.find((x) => x.key === "queries") || { values: timeline.times.map(() => 0) };
|
const q = timeline.series.find((x) => x.key === "queries") || { values: timeline.times.map(() => 0) };
|
||||||
const qSeries = [{ label: "Queries", values: q.values, slot: 1 }];
|
const qSeries = [{ label: filtered ? "Answers" : "Queries", values: q.values, slot: 1 }];
|
||||||
const bl = timeline.series.find((x) => x.key === "blocked");
|
const bl = timeline.series.find((x) => x.key === "blocked");
|
||||||
if (bl && bl.values.some((v) => v > 0)) qSeries.push({ label: "Blocked", values: bl.values, slot: 2 });
|
if (bl && bl.values.some((v) => v > 0)) qSeries.push({ label: "Blocked", values: bl.values, slot: 2 });
|
||||||
// Failed answers: three fixed series, legend plus end labels.
|
// Failed answers: three fixed series, legend plus end labels.
|
||||||
@@ -71,7 +100,8 @@ export async function render(el, params, query) {
|
|||||||
// The two charts are one above the other: the same room for labels on
|
// The two charts are one above the other: the same room for labels on
|
||||||
// both sides keeps their time axes in line.
|
// both sides keeps their time axes in line.
|
||||||
const gutter = { left: 52, right: Math.max(endLabelWidth(qSeries), anyFailures ? endLabelWidth(fSeries) : 0) };
|
const gutter = { left: 52, right: Math.max(endLabelWidth(qSeries), anyFailures ? endLabelWidth(fSeries) : 0) };
|
||||||
parts.push(chartCard(qSeries.length > 1 ? "Queries and blocked queries" : "Queries", "per " + stepName(timeline.step) + ", " + rangeLabel,
|
const qWord = filtered ? "Answers" : "Queries";
|
||||||
|
parts.push(chartCard(qSeries.length > 1 ? qWord + " and blocked queries" : qWord, "per " + stepName(timeline.step) + ", " + rangeLabel,
|
||||||
lineChart({ series: qSeries, times: timeline.times, step, area: true, gutter }), timeTable(qSeries, timeline.times, step),
|
lineChart({ series: qSeries, times: timeline.times, step, area: true, gutter }), timeTable(qSeries, timeline.times, step),
|
||||||
{ wide: true, legend: qSeries.length > 1 ? legend(qSeries) : null }));
|
{ wide: true, legend: qSeries.length > 1 ? legend(qSeries) : null }));
|
||||||
|
|
||||||
@@ -117,19 +147,20 @@ export async function render(el, params, query) {
|
|||||||
scheduleRefresh(el, params, query, gen);
|
scheduleRefresh(el, params, query, gen);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAll(since, heatSince, queryLog) {
|
async function loadAll(since, heatSince, queryLog, vf, filtered) {
|
||||||
const p = {
|
const p = {
|
||||||
summary: get("/stats/summary", { since, top: 0 }),
|
summary: get("/stats/summary", { since, top: 0, ...vf }),
|
||||||
timeline: get("/stats/timeline", { since, key: "queries,blocked" }),
|
timeline: get("/stats/timeline", { since, key: "queries,blocked", ...vf }),
|
||||||
errors: get("/stats/timeline", { since, key: failures.map((f) => f.key).join(",") }),
|
errors: get("/stats/timeline", { since, key: failures.map((f) => f.key).join(","), ...vf }),
|
||||||
heat: get("/stats/heatmap", { since: heatSince }),
|
heat: get("/stats/heatmap", { since: heatSince, ...vf }),
|
||||||
server: get("/stats/server").catch(() => null),
|
server: get("/stats/server").catch(() => null),
|
||||||
gauges: get("/stats/timeline", { since, key: gaugeKeys.join(",") }),
|
gauges: get("/stats/timeline", { since, key: gaugeKeys.join(",") }),
|
||||||
};
|
};
|
||||||
const groups = ["qtype", "rcode", "view", "zone", "answer", "rtt", "blocklist"];
|
const groups = ["qtype", "rcode", "view", "zone", "answer", "rtt", "blocklist"];
|
||||||
groups.forEach((g) => { p["b_" + g] = get("/stats/breakdown", { group: g, since }); });
|
// With some views only, named's own counters aren't asked for.
|
||||||
|
groups.forEach((g) => { p["b_" + g] = filtered && !perViewGroups.includes(g) ? Promise.resolve([]) : get("/stats/breakdown", { group: g, since, ...vf }); });
|
||||||
if (queryLog) {
|
if (queryLog) {
|
||||||
for (const k of ["name", "client", "nxdomain", "refused", "servfail", "blocked", "blocked_client"]) p["t_" + k] = get("/stats/top", { kind: k, since, limit: 15 });
|
for (const k of ["name", "client", "nxdomain", "refused", "servfail", "blocked", "blocked_client"]) p["t_" + k] = get("/stats/top", { kind: k, since, limit: 15, ...vf });
|
||||||
}
|
}
|
||||||
const keys = Object.keys(p);
|
const keys = Object.keys(p);
|
||||||
const vals = await Promise.all(Object.values(p));
|
const vals = await Promise.all(Object.values(p));
|
||||||
@@ -162,11 +193,26 @@ function complete(tl) {
|
|||||||
return tl;
|
return tl;
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterRow(since) {
|
function filterRow(since, views, off) {
|
||||||
return h("div", { class: "filter-row", role: "group", "aria-label": "Time range" },
|
const range = h("div", { class: "filter-row", role: "group", "aria-label": "Time range" },
|
||||||
h("span", { class: "muted" }, "Range"),
|
h("span", { class: "muted" }, "Range"),
|
||||||
h("div", { class: "segmented" }, ranges.map(([k, label]) =>
|
h("div", { class: "segmented" }, ranges.map(([k, label]) =>
|
||||||
h("a", { href: "#/stats?since=" + k, class: k === since ? "active" : "", "aria-current": k === since ? "true" : null, title: "last " + label }, k))));
|
h("a", { href: "#/stats?since=" + k, class: k === since ? "active" : "", "aria-current": k === since ? "true" : null, title: "last " + label }, k))));
|
||||||
|
if (!views || views.length < 2) return range;
|
||||||
|
// A toggle per view (together: linked views, say), and all or none.
|
||||||
|
const set = (next) => { saveViewsOff(next); rerender(); };
|
||||||
|
const toggles = views.map((v) => {
|
||||||
|
const on = !off.has(v);
|
||||||
|
return h("button", { type: "button", class: "chip toggle-chip" + (on ? " on" : ""), "aria-pressed": on ? "true" : "false",
|
||||||
|
title: on ? "Leave view " + v + " out" : "Include view " + v,
|
||||||
|
onclick: () => { const n = new Set(off); if (on) n.add(v); else n.delete(v); set(n); } }, v);
|
||||||
|
});
|
||||||
|
return h("div", { class: "filter-rows" }, range,
|
||||||
|
h("div", { class: "filter-row", role: "group", "aria-label": "Views" },
|
||||||
|
h("span", { class: "muted" }, "Views"),
|
||||||
|
h("div", { class: "chips" }, toggles),
|
||||||
|
h("button", { type: "button", class: "small", disabled: off.size === 0, onclick: () => set(new Set()) }, "All"),
|
||||||
|
h("button", { type: "button", class: "small", disabled: off.size === views.length, onclick: () => set(new Set(views)) }, "None")));
|
||||||
}
|
}
|
||||||
|
|
||||||
const gaugeKeys = ["g:named:rss", "g:named:mem", "g:cache:mem", "g:named:cpu"];
|
const gaugeKeys = ["g:named:rss", "g:named:mem", "g:cache:mem", "g:named:cpu"];
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export async function render(el, params, query) {
|
|||||||
zone.type === "primary" ? ["file", "Zone file"] : null,
|
zone.type === "primary" ? ["file", "Zone file"] : null,
|
||||||
["primary", "secondary", "mirror"].includes(zone.type) ? ["servers", "Name servers"] : null,
|
["primary", "secondary", "mirror"].includes(zone.type) ? ["servers", "Name servers"] : null,
|
||||||
zone.type === "primary" ? ["dnssec", "DNSSEC"] : null,
|
zone.type === "primary" ? ["dnssec", "DNSSEC"] : null,
|
||||||
|
zone.other_views ? ["compare", "Compare views"] : null,
|
||||||
zone.registrar && state.me && state.me.admin ? ["registrar", "Registrar"] : null,
|
zone.registrar && state.me && state.me.admin ? ["registrar", "Registrar"] : null,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
const body = h("div");
|
const body = h("div");
|
||||||
@@ -34,12 +35,15 @@ export async function render(el, params, query) {
|
|||||||
zone.view ? ["view ", viewLink(view, zone.view, "#/zones/" + id + (params.tab ? "/" + params.tab : "")), " · "] : null,
|
zone.view ? ["view ", viewLink(view, zone.view, "#/zones/" + id + (params.tab ? "/" + params.tab : "")), " · "] : null,
|
||||||
zone.type === "primary" ? "serial " + zone.soa.serial + " · " : null,
|
zone.type === "primary" ? "serial " + zone.soa.serial + " · " : null,
|
||||||
zone.dnssec_policy ? [h("a", { href: "#/zones/" + id + "/dnssec" }, badge(zone.dnssec_policy === "insecure" ? "DNSSEC: turning off" : "DNSSEC", zone.dnssec_policy === "insecure" ? "warn" : "ok")), " "] : null,
|
zone.dnssec_policy ? [h("a", { href: "#/zones/" + id + "/dnssec" }, badge(zone.dnssec_policy === "insecure" ? "DNSSEC: turning off" : "DNSSEC", zone.dnssec_policy === "insecure" ? "warn" : "ok")), " "] : null,
|
||||||
|
zone.links ? [h("span", { class: "badge accent" }, "linked with ",
|
||||||
|
zone.links.map((l, i) => [i ? ", " : "", h("a", { href: "#/zones/" + l.id + (params.tab ? "/" + params.tab : ""), title: "The zone in view " + l.view }, l.view)])), " "] : null,
|
||||||
zone.zone_file ? h("span", { class: "mono small" }, zone.zone_file) : null)),
|
zone.zone_file ? h("span", { class: "mono small" }, zone.zone_file) : null)),
|
||||||
h("div", { class: "actions" },
|
h("div", { class: "actions" },
|
||||||
zone.disabled
|
zone.disabled
|
||||||
? h("button", { onclick: () => toggleZone(zone) }, "Enable zone")
|
? h("button", { onclick: () => toggleZone(zone) }, "Enable zone")
|
||||||
: h("button", { disabled: !!zone.dnssec_policy, title: zone.dnssec_policy ? "A signed zone can't be disabled: turn signing off first" : "Stop serving the zone, keeping it in Jarl", onclick: () => toggleZone(zone) }, "Disable zone"),
|
: h("button", { disabled: !!zone.dnssec_policy, title: zone.dnssec_policy ? "A signed zone can't be disabled: turn signing off first" : "Stop serving the zone, keeping it in Jarl", onclick: () => toggleZone(zone) }, "Disable zone"),
|
||||||
" ",
|
" ",
|
||||||
|
zone.type === "primary" && zone.view ? [linkButton(zone), " "] : null,
|
||||||
h("button", { class: "danger", onclick: () => deleteZone(zone) }, "Delete zone")));
|
h("button", { class: "danger", onclick: () => deleteZone(zone) }, "Delete zone")));
|
||||||
const banner = zone.disabled ? h("div", { class: "msg warn" },
|
const banner = zone.disabled ? h("div", { class: "msg warn" },
|
||||||
h("strong", null, "This zone is disabled: "), "named doesn't load it. It is kept here, with its records, and can still be edited; enabling it serves it as it is then.") : null;
|
h("strong", null, "This zone is disabled: "), "named doesn't load it. It is kept here, with its records, and can still be edited; enabling it serves it as it is then.") : null;
|
||||||
@@ -61,6 +65,7 @@ export async function render(el, params, query) {
|
|||||||
else if (tab === "servers") await serversTab(body, zone);
|
else if (tab === "servers") await serversTab(body, zone);
|
||||||
else if (tab === "dnssec") await dnssecTab(body, zone);
|
else if (tab === "dnssec") await dnssecTab(body, zone);
|
||||||
else if (tab === "registrar") await registrarTab(body, zone);
|
else if (tab === "registrar") await registrarTab(body, zone);
|
||||||
|
else if (tab === "compare") await compareTab(body, zone);
|
||||||
// A later click (another tab) has taken over the page.
|
// A later click (another tab) has taken over the page.
|
||||||
if (seq !== renderSeq || !holder.isConnected) return;
|
if (seq !== renderSeq || !holder.isConnected) return;
|
||||||
holder.replaceWith(body);
|
holder.replaceWith(body);
|
||||||
@@ -68,6 +73,113 @@ export async function render(el, params, query) {
|
|||||||
if (first) first.focus();
|
if (first) first.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- comparing a zone across views
|
||||||
|
|
||||||
|
// compareTab shows the names and types that differ between the views that
|
||||||
|
// have the zone, with a button to share one view's version (linked zones).
|
||||||
|
async function compareTab(el, zone) {
|
||||||
|
let all = false;
|
||||||
|
const out = h("div");
|
||||||
|
const draw = async () => {
|
||||||
|
const r = await get("/zones/" + zone.id + "/compare", { all: all ? 1 : "" });
|
||||||
|
const head = h("tr", null, h("th", null, "Name"), h("th", null, "Type"), r.views.map((v) => h("th", null, v)));
|
||||||
|
const rows = r.rows.map((row) => h("tr", { class: row.same ? "muted" : "" },
|
||||||
|
h("td", { class: "mono" }, row.name), h("td", null, h("strong", null, row.type)),
|
||||||
|
row.views.map((vs) => h("td", { class: "compare-cell" },
|
||||||
|
vs.records.length ? vs.records.map((d) => h("div", { class: "mono small" }, d)) : h("span", { class: "muted" }, "–"),
|
||||||
|
vs.linked === "local" ? [badge("only " + vs.view, "accent"), " "] : null,
|
||||||
|
r.linked && !row.same && vs.records.length ? h("button", { class: "small", title: "Make view " + vs.view + "'s version every view's: the others' records of this name and type are replaced",
|
||||||
|
onclick: () => share(vs, row) }, "Use this version") : null))));
|
||||||
|
mount(out,
|
||||||
|
h("p", { class: "muted" }, r.same + " names and types are the same in " + r.views.join(", ") + (r.rows.length && !all ? "; these differ." : ".") + " ",
|
||||||
|
r.linked ? "The zones are linked: shared records are kept in step, and a view's own records override them there." : "The zones aren't linked: a change in one doesn't reach the others. Link them (Link… above) to keep what is the same in step; linking changes no answer."),
|
||||||
|
r.rows.length ? h("div", { class: "table-wrap" }, h("table", { class: "compare" }, h("thead", null, head), h("tbody", null, rows))) : h("div", { class: "empty" }, "Nothing differs."),
|
||||||
|
h("label", { class: "check" }, h("input", { type: "checkbox", checked: all, onchange: (e) => { all = e.target.checked; draw(); } }), "Show what is the same too"));
|
||||||
|
};
|
||||||
|
const share = async (vs, row) => {
|
||||||
|
if (!await confirmDialog("Use view " + vs.view + "'s " + row.name + " " + row.type, "Every view gets " + vs.records.join(", ") + " for " + row.name + " " + row.type + ", shared from now on; the other views' records of that name and type are replaced.", "Use this version")) return;
|
||||||
|
try {
|
||||||
|
toast(describeResult(await change("PATCH", "/records/" + vs.ids[0], { local: false })));
|
||||||
|
await draw();
|
||||||
|
} catch (e) { toast(e.message, "err", 8000); }
|
||||||
|
};
|
||||||
|
await draw();
|
||||||
|
mount(el, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- linked zones (docs/views.md#linked-zones)
|
||||||
|
|
||||||
|
// linkButton links the zone with the same zone in other views, or takes
|
||||||
|
// it out of its link.
|
||||||
|
function linkButton(zone) {
|
||||||
|
if (zone.linked_with) {
|
||||||
|
return h("button", { title: "Stop keeping this zone in step with " + zone.linked_with.join(", ") + "; its records stay, all local", onclick: async () => {
|
||||||
|
if (!await confirmDialog("Unlink zone", "Take " + zone.display_name + " in view " + zone.view + " out of its link with " + zone.linked_with.join(", ") + "? Its records stay as they are, all local to this view; changes no longer reach the other views.", "Unlink")) return;
|
||||||
|
try {
|
||||||
|
toast(describeResult(await change("POST", "/zones/" + zone.id + "/unlink")));
|
||||||
|
rerender();
|
||||||
|
} catch (e) { toast(e.message, "err", 8000); }
|
||||||
|
} }, "Unlink");
|
||||||
|
}
|
||||||
|
return h("button", { title: "Keep this zone's records in step with the same zone in other views", onclick: () => linkDialog(zone) }, "Link…");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function linkDialog(zone) {
|
||||||
|
const zones = await get("/zones");
|
||||||
|
const others = zones.filter((z) => z.name === zone.name && z.id !== zone.id && z.type === "primary" && z.view);
|
||||||
|
if (!others.length) {
|
||||||
|
await modal("Link zone", h("p", null, "No other view has a zone " + zone.display_name + ". Create it there linked: New zone, the same name, \"Copy records from\" this one with \"Keep them linked\" ticked."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const picked = new Set();
|
||||||
|
const err = h("div");
|
||||||
|
let linkBtn = null;
|
||||||
|
const rows = others.map((z) => {
|
||||||
|
const box = h("input", { type: "checkbox", "aria-label": "Link with view " + z.view });
|
||||||
|
box.addEventListener("change", () => {
|
||||||
|
if (box.checked) picked.add(z.view); else picked.delete(z.view);
|
||||||
|
tr.classList.toggle("selected", box.checked);
|
||||||
|
if (linkBtn) {
|
||||||
|
linkBtn.disabled = picked.size === 0;
|
||||||
|
// Zones already linked with a picked one join too: name them.
|
||||||
|
const all = new Set(picked);
|
||||||
|
for (const o of others) if (picked.has(o.view)) (o.linked_with || []).forEach((v) => all.add(v));
|
||||||
|
all.delete(zone.view);
|
||||||
|
linkBtn.textContent = picked.size ? "Link with " + [...all].join(", ") : "Link";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const tr = h("tr", { class: "pick", onclick: (e) => { if (e.target !== box) { box.checked = !box.checked; box.dispatchEvent(new Event("change")); } } },
|
||||||
|
h("td", null, box),
|
||||||
|
h("td", null, h("strong", null, z.view)),
|
||||||
|
h("td", { class: "muted" }, z.record_count + " records"),
|
||||||
|
h("td", { class: "small" }, z.linked_with ? "already linked with " + z.linked_with.join(", ") + " (joins too)" : ""));
|
||||||
|
return tr;
|
||||||
|
});
|
||||||
|
const body = h("div", null,
|
||||||
|
h("p", null, "Linked zones keep their shared records in step: a change in one view reaches the others in the same change. Each view can still have records of its own, which override the shared ones of that name and type there, and keeps its own options."),
|
||||||
|
h("p", { class: "muted small" }, "Linking changes no answer: records that are the same in every view become shared; anything that differs stays local to each view. Share one later from the view whose version is right."),
|
||||||
|
h("h3", null, "Link view " + zone.view + " with"),
|
||||||
|
h("div", { class: "table-wrap" }, h("table", { class: "pick-list" }, h("tbody", null, rows))),
|
||||||
|
err);
|
||||||
|
const open = modal("Link " + zone.display_name, body, [
|
||||||
|
{ label: "Cancel", value: null },
|
||||||
|
{ label: "Link", kind: "primary", onclick: async () => {
|
||||||
|
if (!picked.size) return false;
|
||||||
|
try {
|
||||||
|
const r = await change("POST", "/zones/" + zone.id + "/link", { with: [...picked] });
|
||||||
|
toast(describeResult(r));
|
||||||
|
(r.warnings || []).forEach((m) => toast(m, "warn", 12000));
|
||||||
|
rerender();
|
||||||
|
return true;
|
||||||
|
} catch (e) { mount(err, errorBox(e)); return false; }
|
||||||
|
} },
|
||||||
|
], { wide: true });
|
||||||
|
// Nothing is ticked at first: Link waits for a choice.
|
||||||
|
linkBtn = [...document.querySelectorAll(".modal .buttons button.primary")].pop();
|
||||||
|
if (linkBtn) linkBtn.disabled = true;
|
||||||
|
await open;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- name servers: serials on the other servers, and the delegation
|
// ---- name servers: serials on the other servers, and the delegation
|
||||||
|
|
||||||
const nsStatus = {
|
const nsStatus = {
|
||||||
@@ -321,6 +433,8 @@ async function recordsTab(el, zone, query) {
|
|||||||
const fData = input({ class: "mono", placeholder: typeInfo.A.example });
|
const fData = input({ class: "mono", placeholder: typeInfo.A.example });
|
||||||
const fComment = input({ placeholder: "optional", style: { width: "160px" } });
|
const fComment = input({ placeholder: "optional", style: { width: "160px" } });
|
||||||
const fPTR = checkbox("PTR", false, { title: "Also add the matching PTR record in the reverse zone" });
|
const fPTR = checkbox("PTR", false, { title: "Also add the matching PTR record in the reverse zone" });
|
||||||
|
const linked = !!zone.linked_with;
|
||||||
|
const fLocal = checkbox("Only here", false, { title: "Add it to view " + zone.view + " only, overriding the shared records of this name and type here (otherwise it reaches " + (zone.linked_with || []).join(", ") + " too)" });
|
||||||
const hint = h("div", { class: "hint" });
|
const hint = h("div", { class: "hint" });
|
||||||
const willAdd = h("div", { class: "will-add", "aria-live": "polite" });
|
const willAdd = h("div", { class: "will-add", "aria-live": "polite" });
|
||||||
const suggest = h("div", { class: "aml-suggest" });
|
const suggest = h("div", { class: "aml-suggest" });
|
||||||
@@ -426,6 +540,7 @@ async function recordsTab(el, zone, query) {
|
|||||||
if (fTTL.value !== "") body.ttl = Number(fTTL.value);
|
if (fTTL.value !== "") body.ttl = Number(fTTL.value);
|
||||||
if (fComment.value.trim()) body.comment = fComment.value.trim();
|
if (fComment.value.trim()) body.comment = fComment.value.trim();
|
||||||
if (fPTR.input.checked) body.ptr = true;
|
if (fPTR.input.checked) body.ptr = true;
|
||||||
|
if (linked && fLocal.input.checked) body.local = true;
|
||||||
try {
|
try {
|
||||||
const r = await change("POST", "/zones/" + zone.id + "/records", body);
|
const r = await change("POST", "/zones/" + zone.id + "/records", body);
|
||||||
toast(describeResult(r));
|
toast(describeResult(r));
|
||||||
@@ -452,6 +567,7 @@ async function recordsTab(el, zone, query) {
|
|||||||
h("div", { class: "f grow" }, h("label", null, "Data"), fData),
|
h("div", { class: "f grow" }, h("label", null, "Data"), fData),
|
||||||
h("div", { class: "f" }, h("label", null, "Comment"), fComment),
|
h("div", { class: "f" }, h("label", null, "Comment"), fComment),
|
||||||
h("div", { class: "f" }, h("label", null, " "), fPTR),
|
h("div", { class: "f" }, h("label", null, " "), fPTR),
|
||||||
|
linked ? h("div", { class: "f" }, h("label", null, " "), fLocal) : null,
|
||||||
h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add record")));
|
h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add record")));
|
||||||
|
|
||||||
// Filters and table
|
// Filters and table
|
||||||
@@ -479,8 +595,14 @@ async function recordsTab(el, zone, query) {
|
|||||||
{ label: "TTL", render: (r) => r.ttl !== undefined && r.ttl !== null ? String(r.ttl) : h("span", { class: "muted", title: "zone default" }, String(r.effective_ttl)), tdClass: "mono" },
|
{ label: "TTL", render: (r) => r.ttl !== undefined && r.ttl !== null ? String(r.ttl) : h("span", { class: "muted", title: "zone default" }, String(r.effective_ttl)), tdClass: "mono" },
|
||||||
{ label: "Type", render: (r) => h("strong", null, r.type) },
|
{ label: "Type", render: (r) => h("strong", null, r.type) },
|
||||||
{ label: "Data", render: (r) => r.data, tdClass: "data" },
|
{ label: "Data", render: (r) => r.data, tdClass: "data" },
|
||||||
|
linked ? { label: "Views", render: (r) => r.linked === "local"
|
||||||
|
? badge("only " + zone.view, "accent")
|
||||||
|
: h("span", { class: "muted small", title: "Shared: the same in " + [zone.view, ...zone.linked_with].join(", ") + " (unless a view overrides it)" }, "shared") } : null,
|
||||||
{ label: "Comment", render: (r) => [r.pending ? [pendingBadge(r.pending), " "] : null, h("span", { class: "muted" }, r.comment || "")] },
|
{ label: "Comment", render: (r) => [r.pending ? [pendingBadge(r.pending), " "] : null, h("span", { class: "muted" }, r.comment || "")] },
|
||||||
{ label: "", render: (r) => r.pending === "delete" ? "" : h("span", null,
|
{ label: "", render: (r) => r.pending === "delete" ? "" : h("span", null,
|
||||||
|
linked ? [r.linked === "local"
|
||||||
|
? h("button", { class: "small reveal", title: "Make this view's " + r.relative_name + " " + r.type + " the shared version: it replaces the other views' (" + zone.linked_with.join(", ") + ")", onclick: () => setLocal(r, false) }, "Share")
|
||||||
|
: h("button", { class: "small reveal", title: "Make " + r.relative_name + " " + r.type + " this view's own, so it can differ here; the other views keep the shared one", onclick: () => setLocal(r, true) }, "Only here"), " "] : null,
|
||||||
!reverse && (r.type === "A" || r.type === "AAAA") && !r.disabled
|
!reverse && (r.type === "A" || r.type === "AAAA") && !r.disabled
|
||||||
? [h("button", { class: "small reveal", title: "Add a CNAME record that points to " + r.name.replace(/\.$/, ""), onclick: () => aliasTo(r) }, "+ CNAME"), " "] : null,
|
? [h("button", { class: "small reveal", title: "Add a CNAME record that points to " + r.name.replace(/\.$/, ""), onclick: () => aliasTo(r) }, "+ CNAME"), " "] : null,
|
||||||
h("button", { class: "small", title: "Edit", onclick: () => edit(r) }, "Edit"), " ",
|
h("button", { class: "small", title: "Edit", onclick: () => edit(r) }, "Edit"), " ",
|
||||||
@@ -488,7 +610,7 @@ async function recordsTab(el, zone, query) {
|
|||||||
? h("button", { class: "small toggle", title: "Put the record back in the zone", onclick: () => toggle(r) }, "Enable")
|
? h("button", { class: "small toggle", title: "Put the record back in the zone", onclick: () => toggle(r) }, "Enable")
|
||||||
: h("button", { class: "small toggle", title: "Leave the record out of the zone, but keep it here", onclick: () => toggle(r) }, "Disable"), " ",
|
: h("button", { class: "small toggle", title: "Leave the record out of the zone, but keep it here", onclick: () => toggle(r) }, "Disable"), " ",
|
||||||
h("button", { class: "small danger", title: "Delete", onclick: () => del(r) }, "Delete")), tdClass: "actions-cell" },
|
h("button", { class: "small danger", title: "Delete", onclick: () => del(r) }, "Delete")), tdClass: "actions-cell" },
|
||||||
], rows, { empty: current.length ? "No records match." : "No records yet.", rowClass: (r, i) => [
|
].filter(Boolean), rows, { empty: current.length ? "No records match." : "No records yet.", rowClass: (r, i) => [
|
||||||
r.pending ? "pending-" + r.pending : "",
|
r.pending ? "pending-" + r.pending : "",
|
||||||
r.disabled ? "disabled" : "",
|
r.disabled ? "disabled" : "",
|
||||||
// A record followed by more of the same name: a faint line, so the
|
// A record followed by more of the same name: a faint line, so the
|
||||||
@@ -523,6 +645,14 @@ async function recordsTab(el, zone, query) {
|
|||||||
await redraw();
|
await redraw();
|
||||||
} catch (e) { toast(e.message, "err", 8000); }
|
} catch (e) { toast(e.message, "err", 8000); }
|
||||||
};
|
};
|
||||||
|
const setLocal = async (r, local) => {
|
||||||
|
if (!local && !(await confirmDialog("Share " + r.relative_name + " " + r.type, "View " + zone.view + "'s " + r.relative_name + " " + r.type + " records become the shared version, in " + zone.linked_with.join(", ") + " too: the records there of that name and type are replaced.", "Share"))) return;
|
||||||
|
try {
|
||||||
|
const res = await change("PATCH", "/records/" + r.id, { local });
|
||||||
|
toast(describeResult(res));
|
||||||
|
await redraw();
|
||||||
|
} catch (e) { toast(e.message, "err", 8000); }
|
||||||
|
};
|
||||||
const edit = (r) => { editing = r; draw(); };
|
const edit = (r) => { editing = r; draw(); };
|
||||||
const mountEdit = () => {
|
const mountEdit = () => {
|
||||||
const r = editing;
|
const r = editing;
|
||||||
@@ -531,7 +661,7 @@ async function recordsTab(el, zone, query) {
|
|||||||
const eTTL = input({ type: "number", value: r.ttl ?? "", placeholder: "default", style: { width: "90px" } });
|
const eTTL = input({ type: "number", value: r.ttl ?? "", placeholder: "default", style: { width: "90px" } });
|
||||||
const eData = input({ value: r.type === "TXT" && r.text !== undefined ? r.text : r.data, class: "mono", style: { width: "100%" } });
|
const eData = input({ value: r.type === "TXT" && r.text !== undefined ? r.text : r.data, class: "mono", style: { width: "100%" } });
|
||||||
const eComment = input({ value: r.comment || "", style: { width: "100%" } });
|
const eComment = input({ value: r.comment || "", style: { width: "100%" } });
|
||||||
const ePTR = checkbox("update PTR", false);
|
const ePTR = checkbox("update PTR", false, { title: "Keep the PTR record in the reverse zone in step: moved with a new name or address, or added if it is missing" });
|
||||||
const eErr = h("div");
|
const eErr = h("div");
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
const body = {};
|
const body = {};
|
||||||
@@ -544,7 +674,9 @@ async function recordsTab(el, zone, query) {
|
|||||||
else if (eTTL.value !== "" && Number(eTTL.value) !== r.ttl) body.ttl = Number(eTTL.value);
|
else if (eTTL.value !== "" && Number(eTTL.value) !== r.ttl) body.ttl = Number(eTTL.value);
|
||||||
if (eComment.value !== (r.comment || "")) body.comment = eComment.value;
|
if (eComment.value !== (r.comment || "")) body.comment = eComment.value;
|
||||||
if (ePTR.input.checked) body.ptr = true;
|
if (ePTR.input.checked) body.ptr = true;
|
||||||
if (Object.keys(body).length === 0 || (Object.keys(body).length === 1 && body.ptr)) { editing = null; draw(); return; }
|
// "update PTR" alone adds a missing PTR record (the reverse zone may
|
||||||
|
// be newer than the record).
|
||||||
|
if (Object.keys(body).length === 0) { editing = null; draw(); return; }
|
||||||
try {
|
try {
|
||||||
const res = await change("PATCH", "/records/" + r.id, body);
|
const res = await change("PATCH", "/records/" + r.id, body);
|
||||||
toast(describeResult(res));
|
toast(describeResult(res));
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge, viewLink, lastList } from "../ui.js";
|
import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge, viewLink, lastList, checkbox } from "../ui.js";
|
||||||
import { get, change, changeMany, describeResult, can, draftQuery } from "../api.js";
|
import { get, change, changeMany, describeResult, can, draftQuery } from "../api.js";
|
||||||
import { rerender } from "../app.js";
|
import { rerender } from "../app.js";
|
||||||
import { checkDomain, reverseZoneFor } from "../names.js";
|
import { checkDomain, reverseZoneFor } from "../names.js";
|
||||||
@@ -72,12 +72,17 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
|
|||||||
const file = input({ placeholder: "/usr/share/dns/root.hints", class: "mono" });
|
const file = input({ placeholder: "/usr/share/dns/root.hints", class: "mono" });
|
||||||
const inView = select(views.map((v) => [v.name, v.name]), "");
|
const inView = select(views.map((v) => [v.name, v.name]), "");
|
||||||
const cloneSel = select([["", "– don't copy –"]], "");
|
const cloneSel = select([["", "– don't copy –"]], "");
|
||||||
|
const linkBox = checkbox("Keep them linked: shared records stay in step, each view can add its own", true);
|
||||||
|
const cloneWrap = h("div", null, cloneSel, linkBox);
|
||||||
|
const syncLink = () => { linkBox.style.display = cloneSel.value ? "" : "none"; };
|
||||||
|
cloneSel.addEventListener("change", syncLink);
|
||||||
const comment = input();
|
const comment = input();
|
||||||
const updateClones = () => {
|
const updateClones = () => {
|
||||||
// Records can only be copied into a single zone.
|
// Records can only be copied into a single zone.
|
||||||
const n = (kind === "reverse" ? reverseZoneFor(name.value).name || "-" : name.value.trim().toLowerCase().replace(/\.$/, "")) + ".";
|
const n = (kind === "reverse" ? reverseZoneFor(name.value).name || "-" : name.value.trim().toLowerCase().replace(/\.$/, "")) + ".";
|
||||||
mount(cloneSel, h("option", { value: "" }, "– don't copy –"),
|
mount(cloneSel, h("option", { value: "" }, "– don't copy –"),
|
||||||
zones.filter((z) => z.type === "primary" && z.name === n).map((z) => h("option", { value: z.id }, z.display_name + " [" + (z.view || "no view") + "]")));
|
zones.filter((z) => z.type === "primary" && z.name === n).map((z) => h("option", { value: z.id }, z.display_name + " [" + (z.view || "no view") + "]")));
|
||||||
|
syncLink();
|
||||||
};
|
};
|
||||||
// check validates the name as typed and returns the zone name (or null);
|
// check validates the name as typed and returns the zone name (or null);
|
||||||
// a network that needs several reverse zones gives a list of names.
|
// a network that needs several reverse zones gives a list of names.
|
||||||
@@ -152,7 +157,7 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
|
|||||||
field("Name server addresses", nsAddr, "Optional, for name servers inside this zone: 'ns1 192.0.2.1' per line (glue)."),
|
field("Name server addresses", nsAddr, "Optional, for name servers inside this zone: 'ns1 192.0.2.1' per line (glue)."),
|
||||||
field("SOA contact", contact, "Email address of the zone's administrator."),
|
field("SOA contact", contact, "Email address of the zone's administrator."),
|
||||||
field("Default TTL", ttl, "Seconds, used by records without their own TTL."),
|
field("Default TTL", ttl, "Seconds, used by records without their own TTL."),
|
||||||
field("Copy records from", cloneSel, "For split-horizon setups: start with the records of this zone in another view.")],
|
field("Copy records from", cloneWrap, "For split-horizon setups: start with the records of this zone in another view. Linked, Jarl keeps the shared records the same in both (docs/views.md#linked-zones).")],
|
||||||
secondary: [field("Primary servers", primaries, "One per line: address [port N] [key NAME].")],
|
secondary: [field("Primary servers", primaries, "One per line: address [port N] [key NAME].")],
|
||||||
stub: [field("Primary servers", primaries, "One per line.")],
|
stub: [field("Primary servers", primaries, "One per line.")],
|
||||||
mirror: [field("Primary servers", primaries, "One per line (not needed for the root zone).")],
|
mirror: [field("Primary servers", primaries, "One per line (not needed for the root zone).")],
|
||||||
@@ -196,7 +201,12 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
|
|||||||
data.nameservers = lines(ns.value);
|
data.nameservers = lines(ns.value);
|
||||||
if (contact.value.trim()) data.soa = { rname: contact.value.trim() };
|
if (contact.value.trim()) data.soa = { rname: contact.value.trim() };
|
||||||
data.default_ttl = Number(ttl.value) || undefined;
|
data.default_ttl = Number(ttl.value) || undefined;
|
||||||
if (cloneSel.value) { data.clone_from = Number(cloneSel.value); delete data.nameservers; }
|
if (cloneSel.value) {
|
||||||
|
const src = zones.find((z) => z.id === Number(cloneSel.value));
|
||||||
|
if (linkBox.input.checked && src && src.view) data.link_with = src.view;
|
||||||
|
else data.clone_from = Number(cloneSel.value);
|
||||||
|
delete data.nameservers;
|
||||||
|
}
|
||||||
data.records = lines(nsAddr.value).map((l) => {
|
data.records = lines(nsAddr.value).map((l) => {
|
||||||
const [n, a] = l.split(/\s+/);
|
const [n, a] = l.split(/\s+/);
|
||||||
return { name: n, type: a && a.includes(":") ? "AAAA" : "A", data: a };
|
return { name: n, type: a && a.includes(":") ? "AAAA" : "A", data: a };
|
||||||
|
|||||||
@@ -513,3 +513,57 @@ td textarea.code { min-width: min(72ch, 50vw); }
|
|||||||
/* Certificates: the "check the names" result under the ACME fields. */
|
/* Certificates: the "check the names" result under the ACME fields. */
|
||||||
.check-names { margin: 6px 0 14px; }
|
.check-names { margin: 6px 0 14px; }
|
||||||
.check-names .table-wrap { margin-top: 8px; }
|
.check-names .table-wrap { margin-top: 8px; }
|
||||||
|
|
||||||
|
/* A list to pick rows from (linking zones): the whole row toggles. */
|
||||||
|
table.pick-list tr.pick { cursor: pointer; }
|
||||||
|
table.pick-list tr.pick td:first-child { width: 2em; }
|
||||||
|
table.pick-list tr.pick.selected { background: var(--accent-soft); }
|
||||||
|
|
||||||
|
/* Encrypted DNS: a protocol per line with its port. */
|
||||||
|
.enc-ports > div { margin: 2px 0; }
|
||||||
|
.enc-ports input[type=number] { display: inline-block; }
|
||||||
|
|
||||||
|
/* Statistics: views switched on and off. */
|
||||||
|
.filter-rows { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
|
||||||
|
.filter-rows .filter-row { margin-bottom: 0; }
|
||||||
|
.filter-row .chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.chip.toggle-chip { opacity: 0.55; font-size: 13px; padding: 3px 12px; }
|
||||||
|
.chip.toggle-chip.on { opacity: 1; text-decoration: none; background: var(--accent-soft); border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* Drop-down menus in the top bar (Server, the user). */
|
||||||
|
details.menu { position: relative; }
|
||||||
|
details.menu > summary { list-style: none; cursor: pointer; padding: 6px 10px; border-radius: var(--radius); color: var(--text); white-space: nowrap; }
|
||||||
|
details.menu > summary::-webkit-details-marker { display: none; }
|
||||||
|
details.menu > summary:hover, details.menu[open] > summary { background: var(--info-soft); }
|
||||||
|
details.menu > summary.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
||||||
|
details.menu > summary .caret { margin-left: 4px; font-size: 0.8em; opacity: 0.7; }
|
||||||
|
details.menu > summary .user { color: inherit; }
|
||||||
|
details.menu .menu-list {
|
||||||
|
position: absolute; top: calc(100% + 4px); left: 0; z-index: 20; min-width: 190px;
|
||||||
|
display: flex; flex-direction: column; padding: 4px;
|
||||||
|
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
details.menu.right .menu-list { left: auto; right: 0; }
|
||||||
|
details.menu .menu-list a, details.menu .menu-list .menu-item {
|
||||||
|
display: block; padding: 7px 10px; border-radius: var(--radius); color: var(--text); text-align: left;
|
||||||
|
background: none; border: 0; font: inherit; cursor: pointer; white-space: nowrap;
|
||||||
|
}
|
||||||
|
details.menu .menu-list a:hover, details.menu .menu-list .menu-item:hover { background: var(--info-soft); text-decoration: none; }
|
||||||
|
details.menu .menu-list a.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
||||||
|
nav.main { align-items: center; }
|
||||||
|
header.top .header-right { display: flex; align-items: center; gap: 12px; margin-left: auto; }
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
header.top form.search input { width: 160px; }
|
||||||
|
header.top { gap: 10px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Encrypted DNS: the health of each listener. */
|
||||||
|
ul.probes { list-style: none; padding: 0; margin: 0 0 8px; }
|
||||||
|
ul.probes > li { padding: 6px 0; border-bottom: 1px solid var(--border); }
|
||||||
|
ul.probes > li .msg { margin: 6px 0 0; }
|
||||||
|
|
||||||
|
/* Comparing a zone across views. */
|
||||||
|
table.compare td.compare-cell { vertical-align: top; }
|
||||||
|
table.compare td.compare-cell button { margin-top: 4px; }
|
||||||
|
table.compare tr.muted td { color: var(--muted); }
|
||||||
@@ -377,6 +377,15 @@ run "user delete" 0 $J user delete certbot -y
|
|||||||
run "audit" 0 $J audit --limit 10
|
run "audit" 0 $J audit --limit 10
|
||||||
contains "token.revoke"
|
contains "token.revoke"
|
||||||
|
|
||||||
|
# A reverse zone created after the record: "update PTR" alone adds it.
|
||||||
|
run "an address record" 0 $J record add adbraun.test ldn A 192.0.2.154
|
||||||
|
run "its /32 reverse zone, afterwards" 0 $J zone create 192.0.2.154/32
|
||||||
|
LDN=$($J record list adbraun.test --name ldn -o id)
|
||||||
|
run "record update --ptr adds the missing PTR" 0 $J record update $LDN --ptr
|
||||||
|
expect_dig "the PTR is served" "ldn.adbraun.test." -x 192.0.2.154
|
||||||
|
run "remove the reverse zone again" 0 $J zone delete 154.2.0.192.in-addr.arpa -y
|
||||||
|
run "and the record" 0 $J record delete adbraun.test ldn A -y
|
||||||
|
|
||||||
# Views
|
# Views
|
||||||
run "view create (adopts zones)" 0 $J view create internal --match-clients homelab --adopt-zones
|
run "view create (adopts zones)" 0 $J view create internal --match-clients homelab --adopt-zones
|
||||||
run "view create external" 0 $J view create external --match-clients any
|
run "view create external" 0 $J view create external --match-clients any
|
||||||
@@ -388,6 +397,49 @@ expect_dig "unsigned from here: the internal view" "127.0.0.1" ns1.adbraun.test
|
|||||||
expect_dig "signed with the external view's key" "203.0.113.1" -k $E/external.key ns1.adbraun.test A
|
expect_dig "signed with the external view's key" "203.0.113.1" -k $E/external.key ns1.adbraun.test A
|
||||||
run "view key shows a dig command" 0 $J view key external
|
run "view key shows a dig command" 0 $J view key external
|
||||||
contains "dig -y hmac-sha256:jarl-view-external:"
|
contains "dig -y hmac-sha256:jarl-view-external:"
|
||||||
|
# Linked zones (docs/views.md#linked-zones): the same zone in both views,
|
||||||
|
# shared records kept in step, local ones per view.
|
||||||
|
run "zone link: what is the same is shared" 0 $J zone link adbraun.test --view internal --with external
|
||||||
|
contains "differ between the views and stay local"
|
||||||
|
run "zone show names the link" 0 $J zone show adbraun.test --view external
|
||||||
|
contains "Linked with: *internal"
|
||||||
|
run "record list shows shared and local" 0 $J record list adbraun.test --view external
|
||||||
|
contains "ns1 *[0-9]*\\* *A *local *203.0.113.1"
|
||||||
|
contains "@ *[0-9]*\\* *NS *shared"
|
||||||
|
run "a shared add in internal" 0 $J record add adbraun.test linked A 198.51.100.7 --view internal
|
||||||
|
expect_dig "reaches the external view" "198.51.100.7" -k $E/external.key linked.adbraun.test A
|
||||||
|
expect_dig "and is in the internal view" "198.51.100.7" linked.adbraun.test A
|
||||||
|
run "a local override in internal" 0 $J record add adbraun.test linked A 10.1.1.1 --view internal --local
|
||||||
|
expect_dig "internal answers its own" "10.1.1.1" linked.adbraun.test A
|
||||||
|
expect_dig "external keeps the shared one" "198.51.100.7" -k $E/external.key linked.adbraun.test A
|
||||||
|
LID=$($J record list adbraun.test --view internal --name linked -o id)
|
||||||
|
run "share internal's version" 0 $J record update $LID --shared
|
||||||
|
expect_dig "now every view's" "10.1.1.1" -k $E/external.key linked.adbraun.test A
|
||||||
|
run "--view all changes a link once" 0 $J record set adbraun.test linked A 10.1.1.2 --view all
|
||||||
|
contains "linked with internal"
|
||||||
|
expect_dig "both views" "10.1.1.2" -k $E/external.key linked.adbraun.test A
|
||||||
|
run "zone diff: what differs between the views" 0 $J zone diff adbraun.test
|
||||||
|
contains "ns1 *A *127.0.0.1 \\[local, id [0-9]*\\] *203.0.113.1 \\[local, id [0-9]*\\]"
|
||||||
|
contains "names and types differ, [0-9]* are the same; linked"
|
||||||
|
run "the compare API" 0 curl -sf -H "Authorization: Bearer $(cat $E/admin.token)" "http://127.0.0.1:$HTTP/api/v1/zones/adbraun.test/compare?view=internal&all=1"
|
||||||
|
contains '"linked":true'
|
||||||
|
run "a shared delete" 0 $J record delete adbraun.test linked A --view external -y
|
||||||
|
expect_dig "gone from internal too" "" linked.adbraun.test A
|
||||||
|
run "zone unlink" 0 $J zone unlink adbraun.test --view external
|
||||||
|
run "unlinked: no link column" 0 $J record list adbraun.test --view external
|
||||||
|
not_contains "shared"
|
||||||
|
run "--local on a zone that isn't linked is fine" 0 $J record add adbraun.test solo A 10.9.9.9 --view external --local
|
||||||
|
run "--shared there is refused (exit 6)" 6 $J record add adbraun.test solo2 A 10.9.9.8 --view external --shared
|
||||||
|
contains "nothing to share"
|
||||||
|
run "remove it" 0 $J record delete adbraun.test solo A --view external -y
|
||||||
|
run "zone create --link-with: a linked copy" 0 $J zone create linkcopy.test --view internal --ns ns1.adbraun.test.
|
||||||
|
run "a record in it" 0 $J record add linkcopy.test www A 192.0.2.80 --view internal
|
||||||
|
run "copy it into external, linked" 0 $J zone create linkcopy.test --view external --link-with internal
|
||||||
|
expect_dig "the copy answers" "192.0.2.80" -k $E/external.key www.linkcopy.test A
|
||||||
|
run "delete the copy" 0 $J zone delete linkcopy.test --view external -y
|
||||||
|
run "the other is unlinked again" 0 $J zone show linkcopy.test --view internal
|
||||||
|
not_contains "Linked with"
|
||||||
|
run "delete the other" 0 $J zone delete linkcopy.test --view internal -y
|
||||||
# Internet exposure (docs/exposure.md): external lets in anyone, internal
|
# Internet exposure (docs/exposure.md): external lets in anyone, internal
|
||||||
# only homelab.
|
# only homelab.
|
||||||
run "exposure" 0 $J exposure
|
run "exposure" 0 $J exposure
|
||||||
@@ -401,6 +453,13 @@ contains "rate-limit { responses-per-second 10; log-only yes; };"
|
|||||||
run "exposure: log-only now" 0 $J exposure
|
run "exposure: log-only now" 0 $J exposure
|
||||||
contains "^external *public *log-only"
|
contains "^external *public *log-only"
|
||||||
contains "exposure_rate_limit_log_only"
|
contains "exposure_rate_limit_log_only"
|
||||||
|
# Statistics are off here; render/stats_test.go covers their statements.
|
||||||
|
run "what Jarl adds by itself" 0 $J option generated
|
||||||
|
contains "^include .*keys.conf"
|
||||||
|
contains 'match-clients { !key "jarl-view-external"; key "jarl-view-internal"; ... };'
|
||||||
|
not_contains "statistics-channels"
|
||||||
|
run "generated, as JSON" 0 $J option generated -o json
|
||||||
|
contains '"setting": "always, when there are views"'
|
||||||
run "fix: start enforcing" 0 $J exposure fix external exposure_rate_limit_log_only
|
run "fix: start enforcing" 0 $J exposure fix external exposure_rate_limit_log_only
|
||||||
contains "responses-per-second 10; }"
|
contains "responses-per-second 10; }"
|
||||||
# A burst of identical queries in the external view (signed with its key):
|
# A burst of identical queries in the external view (signed with its key):
|
||||||
@@ -730,6 +789,37 @@ if [ -n "$PEBBLE" ]; then
|
|||||||
run "a second certificate for the web UI is refused (exit 6)" 6 $J cert create other --domain nas.adbraun.test --use web
|
run "a second certificate for the web UI is refused (exit 6)" 6 $J cert create other --domain nas.adbraun.test --use web
|
||||||
run "certificate metrics" 0 curl -sf http://127.0.0.1:$HTTP/metrics
|
run "certificate metrics" 0 curl -sf http://127.0.0.1:$HTTP/metrics
|
||||||
contains '^jarl_certificate_failing{certificate="ns1",source="acme"} 0$'
|
contains '^jarl_certificate_failing{certificate="ns1",source="acme"} 0$'
|
||||||
|
# Encrypted DNS set up by Jarl (docs/encrypted-dns.md), next to the
|
||||||
|
# hand-written listener above.
|
||||||
|
EDOT=${EDOT_PORT:-25855}; EDOH=${EDOH_PORT:-25856}
|
||||||
|
run "encrypted-dns: off" 0 $J encrypted-dns
|
||||||
|
contains "Encrypted DNS: *off"
|
||||||
|
run "encrypted-dns check: the ports are free" 0 $J encrypted-dns check --address 127.0.0.1 --doh-port $EDOH
|
||||||
|
contains "The ports are free"
|
||||||
|
run "encrypted-dns check: jarld's port is taken" 0 $J encrypted-dns check --address 127.0.0.1 --doh-port $HTTP
|
||||||
|
contains "jarld (Jarl's web UI and API) listens there"
|
||||||
|
run "encrypted-dns setup" 0 $J encrypted-dns setup --hostname ns1.adbraun.test --address 127.0.0.1 --dot-port $EDOT --doh-port $EDOH
|
||||||
|
run "status: on, with what to give clients" 0 $J encrypted-dns
|
||||||
|
contains "DNS over TLS: *127.0.0.1 port $EDOT"
|
||||||
|
contains "For DoH clients: *https://ns1.adbraun.test:$EDOH/dns-query"
|
||||||
|
run "DoT through the setup" 0 $DIG +tls +tls-ca=$E/pebble-root.pem +tls-hostname=ns1.adbraun.test +short -p $EDOT @127.0.0.1 nas.adbraun.test A
|
||||||
|
contains "^192.168.1.20$"
|
||||||
|
run "DoH through the setup" 0 $DIG +https +tls-ca=$E/pebble-root.pem +tls-hostname=ns1.adbraun.test +short -p $EDOH @127.0.0.1 nas.adbraun.test A
|
||||||
|
contains "^192.168.1.20$"
|
||||||
|
run "plain DNS still answers on port 53's listener" 0 $DIG +short -p $DNS @127.0.0.1 nas.adbraun.test A
|
||||||
|
contains "^192.168.1.20$"
|
||||||
|
run "encrypted-dns health: both listeners work" 0 $J encrypted-dns health
|
||||||
|
contains "DoT 127.0.0.1 port $EDOT: *working (NOERROR"
|
||||||
|
contains "DoH 127.0.0.1 port $EDOH: *working"
|
||||||
|
contains "isn't from a CA this server trusts"
|
||||||
|
sleep 16 # /metrics caches Jarl's state for 15 seconds
|
||||||
|
run "health metric" 0 curl -sf http://127.0.0.1:$HTTP/metrics
|
||||||
|
contains "^jarl_encrypted_dns_up{kind=\"dot\",address=\"127.0.0.1\",port=\"$EDOT\"} 1$"
|
||||||
|
run "a certificate named uses can't be deleted (exit 6)" 6 $J cert delete ns1 --yes
|
||||||
|
contains "take it out first"
|
||||||
|
run "encrypted-dns disable" 0 $J encrypted-dns disable
|
||||||
|
run "encrypted-dns: off again" 0 $J encrypted-dns
|
||||||
|
contains "Encrypted DNS: *off"
|
||||||
dot_id=$($J option list -o field=id,name,value | awk -F'\t' '$2 == "listen-on" && /tls e2e/ {print $1}')
|
dot_id=$($J option list -o field=id,name,value | awk -F'\t' '$2 == "listen-on" && /tls e2e/ {print $1}')
|
||||||
run "named: remove the DoT listener" 0 $J option unset --id "$dot_id"
|
run "named: remove the DoT listener" 0 $J option unset --id "$dot_id"
|
||||||
run "named: remove the tls block" 0 $J option unset tls --toplevel
|
run "named: remove the tls block" 0 $J option unset tls --toplevel
|
||||||
|
|||||||
Reference in new issue
Block a user