socketduct 0.1.25 → 0.1.26

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.
Files changed (56) hide show
  1. package/AGENTS.md +2 -2
  2. package/changelog.d/20261010130509-bound-log-frame-compression-memory.md +1 -0
  3. package/docs/runbooks/jsonl-integrity-rollout.md +8 -6
  4. package/package.json +2 -2
  5. package/scripts/test.js +9 -14
  6. package/src/logging/compressed-log-writer.js +1 -1
  7. package/src/logging/zstd-frame-codec.js +15 -1
  8. package/test/e2e/gateway-process.test.js +93 -92
  9. package/test/e2e/logger-shutdown-process.test.js +40 -37
  10. package/test/e2e/reverse-client-recovery-process.test.js +107 -103
  11. package/test/e2e/reverse-gateway-process.test.js +328 -318
  12. package/test/integration/initial-target-recovery.test.js +333 -334
  13. package/test/integration/logging-cli.test.js +121 -119
  14. package/test/integration/logging-compressed-recovery.test.js +96 -95
  15. package/test/integration/multiplexed-compatibility.test.js +178 -177
  16. package/test/integration/multiplexed-corrections.test.js +360 -359
  17. package/test/integration/multiplexed-failures.test.js +253 -251
  18. package/test/integration/multiplexed-transport-set.test.js +334 -322
  19. package/test/integration/multiplexed-transport.test.js +135 -134
  20. package/test/integration/reverse-gateway-stream-id-reset.test.js +90 -89
  21. package/test/integration/reverse-gateway.test.js +2309 -2218
  22. package/test/integration/reverse-resume-continuity.test.js +219 -218
  23. package/test/integration/reverse-route-class.test.js +358 -341
  24. package/test/integration/socketduct-http-agent.test.js +314 -312
  25. package/test/integration/socketduct-https-agent.test.js +516 -485
  26. package/test/integration/stream-compression.test.js +862 -824
  27. package/test/integration/tcp-forwarding.test.js +2550 -2447
  28. package/test/integration/tls-forwarding.test.js +593 -592
  29. package/test/unit/auth/auth.test.js +43 -44
  30. package/test/unit/auth/static-token-store.test.js +9 -8
  31. package/test/unit/client/connect.test.js +41 -40
  32. package/test/unit/client/deferred-virtual-socket.test.js +95 -94
  33. package/test/unit/client/multiplexed-spool-budget.test.js +155 -154
  34. package/test/unit/client/multiplexed-transport-set.test.js +830 -748
  35. package/test/unit/client/socketduct-socket.test.js +768 -771
  36. package/test/unit/client/virtual-socket.test.js +198 -168
  37. package/test/unit/config/docker-gateway-files.test.js +224 -224
  38. package/test/unit/config/environment.test.js +472 -492
  39. package/test/unit/index.test.js +53 -52
  40. package/test/unit/logging/compressed-log-worker.test.js +23 -22
  41. package/test/unit/logging/compressed-log-writer.test.js +155 -154
  42. package/test/unit/logging/log-retention.test.js +52 -47
  43. package/test/unit/logging/log-segment-index.test.js +84 -81
  44. package/test/unit/logging/zstd-frame-codec.test.js +28 -19
  45. package/test/unit/protocol/compression.test.js +254 -298
  46. package/test/unit/protocol/frame.test.js +280 -276
  47. package/test/unit/protocol/v3-compression.test.js +21 -23
  48. package/test/unit/protocol/v3-frame.test.js +42 -41
  49. package/test/unit/release-configuration.test.js +15 -12
  50. package/test/unit/reverse/disconnect-attribution.test.js +65 -64
  51. package/test/unit/reverse-terminal-outcomes.test.js +53 -50
  52. package/test/unit/server/control-handler.test.js +232 -231
  53. package/test/unit/server/primary.test.js +51 -49
  54. package/test/unit/server/relay-runtime.test.js +280 -294
  55. package/test/unit/sessions/session.test.js +48 -62
  56. package/test/unit/spool/spool-file.test.js +401 -385
@@ -1,11 +1,10 @@
1
1
  /* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param, jsdoc/require-returns */
2
- import assert from "node:assert/strict"
2
+ import {describe, expect, it, withTestCleanup} from "@velocious/testing"
3
3
  import {readFileSync} from "node:fs"
4
4
  import {mkdtemp, rm} from "node:fs/promises"
5
5
  import {createConnection, createServer} from "node:net"
6
6
  import {tmpdir} from "node:os"
7
7
  import {join} from "node:path"
8
- import test from "node:test"
9
8
  import {createServer as createTlsServer} from "node:tls"
10
9
 
11
10
  import {StaticTokenStore} from "../../src/auth/static-token-store.js"
@@ -22,7 +21,7 @@ async function listen(server) {
22
21
  server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(undefined) })
23
22
  })
24
23
  const address = server.address()
25
- assert(address !== null && typeof address !== "string")
24
+ if (address === null || typeof address === "string") throw new Error("Expected a bound TCP address")
26
25
  return address.port
27
26
  }
28
27
 
@@ -59,7 +58,7 @@ async function waitForStatus(set, predicate) {
59
58
  /** @template T @param {T[]} values - Values. @param {number} index - Index. @returns {T} */
60
59
  function at(values, index) {
61
60
  const value = values[index]
62
- assert(value !== undefined)
61
+ if (value === undefined) throw new Error(`Expected a value at index ${index}`)
63
62
  return value
64
63
  }
65
64
 
@@ -115,338 +114,351 @@ async function createFirstDataDropProxy(relayPort) {
115
114
  }
116
115
  }
117
116
 
118
- test("three pinned gateways warm once and balance virtual sockets without extra physical accepts", async (t) => {
119
- const root = await mkdtemp(join(tmpdir(), "socketduct-set-integration-"))
120
- const target = createServer((socket) => socket.on("data", (chunk) => socket.write(chunk)))
121
- const targetPort = await listen(target)
122
- t.after(() => new Promise((resolve) => target.close(() => resolve(undefined))))
123
- const relays = await Promise.all(["starlink", "zyxel", "o2gateway"].map(() => startMultiplexedTcpRelay({
124
- tokens: new StaticTokenStore([{username: "edge", token: "valid-token"}]),
125
- authorizeTarget: ({target: requested}) => requested.host === "127.0.0.1" && requested.port === targetPort,
126
- maxStreams: 256
127
- })))
128
- const gatewayIds = ["starlink", "zyxel", "o2gateway"]
129
- t.after(() => Promise.all(relays.map((relay) => relay.close())))
117
+ describe("multiplexed transport set", () => {
118
+ it("three pinned gateways warm once and balance virtual sockets without extra physical accepts", async () => withTestCleanup(async (after) => {
119
+ const root = await mkdtemp(join(tmpdir(), "socketduct-set-integration-"))
120
+ const target = createServer((socket) => socket.on("data", (chunk) => socket.write(chunk)))
121
+ const targetPort = await listen(target)
122
+ after(() => new Promise((resolve) => target.close(() => resolve(undefined))))
123
+ const relays = await Promise.all(["starlink", "zyxel", "o2gateway"].map(() => startMultiplexedTcpRelay({
124
+ tokens: new StaticTokenStore([{username: "edge", token: "valid-token"}]),
125
+ authorizeTarget: ({target: requested}) => requested.host === "127.0.0.1" && requested.port === targetPort,
126
+ maxStreams: 256
127
+ })))
128
+ const gatewayIds = ["starlink", "zyxel", "o2gateway"]
129
+ after(async () => {
130
+ await Promise.all(relays.map((relay) => relay.close()))
131
+ })
130
132
 
131
- const set = new MultiplexedSocketductTransportSet({
132
- connectionId: "integration_process_01",
133
- spoolDirectory: root,
134
- minimumReadyMembers: 3,
135
- maxTotalBufferBytes: 32 * 1024 * 1024,
136
- maxTotalSpoolBytes: 32 * 1024 * 1024,
137
- maxTotalQueuedBytes: 33 * 1024 * 1024,
138
- memberOptions: {maxStreams: 256, reconnectDelayMs: 10},
139
- gateways: relays.map((relay, index) => ({
140
- gatewayId: at(gatewayIds, index),
141
- endpoint: {relay: {host: "127.0.0.1", port: relay.port, token: "valid-token"}}
142
- }))
143
- })
144
- t.after(async () => {
145
- await set.close()
146
- await rm(root, {recursive: true, force: true})
147
- })
148
- const started = set.start()
149
- const firstSocket = set.createConnection({
150
- host: "127.0.0.1",
151
- port: targetPort,
152
- sessionName: "set-flow-1"
153
- })
154
- const firstConnected = onceEvent(firstSocket, "connect")
155
- assert.equal(firstSocket.gatewayId, undefined)
156
- await Promise.all([started, firstConnected])
157
- assert.deepEqual(relays.map((relay) => relay.status().physicalAccepts), [1, 1, 1])
158
- assert.deepEqual(relays.map((relay) => relay.status().virtualStreams), [1, 0, 0])
133
+ const set = new MultiplexedSocketductTransportSet({
134
+ connectionId: "integration_process_01",
135
+ spoolDirectory: root,
136
+ minimumReadyMembers: 3,
137
+ maxTotalBufferBytes: 32 * 1024 * 1024,
138
+ maxTotalSpoolBytes: 32 * 1024 * 1024,
139
+ maxTotalQueuedBytes: 33 * 1024 * 1024,
140
+ memberOptions: {maxStreams: 256, reconnectDelayMs: 10},
141
+ gateways: relays.map((relay, index) => ({
142
+ gatewayId: at(gatewayIds, index),
143
+ endpoint: {relay: {host: "127.0.0.1", port: relay.port, token: "valid-token"}}
144
+ }))
145
+ })
146
+ after(async () => {
147
+ await set.close()
148
+ await rm(root, {recursive: true, force: true})
149
+ })
150
+ const started = set.start()
151
+ const firstSocket = set.createConnection({
152
+ host: "127.0.0.1",
153
+ port: targetPort,
154
+ sessionName: "set-flow-1"
155
+ })
156
+ const firstConnected = onceEvent(firstSocket, "connect")
157
+ expect(firstSocket.gatewayId).toBeUndefined()
158
+ await Promise.all([started, firstConnected])
159
+ expect(relays.map((relay) => relay.status().physicalAccepts)).toEqual([1, 1, 1])
160
+ expect(relays.map((relay) => relay.status().virtualStreams)).toEqual([1, 0, 0])
159
161
 
160
- const sockets = [firstSocket, ...Array.from({length: 99}, (_, index) => set.createConnection({
161
- host: "127.0.0.1",
162
- port: targetPort,
163
- sessionName: `set-flow-${index + 2}`
164
- }))]
165
- await Promise.all(sockets.slice(1).map((socket) => onceEvent(socket, "connect")))
166
- assert.deepEqual(set.status().members.map((member) => member.assignments), [34, 33, 33])
167
- assert.deepEqual(relays.map((relay) => relay.status().physicalAccepts), [1, 1, 1])
168
- assert.deepEqual(relays.map((relay) => relay.status().virtualStreams), [34, 33, 33])
169
- sockets.forEach((socket) => socket.destroy())
170
- })
162
+ const sockets = [firstSocket, ...Array.from({length: 99}, (_, index) => set.createConnection({
163
+ host: "127.0.0.1",
164
+ port: targetPort,
165
+ sessionName: `set-flow-${index + 2}`
166
+ }))]
167
+ await Promise.all(sockets.slice(1).map((socket) => onceEvent(socket, "connect")))
168
+ expect(set.status().members.map((member) => member.assignments)).toEqual([34, 33, 33])
169
+ expect(relays.map((relay) => relay.status().physicalAccepts)).toEqual([1, 1, 1])
170
+ expect(relays.map((relay) => relay.status().virtualStreams)).toEqual([34, 33, 33])
171
+ sockets.forEach((socket) => socket.destroy())
172
+ }))
171
173
 
172
- test("a failed gateway keeps its streams pinned while healthy siblings take new opens and recovery rejoins", async (t) => {
173
- const root = await mkdtemp(join(tmpdir(), "socketduct-set-recovery-"))
174
- const target = createServer((socket) => socket.on("data", (chunk) => socket.write(chunk)))
175
- const targetPort = await listen(target)
176
- t.after(() => new Promise((resolve) => target.close(() => resolve(undefined))))
177
- const relays = await Promise.all([0, 1, 2].map(() => startMultiplexedTcpRelay({
178
- tokens: new StaticTokenStore([{username: "edge", token: "valid-token"}]),
179
- authorizeTarget: ({target: requested}) => requested.port === targetPort
180
- })))
181
- const gatewayIds = ["starlink", "zyxel", "o2gateway"]
182
- t.after(() => Promise.all(relays.map((relay) => relay.close())))
183
- const set = new MultiplexedSocketductTransportSet({
184
- connectionId: "integration_process_recovery_01",
185
- spoolDirectory: root,
186
- maxTotalBufferBytes: 32 * 1024 * 1024,
187
- maxTotalSpoolBytes: 32 * 1024 * 1024,
188
- maxTotalQueuedBytes: 33 * 1024 * 1024,
189
- memberOptions: {reconnectDelayMs: 100},
190
- gateways: relays.map((relay, index) => ({
191
- gatewayId: at(gatewayIds, index),
192
- endpoint: {relay: {host: "127.0.0.1", port: relay.port, token: "valid-token"}}
193
- }))
194
- })
195
- t.after(async () => {
196
- await set.close()
197
- await rm(root, {recursive: true, force: true})
198
- })
199
- await set.start()
200
- await waitForStatus(set, (status) => status.readyMembers === 3)
201
- const sticky = Array.from({length: 3}, () => set.createConnection({host: "127.0.0.1", port: targetPort}))
202
- await Promise.all(sticky.map((socket) => onceEvent(socket, "connect")))
203
- const recovered = onceEvent(at(sticky, 1), "ready")
204
- await at(relays, 1).closeActiveTransport()
205
- await waitForStatus(set, (status) => at(status.members, 1).state === "reconnecting")
174
+ it("a failed gateway keeps its streams pinned while healthy siblings take new opens and recovery rejoins", async () => withTestCleanup(async (after) => {
175
+ const root = await mkdtemp(join(tmpdir(), "socketduct-set-recovery-"))
176
+ const target = createServer((socket) => socket.on("data", (chunk) => socket.write(chunk)))
177
+ const targetPort = await listen(target)
178
+ after(() => new Promise((resolve) => target.close(() => resolve(undefined))))
179
+ const relays = await Promise.all([0, 1, 2].map(() => startMultiplexedTcpRelay({
180
+ tokens: new StaticTokenStore([{username: "edge", token: "valid-token"}]),
181
+ authorizeTarget: ({target: requested}) => requested.port === targetPort
182
+ })))
183
+ const gatewayIds = ["starlink", "zyxel", "o2gateway"]
184
+ after(async () => {
185
+ await Promise.all(relays.map((relay) => relay.close()))
186
+ })
187
+ const set = new MultiplexedSocketductTransportSet({
188
+ connectionId: "integration_process_recovery_01",
189
+ spoolDirectory: root,
190
+ maxTotalBufferBytes: 32 * 1024 * 1024,
191
+ maxTotalSpoolBytes: 32 * 1024 * 1024,
192
+ maxTotalQueuedBytes: 33 * 1024 * 1024,
193
+ memberOptions: {reconnectDelayMs: 100},
194
+ gateways: relays.map((relay, index) => ({
195
+ gatewayId: at(gatewayIds, index),
196
+ endpoint: {relay: {host: "127.0.0.1", port: relay.port, token: "valid-token"}}
197
+ }))
198
+ })
199
+ after(async () => {
200
+ await set.close()
201
+ await rm(root, {recursive: true, force: true})
202
+ })
203
+ await set.start()
204
+ await waitForStatus(set, (status) => status.readyMembers === 3)
205
+ const sticky = Array.from({length: 3}, () => set.createConnection({host: "127.0.0.1", port: targetPort}))
206
+ await Promise.all(sticky.map((socket) => onceEvent(socket, "connect")))
207
+ const recovered = onceEvent(at(sticky, 1), "ready")
208
+ await at(relays, 1).closeActiveTransport()
209
+ await waitForStatus(set, (status) => at(status.members, 1).state === "reconnecting")
206
210
 
207
- const siblingOpens = Array.from({length: 10}, () => set.createConnection({host: "127.0.0.1", port: targetPort}))
208
- assert.equal(at(set.status().members, 1).assignments, 1)
209
- const siblingCounts = [at(set.status().members, 0).assignments, at(set.status().members, 2).assignments]
210
- assert(Math.max(...siblingCounts) - Math.min(...siblingCounts) <= 1)
211
- await Promise.all(siblingOpens.map((socket) => onceEvent(socket, "connect")))
212
- await recovered
213
- await waitForStatus(set, (status) => status.readyMembers === 3)
214
- const before = at(set.status().members, 1).assignments
215
- const rejoined = Array.from({length: 3}, () => set.createConnection({host: "127.0.0.1", port: targetPort}))
216
- assert(at(set.status().members, 1).assignments > before)
217
- assert.equal(at(sticky, 1).destroyed, false)
218
- assert.equal(at(relays, 1).status().physicalAccepts, 2)
219
- sticky.forEach((socket) => socket.destroy())
220
- siblingOpens.forEach((socket) => socket.destroy())
221
- rejoined.forEach((socket) => socket.destroy())
222
- })
211
+ const siblingOpens = Array.from({length: 10}, () => set.createConnection({host: "127.0.0.1", port: targetPort}))
212
+ expect(at(set.status().members, 1).assignments).toBe(1)
213
+ const siblingCounts = [at(set.status().members, 0).assignments, at(set.status().members, 2).assignments]
214
+ expect(Math.max(...siblingCounts) - Math.min(...siblingCounts) <= 1).toBeTruthy()
215
+ await Promise.all(siblingOpens.map((socket) => onceEvent(socket, "connect")))
216
+ await recovered
217
+ await waitForStatus(set, (status) => status.readyMembers === 3)
218
+ const before = at(set.status().members, 1).assignments
219
+ const rejoined = Array.from({length: 3}, () => set.createConnection({host: "127.0.0.1", port: targetPort}))
220
+ expect(at(set.status().members, 1).assignments > before).toBeTruthy()
221
+ expect(at(sticky, 1).destroyed).toBe(false)
222
+ expect(at(relays, 1).status().physicalAccepts).toBe(2)
223
+ sticky.forEach((socket) => socket.destroy())
224
+ siblingOpens.forEach((socket) => socket.destroy())
225
+ rejoined.forEach((socket) => socket.destroy())
226
+ }))
223
227
 
224
- test("all reconnecting members hold a new socket until one real member recovers", async (t) => {
225
- const root = await mkdtemp(join(tmpdir(), "socketduct-set-all-reconnect-"))
226
- const target = createServer((socket) => socket.on("data", (chunk) => socket.write(chunk)))
227
- const targetPort = await listen(target)
228
- const relays = await Promise.all([0, 1, 2].map(() => startMultiplexedTcpRelay({
229
- tokens: new StaticTokenStore([{username: "edge", token: "valid-token"}]),
230
- authorizeTarget: ({target: requested}) => requested.port === targetPort
231
- })))
232
- const gatewayIds = ["starlink", "zyxel", "o2gateway"]
233
- const set = new MultiplexedSocketductTransportSet({
234
- connectionId: "integration_all_reconnect_01",
235
- spoolDirectory: root,
236
- minimumReadyMembers: 3,
237
- memberOptions: {reconnectDelayMs: 1_000},
238
- gateways: relays.map((relay, index) => ({
239
- gatewayId: at(gatewayIds, index),
240
- endpoint: {relay: {host: "127.0.0.1", port: relay.port, token: "valid-token"}}
241
- }))
242
- })
243
- let siblingRelaysClosed = false
244
- t.after(async () => {
245
- await set.close()
246
- if (!siblingRelaysClosed) await Promise.all(relays.slice(1).map((relay) => relay.close()))
247
- await at(relays, 0).close()
248
- await new Promise((resolve) => target.close(() => resolve(undefined)))
249
- await rm(root, {recursive: true, force: true})
250
- })
251
- await set.start()
252
- await Promise.all(relays.map((relay) => relay.closeActiveTransport()))
253
- await waitForStatus(set, (status) => status.readyMembers === 0)
254
- await Promise.all(relays.slice(1).map((relay) => relay.close()))
255
- siblingRelaysClosed = true
228
+ it("all reconnecting members hold a new socket until one real member recovers", async () => withTestCleanup(async (after) => {
229
+ const root = await mkdtemp(join(tmpdir(), "socketduct-set-all-reconnect-"))
230
+ const target = createServer((socket) => socket.on("data", (chunk) => socket.write(chunk)))
231
+ const targetPort = await listen(target)
232
+ const relays = await Promise.all([0, 1, 2].map(() => startMultiplexedTcpRelay({
233
+ tokens: new StaticTokenStore([{username: "edge", token: "valid-token"}]),
234
+ authorizeTarget: ({target: requested}) => requested.port === targetPort
235
+ })))
236
+ const gatewayIds = ["starlink", "zyxel", "o2gateway"]
237
+ const set = new MultiplexedSocketductTransportSet({
238
+ connectionId: "integration_all_reconnect_01",
239
+ spoolDirectory: root,
240
+ minimumReadyMembers: 3,
241
+ memberOptions: {reconnectDelayMs: 1_000},
242
+ gateways: relays.map((relay, index) => ({
243
+ gatewayId: at(gatewayIds, index),
244
+ endpoint: {relay: {host: "127.0.0.1", port: relay.port, token: "valid-token"}}
245
+ }))
246
+ })
247
+ let siblingRelaysClosed = false
248
+ after(async () => {
249
+ await set.close()
250
+ if (!siblingRelaysClosed) await Promise.all(relays.slice(1).map((relay) => relay.close()))
251
+ await at(relays, 0).close()
252
+ await new Promise((resolve) => target.close(() => resolve(undefined)))
253
+ await rm(root, {recursive: true, force: true})
254
+ })
255
+ await set.start()
256
+ await Promise.all(relays.map((relay) => relay.closeActiveTransport()))
257
+ await waitForStatus(set, (status) => status.readyMembers === 0)
258
+ await Promise.all(relays.slice(1).map((relay) => relay.close()))
259
+ siblingRelaysClosed = true
256
260
 
257
- const socket = set.createConnection({host: "127.0.0.1", port: targetPort})
258
- assert.equal(socket.gatewayId, undefined)
259
- assert.equal(set.status().pendingOpens, 1)
260
- await onceEvent(socket, "connect")
261
- assert.equal(socket.gatewayId, "starlink")
262
- assert.equal(set.status().pendingOpens, 0)
263
- assert.deepEqual(set.status().members.map((member) => member.assignments), [1, 0, 0])
264
- socket.destroy()
265
- })
261
+ const socket = set.createConnection({host: "127.0.0.1", port: targetPort})
262
+ expect(socket.gatewayId).toBeUndefined()
263
+ expect(set.status().pendingOpens).toBe(1)
264
+ await onceEvent(socket, "connect")
265
+ expect(socket.gatewayId).toBe("starlink")
266
+ expect(set.status().pendingOpens).toBe(0)
267
+ expect(set.status().members.map((member) => member.assignments)).toEqual([1, 0, 0])
268
+ socket.destroy()
269
+ }))
266
270
 
267
- test("terminal authentication failure rejects start and the pending socket once", async (t) => {
268
- const root = await mkdtemp(join(tmpdir(), "socketduct-set-start-failure-"))
269
- const relay = await startMultiplexedTcpRelay({
270
- tokens: new StaticTokenStore([{username: "edge", token: "valid-token"}]),
271
- authorizeTarget: () => true
272
- })
273
- const set = new MultiplexedSocketductTransportSet({
274
- connectionId: "integration_start_failure_01",
275
- spoolDirectory: root,
276
- gateways: [{gatewayId: "starlink", endpoint: {relay: {host: "127.0.0.1", port: relay.port, token: "invalid-token"}}}]
277
- })
278
- t.after(async () => {
279
- await set.close()
280
- await relay.close()
281
- await rm(root, {recursive: true, force: true})
282
- })
283
- const started = set.start()
284
- let callbacks = 0
285
- const callbackResult = new Promise((resolve) => {
286
- set.createConnection({host: "target.internal", port: 443}, (error, socket) => {
287
- callbacks += 1
288
- resolve({error, socket})
271
+ it("terminal authentication failure rejects start and the pending socket once", async () => withTestCleanup(async (after) => {
272
+ const root = await mkdtemp(join(tmpdir(), "socketduct-set-start-failure-"))
273
+ const relay = await startMultiplexedTcpRelay({
274
+ tokens: new StaticTokenStore([{username: "edge", token: "valid-token"}]),
275
+ authorizeTarget: () => true
289
276
  })
290
- })
291
- await assert.rejects(started, (error) => error instanceof AggregateError)
292
- const result = /** @type {{error: Error, socket: import('../../src/client/virtual-socket.js').VirtualSocket}} */ (await callbackResult)
293
- assert(result.error instanceof AggregateError)
294
- assert.equal(callbacks, 1)
295
- assert.equal(result.socket.destroyed, true)
296
- assert.equal(set.status().state, "failed")
297
- assert.equal(relay.status().virtualStreams, 0)
298
- })
299
-
300
- test("createTlsConnection preserves callback ownership on the selected sticky member", async (t) => {
301
- const root = await mkdtemp(join(tmpdir(), "socketduct-set-tls-"))
302
- const certs = generateTestCertificates(join(root, "certs"))
303
- const target = createTlsServer({
304
- cert: readFileSync(certs.serverCertFile),
305
- key: readFileSync(certs.serverKeyFile)
306
- }, (socket) => socket.on("data", (chunk) => socket.write(chunk)))
307
- const targetPort = await listen(target)
308
- const relay = await startMultiplexedTcpRelay({
309
- tokens: new StaticTokenStore([{username: "edge", token: "valid-token"}]),
310
- authorizeTarget: ({target: requested}) => requested.host === "127.0.0.1" && requested.port === targetPort
311
- })
312
- const set = new MultiplexedSocketductTransportSet({
313
- connectionId: "integration_process_tls_01",
314
- spoolDirectory: root,
315
- gateways: [{gatewayId: "starlink", endpoint: {relay: {host: "127.0.0.1", port: relay.port, token: "valid-token"}}}]
316
- })
317
- t.after(async () => {
318
- await set.close()
319
- await relay.close()
320
- await new Promise((resolve) => target.close(() => resolve(undefined)))
321
- await rm(root, {recursive: true, force: true})
322
- })
323
- const started = set.start()
324
- let callbacks = 0
325
- const secure = /** @type {import('node:tls').TLSSocket & {readonly gatewayId: string | undefined}} */ (await new Promise((resolve, reject) => {
326
- const returned = set.createTlsConnection({
327
- host: "127.0.0.1",
328
- port: targetPort,
329
- servername: "localhost",
330
- ca: readFileSync(certs.caCertFile)
331
- }, (error, socket) => {
332
- callbacks += 1
333
- if (error === null) resolve(socket)
334
- else reject(error)
277
+ const set = new MultiplexedSocketductTransportSet({
278
+ connectionId: "integration_start_failure_01",
279
+ spoolDirectory: root,
280
+ gateways: [{gatewayId: "starlink", endpoint: {relay: {host: "127.0.0.1", port: relay.port, token: "invalid-token"}}}]
281
+ })
282
+ after(async () => {
283
+ await set.close()
284
+ await relay.close()
285
+ await rm(root, {recursive: true, force: true})
335
286
  })
336
- assert(returned !== undefined)
337
- assert.equal(returned.gatewayId, undefined)
287
+ const started = set.start()
288
+ let callbacks = 0
289
+ const callbackResult = new Promise((resolve) => {
290
+ set.createConnection({host: "target.internal", port: 443}, (error, socket) => {
291
+ callbacks += 1
292
+ resolve({error, socket})
293
+ })
294
+ })
295
+ let error
296
+ try {
297
+ await started
298
+ } catch (caught) {
299
+ error = caught
300
+ }
301
+ expect(error).toBeDefined()
302
+ expect(error).toBeInstanceOf(AggregateError)
303
+ const result = /** @type {{error: Error, socket: import('../../src/client/virtual-socket.js').VirtualSocket}} */ (await callbackResult)
304
+ expect(result.error).toBeInstanceOf(AggregateError)
305
+ expect(callbacks).toBe(1)
306
+ expect(result.socket.destroyed).toBe(true)
307
+ expect(set.status().state).toBe("failed")
308
+ expect(relay.status().virtualStreams).toBe(0)
338
309
  }))
339
- await started
340
- assert.equal(callbacks, 1)
341
- assert.equal(secure.gatewayId, "starlink")
342
- const response = new Promise((resolve, reject) => {
343
- secure.once("data", (chunk) => resolve(Buffer.from(chunk)))
344
- secure.once("error", reject)
345
- })
346
- secure.write("tls-over-set")
347
- const chunk = /** @type {Buffer} */ (await response)
348
- assert.equal(chunk.toString(), "tls-over-set")
349
- assert.equal(at(set.status().members, 0).assignments, 1)
350
- secure.destroy()
351
- })
352
310
 
353
- test("process replacement recovers a durable stream on its original named gateway before start", async (t) => {
354
- const root = await mkdtemp(join(tmpdir(), "socketduct-set-process-recovery-"))
355
- let targetAccepts = 0
356
- let received = Buffer.alloc(0)
357
- /** @type {() => void} */
358
- let resolveReceived = () => {}
359
- const receivedAll = new Promise((resolve) => { resolveReceived = () => resolve(undefined) })
360
- const target = createServer((socket) => {
361
- targetAccepts += 1
362
- socket.on("data", (chunk) => {
363
- received = Buffer.concat([received, chunk])
364
- if (received.length === 32) resolveReceived()
311
+ it("createTlsConnection preserves callback ownership on the selected sticky member", async () => withTestCleanup(async (after) => {
312
+ const root = await mkdtemp(join(tmpdir(), "socketduct-set-tls-"))
313
+ const certs = generateTestCertificates(join(root, "certs"))
314
+ const target = createTlsServer({
315
+ cert: readFileSync(certs.serverCertFile),
316
+ key: readFileSync(certs.serverKeyFile)
317
+ }, (socket) => socket.on("data", (chunk) => socket.write(chunk)))
318
+ const targetPort = await listen(target)
319
+ const relay = await startMultiplexedTcpRelay({
320
+ tokens: new StaticTokenStore([{username: "edge", token: "valid-token"}]),
321
+ authorizeTarget: ({target: requested}) => requested.host === "127.0.0.1" && requested.port === targetPort
365
322
  })
366
- })
367
- const targetPort = await listen(target)
368
- const relays = await Promise.all([0, 1, 2].map(() => startMultiplexedTcpRelay({
369
- tokens: new StaticTokenStore([{username: "edge", token: "valid-token"}]),
370
- authorizeTarget: ({target: requested}) => requested.host === "127.0.0.1" && requested.port === targetPort,
371
- initialWindowBytes: 32,
372
- maxFrameBytes: 32,
373
- resumeWindowMs: 2_000
374
- })))
375
- const fault = await createFirstDataDropProxy(at(relays, 0).port)
376
- const gatewayIds = ["starlink", "zyxel", "o2gateway"]
377
- const physicalCreates = [0, 0, 0]
378
- const physicalFactories = relays.map((_relay, index) => (/** @type {{host: string, port: number}} */ options) => {
379
- physicalCreates[index] = at(physicalCreates, index) + 1
380
- return createConnection(options)
381
- })
382
- const setOptions = {
383
- connectionId: "replacement_process_identity_01",
384
- spoolDirectory: root,
385
- minimumReadyMembers: 3,
386
- maxTotalBufferBytes: 128,
387
- maxTotalSpoolBytes: 128,
388
- maxTotalQueuedBytes: 1024,
389
- memberOptions: {initialWindowBytes: 32, maxFrameBytes: 32, reconnectDelayMs: 1_000},
390
- gateways: relays.map((relay, index) => ({
391
- gatewayId: at(gatewayIds, index),
392
- endpoint: {
393
- relay: {host: "127.0.0.1", port: index === 0 ? fault.port : relay.port, token: "valid-token"},
394
- createPhysicalSocket: at(physicalFactories, index)
395
- }
323
+ const set = new MultiplexedSocketductTransportSet({
324
+ connectionId: "integration_process_tls_01",
325
+ spoolDirectory: root,
326
+ gateways: [{gatewayId: "starlink", endpoint: {relay: {host: "127.0.0.1", port: relay.port, token: "valid-token"}}}]
327
+ })
328
+ after(async () => {
329
+ await set.close()
330
+ await relay.close()
331
+ await new Promise((resolve) => target.close(() => resolve(undefined)))
332
+ await rm(root, {recursive: true, force: true})
333
+ })
334
+ const started = set.start()
335
+ let callbacks = 0
336
+ const secure = /** @type {import('node:tls').TLSSocket & {readonly gatewayId: string | undefined}} */ (await new Promise((resolve, reject) => {
337
+ const returned = set.createTlsConnection({
338
+ host: "127.0.0.1",
339
+ port: targetPort,
340
+ servername: "localhost",
341
+ ca: readFileSync(certs.caCertFile)
342
+ }, (error, socket) => {
343
+ callbacks += 1
344
+ if (error === null) resolve(socket)
345
+ else reject(error)
346
+ })
347
+ if (returned === undefined) throw new Error("Expected createTlsConnection to return a socket")
348
+ expect(returned.gatewayId).toBeUndefined()
396
349
  }))
397
- }
398
- /** @type {MultiplexedSocketductTransportSet | undefined} */
399
- let replacement
400
- t.after(async () => {
401
- await replacement?.close()
402
- await Promise.all(relays.map((relay) => relay.close()))
403
- await fault.close()
404
- await new Promise((resolve) => target.close(() => resolve(undefined)))
405
- await rm(root, {recursive: true, force: true})
406
- })
350
+ await started
351
+ expect(callbacks).toBe(1)
352
+ expect(secure.gatewayId).toBe("starlink")
353
+ const response = new Promise((resolve, reject) => {
354
+ secure.once("data", (chunk) => resolve(Buffer.from(chunk)))
355
+ secure.once("error", reject)
356
+ })
357
+ secure.write("tls-over-set")
358
+ const chunk = /** @type {Buffer} */ (await response)
359
+ expect(chunk.toString()).toBe("tls-over-set")
360
+ expect(at(set.status().members, 0).assignments).toBe(1)
361
+ secure.destroy()
362
+ }))
407
363
 
408
- const original = new MultiplexedSocketductTransportSet(setOptions)
409
- await original.start()
410
- assert.deepEqual(physicalCreates, [1, 1, 1])
411
- const initial = original.createConnection({
412
- host: "127.0.0.1",
413
- port: targetPort,
414
- identifier: "gateway_bound_stream_01",
415
- sessionName: "gateway-bound"
416
- })
417
- await onceEvent(initial, "connect")
418
- await new Promise((resolve, reject) => initial.write(Buffer.alloc(32, 0x72), (error) => error ? reject(error) : resolve(undefined)))
419
- await fault.dropped
420
- await original.detach()
364
+ it("process replacement recovers a durable stream on its original named gateway before start", async () => withTestCleanup(async (after) => {
365
+ const root = await mkdtemp(join(tmpdir(), "socketduct-set-process-recovery-"))
366
+ let targetAccepts = 0
367
+ let received = Buffer.alloc(0)
368
+ /** @type {() => void} */
369
+ let resolveReceived = () => {}
370
+ const receivedAll = new Promise((resolve) => { resolveReceived = () => resolve(undefined) })
371
+ const target = createServer((socket) => {
372
+ targetAccepts += 1
373
+ socket.on("data", (chunk) => {
374
+ received = Buffer.concat([received, chunk])
375
+ if (received.length === 32) resolveReceived()
376
+ })
377
+ })
378
+ const targetPort = await listen(target)
379
+ const relays = await Promise.all([0, 1, 2].map(() => startMultiplexedTcpRelay({
380
+ tokens: new StaticTokenStore([{username: "edge", token: "valid-token"}]),
381
+ authorizeTarget: ({target: requested}) => requested.host === "127.0.0.1" && requested.port === targetPort,
382
+ initialWindowBytes: 32,
383
+ maxFrameBytes: 32,
384
+ resumeWindowMs: 2_000
385
+ })))
386
+ const fault = await createFirstDataDropProxy(at(relays, 0).port)
387
+ const gatewayIds = ["starlink", "zyxel", "o2gateway"]
388
+ const physicalCreates = [0, 0, 0]
389
+ const physicalFactories = relays.map((_relay, index) => (/** @type {{host: string, port: number}} */ options) => {
390
+ physicalCreates[index] = at(physicalCreates, index) + 1
391
+ return createConnection(options)
392
+ })
393
+ const setOptions = {
394
+ connectionId: "replacement_process_identity_01",
395
+ spoolDirectory: root,
396
+ minimumReadyMembers: 3,
397
+ maxTotalBufferBytes: 128,
398
+ maxTotalSpoolBytes: 128,
399
+ maxTotalQueuedBytes: 1024,
400
+ memberOptions: {initialWindowBytes: 32, maxFrameBytes: 32, reconnectDelayMs: 1_000},
401
+ gateways: relays.map((relay, index) => ({
402
+ gatewayId: at(gatewayIds, index),
403
+ endpoint: {
404
+ relay: {host: "127.0.0.1", port: index === 0 ? fault.port : relay.port, token: "valid-token"},
405
+ createPhysicalSocket: at(physicalFactories, index)
406
+ }
407
+ }))
408
+ }
409
+ /** @type {MultiplexedSocketductTransportSet | undefined} */
410
+ let replacement
411
+ after(async () => {
412
+ await replacement?.close()
413
+ await Promise.all(relays.map((relay) => relay.close()))
414
+ await fault.close()
415
+ await new Promise((resolve) => target.close(() => resolve(undefined)))
416
+ await rm(root, {recursive: true, force: true})
417
+ })
421
418
 
422
- const originalReadChunksAfter = SessionSpool.prototype.readChunksAfter
423
- /** @type {() => void} */
424
- let resolveRecoveryRead = () => {}
425
- const recoveryRead = new Promise((resolve) => { resolveRecoveryRead = () => resolve(undefined) })
426
- SessionSpool.prototype.readChunksAfter = async function (/** @type {string} */ identifier, /** @type {number} */ sequence) {
427
- const chunks = await originalReadChunksAfter.call(this, identifier, sequence)
428
- if (identifier === "gateway_bound_stream_01") resolveRecoveryRead()
429
- return chunks
430
- }
431
- t.after(() => { SessionSpool.prototype.readChunksAfter = originalReadChunksAfter })
432
- replacement = new MultiplexedSocketductTransportSet(setOptions)
433
- const recovered = replacement.recoverConnection({
434
- identifier: "gateway_bound_stream_01"
435
- })
436
- await recoveryRead
437
- await new Promise((resolve) => setImmediate(resolve))
438
- assert.deepEqual(physicalCreates, [1, 1, 1])
439
- assert.equal(fault.accepts(), 1)
440
- assert.deepEqual(replacement.status().members.map((member) => member.assignments), [1, 0, 0])
441
- const connected = onceEvent(recovered, "connect")
442
- await replacement.start()
443
- await connected
444
- assert.deepEqual(physicalCreates, [2, 2, 2])
445
- await receivedAll
419
+ const original = new MultiplexedSocketductTransportSet(setOptions)
420
+ await original.start()
421
+ expect(physicalCreates).toEqual([1, 1, 1])
422
+ const initial = original.createConnection({
423
+ host: "127.0.0.1",
424
+ port: targetPort,
425
+ identifier: "gateway_bound_stream_01",
426
+ sessionName: "gateway-bound"
427
+ })
428
+ await onceEvent(initial, "connect")
429
+ await new Promise((resolve, reject) => initial.write(Buffer.alloc(32, 0x72), (error) => error ? reject(error) : resolve(undefined)))
430
+ await fault.dropped
431
+ await original.detach()
446
432
 
447
- assert.equal(received.toString(), "r".repeat(32))
448
- assert.equal(targetAccepts, 1)
449
- assert.equal(fault.accepts(), 2)
450
- assert.deepEqual(relays.slice(1).map((relay) => relay.status().virtualStreams), [0, 0])
451
- recovered.destroy()
433
+ const originalReadChunksAfter = SessionSpool.prototype.readChunksAfter
434
+ /** @type {() => void} */
435
+ let resolveRecoveryRead = () => {}
436
+ const recoveryRead = new Promise((resolve) => { resolveRecoveryRead = () => resolve(undefined) })
437
+ SessionSpool.prototype.readChunksAfter = async function (/** @type {string} */ identifier, /** @type {number} */ sequence) {
438
+ const chunks = await originalReadChunksAfter.call(this, identifier, sequence)
439
+ if (identifier === "gateway_bound_stream_01") resolveRecoveryRead()
440
+ return chunks
441
+ }
442
+ after(() => { SessionSpool.prototype.readChunksAfter = originalReadChunksAfter })
443
+ replacement = new MultiplexedSocketductTransportSet(setOptions)
444
+ const recovered = replacement.recoverConnection({
445
+ identifier: "gateway_bound_stream_01"
446
+ })
447
+ await recoveryRead
448
+ await new Promise((resolve) => setImmediate(resolve))
449
+ expect(physicalCreates).toEqual([1, 1, 1])
450
+ expect(fault.accepts()).toBe(1)
451
+ expect(replacement.status().members.map((member) => member.assignments)).toEqual([1, 0, 0])
452
+ const connected = onceEvent(recovered, "connect")
453
+ await replacement.start()
454
+ await connected
455
+ expect(physicalCreates).toEqual([2, 2, 2])
456
+ await receivedAll
457
+
458
+ expect(received.toString()).toBe("r".repeat(32))
459
+ expect(targetAccepts).toBe(1)
460
+ expect(fault.accepts()).toBe(2)
461
+ expect(relays.slice(1).map((relay) => relay.status().virtualStreams)).toEqual([0, 0])
462
+ recovered.destroy()
463
+ }))
452
464
  })