Linked zones: shared records kept in step across views

Zones of the same name in different views can be linked. Shared records
are kept in step: a change in one view reaches the others in the same
changeset, as explicit operations, so the diff, permission checks,
history and undo cover every view. Records local to a view override the
shared records of that name and type there; removing the override brings
the shared ones back, and sharing a view's version makes it every view's.
Each zone keeps its own options, serial and file.

Linking existing zones changes no answer: what is the same everywhere
becomes shared, the rest stays local. Unlinking keeps every record, all
local. ACME challenges are written once per link, and --view all changes
a link once. jarl zone link/unlink, zone create --link-with, record
--local/--shared, LINK column, web UI controls, POST /zones/{zone}/link
and /unlink, validation checks linked_*, store migration 11, docs and
e2e.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-12 15:53:58 +01:00
1 parent 6313fa51f8
commit 057433c198
31 files changed
+1729 -55

No files matched your search

+66 -1
View File
@@ -34,12 +34,14 @@ export async function render(el, params, query) {
zone.view ? ["view ", viewLink(view, zone.view, "#/zones/" + id + (params.tab ? "/" + params.tab : "")), " · "] : null,
zone.type === "primary" ? "serial " + zone.soa.serial + " · " : null,
zone.dnssec_policy ? [h("a", { href: "#/zones/" + id + "/dnssec" }, badge(zone.dnssec_policy === "insecure" ? "DNSSEC: turning off" : "DNSSEC", zone.dnssec_policy === "insecure" ? "warn" : "ok")), " "] : null,
zone.linked_with ? [badge("linked with " + zone.linked_with.join(", "), "accent"), " "] : null,
zone.zone_file ? h("span", { class: "mono small" }, zone.zone_file) : null)),
h("div", { class: "actions" },
zone.disabled
? h("button", { onclick: () => toggleZone(zone) }, "Enable zone")
: h("button", { disabled: !!zone.dnssec_policy, title: zone.dnssec_policy ? "A signed zone can't be disabled: turn signing off first" : "Stop serving the zone, keeping it in Jarl", onclick: () => toggleZone(zone) }, "Disable zone"),
" ",
zone.type === "primary" && zone.view ? [linkButton(zone), " "] : null,
h("button", { class: "danger", onclick: () => deleteZone(zone) }, "Delete zone")));
const banner = zone.disabled ? h("div", { class: "msg warn" },
h("strong", null, "This zone is disabled: "), "named doesn't load it. It is kept here, with its records, and can still be edited; enabling it serves it as it is then.") : null;
@@ -68,6 +70,51 @@ export async function render(el, params, query) {
if (first) first.focus();
}
// ---- linked zones (docs/views.md#linked-zones)
// linkButton links the zone with the same zone in other views, or takes
// it out of its link.
function linkButton(zone) {
if (zone.linked_with) {
return h("button", { title: "Stop keeping this zone in step with " + zone.linked_with.join(", ") + "; its records stay, all local", onclick: async () => {
if (!await confirmDialog("Unlink zone", "Take " + zone.display_name + " in view " + zone.view + " out of its link with " + zone.linked_with.join(", ") + "? Its records stay as they are, all local to this view; changes no longer reach the other views.", "Unlink")) return;
try {
toast(describeResult(await change("POST", "/zones/" + zone.id + "/unlink")));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
} }, "Unlink");
}
return h("button", { title: "Keep this zone's records in step with the same zone in other views", onclick: () => linkDialog(zone) }, "Link…");
}
async function linkDialog(zone) {
const zones = await get("/zones");
const others = zones.filter((z) => z.name === zone.name && z.id !== zone.id && z.type === "primary" && z.view);
if (!others.length) {
await modal("Link zone", h("p", null, "No other view has a zone " + zone.display_name + ". Create it there linked: New zone, the same name, \"Copy records from\" this one with \"Keep them linked\" ticked."));
return;
}
const boxes = others.map((z) => { const c = checkbox("view " + z.view, true); c.view = z.view; return c; });
const err = h("div");
await modal("Link " + zone.display_name, h("div", null,
h("p", null, "Linked zones keep their shared records in step: a change in one view reaches the others in the same change. Each view can still have records of its own, which override the shared ones of that name and type there, and keeps its own options."),
h("p", { class: "muted small" }, "Linking changes no answer: records that are the same in every view become shared; anything that differs stays local to each view. Share one later from the view whose version is right."),
field("Link view " + zone.view + " with", h("div", null, boxes)), err), [
{ label: "Cancel", value: null },
{ label: "Link", kind: "primary", onclick: async () => {
const w = boxes.filter((b) => b.input.checked).map((b) => b.view);
if (!w.length) { mount(err, h("div", { class: "msg err" }, "Pick at least one view.")); return false; }
try {
const r = await change("POST", "/zones/" + zone.id + "/link", { with: w });
toast(describeResult(r));
(r.warnings || []).forEach((m) => toast(m, "warn", 12000));
rerender();
return true;
} catch (e) { mount(err, errorBox(e)); return false; }
} },
]);
}
// ---- name servers: serials on the other servers, and the delegation
const nsStatus = {
@@ -321,6 +368,8 @@ async function recordsTab(el, zone, query) {
const fData = input({ class: "mono", placeholder: typeInfo.A.example });
const fComment = input({ placeholder: "optional", style: { width: "160px" } });
const fPTR = checkbox("PTR", false, { title: "Also add the matching PTR record in the reverse zone" });
const linked = !!zone.linked_with;
const fLocal = checkbox("Only here", false, { title: "Add it to view " + zone.view + " only, overriding the shared records of this name and type here (otherwise it reaches " + (zone.linked_with || []).join(", ") + " too)" });
const hint = h("div", { class: "hint" });
const willAdd = h("div", { class: "will-add", "aria-live": "polite" });
const suggest = h("div", { class: "aml-suggest" });
@@ -426,6 +475,7 @@ async function recordsTab(el, zone, query) {
if (fTTL.value !== "") body.ttl = Number(fTTL.value);
if (fComment.value.trim()) body.comment = fComment.value.trim();
if (fPTR.input.checked) body.ptr = true;
if (linked && fLocal.input.checked) body.local = true;
try {
const r = await change("POST", "/zones/" + zone.id + "/records", body);
toast(describeResult(r));
@@ -452,6 +502,7 @@ async function recordsTab(el, zone, query) {
h("div", { class: "f grow" }, h("label", null, "Data"), fData),
h("div", { class: "f" }, h("label", null, "Comment"), fComment),
h("div", { class: "f" }, h("label", null, " "), fPTR),
linked ? h("div", { class: "f" }, h("label", null, " "), fLocal) : null,
h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add record")));
// Filters and table
@@ -479,8 +530,14 @@ async function recordsTab(el, zone, query) {
{ label: "TTL", render: (r) => r.ttl !== undefined && r.ttl !== null ? String(r.ttl) : h("span", { class: "muted", title: "zone default" }, String(r.effective_ttl)), tdClass: "mono" },
{ label: "Type", render: (r) => h("strong", null, r.type) },
{ label: "Data", render: (r) => r.data, tdClass: "data" },
linked ? { label: "Views", render: (r) => r.linked === "local"
? badge("only " + zone.view, "accent")
: h("span", { class: "muted small", title: "Shared: the same in " + [zone.view, ...zone.linked_with].join(", ") + " (unless a view overrides it)" }, "shared") } : null,
{ label: "Comment", render: (r) => [r.pending ? [pendingBadge(r.pending), " "] : null, h("span", { class: "muted" }, r.comment || "")] },
{ label: "", render: (r) => r.pending === "delete" ? "" : h("span", null,
linked ? [r.linked === "local"
? h("button", { class: "small reveal", title: "Make this view's " + r.relative_name + " " + r.type + " the shared version: it replaces the other views' (" + zone.linked_with.join(", ") + ")", onclick: () => setLocal(r, false) }, "Share")
: h("button", { class: "small reveal", title: "Make " + r.relative_name + " " + r.type + " this view's own, so it can differ here; the other views keep the shared one", onclick: () => setLocal(r, true) }, "Only here"), " "] : null,
!reverse && (r.type === "A" || r.type === "AAAA") && !r.disabled
? [h("button", { class: "small reveal", title: "Add a CNAME record that points to " + r.name.replace(/\.$/, ""), onclick: () => aliasTo(r) }, "+ CNAME"), " "] : null,
h("button", { class: "small", title: "Edit", onclick: () => edit(r) }, "Edit"), " ",
@@ -488,7 +545,7 @@ async function recordsTab(el, zone, query) {
? h("button", { class: "small toggle", title: "Put the record back in the zone", onclick: () => toggle(r) }, "Enable")
: h("button", { class: "small toggle", title: "Leave the record out of the zone, but keep it here", onclick: () => toggle(r) }, "Disable"), " ",
h("button", { class: "small danger", title: "Delete", onclick: () => del(r) }, "Delete")), tdClass: "actions-cell" },
], rows, { empty: current.length ? "No records match." : "No records yet.", rowClass: (r, i) => [
].filter(Boolean), rows, { empty: current.length ? "No records match." : "No records yet.", rowClass: (r, i) => [
r.pending ? "pending-" + r.pending : "",
r.disabled ? "disabled" : "",
// A record followed by more of the same name: a faint line, so the
@@ -523,6 +580,14 @@ async function recordsTab(el, zone, query) {
await redraw();
} catch (e) { toast(e.message, "err", 8000); }
};
const setLocal = async (r, local) => {
if (!local && !(await confirmDialog("Share " + r.relative_name + " " + r.type, "View " + zone.view + "'s " + r.relative_name + " " + r.type + " records become the shared version, in " + zone.linked_with.join(", ") + " too: the records there of that name and type are replaced.", "Share"))) return;
try {
const res = await change("PATCH", "/records/" + r.id, { local });
toast(describeResult(res));
await redraw();
} catch (e) { toast(e.message, "err", 8000); }
};
const edit = (r) => { editing = r; draw(); };
const mountEdit = () => {
const r = editing;
+13 -3
View File
@@ -1,4 +1,4 @@
import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge, viewLink, lastList } from "../ui.js";
import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge, viewLink, lastList, checkbox } from "../ui.js";
import { get, change, changeMany, describeResult, can, draftQuery } from "../api.js";
import { rerender } from "../app.js";
import { checkDomain, reverseZoneFor } from "../names.js";
@@ -72,12 +72,17 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
const file = input({ placeholder: "/usr/share/dns/root.hints", class: "mono" });
const inView = select(views.map((v) => [v.name, v.name]), "");
const cloneSel = select([["", "– don't copy –"]], "");
const linkBox = checkbox("Keep them linked: shared records stay in step, each view can add its own", true);
const cloneWrap = h("div", null, cloneSel, linkBox);
const syncLink = () => { linkBox.style.display = cloneSel.value ? "" : "none"; };
cloneSel.addEventListener("change", syncLink);
const comment = input();
const updateClones = () => {
// Records can only be copied into a single zone.
const n = (kind === "reverse" ? reverseZoneFor(name.value).name || "-" : name.value.trim().toLowerCase().replace(/\.$/, "")) + ".";
mount(cloneSel, h("option", { value: "" }, "– don't copy –"),
zones.filter((z) => z.type === "primary" && z.name === n).map((z) => h("option", { value: z.id }, z.display_name + " [" + (z.view || "no view") + "]")));
syncLink();
};
// check validates the name as typed and returns the zone name (or null);
// a network that needs several reverse zones gives a list of names.
@@ -152,7 +157,7 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
field("Name server addresses", nsAddr, "Optional, for name servers inside this zone: 'ns1 192.0.2.1' per line (glue)."),
field("SOA contact", contact, "Email address of the zone's administrator."),
field("Default TTL", ttl, "Seconds, used by records without their own TTL."),
field("Copy records from", cloneSel, "For split-horizon setups: start with the records of this zone in another view.")],
field("Copy records from", cloneWrap, "For split-horizon setups: start with the records of this zone in another view. Linked, Jarl keeps the shared records the same in both (docs/views.md#linked-zones).")],
secondary: [field("Primary servers", primaries, "One per line: address [port N] [key NAME].")],
stub: [field("Primary servers", primaries, "One per line.")],
mirror: [field("Primary servers", primaries, "One per line (not needed for the root zone).")],
@@ -196,7 +201,12 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
data.nameservers = lines(ns.value);
if (contact.value.trim()) data.soa = { rname: contact.value.trim() };
data.default_ttl = Number(ttl.value) || undefined;
if (cloneSel.value) { data.clone_from = Number(cloneSel.value); delete data.nameservers; }
if (cloneSel.value) {
const src = zones.find((z) => z.id === Number(cloneSel.value));
if (linkBox.input.checked && src && src.view) data.link_with = src.view;
else data.clone_from = Number(cloneSel.value);
delete data.nameservers;
}
data.records = lines(nsAddr.value).map((l) => {
const [n, a] = l.split(/\s+/);
return { name: n, type: a && a.includes(":") ? "AAAA" : "A", data: a };