socketduct 0.1.15 → 0.1.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +3 -0
- package/changelog.d/20260916165820-fix-deferred-socket-callback-guard.md +1 -0
- package/changelog.d/20260924120000-wan-route-classes.md +1 -0
- package/docs/architecture.md +16 -3
- package/docs/protocol.md +17 -5
- package/docs/reverse-gateway.md +68 -37
- package/docs/security.md +13 -0
- package/docs/socket-api.md +50 -21
- package/package.json +1 -1
- package/src/client/deferred-virtual-socket.js +18 -6
- package/src/client/multiplexed-transport-set.js +105 -65
- package/src/config/environment.js +24 -1
- package/src/reverse/client.js +31 -17
- package/src/reverse/common.js +1 -0
- package/src/reverse/hub.js +163 -32
- package/src/reverse/runtime.js +31 -6
- package/test/e2e/reverse-gateway-process.test.js +93 -0
- package/test/integration/reverse-route-class.test.js +373 -0
- package/test/unit/client/deferred-virtual-socket.test.js +170 -0
- package/test/unit/client/multiplexed-transport-set.test.js +42 -0
- package/test/unit/config/docker-gateway-files.test.js +3 -1
- package/test/unit/config/environment.test.js +7 -1
package/src/reverse/hub.js
CHANGED
|
@@ -6,6 +6,7 @@ import {availableCodecs, negotiateCodec, zstdAvailable} from "../protocol/compre
|
|
|
6
6
|
import {decodeV3Data, encodeV3Data, AdaptiveCompressor} from "../protocol/v3-compression.js"
|
|
7
7
|
import {decodeV3Control, encodeV3Control, encodeV3Frame, V3FrameDecoder, V3FrameType} from "../protocol/v3-frame.js"
|
|
8
8
|
import {
|
|
9
|
+
asError,
|
|
9
10
|
DEFAULT_FRAME_BYTES,
|
|
10
11
|
DEFAULT_HANDSHAKE_MS,
|
|
11
12
|
DEFAULT_KEEP_ALIVE_INITIAL_DELAY_MS,
|
|
@@ -17,10 +18,13 @@ import {
|
|
|
17
18
|
parseTarget,
|
|
18
19
|
positiveInteger,
|
|
19
20
|
protocolError,
|
|
21
|
+
ROUTE_CLASS,
|
|
20
22
|
STABLE_IDENTIFIER,
|
|
21
23
|
writeSocket
|
|
22
24
|
} from "./common.js"
|
|
23
25
|
|
|
26
|
+
const DEFAULT_OUTPUT_BUFFER_BYTES = 32 * 1024 * 1024
|
|
27
|
+
|
|
24
28
|
/**
|
|
25
29
|
* @typedef {{userId: string, username: string}} Principal
|
|
26
30
|
* @typedef {{host: string, port: number}} Target
|
|
@@ -31,8 +35,11 @@ import {
|
|
|
31
35
|
* @property {{certFile: string, keyFile: string, clientCaFile?: string}} tls - Mandatory outer TLS server configuration.
|
|
32
36
|
* @property {(input: {principal: Principal, gatewayId: string}) => boolean} authorizeGateway - Stable gateway identity binding.
|
|
33
37
|
* @property {(input: {principal: Principal, gatewayId: string}) => boolean} authorizeClientGateway - Client-to-gateway selection policy.
|
|
38
|
+
* @property {(input: {principal: Principal, routeClass: string}) => boolean} [authorizeClientRouteClass] - Client-to-route-class selection policy.
|
|
34
39
|
* @property {(input: {principal: Principal, gatewayId: string, target: Target}) => boolean} authorizeOpen - Hub-side route policy.
|
|
40
|
+
* @property {(input: {principal: Principal, routeClass: string, target: Target}) => string[]} [resolveOpenGateways] - Eligible exact gateways for a route-class OPEN.
|
|
35
41
|
* @property {string[]} [knownGatewayIds] - Configured IDs that may be temporarily unavailable.
|
|
42
|
+
* @property {string[]} [knownRouteClasses] - Configured route classes available to authorized clients.
|
|
36
43
|
* @property {number} [initialWindowBytes] - Per-direction flow-control window.
|
|
37
44
|
* @property {number} [maxFrameBytes] - Maximum uncompressed DATA frame.
|
|
38
45
|
* @property {number} [maxStreams] - Process-wide bridge limit.
|
|
@@ -40,6 +47,7 @@ import {
|
|
|
40
47
|
* @property {number} [maxConnectionIdentities] - Retained stale-generation identity bound.
|
|
41
48
|
* @property {number} [handshakeTimeoutMs] - Authentication deadline.
|
|
42
49
|
* @property {number} [maxInputBytesPerConnection] - Pre-authentication byte bound.
|
|
50
|
+
* @property {number} [maxOutputBytesPerConnection] - Per-physical queued hub output bound.
|
|
43
51
|
* @property {import('../protocol/compression.js').CompressionLimits} [compressionLimits] - zstd safety bounds.
|
|
44
52
|
* @typedef {object} HubPhysical
|
|
45
53
|
* @property {import('node:tls').TLSSocket} socket
|
|
@@ -49,12 +57,19 @@ import {
|
|
|
49
57
|
* @property {number} generation
|
|
50
58
|
* @property {string | undefined} codec
|
|
51
59
|
* @property {string | undefined} gatewayId
|
|
60
|
+
* @property {string | undefined} routeClass
|
|
52
61
|
* @property {Map<number, Bridge>} streams
|
|
53
62
|
* @property {number} lastClientStreamId
|
|
54
63
|
* @property {number} nextGatewayStreamId
|
|
55
64
|
* @property {boolean} authenticated
|
|
56
65
|
* @property {boolean} closing
|
|
57
66
|
* @property {(() => void) | undefined} clearHandshake
|
|
67
|
+
* @property {HubOutputItem[]} outputQueue
|
|
68
|
+
* @property {number} outputBytes
|
|
69
|
+
* @property {boolean} outputWriting
|
|
70
|
+
* @typedef {object} HubOutputItem
|
|
71
|
+
* @property {Buffer} bytes
|
|
72
|
+
* @property {(delivered: boolean) => void} resolve
|
|
58
73
|
* @typedef {object} GatewayOwner
|
|
59
74
|
* @property {string} gatewayId
|
|
60
75
|
* @property {string} connectionId
|
|
@@ -92,6 +107,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
92
107
|
const maxStreams = positiveInteger(options.maxStreams, "maxStreams", DEFAULT_MAX_STREAMS)
|
|
93
108
|
const maxIdentities = positiveInteger(options.maxConnectionIdentities, "maxConnectionIdentities", 4096)
|
|
94
109
|
const maxPhysicalConnections = positiveInteger(options.maxPhysicalConnections, "maxPhysicalConnections", 8192)
|
|
110
|
+
const maxOutputBytes = positiveInteger(options.maxOutputBytesPerConnection, "maxOutputBytesPerConnection", DEFAULT_OUTPUT_BUFFER_BYTES)
|
|
95
111
|
/** @type {Map<string, GatewayOwner>} */
|
|
96
112
|
const gateways = new Map()
|
|
97
113
|
/** @type {Map<string, ClientOwner>} */
|
|
@@ -153,12 +169,16 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
153
169
|
generation: 0,
|
|
154
170
|
codec: undefined,
|
|
155
171
|
gatewayId: undefined,
|
|
172
|
+
routeClass: undefined,
|
|
156
173
|
streams: new Map(),
|
|
157
174
|
lastClientStreamId: 0,
|
|
158
175
|
nextGatewayStreamId: 2,
|
|
159
176
|
authenticated: false,
|
|
160
177
|
closing: false,
|
|
161
|
-
clearHandshake: undefined
|
|
178
|
+
clearHandshake: undefined,
|
|
179
|
+
outputQueue: [],
|
|
180
|
+
outputBytes: 0,
|
|
181
|
+
outputWriting: false
|
|
162
182
|
}
|
|
163
183
|
physicals.add(physical)
|
|
164
184
|
const decoder = new V3FrameDecoder({maxFrameBytes: Math.max(MAX_CONTROL_BYTES, maxFrameBytes + 4), maxBufferedBytes: Math.max(MAX_CONTROL_BYTES, maxFrameBytes + 4) * 2})
|
|
@@ -174,10 +194,9 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
174
194
|
if (!physical.authenticated && unauthenticatedBytes > (options.maxInputBytesPerConnection ?? DEFAULT_MAX_INPUT_BYTES)) throw protocolError("INPUT_LIMIT_EXCEEDED")
|
|
175
195
|
const frames = decoder.push(chunk)
|
|
176
196
|
for (const frame of frames) await handleFrame(physical, frame)
|
|
177
|
-
}).catch(
|
|
197
|
+
}).catch((error) => {
|
|
178
198
|
const code = typeof /** @type {{code?: unknown}} */ (error).code === "string" ? /** @type {{code: string}} */ (error).code : "PROTOCOL_VIOLATION"
|
|
179
|
-
|
|
180
|
-
socket.destroy()
|
|
199
|
+
terminate(physical, code)
|
|
181
200
|
}).finally(() => {
|
|
182
201
|
if (!socket.destroyed && !physical.closing) socket.resume()
|
|
183
202
|
})
|
|
@@ -237,9 +256,9 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
237
256
|
await registerGateway(physical, frame)
|
|
238
257
|
return
|
|
239
258
|
}
|
|
240
|
-
if (physical.role === "client" && physical.gatewayId === undefined) {
|
|
259
|
+
if (physical.role === "client" && physical.gatewayId === undefined && physical.routeClass === undefined) {
|
|
241
260
|
if (frame.type !== V3FrameType.GATEWAY_SELECT) throw protocolError("GATEWAY_SELECTION_REQUIRED")
|
|
242
|
-
await
|
|
261
|
+
await selectClientRoute(physical, frame)
|
|
243
262
|
return
|
|
244
263
|
}
|
|
245
264
|
if (frame.type === V3FrameType.GATEWAY_REGISTER || frame.type === V3FrameType.GATEWAY_REGISTERED || frame.type === V3FrameType.GATEWAY_SELECT || frame.type === V3FrameType.GATEWAY_SELECTED || frame.type === V3FrameType.AUTH || frame.type === V3FrameType.AUTH_OK || frame.type === V3FrameType.AUTH_ERROR || frame.type === V3FrameType.RESUME || frame.type === V3FrameType.RESUMED) throw protocolError("UNSUPPORTED_FRAME")
|
|
@@ -309,17 +328,28 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
309
328
|
}
|
|
310
329
|
|
|
311
330
|
/** @param {HubPhysical} physical - Authenticated client. @param {import('../protocol/v3-frame.js').V3Frame} frame - Selection frame. */
|
|
312
|
-
async function
|
|
331
|
+
async function selectClientRoute(physical, frame) {
|
|
313
332
|
if (frame.streamId !== 0 || frame.sequence !== physical.generation || frame.flags !== 0) throw protocolError("INVALID_GATEWAY_SELECTION")
|
|
314
333
|
const request = decodeV3Control(frame.payload)
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
334
|
+
const hasGatewayId = Object.hasOwn(request, "gatewayId")
|
|
335
|
+
const hasRouteClass = Object.hasOwn(request, "routeClass")
|
|
336
|
+
if (hasGatewayId === hasRouteClass || physical.principal === undefined) throw protocolError("INVALID_GATEWAY_SELECTION")
|
|
337
|
+
if (hasGatewayId) {
|
|
338
|
+
if (typeof request.gatewayId !== "string" || !GATEWAY_ID.test(request.gatewayId)) throw protocolError("INVALID_GATEWAY_SELECTION")
|
|
339
|
+
if (options.authorizeClientGateway({principal: physical.principal, gatewayId: request.gatewayId}) !== true) throw protocolError("GATEWAY_UNAUTHORIZED")
|
|
340
|
+
const owner = gateways.get(request.gatewayId)
|
|
341
|
+
const known = owner !== undefined || options.knownGatewayIds?.includes(request.gatewayId) === true
|
|
342
|
+
if (!known) throw protocolError("UNKNOWN_GATEWAY_ID")
|
|
343
|
+
if (owner?.physical === undefined) throw protocolError("GATEWAY_UNAVAILABLE")
|
|
344
|
+
physical.gatewayId = request.gatewayId
|
|
345
|
+
await sendControl(physical, V3FrameType.GATEWAY_SELECTED, 0, physical.generation, {gatewayId: request.gatewayId, generation: physical.generation})
|
|
346
|
+
return
|
|
347
|
+
}
|
|
348
|
+
if (typeof request.routeClass !== "string" || !ROUTE_CLASS.test(request.routeClass)) throw protocolError("INVALID_ROUTE_CLASS_SELECTION")
|
|
349
|
+
if (options.authorizeClientRouteClass?.({principal: physical.principal, routeClass: request.routeClass}) !== true) throw protocolError("ROUTE_CLASS_UNAUTHORIZED")
|
|
350
|
+
if (options.knownRouteClasses?.includes(request.routeClass) !== true) throw protocolError("UNKNOWN_ROUTE_CLASS")
|
|
351
|
+
physical.routeClass = request.routeClass
|
|
352
|
+
await sendControl(physical, V3FrameType.GATEWAY_SELECTED, 0, physical.generation, {routeClass: request.routeClass, generation: physical.generation})
|
|
323
353
|
}
|
|
324
354
|
|
|
325
355
|
/** @param {HubPhysical} client - Client physical. @param {import('../protocol/v3-frame.js').V3Frame} frame - OPEN. */
|
|
@@ -329,20 +359,51 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
329
359
|
client.lastClientStreamId = frame.streamId
|
|
330
360
|
const request = decodeV3Control(frame.payload)
|
|
331
361
|
const target = parseTarget(request.target)
|
|
332
|
-
if (target === undefined || typeof request.
|
|
362
|
+
if (target === undefined || typeof request.identifier !== "string" || !STABLE_IDENTIFIER.test(request.identifier) || typeof request.name !== "string" || request.name.length === 0 || request.name.length > 256 || client.principal === undefined) {
|
|
333
363
|
await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "INVALID_STREAM_OPEN"})
|
|
334
364
|
return
|
|
335
365
|
}
|
|
336
|
-
|
|
366
|
+
let gatewayId
|
|
367
|
+
if (client.routeClass !== undefined) {
|
|
368
|
+
if (request.routeClass !== client.routeClass || Object.hasOwn(request, "gatewayId")) {
|
|
369
|
+
await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "ROUTE_CLASS_MISMATCH"})
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
const candidates = options.resolveOpenGateways?.({principal: client.principal, routeClass: client.routeClass, target})
|
|
373
|
+
if (!Array.isArray(candidates) || candidates.some((candidate) => typeof candidate !== "string" || !GATEWAY_ID.test(candidate))) {
|
|
374
|
+
rejectedOpens += 1
|
|
375
|
+
await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "INVALID_ROUTE_RESOLUTION"})
|
|
376
|
+
return
|
|
377
|
+
}
|
|
378
|
+
if (candidates.length === 0) {
|
|
379
|
+
rejectedOpens += 1
|
|
380
|
+
await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "TARGET_UNAUTHORIZED"})
|
|
381
|
+
return
|
|
382
|
+
}
|
|
383
|
+
if (candidates.length !== 1 || new Set(candidates).size !== 1) {
|
|
384
|
+
rejectedOpens += 1
|
|
385
|
+
await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "AMBIGUOUS_ROUTE"})
|
|
386
|
+
return
|
|
387
|
+
}
|
|
388
|
+
gatewayId = /** @type {string} */ (candidates[0])
|
|
389
|
+
} else {
|
|
390
|
+
if (typeof request.gatewayId !== "string" || request.gatewayId !== client.gatewayId || !GATEWAY_ID.test(request.gatewayId) || Object.hasOwn(request, "routeClass")) {
|
|
391
|
+
await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "INVALID_STREAM_OPEN"})
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
gatewayId = request.gatewayId
|
|
395
|
+
}
|
|
396
|
+
if (options.authorizeOpen({principal: client.principal, gatewayId, target}) !== true) {
|
|
337
397
|
rejectedOpens += 1
|
|
338
398
|
await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "TARGET_UNAUTHORIZED"})
|
|
339
399
|
return
|
|
340
400
|
}
|
|
341
|
-
const gatewayOwner = gateways.get(
|
|
401
|
+
const gatewayOwner = gateways.get(gatewayId)
|
|
342
402
|
const gateway = gatewayOwner?.physical
|
|
343
|
-
if (gateway === undefined || gateway.socket.destroyed || gateway.gatewayId !==
|
|
403
|
+
if (gatewayOwner === undefined || gateway === undefined || gateway.socket.destroyed || gateway.role !== "gateway" || gateway.gatewayId !== gatewayId || gatewayOwner.gatewayId !== gatewayId || gatewayOwner.userId !== gateway.principal?.userId) {
|
|
344
404
|
rejectedOpens += 1
|
|
345
|
-
|
|
405
|
+
const known = gatewayOwner !== undefined || options.knownGatewayIds?.includes(gatewayId) === true
|
|
406
|
+
await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: known ? "GATEWAY_UNAVAILABLE" : "UNKNOWN_GATEWAY_ID"})
|
|
346
407
|
return
|
|
347
408
|
}
|
|
348
409
|
const gatewayStreamId = gateway.nextGatewayStreamId
|
|
@@ -372,7 +433,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
372
433
|
client.streams.set(frame.streamId, bridge)
|
|
373
434
|
gateway.streams.set(gatewayStreamId, bridge)
|
|
374
435
|
bridges += 1
|
|
375
|
-
|
|
436
|
+
forwardControl(gateway, V3FrameType.OPEN, gatewayStreamId, 0, {identifier: request.identifier, name: request.name, target, initialWindow})
|
|
376
437
|
}
|
|
377
438
|
|
|
378
439
|
/** @param {HubPhysical} source - Frame source. @param {import('../protocol/v3-frame.js').V3Frame} frame - Stream frame. */
|
|
@@ -390,7 +451,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
390
451
|
const detail = decodeV3Control(frame.payload)
|
|
391
452
|
if (detail.identifier !== bridge.identifier) throw protocolError("OPEN_IDENTIFIER_MISMATCH")
|
|
392
453
|
bridge.state = "open"
|
|
393
|
-
|
|
454
|
+
forwardControl(destination, V3FrameType.OPENED, destinationStreamId, 0, {identifier: bridge.identifier, initialWindow})
|
|
394
455
|
return
|
|
395
456
|
}
|
|
396
457
|
if (bridge.state === "opening" && frame.type !== V3FrameType.RESET) throw protocolError("STREAM_NOT_OPEN")
|
|
@@ -400,12 +461,12 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
400
461
|
if (!Number.isSafeInteger(frame.sequence) || frame.sequence < previous || frame.sequence > maximum) throw protocolError("INVALID_ACK")
|
|
401
462
|
if (fromClient) bridge.gatewayToClientAck = frame.sequence
|
|
402
463
|
else bridge.clientToGatewayAck = frame.sequence
|
|
403
|
-
if (bridge.state !== "closing")
|
|
464
|
+
if (bridge.state !== "closing") forwardControl(destination, V3FrameType.ACK, destinationStreamId, frame.sequence, {})
|
|
404
465
|
return
|
|
405
466
|
}
|
|
406
467
|
if (frame.type === V3FrameType.WINDOW) {
|
|
407
468
|
if (frame.sequence <= 0 || frame.sequence > initialWindow) throw protocolError("INVALID_WINDOW")
|
|
408
|
-
if (bridge.state !== "closing")
|
|
469
|
+
if (bridge.state !== "closing") forwardControl(destination, V3FrameType.WINDOW, destinationStreamId, frame.sequence, {})
|
|
409
470
|
return
|
|
410
471
|
}
|
|
411
472
|
if (bridge.state === "closing") {
|
|
@@ -426,8 +487,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
426
487
|
const encoded = await encodeV3Data(raw, destination.codec, fromClient ? bridge.clientToGatewayCompressor : bridge.gatewayToClientCompressor, options.compressionLimits)
|
|
427
488
|
rawDataBytes += encoded.rawBytes
|
|
428
489
|
wireDataBytes += encoded.wireBytes
|
|
429
|
-
|
|
430
|
-
if (!delivered) closeBridge(bridge, "DESTINATION_CLOSED", source)
|
|
490
|
+
forwardOutput(destination, encodeV3Frame({type: V3FrameType.DATA, streamId: destinationStreamId, sequence: frame.sequence, flags: encoded.flags}, encoded.payload))
|
|
431
491
|
return
|
|
432
492
|
}
|
|
433
493
|
if (frame.type === V3FrameType.END) {
|
|
@@ -436,14 +496,14 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
436
496
|
if (fromClient) bridge.clientEnded = true
|
|
437
497
|
else bridge.gatewayEnded = true
|
|
438
498
|
bridge.state = "half-closed"
|
|
439
|
-
|
|
499
|
+
forwardControl(destination, V3FrameType.END, destinationStreamId, frame.sequence, {})
|
|
440
500
|
return
|
|
441
501
|
}
|
|
442
502
|
if (frame.type === V3FrameType.CLOSE || frame.type === V3FrameType.RESET) {
|
|
443
503
|
const expected = fromClient ? bridge.clientToGatewaySequence : bridge.gatewayToClientSequence
|
|
444
504
|
if ((frame.type === V3FrameType.CLOSE && frame.sequence !== expected) || (frame.type === V3FrameType.RESET && frame.sequence !== 0)) throw protocolError("INVALID_STREAM_CLOSE")
|
|
445
505
|
const detail = frame.payload.length === 0 ? {} : decodeV3Control(frame.payload)
|
|
446
|
-
|
|
506
|
+
forwardControl(destination, frame.type, destinationStreamId, frame.sequence, detail)
|
|
447
507
|
if (frame.type === V3FrameType.RESET || fromClient) removeBridge(bridge)
|
|
448
508
|
else bridge.state = "closing"
|
|
449
509
|
return
|
|
@@ -458,6 +518,16 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
458
518
|
if (existed) bridges = Math.max(0, bridges - 1)
|
|
459
519
|
}
|
|
460
520
|
|
|
521
|
+
/** @param {HubPhysical} destination - Frame destination. @param {Buffer} bytes - Encoded frame. */
|
|
522
|
+
function forwardOutput(destination, bytes) {
|
|
523
|
+
void enqueueOutput(destination, bytes)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** @param {HubPhysical} destination - Frame destination. @param {number} type - Frame type. @param {number} streamId - Destination stream. @param {number} sequence - Frame sequence. @param {Record<string, unknown>} value - Control body. */
|
|
527
|
+
function forwardControl(destination, type, streamId, sequence, value) {
|
|
528
|
+
forwardOutput(destination, encodeV3Frame({type, streamId, sequence}, encodeV3Control(value)))
|
|
529
|
+
}
|
|
530
|
+
|
|
461
531
|
/** @param {Bridge} bridge - Stream. @param {string} code - Reset code. @param {HubPhysical} [excluded] - Already-dead peer. */
|
|
462
532
|
function closeBridge(bridge, code, excluded) {
|
|
463
533
|
if (bridge.client !== excluded && !bridge.client.socket.destroyed) void sendControl(bridge.client, V3FrameType.RESET, bridge.clientStreamId, 0, {code})
|
|
@@ -471,7 +541,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
471
541
|
physical.closing = true
|
|
472
542
|
const forceTimer = setTimeout(() => physical.socket.destroy(), 1_000)
|
|
473
543
|
forceTimer.unref?.()
|
|
474
|
-
void sendControl(physical, V3FrameType.GOAWAY, 0, 0, {code}).finally(() => {
|
|
544
|
+
void sendControl(physical, V3FrameType.GOAWAY, 0, 0, {code}, true).finally(() => {
|
|
475
545
|
clearTimeout(forceTimer)
|
|
476
546
|
physical.socket.destroy()
|
|
477
547
|
})
|
|
@@ -481,6 +551,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
481
551
|
function detachPhysical(physical, code) {
|
|
482
552
|
if (!physicals.delete(physical)) return
|
|
483
553
|
physical.closing = true
|
|
554
|
+
discardQueuedOutput(physical)
|
|
484
555
|
for (const bridge of [...physical.streams.values()]) closeBridge(bridge, physical.role === "gateway" ? "GATEWAY_DISCONNECTED" : code, physical)
|
|
485
556
|
if (physical.gatewayId !== undefined) {
|
|
486
557
|
const owner = gateways.get(physical.gatewayId)
|
|
@@ -497,9 +568,64 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
497
568
|
}
|
|
498
569
|
}
|
|
499
570
|
|
|
500
|
-
/** @param {HubPhysical} physical - Destination. @param {
|
|
501
|
-
|
|
502
|
-
|
|
571
|
+
/** @param {HubPhysical} physical - Destination. @param {Buffer} bytes - Encoded frame. @param {boolean} [allowClosing] - Allow a final GOAWAY after closing starts. @returns {Promise<boolean>} Delivery result. */
|
|
572
|
+
function enqueueOutput(physical, bytes, allowClosing = false) {
|
|
573
|
+
if (physical.socket.destroyed || (physical.closing && !allowClosing)) return Promise.resolve(false)
|
|
574
|
+
if (physical.outputBytes + bytes.length > maxOutputBytes) {
|
|
575
|
+
abortOutput(physical)
|
|
576
|
+
return Promise.resolve(false)
|
|
577
|
+
}
|
|
578
|
+
physical.outputBytes += bytes.length
|
|
579
|
+
const queued = new Promise((resolve) => physical.outputQueue.push({bytes, resolve}))
|
|
580
|
+
void drainOutput(physical)
|
|
581
|
+
return queued
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** @param {HubPhysical} physical - Destination output owner. */
|
|
585
|
+
async function drainOutput(physical) {
|
|
586
|
+
if (physical.outputWriting) return
|
|
587
|
+
physical.outputWriting = true
|
|
588
|
+
try {
|
|
589
|
+
while (physical.outputQueue.length > 0) {
|
|
590
|
+
const item = /** @type {HubOutputItem} */ (physical.outputQueue.shift())
|
|
591
|
+
let delivered = false
|
|
592
|
+
try {
|
|
593
|
+
delivered = await writeSocket(physical.socket, item.bytes)
|
|
594
|
+
} catch (error) {
|
|
595
|
+
physical.socket.destroy(asError(error))
|
|
596
|
+
}
|
|
597
|
+
physical.outputBytes = Math.max(0, physical.outputBytes - item.bytes.length)
|
|
598
|
+
item.resolve(delivered)
|
|
599
|
+
if (!delivered) {
|
|
600
|
+
discardQueuedOutput(physical)
|
|
601
|
+
abortOutput(physical, "DESTINATION_CLOSED")
|
|
602
|
+
break
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
} finally {
|
|
606
|
+
physical.outputWriting = false
|
|
607
|
+
if (physical.outputQueue.length > 0) void drainOutput(physical)
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** @param {HubPhysical} physical - Destination whose pending output is no longer deliverable. */
|
|
612
|
+
function discardQueuedOutput(physical) {
|
|
613
|
+
for (const item of physical.outputQueue.splice(0)) {
|
|
614
|
+
physical.outputBytes = Math.max(0, physical.outputBytes - item.bytes.length)
|
|
615
|
+
item.resolve(false)
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** @param {HubPhysical} physical - Destination whose output transport failed. @param {string} [code] - Terminal code. */
|
|
620
|
+
function abortOutput(physical, code = "OUTPUT_BACKPRESSURE_LIMIT") {
|
|
621
|
+
physical.closing = true
|
|
622
|
+
discardQueuedOutput(physical)
|
|
623
|
+
if (!physical.socket.destroyed) physical.socket.destroy(protocolError(code))
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** @param {HubPhysical} physical - Destination. @param {number} type - Frame type. @param {number} streamId - Stream ID. @param {number} sequence - Sequence. @param {Record<string, unknown>} value - Control body. @param {boolean} [allowClosing] - Allow a final GOAWAY. @returns {Promise<boolean>} Delivery result. */
|
|
627
|
+
function sendControl(physical, type, streamId, sequence, value, allowClosing = false) {
|
|
628
|
+
return enqueueOutput(physical, encodeV3Frame({type, streamId, sequence}, encodeV3Control(value)), allowClosing)
|
|
503
629
|
}
|
|
504
630
|
|
|
505
631
|
await new Promise((resolve, reject) => {
|
|
@@ -517,6 +643,8 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
517
643
|
physicalConnections: tcpSockets.size,
|
|
518
644
|
gatewayPhysicalConnections: [...physicals].filter((physical) => physical.role === "gateway" && physical.gatewayId !== undefined).length,
|
|
519
645
|
clientPhysicalConnections: [...physicals].filter((physical) => physical.role === "client" && physical.authenticated).length,
|
|
646
|
+
routeClassClientPhysicalConnections: [...physicals].filter((physical) => physical.role === "client" && physical.routeClass !== undefined).length,
|
|
647
|
+
queuedOutputBytes: [...physicals].reduce((total, physical) => total + physical.outputBytes, 0),
|
|
520
648
|
retainedClientIdentities: clients.size,
|
|
521
649
|
registeredGateways: gateways.size,
|
|
522
650
|
healthyGateways: [...gateways.values()].filter((owner) => owner.physical !== undefined).length,
|
|
@@ -557,6 +685,9 @@ function validateOptions(options) {
|
|
|
557
685
|
if (typeof options.tls !== "object" || options.tls === null || typeof options.tls.certFile !== "string" || typeof options.tls.keyFile !== "string") throw new TypeError("Reverse gateway hub requires TLS certFile and keyFile")
|
|
558
686
|
if (!zstdAvailable()) throw new Error("Reverse gateway hub requires Node.js zstd support")
|
|
559
687
|
if (options.knownGatewayIds !== undefined && (!Array.isArray(options.knownGatewayIds) || options.knownGatewayIds.some((gatewayId) => typeof gatewayId !== "string" || !GATEWAY_ID.test(gatewayId)) || new Set(options.knownGatewayIds).size !== options.knownGatewayIds.length)) throw new TypeError("knownGatewayIds must contain unique valid gateway IDs")
|
|
688
|
+
const routeClassPolicyConfigured = options.knownRouteClasses !== undefined || options.authorizeClientRouteClass !== undefined || options.resolveOpenGateways !== undefined
|
|
689
|
+
if (routeClassPolicyConfigured && (!Array.isArray(options.knownRouteClasses) || options.knownRouteClasses.length === 0 || typeof options.authorizeClientRouteClass !== "function" || typeof options.resolveOpenGateways !== "function")) throw new TypeError("Route-class hub policy requires knownRouteClasses, authorizeClientRouteClass, and resolveOpenGateways")
|
|
690
|
+
if (options.knownRouteClasses !== undefined && (options.knownRouteClasses.some((routeClass) => typeof routeClass !== "string" || !ROUTE_CLASS.test(routeClass)) || new Set(options.knownRouteClasses).size !== options.knownRouteClasses.length)) throw new TypeError("knownRouteClasses must contain unique valid route classes")
|
|
560
691
|
const frame = positiveInteger(options.maxFrameBytes, "maxFrameBytes", DEFAULT_FRAME_BYTES)
|
|
561
692
|
const window = positiveInteger(options.initialWindowBytes, "initialWindowBytes", DEFAULT_WINDOW)
|
|
562
693
|
if (frame > window) throw new RangeError("maxFrameBytes must not exceed initialWindowBytes")
|
package/src/reverse/runtime.js
CHANGED
|
@@ -9,13 +9,28 @@ export async function startReverseHubRuntime(config) {
|
|
|
9
9
|
if (config.relayTls === undefined) throw new Error("Reverse hub mode requires SOCKETDUCT_TLS_CERT_FILE and SOCKETDUCT_TLS_KEY_FILE")
|
|
10
10
|
if (config.authTokens.length === 0) throw new Error("SOCKETDUCT_AUTH_TOKENS must contain hub client and gateway credentials")
|
|
11
11
|
if (config.reverseGatewayBindings === undefined || config.reverseGatewayBindings.length === 0) throw new Error("SOCKETDUCT_REVERSE_GATEWAY_BINDINGS must bind at least one gateway ID to a credential username")
|
|
12
|
-
|
|
12
|
+
const exactRoutes = config.reverseRoutes ?? []
|
|
13
|
+
const routeClassRoutes = config.reverseRouteClassRoutes ?? []
|
|
14
|
+
if (exactRoutes.length === 0 && routeClassRoutes.length === 0) throw new Error("SOCKETDUCT_REVERSE_ROUTES or SOCKETDUCT_REVERSE_ROUTE_CLASS_ROUTES must allow at least one client route")
|
|
13
15
|
if (config.compression === "off") throw new Error("Reverse hub mode requires SOCKETDUCT_COMPRESSION=auto")
|
|
14
16
|
const bindings = new Map(config.reverseGatewayBindings.map((binding) => [binding.gatewayId, binding.username]))
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
+
const authorizedRoutes = new Set([...exactRoutes, ...routeClassRoutes].map((route) => formatRoute(route.username, route.gatewayId, {host: route.host, port: route.port})))
|
|
18
|
+
const exactRouteKeys = new Set(exactRoutes.map((route) => formatRoute(route.username, route.gatewayId, {host: route.host, port: route.port})))
|
|
19
|
+
const exactSelections = new Set(exactRoutes.map((route) => `${route.username}\0${route.gatewayId}`))
|
|
20
|
+
const routeClassSelections = new Set(routeClassRoutes.map((route) => `${route.username}\0${route.routeClass}`))
|
|
21
|
+
const knownRouteClasses = [...new Set(routeClassRoutes.map((route) => route.routeClass))]
|
|
22
|
+
/** @type {Map<string, string[]>} */
|
|
23
|
+
const routeClassGateways = new Map()
|
|
24
|
+
for (const route of routeClassRoutes) {
|
|
25
|
+
const key = formatRouteClassRoute(route.username, route.routeClass, {host: route.host, port: route.port})
|
|
26
|
+
const gatewayIds = routeClassGateways.get(key) ?? []
|
|
27
|
+
gatewayIds.push(route.gatewayId)
|
|
28
|
+
routeClassGateways.set(key, gatewayIds)
|
|
29
|
+
}
|
|
30
|
+
const routeClassRows = new Set(routeClassRoutes.map((route) => `${formatRouteClassRoute(route.username, route.routeClass, {host: route.host, port: route.port})}\0${route.gatewayId}`))
|
|
17
31
|
if (bindings.size !== config.reverseGatewayBindings.length) throw new Error("SOCKETDUCT_REVERSE_GATEWAY_BINDINGS contains a duplicate gateway ID")
|
|
18
|
-
if (
|
|
32
|
+
if (exactRouteKeys.size !== exactRoutes.length) throw new Error("SOCKETDUCT_REVERSE_ROUTES contains a duplicate route")
|
|
33
|
+
if (routeClassRows.size !== routeClassRoutes.length) throw new Error("SOCKETDUCT_REVERSE_ROUTE_CLASS_ROUTES contains a duplicate route")
|
|
19
34
|
const relayTls = config.relayTls
|
|
20
35
|
const tls = {
|
|
21
36
|
certFile: relayTls.certFile,
|
|
@@ -28,9 +43,14 @@ export async function startReverseHubRuntime(config) {
|
|
|
28
43
|
tls,
|
|
29
44
|
tokens: new StaticTokenStore(config.authTokens),
|
|
30
45
|
knownGatewayIds: [...bindings.keys()],
|
|
46
|
+
...(knownRouteClasses.length === 0 ? {} : {knownRouteClasses}),
|
|
31
47
|
authorizeGateway: ({principal, gatewayId}) => bindings.get(gatewayId) === principal.username,
|
|
32
|
-
authorizeClientGateway: ({principal, gatewayId}) =>
|
|
33
|
-
|
|
48
|
+
authorizeClientGateway: ({principal, gatewayId}) => exactSelections.has(`${principal.username}\0${gatewayId}`),
|
|
49
|
+
...(knownRouteClasses.length === 0 ? {} : {
|
|
50
|
+
authorizeClientRouteClass: ({principal, routeClass}) => routeClassSelections.has(`${principal.username}\0${routeClass}`),
|
|
51
|
+
resolveOpenGateways: ({principal, routeClass, target}) => [...(routeClassGateways.get(formatRouteClassRoute(principal.username, routeClass, target)) ?? [])]
|
|
52
|
+
}),
|
|
53
|
+
authorizeOpen: ({principal, gatewayId, target}) => authorizedRoutes.has(formatRoute(principal.username, gatewayId, target)),
|
|
34
54
|
handshakeTimeoutMs: config.handshakeTimeoutMs,
|
|
35
55
|
maxInputBytesPerConnection: config.maxInputBytesPerConnection,
|
|
36
56
|
compressionLimits: compressionLimits(config)
|
|
@@ -68,6 +88,11 @@ function formatRoute(username, gatewayId, target) {
|
|
|
68
88
|
return `${username}\0${gatewayId}\0${target.host}\0${target.port}`
|
|
69
89
|
}
|
|
70
90
|
|
|
91
|
+
/** @param {string} username - Client. @param {string} routeClass - Route class. @param {{host: string, port: number}} target - Target. */
|
|
92
|
+
function formatRouteClassRoute(username, routeClass, target) {
|
|
93
|
+
return `${username}\0${routeClass}\0${target.host}\0${target.port}`
|
|
94
|
+
}
|
|
95
|
+
|
|
71
96
|
/** @param {import('../config/environment.js').RelayEnvironmentConfig} config - Runtime limits. @returns {import('../protocol/compression.js').CompressionLimits} Defined codec limits. */
|
|
72
97
|
function compressionLimits(config) {
|
|
73
98
|
return {
|
|
@@ -6,6 +6,7 @@ import {createServer} from "node:net"
|
|
|
6
6
|
import {tmpdir} from "node:os"
|
|
7
7
|
import {join} from "node:path"
|
|
8
8
|
import test from "node:test"
|
|
9
|
+
import {connect as connectTls} from "node:tls"
|
|
9
10
|
|
|
10
11
|
import {ReverseGatewayConnection} from "../../src/reverse/client.js"
|
|
11
12
|
import {generateTestCertificates} from "../helpers/tls-certificates.js"
|
|
@@ -283,3 +284,95 @@ test("spawned reverse hub and outbound gateway expose an allowlisted Docker stre
|
|
|
283
284
|
assert(gatewayEntries.some(({event}) => event === "transport.connected"))
|
|
284
285
|
assert.doesNotMatch(gatewayContents, /process-gateway-token|127\.0\.0\.1|docker-server/)
|
|
285
286
|
})
|
|
287
|
+
|
|
288
|
+
test("one route-class client physical connection interleaves streams through different gateway processes", async (t) => {
|
|
289
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-route-class-process-"))
|
|
290
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
291
|
+
const certificates = generateTestCertificates(root)
|
|
292
|
+
const targetSockets = new Set()
|
|
293
|
+
const targetA = createServer((socket) => {
|
|
294
|
+
targetSockets.add(socket)
|
|
295
|
+
socket.once("close", () => targetSockets.delete(socket))
|
|
296
|
+
socket.on("data", (chunk) => socket.write(Buffer.concat([Buffer.from("a:"), chunk])))
|
|
297
|
+
})
|
|
298
|
+
const targetB = createServer((socket) => {
|
|
299
|
+
targetSockets.add(socket)
|
|
300
|
+
socket.once("close", () => targetSockets.delete(socket))
|
|
301
|
+
socket.on("data", (chunk) => socket.write(Buffer.concat([Buffer.from("b:"), chunk])))
|
|
302
|
+
})
|
|
303
|
+
const targetAPort = await listen(targetA)
|
|
304
|
+
const targetBPort = await listen(targetB)
|
|
305
|
+
t.after(() => {
|
|
306
|
+
for (const socket of targetSockets) socket.destroy()
|
|
307
|
+
return Promise.all([
|
|
308
|
+
new Promise((resolve) => targetA.close(() => resolve(undefined))),
|
|
309
|
+
new Promise((resolve) => targetB.close(() => resolve(undefined)))
|
|
310
|
+
])
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
const {child: hub, port: hubPort} = await startProcessOnReservedPort({
|
|
314
|
+
SOCKETDUCT_MODE: "reverse-hub",
|
|
315
|
+
SOCKETDUCT_RELAY_HOST: "127.0.0.1",
|
|
316
|
+
SOCKETDUCT_TLS_CERT_FILE: certificates.serverCertFile,
|
|
317
|
+
SOCKETDUCT_TLS_KEY_FILE: certificates.serverKeyFile,
|
|
318
|
+
SOCKETDUCT_AUTH_TOKENS: "home-a:gateway-a-token,home-b:gateway-b-token,tensorbuzz:client-token",
|
|
319
|
+
SOCKETDUCT_REVERSE_GATEWAY_BINDINGS: "server-a-starlink:home-a,server-b-starlink:home-b",
|
|
320
|
+
SOCKETDUCT_REVERSE_ROUTE_CLASS_ROUTES: `tensorbuzz:starlink:server-a-starlink:127.0.0.1:${targetAPort},tensorbuzz:starlink:server-b-starlink:127.0.0.1:${targetBPort}`,
|
|
321
|
+
SOCKETDUCT_STATUS_INTERVAL_MS: "0"
|
|
322
|
+
}, "reverse-hub.started")
|
|
323
|
+
t.after(() => stopProcess(hub))
|
|
324
|
+
|
|
325
|
+
const gatewayA = await startProcess({
|
|
326
|
+
SOCKETDUCT_MODE: "reverse-gateway",
|
|
327
|
+
SOCKETDUCT_REVERSE_GATEWAY_ID: "server-a-starlink",
|
|
328
|
+
SOCKETDUCT_REVERSE_CONNECTION_ID: "route_class_process_gateway_a",
|
|
329
|
+
SOCKETDUCT_REVERSE_HUB_HOST: "127.0.0.1",
|
|
330
|
+
SOCKETDUCT_REVERSE_HUB_PORT: String(hubPort),
|
|
331
|
+
SOCKETDUCT_REVERSE_TOKEN: "gateway-a-token",
|
|
332
|
+
SOCKETDUCT_REVERSE_HUB_CA_FILE: certificates.caCertFile,
|
|
333
|
+
SOCKETDUCT_REVERSE_HUB_SERVERNAME: "localhost",
|
|
334
|
+
SOCKETDUCT_TARGET_ALLOWLIST: `127.0.0.1:${targetAPort}`,
|
|
335
|
+
SOCKETDUCT_STATUS_INTERVAL_MS: "0"
|
|
336
|
+
}, "reverse-gateway.started")
|
|
337
|
+
t.after(() => stopProcess(gatewayA))
|
|
338
|
+
const gatewayB = await startProcess({
|
|
339
|
+
SOCKETDUCT_MODE: "reverse-gateway",
|
|
340
|
+
SOCKETDUCT_REVERSE_GATEWAY_ID: "server-b-starlink",
|
|
341
|
+
SOCKETDUCT_REVERSE_CONNECTION_ID: "route_class_process_gateway_b",
|
|
342
|
+
SOCKETDUCT_REVERSE_HUB_HOST: "127.0.0.1",
|
|
343
|
+
SOCKETDUCT_REVERSE_HUB_PORT: String(hubPort),
|
|
344
|
+
SOCKETDUCT_REVERSE_TOKEN: "gateway-b-token",
|
|
345
|
+
SOCKETDUCT_REVERSE_HUB_CA_FILE: certificates.caCertFile,
|
|
346
|
+
SOCKETDUCT_REVERSE_HUB_SERVERNAME: "localhost",
|
|
347
|
+
SOCKETDUCT_TARGET_ALLOWLIST: `127.0.0.1:${targetBPort}`,
|
|
348
|
+
SOCKETDUCT_STATUS_INTERVAL_MS: "0"
|
|
349
|
+
}, "reverse-gateway.started")
|
|
350
|
+
t.after(() => stopProcess(gatewayB))
|
|
351
|
+
|
|
352
|
+
let clientPhysicalConnections = 0
|
|
353
|
+
const client = new ReverseGatewayConnection({
|
|
354
|
+
routeClass: "starlink",
|
|
355
|
+
connectionId: "route_class_process_client",
|
|
356
|
+
hub: {host: "127.0.0.1", port: hubPort, token: "client-token"},
|
|
357
|
+
tls: {caFile: certificates.caCertFile, servername: "localhost"},
|
|
358
|
+
createPhysicalSocket: ({host, port, tls}) => {
|
|
359
|
+
clientPhysicalConnections += 1
|
|
360
|
+
return connectTls({host, port, ...tls})
|
|
361
|
+
}
|
|
362
|
+
})
|
|
363
|
+
t.after(() => client.close())
|
|
364
|
+
await withTimeout(client.connect(), "route-class process client connect")
|
|
365
|
+
const streamA = client.createConnection({host: "127.0.0.1", port: targetAPort, sessionName: "route-class-a"})
|
|
366
|
+
const streamB = client.createConnection({host: "127.0.0.1", port: targetBPort, sessionName: "route-class-b"})
|
|
367
|
+
await withTimeout(Promise.all([onceEvent(streamA, "connect"), onceEvent(streamB, "connect")]), "route-class process streams")
|
|
368
|
+
const responseA = new Promise((resolve, reject) => { streamA.once("error", reject); streamA.once("data", resolve) })
|
|
369
|
+
const responseB = new Promise((resolve, reject) => { streamB.once("error", reject); streamB.once("data", resolve) })
|
|
370
|
+
streamA.write("one")
|
|
371
|
+
streamB.write("two")
|
|
372
|
+
assert.equal(Buffer.from(/** @type {Uint8Array} */ (await withTimeout(responseA, "route-class response A"))).toString(), "a:one")
|
|
373
|
+
assert.equal(Buffer.from(/** @type {Uint8Array} */ (await withTimeout(responseB, "route-class response B"))).toString(), "b:two")
|
|
374
|
+
assert.equal(clientPhysicalConnections, 1)
|
|
375
|
+
assert.equal(client.status().physicalConnects, 1)
|
|
376
|
+
streamA.destroy()
|
|
377
|
+
streamB.destroy()
|
|
378
|
+
})
|