/** * Copyright (c) Meta Platforms, Inc. and 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 {IncomingMessage} from 'http'; import ServerAdapter from './ServerAdapter'; import WebSocket, { AddressInfo, Server as WSServer, VerifyClientCallbackSync, } from 'ws'; import {createServer as createHttpsServer} from 'https'; import {createServer as createHttpServer} from 'http'; import querystring from 'querystring'; import {ClientQuery, UnableToExtractClientQueryError} from 'flipper-common'; import { assertNotNull, parseClientQuery, parseMessageToJson, verifyClientQueryComesFromCertExchangeSupportedOS, } from './Utilities'; import {SecureServerConfig} from '../utils/certificateUtils'; import {Server} from 'net'; import {serializeError} from 'serialize-error'; import {WSCloseCode} from '../utils/WSCloseCode'; export interface ConnectionCtx { clientQuery?: ClientQuery; ws: WebSocket; request: IncomingMessage; } // based on https://github.com/websockets/ws/blob/master/lib/websocket-server.js#L40, // exposed to share with socket.io defaults export const WEBSOCKET_MAX_MESSAGE_SIZE = 100 * 1024 * 1024; /** * It serves as a base class for WebSocket based servers. It delegates the 'connection' * event to subclasses as a customisation point. */ class ServerWebSocket extends ServerAdapter { protected wsServer?: WSServer; private httpServer?: Server; async start(port: number, sslConfig?: SecureServerConfig): Promise { const assignedPort = await new Promise((resolve, reject) => { const server = sslConfig ? createHttpsServer(sslConfig) : createHttpServer(); const wsServer = new WSServer({ server, verifyClient: this.verifyClient(), maxPayload: WEBSOCKET_MAX_MESSAGE_SIZE, }); // We do not need to listen to http server's `error` because it is propagated to WS // https://github.com/websockets/ws/blob/a3a22e4ed39c1a3be8e727e9c630dd440edc61dd/lib/websocket-server.js#L109 const onConnectionError = (error: Error) => { reject( new Error( `Unable to start server at port ${port} due to ${JSON.stringify( serializeError(error), )}`, ), ); }; wsServer.once('error', onConnectionError); server.listen(port, () => { console.debug( `${sslConfig ? 'Secure' : 'Insecure'} server started on port ${port}`, 'server', ); // Unsubscribe connection error listener. We'll attach a permanent error listener later wsServer.off('error', onConnectionError); this.listener.onListening(port); this.wsServer = wsServer; this.httpServer = server; resolve((server.address() as AddressInfo).port); }); }); assertNotNull(this.wsServer); assertNotNull(this.httpServer); this.wsServer.on( 'connection', (ws: WebSocket, request: IncomingMessage) => { ws.on('error', (error) => { console.error('[conn] WS connection error:', error); this.listener.onError(error); }); try { this.onConnection(ws, request); } catch (error) { // TODO: Investigate if we need to close the socket in the `error` listener // DRI: @aigoncharov ws.close(WSCloseCode.InternalError); if (error instanceof UnableToExtractClientQueryError) { // If we are unable to extract the client query, do not emit an error. // It cannot be determined if the client is legitimately trying to establish // a connection with Flipper or some other process is trying to connect to // the port currently used by Flipper. return; } // If an exception is thrown, an `error` event is not emitted automatically. // We need to explicitly handle the error and emit an error manually. // If we leave it unhanled, the process just dies // https://replit.com/@aigoncharov/WS-error-handling#index.js ws.emit('error', error); } }, ); this.wsServer.on('error', (error) => { console.error('[conn] WS server error:', error); this.listener.onError(error); }); return assignedPort; } async stop(): Promise { if (!this.wsServer) { return; } await new Promise((resolve, reject) => { console.info('[conn] Stopping WS server'); assertNotNull(this.wsServer); this.wsServer.close((err) => { if (err) { reject(err); return; } resolve(); }); }); await new Promise((resolve, reject) => { console.info('[conn] Stopping HTTP server'); assertNotNull(this.httpServer); this.httpServer.close((err) => { if (err) { reject(err); return; } resolve(); }); }); } /** * A connection has been established between the server and a client. Only ever used for * certificate exchange. * * @param ws An active WebSocket. * @param request Incoming request message. */ onConnection(ws: WebSocket, request: IncomingMessage): void { const ctx: ConnectionCtx = {ws, request}; this.handleClientQuery(ctx); this.handleConnectionAttempt(ctx); ws.on('message', async (message: WebSocket.RawData) => { try { const messageString = message.toString(); const parsedMessage = this.handleMessageDeserialization(messageString); // Successful deserialization is a proof that the message is a string this.handleMessage(ctx, parsedMessage, messageString); } catch (error) { // See the reasoning in the error handler for a `connection` event ws.emit('error', error); ws.close(WSCloseCode.InternalError); } }); } protected handleClientQuery(ctx: ConnectionCtx): void { const {request} = ctx; const query = querystring.decode(request.url!.split('?')[1]); const clientQuery = this.parseClientQuery(query); if (!clientQuery) { console.warn( '[conn] Unable to extract the client query from the request URL.', request.url, ); throw new UnableToExtractClientQueryError( '[conn] Unable to extract the client query from the request URL.', ); } ctx.clientQuery = clientQuery; } protected handleConnectionAttempt(ctx: ConnectionCtx): void { const {clientQuery} = ctx; assertNotNull(clientQuery); console.info( `[conn] Insecure websocket connection attempt: ${clientQuery.app} on ${clientQuery.device_id}.`, ); this.listener.onConnectionAttempt(clientQuery); } protected handleMessageDeserialization(message: unknown): object { const parsedMessage = parseMessageToJson(message); if (!parsedMessage) { console.error('[conn] Failed to parse message', message); // TODO: Create custom DeserializationError throw new Error(`[conn] Failed to parse message`); } return parsedMessage; } protected async handleMessage( ctx: ConnectionCtx, parsedMessage: object, // Not used in this method, but left as a reference for overriding classes _rawMessage: string, ) { const {clientQuery, ws} = ctx; assertNotNull(clientQuery); const response = await this._onHandleUntrustedMessage( clientQuery, parsedMessage, ); if (response) { ws.send(response); } } /** * Parse and extract a ClientQuery instance from a message. The ClientQuery * data will be contained in the message url query string. * @param message An incoming web socket message. */ protected parseClientQuery( query: querystring.ParsedUrlQuery, ): ClientQuery | undefined { return verifyClientQueryComesFromCertExchangeSupportedOS( parseClientQuery(query), ); } /** * WebSocket client verification. Usually used to validate the origin. * * Base implementation simply returns true, but this can be overriden by subclasses * that require verification. * * @returns Return true if the client was successfully verified, otherwise * returns false. */ protected verifyClient(): VerifyClientCallbackSync { return (_info: {origin: string; req: IncomingMessage; secure: boolean}) => { // Client verification is not necessary. The connected client has // already been verified using its certificate signed by the server. return true; }; } } export default ServerWebSocket;