Compare commits

...
Author SHA1 Message Date
adamandClaude Opus 5.5 71d9a06e43 Release 0.6.1-rc.3
release / release (push) Successful in 4m39s
0.6.1's third release candidate: reverse zones list the addresses only
other views' records use, with clearer wording, and jarl zone show lists
missing PTR records.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:46:12 +01:00
adamandClaude Opus 5.5 2fa9828bcf Reverse zones: list other views' addresses, clearer wording
The zone page and jarl zone show list the addresses only other views'
records use (ptr_other_views_list), with the name each would get and the
view it comes from, and say plainly that they aren't missing here. The
page's button reads 'Add PTR records for them in view X'. zone show
lists the missing PTRs too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:46:12 +01:00
13 changed files with 103 additions and 24 deletions

No files matched your search

+3 -1
View File
@@ -52,7 +52,9 @@ All notable changes to Jarl are documented here. The format follows
(`fill_other_views`, `?other_views=1`, a checkbox in the New reverse (`fill_other_views`, `?other_views=1`, a checkbox in the New reverse
zone dialog), so one view's host names don't leak into another. When zone dialog), so one view's host names don't leak into another. When
there is nothing else to fill, Jarl says how many there are and how to there is nothing else to fill, Jarl says how many there are and how to
add them. add them. The page and `jarl zone show` list those addresses with the
name each would get, and the view it comes from
(`ptr_other_views_list`); `jarl zone show` lists the missing ones too.
- Records, zones and validation findings sort numeric labels by value, so - Records, zones and validation findings sort numeric labels by value, so
`2.10.0.10.in-addr.arpa` comes before `101.10.0.10.in-addr.arpa` in the `2.10.0.10.in-addr.arpa` comes before `101.10.0.10.in-addr.arpa` in the
+1 -1
View File
@@ -203,7 +203,7 @@ review step.
## Status ## Status
Version 0.6.0 is the current release, and 0.6.1 is in testing Version 0.6.0 is the current release, and 0.6.1 is in testing
(0.6.1-rc.2): it gets certificates onto your other hosts (`jarl cert (0.6.1-rc.3): it gets certificates onto your other hosts (`jarl cert
get`: Jarl obtains and renews them, a host downloads them and runs a get`: Jarl obtains and renews them, a host downloads them and runs a
hook, a daily timer keeps them current), exports certificates, fixes hook, a daily timer keeps them current), exports certificates, fixes
PTR records and overrides in linked zones, and makes one-view tokens PTR records and overrides in linked zones, and makes one-view tokens
+1 -1
View File
@@ -1 +1 @@
0.6.1-rc.2 0.6.1-rc.3
+42 -1
View File
@@ -8,6 +8,7 @@ import (
"net/netip" "net/netip"
"net/url" "net/url"
"os" "os"
"slices"
"strconv" "strconv"
"strings" "strings"
"unicode/utf8" "unicode/utf8"
@@ -100,9 +101,16 @@ func zoneCmd() *cobra.Command {
fmt.Fprintf(w, "Network:\t%s\n", z.Prefix) fmt.Fprintf(w, "Network:\t%s\n", z.Prefix)
if z.PTRMissing > 0 { 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]) 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])
printPTRFills(w, z.PTRMissingList, z.PTRMissing)
} }
if z.PTROtherViews > 0 { if z.PTROtherViews > 0 {
fmt.Fprintf(w, "Other views:\t%d addresses here only other views' records use, without a PTR record (to name them from there: jarl zone fill-ptr %s --other-views)\n", z.PTROtherViews, args[0]) what := "addresses"
if z.PTROtherViews == 1 {
what = "address"
}
fmt.Fprintf(w, "Other views:\t%d %s with A or AAAA records only in %s, so not missing here (add PTRs for them in this view too: jarl zone fill-ptr %s --other-views)\n",
z.PTROtherViews, what, fromViews(z.PTROtherViewsList), args[0])
printPTRFills(w, z.PTROtherViewsList, z.PTROtherViews)
} }
} }
if len(z.LinkedWith) > 0 { if len(z.LinkedWith) > 0 {
@@ -1174,6 +1182,39 @@ func terminalWidth() int {
return width return width
} }
// printPTRFills lists PTR records a reverse zone lacks under zone show's
// line about them: address -> name, and the view the name is from.
func printPTRFills(w io.Writer, fills []api.PTRFill, total int) {
for _, f := range fills {
from := ""
if f.From != "" {
from = " (view " + f.From + ")"
}
fmt.Fprintf(w, "\t %s -> %s%s\n", f.Address, dnsname.Display(f.Target), from)
}
if total > len(fills) {
fmt.Fprintf(w, "\t and %d more\n", total-len(fills))
}
}
// fromViews names the views PTR fills take their names from: "view
// internal", "views internal and lab".
func fromViews(fills []api.PTRFill) string {
var vs []string
for _, f := range fills {
if f.From != "" && !slices.Contains(vs, f.From) {
vs = append(vs, f.From)
}
}
switch len(vs) {
case 0:
return "other views"
case 1:
return "view " + vs[0]
}
return "views " + strings.Join(vs[:len(vs)-1], ", ") + " and " + vs[len(vs)-1]
}
func zoneFillPTRCmd() *cobra.Command { func zoneFillPTRCmd() *cobra.Command {
var view string var view string
var otherViews bool var otherViews bool
+4 -1
View File
@@ -215,7 +215,10 @@ A reverse zone created without a view goes to the private view whose
records use its addresses. Zones come back with `prefix` (reverse zones: records use its addresses. Zones come back with `prefix` (reverse zones:
the network), `ptr_missing` (addresses in it that the view's A and AAAA the network), `ptr_missing` (addresses in it that the view's A and AAAA
records use, without a PTR record) and `ptr_other_views` (addresses in it records use, without a PTR record) and `ptr_other_views` (addresses in it
without a PTR record that only other views' records use; not missing). without a PTR record that only other views' records use; not missing),
with the first 25 of each in `ptr_missing_list` and
`ptr_other_views_list` (`address`, `name`, `target`, and `from`, the view
the name comes from).
`/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)).
+13 -3
View File
@@ -147,9 +147,19 @@ zone show` shows how many as `Missing PTRs`; the API lists them in
`ptr_missing_list`. Only the zone's own view's records count: an address `ptr_missing_list`. Only the zone's own view's records count: an address
that only another view's records use isn't missing (say internal's that only another view's records use isn't missing (say internal's
`claude.adbraun.lol` and the vps view's copy of `10.0.10.in-addr.arpa`). `claude.adbraun.lol` and the vps view's copy of `10.0.10.in-addr.arpa`).
Those are shown apart, as `Other views` in `jarl zone show` Those are listed apart, with the name each would get: `Other views` in
(`ptr_other_views` in the API) and a quiet note with a **Name them from `jarl zone show`, `ptr_other_views` and `ptr_other_views_list` in the
there** button on the page; filling never adds them unasked. API, and a quiet note on the page with an **Add PTR records for them in
view vps** button. Filling never adds them unasked. `jarl zone show`
lists the missing ones too:
```
Missing PTRs: 1 (addresses the view's records use here; add them: jarl zone fill-ptr 10.0.10.0/24)
10.0.10.7 -> printer.adbraun.lol
Other views: 2 addresses with A or AAAA records only in view internal, so not missing here (add PTRs for them in this view too: jarl zone fill-ptr 10.0.10.0/24 --other-views)
10.0.10.26 -> gomft.adbraun.lol (view internal)
10.0.10.101 -> claude.adbraun.lol (view internal)
```
## Keeping PTR records in step ## Keeping PTR records in step
+4 -2
View File
@@ -50,8 +50,10 @@ type Zone struct {
PTRMissingList []PTRFill `json:"ptr_missing_list,omitempty"` PTRMissingList []PTRFill `json:"ptr_missing_list,omitempty"`
// PTROtherViews counts the addresses in it, without a PTR record, // PTROtherViews counts the addresses in it, without a PTR record,
// that only other views' records use: not missing, but fill-ptr with // that only other views' records use: not missing, but fill-ptr with
// other_views=1 can name them from there. // other_views=1 adds them, naming them from there. PTROtherViewsList
PTROtherViews int `json:"ptr_other_views,omitempty"` // are the first 25 (From says which view).
PTROtherViews int `json:"ptr_other_views,omitempty"`
PTROtherViewsList []PTRFill `json:"ptr_other_views_list,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"`
} }
+1 -1
View File
@@ -489,7 +489,7 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
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, 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)))
} }
if !fillOther { if !fillOther {
if h := otherViewsHint(x.st, vid, name, moved, len(fills), "to name them from there, fill it with other views (jarl zone fill-ptr ZONE --other-views)"); h != "" { if h := otherViewsHint(x.st, vid, name, moved, len(fills), "to add PTR records for them in this view too: jarl zone fill-ptr ZONE --other-views"); h != "" {
x.warnings = append(x.warnings, h) x.warnings = append(x.warnings, h)
} }
} }
+4 -4
View File
@@ -207,11 +207,11 @@ func otherViewsHint(st *model.State, viewID int64, zoneName string, existing []*
if n <= 0 { if n <= 0 {
return "" return ""
} }
what := "addresses" what, has, they := "addresses", "have", "they are"
if n == 1 { if n == 1 {
what = "address" what, has, they = "address", "has", "it is"
} }
return fmt.Sprintf("%d %s in %s only other views' records use; %s", n, what, dnsname.Display(zoneName), how) return fmt.Sprintf("%d %s in %s %s A or AAAA records only in other views, so %s not missing here; %s", n, what, dnsname.Display(zoneName), has, they, how)
} }
// zoneFillPTR expands zone.fill_ptr: the PTR records a reverse zone lacks. // zoneFillPTR expands zone.fill_ptr: the PTR records a reverse zone lacks.
@@ -238,7 +238,7 @@ func (x *expander) zoneFillPTR(req Request) ([]model.Op, error) {
if len(fills) == 0 { if len(fills) == 0 {
msg := fmt.Sprintf("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)) msg := fmt.Sprintf("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))
if !in.OtherViews { if !in.OtherViews {
if h := otherViewsHint(x.st, z.ViewID, z.Name, x.st.ZoneRecords(z.ID), 0, "to name them from there, fill with other views (--other-views)"); h != "" { if h := otherViewsHint(x.st, z.ViewID, z.Name, x.st.ZoneRecords(z.ID), 0, "to add PTR records for them in this view too: --other-views"); h != "" {
msg += "; " + h msg += "; " + h
} }
} }
+2
View File
@@ -110,6 +110,8 @@ func TestPTROtherViews(t *testing.T) {
} }
if z := e.do("GET", "/zones/10.0.10.in-addr.arpa?view=vps", e.token, nil, 200); z["ptr_missing"] != nil || z["ptr_other_views"] != float64(1) { if z := e.do("GET", "/zones/10.0.10.in-addr.arpa?view=vps", e.token, nil, 200); z["ptr_missing"] != nil || z["ptr_other_views"] != float64(1) {
t.Errorf("vps: ptr_missing %v, ptr_other_views %v", z["ptr_missing"], z["ptr_other_views"]) t.Errorf("vps: ptr_missing %v, ptr_other_views %v", z["ptr_missing"], z["ptr_other_views"])
} else if l, _ := z["ptr_other_views_list"].([]any); len(l) != 1 || l[0].(map[string]any)["address"] != "10.0.10.101" || l[0].(map[string]any)["target"] != "claude.adbraun.lol." || l[0].(map[string]any)["from"] != "internal" {
t.Errorf("vps: ptr_other_views_list %v", z["ptr_other_views_list"])
} }
e.do("POST", "/zones/10.0.10.in-addr.arpa/fill-ptr?view=vps", e.token, nil, 400) e.do("POST", "/zones/10.0.10.in-addr.arpa/fill-ptr?view=vps", e.token, nil, 400)
e.do("POST", "/zones/10.0.10.in-addr.arpa/fill-ptr?view=vps&other_views=1", e.token, nil, 201) e.do("POST", "/zones/10.0.10.in-addr.arpa/fill-ptr?view=vps&other_views=1", e.token, nil, 201)
+10 -3
View File
@@ -43,12 +43,19 @@ func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]re
if z.Type == model.ZonePrimary { if z.Type == model.ZonePrimary {
fills := engine.PTRFills(st, z.ViewID, z.Name, st.ZoneRecords(z.ID), false) fills := engine.PTRFills(st, z.ViewID, z.Name, st.ZoneRecords(z.ID), false)
out.PTRMissing = len(fills) out.PTRMissing = len(fills)
if z.ViewID != 0 {
out.PTROtherViews = len(engine.PTRFills(st, z.ViewID, z.Name, st.ZoneRecords(z.ID), true)) - len(fills)
}
for _, f := range fills[:min(len(fills), 25)] { for _, f := range fills[:min(len(fills), 25)] {
out.PTRMissingList = append(out.PTRMissingList, api.PTRFill(f)) out.PTRMissingList = append(out.PTRMissingList, api.PTRFill(f))
} }
if z.ViewID != 0 {
for _, f := range engine.PTRFills(st, z.ViewID, z.Name, st.ZoneRecords(z.ID), true) {
if f.From == "" {
continue
}
if out.PTROtherViews++; out.PTROtherViews <= 25 {
out.PTROtherViewsList = append(out.PTROtherViewsList, api.PTRFill(f))
}
}
}
} }
} }
for _, o := range st.LinkedWith(z) { for _, o := range st.LinkedWith(z) {
+15 -4
View File
@@ -73,9 +73,20 @@ export async function render(el, params, query) {
f.from ? h("span", { class: "muted" }, " (from view " + f.from + ")") : null, f.from ? h("span", { class: "muted" }, " (from view " + f.from + ")") : null,
f.others && f.others.length ? h("span", { class: "muted" }, " (also " + f.others.map(display).join(", ") + ")") : null)), f.others && f.others.length ? h("span", { class: "muted" }, " (also " + f.others.map(display).join(", ") + ")") : null)),
zone.ptr_missing > (zone.ptr_missing_list || []).length ? h("li", { class: "muted" }, "and " + (zone.ptr_missing - zone.ptr_missing_list.length) + " more") : null)) zone.ptr_missing > (zone.ptr_missing_list || []).length ? h("li", { class: "muted" }, "and " + (zone.ptr_missing - zone.ptr_missing_list.length) + " more") : null))
: zone.ptr_other_views && can("zones", true) ? h("div", { class: "muted small" }, : null;
plural(zone.ptr_other_views, "address", "addresses") + " in " + zone.prefix + " only other views' records use, without a PTR record here. ", // Addresses only other views have records for: not missing here, but
h("button", { class: "small", title: "Adds PTR records naming them from the other views' records, publishing those names in view " + zone.view, onclick: () => fillPTR(zone, true) }, "Name them from there")) : null; // they can be added (publishing those views' names in this one).
const others = zone.ptr_other_views_list || [];
const fromViews = [...new Set(others.map((f) => f.from))];
const otherNote = !zone.disabled && zone.ptr_other_views && can("zones", true) ? h("div", { class: "muted small" },
plural(zone.ptr_other_views, "address", "addresses") + " in " + zone.prefix + " " + (zone.ptr_other_views === 1 ? "has" : "have") + " A or AAAA records only in view " + fromViews.join(", ") +
", not in view " + zone.view + ", so " + (zone.ptr_other_views === 1 ? "it isn't" : "they aren't") + " counted as missing here. ",
h("button", { class: "small", title: "Clients of view " + zone.view + " will then get these names, which come from view " + fromViews.join(", "), onclick: () => fillPTR(zone, true) },
"Add PTR records for " + (zone.ptr_other_views === 1 ? "it" : "them") + " in view " + zone.view),
h("ul", { class: "small ptr-missing" }, others.map((f) => h("li", null,
h("span", { class: "mono" }, f.address), " → ", h("span", { class: "mono" }, display(f.target)),
fromViews.length > 1 ? h("span", { class: "muted" }, " (view " + f.from + ")") : null)),
zone.ptr_other_views > others.length ? h("li", { class: "muted" }, "and " + (zone.ptr_other_views - others.length) + " more") : null)) : 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
@@ -86,7 +97,7 @@ export async function render(el, params, query) {
holder.classList.add("stale"); holder.classList.add("stale");
body.className = "zone-body"; body.className = "zone-body";
body.dataset.zone = id; body.dataset.zone = id;
mount(el, head, banner, tabBar, holder); mount(el, head, banner, otherNote, tabBar, holder);
if (tab === "records") await recordsTab(body, zone, query); if (tab === "records") await recordsTab(body, zone, query);
else if (tab === "settings") await settingsTab(body, zone); else if (tab === "settings") await settingsTab(body, zone);
else if (tab === "options") await optionsEditor(body, { scope: "zone", scope_id: zone.id, catalogScope: "zone:" + zone.type, title: "Zone options" }); else if (tab === "options") await optionsEditor(body, { scope: "zone", scope_id: zone.id, catalogScope: "zone:" + zone.type, title: "Zone options" });
+3 -2
View File
@@ -706,10 +706,11 @@ contains "nothing to add"
# The same network in another view: internal's hosts aren't missing there, # The same network in another view: internal's hosts aren't missing there,
# and are named from internal only when asked. # and are named from internal only when asked.
run "the network in external too, filled" 0 $J zone create 10.99.1.0/24 --view external --fill run "the network in external too, filled" 0 $J zone create 10.99.1.0/24 --view external --fill
contains "only other views' records use" contains "A or AAAA records only in other views"
run "nothing missing there" 0 $J zone show 1.99.10.in-addr.arpa --view external run "nothing missing there" 0 $J zone show 1.99.10.in-addr.arpa --view external
not_contains "Missing PTRs" not_contains "Missing PTRs"
contains "^Other views: *2 addresses" contains "^Other views: *2 addresses with A or AAAA records only in view internal"
contains "10.99.1.11 -> pi.revtest.test (view internal)"
run "fill-ptr there: nothing of its own (exit 6)" 6 $J zone fill-ptr 10.99.1.0/24 --view external run "fill-ptr there: nothing of its own (exit 6)" 6 $J zone fill-ptr 10.99.1.0/24 --view external
contains "--other-views" contains "--other-views"
run "fill-ptr --other-views" 0 $J zone fill-ptr 10.99.1.0/24 --view external --other-views run "fill-ptr --other-views" 0 $J zone fill-ptr 10.99.1.0/24 --view external --other-views