Encrypted DNS: several host names, views per listen address, what named uses a certificate for
The setup takes several host names (one certificate for all; the health check says when the served certificate misses one), stored in the settings. Each listen address gets its own views for the host names' A/AAAA records (--address ADDR=VIEW,..., a view checkbox per address in the form), always local to the view, with a warning when a public view would get a private address; a view no longer given loses the address, other records of the names stay, and the form starts from where the records are now. Certificates (page and jarl cert show) say what named uses each for: DNS over TLS or HTTPS, on which addresses and ports. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bfab83da6e
commit
fef916b7ab
19 files changed
+587
-122
No files matched your search
@@ -33,7 +33,8 @@ export async function render(el) {
|
||||
{ label: "Names", render: (c) => h("div", { class: "mono small" }, (c.names && c.names.length ? c.names : c.domains).map((n) => h("div", null, n))) },
|
||||
{ label: "Used for", render: (c) => [
|
||||
c.uses.includes("web") ? badge(c.serving ? "web UI (serving)" : "web UI", c.serving ? "ok" : "") : null, " ",
|
||||
c.uses.includes("dns") ? badge("named", "") : null,
|
||||
c.uses.includes("dns") ? [badge("named", ""), (c.named_use || []).map((u) => h("div", { class: "small muted", title: u }, u.replace("DNS over TLS on ", "DoT ").replace("DNS over HTTPS on ", "DoH "))),
|
||||
!(c.named_use || []).length ? h("div", { class: "small muted" }, "not used by named yet") : null] : null,
|
||||
!c.uses.length ? h("span", { class: "muted" }, "nothing yet") : null] },
|
||||
{ label: "Source", render: (c) => c.source === "acme" ? h("span", null, "Jarl", h("div", { class: "small muted" }, "challenge " + c.challenge)) : h("span", null, "your files", h("div", { class: "small muted mono" }, c.cert_file)) },
|
||||
{ label: "Expires", render: (c) => c.not_after ? h("span", null, time(c.not_after), c.source === "acme" && c.next_attempt ? h("div", { class: "small muted" }, "renews " + fmtTime(c.next_attempt)) : null) : h("span", { class: "muted" }, "–") },
|
||||
@@ -79,6 +80,7 @@ function details(c) {
|
||||
modal("Certificate " + c.name, h("div", null,
|
||||
h("dl", { class: "kv" }, rows.map(([k, v]) => [h("dt", null, k), h("dd", null, v)])),
|
||||
c.files ? h("div", null, h("h3", null, "For named"),
|
||||
(c.named_use || []).length ? h("ul", { class: "small" }, c.named_use.map((u) => h("li", null, u))) : h("p", { class: "small muted" }, "named doesn't use it yet (Encrypted DNS sets that up)."),
|
||||
h("p", { class: "small muted" }, "Point a tls block in named's configuration at these files; Jarl rewrites them on renewal and has named reread them."),
|
||||
copyBox(c.files.chain), copyBox(c.files.key)) : null), [{ label: "Close", value: null }], { wide: true });
|
||||
}
|
||||
|
||||
@@ -26,7 +26,10 @@ export async function render(el) {
|
||||
function statusPanel(s) {
|
||||
const eps = (list) => list.map((e) => e.address + " port " + e.port).join(", ");
|
||||
const rows = [
|
||||
h("dt", null, "Certificate"), h("dd", null, h("a", { href: "#/certificates" }, s.certificate || "?"), s.hostname ? " (" + s.hostname + ")" : null),
|
||||
h("dt", null, s.hostnames.length > 1 ? "Host names" : "Host name"), h("dd", { class: "mono" }, s.hostnames.join(", ") || "?"),
|
||||
h("dt", null, "Certificate"), h("dd", null, h("a", { href: "#/certificates" }, s.certificate || "?")),
|
||||
Object.keys(s.address_views || {}).length ? [h("dt", null, "Records"), h("dd", null, Object.entries(s.address_views).filter(([a]) => [...s.dot, ...s.doh].some((e) => e.address === a))
|
||||
.map(([a, vs]) => h("div", null, h("span", { class: "mono" }, a), h("span", { class: "muted" }, " in " + vs.join(", ")))))] : null,
|
||||
s.dot.length ? [h("dt", null, "DNS over TLS"), h("dd", { class: "mono" }, eps(s.dot))] : null,
|
||||
s.doh.length ? [h("dt", null, "DNS over HTTPS"), h("dd", { class: "mono" }, eps(s.doh))] : null,
|
||||
];
|
||||
@@ -58,7 +61,8 @@ function statusPanel(s) {
|
||||
}
|
||||
|
||||
function setupPanel(s) {
|
||||
const host = input({ value: s.hostname || "", placeholder: "dns.adbraun.lol", class: "mono", style: { maxWidth: "320px" } });
|
||||
const hostIn = input({ value: (s.hostnames || []).join(", "), placeholder: "dns.adbraun.lol", class: "mono", style: { maxWidth: "420px" } });
|
||||
const hosts = () => hostIn.value.split(/[\s,]+/).map((x) => x.trim().toLowerCase().replace(/\.$/, "")).filter(Boolean);
|
||||
const certSel = h("select");
|
||||
const dot = checkbox("DNS over TLS", s.enabled ? s.dot.length > 0 : true);
|
||||
const doh = checkbox("DNS over HTTPS", s.enabled ? s.doh.length > 0 : true);
|
||||
@@ -69,40 +73,64 @@ function setupPanel(s) {
|
||||
const portOut = h("div");
|
||||
const err = h("div");
|
||||
const progress = h("div", { class: "muted small" });
|
||||
let views = s.views;
|
||||
// Per address, the views where the host names get it: what their records
|
||||
// say now (the setup's, or ones made by hand, which would otherwise be
|
||||
// taken out), nothing else.
|
||||
const viewsFor = new Map(s.addresses.map((a) => [a.address, new Set((s.address_views || {})[a.address] || [])]));
|
||||
const isPrivate = new Map(s.addresses.map((a) => [a.address, a.private]));
|
||||
|
||||
// Addresses: one per row, nothing ticked (but the current setup's).
|
||||
const addrRows = s.addresses.map((a) => pickRow(a.address, picked, [a.private ? h("span", { class: "muted" }, "private")
|
||||
: a.address.includes(":") ? h("span", { class: "muted", title: "A global IPv6 address: reachable from the internet unless the firewall stops it" }, "global")
|
||||
: badge("public", "warn")],
|
||||
"Listen on " + a.address, () => checkPorts()));
|
||||
// Views for the host name's records: the private ones that have its zone.
|
||||
const viewPicked = new Set();
|
||||
const viewBody = h("tbody");
|
||||
const drawViews = (views) => {
|
||||
viewPicked.clear();
|
||||
mount(viewBody, views.map((v) => {
|
||||
if (!v.public && v.has_zone) viewPicked.add(v.name);
|
||||
return pickRow(v.name, viewPicked, [v.public ? badge("public", "warn") : h("span", { class: "muted" }, "private"), " ",
|
||||
v.has_zone ? h("span", { class: "muted small" }, "has the zone") : h("span", { class: "muted small" }, "no zone for this name: skipped")], "Add the host name to view " + v.name);
|
||||
}));
|
||||
const viewCells = new Map();
|
||||
const drawViewCell = (a) => {
|
||||
const cell = viewCells.get(a);
|
||||
if (!cell) return;
|
||||
if (!picked.has(a)) return mount(cell);
|
||||
const set = viewsFor.get(a);
|
||||
const warn = isPrivate.get(a) && views.some((v) => v.public && set.has(v.name));
|
||||
mount(cell, h("div", { class: "view-chips" }, h("span", { class: "muted small" }, "records in: "),
|
||||
views.map((v) => {
|
||||
const box = h("input", { type: "checkbox", checked: set.has(v.name), disabled: !v.has_zone && !set.has(v.name) });
|
||||
box.addEventListener("change", () => { if (box.checked) set.add(v.name); else set.delete(v.name); drawViewCell(a); });
|
||||
return h("label", { class: "chip" + (v.public ? " public" : ""), title: v.has_zone ? (v.public ? "A public view: the internet's clients get these records" : "") : "View " + v.name + " has no zone for the host names" }, box, " " + v.name, v.public ? h("span", { class: "muted small" }, " (public)") : null);
|
||||
}),
|
||||
!views.length ? h("span", { class: "muted small" }, "no views") : null),
|
||||
warn ? h("div", { class: "msg warn small" }, a + " is a private address: clients of a public view can't reach it.") : null);
|
||||
};
|
||||
drawViews(s.views);
|
||||
const addrRows = s.addresses.map((a) => {
|
||||
const cell = h("td", { class: "addr-views" });
|
||||
// The row toggles the address; clicks on its views shouldn't.
|
||||
cell.addEventListener("click", (e) => e.stopPropagation());
|
||||
viewCells.set(a.address, cell);
|
||||
const tr = pickRow(a.address, picked, [a.private ? h("span", { class: "muted" }, "private")
|
||||
: a.address.includes(":") ? h("span", { class: "muted", title: "A global IPv6 address: reachable from the internet unless the firewall stops it" }, "global")
|
||||
: badge("public", "warn")],
|
||||
"Listen on " + a.address, () => { drawViewCell(a.address); checkPorts(); });
|
||||
tr.append(cell);
|
||||
drawViewCell(a.address);
|
||||
return tr;
|
||||
});
|
||||
const redrawViews = () => { for (const a of viewCells.keys()) drawViewCell(a); };
|
||||
|
||||
const fillCerts = () => {
|
||||
const name = host.value.trim().toLowerCase().replace(/\.$/, "");
|
||||
const fit = s.certificates.filter((c) => covers([...(c.names || []), ...(c.domains || [])], name));
|
||||
const names = hosts();
|
||||
const fit = s.certificates.filter((c) => names.length && names.every((n) => covers([...(c.names || []), ...(c.domains || [])], n)));
|
||||
mount(certSel,
|
||||
fit.map((c) => h("option", { value: c.name, selected: c.name === s.certificate }, c.name + " (" + (c.names || c.domains).join(", ") + (c.obtained ? "" : ", not obtained yet") + ")")),
|
||||
name ? h("option", { value: "" }, "Obtain a new certificate for " + name) : h("option", { value: "" }, "Type the host name first"));
|
||||
names.length ? h("option", { value: "" }, "Obtain a new certificate for " + names.join(", ")) : h("option", { value: "" }, "Type the host names first"));
|
||||
};
|
||||
fillCerts();
|
||||
let viewTimer = null;
|
||||
host.addEventListener("input", () => {
|
||||
hostIn.addEventListener("input", () => {
|
||||
fillCerts();
|
||||
clearTimeout(viewTimer);
|
||||
viewTimer = setTimeout(async () => {
|
||||
const r = await get("/encrypted-dns", { hostname: host.value.trim() }).catch(() => null);
|
||||
if (r) drawViews(r.views);
|
||||
const r = await get("/encrypted-dns", { hostname: hosts().join(",") }).catch(() => null);
|
||||
if (r) {
|
||||
views = r.views;
|
||||
// Records the new host names already have stay ticked.
|
||||
for (const [a, vs] of Object.entries(r.address_views || {})) if (viewsFor.has(a)) vs.forEach((v) => viewsFor.get(a).add(v));
|
||||
redrawViews();
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
|
||||
@@ -129,23 +157,26 @@ function setupPanel(s) {
|
||||
if (picked.size) checkPorts();
|
||||
|
||||
const body = (full) => {
|
||||
const b = { hostname: host.value.trim(), addresses: [...picked], dot: dot.input.checked, doh: doh.input.checked, dot_port: Number(dotPort.value) || 853, doh_port: Number(dohPort.value) || 443 };
|
||||
if (full) { b.certificate = certSel.value; b.views = [...viewPicked]; }
|
||||
const b = { hostnames: hosts(), addresses: [...picked], dot: dot.input.checked, doh: doh.input.checked, dot_port: Number(dotPort.value) || 853, doh_port: Number(dohPort.value) || 443 };
|
||||
if (full) {
|
||||
b.certificate = certSel.value;
|
||||
b.address_views = Object.fromEntries([...picked].map((a) => [a, [...(viewsFor.get(a) || [])]]));
|
||||
}
|
||||
return b;
|
||||
};
|
||||
|
||||
const submit = async (btn) => {
|
||||
mount(err);
|
||||
const b = body(true);
|
||||
if (!b.hostname) return mount(err, h("div", { class: "msg err" }, "Give the host name clients connect to."));
|
||||
if (!b.hostnames.length) return mount(err, h("div", { class: "msg err" }, "Give the host name clients connect to."));
|
||||
if (!b.addresses.length) return mount(err, h("div", { class: "msg err" }, "Choose at least one address to listen on."));
|
||||
btn.disabled = true;
|
||||
try {
|
||||
if (!b.certificate) {
|
||||
// Obtain one for the host name, for named, and wait for it.
|
||||
b.certificate = b.hostname.split(".")[0].replace(/[^a-z0-9_-]/g, "-");
|
||||
mount(progress, "Obtaining a certificate for " + b.hostname + " from the CA…");
|
||||
await api("POST", "/certificates", { body: { name: b.certificate, domains: [b.hostname], uses: ["dns"] } });
|
||||
// Obtain one for the host names, for named, and wait for it.
|
||||
b.certificate = b.hostnames[0].split(".")[0].replace(/[^a-z0-9_-]/g, "-");
|
||||
mount(progress, "Obtaining a certificate for " + b.hostnames.join(", ") + " from the CA…");
|
||||
await api("POST", "/certificates", { body: { name: b.certificate, domains: b.hostnames, uses: ["dns"] } });
|
||||
await waitObtained(b.certificate, progress);
|
||||
} else {
|
||||
const c = await get("/certificates/" + encodeURIComponent(b.certificate));
|
||||
@@ -170,14 +201,12 @@ function setupPanel(s) {
|
||||
return h("div", { class: "panel" },
|
||||
h("h2", null, s.enabled ? "Change the setup" : "Set up"),
|
||||
h("div", { class: "form-grid" },
|
||||
field("Host name", host, "The name clients connect to. The certificate is for it, and it gets this server's addresses in the views below."),
|
||||
field("Certificate", certSel, "One with this name, or a new one Jarl obtains and renews (docs/certificates.md)."),
|
||||
field("Host names", hostIn, "The names clients connect to, separated by commas: dns.adbraun.lol, or several (dns.adbraun.lol, doh.adbraun.lol). The certificate must be for all of them."),
|
||||
field("Certificate", certSel, "One for all the host names, or a new one Jarl obtains and renews (docs/certificates.md)."),
|
||||
field("Listen on", h("div", null, h("div", { class: "table-wrap" }, h("table", { class: "pick-list" }, h("tbody", null, addrRows))), portOut),
|
||||
"This server's addresses on your own networks. A public address works too, but then anyone who reaches it can use these listeners, as with port 53 there."),
|
||||
"This server's addresses on your own networks. For each, tick the views where the host names get it (A or AAAA records, local to the view): a view's clients should only be given an address they can reach."),
|
||||
field("Serve", h("div", { class: "enc-ports" }, h("div", null, dot, " on port ", dotPort), h("div", null, doh, " on port ", dohPort, h("span", { class: "muted small" }, " at /dns-query"))),
|
||||
"Clients expect DoT on port 853. DoH can use any free port; 443 is the default, and needs nothing else listening there."),
|
||||
field("Host name in views", h("div", { class: "table-wrap" }, h("table", { class: "pick-list" }, viewBody)),
|
||||
"Where the host name gets the chosen addresses (A and AAAA), local to the view in a linked zone. Clients outside these views can't find it.")),
|
||||
"Clients expect DoT on port 853. DoH can use any free port; 443 is the default, and needs nothing else listening there. Ports can be changed later: clients set up with another port need it changed too.")),
|
||||
err, progress,
|
||||
h("div", { class: "buttons" }, go));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user