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
18 changes: 14 additions & 4 deletions apps/desktop/extensions/ai-sidebar/background.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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: {
Expand All @@ -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],
},
};
}
Expand All @@ -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' });
Expand Down
42 changes: 41 additions & 1 deletion apps/desktop/extensions/ai-sidebar/moshpit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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(/\/+$/, '');
Expand Down
46 changes: 46 additions & 0 deletions apps/desktop/extensions/ai-sidebar/moshpit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
11 changes: 10 additions & 1 deletion apps/desktop/src/moshpit-resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 });
Expand Down
Loading