socketduct 0.1.18 → 0.1.20
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 +17 -5
- package/README.md +5 -5
- package/changelog.d/20260925143000-reverse-client-process-recovery.md +1 -0
- package/changelog.d/20260925170000-disconnect-attribution-compressed-logs.md +1 -0
- package/docs/architecture.md +11 -9
- package/docs/docker.md +5 -4
- package/docs/logging.md +131 -159
- package/docs/protocol.md +43 -7
- package/docs/reverse-gateway.md +75 -9
- package/docs/running.md +16 -13
- package/docs/security.md +16 -5
- package/docs/session-lifecycle.md +14 -3
- package/docs/socket-api.md +54 -20
- package/package.json +5 -1
- package/scripts/verify-zstd-interoperability.mjs +41 -0
- package/src/cli/socketduct.js +143 -0
- package/src/client/multiplexed-transport-set.js +12 -5
- package/src/client/virtual-socket.js +52 -0
- package/src/config/environment.js +56 -7
- package/src/index.js +64 -26
- package/src/logging/compressed-log-reader.js +256 -0
- package/src/logging/compressed-log-worker.js +430 -0
- package/src/logging/compressed-log-writer.js +538 -0
- package/src/logging/log-batch.js +72 -0
- package/src/logging/log-retention.js +241 -0
- package/src/logging/log-segment-index.js +176 -0
- package/src/logging/structured-logger.js +136 -30
- package/src/logging/zstd-frame-codec.js +73 -0
- package/src/reverse/client.js +372 -82
- package/src/reverse/disconnect-attribution.js +191 -0
- package/src/reverse/gateway.js +68 -18
- package/src/reverse/hub.js +215 -32
- package/src/reverse/runtime.js +5 -3
- package/src/spool/spool-file.js +136 -7
- package/tensorbuzz.yml +4 -2
- package/test/e2e/logger-shutdown-process.test.js +86 -0
- package/test/e2e/reverse-client-recovery-process.test.js +170 -0
- package/test/e2e/reverse-gateway-process.test.js +15 -14
- package/test/fixtures/reverse-client-recovery.mjs +123 -0
- package/test/integration/logging-cli.test.js +168 -0
- package/test/integration/logging-compressed-recovery.test.js +186 -0
- package/test/integration/reverse-gateway.test.js +597 -1
- package/test/integration/reverse-route-class.test.js +6 -1
- package/test/unit/client/multiplexed-transport-set.test.js +12 -1
- package/test/unit/config/docker-gateway-files.test.js +2 -0
- package/test/unit/config/environment.test.js +54 -14
- package/test/unit/index.test.js +3 -0
- package/test/unit/logging/compressed-log-worker.test.js +29 -0
- package/test/unit/logging/compressed-log-writer.test.js +183 -0
- package/test/unit/logging/log-retention.test.js +86 -0
- package/test/unit/logging/log-segment-index.test.js +104 -0
- package/test/unit/logging/structured-logger.test.js +14 -1
- package/test/unit/logging/zstd-frame-codec.test.js +24 -0
- package/test/unit/reverse/disconnect-attribution.test.js +82 -0
- package/test/unit/spool/spool-file.test.js +133 -0
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
/* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param, jsdoc/require-returns */
|
|
2
2
|
import assert from "node:assert/strict"
|
|
3
|
-
import {mkdtemp, readdir, rm} from "node:fs/promises"
|
|
3
|
+
import {mkdtemp, readFile, readdir, rm, stat, writeFile} from "node:fs/promises"
|
|
4
4
|
import {connect as connectTcp, createServer} from "node:net"
|
|
5
5
|
import {tmpdir} from "node:os"
|
|
6
6
|
import {join} from "node:path"
|
|
7
7
|
import {Duplex} from "node:stream"
|
|
8
8
|
import test from "node:test"
|
|
9
9
|
import {connect as connectTls} from "node:tls"
|
|
10
|
+
import zlib from "node:zlib"
|
|
10
11
|
|
|
11
12
|
import {StaticTokenStore} from "../../src/auth/static-token-store.js"
|
|
12
13
|
import {MultiplexedSocketductTransportSet} from "../../src/client/multiplexed-transport-set.js"
|
|
14
|
+
import {ZSTD_CODEC} from "../../src/protocol/compression.js"
|
|
15
|
+
import {encodeV3Data, AdaptiveCompressor} from "../../src/protocol/v3-compression.js"
|
|
13
16
|
import {encodeV3Control, encodeV3Frame, V3FrameDecoder, V3FrameType} from "../../src/protocol/v3-frame.js"
|
|
14
17
|
import {OutboundReverseGateway, ReverseGatewayConnection, startReverseGatewayHub} from "../../src/reverse/index.js"
|
|
18
|
+
import {SessionSpool} from "../../src/spool/spool-file.js"
|
|
15
19
|
import {generateTestCertificates} from "../helpers/tls-certificates.js"
|
|
16
20
|
|
|
17
21
|
/** @param {import('node:net').Server} server - Server. */
|
|
@@ -93,6 +97,44 @@ async function waitUntil(predicate, label) {
|
|
|
93
97
|
}
|
|
94
98
|
}
|
|
95
99
|
|
|
100
|
+
/**
|
|
101
|
+
* @param {string} spoolDirectory - Private persistent spool root.
|
|
102
|
+
* @param {Partial<Record<string, unknown>>} [overrides] - Recovery-state overrides.
|
|
103
|
+
* @param {Array<{sequence: number, data: Buffer}>} [chunks] - Durable client replay chunks.
|
|
104
|
+
*/
|
|
105
|
+
async function seedReverseClientRecovery(spoolDirectory, overrides = {}, chunks = []) {
|
|
106
|
+
/** @type {Record<string, unknown>} */
|
|
107
|
+
const state = {
|
|
108
|
+
version: 3,
|
|
109
|
+
role: "reverse-client",
|
|
110
|
+
connectionId: "recovered_client_01",
|
|
111
|
+
generation: 41,
|
|
112
|
+
gatewayId: "o2",
|
|
113
|
+
streamId: 1,
|
|
114
|
+
identifier: "recovered_stream_01",
|
|
115
|
+
name: "durable-reverse-stream",
|
|
116
|
+
target: {host: "docker-server", port: 2375},
|
|
117
|
+
state: "open",
|
|
118
|
+
opened: true,
|
|
119
|
+
openingSent: true,
|
|
120
|
+
sendSequence: chunks.at(-1)?.sequence ?? 0,
|
|
121
|
+
sendAck: 0,
|
|
122
|
+
receiveSequence: 0,
|
|
123
|
+
receiveAck: 0,
|
|
124
|
+
localEnded: false,
|
|
125
|
+
remoteEnded: false,
|
|
126
|
+
endSent: false,
|
|
127
|
+
...overrides
|
|
128
|
+
}
|
|
129
|
+
if (state.gatewayId === undefined) delete state.gatewayId
|
|
130
|
+
const identifier = /** @type {string} */ (state.identifier)
|
|
131
|
+
const spool = new SessionSpool({rootDir: spoolDirectory, maxBytes: 1024 * 1024, maxAgeMs: 60_000})
|
|
132
|
+
await spool.createNew({sessionId: identifier, ownerId: "reverse-client-v3"})
|
|
133
|
+
for (const chunk of chunks) await spool.appendChunk({sessionId: identifier, ownerId: "reverse-client-v3", ...chunk})
|
|
134
|
+
await spool.writeRecoveryState(identifier, state)
|
|
135
|
+
return state
|
|
136
|
+
}
|
|
137
|
+
|
|
96
138
|
/**
|
|
97
139
|
* @param {{connect(): Promise<void>}} connection - Terminal reverse connection.
|
|
98
140
|
* @param {string} label - Failure context.
|
|
@@ -265,6 +307,412 @@ test("reverse client contains a TLS AUTH write race inside the transport", async
|
|
|
265
307
|
await assert.rejects(connecting, /closed/i)
|
|
266
308
|
})
|
|
267
309
|
|
|
310
|
+
test("fatal hub GOAWAY preserves validated attribution on the public client error", async (t) => {
|
|
311
|
+
/** @type {AuthWriteSocket | undefined} */
|
|
312
|
+
let physical
|
|
313
|
+
const client = new ReverseGatewayConnection({
|
|
314
|
+
gatewayId: "o2",
|
|
315
|
+
connectionId: "fatal_goaway_attribution_client",
|
|
316
|
+
hub: {host: "127.0.0.1", port: 3100, token: "client-token"},
|
|
317
|
+
tls: {servername: "localhost"},
|
|
318
|
+
continuousReconnect: false,
|
|
319
|
+
createPhysicalSocket: () => {
|
|
320
|
+
physical = new AuthWriteSocket()
|
|
321
|
+
return /** @type {import('node:tls').TLSSocket} */ (/** @type {unknown} */ (physical))
|
|
322
|
+
}
|
|
323
|
+
})
|
|
324
|
+
t.after(() => client.close())
|
|
325
|
+
const connecting = client.connect()
|
|
326
|
+
physical?.emit("secureConnect")
|
|
327
|
+
physical?.emit("data", encodeV3Frame({type: V3FrameType.GOAWAY, streamId: 0, sequence: 0}, encodeV3Control({
|
|
328
|
+
code: "PROTOCOL_VIOLATION",
|
|
329
|
+
disconnectIncidentId: "AAAAAAAAAAAAAAAAAAAAAA",
|
|
330
|
+
disconnectReason: "protocol_violation",
|
|
331
|
+
incidentOrigin: "reverse_hub",
|
|
332
|
+
physicalRole: "client",
|
|
333
|
+
clientConnectionGeneration: 1,
|
|
334
|
+
fanOutCount: 0,
|
|
335
|
+
resumeSuccessCount: 0,
|
|
336
|
+
terminalStreamCount: 0
|
|
337
|
+
})))
|
|
338
|
+
/** @type {Error & Record<string, unknown> | undefined} */
|
|
339
|
+
let rejection
|
|
340
|
+
await assert.rejects(withTimeout(connecting, "fatal attributed GOAWAY"), (error) => {
|
|
341
|
+
rejection = /** @type {Error & Record<string, unknown>} */ (error)
|
|
342
|
+
return rejection.code === "PROTOCOL_VIOLATION"
|
|
343
|
+
})
|
|
344
|
+
assert.equal(rejection?.disconnectIncidentId, "AAAAAAAAAAAAAAAAAAAAAA")
|
|
345
|
+
assert.equal(rejection?.disconnectReason, "protocol_violation")
|
|
346
|
+
assert.equal(rejection?.incidentOrigin, "reverse_hub")
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
test("recovered reverse clients require explicit cold durable identity before any physical attempt", async (t) => {
|
|
350
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-recovery-contract-"))
|
|
351
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
352
|
+
const spoolDirectory = join(root, "client-spool")
|
|
353
|
+
await seedReverseClientRecovery(spoolDirectory, {sendAck: 2}, [
|
|
354
|
+
{sequence: 2, data: Buffer.from("aa")},
|
|
355
|
+
{sequence: 4, data: Buffer.from("bb")}
|
|
356
|
+
])
|
|
357
|
+
let physicalAttempts = 0
|
|
358
|
+
/** @type {Record<string, unknown>[]} */
|
|
359
|
+
const diagnostics = []
|
|
360
|
+
const base = {
|
|
361
|
+
gatewayId: "o2",
|
|
362
|
+
connectionId: "recovered_client_01",
|
|
363
|
+
hub: {host: "127.0.0.1", port: 3100, token: "client-token"},
|
|
364
|
+
tls: {servername: "localhost"},
|
|
365
|
+
spoolDirectory,
|
|
366
|
+
initialGeneration: 0,
|
|
367
|
+
logger: {log: (/** @type {string} */ event, /** @type {Record<string, unknown>} */ fields = {}) => diagnostics.push({event, ...fields})},
|
|
368
|
+
createPhysicalSocket: () => {
|
|
369
|
+
physicalAttempts += 1
|
|
370
|
+
return /** @type {import('node:tls').TLSSocket} */ (/** @type {unknown} */ (new AuthWriteSocket()))
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const client = new ReverseGatewayConnection(base)
|
|
375
|
+
t.after(() => client.close())
|
|
376
|
+
assert.throws(() => client.createConnection({recover: true, identifier: "recovered_stream_01", host: "docker-server", port: 2375}), /must use registerRecoveredConnection/i)
|
|
377
|
+
assert.throws(() => client.registerRecoveredConnection(/** @type {import('../../src/client/multiplexed-connection.js').VirtualConnectionOptions} */ ({identifier: "recovered_stream_01", host: "docker-server", port: 2375})), /recover: true/i)
|
|
378
|
+
const recovered = client.registerRecoveredConnection({recover: true, identifier: "recovered_stream_01", sessionName: "durable-reverse-stream", host: "docker-server", port: 2375})
|
|
379
|
+
assert.equal(recovered.streamId, 1)
|
|
380
|
+
assert.equal(client.status().physicalState, "configured")
|
|
381
|
+
assert.equal(client.status().connectionGeneration, 41)
|
|
382
|
+
assert.equal(client.status().spoolBytes, 2)
|
|
383
|
+
assert.equal(physicalAttempts, 0)
|
|
384
|
+
|
|
385
|
+
const recoveryContents = await readFile(join(spoolDirectory, "recovered_stream_01", "recovery.json"), "utf8")
|
|
386
|
+
assert.doesNotMatch(recoveryContents, /client-token/)
|
|
387
|
+
assert.doesNotMatch(JSON.stringify(diagnostics), /client-token/)
|
|
388
|
+
assert.equal((await stat(spoolDirectory)).mode & 0o777, 0o700)
|
|
389
|
+
for (const file of ["metadata.json", "recovery.json", "chunks.jsonl", "usage.json"]) {
|
|
390
|
+
assert.equal((await stat(join(spoolDirectory, "recovered_stream_01", file))).mode & 0o777, 0o600)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const {connectionId: _connectionId, ...withoutConnectionOptions} = base
|
|
394
|
+
void _connectionId
|
|
395
|
+
const withoutConnectionId = new ReverseGatewayConnection(withoutConnectionOptions)
|
|
396
|
+
assert.throws(() => withoutConnectionId.registerRecoveredConnection({recover: true, identifier: "recovered_stream_01", host: "docker-server", port: 2375}), /explicit stable connectionId/i)
|
|
397
|
+
await withoutConnectionId.close()
|
|
398
|
+
const {spoolDirectory: _spoolDirectory, ...withoutSpoolOptions} = base
|
|
399
|
+
void _spoolDirectory
|
|
400
|
+
const withoutSpoolDirectory = new ReverseGatewayConnection(withoutSpoolOptions)
|
|
401
|
+
assert.throws(() => withoutSpoolDirectory.registerRecoveredConnection({recover: true, identifier: "recovered_stream_01", host: "docker-server", port: 2375}), /explicit persistent spoolDirectory/i)
|
|
402
|
+
await withoutSpoolDirectory.close()
|
|
403
|
+
|
|
404
|
+
const routeSpoolDirectory = join(root, "route-spool")
|
|
405
|
+
await seedReverseClientRecovery(routeSpoolDirectory, {gatewayId: undefined, routeClass: "starlink", identifier: "recovered_route_stream", streamId: 3})
|
|
406
|
+
const {gatewayId: _gatewayId, ...routeBase} = base
|
|
407
|
+
void _gatewayId
|
|
408
|
+
const routeClient = new ReverseGatewayConnection({...routeBase, routeClass: "starlink", spoolDirectory: routeSpoolDirectory})
|
|
409
|
+
const recoveredRoute = routeClient.registerRecoveredConnection({recover: true, identifier: "recovered_route_stream", host: "docker-server", port: 2375})
|
|
410
|
+
assert.equal(recoveredRoute.streamId, 3)
|
|
411
|
+
assert.equal(physicalAttempts, 0)
|
|
412
|
+
await routeClient.close()
|
|
413
|
+
|
|
414
|
+
const lateRoot = join(root, "late-spool")
|
|
415
|
+
await seedReverseClientRecovery(lateRoot, {identifier: "recovered_stream_02", streamId: 3})
|
|
416
|
+
const late = new ReverseGatewayConnection({...base, spoolDirectory: lateRoot})
|
|
417
|
+
const connecting = late.connect()
|
|
418
|
+
void connecting.catch(() => {})
|
|
419
|
+
assert.throws(() => late.registerRecoveredConnection({recover: true, identifier: "recovered_stream_02", host: "docker-server", port: 2375}), /before.*physical connection/i)
|
|
420
|
+
await late.close()
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
test("recovered reverse state rejects missing corrupt stale mismatched and unbounded spools before network activity", async (t) => {
|
|
424
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-recovery-invalid-"))
|
|
425
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
426
|
+
let physicalAttempts = 0
|
|
427
|
+
let caseNumber = 0
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* @param {string} label - Filesystem-safe case label.
|
|
431
|
+
* @param {Partial<Record<string, unknown>>} [stateOverrides] - Invalid state mutation.
|
|
432
|
+
* @param {{chunks?: Array<{sequence: number, data: Buffer}>, maxStreamBufferBytes?: number, register?: Record<string, unknown>, mutate?: (input: {spoolDirectory: string, identifier: string, state: Record<string, unknown>}) => Promise<void>, maxAgeMs?: number}} [caseOptions] - Invalid spool shape.
|
|
433
|
+
*/
|
|
434
|
+
async function assertInvalidRecovery(label, stateOverrides = {}, caseOptions = {}) {
|
|
435
|
+
caseNumber += 1
|
|
436
|
+
const spoolDirectory = join(root, `${caseNumber}-${label}`)
|
|
437
|
+
const identifier = `invalid_stream_${String(caseNumber).padStart(2, "0")}`
|
|
438
|
+
const state = await seedReverseClientRecovery(spoolDirectory, {identifier, ...stateOverrides}, caseOptions.chunks)
|
|
439
|
+
await caseOptions.mutate?.({spoolDirectory, identifier, state})
|
|
440
|
+
const client = new ReverseGatewayConnection({
|
|
441
|
+
gatewayId: "o2",
|
|
442
|
+
connectionId: "recovered_client_01",
|
|
443
|
+
hub: {host: "127.0.0.1", port: 3100, token: "never-written-token"},
|
|
444
|
+
tls: {servername: "localhost"},
|
|
445
|
+
spoolDirectory,
|
|
446
|
+
initialGeneration: 0,
|
|
447
|
+
...(caseOptions.maxAgeMs === undefined ? {} : {maxAgeMs: caseOptions.maxAgeMs}),
|
|
448
|
+
...(caseOptions.maxStreamBufferBytes === undefined ? {} : {maxStreamBufferBytes: caseOptions.maxStreamBufferBytes}),
|
|
449
|
+
createPhysicalSocket: () => { physicalAttempts += 1; return /** @type {import('node:tls').TLSSocket} */ (/** @type {unknown} */ (new AuthWriteSocket())) }
|
|
450
|
+
})
|
|
451
|
+
const register = {recover: true, identifier, sessionName: "durable-reverse-stream", host: "docker-server", port: 2375, ...caseOptions.register}
|
|
452
|
+
assert.throws(() => client.registerRecoveredConnection(/** @type {import('../../src/client/multiplexed-connection.js').VirtualConnectionOptions} */ (register)), /Invalid|exceed|recovery/i, label)
|
|
453
|
+
assert.equal(client.status().virtualStreams, 0, label)
|
|
454
|
+
await client.close()
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
await assertInvalidRecovery("wrong-connection", {connectionId: "other_client_01"})
|
|
458
|
+
await assertInvalidRecovery("wrong-selector", {gatewayId: "other"})
|
|
459
|
+
await assertInvalidRecovery("ambiguous-selector", {routeClass: "starlink"})
|
|
460
|
+
await assertInvalidRecovery("wrong-role", {role: "reverse-gateway"})
|
|
461
|
+
await assertInvalidRecovery("wrong-stream", {streamId: 2})
|
|
462
|
+
await assertInvalidRecovery("zero-generation", {generation: 0})
|
|
463
|
+
await assertInvalidRecovery("exhausted-generation", {generation: Number.MAX_SAFE_INTEGER})
|
|
464
|
+
await assertInvalidRecovery("terminal-lifecycle", {state: "closed"})
|
|
465
|
+
await assertInvalidRecovery("inconsistent-half-close", {localEnded: true})
|
|
466
|
+
await assertInvalidRecovery("missing-open", {openingSent: false})
|
|
467
|
+
await assertInvalidRecovery("end-before-open", {state: "opening", opened: false, localEnded: true, endSent: true})
|
|
468
|
+
await assertInvalidRecovery("remote-end-before-open", {state: "opening", opened: false, remoteEnded: true})
|
|
469
|
+
await assertInvalidRecovery("wrong-target", {}, {register: {host: "other-target"}})
|
|
470
|
+
await assertInvalidRecovery("wrong-name", {}, {register: {sessionName: "other-name"}})
|
|
471
|
+
await assertInvalidRecovery("wrong-identifier", {}, {mutate: async ({spoolDirectory, identifier, state}) => {
|
|
472
|
+
await writeFile(join(spoolDirectory, identifier, "recovery.json"), `${JSON.stringify({...state, identifier: "different_stream_01"})}\n`)
|
|
473
|
+
}})
|
|
474
|
+
await assertInvalidRecovery("noncontiguous", {sendSequence: 2}, {chunks: [{sequence: 2, data: Buffer.from("x")}]} )
|
|
475
|
+
await assertInvalidRecovery("oversized", {}, {chunks: [{sequence: 8, data: Buffer.alloc(8)}], maxStreamBufferBytes: 4})
|
|
476
|
+
await assertInvalidRecovery("wrong-owner", {}, {mutate: async ({spoolDirectory, identifier}) => {
|
|
477
|
+
const metadataPath = join(spoolDirectory, identifier, "metadata.json")
|
|
478
|
+
const metadata = JSON.parse(await readFile(metadataPath, "utf8"))
|
|
479
|
+
await writeFile(metadataPath, `${JSON.stringify({...metadata, ownerId: "reverse-gateway-v3"})}\n`)
|
|
480
|
+
}})
|
|
481
|
+
await assertInvalidRecovery("corrupt", {}, {mutate: async ({spoolDirectory, identifier}) => {
|
|
482
|
+
await writeFile(join(spoolDirectory, identifier, "recovery.json"), "{not-json\n")
|
|
483
|
+
}})
|
|
484
|
+
await assertInvalidRecovery("expired", {}, {maxAgeMs: 1, mutate: async ({spoolDirectory, identifier}) => {
|
|
485
|
+
const metadataPath = join(spoolDirectory, identifier, "metadata.json")
|
|
486
|
+
const metadata = JSON.parse(await readFile(metadataPath, "utf8"))
|
|
487
|
+
await writeFile(metadataPath, `${JSON.stringify({...metadata, createdAt: "2000-01-01T00:00:00.000Z"})}\n`)
|
|
488
|
+
}})
|
|
489
|
+
|
|
490
|
+
const routeSpoolDirectory = join(root, "wrong-route-class")
|
|
491
|
+
await seedReverseClientRecovery(routeSpoolDirectory, {gatewayId: undefined, routeClass: "starlink", identifier: "wrong_route_stream"})
|
|
492
|
+
const wrongRoute = new ReverseGatewayConnection({routeClass: "zyxel", connectionId: "recovered_client_01", hub: {host: "127.0.0.1", port: 3100, token: "never-written-token"}, tls: {servername: "localhost"}, spoolDirectory: routeSpoolDirectory, createPhysicalSocket: () => { physicalAttempts += 1; return /** @type {import('node:tls').TLSSocket} */ (/** @type {unknown} */ (new AuthWriteSocket())) }})
|
|
493
|
+
assert.throws(() => wrongRoute.registerRecoveredConnection({recover: true, identifier: "wrong_route_stream", host: "docker-server", port: 2375}), /Invalid.*selector/i)
|
|
494
|
+
await wrongRoute.close()
|
|
495
|
+
|
|
496
|
+
const missing = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "recovered_client_01", hub: {host: "127.0.0.1", port: 3100, token: "never-written-token"}, tls: {servername: "localhost"}, spoolDirectory: join(root, "missing"), createPhysicalSocket: () => { physicalAttempts += 1; return /** @type {import('node:tls').TLSSocket} */ (/** @type {unknown} */ (new AuthWriteSocket())) }})
|
|
497
|
+
assert.throws(() => missing.registerRecoveredConnection({recover: true, identifier: "missing_stream_01", sessionName: "durable-reverse-stream", host: "docker-server", port: 2375}), /Invalid.*recovery/i)
|
|
498
|
+
await missing.close()
|
|
499
|
+
assert.equal(physicalAttempts, 0)
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
test("recovered reverse stream reports session loss after hub state loss without opening a target", async (t) => {
|
|
503
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-recovery-hub-loss-"))
|
|
504
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
505
|
+
const certificates = generateTestCertificates(root)
|
|
506
|
+
const spoolDirectory = join(root, "client-spool")
|
|
507
|
+
await seedReverseClientRecovery(spoolDirectory, {generation: 100})
|
|
508
|
+
const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
|
|
509
|
+
const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true})
|
|
510
|
+
t.after(() => hub.close())
|
|
511
|
+
const tls = {caFile: certificates.caCertFile, servername: "localhost"}
|
|
512
|
+
let targetCreates = 0
|
|
513
|
+
const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "hub_loss_gateway_01", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, authorizeTarget: () => true, createTargetConnection: () => {
|
|
514
|
+
targetCreates += 1
|
|
515
|
+
throw new Error("lost hub state must not recreate a target")
|
|
516
|
+
}})
|
|
517
|
+
t.after(() => gateway.close())
|
|
518
|
+
await gateway.start()
|
|
519
|
+
const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "recovered_client_01", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, spoolDirectory, initialGeneration: 0})
|
|
520
|
+
t.after(() => client.close())
|
|
521
|
+
const socket = client.registerRecoveredConnection({recover: true, identifier: "recovered_stream_01", sessionName: "durable-reverse-stream", host: "docker-server", port: 2375})
|
|
522
|
+
const lost = nextError(socket)
|
|
523
|
+
await client.connect()
|
|
524
|
+
assert.equal((await withTimeout(lost, "hub-state-loss recovery")).code, "RESUME_SESSION_LOST")
|
|
525
|
+
assert.equal(targetCreates, 0)
|
|
526
|
+
assert.equal(gateway.status().targetOpens, 0)
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
test("replacement reverse client restores a spooled stream without OPEN or a second target", async (t) => {
|
|
530
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-client-replacement-"))
|
|
531
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
532
|
+
const certificates = generateTestCertificates(root)
|
|
533
|
+
const spoolDirectory = join(root, "client-spool")
|
|
534
|
+
let targetAccepts = 0
|
|
535
|
+
const targetSockets = new Set()
|
|
536
|
+
const target = createServer((socket) => {
|
|
537
|
+
targetAccepts += 1
|
|
538
|
+
targetSockets.add(socket)
|
|
539
|
+
socket.once("close", () => targetSockets.delete(socket))
|
|
540
|
+
socket.on("data", (chunk) => socket.write(Buffer.concat([Buffer.from("target:"), chunk])))
|
|
541
|
+
})
|
|
542
|
+
const targetPort = await listen(target)
|
|
543
|
+
t.after(() => { for (const socket of targetSockets) socket.destroy(); return new Promise((resolve) => target.close(() => resolve(undefined))) })
|
|
544
|
+
const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
|
|
545
|
+
const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true, resumeWindowMs: 2_000})
|
|
546
|
+
t.after(() => hub.close())
|
|
547
|
+
const tls = {caFile: certificates.caCertFile, servername: "localhost"}
|
|
548
|
+
const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "recovered_gateway_01", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, authorizeTarget: () => true, reconnectDelayMs: 10, maxReconnectDelayMs: 20})
|
|
549
|
+
t.after(() => gateway.close())
|
|
550
|
+
await gateway.start()
|
|
551
|
+
|
|
552
|
+
const connectionOptions = {gatewayId: "o2", connectionId: "recovered_client_live", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, spoolDirectory, reconnectDelayMs: 10, maxReconnectDelayMs: 20}
|
|
553
|
+
const original = new ReverseGatewayConnection(connectionOptions)
|
|
554
|
+
await original.connect()
|
|
555
|
+
const originalSocket = original.createConnection({identifier: "recovered_live_stream", sessionName: "durable-docker-operation", host: "127.0.0.1", port: targetPort})
|
|
556
|
+
await onceEvent(originalSocket, "connect")
|
|
557
|
+
const initial = readBytes(originalSocket, 13)
|
|
558
|
+
originalSocket.write("before")
|
|
559
|
+
assert.equal((await withTimeout(initial, "initial reverse response")).toString(), "target:before")
|
|
560
|
+
assert.equal(targetAccepts, 1)
|
|
561
|
+
const oldGeneration = original.status().connectionGeneration
|
|
562
|
+
await original.detach()
|
|
563
|
+
const detachedState = JSON.parse(await readFile(join(spoolDirectory, "recovered_live_stream", "recovery.json"), "utf8"))
|
|
564
|
+
assert.equal(detachedState.receiveAck, 13, "detach must persist application-consumed target bytes")
|
|
565
|
+
|
|
566
|
+
/** @type {number[]} */
|
|
567
|
+
const outboundTypes = []
|
|
568
|
+
const replacement = new ReverseGatewayConnection({...connectionOptions, initialGeneration: 0, createPhysicalSocket: (options) => {
|
|
569
|
+
const physical = connectTls({host: options.host, port: options.port, ...options.tls})
|
|
570
|
+
const originalWrite = physical.write.bind(physical)
|
|
571
|
+
physical.write = /** @type {typeof physical.write} */ ((chunk, ...args) => {
|
|
572
|
+
if (Buffer.isBuffer(chunk) && chunk.length >= 6) outboundTypes.push(/** @type {number} */ (chunk[5]))
|
|
573
|
+
return Reflect.apply(originalWrite, physical, [chunk, ...args])
|
|
574
|
+
})
|
|
575
|
+
return physical
|
|
576
|
+
}})
|
|
577
|
+
t.after(() => replacement.close())
|
|
578
|
+
const recovered = replacement.registerRecoveredConnection({recover: true, identifier: "recovered_live_stream", sessionName: "durable-docker-operation", host: "127.0.0.1", port: targetPort})
|
|
579
|
+
const connected = onceEvent(recovered, "connect")
|
|
580
|
+
await replacement.connect()
|
|
581
|
+
await withTimeout(connected, "recovered reverse VirtualSocket")
|
|
582
|
+
assert(replacement.status().connectionGeneration > oldGeneration)
|
|
583
|
+
assert.equal(outboundTypes.includes(V3FrameType.OPEN), false)
|
|
584
|
+
assert.equal(targetAccepts, 1)
|
|
585
|
+
const resumed = readBytes(recovered, 12)
|
|
586
|
+
recovered.write("after")
|
|
587
|
+
assert.equal((await withTimeout(resumed, "recovered reverse response")).toString(), "target:after")
|
|
588
|
+
assert.equal(targetAccepts, 1)
|
|
589
|
+
const recoveredClosed = onceEvent(recovered, "close")
|
|
590
|
+
recovered.destroy()
|
|
591
|
+
await recoveredClosed
|
|
592
|
+
await replacement.close()
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
test("reverse client detach waits for queued asynchronous DATA and prevents late spool mutation", async (t) => {
|
|
596
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-client-detach-barrier-"))
|
|
597
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
598
|
+
const certificates = generateTestCertificates(root)
|
|
599
|
+
const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
|
|
600
|
+
const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true, resumeWindowMs: 2_000})
|
|
601
|
+
t.after(() => hub.close())
|
|
602
|
+
const tls = {caFile: certificates.caCertFile, servername: "localhost"}
|
|
603
|
+
/** @type {HeldWriteTarget[]} */
|
|
604
|
+
const targets = []
|
|
605
|
+
const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "detach_barrier_gateway", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, authorizeTarget: () => true, createTargetConnection: () => {
|
|
606
|
+
const target = new HeldWriteTarget()
|
|
607
|
+
targets.push(target)
|
|
608
|
+
return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (target))
|
|
609
|
+
}})
|
|
610
|
+
t.after(() => gateway.close())
|
|
611
|
+
t.after(() => { for (const target of targets) target.release() })
|
|
612
|
+
await gateway.start()
|
|
613
|
+
|
|
614
|
+
/** @type {import('node:tls').TLSSocket | undefined} */
|
|
615
|
+
let physical
|
|
616
|
+
const spoolDirectory = join(root, "client-spool")
|
|
617
|
+
const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "detach_barrier_client", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, spoolDirectory, createPhysicalSocket: (options) => {
|
|
618
|
+
physical = connectTls({host: options.host, port: options.port, ...options.tls})
|
|
619
|
+
return physical
|
|
620
|
+
}})
|
|
621
|
+
t.after(() => client.close())
|
|
622
|
+
await client.connect()
|
|
623
|
+
const socket = client.createConnection({identifier: "detach_barrier_stream", host: "held-target", port: 2375})
|
|
624
|
+
await onceEvent(socket, "connect")
|
|
625
|
+
assert(physical !== undefined)
|
|
626
|
+
|
|
627
|
+
const payload = Buffer.from("queued-after-detach-".repeat(64))
|
|
628
|
+
const encoded = await encodeV3Data(payload, ZSTD_CODEC, new AdaptiveCompressor())
|
|
629
|
+
assert.notEqual(encoded.flags, 0, "fixture DATA must cross the asynchronous decompression boundary")
|
|
630
|
+
const originalDecompress = zlib.zstdDecompress
|
|
631
|
+
assert.equal(typeof originalDecompress, "function")
|
|
632
|
+
/** @type {(() => void) | undefined} */
|
|
633
|
+
let releaseDecompression
|
|
634
|
+
/** @type {() => void} */
|
|
635
|
+
let markDecompressionStarted = () => {}
|
|
636
|
+
const decompressionStarted = new Promise((resolve) => { markDecompressionStarted = () => resolve(undefined) })
|
|
637
|
+
/** @type {() => void} */
|
|
638
|
+
let markDecompressionFinished = () => {}
|
|
639
|
+
const decompressionFinished = new Promise((resolve) => { markDecompressionFinished = () => resolve(undefined) })
|
|
640
|
+
zlib.zstdDecompress = /** @type {typeof zlib.zstdDecompress} */ ((input, options, callback) => {
|
|
641
|
+
markDecompressionStarted()
|
|
642
|
+
releaseDecompression = () => originalDecompress(input, options, (error, result) => {
|
|
643
|
+
callback(error, result)
|
|
644
|
+
markDecompressionFinished()
|
|
645
|
+
})
|
|
646
|
+
})
|
|
647
|
+
t.after(() => {
|
|
648
|
+
releaseDecompression?.()
|
|
649
|
+
zlib.zstdDecompress = originalDecompress
|
|
650
|
+
})
|
|
651
|
+
|
|
652
|
+
physical.emit("data", encodeV3Frame({type: V3FrameType.DATA, streamId: socket.streamId, sequence: payload.length, flags: encoded.flags}, encoded.payload))
|
|
653
|
+
await withTimeout(decompressionStarted, "queued reverse DATA decompression")
|
|
654
|
+
let detached = false
|
|
655
|
+
const detaching = client.detach().then(() => { detached = true })
|
|
656
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
657
|
+
const resolvedBeforeQueue = detached
|
|
658
|
+
releaseDecompression?.()
|
|
659
|
+
releaseDecompression = undefined
|
|
660
|
+
await withTimeout(decompressionFinished, "released reverse DATA decompression")
|
|
661
|
+
await withTimeout(detaching, "reverse client detach barrier")
|
|
662
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
663
|
+
|
|
664
|
+
assert.equal(resolvedBeforeQueue, false, "detach must not resolve while an old-generation frame handler is queued")
|
|
665
|
+
assert.equal(socket.readableLength, 0, "a queued old-generation frame must not deliver after detach starts")
|
|
666
|
+
const recovery = JSON.parse(await readFile(join(spoolDirectory, "detach_barrier_stream", "recovery.json"), "utf8"))
|
|
667
|
+
assert.equal(recovery.receiveSequence, 0)
|
|
668
|
+
assert.equal(recovery.receiveAck, 0)
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
test("replacement gateway submits an empty resume manifest and resets omitted streams once", async (t) => {
|
|
672
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-gateway-replacement-"))
|
|
673
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
674
|
+
const certificates = generateTestCertificates(root)
|
|
675
|
+
let targetAccepts = 0
|
|
676
|
+
const targetSockets = new Set()
|
|
677
|
+
const target = createServer((socket) => { targetAccepts += 1; targetSockets.add(socket); socket.once("close", () => targetSockets.delete(socket)) })
|
|
678
|
+
const targetPort = await listen(target)
|
|
679
|
+
t.after(() => { for (const socket of targetSockets) socket.destroy(); return new Promise((resolve) => target.close(() => resolve(undefined))) })
|
|
680
|
+
const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
|
|
681
|
+
const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true, resumeWindowMs: 2_000})
|
|
682
|
+
t.after(() => hub.close())
|
|
683
|
+
const tls = {caFile: certificates.caCertFile, servername: "localhost"}
|
|
684
|
+
const gatewayOptions = {gatewayId: "o2", connectionId: "replacement_gateway_01", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, authorizeTarget: () => true}
|
|
685
|
+
const originalGateway = new OutboundReverseGateway({...gatewayOptions, reconnectDelayMs: 10_000, maxReconnectDelayMs: 10_000})
|
|
686
|
+
await originalGateway.start()
|
|
687
|
+
const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "replacement_gateway_client", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, reconnectDelayMs: 10, maxReconnectDelayMs: 20})
|
|
688
|
+
t.after(() => client.close())
|
|
689
|
+
await client.connect()
|
|
690
|
+
const socket = client.createConnection({identifier: "replacement_stream_01", host: "127.0.0.1", port: targetPort})
|
|
691
|
+
let resets = 0
|
|
692
|
+
const reset = new Promise((resolve) => socket.on("error", (error) => { resets += 1; resolve(error) }))
|
|
693
|
+
await onceEvent(socket, "connect")
|
|
694
|
+
assert.equal(targetAccepts, 1)
|
|
695
|
+
const generation = originalGateway.status().connectionGeneration
|
|
696
|
+
await hub.closeActiveGateway("o2")
|
|
697
|
+
await originalGateway.close()
|
|
698
|
+
|
|
699
|
+
let replacementTargetCreates = 0
|
|
700
|
+
const replacement = new OutboundReverseGateway({...gatewayOptions, initialGeneration: generation, createTargetConnection: () => {
|
|
701
|
+
replacementTargetCreates += 1
|
|
702
|
+
throw new Error("replacement gateway must not recreate a retained target")
|
|
703
|
+
}})
|
|
704
|
+
t.after(() => replacement.close())
|
|
705
|
+
await replacement.start()
|
|
706
|
+
const error = /** @type {Error & {code?: string}} */ (await withTimeout(reset, "omitted replacement stream reset"))
|
|
707
|
+
assert.equal(error.code, "RESUME_SESSION_LOST")
|
|
708
|
+
await waitUntil(() => hub.status().virtualStreams === 0, "omitted bridge cleanup")
|
|
709
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
710
|
+
assert.equal(resets, 1)
|
|
711
|
+
assert.equal(replacementTargetCreates, 0)
|
|
712
|
+
assert.equal(replacement.status().targetOpens, 0)
|
|
713
|
+
assert.equal(targetAccepts, 1)
|
|
714
|
+
})
|
|
715
|
+
|
|
268
716
|
class DataThenErrorTarget extends Duplex {
|
|
269
717
|
/** @param {Buffer} payload - Bytes observed before the terminal target error. */
|
|
270
718
|
constructor(payload) {
|
|
@@ -1111,6 +1559,17 @@ test("reverse hub rejects bad credentials, unknown gateways, duplicate owners, a
|
|
|
1111
1559
|
await replacement.start()
|
|
1112
1560
|
await waitUntil(() => primary.status().physicalState === "closed", "newer generation fences prior owner")
|
|
1113
1561
|
assert.equal(hub.status().healthyGateways, 1)
|
|
1562
|
+
|
|
1563
|
+
const primaryClient = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "stable_client_owner", initialGeneration: 100, hub: {host: "127.0.0.1", port: hub.port, token: "client-valid-token"}, tls})
|
|
1564
|
+
t.after(() => primaryClient.close())
|
|
1565
|
+
await primaryClient.connect()
|
|
1566
|
+
const equalClient = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "stable_client_owner", initialGeneration: 100, hub: {host: "127.0.0.1", port: hub.port, token: "client-valid-token"}, tls, continuousReconnect: false})
|
|
1567
|
+
t.after(() => equalClient.close())
|
|
1568
|
+
await assert.rejects(withTimeout(equalClient.connect(), "equal client generation"), (error) => /** @type {{code?: string}} */ (error).code === "STALE_GENERATION")
|
|
1569
|
+
const newerClient = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "stable_client_owner", initialGeneration: 200, hub: {host: "127.0.0.1", port: hub.port, token: "client-valid-token"}, tls})
|
|
1570
|
+
t.after(() => newerClient.close())
|
|
1571
|
+
await newerClient.connect()
|
|
1572
|
+
await waitUntil(() => primaryClient.status().physicalState === "closed", "newer client generation fences prior claimant")
|
|
1114
1573
|
})
|
|
1115
1574
|
|
|
1116
1575
|
test("hub and gateway both enforce target policy without opening a proxy", async (t) => {
|
|
@@ -1748,6 +2207,143 @@ test("an expired reverse resume window fails the logical stream once and release
|
|
|
1748
2207
|
assert.equal(targets[0]?.destroyed, true)
|
|
1749
2208
|
})
|
|
1750
2209
|
|
|
2210
|
+
test("one gateway disconnect incident preserves physical cause across fan-out and terminal resume errors", async (t) => {
|
|
2211
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-attribution-"))
|
|
2212
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
2213
|
+
const certificates = generateTestCertificates(root)
|
|
2214
|
+
const tokens = new StaticTokenStore([{username: "home", token: "gateway-attribution-token"}, {username: "app", token: "client-attribution-token"}])
|
|
2215
|
+
/** @type {Array<Record<string, unknown> & {event: string}>} */
|
|
2216
|
+
const hubEvents = []
|
|
2217
|
+
/** @type {Array<Record<string, unknown> & {event: string}>} */
|
|
2218
|
+
const gatewayEvents = []
|
|
2219
|
+
/** @type {Array<Record<string, unknown> & {event: string}>} */
|
|
2220
|
+
const clientEvents = []
|
|
2221
|
+
const hub = await startReverseGatewayHub({
|
|
2222
|
+
tokens,
|
|
2223
|
+
tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
|
|
2224
|
+
authorizeGateway: () => true,
|
|
2225
|
+
authorizeClientGateway: () => true,
|
|
2226
|
+
authorizeOpen: () => true,
|
|
2227
|
+
resumeWindowMs: 40,
|
|
2228
|
+
logger: {log: (/** @type {string} */ event, /** @type {Record<string, unknown>} */ fields = {}) => { hubEvents.push({event, ...fields}) }},
|
|
2229
|
+
connectionFingerprint: {key: Buffer.alloc(32, 0x5a), keyEpoch: "test-epoch"}
|
|
2230
|
+
})
|
|
2231
|
+
t.after(() => hub.close())
|
|
2232
|
+
const tls = {caFile: certificates.caCertFile, servername: "localhost"}
|
|
2233
|
+
const targets = []
|
|
2234
|
+
const gateway = new OutboundReverseGateway({
|
|
2235
|
+
gatewayId: "o2",
|
|
2236
|
+
connectionId: "low_entropy_gateway_connection",
|
|
2237
|
+
hub: {host: "127.0.0.1", port: hub.port, token: "gateway-attribution-token"},
|
|
2238
|
+
tls,
|
|
2239
|
+
authorizeTarget: () => true,
|
|
2240
|
+
reconnectDelayMs: 150,
|
|
2241
|
+
maxReconnectDelayMs: 150,
|
|
2242
|
+
logger: {log: (/** @type {string} */ event, /** @type {Record<string, unknown>} */ fields = {}) => { gatewayEvents.push({event, ...fields}) }},
|
|
2243
|
+
createTargetConnection: () => {
|
|
2244
|
+
const target = new InterruptibleTarget()
|
|
2245
|
+
targets.push(target)
|
|
2246
|
+
return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (target))
|
|
2247
|
+
}
|
|
2248
|
+
})
|
|
2249
|
+
t.after(() => gateway.close())
|
|
2250
|
+
await gateway.start()
|
|
2251
|
+
const client = new ReverseGatewayConnection({
|
|
2252
|
+
gatewayId: "o2",
|
|
2253
|
+
connectionId: "low_entropy_client_connection",
|
|
2254
|
+
hub: {host: "127.0.0.1", port: hub.port, token: "client-attribution-token"},
|
|
2255
|
+
tls,
|
|
2256
|
+
reconnectDelayMs: 5,
|
|
2257
|
+
maxReconnectDelayMs: 10,
|
|
2258
|
+
logger: {log: (/** @type {string} */ event, /** @type {Record<string, unknown>} */ fields = {}) => { clientEvents.push({event, ...fields}) }}
|
|
2259
|
+
})
|
|
2260
|
+
t.after(() => client.close())
|
|
2261
|
+
await client.connect()
|
|
2262
|
+
const sockets = [client.createConnection({host: "target", port: 2375}), client.createConnection({host: "target", port: 2375})]
|
|
2263
|
+
await Promise.all(sockets.map((socket) => onceEvent(socket, "connect")))
|
|
2264
|
+
const terminalErrors = sockets.map((socket) => nextError(socket))
|
|
2265
|
+
|
|
2266
|
+
await hub.closeActiveGateway("o2")
|
|
2267
|
+
const errors = /** @type {Array<Error & Record<string, unknown>>} */ (await Promise.all(terminalErrors))
|
|
2268
|
+
assert.deepEqual(errors.map((error) => error.code), ["RESUME_WINDOW_EXPIRED", "RESUME_WINDOW_EXPIRED"])
|
|
2269
|
+
assert.equal(new Set(errors.map((error) => error.disconnectIncidentId)).size, 1)
|
|
2270
|
+
for (const error of errors) {
|
|
2271
|
+
assert.match(/** @type {string} */ (error.disconnectIncidentId), /^[A-Za-z0-9_-]{22}$/)
|
|
2272
|
+
assert.equal(error.disconnectReason, "unknown_transport_close")
|
|
2273
|
+
assert.equal(error.incidentOrigin, "reverse_hub")
|
|
2274
|
+
assert.equal(error.physicalRole, "gateway")
|
|
2275
|
+
assert.equal(typeof error.clientConnectionGeneration, "number")
|
|
2276
|
+
assert.equal(typeof error.gatewayConnectionGeneration, "number")
|
|
2277
|
+
assert.equal(error.fanOutCount, 2)
|
|
2278
|
+
}
|
|
2279
|
+
const detached = hubEvents.find(({event, physicalRole}) => event === "reverse_hub.physical_detached" && physicalRole === "gateway")
|
|
2280
|
+
assert(detached !== undefined)
|
|
2281
|
+
assert.equal(detached.disconnectIncidentId, /** @type {Error & Record<string, unknown>} */ (errors[0]).disconnectIncidentId)
|
|
2282
|
+
assert.match(/** @type {string} */ (detached.connectionFingerprint), /^[a-f0-9]{32}$/)
|
|
2283
|
+
assert.equal(detached.fingerprintKeyEpoch, "test-epoch")
|
|
2284
|
+
assert(hubEvents.some(({event, fanOutCount}) => event === "reverse_hub.streams_interrupted" && fanOutCount === 2))
|
|
2285
|
+
assert(hubEvents.some(({event, terminalStreamCount}) => event === "reverse_hub.streams_reset" && terminalStreamCount === 2))
|
|
2286
|
+
assert(clientEvents.some(({event}) => event === "reverse_client.physical_detached"))
|
|
2287
|
+
assert(gatewayEvents.some(({event}) => event === "reverse_gateway.physical_detached"))
|
|
2288
|
+
assert.equal(targets.length, 2)
|
|
2289
|
+
assert.doesNotMatch(JSON.stringify([hubEvents, gatewayEvents, clientEvents]), /low_entropy_(?:gateway|client)_connection|attribution-token/)
|
|
2290
|
+
})
|
|
2291
|
+
|
|
2292
|
+
test("peer GOAWAY classification and hub attribution reach terminal non-resumable RESET", async (t) => {
|
|
2293
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-terminal-attribution-"))
|
|
2294
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
2295
|
+
const certificates = generateTestCertificates(root)
|
|
2296
|
+
const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
|
|
2297
|
+
/** @type {Array<Record<string, unknown> & {event: string}>} */
|
|
2298
|
+
const hubEvents = []
|
|
2299
|
+
const hub = await startReverseGatewayHub({
|
|
2300
|
+
tokens,
|
|
2301
|
+
tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
|
|
2302
|
+
authorizeGateway: () => true,
|
|
2303
|
+
authorizeClientGateway: () => true,
|
|
2304
|
+
authorizeOpen: () => true,
|
|
2305
|
+
logger: {log: (/** @type {string} */ event, /** @type {Record<string, unknown>} */ fields = {}) => { hubEvents.push({event, ...fields}) }}
|
|
2306
|
+
})
|
|
2307
|
+
t.after(() => hub.close())
|
|
2308
|
+
const tls = {caFile: certificates.caCertFile, servername: "localhost"}
|
|
2309
|
+
const gateway = new OutboundReverseGateway({
|
|
2310
|
+
gatewayId: "o2",
|
|
2311
|
+
connectionId: "terminal_attribution_gateway",
|
|
2312
|
+
hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"},
|
|
2313
|
+
tls,
|
|
2314
|
+
authorizeTarget: () => true,
|
|
2315
|
+
createTargetConnection: () => /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (new InterruptibleTarget())),
|
|
2316
|
+
createPhysicalSocket: (options) => {
|
|
2317
|
+
const physical = connectTls({host: options.host, port: options.port, ...options.tls})
|
|
2318
|
+
const originalWrite = physical.write.bind(physical)
|
|
2319
|
+
physical.write = /** @type {typeof physical.write} */ ((chunk, ...args) => {
|
|
2320
|
+
if (!Buffer.isBuffer(chunk) || chunk[5] !== V3FrameType.GOAWAY) return Reflect.apply(originalWrite, physical, [chunk, ...args])
|
|
2321
|
+
const safeGoaway = encodeV3Frame({type: V3FrameType.GOAWAY, streamId: 0, sequence: 0}, encodeV3Control({code: "GATEWAY_MAINTENANCE", disconnectIncidentId: "BBBBBBBBBBBBBBBBBBBBBB"}))
|
|
2322
|
+
return Reflect.apply(originalWrite, physical, [safeGoaway, ...args])
|
|
2323
|
+
})
|
|
2324
|
+
return physical
|
|
2325
|
+
}
|
|
2326
|
+
})
|
|
2327
|
+
t.after(() => gateway.close())
|
|
2328
|
+
await gateway.start()
|
|
2329
|
+
const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "terminal_attribution_client", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, continuousReconnect: false})
|
|
2330
|
+
t.after(() => client.close())
|
|
2331
|
+
await client.connect()
|
|
2332
|
+
const socket = client.createConnection({host: "target", port: 2375})
|
|
2333
|
+
await onceEvent(socket, "connect")
|
|
2334
|
+
const terminal = nextError(socket)
|
|
2335
|
+
await gateway.close()
|
|
2336
|
+
const error = /** @type {Error & Record<string, unknown>} */ (await withTimeout(terminal, "terminal peer GOAWAY reset"))
|
|
2337
|
+
assert.equal(error.code, "GATEWAY_MAINTENANCE")
|
|
2338
|
+
assert.equal(error.disconnectReason, "peer_goaway")
|
|
2339
|
+
assert.equal(error.incidentOrigin, "reverse_hub")
|
|
2340
|
+
assert.equal(error.physicalRole, "gateway")
|
|
2341
|
+
assert.equal(error.fanOutCount, 1)
|
|
2342
|
+
assert.equal(error.terminalStreamCount, 1)
|
|
2343
|
+
assert.notEqual(error.disconnectIncidentId, "BBBBBBBBBBBBBBBBBBBBBB")
|
|
2344
|
+
assert(hubEvents.some(({event, terminalStreamCount}) => event === "reverse_hub.streams_reset" && terminalStreamCount === 1), JSON.stringify(hubEvents))
|
|
2345
|
+
})
|
|
2346
|
+
|
|
1751
2347
|
test("reverse mode enforces verified TLS and stream resource bounds", async (t) => {
|
|
1752
2348
|
const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-limits-"))
|
|
1753
2349
|
const wrongRoot = await mkdtemp(join(tmpdir(), "socketduct-reverse-wrong-ca-"))
|
|
@@ -364,7 +364,12 @@ test("a backpressured gateway does not block DATA or a new OPEN for another rout
|
|
|
364
364
|
t.after(() => socket.destroy())
|
|
365
365
|
return nextError(socket)
|
|
366
366
|
})
|
|
367
|
-
|
|
367
|
+
const outputError = /** @type {Error & Record<string, unknown>} */ (await withTimeout(streamAError, "gateway A output limit"))
|
|
368
|
+
assert.equal(outputError.code, "OUTPUT_BACKPRESSURE_LIMIT")
|
|
369
|
+
assert.equal(outputError.disconnectReason, "output_backpressure_limit")
|
|
370
|
+
assert.equal(outputError.incidentOrigin, "reverse_hub")
|
|
371
|
+
assert.equal(outputError.physicalRole, "gateway")
|
|
372
|
+
assert.match(/** @type {string} */ (outputError.disconnectIncidentId), /^[A-Za-z0-9_-]{22}$/)
|
|
368
373
|
await withTimeout(Promise.all(queuedAErrors), "queued gateway A stream cleanup")
|
|
369
374
|
assert.equal(await exchange(streamB, "after-limit"), "b:after-limit")
|
|
370
375
|
assert.equal(hub.status().healthyGateways, 1)
|