From f548512204602df8f386bcaec9a258653bc64280 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 11:03:27 +0000 Subject: [PATCH 001/100] feat(tor): integrate Arti (Rust Tor client) for .onion access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add opt-in Tor support so the browser can reach .onion services, using Arti — the Tor Project's pure-Rust Tor client — bundled as a managed SOCKS5 proxy subprocess (the same pattern as the Bee/Radicle managers). Scope is .onion-only: a PAC script on the Electron session routes *.onion through the local Arti SOCKS5 proxy and returns DIRECT for everything else, so clearnet and the decentralized protocols keep connecting directly. SOCKS5 does remote DNS, so .onion resolves at Tor with no custom scheme; .onion is treated as an ordinary http(s) host (defaulted to http). Backend: - src/main/tor-proxy.js: PAC builder + apply/clear proxy helpers - src/main/tor-manager.js: arti lifecycle, health check, service registry, IPC handlers gated by the enableTorIntegration setting, Arti version lookup (arti --version) - scripts/fetch-arti.js: build arti from crates.io via cargo install - service-registry / ipc-channels / settings-store / preload / index wiring - electron-builder extraResources bundles arti-bin per platform (macOS/Linux) UI: - Settings → Experimental "Enable Tor (.onion access)" toggle (hidden on Windows) - Tor section in the node-status menu: toggle, SOCKS status line, and Arti version Tests for the PAC script, manager paths/config, version, and IPC gating. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BExqPQKeGo7Lc6FxjhHx36 --- .gitignore | 2 + README.md | 41 +++ package.json | 16 + scripts/check-binaries.js | 27 ++ scripts/fetch-arti.js | 103 ++++++ src/main/index.js | 9 +- src/main/preload.js | 14 + src/main/preload.test.js | 11 +- src/main/service-registry.js | 20 ++ src/main/settings-store.js | 5 + src/main/settings-store.test.js | 2 + src/main/tor-manager.js | 472 ++++++++++++++++++++++++++++ src/main/tor-manager.test.js | 125 ++++++++ src/main/tor-proxy.js | 75 +++++ src/main/tor-proxy.test.js | 60 ++++ src/renderer/index.html | 25 ++ src/renderer/index.js | 13 +- src/renderer/lib/settings-ui.js | 13 +- src/renderer/lib/state.js | 15 + src/renderer/lib/tor-ui.js | 199 ++++++++++++ src/renderer/lib/url-utils.js | 10 +- src/renderer/lib/url-utils.test.js | 14 + src/renderer/pages/settings.html | 44 ++- src/renderer/styles/light-theme.css | 3 +- src/renderer/styles/services.css | 92 ++++++ src/shared/ipc-channels.js | 8 + 26 files changed, 1409 insertions(+), 9 deletions(-) create mode 100644 scripts/fetch-arti.js create mode 100644 src/main/tor-manager.js create mode 100644 src/main/tor-manager.test.js create mode 100644 src/main/tor-proxy.js create mode 100644 src/main/tor-proxy.test.js create mode 100644 src/renderer/lib/tor-ui.js diff --git a/.gitignore b/.gitignore index db391bab..d3221c1c 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,8 @@ dev-scripts/ helios-bin/ radicle-bin/ radicle-data/ +arti-bin/ +tor-data/ dev-app-update.yml CLAUDE.md .codex diff --git a/README.md b/README.md index 60896be2..55218723 100644 --- a/README.md +++ b/README.md @@ -461,6 +461,47 @@ profile-managed dev data. | `npm run radicle:status` | Check the default profile's Radicle httpd root endpoint | | `npm run radicle:reset` | Delete all Radicle data and start fresh | +### Tor Scripts + +| Script | Description | +|--------|-------------| +| `npm run tor:download` | Build the Arti (Rust Tor client) binary for your platform | +| `npm run tor:reset` | Delete all Tor data and start fresh | + +--- + +## Tor (.onion) Access + +Freedom can reach Tor `.onion` services using [Arti](https://arti.torproject.org/), +the Tor Project's pure-Rust Tor client. It is **off by default** and gated behind +**Settings → Experimental → Enable Tor (.onion access) (Beta)**. + +- **Scope is `.onion`-only.** When enabled, only `*.onion` hostnames are routed + through Tor; clearnet and the decentralized protocols (bzz/ipfs/ipns/rad) keep + connecting directly. Routing is done with a PAC script on the Electron session + (`src/main/tor-proxy.js`) that returns `SOCKS5 127.0.0.1:9150` for `.onion` and + `DIRECT` for everything else. SOCKS5 does remote DNS, so the onion name resolves + at Tor — no custom scheme is needed; `.onion` is just an ordinary http(s) host + (defaulted to `http://` since most onion services are http-only). +- **Lifecycle.** `src/main/tor-manager.js` spawns the bundled `arti` binary as a + local SOCKS5 proxy (`arti proxy -c `), health-checks the SOCKS port, + applies/clears the proxy, and reports status through the service registry — the + same pattern as the Bee / Radicle managers. +- **Status readout.** The node-status menu's Tor section shows the SOCKS endpoint + and the Arti software version (`arti --version`, via `getArtiVersion`). Exit-node + details are intentionally not shown: `.onion` connections have no exit node, so + an exit indicator only becomes meaningful once clearnet-over-Tor lands. +- **Binary.** Arti has no clean prebuilt-binary distribution, so `npm run + tor:download` builds it from crates.io via `cargo install` (requires a Rust + toolchain). The binary lands in `arti-bin/-/arti` and is bundled + via electron-builder `extraResources`. Bundling is **optional**: if `arti-bin` + hasn't been built, `npm run build/dist` still succeeds (a non-fatal warning from + `check-binaries.js`) and simply ships without Tor — the in-app toggle stays + disabled until the binary is present. Like Radicle, Tor is macOS/Linux-only for + now; the toggle is hidden on Windows. +- **Data.** Arti state/cache live under `/tor-data` (override with + `FREEDOM_TOR_DATA`). + --- ## Project Structure diff --git a/package.json b/package.json index 0a7dd9ad..65654670 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,8 @@ "ipfs:build": "node scripts/fetch-freedom-ipfs-native.js --from-source", "ipfs:native:smoke": "node scripts/smoke-freedom-ipfs-native.js", "ipfs:reset": "rm -rf ipfs-data && echo 'Legacy repo-root IPFS data reset.'", + "tor:download": "node scripts/fetch-arti.js", + "tor:reset": "rm -rf tor-data && echo 'Tor data reset.'", "radicle:download": "node scripts/fetch-radicle.js", "radicle:init": "node scripts/init-radicle.js", "radicle:status": "RADICLE_API=${RADICLE_API:-http://127.0.0.1:18780}; curl -s \"$RADICLE_API/\" | head -c 500", @@ -96,6 +98,13 @@ "filter": [ "**/*" ] + }, + { + "from": "arti-bin/${os}-${arch}/", + "to": "arti-bin", + "filter": [ + "**/*" + ] } ] }, @@ -119,6 +128,13 @@ "filter": [ "**/*" ] + }, + { + "from": "arti-bin/${os}-${arch}/", + "to": "arti-bin", + "filter": [ + "**/*" + ] } ] }, diff --git a/scripts/check-binaries.js b/scripts/check-binaries.js index 8ea34c94..1692f698 100644 --- a/scripts/check-binaries.js +++ b/scripts/check-binaries.js @@ -11,6 +11,7 @@ const FREEDOM_IPFS_NATIVE_PREBUILDS_DIR = path.join( ); const FREEDOM_IPFS_NATIVE_ADDON = 'freedom_ipfs_native.node'; const RADICLE_BIN_DIR = path.join(__dirname, '..', 'radicle-bin'); +const ARTI_BIN_DIR = path.join(__dirname, '..', 'arti-bin'); function getPlatformArch() { const args = process.argv.slice(2); @@ -114,6 +115,29 @@ function checkBinaries(platforms) { return missing; } +/** + * Arti (Tor) is OPTIONAL and built from source via `npm run tor:download` + * (cargo), unlike the prebuilt Bee/Radicle downloads. It is intentionally not + * a required build binary: when absent, Tor simply isn't bundled and the + * in-app toggle stays disabled. We still create the per-platform resource dir + * so electron-builder's `extraResources` entry resolves cleanly instead of + * failing late during packaging. + */ +function ensureOptionalArti(platforms) { + for (const { os, arch } of platforms) { + if (os === 'win') continue; // Arti is bundled for macOS/Linux only + const platformDir = `${os}-${arch}`; + const artiPath = path.join(ARTI_BIN_DIR, platformDir, 'arti'); + if (!fs.existsSync(artiPath)) { + fs.mkdirSync(path.join(ARTI_BIN_DIR, platformDir), { recursive: true }); + console.warn( + `⚠️ Arti (Tor) binary not found for ${platformDir} — Tor will not be bundled.\n` + + ` Optional; build it with: npm run tor:download (requires a Rust toolchain)` + ); + } + } +} + function main() { const platforms = getPlatformArch(); console.log(`Checking binaries for: ${platforms.map((p) => `${p.os}-${p.arch}`).join(', ')}`); @@ -130,6 +154,9 @@ function main() { process.exit(1); } + // Optional binaries (non-fatal): warn and prepare resource dirs. + ensureOptionalArti(platforms); + console.log('✅ All required binaries found.\n'); process.exit(0); } diff --git a/scripts/fetch-arti.js b/scripts/fetch-arti.js new file mode 100644 index 00000000..526ced2d --- /dev/null +++ b/scripts/fetch-arti.js @@ -0,0 +1,103 @@ +/** + * Fetch (build) the Arti Tor client binary. + * + * Unlike Bee / Radicle, the Tor Project does not publish a clean, scriptable + * set of prebuilt `arti` binaries. The reliable, official, pinnable source is + * crates.io, so we build from source with `cargo install`. This requires a + * Rust toolchain (`cargo`) on the build machine. + * + * The binary is placed at `arti-bin/-/arti` to match the + * layout that `src/main/tor-manager.js#getArtiBinaryPath` and the + * electron-builder `extraResources` entries expect. + * + * Cross-compilation is out of scope here (it needs per-target toolchains), so + * this builds for the host platform/arch only — mirroring how the Docker dist + * jobs fetch host-only Radicle binaries. + * + * Env: + * ARTI_VERSION crates.io version to install (default: pinned below) + * CARGO_BIN path to cargo (default: 'cargo' on PATH) + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { execFileSync } = require('child_process'); + +// Pin a known-good Arti release. Bump deliberately and re-test the SOCKS flags. +const ARTI_VERSION = process.env.ARTI_VERSION || '1.4.4'; +const CARGO_BIN = process.env.CARGO_BIN || 'cargo'; + +const OUTPUT_DIR = path.join(__dirname, '..', 'arti-bin'); + +function platformKey() { + const platformMap = { darwin: 'mac', linux: 'linux', win32: 'win' }; + const platform = platformMap[process.platform] || process.platform; + return `${platform}-${process.arch}`; +} + +function hasCargo() { + try { + execFileSync(CARGO_BIN, ['--version'], { stdio: 'pipe' }); + return true; + } catch { + return false; + } +} + +function main() { + if (!hasCargo()) { + console.error( + '\nError: `cargo` (Rust toolchain) not found.\n' + + 'Arti has no clean prebuilt-binary distribution, so it is built from\n' + + 'crates.io. Install Rust (https://rustup.rs) and re-run, or set CARGO_BIN.\n' + ); + process.exit(1); + } + + const target = platformKey(); + const targetDir = path.join(OUTPUT_DIR, target); + const binName = process.platform === 'win32' ? 'arti.exe' : 'arti'; + const destBin = path.join(targetDir, binName); + + fs.mkdirSync(targetDir, { recursive: true }); + + // Install into a temp root, then copy just the binary into place. Using a + // dedicated root keeps cargo's bookkeeping out of the repo tree. + const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'arti-install-')); + let ok = false; + + console.log(`Building arti ${ARTI_VERSION} for ${target} (this can take several minutes)...`); + try { + execFileSync( + CARGO_BIN, + ['install', 'arti', '--version', ARTI_VERSION, '--locked', '--root', installRoot], + { stdio: 'inherit' } + ); + + const builtBin = path.join(installRoot, 'bin', binName); + if (!fs.existsSync(builtBin)) { + console.error(`\nError: arti binary not found at ${builtBin} after build.`); + } else { + fs.copyFileSync(builtBin, destBin); + if (process.platform !== 'win32') { + fs.chmodSync(destBin, 0o755); + } + console.log(`\nInstalled arti for ${target} -> ${destBin}`); + ok = true; + } + } catch (err) { + console.error(`\nError: cargo install arti failed: ${err.message}`); + } finally { + // Always clean up the temp install root, even on failure. + try { + fs.rmSync(installRoot, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } + } + + process.exit(ok ? 0 : 1); +} + +main(); diff --git a/src/main/index.js b/src/main/index.js index b0e7b1ea..c30d8820 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -142,6 +142,7 @@ const { registerEnsIpc } = require('./ens-resolver'); const { registerAntIpc, createAntLifecycle, stopAnt, startAnt, setUseInjectedIdentity: setAntInjectedIdentity } = require('./ant-manager'); const { registerIpfsIpc, stopIpfs, startIpfs, setUseInjectedIdentity: setIpfsInjectedIdentity } = require('./ipfs-manager'); const { registerRadicleIpc, stopRadicle, startRadicle, setUseInjectedIdentity: setRadicleInjectedIdentity } = require('./radicle-manager'); +const { registerTorIpc, stopTor, startTor } = require('./tor-manager'); const { registerIdentityIpc, hasVault, setBeeLifecycle } = require('./identity-manager'); const { registerQuickUnlockIpc } = require('./quick-unlock'); const { registerWalletIpc } = require('./wallet/wallet-ipc'); @@ -223,6 +224,7 @@ async function bootstrap() { registerAntIpc(); registerIpfsIpc(); registerRadicleIpc(); + registerTorIpc(); registerGithubBridgeIpc(); registerServiceRegistryIpc(); registerIdentityIpc(); @@ -322,6 +324,9 @@ async function bootstrap() { if (settings.enableRadicleIntegration && settings.startRadicleAtLaunch) { startRadicle(); } + if (settings.enableTorIntegration && settings.startTorAtLaunch) { + startTor({ targetSession: defaultSession }); + } } // Initialize auto-updater (pass menu update callback). Skipped in @@ -398,8 +403,8 @@ app.on('before-quit', async (event) => { // Clean up any GitHub bridge temp directories cleanupTempDirs(); - log.info('[App] Waiting for Ant, IPFS, and Radicle to stop...'); - await Promise.all([stopAnt(), stopIpfs(), stopRadicle()]); + log.info('[App] Waiting for Ant, IPFS, Radicle, and Tor to stop...'); + await Promise.all([stopAnt(), stopIpfs(), stopRadicle(), stopTor()]); log.info('[App] All processes stopped, quitting...'); diff --git a/src/main/preload.js b/src/main/preload.js index 59c55b49..959d48fa 100644 --- a/src/main/preload.js +++ b/src/main/preload.js @@ -313,6 +313,20 @@ contextBridge.exposeInMainWorld('radicle', { }, }); +contextBridge.exposeInMainWorld('tor', { + start: () => ipcRenderer.invoke('tor:start'), + stop: () => ipcRenderer.invoke('tor:stop'), + getStatus: () => ipcRenderer.invoke('tor:getStatus'), + checkBinary: () => ipcRenderer.invoke('tor:checkBinary'), + getVersion: () => ipcRenderer.invoke('tor:getVersion'), + onStatusUpdate: (callback) => { + const handler = (_event, value) => callback(value); + ipcRenderer.on('tor:statusUpdate', handler); + ipcRenderer.invoke('tor:getStatus').then(callback); + return () => ipcRenderer.removeListener('tor:statusUpdate', handler); + }, +}); + contextBridge.exposeInMainWorld('githubBridge', { import: (url) => ipcRenderer.invoke('github-bridge:import', url), checkGit: () => ipcRenderer.invoke('github-bridge:check-git'), diff --git a/src/main/preload.test.js b/src/main/preload.test.js index e2c148ee..65b07aed 100644 --- a/src/main/preload.test.js +++ b/src/main/preload.test.js @@ -83,7 +83,7 @@ describe('preload', () => { beeApiEnv: 'http://127.0.0.1:1700', }); - expect(contextBridge.exposeInMainWorld).toHaveBeenCalledTimes(20); + expect(contextBridge.exposeInMainWorld).toHaveBeenCalledTimes(21); expect(Object.keys(exposures)).toEqual([ 'nodeConfig', 'internalPages', @@ -91,6 +91,7 @@ describe('preload', () => { 'ant', 'ipfs', 'radicle', + 'tor', 'githubBridge', 'serviceRegistry', 'identity', @@ -160,6 +161,11 @@ describe('preload', () => { [exposures.radicle, 'getStatus', [], IPC.RADICLE_GET_STATUS, []], [exposures.radicle, 'checkBinary', [], IPC.RADICLE_CHECK_BINARY, []], [exposures.radicle, 'getConnections', [], IPC.RADICLE_GET_CONNECTIONS, []], + [exposures.tor, 'start', [], IPC.TOR_START, []], + [exposures.tor, 'stop', [], IPC.TOR_STOP, []], + [exposures.tor, 'getStatus', [], IPC.TOR_GET_STATUS, []], + [exposures.tor, 'checkBinary', [], IPC.TOR_CHECK_BINARY, []], + [exposures.tor, 'getVersion', [], IPC.TOR_GET_VERSION, []], [exposures.githubBridge, 'import', ['https://github.com/openai/project'], IPC.GITHUB_BRIDGE_IMPORT, ['https://github.com/openai/project']], [exposures.githubBridge, 'checkGit', [], IPC.GITHUB_BRIDGE_CHECK_GIT, []], [exposures.githubBridge, 'checkPrerequisites', [], IPC.GITHUB_BRIDGE_CHECK_PREREQUISITES, []], @@ -250,11 +256,13 @@ describe('preload', () => { const beeStatus = { status: 'running', error: null }; const ipfsStatus = { status: 'stopped', error: null }; const radicleStatus = { status: 'error', error: 'offline' }; + const torStatus = { status: 'stopped', error: null }; const { exposures, ipcRenderer } = loadPreloadModule({ invokeResponses: { [IPC.ANT_GET_STATUS]: beeStatus, [IPC.IPFS_GET_STATUS]: ipfsStatus, [IPC.RADICLE_GET_STATUS]: radicleStatus, + [IPC.TOR_GET_STATUS]: torStatus, }, }); @@ -262,6 +270,7 @@ describe('preload', () => { [exposures.ant, IPC.ANT_STATUS_UPDATE, IPC.ANT_GET_STATUS, beeStatus, { status: 'starting', error: null }], [exposures.ipfs, IPC.IPFS_STATUS_UPDATE, IPC.IPFS_GET_STATUS, ipfsStatus, { status: 'running', error: null }], [exposures.radicle, IPC.RADICLE_STATUS_UPDATE, IPC.RADICLE_GET_STATUS, radicleStatus, { status: 'running', error: null }], + [exposures.tor, IPC.TOR_STATUS_UPDATE, IPC.TOR_GET_STATUS, torStatus, { status: 'running', error: null }], ]; for (const [target, updateChannel, getStatusChannel, initialStatus, pushedStatus] of statusCases) { diff --git a/src/main/service-registry.js b/src/main/service-registry.js index 3072ab86..4bce4db6 100644 --- a/src/main/service-registry.js +++ b/src/main/service-registry.js @@ -43,6 +43,13 @@ const registry = { tempMessage: null, tempMessageTimeout: null, }, + tor: { + socks: null, // e.g., '127.0.0.1:9150' (Arti SOCKS5 proxy) + mode: MODE.NONE, + statusMessage: null, + tempMessage: null, + tempMessageTimeout: null, + }, }; // Default ports @@ -58,6 +65,10 @@ const DEFAULTS = { p2pPort: 8776, // radicle-node P2P port fallbackRange: 10, }, + tor: { + socksPort: 9150, // Arti SOCKS5 proxy (Tor Browser's default SOCKS port) + fallbackRange: 10, + }, }; /** @@ -75,6 +86,7 @@ function getRegistry() { ipfs: { ...registry.ipfs }, ant: { ...registry.ant }, radicle: { ...registry.radicle }, + tor: { ...registry.tor }, }; } @@ -241,6 +253,13 @@ function getRadicleApiUrl() { return registry.radicle.api; } +/** + * Get the Arti SOCKS proxy host:port (or default) + */ +function getTorSocksUrl() { + return registry.tor.socks || `127.0.0.1:${DEFAULTS.tor.socksPort}`; +} + /** * Register IPC handlers for service registry */ @@ -267,6 +286,7 @@ module.exports = { getAntApiUrl, getAntGatewayUrl, getRadicleApiUrl, + getTorSocksUrl, broadcastRegistryUpdate, registerServiceRegistryIpc, }; diff --git a/src/main/settings-store.js b/src/main/settings-store.js index 3dee28db..65ad88c9 100644 --- a/src/main/settings-store.js +++ b/src/main/settings-store.js @@ -34,6 +34,11 @@ const DEFAULT_SETTINGS = { startAntAtLaunch: true, startIpfsAtLaunch: true, startRadicleAtLaunch: false, + // Tor (.onion) access via the bundled Arti SOCKS proxy. Off by default; + // when enabled, only *.onion traffic is routed through Tor (clearnet and + // the decentralized protocols keep connecting directly). + enableTorIntegration: false, + startTorAtLaunch: false, autoUpdate: true, showBookmarkBar: false, // When true, navigating to an ENS name that resolved with trust.level = diff --git a/src/main/settings-store.test.js b/src/main/settings-store.test.js index b6d6f82f..29dddbd7 100644 --- a/src/main/settings-store.test.js +++ b/src/main/settings-store.test.js @@ -43,6 +43,8 @@ describe('settings-store', () => { startAntAtLaunch: true, startIpfsAtLaunch: true, startRadicleAtLaunch: false, + enableTorIntegration: false, + startTorAtLaunch: false, autoUpdate: true, showBookmarkBar: false, sidebarOpen: false, diff --git a/src/main/tor-manager.js b/src/main/tor-manager.js new file mode 100644 index 00000000..84430527 --- /dev/null +++ b/src/main/tor-manager.js @@ -0,0 +1,472 @@ +/** + * Tor (Arti) node manager. + * + * Spawns the bundled `arti` binary as a local SOCKS5 proxy and wires the + * default Electron session to route `.onion` traffic through it. Mirrors the + * lifecycle/state-machine shape of `radicle-manager.js` (STATUS states, + * service-registry broadcasts, IPC handlers gated by an Experimental setting). + * + * Arti is the Tor Project's pure-Rust Tor client. We run it in SOCKS-proxy + * mode (`arti proxy -c `); see README and `scripts/fetch-arti.js`. + */ + +const log = require('./logger'); +const { ipcMain, app, session } = require('electron'); +const { spawn, execFile } = require('child_process'); +const { promisify } = require('util'); +const path = require('path'); + +const execFileAsync = promisify(execFile); +const fs = require('fs'); +const net = require('net'); +const IPC = require('../shared/ipc-channels'); +const { success, failure } = require('./ipc-contract'); +const { loadSettings } = require('./settings-store'); +const { applyOnionProxy, clearOnionProxy } = require('./tor-proxy'); +const { + MODE, + DEFAULTS, + updateService, + setStatusMessage, + setErrorState, + clearErrorState, + clearService, +} = require('./service-registry'); + +// States (mirrors radicle-manager.js) +const STATUS = { + STOPPED: 'stopped', + STARTING: 'starting', + RUNNING: 'running', + STOPPING: 'stopping', + ERROR: 'error', +}; + +let currentState = STATUS.STOPPED; +let lastError = null; +let artiProcess = null; +let healthCheckInterval = null; +let pendingStart = false; +let forceKillTimeout = null; +let currentSocksPort = DEFAULTS.tor.socksPort; +let proxySession = null; + +/** + * Resolve the bundled arti binary path. Dev layout mirrors radicle: + * dev: /arti-bin/-/arti + * packaged: /arti-bin/arti + */ +function getArtiBinaryPath() { + const platformMap = { darwin: 'mac', linux: 'linux', win32: 'win' }; + const platform = platformMap[process.platform] || process.platform; + const binName = process.platform === 'win32' ? 'arti.exe' : 'arti'; + + if (app.isPackaged) { + return path.join(process.resourcesPath, 'arti-bin', binName); + } + return path.join(__dirname, '..', '..', 'arti-bin', `${platform}-${process.arch}`, binName); +} + +/** + * State/cache directory for Arti. Honors FREEDOM_TOR_DATA (tests / advanced + * users), mirroring the Ant/IPFS/Radicle data-dir overrides. Created with + * 0700 perms so Arti's filesystem-permission checks pass. + */ +function getTorDataPath() { + let dir; + if (process.env.FREEDOM_TOR_DATA) { + dir = process.env.FREEDOM_TOR_DATA; + } else if (!app.isPackaged) { + dir = path.join(__dirname, '..', '..', 'tor-data'); + } else { + dir = path.join(app.getPath('userData'), 'tor-data'); + } + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + // chmod unconditionally: mkdirSync's mode is subject to the process umask, + // and a pre-existing dir may have looser perms. Arti refuses to start if its + // data dir is group/world-accessible. + try { + fs.chmodSync(dir, 0o700); + } catch { + // Non-fatal: on Windows chmod is a no-op and Arti's perm checks differ. + } + return dir; +} + +/** + * Write an arti.toml that pins the SOCKS port and redirects state/cache into + * our data dir, then return its path. + */ +function writeArtiConfig(dataDir, socksPort) { + const stateDir = path.join(dataDir, 'state'); + const cacheDir = path.join(dataDir, 'cache'); + for (const d of [stateDir, cacheDir]) { + if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true }); + // chmod unconditionally — same umask/pre-existing-dir rationale as the + // parent data dir; Arti rejects group/world-accessible state/cache dirs. + try { + fs.chmodSync(d, 0o700); + } catch { + // Non-fatal on Windows. + } + } + // TOML strings need forward slashes even on Windows; JSON.stringify escapes safely. + const toml = [ + '[proxy]', + `socks_listen = ${socksPort}`, + '', + '[storage]', + `cache_dir = ${JSON.stringify(cacheDir)}`, + `state_dir = ${JSON.stringify(stateDir)}`, + '', + '[logging]', + 'console = "info"', + '', + ].join('\n'); + const configPath = path.join(dataDir, 'arti.toml'); + fs.writeFileSync(configPath, toml, 'utf-8'); + return configPath; +} + +function updateState(newState, error = null) { + log.info('[Tor] State change:', currentState, '->', newState, error ? `(error: ${error})` : ''); + currentState = newState; + lastError = error; + const windows = require('electron').BrowserWindow.getAllWindows(); + for (const win of windows) { + try { + win.webContents.send(IPC.TOR_STATUS_UPDATE, { status: currentState, error: lastError }); + } catch { + // Window might be closing + } + } +} + +/** Check if a port is open (something is listening). */ +function isPortOpen(port, host = '127.0.0.1') { + return new Promise((resolve) => { + const socket = new net.Socket(); + socket.setTimeout(1000); + socket.on('connect', () => { + socket.destroy(); + resolve(true); + }); + socket.on('timeout', () => { + socket.destroy(); + resolve(false); + }); + socket.on('error', () => { + socket.destroy(); + resolve(false); + }); + socket.connect(port, host); + }); +} + +/** Find an available port starting from the default. */ +async function findAvailablePort(defaultPort, maxAttempts = DEFAULTS.tor.fallbackRange) { + for (let i = 0; i < maxAttempts; i++) { + const port = defaultPort + i; + const open = await isPortOpen(port); + if (!open) return port; + log.info(`[Tor] Port ${port} is busy, trying next...`); + } + return null; +} + +/** Readiness: the SOCKS port is accepting connections. */ +async function checkHealth() { + return isPortOpen(currentSocksPort); +} + +function startHealthCheck() { + if (healthCheckInterval) clearInterval(healthCheckInterval); + healthCheckInterval = setInterval(async () => { + const healthy = await checkHealth(); + if (!healthy && currentState === STATUS.RUNNING) { + updateState(STATUS.ERROR, 'SOCKS port unreachable'); + setErrorState('tor', 'Tor unreachable. Retrying…'); + } else if (healthy && currentState === STATUS.ERROR) { + clearErrorState('tor'); + updateState(STATUS.RUNNING); + } + }, 5000); +} + +function checkBinary() { + return fs.existsSync(getArtiBinaryPath()); +} + +let cachedVersion = null; + +/** + * Read the bundled arti binary's version (`arti --version`). Cached after the + * first successful read since the binary doesn't change at runtime. + * @returns {Promise<{success: boolean, name?: string, version?: string}>} + */ +async function getArtiVersion() { + if (cachedVersion) { + return success({ name: 'Arti', version: cachedVersion }); + } + const artiPath = getArtiBinaryPath(); + if (!fs.existsSync(artiPath)) { + return failure('TOR_BINARY_NOT_FOUND', 'arti binary not found'); + } + try { + const { stdout, stderr } = await execFileAsync(artiPath, ['--version'], { timeout: 5000 }); + const out = `${stdout || ''}${stderr || ''}`.trim(); + // `arti --version` prints e.g. "arti 1.4.4"; fall back to raw output. + const match = out.match(/(\d+\.\d+\.\d+[^\s]*)/); + cachedVersion = match ? match[1] : out; + return success({ name: 'Arti', version: cachedVersion }); + } catch (err) { + log.warn('[Tor] version lookup failed:', err.message); + return failure('TOR_VERSION_FAILED', err.message); + } +} + +/** + * Start Arti as a SOCKS proxy and route `.onion` through it. + * @param {object} [opts] + * @param {import('electron').Session} [opts.targetSession] session to proxy + */ +async function startTor(opts = {}) { + log.info('[Tor] startTor() called, currentState:', currentState); + + if (currentState === STATUS.RUNNING || currentState === STATUS.STARTING) { + log.info(`[Tor] Ignoring start request, current state: ${currentState}`); + return; + } + if (currentState === STATUS.STOPPING) { + log.info('[Tor] Currently stopping, queuing start for after stop completes'); + pendingStart = true; + return; + } + + proxySession = opts.targetSession || session.defaultSession; + + pendingStart = false; + updateState(STATUS.STARTING); + setStatusMessage('tor', 'Bootstrapping…'); + + const artiPath = getArtiBinaryPath(); + if (!fs.existsSync(artiPath)) { + updateState(STATUS.ERROR, `arti binary not found at ${artiPath}`); + setStatusMessage('tor', 'Tor binary not found'); + return; + } + + // Resolve a free SOCKS port (default 9150, fall back if busy). + let socksPort = DEFAULTS.tor.socksPort; + if (await isPortOpen(socksPort)) { + const next = await findAvailablePort(socksPort + 1); + if (!next) { + updateState(STATUS.ERROR, 'No available ports for Tor SOCKS proxy'); + setStatusMessage('tor', 'Tor failed to start'); + return; + } + socksPort = next; + } + currentSocksPort = socksPort; + + let configPath; + try { + configPath = writeArtiConfig(getTorDataPath(), socksPort); + } catch (err) { + updateState(STATUS.ERROR, `Failed to write arti config: ${err.message}`); + setStatusMessage('tor', 'Tor failed to start'); + return; + } + + log.info(`[Tor] Starting arti: ${artiPath} proxy -c ${configPath} (SOCKS ${socksPort})`); + try { + artiProcess = spawn(artiPath, ['proxy', '-c', configPath], { + env: { ...process.env }, + }); + } catch (err) { + updateState(STATUS.ERROR, err.message); + setStatusMessage('tor', 'Tor failed to start'); + return; + } + + artiProcess.stdout.on('data', (data) => log.info(`[arti stdout]: ${data}`)); + artiProcess.stderr.on('data', (data) => log.info(`[arti stderr]: ${data}`)); + + artiProcess.on('error', (err) => { + log.error('[Tor] Failed to start process:', err); + updateState(STATUS.ERROR, err.message); + setStatusMessage('tor', 'Tor failed to start'); + }); + + artiProcess.on('close', (code) => { + log.info(`[Tor] arti process exited with code ${code}`); + artiProcess = null; + if (forceKillTimeout) { + clearTimeout(forceKillTimeout); + forceKillTimeout = null; + } + if (healthCheckInterval) { + clearInterval(healthCheckInterval); + healthCheckInterval = null; + } + // Tear down the proxy so clearnet isn't pointed at a dead SOCKS port. + if (proxySession) clearOnionProxy(proxySession).catch(() => {}); + if (currentState !== STATUS.STOPPING && code !== 0) { + // Unexpected exit (crash / killed): surface it as an error and keep the + // service entry so the menu shows a failure indication, rather than a + // silent stop that looks identical to a clean shutdown. + updateState(STATUS.ERROR, `arti exited with code ${code}`); + setErrorState('tor', `Tor exited unexpectedly (code ${code})`); + } else { + updateState(STATUS.STOPPED); + clearService('tor'); + } + + if (pendingStart) { + pendingStart = false; + setTimeout(() => startTor({ targetSession: proxySession }), 100); + } + }); + + // Poll for the SOCKS port to come up, then apply the proxy. + let attempts = 0; + const maxAttempts = 120; // up to ~120s for first bootstrap + const pollInterval = setInterval(async () => { + if (currentState === STATUS.STOPPED || currentState === STATUS.ERROR || !artiProcess) { + clearInterval(pollInterval); + return; + } + const healthy = await checkHealth(); + if (healthy) { + clearInterval(pollInterval); + try { + await applyOnionProxy(proxySession, `127.0.0.1:${currentSocksPort}`); + } catch (err) { + log.error('[Tor] Failed to apply proxy:', err.message); + } + updateService('tor', { + socks: `127.0.0.1:${currentSocksPort}`, + mode: MODE.BUNDLED, + }); + setStatusMessage('tor', `SOCKS: 127.0.0.1:${currentSocksPort}`); + updateState(STATUS.RUNNING); + startHealthCheck(); + } else { + attempts++; + if (attempts >= maxAttempts) { + clearInterval(pollInterval); + stopTor(); + updateState(STATUS.ERROR, 'Startup timed out'); + setStatusMessage('tor', 'Tor failed to start'); + } + } + }, 1000); +} + +/** Stop Arti and restore direct connections. Resolves when the process exits. */ +function stopTor() { + return new Promise((resolve) => { + pendingStart = false; + + const finish = () => { + if (healthCheckInterval) { + clearInterval(healthCheckInterval); + healthCheckInterval = null; + } + if (forceKillTimeout) { + clearTimeout(forceKillTimeout); + forceKillTimeout = null; + } + if (proxySession) clearOnionProxy(proxySession).catch(() => {}); + clearService('tor'); + resolve(); + }; + + if (!artiProcess) { + updateState(STATUS.STOPPED); + finish(); + return; + } + + updateState(STATUS.STOPPING); + if (healthCheckInterval) clearInterval(healthCheckInterval); + + artiProcess.once('close', finish); + + if (forceKillTimeout) clearTimeout(forceKillTimeout); + forceKillTimeout = setTimeout(() => { + if (artiProcess) { + log.warn('[Tor] Force killing arti...'); + artiProcess.kill('SIGKILL'); + } + forceKillTimeout = null; + }, 10000); + + artiProcess.kill('SIGTERM'); + }); +} + +function getActivePort() { + return currentSocksPort; +} + +function registerTorIpc() { + log.info('[Tor] Registering IPC handlers'); + const torDisabledResponse = { + status: STATUS.STOPPED, + error: 'Tor integration is disabled. Enable it in Settings > Experimental', + }; + const isTorEnabled = () => loadSettings().enableTorIntegration === true; + + ipcMain.handle(IPC.TOR_START, () => { + if (!isTorEnabled()) { + log.info('[Tor] IPC: start blocked, integration disabled'); + return torDisabledResponse; + } + log.info('[Tor] IPC: start requested'); + startTor(); + return { status: currentState, error: lastError }; + }); + + ipcMain.handle(IPC.TOR_STOP, () => { + log.info('[Tor] IPC: stop requested'); + stopTor(); + return { status: currentState, error: lastError }; + }); + + ipcMain.handle(IPC.TOR_GET_STATUS, () => { + if (!isTorEnabled()) return torDisabledResponse; + return { status: currentState, error: lastError }; + }); + + ipcMain.handle(IPC.TOR_CHECK_BINARY, () => { + const available = checkBinary(); + log.info('[Tor] IPC: checkBinary requested, available:', available); + return { available }; + }); + + ipcMain.handle(IPC.TOR_GET_VERSION, async () => { + if (!isTorEnabled()) { + return failure( + 'TOR_DISABLED', + 'Tor integration is disabled. Enable it in Settings > Experimental' + ); + } + return getArtiVersion(); + }); +} + +module.exports = { + registerTorIpc, + startTor, + stopTor, + getActivePort, + getArtiVersion, + getArtiBinaryPath, + getTorDataPath, + writeArtiConfig, + checkBinary, + STATUS, +}; diff --git a/src/main/tor-manager.test.js b/src/main/tor-manager.test.js new file mode 100644 index 00000000..46eaa742 --- /dev/null +++ b/src/main/tor-manager.test.js @@ -0,0 +1,125 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const IPC = require('../shared/ipc-channels'); +const { createIpcMainMock, loadMainModule } = require('../../test/helpers/main-process-test-utils'); + +const PROJECT_ROOT = path.join(__dirname, '..', '..'); + +function loadTorManager(options = {}) { + const ipcMain = options.ipcMain || createIpcMainMock(); + const enableTorIntegration = options.enableTorIntegration === true; + return loadMainModule(require.resolve('./tor-manager'), { + ipcMain, + electronOverrides: { + session: { defaultSession: { setProxy: jest.fn().mockResolvedValue(undefined) } }, + }, + extraMocks: { + [require.resolve('./logger')]: () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }), + [require.resolve('./settings-store')]: () => ({ + loadSettings: () => ({ enableTorIntegration }), + }), + }, + }); +} + +describe('tor-manager paths and config', () => { + test('getArtiBinaryPath points at the dev arti-bin layout', () => { + const { mod } = loadTorManager(); + const expected = path.join( + PROJECT_ROOT, + 'arti-bin', + `${{ darwin: 'mac', linux: 'linux', win32: 'win' }[process.platform] || process.platform}-${process.arch}`, + process.platform === 'win32' ? 'arti.exe' : 'arti' + ); + expect(mod.getArtiBinaryPath()).toBe(expected); + }); + + test('getTorDataPath honors FREEDOM_TOR_DATA override', () => { + const overrideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tor-data-')); + const prev = process.env.FREEDOM_TOR_DATA; + process.env.FREEDOM_TOR_DATA = overrideDir; + try { + const { mod } = loadTorManager(); + expect(mod.getTorDataPath()).toBe(overrideDir); + } finally { + if (prev === undefined) delete process.env.FREEDOM_TOR_DATA; + else process.env.FREEDOM_TOR_DATA = prev; + fs.rmSync(overrideDir, { recursive: true, force: true }); + } + }); + + test('writeArtiConfig pins the SOCKS port and storage dirs', () => { + const { mod } = loadTorManager(); + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tor-cfg-')); + try { + const configPath = mod.writeArtiConfig(dataDir, 9155); + const toml = fs.readFileSync(configPath, 'utf-8'); + expect(toml).toContain('socks_listen = 9155'); + expect(toml).toContain('[storage]'); + expect(toml).toContain(JSON.stringify(path.join(dataDir, 'state'))); + expect(toml).toContain(JSON.stringify(path.join(dataDir, 'cache'))); + expect(fs.existsSync(path.join(dataDir, 'state'))).toBe(true); + expect(fs.existsSync(path.join(dataDir, 'cache'))).toBe(true); + } finally { + fs.rmSync(dataDir, { recursive: true, force: true }); + } + }); + + test('checkBinary returns false when the arti binary is absent', () => { + const { mod } = loadTorManager(); + // No arti-bin built in the test tree. + expect(mod.checkBinary()).toBe(false); + }); +}); + +describe('tor-manager IPC', () => { + test('TOR_GET_STATUS returns disabled response when integration is off', async () => { + const ipcMain = createIpcMainMock(); + const { mod } = loadTorManager({ ipcMain, enableTorIntegration: false }); + mod.registerTorIpc(); + + const res = await ipcMain.invoke(IPC.TOR_GET_STATUS); + expect(res.status).toBe('stopped'); + expect(res.error).toMatch(/disabled/i); + }); + + test('TOR_START is blocked when integration is off', async () => { + const ipcMain = createIpcMainMock(); + const { mod } = loadTorManager({ ipcMain, enableTorIntegration: false }); + mod.registerTorIpc(); + + const res = await ipcMain.invoke(IPC.TOR_START); + expect(res.status).toBe('stopped'); + expect(res.error).toMatch(/disabled/i); + }); + + test('TOR_CHECK_BINARY reports availability', async () => { + const ipcMain = createIpcMainMock(); + const { mod } = loadTorManager({ ipcMain }); + mod.registerTorIpc(); + + const res = await ipcMain.invoke(IPC.TOR_CHECK_BINARY); + expect(res).toEqual({ available: false }); + }); + + test('TOR_GET_VERSION is blocked when integration is off', async () => { + const ipcMain = createIpcMainMock(); + const { mod } = loadTorManager({ ipcMain, enableTorIntegration: false }); + mod.registerTorIpc(); + + const res = await ipcMain.invoke(IPC.TOR_GET_VERSION); + expect(res.success).toBe(false); + expect(res.error?.message || res.error).toMatch(/disabled/i); + }); + + test('getArtiVersion fails when the binary is absent', async () => { + const { mod } = loadTorManager({ enableTorIntegration: true }); + const res = await mod.getArtiVersion(); + expect(res.success).toBe(false); + }); +}); diff --git a/src/main/tor-proxy.js b/src/main/tor-proxy.js new file mode 100644 index 00000000..2f0cd2d6 --- /dev/null +++ b/src/main/tor-proxy.js @@ -0,0 +1,75 @@ +/** + * Tor proxy wiring for Electron sessions. + * + * Scope is `.onion`-only: a PAC script routes `*.onion` hostnames through the + * local Arti SOCKS5 proxy and returns DIRECT for everything else, so clearnet + * and the decentralized protocols (bzz/ipfs/ipns/rad) keep connecting directly. + * `.onion` needs no custom scheme — it is an ordinary http(s) host that just + * needs proxying, and SOCKS5 does remote DNS so the name resolves at Tor. + */ + +const log = require('./logger'); + +/** + * Build the PAC script that routes only `*.onion` through the SOCKS5 proxy. + * Pure function so it can be unit-tested without a live session. + * + * @param {string} socksHostPort - e.g. '127.0.0.1:9150' + * @returns {string} PAC script source + */ +function buildOnionPacScript(socksHostPort) { + // dnsDomainIs matches both `foo.onion` and `sub.foo.onion`. + // SOCKS5 only (no SOCKS4 fallback): SOCKS4 can't do remote DNS, so a + // fallback would make Chromium resolve the .onion name locally — a DNS leak + // and a guaranteed failure. Fail closed if the proxy is unreachable. + return [ + 'function FindProxyForURL(url, host) {', + ` if (dnsDomainIs(host, ".onion") || host === "onion") {`, + ` return "SOCKS5 ${socksHostPort}";`, + ' }', + ' return "DIRECT";', + '}', + ].join('\n'); +} + +/** + * Point a session at the Arti SOCKS proxy for `.onion` traffic only. + * + * @param {import('electron').Session} targetSession + * @param {string} socksHostPort - e.g. '127.0.0.1:9150' + * @returns {Promise} + */ +async function applyOnionProxy(targetSession, socksHostPort) { + if (!targetSession || typeof targetSession.setProxy !== 'function') { + log.warn('[tor-proxy] session.setProxy unavailable — skipping proxy apply'); + return; + } + const pacScript = buildOnionPacScript(socksHostPort); + // Inline the PAC via a data: URL so we don't depend on a file on disk. + const pacUrl = `data:application/x-ns-proxy-autoconfig;base64,${Buffer.from( + pacScript, + 'utf-8' + ).toString('base64')}`; + await targetSession.setProxy({ mode: 'pac_script', pacScript: pacUrl }); + log.info(`[tor-proxy] .onion traffic routed via SOCKS5 ${socksHostPort}`); +} + +/** + * Restore direct connections (no proxy) on a session. + * + * @param {import('electron').Session} targetSession + * @returns {Promise} + */ +async function clearOnionProxy(targetSession) { + if (!targetSession || typeof targetSession.setProxy !== 'function') { + return; + } + await targetSession.setProxy({ mode: 'direct' }); + log.info('[tor-proxy] proxy cleared — connections are direct'); +} + +module.exports = { + buildOnionPacScript, + applyOnionProxy, + clearOnionProxy, +}; diff --git a/src/main/tor-proxy.test.js b/src/main/tor-proxy.test.js new file mode 100644 index 00000000..7403af36 --- /dev/null +++ b/src/main/tor-proxy.test.js @@ -0,0 +1,60 @@ +jest.mock('./logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() })); + +const { buildOnionPacScript, applyOnionProxy, clearOnionProxy } = require('./tor-proxy'); + +// Compile the PAC text into a callable FindProxyForURL, supplying the +// `dnsDomainIs` built-in that Chromium provides to PAC scripts (it isn't a +// plain-JS global). Standard semantics: host ends with the given domain. +function compilePac(pac) { + return new Function('dnsDomainIs', `${pac}; return FindProxyForURL;`)((host, domain) => + String(host).toLowerCase().endsWith(String(domain).toLowerCase()) + ); +} + +describe('buildOnionPacScript', () => { + const pac = buildOnionPacScript('127.0.0.1:9150'); + + test('is a syntactically valid PAC FindProxyForURL function', () => { + expect(pac).toContain('function FindProxyForURL(url, host)'); + expect(() => compilePac(pac)).not.toThrow(); + }); + + test('routes .onion (and subdomains) through the SOCKS5 proxy', () => { + const find = compilePac(pac); + expect(find('http://abc.onion/', 'abc.onion')).toContain('SOCKS5 127.0.0.1:9150'); + expect(find('http://sub.abc.onion/', 'sub.abc.onion')).toContain('SOCKS5 127.0.0.1:9150'); + }); + + test('returns DIRECT for clearnet hosts', () => { + const find = compilePac(pac); + expect(find('https://example.com/', 'example.com')).toBe('DIRECT'); + expect(find('https://onion.example.com/', 'onion.example.com')).toBe('DIRECT'); + }); + + test('embeds the provided host:port', () => { + const custom = buildOnionPacScript('127.0.0.1:9999'); + expect(custom).toContain('127.0.0.1:9999'); + }); +}); + +describe('applyOnionProxy / clearOnionProxy', () => { + test('applyOnionProxy sets a pac_script proxy on the session', async () => { + const setProxy = jest.fn().mockResolvedValue(undefined); + await applyOnionProxy({ setProxy }, '127.0.0.1:9150'); + expect(setProxy).toHaveBeenCalledTimes(1); + const arg = setProxy.mock.calls[0][0]; + expect(arg.mode).toBe('pac_script'); + expect(arg.pacScript).toMatch(/^data:application\/x-ns-proxy-autoconfig;base64,/); + }); + + test('clearOnionProxy resets the session to direct', async () => { + const setProxy = jest.fn().mockResolvedValue(undefined); + await clearOnionProxy({ setProxy }); + expect(setProxy).toHaveBeenCalledWith({ mode: 'direct' }); + }); + + test('no-ops gracefully when session has no setProxy', async () => { + await expect(applyOnionProxy(null, '127.0.0.1:9150')).resolves.toBeUndefined(); + await expect(clearOnionProxy({})).resolves.toBeUndefined(); + }); +}); diff --git a/src/renderer/index.html b/src/renderer/index.html index 9ac25121..4deac54f 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -579,6 +579,31 @@ + +
+ +
+ + + +
+
+ + +
+
+ Version: + +
+
+
+ @@ -1328,6 +1335,83 @@

Ledger Account Added!

+ + +