// Blocking: your block/allow rules and blocklists (docs/blocking.md). import { h, mount, table, field, input, select, checkbox, toast, errorBox, badge, confirmDialog, modal, relTime, time } from "../ui.js"; import { get, change, describeResult, draftQuery, can, api } from "../api.js"; import { rerender } from "../app.js"; import { pendingBadge } from "./zones.js"; // Well-known lists in Adblock format (they say which entries cover // subdomains). Sizes change; these are rough guides. const suggestions = [ ["hagezi-light", "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/light.txt", "Hagezi Light: small, very few false positives"], ["hagezi-multi", "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/multi.txt", "Hagezi Multi Normal: ads, tracking, malware; a good default"], ["hagezi-pro", "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/pro.txt", "Hagezi Pro: more thorough, occasionally blocks something wanted"], ["oisd-small", "https://small.oisd.nl/", "OISD Small: ads and tracking, very few false positives"], ]; const stateBadge = { ok: ["in use", "ok"], waiting: ["downloading", "accent"], error: ["update failed", "err"], disabled: ["disabled", ""], idle: ["no effect", "warn"], }; let refreshTimer = null; export async function render(el, params, query) { clearTimeout(refreshTimer); const [lists, rules, views, status] = await Promise.all([get("/blocking/lists", draftQuery()), get("/blocking/rules", draftQuery()), get("/views"), get("/blocking/status")]); const writable = can("blocking", true); const recursive = lists.concat(rules).find((x) => x.auto_views); const autoViews = recursive ? recursive.views : null; const checkOut = h("div"); const checkName = input({ type: "search", placeholder: "ads.example.com", class: "mono", value: (query && query.get("check")) || "" }); const runCheck = async (ev) => { if (ev) ev.preventDefault(); const name = checkName.value.trim(); if (!name) return; try { const res = await get("/blocking/check", { name }); mount(checkOut, checkResult(name, res, rules, writable)); } catch (e) { mount(checkOut, errorBox(e)); } }; const paused = status.paused_until || status.paused_indefinitely; mount(el, h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Blocking"), h("div", { class: "muted" }, "Answer \"doesn't exist\" for ads, trackers and malware, for the devices that use this server to resolve names. Your rules come first, then the blocklists.")), writable && !paused && (lists.length || rules.length) ? pauseControls() : null), paused ? h("div", { class: "msg warn" }, h("strong", null, status.paused_indefinitely ? "Blocking is paused: " : "Blocking is paused until " + new Date(paused).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hourCycle: "h23" }) + ": "), status.paused_indefinitely ? "every name is answered normally until you resume it. " : "every name is answered normally. It resumes by itself. ", writable ? h("button", { class: "small", onclick: () => pause(0) }, "Resume now") : null) : null, h("div", { class: "panel" }, h("form", { class: "actions", onsubmit: runCheck }, h("label", { class: "muted" }, "Check a name"), checkName, h("button", { type: "submit" }, "Check")), checkOut), rulesPanel(rules, views, autoViews, writable), listsPanel(lists, views, autoViews, writable)); if (checkName.value) runCheck(); // Lists are downloaded in the background, and a pause ends by itself: // show it when it happens. let wait = 0; if (lists.some((l) => l.state === "waiting")) wait = 4000; if (status.paused_until) wait = Math.max(1000, Math.min(wait || Infinity, new Date(status.paused_until) - Date.now() + 3000)); if (wait) refreshTimer = setTimeout(() => { if (location.hash.startsWith("#/blocking") && el.isConnected) rerender(); }, wait); } function pauseControls() { const length = select([["5", "5 minutes"], ["15", "15 minutes"], ["60", "1 hour"], ["240", "4 hours"], ["-1", "Until I resume"]], "15"); return h("div", { class: "actions" }, length, h("button", { title: "Answer every name normally for a while, to see whether blocking is what breaks something", onclick: () => pause(Number(length.value)) }, "Pause blocking")); } // pause pauses blocking for minutes (-1: until resumed), or resumes it // (0). It applies at once, also with a draft open: it's for trying // something now. async function pause(minutes) { try { const body = minutes < 0 ? { indefinitely: true } : { minutes }; await api("POST", minutes ? "/blocking/pause" : "/blocking/resume", minutes ? { body } : {}); toast(minutes ? "Blocking paused" : "Blocking resumed"); rerender(); } catch (e) { toast(e.message, "err", 8000); } } // checkResult shows what blocking does with a name, and offers the way // to stop it: an allow rule (it beats every list, and a rule for a parent // domain), or, when your own block rule is for this very name, removing // that rule (a second rule for the same name isn't allowed). function checkResult(name, res, rules, writable) { if (!res.length) return h("p", { class: "muted" }, "Blocking isn't used in any view yet: add a rule or a blocklist below."); const n = name.trim().toLowerCase().replace(/\.$/, ""); const ruleById = new Map(rules.map((r) => [r.id, r])); const blocked = res.filter((m) => m.result === "blocked"); const ownBlocks = [...new Set(blocked.map((m) => ruleById.get(m.rule_id)).filter((r) => r && r.name === n))]; const sameName = rules.filter((r) => r.name === n && !ownBlocks.includes(r)); let action = null; if (writable && ownBlocks.length) { action = h("p", null, h("button", { class: "small", onclick: () => removeRules(ownBlocks) }, "Remove your block rule for " + n), h("span", { class: "muted small" }, ownBlocks.some((r) => !r.exact) ? " (it also covers the names below it)" : "")); } else if (writable && blocked.length && !sameName.length) { action = h("p", null, h("button", { class: "small", onclick: () => addRule(n, "allow", false, []) }, "Allow " + n), h("span", { class: "muted small" }, " adds an allow rule for it and the names below it, which beats every list and any rule for a parent domain.")); } else if (writable && blocked.length) { action = h("p", { class: "muted small" }, "There already is a rule for " + n + " (in other views): change it under Your rules."); } return h("div", null, h("ul", { class: "plain" }, res.map((m) => { const rule = ruleById.get(m.rule_id); return h("li", null, m.view ? h("span", { class: "muted" }, "view " + m.view + ": ") : null, m.result === "blocked" ? badge("blocked", "err") : m.result === "allowed" ? badge("allowed", "ok") : badge("not blocked", ""), " ", m.list ? ["by blocklist ", h("strong", null, m.list), " (entry ", h("code", null, m.entry), ")"] : m.result !== "none" ? ["by your rule to ", rule ? rule.action : "", " ", h("code", null, rule ? rule.name : m.entry.replace(/^\*\./, "")), rule && !rule.exact ? " and below" : ""] : null); })), action); } async function removeRules(list) { try { let r; for (const rule of list) r = await change("DELETE", "/blocking/rules/" + rule.id); toast(describeResult(r)); rerender(); } catch (e) { toast(e.message, "err", 8000); } } async function addRule(name, action, exact, views, comment) { try { const r = await change("POST", "/blocking/rules", { name, action, exact, views, comment: comment || "" }); toast(describeResult(r)); rerender(); return true; } catch (e) { toast(e.message, "err", 8000); return false; } } // viewPicker is a compact choice of views: it shows the choice, and opens // a list of checkboxes (none ticked: every view that offers recursion). function viewPicker(views, autoViews, selected = []) { if (!views.length) return { el: null, picker: null, value: () => [] }; const boxes = views.map((v) => checkbox(v.name, selected.includes(v.name))); const value = () => views.filter((v, i) => boxes[i].input.checked).map((v) => v.name); const summary = h("summary"); const show = () => { const sel = value(); summary.textContent = sel.length ? sel.join(", ") : "automatic" + (autoViews && autoViews.length ? " (" + autoViews.join(", ") + ")" : ""); }; boxes.forEach((b) => b.input.addEventListener("change", show)); show(); const picker = h("details", { class: "view-picker" }, summary, h("div", { class: "view-picker-list" }, h("div", { class: "muted small" }, "None ticked: every view that offers recursion"), boxes)); return { el: h("div", { class: "f" }, h("label", null, "Views"), picker), picker, value }; } // The views a list or rule names itself ([] when automatic). const ownViews = (x) => (x.auto_views ? [] : x.views); // editDialog changes a list or rule: fields are [label, control, help] // rows, and data() the PATCH body. async function editDialog(title, path, rows, data) { const err = h("div"); await modal(title, h("div", null, h("div", { class: "form-grid" }, rows.filter(Boolean).map(([l, c, help]) => field(l, c, help))), err), [ { label: "Cancel", value: null }, { label: "Save", kind: "primary", onclick: async () => { try { const r = await change("PATCH", path, data()); toast(describeResult(r)); rerender(); return true; } catch (e) { mount(err, errorBox(e)); return false; } } }, ], { sticky: true }); } function editList(l, views, autoViews) { const name = input({ value: l.name, class: "mono" }); const url = input({ value: l.url, class: "mono", style: { width: "100%" } }); const comment = input({ value: l.comment || "" }); const vp = viewPicker(views, autoViews, ownViews(l)); editDialog("Edit blocklist " + l.name, "/blocking/lists/" + l.id, [ ["Name", name], ["URL", url, "A new URL is downloaded right after saving."], vp.picker ? ["Views", vp.picker, "None ticked: every view that offers recursion."] : null, ["Comment", comment], ], () => ({ name: name.value.trim(), url: url.value.trim(), views: vp.value(), comment: comment.value })); } function editRule(r, views, autoViews) { const name = input({ value: r.name, class: "mono" }); const action = select([["block", "Block"], ["allow", "Allow"]], r.action); const exact = checkbox("only this exact name", !!r.exact); const comment = input({ value: r.comment || "" }); const vp = viewPicker(views, autoViews, ownViews(r)); editDialog("Edit the rule for " + r.name, "/blocking/rules/" + r.id, [ ["Action", action], ["Domain", name], ["", exact], vp.picker ? ["Views", vp.picker, "None ticked: every view that offers recursion."] : null, ["Comment", comment], ], () => ({ name: name.value.trim(), action: action.value, exact: exact.input.checked, views: vp.value(), comment: comment.value })); } // An open view picker closes when you click elsewhere. document.addEventListener("click", (ev) => { for (const d of document.querySelectorAll("details.view-picker[open]")) if (!d.contains(ev.target)) d.open = false; }); function viewsCell(x) { if (!x.views.length) return h("span", { class: "muted" }, "none"); return x.auto_views ? [x.views.join(", "), h("span", { class: "muted small" }, " (auto)")] : x.views.join(", "); } function rulesPanel(rules, views, autoViews, writable) { const name = input({ placeholder: "ads.example.com", class: "mono" }); const action = select([["block", "Block"], ["allow", "Allow"]], "block"); const exact = checkbox("only this exact name", false); const comment = input({ placeholder: "optional" }); const vp = viewPicker(views, autoViews); const err = h("div"); const form = h("form", { class: "inline-form", onsubmit: async (ev) => { ev.preventDefault(); try { const r = await change("POST", "/blocking/rules", { name: name.value.trim(), action: action.value, exact: exact.input.checked, views: vp.value(), comment: comment.value }); toast(describeResult(r)); rerender(); } catch (e) { mount(err, errorBox(e)); } } }, h("div", { class: "f" }, h("label", null, "Action"), action), h("div", { class: "f grow" }, h("label", null, "Domain"), name), h("div", { class: "f" }, h("label", null, " "), exact), h("div", { class: "f" }, h("label", null, "Comment"), comment), vp.el, h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add rule"))); return h("div", { class: "panel" }, h("h2", null, "Your rules"), h("p", { class: "muted small" }, "A rule covers the domain and every name below it, unless it's for the exact name only. Rules beat every blocklist, and the most specific rule wins: allowing cdn.example.com overrides blocking example.com for cdn.example.com and the names below it."), writable ? [form, err] : null, table([ { label: "Action", render: (r) => [r.action === "block" ? badge("block", "err") : badge("allow", "ok"), r.pending ? [" ", pendingBadge(r.pending)] : null] }, { label: "Domain", render: (r) => [h("span", { class: "mono" }, r.name), r.exact ? null : h("span", { class: "muted small" }, " and below")] }, views.length ? { label: "Views", render: viewsCell } : null, { label: "Comment", render: (r) => h("span", { class: "muted" }, r.comment || "") }, writable ? { label: "", render: (r) => r.pending === "delete" ? "" : h("span", null, h("button", { class: "small", onclick: () => editRule(r, views, autoViews) }, "Edit"), " ", h("button", { class: "small danger", onclick: () => del("/blocking/rules/" + r.id, "the rule for " + r.name) }, "Delete")), tdClass: "actions-cell" } : null, ].filter(Boolean), rules, { empty: "No rules yet.", rowClass: (r) => r.pending ? "pending-" + r.pending : "" })); } function listsPanel(lists, views, autoViews, writable) { const name = input({ placeholder: "hagezi-multi", class: "mono", style: { width: "11em" } }); const url = input({ placeholder: "https://…", class: "mono" }); const vp = viewPicker(views, autoViews); const err = h("div"); const form = h("form", { class: "inline-form", onsubmit: async (ev) => { ev.preventDefault(); try { const r = await change("POST", "/blocking/lists", { name: name.value.trim(), url: url.value.trim(), views: vp.value() }); toast(describeResult(r) + (r.applied ? " — jarld downloads it now" : "")); rerender(); } catch (e) { mount(err, errorBox(e)); } } }, h("div", { class: "f" }, h("label", null, "Name"), name), h("div", { class: "f grow" }, h("label", null, "URL"), url), vp.el, h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add list"))); const used = new Set(lists.map((l) => l.url)); const picks = suggestions.filter(([, u]) => !used.has(u)); const totalMB = lists.reduce((a, l) => a + (l.memory_mb || 0), 0); return h("div", { class: "panel" }, h("h2", null, "Blocklists"), h("p", { class: "muted small" }, "jarld downloads each list, checks it for changes daily, and keeps using the previous version if an update fails. Hosts, plain domain and Adblock (||domain^) lists work; Adblock entries also cover the names below them."), writable ? [form, err] : null, writable && picks.length ? h("p", { class: "small" }, h("span", { class: "muted" }, "Well-known lists: "), picks.map(([n, u, d], i) => [i ? " · " : "", h("a", { href: "#", title: d + "\n" + u, onclick: (ev) => { ev.preventDefault(); name.value = n; url.value = u; name.focus(); } }, n)])) : null, table([ { label: "Name", render: (l) => [h("strong", { class: "mono" }, l.name), l.pending ? [" ", pendingBadge(l.pending)] : null] }, { label: "State", render: (l) => { const [t, k] = stateBadge[l.state] || [l.state, ""]; return [badge(t, k), l.status && l.status.error ? h("div", { class: "small err-text" }, l.status.error) : null]; } }, { label: "Domains", render: (l) => l.status && l.status.updated_at ? l.status.entries.toLocaleString() : "–", tdClass: "right" }, views.length ? { label: "Views", render: viewsCell } : null, { label: "Updated", render: (l) => l.status && l.status.updated_at ? h("span", { title: "checked " + time(l.status.checked_at) }, relTime(l.status.updated_at)) : "–", tdClass: "small" }, { label: "Memory", render: (l) => l.memory_mb ? h("span", { title: "named's memory for this list, estimated (all its views)" }, "~" + l.memory_mb + " MB") : "–", tdClass: "small right" }, { label: "URL", render: (l) => h("a", { href: l.url, target: "_blank", rel: "noopener", class: "small mono", title: l.url }, shortURL(l.url)) }, writable ? { label: "", render: (l) => l.pending === "delete" ? "" : h("span", null, h("button", { class: "small", onclick: () => editList(l, views, autoViews) }, "Edit"), " ", h("button", { class: "small", disabled: l.state === "disabled" || !!l.pending, title: "Download it now (it is checked daily anyway)", onclick: () => refresh(l) }, "Refresh"), " ", h("button", { class: "small", onclick: () => toggle(l) }, l.disabled ? "Enable" : "Disable"), " ", h("button", { class: "small danger", onclick: () => del("/blocking/lists/" + l.id, "blocklist " + l.name) }, "Delete")), tdClass: "actions-cell" } : null, ].filter(Boolean), lists, { empty: "No blocklists yet.", rowClass: (l) => l.pending ? "pending-" + l.pending : l.disabled ? "disabled" : "" }), totalMB ? h("p", { class: "muted small" }, "named needs about " + totalMB + " MB of memory for these lists (an estimate). A list costs memory in every view it applies to, so pick views if some don't need it.") : null); } function shortURL(u) { const s = u.replace(/^https?:\/\//, ""); return s.length > 48 ? s.slice(0, 22) + "…" + s.slice(-24) : s; } async function refresh(l) { toast("Downloading " + l.name + "…", "ok", 2000); try { const r = await api("POST", "/blocking/lists/" + l.id + "/refresh"); if (r.status && r.status.error) toast(l.name + ": " + r.status.error, "err", 10000); else toast(l.name + ": " + r.status.entries.toLocaleString() + " domains"); rerender(); } catch (e) { toast(e.message, "err", 8000); } } async function toggle(l) { try { const r = await change("PATCH", "/blocking/lists/" + l.id, { disabled: !l.disabled }); toast(describeResult(r)); rerender(); } catch (e) { toast(e.message, "err", 8000); } } async function del(path, what) { if (!(await confirmDialog("Delete", "Delete " + what + "?", "Delete", "danger"))) return; try { const r = await change("DELETE", path); toast(describeResult(r)); rerender(); } catch (e) { toast(e.message, "err", 8000); } }