diff --git a/packages/backend/package.json b/packages/backend/package.json index d95da929ca..55cf4ec45c 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -10,8 +10,9 @@ "private": true, "scripts": { "build": "tsc -p tsconfig.build.json", - "webpack": "webpack --env mode=development && cp ./lib/bundle.cjs ../backend-bundle/bundle.cjs", - "webpack:prod": "webpack --env mode=production && cp ./lib/bundle.cjs ../backend-bundle/bundle.cjs", + "webpack": "webpack --env mode=development && npm run sync-bundle-consumers", + "webpack:prod": "webpack --env mode=production && npm run sync-bundle-consumers", + "sync-bundle-consumers": "node ./scripts/sync-bundle-consumers.cjs", "postinstall": "npm run applyPatches", "applyPatches": "npm run applyPatches:it && npm run applyPatches:libp2p", "applyPatches:libp2p": "patch -f -p0 < ./patch/libp2p/mplex.patch || true", diff --git a/packages/backend/scripts/sync-bundle-consumers.cjs b/packages/backend/scripts/sync-bundle-consumers.cjs new file mode 100644 index 0000000000..fdd2b7a1d3 --- /dev/null +++ b/packages/backend/scripts/sync-bundle-consumers.cjs @@ -0,0 +1,13 @@ +const { copyFileSync, mkdirSync } = require('node:fs') +const { dirname, resolve } = require('node:path') + +const bundle = resolve(__dirname, '../lib/bundle.cjs') +const consumers = [ + resolve(__dirname, '../../backend-bundle/bundle.cjs'), + resolve(__dirname, '../../mobile/nodejs-assets/nodejs-project/bundle.cjs'), +] + +for (const consumer of consumers) { + mkdirSync(dirname(consumer), { recursive: true }) + copyFileSync(bundle, consumer) +} diff --git a/packages/backend/src/backendManager.ts b/packages/backend/src/backendManager.ts index a5de3cf7db..fe5b6eb69d 100644 --- a/packages/backend/src/backendManager.ts +++ b/packages/backend/src/backendManager.ts @@ -15,10 +15,19 @@ import { HttpsProxyAgent } from 'https-proxy-agent' import { randomBytes } from 'crypto' import { sleep } from './nest/common/sleep' import { type BackendLeaveCommunityMessage } from '@quiet/types' +import { MobileLifecycleCoordinator } from './mobile-lifecycle-coordinator' // Shutdown helper constants const SHUTDOWN_TIMEOUT = 60_000 // 1 minute +const parseMobilePort = (value: unknown, name: string): number => { + const port = Number(value) + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new Error(`Invalid mobile ${name}`) + } + return port +} + const logger = createLogger('backendManager') logger.info('Launching backend manager') @@ -232,13 +241,21 @@ export const runBackendMobile = async (rn_bridge: any, secret: string) => { process.env['BACKEND'] = 'mobile' process.env['CONNECTION_TIME'] = (new Date().getTime() / 1000).toString() + const socketIOPort = parseMobilePort(options.dataPort, 'data port') + const httpTunnelPort = options.httpTunnelPort + ? parseMobilePort(options.httpTunnelPort, 'HTTP tunnel port') + : undefined + const torControlPort = options.controlPort + ? parseMobilePort(options.controlPort, 'Tor control port') + : await getPort() + const app: INestApplicationContext = await NestFactory.createApplicationContext( AppModule.forOptions({ - socketIOPort: options.dataPort, + socketIOPort, socketIOSecret: secret, - httpTunnelPort: options.httpTunnelPort ? options.httpTunnelPort : null, + httpTunnelPort, torAuthCookie: options.authCookie ? options.authCookie : null, - torControlPort: options.controlPort ? options.controlPort : await getPort(), + torControlPort, torBinaryPath: options.torBinary ? options.torBinary : null, options: { env: { @@ -249,11 +266,29 @@ export const runBackendMobile = async (rn_bridge: any, secret: string) => { }), { logger: ['warn', 'error', 'log', 'debug', 'verbose'] } ) - let proxyAgent: HttpsProxyAgent | undefined + const connectionsManager = app.get(ConnectionsManagerService) + const tor = app.get(Tor) + const proxyAgent = app.get>(SOCKS_PROXY_AGENT) + const mobileLifecycle = new MobileLifecycleCoordinator({ + pause: async () => connectionsManager.pause(), + activate: async (msg: OpenServices) => { + const torControlPort = parseMobilePort(msg.torControlPort, 'Tor control port') + const httpTunnelPort = parseMobilePort(msg.httpTunnelPort, 'HTTP tunnel port') + tor.rewireNativeTor({ + controlPort: torControlPort, + httpTunnelPort, + authCookie: msg.authCookie, + }) + proxyAgent.connectOpts.port = httpTunnelPort + proxyAgent.proxy.port = httpTunnelPort.toString() + await connectionsManager.resume() + }, + }) let shutdownRequestedFromBridge = false rn_bridge.channel.on('close', () => { - const connectionsManager = app.get(ConnectionsManagerService) - connectionsManager.pause() + void mobileLifecycle.pause().catch(error => { + logger.error('Failed to pause mobile services', error) + }) }) rn_bridge.channel.on('hibernate', async () => { logger.info('Received hibernate message from RN bridge') @@ -276,19 +311,9 @@ export const runBackendMobile = async (rn_bridge: any, secret: string) => { } }) rn_bridge.channel.on('open', (msg: OpenServices) => { - const connectionsManager = app.get(ConnectionsManagerService) - const tor = app.get(Tor) - proxyAgent = app.get>(SOCKS_PROXY_AGENT) - const torControlPort = Number(msg.torControlPort) - const httpTunnelPort = Number(msg.httpTunnelPort) - tor.rewireNativeTor({ - controlPort: torControlPort, - httpTunnelPort, - authCookie: msg.authCookie, + void mobileLifecycle.activate(msg).catch(error => { + logger.error('Failed to activate mobile services', error) }) - proxyAgent.connectOpts.port = httpTunnelPort - proxyAgent.proxy.port = httpTunnelPort.toString() - connectionsManager.resume() }) const shutdown = setupGracefulShutdown(app, () => app.get(ConnectionsManagerService)) rn_bridge.channel.on('shutdown', async () => { diff --git a/packages/backend/src/mobile-lifecycle-coordinator.spec.ts b/packages/backend/src/mobile-lifecycle-coordinator.spec.ts new file mode 100644 index 0000000000..624dfa98cc --- /dev/null +++ b/packages/backend/src/mobile-lifecycle-coordinator.spec.ts @@ -0,0 +1,117 @@ +import { jest } from '@jest/globals' + +import { MobileLifecycleCoordinator } from './mobile-lifecycle-coordinator' +import type { OpenServices } from './options' + +const deferred = () => { + let resolve!: (value: T | PromiseLike) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve + reject = promiseReject + }) + return { promise, reject, resolve } +} + +const services = (authCookie: string): OpenServices => ({ + authCookie, + httpTunnelPort: 12_345, + torControlPort: 23_456, +}) + +describe('MobileLifecycleCoordinator', () => { + it('serializes active behind an in-flight pause', async () => { + const pauseDeferred = deferred() + const pause = jest.fn(() => pauseDeferred.promise) + const activate = jest.fn(async (_services: OpenServices) => undefined) + const coordinator = new MobileLifecycleCoordinator({ activate, pause }) + + const pauseRequest = coordinator.pause() + const activeRequest = coordinator.activate(services('cookie-a')) + + expect(pause).toHaveBeenCalledTimes(1) + expect(activate).not.toHaveBeenCalled() + + pauseDeferred.resolve() + await Promise.all([pauseRequest, activeRequest]) + + expect(activate).toHaveBeenCalledTimes(1) + expect(activate).toHaveBeenCalledWith(services('cookie-a')) + }) + + it('coalesces pause-active-pause to the final paused intent', async () => { + const pauseDeferred = deferred() + const pause = jest.fn(() => pauseDeferred.promise) + const activate = jest.fn(async (_services: OpenServices) => undefined) + const coordinator = new MobileLifecycleCoordinator({ activate, pause }) + + const firstPause = coordinator.pause() + const active = coordinator.activate(services('cookie-a')) + const finalPause = coordinator.pause() + + pauseDeferred.resolve() + await Promise.all([firstPause, active, finalPause]) + + expect(pause).toHaveBeenCalledTimes(1) + expect(activate).not.toHaveBeenCalled() + }) + + it('applies the newest active payload and skips an obsolete pause', async () => { + const firstActiveDeferred = deferred() + const pause = jest.fn(async () => undefined) + const activate = jest.fn((payload: OpenServices) => { + if (payload.authCookie === 'cookie-a') { + return firstActiveDeferred.promise + } + return Promise.resolve() + }) + const coordinator = new MobileLifecycleCoordinator({ activate, pause }) + + const firstActive = coordinator.activate(services('cookie-a')) + const pauseRequest = coordinator.pause() + const latestActive = coordinator.activate(services('cookie-b')) + + expect(activate).toHaveBeenCalledTimes(1) + firstActiveDeferred.resolve() + await Promise.all([firstActive, pauseRequest, latestActive]) + + expect(pause).not.toHaveBeenCalled() + expect(activate).toHaveBeenCalledTimes(2) + expect(activate).toHaveBeenNthCalledWith(1, services('cookie-a')) + expect(activate).toHaveBeenNthCalledWith(2, services('cookie-b')) + }) + + it('applies a queued newer intent before reporting an earlier failure', async () => { + const error = new Error('pause failed') + const pauseDeferred = deferred() + const pause = jest.fn(() => pauseDeferred.promise) + const activate = jest.fn(async (_services: OpenServices) => undefined) + const coordinator = new MobileLifecycleCoordinator({ activate, pause }) + + const pauseRequest = coordinator.pause() + const activeRequest = coordinator.activate(services('cookie-b')) + const requestsSettled = Promise.allSettled([pauseRequest, activeRequest]) + + pauseDeferred.reject(error) + const results = await requestsSettled + + expect(activate).toHaveBeenCalledTimes(1) + expect(activate).toHaveBeenCalledWith(services('cookie-b')) + expect(results).toEqual([ + { reason: error, status: 'rejected' }, + { reason: error, status: 'rejected' }, + ]) + }) + + it('recovers after a rejected transition', async () => { + const error = new Error('pause failed') + const pause = jest.fn<() => Promise>().mockRejectedValueOnce(error).mockResolvedValueOnce(undefined) + const activate = jest.fn(async (_services: OpenServices) => undefined) + const coordinator = new MobileLifecycleCoordinator({ activate, pause }) + + await expect(coordinator.pause()).rejects.toThrow(error) + await coordinator.pause() + + expect(pause).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/backend/src/mobile-lifecycle-coordinator.ts b/packages/backend/src/mobile-lifecycle-coordinator.ts new file mode 100644 index 0000000000..2c3402741f --- /dev/null +++ b/packages/backend/src/mobile-lifecycle-coordinator.ts @@ -0,0 +1,94 @@ +import type { OpenServices } from './options' +import { + MobileLifecycleIntentType, + type MobileLifecycleHandlers, + type MobileLifecycleIntent, +} from './mobile-lifecycle-coordinator.types' + +/** + * Serializes mobile close/open events while coalescing superseded intents. + * Active intents retain their payload because a new payload may describe a + * replacement native Tor session even when the app is already foregrounded. + */ +export class MobileLifecycleCoordinator { + private desiredIntent: MobileLifecycleIntent | undefined + private transitionInFlight: Promise | undefined + + constructor(private readonly handlers: MobileLifecycleHandlers) {} + + public pause(): Promise { + return this.request({ type: MobileLifecycleIntentType.PAUSED }) + } + + public activate(services: OpenServices): Promise { + return this.request({ type: MobileLifecycleIntentType.ACTIVE, services: { ...services } }) + } + + private request(intent: MobileLifecycleIntent): Promise { + this.desiredIntent = intent + + if (!this.transitionInFlight) { + const transition = this.drain() + this.transitionInFlight = transition + void transition.then( + () => this.clearTransition(transition), + () => this.clearTransition(transition) + ) + } + + return this.transitionInFlight + } + + private clearTransition(transition: Promise): void { + if (this.transitionInFlight === transition) { + this.transitionInFlight = undefined + } + } + + private async drain(): Promise { + let transitionError: unknown + let transitionFailed = false + + while (this.desiredIntent) { + const intent = this.desiredIntent + + try { + if (intent.type === MobileLifecycleIntentType.PAUSED) { + await this.handlers.pause() + } else { + await this.handlers.activate(intent.services) + } + } catch (error) { + if (!transitionFailed) { + transitionError = error + transitionFailed = true + } + + // A newer request must still be applied even when the transition it + // superseded failed. The shared request promise reports the failure + // after the coordinator reaches the latest requested state. + if (this.desiredIntent !== intent) { + continue + } + throw transitionError + } + + const latestIntent = this.desiredIntent + if (latestIntent === intent) { + if (transitionFailed) { + throw transitionError + } + return + } + + // Repeated close requests require no additional work. Active requests + // are always replayed so the newest Tor control payload is applied. + if (intent.type === MobileLifecycleIntentType.PAUSED && latestIntent.type === MobileLifecycleIntentType.PAUSED) { + if (transitionFailed) { + throw transitionError + } + return + } + } + } +} diff --git a/packages/backend/src/mobile-lifecycle-coordinator.types.ts b/packages/backend/src/mobile-lifecycle-coordinator.types.ts new file mode 100644 index 0000000000..25369b5b6a --- /dev/null +++ b/packages/backend/src/mobile-lifecycle-coordinator.types.ts @@ -0,0 +1,18 @@ +import type { OpenServices } from './options' + +export enum MobileLifecycleIntentType { + PAUSED = 'paused', + ACTIVE = 'active', +} + +export type MobileLifecycleIntent = + | { type: MobileLifecycleIntentType.PAUSED } + | { + type: MobileLifecycleIntentType.ACTIVE + services: OpenServices + } + +export interface MobileLifecycleHandlers { + pause: () => Promise + activate: (services: OpenServices) => Promise +} diff --git a/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts b/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts index 62556ece79..da2427923a 100644 --- a/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts +++ b/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts @@ -188,7 +188,7 @@ describe('ConnectionsManagerService', () => { it('pauses and resumes qss alongside the mobile lifecycle', async () => { const closeSocketSpy = jest.spyOn(connectionsManagerService, 'closeSocket').mockResolvedValue() - const openSocketSpy = jest.spyOn(connectionsManagerService, 'openSocket').mockResolvedValue() + const listenSpy = jest.spyOn(connectionsManagerService['socketService'], 'listen').mockResolvedValue() const libp2pPauseSpy = jest.spyOn(connectionsManagerService.libp2pService, 'pause').mockResolvedValue(true) const libp2pResumeSpy = jest.spyOn(connectionsManagerService.libp2pService, 'resume').mockResolvedValue(true) const qssPauseSpy = jest.spyOn(qssService, 'pause').mockImplementation(() => {}) @@ -200,11 +200,38 @@ describe('ConnectionsManagerService', () => { expect(libp2pPauseSpy).toHaveBeenCalledTimes(1) await connectionsManagerService.resume() - expect(openSocketSpy).toHaveBeenCalledTimes(1) + expect(listenSpy).toHaveBeenCalledTimes(1) expect(libp2pResumeSpy).toHaveBeenCalledTimes(1) expect(qssResumeSpy).toHaveBeenCalledTimes(1) }) + it('uses bounded socket readiness and awaits libp2p before resuming qss', async () => { + let resolveLibp2pResume!: (value: boolean) => void + const libp2pResumePromise = new Promise(resolve => { + resolveLibp2pResume = resolve + }) + const socketInitSpy = jest.spyOn(connectionsManagerService['socketService'], 'init').mockResolvedValue() + const listenSpy = jest.spyOn(connectionsManagerService['socketService'], 'listen').mockResolvedValue() + const libp2pResumeSpy = jest + .spyOn(connectionsManagerService.libp2pService, 'resume') + .mockReturnValue(libp2pResumePromise) + const qssResumeSpy = jest.spyOn(qssService, 'resume').mockResolvedValue() + + const resumePromise = connectionsManagerService.resume() + await waitForExpect(() => expect(libp2pResumeSpy).toHaveBeenCalledTimes(1)) + + expect(socketInitSpy).not.toHaveBeenCalled() + expect(listenSpy).toHaveBeenCalledTimes(1) + expect(qssResumeSpy).not.toHaveBeenCalled() + + resolveLibp2pResume(true) + await resumePromise + + expect(qssResumeSpy).toHaveBeenCalledTimes(1) + expect(listenSpy.mock.invocationCallOrder[0]).toBeLessThan(libp2pResumeSpy.mock.invocationCallOrder[0]) + expect(libp2pResumeSpy.mock.invocationCallOrder[0]).toBeLessThan(qssResumeSpy.mock.invocationCallOrder[0]) + }) + it('sets storage team metadata once when QSS and libp2p join events race', async () => { const teamId = 'team-id' let resolveStorageInit: () => void diff --git a/packages/backend/src/nest/connections-manager/connections-manager.service.ts b/packages/backend/src/nest/connections-manager/connections-manager.service.ts index 0f239bced4..e88f9b21b9 100644 --- a/packages/backend/src/nest/connections-manager/connections-manager.service.ts +++ b/packages/backend/src/nest/connections-manager/connections-manager.service.ts @@ -288,8 +288,11 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI public async resume() { this.logger.info('Resuming!') - await this.openSocket() - this.libp2pService?.resume() + // A lifecycle transition only needs the data server to accept connections. + // Waiting for the frontend START event here would prevent a later pause + // from running if the app returns to the background before reconnecting. + await this.socketService.listen() + await this.libp2pService?.resume() await this.qssService.resume() } @@ -403,7 +406,8 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI } } - // This method is only used on iOS through rn-bridge for reacting on lifecycle changes + // Reopen the socket and wait for the frontend handshake. Workflows such as + // leaveCommunity use this stronger readiness guarantee before completing. public async openSocket() { await this.socketService.init() } diff --git a/packages/backend/src/nest/storage/notifications/notificationTokens.store.ts b/packages/backend/src/nest/storage/notifications/notificationTokens.store.ts index e4afed8120..39b927e4e2 100644 --- a/packages/backend/src/nest/storage/notifications/notificationTokens.store.ts +++ b/packages/backend/src/nest/storage/notifications/notificationTokens.store.ts @@ -219,17 +219,15 @@ export class NotificationTokensStore extends EncryptedKeyValueIndexedValidatedSt const valueUserId = encPayload.userId const decUserId = decEntry.userId const sigAuthor = encPayload.signature.author.name - if ( - !( - key && - valueUserId && - decUserId && - sigAuthor && - key === valueUserId && - key === decUserId && - key === sigAuthor - ) - ) { + if (!( + key && + valueUserId && + decUserId && + sigAuthor && + key === valueUserId && + key === decUserId && + key === sigAuthor + )) { logger.error( `Failed to verify notification token entry: ${entry.hash} - ID mismatch. key=${key}, valueUserId=${valueUserId}, decUserId=${decUserId}, sigAuthor=${sigAuthor}` ) diff --git a/packages/backend/src/nest/storage/userProfile/userProfile.store.ts b/packages/backend/src/nest/storage/userProfile/userProfile.store.ts index eee637bff1..998a758021 100644 --- a/packages/backend/src/nest/storage/userProfile/userProfile.store.ts +++ b/packages/backend/src/nest/storage/userProfile/userProfile.store.ts @@ -253,17 +253,15 @@ export class UserProfileStore extends EncryptedKeyValueIndexedValidatedStoreBase const valueUserId = encPayload.userId const decUserId = decEntry.userId const sigAuthor = encPayload.signature.author.name - if ( - !( - key && - valueUserId && - decUserId && - sigAuthor && - key === valueUserId && - key === decUserId && - key === sigAuthor - ) - ) { + if (!( + key && + valueUserId && + decUserId && + sigAuthor && + key === valueUserId && + key === decUserId && + key === sigAuthor + )) { logger.error( `Failed to verify user profile entry: ${entry.hash} - key, value.userId, decEntry.userId, and signature.author.name must all match. Got key=${key}, valueUserId=${valueUserId}, decUserId=${decUserId}, sigAuthor=${sigAuthor}` ) diff --git a/packages/backend/src/nest/tor/tor-control.service.spec.ts b/packages/backend/src/nest/tor/tor-control.service.spec.ts new file mode 100644 index 0000000000..5ad14cbba8 --- /dev/null +++ b/packages/backend/src/nest/tor/tor-control.service.spec.ts @@ -0,0 +1,61 @@ +import { jest } from '@jest/globals' + +import { ConfigOptions } from '../types' +import { TorControl } from './tor-control.service' +import { TorControlAuthType, TorControlParams } from './tor.types' + +const createTorControl = (authCookie: string) => { + const torControlParams: TorControlParams = { + port: 9051, + host: 'localhost', + auth: { + type: TorControlAuthType.COOKIE, + value: authCookie, + }, + } + const torControl = new TorControl(torControlParams, {} as ConfigOptions) + const logger = { + debug: jest.fn(), + error: jest.fn(), + } + ;(torControl as any).logger = logger + jest.spyOn(torControl as any, 'connect').mockResolvedValue(undefined) + return { logger, torControl } +} + +describe('TorControl logging', () => { + it('logs command and response metadata without ADD_ONION private keys', async () => { + const authCookie = 'auth-cookie-sentinel' + const requestPrivateKey = 'ED25519-V3:request-private-key-sentinel' + const responsePrivateKey = 'ED25519-V3:response-private-key-sentinel' + const { logger, torControl } = createTorControl(authCookie) + jest.spyOn(torControl, '_sendCommand').mockResolvedValue({ + code: 250, + messages: ['250-ServiceID=example', `250-PrivateKey=${responsePrivateKey}`, '250 OK'], + }) + + await torControl.sendCommand(`ADD_ONION ${requestPrivateKey} Flags=Detach Port=80,127.0.0.1:3000`) + + const logs = JSON.stringify(logger.debug.mock.calls) + expect(logger.debug).toHaveBeenCalledWith('Sending Tor command', { command: 'ADD_ONION' }) + expect(logger.debug).toHaveBeenCalledWith('Tor command response', { + command: 'ADD_ONION', + code: 250, + messageCount: 3, + }) + expect(logs).not.toContain(requestPrivateKey) + expect(logs).not.toContain(responsePrivateKey) + expect(logs).not.toContain(authCookie) + }) + + it('does not log an authentication cookie', async () => { + const authCookie = 'authentication-cookie-sentinel' + const { logger, torControl } = createTorControl(authCookie) + jest.spyOn(torControl, '_sendCommand').mockResolvedValue({ code: 250, messages: ['250 OK'] }) + + await torControl.sendCommand(`AUTHENTICATE ${authCookie}`) + + expect(logger.debug).toHaveBeenCalledWith('Sending Tor command', { command: 'AUTHENTICATE' }) + expect(JSON.stringify(logger.debug.mock.calls)).not.toContain(authCookie) + }) +}) diff --git a/packages/backend/src/nest/tor/tor-control.service.ts b/packages/backend/src/nest/tor/tor-control.service.ts index a31fcf51ef..dc66c246ee 100644 --- a/packages/backend/src/nest/tor/tor-control.service.ts +++ b/packages/backend/src/nest/tor/tor-control.service.ts @@ -92,7 +92,10 @@ export class TorControl { resolve({ code: 250, messages: dataArray }) } else { clearTimeout(connectionTimeout) - this.logger.error(`TOR CONNECTION ERROR:`, dataArray) + this.logger.error('Tor control command failed', { + responseCode: dataArray[0].slice(0, 3), + messageCount: dataArray.length, + }) reject(`${dataArray[0]}`) } clearTimeout(connectionTimeout) @@ -103,7 +106,8 @@ export class TorControl { } public async sendCommand(command: string): Promise<{ code: number; messages: string[] }> { - this.logger.debug(`Sending tor command: ${command}`) + const commandName = command.trim().split(/\s+/, 1)[0]?.toUpperCase() || 'UNKNOWN' + this.logger.debug('Sending Tor command', { command: commandName }) // Only send one command at a time. if (this.isSending) { this.logger.debug('Tor connection already established, waiting...') @@ -120,7 +124,11 @@ export class TorControl { try { await this.connect() const res = await this._sendCommand(command) - this.logger.debug(`Tor command response: ${res.code} ${res.messages}`) + this.logger.debug('Tor command response', { + command: commandName, + code: res.code, + messageCount: res.messages.length, + }) return res } finally { this.disconnect() diff --git a/packages/backend/src/nest/tor/tor.service.spec.ts b/packages/backend/src/nest/tor/tor.service.spec.ts new file mode 100644 index 0000000000..d0b6b6d795 --- /dev/null +++ b/packages/backend/src/nest/tor/tor.service.spec.ts @@ -0,0 +1,273 @@ +import { jest } from '@jest/globals' + +import { ConfigOptions, ServerIoProviderTypes } from '../types' +import { TorControl } from './tor-control.service' +import { Tor } from './tor.service' +import { TorControlAuthType, TorParamsProvider, TorPasswordProvider } from './tor.types' + +describe('Tor native session rewiring', () => { + const controlPort = 19051 + const httpTunnelPort = 18118 + const authCookie = 'cookie-a' + const privKey = 'ED25519-V3:test-private-key' + const onionAddress = 'test-service-id' + const bootstrapDone = '250-status/bootstrap-phase=NOTICE BOOTSTRAP PROGRESS=100 TAG=done SUMMARY="Done"' + + const deferred = () => { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve + reject = promiseReject + }) + return { promise, reject, resolve } + } + + const addOnionResponse = () => ({ + code: 250, + messages: [`250-ServiceID=${onionAddress}`, '250 OK'], + }) + + const createTorService = () => { + const configOptions: ConfigOptions = { + options: {}, + socketIOPort: 0, + httpTunnelPort, + torAuthCookie: authCookie, + torControlPort: controlPort, + env: {}, + } + const torControl = new TorControl( + { + port: controlPort, + host: 'localhost', + auth: { + type: TorControlAuthType.COOKIE, + value: authCookie, + }, + }, + configOptions + ) + const torParamsProvider: TorParamsProvider = { + torPath: '', + options: { + env: { + LD_LIBRARY_PATH: undefined, + HOME: '', + }, + detached: false, + }, + } + const torPasswordProvider: TorPasswordProvider = { + torPassword: '', + torHashedPassword: '', + } + const serverIoProvider = { + io: { emit: jest.fn() }, + } as unknown as ServerIoProviderTypes + const torService = new Tor(configOptions, '', torParamsProvider, torPasswordProvider, serverIoProvider, torControl) + + return { torControl, torService } + } + + const registerHiddenService = async (torService: Tor, torControl: TorControl) => { + jest.spyOn(torControl, 'sendCommand').mockResolvedValue(addOnionResponse()) + await torService.spawnHiddenService({ targetPort: 4343, privKey }) + } + + it('preserves bootstrap and initialized hidden services when the native Tor session is unchanged', async () => { + const { torControl, torService } = createTorService() + await registerHiddenService(torService, torControl) + torService.bootstrapped = true + jest.mocked(torControl.sendCommand).mockClear() + + torService.rewireNativeTor({ controlPort, httpTunnelPort, authCookie }) + await torService.spawnHiddenService({ targetPort: 4343, privKey }) + + expect(torService.bootstrapped).toBe(true) + expect(torControl.sendCommand).not.toHaveBeenCalled() + }) + + it('treats a control port parsed from the mobile CLI as the unchanged native Tor session', async () => { + const { torControl, torService } = createTorService() + torControl.torControlParams.port = String(controlPort) as unknown as number + await registerHiddenService(torService, torControl) + torService.bootstrapped = true + const startBootstrapWatcher = jest.spyOn(torService, 'startBootstrapWatcher') + jest.mocked(torControl.sendCommand).mockClear() + + torService.rewireNativeTor({ controlPort, httpTunnelPort, authCookie }) + await torService.spawnHiddenService({ targetPort: 4343, privKey }) + + expect(torService.bootstrapped).toBe(true) + expect(startBootstrapWatcher).not.toHaveBeenCalled() + expect(torControl.sendCommand).not.toHaveBeenCalled() + }) + + it('resets bootstrap state and replays hidden services for a new native Tor session', async () => { + const { torControl, torService } = createTorService() + await registerHiddenService(torService, torControl) + torService.bootstrapped = true + const startBootstrapWatcher = jest.spyOn(torService, 'startBootstrapWatcher').mockImplementation(() => {}) + jest.mocked(torControl.sendCommand).mockClear() + + torService.rewireNativeTor({ controlPort, httpTunnelPort, authCookie: 'cookie-b' }) + await torService.spawnHiddenServices() + + expect(torService.bootstrapped).toBe(false) + expect(startBootstrapWatcher).toHaveBeenCalledTimes(1) + expect(torControl.sendCommand).toHaveBeenCalledWith(`ADD_ONION ${privKey} Flags=Detach Port=80,127.0.0.1:4343`) + }) + + it('ignores a stale bootstrap status without spawning services or stopping the replacement watcher', async () => { + jest.useFakeTimers() + const { torControl, torService } = createTorService() + await registerHiddenService(torService, torControl) + const staleStatus = deferred<{ code: number; messages: string[] }>() + jest + .mocked(torControl.sendCommand) + .mockReset() + .mockImplementation(command => { + if (command === 'GETINFO status/bootstrap-phase') return staleStatus.promise + return Promise.resolve(addOnionResponse()) + }) + + try { + torService.startBootstrapWatcher(100) + await jest.advanceTimersByTimeAsync(100) + expect(torControl.sendCommand).toHaveBeenCalledWith('GETINFO status/bootstrap-phase') + + torService.rewireNativeTor({ controlPort, httpTunnelPort, authCookie: 'cookie-b' }) + const replacementWatcher = torService.interval + staleStatus.resolve({ code: 250, messages: [bootstrapDone, '250 OK'] }) + await jest.advanceTimersByTimeAsync(0) + + expect(torService.bootstrapped).toBe(false) + expect(torService.interval).toBe(replacementWatcher) + expect(torControl.sendCommand).toHaveBeenCalledTimes(1) + } finally { + torService.resetBootstrapState() + jest.useRealTimers() + } + }) + + it('does not let a replaced watcher stop its same-generation successor', async () => { + jest.useFakeTimers() + const { torControl, torService } = createTorService() + await registerHiddenService(torService, torControl) + const staleStatus = deferred<{ code: number; messages: string[] }>() + jest.mocked(torControl.sendCommand).mockReset().mockReturnValue(staleStatus.promise) + + try { + torService.startBootstrapWatcher(100) + await jest.advanceTimersByTimeAsync(100) + + torService.startBootstrapWatcher(2500) + const replacementWatcher = torService.interval + staleStatus.resolve({ code: 250, messages: [bootstrapDone, '250 OK'] }) + await jest.advanceTimersByTimeAsync(0) + + expect(torService.bootstrapped).toBe(false) + expect(torService.interval).toBe(replacementWatcher) + } finally { + torService.resetBootstrapState() + jest.useRealTimers() + } + }) + + it('does not let stale mark work populate state or stop a new-generation watcher', async () => { + jest.useFakeTimers() + const { torControl, torService } = createTorService() + await registerHiddenService(torService, torControl) + torService.resetBootstrapState() + const staleInitialization = deferred<{ code: number; messages: string[] }>() + const sendCommand = jest + .mocked(torControl.sendCommand) + .mockReset() + .mockImplementation(command => { + if (command === 'GETINFO status/bootstrap-phase') { + return Promise.resolve({ code: 250, messages: [bootstrapDone, '250 OK'] }) + } + return staleInitialization.promise + }) + + try { + torService.startBootstrapWatcher(100) + await jest.advanceTimersByTimeAsync(100) + expect(sendCommand).toHaveBeenCalledTimes(2) + + torService.rewireNativeTor({ controlPort, httpTunnelPort, authCookie: 'cookie-b' }) + const replacementWatcher = torService.interval + staleInitialization.resolve(addOnionResponse()) + await jest.advanceTimersByTimeAsync(0) + + expect(torService.bootstrapped).toBe(false) + expect(torService.interval).toBe(replacementWatcher) + + sendCommand.mockResolvedValue(addOnionResponse()) + await torService.spawnHiddenService({ targetPort: 4343, privKey }) + expect(sendCommand).toHaveBeenCalledTimes(3) + } finally { + torService.resetBootstrapState() + jest.useRealTimers() + } + }) + + it('rechecks the bootstrap generation after spawning hidden services', async () => { + const { torControl, torService } = createTorService() + jest.spyOn(torControl, 'sendCommand').mockResolvedValue({ code: 250, messages: [bootstrapDone, '250 OK'] }) + jest.spyOn(torService, 'startBootstrapWatcher').mockImplementation(() => {}) + jest.spyOn(torService, 'spawnHiddenServices').mockImplementation(async () => { + torService.rewireNativeTor({ controlPort, httpTunnelPort, authCookie: 'cookie-b' }) + }) + + await expect(torService.isBootstrappingFinished()).resolves.toBe(false) + + expect(torService.bootstrapped).toBe(false) + }) + + it('preserves native Tor state when kill has no managed process to terminate', async () => { + const { torControl, torService } = createTorService() + await registerHiddenService(torService, torControl) + torService.bootstrapped = true + jest.mocked(torControl.sendCommand).mockClear() + + await torService.kill() + torService.rewireNativeTor({ controlPort, httpTunnelPort, authCookie }) + await torService.spawnHiddenService({ targetPort: 4343, privKey }) + + expect(torService.bootstrapped).toBe(true) + expect(torControl.sendCommand).not.toHaveBeenCalled() + }) + + it('deduplicates concurrent hidden-service initialization by private key', async () => { + const { torControl, torService } = createTorService() + const initialization = deferred<{ code: number; messages: string[] }>() + const sendCommand = jest.spyOn(torControl, 'sendCommand').mockReturnValue(initialization.promise) + + const first = torService.spawnHiddenService({ targetPort: 4343, privKey }) + const second = torService.spawnHiddenService({ targetPort: 4343, privKey }) + + expect(sendCommand).toHaveBeenCalledTimes(1) + initialization.resolve(addOnionResponse()) + await expect(Promise.all([first, second])).resolves.toEqual([`${onionAddress}.onion`, `${onionAddress}.onion`]) + }) + + it('does not let stale hidden-service initialization populate a new Tor generation', async () => { + const { torControl, torService } = createTorService() + const staleInitialization = deferred<{ code: number; messages: string[] }>() + const sendCommand = jest.spyOn(torControl, 'sendCommand').mockReturnValue(staleInitialization.promise) + const startBootstrapWatcher = jest.spyOn(torService, 'startBootstrapWatcher').mockImplementation(() => {}) + + const staleResult = torService.spawnHiddenService({ targetPort: 4343, privKey }) + torService.rewireNativeTor({ controlPort, httpTunnelPort, authCookie: 'cookie-b' }) + staleInitialization.resolve(addOnionResponse()) + await expect(staleResult).rejects.toThrow('Tor generation changed while initializing hidden service') + + sendCommand.mockResolvedValue(addOnionResponse()) + await torService.spawnHiddenService({ targetPort: 4343, privKey }) + + expect(startBootstrapWatcher).toHaveBeenCalledTimes(1) + expect(sendCommand).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/backend/src/nest/tor/tor.service.ts b/packages/backend/src/nest/tor/tor.service.ts index c80cadca49..4ab3a02e98 100644 --- a/packages/backend/src/nest/tor/tor.service.ts +++ b/packages/backend/src/nest/tor/tor.service.ts @@ -40,6 +40,8 @@ export class Tor extends EventEmitter implements OnModuleInit { private readonly logger = createLogger(Tor.name) private hiddenServices: Map = new Map() private initializedHiddenServices: Map = new Map() + private hiddenServiceInitializationPromises: Map> = new Map() + private hiddenServiceGeneration = 0 private markBootstrappedPromise: Promise | undefined private bootstrapRestartPromise: Promise | undefined private bootstrapStallState: BootstrapStallState | undefined @@ -90,22 +92,42 @@ export class Tor extends EventEmitter implements OnModuleInit { httpTunnelPort: number authCookie: string }) { - this.logger.info('Rewiring native Tor control params', { controlPort, httpTunnelPort }) + const isSameNativeTorSession = + Number(this.torControl.torControlParams.port) === controlPort && + this.torControl.torControlParams.auth.type === TorControlAuthType.COOKIE && + this.torControl.torControlParams.auth.value === authCookie + + this.logger.info('Rewiring native Tor control params', { + controlPort, + httpTunnelPort, + isSameNativeTorSession, + }) this.configOptions.torControlPort = controlPort this.configOptions.httpTunnelPort = httpTunnelPort this.controlPort = controlPort this.torControl.torControlParams.port = controlPort this.torControl.torControlParams.auth.value = authCookie this.torControl.torControlParams.auth.type = TorControlAuthType.COOKIE + + if (isSameNativeTorSession) { + this.logger.info('Native Tor session unchanged; preserving bootstrap and hidden-service state') + if (!this.bootstrapped && !this.interval) { + this.startBootstrapWatcher() + } + return + } + this.resetBootstrapState() this.startBootstrapWatcher() } public resetBootstrapState() { this.bootstrapGeneration += 1 + this.hiddenServiceGeneration += 1 this.bootstrapped = false this.bootstrapStallState = undefined this.initializedHiddenServices = new Map() + this.hiddenServiceInitializationPromises.clear() this.markBootstrappedPromise = undefined if (this.initTimeout) { clearTimeout(this.initTimeout) @@ -114,16 +136,18 @@ export class Tor extends EventEmitter implements OnModuleInit { this.stopBootstrapWatcher() } - private async markBootstrapped() { + private async markBootstrapped(bootstrapGeneration: number) { + if (bootstrapGeneration !== this.bootstrapGeneration) return if (this.bootstrapped) return if (this.markBootstrappedPromise) { await this.markBootstrappedPromise return } - const bootstrapGeneration = this.bootstrapGeneration const markBootstrappedPromise = (async () => { - await this.spawnHiddenServices() + await this.spawnHiddenServices(bootstrapGeneration) + // The generation can change while hidden-service initialization is in flight, + // so revalidate before publishing bootstrapped state for the session. if (bootstrapGeneration !== this.bootstrapGeneration) { this.logger.warn('Bootstrap generation changed while marking Tor bootstrapped, skipping stale event') return @@ -137,7 +161,6 @@ export class Tor extends EventEmitter implements OnModuleInit { } this.emit('bootstrapped') this.serverIoProvider.io.emit(SocketEvents.TOR_INITIALIZED) - this.stopBootstrapWatcher() })() this.markBootstrappedPromise = markBootstrappedPromise @@ -161,6 +184,7 @@ export class Tor extends EventEmitter implements OnModuleInit { this.stopBootstrapWatcher() } + const bootstrapGeneration = this.bootstrapGeneration let tickInProgress = false let tickCount = 0 let lastTickStartedAt = 0 @@ -171,7 +195,8 @@ export class Tor extends EventEmitter implements OnModuleInit { torPids: this.torDataDirectory ? this.getTorProcessIds() : undefined, }) - this.interval = setInterval(() => { + const watcher = setInterval(() => { + if (!this.isCurrentBootstrapWatcher(watcher, bootstrapGeneration)) return if (tickInProgress) { this.logger.debug('Bootstrap interval tick skipped (previous still running)', { tickCount, @@ -186,20 +211,23 @@ export class Tor extends EventEmitter implements OnModuleInit { void (async () => { this.logger.info('Checking bootstrap interval', { tickCount }) - const restartedMissingTorProcess = await this.checkManagedTorProcessHealth() + const restartedMissingTorProcess = await this.checkManagedTorProcessHealth(bootstrapGeneration) + if (!this.isCurrentBootstrapWatcher(watcher, bootstrapGeneration)) return if (restartedMissingTorProcess) { return } const bootstrapStatus = await this.getBootstrapStatus() + if (!this.isCurrentBootstrapWatcher(watcher, bootstrapGeneration)) return if (bootstrapStatus.done) { - await this.markBootstrapped() - this.stopBootstrapWatcher() + await this.markBootstrapped(bootstrapGeneration) + this.stopBootstrapWatcher(watcher) return } - await this.checkBootstrapStall(bootstrapStatus) + await this.checkBootstrapStall(bootstrapStatus, bootstrapGeneration) })() .catch(e => { + if (!this.isCurrentBootstrapWatcher(watcher, bootstrapGeneration)) return this.logger.error( `Bootstrap interval tick failed (tickCount=${tickCount}, startedAt=${lastTickStartedAt})`, e @@ -214,9 +242,15 @@ export class Tor extends EventEmitter implements OnModuleInit { tickInProgress = false }) }, intervalMs) + this.interval = watcher + } + + private isCurrentBootstrapWatcher(watcher: any, bootstrapGeneration: number): boolean { + return this.interval === watcher && this.bootstrapGeneration === bootstrapGeneration } - private stopBootstrapWatcher() { + private stopBootstrapWatcher(watcher = this.interval) { + if (this.interval !== watcher) return if (this.interval) { clearInterval(this.interval) this.interval = undefined @@ -236,10 +270,15 @@ export class Tor extends EventEmitter implements OnModuleInit { public async isBootstrappingFinished(): Promise { if (this.bootstrapped) return true + const bootstrapGeneration = this.bootstrapGeneration + const watcher = this.interval const bootstrapStatus = await this.getBootstrapStatus() + if (bootstrapGeneration !== this.bootstrapGeneration) return false if (bootstrapStatus.done) { - await this.markBootstrapped() - return true + await this.markBootstrapped(bootstrapGeneration) + if (bootstrapGeneration !== this.bootstrapGeneration || !this.bootstrapped) return false + this.stopBootstrapWatcher(watcher) + return this.bootstrapped } return false } @@ -266,7 +305,8 @@ export class Tor extends EventEmitter implements OnModuleInit { } } - private async checkBootstrapStall(status: BootstrapStatus): Promise { + private async checkBootstrapStall(status: BootstrapStatus, bootstrapGeneration: number): Promise { + if (bootstrapGeneration !== this.bootstrapGeneration) return if (status.done || status.progress == null) { this.bootstrapStallState = undefined return @@ -302,10 +342,16 @@ export class Tor extends EventEmitter implements OnModuleInit { return } - await this.restartAfterBootstrapStall(status, stalledMs, this.bootstrapStallState.timeoutWarningCount) + await this.restartAfterBootstrapStall( + status, + stalledMs, + this.bootstrapStallState.timeoutWarningCount, + bootstrapGeneration + ) } - private async checkManagedTorProcessHealth(): Promise { + private async checkManagedTorProcessHealth(bootstrapGeneration: number): Promise { + if (bootstrapGeneration !== this.bootstrapGeneration) return false if (!this.torParamsProvider.torPath || !this.torDataDirectory || this.bootstrapped) { return false } @@ -322,19 +368,25 @@ export class Tor extends EventEmitter implements OnModuleInit { return false } - await this.restartManagedTor('Managed Tor process disappeared during bootstrap; restarting Tor', { - controlPort: this.controlPort, - socksPort: this.socksPort, - torPids, - }) + await this.restartManagedTor( + 'Managed Tor process disappeared during bootstrap; restarting Tor', + { + controlPort: this.controlPort, + socksPort: this.socksPort, + torPids, + }, + bootstrapGeneration + ) return true } private async restartAfterBootstrapStall( status: BootstrapStatus, stalledMs: number, - timeoutWarningCount: number + timeoutWarningCount: number, + bootstrapGeneration: number ): Promise { + if (bootstrapGeneration !== this.bootstrapGeneration) return if (!this.torParamsProvider.torPath) { this.logger.warn('Tor bootstrap appears stalled, but Tor is externally managed; waiting for native Tor', { progress: status.progress, @@ -347,19 +399,28 @@ export class Tor extends EventEmitter implements OnModuleInit { return } - await this.restartManagedTor('Tor bootstrap appears stalled; restarting Tor', { - progress: status.progress, - tag: status.tag, - stalledMs, - timeoutWarningCount, - controlPort: this.controlPort, - socksPort: this.socksPort, - torPids: this.torDataDirectory ? this.getTorProcessIds() : undefined, - status: status.rawMessage, - }) + await this.restartManagedTor( + 'Tor bootstrap appears stalled; restarting Tor', + { + progress: status.progress, + tag: status.tag, + stalledMs, + timeoutWarningCount, + controlPort: this.controlPort, + socksPort: this.socksPort, + torPids: this.torDataDirectory ? this.getTorProcessIds() : undefined, + status: status.rawMessage, + }, + bootstrapGeneration + ) } - private async restartManagedTor(reason: string, context: Record): Promise { + private async restartManagedTor( + reason: string, + context: Record, + bootstrapGeneration: number + ): Promise { + if (bootstrapGeneration !== this.bootstrapGeneration) return if (this.bootstrapRestartPromise) { await this.bootstrapRestartPromise return @@ -368,9 +429,6 @@ export class Tor extends EventEmitter implements OnModuleInit { this.logger.warn(reason, context) this.bootstrapRestartPromise = (async () => { - this.initializedHiddenServices = new Map() - this.bootstrapStallState = undefined - this.stopBootstrapWatcher() await this.init() })() @@ -382,9 +440,9 @@ export class Tor extends EventEmitter implements OnModuleInit { } public async init(timeout = 120_000): Promise { + this.resetBootstrapState() if (!this.socksPort) this.socksPort = await getPort() this.logger.info('Initializing tor...') - this.resetBootstrapState() return await new Promise((resolve, reject) => { if (!fs.existsSync(this.quietDir)) { @@ -401,12 +459,13 @@ export class Tor extends EventEmitter implements OnModuleInit { this.logger.info(`${this.torPidPath} exists. Old tor pid: ${oldTorPid}`) } + const bootstrapGeneration = this.bootstrapGeneration this.initTimeout = setTimeout(async () => { + if (bootstrapGeneration !== this.bootstrapGeneration) return this.logger.debug('Checking init timeout') const bootstrapDone = await this.isBootstrappingFinished() + if (bootstrapGeneration !== this.bootstrapGeneration) return if (!bootstrapDone) { - this.initializedHiddenServices = new Map() - clearInterval(this.interval) await this.init() } }, timeout) @@ -446,8 +505,10 @@ export class Tor extends EventEmitter implements OnModuleInit { } public resetHiddenServices() { + this.hiddenServiceGeneration += 1 this.hiddenServices = new Map() this.initializedHiddenServices = new Map() + this.hiddenServiceInitializationPromises.clear() } private torProcessNameCommand(oldTorPid: string): string { @@ -590,10 +651,12 @@ export class Tor extends EventEmitter implements OnModuleInit { }) } - public async spawnHiddenServices() { + public async spawnHiddenServices(bootstrapGeneration = this.bootstrapGeneration) { + if (bootstrapGeneration !== this.bootstrapGeneration) return this.logger.info(`Spawning hidden service(s) (count: ${this.hiddenServices.size})`) for (const el of Array.from(this.hiddenServices.values())) { await this.spawnHiddenService(el) + if (bootstrapGeneration !== this.bootstrapGeneration) return } } @@ -612,16 +675,36 @@ export class Tor extends EventEmitter implements OnModuleInit { this.logger.warn(`Hidden service already initialized for ${initializedHiddenService.onionAddress}`) return initializedHiddenService.onionAddress } - const status = await this.torControl.sendCommand( - `ADD_ONION ${privKey} Flags=Detach Port=${virtPort},127.0.0.1:${targetPort}` - ) - const onionAddress = status.messages[0].replace('250-ServiceID=', '') - this.logger.debug(`Spawned hidden service with onion address ${onionAddress}`) - const hiddenService: HiddenServiceData = { targetPort, privKey, virtPort, onionAddress } - this.hiddenServices.set(privKey, hiddenService) - this.initializedHiddenServices.set(privKey, hiddenService) - return `${onionAddress}.onion` + const initializationInFlight = this.hiddenServiceInitializationPromises.get(privKey) + if (initializationInFlight) return await initializationInFlight + + const hiddenServiceGeneration = this.hiddenServiceGeneration + const initializationPromise = (async () => { + const status = await this.torControl.sendCommand( + `ADD_ONION ${privKey} Flags=Detach Port=${virtPort},127.0.0.1:${targetPort}` + ) + if (hiddenServiceGeneration !== this.hiddenServiceGeneration) { + throw new Error('Tor generation changed while initializing hidden service') + } + + const onionAddress = status.messages[0].replace('250-ServiceID=', '') + this.logger.debug(`Spawned hidden service with onion address ${onionAddress}`) + + const hiddenService: HiddenServiceData = { targetPort, privKey, virtPort, onionAddress } + this.hiddenServices.set(privKey, hiddenService) + this.initializedHiddenServices.set(privKey, hiddenService) + return `${onionAddress}.onion` + })() + + this.hiddenServiceInitializationPromises.set(privKey, initializationPromise) + try { + return await initializationPromise + } finally { + if (this.hiddenServiceInitializationPromises.get(privKey) === initializationPromise) { + this.hiddenServiceInitializationPromises.delete(privKey) + } + } } public async destroyHiddenService(serviceId: string): Promise { @@ -677,7 +760,6 @@ export class Tor extends EventEmitter implements OnModuleInit { } public async kill(): Promise { - this.resetBootstrapState() return await new Promise((resolve, reject) => { this.logger.info('Killing tor... with pid', this.process?.pid) if (this.process === null) { @@ -685,6 +767,7 @@ export class Tor extends EventEmitter implements OnModuleInit { resolve() return } + this.resetBootstrapState() if (this.initTimeout) clearTimeout(this.initTimeout) if (this.interval) clearInterval(this.interval) this.process?.on('close', () => { diff --git a/packages/backend/src/rn-bridge.spec.ts b/packages/backend/src/rn-bridge.spec.ts new file mode 100644 index 0000000000..8cbcce48c8 --- /dev/null +++ b/packages/backend/src/rn-bridge.spec.ts @@ -0,0 +1,75 @@ +import { jest } from '@jest/globals' + +const logger = { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), +} + +jest.unstable_mockModule('./nest/common/logger', () => ({ + createLogger: () => logger, +})) + +const { EventChannel } = await import('./rn-bridge') + +const loggedValues = (): string => JSON.stringify(Object.values(logger).flatMap(mock => mock.mock.calls)) + +describe('rn-bridge logging', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('logs event metadata without logging a valid secret payload', async () => { + const socketIOSecret = 'socket-secret-sentinel' + const authCookie = 'auth-cookie-sentinel' + const nonce = 'nonce-sentinel' + const channel = new EventChannel('_EVENTS_') + + channel.processData( + JSON.stringify({ + event: 'secret', + payload: JSON.stringify([{ socketIOSecret, authCookie, nonce }]), + }) + ) + await new Promise(resolve => setImmediate(resolve)) + + expect(logger.info).toHaveBeenCalledWith('EventChannel received event', { + event: 'secret', + payloadType: 'array', + payloadItemCount: 1, + }) + expect(loggedValues()).not.toContain(socketIOSecret) + expect(loggedValues()).not.toContain(authCookie) + expect(loggedValues()).not.toContain(nonce) + }) + + it('does not log malformed legacy payload contents', async () => { + const socketIOSecret = 'malformed-socket-secret-sentinel' + const authCookie = 'malformed-auth-cookie-sentinel' + const malformedEntry = 'malformed-nonce-sentinel' + const channel = new EventChannel('_EVENTS_') + + channel.processData( + JSON.stringify({ + event: 'secret', + payload: `socketIOSecret:${socketIOSecret}|authCookie:${authCookie}|${malformedEntry}`, + }) + ) + await new Promise(resolve => setImmediate(resolve)) + + expect(logger.warn).toHaveBeenCalledWith('Malformed rn-bridge entry', { index: 2 }) + expect(loggedValues()).not.toContain(socketIOSecret) + expect(loggedValues()).not.toContain(authCookie) + expect(loggedValues()).not.toContain(malformedEntry) + }) + + it('does not log a malformed envelope payload', () => { + const secretPayload = 'malformed-envelope-secret-sentinel' + const channel = new EventChannel('_EVENTS_') + + expect(() => channel.processData(JSON.stringify({ payload: secretPayload }))).toThrow('Malformed message envelope') + + expect(loggedValues()).not.toContain(secretPayload) + }) +}) diff --git a/packages/backend/src/rn-bridge.ts b/packages/backend/src/rn-bridge.ts index 74048e44ea..44b18d13b7 100644 --- a/packages/backend/src/rn-bridge.ts +++ b/packages/backend/src/rn-bridge.ts @@ -25,6 +25,34 @@ interface MessageEnvelope { payload: string | Record } +interface MessageEnvelopeLogMetadata { + event: string + payloadType: 'array' | 'object' | 'string' + payloadItemCount?: number + payloadFieldCount?: number +} + +const getMessageEnvelopeLogMetadata = (envelope: MessageEnvelope): MessageEnvelopeLogMetadata => { + if (Array.isArray(envelope.payload)) { + return { + event: envelope.event, + payloadType: 'array', + payloadItemCount: envelope.payload.length, + } + } + if (typeof envelope.payload === 'object') { + return { + event: envelope.event, + payloadType: 'object', + payloadFieldCount: Object.keys(envelope.payload).length, + } + } + return { + event: envelope.event, + payloadType: 'string', + } +} + class MessageCodec { event: string payload: string @@ -37,28 +65,32 @@ class MessageCodec { return JSON.stringify(envelope) } static parsePayload(message: string): Record { - logger.warn('rn-bridge payload', message) const parsed: Record = {} const entries = message.split('|') + logger.debug('Parsing legacy rn-bridge payload', { entryCount: entries.length }) if (entries.length < 1) { - logger.warn('Malformed or non-existent rn-bridge payload ', entries) + logger.warn('Malformed or non-existent rn-bridge payload') return parsed } - entries.forEach(s => { + entries.forEach((s, index) => { const split = s.split(':') if (split.length !== 2) { - logger.warn('Malformed rn-bridge entry: ', split) + logger.warn('Malformed rn-bridge entry', { index }) return } parsed[split[0]] = split[1] }) - logger.info('parsed', JSON.stringify(parsed)) + logger.debug('Parsed legacy rn-bridge payload', { fieldCount: Object.keys(parsed).length }) return parsed } static deserialize(message: string): MessageEnvelope { const envelope = JSON.parse(message) as MessageEnvelope if (typeof envelope !== 'object' || !Object.prototype.hasOwnProperty.call(envelope, 'event')) { - logger.error('Malformed message envelope: ', envelope) + logger.error('Malformed message envelope', { + valueType: typeof envelope, + hasEvent: + typeof envelope === 'object' && envelope !== null && Object.prototype.hasOwnProperty.call(envelope, 'event'), + }) throw new Error('Malformed message envelope') } if (typeof envelope.payload === 'string') { @@ -100,8 +132,8 @@ class EventChannel extends ChannelSuper { this.post('message', ...msg) } processData(data: string): void { - logger.info('EventChannel received data:', data) const envelope = MessageCodec.deserialize(data) + logger.info('EventChannel received event', getMessageEnvelopeLogMetadata(envelope)) setImmediate(() => { this.emitLocal(envelope.event, envelope.payload) }) diff --git a/packages/mobile/ios/NodeJsMobile/RNNodeJsMobile.h b/packages/mobile/ios/NodeJsMobile/RNNodeJsMobile.h index 9fc2b73d31..c2d199539a 100644 --- a/packages/mobile/ios/NodeJsMobile/RNNodeJsMobile.h +++ b/packages/mobile/ios/NodeJsMobile/RNNodeJsMobile.h @@ -3,7 +3,7 @@ @interface RNNodeJsMobile : NSObject @property (nonatomic, strong) NSString *socketIOSecret; + -(void) startNodeProjectInBackground:(NSString *)command; -(void) sendMessageToNode:(NSString*)event :(NSString*)message; -(void) sendMessageBackToReact:(NSString*)channelName :(NSString*)message; - -(void) callStartNodeProject:(NSString *)input; @end diff --git a/packages/mobile/ios/NodeJsMobile/RNNodeJsMobile.m b/packages/mobile/ios/NodeJsMobile/RNNodeJsMobile.m index 186ff21f00..5fb6ee6bcb 100644 --- a/packages/mobile/ios/NodeJsMobile/RNNodeJsMobile.m +++ b/packages/mobile/ios/NodeJsMobile/RNNodeJsMobile.m @@ -87,8 +87,10 @@ -(void)callStartNodeProject:(NSString *)input [[NodeRunner sharedInstance] startEngineWithArguments:nodeArguments:nodePath]; } -RCT_EXPORT_METHOD(startNodeProject:(NSString *)command options:(NSDictionary *)options) +-(void)startNodeProjectInBackground:(NSString *)command { + // node_start runs the Node event loop synchronously for the backend's + // lifetime. Keep it off React Native's method queue and UIKit's main thread. if(![NodeRunner sharedInstance].startedNodeAlready) { [NodeRunner sharedInstance].startedNodeAlready=true; @@ -104,6 +106,11 @@ -(void)callStartNodeProject:(NSString *)input } } +RCT_EXPORT_METHOD(startNodeProject:(NSString *)command options:(NSDictionary *)options) +{ + [self startNodeProjectInBackground:command]; +} + -(void)sendMessageToNode:(NSString *)event :(NSString *)message { NSString * data = [NSString stringWithFormat:@"{ \"event\": \"%@\", \"payload\": \"%@\" }", event, message]; diff --git a/packages/mobile/ios/Podfile.lock b/packages/mobile/ios/Podfile.lock index 2dcfb3bc9d..76bb5173cb 100644 --- a/packages/mobile/ios/Podfile.lock +++ b/packages/mobile/ios/Podfile.lock @@ -2136,7 +2136,7 @@ SPEC CHECKSUMS: Sodium: 23d11554ecd556196d313cf6130d406dfe7ac6da SSZipArchive: c69881e8ac5521f0e622291387add5f60f30f3c4 Tor: 39dc71bf048312e202608eb499ca5c74e841b503 - Yoga: 2b02f3f767761bb4ffd25b1bb56fd264be57bd6b + Yoga: 1a10502f162e8fc40ff0907c82d01cfe555d00c2 PODFILE CHECKSUM: cfc64e6e7d6e469da4bad48f367062c5e329239f diff --git a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj index 91211535f6..79035cd99d 100644 --- a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj +++ b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj @@ -55,11 +55,10 @@ 18FD2A3E296F009E00A2B8C0 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 18FD2A37296F009E00A2B8C0 /* AppDelegate.m */; }; 18FD2A3F296F009E00A2B8C0 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 18FD2A38296F009E00A2B8C0 /* Images.xcassets */; }; 18FD2A40296F009E00A2B8C0 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 18FD2A39296F009E00A2B8C0 /* main.m */; }; - 2815B731ADE0FE0C770C78E3 /* Pods_QuietNotificationServiceExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1725031A98BBEE5E8F3F6561 /* Pods_QuietNotificationServiceExtension.framework */; }; + 4D83A3053BB0BA758B7C612D /* Pods_QuietNotificationServiceExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D609F9D4DF7238B86189CDC /* Pods_QuietNotificationServiceExtension.framework */; }; + 6204263D91F3440F598B7BB0 /* Pods_Quiet.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4656C8360BE2B6E9C1BCF8CF /* Pods_Quiet.framework */; }; 663DC8C12F621139005D2086 /* UserMetadataHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 663DC8C02F621134005D2086 /* UserMetadataHandler.swift */; }; - 673478D924128C1639690CFF /* Pods_Quiet_QuietTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 805522FA9F468CE465C44459 /* Pods_Quiet_QuietTests.framework */; }; 955DC7582BD930B30014725B /* WebsocketSingleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 955DC7572BD930B30014725B /* WebsocketSingleton.swift */; }; - CE99A25A0E4E1440E0C42536 /* Pods_Quiet.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1A49D8557AFF7D73033A5FD2 /* Pods_Quiet.framework */; }; D3239FB5EFA85E780E1AD201 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 84F12DFE2A5B0E05C2C41286 /* PrivacyInfo.xcprivacy */; }; EB4DC7EC2F5FF6AB00EFD23F /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = EB4DC7EB2F5FF6AB00EFD23F /* GoogleService-Info.plist */; }; EB4DC8002F608A3300EFD23F /* QuietNotificationServiceExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = EB4DC7F92F608A3300EFD23F /* QuietNotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -67,6 +66,7 @@ EB4DC8172F60A81000EFD23F /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = EB4DC7EB2F5FF6AB00EFD23F /* GoogleService-Info.plist */; }; EB5F92FC2F5FBB2100B5C60D /* FirebaseMessagingModule.m in Sources */ = {isa = PBXBuildFile; fileRef = EB5F92FA2F5FBB2100B5C60D /* FirebaseMessagingModule.m */; }; EB5F92FD2F5FBB2100B5C60D /* FirebaseMessagingModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB5F92FB2F5FBB2100B5C60D /* FirebaseMessagingModule.swift */; }; + F4B2150A7001269B8241CC86 /* Pods_Quiet_QuietTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CB1D543483316490C8FEBE9 /* Pods_Quiet_QuietTests.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -132,9 +132,7 @@ 03B674042E6103DC00A86655 /* Rubik-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "Rubik-Regular.ttf"; path = "../src/assets/fonts/Rubik-Regular.ttf"; sourceTree = SOURCE_ROOT; }; 03B674052E6103DC00A86655 /* Rubik-SemiBold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "Rubik-SemiBold.ttf"; path = "../src/assets/fonts/Rubik-SemiBold.ttf"; sourceTree = SOURCE_ROOT; }; 03B674062E6103DC00A86655 /* Rubik-SemiBoldItalic.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "Rubik-SemiBoldItalic.ttf"; path = "../src/assets/fonts/Rubik-SemiBoldItalic.ttf"; sourceTree = SOURCE_ROOT; }; - 09783DD98C14F076599A13B4 /* Pods-Quiet.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.release.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.release.xcconfig"; sourceTree = ""; }; 13B07F961A680F5B00A75B9A /* Quiet.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Quiet.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 1725031A98BBEE5E8F3F6561 /* Pods_QuietNotificationServiceExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_QuietNotificationServiceExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 180E120A2AEFB7F900804659 /* Utils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Utils.swift; sourceTree = ""; }; 1827A9E129783D6E00245FD3 /* classic-level.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = "classic-level.framework"; sourceTree = ""; }; 183C484F296C7B6700BA2D8B /* v8-platform.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "v8-platform.h"; sourceTree = ""; }; @@ -644,17 +642,19 @@ 18FD2A39296F009E00A2B8C0 /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Quiet/main.m; sourceTree = ""; }; 18FD2A3A296F009E00A2B8C0 /* Quiet.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; name = Quiet.entitlements; path = Quiet/Quiet.entitlements; sourceTree = ""; }; 18FD2A3B296F009E00A2B8C0 /* QuietDebug.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; name = QuietDebug.entitlements; path = Quiet/QuietDebug.entitlements; sourceTree = ""; }; - 1A49D8557AFF7D73033A5FD2 /* Pods_Quiet.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Quiet.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 58FBDCDCEEA5EACED0FF5D68 /* Pods-Quiet-QuietTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.release.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.release.xcconfig"; sourceTree = ""; }; + 1CB1D543483316490C8FEBE9 /* Pods_Quiet_QuietTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Quiet_QuietTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 3D609F9D4DF7238B86189CDC /* Pods_QuietNotificationServiceExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_QuietNotificationServiceExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 4656C8360BE2B6E9C1BCF8CF /* Pods_Quiet.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Quiet.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 4F5C736CD3F644621489397D /* Pods-QuietNotificationServiceExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-QuietNotificationServiceExtension.release.xcconfig"; path = "Target Support Files/Pods-QuietNotificationServiceExtension/Pods-QuietNotificationServiceExtension.release.xcconfig"; sourceTree = ""; }; 663DC8C02F621134005D2086 /* UserMetadataHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserMetadataHandler.swift; sourceTree = ""; }; - 72EA7AA4B77C0780A86EC300 /* Pods-Quiet-QuietTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.debug.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.debug.xcconfig"; sourceTree = ""; }; - 805522FA9F468CE465C44459 /* Pods_Quiet_QuietTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Quiet_QuietTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 84F12DFE2A5B0E05C2C41286 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Quiet/PrivacyInfo.xcprivacy; sourceTree = ""; }; + 866ADD8CCEDAE901228A8325 /* Pods-Quiet.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.debug.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.debug.xcconfig"; sourceTree = ""; }; + 88791CE736EBC48C100DEECB /* Pods-Quiet-QuietTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.debug.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.debug.xcconfig"; sourceTree = ""; }; 955DC7572BD930B30014725B /* WebsocketSingleton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsocketSingleton.swift; sourceTree = ""; }; - C5124A51F9229F2CDC5100C0 /* Pods-QuietNotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-QuietNotificationServiceExtension.debug.xcconfig"; path = "Target Support Files/Pods-QuietNotificationServiceExtension/Pods-QuietNotificationServiceExtension.debug.xcconfig"; sourceTree = ""; }; - CDD5E3F75BDB63D6BC806F2B /* Pods-QuietNotificationServiceExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-QuietNotificationServiceExtension.release.xcconfig"; path = "Target Support Files/Pods-QuietNotificationServiceExtension/Pods-QuietNotificationServiceExtension.release.xcconfig"; sourceTree = ""; }; + 983C2DA0FB6D06D1452AFCDF /* Pods-Quiet.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.release.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.release.xcconfig"; sourceTree = ""; }; + D138F5E8F9C27CF118B62554 /* Pods-Quiet-QuietTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.release.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.release.xcconfig"; sourceTree = ""; }; DC03A48F321B4BE2A5339F05 /* Quiet.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Quiet.xcconfig; sourceTree = SOURCE_ROOT; }; - DE73D517F18C6E0B941FDFF2 /* Pods-Quiet.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.debug.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.debug.xcconfig"; sourceTree = ""; }; + DCB679A6329CD994279348D8 /* Pods-QuietNotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-QuietNotificationServiceExtension.debug.xcconfig"; path = "Target Support Files/Pods-QuietNotificationServiceExtension/Pods-QuietNotificationServiceExtension.debug.xcconfig"; sourceTree = ""; }; E079692B46015F1D27CDBBC1 /* Quiet.debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Quiet.debug.xcconfig; sourceTree = SOURCE_ROOT; }; E079692B46015F1D27CDBBC2 /* Quiet.release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Quiet.release.xcconfig; sourceTree = SOURCE_ROOT; }; E17A7BE86B5879902AF7BA3A /* QuietNotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = QuietNotificationServiceExtension.debug.xcconfig; sourceTree = SOURCE_ROOT; }; @@ -701,7 +701,7 @@ buildActionMask = 2147483647; files = ( 1827A9E229783D6E00245FD3 /* classic-level.framework in Frameworks */, - 673478D924128C1639690CFF /* Pods_Quiet_QuietTests.framework in Frameworks */, + F4B2150A7001269B8241CC86 /* Pods_Quiet_QuietTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -711,7 +711,7 @@ files = ( 00A416342EC2EAA900ACC877 /* NodeMobile.xcframework in Frameworks */, 1827A9E329783D7600245FD3 /* classic-level.framework in Frameworks */, - CE99A25A0E4E1440E0C42536 /* Pods_Quiet.framework in Frameworks */, + 6204263D91F3440F598B7BB0 /* Pods_Quiet.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -719,7 +719,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 2815B731ADE0FE0C770C78E3 /* Pods_QuietNotificationServiceExtension.framework in Frameworks */, + 4D83A3053BB0BA758B7C612D /* Pods_QuietNotificationServiceExtension.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4782,12 +4782,12 @@ 1CEEDB4F07B9978C125775C5 /* Pods */ = { isa = PBXGroup; children = ( - DE73D517F18C6E0B941FDFF2 /* Pods-Quiet.debug.xcconfig */, - 09783DD98C14F076599A13B4 /* Pods-Quiet.release.xcconfig */, - 72EA7AA4B77C0780A86EC300 /* Pods-Quiet-QuietTests.debug.xcconfig */, - 58FBDCDCEEA5EACED0FF5D68 /* Pods-Quiet-QuietTests.release.xcconfig */, - C5124A51F9229F2CDC5100C0 /* Pods-QuietNotificationServiceExtension.debug.xcconfig */, - CDD5E3F75BDB63D6BC806F2B /* Pods-QuietNotificationServiceExtension.release.xcconfig */, + 866ADD8CCEDAE901228A8325 /* Pods-Quiet.debug.xcconfig */, + 983C2DA0FB6D06D1452AFCDF /* Pods-Quiet.release.xcconfig */, + 88791CE736EBC48C100DEECB /* Pods-Quiet-QuietTests.debug.xcconfig */, + D138F5E8F9C27CF118B62554 /* Pods-Quiet-QuietTests.release.xcconfig */, + DCB679A6329CD994279348D8 /* Pods-QuietNotificationServiceExtension.debug.xcconfig */, + 4F5C736CD3F644621489397D /* Pods-QuietNotificationServiceExtension.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -4797,9 +4797,9 @@ children = ( 00A416332EC2EAA900ACC877 /* NodeMobile.xcframework */, 1827A9E129783D6E00245FD3 /* classic-level.framework */, - 1A49D8557AFF7D73033A5FD2 /* Pods_Quiet.framework */, - 805522FA9F468CE465C44459 /* Pods_Quiet_QuietTests.framework */, - 1725031A98BBEE5E8F3F6561 /* Pods_QuietNotificationServiceExtension.framework */, + 4656C8360BE2B6E9C1BCF8CF /* Pods_Quiet.framework */, + 1CB1D543483316490C8FEBE9 /* Pods_Quiet_QuietTests.framework */, + 3D609F9D4DF7238B86189CDC /* Pods_QuietNotificationServiceExtension.framework */, ); name = Frameworks; sourceTree = ""; @@ -4873,12 +4873,12 @@ isa = PBXNativeTarget; buildConfigurationList = 00E357021AD99517003FC87E /* Build configuration list for PBXNativeTarget "QuietTests" */; buildPhases = ( - B84DABF150D7B1B5C2DF9239 /* [CP] Check Pods Manifest.lock */, + 765F2ED3DBDDEF2FF5C056CB /* [CP] Check Pods Manifest.lock */, 00E356EA1AD99517003FC87E /* Sources */, 00E356EB1AD99517003FC87E /* Frameworks */, 00E356EC1AD99517003FC87E /* Resources */, - C0311D831E5FCA078E3114A7 /* [CP] Embed Pods Frameworks */, - D779ACFAE5667A522EFCF468 /* [CP] Copy Pods Resources */, + F2CC9725101511987408ECEC /* [CP] Embed Pods Frameworks */, + 2FF92392E3AF19C0048F19EA /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -4894,7 +4894,7 @@ isa = PBXNativeTarget; buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Quiet" */; buildPhases = ( - 669A6891CB9F7ADDDD9A98B4 /* [CP] Check Pods Manifest.lock */, + 6B0C767FCED8729054722020 /* [CP] Check Pods Manifest.lock */, FD10A7F022414F080027D42C /* Start Packager */, 13B07F871A680F5B00A75B9A /* Sources */, 13B07F8C1A680F5B00A75B9A /* Frameworks */, @@ -4907,8 +4907,8 @@ 1827A9E0297837FE00245FD3 /* [CUSTOM NODEJS MOBILE] Remove prebuilds */, 1868C095292F8FE2001D6D5E /* Embed Frameworks */, EBD3273E2F5FA01F00E2CD0C /* Embed Foundation Extensions */, - 468C008BFE8FDD32EC649A5B /* [CP] Embed Pods Frameworks */, - B04C9103A39CF5F564636327 /* [CP] Copy Pods Resources */, + 45E8E1CED3ACB0A8578792B4 /* [CP] Embed Pods Frameworks */, + 256DD21612F4D56D318DA3D7 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -4927,7 +4927,7 @@ isa = PBXNativeTarget; buildConfigurationList = EB4DC8022F608A3300EFD23F /* Build configuration list for PBXNativeTarget "QuietNotificationServiceExtension" */; buildPhases = ( - 3D09985B32EEEAFD930419CB /* [CP] Check Pods Manifest.lock */, + 8A70AA2C80FA5736FD665DB6 /* [CP] Check Pods Manifest.lock */, EB4DC7F52F608A3300EFD23F /* Sources */, EB4DC7F62F608A3300EFD23F /* Frameworks */, EB4DC7F72F608A3300EFD23F /* Resources */, @@ -5155,29 +5155,41 @@ shellPath = /bin/sh; shellScript = "find \"$CODESIGNING_FOLDER_PATH/nodejs-project/node_modules/\" -name \"python3\" | xargs rm\n"; }; - 3D09985B32EEEAFD930419CB /* [CP] Check Pods Manifest.lock */ = { + 256DD21612F4D56D318DA3D7 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; + name = "[CP] Copy Pods Resources"; outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-QuietNotificationServiceExtension-checkManifestLockResult.txt", + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 2FF92392E3AF19C0048F19EA /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources.sh\"\n"; showEnvVarsInLog = 0; }; - 468C008BFE8FDD32EC649A5B /* [CP] Embed Pods Frameworks */ = { + 45E8E1CED3ACB0A8578792B4 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -5194,7 +5206,7 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - 669A6891CB9F7ADDDD9A98B4 /* [CP] Check Pods Manifest.lock */ = { + 6B0C767FCED8729054722020 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -5216,24 +5228,29 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - B04C9103A39CF5F564636327 /* [CP] Copy Pods Resources */ = { + 765F2ED3DBDDEF2FF5C056CB /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources-${CONFIGURATION}-input-files.xcfilelist", ); - name = "[CP] Copy Pods Resources"; + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Quiet-QuietTests-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources.sh\"\n"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - B84DABF150D7B1B5C2DF9239 /* [CP] Check Pods Manifest.lock */ = { + 8A70AA2C80FA5736FD665DB6 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -5248,14 +5265,14 @@ outputFileListPaths = ( ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Quiet-QuietTests-checkManifestLockResult.txt", + "$(DERIVED_FILE_DIR)/Pods-QuietNotificationServiceExtension-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - C0311D831E5FCA078E3114A7 /* [CP] Embed Pods Frameworks */ = { + F2CC9725101511987408ECEC /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -5272,23 +5289,6 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - D779ACFAE5667A522EFCF468 /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Copy Pods Resources"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; FD10A7F022414F080027D42C /* Start Packager */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -5368,7 +5368,7 @@ /* Begin XCBuildConfiguration section */ 00E356F61AD99517003FC87E /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 72EA7AA4B77C0780A86EC300 /* Pods-Quiet-QuietTests.debug.xcconfig */; + baseConfigurationReference = 88791CE736EBC48C100DEECB /* Pods-Quiet-QuietTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ENABLE_MODULES = YES; @@ -5403,7 +5403,7 @@ }; 00E356F71AD99517003FC87E /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 58FBDCDCEEA5EACED0FF5D68 /* Pods-Quiet-QuietTests.release.xcconfig */; + baseConfigurationReference = D138F5E8F9C27CF118B62554 /* Pods-Quiet-QuietTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ENABLE_MODULES = YES; diff --git a/packages/mobile/ios/Quiet/AppDelegate.h b/packages/mobile/ios/Quiet/AppDelegate.h index 0cdef8198c..a4ab40a49c 100644 --- a/packages/mobile/ios/Quiet/AppDelegate.h +++ b/packages/mobile/ios/Quiet/AppDelegate.h @@ -1,6 +1,5 @@ #import #import -#import #import #import "RNNodeJsMobile.h" @@ -8,8 +7,6 @@ // Forward declarations for Swift classes // (Actual imports happen in AppDelegate.m to avoid circular dependencies) @class TorHandler; -@class TORConfiguration; -@class TORController; @interface AppDelegate : RCTAppDelegate @@ -22,7 +19,5 @@ @property (nonatomic, strong) RNNodeJsMobile *nodeJsMobile; @property (nonatomic, strong) TorHandler *tor; -@property (nonatomic, strong) TORConfiguration *torConfiguration; -@property (nonatomic, strong) TORController *torController; @end diff --git a/packages/mobile/ios/Quiet/AppDelegate.m b/packages/mobile/ios/Quiet/AppDelegate.m index 3df7ec57fa..5a464f45fa 100644 --- a/packages/mobile/ios/Quiet/AppDelegate.m +++ b/packages/mobile/ios/Quiet/AppDelegate.m @@ -10,6 +10,9 @@ #import "RNNodeJsMobile.h" #import "Quiet-Swift.h" +@interface AppDelegate () +@end + @implementation AppDelegate static NSString *const platform = @"mobile"; @@ -58,7 +61,7 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:( // Call only once per nodejs thread [self createDataDirectory]; - [self spinupBackend:true]; + [self startTorAndBackend]; return [super application:application didFinishLaunchingWithOptions:launchOptions]; }; @@ -68,9 +71,13 @@ - (void) createDataDirectory { self.dataPath = [dataDirectory create]; } -- (void) spinupBackend:(BOOL)init { +- (void) startTorAndBackend { + if (self.tor != nil) { + [self.tor enterForeground]; + return; + } - // (1/4) Find ports to use in tor and backend configuration + // Find ports to use in Tor and backend configuration. Utils *utils = [Utils new]; @@ -91,77 +98,38 @@ - (void) spinupBackend:(BOOL)init { uint16_t httpTunnelPort = [findFreePort getFirstStartingFromPort:arc4random_uniform(65000 - 1024) + 1024]; - // (2/4) Spawn tor with proper configuration - + // Spawn one Tor instance for the lifetime of this app process. App + // background/foreground transitions switch it between DORMANT and ACTIVE. self.tor = [TorHandler new]; - - self.torConfiguration = [self.tor getTorConfiguration:socksPort controlPort:controlPort httpTunnelPort:httpTunnelPort]; - - [self.tor removeOldAuthCookieWithConfiguration:self.torConfiguration]; - - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - [self.tor spawnWithConfiguration:self.torConfiguration]; - }); - - - // (3/4) Connect to tor control port natively (so we can use it to shutdown tor when app goes idle) - - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{ - NSData *authCookieData = [self getAuthCookieData]; - - self.torController = [[TORController alloc] initWithSocketHost:@"127.0.0.1" port:controlPort]; - - NSError *error = nil; - // BOOL connected = [self.torController connect:&error]; - - NSLog(@"Tor control port error %@", error); - - [self.torController authenticateWithData:authCookieData completion:^(BOOL success, NSError * _Nullable error) { - NSString *res = success ? @"YES" : @"NO"; - NSLog(@"Tor control port auth success %@", res); - NSLog(@"Tor control port auth error %@", error); - }]; - }); - - // (4/4) Launch backend or rewire services - - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ - - NSString *authCookie = [self getAuthCookie]; - - if (init) { - [self launchBackend:controlPort httpTunnelPort:httpTunnelPort authCookie:authCookie]; - } else { - [self rewireServices:controlPort httpTunnelPort:httpTunnelPort authCookie:authCookie]; - } - }); -} - -- (NSString *) getAuthCookie { - NSString *authCookie = [self.tor getAuthCookieWithConfiguration:self.torConfiguration]; - - while (authCookie == nil) { - authCookie = [self.tor getAuthCookieWithConfiguration:self.torConfiguration]; - }; - - return authCookie; + self.tor.delegate = self; + [self.tor startWithSocksPort:socksPort controlPort:controlPort httpTunnelPort:httpTunnelPort]; } -- (NSData *) getAuthCookieData { - NSData *authCookie = [self.tor getAuthCookieDataWithConfiguration:self.torConfiguration]; +- (void)torHandlerReady:(TorHandler *)handler + controlPort:(uint16_t)controlPort + httpTunnelPort:(uint16_t)httpTunnelPort + authCookie:(NSString *)authCookie +{ + (void)handler; - while (authCookie == nil) { - authCookie = [self.tor getAuthCookieDataWithConfiguration:self.torConfiguration]; - }; + // A readiness callback can race with a background transition. The next + // foreground callback will request readiness again, so do nothing here. + if ([UIApplication sharedApplication].applicationState == UIApplicationStateBackground) { + return; + } - return authCookie; + if (self.nodeJsMobile == nil) { + [self launchBackend:controlPort httpTunnelPort:httpTunnelPort authCookie:authCookie]; + } else { + [self rewireServices:controlPort httpTunnelPort:httpTunnelPort authCookie:authCookie]; + } } - - (void)launchBackend:(uint16_t)controlPort httpTunnelPort:(uint16_t)httpTunnelPort authCookie:(NSString *)authCookie { self.nodeJsMobile = [RNNodeJsMobile new]; [self.nodeJsMobile setSocketIOSecret:self.socketIOSecret]; - [self.nodeJsMobile callStartNodeProject:[NSString stringWithFormat:@"bundle.cjs --dataPort %hu --dataPath %@ --controlPort %hu --httpTunnelPort %hu --authCookie %@ --platform %@", self.dataPort, self.dataPath, controlPort, httpTunnelPort, authCookie, platform]]; + NSString *command = [NSString stringWithFormat:@"bundle.cjs --dataPort %hu --dataPath %@ --controlPort %hu --httpTunnelPort %hu --authCookie %@ --platform %@", self.dataPort, self.dataPath, controlPort, httpTunnelPort, authCookie, platform]; + [self.nodeJsMobile startNodeProjectInBackground:command]; } - (void)rewireServices:(uint16_t)controlPort httpTunnelPort:(uint16_t)httpTunnelPort authCookie:(NSString *)authCookie { @@ -175,31 +143,10 @@ - (void)rewireServices:(uint16_t)controlPort httpTunnelPort:(uint16_t)httpTunnel [self.nodeJsMobile sendMessageToNode:@"open":payload]; } -- (void) stopTor { - NSLog(@"Sending SIGNAL SHUTDOWN on Tor control port %d", (int)[self.torController isConnected]); - [self.torController sendCommand:@"SIGNAL SHUTDOWN" arguments:nil data:nil observer:^BOOL(NSArray *codes, NSArray *lines, BOOL *stop) { - NSUInteger code = codes.firstObject.unsignedIntegerValue; - - NSLog(@"Tor control port response code %lu", (unsigned long)code); - - if (code != TORControlReplyCodeOK && code != TORControlReplyCodeBadAuthentication) - return NO; - - NSString *message = lines.firstObject ? [[NSString alloc] initWithData:(NSData * _Nonnull)lines.firstObject encoding:NSUTF8StringEncoding] : @""; - - NSLog(@"Tor control port response message %@", message); - - // BOOL success = (code == TORControlReplyCodeOK && [message isEqualToString:@"OK"]); - - *stop = YES; - return YES; - }]; -} - - (void)applicationDidEnterBackground:(UIApplication *)application { QuietSetAppForegroundFlag(NO); - [self stopTor]; + [self.tor enterBackground]; NSString * message = [NSString stringWithFormat:@"app:close"]; [self.nodeJsMobile sendMessageToNode:@"close":message]; @@ -226,7 +173,12 @@ - (void)applicationWillEnterForeground:(UIApplication *)application }); }); - [self spinupBackend:false]; + [self.tor enterForeground]; +} + +- (void)applicationWillTerminate:(UIApplication *)application +{ + [self.tor shutdown]; } /// This method controls whether the `concurrentRoot`feature of React18 is turned on or off. diff --git a/packages/mobile/ios/TorHandler.swift b/packages/mobile/ios/TorHandler.swift index 1a62ade0f4..910375fe54 100644 --- a/packages/mobile/ios/TorHandler.swift +++ b/packages/mobile/ios/TorHandler.swift @@ -1,162 +1,681 @@ +import Darwin +import Foundation +import OSLog import Tor +@objc(TorHandlerDelegate) +protocol TorHandlerDelegate: AnyObject { + @objc(torHandlerReady:controlPort:httpTunnelPort:authCookie:) + func torHandlerReady( + _ handler: TorHandler, + controlPort: UInt16, + httpTunnelPort: UInt16, + authCookie: String + ) +} + @objc(TorHandler) -class TorHandler: NSObject { - - @objc(getTorConfiguration:controlPort:httpTunnelPort:) - func getTorConfiguration(socksPort: in_port_t, controlPort: in_port_t, httpTunnelPort: in_port_t) -> TorConfiguration { - let conf = TorConfiguration() +final class TorHandler: NSObject { + private enum State: String { + case stopped + case starting + case unknown + case active + case dormant + case stopping + } - let log_loc = "notice stdout" + private enum DesiredMode { + case stopped + case active + case dormant - conf.cookieAuthentication = true + var signal: String? { + switch self { + case .stopped: + return nil + case .active: + return "ACTIVE" + case .dormant: + return "DORMANT" + } + } + } - conf.arguments = [ - "--allow-missing-torrc", - "--ignore-missing-torrc", - "--ClientOnly", "1", - "--AvoidDiskWrites", "1", - "--SocksPort", "127.0.0.1:\(socksPort)", - "--ControlPort", "127.0.0.1:\(controlPort)", - "--HTTPTunnelPort", "127.0.0.1:\(httpTunnelPort)", - "--Log", log_loc, - ] + private struct PendingModeCommand { + let id: UInt + let generation: UInt + let mode: DesiredMode + } - if let dataDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask) - .first?.appendingPathComponent("tor", isDirectory: true) { + private static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "com.quietmobile", + category: "TorHandler" + ) + private static let cookieLength = 32 + private static let controlTimeout: TimeInterval = 3 + private static let cookieRetryInterval: TimeInterval = 0.1 + private static let slowCookieRetryInterval: TimeInterval = 1 + private static let fastCookieRetryCount = 300 + private static let stableUptime: TimeInterval = 60 + private static let sharedLifecycleQueue = DispatchQueue(label: "com.quietmobile.tor-lifecycle") - // Create tor data directory if it does not yet exist. - try? FileManager.default.createDirectory(at: dataDir, withIntermediateDirectories: true) + // Tor 0.4.5.9's compiled-in authority identities are stale. Keep this list + // synchronized with the Tor Project's audited auth_dirs.inc. Bundling these + // trust anchors keeps bootstrap independent of a runtime network fetch. + // Source: tor commit 4b996aa6469ac78fe746bcc8d6d8f100643e3c01. + private static let directoryAuthorities = """ + moria1 orport=9201 v3ident=F533C81CEF0BC0267857C99B2F471ADF249FA232 128.31.0.39:9231 1A25C6358DB91342AA51720A5038B72742732498 + tor26 orport=443 v3ident=2F3DF9CA0E5D36F2685A2DA67184EB8DCB8CBA8C ipv6=[2a02:16a8:662:2203::1]:443 217.196.147.77:80 FAA4BCA4A6AC0FB4CA2F8AD5A11D9E122BA894F6 + dizum orport=443 v3ident=E8A9C45EDE6D711294FADF8E7951F4DE6CA56B58 45.66.35.11:80 7EA6EAD6FD83083C538F44038BBFA077587DD755 + gabelmoo orport=443 v3ident=ED03BB616EB2F60BEC80151114BB25CEF515B226 ipv6=[2001:638:a000:4140::ffff:189]:443 131.188.40.189:80 F2044413DAC2E02E3D6BCF4735A19BCA1DE97281 + dannenberg orport=443 v3ident=0232AF901C31A04EE9848595AF9BB7620D4C5B2E ipv6=[2001:678:558:1000::244]:443 193.23.244.244:80 7BE683E65D48141321C5ED92F075C55364AC7123 + maatuska orport=80 v3ident=49015F787433103580E3B66A1707A00E60F2D15B ipv6=[2001:67c:289c::9]:80 171.25.193.9:443 BD6A829255CB08E66FBE7D3748363586E46B3810 + longclaw orport=443 v3ident=23D15D965BC35114467363C165C4F724B64B4F66 199.58.81.140:80 74A910646BCEEFBCD2E874FC1DC997430F968145 + bastet orport=443 v3ident=27102BC123E7AF1D4741AE047E160C91ADC76B21 ipv6=[2620:13:4000:6000::1000:118]:443 204.13.164.118:80 24E2F139121D4394C54B5BCC368B3B411857C413 + faravahar orport=443 v3ident=70849B868D606BAECFB6128C5E3D782029AA394F 216.218.219.41:80 E3E42D35F801C9D5AB23584E0025D56FE2B33396 + """.split(separator: "\n").map(String.init) - conf.dataDirectory = dataDir - } + // Tor.framework registers callbacks globally and appends each registration. + // Tor 0.4.5.9 also asserts if its callback is registered before init_logging, + // so install one pair per Tor generation only after control authentication. + private static func installLoggingCallbacks() { + TORInstallTorLoggingCallback { severity, message in + guard severity != .debug, severity != .info else { return } + let text = String(cString: message).trimmingCharacters(in: .whitespacesAndNewlines) + if severity == .error || severity == .fault { + TorHandler.logger.error("Tor: \(text, privacy: .public)") + } else { + TorHandler.logger.notice("Tor: \(text, privacy: .public)") + } + } - return conf - } + TORInstallEventLoggingCallback { severity, message in + guard severity != .debug, severity != .info else { return } + let text = String(cString: message).trimmingCharacters(in: .whitespacesAndNewlines) + if severity == .error || severity == .fault { + TorHandler.logger.error("libevent: \(text, privacy: .public)") + } else { + TorHandler.logger.notice("libevent: \(text, privacy: .public)") + } + } + } + + @objc weak var delegate: TorHandlerDelegate? + + // Tor is process-global. Sharing this queue makes the active-thread check and + // construction atomic even if a second TorHandler is created accidentally. + private var lifecycleQueue: DispatchQueue { Self.sharedLifecycleQueue } + private var state = State.stopped + private var desiredMode = DesiredMode.stopped + + private var configuration: TorConfiguration? + private var controlPort: UInt16 = 0 + private var httpTunnelPort: UInt16 = 0 private var torThread: TorThread? - - @objc - func spawn(configuration: TorConfiguration) -> Void { - - #if DEBUG - print("[\(String(describing: type(of: self)))] arguments=\(String(describing: configuration.arguments))") - #endif - - var cookiePath: String? { - if let cookieUrl = configuration.dataDirectory?.appendingPathComponent("control_auth_cookie") { - return cookieUrl.absoluteString - } - - return nil - } - - if cookiePath != nil && FileManager.default.fileExists(atPath: cookiePath!) { - do { - try FileManager.default.removeItem(atPath: cookiePath!) - } catch { - print("Could not delete cookie file, probably read-only filesystem") - } - } - - // Start Tor - torThread = TorThread(configuration: configuration) - torThread?.start() - - print("[\(String(describing: type(of: self)))] Starting Tor") - - // Wait long enough for Tor itself to have started. It's OK to wait for this - // because Tor is already trying to connect; this is just the part that polls for - // progress. - DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: { - TORInstallTorLoggingCallback { severity, msg in - let s: String - - switch severity { - case .debug: - return - - case .error: - s = "error" - - case .fault: - s = "fault" - - case .info: - // s = "info" - return - - default: - s = "default" - } - - print("[Tor \(s)] \(String(cString: msg).trimmingCharacters(in: .whitespacesAndNewlines))") - } - TORInstallEventLoggingCallback { severity, msg in - let s: String - - switch severity { - case .debug: - return - - case .error: - s = "error" - - case .fault: - s = "fault" - - case .info: - // s = "info" - return - - default: - s = "default" + private var controller: TorController? + private var monitorTimer: DispatchSourceTimer? + + private var generation: UInt = 0 + private var authenticationAttempt: UInt = 0 + private var commandSequence: UInt = 0 + private var controllerRetrySequence: UInt = 0 + private var readinessSequence: UInt = 0 + private var scheduledControllerRetry: UInt? + private var scheduledReadiness: UInt? + private var pendingModeCommand: PendingModeCommand? + private var restartScheduled = false + private var restartAttempts = 0 + private var authenticationInFlight = false + private var readyNotificationPending = false + private var loggingCallbacksGeneration: UInt? + + private var cookieData: Data? + private var authCookie: String? + + deinit { + monitorTimer?.cancel() + } + + @objc(startWithSocksPort:controlPort:httpTunnelPort:) + func start(socksPort: UInt16, controlPort: UInt16, httpTunnelPort: UInt16) { + lifecycleQueue.async { [weak self] in + guard let self else { return } + + if self.configuration == nil { + self.configuration = self.makeConfiguration( + socksPort: socksPort, + controlPort: controlPort, + httpTunnelPort: httpTunnelPort + ) + self.controlPort = controlPort + self.httpTunnelPort = httpTunnelPort + } + + self.desiredMode = .active + self.readyNotificationPending = true + self.ensureThreadRunning() + } + } + + /// Wake the existing Tor instance and notify the app when it is usable. + /// Repeated foreground callbacks are coalesced on the lifecycle queue. + @objc func enterForeground() { + lifecycleQueue.async { [weak self] in + guard let self else { return } + self.desiredMode = .active + self.readyNotificationPending = true + self.ensureThreadRunning() + self.applyDesiredMode() + } + } + + /// Put Tor into its low-activity mode without destroying process-global state. + @objc func enterBackground() { + lifecycleQueue.async { [weak self] in + guard let self else { return } + self.desiredMode = .dormant + self.readyNotificationPending = false + self.scheduledReadiness = nil + self.applyDesiredMode() + } + } + + /// Explicit teardown is reserved for process termination. Normal app lifecycle + /// transitions use ACTIVE/DORMANT so Tor is never initialized concurrently. + @objc func shutdown() { + lifecycleQueue.async { [weak self] in + guard let self else { return } + self.desiredMode = .stopped + self.readyNotificationPending = false + self.scheduledReadiness = nil + self.restartScheduled = false + self.state = .stopping + self.pendingModeCommand = nil + + guard let controller = self.controller, controller.isConnected else { return } + controller.sendCommand("SIGNAL SHUTDOWN", arguments: nil, data: nil) { _, _, stop in + stop.pointee = true + return true + } + } + } + + private func makeConfiguration( + socksPort: UInt16, + controlPort: UInt16, + httpTunnelPort: UInt16 + ) -> TorConfiguration { + let configuration = TorConfiguration() + configuration.cookieAuthentication = true + configuration.arguments = [ + "--allow-missing-torrc", + "--ignore-missing-torrc", + "--ClientOnly", "1", + "--AvoidDiskWrites", "1", + "--SocksPort", "127.0.0.1:\(socksPort)", + "--ControlPort", "127.0.0.1:\(controlPort)", + "--HTTPTunnelPort", "127.0.0.1:\(httpTunnelPort)", + "--Log", "notice stdout", + ] + Self.directoryAuthorities.flatMap { ["--AlternateDirAuthority", $0] } + + if let dataDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask) + .first?.appendingPathComponent("tor", isDirectory: true) { + try? FileManager.default.createDirectory(at: dataDirectory, withIntermediateDirectories: true) + configuration.dataDirectory = dataDirectory + } + + return configuration + } + + private func ensureThreadRunning() { + guard desiredMode != .stopped, configuration != nil else { return } + + if let thread = torThread { + if thread.isFinished { + handleThreadExit(generation: generation) + } + return + } + + // Tor.framework only keeps a weak process-global reference and does not + // enforce this in release builds. Treat any active framework thread as a + // hard serialization barrier, even if it was not created by this handler. + if let activeThread = TorThread.active { + if activeThread.isFinished { + scheduleThreadCheck(after: 0.25) + } else { + Self.logger.error("Refusing to initialize Tor while another Tor thread is active") + scheduleThreadCheck(after: 1) + } + return + } + + startThread() + } + + private func startThread() { + guard let configuration, desiredMode != .stopped, torThread == nil else { return } + + removeAuthCookie(configuration: configuration) + generation &+= 1 + let currentGeneration = generation + + controller = nil + cookieData = nil + authCookie = nil + authenticationInFlight = false + authenticationAttempt &+= 1 + scheduledControllerRetry = nil + scheduledReadiness = nil + pendingModeCommand = nil + state = .starting + restartScheduled = false + + let thread = TorThread(configuration: configuration) + torThread = thread + thread.start() + + Self.logger.info("Started Tor generation \(currentGeneration)") + startThreadMonitor(generation: currentGeneration) + pollForCookie(generation: currentGeneration, attempt: 0) + + lifecycleQueue.asyncAfter(deadline: .now() + Self.stableUptime) { [weak self] in + guard let self, + currentGeneration == self.generation, + self.torThread?.isFinished == false else { return } + self.restartAttempts = 0 + } + } + + private func startThreadMonitor(generation: UInt) { + monitorTimer?.cancel() + + let timer = DispatchSource.makeTimerSource(queue: lifecycleQueue) + timer.schedule(deadline: .now() + 0.25, repeating: 0.25) + timer.setEventHandler { [weak self] in + guard let self else { return } + guard generation == self.generation else { + timer.cancel() + return + } + guard self.torThread?.isFinished == true else { return } + self.handleThreadExit(generation: generation) + } + monitorTimer = timer + timer.resume() + } + + private func handleThreadExit(generation exitedGeneration: UInt) { + guard exitedGeneration == generation else { return } + + monitorTimer?.cancel() + monitorTimer = nil + controller = nil + cookieData = nil + authCookie = nil + authenticationInFlight = false + authenticationAttempt &+= 1 + scheduledControllerRetry = nil + scheduledReadiness = nil + pendingModeCommand = nil + torThread = nil + state = .stopped + + guard desiredMode == .active else { + Self.logger.info("Tor stopped while the app does not require an active connection") + return + } + + readyNotificationPending = true + restartAttempts += 1 + let exponent = min(max(restartAttempts - 1, 0), 5) + let delay = min(pow(2, Double(exponent)), 30) + Self.logger.error("Tor exited unexpectedly; scheduling recovery in \(delay, privacy: .public) seconds") + scheduleRestart(after: delay, exitedGeneration: exitedGeneration) + } + + private func scheduleRestart(after delay: TimeInterval, exitedGeneration: UInt) { + guard !restartScheduled else { return } + restartScheduled = true + + lifecycleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self else { return } + guard self.generation == exitedGeneration, self.desiredMode == .active else { + self.restartScheduled = false + return + } + self.restartScheduled = false + self.ensureThreadRunning() + } + } + + private func scheduleThreadCheck(after delay: TimeInterval) { + guard !restartScheduled else { return } + restartScheduled = true + lifecycleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self else { return } + self.restartScheduled = false + self.ensureThreadRunning() + } + } + + private func pollForCookie(generation: UInt, attempt: Int) { + guard generation == self.generation, + desiredMode != .stopped, + let thread = torThread else { return } + guard !thread.isFinished else { + handleThreadExit(generation: generation) + return + } + + if let configuration, + let data = readAuthCookie(configuration: configuration), + data.count == Self.cookieLength { + cookieData = data + connectController(generation: generation) + return + } + + if attempt == Self.fastCookieRetryCount { + Self.logger.error("Tor control cookie is still unavailable; continuing low-frequency readiness checks") + } + + let delay = attempt < Self.fastCookieRetryCount + ? Self.cookieRetryInterval + : Self.slowCookieRetryInterval + lifecycleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in + self?.pollForCookie(generation: generation, attempt: attempt + 1) + } + } + + private func connectController(generation: UInt) { + guard generation == self.generation, + desiredMode != .stopped, + let thread = torThread, + !thread.isFinished, + let cookieData else { return } + guard !authenticationInFlight else { return } + + // A direct lifecycle event supersedes a delayed retry. Its sequence token + // makes any already-enqueued retry closure a no-op. + scheduledControllerRetry = nil + + let control: TorController + if let existingController = controller, existingController.isConnected { + if authCookie != nil { + applyDesiredMode() + return + } + control = existingController + } else { + controller = nil + guard controlPortIsReachable() else { + Self.logger.debug("Tor control port is not ready; retrying") + scheduleControllerRetry(generation: generation) + return + } + + let newController = TorController(socketHost: "127.0.0.1", port: controlPort) + guard newController.isConnected else { + Self.logger.debug("Tor control port is not ready; retrying") + scheduleControllerRetry(generation: generation) + return + } + controller = newController + control = newController + } + + authenticationInFlight = true + authenticationAttempt &+= 1 + let attempt = authenticationAttempt + + control.authenticate(with: cookieData) { [weak self, weak control] success, error in + self?.lifecycleQueue.async { + guard let self, + generation == self.generation, + attempt == self.authenticationAttempt, + control === self.controller else { return } + + self.authenticationInFlight = false + if success { + guard self.desiredMode != .stopped, + self.torThread?.isFinished == false else { return } + if self.loggingCallbacksGeneration != generation { + Self.installLoggingCallbacks() + self.loggingCallbacksGeneration = generation + } + self.authCookie = cookieData.hexEncodedString() + self.applyDesiredMode() + } else { + Self.logger.error("Tor control authentication failed: \(error?.localizedDescription ?? "unknown error", privacy: .public)") + self.cookieData = nil + self.authCookie = nil + self.lifecycleQueue.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.pollForCookie(generation: generation, attempt: 0) + } } - - print("[libevent \(s)] \(String(cString: msg).trimmingCharacters(in: .whitespacesAndNewlines))") } - }) + } + + lifecycleQueue.asyncAfter(deadline: .now() + Self.controlTimeout) { [weak self, weak control] in + guard let self, + generation == self.generation, + attempt == self.authenticationAttempt, + self.authenticationInFlight, + control === self.controller else { return } + + self.authenticationInFlight = false + self.authenticationAttempt &+= 1 + self.controller = nil + Self.logger.debug("Tor control authentication timed out; reconnecting") + self.scheduleControllerRetry(generation: generation) + } + } + + private func scheduleControllerRetry(generation: UInt) { + guard generation == self.generation, + desiredMode != .stopped, + scheduledControllerRetry == nil else { return } + + controllerRetrySequence &+= 1 + let retry = controllerRetrySequence + scheduledControllerRetry = retry + + lifecycleQueue.asyncAfter(deadline: .now() + 0.5) { [weak self] in + guard let self, + self.scheduledControllerRetry == retry, + generation == self.generation else { return } + self.scheduledControllerRetry = nil + self.connectController(generation: generation) + } } - - @objc - func getAuthCookie(configuration: TorConfiguration) -> String? { - var auth: Data? { - if let cookieUrl = configuration.dataDirectory?.appendingPathComponent("control_auth_cookie") { - return try? Data(contentsOf: cookieUrl) + + private func applyDesiredMode() { + guard desiredMode != .stopped else { return } + + guard torThread != nil else { + ensureThreadRunning() + return + } + guard torThread?.isFinished != true else { + handleThreadExit(generation: generation) + return + } + guard let controller, controller.isConnected, authCookie != nil else { + if !authenticationInFlight, cookieData != nil { + connectController(generation: generation) } + return + } + guard pendingModeCommand == nil else { return } - return nil + if desiredMode == .active, state == .active { + notifyReadyIfNeeded() + return } - - guard let cookie = auth else { - return nil + if desiredMode == .dormant, state == .dormant { + return } - - return cookie.hexEncodedString() + + sendModeCommand(desiredMode, controller: controller) } - - @objc - func getAuthCookieData(configuration: TorConfiguration) -> NSData? { - var auth: NSData? { - if let cookieUrl = configuration.dataDirectory?.appendingPathComponent("control_auth_cookie") { - return NSData(contentsOf: cookieUrl) + + private func sendModeCommand(_ mode: DesiredMode, controller: TorController) { + guard let signal = mode.signal else { return } + + commandSequence &+= 1 + let command = PendingModeCommand(id: commandSequence, generation: generation, mode: mode) + pendingModeCommand = command + + controller.sendCommand("SIGNAL \(signal)", arguments: nil, data: nil) { [weak self] codes, _, stop in + guard let code = codes.first?.intValue else { return false } + stop.pointee = true + let success = code == 250 + self?.lifecycleQueue.async { + self?.finishModeCommand(command, success: success) } + return true + } - return nil + lifecycleQueue.asyncAfter(deadline: .now() + Self.controlTimeout) { [weak self, weak controller] in + guard let self, + self.pendingModeCommand?.id == command.id, + self.pendingModeCommand?.generation == command.generation, + controller === self.controller else { return } + + self.pendingModeCommand = nil + self.state = .unknown + self.controller = nil + Self.logger.debug("Tor mode command timed out; reconnecting the controller") + self.connectController(generation: command.generation) } - - guard let cookie = auth else { - return nil + } + + private func finishModeCommand(_ command: PendingModeCommand, success: Bool) { + guard pendingModeCommand?.id == command.id, + command.generation == generation else { return } + + pendingModeCommand = nil + if success { + state = command.mode == .active ? .active : .dormant + if desiredMode == command.mode { + if command.mode == .active { + notifyReadyIfNeeded() + } + return + } + applyDesiredMode() + } else { + state = .unknown + scheduleControllerRetry(generation: generation) } - - return cookie } - - @objc - func removeOldAuthCookie(configuration: TorConfiguration) -> Void { - if let cookieUrl = configuration.dataDirectory?.appendingPathComponent("control_auth_cookie") { - print("[\(String(describing: type(of: self)))] Removing old auth cookie") - try? FileManager.default.removeItem(at: cookieUrl) + + private func notifyReadyIfNeeded() { + guard desiredMode == .active, + readyNotificationPending, + scheduledReadiness == nil, + state == .active, + torThread?.isFinished == false, + controller?.isConnected == true, + !authenticationInFlight, + let authCookie else { return } + + readinessSequence &+= 1 + let readiness = readinessSequence + let currentGeneration = generation + scheduledReadiness = readiness + let controlPort = controlPort + let httpTunnelPort = httpTunnelPort + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + let shouldDeliver = self.lifecycleQueue.sync { + guard self.scheduledReadiness == readiness else { return false } + self.scheduledReadiness = nil + + guard self.generation == currentGeneration, + self.desiredMode == .active, + self.state == .active, + self.torThread?.isFinished == false, + self.controller?.isConnected == true, + !self.authenticationInFlight, + self.authCookie == authCookie else { + self.applyDesiredMode() + return false + } + + self.readyNotificationPending = false + return true + } + guard shouldDeliver else { return } + + self.delegate?.torHandlerReady( + self, + controlPort: controlPort, + httpTunnelPort: httpTunnelPort, + authCookie: authCookie + ) } } + + private func controlPortIsReachable() -> Bool { + let socketDescriptor = Darwin.socket(AF_INET, SOCK_STREAM, 0) + guard socketDescriptor >= 0 else { return false } + defer { Darwin.close(socketDescriptor) } + + guard fcntl(socketDescriptor, F_SETFL, O_NONBLOCK) != -1 else { return false } + + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = controlPort.bigEndian + address.sin_addr.s_addr = inet_addr("127.0.0.1") + + let connectionResult = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { socketAddress in + Darwin.connect( + socketDescriptor, + socketAddress, + socklen_t(MemoryLayout.size) + ) + } + } + + if connectionResult == 0 { + return true + } + guard errno == EINPROGRESS else { return false } + + var descriptor = pollfd(fd: socketDescriptor, events: Int16(POLLOUT), revents: 0) + guard Darwin.poll(&descriptor, 1, 100) > 0 else { return false } + + var socketError: Int32 = 0 + var socketErrorLength = socklen_t(MemoryLayout.size) + guard Darwin.getsockopt( + socketDescriptor, + SOL_SOCKET, + SO_ERROR, + &socketError, + &socketErrorLength + ) == 0 else { return false } + + return socketError == 0 + } + + private func authCookieURL(configuration: TorConfiguration) -> URL? { + configuration.dataDirectory?.appendingPathComponent("control_auth_cookie", isDirectory: false) + } + + private func removeAuthCookie(configuration: TorConfiguration) { + guard let url = authCookieURL(configuration: configuration) else { return } + try? FileManager.default.removeItem(at: url) + } + + private func readAuthCookie(configuration: TorConfiguration) -> Data? { + guard let url = authCookieURL(configuration: configuration) else { return nil } + return try? Data(contentsOf: url) + } } diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 14e100c27c..372f81861e 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -14,12 +14,11 @@ "lint-ci": "npm run lint:no-fix", "lint-staged": "lint-staged --no-stash", "gen": "plop", - "prepare-backend-assets": "mkdir -p ./nodejs-assets/nodejs-project && cp ../backend/lib/bundle.cjs ./nodejs-assets/nodejs-project/bundle.cjs", "patch-react-native-file-logger": "patch -f -p0 --forward --binary < ./patch/rnfl/build.gradle.patch || true && patch -f -p0 --forward --binary < ./patch/rnfl/AndroidManifest.xml.patch || true", "patch-state-manager": "node -e \"if (process.env.NODE_ENV !== 'production'){process.exit(1)} \" || patch -f -d ../state-manager -p0 < ./factory-girl.patch || true", "patch-react-native": "patch -f -p0 --forward --binary < ./react-native.patch || true", "patch-webview-crypto": "patch -f -p0 --forward --binary < ./react-native-webview-crypto.patch || true", - "prepare": "npm run prepare-backend-assets && npm run patch-react-native-file-logger && npm run patch-state-manager && npm run patch-react-native && npm run patch-webview-crypto && npm run build", + "prepare": "npm run patch-react-native-file-logger && npm run patch-state-manager && npm run patch-react-native && npm run patch-webview-crypto && npm run build", "version": "react-native-version --skip-tag" }, "dependencies": {