Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48f0082d8b | ||
|
|
d4399cf630 | ||
|
|
d2a25d654d | ||
|
|
87368c4aaf | ||
|
|
7d93c373d1 | ||
|
|
e94d3634b6 | ||
|
|
a8c7760fc8 | ||
|
|
5443e95897 | ||
|
|
ea26171875 | ||
|
|
250e734046 | ||
|
|
1b102a78f6 | ||
|
|
01ec37b9b2 | ||
|
|
fef916b7ab | ||
|
|
bfab83da6e | ||
|
|
304c89dba4 | ||
|
|
b1719ce362 | ||
|
|
ec16de875d | ||
|
|
b24c730b97 | ||
|
|
aa4b004a14 | ||
|
|
a3b054cc86 | ||
|
|
c27a6c10bd | ||
|
|
d33f573782 | ||
|
|
13ffd8cad4 |
No files matched your search
@@ -3,3 +3,5 @@
|
|||||||
*.db
|
*.db
|
||||||
*.db-wal
|
*.db-wal
|
||||||
*.db-shm
|
*.db-shm
|
||||||
|
/jarl
|
||||||
|
/jarld
|
||||||
+164
@@ -6,6 +6,170 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.6.0] - 2026-10-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Reverse zones made easy (docs/reverse.md):
|
||||||
|
- **Missing reverse zones**: the Zones page and `jarl zone
|
||||||
|
suggest-reverse` list the reverse zones the private views lack for
|
||||||
|
their A and AAAA records' addresses: the /24 of a private IPv4 network,
|
||||||
|
a /32 for a public address (the rest of its /24 belongs to others),
|
||||||
|
the /48 of IPv6 addresses. Create one, or all (`--create`), filled.
|
||||||
|
- **Fill**: `jarl zone create 10.0.10.0/24 --fill` (ticked in the New
|
||||||
|
zone dialog) adds a PTR record for each address the view's records use
|
||||||
|
there; `jarl zone fill-ptr` and the reverse zone's page add the ones
|
||||||
|
missing later. An address with several names gets its mail or name
|
||||||
|
server's name, else a host's.
|
||||||
|
- **The right view**: a reverse zone created without a view goes to the
|
||||||
|
private view whose records use its addresses.
|
||||||
|
- **PTR records kept in step** per zone (`jarl zone update ZONE
|
||||||
|
--auto-ptr`, zone settings → Reverse DNS): adding, changing,
|
||||||
|
disabling or deleting an A or AAAA record does the same to its PTR
|
||||||
|
record, as the Update PTR box does for one change.
|
||||||
|
- Suggestions come in two groups: **yours** (private networks, a ULA or
|
||||||
|
an IPv6 /48 this server has an address in), which Create all
|
||||||
|
creates, and **public addresses**, each a /32 or /128 to create only
|
||||||
|
if it is yours (not a web host's) or **Dismiss**. A view can turn
|
||||||
|
suggestions off (`jarl view update vps --reverse-suggestions=false`),
|
||||||
|
e.g. one only a VPS uses.
|
||||||
|
- A zone created inside another in the same view (a /24 inside a /8,
|
||||||
|
`lab.adbraun.lol` inside `adbraun.lol`) takes the parent's records
|
||||||
|
under it, which named would no longer serve from the parent; a
|
||||||
|
delegation and its glue stay.
|
||||||
|
- Zone commands take a network for a reverse zone: `jarl zone show
|
||||||
|
10.0.10.0/24`.
|
||||||
|
- **New reverse zone** (Zones page) lists the networks your records use
|
||||||
|
without a reverse zone, in every view, also where nothing is suggested
|
||||||
|
(suggestions off, dismissed, public views); pick one, change its size
|
||||||
|
(/48, /56, /64, /128 for IPv6, around the records' address) and create
|
||||||
|
it filled. With a view chosen, it also lists the addresses only other
|
||||||
|
views' records use, and fill names them from there (a VPS's AAAA in
|
||||||
|
external, its PTR in internal). `jarl zone suggest-reverse --all
|
||||||
|
[--view V]`. Both lists have a search, so hundreds of addresses stay
|
||||||
|
manageable: the public addresses fold away, show 25 at a time, and
|
||||||
|
**Dismiss these N** dismisses everything a search found in one change
|
||||||
|
(`--dismiss` takes several networks).
|
||||||
|
- Encrypted DNS: **several host names** (one certificate for all; the
|
||||||
|
health check says when the served certificate misses one), and **each
|
||||||
|
listen address gets its own views** for the host names' records
|
||||||
|
(`--address 10.0.10.5=internal`, a view checkbox per address in the
|
||||||
|
form), local to the view, with a warning when a public view would get
|
||||||
|
a private address. A view no longer ticked loses the address. The form
|
||||||
|
starts from where the records are now. The Certificates page and `jarl
|
||||||
|
cert show` say what named uses a certificate for (DNS over TLS or HTTPS,
|
||||||
|
on which addresses and ports).
|
||||||
|
- docs/encrypted-dns.md: forwarding to Quad9 or Cloudflare over TLS, how to
|
||||||
|
check it works, and why not over HTTPS.
|
||||||
|
- Comparing one name across views: `jarl zone diff adbraun.lol nas [A]`,
|
||||||
|
and a **Compare** button on each record.
|
||||||
|
|
||||||
|
- **Exploring the statistics**: jarld keeps every answer for a while
|
||||||
|
(`stats.retention.queries`, default 7 days), and every client, name,
|
||||||
|
view, record type, result and blocklist on the Statistics page leads to
|
||||||
|
an explore page for it: answers over time, the top clients, names,
|
||||||
|
views, types, results and blocklists among them (each a link that
|
||||||
|
narrows it further), and the latest answers. "NXDOMAIN in external" →
|
||||||
|
the clients asking → everything one of them asks. A filter bar types
|
||||||
|
filters in, several values each (any of them), `!value` to leave one
|
||||||
|
out and `*.example.com` for a domain and below; **hide** on a list entry
|
||||||
|
leaves it out. **Explore the answers** opens the page from the
|
||||||
|
Statistics page. `GET /stats/explore`.
|
||||||
|
- **Storage** on the dashboard: the size of Jarl's databases (and how many
|
||||||
|
answers the query log holds), and per disk Jarl uses a bar of how full it
|
||||||
|
is and how much of it the databases take. The Statistics page shows the
|
||||||
|
statistics database's size too, and its disk tiles get the same bar.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `jarl option set tls NAME …` (and `key`, `remote-servers`, `http`,
|
||||||
|
`dnssec-policy`, `server` …) replaces only the statement with that name:
|
||||||
|
setting `tls cloudflare` used to replace `tls quad9`. `jarl option unset
|
||||||
|
tls cloudflare` removes one by its name.
|
||||||
|
- **Compare views** and `jarl zone diff` are rebuilt around the view
|
||||||
|
you're on (docs/views.md#comparing-views): a summary per view, then
|
||||||
|
what is answered differently (a view's own records in linked zones,
|
||||||
|
drift otherwise) with **Use this everywhere**, the names only some
|
||||||
|
views have, grouped, with **Add to VIEW**, and how many are the same.
|
||||||
|
One line per record: the CLI cuts long TXT records to the terminal
|
||||||
|
(`--wide` shows them whole). `--with` picks the views to compare with.
|
||||||
|
`GET /zones/{zone}/compare` returns these sections instead of `rows`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Encrypted DNS: the client hints tested with kdig (Knot's, often not
|
||||||
|
installed) and with curl's JSON API, which named's DoH doesn't speak;
|
||||||
|
they use BIND's `dig +tls` / `dig +https` now.
|
||||||
|
- Server options → Added by Jarl: long statements didn't wrap, and the
|
||||||
|
browser console reported a Content Security Policy violation (an inline
|
||||||
|
style the page's policy blocks).
|
||||||
|
|
||||||
|
### Upgrading from 0.5
|
||||||
|
|
||||||
|
- Tested: 0.5.2 packages with views, a linked zone, a reverse zone,
|
||||||
|
blocking, a `tls` statement and statistics, upgraded in place in Debian
|
||||||
|
13 (BIND 9.20.29): everything kept and answered as before, the new
|
||||||
|
features worked on the upgraded data, and a 0.5.2 backup restored.
|
||||||
|
- The database gets two new versions (12 and 13), applied when jarld
|
||||||
|
starts; a 0.5 jarld can't open it afterwards, so take a backup first (`sudo
|
||||||
|
jarld backup`). Nothing changes until you turn `auto_ptr` on for a
|
||||||
|
zone.
|
||||||
|
- The statistics database gets a new version too (the query log); 0.5
|
||||||
|
can't open it afterwards either. It starts empty and fills from then on.
|
||||||
|
|
||||||
|
## [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
|
## [0.5.0] - 2026-10-12
|
||||||
|
|
||||||
### Upgrading from 0.4
|
### Upgrading from 0.4
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ error: www.adbraun.lol has a CNAME, so it can't have any other records (found A)
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
* **Everything in one place**: primary, secondary, forward, stub, mirror,
|
* **Everything in one place**: primary, secondary, forward, stub, mirror,
|
||||||
hint, redirect and in-view zones, reverse zones from a CIDR, split DNS with
|
hint, redirect and in-view zones, reverse zones from a CIDR (suggested,
|
||||||
|
filled and kept in step from your A records), split DNS with
|
||||||
views, ACLs, TSIG keys, and every BIND 9.20 option at server, view, zone
|
views, ACLs, TSIG keys, and every BIND 9.20 option at server, view, zone
|
||||||
and top level, with the ARM's description and grammar built in.
|
and top level, with the ARM's description and grammar built in.
|
||||||
* **Safe changes.** Every change is validated as a whole (CNAME rules,
|
* **Safe changes.** Every change is validated as a whole (CNAME rules,
|
||||||
@@ -147,6 +148,7 @@ in `/etc/jarl/jarl.yaml` to reach it from your management network.
|
|||||||
| [docs/encrypted-dns.md](docs/encrypted-dns.md) | DNS over TLS and HTTPS for your clients: setup, ports, client settings |
|
| [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/reverse.md](docs/reverse.md) | Reverse zones: suggested from your records, created in the right view and filled, PTR records kept in step |
|
||||||
| [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 |
|
||||||
| [docs/metrics.md](docs/metrics.md) | Prometheus metrics: access, what is exported, example alerts |
|
| [docs/metrics.md](docs/metrics.md) | Prometheus metrics: access, what is exported, example alerts |
|
||||||
| [docs/blocking.md](docs/blocking.md) | Blocking ads, trackers and malware: blocklists, your own rules, views, memory |
|
| [docs/blocking.md](docs/blocking.md) | Blocking ads, trackers and malware: blocklists, your own rules, views, memory |
|
||||||
@@ -200,7 +202,11 @@ review step.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Version 0.5.0 is the current release. It adds Prometheus metrics,
|
Version 0.6.0 is the current release. It makes reverse zones easy (suggested from your records,
|
||||||
|
created in the right view and filled, PTR records kept in step), rebuilds
|
||||||
|
comparing a zone's views, lets you explore the statistics down to each
|
||||||
|
client and name (a query log), shows the databases' size, and gives
|
||||||
|
encrypted DNS several host names and views per listen address. 0.5 added Prometheus metrics,
|
||||||
address ranges in ACLs and other address match lists, safer changeset
|
address ranges in ACLs and other address match lists, safer changeset
|
||||||
handling, internet exposure checks with response rate limiting, TLS
|
handling, internet exposure checks with response rate limiting, TLS
|
||||||
certificates Jarl obtains and renews itself, linked zones for split DNS,
|
certificates Jarl obtains and renews itself, linked zones for split DNS,
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ type certInfo struct {
|
|||||||
NextAttempt *time.Time `json:"next_attempt,omitempty"`
|
NextAttempt *time.Time `json:"next_attempt,omitempty"`
|
||||||
IssuingSince *time.Time `json:"issuing_since,omitempty"`
|
IssuingSince *time.Time `json:"issuing_since,omitempty"`
|
||||||
Serving bool `json:"serving,omitempty"`
|
Serving bool `json:"serving,omitempty"`
|
||||||
|
NamedUse []string `json:"named_use,omitempty"`
|
||||||
Files *struct {
|
Files *struct {
|
||||||
Chain string `json:"chain"`
|
Chain string `json:"chain"`
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
@@ -173,6 +174,9 @@ func printCert(w io.Writer, ci certInfo) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, "Uses:\t%s\n", dash(strings.Join(uses, ", ")))
|
fmt.Fprintf(w, "Uses:\t%s\n", dash(strings.Join(uses, ", ")))
|
||||||
|
for _, u := range ci.NamedUse {
|
||||||
|
fmt.Fprintf(w, "named:\t%s\n", u)
|
||||||
|
}
|
||||||
if ci.Comment != "" {
|
if ci.Comment != "" {
|
||||||
fmt.Fprintf(w, "Comment:\t%s\n", ci.Comment)
|
fmt.Fprintf(w, "Comment:\t%s\n", ci.Comment)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPrintCompare(t *testing.T) {
|
||||||
|
long := `"v=spf1 ` + strings.Repeat("include:_spf.example.net ", 10) + `-all"`
|
||||||
|
r := &compareReport{Zone: "adbraun.lol", Base: "internal", Views: []string{"internal", "external"}, Linked: true}
|
||||||
|
r.Summary = append(r.Summary, struct {
|
||||||
|
View string `json:"view"`
|
||||||
|
Different int `json:"different"`
|
||||||
|
OnlyBase int `json:"only_base"`
|
||||||
|
OnlyOther int `json:"only_other"`
|
||||||
|
Same int `json:"same"`
|
||||||
|
}{View: "external", Different: 1, OnlyBase: 1, Same: 5})
|
||||||
|
r.Different = []compareRow{{Name: "@", Type: "TXT", Views: []compareSet{
|
||||||
|
{View: "internal", Records: []string{long, `"short"`}, IDs: []int64{7, 8}, Linked: "local"},
|
||||||
|
{View: "external", Records: []string{`"other"`}, IDs: []int64{9}, Linked: "local"},
|
||||||
|
}}}
|
||||||
|
r.Only = append(r.Only, struct {
|
||||||
|
Views []string `json:"views"`
|
||||||
|
Rows []compareRow `json:"rows"`
|
||||||
|
}{Views: []string{"internal"}, Rows: []compareRow{{Name: "nas", Type: "A", Views: []compareSet{{View: "internal", Records: []string{"10.0.10.20"}}, {View: "external"}}}}})
|
||||||
|
r.Same = 5
|
||||||
|
|
||||||
|
var b bytes.Buffer
|
||||||
|
printCompare(&b, r, false, 60)
|
||||||
|
out := b.String()
|
||||||
|
// Record lines fit (the heading and summary are prose, and may wrap).
|
||||||
|
for _, l := range strings.Split(strings.TrimRight(out, "\n"), "\n")[3:] {
|
||||||
|
if n := utf8.RuneCountInString(l); n > 60 {
|
||||||
|
t.Errorf("line of %d characters: %q", n, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"adbraun.lol in internal, compared with external\n linked: ",
|
||||||
|
" external: 1 answered differently, 1 only in internal, 0 only in external, 5 the same",
|
||||||
|
"ANSWERED DIFFERENTLY (1)\n @ TXT\n internal \"v=spf1 include:",
|
||||||
|
"…\n \"short\"\n external \"other\" [local, id 9]",
|
||||||
|
"ONLY IN INTERNAL (1)\n nas A 10.0.10.20",
|
||||||
|
"5 names and types are the same (--all lists them)",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("output lacks %q:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Width 0: whole.
|
||||||
|
b.Reset()
|
||||||
|
printCompare(&b, r, false, 0)
|
||||||
|
if !strings.Contains(b.String(), long+" [local, id 7]") {
|
||||||
|
t.Errorf("whole: %s", b.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
+85
-7
@@ -37,7 +37,7 @@ func viewCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
var clients, dests []string
|
var clients, dests []string
|
||||||
var recursiveOnly, comment, exposure string
|
var recursiveOnly, comment, exposure string
|
||||||
var adopt, cascade, yes bool
|
var adopt, cascade, yes, reverseSuggest bool
|
||||||
viewBody := func(cmd *cobra.Command) map[string]any {
|
viewBody := func(cmd *cobra.Command) map[string]any {
|
||||||
body := map[string]any{}
|
body := map[string]any{}
|
||||||
f := cmd.Flags()
|
f := cmd.Flags()
|
||||||
@@ -63,6 +63,9 @@ func viewCmd() *cobra.Command {
|
|||||||
if f.Changed("exposure") {
|
if f.Changed("exposure") {
|
||||||
body["exposure"] = exposure
|
body["exposure"] = exposure
|
||||||
}
|
}
|
||||||
|
if f.Changed("reverse-suggestions") {
|
||||||
|
body["reverse_suggestions"] = reverseSuggest
|
||||||
|
}
|
||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
addViewFlags := func(c *cobra.Command) {
|
addViewFlags := func(c *cobra.Command) {
|
||||||
@@ -71,6 +74,7 @@ func viewCmd() *cobra.Command {
|
|||||||
c.Flags().StringVar(&recursiveOnly, "recursive-only", "", "match-recursive-only: yes, no or default")
|
c.Flags().StringVar(&recursiveOnly, "recursive-only", "", "match-recursive-only: yes, no or default")
|
||||||
c.Flags().StringVar(&comment, "comment", "", "comment")
|
c.Flags().StringVar(&comment, "comment", "", "comment")
|
||||||
c.Flags().StringVar(&exposure, "exposure", "", "reachable from the internet: auto (worked out), public or private (docs/exposure.md)")
|
c.Flags().StringVar(&exposure, "exposure", "", "reachable from the internet: auto (worked out), public or private (docs/exposure.md)")
|
||||||
|
c.Flags().BoolVar(&reverseSuggest, "reverse-suggestions", true, "suggest reverse zones for the view's addresses (--reverse-suggestions=false: don't; docs/reverse.md)")
|
||||||
}
|
}
|
||||||
create := &cobra.Command{
|
create := &cobra.Command{
|
||||||
Use: "create NAME",
|
Use: "create NAME",
|
||||||
@@ -582,6 +586,12 @@ func optionCmd() *cobra.Command {
|
|||||||
Short: "Set an option (replaces an existing option of the same name)",
|
Short: "Set an option (replaces an existing option of the same name)",
|
||||||
Long: `Set an option; VALUE is named.conf syntax without the trailing semicolon.
|
Long: `Set an option; VALUE is named.conf syntax without the trailing semicolon.
|
||||||
|
|
||||||
|
It replaces the option of that name. Statements told apart by the name
|
||||||
|
after the keyword (tls NAME, key NAME, remote-servers NAME, http NAME,
|
||||||
|
dnssec-policy NAME, server ADDRESS, ...) replace only the one with that
|
||||||
|
name: setting tls cloudflare leaves tls quad9 alone. --add adds another
|
||||||
|
instance of any other option that may occur several times.
|
||||||
|
|
||||||
jarl option set recursion no
|
jarl option set recursion no
|
||||||
jarl option set allow-recursion '{ homelab; localhost; }'
|
jarl option set allow-recursion '{ homelab; localhost; }'
|
||||||
jarl option set forwarders '{ 1.1.1.1; 9.9.9.9; }'
|
jarl option set forwarders '{ 1.1.1.1; 9.9.9.9; }'
|
||||||
@@ -606,7 +616,11 @@ func optionCmd() *cobra.Command {
|
|||||||
if cmd.Flags().Changed("comment") {
|
if cmd.Flags().Changed("comment") {
|
||||||
body["comment"] = comment
|
body["comment"] = comment
|
||||||
}
|
}
|
||||||
|
label := optionLabel(strings.ToLower(args[0]), value)
|
||||||
for _, o := range opts {
|
for _, o := range opts {
|
||||||
|
if label != "" && optionLabel(o.Name, o.Value) != label {
|
||||||
|
continue // another tls (key, server, ...) statement
|
||||||
|
}
|
||||||
if o.Name == strings.ToLower(args[0]) && !multiple {
|
if o.Name == strings.ToLower(args[0]) && !multiple {
|
||||||
resp, err := c.Mutate(ctx(), "PATCH", fmt.Sprintf("/options/%d", o.ID), nil, body)
|
resp, err := c.Mutate(ctx(), "PATCH", fmt.Sprintf("/options/%d", o.ID), nil, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -627,7 +641,7 @@ func optionCmd() *cobra.Command {
|
|||||||
set.Flags().StringVar(&comment, "comment", "", "comment")
|
set.Flags().StringVar(&comment, "comment", "", "comment")
|
||||||
set.Flags().BoolVar(&multiple, "add", false, "add another instance instead of replacing (for options that may occur several times)")
|
set.Flags().BoolVar(&multiple, "add", false, "add another instance instead of replacing (for options that may occur several times)")
|
||||||
unset := &cobra.Command{
|
unset := &cobra.Command{
|
||||||
Use: "unset NAME | unset --id ID",
|
Use: "unset NAME [LABEL] | unset --id ID",
|
||||||
Short: "Remove an option (BIND's default applies again)",
|
Short: "Remove an option (BIND's default applies again)",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
c, err := newClient()
|
c, err := newClient()
|
||||||
@@ -641,8 +655,8 @@ func optionCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
return report(resp)
|
return report(resp)
|
||||||
}
|
}
|
||||||
if len(args) != 1 {
|
if len(args) < 1 || len(args) > 2 {
|
||||||
return fmt.Errorf("give the option name or --id")
|
return fmt.Errorf("give the option name (and the name after it, for tls NAME and the like) or --id")
|
||||||
}
|
}
|
||||||
sc, id, err := scope()
|
sc, id, err := scope()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -654,15 +668,24 @@ func optionCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
var matches []api.Option
|
var matches []api.Option
|
||||||
for _, o := range opts {
|
for _, o := range opts {
|
||||||
if o.Name == strings.ToLower(args[0]) {
|
if o.Name == strings.ToLower(args[0]) && (len(args) == 1 || strings.EqualFold(optionLabel(o.Name, o.Value), strings.Trim(args[1], `"`))) {
|
||||||
matches = append(matches, o)
|
matches = append(matches, o)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
switch len(matches) {
|
switch len(matches) {
|
||||||
case 0:
|
case 0:
|
||||||
return fmt.Errorf("option %s is not set here", args[0])
|
return fmt.Errorf("option %s is not set here", strings.Join(args, " "))
|
||||||
case 1:
|
case 1:
|
||||||
default:
|
default:
|
||||||
|
var labels []string
|
||||||
|
for _, o := range matches {
|
||||||
|
if l := optionLabel(o.Name, o.Value); l != "" {
|
||||||
|
labels = append(labels, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(labels) == len(matches) {
|
||||||
|
return fmt.Errorf("option %s is set %d times (%s); say which: jarl option unset %s %s", args[0], len(matches), strings.Join(labels, ", "), args[0], labels[0])
|
||||||
|
}
|
||||||
return fmt.Errorf("option %s is set %d times; remove one with --id", args[0], len(matches))
|
return fmt.Errorf("option %s is set %d times; remove one with --id", args[0], len(matches))
|
||||||
}
|
}
|
||||||
resp, err := c.Mutate(ctx(), "DELETE", fmt.Sprintf("/options/%d", matches[0].ID), nil, nil)
|
resp, err := c.Mutate(ctx(), "DELETE", fmt.Sprintf("/options/%d", matches[0].ID), nil, nil)
|
||||||
@@ -721,7 +744,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -815,3 +866,30 @@ func settingsCmd() *cobra.Command {
|
|||||||
cmd.AddCommand(show, set)
|
cmd.AddCommand(show, set)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// labelled are the statements told apart by the name after the keyword:
|
||||||
|
// tls quad9 { ... }, key xfer { ... }, server 192.0.2.1 { ... }.
|
||||||
|
var labelled = map[string]bool{"tls": true, "http": true, "key": true, "remote-servers": true, "primaries": true, "masters": true,
|
||||||
|
"parental-agents": true, "dnssec-policy": true, "key-store": true, "server": true, "view": true, "acl": true, "zone": true}
|
||||||
|
|
||||||
|
// optionLabel returns the name a labelled statement's value starts with
|
||||||
|
// ("quad9" for tls quad9 { ... }), lowercase; "" for other statements.
|
||||||
|
func optionLabel(name, value string) string {
|
||||||
|
if !labelled[name] {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
v := strings.TrimSpace(value)
|
||||||
|
if v == "" || strings.HasPrefix(v, "{") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(v, `"`) {
|
||||||
|
if end := strings.Index(v[1:], `"`); end >= 0 {
|
||||||
|
return strings.ToLower(v[1 : end+1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end := strings.IndexAny(v, " \t\n{;")
|
||||||
|
if end < 0 {
|
||||||
|
end = len(v)
|
||||||
|
}
|
||||||
|
return strings.ToLower(v[:end])
|
||||||
|
}
|
||||||
+158
-32
@@ -6,6 +6,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -16,14 +17,17 @@ type encEndpoint struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type encStatus struct {
|
type encStatus struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Certificate string `json:"certificate"`
|
Certificate string `json:"certificate"`
|
||||||
Hostname string `json:"hostname"`
|
Hostname string `json:"hostname"`
|
||||||
DoTHost string `json:"dot_host"`
|
Hostnames []string `json:"hostnames"`
|
||||||
DoHURL string `json:"doh_url"`
|
AddrViews map[string][]string `json:"address_views"`
|
||||||
DoT []encEndpoint `json:"dot"`
|
DoTHost string `json:"dot_host"`
|
||||||
DoH []encEndpoint `json:"doh"`
|
DoHURL string `json:"doh_url"`
|
||||||
Other bool `json:"other"`
|
DoT []encEndpoint `json:"dot"`
|
||||||
|
DoH []encEndpoint `json:"doh"`
|
||||||
|
Other bool `json:"other"`
|
||||||
|
Health *encHealth `json:"health"`
|
||||||
Addresses []struct {
|
Addresses []struct {
|
||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
Private bool `json:"private"`
|
Private bool `json:"private"`
|
||||||
@@ -85,7 +89,14 @@ views.`,
|
|||||||
if !s.Enabled {
|
if !s.Enabled {
|
||||||
fmt.Fprintln(w, "Encrypted DNS:\toff (jarl encrypted-dns setup --hostname NAME --address ADDR)")
|
fmt.Fprintln(w, "Encrypted DNS:\toff (jarl encrypted-dns setup --hostname NAME --address ADDR)")
|
||||||
} else {
|
} else {
|
||||||
|
fmt.Fprintf(w, "Host names:\t%s\n", strings.Join(s.Hostnames, ", "))
|
||||||
fmt.Fprintf(w, "Certificate:\t%s\n", s.Certificate)
|
fmt.Fprintf(w, "Certificate:\t%s\n", s.Certificate)
|
||||||
|
for _, e := range append(append([]encEndpoint{}, s.DoT...), s.DoH...) {
|
||||||
|
if vs, ok := s.AddrViews[e.Address]; ok {
|
||||||
|
fmt.Fprintf(w, "Records:\t%s in %s\n", e.Address, strings.Join(vs, ", "))
|
||||||
|
delete(s.AddrViews, e.Address)
|
||||||
|
}
|
||||||
|
}
|
||||||
if len(s.DoT) > 0 {
|
if len(s.DoT) > 0 {
|
||||||
fmt.Fprintf(w, "DNS over TLS:\t%s\n", endpoints(s.DoT))
|
fmt.Fprintf(w, "DNS over TLS:\t%s\n", endpoints(s.DoT))
|
||||||
}
|
}
|
||||||
@@ -99,6 +110,9 @@ views.`,
|
|||||||
fmt.Fprintf(w, "For DoH clients:\t%s (Firefox, Chrome, Windows 11)\n", 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 {
|
if s.Other {
|
||||||
fmt.Fprintln(w, "Note:\tnamed has DoT or DoH listeners written by hand too; setup leaves them alone")
|
fmt.Fprintln(w, "Note:\tnamed has DoT or DoH listeners written by hand too; setup leaves them alone")
|
||||||
}
|
}
|
||||||
@@ -114,12 +128,13 @@ views.`,
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
var host, certName string
|
var hostList []string
|
||||||
|
var certName string
|
||||||
var addrs, views []string
|
var addrs, views []string
|
||||||
var noDoT, noDoH bool
|
var noDoT, noDoH bool
|
||||||
var dohPort, dotPort int
|
var dohPort, dotPort int
|
||||||
setup := &cobra.Command{
|
setup := &cobra.Command{
|
||||||
Use: "setup --hostname NAME --address ADDR...",
|
Use: "setup --hostname NAME... --address ADDR[=VIEW,...]...",
|
||||||
Short: "Set up DNS over TLS and HTTPS (obtaining a certificate if needed)",
|
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
|
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:
|
given, which should be this server's addresses on your own networks:
|
||||||
@@ -130,25 +145,57 @@ given, which should be this server's addresses on your own networks:
|
|||||||
- named's tls statement and listen-on statements (and an explicit
|
- named's tls statement and listen-on statements (and an explicit
|
||||||
listen-on { any; } when there was none, as the first listen-on would
|
listen-on { any; } when there was none, as the first listen-on would
|
||||||
otherwise stop named listening on port 53 everywhere);
|
otherwise stop named listening on port 53 everywhere);
|
||||||
- the host name's A and AAAA records, in the private views that have its
|
- the host names' A and AAAA records, local to each view: per address,
|
||||||
zone (or --view), local to the view when the zone is linked.
|
the views after = (--address 10.0.10.5=internal,lab), or the views of
|
||||||
|
--view for every address (default: the private views that have the
|
||||||
|
zone). A view no longer given loses the address.
|
||||||
|
|
||||||
It is one change, staged or applied like any other. Running it again
|
Several --hostname: the certificate must be for all of them. It is one
|
||||||
replaces the earlier setup.`,
|
change, staged or applied like any other. Running it again replaces the
|
||||||
|
earlier setup, ports included.`,
|
||||||
|
Example: ` jarl encrypted-dns setup --hostname dns.adbraun.lol --address 10.0.10.5=internal --address fdab:abab:abab:10::5=internal
|
||||||
|
jarl encrypted-dns setup --hostname dns.adbraun.lol --hostname doh.adbraun.lol --address 10.0.10.5 --view internal --dot-port 8853`,
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
c, err := newClient()
|
c, err := newClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
host = strings.ToLower(strings.TrimSuffix(host, "."))
|
var hosts []string
|
||||||
if host == "" {
|
for _, h := range hostList {
|
||||||
|
for _, x := range strings.Split(h, ",") {
|
||||||
|
if x = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(x), ".")); x != "" {
|
||||||
|
hosts = append(hosts, x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(hosts) == 0 {
|
||||||
return errors.New("give --hostname, the name clients connect to (e.g. dns.adbraun.lol)")
|
return errors.New("give --hostname, the name clients connect to (e.g. dns.adbraun.lol)")
|
||||||
}
|
}
|
||||||
|
host := hosts[0]
|
||||||
var s encStatus
|
var s encStatus
|
||||||
if err := c.Get(ctx(), "/encrypted-dns", q("hostname", host), &s); err != nil {
|
if err := c.Get(ctx(), "/encrypted-dns", q("hostname", strings.Join(hosts, ",")), &s); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// ADDR=VIEW,VIEW gives an address its own views.
|
||||||
|
addrViews := map[string][]string{}
|
||||||
|
perAddress := false
|
||||||
|
for i, a := range addrs {
|
||||||
|
addr, vs, ok := strings.Cut(a, "=")
|
||||||
|
addrs[i] = strings.TrimSpace(addr)
|
||||||
|
if ok {
|
||||||
|
perAddress = true
|
||||||
|
addrViews[addrs[i]] = nil
|
||||||
|
for _, v := range strings.Split(vs, ",") {
|
||||||
|
if v = strings.TrimSpace(v); v != "" {
|
||||||
|
addrViews[addrs[i]] = append(addrViews[addrs[i]], v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if perAddress && len(views) > 0 {
|
||||||
|
return errors.New("give the views per address (--address ADDR=VIEW,...) or for all of them (--view), not both")
|
||||||
|
}
|
||||||
if len(addrs) == 0 {
|
if len(addrs) == 0 {
|
||||||
var list []string
|
var list []string
|
||||||
for _, a := range s.Addresses {
|
for _, a := range s.Addresses {
|
||||||
@@ -158,20 +205,24 @@ replaces the earlier setup.`,
|
|||||||
}
|
}
|
||||||
// The certificate.
|
// The certificate.
|
||||||
if certName == "" {
|
if certName == "" {
|
||||||
|
certs:
|
||||||
for _, ci := range s.Certificates {
|
for _, ci := range s.Certificates {
|
||||||
if coversName(append(ci.Names, ci.Domains...), host) {
|
for _, h := range hosts {
|
||||||
certName = ci.Name
|
if !coversName(append(ci.Names, ci.Domains...), h) {
|
||||||
break
|
continue certs
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
certName = ci.Name
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if certName == "" {
|
if certName == "" {
|
||||||
certName = strings.SplitN(host, ".", 2)[0]
|
certName = strings.SplitN(host, ".", 2)[0]
|
||||||
fmt.Printf("Creating certificate %s for %s\n", certName, host)
|
fmt.Printf("Creating certificate %s for %s\n", certName, strings.Join(hosts, ", "))
|
||||||
var r struct {
|
var r struct {
|
||||||
Certificate certInfo `json:"certificate"`
|
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 {
|
if err := c.Do(ctx(), "POST", "/certificates", nil, map[string]any{"name": certName, "domains": hosts, "uses": []string{"dns"}}, &r); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := waitIssue(c, certName); err != nil {
|
if err := waitIssue(c, certName); err != nil {
|
||||||
@@ -199,7 +250,7 @@ replaces the earlier setup.`,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
body := map[string]any{"hostname": host, "certificate": certName, "addresses": addrs, "dot": !noDoT, "doh": !noDoH}
|
body := map[string]any{"hostnames": hosts, "certificate": certName, "addresses": addrs, "dot": !noDoT, "doh": !noDoH}
|
||||||
if cmd.Flags().Changed("doh-port") {
|
if cmd.Flags().Changed("doh-port") {
|
||||||
body["doh_port"] = dohPort
|
body["doh_port"] = dohPort
|
||||||
}
|
}
|
||||||
@@ -229,14 +280,24 @@ replaces the earlier setup.`,
|
|||||||
if chk.Error != "" && !flagQuiet {
|
if chk.Error != "" && !flagQuiet {
|
||||||
fmt.Println("Note:", chk.Error)
|
fmt.Println("Note:", chk.Error)
|
||||||
}
|
}
|
||||||
if len(views) == 0 {
|
switch {
|
||||||
for _, v := range s.Views {
|
case perAddress:
|
||||||
if !v.Public && v.HasZone {
|
for _, a := range addrs {
|
||||||
views = append(views, v.Name)
|
if _, ok := addrViews[a]; !ok {
|
||||||
|
addrViews[a] = []string{} // no records for it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
body["address_views"] = addrViews
|
||||||
|
default:
|
||||||
|
if len(views) == 0 {
|
||||||
|
for _, v := range s.Views {
|
||||||
|
if !v.Public && v.HasZone {
|
||||||
|
views = append(views, v.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body["views"] = views
|
||||||
}
|
}
|
||||||
body["views"] = views
|
|
||||||
resp, err := c.Mutate(ctx(), "POST", "/encrypted-dns", nil, body)
|
resp, err := c.Mutate(ctx(), "POST", "/encrypted-dns", nil, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -245,14 +306,14 @@ replaces the earlier setup.`,
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
f := setup.Flags()
|
f := setup.Flags()
|
||||||
f.StringVar(&host, "hostname", "", "the name clients connect to (the certificate's)")
|
f.StringArrayVar(&hostList, "hostname", nil, "a name clients connect to (repeat, or comma separated, for several; the certificate must be for all)")
|
||||||
f.StringVar(&certName, "certificate", "", "the Jarl certificate to serve (default: one for the host name, obtained if there is none)")
|
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.StringArrayVar(&addrs, "address", nil, "this server's address to listen on, with the views that get it: 10.0.10.5=internal,lab (repeat for several)")
|
||||||
f.BoolVar(&noDoT, "no-dot", false, "no DNS over TLS")
|
f.BoolVar(&noDoT, "no-dot", false, "no DNS over TLS")
|
||||||
f.BoolVar(&noDoH, "no-doh", false, "no DNS over HTTPS")
|
f.BoolVar(&noDoH, "no-doh", false, "no DNS over HTTPS")
|
||||||
f.IntVar(&dohPort, "doh-port", 443, "port for 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.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)")
|
f.StringArrayVar(&views, "view", nil, "views where every address gets the host names' records (default: the private views that have the zone); per address: --address ADDR=VIEW")
|
||||||
|
|
||||||
var cAddrs []string
|
var cAddrs []string
|
||||||
var cDoH int
|
var cDoH int
|
||||||
@@ -308,7 +369,38 @@ replaces the earlier setup.`,
|
|||||||
return report(resp)
|
return report(resp)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.AddCommand(setup, check, disable)
|
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
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,3 +429,37 @@ func contains(list []string, s string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
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
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -23,6 +24,7 @@ import (
|
|||||||
|
|
||||||
"jarl/internal/api"
|
"jarl/internal/api"
|
||||||
"jarl/internal/client"
|
"jarl/internal/client"
|
||||||
|
"jarl/internal/dnsname"
|
||||||
"jarl/internal/version"
|
"jarl/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -569,7 +571,13 @@ func q(kv ...string) url.Values {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// zonePath returns the API path for a zone given by name or ID.
|
// zonePath returns the API path for a zone given by name or ID.
|
||||||
|
// A network names its reverse zone: 10.0.10.0/24 is 10.0.10.in-addr.arpa.
|
||||||
func zonePath(zone, view string) (string, url.Values) {
|
func zonePath(zone, view string) (string, url.Values) {
|
||||||
|
if p, err := netip.ParsePrefix(strings.TrimSpace(zone)); err == nil {
|
||||||
|
if name, err := dnsname.ReverseZone(p); err == nil {
|
||||||
|
zone = name
|
||||||
|
}
|
||||||
|
}
|
||||||
z := strings.TrimSuffix(zone, ".")
|
z := strings.TrimSuffix(zone, ".")
|
||||||
if zone == "." {
|
if zone == "." {
|
||||||
z = "root"
|
z = "root"
|
||||||
|
|||||||
@@ -232,3 +232,20 @@ func TestOutputFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOptionLabel(t *testing.T) {
|
||||||
|
for _, c := range []struct{ name, value, want string }{
|
||||||
|
{"tls", `quad9 { remote-hostname "dns.quad9.net"; }`, "quad9"},
|
||||||
|
{"tls", `Cloudflare{ remote-hostname "cloudflare-dns.com"; }`, "cloudflare"},
|
||||||
|
{"key", `"xfer key" { algorithm hmac-sha256; secret "x"; }`, "xfer key"},
|
||||||
|
{"server", "192.0.2.1 { keys { xfer; }; }", "192.0.2.1"},
|
||||||
|
{"remote-servers", "secondaries { 192.0.2.53; }", "secondaries"},
|
||||||
|
{"forwarders", "{ 9.9.9.9; }", ""},
|
||||||
|
{"logging", "{ channel x { stderr; }; }", ""},
|
||||||
|
{"tls", "", ""},
|
||||||
|
} {
|
||||||
|
if got := optionLabel(c.name, c.value); got != c.want {
|
||||||
|
t.Errorf("optionLabel(%q, %q) = %q, want %q", c.name, c.value, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+387
-2
@@ -93,6 +93,15 @@ 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 z.AutoPTR {
|
||||||
|
fmt.Fprintf(w, "PTR records:\tkept in step with the A and AAAA records\n")
|
||||||
|
}
|
||||||
|
if z.Prefix != "" {
|
||||||
|
fmt.Fprintf(w, "Network:\t%s\n", z.Prefix)
|
||||||
|
if z.PTRMissing > 0 {
|
||||||
|
fmt.Fprintf(w, "Missing PTRs:\t%d (addresses the view's records use here; add them: jarl zone fill-ptr %s)\n", z.PTRMissing, args[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
if len(z.LinkedWith) > 0 {
|
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, ", "))
|
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, ", "))
|
||||||
}
|
}
|
||||||
@@ -110,17 +119,21 @@ func zoneCmd() *cobra.Command {
|
|||||||
var ns, records, primaries, forwarders, opts []string
|
var ns, records, primaries, forwarders, opts []string
|
||||||
var contact, comment, cloneFrom, file, inView, forward, linkWith string
|
var contact, comment, cloneFrom, file, inView, forward, linkWith string
|
||||||
var ttl uint32
|
var ttl uint32
|
||||||
|
var fill bool
|
||||||
create := &cobra.Command{
|
create := &cobra.Command{
|
||||||
Use: "create NAME",
|
Use: "create NAME",
|
||||||
Short: "Create a zone",
|
Short: "Create a zone",
|
||||||
Long: `Create a zone. NAME may be a network (192.168.1.0/24, 2001:db8::/48) to
|
Long: `Create a zone. NAME may be a network (192.168.1.0/24, 2001:db8::/48) to
|
||||||
create its reverse zone: a /32 (or /128) gives a zone for that one address,
|
create its reverse zone: a /32 (or /128) gives a zone for that one address,
|
||||||
and a network between octet boundaries, like a /22, gets one zone per /24
|
and a network between octet boundaries, like a /22, gets one zone per /24
|
||||||
(created together).
|
(created together). Without --view, a reverse zone goes to the private
|
||||||
|
view whose records use its addresses; --fill adds a PTR record for each
|
||||||
|
of them (docs/reverse.md).
|
||||||
|
|
||||||
jarl zone create example.com --ns ns1.example.com. --record "ns1 A 192.0.2.1"
|
jarl zone create example.com --ns ns1.example.com. --record "ns1 A 192.0.2.1"
|
||||||
jarl zone create 192.168.1.0/24
|
jarl zone create 192.168.1.0/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 10.0.10.0/24 --fill # with PTR records for the view's A records there
|
||||||
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
|
||||||
@@ -156,6 +169,9 @@ and a network between octet boundaries, like a /22, gets one zone per /24
|
|||||||
if linkWith != "" {
|
if linkWith != "" {
|
||||||
body["link_with"] = linkWith
|
body["link_with"] = linkWith
|
||||||
}
|
}
|
||||||
|
if fill {
|
||||||
|
body["fill"] = true
|
||||||
|
}
|
||||||
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)
|
||||||
@@ -228,9 +244,11 @@ and a network between octet boundaries, like a /22, gets one zone per /24
|
|||||||
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(&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")
|
||||||
|
create.Flags().BoolVar(&fill, "fill", false, "reverse zones: add PTR records for the view's A and AAAA records in the network")
|
||||||
|
|
||||||
var refresh, retry, expire, minimum, serial uint32
|
var refresh, retry, expire, minimum, serial uint32
|
||||||
var mname, policy, moveView string
|
var mname, policy, moveView string
|
||||||
|
var autoPTR bool
|
||||||
update := &cobra.Command{
|
update := &cobra.Command{
|
||||||
Use: "update ZONE",
|
Use: "update ZONE",
|
||||||
Short: "Change a zone's settings (SOA, TTL, view, comment)",
|
Short: "Change a zone's settings (SOA, TTL, view, comment)",
|
||||||
@@ -272,6 +290,9 @@ and a network between octet boundaries, like a /22, gets one zone per /24
|
|||||||
if f.Changed("file") {
|
if f.Changed("file") {
|
||||||
body["file"] = file
|
body["file"] = file
|
||||||
}
|
}
|
||||||
|
if f.Changed("auto-ptr") {
|
||||||
|
body["auto_ptr"] = autoPTR
|
||||||
|
}
|
||||||
if len(body) == 0 {
|
if len(body) == 0 {
|
||||||
return fmt.Errorf("nothing to change (see --help)")
|
return fmt.Errorf("nothing to change (see --help)")
|
||||||
}
|
}
|
||||||
@@ -296,6 +317,7 @@ and a network between octet boundaries, like a /22, gets one zone per /24
|
|||||||
update.Flags().StringVar(&comment, "comment", "", "comment")
|
update.Flags().StringVar(&comment, "comment", "", "comment")
|
||||||
update.Flags().StringVar(&moveView, "move-to-view", "", "move the zone to this view")
|
update.Flags().StringVar(&moveView, "move-to-view", "", "move the zone to this view")
|
||||||
update.Flags().StringVar(&file, "file", "", "zone file (secondary, hint and redirect zones)")
|
update.Flags().StringVar(&file, "file", "", "zone file (secondary, hint and redirect zones)")
|
||||||
|
update.Flags().BoolVar(&autoPTR, "auto-ptr", false, "keep the PTR records of the zone's A and AAAA records in step (--auto-ptr=false: stop)")
|
||||||
|
|
||||||
var yes bool
|
var yes bool
|
||||||
del := &cobra.Command{
|
del := &cobra.Command{
|
||||||
@@ -424,7 +446,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(), zoneLinkCmd(true), zoneLinkCmd(false))
|
cmd.AddCommand(list, show, create, update, del, toggle(true), toggle(false), fileCmd, importCmd, zoneSerialsCmd(), zoneDelegationCmd(), zoneDNSSECCmd(), zoneRegistrarCmd(), zoneLinkCmd(true), zoneLinkCmd(false), zoneDiffCmd(), zoneFillPTRCmd(), zoneSuggestReverseCmd())
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -937,3 +959,366 @@ zone is dissolved.`
|
|||||||
cmd.Example = " jarl zone unlink adbraun.lol --view vps"
|
cmd.Example = " jarl zone unlink adbraun.lol --view vps"
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// zoneDiffCmd is 'zone diff': a zone's records side by side in the views
|
||||||
|
// that have it.
|
||||||
|
type compareSet struct {
|
||||||
|
View string `json:"view"`
|
||||||
|
Records []string `json:"records"`
|
||||||
|
IDs []int64 `json:"ids"`
|
||||||
|
Linked string `json:"linked"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type compareRow struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Views []compareSet `json:"views"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type compareReport struct {
|
||||||
|
Zone string `json:"zone"`
|
||||||
|
Base string `json:"base"`
|
||||||
|
Views []string `json:"views"`
|
||||||
|
Linked bool `json:"linked"`
|
||||||
|
Summary []struct {
|
||||||
|
View string `json:"view"`
|
||||||
|
Different int `json:"different"`
|
||||||
|
OnlyBase int `json:"only_base"`
|
||||||
|
OnlyOther int `json:"only_other"`
|
||||||
|
Same int `json:"same"`
|
||||||
|
} `json:"summary"`
|
||||||
|
Different []compareRow `json:"different"`
|
||||||
|
Only []struct {
|
||||||
|
Views []string `json:"views"`
|
||||||
|
Rows []compareRow `json:"rows"`
|
||||||
|
} `json:"only"`
|
||||||
|
Same int `json:"same"`
|
||||||
|
SameRows []compareRow `json:"same_rows"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func zoneDiffCmd() *cobra.Command {
|
||||||
|
var view string
|
||||||
|
var with []string
|
||||||
|
var all, wide bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "diff ZONE [NAME [TYPE]] [--view V] [--with W...] [--all] [--wide]",
|
||||||
|
Short: "Compare a zone with the same zone in other views",
|
||||||
|
Long: `Compares a zone in one view (--view, default the first view that has it)
|
||||||
|
with the same zone in the other views (--with, default all of them): how
|
||||||
|
many names each answers differently, the names and types only some views
|
||||||
|
have, and how many are the same. NAME (and TYPE) narrows it to one name,
|
||||||
|
the same records included. One line per record; long values are cut to
|
||||||
|
fit the terminal (--wide shows them whole).
|
||||||
|
|
||||||
|
In a linked zone a difference is a view's own (local) records, on
|
||||||
|
purpose; between zones that aren't linked it is drift. 'jarl record
|
||||||
|
update ID --shared' makes a linked view's version every view's.`,
|
||||||
|
Example: ` jarl zone diff adbraun.lol
|
||||||
|
jarl zone diff adbraun.lol --view internal --with external
|
||||||
|
jarl zone diff adbraun.lol nas
|
||||||
|
jarl zone diff adbraun.lol @ TXT --wide`,
|
||||||
|
Args: cobra.RangeArgs(1, 3),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path, qv := zonePath(args[0], view)
|
||||||
|
if len(with) > 0 {
|
||||||
|
qv.Set("with", strings.Join(with, ","))
|
||||||
|
}
|
||||||
|
if len(args) > 1 {
|
||||||
|
qv.Set("name", args[1])
|
||||||
|
}
|
||||||
|
if len(args) > 2 {
|
||||||
|
qv.Set("type", args[2])
|
||||||
|
}
|
||||||
|
if all {
|
||||||
|
qv.Set("all", "1")
|
||||||
|
}
|
||||||
|
var r compareReport
|
||||||
|
if err := c.Get(ctx(), path+"/compare", qv, &r); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
width := 0
|
||||||
|
if !wide {
|
||||||
|
width = terminalWidth()
|
||||||
|
}
|
||||||
|
return output(r, func(w io.Writer) { printCompare(w, &r, len(args) > 1, width) })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&view, "view", "", "the view to compare from (default: the first that has the zone)")
|
||||||
|
cmd.Flags().StringArrayVar(&with, "with", nil, "a view to compare with (repeat; default: every other view with the zone)")
|
||||||
|
cmd.Flags().BoolVar(&all, "all", false, "also list what is the same")
|
||||||
|
cmd.Flags().BoolVarP(&wide, "wide", "w", false, "show long values whole instead of cutting them to the terminal")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// printCompare prints a comparison as sections, one line per record,
|
||||||
|
// each cut to width characters (0: whole).
|
||||||
|
func printCompare(w io.Writer, r *compareReport, oneName bool, width int) {
|
||||||
|
vw := 0 // the views column
|
||||||
|
for _, v := range r.Views {
|
||||||
|
vw = max(vw, len(v))
|
||||||
|
}
|
||||||
|
line := func(indent, label, text string) {
|
||||||
|
s := indent + label + text
|
||||||
|
if width > 0 && utf8.RuneCountInString(s) > width {
|
||||||
|
keep := max(width-utf8.RuneCountInString(indent+label)-1, 8)
|
||||||
|
text = string([]rune(text)[:min(keep, utf8.RuneCountInString(text))]) + "…"
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w, indent+label+text)
|
||||||
|
}
|
||||||
|
link := "not linked, so a difference may have crept in ('jarl zone link " + r.Zone + "' keeps them in step)"
|
||||||
|
if r.Linked {
|
||||||
|
link = "linked: a difference is a view's own records, there on purpose"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s in %s, compared with %s\n %s\n", r.Zone, r.Base, strings.Join(r.Views[1:], ", "), link)
|
||||||
|
if !oneName {
|
||||||
|
for _, s := range r.Summary {
|
||||||
|
fmt.Fprintf(w, " %s: %d answered differently, %d only in %s, %d only in %s, %d the same\n", s.View, s.Different, s.OnlyBase, r.Base, s.OnlyOther, s.View, s.Same)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
name := func(row compareRow) string { return row.Name + " " + row.Type }
|
||||||
|
sets := func(row compareRow, local bool) {
|
||||||
|
for _, vs := range row.Views {
|
||||||
|
label := fmt.Sprintf(" %-*s ", vw, vs.View)
|
||||||
|
if len(vs.Records) == 0 {
|
||||||
|
line("", label, "-")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i, d := range vs.Records {
|
||||||
|
if i > 0 {
|
||||||
|
label = strings.Repeat(" ", 4+vw+2)
|
||||||
|
}
|
||||||
|
suffix := ""
|
||||||
|
if local && vs.Linked == "local" && i == 0 {
|
||||||
|
suffix = " [local, id " + fmt.Sprint(vs.IDs[0]) + "]"
|
||||||
|
}
|
||||||
|
line("", label, d+suffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(r.Different) > 0 {
|
||||||
|
fmt.Fprintf(w, "\nANSWERED DIFFERENTLY (%d)\n", len(r.Different))
|
||||||
|
for _, row := range r.Different {
|
||||||
|
fmt.Fprintln(w, " "+name(row))
|
||||||
|
sets(row, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, o := range r.Only {
|
||||||
|
fmt.Fprintf(w, "\nONLY IN %s (%d)\n", strings.ToUpper(strings.Join(o.Views, ", ")), len(o.Rows))
|
||||||
|
nw := 0
|
||||||
|
for _, row := range o.Rows {
|
||||||
|
nw = max(nw, len(name(row)))
|
||||||
|
}
|
||||||
|
for _, row := range o.Rows {
|
||||||
|
var recs []string
|
||||||
|
for _, vs := range row.Views {
|
||||||
|
if len(vs.Records) > 0 {
|
||||||
|
recs = vs.Records
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, d := range recs {
|
||||||
|
label := fmt.Sprintf(" %-*s ", nw, name(row))
|
||||||
|
if i > 0 {
|
||||||
|
label = strings.Repeat(" ", 2+nw+2)
|
||||||
|
}
|
||||||
|
line("", label, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(r.SameRows) > 0 {
|
||||||
|
fmt.Fprintf(w, "\nTHE SAME IN %s (%d)\n", strings.ToUpper(strings.Join(r.Views, ", ")), len(r.SameRows))
|
||||||
|
nw := 0
|
||||||
|
for _, row := range r.SameRows {
|
||||||
|
nw = max(nw, len(name(row)))
|
||||||
|
}
|
||||||
|
for _, row := range r.SameRows {
|
||||||
|
for i, d := range row.Views[0].Records {
|
||||||
|
label := fmt.Sprintf(" %-*s ", nw, name(row))
|
||||||
|
if i > 0 {
|
||||||
|
label = strings.Repeat(" ", 2+nw+2)
|
||||||
|
}
|
||||||
|
line("", label, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if r.Same > 0 && !oneName {
|
||||||
|
fmt.Fprintf(w, "\n%d names and types are the same (--all lists them)\n", r.Same)
|
||||||
|
}
|
||||||
|
if oneName && len(r.Different) == 0 && len(r.Only) == 0 && len(r.SameRows) == 0 {
|
||||||
|
fmt.Fprintln(w, "\nNo records of that name in these views.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// terminalWidth returns the terminal's width, or 0 when standard output
|
||||||
|
// isn't a terminal (then nothing is cut).
|
||||||
|
func terminalWidth() int {
|
||||||
|
fd := int(os.Stdout.Fd())
|
||||||
|
if !term.IsTerminal(fd) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
width, _, err := term.GetSize(fd)
|
||||||
|
if err != nil || width <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return width
|
||||||
|
}
|
||||||
|
|
||||||
|
func zoneFillPTRCmd() *cobra.Command {
|
||||||
|
var view string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "fill-ptr ZONE [--view VIEW]",
|
||||||
|
Short: "Add the PTR records a reverse zone lacks for its view's A and AAAA records",
|
||||||
|
Long: `Adds a PTR record to a reverse zone for every address in its network that
|
||||||
|
the view's A and AAAA records use and that has none yet. An address with
|
||||||
|
several names gets the mail or name server's name, else a host's rather
|
||||||
|
than the domain's, else the shortest (docs/reverse.md).`,
|
||||||
|
Example: ` jarl zone fill-ptr 10.0.10.0/24
|
||||||
|
jarl zone fill-ptr 10.0.10.in-addr.arpa --view internal`,
|
||||||
|
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)
|
||||||
|
resp, err := c.Mutate(ctx(), "POST", path+"/fill-ptr", qv, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&view, "view", "", "view of the zone")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
type reverseSuggestion struct {
|
||||||
|
ViewID int64 `json:"view_id"`
|
||||||
|
View string `json:"view"`
|
||||||
|
Prefix string `json:"prefix"`
|
||||||
|
Zone string `json:"zone"`
|
||||||
|
Group string `json:"group"`
|
||||||
|
Hidden string `json:"hidden"`
|
||||||
|
Why string `json:"why"`
|
||||||
|
Fill int `json:"fill"`
|
||||||
|
Samples []string `json:"samples"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func zoneSuggestReverseCmd() *cobra.Command {
|
||||||
|
var create, all bool
|
||||||
|
var dismiss, undismiss []string
|
||||||
|
var view string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "suggest-reverse [--create] [--dismiss NETWORK --view V]",
|
||||||
|
Short: "The reverse zones your private views lack for their records' addresses",
|
||||||
|
Long: `Lists the reverse zones the private views (or the server, without views)
|
||||||
|
lack for the addresses of their A and AAAA records, in two groups:
|
||||||
|
|
||||||
|
yours the /24 of a private IPv4 network, the /48 of a ULA or of an IPv6
|
||||||
|
prefix this server has an address in
|
||||||
|
public a /32 (or /128) for any other public address: create it only if
|
||||||
|
the address is yours, not a web host's
|
||||||
|
|
||||||
|
--create creates the ones that are yours, filled with PTR records, in one
|
||||||
|
changeset; create a public one with 'jarl zone create NETWORK --view V
|
||||||
|
--fill'. --dismiss hides a suggestion for good (--undismiss shows it
|
||||||
|
again). A view can stop suggestions: jarl view update V
|
||||||
|
--reverse-suggestions=false (docs/reverse.md).
|
||||||
|
|
||||||
|
--all lists every network the views' records use without a reverse zone,
|
||||||
|
also where nothing is suggested unasked (a view with suggestions off, a
|
||||||
|
dismissed network, a public view), to create one by hand; --view narrows
|
||||||
|
the list to one view. Change the size as you need: a /48 suggested for
|
||||||
|
IPv6 can be created as a /56 or /64 instead.`,
|
||||||
|
Example: ` jarl zone suggest-reverse
|
||||||
|
jarl zone suggest-reverse --create
|
||||||
|
jarl zone create 139.144.144.154/32 --view internal --fill
|
||||||
|
jarl zone suggest-reverse --dismiss 185.199.108.153/32 --view internal
|
||||||
|
jarl zone suggest-reverse --all --view vps
|
||||||
|
jarl zone create 2a02:8013:7f3d::/48 --view internal --fill`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(dismiss) > 0 || len(undismiss) > 0 {
|
||||||
|
path, prefixes := "/reverse-zones/dismiss", dismiss
|
||||||
|
if len(undismiss) > 0 {
|
||||||
|
path, prefixes = "/reverse-zones/undismiss", undismiss
|
||||||
|
}
|
||||||
|
resp, err := c.Mutate(ctx(), "POST", path, nil, map[string]any{"view": view, "prefixes": prefixes})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
}
|
||||||
|
if create && (all || view != "") {
|
||||||
|
return fmt.Errorf("--create creates the suggestions that are yours; create others one by one: jarl zone create NETWORK --view VIEW --fill")
|
||||||
|
}
|
||||||
|
qv := q("view", view)
|
||||||
|
if all {
|
||||||
|
qv.Set("all", "1")
|
||||||
|
}
|
||||||
|
var sugg []reverseSuggestion
|
||||||
|
if err := c.Get(ctx(), "/reverse-zones/suggestions", qv, &sugg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !create {
|
||||||
|
return output(sugg, func(w io.Writer) {
|
||||||
|
if len(sugg) == 0 {
|
||||||
|
fmt.Fprintln(w, "No reverse zones missing.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
head := "GROUP\tVIEW\tNETWORK\tZONE\tPTRS\tFOR EXAMPLE"
|
||||||
|
if all {
|
||||||
|
head = "GROUP\tVIEW\tNETWORK\tZONE\tPTRS\tHIDDEN\tFOR EXAMPLE"
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w, head)
|
||||||
|
for _, s := range sugg {
|
||||||
|
v := s.View
|
||||||
|
if v == "" {
|
||||||
|
v = "-"
|
||||||
|
}
|
||||||
|
cols := []string{s.Group, v, s.Prefix, s.Zone, fmt.Sprint(s.Fill)}
|
||||||
|
if all {
|
||||||
|
cols = append(cols, orDash(s.Hidden))
|
||||||
|
}
|
||||||
|
cols = append(cols, strings.Join(s.Samples, ", "))
|
||||||
|
fmt.Fprintln(w, strings.Join(cols, "\t"))
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w, "\nCreate yours, filled: jarl zone suggest-reverse --create. A public one only if the address is yours:\njarl zone create NETWORK --view VIEW --fill (or hide it: --dismiss NETWORK --view VIEW).")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
var reqs []api.ChangeRequest
|
||||||
|
for _, s := range sugg {
|
||||||
|
if s.Group != "yours" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body := map[string]any{"name": s.Prefix, "fill": true}
|
||||||
|
if s.ViewID != 0 {
|
||||||
|
body["view_id"] = s.ViewID
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reqs = append(reqs, api.ChangeRequest{Op: "zone.create", Data: data})
|
||||||
|
}
|
||||||
|
if len(reqs) == 0 {
|
||||||
|
fmt.Println("No reverse zones of yours missing (public addresses are created one by one: see 'jarl zone suggest-reverse').")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return submitBatch(c, fmt.Sprintf("%d suggested reverse zones", len(reqs)), reqs)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().BoolVar(&create, "create", false, "create the ones that are yours, filled with PTR records")
|
||||||
|
cmd.Flags().StringSliceVar(&dismiss, "dismiss", nil, "hide the suggestions for these networks (comma separated or repeated)")
|
||||||
|
cmd.Flags().StringSliceVar(&undismiss, "undismiss", nil, "show dismissed suggestions again")
|
||||||
|
cmd.Flags().StringVar(&view, "view", "", "only this view; with --dismiss/--undismiss: the suggestion's view")
|
||||||
|
cmd.Flags().BoolVar(&all, "all", false, "also networks not suggested unasked (suggestions off, dismissed, public views)")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
+6
-2
@@ -151,7 +151,8 @@ func statsOptions(cfg *config.Config) stats.Options {
|
|||||||
sc := cfg.Stats
|
sc := cfg.Stats
|
||||||
o := stats.Options{QueryLog: sc.QueryLog, LogFile: sc.LogFile, StatisticsChannel: sc.StatisticsChannel, TopN: sc.TopN,
|
o := stats.Options{QueryLog: sc.QueryLog, LogFile: sc.LogFile, StatisticsChannel: sc.StatisticsChannel, TopN: sc.TopN,
|
||||||
RetainMinute: time.Duration(sc.Retention.Minute), RetainHour: time.Duration(sc.Retention.Hour), RetainDay: time.Duration(sc.Retention.Day),
|
RetainMinute: time.Duration(sc.Retention.Minute), RetainHour: time.Duration(sc.Retention.Hour), RetainDay: time.Duration(sc.Retention.Day),
|
||||||
Anonymize: sc.AnonymizeClients, MainConfig: cfg.BIND.MainConfig}
|
RetainQueries: time.Duration(sc.Retention.Queries),
|
||||||
|
Anonymize: sc.AnonymizeClients, MainConfig: cfg.BIND.MainConfig}
|
||||||
for _, p := range sc.ExcludeClients {
|
for _, p := range sc.ExcludeClients {
|
||||||
if pf, err := config.ParsePrefix(p); err == nil {
|
if pf, err := config.ParsePrefix(p); err == nil {
|
||||||
o.Exclude = append(o.Exclude, pf.Masked())
|
o.Exclude = append(o.Exclude, pf.Masked())
|
||||||
@@ -188,6 +189,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 +335,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,
|
||||||
|
|||||||
+15
-2
@@ -190,6 +190,7 @@ ZoneInput:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "adbraun.lol", // or a CIDR like "192.168.1.0/24" for a reverse zone
|
"name": "adbraun.lol", // or a CIDR like "192.168.1.0/24" for a reverse zone
|
||||||
|
"fill": true, // reverse zones: PTR records for the view's A/AAAA records there
|
||||||
"view": "internal", // or "view_id"; moving a zone = PATCH with another view
|
"view": "internal", // or "view_id"; moving a zone = PATCH with another view
|
||||||
"type": "primary", // secondary, stub, static-stub, forward, mirror, hint, redirect, in-view
|
"type": "primary", // secondary, stub, static-stub, forward, mirror, hint, redirect, in-view
|
||||||
"in_view": "internal", // type in-view only
|
"in_view": "internal", // type in-view only
|
||||||
@@ -199,6 +200,7 @@ ZoneInput:
|
|||||||
"default_ttl": 3600,
|
"default_ttl": 3600,
|
||||||
"serial_policy": "date", // date, increment, unixtime
|
"serial_policy": "date", // date, increment, unixtime
|
||||||
"dnssec_policy": "default", // primary zones: "" (off), "default", a custom policy, "insecure" (turning off)
|
"dnssec_policy": "default", // primary zones: "" (off), "default", a custom policy, "insecure" (turning off)
|
||||||
|
"auto_ptr": true, // keep the PTRs of the zone's A/AAAA records in step (docs/reverse.md)
|
||||||
"comment": "",
|
"comment": "",
|
||||||
// create only:
|
// create only:
|
||||||
"nameservers": ["ns1.adbraun.lol."], // default: settings
|
"nameservers": ["ns1.adbraun.lol."], // default: settings
|
||||||
@@ -208,6 +210,11 @@ ZoneInput:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
A reverse zone created without a view goes to the private view whose
|
||||||
|
records use its addresses. Zones come back with `prefix` (reverse zones:
|
||||||
|
the network) and `ptr_missing` (addresses in it that the view's A and AAAA
|
||||||
|
records use, without a PTR record).
|
||||||
|
|
||||||
`/serials` and `/delegation` send DNS queries from the server, and only
|
`/serials` and `/delegation` send DNS queries from the server, and only
|
||||||
when called ([operations.md](operations.md#checking-name-servers-and-the-delegation)).
|
when called ([operations.md](operations.md#checking-name-servers-and-the-delegation)).
|
||||||
`/serials` works for primary, secondary and mirror zones:
|
`/serials` works for primary, secondary and mirror zones:
|
||||||
@@ -353,6 +360,10 @@ and `linked_with` (the other views).
|
|||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| 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}/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 |
|
| POST | `/zones/{zone}/unlink` | Takes the zone out of its link; its records stay, all local |
|
||||||
|
| POST | `/zones/{zone}/fill-ptr` | Adds the PTR records a reverse zone lacks for its view's A and AAAA records ([reverse.md](reverse.md)) |
|
||||||
|
| GET | `/reverse-zones/suggestions` | The reverse zones the private views lack (`?all=1`: also the ones not suggested unasked, with `hidden`: public view, suggestions off, dismissed; `?view=`: one view): `view_id`, `view`, `prefix`, `zone`, `group` (`yours`, or `public`: only if the address is yours), `why`, `fill` (PTR records it would get), `samples` |
|
||||||
|
| POST | `/reverse-zones/dismiss` | `{"view": "internal", "prefix": "185.199.108.153/32"}` (or `"prefixes": [...]` for several): don't suggest it again (`/undismiss` brings it back). A view's `reverse_suggestions: false` (PATCH `/views/{id}`) stops its suggestions |
|
||||||
|
| GET | `/zones/{zone}/compare` | The zone compared with the same zone in other views, from its own view ([views.md](views.md#comparing-views)): `?with=a,b` (default: all), `?name=www&type=A` (one name, the same records included), `?all=1`. Returns `base`, `views`, `linked`, `summary` (per other view: `different`, `only_base`, `only_other`, `same`), `different` and `only` (`views`, `rows`) rows (name, `fqdn`, type, per view `records`, `data`, `ids`, `ttl`, `linked`), `same`, `same_rows`. A zone named without its view is compared from the first view that has it |
|
||||||
|
|
||||||
`POST /zones` takes `"link_with": "VIEW"` to create a zone linked with
|
`POST /zones` takes `"link_with": "VIEW"` to create a zone linked with
|
||||||
that view's zone of the same name.
|
that view's zone of the same name.
|
||||||
@@ -469,7 +480,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"}` |
|
||||||
@@ -553,7 +565,8 @@ Needs `stats:read`. When statistics are disabled, every endpoint but
|
|||||||
| GET | `/stats/timeline` | `since`, `step` (auto, 1m, 5m, 15m, 1h, 6h, 1d), `key` (repeatable or comma separated; `rcode:*` = every counter of a group; default `queries`) | `{step, times: [unix...], series: [{key, values}]}` |
|
| GET | `/stats/timeline` | `since`, `step` (auto, 1m, 5m, 15m, 1h, 6h, 1d), `key` (repeatable or comma separated; `rcode:*` = every counter of a group; default `queries`) | `{step, times: [unix...], series: [{key, values}]}` |
|
||||||
| GET | `/stats/top` | `kind` (name, client, nxdomain, servfail, refused, blocked, blocked_client), `since`, `limit` (default 20) | `[{key, count}]`; client, refused and blocked_client entries add `name`, the host name of Jarl's record for the address (the summary's client list too) |
|
| GET | `/stats/top` | `kind` (name, client, nxdomain, servfail, refused, blocked, blocked_client), `since`, `limit` (default 20) | `[{key, count}]`; client, refused and blocked_client entries add `name`, the host name of Jarl's record for the address (the summary's client list too) |
|
||||||
| GET | `/stats/heatmap` | `since` (default 28d), `key` (default queries) | `{key, from, avg[7][24], samples[7][24]}`, Monday first, local time, `-1` = no data |
|
| GET | `/stats/heatmap` | `since` (default 28d), `key` (default queries) | `{key, from, avg[7][24], samples[7][24]}`, Monday first, local time, `-1` = no data |
|
||||||
| GET | `/stats/server` | | `{server: {rss, mem_in_use, cpu_percent, threads, pid, cache_rrsets, cache_mem, sockets_udp, sockets_tcp, recursion_highwater, tcp_highwater, version, boot_time, config_time, host: {mem_total, mem_available, load, cpus, uptime}}, named: {recursive_clients, tcp_clients, zones, cpus, worker_threads}, disks: [{path, holds, total, free}]}`; gauges over time: `/stats/timeline?key=g:named:rss` (averages, -1 without a sample) |
|
| GET | `/stats/explore` | `?client=&name=&domain=&view=&type=&rcode=&blocked=yes\|no\|LIST&since=24h&views=`; each takes several values, comma separated (any of them), `!value` to leave one out, and names `*.example.com` for it and below | The query log for a filter ([stats.md](stats.md#exploring)): `total`, `blocked`, `series` (per `step` seconds: `t`, `count`, `blocked`), `by` (top 15 of each dimension the filter leaves open: client, name, view, type, rcode, blocked; clients with `name`), `recent` (latest 100: ts, client, client_name, view, name, type, rcode, blocked), `oldest`, `retention`, `client_name`. 404 `query_log_off` without a query log |
|
||||||
|
| GET | `/stats/server` | | `{server: {rss, mem_in_use, cpu_percent, threads, pid, cache_rrsets, cache_mem, sockets_udp, sockets_tcp, recursion_highwater, tcp_highwater, version, boot_time, config_time, host: {mem_total, mem_available, load, cpus, uptime}}, named: {recursive_clients, tcp_clients, zones, cpus, worker_threads}, disks: [{path, holds, total, free}], database: {path, bytes, query_log_rows}}`; gauges over time: `/stats/timeline?key=g:named:rss` (averages, -1 without a sample) |
|
||||||
| GET | `/stats/breakdown` | `group` (qtype, rcode, view, zone, answer, transport, rtt, dnssec, cache, ratelimit, blocklist), `since` | `[{key, count}]` |
|
| GET | `/stats/breakdown` | `group` (qtype, rcode, view, zone, answer, transport, rtt, dnssec, cache, ratelimit, blocklist), `since` | `[{key, count}]` |
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -192,8 +192,15 @@ jarl option set tls 'dot { cert-file "/etc/bind/jarl/tls/ns1/fullchain.pem"; key
|
|||||||
jarl option set listen-on 'port 853 tls dot { 10.0.10.5; }' --add
|
jarl option set listen-on 'port 853 tls dot { 10.0.10.5; }' --add
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The Certificates page (and `jarl cert show`) says what named uses each
|
||||||
|
for: `DNS over TLS on 10.0.10.5, fd00::5 port 853`, `DNS over HTTPS on
|
||||||
|
… port 443`, or a hand-written `tls` statement no listener uses.
|
||||||
|
|
||||||
A certificate named uses can't be deleted or lose its `dns` use until
|
A certificate named uses can't be deleted or lose its `dns` use until
|
||||||
named no longer names its files.
|
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
|
||||||
|
|
||||||
|
|||||||
+22
@@ -177,6 +177,13 @@ jarl zone create lab.adbraun.lol --ns ns1.adbraun.lol.
|
|||||||
jarl zone create 192.168.20.0/24 # reverse zone from a network
|
jarl zone create 192.168.20.0/24 # reverse zone from a network
|
||||||
jarl zone create 10.0.8.0/22 # four zones, 8-11.0.10.in-addr.arpa
|
jarl zone create 10.0.8.0/22 # four zones, 8-11.0.10.in-addr.arpa
|
||||||
jarl zone create 10.0.10.100/32 # a zone for just that address
|
jarl zone create 10.0.10.100/32 # a zone for just that address
|
||||||
|
jarl zone create 10.0.10.0/24 --fill # in the view whose records use it, with their PTRs
|
||||||
|
jarl zone fill-ptr 10.0.10.0/24 # add the PTRs it lacks (zone commands take a network too)
|
||||||
|
jarl zone suggest-reverse [--create] # reverse zones your private views lack (--create: yours)
|
||||||
|
jarl zone suggest-reverse --dismiss 185.199.108.153/32,185.199.109.153/32 --view internal # not yours: don't suggest them again
|
||||||
|
jarl zone suggest-reverse --all [--view V] # every network without a reverse zone, also the hidden ones
|
||||||
|
jarl view update vps --reverse-suggestions=false # no suggestions for this view
|
||||||
|
jarl zone update adbraun.lol --auto-ptr # keep PTRs in step with the A/AAAA records (docs/reverse.md)
|
||||||
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 zone create partner.example --type secondary --primary "192.0.2.53 key xfer"
|
jarl zone create partner.example --type secondary --primary "192.0.2.53 key xfer"
|
||||||
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"
|
||||||
@@ -221,6 +228,8 @@ 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 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 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 record update 1234 --local | --shared
|
||||||
|
jarl zone diff adbraun.lol [--view V] [--with W] [--all] [--wide] # compared with the same zone in other views
|
||||||
|
jarl zone diff adbraun.lol nas [A] # just one name
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
||||||
@@ -404,6 +413,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
|
||||||
@@ -417,6 +427,7 @@ See [exposure.md](exposure.md).
|
|||||||
jarl encrypted-dns # what is set up, what to give clients
|
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 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 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
|
jarl encrypted-dns disable
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -471,11 +482,22 @@ jarl option set allow-transfer 'key xfer; 10.0.10.53' --zone adbraun.lol
|
|||||||
jarl option set allow-query '10.0.20.1-10.0.20.99; localhost' --view internal
|
jarl option set allow-query '10.0.20.1-10.0.20.99; localhost' --view internal
|
||||||
jarl option set response-policy '{ zone "rpz.local"; }' --view internal
|
jarl option set response-policy '{ zone "rpz.local"; }' --view internal
|
||||||
jarl option set logging '{ channel q { file "/var/log/named/query.log" versions 3 size 20m; }; category queries { q; }; }' --toplevel
|
jarl option set logging '{ channel q { file "/var/log/named/query.log" versions 3 size 20m; }; category queries { q; }; }' --toplevel
|
||||||
|
jarl option set tls 'quad9 { remote-hostname "dns.quad9.net"; ca-file "/etc/ssl/certs/ca-certificates.crt"; }' --toplevel
|
||||||
|
jarl option set tls 'cloudflare { remote-hostname "cloudflare-dns.com"; }' --toplevel # tls quad9 stays
|
||||||
|
jarl option unset tls cloudflare --toplevel # statements with a name: say which
|
||||||
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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`option set` replaces the option of that name. Statements told apart by
|
||||||
|
the name after the keyword (`tls NAME`, `key NAME`, `remote-servers NAME`,
|
||||||
|
`http NAME`, `dnssec-policy NAME`, `server ADDRESS`, ...) replace only the
|
||||||
|
one with that name; `--add` adds another instance of any other option that
|
||||||
|
may occur several times. Forwarding to Quad9 or Cloudflare over TLS:
|
||||||
|
[encrypted-dns.md](encrypted-dns.md#forwarding-over-tls).
|
||||||
|
|
||||||
Options that end in a list (`allow-*`, `forwarders`, `listen-on`, ...) may
|
Options that end in a list (`allow-*`, `forwarders`, `listen-on`, ...) may
|
||||||
be given without the braces: elements are separated by `;`, `,` or spaces,
|
be given without the braces: elements are separated by `;`, `,` or spaces,
|
||||||
and `key NAME` and `port N` stay with their element. Options are always
|
and `key NAME` and `port N` stay with their element. Options are always
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -250,6 +258,11 @@ boundary. So:
|
|||||||
(RFC 2317 delegation, where an ISP hands you part of a /24, is set up by
|
(RFC 2317 delegation, where an ISP hands you part of a /24, is set up by
|
||||||
hand with CNAMEs.)
|
hand with CNAMEs.)
|
||||||
|
|
||||||
|
Jarl suggests the reverse zones your private views lack, creates them in
|
||||||
|
the right view filled with PTR records, and can keep PTR records in step
|
||||||
|
with a zone's A and AAAA records: [reverse.md](reverse.md). A zone created
|
||||||
|
inside another (a /24 inside a /8) takes the parent's records under it.
|
||||||
|
|
||||||
### Dynamic updates and TSIG keys
|
### Dynamic updates and TSIG keys
|
||||||
|
|
||||||
Jarl writes every zone file, so it can't let named change a zone through
|
Jarl writes every zone file, so it can't let named change a zone through
|
||||||
|
|||||||
+123
-17
@@ -14,10 +14,15 @@ host name's address records in your private views.
|
|||||||
In the web UI: **Encrypted DNS** (administrators). In the CLI: `jarl
|
In the web UI: **Encrypted DNS** (administrators). In the CLI: `jarl
|
||||||
encrypted-dns`. In the API: `/api/v1/encrypted-dns`.
|
encrypted-dns`. In the API: `/api/v1/encrypted-dns`.
|
||||||
|
|
||||||
|
That is encryption between your clients and this server. For encryption
|
||||||
|
between this server and the resolvers it forwards to (Quad9, Cloudflare),
|
||||||
|
see [Forwarding over TLS](#forwarding-over-tls) below.
|
||||||
|
|
||||||
## Setting it up
|
## Setting it up
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
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 --address 2a02:8013:7f3d:10::5
|
||||||
|
jarl encrypted-dns setup --hostname dns.adbraun.lol --address 10.0.10.5=internal --address 203.0.113.5=external
|
||||||
```
|
```
|
||||||
|
|
||||||
This:
|
This:
|
||||||
@@ -47,25 +52,37 @@ This:
|
|||||||
`listen-on` statement at all. The first one, for DoT, would end that, so
|
`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
|
when you have none the setup adds `listen-on { any; };` (and the same
|
||||||
for IPv6) to keep plain DNS where it was.
|
for IPv6) to keep plain DNS where it was.
|
||||||
4. **adds the host name's A and AAAA records** with the chosen addresses,
|
4. **adds the host names' A and AAAA records**, each address in the views
|
||||||
in the private views that have a zone for it (`--view` to choose). In a
|
you give it: `--address 10.0.10.5=internal,lab`, or every address in
|
||||||
linked zone they are local to the view, so the name doesn't reach your
|
the views of `--view` (default: the private views that have a zone for
|
||||||
external view.
|
the name). A view's clients should only be given an address they can
|
||||||
|
reach, so a private address in a public view gets a warning. The
|
||||||
|
records are local to their view (in a linked zone the other views keep
|
||||||
|
theirs), and a view you no longer give an address loses it; other
|
||||||
|
records of the names stay.
|
||||||
|
|
||||||
Running it again replaces the earlier setup (other addresses, ports, DoT
|
**Several host names** (`--hostname dns.adbraun.lol --hostname
|
||||||
or DoH only with `--no-doh` / `--no-dot`). `jarl encrypted-dns disable`
|
doh.adbraun.lol`, or comma separated): each gets the records, the
|
||||||
takes the listeners and the `tls` statement out; the certificate and the
|
certificate must be for all of them (a new one is obtained with all of
|
||||||
records stay.
|
them), and the health check says when the certificate named serves misses
|
||||||
|
one. Clients may use any of them; the client hints use the first.
|
||||||
|
|
||||||
|
Running it again replaces the earlier setup: other addresses, views, host
|
||||||
|
names, **ports** (clients set up with another port need theirs changed
|
||||||
|
too), DoT or DoH only with `--no-doh` / `--no-dot`. The web UI's form
|
||||||
|
starts from the current setup, with each address's views as its records
|
||||||
|
are now. `jarl encrypted-dns disable` takes the listeners and the `tls`
|
||||||
|
statement out; the certificate and the records stay.
|
||||||
|
|
||||||
| Option | Default | |
|
| Option | Default | |
|
||||||
|--|--|--|
|
|--|--|--|
|
||||||
| `--hostname` | | The name clients connect to; the certificate is for it |
|
| `--hostname` | | A name clients connect to (repeat for several); the certificate is for all of them |
|
||||||
| `--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 |
|
| `--address` | | This server's addresses to listen on (repeat), with the views that get its records after `=` (`10.0.10.5=internal`). 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 |
|
| `--certificate` | one for the host name, obtained if needed | A certificate Jarl manages |
|
||||||
| `--no-dot`, `--no-doh` | both on | |
|
| `--no-dot`, `--no-doh` | both on | |
|
||||||
| `--dot-port` | 853 | Clients expect 853 |
|
| `--dot-port` | 853 | Clients expect 853 |
|
||||||
| `--doh-port` | 443 | Any free port; clients take it in the URL |
|
| `--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 |
|
| `--view` | private views with the zone | Where every address's records go, when `--address` gives no views |
|
||||||
|
|
||||||
In the web UI the same form checks the ports as you choose, offers the
|
In the web UI the same form checks the ports as you choose, offers the
|
||||||
free port, and obtains the certificate before setting up.
|
free port, and obtains the certificate before setting up.
|
||||||
@@ -94,14 +111,21 @@ For DoH clients: https://dns.adbraun.lol/dns-query
|
|||||||
| Chrome, Edge | Settings → Privacy and security → Security → Use secure DNS → 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 |
|
| 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:
|
Test from a machine on your network, with BIND's own `dig` (9.18 or
|
||||||
|
later; `bind9-dnsutils` on Debian):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
kdig +tls-ca +tls-host=dns.adbraun.lol @10.0.10.5 example.com
|
dig +tls +tls-ca +tls-hostname=dns.adbraun.lol @10.0.10.5 example.com # DoT, port 853
|
||||||
dig +https @10.0.10.5 example.com
|
dig +https +tls-ca +tls-hostname=dns.adbraun.lol @10.0.10.5 example.com # DoH, port 443, /dns-query
|
||||||
curl -s -H 'accept: application/dns-json' 'https://dns.adbraun.lol/dns-query?name=example.com'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`+tls-ca` checks the certificate against the system's CAs and
|
||||||
|
`+tls-hostname` against the host name, as clients do; add `-p PORT` for
|
||||||
|
another port. Without `+tls-ca` the query is still encrypted, but the
|
||||||
|
certificate isn't checked. named's DoH speaks the standard wire format
|
||||||
|
(RFC 8484), not the JSON API some public resolvers also offer, so
|
||||||
|
`curl ... accept: application/dns-json` doesn't work against it.
|
||||||
|
|
||||||
**Android away from home.** Private DNS in hostname mode applies on every
|
**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
|
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
|
resolves nothing at all ("Private DNS server cannot be accessed"). Use a
|
||||||
@@ -113,6 +137,87 @@ 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.
|
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.
|
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 |
|
||||||
|
|
||||||
|
## Forwarding over TLS
|
||||||
|
|
||||||
|
When a view forwards its recursive queries (to Quad9, Cloudflare, your
|
||||||
|
ISP's resolver), named can send them over TLS, so your ISP doesn't see the
|
||||||
|
names you look up: only connections to the forwarder on port 853. It takes
|
||||||
|
two options, a `tls` statement for each provider (the name its certificate
|
||||||
|
must have) and the view's `forwarders` using it. Stage them in one
|
||||||
|
changeset, so one revert undoes them:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
CS=$(jarl changeset create "internal: forward to Quad9 over TLS" -q)
|
||||||
|
jarl option set tls 'quad9 { remote-hostname "dns.quad9.net"; ca-file "/etc/ssl/certs/ca-certificates.crt"; }' --toplevel -C $CS
|
||||||
|
jarl option set forwarders '{ 9.9.9.9 port 853 tls quad9; 149.112.112.112 port 853 tls quad9; 2620:fe::fe port 853 tls quad9; 2620:fe::9 port 853 tls quad9; }' --view internal -C $CS
|
||||||
|
jarl option set forward only --view internal -C $CS
|
||||||
|
jarl changeset diff $CS # what named.conf gets
|
||||||
|
jarl changeset apply $CS
|
||||||
|
# undo: jarl changeset apply $(jarl changeset revert $CS -q)
|
||||||
|
```
|
||||||
|
|
||||||
|
* **`remote-hostname` and `ca-file`** make named check the forwarder's
|
||||||
|
certificate against that name (Debian's CA bundle at that path). Without
|
||||||
|
them the connection is encrypted but anyone in between could answer.
|
||||||
|
* **`forward only`** stops named falling back to plain DNS (to the root
|
||||||
|
servers) when the forwarders don't answer: lookups then fail instead
|
||||||
|
(SERVFAIL), which you notice, rather than quietly going unencrypted.
|
||||||
|
* **Several providers**: a `tls` statement each, and their addresses in one
|
||||||
|
`forwarders` list. Setting `tls cloudflare` doesn't touch `tls quad9`:
|
||||||
|
`jarl option set` replaces only the statement of that name.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl option set tls 'cloudflare { remote-hostname "cloudflare-dns.com"; ca-file "/etc/ssl/certs/ca-certificates.crt"; }' --toplevel
|
||||||
|
jarl option set forwarders '{ 9.9.9.9 port 853 tls quad9; 149.112.112.112 port 853 tls quad9; 1.1.1.1 port 853 tls cloudflare; 1.0.0.1 port 853 tls cloudflare; 2606:4700:4700::1111 port 853 tls cloudflare; 2606:4700:4700::1001 port 853 tls cloudflare; }' --view internal
|
||||||
|
```
|
||||||
|
|
||||||
|
named sends each query to whichever forwarder has answered fastest, not
|
||||||
|
in order, and the providers filter differently (Quad9 blocks known
|
||||||
|
malicious domains, Cloudflare's 1.1.1.1 doesn't; 1.1.1.2 does, with
|
||||||
|
`remote-hostname "security.cloudflare-dns.com"`).
|
||||||
|
* **DNS over HTTPS** can't be used for forwarding: BIND serves DoH to
|
||||||
|
clients but forwards only over plain DNS or TLS. For a DoH upstream, run
|
||||||
|
a local proxy (dnscrypt-proxy, cloudflared) and forward to it on
|
||||||
|
`127.0.0.1`.
|
||||||
|
* Providers write their DoT address as `tls://dns.quad9.net`: the name is
|
||||||
|
`remote-hostname`, and the addresses it resolves to go in `forwarders`
|
||||||
|
(named needs addresses there, not names).
|
||||||
|
|
||||||
|
**Checking it works**: Quad9 says how a query reached it.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
dig +short TXT proto.on.quad9.net @YOUR-SERVER # "dot": over TLS
|
||||||
|
```
|
||||||
|
|
||||||
|
`do53-udp` means plain DNS. `ss -tnp | grep :853` shows named's connections
|
||||||
|
only briefly: it closes them when idle. `tcpdump -ni any port 853` shows
|
||||||
|
the traffic as it happens.
|
||||||
|
|
||||||
## What Jarl checks
|
## What Jarl checks
|
||||||
|
|
||||||
* A `tls` statement whose files don't exist is an error
|
* A `tls` statement whose files don't exist is an error
|
||||||
@@ -125,7 +230,8 @@ Open the ports in the server's firewall for your own networks only.
|
|||||||
|
|
||||||
| Method | Path | Body |
|
| 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` |
|
| GET | `/encrypted-dns` | `enabled`, `certificate`, `hostnames` (and `hostname`, the first), `dot` and `doh` (address and port), `address_views` (per address, the views whose records give a host name it), `dot_host`, `doh_url`, `listeners`; for the form: `addresses` (this server's, with `private`), `views` (with `public`, and `has_zone`), `certificates`. `?hostname=a,b` asks about other names |
|
||||||
| POST | `/encrypted-dns/check` | `{"addresses", "dot", "doh", "dot_port", "doh_port"}`: `conflicts` (address, port, what) and `free_doh_port` |
|
| 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 |
|
| POST | `/encrypted-dns` | `{"hostnames", "certificate", "addresses", "address_views": {"10.0.10.5": ["internal"]}, "dot", "doh", "dot_port", "doh_port"}` (`"hostname"` and `"views"`, every address in each, work too): staged or applied like any change; 409 while the certificate hasn't been obtained |
|
||||||
| DELETE | `/encrypted-dns` | Turns it off |
|
| 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
|
||||||
|
|||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
# Reverse zones
|
||||||
|
|
||||||
|
A reverse zone answers "which host has this address?" with PTR records:
|
||||||
|
`20.10.0.10.in-addr.arpa. PTR nas.adbraun.lol.`. Your own clients use them
|
||||||
|
for names in logs, `traceroute`, the router's client list and `ssh`
|
||||||
|
warnings. For your private networks they only exist if this server has
|
||||||
|
them, and they are easy to forget, so Jarl can work them out from the
|
||||||
|
records you already have.
|
||||||
|
|
||||||
|
Who asks matters:
|
||||||
|
|
||||||
|
* **Your own clients** ask this server, so a reverse zone in a private
|
||||||
|
view (internal) answers them.
|
||||||
|
* **The internet** asks whoever owns the address: your ISP or hosting
|
||||||
|
provider for public addresses. Mail servers check those PTR records, and
|
||||||
|
you set them in the provider's panel, not in Jarl. The Exposure page
|
||||||
|
checks them ([exposure.md](exposure.md#mail)). A reverse zone for a
|
||||||
|
public address in a public view (external) does nothing unless the owner
|
||||||
|
delegated it to you.
|
||||||
|
|
||||||
|
So reverse zones belong in private views. A view whose clients reach it
|
||||||
|
from the internet gets no suggestions, and neither does a view you turn
|
||||||
|
them off for: a view only a few servers use, such as one for your VPS,
|
||||||
|
matches only their addresses and so counts as private, but its clients
|
||||||
|
don't need reverse zones (`jarl view update vps
|
||||||
|
--reverse-suggestions=false`, or the view's settings). Exposure doesn't
|
||||||
|
change.
|
||||||
|
|
||||||
|
## Missing reverse zones
|
||||||
|
|
||||||
|
The Zones page lists, under **Missing reverse zones**, the reverse zones a
|
||||||
|
private view lacks for the addresses of its A and AAAA records, in two
|
||||||
|
groups:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ jarl zone suggest-reverse
|
||||||
|
GROUP VIEW NETWORK ZONE PTRS FOR EXAMPLE
|
||||||
|
yours internal 10.0.10.0/24 10.0.10.in-addr.arpa 14 10.0.10.1 router.adbraun.lol, ...
|
||||||
|
yours internal 2a02:8013:7f3d::/48 d.3.f.7.3.1.0.8.2.0.a.2.ip6.arpa 9 2a02:8013:7f3d:10::5 nas.adbraun.lol, ...
|
||||||
|
public internal 139.144.144.154/32 154.144.144.139.in-addr.arpa 1 139.144.144.154 ldn.adbraun.lol
|
||||||
|
public internal 185.199.108.153/32 153.108.199.185.in-addr.arpa 1 185.199.108.153 blog.adbraun.lol
|
||||||
|
|
||||||
|
$ jarl zone suggest-reverse --create # yours, filled, in one changeset
|
||||||
|
$ jarl zone create 139.144.144.154/32 --view internal --fill # a public one that is yours
|
||||||
|
$ jarl zone suggest-reverse --dismiss 185.199.108.153/32 --view internal # GitHub's, not yours
|
||||||
|
```
|
||||||
|
|
||||||
|
**Yours**, created by **Create all** (`--create`):
|
||||||
|
|
||||||
|
* the **/24** of a private IPv4 address (10/8, 172.16/12, 192.168/16,
|
||||||
|
100.64/10);
|
||||||
|
* the **/48** of a ULA (fd00::/8), or of a public IPv6 prefix this server
|
||||||
|
has an address in (the /48 your ISP delegates): one zone for all its
|
||||||
|
subnets. If you have a /56, create that instead (`jarl zone create
|
||||||
|
2a02:db8:1:ab00::/56`).
|
||||||
|
|
||||||
|
**Public addresses, only if they're yours**: any other public address,
|
||||||
|
each as a /32 (or /128) of its own. Jarl can't tell your VPS from a web
|
||||||
|
host's server (a `blog` record pointing at GitHub Pages), and a zone for
|
||||||
|
someone else's address would show your clients your name for it instead
|
||||||
|
of theirs. The rest of the /24 belongs to others: a /24 zone here would
|
||||||
|
answer NXDOMAIN to your clients for all of their addresses. Create the
|
||||||
|
ones that are yours one by one, and **Dismiss** the others: Jarl
|
||||||
|
remembers, per view (`--undismiss` brings one back).
|
||||||
|
|
||||||
|
There can be hundreds of public addresses (records pointing at a web
|
||||||
|
host or a CDN), so the group is folded away when it is long. Opened, it
|
||||||
|
has a search and shows 25 at a time; **Dismiss these N** dismisses every
|
||||||
|
address the search found, in one change: search `104.16.` and dismiss
|
||||||
|
the CDN's addresses together. From the CLI, `--dismiss` takes several
|
||||||
|
networks (comma separated or repeated). The New reverse zone list has a
|
||||||
|
search too.
|
||||||
|
|
||||||
|
Loopback and link-local addresses, wildcards and disabled records are left
|
||||||
|
out.
|
||||||
|
|
||||||
|
## Creating one by hand
|
||||||
|
|
||||||
|
Where nothing is suggested (a view with suggestions off, a dismissed
|
||||||
|
network, a public view) or the suggested size isn't the one you want,
|
||||||
|
**New reverse zone** on the Zones page lists every network your records
|
||||||
|
use that has no reverse zone, in every view, marked when it isn't
|
||||||
|
suggested unasked. Pick one: the network and the view are filled in.
|
||||||
|
Then the **Size** buttons change it, around the address of one of the
|
||||||
|
records: a suggested `2a02:8013:7f3d::/48` becomes
|
||||||
|
`2a02:8013:7f3d:10::/64` for the subnet the records are in, or `/128`
|
||||||
|
for one address. The zone name shows as you go, and **PTR records** stays
|
||||||
|
ticked, so the zone is created with a PTR record for each address
|
||||||
|
(IPv6 nibble names written for you).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl zone suggest-reverse --all --view vps # every network, hidden ones marked
|
||||||
|
jarl zone create 2a02:8013:7f3d:10::/64 --view internal --fill
|
||||||
|
```
|
||||||
|
|
||||||
|
**Records in another view.** With a view chosen (`--view internal`, or
|
||||||
|
the dialog's View), the list also has the addresses only other views'
|
||||||
|
records use, marked "records in external": say a VPS's AAAA record that
|
||||||
|
only the external view has. Fill names those from the other view's
|
||||||
|
record, when the zone's own view has none for the address, and says so.
|
||||||
|
Keeping PTR records in step (below) stays within a zone's own view and
|
||||||
|
its linked views: a later change to that external record doesn't reach
|
||||||
|
the internal reverse zone.
|
||||||
|
|
||||||
|
## Creating and filling
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl zone create 10.0.10.0/24 --fill # the view whose records use 10.0.10.x
|
||||||
|
jarl zone fill-ptr 10.0.10.0/24 # later: add what is missing
|
||||||
|
```
|
||||||
|
|
||||||
|
**The view.** A reverse zone created without `--view` goes to the private
|
||||||
|
view whose records use addresses in its network (views with suggestions
|
||||||
|
off don't count). If none does, it goes to the only private view. If
|
||||||
|
several do, Jarl asks you to name the view, or to create the zone in each.
|
||||||
|
|
||||||
|
**Inside another zone.** A zone created inside an existing one in the same
|
||||||
|
view, a /24 inside a /8 or `lab.adbraun.lol` inside `adbraun.lol`, takes
|
||||||
|
the parent's records under it, in the same changeset: named answers those
|
||||||
|
names from the new zone, so left in the parent they would never be served
|
||||||
|
again. PTR records you added by hand come along; `--fill` then adds only
|
||||||
|
what is still missing. A delegation (NS records at the new zone's name,
|
||||||
|
with their glue) stays in the parent. A linked parent keeps its records,
|
||||||
|
with a warning, since its other views would lose them too.
|
||||||
|
|
||||||
|
**Fill** adds a PTR record for every address in the network that the
|
||||||
|
view's A and AAAA records use. An address with several names gets one PTR:
|
||||||
|
|
||||||
|
1. the name of a mail or name server (an MX or NS target), else
|
||||||
|
2. a host's name rather than the domain's own (`nas.adbraun.lol`, not
|
||||||
|
`adbraun.lol`), else
|
||||||
|
3. the shortest name, then the first alphabetically.
|
||||||
|
|
||||||
|
Jarl says which name it chose, and which it didn't, so you can change it.
|
||||||
|
A reverse zone's page shows how many addresses still lack a PTR, with a
|
||||||
|
button to add them. `jarl zone show` shows it as `Missing PTRs`.
|
||||||
|
|
||||||
|
## Keeping PTR records in step
|
||||||
|
|
||||||
|
The **Update PTR** box on an A or AAAA record (`--ptr`) moves the PTR record
|
||||||
|
along with that one change. To have that on every change, turn it on for
|
||||||
|
the forward zone:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl zone update adbraun.lol --view internal --auto-ptr # off: --auto-ptr=false
|
||||||
|
```
|
||||||
|
|
||||||
|
(Zone settings → **Reverse DNS** in the web UI.) Then adding, changing,
|
||||||
|
disabling, enabling or deleting an A or AAAA record in the zone does the
|
||||||
|
same to its PTR record, in the reverse zones of the zone's view and of the
|
||||||
|
views it is linked with ([views.md](views.md#linked-zones)). A whole set
|
||||||
|
replaced at once (`jarl record set`, a batch) works too.
|
||||||
|
|
||||||
|
* **One name per address.** When an address's PTR record names another
|
||||||
|
host, it stays: a second A record for the same address doesn't add a
|
||||||
|
second PTR record.
|
||||||
|
* **No reverse zone, no PTR.** An address without a reverse zone in the
|
||||||
|
view, such as a public address in the external view, is skipped without
|
||||||
|
a warning. The suggestions above show what is missing.
|
||||||
|
* **Wildcards** (`*.dev`) never get a PTR record.
|
||||||
|
|
||||||
|
The PTR changes are part of the same changeset, so you review them, and
|
||||||
|
they are applied or undone together with the record.
|
||||||
@@ -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
|
||||||
@@ -69,6 +70,7 @@ stats:
|
|||||||
minute: 7d # per-minute counters
|
minute: 7d # per-minute counters
|
||||||
hour: 90d # hourly counters and top lists
|
hour: 90d # hourly counters and top lists
|
||||||
day: 730d # daily counters and top lists
|
day: 730d # daily counters and top lists
|
||||||
|
queries: 7d # every answer, for exploring (0: none)
|
||||||
top_n: 100 # names and clients kept per hour and per day
|
top_n: 100 # names and clients kept per hour and per day
|
||||||
anonymize_clients: false # store 10.10.0.0/24 instead of 10.10.0.37 (IPv6: /48)
|
anonymize_clients: false # store 10.10.0.0/24 instead of 10.10.0.37 (IPv6: /48)
|
||||||
exclude_clients: [] # e.g. [10.10.0.250] to ignore a monitoring probe
|
exclude_clients: [] # e.g. [10.10.0.250] to ignore a monitoring probe
|
||||||
@@ -81,6 +83,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 +193,50 @@ 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.
|
||||||
|
|
||||||
|
## Exploring
|
||||||
|
|
||||||
|
The counters and top lists say how much, not who asked for what: "100
|
||||||
|
NXDOMAIN answers in the external view" can't tell you which clients asked.
|
||||||
|
For that, jarld also keeps every answer for a while, the **query log**: time,
|
||||||
|
client, view, name, record type, result, and the blocklist (or your rules)
|
||||||
|
that blocked it. It is in the statistics database, from the response log
|
||||||
|
named writes anyway, and answers older than `retention.queries` (default
|
||||||
|
7 days) are deleted every hour; `0` keeps none.
|
||||||
|
|
||||||
|
With it, every client, name, view, record type, result and blocklist on the
|
||||||
|
Statistics page is a link to the **explore** page (`#/stats/explore`),
|
||||||
|
which shows, for what you clicked:
|
||||||
|
|
||||||
|
* the answers over time (and how many were blocked);
|
||||||
|
* the top clients, names, views, record types, results and blocklists
|
||||||
|
among them, each a link that narrows the filter further;
|
||||||
|
* the latest 100 answers, every cell a link.
|
||||||
|
|
||||||
|
So "external, NXDOMAIN" leads to the clients asking, a client to everything
|
||||||
|
it asks, one of its names to who else asks for it. The page also opens
|
||||||
|
from **Explore the answers** at the top of the Statistics page, and has a
|
||||||
|
filter bar to type filters in: clients, names, views, record types,
|
||||||
|
results, blocked or not; it suggests values from what is shown.
|
||||||
|
|
||||||
|
Each kind of filter takes several values, comma separated, and matches
|
||||||
|
any of them: `10.0.0.5, 10.0.0.7` is either client. `!value` leaves one
|
||||||
|
out (`!10.0.0.250` hides a monitoring probe), and `*.example.com` is the
|
||||||
|
name and every name below it, so `!*.apple.com` drops a chatty device's
|
||||||
|
noise. Kinds combine: this client, in this view, NXDOMAIN, not that name.
|
||||||
|
In the lists, clicking a value adds it and **hide** leaves it out; every
|
||||||
|
value has a chip with a ✕ to take it out again. Filters combine (this
|
||||||
|
client, in this view, blocked only) and each has a ✕ to take it out. A name
|
||||||
|
can be widened to the names below it (`domain=`). The Statistics page's
|
||||||
|
view switches apply here too.
|
||||||
|
|
||||||
|
`anonymize_clients` and `exclude_clients` apply to the query log as to
|
||||||
|
everything else. Its size: each answer takes about 150 to 200 bytes with
|
||||||
|
its indexes, so 50,000 answers a day for 7 days is about 60 MB, 200,000 a
|
||||||
|
day about 250 MB. The Statistics page's server section shows the
|
||||||
|
statistics database's size and how many answers the query log holds, next
|
||||||
|
to the disks (with a bar of how full each is); the dashboard's Storage
|
||||||
|
panel has all of Jarl's databases.
|
||||||
|
|
||||||
## Views
|
## Views
|
||||||
|
|
||||||
With views, the Statistics page has a toggle per view (and **All** and
|
With views, the Statistics page has a toggle per view (and **All** and
|
||||||
|
|||||||
@@ -209,6 +209,9 @@ 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
|
keeps every record, all local; a link left with one zone is dissolved, as
|
||||||
when the other zone is deleted.
|
when the other zone is deleted.
|
||||||
|
|
||||||
|
`--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
|
**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
|
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
|
records and make overrides, but not a shared record, since that would
|
||||||
@@ -229,6 +232,54 @@ 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
|
don't depend on the view order (`in-view` can only use a view defined
|
||||||
before it).
|
before it).
|
||||||
|
|
||||||
|
## Comparing views
|
||||||
|
|
||||||
|
A zone's **Compare views** tab, or `jarl zone diff`, compares it with the
|
||||||
|
same zone in another view (or all of them), from the view you're on:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ jarl zone diff adbraun.lol --view internal
|
||||||
|
adbraun.lol in internal, compared with external
|
||||||
|
linked: a difference is a view's own records, there on purpose
|
||||||
|
external: 1 answered differently, 24 only in internal, 2 only in external, 31 the same
|
||||||
|
|
||||||
|
ANSWERED DIFFERENTLY (1)
|
||||||
|
nas A
|
||||||
|
internal 10.0.10.20 [local, id 812]
|
||||||
|
external 203.0.113.20 [local, id 790]
|
||||||
|
|
||||||
|
ONLY IN INTERNAL (24)
|
||||||
|
printer A 10.0.10.30
|
||||||
|
router A 10.0.10.1
|
||||||
|
...
|
||||||
|
|
||||||
|
ONLY IN EXTERNAL (2)
|
||||||
|
_acme-challenge TXT "…"
|
||||||
|
...
|
||||||
|
|
||||||
|
31 names and types are the same (--all lists them)
|
||||||
|
```
|
||||||
|
|
||||||
|
* **Answered differently**: names and types more than one view has, with
|
||||||
|
different records. In linked zones that is a view's own (local)
|
||||||
|
records, there on purpose; between zones that aren't linked, it is a
|
||||||
|
difference that crept in. **Use this everywhere** gives every view one
|
||||||
|
view's records (in linked zones by sharing them: `jarl record update ID
|
||||||
|
--shared`).
|
||||||
|
* **Only in …**: names and types some views lack. **Add to external**
|
||||||
|
gives that view the same records, its own.
|
||||||
|
* **The same**: how many; `--all` (or "List them") shows them.
|
||||||
|
|
||||||
|
One line per record: the CLI cuts long values to fit the terminal
|
||||||
|
(`--wide` shows them whole); the web UI shows the whole value on hover.
|
||||||
|
The apex NS records are left out, since views often list their name
|
||||||
|
servers differently on purpose.
|
||||||
|
|
||||||
|
**One name.** `jarl zone diff adbraun.lol nas` (and a type: `... @ TXT`)
|
||||||
|
shows just that name in every view, the same records included. In the
|
||||||
|
web UI, a record's **Compare** button does the same. `--with external`
|
||||||
|
(repeat it) picks the views to compare with.
|
||||||
|
|
||||||
## 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`
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ type Zone struct {
|
|||||||
LinkedWith []string `json:"linked_with,omitempty"`
|
LinkedWith []string `json:"linked_with,omitempty"`
|
||||||
// Links are the zones this one is linked with, with their views.
|
// Links are the zones this one is linked with, with their views.
|
||||||
Links []ZoneLink `json:"links,omitempty"`
|
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"`
|
||||||
|
// Prefix is the network a reverse zone answers for; PTRMissing how
|
||||||
|
// many of its view's A and AAAA addresses in it have no PTR record
|
||||||
|
// (POST /zones/{zone}/fill-ptr adds them).
|
||||||
|
Prefix string `json:"prefix,omitempty"`
|
||||||
|
PTRMissing int `json:"ptr_missing,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"`
|
||||||
}
|
}
|
||||||
@@ -266,6 +274,40 @@ 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"`
|
||||||
|
// Storage is the size of Jarl's databases and how full the disks Jarl
|
||||||
|
// uses are (administrators only).
|
||||||
|
Storage *Storage `json:"storage,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage is the size of Jarl's databases and the disks Jarl uses.
|
||||||
|
type Storage struct {
|
||||||
|
Databases []Database `json:"databases"`
|
||||||
|
Total int64 `json:"total"` // the databases' bytes
|
||||||
|
Disks []Disk `json:"disks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database is one of Jarl's databases: its file with its -wal and -shm.
|
||||||
|
type Database struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
// Rows is how many answers the query log holds (statistics only),
|
||||||
|
// and QueryLogRetention how long it keeps them ("7d").
|
||||||
|
QueryLogRows *int64 `json:"query_log_rows,omitempty"`
|
||||||
|
QueryLogRetention string `json:"query_log_retention,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disk is a file system Jarl uses: what of Jarl's it holds, its size,
|
||||||
|
// what is free, and how much of it the databases take.
|
||||||
|
type Disk struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Holds []string `json:"holds"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Free int64 `json:"free"`
|
||||||
|
Databases int64 `json:"databases"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CertSummary is what the dashboard reports about certificates.
|
// CertSummary is what the dashboard reports about certificates.
|
||||||
@@ -575,3 +617,11 @@ type ZoneLink struct {
|
|||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
View string `json:"view"`
|
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"
|
||||||
@@ -766,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
|
||||||
|
|||||||
@@ -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"`
|
||||||
@@ -391,6 +395,9 @@ type StatsRetention struct {
|
|||||||
Minute Duration `yaml:"minute"`
|
Minute Duration `yaml:"minute"`
|
||||||
Hour Duration `yaml:"hour"`
|
Hour Duration `yaml:"hour"`
|
||||||
Day Duration `yaml:"day"`
|
Day Duration `yaml:"day"`
|
||||||
|
// Queries is how long the query log keeps every answer, for exploring
|
||||||
|
// (docs/stats.md#exploring); 0 keeps none.
|
||||||
|
Queries Duration `yaml:"queries"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Duration is a time.Duration written as "15s" or "12h" in YAML. Days
|
// Duration is a time.Duration written as "15s" or "12h" in YAML. Days
|
||||||
@@ -451,12 +458,13 @@ 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",
|
||||||
LogSize: "20m",
|
LogSize: "20m",
|
||||||
LogVersions: 2,
|
LogVersions: 2,
|
||||||
Retention: StatsRetention{Minute: Duration(7 * 24 * time.Hour), Hour: Duration(90 * 24 * time.Hour), Day: Duration(730 * 24 * time.Hour)},
|
Retention: StatsRetention{Minute: Duration(7 * 24 * time.Hour), Hour: Duration(90 * 24 * time.Hour), Day: Duration(730 * 24 * time.Hour), Queries: Duration(7 * 24 * time.Hour)},
|
||||||
TopN: 100,
|
TopN: 100,
|
||||||
},
|
},
|
||||||
ACME: ACME{HTTPReq: HTTPReq{RequireHTTPS: true}, Account: ACMEAccount{Directory: "letsencrypt"},
|
ACME: ACME{HTTPReq: HTTPReq{RequireHTTPS: true}, Account: ACMEAccount{Directory: "letsencrypt"},
|
||||||
@@ -599,6 +607,9 @@ func (s *Stats) check() error {
|
|||||||
if r.Minute < Duration(time.Hour) || r.Hour < Duration(24*time.Hour) || r.Day < Duration(24*time.Hour) {
|
if r.Minute < Duration(time.Hour) || r.Hour < Duration(24*time.Hour) || r.Day < Duration(24*time.Hour) {
|
||||||
return fmt.Errorf("retention must keep minutes for at least 1h, hours and days for at least 1d")
|
return fmt.Errorf("retention must keep minutes for at least 1h, hours and days for at least 1d")
|
||||||
}
|
}
|
||||||
|
if r.Queries < 0 || (r.Queries > 0 && r.Queries < Duration(time.Hour)) {
|
||||||
|
return fmt.Errorf("retention.queries must be at least 1h, or 0 for no query log")
|
||||||
|
}
|
||||||
if s.TopN < 10 || s.TopN > 10000 {
|
if s.TopN < 10 || s.TopN > 10000 {
|
||||||
return fmt.Errorf("top_n must be between 10 and 10000")
|
return fmt.Errorf("top_n must be between 10 and 10000")
|
||||||
}
|
}
|
||||||
@@ -736,6 +747,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
|
||||||
@@ -745,6 +760,7 @@ stats:
|
|||||||
minute: 7d # per-minute counters
|
minute: 7d # per-minute counters
|
||||||
hour: 90d # hourly counters and top lists
|
hour: 90d # hourly counters and top lists
|
||||||
day: 730d # daily counters and top lists
|
day: 730d # daily counters and top lists
|
||||||
|
queries: 7d # every answer, to explore (who asked what); 0: none
|
||||||
top_n: 100 # names and clients kept per hour and per day
|
top_n: 100 # names and clients kept per hour and per day
|
||||||
anonymize_clients: false # store 10.10.0.0/24 instead of 10.10.0.37
|
anonymize_clients: false # store 10.10.0.0/24 instead of 10.10.0.37
|
||||||
exclude_clients: [] # e.g. [127.0.0.1] to ignore local monitoring
|
exclude_clients: [] # e.g. [127.0.0.1] to ignore local monitoring
|
||||||
|
|||||||
@@ -469,3 +469,47 @@ func BestZone(name string, zones []string) string {
|
|||||||
}
|
}
|
||||||
return best
|
return best
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PrefixFromReverse returns the network a reverse zone answers for:
|
||||||
|
// 10.0.10.in-addr.arpa. is 10.0.10.0/24, a /32 zone a single address, and
|
||||||
|
// an ip6.arpa zone of 12 nibbles a /48. False for names that aren't whole
|
||||||
|
// octets or nibbles (classless delegations such as 0-63.1.0.10.in-addr.arpa.).
|
||||||
|
func PrefixFromReverse(zone string) (netip.Prefix, bool) {
|
||||||
|
zone = strings.ToLower(Fqdn(zone))
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(zone, ".in-addr.arpa."):
|
||||||
|
labels := strings.Split(strings.TrimSuffix(zone, ".in-addr.arpa."), ".")
|
||||||
|
if len(labels) > 4 {
|
||||||
|
return netip.Prefix{}, false
|
||||||
|
}
|
||||||
|
var b [4]byte
|
||||||
|
for i, l := range labels {
|
||||||
|
v, err := strconv.Atoi(l)
|
||||||
|
if err != nil || v < 0 || v > 255 || strconv.Itoa(v) != l {
|
||||||
|
return netip.Prefix{}, false
|
||||||
|
}
|
||||||
|
b[len(labels)-1-i] = byte(v)
|
||||||
|
}
|
||||||
|
return netip.PrefixFrom(netip.AddrFrom4(b), 8*len(labels)), true
|
||||||
|
case strings.HasSuffix(zone, ".ip6.arpa."):
|
||||||
|
labels := strings.Split(strings.TrimSuffix(zone, ".ip6.arpa."), ".")
|
||||||
|
if len(labels) > 32 {
|
||||||
|
return netip.Prefix{}, false
|
||||||
|
}
|
||||||
|
var b [16]byte
|
||||||
|
for i, l := range labels {
|
||||||
|
v := strings.IndexByte("0123456789abcdef", l[0])
|
||||||
|
if len(l) != 1 || v < 0 {
|
||||||
|
return netip.Prefix{}, false
|
||||||
|
}
|
||||||
|
n := len(labels) - 1 - i // nibble position from the left
|
||||||
|
if n%2 == 0 {
|
||||||
|
b[n/2] |= byte(v) << 4
|
||||||
|
} else {
|
||||||
|
b[n/2] |= byte(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return netip.PrefixFrom(netip.AddrFrom16(b), 4*len(labels)), true
|
||||||
|
}
|
||||||
|
return netip.Prefix{}, false
|
||||||
|
}
|
||||||
@@ -217,3 +217,35 @@ func TestReverseZones(t *testing.T) {
|
|||||||
t.Errorf("ReverseZone /22: %v", err)
|
t.Errorf("ReverseZone /22: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPrefixFromReverse(t *testing.T) {
|
||||||
|
for zone, want := range map[string]string{
|
||||||
|
"10.0.10.in-addr.arpa.": "10.0.10.0/24",
|
||||||
|
"10.in-addr.arpa": "10.0.0.0/8",
|
||||||
|
"154.144.144.139.in-addr.arpa.": "139.144.144.154/32",
|
||||||
|
"d.3.f.7.3.1.0.8.2.0.a.2.ip6.arpa.": "2a02:8013:7f3d::/48",
|
||||||
|
"b.a.b.a.b.a.b.a.b.a.d.f.ip6.arpa.": "fdab:abab:abab::/48",
|
||||||
|
"0-63.1.0.10.in-addr.arpa.": "",
|
||||||
|
"example.com.": "",
|
||||||
|
"256.10.in-addr.arpa.": "",
|
||||||
|
} {
|
||||||
|
p, ok := PrefixFromReverse(zone)
|
||||||
|
got := ""
|
||||||
|
if ok {
|
||||||
|
got = p.String()
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("PrefixFromReverse(%q) = %q, want %q", zone, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Round trip with ReverseZone.
|
||||||
|
for _, s := range []string{"192.168.1.0/24", "2a02:8013:7f3d::/48", "fd00::/8", "10.0.0.5/32"} {
|
||||||
|
z, err := ReverseZone(netip.MustParsePrefix(s))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if p, ok := PrefixFromReverse(z); !ok || p.String() != s {
|
||||||
|
t.Errorf("%s -> %s -> %v", s, z, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+192
-28
@@ -9,6 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -208,8 +209,10 @@ func GetStatus(st *model.State, certDir string) Status {
|
|||||||
|
|
||||||
// Setup is what to set up.
|
// Setup is what to set up.
|
||||||
type Setup struct {
|
type Setup struct {
|
||||||
// Hostname is the name clients connect to (the certificate's).
|
// Hostnames are the names clients connect to; the certificate must be
|
||||||
Hostname string `json:"hostname"`
|
// for all of them. Hostname (one) works too.
|
||||||
|
Hostnames []string `json:"hostnames,omitempty"`
|
||||||
|
Hostname string `json:"hostname,omitempty"`
|
||||||
// Certificate is the Jarl certificate (with the dns use) to serve.
|
// Certificate is the Jarl certificate (with the dns use) to serve.
|
||||||
Certificate string `json:"certificate"`
|
Certificate string `json:"certificate"`
|
||||||
// Addresses are the addresses of this server to listen on.
|
// Addresses are the addresses of this server to listen on.
|
||||||
@@ -218,9 +221,27 @@ type Setup struct {
|
|||||||
DoH bool `json:"doh"`
|
DoH bool `json:"doh"`
|
||||||
DoHPort int `json:"doh_port"` // default 443
|
DoHPort int `json:"doh_port"` // default 443
|
||||||
DoTPort int `json:"dot_port"` // default 853 (clients expect it)
|
DoTPort int `json:"dot_port"` // default 853 (clients expect it)
|
||||||
// Views get the hostname's A/AAAA records (default: none). In a
|
// AddressViews says, per listen address, the views where the host
|
||||||
// linked zone they are local to the view.
|
// names get it (A or AAAA): a private address shouldn't reach a public
|
||||||
Views []string `json:"views"`
|
// view. Views (every address in each) works too. The records are local
|
||||||
|
// to their view in a linked zone.
|
||||||
|
AddressViews map[string][]string `json:"address_views,omitempty"`
|
||||||
|
Views []string `json:"views,omitempty"`
|
||||||
|
// PublicViews are the views the internet reaches (filled in by jarld,
|
||||||
|
// for a warning).
|
||||||
|
PublicViews map[string]bool `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hosts returns the setup's host names, normalized, the first first.
|
||||||
|
func (s Setup) Hosts() []string {
|
||||||
|
var out []string
|
||||||
|
for _, h := range append(append([]string{}, s.Hostnames...), s.Hostname) {
|
||||||
|
h = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(h), "."))
|
||||||
|
if h != "" && !slices.Contains(out, h) {
|
||||||
|
out = append(out, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plan returns the requests that set up encrypted DNS as s says,
|
// Plan returns the requests that set up encrypted DNS as s says,
|
||||||
@@ -248,10 +269,15 @@ func Plan(st *model.State, s Setup, certDir string) ([]engine.Request, []string,
|
|||||||
if s.Certificate == "" {
|
if s.Certificate == "" {
|
||||||
return nil, nil, fmt.Errorf("choose the certificate to serve")
|
return nil, nil, fmt.Errorf("choose the certificate to serve")
|
||||||
}
|
}
|
||||||
host := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(s.Hostname), "."))
|
hosts := s.Hosts()
|
||||||
if host == "" || !strings.Contains(host, ".") {
|
if len(hosts) == 0 {
|
||||||
return nil, nil, fmt.Errorf("give the host name clients connect to, e.g. dns.example.com")
|
return nil, nil, fmt.Errorf("give the host name clients connect to, e.g. dns.example.com")
|
||||||
}
|
}
|
||||||
|
for _, host := range hosts {
|
||||||
|
if !strings.Contains(host, ".") || strings.HasPrefix(host, "*") {
|
||||||
|
return nil, nil, fmt.Errorf("%q isn't a host name clients can connect to, e.g. dns.example.com", host)
|
||||||
|
}
|
||||||
|
}
|
||||||
var v4, v6 []string
|
var v4, v6 []string
|
||||||
for _, a := range s.Addresses {
|
for _, a := range s.Addresses {
|
||||||
ip, err := netip.ParseAddr(strings.TrimSpace(a))
|
ip, err := netip.ParseAddr(strings.TrimSpace(a))
|
||||||
@@ -272,9 +298,7 @@ func Plan(st *model.State, s Setup, certDir string) ([]engine.Request, []string,
|
|||||||
return nil, nil, fmt.Errorf("choose at least one address to listen on")
|
return nil, nil, fmt.Errorf("choose at least one address to listen on")
|
||||||
}
|
}
|
||||||
tlsName := Prefix + s.Certificate
|
tlsName := Prefix + s.Certificate
|
||||||
dir := filepath.Join(certDir, s.Certificate)
|
tlsValue := TLSValue(s.Certificate, certDir)
|
||||||
tlsValue := fmt.Sprintf(`%s { cert-file "%s"; key-file "%s"; protocols { TLSv1.2; TLSv1.3; }; }`,
|
|
||||||
tlsName, filepath.Join(dir, "fullchain.pem"), filepath.Join(dir, "privkey.pem"))
|
|
||||||
|
|
||||||
var reqs []engine.Request
|
var reqs []engine.Request
|
||||||
// Take out the earlier setup's listeners and tls statements.
|
// Take out the earlier setup's listeners and tls statements.
|
||||||
@@ -327,32 +351,98 @@ func Plan(st *model.State, s Setup, certDir string) ([]engine.Request, []string,
|
|||||||
add("listen-on", v4)
|
add("listen-on", v4)
|
||||||
add("listen-on-v6", v6)
|
add("listen-on-v6", v6)
|
||||||
|
|
||||||
// The host name's addresses, in the views asked for.
|
// The host names' addresses, in the views asked for, per address.
|
||||||
fqdn := host + "."
|
want := map[string][]string{} // view -> addresses
|
||||||
for _, vn := range s.Views {
|
all := append(append([]string{}, v4...), v6...)
|
||||||
v := st.ViewByName(vn)
|
if s.AddressViews != nil {
|
||||||
if v == nil {
|
for a, vs := range s.AddressViews {
|
||||||
|
ip, err := netip.ParseAddr(strings.TrimSpace(a))
|
||||||
|
if err != nil || !slices.Contains(all, ip.Unmap().String()) {
|
||||||
|
return nil, nil, fmt.Errorf("address_views: %s isn't one of the addresses to listen on", a)
|
||||||
|
}
|
||||||
|
for _, vn := range vs {
|
||||||
|
want[vn] = append(want[vn], ip.Unmap().String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for _, vn := range s.Views {
|
||||||
|
want[vn] = all
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for vn, addrs := range want {
|
||||||
|
if st.ViewByName(vn) == nil {
|
||||||
return nil, nil, fmt.Errorf("view %q does not exist", vn)
|
return nil, nil, fmt.Errorf("view %q does not exist", vn)
|
||||||
}
|
}
|
||||||
z := zoneFor(st, fqdn, v.ID)
|
if s.PublicViews[vn] {
|
||||||
if z == nil {
|
for _, a := range addrs {
|
||||||
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))
|
if ip, err := netip.ParseAddr(a); err == nil && Private(ip) {
|
||||||
continue
|
notes = append(notes, fmt.Sprintf("view %s is reachable from the internet, but gets the private address %s for %s: its clients can't reach that", vn, a, strings.Join(hosts, ", ")))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var local *bool
|
}
|
||||||
if z.LinkGroup != 0 {
|
// Ours are the addresses this setup (or the one before) gives the host
|
||||||
local = model.Ptr(true)
|
// names: a view that no longer gets one loses it; other records of the
|
||||||
|
// names stay.
|
||||||
|
ours := map[string]bool{}
|
||||||
|
for _, a := range all {
|
||||||
|
ours[a] = true
|
||||||
|
}
|
||||||
|
for _, l := range current {
|
||||||
|
if l.Managed() {
|
||||||
|
for _, a := range l.Addrs {
|
||||||
|
ours[a] = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, set := range []struct {
|
}
|
||||||
typ string
|
names := slices.Clone(hosts)
|
||||||
addrs []string
|
for _, h := range st.Settings.EncryptedDNSHosts {
|
||||||
}{{"A", v4}, {"AAAA", v6}} {
|
if !slices.Contains(names, h) {
|
||||||
if len(set.addrs) == 0 {
|
names = append(names, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, v := range st.OrderedViews() {
|
||||||
|
for _, host := range names {
|
||||||
|
fqdn := host + "."
|
||||||
|
z := zoneFor(st, fqdn, v.ID)
|
||||||
|
wanted := want[v.Name]
|
||||||
|
if !slices.Contains(hosts, host) {
|
||||||
|
wanted = nil // a host name no longer used
|
||||||
|
}
|
||||||
|
if z == nil {
|
||||||
|
if len(wanted) > 0 {
|
||||||
|
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
|
continue
|
||||||
}
|
}
|
||||||
reqs = append(reqs, engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: fqdn, Type: set.typ, Data: set.addrs, Local: local}))
|
for _, typ := range []string{"A", "AAAA"} {
|
||||||
|
var have, next []string
|
||||||
|
for _, r := range st.RRset(z.ID, fqdn, typ) {
|
||||||
|
have = append(have, r.Data)
|
||||||
|
if !ours[r.Data] {
|
||||||
|
next = append(next, r.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, a := range wanted {
|
||||||
|
if strings.Contains(a, ":") == (typ == "AAAA") && !slices.Contains(next, a) {
|
||||||
|
next = append(next, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.Sort(have)
|
||||||
|
slices.Sort(next)
|
||||||
|
if slices.Equal(have, next) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if next == nil {
|
||||||
|
next = []string{}
|
||||||
|
}
|
||||||
|
reqs = append(reqs, engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: fqdn, Type: typ, Data: next, Local: model.Ptr(true)}))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !slices.Equal(st.Settings.EncryptedDNSHosts, hosts) {
|
||||||
|
reqs = append(reqs, engine.NewRequest(engine.OpSettingsUpdate, 0, engine.SettingsInput{EncryptedDNSHosts: &hosts}))
|
||||||
|
}
|
||||||
return reqs, notes, nil
|
return reqs, notes, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,6 +461,69 @@ func zoneFor(st *model.State, name string, viewID int64) *model.Zone {
|
|||||||
return best
|
return best
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CurrentAddressViews returns, per address, the views whose records give
|
||||||
|
// one of the host names that address (what the setup form starts from).
|
||||||
|
func CurrentAddressViews(st *model.State, hosts []string) map[string][]string {
|
||||||
|
out := map[string][]string{}
|
||||||
|
for _, v := range st.OrderedViews() {
|
||||||
|
for _, host := range hosts {
|
||||||
|
fqdn := strings.TrimSuffix(host, ".") + "."
|
||||||
|
z := zoneFor(st, fqdn, v.ID)
|
||||||
|
if z == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, typ := range []string{"A", "AAAA"} {
|
||||||
|
for _, r := range st.RRset(z.ID, fqdn, typ) {
|
||||||
|
if !r.Disabled && !slices.Contains(out[r.Data], v.Name) {
|
||||||
|
out[r.Data] = append(out[r.Data], v.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// CertUses says what of named's configuration uses the certificate whose
|
||||||
|
// files are in dir: "DNS over TLS on 10.0.10.5, fd00::5 port 853", or the
|
||||||
|
// tls statement alone when no listener uses it.
|
||||||
|
func CertUses(st *model.State, dir string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, b := range TLSBlocks(st) {
|
||||||
|
if filepath.Dir(b.CertFile) != filepath.Clean(dir) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
used := false
|
||||||
|
byKind := map[string]map[int][]string{}
|
||||||
|
for _, l := range Listeners(st) {
|
||||||
|
if l.TLS != b.Name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
used = true
|
||||||
|
k := l.Kind()
|
||||||
|
if byKind[k] == nil {
|
||||||
|
byKind[k] = map[int][]string{}
|
||||||
|
}
|
||||||
|
byKind[k][l.EffectivePort()] = append(byKind[k][l.EffectivePort()], l.Addrs...)
|
||||||
|
}
|
||||||
|
for _, k := range []string{"dot", "doh"} {
|
||||||
|
label := map[string]string{"dot": "DNS over TLS", "doh": "DNS over HTTPS"}[k]
|
||||||
|
ports := make([]int, 0, len(byKind[k]))
|
||||||
|
for p := range byKind[k] {
|
||||||
|
ports = append(ports, p)
|
||||||
|
}
|
||||||
|
slices.Sort(ports)
|
||||||
|
for _, p := range ports {
|
||||||
|
out = append(out, fmt.Sprintf("%s on %s port %d", label, strings.Join(byKind[k][p], ", "), p))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !used {
|
||||||
|
out = append(out, "tls statement "+b.Name+" (no listener uses it)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// Disable returns the requests that take the setup out: its listeners and
|
// Disable returns the requests that take the setup out: its listeners and
|
||||||
// tls statement. Plain listeners and the host name's records stay.
|
// tls statement. Plain listeners and the host name's records stay.
|
||||||
func Disable(st *model.State) []engine.Request {
|
func Disable(st *model.State) []engine.Request {
|
||||||
@@ -385,6 +538,9 @@ func Disable(st *model.State) []engine.Request {
|
|||||||
reqs = append(reqs, engine.NewRequest(engine.OpOptionDelete, b.OptionID, nil))
|
reqs = append(reqs, engine.NewRequest(engine.OpOptionDelete, b.OptionID, nil))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(reqs) > 0 && len(st.Settings.EncryptedDNSHosts) > 0 {
|
||||||
|
reqs = append(reqs, engine.NewRequest(engine.OpSettingsUpdate, 0, engine.SettingsInput{EncryptedDNSHosts: &[]string{}}))
|
||||||
|
}
|
||||||
return reqs
|
return reqs
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,3 +549,11 @@ func Private(a netip.Addr) bool {
|
|||||||
a = a.Unmap()
|
a = a.Unmap()
|
||||||
return a.IsPrivate() || a.IsLoopback() || a.IsLinkLocalUnicast() || netip.MustParsePrefix("100.64.0.0/10").Contains(a)
|
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"))
|
||||||
|
}
|
||||||
@@ -186,3 +186,70 @@ func TestPorts(t *testing.T) {
|
|||||||
t.Errorf("free DoH port = %d, want 4443", p)
|
t.Errorf("free DoH port = %d, want 4443", p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Each address gets its own views; a view no longer given loses it, and
|
||||||
|
// records of the host names that aren't the setup's stay.
|
||||||
|
func TestSetupAddressViews(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.submit(engine.NewRequest(engine.OpZoneCreate, 0, engine.ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
|
||||||
|
e.submit(engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{Zone: "example.com", View: "external", Name: "doh.example.com.", Type: "A", Data: []string{"198.51.100.9"}, Local: model.Ptr(true)}))
|
||||||
|
hosts := func(st *model.State, view string) []string {
|
||||||
|
z := st.FindZone("example.com.", st.ViewByName(view).ID)
|
||||||
|
var out []string
|
||||||
|
for _, r := range st.ZoneRecords(z.ID) {
|
||||||
|
if strings.HasPrefix(r.Name, "dns.") || strings.HasPrefix(r.Name, "doh.") {
|
||||||
|
out = append(out, strings.SplitN(r.Name, ".", 2)[0]+" "+r.Type+" "+r.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
setup := Setup{Hostnames: []string{"dns.example.com", "doh.example.com"}, Certificate: "dns", DoT: true, DoH: true,
|
||||||
|
Addresses: []string{"10.0.10.5", "203.0.113.5", "10.0.10.6"},
|
||||||
|
AddressViews: map[string][]string{"10.0.10.5": {"internal"}, "203.0.113.5": {"external"}, "10.0.10.6": {"external"}},
|
||||||
|
PublicViews: map[string]bool{"external": true}}
|
||||||
|
reqs, notes, err := Plan(e.state(), setup, e.certDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !slices.ContainsFunc(notes, func(n string) bool {
|
||||||
|
return strings.Contains(n, "view external is reachable from the internet, but gets the private address 10.0.10.6")
|
||||||
|
}) {
|
||||||
|
t.Errorf("notes %q", notes)
|
||||||
|
}
|
||||||
|
e.submit(reqs...)
|
||||||
|
st := e.state()
|
||||||
|
if got, want := hosts(st, "internal"), []string{"dns A 10.0.10.5", "doh A 10.0.10.5"}; !slices.Equal(got, want) {
|
||||||
|
t.Errorf("internal %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
// The external doh record that isn't the setup's stays.
|
||||||
|
if got, want := hosts(st, "external"), []string{"dns A 10.0.10.6", "dns A 203.0.113.5", "doh A 10.0.10.6", "doh A 198.51.100.9", "doh A 203.0.113.5"}; !slices.Equal(got, want) {
|
||||||
|
t.Errorf("external %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if !slices.Equal(st.Settings.EncryptedDNSHosts, []string{"dns.example.com", "doh.example.com"}) {
|
||||||
|
t.Errorf("hosts %q", st.Settings.EncryptedDNSHosts)
|
||||||
|
}
|
||||||
|
av := CurrentAddressViews(st, st.Settings.EncryptedDNSHosts)
|
||||||
|
if !slices.Equal(av["10.0.10.5"], []string{"internal"}) || !slices.Equal(av["10.0.10.6"], []string{"external"}) {
|
||||||
|
t.Errorf("address views %v", av)
|
||||||
|
}
|
||||||
|
uses := CertUses(st, filepath.Join(e.certDir, "dns"))
|
||||||
|
if len(uses) != 2 || !strings.HasPrefix(uses[0], "DNS over TLS on 10.0.10.5, 203.0.113.5, 10.0.10.6 port 853") || !strings.HasPrefix(uses[1], "DNS over HTTPS on") {
|
||||||
|
t.Errorf("cert uses %q", uses)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10.0.10.6 no longer in external, doh.example.com no longer a host name.
|
||||||
|
setup = Setup{Hostname: "dns.example.com", Certificate: "dns", DoT: true, Addresses: []string{"10.0.10.5", "203.0.113.5", "10.0.10.6"},
|
||||||
|
AddressViews: map[string][]string{"10.0.10.5": {"internal"}, "203.0.113.5": {"external"}}}
|
||||||
|
reqs, _, err = Plan(st, setup, e.certDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
e.submit(reqs...)
|
||||||
|
st = e.state()
|
||||||
|
if got, want := hosts(st, "external"), []string{"dns A 203.0.113.5", "doh A 198.51.100.9"}; !slices.Equal(got, want) {
|
||||||
|
t.Errorf("external after %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := hosts(st, "internal"), []string{"dns A 10.0.10.5"}; !slices.Equal(got, want) {
|
||||||
|
t.Errorf("internal after %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireNames marks the probes whose certificate doesn't cover every host
|
||||||
|
// name clients may use (the first is what the probe asked for).
|
||||||
|
func (h *Health) RequireNames(hosts []string) {
|
||||||
|
for i := range h.Probes {
|
||||||
|
p := &h.Probes[i]
|
||||||
|
if p.Problem != "" || len(p.CertNames) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var missing []string
|
||||||
|
for _, host := range hosts {
|
||||||
|
if !nameCovered(p.CertNames, host) {
|
||||||
|
missing = append(missing, host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
p.OK = false
|
||||||
|
p.Problem = fmt.Sprintf("named serves a certificate for %s, which doesn't cover %s: clients using that name reject it", strings.Join(p.CertNames, ", "), strings.Join(missing, ", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func nameCovered(names []string, host string) bool {
|
||||||
|
host = strings.ToLower(strings.TrimSuffix(host, "."))
|
||||||
|
for _, n := range names {
|
||||||
|
n = strings.ToLower(strings.TrimSuffix(n, "."))
|
||||||
|
if n == host {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if rest, ok := strings.CutPrefix(n, "*."); ok {
|
||||||
|
if _, parent, ok := strings.Cut(host, "."); ok && parent == rest {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireNames(t *testing.T) {
|
||||||
|
h := &Health{Probes: []Probe{
|
||||||
|
{Kind: "dot", OK: true, CertNames: []string{"dns.example.com", "*.example.net"}},
|
||||||
|
{Kind: "doh", OK: false, Problem: "nothing listens", CertNames: nil},
|
||||||
|
}}
|
||||||
|
h.RequireNames([]string{"dns.example.com", "doh.example.net"})
|
||||||
|
if !h.Probes[0].OK {
|
||||||
|
t.Errorf("covered names: %+v", h.Probes[0])
|
||||||
|
}
|
||||||
|
h.RequireNames([]string{"dns.example.com", "doh.example.com"})
|
||||||
|
if p := h.Probes[0]; p.OK || !strings.Contains(p.Problem, "doesn't cover doh.example.com") {
|
||||||
|
t.Errorf("missing name: %+v", p)
|
||||||
|
}
|
||||||
|
if h.Probes[1].Problem != "nothing listens" {
|
||||||
|
t.Errorf("other problem replaced: %+v", h.Probes[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"jarl/internal/dnsname"
|
||||||
|
"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
|
||||||
|
Data []string `json:"data"` // their data alone, in the same order
|
||||||
|
TTL *uint32 `json:"ttl,omitempty"` // the first record's own TTL
|
||||||
|
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{}, Data: []string{}}
|
||||||
|
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)
|
||||||
|
vs.Data = append(vs.Data, r.Data)
|
||||||
|
if vs.TTL == nil && r.TTL != nil {
|
||||||
|
vs.TTL = model.Ptr(*r.TTL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompareReport is a zone in one view (the base) next to the same zone in
|
||||||
|
// other views (docs/views.md#comparing-views): what the other views answer
|
||||||
|
// differently, the names only some views have, and how much is the same.
|
||||||
|
type CompareReport struct {
|
||||||
|
Zone string `json:"zone"`
|
||||||
|
Base string `json:"base"` // the view compared from
|
||||||
|
Views []string `json:"views"` // the base first
|
||||||
|
Linked bool `json:"linked"` // all in one link
|
||||||
|
// Summary is, per other view, how it differs from the base.
|
||||||
|
Summary []CompareSummary `json:"summary"`
|
||||||
|
// Different are names and types more than one view has, with
|
||||||
|
// different records.
|
||||||
|
Different []CompareRow `json:"different"`
|
||||||
|
// Only are the names and types only some views have, by the views
|
||||||
|
// that have them.
|
||||||
|
Only []CompareOnly `json:"only"`
|
||||||
|
Same int `json:"same"`
|
||||||
|
// SameRows are the ones that are the same (when asked for).
|
||||||
|
SameRows []CompareRow `json:"same_rows,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompareSummary counts how a view differs from the base view.
|
||||||
|
type CompareSummary struct {
|
||||||
|
View string `json:"view"`
|
||||||
|
Different int `json:"different"` // both have it, with different records
|
||||||
|
OnlyBase int `json:"only_base"` // the base has it, this view doesn't
|
||||||
|
OnlyOther int `json:"only_other"` // this view has it, the base doesn't
|
||||||
|
Same int `json:"same"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompareOnly are names and types that only some views have.
|
||||||
|
type CompareOnly struct {
|
||||||
|
Views []string `json:"views"`
|
||||||
|
Rows []CompareRow `json:"rows"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompareFilter narrows a comparison to a name, and optionally a type.
|
||||||
|
type CompareFilter struct {
|
||||||
|
Name string // relative ("www", "@") or absolute
|
||||||
|
Type string
|
||||||
|
All bool // include what is the same
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare builds the report for zones (the base first). Apex NS records are
|
||||||
|
// left out: they name the servers, which views often list differently on
|
||||||
|
// purpose.
|
||||||
|
func Compare(st *model.State, zones []*model.Zone, f CompareFilter) *CompareReport {
|
||||||
|
rep := &CompareReport{Zone: dnsname.Display(zones[0].Name), Base: st.ViewName(zones[0]), Different: []CompareRow{}, Only: []CompareOnly{}, Linked: true}
|
||||||
|
for _, z := range zones {
|
||||||
|
rep.Views = append(rep.Views, st.ViewName(z))
|
||||||
|
rep.Linked = rep.Linked && z.LinkGroup != 0 && z.LinkGroup == zones[0].LinkGroup
|
||||||
|
}
|
||||||
|
for _, v := range rep.Views[1:] {
|
||||||
|
rep.Summary = append(rep.Summary, CompareSummary{View: v})
|
||||||
|
}
|
||||||
|
want := ""
|
||||||
|
if f.Name != "" {
|
||||||
|
if o, err := dnsname.Owner(f.Name, zones[0].Name); err == nil {
|
||||||
|
want = o
|
||||||
|
}
|
||||||
|
}
|
||||||
|
only := map[string]int{}
|
||||||
|
for _, row := range CompareZones(st, zones) {
|
||||||
|
if row.FQDN == zones[0].Name && row.Type == "NS" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if want != "" && (row.FQDN != want || f.Type != "" && !strings.EqualFold(row.Type, f.Type)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
base := row.Views[0]
|
||||||
|
for i, vs := range row.Views[1:] {
|
||||||
|
s := &rep.Summary[i]
|
||||||
|
switch {
|
||||||
|
case len(base.Records) > 0 && len(vs.Records) > 0 && slices.Equal(base.Records, vs.Records):
|
||||||
|
s.Same++
|
||||||
|
case len(base.Records) > 0 && len(vs.Records) > 0:
|
||||||
|
s.Different++
|
||||||
|
case len(base.Records) > 0:
|
||||||
|
s.OnlyBase++
|
||||||
|
case len(vs.Records) > 0:
|
||||||
|
s.OnlyOther++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if row.Same {
|
||||||
|
rep.Same++
|
||||||
|
if f.All || want != "" {
|
||||||
|
rep.SameRows = append(rep.SameRows, row)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var having []string
|
||||||
|
versions := map[string]bool{}
|
||||||
|
for _, vs := range row.Views {
|
||||||
|
if len(vs.Records) > 0 {
|
||||||
|
having = append(having, vs.View)
|
||||||
|
versions[strings.Join(vs.Records, "\n")] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(versions) > 1 {
|
||||||
|
rep.Different = append(rep.Different, row)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k := strings.Join(having, ",")
|
||||||
|
i, ok := only[k]
|
||||||
|
if !ok {
|
||||||
|
i = len(rep.Only)
|
||||||
|
only[k] = i
|
||||||
|
rep.Only = append(rep.Only, CompareOnly{Views: having})
|
||||||
|
}
|
||||||
|
rep.Only[i].Rows = append(rep.Only[i].Rows, row)
|
||||||
|
}
|
||||||
|
// The base's own names first, then by how many views have them.
|
||||||
|
slices.SortStableFunc(rep.Only, func(a, b CompareOnly) int {
|
||||||
|
ab, bb := slices.Contains(a.Views, rep.Base), slices.Contains(b.Views, rep.Base)
|
||||||
|
if ab != bb {
|
||||||
|
if ab {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return len(b.Views) - len(a.Views)
|
||||||
|
})
|
||||||
|
return rep
|
||||||
|
}
|
||||||
@@ -25,7 +25,8 @@ func DiffOps(from, to *model.State) []model.Op {
|
|||||||
s := to.Settings
|
s := to.Settings
|
||||||
add(OpSettingsUpdate, 0, 0, SettingsInput{DefaultTTL: &s.DefaultTTL, Nameservers: model.Ptr(append([]string{}, s.Nameservers...)), RName: &s.RName,
|
add(OpSettingsUpdate, 0, 0, SettingsInput{DefaultTTL: &s.DefaultTTL, Nameservers: model.Ptr(append([]string{}, s.Nameservers...)), RName: &s.RName,
|
||||||
Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, NegativeTTL: &s.NegativeTTL, SerialPolicy: &s.SerialPolicy,
|
Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, NegativeTTL: &s.NegativeTTL, SerialPolicy: &s.SerialPolicy,
|
||||||
Exposure: &s.Exposure, Acknowledged: model.Ptr(slices.Clone(s.Acknowledged))}, "restore settings")
|
Exposure: &s.Exposure, Acknowledged: model.Ptr(slices.Clone(s.Acknowledged)), DismissedReverse: model.Ptr(slices.Clone(s.DismissedReverse)),
|
||||||
|
EncryptedDNSHosts: model.Ptr(slices.Clone(s.EncryptedDNSHosts))}, "restore settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, k := range sortedKeys(to.Keys) {
|
for _, k := range sortedKeys(to.Keys) {
|
||||||
@@ -50,7 +51,8 @@ func DiffOps(from, to *model.State) []model.Op {
|
|||||||
n := to.Views[k]
|
n := to.Views[k]
|
||||||
in := ViewInput{Name: &n.Name, MatchClients: model.Ptr(append([]string{}, n.MatchClients...)),
|
in := ViewInput{Name: &n.Name, MatchClients: model.Ptr(append([]string{}, n.MatchClients...)),
|
||||||
MatchDestinations: model.Ptr(append([]string{}, n.MatchDestinations...)), Comment: &n.Comment, Position: model.Ptr(n.Position),
|
MatchDestinations: model.Ptr(append([]string{}, n.MatchDestinations...)), Comment: &n.Comment, Position: model.Ptr(n.Position),
|
||||||
Exposure: model.Ptr(n.Exposure), Acknowledged: model.Ptr(slices.Clone(n.Acknowledged))}
|
Exposure: model.Ptr(n.Exposure), Acknowledged: model.Ptr(slices.Clone(n.Acknowledged)),
|
||||||
|
ReverseSuggestions: model.Ptr(!n.ReverseSuggestionsOff), DismissedReverse: model.Ptr(slices.Clone(n.DismissedReverse))}
|
||||||
if n.MatchRecursiveOnly != nil {
|
if n.MatchRecursiveOnly != nil {
|
||||||
in.MatchRecursiveOnly = model.Some(*n.MatchRecursiveOnly)
|
in.MatchRecursiveOnly = model.Some(*n.MatchRecursiveOnly)
|
||||||
} else {
|
} else {
|
||||||
@@ -66,7 +68,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, LinkGroup: model.Ptr(n.LinkGroup)}
|
DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, DNSSECPolicy: &n.DNSSECPolicy, Comment: &n.Comment, Disabled: &n.Disabled, LinkGroup: model.Ptr(n.LinkGroup), AutoPTR: model.Ptr(n.AutoPTR)}
|
||||||
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}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -285,7 +285,7 @@ func (e *Engine) expandAll(ctx context.Context, work *model.State, reqs []Reques
|
|||||||
var warnings []string
|
var warnings []string
|
||||||
c := &applyCtx{p: p, now: e.Now()}
|
c := &applyCtx{p: p, now: e.Now()}
|
||||||
for i, req := range reqs {
|
for i, req := range reqs {
|
||||||
x := &expander{ctx: ctx, st: work, alloc: e.store.AllocateIDs, p: p, now: e.Now()}
|
x := &expander{ctx: ctx, st: work, alloc: e.store.AllocateIDs, p: p, now: e.Now(), exposures: e.Exposures}
|
||||||
more, err := x.expand(req)
|
more, err := x.expand(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ee, ok := AsError(err); ok && len(reqs) > 1 {
|
if ee, ok := AsError(err); ok && len(reqs) > 1 {
|
||||||
|
|||||||
+230
-31
@@ -28,6 +28,9 @@ type expander struct {
|
|||||||
p *auth.Principal
|
p *auth.Principal
|
||||||
now time.Time
|
now time.Time
|
||||||
warnings []string
|
warnings []string
|
||||||
|
// exposures classifies the views as public or private (nil in tests:
|
||||||
|
// every view private).
|
||||||
|
exposures func(*model.State) []validate.Exposure
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *expander) ids(n int) ([]int64, error) {
|
func (x *expander) ids(n int) ([]int64, error) {
|
||||||
@@ -137,7 +140,7 @@ func (x *expander) expand(req Request) ([]model.Op, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
switch req.Op {
|
switch req.Op {
|
||||||
case OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace, OpRecordDisable, OpRecordEnable:
|
case OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace, OpRecordDisable, OpRecordEnable, OpZoneFillPTR:
|
||||||
// Changes to linked zones reach the other zones of the link.
|
// Changes to linked zones reach the other zones of the link.
|
||||||
return x.linkSync(ops)
|
return x.linkSync(ops)
|
||||||
}
|
}
|
||||||
@@ -152,6 +155,10 @@ func (x *expander) expandOne(req Request) ([]model.Op, error) {
|
|||||||
return x.zoneUnlink(req)
|
return x.zoneUnlink(req)
|
||||||
case OpZoneCreate:
|
case OpZoneCreate:
|
||||||
return x.zoneCreate(req)
|
return x.zoneCreate(req)
|
||||||
|
case OpZoneFillPTR:
|
||||||
|
return x.zoneFillPTR(req)
|
||||||
|
case OpReverseDismiss, OpReverseUndismiss:
|
||||||
|
return x.reverseDismiss(req, req.Op == OpReverseDismiss)
|
||||||
case OpZoneUpdate, OpZoneDelete:
|
case OpZoneUpdate, OpZoneDelete:
|
||||||
z := x.st.Zones[req.ID]
|
z := x.st.Zones[req.ID]
|
||||||
if z == nil {
|
if z == nil {
|
||||||
@@ -376,6 +383,7 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
|
|||||||
return nil, errf(KindBadRequest, "%v", err)
|
return nil, errf(KindBadRequest, "%v", err)
|
||||||
}
|
}
|
||||||
in.Type = &zt
|
in.Type = &zt
|
||||||
|
viewGiven := in.View != nil || in.ViewID != nil
|
||||||
if in.View != nil {
|
if in.View != nil {
|
||||||
if *in.View != "" {
|
if *in.View != "" {
|
||||||
v := x.st.ViewByName(*in.View)
|
v := x.st.ViewByName(*in.View)
|
||||||
@@ -386,6 +394,74 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
|
|||||||
}
|
}
|
||||||
in.View = nil
|
in.View = nil
|
||||||
}
|
}
|
||||||
|
if !viewGiven && in.LinkWith == "" && in.CloneFrom == 0 && dnsname.IsReverse(name) {
|
||||||
|
// A reverse zone belongs in the private view whose records use
|
||||||
|
// its addresses (docs/reverse.md).
|
||||||
|
v, err := reverseViewFor(x.st, x.publicViews(), name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if v != nil {
|
||||||
|
in.ViewID = &v.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var moveOps []model.Op
|
||||||
|
var moved []*model.Record
|
||||||
|
if zt == model.ZonePrimary {
|
||||||
|
// The records a parent zone in the view has under the new zone
|
||||||
|
// move into it: named answers those names from the new zone, so
|
||||||
|
// left behind they would never be served again.
|
||||||
|
vid := int64(0)
|
||||||
|
if in.ViewID != nil {
|
||||||
|
vid = *in.ViewID
|
||||||
|
}
|
||||||
|
if parent := x.parentZone(name, vid); parent != nil {
|
||||||
|
moved = x.recordsUnder(parent, name)
|
||||||
|
switch {
|
||||||
|
case len(moved) == 0:
|
||||||
|
case parent.LinkGroup != 0:
|
||||||
|
x.warnings = append(x.warnings, fmt.Sprintf("zone %s is linked, so its %d records under %s stay there (hidden by the new zone): move them by hand", zoneDesc(x.st, parent), len(moved), dnsname.Display(name)))
|
||||||
|
moved = nil
|
||||||
|
default:
|
||||||
|
for _, r := range moved {
|
||||||
|
ri := RecordInput{Name: model.Ptr(r.Name), Type: model.Ptr(r.Type), Data: model.Ptr(r.Data)}
|
||||||
|
if r.TTL != nil {
|
||||||
|
ri.TTL = model.Some(*r.TTL)
|
||||||
|
}
|
||||||
|
if r.Comment != "" {
|
||||||
|
ri.Comment = model.Ptr(r.Comment)
|
||||||
|
}
|
||||||
|
if r.Disabled {
|
||||||
|
ri.Disabled = model.Ptr(true)
|
||||||
|
}
|
||||||
|
in.Records = append(in.Records, ri)
|
||||||
|
moveOps = append(moveOps, newOp(OpRecordDelete, r.ID, r.Version, nil, fmt.Sprintf("move %s %s %s from zone %s into the new zone", r.Name, r.Type, r.Data, zoneDesc(x.st, parent))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fill := in.Fill
|
||||||
|
in.Fill = false
|
||||||
|
if fill {
|
||||||
|
if zt != model.ZonePrimary {
|
||||||
|
return nil, errf(KindBadRequest, "fill only applies to primary zones")
|
||||||
|
}
|
||||||
|
if _, ok := dnsname.PrefixFromReverse(name); !ok {
|
||||||
|
return nil, errf(KindBadRequest, "fill only applies to reverse zones of whole octets or nibbles (like 10.0.10.0/24)")
|
||||||
|
}
|
||||||
|
vid := int64(0)
|
||||||
|
if in.ViewID != nil {
|
||||||
|
vid = *in.ViewID
|
||||||
|
}
|
||||||
|
fills := PTRFills(x.st, vid, name, moved)
|
||||||
|
if len(fills) == 0 {
|
||||||
|
x.warnings = append(x.warnings, fmt.Sprintf("no A or AAAA record of %s has an address in %s, so there was nothing to fill it with", viewOrServer(x.st, vid), dnsname.Display(name)))
|
||||||
|
}
|
||||||
|
x.warnings = append(x.warnings, fillWarnings(fills)...)
|
||||||
|
for _, f := range fills {
|
||||||
|
in.Records = append(in.Records, RecordInput{Name: model.Ptr(f.Name), Type: model.Ptr("PTR"), Data: model.Ptr(f.Target)})
|
||||||
|
}
|
||||||
|
}
|
||||||
nameservers := in.Nameservers
|
nameservers := in.Nameservers
|
||||||
records := in.Records
|
records := in.Records
|
||||||
cloneFrom := in.CloneFrom
|
cloneFrom := in.CloneFrom
|
||||||
@@ -603,7 +679,45 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
|
|||||||
r.in.ZoneID = ids[0]
|
r.in.ZoneID = ids[0]
|
||||||
ops = append(ops, newOp(OpRecordCreate, ids[1+i], 0, r.in, r.summary))
|
ops = append(ops, newOp(OpRecordCreate, ids[1+i], 0, r.in, r.summary))
|
||||||
}
|
}
|
||||||
return ops, nil
|
return append(ops, moveOps...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parentZone returns the most specific primary zone in a view that
|
||||||
|
// contains name (not name itself).
|
||||||
|
func (x *expander) parentZone(name string, viewID int64) *model.Zone {
|
||||||
|
var best *model.Zone
|
||||||
|
for _, z := range x.st.ZonesInView(viewID) {
|
||||||
|
if z.Type == model.ZonePrimary && dnsname.IsStrictSubdomain(name, z.Name) && (best == nil || len(z.Name) > len(best.Name)) {
|
||||||
|
best = z
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordsUnder returns a zone's records at or below name, except a
|
||||||
|
// delegation's NS and DS records and its glue, which belong to the parent.
|
||||||
|
func (x *expander) recordsUnder(z *model.Zone, name string) []*model.Record {
|
||||||
|
all := x.st.ZoneRecords(z.ID)
|
||||||
|
var ns []string
|
||||||
|
for _, r := range all {
|
||||||
|
if r.Name == name && r.Type == "NS" {
|
||||||
|
ns = append(ns, strings.ToLower(r.Data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out []*model.Record
|
||||||
|
for _, r := range all {
|
||||||
|
if !dnsname.IsSubdomain(r.Name, name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r.Name == name && (r.Type == "NS" || r.Type == "DS") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (r.Type == "A" || r.Type == "AAAA") && slices.Contains(ns, strings.ToLower(r.Name)) {
|
||||||
|
continue // glue
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func deref(s *string) string {
|
func deref(s *string) string {
|
||||||
@@ -638,7 +752,11 @@ func addrOf(p *rr.Parsed) (netip.Addr, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ptrOps returns operations creating the PTR record for an address record.
|
// ptrOps returns operations creating the PTR record for an address record.
|
||||||
func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed) ([]model.Op, error) {
|
//
|
||||||
|
// auto is set when the zone's auto_ptr asked rather than the request: a
|
||||||
|
// missing reverse zone isn't worth a warning then (most addresses have
|
||||||
|
// none), and an address whose PTR record names another host keeps it.
|
||||||
|
func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed, auto bool) ([]model.Op, error) {
|
||||||
addr, ok := addrOf(p)
|
addr, ok := addrOf(p)
|
||||||
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")
|
||||||
@@ -651,13 +769,13 @@ func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed) ([]model
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
found = true
|
found = true
|
||||||
more, err := x.ptrCreateIn(rz, ptrName, owner)
|
more, err := x.ptrCreateIn(rz, ptrName, owner, auto)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
ops = append(ops, more...)
|
ops = append(ops, more...)
|
||||||
}
|
}
|
||||||
if !found {
|
if !found && !auto {
|
||||||
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()))
|
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
|
return ops, nil
|
||||||
@@ -678,7 +796,8 @@ func ptrViews(st *model.State, z *model.Zone) []int64 {
|
|||||||
|
|
||||||
// ptrCreateIn returns operations creating the PTR record ptrName -> owner
|
// ptrCreateIn returns operations creating the PTR record ptrName -> owner
|
||||||
// in reverse zone rz (none when it is there; enabling it when disabled).
|
// 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) {
|
func (x *expander) ptrCreateIn(rz *model.Zone, ptrName, owner string, auto bool) ([]model.Op, error) {
|
||||||
|
var other string
|
||||||
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) {
|
||||||
@@ -688,9 +807,18 @@ func (x *expander) ptrCreateIn(rz *model.Zone, ptrName, owner string) ([]model.O
|
|||||||
}
|
}
|
||||||
return nil, nil // already there
|
return nil, nil // already there
|
||||||
}
|
}
|
||||||
x.warnings = append(x.warnings, fmt.Sprintf("%s already has a PTR record (%s); adding another one", ptrName, r.Data))
|
if !r.Disabled {
|
||||||
|
other = r.Data
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if other != "" {
|
||||||
|
if auto {
|
||||||
|
// One name per address: the one it has stays.
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
x.warnings = append(x.warnings, fmt.Sprintf("%s already has a PTR record (%s); adding another one", ptrName, other))
|
||||||
|
}
|
||||||
ids, err := x.ids(1)
|
ids, err := x.ids(1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -778,7 +906,7 @@ func (x *expander) recordCreate(req Request) ([]model.Op, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
x.parseWarnings(owner, p)
|
x.parseWarnings(owner, p)
|
||||||
ptr := in.PTR
|
ptr, auto := in.PTR, !in.PTR && autoPTR(z, owner, p)
|
||||||
in.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false
|
in.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false
|
||||||
in.Name, in.Type, in.Data = &owner, &p.Type, &p.Data
|
in.Name, in.Type, in.Data = &owner, &p.Type, &p.Data
|
||||||
ids, err := x.ids(1)
|
ids, err := x.ids(1)
|
||||||
@@ -801,13 +929,14 @@ func (x *expander) recordCreate(req Request) ([]model.Op, error) {
|
|||||||
}
|
}
|
||||||
in.SharedID = &ids[0]
|
in.SharedID = &ids[0]
|
||||||
}
|
}
|
||||||
} else if in.Local != nil && *in.Local {
|
} else if in.Local != nil && !*in.Local {
|
||||||
return nil, errf(KindBadRequest, "zone %s isn't linked with other views, so its records are local anyway", zoneDesc(x.st, z))
|
return nil, notLinked(x.st, z)
|
||||||
}
|
}
|
||||||
|
// local on a zone that isn't linked: its records are local anyway.
|
||||||
in.Local = nil
|
in.Local = nil
|
||||||
ops := []model.Op{newOp(OpRecordCreate, ids[0], 0, in, summary)}
|
ops := []model.Op{newOp(OpRecordCreate, ids[0], 0, in, summary)}
|
||||||
if ptr {
|
if ptr || auto && (in.Disabled == nil || !*in.Disabled) {
|
||||||
more, err := x.ptrCreate(z, owner, p)
|
more, err := x.ptrCreate(z, owner, p, auto)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -834,10 +963,10 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
|||||||
}
|
}
|
||||||
var pre []model.Op
|
var pre []model.Op
|
||||||
if in.Local != nil {
|
if in.Local != nil {
|
||||||
if z.LinkGroup == 0 {
|
if z.LinkGroup == 0 && !*in.Local {
|
||||||
return nil, errf(KindBadRequest, "zone %s isn't linked with other views, so its records are local anyway", zoneDesc(x.st, z))
|
return nil, notLinked(x.st, z)
|
||||||
}
|
}
|
||||||
if want := *in.Local; want != (r.SharedID == 0) {
|
if want := *in.Local; z.LinkGroup != 0 && want != (r.SharedID == 0) {
|
||||||
// The whole set of this name and type changes sides.
|
// The whole set of this name and type changes sides.
|
||||||
for _, o := range x.st.RRset(z.ID, r.Name, r.Type) {
|
for _, o := range x.st.RRset(z.ID, r.Name, r.Type) {
|
||||||
id := int64(0)
|
id := int64(0)
|
||||||
@@ -848,10 +977,11 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
|||||||
}
|
}
|
||||||
pre = append(pre, newOp(OpRecordUpdate, o.ID, o.Version, RecordInput{SharedID: model.Ptr(id)}, what))
|
pre = append(pre, newOp(OpRecordUpdate, o.ID, o.Version, RecordInput{SharedID: model.Ptr(id)}, what))
|
||||||
}
|
}
|
||||||
if !want {
|
}
|
||||||
// Sharing a version makes it every view's.
|
if z.LinkGroup != 0 && !*in.Local {
|
||||||
pre = append(pre, x.dropOverrides(z, r.Name, r.Type)...)
|
// 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
|
in.Local = nil
|
||||||
if string(mustJSON(in)) == "{}" {
|
if string(mustJSON(in)) == "{}" {
|
||||||
@@ -937,13 +1067,43 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
|||||||
if in.Disabled != nil {
|
if in.Disabled != nil {
|
||||||
disabled = *in.Disabled
|
disabled = *in.Disabled
|
||||||
}
|
}
|
||||||
moved := p.Data != r.Data || owner != r.Name
|
moved := p.Data != r.Data || owner != r.Name || p.Type != r.Type
|
||||||
|
if !ptr && z.AutoPTR {
|
||||||
|
// The zone keeps PTR records in step (docs/reverse.md): as if
|
||||||
|
// asked, for whichever of the old and new record is an address.
|
||||||
|
old, err := rr.Parse(z.Name, r.Name, r.Type, r.Data)
|
||||||
|
oldAuto := err == nil && autoPTR(z, r.Name, old)
|
||||||
|
newAuto := autoPTR(z, owner, p)
|
||||||
|
switch {
|
||||||
|
case moved:
|
||||||
|
if oldAuto {
|
||||||
|
ops = append(ops, x.ptrDelete(z, r.Name, old)...)
|
||||||
|
}
|
||||||
|
if newAuto && !disabled {
|
||||||
|
more, err := x.ptrCreate(z, owner, p, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ops = append(ops, more...)
|
||||||
|
}
|
||||||
|
case toggled && newAuto && disabled:
|
||||||
|
ops = append(ops, x.ptrSetDisabled(z, owner, p, true)...)
|
||||||
|
case toggled && newAuto:
|
||||||
|
// Enables the PTR record, or adds it when it is missing.
|
||||||
|
more, err := x.ptrCreate(z, owner, p, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ops = append(ops, more...)
|
||||||
|
}
|
||||||
|
return ops, nil
|
||||||
|
}
|
||||||
if ptr && moved {
|
if ptr && moved {
|
||||||
if old, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
|
if old, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
|
||||||
ops = append(ops, x.ptrDelete(z, r.Name, old)...)
|
ops = append(ops, x.ptrDelete(z, r.Name, old)...)
|
||||||
}
|
}
|
||||||
if !disabled {
|
if !disabled {
|
||||||
more, err := x.ptrCreate(z, owner, p)
|
more, err := x.ptrCreate(z, owner, p, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -959,7 +1119,7 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
|||||||
if ptr && !moved && !toggled && !disabled {
|
if ptr && !moved && !toggled && !disabled {
|
||||||
// Nothing else changes: make sure the PTR record is there (the
|
// Nothing else changes: make sure the PTR record is there (the
|
||||||
// reverse zone may be newer than the record).
|
// reverse zone may be newer than the record).
|
||||||
more, err := x.ptrCreate(z, owner, p)
|
more, err := x.ptrCreate(z, owner, p, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -986,11 +1146,9 @@ func (x *expander) recordDelete(req Request) ([]model.Op, error) {
|
|||||||
base = r.Version
|
base = r.Version
|
||||||
}
|
}
|
||||||
ops := []model.Op{newOp(OpRecordDelete, r.ID, base, nil, fmt.Sprintf("delete %s %s %s", r.Name, r.Type, r.Data))}
|
ops := []model.Op{newOp(OpRecordDelete, r.ID, base, nil, fmt.Sprintf("delete %s %s %s", r.Name, r.Type, r.Data))}
|
||||||
if in.PTR {
|
z := x.st.Zones[r.ZoneID]
|
||||||
z := x.st.Zones[r.ZoneID]
|
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil && (in.PTR || autoPTR(z, r.Name, p)) {
|
||||||
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
|
ops = append(ops, x.ptrDelete(z, r.Name, p)...)
|
||||||
ops = append(ops, x.ptrDelete(z, r.Name, p)...)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return ops, nil
|
return ops, nil
|
||||||
}
|
}
|
||||||
@@ -1030,8 +1188,19 @@ func (x *expander) recordDelete(req Request) ([]model.Op, error) {
|
|||||||
in.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false
|
in.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false
|
||||||
in.Name, in.Type = &owner, &typ
|
in.Name, in.Type = &owner, &typ
|
||||||
ops := []model.Op{newOp(OpRecordDelete, 0, 0, in, "delete "+what)}
|
ops := []model.Op{newOp(OpRecordDelete, 0, 0, in, "delete "+what)}
|
||||||
if ptr && parsed != nil {
|
switch {
|
||||||
|
case ptr && parsed != nil:
|
||||||
ops = append(ops, x.ptrDelete(z, owner, parsed)...)
|
ops = append(ops, x.ptrDelete(z, owner, parsed)...)
|
||||||
|
case z.AutoPTR:
|
||||||
|
// Every matching record's PTR record goes too.
|
||||||
|
for _, r := range x.st.RRset(z.ID, owner, typ) {
|
||||||
|
if parsed != nil && r.Data != parsed.Data {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil && autoPTR(z, r.Name, p) {
|
||||||
|
ops = append(ops, x.ptrDelete(z, r.Name, p)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return ops, nil
|
return ops, nil
|
||||||
}
|
}
|
||||||
@@ -1153,8 +1322,8 @@ func (x *expander) rrsetReplace(req Request) ([]model.Op, error) {
|
|||||||
// Explicitly shared: this version becomes every view's.
|
// Explicitly shared: this version becomes every view's.
|
||||||
pre = x.dropOverrides(z, owner, typ)
|
pre = x.dropOverrides(z, owner, typ)
|
||||||
}
|
}
|
||||||
} else if in.Local != nil && *in.Local {
|
} else if in.Local != nil && !*in.Local {
|
||||||
return nil, errf(KindBadRequest, "zone %s isn't linked with other views, so its records are local anyway", zoneDesc(x.st, z))
|
return nil, notLinked(x.st, z)
|
||||||
}
|
}
|
||||||
in.Local = nil
|
in.Local = nil
|
||||||
if len(canon) > 0 {
|
if len(canon) > 0 {
|
||||||
@@ -1167,7 +1336,32 @@ func (x *expander) rrsetReplace(req Request) ([]model.Op, error) {
|
|||||||
if len(canon) == 0 {
|
if len(canon) == 0 {
|
||||||
summary = fmt.Sprintf("delete all %s records of %s%s", typ, owner, where)
|
summary = fmt.Sprintf("delete all %s records of %s%s", typ, owner, where)
|
||||||
}
|
}
|
||||||
return append(pre, newOp(OpRRSetReplace, 0, 0, in, summary)), nil
|
ops := append(pre, newOp(OpRRSetReplace, 0, 0, in, summary))
|
||||||
|
if z.AutoPTR && (typ == "A" || typ == "AAAA") && !strings.HasPrefix(owner, "*.") {
|
||||||
|
// PTR records follow the addresses that go and come.
|
||||||
|
old := map[string]bool{}
|
||||||
|
for _, r := range x.st.RRset(z.ID, owner, typ) {
|
||||||
|
old[r.Data] = true
|
||||||
|
if !slices.Contains(canon, r.Data) {
|
||||||
|
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
|
||||||
|
ops = append(ops, x.ptrDelete(z, r.Name, p)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, d := range canon {
|
||||||
|
if old[d] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, p, err := parseRecord(z, owner, typ, d); err == nil {
|
||||||
|
more, err := x.ptrCreate(z, owner, p, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ops = append(ops, more...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ops, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *expander) viewCreate(req Request) ([]model.Op, error) {
|
func (x *expander) viewCreate(req Request) ([]model.Op, error) {
|
||||||
@@ -1356,3 +1550,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))
|
||||||
|
}
|
||||||
@@ -59,8 +59,25 @@ const (
|
|||||||
// into zone and record updates.
|
// into zone and record updates.
|
||||||
OpZoneLink = "zone.link"
|
OpZoneLink = "zone.link"
|
||||||
OpZoneUnlink = "zone.unlink"
|
OpZoneUnlink = "zone.unlink"
|
||||||
|
// Filling a reverse zone with the PTR records of its view's A and
|
||||||
|
// AAAA records (docs/reverse.md) becomes record.create operations.
|
||||||
|
OpZoneFillPTR = "zone.fill_ptr"
|
||||||
|
// Dismissing a reverse zone suggestion, and taking that back, become
|
||||||
|
// view.update or settings.update operations.
|
||||||
|
OpReverseDismiss = "reverse.dismiss"
|
||||||
|
OpReverseUndismiss = "reverse.undismiss"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DismissInput dismisses a reverse zone suggestion: a network in a view
|
||||||
|
// (by ID or name), or the server's (neither given).
|
||||||
|
type DismissInput struct {
|
||||||
|
ViewID int64 `json:"view_id,omitempty"`
|
||||||
|
View string `json:"view,omitempty"`
|
||||||
|
Prefix string `json:"prefix,omitempty"`
|
||||||
|
// Prefixes dismisses (or brings back) several at once.
|
||||||
|
Prefixes []string `json:"prefixes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// AckInput acknowledges (or un-acknowledges) an exposure finding of a view
|
// AckInput acknowledges (or un-acknowledges) an exposure finding of a view
|
||||||
// (by ID or name), or of the server (neither given).
|
// (by ID or name), or of the server (neither given).
|
||||||
type AckInput struct {
|
type AckInput struct {
|
||||||
@@ -142,6 +159,12 @@ type ZoneInput struct {
|
|||||||
// name in this view, copying its shared records (or all of its
|
// name in this view, copying its shared records (or all of its
|
||||||
// records, when it isn't linked yet).
|
// records, when it isn't linked yet).
|
||||||
LinkWith string `json:"link_with,omitempty"`
|
LinkWith string `json:"link_with,omitempty"`
|
||||||
|
// AutoPTR keeps the PTR records of the zone's A and AAAA records in
|
||||||
|
// step (docs/reverse.md).
|
||||||
|
AutoPTR *bool `json:"auto_ptr,omitempty"`
|
||||||
|
// Fill (create only, reverse zones) adds a PTR record for every
|
||||||
|
// address of the view's A and AAAA records in the zone's range.
|
||||||
|
Fill bool `json:"fill,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
|
||||||
@@ -237,6 +260,11 @@ type ViewInput struct {
|
|||||||
// Exposure is "auto" (or ""), "public" or "private" (docs/exposure.md).
|
// Exposure is "auto" (or ""), "public" or "private" (docs/exposure.md).
|
||||||
Exposure *string `json:"exposure,omitempty"`
|
Exposure *string `json:"exposure,omitempty"`
|
||||||
Acknowledged *[]model.Acknowledgement `json:"acknowledged,omitempty"`
|
Acknowledged *[]model.Acknowledgement `json:"acknowledged,omitempty"`
|
||||||
|
// ReverseSuggestions turns the reverse zone suggestions for the
|
||||||
|
// view's addresses on or off (docs/reverse.md).
|
||||||
|
ReverseSuggestions *bool `json:"reverse_suggestions,omitempty"`
|
||||||
|
// DismissedReverse are the networks whose suggestion was dismissed.
|
||||||
|
DismissedReverse *[]string `json:"dismissed_reverse,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReorderInput sets the order of views.
|
// ReorderInput sets the order of views.
|
||||||
@@ -283,6 +311,11 @@ type SettingsInput struct {
|
|||||||
// Exposure and Acknowledged are the server's (docs/exposure.md).
|
// Exposure and Acknowledged are the server's (docs/exposure.md).
|
||||||
Exposure *string `json:"exposure,omitempty"`
|
Exposure *string `json:"exposure,omitempty"`
|
||||||
Acknowledged *[]model.Acknowledgement `json:"acknowledged,omitempty"`
|
Acknowledged *[]model.Acknowledgement `json:"acknowledged,omitempty"`
|
||||||
|
// DismissedReverse are dismissed reverse zone suggestions (without
|
||||||
|
// views).
|
||||||
|
DismissedReverse *[]string `json:"dismissed_reverse,omitempty"`
|
||||||
|
// EncryptedDNSHosts are the encrypted DNS setup's host names.
|
||||||
|
EncryptedDNSHosts *[]string `json:"encrypted_dns_hosts,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteInput carries options for delete operations.
|
// DeleteInput carries options for delete operations.
|
||||||
|
|||||||
@@ -285,3 +285,104 @@ func TestLinkExisting(t *testing.T) {
|
|||||||
t.Errorf("shared from internal: external %q", env.view("external"))
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareReport(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)}),
|
||||||
|
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)}),
|
||||||
|
NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("printer"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.30"), Local: model.Ptr(true)}),
|
||||||
|
NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "external", Name: model.Ptr("shop"), Type: model.Ptr("CNAME"), Data: model.Ptr("shops.example.net."), Local: model.Ptr(true)}))
|
||||||
|
st := env.state()
|
||||||
|
zones := []*model.Zone{st.FindZone("example.com.", st.ViewByName("internal").ID), st.FindZone("example.com.", st.ViewByName("external").ID)}
|
||||||
|
|
||||||
|
rep := Compare(st, zones, CompareFilter{})
|
||||||
|
if rep.Base != "internal" || !rep.Linked || !slices.Equal(rep.Views, []string{"internal", "external"}) {
|
||||||
|
t.Errorf("report %+v", rep)
|
||||||
|
}
|
||||||
|
if s := rep.Summary[0]; s.View != "external" || s.Different != 1 || s.OnlyBase != 2 || s.OnlyOther != 1 || s.Same != 2 {
|
||||||
|
t.Errorf("summary %+v", s)
|
||||||
|
}
|
||||||
|
if len(rep.Different) != 1 || rep.Different[0].Name != "www" {
|
||||||
|
t.Errorf("different %+v", rep.Different)
|
||||||
|
}
|
||||||
|
var only []string
|
||||||
|
for _, o := range rep.Only {
|
||||||
|
var names []string
|
||||||
|
for _, r := range o.Rows {
|
||||||
|
names = append(names, r.Name)
|
||||||
|
}
|
||||||
|
only = append(only, strings.Join(o.Views, "+")+": "+strings.Join(names, ","))
|
||||||
|
}
|
||||||
|
want(t, "only", only, "internal: nas,printer", "external: shop")
|
||||||
|
if rep.Same != 2 || rep.SameRows != nil {
|
||||||
|
t.Errorf("same %d, rows %v", rep.Same, rep.SameRows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One name: just its rows, the same ones included.
|
||||||
|
rep = Compare(st, zones, CompareFilter{Name: "www"})
|
||||||
|
if len(rep.Different) != 1 || len(rep.Only) != 0 || len(rep.SameRows) != 0 {
|
||||||
|
t.Errorf("www: %+v", rep)
|
||||||
|
}
|
||||||
|
rep = Compare(st, zones, CompareFilter{Name: "mail.example.com.", Type: "A"})
|
||||||
|
if len(rep.SameRows) != 1 || rep.SameRows[0].Name != "mail" || len(rep.Different)+len(rep.Only) != 0 {
|
||||||
|
t.Errorf("mail A: %+v", rep)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -268,6 +269,9 @@ func setZoneFields(st *model.State, z *model.Zone, in *ZoneInput, creating bool)
|
|||||||
if in.LinkGroup != nil {
|
if in.LinkGroup != nil {
|
||||||
z.LinkGroup = *in.LinkGroup
|
z.LinkGroup = *in.LinkGroup
|
||||||
}
|
}
|
||||||
|
if in.AutoPTR != nil {
|
||||||
|
z.AutoPTR = *in.AutoPTR
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -805,6 +809,12 @@ func setViewFields(v *model.View, in *ViewInput) error {
|
|||||||
if in.Acknowledged != nil {
|
if in.Acknowledged != nil {
|
||||||
v.Acknowledged = cleanAcks(*in.Acknowledged)
|
v.Acknowledged = cleanAcks(*in.Acknowledged)
|
||||||
}
|
}
|
||||||
|
if in.ReverseSuggestions != nil {
|
||||||
|
v.ReverseSuggestionsOff = !*in.ReverseSuggestions
|
||||||
|
}
|
||||||
|
if in.DismissedReverse != nil {
|
||||||
|
v.DismissedReverse = slices.Clone(*in.DismissedReverse)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1401,6 +1411,12 @@ func applySettings(st *model.State, op model.Op, c *applyCtx) error {
|
|||||||
if in.Acknowledged != nil {
|
if in.Acknowledged != nil {
|
||||||
s.Acknowledged = cleanAcks(*in.Acknowledged)
|
s.Acknowledged = cleanAcks(*in.Acknowledged)
|
||||||
}
|
}
|
||||||
|
if in.DismissedReverse != nil {
|
||||||
|
s.DismissedReverse = slices.Clone(*in.DismissedReverse)
|
||||||
|
}
|
||||||
|
if in.EncryptedDNSHosts != nil {
|
||||||
|
s.EncryptedDNSHosts = slices.Clone(*in.EncryptedDNSHosts)
|
||||||
|
}
|
||||||
s.Version++
|
s.Version++
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,549 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"jarl/internal/dnsname"
|
||||||
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/rr"
|
||||||
|
"jarl/internal/validate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reverse zones made easy (docs/reverse.md): filling a reverse zone from
|
||||||
|
// the A and AAAA records of its view, choosing its view, and suggesting
|
||||||
|
// the reverse zones a view lacks.
|
||||||
|
|
||||||
|
// addrUse is an address a forward record gives a name.
|
||||||
|
type addrUse struct {
|
||||||
|
addr netip.Addr
|
||||||
|
name string // the owner name, absolute
|
||||||
|
apex bool
|
||||||
|
view string // another view's record (fillAddresses), else ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// forwardAddresses returns the addresses of the enabled A and AAAA
|
||||||
|
// records of a view's primary forward zones (wildcards aside: a PTR record
|
||||||
|
// can't name them).
|
||||||
|
func forwardAddresses(st *model.State, viewID int64) []addrUse {
|
||||||
|
var out []addrUse
|
||||||
|
for _, z := range st.ZonesInView(viewID) {
|
||||||
|
if z.Type != model.ZonePrimary || z.Disabled || dnsname.IsReverse(z.Name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, r := range st.ZoneRecords(z.ID) {
|
||||||
|
if r.Disabled || (r.Type != "A" && r.Type != "AAAA") || strings.HasPrefix(r.Name, "*.") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a, err := netip.ParseAddr(r.Data)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, addrUse{addr: a.Unmap(), name: r.Name, apex: r.Name == z.Name})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// fillAddresses returns a view's addresses (forwardAddresses), then those
|
||||||
|
// of the other views' records that none of its own records use, marked
|
||||||
|
// with their view: a reverse zone in the internal view can name a host
|
||||||
|
// only the external view has a record for.
|
||||||
|
func fillAddresses(st *model.State, viewID int64) []addrUse {
|
||||||
|
out := forwardAddresses(st, viewID)
|
||||||
|
if len(st.Views) == 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
own := map[netip.Addr]bool{}
|
||||||
|
for _, u := range out {
|
||||||
|
own[u.addr] = true
|
||||||
|
}
|
||||||
|
for _, v := range st.OrderedViews() {
|
||||||
|
if v.ID == viewID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, u := range forwardAddresses(st, v.ID) {
|
||||||
|
if !own[u.addr] {
|
||||||
|
u.view = v.Name
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// PTRFill is a PTR record filling a reverse zone would add.
|
||||||
|
type PTRFill struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Name string `json:"name"` // the PTR owner name
|
||||||
|
Target string `json:"target"` // the host name it gives
|
||||||
|
Others []string `json:"others,omitempty"` // other names with this address
|
||||||
|
// From is the view whose record names the host, when the zone's own
|
||||||
|
// view has none for the address.
|
||||||
|
From string `json:"from,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PTRFills returns the PTR records a reverse zone named zoneName in a view
|
||||||
|
// lacks for the addresses of the view's A and AAAA records: one per
|
||||||
|
// address, naming its mail or name server host when it is one, otherwise
|
||||||
|
// a host rather than the domain itself, then the shortest name. An
|
||||||
|
// address no record of the view uses is named from another view's
|
||||||
|
// records (fillAddresses), marked From. existing are the zone's records
|
||||||
|
// (none for a new zone).
|
||||||
|
func PTRFills(st *model.State, viewID int64, zoneName string, existing []*model.Record) []PTRFill {
|
||||||
|
prefix, ok := dnsname.PrefixFromReverse(zoneName)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
has := map[string]bool{}
|
||||||
|
for _, r := range existing {
|
||||||
|
if r.Type == "PTR" {
|
||||||
|
has[r.Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
servers := serverNames(st, viewID)
|
||||||
|
byAddr := map[netip.Addr][]addrUse{}
|
||||||
|
var order []netip.Addr
|
||||||
|
for _, u := range fillAddresses(st, viewID) {
|
||||||
|
if !prefix.Contains(u.addr) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, seen := byAddr[u.addr]; !seen {
|
||||||
|
order = append(order, u.addr)
|
||||||
|
}
|
||||||
|
if !slices.ContainsFunc(byAddr[u.addr], func(o addrUse) bool { return o.name == u.name }) {
|
||||||
|
byAddr[u.addr] = append(byAddr[u.addr], u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.SortFunc(order, func(a, b netip.Addr) int { return a.Compare(b) })
|
||||||
|
var out []PTRFill
|
||||||
|
for _, a := range order {
|
||||||
|
owner := dnsname.ReverseName(a)
|
||||||
|
if has[owner] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
uses := byAddr[a]
|
||||||
|
slices.SortStableFunc(uses, func(x, y addrUse) int {
|
||||||
|
rank := func(u addrUse) int {
|
||||||
|
switch {
|
||||||
|
case servers[u.name]:
|
||||||
|
return 0
|
||||||
|
case !u.apex:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if d := rank(x) - rank(y); d != 0 {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
if d := strings.Count(x.name, ".") - strings.Count(y.name, "."); d != 0 {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
return strings.Compare(x.name, y.name)
|
||||||
|
})
|
||||||
|
f := PTRFill{Address: a.String(), Name: owner, Target: uses[0].name, From: uses[0].view}
|
||||||
|
for _, u := range uses[1:] {
|
||||||
|
if u.name != f.Target {
|
||||||
|
f.Others = append(f.Others, u.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// serverNames are the hosts the view's MX and NS records name.
|
||||||
|
func serverNames(st *model.State, viewID int64) map[string]bool {
|
||||||
|
out := map[string]bool{}
|
||||||
|
for _, z := range st.ZonesInView(viewID) {
|
||||||
|
if z.Type != model.ZonePrimary {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, r := range st.ZoneRecords(z.ID) {
|
||||||
|
switch r.Type {
|
||||||
|
case "MX":
|
||||||
|
if f := strings.Fields(r.Data); len(f) == 2 {
|
||||||
|
out[strings.ToLower(f[1])] = true
|
||||||
|
}
|
||||||
|
case "NS":
|
||||||
|
out[strings.ToLower(r.Data)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func fillWarnings(fills []PTRFill) []string {
|
||||||
|
var out []string
|
||||||
|
for _, f := range fills {
|
||||||
|
if f.From != "" {
|
||||||
|
out = append(out, fmt.Sprintf("%s: only view %s has a record for it, so its PTR record gives %s from there", f.Address, f.From, dnsname.Display(f.Target)))
|
||||||
|
}
|
||||||
|
if len(f.Others) > 0 {
|
||||||
|
out = append(out, fmt.Sprintf("%s has several names: its PTR record gives %s (not %s); change it if you prefer another", f.Address, dnsname.Display(f.Target), strings.Join(displayAll(f.Others), ", ")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayAll(names []string) []string {
|
||||||
|
out := make([]string, len(names))
|
||||||
|
for i, n := range names {
|
||||||
|
out[i] = dnsname.Display(n)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// zoneFillPTR expands zone.fill_ptr: the PTR records a reverse zone lacks.
|
||||||
|
func (x *expander) zoneFillPTR(req Request) ([]model.Op, error) {
|
||||||
|
var in LinkInput
|
||||||
|
if err := decode(req.Data, &in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
id := in.ZoneID
|
||||||
|
if id == 0 {
|
||||||
|
id = req.ID
|
||||||
|
}
|
||||||
|
z := x.st.Zones[id]
|
||||||
|
if z == nil {
|
||||||
|
return nil, errf(KindNotFound, "zone %d does not exist", id)
|
||||||
|
}
|
||||||
|
if z.Type != model.ZonePrimary {
|
||||||
|
return nil, errf(KindBadRequest, "zone %s isn't a primary zone", zoneDesc(x.st, z))
|
||||||
|
}
|
||||||
|
if _, ok := dnsname.PrefixFromReverse(z.Name); !ok {
|
||||||
|
return nil, errf(KindBadRequest, "zone %s isn't a reverse zone of whole octets or nibbles (like 10.0.10.in-addr.arpa)", zoneDesc(x.st, z))
|
||||||
|
}
|
||||||
|
fills := PTRFills(x.st, z.ViewID, z.Name, x.st.ZoneRecords(z.ID))
|
||||||
|
if len(fills) == 0 {
|
||||||
|
return nil, errf(KindBadRequest, "nothing to add: every address of %s's A and AAAA records in %s has a PTR record", viewOrServer(x.st, z.ViewID), zoneDesc(x.st, z))
|
||||||
|
}
|
||||||
|
x.warnings = append(x.warnings, fillWarnings(fills)...)
|
||||||
|
ids, err := x.ids(len(fills))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var ops []model.Op
|
||||||
|
for i, f := range fills {
|
||||||
|
ri := RecordInput{ZoneID: z.ID, Name: model.Ptr(f.Name), Type: model.Ptr("PTR"), Data: model.Ptr(f.Target)}
|
||||||
|
if z.LinkGroup != 0 {
|
||||||
|
ri.SharedID = model.Ptr(ids[i])
|
||||||
|
}
|
||||||
|
ops = append(ops, newOp(OpRecordCreate, ids[i], 0, ri, fmt.Sprintf("add %s IN PTR %s", f.Name, f.Target)))
|
||||||
|
}
|
||||||
|
return ops, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func viewOrServer(st *model.State, viewID int64) string {
|
||||||
|
if v := st.Views[viewID]; v != nil {
|
||||||
|
return "view " + v.Name
|
||||||
|
}
|
||||||
|
return "the server"
|
||||||
|
}
|
||||||
|
|
||||||
|
// publicViews returns which views the internet reaches.
|
||||||
|
func (x *expander) publicViews() map[int64]bool {
|
||||||
|
out := map[int64]bool{}
|
||||||
|
if x.exposures == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, e := range x.exposures(x.st) {
|
||||||
|
out[e.ViewID] = e.Public
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// reverseViewFor chooses the view of a new reverse zone given without one:
|
||||||
|
// the private view whose records use addresses in its range, or the only
|
||||||
|
// private view; views with reverse suggestions off don't count. nil with
|
||||||
|
// no error leaves the choice to the usual rules.
|
||||||
|
func reverseViewFor(st *model.State, public map[int64]bool, zoneName string) (*model.View, error) {
|
||||||
|
prefix, ok := dnsname.PrefixFromReverse(zoneName)
|
||||||
|
if !ok || len(st.Views) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var private, using []*model.View
|
||||||
|
for _, v := range st.OrderedViews() {
|
||||||
|
if public[v.ID] || v.ReverseSuggestionsOff {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
private = append(private, v)
|
||||||
|
if slices.ContainsFunc(forwardAddresses(st, v.ID), func(u addrUse) bool { return prefix.Contains(u.addr) }) {
|
||||||
|
using = append(using, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(using) == 1:
|
||||||
|
return using[0], nil
|
||||||
|
case len(using) > 1:
|
||||||
|
var names []string
|
||||||
|
for _, v := range using {
|
||||||
|
names = append(names, v.Name)
|
||||||
|
}
|
||||||
|
return nil, errf(KindBadRequest, "views %s all have addresses in %s: give the view (or create the zone in each)", strings.Join(names, " and "), prefix)
|
||||||
|
case len(private) == 1:
|
||||||
|
return private[0], nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReverseSuggestion is a reverse zone a view lacks for addresses its
|
||||||
|
// records use.
|
||||||
|
type ReverseSuggestion struct {
|
||||||
|
ViewID int64 `json:"view_id"`
|
||||||
|
View string `json:"view,omitempty"`
|
||||||
|
Prefix string `json:"prefix"`
|
||||||
|
Zone string `json:"zone"`
|
||||||
|
// Group is "yours" for networks that are yours for sure (private
|
||||||
|
// ranges, IPv6 prefixes this server has an address in), "public" for
|
||||||
|
// a public address that may be someone else's (a web host's).
|
||||||
|
Group string `json:"group"`
|
||||||
|
// Hidden says why the suggestion isn't made unasked (with All only):
|
||||||
|
// "public view", "suggestions off" or "dismissed".
|
||||||
|
Hidden string `json:"hidden,omitempty"`
|
||||||
|
Why string `json:"why"`
|
||||||
|
Fill int `json:"fill"` // PTR records creating it with fill adds
|
||||||
|
Samples []string `json:"samples"` // a few of them, "address name"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suggestion groups.
|
||||||
|
const (
|
||||||
|
SuggestYours = "yours"
|
||||||
|
SuggestPublic = "public"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReverseSuggestions returns the reverse zones the private views (or the
|
||||||
|
// server, without views) lack for their A and AAAA records' addresses:
|
||||||
|
//
|
||||||
|
// - yours: the /24 of a private IPv4 address, the /48 of a ULA or of a
|
||||||
|
// public IPv6 prefix this server has an address in (local);
|
||||||
|
// - public: a /32 (or /128) for any other public address. It may be a
|
||||||
|
// web host's; the rest of its /24 surely belongs to others, and a
|
||||||
|
// zone for it here would hide their names from your clients.
|
||||||
|
//
|
||||||
|
// Public views, views with suggestions turned off and dismissed networks
|
||||||
|
// get none: the internet asks the addresses' owners.
|
||||||
|
func ReverseSuggestions(st *model.State, exposures []validate.Exposure, local []netip.Addr) []ReverseSuggestion {
|
||||||
|
return ReverseCandidates(st, exposures, local, ReverseOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReverseOptions widen or narrow ReverseCandidates.
|
||||||
|
type ReverseOptions struct {
|
||||||
|
// All includes what isn't suggested unasked, marked Hidden: public
|
||||||
|
// views, views with suggestions off, dismissed networks.
|
||||||
|
All bool
|
||||||
|
// View narrows it to one view (by name; "" all). With All, the view
|
||||||
|
// also gets the addresses only other views' records use, marked
|
||||||
|
// Hidden "records in VIEW": a reverse zone there can name them.
|
||||||
|
View string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReverseCandidates is ReverseSuggestions with options: with All, every
|
||||||
|
// network the views' records use that no reverse zone in their view
|
||||||
|
// covers, for creating one by hand where suggestions are off.
|
||||||
|
func ReverseCandidates(st *model.State, exposures []validate.Exposure, local []netip.Addr, opt ReverseOptions) []ReverseSuggestion {
|
||||||
|
public := map[int64]bool{}
|
||||||
|
for _, e := range exposures {
|
||||||
|
public[e.ViewID] = e.Public
|
||||||
|
}
|
||||||
|
type scope struct {
|
||||||
|
id int64
|
||||||
|
dismissed []string
|
||||||
|
hidden string
|
||||||
|
}
|
||||||
|
var scopes []scope
|
||||||
|
if len(st.Views) > 0 {
|
||||||
|
for _, v := range st.OrderedViews() {
|
||||||
|
if opt.View != "" && !strings.EqualFold(v.Name, opt.View) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hidden := ""
|
||||||
|
switch {
|
||||||
|
case public[v.ID]:
|
||||||
|
hidden = "public view"
|
||||||
|
case v.ReverseSuggestionsOff:
|
||||||
|
hidden = "suggestions off"
|
||||||
|
}
|
||||||
|
if hidden == "" || opt.All {
|
||||||
|
scopes = append(scopes, scope{v.ID, v.DismissedReverse, hidden})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if !public[0] || opt.All {
|
||||||
|
hidden := ""
|
||||||
|
if public[0] {
|
||||||
|
hidden = "public server"
|
||||||
|
}
|
||||||
|
scopes = append(scopes, scope{0, st.Settings.DismissedReverse, hidden})
|
||||||
|
}
|
||||||
|
out := []ReverseSuggestion{}
|
||||||
|
for _, sc := range scopes {
|
||||||
|
seen := map[netip.Prefix]bool{}
|
||||||
|
addrs := forwardAddresses(st, sc.id)
|
||||||
|
if opt.All && opt.View != "" {
|
||||||
|
addrs = fillAddresses(st, sc.id)
|
||||||
|
}
|
||||||
|
for _, u := range addrs {
|
||||||
|
a := u.addr
|
||||||
|
if a.IsLoopback() || a.IsLinkLocalUnicast() || a.IsUnspecified() || a.IsMulticast() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rz, _ := reverseZoneFor(st, sc.id, a); rz != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p, group, why := suggestedPrefix(a, local)
|
||||||
|
hidden := sc.hidden
|
||||||
|
if hidden == "" && u.view != "" {
|
||||||
|
hidden = "records in " + u.view
|
||||||
|
}
|
||||||
|
if seen[p] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if slices.Contains(sc.dismissed, p.String()) {
|
||||||
|
if !opt.All {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if hidden == "" {
|
||||||
|
hidden = "dismissed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen[p] = true
|
||||||
|
name, err := dnsname.ReverseZone(p)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s := ReverseSuggestion{ViewID: sc.id, View: viewName(st, sc.id), Prefix: p.String(), Zone: strings.TrimSuffix(name, "."), Group: group, Hidden: hidden, Why: why, Samples: []string{}}
|
||||||
|
fills := PTRFills(st, sc.id, name, nil)
|
||||||
|
s.Fill = len(fills)
|
||||||
|
for i, f := range fills {
|
||||||
|
if i == 3 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
s.Samples = append(s.Samples, f.Address+" "+dnsname.Display(f.Target))
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.SortStableFunc(out, func(a, b ReverseSuggestion) int {
|
||||||
|
if a.Group != b.Group {
|
||||||
|
return strings.Compare(b.Group, a.Group) // yours first
|
||||||
|
}
|
||||||
|
if a.View != b.View {
|
||||||
|
return strings.Compare(a.View, b.View)
|
||||||
|
}
|
||||||
|
return netip.MustParsePrefix(a.Prefix).Addr().Compare(netip.MustParsePrefix(b.Prefix).Addr())
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// suggestedPrefix returns the reverse zone network for an address, its
|
||||||
|
// group and why.
|
||||||
|
func suggestedPrefix(a netip.Addr, local []netip.Addr) (netip.Prefix, string, string) {
|
||||||
|
switch {
|
||||||
|
case a.Is4() && (a.IsPrivate() || cgnat.Contains(a)):
|
||||||
|
return netip.PrefixFrom(a, 24).Masked(), SuggestYours, "private network"
|
||||||
|
case a.Is4():
|
||||||
|
return netip.PrefixFrom(a, 32), SuggestPublic, "a public address: create it only if it is yours (not a web host's); the rest of its /24 belongs to others"
|
||||||
|
case a.IsPrivate():
|
||||||
|
return netip.PrefixFrom(a, 48).Masked(), SuggestYours, "your ULA /48 (one zone for all its subnets)"
|
||||||
|
}
|
||||||
|
p := netip.PrefixFrom(a, 48).Masked()
|
||||||
|
if slices.ContainsFunc(local, func(l netip.Addr) bool { return p.Contains(l.Unmap()) }) {
|
||||||
|
return p, SuggestYours, "your IPv6 /48 (this server has an address in it): one zone for all its subnets"
|
||||||
|
}
|
||||||
|
return netip.PrefixFrom(a, 128), SuggestPublic, "a public address outside this server's networks: create it only if it is yours"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReverseSuggestions lists the reverse zones the views lack (opt.All:
|
||||||
|
// also where suggestions aren't made unasked).
|
||||||
|
func (e *Engine) ReverseSuggestions(st *model.State, opt ReverseOptions) []ReverseSuggestion {
|
||||||
|
var local []netip.Addr
|
||||||
|
if e.cfg.LocalAddrs != nil {
|
||||||
|
local = e.cfg.LocalAddrs()
|
||||||
|
}
|
||||||
|
return ReverseCandidates(st, e.Exposures(st), local, opt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reverseDismiss expands reverse.dismiss and reverse.undismiss.
|
||||||
|
func (x *expander) reverseDismiss(req Request, dismiss bool) ([]model.Op, error) {
|
||||||
|
var in DismissInput
|
||||||
|
if err := decode(req.Data, &in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
raw := in.Prefixes
|
||||||
|
if in.Prefix != "" {
|
||||||
|
raw = append(raw, in.Prefix)
|
||||||
|
}
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil, errf(KindBadRequest, "give the network (prefix) of the suggestion")
|
||||||
|
}
|
||||||
|
var prefixes []string
|
||||||
|
for _, r := range raw {
|
||||||
|
p, err := netip.ParsePrefix(strings.TrimSpace(r))
|
||||||
|
if err != nil {
|
||||||
|
return nil, errf(KindBadRequest, "prefix: %v", err)
|
||||||
|
}
|
||||||
|
if s := p.Masked().String(); !slices.Contains(prefixes, s) {
|
||||||
|
prefixes = append(prefixes, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var v *model.View
|
||||||
|
switch {
|
||||||
|
case in.ViewID != 0:
|
||||||
|
v = x.st.Views[in.ViewID]
|
||||||
|
case in.View != "":
|
||||||
|
v = x.st.ViewByName(in.View)
|
||||||
|
}
|
||||||
|
if (in.ViewID != 0 || in.View != "") && v == nil {
|
||||||
|
return nil, errf(KindNotFound, "no view %s", firstNonEmptyStr(in.View, fmt.Sprint(in.ViewID)))
|
||||||
|
}
|
||||||
|
if v == nil && len(x.st.Views) > 0 {
|
||||||
|
return nil, errf(KindBadRequest, "give the view of the suggestion")
|
||||||
|
}
|
||||||
|
list, where := x.st.Settings.DismissedReverse, "the server"
|
||||||
|
if v != nil {
|
||||||
|
list, where = v.DismissedReverse, "view "+v.Name
|
||||||
|
}
|
||||||
|
out := slices.DeleteFunc(slices.Clone(list), func(s string) bool { return slices.Contains(prefixes, s) })
|
||||||
|
what := prefixes[0]
|
||||||
|
if len(prefixes) > 1 {
|
||||||
|
what = fmt.Sprintf("%d networks (%s, …)", len(prefixes), prefixes[0])
|
||||||
|
}
|
||||||
|
summary := "dismiss the reverse zone suggestion for " + what + " in " + where
|
||||||
|
if dismiss {
|
||||||
|
out = append(out, prefixes...)
|
||||||
|
slices.Sort(out)
|
||||||
|
} else {
|
||||||
|
if len(out) == len(list) {
|
||||||
|
return nil, errf(KindConflict, "%s isn't dismissed in %s", what, where)
|
||||||
|
}
|
||||||
|
summary = "suggest a reverse zone for " + what + " in " + where + " again"
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
out = []string{}
|
||||||
|
}
|
||||||
|
if v != nil {
|
||||||
|
return []model.Op{newOp(OpViewUpdate, v.ID, v.Version, ViewInput{DismissedReverse: &out}, summary)}, nil
|
||||||
|
}
|
||||||
|
return []model.Op{newOp(OpSettingsUpdate, 0, 0, SettingsInput{DismissedReverse: &out}, summary)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var cgnat = netip.MustParsePrefix("100.64.0.0/10")
|
||||||
|
|
||||||
|
// autoPTR reports whether a record's PTR is kept in step by its zone's
|
||||||
|
// auto_ptr setting.
|
||||||
|
func autoPTR(z *model.Zone, owner string, p *rr.Parsed) bool {
|
||||||
|
if z == nil || !z.AutoPTR || strings.HasPrefix(owner, "*.") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok := addrOf(p)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func viewName(st *model.State, id int64) string {
|
||||||
|
if v := st.Views[id]; v != nil {
|
||||||
|
return v.Name
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ptrs returns a reverse zone's PTR records in a view as "owner target",
|
||||||
|
// with " disabled" when so.
|
||||||
|
func (env *testEnv) ptrs(view, zone string) []string {
|
||||||
|
env.t.Helper()
|
||||||
|
s := env.state()
|
||||||
|
z := s.FindZone(zone, s.ViewByName(view).ID)
|
||||||
|
if z == nil {
|
||||||
|
env.t.Fatalf("no zone %s in %s", zone, view)
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, r := range s.ZoneRecords(z.ID) {
|
||||||
|
if r.Type == "PTR" {
|
||||||
|
d := r.Name + " " + r.Data
|
||||||
|
if r.Disabled {
|
||||||
|
d += " disabled"
|
||||||
|
}
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReverseFillAndSuggest(t *testing.T) {
|
||||||
|
env := linkedEnv(t)
|
||||||
|
env.submit(env.admin,
|
||||||
|
recIn("internal", "@", "A", "192.0.2.25"),
|
||||||
|
recIn("internal", "nas", "A", "10.0.10.20"),
|
||||||
|
recIn("internal", "files", "A", "10.0.10.20"),
|
||||||
|
recIn("internal", "nas", "AAAA", "fdab:abab:abab:10::20"),
|
||||||
|
recIn("internal", "*.dev", "A", "10.0.10.99"),
|
||||||
|
recIn("internal", "lo", "A", "127.0.0.1"))
|
||||||
|
|
||||||
|
sugg := ReverseSuggestions(env.state(), env.e.Exposures(env.state()), nil)
|
||||||
|
var got []string
|
||||||
|
for _, s := range sugg {
|
||||||
|
got = append(got, s.Group+" "+s.View+" "+s.Prefix+" "+s.Zone)
|
||||||
|
}
|
||||||
|
want(t, "suggestions", got,
|
||||||
|
"yours internal 10.0.10.0/24 10.0.10.in-addr.arpa",
|
||||||
|
"yours internal fdab:abab:abab::/48 b.a.b.a.b.a.b.a.b.a.d.f.ip6.arpa",
|
||||||
|
"public internal 192.0.2.10/32 10.2.0.192.in-addr.arpa",
|
||||||
|
"public internal 192.0.2.25/32 25.2.0.192.in-addr.arpa")
|
||||||
|
if sugg[0].Fill != 1 || sugg[0].Samples[0] != "10.0.10.20 files.example.com" {
|
||||||
|
t.Errorf("10.0.10.0/24: %+v", sugg[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// No view given: the private view whose records use the addresses.
|
||||||
|
res := env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.10.0/24"), Fill: true}))
|
||||||
|
want(t, "10.0.10/24", env.ptrs("internal", "10.0.10.in-addr.arpa."), "20.10.0.10.in-addr.arpa. files.example.com.")
|
||||||
|
if !slices.ContainsFunc(res.Warnings, func(w string) bool { return strings.Contains(w, "10.0.10.20 has several names") }) {
|
||||||
|
t.Errorf("warnings %q", res.Warnings)
|
||||||
|
}
|
||||||
|
// The mail server's name beats the domain's own.
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.25/32"), Fill: true}))
|
||||||
|
want(t, "192.0.2.25", env.ptrs("internal", "25.2.0.192.in-addr.arpa."), "25.2.0.192.in-addr.arpa. mail.example.com.")
|
||||||
|
|
||||||
|
// Filling an existing zone; then there is nothing left to add.
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("fdab:abab:abab::/48")}))
|
||||||
|
st := env.state()
|
||||||
|
z6 := st.FindZone("b.a.b.a.b.a.b.a.b.a.d.f.ip6.arpa.", st.ViewByName("internal").ID)
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneFillPTR, z6.ID, LinkInput{}))
|
||||||
|
if p := env.ptrs("internal", z6.Name); len(p) != 1 || !strings.HasSuffix(p[0], " nas.example.com.") {
|
||||||
|
t.Errorf("ip6 fill: %q", p)
|
||||||
|
}
|
||||||
|
if e := env.submitErr(env.admin, NewRequest(OpZoneFillPTR, z6.ID, LinkInput{})); !strings.Contains(e.Message, "nothing to add") {
|
||||||
|
t.Errorf("second fill: %v", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
got = got[:0]
|
||||||
|
for _, s := range ReverseSuggestions(env.state(), env.e.Exposures(env.state()), nil) {
|
||||||
|
got = append(got, s.Prefix)
|
||||||
|
}
|
||||||
|
want(t, "suggestions left", got, "192.0.2.10/32")
|
||||||
|
|
||||||
|
// Addresses that two private views use: the view must be given.
|
||||||
|
env.submit(env.admin, NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("lab"), MatchClients: &[]string{"172.16.0.0/12"}}))
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("lab.test"), View: model.Ptr("lab")}),
|
||||||
|
NewRequest(OpRecordCreate, 0, RecordInput{Zone: "lab.test", View: "lab", Name: model.Ptr("x"), Type: model.Ptr("A"), Data: model.Ptr("192.0.2.10")}))
|
||||||
|
if e := env.submitErr(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.10/32")})); !strings.Contains(e.Message, "views internal and lab all have addresses") {
|
||||||
|
t.Errorf("two views: %v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoPTR(t *testing.T) {
|
||||||
|
env := linkedEnv(t)
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.10.0/24"), View: model.Ptr("internal")}))
|
||||||
|
st := env.state()
|
||||||
|
zid := st.FindZone("example.com.", st.ViewByName("internal").ID).ID
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneUpdate, zid, ZoneInput{AutoPTR: model.Ptr(true)}))
|
||||||
|
rz := "10.0.10.in-addr.arpa."
|
||||||
|
|
||||||
|
res := env.submit(env.admin, recIn("internal", "host", "A", "10.0.10.30"), recIn("internal", "pub", "A", "203.0.113.5"))
|
||||||
|
want(t, "added", env.ptrs("internal", rz), "30.10.0.10.in-addr.arpa. host.example.com.")
|
||||||
|
if len(res.Warnings) > 0 {
|
||||||
|
t.Errorf("no reverse zone for 203.0.113.5 isn't worth a warning: %q", res.Warnings)
|
||||||
|
}
|
||||||
|
// A second name for the address: its PTR stays.
|
||||||
|
res = env.submit(env.admin, recIn("internal", "alias", "A", "10.0.10.30"))
|
||||||
|
want(t, "second name", env.ptrs("internal", rz), "30.10.0.10.in-addr.arpa. host.example.com.")
|
||||||
|
if len(res.Warnings) > 0 {
|
||||||
|
t.Errorf("warnings %q", res.Warnings)
|
||||||
|
}
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("internal", "alias", "A"), nil))
|
||||||
|
|
||||||
|
// Moved, disabled, enabled, deleted.
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "host", "A"), RecordInput{Data: model.Ptr("10.0.10.31")}))
|
||||||
|
want(t, "moved", env.ptrs("internal", rz), "31.10.0.10.in-addr.arpa. host.example.com.")
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "host", "A"), RecordInput{Disabled: model.Ptr(true)}))
|
||||||
|
want(t, "disabled", env.ptrs("internal", rz), "31.10.0.10.in-addr.arpa. host.example.com. disabled")
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "host", "A"), RecordInput{Disabled: model.Ptr(false)}))
|
||||||
|
want(t, "enabled", env.ptrs("internal", rz), "31.10.0.10.in-addr.arpa. host.example.com.")
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("internal", "host", "A"), nil))
|
||||||
|
want(t, "deleted", env.ptrs("internal", rz))
|
||||||
|
|
||||||
|
// A whole set replaced.
|
||||||
|
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "internal", Name: "multi", Type: "A", Data: []string{"10.0.10.40", "10.0.10.41"}}))
|
||||||
|
want(t, "set", env.ptrs("internal", rz), "40.10.0.10.in-addr.arpa. multi.example.com.", "41.10.0.10.in-addr.arpa. multi.example.com.")
|
||||||
|
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "internal", Name: "multi", Type: "A", Data: []string{"10.0.10.41"}}))
|
||||||
|
want(t, "set shrunk", env.ptrs("internal", rz), "41.10.0.10.in-addr.arpa. multi.example.com.")
|
||||||
|
// Deleting by name and type.
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordDelete, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("multi"), Type: model.Ptr("A")}))
|
||||||
|
want(t, "deleted by match", env.ptrs("internal", rz))
|
||||||
|
|
||||||
|
// Off again: records come and go alone.
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneUpdate, zid, ZoneInput{AutoPTR: model.Ptr(false)}))
|
||||||
|
env.submit(env.admin, recIn("internal", "quiet", "A", "10.0.10.50"))
|
||||||
|
want(t, "off", env.ptrs("internal", rz))
|
||||||
|
}
|
||||||
|
|
||||||
|
func suggested(env *testEnv, local ...netip.Addr) []string {
|
||||||
|
var out []string
|
||||||
|
for _, s := range ReverseSuggestions(env.state(), env.e.Exposures(env.state()), local) {
|
||||||
|
out = append(out, s.Group+" "+s.View+" "+s.Prefix)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public IPv6: the /48 only when this server has an address in it, else
|
||||||
|
// the address alone, as a public suggestion. Dismissed networks and views
|
||||||
|
// with suggestions off get none.
|
||||||
|
func TestReverseSuggestionGroups(t *testing.T) {
|
||||||
|
env := linkedEnv(t)
|
||||||
|
env.submit(env.admin,
|
||||||
|
recIn("internal", "home", "AAAA", "2a02:8013:7f3d:10::5"),
|
||||||
|
recIn("internal", "gh", "AAAA", "2606:50c0:8000::153"))
|
||||||
|
want(t, "no local addresses", suggested(env),
|
||||||
|
"public internal 192.0.2.10/32", "public internal 192.0.2.25/32",
|
||||||
|
"public internal 2606:50c0:8000::153/128", "public internal 2a02:8013:7f3d:10::5/128")
|
||||||
|
want(t, "this server is in 2a02:8013:7f3d::/48", suggested(env, netip.MustParseAddr("2a02:8013:7f3d:10:1266:6aff:fef9:f5b1")),
|
||||||
|
"yours internal 2a02:8013:7f3d::/48",
|
||||||
|
"public internal 192.0.2.10/32", "public internal 192.0.2.25/32", "public internal 2606:50c0:8000::153/128")
|
||||||
|
|
||||||
|
env.submit(env.admin, NewRequest(OpReverseDismiss, 0, DismissInput{View: "internal", Prefix: "2606:50c0:8000::153/128"}))
|
||||||
|
if got := suggested(env); slices.Contains(got, "public internal 2606:50c0:8000::153/128") {
|
||||||
|
t.Errorf("dismissed still suggested: %q", got)
|
||||||
|
}
|
||||||
|
if e := env.submitErr(env.admin, NewRequest(OpReverseUndismiss, 0, DismissInput{View: "internal", Prefix: "192.0.2.10/32"})); e.Kind != KindConflict {
|
||||||
|
t.Errorf("undismiss of one not dismissed: %v", e)
|
||||||
|
}
|
||||||
|
env.submit(env.admin, NewRequest(OpReverseUndismiss, 0, DismissInput{View: "internal", Prefix: "2606:50c0:8000::153/128"}))
|
||||||
|
// Several at once.
|
||||||
|
env.submit(env.admin, NewRequest(OpReverseDismiss, 0, DismissInput{View: "internal", Prefixes: []string{"192.0.2.10/32", "192.0.2.25/32"}}))
|
||||||
|
if got := suggested(env); slices.ContainsFunc(got, func(s string) bool { return strings.Contains(s, "192.0.2.") }) {
|
||||||
|
t.Errorf("dismissed together, still suggested: %q", got)
|
||||||
|
}
|
||||||
|
env.submit(env.admin, NewRequest(OpReverseUndismiss, 0, DismissInput{View: "internal", Prefixes: []string{"192.0.2.10/32", "192.0.2.25/32"}}))
|
||||||
|
if got := suggested(env); !slices.Contains(got, "public internal 2606:50c0:8000::153/128") {
|
||||||
|
t.Errorf("undismissed not suggested: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second private view using the same address (a VPS's view): with
|
||||||
|
// its suggestions off it gets none, and doesn't count when choosing
|
||||||
|
// the view of a new reverse zone.
|
||||||
|
env.submit(env.admin, NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("vps"), MatchClients: &[]string{"198.51.100.7"}}))
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("vps"), LinkWith: "internal"}))
|
||||||
|
if got := suggested(env); !slices.Contains(got, "public vps 192.0.2.25/32") {
|
||||||
|
t.Errorf("vps view: %q", got)
|
||||||
|
}
|
||||||
|
if e := env.submitErr(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.25/32")})); !strings.Contains(e.Message, "views internal and vps") {
|
||||||
|
t.Errorf("two views: %v", e)
|
||||||
|
}
|
||||||
|
vps := env.state().ViewByName("vps")
|
||||||
|
env.submit(env.admin, NewRequest(OpViewUpdate, vps.ID, ViewInput{ReverseSuggestions: model.Ptr(false)}))
|
||||||
|
if got := suggested(env); slices.ContainsFunc(got, func(s string) bool { return strings.Contains(s, " vps ") }) {
|
||||||
|
t.Errorf("vps view with suggestions off: %q", got)
|
||||||
|
}
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.25/32"), Fill: true}))
|
||||||
|
want(t, "went to internal", env.ptrs("internal", "25.2.0.192.in-addr.arpa."), "25.2.0.192.in-addr.arpa. mail.example.com.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A zone created inside another takes the parent's records under it.
|
||||||
|
func TestChildZoneTakesRecords(t *testing.T) {
|
||||||
|
env := linkedEnv(t)
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.0.0/8"), View: model.Ptr("internal")}))
|
||||||
|
ptr := func(name, data string) Request {
|
||||||
|
return NewRequest(OpRecordCreate, 0, RecordInput{Zone: "10.in-addr.arpa", View: "internal", Name: &name, Type: model.Ptr("PTR"), Data: &data})
|
||||||
|
}
|
||||||
|
env.submit(env.admin, ptr("20.10.0.10.in-addr.arpa.", "printer.example.com."), ptr("5.20.0.10.in-addr.arpa.", "other.example.com."))
|
||||||
|
env.submit(env.admin, recIn("internal", "nas", "A", "10.0.10.30"))
|
||||||
|
res := env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.10.0/24"), View: model.Ptr("internal"), Fill: true}))
|
||||||
|
want(t, "/24", env.ptrs("internal", "10.0.10.in-addr.arpa."),
|
||||||
|
"20.10.0.10.in-addr.arpa. printer.example.com.", "30.10.0.10.in-addr.arpa. nas.example.com.")
|
||||||
|
want(t, "/8 keeps the rest", env.ptrs("internal", "10.in-addr.arpa."), "5.20.0.10.in-addr.arpa. other.example.com.")
|
||||||
|
for _, p := range res.Problems {
|
||||||
|
if p.Code == "shadowed_by_zone" {
|
||||||
|
t.Errorf("still hidden records: %v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A forward child: its delegation and glue stay in the parent.
|
||||||
|
env.submit(env.admin,
|
||||||
|
recIn("internal", "lab", "NS", "ns.lab.example.com."),
|
||||||
|
recIn("internal", "ns.lab", "A", "10.0.30.1"),
|
||||||
|
recIn("internal", "www.lab", "A", "10.0.30.2"))
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("lab.example.com"), View: model.Ptr("internal")}))
|
||||||
|
st := env.state()
|
||||||
|
lab := st.FindZone("lab.example.com.", st.ViewByName("internal").ID)
|
||||||
|
var labRecs []string
|
||||||
|
for _, r := range st.ZoneRecords(lab.ID) {
|
||||||
|
if r.Type != "NS" {
|
||||||
|
labRecs = append(labRecs, r.Name+" "+r.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
want(t, "lab", labRecs, "www.lab.example.com. A")
|
||||||
|
v := env.view("internal") // without NS records
|
||||||
|
if !slices.Contains(v, "ns.lab A 10.0.30.1 L") || slices.ContainsFunc(v, func(s string) bool { return strings.HasPrefix(s, "www.lab ") }) {
|
||||||
|
t.Errorf("parent after: %q", v)
|
||||||
|
}
|
||||||
|
if env.recordID("internal", "lab", "NS") == 0 {
|
||||||
|
t.Error("the delegation left the parent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With All, the networks no suggestion is made for unasked are listed too,
|
||||||
|
// marked: views with suggestions off, dismissed ones, public views.
|
||||||
|
func TestReverseCandidatesAll(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: "internal", Name: "nas", Type: "AAAA", Data: []string{"2a02:8013:7f3d:10::5"}, Local: model.Ptr(true)}))
|
||||||
|
env.submit(env.admin, NewRequest(OpReverseDismiss, 0, DismissInput{View: "internal", Prefix: "192.0.2.10/32"}))
|
||||||
|
internal := env.state().ViewByName("internal")
|
||||||
|
env.submit(env.admin, NewRequest(OpViewUpdate, internal.ID, ViewInput{ReverseSuggestions: model.Ptr(false)}))
|
||||||
|
list := func(opt ReverseOptions) []string {
|
||||||
|
var out []string
|
||||||
|
for _, s := range ReverseCandidates(env.state(), env.e.Exposures(env.state()), nil, opt) {
|
||||||
|
out = append(out, s.View+" "+s.Prefix+" "+s.Hidden)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
want(t, "unasked", list(ReverseOptions{}))
|
||||||
|
want(t, "internal, all", list(ReverseOptions{All: true, View: "internal"}),
|
||||||
|
"internal 192.0.2.10/32 suggestions off", "internal 192.0.2.25/32 suggestions off", "internal 2a02:8013:7f3d:10::5/128 suggestions off")
|
||||||
|
got := list(ReverseOptions{All: true, View: "external"})
|
||||||
|
want(t, "external, all", got, "external 192.0.2.10/32 public view", "external 192.0.2.25/32 public view", "external 2a02:8013:7f3d:10::5/128 public view")
|
||||||
|
|
||||||
|
env.submit(env.admin, NewRequest(OpViewUpdate, internal.ID, ViewInput{ReverseSuggestions: model.Ptr(true)}))
|
||||||
|
want(t, "dismissed", list(ReverseOptions{All: true, View: "internal"}),
|
||||||
|
"internal 192.0.2.10/32 dismissed", "internal 192.0.2.25/32 ", "internal 2a02:8013:7f3d:10::5/128 ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// An address only another view has a record for: a reverse zone in the
|
||||||
|
// internal view is offered for it, and filled from that view's record.
|
||||||
|
func TestReverseFromOtherView(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: "vps", Type: "AAAA", Data: []string{"2600:3c01::f03c:93ff:fe2b:1"}, Local: model.Ptr(true)}))
|
||||||
|
var got []string
|
||||||
|
for _, s := range ReverseCandidates(env.state(), env.e.Exposures(env.state()), nil, ReverseOptions{All: true, View: "internal"}) {
|
||||||
|
got = append(got, s.Prefix+" "+s.Hidden)
|
||||||
|
}
|
||||||
|
if !slices.Contains(got, "2600:3c01::f03c:93ff:fe2b:1/128 records in external") {
|
||||||
|
t.Errorf("internal candidates %q", got)
|
||||||
|
}
|
||||||
|
// Not suggested unasked: the internal view's own records don't use it.
|
||||||
|
for _, s := range ReverseSuggestions(env.state(), env.e.Exposures(env.state()), nil) {
|
||||||
|
if strings.HasPrefix(s.Prefix, "2600:") {
|
||||||
|
t.Errorf("suggested unasked: %+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res := env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("2600:3c01::f03c:93ff:fe2b:1/128"), View: model.Ptr("internal"), Fill: true}))
|
||||||
|
st := env.state()
|
||||||
|
var zone string
|
||||||
|
for _, z := range st.ZonesInView(st.ViewByName("internal").ID) {
|
||||||
|
if strings.HasSuffix(z.Name, ".ip6.arpa.") {
|
||||||
|
zone = z.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p := env.ptrs("internal", zone); len(p) != 1 || !strings.HasSuffix(p[0], " vps.example.com.") {
|
||||||
|
t.Errorf("filled %q", p)
|
||||||
|
}
|
||||||
|
if !slices.ContainsFunc(res.Warnings, func(w string) bool { return strings.Contains(w, "only view external has a record for it") }) {
|
||||||
|
t.Errorf("warnings %q", res.Warnings)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-1
@@ -54,6 +54,11 @@ type View struct {
|
|||||||
// internet (docs/exposure.md): "" works it out, ExposurePublic or
|
// internet (docs/exposure.md): "" works it out, ExposurePublic or
|
||||||
// ExposurePrivate say so.
|
// ExposurePrivate say so.
|
||||||
Exposure string `json:"exposure,omitempty"`
|
Exposure string `json:"exposure,omitempty"`
|
||||||
|
// ReverseSuggestionsOff stops suggesting reverse zones for the view's
|
||||||
|
// addresses (docs/reverse.md), e.g. for a view only a few servers use;
|
||||||
|
// DismissedReverse are the networks whose suggestion was dismissed.
|
||||||
|
ReverseSuggestionsOff bool `json:"reverse_suggestions_off,omitempty"`
|
||||||
|
DismissedReverse []string `json:"dismissed_reverse,omitempty"`
|
||||||
// Acknowledged are exposure findings accepted on purpose; they are
|
// Acknowledged are exposure findings accepted on purpose; they are
|
||||||
// shown as info instead of warnings.
|
// shown as info instead of warnings.
|
||||||
Acknowledged []Acknowledgement `json:"acknowledged,omitempty"`
|
Acknowledged []Acknowledgement `json:"acknowledged,omitempty"`
|
||||||
@@ -170,6 +175,10 @@ type Zone struct {
|
|||||||
// keeps their shared records in step (docs/views.md#linked-zones). 0
|
// keeps their shared records in step (docs/views.md#linked-zones). 0
|
||||||
// for a zone that isn't linked; linked zones share the ID.
|
// for a zone that isn't linked; linked zones share the ID.
|
||||||
LinkGroup int64 `json:"link_group,omitempty"`
|
LinkGroup int64 `json:"link_group,omitempty"`
|
||||||
|
// AutoPTR keeps the PTR records of the zone's A and AAAA records in
|
||||||
|
// step with them, in the reverse zones of the zone's views, as if every
|
||||||
|
// change asked for it (docs/reverse.md).
|
||||||
|
AutoPTR bool `json:"auto_ptr,omitempty"`
|
||||||
Meta
|
Meta
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +272,13 @@ type Settings struct {
|
|||||||
// for server-wide findings such as a visible version): see View.
|
// for server-wide findings such as a visible version): see View.
|
||||||
Exposure string `json:"exposure,omitempty"`
|
Exposure string `json:"exposure,omitempty"`
|
||||||
Acknowledged []Acknowledgement `json:"acknowledged,omitempty"`
|
Acknowledged []Acknowledgement `json:"acknowledged,omitempty"`
|
||||||
Version int `json:"version"`
|
// DismissedReverse are dismissed reverse zone suggestions (without
|
||||||
|
// views; see View).
|
||||||
|
DismissedReverse []string `json:"dismissed_reverse,omitempty"`
|
||||||
|
// EncryptedDNSHosts are the host names clients use for DNS over TLS
|
||||||
|
// and HTTPS (docs/encrypted-dns.md); the first is checked first.
|
||||||
|
EncryptedDNSHosts []string `json:"encrypted_dns_hosts,omitempty"`
|
||||||
|
Version int `json:"version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BlockingPaused reports whether blocking is paused, for a time or until
|
// BlockingPaused reports whether blocking is paused, for a time or until
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"jarl/internal/api"
|
"jarl/internal/api"
|
||||||
"jarl/internal/auth"
|
"jarl/internal/auth"
|
||||||
"jarl/internal/certs"
|
"jarl/internal/certs"
|
||||||
|
"jarl/internal/encdns"
|
||||||
"jarl/internal/model"
|
"jarl/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,6 +22,9 @@ type certView struct {
|
|||||||
NextTry *time.Time `json:"next_attempt,omitempty"` // acme: when the loop obtains it next
|
NextTry *time.Time `json:"next_attempt,omitempty"` // acme: when the loop obtains it next
|
||||||
Files *certFiles `json:"files,omitempty"` // dns use: where named reads it
|
Files *certFiles `json:"files,omitempty"` // dns use: where named reads it
|
||||||
Serving bool `json:"serving,omitempty"` // jarld serves it now (web use)
|
Serving bool `json:"serving,omitempty"` // jarld serves it now (web use)
|
||||||
|
// NamedUse says what of named's configuration uses it: "DNS over TLS
|
||||||
|
// on 10.0.10.5 port 853" (list and get only).
|
||||||
|
NamedUse []string `json:"named_use,omitempty"`
|
||||||
// IssuingSince is set while Jarl is obtaining it.
|
// IssuingSince is set while Jarl is obtaining it.
|
||||||
IssuingSince *time.Time `json:"issuing_since,omitempty"`
|
IssuingSince *time.Time `json:"issuing_since,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -79,9 +83,15 @@ func (s *Server) certList(w http.ResponseWriter, r *http.Request, p *auth.Princi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
st, err := s.store.LoadState(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
out := []certView{}
|
out := []certView{}
|
||||||
for _, c := range all {
|
for _, c := range all {
|
||||||
out = append(out, s.presentCert(c))
|
v := s.presentCert(c)
|
||||||
|
v.NamedUse = encdns.CertUses(st, s.cfg.Certs.FilesDir(c.Name))
|
||||||
|
out = append(out, v)
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"certificates": out, "account": s.cfg.Certs.AccountInfo(r.Context())})
|
writeJSON(w, http.StatusOK, map[string]any{"certificates": out, "account": s.cfg.Certs.AccountInfo(r.Context())})
|
||||||
return nil
|
return nil
|
||||||
@@ -96,7 +106,13 @@ func (s *Server) certGet(w http.ResponseWriter, r *http.Request, p *auth.Princip
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return certErr(err)
|
return certErr(err)
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, s.presentCert(c))
|
st, err := s.store.LoadState(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
v := s.presentCert(c)
|
||||||
|
v.NamedUse = encdns.CertUses(st, s.cfg.Certs.FilesDir(c.Name))
|
||||||
|
writeJSON(w, http.StatusOK, v)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
@@ -284,6 +285,21 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
|||||||
resp.Backup = s.backupStatus(r.Context(), now)
|
resp.Backup = s.backupStatus(r.Context(), now)
|
||||||
resp.TOTP = s.totpOverview(r.Context())
|
resp.TOTP = s.totpOverview(r.Context())
|
||||||
resp.Certificates = s.certSummary(r.Context())
|
resp.Certificates = s.certSummary(r.Context())
|
||||||
|
resp.Storage = s.storage(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())
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/engine"
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// zoneCompare: GET /zones/{zone}/compare compares a zone with the same
|
||||||
|
// zone in other views (docs/views.md#comparing-views), from the zone's own
|
||||||
|
// view: ?with=a,b picks the other views (default: every view with a
|
||||||
|
// primary zone of that name), ?name=www[&type=A] narrows it to one name,
|
||||||
|
// ?all=1 includes what is the same. A zone named without its view is
|
||||||
|
// compared from the first view that has it. (?views=a,b, the views to
|
||||||
|
// compare with the first as the base, works too.)
|
||||||
|
func (s *Server) zoneCompare(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
st, err := s.state(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
q := r.URL.Query()
|
||||||
|
list := func(k string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, v := range strings.Split(q.Get(k), ",") {
|
||||||
|
if v = strings.TrimSpace(v); v != "" {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
with, views := list("with"), list("views")
|
||||||
|
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)
|
||||||
|
zs := st.ZonesNamed(name)
|
||||||
|
if perr != nil || len(zs) < 2 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st.SortByView(zs)
|
||||||
|
z = zs[0]
|
||||||
|
if len(views) > 0 {
|
||||||
|
for _, o := range zs {
|
||||||
|
if strings.EqualFold(st.ViewName(o), views[0]) {
|
||||||
|
z = o
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
readable := func(o *model.Zone) bool {
|
||||||
|
vn := st.ViewName(o)
|
||||||
|
return o.Type == model.ZonePrimary && p.CanSeeZone(o.Name, vn) && p.Can(auth.Action{Resource: auth.ResRecords, Zone: o.Name, View: vn})
|
||||||
|
}
|
||||||
|
if !readable(z) {
|
||||||
|
return badRequest("zone %s isn't a primary zone whose records you may read", engine.Display(z.Name))
|
||||||
|
}
|
||||||
|
zones := []*model.Zone{z}
|
||||||
|
var others []*model.Zone
|
||||||
|
for _, o := range st.ZonesNamed(z.Name) {
|
||||||
|
vn := st.ViewName(o)
|
||||||
|
if o.ID == z.ID || !readable(o) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(with) > 0 && !containsFold(with, vn) || len(views) > 0 && !containsFold(views, vn) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
others = append(others, o)
|
||||||
|
}
|
||||||
|
st.SortByView(others)
|
||||||
|
zones = append(zones, others...)
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, engine.Compare(st, zones, engine.CompareFilter{Name: q.Get("name"), Type: q.Get("type"), All: queryBool(r, "all")}))
|
||||||
|
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
|
||||||
|
}
|
||||||
+125
-10
@@ -1,6 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -38,11 +39,18 @@ type encCert struct {
|
|||||||
|
|
||||||
type encStatus struct {
|
type encStatus struct {
|
||||||
encdns.Status
|
encdns.Status
|
||||||
// Hostname is the name clients connect to (the certificate's first).
|
// Hostnames are the names clients connect to (the setup's, else the
|
||||||
Hostname string `json:"hostname,omitempty"`
|
// certificate's first); Hostname is the first.
|
||||||
|
Hostnames []string `json:"hostnames"`
|
||||||
|
Hostname string `json:"hostname,omitempty"`
|
||||||
|
// AddressViews are, per address, the views whose records give a host
|
||||||
|
// name that address.
|
||||||
|
AddressViews map[string][]string `json:"address_views"`
|
||||||
// DoTHost and DoHURL are what to give clients.
|
// DoTHost and DoHURL are what to give clients.
|
||||||
DoTHost string `json:"dot_host,omitempty"`
|
DoTHost string `json:"dot_host,omitempty"`
|
||||||
DoHURL string `json:"doh_url,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.
|
// For the setup form.
|
||||||
Addresses []encAddress `json:"addresses"`
|
Addresses []encAddress `json:"addresses"`
|
||||||
Views []encView `json:"views"`
|
Views []encView `json:"views"`
|
||||||
@@ -89,6 +97,15 @@ func (s *Server) encryptedDNS(w http.ResponseWriter, r *http.Request, p *auth.Pr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if hs := st.Settings.EncryptedDNSHosts; len(hs) > 0 {
|
||||||
|
out.Hostname = hs[0]
|
||||||
|
out.Hostnames = hs
|
||||||
|
} else if out.Hostname != "" {
|
||||||
|
out.Hostnames = []string{out.Hostname}
|
||||||
|
}
|
||||||
|
if out.Hostnames == nil {
|
||||||
|
out.Hostnames = []string{}
|
||||||
|
}
|
||||||
if out.Hostname != "" {
|
if out.Hostname != "" {
|
||||||
if len(out.DoT) > 0 {
|
if len(out.DoT) > 0 {
|
||||||
out.DoTHost = out.Hostname
|
out.DoTHost = out.Hostname
|
||||||
@@ -104,6 +121,9 @@ func (s *Server) encryptedDNS(w http.ResponseWriter, r *http.Request, p *auth.Pr
|
|||||||
out.DoHURL += "/dns-query"
|
out.DoHURL += "/dns-query"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if out.Enabled {
|
||||||
|
out.Health = s.encryptedDNSHealth()
|
||||||
|
}
|
||||||
seen := map[netip.Addr]bool{}
|
seen := map[netip.Addr]bool{}
|
||||||
for _, a := range localAddrs() {
|
for _, a := range localAddrs() {
|
||||||
a = a.Unmap()
|
a = a.Unmap()
|
||||||
@@ -114,17 +134,25 @@ func (s *Server) encryptedDNS(w http.ResponseWriter, r *http.Request, p *auth.Pr
|
|||||||
out.Addresses = append(out.Addresses, encAddress{Address: a.String(), Private: encdns.Private(a)})
|
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) })
|
slices.SortFunc(out.Addresses, func(a, b encAddress) int { return strings.Compare(a.Address, b.Address) })
|
||||||
host := strings.TrimSuffix(r.URL.Query().Get("hostname"), ".")
|
hosts := out.Hostnames
|
||||||
if host == "" {
|
if q := r.URL.Query().Get("hostname"); q != "" {
|
||||||
host = out.Hostname
|
hosts = nil
|
||||||
|
for _, h := range strings.Split(q, ",") {
|
||||||
|
if h = strings.TrimSuffix(strings.TrimSpace(h), "."); h != "" {
|
||||||
|
hosts = append(hosts, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// Where the records of these host names give each address now, the
|
||||||
|
// setup's or not: what the form starts from.
|
||||||
|
out.AddressViews = encdns.CurrentAddressViews(st, hosts)
|
||||||
for _, x := range s.eng.Exposures(st) {
|
for _, x := range s.eng.Exposures(st) {
|
||||||
if x.View == "" {
|
if x.View == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
v := encView{Name: x.View, Public: x.Public}
|
v := encView{Name: x.View, Public: x.Public}
|
||||||
if host != "" {
|
if vv := st.ViewByName(x.View); vv != nil {
|
||||||
if vv := st.ViewByName(x.View); vv != nil {
|
for _, host := range hosts {
|
||||||
for _, z := range st.ZonesInView(vv.ID) {
|
for _, z := range st.ZonesInView(vv.ID) {
|
||||||
if z.Type == model.ZonePrimary && (host+"." == z.Name || strings.HasSuffix(host+".", "."+z.Name)) {
|
if z.Type == model.ZonePrimary && (host+"." == z.Name || strings.HasSuffix(host+".", "."+z.Name)) {
|
||||||
v.HasZone = true
|
v.HasZone = true
|
||||||
@@ -198,13 +226,17 @@ func (s *Server) encryptedDNSSetup(w http.ResponseWriter, r *http.Request, p *au
|
|||||||
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)
|
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:
|
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)
|
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}):
|
case len(in.Hosts()) > 0 && !certs.Covers(c.Names, in.Hosts()):
|
||||||
return badRequest("certificate %s is for %s, not %s: clients would reject it", c.Name, strings.Join(c.Names, ", "), in.Hostname)
|
return badRequest("certificate %s is for %s, not all of %s: clients would reject it (add the names to it: jarl cert update %s --domain …)", c.Name, strings.Join(c.Names, ", "), strings.Join(in.Hosts(), ", "), c.Name)
|
||||||
}
|
}
|
||||||
st, err := s.store.LoadState(r.Context())
|
st, err := s.store.LoadState(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
in.PublicViews = map[string]bool{}
|
||||||
|
for _, x := range s.eng.Exposures(st) {
|
||||||
|
in.PublicViews[x.View] = x.Public
|
||||||
|
}
|
||||||
reqs, notes, err := encdns.Plan(st, in, s.cfg.Certs.FilesDir(""))
|
reqs, notes, err := encdns.Plan(st, in, s.cfg.Certs.FilesDir(""))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return badRequest("%v", err)
|
return badRequest("%v", err)
|
||||||
@@ -216,7 +248,7 @@ func (s *Server) encryptedDNSSetup(w http.ResponseWriter, r *http.Request, p *au
|
|||||||
if in.DoH {
|
if in.DoH {
|
||||||
what = append(what, "DNS over HTTPS")
|
what = append(what, "DNS over HTTPS")
|
||||||
}
|
}
|
||||||
return s.mutateNotes(w, r, p, "set up "+strings.Join(what, " and ")+" for "+in.Hostname, reqs, nil, notes)
|
return s.mutateNotes(w, r, p, "set up "+strings.Join(what, " and ")+" for "+strings.Join(in.Hosts(), ", "), reqs, nil, notes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// encryptedDNSDisable: DELETE /encrypted-dns.
|
// encryptedDNSDisable: DELETE /encrypted-dns.
|
||||||
@@ -234,3 +266,86 @@ func (s *Server) encryptedDNSDisable(w http.ResponseWriter, r *http.Request, p *
|
|||||||
}
|
}
|
||||||
return s.mutate(w, r, p, "turn DNS over TLS and HTTPS off", reqs, nil)
|
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 != "" {
|
||||||
|
hosts := state.Settings.EncryptedDNSHosts
|
||||||
|
if len(hosts) > 0 {
|
||||||
|
host = hosts[0]
|
||||||
|
}
|
||||||
|
h = encdns.Check(ctx, st, host, serial, s.encRoots)
|
||||||
|
h.RequireNames(append([]string{host}, hosts...))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/engine"
|
||||||
|
)
|
||||||
|
|
||||||
|
// zoneFillPTR: POST /zones/{zone}/fill-ptr adds the PTR records a reverse
|
||||||
|
// zone lacks for its view's A and AAAA records (docs/reverse.md).
|
||||||
|
func (s *Server) zoneFillPTR(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
|
||||||
|
}
|
||||||
|
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(engine.OpZoneFillPTR, z.ID, engine.LinkInput{ZoneID: z.ID})}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reverseSuggestions: GET /reverse-zones/suggestions lists the reverse
|
||||||
|
// zones the private views lack for their records' addresses. ?all=1 adds
|
||||||
|
// the ones not suggested unasked (public views, views with suggestions
|
||||||
|
// off, dismissed), marked hidden; ?view= narrows it to one view.
|
||||||
|
func (s *Server) reverseSuggestions(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
st, err := s.state(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out := []engine.ReverseSuggestion{}
|
||||||
|
for _, sg := range s.eng.ReverseSuggestions(st, engine.ReverseOptions{All: queryBool(r, "all"), View: r.URL.Query().Get("view")}) {
|
||||||
|
if p.CanSeeZone(sg.Zone+".", sg.View) {
|
||||||
|
out = append(out, sg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reverseDismiss: POST /reverse-zones/dismiss {"view": "internal",
|
||||||
|
// "prefix": "185.199.108.153/32"} hides a suggestion; /undismiss shows it
|
||||||
|
// again.
|
||||||
|
func (s *Server) reverseDismiss(dismiss bool) handler {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
var in engine.DismissInput
|
||||||
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
op := engine.OpReverseUndismiss
|
||||||
|
if dismiss {
|
||||||
|
op = engine.OpReverseDismiss
|
||||||
|
}
|
||||||
|
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(op, 0, in)}, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -283,6 +283,72 @@ func (s *Server) statsServer(w http.ResponseWriter, r *http.Request, p *auth.Pri
|
|||||||
labels = append(labels, "statistics database")
|
labels = append(labels, "statistics database")
|
||||||
}
|
}
|
||||||
out["disks"] = stats.DiskUsage(labels, dirs)
|
out["disks"] = stats.DiskUsage(labels, dirs)
|
||||||
|
// The statistics database: what the statistics cost on disk.
|
||||||
|
if db := s.cfg.Stats.Store().Path(); db != "" && db != ":memory:" {
|
||||||
|
info := map[string]any{"path": db, "bytes": dbBytes(db)}
|
||||||
|
if keep := s.cfg.Stats.Options().RetainQueries; keep > 0 && s.cfg.Stats.Options().QueryLog {
|
||||||
|
if n, err := s.cfg.Stats.Store().QueryLogSize(r.Context()); err == nil {
|
||||||
|
info["query_log_rows"] = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out["database"] = info
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// statsExplore: GET /stats/explore?client=&name=&domain=&view=&type=&rcode=
|
||||||
|
// &blocked=&since=24h explores the query log (docs/stats.md#exploring):
|
||||||
|
// totals, a series, the top values of each dimension left open, and the
|
||||||
|
// latest answers, with client host names.
|
||||||
|
func (s *Server) statsExplore(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
q, err := s.statsQuerier(r, p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
since, err := statsSince(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
v := r.URL.Query()
|
||||||
|
f := stats.ExploreFilter{Client: v.Get("client"), Name: v.Get("name"), Domain: v.Get("domain"), View: v.Get("view"),
|
||||||
|
Type: v.Get("type"), RCode: v.Get("rcode"), Blocked: v.Get("blocked"), Since: since}
|
||||||
|
ex, err := q.Explore(r.Context(), f, 15, 100)
|
||||||
|
if errors.Is(err, stats.ErrNoQueryLog) {
|
||||||
|
return &httpError{http.StatusNotFound, "query_log_off", err.Error()}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.nameClients(r, p, ex.By["client"]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// The recent answers' clients, named once each.
|
||||||
|
var es []stats.Entry
|
||||||
|
seen := map[string]int{}
|
||||||
|
for _, row := range ex.Recent {
|
||||||
|
if _, ok := seen[row.Client]; !ok {
|
||||||
|
seen[row.Client] = len(es)
|
||||||
|
es = append(es, stats.Entry{Key: row.Client})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.nameClients(r, p, es); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range ex.Recent {
|
||||||
|
ex.Recent[i].ClientName = es[seen[ex.Recent[i].Client]].Name
|
||||||
|
}
|
||||||
|
// The filter's client, named too.
|
||||||
|
out := struct {
|
||||||
|
*stats.Exploration
|
||||||
|
ClientName string `json:"client_name,omitempty"`
|
||||||
|
}{Exploration: ex}
|
||||||
|
if f.Client != "" {
|
||||||
|
one := []stats.Entry{{Key: f.Client}}
|
||||||
|
if err := s.nameClients(r, p, one); err == nil {
|
||||||
|
out.ClientName = one[0].Name
|
||||||
|
}
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"jarl/internal/api"
|
||||||
|
"jarl/internal/stats"
|
||||||
|
)
|
||||||
|
|
||||||
|
// storage reports the size of Jarl's databases and how full the disks
|
||||||
|
// Jarl uses are (the dashboard's Storage card).
|
||||||
|
func (s *Server) storage(ctx context.Context) *api.Storage {
|
||||||
|
out := &api.Storage{Databases: []api.Database{}, Disks: []api.Disk{}}
|
||||||
|
dbFile := func(label, path string) *api.Database {
|
||||||
|
if path == "" || path == ":memory:" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
d := api.Database{Label: label, Path: path, Bytes: dbBytes(path)}
|
||||||
|
out.Databases = append(out.Databases, d)
|
||||||
|
out.Total += d.Bytes
|
||||||
|
return &out.Databases[len(out.Databases)-1]
|
||||||
|
}
|
||||||
|
labels := []string{}
|
||||||
|
dirs := map[string]string{}
|
||||||
|
add := func(label, dir string) {
|
||||||
|
if dir != "" {
|
||||||
|
labels = append(labels, label)
|
||||||
|
dirs[label] = dir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const jarlDB, statsDB = "Jarl's database", "statistics database"
|
||||||
|
if p := s.store.Path(); p != "" {
|
||||||
|
dbFile(jarlDB, p)
|
||||||
|
add(jarlDB, filepath.Dir(p))
|
||||||
|
}
|
||||||
|
if s.cfg.Stats != nil {
|
||||||
|
if p := s.cfg.Stats.Store().Path(); p != "" && p != ":memory:" {
|
||||||
|
if d := dbFile(statsDB, p); d != nil && s.cfg.Stats.Options().RetainQueries > 0 {
|
||||||
|
if n, err := s.cfg.Stats.Store().QueryLogSize(ctx); err == nil {
|
||||||
|
d.QueryLogRows = &n
|
||||||
|
d.QueryLogRetention = s.cfg.Stats.Status().Retention.Queries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add(statsDB, filepath.Dir(p))
|
||||||
|
}
|
||||||
|
if lf := s.cfg.Stats.Options().LogFile; lf != "" {
|
||||||
|
add("named's logs", filepath.Dir(lf))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
paths := s.eng.Config().Paths
|
||||||
|
add("zone files", paths.ZoneDir)
|
||||||
|
add("named.conf", paths.ConfigDir)
|
||||||
|
add("Jarl's cache and blocklists", paths.CacheDir)
|
||||||
|
add("backups", s.cfg.BackupDir)
|
||||||
|
for _, d := range stats.DiskUsage(labels, dirs) {
|
||||||
|
disk := api.Disk{Path: d.Path, Holds: d.Holds, Total: d.Total, Free: d.Free}
|
||||||
|
for _, db := range out.Databases {
|
||||||
|
if slices.Contains(d.Holds, db.Label) {
|
||||||
|
disk.Databases += db.Bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Disks = append(out.Disks, disk)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// dbBytes is the size of a SQLite database: its file, -wal and -shm.
|
||||||
|
func dbBytes(path string) int64 {
|
||||||
|
var n int64
|
||||||
|
for _, suffix := range []string{"", "-wal", "-shm"} {
|
||||||
|
if fi, err := os.Stat(path + suffix); err == nil {
|
||||||
|
n += fi.Size()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -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,19 @@ 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pf, ok := dnsname.PrefixFromReverse(z.Name); ok {
|
||||||
|
out.Prefix = pf.String()
|
||||||
|
if z.Type == model.ZonePrimary {
|
||||||
|
out.PTRMissing = len(engine.PTRFills(st, z.ViewID, z.Name, st.ZoneRecords(z.ID)))
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, o := range st.LinkedWith(z) {
|
for _, o := range st.LinkedWith(z) {
|
||||||
out.LinkedWith = append(out.LinkedWith, st.ViewName(o))
|
out.LinkedWith = append(out.LinkedWith, st.ViewName(o))
|
||||||
out.Links = append(out.Links, api.ZoneLink{ID: o.ID, View: st.ViewName(o)})
|
out.Links = append(out.Links, api.ZoneLink{ID: o.ID, View: st.ViewName(o)})
|
||||||
@@ -111,7 +124,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,8 +111,13 @@ 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}/link", s.authed(s.zoneLink(true)))
|
||||||
m.HandleFunc("POST "+p+"/zones/{zone}/unlink", s.authed(s.zoneLink(false)))
|
m.HandleFunc("POST "+p+"/zones/{zone}/unlink", s.authed(s.zoneLink(false)))
|
||||||
|
m.HandleFunc("POST "+p+"/zones/{zone}/fill-ptr", s.authed(s.zoneFillPTR))
|
||||||
|
m.HandleFunc("GET "+p+"/reverse-zones/suggestions", s.authed(s.reverseSuggestions))
|
||||||
|
m.HandleFunc("POST "+p+"/reverse-zones/dismiss", s.authed(s.reverseDismiss(true)))
|
||||||
|
m.HandleFunc("POST "+p+"/reverse-zones/undismiss", s.authed(s.reverseDismiss(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))
|
||||||
@@ -128,6 +133,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))
|
||||||
@@ -176,8 +182,10 @@ func (s *Server) routes() {
|
|||||||
m.HandleFunc("POST "+p+"/encrypted-dns", s.authed(s.encryptedDNSSetup))
|
m.HandleFunc("POST "+p+"/encrypted-dns", s.authed(s.encryptedDNSSetup))
|
||||||
m.HandleFunc("DELETE "+p+"/encrypted-dns", s.authed(s.encryptedDNSDisable))
|
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/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))
|
||||||
@@ -187,6 +195,7 @@ func (s *Server) routes() {
|
|||||||
m.HandleFunc("GET "+p+"/stats/heatmap", s.authed(s.statsHeatmap))
|
m.HandleFunc("GET "+p+"/stats/heatmap", s.authed(s.statsHeatmap))
|
||||||
m.HandleFunc("GET "+p+"/stats/breakdown", s.authed(s.statsBreakdown))
|
m.HandleFunc("GET "+p+"/stats/breakdown", s.authed(s.statsBreakdown))
|
||||||
m.HandleFunc("GET "+p+"/stats/server", s.authed(s.statsServer))
|
m.HandleFunc("GET "+p+"/stats/server", s.authed(s.statsServer))
|
||||||
|
m.HandleFunc("GET "+p+"/stats/explore", s.authed(s.statsExplore))
|
||||||
m.HandleFunc(p+"/", func(w http.ResponseWriter, r *http.Request) {
|
m.HandleFunc(p+"/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
writeError(w, http.StatusNotFound, "not_found", "no such API endpoint: "+r.Method+" "+r.URL.Path)
|
writeError(w, http.StatusNotFound, "not_found", "no such API endpoint: "+r.Method+" "+r.URL.Path)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -121,3 +122,79 @@ func TestStatsAPI(t *testing.T) {
|
|||||||
vtok := e.do("POST", "/users/"+itoa(viewerID)+"/tokens", e.token, map[string]any{"name": "t"}, 201)["token"].(string)
|
vtok := e.do("POST", "/users/"+itoa(viewerID)+"/tokens", e.token, map[string]any{"name": "t"}, 201)["token"].(string)
|
||||||
e.do("GET", "/stats/summary", vtok, nil, 200)
|
e.do("GET", "/stats/summary", vtok, nil, 200)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStatsExploreAPI(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
ss, err := stats.OpenStore(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ss.Close()
|
||||||
|
now := time.Now().Unix()
|
||||||
|
b := &stats.Batch{}
|
||||||
|
b.AddQueries([]stats.QueryRow{
|
||||||
|
{Time: now - 60, Client: "10.0.0.7", View: "internal", Name: "www.example.com", Type: "A", RCode: "NOERROR"},
|
||||||
|
{Time: now - 50, Client: "10.0.0.7", View: "internal", Name: "ads.tracker.test", Type: "A", RCode: "NXDOMAIN", Blocked: "hagezi"},
|
||||||
|
{Time: now - 40, Client: "198.51.100.9", View: "external", Name: "nope.example.com", Type: "A", RCode: "NXDOMAIN"},
|
||||||
|
})
|
||||||
|
if err := ss.Commit(ctx, b); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
opts := stats.Options{QueryLog: true, StatisticsChannel: "127.0.0.1:1", TopN: 10, RetainMinute: time.Hour, RetainHour: 24 * time.Hour, RetainDay: 24 * time.Hour, RetainQueries: 7 * 24 * time.Hour}
|
||||||
|
c := stats.NewCollector(ss, opts, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
e := newAPIEnv(t, c)
|
||||||
|
e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}, {"name": "laptop", "type": "A", "data": "10.0.0.7"}}}, 201)
|
||||||
|
|
||||||
|
ex := e.do("GET", "/stats/explore?rcode=NXDOMAIN&since=1h", e.token, nil, 200)
|
||||||
|
if ex["total"].(float64) != 2 || ex["blocked"].(float64) != 1 {
|
||||||
|
t.Errorf("NXDOMAIN: %v", ex)
|
||||||
|
}
|
||||||
|
clients := ex["by"].(map[string]any)["client"].([]any)
|
||||||
|
if len(clients) != 2 {
|
||||||
|
t.Fatalf("clients %v", clients)
|
||||||
|
}
|
||||||
|
named := false
|
||||||
|
for _, c := range clients {
|
||||||
|
m := c.(map[string]any)
|
||||||
|
named = named || m["key"] == "10.0.0.7" && m["name"] == "laptop.example.com"
|
||||||
|
}
|
||||||
|
if !named {
|
||||||
|
t.Errorf("client names %v", clients)
|
||||||
|
}
|
||||||
|
ex = e.do("GET", "/stats/explore?client=10.0.0.7", e.token, nil, 200)
|
||||||
|
recent := ex["recent"].([]any)
|
||||||
|
if ex["total"].(float64) != 2 || ex["client_name"] != "laptop.example.com" || len(recent) != 2 || recent[0].(map[string]any)["client_name"] != "laptop.example.com" {
|
||||||
|
t.Errorf("laptop: %v", ex)
|
||||||
|
}
|
||||||
|
if ex := e.do("GET", "/stats/explore?client=10.0.0.7&views=external", e.token, nil, 200); ex["total"].(float64) != 0 {
|
||||||
|
t.Errorf("laptop in external only: %v", ex["total"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// The dashboard reports the databases' size.
|
||||||
|
st := e.do("GET", "/status", e.token, nil, 200)
|
||||||
|
sg, ok := st["storage"].(map[string]any)
|
||||||
|
if !ok || len(sg["databases"].([]any)) == 0 {
|
||||||
|
t.Errorf("storage %v", st["storage"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// /stats/server reports the statistics database's size and the query log.
|
||||||
|
func TestStatsServerDatabase(t *testing.T) {
|
||||||
|
ss, err := stats.OpenStore(filepath.Join(t.TempDir(), "stats.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ss.Close()
|
||||||
|
b := &stats.Batch{}
|
||||||
|
b.AddQueries([]stats.QueryRow{{Time: time.Now().Unix(), Client: "10.0.0.7", View: "internal", Name: "www.example.com", Type: "A", RCode: "NOERROR"}})
|
||||||
|
if err := ss.Commit(context.Background(), b); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
opts := stats.Options{QueryLog: true, StatisticsChannel: "127.0.0.1:1", TopN: 10, RetainMinute: time.Hour, RetainHour: 24 * time.Hour, RetainDay: 24 * time.Hour, RetainQueries: 7 * 24 * time.Hour}
|
||||||
|
e := newAPIEnv(t, stats.NewCollector(ss, opts, nil, slog.New(slog.NewTextHandler(io.Discard, nil))))
|
||||||
|
sv := e.do("GET", "/stats/server", e.token, nil, 200)
|
||||||
|
db, ok := sv["database"].(map[string]any)
|
||||||
|
if !ok || db["bytes"].(float64) <= 0 || db["query_log_rows"].(float64) != 1 {
|
||||||
|
t.Errorf("database %v", sv["database"])
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
-12
@@ -56,9 +56,12 @@ type Options struct {
|
|||||||
RetainMinute time.Duration
|
RetainMinute time.Duration
|
||||||
RetainHour time.Duration
|
RetainHour time.Duration
|
||||||
RetainDay time.Duration
|
RetainDay time.Duration
|
||||||
Anonymize bool
|
// RetainQueries is how long the query log keeps every answer (0: no
|
||||||
Exclude []netip.Prefix
|
// query log; it needs QueryLog).
|
||||||
Location *time.Location // for days and log timestamps (default: local time)
|
RetainQueries time.Duration
|
||||||
|
Anonymize bool
|
||||||
|
Exclude []netip.Prefix
|
||||||
|
Location *time.Location // for days and log timestamps (default: local time)
|
||||||
// MainConfig is named's main configuration file; it tells named's
|
// MainConfig is named's main configuration file; it tells named's
|
||||||
// process apart from other named processes (for its memory and CPU).
|
// process apart from other named processes (for its memory and CPU).
|
||||||
MainConfig string
|
MainConfig string
|
||||||
@@ -87,9 +90,10 @@ type Status struct {
|
|||||||
Warnings []string `json:"warnings"`
|
Warnings []string `json:"warnings"`
|
||||||
FirstData *time.Time `json:"first_data,omitempty"`
|
FirstData *time.Time `json:"first_data,omitempty"`
|
||||||
Retention struct {
|
Retention struct {
|
||||||
Minute string `json:"minute"`
|
Minute string `json:"minute"`
|
||||||
Hour string `json:"hour"`
|
Hour string `json:"hour"`
|
||||||
Day string `json:"day"`
|
Day string `json:"day"`
|
||||||
|
Queries string `json:"queries"` // the query log ("" when off)
|
||||||
} `json:"retention"`
|
} `json:"retention"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,9 +123,11 @@ type Collector struct {
|
|||||||
recentL []int64 // response log lines per minute, last 10 minutes
|
recentL []int64 // response log lines per minute, last 10 minutes
|
||||||
linesMin int64
|
linesMin int64
|
||||||
ensureAt time.Time
|
ensureAt time.Time
|
||||||
// rewritten holds queries named blocked (client#port|name) until their
|
// rewritten holds queries named blocked (client#port|name), and what
|
||||||
// response line arrives.
|
// blocked them, until their response line arrives.
|
||||||
rewritten map[string]bool
|
rewritten map[string]string
|
||||||
|
// pending are answers for the query log, written at the next flush.
|
||||||
|
pending []QueryRow
|
||||||
// viewsSince is set once per-view counters exist (meta views_since).
|
// viewsSince is set once per-view counters exist (meta views_since).
|
||||||
viewsSince bool
|
viewsSince bool
|
||||||
viewsSinceAt int64
|
viewsSinceAt int64
|
||||||
@@ -137,6 +143,10 @@ type Collector struct {
|
|||||||
// maxKeys bounds the per-hour maps; rarely seen keys are dropped first.
|
// maxKeys bounds the per-hour maps; rarely seen keys are dropped first.
|
||||||
const maxKeys = 200_000
|
const maxKeys = 200_000
|
||||||
|
|
||||||
|
// maxPending bounds the answers waiting for the query log between flushes
|
||||||
|
// (a minute); past it they are counted but not logged.
|
||||||
|
const maxPending = 500_000
|
||||||
|
|
||||||
// NewCollector creates a collector. Run starts it.
|
// NewCollector creates a collector. Run starts it.
|
||||||
func NewCollector(store *Store, opts Options, zones ZoneIndex, log *slog.Logger) *Collector {
|
func NewCollector(store *Store, opts Options, zones ZoneIndex, log *slog.Logger) *Collector {
|
||||||
if opts.Location == nil {
|
if opts.Location == nil {
|
||||||
@@ -154,6 +164,9 @@ func NewCollector(store *Store, opts Options, zones ZoneIndex, log *slog.Logger)
|
|||||||
c.status.Retention.Minute = humanDuration(opts.RetainMinute)
|
c.status.Retention.Minute = humanDuration(opts.RetainMinute)
|
||||||
c.status.Retention.Hour = humanDuration(opts.RetainHour)
|
c.status.Retention.Hour = humanDuration(opts.RetainHour)
|
||||||
c.status.Retention.Day = humanDuration(opts.RetainDay)
|
c.status.Retention.Day = humanDuration(opts.RetainDay)
|
||||||
|
if opts.QueryLog && opts.RetainQueries > 0 {
|
||||||
|
c.status.Retention.Queries = humanDuration(opts.RetainQueries)
|
||||||
|
}
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,7 +375,8 @@ func (c *Collector) handleLine(line string) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
blocked := c.rewritten[ev.Client.String()+"#"+ev.Port+"|"+ev.Name]
|
blockedBy := c.rewritten[ev.Client.String()+"#"+ev.Port+"|"+ev.Name]
|
||||||
|
blocked := blockedBy != ""
|
||||||
if blocked {
|
if blocked {
|
||||||
delete(c.rewritten, ev.Client.String()+"#"+ev.Port+"|"+ev.Name)
|
delete(c.rewritten, ev.Client.String()+"#"+ev.Port+"|"+ev.Name)
|
||||||
}
|
}
|
||||||
@@ -405,6 +419,9 @@ func (c *Collector) handleLine(line string) {
|
|||||||
}
|
}
|
||||||
add(KindName, ev.Name)
|
add(KindName, ev.Name)
|
||||||
add(KindClient, client)
|
add(KindClient, client)
|
||||||
|
if c.opts.RetainQueries > 0 && len(c.pending) < maxPending {
|
||||||
|
c.pending = append(c.pending, QueryRow{Time: ev.Time.Unix(), Client: client, View: view, Name: ev.Name, Type: ev.Type, RCode: ev.RCode, Blocked: blockedBy})
|
||||||
|
}
|
||||||
switch {
|
switch {
|
||||||
case blocked:
|
case blocked:
|
||||||
// Counted by handleRewrite; not a name that doesn't exist.
|
// Counted by handleRewrite; not a name that doesn't exist.
|
||||||
@@ -445,9 +462,9 @@ func (c *Collector) handleRewrite(rw Rewrite) {
|
|||||||
if c.rewritten == nil || len(c.rewritten) > 10_000 {
|
if c.rewritten == nil || len(c.rewritten) > 10_000 {
|
||||||
// Rewrites whose response line never came (named only logs
|
// Rewrites whose response line never came (named only logs
|
||||||
// responses while response logging is on) don't pile up.
|
// responses while response logging is on) don't pile up.
|
||||||
c.rewritten = map[string]bool{}
|
c.rewritten = map[string]string{}
|
||||||
}
|
}
|
||||||
c.rewritten[rw.Client.String()+"#"+rw.Port+"|"+rw.Name] = true
|
c.rewritten[rw.Client.String()+"#"+rw.Port+"|"+rw.Name] = policySource(rw.Via)
|
||||||
minute, hour := c.minuteOf(rw.Time), c.hourOf(rw.Time)
|
minute, hour := c.minuteOf(rw.Time), c.hourOf(rw.Time)
|
||||||
view := rw.View
|
view := rw.View
|
||||||
if view == "" {
|
if view == "" {
|
||||||
@@ -639,6 +656,8 @@ func (c *Collector) flush(ctx context.Context, final bool) {
|
|||||||
pos, _ := json.Marshal(c.tail.Position())
|
pos, _ := json.Marshal(c.tail.Position())
|
||||||
b.SetMeta("tail", string(pos))
|
b.SetMeta("tail", string(pos))
|
||||||
}
|
}
|
||||||
|
b.AddQueries(c.pending)
|
||||||
|
c.pending = nil
|
||||||
c.updateWarnings()
|
c.updateWarnings()
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
@@ -737,6 +756,17 @@ func (c *Collector) prune(ctx context.Context) {
|
|||||||
c.log.Debug("statistics: pruned", "resolution", res, "rows", n)
|
c.log.Debug("statistics: pruned", "resolution", res, "rows", n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The query log keeps answers for RetainQueries; with no query log,
|
||||||
|
// none at all (it may have been turned off).
|
||||||
|
keep := c.opts.RetainQueries
|
||||||
|
if !c.opts.QueryLog {
|
||||||
|
keep = 0
|
||||||
|
}
|
||||||
|
if n, err := c.store.PruneQueries(ctx, now-int64(keep.Seconds())); err != nil {
|
||||||
|
c.log.Warn("statistics: pruning the query log failed", "err", err)
|
||||||
|
} else if n > 0 {
|
||||||
|
c.log.Debug("statistics: pruned the query log", "rows", n)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func humanDuration(d time.Duration) string {
|
func humanDuration(d time.Duration) string {
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
package stats
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Exploring the query log (docs/stats.md#exploring): every answer of the
|
||||||
|
// last days (stats.query_retention), filtered by any mix of client, name,
|
||||||
|
// view, type, result and blocking, broken down by each of them.
|
||||||
|
|
||||||
|
// ExploreFilter narrows the query log. Empty fields don't filter. Each
|
||||||
|
// field may hold several values, comma separated: any of them; a value
|
||||||
|
// written !VALUE excludes it. A name *.example.com is it and the names
|
||||||
|
// below it.
|
||||||
|
type ExploreFilter struct {
|
||||||
|
Client string // a client key, as the top lists show it
|
||||||
|
Name string // exactly this name
|
||||||
|
Domain string // this name or any below it
|
||||||
|
View string
|
||||||
|
Type string
|
||||||
|
RCode string
|
||||||
|
// Blocked is "yes" (blocked by anything), "no", or a blocklist's name
|
||||||
|
// ("rules" for your own rules).
|
||||||
|
Blocked string
|
||||||
|
Since time.Duration // how far back (default 24h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dimensions are what the query log breaks down by, in the order shown.
|
||||||
|
var Dimensions = []string{"client", "name", "view", "type", "rcode", "blocked"}
|
||||||
|
|
||||||
|
// Exploration is what the query log says for a filter.
|
||||||
|
type Exploration struct {
|
||||||
|
From time.Time `json:"from"`
|
||||||
|
To time.Time `json:"to"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
// Blocked counts the blocked answers among them.
|
||||||
|
Blocked int64 `json:"blocked"`
|
||||||
|
// Series are the answers (and blocked ones) per Step seconds.
|
||||||
|
Step int64 `json:"step"`
|
||||||
|
Series []ExplorePoint `json:"series"`
|
||||||
|
// By are the top values of each dimension the filter leaves open.
|
||||||
|
By map[string][]Entry `json:"by"`
|
||||||
|
// Recent are the latest answers.
|
||||||
|
Recent []QueryRow `json:"recent"`
|
||||||
|
// Oldest is the first answer the query log still has.
|
||||||
|
Oldest *time.Time `json:"oldest,omitempty"`
|
||||||
|
// Retention is how long it keeps them.
|
||||||
|
Retention string `json:"retention"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExplorePoint is one step of the series.
|
||||||
|
type ExplorePoint struct {
|
||||||
|
Time int64 `json:"t"`
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
Blocked int64 `json:"blocked"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrNoQueryLog is returned when the query log is off.
|
||||||
|
var ErrNoQueryLog = fmt.Errorf("the query log is off: it needs stats.query_log and a stats.query_retention above 0 in /etc/jarl/jarl.yaml (docs/stats.md#exploring)")
|
||||||
|
|
||||||
|
// values splits a filter field into the values it must be one of and the
|
||||||
|
// ones (written !VALUE) it must not be: "10.0.0.5, !10.0.0.9".
|
||||||
|
func values(field string, norm func(string) string) (in, out []string) {
|
||||||
|
for _, v := range strings.Split(field, ",") {
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
not := strings.HasPrefix(v, "!")
|
||||||
|
v = norm(strings.TrimSpace(strings.TrimPrefix(v, "!")))
|
||||||
|
switch {
|
||||||
|
case v == "":
|
||||||
|
case not:
|
||||||
|
out = append(out, v)
|
||||||
|
default:
|
||||||
|
in = append(in, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return in, out
|
||||||
|
}
|
||||||
|
|
||||||
|
// nameValues are the name filter's values: Name's, and Domain's as
|
||||||
|
// "*.DOMAIN" (the name and the names below it).
|
||||||
|
func (f ExploreFilter) nameValues() (in, out []string) {
|
||||||
|
norm := func(v string) string { return strings.ToLower(strings.TrimSuffix(v, ".")) }
|
||||||
|
in, out = values(f.Name, norm)
|
||||||
|
din, dout := values(f.Domain, norm)
|
||||||
|
for _, d := range din {
|
||||||
|
in = append(in, "*."+strings.TrimPrefix(d, "*."))
|
||||||
|
}
|
||||||
|
for _, d := range dout {
|
||||||
|
out = append(out, "*."+strings.TrimPrefix(d, "*."))
|
||||||
|
}
|
||||||
|
return in, out
|
||||||
|
}
|
||||||
|
|
||||||
|
// fieldValues returns a dimension's filter values.
|
||||||
|
func (f ExploreFilter) fieldValues(dim string) (in, out []string) {
|
||||||
|
same := func(v string) string { return v }
|
||||||
|
upper := strings.ToUpper
|
||||||
|
switch dim {
|
||||||
|
case "client":
|
||||||
|
return values(f.Client, same)
|
||||||
|
case "name":
|
||||||
|
return f.nameValues()
|
||||||
|
case "view":
|
||||||
|
return values(f.View, same)
|
||||||
|
case "type":
|
||||||
|
return values(f.Type, upper)
|
||||||
|
case "rcode":
|
||||||
|
return values(f.RCode, upper)
|
||||||
|
case "blocked":
|
||||||
|
return values(f.Blocked, same)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cond is the SQL condition for one value of a dimension.
|
||||||
|
func cond(dim, v string) (string, []any) {
|
||||||
|
switch {
|
||||||
|
case dim == "name" && strings.HasPrefix(v, "*."):
|
||||||
|
d := strings.TrimPrefix(v, "*.")
|
||||||
|
return "(name = ? OR (length(name) > length(?) AND substr(name, length(name) - length(?)) = ?))", []any{d, d, d, "." + d}
|
||||||
|
case dim == "blocked" && v == "yes":
|
||||||
|
return "blocked != ''", nil
|
||||||
|
case dim == "blocked" && v == "no":
|
||||||
|
return "blocked = ''", nil
|
||||||
|
}
|
||||||
|
return dim + " = ?", []any{v}
|
||||||
|
}
|
||||||
|
|
||||||
|
// where builds the SQL condition and arguments for a filter: within a
|
||||||
|
// dimension any of its values, and none of its !values; the dimensions
|
||||||
|
// together.
|
||||||
|
func (q *Querier) where(f ExploreFilter, from int64) (string, []any) {
|
||||||
|
conds := []string{"ts >= ?"}
|
||||||
|
args := []any{from}
|
||||||
|
for _, dim := range Dimensions {
|
||||||
|
in, out := f.fieldValues(dim)
|
||||||
|
if len(in) > 0 {
|
||||||
|
var or []string
|
||||||
|
for _, v := range in {
|
||||||
|
c, a := cond(dim, v)
|
||||||
|
or = append(or, c)
|
||||||
|
args = append(args, a...)
|
||||||
|
}
|
||||||
|
conds = append(conds, "("+strings.Join(or, " OR ")+")")
|
||||||
|
}
|
||||||
|
for _, v := range out {
|
||||||
|
c, a := cond(dim, v)
|
||||||
|
conds = append(conds, "NOT "+c)
|
||||||
|
args = append(args, a...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(q.Views) > 0 {
|
||||||
|
marks := strings.TrimSuffix(strings.Repeat("?,", len(q.Views)), ",")
|
||||||
|
conds = append(conds, "view IN ("+marks+")")
|
||||||
|
for _, v := range q.Views {
|
||||||
|
args = append(args, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(conds, " AND "), args
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explore answers a filter: totals, a series, the top values of each open
|
||||||
|
// dimension (topN each) and the latest answers (recent of them).
|
||||||
|
func (q *Querier) Explore(ctx context.Context, f ExploreFilter, topN, recent int) (*Exploration, error) {
|
||||||
|
if !q.Opts.QueryLog || q.Opts.RetainQueries <= 0 {
|
||||||
|
return nil, ErrNoQueryLog
|
||||||
|
}
|
||||||
|
now := q.Now()
|
||||||
|
since := f.Since
|
||||||
|
if since <= 0 {
|
||||||
|
since = 24 * time.Hour
|
||||||
|
}
|
||||||
|
if since > q.Opts.RetainQueries {
|
||||||
|
since = q.Opts.RetainQueries
|
||||||
|
}
|
||||||
|
from := now.Add(-since).Unix()
|
||||||
|
out := &Exploration{From: time.Unix(from, 0), To: now, By: map[string][]Entry{}, Series: []ExplorePoint{}, Recent: []QueryRow{},
|
||||||
|
Retention: humanDuration(q.Opts.RetainQueries)}
|
||||||
|
cond, args := q.where(f, from)
|
||||||
|
db := q.Store.db
|
||||||
|
|
||||||
|
if err := db.QueryRowContext(ctx, `SELECT count(*), coalesce(sum(blocked != ''), 0) FROM queries WHERE `+cond, args...).Scan(&out.Total, &out.Blocked); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// The series: about 100 steps over the range, in whole minutes.
|
||||||
|
step := int64(since.Seconds()) / 100
|
||||||
|
step = max(60, step-step%60)
|
||||||
|
out.Step = step
|
||||||
|
rows, err := db.QueryContext(ctx, `SELECT ts - ts % ? AS t, count(*), sum(blocked != '') FROM queries WHERE `+cond+` GROUP BY t ORDER BY t`, append([]any{step}, args...)...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
got := map[int64]ExplorePoint{}
|
||||||
|
for rows.Next() {
|
||||||
|
var p ExplorePoint
|
||||||
|
if err := rows.Scan(&p.Time, &p.Count, &p.Blocked); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
got[p.Time] = p
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
for t := from - from%step; t <= now.Unix(); t += step {
|
||||||
|
p := got[t]
|
||||||
|
p.Time = t
|
||||||
|
out.Series = append(out.Series, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A dimension is broken down unless the filter pins it to one value
|
||||||
|
// (several: how they compare; !values leave the rest to see).
|
||||||
|
open := map[string]bool{}
|
||||||
|
for _, dim := range Dimensions {
|
||||||
|
in, _ := f.fieldValues(dim)
|
||||||
|
open[dim] = len(in) != 1 || (dim == "blocked" && in[0] == "yes") || (dim == "name" && strings.HasPrefix(in[0], "*."))
|
||||||
|
}
|
||||||
|
for _, dim := range Dimensions {
|
||||||
|
if !open[dim] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c := cond
|
||||||
|
if dim == "blocked" {
|
||||||
|
c += " AND blocked != ''"
|
||||||
|
}
|
||||||
|
rows, err := db.QueryContext(ctx, `SELECT `+dim+`, count(*) AS n FROM queries WHERE `+c+` GROUP BY `+dim+` ORDER BY n DESC, `+dim+` LIMIT ?`, append(args, topN)...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
es := []Entry{}
|
||||||
|
for rows.Next() {
|
||||||
|
var e Entry
|
||||||
|
if err := rows.Scan(&e.Key, &e.Count); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
es = append(es, e)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
out.By[dim] = es
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err = db.QueryContext(ctx, `SELECT ts, client, view, name, type, rcode, blocked FROM queries WHERE `+cond+` ORDER BY ts DESC, rowid DESC LIMIT ?`, append(args, recent)...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var r QueryRow
|
||||||
|
if err := rows.Scan(&r.Time, &r.Client, &r.View, &r.Name, &r.Type, &r.RCode, &r.Blocked); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out.Recent = append(out.Recent, r)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
var oldest *int64
|
||||||
|
if err := db.QueryRowContext(ctx, `SELECT min(ts) FROM queries`).Scan(&oldest); err == nil && oldest != nil {
|
||||||
|
t := time.Unix(*oldest, 0)
|
||||||
|
out.Oldest = &t
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryLogSize returns how many answers the query log has.
|
||||||
|
func (s *Store) QueryLogSize(ctx context.Context) (int64, error) {
|
||||||
|
var n int64
|
||||||
|
err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM queries`).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package stats
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExplore(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
loc := time.UTC
|
||||||
|
store, err := OpenStore(filepath.Join(t.TempDir(), "stats.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
clk := &clock{time.Date(2026, 10, 13, 12, 0, 30, 0, loc)}
|
||||||
|
opts := Options{QueryLog: true, LogFile: "unused", StatisticsChannel: "unused", TopN: 10, RetainMinute: time.Hour, RetainHour: time.Hour, RetainDay: time.Hour,
|
||||||
|
RetainQueries: 7 * 24 * time.Hour, Location: loc}
|
||||||
|
c := NewCollector(store, opts, func() map[string][]string { return nil }, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
c.now = clk.now
|
||||||
|
at := clk.t.Add(-10 * time.Minute)
|
||||||
|
// The phone: lookups, an NXDOMAIN, a blocked tracker.
|
||||||
|
c.HandleLine(logLine(at, "10.0.0.5", "internal", "www.adbraun.lol", "A", "NOERROR"))
|
||||||
|
c.HandleLine(logLine(at, "10.0.0.5", "internal", "nas.adbraun.lol", "AAAA", "NOERROR"))
|
||||||
|
c.HandleLine(logLine(at, "10.0.0.5", "internal", "typo.adbraun.lol", "A", "NXDOMAIN"))
|
||||||
|
c.HandleLine(fmt.Sprintf("%s client @0x7f0000000000 10.0.0.5#5353 (t.tracker.example): view internal: rpz QNAME NXDOMAIN rewrite t.tracker.example/A/IN via t.tracker.example.hagezi.blocklist.jarl.invalid\n", at.Format(logTimeLayout)))
|
||||||
|
c.HandleLine(logLine(at, "10.0.0.5", "internal", "t.tracker.example", "A", "NXDOMAIN"))
|
||||||
|
// A scanner in the external view, NXDOMAIN twice.
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
c.HandleLine(logLine(at, "198.51.100.7", "external", "nope.adbraun.lol", "A", "NXDOMAIN"))
|
||||||
|
}
|
||||||
|
c.flush(ctx, true)
|
||||||
|
|
||||||
|
q := &Querier{Store: store, Opts: opts, Now: clk.now}
|
||||||
|
ex, err := q.Explore(ctx, ExploreFilter{View: "external", RCode: "NXDOMAIN"}, 10, 50)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ex.Total != 2 || len(ex.By["client"]) != 1 || ex.By["client"][0].Key != "198.51.100.7" || ex.By["view"] != nil {
|
||||||
|
t.Errorf("external NXDOMAIN: %+v", ex)
|
||||||
|
}
|
||||||
|
// What else does the phone do?
|
||||||
|
ex, err = q.Explore(ctx, ExploreFilter{Client: "10.0.0.5"}, 10, 50)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ex.Total != 4 || ex.Blocked != 1 || len(ex.Recent) != 4 {
|
||||||
|
t.Errorf("phone: total %d blocked %d recent %d", ex.Total, ex.Blocked, len(ex.Recent))
|
||||||
|
}
|
||||||
|
if b := ex.By["blocked"]; len(b) != 1 || b[0].Key != "hagezi" || b[0].Count != 1 {
|
||||||
|
t.Errorf("blocked by %+v", b)
|
||||||
|
}
|
||||||
|
if r := ex.By["rcode"]; len(r) != 2 || r[0].Key != "NOERROR" || r[0].Count != 2 {
|
||||||
|
t.Errorf("rcode %+v", r)
|
||||||
|
}
|
||||||
|
var sum int64
|
||||||
|
for _, p := range ex.Series {
|
||||||
|
sum += p.Count
|
||||||
|
}
|
||||||
|
if sum != 4 || ex.Step != 60*int64(24*60/100) {
|
||||||
|
t.Errorf("series sum %d step %d", sum, ex.Step)
|
||||||
|
}
|
||||||
|
// A domain and what's below it.
|
||||||
|
ex, _ = q.Explore(ctx, ExploreFilter{Domain: "adbraun.lol"}, 10, 50)
|
||||||
|
if ex.Total != 5 {
|
||||||
|
t.Errorf("domain adbraun.lol: %d", ex.Total)
|
||||||
|
}
|
||||||
|
ex, _ = q.Explore(ctx, ExploreFilter{Name: "nas.adbraun.lol", Type: "aaaa"}, 10, 50)
|
||||||
|
if ex.Total != 1 || ex.Recent[0].Client != "10.0.0.5" {
|
||||||
|
t.Errorf("nas AAAA: %+v", ex)
|
||||||
|
}
|
||||||
|
ex, _ = q.Explore(ctx, ExploreFilter{Blocked: "no", Client: "10.0.0.5"}, 10, 50)
|
||||||
|
if ex.Total != 3 {
|
||||||
|
t.Errorf("not blocked: %d", ex.Total)
|
||||||
|
}
|
||||||
|
// Limited to a view (the Statistics page's view switches).
|
||||||
|
ex, _ = q.ForViews([]string{"internal"}).Explore(ctx, ExploreFilter{RCode: "NXDOMAIN"}, 10, 50)
|
||||||
|
if ex.Total != 2 {
|
||||||
|
t.Errorf("internal NXDOMAIN: %d", ex.Total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Several values: any of them; !values: none of them.
|
||||||
|
total := func(f ExploreFilter) int64 {
|
||||||
|
t.Helper()
|
||||||
|
ex, err := q.Explore(ctx, f, 10, 50)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return ex.Total
|
||||||
|
}
|
||||||
|
for _, c := range []struct {
|
||||||
|
f ExploreFilter
|
||||||
|
want int64
|
||||||
|
}{
|
||||||
|
{ExploreFilter{Client: "10.0.0.5, 198.51.100.7"}, 6},
|
||||||
|
{ExploreFilter{Client: "!10.0.0.5"}, 2},
|
||||||
|
{ExploreFilter{RCode: "NXDOMAIN,noerror"}, 6},
|
||||||
|
{ExploreFilter{Name: "*.adbraun.lol"}, 5},
|
||||||
|
{ExploreFilter{Name: "!*.adbraun.lol"}, 1},
|
||||||
|
{ExploreFilter{Name: "www.adbraun.lol,nas.adbraun.lol"}, 2},
|
||||||
|
{ExploreFilter{Name: "*.adbraun.lol, !typo.adbraun.lol", Client: "!198.51.100.7"}, 2},
|
||||||
|
{ExploreFilter{Blocked: "!hagezi"}, 5},
|
||||||
|
{ExploreFilter{Domain: "adbraun.lol", Name: "!nope.adbraun.lol"}, 3},
|
||||||
|
} {
|
||||||
|
if got := total(c.f); got != c.want {
|
||||||
|
t.Errorf("%+v: %d answers, want %d", c.f, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Two clients: their breakdown shows them side by side.
|
||||||
|
ex, _ = q.Explore(ctx, ExploreFilter{Client: "10.0.0.5,198.51.100.7"}, 10, 50)
|
||||||
|
if len(ex.By["client"]) != 2 {
|
||||||
|
t.Errorf("two clients' breakdown %+v", ex.By["client"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pruning: past the retention they go.
|
||||||
|
clk.t = clk.t.Add(8 * 24 * time.Hour)
|
||||||
|
c.prune(ctx)
|
||||||
|
if n, _ := store.QueryLogSize(ctx); n != 0 {
|
||||||
|
t.Errorf("after pruning: %d rows", n)
|
||||||
|
}
|
||||||
|
// Off: an error that says how to turn it on.
|
||||||
|
q.Opts.RetainQueries = 0
|
||||||
|
if _, err := q.Explore(ctx, ExploreFilter{}, 10, 50); err != ErrNoQueryLog {
|
||||||
|
t.Errorf("off: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,21 @@ var schema = []string{
|
|||||||
PRIMARY KEY (res, ts, kind, key)
|
PRIMARY KEY (res, ts, kind, key)
|
||||||
) WITHOUT ROWID;
|
) WITHOUT ROWID;
|
||||||
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);`,
|
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);`,
|
||||||
|
// 2: the query log, every answer for a while (stats.query_retention),
|
||||||
|
// for drilling down: what a client asked, who asked for a name
|
||||||
|
// (docs/stats.md#exploring).
|
||||||
|
`CREATE TABLE queries (
|
||||||
|
ts INTEGER NOT NULL,
|
||||||
|
client TEXT NOT NULL,
|
||||||
|
view TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
rcode TEXT NOT NULL,
|
||||||
|
blocked TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
CREATE INDEX queries_ts ON queries (ts);
|
||||||
|
CREATE INDEX queries_client ON queries (client, ts);
|
||||||
|
CREATE INDEX queries_name ON queries (name, ts);`,
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenStore opens (creating if needed) the statistics database. Use
|
// OpenStore opens (creating if needed) the statistics database. Use
|
||||||
@@ -123,8 +138,25 @@ type Batch struct {
|
|||||||
counters []counterRow
|
counters []counterRow
|
||||||
tops []topsWrite
|
tops []topsWrite
|
||||||
meta map[string]string
|
meta map[string]string
|
||||||
|
queries []QueryRow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QueryRow is one answer in the query log.
|
||||||
|
type QueryRow struct {
|
||||||
|
Time int64 `json:"ts"` // Unix seconds
|
||||||
|
Client string `json:"client"`
|
||||||
|
View string `json:"view"` // "_default" without views
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
RCode string `json:"rcode"`
|
||||||
|
Blocked string `json:"blocked,omitempty"` // the blocklist (or "rules") that blocked it
|
||||||
|
// ClientName is the client's host name (set by the API).
|
||||||
|
ClientName string `json:"client_name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddQueries adds answers to the query log.
|
||||||
|
func (b *Batch) AddQueries(rows []QueryRow) { b.queries = append(b.queries, rows...) }
|
||||||
|
|
||||||
type counterRow struct {
|
type counterRow struct {
|
||||||
res string
|
res string
|
||||||
ts int64
|
ts int64
|
||||||
@@ -220,9 +252,39 @@ func (s *Store) Commit(ctx context.Context, b *Batch) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(b.queries) > 0 {
|
||||||
|
st, err := tx.PrepareContext(ctx, `INSERT INTO queries (ts, client, view, name, type, rcode, blocked) VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, q := range b.queries {
|
||||||
|
if _, err := st.ExecContext(ctx, q.Time, q.Client, q.View, q.Name, q.Type, q.RCode, q.Blocked); err != nil {
|
||||||
|
st.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
st.Close()
|
||||||
|
}
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PruneQueries deletes the query log's answers older than before (Unix
|
||||||
|
// time), a slice at a time so the database isn't locked for long.
|
||||||
|
func (s *Store) PruneQueries(ctx context.Context, before int64) (int64, error) {
|
||||||
|
var n int64
|
||||||
|
for {
|
||||||
|
r, err := s.db.ExecContext(ctx, `DELETE FROM queries WHERE rowid IN (SELECT rowid FROM queries WHERE ts < ? LIMIT 50000)`, before)
|
||||||
|
if err != nil {
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
c, _ := r.RowsAffected()
|
||||||
|
n += c
|
||||||
|
if c < 50000 {
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Meta returns a stored value ("" if unset).
|
// Meta returns a stored value ("" if unset).
|
||||||
func (s *Store) Meta(ctx context.Context, key string) (string, error) {
|
func (s *Store) Meta(ctx context.Context, key string) (string, error) {
|
||||||
var v string
|
var v string
|
||||||
|
|||||||
+12
-9
@@ -31,15 +31,15 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := e.QueryContext(ctx, `SELECT id, name, position, match_clients, match_destinations, match_recursive_only, comment, exposure, acknowledged, version, created_at, updated_at, created_by, updated_by FROM views`)
|
rows, err := e.QueryContext(ctx, `SELECT id, name, position, match_clients, match_destinations, match_recursive_only, comment, exposure, acknowledged, reverse_suggestions_off, dismissed_reverse, version, created_at, updated_at, created_by, updated_by FROM views`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
v := &model.View{}
|
v := &model.View{}
|
||||||
var mc, md, acks, ca, ua string
|
var mc, md, acks, dismissed, ca, ua string
|
||||||
var mro sql.NullInt64
|
var mro sql.NullInt64
|
||||||
if err := rows.Scan(&v.ID, &v.Name, &v.Position, &mc, &md, &mro, &v.Comment, &v.Exposure, &acks, &v.Version, &ca, &ua, &v.CreatedBy, &v.UpdatedBy); err != nil {
|
if err := rows.Scan(&v.ID, &v.Name, &v.Position, &mc, &md, &mro, &v.Comment, &v.Exposure, &acks, &v.ReverseSuggestionsOff, &dismissed, &v.Version, &ca, &ua, &v.CreatedBy, &v.UpdatedBy); err != nil {
|
||||||
rows.Close()
|
rows.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -48,6 +48,9 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
|
|||||||
if acks != "[]" && acks != "" {
|
if acks != "[]" && acks != "" {
|
||||||
json.Unmarshal([]byte(acks), &v.Acknowledged)
|
json.Unmarshal([]byte(acks), &v.Acknowledged)
|
||||||
}
|
}
|
||||||
|
if dismissed != "[]" && dismissed != "" {
|
||||||
|
json.Unmarshal([]byte(dismissed), &v.DismissedReverse)
|
||||||
|
}
|
||||||
if mro.Valid {
|
if mro.Valid {
|
||||||
v.MatchRecursiveOnly = model.Ptr(mro.Int64 != 0)
|
v.MatchRecursiveOnly = model.Ptr(mro.Int64 != 0)
|
||||||
}
|
}
|
||||||
@@ -95,14 +98,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, link_group, 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, auto_ptr, 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.LinkGroup, &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.AutoPTR, &z.Version, &ca, &ua, &z.CreatedBy, &z.UpdatedBy); err != nil {
|
||||||
rows.Close()
|
rows.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -267,8 +270,8 @@ func saveState(ctx context.Context, e execer, old, new *model.State) error {
|
|||||||
if v.MatchRecursiveOnly != nil {
|
if v.MatchRecursiveOnly != nil {
|
||||||
mro = boolInt(*v.MatchRecursiveOnly)
|
mro = boolInt(*v.MatchRecursiveOnly)
|
||||||
}
|
}
|
||||||
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO views (id, name, position, match_clients, match_destinations, match_recursive_only, comment, exposure, acknowledged, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO views (id, name, position, match_clients, match_destinations, match_recursive_only, comment, exposure, acknowledged, reverse_suggestions_off, dismissed_reverse, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
v.ID, v.Name, v.Position, jsonText(nonNil(v.MatchClients)), jsonText(nonNil(v.MatchDestinations)), mro, v.Comment, v.Exposure, jsonText(nonNilAcks(v.Acknowledged)), v.Version, ts(v.CreatedAt), ts(v.UpdatedAt), v.CreatedBy, v.UpdatedBy)
|
v.ID, v.Name, v.Position, jsonText(nonNil(v.MatchClients)), jsonText(nonNil(v.MatchDestinations)), mro, v.Comment, v.Exposure, jsonText(nonNilAcks(v.Acknowledged)), v.ReverseSuggestionsOff, jsonText(nonNil(v.DismissedReverse)), v.Version, ts(v.CreatedAt), ts(v.UpdatedAt), v.CreatedBy, v.UpdatedBy)
|
||||||
return err
|
return err
|
||||||
}, func(id int64) error { return del(ctx, e, "views", id) }); err != nil {
|
}, func(id int64) error { return del(ctx, e, "views", id) }); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -288,8 +291,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, link_group, 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, auto_ptr, 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.LinkGroup, 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.AutoPTR, 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
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ type Store struct {
|
|||||||
// DB exposes the underlying database (for backups and tests).
|
// DB exposes the underlying database (for backups and tests).
|
||||||
func (s *Store) DB() *sql.DB { return s.db }
|
func (s *Store) DB() *sql.DB { return s.db }
|
||||||
|
|
||||||
|
// Path returns the database file ("" in memory).
|
||||||
|
func (s *Store) Path() string {
|
||||||
|
var file string
|
||||||
|
if err := s.db.QueryRow(`SELECT file FROM pragma_database_list WHERE name = 'main'`).Scan(&file); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
// Open opens (creating if needed) the database at path and applies
|
// Open opens (creating if needed) the database at path and applies
|
||||||
// migrations. Use ":memory:" for a throwaway in-memory database.
|
// migrations. Use ":memory:" for a throwaway in-memory database.
|
||||||
func Open(path string) (*Store, error) {
|
func Open(path string) (*Store, error) {
|
||||||
@@ -366,6 +375,15 @@ CREATE TABLE certificates (
|
|||||||
`
|
`
|
||||||
ALTER TABLE zones ADD COLUMN link_group INTEGER NOT NULL DEFAULT 0;
|
ALTER TABLE zones ADD COLUMN link_group INTEGER NOT NULL DEFAULT 0;
|
||||||
ALTER TABLE records ADD COLUMN shared_id INTEGER NOT NULL DEFAULT 0;
|
ALTER TABLE records ADD COLUMN shared_id INTEGER NOT NULL DEFAULT 0;
|
||||||
|
`,
|
||||||
|
// 12: PTR records kept in step per zone (docs/reverse.md).
|
||||||
|
`
|
||||||
|
ALTER TABLE zones ADD COLUMN auto_ptr INTEGER NOT NULL DEFAULT 0;
|
||||||
|
`,
|
||||||
|
// 13: reverse zone suggestions per view (docs/reverse.md).
|
||||||
|
`
|
||||||
|
ALTER TABLE views ADD COLUMN reverse_suggestions_off INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE views ADD COLUMN dismissed_reverse TEXT NOT NULL DEFAULT '[]';
|
||||||
`,
|
`,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import * as users from "./pages/users.js";
|
|||||||
import * as account from "./pages/account.js";
|
import * as account from "./pages/account.js";
|
||||||
import * as search from "./pages/search.js";
|
import * as search from "./pages/search.js";
|
||||||
import * as stats from "./pages/stats.js";
|
import * as stats from "./pages/stats.js";
|
||||||
|
import * as explore from "./pages/explore.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";
|
||||||
@@ -39,6 +40,7 @@ const routes = [
|
|||||||
{ pattern: /^account$/, page: account, nav: "account" },
|
{ pattern: /^account$/, page: account, nav: "account" },
|
||||||
{ pattern: /^search$/, page: search, nav: "" },
|
{ pattern: /^search$/, page: search, nav: "" },
|
||||||
{ pattern: /^stats$/, page: stats, nav: "stats" },
|
{ pattern: /^stats$/, page: stats, nav: "stats" },
|
||||||
|
{ pattern: /^stats\/explore$/, page: explore, nav: "stats" },
|
||||||
{ 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" },
|
||||||
|
|||||||
@@ -329,7 +329,9 @@ export function heatmapTable(hm, unit) {
|
|||||||
// barList shows ranked entries as labelled horizontal bars in one hue
|
// barList shows ranked entries as labelled horizontal bars in one hue
|
||||||
// (one series: no legend, the title names it). Values are always printed,
|
// (one series: no legend, the title names it). Values are always printed,
|
||||||
// so the list is its own table view.
|
// so the list is its own table view.
|
||||||
export function barList(entries, { total, label = (k) => k, empty = "No data yet.", href } = {}) {
|
// extra(key), when given, adds something after an entry's label (e.g. a
|
||||||
|
// link that excludes it).
|
||||||
|
export function barList(entries, { total, label = (k) => k, empty = "No data yet.", href, extra } = {}) {
|
||||||
if (!entries.length) return h("div", { class: "empty" }, empty);
|
if (!entries.length) return h("div", { class: "empty" }, empty);
|
||||||
// Entries may be in a natural order (e.g. latency buckets), not by size.
|
// Entries may be in a natural order (e.g. latency buckets), not by size.
|
||||||
const peak = Math.max(1, ...entries.map((e) => e.count));
|
const peak = Math.max(1, ...entries.map((e) => e.count));
|
||||||
@@ -343,7 +345,7 @@ export function barList(entries, { total, label = (k) => k, empty = "No data yet
|
|||||||
return h("li", { title: (e.name ? e.name + " (" + name + ")" : name) + ": " + e.count.toLocaleString() + " (" + share.toFixed(1) + "%)" },
|
return h("li", { title: (e.name ? e.name + " (" + name + ")" : name) + ": " + e.count.toLocaleString() + " (" + share.toFixed(1) + "%)" },
|
||||||
h("div", { class: "bar-text" },
|
h("div", { class: "bar-text" },
|
||||||
link ? h("a", { href: link, class: "bar-label" }, shown) : h("span", { class: "bar-label" }, shown),
|
link ? h("a", { href: link, class: "bar-label" }, shown) : h("span", { class: "bar-label" }, shown),
|
||||||
h("span", { class: "bar-value" }, compact(e.count), h("span", { class: "muted" }, " · " + share.toFixed(share < 10 ? 1 : 0) + "%"))),
|
h("span", { class: "bar-value" }, extra ? [extra(e.key), " "] : null, compact(e.count), h("span", { class: "muted" }, " · " + share.toFixed(share < 10 ? 1 : 0) + "%"))),
|
||||||
h("div", { class: "bar-track" }, h("div", { class: "bar-fill", style: { width: Math.max(0.5, (e.count / peak) * 100) + "%" } })));
|
h("div", { class: "bar-track" }, h("div", { class: "bar-fill", style: { width: Math.max(0.5, (e.count / peak) * 100) + "%" } })));
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -182,3 +182,17 @@ export function checkDomain(input) {
|
|||||||
if (isReverseZone(name)) notes.push("a reverse zone: choosing \"Reverse zone\" and giving the network is easier");
|
if (isReverseZone(name)) notes.push("a reverse zone: choosing \"Reverse zone\" and giving the network is easier");
|
||||||
return { name, problems, notes };
|
return { name, problems, notes };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maskNetwork returns the network of the given size an address (or a
|
||||||
|
// network, "2a02:8013:7f3d:10::/64") is in: maskNetwork("2a02:8013:7f3d:10::5", 48)
|
||||||
|
// is "2a02:8013:7f3d::/48". null when it isn't an address.
|
||||||
|
export function maskNetwork(input, bits) {
|
||||||
|
const addr = String(input).trim().split("/")[0];
|
||||||
|
const b4 = parseIPv4(addr);
|
||||||
|
const n6 = b4 ? null : parseIPv6(addr);
|
||||||
|
if (!b4 && !n6) return null;
|
||||||
|
const digits = b4 || n6.map((x) => parseInt(x, 16));
|
||||||
|
const step = b4 ? 8 : 4;
|
||||||
|
const net = digits.map((d, i) => { const k = Math.max(0, Math.min(step, bits - i * step)); return d & ~((1 << (step - k)) - 1) & ((1 << step) - 1); });
|
||||||
|
return (b4 ? net.join(".") : sixOf(net.map((d) => d.toString(16)))) + "/" + bits;
|
||||||
|
}
|
||||||
@@ -33,7 +33,8 @@ export async function render(el) {
|
|||||||
{ label: "Names", render: (c) => h("div", { class: "mono small" }, (c.names && c.names.length ? c.names : c.domains).map((n) => h("div", null, n))) },
|
{ label: "Names", render: (c) => h("div", { class: "mono small" }, (c.names && c.names.length ? c.names : c.domains).map((n) => h("div", null, n))) },
|
||||||
{ label: "Used for", render: (c) => [
|
{ label: "Used for", render: (c) => [
|
||||||
c.uses.includes("web") ? badge(c.serving ? "web UI (serving)" : "web UI", c.serving ? "ok" : "") : null, " ",
|
c.uses.includes("web") ? badge(c.serving ? "web UI (serving)" : "web UI", c.serving ? "ok" : "") : null, " ",
|
||||||
c.uses.includes("dns") ? badge("named", "") : null,
|
c.uses.includes("dns") ? [badge("named", ""), (c.named_use || []).map((u) => h("div", { class: "small muted", title: u }, u.replace("DNS over TLS on ", "DoT ").replace("DNS over HTTPS on ", "DoH "))),
|
||||||
|
!(c.named_use || []).length ? h("div", { class: "small muted" }, "not used by named yet") : null] : null,
|
||||||
!c.uses.length ? h("span", { class: "muted" }, "nothing yet") : null] },
|
!c.uses.length ? h("span", { class: "muted" }, "nothing yet") : null] },
|
||||||
{ label: "Source", render: (c) => c.source === "acme" ? h("span", null, "Jarl", h("div", { class: "small muted" }, "challenge " + c.challenge)) : h("span", null, "your files", h("div", { class: "small muted mono" }, c.cert_file)) },
|
{ label: "Source", render: (c) => c.source === "acme" ? h("span", null, "Jarl", h("div", { class: "small muted" }, "challenge " + c.challenge)) : h("span", null, "your files", h("div", { class: "small muted mono" }, c.cert_file)) },
|
||||||
{ label: "Expires", render: (c) => c.not_after ? h("span", null, time(c.not_after), c.source === "acme" && c.next_attempt ? h("div", { class: "small muted" }, "renews " + fmtTime(c.next_attempt)) : null) : h("span", { class: "muted" }, "–") },
|
{ label: "Expires", render: (c) => c.not_after ? h("span", null, time(c.not_after), c.source === "acme" && c.next_attempt ? h("div", { class: "small muted" }, "renews " + fmtTime(c.next_attempt)) : null) : h("span", { class: "muted" }, "–") },
|
||||||
@@ -79,6 +80,7 @@ function details(c) {
|
|||||||
modal("Certificate " + c.name, h("div", null,
|
modal("Certificate " + c.name, h("div", null,
|
||||||
h("dl", { class: "kv" }, rows.map(([k, v]) => [h("dt", null, k), h("dd", null, v)])),
|
h("dl", { class: "kv" }, rows.map(([k, v]) => [h("dt", null, k), h("dd", null, v)])),
|
||||||
c.files ? h("div", null, h("h3", null, "For named"),
|
c.files ? h("div", null, h("h3", null, "For named"),
|
||||||
|
(c.named_use || []).length ? h("ul", { class: "small" }, c.named_use.map((u) => h("li", null, u))) : h("p", { class: "small muted" }, "named doesn't use it yet (Encrypted DNS sets that up)."),
|
||||||
h("p", { class: "small muted" }, "Point a tls block in named's configuration at these files; Jarl rewrites them on renewal and has named reread them."),
|
h("p", { class: "small muted" }, "Point a tls block in named's configuration at these files; Jarl rewrites them on renewal and has named reread them."),
|
||||||
copyBox(c.files.chain), copyBox(c.files.key)) : null), [{ label: "Close", value: null }], { wide: true });
|
copyBox(c.files.chain), copyBox(c.files.key)) : null), [{ label: "Close", value: null }], { wide: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ 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.storage ? storageCard(st.storage) : 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 +47,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, " ",
|
||||||
@@ -94,6 +101,7 @@ export async function render(el) {
|
|||||||
h("h2", null, "Configuration checks"),
|
h("h2", null, "Configuration checks"),
|
||||||
real.length ? problemsList(real) : h("div", { class: "msg ok" }, "No problems found in the current configuration."),
|
real.length ? problemsList(real) : h("div", { class: "msg ok" }, "No problems found in the current configuration."),
|
||||||
problems.length > real.length ? h("details", null, h("summary", { class: "muted" }, plural(problems.length - real.length, "note")), problemsList(problems.filter((p) => p.severity === "info"))) : null));
|
problems.length > real.length ? h("details", null, h("summary", { class: "muted" }, plural(problems.length - real.length, "note")), problemsList(problems.filter((p) => p.severity === "info"))) : null));
|
||||||
|
if (st.storage) parts.push(storagePanel(st.storage));
|
||||||
if (s.paths) {
|
if (s.paths) {
|
||||||
parts.push(h("div", { class: "panel" }, h("h2", null, "Files"), h("dl", { class: "kv" },
|
parts.push(h("div", { class: "panel" }, h("h2", null, "Files"), h("dl", { class: "kv" },
|
||||||
h("dt", null, "named's main config"), h("dd", { class: "mono" }, s.main_config || "–"),
|
h("dt", null, "named's main config"), h("dd", { class: "mono" }, s.main_config || "–"),
|
||||||
@@ -134,3 +142,52 @@ function certCard(c) {
|
|||||||
function card(label, value, sub) {
|
function card(label, value, sub) {
|
||||||
return h("div", { class: "card" }, h("div", { class: "label" }, label), h("div", { class: "value" }, value), sub ? h("div", { class: "sub" }, sub) : null);
|
return h("div", { class: "card" }, h("div", { class: "label" }, label), h("div", { class: "value" }, value), sub ? h("div", { class: "sub" }, sub) : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// storageCard: the databases' size, and the fullest disk Jarl uses.
|
||||||
|
function storageCard(sg) {
|
||||||
|
const fullest = sg.disks.reduce((a, d) => (!a || used(d) > used(a) ? d : a), null);
|
||||||
|
const value = h("span", null, bytes(sg.total));
|
||||||
|
const sub = fullest ? [h("span", { class: used(fullest) > 0.9 ? "warn-text" : "" }, Math.round(used(fullest) * 100) + "% full"), " · " + fullest.path] : "databases";
|
||||||
|
return h("a", { class: "card link", href: "#storage" , onclick: (e) => { e.preventDefault(); document.getElementById("storage")?.scrollIntoView({ behavior: "smooth" }); } },
|
||||||
|
h("div", { class: "label" }, "Databases"), h("div", { class: "value" }, value), h("div", { class: "sub" }, sub));
|
||||||
|
}
|
||||||
|
|
||||||
|
// storagePanel shows each database's size and, per disk Jarl uses, a bar:
|
||||||
|
// the databases, everything else, and what is free.
|
||||||
|
function storagePanel(sg) {
|
||||||
|
const dbRows = sg.databases.map((d) => [h("dt", null, d.label),
|
||||||
|
h("dd", null, h("strong", null, bytes(d.bytes)), " ", h("span", { class: "mono small muted" }, d.path),
|
||||||
|
d.query_log_rows != null ? h("div", { class: "small muted" }, "query log: " + d.query_log_rows.toLocaleString() + (d.query_log_rows === 1 ? " answer" : " answers") + " so far, each kept for " + retentionWords(d.query_log_retention) + " (stats.retention.queries)") : null)]);
|
||||||
|
return h("div", { class: "panel", id: "storage" },
|
||||||
|
h("h2", null, "Storage"),
|
||||||
|
h("dl", { class: "kv" }, dbRows, h("dt", null, "Together"), h("dd", null, h("strong", null, bytes(sg.total)))),
|
||||||
|
sg.disks.map((d) => {
|
||||||
|
const other = Math.max(0, d.total - d.free - d.databases);
|
||||||
|
const pctOf = (n) => (d.total ? (n / d.total) * 100 : 0) + "%";
|
||||||
|
const u = used(d);
|
||||||
|
return h("div", { class: "disk" },
|
||||||
|
h("div", { class: "disk-head" }, h("span", { class: "mono" }, d.path), h("span", { class: "muted small" }, " · " + d.holds.join(", ")),
|
||||||
|
h("span", { class: "grow" }), h("span", { class: u > 0.9 ? "warn-text" : "muted" }, bytes(d.free) + " free of " + bytes(d.total) + " (" + Math.round(u * 100) + "% used)")),
|
||||||
|
h("div", { class: "disk-bar", role: "img", "aria-label": Math.round(u * 100) + "% used, Jarl's databases " + bytes(d.databases) },
|
||||||
|
d.databases ? h("div", { class: "seg db", title: "Jarl's databases: " + bytes(d.databases), style: { width: pctOf(d.databases) } }) : null,
|
||||||
|
h("div", { class: "seg other" + (u > 0.9 ? " full" : ""), title: "Everything else: " + bytes(other), style: { width: pctOf(other) } })),
|
||||||
|
d.databases ? h("div", { class: "small muted" }, h("span", { class: "swatch db" }), " Jarl's databases " + bytes(d.databases), " ", h("span", { class: "swatch other" }), " everything else") : null);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function used(d) { return d.total ? (d.total - d.free) / d.total : 0; }
|
||||||
|
|
||||||
|
function bytes(n) {
|
||||||
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
let i = 0;
|
||||||
|
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
|
||||||
|
return (n >= 100 || i === 0 ? Math.round(n) : n.toFixed(1)) + " " + units[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// retentionWords: "7d" -> "7 days".
|
||||||
|
function retentionWords(r) {
|
||||||
|
const m = /^(\d+)([dhm])$/.exec(r || "");
|
||||||
|
if (!m) return r || "a while";
|
||||||
|
const word = { d: "day", h: "hour", m: "minute" }[m[2]];
|
||||||
|
return m[1] + " " + word + (m[1] === "1" ? "" : "s");
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
// set up and what to give clients, and a setup that obtains the
|
// set up and what to give clients, and a setup that obtains the
|
||||||
// certificate if needed, checks the ports and stages named's
|
// certificate if needed, checks the ports and stages named's
|
||||||
// configuration like any change.
|
// configuration like any change.
|
||||||
import { h, mount, field, input, badge, toast, errorBox, confirmDialog, copyBox, checkbox } from "../ui.js";
|
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 { api, get, change, describeResult, state } from "../api.js";
|
||||||
import { rerender } from "../app.js";
|
import { rerender } from "../app.js";
|
||||||
|
|
||||||
@@ -26,19 +26,24 @@ export async function render(el) {
|
|||||||
function statusPanel(s) {
|
function statusPanel(s) {
|
||||||
const eps = (list) => list.map((e) => e.address + " port " + e.port).join(", ");
|
const eps = (list) => list.map((e) => e.address + " port " + e.port).join(", ");
|
||||||
const rows = [
|
const rows = [
|
||||||
h("dt", null, "Certificate"), h("dd", null, h("a", { href: "#/certificates" }, s.certificate || "?"), s.hostname ? " (" + s.hostname + ")" : null),
|
h("dt", null, s.hostnames.length > 1 ? "Host names" : "Host name"), h("dd", { class: "mono" }, s.hostnames.join(", ") || "?"),
|
||||||
|
h("dt", null, "Certificate"), h("dd", null, h("a", { href: "#/certificates" }, s.certificate || "?")),
|
||||||
|
Object.keys(s.address_views || {}).length ? [h("dt", null, "Records"), h("dd", null, Object.entries(s.address_views).filter(([a]) => [...s.dot, ...s.doh].some((e) => e.address === a))
|
||||||
|
.map(([a, vs]) => h("div", null, h("span", { class: "mono" }, a), h("span", { class: "muted" }, " in " + vs.join(", ")))))] : null,
|
||||||
s.dot.length ? [h("dt", null, "DNS over TLS"), h("dd", { class: "mono" }, eps(s.dot))] : 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,
|
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 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 dotPortArg = s.dot[0] && s.dot[0].port !== 853 ? " -p " + s.dot[0].port : "";
|
||||||
|
const dohPortArg = s.doh[0] && s.doh[0].port !== 443 ? " -p " + s.doh[0].port : "";
|
||||||
const clients = [];
|
const clients = [];
|
||||||
if (s.dot_host) {
|
if (s.dot_host) {
|
||||||
clients.push(h("h3", null, "DNS over TLS"), copyBox(s.dot_host),
|
clients.push(h("h3", null, "DNS over TLS"), copyBox(s.dot_host),
|
||||||
h("ul", { class: "small" },
|
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, "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, "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"))));
|
h("li", null, h("strong", null, "Test: "), h("code", null, "dig +tls +tls-ca +tls-hostname=" + s.hostname + dotPortArg + " @" + addr + " example.com"),
|
||||||
|
" (BIND's dig; +tls-ca checks the certificate against the system's CAs)")));
|
||||||
}
|
}
|
||||||
if (s.doh_url) {
|
if (s.doh_url) {
|
||||||
clients.push(h("h3", null, "DNS over HTTPS"), copyBox(s.doh_url),
|
clients.push(h("h3", null, "DNS over HTTPS"), copyBox(s.doh_url),
|
||||||
@@ -46,10 +51,11 @@ function statusPanel(s) {
|
|||||||
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, "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, "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, "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"), ")")));
|
h("li", null, h("strong", null, "Test: "), h("code", null, "dig +https +tls-ca +tls-hostname=" + s.hostname + dohPortArg + " @" + addr + " example.com"))));
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
h("div", { class: "panel" }, h("h2", null, "Set up ", badge("on", "ok")), h("dl", { class: "kv" }, rows)),
|
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("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."),
|
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),
|
clients),
|
||||||
@@ -57,7 +63,8 @@ function statusPanel(s) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setupPanel(s) {
|
function setupPanel(s) {
|
||||||
const host = input({ value: s.hostname || "", placeholder: "dns.adbraun.lol", class: "mono", style: { maxWidth: "320px" } });
|
const hostIn = input({ value: (s.hostnames || []).join(", "), placeholder: "dns.adbraun.lol", class: "mono", style: { maxWidth: "420px" } });
|
||||||
|
const hosts = () => hostIn.value.split(/[\s,]+/).map((x) => x.trim().toLowerCase().replace(/\.$/, "")).filter(Boolean);
|
||||||
const certSel = h("select");
|
const certSel = h("select");
|
||||||
const dot = checkbox("DNS over TLS", s.enabled ? s.dot.length > 0 : true);
|
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 doh = checkbox("DNS over HTTPS", s.enabled ? s.doh.length > 0 : true);
|
||||||
@@ -68,40 +75,64 @@ function setupPanel(s) {
|
|||||||
const portOut = h("div");
|
const portOut = h("div");
|
||||||
const err = h("div");
|
const err = h("div");
|
||||||
const progress = h("div", { class: "muted small" });
|
const progress = h("div", { class: "muted small" });
|
||||||
|
let views = s.views;
|
||||||
|
// Per address, the views where the host names get it: what their records
|
||||||
|
// say now (the setup's, or ones made by hand, which would otherwise be
|
||||||
|
// taken out), nothing else.
|
||||||
|
const viewsFor = new Map(s.addresses.map((a) => [a.address, new Set((s.address_views || {})[a.address] || [])]));
|
||||||
|
const isPrivate = new Map(s.addresses.map((a) => [a.address, a.private]));
|
||||||
|
|
||||||
// Addresses: one per row, nothing ticked (but the current setup's).
|
const viewCells = new Map();
|
||||||
const addrRows = s.addresses.map((a) => pickRow(a.address, picked, [a.private ? h("span", { class: "muted" }, "private")
|
const drawViewCell = (a) => {
|
||||||
: a.address.includes(":") ? h("span", { class: "muted", title: "A global IPv6 address: reachable from the internet unless the firewall stops it" }, "global")
|
const cell = viewCells.get(a);
|
||||||
: badge("public", "warn")],
|
if (!cell) return;
|
||||||
"Listen on " + a.address, () => checkPorts()));
|
if (!picked.has(a)) return mount(cell);
|
||||||
// Views for the host name's records: the private ones that have its zone.
|
const set = viewsFor.get(a);
|
||||||
const viewPicked = new Set();
|
const warn = isPrivate.get(a) && views.some((v) => v.public && set.has(v.name));
|
||||||
const viewBody = h("tbody");
|
mount(cell, h("div", { class: "view-chips" }, h("span", { class: "muted small" }, "records in: "),
|
||||||
const drawViews = (views) => {
|
views.map((v) => {
|
||||||
viewPicked.clear();
|
const box = h("input", { type: "checkbox", checked: set.has(v.name), disabled: !v.has_zone && !set.has(v.name) });
|
||||||
mount(viewBody, views.map((v) => {
|
box.addEventListener("change", () => { if (box.checked) set.add(v.name); else set.delete(v.name); drawViewCell(a); });
|
||||||
if (!v.public && v.has_zone) viewPicked.add(v.name);
|
return h("label", { class: "chip" + (v.public ? " public" : ""), title: v.has_zone ? (v.public ? "A public view: the internet's clients get these records" : "") : "View " + v.name + " has no zone for the host names" }, box, " " + v.name, v.public ? h("span", { class: "muted small" }, " (public)") : null);
|
||||||
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);
|
!views.length ? h("span", { class: "muted small" }, "no views") : null),
|
||||||
}));
|
warn ? h("div", { class: "msg warn small" }, a + " is a private address: clients of a public view can't reach it.") : null);
|
||||||
};
|
};
|
||||||
drawViews(s.views);
|
const addrRows = s.addresses.map((a) => {
|
||||||
|
const cell = h("td", { class: "addr-views" });
|
||||||
|
// The row toggles the address; clicks on its views shouldn't.
|
||||||
|
cell.addEventListener("click", (e) => e.stopPropagation());
|
||||||
|
viewCells.set(a.address, cell);
|
||||||
|
const tr = 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, () => { drawViewCell(a.address); checkPorts(); });
|
||||||
|
tr.append(cell);
|
||||||
|
drawViewCell(a.address);
|
||||||
|
return tr;
|
||||||
|
});
|
||||||
|
const redrawViews = () => { for (const a of viewCells.keys()) drawViewCell(a); };
|
||||||
|
|
||||||
const fillCerts = () => {
|
const fillCerts = () => {
|
||||||
const name = host.value.trim().toLowerCase().replace(/\.$/, "");
|
const names = hosts();
|
||||||
const fit = s.certificates.filter((c) => covers([...(c.names || []), ...(c.domains || [])], name));
|
const fit = s.certificates.filter((c) => names.length && names.every((n) => covers([...(c.names || []), ...(c.domains || [])], n)));
|
||||||
mount(certSel,
|
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") + ")")),
|
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"));
|
names.length ? h("option", { value: "" }, "Obtain a new certificate for " + names.join(", ")) : h("option", { value: "" }, "Type the host names first"));
|
||||||
};
|
};
|
||||||
fillCerts();
|
fillCerts();
|
||||||
let viewTimer = null;
|
let viewTimer = null;
|
||||||
host.addEventListener("input", () => {
|
hostIn.addEventListener("input", () => {
|
||||||
fillCerts();
|
fillCerts();
|
||||||
clearTimeout(viewTimer);
|
clearTimeout(viewTimer);
|
||||||
viewTimer = setTimeout(async () => {
|
viewTimer = setTimeout(async () => {
|
||||||
const r = await get("/encrypted-dns", { hostname: host.value.trim() }).catch(() => null);
|
const r = await get("/encrypted-dns", { hostname: hosts().join(",") }).catch(() => null);
|
||||||
if (r) drawViews(r.views);
|
if (r) {
|
||||||
|
views = r.views;
|
||||||
|
// Records the new host names already have stay ticked.
|
||||||
|
for (const [a, vs] of Object.entries(r.address_views || {})) if (viewsFor.has(a)) vs.forEach((v) => viewsFor.get(a).add(v));
|
||||||
|
redrawViews();
|
||||||
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -128,23 +159,26 @@ function setupPanel(s) {
|
|||||||
if (picked.size) checkPorts();
|
if (picked.size) checkPorts();
|
||||||
|
|
||||||
const body = (full) => {
|
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 };
|
const b = { hostnames: hosts(), 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]; }
|
if (full) {
|
||||||
|
b.certificate = certSel.value;
|
||||||
|
b.address_views = Object.fromEntries([...picked].map((a) => [a, [...(viewsFor.get(a) || [])]]));
|
||||||
|
}
|
||||||
return b;
|
return b;
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = async (btn) => {
|
const submit = async (btn) => {
|
||||||
mount(err);
|
mount(err);
|
||||||
const b = body(true);
|
const b = body(true);
|
||||||
if (!b.hostname) return mount(err, h("div", { class: "msg err" }, "Give the host name clients connect to."));
|
if (!b.hostnames.length) 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."));
|
if (!b.addresses.length) return mount(err, h("div", { class: "msg err" }, "Choose at least one address to listen on."));
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
try {
|
try {
|
||||||
if (!b.certificate) {
|
if (!b.certificate) {
|
||||||
// Obtain one for the host name, for named, and wait for it.
|
// Obtain one for the host names, for named, and wait for it.
|
||||||
b.certificate = b.hostname.split(".")[0].replace(/[^a-z0-9_-]/g, "-");
|
b.certificate = b.hostnames[0].split(".")[0].replace(/[^a-z0-9_-]/g, "-");
|
||||||
mount(progress, "Obtaining a certificate for " + b.hostname + " from the CA…");
|
mount(progress, "Obtaining a certificate for " + b.hostnames.join(", ") + " from the CA…");
|
||||||
await api("POST", "/certificates", { body: { name: b.certificate, domains: [b.hostname], uses: ["dns"] } });
|
await api("POST", "/certificates", { body: { name: b.certificate, domains: b.hostnames, uses: ["dns"] } });
|
||||||
await waitObtained(b.certificate, progress);
|
await waitObtained(b.certificate, progress);
|
||||||
} else {
|
} else {
|
||||||
const c = await get("/certificates/" + encodeURIComponent(b.certificate));
|
const c = await get("/certificates/" + encodeURIComponent(b.certificate));
|
||||||
@@ -169,14 +203,12 @@ function setupPanel(s) {
|
|||||||
return h("div", { class: "panel" },
|
return h("div", { class: "panel" },
|
||||||
h("h2", null, s.enabled ? "Change the setup" : "Set up"),
|
h("h2", null, s.enabled ? "Change the setup" : "Set up"),
|
||||||
h("div", { class: "form-grid" },
|
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("Host names", hostIn, "The names clients connect to, separated by commas: dns.adbraun.lol, or several (dns.adbraun.lol, doh.adbraun.lol). The certificate must be for all of them."),
|
||||||
field("Certificate", certSel, "One with this name, or a new one Jarl obtains and renews (docs/certificates.md)."),
|
field("Certificate", certSel, "One for all the host names, 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),
|
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."),
|
"This server's addresses on your own networks. For each, tick the views where the host names get it (A or AAAA records, local to the view): a view's clients should only be given an address they can reach."),
|
||||||
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"))),
|
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."),
|
"Clients expect DoT on port 853. DoH can use any free port; 443 is the default, and needs nothing else listening there. Ports can be changed later: clients set up with another port need it changed too.")),
|
||||||
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,
|
err, progress,
|
||||||
h("div", { class: "buttons" }, go));
|
h("div", { class: "buttons" }, go));
|
||||||
}
|
}
|
||||||
@@ -223,3 +255,28 @@ async function disable() {
|
|||||||
rerender();
|
rerender();
|
||||||
} catch (e) { toast(e.message, "err", 8000); }
|
} 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
// Exploring the query log (docs/stats.md#exploring): the answers of the
|
||||||
|
// last days, filtered by any mix of client, name, view, record type, result
|
||||||
|
// and blocking. Every value is a link that adds it to the filter, so a
|
||||||
|
// client leads to its names, a name to the clients asking for it, and so on.
|
||||||
|
import { h, mount, fmtTime, errorBox, badge, backLink, input } from "../ui.js";
|
||||||
|
import { get } from "../api.js";
|
||||||
|
import { lineChart, chartCard, legend, timeTable, barList, compact } from "../charts.js";
|
||||||
|
import { viewsOff } from "./stats.js";
|
||||||
|
|
||||||
|
const dims = [
|
||||||
|
["client", "Clients"], ["name", "Names"], ["view", "Views"],
|
||||||
|
["type", "Record types"], ["rcode", "Results"], ["blocked", "Blocked by"],
|
||||||
|
];
|
||||||
|
// What a filter is called, in the chips and the title.
|
||||||
|
const filterWords = { client: "client", name: "name", domain: "domain", view: "view", type: "type", rcode: "result", blocked: "blocked" };
|
||||||
|
const keys = Object.keys(filterWords);
|
||||||
|
const ranges = [["1h", 3600], ["6h", 6 * 3600], ["24h", 86400], ["3d", 3 * 86400], ["7d", 7 * 86400], ["30d", 30 * 86400]];
|
||||||
|
|
||||||
|
// exploreHref is the explore page for these filters (and range).
|
||||||
|
export function exploreHref(filters, since) {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
for (const k of keys) {
|
||||||
|
const v = Array.isArray(filters[k]) ? filters[k].join(",") : filters[k];
|
||||||
|
if (v) q.set(k, v);
|
||||||
|
}
|
||||||
|
if (since && since !== "24h") q.set("since", since);
|
||||||
|
return "#/stats/explore" + (q.toString() ? "?" + q : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockedLabel = (k) => (k === "rules" ? "your rules" : k === "yes" ? "anything" : k === "no" ? "nothing" : k);
|
||||||
|
|
||||||
|
// A filter is, per kind, a list of values: any of them, and !VALUE for
|
||||||
|
// "not this". Names: *.example.com is it and the names below it.
|
||||||
|
const kinds = ["client", "name", "view", "type", "rcode", "blocked"];
|
||||||
|
const split = (v) => (Array.isArray(v) ? v : String(v || "").split(",")).map((x) => x.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
function parse(query) {
|
||||||
|
const f = {};
|
||||||
|
for (const k of kinds) f[k] = split(query.get(k));
|
||||||
|
for (const d of split(query.get("domain"))) f.name.push(d.startsWith("!") ? "!*." + d.slice(1).replace(/^\*\./, "") : "*." + d.replace(/^\*\./, ""));
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
const plain = (v) => v.replace(/^!/, "");
|
||||||
|
const isNot = (v) => v.startsWith("!");
|
||||||
|
function addTo(f, k, v) { const g = copy(f); g[k] = g[k].filter((x) => x !== "!" + v && x !== v).concat(v); return g; }
|
||||||
|
function notTo(f, k, v) { const g = copy(f); g[k] = g[k].filter((x) => x !== v && x !== "!" + v).concat("!" + v); return g; }
|
||||||
|
function dropFrom(f, k, v) { const g = copy(f); g[k] = g[k].filter((x) => x !== v); return g; }
|
||||||
|
function copy(f) { const g = {}; for (const k of kinds) g[k] = [...(f[k] || [])]; return g; }
|
||||||
|
const includes = (f, k) => (f[k] || []).filter((v) => !isNot(v));
|
||||||
|
const anyFilter = (f) => kinds.some((k) => (f[k] || []).length);
|
||||||
|
|
||||||
|
let generation = 0;
|
||||||
|
|
||||||
|
export async function render(el, params, query) {
|
||||||
|
const gen = ++generation;
|
||||||
|
const hash = location.hash;
|
||||||
|
const f = parse(query);
|
||||||
|
const since = query.get("since") || "24h";
|
||||||
|
const go = (g) => exploreHref(g, since);
|
||||||
|
const q = {};
|
||||||
|
for (const k of kinds) if (f[k].length) q[k] = f[k].join(",");
|
||||||
|
|
||||||
|
// The Statistics page's view switches apply here too.
|
||||||
|
const off = viewsOff();
|
||||||
|
const allViews = (await get("/views").catch(() => [])).map((v) => v.name);
|
||||||
|
const views = off.size ? allViews.filter((v) => !off.has(v)).join(",") : "";
|
||||||
|
let ex;
|
||||||
|
try {
|
||||||
|
ex = await get("/stats/explore", { ...q, since, views: views || undefined });
|
||||||
|
} catch (e) {
|
||||||
|
mount(el, head(f, go, since, null), filterBar(f, since, null, allViews), e.code === "query_log_off"
|
||||||
|
? h("div", { class: "msg info" }, e.message) : errorBox(e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = [head(f, go, since, ex), filterBar(f, since, ex, allViews)];
|
||||||
|
const maxSecs = retentionSecs(ex.retention);
|
||||||
|
parts.push(h("div", { class: "filter-row", role: "group", "aria-label": "Time range" },
|
||||||
|
h("span", { class: "muted" }, "Range"),
|
||||||
|
h("div", { class: "segmented" }, ranges.filter(([, s]) => s <= maxSecs).map(([k]) =>
|
||||||
|
h("a", { href: exploreHref(f, k), class: k === since ? "active" : "" }, k))),
|
||||||
|
h("span", { class: "muted small" }, " · the query log keeps " + ex.retention + (ex.oldest ? ", since " + fmtTime(ex.oldest) : "")),
|
||||||
|
off.size ? h("span", { class: "muted small" }, " · views switched off on the Statistics page are left out") : null));
|
||||||
|
|
||||||
|
const nx = (ex.by.rcode || []).find((e) => e.key === "NXDOMAIN");
|
||||||
|
parts.push(h("div", { class: "cards" },
|
||||||
|
tile("Answers", compact(ex.total), "from " + fmtTime(ex.from)),
|
||||||
|
tile("Blocked", compact(ex.blocked), ex.total ? pct(ex.blocked / ex.total) + " of them" : ""),
|
||||||
|
nx && !includes(f, "rcode").length ? tile("NXDOMAIN", compact(nx.count), pct(nx.count / Math.max(1, ex.total)) + " of them") : null,
|
||||||
|
includes(f, "client").length !== 1 && ex.by.client ? tile("Clients", (ex.by.client.length >= 15 ? "15+" : String(ex.by.client.length)), "asking") : null));
|
||||||
|
|
||||||
|
const times = ex.series.map((p) => p.t);
|
||||||
|
const series = [{ label: "Answers", values: ex.series.map((p) => p.count), slot: 1 }];
|
||||||
|
if (ex.blocked) series.push({ label: "Blocked", values: ex.series.map((p) => p.blocked), slot: 2 });
|
||||||
|
parts.push(chartCard(ex.blocked ? "Answers and blocked" : "Answers", "per " + stepWord(ex.step),
|
||||||
|
ex.total ? lineChart({ series, times, step: ex.step, area: true }) : h("div", { class: "empty" }, "No answers match."),
|
||||||
|
ex.total ? timeTable(series, times, ex.step) : null, { wide: true, legend: series.length > 1 ? legend(series) : null }));
|
||||||
|
|
||||||
|
// A breakdown for each kind the filter leaves open: click a value to add
|
||||||
|
// it, "hide" to leave it out.
|
||||||
|
const lists = dims.filter(([d]) => ex.by[d] && !(d === "blocked" && !ex.blocked)).map(([d, title]) => {
|
||||||
|
const label = d === "blocked" ? blockedLabel : d === "view" ? (k) => (k === "_default" ? "(no views)" : k) : (k) => k;
|
||||||
|
const pinned = (k) => includes(f, d).includes(k);
|
||||||
|
return h("div", { class: "card chart-card" }, h("h3", null, title),
|
||||||
|
barList(ex.by[d], { total: d === "blocked" ? ex.blocked : ex.total, label, empty: d === "blocked" ? "Nothing blocked." : "None.",
|
||||||
|
href: (k) => (pinned(k) ? null : go(addTo(f, d, k))),
|
||||||
|
extra: (k) => h("a", { class: "bar-hide", href: go(notTo(f, d, k)), title: "Leave " + label(k) + " out" }, "hide") }));
|
||||||
|
});
|
||||||
|
if (lists.length) parts.push(h("div", { class: "chart-grid" }, lists));
|
||||||
|
|
||||||
|
parts.push(recentPanel(ex, (k, v) => go(addTo(f, k, v))));
|
||||||
|
if (gen !== generation) return; // the user moved on
|
||||||
|
el.classList.remove("refreshing");
|
||||||
|
mount(el, parts);
|
||||||
|
scheduleRefresh(el, params, query, gen, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
// scheduleRefresh redraws the page a minute later with the same filters,
|
||||||
|
// so the graphs move along; not while a filter is being typed in.
|
||||||
|
function scheduleRefresh(el, params, query, gen, hash) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (gen !== generation || location.hash !== hash || !el.isConnected) return;
|
||||||
|
const form = el.querySelector("form.explore-filters");
|
||||||
|
if (form && (form.contains(document.activeElement) || form.dataset.dirty)) {
|
||||||
|
scheduleRefresh(el, params, query, gen, hash); // try again later
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.classList.add("refreshing");
|
||||||
|
render(el, params, query).catch(() => el.classList.remove("refreshing"));
|
||||||
|
}, 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterBar types filters in. Each field takes several values, comma
|
||||||
|
// separated (any of them), !value to leave one out, and names *.domain for
|
||||||
|
// it and the names below it. It starts from the current filters and
|
||||||
|
// suggests values from what is shown.
|
||||||
|
function filterBar(f, since, ex, views) {
|
||||||
|
const sugg = (id, entries) => h("datalist", { id }, (entries || []).map((e) => h("option", { value: e.key }, e.name || null)));
|
||||||
|
const field = (k, placeholder, width, list) => input({ value: (f[k] || []).join(", "), placeholder, class: "mono", list, style: { width } });
|
||||||
|
const client = field("client", "any client", "190px", "ex-clients");
|
||||||
|
const name = field("name", "nas.adbraun.lol, *.apple.com", "250px", "ex-names");
|
||||||
|
const view = field("view", "any view", "130px", "ex-views");
|
||||||
|
const type = field("type", "any type", "110px", "ex-types");
|
||||||
|
const rcode = field("rcode", "any result", "130px", "ex-rcodes");
|
||||||
|
const blocked = field("blocked", "yes, no or a list", "140px", "ex-blocked");
|
||||||
|
const apply = () => {
|
||||||
|
const g = { client: split(client.value), name: split(name.value).map((v) => v.toLowerCase().replace(/\.$/, "")), view: split(view.value),
|
||||||
|
type: split(type.value).map((v) => v.toUpperCase()), rcode: split(rcode.value).map((v) => v.toUpperCase()), blocked: split(blocked.value) };
|
||||||
|
location.hash = exploreHref(g, since);
|
||||||
|
};
|
||||||
|
const form = h("form", { class: "explore-filters", onsubmit: (e) => { e.preventDefault(); apply(); }, oninput: () => { form.dataset.dirty = "1"; } },
|
||||||
|
h("label", null, "Client ", client), h("label", null, "Name ", name), h("label", null, "View ", view),
|
||||||
|
h("label", null, "Type ", type), h("label", null, "Result ", rcode), h("label", null, "Blocked ", blocked),
|
||||||
|
h("button", { type: "submit", class: "primary small" }, "Show"),
|
||||||
|
sugg("ex-clients", ex && ex.by.client), sugg("ex-names", ex && ex.by.name), sugg("ex-views", views.map((v) => ({ key: v }))),
|
||||||
|
sugg("ex-types", (ex && ex.by.type) || ["A", "AAAA", "PTR", "MX", "TXT", "HTTPS", "SRV"].map((k) => ({ key: k }))),
|
||||||
|
sugg("ex-rcodes", ["NOERROR", "NXDOMAIN", "SERVFAIL", "REFUSED"].map((k) => ({ key: k }))),
|
||||||
|
sugg("ex-blocked", [{ key: "yes" }, { key: "no" }, ...((ex && ex.by.blocked) || [])]));
|
||||||
|
return h("div", { class: "panel explore-filter-panel" }, form,
|
||||||
|
h("div", { class: "muted small" }, "Several values: comma separated, any of them. !value leaves one out (!10.0.0.250, !*.apple.com). *.example.com is it and the names below it."));
|
||||||
|
}
|
||||||
|
|
||||||
|
// head is the title and a chip per filter value, each with a ✕.
|
||||||
|
function head(f, go, since, ex) {
|
||||||
|
const chips = [];
|
||||||
|
for (const k of kinds) for (const v of f[k] || []) {
|
||||||
|
const val = plain(v);
|
||||||
|
chips.push(h("span", { class: "chip filter-chip" + (isNot(v) ? " not" : "") },
|
||||||
|
h("span", { class: "muted" }, (isNot(v) ? "not " : "") + filterWords[k] + " "),
|
||||||
|
h("strong", { class: "mono" }, k === "blocked" ? blockedLabel(val) : val),
|
||||||
|
k === "client" && !isNot(v) && ex && ex.client_name && includes(f, "client").length === 1 ? h("span", { class: "muted" }, " " + ex.client_name) : null,
|
||||||
|
" ", h("a", { href: go(dropFrom(f, k, v)), title: "Take it out", class: "chip-x", "aria-label": "Remove " + filterWords[k] + " " + val }, "✕")));
|
||||||
|
}
|
||||||
|
const extra = [];
|
||||||
|
const names = includes(f, "name");
|
||||||
|
if (names.length === 1) {
|
||||||
|
const n = names[0], bare = n.replace(/^\*\./, "");
|
||||||
|
if (!n.startsWith("*.")) extra.push(h("a", { href: go(addTo(dropFrom(f, "name", n), "name", "*." + n)) }, "this name and the names below it"));
|
||||||
|
if (includes(f, "blocked").length || (ex && ex.blocked)) extra.push(h("a", { href: "#/blocking?check=" + encodeURIComponent(bare) }, "why is it blocked?"));
|
||||||
|
extra.push(h("a", { href: "#/search?q=" + encodeURIComponent(bare) }, "find it in your zones"));
|
||||||
|
}
|
||||||
|
return h("div", { class: "page-head" },
|
||||||
|
h("div", { class: "grow" },
|
||||||
|
backLink("Statistics", "#/stats"),
|
||||||
|
h("h1", null, title(f, ex)),
|
||||||
|
h("div", { class: "muted" }, "Every answer in the query log that matches. Click a value to add it, \"hide\" to leave it out; ✕ takes it out of the filter."),
|
||||||
|
chips.length ? h("div", { class: "chips filter-chips" }, chips,
|
||||||
|
h("a", { href: exploreHref({}, since), class: "small" }, "Clear all")) : null,
|
||||||
|
extra.length ? h("div", { class: "small" }, extra.map((a, i) => [i ? " · " : "", a])) : null));
|
||||||
|
}
|
||||||
|
|
||||||
|
function title(f, ex) {
|
||||||
|
if (!anyFilter(f)) return "Explore the answers";
|
||||||
|
const inc = (k) => includes(f, k);
|
||||||
|
const or = (vs, fmt = (x) => x) => vs.map(fmt).join(" or ");
|
||||||
|
const clients = inc("client");
|
||||||
|
const who = clients.length === 1 && ex && ex.client_name ? ex.client_name : or(clients);
|
||||||
|
const onlyClient = clients.length && kinds.every((k) => k === "client" || !(f[k] || []).length);
|
||||||
|
if (onlyClient && clients.length === 1) return "Client " + who;
|
||||||
|
const what = [or(inc("rcode")), or(inc("type")),
|
||||||
|
inc("blocked").length ? "blocked" + (inc("blocked")[0] !== "yes" ? " by " + or(inc("blocked"), blockedLabel) : "") : "",
|
||||||
|
or(inc("name"), (n) => (n.startsWith("*.") ? n.slice(2) + " and below" : n))].filter(Boolean).join(" ");
|
||||||
|
const parts = [what || "Answers"];
|
||||||
|
if (clients.length) parts.push("for " + who);
|
||||||
|
if (inc("view").length) parts.push("in view " + or(inc("view")));
|
||||||
|
return parts.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// recentPanel lists the latest answers, every cell a link.
|
||||||
|
function recentPanel(ex, add) {
|
||||||
|
if (!ex.recent.length) return null;
|
||||||
|
const a = (text, more, cls) => { const [k, v] = Object.entries(more)[0]; return h("a", { href: add(k, v), class: cls || "" }, text); };
|
||||||
|
return h("div", { class: "panel" }, h("h2", null, "Latest answers"),
|
||||||
|
h("div", { class: "table-wrap" }, h("table", { class: "recent-queries" },
|
||||||
|
h("thead", null, h("tr", null, ["Time", "Client", "View", "Name", "Type", "Result"].map((t) => h("th", null, t)))),
|
||||||
|
h("tbody", null, ex.recent.map((r) => h("tr", null,
|
||||||
|
h("td", { class: "small muted nowrap" }, fmtTime(new Date(r.ts * 1000).toISOString())),
|
||||||
|
h("td", { class: "nowrap" }, a(r.client_name || r.client, { client: r.client }, "mono"), r.client_name ? h("span", { class: "muted small" }, " " + r.client) : null),
|
||||||
|
h("td", null, a(r.view === "_default" ? "–" : r.view, { view: r.view })),
|
||||||
|
h("td", { class: "oneline-cell" }, a(r.name, { name: r.name }, "mono")),
|
||||||
|
h("td", null, a(r.type, { type: r.type })),
|
||||||
|
h("td", null, r.blocked ? a(badge("blocked: " + blockedLabel(r.blocked), "warn"), { blocked: r.blocked }) : a(r.rcode, { rcode: r.rcode }, r.rcode === "NOERROR" ? "" : "warn-text"))))))),
|
||||||
|
ex.recent.length >= 100 ? h("div", { class: "muted small" }, "The latest 100: narrow the filter to see others.") : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tile(label, value, sub) {
|
||||||
|
return h("div", { class: "card" }, h("div", { class: "label" }, label), h("div", { class: "value" }, value), h("div", { class: "sub" }, sub));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pct(r) { return (r * 100).toFixed(r < 0.1 ? 1 : 0) + "%"; }
|
||||||
|
|
||||||
|
function stepWord(s) {
|
||||||
|
if (s % 86400 === 0) return s === 86400 ? "day" : s / 86400 + " days";
|
||||||
|
if (s % 3600 === 0) return s === 3600 ? "hour" : s / 3600 + " hours";
|
||||||
|
return s === 60 ? "minute" : s / 60 + " minutes";
|
||||||
|
}
|
||||||
|
|
||||||
|
function retentionSecs(r) {
|
||||||
|
const m = /^(\d+)([dhm])$/.exec(r || "");
|
||||||
|
if (!m) return 7 * 86400;
|
||||||
|
return Number(m[1]) * { d: 86400, h: 3600, m: 60 }[m[2]];
|
||||||
|
}
|
||||||
@@ -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 wrap" }, g.text)),
|
||||||
|
h("td", { class: "small" }, g.why),
|
||||||
|
h("td", { class: "small mono" }, g.setting))))))));
|
||||||
}
|
}
|
||||||
|
|
||||||
function settingsForm(el, s) {
|
function settingsForm(el, s) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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 { 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";
|
||||||
|
import { exploreHref } from "./explore.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"]];
|
||||||
const rangeSecs = { "1h": 3600, "6h": 6 * 3600, "24h": 86400, "7d": 7 * 86400, "30d": 30 * 86400, "90d": 90 * 86400 };
|
const rangeSecs = { "1h": 3600, "6h": 6 * 3600, "24h": 86400, "7d": 7 * 86400, "30d": 30 * 86400, "90d": 90 * 86400 };
|
||||||
@@ -21,7 +22,7 @@ let generation = 0;
|
|||||||
// The views shown are remembered per browser: the ones switched off, so a
|
// The views shown are remembered per browser: the ones switched off, so a
|
||||||
// new view shows up switched on.
|
// new view shows up switched on.
|
||||||
const offKey = "jarl.stats.views.off";
|
const offKey = "jarl.stats.views.off";
|
||||||
function viewsOff() {
|
export function viewsOff() {
|
||||||
try { return new Set(JSON.parse(localStorage.getItem(offKey) || "[]")); } catch { return new Set(); }
|
try { return new Set(JSON.parse(localStorage.getItem(offKey) || "[]")); } catch { return new Set(); }
|
||||||
}
|
}
|
||||||
function saveViewsOff(set) {
|
function saveViewsOff(set) {
|
||||||
@@ -42,7 +43,9 @@ export async function render(el, params, query) {
|
|||||||
h("div", { class: "muted" }, status.enabled
|
h("div", { class: "muted" }, status.enabled
|
||||||
? "From named's counters" + (status.query_log ? " and response log" : "") + " · data since " + (status.first_data ? fmtTime(status.first_data) : "–") +
|
? "From named's counters" + (status.query_log ? " and response log" : "") + " · data since " + (status.first_data ? fmtTime(status.first_data) : "–") +
|
||||||
" · kept: minutes " + status.retention.minute + ", hours " + status.retention.hour + ", days " + status.retention.day
|
" · kept: minutes " + status.retention.minute + ", hours " + status.retention.hour + ", days " + status.retention.day
|
||||||
: "Query statistics are off")));
|
: "Query statistics are off")),
|
||||||
|
status.enabled && status.retention && status.retention.queries
|
||||||
|
? h("div", { class: "actions" }, h("a", { class: "button", href: "#/stats/explore", title: "Every answer of the last " + status.retention.queries + ", by client, name, view, type and result" }, "Explore the answers")) : null);
|
||||||
if (!status.enabled) {
|
if (!status.enabled) {
|
||||||
mount(el, head, disabledPanel());
|
mount(el, head, disabledPanel());
|
||||||
return;
|
return;
|
||||||
@@ -114,17 +117,20 @@ export async function render(el, params, query) {
|
|||||||
parts.push(chartCard("Busiest times", "average queries per hour by weekday and hour of day (local time), last " + ranges.find((r) => r[0] === heatSince)[1],
|
parts.push(chartCard("Busiest times", "average queries per hour by weekday and hour of day (local time), last " + ranges.find((r) => r[0] === heatSince)[1],
|
||||||
heatmap(heat, "queries"), heatmapTable(heat, "queries"), { wide: true }));
|
heatmap(heat, "queries"), heatmapTable(heat, "queries"), { wide: true }));
|
||||||
|
|
||||||
// Who asks for what.
|
// Who asks for what. With the query log, every value leads to the
|
||||||
|
// explore page: a client to what it asked, a name to who asked.
|
||||||
|
const ql = !!(status.retention || {}).queries;
|
||||||
|
const ex = (filters) => (ql ? (k) => exploreHref(filters(k), exploreSince(since)) : null);
|
||||||
if (status.query_log) {
|
if (status.query_log) {
|
||||||
const t = data.tops;
|
const t = data.tops;
|
||||||
parts.push(h("div", { class: "chart-grid" },
|
parts.push(h("div", { class: "chart-grid" },
|
||||||
listCard("Most queried names", "", barList(t.name, { href: (k) => "#/search?q=" + encodeURIComponent(k) })),
|
listCard("Most queried names", ql ? "click one to see who asks for it" : "", barList(t.name, { href: ex((k) => ({ name: k })) || ((k) => "#/search?q=" + encodeURIComponent(k)) })),
|
||||||
listCard("Busiest clients", "", barList(t.client)),
|
listCard("Busiest clients", ql ? "click one to see what it asks" : "", barList(t.client, { href: ex((k) => ({ client: k })) })),
|
||||||
listCard("Names that don't exist", "NXDOMAIN answers: typos, removed hosts, misconfigured clients, malware", barList(t.nxdomain, { empty: "None " + rangeLabel + "." })),
|
listCard("Names that don't exist", "NXDOMAIN answers: typos, removed hosts, misconfigured clients, malware", barList(t.nxdomain, { empty: "None " + rangeLabel + ".", href: ex((k) => ({ name: k, rcode: "NXDOMAIN" })) })),
|
||||||
listCard("Refused clients", "clients asking for something they may not have (often internet scanners)", barList(t.refused, { empty: "None " + rangeLabel + "." })),
|
listCard("Refused clients", "clients asking for something they may not have (often internet scanners)", barList(t.refused, { empty: "None " + rangeLabel + ".", href: ex((k) => ({ client: k, rcode: "REFUSED" })) })),
|
||||||
t.servfail.length ? listCard("Names that failed to resolve", "SERVFAIL answers", barList(t.servfail)) : null,
|
t.servfail.length ? listCard("Names that failed to resolve", "SERVFAIL answers", barList(t.servfail, { href: ex((k) => ({ name: k, rcode: "SERVFAIL" })) })) : null,
|
||||||
t.blocked_client.length ? listCard("Clients with the most blocked queries", "often a smart TV, phone or app phoning home", barList(t.blocked_client)) : null,
|
t.blocked_client.length ? listCard("Clients with the most blocked queries", "often a smart TV, phone or app phoning home", barList(t.blocked_client, { href: ex((k) => ({ client: k, blocked: "yes" })) })) : null,
|
||||||
t.blocked.length ? listCard("Most blocked names", "answered \"doesn't exist\" by blocking; click a name to see why, or to allow it", barList(t.blocked, { href: (k) => "#/blocking?check=" + encodeURIComponent(k) })) : null));
|
t.blocked.length ? listCard("Most blocked names", ql ? "click a name to see who asks for it, and why it is blocked" : "answered \"doesn't exist\" by blocking; click a name to see why, or to allow it", barList(t.blocked, { href: ex((k) => ({ name: k, blocked: "yes" })) || ((k) => "#/blocking?check=" + encodeURIComponent(k)) })) : null));
|
||||||
} else {
|
} else {
|
||||||
parts.push(h("div", { class: "msg info" }, "Top names and clients need the response log: set stats.query_log: true in /etc/jarl/jarl.yaml and restart jarl."));
|
parts.push(h("div", { class: "msg info" }, "Top names and clients need the response log: set stats.query_log: true in /etc/jarl/jarl.yaml and restart jarl."));
|
||||||
}
|
}
|
||||||
@@ -133,15 +139,15 @@ export async function render(el, params, query) {
|
|||||||
const b = data.breakdowns;
|
const b = data.breakdowns;
|
||||||
const rtt = rttOrder.map((k) => b.rtt.find((e) => e.key === k)).filter(Boolean);
|
const rtt = rttOrder.map((k) => b.rtt.find((e) => e.key === k)).filter(Boolean);
|
||||||
parts.push(h("div", { class: "chart-grid" },
|
parts.push(h("div", { class: "chart-grid" },
|
||||||
listCard("Record types", "", barList(b.qtype, { total: s.queries })),
|
listCard("Record types", "", barList(b.qtype, { total: s.queries, href: ex((k) => ({ type: k })) })),
|
||||||
listCard("Result codes", "", barList(b.rcode, { total: s.queries })),
|
listCard("Result codes", ql ? "click one to see who gets it, for what" : "", barList(b.rcode, { total: s.queries, href: ex((k) => ({ rcode: k })) })),
|
||||||
b.view.length > 1 || (b.view.length === 1 && b.view[0].key !== "_default") ? listCard("Views", "", barList(b.view, { label: viewLabel })) : null,
|
b.view.length > 1 || (b.view.length === 1 && b.view[0].key !== "_default") ? listCard("Views", "", barList(b.view, { label: viewLabel, href: ex((k) => ({ view: k })) })) : null,
|
||||||
b.zone.length ? listCard("Zones", "queries per local zone; other = names in no local zone (recursion)", barList(b.zone, { label: (k) => (k === "-" ? "other" : k), href: (k) => (k === "-" ? null : "#/search?q=" + encodeURIComponent(k)) })) : null,
|
b.zone.length ? listCard("Zones", "queries per local zone; other = names in no local zone (recursion)", barList(b.zone, { label: (k) => (k === "-" ? "other" : k), href: (k) => (k === "-" ? null : "#/search?q=" + encodeURIComponent(k)) })) : null,
|
||||||
b.answer.length ? listCard("Answers", "authoritative (your zones) or recursive (from the cache or the internet)", barList(b.answer)) : null,
|
b.answer.length ? listCard("Answers", "authoritative (your zones) or recursive (from the cache or the internet)", barList(b.answer)) : null,
|
||||||
rtt.length ? listCard("Upstream response time", "how long other servers took to answer named's recursive queries", barList(rtt)) : null,
|
rtt.length ? listCard("Upstream response time", "how long other servers took to answer named's recursive queries", barList(rtt)) : null,
|
||||||
b.blocklist.length ? listCard("Blocked by", "which of your rules and blocklists blocked queries", barList(b.blocklist, { label: (k) => (k === "rules" ? "your rules" : k), href: () => "#/blocking" })) : null));
|
b.blocklist.length ? listCard("Blocked by", "which of your rules and blocklists blocked queries", barList(b.blocklist, { label: (k) => (k === "rules" ? "your rules" : k), href: ex((k) => ({ blocked: k })) || (() => "#/blocking") })) : null));
|
||||||
|
|
||||||
parts.push(...serverSection(data.server, complete(data.gauges), rangeLabel));
|
parts.push(...serverSection(data.server, complete(data.gauges), rangeLabel, status));
|
||||||
el.classList.remove("refreshing");
|
el.classList.remove("refreshing");
|
||||||
mount(el, parts);
|
mount(el, parts);
|
||||||
scheduleRefresh(el, params, query, gen);
|
scheduleRefresh(el, params, query, gen);
|
||||||
@@ -177,7 +183,9 @@ async function loadAll(since, heatSince, queryLog, vf, filtered) {
|
|||||||
// keeping the current render (dimmed) until the new one is ready.
|
// keeping the current render (dimmed) until the new one is ready.
|
||||||
function scheduleRefresh(el, params, query, gen) {
|
function scheduleRefresh(el, params, query, gen) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (gen !== generation || !location.hash.startsWith("#/stats") || !el.isConnected) return;
|
// Only on the Statistics page itself: not #/stats/explore.
|
||||||
|
const h = location.hash;
|
||||||
|
if (gen !== generation || !(h === "#/stats" || h.startsWith("#/stats?")) || !el.isConnected) return;
|
||||||
el.classList.add("refreshing");
|
el.classList.add("refreshing");
|
||||||
render(el, params, query).catch(() => el.classList.remove("refreshing"));
|
render(el, params, query).catch(() => el.classList.remove("refreshing"));
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
@@ -219,7 +227,7 @@ const gaugeKeys = ["g:named:rss", "g:named:mem", "g:cache:mem", "g:named:cpu"];
|
|||||||
|
|
||||||
// serverSection shows named's process, cache, clients, the host and the
|
// serverSection shows named's process, cache, clients, the host and the
|
||||||
// disks now, and named's memory and CPU over time.
|
// disks now, and named's memory and CPU over time.
|
||||||
function serverSection(sv, gauges, rangeLabel) {
|
function serverSection(sv, gauges, rangeLabel, status) {
|
||||||
const srv = (sv && sv.server) || {};
|
const srv = (sv && sv.server) || {};
|
||||||
const named = (sv && sv.named) || {};
|
const named = (sv && sv.named) || {};
|
||||||
const host = srv.host || {};
|
const host = srv.host || {};
|
||||||
@@ -239,7 +247,8 @@ function serverSection(sv, gauges, rangeLabel) {
|
|||||||
tc ? tile("TCP clients", tc[0], tc[1]) : null,
|
tc ? tile("TCP clients", tc[0], tc[1]) : null,
|
||||||
tile("Up since", srv.boot_time ? relTime(srv.boot_time).replace(" ago", "") : "–", srv.config_time ? "named started " + fmtTime(srv.boot_time) + " · reconfigured " + relTime(srv.config_time) : ""),
|
tile("Up since", srv.boot_time ? relTime(srv.boot_time).replace(" ago", "") : "–", srv.config_time ? "named started " + fmtTime(srv.boot_time) + " · reconfigured " + relTime(srv.config_time) : ""),
|
||||||
host.mem_total ? tile("Host memory", pct((host.mem_total - host.mem_available) / host.mem_total), "of " + bytes(host.mem_total) + " in use · load " + (host.load || []).map((x) => x.toFixed(2)).join(" ")) : null,
|
host.mem_total ? tile("Host memory", pct((host.mem_total - host.mem_available) / host.mem_total), "of " + bytes(host.mem_total) + " in use · load " + (host.load || []).map((x) => x.toFixed(2)).join(" ")) : null,
|
||||||
...((sv && sv.disks) || []).map((d) => tile("Disk space", bytes(d.free) + " free", h("span", { title: d.path }, "of " + bytes(d.total) + ", for " + d.holds.join(", "))))));
|
sv && sv.database ? dbTile(sv.database, status) : null,
|
||||||
|
...((sv && sv.disks) || []).map(diskTile)));
|
||||||
if (sv && sv.named_error) out.push(h("div", { class: "msg info" }, "Client counts need rndc: " + sv.named_error));
|
if (sv && sv.named_error) out.push(h("div", { class: "msg info" }, "Client counts need rndc: " + sv.named_error));
|
||||||
// Gauges over time: averages, -1 where there was no sample.
|
// Gauges over time: averages, -1 where there was no sample.
|
||||||
const series = (key) => ((gauges.series || []).find((x) => x.key === key) || { values: gauges.times.map(() => -1) }).values.map((v) => (v < 0 ? null : v));
|
const series = (key) => ((gauges.series || []).find((x) => x.key === key) || { values: gauges.times.map(() => -1) }).values.map((v) => (v < 0 ? null : v));
|
||||||
@@ -302,3 +311,39 @@ function disabledPanel() {
|
|||||||
h("p", { class: "muted" }, "Jarl then makes named publish its counters on a local statistics channel and log responses to /var/log/named, and keeps aggregates " +
|
h("p", { class: "muted" }, "Jarl then makes named publish its counters on a local statistics channel and log responses to /var/log/named, and keeps aggregates " +
|
||||||
"(never individual queries) in a separate database, by default for 7 days per minute, 90 days per hour and 2 years per day. See docs/stats.md."));
|
"(never individual queries) in a separate database, by default for 7 days per minute, 90 days per hour and 2 years per day. See docs/stats.md."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// exploreSince is the Statistics range for the explore page: the query log
|
||||||
|
// keeps days, not months.
|
||||||
|
function exploreSince(since) {
|
||||||
|
return rangeSecs[since] > 7 * 86400 ? "7d" : since;
|
||||||
|
}
|
||||||
|
|
||||||
|
// dbTile is the statistics database's size: what the statistics cost on
|
||||||
|
// disk (the dashboard's Storage panel has all of Jarl's databases).
|
||||||
|
function dbTile(db, status) {
|
||||||
|
const keep = ((status || {}).retention || {}).queries;
|
||||||
|
const sub = db.query_log_rows != null
|
||||||
|
? "query log: " + compact(db.query_log_rows) + (db.query_log_rows === 1 ? " answer" : " answers") + " so far, each kept for " + retentionWords(keep)
|
||||||
|
: "counters and top lists; no query log";
|
||||||
|
return h("a", { class: "card link", href: "#/", title: db.path + " · all of Jarl's databases: Dashboard → Storage" },
|
||||||
|
h("div", { class: "label" }, "Statistics database"), h("div", { class: "value" }, bytes(db.bytes)), h("div", { class: "sub" }, sub));
|
||||||
|
}
|
||||||
|
|
||||||
|
// diskTile is a disk's free space, with a bar of how full it is.
|
||||||
|
function diskTile(d) {
|
||||||
|
const u = d.total ? (d.total - d.free) / d.total : 0;
|
||||||
|
return h("div", { class: "card", title: d.path },
|
||||||
|
h("div", { class: "label" }, "Disk space"),
|
||||||
|
h("div", { class: "value" }, bytes(d.free) + " free"),
|
||||||
|
h("div", { class: "disk-bar", role: "img", "aria-label": Math.round(u * 100) + "% used" },
|
||||||
|
h("div", { class: "seg other" + (u > 0.9 ? " full" : ""), style: { width: u * 100 + "%" } })),
|
||||||
|
h("div", { class: "sub" }, h("span", { class: u > 0.9 ? "warn-text" : "" }, Math.round(u * 100) + "% of " + bytes(d.total) + " used"), ", for " + d.holds.join(", ")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// retentionWords: "7d" -> "7 days".
|
||||||
|
function retentionWords(r) {
|
||||||
|
const m = /^(\d+)([dhm])$/.exec(r || "");
|
||||||
|
if (!m) return r || "a while";
|
||||||
|
const word = { d: "day", h: "hour", m: "minute" }[m[2]];
|
||||||
|
return m[1] + " " + word + (m[1] === "1" ? "" : "s");
|
||||||
|
}
|
||||||
@@ -125,6 +125,7 @@ async function viewForm(v, viewless) {
|
|||||||
const mro = select([["", "(default: no)"], ["true", "yes"], ["false", "no"]], v && v.match_recursive_only !== undefined && v.match_recursive_only !== null ? String(v.match_recursive_only) : "");
|
const mro = select([["", "(default: no)"], ["true", "yes"], ["false", "no"]], v && v.match_recursive_only !== undefined && v.match_recursive_only !== null ? String(v.match_recursive_only) : "");
|
||||||
const comment = input({ value: v ? v.comment : "" });
|
const comment = input({ value: v ? v.comment : "" });
|
||||||
const color = colorPicker(v ? v.color : "");
|
const color = colorPicker(v ? v.color : "");
|
||||||
|
const revSuggest = checkbox("Suggest reverse zones for the addresses this view's records use", !(v && v.reverse_suggestions_off));
|
||||||
const adopt = checkbox("Move the " + viewless.length + " zone(s) that are not in a view into this view", true);
|
const adopt = checkbox("Move the " + viewless.length + " zone(s) that are not in a view into this view", true);
|
||||||
const body = h("div", null, h("div", { class: "form-grid" },
|
const body = h("div", null, h("div", { class: "form-grid" },
|
||||||
field("Name", name),
|
field("Name", name),
|
||||||
@@ -132,6 +133,7 @@ async function viewForm(v, viewless) {
|
|||||||
field("Match destinations", h("div", null, md, suggestions(md, ctx)), "Server addresses this view answers on (optional)."),
|
field("Match destinations", h("div", null, md, suggestions(md, ctx)), "Server addresses this view answers on (optional)."),
|
||||||
field("Recursive only", mro, "If yes, only recursive queries match this view."),
|
field("Recursive only", mro, "If yes, only recursive queries match this view."),
|
||||||
field("Comment", comment),
|
field("Comment", comment),
|
||||||
|
field("Reverse zones", revSuggest, "Turn it off for a view only some servers use (a VPS's view, say): its addresses then get no suggestions under Zones, and it doesn't count when Jarl picks the view of a new reverse zone. Public views never get suggestions."),
|
||||||
field("Colour", color, "How the web UI shows this view, so zones of the same view are easy to spot. Only changes the web UI: it applies at once, with no deploy.")),
|
field("Colour", color, "How the web UI shows this view, so zones of the same view are easy to spot. Only changes the web UI: it applies at once, with no deploy.")),
|
||||||
!v && viewless.length ? h("div", { class: "msg warn" }, "Once views exist, named requires every zone to be inside a view. ", adopt) : null,
|
!v && viewless.length ? h("div", { class: "msg warn" }, "Once views exist, named requires every zone to be inside a view. ", adopt) : null,
|
||||||
err);
|
err);
|
||||||
@@ -139,6 +141,7 @@ async function viewForm(v, viewless) {
|
|||||||
mount(err);
|
mount(err);
|
||||||
const data = { name: name.value.trim(), match_clients: lines(mc.value), match_destinations: lines(md.value), comment: comment.value };
|
const data = { name: name.value.trim(), match_clients: lines(mc.value), match_destinations: lines(md.value), comment: comment.value };
|
||||||
data.match_recursive_only = mro.value === "" ? null : mro.value === "true";
|
data.match_recursive_only = mro.value === "" ? null : mro.value === "true";
|
||||||
|
data.reverse_suggestions = revSuggest.input.checked;
|
||||||
if (!v && viewless.length && adopt.input.checked) data.adopt_zones = true;
|
if (!v && viewless.length && adopt.input.checked) data.adopt_zones = true;
|
||||||
const colorChanged = color.value !== ((v && v.color) || "");
|
const colorChanged = color.value !== ((v && v.color) || "");
|
||||||
try {
|
try {
|
||||||
@@ -170,7 +173,7 @@ function configChanged(v, data) {
|
|||||||
const same = (a, b) => JSON.stringify(a || []) === JSON.stringify(b || []);
|
const same = (a, b) => JSON.stringify(a || []) === JSON.stringify(b || []);
|
||||||
const mro = v.match_recursive_only === undefined ? null : v.match_recursive_only;
|
const mro = v.match_recursive_only === undefined ? null : v.match_recursive_only;
|
||||||
return data.name !== v.name || !same(data.match_clients, v.match_clients) || !same(data.match_destinations, v.match_destinations) ||
|
return data.name !== v.name || !same(data.match_clients, v.match_clients) || !same(data.match_destinations, v.match_destinations) ||
|
||||||
data.match_recursive_only !== mro || data.comment !== (v.comment || "");
|
data.match_recursive_only !== mro || data.comment !== (v.comment || "") || data.reverse_suggestions === !!v.reverse_suggestions_off;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteView(v) {
|
async function deleteView(v) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { h, mount, table, field, input, select, textarea, toast, errorBox, display, badge, confirmDialog, modal, checkbox, time, diffView, backLink, viewLink, lastList } from "../ui.js";
|
import { h, mount, table, field, input, select, textarea, toast, errorBox, display, badge, confirmDialog, modal, checkbox, time, diffView, backLink, viewLink, lastList, plural } from "../ui.js";
|
||||||
import { api, get, stream, change, describeResult, rrtypes, state, draftQuery } from "../api.js";
|
import { api, get, stream, change, changeMany, describeResult, rrtypes, state, draftQuery, can } from "../api.js";
|
||||||
import { rerender } from "../app.js";
|
import { rerender } from "../app.js";
|
||||||
import { optionsEditor } from "./options.js";
|
import { optionsEditor } from "./options.js";
|
||||||
import { emailOf, pendingBadge } from "./zones.js";
|
import { emailOf, pendingBadge } from "./zones.js";
|
||||||
@@ -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");
|
||||||
@@ -33,6 +34,8 @@ export async function render(el, params, query) {
|
|||||||
zone.pending ? [pendingBadge(zone.pending), " "] : null,
|
zone.pending ? [pendingBadge(zone.pending), " "] : null,
|
||||||
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.prefix ? h("span", { class: "mono" }, zone.prefix + " · ") : null,
|
||||||
|
zone.auto_ptr ? [h("a", { href: "#/zones/" + id + "/settings", title: "Adding, changing, disabling or deleting an A or AAAA record does the same to its PTR record" }, badge("PTRs kept in step", "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.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 ? [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.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,
|
||||||
@@ -45,7 +48,10 @@ export async function render(el, params, query) {
|
|||||||
zone.type === "primary" && zone.view ? [linkButton(zone), " "] : null,
|
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.")
|
||||||
|
: zone.ptr_missing && can("zones", true) ? h("div", { class: "msg info" },
|
||||||
|
plural(zone.ptr_missing, "address", "addresses") + " in " + zone.prefix + " that " + (zone.view ? "view " + zone.view + "'s" : "your") + " A and AAAA records use " + (zone.ptr_missing === 1 ? "has" : "have") + " no PTR record here. ",
|
||||||
|
h("button", { class: "small", onclick: () => fillPTR(zone) }, "Add " + (zone.ptr_missing === 1 ? "it" : "them"))) : null;
|
||||||
const tabBar = h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/zones/" + id + "/" + k, class: k === tab ? "active" : "" }, label)));
|
const tabBar = h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/zones/" + id + "/" + k, class: k === tab ? "active" : "" }, label)));
|
||||||
// The header and tabs show at once. Below them, switching tabs keeps the
|
// The header and tabs show at once. Below them, switching tabs keeps the
|
||||||
// previous tab's content (faded) until the new one is loaded, rather
|
// previous tab's content (faded) until the new one is loaded, rather
|
||||||
@@ -64,6 +70,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, query);
|
||||||
// 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);
|
||||||
@@ -71,6 +78,116 @@ 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).
|
||||||
|
// compareTab compares the zone with the same zone in other views, from
|
||||||
|
// this one (docs/views.md#comparing-views): a summary, what the views
|
||||||
|
// answer differently, the names only some views have, and how much is
|
||||||
|
// the same. ?name= (and &type=) narrows it to one name.
|
||||||
|
async function compareTab(el, zone, query) {
|
||||||
|
const others = zone.other_views || [];
|
||||||
|
const withSel = select([...(others.length > 1 ? [["", "all other views"]] : []), ...others.map((v) => [v, v])], query.get("with") || others[0] || "");
|
||||||
|
const nameIn = input({ value: query.get("name") || "", placeholder: "all names, or one: www, @", class: "mono", style: { width: "200px" } });
|
||||||
|
const typeIn = input({ value: query.get("type") || "", placeholder: "any type", class: "mono", style: { width: "90px" } });
|
||||||
|
let showSame = false;
|
||||||
|
const out = h("div");
|
||||||
|
const one = (d) => h("div", { class: "oneline mono small", title: d }, d);
|
||||||
|
const draw = async () => {
|
||||||
|
const q = { with: withSel.value || undefined, name: nameIn.value.trim() || undefined, type: nameIn.value.trim() ? typeIn.value.trim() || undefined : undefined, all: showSame ? 1 : undefined };
|
||||||
|
let r;
|
||||||
|
try { r = await get("/zones/" + zone.id + "/compare", draftQuery(q)); } catch (e) { mount(out, errorBox(e)); return; }
|
||||||
|
const oneName = !!q.name;
|
||||||
|
const parts = [];
|
||||||
|
parts.push(h("p", { class: "muted" }, r.linked
|
||||||
|
? "The zones are linked: shared records are kept in step, so a difference is a view's own records, there on purpose."
|
||||||
|
: "The zones aren't linked: nothing keeps them in step, so a difference may be one that crept in. Link them (Link… above) to keep what is the same in step."));
|
||||||
|
if (!oneName) parts.push(h("ul", { class: "compare-summary" }, r.summary.map((s) => h("li", null,
|
||||||
|
h("strong", null, s.view), ": ",
|
||||||
|
[[s.different, "answered differently"], [s.only_base, "only in " + r.base], [s.only_other, "only in " + s.view], [s.same, "the same"]]
|
||||||
|
.map(([n, what], i) => [i ? " · " : "", h("span", { class: n && i < 3 ? "" : "muted" }, n + " " + what)])))));
|
||||||
|
|
||||||
|
if (r.different.length) {
|
||||||
|
parts.push(h("h3", null, "Answered differently (" + r.different.length + ")"),
|
||||||
|
h("div", { class: "table-wrap" }, h("table", { class: "compare" },
|
||||||
|
h("thead", null, h("tr", null, h("th", null, "Name"), h("th", null, "Type"), r.views.map((v) => h("th", null, v)))),
|
||||||
|
h("tbody", null, r.different.map((row) => h("tr", null,
|
||||||
|
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(one) : h("span", { class: "muted" }, "–"),
|
||||||
|
vs.records.length ? h("div", { class: "compare-actions" },
|
||||||
|
vs.linked === "local" ? [badge("own records", "accent"), " "] : null,
|
||||||
|
h("button", { class: "small", title: "Every view gets " + vs.view + "'s records for " + row.name + " " + row.type, onclick: () => useEverywhere(r, row, vs) }, "Use this everywhere")) : null))))))));
|
||||||
|
}
|
||||||
|
for (const o of r.only) {
|
||||||
|
const missing = r.views.filter((v) => !o.views.includes(v));
|
||||||
|
parts.push(h("details", { class: "compare-only", open: oneName || o.rows.length <= 8 },
|
||||||
|
h("summary", null, h("strong", null, "Only in " + o.views.join(" and ")), " (" + o.rows.length + ")",
|
||||||
|
h("span", { class: "muted small" }, " · " + missing.join(", ") + (missing.length > 1 ? " have" : " has") + " no records of these names and types")),
|
||||||
|
h("div", { class: "table-wrap" }, h("table", { class: "compare" }, h("tbody", null, o.rows.map((row) => {
|
||||||
|
const src = row.views.find((vs) => vs.records.length);
|
||||||
|
return h("tr", null, h("td", { class: "mono" }, row.name), h("td", null, h("strong", null, row.type)),
|
||||||
|
h("td", { class: "compare-cell" }, src.records.map(one)),
|
||||||
|
h("td", { class: "actions-cell" }, missing.map((v) => h("button", { class: "small", title: "Give view " + v + " the same records (its own, local to it)", onclick: () => copyTo(row, src, row.views.find((x) => x.view === v)) }, "Add to " + v))));
|
||||||
|
}))))));
|
||||||
|
}
|
||||||
|
if (r.same_rows && r.same_rows.length) {
|
||||||
|
parts.push(h("h3", null, "The same in " + r.views.join(", ") + " (" + r.same_rows.length + ")"),
|
||||||
|
h("div", { class: "table-wrap" }, h("table", { class: "compare" }, h("tbody", null, r.same_rows.map((row) => h("tr", { class: "muted" },
|
||||||
|
h("td", { class: "mono" }, row.name), h("td", null, row.type), h("td", { class: "compare-cell" }, row.views[0].records.map(one))))))));
|
||||||
|
} else if (!oneName && r.same) {
|
||||||
|
parts.push(h("p", { class: "muted" }, r.same + " names and types are the same. ", h("a", { href: "#", onclick: (e) => { e.preventDefault(); showSame = true; draw(); } }, "List them")));
|
||||||
|
}
|
||||||
|
if (oneName && !r.different.length && !r.only.length && !(r.same_rows || []).length) parts.push(h("div", { class: "empty" }, "No records of that name in these views."));
|
||||||
|
else if (!r.different.length && !r.only.length && !oneName) parts.push(h("div", { class: "empty" }, "Nothing differs."));
|
||||||
|
mount(out, parts);
|
||||||
|
};
|
||||||
|
// useEverywhere gives every view one view's records of a name and type:
|
||||||
|
// in a linked zone by sharing them, otherwise by setting each zone's.
|
||||||
|
const useEverywhere = async (r, row, vs) => {
|
||||||
|
const rest = row.views.filter((x) => x !== vs);
|
||||||
|
const linkedAll = r.linked && zone.linked_with;
|
||||||
|
const msg = linkedAll
|
||||||
|
? "View " + vs.view + "'s " + row.name + " " + row.type + " records become shared: every linked view (" + [zone.view, ...zone.linked_with].join(", ") + ") answers " + vs.data.join(", ") + ", and their own records of that name and type are replaced."
|
||||||
|
: rest.map((x) => x.view).join(" and ") + " get" + (rest.length > 1 ? "" : "s") + " " + vs.data.join(", ") + " for " + row.name + " " + row.type + ", replacing what " + (rest.length > 1 ? "they have" : "it has") + ".";
|
||||||
|
if (!await confirmDialog("Use " + vs.view + "'s " + row.name + " " + row.type, msg, "Use this everywhere")) return;
|
||||||
|
try {
|
||||||
|
const res = linkedAll
|
||||||
|
? await change("PATCH", "/records/" + vs.ids[0], { local: false })
|
||||||
|
: await changeMany(rest.map((x) => ({ op: "rrset.replace", data: rrsetFrom(row, vs, x) })), "use " + vs.view + "'s " + row.name + " " + row.type);
|
||||||
|
toast(describeResult(res));
|
||||||
|
await draw();
|
||||||
|
} catch (e) { toast(e.message, "err", 8000); }
|
||||||
|
};
|
||||||
|
const copyTo = async (row, src, dst) => {
|
||||||
|
try {
|
||||||
|
toast(describeResult(await changeMany([{ op: "rrset.replace", data: rrsetFrom(row, src, dst) }], "add " + row.name + " " + row.type + " to view " + dst.view)));
|
||||||
|
await draw();
|
||||||
|
} catch (e) { toast(e.message, "err", 8000); }
|
||||||
|
};
|
||||||
|
const go = () => draw();
|
||||||
|
withSel.addEventListener("change", go);
|
||||||
|
nameIn.addEventListener("keydown", (e) => { if (e.key === "Enter") go(); });
|
||||||
|
typeIn.addEventListener("keydown", (e) => { if (e.key === "Enter") go(); });
|
||||||
|
mount(el, h("div", { class: "panel" },
|
||||||
|
h("div", { class: "compare-controls" },
|
||||||
|
h("label", null, "Compare " + zone.view + " with "), withSel, " ",
|
||||||
|
h("label", null, " Name "), nameIn, typeIn, " ",
|
||||||
|
h("button", { class: "small", onclick: go }, "Compare"),
|
||||||
|
nameIn.value ? [" ", h("button", { class: "small", onclick: () => { nameIn.value = ""; typeIn.value = ""; go(); } }, "All names")] : null),
|
||||||
|
out));
|
||||||
|
await draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
// rrsetFrom is the request giving zone dst a name and type's records from
|
||||||
|
// src: its own (local) records, in a linked zone.
|
||||||
|
function rrsetFrom(row, src, dst) {
|
||||||
|
const d = { zone_id: dst.zone_id, name: row.fqdn, type: row.type, data: src.data, local: true };
|
||||||
|
if (src.ttl !== undefined && src.ttl !== null) d.ttl = src.ttl;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- linked zones (docs/views.md#linked-zones)
|
// ---- linked zones (docs/views.md#linked-zones)
|
||||||
|
|
||||||
// linkButton links the zone with the same zone in other views, or takes
|
// linkButton links the zone with the same zone in other views, or takes
|
||||||
@@ -406,7 +523,7 @@ async function recordsTab(el, zone, query) {
|
|||||||
const t = typeInfo[fType.value] || {};
|
const t = typeInfo[fType.value] || {};
|
||||||
fData.placeholder = reverse && fType.value === "PTR" ? "nas.adbraun.lol" : t.example || t.format || "";
|
fData.placeholder = reverse && fType.value === "PTR" ? "nas.adbraun.lol" : t.example || t.format || "";
|
||||||
mount(hint, h("strong", null, fType.value + ": "), t.description || "", t.format ? [" · format: ", h("code", null, t.format)] : null);
|
mount(hint, h("strong", null, fType.value + ": "), t.description || "", t.format ? [" · format: ", h("code", null, t.format)] : null);
|
||||||
fPTR.classList.toggle("hidden", !(fType.value === "A" || fType.value === "AAAA"));
|
fPTR.classList.toggle("hidden", zone.auto_ptr || !(fType.value === "A" || fType.value === "AAAA"));
|
||||||
};
|
};
|
||||||
fType.addEventListener("change", () => { showHint(); preview(); });
|
fType.addEventListener("change", () => { showHint(); preview(); });
|
||||||
showHint();
|
showHint();
|
||||||
@@ -569,6 +686,7 @@ async function recordsTab(el, zone, query) {
|
|||||||
: 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,
|
: 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,
|
||||||
|
zone.other_views && !(r.type === "NS" && r.relative_name === "@") ? [h("a", { class: "button small reveal", href: "#/zones/" + zone.id + "/compare?name=" + encodeURIComponent(r.relative_name) + "&type=" + r.type, title: "Compare " + r.relative_name + " " + r.type + " with view" + (zone.other_views.length > 1 ? "s " : " ") + zone.other_views.join(", ") }, "Compare"), " "] : null,
|
||||||
h("button", { class: "small", title: "Edit", onclick: () => edit(r) }, "Edit"), " ",
|
h("button", { class: "small", title: "Edit", onclick: () => edit(r) }, "Edit"), " ",
|
||||||
r.disabled
|
r.disabled
|
||||||
? 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")
|
||||||
@@ -586,7 +704,9 @@ async function recordsTab(el, zone, query) {
|
|||||||
const del = async (r) => {
|
const del = async (r) => {
|
||||||
const isAddr = r.type === "A" || r.type === "AAAA";
|
const isAddr = r.type === "A" || r.type === "AAAA";
|
||||||
let ptr = false;
|
let ptr = false;
|
||||||
if (isAddr) {
|
if (isAddr && zone.auto_ptr) {
|
||||||
|
if (!(await confirmDialog("Delete record", display(r.name) + " " + r.type + " " + r.data + ", and its PTR record (this zone keeps them in step)", "Delete", "danger"))) return;
|
||||||
|
} else if (isAddr) {
|
||||||
const c = checkbox("Also delete the matching PTR record", true);
|
const c = checkbox("Also delete the matching PTR record", true);
|
||||||
const ok = await modal("Delete record", h("div", null, h("p", { class: "mono" }, display(r.name) + " " + r.type + " " + r.data), c), [{ label: "Cancel", value: false }, { label: "Delete", kind: "danger", value: true }]);
|
const ok = await modal("Delete record", h("div", null, h("p", { class: "mono" }, display(r.name) + " " + r.type + " " + r.data), c), [{ label: "Cancel", value: false }, { label: "Delete", kind: "danger", value: true }]);
|
||||||
if (ok !== true) return;
|
if (ok !== true) return;
|
||||||
@@ -657,7 +777,7 @@ async function recordsTab(el, zone, query) {
|
|||||||
h("div", { class: "f" }, h("label", null, "TTL"), eTTL),
|
h("div", { class: "f" }, h("label", null, "TTL"), eTTL),
|
||||||
h("div", { class: "f grow" }, h("label", null, "Data"), eData),
|
h("div", { class: "f grow" }, h("label", null, "Data"), eData),
|
||||||
h("div", { class: "f" }, h("label", null, "Comment"), eComment),
|
h("div", { class: "f" }, h("label", null, "Comment"), eComment),
|
||||||
(r.type === "A" || r.type === "AAAA") ? h("div", { class: "f" }, h("label", null, " "), ePTR) : null,
|
(r.type === "A" || r.type === "AAAA") && !zone.auto_ptr ? h("div", { class: "f" }, h("label", null, " "), ePTR) : null,
|
||||||
h("div", { class: "f" }, h("label", null, " "), h("span", null, h("button", { class: "primary", onclick: save }, "Save"), " ", h("button", { onclick: () => { editing = null; draw(); } }, "Cancel")))),
|
h("div", { class: "f" }, h("label", null, " "), h("span", null, h("button", { class: "primary", onclick: save }, "Save"), " ", h("button", { onclick: () => { editing = null; draw(); } }, "Cancel")))),
|
||||||
h("div", { class: "hint" }, "Created ", time(r.created_at), r.created_by ? " by " + r.created_by : "", "; changed ", time(r.updated_at), r.updated_by ? " by " + r.updated_by : "", "."),
|
h("div", { class: "hint" }, "Created ", time(r.created_at), r.created_by ? " by " + r.created_by : "", "; changed ", time(r.updated_at), r.updated_by ? " by " + r.updated_by : "", "."),
|
||||||
eErr);
|
eErr);
|
||||||
@@ -697,6 +817,7 @@ async function settingsTab(el, zone) {
|
|||||||
const view = select([["", "(no view)"], ...views.map((v) => [String(v.id), v.name])], String(zone.view_id || ""));
|
const view = select([["", "(no view)"], ...views.map((v) => [String(v.id), v.name])], String(zone.view_id || ""));
|
||||||
const file = input({ value: zone.file || "", class: "mono" });
|
const file = input({ value: zone.file || "", class: "mono" });
|
||||||
const comment = textarea(zone.comment, { rows: 2 });
|
const comment = textarea(zone.comment, { rows: 2 });
|
||||||
|
const autoPTR = checkbox("Keep PTR records in step with the A and AAAA records", !!zone.auto_ptr);
|
||||||
if (zone.type === "primary") {
|
if (zone.type === "primary") {
|
||||||
rows.push(
|
rows.push(
|
||||||
field("Primary name server", mname, "SOA MNAME."),
|
field("Primary name server", mname, "SOA MNAME."),
|
||||||
@@ -707,6 +828,8 @@ async function settingsTab(el, zone) {
|
|||||||
field("Negative TTL", minimum, "SOA minimum: how long resolvers cache 'does not exist' answers (at most 3 hours in BIND)."),
|
field("Negative TTL", minimum, "SOA minimum: how long resolvers cache 'does not exist' answers (at most 3 hours in BIND)."),
|
||||||
field("Default TTL", ttl, "TTL for records that don't set their own."),
|
field("Default TTL", ttl, "TTL for records that don't set their own."),
|
||||||
field("Serial policy", policy, "How the serial (now " + s.serial + ") is bumped when the zone changes."));
|
field("Serial policy", policy, "How the serial (now " + s.serial + ") is bumped when the zone changes."));
|
||||||
|
if (!zone.prefix && !/\.(in-addr|ip6)\.arpa\.$/.test(zone.name)) rows.push(field("Reverse DNS", autoPTR,
|
||||||
|
"Adding, changing, disabling or deleting an address record does the same to its PTR record, in the reverse zones of " + (zone.view ? "view " + zone.view + (zone.linked_with ? " and the linked views" : "") : "the server") + ". An address keeps the name it has; one without a reverse zone is left alone (Zones lists the reverse zones you're missing)."));
|
||||||
}
|
}
|
||||||
if (["secondary", "mirror", "stub", "hint", "redirect"].includes(zone.type)) rows.push(field("File", file, "Leave empty for a file managed by Jarl's cache directory (secondary zones)."));
|
if (["secondary", "mirror", "stub", "hint", "redirect"].includes(zone.type)) rows.push(field("File", file, "Leave empty for a file managed by Jarl's cache directory (secondary zones)."));
|
||||||
if (views.length) rows.push(field("View", view, "Moving a zone to another view changes which clients see it."));
|
if (views.length) rows.push(field("View", view, "Moving a zone to another view changes which clients see it."));
|
||||||
@@ -718,6 +841,7 @@ async function settingsTab(el, zone) {
|
|||||||
body.soa = { mname: mname.value.trim(), rname: rname.value.trim(), refresh: +refresh.value, retry: +retry.value, expire: +expire.value, minimum: +minimum.value };
|
body.soa = { mname: mname.value.trim(), rname: rname.value.trim(), refresh: +refresh.value, retry: +retry.value, expire: +expire.value, minimum: +minimum.value };
|
||||||
body.default_ttl = +ttl.value;
|
body.default_ttl = +ttl.value;
|
||||||
body.serial_policy = policy.value;
|
body.serial_policy = policy.value;
|
||||||
|
if (!!zone.auto_ptr !== autoPTR.input.checked) body.auto_ptr = autoPTR.input.checked;
|
||||||
}
|
}
|
||||||
if (["secondary", "mirror", "stub", "hint", "redirect"].includes(zone.type)) body.file = file.value.trim();
|
if (["secondary", "mirror", "stub", "hint", "redirect"].includes(zone.type)) body.file = file.value.trim();
|
||||||
if (views.length) body.view_id = view.value ? Number(view.value) : 0;
|
if (views.length) body.view_id = view.value ? Number(view.value) : 0;
|
||||||
@@ -788,3 +912,13 @@ async function toggleZone(zone) {
|
|||||||
rerender();
|
rerender();
|
||||||
} catch (e) { toast(e.message, "err", 8000); }
|
} catch (e) { toast(e.message, "err", 8000); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fillPTR adds the PTR records a reverse zone lacks (docs/reverse.md).
|
||||||
|
async function fillPTR(zone) {
|
||||||
|
try {
|
||||||
|
const r = await change("POST", "/zones/" + zone.id + "/fill-ptr");
|
||||||
|
toast(describeResult(r));
|
||||||
|
(r.warnings || []).forEach((w) => toast(w, "warn", 10000));
|
||||||
|
rerender();
|
||||||
|
} catch (e) { toast(e.message, "err", 8000); }
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge, viewLink, lastList, checkbox } from "../ui.js";
|
import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge, viewLink, lastList, checkbox, plural, confirmDialog } 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, maskNetwork } from "../names.js";
|
||||||
|
|
||||||
export async function render(el, params, query) {
|
export async function render(el, params, query) {
|
||||||
const [zones, views, settings] = await Promise.all([get("/zones", draftQuery()), get("/views"), get("/settings")]);
|
const [zones, views, settings, suggestions] = await Promise.all([get("/zones", draftQuery()), get("/views"), get("/settings"),
|
||||||
|
get("/reverse-zones/suggestions", draftQuery()).catch(() => [])]);
|
||||||
const filter = input({ type: "search", placeholder: "Filter zones…", value: query.get("q") || "" });
|
const filter = input({ type: "search", placeholder: "Filter zones…", value: query.get("q") || "" });
|
||||||
const viewSel = select([["", "All views"], ...views.map((v) => [v.name, v.name])], query.get("view") || "");
|
const viewSel = select([["", "All views"], ...views.map((v) => [v.name, v.name])], query.get("view") || "");
|
||||||
const typeSel = select([["", "All types"], "primary", "secondary", "forward", "stub", "static-stub", "mirror", "hint", "redirect", "in-view"], query.get("type") || "");
|
const typeSel = select([["", "All types"], "primary", "secondary", "forward", "stub", "static-stub", "mirror", "hint", "redirect", "in-view"], query.get("type") || "");
|
||||||
@@ -23,7 +24,7 @@ export async function render(el, params, query) {
|
|||||||
lastList.zones = hash;
|
lastList.zones = hash;
|
||||||
const rows = zones.filter((z) => (!q || z.name.includes(q)) && (!viewSel.value || z.view === viewSel.value) && (!typeSel.value || z.type === typeSel.value));
|
const rows = zones.filter((z) => (!q || z.name.includes(q)) && (!viewSel.value || z.view === viewSel.value) && (!typeSel.value || z.type === typeSel.value));
|
||||||
mount(list, table([
|
mount(list, table([
|
||||||
{ label: "Zone", render: (z) => [z.pending === "delete" ? h("span", null, z.display_name) : h("a", { href: "#/zones/" + z.id }, z.display_name), dnssecLock(z), z.disabled ? [" ", badge("disabled", "off")] : null, z.pending ? [" ", pendingBadge(z.pending)] : null], tdClass: "name" },
|
{ label: "Zone", render: (z) => [z.pending === "delete" ? h("span", null, z.display_name) : h("a", { href: "#/zones/" + z.id }, z.display_name), z.prefix ? h("span", { class: "muted small" }, " " + z.prefix) : null, z.ptr_missing ? [" ", badge(z.ptr_missing + " PTR missing", "warn")] : null, dnssecLock(z), z.disabled ? [" ", badge("disabled", "off")] : null, z.pending ? [" ", pendingBadge(z.pending)] : null], tdClass: "name" },
|
||||||
views.length ? { label: "View", render: (z) => viewLink(viewsById.get(z.view_id), z.view, hash) } : null,
|
views.length ? { label: "View", render: (z) => viewLink(viewsById.get(z.view_id), z.view, hash) } : null,
|
||||||
{ label: "Type", render: (z) => badge(z.type, z.type === "primary" ? "accent" : "") },
|
{ label: "Type", render: (z) => badge(z.type, z.type === "primary" ? "accent" : "") },
|
||||||
{ label: "Records", render: (z) => z.type === "primary" ? [String(z.record_count), z.disabled_count ? h("span", { class: "muted small", title: z.disabled_count + " disabled (left out of the zone)" }, " (" + z.disabled_count + " off)") : null] : "", class: "right", tdClass: "right" },
|
{ label: "Records", render: (z) => z.type === "primary" ? [String(z.record_count), z.disabled_count ? h("span", { class: "muted small", title: z.disabled_count + " disabled (left out of the zone)" }, " (" + z.disabled_count + " off)") : null] : "", class: "right", tdClass: "right" },
|
||||||
@@ -38,8 +39,10 @@ export async function render(el, params, query) {
|
|||||||
h("div", { class: "page-head" },
|
h("div", { class: "page-head" },
|
||||||
h("div", { class: "grow" }, h("h1", null, "Zones"), h("div", { class: "muted" }, zones.length + " zones")),
|
h("div", { class: "grow" }, h("h1", null, "Zones"), h("div", { class: "muted" }, zones.length + " zones")),
|
||||||
h("div", { class: "actions" }, filter, views.length ? viewSel : null, typeSel,
|
h("div", { class: "actions" }, filter, views.length ? viewSel : null, typeSel,
|
||||||
can("zones", true) ? h("button", { class: "primary", onclick: () => newZoneDialog(views, zones, settings) }, "New zone") : null)),
|
can("zones", true) ? [h("button", { title: "A zone for the PTR records of a network: Jarl lists the networks your records use", onclick: () => newZoneDialog(views, zones, settings, { kind: "reverse" }) }, "New reverse zone"), " ",
|
||||||
h("div", { class: "panel" }, list));
|
h("button", { class: "primary", onclick: () => newZoneDialog(views, zones, settings) }, "New zone")] : null)),
|
||||||
|
h("div", { class: "panel" }, list),
|
||||||
|
suggestions.length && can("zones", true) ? suggestPanel(suggestions, views) : null);
|
||||||
draw();
|
draw();
|
||||||
if (query.get("new")) {
|
if (query.get("new")) {
|
||||||
// Drop ?new=1 so re-rendering after the zone is created doesn't open
|
// Drop ?new=1 so re-rendering after the zone is created doesn't open
|
||||||
@@ -49,16 +52,38 @@ export async function render(el, params, query) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// preset.view picks the view (opened from a view's page).
|
// preset.view picks the view (opened from a view's page), preset.kind
|
||||||
|
// "reverse" starts with a reverse zone.
|
||||||
export async function newZoneDialog(views, zones, settings, preset = {}) {
|
export async function newZoneDialog(views, zones, settings, preset = {}) {
|
||||||
const err = h("div");
|
const err = h("div");
|
||||||
// The zone's name matters most and is easy to get wrong, so the dialog
|
// The zone's name matters most and is easy to get wrong, so the dialog
|
||||||
// asks for a domain or a network and shows the resulting zone name.
|
// asks for a domain or a network and shows the resulting zone name.
|
||||||
let kind = "forward";
|
let kind = preset.kind === "reverse" ? "reverse" : "forward";
|
||||||
const name = input({ placeholder: "adbraun.lol", class: "mono", autocomplete: "off", spellcheck: "false" });
|
const name = input({ placeholder: "adbraun.lol", class: "mono", autocomplete: "off", spellcheck: "false" });
|
||||||
const nameLabel = h("label", { for: "zone-name" }, "Domain");
|
const nameLabel = h("label", { for: "zone-name" }, "Domain");
|
||||||
name.id = "zone-name";
|
name.id = "zone-name";
|
||||||
const preview = h("div", { class: "name-preview", "aria-live": "polite" });
|
const preview = h("div", { class: "name-preview", "aria-live": "polite" });
|
||||||
|
// For a reverse zone: sizes to pick from, and the networks the records
|
||||||
|
// use without a reverse zone, in every view (docs/reverse.md).
|
||||||
|
const helper = h("div", { class: "reverse-helper" });
|
||||||
|
// The networks per view picked ("" for all views): a chosen view's list
|
||||||
|
// also has the addresses only other views' records use.
|
||||||
|
const candsBy = {};
|
||||||
|
let cands = null;
|
||||||
|
const candQ = input({ type: "search", placeholder: "Search: an address, a name or a view", class: "small", style: { marginBottom: "6px" } });
|
||||||
|
candQ.addEventListener("input", () => drawHelper());
|
||||||
|
const loadCands = () => {
|
||||||
|
const k = view.value || "";
|
||||||
|
if (candsBy[k]) { cands = candsBy[k]; return drawHelper(); }
|
||||||
|
get("/reverse-zones/suggestions", draftQuery({ all: 1, view: k || undefined })).catch(() => []).then((c) => {
|
||||||
|
candsBy[k] = c;
|
||||||
|
if ((view.value || "") === k) { cands = c; drawHelper(); }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// sizeFrom is the address the size buttons work from: a record's, once a
|
||||||
|
// network was picked, so a /48 made smaller becomes that record's /64,
|
||||||
|
// not the /48's first one.
|
||||||
|
let sizeFrom = null;
|
||||||
const kindButtons = h("div", { class: "segmented", role: "group", "aria-label": "Kind of zone" });
|
const kindButtons = h("div", { class: "segmented", role: "group", "aria-label": "Kind of zone" });
|
||||||
const type = select([["primary", "primary (Jarl manages the records)"], ["secondary", "secondary (copy from another server)"], ["forward", "forward (send queries to other resolvers)"], ["stub", "stub"], ["static-stub", "static-stub"], ["mirror", "mirror"], ["hint", "hint (root hints)"], ["in-view", "in-view (share a zone from an earlier view)"]], "primary");
|
const type = select([["primary", "primary (Jarl manages the records)"], ["secondary", "secondary (copy from another server)"], ["forward", "forward (send queries to other resolvers)"], ["stub", "stub"], ["static-stub", "static-stub"], ["mirror", "mirror"], ["hint", "hint (root hints)"], ["in-view", "in-view (share a zone from an earlier view)"]], "primary");
|
||||||
const view = select([["", views.length ? "– choose a view –" : "(no views)"], ...views.map((v) => [v.name, v.name])], preset.view || (views.length === 1 ? views[0].name : ""));
|
const view = select([["", views.length ? "– choose a view –" : "(no views)"], ...views.map((v) => [v.name, v.name])], preset.view || (views.length === 1 ? views[0].name : ""));
|
||||||
@@ -77,6 +102,8 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
|
|||||||
const syncLink = () => { linkBox.style.display = cloneSel.value ? "" : "none"; };
|
const syncLink = () => { linkBox.style.display = cloneSel.value ? "" : "none"; };
|
||||||
cloneSel.addEventListener("change", syncLink);
|
cloneSel.addEventListener("change", syncLink);
|
||||||
const comment = input();
|
const comment = input();
|
||||||
|
const fill = checkbox("Add a PTR record for each address the view's A and AAAA records use in this network", true);
|
||||||
|
const fillRow = field("PTR records", fill, "Names already in your zones, so the reverse zone answers from the start. Leave it off to add them yourself.");
|
||||||
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(/\.$/, "")) + ".";
|
||||||
@@ -144,20 +171,61 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
|
|||||||
nameLabel.textContent = k === "reverse" ? "Network" : "Domain";
|
nameLabel.textContent = k === "reverse" ? "Network" : "Domain";
|
||||||
name.placeholder = k === "reverse" ? "10.10.0.0/24 or 2001:db8:1::/48" : "adbraun.lol";
|
name.placeholder = k === "reverse" ? "10.10.0.0/24 or 2001:db8:1::/48" : "adbraun.lol";
|
||||||
for (const b of kindButtons.children) b.classList.toggle("active", b.dataset.kind === k);
|
for (const b of kindButtons.children) b.classList.toggle("active", b.dataset.kind === k);
|
||||||
|
syncFill();
|
||||||
|
if (k === "reverse") loadCands();
|
||||||
|
drawHelper();
|
||||||
|
if (views.length) view.options[0].textContent = k === "reverse" ? "– the private view that uses these addresses –" : "– choose a view –";
|
||||||
check();
|
check();
|
||||||
name.focus();
|
name.focus();
|
||||||
};
|
};
|
||||||
for (const [k, label, title] of [["forward", "Domain", "Names to addresses, e.g. adbraun.lol"], ["reverse", "Reverse zone", "Addresses to names (PTR records) for a network"]]) {
|
for (const [k, label, title] of [["forward", "Domain", "Names to addresses, e.g. adbraun.lol"], ["reverse", "Reverse zone", "Addresses to names (PTR records) for a network"]]) {
|
||||||
kindButtons.append(h("button", { type: "button", dataset: { kind: k }, title, class: k === kind ? "active" : "", onclick: () => setKind(k) }, label));
|
kindButtons.append(h("button", { type: "button", dataset: { kind: k }, title, class: k === kind ? "active" : "", onclick: () => setKind(k) }, label));
|
||||||
}
|
}
|
||||||
name.addEventListener("input", () => { mount(err); updateClones(); check(); });
|
const changed = () => { mount(err); updateClones(); check(); drawHelper(); };
|
||||||
view.addEventListener("change", check);
|
const typed = () => { sizeFrom = null; changed(); };
|
||||||
|
const drawHelper = () => {
|
||||||
|
if (kind !== "reverse") return mount(helper);
|
||||||
|
const v = name.value.trim();
|
||||||
|
const isNet = v && maskNetwork(v, 32) !== null;
|
||||||
|
const v6 = v.includes(":");
|
||||||
|
const bits = Number(v.split("/")[1]);
|
||||||
|
const sizes = isNet ? h("div", { class: "size-chips" }, h("span", { class: "muted small" }, "Size: "),
|
||||||
|
(v6 ? [[48, "a whole site (all its /64s)"], [56, "a smaller delegation"], [64, "one subnet"], [128, "one address"]] : [[16, "a /16"], [24, "one network"], [32, "one address"]])
|
||||||
|
.map(([b, title]) => h("button", { type: "button", class: "small" + (bits === b ? " active" : ""), title, onclick: () => { name.value = maskNetwork(sizeFrom || name.value, b); changed(); } }, "/" + b)),
|
||||||
|
sizeFrom ? h("span", { class: "muted small" }, " around " + sizeFrom) : null) : null;
|
||||||
|
const q = candQ.value.trim().toLowerCase();
|
||||||
|
// Yours first, then the networks with the most records.
|
||||||
|
const list = (cands || []).filter((c) => !q || [c.prefix, c.zone, c.view, c.hidden, ...c.samples].join(" ").toLowerCase().includes(q))
|
||||||
|
.sort((a, b) => (a.group === "yours" ? 0 : 1) - (b.group === "yours" ? 0 : 1) || b.fill - a.fill);
|
||||||
|
const shown = list.slice(0, 15);
|
||||||
|
mount(helper, sizes,
|
||||||
|
(cands || []).length > 8 ? candQ : null,
|
||||||
|
shown.length ? h("div", { class: "cands" },
|
||||||
|
h("div", { class: "muted small" }, "Networks your records use without a reverse zone" + (view.value ? " in view " + view.value : "") + ": pick one, then change its size if you need"),
|
||||||
|
shown.map((c) => h("button", { type: "button", class: "cand", title: c.why, onclick: () => {
|
||||||
|
name.value = c.prefix;
|
||||||
|
sizeFrom = c.samples.length ? c.samples[0].split(" ")[0] : null;
|
||||||
|
if (views.length && c.view) view.value = c.view;
|
||||||
|
changed();
|
||||||
|
} },
|
||||||
|
h("span", { class: "mono" }, c.prefix),
|
||||||
|
views.length && !view.value ? h("span", { class: "muted" }, " " + c.view) : null,
|
||||||
|
h("span", { class: "muted small" }, " " + c.fill + " PTR" + (c.samples.length ? " · " + c.samples.slice(0, 2).join(", ") : "")),
|
||||||
|
c.hidden ? [" ", badge(c.hidden, "off")] : null)),
|
||||||
|
list.length > shown.length ? h("div", { class: "muted small" }, "… and " + (list.length - shown.length) + " more: search to find one") : null)
|
||||||
|
: q && cands && cands.length ? h("div", { class: "muted small" }, "No network matches " + JSON.stringify(candQ.value.trim()) + ".")
|
||||||
|
: cands && !list.length ? h("div", { class: "muted small" }, "Every address your records use" + (view.value ? " in view " + view.value : "") + " has a reverse zone.") : null);
|
||||||
|
};
|
||||||
|
name.addEventListener("input", typed);
|
||||||
|
view.addEventListener("change", () => { check(); if (kind === "reverse") loadCands(); else drawHelper(); });
|
||||||
|
const glueRow = field("Name server addresses", nsAddr, "Optional, for name servers inside this zone: 'ns1 192.0.2.1' per line (glue).");
|
||||||
|
const cloneRow = 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).");
|
||||||
const rows = {
|
const rows = {
|
||||||
primary: [field("Name servers", ns, "One per line. Become the zone's NS records; the first is the SOA primary name server."),
|
primary: [field("Name servers", ns, "One per line. Become the zone's NS records; the first is the SOA primary name server."),
|
||||||
field("Name server addresses", nsAddr, "Optional, for name servers inside this zone: 'ns1 192.0.2.1' per line (glue)."),
|
glueRow,
|
||||||
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", 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).")],
|
cloneRow],
|
||||||
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).")],
|
||||||
@@ -167,7 +235,13 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
|
|||||||
"in-view": [field("Share from view", inView, "The zone must exist in an earlier view.")],
|
"in-view": [field("Share from view", inView, "The zone must exist in an earlier view.")],
|
||||||
};
|
};
|
||||||
const typeRows = h("div", { class: "form-grid" });
|
const typeRows = h("div", { class: "form-grid" });
|
||||||
|
const syncFill = () => {
|
||||||
|
for (const n of fillRow) if (n) n.style.display = kind === "reverse" && type.value === "primary" ? "" : "none";
|
||||||
|
// Glue and copying a zone from another view are for domains.
|
||||||
|
for (const n of [...glueRow, ...cloneRow]) if (n) n.style.display = kind === "reverse" ? "none" : "";
|
||||||
|
};
|
||||||
const drawType = () => {
|
const drawType = () => {
|
||||||
|
syncFill();
|
||||||
mount(typeRows, rows[type.value] || []);
|
mount(typeRows, rows[type.value] || []);
|
||||||
if (type.value === "hint" && !name.value.trim()) name.value = ".";
|
if (type.value === "hint" && !name.value.trim()) name.value = ".";
|
||||||
check();
|
check();
|
||||||
@@ -177,13 +251,15 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
|
|||||||
const body = h("div", null,
|
const body = h("div", null,
|
||||||
h("div", { class: "form-grid" },
|
h("div", { class: "form-grid" },
|
||||||
h("label", null, "Kind"), h("div", null, kindButtons),
|
h("label", null, "Kind"), h("div", null, kindButtons),
|
||||||
nameLabel, h("div", null, name, preview),
|
nameLabel, h("div", { class: "grid-cell" }, name, preview, helper),
|
||||||
views.length ? field("View", view) : null,
|
views.length ? field("View", view) : null,
|
||||||
field("Type", type),
|
field("Type", type),
|
||||||
|
fillRow,
|
||||||
field("Comment", comment)),
|
field("Comment", comment)),
|
||||||
h("div", { style: { marginTop: "8px" } }, typeRows),
|
h("div", { style: { marginTop: "8px" } }, typeRows),
|
||||||
err);
|
err);
|
||||||
await modal("New zone", body, [
|
if (kind === "reverse") setKind("reverse");
|
||||||
|
await modal(kind === "reverse" ? "New reverse zone" : "New zone", body, [
|
||||||
{ label: "Cancel", value: null },
|
{ label: "Cancel", value: null },
|
||||||
{ label: "Create zone", kind: "primary", onclick: async () => {
|
{ label: "Create zone", kind: "primary", onclick: async () => {
|
||||||
const t = type.value;
|
const t = type.value;
|
||||||
@@ -197,6 +273,7 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
|
|||||||
if (view.value) data.view = view.value;
|
if (view.value) data.view = view.value;
|
||||||
const opts = [];
|
const opts = [];
|
||||||
const list = (ta) => lines(ta.value).map((l) => l.replace(/;$/, "") + ";").join(" ");
|
const list = (ta) => lines(ta.value).map((l) => l.replace(/;$/, "") + ";").join(" ");
|
||||||
|
if (t === "primary" && kind === "reverse" && fill.input.checked) data.fill = true;
|
||||||
if (t === "primary") {
|
if (t === "primary") {
|
||||||
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() };
|
||||||
@@ -229,6 +306,7 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
|
|||||||
? await changeMany(zoneName.map((n) => ({ op: "zone.create", data: { ...data, name: n } })), "reverse zones for " + name.value.trim())
|
? await changeMany(zoneName.map((n) => ({ op: "zone.create", data: { ...data, name: n } })), "reverse zones for " + name.value.trim())
|
||||||
: await change("POST", "/zones", data);
|
: await change("POST", "/zones", data);
|
||||||
toast(describeResult(r));
|
toast(describeResult(r));
|
||||||
|
(r.warnings || []).forEach((w) => toast(w, "warn", 10000));
|
||||||
rerender();
|
rerender();
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -268,3 +346,84 @@ export function dnssecLock(z) {
|
|||||||
export function pendingBadge(kind) {
|
export function pendingBadge(kind) {
|
||||||
return badge({ add: "pending: new", change: "pending: changed", delete: "pending: delete", disable: "pending: disable", enable: "pending: enable" }[kind] || kind, kind === "delete" ? "err" : "warn");
|
return badge({ add: "pending: new", change: "pending: changed", delete: "pending: delete", disable: "pending: disable", enable: "pending: enable" }[kind] || kind, kind === "delete" ? "err" : "warn");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// suggestPanel lists the reverse zones the private views lack for their
|
||||||
|
// records' addresses, to create filled with PTR records (docs/reverse.md):
|
||||||
|
// networks that are yours, and public addresses, which may be a web
|
||||||
|
// host's, to create one by one or dismiss.
|
||||||
|
function suggestPanel(sugg, views) {
|
||||||
|
const create = async (list) => {
|
||||||
|
const reqs = list.map((s) => ({ op: "zone.create", data: { name: s.prefix, view_id: s.view_id || undefined, fill: true } }));
|
||||||
|
try {
|
||||||
|
const r = reqs.length === 1 ? await change("POST", "/zones", reqs[0].data) : await changeMany(reqs, plural(reqs.length, "reverse zone"));
|
||||||
|
toast(describeResult(r));
|
||||||
|
(r.warnings || []).forEach((w) => toast(w, "warn", 10000));
|
||||||
|
rerender();
|
||||||
|
} catch (e) { toast(e.message, "err", 8000); }
|
||||||
|
};
|
||||||
|
const dismiss = async (s) => {
|
||||||
|
try {
|
||||||
|
const r = await change("POST", "/reverse-zones/dismiss", { view_id: s.view_id || undefined, prefix: s.prefix });
|
||||||
|
toast(describeResult(r));
|
||||||
|
rerender();
|
||||||
|
} catch (e) { toast(e.message, "err", 8000); }
|
||||||
|
};
|
||||||
|
const cols = (pub) => [
|
||||||
|
views.length ? { label: "View", render: (s) => s.view } : null,
|
||||||
|
{ label: pub ? "Address" : "Network", render: (s) => [h("span", { class: "mono" }, pub ? s.prefix.replace(/\/(32|128)$/, "") : s.prefix), pub ? null : h("div", { class: "muted small" }, s.why)] },
|
||||||
|
{ label: "Zone", render: (s) => h("span", { class: "mono small" }, s.zone) },
|
||||||
|
{ label: pub ? "Name" : "PTR records", render: (s) => pub ? h("span", { class: "mono small" }, s.samples.map((x) => x.split(" ")[1]).join(", ")) : [String(s.fill), s.samples.length ? h("div", { class: "muted small mono" }, s.samples.join(", ") + (s.fill > s.samples.length ? ", …" : "")) : null] },
|
||||||
|
{ label: "", render: (s) => [h("button", { class: "small", onclick: () => create([s]) }, "Create"), pub ? [" ", h("button", { class: "small", title: "Not yours (a web host's, say): don't suggest it again", onclick: () => dismiss(s) }, "Dismiss")] : null], tdClass: "actions-cell" },
|
||||||
|
].filter(Boolean);
|
||||||
|
const yours = sugg.filter((s) => s.group === "yours");
|
||||||
|
const pub = sugg.filter((s) => s.group !== "yours");
|
||||||
|
return h("div", { class: "panel" },
|
||||||
|
h("div", { class: "page-head" }, h("h2", { class: "grow" }, "Missing reverse zones"),
|
||||||
|
yours.length > 1 ? h("div", { class: "actions" }, h("button", { onclick: () => create(yours) }, "Create all " + yours.length)) : null),
|
||||||
|
h("p", { class: "muted small" }, "Your records use these addresses, but no reverse zone answers \"which host has this address?\" for them" + (views.length ? " in their view" : "") + ". Jarl creates each with a PTR record for every address there. Only your own clients ask this server: the internet asks whoever owns the addresses."),
|
||||||
|
yours.length ? table(cols(false), yours) : null,
|
||||||
|
pub.length ? publicGroup(pub, cols(true), create) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// publicGroup lists the public addresses, collapsed: there can be hundreds
|
||||||
|
// (a web host's), so it has a search, shows 25 at a time, and dismisses
|
||||||
|
// what the search found at once.
|
||||||
|
function publicGroup(pub, cols, create) {
|
||||||
|
const PAGE = 25;
|
||||||
|
let limit = PAGE;
|
||||||
|
const q = input({ type: "search", placeholder: "Search: an address, a name or a view", class: "small", style: { width: "300px" } });
|
||||||
|
const body = h("div");
|
||||||
|
const match = () => {
|
||||||
|
const t = q.value.trim().toLowerCase();
|
||||||
|
return pub.filter((s) => !t || [s.prefix, s.zone, s.view, ...s.samples].join(" ").toLowerCase().includes(t));
|
||||||
|
};
|
||||||
|
const dismissAll = async (list) => {
|
||||||
|
const byView = new Map();
|
||||||
|
for (const s of list) {
|
||||||
|
const k = s.view_id || 0;
|
||||||
|
if (!byView.has(k)) byView.set(k, []);
|
||||||
|
byView.get(k).push(s.prefix);
|
||||||
|
}
|
||||||
|
if (!await confirmDialog("Dismiss " + plural(list.length, "address", "addresses"), "Jarl stops suggesting reverse zones for " + (list.length === 1 ? list[0].prefix : list.length + " public addresses" + (q.value.trim() ? " matching " + JSON.stringify(q.value.trim()) : "")) + ". Bring one back with: jarl zone suggest-reverse --undismiss NETWORK --view VIEW.", "Dismiss")) return;
|
||||||
|
try {
|
||||||
|
const ops = [...byView].map(([vid, prefixes]) => ({ op: "reverse.dismiss", data: { view_id: vid || undefined, prefixes } }));
|
||||||
|
toast(describeResult(await changeMany(ops, "dismiss " + plural(list.length, "reverse zone suggestion"))));
|
||||||
|
rerender();
|
||||||
|
} catch (e) { toast(e.message, "err", 8000); }
|
||||||
|
};
|
||||||
|
const draw = () => {
|
||||||
|
const list = match();
|
||||||
|
const shown = list.slice(0, limit);
|
||||||
|
mount(body,
|
||||||
|
h("div", { class: "compare-controls" }, q,
|
||||||
|
list.length ? h("button", { class: "small", title: "Not yours: stop suggesting all of these", onclick: () => dismissAll(list) }, list.length === pub.length ? "Dismiss all " + list.length : "Dismiss these " + list.length) : null),
|
||||||
|
shown.length ? table(cols, shown) : h("div", { class: "muted small" }, "No address matches."),
|
||||||
|
list.length > shown.length ? h("button", { class: "small", onclick: () => { limit += PAGE; draw(); } }, "Show " + Math.min(PAGE, list.length - shown.length) + " more (of " + (list.length - shown.length) + ")") : null);
|
||||||
|
};
|
||||||
|
q.addEventListener("input", () => { limit = PAGE; draw(); });
|
||||||
|
draw();
|
||||||
|
return h("details", { class: "compare-only", open: pub.length <= 10 },
|
||||||
|
h("summary", null, h("strong", null, "Public addresses: " + pub.length), h("span", { class: "muted small" }, " · create a zone only for an address that is yours; dismiss the others (a web host's)")),
|
||||||
|
h("p", { class: "muted small" }, "Jarl can't tell your own servers from someone else's. Each gets a zone of its own: the rest of its network belongs to others."),
|
||||||
|
body);
|
||||||
|
}
|
||||||
@@ -557,3 +557,68 @@ header.top .header-right { display: flex; align-items: center; gap: 12px; margin
|
|||||||
header.top form.search input { width: 160px; }
|
header.top form.search input { width: 160px; }
|
||||||
header.top { gap: 10px; }
|
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 { vertical-align: top; }
|
||||||
|
/* The same name and type columns in every section, so they line up. */
|
||||||
|
table.compare { table-layout: auto; width: 100%; }
|
||||||
|
table.compare tr > :nth-child(1) { width: 18ch; }
|
||||||
|
table.compare tr > :nth-child(2) { width: 8ch; }
|
||||||
|
table.compare td.compare-cell button { margin-top: 4px; }
|
||||||
|
/* One line per record: a long TXT record is cut, with the whole of it on hover. */
|
||||||
|
table.compare .oneline { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 46ch; }
|
||||||
|
.compare-actions { margin-top: 4px; }
|
||||||
|
.compare-controls { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-bottom: 12px; }
|
||||||
|
.compare-controls label { color: var(--muted); }
|
||||||
|
ul.compare-summary { margin: 0 0 12px; padding-left: 18px; }
|
||||||
|
details.compare-only { margin: 12px 0; }
|
||||||
|
details.compare-only summary { cursor: pointer; padding: 4px 0; }
|
||||||
|
table.compare tr.muted td { color: var(--muted); }
|
||||||
|
|
||||||
|
/* Text that wraps anywhere, e.g. long statements in the Added by Jarl table. */
|
||||||
|
pre.wrap { white-space: pre-wrap; word-break: break-all; }
|
||||||
|
|
||||||
|
/* New reverse zone: sizes and the networks the records use. */
|
||||||
|
.reverse-helper { margin-top: 6px; }
|
||||||
|
.form-grid > .grid-cell { min-width: 0; }
|
||||||
|
.size-chips { display: flex; gap: 4px; align-items: center; flex-wrap: wrap; margin-bottom: 6px; }
|
||||||
|
.size-chips button.active { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
|
||||||
|
.cands { display: flex; flex-direction: column; gap: 3px; max-height: 260px; overflow-y: auto; }
|
||||||
|
.cands button.cand { flex-shrink: 0; text-align: left; font-weight: normal; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|
||||||
|
/* Encrypted DNS: per listen address, the views that get its records. */
|
||||||
|
td.addr-views { width: 100%; }
|
||||||
|
.view-chips { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: center; }
|
||||||
|
.view-chips label.chip { white-space: nowrap; font-size: 0.9em; }
|
||||||
|
|
||||||
|
/* Explore: the filter chips with a ✕ each, and the latest answers. */
|
||||||
|
.filter-chips { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
||||||
|
.chip.filter-chip { display: inline-flex; gap: 2px; align-items: center; padding: 2px 6px 2px 10px; border-radius: 999px; background: var(--info-soft); border: 1px solid var(--border); font-size: 13px; }
|
||||||
|
.chip.filter-chip a.chip-x { text-decoration: none; color: var(--muted); padding: 0 4px; }
|
||||||
|
.chip.filter-chip a.chip-x:hover { color: var(--err, #c00); }
|
||||||
|
table.recent-queries td.oneline-cell { max-width: 40ch; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
/* Dashboard storage: a bar per disk. */
|
||||||
|
.disk { margin-top: 14px; }
|
||||||
|
.disk-head { display: flex; gap: 6px; align-items: baseline; flex-wrap: wrap; margin-bottom: 4px; }
|
||||||
|
.disk-head .grow { flex: 1; }
|
||||||
|
.disk-bar { display: flex; height: 10px; border-radius: 5px; background: var(--info-soft); overflow: hidden; border: 1px solid var(--border); }
|
||||||
|
.disk-bar .seg.db { background: var(--accent); }
|
||||||
|
.disk-bar .seg.other { background: var(--muted); opacity: 0.45; }
|
||||||
|
.disk-bar .seg.other.full { background: var(--warn); opacity: 0.8; }
|
||||||
|
.swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; vertical-align: -1px; }
|
||||||
|
.swatch.db { background: var(--accent); }
|
||||||
|
.swatch.other { background: var(--muted); opacity: 0.45; }
|
||||||
|
.card .disk-bar { margin: 6px 0 4px; height: 8px; }
|
||||||
|
|
||||||
|
/* Explore: the filter bar. */
|
||||||
|
form.explore-filters { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: center; }
|
||||||
|
form.explore-filters label { color: var(--muted); display: inline-flex; gap: 6px; align-items: center; }
|
||||||
|
.explore-filter-panel { padding-top: 10px; padding-bottom: 10px; }
|
||||||
|
.chip.filter-chip.not strong { text-decoration: line-through; }
|
||||||
|
.bar-list a.bar-hide { font-size: 11px; color: var(--muted); margin-right: 4px; text-decoration: none; opacity: 0.6; }
|
||||||
|
.bar-list li:hover a.bar-hide, .bar-list a.bar-hide:focus { opacity: 1; text-decoration: underline; }
|
||||||
+137
@@ -418,11 +418,37 @@ expect_dig "now every view's" "10.1.1.1" -k $E/external.key linked.adbraun.test
|
|||||||
run "--view all changes a link once" 0 $J record set adbraun.test linked A 10.1.1.2 --view all
|
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"
|
contains "linked with internal"
|
||||||
expect_dig "both views" "10.1.1.2" -k $E/external.key linked.adbraun.test A
|
expect_dig "both views" "10.1.1.2" -k $E/external.key linked.adbraun.test A
|
||||||
|
run "zone diff: internal compared with external" 0 $J zone diff adbraun.test
|
||||||
|
contains "^adbraun.test in internal, compared with external$"
|
||||||
|
contains "^ linked: a difference is a view's own records"
|
||||||
|
contains "^ external: [1-9][0-9]* answered differently, [0-9]* only in internal, [0-9]* only in external, [0-9]* the same"
|
||||||
|
contains "^ANSWERED DIFFERENTLY"
|
||||||
|
contains "^ ns1 A$"
|
||||||
|
contains "^ internal 127.0.0.1 \\[local, id [0-9]*\\]"
|
||||||
|
contains "^ external 203.0.113.1 \\[local, id [0-9]*\\]"
|
||||||
|
contains "the same (--all lists them)"
|
||||||
|
run "zone diff from external" 0 $J zone diff adbraun.test --view external
|
||||||
|
contains "^adbraun.test in external, compared with internal"
|
||||||
|
run "zone diff for one name" 0 $J zone diff adbraun.test ns1 A
|
||||||
|
contains "^ ns1 A$"
|
||||||
|
not_contains "the same (--all"
|
||||||
|
run "a long TXT record" 0 $J record add adbraun.test longtxt TXT "$(printf 'x%.0s' $(seq 300))" --view internal --local
|
||||||
|
run "zone diff --wide shows it whole" 0 $J zone diff adbraun.test longtxt --wide
|
||||||
|
contains "x\\{250\\}"
|
||||||
|
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&with=external&all=1"
|
||||||
|
contains '"linked":true'
|
||||||
|
contains '"base":"internal"'
|
||||||
|
contains '"same_rows":\['
|
||||||
|
run "remove the long TXT" 0 $J record delete adbraun.test longtxt TXT --view internal -y
|
||||||
run "a shared delete" 0 $J record delete adbraun.test linked A --view external -y
|
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
|
expect_dig "gone from internal too" "" linked.adbraun.test A
|
||||||
run "zone unlink" 0 $J zone unlink adbraun.test --view external
|
run "zone unlink" 0 $J zone unlink adbraun.test --view external
|
||||||
run "unlinked: no link column" 0 $J record list adbraun.test --view external
|
run "unlinked: no link column" 0 $J record list adbraun.test --view external
|
||||||
not_contains "shared"
|
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 "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 "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
|
run "copy it into external, linked" 0 $J zone create linkcopy.test --view external --link-with internal
|
||||||
@@ -444,6 +470,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):
|
||||||
@@ -464,6 +497,27 @@ run "acknowledged: info, with the reason" 0 $J exposure
|
|||||||
contains "info *external *security_open_resolver .*acknowledged by .*e2e: on purpose"
|
contains "info *external *security_open_resolver .*acknowledged by .*e2e: on purpose"
|
||||||
run "take it back" 0 $J exposure unack external security_open_resolver
|
run "take it back" 0 $J exposure unack external security_open_resolver
|
||||||
run "close the resolver again" 0 $J option unset allow-recursion --view external
|
run "close the resolver again" 0 $J option unset allow-recursion --view external
|
||||||
|
# Statements told apart by their name (tls NAME, key NAME, ...): setting
|
||||||
|
# one replaces only the one of that name.
|
||||||
|
run "a tls statement for forwarding" 0 $J option set tls 'e2e-quad9 { remote-hostname "dns.quad9.net"; }' --toplevel
|
||||||
|
run "a second one, without --add" 0 $J option set tls 'e2e-cf { remote-hostname "cloudflare-dns.com"; }' --toplevel
|
||||||
|
run "both are there" 0 $J option list --toplevel
|
||||||
|
contains "e2e-quad9"
|
||||||
|
contains "e2e-cf"
|
||||||
|
run "changing one leaves the other" 0 $J option set tls 'e2e-quad9 { remote-hostname "dns11.quad9.net"; }' --toplevel
|
||||||
|
run "list" 0 $J option list --toplevel
|
||||||
|
contains "dns11.quad9.net"
|
||||||
|
contains "e2e-cf"
|
||||||
|
not_contains '"dns.quad9.net"'
|
||||||
|
run "unset needs to know which (exit 1)" 1 $J option unset tls --toplevel
|
||||||
|
contains "say which: jarl option unset tls"
|
||||||
|
run "forwarders over TLS" 0 $J option set forwarders '{ 9.9.9.9 port 853 tls e2e-quad9; 1.1.1.1 port 853 tls e2e-cf; }' --view internal
|
||||||
|
run "named.conf has them" 0 grep -q 'tls e2e-quad9' $E/etc-jarl/named.conf
|
||||||
|
run "take the forwarders out" 0 $J option unset forwarders --view internal
|
||||||
|
run "unset one by its name" 0 $J option unset tls e2e-cf --toplevel
|
||||||
|
run "and the other" 0 $J option unset tls e2e-quad9 --toplevel
|
||||||
|
run "none left" 0 $J option list --toplevel
|
||||||
|
not_contains "e2e-quad9"
|
||||||
run "fix: hide the version" 0 $J exposure fix server exposure_version
|
run "fix: hide the version" 0 $J exposure fix server exposure_version
|
||||||
expect_dig "version.bind is hidden" "" -c CH version.bind TXT
|
expect_dig "version.bind is hidden" "" -c CH version.bind TXT
|
||||||
run "mark external private" 0 $J view update external --exposure private
|
run "mark external private" 0 $J view update external --exposure private
|
||||||
@@ -624,6 +678,75 @@ expect_dig "enabled zone served again" "192.0.2.70" www.parked.test A
|
|||||||
expect_dig "with the change made while disabled" "192.0.2.71" api.parked.test A
|
expect_dig "with the change made while disabled" "192.0.2.71" api.parked.test A
|
||||||
run "delete it" 0 $J zone delete parked.test --view internal --yes
|
run "delete it" 0 $J zone delete parked.test --view internal --yes
|
||||||
|
|
||||||
|
# Reverse zones made easy (docs/reverse.md): suggested from the records'
|
||||||
|
# addresses, created in the right view and filled, PTRs kept in step.
|
||||||
|
run "zone for reverse" 0 $J zone create revtest.test --view internal --ns ns1.adbraun.test.
|
||||||
|
run "its hosts" 0 $J record add revtest.test nas A 10.99.1.10 --view internal
|
||||||
|
run "a second name for nas" 0 $J record add revtest.test files A 10.99.1.10 --view internal
|
||||||
|
run "another host" 0 $J record add revtest.test pi A 10.99.1.11 --view internal
|
||||||
|
run "and IPv6" 0 $J record add revtest.test nas AAAA fdab:99:0:10::10 --view internal
|
||||||
|
run "suggest-reverse" 0 $J zone suggest-reverse
|
||||||
|
contains "^yours *internal *10.99.1.0/24 *1.99.10.in-addr.arpa *2 *10.99.1.10 files.revtest.test, 10.99.1.11 pi.revtest.test"
|
||||||
|
contains "^yours *internal *fdab:99::/48 *0.0.0.0.9.9.0.0.b.a.d.f.ip6.arpa *1"
|
||||||
|
run "create it without a view, filled" 0 $J zone create 10.99.1.0/24 --fill
|
||||||
|
contains "10.99.1.10 has several names"
|
||||||
|
run "it went to the internal view" 0 $J zone show 1.99.10.in-addr.arpa
|
||||||
|
contains "^View: *internal"
|
||||||
|
contains "^Network: *10.99.1.0/24"
|
||||||
|
not_contains "Missing PTRs"
|
||||||
|
expect_dig "filled: PTR for pi" "pi.revtest.test." -x 10.99.1.11
|
||||||
|
expect_dig "filled: one name for nas" "files.revtest.test." -x 10.99.1.10
|
||||||
|
run "an empty /48 zone" 0 $J zone create fdab:99::/48
|
||||||
|
run "shows what it lacks" 0 $J zone show fdab:99::/48
|
||||||
|
contains "^Missing PTRs: *1 "
|
||||||
|
run "fill-ptr" 0 $J zone fill-ptr fdab:99::/48
|
||||||
|
expect_dig "filled later: IPv6 PTR" "nas.revtest.test." -x fdab:99:0:10::10
|
||||||
|
run "fill-ptr again: nothing to add (exit 6)" 6 $J zone fill-ptr fdab:99::/48
|
||||||
|
contains "nothing to add"
|
||||||
|
run "keep PTRs in step" 0 $J zone update revtest.test --view internal --auto-ptr
|
||||||
|
run "zone show says so" 0 $J zone show revtest.test --view internal
|
||||||
|
contains "^PTR records: *kept in step"
|
||||||
|
run "a new host" 0 $J record add revtest.test cam A 10.99.1.12 --view internal
|
||||||
|
expect_dig "its PTR came along" "cam.revtest.test." -x 10.99.1.12
|
||||||
|
CAM=$($J record list revtest.test --view internal -o json | python3 -c 'import json,sys; print([r["id"] for r in json.load(sys.stdin) if r["name"].startswith("cam.")][0])')
|
||||||
|
run "the host moves" 0 $J record update $CAM --data 10.99.1.13
|
||||||
|
expect_dig "PTR followed: new address" "cam.revtest.test." -x 10.99.1.13
|
||||||
|
expect_dig "PTR followed: old address gone" "" -x 10.99.1.12
|
||||||
|
run "the host goes" 0 $J record delete --id $CAM
|
||||||
|
expect_dig "PTR gone with it" "" -x 10.99.1.13
|
||||||
|
run "a web host's address" 0 $J record add revtest.test blog A 185.199.108.153 --view internal
|
||||||
|
run "suggested as public" 0 $J zone suggest-reverse
|
||||||
|
contains "^public *internal *185.199.108.153/32"
|
||||||
|
run "two more web host addresses" 0 $J record add revtest.test docs A 185.199.109.153 --view internal
|
||||||
|
run "and" 0 $J record add revtest.test pages A 185.199.110.153 --view internal
|
||||||
|
run "dismiss three at once" 0 $J zone suggest-reverse --dismiss 185.199.108.153/32,185.199.109.153/32 --dismiss 185.199.110.153/32 --view internal
|
||||||
|
run "gone from the suggestions" 0 $J zone suggest-reverse
|
||||||
|
not_contains "185.199."
|
||||||
|
run "suggest-reverse --create: yours only" 0 $J zone suggest-reverse --create
|
||||||
|
run "none of yours missing now" 0 $J zone suggest-reverse
|
||||||
|
not_contains "^yours"
|
||||||
|
run "a view can turn suggestions off" 0 $J view update internal --reverse-suggestions=false
|
||||||
|
run "then none for it" 0 $J zone suggest-reverse
|
||||||
|
not_contains "internal"
|
||||||
|
run "but --all still lists its networks" 0 $J zone suggest-reverse --all --view internal
|
||||||
|
contains "^public *internal *185.199.108.153/32 .* suggestions off"
|
||||||
|
run "and back on" 0 $J view update internal --reverse-suggestions
|
||||||
|
# An address only the external view has a record for: a reverse zone in
|
||||||
|
# the internal view can still name it.
|
||||||
|
run "an AAAA only in external" 0 $J record add adbraun.test vps AAAA 2600:3c01::f03c:93ff:fe2b:1 --view external
|
||||||
|
run "offered for internal" 0 $J zone suggest-reverse --all --view internal
|
||||||
|
contains "2600:3c01::f03c:93ff:fe2b:1/128 .*records in external"
|
||||||
|
run "created in internal, filled from external's record" 0 $J zone create 2600:3c01::f03c:93ff:fe2b:1/128 --view internal --fill
|
||||||
|
expect_dig "internal answers its PTR" "vps.adbraun.test." -x 2600:3c01::f03c:93ff:fe2b:1
|
||||||
|
# A zone created inside another takes the parent's records under it.
|
||||||
|
run "a /16 reverse zone" 0 $J zone create 10.88.0.0/16 --view internal
|
||||||
|
run "with a PTR made by hand" 0 $J record add 10.88.0.0/16 10.88.5.5 PTR printer.revtest.test --view internal
|
||||||
|
expect_dig "the /16 answers" "printer.revtest.test." -x 10.88.5.5
|
||||||
|
run "a /24 inside it" 0 $J zone create 10.88.5.0/24 --view internal
|
||||||
|
run "the PTR moved" 0 $J record list 10.88.5.0/24 --view internal
|
||||||
|
contains "printer.revtest.test."
|
||||||
|
expect_dig "and still answers, from the /24" "printer.revtest.test." -x 10.88.5.5
|
||||||
|
|
||||||
# Blocking (docs/blocking.md). It only changes answers to recursive
|
# Blocking (docs/blocking.md). It only changes answers to recursive
|
||||||
# queries, so these ask with recursion and look at which policy zone (if
|
# queries, so these ask with recursion and look at which policy zone (if
|
||||||
# any) the answer came from.
|
# any) the answer came from.
|
||||||
@@ -792,6 +915,20 @@ if [ -n "$PEBBLE" ]; then
|
|||||||
contains "^192.168.1.20$"
|
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
|
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$"
|
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 "cert show: what named uses it for" 0 $J cert show ns1
|
||||||
|
contains "^named: *DNS over TLS on 127.0.0.1 port $EDOT"
|
||||||
|
contains "^named: *DNS over HTTPS on 127.0.0.1 port $EDOH"
|
||||||
|
run "per address views: 127.0.0.1 only in internal" 0 $J encrypted-dns setup --hostname ns1.adbraun.test --address 127.0.0.1=internal --dot-port $EDOT --doh-port $EDOH
|
||||||
|
run "status shows where the records are" 0 $J encrypted-dns
|
||||||
|
contains "^Records: *127.0.0.1 in internal$"
|
||||||
|
contains "^Host names: *ns1.adbraun.test$"
|
||||||
run "a certificate named uses can't be deleted (exit 6)" 6 $J cert delete ns1 --yes
|
run "a certificate named uses can't be deleted (exit 6)" 6 $J cert delete ns1 --yes
|
||||||
contains "take it out first"
|
contains "take it out first"
|
||||||
run "encrypted-dns disable" 0 $J encrypted-dns disable
|
run "encrypted-dns disable" 0 $J encrypted-dns disable
|
||||||
|
|||||||
Reference in new issue
Block a user