From 9517f0876be58c00f745c6c514778e657590a99e Mon Sep 17 00:00:00 2001 From: Lucas Leblow Date: Wed, 13 Mar 2024 15:13:35 -0700 Subject: [PATCH] fix: Add retry ability to tor-control and update Tor port on resume (#2360) We were getting an uncaught ECONNREFUSED exception on iOS due to the Tor control port not getting updated after the app has been resumed on iOS, so this commit fixes that and refactors the tor-control code in order to add retry ability and fix misc issues with that code. --- CHANGELOG.md | 1 + packages/backend/src/backendManager.ts | 5 +- .../src/nest/tor/tor-control.service.ts | 101 +++++++++++------- packages/mobile/ios/Quiet/AppDelegate.m | 84 +++++++-------- 4 files changed, 110 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 218a0781d2..4e415d9c27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ * Prevent channel creation with names that start with special character, then a hyphen * Choose random ports for Tor services (iOS) * Use consistent identicons for messages and profile +* Add retry ability to tor-control and misc tor-control fixes # Other: diff --git a/packages/backend/src/backendManager.ts b/packages/backend/src/backendManager.ts index abe94f7647..5862e47218 100644 --- a/packages/backend/src/backendManager.ts +++ b/packages/backend/src/backendManager.ts @@ -114,8 +114,9 @@ export const runBackendMobile = async () => { }) rn_bridge.channel.on('open', async (msg: OpenServices) => { const connectionsManager = app.get(ConnectionsManagerService) - const torControlParams = app.get(TorControl) - torControlParams.torControlParams.auth.value = msg.authCookie + const torControl = app.get(TorControl) + torControl.torControlParams.port = msg.torControlPort + torControl.torControlParams.auth.value = msg.authCookie await connectionsManager.openSocket() }) } diff --git a/packages/backend/src/nest/tor/tor-control.service.ts b/packages/backend/src/nest/tor/tor-control.service.ts index 5fa1426bea..b83040c7a4 100644 --- a/packages/backend/src/nest/tor/tor-control.service.ts +++ b/packages/backend/src/nest/tor/tor-control.service.ts @@ -8,12 +8,16 @@ import Logger from '../common/logger' @Injectable() export class TorControl { connection: net.Socket | null + isSending: boolean authString: string private readonly logger = Logger(TorControl.name) + constructor( @Inject(TOR_CONTROL_PARAMS) public torControlParams: TorControlParams, @Inject(CONFIG_OPTIONS) public configOptions: ConfigOptions - ) {} + ) { + this.isSending = false + } private updateAuthString() { if (this.torControlParams.auth.type === TorControlAuthType.PASSWORD) { @@ -25,74 +29,97 @@ export class TorControl { } } - private async connect(): Promise { - return await new Promise((resolve, reject) => { - if (this.connection) { - reject(new Error('TOR: Connection already established')) - } - + private async _connect(): Promise { + return new Promise((resolve, reject) => { this.connection = net.connect({ port: this.torControlParams.port, family: 4, }) this.connection.once('error', err => { - reject(new Error(`TOR: Connection via tor control failed: ${err.message}`)) + this.disconnect() + reject(new Error(`Connection via Tor control failed: ${err.message}`)) }) + this.connection.once('data', (data: any) => { if (/250 OK/.test(data.toString())) { resolve() } else { - reject(new Error(`TOR: Control port error: ${data.toString() as string}`)) + this.disconnect() + reject(new Error(`Tor Control port error: ${data.toString() as string}`)) } }) + this.updateAuthString() this.connection.write(this.authString) }) } + private async connect(): Promise { + // TODO: We may want to limit the number of connection attempts. + // eslint-disable-next-line no-constant-condition + while (true) { + try { + this.logger(`Connecting to Tor, host: ${this.torControlParams.host} port: ${this.torControlParams.port}`) + await this._connect() + this.logger('Tor connected') + return + } catch (e) { + this.logger(e) + this.logger('Retrying...') + await new Promise(r => setTimeout(r, 500)) + } + } + } + private disconnect() { try { this.connection?.end() } catch (e) { - this.logger.error('Cant disconnect', e.message) + this.logger.error('Disconnect failed:', e.message) } this.connection = null } - // eslint-disable-next-line @typescript-eslint/ban-types - private async _sendCommand(command: string, resolve: Function, reject: Function) { - await this.connect() + public _sendCommand(command: string): Promise<{ code: number; messages: string[] }> { + return new Promise((resolve, reject) => { + const connectionTimeout = setTimeout(() => { + reject('Timeout while sending command to Tor') + }, 5000) + + this.connection?.on('data', async data => { + const dataArray = data.toString().split(/\r?\n/) - const connectionTimeout = setTimeout(() => { - reject('TOR: Send command timeout') - }, 5000) - this.connection?.on('data', async data => { - this.disconnect() - const dataArray = data.toString().split(/\r?\n/) - if (dataArray[0].startsWith('250')) { - resolve({ code: 250, messages: dataArray }) - } else { + if (dataArray[0].startsWith('250')) { + resolve({ code: 250, messages: dataArray }) + } else { + clearTimeout(connectionTimeout) + reject(`${dataArray[0]}`) + } clearTimeout(connectionTimeout) - reject(`${dataArray[0]}`) - } - clearTimeout(connectionTimeout) + }) + + this.connection?.write(command + '\r\n') }) - this.connection?.write(command + '\r\n') } public async sendCommand(command: string): Promise<{ code: number; messages: string[] }> { - await this.waitForDisconnect() - return await new Promise((resolve, reject) => { - void this._sendCommand(command, resolve, reject) - }) - } + // Only send one command at a time. + if (this.isSending) { + this.logger('Tor connection already established, waiting...') + } - private async waitForDisconnect() { - await new Promise(resolve => { - if (!this.connection) { - resolve() - } - }) + // Wait for existing command to finish. + while (this.isSending) { + await new Promise(r => setTimeout(r, 750)) + } + + this.isSending = true + await this.connect() + // FIXME: Errors are not caught here. Is this what we want? + const res = await this._sendCommand(command) + this.disconnect() + this.isSending = false + return res } } diff --git a/packages/mobile/ios/Quiet/AppDelegate.m b/packages/mobile/ios/Quiet/AppDelegate.m index 564f23e6e7..733bdcc4b2 100644 --- a/packages/mobile/ios/Quiet/AppDelegate.m +++ b/packages/mobile/ios/Quiet/AppDelegate.m @@ -31,12 +31,12 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:( // You can add your custom initial props in the dictionary below. // They will be passed down to the ViewController used by React Native. self.initialProps = @{}; - + // Call only once per nodejs thread [self createDataDirectory]; - + [self spinupBackend:true]; - + return [super application:application didFinishLaunchingWithOptions:launchOptions]; }; @@ -60,65 +60,65 @@ - (void) initWebsocketConnection { } - (void) spinupBackend:(BOOL)init { - + // (1/6) Find ports to use in tor and backend configuration - + Utils *utils = [Utils new]; - + if (self.socketIOSecret == nil) { self.socketIOSecret = [utils generateSecretWithLength:(20)]; } - + FindFreePort *findFreePort = [FindFreePort new]; - + self.dataPort = [findFreePort getFirstStartingFromPort:11000]; - + uint16_t socksPort = [findFreePort getFirstStartingFromPort:arc4random_uniform(65535 - 1024) + 1024]; uint16_t controlPort = [findFreePort getFirstStartingFromPort:arc4random_uniform(65535 - 1024) + 1024]; uint16_t httpTunnelPort = [findFreePort getFirstStartingFromPort:arc4random_uniform(65535 - 1024) + 1024]; - - + + // (2/6) Spawn tor with proper configuration - + 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]; }); - - + + // (4/6) 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); }]; }); - - + + // (5/6) Update data port information and broadcast it to frontend if (init) { [self initWebsocketConnection]; } - - - // (6/6) Launch backend or reviwe services - + + + // (6/6) Launch backend or rewire services + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ NSString *authCookie = [self getAuthCookie]; @@ -126,28 +126,28 @@ - (void) spinupBackend:(BOOL)init { if (init) { [self launchBackend:controlPort :httpTunnelPort :authCookie]; } else { - [self reviweServices:controlPort :httpTunnelPort : authCookie]; + [self rewireServices:controlPort :httpTunnelPort : authCookie]; } }); } - (NSString *) getAuthCookie { NSString *authCookie = [self.tor getAuthCookieWithConfiguration:self.torConfiguration]; - + while (authCookie == nil) { authCookie = [self.tor getAuthCookieWithConfiguration:self.torConfiguration]; }; - + return authCookie; } - (NSData *) getAuthCookieData { NSData *authCookie = [self.tor getAuthCookieDataWithConfiguration:self.torConfiguration]; - + while (authCookie == nil) { authCookie = [self.tor getAuthCookieDataWithConfiguration:self.torConfiguration]; }; - + return authCookie; } @@ -156,13 +156,13 @@ - (void) launchBackend:(uint16_t)controlPort:(uint16_t)httpTunnelPort:(NSString [self.nodeJsMobile callStartNodeProject:[NSString stringWithFormat:@"bundle.cjs --dataPort %hu --dataPath %@ --controlPort %hu --httpTunnelPort %hu --authCookie %@ --platform %@ --socketIOSecret %@", self.dataPort, self.dataPath, controlPort, httpTunnelPort, authCookie, platform, self.socketIOSecret]]; } -- (void) reviweServices:(uint16_t)controlPort:(uint16_t)httpTunnelPort:(NSString *)authCookie { +- (void) rewireServices:(uint16_t)controlPort:(uint16_t)httpTunnelPort:(NSString *)authCookie { NSString * dataPortPayload = [NSString stringWithFormat:@"%@:%hu", @"socketIOPort", self.dataPort]; NSString * socketIOSecretPayload = [NSString stringWithFormat:@"%@:%@", @"socketIOSecret", self.socketIOSecret]; NSString * controlPortPayload = [NSString stringWithFormat:@"%@:%hu", @"torControlPort", controlPort]; NSString * httpTunnelPortPayload = [NSString stringWithFormat:@"%@:%hu", @"httpTunnelPort", httpTunnelPort]; NSString * authCookiePayload = [NSString stringWithFormat:@"%@:%@", @"authCookie", authCookie]; - + NSString * payload = [NSString stringWithFormat:@"%@|%@|%@|%@|%@", dataPortPayload, socketIOSecretPayload, controlPortPayload, httpTunnelPortPayload, authCookiePayload]; [self.nodeJsMobile sendMessageToNode:@"open":payload]; } @@ -171,16 +171,16 @@ - (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); - + NSDictionary *userInfo = [NSDictionary dictionaryWithObjectsAndKeys:message, NSLocalizedDescriptionKey, nil]; BOOL success = (code == TORControlReplyCodeOK && [message isEqualToString:@"OK"]); @@ -192,10 +192,10 @@ - (void) stopTor { - (void)applicationDidEnterBackground:(UIApplication *)application { [self stopTor]; - + NSString * message = [NSString stringWithFormat:@""]; [self.nodeJsMobile sendMessageToNode:@"close":message]; - + // Flush persistor before app goes idle dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ NSTimeInterval delayInSeconds = 0; @@ -216,7 +216,7 @@ - (void)applicationWillEnterForeground:(UIApplication *)application [[self.bridge moduleForName:@"CommunicationModule"] appResume]; }); }); - + [self spinupBackend:false]; }