From 5029b1d4015a3a0953a9da37ff996fc111ac4400 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 16 Sep 2026 08:56:33 +0000 Subject: [PATCH] =?UTF-8?q?Add=20a=20=F0=9F=A4=98=20Pit=20toggle:=20resolv?= =?UTF-8?q?e=20Moshpit=20names=20for=20the=20session,=20like=20the=20Tor?= =?UTF-8?q?=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AI sidebar gets a Pit button next to Tor. The launcher's helper (now 3.3.0) serves /pit/start, /pit/stop and /pit/status: a loopback SOCKS5 resolver on 127.0.0.1:9081 that answers through the Moshpit DNS-over-HTTPS resolver and relays the connection. The extension installs a PAC that sends only hosts the system resolver has no answer for to it, so clearnet names are never touched, nothing on the machine changes and no root is needed. Session-scoped and exclusive with Tor (the PAC asks the system resolver, which under Tor would leak lookups). Whole-machine resolution is still moshcode dns enable; the settings copy now says both. Fix along the way: closing a listening socket from another thread does not wake accept(), so /pit/stop left the port bound; the listener now polls a stop flag and stop() joins before replying. Co-Authored-By: Claude Fable 5.1 --- README.md | 5 + apps/desktop/extensions/ai-sidebar/README.md | 3 +- .../extensions/ai-sidebar/background.js | 111 +++++- .../extensions/ai-sidebar/options.html | 12 +- .../extensions/ai-sidebar/pit-proxy.js | 52 +++ .../extensions/ai-sidebar/pit-proxy.test.js | 55 +++ .../extensions/ai-sidebar/sidepanel.css | 2 + .../extensions/ai-sidebar/sidepanel.html | 1 + .../extensions/ai-sidebar/sidepanel.js | 93 ++++- apps/desktop/launcher/tron-tor-helper | 347 +++++++++++++++++- apps/desktop/launcher/tronbrowser | 11 +- apps/desktop/scripts/build-release.sh | 4 +- docs/moshpit-pit-toggle.md | 115 ++++++ docs/tor-onion-mode.md | 6 +- 14 files changed, 777 insertions(+), 40 deletions(-) create mode 100644 apps/desktop/extensions/ai-sidebar/pit-proxy.js create mode 100644 apps/desktop/extensions/ai-sidebar/pit-proxy.test.js create mode 100644 docs/moshpit-pit-toggle.md diff --git a/README.md b/README.md index d5e6c65d..7734da5e 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,11 @@ tron version > [Tor Browser](https://www.torproject.org/) — it's much safer.** Requires the > `tor` daemon installed. Details: [`docs/tor-onion-mode.md`](docs/tor-onion-mode.md). +> **🤘 Pit** in the AI sidebar resolves Moshpit names (`.eggs`, `.moshpit`, …) for +> the current session with one click — no root, nothing on the machine changes, +> clearnet names untouched. For every app on the box run `moshcode dns enable` +> instead. Details: [`docs/moshpit-pit-toggle.md`](docs/moshpit-pit-toggle.md). + Also packaged for **macOS · Windows · Debian/Ubuntu (.deb) · Fedora/RHEL (.rpm) · Arch (AUR) · Gentoo · NixOS · Snap · Flatpak · AppImage · FreeBSD** — and arm64 Linux phones (Librem 5 / PinePhone / Ubuntu Touch). See diff --git a/apps/desktop/extensions/ai-sidebar/README.md b/apps/desktop/extensions/ai-sidebar/README.md index dd03260d..38a3a3e0 100644 --- a/apps/desktop/extensions/ai-sidebar/README.md +++ b/apps/desktop/extensions/ai-sidebar/README.md @@ -53,7 +53,8 @@ CRX and cannot be replaced by one. | File | Role | | --- | --- | | `manifest.json` | MV3 manifest (side_panel, storage, tabs, management, host permissions) | -| `background.js` | Opens the panel on action click; resolves store-install targets | +| `background.js` | Opens the panel on action click; resolves store-install targets; the 🧅 Tor and 🤘 Pit toggles (`chrome.proxy` + the launcher's helper) | +| `pit-proxy.js` | The Pit PAC: hosts the system resolver cannot answer go to the helper's Moshpit resolver, everything else DIRECT (`docs/moshpit-pit-toggle.md`) | | `install-helper.js` | The "Add to TronBrowser" button on Web Store detail pages | | `install-state.js` | Pure decision + `chrome.management` lookup behind that button | | `sidepanel.html/.css/.js` | The chat UI | diff --git a/apps/desktop/extensions/ai-sidebar/background.js b/apps/desktop/extensions/ai-sidebar/background.js index de08e772..d7e09a71 100644 --- a/apps/desktop/extensions/ai-sidebar/background.js +++ b/apps/desktop/extensions/ai-sidebar/background.js @@ -1,4 +1,5 @@ import { decideInstallTarget, lookupInstalled } from './install-state.js'; +import { PIT_SOCKS_PORT, pitProxyConfig } from './pit-proxy.js'; // Open the AI side panel when the toolbar action is clicked. chrome.sidePanel @@ -256,6 +257,13 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (msg?.type === 'tor-set') { (async () => { if (msg.on) { + // Tor and the pit are exclusive: the pit's PAC asks the system resolver + // about every host, which under Tor would leak lookups outside it. + const { pitEnabled } = await chrome.storage.local.get('pitEnabled'); + if (pitEnabled) { + await disablePit(); + await stopPitViaHelper(); + } const started = await startTorViaHelper(); if (started.error === 'unreachable') { sendResponse({ enabled: false, started: { error: 'unreachable' } }); @@ -293,21 +301,110 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { } }); -// Tor defaults OFF on every fresh browser start — nobody is routed through Tor -// unless they flip the toggle. chrome.storage.session is wiped on browser -// restart, so it tells a fresh launch from a service-worker restart mid-session. +// --- 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. +async function setPitBadge(on) { + try { + await chrome.action.setBadgeText({ text: on ? 'PIT' : '' }); + await chrome.action.setBadgeBackgroundColor({ color: '#a6ff1a' }); // moshcode acid + if (chrome.action.setBadgeTextColor) await chrome.action.setBadgeTextColor({ color: '#0a1400' }); + await chrome.action.setTitle({ title: on ? 'TronBrowser — Pit ON' : 'TronBrowser' }); + } catch (_) { /* action API may be unavailable */ } +} + +async function enablePit() { + await chrome.proxy.settings.set({ value: pitProxyConfig(PIT_SOCKS_PORT), scope: 'regular' }); + await chrome.storage.local.set({ pitEnabled: true }); + try { await chrome.storage.session.set({ pitSession: true }); } catch (_) { /* no-op */ } + await setPitBadge(true); +} + +async function disablePit() { + // Only hand the proxy back to the OS when Tor is not holding it. + const { torEnabled } = await chrome.storage.local.get('torEnabled'); + if (!torEnabled) { + try { + await chrome.proxy.settings.set({ value: { mode: 'system' }, scope: 'regular' }); + } catch (_) { /* fall through to clear */ } + try { await chrome.proxy.settings.clear({ scope: 'regular' }); } catch (_) { /* already clear */ } + } + await chrome.storage.local.set({ pitEnabled: false }); + try { await chrome.storage.session.remove('pitSession'); } catch (_) { /* no-op */ } + await setPitBadge(false); +} + +async function stopPitViaHelper() { + try { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 5000); + await fetch(`${TOR_HELPER}/pit/stop`, { method: 'POST', signal: ctrl.signal }); + clearTimeout(t); + } catch (_) { /* helper not running — nothing to stop */ } +} + +chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg?.type === 'pit-set') { + (async () => { + if (msg.on) { + const { torEnabled } = await chrome.storage.local.get('torEnabled'); + if (torEnabled) { + sendResponse({ enabled: false, error: 'tor-on' }); + return; + } + let started; + try { + started = await helperJson('/pit/start', 'POST'); + } catch (_) { + sendResponse({ enabled: false, error: 'unreachable' }); + return; + } + if (!started || !started.started) { + sendResponse({ enabled: false, error: (started && started.error) || 'pit-failed' }); + return; + } + await enablePit(); + sendResponse({ enabled: true, check: started.check || null, port: started.port }); + } else { + await disablePit(); + await stopPitViaHelper(); + sendResponse({ enabled: false }); + } + })(); + return true; // async sendResponse + } + if (msg?.type === 'pit-status') { + (async () => { + const { pitEnabled } = await chrome.storage.local.get('pitEnabled'); + sendResponse({ enabled: !!pitEnabled }); + })(); + return true; + } +}); + +// Tor and the pit default OFF on every fresh browser start — nobody is routed +// anywhere unless they flip a toggle. chrome.storage.session is wiped on +// browser restart, so it tells a fresh launch from a service-worker restart +// mid-session. (async () => { try { - const { torSession } = await chrome.storage.session.get('torSession'); + const { torSession, pitSession } = await chrome.storage.session.get(['torSession', 'pitSession']); if (torSession) { // Same session, SW just restarted → keep Tor on (re-apply the proxy). await enableTor(); + } else if (pitSession) { + await enablePit(); } else { - // Fresh browser start. If local still says Tor was on (carried over from - // the last run), clear it + any persisted proxy. Otherwise leave the + // Fresh browser start. If local still says a toggle was on (carried over + // from the last run), clear it + any persisted proxy. Otherwise leave the // proxy untouched. - const { torEnabled } = await chrome.storage.local.get('torEnabled'); + const { torEnabled, pitEnabled } = await chrome.storage.local.get(['torEnabled', 'pitEnabled']); if (torEnabled) await disableTor(); + else if (pitEnabled) { await disablePit(); await stopPitViaHelper(); } } } catch (_) { /* best effort */ } })(); diff --git a/apps/desktop/extensions/ai-sidebar/options.html b/apps/desktop/extensions/ai-sidebar/options.html index d2698420..8d103083 100644 --- a/apps/desktop/extensions/ai-sidebar/options.html +++ b/apps/desktop/extensions/ai-sidebar/options.html @@ -79,10 +79,14 @@

Account

Name resolution

- Moshpit names resolve at the operating system, not in the browser — run - moshcode dns enable once on this machine and every application - resolves them, not just this one. The browser used to do it here, which meant - a hook on every navigation and names that worked in one app and not the next. + Two ways to reach Moshpit names (.eggs, .moshpit, …). + For the whole machine, run moshcode dns enable once and every + application resolves them, not just this one. For this browser session only — + a machine where DNS is not yours to change — click 🤘 Pit in + the sidebar: names the system resolver has no answer for are resolved through + the Moshpit resolver, clearnet names are untouched, nothing on the machine + changes, and it is off again on the next launch. https:// on a pit + name still needs the certificate that moshcode dns enable installs.

AI providers (bring your own keys)

diff --git a/apps/desktop/extensions/ai-sidebar/pit-proxy.js b/apps/desktop/extensions/ai-sidebar/pit-proxy.js new file mode 100644 index 00000000..41e401f7 --- /dev/null +++ b/apps/desktop/extensions/ai-sidebar/pit-proxy.js @@ -0,0 +1,52 @@ +// Moshpit ("the pit") session routing — what the 🤘 Pit toggle installs. +// +// Moshpit endings (.eggs, .moshpit, .yeah, …) live outside the ICANN root, so +// the system resolver has no answer for them. `moshcode dns enable` teaches the +// whole machine, but needs root. This is the no-root, this-browser-only route, +// built like the Tor toggle: the launcher's helper runs a loopback SOCKS5 +// 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. + +/** Loopback port the helper's pit resolver listens on (tron-tor-helper PIT_SOCKS_PORT). */ +export const PIT_SOCKS_PORT = 9081; + +function checkPort(port) { + const p = Number(port); + if (!Number.isInteger(p) || p <= 0 || p > 65535) throw new Error(`bad pit port: ${port}`); + return p; +} + +/** + * The PAC script. Kept to plain ES3-style JavaScript: Chromium evaluates it in + * its own PAC sandbox, where only the PAC helpers (dnsResolve, …) exist. + */ +export function buildPitPac(port = PIT_SOCKS_PORT) { + const p = checkPort(port); + return [ + 'function FindProxyForURL(url, host) {', + " var h = String(host || '').toLowerCase();", + " if (h.charAt(h.length - 1) === '.') h = h.slice(0, -1);", + // Loopback, single-label intranet names and IP literals never touch the pit. + " 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. + " if (dnsResolve(h)) return 'DIRECT';", + ` return 'SOCKS5 127.0.0.1:${p}';`, + '}', + ].join('\n'); +} + +/** chrome.proxy.settings value for the pit route. */ +export function pitProxyConfig(port = PIT_SOCKS_PORT) { + // Not `mandatory`: if the PAC ever fails to evaluate, Chromium falls back to + // DIRECT and ordinary browsing keeps working — a pit name failing is the + // acceptable failure, every site failing is not. + return { mode: 'pac_script', pacScript: { data: buildPitPac(port) } }; +} diff --git a/apps/desktop/extensions/ai-sidebar/pit-proxy.test.js b/apps/desktop/extensions/ai-sidebar/pit-proxy.test.js new file mode 100644 index 00000000..6a1706e5 --- /dev/null +++ b/apps/desktop/extensions/ai-sidebar/pit-proxy.test.js @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { PIT_SOCKS_PORT, buildPitPac, pitProxyConfig } from './pit-proxy.js'; + +// Run the PAC the way Chromium would: only the PAC helpers exist, and +// dnsResolve is the system resolver — here a stub that knows two clearnet hosts. +function findProxy(host, resolvable = ['example.com', 'www.github.io']) { + const dnsResolve = (h) => (resolvable.includes(h) ? '93.184.216.34' : null); + const find = new Function('dnsResolve', `${buildPitPac()}; return FindProxyForURL;`)(dnsResolve); + return find(`http://${host}/`, host); +} + +describe('buildPitPac', () => { + it('sends a host the system resolver has no answer for to the pit resolver', () => { + expect(findProxy('scrambled.eggs')).toBe(`SOCKS5 127.0.0.1:${PIT_SOCKS_PORT}`); + expect(findProxy('anything.moshpit')).toBe(`SOCKS5 127.0.0.1:${PIT_SOCKS_PORT}`); + }); + + it('leaves a host the system resolver knows untouched (clearnet wins)', () => { + expect(findProxy('example.com')).toBe('DIRECT'); + // .io is claimed as a Moshpit ending too; a name that resolves still goes direct. + expect(findProxy('www.github.io')).toBe('DIRECT'); + }); + + it('never routes loopback, intranet names or IP literals through the pit', () => { + expect(findProxy('localhost')).toBe('DIRECT'); + expect(findProxy('app.localhost')).toBe('DIRECT'); + expect(findProxy('intranet')).toBe('DIRECT'); + expect(findProxy('127.0.0.1')).toBe('DIRECT'); + expect(findProxy('10.0.0.7')).toBe('DIRECT'); + expect(findProxy('::1')).toBe('DIRECT'); + expect(findProxy('')).toBe('DIRECT'); + }); + + it('normalises case and a trailing dot before deciding', () => { + expect(findProxy('Example.COM.')).toBe('DIRECT'); + expect(findProxy('Mosh.EGGS.')).toBe(`SOCKS5 127.0.0.1:${PIT_SOCKS_PORT}`); + }); + + it('uses the port it is given and refuses a bad one', () => { + expect(buildPitPac(1234)).toContain('SOCKS5 127.0.0.1:1234'); + expect(() => buildPitPac(0)).toThrow(/bad pit port/); + expect(() => buildPitPac(70000)).toThrow(/bad pit port/); + expect(() => buildPitPac('nope')).toThrow(/bad pit port/); + }); +}); + +describe('pitProxyConfig', () => { + it('is a non-mandatory inline PAC, so a broken script falls back to DIRECT', () => { + const cfg = pitProxyConfig(); + expect(cfg.mode).toBe('pac_script'); + expect(cfg.pacScript.data).toContain('function FindProxyForURL'); + expect(cfg.pacScript.mandatory).toBeUndefined(); + expect(cfg.pacScript.url).toBeUndefined(); + }); +}); diff --git a/apps/desktop/extensions/ai-sidebar/sidepanel.css b/apps/desktop/extensions/ai-sidebar/sidepanel.css index b4852583..a45460c6 100644 --- a/apps/desktop/extensions/ai-sidebar/sidepanel.css +++ b/apps/desktop/extensions/ai-sidebar/sidepanel.css @@ -27,6 +27,8 @@ header button { background: transparent; border: 1px solid var(--line); color: v .tor-btn { font-size: 12px; white-space: nowrap; } .tor-btn[aria-pressed="true"] { background: #7d4698; border-color: #7d4698; color: #fff; font-weight: 700; } .tor-btn.busy { opacity: .6; cursor: progress; } +/* The pit wears moshcode's acid green, so ON reads as "pit", not "Tor". */ +#pit[aria-pressed="true"] { background: #a6ff1a; border-color: #a6ff1a; color: #0a1400; } .tor-status { padding: 6px 12px; font-size: 12px; border-bottom: 1px solid var(--line); background: #0b1020; color: var(--muted); } .tor-status.ok { color: #6ee7a8; } diff --git a/apps/desktop/extensions/ai-sidebar/sidepanel.html b/apps/desktop/extensions/ai-sidebar/sidepanel.html index 7de76b34..8b7dad4f 100644 --- a/apps/desktop/extensions/ai-sidebar/sidepanel.html +++ b/apps/desktop/extensions/ai-sidebar/sidepanel.html @@ -15,6 +15,7 @@ + diff --git a/apps/desktop/extensions/ai-sidebar/sidepanel.js b/apps/desktop/extensions/ai-sidebar/sidepanel.js index 68429b2c..4a775f8d 100644 --- a/apps/desktop/extensions/ai-sidebar/sidepanel.js +++ b/apps/desktop/extensions/ai-sidebar/sidepanel.js @@ -1,6 +1,7 @@ import { PROVIDERS, chatStream } from './providers.js'; import { renderMarkdown } from './markdown.js'; import { storageGet } from './net.js'; +import { PIT_SOCKS_PORT } from './pit-proxy.js'; const el = (id) => document.getElementById(id); @@ -160,11 +161,11 @@ const torStatusEl = el('tor-status'); const torProgressEl = el('tor-progress'); const torProgressBar = el('tor-progress-bar'); -function showTorStatus(kind, html) { +function showNetStatus(kind, html) { torStatusEl.className = 'tor-status ' + kind; torStatusEl.innerHTML = html; } -function hideTorStatus() { +function hideNetStatus() { torStatusEl.className = 'tor-status hidden'; torStatusEl.textContent = ''; } @@ -181,7 +182,7 @@ function hideTorProgress() { chrome.runtime.onMessage.addListener((m) => { if (m && m.type === 'tor-progress') { showTorProgress(m.pct); - showTorStatus('', `Connecting through Tor… ${Math.round(m.pct)}%`); + showNetStatus('', `Connecting through Tor… ${Math.round(m.pct)}%`); } }); function setTorButton(on) { @@ -240,7 +241,7 @@ async function toggleTor() { torBtn.classList.add('busy'); torBtn.disabled = true; if (turningOn) { - showTorStatus('', 'Connecting through Tor… (the first run can take up to a minute)'); + showNetStatus('', 'Connecting through Tor… (the first run can take up to a minute)'); showTorProgress(0); } try { @@ -250,41 +251,107 @@ async function toggleTor() { 'Tor Browser.'; if (!turningOn) { setTorButton(false); - hideTorStatus(); + hideNetStatus(); } else if (res && res.enabled && res.check && res.check.ok && res.check.isTor) { setTorButton(true); - showTorStatus('ok', `Connected via Tor · exit IP ${safeIp(res.check.ip)}. ${torBrowserNote}`); + showNetStatus('ok', `Connected via Tor · exit IP ${safeIp(res.check.ip)}. ${torBrowserNote}`); } else if (res && res.enabled) { // Tor started and we're routing through it; the exit-IP probe just didn't // confirm in time (a fresh circuit can be slow). Stay ON, don't alarm. setTorButton(true); - showTorStatus('ok', `Tor is on — routing this session through Tor. ${torBrowserNote}`); + showNetStatus('ok', `Tor is on — routing this session through Tor. ${torBrowserNote}`); } else { // Background couldn't route. Explain why, in plain language. setTorButton(false); const err = res && res.started && res.started.error; if (err === 'tor-starting') { - showTorStatus('', 'Tor is still connecting — the first run downloads the Tor network and can take a minute or two. Click 🧅 again in a few seconds; it’ll finish in the background.'); + showNetStatus('', 'Tor is still connecting — the first run downloads the Tor network and can take a minute or two. Click 🧅 again in a few seconds; it’ll finish in the background.'); } else if (err === 'tor-not-installed') { - showTorStatus('warn', 'Tor isn’t installed yet. Run tron tor once (it installs Tor automatically), then try again.'); + showNetStatus('warn', 'Tor isn’t installed yet. Run tron tor once (it installs Tor automatically), then try again.'); } else if (err === 'unreachable') { - showTorStatus('warn', 'Couldn’t reach the Tor helper. Restart TronBrowser and try again, or run tron tor.'); + showNetStatus('warn', 'Couldn’t reach the Tor helper. Restart TronBrowser and try again, or run tron tor.'); } else { - showTorStatus('warn', 'Tor couldn’t start. See ~/.tronbrowser/tor-helper.log for the reason.'); + showNetStatus('warn', 'Tor couldn’t start. See ~/.tronbrowser/tor-helper.log for the reason.'); } } } catch (e) { setTorButton(false); - showTorStatus('warn', 'Could not toggle Tor: ' + ((e && e.message) || e)); + showNetStatus('warn', 'Could not toggle Tor: ' + ((e && e.message) || e)); } finally { hideTorProgress(); torBtn.classList.remove('busy'); torBtn.disabled = false; + refreshPitState(); // turning Tor on takes the pit down } } torBtn.addEventListener('click', toggleTor); +// --- Pit toggle ---------------------------------------------------------- +// Resolves Moshpit names (.eggs, .moshpit, …) in this session only — the +// background points a PAC at the helper's local resolver for hosts the system +// resolver has no answer for. Nothing on the machine changes and no root is +// needed; for every app on the box, `moshcode dns enable` is still the answer. +const pitBtn = el('pit'); +function setPitButton(on) { + pitBtn.setAttribute('aria-pressed', on ? 'true' : 'false'); + pitBtn.textContent = on ? '🤘 Pit ON' : '🤘 Pit'; +} +// The probe name comes from the helper; still strip to hostname chars before injecting. +function safeHost(h) { return String(h || '?').replace(/[^0-9a-zA-Z.-]/g, ''); } + +async function refreshPitState() { + try { + const res = await chrome.runtime.sendMessage({ type: 'pit-status' }); + setPitButton(!!(res && res.enabled)); + } catch (_) { /* background may be asleep */ } +} + +async function togglePit() { + const turningOn = pitBtn.getAttribute('aria-pressed') !== 'true'; + pitBtn.classList.add('busy'); + pitBtn.disabled = true; + if (turningOn) showNetStatus('', 'Starting the pit resolver…'); + try { + const res = await chrome.runtime.sendMessage({ type: 'pit-set', on: turningOn }); + const tip = + 'Clearnet names are untouched. https:// on a pit name needs ' + + 'moshcode dns enable once, for the certificate.'; + if (!turningOn) { + setPitButton(false); + hideNetStatus(); + } else if (res && res.enabled && res.check && res.check.ok) { + setPitButton(true); + showNetStatus('ok', `Pit is on — Moshpit names resolve in this session (${safeHost(res.check.name)}${safeIp(res.check.ip)}). ${tip}`); + } else if (res && res.enabled) { + // The resolver is up but the Moshpit DoH endpoint didn't answer the probe + // (offline, or slow). Stay ON — names resolve as soon as it is reachable. + setPitButton(true); + showNetStatus('ok', `Pit is on, but the Moshpit resolver didn’t answer yet — names resolve once dns.moshcode.sh is reachable. ${tip}`); + } else { + setPitButton(false); + const err = res && res.error; + if (err === 'tor-on') { + showNetStatus('warn', 'Turn 🧅 Tor off first — Moshpit names can’t resolve through Tor, and checking them would leak lookups outside it.'); + } else if (err === 'unreachable') { + showNetStatus('warn', 'Couldn’t reach the TronBrowser helper. Restart TronBrowser and try again, or run tron upgrade.'); + } else if (err === 'pit-port-busy') { + showNetStatus('warn', `Port ${PIT_SOCKS_PORT} on this machine is taken by another program, so the pit resolver couldn’t start.`); + } else { + showNetStatus('warn', 'The pit resolver couldn’t start. See ~/.tronbrowser/tor-helper.log for the reason.'); + } + } + } catch (e) { + setPitButton(false); + showNetStatus('warn', 'Could not toggle the pit: ' + ((e && e.message) || e)); + } finally { + pitBtn.classList.remove('busy'); + pitBtn.disabled = false; + } +} + +pitBtn.addEventListener('click', togglePit); + // The ? next to the Tor button opens the same explainer on demand (info mode — // never enables Tor, whatever button closes it). el('tor-info').addEventListener('click', () => { @@ -293,4 +360,4 @@ el('tor-info').addEventListener('click', () => { torWarnDlg.showModal(); }); -(async () => { await loadConfig(); await consumePendingQuery(); await refreshTorState(); })(); +(async () => { await loadConfig(); await consumePendingQuery(); await refreshTorState(); await refreshPitState(); })(); diff --git a/apps/desktop/launcher/tron-tor-helper b/apps/desktop/launcher/tron-tor-helper index 86179d42..36501f6c 100755 --- a/apps/desktop/launcher/tron-tor-helper +++ b/apps/desktop/launcher/tron-tor-helper @@ -1,8 +1,10 @@ #!/usr/bin/env python3 -"""TronBrowser Tor control helper. +"""TronBrowser network helper: the 🧅 Tor and 🤘 Pit toggles' back end. A loopback-only HTTP control endpoint the AI-sidebar 🧅 Tor toggle calls to -start/stop a local Tor daemon ON DEMAND. The launcher starts this helper at +start/stop a local Tor daemon ON DEMAND, and the 🤘 Pit toggle calls to +start/stop a local SOCKS5 resolver for Moshpit names (see the Moshpit section +below). The launcher starts this helper at browser launch (both the desktop app entry and `tron` go through the same shim), but it makes NO network connection until the toggle asks it to /start — so nobody connects to Tor unless they actually turn it on. @@ -11,22 +13,32 @@ This is convenience routing, NOT Tor-Browser-grade anonymity. See docs/tor-onion-mode.md. Endpoints (POST/GET on 127.0.0.1): - /start start tor, block until bootstrapped → {"ready": true} or error - /stop stop tor → {"stopped": true} - /status {"running": bool, "ready": bool, "torInstalled": bool} + /start start tor, block until bootstrapped → {"ready": true} or error + /stop stop tor → {"stopped": true} + /status {"running": bool, "ready": bool, "torInstalled": bool, "pit": {…}} + /pit/start start the Moshpit SOCKS5 resolver → {"started": true, "check": {…}} + /pit/stop stop it → {"stopped": true} + /pit/status {"running": bool, "port": int, "doh": url} Single-instance: binds a fixed loopback port; a second copy exits cleanly when the port is taken, so the launcher can fire-and-forget it every launch. """ +import base64 import glob import json import os +import random import re +import select import shutil import signal +import socket +import struct import subprocess import sys import threading +import time +import urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer HOST = "127.0.0.1" @@ -37,7 +49,7 @@ BUNDLED_DIR = os.environ.get("TRON_TOR_BIN_DIR", "") PIDFILE = os.environ.get("TRON_TOR_PIDFILE", "") # Bumped whenever the helper protocol/behaviour changes; the launcher kills a # stale helper so the current version always runs. -HELPER_VERSION = "3.2.8" +HELPER_VERSION = "3.3.0" _lock = threading.Lock() _proc = None # the running tor subprocess (or None) _ready = False # True once tor reported Bootstrapped 100% @@ -198,9 +210,313 @@ def status(): "progress": _progress, "error": _error, "torInstalled": tor_binary() is not None, + "pit": pit_status(), "version": HELPER_VERSION} +# --- Moshpit names (🤘 Pit toggle) ------------------------------------------ +# Moshpit endings (.eggs, .moshpit, .yeah, …) live outside the ICANN root, so the +# system resolver has nowhere to look them up. `moshcode dns enable` fixes that +# for the whole machine, but it needs root and rewrites the resolver config. +# This is the no-root, this-browser-only alternative, built like the Tor toggle: +# a loopback SOCKS5 proxy that resolves names through the Moshpit DNS-over-HTTPS +# resolver and connects to whatever it answers. The extension's PAC script sends +# only hosts the system resolver has NO answer for here (clearnet wins, the pit +# is the fallback — the house policy), so ordinary sites never pass through it. +# Nothing on the machine changes, and it is off again on the next launch. +PIT_SOCKS_PORT = int(os.environ.get("TRON_PIT_SOCKS_PORT", "9081")) +PIT_DOH_URL = os.environ.get("TRON_PIT_DOH_URL", "https://dns.moshcode.sh/dns-query") +# A name that must resolve while the pit is up: the Pit's own front door for +# .eggs. `mosh.` is reserved by the registry, so nobody can squat it. +PIT_PROBE_NAME = os.environ.get("TRON_PIT_PROBE_NAME", "mosh.eggs") +PIT_CONNECT_TIMEOUT = 15 +PIT_IDLE_TIMEOUT = 600 # seconds without a byte either way before a relay closes +_pit = None # the running PitSocks server (or None) +_pit_lock = threading.Lock() +_pit_cache = {} # name -> (expires_at, [ips]) +_pit_cache_lock = threading.Lock() + + +def _dns_query(name, qtype=1): + """Wire-format DNS query for `name` (RFC 1035): (id, bytes).""" + qid = random.randint(0, 0xFFFF) + q = struct.pack(">HHHHHH", qid, 0x0100, 1, 0, 0, 0) # RD=1, one question + for label in name.rstrip(".").split("."): + raw = label.encode("idna") + if not raw or len(raw) > 63: + raise ValueError("bad label in %r" % name) + q += bytes([len(raw)]) + raw + q += b"\x00" + struct.pack(">HH", qtype, 1) # QTYPE, QCLASS=IN + return qid, q + + +def _skip_name(buf, pos): + """Index just past the (possibly compressed) name starting at `pos`.""" + while True: + if pos >= len(buf): + raise ValueError("truncated name") + length = buf[pos] + if length == 0: + return pos + 1 + if length & 0xC0 == 0xC0: # compression pointer: two bytes, ends the name + return pos + 2 + pos += 1 + length + + +def _parse_a_records(buf, qid): + """(rcode, [ipv4 strings], min ttl) from a DNS response — A records only.""" + if len(buf) < 12: + raise ValueError("short response") + rid, flags, qdcount, ancount, _ns, _ar = struct.unpack(">HHHHHH", buf[:12]) + if rid != qid: + raise ValueError("transaction id mismatch") + pos = 12 + for _ in range(qdcount): + pos = _skip_name(buf, pos) + 4 # QTYPE + QCLASS + ips, ttl = [], None + for _ in range(ancount): + pos = _skip_name(buf, pos) + rtype, rclass, rttl, rdlen = struct.unpack(">HHIH", buf[pos:pos + 10]) + pos += 10 + rdata = buf[pos:pos + rdlen] + pos += rdlen + if rtype == 1 and rclass == 1 and rdlen == 4: + ips.append(socket.inet_ntoa(rdata)) + ttl = rttl if ttl is None else min(ttl, rttl) + return flags & 0xF, ips, (60 if ttl is None else ttl) + + +def doh_resolve(name, timeout=6.0): + """A records for `name` from the Moshpit DoH resolver (RFC 8484 GET). + Returns (ips, ttl, rcode); raises on transport or malformed answers.""" + qid, q = _dns_query(name) + url = "%s?dns=%s" % (PIT_DOH_URL, base64.urlsafe_b64encode(q).rstrip(b"=").decode("ascii")) + req = urllib.request.Request(url, headers={ + "Accept": "application/dns-message", + "User-Agent": "tron-pit-helper/%s" % HELPER_VERSION, + }) + with urllib.request.urlopen(req, timeout=timeout) as res: + body = res.read(65535) + rcode, ips, ttl = _parse_a_records(body, qid) + return ips, ttl, rcode + + +def pit_resolve(name): + """IPv4 addresses for a host: the cached DoH answer, else the system resolver + as a last resort (a clearnet name should never arrive here, but when one does + it must still work). [] when nobody knows the name; negatives are cached + briefly so a page full of broken links does not hammer the resolver.""" + key = name.lower().rstrip(".") + now = time.time() + with _pit_cache_lock: + hit = _pit_cache.get(key) + if hit and hit[0] > now: + return hit[1] + ips, ttl = [], 60 + try: + ips, ttl, _rcode = doh_resolve(key) + except Exception as exc: # noqa: BLE001 — any failure means "try the OS" + log("pit: DoH lookup of %s failed: %s" % (key, exc)) + if not ips: + try: + ips = sorted({ai[4][0] for ai in socket.getaddrinfo(key, None, socket.AF_INET, socket.SOCK_STREAM)}) + except OSError: + ips = [] + with _pit_cache_lock: + _pit_cache[key] = (now + (max(30, min(int(ttl), 3600)) if ips else 30), ips) + return ips + + +def _recv_exact(sock, n): + buf = b"" + while len(buf) < n: + chunk = sock.recv(n - len(buf)) + if not chunk: + raise ConnectionError("client closed") + buf += chunk + return buf + + +def _socks_reply(conn, rep): + # BND.ADDR/BND.PORT are meaningless for CONNECT through a relay; Chromium + # ignores them, so always answer 0.0.0.0:0. + try: + conn.sendall(b"\x05" + bytes([rep]) + b"\x00\x01" + b"\x00\x00\x00\x00" + b"\x00\x00") + except OSError: + pass + + +def _pit_relay(a, b): + """Shuttle bytes both ways until either side closes or goes idle.""" + pair = [a, b] + try: + while True: + readable, _w, broken = select.select(pair, [], pair, PIT_IDLE_TIMEOUT) + if broken or not readable: + return + for s in readable: + data = s.recv(65536) + if not data: + return + (b if s is a else a).sendall(data) + except OSError: + return + finally: + for s in pair: + try: + s.close() + except OSError: + pass + + +def _pit_serve(conn): + """One SOCKS5 client: no-auth handshake, CONNECT, resolve, relay.""" + conn.settimeout(PIT_CONNECT_TIMEOUT) + upstream = None + try: + ver, nmethods = _recv_exact(conn, 2) + if ver != 5: + return + _recv_exact(conn, nmethods) + conn.sendall(b"\x05\x00") # no authentication required + ver, cmd, _rsv, atyp = _recv_exact(conn, 4) + if ver != 5: + return + if atyp == 1: + host = socket.inet_ntoa(_recv_exact(conn, 4)) + elif atyp == 3: + host = _recv_exact(conn, _recv_exact(conn, 1)[0]).decode("ascii", "replace") + elif atyp == 4: + host = socket.inet_ntop(socket.AF_INET6, _recv_exact(conn, 16)) + else: + _socks_reply(conn, 0x08) # address type not supported + return + port = struct.unpack(">H", _recv_exact(conn, 2))[0] + if cmd != 1: + _socks_reply(conn, 0x07) # only CONNECT; no BIND / UDP ASSOCIATE + return + ips = pit_resolve(host) if atyp == 3 else [host] + if not ips: + log("pit: no address for %s" % host) + _socks_reply(conn, 0x04) # host unreachable + return + rep = 0x05 + for ip in ips[:3]: + try: + upstream = socket.create_connection((ip, port), timeout=PIT_CONNECT_TIMEOUT) + break + except OSError as exc: + rep = 0x05 if isinstance(exc, ConnectionRefusedError) else 0x04 + if upstream is None: + log("pit: could not connect to %s:%d via %s" % (host, port, ", ".join(ips[:3]))) + _socks_reply(conn, rep) + return + upstream.settimeout(None) + conn.settimeout(None) + _socks_reply(conn, 0x00) + _pit_relay(conn, upstream) + except (OSError, ConnectionError, ValueError, struct.error, IndexError): + pass + finally: + for s in (conn, upstream): + if s is not None: + try: + s.close() + except OSError: + pass + + +class PitSocks(threading.Thread): + """Loopback SOCKS5 listener; one daemon thread per client.""" + + def __init__(self, port): + super().__init__(daemon=True, name="pit-socks") + self.port = port + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.sock.bind((HOST, port)) + self.sock.listen(64) + self._stopping = threading.Event() + + def run(self): + # A blocked accept() is not woken by close() from another thread — the + # port would stay bound with nobody listening. Poll the stop flag instead; + # accepted sockets come back blocking regardless of this timeout. + self.sock.settimeout(0.5) + while not self._stopping.is_set(): + try: + conn, _addr = self.sock.accept() + except socket.timeout: + continue + except OSError: + break # shut down by stop() + threading.Thread(target=_pit_serve, args=(conn,), daemon=True).start() + try: + self.sock.close() + except OSError: + pass + + def stop(self): + """Stop listening and wait for the port to be released, so a caller that + gets {"stopped": true} can rely on it being closed.""" + self._stopping.set() + try: + self.sock.shutdown(socket.SHUT_RDWR) # wakes accept() at once on Linux + except OSError: + pass + self.join(timeout=3) + try: + self.sock.close() + except OSError: + pass + + +def start_pit(): + """Ensure the resolver is listening. Returns (ok, error).""" + global _pit + with _pit_lock: + if _pit is not None and _pit.is_alive(): + return True, None + try: + srv = PitSocks(PIT_SOCKS_PORT) + except OSError as exc: + log("pit: cannot bind %s:%d: %s" % (HOST, PIT_SOCKS_PORT, exc)) + return False, "pit-port-busy" + srv.start() + _pit = srv + log("pit: SOCKS5 resolver listening on %s:%d (DoH %s)" % (HOST, PIT_SOCKS_PORT, PIT_DOH_URL)) + return True, None + + +def stop_pit(): + global _pit + with _pit_lock: + srv, _pit = _pit, None + if srv is not None: + srv.stop() + log("pit: stopped") + with _pit_cache_lock: + _pit_cache.clear() + + +def pit_probe(): + """Resolve a name that must exist so the toggle can report whether names + will actually resolve, not merely that a port is open.""" + try: + ips, _ttl, rcode = doh_resolve(PIT_PROBE_NAME) + if ips: + return {"ok": True, "name": PIT_PROBE_NAME, "ip": ips[0]} + return {"ok": False, "name": PIT_PROBE_NAME, "error": "no-answer (rcode %d)" % rcode} + except Exception as exc: # noqa: BLE001 — reported to the user, never raised + return {"ok": False, "name": PIT_PROBE_NAME, "error": str(exc)} + + +def pit_status(): + with _pit_lock: + running = _pit is not None and _pit.is_alive() + return {"running": running, "port": PIT_SOCKS_PORT, "doh": PIT_DOH_URL, "version": HELPER_VERSION} + + class Handler(BaseHTTPRequestHandler): def _send(self, code, obj): body = json.dumps(obj).encode("utf-8") @@ -228,6 +544,20 @@ class Handler(BaseHTTPRequestHandler): self._send(200, {"stopped": True}) elif path in ("/status", "/"): self._send(200, status()) + elif path == "/pit/start": + ok, err = start_pit() + st = pit_status() + st["started"] = ok + if err: + st["error"] = err + else: + st["check"] = pit_probe() + self._send(200 if ok else 503, st) + elif path == "/pit/stop": + stop_pit() + self._send(200, {"stopped": True}) + elif path == "/pit/status": + self._send(200, pit_status()) else: self._send(404, {"error": "not-found"}) @@ -243,6 +573,7 @@ class Handler(BaseHTTPRequestHandler): def _shutdown(*_): stop_tor() + stop_pit() _remove_pidfile() sys.exit(0) @@ -275,11 +606,13 @@ def main(): # launcher kills a stale helper before us, so this is rare.) return _write_pidfile() - log("listening on %s:%d v%s (tor: %s)" % (HOST, PORT, HELPER_VERSION, tor_binary() or "NOT FOUND")) + log("listening on %s:%d v%s (tor: %s; pit resolver on :%d when toggled)" + % (HOST, PORT, HELPER_VERSION, tor_binary() or "NOT FOUND", PIT_SOCKS_PORT)) try: server.serve_forever() finally: stop_tor() + stop_pit() _remove_pidfile() diff --git a/apps/desktop/launcher/tronbrowser b/apps/desktop/launcher/tronbrowser index ea692a1c..cd83ee3b 100755 --- a/apps/desktop/launcher/tronbrowser +++ b/apps/desktop/launcher/tronbrowser @@ -150,9 +150,10 @@ if [ "$TOR" = "1" ]; then rm -rf "$DATA" fi -# On-demand Tor control helper for the in-browser 🧅 Tor toggle (AI sidebar). -# Loopback-only; it makes NO network connection until the toggle asks it to -# start Tor — so nobody connects to Tor unless they turn it on. Fire-and-forget +# On-demand network helper for the in-browser 🧅 Tor and 🤘 Pit toggles (AI +# sidebar). Loopback-only; it makes NO network connection until a toggle asks +# it to start Tor or the Moshpit resolver — so nobody connects to either unless +# they turn it on. Fire-and-forget # + idempotent (a second copy exits when the port is taken), so both the desktop # app entry and `tron` (which share this shim) get it. Best-effort: needs # python3, and skipped in --tor mode (that path runs its own Tor). @@ -168,13 +169,13 @@ if [ "$TOR" != "1" ]; then elif [ -x "$DIR/tor-bin/tor" ]; then _torbin="$DIR/tor-bin/tor (auto-installed)"; elif command -v tor >/dev/null 2>&1; then _torbin="$(command -v tor)"; else _torbin="NOT FOUND — run 'tron tor' to install it"; fi - echo "TronBrowser: Tor toggle ready — helper control on 127.0.0.1:9061, Tor SOCKS on 127.0.0.1:$TOR_SOCKS_PORT (tor: $_torbin). Click 🧅 Tor in the AI sidebar. Log: $DATA/tor-helper.log" >&2 + echo "TronBrowser: Tor + Pit toggles ready — helper control on 127.0.0.1:9061, Tor SOCKS on 127.0.0.1:$TOR_SOCKS_PORT (tor: $_torbin), Moshpit resolver on 127.0.0.1:9081. Click 🧅 Tor or 🤘 Pit in the AI sidebar. Log: $DATA/tor-helper.log" >&2 # The helper persists across launches, so an OLD one (from before an upgrade) # would keep answering the toggle with stale code. Replace it ONLY when the # running helper isn't this version — otherwise leave a healthy current # helper alone (don't drop an active Tor session). All backgrounded so the # kill+settle never holds up the browser launch. - HELPER_VERSION=3.2.8 + HELPER_VERSION=3.3.0 ( _pf="$DATA/tor-helper.pid" _rv="$(curl -fsS --max-time 1 http://127.0.0.1:9061/status 2>/dev/null | sed -n 's/.*"version"[^"]*"\([^"]*\)".*/\1/p')" diff --git a/apps/desktop/scripts/build-release.sh b/apps/desktop/scripts/build-release.sh index 97cdab2e..cefe93b5 100755 --- a/apps/desktop/scripts/build-release.sh +++ b/apps/desktop/scripts/build-release.sh @@ -68,8 +68,8 @@ stage() { # dest dir mkdir -p "$s/extensions" install -m 0755 "$DESKTOP/launcher/tronbrowser" "$s/tronbrowser" cp "$DESKTOP/launcher/tronbrowser.cmd" "$s/tronbrowser.cmd" - # On-demand Tor control helper for the in-browser 🧅 Tor toggle (the launcher - # starts it; it starts Tor only when the toggle asks). + # On-demand network helper for the in-browser 🧅 Tor and 🤘 Pit toggles (the + # launcher starts it; it starts Tor / the Moshpit resolver only when asked). install -m 0755 "$DESKTOP/launcher/tron-tor-helper" "$s/tron-tor-helper" # Repoints installed-web-app desktop icons at the launcher. The shim runs it # on every start (the engine rewrites those files behind us); `tron pwa` is diff --git a/docs/moshpit-pit-toggle.md b/docs/moshpit-pit-toggle.md new file mode 100644 index 00000000..ded06c03 --- /dev/null +++ b/docs/moshpit-pit-toggle.md @@ -0,0 +1,115 @@ +# 🤘 Pit toggle — Moshpit names for one browser session + +**Status:** shipped with the AI-sidebar extension + `tron-tor-helper` 3.3.0 +**Owner:** desktop (`apps/desktop`) +**Scope:** resolve Moshpit names in the running browser with one click. Not a +replacement for `moshcode dns enable`, which does it for the whole machine. + +--- + +## What it is + +Moshpit endings (`.eggs`, `.moshpit`, `.yeah`, thousands more) live outside the +ICANN root, so the system resolver has nowhere to look them up. There are two +ways to make them work, and the settings page says so: + +| | `moshcode dns enable` | 🤘 Pit toggle | +| --- | --- | --- | +| Scope | every application on the machine | this browser session | +| 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) | warns, unless `moshcode dns enable` has installed the CA | +| Clearnet names | forwarded to public resolvers | never touched | + +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`. + +## How it works + +It is built exactly like the 🧅 Tor toggle: + +1. **The helper.** The launcher already runs a loopback control helper + (`apps/desktop/launcher/tron-tor-helper`, `127.0.0.1:9061`) at every browser + launch. It now also serves `/pit/start`, `/pit/stop` and `/pit/status`. + `/pit/start` binds a **SOCKS5 resolver on `127.0.0.1:9081`** and probes the + Moshpit DNS-over-HTTPS resolver (`https://dns.moshcode.sh/dns-query`) with + `mosh.eggs`, a name the registry reserves, so the toggle can say whether names + will actually resolve and not merely that a port is open. Like Tor, **nothing + listens and nothing is contacted until the toggle asks**. +2. **The resolver.** For each `CONNECT`, the SOCKS server asks the DoH resolver + for the name's A records (RFC 8484 GET, answers cached for their TTL, negatives + 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. + +The PAC is not `mandatory`: if it ever fails to evaluate, Chromium falls back to +`DIRECT` and ordinary browsing keeps working. + +## Tor and the pit are exclusive + +The pit's PAC asks the **system** resolver about every host. With Tor on, that +would leak every lookup outside Tor, so: + +- turning 🧅 Tor on takes the pit down first (the background does it, the + sidebar refreshes the button); +- turning 🤘 Pit on while Tor is on is refused with a plain-language reason. + +## What you see + +- The button reads **🤘 Pit ON** in moshcode's acid green, the toolbar icon gets + a **PIT** badge, and the status strip shows the probe: + `Pit is on — Moshpit names resolve in this session (mosh.eggs → 67.205.189.229)`. +- Test it by opening `http://mosh.eggs` — the Pit's own front door for `.eggs`. +- If the DoH resolver did not answer the probe (offline, slow), the pit stays + **on** and says so; names resolve as soon as it is reachable. +- Failures the sidebar explains: the helper is not running (`tron upgrade`, + restart), port 9081 is taken, Tor is on. + +## Files + +| File | Role | +| --- | --- | +| `apps/desktop/launcher/tron-tor-helper` | `/pit/*` routes, the SOCKS5 resolver, the DoH client | +| `apps/desktop/launcher/tronbrowser` | starts the helper; `HELPER_VERSION` must match the helper's so a stale one is replaced | +| `apps/desktop/extensions/ai-sidebar/pit-proxy.js` | the PAC + proxy config (pure, tested in `pit-proxy.test.js`) | +| `apps/desktop/extensions/ai-sidebar/background.js` | `pit-set` / `pit-status` messages, badge, session-scoped state | +| `apps/desktop/extensions/ai-sidebar/sidepanel.*` | the button and its status copy | + +Environment knobs on the helper: `TRON_PIT_SOCKS_PORT` (9081), +`TRON_PIT_DOH_URL`, `TRON_PIT_PROBE_NAME`. + +## Testing the helper by hand + +```sh +TRON_TOR_HELPER_PORT=19061 TRON_PIT_SOCKS_PORT=19081 python3 apps/desktop/launcher/tron-tor-helper & +curl -X POST http://127.0.0.1:19061/pit/start +curl --socks5-hostname 127.0.0.1:19081 -I http://mosh.eggs/ # 302 → pit.moshcode.sh +curl --socks5-hostname 127.0.0.1:19081 -I https://example.com/ # clearnet relays too +curl -X POST http://127.0.0.1:19061/pit/stop +``` + +## Not in this version + +- **`https://` on pit names without the CA.** The pit page documents it: no + public CA issues for a namespace outside the ICANN root. `moshcode dns enable` + installs the Moshpit CA and the launcher mirrors it into Chromium's trust + store on every start, so the two features compose. +- **"Moshpit wins."** The resolvers' `MOSHPIT_RESOLVE_MODE=moshpit` lets a + registered name override a clearnet one. The toggle only implements the + default `fallback` policy. +- **Persisting across launches.** Mirrors Tor deliberately; a setting to keep + the pit on would be a small follow-up. +- **Windows.** The `.cmd` shim does not start the helper, so neither toggle + works there yet. diff --git a/docs/tor-onion-mode.md b/docs/tor-onion-mode.md index 4d89b1e7..b0f86535 100644 --- a/docs/tor-onion-mode.md +++ b/docs/tor-onion-mode.md @@ -35,7 +35,11 @@ always-on Tor on everyone, the launcher runs a tiny **control helper** (`launcher/tron-tor-helper`, loopback-only on `127.0.0.1:9061`) at every browser launch — both the desktop app entry and the `tron` CLI go through the same shim, so both get it. **The helper makes no network connection until asked:** nobody -connects to Tor unless they flip the toggle on. +connects to Tor unless they flip the toggle on. (The same helper backs the +**🤘 Pit** toggle — a loopback resolver for Moshpit names, see +[`moshpit-pit-toggle.md`](moshpit-pit-toggle.md). The two are exclusive: turning +Tor on takes the pit down, because the pit's PAC asks the system resolver about +every host, which under Tor would leak lookups outside it.) Flow when you click 🧅 Tor: