diff --git a/README.md b/README.md index d5e6c65..7734da5 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 dd03260..38a3a3e 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 de08e77..d7e09a7 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 d269842..8d10308 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 0000000..41e401f --- /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 0000000..6a1706e --- /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 b485258..a45460c 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 7de76b3..8b7dad4 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 68429b2..4a775f8 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 86179d4..36501f6 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 ea692a1..cd83ee3 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 97cdab2..cefe93b 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 0000000..ded06c0 --- /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 4d89b1e..b0f8653 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: