The client hints and docs tested DoT with kdig (Knot's, often not installed) and DoH with curl's application/dns-json, which named's DoH doesn't speak (RFC 8484 wire format only). They use dig +tls / dig +https with +tls-ca and +tls-hostname now, with the port when it isn't the default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
283 lines
17 KiB
JavaScript
283 lines
17 KiB
JavaScript
// DNS over TLS and HTTPS for your clients (docs/encrypted-dns.md): what is
|
|
// set up and what to give clients, and a setup that obtains the
|
|
// certificate if needed, checks the ports and stages named's
|
|
// configuration like any change.
|
|
import { h, mount, field, input, badge, toast, errorBox, confirmDialog, copyBox, checkbox, fmtTime, relTime } from "../ui.js";
|
|
import { api, get, change, describeResult, state } from "../api.js";
|
|
import { rerender } from "../app.js";
|
|
|
|
export async function render(el) {
|
|
if (!state.me.admin) {
|
|
mount(el, h("h1", null, "Encrypted DNS"), h("div", { class: "msg warn" }, "Only administrators can set up DNS over TLS and HTTPS."));
|
|
return;
|
|
}
|
|
const s = await get("/encrypted-dns");
|
|
const parts = [h("div", { class: "page-head" },
|
|
h("div", { class: "grow" }, h("h1", null, "Encrypted DNS"),
|
|
h("div", { class: "muted" }, "DNS over TLS (DoT) and DNS over HTTPS (DoH) for your own devices: phones, laptops and browsers ask this server over an encrypted connection, and get the same answers (and blocking) as over plain DNS. Clients get the view their address matches, as with plain DNS.")),
|
|
s.enabled ? h("div", { class: "actions" }, h("button", { class: "danger", onclick: disable }, "Turn off")) : null)];
|
|
|
|
if (s.enabled) parts.push(statusPanel(s));
|
|
if (s.other) parts.push(h("div", { class: "msg info" }, "named also has DoT or DoH listeners written by hand (Server options → listen-on). The setup leaves them alone."));
|
|
parts.push(setupPanel(s));
|
|
mount(el, parts);
|
|
}
|
|
|
|
function statusPanel(s) {
|
|
const eps = (list) => list.map((e) => e.address + " port " + e.port).join(", ");
|
|
const rows = [
|
|
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,
|
|
];
|
|
const addr = (s.dot[0] || s.doh[0] || {}).address || "ADDRESS";
|
|
const dotPortArg = s.dot[0] && s.dot[0].port !== 853 ? " -p " + s.dot[0].port : "";
|
|
const dohPortArg = s.doh[0] && s.doh[0].port !== 443 ? " -p " + s.doh[0].port : "";
|
|
const clients = [];
|
|
if (s.dot_host) {
|
|
clients.push(h("h3", null, "DNS over TLS"), copyBox(s.dot_host),
|
|
h("ul", { class: "small" },
|
|
h("li", null, h("strong", null, "Android: "), "Settings → Network → Private DNS → hostname: ", h("code", null, s.hostname), ". Away from home Android can't reach this server and then resolves nothing, so use a VPN home (WireGuard) or switch Private DNS to Automatic when you leave."),
|
|
h("li", null, h("strong", null, "Linux (systemd-resolved): "), h("code", null, "DNS=" + addr + "#" + s.hostname), " and ", h("code", null, "DNSOverTLS=yes"), " in /etc/systemd/resolved.conf (", h("code", null, "opportunistic"), " falls back to plain DNS away from home)."),
|
|
h("li", null, h("strong", null, "Test: "), h("code", null, "dig +tls +tls-ca +tls-hostname=" + s.hostname + dotPortArg + " @" + addr + " example.com"),
|
|
" (BIND's dig; +tls-ca checks the certificate against the system's CAs)")));
|
|
}
|
|
if (s.doh_url) {
|
|
clients.push(h("h3", null, "DNS over HTTPS"), copyBox(s.doh_url),
|
|
h("ul", { class: "small" },
|
|
h("li", null, h("strong", null, "Firefox: "), "Settings → Privacy & Security → DNS over HTTPS → Increased protection (falls back to normal DNS) → Custom: the URL above."),
|
|
h("li", null, h("strong", null, "Chrome, Edge: "), "Settings → Privacy and security → Security → Use secure DNS → custom: the URL above."),
|
|
h("li", null, h("strong", null, "Windows 11: "), "Settings → Network → your connection → DNS server assignment → Edit: " + addr + ", DNS over HTTPS on (manual template), the URL above."),
|
|
h("li", null, h("strong", null, "Test: "), h("code", null, "dig +https +tls-ca +tls-hostname=" + s.hostname + dohPortArg + " @" + addr + " example.com"))));
|
|
}
|
|
return [
|
|
h("div", { class: "panel" }, h("h2", null, "Set up ", badge("on", "ok")), h("dl", { class: "kv" }, rows)),
|
|
healthPanel(s.health),
|
|
h("div", { class: "panel" }, h("h2", null, "For your clients"),
|
|
h("p", { class: "muted small" }, "Clients check the certificate against the host name, so it must resolve to this server for them (Jarl added it to the private views you chose). Open the ports in the server's firewall for your networks only."),
|
|
clients),
|
|
];
|
|
}
|
|
|
|
function setupPanel(s) {
|
|
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);
|
|
const dotPort = input({ type: "number", value: (s.dot[0] || {}).port || 853, style: { width: "90px" } });
|
|
const dohPort = input({ type: "number", value: (s.doh[0] || {}).port || 443, style: { width: "90px" } });
|
|
const current = new Set([...s.dot, ...s.doh].map((e) => e.address));
|
|
const picked = new Set(current);
|
|
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]));
|
|
|
|
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);
|
|
};
|
|
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 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") + ")")),
|
|
names.length ? h("option", { value: "" }, "Obtain a new certificate for " + names.join(", ")) : h("option", { value: "" }, "Type the host names first"));
|
|
};
|
|
fillCerts();
|
|
let viewTimer = null;
|
|
hostIn.addEventListener("input", () => {
|
|
fillCerts();
|
|
clearTimeout(viewTimer);
|
|
viewTimer = setTimeout(async () => {
|
|
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);
|
|
});
|
|
|
|
let checkTimer = null, checkSeq = 0;
|
|
const checkPorts = () => {
|
|
clearTimeout(checkTimer);
|
|
checkTimer = setTimeout(async () => {
|
|
const seq = ++checkSeq;
|
|
if (!picked.size) { mount(portOut); return; }
|
|
const r = await api("POST", "/encrypted-dns/check", { body: body(false) }).catch((e) => ({ error: e.message, conflicts: [] }));
|
|
if (seq !== checkSeq) return;
|
|
const msgs = r.conflicts.map((c) => h("div", null, c.what + " listens on " + c.address + " port " + c.port + "."));
|
|
if (r.conflicts.some((c) => c.port === Number(dohPort.value)) && r.free_doh_port) {
|
|
msgs.push(h("div", null, "Port " + r.free_doh_port + " is free for DoH: ",
|
|
h("button", { type: "button", class: "small", onclick: () => { dohPort.value = r.free_doh_port; checkPorts(); } }, "Use " + r.free_doh_port)));
|
|
}
|
|
mount(portOut, r.error ? h("div", { class: "msg warn small" }, r.error)
|
|
: msgs.length ? h("div", { class: "msg err small" }, msgs)
|
|
: h("div", { class: "msg ok small" }, "The ports are free on the chosen addresses."));
|
|
}, 250);
|
|
};
|
|
for (const x of [dotPort, dohPort]) x.addEventListener("input", checkPorts);
|
|
for (const x of [dot, doh]) x.input.addEventListener("change", checkPorts);
|
|
if (picked.size) checkPorts();
|
|
|
|
const body = (full) => {
|
|
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.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 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));
|
|
if (!c.uses.includes("dns")) await api("PATCH", "/certificates/" + encodeURIComponent(b.certificate), { body: { uses: [...c.uses, "dns"] } });
|
|
if (!c.not_after) {
|
|
if (!c.issuing_since) await api("POST", "/certificates/" + encodeURIComponent(b.certificate) + "/issue", { body: {} });
|
|
await waitObtained(b.certificate, progress);
|
|
}
|
|
}
|
|
mount(progress, "Setting up named…");
|
|
const r = await change("POST", "/encrypted-dns", b);
|
|
toast(describeResult(r));
|
|
(r.warnings || []).forEach((w) => toast(w, "warn", 10000));
|
|
rerender();
|
|
} catch (e) {
|
|
mount(progress);
|
|
mount(err, errorBox(e));
|
|
} finally { btn.disabled = false; }
|
|
};
|
|
|
|
const go = h("button", { class: "primary", onclick: (e) => submit(e.target) }, s.enabled ? "Change setup" : "Set up");
|
|
return h("div", { class: "panel" },
|
|
h("h2", null, s.enabled ? "Change the setup" : "Set up"),
|
|
h("div", { class: "form-grid" },
|
|
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. 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. Ports can be changed later: clients set up with another port need it changed too.")),
|
|
err, progress,
|
|
h("div", { class: "buttons" }, go));
|
|
}
|
|
|
|
// pickRow is a table row that toggles value in set when clicked.
|
|
function pickRow(value, set, extra, label, onChange) {
|
|
const box = h("input", { type: "checkbox", checked: set.has(value), "aria-label": label });
|
|
const tr = h("tr", { class: "pick" + (set.has(value) ? " selected" : "") }, h("td", null, box), h("td", { class: "mono" }, value), h("td", null, extra));
|
|
box.addEventListener("change", () => {
|
|
if (box.checked) set.add(value); else set.delete(value);
|
|
tr.classList.toggle("selected", box.checked);
|
|
if (onChange) onChange();
|
|
});
|
|
tr.addEventListener("click", (e) => { if (e.target !== box) { box.checked = !box.checked; box.dispatchEvent(new Event("change")); } });
|
|
return tr;
|
|
}
|
|
|
|
function covers(names, host) {
|
|
if (!host) return false;
|
|
return names.some((n) => {
|
|
n = n.toLowerCase().replace(/\.$/, "");
|
|
if (n === host) return true;
|
|
return n.startsWith("*.") && host.split(".").slice(1).join(".") === n.slice(2);
|
|
});
|
|
}
|
|
|
|
async function waitObtained(name, progress) {
|
|
const started = Date.now();
|
|
for (;;) {
|
|
await new Promise((r) => setTimeout(r, 2500));
|
|
const c = await get("/certificates/" + encodeURIComponent(name));
|
|
if (!c.issuing_since) {
|
|
if (!c.not_after) throw new Error("Obtaining the certificate failed: " + (c.last_error || "see the Certificates page"));
|
|
return c;
|
|
}
|
|
mount(progress, "Obtaining a certificate for " + (c.domains || []).join(", ") + " from the CA… " + Math.round((Date.now() - started) / 1000) + "s (the challenges must reach every name server)");
|
|
}
|
|
}
|
|
|
|
async function disable() {
|
|
if (!await confirmDialog("Turn encrypted DNS off", "named stops serving DNS over TLS and HTTPS. The certificate and the host name's records stay.", "Turn off", "danger")) return;
|
|
try {
|
|
toast(describeResult(await change("DELETE", "/encrypted-dns")));
|
|
rerender();
|
|
} catch (e) { toast(e.message, "err", 8000); }
|
|
}
|
|
|
|
// healthPanel shows the last check of the listeners: Jarl connects to each
|
|
// as a client would, checks the certificate and asks a question.
|
|
function healthPanel(hl) {
|
|
const kind = { dot: "DNS over TLS", doh: "DNS over HTTPS" };
|
|
const out = h("div");
|
|
const draw = (x) => {
|
|
if (!x) return mount(out, h("div", { class: "muted" }, "Not checked yet: Jarl checks a minute after it starts, then every 10 minutes."));
|
|
mount(out, h("ul", { class: "probes" }, x.probes.map((p) => h("li", null,
|
|
badge(p.ok ? "working" : "not working", p.ok ? "ok" : "err"), " ",
|
|
h("strong", null, kind[p.kind]), " ", h("span", { class: "mono" }, p.address + " port " + p.port),
|
|
p.ok ? h("span", { class: "muted small" }, " answered " + p.rcode + " in " + p.ms + " ms" + (p.cert_not_after ? " · certificate until " + fmtTime(p.cert_not_after) : "")) : null,
|
|
p.problem ? h("div", { class: "msg err small" }, p.problem) : null,
|
|
p.warning ? h("div", { class: "msg warn small" }, p.warning) : null))),
|
|
h("div", { class: "muted small" }, "Checked ", relTime(x.checked), "."));
|
|
};
|
|
draw(hl);
|
|
const btn = h("button", { class: "small", onclick: async () => {
|
|
btn.disabled = true;
|
|
try { draw(await api("POST", "/encrypted-dns/health", { body: {} })); } catch (e) { toast(e.message, "err", 8000); } finally { btn.disabled = false; }
|
|
} }, "Check now");
|
|
return h("div", { class: "panel" }, h("div", { class: "page-head" }, h("h2", { class: "grow" }, "Health"), h("div", { class: "actions" }, btn)),
|
|
h("p", { class: "muted small" }, "Jarl connects to each listener as a client would: the certificate must be for the host name, current, and the one Jarl last wrote; then it asks for the host name."),
|
|
out);
|
|
}
|