diff --git a/desktop/app/src/Client.tsx b/desktop/app/src/Client.tsx index 96306cf32..a95c8b214 100644 --- a/desktop/app/src/Client.tsx +++ b/desktop/app/src/Client.tsx @@ -433,10 +433,7 @@ export default class Client extends EventEmitter { } onResponse( - data: { - success?: Object; - error?: ClientErrorType; - }, + data: ClientResponseType, resolve: ((a: any) => any) | undefined, reject: (error: ClientErrorType) => any, ) { diff --git a/desktop/app/src/test-utils/MockFlipper.tsx b/desktop/app/src/test-utils/MockFlipper.tsx index 393d0ed94..f5fa9b961 100644 --- a/desktop/app/src/test-utils/MockFlipper.tsx +++ b/desktop/app/src/test-utils/MockFlipper.tsx @@ -12,7 +12,12 @@ import BaseDevice from '../devices/BaseDevice'; import {createRootReducer} from '../reducers'; import {Store} from '../reducers/index'; import Client, {ClientConnection} from '../Client'; -import {Logger, buildClientId, FlipperServer} from 'flipper-common'; +import { + Logger, + buildClientId, + FlipperServer, + ClientResponseType, +} from 'flipper-common'; import {PluginDefinition} from '../plugin'; import {registerPlugins} from '../reducers/plugins'; import {getLogger} from 'flipper-common'; @@ -244,7 +249,7 @@ function createStubConnection(): ClientConnection { send(_: any) { throw new Error('Should not be called in test'); }, - sendExpectResponse(_: any): Promise { + sendExpectResponse(_: any): Promise { throw new Error('Should not be called in test'); }, }; diff --git a/desktop/flipper-common/src/server-types.tsx b/desktop/flipper-common/src/server-types.tsx index d4be2a6c9..ec71cdae1 100644 --- a/desktop/flipper-common/src/server-types.tsx +++ b/desktop/flipper-common/src/server-types.tsx @@ -25,7 +25,7 @@ export type FlipperServerState = export type DeviceType = PluginDeviceType; -export type DeviceOS = PluginOS | 'Windows' | 'MacOS'; +export type DeviceOS = PluginOS | 'Windows' | 'MacOS' | 'Browser' | 'Linux'; export type DeviceDescription = { readonly os: DeviceOS; @@ -83,11 +83,13 @@ export type ClientErrorType = { name: string; }; -export type ClientResponseType = { - success?: Object; - error?: ClientErrorType; - length: number; -}; +export type ClientResponseType = + | { + success: object | string | number | boolean | null; + error?: never; + length: number; + } + | {success?: never; error: ClientErrorType; length: number}; export type FlipperServerEvents = { 'server-state': {state: FlipperServerState; error?: Error}; @@ -258,3 +260,38 @@ export type MetroReportableEvent = | 'debug'; data: Array; }; + +// TODO: Complete message list +export type SignCertificateMessage = { + method: 'signCertificate'; + csr: string; + destination: string; + medium: number | undefined; +}; +export type GetPluginsMessage = { + id: number; + method: 'getPlugins'; +}; +export type GetBackgroundPluginsMessage = { + id: number; + method: 'getBackgroundPlugins'; +}; +export type ExecuteMessage = { + method: 'execute'; + params: { + method: string; + api: string; + params?: unknown; + }; +}; +export type ResponseMessage = + | { + id: number; + success: object | string | number | boolean | null; + error?: never; + } + | { + id: number; + success?: never; + error: ClientErrorType; + }; diff --git a/desktop/flipper-server-core/package.json b/desktop/flipper-server-core/package.json index db69e86ff..bc5c972cc 100644 --- a/desktop/flipper-server-core/package.json +++ b/desktop/flipper-server-core/package.json @@ -26,6 +26,7 @@ "rsocket-flowable": "^0.0.27", "rsocket-tcp-server": "^0.0.25", "rsocket-types": "^0.0.25", + "serialize-error": "^8.1.0", "split2": "^3.2.2", "tmp": "^0.2.1", "uuid": "^8.3.2", diff --git a/desktop/flipper-server-core/src/comms/BrowserClientConnection.tsx b/desktop/flipper-server-core/src/comms/BrowserClientConnection.tsx new file mode 100644 index 000000000..a0b8f0f0b --- /dev/null +++ b/desktop/flipper-server-core/src/comms/BrowserClientConnection.tsx @@ -0,0 +1,65 @@ +/** + * 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 { + ClientResponseType, + GetBackgroundPluginsMessage, + GetPluginsMessage, +} from 'flipper-common'; +import WebSocket from 'ws'; +import WebSocketClientConnection from './WebSocketClientConnection'; + +/** + * @deprecated + * Default `WebSocketClientConnection` should be used instead. + * See BrowserServerWebSocket.handleMessage. + */ +export class BrowserClientConnection extends WebSocketClientConnection { + public legacyFormat = false; + + private static isGetPluginsCall(data: object): data is GetPluginsMessage { + return (data as GetPluginsMessage).method === 'getPlugins'; + } + private static isGetBackgroundPluginsCall( + data: object, + ): data is GetBackgroundPluginsMessage { + return ( + (data as GetBackgroundPluginsMessage).method === 'getBackgroundPlugins' + ); + } + + constructor(ws: WebSocket, public plugins?: string[]) { + super(ws); + } + + async sendExpectResponse(data: object): Promise { + if (BrowserClientConnection.isGetPluginsCall(data) && this.plugins) { + return { + success: {plugins: this.plugins}, + length: 0, + }; + } + + if ( + BrowserClientConnection.isGetBackgroundPluginsCall(data) && + this.plugins + ) { + return { + success: {plugins: []}, + length: 0, + }; + } + + return super.sendExpectResponse(data); + } + + protected serializeData(data: object): string { + return super.serializeData(this.legacyFormat ? {payload: data} : data); + } +} diff --git a/desktop/flipper-server-core/src/comms/BrowserClientFlipperConnection.tsx b/desktop/flipper-server-core/src/comms/BrowserClientFlipperConnection.tsx deleted file mode 100644 index da3a7eefe..000000000 --- a/desktop/flipper-server-core/src/comms/BrowserClientFlipperConnection.tsx +++ /dev/null @@ -1,84 +0,0 @@ -/** - * 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 {ClientResponseType} from 'flipper-common'; -import WebSocket from 'ws'; -import { - ConnectionStatusChange, - ConnectionStatus, - ClientConnection, -} from './ClientConnection'; - -export class BrowserClientFlipperConnection implements ClientConnection { - websocket: WebSocket; - connStatusSubscribers: Set = new Set(); - connStatus: ConnectionStatus; - app: string; - plugins: string[] | undefined = undefined; - - constructor(ws: WebSocket, app: string, plugins: string[]) { - this.websocket = ws; - this.connStatus = ConnectionStatus.CONNECTED; - this.app = app; - this.plugins = plugins; - } - subscribeToEvents(subscriber: ConnectionStatusChange): void { - this.connStatusSubscribers.add(subscriber); - } - send(data: any): void { - this.websocket.send( - JSON.stringify({ - type: 'send', - app: this.app, - payload: data != null ? data : {}, - }), - ); - } - sendExpectResponse(data: any): Promise { - return new Promise((resolve, reject) => { - const {id: callId = undefined, method = undefined} = - data != null ? data : {}; - - if (method === 'getPlugins' && this.plugins != null) { - resolve({ - success: {plugins: this.plugins}, - length: 0, - }); - return; - } - - this.websocket.send( - JSON.stringify({ - type: 'call', - app: this.app, - payload: data != null ? data : {}, - }), - ); - - this.websocket.on('message', (message: string) => { - const {app, payload} = JSON.parse(message); - - if (app === this.app && payload?.id === callId) { - resolve(payload); - } - }); - this.websocket.on('error', (error: Error) => { - reject(error); - }); - }); - } - - close(): void { - this.connStatus = ConnectionStatus.CLOSED; - this.connStatusSubscribers.forEach((subscriber) => { - subscriber(this.connStatus); - }); - this.websocket.send(JSON.stringify({type: 'disconnect', app: this.app})); - } -} diff --git a/desktop/flipper-server-core/src/comms/BrowserServerWebSocket.tsx b/desktop/flipper-server-core/src/comms/BrowserServerWebSocket.tsx new file mode 100644 index 000000000..3a4d345a1 --- /dev/null +++ b/desktop/flipper-server-core/src/comms/BrowserServerWebSocket.tsx @@ -0,0 +1,155 @@ +/** + * 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 {ParsedUrlQuery} from 'querystring'; +import {BrowserClientConnection} from './BrowserClientConnection'; +import {getFlipperServerConfig} from '../FlipperServerConfig'; +import ws from 'ws'; +import {IncomingMessage} from 'http'; +import {assertNotNull, parseClientQuery} from './Utilities'; +import SecureServerWebSocket, { + SecureConnectionCtx, +} from './SecureServerWebSocket'; +import {SecureClientQuery} from './ServerAdapter'; +import {ClientDescription, DeviceOS} from 'flipper-common'; + +interface BrowserConnectionCtx extends SecureConnectionCtx { + clientConnection?: BrowserClientConnection; +} + +type LegacyWsMessage = + | { + app: string; + payload: object; + type?: never; + plugins?: never; + } + | { + app: string; + payload?: never; + type: 'connect'; + plugins?: string[]; + }; + +function isLegacyMessage(message: object): message is LegacyWsMessage { + return typeof (message as LegacyWsMessage).app === 'string'; +} + +/** + * WebSocket-based server over an insecure channel that does not support the certificate exchange flow. E.g. web browser. + */ +class BrowserServerWebSocket extends SecureServerWebSocket { + protected handleConnectionAttempt(ctx: BrowserConnectionCtx): void { + const {clientQuery, ws} = ctx; + assertNotNull(clientQuery); + + console.info( + `[conn] Local websocket connection attempt: ${clientQuery.app} on ${clientQuery.device_id}.`, + ); + this.listener.onConnectionAttempt(clientQuery); + + this.listener.onSecureConnectionAttempt(clientQuery); + + // Mock an initial empty list of plugins + // Read more o the reasoning in `handleMessage` + const clientConnection = new BrowserClientConnection(ws); + + const client: Promise = + this.listener.onConnectionCreated(clientQuery, clientConnection); + + ctx.clientConnection = clientConnection; + ctx.clientPromise = client; + } + + protected async handleMessage( + ctx: BrowserConnectionCtx, + parsedMessage: object, + rawMessage: string, + ) { + const {clientQuery, clientConnection} = ctx; + assertNotNull(clientQuery); + assertNotNull(clientConnection); + + // Remove this part once our current customers migrate to the new message structure + if (isLegacyMessage(parsedMessage)) { + if (parsedMessage.type === 'connect') { + // TODO: Show a user warning about legacy message structure and protocol. Provide them with clear instructions on how to upgrade. + + // Legacy protocol supported passing an optional list of plugins with a 'connect' message. + // Clients that pass the list of plugins this way might not suport `getPlugins` call. + // We create a special BrowserClientConnection that intercepts any `getPlugings` call if the list was passed and fakes a client reply using the list of plugins from the `connect` message. + + const plugins = parsedMessage.plugins; + clientConnection.plugins = plugins; + clientConnection.legacyFormat = true; + + if (plugins) { + // Client connection was initialized without a list of plugins. + // Upon initialization it sent a `getPlugins` request. + // We find that request and resolve it with the list of plugins we received from the `connect` message + const getPluginsCallbacks = clientConnection.matchPendingRequest(0); + getPluginsCallbacks.resolve({ + success: {plugins}, + length: rawMessage.length, + }); + } + + return; + } + + // Legacy messages wrap the actual message content with { app: string, payload: object }. + // This way we normalize them to the current message format which does not require that wrapper. + parsedMessage = parsedMessage.payload; + rawMessage = JSON.stringify(parsedMessage); + } + + super.handleMessage(ctx, parsedMessage, rawMessage); + } + + protected parseClientQuery( + query: ParsedUrlQuery, + ): SecureClientQuery | undefined { + // Some legacy clients send only deviceId and device + // Remove it once they fix it + // P463066994 + const fallbackOS: DeviceOS = 'MacOS'; + const fallbackApp = query.device; + const fallbackDeviceId = query.deviceId; + const fallbackSdkVersion = '4'; + query = { + app: fallbackApp, + os: fallbackOS, + device_id: fallbackDeviceId, + sdk_version: fallbackSdkVersion, + ...query, + }; + + const parsedBaseQuery = parseClientQuery(query); + if (!parsedBaseQuery) { + return; + } + return {...parsedBaseQuery, medium: 3}; + } + + protected verifyClient(): ws.VerifyClientCallbackSync { + return (info: {origin: string; req: IncomingMessage; secure: boolean}) => { + const ok = getFlipperServerConfig().validWebSocketOrigins.some( + (validPrefix) => info.origin.startsWith(validPrefix), + ); + if (!ok) { + console.warn( + `[conn] Refused webSocket connection from ${info.origin} (secure: ${info.secure})`, + ); + } + return ok; + }; + } +} + +export default BrowserServerWebSocket; diff --git a/desktop/flipper-server-core/src/comms/ClientConnection.tsx b/desktop/flipper-server-core/src/comms/ClientConnection.tsx index 9d8339166..f043e07dc 100644 --- a/desktop/flipper-server-core/src/comms/ClientConnection.tsx +++ b/desktop/flipper-server-core/src/comms/ClientConnection.tsx @@ -9,6 +9,11 @@ import {ClientResponseType} from 'flipper-common'; +export interface PendingRequestResolvers { + resolve: (data: ClientResponseType) => void; + reject: (err: Error) => void; +} + export enum ConnectionStatus { ERROR = 'error', CLOSED = 'closed', @@ -22,6 +27,6 @@ export type ConnectionStatusChange = (status: ConnectionStatus) => void; export interface ClientConnection { subscribeToEvents(subscriber: ConnectionStatusChange): void; close(): void; - send(data: any): void; - sendExpectResponse(data: any): Promise; + send(data: object): void; + sendExpectResponse(data: object): Promise; } diff --git a/desktop/flipper-server-core/src/comms/SecureServerWebSocket.tsx b/desktop/flipper-server-core/src/comms/SecureServerWebSocket.tsx new file mode 100644 index 000000000..e0a586345 --- /dev/null +++ b/desktop/flipper-server-core/src/comms/SecureServerWebSocket.tsx @@ -0,0 +1,135 @@ +/** + * 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 ServerWebSocket, {ConnectionCtx} from './ServerWebSocket'; +import {SecureClientQuery} from './ServerAdapter'; +import {ParsedUrlQuery} from 'querystring'; +import {ClientDescription} from 'flipper-common'; +import { + isWsResponseMessage, + parseSecureClientQuery, + assertNotNull, +} from './Utilities'; +import WebSocketClientConnection from './WebSocketClientConnection'; +import {serializeError} from 'serialize-error'; +import {WSCloseCode} from '../utils/WSCloseCode'; + +export interface SecureConnectionCtx extends ConnectionCtx { + clientQuery?: SecureClientQuery; + clientConnection?: WebSocketClientConnection; + clientPromise?: Promise; + client?: ClientDescription; +} + +/** + * WebSocket-based server. + * A secure connection has been established between the server and a client. Once a client + * has a valid certificate, it can use a secure connection with Flipper and start exchanging + * messages. + * https://fbflipper.com/docs/extending/new-clients + * https://fbflipper.com/docs/extending/establishing-a-connection + */ +class SecureServerWebSocket extends ServerWebSocket { + protected handleConnectionAttempt(ctx: SecureConnectionCtx): void { + const {clientQuery, ws} = ctx; + assertNotNull(clientQuery); + + console.info( + `[conn] Secure websocket connection attempt: ${clientQuery.app} on ${clientQuery.device_id}. Medium ${clientQuery.medium}. CSR: ${clientQuery.csr_path}`, + ); + this.listener.onSecureConnectionAttempt(clientQuery); + + const clientConnection = new WebSocketClientConnection(ws); + + // TODO: Could we just await it here? How much time could it be, potentially? + // DRI: @aigoncharov + const clientPromise: Promise = this.listener + .onConnectionCreated(clientQuery, clientConnection) + .then((client) => { + ctx.client = client; + return client; + }) + .catch((e) => { + throw new Error( + `Failed to resolve client ${clientQuery.app} on ${ + clientQuery.device_id + } medium ${clientQuery.medium}. Reason: ${JSON.stringify( + serializeError(e), + )}`, + ); + }); + + ctx.clientConnection = clientConnection; + ctx.clientPromise = clientPromise; + } + + protected async handleMessage( + ctx: SecureConnectionCtx, + parsedMessage: object, + rawMessage: string, + ) { + const {clientQuery, clientConnection, clientPromise, client, ws} = ctx; + assertNotNull(clientQuery); + assertNotNull(clientConnection); + assertNotNull(clientPromise); + + // We can recieve either "execute" messages from the client or "responses" to our messages + // https://fbflipper.com/docs/extending/new-clients#responding-to-messages + + // Received a response message + if (isWsResponseMessage(parsedMessage)) { + const callbacks = clientConnection.matchPendingRequest(parsedMessage.id); + + if (parsedMessage.success !== undefined) { + callbacks.resolve({ + ...parsedMessage, + length: rawMessage.length, + }); + return; + } + + callbacks.reject(parsedMessage.error); + return; + } + + // Received an "execute" message + + if (client) { + this.listener.onClientMessage(client.id, rawMessage); + } else { + // Client promise is not resolved yet + // So we schedule the execution for when it is resolved + clientPromise + .then((client) => { + this.listener.onClientMessage(client.id, rawMessage); + }) + .catch((error) => { + // It is an async action, which might run after the socket is closed + if (ws.readyState === ws.OPEN) { + // See the reasoning in the error handler for a `connection` event in ServerWebSocket + ws.emit('error', error); + ws.close(WSCloseCode.InternalError); + } + }); + } + } + + /** + * Parse and extract a SecureClientQuery 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: ParsedUrlQuery, + ): SecureClientQuery | undefined { + return parseSecureClientQuery(query); + } +} + +export default SecureServerWebSocket; diff --git a/desktop/flipper-server-core/src/comms/ServerAdapter.tsx b/desktop/flipper-server-core/src/comms/ServerAdapter.tsx index 454d5e0b3..37945ec76 100644 --- a/desktop/flipper-server-core/src/comms/ServerAdapter.tsx +++ b/desktop/flipper-server-core/src/comms/ServerAdapter.tsx @@ -13,7 +13,11 @@ import { } from '../utils/CertificateProvider'; import {ClientConnection} from './ClientConnection'; import {transformCertificateExchangeMediumToType} from './Utilities'; -import {ClientDescription, ClientQuery} from 'flipper-common'; +import { + ClientDescription, + ClientQuery, + SignCertificateMessage, +} from 'flipper-common'; /** * ClientCsrQuery defines a client query with CSR @@ -86,6 +90,7 @@ export interface ServerEventsListener { onConnectionCreated( clientQuery: SecureClientQuery, clientConnection: ClientConnection, + downgrade?: boolean, ): Promise; /** * A connection with a client has been closed. @@ -109,22 +114,18 @@ export interface ServerEventsListener { * RSocket, WebSocket, etc. */ abstract class ServerAdapter { - listener: ServerEventsListener; - - constructor(listener: ServerEventsListener) { - this.listener = listener; - } + constructor(protected listener: ServerEventsListener) {} /** * Start and bind server to the specified port. - * @param port A port number. + * @param port A port number. Pass 0 to get a random free port. + * https://stackoverflow.com/a/28050404 * @param sslConfig An optional SSL configuration to be used for * TLS servers. + * + * @returns An assigned port number */ - abstract start( - port: number, - sslConfig?: SecureServerConfig, - ): Promise; + abstract start(port: number, sslConfig?: SecureServerConfig): Promise; /** * Stop the server. */ @@ -140,7 +141,7 @@ abstract class ServerAdapter { * request is to sign a certificate and no errors were found, the response * should contain the device identifier to use by the client. */ - async _onHandleUntrustedMessage( + protected async _onHandleUntrustedMessage( clientQuery: ClientQuery, rawData: any, ): Promise { @@ -148,12 +149,7 @@ abstract class ServerAdapter { // This is not an issue for internal FB users, as Flipper release // is insync with client SDK through launcher. - const message: { - method: 'signCertificate'; - csr: string; - destination: string; - medium: number | undefined; - } = rawData; + const message: SignCertificateMessage = rawData; console.info( `[conn] Connection attempt: ${clientQuery.app} on ${clientQuery.device}, medium: ${message.medium}, cert: ${message.destination}`, diff --git a/desktop/flipper-server-core/src/comms/ServerController.tsx b/desktop/flipper-server-core/src/comms/ServerController.tsx index 6919b62c2..322ddf739 100644 --- a/desktop/flipper-server-core/src/comms/ServerController.tsx +++ b/desktop/flipper-server-core/src/comms/ServerController.tsx @@ -8,18 +8,18 @@ */ import {CertificateExchangeMedium} from '../utils/CertificateProvider'; -import {Logger} from 'flipper-common'; import { ClientDescription, ClientQuery, isTest, GK, buildClientId, + Logger, + UninitializedClient, + reportPlatformFailures, } from 'flipper-common'; import CertificateProvider from '../utils/CertificateProvider'; import {ClientConnection, ConnectionStatus} from './ClientConnection'; -import {UninitializedClient} from 'flipper-common'; -import {reportPlatformFailures} from 'flipper-common'; import {EventEmitter} from 'events'; import invariant from 'invariant'; import DummyDevice from '../devices/DummyDevice'; @@ -161,6 +161,7 @@ class ServerController extends EventEmitter implements ServerEventsListener { }); if (GK.get('comet_enable_flipper_connection')) { + console.info('[conn] Browser server (ws) listening at port: ', 8333); this.browserServer = createBrowserServer(8333, this); } @@ -187,6 +188,7 @@ class ServerController extends EventEmitter implements ServerEventsListener { onConnectionCreated( clientQuery: SecureClientQuery, clientConnection: ClientConnection, + downgrade: boolean, ): Promise { const {app, os, device, device_id, sdk_version, csr, csr_path, medium} = clientQuery; @@ -206,6 +208,7 @@ class ServerController extends EventEmitter implements ServerEventsListener { medium: transformedMedium, }, {csr, csr_path}, + downgrade, ); } @@ -334,6 +337,7 @@ class ServerController extends EventEmitter implements ServerEventsListener { connection: ClientConnection, query: ClientQuery & {medium: CertificateExchangeMedium}, csrQuery: ClientCsrQuery, + silentReplace?: boolean, ): Promise { invariant(query, 'expected query'); @@ -411,7 +415,9 @@ class ServerController extends EventEmitter implements ServerEventsListener { connectionInfo.connection && connectionInfo.connection !== connection ) { - connectionInfo.connection.close(); + if (!silentReplace) { + connectionInfo.connection.close(); + } this.removeConnection(id); } } diff --git a/desktop/flipper-server-core/src/comms/ServerFactory.tsx b/desktop/flipper-server-core/src/comms/ServerFactory.tsx index 348bfc3a2..4c0e8a562 100644 --- a/desktop/flipper-server-core/src/comms/ServerFactory.tsx +++ b/desktop/flipper-server-core/src/comms/ServerFactory.tsx @@ -10,8 +10,9 @@ import {SecureServerConfig} from '../utils/CertificateProvider'; import ServerAdapter, {ServerEventsListener} from './ServerAdapter'; import ServerRSocket from './ServerRSocket'; +import SecureServerWebSocket from './SecureServerWebSocket'; +import BrowserServerWebSocket from './BrowserServerWebSocket'; import ServerWebSocket from './ServerWebSocket'; -import ServerWebSocketBrowser from './ServerWebSocketBrowser'; export enum TransportType { RSocket, @@ -25,33 +26,22 @@ export enum TransportType { * @param listener An object implementing the ServerEventsListener interface. * @param sslConfig An SSL configuration for TLS servers. */ -export function createServer( +export async function createServer( port: number, listener: ServerEventsListener, sslConfig?: SecureServerConfig, transportType: TransportType = TransportType.RSocket, ): Promise { - return new Promise((resolve, reject) => { - const server = - transportType === TransportType.RSocket - ? new ServerRSocket(listener) - : new ServerWebSocket(listener); - server - .start(port, sslConfig) - .then((started) => { - if (started) { - resolve(server); - } else { - reject( - new Error(`An error occurred whilst trying - to start the server listening at port ${port}`), - ); - } - }) - .catch((error: any) => { - reject(error); - }); - }); + let server: ServerAdapter; + if (transportType === TransportType.RSocket) { + server = new ServerRSocket(listener); + } else if (sslConfig) { + server = new SecureServerWebSocket(listener); + } else { + server = new ServerWebSocket(listener); + } + await server.start(port, sslConfig); + return server; } /** @@ -63,26 +53,11 @@ export function createServer( * @param listener An object implementing the ServerEventsListener interface. * @returns */ -export function createBrowserServer( +export async function createBrowserServer( port: number, listener: ServerEventsListener, ): Promise { - return new Promise((resolve, reject) => { - const server = new ServerWebSocketBrowser(listener); - server - .start(port) - .then((started) => { - if (started) { - resolve(server); - } else { - reject( - new Error(`An error occurred whilst trying - to start the server listening at port ${port}`), - ); - } - }) - .catch((error: any) => { - reject(error); - }); - }); + const server = new BrowserServerWebSocket(listener); + await server.start(port); + return server; } diff --git a/desktop/flipper-server-core/src/comms/ServerRSocket.tsx b/desktop/flipper-server-core/src/comms/ServerRSocket.tsx index 350b019dd..23db9e572 100644 --- a/desktop/flipper-server-core/src/comms/ServerRSocket.tsx +++ b/desktop/flipper-server-core/src/comms/ServerRSocket.tsx @@ -13,7 +13,7 @@ import ServerAdapter, { ServerEventsListener, } from './ServerAdapter'; import tls from 'tls'; -import net, {Socket} from 'net'; +import net, {AddressInfo, Socket} from 'net'; import {RSocketServer} from 'rsocket-core'; import RSocketTCPServer from 'rsocket-tcp-server'; import {Payload, ReactiveSocket, Responder} from 'rsocket-types'; @@ -45,7 +45,7 @@ class ServerRSocket extends ServerAdapter { * the RSocket server factory and request handler based on the optional * sslConfig argument. */ - start(port: number, sslConfig?: SecureServerConfig): Promise { + start(port: number, sslConfig?: SecureServerConfig): Promise { const self = this; return new Promise((resolve, reject) => { // eslint-disable-next-line prefer-const @@ -71,7 +71,7 @@ class ServerRSocket extends ServerAdapter { ); self.listener.onListening(port); self.rawServer_ = rawServer; - resolve(true); + resolve((transportServer.address() as AddressInfo).port); }); return transportServer; }; @@ -84,7 +84,7 @@ class ServerRSocket extends ServerAdapter { serverFactory: serverFactory, }), }); - rawServer && rawServer.start(); + rawServer.start(); }); } diff --git a/desktop/flipper-server-core/src/comms/ServerWebSocket.tsx b/desktop/flipper-server-core/src/comms/ServerWebSocket.tsx index fe271060c..aecd0c0fa 100644 --- a/desktop/flipper-server-core/src/comms/ServerWebSocket.tsx +++ b/desktop/flipper-server-core/src/comms/ServerWebSocket.tsx @@ -7,198 +7,224 @@ * @format */ -import ServerWebSocketBase from './ServerWebSocketBase'; -import WebSocket from 'ws'; -import ws from 'ws'; -import {SecureClientQuery, ServerEventsListener} from './ServerAdapter'; -import querystring from 'querystring'; -import { - ClientConnection, - ConnectionStatus, - ConnectionStatusChange, -} from './ClientConnection'; 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} from 'flipper-common'; import { - ClientDescription, - ClientErrorType, - ClientQuery, - DeviceOS, -} from 'flipper-common'; -import {cloneClientQuerySafeForLogging} from './Utilities'; + assertNotNull, + parseClientQuery, + parseMessageToJson, + verifyClientQueryComesFromCertExchangeSupportedOS, +} from './Utilities'; +import {SecureServerConfig} from '../utils/CertificateProvider'; +import {Server} from 'net'; +import {serializeError} from 'serialize-error'; +import {WSCloseCode} from '../utils/WSCloseCode'; + +export interface ConnectionCtx { + clientQuery?: ClientQuery; + ws: WebSocket; + request: IncomingMessage; +} /** - * WebSocket-based server. + * It serves as a base class for WebSocket based servers. It delegates the 'connection' + * event to subclasses as a customisation point. */ -class ServerWebSocket extends ServerWebSocketBase { - constructor(listener: ServerEventsListener) { - super(listener); +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(), + }); + + // 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) => { + console.error(`[conn] Unable to start server at port ${port}`, error); + this.listener.onError(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) { + // 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); + // TODO: Investigate if we need to close the socket in the `error` listener + // DRI: @aigoncharov + ws.close(WSCloseCode.InternalError); + } + }, + ); + this.wsServer.on('error', (error) => { + console.error('[conn] WS server error:', error); + this.listener.onError(error); + }); + + return assignedPort; } - /** - * Client verification is not necessary. The connected client has - * already been verified using its certificate signed by the server. - * @returns - */ - verifyClient(): ws.VerifyClientCallbackSync { - return (_info: {origin: string; req: IncomingMessage; secure: boolean}) => { - return true; - }; + 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 message Incoming request message. + * @param request Incoming request message. */ - onConnection(ws: WebSocket, message: any): void { - const query = querystring.decode(message.url.split('?')[1]); - const clientQuery = this._parseClientQuery(query); + onConnection(ws: WebSocket, request: IncomingMessage): void { + const ctx: ConnectionCtx = {ws, request}; + this.handleClientQuery(ctx); + this.handleConnectionAttempt(ctx); + + ws.on('message', async (message: unknown) => { + try { + const parsedMessage = this.handleMessageDeserialization(message); + // Successful deserialization is a proof that the message is a string + this.handleMessage(ctx, parsedMessage, message as string); + } 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( + console.error( + '[conn] Unable to extract the client query from the request URL.', + ); + throw new Error( '[conn] Unable to extract the client query from the request URL.', ); - ws.close(); - return; } + 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}.`, - clientQuery, ); this.listener.onConnectionAttempt(clientQuery); - - ws.on('message', async (message: any) => { - const json = JSON.parse(message.toString()); - const response = await this._onHandleUntrustedMessage(clientQuery, json); - if (response) { - ws.send(response); - } - }); } - /** - * A secure connection has been established between the server and a client. Once a client - * has a valid certificate, it can use a secure connection with Flipper and start exchanging - * messages. - * @param _ws An active WebSocket. - * @param message Incoming request message. - */ - onSecureConnection(ws: WebSocket, message: any): void { - const query = querystring.decode(message.url.split('?')[1]); - const clientQuery = this._parseSecureClientQuery(query); - if (!clientQuery) { - console.warn( - '[conn] Unable to extract the client query from the request URL.', - ); - ws.close(); - return; + 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`); } - console.info( - `[conn] Secure websocket connection attempt: ${clientQuery.app} on ${clientQuery.device_id}. Medium ${clientQuery.medium}. CSR: ${clientQuery.csr_path}`, - cloneClientQuerySafeForLogging(clientQuery), - ); - this.listener.onSecureConnectionAttempt(clientQuery); - - const pendingRequests: Map< - number, - { - resolve: (data: any) => void; - reject: (err: Error) => void; - } - > = new Map(); - - const clientConnection: ClientConnection = { - subscribeToEvents(subscriber: ConnectionStatusChange): void { - ws.on('close', () => subscriber(ConnectionStatus.CLOSED)); - ws.on('error', () => subscriber(ConnectionStatus.ERROR)); - }, - close(): void { - ws.close(); - }, - send(data: any): void { - ws.send(JSON.stringify(data)); - }, - sendExpectResponse(data: any): Promise { - return new Promise((resolve, reject) => { - pendingRequests.set(data.id, {reject, resolve}); - ws.send(JSON.stringify(data)); - }); - }, - }; - - let resolvedClient: ClientDescription | undefined; - const client: Promise = - this.listener.onConnectionCreated(clientQuery, clientConnection); - client - .then((client) => (resolvedClient = client)) - .catch((e) => { - console.error( - `[conn] Failed to resolve client ${clientQuery.app} on ${clientQuery.device_id} medium ${clientQuery.medium}`, - e, - ); - }); - - ws.on('message', (message: any) => { - let json: any | undefined; - try { - json = JSON.parse(message); - } catch (err) { - console.warn(`Invalid JSON: ${message}`, 'clientMessage'); - return; - } - - const data: { - id?: number; - success?: Object | undefined; - error?: ClientErrorType | undefined; - } = json; - - if (data.hasOwnProperty('id') && data.id !== undefined) { - const callbacks = pendingRequests.get(data.id); - if (!callbacks) { - return; - } - - pendingRequests.delete(data.id); - - if (data.success) { - callbacks.resolve && callbacks.resolve(data); - } else if (data.error) { - callbacks.reject && callbacks.reject(data.error); - } - } else { - if (resolvedClient) { - this.listener.onClientMessage(resolvedClient.id, message); - } else { - client && - client - .then((client) => { - this.listener.onClientMessage(client.id, message); - }) - .catch((e) => { - console.warn( - 'Could not deliver message, client did not resolve. ', - e, - ); - }); - } - } - }); + return parsedMessage; } - /** - * Validates a string as being one of those defined as valid OS. - * @param str An input string. - */ - private isOS(str: string): str is DeviceOS { - return ( - str === 'iOS' || - str === 'Android' || - str === 'Metro' || - str === 'Windows' || - str === 'MacOS' + 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); + } } /** @@ -206,95 +232,29 @@ class ServerWebSocket extends ServerWebSocketBase { * data will be contained in the message url query string. * @param message An incoming web socket message. */ - private _parseClientQuery( + protected parseClientQuery( query: querystring.ParsedUrlQuery, ): ClientQuery | undefined { - /** Any required arguments to construct a ClientQuery come - * embedded in the query string. - */ - let device_id: string | undefined; - if (typeof query.device_id === 'string') { - device_id = query.device_id; - } else { - return; - } - - let device: string | undefined; - if (typeof query.device === 'string') { - device = query.device; - } else { - return; - } - - let app: string | undefined; - if (typeof query.app === 'string') { - app = query.app; - } else { - return; - } - - let os: DeviceOS | undefined; - if (typeof query.os === 'string' && this.isOS(query.os)) { - os = query.os; - } else { - return; - } - - const clientQuery: ClientQuery = { - device_id, - device, - app, - os, - }; - - if (typeof query.sdk_version === 'string') { - const sdk_version = parseInt(query.sdk_version, 10); - if (sdk_version) { - // TODO: allocate new object, kept now as is to keep changes minimal - (clientQuery as any).sdk_version = sdk_version; - } - } - - return clientQuery; + return verifyClientQueryComesFromCertExchangeSupportedOS( + parseClientQuery(query), + ); } /** - * Parse and extract a SecureClientQuery instance from a message. The ClientQuery - * data will be contained in the message url query string. - * @param message An incoming web socket message. + * 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. */ - private _parseSecureClientQuery( - query: querystring.ParsedUrlQuery, - ): SecureClientQuery | undefined { - /** Any required arguments to construct a SecureClientQuery come - * embedded in the query string. - */ - const clientQuery = this._parseClientQuery(query); - if (!clientQuery) { - return; - } - - let csr: string | undefined; - if (typeof query.csr === 'string') { - const buffer = Buffer.from(query.csr, 'base64'); - if (buffer) { - csr = buffer.toString('ascii'); - } - } - - let csr_path: string | undefined; - if (typeof query.csr_path === 'string') { - csr_path = query.csr_path; - } - - let medium: number | undefined; - if (typeof query.medium === 'string') { - medium = parseInt(query.medium, 10); - } - if (medium !== undefined && (medium < 1 || medium > 3)) { - throw new Error('Unsupported exchange medium: ' + medium); - } - return {...clientQuery, csr, csr_path, medium: medium as any}; + 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; + }; } } diff --git a/desktop/flipper-server-core/src/comms/ServerWebSocketBase.tsx b/desktop/flipper-server-core/src/comms/ServerWebSocketBase.tsx deleted file mode 100644 index 5d42d6cf6..000000000 --- a/desktop/flipper-server-core/src/comms/ServerWebSocketBase.tsx +++ /dev/null @@ -1,113 +0,0 @@ -/** - * 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 {IncomingMessage} from 'http'; -import {SecureServerConfig} from '../utils/CertificateProvider'; -import ServerAdapter, {ServerEventsListener} from './ServerAdapter'; -import ws from 'ws'; -import WebSocket from 'ws'; -import https from 'https'; -import http from 'http'; - -/** - * It serves as a base class for WebSocket based servers. It delegates the 'connection' - * event to subclasses as a customisation point. - */ -abstract class ServerWebSocketBase extends ServerAdapter { - rawServer_: ws.Server | null; - constructor(listener: ServerEventsListener) { - super(listener); - this.rawServer_ = null; - } - - /** - * 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. - */ - verifyClient(): ws.VerifyClientCallbackSync { - return (_info: {origin: string; req: IncomingMessage; secure: boolean}) => { - return false; - }; - } - - start(port: number, sslConfig?: SecureServerConfig): Promise { - const self = this; - return new Promise((resolve, reject) => { - let server: http.Server | https.Server | undefined; - if (sslConfig) { - server = https.createServer({ - key: sslConfig.key, - cert: sslConfig.cert, - ca: sslConfig.ca, - // Client to provide a certificate to authenticate. - requestCert: sslConfig.requestCert, - // As specified as "true", so no unauthenticated traffic - // will make it to the specified route specified - rejectUnauthorized: sslConfig.rejectUnauthorized, - }); - } else { - server = http.createServer(); - } - - const handleRequest = sslConfig - ? self.onSecureConnection - : self.onConnection; - - const rawServer = new WebSocket.Server({ - server, - verifyClient: this.verifyClient(), - }); - rawServer.on('connection', (ws: WebSocket, message: any) => { - handleRequest.apply(self, [ws, message]); - }); - rawServer.on('error', (_ws: WebSocket, error: any) => { - console.warn('[conn] Server found connection error: ' + error); - reject(error); - }); - - if (server) { - server.listen(port, () => { - console.debug( - `${ - sslConfig ? 'Secure' : 'Certificate' - } server started on port ${port}`, - 'server', - ); - self.listener.onListening(port); - self.rawServer_ = rawServer; - resolve(true); - }); - } else { - reject(new Error(`Unable to start server at port ${port}`)); - } - }); - } - - stop(): Promise { - if (this.rawServer_) { - return Promise.resolve(this.rawServer_.close()); - } - return Promise.resolve(); - } - - onConnection(_ws: WebSocket, _message: any): void { - throw new Error('Method not implemented.'); - } - - onSecureConnection(_ws: WebSocket, _message: any): void { - throw new Error('Method not implemented.'); - } -} - -export default ServerWebSocketBase; diff --git a/desktop/flipper-server-core/src/comms/ServerWebSocketBrowser.tsx b/desktop/flipper-server-core/src/comms/ServerWebSocketBrowser.tsx deleted file mode 100644 index 009fc7393..000000000 --- a/desktop/flipper-server-core/src/comms/ServerWebSocketBrowser.tsx +++ /dev/null @@ -1,192 +0,0 @@ -/** - * 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 ServerWebSocketBase from './ServerWebSocketBase'; -import WebSocket from 'ws'; -import querystring from 'querystring'; -import {BrowserClientFlipperConnection} from './BrowserClientFlipperConnection'; -import {ServerEventsListener} from './ServerAdapter'; -import ws from 'ws'; -import {IncomingMessage} from 'http'; -import {ClientDescription, ClientQuery} from 'flipper-common'; -import {getFlipperServerConfig} from '../FlipperServerConfig'; - -/** - * WebSocket-based server which uses a connect/disconnect handshake over an insecure channel. - */ -class ServerWebSocketBrowser extends ServerWebSocketBase { - constructor(listener: ServerEventsListener) { - super(listener); - } - - verifyClient(): ws.VerifyClientCallbackSync { - return (info: {origin: string; req: IncomingMessage; secure: boolean}) => { - const ok = getFlipperServerConfig().validWebSocketOrigins.some( - (validPrefix) => info.origin.startsWith(validPrefix), - ); - if (!ok) { - console.warn( - `[conn] Refused webSocket connection from ${info.origin} (secure: ${info.secure})`, - ); - } - return ok; - }; - } - - /** - * A connection has been established between the server and a client. - * @param ws An active WebSocket. - * @param message Incoming request message. - */ - onConnection(ws: WebSocket, message: any): void { - const clients: { - [app: string]: Promise; - } = {}; - - /** - * Any required arguments to construct a ClientQuery come - * embedded in the query string. - */ - const query = querystring.decode(message.url.split('?')[1]); - const deviceId: string = - typeof query.deviceId === 'string' ? query.deviceId : 'webbrowser'; - const device = - typeof query.device === 'string' ? query.device : 'WebSocket'; - - const clientQuery: ClientQuery = { - device_id: deviceId, - device, - app: device, - os: 'MacOS', // TODO: not hardcoded! Use host device? - }; - - console.info( - `[conn] Local websocket connection attempt: ${clientQuery.app} on ${clientQuery.device_id}.`, - ); - this.listener.onConnectionAttempt(clientQuery); - - const cleanup = () => { - Object.values(clients).map((p) => - p.then((c) => this.listener.onConnectionClosed(c.id)), - ); - }; - - /** - * Subscribe to the 'message' event. Initially, a handshake should take place in the form of a - * 'connect' message. Once received, a client connection will be established and registered. This - * is followed by another subscription to the 'message' event, again. Effectively, two listeners - * are now attached to that event. The former will continue to check for 'connect' and 'disconnect' - * messages. The latter will deliver messages to the client. - */ - ws.on('message', (rawMessage: any) => { - let message: any | undefined; - try { - message = JSON.parse(rawMessage.toString()); - } catch (error) { - // Throws a SyntaxError exception if the string to parse is not valid JSON. - console.log('Received message is not valid.', error); - return; - } - - switch (message.type) { - case 'connect': { - const app = message.app; - const plugins = message.plugins; - - const clientConnection = new BrowserClientFlipperConnection( - ws, - app, - plugins, - ); - - const extendedClientQuery = {...clientQuery, medium: 3 as const}; - extendedClientQuery.sdk_version = plugins == null ? 4 : 1; - - console.info( - `[conn] Local websocket connection established: ${clientQuery.app} on ${clientQuery.device_id}.`, - ); - - let resolvedClient: ClientDescription | null = null; - this.listener.onSecureConnectionAttempt(extendedClientQuery); - const client: Promise = - this.listener.onConnectionCreated( - extendedClientQuery, - clientConnection, - ); - client - .then((client) => { - console.info( - `[conn] Client connected: ${clientQuery.app} on ${clientQuery.device_id}.`, - ); - resolvedClient = client; - }) - .catch((e) => { - console.error( - '[conn] Failed to connect client over webSocket', - e, - ); - }); - - clients[app] = client; - - ws.on('message', (m: any) => { - let parsed: any | undefined; - try { - parsed = JSON.parse(m.toString()); - } catch (error) { - // Throws a SyntaxError exception if the string to parse is not valid JSON. - console.info('[conn] Received message is not valid.', error); - return; - } - // non-null payload id means response to prev request, it's handled in connection - if (parsed.app === app && parsed.payload?.id == null) { - const message = JSON.stringify(parsed.payload); - if (resolvedClient) { - this.listener.onClientMessage(resolvedClient.id, message); - } else { - client - .then((c) => this.listener.onClientMessage(c.id, message)) - .catch((e) => { - console.warn( - 'Could not deliver message, client did not resolve: ' + - app, - e, - ); - }); - } - } - }); - break; - } - case 'disconnect': { - const app = message.app; - (clients[app] || Promise.resolve()) - .then((c) => { - this.listener.onConnectionClosed(c.id); - delete clients[app]; - }) - .catch((_) => {}); - break; - } - } - }); - - /** Close event from the existing client connection. */ - ws.on('close', () => { - cleanup(); - }); - /** Error event from the existing client connection. */ - ws.on('error', (error) => { - console.warn('[conn] Server found connection error: ' + error); - cleanup(); - }); - } -} - -export default ServerWebSocketBrowser; diff --git a/desktop/flipper-server-core/src/comms/Utilities.tsx b/desktop/flipper-server-core/src/comms/Utilities.tsx index 4ab6d7a44..2589332cb 100644 --- a/desktop/flipper-server-core/src/comms/Utilities.tsx +++ b/desktop/flipper-server-core/src/comms/Utilities.tsx @@ -7,7 +7,8 @@ * @format */ -import {ClientQuery} from 'flipper-common'; +import {ClientQuery, DeviceOS, ResponseMessage} from 'flipper-common'; +import {ParsedUrlQuery} from 'querystring'; import {CertificateExchangeMedium} from '../utils/CertificateProvider'; import {SecureClientQuery} from './ServerAdapter'; @@ -50,6 +51,150 @@ export function appNameWithUpdateHint(query: ClientQuery): string { return query.app; } +export function parseMessageToJson( + message: any, +): T | undefined { + try { + return JSON.parse(message.toString()); + } catch (err) { + console.warn(`Invalid JSON: ${message}`, 'clientMessage'); + return; + } +} +export function isWsResponseMessage( + message: object, +): message is ResponseMessage { + return typeof (message as ResponseMessage).id === 'number'; +} + +const certExchangeSupportedOSes = new Set([ + 'Android', + 'iOS', + 'MacOS', + 'Metro', + 'Windows', +]); +/** + * Validates a string as being one of those defined as valid OS. + * @param str An input string. + */ +export function verifyClientQueryComesFromCertExchangeSupportedOS( + query: ClientQuery | undefined, +): ClientQuery | undefined { + if (!query || !certExchangeSupportedOSes.has(query.os)) { + return; + } + return query; +} + +/** + * 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. + */ +export function parseClientQuery( + query: ParsedUrlQuery, +): ClientQuery | undefined { + /** Any required arguments to construct a ClientQuery come + * embedded in the query string. + */ + let device_id: string | undefined; + if (typeof query.device_id === 'string') { + device_id = query.device_id; + } else { + return; + } + + let device: string | undefined; + if (typeof query.device === 'string') { + device = query.device; + } else { + return; + } + + let app: string | undefined; + if (typeof query.app === 'string') { + app = query.app; + } else { + return; + } + + let os: DeviceOS | undefined; + if (typeof query.os === 'string') { + os = query.os as DeviceOS; + } else { + return; + } + + const clientQuery: ClientQuery = { + device_id, + device, + app, + os, + }; + + if (typeof query.sdk_version === 'string') { + const sdk_version = parseInt(query.sdk_version, 10); + if (sdk_version) { + // TODO: allocate new object, kept now as is to keep changes minimal + (clientQuery as any).sdk_version = sdk_version; + } + } + + return clientQuery; +} + +/** + * Parse and extract a SecureClientQuery instance from a message. The ClientQuery + * data will be contained in the message url query string. + * @param message An incoming web socket message. + */ +export function parseSecureClientQuery( + query: ParsedUrlQuery, +): SecureClientQuery | undefined { + /** Any required arguments to construct a SecureClientQuery come + * embedded in the query string. + */ + const clientQuery = verifyClientQueryComesFromCertExchangeSupportedOS( + parseClientQuery(query), + ); + if (!clientQuery) { + return; + } + + let csr: string | undefined; + if (typeof query.csr === 'string') { + const buffer = Buffer.from(query.csr, 'base64'); + if (buffer) { + csr = buffer.toString('ascii'); + } + } + + let csr_path: string | undefined; + if (typeof query.csr_path === 'string') { + csr_path = query.csr_path; + } + + let medium: number | undefined; + if (typeof query.medium === 'string') { + medium = parseInt(query.medium, 10); + } + if (medium !== undefined && (medium < 1 || medium > 3)) { + throw new Error('Unsupported exchange medium: ' + medium); + } + return {...clientQuery, csr, csr_path, medium: medium as any}; +} + export function cloneClientQuerySafeForLogging(clientQuery: SecureClientQuery) { return {...clientQuery, csr: !clientQuery.csr ? clientQuery.csr : ''}; } + +// TODO: Merge with the same fn in desktop/app/src/utils +export function assertNotNull( + value: T, + message: string = 'Unexpected null/undefined value found', +): asserts value is Exclude { + if (value === null || value === undefined) { + throw new Error(message); + } +} diff --git a/desktop/flipper-server-core/src/comms/WebSocketClientConnection.tsx b/desktop/flipper-server-core/src/comms/WebSocketClientConnection.tsx new file mode 100644 index 000000000..cb67533c0 --- /dev/null +++ b/desktop/flipper-server-core/src/comms/WebSocketClientConnection.tsx @@ -0,0 +1,54 @@ +/** + * 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 {ClientResponseType} from 'flipper-common'; +import WebSocket from 'ws'; +import {WSCloseCode} from '../utils/WSCloseCode'; +import { + ClientConnection, + ConnectionStatus, + ConnectionStatusChange, + PendingRequestResolvers, +} from './ClientConnection'; + +export default class WebSocketClientConnection implements ClientConnection { + protected pendingRequests: Map = new Map(); + constructor(protected ws: WebSocket) {} + subscribeToEvents(subscriber: ConnectionStatusChange): void { + this.ws.on('close', () => subscriber(ConnectionStatus.CLOSED)); + this.ws.on('error', () => subscriber(ConnectionStatus.ERROR)); + } + close(): void { + this.ws.close(WSCloseCode.NormalClosure); + } + send(data: any): void { + this.ws.send(this.serializeData(data)); + } + sendExpectResponse(data: any): Promise { + return new Promise((resolve, reject) => { + this.pendingRequests.set(data.id, {reject, resolve}); + this.ws.send(this.serializeData(data)); + }); + } + + matchPendingRequest(id: number): PendingRequestResolvers { + const callbacks = this.pendingRequests.get(id); + + if (!callbacks) { + throw new Error(`Pending request ${id} is not found`); + } + + this.pendingRequests.delete(id); + return callbacks; + } + + protected serializeData(data: object): string { + return JSON.stringify(data); + } +} diff --git a/desktop/flipper-server-core/src/comms/__tests__/BrowserServerWebSocket.node.tsx b/desktop/flipper-server-core/src/comms/__tests__/BrowserServerWebSocket.node.tsx new file mode 100644 index 000000000..efcde333a --- /dev/null +++ b/desktop/flipper-server-core/src/comms/__tests__/BrowserServerWebSocket.node.tsx @@ -0,0 +1,254 @@ +/** + * 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 { + DeviceOS, + ExecuteMessage, + GetPluginsMessage, + ResponseMessage, +} from 'flipper-common'; +import WebSocket from 'ws'; + +import {BrowserClientConnection} from '../BrowserClientConnection'; +import {getFlipperServerConfig} from '../../FlipperServerConfig'; +import BrowserServerWebSocket from '../BrowserServerWebSocket'; +import {SecureClientQuery} from '../ServerAdapter'; +import {createMockSEListener, WSMessageAccumulator} from './utils'; + +jest.mock('../../FlipperServerConfig'); +(getFlipperServerConfig as jest.Mock).mockImplementation(() => ({ + validWebSocketOrigins: ['localhost:'], +})); + +describe('BrowserServerWebSocket', () => { + let server: BrowserServerWebSocket | undefined; + let wsClient: WebSocket | undefined; + + afterEach(async () => { + wsClient?.close(); + wsClient = undefined; + await server?.stop(); + server = undefined; + }); + + test('handles a modern execute message', async () => { + const deviceId = 'yoda42'; + const device = 'yoda'; + const os: DeviceOS = 'MacOS'; + const app = 'deathstar'; + const sdkVersion = 4; + + const mockSEListener = createMockSEListener(); + + server = new BrowserServerWebSocket(mockSEListener); + const serverReceivedMessages = new WSMessageAccumulator(); + (mockSEListener.onClientMessage as jest.Mock).mockImplementation( + (_, parsedMessage) => serverReceivedMessages.add(parsedMessage), + ); + + expect(mockSEListener.onListening).toBeCalledTimes(0); + const port = await server.start(0); + expect(mockSEListener.onListening).toBeCalledTimes(1); + + expect(mockSEListener.onConnectionAttempt).toBeCalledTimes(0); + expect(mockSEListener.onSecureConnectionAttempt).toBeCalledTimes(0); + expect(mockSEListener.onConnectionCreated).toBeCalledTimes(0); + const clientReceivedMessages = new WSMessageAccumulator(); + wsClient = new WebSocket( + `ws://localhost:${port}?device_id=${deviceId}&device=${device}&app=${app}&os=${os}&sdk_version=${sdkVersion}`, + {origin: 'localhost:'}, + ); + wsClient.onmessage = ({data}) => clientReceivedMessages.add(data); + await new Promise((resolve, reject) => { + wsClient!.onopen = () => resolve(); + wsClient!.onerror = (e) => reject(e); + wsClient!.onmessage = ({data}) => { + clientReceivedMessages.add(data); + }; + }); + expect(mockSEListener.onConnectionAttempt).toBeCalledTimes(1); + expect(mockSEListener.onSecureConnectionAttempt).toBeCalledTimes(1); + expect(mockSEListener.onConnectionCreated).toBeCalledTimes(1); + const expectedClientQuery: SecureClientQuery = { + device_id: deviceId, + device, + os, + app, + sdk_version: sdkVersion, + medium: 3, + }; + expect(mockSEListener.onConnectionAttempt).toBeCalledWith( + expectedClientQuery, + ); + expect(mockSEListener.onSecureConnectionAttempt).toBeCalledWith( + expectedClientQuery, + ); + expect(mockSEListener.onConnectionCreated).toBeCalledWith( + expectedClientQuery, + expect.anything(), + ); + const connection: BrowserClientConnection = ( + mockSEListener.onConnectionCreated as jest.Mock + ).mock.calls[0][1]; + expect(connection).toBeInstanceOf(BrowserClientConnection); + + // When client connects, server requests a list of plugins and a list of background plugins + const getPluginsMessage: GetPluginsMessage = { + id: 0, + method: 'getPlugins', + }; + const actualGetPluginsResponsePromise = + connection.sendExpectResponse(getPluginsMessage); + + const actualGetPluginsMessage = await clientReceivedMessages.newMessage; + expect(actualGetPluginsMessage).toBe(JSON.stringify(getPluginsMessage)); + + // Client sends a response to the server + const getPluginsResponse: ResponseMessage = { + id: 0, + success: { + plugins: ['fbrocks'], + }, + }; + wsClient.send(JSON.stringify(getPluginsResponse)); + + // Server receives the response + const actualGetPluginsResponse = await actualGetPluginsResponsePromise; + expect(actualGetPluginsResponse).toMatchObject(getPluginsResponse); + + // Now client can send an execute message + // Note: In real world, the server should have sent a getBackgroundPluginsRequest as well + const executeMessage: ExecuteMessage = { + method: 'execute', + params: { + method: 'admire', + api: 'flipper', + params: 'constantly', + }, + }; + wsClient.send(JSON.stringify(executeMessage)); + const actualExecuteMessage = await serverReceivedMessages.newMessage; + expect(actualExecuteMessage).toEqual(JSON.stringify(executeMessage)); + expect(mockSEListener.onClientMessage).toBeCalledTimes(1); + + expect(mockSEListener.onProcessCSR).toBeCalledTimes(0); + expect(mockSEListener.onConnectionClosed).toBeCalledTimes(0); + expect(mockSEListener.onError).toBeCalledTimes(0); + }); + + test('handles a legacy execute message', async () => { + const deviceId = 'yoda42'; + const device = 'yoda'; + + const mockSEListener = createMockSEListener(); + + server = new BrowserServerWebSocket(mockSEListener); + const serverReceivedMessages = new WSMessageAccumulator(); + (mockSEListener.onClientMessage as jest.Mock).mockImplementation( + (_, parsedMessage) => serverReceivedMessages.add(parsedMessage), + ); + + expect(mockSEListener.onListening).toBeCalledTimes(0); + const port = await server.start(0); + expect(mockSEListener.onListening).toBeCalledTimes(1); + + expect(mockSEListener.onConnectionAttempt).toBeCalledTimes(0); + expect(mockSEListener.onSecureConnectionAttempt).toBeCalledTimes(0); + expect(mockSEListener.onConnectionCreated).toBeCalledTimes(0); + const clientReceivedMessages = new WSMessageAccumulator(); + wsClient = new WebSocket( + `ws://localhost:${port}?deviceId=${deviceId}&device=${device}`, + {origin: 'localhost:'}, + ); + wsClient.onmessage = ({data}) => clientReceivedMessages.add(data); + await new Promise((resolve, reject) => { + wsClient!.onopen = () => resolve(); + wsClient!.onerror = (e) => reject(e); + wsClient!.onmessage = ({data}) => { + clientReceivedMessages.add(data); + }; + }); + expect(mockSEListener.onConnectionAttempt).toBeCalledTimes(1); + expect(mockSEListener.onSecureConnectionAttempt).toBeCalledTimes(1); + expect(mockSEListener.onConnectionCreated).toBeCalledTimes(1); + const expectedClientQuery: SecureClientQuery = { + device_id: deviceId, + device, + os: 'MacOS', + app: device, + sdk_version: 4, + medium: 3, + }; + expect(mockSEListener.onConnectionAttempt).toBeCalledWith( + expectedClientQuery, + ); + expect(mockSEListener.onSecureConnectionAttempt).toBeCalledWith( + expectedClientQuery, + ); + expect(mockSEListener.onConnectionCreated).toBeCalledWith( + expectedClientQuery, + expect.anything(), + ); + const connection: BrowserClientConnection = ( + mockSEListener.onConnectionCreated as jest.Mock + ).mock.calls[0][1]; + expect(connection).toBeInstanceOf(BrowserClientConnection); + + // When client connects, server requests a list of plugins and a list of background plugins + const getPluginsMessage: GetPluginsMessage = { + id: 0, + method: 'getPlugins', + }; + const actualGetPluginsResponsePromise = + connection.sendExpectResponse(getPluginsMessage); + + // In teh legacy flow, client sends a connect message + // Client sends a response to the server + const connectMessage = { + app: device, + type: 'connect', + plugins: ['fbrockslegacy'], + }; + wsClient.send(JSON.stringify(connectMessage)); + + // Even thoug the client did not send a response for get plugins, the servers gets this information from the connect message + const actualGetPluginsResponse = await actualGetPluginsResponsePromise; + const expectedGetPluginsResponse = { + success: { + plugins: ['fbrockslegacy'], + }, + }; + expect(actualGetPluginsResponse).toMatchObject(expectedGetPluginsResponse); + + // Now client can send an execute message + // Note: In real world, the server should have sent a getBackgroundPluginsRequest as well + const executeMessage: ExecuteMessage = { + method: 'execute', + params: { + method: 'admire', + api: 'flipper', + params: 'constantly', + }, + }; + // In the legacy mode, the client is going to use the legacy format + const legacyExecuteMessage = { + app: device, + payload: executeMessage, + }; + wsClient.send(JSON.stringify(legacyExecuteMessage)); + const actualExecuteMessage = await serverReceivedMessages.newMessage; + // Server is smart enough to handle the legacy format and normalize the message + expect(actualExecuteMessage).toEqual(JSON.stringify(executeMessage)); + expect(mockSEListener.onClientMessage).toBeCalledTimes(1); + + expect(mockSEListener.onProcessCSR).toBeCalledTimes(0); + expect(mockSEListener.onConnectionClosed).toBeCalledTimes(0); + expect(mockSEListener.onError).toBeCalledTimes(0); + }); +}); diff --git a/desktop/flipper-server-core/src/comms/__tests__/SecureServerWebSocket.node.tsx b/desktop/flipper-server-core/src/comms/__tests__/SecureServerWebSocket.node.tsx new file mode 100644 index 000000000..c2494f085 --- /dev/null +++ b/desktop/flipper-server-core/src/comms/__tests__/SecureServerWebSocket.node.tsx @@ -0,0 +1,140 @@ +/** + * 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 { + DeviceOS, + ExecuteMessage, + GetPluginsMessage, + ResponseMessage, +} from 'flipper-common'; +import {toBase64} from 'js-base64'; +import WebSocket from 'ws'; + +import SecureServerWebSocket from '../SecureServerWebSocket'; +import {SecureClientQuery} from '../ServerAdapter'; +import WebSocketClientConnection from '../WebSocketClientConnection'; +import {createMockSEListener, WSMessageAccumulator} from './utils'; + +describe('SecureServerWebSocket', () => { + let server: SecureServerWebSocket | undefined; + let wsClient: WebSocket | undefined; + + afterEach(async () => { + wsClient?.close(); + wsClient = undefined; + await server?.stop(); + server = undefined; + }); + + test('handles an execute message', async () => { + const deviceId = 'yoda42'; + const device = 'yoda'; + const os: DeviceOS = 'MacOS'; + const app = 'deathstar'; + const sdkVersion = 4; + const medium = 2; + const csr = 'luke'; + const csrPath = 'notearth'; + + const mockSEListener = createMockSEListener(); + + server = new SecureServerWebSocket(mockSEListener); + const serverReceivedMessages = new WSMessageAccumulator(); + (mockSEListener.onClientMessage as jest.Mock).mockImplementation( + (_, parsedMessage) => serverReceivedMessages.add(parsedMessage), + ); + + expect(mockSEListener.onListening).toBeCalledTimes(0); + const port = await server.start(0); + expect(mockSEListener.onListening).toBeCalledTimes(1); + + expect(mockSEListener.onSecureConnectionAttempt).toBeCalledTimes(0); + expect(mockSEListener.onConnectionCreated).toBeCalledTimes(0); + const clientReceivedMessages = new WSMessageAccumulator(); + wsClient = new WebSocket( + `ws://localhost:${port}?device_id=${deviceId}&device=${device}&app=${app}&os=${os}&sdk_version=${sdkVersion}&csr=${toBase64( + csr, + )}&csr_path=${csrPath}&medium=${medium}`, + ); + wsClient.onmessage = ({data}) => clientReceivedMessages.add(data); + await new Promise((resolve, reject) => { + wsClient!.onopen = () => resolve(); + wsClient!.onerror = (e) => reject(e); + wsClient!.onmessage = ({data}) => { + clientReceivedMessages.add(data); + }; + }); + expect(mockSEListener.onSecureConnectionAttempt).toBeCalledTimes(1); + expect(mockSEListener.onConnectionCreated).toBeCalledTimes(1); + const expectedClientQuery: SecureClientQuery = { + device_id: deviceId, + device, + os, + app, + sdk_version: sdkVersion, + csr, + csr_path: csrPath, + medium, + }; + expect(mockSEListener.onSecureConnectionAttempt).toBeCalledWith( + expectedClientQuery, + ); + expect(mockSEListener.onConnectionCreated).toBeCalledWith( + expectedClientQuery, + expect.anything(), + ); + const connection: WebSocketClientConnection = ( + mockSEListener.onConnectionCreated as jest.Mock + ).mock.calls[0][1]; + expect(connection).toBeInstanceOf(WebSocketClientConnection); + + // When client connects, server requests a list of plugins and a list of background plugins + const getPluginsMessage: GetPluginsMessage = { + id: 0, + method: 'getPlugins', + }; + const actualGetPluginsResponsePromise = + connection.sendExpectResponse(getPluginsMessage); + + const actualGetPluginsMessage = await clientReceivedMessages.newMessage; + expect(actualGetPluginsMessage).toBe(JSON.stringify(getPluginsMessage)); + + // Client sends a response to the server + const getPluginsResponse: ResponseMessage = { + id: 0, + success: { + plugins: ['fbrocks'], + }, + }; + wsClient.send(JSON.stringify(getPluginsResponse)); + + // Server receives the response + const actualGetPluginsResponse = await actualGetPluginsResponsePromise; + expect(actualGetPluginsResponse).toMatchObject(getPluginsResponse); + + // Now client can send an execute message + // Note: In real world, the server should have sent a getBackgroundPluginsRequest as well + const executeMessage: ExecuteMessage = { + method: 'execute', + params: { + method: 'admire', + api: 'flipper', + params: 'constantly', + }, + }; + wsClient.send(JSON.stringify(executeMessage)); + const actualExecuteMessage = await serverReceivedMessages.newMessage; + expect(actualExecuteMessage).toEqual(JSON.stringify(executeMessage)); + expect(mockSEListener.onClientMessage).toBeCalledTimes(1); + + expect(mockSEListener.onProcessCSR).toBeCalledTimes(0); + expect(mockSEListener.onConnectionClosed).toBeCalledTimes(0); + expect(mockSEListener.onError).toBeCalledTimes(0); + }); +}); diff --git a/desktop/flipper-server-core/src/comms/__tests__/ServerWebSocket.node.tsx b/desktop/flipper-server-core/src/comms/__tests__/ServerWebSocket.node.tsx new file mode 100644 index 000000000..f00cc4266 --- /dev/null +++ b/desktop/flipper-server-core/src/comms/__tests__/ServerWebSocket.node.tsx @@ -0,0 +1,142 @@ +/** + * 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 {ClientQuery, DeviceOS, SignCertificateMessage} from 'flipper-common'; +import WebSocket from 'ws'; + +import ServerWebSocket from '../ServerWebSocket'; +import { + createMockSEListener, + WSMessageAccumulator, + processCSRResponse, +} from './utils'; + +describe('ServerWebSocket', () => { + let server: ServerWebSocket | undefined; + let wsClient: WebSocket | undefined; + + afterEach(async () => { + wsClient?.close(); + wsClient = undefined; + await server?.stop(); + server = undefined; + }); + + const deviceId = 'yoda42'; + const device = 'yoda'; + const os: DeviceOS = 'MacOS'; + const app = 'deathstar'; + const sdkVersion = 4; + + test('accepts new connections and handles a signCertificate message', async () => { + const mockSEListener = createMockSEListener(); + + server = new ServerWebSocket(mockSEListener); + + expect(mockSEListener.onListening).toBeCalledTimes(0); + const port = await server.start(0); + expect(mockSEListener.onListening).toBeCalledTimes(1); + + expect(mockSEListener.onConnectionAttempt).toBeCalledTimes(0); + wsClient = new WebSocket( + `ws://localhost:${port}?device_id=${deviceId}&device=${device}&app=${app}&os=${os}&sdk_version=${sdkVersion}`, + ); + const receivedMessages = new WSMessageAccumulator(); + await new Promise((resolve, reject) => { + wsClient!.onopen = () => resolve(); + wsClient!.onerror = (e) => reject(e); + wsClient!.onmessage = ({data}) => receivedMessages.add(data); + }); + expect(mockSEListener.onConnectionAttempt).toBeCalledTimes(1); + const expectedClientQuery: ClientQuery = { + device_id: deviceId, + device, + os, + app, + sdk_version: sdkVersion, + }; + expect(mockSEListener.onConnectionAttempt).toBeCalledWith( + expectedClientQuery, + ); + + expect(mockSEListener.onProcessCSR).toBeCalledTimes(0); + const signCertMessage: SignCertificateMessage = { + method: 'signCertificate', + csr: 'ilovepancakes', + destination: 'mars', + medium: 2, + }; + wsClient.send(JSON.stringify(signCertMessage)); + const response = await receivedMessages.newMessage; + expect(response).toBe(JSON.stringify(processCSRResponse)); + expect(mockSEListener.onProcessCSR).toBeCalledTimes(1); + + expect(mockSEListener.onConnectionCreated).toBeCalledTimes(0); + expect(mockSEListener.onConnectionClosed).toBeCalledTimes(0); + expect(mockSEListener.onError).toBeCalledTimes(0); + expect(mockSEListener.onClientMessage).toBeCalledTimes(0); + }); + + test('throws if starts on the same port', async () => { + const mockSEListener = createMockSEListener(); + server = new ServerWebSocket(mockSEListener); + const assignedPort = await server.start(0); + + expect(mockSEListener.onListening).toBeCalledTimes(1); + expect(mockSEListener.onError).toBeCalledTimes(0); + + const conflictingServer = new ServerWebSocket(mockSEListener); + await expect(conflictingServer.start(assignedPort)).rejects.toThrow(); + + expect(mockSEListener.onListening).toBeCalledTimes(1); + expect(mockSEListener.onError).toBeCalledTimes(1); + }); + + test('calls listener onError if a connection attempt fails', async () => { + const mockSEListener = createMockSEListener(); + + server = new ServerWebSocket(mockSEListener); + + const port = await server.start(0); + + expect(mockSEListener.onError).toBeCalledTimes(0); + // We pass a conection URL without required params hoping that it is going to fail + wsClient = new WebSocket(`ws://localhost:${port}`); + await new Promise((resolve) => { + wsClient!.onclose = () => { + resolve(); + }; + }); + wsClient = undefined; + expect(mockSEListener.onError).toBeCalledTimes(1); + }); + + test('calls listener onError if a message handling fails', async () => { + const mockSEListener = createMockSEListener(); + + server = new ServerWebSocket(mockSEListener); + const port = await server.start(0); + + wsClient = new WebSocket( + `ws://localhost:${port}?device_id=${deviceId}&device=${device}&app=${app}&os=${os}&sdk_version=${sdkVersion}`, + ); + await new Promise((resolve) => { + wsClient!.onopen = () => resolve(); + }); + + expect(mockSEListener.onError).toBeCalledTimes(0); + // Sending invalid JSON to cause a parsing error + wsClient.send(`{{{{`); + // Server must close the connection on error + await new Promise((resolve) => { + wsClient!.onclose = resolve; + }); + expect(mockSEListener.onError).toBeCalledTimes(1); + }); +}); diff --git a/desktop/flipper-server-core/src/comms/__tests__/utils.ts b/desktop/flipper-server-core/src/comms/__tests__/utils.ts new file mode 100644 index 000000000..0b9a4db8e --- /dev/null +++ b/desktop/flipper-server-core/src/comms/__tests__/utils.ts @@ -0,0 +1,65 @@ +/** + * 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 {ClientQuery, ClientDescription} from 'flipper-common'; +import {ServerEventsListener} from '../ServerAdapter'; + +export class WSMessageAccumulator { + private messages: unknown[] = []; + private newMessageSubscribers: ((newMessageContent: unknown) => void)[] = []; + + constructor(private readonly timeout = 1000) {} + + get newMessage(): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error('Timeout exceeded')); + }, this.timeout); + + this.newMessageSubscribers.push((newMessageContent: unknown) => { + clearTimeout(timer); + resolve(newMessageContent); + }); + + this.consume(); + }); + } + + add(newMessageContent: unknown) { + this.messages.push(newMessageContent); + this.consume(); + } + + private consume() { + if (this.messages.length && this.newMessageSubscribers.length) { + const message = this.messages.shift(); + const subscriber = this.newMessageSubscribers.shift(); + subscriber!(message); + } + } +} + +export const processCSRResponse = {deviceId: 'dagobah'}; + +export const createMockSEListener = (): ServerEventsListener => ({ + onListening: jest.fn(), + onConnectionAttempt: jest.fn(), + onSecureConnectionAttempt: jest.fn(), + onProcessCSR: jest.fn().mockImplementation(() => processCSRResponse), + onConnectionCreated: jest.fn().mockImplementation( + (query: ClientQuery): Promise => + Promise.resolve({ + id: 'tatooine', + query, + }), + ), + onConnectionClosed: jest.fn(), + onError: jest.fn(), + onClientMessage: jest.fn(), +}); diff --git a/desktop/flipper-server-core/src/utils/WSCloseCode.ts b/desktop/flipper-server-core/src/utils/WSCloseCode.ts new file mode 100644 index 000000000..ef2485f4e --- /dev/null +++ b/desktop/flipper-server-core/src/utils/WSCloseCode.ts @@ -0,0 +1,96 @@ +/** + * 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 + */ + +export enum WSCloseCode { + /** + * Normal closure; the connection successfully completed whatever + * purpose for which it was created. + */ + NormalClosure = 1000, + /** + * The endpoint is going away, either because of a server failure + * or because the browser is navigating away from the page that + * opened the connection. + */ + GoingAway = 1001, + /** + * The endpoint is terminating the connection due to a protocol + * error. + */ + ProtocolError = 1002, + /** + * The connection is being terminated because the endpoint + * received data of a type it cannot accept (for example, a + * text-only endpoint received binary data). + */ + UnsupportedData = 1003, + /** + * (Reserved.) Indicates that no status code was provided even + * though one was expected. + */ + NoStatusRecvd = 1005, + /** + * (Reserved.) Used to indicate that a connection was closed + * abnormally (that is, with no close frame being sent) when a + * status code is expected. + */ + AbnormalClosure = 1006, + /** + * The endpoint is terminating the connection because a message + * was received that contained inconsistent data (e.g., non-UTF-8 + * data within a text message). + */ + InvalidFramePayloadData = 1007, + /** + * The endpoint is terminating the connection because it received + * a message that violates its policy. This is a generic status + * code, used when codes 1003 and 1009 are not suitable. + */ + PolicyViolation = 1008, + /** + * The endpoint is terminating the connection because a data frame + * was received that is too large. + */ + MessageTooBig = 1009, + /** + * The client is terminating the connection because it expected + * the server to negotiate one or more extension, but the server + * didn't. + */ + MissingExtension = 1010, + /** + * The server is terminating the connection because it encountered + * an unexpected condition that prevented it from fulfilling the + * request. + */ + InternalError = 1011, + /** + * The server is terminating the connection because it is + * restarting. [Ref] + */ + ServiceRestart = 1012, + /** + * The server is terminating the connection due to a temporary + * condition, e.g. it is overloaded and is casting off some of its + * clients. + */ + TryAgainLater = 1013, + /** + * The server was acting as a gateway or proxy and received an + * invalid response from the upstream server. This is similar to + * 502 HTTP Status Code. + */ + BadGateway = 1014, + /** + * (Reserved.) Indicates that the connection was closed due to a + * failure to perform a TLS handshake (e.g., the server + * certificate can't be verified). + */ + TLSHandshake = 1015, +} diff --git a/desktop/yarn.lock b/desktop/yarn.lock index 6b45a429d..7c6ac6307 100644 --- a/desktop/yarn.lock +++ b/desktop/yarn.lock @@ -4855,9 +4855,9 @@ camelcase@^6.0.0, camelcase@^6.2.0: integrity sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg== caniuse-lite@^1.0.30001265: - version "1.0.30001269" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001269.tgz#3a71bee03df627364418f9fd31adfc7aa1cc2d56" - integrity sha512-UOy8okEVs48MyHYgV+RdW1Oiudl1H6KolybD6ZquD0VcrPSgj25omXO1S7rDydjpqaISCwA8Pyx+jUQKZwWO5w== + version "1.0.30001267" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001267.tgz#b1cf2937175afc0570e4615fc2d2f9069fa0ed30" + integrity sha512-r1mjTzAuJ9W8cPBGbbus8E0SKcUP7gn03R14Wk8FlAlqhH9hroy9nLqmpuXlfKEw/oILW+FGz47ipXV2O7x8lg== capture-exit@^2.0.0: version "2.0.0" @@ -6063,9 +6063,9 @@ electron-publish@22.14.5: mime "^2.5.2" electron-to-chromium@^1.3.867: - version "1.3.871" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.871.tgz#6e87365fd72037a6c898fb46050ad4be3ac9ef62" - integrity sha512-qcLvDUPf8DSIMWarHT2ptgcqrYg62n3vPA7vhrOF24d8UNzbUBaHu2CySiENR3nEDzYgaN60071t0F6KLYMQ7Q== + version "1.3.870" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.870.tgz#c15c921e66a46985181b261f8093b91c2abb6604" + integrity sha512-PiJMshfq6PL+i1V+nKLwhHbCKeD8eAz8rvO9Cwk/7cChOHJBtufmjajLyYLsSRHguRFiOCVx3XzJLeZsIAYfSA== electron@11.2.3: version "11.2.3" @@ -9189,7 +9189,15 @@ jsonparse@^1.2.0: resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" integrity sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA= -"jsx-ast-utils@^2.4.1 || ^3.0.0", jsx-ast-utils@^3.1.0: +"jsx-ast-utils@^2.4.1 || ^3.0.0": + version "3.2.1" + resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.2.1.tgz#720b97bfe7d901b927d87c3773637ae8ea48781b" + integrity sha512-uP5vu8xfy2F9A6LGC22KO7e2/vGTS1MhP+18f++ZNlf0Ohaxbc9nIEwHAsejlJKyzfZzU5UIhe5ItYkitcZnZA== + dependencies: + array-includes "^3.1.3" + object.assign "^4.1.2" + +jsx-ast-utils@^3.1.0: version "3.2.0" resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.2.0.tgz#41108d2cec408c3453c1bbe8a4aae9e1e2bd8f82" integrity sha512-EIsmt3O3ljsU6sot/J4E1zDRxfBNrhjyf/OKjlydwgEimQuznlM4Wv7U+ueONJMyEn1WRE0K8dhi3dVAXYT24Q== @@ -12425,6 +12433,13 @@ serialize-error@^5.0.0: dependencies: type-fest "^0.8.0" +serialize-error@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/serialize-error/-/serialize-error-8.1.0.tgz#3a069970c712f78634942ddd50fbbc0eaebe2f67" + integrity sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ== + dependencies: + type-fest "^0.20.2" + serve-static@1.14.1: version "1.14.1" resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.14.1.tgz#666e636dc4f010f7ef29970a88a674320898b2f9"