jarld checks the DoT and DoH listeners every 10 minutes as a client would (page, dashboard, jarl encrypted-dns health, jarl_encrypted_dns_up). The Compare views tab and jarl zone diff show what differs between a zone's views, with Use this version. --local works on unlinked zones. A changed certificates.dir is followed by encrypted DNS's tls statements. Sharing an already shared version replaces other views' overrides. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
252 lines
15 KiB
JavaScript
252 lines
15 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, "Certificate"), h("dd", null, h("a", { href: "#/certificates" }, s.certificate || "?"), s.hostname ? " (" + s.hostname + ")" : 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 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, "kdig +tls-ca +tls-host=" + s.hostname + dotPortArg + " @" + addr + " example.com"))));
|
|
}
|
|
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, "curl -s -H 'accept: application/dns-json' '" + s.doh_url + "?name=example.com'"), " (or ", h("code", null, "dig +https @" + 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 host = input({ value: s.hostname || "", placeholder: "dns.adbraun.lol", class: "mono", style: { maxWidth: "320px" } });
|
|
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" });
|
|
|
|
// 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);
|
|
}));
|
|
};
|
|
drawViews(s.views);
|
|
|
|
const fillCerts = () => {
|
|
const name = host.value.trim().toLowerCase().replace(/\.$/, "");
|
|
const fit = s.certificates.filter((c) => covers([...(c.names || []), ...(c.domains || [])], name));
|
|
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"));
|
|
};
|
|
fillCerts();
|
|
let viewTimer = null;
|
|
host.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);
|
|
}, 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 = { 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]; }
|
|
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.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"] } });
|
|
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 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("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."),
|
|
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.")),
|
|
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);
|
|
}
|