From 8ce66378183acfe38c5f565f104556e505fb6ce4 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 31 Oct 2026 06:43:48 +0000 Subject: [PATCH 1/2] fix(pit): prefer W3BS with Moshpit backfill --- .../extensions/ai-sidebar/background.js | 142 +++++++++++++++++- .../extensions/ai-sidebar/manifest.json | 1 + .../extensions/ai-sidebar/pit-proxy.js | 15 +- .../extensions/ai-sidebar/sidepanel.html | 2 +- .../extensions/ai-sidebar/sidepanel.js | 2 +- docs/moshpit-pit-toggle.md | 36 +++-- 6 files changed, 168 insertions(+), 30 deletions(-) diff --git a/apps/desktop/extensions/ai-sidebar/background.js b/apps/desktop/extensions/ai-sidebar/background.js index e930a59..88d9388 100644 --- a/apps/desktop/extensions/ai-sidebar/background.js +++ b/apps/desktop/extensions/ai-sidebar/background.js @@ -333,12 +333,142 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { }); // --- Pit toggle ---------------------------------------------------------- -// Resolves Moshpit names (.eggs, .moshpit, …) in THIS session only. The same -// helper that starts Tor runs a loopback SOCKS5 resolver on demand (/pit/*); -// the extension installs the PAC from pit-proxy.js, which sends only hosts the -// system resolver has no answer for to it — clearnet wins, nothing on the -// machine changes, no root. Whole-machine resolution is still -// `moshcode dns enable`. Off again on every fresh browser start, like Tor. +// W3BS Net is primary for recognized names; Moshpit is the backfill. For a +// bare W3BS name (fumb.duck), route to its W3BS host or published target before +// DNS. If W3BS has no record, the PAC and SOCKS helper use Moshpit resolution. +// All of this is scoped to this browser session and only runs while Pit is on. +const W3BS_NET_RESOLVE = 'https://w3bs.org/api/net/v1/resolve/'; +const W3BS_NET_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; +const W3BS_HOST_SUFFIX = '.w3bs.org'; +const pitNavigationGeneration = new Map(); +const pitNavigationPending = new Set(); +const pitNavigationChains = new Map(); + +function w3bsNameForHost(host) { + const lower = String(host || '').toLowerCase().replace(/\.$/, ''); + if (lower.endsWith(W3BS_HOST_SUFFIX)) { + const name = lower.slice(0, -W3BS_HOST_SUFFIX.length); + return W3BS_NET_NAME.test(name) ? { name } : null; + } + return W3BS_NET_NAME.test(lower) ? { name: lower } : null; +} + +function appendNetPath(destination, source) { + const target = new URL(destination); + const incoming = new URL(source); + if (incoming.pathname !== '/') { + target.pathname = `${target.pathname.replace(/\/+$/, '')}/${incoming.pathname.replace(/^\/+/, '')}`; + } + if (incoming.search) target.search = incoming.search; + if (incoming.hash) target.hash = incoming.hash; + return target.href; +} + +async function w3bsNetRecord(name) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 2500); + try { + const response = await fetch(`${W3BS_NET_RESOLVE}${encodeURIComponent(name)}`, { + headers: { accept: 'application/json' }, + redirect: 'error', + signal: controller.signal, + }); + if (response.status === 404) return null; + if (!response.ok) return null; + return await response.json(); + } catch (_) { + return null; + } finally { + clearTimeout(timer); + } +} + +async function routeW3bsNetNavigation(details) { + if (details.frameId !== 0 || details.tabId < 0) return; + const pendingKey = `${details.tabId}:${details.url}`; + if (pitNavigationPending.has(pendingKey)) return; + const generation = (pitNavigationGeneration.get(details.tabId) || 0) + 1; + pitNavigationGeneration.set(details.tabId, generation); + + let incoming; + try { + incoming = new URL(details.url); + } catch (_) { + return; + } + if (incoming.protocol !== 'https:') return; + const candidate = w3bsNameForHost(incoming.hostname); + if (!candidate) return; + + pitNavigationPending.add(pendingKey); + try { + const { pitEnabled } = await chrome.storage.local.get('pitEnabled'); + if (!pitEnabled || pitNavigationGeneration.get(details.tabId) !== generation) return; + + const record = await w3bsNetRecord(candidate.name); + if (!record || pitNavigationGeneration.get(details.tabId) !== generation) return; + + let base; + let preservePath = true; + if (record.source === 'w3bs') { + if (record.status !== 'active') { + base = `https://w3bs.org/net?q=${encodeURIComponent(candidate.name)}`; + preservePath = false; + } else if (typeof record.target === 'string' && record.target) { + try { + const target = new URL(record.target); + if (target.protocol !== 'https:' || target.username || target.password) return; + base = target.href; + } catch (_) { + return; + } + } else { + base = `https://${candidate.name}.w3bs.org/`; + } + } else if (record.source === 'moshpit') { + // W3BS Net imported this name as backfill; its public gateway carries the + // name and path without asking ordinary DNS to resolve the Moshpit TLD. + const resolved = typeof record.resolved === 'string' && W3BS_NET_NAME.test(record.resolved) + ? record.resolved + : candidate.name; + base = `https://${resolved}.pit.world/`; + } else { + return; + } + + const destination = preservePath ? appendNetPath(base, details.url) : base; + if (destination === details.url || pitNavigationGeneration.get(details.tabId) !== generation) return; + + const chain = pitNavigationChains.get(details.tabId) || { seen: new Set(), expiresAt: 0 }; + if (chain.expiresAt < Date.now()) chain.seen.clear(); + if (chain.seen.has(incoming.hostname)) return; + chain.seen.add(incoming.hostname); + chain.expiresAt = Date.now() + 15000; + pitNavigationChains.set(details.tabId, chain); + + await chrome.tabs.update(details.tabId, { url: destination }); + } catch (_) { + // A lookup or redirect failure leaves the existing Moshpit/clearnet path + // alone; the W3BS check must never block normal navigation. + } finally { + pitNavigationPending.delete(pendingKey); + } +} + +chrome.webNavigation.onBeforeNavigate.addListener((details) => { + void routeW3bsNetNavigation(details); +}); +chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { + if (changeInfo.status === 'complete') { + pitNavigationChains.delete(tabId); + } +}); +chrome.tabs.onRemoved.addListener((tabId) => { + pitNavigationChains.delete(tabId); + pitNavigationGeneration.delete(tabId); + for (const key of pitNavigationPending) if (key.startsWith(`${tabId}:`)) pitNavigationPending.delete(key); +}); + async function setPitBadge(on) { try { await chrome.action.setBadgeText({ text: on ? 'PIT' : '' }); diff --git a/apps/desktop/extensions/ai-sidebar/manifest.json b/apps/desktop/extensions/ai-sidebar/manifest.json index b72b4b2..1f45347 100644 --- a/apps/desktop/extensions/ai-sidebar/manifest.json +++ b/apps/desktop/extensions/ai-sidebar/manifest.json @@ -17,6 +17,7 @@ "activeTab", "scripting", "proxy", + "webNavigation", "privacy", "notifications", "alarms", diff --git a/apps/desktop/extensions/ai-sidebar/pit-proxy.js b/apps/desktop/extensions/ai-sidebar/pit-proxy.js index 41e401f..e1c791c 100644 --- a/apps/desktop/extensions/ai-sidebar/pit-proxy.js +++ b/apps/desktop/extensions/ai-sidebar/pit-proxy.js @@ -7,11 +7,9 @@ // resolver (tron-tor-helper, /pit/*) that answers through the Moshpit // DNS-over-HTTPS resolver, and the extension points a PAC script at it. // -// The PAC decides per host with `dnsResolve`: whatever the system resolver CAN -// answer goes DIRECT, untouched; only a host it has no answer for goes to the -// pit. That is the house policy — clearnet wins, the pit is the fallback — so no -// ending list is needed and a real domain is never redirected. Pure functions, -// tested in pit-proxy.test.js. +// W3BS names go through the W3BS-first resolver while Pit is on, even if public +// DNS has a stale answer. Other names the system resolver can answer stay +// DIRECT; unresolved names use Moshpit as the backfill. Pure functions. /** Loopback port the helper's pit resolver listens on (tron-tor-helper PIT_SOCKS_PORT). */ export const PIT_SOCKS_PORT = 9081; @@ -36,7 +34,12 @@ export function buildPitPac(port = PIT_SOCKS_PORT) { " if (!h || h === 'localhost' || h.indexOf('.') === -1) return 'DIRECT';", " if (h.slice(-10) === '.localhost') return 'DIRECT';", " if (/^[0-9.]+$/.test(h) || h.indexOf(':') !== -1) return 'DIRECT';", - // Clearnet wins: anything the system resolver knows stays exactly as it was. + // W3BS Net is the primary namespace for its two-label names. Its DoH + // resolver falls through to public DNS when W3BS has no matching record. + " var suffix = '.w3bs.org';", + " var w3bsName = h.slice(-suffix.length) === suffix ? h.slice(0, -suffix.length) : '';", + ` if (w3bsName && w3bsName.split('.').length === 2) return 'SOCKS5 127.0.0.1:${p}';`, + // For all remaining names, ordinary DNS wins and Moshpit backfills misses. " if (dnsResolve(h)) return 'DIRECT';", ` return 'SOCKS5 127.0.0.1:${p}';`, '}', diff --git a/apps/desktop/extensions/ai-sidebar/sidepanel.html b/apps/desktop/extensions/ai-sidebar/sidepanel.html index 8b7dad4..b032511 100644 --- a/apps/desktop/extensions/ai-sidebar/sidepanel.html +++ b/apps/desktop/extensions/ai-sidebar/sidepanel.html @@ -15,7 +15,7 @@ - + diff --git a/apps/desktop/extensions/ai-sidebar/sidepanel.js b/apps/desktop/extensions/ai-sidebar/sidepanel.js index 2b863c7..3201ae9 100644 --- a/apps/desktop/extensions/ai-sidebar/sidepanel.js +++ b/apps/desktop/extensions/ai-sidebar/sidepanel.js @@ -329,7 +329,7 @@ async function togglePit() { : trust.why === 'no-certutil' ? 'https:// on a pit name will warn until certutil is installed (Debian/Ubuntu: libnss3-tools, Fedora: nss-tools, Arch: nss).' : 'https:// on a pit name will warn on this platform; run moshcode dns enable for the certificate.'; - const tip = `Clearnet names are untouched. ${httpsTip}`; + const tip = `W3BS Net names take priority; Moshpit is the backfill. Other clearnet names stay direct. ${httpsTip}`; if (!turningOn) { setPitButton(false); hideNetStatus(); diff --git a/docs/moshpit-pit-toggle.md b/docs/moshpit-pit-toggle.md index 4048cad..1b3f395 100644 --- a/docs/moshpit-pit-toggle.md +++ b/docs/moshpit-pit-toggle.md @@ -19,7 +19,7 @@ ways to make them work, and the settings page says so: | Needs root | yes (rewrites the resolver config, installs a local CA) | no | | Survives restart | yes | no — off again on every launch, like 🧅 Tor | | `https://` on a pit name | works (pinned proxy + local CA) | works on Linux: the leaf is trusted per name on first use, against the registry pin | -| Clearnet names | forwarded to public resolvers | never touched | +| Clearnet names | forwarded to public resolvers | ordinary sites stay direct; W3BS Net names take priority while Pit is on | The toggle is for the laptop where DNS is not yours to change, or the first five minutes before you have run `moshcode dns enable`. @@ -41,18 +41,22 @@ It is built exactly like the 🧅 Tor toggle: for 30 s), falls back to the system resolver if that fails, connects to the answer and relays bytes. TLS passes through untouched. Only `CONNECT` over TCP is implemented; the browser never asks a SOCKS proxy for UDP. -3. **The PAC.** The extension (`apps/desktop/extensions/ai-sidebar/pit-proxy.js`) - installs a `pac_script` proxy config whose `FindProxyForURL` calls - `dnsResolve(host)`. **Anything the system resolver can answer goes `DIRECT`, - untouched.** Only a host it has no answer for is sent to the pit resolver. - Loopback, single-label intranet names and IP literals always go `DIRECT`. - -That last rule is the point. Real TLDs (`.io`, `.dev`, `.sh`, …) have been -claimed as Moshpit endings too, so an ending list cannot say whether a host is a -Moshpit name. The house policy is **clearnet wins, the pit is the fallback**: a -resolver that silently redirected a domain which already works would be -indistinguishable from a hijack. `dnsResolve` applies that policy per host with -no ending list to fetch, cache or age out. +3. **W3BS first.** While Pit is on, the extension checks two-label names against + W3BS Net before navigation. An active W3BS record opens its published HTTPS + target, or its canonical `.w3bs.org` host when no target is published. + A name imported from Moshpit uses its `.pit.world` gateway. A W3BS miss moves + on to the Moshpit resolver. +4. **The PAC.** The extension (`apps/desktop/extensions/ai-sidebar/pit-proxy.js`) + installs a `pac_script` proxy config. W3BS Net hostnames go through the + W3BS-first resolver even when public DNS has a stale answer. For other names, + anything the system resolver answers goes `DIRECT`; only unresolved names go + to the Moshpit resolver. Loopback, single-label intranet names and IP + literals always go `DIRECT`. + +W3BS is the primary registry when Pit is enabled. Moshpit only fills names W3BS +does not own; ordinary public sites still connect directly. This lets an active +W3BS name override a stale public DNS answer without routing unrelated clearnet +traffic through the pit. The PAC is not `mandatory`: if it ever fails to evaluate, Chromium falls back to `DIRECT` and ordinary browsing keeps working. @@ -207,9 +211,9 @@ curl -X POST http://127.0.0.1:19061/pit/stop database, which only Chromium on Linux reads. Windows can explicitly opt into registry-root trust using the setup below. This does not trust self-signed origins, and macOS trust setup remains unchanged. -- **"Moshpit wins."** The resolvers' `MOSHPIT_RESOLVE_MODE=moshpit` lets a - registered name override a clearnet one. The toggle only implements the - default `fallback` policy. +- **"Moshpit wins over clearnet."** The resolvers' `MOSHPIT_RESOLVE_MODE=moshpit` + lets a registered Moshpit name override a public name. TronBrowser keeps + Moshpit as backfill; W3BS primary records are checked first while Pit is on. - **Persisting across launches.** Mirrors Tor deliberately; a setting to keep the pit on would be a small follow-up. From ac0f58bd52fe488c15173017e5bf8d1eadd36050 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 31 Oct 2026 06:45:49 +0000 Subject: [PATCH 2/2] test(pit): mock navigation listeners --- apps/desktop/extensions/ai-sidebar/background-helper.test.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/desktop/extensions/ai-sidebar/background-helper.test.js b/apps/desktop/extensions/ai-sidebar/background-helper.test.js index a1601c4..7055c8e 100644 --- a/apps/desktop/extensions/ai-sidebar/background-helper.test.js +++ b/apps/desktop/extensions/ai-sidebar/background-helper.test.js @@ -21,7 +21,10 @@ describe('extension network helper requests', () => { sendMessage: done(), }, storage: { local: storage(), session: storage(), onChanged: { addListener: vi.fn() } }, - tabs: { onUpdated: { addListener: vi.fn() }, update: done() }, + webNavigation: { onBeforeNavigate: { addListener: vi.fn() } }, + tabs: { + onUpdated: { addListener: vi.fn() }, onRemoved: { addListener: vi.fn() }, update: done(), + }, declarativeNetRequest: { updateSessionRules: done() }, proxy: { settings: { set: done(), clear: done() } }, privacy: { network: { webRTCIPHandlingPolicy: { set: done(), clear: done() } } },