comms: app-connectivity
Summary: It doesn't get more generic than 'comms'. So, narrow it down: app-connectivity. Reviewed By: passy Differential Revision: D47185255 fbshipit-source-id: 87e9c2487c9b07603d14e856de670757078c0da1
This commit is contained in:
committed by
Facebook GitHub Bot
parent
48495c906e
commit
62cb33b763
@@ -0,0 +1,598 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {
|
||||
ClientDescription,
|
||||
ClientQuery,
|
||||
isTest,
|
||||
buildClientId,
|
||||
Logger,
|
||||
UninitializedClient,
|
||||
reportPlatformFailures,
|
||||
FlipperServerEvents,
|
||||
} from 'flipper-common';
|
||||
import CertificateProvider from '../utils/CertificateProvider';
|
||||
import {ClientConnection, ConnectionStatus} from './ClientConnection';
|
||||
import {EventEmitter} from 'events';
|
||||
import invariant from 'invariant';
|
||||
import DummyDevice from '../devices/DummyDevice';
|
||||
import {
|
||||
appNameWithUpdateHint,
|
||||
assertNotNull,
|
||||
cloneClientQuerySafeForLogging,
|
||||
} from './Utilities';
|
||||
import ServerWebSocketBase, {
|
||||
SecureClientQuery,
|
||||
ServerEventsListener,
|
||||
} from './ServerWebSocketBase';
|
||||
import {
|
||||
createBrowserServer,
|
||||
createServer,
|
||||
TransportType,
|
||||
} from './ServerFactory';
|
||||
import {FlipperServerImpl} from '../FlipperServerImpl';
|
||||
import {
|
||||
getServerPortsConfig,
|
||||
getFlipperServerConfig,
|
||||
} from '../FlipperServerConfig';
|
||||
import {
|
||||
extractAppNameFromCSR,
|
||||
loadSecureServerConfig,
|
||||
} from '../utils/certificateUtils';
|
||||
import DesktopCertificateProvider from '../devices/desktop/DesktopCertificateProvider';
|
||||
import WWWCertificateProvider from '../fb-stubs/WWWCertificateProvider';
|
||||
import {tracker} from '../utils/tracker';
|
||||
|
||||
type ClientTimestampTracker = {
|
||||
insecureStart?: number;
|
||||
secureStart?: number;
|
||||
};
|
||||
|
||||
type ClientInfo = {
|
||||
connection: ClientConnection | null | undefined;
|
||||
client: ClientDescription;
|
||||
};
|
||||
|
||||
type ClientCsrQuery = {
|
||||
csr?: string | undefined;
|
||||
csr_path?: string | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Responsible of creating and managing the actual underlying servers:
|
||||
* - Insecure (used for certificate exchange)
|
||||
* - Secure (used for secure communication between the client and server)
|
||||
* - Browser (only ever used between Desktop and a local Browser)
|
||||
*
|
||||
* Additionally, it manages client connections.
|
||||
*/
|
||||
export class ServerController
|
||||
extends EventEmitter
|
||||
implements ServerEventsListener
|
||||
{
|
||||
connections: Map<string, ClientInfo> = new Map();
|
||||
timestamps: Map<string, ClientTimestampTracker> = new Map();
|
||||
|
||||
secureServer: ServerWebSocketBase | null = null;
|
||||
insecureServer: ServerWebSocketBase | null = null;
|
||||
altSecureServer: ServerWebSocketBase | null = null;
|
||||
altInsecureServer: ServerWebSocketBase | null = null;
|
||||
browserServer: ServerWebSocketBase | null = null;
|
||||
|
||||
connectionTracker: ConnectionTracker;
|
||||
|
||||
flipperServer: FlipperServerImpl;
|
||||
|
||||
timeHandlers: Map<string, NodeJS.Timeout> = new Map();
|
||||
|
||||
constructor(flipperServer: FlipperServerImpl) {
|
||||
super();
|
||||
this.flipperServer = flipperServer;
|
||||
this.connectionTracker = new ConnectionTracker(this.logger);
|
||||
}
|
||||
|
||||
onClientMessage(clientId: string, payload: string): void {
|
||||
this.flipperServer.emit('client-message', {
|
||||
id: clientId,
|
||||
message: payload,
|
||||
});
|
||||
}
|
||||
|
||||
get logger(): Logger {
|
||||
return this.flipperServer.logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the secure server configuration and starts any necessary servers.
|
||||
* Initialisation is complete once the initialized promise is fullfilled at
|
||||
* which point Flipper is accepting connections.
|
||||
*/
|
||||
async init() {
|
||||
if (isTest()) {
|
||||
throw new Error('Spawing new server is not supported in test');
|
||||
}
|
||||
const {insecure, secure} = getServerPortsConfig().serverPorts;
|
||||
|
||||
const options = await loadSecureServerConfig();
|
||||
|
||||
console.info('[conn] secure server listening at port: ', secure);
|
||||
this.secureServer = await createServer(secure, this, options);
|
||||
const {secure: altSecure} = getServerPortsConfig().altServerPorts;
|
||||
console.info('[conn] secure server (ws) listening at port: ', altSecure);
|
||||
this.altSecureServer = await createServer(
|
||||
altSecure,
|
||||
this,
|
||||
options,
|
||||
TransportType.WebSocket,
|
||||
);
|
||||
|
||||
console.info('[conn] insecure server listening at port: ', insecure);
|
||||
this.insecureServer = await createServer(insecure, this);
|
||||
const {insecure: altInsecure} = getServerPortsConfig().altServerPorts;
|
||||
console.info(
|
||||
'[conn] insecure server (ws) listening at port: ',
|
||||
altInsecure,
|
||||
);
|
||||
this.altInsecureServer = await createServer(
|
||||
altInsecure,
|
||||
this,
|
||||
undefined,
|
||||
TransportType.WebSocket,
|
||||
);
|
||||
|
||||
const browserPort = getServerPortsConfig().browserPort;
|
||||
console.info('[conn] Browser server (ws) listening at port: ', browserPort);
|
||||
this.browserServer = await createBrowserServer(browserPort, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* If initialized, it stops any started servers.
|
||||
*/
|
||||
async close() {
|
||||
await Promise.all([
|
||||
this.insecureServer?.stop(),
|
||||
this.secureServer?.stop(),
|
||||
this.altInsecureServer?.stop(),
|
||||
this.altSecureServer?.stop(),
|
||||
this.browserServer?.stop(),
|
||||
]);
|
||||
}
|
||||
|
||||
onConnectionCreated(
|
||||
clientQuery: SecureClientQuery,
|
||||
clientConnection: ClientConnection,
|
||||
downgrade?: boolean,
|
||||
): Promise<ClientDescription> {
|
||||
const {
|
||||
app,
|
||||
os,
|
||||
device,
|
||||
device_id,
|
||||
sdk_version,
|
||||
csr,
|
||||
csr_path,
|
||||
medium,
|
||||
rsocket,
|
||||
} = clientQuery;
|
||||
|
||||
console.info(
|
||||
`[conn] Connection established: ${app} on ${device_id}. Medium ${medium}. CSR: ${csr_path}`,
|
||||
cloneClientQuerySafeForLogging(clientQuery),
|
||||
);
|
||||
|
||||
tracker.track('app-connection-created', {
|
||||
app,
|
||||
os,
|
||||
device,
|
||||
device_id,
|
||||
medium,
|
||||
});
|
||||
|
||||
return this.addConnection(
|
||||
clientConnection,
|
||||
{
|
||||
app,
|
||||
os,
|
||||
device,
|
||||
device_id,
|
||||
sdk_version,
|
||||
medium,
|
||||
rsocket,
|
||||
},
|
||||
{csr, csr_path},
|
||||
downgrade,
|
||||
);
|
||||
}
|
||||
|
||||
onConnectionClosed(clientId: string) {
|
||||
this.removeConnection(clientId);
|
||||
}
|
||||
|
||||
onListening(port: number): void {
|
||||
this.emit('listening', port);
|
||||
}
|
||||
|
||||
onSecureConnectionAttempt(clientQuery: SecureClientQuery): void {
|
||||
const strippedClientQuery = (({device_id, ...o}) => o)(clientQuery);
|
||||
let id = buildClientId({device_id: 'unknown', ...strippedClientQuery});
|
||||
const timestamp = this.timestamps.get(id);
|
||||
if (timestamp) {
|
||||
this.timestamps.delete(id);
|
||||
}
|
||||
id = buildClientId(clientQuery);
|
||||
this.timestamps.set(id, {
|
||||
secureStart: Date.now(),
|
||||
...timestamp,
|
||||
});
|
||||
|
||||
tracker.track('app-connection-secure-attempt', {
|
||||
app: clientQuery.app,
|
||||
os: clientQuery.os,
|
||||
device: clientQuery.device,
|
||||
device_id: clientQuery.device_id,
|
||||
medium: clientQuery.medium,
|
||||
});
|
||||
|
||||
const {os, app, device_id} = clientQuery;
|
||||
// without these checks, the user might see a connection timeout error instead, which would be much harder to track down
|
||||
if (os === 'iOS' && !getFlipperServerConfig().settings.enableIOS) {
|
||||
console.error(
|
||||
`Refusing connection from ${app} on ${device_id}, since iOS support is disabled in settings`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (os === 'Android' && !getFlipperServerConfig().settings.enableAndroid) {
|
||||
console.error(
|
||||
`Refusing connection from ${app} on ${device_id}, since Android support is disabled in settings`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.connectionTracker.logConnectionAttempt(clientQuery);
|
||||
|
||||
const timeout = this.timeHandlers.get(clientQueryToKey(clientQuery));
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
if (clientQuery.medium === 'WWW' || clientQuery.medium === 'NONE') {
|
||||
this.flipperServer.registerDevice(
|
||||
new DummyDevice(
|
||||
this.flipperServer,
|
||||
clientQuery.device_id,
|
||||
clientQuery.app +
|
||||
(clientQuery.medium === 'WWW' ? ' Server Exchanged' : ''),
|
||||
clientQuery.os,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onConnectionAttempt(clientQuery: ClientQuery): void {
|
||||
const strippedClientQuery = (({device_id, ...o}) => o)(clientQuery);
|
||||
const id = buildClientId({device_id: 'unknown', ...strippedClientQuery});
|
||||
this.timestamps.set(id, {
|
||||
insecureStart: Date.now(),
|
||||
});
|
||||
|
||||
tracker.track('app-connection-insecure-attempt', clientQuery);
|
||||
|
||||
this.connectionTracker.logConnectionAttempt(clientQuery);
|
||||
|
||||
const client: UninitializedClient = {
|
||||
os: clientQuery.os,
|
||||
deviceName: clientQuery.device,
|
||||
appName: appNameWithUpdateHint(clientQuery),
|
||||
};
|
||||
this.emit('start-client-setup', client);
|
||||
}
|
||||
|
||||
onProcessCSR(
|
||||
unsanitizedCSR: string,
|
||||
clientQuery: ClientQuery,
|
||||
appDirectory: string,
|
||||
): Promise<{deviceId: string}> {
|
||||
let certificateProvider: CertificateProvider;
|
||||
switch (clientQuery.os) {
|
||||
case 'Android': {
|
||||
assertNotNull(
|
||||
this.flipperServer.android,
|
||||
'Android settings have not been provided / enabled',
|
||||
);
|
||||
certificateProvider = this.flipperServer.android.certificateProvider;
|
||||
break;
|
||||
}
|
||||
case 'iOS': {
|
||||
assertNotNull(
|
||||
this.flipperServer.ios,
|
||||
'iOS settings have not been provided / enabled',
|
||||
);
|
||||
certificateProvider = this.flipperServer.ios.certificateProvider;
|
||||
|
||||
if (clientQuery.medium === 'WWW') {
|
||||
certificateProvider = new WWWCertificateProvider(
|
||||
this.flipperServer.keytarManager,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
// Used by Spark AR studio (search for SkylightFlipperClient)
|
||||
// See D30992087
|
||||
case 'MacOS':
|
||||
case 'Windows': {
|
||||
certificateProvider = new DesktopCertificateProvider();
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error(
|
||||
`ServerController.onProcessCSR -> os ${clientQuery.os} does not support certificate exchange.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
certificateProvider.verifyMedium(clientQuery.medium);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
reportPlatformFailures(
|
||||
certificateProvider.processCertificateSigningRequest(
|
||||
unsanitizedCSR,
|
||||
clientQuery.os,
|
||||
appDirectory,
|
||||
),
|
||||
'processCertificateSigningRequest',
|
||||
)
|
||||
.then((response) => {
|
||||
const client: UninitializedClient = {
|
||||
os: clientQuery.os,
|
||||
deviceName: clientQuery.device,
|
||||
appName: appNameWithUpdateHint(clientQuery),
|
||||
};
|
||||
|
||||
this.timeHandlers.set(
|
||||
// In the original insecure connection request, `device_id` is set to "unknown".
|
||||
// Flipper queries adb/idb to learn the device ID and provides it back to the app.
|
||||
// Once app knows it, it starts using the correct device ID for its subsequent secure connections.
|
||||
// When the app re-connects securely after the cert exchange process, we need to cancel this timeout.
|
||||
// Since the original clientQuery has `device_id` set to "unknown", we update it here with the correct `device_id` to find it and cancel it later.
|
||||
clientQueryToKey({...clientQuery, device_id: response.deviceId}),
|
||||
setTimeout(() => {
|
||||
this.emit('client-unresponsive-error', {
|
||||
client,
|
||||
medium: clientQuery.medium,
|
||||
deviceID: response.deviceId,
|
||||
});
|
||||
}, 30 * 1000),
|
||||
);
|
||||
|
||||
tracker.track('app-connection-certificate-exchange', {
|
||||
...clientQuery,
|
||||
successful: true,
|
||||
device_id: response.deviceId,
|
||||
});
|
||||
|
||||
resolve(response);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
tracker.track('app-connection-certificate-exchange', {
|
||||
...clientQuery,
|
||||
successful: false,
|
||||
error: error.message,
|
||||
});
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onError(error: Error): void {
|
||||
this.emit('error', error);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return null;
|
||||
}
|
||||
|
||||
onClientSetupError(clientQuery: ClientQuery, e: any) {
|
||||
console.warn(
|
||||
`[conn] Failed to exchange certificate with ${clientQuery.app} on ${
|
||||
clientQuery.device || clientQuery.device_id
|
||||
}`,
|
||||
e,
|
||||
);
|
||||
const client: UninitializedClient = {
|
||||
os: clientQuery.os,
|
||||
deviceName: clientQuery.device,
|
||||
appName: appNameWithUpdateHint(clientQuery),
|
||||
};
|
||||
this.emit('client-setup-error', {
|
||||
client,
|
||||
error: `[conn] Failed to exchange certificate with ${
|
||||
clientQuery.app
|
||||
} on ${clientQuery.device || clientQuery.device_id}: ${e}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Client and sets the underlying connection.
|
||||
* @param connection A client connection to communicate between server and client.
|
||||
* @param query The client query created from the initial handshake.
|
||||
* @param csrQuery The CSR query which contains CSR related information.
|
||||
*/
|
||||
async addConnection(
|
||||
connection: ClientConnection,
|
||||
query: ClientQuery,
|
||||
csrQuery: ClientCsrQuery,
|
||||
silentReplace?: boolean,
|
||||
): Promise<ClientDescription> {
|
||||
invariant(query, 'expected query');
|
||||
|
||||
// try to get id by comparing giving `csr` to file from `csr_path`
|
||||
// otherwise, use given device_id
|
||||
const {csr_path, csr} = csrQuery;
|
||||
|
||||
// For Android, device id might change
|
||||
if (csr_path && csr && query.os === 'Android') {
|
||||
const app_name = await extractAppNameFromCSR(csr);
|
||||
assertNotNull(this.flipperServer.android);
|
||||
// TODO: allocate new object, kept now as is to keep changes minimal
|
||||
(query as any).device_id =
|
||||
await this.flipperServer.android.certificateProvider.getTargetDeviceId(
|
||||
app_name,
|
||||
csr_path,
|
||||
csr,
|
||||
);
|
||||
console.info(
|
||||
`[conn] Detected ${app_name} on ${query.device_id} in certificate`,
|
||||
query,
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: allocate new object, kept now as is to keep changes minimal
|
||||
(query as any).app = appNameWithUpdateHint(query);
|
||||
|
||||
const id = buildClientId(query);
|
||||
console.info(
|
||||
`[conn] Matching device for ${query.app} on ${query.device_id}...`,
|
||||
query,
|
||||
);
|
||||
|
||||
const client: ClientDescription = {
|
||||
id,
|
||||
query,
|
||||
};
|
||||
|
||||
const info = {
|
||||
client,
|
||||
connection: connection,
|
||||
};
|
||||
|
||||
console.info(
|
||||
`[conn] Initializing client ${query.app} on ${query.device_id}...`,
|
||||
query,
|
||||
);
|
||||
|
||||
connection.subscribeToEvents((status: ConnectionStatus) => {
|
||||
if (
|
||||
status === ConnectionStatus.CLOSED ||
|
||||
status === ConnectionStatus.ERROR
|
||||
) {
|
||||
this.onConnectionClosed(client.id);
|
||||
}
|
||||
});
|
||||
|
||||
console.debug(`[conn] Device client initialized: ${id}.`, 'server', query);
|
||||
|
||||
/* If a device gets disconnected without being cleaned up properly,
|
||||
* Flipper won't be aware until it attempts to reconnect.
|
||||
* When it does we need to terminate the zombie connection.
|
||||
*/
|
||||
if (this.connections.has(id)) {
|
||||
const connectionInfo = this.connections.get(id);
|
||||
if (connectionInfo) {
|
||||
if (
|
||||
connectionInfo.connection &&
|
||||
connectionInfo.connection !== connection
|
||||
) {
|
||||
if (!silentReplace) {
|
||||
connectionInfo.connection.close();
|
||||
}
|
||||
this.removeConnection(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.connections.set(id, info);
|
||||
this.flipperServer.emit('client-connected', client);
|
||||
|
||||
const tracker = this.timestamps.get(id);
|
||||
if (tracker) {
|
||||
const end = Date.now();
|
||||
const start = tracker.insecureStart
|
||||
? tracker.insecureStart
|
||||
: tracker.secureStart;
|
||||
const elapsed = Math.round(end - start!);
|
||||
this.logger.track('performance', 'client-connection-tracker', {
|
||||
'time-to-connection': elapsed,
|
||||
...query,
|
||||
});
|
||||
this.timestamps.delete(id);
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
attachFakeClient(client: ClientDescription) {
|
||||
this.connections.set(client.id, {
|
||||
client,
|
||||
connection: null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a client connection by disconnecting it, if still connected
|
||||
* and then deleting it from the tracked connections.
|
||||
* @param id The client connection identifier.
|
||||
*/
|
||||
removeConnection(id: string) {
|
||||
const info = this.connections.get(id);
|
||||
if (info) {
|
||||
console.info(
|
||||
`[conn] Disconnected: ${info.client.query.app} on ${info.client.query.device_id}.`,
|
||||
info.client.query,
|
||||
);
|
||||
this.flipperServer.emit('client-disconnected', {id});
|
||||
this.connections.delete(id);
|
||||
this.flipperServer.pluginManager.stopAllServerAddOns(id);
|
||||
}
|
||||
}
|
||||
|
||||
onDeprecationNotice(message: string) {
|
||||
const notification: FlipperServerEvents['notification'] = {
|
||||
type: 'warning',
|
||||
title: 'Deprecation notice',
|
||||
description: message,
|
||||
};
|
||||
this.flipperServer.emit('notification', notification);
|
||||
}
|
||||
}
|
||||
|
||||
class ConnectionTracker {
|
||||
timeWindowMillis = 20 * 1000;
|
||||
connectionProblemThreshold = 4;
|
||||
|
||||
// "${device}.${app}" -> [timestamp1, timestamp2...]
|
||||
connectionAttempts: Map<string, Array<number>> = new Map();
|
||||
logger: Logger;
|
||||
|
||||
constructor(logger: Logger) {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
logConnectionAttempt(client: ClientQuery) {
|
||||
const key = `${client.os}-${client.device}-${client.app}`;
|
||||
const time = Date.now();
|
||||
let entry = this.connectionAttempts.get(key) || [];
|
||||
entry.push(time);
|
||||
entry = entry.filter((t) => t >= time - this.timeWindowMillis);
|
||||
|
||||
this.connectionAttempts.set(key, entry);
|
||||
if (entry.length >= this.connectionProblemThreshold) {
|
||||
console.warn(
|
||||
`[conn] Connection loop detected with ${key}. Connected ${
|
||||
this.connectionProblemThreshold
|
||||
} times within ${this.timeWindowMillis / 1000}s.`,
|
||||
'server',
|
||||
client,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clientQueryToKey(clientQuery: ClientQuery): string {
|
||||
return `${clientQuery.app}/${clientQuery.os}/${clientQuery.device}/${clientQuery.device_id}`;
|
||||
}
|
||||
Reference in New Issue
Block a user