Remove libsignal shadowing modes

This commit is contained in:
Alex Bakon
2025-02-26 13:14:54 -05:00
committed by GitHub
parent 7904b573cd
commit 6a3f0c37f4
2 changed files with 11 additions and 263 deletions

View File

@@ -13,7 +13,7 @@ import type { connection as WebSocket } from 'websocket';
import qs from 'querystring'; import qs from 'querystring';
import EventListener from 'events'; import EventListener from 'events';
import { AbortableProcess } from '../util/AbortableProcess'; import type { AbortableProcess } from '../util/AbortableProcess';
import { strictAssert } from '../util/assert'; import { strictAssert } from '../util/assert';
import { explodePromise } from '../util/explodePromise'; import { explodePromise } from '../util/explodePromise';
import { import {
@@ -42,7 +42,6 @@ import WebSocketResource, {
LibsignalWebSocketResource, LibsignalWebSocketResource,
ServerRequestType, ServerRequestType,
TransportOption, TransportOption,
WebSocketResourceWithShadowing,
} from './WebsocketResources'; } from './WebsocketResources';
import { ConnectTimeoutError, HTTPError } from './Errors'; import { ConnectTimeoutError, HTTPError } from './Errors';
import type { IRequestHandler, WebAPICredentials } from './Types.d'; import type { IRequestHandler, WebAPICredentials } from './Types.d';
@@ -50,6 +49,7 @@ import { connect as connectWebSocket } from './WebSocket';
import { isNightly, isBeta, isStaging } from '../util/version'; import { isNightly, isBeta, isStaging } from '../util/version';
import { getBasicAuth } from '../util/getBasicAuth'; import { getBasicAuth } from '../util/getBasicAuth';
import { isTestOrMockEnvironment } from '../environment'; import { isTestOrMockEnvironment } from '../environment';
import type { ConfigKeyType } from '../RemoteConfig';
const FIVE_MINUTES = 5 * durations.MINUTE; const FIVE_MINUTES = 5 * durations.MINUTE;
@@ -645,34 +645,19 @@ export class SocketManager extends EventListener {
return TransportOption.Libsignal; return TransportOption.Libsignal;
} }
// in alpha, switch to using libsignal transport, unless user opts out, let libsignalRemoteConfigFlag: ConfigKeyType;
// in which case switching to shadowing
if (isNightly(this.options.version)) { if (isNightly(this.options.version)) {
const configValue = window.Signal.RemoteConfig.isEnabled( libsignalRemoteConfigFlag = 'desktop.experimentalTransportEnabled.alpha';
'desktop.experimentalTransportEnabled.alpha' } else if (isBeta(this.options.version)) {
); libsignalRemoteConfigFlag = 'desktop.experimentalTransportEnabled.beta';
return configValue } else {
? TransportOption.Libsignal libsignalRemoteConfigFlag = 'desktop.experimentalTransportEnabled.prod';
: TransportOption.ShadowingHigh;
}
// in beta, switch to using 'ShadowingHigh' mode, unless user opts out,
// in which case switching to `ShadowingLow`
if (isBeta(this.options.version)) {
const configValue = window.Signal.RemoteConfig.isEnabled(
'desktop.experimentalTransportEnabled.beta'
);
return configValue
? TransportOption.ShadowingHigh
: TransportOption.ShadowingLow;
} }
const configValue = window.Signal.RemoteConfig.isEnabled( const configValue = window.Signal.RemoteConfig.isEnabled(
'desktop.experimentalTransportEnabled.prod' libsignalRemoteConfigFlag
); );
return configValue return configValue ? TransportOption.Libsignal : TransportOption.Original;
? TransportOption.ShadowingLow
: TransportOption.Original;
} }
async #getUnauthenticatedResource(): Promise<IWebSocketResource> { async #getUnauthenticatedResource(): Promise<IWebSocketResource> {
@@ -812,64 +797,7 @@ export class SocketManager extends EventListener {
}, },
}); });
const shadowingModeEnabled = return webSocketResourceConnection;
!resourceOptions.transportOption ||
resourceOptions.transportOption === TransportOption.Original;
return shadowingModeEnabled
? webSocketResourceConnection
: this.#connectWithShadowing(
webSocketResourceConnection,
resourceOptions
);
}
/**
* A method that takes in an `AbortableProcess<>` that establishes
* a `WebSocketResource` connection and wraps it in a process
* that also tries to establish a `LibsignalWebSocketResource` connection.
*
* The shadowing connection will not block the main one (e.g. if it takes
* longer to connect) and an error in the shadowing connection will not
* affect the overall behavior.
*
* @param mainConnection an `AbortableProcess<WebSocketResource>` responsible
* for establishing a Desktop system WebSocket connection.
* @param options `WebSocketResourceOptions` options
* @private
*/
#connectWithShadowing(
mainConnection: AbortableProcess<WebSocketResource>,
options: WebSocketResourceOptions
): AbortableProcess<IWebSocketResource> {
// creating an `AbortableProcess` of libsignal websocket connection
const shadowingConnection = connectUnauthenticatedLibsignal({
libsignalNet: this.libsignalNet,
name: options.name,
keepalive: options.keepalive ?? {},
});
const shadowWrapper = async () => {
// if main connection results in an error,
// it's propagated as the error of the resulting process
const mainSocket = await mainConnection.resultPromise;
// here, we're not awaiting on `shadowingConnection.resultPromise`
// and just letting `WebSocketResourceWithShadowing`
// initiate and handle the result of the shadowing connection attempt
return new WebSocketResourceWithShadowing(
mainSocket,
shadowingConnection,
options
);
};
return new AbortableProcess<IWebSocketResource>(
`WebSocketResourceWithShadowing.connect(${options.name})`,
{
abort() {
mainConnection.abort();
shadowingConnection.abort();
},
},
shadowWrapper()
);
} }
async #checkResource( async #checkResource(

View File

@@ -32,8 +32,6 @@ import pTimeout from 'p-timeout';
import { Response } from 'node-fetch'; import { Response } from 'node-fetch';
import net from 'net'; import net from 'net';
import { z } from 'zod'; import { z } from 'zod';
import { clearInterval } from 'timers';
import { random } from 'lodash';
import type { LibSignalError, Net } from '@signalapp/libsignal-client'; import type { LibSignalError, Net } from '@signalapp/libsignal-client';
import { Buffer } from 'node:buffer'; import { Buffer } from 'node:buffer';
@@ -55,9 +53,7 @@ import { SignalService as Proto } from '../protobuf';
import * as log from '../logging/log'; import * as log from '../logging/log';
import * as Timers from '../Timers'; import * as Timers from '../Timers';
import type { IResource } from './WebSocket'; import type { IResource } from './WebSocket';
import { isProduction } from '../util/version';
import { ToastType } from '../types/Toast';
import { AbortableProcess } from '../util/AbortableProcess'; import { AbortableProcess } from '../util/AbortableProcess';
import type { WebAPICredentials } from './Types'; import type { WebAPICredentials } from './Types';
import { NORMAL_DISCONNECT_CODE } from './SocketManager'; import { NORMAL_DISCONNECT_CODE } from './SocketManager';
@@ -65,8 +61,6 @@ import { parseUnknown } from '../util/schemas';
const THIRTY_SECONDS = 30 * durations.SECOND; const THIRTY_SECONDS = 30 * durations.SECOND;
const STATS_UPDATE_INTERVAL = durations.MINUTE;
const MAX_MESSAGE_SIZE = 512 * 1024; const MAX_MESSAGE_SIZE = 512 * 1024;
const AGGREGATED_STATS_KEY = 'websocketStats'; const AGGREGATED_STATS_KEY = 'websocketStats';
@@ -270,17 +264,6 @@ export type SendRequestResult = Readonly<{
export enum TransportOption { export enum TransportOption {
// Only original transport is used // Only original transport is used
Original = 'original', Original = 'original',
// All requests are going through the original transport,
// but for every request that completes sucessfully we're initiating
// a healthcheck request via libsignal transport,
// collecting comparison statistics, and if we see many inconsistencies,
// we're showing a toast asking user to submit a debug log
ShadowingHigh = 'shadowingHigh',
// Similar to `shadowingHigh`, however, only 10% of requests
// will trigger a healthcheck, and toast is never shown.
// Statistics data is still added to the debug logs,
// so it will be available to us with all the debug log uploads.
ShadowingLow = 'shadowingLow',
// Only libsignal transport is used // Only libsignal transport is used
Libsignal = 'libsignal', Libsignal = 'libsignal',
} }
@@ -580,169 +563,6 @@ export class LibsignalWebSocketResource
} }
} }
export class WebSocketResourceWithShadowing implements IWebSocketResource {
#shadowing: LibsignalWebSocketResource | undefined;
#stats: AggregatedStats;
#statsTimer: NodeJS.Timeout;
#shadowingWithReporting: boolean;
#logId: string;
constructor(
private readonly main: WebSocketResource,
private readonly shadowingConnection: AbortableProcess<LibsignalWebSocketResource>,
options: WebSocketResourceOptions
) {
this.#stats = AggregatedStats.createEmpty();
this.#logId = `WebSocketResourceWithShadowing(${options.name})`;
this.#statsTimer = setInterval(
() => this.#updateStats(options.name),
STATS_UPDATE_INTERVAL
);
this.#shadowingWithReporting =
options.transportOption === TransportOption.ShadowingHigh;
// the idea is that we want to keep the shadowing connection process
// "in the background", so that the main connection wouldn't need to wait on it.
// then when we're connected, `this.shadowing` socket resource is initialized
// or an error reported in case of connection failure
const initializeAfterConnected = async () => {
try {
this.#shadowing = await shadowingConnection.resultPromise;
// checking IP one time per connection
if (this.main.ipVersion() !== this.#shadowing.ipVersion()) {
this.#stats.ipVersionMismatches += 1;
const mainIpType = this.main.ipVersion();
const shadowIpType = this.#shadowing.ipVersion();
log.warn(
`${this.#logId}: libsignal websocket IP [${shadowIpType}], Desktop websocket IP [${mainIpType}]`
);
}
} catch (error) {
this.#stats.connectionFailures += 1;
}
};
drop(initializeAfterConnected());
this.addEventListener('close', (_ev): void => {
clearInterval(this.#statsTimer);
this.#updateStats(options.name);
});
}
#updateStats(name: string) {
const storedStats = AggregatedStats.loadOrCreateEmpty(name);
let updatedStats = AggregatedStats.add(storedStats, this.#stats);
if (
this.#shadowingWithReporting &&
AggregatedStats.shouldReportError(updatedStats) &&
!isProduction(window.getVersion())
) {
window.reduxActions.toast.showToast({
toastType: ToastType.TransportError,
});
log.warn(
`${this.#logId}: experimental transport toast displayed, flushing transport statistics before resetting`,
updatedStats
);
updatedStats = AggregatedStats.createEmpty();
updatedStats.lastToastTimestamp = Date.now();
}
AggregatedStats.store(updatedStats, name);
this.#stats = AggregatedStats.createEmpty();
}
public localPort(): number | undefined {
return this.main.localPort();
}
public addEventListener(
name: 'close',
handler: (ev: CloseEvent) => void
): void {
this.main.addEventListener(name, handler);
}
public close(code = NORMAL_DISCONNECT_CODE, reason?: string): void {
this.main.close(code, reason);
if (this.#shadowing) {
this.#shadowing.close(code, reason);
this.#shadowing = undefined;
} else {
this.shadowingConnection.abort();
}
}
public shutdown(): void {
this.main.shutdown();
if (this.#shadowing) {
this.#shadowing.shutdown();
this.#shadowing = undefined;
} else {
this.shadowingConnection.abort();
}
}
public forceKeepAlive(timeout?: number): void {
this.main.forceKeepAlive(timeout);
}
public async sendRequest(options: SendRequestOptions): Promise<Response> {
const responsePromise = this.main.sendRequest(options);
const response = await responsePromise;
// if we're received a response from the main channel and the status was successful,
// attempting to run a healthcheck on a libsignal transport.
if (
isSuccessfulStatusCode(response.status) &&
this.#shouldSendShadowRequest()
) {
drop(this.#sendShadowRequest());
}
return response;
}
async #sendShadowRequest(): Promise<void> {
// In the shadowing mode, it could be that we're either
// still connecting libsignal websocket or have already closed it.
// In those cases we're not running shadowing check.
if (!this.#shadowing) {
log.info(
`${this.#logId}: skipping healthcheck - websocket not connected or already closed`
);
return;
}
try {
const healthCheckResult = await this.#shadowing.sendRequest({
verb: 'GET',
path: '/v1/keepalive',
timeout: KEEPALIVE_TIMEOUT_MS,
});
this.#stats.requestsCompared += 1;
if (!isSuccessfulStatusCode(healthCheckResult.status)) {
this.#stats.healthcheckBadStatus += 1;
log.warn(
`${this.#logId}: keepalive via libsignal responded with status [${healthCheckResult.status}]`
);
}
} catch (error) {
this.#stats.healthcheckFailures += 1;
log.warn(
`${this.#logId}: failed to send keepalive via libsignal`,
Errors.toLogFormat(error)
);
}
}
#shouldSendShadowRequest(): boolean {
return this.#shadowingWithReporting || random(0, 100) < 10;
}
}
function isSuccessfulStatusCode(status: number): boolean {
return status >= 200 && status < 300;
}
export default class WebSocketResource export default class WebSocketResource
extends EventTarget extends EventTarget
implements IWebSocketResource implements IWebSocketResource