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
|
@ -4343,7 +4343,7 @@ For more information on this, and how to apply and follow the GNU AGPL, see
|
|||
|
||||
```
|
||||
|
||||
## attest 0.1.0, device-transfer 0.1.0, libsignal-bridge 0.1.0, libsignal-bridge-macros 0.1.0, libsignal-core 0.1.0, libsignal-ffi 0.40.1, libsignal-jni 0.40.1, libsignal-message-backup 0.1.0, libsignal-message-backup-macros 0.1.0, libsignal-net 0.1.0, libsignal-node 0.40.1, libsignal-protocol 0.1.0, libsignal-svr3 0.1.0, poksho 0.7.0, signal-crypto 0.1.0, signal-media 0.1.0, signal-neon-futures 0.1.0, signal-neon-futures-tests 0.1.0, signal-pin 0.1.0, usernames 0.1.0, zkcredential 0.1.0, zkgroup 0.9.0
|
||||
## attest 0.1.0, device-transfer 0.1.0, libsignal-bridge 0.1.0, libsignal-bridge-macros 0.1.0, libsignal-core 0.1.0, libsignal-ffi 0.41.2, libsignal-jni 0.41.2, libsignal-message-backup 0.1.0, libsignal-message-backup-macros 0.1.0, libsignal-net 0.1.0, libsignal-node 0.41.2, libsignal-protocol 0.1.0, libsignal-svr3 0.1.0, poksho 0.7.0, signal-crypto 0.1.0, signal-media 0.1.0, signal-neon-futures 0.1.0, signal-neon-futures-tests 0.1.0, signal-pin 0.1.0, usernames 0.1.0, zkcredential 0.1.0, zkgroup 0.9.0
|
||||
|
||||
```
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
|
@ -6069,7 +6069,7 @@ express Statement of Purpose.
|
|||
* CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. */
|
||||
```
|
||||
|
||||
## libloading 0.6.7, libloading 0.8.1
|
||||
## libloading 0.8.1
|
||||
|
||||
```
|
||||
Copyright © 2015, Simonas Kazlauskas
|
||||
|
@ -6479,7 +6479,7 @@ DEALINGS IN THE SOFTWARE.
|
|||
|
||||
```
|
||||
|
||||
## bitflags 1.3.2, bitflags 2.4.2, glob 0.3.1, log 0.4.20, num-derive 0.4.2, num-integer 0.1.45, num-traits 0.2.17, range-map 0.2.0, regex 1.10.3, regex-automata 0.4.4, regex-syntax 0.8.2, semver 0.9.0
|
||||
## bitflags 1.3.2, bitflags 2.4.2, glob 0.3.1, log 0.4.20, num-derive 0.4.2, num-integer 0.1.45, num-traits 0.2.17, range-map 0.2.0, regex 1.10.3, regex-automata 0.4.4, regex-syntax 0.8.2
|
||||
|
||||
```
|
||||
Copyright (c) 2014 The Rust Project Developers
|
||||
|
@ -6681,7 +6681,7 @@ THE SOFTWARE.
|
|||
|
||||
```
|
||||
|
||||
## neon 0.10.1, neon-build 0.10.1, neon-macros 0.10.1, neon-runtime 0.10.1
|
||||
## neon-macros 1.0.0
|
||||
|
||||
```
|
||||
Copyright (c) 2015 David Herman
|
||||
|
@ -7301,37 +7301,6 @@ DEALINGS IN THE SOFTWARE.
|
|||
|
||||
```
|
||||
|
||||
## semver-parser 0.7.0
|
||||
|
||||
```
|
||||
Copyright (c) 2016 Steve Klabnik
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without
|
||||
limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software
|
||||
is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice
|
||||
shall be included in all copies or substantial portions
|
||||
of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
||||
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
```
|
||||
|
||||
## lock_api 0.4.11, parking_lot 0.12.1, parking_lot_core 0.9.9, rustc_version 0.4.0
|
||||
|
||||
```
|
||||
|
@ -9459,6 +9428,33 @@ SOFTWARE.
|
|||
|
||||
```
|
||||
|
||||
## strum 0.26.1, strum_macros 0.26.1
|
||||
|
||||
```
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019 Peter Glotfelty
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
```
|
||||
|
||||
## fslock 0.2.1
|
||||
|
||||
```
|
||||
|
@ -9674,7 +9670,7 @@ SOFTWARE.
|
|||
|
||||
```
|
||||
|
||||
## cesu8 1.1.0, curve25519-dalek-derive 0.1.0, half 1.8.2, pqcrypto-internals 0.2.5, pqcrypto-kyber 0.7.9, pqcrypto-kyber 0.8.0, pqcrypto-traits 0.3.5
|
||||
## cesu8 1.1.0, curve25519-dalek-derive 0.1.0, half 1.8.2, neon 1.0.0, pqcrypto-internals 0.2.5, pqcrypto-kyber 0.7.9, pqcrypto-kyber 0.8.0, pqcrypto-traits 0.3.5
|
||||
|
||||
```
|
||||
MIT License
|
||||
|
@ -9788,7 +9784,7 @@ DEALINGS IN THE SOFTWARE.
|
|||
|
||||
```
|
||||
|
||||
## adler 1.0.2, anyhow 1.0.79, async-trait 0.1.77, dyn-clone 1.0.16, fastrand 2.0.1, home 0.5.9, itoa 1.0.10, linkme 0.3.22, linkme-impl 0.3.22, linux-raw-sys 0.4.13, minimal-lexical 0.2.1, num_enum 0.6.1, num_enum_derive 0.6.1, once_cell 1.19.0, paste 1.0.14, pin-project-lite 0.2.13, prettyplease 0.2.16, proc-macro-crate 1.3.1, proc-macro2 1.0.78, quote 1.0.35, rustc-hash 1.1.0, rustix 0.38.30, semver 1.0.21, serde 1.0.195, serde_derive 1.0.195, serde_json 1.0.111, syn 1.0.109, syn 2.0.48, syn-mid 0.5.4, syn-mid 0.6.0, thiserror 1.0.56, thiserror-impl 1.0.56, unicode-ident 1.0.12, utf-8 0.7.6
|
||||
## adler 1.0.2, anyhow 1.0.79, async-trait 0.1.77, dyn-clone 1.0.16, fastrand 2.0.1, home 0.5.9, itoa 1.0.10, linkme 0.3.22, linkme-impl 0.3.22, linux-raw-sys 0.4.13, minimal-lexical 0.2.1, num_enum 0.6.1, num_enum_derive 0.6.1, once_cell 1.19.0, paste 1.0.14, pin-project-lite 0.2.13, prettyplease 0.2.16, proc-macro-crate 1.3.1, proc-macro2 1.0.78, quote 1.0.35, rustc-hash 1.1.0, rustix 0.38.30, rustversion 1.0.14, semver 1.0.21, send_wrapper 0.6.0, serde 1.0.195, serde_derive 1.0.195, serde_json 1.0.111, syn 1.0.109, syn 2.0.48, syn-mid 0.6.0, thiserror 1.0.56, thiserror-impl 1.0.56, unicode-ident 1.0.12, utf-8 0.7.6
|
||||
|
||||
```
|
||||
Permission is hereby granted, free of charge, to any
|
||||
|
@ -10590,6 +10586,10 @@ written authorization of the copyright holder.
|
|||
|
||||
```
|
||||
|
||||
## Kyber Patent License
|
||||
|
||||
<https://csrc.nist.gov/csrc/media/Projects/post-quantum-cryptography/documents/selected-algos-2022/nist-pqc-license-summary-and-excerpts.pdf>
|
||||
|
||||
# Acknowledgements for @signalapp/ringrtc
|
||||
|
||||
RingRTC makes use of the following open source projects.
|
||||
|
|
|
@ -7368,6 +7368,10 @@
|
|||
"messageformat": "{count, plural, one {# other is} other {# others are}} typing.",
|
||||
"description": "Group chat multiple person typing indicator when space isn't available to show every avatar, this is the count of avatars hidden."
|
||||
},
|
||||
"icu:TransportError": {
|
||||
"messageformat": "Experimental WebSocket Transport is seeing too many errors. Please submit a debug log",
|
||||
"description": "A toast that is shown to alpha/beta version users when an experimental transport is seeing too many errors"
|
||||
},
|
||||
"icu:WhoCanFindMeReadOnlyToast": {
|
||||
"messageformat": "To change this setting, set “Who can see my number” to “Nobody”.",
|
||||
"description": "A toast displayed when user clicks disabled option in settings window"
|
||||
|
|
|
@ -102,7 +102,7 @@
|
|||
"@react-aria/utils": "3.16.0",
|
||||
"@react-spring/web": "9.5.5",
|
||||
"@signalapp/better-sqlite3": "8.7.1",
|
||||
"@signalapp/libsignal-client": "0.40.1",
|
||||
"@signalapp/libsignal-client": "0.41.2",
|
||||
"@signalapp/ringrtc": "2.39.1",
|
||||
"@signalapp/windows-dummy-keystroke": "1.0.0",
|
||||
"@types/fabric": "4.5.3",
|
||||
|
|
|
@ -24,6 +24,8 @@ export type ConfigKeyType =
|
|||
| 'desktop.retryRespondMaxAge'
|
||||
| 'desktop.senderKey.retry'
|
||||
| 'desktop.senderKeyMaxAge'
|
||||
| 'desktop.experimentalTransportEnabled.alpha'
|
||||
| 'desktop.experimentalTransportEnabled.beta'
|
||||
| 'global.attachments.maxBytes'
|
||||
| 'global.attachments.maxReceiveBytes'
|
||||
| 'global.calling.maxGroupCallRingSize'
|
||||
|
|
|
@ -141,6 +141,8 @@ function getToast(toastType: ToastType): AnyToast {
|
|||
return { toastType: ToastType.TapToViewExpiredIncoming };
|
||||
case ToastType.TapToViewExpiredOutgoing:
|
||||
return { toastType: ToastType.TapToViewExpiredOutgoing };
|
||||
case ToastType.TransportError:
|
||||
return { toastType: ToastType.TransportError };
|
||||
case ToastType.TooManyMessagesToDeleteForEveryone:
|
||||
return {
|
||||
toastType: ToastType.TooManyMessagesToDeleteForEveryone,
|
||||
|
|
|
@ -470,6 +470,10 @@ export function renderToast({
|
|||
);
|
||||
}
|
||||
|
||||
if (toastType === ToastType.TransportError) {
|
||||
return <Toast onClose={hideToast}>{i18n('icu:TransportError')}</Toast>;
|
||||
}
|
||||
|
||||
if (toastType === ToastType.UnableToLoadAttachment) {
|
||||
return (
|
||||
<Toast onClose={hideToast}>{i18n('icu:unableToLoadAttachment')}</Toast>
|
||||
|
|
|
@ -127,9 +127,9 @@ describe('WebSocket-Resource', () => {
|
|||
}).finish(),
|
||||
});
|
||||
|
||||
const { status, message } = await promise;
|
||||
assert.strictEqual(message, 'OK');
|
||||
assert.strictEqual(status, 200);
|
||||
const response = await promise;
|
||||
assert.strictEqual(response.statusText, 'OK');
|
||||
assert.strictEqual(response.status, 200);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -55,7 +55,6 @@ import { isBadgeImageFileUrlValid } from '../badges/isBadgeImageFileUrlValid';
|
|||
import { SocketManager } from './SocketManager';
|
||||
import type { CDSAuthType, CDSResponseType } from './cds/Types.d';
|
||||
import { CDSI } from './cds/CDSI';
|
||||
import type WebSocketResource from './WebsocketResources';
|
||||
import { SignalService as Proto } from '../protobuf';
|
||||
|
||||
import { HTTPError } from './Errors';
|
||||
|
@ -70,6 +69,7 @@ import { handleStatusCode, translateError } from './Utils';
|
|||
import * as log from '../logging/log';
|
||||
import { maybeParseUrl, urlPathFromComponents } from '../util/url';
|
||||
import { SECOND } from '../util/durations';
|
||||
import type { IWebSocketResource } from './WebsocketResources';
|
||||
|
||||
// Note: this will break some code that expects to be able to use err.response when a
|
||||
// web request fails, because it will force it to text. But it is very useful for
|
||||
|
@ -1034,7 +1034,7 @@ export type WebAPIType = {
|
|||
) => Promise<unknown>;
|
||||
getProvisioningResource: (
|
||||
handler: IRequestHandler
|
||||
) => Promise<WebSocketResource>;
|
||||
) => Promise<IWebSocketResource>;
|
||||
getArtProvisioningSocket: (token: string) => Promise<WebSocket>;
|
||||
getSenderCertificate: (
|
||||
withUuid?: boolean
|
||||
|
@ -3473,7 +3473,7 @@ export function initialize({
|
|||
|
||||
function getProvisioningResource(
|
||||
handler: IRequestHandler
|
||||
): Promise<WebSocketResource> {
|
||||
): Promise<IWebSocketResource> {
|
||||
return socketManager.getProvisioningResource(handler);
|
||||
}
|
||||
|
||||
|
|
|
@ -23,9 +23,19 @@
|
|||
*
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-namespace */
|
||||
/* eslint-disable @typescript-eslint/brace-style */
|
||||
|
||||
import { Net } from '@signalapp/libsignal-client';
|
||||
import type { connection as WebSocket, IMessage } from 'websocket';
|
||||
import Long from 'long';
|
||||
import pTimeout from 'p-timeout';
|
||||
import { Response } from 'node-fetch';
|
||||
import net from 'net';
|
||||
import { z } from 'zod';
|
||||
import { clearInterval } from 'timers';
|
||||
import { random } from 'lodash';
|
||||
import type { DebugInfo } from '@signalapp/libsignal-client/Native';
|
||||
|
||||
import type { EventHandler } from './EventTarget';
|
||||
import EventTarget from './EventTarget';
|
||||
|
@ -38,11 +48,121 @@ import * as Errors from '../types/errors';
|
|||
import { SignalService as Proto } from '../protobuf';
|
||||
import * as log from '../logging/log';
|
||||
import * as Timers from '../Timers';
|
||||
import type { IResource } from './WebSocket';
|
||||
import { isProduction, isStaging } from '../util/version';
|
||||
|
||||
import { ToastType } from '../types/Toast';
|
||||
import { drop } from '../util/drop';
|
||||
|
||||
const THIRTY_SECONDS = 30 * durations.SECOND;
|
||||
|
||||
const HEALTHCHECK_TIMEOUT = durations.SECOND;
|
||||
|
||||
const STATS_UPDATE_INTERVAL = durations.MINUTE;
|
||||
|
||||
const MAX_MESSAGE_SIZE = 512 * 1024;
|
||||
|
||||
const AGGREGATED_STATS_KEY = 'websocketStats';
|
||||
|
||||
export enum IpVersion {
|
||||
IPv4 = 'ipv4',
|
||||
IPv6 = 'ipv6',
|
||||
}
|
||||
|
||||
export namespace IpVersion {
|
||||
export function fromDebugInfoCode(ipType: number): IpVersion | undefined {
|
||||
switch (ipType) {
|
||||
case 1:
|
||||
return IpVersion.IPv4;
|
||||
case 2:
|
||||
return IpVersion.IPv6;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const AggregatedStatsSchema = z.object({
|
||||
requestsCompared: z.number(),
|
||||
ipVersionMismatches: z.number(),
|
||||
unexpectedReconnects: z.number(),
|
||||
healthcheckFailures: z.number(),
|
||||
healthcheckBadStatus: z.number(),
|
||||
lastToastTimestamp: z.number(),
|
||||
});
|
||||
|
||||
export type AggregatedStats = z.infer<typeof AggregatedStatsSchema>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
||||
export namespace AggregatedStats {
|
||||
export function loadOrCreateEmpty(name: string): AggregatedStats {
|
||||
const key = localStorageKey(name);
|
||||
try {
|
||||
const json = localStorage.getItem(key);
|
||||
return json != null
|
||||
? AggregatedStatsSchema.parse(JSON.parse(json))
|
||||
: createEmpty();
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
`Could not load [${key}] from local storage. Possibly, attempting to load for the first time`,
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
return createEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
export function store(stats: AggregatedStats, name: string): void {
|
||||
const key = localStorageKey(name);
|
||||
try {
|
||||
const json = JSON.stringify(stats);
|
||||
localStorage.setItem(key, json);
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
`Failed to store key [${key}] to the local storage`,
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function add(a: AggregatedStats, b: AggregatedStats): AggregatedStats {
|
||||
return {
|
||||
requestsCompared: a.requestsCompared + b.requestsCompared,
|
||||
healthcheckFailures: a.healthcheckFailures + b.healthcheckFailures,
|
||||
ipVersionMismatches: a.ipVersionMismatches + b.ipVersionMismatches,
|
||||
unexpectedReconnects: a.unexpectedReconnects + b.unexpectedReconnects,
|
||||
healthcheckBadStatus: a.healthcheckBadStatus + b.healthcheckBadStatus,
|
||||
lastToastTimestamp: Math.max(a.lastToastTimestamp, b.lastToastTimestamp),
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmpty(): AggregatedStats {
|
||||
return {
|
||||
requestsCompared: 0,
|
||||
ipVersionMismatches: 0,
|
||||
unexpectedReconnects: 0,
|
||||
healthcheckFailures: 0,
|
||||
healthcheckBadStatus: 0,
|
||||
lastToastTimestamp: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldReportError(stats: AggregatedStats): boolean {
|
||||
const timeSinceLastToast = Date.now() - stats.lastToastTimestamp;
|
||||
if (timeSinceLastToast < durations.DAY || stats.requestsCompared < 1000) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
stats.healthcheckBadStatus + stats.healthcheckFailures > 20 ||
|
||||
stats.ipVersionMismatches > 50 ||
|
||||
stats.unexpectedReconnects > 50
|
||||
);
|
||||
}
|
||||
|
||||
export function localStorageKey(name: string): string {
|
||||
return `${AGGREGATED_STATS_KEY}.${name}`;
|
||||
}
|
||||
}
|
||||
|
||||
export class IncomingWebSocketRequest {
|
||||
private readonly id: Long;
|
||||
|
||||
|
@ -84,7 +204,7 @@ export type SendRequestOptions = Readonly<{
|
|||
path: string;
|
||||
body?: Uint8Array;
|
||||
timeout?: number;
|
||||
headers?: ReadonlyArray<string>;
|
||||
headers?: ReadonlyArray<[string, string]>;
|
||||
}>;
|
||||
|
||||
export type SendRequestResult = Readonly<{
|
||||
|
@ -94,10 +214,29 @@ export type SendRequestResult = Readonly<{
|
|||
headers: ReadonlyArray<string>;
|
||||
}>;
|
||||
|
||||
export enum TransportOption {
|
||||
// Only original transport is used
|
||||
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
|
||||
Libsignal = 'libsignal',
|
||||
}
|
||||
|
||||
export type WebSocketResourceOptions = {
|
||||
name: string;
|
||||
handleRequest?: (request: IncomingWebSocketRequest) => void;
|
||||
keepalive?: KeepAliveOptionsType;
|
||||
transportOption?: TransportOption;
|
||||
};
|
||||
|
||||
export class CloseEvent extends Event {
|
||||
|
@ -106,7 +245,225 @@ export class CloseEvent extends Event {
|
|||
}
|
||||
}
|
||||
|
||||
export default class WebSocketResource extends EventTarget {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export interface IWebSocketResource extends IResource {
|
||||
sendRequest(options: SendRequestOptions): Promise<Response>;
|
||||
|
||||
addEventListener(name: 'close', handler: (ev: CloseEvent) => void): void;
|
||||
|
||||
forceKeepAlive(): void;
|
||||
|
||||
shutdown(): void;
|
||||
|
||||
close(): void;
|
||||
|
||||
localPort(): number | undefined;
|
||||
}
|
||||
|
||||
export class LibsignalWebSocketResource implements IWebSocketResource {
|
||||
private readonly net: Net.Net;
|
||||
|
||||
constructor(version: string) {
|
||||
this.net = new Net.Net(
|
||||
isStaging(version) ? Net.Environment.Staging : Net.Environment.Production
|
||||
);
|
||||
}
|
||||
|
||||
public localPort(): number | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public addEventListener(
|
||||
_name: 'close',
|
||||
_handler: (ev: CloseEvent) => void
|
||||
): void {
|
||||
// noop
|
||||
}
|
||||
|
||||
public close(_code?: number, _reason?: string): void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.net.disconnectChatService();
|
||||
}
|
||||
|
||||
public shutdown(): void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.net.disconnectChatService();
|
||||
}
|
||||
|
||||
public forceKeepAlive(): void {
|
||||
// no-op
|
||||
}
|
||||
|
||||
public async sendRequest(options: SendRequestOptions): Promise<Response> {
|
||||
const [response] = await this.sendRequestGetDebugInfo(options);
|
||||
return response;
|
||||
}
|
||||
|
||||
public async sendRequestGetDebugInfo(
|
||||
options: SendRequestOptions
|
||||
): Promise<[Response, DebugInfo]> {
|
||||
const { response, debugInfo } = await this.net.unauthenticatedFetchAndDebug(
|
||||
{
|
||||
verb: options.verb,
|
||||
path: options.path,
|
||||
headers: options.headers ? options.headers : [],
|
||||
body: options.body,
|
||||
timeoutMillis: options.timeout,
|
||||
}
|
||||
);
|
||||
return [
|
||||
new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.message,
|
||||
headers: [...response.headers],
|
||||
}),
|
||||
debugInfo,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export class WebSocketResourceWithShadowing implements IWebSocketResource {
|
||||
private main: WebSocketResource;
|
||||
|
||||
private shadowing: LibsignalWebSocketResource;
|
||||
|
||||
private stats: AggregatedStats;
|
||||
|
||||
private statsTimer: NodeJS.Timer;
|
||||
|
||||
private shadowingWithReporting: boolean;
|
||||
|
||||
private logId: string;
|
||||
|
||||
constructor(
|
||||
socket: WebSocket,
|
||||
options: WebSocketResourceOptions,
|
||||
version: string
|
||||
) {
|
||||
this.main = new WebSocketResource(socket, options);
|
||||
this.shadowing = new LibsignalWebSocketResource(version);
|
||||
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;
|
||||
|
||||
this.addEventListener('close', (_ev): void => {
|
||||
clearInterval(this.statsTimer);
|
||||
this.updateStats(options.name);
|
||||
});
|
||||
}
|
||||
|
||||
private updateStats(name: string) {
|
||||
const storedStats = AggregatedStats.loadOrCreateEmpty(name);
|
||||
const updatedStats = AggregatedStats.add(storedStats, this.stats);
|
||||
if (
|
||||
this.shadowingWithReporting &&
|
||||
AggregatedStats.shouldReportError(updatedStats) &&
|
||||
!isProduction(window.getVersion())
|
||||
) {
|
||||
window.reduxActions.toast.showToast({
|
||||
toastType: ToastType.TransportError,
|
||||
});
|
||||
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(): void {
|
||||
this.main.close();
|
||||
this.shadowing.close();
|
||||
}
|
||||
|
||||
public shutdown(): void {
|
||||
this.main.shutdown();
|
||||
this.shadowing.shutdown();
|
||||
}
|
||||
|
||||
public forceKeepAlive(): void {
|
||||
this.main.forceKeepAlive();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private async sendShadowRequest(): Promise<void> {
|
||||
try {
|
||||
const [healthCheckResult, debugInfo] =
|
||||
await this.shadowing.sendRequestGetDebugInfo({
|
||||
verb: 'GET',
|
||||
path: '/v1/keepalive',
|
||||
timeout: HEALTHCHECK_TIMEOUT,
|
||||
});
|
||||
this.stats.requestsCompared += 1;
|
||||
if (!isSuccessfulStatusCode(healthCheckResult.status)) {
|
||||
this.stats.healthcheckBadStatus += 1;
|
||||
log.warn(
|
||||
`${this.logId}: keepalive via libsignal responded with status [${healthCheckResult.status}]`
|
||||
);
|
||||
}
|
||||
const ipVersion = IpVersion.fromDebugInfoCode(debugInfo.ipType);
|
||||
if (this.main.ipVersion() !== ipVersion) {
|
||||
this.stats.ipVersionMismatches += 1;
|
||||
log.warn(
|
||||
`${
|
||||
this.logId
|
||||
}: keepalive via libsignal using IP [${ipVersion}] while main is using IP [${this.main.ipVersion()}]`
|
||||
);
|
||||
}
|
||||
if (debugInfo.reconnectCount > 1) {
|
||||
this.stats.unexpectedReconnects = debugInfo.reconnectCount - 1;
|
||||
}
|
||||
} catch (error) {
|
||||
this.stats.healthcheckFailures += 1;
|
||||
log.warn(
|
||||
`${this.logId}: failed to send keepalive via libsignal`,
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private shouldSendShadowRequest(): boolean {
|
||||
return this.shadowingWithReporting || random(0, 100) < 10;
|
||||
}
|
||||
}
|
||||
|
||||
function isSuccessfulStatusCode(status: number): boolean {
|
||||
return status >= 200 && status < 300;
|
||||
}
|
||||
|
||||
export default class WebSocketResource
|
||||
extends EventTarget
|
||||
implements IWebSocketResource
|
||||
{
|
||||
private outgoingId = Long.fromNumber(1, true);
|
||||
|
||||
private closed = false;
|
||||
|
@ -126,7 +483,9 @@ export default class WebSocketResource extends EventTarget {
|
|||
|
||||
private readonly logId: string;
|
||||
|
||||
public readonly localPort: number | undefined;
|
||||
private readonly localSocketPort: number | undefined;
|
||||
|
||||
private readonly socketIpVersion: IpVersion | undefined;
|
||||
|
||||
// Public for tests
|
||||
public readonly keepalive?: KeepAlive;
|
||||
|
@ -138,7 +497,20 @@ export default class WebSocketResource extends EventTarget {
|
|||
super();
|
||||
|
||||
this.logId = `WebSocketResource(${options.name})`;
|
||||
this.localPort = socket.socket.localPort;
|
||||
this.localSocketPort = socket.socket.localPort;
|
||||
|
||||
if (!socket.socket.localAddress) {
|
||||
this.socketIpVersion = undefined;
|
||||
}
|
||||
if (socket.socket.localAddress == null) {
|
||||
this.socketIpVersion = undefined;
|
||||
} else if (net.isIPv4(socket.socket.localAddress)) {
|
||||
this.socketIpVersion = IpVersion.IPv4;
|
||||
} else if (net.isIPv6(socket.socket.localAddress)) {
|
||||
this.socketIpVersion = IpVersion.IPv6;
|
||||
} else {
|
||||
this.socketIpVersion = undefined;
|
||||
}
|
||||
|
||||
this.boundOnMessage = this.onMessage.bind(this);
|
||||
|
||||
|
@ -169,6 +541,14 @@ export default class WebSocketResource extends EventTarget {
|
|||
this.addEventListener('close', () => this.onClose());
|
||||
}
|
||||
|
||||
public ipVersion(): IpVersion | undefined {
|
||||
return this.socketIpVersion;
|
||||
}
|
||||
|
||||
public localPort(): number | undefined {
|
||||
return this.localSocketPort;
|
||||
}
|
||||
|
||||
public override addEventListener(
|
||||
name: 'close',
|
||||
handler: (ev: CloseEvent) => void
|
||||
|
@ -178,9 +558,7 @@ export default class WebSocketResource extends EventTarget {
|
|||
return super.addEventListener(name, handler);
|
||||
}
|
||||
|
||||
public async sendRequest(
|
||||
options: SendRequestOptions
|
||||
): Promise<SendRequestResult> {
|
||||
public async sendRequest(options: SendRequestOptions): Promise<Response> {
|
||||
const id = this.outgoingId;
|
||||
const idString = id.toString();
|
||||
strictAssert(!this.outgoingMap.has(idString), 'Duplicate outgoing request');
|
||||
|
@ -194,7 +572,13 @@ export default class WebSocketResource extends EventTarget {
|
|||
verb: options.verb,
|
||||
path: options.path,
|
||||
body: options.body,
|
||||
headers: options.headers ? options.headers.slice() : undefined,
|
||||
headers: options.headers
|
||||
? options.headers
|
||||
.map(([key, value]) => {
|
||||
return `${key}:${value}`;
|
||||
})
|
||||
.slice()
|
||||
: undefined,
|
||||
id,
|
||||
},
|
||||
}).finish();
|
||||
|
@ -239,7 +623,8 @@ export default class WebSocketResource extends EventTarget {
|
|||
|
||||
this.socket.sendBytes(Buffer.from(bytes));
|
||||
|
||||
return promise;
|
||||
const requestResult = await promise;
|
||||
return WebSocketResource.intoResponse(requestResult);
|
||||
}
|
||||
|
||||
public forceKeepAlive(): void {
|
||||
|
@ -399,6 +784,27 @@ export default class WebSocketResource extends EventTarget {
|
|||
log.info(`${this.logId}.removeActive: shutdown complete`);
|
||||
this.close(3000, 'Shutdown');
|
||||
}
|
||||
|
||||
private static intoResponse(sendRequestResult: SendRequestResult): Response {
|
||||
const {
|
||||
status,
|
||||
message: statusText,
|
||||
response,
|
||||
headers: flatResponseHeaders,
|
||||
} = sendRequestResult;
|
||||
|
||||
const headers: 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export type KeepAliveOptionsType = {
|
||||
|
|
2
ts/textsecure/cds/Types.d.ts
vendored
2
ts/textsecure/cds/Types.d.ts
vendored
|
@ -4,7 +4,7 @@
|
|||
import type { Net } from '@signalapp/libsignal-client';
|
||||
import type { AciString, PniString } from '../../types/ServiceId';
|
||||
|
||||
export type CDSAuthType = Net.CDSAuthType;
|
||||
export type CDSAuthType = Net.ServiceAuth;
|
||||
export type CDSResponseEntryType = Net.CDSResponseEntryType<
|
||||
AciString,
|
||||
PniString
|
||||
|
|
|
@ -56,6 +56,7 @@ export enum ToastType {
|
|||
TapToViewExpiredOutgoing = 'TapToViewExpiredOutgoing',
|
||||
TooManyMessagesToDeleteForEveryone = 'TooManyMessagesToDeleteForEveryone',
|
||||
TooManyMessagesToForward = 'TooManyMessagesToForward',
|
||||
TransportError = 'TransportError',
|
||||
UnableToLoadAttachment = 'UnableToLoadAttachment',
|
||||
UnsupportedMultiAttachment = 'UnsupportedMultiAttachment',
|
||||
UnsupportedOS = 'UnsupportedOS',
|
||||
|
@ -136,6 +137,7 @@ export type AnyToast =
|
|||
parameters: { count: number };
|
||||
}
|
||||
| { toastType: ToastType.TooManyMessagesToForward }
|
||||
| { toastType: ToastType.TransportError }
|
||||
| { toastType: ToastType.UnableToLoadAttachment }
|
||||
| { toastType: ToastType.UnsupportedMultiAttachment }
|
||||
| { toastType: ToastType.UnsupportedOS }
|
||||
|
|
|
@ -22,6 +22,8 @@ import type {
|
|||
WindowsNotificationData,
|
||||
} from '../../services/notifications';
|
||||
import { isAdhocCallingEnabled } from '../../util/isAdhocCallingEnabled';
|
||||
import { AggregatedStats } from '../../textsecure/WebsocketResources';
|
||||
import { UNAUTHENTICATED_CHANNEL_NAME } from '../../textsecure/SocketManager';
|
||||
|
||||
// It is important to call this as early as possible
|
||||
window.i18n = SignalContext.i18n;
|
||||
|
@ -171,6 +173,15 @@ if (config.ciMode !== 'full' && config.environment !== 'test') {
|
|||
window.eval = global.eval = () => null;
|
||||
}
|
||||
|
||||
type NetworkStatistics = {
|
||||
signalConnectionCount?: string;
|
||||
unauthorizedRequestsCompared?: string;
|
||||
unauthorizedHealthcheckFailures?: string;
|
||||
unauthorizedHealthcheckBadStatus?: string;
|
||||
unauthorizedUnexpectedReconnects?: string;
|
||||
unauthorizedIpVersionMismatches?: string;
|
||||
};
|
||||
|
||||
ipc.on('additional-log-data-request', async event => {
|
||||
const ourConversation = window.ConversationController.getOurConversation();
|
||||
const ourCapabilities = ourConversation
|
||||
|
@ -186,6 +197,33 @@ ipc.on('additional-log-data-request', async event => {
|
|||
statistics = {};
|
||||
}
|
||||
|
||||
let networkStatistics: NetworkStatistics = {
|
||||
signalConnectionCount: formatCountForLogging(getSignalConnections().length),
|
||||
};
|
||||
const unauthorizedStats = AggregatedStats.loadOrCreateEmpty(
|
||||
UNAUTHENTICATED_CHANNEL_NAME
|
||||
);
|
||||
if (unauthorizedStats.requestsCompared > 0) {
|
||||
networkStatistics = {
|
||||
...networkStatistics,
|
||||
unauthorizedRequestsCompared: formatCountForLogging(
|
||||
unauthorizedStats.requestsCompared
|
||||
),
|
||||
unauthorizedHealthcheckFailures: formatCountForLogging(
|
||||
unauthorizedStats.healthcheckFailures
|
||||
),
|
||||
unauthorizedHealthcheckBadStatus: formatCountForLogging(
|
||||
unauthorizedStats.healthcheckBadStatus
|
||||
),
|
||||
unauthorizedUnexpectedReconnects: formatCountForLogging(
|
||||
unauthorizedStats.unexpectedReconnects
|
||||
),
|
||||
unauthorizedIpVersionMismatches: formatCountForLogging(
|
||||
unauthorizedStats.ipVersionMismatches
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const ourAci = window.textsecure.storage.user.getAci();
|
||||
const ourPni = window.textsecure.storage.user.getPni();
|
||||
|
||||
|
@ -198,9 +236,7 @@ ipc.on('additional-log-data-request', async event => {
|
|||
}),
|
||||
statistics: {
|
||||
...statistics,
|
||||
signalConnectionCount: formatCountForLogging(
|
||||
getSignalConnections().length
|
||||
),
|
||||
...networkStatistics,
|
||||
},
|
||||
user: {
|
||||
deviceId: window.textsecure.storage.user.getDeviceId(),
|
||||
|
|
|
@ -3983,10 +3983,10 @@
|
|||
bindings "^1.5.0"
|
||||
tar "^6.1.0"
|
||||
|
||||
"@signalapp/libsignal-client@0.40.1":
|
||||
version "0.40.1"
|
||||
resolved "https://registry.yarnpkg.com/@signalapp/libsignal-client/-/libsignal-client-0.40.1.tgz#07ed6d3da9d2458aa7e66f5a71a8a7ef6b4a467a"
|
||||
integrity sha512-5BLDpOrP7eXT9U1Vf8F3HVOt4dnLeFMCzalkSyGdFgzD59i/wva3PHtQtbfKhxX7pNaXwhele1tAbIXsKYtXUQ==
|
||||
"@signalapp/libsignal-client@0.41.2":
|
||||
version "0.41.2"
|
||||
resolved "https://registry.yarnpkg.com/@signalapp/libsignal-client/-/libsignal-client-0.41.2.tgz#da8d415168ab1f89c0b6e05d259feab60893e717"
|
||||
integrity sha512-GQh0AJ1gwifYbZhe+974e17Qj0Fymiv5qM1u0NHtr5SK0bTly7stTi14JYJ9exlOysaLMq1QdAt/CoQFwfQahg==
|
||||
dependencies:
|
||||
node-gyp-build "^4.2.3"
|
||||
type-fest "^3.5.0"
|
||||
|
|
Loading…
Reference in a new issue