Move app/server to flipper-server-core

Summary: moved `app/src/server` to `flipper-server-core/src` and fixed any fallout from that (aka integration points I missed on the preparing diffs).

Reviewed By: passy

Differential Revision: D31541378

fbshipit-source-id: 8a7e0169ebefa515781f6e5e0f7b926415d4b7e9
This commit is contained in:
Michel Weststrate
2021-10-12 15:59:44 -07:00
committed by Facebook GitHub Bot
parent 3e7a6b1b4b
commit d88b28330a
73 changed files with 563 additions and 534 deletions

View File

@@ -0,0 +1,84 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {ClientResponseType} from 'flipper-common';
import WebSocket from 'ws';
import {
ConnectionStatusChange,
ConnectionStatus,
ClientConnection,
} from './ClientConnection';
export class BrowserClientFlipperConnection implements ClientConnection {
websocket: WebSocket;
connStatusSubscribers: Set<ConnectionStatusChange> = new Set();
connStatus: ConnectionStatus;
app: string;
plugins: string[] | undefined = undefined;
constructor(ws: WebSocket, app: string, plugins: string[]) {
this.websocket = ws;
this.connStatus = ConnectionStatus.CONNECTED;
this.app = app;
this.plugins = plugins;
}
subscribeToEvents(subscriber: ConnectionStatusChange): void {
this.connStatusSubscribers.add(subscriber);
}
send(data: any): void {
this.websocket.send(
JSON.stringify({
type: 'send',
app: this.app,
payload: data != null ? data : {},
}),
);
}
sendExpectResponse(data: any): Promise<ClientResponseType> {
return new Promise((resolve, reject) => {
const {id: callId = undefined, method = undefined} =
data != null ? data : {};
if (method === 'getPlugins' && this.plugins != null) {
resolve({
success: {plugins: this.plugins},
length: 0,
});
return;
}
this.websocket.send(
JSON.stringify({
type: 'call',
app: this.app,
payload: data != null ? data : {},
}),
);
this.websocket.on('message', (message: string) => {
const {app, payload} = JSON.parse(message);
if (app === this.app && payload?.id === callId) {
resolve(payload);
}
});
this.websocket.on('error', (error: Error) => {
reject(error);
});
});
}
close(): void {
this.connStatus = ConnectionStatus.CLOSED;
this.connStatusSubscribers.forEach((subscriber) => {
subscriber(this.connStatus);
});
this.websocket.send(JSON.stringify({type: 'disconnect', app: this.app}));
}
}

View File

@@ -0,0 +1,27 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {ClientResponseType} from 'flipper-common';
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: any): void;
sendExpectResponse(data: any): Promise<ClientResponseType>;
}

View File

@@ -0,0 +1,200 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {
CertificateExchangeMedium,
SecureServerConfig,
} from '../utils/CertificateProvider';
import {ClientConnection} from './ClientConnection';
import {transformCertificateExchangeMediumToType} from './Utilities';
import {ClientDescription, ClientQuery} from 'flipper-common';
/**
* 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 & {medium: 1 /*FS*/ | 2 /*WWW*/ | 3 /*NONE*/ | undefined};
/**
* 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,
medium: CertificateExchangeMedium,
): 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,
): 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;
}
/**
* Defines the base class to be used by any server implementation e.g.
* RSocket, WebSocket, etc.
*/
abstract class ServerAdapter {
listener: ServerEventsListener;
constructor(listener: ServerEventsListener) {
this.listener = listener;
}
/**
* Start and bind server to the specified port.
* @param port A port number.
* @param sslConfig An optional SSL configuration to be used for
* TLS servers.
*/
abstract start(
port: number,
sslConfig?: SecureServerConfig,
): Promise<boolean>;
/**
* 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.
*/
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: {
method: 'signCertificate';
csr: string;
destination: string;
medium: number | undefined;
} = 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, medium} = message;
console.info(
`[conn] Starting certificate exchange: ${clientQuery.app} on ${clientQuery.device}`,
);
try {
const result = await this.listener.onProcessCSR(
csr,
clientQuery,
destination,
transformCertificateExchangeMediumToType(medium),
);
console.info(
`[conn] Exchanged certificate: ${clientQuery.app} on ${result.deviceId}`,
);
const response = JSON.stringify({
deviceId: result.deviceId,
});
return response;
} catch (e) {
console.error(
`[conn] Failed to exchange certificate with ${clientQuery.app} on ${
clientQuery.device || clientQuery.device_id
}`,
e,
);
}
}
return undefined;
}
}
export default ServerAdapter;

View File

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

View File

@@ -0,0 +1,88 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {SecureServerConfig} from '../utils/CertificateProvider';
import ServerAdapter, {ServerEventsListener} from './ServerAdapter';
import ServerRSocket from './ServerRSocket';
import ServerWebSocket from './ServerWebSocket';
import ServerWebSocketBrowser from './ServerWebSocketBrowser';
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 function createServer(
port: number,
listener: ServerEventsListener,
sslConfig?: SecureServerConfig,
transportType: TransportType = TransportType.RSocket,
): Promise<ServerAdapter> {
return new Promise((resolve, reject) => {
const server =
transportType === TransportType.RSocket
? new ServerRSocket(listener)
: new ServerWebSocket(listener);
server
.start(port, sslConfig)
.then((started) => {
if (started) {
resolve(server);
} else {
reject(
new Error(`An error occurred whilst trying
to start the server listening at port ${port}`),
);
}
})
.catch((error: any) => {
reject(error);
});
});
}
/**
* 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 function createBrowserServer(
port: number,
listener: ServerEventsListener,
): Promise<ServerAdapter> {
return new Promise((resolve, reject) => {
const server = new ServerWebSocketBrowser(listener);
server
.start(port)
.then((started) => {
if (started) {
resolve(server);
} else {
reject(
new Error(`An error occurred whilst trying
to start the server listening at port ${port}`),
);
}
})
.catch((error: any) => {
reject(error);
});
});
}

View File

@@ -0,0 +1,286 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {SecureServerConfig} from '../utils/CertificateProvider';
import ServerAdapter, {
SecureClientQuery,
ServerEventsListener,
} from './ServerAdapter';
import tls from 'tls';
import net, {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';
/**
* RSocket based server. RSocket uses its own protocol for communication between
* client and server.
*/
class ServerRSocket extends ServerAdapter {
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<boolean> {
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', (err) => {
self.listener.onError(err);
console.error(`Error opening server on port ${port}`, 'server');
reject(err);
})
.on('listening', () => {
console.debug(
`${
sslConfig ? 'Secure' : 'Certificate'
} server started on port ${port}`,
'server',
);
self.listener.onListening(port);
self.rawServer_ = rawServer;
resolve(true);
});
return transportServer;
};
rawServer = new RSocketServer({
getRequestHandler: sslConfig
? this._trustedRequestHandler
: this._untrustedRequestHandler,
transport: new RSocketTCPServer({
port: port,
serverFactory: serverFactory,
}),
});
rawServer && 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 clientQuery: SecureClientQuery = JSON.parse(payload.data);
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 clientQuery: ClientQuery = JSON.parse(payload.data);
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;

View File

@@ -0,0 +1,300 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import ServerWebSocketBase from './ServerWebSocketBase';
import WebSocket from 'ws';
import ws from 'ws';
import {SecureClientQuery, ServerEventsListener} from './ServerAdapter';
import querystring from 'querystring';
import {
ClientConnection,
ConnectionStatus,
ConnectionStatusChange,
} from './ClientConnection';
import {IncomingMessage} from 'http';
import {
ClientDescription,
ClientErrorType,
ClientQuery,
DeviceOS,
} from 'flipper-common';
/**
* WebSocket-based server.
*/
class ServerWebSocket extends ServerWebSocketBase {
constructor(listener: ServerEventsListener) {
super(listener);
}
/**
* Client verification is not necessary. The connected client has
* already been verified using its certificate signed by the server.
* @returns
*/
verifyClient(): ws.VerifyClientCallbackSync {
return (_info: {origin: string; req: IncomingMessage; secure: boolean}) => {
return true;
};
}
/**
* A connection has been established between the server and a client. Only ever used for
* certificate exchange.
* @param ws An active WebSocket.
* @param message Incoming request message.
*/
onConnection(ws: WebSocket, message: any): void {
const query = querystring.decode(message.url.split('?')[1]);
const clientQuery = this._parseClientQuery(query);
if (!clientQuery) {
console.warn(
'[conn] Unable to extract the client query from the request URL.',
);
ws.close();
return;
}
console.info(
`[conn] Insecure websocket connection attempt: ${clientQuery.app} on ${clientQuery.device_id}.`,
clientQuery,
);
this.listener.onConnectionAttempt(clientQuery);
ws.on('message', async (message: any) => {
const json = JSON.parse(message.toString());
const response = await this._onHandleUntrustedMessage(clientQuery, json);
if (response) {
ws.send(response);
}
});
}
/**
* A secure connection has been established between the server and a client. Once a client
* has a valid certificate, it can use a secure connection with Flipper and start exchanging
* messages.
* @param _ws An active WebSocket.
* @param message Incoming request message.
*/
onSecureConnection(ws: WebSocket, message: any): void {
const query = querystring.decode(message.url.split('?')[1]);
const clientQuery = this._parseSecureClientQuery(query);
if (!clientQuery) {
console.warn(
'[conn] Unable to extract the client query from the request URL.',
);
ws.close();
return;
}
console.info(
`[conn] Secure websocket connection attempt: ${clientQuery.app} on ${clientQuery.device_id}. Medium ${clientQuery.medium}. CSR: ${clientQuery.csr_path}`,
clientQuery,
);
this.listener.onSecureConnectionAttempt(clientQuery);
const pendingRequests: Map<
number,
{
resolve: (data: any) => void;
reject: (err: Error) => void;
}
> = new Map();
const clientConnection: ClientConnection = {
subscribeToEvents(subscriber: ConnectionStatusChange): void {
ws.on('close', () => subscriber(ConnectionStatus.CLOSED));
ws.on('error', () => subscriber(ConnectionStatus.ERROR));
},
close(): void {
ws.close();
},
send(data: any): void {
ws.send(JSON.stringify(data));
},
sendExpectResponse(data: any): Promise<any> {
return new Promise((resolve, reject) => {
pendingRequests.set(data.id, {reject, resolve});
ws.send(JSON.stringify(data));
});
},
};
let resolvedClient: ClientDescription | undefined;
const client: Promise<ClientDescription> =
this.listener.onConnectionCreated(clientQuery, clientConnection);
client
.then((client) => (resolvedClient = client))
.catch((e) => {
console.error(
`[conn] Failed to resolve client ${clientQuery.app} on ${clientQuery.device_id} medium ${clientQuery.medium}`,
e,
);
});
ws.on('message', (message: any) => {
let json: any | undefined;
try {
json = JSON.parse(message);
} catch (err) {
console.warn(`Invalid JSON: ${message}`, 'clientMessage');
return;
}
const data: {
id?: number;
success?: Object | undefined;
error?: ClientErrorType | undefined;
} = json;
if (data.hasOwnProperty('id') && data.id !== undefined) {
const callbacks = pendingRequests.get(data.id);
if (!callbacks) {
return;
}
pendingRequests.delete(data.id);
if (data.success) {
callbacks.resolve && callbacks.resolve(data);
} else if (data.error) {
callbacks.reject && callbacks.reject(data.error);
}
} else {
if (resolvedClient) {
this.listener.onClientMessage(resolvedClient.id, message);
} else {
client &&
client
.then((client) => {
this.listener.onClientMessage(client.id, message);
})
.catch((e) => {
console.warn(
'Could not deliver message, client did not resolve. ',
e,
);
});
}
}
});
}
/**
* Validates a string as being one of those defined as valid OS.
* @param str An input string.
*/
private isOS(str: string): str is DeviceOS {
return (
str === 'iOS' ||
str === 'Android' ||
str === 'Metro' ||
str === 'Windows' ||
str === 'MacOS'
);
}
/**
* 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.
*/
private _parseClientQuery(
query: querystring.ParsedUrlQuery,
): ClientQuery | undefined {
/** Any required arguments to construct a ClientQuery come
* embedded in the query string.
*/
let device_id: string | undefined;
if (typeof query.device_id === 'string') {
device_id = query.device_id;
} else {
return;
}
let device: string | undefined;
if (typeof query.device === 'string') {
device = query.device;
} else {
return;
}
let app: string | undefined;
if (typeof query.app === 'string') {
app = query.app;
} else {
return;
}
let os: DeviceOS | undefined;
if (typeof query.os === 'string' && this.isOS(query.os)) {
os = query.os;
} else {
return;
}
const clientQuery: ClientQuery = {
device_id,
device,
app,
os,
};
if (typeof query.sdk_version === 'string') {
const sdk_version = parseInt(query.sdk_version, 10);
if (sdk_version) {
// TODO: allocate new object, kept now as is to keep changes minimal
(clientQuery as any).sdk_version = sdk_version;
}
}
return clientQuery;
}
/**
* 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.
*/
private _parseSecureClientQuery(
query: querystring.ParsedUrlQuery,
): SecureClientQuery | undefined {
/** Any required arguments to construct a SecureClientQuery come
* embedded in the query string.
*/
const clientQuery = this._parseClientQuery(query);
if (!clientQuery) {
return;
}
let csr: string | undefined;
if (typeof query.csr === 'string') {
const buffer = Buffer.from(query.csr, 'base64');
if (buffer) {
csr = buffer.toString('ascii');
}
}
let csr_path: string | undefined;
if (typeof query.csr_path === 'string') {
csr_path = query.csr_path;
}
let medium: number | undefined;
if (typeof query.medium === 'string') {
medium = parseInt(query.medium, 10);
}
if (medium !== undefined && (medium < 1 || medium > 3)) {
throw new Error('Unsupported exchange medium: ' + medium);
}
return {...clientQuery, csr, csr_path, medium: medium as any};
}
}
export default ServerWebSocket;

View File

@@ -0,0 +1,113 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {IncomingMessage} from 'http';
import {SecureServerConfig} from '../utils/CertificateProvider';
import ServerAdapter, {ServerEventsListener} from './ServerAdapter';
import ws from 'ws';
import WebSocket from 'ws';
import https from 'https';
import http from 'http';
/**
* It serves as a base class for WebSocket based servers. It delegates the 'connection'
* event to subclasses as a customisation point.
*/
abstract class ServerWebSocketBase extends ServerAdapter {
rawServer_: ws.Server | null;
constructor(listener: ServerEventsListener) {
super(listener);
this.rawServer_ = null;
}
/**
* WebSocket client verification. Usually used to validate the origin.
*
* Base implementation simply returns true, but this can be overriden by subclasses
* that require verification.
*
* @returns Return true if the client was successfully verified, otherwise
* returns false.
*/
verifyClient(): ws.VerifyClientCallbackSync {
return (_info: {origin: string; req: IncomingMessage; secure: boolean}) => {
return false;
};
}
start(port: number, sslConfig?: SecureServerConfig): Promise<boolean> {
const self = this;
return new Promise((resolve, reject) => {
let server: http.Server | https.Server | undefined;
if (sslConfig) {
server = https.createServer({
key: sslConfig.key,
cert: sslConfig.cert,
ca: sslConfig.ca,
// Client to provide a certificate to authenticate.
requestCert: sslConfig.requestCert,
// As specified as "true", so no unauthenticated traffic
// will make it to the specified route specified
rejectUnauthorized: sslConfig.rejectUnauthorized,
});
} else {
server = http.createServer();
}
const handleRequest = sslConfig
? self.onSecureConnection
: self.onConnection;
const rawServer = new WebSocket.Server({
server,
verifyClient: this.verifyClient(),
});
rawServer.on('connection', (ws: WebSocket, message: any) => {
handleRequest.apply(self, [ws, message]);
});
rawServer.on('error', (_ws: WebSocket, error: any) => {
console.warn('[conn] Server found connection error: ' + error);
reject(error);
});
if (server) {
server.listen(port, () => {
console.debug(
`${
sslConfig ? 'Secure' : 'Certificate'
} server started on port ${port}`,
'server',
);
self.listener.onListening(port);
self.rawServer_ = rawServer;
resolve(true);
});
} else {
reject(new Error(`Unable to start server at port ${port}`));
}
});
}
stop(): Promise<void> {
if (this.rawServer_) {
return Promise.resolve(this.rawServer_.close());
}
return Promise.resolve();
}
onConnection(_ws: WebSocket, _message: any): void {
throw new Error('Method not implemented.');
}
onSecureConnection(_ws: WebSocket, _message: any): void {
throw new Error('Method not implemented.');
}
}
export default ServerWebSocketBase;

View File

@@ -0,0 +1,192 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import ServerWebSocketBase from './ServerWebSocketBase';
import WebSocket from 'ws';
import querystring from 'querystring';
import {BrowserClientFlipperConnection} from './BrowserClientFlipperConnection';
import {ServerEventsListener} from './ServerAdapter';
import ws from 'ws';
import {IncomingMessage} from 'http';
import {ClientDescription, ClientQuery} from 'flipper-common';
import {getFlipperServerConfig} from '../FlipperServerConfig';
/**
* WebSocket-based server which uses a connect/disconnect handshake over an insecure channel.
*/
class ServerWebSocketBrowser extends ServerWebSocketBase {
constructor(listener: ServerEventsListener) {
super(listener);
}
verifyClient(): ws.VerifyClientCallbackSync {
return (info: {origin: string; req: IncomingMessage; secure: boolean}) => {
const ok = getFlipperServerConfig().validWebSocketOrigins.some(
(validPrefix) => info.origin.startsWith(validPrefix),
);
if (!ok) {
console.warn(
`[conn] Refused webSocket connection from ${info.origin} (secure: ${info.secure})`,
);
}
return ok;
};
}
/**
* A connection has been established between the server and a client.
* @param ws An active WebSocket.
* @param message Incoming request message.
*/
onConnection(ws: WebSocket, message: any): void {
const clients: {
[app: string]: Promise<ClientDescription>;
} = {};
/**
* Any required arguments to construct a ClientQuery come
* embedded in the query string.
*/
const query = querystring.decode(message.url.split('?')[1]);
const deviceId: string =
typeof query.deviceId === 'string' ? query.deviceId : 'webbrowser';
const device =
typeof query.device === 'string' ? query.device : 'WebSocket';
const clientQuery: ClientQuery = {
device_id: deviceId,
device,
app: device,
os: 'MacOS', // TODO: not hardcoded! Use host device?
};
console.info(
`[conn] Local websocket connection attempt: ${clientQuery.app} on ${clientQuery.device_id}.`,
);
this.listener.onConnectionAttempt(clientQuery);
const cleanup = () => {
Object.values(clients).map((p) =>
p.then((c) => this.listener.onConnectionClosed(c.id)),
);
};
/**
* Subscribe to the 'message' event. Initially, a handshake should take place in the form of a
* 'connect' message. Once received, a client connection will be established and registered. This
* is followed by another subscription to the 'message' event, again. Effectively, two listeners
* are now attached to that event. The former will continue to check for 'connect' and 'disconnect'
* messages. The latter will deliver messages to the client.
*/
ws.on('message', (rawMessage: any) => {
let message: any | undefined;
try {
message = JSON.parse(rawMessage.toString());
} catch (error) {
// Throws a SyntaxError exception if the string to parse is not valid JSON.
console.log('Received message is not valid.', error);
return;
}
switch (message.type) {
case 'connect': {
const app = message.app;
const plugins = message.plugins;
const clientConnection = new BrowserClientFlipperConnection(
ws,
app,
plugins,
);
const extendedClientQuery = {...clientQuery, medium: 3 as const};
extendedClientQuery.sdk_version = plugins == null ? 4 : 1;
console.info(
`[conn] Local websocket connection established: ${clientQuery.app} on ${clientQuery.device_id}.`,
);
let resolvedClient: ClientDescription | null = null;
this.listener.onSecureConnectionAttempt(extendedClientQuery);
const client: Promise<ClientDescription> =
this.listener.onConnectionCreated(
extendedClientQuery,
clientConnection,
);
client
.then((client) => {
console.info(
`[conn] Client connected: ${clientQuery.app} on ${clientQuery.device_id}.`,
);
resolvedClient = client;
})
.catch((e) => {
console.error(
'[conn] Failed to connect client over webSocket',
e,
);
});
clients[app] = client;
ws.on('message', (m: any) => {
let parsed: any | undefined;
try {
parsed = JSON.parse(m.toString());
} catch (error) {
// Throws a SyntaxError exception if the string to parse is not valid JSON.
console.info('[conn] Received message is not valid.', error);
return;
}
// non-null payload id means response to prev request, it's handled in connection
if (parsed.app === app && parsed.payload?.id == null) {
const message = JSON.stringify(parsed.payload);
if (resolvedClient) {
this.listener.onClientMessage(resolvedClient.id, message);
} else {
client
.then((c) => this.listener.onClientMessage(c.id, message))
.catch((e) => {
console.warn(
'Could not deliver message, client did not resolve: ' +
app,
e,
);
});
}
}
});
break;
}
case 'disconnect': {
const app = message.app;
(clients[app] || Promise.resolve())
.then((c) => {
this.listener.onConnectionClosed(c.id);
delete clients[app];
})
.catch((_) => {});
break;
}
}
});
/** Close event from the existing client connection. */
ws.on('close', () => {
cleanup();
});
/** Error event from the existing client connection. */
ws.on('error', (error) => {
console.warn('[conn] Server found connection error: ' + error);
cleanup();
});
}
}
export default ServerWebSocketBrowser;

View File

@@ -0,0 +1,50 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {ClientQuery} from 'flipper-common';
import {CertificateExchangeMedium} from '../utils/CertificateProvider';
/**
* 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;
}