major: switch to http2
This commit is contained in:
parent
2bf3aa95f0
commit
d7a1b5852c
10
.github/workflows/publish.yaml
vendored
10
.github/workflows/publish.yaml
vendored
@ -24,6 +24,8 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
|
||||
- name: Setup node.js
|
||||
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||
with:
|
||||
@ -31,13 +33,13 @@ jobs:
|
||||
registry-url: 'https://registry.npmjs.org/'
|
||||
|
||||
- name: Install node_modules
|
||||
run: npm ci
|
||||
run: pnpm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
run: pnpm run lint
|
||||
|
||||
- name: Test
|
||||
run: npm test
|
||||
run: pnpm test
|
||||
|
||||
- name: Publish
|
||||
run: npm publish --access public
|
||||
run: pnpm publish --access public
|
||||
|
||||
8
.github/workflows/test.yaml
vendored
8
.github/workflows/test.yaml
vendored
@ -16,16 +16,18 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
|
||||
- name: Setup node.js
|
||||
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Install node_modules
|
||||
run: npm install
|
||||
run: pnpm install
|
||||
|
||||
- name: Run lint
|
||||
run: npm run lint
|
||||
run: pnpm run lint
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
run: pnpm test
|
||||
|
||||
3230
package-lock.json
generated
3230
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "@signalapp/mock-server",
|
||||
"packageManager": "pnpm@10.18.1",
|
||||
"version": "18.3.0",
|
||||
"description": "Mock Signal Server for writing tests",
|
||||
"main": "src/index.js",
|
||||
@ -73,5 +74,10 @@
|
||||
"mocha": "^9.2.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.44.1"
|
||||
},
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"@types/ws": "patches/@types__ws.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
30
patches/@types__ws.patch
Normal file
30
patches/@types__ws.patch
Normal file
@ -0,0 +1,30 @@
|
||||
diff --git a/index.d.ts b/index.d.ts
|
||||
index 6d08adc155873e948d2ffebf40622fe405159bc0..4041a625f51d84d719c878244aad2f74bc9791d9 100644
|
||||
--- a/index.d.ts
|
||||
+++ b/index.d.ts
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Server as HTTPServer,
|
||||
} from "http";
|
||||
import { Server as HTTPSServer } from "https";
|
||||
+import { ServerHttp2Stream } from "http2";
|
||||
import { createConnection } from "net";
|
||||
import { Duplex, DuplexOptions } from "stream";
|
||||
import { SecureContextOptions } from "tls";
|
||||
@@ -76,7 +77,7 @@ declare class WebSocket extends EventEmitter {
|
||||
onclose: ((event: WebSocket.CloseEvent) => void) | null;
|
||||
onmessage: ((event: WebSocket.MessageEvent) => void) | null;
|
||||
|
||||
- constructor(address: null);
|
||||
+ constructor(address: null, protocols: undefined, options: WebSocket.ClientOptions | ClientRequestArgs);
|
||||
constructor(address: string | URL, options?: WebSocket.ClientOptions | ClientRequestArgs);
|
||||
constructor(
|
||||
address: string | URL,
|
||||
@@ -84,6 +85,8 @@ declare class WebSocket extends EventEmitter {
|
||||
options?: WebSocket.ClientOptions | ClientRequestArgs,
|
||||
);
|
||||
|
||||
+ setSocket(socket: ServerHttp2Stream, head: Buffer, options: WebSocket.ClientOptions | ClientRequestArgs): void;
|
||||
+
|
||||
close(code?: number, data?: string | Buffer): void;
|
||||
ping(data?: any, mask?: boolean, cb?: (err: Error) => void): void;
|
||||
pong(data?: any, mask?: boolean, cb?: (err: Error) => void): void;
|
||||
2214
pnpm-lock.yaml
generated
Normal file
2214
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -6,7 +6,12 @@ import fs from 'fs';
|
||||
import fsPromises from 'fs/promises';
|
||||
import { type Readable } from 'stream';
|
||||
import path from 'path';
|
||||
import https, { ServerOptions } from 'https';
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import http2, {
|
||||
SecureServerOptions,
|
||||
Http2ServerRequest,
|
||||
Http2ServerResponse,
|
||||
} from 'http2';
|
||||
import { parse as parseURL } from 'url';
|
||||
import { PrivateKey, PublicKey } from '@signalapp/libsignal-client';
|
||||
import {
|
||||
@ -77,7 +82,7 @@ type ZKParams = Readonly<{
|
||||
type StrictConfig = Readonly<{
|
||||
trustRoot: TrustRoot;
|
||||
zkParams: ZKParams;
|
||||
https: ServerOptions;
|
||||
https: SecureServerOptions;
|
||||
timeout: number;
|
||||
maxStorageReadKeys?: number;
|
||||
cdn3Path?: string;
|
||||
@ -87,7 +92,7 @@ type StrictConfig = Readonly<{
|
||||
export type Config = Readonly<{
|
||||
trustRoot?: TrustRoot;
|
||||
zkParams?: ZKParams;
|
||||
https?: ServerOptions;
|
||||
https?: SecureServerOptions;
|
||||
timeout?: number;
|
||||
maxStorageReadKeys?: number;
|
||||
cdn3Path?: string;
|
||||
@ -178,7 +183,12 @@ export class Server extends BaseServer {
|
||||
https: {
|
||||
key: KEY,
|
||||
cert: CERT,
|
||||
allowHTTP1: true,
|
||||
...(config.https ?? {}),
|
||||
settings: {
|
||||
...(config.https?.settings ?? {}),
|
||||
enableConnectProtocol: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@ -223,63 +233,32 @@ export class Server extends BaseServer {
|
||||
updates2Path: this.config.updates2Path,
|
||||
});
|
||||
|
||||
const server = https.createServer(this.config.https, (req, res) => {
|
||||
void run(req, res, httpHandler);
|
||||
});
|
||||
const server = http2
|
||||
.createSecureServer(this.config.https, (req, res) => {
|
||||
// micro is actually compatible with http2 requests, but the types are
|
||||
// not.
|
||||
void run(
|
||||
req as unknown as IncomingMessage,
|
||||
res as unknown as ServerResponse,
|
||||
httpHandler,
|
||||
);
|
||||
})
|
||||
.on('connect', (req: Http2ServerRequest, res: Http2ServerResponse) => {
|
||||
// WebSocket
|
||||
if (req.method === 'CONNECT') {
|
||||
res.writeHead(200, this.wsUpgradeResponseHeaders);
|
||||
|
||||
const wss = new WebSocket.Server({
|
||||
server,
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
verifyClient: async (info, callback) => {
|
||||
const { url } = info.req;
|
||||
assert(url, 'verifyClient: expected a URL on incoming request');
|
||||
const { query } = parseURL(url, true);
|
||||
const websocket = new WebSocket(null, undefined, {});
|
||||
websocket.setSocket(req.stream, Buffer.alloc(0), {});
|
||||
const conn = new WSConnection(req, websocket, this);
|
||||
|
||||
if (query.login == null && query.password == null) {
|
||||
debug('verifyClient: Allowing connection with no credentials');
|
||||
callback(true);
|
||||
conn.start().catch((error: unknown) => {
|
||||
websocket.close();
|
||||
debug('Websocket handling error', error);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Note: when a device has been unlinked, it will use '' as its password
|
||||
if (
|
||||
query.login == null ||
|
||||
Array.isArray(query.login) ||
|
||||
typeof query.password !== 'string' ||
|
||||
Array.isArray(query.password)
|
||||
) {
|
||||
debug('verifyClient: Malformed credentials @ %s: %j', url, query);
|
||||
callback(false, 403);
|
||||
return;
|
||||
}
|
||||
|
||||
const device = await this.auth(query.login, query.password);
|
||||
if (!device) {
|
||||
debug('verifyClient: Invalid credentials @ %s: %j', url, query);
|
||||
callback(false, 403);
|
||||
return;
|
||||
}
|
||||
|
||||
callback(true);
|
||||
},
|
||||
});
|
||||
|
||||
wss.on('connection', (ws, request) => {
|
||||
const conn = new WSConnection(request, ws, this);
|
||||
|
||||
conn.start().catch((error: unknown) => {
|
||||
ws.close();
|
||||
debug('Websocket handling error', error);
|
||||
});
|
||||
});
|
||||
|
||||
wss.on('headers', (headers) => {
|
||||
Object.entries(this.wsUpgradeResponseHeaders).forEach(
|
||||
([header, value]) => {
|
||||
headers.push(`${header}: ${value}`);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
this.https = server;
|
||||
|
||||
|
||||
@ -26,7 +26,7 @@ import {
|
||||
UuidCiphertext,
|
||||
} from '@signalapp/libsignal-client/zkgroup';
|
||||
import assert from 'assert';
|
||||
import https from 'https';
|
||||
import http2 from 'http2';
|
||||
import crypto from 'crypto';
|
||||
import createDebug from 'debug';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
@ -222,11 +222,7 @@ type StorageAuthEntry = Readonly<{
|
||||
device: Device;
|
||||
}>;
|
||||
|
||||
type MessageQueueEntry = {
|
||||
readonly message: Buffer<ArrayBuffer>;
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
};
|
||||
type MessageQueueEntry = (socket: WebSocket) => Promise<void>;
|
||||
|
||||
export type CallLinkEntry = Readonly<{
|
||||
adminPasskey: Buffer<ArrayBuffer>;
|
||||
@ -360,7 +356,7 @@ export abstract class Server {
|
||||
protected privZKSecret: ServerSecretParams | undefined;
|
||||
protected privGenericServerSecret: GenericServerSecretParams | undefined;
|
||||
protected privBackupServerSecret: GenericServerSecretParams | undefined;
|
||||
protected https: https.Server | undefined;
|
||||
protected https: http2.Http2SecureServer | undefined;
|
||||
|
||||
public address(): AddressInfo {
|
||||
if (!this.https) {
|
||||
@ -807,7 +803,8 @@ export abstract class Server {
|
||||
}
|
||||
sockets.add(socket);
|
||||
|
||||
await this.sendQueue(device, socket);
|
||||
// Don't wait for send to be over
|
||||
void this.sendQueue(device, socket);
|
||||
}
|
||||
|
||||
public removeWebSocket(device: Device, socket: WebSocket): void {
|
||||
@ -862,21 +859,13 @@ export abstract class Server {
|
||||
|
||||
debug('queueing message for device=%s', target.debugId);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
// NOTE: set and push have to happen in the same tick, otherwise a race
|
||||
// condition is possible in `removeWebSocket`.
|
||||
let queue = this.messageQueue.get(target);
|
||||
if (!queue) {
|
||||
queue = [];
|
||||
this.messageQueue.set(target, queue);
|
||||
}
|
||||
let queue = this.messageQueue.get(target);
|
||||
if (!queue) {
|
||||
queue = [];
|
||||
this.messageQueue.set(target, queue);
|
||||
}
|
||||
|
||||
queue.push({
|
||||
message,
|
||||
resolve,
|
||||
reject,
|
||||
});
|
||||
});
|
||||
queue.push((socket) => socket.sendMessage(message));
|
||||
|
||||
debug('queued message sent to device=%s', target.debugId);
|
||||
}
|
||||
@ -1756,24 +1745,13 @@ export abstract class Server {
|
||||
}
|
||||
|
||||
debug('sending queued %d messages to %s', queue.length, device.debugId);
|
||||
await Promise.all(
|
||||
queue.map(async (entry) => {
|
||||
const { message, resolve, reject } = entry;
|
||||
|
||||
try {
|
||||
await socket.sendMessage(message);
|
||||
} catch (error) {
|
||||
assert(error instanceof Error);
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve();
|
||||
}),
|
||||
);
|
||||
|
||||
debug('queue for %s is empty', device.debugId);
|
||||
await socket.sendMessage('empty');
|
||||
try {
|
||||
await Promise.all(
|
||||
queue.map((fn) => fn(socket)).concat(socket.sendMessage('empty')),
|
||||
);
|
||||
} catch {
|
||||
// Ignore errors, socket likely closed
|
||||
}
|
||||
}
|
||||
|
||||
private issueCredentials(
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
|
||||
import assert from 'assert';
|
||||
import { Buffer } from 'buffer';
|
||||
import { IncomingMessage } from 'http';
|
||||
import { Http2ServerRequest } from 'http2';
|
||||
import { timingSafeEqual } from 'crypto';
|
||||
import createDebug from 'debug';
|
||||
import {
|
||||
@ -64,10 +64,14 @@ const debug = createDebug('mock:ws:connection');
|
||||
|
||||
export class Connection extends Service {
|
||||
private device: Device | undefined;
|
||||
private readonly router = new Router();
|
||||
private readonly router = new Router({
|
||||
beforeRequest: (verb, path, headers) => {
|
||||
return this.handleAuth(verb, path, headers);
|
||||
},
|
||||
});
|
||||
|
||||
constructor(
|
||||
private readonly request: IncomingMessage,
|
||||
private readonly request: Http2ServerRequest,
|
||||
ws: WebSocket,
|
||||
private readonly server: Server,
|
||||
) {
|
||||
@ -1011,10 +1015,9 @@ export class Connection extends Service {
|
||||
}
|
||||
|
||||
if (path === '/v1/websocket/') {
|
||||
return this.handleNormal(this.request);
|
||||
} else {
|
||||
debug('websocket connection has unexpected URL %s', url);
|
||||
return;
|
||||
}
|
||||
debug('websocket connection has unexpected URL %s', url);
|
||||
}
|
||||
|
||||
public async sendMessage(
|
||||
@ -1067,8 +1070,17 @@ export class Connection extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleNormal(incomingMessage: IncomingMessage) {
|
||||
const authHeaders = incomingMessage.headers.authorization;
|
||||
private async handleAuth(
|
||||
verb: string,
|
||||
path: string,
|
||||
headers: Record<string, string>,
|
||||
) {
|
||||
// We are actively registering device
|
||||
if (verb === 'PUT' && path === '/v1/devices/link') {
|
||||
return;
|
||||
}
|
||||
|
||||
const authHeaders = headers.authorization;
|
||||
if (!authHeaders) {
|
||||
debug('Websocket connection does not include Authorization header');
|
||||
return;
|
||||
@ -1088,7 +1100,7 @@ export class Connection extends Service {
|
||||
|
||||
const device = await this.server.auth(username, password);
|
||||
if (!device) {
|
||||
debug('Invalid WebSocket credentials @ %s: %j', incomingMessage.url, {
|
||||
debug('Invalid WebSocket credentials @ %j', {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
@ -1096,6 +1108,11 @@ export class Connection extends Service {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.device !== undefined) {
|
||||
assert.strictEqual(this.device, device, 'Cannot change active device');
|
||||
return;
|
||||
}
|
||||
|
||||
this.device = device;
|
||||
this.router.setIsAuthenticated(true);
|
||||
|
||||
|
||||
@ -32,11 +32,21 @@ type Route = Readonly<{
|
||||
handler: Handler;
|
||||
}>;
|
||||
|
||||
export type RouterOptions = Readonly<{
|
||||
beforeRequest: (
|
||||
verb: string,
|
||||
path: string,
|
||||
headers: Record<string, string>,
|
||||
) => Promise<void>;
|
||||
}>;
|
||||
|
||||
export class Router {
|
||||
private readonly routes: Array<Route> = [];
|
||||
|
||||
private isAuthenticated = false;
|
||||
|
||||
constructor(private options: RouterOptions) {}
|
||||
|
||||
public register(method: string, pattern: string, handler: Handler): void {
|
||||
this.routes.push({
|
||||
method,
|
||||
@ -83,6 +93,12 @@ export class Router {
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
const { pathname, query } = parseURL(request.path ?? '');
|
||||
|
||||
await this.options.beforeRequest(
|
||||
request.verb ?? '',
|
||||
pathname ?? '',
|
||||
headers,
|
||||
);
|
||||
|
||||
for (const { method, pattern, handler } of this.routes) {
|
||||
if (method !== request.verb) {
|
||||
continue;
|
||||
|
||||
@ -35,7 +35,9 @@ export abstract class Service {
|
||||
debug('onMessage error', error.stack);
|
||||
}
|
||||
});
|
||||
this.ws.once('close', () => this.onClose());
|
||||
this.ws.once('close', () => {
|
||||
this.onClose();
|
||||
});
|
||||
}
|
||||
|
||||
public async send(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user