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:
1 parent
785b0006f9
commit
5cb8602a7f
5 files changed
+104
-6
No files matched your search
@@ -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
|
||||
enclosing /24's zone.
|
||||
- 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
|
||||
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
|
||||
|
||||
- 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
|
||||
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.
|
||||
|
||||
+66
-1
@@ -140,7 +140,8 @@ RFC 1982 arithmetic.
|
||||
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`.
|
||||
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
|
||||
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
|
||||
@@ -161,6 +162,70 @@ RFC 1982 arithmetic.
|
||||
* **Settings** are Jarl's defaults for new zones: name servers, SOA contact
|
||||
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
|
||||
(other ACLs, `match-clients`, `allow-*` options, `primaries ... key`, ...):
|
||||
deleting something that is still in use is refused with the list of users,
|
||||
|
||||
@@ -1080,7 +1080,7 @@ type Status struct {
|
||||
Drift []DriftEntry `json:"drift"`
|
||||
Deployed bool `json:"deployed"`
|
||||
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"`
|
||||
Counts map[string]int `json:"counts"`
|
||||
Paths render.Paths `json:"paths"`
|
||||
@@ -1121,8 +1121,14 @@ func (e *Engine) Status(ctx context.Context) (*Status, error) {
|
||||
s.LastApplied = list[0]
|
||||
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 {
|
||||
s.OpenChangesets = len(list)
|
||||
for _, cs := range list {
|
||||
if len(cs.Ops) > 0 {
|
||||
s.OpenChangesets++
|
||||
}
|
||||
}
|
||||
}
|
||||
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)}
|
||||
|
||||
@@ -116,10 +116,19 @@ export function useDraft(cs) {
|
||||
async function ensureDraft() {
|
||||
if (state.draft) return state.draft;
|
||||
const cs = await api("POST", "/changesets", { body: { description: "" } });
|
||||
cs.fresh = true;
|
||||
useDraft(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,
|
||||
// or applied at once when immediate mode is on (or opts.immediate).
|
||||
// It returns the API response.
|
||||
@@ -140,6 +149,7 @@ export async function change(method, path, body, opts = {}) {
|
||||
useDraft(null);
|
||||
return change(method, path, body, opts);
|
||||
}
|
||||
await dropFreshDraft(cs);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -152,7 +162,13 @@ export async function changeMany(ops, description, opts = {}) {
|
||||
return r;
|
||||
}
|
||||
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;
|
||||
notify();
|
||||
return r;
|
||||
|
||||
@@ -2,8 +2,10 @@ import { h, mount, time, problemsList, badge, plural } from "../ui.js";
|
||||
import { get, state } from "../api.js";
|
||||
|
||||
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 || {};
|
||||
// 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 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" : ""),
|
||||
@@ -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: "actions" },
|
||||
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];
|
||||
if (s.drift && s.drift.length) {
|
||||
parts.push(h("div", { class: "msg warn" },
|
||||
|
||||
Reference in new issue
Block a user