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,287 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {IncomingMessage} from 'http';
|
||||
import ServerWebSocketBase from './ServerWebSocketBase';
|
||||
import WebSocket, {
|
||||
AddressInfo,
|
||||
Server as WSServer,
|
||||
VerifyClientCallbackSync,
|
||||
} from 'ws';
|
||||
import {createServer as createHttpsServer} from 'https';
|
||||
import {createServer as createHttpServer} from 'http';
|
||||
import querystring from 'querystring';
|
||||
import {ClientQuery, UnableToExtractClientQueryError} from 'flipper-common';
|
||||
import {
|
||||
assertNotNull,
|
||||
parseClientQuery,
|
||||
parseMessageToJson,
|
||||
verifyClientQueryComesFromCertExchangeSupportedOS,
|
||||
} from './Utilities';
|
||||
import {SecureServerConfig} from '../utils/certificateUtils';
|
||||
import {Server} from 'net';
|
||||
import {serializeError} from 'serialize-error';
|
||||
import {WSCloseCode} from '../utils/WSCloseCode';
|
||||
|
||||
export interface ConnectionCtx {
|
||||
clientQuery?: ClientQuery;
|
||||
ws: WebSocket;
|
||||
request: IncomingMessage;
|
||||
}
|
||||
|
||||
// Based on https://github.com/websockets/ws/blob/master/lib/websocket-server.js#L40,
|
||||
// exposed to share with socket.io defaults.
|
||||
export const WEBSOCKET_MAX_MESSAGE_SIZE = 100 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* It serves as a base class for WebSocket based servers. It delegates the 'connection'
|
||||
* event to subclasses as a customisation point.
|
||||
*/
|
||||
class ServerWebSocket extends ServerWebSocketBase {
|
||||
protected wsServer?: WSServer;
|
||||
private httpServer?: Server;
|
||||
|
||||
async start(port: number, sslConfig?: SecureServerConfig): Promise<number> {
|
||||
const assignedPort = await new Promise<number>((resolve, reject) => {
|
||||
const server = sslConfig
|
||||
? createHttpsServer(sslConfig)
|
||||
: createHttpServer();
|
||||
|
||||
const wsServer = new WSServer({
|
||||
server,
|
||||
verifyClient: this.verifyClient(),
|
||||
maxPayload: WEBSOCKET_MAX_MESSAGE_SIZE,
|
||||
});
|
||||
|
||||
// We do not need to listen to http server's `error` because it is propagated to WS
|
||||
// https://github.com/websockets/ws/blob/a3a22e4ed39c1a3be8e727e9c630dd440edc61dd/lib/websocket-server.js#L109
|
||||
const onConnectionError = (error: Error) => {
|
||||
reject(
|
||||
new Error(
|
||||
`Unable to start server at port ${port} due to ${JSON.stringify(
|
||||
serializeError(error),
|
||||
)}`,
|
||||
),
|
||||
);
|
||||
};
|
||||
wsServer.once('error', onConnectionError);
|
||||
server.listen(port, () => {
|
||||
console.debug(
|
||||
`${sslConfig ? 'Secure' : 'Insecure'} server started on port ${port}`,
|
||||
'server',
|
||||
);
|
||||
|
||||
// Unsubscribe connection error listener.
|
||||
// We'll attach a permanent error listener later.
|
||||
wsServer.off('error', onConnectionError);
|
||||
|
||||
this.listener.onListening(port);
|
||||
this.wsServer = wsServer;
|
||||
this.httpServer = server;
|
||||
|
||||
resolve((server.address() as AddressInfo).port);
|
||||
});
|
||||
});
|
||||
|
||||
assertNotNull(this.wsServer);
|
||||
assertNotNull(this.httpServer);
|
||||
|
||||
this.wsServer.on(
|
||||
'connection',
|
||||
(ws: WebSocket, request: IncomingMessage) => {
|
||||
ws.on('error', (error) => {
|
||||
console.error('[conn] WS connection error:', error);
|
||||
this.listener.onError(error);
|
||||
});
|
||||
|
||||
try {
|
||||
this.onConnection(ws, request);
|
||||
} catch (error) {
|
||||
// TODO: Investigate if we need to close the socket in the `error` listener
|
||||
// DRI: @aigoncharov
|
||||
ws.close(WSCloseCode.InternalError);
|
||||
|
||||
if (error instanceof UnableToExtractClientQueryError) {
|
||||
// If we are unable to extract the client query, do not emit an error.
|
||||
// It cannot be determined if the client is legitimately trying to establish
|
||||
// a connection with Flipper or some other process is trying to connect to
|
||||
// the port currently used by Flipper.
|
||||
return;
|
||||
}
|
||||
// If an exception is thrown, an `error` event is not emitted automatically.
|
||||
// We need to explicitly handle the error and emit an error manually.
|
||||
// If we leave it unhanled, the process just dies
|
||||
// https://replit.com/@aigoncharov/WS-error-handling#index.js
|
||||
ws.emit('error', error);
|
||||
}
|
||||
},
|
||||
);
|
||||
this.wsServer.on('error', (error) => {
|
||||
console.error('[conn] WS server error:', error);
|
||||
this.listener.onError(error);
|
||||
});
|
||||
|
||||
return assignedPort;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.wsServer) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
console.info('[conn] Stopping WS server');
|
||||
assertNotNull(this.wsServer);
|
||||
this.wsServer.close((err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
console.info('[conn] Stopping HTTP server');
|
||||
assertNotNull(this.httpServer);
|
||||
this.httpServer.close((err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A connection has been established between the server and a client. Only ever used for
|
||||
* certificate exchange.
|
||||
*
|
||||
* @param ws An active WebSocket.
|
||||
* @param request Incoming request message.
|
||||
*/
|
||||
onConnection(ws: WebSocket, request: IncomingMessage): void {
|
||||
const ctx: ConnectionCtx = {ws, request};
|
||||
|
||||
this.extractClientQuery(ctx);
|
||||
this.handleConnectionAttempt(ctx);
|
||||
|
||||
ws.on('message', async (message: WebSocket.RawData) => {
|
||||
const messageString = message.toString();
|
||||
try {
|
||||
const parsedMessage = this.handleMessageDeserialization(messageString);
|
||||
// Successful deserialization is a proof that the message is a string
|
||||
this.handleMessage(ctx, parsedMessage, messageString);
|
||||
} catch (error) {
|
||||
// If handling an individual message failes, we don't necessarily kill the connection,
|
||||
// all other plugins might still be working correctly. So let's just report it.
|
||||
// This avoids ping-ponging connections if an individual plugin sends garbage (e.g. T129428800)
|
||||
// or throws an error when handling messages
|
||||
console.error('[conn] Failed to handle message', messageString, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract and create a ClientQuery from the request URL. This method will throw if:
|
||||
* @param ctx The connection context.
|
||||
* @returns It doesn't return anything, if the client query
|
||||
* is extracted, this one is set into the connection context.
|
||||
*/
|
||||
protected extractClientQuery(ctx: ConnectionCtx): void {
|
||||
const {request} = ctx;
|
||||
if (!request.url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const query = querystring.decode(request.url.split('?')[1]);
|
||||
const clientQuery = this.parseClientQuery(query);
|
||||
|
||||
if (!clientQuery) {
|
||||
console.warn(
|
||||
'[conn] Unable to extract the client query from the request URL.',
|
||||
request.url,
|
||||
);
|
||||
throw new UnableToExtractClientQueryError(
|
||||
'[conn] Unable to extract the client query from the request URL.',
|
||||
);
|
||||
}
|
||||
|
||||
ctx.clientQuery = clientQuery;
|
||||
}
|
||||
|
||||
protected handleConnectionAttempt(ctx: ConnectionCtx): void {
|
||||
const {clientQuery} = ctx;
|
||||
assertNotNull(clientQuery);
|
||||
|
||||
console.info(
|
||||
`[conn] Insecure websocket connection attempt: ${clientQuery.app} on ${clientQuery.device_id}.`,
|
||||
);
|
||||
this.listener.onConnectionAttempt(clientQuery);
|
||||
}
|
||||
|
||||
protected handleMessageDeserialization(message: unknown): object {
|
||||
const parsedMessage = parseMessageToJson(message);
|
||||
if (!parsedMessage) {
|
||||
console.error('[conn] Failed to parse message', message);
|
||||
throw new Error(`[conn] Failed to parse message`);
|
||||
}
|
||||
return parsedMessage;
|
||||
}
|
||||
|
||||
protected async handleMessage(
|
||||
ctx: ConnectionCtx,
|
||||
parsedMessage: object,
|
||||
// Not used in this method, but left as a reference for overriding classes.
|
||||
_rawMessage: string,
|
||||
) {
|
||||
const {clientQuery, ws} = ctx;
|
||||
assertNotNull(clientQuery);
|
||||
|
||||
const response = await this._onHandleUntrustedMessage(
|
||||
clientQuery,
|
||||
parsedMessage,
|
||||
);
|
||||
if (response) {
|
||||
ws.send(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and extract a ClientQuery instance from a message. The ClientQuery
|
||||
* data will be contained in the message url query string.
|
||||
* @param message An incoming web socket message.
|
||||
*/
|
||||
protected parseClientQuery(
|
||||
query: querystring.ParsedUrlQuery,
|
||||
): ClientQuery | undefined {
|
||||
return verifyClientQueryComesFromCertExchangeSupportedOS(
|
||||
parseClientQuery(query),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket client verification. Usually used to validate the origin.
|
||||
*
|
||||
* Base implementation simply returns true, but this can be overriden by subclasses
|
||||
* that require verification.
|
||||
*
|
||||
* @returns Return true if the client was successfully verified, otherwise
|
||||
* returns false.
|
||||
*/
|
||||
protected verifyClient(): VerifyClientCallbackSync {
|
||||
return (_info: {origin: string; req: IncomingMessage; secure: boolean}) => {
|
||||
// Client verification is not necessary. The connected client has
|
||||
// already been verified using its certificate signed by the server.
|
||||
return true;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default ServerWebSocket;
|
||||
Reference in New Issue
Block a user