diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c0d4fc..77c73a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/concepts.md b/docs/concepts.md index 152956d..8d280a8 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -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, diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 6fb6ffa..2f47482 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -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)} diff --git a/internal/web/static/assets/api.js b/internal/web/static/assets/api.js index 68e1ba1..ddbe96f 100644 --- a/internal/web/static/assets/api.js +++ b/internal/web/static/assets/api.js @@ -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; diff --git a/internal/web/static/assets/pages/dashboard.js b/internal/web/static/assets/pages/dashboard.js index f687e2d..44731f9 100644 --- a/internal/web/static/assets/pages/dashboard.js +++ b/internal/web/static/assets/pages/dashboard.js @@ -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" },