Summary: Already have SecureClientQuery. No need to have this as a type as it's not used or needed anywhere. Reviewed By: antonk52 Differential Revision: D47210345 fbshipit-source-id: d9f3026a0e2a0b5dd2e87f16dba34a388dacd75f
592 lines
17 KiB
TypeScript
592 lines
17 KiB
TypeScript
/**
|
|
* 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,
|
|
SecureClientQuery,
|
|
isTest,
|
|
buildClientId,
|
|
Logger,
|
|
UninitializedClient,
|
|
reportPlatformFailures,
|
|
FlipperServerEvents,
|
|
} from 'flipper-common';
|
|
import CertificateProvider from './certificate-exchange/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, {ServerEventsListener} from './ServerWebSocketBase';
|
|
import {
|
|
createBrowserServer,
|
|
createServer,
|
|
TransportType,
|
|
} from './ServerFactory';
|
|
import {FlipperServerImpl} from '../FlipperServerImpl';
|
|
import {
|
|
getServerPortsConfig,
|
|
getFlipperServerConfig,
|
|
} from '../FlipperServerConfig';
|
|
import {
|
|
extractAppNameFromCSR,
|
|
loadSecureServerConfig,
|
|
} from './certificate-exchange/certificate-utils';
|
|
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;
|
|
};
|
|
|
|
/**
|
|
* 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: SecureClientQuery,
|
|
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} = query;
|
|
|
|
// 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}`;
|
|
}
|