From 37498ad5a95a65c409c1b45876da6d40acb6967e Mon Sep 17 00:00:00 2001 From: Andrey Goncharov Date: Thu, 21 Oct 2021 03:30:41 -0700 Subject: [PATCH] Refactor server implementation for WebSockets Summary: Standardize WS implementation for JS environments. Why do we need a separate server implementation for browsers? Browser targets cannot authenticate via the default certificate exchange flow. For browser targets we verify the origin instead. Moreover, for already forgotten reasons the initial implementation of the WS server for browsers used a different kind of message structure and added extra `connect`/`disconnect` messages. After examination, it seems the `connect`/`disconnect` flow is redundant. Major changes: 1. Updated class hierarchy for WS server implementations. 2. Updated browser WS server to support the modern and the legacy protocols. 3. Now a websocket connection with the device is closed on error. The idea is it is highly unlikely to handle any subsequent messages properly once we observe an error. It is better to bail and reconnect. What do you think? Reviewed By: mweststrate Differential Revision: D31532172 fbshipit-source-id: f86aa63a40efe4d5263353cc124fac8c63b80e45 --- desktop/app/src/Client.tsx | 5 +- desktop/app/src/test-utils/MockFlipper.tsx | 9 +- desktop/flipper-common/src/server-types.tsx | 49 +- desktop/flipper-server-core/package.json | 1 + .../src/comms/BrowserClientConnection.tsx | 65 +++ .../comms/BrowserClientFlipperConnection.tsx | 84 ---- .../src/comms/BrowserServerWebSocket.tsx | 155 ++++++ .../src/comms/ClientConnection.tsx | 9 +- .../src/comms/SecureServerWebSocket.tsx | 135 ++++++ .../src/comms/ServerAdapter.tsx | 32 +- .../src/comms/ServerController.tsx | 14 +- .../src/comms/ServerFactory.tsx | 59 +-- .../src/comms/ServerRSocket.tsx | 8 +- .../src/comms/ServerWebSocket.tsx | 452 ++++++++---------- .../src/comms/ServerWebSocketBase.tsx | 113 ----- .../src/comms/ServerWebSocketBrowser.tsx | 192 -------- .../src/comms/Utilities.tsx | 147 +++++- .../src/comms/WebSocketClientConnection.tsx | 54 +++ .../__tests__/BrowserServerWebSocket.node.tsx | 254 ++++++++++ .../__tests__/SecureServerWebSocket.node.tsx | 140 ++++++ .../comms/__tests__/ServerWebSocket.node.tsx | 142 ++++++ .../src/comms/__tests__/utils.ts | 65 +++ .../src/utils/WSCloseCode.ts | 96 ++++ desktop/yarn.lock | 29 +- 24 files changed, 1584 insertions(+), 725 deletions(-) create mode 100644 desktop/flipper-server-core/src/comms/BrowserClientConnection.tsx delete mode 100644 desktop/flipper-server-core/src/comms/BrowserClientFlipperConnection.tsx create mode 100644 desktop/flipper-server-core/src/comms/BrowserServerWebSocket.tsx create mode 100644 desktop/flipper-server-core/src/comms/SecureServerWebSocket.tsx delete mode 100644 desktop/flipper-server-core/src/comms/ServerWebSocketBase.tsx delete mode 100644 desktop/flipper-server-core/src/comms/ServerWebSocketBrowser.tsx create mode 100644 desktop/flipper-server-core/src/comms/WebSocketClientConnection.tsx create mode 100644 desktop/flipper-server-core/src/comms/__tests__/BrowserServerWebSocket.node.tsx create mode 100644 desktop/flipper-server-core/src/comms/__tests__/SecureServerWebSocket.node.tsx create mode 100644 desktop/flipper-server-core/src/comms/__tests__/ServerWebSocket.node.tsx create mode 100644 desktop/flipper-server-core/src/comms/__tests__/utils.ts create mode 100644 desktop/flipper-server-core/src/utils/WSCloseCode.ts 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"