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,4 +1,4 @@
1
- import assert from "node:assert/strict"
1
+ import {describe, expect, it} from "@velocious/testing"
2
2
  import {connect as tlsConnect} from "node:tls"
3
3
  import {createConnection, createServer} from "node:net"
4
4
  import {mkdtemp, rm} from "node:fs/promises"
@@ -6,7 +6,6 @@ import {mkdtempSync} from "node:fs"
6
6
  import {tmpdir} from "node:os"
7
7
  import {join} from "node:path"
8
8
  import {readFileSync} from "node:fs"
9
- import test from "node:test"
10
9
 
11
10
  import {InMemoryUserStore, login, TokenStore} from "../../src/auth/user-store.js"
12
11
  import {SocketductSocket, connectSocketduct} from "../../src/client/socketduct-socket.js"
@@ -60,689 +59,691 @@ async function waitConnect(socket) {
60
59
  })
61
60
  }
62
61
 
63
- test("TLS relay forwards traffic and edge replays after reconnect", async () => {
64
- const certDir = mkdtempSync(join(tmpdir(), "socketduct-tls-forwarding-"))
65
- const certs = generateTestCertificates(certDir)
62
+ describe("tls forwarding", () => {
63
+ it("TLS relay forwards traffic and edge replays after reconnect", async () => {
64
+ const certDir = mkdtempSync(join(tmpdir(), "socketduct-tls-forwarding-"))
65
+ const certs = generateTestCertificates(certDir)
66
66
 
67
- const tempDir = await mkdtemp(join(tmpdir(), "socketduct-tls-edge-spool-"))
68
- const echoServer = createServer((socket) => {
69
- socket.on("data", (chunk) => socket.write(chunk))
70
- })
71
- const targetPort = await listen(echoServer)
72
-
73
- const users = new InMemoryUserStore()
74
- const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
75
- await users.createUser({username: "kasper", password: "correct horse battery staple"})
76
- const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
77
-
78
- const relay = await startTcpRelay({
79
- authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
80
- tokens,
81
- sessions: new SessionStore({now: () => 1000}),
82
- tls: {certFile: certs.serverCertFile, keyFile: certs.serverKeyFile}
83
- })
67
+ const tempDir = await mkdtemp(join(tmpdir(), "socketduct-tls-edge-spool-"))
68
+ const echoServer = createServer((socket) => {
69
+ socket.on("data", (chunk) => socket.write(chunk))
70
+ })
71
+ const targetPort = await listen(echoServer)
72
+
73
+ const users = new InMemoryUserStore()
74
+ const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
75
+ await users.createUser({username: "kasper", password: "correct horse battery staple"})
76
+ const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
77
+
78
+ const relay = await startTcpRelay({
79
+ authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
80
+ tokens,
81
+ sessions: new SessionStore({now: () => 1000}),
82
+ tls: {certFile: certs.serverCertFile, keyFile: certs.serverKeyFile}
83
+ })
84
84
 
85
- const tlsSocket = tlsConnect({
86
- host: "127.0.0.1",
87
- port: relay.port,
88
- ca: readFileSync(certs.caCertFile),
89
- rejectUnauthorized: true
90
- })
85
+ const tlsSocket = tlsConnect({
86
+ host: "127.0.0.1",
87
+ port: relay.port,
88
+ ca: readFileSync(certs.caCertFile),
89
+ rejectUnauthorized: true
90
+ })
91
91
 
92
- const decoder = new FrameDecoder({compactDirection: "target-to-client"})
93
- await waitConnect(tlsSocket)
92
+ const decoder = new FrameDecoder({compactDirection: "target-to-client"})
93
+ await waitConnect(tlsSocket)
94
94
 
95
- const createdPromise = new Promise((resolve) => {
96
- tlsSocket.on("data", (chunk) => {
97
- for (const frame of decoder.push(chunk)) {
98
- if (frame.header.type === "session.created") {
99
- resolve(frame.header)
95
+ const createdPromise = new Promise((resolve) => {
96
+ tlsSocket.on("data", (chunk) => {
97
+ for (const frame of decoder.push(chunk)) {
98
+ if (frame.header.type === "session.created") {
99
+ resolve(frame.header)
100
+ }
100
101
  }
101
- }
102
+ })
102
103
  })
103
- })
104
104
 
105
- tlsSocket.write(encodeFrame({
106
- type: "session.create",
107
- token: token.value,
108
- name: "tls-forward-session",
109
- identifier: "tls-forward-session",
110
- target: {host: "127.0.0.1", port: targetPort},
111
- framing: [2]
112
- }))
113
-
114
- const created = await createdPromise
115
- assert.equal(/** @type {{type: string}} */ (created).type, "session.created")
116
-
117
- const echoPromise = new Promise((resolve) => {
118
- tlsSocket.on("data", (chunk) => {
119
- for (const frame of decoder.push(chunk)) {
120
- if (frame.header.type === "stream.data" && frame.header.direction === "target-to-client") {
121
- resolve(frame.payload.toString("utf8"))
105
+ tlsSocket.write(encodeFrame({
106
+ type: "session.create",
107
+ token: token.value,
108
+ name: "tls-forward-session",
109
+ identifier: "tls-forward-session",
110
+ target: {host: "127.0.0.1", port: targetPort},
111
+ framing: [2]
112
+ }))
113
+
114
+ const created = await createdPromise
115
+ expect(/** @type {{type: string}} */ (created).type).toBe("session.created")
116
+
117
+ const echoPromise = new Promise((resolve) => {
118
+ tlsSocket.on("data", (chunk) => {
119
+ for (const frame of decoder.push(chunk)) {
120
+ if (frame.header.type === "stream.data" && frame.header.direction === "target-to-client") {
121
+ resolve(frame.payload.toString("utf8"))
122
+ }
122
123
  }
123
- }
124
+ })
124
125
  })
125
- })
126
126
 
127
- tlsSocket.write(encodeCompactData({sequence: 8, payload: Buffer.from("tls-echo")}))
127
+ tlsSocket.write(encodeCompactData({sequence: 8, payload: Buffer.from("tls-echo")}))
128
128
 
129
- const echoed = await echoPromise
130
- assert.equal(echoed, "tls-echo")
129
+ const echoed = await echoPromise
130
+ expect(echoed).toBe("tls-echo")
131
131
 
132
- tlsSocket.destroy()
133
- await relay.close()
134
- await new Promise((resolve, reject) => {
135
- echoServer.close((error) => {
136
- if (error) {
137
- reject(error)
138
- return
139
- }
140
- resolve(undefined)
132
+ tlsSocket.destroy()
133
+ await relay.close()
134
+ await new Promise((resolve, reject) => {
135
+ echoServer.close((error) => {
136
+ if (error) {
137
+ reject(error)
138
+ return
139
+ }
140
+ resolve(undefined)
141
+ })
141
142
  })
142
- })
143
- await rm(tempDir, {recursive: true, force: true})
144
- await rm(certDir, {recursive: true, force: true})
145
- })
146
-
147
- test("TLS relay with mTLS rejects client without valid certificate (handshake completes, server closes)", async () => {
148
- const certDir = mkdtempSync(join(tmpdir(), "socketduct-mtls-reject-"))
149
- const certs = generateTestCertificates(certDir)
150
-
151
- const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
152
- const relay = await startTcpRelay({
153
- authorizeTarget: () => true,
154
- tokens,
155
- sessions: new SessionStore({now: () => 1000}),
156
- tls: {certFile: certs.serverCertFile, keyFile: certs.serverKeyFile, clientCaFile: certs.caCertFile}
143
+ await rm(tempDir, {recursive: true, force: true})
144
+ await rm(certDir, {recursive: true, force: true})
157
145
  })
158
146
 
159
- const unauthorizedSocket = tlsConnect({
160
- host: "127.0.0.1",
161
- port: relay.port,
162
- ca: readFileSync(certs.caCertFile),
163
- rejectUnauthorized: false
164
- })
165
-
166
- let dataReceived = false
167
- unauthorizedSocket.on("data", () => { dataReceived = true })
147
+ it("TLS relay with mTLS rejects client without valid certificate (handshake completes, server closes)", async () => {
148
+ const certDir = mkdtempSync(join(tmpdir(), "socketduct-mtls-reject-"))
149
+ const certs = generateTestCertificates(certDir)
168
150
 
169
- await new Promise((resolve) => {
170
- unauthorizedSocket.once("error", () => resolve("error"))
171
- unauthorizedSocket.once("close", () => resolve("close"))
172
- })
151
+ const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
152
+ const relay = await startTcpRelay({
153
+ authorizeTarget: () => true,
154
+ tokens,
155
+ sessions: new SessionStore({now: () => 1000}),
156
+ tls: {certFile: certs.serverCertFile, keyFile: certs.serverKeyFile, clientCaFile: certs.caCertFile}
157
+ })
173
158
 
174
- assert.equal(dataReceived, false, "client without mTLS certificate must not receive data from the relay")
159
+ const unauthorizedSocket = tlsConnect({
160
+ host: "127.0.0.1",
161
+ port: relay.port,
162
+ ca: readFileSync(certs.caCertFile),
163
+ rejectUnauthorized: false
164
+ })
175
165
 
176
- unauthorizedSocket.destroy()
177
- await relay.close()
178
- await rm(certDir, {recursive: true, force: true})
179
- })
166
+ let dataReceived = false
167
+ unauthorizedSocket.on("data", () => { dataReceived = true })
180
168
 
181
- test("TLS relay with mTLS accepts client with valid certificate", async () => {
182
- const certDir = mkdtempSync(join(tmpdir(), "socketduct-mtls-accept-"))
183
- const certs = generateTestCertificates(certDir)
169
+ await new Promise((resolve) => {
170
+ unauthorizedSocket.once("error", () => resolve("error"))
171
+ unauthorizedSocket.once("close", () => resolve("close"))
172
+ })
184
173
 
185
- const echoServer = createServer((socket) => {
186
- socket.on("data", (chunk) => socket.write(chunk))
187
- })
188
- const targetPort = await listen(echoServer)
189
-
190
- const users = new InMemoryUserStore()
191
- const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
192
- await users.createUser({username: "kasper", password: "correct horse battery staple"})
193
- const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
194
-
195
- const relay = await startTcpRelay({
196
- authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
197
- tokens,
198
- sessions: new SessionStore({now: () => 1000}),
199
- tls: {certFile: certs.serverCertFile, keyFile: certs.serverKeyFile, clientCaFile: certs.caCertFile}
200
- })
174
+ expect(dataReceived).toBe(false)
201
175
 
202
- const clientSocket = tlsConnect({
203
- host: "127.0.0.1",
204
- port: relay.port,
205
- ca: readFileSync(certs.caCertFile),
206
- cert: readFileSync(certs.clientCertFile),
207
- key: readFileSync(certs.clientKeyFile),
208
- rejectUnauthorized: true
176
+ unauthorizedSocket.destroy()
177
+ await relay.close()
178
+ await rm(certDir, {recursive: true, force: true})
209
179
  })
210
180
 
211
- const decoder = new FrameDecoder()
212
- await waitConnect(clientSocket)
181
+ it("TLS relay with mTLS accepts client with valid certificate", async () => {
182
+ const certDir = mkdtempSync(join(tmpdir(), "socketduct-mtls-accept-"))
183
+ const certs = generateTestCertificates(certDir)
213
184
 
214
- const sessionPromise = new Promise((resolve) => {
215
- clientSocket.on("data", (chunk) => {
216
- for (const frame of decoder.push(chunk)) {
217
- if (frame.header.type === "session.created" || frame.header.type === "error") {
218
- resolve(frame.header)
219
- }
220
- }
185
+ const echoServer = createServer((socket) => {
186
+ socket.on("data", (chunk) => socket.write(chunk))
187
+ })
188
+ const targetPort = await listen(echoServer)
189
+
190
+ const users = new InMemoryUserStore()
191
+ const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
192
+ await users.createUser({username: "kasper", password: "correct horse battery staple"})
193
+ const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
194
+
195
+ const relay = await startTcpRelay({
196
+ authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
197
+ tokens,
198
+ sessions: new SessionStore({now: () => 1000}),
199
+ tls: {certFile: certs.serverCertFile, keyFile: certs.serverKeyFile, clientCaFile: certs.caCertFile}
221
200
  })
222
- })
223
201
 
224
- clientSocket.write(encodeFrame({
225
- type: "session.create",
226
- token: token.value,
227
- name: "mtls-session",
228
- target: {host: "127.0.0.1", port: targetPort},
229
- framing: [2]
230
- }))
202
+ const clientSocket = tlsConnect({
203
+ host: "127.0.0.1",
204
+ port: relay.port,
205
+ ca: readFileSync(certs.caCertFile),
206
+ cert: readFileSync(certs.clientCertFile),
207
+ key: readFileSync(certs.clientKeyFile),
208
+ rejectUnauthorized: true
209
+ })
231
210
 
232
- const response = await sessionPromise
233
- assert.equal(/** @type {{type: string}} */ (response).type, "session.created")
211
+ const decoder = new FrameDecoder()
212
+ await waitConnect(clientSocket)
234
213
 
235
- clientSocket.destroy()
236
- await relay.close()
237
- await new Promise((resolve, reject) => {
238
- echoServer.close((error) => {
239
- if (error) {
240
- reject(error)
241
- return
242
- }
243
- resolve(undefined)
214
+ const sessionPromise = new Promise((resolve) => {
215
+ clientSocket.on("data", (chunk) => {
216
+ for (const frame of decoder.push(chunk)) {
217
+ if (frame.header.type === "session.created" || frame.header.type === "error") {
218
+ resolve(frame.header)
219
+ }
220
+ }
221
+ })
244
222
  })
245
- })
246
- await rm(certDir, {recursive: true, force: true})
247
- })
248
223
 
249
- test("TLS relay with mTLS requires certificate from the configured client CA (handshake completes, server closes)", async () => {
250
- const certDir = mkdtempSync(join(tmpdir(), "socketduct-mtls-wrong-ca-"))
251
- const wrongCertDir = mkdtempSync(join(tmpdir(), "socketduct-mtls-wrong-ca-alt-"))
252
- const certs = generateTestCertificates(certDir)
253
- const wrongCerts = generateTestCertificates(wrongCertDir)
254
-
255
- const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
256
- const relay = await startTcpRelay({
257
- authorizeTarget: () => true,
258
- tokens,
259
- sessions: new SessionStore({now: () => 1000}),
260
- tls: {certFile: certs.serverCertFile, keyFile: certs.serverKeyFile, clientCaFile: certs.caCertFile}
224
+ clientSocket.write(encodeFrame({
225
+ type: "session.create",
226
+ token: token.value,
227
+ name: "mtls-session",
228
+ target: {host: "127.0.0.1", port: targetPort},
229
+ framing: [2]
230
+ }))
231
+
232
+ const response = await sessionPromise
233
+ expect(/** @type {{type: string}} */ (response).type).toBe("session.created")
234
+
235
+ clientSocket.destroy()
236
+ await relay.close()
237
+ await new Promise((resolve, reject) => {
238
+ echoServer.close((error) => {
239
+ if (error) {
240
+ reject(error)
241
+ return
242
+ }
243
+ resolve(undefined)
244
+ })
245
+ })
246
+ await rm(certDir, {recursive: true, force: true})
261
247
  })
262
248
 
263
- const clientSocket = tlsConnect({
264
- host: "127.0.0.1",
265
- port: relay.port,
266
- ca: readFileSync(certs.caCertFile),
267
- cert: readFileSync(wrongCerts.clientCertFile),
268
- key: readFileSync(wrongCerts.clientKeyFile),
269
- rejectUnauthorized: false
270
- })
249
+ it("TLS relay with mTLS requires certificate from the configured client CA (handshake completes, server closes)", async () => {
250
+ const certDir = mkdtempSync(join(tmpdir(), "socketduct-mtls-wrong-ca-"))
251
+ const wrongCertDir = mkdtempSync(join(tmpdir(), "socketduct-mtls-wrong-ca-alt-"))
252
+ const certs = generateTestCertificates(certDir)
253
+ const wrongCerts = generateTestCertificates(wrongCertDir)
271
254
 
272
- let dataReceived = false
273
- clientSocket.on("data", () => { dataReceived = true })
255
+ const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
256
+ const relay = await startTcpRelay({
257
+ authorizeTarget: () => true,
258
+ tokens,
259
+ sessions: new SessionStore({now: () => 1000}),
260
+ tls: {certFile: certs.serverCertFile, keyFile: certs.serverKeyFile, clientCaFile: certs.caCertFile}
261
+ })
274
262
 
275
- await new Promise((resolve) => {
276
- clientSocket.once("error", () => resolve("error"))
277
- clientSocket.once("close", () => resolve("close"))
278
- })
263
+ const clientSocket = tlsConnect({
264
+ host: "127.0.0.1",
265
+ port: relay.port,
266
+ ca: readFileSync(certs.caCertFile),
267
+ cert: readFileSync(wrongCerts.clientCertFile),
268
+ key: readFileSync(wrongCerts.clientKeyFile),
269
+ rejectUnauthorized: false
270
+ })
279
271
 
280
- assert.equal(dataReceived, false, "client with wrong-CA certificate must not receive data from the relay")
272
+ let dataReceived = false
273
+ clientSocket.on("data", () => { dataReceived = true })
281
274
 
282
- clientSocket.destroy()
283
- await relay.close()
284
- await rm(certDir, {recursive: true, force: true})
285
- await rm(wrongCertDir, {recursive: true, force: true})
286
- })
275
+ await new Promise((resolve) => {
276
+ clientSocket.once("error", () => resolve("error"))
277
+ clientSocket.once("close", () => resolve("close"))
278
+ })
287
279
 
288
- test("Plaintext client is rejected by TLS relay (no plaintext protocol reply)", async () => {
289
- const certDir = mkdtempSync(join(tmpdir(), "socketduct-wrong-trust-"))
290
- const certs = generateTestCertificates(certDir)
280
+ expect(dataReceived).toBe(false)
291
281
 
292
- const echoServer = createServer((socket) => {
293
- socket.on("data", (chunk) => socket.write(chunk))
294
- })
295
- const targetPort = await listen(echoServer)
296
-
297
- const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
298
- const relay = await startTcpRelay({
299
- authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
300
- tokens,
301
- sessions: new SessionStore({now: () => 1000}),
302
- tls: {certFile: certs.serverCertFile, keyFile: certs.serverKeyFile}
282
+ clientSocket.destroy()
283
+ await relay.close()
284
+ await rm(certDir, {recursive: true, force: true})
285
+ await rm(wrongCertDir, {recursive: true, force: true})
303
286
  })
304
287
 
305
- const plainSocket = createConnection({host: "127.0.0.1", port: relay.port})
306
- await waitConnect(plainSocket)
307
- plainSocket.write("GET / HTTP/1.1\r\n\r\n")
288
+ it("Plaintext client is rejected by TLS relay (no plaintext protocol reply)", async () => {
289
+ const certDir = mkdtempSync(join(tmpdir(), "socketduct-wrong-trust-"))
290
+ const certs = generateTestCertificates(certDir)
308
291
 
309
- let dataReceived = false
310
- plainSocket.on("data", () => { dataReceived = true })
292
+ const echoServer = createServer((socket) => {
293
+ socket.on("data", (chunk) => socket.write(chunk))
294
+ })
295
+ const targetPort = await listen(echoServer)
296
+
297
+ const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
298
+ const relay = await startTcpRelay({
299
+ authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
300
+ tokens,
301
+ sessions: new SessionStore({now: () => 1000}),
302
+ tls: {certFile: certs.serverCertFile, keyFile: certs.serverKeyFile}
303
+ })
311
304
 
312
- await new Promise((resolve) => {
313
- plainSocket.once("close", () => resolve("close"))
314
- plainSocket.once("error", () => resolve("error"))
315
- })
305
+ const plainSocket = createConnection({host: "127.0.0.1", port: relay.port})
306
+ await waitConnect(plainSocket)
307
+ plainSocket.write("GET / HTTP/1.1\r\n\r\n")
316
308
 
317
- assert.equal(dataReceived, false, "plaintext client must not receive any protocol reply from a TLS relay")
309
+ let dataReceived = false
310
+ plainSocket.on("data", () => { dataReceived = true })
318
311
 
319
- plainSocket.destroy()
320
- await relay.close()
321
- await new Promise((resolve, reject) => {
322
- echoServer.close((error) => {
323
- if (error) {
324
- reject(error)
325
- return
326
- }
327
- resolve(undefined)
312
+ await new Promise((resolve) => {
313
+ plainSocket.once("close", () => resolve("close"))
314
+ plainSocket.once("error", () => resolve("error"))
328
315
  })
329
- })
330
- await rm(certDir, {recursive: true, force: true})
331
- })
332
316
 
333
- test("Edge with TLS reconnects and replays spooled data after relay tunnel drop", async () => {
334
- const certDir = mkdtempSync(join(tmpdir(), "socketduct-edge-tls-replay-"))
335
- const certs = generateTestCertificates(certDir)
317
+ expect(dataReceived).toBe(false)
336
318
 
337
- const tempDir = await mkdtemp(join(tmpdir(), "socketduct-edge-tls-replay-spool-"))
338
- const echoServer = createServer((socket) => {
339
- socket.on("data", (chunk) => socket.write(chunk))
340
- })
341
- const targetPort = await listen(echoServer)
342
-
343
- const users = new InMemoryUserStore()
344
- const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
345
- await users.createUser({username: "kasper", password: "correct horse battery staple"})
346
- const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
347
-
348
- const relay = await startTcpRelay({
349
- authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
350
- tokens,
351
- sessions: new SessionStore({now: () => 1000}),
352
- resumeWindowMs: 1_000,
353
- tls: {certFile: certs.serverCertFile, keyFile: certs.serverKeyFile}
354
- })
355
-
356
- const edge = await startTcpEdge({
357
- relayHost: "127.0.0.1",
358
- relayPort: relay.port,
359
- token: token.value,
360
- sessionName: "edge-tls-replay",
361
- spool: new SessionSpool({rootDir: tempDir}),
362
- reconnectDelayMs: 10,
363
- target: {host: "127.0.0.1", port: targetPort},
364
- tls: {caFile: certs.caCertFile, rejectUnauthorized: true}
319
+ plainSocket.destroy()
320
+ await relay.close()
321
+ await new Promise((resolve, reject) => {
322
+ echoServer.close((error) => {
323
+ if (error) {
324
+ reject(error)
325
+ return
326
+ }
327
+ resolve(undefined)
328
+ })
329
+ })
330
+ await rm(certDir, {recursive: true, force: true})
365
331
  })
366
332
 
367
- const appSocket = createConnection({host: "127.0.0.1", port: edge.port})
368
- await waitConnect(appSocket)
369
- appSocket.write("tls-first")
370
- assert.equal((await readOnce(appSocket)).toString("utf8"), "tls-first")
333
+ it("Edge with TLS reconnects and replays spooled data after relay tunnel drop", async () => {
334
+ const certDir = mkdtempSync(join(tmpdir(), "socketduct-edge-tls-replay-"))
335
+ const certs = generateTestCertificates(certDir)
371
336
 
372
- relay.closeActiveTunnel()
373
- appSocket.write("tls-second")
374
- assert.equal((await readOnce(appSocket)).toString("utf8"), "tls-second")
375
-
376
- appSocket.destroy()
377
- await edge.close()
378
- await relay.close()
379
- await new Promise((resolve, reject) => {
380
- echoServer.close((error) => {
381
- if (error) {
382
- reject(error)
383
- return
384
- }
385
- resolve(undefined)
337
+ const tempDir = await mkdtemp(join(tmpdir(), "socketduct-edge-tls-replay-spool-"))
338
+ const echoServer = createServer((socket) => {
339
+ socket.on("data", (chunk) => socket.write(chunk))
340
+ })
341
+ const targetPort = await listen(echoServer)
342
+
343
+ const users = new InMemoryUserStore()
344
+ const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
345
+ await users.createUser({username: "kasper", password: "correct horse battery staple"})
346
+ const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
347
+
348
+ const relay = await startTcpRelay({
349
+ authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
350
+ tokens,
351
+ sessions: new SessionStore({now: () => 1000}),
352
+ resumeWindowMs: 1_000,
353
+ tls: {certFile: certs.serverCertFile, keyFile: certs.serverKeyFile}
386
354
  })
387
- })
388
- await rm(tempDir, {recursive: true, force: true})
389
- await rm(certDir, {recursive: true, force: true})
390
- })
391
355
 
392
- test("SocketductSocket with valid mTLS client cert is accepted by mTLS relay", async () => {
393
- const certDir = mkdtempSync(join(tmpdir(), "socketduct-mtls-socket-accept-"))
394
- const certs = generateTestCertificates(certDir)
356
+ const edge = await startTcpEdge({
357
+ relayHost: "127.0.0.1",
358
+ relayPort: relay.port,
359
+ token: token.value,
360
+ sessionName: "edge-tls-replay",
361
+ spool: new SessionSpool({rootDir: tempDir}),
362
+ reconnectDelayMs: 10,
363
+ target: {host: "127.0.0.1", port: targetPort},
364
+ tls: {caFile: certs.caCertFile, rejectUnauthorized: true}
365
+ })
395
366
 
396
- const tempDir = await mkdtemp(join(tmpdir(), "socketduct-mtls-socket-spool-"))
397
- const echoServer = createServer((socket) => {
398
- socket.on("data", (chunk) => socket.write(chunk))
399
- })
400
- const targetPort = await listen(echoServer)
401
-
402
- const users = new InMemoryUserStore()
403
- const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
404
- await users.createUser({username: "kasper", password: "correct horse battery staple"})
405
- const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
406
-
407
- const relay = await startTcpRelay({
408
- authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
409
- tokens,
410
- sessions: new SessionStore({now: () => 1000}),
411
- tls: {certFile: certs.serverCertFile, keyFile: certs.serverKeyFile, clientCaFile: certs.caCertFile}
367
+ const appSocket = createConnection({host: "127.0.0.1", port: edge.port})
368
+ await waitConnect(appSocket)
369
+ appSocket.write("tls-first")
370
+ expect((await readOnce(appSocket)).toString("utf8")).toBe("tls-first")
371
+
372
+ relay.closeActiveTunnel()
373
+ appSocket.write("tls-second")
374
+ expect((await readOnce(appSocket)).toString("utf8")).toBe("tls-second")
375
+
376
+ appSocket.destroy()
377
+ await edge.close()
378
+ await relay.close()
379
+ await new Promise((resolve, reject) => {
380
+ echoServer.close((error) => {
381
+ if (error) {
382
+ reject(error)
383
+ return
384
+ }
385
+ resolve(undefined)
386
+ })
387
+ })
388
+ await rm(tempDir, {recursive: true, force: true})
389
+ await rm(certDir, {recursive: true, force: true})
412
390
  })
413
391
 
414
- const socket = new SocketductSocket({
415
- relayHost: "127.0.0.1",
416
- relayPort: relay.port,
417
- token: token.value,
418
- sessionName: "mtls-socket",
419
- target: {host: "127.0.0.1", port: targetPort},
420
- spool: new SessionSpool({rootDir: tempDir}),
421
- reconnectDelayMs: 50,
422
- tls: {
423
- caFile: certs.caCertFile,
424
- certFile: certs.clientCertFile,
425
- keyFile: certs.clientKeyFile,
426
- rejectUnauthorized: true
427
- }
428
- })
392
+ it("SocketductSocket with valid mTLS client cert is accepted by mTLS relay", async () => {
393
+ const certDir = mkdtempSync(join(tmpdir(), "socketduct-mtls-socket-accept-"))
394
+ const certs = generateTestCertificates(certDir)
429
395
 
430
- socket.connect()
431
- await new Promise((resolve, reject) => {
432
- socket.once("error", reject)
433
- socket.once("connect", () => {
434
- socket.off("error", reject)
435
- resolve(undefined)
396
+ const tempDir = await mkdtemp(join(tmpdir(), "socketduct-mtls-socket-spool-"))
397
+ const echoServer = createServer((socket) => {
398
+ socket.on("data", (chunk) => socket.write(chunk))
399
+ })
400
+ const targetPort = await listen(echoServer)
401
+
402
+ const users = new InMemoryUserStore()
403
+ const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
404
+ await users.createUser({username: "kasper", password: "correct horse battery staple"})
405
+ const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
406
+
407
+ const relay = await startTcpRelay({
408
+ authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
409
+ tokens,
410
+ sessions: new SessionStore({now: () => 1000}),
411
+ tls: {certFile: certs.serverCertFile, keyFile: certs.serverKeyFile, clientCaFile: certs.caCertFile}
436
412
  })
437
- })
438
-
439
- socket.write("mtls-test")
440
- assert.equal((await readOnce(socket)).toString("utf8"), "mtls-test")
441
413
 
442
- socket.destroy()
443
- await relay.close()
444
- await new Promise((resolve, reject) => {
445
- echoServer.close((error) => {
446
- if (error) {
447
- reject(error)
448
- return
414
+ const socket = new SocketductSocket({
415
+ relayHost: "127.0.0.1",
416
+ relayPort: relay.port,
417
+ token: token.value,
418
+ sessionName: "mtls-socket",
419
+ target: {host: "127.0.0.1", port: targetPort},
420
+ spool: new SessionSpool({rootDir: tempDir}),
421
+ reconnectDelayMs: 50,
422
+ tls: {
423
+ caFile: certs.caCertFile,
424
+ certFile: certs.clientCertFile,
425
+ keyFile: certs.clientKeyFile,
426
+ rejectUnauthorized: true
449
427
  }
450
- resolve(undefined)
451
428
  })
452
- })
453
- await rm(tempDir, {recursive: true, force: true})
454
- await rm(certDir, {recursive: true, force: true})
455
- })
456
429
 
457
- test("SocketductSocket without client cert is rejected by mTLS relay (connect never fires)", async () => {
458
- const certDir = mkdtempSync(join(tmpdir(), "socketduct-mtls-no-cert-"))
459
- const certs = generateTestCertificates(certDir)
430
+ socket.connect()
431
+ await new Promise((resolve, reject) => {
432
+ socket.once("error", reject)
433
+ socket.once("connect", () => {
434
+ socket.off("error", reject)
435
+ resolve(undefined)
436
+ })
437
+ })
460
438
 
461
- const tempDir = await mkdtemp(join(tmpdir(), "socketduct-mtls-no-cert-spool-"))
462
- const echoServer = createServer((socket) => {
463
- socket.on("data", (chunk) => socket.write(chunk))
464
- })
465
- const targetPort = await listen(echoServer)
466
-
467
- const users = new InMemoryUserStore()
468
- const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
469
- await users.createUser({username: "kasper", password: "correct horse battery staple"})
470
- const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
471
-
472
- const relay = await startTcpRelay({
473
- authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
474
- tokens,
475
- sessions: new SessionStore({now: () => 1000}),
476
- tls: {certFile: certs.serverCertFile, keyFile: certs.serverKeyFile, clientCaFile: certs.caCertFile}
477
- })
439
+ socket.write("mtls-test")
440
+ expect((await readOnce(socket)).toString("utf8")).toBe("mtls-test")
478
441
 
479
- const socket = new SocketductSocket({
480
- relayHost: "127.0.0.1",
481
- relayPort: relay.port,
482
- token: token.value,
483
- sessionName: "mtls-no-cert",
484
- target: {host: "127.0.0.1", port: targetPort},
485
- spool: new SessionSpool({rootDir: tempDir}),
486
- reconnectDelayMs: 1000,
487
- tls: {
488
- caFile: certs.caCertFile,
489
- rejectUnauthorized: true
490
- }
442
+ socket.destroy()
443
+ await relay.close()
444
+ await new Promise((resolve, reject) => {
445
+ echoServer.close((error) => {
446
+ if (error) {
447
+ reject(error)
448
+ return
449
+ }
450
+ resolve(undefined)
451
+ })
452
+ })
453
+ await rm(tempDir, {recursive: true, force: true})
454
+ await rm(certDir, {recursive: true, force: true})
491
455
  })
492
456
 
493
- socket.connect()
457
+ it("SocketductSocket without client cert is rejected by mTLS relay (connect never fires)", async () => {
458
+ const certDir = mkdtempSync(join(tmpdir(), "socketduct-mtls-no-cert-"))
459
+ const certs = generateTestCertificates(certDir)
494
460
 
495
- let connectFired = false
496
- let dataReceived = false
497
- socket.on("connect", () => { connectFired = true })
498
- socket.on("data", () => { dataReceived = true })
499
-
500
- const error = await new Promise((resolve) => { socket.once("error", resolve) })
501
- assert.ok(error instanceof Error, "expected error when mTLS client cert is missing")
502
- assert.equal(connectFired, false, "connect must not fire when mTLS client cert is missing")
503
- assert.equal(dataReceived, false, "no data must be exchanged without a valid mTLS client cert")
461
+ const tempDir = await mkdtemp(join(tmpdir(), "socketduct-mtls-no-cert-spool-"))
462
+ const echoServer = createServer((socket) => {
463
+ socket.on("data", (chunk) => socket.write(chunk))
464
+ })
465
+ const targetPort = await listen(echoServer)
466
+
467
+ const users = new InMemoryUserStore()
468
+ const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
469
+ await users.createUser({username: "kasper", password: "correct horse battery staple"})
470
+ const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
471
+
472
+ const relay = await startTcpRelay({
473
+ authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
474
+ tokens,
475
+ sessions: new SessionStore({now: () => 1000}),
476
+ tls: {certFile: certs.serverCertFile, keyFile: certs.serverKeyFile, clientCaFile: certs.caCertFile}
477
+ })
504
478
 
505
- socket.destroy()
506
- await relay.close()
507
- await new Promise((resolve, reject) => {
508
- echoServer.close((error) => {
509
- if (error) {
510
- reject(error)
511
- return
479
+ const socket = new SocketductSocket({
480
+ relayHost: "127.0.0.1",
481
+ relayPort: relay.port,
482
+ token: token.value,
483
+ sessionName: "mtls-no-cert",
484
+ target: {host: "127.0.0.1", port: targetPort},
485
+ spool: new SessionSpool({rootDir: tempDir}),
486
+ reconnectDelayMs: 1000,
487
+ tls: {
488
+ caFile: certs.caCertFile,
489
+ rejectUnauthorized: true
512
490
  }
513
- resolve(undefined)
514
491
  })
515
- })
516
- await rm(tempDir, {recursive: true, force: true})
517
- await rm(certDir, {recursive: true, force: true})
518
- })
519
492
 
520
- test("SocketductSocket with wrong server CA is rejected during TLS handshake (connect never fires)", async () => {
521
- const certDir = mkdtempSync(join(tmpdir(), "socketduct-wrong-server-ca-"))
522
- const wrongCertDir = mkdtempSync(join(tmpdir(), "socketduct-wrong-server-ca-alt-"))
523
- const certs = generateTestCertificates(certDir)
524
- const wrongCerts = generateTestCertificates(wrongCertDir)
525
-
526
- const tempDir = await mkdtemp(join(tmpdir(), "socketduct-wrong-server-ca-spool-"))
527
- const echoServer = createServer((socket) => {
528
- socket.on("data", (chunk) => socket.write(chunk))
529
- })
530
- const targetPort = await listen(echoServer)
531
-
532
- const users = new InMemoryUserStore()
533
- const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
534
- await users.createUser({username: "kasper", password: "correct horse battery staple"})
535
- const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
536
-
537
- const relay = await startTcpRelay({
538
- authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
539
- tokens,
540
- sessions: new SessionStore({now: () => 1000}),
541
- tls: {certFile: certs.serverCertFile, keyFile: certs.serverKeyFile}
542
- })
543
-
544
- const socket = connectSocketduct({
545
- relay: {host: "127.0.0.1", port: relay.port, token: token.value},
546
- sessionName: "wrong-server-ca",
547
- target: {host: "127.0.0.1", port: targetPort},
548
- spoolDirectory: tempDir,
549
- reconnect: {initialDelayMs: 1000},
550
- tls: {
551
- caFile: wrongCerts.caCertFile,
552
- rejectUnauthorized: true
553
- }
493
+ socket.connect()
494
+
495
+ let connectFired = false
496
+ let dataReceived = false
497
+ socket.on("connect", () => { connectFired = true })
498
+ socket.on("data", () => { dataReceived = true })
499
+
500
+ const error = await new Promise((resolve) => { socket.once("error", resolve) })
501
+ expect(error instanceof Error).toBeTruthy()
502
+ expect(connectFired).toBe(false)
503
+ expect(dataReceived).toBe(false)
504
+
505
+ socket.destroy()
506
+ await relay.close()
507
+ await new Promise((resolve, reject) => {
508
+ echoServer.close((error) => {
509
+ if (error) {
510
+ reject(error)
511
+ return
512
+ }
513
+ resolve(undefined)
514
+ })
515
+ })
516
+ await rm(tempDir, {recursive: true, force: true})
517
+ await rm(certDir, {recursive: true, force: true})
554
518
  })
555
519
 
556
- let connectFired = false
557
- let dataReceived = false
558
- socket.on("connect", () => { connectFired = true })
559
- socket.on("data", () => { dataReceived = true })
520
+ it("SocketductSocket with wrong server CA is rejected during TLS handshake (connect never fires)", async () => {
521
+ const certDir = mkdtempSync(join(tmpdir(), "socketduct-wrong-server-ca-"))
522
+ const wrongCertDir = mkdtempSync(join(tmpdir(), "socketduct-wrong-server-ca-alt-"))
523
+ const certs = generateTestCertificates(certDir)
524
+ const wrongCerts = generateTestCertificates(wrongCertDir)
560
525
 
561
- const error = await new Promise((resolve) => { socket.once("error", resolve) })
562
- assert.ok(error instanceof Error, "expected error when server CA is untrusted")
563
- assert.equal(connectFired, false, "connect must not fire when server CA is untrusted")
564
- assert.equal(dataReceived, false, "no data must be exchanged with an untrusted server CA")
526
+ const tempDir = await mkdtemp(join(tmpdir(), "socketduct-wrong-server-ca-spool-"))
527
+ const echoServer = createServer((socket) => {
528
+ socket.on("data", (chunk) => socket.write(chunk))
529
+ })
530
+ const targetPort = await listen(echoServer)
531
+
532
+ const users = new InMemoryUserStore()
533
+ const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
534
+ await users.createUser({username: "kasper", password: "correct horse battery staple"})
535
+ const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
536
+
537
+ const relay = await startTcpRelay({
538
+ authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
539
+ tokens,
540
+ sessions: new SessionStore({now: () => 1000}),
541
+ tls: {certFile: certs.serverCertFile, keyFile: certs.serverKeyFile}
542
+ })
565
543
 
566
- socket.destroy()
567
- await relay.close()
568
- await new Promise((resolve, reject) => {
569
- echoServer.close((error) => {
570
- if (error) {
571
- reject(error)
572
- return
544
+ const socket = connectSocketduct({
545
+ relay: {host: "127.0.0.1", port: relay.port, token: token.value},
546
+ sessionName: "wrong-server-ca",
547
+ target: {host: "127.0.0.1", port: targetPort},
548
+ spoolDirectory: tempDir,
549
+ reconnect: {initialDelayMs: 1000},
550
+ tls: {
551
+ caFile: wrongCerts.caCertFile,
552
+ rejectUnauthorized: true
573
553
  }
574
- resolve(undefined)
575
554
  })
576
- })
577
- await rm(tempDir, {recursive: true, force: true})
578
- await rm(certDir, {recursive: true, force: true})
579
- await rm(wrongCertDir, {recursive: true, force: true})
580
- })
581
-
582
- test("TcpEdge with mTLS client cert connects to mTLS relay and forwards data", async () => {
583
- const certDir = mkdtempSync(join(tmpdir(), "socketduct-tcp-edge-mtls-"))
584
- const certs = generateTestCertificates(certDir)
585
555
 
586
- const tempDir = await mkdtemp(join(tmpdir(), "socketduct-tcp-edge-mtls-spool-"))
587
- const echoServer = createServer((socket) => {
588
- socket.on("data", (chunk) => socket.write(chunk))
589
- })
590
- const targetPort = await listen(echoServer)
591
-
592
- const users = new InMemoryUserStore()
593
- const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
594
- await users.createUser({username: "kasper", password: "correct horse battery staple"})
595
- const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
596
-
597
- const relay = await startTcpRelay({
598
- authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
599
- tokens,
600
- sessions: new SessionStore({now: () => 1000}),
601
- tls: {certFile: certs.serverCertFile, keyFile: certs.serverKeyFile, clientCaFile: certs.caCertFile}
556
+ let connectFired = false
557
+ let dataReceived = false
558
+ socket.on("connect", () => { connectFired = true })
559
+ socket.on("data", () => { dataReceived = true })
560
+
561
+ const error = await new Promise((resolve) => { socket.once("error", resolve) })
562
+ expect(error instanceof Error).toBeTruthy()
563
+ expect(connectFired).toBe(false)
564
+ expect(dataReceived).toBe(false)
565
+
566
+ socket.destroy()
567
+ await relay.close()
568
+ await new Promise((resolve, reject) => {
569
+ echoServer.close((error) => {
570
+ if (error) {
571
+ reject(error)
572
+ return
573
+ }
574
+ resolve(undefined)
575
+ })
576
+ })
577
+ await rm(tempDir, {recursive: true, force: true})
578
+ await rm(certDir, {recursive: true, force: true})
579
+ await rm(wrongCertDir, {recursive: true, force: true})
602
580
  })
603
581
 
604
- const edge = await startTcpEdge({
605
- relayHost: "127.0.0.1",
606
- relayPort: relay.port,
607
- token: token.value,
608
- sessionName: "tcp-edge-mtls",
609
- spool: new SessionSpool({rootDir: tempDir}),
610
- target: {host: "127.0.0.1", port: targetPort},
611
- reconnectDelayMs: 50,
612
- tls: {
613
- caFile: certs.caCertFile,
614
- certFile: certs.clientCertFile,
615
- keyFile: certs.clientKeyFile,
616
- rejectUnauthorized: true
617
- }
618
- })
582
+ it("TcpEdge with mTLS client cert connects to mTLS relay and forwards data", async () => {
583
+ const certDir = mkdtempSync(join(tmpdir(), "socketduct-tcp-edge-mtls-"))
584
+ const certs = generateTestCertificates(certDir)
619
585
 
620
- const appSocket = createConnection({host: "127.0.0.1", port: edge.port})
621
- await waitConnect(appSocket)
622
- appSocket.write("tcp-edge-mtls")
623
- assert.equal((await readOnce(appSocket)).toString("utf8"), "tcp-edge-mtls")
586
+ const tempDir = await mkdtemp(join(tmpdir(), "socketduct-tcp-edge-mtls-spool-"))
587
+ const echoServer = createServer((socket) => {
588
+ socket.on("data", (chunk) => socket.write(chunk))
589
+ })
590
+ const targetPort = await listen(echoServer)
591
+
592
+ const users = new InMemoryUserStore()
593
+ const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
594
+ await users.createUser({username: "kasper", password: "correct horse battery staple"})
595
+ const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
596
+
597
+ const relay = await startTcpRelay({
598
+ authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
599
+ tokens,
600
+ sessions: new SessionStore({now: () => 1000}),
601
+ tls: {certFile: certs.serverCertFile, keyFile: certs.serverKeyFile, clientCaFile: certs.caCertFile}
602
+ })
624
603
 
625
- appSocket.destroy()
626
- await edge.close()
627
- await relay.close()
628
- await new Promise((resolve, reject) => {
629
- echoServer.close((error) => {
630
- if (error) {
631
- reject(error)
632
- return
604
+ const edge = await startTcpEdge({
605
+ relayHost: "127.0.0.1",
606
+ relayPort: relay.port,
607
+ token: token.value,
608
+ sessionName: "tcp-edge-mtls",
609
+ spool: new SessionSpool({rootDir: tempDir}),
610
+ target: {host: "127.0.0.1", port: targetPort},
611
+ reconnectDelayMs: 50,
612
+ tls: {
613
+ caFile: certs.caCertFile,
614
+ certFile: certs.clientCertFile,
615
+ keyFile: certs.clientKeyFile,
616
+ rejectUnauthorized: true
633
617
  }
634
- resolve(undefined)
635
618
  })
636
- })
637
- await rm(tempDir, {recursive: true, force: true})
638
- await rm(certDir, {recursive: true, force: true})
639
- })
640
-
641
- test("TLS SocketductSocket reconnects cleanly when relay closes connection after TLS handshake but before session response", async () => {
642
- const certDir = mkdtempSync(join(tmpdir(), "socketduct-tls-pre-session-close-"))
643
- const certs = generateTestCertificates(certDir)
644
619
 
645
- const tempDir = await mkdtemp(join(tmpdir(), "socketduct-tls-pre-session-close-spool-"))
646
- const echoServer = createServer((socket) => {
647
- socket.on("data", (chunk) => socket.write(chunk))
648
- })
649
- const targetPort = await listen(echoServer)
650
-
651
- const users = new InMemoryUserStore()
652
- const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
653
- await users.createUser({username: "kasper", password: "correct horse battery staple"})
654
- const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
655
-
656
- const relay = await startTcpRelay({
657
- authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
658
- tokens,
659
- sessions: new SessionStore({now: () => 1000}),
660
- tls: {certFile: certs.serverCertFile, keyFile: certs.serverKeyFile}
620
+ const appSocket = createConnection({host: "127.0.0.1", port: edge.port})
621
+ await waitConnect(appSocket)
622
+ appSocket.write("tcp-edge-mtls")
623
+ expect((await readOnce(appSocket)).toString("utf8")).toBe("tcp-edge-mtls")
624
+
625
+ appSocket.destroy()
626
+ await edge.close()
627
+ await relay.close()
628
+ await new Promise((resolve, reject) => {
629
+ echoServer.close((error) => {
630
+ if (error) {
631
+ reject(error)
632
+ return
633
+ }
634
+ resolve(undefined)
635
+ })
636
+ })
637
+ await rm(tempDir, {recursive: true, force: true})
638
+ await rm(certDir, {recursive: true, force: true})
661
639
  })
662
640
 
663
- const socket = connectSocketduct({
664
- relay: {host: "127.0.0.1", port: relay.port, token: token.value},
665
- sessionName: "tls-pre-session-close",
666
- target: {host: "127.0.0.1", port: targetPort},
667
- spoolDirectory: tempDir,
668
- reconnect: {initialDelayMs: 25},
669
- tls: {
670
- caFile: certs.caCertFile,
671
- rejectUnauthorized: true
672
- }
673
- })
641
+ it("TLS SocketductSocket reconnects cleanly when relay closes connection after TLS handshake but before session response", async () => {
642
+ const certDir = mkdtempSync(join(tmpdir(), "socketduct-tls-pre-session-close-"))
643
+ const certs = generateTestCertificates(certDir)
674
644
 
675
- let errorEmitted = false
676
- socket.on("error", () => { errorEmitted = true })
677
-
678
- let connectCount = 0
679
- socket.on("connect", () => { connectCount += 1 })
680
-
681
- // Force-close the relay tunnel after the TLS handshake completes
682
- // but before any protocol frame is exchanged. The relay's
683
- // secureConnection event fires synchronously inside setupTunnel
684
- // — which adds the socket to the tunnel set — so closeActiveTunnel
685
- // destroys it before any data event can process session.create.
686
- // This proves the vulnerable pre-session branch: the first TLS
687
- // connection reaches secureConnect yet is closed before the client
688
- // receives session.created / session.resumed.
689
- socket.connect()
690
- await new Promise((resolve) => {
691
- relay.server.once("secureConnection", () => {
692
- relay.closeActiveTunnel()
693
- resolve(undefined)
645
+ const tempDir = await mkdtemp(join(tmpdir(), "socketduct-tls-pre-session-close-spool-"))
646
+ const echoServer = createServer((socket) => {
647
+ socket.on("data", (chunk) => socket.write(chunk))
648
+ })
649
+ const targetPort = await listen(echoServer)
650
+
651
+ const users = new InMemoryUserStore()
652
+ const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
653
+ await users.createUser({username: "kasper", password: "correct horse battery staple"})
654
+ const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
655
+
656
+ const relay = await startTcpRelay({
657
+ authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
658
+ tokens,
659
+ sessions: new SessionStore({now: () => 1000}),
660
+ tls: {certFile: certs.serverCertFile, keyFile: certs.serverKeyFile}
694
661
  })
695
- })
696
662
 
697
- assert.equal(socket.pending, true, "socket must still be pending after pre-session forced close")
663
+ const socket = connectSocketduct({
664
+ relay: {host: "127.0.0.1", port: relay.port, token: token.value},
665
+ sessionName: "tls-pre-session-close",
666
+ target: {host: "127.0.0.1", port: targetPort},
667
+ spoolDirectory: tempDir,
668
+ reconnect: {initialDelayMs: 25},
669
+ tls: {
670
+ caFile: certs.caCertFile,
671
+ rejectUnauthorized: true
672
+ }
673
+ })
698
674
 
699
- // The socket must not be destroyed with an error; it must reconnect
700
- // and deliver data on the later connection. "connect" fires only on
701
- // the first successful session creation; reconnects emit "ready".
702
- await new Promise((resolve, reject) => {
703
- const timeout = setTimeout(() => reject(new Error("Timed out waiting for TLS reconnect")), 5000)
704
- const onConnect = () => {
705
- clearTimeout(timeout)
706
- cleanup()
707
- resolve(undefined)
708
- }
709
- const onReady = () => {
710
- clearTimeout(timeout)
711
- cleanup()
712
- resolve(undefined)
713
- }
714
- const onError = (/** @type {Error} */ err) => {
715
- clearTimeout(timeout)
716
- cleanup()
717
- reject(err)
718
- }
719
- const cleanup = () => {
720
- socket.off("connect", onConnect)
721
- socket.off("ready", onReady)
722
- socket.off("error", onError)
723
- }
724
- socket.on("connect", onConnect)
725
- socket.on("ready", onReady)
726
- socket.on("error", onError)
727
- })
675
+ let errorEmitted = false
676
+ socket.on("error", () => { errorEmitted = true })
677
+
678
+ let connectCount = 0
679
+ socket.on("connect", () => { connectCount += 1 })
680
+
681
+ // Force-close the relay tunnel after the TLS handshake completes
682
+ // but before any protocol frame is exchanged. The relay's
683
+ // secureConnection event fires synchronously inside setupTunnel
684
+ // — which adds the socket to the tunnel set — so closeActiveTunnel
685
+ // destroys it before any data event can process session.create.
686
+ // This proves the vulnerable pre-session branch: the first TLS
687
+ // connection reaches secureConnect yet is closed before the client
688
+ // receives session.created / session.resumed.
689
+ socket.connect()
690
+ await new Promise((resolve) => {
691
+ relay.server.once("secureConnection", () => {
692
+ relay.closeActiveTunnel()
693
+ resolve(undefined)
694
+ })
695
+ })
696
+
697
+ expect(socket.pending).toBe(true)
698
+
699
+ // The socket must not be destroyed with an error; it must reconnect
700
+ // and deliver data on the later connection. "connect" fires only on
701
+ // the first successful session creation; reconnects emit "ready".
702
+ await new Promise((resolve, reject) => {
703
+ const timeout = setTimeout(() => reject(new Error("Timed out waiting for TLS reconnect")), 5000)
704
+ const onConnect = () => {
705
+ clearTimeout(timeout)
706
+ cleanup()
707
+ resolve(undefined)
708
+ }
709
+ const onReady = () => {
710
+ clearTimeout(timeout)
711
+ cleanup()
712
+ resolve(undefined)
713
+ }
714
+ const onError = (/** @type {Error} */ err) => {
715
+ clearTimeout(timeout)
716
+ cleanup()
717
+ reject(err)
718
+ }
719
+ const cleanup = () => {
720
+ socket.off("connect", onConnect)
721
+ socket.off("ready", onReady)
722
+ socket.off("error", onError)
723
+ }
724
+ socket.on("connect", onConnect)
725
+ socket.on("ready", onReady)
726
+ socket.on("error", onError)
727
+ })
728
728
 
729
- assert.equal(errorEmitted, false, "no error must emit when TLS connection closes before session response")
730
- assert.ok(connectCount >= 1, "connect must have fired at least once")
729
+ expect(errorEmitted).toBe(false)
730
+ expect(connectCount).toBeGreaterThanOrEqual(1)
731
731
 
732
- socket.write("tls-pre-session-reconnect")
733
- assert.equal((await readOnce(socket)).toString("utf8"), "tls-pre-session-reconnect")
732
+ socket.write("tls-pre-session-reconnect")
733
+ expect((await readOnce(socket)).toString("utf8")).toBe("tls-pre-session-reconnect")
734
734
 
735
- socket.destroy()
736
- await relay.close()
737
- await new Promise((resolve, reject) => {
738
- echoServer.close((error) => {
739
- if (error) {
740
- reject(error)
741
- return
742
- }
743
- resolve(undefined)
735
+ socket.destroy()
736
+ await relay.close()
737
+ await new Promise((resolve, reject) => {
738
+ echoServer.close((error) => {
739
+ if (error) {
740
+ reject(error)
741
+ return
742
+ }
743
+ resolve(undefined)
744
+ })
744
745
  })
746
+ await rm(tempDir, {recursive: true, force: true})
747
+ await rm(certDir, {recursive: true, force: true})
745
748
  })
746
- await rm(tempDir, {recursive: true, force: true})
747
- await rm(certDir, {recursive: true, force: true})
748
749
  })