diff --git a/apps/desktop/extensions/ai-sidebar/background.js b/apps/desktop/extensions/ai-sidebar/background.js index 9f61814..e64cc82 100644 --- a/apps/desktop/extensions/ai-sidebar/background.js +++ b/apps/desktop/extensions/ai-sidebar/background.js @@ -1,4 +1,4 @@ -import { destinationFor, moshpitConfig, parseRegistryName } from './moshpit.js'; +import { destinationFor, moshpitBypassHosts, moshpitConfig, parseRegistryName } from './moshpit.js'; // Open the AI side panel when the toolbar action is clicked. chrome.sidePanel @@ -168,7 +168,7 @@ async function stopTorViaHelper() { } catch (_) { /* helper not running — nothing to stop */ } } -function torProxyConfig(port) { +function torProxyConfig(port, pitHosts = []) { return { mode: 'fixed_servers', rules: { @@ -177,7 +177,13 @@ function torProxyConfig(port) { singleProxy: { scheme: 'socks5', host: '127.0.0.1', port }, // Loopback must bypass Tor: the SOCKS port + the control helper are on // 127.0.0.1, and Tor refuses to proxy private addresses anyway. - bypassList: ['localhost', '127.0.0.1', '[::1]'], + // + // The pit's own hosts bypass too. Resolution asks the registry a question + // before a Moshpit navigation can complete, and a cold Tor circuit does + // not answer inside the lookup budget — so routing them through Tor made + // every Moshpit name fall back to clearnet, which looks exactly like the + // namespace not existing. See moshpitBypassHosts for the privacy trade. + bypassList: ['localhost', '127.0.0.1', '[::1]', ...pitHosts], }, }; } @@ -191,7 +197,11 @@ async function setTorBadge(on) { } async function enableTor() { - await chrome.proxy.settings.set({ value: torProxyConfig(TOR_SOCKS_PORT), scope: 'regular' }); + // Read at enable time rather than cached: the options page can repoint the + // registry at a self-hosted pit between one toggle and the next. + let pitHosts = []; + try { pitHosts = moshpitBypassHosts(await moshpitConfig()); } catch (_) { /* defaults are enough */ } + await chrome.proxy.settings.set({ value: torProxyConfig(TOR_SOCKS_PORT, pitHosts), scope: 'regular' }); // Stop WebRTC from leaking the real IP via non-proxied UDP. try { await chrome.privacy.network.webRTCIPHandlingPolicy.set({ value: 'disable_non_proxied_udp' }); diff --git a/apps/desktop/extensions/ai-sidebar/moshpit.js b/apps/desktop/extensions/ai-sidebar/moshpit.js index 6532cf7..fda93cf 100644 --- a/apps/desktop/extensions/ai-sidebar/moshpit.js +++ b/apps/desktop/extensions/ai-sidebar/moshpit.js @@ -27,6 +27,46 @@ export function parkingUrlFor(name, parkingBase = DEFAULT_PARKING_BASE) { return `${parkingBase.replace(/\/+$/, '')}/parking?name=${encodeURIComponent(name)}`; } +/** + * How long a registry lookup may take before navigation gives up on it. + * + * This sits in front of navigation, so it is a budget rather than a guess at + * the worst case: exceeding it falls back to clearnet, which is the right + * answer for a registry that is down but the wrong one for a registry that was + * merely slow. 4s was tight for a phone on a bad connection and hopeless for + * anything routed through Tor. + */ +export const DEFAULT_LOOKUP_TIMEOUT_MS = 8000; + +/** + * The hosts that must not be routed through Tor. + * + * Resolution asks the registry a question before every Moshpit navigation, and + * a cold Tor circuit does not answer inside the lookup budget — so with Tor on + * and no bypass, real Moshpit names quietly resolve as ordinary clearnet ones + * and the namespace appears not to work. + * + * The trade is deliberate and worth stating: bypassing means the network path + * sees which Moshpit names are being looked up, and the registry sees the real + * IP. Neither was hidden before — the registry is told the name either way, and + * the console is where the account is signed in — so what Tor protected on this + * leg was close to nothing, at the cost of the feature working at all. + * + * Built from the configured bases rather than hardcoded, so a self-hosted pit + * gets the same treatment as the public one. + */ +export function moshpitBypassHosts(config) { + const hosts = []; + for (const base of [config?.registryBase, config?.consoleBase, config?.parkingBase]) { + if (!base) continue; + try { + const { hostname } = new URL(base); + if (hostname && !hosts.includes(hostname)) hosts.push(hostname); + } catch { /* an unparseable base is simply not bypassed */ } + } + return hosts; +} + /** Read the settings the options page writes. */ export async function moshpitConfig() { const { moshpitConfig: cfg } = await chrome.storage.local.get('moshpitConfig'); @@ -72,7 +112,7 @@ export function gatewayUrlFor(resolved, registryBase = DEFAULT_REGISTRY_BASE) { * resolution sits in front of every navigation, so a registry that is slow, * down, or serving nonsense must degrade to "clearnet as usual". */ -export async function lookupMoshpit(hostname, { registryBase, timeoutMs = 4000 } = {}) { +export async function lookupMoshpit(hostname, { registryBase, timeoutMs = DEFAULT_LOOKUP_TIMEOUT_MS } = {}) { const parsed = parseRegistryName(hostname); if (!parsed) return null; const base = (registryBase || DEFAULT_REGISTRY_BASE).replace(/\/+$/, ''); diff --git a/apps/desktop/extensions/ai-sidebar/moshpit.test.js b/apps/desktop/extensions/ai-sidebar/moshpit.test.js index 7819b47..1aa3c09 100644 --- a/apps/desktop/extensions/ai-sidebar/moshpit.test.js +++ b/apps/desktop/extensions/ai-sidebar/moshpit.test.js @@ -186,3 +186,49 @@ describe('lookupMoshpit — the registry payload as it really is', () => { expect((await js.lookupMoshpit('x.eggs')).target).toBe('203.0.113.7'); }); }); + +describe('hosts that bypass Tor', () => { + it('lifts the hostname out of each configured base', () => { + expect(js.moshpitBypassHosts({ + registryBase: 'https://pit.moshcode.sh', + consoleBase: 'https://app.moshcode.sh', + parkingBase: 'https://app.moshcode.sh', + })).toEqual(['pit.moshcode.sh', 'app.moshcode.sh']); + }); + + it('follows a self-hosted pit rather than hardcoding the public one', () => { + // The whole reason this is computed: someone pointing at their own pit + // needs the same bypass, or Tor breaks resolution for them and not for us. + expect(js.moshpitBypassHosts({ registryBase: 'https://my.pit:8443' })) + .toEqual(['my.pit']); + }); + + it('drops a port, since a proxy bypass entry matches on host', () => { + expect(js.moshpitBypassHosts({ registryBase: 'https://pit.example:9443' })) + .toEqual(['pit.example']); + }); + + it('survives missing or unparseable bases instead of throwing', () => { + // Called while enabling Tor; throwing here would leave the proxy unset and + // the browser routing in the clear while the badge says TOR. + expect(js.moshpitBypassHosts({})).toEqual([]); + expect(js.moshpitBypassHosts(undefined)).toEqual([]); + expect(js.moshpitBypassHosts({ registryBase: 'not a url' })).toEqual([]); + }); + + it('never routes loopback through Tor by accident', () => { + expect(js.moshpitBypassHosts({ registryBase: 'http://127.0.0.1:8787' })) + .toEqual(['127.0.0.1']); + }); +}); + +describe('the lookup budget', () => { + it('is the same in both ports', () => { + expect(js.DEFAULT_LOOKUP_TIMEOUT_MS).toBe(ts.DEFAULT_LOOKUP_TIMEOUT_MS); + }); + + it('leaves room for a slow network without stalling navigation', () => { + expect(js.DEFAULT_LOOKUP_TIMEOUT_MS).toBeGreaterThan(4000); + expect(js.DEFAULT_LOOKUP_TIMEOUT_MS).toBeLessThanOrEqual(10000); + }); +}); diff --git a/apps/desktop/src/moshpit-resolve.ts b/apps/desktop/src/moshpit-resolve.ts index e07f287..6768994 100644 --- a/apps/desktop/src/moshpit-resolve.ts +++ b/apps/desktop/src/moshpit-resolve.ts @@ -30,6 +30,15 @@ export type ResolveMode = 'clearnet' | 'moshpit'; export const DEFAULT_RESOLVE_MODE: ResolveMode = 'clearnet'; +/** + * How long a registry lookup may take before navigation gives up on it. + * + * A budget, not a worst case: this sits in front of navigation, and exceeding + * it falls back to clearnet. Kept in step with the extension port in + * extensions/ai-sidebar/moshpit.js. + */ +export const DEFAULT_LOOKUP_TIMEOUT_MS = 8000; + /** The public registry. Overridable so a self-hosted pit can be pointed at. */ export const DEFAULT_REGISTRY_BASE = 'https://pit.moshcode.sh'; @@ -255,7 +264,7 @@ export async function lookupMoshpit( const base = (options.registryBase ?? DEFAULT_REGISTRY_BASE).replace(/\/+$/, ''); const fetchImpl = options.fetchImpl ?? fetch; const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 4000); + const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_LOOKUP_TIMEOUT_MS); try { const url = `${base}/api/moshpit/resolve?name=${encodeURIComponent(`${parsed.label}.${parsed.tld}`)}`; const res = await fetchImpl(url, { signal: controller.signal });