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:
committed by
Facebook GitHub Bot
parent
3e7a6b1b4b
commit
d88b28330a
@@ -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}));
|
||||
}
|
||||
}
|
||||
27
desktop/flipper-server-core/src/comms/ClientConnection.tsx
Normal file
27
desktop/flipper-server-core/src/comms/ClientConnection.tsx
Normal 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>;
|
||||
}
|
||||
200
desktop/flipper-server-core/src/comms/ServerAdapter.tsx
Normal file
200
desktop/flipper-server-core/src/comms/ServerAdapter.tsx
Normal 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;
|
||||
494
desktop/flipper-server-core/src/comms/ServerController.tsx
Normal file
494
desktop/flipper-server-core/src/comms/ServerController.tsx
Normal 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}`;
|
||||
}
|
||||
88
desktop/flipper-server-core/src/comms/ServerFactory.tsx
Normal file
88
desktop/flipper-server-core/src/comms/ServerFactory.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
286
desktop/flipper-server-core/src/comms/ServerRSocket.tsx
Normal file
286
desktop/flipper-server-core/src/comms/ServerRSocket.tsx
Normal 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;
|
||||
300
desktop/flipper-server-core/src/comms/ServerWebSocket.tsx
Normal file
300
desktop/flipper-server-core/src/comms/ServerWebSocket.tsx
Normal 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;
|
||||
113
desktop/flipper-server-core/src/comms/ServerWebSocketBase.tsx
Normal file
113
desktop/flipper-server-core/src/comms/ServerWebSocketBase.tsx
Normal 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;
|
||||
192
desktop/flipper-server-core/src/comms/ServerWebSocketBrowser.tsx
Normal file
192
desktop/flipper-server-core/src/comms/ServerWebSocketBrowser.tsx
Normal 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;
|
||||
50
desktop/flipper-server-core/src/comms/Utilities.tsx
Normal file
50
desktop/flipper-server-core/src/comms/Utilities.tsx
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user