unauthenticated WebSocket via libsignal: shadowing mode
Co-authored-by: Scott Nonnenberg <scott@signal.org>
This commit is contained in:
parent
d696a2c082
commit
9f40562b19
14 changed files with 636 additions and 119 deletions
|
@ -2,13 +2,13 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import URL from 'url';
|
||||
import type { RequestInit } from 'node-fetch';
|
||||
import { Response, Headers } from 'node-fetch';
|
||||
import type { RequestInit, Response } from 'node-fetch';
|
||||
import { Headers } from 'node-fetch';
|
||||
import type { connection as WebSocket } from 'websocket';
|
||||
import qs from 'querystring';
|
||||
import EventListener from 'events';
|
||||
|
||||
import type { AbortableProcess } from '../util/AbortableProcess';
|
||||
import { AbortableProcess } from '../util/AbortableProcess';
|
||||
import { strictAssert } from '../util/assert';
|
||||
import { BackOff, FIBONACCI_TIMEOUTS } from '../util/BackOff';
|
||||
import * as durations from '../util/durations';
|
||||
|
@ -20,18 +20,28 @@ import * as Bytes from '../Bytes';
|
|||
import * as log from '../logging/log';
|
||||
|
||||
import type {
|
||||
WebSocketResourceOptions,
|
||||
IncomingWebSocketRequest,
|
||||
IWebSocketResource,
|
||||
WebSocketResourceOptions,
|
||||
} from './WebsocketResources';
|
||||
import WebSocketResource, {
|
||||
LibsignalWebSocketResource,
|
||||
TransportOption,
|
||||
WebSocketResourceWithShadowing,
|
||||
} from './WebsocketResources';
|
||||
import WebSocketResource from './WebsocketResources';
|
||||
import { HTTPError } from './Errors';
|
||||
import type { WebAPICredentials, IRequestHandler } from './Types.d';
|
||||
import type { IRequestHandler, WebAPICredentials } from './Types.d';
|
||||
import { connect as connectWebSocket } from './WebSocket';
|
||||
import { isAlpha, isBeta, isStaging } from '../util/version';
|
||||
|
||||
const FIVE_MINUTES = 5 * durations.MINUTE;
|
||||
|
||||
const JITTER = 5 * durations.SECOND;
|
||||
|
||||
export const UNAUTHENTICATED_CHANNEL_NAME = 'unauthenticated';
|
||||
|
||||
export const AUTHENTICATED_CHANNEL_NAME = 'authenticated';
|
||||
|
||||
export type SocketManagerOptions = Readonly<{
|
||||
url: string;
|
||||
artCreatorUrl: string;
|
||||
|
@ -43,9 +53,9 @@ export type SocketManagerOptions = Readonly<{
|
|||
|
||||
// This class manages two websocket resources:
|
||||
//
|
||||
// - Authenticated WebSocketResource which uses supplied WebAPICredentials and
|
||||
// - Authenticated IWebSocketResource which uses supplied WebAPICredentials and
|
||||
// automatically reconnects on closed socket (using back off)
|
||||
// - Unauthenticated WebSocketResource that is created on the first outgoing
|
||||
// - Unauthenticated IWebSocketResource that is created on the first outgoing
|
||||
// unauthenticated request and is periodically rotated (5 minutes since first
|
||||
// activity on the socket).
|
||||
//
|
||||
|
@ -54,15 +64,15 @@ export type SocketManagerOptions = Readonly<{
|
|||
// least one such request handler becomes available.
|
||||
//
|
||||
// Incoming requests on unauthenticated resource are not currently supported.
|
||||
// WebSocketResource is responsible for their immediate termination.
|
||||
// IWebSocketResource is responsible for their immediate termination.
|
||||
export class SocketManager extends EventListener {
|
||||
private backOff = new BackOff(FIBONACCI_TIMEOUTS, {
|
||||
jitter: JITTER,
|
||||
});
|
||||
|
||||
private authenticated?: AbortableProcess<WebSocketResource>;
|
||||
private authenticated?: AbortableProcess<IWebSocketResource>;
|
||||
|
||||
private unauthenticated?: AbortableProcess<WebSocketResource>;
|
||||
private unauthenticated?: AbortableProcess<IWebSocketResource>;
|
||||
|
||||
private unauthenticatedExpirationTimer?: NodeJS.Timeout;
|
||||
|
||||
|
@ -138,11 +148,11 @@ export class SocketManager extends EventListener {
|
|||
this.setStatus(SocketStatus.CONNECTING);
|
||||
|
||||
const process = this.connectResource({
|
||||
name: 'authenticated',
|
||||
name: AUTHENTICATED_CHANNEL_NAME,
|
||||
path: '/v1/websocket/',
|
||||
query: { login: username, password },
|
||||
resourceOptions: {
|
||||
name: 'authenticated',
|
||||
name: AUTHENTICATED_CHANNEL_NAME,
|
||||
keepalive: { path: '/v1/keepalive' },
|
||||
handleRequest: (req: IncomingWebSocketRequest): void => {
|
||||
this.queueOrHandleRequest(req);
|
||||
|
@ -190,7 +200,7 @@ export class SocketManager extends EventListener {
|
|||
}
|
||||
};
|
||||
|
||||
let authenticated: WebSocketResource;
|
||||
let authenticated: IWebSocketResource;
|
||||
try {
|
||||
authenticated = await process.getResult();
|
||||
this.setStatus(SocketStatus.OPEN);
|
||||
|
@ -230,7 +240,7 @@ export class SocketManager extends EventListener {
|
|||
}
|
||||
|
||||
log.info(
|
||||
`SocketManager: connected authenticated socket (localPort: ${authenticated.localPort})`
|
||||
`SocketManager: connected authenticated socket (localPort: ${authenticated.localPort()})`
|
||||
);
|
||||
|
||||
window.logAuthenticatedConnect?.();
|
||||
|
@ -262,8 +272,8 @@ export class SocketManager extends EventListener {
|
|||
}
|
||||
|
||||
// Either returns currently connecting/active authenticated
|
||||
// WebSocketResource or connects a fresh one.
|
||||
public async getAuthenticatedResource(): Promise<WebSocketResource> {
|
||||
// IWebSocketResource or connects a fresh one.
|
||||
public async getAuthenticatedResource(): Promise<IWebSocketResource> {
|
||||
if (!this.authenticated) {
|
||||
strictAssert(this.credentials !== undefined, 'Missing credentials');
|
||||
await this.authenticate(this.credentials);
|
||||
|
@ -273,10 +283,10 @@ export class SocketManager extends EventListener {
|
|||
return this.authenticated.getResult();
|
||||
}
|
||||
|
||||
// Creates new WebSocketResource for AccountManager's provisioning
|
||||
// Creates new IWebSocketResource for AccountManager's provisioning
|
||||
public async getProvisioningResource(
|
||||
handler: IRequestHandler
|
||||
): Promise<WebSocketResource> {
|
||||
): Promise<IWebSocketResource> {
|
||||
return this.connectResource({
|
||||
name: 'provisioning',
|
||||
path: '/v1/websocket/provisioning/',
|
||||
|
@ -317,7 +327,7 @@ export class SocketManager extends EventListener {
|
|||
public async fetch(url: string, init: RequestInit): Promise<Response> {
|
||||
const headers = new Headers(init.headers);
|
||||
|
||||
let resource: WebSocketResource;
|
||||
let resource: IWebSocketResource;
|
||||
if (this.isAuthenticated(headers)) {
|
||||
resource = await this.getAuthenticatedResource();
|
||||
} else {
|
||||
|
@ -343,34 +353,13 @@ export class SocketManager extends EventListener {
|
|||
throw new Error(`Unsupported body type: ${typeof body}`);
|
||||
}
|
||||
|
||||
const {
|
||||
status,
|
||||
message: statusText,
|
||||
response,
|
||||
headers: flatResponseHeaders,
|
||||
} = await resource.sendRequest({
|
||||
return resource.sendRequest({
|
||||
verb: method,
|
||||
path,
|
||||
body: bodyBytes,
|
||||
headers: Array.from(headers.entries()).map(([key, value]) => {
|
||||
return `${key}:${value}`;
|
||||
}),
|
||||
headers: Array.from(headers.entries()),
|
||||
timeout,
|
||||
});
|
||||
|
||||
const responseHeaders: Array<[string, string]> = flatResponseHeaders.map(
|
||||
header => {
|
||||
const [key, value] = header.split(':', 2);
|
||||
strictAssert(value !== undefined, 'Invalid header!');
|
||||
return [key, value];
|
||||
}
|
||||
);
|
||||
|
||||
return new Response(response, {
|
||||
status,
|
||||
statusText,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
public registerRequestHandler(handler: IRequestHandler): void {
|
||||
|
@ -427,7 +416,7 @@ export class SocketManager extends EventListener {
|
|||
}
|
||||
|
||||
// Puts SocketManager into "online" state and reconnects the authenticated
|
||||
// WebSocketResource (if there are valid credentials)
|
||||
// IWebSocketResource (if there are valid credentials)
|
||||
public async onOnline(): Promise<void> {
|
||||
log.info('SocketManager.onOnline');
|
||||
this.isOffline = false;
|
||||
|
@ -477,7 +466,62 @@ export class SocketManager extends EventListener {
|
|||
this.emit('statusChange');
|
||||
}
|
||||
|
||||
private async getUnauthenticatedResource(): Promise<WebSocketResource> {
|
||||
private transportOption(): TransportOption {
|
||||
const { hostname } = URL.parse(this.options.url);
|
||||
|
||||
// transport experiment doesn't support proxy
|
||||
if (
|
||||
this.proxyAgent ||
|
||||
hostname == null ||
|
||||
!hostname.endsWith('signal.org')
|
||||
) {
|
||||
return TransportOption.Original;
|
||||
}
|
||||
|
||||
// in staging, switch to using libsignal transport
|
||||
if (isStaging(this.options.version)) {
|
||||
return TransportOption.Libsignal;
|
||||
}
|
||||
|
||||
// in alpha, switch to using libsignal transport, unless user opts out,
|
||||
// in which case switching to shadowing
|
||||
if (isAlpha(this.options.version)) {
|
||||
const configValue = window.Signal.RemoteConfig.isEnabled(
|
||||
'desktop.experimentalTransportEnabled.alpha'
|
||||
);
|
||||
return configValue
|
||||
? TransportOption.Libsignal
|
||||
: 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;
|
||||
}
|
||||
|
||||
// in prod, using original
|
||||
return TransportOption.ShadowingLow;
|
||||
}
|
||||
|
||||
private connectLibsignalUnauthenticated(): AbortableProcess<IWebSocketResource> {
|
||||
return new AbortableProcess<IWebSocketResource>(
|
||||
`WebSocket.connect(libsignal.${UNAUTHENTICATED_CHANNEL_NAME})`,
|
||||
{
|
||||
abort() {
|
||||
// noop
|
||||
},
|
||||
},
|
||||
Promise.resolve(new LibsignalWebSocketResource(this.options.version))
|
||||
);
|
||||
}
|
||||
|
||||
private async getUnauthenticatedResource(): Promise<IWebSocketResource> {
|
||||
if (this.isOffline) {
|
||||
throw new HTTPError('SocketManager offline', {
|
||||
code: 0,
|
||||
|
@ -490,19 +534,28 @@ export class SocketManager extends EventListener {
|
|||
return this.unauthenticated.getResult();
|
||||
}
|
||||
|
||||
log.info('SocketManager: connecting unauthenticated socket');
|
||||
const transportOption = this.transportOption();
|
||||
log.info(
|
||||
`SocketManager: connecting unauthenticated socket, transport option [${transportOption}]`
|
||||
);
|
||||
|
||||
if (transportOption === TransportOption.Libsignal) {
|
||||
this.unauthenticated = this.connectLibsignalUnauthenticated();
|
||||
return this.unauthenticated.getResult();
|
||||
}
|
||||
|
||||
const process = this.connectResource({
|
||||
name: 'unauthenticated',
|
||||
name: UNAUTHENTICATED_CHANNEL_NAME,
|
||||
path: '/v1/websocket/',
|
||||
resourceOptions: {
|
||||
name: 'unauthenticated',
|
||||
name: UNAUTHENTICATED_CHANNEL_NAME,
|
||||
keepalive: { path: '/v1/keepalive' },
|
||||
transportOption,
|
||||
},
|
||||
});
|
||||
this.unauthenticated = process;
|
||||
|
||||
let unauthenticated: WebSocketResource;
|
||||
let unauthenticated: IWebSocketResource;
|
||||
try {
|
||||
unauthenticated = await this.unauthenticated.getResult();
|
||||
} catch (error) {
|
||||
|
@ -515,7 +568,7 @@ export class SocketManager extends EventListener {
|
|||
}
|
||||
|
||||
log.info(
|
||||
`SocketManager: connected unauthenticated socket (localPort: ${unauthenticated.localPort})`
|
||||
`SocketManager: connected unauthenticated socket (localPort: ${unauthenticated.localPort()})`
|
||||
);
|
||||
|
||||
unauthenticated.addEventListener('close', ({ code, reason }): void => {
|
||||
|
@ -546,7 +599,7 @@ export class SocketManager extends EventListener {
|
|||
resourceOptions: WebSocketResourceOptions;
|
||||
query?: Record<string, string>;
|
||||
extraHeaders?: Record<string, string>;
|
||||
}): AbortableProcess<WebSocketResource> {
|
||||
}): AbortableProcess<IWebSocketResource> {
|
||||
const queryWithDefaults = {
|
||||
agent: 'OWD',
|
||||
version: this.options.version,
|
||||
|
@ -554,24 +607,32 @@ export class SocketManager extends EventListener {
|
|||
};
|
||||
|
||||
const url = `${this.options.url}${path}?${qs.encode(queryWithDefaults)}`;
|
||||
const { version } = this.options;
|
||||
|
||||
return connectWebSocket({
|
||||
name,
|
||||
url,
|
||||
version,
|
||||
certificateAuthority: this.options.certificateAuthority,
|
||||
version: this.options.version,
|
||||
proxyAgent: this.proxyAgent,
|
||||
|
||||
extraHeaders,
|
||||
|
||||
createResource(socket: WebSocket): WebSocketResource {
|
||||
return new WebSocketResource(socket, resourceOptions);
|
||||
createResource(socket: WebSocket): IWebSocketResource {
|
||||
return !resourceOptions.transportOption ||
|
||||
resourceOptions.transportOption === TransportOption.Original
|
||||
? new WebSocketResource(socket, resourceOptions)
|
||||
: new WebSocketResourceWithShadowing(
|
||||
socket,
|
||||
resourceOptions,
|
||||
version
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private static async checkResource(
|
||||
process?: AbortableProcess<WebSocketResource>
|
||||
process?: AbortableProcess<IWebSocketResource>
|
||||
): Promise<void> {
|
||||
if (!process) {
|
||||
return;
|
||||
|
@ -582,7 +643,7 @@ export class SocketManager extends EventListener {
|
|||
}
|
||||
|
||||
private dropAuthenticated(
|
||||
process: AbortableProcess<WebSocketResource>
|
||||
process: AbortableProcess<IWebSocketResource>
|
||||
): void {
|
||||
if (this.authenticated !== process) {
|
||||
return;
|
||||
|
@ -594,7 +655,7 @@ export class SocketManager extends EventListener {
|
|||
}
|
||||
|
||||
private dropUnauthenticated(
|
||||
process: AbortableProcess<WebSocketResource>
|
||||
process: AbortableProcess<IWebSocketResource>
|
||||
): void {
|
||||
if (this.unauthenticated !== process) {
|
||||
return;
|
||||
|
@ -609,7 +670,7 @@ export class SocketManager extends EventListener {
|
|||
}
|
||||
|
||||
private async startUnauthenticatedExpirationTimer(
|
||||
expected: WebSocketResource
|
||||
expected: IWebSocketResource
|
||||
): Promise<void> {
|
||||
const process = this.unauthenticated;
|
||||
strictAssert(
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue