diff --git a/packages/node-utils/src/checkSocks5Proxy.ts b/packages/node-utils/src/checkSocks5Proxy.ts new file mode 100644 index 00000000000..e6af1f4e138 --- /dev/null +++ b/packages/node-utils/src/checkSocks5Proxy.ts @@ -0,0 +1,35 @@ +import net from 'net'; + +export const checkSocks5Proxy = (host: string, port: number): Promise => { + return new Promise((resolve, reject) => { + const socket = new net.Socket(); + + socket.setTimeout(2_000); + + socket.on('connect', () => { + // Version 5, 1 method, no authentication + const handshakeRequest = Buffer.from([0x05, 0x01, 0x00]); + socket.write(handshakeRequest); + }); + + socket.on('data', data => { + if (data[0] === 0x05 && data[1] === 0x00) { + resolve(true); + } else { + resolve(false); + } + socket.destroy(); + }); + + socket.on('error', err => { + reject(err); + }); + + socket.on('timeout', () => { + socket.destroy(); + reject(new Error('Connection timed out')); + }); + + socket.connect(port, host); + }); +}; diff --git a/packages/node-utils/src/index.ts b/packages/node-utils/src/index.ts index 0201677b904..951c43c5d5e 100644 --- a/packages/node-utils/src/index.ts +++ b/packages/node-utils/src/index.ts @@ -12,3 +12,4 @@ export { type Response, } from './http'; export { checkFileExists } from './checkFileExists'; +export { checkSocks5Proxy } from './checkSocks5Proxy'; diff --git a/packages/node-utils/src/tests/checkSocks5Proxy.test.ts b/packages/node-utils/src/tests/checkSocks5Proxy.test.ts new file mode 100644 index 00000000000..7a90e28ae78 --- /dev/null +++ b/packages/node-utils/src/tests/checkSocks5Proxy.test.ts @@ -0,0 +1,62 @@ +import net from 'net'; + +import { checkSocks5Proxy } from '../checkSocks5Proxy'; + +jest.mock('net'); + +describe('checkSocks5Proxy', () => { + const host = '127.0.0.1'; + const port = 9050; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return true for a valid SOCKS5 proxy', async () => { + const mockSocket = { + connect: jest.fn(), + write: jest.fn(), + on: jest.fn((event, callback) => { + if (event === 'connect') { + callback(); + } + if (event === 'data') { + // Valid SOCKS5 response. + callback(Buffer.from([0x05, 0x00])); + } + }), + setTimeout: jest.fn(), + destroy: jest.fn(), + }; + + // @ts-expect-error + net.Socket.mockImplementation(() => mockSocket); + + const result = await checkSocks5Proxy(host, port); + expect(result).toBe(true); + }); + + it('should return false for an invalid SOCKS5 proxy', async () => { + const mockSocket = { + connect: jest.fn(), + write: jest.fn(), + on: jest.fn((event, callback) => { + if (event === 'connect') { + callback(); + } + if (event === 'data') { + // Not valid SOCKS5 response + callback(Buffer.from([0x05, 0x01])); + } + }), + setTimeout: jest.fn(), + destroy: jest.fn(), + }; + + // @ts-expect-error + net.Socket.mockImplementation(() => mockSocket); + + const result = await checkSocks5Proxy(host, port); + expect(result).toBe(false); + }); +}); diff --git a/packages/request-manager/e2e/identities-stress.ts b/packages/request-manager/e2e/identities-stress.ts index 5f1289c8c53..faad13c90c0 100644 --- a/packages/request-manager/e2e/identities-stress.ts +++ b/packages/request-manager/e2e/identities-stress.ts @@ -37,9 +37,8 @@ const intervalBetweenRequests = 1000 * 20; port, controlPort, torDataDir, - snowflakeBinaryPath: '', }); - const torParams = await torController.getTorConfiguration(processId); + const torParams = torController.getTorConfiguration(processId); // Starting Tor process from binary. torRunner({ torParams, diff --git a/packages/request-manager/e2e/interceptor.test.ts b/packages/request-manager/e2e/interceptor.test.ts index dd12649c141..5186a783a83 100644 --- a/packages/request-manager/e2e/interceptor.test.ts +++ b/packages/request-manager/e2e/interceptor.test.ts @@ -10,7 +10,6 @@ const hostIp = '127.0.0.1'; const port = 38835; const controlPort = 35527; const processId = process.pid; -const snowflakeBinaryPath = ''; // 1 minute before timeout, because Tor might be slow to start. jest.setTimeout(60000); @@ -29,7 +28,7 @@ describe('Interceptor', () => { let torController: TorController; let torIdentities: TorIdentities; - const torSettings = { running: true, host: hostIp, port, snowflakeBinaryPath }; + const torSettings = { running: true, host: hostIp, port }; const INTERCEPTOR = { handler: () => {}, @@ -45,9 +44,8 @@ describe('Interceptor', () => { port, controlPort, torDataDir, - snowflakeBinaryPath, }); - const torParams = await torController.getTorConfiguration(processId); + const torParams = torController.getTorConfiguration(processId); // Starting Tor process from binary. torProcess = torRunner({ torParams, diff --git a/packages/request-manager/e2e/torControlPort.test.ts b/packages/request-manager/e2e/torControlPort.test.ts index d341d062f13..864764c11ad 100644 --- a/packages/request-manager/e2e/torControlPort.test.ts +++ b/packages/request-manager/e2e/torControlPort.test.ts @@ -18,7 +18,6 @@ const controlAuthCookiePath = `${torDataDir}/control_auth_cookie`; const host = 'localhost'; const port = 9998; const controlPort = 9999; -const snowflakeBinaryPath = ''; describe('TorControlPort', () => { beforeAll(async () => { @@ -40,7 +39,6 @@ describe('TorControlPort', () => { port, controlPort, torDataDir, - snowflakeBinaryPath, }; const fakeListener = () => {}; const torControlPort = new TorControlPort(options, fakeListener); @@ -105,7 +103,6 @@ describe('TorControlPort', () => { port, controlPort, torDataDir, - snowflakeBinaryPath, }; const fakeListener = () => {}; const torControlPort = new TorControlPort(options, fakeListener); diff --git a/packages/request-manager/src/controller.ts b/packages/request-manager/src/controller.ts index ac42aeb48c8..ade8cdd97af 100644 --- a/packages/request-manager/src/controller.ts +++ b/packages/request-manager/src/controller.ts @@ -1,8 +1,7 @@ import { EventEmitter } from 'events'; import path from 'path'; -import { createTimeoutPromise } from '@trezor/utils'; -import { checkFileExists } from '@trezor/node-utils'; +import { ScheduleActionParams, ScheduledAction, scheduleAction } from '@trezor/utils'; import { TorControlPort } from './torControlPort'; import { @@ -13,15 +12,15 @@ import { } from './types'; import { bootstrapParser, BOOTSTRAP_EVENT_PROGRESS } from './events/bootstrap'; +const WAITING_TIME = 1000; +const MAX_TRIES_WAITING = 200; +const BOOTSTRAP_SLOW_TRESHOLD = 1000 * 5; // 5 seconds. + export class TorController extends EventEmitter { options: TorConnectionOptions; controlPort: TorControlPort; bootstrapSlownessChecker?: NodeJS.Timeout; status: TorControllerStatus = TOR_CONTROLLER_STATUS.Stopped; - // Configurations - waitingTime = 1000; - maxTriesWaiting = 200; - bootstrapSlowThreshold = 1000 * 5; // 5 seconds. constructor(options: TorConnectionOptions) { super(); @@ -57,23 +56,37 @@ export class TorController extends EventEmitter { if (this.bootstrapSlownessChecker) { clearTimeout(this.bootstrapSlownessChecker); } - // When Bootstrap starts we wait time defined in bootstrapSlowThreshold and if after that time, + // When Bootstrap starts we wait time defined in BOOTSTRAP_SLOW_TRESHOLD and if after that time, // it has not being finalized, then we send slow event. We know that Bootstrap is going on since // we received, at least, first Bootstrap events from ControlPort. this.bootstrapSlownessChecker = setTimeout(() => { this.emit('bootstrap/event', { type: 'slow', }); - }, this.bootstrapSlowThreshold); + }, BOOTSTRAP_SLOW_TRESHOLD); } - public async getTorConfiguration( - processId: number, - snowflakeBinaryPath?: string, - ): Promise { + private onMessageReceived(message: string) { + const bootstrap: BootstrapEvent[] = bootstrapParser(message); + bootstrap.forEach(event => { + if (event.type !== 'progress') return; + if (event.progress && !this.getIsBootstrapping()) { + // We consider that bootstrap has started when we receive any bootstrap event and + // Tor is not bootstrapping yet. + // If we do not receive any bootstrapping event, we can consider there is something going wrong and + // an error will be thrown when `MAX_TRIES_WAITING` is reached in `waitUntilAlive`. + this.startBootstrap(); + } + if (event.progress === BOOTSTRAP_EVENT_PROGRESS.Done) { + this.successfullyBootstrapped(); + } + this.emit('bootstrap/event', event); + }); + } + + public getTorConfiguration(processId: number): string[] { const { torDataDir } = this.options; const controlAuthCookiePath = path.join(torDataDir, 'control_auth_cookie'); - const snowflakeLogPath = path.join(torDataDir, 'snowflake.log'); // https://github.com/torproject/tor/blob/bf30943cb75911d70367106af644d4273baaa85d/doc/man/tor.1.txt const config: string[] = [ @@ -136,96 +149,42 @@ export class TorController extends EventEmitter { this.options.torDataDir, ]; - let existsSnowflakeBinary = false; - if (snowflakeBinaryPath && snowflakeBinaryPath.trim() !== '') { - // If provided snowflake file does not exists, do not use it. - existsSnowflakeBinary = await checkFileExists(snowflakeBinaryPath); - } - - if (existsSnowflakeBinary) { - // Snowflake is a WebRTC pluggable transport for Tor (client) - // More info: - // https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/-/tree/main/client - // https://packages.debian.org/bookworm/snowflake-client - - const SNOWFLAKE_PLUGIN = 'snowflake exec'; - const SNOWFLAKE_SERVER = 'snowflake 192.0.2.3:80'; - const SNOWFLAKE_FINGERPRINT = '2B280B23E1107BB62ABFC40DDCC8824814F80A72'; - const SNOWFLAKE_URL = 'https://snowflake-broker.torproject.net.global.prod.fastly.net/'; - const SNOWFLAKE_FRONT = 'fronts=foursquare.com,github.githubassets.com'; - const SNOWFLAKE_ICE = - 'ice=stun:stun.l.google.com:19302,stun:stun.antisip.com:3478,stun:stun.bluesip.net:3478,stun:stun.dus.net:3478,stun:stun.epygi.com:3478,stun:stun.sonetel.com:3478,stun:stun.uls.co.za:3478,stun:stun.voipgate.com:3478,stun:stun.voys.nl:3478'; - const SNOWFLAKE_UTLS = 'utls-imitate=hellorandomizedalpn'; - - const snowflakeCommand = `${SNOWFLAKE_PLUGIN} ${snowflakeBinaryPath} -log ${snowflakeLogPath}`; - const snowflakeBridge = `${SNOWFLAKE_SERVER} ${SNOWFLAKE_FINGERPRINT} fingerprint=${SNOWFLAKE_FINGERPRINT} url=${SNOWFLAKE_URL} ${SNOWFLAKE_FRONT} ${SNOWFLAKE_ICE} ${SNOWFLAKE_UTLS}`; - - config.push( - '--UseBridges', - '1', - '--ClientTransportPlugin', - snowflakeCommand, - '--Bridge', - snowflakeBridge, - ); - } - return config; } - public onMessageReceived(message: string) { - const bootstrap: BootstrapEvent[] = bootstrapParser(message); - bootstrap.forEach(event => { - if (event.type !== 'progress') return; - if (event.progress && !this.getIsBootstrapping()) { - // We consider that bootstrap has started when we receive any bootstrap event and - // Tor is not bootstrapping yet. - // If we do not receive any bootstrapping event, we can consider there is something going wrong and - // an error will be thrown when `maxTriesWaiting` is reached in `waitUntilAlive`. - this.startBootstrap(); - } - if (event.progress === BOOTSTRAP_EVENT_PROGRESS.Done) { - this.successfullyBootstrapped(); - } - this.emit('bootstrap/event', event); - }); - } - - public waitUntilAlive(): Promise { - const errorMessages: string[] = []; + public async waitUntilAlive(): Promise { this.status = TOR_CONTROLLER_STATUS.Bootstrapping; - const waitUntilResponse = async (triesCount: number): Promise => { - if (this.getIsStopped()) { - // If TOR is starting and we want to cancel it. - return; + + const abortController = new AbortController(); + const checkConnection: ScheduledAction = async signal => { + if (signal?.aborted) { + throw new Error('Tor controller check alive aborted'); } - if (triesCount >= this.maxTriesWaiting) { - throw new Error( - `Timeout waiting for TOR control port: \n${errorMessages.join('\n')}`, - ); + const isConnected = await this.controlPort.connect(); + const isAlive = this.controlPort.ping(); + const isCircuitEstablished = this.getIsCircuitEstablished(); + // It is running so let's not wait anymore. + if (isConnected && isAlive && isCircuitEstablished) { + return true; } - try { - const isConnected = await this.controlPort.connect(); - const isAlive = this.controlPort.ping(); - if (isConnected && isAlive && this.getIsCircuitEstablished()) { - // It is running so let's not wait anymore. - return; - } - } catch (error) { - // Some error here is expected when waiting but - // we do not want to throw until maxTriesWaiting is reach. - // Instead we want to log it to know what causes the error. - if (error && error.message) { - console.warn('request-manager:', error.message); - errorMessages.push(error.message); + throw new Error('Tor not alive'); + }; + const params: ScheduleActionParams = { + attempts: MAX_TRIES_WAITING, + timeout: WAITING_TIME, + gap: WAITING_TIME, + signal: abortController.signal, + attemptFailureHandler: () => { + if (this.getIsStopped()) { + abortController.abort(); + + return new Error('Operation stopped.'); } - } - await createTimeoutPromise(this.waitingTime); - return waitUntilResponse(triesCount + 1); + return undefined; + }, }; - - return waitUntilResponse(1); + await scheduleAction(checkConnection, params); } public getStatus(): Promise { diff --git a/packages/request-manager/src/controllerExternal.ts b/packages/request-manager/src/controllerExternal.ts new file mode 100644 index 00000000000..8e81d701e43 --- /dev/null +++ b/packages/request-manager/src/controllerExternal.ts @@ -0,0 +1,99 @@ +import { EventEmitter } from 'events'; + +import { ScheduleActionParams, ScheduledAction, scheduleAction } from '@trezor/utils'; +import { checkSocks5Proxy } from '@trezor/node-utils'; + +import { TOR_CONTROLLER_STATUS, TorControllerStatus, TorExternalConnectionOptions } from './types'; + +const WAITING_TIME = 1_000; +const MAX_TRIES_WAITING = 200; + +export class TorControllerExternal extends EventEmitter { + status: TorControllerStatus = TOR_CONTROLLER_STATUS.Stopped; + options: TorExternalConnectionOptions; + + constructor(options: TorExternalConnectionOptions) { + super(); + this.options = options; + } + + private getIsStopped() { + return this.status === TOR_CONTROLLER_STATUS.Stopped; + } + + private async getIsExternalTorRunning() { + let isSocks5ProxyPort = false; + try { + isSocks5ProxyPort = await checkSocks5Proxy(this.options.host, this.options.port); + } catch { + // Ignore errors. + } + + return isSocks5ProxyPort; + } + + private startBootstrap() { + this.status = TOR_CONTROLLER_STATUS.Bootstrapping; + } + + private successfullyBootstrapped() { + this.status = TOR_CONTROLLER_STATUS.ExternalTorRunning; + } + + public getTorConfiguration() { + return ''; + } + + public async waitUntilAlive() { + this.startBootstrap(); + + const abortController = new AbortController(); + const checkConnection: ScheduledAction = async signal => { + if (signal?.aborted) { + throw new Error('Operation aborted'); + } + const isRunning = await this.getIsExternalTorRunning(); + if (isRunning) { + this.successfullyBootstrapped(); + + return true; + } + + throw new Error('Tor external not alive'); + }; + + const params: ScheduleActionParams = { + attempts: MAX_TRIES_WAITING, + timeout: WAITING_TIME, + gap: WAITING_TIME, + signal: abortController.signal, + attemptFailureHandler: () => { + if (this.getIsStopped()) { + abortController.abort(); + + return new Error('Operation stopped.'); + } + + return undefined; + }, + }; + await scheduleAction(checkConnection, params); + } + + public async getStatus() { + const isExternalTorRunning = await this.getIsExternalTorRunning(); + if (isExternalTorRunning) { + return TOR_CONTROLLER_STATUS.ExternalTorRunning; + } + + return TOR_CONTROLLER_STATUS.Stopped; + } + + public closeActiveCircuits() { + // Do nothing. Not possible in External Tor without ControlPort. + } + + public stop() { + this.status = TOR_CONTROLLER_STATUS.Stopped; + } +} diff --git a/packages/request-manager/src/index.ts b/packages/request-manager/src/index.ts index aef9257d339..2e198e5fe49 100644 --- a/packages/request-manager/src/index.ts +++ b/packages/request-manager/src/index.ts @@ -1,4 +1,5 @@ export { TorController } from './controller'; +export { TorControllerExternal } from './controllerExternal'; export { createInterceptor } from './interceptor'; export type { InterceptedEvent, BootstrapEvent, TorControllerStatus } from './types'; export { TOR_CONTROLLER_STATUS } from './types'; diff --git a/packages/request-manager/src/types.ts b/packages/request-manager/src/types.ts index ac9fff30985..052a84251d8 100644 --- a/packages/request-manager/src/types.ts +++ b/packages/request-manager/src/types.ts @@ -3,7 +3,11 @@ export interface TorConnectionOptions { port: number; controlPort: number; torDataDir: string; - snowflakeBinaryPath: string; +} + +export interface TorExternalConnectionOptions { + host: string; + port: number; } export type TorCommandResponse = @@ -72,5 +76,6 @@ export const TOR_CONTROLLER_STATUS = { Bootstrapping: 'Bootstrapping', Stopped: 'Stopped', CircuitEstablished: 'CircuitEstablished', + ExternalTorRunning: 'ExternalTorRunning', } as const; export type TorControllerStatus = keyof typeof TOR_CONTROLLER_STATUS; diff --git a/packages/suite-desktop-api/src/factory.ts b/packages/suite-desktop-api/src/factory.ts index 17269e44fa1..baa14bfd23f 100644 --- a/packages/suite-desktop-api/src/factory.ts +++ b/packages/suite-desktop-api/src/factory.ts @@ -125,7 +125,7 @@ export const factory = >( getTorSettings: () => ipcRenderer.invoke('tor/get-settings'), changeTorSettings: payload => { - if (validation.isObject({ snowflakeBinaryPath: 'string' }, payload)) { + if (validation.isObject({ useExternalTor: 'boolean' }, payload)) { return ipcRenderer.invoke('tor/change-settings', payload); } diff --git a/packages/suite-desktop-api/src/messages.ts b/packages/suite-desktop-api/src/messages.ts index 6eeb54c9562..b8e5bc6cf2e 100644 --- a/packages/suite-desktop-api/src/messages.ts +++ b/packages/suite-desktop-api/src/messages.ts @@ -54,7 +54,7 @@ export type HandshakeTorModule = { }; export type TorSettings = { - snowflakeBinaryPath: string; + useExternalTor: boolean; }; export type TraySettings = { diff --git a/packages/suite-desktop-core/src/index.d.ts b/packages/suite-desktop-core/src/index.d.ts index 3c39c93cfb3..3ea4b0fb9a4 100644 --- a/packages/suite-desktop-core/src/index.d.ts +++ b/packages/suite-desktop-core/src/index.d.ts @@ -100,8 +100,10 @@ declare type UpdateSettings = { declare type TorSettings = { running: boolean; // Tor should be enabled host: string; // Hostname of the tor process through which traffic is routed - port: number; // Port of the tor process through which traffic is routed - snowflakeBinaryPath: string; // Path in user system to the snowflake binary + port: number; // Port of the Tor process through which traffic is routed + controlPort: number; // Port of the Tor Control Port + torDataDir: string; // Path of tor data directory + useExternalTor: boolean; // Tor should use external daemon instead of the one built-in suite. }; declare type BridgeSettings = { diff --git a/packages/suite-desktop-core/src/libs/processes/TorExternalProcess.ts b/packages/suite-desktop-core/src/libs/processes/TorExternalProcess.ts new file mode 100644 index 00000000000..a2e30fe459e --- /dev/null +++ b/packages/suite-desktop-core/src/libs/processes/TorExternalProcess.ts @@ -0,0 +1,42 @@ +import { TOR_CONTROLLER_STATUS, TorControllerExternal } from '@trezor/request-manager'; + +import { Status } from './BaseProcess'; + +export type TorProcessStatus = Status & { isBootstrapping?: boolean }; + +const DEFAULT_TOR_EXTERNAL_HOST = '127.0.0.1'; +const DEFAULT_TOR_EXTERNAL_PORT = 9050; + +export class TorExternalProcess { + isStopped = true; + torController: TorControllerExternal; + port = DEFAULT_TOR_EXTERNAL_PORT; + host = DEFAULT_TOR_EXTERNAL_HOST; + constructor() { + this.torController = new TorControllerExternal({ host: this.host, port: this.port }); + } + + public getPort() { + return this.port; + } + + public async status(): Promise { + const torControllerStatus = await this.torController.getStatus(); + + return { + service: torControllerStatus === TOR_CONTROLLER_STATUS.ExternalTorRunning, + process: torControllerStatus === TOR_CONTROLLER_STATUS.ExternalTorRunning, + isBootstrapping: false, // For Tor external we fake bootstrap process. + }; + } + + public async start(): Promise { + this.isStopped = false; + await this.torController.waitUntilAlive(); + } + + public stop() { + // We should not stop External Tor Process but ignore it. + this.isStopped = true; + } +} diff --git a/packages/suite-desktop-core/src/libs/processes/TorProcess.ts b/packages/suite-desktop-core/src/libs/processes/TorProcess.ts index 6ac1d5b5622..2a543a7c5f8 100644 --- a/packages/suite-desktop-core/src/libs/processes/TorProcess.ts +++ b/packages/suite-desktop-core/src/libs/processes/TorProcess.ts @@ -11,7 +11,6 @@ export class TorProcess extends BaseProcess { controlPort: number; torHost: string; torDataDir: string; - snowflakeBinaryPath: string; constructor(options: TorConnectionOptions) { super('tor', 'tor'); @@ -20,22 +19,20 @@ export class TorProcess extends BaseProcess { this.controlPort = options.controlPort; this.torHost = options.host; this.torDataDir = options.torDataDir; - this.snowflakeBinaryPath = ''; this.torController = new TorController({ host: this.torHost, port: this.port, controlPort: this.controlPort, torDataDir: this.torDataDir, - snowflakeBinaryPath: this.snowflakeBinaryPath, }); } - setTorConfig(torConfig: Pick) { - this.snowflakeBinaryPath = torConfig.snowflakeBinaryPath; + public getPort() { + return this.port; } - async status(): Promise { + public async status(): Promise { const torControllerStatus = await this.torController.getStatus(); return { @@ -45,12 +42,9 @@ export class TorProcess extends BaseProcess { }; } - async start(): Promise { + public async start(): Promise { const electronProcessId = process.pid; - const torConfiguration = await this.torController.getTorConfiguration( - electronProcessId, - this.snowflakeBinaryPath, - ); + const torConfiguration = this.torController.getTorConfiguration(electronProcessId); await super.start(torConfiguration); diff --git a/packages/suite-desktop-core/src/libs/store.ts b/packages/suite-desktop-core/src/libs/store.ts index f8a5cd9e02d..42cd31a25af 100644 --- a/packages/suite-desktop-core/src/libs/store.ts +++ b/packages/suite-desktop-core/src/libs/store.ts @@ -63,8 +63,10 @@ export class Store { return this.store.get('torSettings', { running: false, port: 9050, + controlPort: 9051, host: '127.0.0.1', - snowflakeBinaryPath: '', + useExternalTor: false, + torDataDir: '', }); } diff --git a/packages/suite-desktop-core/src/modules/tor.ts b/packages/suite-desktop-core/src/modules/tor.ts index 5c3ca1b5c3c..3d2af751064 100644 --- a/packages/suite-desktop-core/src/modules/tor.ts +++ b/packages/suite-desktop-core/src/modules/tor.ts @@ -12,28 +12,47 @@ import { getFreePort } from '@trezor/node-utils'; import { validateIpcMessage } from '@trezor/ipc-proxy'; import { TorProcess, TorProcessStatus } from '../libs/processes/TorProcess'; +import { TorExternalProcess } from '../libs/processes/TorExternalProcess'; import { app, ipcMain } from '../typed-electron'; import type { Dependencies } from './index'; const load = async ({ mainWindowProxy, store, mainThreadEmitter }: Dependencies) => { const { logger } = global; - const host = '127.0.0.1'; - const port = await getFreePort(); - const controlPort = await getFreePort(); - const torDataDir = path.join(app.getPath('userData'), 'tor'); const initialSettings = store.getTorSettings(); - store.setTorSettings({ ...initialSettings, host, port }); + store.setTorSettings({ + ...initialSettings, + port: await getFreePort(), + controlPort: await getFreePort(), + torDataDir: path.join(app.getPath('userData'), 'tor'), + }); + + const settings = store.getTorSettings(); - const tor = new TorProcess({ - host, - port, - controlPort, - torDataDir, - snowflakeBinaryPath: initialSettings.snowflakeBinaryPath, + const bundledTorProcess = new TorProcess({ + host: settings.host, + port: settings.port, + controlPort: settings.controlPort, + torDataDir: settings.torDataDir, }); + const externalTorProcess = new TorExternalProcess(); + + const getTarget = () => { + const { useExternalTor } = store.getTorSettings(); + + if (useExternalTor) { + return externalTorProcess; + } + + return bundledTorProcess; + }; + + const updateTorPort = (port: number) => { + store.setTorSettings({ ...store.getTorSettings(), port }); + }; + const setProxy = (rule: string) => { logger.info('tor', `Setting proxy rules to "${rule}"`); // Including network session of electron auto-updater in the Tor proxy. @@ -44,17 +63,26 @@ const load = async ({ mainWindowProxy, store, mainThreadEmitter }: Dependencies) }); }; - const getProxySettings = (shouldEnableTor: boolean) => - shouldEnableTor ? { proxy: `socks://${host}:${port}` } : { proxy: '' }; + const getProxySettings = (shouldEnableTor: boolean) => { + const { useExternalTor, port, host } = store.getTorSettings(); + return shouldEnableTor + ? { + proxy: `socks://${host}:${useExternalTor ? 9050 : port}`, + } + : { proxy: '' }; + }; const handleTorProcessStatus = (status: TorProcessStatus) => { + const { useExternalTor, running } = store.getTorSettings(); let type: TorStatus; if (!status.process) { type = TorStatus.Disabled; } else if (status.isBootstrapping) { type = TorStatus.Enabling; - } else if (status.service) { + } else if (status.service && !useExternalTor) { + type = TorStatus.Enabled; + } else if (useExternalTor && running) { type = TorStatus.Enabled; } else { type = TorStatus.Disabled; @@ -90,20 +118,51 @@ const load = async ({ mainWindowProxy, store, mainThreadEmitter }: Dependencies) } }; + const createFakeBootstrapProcess = () => { + let progress = 0; + const duration = 3_000; + // update progress every 300ms. + const interval = 300; + + const increment = (100 / duration) * interval; + const intervalId = setInterval(() => { + progress += increment; + if (progress >= 100) { + progress = 100; + clearInterval(intervalId); + } + handleBootstrapEvent({ + type: 'progress', + progress: `${progress}`, + summary: 'Using External Tor fake progress', + }); + }, interval); + }; + const setupTor = async (shouldEnableTor: boolean) => { - const isTorRunning = (await tor.status()).process; - const { snowflakeBinaryPath } = store.getTorSettings(); + const { useExternalTor } = store.getTorSettings(); + + const isTorRunning = (await getTarget().status()).process; - if (shouldEnableTor === isTorRunning) { + if (shouldEnableTor === isTorRunning && !useExternalTor) { return; } if (shouldEnableTor === true) { - setProxy(`socks5://${host}:${port}`); - tor.torController.on('bootstrap/event', handleBootstrapEvent); + const { host } = store.getTorSettings(); + const port = getTarget().getPort(); + const proxyRule = `socks5://${host}:${port}`; + setProxy(proxyRule); + getTarget().torController.on('bootstrap/event', handleBootstrapEvent); + try { - tor.setTorConfig({ snowflakeBinaryPath }); - await tor.start(); + updateTorPort(port); + if (useExternalTor) { + await getTarget().start(); + createFakeBootstrapProcess(); + } else { + await getTarget().start(); + } } catch (error) { mainWindowProxy.getInstance()?.webContents.send('tor/bootstrap', { type: 'error', @@ -111,18 +170,19 @@ const load = async ({ mainWindowProxy, store, mainThreadEmitter }: Dependencies) }); // When there is error does not mean that the process is stop, // so we make sure to stop it so we are able to restart it. - tor.stop(); + getTarget().stop(); + throw error; } finally { - tor.torController.removeAllListeners(); + getTarget().torController.removeAllListeners(); } } else { mainWindowProxy.getInstance()?.webContents.send('tor/status', { type: TorStatus.Disabling, }); setProxy(''); - tor.torController.stop(); - await tor.stop(); + getTarget().torController.stop(); + await getTarget().stop(); } store.setTorSettings({ ...store.getTorSettings(), running: shouldEnableTor }); @@ -130,15 +190,13 @@ const load = async ({ mainWindowProxy, store, mainThreadEmitter }: Dependencies) ipcMain.handle( 'tor/change-settings', - (ipcEvent, { snowflakeBinaryPath }: { snowflakeBinaryPath: string }) => { + (ipcEvent, { useExternalTor }: { useExternalTor: boolean }) => { validateIpcMessage(ipcEvent); try { store.setTorSettings({ - running: store.getTorSettings().running, - host, - port, - snowflakeBinaryPath, + ...store.getTorSettings(), + useExternalTor, }); return { success: true }; @@ -175,6 +233,7 @@ const load = async ({ mainWindowProxy, store, mainThreadEmitter }: Dependencies) // correctly set in module trezor-connect-ipc. const proxySettings = getProxySettings(shouldEnableTor); + // Proxy is also set in packages/suite-desktop-core/src/modules/trezor-connect.ts await TrezorConnect.setProxy(proxySettings); logger.info( @@ -201,7 +260,8 @@ const load = async ({ mainWindowProxy, store, mainThreadEmitter }: Dependencies) } // Once Tor is toggled it renderer should know the new status. - const status = await tor.status(); + const status = await getTarget().status(); + handleTorProcessStatus(status); return { success: true }; @@ -211,11 +271,16 @@ const load = async ({ mainWindowProxy, store, mainThreadEmitter }: Dependencies) let lastCircuitResetTime = 0; const socksTimeout = 30000; // this value reflects --SocksTimeout flag set by TorController config mainThreadEmitter.on('module/reset-tor-circuits', event => { + if (store.getTorSettings().useExternalTor) { + logger.debug('tor', `Ignore circuit reset. Running External Tor without Control Port.`); + + return; + } const lastResetDiff = Date.now() - lastCircuitResetTime; if (lastResetDiff > socksTimeout) { logger.debug('tor', `Close active circuits. Triggered by identity ${event.identity}`); lastCircuitResetTime = Date.now(); - tor.torController.closeActiveCircuits(); + getTarget().torController.closeActiveCircuits(); } else { logger.debug( 'tor', @@ -225,8 +290,9 @@ const load = async ({ mainWindowProxy, store, mainThreadEmitter }: Dependencies) }); ipcMain.on('tor/get-status', async () => { - logger.debug('tor', `Getting status (${store.getTorSettings().running ? 'ON' : 'OFF'})`); - const status = await tor.status(); + const { running } = store.getTorSettings(); + logger.debug('tor', `Getting status (${running ? 'ON' : 'OFF'})`); + const status = await getTarget().status(); handleTorProcessStatus(status); }); @@ -235,7 +301,7 @@ const load = async ({ mainWindowProxy, store, mainThreadEmitter }: Dependencies) store.setTorSettings({ ...store.getTorSettings(), running: true }); } - return tor; + return getTarget; }; type TorModule = (dependencies: Dependencies) => { @@ -245,24 +311,24 @@ type TorModule = (dependencies: Dependencies) => { export const init: TorModule = dependencies => { let loaded = false; - let tor: TorProcess | undefined; + let getTarget: any; const onLoad = async () => { if (loaded) return { shouldRunTor: false }; loaded = true; - tor = await load(dependencies); - const torSettings = dependencies.store.getTorSettings(); + getTarget = await load(dependencies); + const { running } = dependencies.store.getTorSettings(); return { - shouldRunTor: torSettings.running, + shouldRunTor: running, }; }; const onQuit = async () => { const { logger } = global; logger.info('tor', 'Stopping (app quit)'); - await tor?.stop(); + await getTarget()?.stop(); }; return { onLoad, onQuit }; diff --git a/packages/suite-desktop-core/src/modules/trezor-connect.ts b/packages/suite-desktop-core/src/modules/trezor-connect.ts index eb8c1d375cf..393e13896dc 100644 --- a/packages/suite-desktop-core/src/modules/trezor-connect.ts +++ b/packages/suite-desktop-core/src/modules/trezor-connect.ts @@ -11,11 +11,10 @@ export const initBackground: ModuleInitBackground = ({ mainThreadEmitter, store const { logger } = global; logger.info(SERVICE_NAME, `Starting service`); - const setProxy = (ifRunning = false) => { - const tor = store.getTorSettings(); - if (ifRunning && !tor.running) return Promise.resolve(); - const payload = tor.running ? { proxy: `socks://${tor.host}:${tor.port}` } : { proxy: '' }; - logger.info(SERVICE_NAME, `${tor.running ? 'Enable' : 'Disable'} proxy ${payload.proxy}`); + const setProxy = () => { + const { running, host, port } = store.getTorSettings(); + const payload = running ? { proxy: `socks://${host}:${port}` } : { proxy: '' }; + logger.info(SERVICE_NAME, `${running ? 'Enable' : 'Disable'} proxy ${payload.proxy}`); return TrezorConnect.setProxy(payload); }; @@ -26,7 +25,7 @@ export const initBackground: ModuleInitBackground = ({ mainThreadEmitter, store logger.debug(SERVICE_NAME, `call ${method}`); if (method === 'init') { const response = await TrezorConnect[method](...params); - await setProxy(true); + await setProxy(); return response; } diff --git a/packages/suite/src/constants/suite/experimental.ts b/packages/suite/src/constants/suite/experimental.ts index 45e6721e889..adbd887b33d 100644 --- a/packages/suite/src/constants/suite/experimental.ts +++ b/packages/suite/src/constants/suite/experimental.ts @@ -1,11 +1,12 @@ import { TranslationKey } from '@suite-common/intl-types'; import { desktopApi } from '@trezor/suite-desktop-api'; -import { EXPERIMENTAL_PASSWORD_MANAGER_KB_URL, TOR_SNOWFLAKE_KB_URL, Url } from '@trezor/urls'; +import { EXPERIMENTAL_PASSWORD_MANAGER_KB_URL, Url } from '@trezor/urls'; import { Route } from '@suite-common/suite-types'; +import { isDesktop } from '@trezor/env-utils'; import { Dispatch } from '../../types/suite'; -export type ExperimentalFeature = 'password-manager' | 'tor-snowflake'; +export type ExperimentalFeature = 'password-manager' | 'tor-external'; export type ExperimentalFeatureConfig = { title: TranslationKey; @@ -23,19 +24,19 @@ export const EXPERIMENTAL_FEATURES: Record !isDesktop(), onToggle: async ({ newValue }) => { - if (!newValue) { - const result = await desktopApi.getTorSettings(); - if (result.success && result.payload.snowflakeBinaryPath !== '') { - await desktopApi.changeTorSettings({ - ...result.payload, - snowflakeBinaryPath: '', - }); - } + const result = await desktopApi.getTorSettings(); + if (result.success && result.payload.useExternalTor !== newValue) { + await desktopApi.changeTorSettings({ + ...result.payload, + useExternalTor: newValue, + }); } }, }, diff --git a/packages/suite/src/support/messages.ts b/packages/suite/src/support/messages.ts index 388e72c5b3b..d92d06b0df7 100644 --- a/packages/suite/src/support/messages.ts +++ b/packages/suite/src/support/messages.ts @@ -4052,27 +4052,6 @@ export default defineMessages({ id: 'TR_ONION_LINKS_TITLE', defaultMessage: 'Open trezor.io links as .onion links', }, - TR_TOR_CONFIG_SNOWFLAKE_TITLE: { - id: 'TR_TOR_CONFIG_SNOWFLAKE_TITLE', - defaultMessage: 'Tor Snowflake Binary Path', - }, - TR_TOR_CONFIG_SNOWFLAKE_DESCRIPTION: { - id: 'TR_TOR_CONFIG_SNOWFLAKE_DESCRIPTION', - defaultMessage: - 'Enter the path to the Tor Snowflake binary on your system. Make sure Tor is disabled before making this change.', - }, - TR_TOR_CONFIG_SNOWFLAKE_ERROR_PATH: { - id: 'TR_TOR_CONFIG_SNOWFLAKE_ERROR_PATH', - defaultMessage: 'Must be a valid full path.', - }, - TR_TOR_CONFIG_SNOWFLAKE_UPDATE_LABEL: { - id: 'TR_TOR_CONFIG_SNOWFLAKE_UPDATE_LABEL', - defaultMessage: 'Update path', - }, - TR_TOR_CONFIG_SNOWFLAKE_DISABLE_LABEL: { - id: 'TR_TOR_CONFIG_SNOWFLAKE_DISABLE_LABEL', - defaultMessage: 'Disable Tor Snowflake', - }, TR_TOR_ENABLE_TITLE: { id: 'TR_TOR_ENABLE_TITLE', defaultMessage: 'Enable Tor', @@ -5001,14 +4980,14 @@ export default defineMessages({ defaultMessage: 'Use this utility to retrieve passwords stored on Dropbox and secured by Trezor. Designed for former users of the Trezor Password Manager Chrome extension.', }, - TR_EXPERIMENTAL_TOR_SNOWFLAKE: { - id: 'TR_EXPERIMENTAL_TOR_SNOWFLAKE', - defaultMessage: 'Tor Snowflake', + TR_EXPERIMENTAL_TOR_EXTERNAL: { + id: 'TR_EXPERIMENTAL_TOR_EXTERNAL', + defaultMessage: 'Tor external', }, - TR_EXPERIMENTAL_TOR_SNOWFLAKE_DESCRIPTION: { - id: 'TR_EXPERIMENTAL_TOR_SNOWFLAKE_DESCRIPTION', + TR_EXPERIMENTAL_TOR_EXTERNAL_DESCRIPTION: { + id: 'TR_EXPERIMENTAL_TOR_EXTERNAL_DESCRIPTION', defaultMessage: - 'Access censored websites and apps using Tor Snowflake, a system designed to bypass restrictions.', + 'Allows you to use Tor daemon running in a external process on port 9050 instead of the one bundled with Trezor Suite.', }, TR_EARLY_ACCESS: { id: 'TR_EARLY_ACCESS', diff --git a/packages/suite/src/types/suite/index.ts b/packages/suite/src/types/suite/index.ts index b8a8cc4e233..d5739278aec 100644 --- a/packages/suite/src/types/suite/index.ts +++ b/packages/suite/src/types/suite/index.ts @@ -117,11 +117,6 @@ export interface TorBootstrap { isSlow?: boolean; } -export type TorConfig = { - enableSnowflake: boolean; - snowflakeBinaryPath: string; -}; - export enum DisplayMode { CHUNKS = 1, PAGINATED_TEXT, diff --git a/packages/suite/src/views/settings/SettingsGeneral/SettingsGeneral.tsx b/packages/suite/src/views/settings/SettingsGeneral/SettingsGeneral.tsx index d13a60e79ed..2972d683f3f 100644 --- a/packages/suite/src/views/settings/SettingsGeneral/SettingsGeneral.tsx +++ b/packages/suite/src/views/settings/SettingsGeneral/SettingsGeneral.tsx @@ -5,7 +5,6 @@ import { SettingsLayout, SettingsSection } from 'src/components/settings'; import { Translation } from 'src/components/suite'; import { useLayoutSize, useSelector } from 'src/hooks/suite'; import { - selectHasExperimentalFeature, selectIsSettingsDesktopAppPromoBannerShown, selectTorState, } from 'src/reducers/suite/suiteReducer'; @@ -30,7 +29,6 @@ import { DesktopSuiteBanner } from './DesktopSuiteBanner'; import { AddressDisplay } from './AddressDisplay'; import { EnableViewOnly } from './EnableViewOnly'; import { Experimental } from './Experimental'; -import { TorSnowflake } from './TorSnowflake'; import { AutomaticUpdate } from './AutomaticUpdate'; import { AutoStart } from './AutoStart'; import { ShowOnTray } from './ShowOnTray'; @@ -45,9 +43,6 @@ export const SettingsGeneral = () => { const desktopUpdate = useSelector(state => state.desktopUpdate); const metadata = useSelector(state => state.metadata); const { isMobileLayout } = useLayoutSize(); - const torSnowflakeExperimentalFeature = useSelector( - selectHasExperimentalFeature('tor-snowflake'), - ); const hasBitcoinNetworks = enabledNetworks.some(symbol => { const networkFeatures = getNetwork(symbol).features; @@ -85,7 +80,6 @@ export const SettingsGeneral = () => { } icon="torBrowser"> {isDesktop() && } {isTorEnabled && } - {isDesktop() && torSnowflakeExperimentalFeature && } )} diff --git a/packages/suite/src/views/settings/SettingsGeneral/TorSnowflake.tsx b/packages/suite/src/views/settings/SettingsGeneral/TorSnowflake.tsx deleted file mode 100644 index acb15cff5dd..00000000000 --- a/packages/suite/src/views/settings/SettingsGeneral/TorSnowflake.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { ChangeEventHandler, useEffect, useState } from 'react'; - -import styled from 'styled-components'; - -import { TorSettings } from '@trezor/suite-desktop-api/src/messages'; -import { TOR_SNOWFLAKE_KB_URL } from '@trezor/urls'; -import { breakpointMediaQueries } from '@trezor/styles'; -import { desktopApi } from '@trezor/suite-desktop-api'; -import { Button, Input } from '@trezor/components'; -import { isFullPath } from '@trezor/utils'; -import { spacingsPx } from '@trezor/theme'; - -import { selectTorState } from 'src/reducers/suite/suiteReducer'; -import { useSelector, useTranslation } from 'src/hooks/suite'; -import { ActionColumn, SectionItem, TextColumn, Translation } from 'src/components/suite'; - -const Container = styled.div` - display: flex; - flex-direction: column; - align-items: center; - gap: ${spacingsPx.sm}; - min-width: 200px; - - ${breakpointMediaQueries.below_sm} { - min-width: 100%; - } -`; - -export const TorSnowflake = () => { - const { isTorEnabled } = useSelector(selectTorState); - const [torSettings, setTorSettings] = useState(null); - const [hasPathChanged, setHasPathChanged] = useState(false); - const [error, setError] = useState(null); - const { translationString } = useTranslation(); - - useEffect(() => { - const fetchTorSettings = async () => { - const result = await desktopApi.getTorSettings(); - if (result.success) { - setTorSettings(result.payload); - } else { - setError(result.error); - } - }; - - fetchTorSettings(); - - const handleTorSettingsChange = (settings: TorSettings) => setTorSettings(settings); - desktopApi.on('tor/settings', handleTorSettingsChange); - - return () => { - desktopApi.removeAllListeners('tor/settings'); - }; - }, []); - - const handleChange: ChangeEventHandler = ({ target: { value } }) => { - if (!torSettings) return; - - setHasPathChanged(true); - if (!isFullPath(value) && value !== '') { - setError(translationString('TR_TOR_CONFIG_SNOWFLAKE_ERROR_PATH')); - } else { - setError(null); - } - setTorSettings(prevSettings => ({ - ...prevSettings!, - snowflakeBinaryPath: value, - })); - }; - - const handleClick = async () => { - if (!torSettings || error) return; - - await desktopApi.changeTorSettings({ - ...torSettings, - snowflakeBinaryPath: torSettings.snowflakeBinaryPath, - }); - setHasPathChanged(false); - }; - - const isUpdateDisabled = - !torSettings || - !!error || - (!isFullPath(torSettings.snowflakeBinaryPath) && torSettings.snowflakeBinaryPath !== '') || - isTorEnabled || - !hasPathChanged; - - if (!torSettings) return null; - - const buttonTranslationId = - torSettings.snowflakeBinaryPath === '' && hasPathChanged - ? 'TR_TOR_CONFIG_SNOWFLAKE_DISABLE_LABEL' - : 'TR_TOR_CONFIG_SNOWFLAKE_UPDATE_LABEL'; - - return ( - - } - description={} - buttonLink={TOR_SNOWFLAKE_KB_URL} - /> - - - - - - - - ); -}; diff --git a/packages/urls/src/urls.ts b/packages/urls/src/urls.ts index 5aad9f142a2..ecf0998b97f 100644 --- a/packages/urls/src/urls.ts +++ b/packages/urls/src/urls.ts @@ -128,8 +128,6 @@ export const CHROME_UPDATE_URL: Url = 'https://support.google.com/chrome/answer/ export const CHROME_ANDROID_URL: Url = 'https://play.google.com/store/apps/details?id=com.android.chrome'; export const TOR_PROJECT_URL: Url = 'https://www.torproject.org/'; -export const TOR_SNOWFLAKE_PROJECT_URL: Url = 'https://snowflake.torproject.org/'; -export const TOR_SNOWFLAKE_KB_URL: Url = 'https://trezor.io/learn/a/tor-snowflake-in-trezor-suite'; export const EXPERIMENTAL_FEATURES_KB_URL: Url = 'https://trezor.io/learn/a/experimental-features-in-trezor-suite'; export const EXPERIMENTAL_PASSWORD_MANAGER_KB_URL: Url =