/** * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @format */ import {CertificateExchangeMedium} from '../utils/CertificateProvider'; import {Logger} from '../../fb-interfaces/Logger'; import {ClientDescription, ClientQuery} from 'flipper-plugin'; import {Store} from '../../reducers/index'; import CertificateProvider from '../utils/CertificateProvider'; import {ClientConnection, ConnectionStatus} from './ClientConnection'; import {UninitializedClient} from '../UninitializedClient'; import {reportPlatformFailures} from '../../utils/metrics'; import {EventEmitter} from 'events'; import invariant from 'invariant'; import GK from '../../fb-stubs/GK'; import {buildClientId} from '../../utils/clientUtils'; import DummyDevice from '../../server/devices/DummyDevice'; import { appNameWithUpdateHint, transformCertificateExchangeMediumToType, } from './Utilities'; import ServerAdapter, { SecureClientQuery, ServerEventsListener, } from './ServerAdapter'; import { createBrowserServer, createServer, TransportType, } from './ServerFactory'; import {FlipperServerImpl} from '../FlipperServerImpl'; import {isTest} from '../../utils/isProduction'; type ClientInfo = { connection: ClientConnection | null | undefined; client: ClientDescription; }; type ClientCsrQuery = { csr?: string | undefined; csr_path?: string | undefined; }; declare interface ServerController { on(event: 'error', callback: (err: Error) => void): this; } /** * Responsible of creating and managing the actual underlying servers: * - Insecure (used for certificate exchange) * - Secure (used for secure communication between the client and server) * - Browser (only ever used between Desktop and a local Browser) * * Additionally, it manages client connections. */ class ServerController extends EventEmitter implements ServerEventsListener { connections: Map; initialized: Promise | null; secureServer: Promise | null; insecureServer: Promise | null; altSecureServer: Promise | null; altInsecureServer: Promise | null; browserServer: Promise | null; certificateProvider: CertificateProvider; connectionTracker: ConnectionTracker; flipperServer: FlipperServerImpl; timeHandlers: Map = new Map(); constructor(flipperServer: FlipperServerImpl) { super(); this.flipperServer = flipperServer; this.connections = new Map(); this.certificateProvider = new CertificateProvider( this, this.logger, this.store.getState().settingsState, ); this.connectionTracker = new ConnectionTracker(this.logger); this.secureServer = null; this.insecureServer = null; this.altSecureServer = null; this.altInsecureServer = null; this.browserServer = null; this.initialized = null; } onClientMessage(clientId: string, payload: string): void { this.flipperServer.emit('client-message', { id: clientId, message: payload, }); } get logger(): Logger { return this.flipperServer.logger; } /** * @deprecated */ get store(): Store { return this.flipperServer.store; } /** * Loads the secure server configuration and starts any necessary servers. * Initialisation is complete once the initialized promise is fullfilled at * which point Flipper is accepting connections. */ init() { if (isTest()) { throw new Error('Spawing new server is not supported in test'); } const {insecure, secure} = this.store.getState().application.serverPorts; this.initialized = this.certificateProvider .loadSecureServerConfig() .then((options) => { console.info('[conn] secure server listening at port: ', secure); this.secureServer = createServer(secure, this, options); if (GK.get('flipper_websocket_server')) { const {secure: altSecure} = this.store.getState().application.altServerPorts; console.info( '[conn] secure server (ws) listening at port: ', altSecure, ); this.altSecureServer = createServer( altSecure, this, options, TransportType.WebSocket, ); } }) .then(() => { console.info('[conn] insecure server listening at port: ', insecure); this.insecureServer = createServer(insecure, this); if (GK.get('flipper_websocket_server')) { const {insecure: altInsecure} = this.store.getState().application.altServerPorts; console.info( '[conn] insecure server (ws) listening at port: ', altInsecure, ); this.altInsecureServer = createServer( altInsecure, this, undefined, TransportType.WebSocket, ); } return; }); if (GK.get('comet_enable_flipper_connection')) { this.browserServer = createBrowserServer(8333, this); } reportPlatformFailures(this.initialized, 'initializeServer'); return this.initialized; } /** * If initialized, it stops any started servers. */ async close() { if (this.initialized && (await this.initialized)) { await Promise.all([ this.insecureServer && (await this.insecureServer).stop(), this.secureServer && (await this.secureServer).stop(), this.altInsecureServer && (await this.altInsecureServer).stop(), this.altSecureServer && (await this.altSecureServer).stop(), this.browserServer && (await this.browserServer).stop(), ]); } } onConnectionCreated( clientQuery: SecureClientQuery, clientConnection: ClientConnection, ): Promise { const {app, os, device, device_id, sdk_version, csr, csr_path, medium} = clientQuery; const transformedMedium = transformCertificateExchangeMediumToType(medium); console.info( `[conn] Connection established: ${app} on ${device_id}. Medium ${medium}. CSR: ${csr_path}`, clientQuery, ); return this.addConnection( clientConnection, { app, os, device, device_id, sdk_version, medium: transformedMedium, }, {csr, csr_path}, ); } onConnectionClosed(clientId: string) { this.removeConnection(clientId); } onListening(port: number): void { this.emit('listening', port); } onSecureConnectionAttempt(clientQuery: SecureClientQuery): void { this.logger.track('usage', 'trusted-request-handler-called', clientQuery); const {os, app, device_id} = clientQuery; // without these checks, the user might see a connection timeout error instead, which would be much harder to track down if (os === 'iOS' && !this.flipperServer.config.enableIOS) { console.error( `Refusing connection from ${app} on ${device_id}, since iOS support is disabled in settings`, ); return; } if (os === 'Android' && !this.flipperServer.config.enableAndroid) { console.error( `Refusing connection from ${app} on ${device_id}, since Android support is disabled in settings`, ); return; } this.connectionTracker.logConnectionAttempt(clientQuery); if (this.timeHandlers.get(clientQueryToKey(clientQuery))) { clearTimeout(this.timeHandlers.get(clientQueryToKey(clientQuery))!); } const transformedMedium = transformCertificateExchangeMediumToType( clientQuery.medium, ); if (transformedMedium === 'WWW' || transformedMedium === 'NONE') { this.flipperServer.registerDevice( new DummyDevice( this.flipperServer, clientQuery.device_id, clientQuery.app + (transformedMedium === 'WWW' ? ' Server Exchanged' : ''), clientQuery.os, ), ); } } onConnectionAttempt(clientQuery: ClientQuery): void { this.logger.track('usage', 'untrusted-request-handler-called', clientQuery); this.connectionTracker.logConnectionAttempt(clientQuery); const client: UninitializedClient = { os: clientQuery.os, deviceName: clientQuery.device, appName: appNameWithUpdateHint(clientQuery), }; this.emit('start-client-setup', client); } onProcessCSR( unsanitizedCSR: string, clientQuery: ClientQuery, appDirectory: string, medium: CertificateExchangeMedium, ): Promise<{deviceId: string}> { return new Promise((resolve, reject) => { reportPlatformFailures( this.certificateProvider.processCertificateSigningRequest( unsanitizedCSR, clientQuery.os, appDirectory, medium, ), 'processCertificateSigningRequest', ) .then((response) => { const client: UninitializedClient = { os: clientQuery.os, deviceName: clientQuery.device, appName: appNameWithUpdateHint(clientQuery), }; // TODO: if multiple clients are establishing a connection // at the same time, then this unresponsive timeout can potentially // lead to errors. For example, client A starts connectiving followed // by client B. Client B timeHandler will override client A, thus, if // client A takes longer, then the unresponsive timeout will not be called // for it. this.timeHandlers.set( clientQueryToKey(clientQuery), setTimeout(() => { this.emit('client-unresponsive-error', { client, medium, deviceID: response.deviceId, }); }, 30 * 1000), ); resolve(response); }) .catch((error) => { reject(error); }); }); } onError(error: Error): void { this.emit('error', error); } toJSON() { return null; } /** * Creates a Client and sets the underlying connection. * @param connection A client connection to communicate between server and client. * @param query The client query created from the initial handshake. * @param csrQuery The CSR query which contains CSR related information. */ async addConnection( connection: ClientConnection, query: ClientQuery & {medium: CertificateExchangeMedium}, csrQuery: ClientCsrQuery, ): Promise { invariant(query, 'expected query'); // try to get id by comparing giving `csr` to file from `csr_path` // otherwise, use given device_id const {csr_path, csr} = csrQuery; // For Android, device id might change if (csr_path && csr && query.os === 'Android') { const app_name = await this.certificateProvider.extractAppNameFromCSR( csr, ); // TODO: allocate new object, kept now as is to keep changes minimal (query as any).device_id = await this.certificateProvider.getTargetDeviceId( query.os, app_name, csr_path, csr, ); console.info( `[conn] Detected ${app_name} on ${query.device_id} in certificate`, query, csrQuery, ); } // TODO: allocate new object, kept now as is to keep changes minimal (query as any).app = appNameWithUpdateHint(query); const id = buildClientId({ app: query.app, os: query.os, device: query.device, device_id: query.device_id, }); console.info( `[conn] Matching device for ${query.app} on ${query.device_id}...`, query, csrQuery, ); const client: ClientDescription = { id, query, }; const info = { client, connection: connection, }; console.info( `[conn] Initializing client ${query.app} on ${query.device_id}...`, query, csrQuery, ); connection.subscribeToEvents((status: ConnectionStatus) => { if ( status === ConnectionStatus.CLOSED || status === ConnectionStatus.ERROR ) { this.onConnectionClosed(client.id); } }); console.debug( `[conn] Device client initialized: ${id}.`, 'server', query, csrQuery, ); /* If a device gets disconnected without being cleaned up properly, * Flipper won't be aware until it attempts to reconnect. * When it does we need to terminate the zombie connection. */ if (this.connections.has(id)) { const connectionInfo = this.connections.get(id); if (connectionInfo) { if ( connectionInfo.connection && connectionInfo.connection !== connection ) { connectionInfo.connection.close(); this.removeConnection(id); } } } this.connections.set(id, info); this.flipperServer.emit('client-connected', client); return client; } attachFakeClient(client: ClientDescription) { this.connections.set(client.id, { client, connection: null, }); } /** * Removes a client connection by disconnecting it, if still connected * and then deleting it from the tracked connections. * @param id The client connection identifier. */ removeConnection(id: string) { const info = this.connections.get(id); if (info) { console.info( `[conn] Disconnected: ${info.client.query.app} on ${info.client.query.device_id}.`, info.client.query, ); this.flipperServer.emit('client-disconnected', {id}); this.connections.delete(id); } } } class ConnectionTracker { timeWindowMillis = 20 * 1000; connectionProblemThreshold = 4; // "${device}.${app}" -> [timestamp1, timestamp2...] connectionAttempts: Map> = new Map(); logger: Logger; constructor(logger: Logger) { this.logger = logger; } logConnectionAttempt(client: ClientQuery) { const key = `${client.os}-${client.device}-${client.app}`; const time = Date.now(); let entry = this.connectionAttempts.get(key) || []; entry.push(time); entry = entry.filter((t) => t >= time - this.timeWindowMillis); this.connectionAttempts.set(key, entry); if (entry.length >= this.connectionProblemThreshold) { console.error( `[conn] Connection loop detected with ${key}. Connected ${ this.connectionProblemThreshold } times within ${this.timeWindowMillis / 1000}s.`, 'server', client, ); } } } export default ServerController; function clientQueryToKey(clientQuery: ClientQuery): string { return `${clientQuery.app}/${clientQuery.os}/${clientQuery.device}/${clientQuery.device_id}`; }