From 12013b12d5997ac023a54ee852c3b78f702c675b Mon Sep 17 00:00:00 2001 From: adam Date: Sun, 18 Oct 2026 16:58:40 +0100 Subject: [PATCH] Stats heatmap: empty hours same height; cname_chain only for long chains The "Busiest times" grid's hours without data used the class "empty", which picked up the padding of empty messages. One CNAME to another no longer warns; three or more get an info note showing the chain. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 +++++++++ docs/validation.md | 2 +- internal/validate/records.go | 16 ++++++++++++---- internal/validate/validate_test.go | 18 ++++++++++++++++-- internal/web/static/assets/charts.js | 4 ++-- internal/web/static/assets/style.css | 2 +- 6 files changed, 41 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf29823..393f650 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,11 @@ All notable changes to Jarl are documented here. The format follows ### Changed +- `cname_chain` no longer warns about one CNAME pointing at another (a + service name at a host alias is common, and named answers the chain at + once). A chain of three or more CNAMEs gets an info note that shows + it. + - A certificate's private key is only exported over HTTPS, the local socket, or to the server itself (plain HTTP from another host is refused with `https_required`). @@ -70,6 +75,10 @@ All notable changes to Jarl are documented here. The format follows ### Fixed +- The statistics' "Busiest times" grid drew hours without data taller + than the others (they picked up the page's padding for empty + messages). + - A token for one view of a linked zone (such as an ACME token with `views=external`) couldn't add records: a new record is shared, and that reached the other view. A new name and type is now local to the diff --git a/docs/validation.md b/docs/validation.md index 21e4923..1d644a6 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -34,7 +34,7 @@ showing them. | `cname_multiple` | error | A name can have only one CNAME | | `cname_apex` | error | No CNAME at the zone apex (it needs SOA and NS) | | `cname_loop` | error | CNAMEs that point back to themselves | -| `cname_chain` | warning | CNAME to a CNAME: works, but slows resolution | +| `cname_chain` | info | a chain of three or more CNAMEs (one CNAME to another says nothing): works, but each is another lookup for some resolvers | | `cname_dangling` | warning | CNAME to a name that doesn't exist in the zone | | `dname_multiple`, `below_dname` | error | One DNAME per name, and nothing below a DNAME (RFC 6672) | | `apex_ns_missing` | error | A zone needs NS records at its apex | diff --git a/internal/validate/records.go b/internal/validate/records.go index 03bcd08..66fd9a1 100644 --- a/internal/validate/records.go +++ b/internal/validate/records.go @@ -385,8 +385,16 @@ func (c *checker) checkCNAMETarget(z *model.Zone, ix *zoneIndex, r *model.Record if !ix.inZoneTarget(target) { return } - // Follow the chain inside the zone to detect loops. + // Follow the chain inside the zone to detect loops. One CNAME to + // another is common and costs nothing (named answers the chain in + // one response); a longer chain gets a note. seen := map[string]bool{r.Name: true} + chain := []string{disp(r.Name), disp(target)} + note := func() { + if len(chain) > 3 { + c.recProblem(z, r, Info, "cname_chain", "%s reaches its address through %d CNAMEs (%s); fine, but each one is another lookup for resolvers that don't get the chain at once", disp(r.Name), len(chain)-1, strings.Join(chain, " → ")) + } + } cur := target for hops := 0; hops < 20; hops++ { if seen[cur] { @@ -398,18 +406,18 @@ func (c *checker) checkCNAMETarget(z *model.Zone, ix *zoneIndex, r *model.Record if len(next) == 0 { break } - if hops == 0 { - c.recProblem(z, r, Warning, "cname_chain", "%s is a CNAME to %s, which is itself a CNAME; chains slow down resolution", disp(r.Name), disp(target)) - } p := ix.parsed[next[0].ID] if p == nil { break } cur = p.RR.(*dns.CNAME).Target + chain = append(chain, disp(cur)) if !ix.inZoneTarget(cur) { + note() return } } + note() if len(ix.byName[target]) == 0 && !ix.hasAddress(target) && !hasDescendant(ix, target) { c.recProblem(z, r, Warning, "cname_dangling", "%s is a CNAME to %s, which does not exist in the zone", disp(r.Name), disp(target)) } diff --git a/internal/validate/validate_test.go b/internal/validate/validate_test.go index df74447..92d9180 100644 --- a/internal/validate/validate_test.go +++ b/internal/validate/validate_test.go @@ -161,8 +161,9 @@ func TestRecordConflicts(t *testing.T) { }, "cname_loop", Error}, {"cname chain", func(b *builder, z *model.Zone) { b.rec(z, "a", "CNAME", "b") - b.rec(z, "b", "CNAME", "ns1") - }, "cname_chain", Warning}, + b.rec(z, "b", "CNAME", "c") + b.rec(z, "c", "CNAME", "ns1") + }, "cname_chain", Info}, {"dangling cname", func(b *builder, z *model.Zone) { b.rec(z, "a", "CNAME", "nothing") }, "cname_dangling", Warning}, {"null mx not alone", func(b *builder, z *model.Zone) { b.rec(z, "@", "MX", "0 .") @@ -380,3 +381,16 @@ func TestProblemStringDisplaysZone(t *testing.T) { t.Errorf("String() = %q, want %q", got, want) } } + +// One CNAME to another is common and says nothing. +func TestShortCNAMEChain(t *testing.T) { + b := newBuilder() + z := b.primary("example.com", nil) + b.rec(z, "songshare", "CNAME", "navidrome") + b.rec(z, "navidrome", "CNAME", "ns1") + for _, p := range State(b.s, Options{}) { + if p.Code == "cname_chain" { + t.Errorf("one extra CNAME: %s %s", p.Severity, p.Message) + } + } +} diff --git a/internal/web/static/assets/charts.js b/internal/web/static/assets/charts.js index 0624035..4960989 100644 --- a/internal/web/static/assets/charts.js +++ b/internal/web/static/assets/charts.js @@ -283,7 +283,7 @@ export function heatmap(hm, unit) { const n = hm.samples[d][hr]; const text = v < 0 ? day + " " + pad(hr) + ":00 – no data" : day + " " + pad(hr) + ":00 – " + Math.round(v).toLocaleString() + " " + unit + " per hour (average of " + n + (n === 1 ? " hour)" : " hours)"); - const cell = h("div", { class: "hm-cell " + (v < 0 ? "empty" : "heat-" + level(v)), tabindex: "-1", "aria-label": text }); + const cell = h("div", { class: "hm-cell " + (v < 0 ? "hm-empty" : "heat-" + level(v)), tabindex: "-1", "aria-label": text }); const showTip = () => { tip.textContent = text; tip.hidden = false; @@ -313,7 +313,7 @@ export function heatmap(hm, unit) { h("span", { class: "muted small" }, "0"), Array.from({ length: steps }, (_, i) => h("span", { class: "hm-swatch heat-" + i })), h("span", { class: "muted small" }, compact(peak) + " " + unit + "/hour"), - h("span", { class: "hm-swatch empty" }), h("span", { class: "muted small" }, "no data")); + h("span", { class: "hm-swatch hm-empty" }), h("span", { class: "muted small" }, "no data")); return h("div", { class: "heatmap-wrap" }, grid, tip, scale); } diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index c6fa914..9082304 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -398,7 +398,7 @@ td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; } .hm-row { display: flex; align-items: center; } .hm-cell { height: 22px; border-radius: 3px; } .hm-cell:hover, .hm-cell:focus { outline: 2px solid var(--text); outline-offset: -1px; } -.hm-cell.empty, .hm-swatch.empty { background: transparent; box-shadow: inset 0 0 0 1px var(--chart-grid); } +.hm-empty { background: transparent; box-shadow: inset 0 0 0 1px var(--chart-grid); } .heat-0 { background: var(--heat-0); } .heat-1 { background: var(--heat-1); } .heat-2 { background: var(--heat-2); } .heat-3 { background: var(--heat-3); } .heat-4 { background: var(--heat-4); } .heat-5 { background: var(--heat-5); } .heat-6 { background: var(--heat-6); }