Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/desktop/extensions/ai-sidebar/background-helper.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() } } },
Expand Down
142 changes: 136 additions & 6 deletions apps/desktop/extensions/ai-sidebar/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' : '' });
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/extensions/ai-sidebar/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"activeTab",
"scripting",
"proxy",
"webNavigation",
"privacy",
"notifications",
"alarms",
Expand Down
15 changes: 9 additions & 6 deletions apps/desktop/extensions/ai-sidebar/pit-proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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}';`,
'}',
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/extensions/ai-sidebar/sidepanel.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
<button id="media" class="tor-btn" title="Open Media (bittorrented.com)">📺</button>
<button id="tor" class="tor-btn" title="Route this session through Tor" aria-pressed="false">🧅 Tor</button>
<button id="tor-info" class="tor-info-btn" title="What does Tor mode do?" aria-label="About Tor mode">?</button>
<button id="pit" class="tor-btn" title="Resolve Moshpit names (.eggs, .moshpit, …) in this session — no root, nothing on the machine changes" aria-pressed="false">🤘 Pit</button>
<button id="pit" class="tor-btn" title="Resolve W3BS names first, with Moshpit as backfill, in this session — no root, nothing on the machine changes" aria-pressed="false">🤘 Pit</button>
<button id="settings" title="Settings">⚙</button>
</header>

Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/extensions/ai-sidebar/sidepanel.js
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ async function togglePit() {
: trust.why === 'no-certutil'
? '<code>https://</code> on a pit name will warn until <code>certutil</code> is installed (Debian/Ubuntu: <code>libnss3-tools</code>, Fedora: <code>nss-tools</code>, Arch: <code>nss</code>).'
: '<code>https://</code> on a pit name will warn on this platform; run <code>moshcode dns enable</code> 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();
Expand Down
36 changes: 20 additions & 16 deletions docs/moshpit-pit-toggle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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 `<name>.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.
Expand Down Expand Up @@ -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.

Expand Down
Loading