comms: app-connectivity
Summary: It doesn't get more generic than 'comms'. So, narrow it down: app-connectivity. Reviewed By: passy Differential Revision: D47185255 fbshipit-source-id: 87e9c2487c9b07603d14e856de670757078c0da1
This commit is contained in:
committed by
Facebook GitHub Bot
parent
48495c906e
commit
62cb33b763
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {
|
||||
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<ClientResponseType> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {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 './ServerWebSocketBase';
|
||||
import {ClientDescription, DeviceOS} from 'flipper-common';
|
||||
import {URL} from 'url';
|
||||
import {isFBBuild} from '../fb-stubs/constants';
|
||||
|
||||
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<ClientDescription> =
|
||||
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: Add a link to a blog post when it is ready.
|
||||
console.warn(
|
||||
'[conn] Legacy WebSocket connection. Please, upgrade. See https://github.com/facebook/flipper/tree/main/js/js-flipper for references.',
|
||||
);
|
||||
|
||||
// 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);
|
||||
|
||||
if (!getPluginsCallbacks) {
|
||||
return;
|
||||
}
|
||||
|
||||
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: 'NONE'};
|
||||
}
|
||||
|
||||
protected verifyClient(): ws.VerifyClientCallbackSync {
|
||||
return (info: {origin: string; req: IncomingMessage; secure: boolean}) => {
|
||||
if (isFBBuild) {
|
||||
try {
|
||||
const urlObj = new URL(info.origin);
|
||||
if (urlObj.hostname.endsWith('.facebook.com')) {
|
||||
return true;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {ClientResponseType} from 'flipper-common';
|
||||
|
||||
export interface PendingRequestResolvers {
|
||||
resolve: (data: ClientResponseType) => void;
|
||||
reject: (err: Error) => void;
|
||||
}
|
||||
|
||||
export enum ConnectionStatus {
|
||||
ERROR = 'error',
|
||||
CLOSED = 'closed',
|
||||
CONNECTED = 'connected',
|
||||
NOT_CONNECTED = 'not_connected',
|
||||
CONNECTING = 'connecting',
|
||||
}
|
||||
|
||||
export type ConnectionStatusChange = (status: ConnectionStatus) => void;
|
||||
|
||||
export interface ClientConnection {
|
||||
subscribeToEvents(subscriber: ConnectionStatusChange): void;
|
||||
close(): void;
|
||||
send(data: object): void;
|
||||
sendExpectResponse(data: object): Promise<ClientResponseType>;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import ServerWebSocket, {ConnectionCtx} from './ServerWebSocket';
|
||||
import {SecureClientQuery} from './ServerWebSocketBase';
|
||||
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<ClientDescription>;
|
||||
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<ClientDescription> = 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 (!callbacks) {
|
||||
return;
|
||||
}
|
||||
callbacks.resolve({
|
||||
...parsedMessage,
|
||||
length: rawMessage.length,
|
||||
});
|
||||
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;
|
||||
@@ -0,0 +1,598 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {
|
||||
ClientDescription,
|
||||
ClientQuery,
|
||||
isTest,
|
||||
buildClientId,
|
||||
Logger,
|
||||
UninitializedClient,
|
||||
reportPlatformFailures,
|
||||
FlipperServerEvents,
|
||||
} from 'flipper-common';
|
||||
import CertificateProvider from '../utils/CertificateProvider';
|
||||
import {ClientConnection, ConnectionStatus} from './ClientConnection';
|
||||
import {EventEmitter} from 'events';
|
||||
import invariant from 'invariant';
|
||||
import DummyDevice from '../devices/DummyDevice';
|
||||
import {
|
||||
appNameWithUpdateHint,
|
||||
assertNotNull,
|
||||
cloneClientQuerySafeForLogging,
|
||||
} from './Utilities';
|
||||
import ServerWebSocketBase, {
|
||||
SecureClientQuery,
|
||||
ServerEventsListener,
|
||||
} from './ServerWebSocketBase';
|
||||
import {
|
||||
createBrowserServer,
|
||||
createServer,
|
||||
TransportType,
|
||||
} from './ServerFactory';
|
||||
import {FlipperServerImpl} from '../FlipperServerImpl';
|
||||
import {
|
||||
getServerPortsConfig,
|
||||
getFlipperServerConfig,
|
||||
} from '../FlipperServerConfig';
|
||||
import {
|
||||
extractAppNameFromCSR,
|
||||
loadSecureServerConfig,
|
||||
} from '../utils/certificateUtils';
|
||||
import DesktopCertificateProvider from '../devices/desktop/DesktopCertificateProvider';
|
||||
import WWWCertificateProvider from '../fb-stubs/WWWCertificateProvider';
|
||||
import {tracker} from '../utils/tracker';
|
||||
|
||||
type ClientTimestampTracker = {
|
||||
insecureStart?: number;
|
||||
secureStart?: number;
|
||||
};
|
||||
|
||||
type ClientInfo = {
|
||||
connection: ClientConnection | null | undefined;
|
||||
client: ClientDescription;
|
||||
};
|
||||
|
||||
type ClientCsrQuery = {
|
||||
csr?: string | undefined;
|
||||
csr_path?: string | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Responsible of creating and managing the actual underlying servers:
|
||||
* - Insecure (used for certificate exchange)
|
||||
* - Secure (used for secure communication between the client and server)
|
||||
* - Browser (only ever used between Desktop and a local Browser)
|
||||
*
|
||||
* Additionally, it manages client connections.
|
||||
*/
|
||||
export class ServerController
|
||||
extends EventEmitter
|
||||
implements ServerEventsListener
|
||||
{
|
||||
connections: Map<string, ClientInfo> = new Map();
|
||||
timestamps: Map<string, ClientTimestampTracker> = new Map();
|
||||
|
||||
secureServer: ServerWebSocketBase | null = null;
|
||||
insecureServer: ServerWebSocketBase | null = null;
|
||||
altSecureServer: ServerWebSocketBase | null = null;
|
||||
altInsecureServer: ServerWebSocketBase | null = null;
|
||||
browserServer: ServerWebSocketBase | null = null;
|
||||
|
||||
connectionTracker: ConnectionTracker;
|
||||
|
||||
flipperServer: FlipperServerImpl;
|
||||
|
||||
timeHandlers: Map<string, NodeJS.Timeout> = new Map();
|
||||
|
||||
constructor(flipperServer: FlipperServerImpl) {
|
||||
super();
|
||||
this.flipperServer = flipperServer;
|
||||
this.connectionTracker = new ConnectionTracker(this.logger);
|
||||
}
|
||||
|
||||
onClientMessage(clientId: string, payload: string): void {
|
||||
this.flipperServer.emit('client-message', {
|
||||
id: clientId,
|
||||
message: payload,
|
||||
});
|
||||
}
|
||||
|
||||
get logger(): Logger {
|
||||
return this.flipperServer.logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the secure server configuration and starts any necessary servers.
|
||||
* Initialisation is complete once the initialized promise is fullfilled at
|
||||
* which point Flipper is accepting connections.
|
||||
*/
|
||||
async init() {
|
||||
if (isTest()) {
|
||||
throw new Error('Spawing new server is not supported in test');
|
||||
}
|
||||
const {insecure, secure} = getServerPortsConfig().serverPorts;
|
||||
|
||||
const options = await loadSecureServerConfig();
|
||||
|
||||
console.info('[conn] secure server listening at port: ', secure);
|
||||
this.secureServer = await createServer(secure, this, options);
|
||||
const {secure: altSecure} = getServerPortsConfig().altServerPorts;
|
||||
console.info('[conn] secure server (ws) listening at port: ', altSecure);
|
||||
this.altSecureServer = await createServer(
|
||||
altSecure,
|
||||
this,
|
||||
options,
|
||||
TransportType.WebSocket,
|
||||
);
|
||||
|
||||
console.info('[conn] insecure server listening at port: ', insecure);
|
||||
this.insecureServer = await createServer(insecure, this);
|
||||
const {insecure: altInsecure} = getServerPortsConfig().altServerPorts;
|
||||
console.info(
|
||||
'[conn] insecure server (ws) listening at port: ',
|
||||
altInsecure,
|
||||
);
|
||||
this.altInsecureServer = await createServer(
|
||||
altInsecure,
|
||||
this,
|
||||
undefined,
|
||||
TransportType.WebSocket,
|
||||
);
|
||||
|
||||
const browserPort = getServerPortsConfig().browserPort;
|
||||
console.info('[conn] Browser server (ws) listening at port: ', browserPort);
|
||||
this.browserServer = await createBrowserServer(browserPort, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* If initialized, it stops any started servers.
|
||||
*/
|
||||
async close() {
|
||||
await Promise.all([
|
||||
this.insecureServer?.stop(),
|
||||
this.secureServer?.stop(),
|
||||
this.altInsecureServer?.stop(),
|
||||
this.altSecureServer?.stop(),
|
||||
this.browserServer?.stop(),
|
||||
]);
|
||||
}
|
||||
|
||||
onConnectionCreated(
|
||||
clientQuery: SecureClientQuery,
|
||||
clientConnection: ClientConnection,
|
||||
downgrade?: boolean,
|
||||
): Promise<ClientDescription> {
|
||||
const {
|
||||
app,
|
||||
os,
|
||||
device,
|
||||
device_id,
|
||||
sdk_version,
|
||||
csr,
|
||||
csr_path,
|
||||
medium,
|
||||
rsocket,
|
||||
} = clientQuery;
|
||||
|
||||
console.info(
|
||||
`[conn] Connection established: ${app} on ${device_id}. Medium ${medium}. CSR: ${csr_path}`,
|
||||
cloneClientQuerySafeForLogging(clientQuery),
|
||||
);
|
||||
|
||||
tracker.track('app-connection-created', {
|
||||
app,
|
||||
os,
|
||||
device,
|
||||
device_id,
|
||||
medium,
|
||||
});
|
||||
|
||||
return this.addConnection(
|
||||
clientConnection,
|
||||
{
|
||||
app,
|
||||
os,
|
||||
device,
|
||||
device_id,
|
||||
sdk_version,
|
||||
medium,
|
||||
rsocket,
|
||||
},
|
||||
{csr, csr_path},
|
||||
downgrade,
|
||||
);
|
||||
}
|
||||
|
||||
onConnectionClosed(clientId: string) {
|
||||
this.removeConnection(clientId);
|
||||
}
|
||||
|
||||
onListening(port: number): void {
|
||||
this.emit('listening', port);
|
||||
}
|
||||
|
||||
onSecureConnectionAttempt(clientQuery: SecureClientQuery): void {
|
||||
const strippedClientQuery = (({device_id, ...o}) => o)(clientQuery);
|
||||
let id = buildClientId({device_id: 'unknown', ...strippedClientQuery});
|
||||
const timestamp = this.timestamps.get(id);
|
||||
if (timestamp) {
|
||||
this.timestamps.delete(id);
|
||||
}
|
||||
id = buildClientId(clientQuery);
|
||||
this.timestamps.set(id, {
|
||||
secureStart: Date.now(),
|
||||
...timestamp,
|
||||
});
|
||||
|
||||
tracker.track('app-connection-secure-attempt', {
|
||||
app: clientQuery.app,
|
||||
os: clientQuery.os,
|
||||
device: clientQuery.device,
|
||||
device_id: clientQuery.device_id,
|
||||
medium: clientQuery.medium,
|
||||
});
|
||||
|
||||
const {os, app, device_id} = clientQuery;
|
||||
// without these checks, the user might see a connection timeout error instead, which would be much harder to track down
|
||||
if (os === 'iOS' && !getFlipperServerConfig().settings.enableIOS) {
|
||||
console.error(
|
||||
`Refusing connection from ${app} on ${device_id}, since iOS support is disabled in settings`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (os === 'Android' && !getFlipperServerConfig().settings.enableAndroid) {
|
||||
console.error(
|
||||
`Refusing connection from ${app} on ${device_id}, since Android support is disabled in settings`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.connectionTracker.logConnectionAttempt(clientQuery);
|
||||
|
||||
const timeout = this.timeHandlers.get(clientQueryToKey(clientQuery));
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
if (clientQuery.medium === 'WWW' || clientQuery.medium === 'NONE') {
|
||||
this.flipperServer.registerDevice(
|
||||
new DummyDevice(
|
||||
this.flipperServer,
|
||||
clientQuery.device_id,
|
||||
clientQuery.app +
|
||||
(clientQuery.medium === 'WWW' ? ' Server Exchanged' : ''),
|
||||
clientQuery.os,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onConnectionAttempt(clientQuery: ClientQuery): void {
|
||||
const strippedClientQuery = (({device_id, ...o}) => o)(clientQuery);
|
||||
const id = buildClientId({device_id: 'unknown', ...strippedClientQuery});
|
||||
this.timestamps.set(id, {
|
||||
insecureStart: Date.now(),
|
||||
});
|
||||
|
||||
tracker.track('app-connection-insecure-attempt', clientQuery);
|
||||
|
||||
this.connectionTracker.logConnectionAttempt(clientQuery);
|
||||
|
||||
const client: UninitializedClient = {
|
||||
os: clientQuery.os,
|
||||
deviceName: clientQuery.device,
|
||||
appName: appNameWithUpdateHint(clientQuery),
|
||||
};
|
||||
this.emit('start-client-setup', client);
|
||||
}
|
||||
|
||||
onProcessCSR(
|
||||
unsanitizedCSR: string,
|
||||
clientQuery: ClientQuery,
|
||||
appDirectory: string,
|
||||
): Promise<{deviceId: string}> {
|
||||
let certificateProvider: CertificateProvider;
|
||||
switch (clientQuery.os) {
|
||||
case 'Android': {
|
||||
assertNotNull(
|
||||
this.flipperServer.android,
|
||||
'Android settings have not been provided / enabled',
|
||||
);
|
||||
certificateProvider = this.flipperServer.android.certificateProvider;
|
||||
break;
|
||||
}
|
||||
case 'iOS': {
|
||||
assertNotNull(
|
||||
this.flipperServer.ios,
|
||||
'iOS settings have not been provided / enabled',
|
||||
);
|
||||
certificateProvider = this.flipperServer.ios.certificateProvider;
|
||||
|
||||
if (clientQuery.medium === 'WWW') {
|
||||
certificateProvider = new WWWCertificateProvider(
|
||||
this.flipperServer.keytarManager,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
// Used by Spark AR studio (search for SkylightFlipperClient)
|
||||
// See D30992087
|
||||
case 'MacOS':
|
||||
case 'Windows': {
|
||||
certificateProvider = new DesktopCertificateProvider();
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error(
|
||||
`ServerController.onProcessCSR -> os ${clientQuery.os} does not support certificate exchange.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
certificateProvider.verifyMedium(clientQuery.medium);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
reportPlatformFailures(
|
||||
certificateProvider.processCertificateSigningRequest(
|
||||
unsanitizedCSR,
|
||||
clientQuery.os,
|
||||
appDirectory,
|
||||
),
|
||||
'processCertificateSigningRequest',
|
||||
)
|
||||
.then((response) => {
|
||||
const client: UninitializedClient = {
|
||||
os: clientQuery.os,
|
||||
deviceName: clientQuery.device,
|
||||
appName: appNameWithUpdateHint(clientQuery),
|
||||
};
|
||||
|
||||
this.timeHandlers.set(
|
||||
// In the original insecure connection request, `device_id` is set to "unknown".
|
||||
// Flipper queries adb/idb to learn the device ID and provides it back to the app.
|
||||
// Once app knows it, it starts using the correct device ID for its subsequent secure connections.
|
||||
// When the app re-connects securely after the cert exchange process, we need to cancel this timeout.
|
||||
// Since the original clientQuery has `device_id` set to "unknown", we update it here with the correct `device_id` to find it and cancel it later.
|
||||
clientQueryToKey({...clientQuery, device_id: response.deviceId}),
|
||||
setTimeout(() => {
|
||||
this.emit('client-unresponsive-error', {
|
||||
client,
|
||||
medium: clientQuery.medium,
|
||||
deviceID: response.deviceId,
|
||||
});
|
||||
}, 30 * 1000),
|
||||
);
|
||||
|
||||
tracker.track('app-connection-certificate-exchange', {
|
||||
...clientQuery,
|
||||
successful: true,
|
||||
device_id: response.deviceId,
|
||||
});
|
||||
|
||||
resolve(response);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
tracker.track('app-connection-certificate-exchange', {
|
||||
...clientQuery,
|
||||
successful: false,
|
||||
error: error.message,
|
||||
});
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onError(error: Error): void {
|
||||
this.emit('error', error);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return null;
|
||||
}
|
||||
|
||||
onClientSetupError(clientQuery: ClientQuery, e: any) {
|
||||
console.warn(
|
||||
`[conn] Failed to exchange certificate with ${clientQuery.app} on ${
|
||||
clientQuery.device || clientQuery.device_id
|
||||
}`,
|
||||
e,
|
||||
);
|
||||
const client: UninitializedClient = {
|
||||
os: clientQuery.os,
|
||||
deviceName: clientQuery.device,
|
||||
appName: appNameWithUpdateHint(clientQuery),
|
||||
};
|
||||
this.emit('client-setup-error', {
|
||||
client,
|
||||
error: `[conn] Failed to exchange certificate with ${
|
||||
clientQuery.app
|
||||
} on ${clientQuery.device || clientQuery.device_id}: ${e}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Client and sets the underlying connection.
|
||||
* @param connection A client connection to communicate between server and client.
|
||||
* @param query The client query created from the initial handshake.
|
||||
* @param csrQuery The CSR query which contains CSR related information.
|
||||
*/
|
||||
async addConnection(
|
||||
connection: ClientConnection,
|
||||
query: ClientQuery,
|
||||
csrQuery: ClientCsrQuery,
|
||||
silentReplace?: boolean,
|
||||
): Promise<ClientDescription> {
|
||||
invariant(query, 'expected query');
|
||||
|
||||
// try to get id by comparing giving `csr` to file from `csr_path`
|
||||
// otherwise, use given device_id
|
||||
const {csr_path, csr} = csrQuery;
|
||||
|
||||
// For Android, device id might change
|
||||
if (csr_path && csr && query.os === 'Android') {
|
||||
const app_name = await extractAppNameFromCSR(csr);
|
||||
assertNotNull(this.flipperServer.android);
|
||||
// TODO: allocate new object, kept now as is to keep changes minimal
|
||||
(query as any).device_id =
|
||||
await this.flipperServer.android.certificateProvider.getTargetDeviceId(
|
||||
app_name,
|
||||
csr_path,
|
||||
csr,
|
||||
);
|
||||
console.info(
|
||||
`[conn] Detected ${app_name} on ${query.device_id} in certificate`,
|
||||
query,
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: allocate new object, kept now as is to keep changes minimal
|
||||
(query as any).app = appNameWithUpdateHint(query);
|
||||
|
||||
const id = buildClientId(query);
|
||||
console.info(
|
||||
`[conn] Matching device for ${query.app} on ${query.device_id}...`,
|
||||
query,
|
||||
);
|
||||
|
||||
const client: ClientDescription = {
|
||||
id,
|
||||
query,
|
||||
};
|
||||
|
||||
const info = {
|
||||
client,
|
||||
connection: connection,
|
||||
};
|
||||
|
||||
console.info(
|
||||
`[conn] Initializing client ${query.app} on ${query.device_id}...`,
|
||||
query,
|
||||
);
|
||||
|
||||
connection.subscribeToEvents((status: ConnectionStatus) => {
|
||||
if (
|
||||
status === ConnectionStatus.CLOSED ||
|
||||
status === ConnectionStatus.ERROR
|
||||
) {
|
||||
this.onConnectionClosed(client.id);
|
||||
}
|
||||
});
|
||||
|
||||
console.debug(`[conn] Device client initialized: ${id}.`, 'server', query);
|
||||
|
||||
/* If a device gets disconnected without being cleaned up properly,
|
||||
* Flipper won't be aware until it attempts to reconnect.
|
||||
* When it does we need to terminate the zombie connection.
|
||||
*/
|
||||
if (this.connections.has(id)) {
|
||||
const connectionInfo = this.connections.get(id);
|
||||
if (connectionInfo) {
|
||||
if (
|
||||
connectionInfo.connection &&
|
||||
connectionInfo.connection !== connection
|
||||
) {
|
||||
if (!silentReplace) {
|
||||
connectionInfo.connection.close();
|
||||
}
|
||||
this.removeConnection(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.connections.set(id, info);
|
||||
this.flipperServer.emit('client-connected', client);
|
||||
|
||||
const tracker = this.timestamps.get(id);
|
||||
if (tracker) {
|
||||
const end = Date.now();
|
||||
const start = tracker.insecureStart
|
||||
? tracker.insecureStart
|
||||
: tracker.secureStart;
|
||||
const elapsed = Math.round(end - start!);
|
||||
this.logger.track('performance', 'client-connection-tracker', {
|
||||
'time-to-connection': elapsed,
|
||||
...query,
|
||||
});
|
||||
this.timestamps.delete(id);
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
attachFakeClient(client: ClientDescription) {
|
||||
this.connections.set(client.id, {
|
||||
client,
|
||||
connection: null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a client connection by disconnecting it, if still connected
|
||||
* and then deleting it from the tracked connections.
|
||||
* @param id The client connection identifier.
|
||||
*/
|
||||
removeConnection(id: string) {
|
||||
const info = this.connections.get(id);
|
||||
if (info) {
|
||||
console.info(
|
||||
`[conn] Disconnected: ${info.client.query.app} on ${info.client.query.device_id}.`,
|
||||
info.client.query,
|
||||
);
|
||||
this.flipperServer.emit('client-disconnected', {id});
|
||||
this.connections.delete(id);
|
||||
this.flipperServer.pluginManager.stopAllServerAddOns(id);
|
||||
}
|
||||
}
|
||||
|
||||
onDeprecationNotice(message: string) {
|
||||
const notification: FlipperServerEvents['notification'] = {
|
||||
type: 'warning',
|
||||
title: 'Deprecation notice',
|
||||
description: message,
|
||||
};
|
||||
this.flipperServer.emit('notification', notification);
|
||||
}
|
||||
}
|
||||
|
||||
class ConnectionTracker {
|
||||
timeWindowMillis = 20 * 1000;
|
||||
connectionProblemThreshold = 4;
|
||||
|
||||
// "${device}.${app}" -> [timestamp1, timestamp2...]
|
||||
connectionAttempts: Map<string, Array<number>> = new Map();
|
||||
logger: Logger;
|
||||
|
||||
constructor(logger: Logger) {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
logConnectionAttempt(client: ClientQuery) {
|
||||
const key = `${client.os}-${client.device}-${client.app}`;
|
||||
const time = Date.now();
|
||||
let entry = this.connectionAttempts.get(key) || [];
|
||||
entry.push(time);
|
||||
entry = entry.filter((t) => t >= time - this.timeWindowMillis);
|
||||
|
||||
this.connectionAttempts.set(key, entry);
|
||||
if (entry.length >= this.connectionProblemThreshold) {
|
||||
console.warn(
|
||||
`[conn] Connection loop detected with ${key}. Connected ${
|
||||
this.connectionProblemThreshold
|
||||
} times within ${this.timeWindowMillis / 1000}s.`,
|
||||
'server',
|
||||
client,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clientQueryToKey(clientQuery: ClientQuery): string {
|
||||
return `${clientQuery.app}/${clientQuery.os}/${clientQuery.device}/${clientQuery.device_id}`;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {SecureServerConfig} from '../utils/certificateUtils';
|
||||
import ServerWebSocketBase, {ServerEventsListener} from './ServerWebSocketBase';
|
||||
import ServerRSocket from './ServerRSocket';
|
||||
import SecureServerWebSocket from './SecureServerWebSocket';
|
||||
import BrowserServerWebSocket from './BrowserServerWebSocket';
|
||||
import ServerWebSocket from './ServerWebSocket';
|
||||
|
||||
export enum TransportType {
|
||||
RSocket,
|
||||
WebSocket,
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a server to be used by Flipper. The created server will be set into
|
||||
* the promise once it has started and bound to the specified port.
|
||||
* @param port A port number in which to listen for incoming connections.
|
||||
* @param listener An object implementing the ServerEventsListener interface.
|
||||
* @param sslConfig An SSL configuration for TLS servers.
|
||||
*/
|
||||
export async function createServer(
|
||||
port: number,
|
||||
listener: ServerEventsListener,
|
||||
sslConfig?: SecureServerConfig,
|
||||
transportType: TransportType = TransportType.RSocket,
|
||||
): Promise<ServerWebSocketBase> {
|
||||
let server: ServerWebSocketBase;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a server to be used by Flipper to allow Browser connections.
|
||||
* The protocol is slightly different for Browser connections hence a different
|
||||
* factory method. The created server will be set into the promise
|
||||
* once it has started and bound to the specified port.
|
||||
* @param port A port number in which to listen for incoming connections.
|
||||
* @param listener An object implementing the ServerEventsListener interface.
|
||||
* @returns
|
||||
*/
|
||||
export async function createBrowserServer(
|
||||
port: number,
|
||||
listener: ServerEventsListener,
|
||||
): Promise<ServerWebSocketBase> {
|
||||
const server = new BrowserServerWebSocket(listener);
|
||||
await server.start(port);
|
||||
return server;
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {SecureServerConfig} from '../utils/certificateUtils';
|
||||
import ServerWebSocketBase, {
|
||||
SecureClientQuery,
|
||||
ServerEventsListener,
|
||||
} from './ServerWebSocketBase';
|
||||
import tls from 'tls';
|
||||
import net, {AddressInfo, Socket} from 'net';
|
||||
import {RSocketServer} from 'rsocket-core';
|
||||
import RSocketTCPServer from 'rsocket-tcp-server';
|
||||
import {Payload, ReactiveSocket, Responder} from 'rsocket-types';
|
||||
import {Single} from 'rsocket-flowable';
|
||||
import {
|
||||
ClientConnection,
|
||||
ConnectionStatusChange,
|
||||
ConnectionStatus,
|
||||
} from './ClientConnection';
|
||||
import {
|
||||
ClientDescription,
|
||||
ClientQuery,
|
||||
ClientResponseType,
|
||||
} from 'flipper-common';
|
||||
import {transformCertificateExchangeMediumToType} from './Utilities';
|
||||
|
||||
/**
|
||||
* RSocket based server. RSocket uses its own protocol for communication between
|
||||
* client and server.
|
||||
*/
|
||||
class ServerRSocket extends ServerWebSocketBase {
|
||||
rawServer_: RSocketServer<any, any> | null | undefined;
|
||||
constructor(listener: ServerEventsListener) {
|
||||
super(listener);
|
||||
this.rawServer_ = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the server bound to the specified port. It configures
|
||||
* the RSocket server factory and request handler based on the optional
|
||||
* sslConfig argument.
|
||||
*/
|
||||
start(port: number, sslConfig?: SecureServerConfig): Promise<number> {
|
||||
const self = this;
|
||||
return new Promise((resolve, reject) => {
|
||||
// eslint-disable-next-line prefer-const
|
||||
let rawServer: RSocketServer<any, any> | undefined;
|
||||
const serverFactory = (onConnect: (socket: Socket) => void) => {
|
||||
const transportServer = sslConfig
|
||||
? tls.createServer(sslConfig, (socket) => {
|
||||
onConnect(socket);
|
||||
})
|
||||
: net.createServer(onConnect);
|
||||
transportServer.on('error', reject).on('listening', () => {
|
||||
console.debug(
|
||||
`${
|
||||
sslConfig ? 'Secure' : 'Certificate'
|
||||
} server started on port ${port}`,
|
||||
'server',
|
||||
);
|
||||
self.listener.onListening(port);
|
||||
self.rawServer_ = rawServer;
|
||||
resolve((transportServer.address() as AddressInfo).port);
|
||||
});
|
||||
return transportServer;
|
||||
};
|
||||
rawServer = new RSocketServer({
|
||||
getRequestHandler: sslConfig
|
||||
? this._trustedRequestHandler
|
||||
: this._untrustedRequestHandler,
|
||||
transport: new RSocketTCPServer({
|
||||
port: port,
|
||||
serverFactory: serverFactory,
|
||||
}),
|
||||
});
|
||||
rawServer.start();
|
||||
});
|
||||
}
|
||||
|
||||
stop(): Promise<void> {
|
||||
if (this.rawServer_) {
|
||||
return Promise.resolve(this.rawServer_.stop());
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming connection request over TLS.
|
||||
* @param socket Underlying socket connection.
|
||||
* @param payload Payload or message received.
|
||||
* @returns Returns a valid RSocket responder which will handle further
|
||||
* communication from the client.
|
||||
*/
|
||||
_trustedRequestHandler = (
|
||||
socket: ReactiveSocket<string, any>,
|
||||
payload: Payload<string, any>,
|
||||
): Partial<Responder<string, any>> => {
|
||||
if (!payload.data) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const query = JSON.parse(payload.data);
|
||||
const clientQuery: SecureClientQuery = {
|
||||
...query,
|
||||
medium: transformCertificateExchangeMediumToType(query.medium),
|
||||
rsocket: true,
|
||||
};
|
||||
|
||||
this.listener.onDeprecationNotice(
|
||||
`[conn] RSockets are being deprecated at Flipper. Please, use the latest Flipper client in your app to migrate to WebSockets. App: ${clientQuery.app}. Device: ${clientQuery.device}.`,
|
||||
);
|
||||
|
||||
this.listener.onSecureConnectionAttempt(clientQuery);
|
||||
console.info(
|
||||
`[conn] Secure rsocket connection attempt: ${clientQuery.app} on ${clientQuery.device_id}. Medium ${clientQuery.medium}. CSR: ${clientQuery.csr_path}`,
|
||||
);
|
||||
|
||||
const clientConnection: ClientConnection = {
|
||||
subscribeToEvents(subscriber: ConnectionStatusChange): void {
|
||||
socket.connectionStatus().subscribe({
|
||||
onNext(payload) {
|
||||
let status = ConnectionStatus.CONNECTED;
|
||||
|
||||
if (payload.kind == 'ERROR') status = ConnectionStatus.ERROR;
|
||||
else if (payload.kind == 'CLOSED') status = ConnectionStatus.CLOSED;
|
||||
else if (payload.kind == 'CONNECTED')
|
||||
status = ConnectionStatus.CONNECTED;
|
||||
else if (payload.kind == 'NOT_CONNECTED')
|
||||
status = ConnectionStatus.NOT_CONNECTED;
|
||||
else if (payload.kind == 'CONNECTING')
|
||||
status = ConnectionStatus.CONNECTING;
|
||||
|
||||
subscriber(status);
|
||||
},
|
||||
onSubscribe(subscription) {
|
||||
subscription.request(Number.MAX_SAFE_INTEGER);
|
||||
},
|
||||
onError(payload) {
|
||||
console.error('[client] connection status error ', payload);
|
||||
},
|
||||
});
|
||||
},
|
||||
close(): void {
|
||||
socket.close();
|
||||
},
|
||||
send(data: any): void {
|
||||
socket.fireAndForget({data: JSON.stringify(data)});
|
||||
},
|
||||
sendExpectResponse(data: any): Promise<any> {
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
socket
|
||||
.requestResponse({
|
||||
data: JSON.stringify(data),
|
||||
})
|
||||
.subscribe({
|
||||
onComplete: (payload: Payload<any, any>) => {
|
||||
const response: ClientResponseType = JSON.parse(payload.data);
|
||||
response.length = payload.data.length;
|
||||
resolve(response);
|
||||
},
|
||||
onError: (e) => {
|
||||
reject(e);
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
let resolvedClient: ClientDescription | undefined;
|
||||
const client: Promise<ClientDescription> =
|
||||
this.listener.onConnectionCreated(clientQuery, clientConnection);
|
||||
client
|
||||
.then((client) => {
|
||||
console.info(
|
||||
`[conn] Client connected: ${clientQuery.app} on ${clientQuery.device_id}. Medium ${clientQuery.medium}. CSR: ${clientQuery.csr_path}`,
|
||||
);
|
||||
resolvedClient = client;
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('[conn] Failed to resolve new client', e);
|
||||
});
|
||||
|
||||
return {
|
||||
fireAndForget: (payload: {data: string}) => {
|
||||
if (resolvedClient) {
|
||||
this.listener.onClientMessage(resolvedClient.id, payload.data);
|
||||
} else {
|
||||
client &&
|
||||
client
|
||||
.then((client) => {
|
||||
this.listener.onClientMessage(client.id, payload.data);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('Could not deliver message: ', e);
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle an incoming connection request over an insecure connection.
|
||||
* @param socket Underlying socket connection.
|
||||
* @param payload Payload or message received.
|
||||
* @returns Returns a valid RSocket responder which will handle further
|
||||
* communication from the client.
|
||||
*/
|
||||
_untrustedRequestHandler = (
|
||||
_socket: ReactiveSocket<string, any>,
|
||||
payload: Payload<string, any>,
|
||||
): Partial<Responder<string, any>> => {
|
||||
if (!payload.data) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const query = JSON.parse(payload.data);
|
||||
const clientQuery: ClientQuery = {
|
||||
...query,
|
||||
medium: transformCertificateExchangeMediumToType(query.medium),
|
||||
rsocket: true,
|
||||
};
|
||||
this.listener.onConnectionAttempt(clientQuery);
|
||||
|
||||
return {
|
||||
requestResponse: (
|
||||
payload: Payload<string, any>,
|
||||
): Single<Payload<string, any>> => {
|
||||
if (typeof payload.data !== 'string') {
|
||||
return new Single((_) => {});
|
||||
}
|
||||
|
||||
let rawData: any;
|
||||
try {
|
||||
rawData = JSON.parse(payload.data);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[conn] Invalid JSON: ${payload.data}`,
|
||||
'clientMessage',
|
||||
'server',
|
||||
);
|
||||
return new Single((_) => {});
|
||||
}
|
||||
|
||||
return new Single((subscriber) => {
|
||||
subscriber.onSubscribe(undefined);
|
||||
this._onHandleUntrustedMessage(clientQuery, rawData)
|
||||
.then((response) => {
|
||||
subscriber.onComplete({
|
||||
data: response,
|
||||
metadata: '',
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
subscriber.onError(err);
|
||||
});
|
||||
});
|
||||
},
|
||||
// Can probably refactor this out
|
||||
// Leaving this here for a while for backwards compatibility,
|
||||
// but for up to date SDKs it will no longer used.
|
||||
fireAndForget: (payload: Payload<string, any>) => {
|
||||
if (typeof payload.data !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
let rawData: any;
|
||||
try {
|
||||
rawData = JSON.parse(payload.data);
|
||||
} catch (err) {
|
||||
console.error(`Invalid JSON: ${payload.data}`, 'server');
|
||||
return;
|
||||
}
|
||||
|
||||
if (rawData && rawData.method === 'signCertificate') {
|
||||
console.debug('CSR received from device', 'server');
|
||||
this._onHandleUntrustedMessage(clientQuery, rawData)
|
||||
.then((_) => {})
|
||||
.catch((err) => {
|
||||
console.error(
|
||||
'[conn] Unable to process CSR, failed with error.',
|
||||
err,
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export default ServerRSocket;
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {IncomingMessage} from 'http';
|
||||
import ServerWebSocketBase from './ServerWebSocketBase';
|
||||
import WebSocket, {
|
||||
AddressInfo,
|
||||
Server as WSServer,
|
||||
VerifyClientCallbackSync,
|
||||
} from 'ws';
|
||||
import {createServer as createHttpsServer} from 'https';
|
||||
import {createServer as createHttpServer} from 'http';
|
||||
import querystring from 'querystring';
|
||||
import {ClientQuery, UnableToExtractClientQueryError} from 'flipper-common';
|
||||
import {
|
||||
assertNotNull,
|
||||
parseClientQuery,
|
||||
parseMessageToJson,
|
||||
verifyClientQueryComesFromCertExchangeSupportedOS,
|
||||
} from './Utilities';
|
||||
import {SecureServerConfig} from '../utils/certificateUtils';
|
||||
import {Server} from 'net';
|
||||
import {serializeError} from 'serialize-error';
|
||||
import {WSCloseCode} from '../utils/WSCloseCode';
|
||||
|
||||
export interface ConnectionCtx {
|
||||
clientQuery?: ClientQuery;
|
||||
ws: WebSocket;
|
||||
request: IncomingMessage;
|
||||
}
|
||||
|
||||
// Based on https://github.com/websockets/ws/blob/master/lib/websocket-server.js#L40,
|
||||
// exposed to share with socket.io defaults.
|
||||
export const WEBSOCKET_MAX_MESSAGE_SIZE = 100 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* It serves as a base class for WebSocket based servers. It delegates the 'connection'
|
||||
* event to subclasses as a customisation point.
|
||||
*/
|
||||
class ServerWebSocket extends ServerWebSocketBase {
|
||||
protected wsServer?: WSServer;
|
||||
private httpServer?: Server;
|
||||
|
||||
async start(port: number, sslConfig?: SecureServerConfig): Promise<number> {
|
||||
const assignedPort = await new Promise<number>((resolve, reject) => {
|
||||
const server = sslConfig
|
||||
? createHttpsServer(sslConfig)
|
||||
: createHttpServer();
|
||||
|
||||
const wsServer = new WSServer({
|
||||
server,
|
||||
verifyClient: this.verifyClient(),
|
||||
maxPayload: WEBSOCKET_MAX_MESSAGE_SIZE,
|
||||
});
|
||||
|
||||
// We do not need to listen to http server's `error` because it is propagated to WS
|
||||
// https://github.com/websockets/ws/blob/a3a22e4ed39c1a3be8e727e9c630dd440edc61dd/lib/websocket-server.js#L109
|
||||
const onConnectionError = (error: Error) => {
|
||||
reject(
|
||||
new Error(
|
||||
`Unable to start server at port ${port} due to ${JSON.stringify(
|
||||
serializeError(error),
|
||||
)}`,
|
||||
),
|
||||
);
|
||||
};
|
||||
wsServer.once('error', onConnectionError);
|
||||
server.listen(port, () => {
|
||||
console.debug(
|
||||
`${sslConfig ? 'Secure' : 'Insecure'} server started on port ${port}`,
|
||||
'server',
|
||||
);
|
||||
|
||||
// Unsubscribe connection error listener.
|
||||
// We'll attach a permanent error listener later.
|
||||
wsServer.off('error', onConnectionError);
|
||||
|
||||
this.listener.onListening(port);
|
||||
this.wsServer = wsServer;
|
||||
this.httpServer = server;
|
||||
|
||||
resolve((server.address() as AddressInfo).port);
|
||||
});
|
||||
});
|
||||
|
||||
assertNotNull(this.wsServer);
|
||||
assertNotNull(this.httpServer);
|
||||
|
||||
this.wsServer.on(
|
||||
'connection',
|
||||
(ws: WebSocket, request: IncomingMessage) => {
|
||||
ws.on('error', (error) => {
|
||||
console.error('[conn] WS connection error:', error);
|
||||
this.listener.onError(error);
|
||||
});
|
||||
|
||||
try {
|
||||
this.onConnection(ws, request);
|
||||
} catch (error) {
|
||||
// TODO: Investigate if we need to close the socket in the `error` listener
|
||||
// DRI: @aigoncharov
|
||||
ws.close(WSCloseCode.InternalError);
|
||||
|
||||
if (error instanceof UnableToExtractClientQueryError) {
|
||||
// If we are unable to extract the client query, do not emit an error.
|
||||
// It cannot be determined if the client is legitimately trying to establish
|
||||
// a connection with Flipper or some other process is trying to connect to
|
||||
// the port currently used by Flipper.
|
||||
return;
|
||||
}
|
||||
// If an exception is thrown, an `error` event is not emitted automatically.
|
||||
// We need to explicitly handle the error and emit an error manually.
|
||||
// If we leave it unhanled, the process just dies
|
||||
// https://replit.com/@aigoncharov/WS-error-handling#index.js
|
||||
ws.emit('error', error);
|
||||
}
|
||||
},
|
||||
);
|
||||
this.wsServer.on('error', (error) => {
|
||||
console.error('[conn] WS server error:', error);
|
||||
this.listener.onError(error);
|
||||
});
|
||||
|
||||
return assignedPort;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.wsServer) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
console.info('[conn] Stopping WS server');
|
||||
assertNotNull(this.wsServer);
|
||||
this.wsServer.close((err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
console.info('[conn] Stopping HTTP server');
|
||||
assertNotNull(this.httpServer);
|
||||
this.httpServer.close((err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A connection has been established between the server and a client. Only ever used for
|
||||
* certificate exchange.
|
||||
*
|
||||
* @param ws An active WebSocket.
|
||||
* @param request Incoming request message.
|
||||
*/
|
||||
onConnection(ws: WebSocket, request: IncomingMessage): void {
|
||||
const ctx: ConnectionCtx = {ws, request};
|
||||
|
||||
this.extractClientQuery(ctx);
|
||||
this.handleConnectionAttempt(ctx);
|
||||
|
||||
ws.on('message', async (message: WebSocket.RawData) => {
|
||||
const messageString = message.toString();
|
||||
try {
|
||||
const parsedMessage = this.handleMessageDeserialization(messageString);
|
||||
// Successful deserialization is a proof that the message is a string
|
||||
this.handleMessage(ctx, parsedMessage, messageString);
|
||||
} catch (error) {
|
||||
// If handling an individual message failes, we don't necessarily kill the connection,
|
||||
// all other plugins might still be working correctly. So let's just report it.
|
||||
// This avoids ping-ponging connections if an individual plugin sends garbage (e.g. T129428800)
|
||||
// or throws an error when handling messages
|
||||
console.error('[conn] Failed to handle message', messageString, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract and create a ClientQuery from the request URL. This method will throw if:
|
||||
* @param ctx The connection context.
|
||||
* @returns It doesn't return anything, if the client query
|
||||
* is extracted, this one is set into the connection context.
|
||||
*/
|
||||
protected extractClientQuery(ctx: ConnectionCtx): void {
|
||||
const {request} = ctx;
|
||||
if (!request.url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const query = querystring.decode(request.url.split('?')[1]);
|
||||
const clientQuery = this.parseClientQuery(query);
|
||||
|
||||
if (!clientQuery) {
|
||||
console.warn(
|
||||
'[conn] Unable to extract the client query from the request URL.',
|
||||
request.url,
|
||||
);
|
||||
throw new UnableToExtractClientQueryError(
|
||||
'[conn] Unable to extract the client query from the request URL.',
|
||||
);
|
||||
}
|
||||
|
||||
ctx.clientQuery = clientQuery;
|
||||
}
|
||||
|
||||
protected handleConnectionAttempt(ctx: ConnectionCtx): void {
|
||||
const {clientQuery} = ctx;
|
||||
assertNotNull(clientQuery);
|
||||
|
||||
console.info(
|
||||
`[conn] Insecure websocket connection attempt: ${clientQuery.app} on ${clientQuery.device_id}.`,
|
||||
);
|
||||
this.listener.onConnectionAttempt(clientQuery);
|
||||
}
|
||||
|
||||
protected handleMessageDeserialization(message: unknown): object {
|
||||
const parsedMessage = parseMessageToJson(message);
|
||||
if (!parsedMessage) {
|
||||
console.error('[conn] Failed to parse message', message);
|
||||
throw new Error(`[conn] Failed to parse message`);
|
||||
}
|
||||
return parsedMessage;
|
||||
}
|
||||
|
||||
protected async handleMessage(
|
||||
ctx: ConnectionCtx,
|
||||
parsedMessage: object,
|
||||
// Not used in this method, but left as a reference for overriding classes.
|
||||
_rawMessage: string,
|
||||
) {
|
||||
const {clientQuery, ws} = ctx;
|
||||
assertNotNull(clientQuery);
|
||||
|
||||
const response = await this._onHandleUntrustedMessage(
|
||||
clientQuery,
|
||||
parsedMessage,
|
||||
);
|
||||
if (response) {
|
||||
ws.send(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and extract a ClientQuery instance from a message. The ClientQuery
|
||||
* data will be contained in the message url query string.
|
||||
* @param message An incoming web socket message.
|
||||
*/
|
||||
protected parseClientQuery(
|
||||
query: querystring.ParsedUrlQuery,
|
||||
): ClientQuery | undefined {
|
||||
return verifyClientQueryComesFromCertExchangeSupportedOS(
|
||||
parseClientQuery(query),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket client verification. Usually used to validate the origin.
|
||||
*
|
||||
* Base implementation simply returns true, but this can be overriden by subclasses
|
||||
* that require verification.
|
||||
*
|
||||
* @returns Return true if the client was successfully verified, otherwise
|
||||
* returns false.
|
||||
*/
|
||||
protected verifyClient(): VerifyClientCallbackSync {
|
||||
return (_info: {origin: string; req: IncomingMessage; secure: boolean}) => {
|
||||
// Client verification is not necessary. The connected client has
|
||||
// already been verified using its certificate signed by the server.
|
||||
return true;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default ServerWebSocket;
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {ClientConnection} from './ClientConnection';
|
||||
import {
|
||||
ClientDescription,
|
||||
ClientQuery,
|
||||
SignCertificateMessage,
|
||||
} from 'flipper-common';
|
||||
import {SecureServerConfig} from '../utils/certificateUtils';
|
||||
|
||||
/**
|
||||
* ClientCsrQuery defines a client query with CSR
|
||||
* information.
|
||||
*/
|
||||
export type ClientCsrQuery = {
|
||||
csr?: string | undefined;
|
||||
csr_path?: string | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* SecureClientQuery combines a ClientQuery with
|
||||
* ClientCsrQuery. It also adds medium information.
|
||||
*/
|
||||
export type SecureClientQuery = ClientQuery & ClientCsrQuery;
|
||||
|
||||
/**
|
||||
* Defines an interface for events triggered by a running server interacting
|
||||
* with a client.
|
||||
*/
|
||||
export interface ServerEventsListener {
|
||||
/**
|
||||
* Server started and listening at the specified port.
|
||||
* @param port The port in which the server is listening to.
|
||||
*/
|
||||
onListening(port: number): void;
|
||||
/**
|
||||
* An insecure connection attempt has been made by a client. At this
|
||||
* point, a connection should be already be available but needs to be
|
||||
* validated by the server.
|
||||
* @param clientQuery A ClientQuery instance containing metadata about
|
||||
* the client e.g. OS, device, app, etc.
|
||||
*/
|
||||
onConnectionAttempt(clientQuery: ClientQuery): void;
|
||||
/**
|
||||
* A TLS connection attempt has been made by a client. At this
|
||||
* point, a connection should be already be available but needs to be
|
||||
* validated by the server.
|
||||
* @param clientQuery A SecureClientQuery instance containing metadata about
|
||||
* the client and CSR information as exchanged on the previously
|
||||
* established insecure connection.
|
||||
*/
|
||||
onSecureConnectionAttempt(clientQuery: SecureClientQuery): void;
|
||||
/**
|
||||
* CSR received by the server and needs to be processed. If successfully
|
||||
* processed, it should return a generated device identifier.
|
||||
* @param unsanitizedCSR CSR as sent by the client, will need to be sanitized
|
||||
* before usage.
|
||||
* @param clientQuery A ClientQuery instance containing metadata about
|
||||
* the client e.g. OS, device, app, etc.
|
||||
* @param appDirectory App directory in which to deploy the CA and client
|
||||
* certificates.
|
||||
* @param medium Certificate exchange medium type e.g. FS_ACCESS, WWW.
|
||||
*/
|
||||
onProcessCSR(
|
||||
unsanitizedCSR: string,
|
||||
clientQuery: ClientQuery,
|
||||
appDirectory: string,
|
||||
): Promise<{deviceId: string}>;
|
||||
/**
|
||||
* A secure connection has been established with a validated client.
|
||||
* A promise to a Client instance needs to be returned.
|
||||
* @param clientQuery A SecureClientQuery instance containing metadata about
|
||||
* the client and CSR information as exchanged on the previously
|
||||
* established insecure connection.
|
||||
* @param clientConnection A valid client connection.
|
||||
*/
|
||||
onConnectionCreated(
|
||||
clientQuery: SecureClientQuery,
|
||||
clientConnection: ClientConnection,
|
||||
downgrade?: boolean,
|
||||
): Promise<ClientDescription>;
|
||||
/**
|
||||
* A connection with a client has been closed.
|
||||
* @param id The client identifier.
|
||||
*/
|
||||
onConnectionClosed(id: string): void;
|
||||
/**
|
||||
* An error has occurred.
|
||||
* @param error An Error instance.
|
||||
*/
|
||||
onError(error: Error): void;
|
||||
/**
|
||||
* A message was received for a specif client
|
||||
* // TODO: payload should become JSON
|
||||
*/
|
||||
onClientMessage(clientId: string, payload: string): void;
|
||||
|
||||
onClientSetupError(clientQuery: ClientQuery, error: any): void;
|
||||
|
||||
onDeprecationNotice: (message: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the base class to be used by any server implementation e.g.
|
||||
* RSocket, WebSocket, etc.
|
||||
*/
|
||||
abstract class ServerWebSocketBase {
|
||||
constructor(protected listener: ServerEventsListener) {}
|
||||
|
||||
/**
|
||||
* Start and bind server to the specified port.
|
||||
* @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<number>;
|
||||
/**
|
||||
* Stop the server.
|
||||
*/
|
||||
abstract stop(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Handle a message received over an insecure connection. The only
|
||||
* supported message is to sign certificates.
|
||||
* @param clientQuery A ClientQuery instance containing metadata about
|
||||
* the client e.g. OS, device, app, etc.
|
||||
* @param rawData Raw data as sent by the client.
|
||||
* @returns The response to be sent back to the client. If the received
|
||||
* request is to sign a certificate and no errors were found, the response
|
||||
* should contain the device identifier to use by the client.
|
||||
*/
|
||||
protected async _onHandleUntrustedMessage(
|
||||
clientQuery: ClientQuery,
|
||||
rawData: any,
|
||||
): Promise<string | undefined> {
|
||||
// OSS's older Client SDK might not send medium information.
|
||||
// This is not an issue for internal FB users, as Flipper release
|
||||
// is insync with client SDK through launcher.
|
||||
|
||||
const message: SignCertificateMessage = rawData;
|
||||
|
||||
console.info(
|
||||
`[conn] Connection attempt: ${clientQuery.app} on ${clientQuery.device}, medium: ${message.medium}, cert: ${message.destination}`,
|
||||
clientQuery,
|
||||
);
|
||||
|
||||
if (message.method === 'signCertificate') {
|
||||
console.debug('CSR received from device', 'server');
|
||||
|
||||
const {csr, destination} = message;
|
||||
|
||||
console.info(
|
||||
`[conn] Starting certificate exchange: ${clientQuery.app} on ${clientQuery.device}`,
|
||||
);
|
||||
try {
|
||||
const result = await this.listener.onProcessCSR(
|
||||
csr,
|
||||
clientQuery,
|
||||
destination,
|
||||
);
|
||||
|
||||
console.info(
|
||||
`[conn] Exchanged certificate: ${clientQuery.app} on ${result.deviceId}`,
|
||||
);
|
||||
const response = JSON.stringify({
|
||||
deviceId: result.deviceId,
|
||||
});
|
||||
return response;
|
||||
} catch (e) {
|
||||
this.listener.onClientSetupError(clientQuery, e);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export default ServerWebSocketBase;
|
||||
224
desktop/flipper-server-core/src/app-connectivity/Utilities.tsx
Normal file
224
desktop/flipper-server-core/src/app-connectivity/Utilities.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {
|
||||
CertificateExchangeMedium,
|
||||
ClientQuery,
|
||||
DeviceOS,
|
||||
ResponseMessage,
|
||||
} from 'flipper-common';
|
||||
import {ParsedUrlQuery} from 'querystring';
|
||||
import {SecureClientQuery} from './ServerWebSocketBase';
|
||||
|
||||
/**
|
||||
* Transforms the certificate exchange medium type as number to the
|
||||
* CertificateExchangeMedium type.
|
||||
* @param medium A number representing the certificate exchange medium type.
|
||||
*/
|
||||
export function transformCertificateExchangeMediumToType(
|
||||
medium: number | undefined,
|
||||
): CertificateExchangeMedium {
|
||||
switch (medium) {
|
||||
case undefined:
|
||||
case 1:
|
||||
return 'FS_ACCESS';
|
||||
case 2:
|
||||
return 'WWW';
|
||||
case 3:
|
||||
return 'NONE';
|
||||
default:
|
||||
throw new Error('Unknown Certificate exchange medium: ' + medium);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the app name from a ClientQuery instance. In most cases it should be
|
||||
* the app name as given in the query. On Android, and for old SDK versions (<3) it
|
||||
* will returned the app name suffixed by '(Outdated SDK)'.
|
||||
*
|
||||
* Reason is, in previous version (<3), app may not appear in correct device
|
||||
* section because it refers to the name given by client which is not fixed
|
||||
* for android emulators, so it is indicated as outdated so that developers
|
||||
* might want to update SDK to get rid of this connection swap problem
|
||||
* @param query A ClientQuery object.
|
||||
*/
|
||||
export function appNameWithUpdateHint(query: ClientQuery): string {
|
||||
if (query.os === 'Android' && (!query.sdk_version || query.sdk_version < 3)) {
|
||||
return query.app + ' (Outdated SDK)';
|
||||
}
|
||||
return query.app;
|
||||
}
|
||||
|
||||
export function parseMessageToJson<T extends object = object>(
|
||||
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<DeviceOS>([
|
||||
'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;
|
||||
}
|
||||
|
||||
let medium: number | undefined;
|
||||
if (typeof query.medium === 'string') {
|
||||
medium = parseInt(query.medium, 10);
|
||||
} else if (typeof query.medium === 'number') {
|
||||
medium = query.medium;
|
||||
}
|
||||
|
||||
if (medium !== undefined && (medium < 1 || medium > 3)) {
|
||||
throw new Error('Unsupported exchange medium: ' + medium);
|
||||
}
|
||||
|
||||
const clientQuery: ClientQuery = {
|
||||
device_id,
|
||||
device,
|
||||
app,
|
||||
os,
|
||||
medium: transformCertificateExchangeMediumToType(medium),
|
||||
};
|
||||
|
||||
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);
|
||||
} else if (typeof query.medium === 'number') {
|
||||
medium = query.medium;
|
||||
}
|
||||
|
||||
if (medium !== undefined && (medium < 1 || medium > 3)) {
|
||||
throw new Error('Unsupported exchange medium: ' + medium);
|
||||
}
|
||||
return {
|
||||
...clientQuery,
|
||||
csr,
|
||||
csr_path,
|
||||
medium: transformCertificateExchangeMediumToType(medium),
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneClientQuerySafeForLogging(clientQuery: SecureClientQuery) {
|
||||
return {...clientQuery, csr: !clientQuery.csr ? clientQuery.csr : '<hidden>'};
|
||||
}
|
||||
|
||||
// TODO: Merge with the same fn in desktop/app/src/utils
|
||||
export function assertNotNull<T extends any>(
|
||||
value: T,
|
||||
message: string = 'Unexpected null/undefined value found',
|
||||
): asserts value is Exclude<T, undefined | null> {
|
||||
if (value === null || value === undefined) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {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<number, PendingRequestResolvers> = 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<ClientResponseType> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pendingRequests.set(data.id, {reject, resolve});
|
||||
this.ws.send(this.serializeData(data));
|
||||
});
|
||||
}
|
||||
|
||||
matchPendingRequest(id: number): PendingRequestResolvers | undefined {
|
||||
const callbacks = this.pendingRequests.get(id);
|
||||
|
||||
if (!callbacks) {
|
||||
console.debug(`[conn] Pending request ${id} is not found. Ignore.`);
|
||||
// It must be a response for a message from the older connection. Ignore.
|
||||
// TODO: When we decide to bump sdk_version, make `id` a string equal to `connectionId:messageId`. Ignore messages only from other conections. Raise an error for missing mesages from this connection.
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingRequests.delete(id);
|
||||
return callbacks;
|
||||
}
|
||||
|
||||
protected serializeData(data: object): string {
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {
|
||||
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 '../ServerWebSocketBase';
|
||||
import {createMockSEListener, WSMessageAccumulator} from './utils';
|
||||
|
||||
jest.mock('../../FlipperServerConfig');
|
||||
(getFlipperServerConfig as jest.Mock).mockImplementation(() => ({
|
||||
validWebSocketOrigins: ['http://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: 'http://localhost'},
|
||||
);
|
||||
wsClient.onmessage = ({data}) => clientReceivedMessages.add(data);
|
||||
await new Promise<void>((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: 'NONE',
|
||||
};
|
||||
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: 'http://localhost'},
|
||||
);
|
||||
wsClient.onmessage = ({data}) => clientReceivedMessages.add(data);
|
||||
await new Promise<void>((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: 'NONE',
|
||||
};
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {
|
||||
DeviceOS,
|
||||
ExecuteMessage,
|
||||
GetPluginsMessage,
|
||||
ResponseMessage,
|
||||
} from 'flipper-common';
|
||||
import {toBase64} from 'js-base64';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
import SecureServerWebSocket from '../SecureServerWebSocket';
|
||||
import {SecureClientQuery} from '../ServerWebSocketBase';
|
||||
import {transformCertificateExchangeMediumToType} from '../Utilities';
|
||||
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<void>((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: transformCertificateExchangeMediumToType(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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {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}&medium=2`,
|
||||
);
|
||||
const receivedMessages = new WSMessageAccumulator();
|
||||
await new Promise<void>((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,
|
||||
medium: 'WWW',
|
||||
};
|
||||
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(
|
||||
/EADDRINUSE/,
|
||||
);
|
||||
|
||||
expect(mockSEListener.onListening).toBeCalledTimes(1);
|
||||
expect(mockSEListener.onError).toBeCalledTimes(0); // no onError triggered, as start throws already
|
||||
});
|
||||
|
||||
test('does NOT call listener onError if individual 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<void>((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
|
||||
|
||||
expect(mockSEListener.onError).toBeCalledTimes(0);
|
||||
expect(mockSEListener.onListening).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {ClientQuery, ClientDescription} from 'flipper-common';
|
||||
import {ServerEventsListener} from '../ServerWebSocketBase';
|
||||
|
||||
export class WSMessageAccumulator {
|
||||
private messages: unknown[] = [];
|
||||
private newMessageSubscribers: ((newMessageContent: unknown) => void)[] = [];
|
||||
|
||||
constructor(private readonly timeout = 1000) {}
|
||||
|
||||
get newMessage(): Promise<unknown> {
|
||||
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<ClientDescription> =>
|
||||
Promise.resolve({
|
||||
id: 'tatooine',
|
||||
query,
|
||||
}),
|
||||
),
|
||||
onConnectionClosed: jest.fn(),
|
||||
onError: jest.fn(),
|
||||
onClientMessage: jest.fn(),
|
||||
onClientSetupError: jest.fn(),
|
||||
onDeprecationNotice: jest.fn(),
|
||||
});
|
||||
Reference in New Issue
Block a user