socketduct 0.1.17 → 0.1.18

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.
@@ -1,6 +1,6 @@
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, rm} from "node:fs/promises"
3
+ import {mkdtemp, readdir, rm} 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"
@@ -10,7 +10,7 @@ import {connect as connectTls} from "node:tls"
10
10
 
11
11
  import {StaticTokenStore} from "../../src/auth/static-token-store.js"
12
12
  import {MultiplexedSocketductTransportSet} from "../../src/client/multiplexed-transport-set.js"
13
- import {encodeV3Control, encodeV3Frame, V3FrameType} from "../../src/protocol/v3-frame.js"
13
+ import {encodeV3Control, encodeV3Frame, V3FrameDecoder, V3FrameType} from "../../src/protocol/v3-frame.js"
14
14
  import {OutboundReverseGateway, ReverseGatewayConnection, startReverseGatewayHub} from "../../src/reverse/index.js"
15
15
  import {generateTestCertificates} from "../helpers/tls-certificates.js"
16
16
 
@@ -151,6 +151,7 @@ class HeldWriteTarget extends Duplex {
151
151
 
152
152
  class ObservedMemoryBudget {
153
153
  /** @type {number} */ bytes = 0
154
+ /** @type {number} */ spoolBytes = 0
154
155
  /** @type {number} */ peakBytes = 0
155
156
 
156
157
  /** @param {number} maxBytes - Hard reservation ceiling. */
@@ -159,9 +160,10 @@ class ObservedMemoryBudget {
159
160
  }
160
161
 
161
162
  /** @param {number} bytes - Reverse-stream bytes to retain. */
162
- reserveMemory(bytes) {
163
+ reserveData(bytes) {
163
164
  assert(this.bytes + bytes <= this.maxBytes, "reverse client exceeded the aggregate retained-byte ceiling")
164
165
  this.bytes += bytes
166
+ this.spoolBytes += bytes
165
167
  this.peakBytes = Math.max(this.peakBytes, this.bytes)
166
168
  }
167
169
 
@@ -171,8 +173,11 @@ class ObservedMemoryBudget {
171
173
  this.bytes -= bytes
172
174
  }
173
175
 
174
- reserveData() { assert.fail("reverse client must reserve non-durable bytes as memory") }
175
- releaseSpool() { assert.fail("reverse client must not reserve spool bytes") }
176
+ reserveMemory() { assert.fail("reverse client replay must be durably reserved") }
177
+ releaseSpool(/** @type {number} */ bytes) {
178
+ assert(bytes <= this.spoolBytes, "reverse client released more spool bytes than it reserved")
179
+ this.spoolBytes -= bytes
180
+ }
176
181
  reserveControl() {}
177
182
  releaseControl() {}
178
183
  }
@@ -346,6 +351,23 @@ class DataThenLocalCloseTarget extends Duplex {
346
351
  }
347
352
  }
348
353
 
354
+ class InterruptibleTarget extends Duplex {
355
+ constructor() {
356
+ super()
357
+ setImmediate(() => this.emit("connect"))
358
+ }
359
+
360
+ /** @override */
361
+ _read() {}
362
+
363
+ /** @param {Buffer} _chunk - Ignored bytes. @param {BufferEncoding} _encoding - Ignored encoding. @param {(error?: Error | null) => void} callback - Completion. @override */
364
+ _write(_chunk, _encoding, callback) { callback() }
365
+
366
+ fail() { this.emit("error", new Error("target disappeared during reverse transport interruption")) }
367
+
368
+ closeLocally() { this.emit("end"); this.emit("close") }
369
+ }
370
+
349
371
  test("reverse gateway registers outbound over TLS and multiplexes compressed streams", async (t) => {
350
372
  /** @type {Array<{event: string} & Record<string, unknown>>} */
351
373
  const gatewayLifecycle = []
@@ -1155,49 +1177,575 @@ test("hub and gateway both enforce target policy without opening a proxy", async
1155
1177
  allowed.destroy()
1156
1178
  })
1157
1179
 
1158
- test("gateway loss terminates uncertain streams, reconnects one owner, and accepts clean new work", async (t) => {
1159
- const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-reconnect-"))
1180
+ test("gateway transport replacement resumes one established reverse stream exactly once", async (t) => {
1181
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-resume-"))
1160
1182
  t.after(() => rm(root, {recursive: true, force: true}))
1161
1183
  const certificates = generateTestCertificates(root)
1184
+ const clientSpoolDirectory = join(root, "client-spool")
1185
+ const gatewaySpoolDirectory = join(root, "gateway-spool")
1186
+ /** @type {import('node:net').Socket | undefined} */
1187
+ let targetSocket
1188
+ let targetAccepts = 0
1189
+ /** @type {Buffer[]} */
1190
+ const targetReceived = []
1162
1191
  const targetSockets = new Set()
1163
1192
  const target = createServer((socket) => {
1193
+ targetAccepts += 1
1194
+ targetSocket = socket
1164
1195
  targetSockets.add(socket)
1165
1196
  socket.once("close", () => targetSockets.delete(socket))
1166
- socket.on("data", (chunk) => socket.write(chunk))
1197
+ socket.on("data", (chunk) => targetReceived.push(Buffer.from(chunk)))
1167
1198
  })
1168
1199
  const targetPort = await listen(target)
1169
1200
  t.after(() => {
1170
1201
  for (const socket of targetSockets) socket.destroy()
1171
1202
  return new Promise((resolve) => target.close(() => resolve(undefined)))
1172
1203
  })
1204
+
1173
1205
  const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
1174
- const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true})
1206
+ const hub = await startReverseGatewayHub({
1207
+ tokens,
1208
+ tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
1209
+ knownGatewayIds: ["minisforum-starlink"],
1210
+ knownRouteClasses: ["starlink"],
1211
+ authorizeGateway: () => true,
1212
+ authorizeClientGateway: () => false,
1213
+ authorizeClientRouteClass: () => true,
1214
+ resolveOpenGateways: () => ["minisforum-starlink"],
1215
+ authorizeOpen: () => true,
1216
+ resumeWindowMs: 2_000
1217
+ })
1218
+ t.after(() => hub.close())
1219
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
1220
+ let holdNextGatewayData = false
1221
+ /** @type {() => void} */
1222
+ let markGatewayDataHeld = () => {}
1223
+ const gatewayDataHeld = new Promise((resolve) => { markGatewayDataHeld = () => resolve(undefined) })
1224
+ const gateway = new OutboundReverseGateway({
1225
+ gatewayId: "minisforum-starlink",
1226
+ connectionId: "resume_gateway_01",
1227
+ hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"},
1228
+ tls,
1229
+ spoolDirectory: gatewaySpoolDirectory,
1230
+ authorizeTarget: () => true,
1231
+ reconnectDelayMs: 50,
1232
+ maxReconnectDelayMs: 50,
1233
+ createPhysicalSocket: (options) => {
1234
+ const physical = connectTls({host: options.host, port: options.port, ...options.tls})
1235
+ const originalWrite = physical.write.bind(physical)
1236
+ physical.write = /** @type {typeof physical.write} */ ((chunk, ...args) => {
1237
+ if (holdNextGatewayData && Buffer.isBuffer(chunk) && chunk[5] === V3FrameType.DATA) {
1238
+ holdNextGatewayData = false
1239
+ markGatewayDataHeld()
1240
+ return true
1241
+ }
1242
+ return Reflect.apply(originalWrite, physical, [chunk, ...args])
1243
+ })
1244
+ return physical
1245
+ }
1246
+ })
1247
+ t.after(() => gateway.close())
1248
+ await gateway.start()
1249
+ const client = new ReverseGatewayConnection({
1250
+ routeClass: "starlink",
1251
+ connectionId: "resume_client_01",
1252
+ hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
1253
+ tls,
1254
+ spoolDirectory: clientSpoolDirectory,
1255
+ reconnectDelayMs: 10,
1256
+ maxReconnectDelayMs: 20
1257
+ })
1258
+ t.after(() => client.close())
1259
+ await client.connect()
1260
+
1261
+ const socket = client.createConnection({host: "127.0.0.1", port: targetPort, sessionName: "docker-exec"})
1262
+ /** @type {Buffer[]} */
1263
+ const clientReceived = []
1264
+ /** @type {Error[]} */
1265
+ const clientErrors = []
1266
+ let clientEnds = 0
1267
+ let clientCloses = 0
1268
+ socket.on("data", (chunk) => clientReceived.push(Buffer.from(chunk)))
1269
+ socket.on("error", (error) => clientErrors.push(error))
1270
+ socket.on("end", () => { clientEnds += 1 })
1271
+ socket.on("close", () => { clientCloses += 1 })
1272
+ await onceEvent(socket, "connect")
1273
+ assert(targetSocket !== undefined)
1274
+
1275
+ socket.write("client-before|")
1276
+ await waitUntil(() => Buffer.concat(targetReceived).toString() === "client-before|", "initial client bytes")
1277
+ targetSocket.write("server-before|")
1278
+ await waitUntil(() => Buffer.concat(clientReceived).toString() === "server-before|", "initial target bytes")
1279
+
1280
+ holdNextGatewayData = true
1281
+ targetSocket.write("server-during|")
1282
+ await withTimeout(gatewayDataHeld, "unacknowledged gateway DATA")
1283
+ await hub.closeActiveGateway("minisforum-starlink")
1284
+ await waitUntil(() => gateway.status().physicalState === "reconnecting", "gateway interruption")
1285
+ await new Promise((resolve) => setTimeout(resolve, 20))
1286
+ assert.equal(clientErrors.length, 0, "transport loss must not error the app-facing socket")
1287
+ assert.equal(clientEnds, 0, "transport loss must not end the app-facing socket")
1288
+ assert.equal(clientCloses, 0, "transport loss must not close the app-facing socket")
1289
+
1290
+ const duringWrite = new Promise((resolve, reject) => socket.write("client-during|", (error) => error ? reject(error) : resolve(undefined)))
1291
+ await waitUntil(() => gateway.status().physicalConnects >= 2 && gateway.status().physicalState === "ready", "gateway transport replacement")
1292
+ await withTimeout(duringWrite, "client write retained during disconnect")
1293
+ await waitUntil(() => Buffer.concat(targetReceived).toString() === "client-before|client-during|", "client bytes replayed after resume")
1294
+ await waitUntil(() => Buffer.concat(clientReceived).toString() === "server-before|server-during|", "unacknowledged target bytes replayed after resume")
1295
+
1296
+ socket.write("client-after|")
1297
+ targetSocket.write("server-after|")
1298
+ await waitUntil(() => Buffer.concat(targetReceived).toString() === "client-before|client-during|client-after|", "post-resume client bytes")
1299
+ await waitUntil(() => Buffer.concat(clientReceived).toString() === "server-before|server-during|server-after|", "post-resume target bytes")
1300
+ assert.equal(targetAccepts, 1, "resume must retain the original logical target stream")
1301
+ assert.equal(clientErrors.length, 0)
1302
+ assert.equal(clientEnds, 0)
1303
+ assert.equal(clientCloses, 0)
1304
+
1305
+ const closed = onceEvent(socket, "close")
1306
+ socket.end()
1307
+ await withTimeout(closed, "resumed reverse stream cleanup")
1308
+ await waitUntil(() => hub.status().virtualStreams === 0 && gateway.status().virtualStreams === 0 && client.status().virtualStreams === 0 && gateway.status().spoolBytes === 0 && client.status().spoolBytes === 0 && targetSockets.size === 0, "resumed stream ownership cleanup")
1309
+ assert.equal(clientErrors.length, 0)
1310
+ assert.equal(clientEnds, 1)
1311
+ assert.equal(clientCloses, 1)
1312
+ assert.deepEqual(await readdir(clientSpoolDirectory), [])
1313
+ assert.deepEqual(await readdir(gatewaySpoolDirectory), [])
1314
+ })
1315
+
1316
+ test("gateway retains final response replay until resumed CLOSE is acknowledged", async (t) => {
1317
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-resume-close-"))
1318
+ t.after(() => rm(root, {recursive: true, force: true}))
1319
+ const certificates = generateTestCertificates(root)
1320
+ const payload = Buffer.from("final-response-before-transport-loss")
1321
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
1322
+ const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true, resumeWindowMs: 2_000})
1323
+ t.after(() => hub.close())
1324
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
1325
+ /** @type {DataThenLocalCloseTarget | undefined} */
1326
+ let target
1327
+ /** @type {() => void} */
1328
+ let markCloseHeld = () => {}
1329
+ const closeHeld = new Promise((resolve) => { markCloseHeld = () => resolve(undefined) })
1330
+ let holdGatewayClose = true
1331
+ const gateway = new OutboundReverseGateway({
1332
+ gatewayId: "o2",
1333
+ connectionId: "resume_close_gateway",
1334
+ hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"},
1335
+ tls,
1336
+ spoolDirectory: join(root, "gateway-spool"),
1337
+ authorizeTarget: () => true,
1338
+ reconnectDelayMs: 20,
1339
+ maxReconnectDelayMs: 20,
1340
+ createTargetConnection: () => {
1341
+ target = new DataThenLocalCloseTarget(payload)
1342
+ return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (target))
1343
+ },
1344
+ createPhysicalSocket: (options) => {
1345
+ const physical = connectTls({host: options.host, port: options.port, ...options.tls})
1346
+ const originalWrite = physical.write.bind(physical)
1347
+ physical.write = /** @type {typeof physical.write} */ ((chunk, ...args) => {
1348
+ if (holdGatewayClose && Buffer.isBuffer(chunk) && chunk[5] === V3FrameType.CLOSE) {
1349
+ holdGatewayClose = false
1350
+ markCloseHeld()
1351
+ return true
1352
+ }
1353
+ return Reflect.apply(originalWrite, physical, [chunk, ...args])
1354
+ })
1355
+ return physical
1356
+ }
1357
+ })
1358
+ t.after(() => gateway.close())
1359
+ await gateway.start()
1360
+ /** @type {() => void} */
1361
+ let markAckHeld = () => {}
1362
+ const ackHeld = new Promise((resolve) => { markAckHeld = () => resolve(undefined) })
1363
+ let holdClientAck = true
1364
+ const client = new ReverseGatewayConnection({
1365
+ gatewayId: "o2",
1366
+ connectionId: "resume_close_client",
1367
+ hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
1368
+ tls,
1369
+ spoolDirectory: join(root, "client-spool"),
1370
+ reconnectDelayMs: 10,
1371
+ maxReconnectDelayMs: 20,
1372
+ createPhysicalSocket: (options) => {
1373
+ const physical = connectTls({host: options.host, port: options.port, ...options.tls})
1374
+ const originalWrite = physical.write.bind(physical)
1375
+ physical.write = /** @type {typeof physical.write} */ ((chunk, ...args) => {
1376
+ if (holdClientAck && Buffer.isBuffer(chunk) && chunk[5] === V3FrameType.ACK) {
1377
+ holdClientAck = false
1378
+ markAckHeld()
1379
+ return true
1380
+ }
1381
+ return Reflect.apply(originalWrite, physical, [chunk, ...args])
1382
+ })
1383
+ return physical
1384
+ }
1385
+ })
1386
+ t.after(() => client.close())
1387
+ await client.connect()
1388
+
1389
+ const socket = client.createConnection({host: "closing-target", port: 2375, allowHalfOpen: true})
1390
+ const response = readToEnd(socket)
1391
+ /** @type {Error[]} */
1392
+ const errors = []
1393
+ socket.on("error", (error) => errors.push(error))
1394
+ const closed = onceEvent(socket, "close")
1395
+ await onceEvent(socket, "connect")
1396
+ await withTimeout(ackHeld, "final response ACK held")
1397
+ assert(target !== undefined)
1398
+ target.closeLocally()
1399
+ await withTimeout(closeHeld, "gateway CLOSE held before transport loss")
1400
+ assert.equal(gateway.status().virtualStreams, 1, "gateway must retain the closing stream until echoed CLOSE")
1401
+ assert.equal(gateway.status().spoolBytes, payload.length, "unacknowledged final response must remain spooled")
1402
+ assert.equal(socket.destroyed, false)
1403
+
1404
+ await hub.closeActiveGateway("o2")
1405
+ await waitUntil(() => gateway.status().physicalConnects >= 2 && gateway.status().physicalState === "ready", "closing gateway resume")
1406
+ assert.deepEqual(await withTimeout(response, "resumed final response"), payload)
1407
+ await withTimeout(closed, "resumed CLOSE acknowledgement")
1408
+ await waitUntil(() => hub.status().virtualStreams === 0 && gateway.status().virtualStreams === 0 && client.status().virtualStreams === 0 && gateway.status().spoolBytes === 0, "resumed close cleanup")
1409
+ assert.deepEqual(errors, [])
1410
+ })
1411
+
1412
+ test("a gateway in paired resume is unavailable for selection and new OPEN", async (t) => {
1413
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-resume-admission-"))
1414
+ t.after(() => rm(root, {recursive: true, force: true}))
1415
+ const certificates = generateTestCertificates(root)
1416
+ let targetAccepts = 0
1417
+ const targetSockets = new Set()
1418
+ const target = createServer((socket) => {
1419
+ targetAccepts += 1
1420
+ targetSockets.add(socket)
1421
+ socket.once("close", () => targetSockets.delete(socket))
1422
+ socket.on("data", (chunk) => socket.write(chunk))
1423
+ })
1424
+ const targetPort = await listen(target)
1425
+ t.after(() => { for (const socket of targetSockets) socket.destroy(); return new Promise((resolve) => target.close(() => resolve(undefined))) })
1426
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
1427
+ const hub = await startReverseGatewayHub({
1428
+ tokens,
1429
+ tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
1430
+ knownGatewayIds: ["o2"],
1431
+ knownRouteClasses: ["starlink"],
1432
+ authorizeGateway: () => true,
1433
+ authorizeClientGateway: () => true,
1434
+ authorizeClientRouteClass: () => true,
1435
+ resolveOpenGateways: () => ["o2"],
1436
+ authorizeOpen: () => true,
1437
+ resumeWindowMs: 2_000
1438
+ })
1439
+ t.after(() => hub.close())
1440
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
1441
+ let gatewayPhysicals = 0
1442
+ /** @type {() => void} */
1443
+ let markResumeHeld = () => {}
1444
+ const resumeHeld = new Promise((resolve) => { markResumeHeld = () => resolve(undefined) })
1445
+ /** @type {() => void} */
1446
+ let releaseResume = () => {}
1447
+ let resumeCaptured = false
1448
+ const gateway = new OutboundReverseGateway({
1449
+ gatewayId: "o2",
1450
+ connectionId: "resume_admission_gateway",
1451
+ hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"},
1452
+ tls,
1453
+ authorizeTarget: () => true,
1454
+ reconnectDelayMs: 10,
1455
+ maxReconnectDelayMs: 20,
1456
+ createPhysicalSocket: (options) => {
1457
+ gatewayPhysicals += 1
1458
+ const physical = connectTls({host: options.host, port: options.port, ...options.tls})
1459
+ const originalWrite = physical.write.bind(physical)
1460
+ physical.write = /** @type {typeof physical.write} */ ((chunk, ...args) => {
1461
+ if (gatewayPhysicals > 1 && Buffer.isBuffer(chunk) && chunk[5] === V3FrameType.RESUME && !resumeCaptured) {
1462
+ resumeCaptured = true
1463
+ const held = Buffer.from(chunk)
1464
+ releaseResume = () => { Reflect.apply(originalWrite, physical, [held, ...args]) }
1465
+ markResumeHeld()
1466
+ return true
1467
+ }
1468
+ return Reflect.apply(originalWrite, physical, [chunk, ...args])
1469
+ })
1470
+ return physical
1471
+ }
1472
+ })
1473
+ t.after(() => gateway.close())
1474
+ await gateway.start()
1475
+ const retainedClient = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "resume_admission_retained", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, reconnectDelayMs: 10, maxReconnectDelayMs: 20})
1476
+ t.after(() => retainedClient.close())
1477
+ await retainedClient.connect()
1478
+ const retained = retainedClient.createConnection({host: "127.0.0.1", port: targetPort})
1479
+ await onceEvent(retained, "connect")
1480
+ const routeClient = new ReverseGatewayConnection({routeClass: "starlink", connectionId: "resume_admission_route", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls})
1481
+ t.after(() => routeClient.close())
1482
+ await routeClient.connect()
1483
+
1484
+ await hub.closeActiveGateway("o2")
1485
+ await withTimeout(resumeHeld, "gateway RESUME admission hold")
1486
+ assert.equal(hub.status().healthyGateways, 0, "resuming gateway must not be advertised healthy")
1487
+
1488
+ const selectingClient = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "resume_admission_new", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, reconnectDelayMs: 50, maxReconnectDelayMs: 50})
1489
+ t.after(() => selectingClient.close())
1490
+ void selectingClient.connect().catch(() => {})
1491
+ await new Promise((resolve) => setTimeout(resolve, 100))
1492
+ assert.equal(selectingClient.status().physicalConnects, 0, "new exact selection must wait until paired resume completes")
1493
+
1494
+ const deniedOpen = routeClient.createConnection({host: "127.0.0.1", port: targetPort})
1495
+ assert.equal((await withTimeout(nextError(deniedOpen), "OPEN while gateway resumes")).code, "GATEWAY_UNAVAILABLE")
1496
+ assert.equal(targetAccepts, 1, "resume-only gateway must not receive a new target OPEN")
1497
+
1498
+ releaseResume()
1499
+ await waitUntil(() => gateway.status().physicalState === "ready" && retainedClient.status().physicalState === "ready", "paired resume after admission hold")
1500
+ const echoed = readBytes(retained, 14)
1501
+ retained.write("retained-alive")
1502
+ assert.equal((await withTimeout(echoed, "retained stream after admission hold")).toString(), "retained-alive")
1503
+ retained.destroy()
1504
+ })
1505
+
1506
+ test("paired resume preserves unread reverse DATA against the receive window", async (t) => {
1507
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-resume-window-"))
1508
+ t.after(() => rm(root, {recursive: true, force: true}))
1509
+ const certificates = generateTestCertificates(root)
1510
+ /** @type {import('node:net').Socket | undefined} */
1511
+ let targetSocket
1512
+ const targetSockets = new Set()
1513
+ const target = createServer((socket) => {
1514
+ targetSocket = socket
1515
+ targetSockets.add(socket)
1516
+ socket.once("close", () => targetSockets.delete(socket))
1517
+ })
1518
+ const targetPort = await listen(target)
1519
+ t.after(() => { for (const socket of targetSockets) socket.destroy(); return new Promise((resolve) => target.close(() => resolve(undefined))) })
1520
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
1521
+ const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true, initialWindowBytes: 32, maxFrameBytes: 16, resumeWindowMs: 2_000})
1522
+ t.after(() => hub.close())
1523
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
1524
+ const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "resume_window_gateway", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, spoolDirectory: join(root, "gateway-spool"), authorizeTarget: () => true, initialWindowBytes: 32, maxFrameBytes: 16, reconnectDelayMs: 10, maxReconnectDelayMs: 20})
1525
+ t.after(() => gateway.close())
1526
+ await gateway.start()
1527
+ const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "resume_window_client", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, spoolDirectory: join(root, "client-spool"), initialWindowBytes: 32, maxFrameBytes: 16, reconnectDelayMs: 10, maxReconnectDelayMs: 20})
1528
+ t.after(() => client.close())
1529
+ await client.connect()
1530
+ const socket = client.createConnection({host: "127.0.0.1", port: targetPort, readableHighWaterMark: 1})
1531
+ await onceEvent(socket, "connect")
1532
+ assert(targetSocket !== undefined)
1533
+
1534
+ targetSocket.write(Buffer.alloc(32, 0x61))
1535
+ await waitUntil(() => socket.readableLength === 32 && gateway.status().spoolBytes === 32, "initial unread window")
1536
+ await hub.closeActiveGateway("o2")
1537
+ await waitUntil(() => gateway.status().physicalConnects >= 2 && gateway.status().physicalState === "ready", "first unread resume")
1538
+ assert.equal(gateway.status().spoolBytes, 32, "resume must not ACK unread VirtualSocket bytes")
1539
+
1540
+ targetSocket.write(Buffer.alloc(32, 0x62))
1541
+ await waitUntil(() => gateway.status().spoolBytes === 64, "second target window retained")
1542
+ await new Promise((resolve) => setTimeout(resolve, 50))
1543
+ assert.equal(socket.readableLength, 32, "resume must not replenish credit for unread bytes")
1544
+ await hub.closeActiveGateway("o2")
1545
+ await waitUntil(() => gateway.status().physicalConnects >= 3 && gateway.status().physicalState === "ready", "second unread resume")
1546
+ assert.equal(socket.readableLength, 32, "repeated resume must preserve the application-consumed boundary")
1547
+ assert.equal(gateway.status().spoolBytes, 64)
1548
+
1549
+ const received = await withTimeout(readBytes(socket, 64), "flow resumes after application consumption")
1550
+ assert.deepEqual(received, Buffer.concat([Buffer.alloc(32, 0x61), Buffer.alloc(32, 0x62)]))
1551
+ const closed = onceEvent(socket, "close")
1552
+ targetSocket.end()
1553
+ await withTimeout(closed, "unread resume cleanup")
1554
+ await waitUntil(() => hub.status().virtualStreams === 0 && gateway.status().virtualStreams === 0 && client.status().virtualStreams === 0 && gateway.status().spoolBytes === 0 && client.status().spoolBytes === 0, "unread resume ownership cleanup")
1555
+ })
1556
+
1557
+ test("client CLOSE uses the receiver-confirmed sequence immediately after resume", async (t) => {
1558
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-resume-close-sequence-"))
1559
+ t.after(() => rm(root, {recursive: true, force: true}))
1560
+ const certificates = generateTestCertificates(root)
1561
+ const siblingSockets = new Set()
1562
+ const siblingTarget = createServer((socket) => {
1563
+ siblingSockets.add(socket)
1564
+ socket.once("close", () => siblingSockets.delete(socket))
1565
+ socket.on("data", (chunk) => socket.write(chunk))
1566
+ })
1567
+ const siblingPort = await listen(siblingTarget)
1568
+ t.after(() => { for (const socket of siblingSockets) socket.destroy(); return new Promise((resolve) => siblingTarget.close(() => resolve(undefined))) })
1569
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
1570
+ const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true, initialWindowBytes: 65_536, maxFrameBytes: 65_536, resumeWindowMs: 2_000})
1571
+ t.after(() => hub.close())
1572
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
1573
+ /** @type {InterruptibleTarget | undefined} */
1574
+ let closingTarget
1575
+ const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "resume_close_sequence_gateway", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, authorizeTarget: () => true, initialWindowBytes: 65_536, maxFrameBytes: 65_536, reconnectDelayMs: 40, maxReconnectDelayMs: 40, createTargetConnection: (target) => {
1576
+ if (target.host !== "closing-target") return connectTcp(target)
1577
+ closingTarget = new InterruptibleTarget()
1578
+ return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (closingTarget))
1579
+ }})
1580
+ t.after(() => gateway.close())
1581
+ await gateway.start()
1582
+ let clientPhysicals = 0
1583
+ /** @type {number | undefined} */
1584
+ let closingStreamId
1585
+ /** @type {() => void} */
1586
+ let markDataHeld = () => {}
1587
+ const dataHeld = new Promise((resolve) => { markDataHeld = () => resolve(undefined) })
1588
+ /** @type {() => void} */
1589
+ let markCloseCoalesced = () => {}
1590
+ const closeCoalesced = new Promise((resolve) => { markCloseCoalesced = () => resolve(undefined) })
1591
+ const client = new ReverseGatewayConnection({
1592
+ gatewayId: "o2",
1593
+ connectionId: "resume_close_sequence_client",
1594
+ hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
1595
+ tls,
1596
+ initialWindowBytes: 65_536,
1597
+ maxFrameBytes: 65_536,
1598
+ reconnectDelayMs: 10,
1599
+ maxReconnectDelayMs: 20,
1600
+ createPhysicalSocket: (options) => {
1601
+ clientPhysicals += 1
1602
+ const generation = clientPhysicals
1603
+ const physical = connectTls({host: options.host, port: options.port, ...options.tls})
1604
+ const originalWrite = physical.write.bind(physical)
1605
+ physical.write = /** @type {typeof physical.write} */ ((chunk, ...args) => {
1606
+ if (generation === 1 && Buffer.isBuffer(chunk) && chunk[5] === V3FrameType.DATA && chunk.readUInt32BE(8) === closingStreamId) {
1607
+ markDataHeld()
1608
+ return true
1609
+ }
1610
+ return Reflect.apply(originalWrite, physical, [chunk, ...args])
1611
+ })
1612
+ if (generation > 1) {
1613
+ const originalEmit = physical.emit.bind(physical)
1614
+ const decoder = new V3FrameDecoder({maxFrameBytes: 128 * 1024, maxBufferedBytes: 256 * 1024})
1615
+ /** @type {Buffer[]} */
1616
+ let heldInbound = []
1617
+ physical.emit = /** @type {typeof physical.emit} */ ((event, ...args) => {
1618
+ if (event !== "data" || !Buffer.isBuffer(args[0])) return originalEmit(event, ...args)
1619
+ for (const frame of decoder.push(args[0])) {
1620
+ const wire = encodeV3Frame({type: frame.type, streamId: frame.streamId, sequence: frame.sequence, flags: frame.flags}, frame.payload)
1621
+ if (frame.type === V3FrameType.RESUMED) heldInbound.push(wire)
1622
+ else if (heldInbound.length > 0) heldInbound.push(wire)
1623
+ else originalEmit("data", wire)
1624
+ if (heldInbound.length > 0 && frame.type === V3FrameType.CLOSE && frame.streamId === closingStreamId) {
1625
+ const coalesced = Buffer.concat(heldInbound)
1626
+ heldInbound = []
1627
+ originalEmit("data", coalesced)
1628
+ markCloseCoalesced()
1629
+ }
1630
+ }
1631
+ return true
1632
+ })
1633
+ }
1634
+ return physical
1635
+ }
1636
+ })
1637
+ t.after(() => client.close())
1638
+ await client.connect()
1639
+ const closing = client.createConnection({host: "closing-target", port: 2375})
1640
+ closingStreamId = closing.streamId
1641
+ const closingRead = readToEnd(closing)
1642
+ const sibling = client.createConnection({host: "127.0.0.1", port: siblingPort})
1643
+ /** @type {Error[]} */
1644
+ const siblingErrors = []
1645
+ sibling.on("error", (error) => siblingErrors.push(error))
1646
+ await withTimeout(Promise.all([onceEvent(closing, "connect"), onceEvent(sibling, "connect")]), "streams before close sequence resume")
1647
+ closing.write(Buffer.alloc(65_536, 0x63))
1648
+ await withTimeout(dataHeld, "unacknowledged client DATA before resume")
1649
+
1650
+ await hub.closeActiveGateway("o2")
1651
+ await waitUntil(() => gateway.status().physicalState === "reconnecting", "gateway interrupted before target close")
1652
+ assert(closingTarget !== undefined)
1653
+ closingTarget.closeLocally()
1654
+ await withTimeout(closeCoalesced, "RESUMED and target CLOSE delivery")
1655
+ assert.equal((await withTimeout(closingRead, "receiver-confirmed close sequence")).length, 0)
1656
+ const echoed = readBytes(sibling, 13)
1657
+ sibling.write("sibling-alive")
1658
+ assert.equal((await withTimeout(echoed, "sibling after immediate resumed CLOSE")).toString(), "sibling-alive")
1659
+ assert.deepEqual(siblingErrors, [])
1660
+ assert.equal(client.status().physicalConnects, 2, "invalid CLOSE must not recycle the shared client physical")
1661
+ sibling.destroy()
1662
+ })
1663
+
1664
+ test("a target lost during reverse resume fails the logical stream once with its causal error", async (t) => {
1665
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-resume-target-loss-"))
1666
+ t.after(() => rm(root, {recursive: true, force: true}))
1667
+ const certificates = generateTestCertificates(root)
1668
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
1669
+ const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true, resumeWindowMs: 1_000})
1175
1670
  t.after(() => hub.close())
1176
1671
  const tls = {caFile: certificates.caCertFile, servername: "localhost"}
1177
- const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "reconnecting_gateway", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, authorizeTarget: () => true, reconnectDelayMs: 10, maxReconnectDelayMs: 20})
1672
+ /** @type {InterruptibleTarget | undefined} */
1673
+ let target
1674
+ const gateway = new OutboundReverseGateway({
1675
+ gatewayId: "o2",
1676
+ connectionId: "resume_target_loss_gateway",
1677
+ hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"},
1678
+ tls,
1679
+ spoolDirectory: join(root, "gateway-spool"),
1680
+ authorizeTarget: () => true,
1681
+ reconnectDelayMs: 80,
1682
+ maxReconnectDelayMs: 80,
1683
+ createTargetConnection: () => {
1684
+ target = new InterruptibleTarget()
1685
+ return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (target))
1686
+ }
1687
+ })
1688
+ t.after(() => gateway.close())
1689
+ await gateway.start()
1690
+ const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "resume_target_loss_client", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, spoolDirectory: join(root, "client-spool"), reconnectDelayMs: 10, maxReconnectDelayMs: 20})
1691
+ t.after(() => client.close())
1692
+ await client.connect()
1693
+ const socket = client.createConnection({host: "target", port: 2375})
1694
+ let errors = 0
1695
+ const terminal = new Promise((resolve) => socket.on("error", (error) => { errors += 1; resolve(error) }))
1696
+ await onceEvent(socket, "connect")
1697
+ assert(target !== undefined)
1698
+ await hub.closeActiveGateway("o2")
1699
+ await waitUntil(() => gateway.status().physicalState === "reconnecting", "gateway interruption before target loss")
1700
+ target.fail()
1701
+ const error = /** @type {Error & {code?: string}} */ (await withTimeout(terminal, "target-loss resume error"))
1702
+ assert.equal(error.code, "TARGET_CLOSED")
1703
+ await waitUntil(() => hub.status().virtualStreams === 0 && gateway.status().virtualStreams === 0 && client.status().virtualStreams === 0, "target-loss stream cleanup")
1704
+ assert.equal(errors, 1)
1705
+ })
1706
+
1707
+ test("an expired reverse resume window fails the logical stream once and releases its target", async (t) => {
1708
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-resume-expiry-"))
1709
+ t.after(() => rm(root, {recursive: true, force: true}))
1710
+ const certificates = generateTestCertificates(root)
1711
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
1712
+ const hub = await startReverseGatewayHub({
1713
+ tokens,
1714
+ tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
1715
+ knownGatewayIds: ["o2"],
1716
+ knownRouteClasses: ["starlink"],
1717
+ authorizeGateway: () => true,
1718
+ authorizeClientGateway: () => false,
1719
+ authorizeClientRouteClass: () => true,
1720
+ resolveOpenGateways: () => ["o2"],
1721
+ authorizeOpen: () => true,
1722
+ resumeWindowMs: 40
1723
+ })
1724
+ t.after(() => hub.close())
1725
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
1726
+ /** @type {InterruptibleTarget[]} */
1727
+ const targets = []
1728
+ const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "resume_expiry_gateway", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, spoolDirectory: join(root, "gateway-spool"), authorizeTarget: () => true, reconnectDelayMs: 150, maxReconnectDelayMs: 150, createTargetConnection: () => {
1729
+ const target = new InterruptibleTarget()
1730
+ targets.push(target)
1731
+ return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (target))
1732
+ }})
1178
1733
  t.after(() => gateway.close())
1179
1734
  await gateway.start()
1180
- const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "reconnecting_client", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, reconnectDelayMs: 10, maxReconnectDelayMs: 20})
1735
+ const client = new ReverseGatewayConnection({routeClass: "starlink", connectionId: "resume_expiry_client", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, spoolDirectory: join(root, "client-spool"), reconnectDelayMs: 5, maxReconnectDelayMs: 10})
1181
1736
  t.after(() => client.close())
1182
1737
  await client.connect()
1183
- const uncertain = client.createConnection({host: "127.0.0.1", port: targetPort})
1184
- await onceEvent(uncertain, "connect")
1185
- const lost = nextError(uncertain)
1738
+ const socket = client.createConnection({host: "target", port: 2375})
1739
+ let errors = 0
1740
+ const terminal = new Promise((resolve) => socket.on("error", (error) => { errors += 1; resolve(error) }))
1741
+ await onceEvent(socket, "connect")
1186
1742
  await hub.closeActiveGateway("o2")
1187
- assert.equal((await lost).code, "GATEWAY_DISCONNECTED")
1188
- await waitUntil(() => gateway.status().physicalConnects >= 2 && client.status().physicalConnects >= 2 && gateway.status().physicalState === "ready" && client.status().physicalState === "ready", "gateway and selected member reconnect")
1189
- await waitUntil(() => targetSockets.size === 0, "uncertain target cleanup")
1190
- assert.equal(hub.status().virtualStreams, 0)
1191
- assert.equal(targetSockets.size, 0)
1192
-
1193
- const clean = client.createConnection({host: "127.0.0.1", port: targetPort})
1194
- await onceEvent(clean, "connect")
1195
- const echoed = readBytes(clean, 12)
1196
- clean.write("clean-retry!")
1197
- assert.equal((await echoed).toString(), "clean-retry!")
1198
- assert(gateway.status().physicalConnects >= 2)
1199
- assert(client.status().physicalConnects >= 2)
1200
- clean.destroy()
1743
+ const error = /** @type {Error & {code?: string}} */ (await withTimeout(terminal, "expired reverse resume"))
1744
+ assert.equal(error.code, "RESUME_WINDOW_EXPIRED")
1745
+ await waitUntil(() => gateway.status().physicalConnects >= 2 && gateway.status().virtualStreams === 0 && client.status().virtualStreams === 0 && hub.status().virtualStreams === 0, "expired reverse resume cleanup")
1746
+ assert.equal(errors, 1)
1747
+ assert.equal(targets.length, 1)
1748
+ assert.equal(targets[0]?.destroyed, true)
1201
1749
  })
1202
1750
 
1203
1751
  test("reverse mode enforces verified TLS and stream resource bounds", async (t) => {