Fix the dashboard's pending-changes count; explain zones in the docs

The dashboard counted every open changeset, including empty ones and
other users', so "Pending changes (1 open)" led to an empty Changes page.
It now counts the user's own changesets that contain changes, the status
API's open_changesets skips empty ones, and in review mode a draft created
for an edit the server rejects is discarded instead of left open.

docs/concepts.md: zone types, a new zone's name servers and glue, reverse
zones for any network, and dynamic updates versus API tokens and TSIG keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-08 11:52:10 +00:00
1 parent 785b0006f9
commit 5cb8602a7f
5 files changed
+104 -6

No files matched your search

+9
View File
@@ -24,6 +24,9 @@ The first version, to be released as 0.1.0.
the web UI. Networks smaller than a /24 explain that they belong in the the web UI. Networks smaller than a /24 explain that they belong in the
enclosing /24's zone. enclosing /24's zone.
- docs/operations.md: setting up a secondary server with its own jarld. - docs/operations.md: setting up a secondary server with its own jarld.
- docs/concepts.md: what each zone type does, what to enter as a new zone's
name servers (and when glue addresses are needed), how reverse zones map
to networks, and why dynamic updates give way to API tokens.
- A logo: a Viking jarl drawn like a woodcut, with a spear-pointed crown - A logo: a Viking jarl drawn like a woodcut, with a spear-pointed crown
that holds a DNS tree (a root node and its branches), a stern face and a that holds a DNS tree (a root node and its branches), a stern face and a
@@ -163,6 +166,12 @@ The first version, to be released as 0.1.0.
### Fixed ### Fixed
- The dashboard's "Pending changes (1 open)" counted empty changesets (and
other users'), so the Changes page then showed none. It now counts your
own changesets that contain changes, and the status API's
`open_changesets` only counts non-empty ones. In review mode, a draft
created for an edit that the server rejects is discarded instead of
being left open and empty.
- Under the shipped systemd unit (`RestrictSUIDSGID=yes`), the first zone - Under the shipped systemd unit (`RestrictSUIDSGID=yes`), the first zone
in a new view failed with "chmod /var/lib/bind/jarl/VIEW: operation not in a new view failed with "chmod /var/lib/bind/jarl/VIEW: operation not
permitted": setting the setgid bit on the new directory is refused there. permitted": setting the setgid bit on the new directory is refused there.
+66 -1
View File
@@ -140,7 +140,8 @@ RFC 1982 arithmetic.
another view). Only primary zones have records in Jarl. The same zone name another view). Only primary zones have records in Jarl. The same zone name
can exist in several views (split DNS); copy one with `clone_from`. can exist in several views (split DNS); copy one with `clone_from`.
A CIDR such as `192.168.1.0/24` or `2001:db8::/48` creates the matching A CIDR such as `192.168.1.0/24` or `2001:db8::/48` creates the matching
reverse zone. reverse zone (see [Zone types](#zone-types) and
[Reverse zones](#reverse-zones) below).
* **Records** are stored canonically (owner FQDN, type, TTL or "zone * **Records** are stored canonically (owner FQDN, type, TTL or "zone
default", data in presentation format). TXT data is entered as plain text; default", data in presentation format). TXT data is entered as plain text;
Jarl quotes it and splits it into 255-byte strings. Relative names are Jarl quotes it and splits it into 255-byte strings. Relative names are
@@ -161,6 +162,70 @@ RFC 1982 arithmetic.
* **Settings** are Jarl's defaults for new zones: name servers, SOA contact * **Settings** are Jarl's defaults for new zones: name servers, SOA contact
and timers, default TTL, serial policy. and timers, default TTL, serial policy.
### Zone types
Every type works for a domain as well as a reverse zone; a reverse zone is
just a zone with a special name.
| Type | What it does | Typical use |
|------|--------------|-------------|
| `primary` | This server holds the zone; Jarl manages its records | Almost every zone you create |
| `secondary` | Copies the zone from another server by zone transfer (AXFR/IXFR) and serves the copy | A backup name server ([operations.md](operations.md#a-secondary-server)) |
| `forward` | Sends queries for names in the zone to other resolvers (`forwarders`) | Names your router or Consul answers, e.g. forward `10.0.10.in-addr.arpa` to the router |
| `stub` | Keeps only the zone's NS records, fetched from its primaries, to find the right servers | Internal zones held elsewhere that aren't delegated publicly |
| `static-stub` | Like stub, but you list the server addresses (`server-addresses`) | The same, without asking the primaries |
| `mirror` | A copy that named validates with DNSSEC before using | The root zone (RFC 8806) |
| `hint` | The root hints file, so the resolver can find the root servers | Zone `.` only |
| `redirect` | Answers given instead of NXDOMAIN | Rarely; one per server, named `.` |
| `in-view` | Uses a zone defined in an earlier view instead of a copy | Split DNS where a zone is identical in two views |
Only primary zones have records in Jarl; the others are configuration.
### A new zone's name servers
Every primary zone needs at least one NS record, or named won't load it.
When creating a zone:
* **Name servers**: the host name(s) of the server(s) answering for the
zone, normally this server, e.g. `ns1.adbraun.lol.`, plus a secondary if
you have one. The first one is also the SOA's primary name server. Set
your usual ones once with `jarl settings set --ns ns1.adbraun.lol.` (or
Server options → Defaults for new zones) and they are filled in.
* **Name server addresses** are only needed when a name server's name is
inside the zone being created: zone `adbraun.lol` with name server
`ns1.adbraun.lol` needs an address record `ns1 10.0.10.2` in the zone
(glue). For reverse zones, and zones whose name servers are named in
another zone, leave it empty.
Other zone types don't take name servers.
### Reverse zones
Reverse names have one label per octet (`100.10.0.10.in-addr.arpa` for
10.0.10.100), or per nibble for IPv6, and a zone can only start at a label
boundary. So:
* A /8, /16 or /24 is one zone (any multiple of /4 for IPv6).
* A network in between is several zones: a /22 is four /24 zones. Jarl
creates them together, up to 16 at a time.
* A /32 (or /128) is a zone for one address. Use it to answer for a few
hosts while another server, such as your router, answers for the rest of
the /24. When a /32 zone and its /24 zone are both on this server, the
/32 zone answers for its address.
* A /25 to /31 has no zone of its own: its names live in the /24's zone.
(RFC 2317 delegation, where an ISP hands you part of a /24, is set up by
hand with CNAMEs.)
### Dynamic updates and TSIG keys
Jarl writes every zone file, so it can't let named change a zone through
dynamic updates (`allow-update`, `update-policy`, nsupdate): named's
changes would be overwritten. Automation goes through the API instead, with
an API token restricted to what it may change; `jarl acme` and the acme.sh
and certbot hooks do this for DNS-01 challenges ([acme.md](acme.md)).
TSIG keys remain for what servers do among themselves: zone transfers to
secondaries, NOTIFY, and choosing a view by key (`match-clients { key K; }`).
ACLs and TSIG keys are referenced by name. Jarl tracks every reference ACLs and TSIG keys are referenced by name. Jarl tracks every reference
(other ACLs, `match-clients`, `allow-*` options, `primaries ... key`, ...): (other ACLs, `match-clients`, `allow-*` options, `primaries ... key`, ...):
deleting something that is still in use is refused with the list of users, deleting something that is still in use is refused with the list of users,
+8 -2
View File
@@ -1080,7 +1080,7 @@ type Status struct {
Drift []DriftEntry `json:"drift"` Drift []DriftEntry `json:"drift"`
Deployed bool `json:"deployed"` Deployed bool `json:"deployed"`
LastApplied *model.Changeset `json:"last_applied,omitempty"` LastApplied *model.Changeset `json:"last_applied,omitempty"`
OpenChangesets int `json:"open_changesets"` OpenChangesets int `json:"open_changesets"` // with at least one operation
Problems []validate.Problem `json:"problems"` Problems []validate.Problem `json:"problems"`
Counts map[string]int `json:"counts"` Counts map[string]int `json:"counts"`
Paths render.Paths `json:"paths"` Paths render.Paths `json:"paths"`
@@ -1121,8 +1121,14 @@ func (e *Engine) Status(ctx context.Context) (*Status, error) {
s.LastApplied = list[0] s.LastApplied = list[0]
s.LastApplied.Ops = RedactOps(s.LastApplied.Ops) s.LastApplied.Ops = RedactOps(s.LastApplied.Ops)
} }
// Only changesets with something in them: an empty one (a web UI draft
// whose edit was rejected, say) has nothing to review.
if list, err := e.store.ListChangesets(ctx, store.ChangesetFilter{Status: model.ChangesetOpen, Limit: 1000}); err == nil { if list, err := e.store.ListChangesets(ctx, store.ChangesetFilter{Status: model.ChangesetOpen, Limit: 1000}); err == nil {
s.OpenChangesets = len(list) for _, cs := range list {
if len(cs.Ops) > 0 {
s.OpenChangesets++
}
}
} }
s.Problems = nonNilProblems(e.validate(st)) s.Problems = nonNilProblems(e.validate(st))
s.Counts = map[string]int{"views": len(st.Views), "zones": len(st.Zones), "records": len(st.Records), "acls": len(st.ACLs), "tsig_keys": len(st.Keys), "options": len(st.Options)} s.Counts = map[string]int{"views": len(st.Views), "zones": len(st.Zones), "records": len(st.Records), "acls": len(st.ACLs), "tsig_keys": len(st.Keys), "options": len(st.Options)}
+17 -1
View File
@@ -116,10 +116,19 @@ export function useDraft(cs) {
async function ensureDraft() { async function ensureDraft() {
if (state.draft) return state.draft; if (state.draft) return state.draft;
const cs = await api("POST", "/changesets", { body: { description: "" } }); const cs = await api("POST", "/changesets", { body: { description: "" } });
cs.fresh = true;
useDraft(cs); useDraft(cs);
return cs; return cs;
} }
// dropFreshDraft discards a draft that was created for an edit the server
// then rejected, so no empty changeset is left open.
async function dropFreshDraft(cs) {
if (!cs.fresh || (state.draft && state.draft.id !== cs.id) || (state.draft && (state.draft.ops || []).length)) return;
try { await api("POST", "/changesets/" + cs.id + "/discard"); } catch { /* leave it */ }
useDraft(null);
}
// change sends a configuration change: staged into the draft changeset, // change sends a configuration change: staged into the draft changeset,
// or applied at once when immediate mode is on (or opts.immediate). // or applied at once when immediate mode is on (or opts.immediate).
// It returns the API response. // It returns the API response.
@@ -140,6 +149,7 @@ export async function change(method, path, body, opts = {}) {
useDraft(null); useDraft(null);
return change(method, path, body, opts); return change(method, path, body, opts);
} }
await dropFreshDraft(cs);
throw e; throw e;
} }
} }
@@ -152,7 +162,13 @@ export async function changeMany(ops, description, opts = {}) {
return r; return r;
} }
const cs = await ensureDraft(); const cs = await ensureDraft();
const r = await api("POST", "/changesets/" + cs.id + "/ops", { body: { ops } }); let r;
try {
r = await api("POST", "/changesets/" + cs.id + "/ops", { body: { ops } });
} catch (e) {
await dropFreshDraft(cs);
throw e;
}
if (r && r.changeset) state.draft = r.changeset; if (r && r.changeset) state.draft = r.changeset;
notify(); notify();
return r; return r;
@@ -2,8 +2,10 @@ import { h, mount, time, problemsList, badge, plural } from "../ui.js";
import { get, state } from "../api.js"; import { get, state } from "../api.js";
export async function render(el) { export async function render(el) {
const st = await get("/status"); const [st, mine] = await Promise.all([get("/status"), get("/changesets", { status: "open", mine: 1, limit: 50 }).catch(() => [])]);
const s = st.engine || {}; const s = st.engine || {};
// The user's own changesets with something in them: what the Changes page shows.
const pending = mine.filter((c) => (c.ops || []).length).length;
const n = s.named; const n = s.named;
const cards = h("div", { class: "cards" }, const cards = h("div", { class: "cards" },
card("named", n ? (n.running ? badge("running", "ok") : badge("unknown", "warn")) : badge(s.named_error ? "unreachable" : "not checked", s.named_error ? "err" : ""), card("named", n ? (n.running ? badge("running", "ok") : badge("unknown", "warn")) : badge(s.named_error ? "unreachable" : "not checked", s.named_error ? "err" : ""),
@@ -18,7 +20,7 @@ export async function render(el) {
h("div", { class: "grow" }, h("h1", null, "Dashboard"), h("div", { class: "muted" }, "Signed in as " + state.me.user.username + " · " + (state.me.permissions || []).join(", "))), h("div", { class: "grow" }, h("h1", null, "Dashboard"), h("div", { class: "muted" }, "Signed in as " + state.me.user.username + " · " + (state.me.permissions || []).join(", "))),
h("div", { class: "actions" }, h("div", { class: "actions" },
h("a", { class: "button primary", href: "#/zones?new=1" }, "New zone"), h("a", { class: "button primary", href: "#/zones?new=1" }, "New zone"),
h("a", { class: "button", href: "#/changes" }, "Pending changes" + (s.open_changesets ? " (" + s.open_changesets + " open)" : "")))), h("a", { class: "button", href: "#/changes" }, "Pending changes" + (pending ? " (" + pending + " open)" : "")))),
cards]; cards];
if (s.drift && s.drift.length) { if (s.drift && s.drift.length) {
parts.push(h("div", { class: "msg warn" }, parts.push(h("div", { class: "msg warn" },