Merge ship/mirrors-and-cf: branded error pages and the mirrors docs

These three commits were pushed to the forge's theseus and site repos
from their branch but never reached master, so pushing master would
have dropped them there. Merged so master carries them: the branded
error page on BCNR-to-clearnet fallback failures, the Cloudflare Bot
Fight 503 hint with a .bch mirror suggestion, and the site's docs page
for the p-record reverse-proxy workaround.
This commit is contained in:
Local Dev 2026-10-04 15:14:59 +02:00
commit f3fbbc5805
2 changed files with 128 additions and 4 deletions

View file

@ -182,6 +182,50 @@
},
actions: ["retry", "home"],
},
"cf-blocked": {
icon: "⛔", iconCls: "warn",
title: "This site blocks Electron browsers",
sub: host ? (host + " returned 503 — Cloudflare Bot Fight Mode") : "Cloudflare Bot Fight Mode",
body: (b) => {
b.append("The server at ");
b.append(hostSpan(host));
b.append(" is behind Cloudflare's Bot Fight Mode. Cloudflare fingerprints the browser's TLS handshake below HTTP and refuses Electron-based browsers like Theseus, no matter what user-agent is sent.");
b.append(document.createElement("br"));
b.append(document.createElement("br"));
const mirror = q.get("mirror");
if (mirror) {
b.append("A ");
const s = el("b", null, ".bch mirror");
b.append(s);
b.append(" is registered on chain for this site: ");
const a = el("a", "host", mirror);
a.href = "#";
a.style.cursor = "pointer";
a.addEventListener("click", (ev) => {
ev.preventDefault();
// Preserve the original path/search/hash so /faq on the upstream
// becomes /faq on the mirror, not the mirror's root.
let origPath = "/";
try { const u = new URL(url); origPath = u.pathname + u.search + u.hash; } catch {}
window.errorpage.retry("https://" + mirror + origPath);
});
b.append(a);
b.append(". Theseus proxies the request through Node's HTTP stack, which Cloudflare doesn't block — the page will load.");
} else {
b.append("No .bch mirror is registered for this site yet. See ");
const a = el("a", "host", "silentmode.st/mirrors");
a.href = "#";
a.style.cursor = "pointer";
a.addEventListener("click", (ev) => {
ev.preventDefault();
window.errorpage.openExternal("https://silentmode.st/mirrors/");
});
b.append(a);
b.append(" — it explains why this happens and how to mint one.");
}
},
actions: ["retry", "home"],
},
"generic": {
icon: "✕", iconCls: "",
title: "Couldn't load this page",

88
main.js
View file

@ -3874,6 +3874,37 @@ function loadHome(id) {
if (id === activeId) pushNav(t.prov);
emitTabs();
}
// Scan the warm BNS index for a name whose `p` record proxies the given
// origin. Only exact-host matches — a `p` value of "https://x.example/a" only
// mirrors x.example, not sub.x.example. Returns the first match, or null.
// Cheap: sharedIndex is in-memory and typically < 10k entries.
function findMirrorFor(originHost) {
if (!sharedIndex || typeof sharedIndex.values !== "function") return null;
const wanted = String(originHost || "").toLowerCase();
if (!wanted) return null;
for (const entry of sharedIndex.values()) {
const p = entry?.records?.p;
if (!p || typeof p !== "string") continue;
try {
if (new URL(p).hostname.toLowerCase() === wanted) return entry.name;
} catch {}
}
return null;
}
// CF Bot Fight Mode returns 503 with a short body carrying either the
// `cf-mitigated: block` header or "Just a moment" / "cloudflare" in the
// visible text. We can't see headers from `did-navigate`, so we sniff the
// page body via executeJavaScript once loading finishes. Keep the string
// short — the CF page is a few KB, real 503s from origin servers can be
// large HTML with different content.
function looksLikeCloudflareBlock(bodyText) {
if (typeof bodyText !== "string" || !bodyText) return false;
if (bodyText.length > 8_000) return false;
const s = bodyText.toLowerCase();
return (s.includes("cloudflare") && (s.includes("blocked") || s.includes("just a moment") || s.includes("attention required")))
|| s.includes("cf-chl")
|| s.includes("cf_chl");
}
// Errors we deliberately ignore (Chromium's own reasons that shouldn't show
// a user-facing error page):
// -3 ERR_ABORTED — navigation superseded by another / user pressed Stop
@ -4038,6 +4069,50 @@ function createTab(initial, opts = {}) {
if (tab.id === activeId) pushNav(tab.prov);
});
wc.on("did-navigate", () => { if (tab.id === activeId) { notifyTabChange(); emitPwAvailability(); } });
// Cloudflare Bot Fight Mode fingerprints Electron's TLS ClientHello and
// returns 503 to Theseus regardless of user-agent. When we see 503 on a
// top-level main-frame HTTPS load, sniff the body for the CF signature
// and — if it matches — replace the tab with our branded error page,
// pre-filled with a `.bch` mirror suggestion if one exists on chain.
// See site/mirrors/ for the user-facing docs on the workaround.
wc.on("did-navigate", (_e, url, httpResponseCode) => {
if (httpResponseCode !== 503 || tab.internalNav) return;
let parsed; try { parsed = new URL(url); } catch { return; }
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return;
const host = parsed.hostname;
// Small delay so the body is present. did-navigate fires early in some
// renders (before all DOM text is materialized); dom-ready is more
// reliable but harder to plumb per-navigation, so a short setTimeout on
// the executeJavaScript call is a workable compromise.
setTimeout(() => {
// The user may have navigated away in the 250 ms window (a JS redirect
// after the 503, or a manual click). Skip if the tab is now on a
// different URL — otherwise we'd sniff a different page's body and
// misidentify it as a CF block.
try { if (wc.isDestroyed() || wc.getURL() !== url) return; } catch { return; }
if (tab.internalNav) return;
wc.executeJavaScript("(document.body && document.body.innerText || '').slice(0, 4000)", true)
.then((text) => {
if (!looksLikeCloudflareBlock(text)) return;
try { if (wc.isDestroyed() || wc.getURL() !== url) return; } catch { return; }
const mirror = findMirrorFor(host);
const q = new URLSearchParams({
kind: "cf-blocked", host, url,
code: "503", desc: "Cloudflare Bot Fight Mode",
});
if (mirror) q.set("mirror", mirror);
tab.internalNav = true;
tab.title = "Error — " + host;
tab.prov = { host, kind: "error", code: 503, desc: "cloudflare-block" };
wc.loadFile(path.join(__dirname, "error.html"), { search: q.toString() })
.catch((e) => console.warn("cf-block error page load failed:", e?.message))
.finally(() => { tab.internalNav = false; });
if (tab.id === activeId) pushNav(tab.prov);
emitTabs();
})
.catch(() => {});
}, 250);
});
wc.on("did-navigate-in-page", () => { if (tab.id === activeId) notifyTabChange(); });
// Translator state is per-document: a new navigation drops any "translated"
// flag, the autoTried latch, and the cached page language. The chip then
@ -4099,13 +4174,18 @@ function createTab(initial, opts = {}) {
wc.on("did-finish-load", () => { if (!tab.settings && !tab.addonId) webapps.probeTab(tab).then(() => { if (tab.id === activeId) emitTabs(); }).catch(() => {}); });
// Failed loads: NAME_NOT_RESOLVED, CONNECTION_REFUSED, cert errors, etc.
// Show the branded error page instead of Chromium's default "This site
// can't be reached". Skip subframe errors, our own programmatic loads,
// and the couple of Chromium codes that fire on normal user actions
// (Stop / superseded nav / extension cancel).
// can't be reached". Skip subframe errors and the couple of Chromium
// codes that fire on normal user actions (Stop / superseded nav /
// extension cancel). Also skip failures of our own file:// loads
// (error.html / home.html) to avoid recursion — but DO handle failures
// of programmatic loadURL calls to clearnet (BNS fallbackToWeb sets
// internalNav to block will-navigate re-entry, not to swallow errors;
// without this, a cert-expired or unreachable upstream after a failed
// BNS lookup leaves the tab blank).
wc.on("did-fail-load", (_e, code, desc, validatedURL, isMainFrame) => {
if (!isMainFrame) return;
if (tab.internalNav) return;
if (ERROR_CODE_IGNORE.has(code)) return;
if (typeof validatedURL === "string" && validatedURL.startsWith("file://")) return;
loadErrorPage(tab, tab.id, { url: validatedURL || tab.url, code, desc });
});
// Firefox / Chrome-style bottom-left link preview: fires with the href