socketduct 0.1.15 → 0.1.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +3 -0
- package/changelog.d/20260916165820-fix-deferred-socket-callback-guard.md +1 -0
- package/changelog.d/20260924120000-wan-route-classes.md +1 -0
- package/docs/architecture.md +16 -3
- package/docs/protocol.md +17 -5
- package/docs/reverse-gateway.md +68 -37
- package/docs/security.md +13 -0
- package/docs/socket-api.md +50 -21
- package/package.json +1 -1
- package/src/client/deferred-virtual-socket.js +18 -6
- package/src/client/multiplexed-transport-set.js +105 -65
- package/src/config/environment.js +24 -1
- package/src/reverse/client.js +31 -17
- package/src/reverse/common.js +1 -0
- package/src/reverse/hub.js +163 -32
- package/src/reverse/runtime.js +31 -6
- package/test/e2e/reverse-gateway-process.test.js +93 -0
- package/test/integration/reverse-route-class.test.js +373 -0
- package/test/unit/client/deferred-virtual-socket.test.js +170 -0
- package/test/unit/client/multiplexed-transport-set.test.js +42 -0
- package/test/unit/config/docker-gateway-files.test.js +3 -1
- package/test/unit/config/environment.test.js +7 -1
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param, jsdoc/require-returns */
|
|
2
|
+
import assert from "node:assert/strict"
|
|
3
|
+
import {mkdtemp, rm} from "node:fs/promises"
|
|
4
|
+
import {createServer} from "node:net"
|
|
5
|
+
import {tmpdir} from "node:os"
|
|
6
|
+
import {join} from "node:path"
|
|
7
|
+
import test from "node:test"
|
|
8
|
+
|
|
9
|
+
import {StaticTokenStore} from "../../src/auth/static-token-store.js"
|
|
10
|
+
import {V3FrameType} from "../../src/protocol/v3-frame.js"
|
|
11
|
+
import {OutboundReverseGateway, ReverseGatewayConnection, startReverseGatewayHub} from "../../src/reverse/index.js"
|
|
12
|
+
import {generateTestCertificates} from "../helpers/tls-certificates.js"
|
|
13
|
+
|
|
14
|
+
/** @param {import('node:net').Server} server - Server. */
|
|
15
|
+
async function listen(server) {
|
|
16
|
+
await new Promise((resolve, reject) => {
|
|
17
|
+
server.once("error", reject)
|
|
18
|
+
server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(undefined) })
|
|
19
|
+
})
|
|
20
|
+
const address = server.address()
|
|
21
|
+
assert(address !== null && typeof address !== "string")
|
|
22
|
+
return address.port
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** @param {import('node:events').EventEmitter} emitter - Source. @param {string} event - Event. */
|
|
26
|
+
async function onceEvent(emitter, event) {
|
|
27
|
+
await new Promise((resolve, reject) => {
|
|
28
|
+
emitter.once("error", reject)
|
|
29
|
+
emitter.once(event, () => { emitter.off("error", reject); resolve(undefined) })
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** @param {import('node:stream').Duplex} socket - Stream. @returns {Promise<Error & {code?: string}>} Next stream error. */
|
|
34
|
+
async function nextError(socket) {
|
|
35
|
+
return await new Promise((resolve) => socket.once("error", (error) => resolve(/** @type {Error & {code?: string}} */ (error))))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** @param {Promise<unknown>} promise - Work. @param {string} label - Timeout label. */
|
|
39
|
+
async function withTimeout(promise, label) {
|
|
40
|
+
let timer
|
|
41
|
+
try {
|
|
42
|
+
return await Promise.race([
|
|
43
|
+
promise,
|
|
44
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`Timed out: ${label}`)), 4_000) })
|
|
45
|
+
])
|
|
46
|
+
} finally {
|
|
47
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @param {() => boolean} predicate - Completion predicate. @param {string} label - Failure label. */
|
|
52
|
+
async function waitUntil(predicate, label) {
|
|
53
|
+
const deadline = Date.now() + 4_000
|
|
54
|
+
while (!predicate()) {
|
|
55
|
+
if (Date.now() >= deadline) throw new Error(`Timed out: ${label}`)
|
|
56
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** @param {string} prefix - Echo response prefix. */
|
|
61
|
+
async function createEchoTarget(prefix) {
|
|
62
|
+
const sockets = new Set()
|
|
63
|
+
const server = createServer((socket) => {
|
|
64
|
+
sockets.add(socket)
|
|
65
|
+
socket.once("close", () => sockets.delete(socket))
|
|
66
|
+
socket.on("data", (chunk) => socket.write(Buffer.concat([Buffer.from(prefix), chunk])))
|
|
67
|
+
})
|
|
68
|
+
const port = await listen(server)
|
|
69
|
+
return {
|
|
70
|
+
host: "127.0.0.1",
|
|
71
|
+
port,
|
|
72
|
+
close: async () => {
|
|
73
|
+
for (const socket of sockets) socket.destroy()
|
|
74
|
+
await new Promise((resolve) => server.close(() => resolve(undefined)))
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** @param {import('node:stream').Duplex} socket - Open stream. @param {string} payload - Request. */
|
|
80
|
+
async function exchange(socket, payload) {
|
|
81
|
+
const response = new Promise((resolve, reject) => {
|
|
82
|
+
socket.once("error", reject)
|
|
83
|
+
socket.once("data", (chunk) => resolve(Buffer.from(chunk).toString()))
|
|
84
|
+
})
|
|
85
|
+
socket.write(payload)
|
|
86
|
+
return await withTimeout(response, `response for ${payload}`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
test("route-class clients fail closed per OPEN while unaffected routes and legacy exact clients remain usable", async (t) => {
|
|
90
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-route-class-"))
|
|
91
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
92
|
+
const certificates = generateTestCertificates(root)
|
|
93
|
+
const targetA = await createEchoTarget("a:")
|
|
94
|
+
const targetB = await createEchoTarget("b:")
|
|
95
|
+
t.after(() => targetA.close())
|
|
96
|
+
t.after(() => targetB.close())
|
|
97
|
+
|
|
98
|
+
const tokens = new StaticTokenStore([
|
|
99
|
+
{username: "home-a", token: "gateway-a-token"},
|
|
100
|
+
{username: "home-b", token: "gateway-b-token"},
|
|
101
|
+
{username: "tensorbuzz", token: "client-token"},
|
|
102
|
+
{username: "untrusted", token: "untrusted-token"}
|
|
103
|
+
])
|
|
104
|
+
let ambiguousTargetA = false
|
|
105
|
+
const hub = await startReverseGatewayHub({
|
|
106
|
+
tokens,
|
|
107
|
+
tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
|
|
108
|
+
knownGatewayIds: ["server-a-starlink", "server-b-starlink"],
|
|
109
|
+
knownRouteClasses: ["starlink"],
|
|
110
|
+
authorizeGateway: ({principal, gatewayId}) => (
|
|
111
|
+
(principal.username === "home-a" && gatewayId === "server-a-starlink")
|
|
112
|
+
|| (principal.username === "home-b" && gatewayId === "server-b-starlink")
|
|
113
|
+
),
|
|
114
|
+
authorizeClientGateway: ({principal, gatewayId}) => principal.username === "tensorbuzz" && gatewayId === "server-a-starlink",
|
|
115
|
+
authorizeClientRouteClass: ({principal}) => principal.username === "tensorbuzz",
|
|
116
|
+
resolveOpenGateways: ({principal, routeClass, target}) => {
|
|
117
|
+
if (principal.username !== "tensorbuzz" || routeClass !== "starlink" || target.host !== "127.0.0.1") return []
|
|
118
|
+
if (target.port === targetA.port) return ambiguousTargetA ? ["server-a-starlink", "server-b-starlink"] : ["server-a-starlink"]
|
|
119
|
+
if (target.port === targetB.port) return ["server-b-starlink"]
|
|
120
|
+
return []
|
|
121
|
+
},
|
|
122
|
+
authorizeOpen: ({principal, gatewayId, target}) => principal.username === "tensorbuzz" && (
|
|
123
|
+
(gatewayId === "server-a-starlink" && target.host === targetA.host && target.port === targetA.port)
|
|
124
|
+
|| (gatewayId === "server-b-starlink" && target.host === targetB.host && target.port === targetB.port)
|
|
125
|
+
)
|
|
126
|
+
})
|
|
127
|
+
t.after(() => hub.close())
|
|
128
|
+
const tls = {caFile: certificates.caCertFile, servername: "localhost"}
|
|
129
|
+
const gatewayA = new OutboundReverseGateway({
|
|
130
|
+
gatewayId: "server-a-starlink",
|
|
131
|
+
connectionId: "route_class_gateway_a",
|
|
132
|
+
hub: {host: "127.0.0.1", port: hub.port, token: "gateway-a-token"},
|
|
133
|
+
tls,
|
|
134
|
+
authorizeTarget: (target) => target.host === targetA.host && target.port === targetA.port,
|
|
135
|
+
reconnectDelayMs: 10_000,
|
|
136
|
+
maxReconnectDelayMs: 10_000
|
|
137
|
+
})
|
|
138
|
+
const gatewayB = new OutboundReverseGateway({
|
|
139
|
+
gatewayId: "server-b-starlink",
|
|
140
|
+
connectionId: "route_class_gateway_b",
|
|
141
|
+
hub: {host: "127.0.0.1", port: hub.port, token: "gateway-b-token"},
|
|
142
|
+
tls,
|
|
143
|
+
authorizeTarget: (target) => target.host === targetB.host && target.port === targetB.port,
|
|
144
|
+
reconnectDelayMs: 10_000,
|
|
145
|
+
maxReconnectDelayMs: 10_000
|
|
146
|
+
})
|
|
147
|
+
t.after(() => gatewayA.close())
|
|
148
|
+
t.after(() => gatewayB.close())
|
|
149
|
+
await Promise.all([gatewayA.start(), gatewayB.start()])
|
|
150
|
+
|
|
151
|
+
const unknown = new ReverseGatewayConnection({
|
|
152
|
+
routeClass: "missing",
|
|
153
|
+
connectionId: "route_class_unknown_client",
|
|
154
|
+
hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
|
|
155
|
+
tls,
|
|
156
|
+
continuousReconnect: false
|
|
157
|
+
})
|
|
158
|
+
t.after(() => unknown.close())
|
|
159
|
+
await assert.rejects(withTimeout(unknown.connect(), "unknown route class"), (error) => /** @type {{code?: string}} */ (error).code === "UNKNOWN_ROUTE_CLASS")
|
|
160
|
+
|
|
161
|
+
const unauthorized = new ReverseGatewayConnection({
|
|
162
|
+
routeClass: "starlink",
|
|
163
|
+
connectionId: "route_class_unauthorized_client",
|
|
164
|
+
hub: {host: "127.0.0.1", port: hub.port, token: "untrusted-token"},
|
|
165
|
+
tls,
|
|
166
|
+
continuousReconnect: false
|
|
167
|
+
})
|
|
168
|
+
t.after(() => unauthorized.close())
|
|
169
|
+
await assert.rejects(withTimeout(unauthorized.connect(), "unauthorized route class"), (error) => /** @type {{code?: string}} */ (error).code === "ROUTE_CLASS_UNAUTHORIZED")
|
|
170
|
+
|
|
171
|
+
const client = new ReverseGatewayConnection({
|
|
172
|
+
routeClass: "starlink",
|
|
173
|
+
connectionId: "route_class_tensorbuzz_client",
|
|
174
|
+
hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
|
|
175
|
+
tls
|
|
176
|
+
})
|
|
177
|
+
t.after(() => client.close())
|
|
178
|
+
await client.connect()
|
|
179
|
+
assert.equal(client.status().physicalConnects, 1)
|
|
180
|
+
assert.equal(hub.status().clientPhysicalConnections, 1)
|
|
181
|
+
|
|
182
|
+
const denied = client.createConnection({host: "127.0.0.1", port: 65_534})
|
|
183
|
+
assert.equal((await nextError(denied)).code, "TARGET_UNAUTHORIZED")
|
|
184
|
+
|
|
185
|
+
ambiguousTargetA = true
|
|
186
|
+
const ambiguous = client.createConnection({host: targetA.host, port: targetA.port})
|
|
187
|
+
assert.equal((await nextError(ambiguous)).code, "AMBIGUOUS_ROUTE")
|
|
188
|
+
ambiguousTargetA = false
|
|
189
|
+
|
|
190
|
+
const legacy = new ReverseGatewayConnection({
|
|
191
|
+
gatewayId: "server-a-starlink",
|
|
192
|
+
connectionId: "legacy_exact_gateway_client",
|
|
193
|
+
hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
|
|
194
|
+
tls
|
|
195
|
+
})
|
|
196
|
+
t.after(() => legacy.close())
|
|
197
|
+
await legacy.connect()
|
|
198
|
+
const legacyStream = legacy.createConnection({host: targetA.host, port: targetA.port})
|
|
199
|
+
await onceEvent(legacyStream, "connect")
|
|
200
|
+
assert.equal(await exchange(legacyStream, "legacy"), "a:legacy")
|
|
201
|
+
legacyStream.destroy()
|
|
202
|
+
await legacy.close()
|
|
203
|
+
|
|
204
|
+
const streamA = client.createConnection({host: targetA.host, port: targetA.port})
|
|
205
|
+
const streamB = client.createConnection({host: targetB.host, port: targetB.port})
|
|
206
|
+
await Promise.all([onceEvent(streamA, "connect"), onceEvent(streamB, "connect")])
|
|
207
|
+
assert.deepEqual(await Promise.all([exchange(streamA, "one"), exchange(streamB, "two")]), ["a:one", "b:two"])
|
|
208
|
+
|
|
209
|
+
const streamAError = nextError(streamA)
|
|
210
|
+
await hub.closeActiveGateway("server-a-starlink")
|
|
211
|
+
assert.equal((await streamAError).code, "GATEWAY_DISCONNECTED")
|
|
212
|
+
await waitUntil(() => hub.status().healthyGateways === 1, "gateway A disconnect")
|
|
213
|
+
|
|
214
|
+
const disconnected = client.createConnection({host: targetA.host, port: targetA.port})
|
|
215
|
+
assert.equal((await nextError(disconnected)).code, "GATEWAY_UNAVAILABLE")
|
|
216
|
+
assert.equal(await exchange(streamB, "still-open"), "b:still-open")
|
|
217
|
+
const anotherB = client.createConnection({host: targetB.host, port: targetB.port})
|
|
218
|
+
await onceEvent(anotherB, "connect")
|
|
219
|
+
assert.equal(await exchange(anotherB, "new"), "b:new")
|
|
220
|
+
|
|
221
|
+
await gatewayA.close()
|
|
222
|
+
const replacementA = new OutboundReverseGateway({
|
|
223
|
+
gatewayId: "server-a-starlink",
|
|
224
|
+
connectionId: "route_class_gateway_a",
|
|
225
|
+
initialGeneration: gatewayA.status().connectionGeneration,
|
|
226
|
+
hub: {host: "127.0.0.1", port: hub.port, token: "gateway-a-token"},
|
|
227
|
+
tls,
|
|
228
|
+
authorizeTarget: (target) => target.host === targetA.host && target.port === targetA.port
|
|
229
|
+
})
|
|
230
|
+
t.after(() => replacementA.close())
|
|
231
|
+
await replacementA.start()
|
|
232
|
+
const rejoinedA = client.createConnection({host: targetA.host, port: targetA.port})
|
|
233
|
+
await onceEvent(rejoinedA, "connect")
|
|
234
|
+
assert.equal(await exchange(rejoinedA, "rejoined"), "a:rejoined")
|
|
235
|
+
assert.equal(client.status().physicalConnects, 1)
|
|
236
|
+
assert.equal(hub.status().clientPhysicalConnections, 1)
|
|
237
|
+
streamB.destroy()
|
|
238
|
+
anotherB.destroy()
|
|
239
|
+
rejoinedA.destroy()
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test("a backpressured gateway does not block DATA or a new OPEN for another route-class gateway", async (t) => {
|
|
243
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-route-class-backpressure-"))
|
|
244
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
245
|
+
const certificates = generateTestCertificates(root)
|
|
246
|
+
const targetA = await createEchoTarget("a:")
|
|
247
|
+
const targetB = await createEchoTarget("b:")
|
|
248
|
+
t.after(() => targetA.close())
|
|
249
|
+
t.after(() => targetB.close())
|
|
250
|
+
|
|
251
|
+
const hub = await startReverseGatewayHub({
|
|
252
|
+
tokens: new StaticTokenStore([
|
|
253
|
+
{username: "home-a", token: "gateway-a-token"},
|
|
254
|
+
{username: "home-b", token: "gateway-b-token"},
|
|
255
|
+
{username: "tensorbuzz", token: "client-token"}
|
|
256
|
+
]),
|
|
257
|
+
tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
|
|
258
|
+
knownGatewayIds: ["server-a-starlink", "server-b-starlink"],
|
|
259
|
+
knownRouteClasses: ["starlink"],
|
|
260
|
+
authorizeGateway: ({principal, gatewayId}) => (
|
|
261
|
+
(principal.username === "home-a" && gatewayId === "server-a-starlink")
|
|
262
|
+
|| (principal.username === "home-b" && gatewayId === "server-b-starlink")
|
|
263
|
+
),
|
|
264
|
+
authorizeClientGateway: () => false,
|
|
265
|
+
authorizeClientRouteClass: ({principal, routeClass}) => principal.username === "tensorbuzz" && routeClass === "starlink",
|
|
266
|
+
resolveOpenGateways: ({routeClass, target}) => {
|
|
267
|
+
if (routeClass !== "starlink" || target.host !== "127.0.0.1") return []
|
|
268
|
+
if (target.port === targetA.port) return ["server-a-starlink"]
|
|
269
|
+
if (target.port === targetB.port) return ["server-b-starlink"]
|
|
270
|
+
return []
|
|
271
|
+
},
|
|
272
|
+
authorizeOpen: ({gatewayId, target}) => (
|
|
273
|
+
(gatewayId === "server-a-starlink" && target.port === targetA.port)
|
|
274
|
+
|| (gatewayId === "server-b-starlink" && target.port === targetB.port)
|
|
275
|
+
),
|
|
276
|
+
maxOutputBytesPerConnection: 1_024
|
|
277
|
+
})
|
|
278
|
+
t.after(() => hub.close())
|
|
279
|
+
/** @type {import('node:tls').TLSSocket[]} */
|
|
280
|
+
const accepted = []
|
|
281
|
+
hub.server.on("secureConnection", (socket) => accepted.push(socket))
|
|
282
|
+
const tls = {caFile: certificates.caCertFile, servername: "localhost"}
|
|
283
|
+
const gatewayA = new OutboundReverseGateway({
|
|
284
|
+
gatewayId: "server-a-starlink",
|
|
285
|
+
connectionId: "backpressure_gateway_a",
|
|
286
|
+
hub: {host: "127.0.0.1", port: hub.port, token: "gateway-a-token"},
|
|
287
|
+
tls,
|
|
288
|
+
authorizeTarget: (target) => target.host === targetA.host && target.port === targetA.port,
|
|
289
|
+
reconnectDelayMs: 10_000,
|
|
290
|
+
maxReconnectDelayMs: 10_000
|
|
291
|
+
})
|
|
292
|
+
const gatewayB = new OutboundReverseGateway({
|
|
293
|
+
gatewayId: "server-b-starlink",
|
|
294
|
+
connectionId: "backpressure_gateway_b",
|
|
295
|
+
hub: {host: "127.0.0.1", port: hub.port, token: "gateway-b-token"},
|
|
296
|
+
tls,
|
|
297
|
+
authorizeTarget: (target) => target.host === targetB.host && target.port === targetB.port,
|
|
298
|
+
reconnectDelayMs: 10_000,
|
|
299
|
+
maxReconnectDelayMs: 10_000
|
|
300
|
+
})
|
|
301
|
+
t.after(() => gatewayA.close())
|
|
302
|
+
t.after(() => gatewayB.close())
|
|
303
|
+
await gatewayA.start()
|
|
304
|
+
await gatewayB.start()
|
|
305
|
+
assert.equal(accepted.length, 2)
|
|
306
|
+
|
|
307
|
+
const client = new ReverseGatewayConnection({
|
|
308
|
+
routeClass: "starlink",
|
|
309
|
+
connectionId: "backpressure_route_client",
|
|
310
|
+
hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
|
|
311
|
+
tls,
|
|
312
|
+
continuousReconnect: false,
|
|
313
|
+
reconnectDelayMs: 10_000,
|
|
314
|
+
maxReconnectDelayMs: 10_000
|
|
315
|
+
})
|
|
316
|
+
t.after(() => client.close())
|
|
317
|
+
await client.connect()
|
|
318
|
+
const streamA = client.createConnection({host: targetA.host, port: targetA.port})
|
|
319
|
+
const streamB = client.createConnection({host: targetB.host, port: targetB.port})
|
|
320
|
+
await Promise.all([onceEvent(streamA, "connect"), onceEvent(streamB, "connect")])
|
|
321
|
+
|
|
322
|
+
const gatewayASocket = /** @type {import('node:tls').TLSSocket} */ (accepted[0])
|
|
323
|
+
const writable = /** @type {{write: (chunk: Buffer) => boolean}} */ (/** @type {unknown} */ (gatewayASocket))
|
|
324
|
+
const originalWrite = writable.write
|
|
325
|
+
let blocked = false
|
|
326
|
+
/** @type {() => void} */
|
|
327
|
+
let markBlocked = () => {}
|
|
328
|
+
const backpressured = new Promise((resolve) => { markBlocked = () => resolve(undefined) })
|
|
329
|
+
writable.write = (chunk) => {
|
|
330
|
+
const acceptedWrite = Reflect.apply(originalWrite, gatewayASocket, [chunk])
|
|
331
|
+
if (!blocked && chunk[5] === V3FrameType.DATA) {
|
|
332
|
+
blocked = true
|
|
333
|
+
markBlocked()
|
|
334
|
+
return false
|
|
335
|
+
}
|
|
336
|
+
return acceptedWrite
|
|
337
|
+
}
|
|
338
|
+
t.after(() => {
|
|
339
|
+
writable.write = originalWrite
|
|
340
|
+
if (blocked) gatewayASocket.emit("drain")
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
streamA.write("blocked")
|
|
344
|
+
await withTimeout(backpressured, "gateway A backpressure")
|
|
345
|
+
const newStreamB = client.createConnection({host: targetB.host, port: targetB.port})
|
|
346
|
+
t.after(() => newStreamB.destroy())
|
|
347
|
+
const newStreamBExchange = (async () => {
|
|
348
|
+
await onceEvent(newStreamB, "connect")
|
|
349
|
+
return await exchange(newStreamB, "new-open")
|
|
350
|
+
})()
|
|
351
|
+
assert.deepEqual(await withTimeout(Promise.all([
|
|
352
|
+
exchange(streamB, "existing"),
|
|
353
|
+
newStreamBExchange
|
|
354
|
+
]), "healthy gateway progress while gateway A is backpressured"), ["b:existing", "b:new-open"])
|
|
355
|
+
assert.equal(client.status().physicalConnects, 1)
|
|
356
|
+
assert(hub.status().queuedOutputBytes > 0)
|
|
357
|
+
const streamAError = nextError(streamA)
|
|
358
|
+
const queuedAErrors = Array.from({length: 8}, () => {
|
|
359
|
+
const socket = client.createConnection({host: targetA.host, port: targetA.port, sessionName: "a".repeat(256)})
|
|
360
|
+
t.after(() => socket.destroy())
|
|
361
|
+
return nextError(socket)
|
|
362
|
+
})
|
|
363
|
+
assert.equal((await withTimeout(streamAError, "gateway A output limit")).code, "GATEWAY_DISCONNECTED")
|
|
364
|
+
await withTimeout(Promise.all(queuedAErrors), "queued gateway A stream cleanup")
|
|
365
|
+
assert.equal(await exchange(streamB, "after-limit"), "b:after-limit")
|
|
366
|
+
assert.equal(hub.status().healthyGateways, 1)
|
|
367
|
+
assert.equal(hub.status().clientPhysicalConnections, 1)
|
|
368
|
+
assert.equal(client.status().physicalConnects, 1)
|
|
369
|
+
writable.write = originalWrite
|
|
370
|
+
assert.equal(hub.status().queuedOutputBytes, 0)
|
|
371
|
+
streamA.destroy()
|
|
372
|
+
streamB.destroy()
|
|
373
|
+
})
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param-type, jsdoc/require-returns */
|
|
2
|
+
import assert from "node:assert/strict"
|
|
3
|
+
import test from "node:test"
|
|
4
|
+
|
|
5
|
+
import {DeferredVirtualSocket} from "../../../src/client/deferred-virtual-socket.js"
|
|
6
|
+
import {VirtualSocket} from "../../../src/client/virtual-socket.js"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {{reserveMemoryError?: Error}} [options] - Optional rejection to inject into the byte budget.
|
|
10
|
+
* @returns {{reservedBytes: () => number, socket: import('../../../src/client/deferred-virtual-socket.js').DeferredVirtualSocket}} Deferred socket fixture with reserved-byte accounting.
|
|
11
|
+
*/
|
|
12
|
+
function deferredSocket(options = {}) {
|
|
13
|
+
let reservedBytes = 0
|
|
14
|
+
const socket = new DeferredVirtualSocket({host: "127.0.0.1", port: 4793}, {
|
|
15
|
+
budget: {
|
|
16
|
+
reserveMemory(bytes) {
|
|
17
|
+
if (options.reserveMemoryError) throw options.reserveMemoryError
|
|
18
|
+
reservedBytes += bytes
|
|
19
|
+
},
|
|
20
|
+
releaseMemory(bytes) {
|
|
21
|
+
reservedBytes -= bytes
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
onCancel: () => {},
|
|
25
|
+
ref: () => {},
|
|
26
|
+
unref: () => {}
|
|
27
|
+
})
|
|
28
|
+
return {reservedBytes: () => reservedBytes, socket}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Fake member connection that settles write and final callbacks on the next macrotask. */
|
|
32
|
+
function memberConnection() {
|
|
33
|
+
/** @type {Buffer[]} */
|
|
34
|
+
const writes = []
|
|
35
|
+
let ended = 0
|
|
36
|
+
return {
|
|
37
|
+
connection: /** @type {import('../../../src/client/virtual-socket.js').VirtualSocketOwner} */ (/** @type {unknown} */ ({
|
|
38
|
+
/**
|
|
39
|
+
* @param {number} _id @param {Buffer} data @param {(error?: Error | null) => void} callback
|
|
40
|
+
* @param data
|
|
41
|
+
* @param callback
|
|
42
|
+
*/
|
|
43
|
+
_writeStream: (_id, data, callback) => {
|
|
44
|
+
writes.push(data)
|
|
45
|
+
setImmediate(callback)
|
|
46
|
+
},
|
|
47
|
+
/**
|
|
48
|
+
* @param {number} _id @param {(error?: Error | null) => void} callback
|
|
49
|
+
* @param callback
|
|
50
|
+
*/
|
|
51
|
+
_endStream: (_id, callback) => {
|
|
52
|
+
ended += 1
|
|
53
|
+
callback()
|
|
54
|
+
},
|
|
55
|
+
_destroyStream: () => {},
|
|
56
|
+
_resumeReadable: () => {},
|
|
57
|
+
physicalAddress: () => ({address: "127.0.0.1", port: 43210}),
|
|
58
|
+
ref: () => {},
|
|
59
|
+
unref: () => {}
|
|
60
|
+
})),
|
|
61
|
+
ended: () => ended,
|
|
62
|
+
writes
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
test("DeferredVirtualSocket write ignores non-function callback values in both parameter slots", async () => {
|
|
67
|
+
const first = deferredSocket()
|
|
68
|
+
// @ts-expect-error Deliberate non-function callback value; the socket must ignore it.
|
|
69
|
+
assert.equal(first.socket.write("hello", null), true)
|
|
70
|
+
const closed = new Promise((resolve) => first.socket.once("close", resolve))
|
|
71
|
+
first.socket.destroy()
|
|
72
|
+
await closed
|
|
73
|
+
assert.equal(first.socket.destroyed, true)
|
|
74
|
+
|
|
75
|
+
const second = deferredSocket()
|
|
76
|
+
// @ts-expect-error Deliberate non-function callback value; the socket must ignore it.
|
|
77
|
+
assert.equal(second.socket.write("hello", "utf8", null), true)
|
|
78
|
+
const closedAgain = new Promise((resolve) => second.socket.once("close", resolve))
|
|
79
|
+
assert.doesNotThrow(() => second.socket.destroy())
|
|
80
|
+
await closedAgain
|
|
81
|
+
assert.equal(second.socket.destroyed, true)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test("DeferredVirtualSocket write budget failure destroys the socket without crashing on non-function callback values", async () => {
|
|
85
|
+
const failure = new Error("budget exhausted")
|
|
86
|
+
const {socket} = deferredSocket({reserveMemoryError: failure})
|
|
87
|
+
const error = new Promise((resolve) => socket.once("error", resolve))
|
|
88
|
+
// @ts-expect-error Deliberate non-function callback value; the socket must ignore it.
|
|
89
|
+
assert.equal(socket.write("hello", "utf8", null), false)
|
|
90
|
+
assert.equal(await error, failure)
|
|
91
|
+
assert.equal(socket.destroyed, true)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test("DeferredVirtualSocket write budget failure invokes the completion callback with the failure", async () => {
|
|
95
|
+
const failure = new Error("budget exhausted")
|
|
96
|
+
const {reservedBytes, socket} = deferredSocket({reserveMemoryError: failure})
|
|
97
|
+
/** @type {Error | null | undefined} */
|
|
98
|
+
let callbackError
|
|
99
|
+
const error = new Promise((resolve) => socket.once("error", resolve))
|
|
100
|
+
assert.equal(socket.write("hello", "utf8", (received) => {
|
|
101
|
+
callbackError = received
|
|
102
|
+
}), false)
|
|
103
|
+
await error
|
|
104
|
+
assert.equal(callbackError, failure)
|
|
105
|
+
assert.equal(reservedBytes(), 0)
|
|
106
|
+
assert.equal(socket.destroyed, true)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test("DeferredVirtualSocket write completion after member assignment ignores non-function callback values", async () => {
|
|
110
|
+
const {socket} = deferredSocket()
|
|
111
|
+
const member = memberConnection()
|
|
112
|
+
socket.assign(new VirtualSocket(member.connection, 1, {host: "127.0.0.1", port: 4793}), "gateway-1")
|
|
113
|
+
// @ts-expect-error Deliberate non-function callback value; the socket must ignore it.
|
|
114
|
+
assert.equal(socket.write("hello", "utf8", null), true)
|
|
115
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
116
|
+
const written = member.writes[0]
|
|
117
|
+
assert(written !== undefined)
|
|
118
|
+
assert.equal(written.toString(), "hello")
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test("DeferredVirtualSocket end ignores non-function callback values in both parameter slots", async () => {
|
|
122
|
+
const first = deferredSocket()
|
|
123
|
+
assert.doesNotThrow(() => {
|
|
124
|
+
// @ts-expect-error Deliberate non-function callback value; the socket must ignore it.
|
|
125
|
+
first.socket.end("hello", null)
|
|
126
|
+
})
|
|
127
|
+
assert.equal(first.socket.writableEnded, true)
|
|
128
|
+
const closed = new Promise((resolve) => first.socket.once("close", resolve))
|
|
129
|
+
first.socket.destroy()
|
|
130
|
+
await closed
|
|
131
|
+
assert.equal(first.socket.destroyed, true)
|
|
132
|
+
|
|
133
|
+
const second = deferredSocket()
|
|
134
|
+
assert.doesNotThrow(() => {
|
|
135
|
+
// @ts-expect-error Deliberate non-function callback value; the socket must ignore it.
|
|
136
|
+
second.socket.end("hello", "utf8", null)
|
|
137
|
+
})
|
|
138
|
+
assert.equal(second.socket.writableEnded, true)
|
|
139
|
+
const closedAgain = new Promise((resolve) => second.socket.once("close", resolve))
|
|
140
|
+
second.socket.destroy()
|
|
141
|
+
await closedAgain
|
|
142
|
+
assert.equal(second.socket.destroyed, true)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test("DeferredVirtualSocket end does not forward non-function callback values to the stream", async () => {
|
|
146
|
+
const {socket} = deferredSocket()
|
|
147
|
+
assert.doesNotThrow(() => {
|
|
148
|
+
// @ts-expect-error Deliberate non-function callback value; the socket must ignore it.
|
|
149
|
+
socket.end("hello", "utf8", 42)
|
|
150
|
+
})
|
|
151
|
+
assert.equal(socket.writableEnded, true)
|
|
152
|
+
const closed = new Promise((resolve) => socket.once("close", resolve))
|
|
153
|
+
socket.destroy()
|
|
154
|
+
await closed
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test("DeferredVirtualSocket end completion after member assignment invokes the callback on finish", async () => {
|
|
158
|
+
const {socket} = deferredSocket()
|
|
159
|
+
const member = memberConnection()
|
|
160
|
+
socket.assign(new VirtualSocket(member.connection, 1, {host: "127.0.0.1", port: 4793}), "gateway-1")
|
|
161
|
+
let finishedCallback = false
|
|
162
|
+
const finished = new Promise((resolve) => socket.once("finish", resolve))
|
|
163
|
+
socket.end("hello", "utf8", () => {
|
|
164
|
+
finishedCallback = true
|
|
165
|
+
})
|
|
166
|
+
await finished
|
|
167
|
+
assert.equal(finishedCallback, true)
|
|
168
|
+
assert.equal(socket.writableFinished, true)
|
|
169
|
+
assert.equal(member.ended(), 1)
|
|
170
|
+
})
|
|
@@ -260,6 +260,48 @@ test("validates stable gateway identities, pinned endpoints, and process limits"
|
|
|
260
260
|
}
|
|
261
261
|
})
|
|
262
262
|
|
|
263
|
+
test("route-class members are first-class reverse transports with route-class allocation metadata", async () => {
|
|
264
|
+
/** @type {FakeMember[]} */
|
|
265
|
+
const members = []
|
|
266
|
+
const set = new MultiplexedSocketductTransportSet(/** @type {import('../../../src/client/multiplexed-transport-set.js').TransportSetOptions} */ (/** @type {unknown} */ ({
|
|
267
|
+
connectionId: "route_class_process_identity",
|
|
268
|
+
minimumReadyMembers: 1,
|
|
269
|
+
routeClasses: [
|
|
270
|
+
{routeClass: "starlink", endpoint: {reverseHub: {host: "hub.example", port: 3100, token: "starlink-token"}, tls: {servername: "hub.example"}}},
|
|
271
|
+
{routeClass: "zyxel", endpoint: {reverseHub: {host: "hub.example", port: 3100, token: "zyxel-token"}, tls: {servername: "hub.example"}}}
|
|
272
|
+
]
|
|
273
|
+
})), {
|
|
274
|
+
createMember(memberOptions, routeClass) {
|
|
275
|
+
const member = new FakeMember(memberOptions, routeClass, undefined)
|
|
276
|
+
members.push(member)
|
|
277
|
+
return member
|
|
278
|
+
}
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
assert.deepEqual(members.map((member) => {
|
|
282
|
+
const memberOptions = /** @type {import('../../../src/reverse/client.js').ReverseGatewayConnectionOptions} */ (member.options)
|
|
283
|
+
return {gatewayId: memberOptions.gatewayId, routeClass: memberOptions.routeClass}
|
|
284
|
+
}), [
|
|
285
|
+
{gatewayId: undefined, routeClass: "starlink"},
|
|
286
|
+
{gatewayId: undefined, routeClass: "zyxel"}
|
|
287
|
+
])
|
|
288
|
+
const connectionIds = members.map((member) => member.options.connectionId)
|
|
289
|
+
assert(connectionIds.every((connectionId) => typeof connectionId === "string"))
|
|
290
|
+
assert.equal(new Set(connectionIds).size, 2)
|
|
291
|
+
|
|
292
|
+
const started = set.start()
|
|
293
|
+
at(members, 0).ready()
|
|
294
|
+
await started
|
|
295
|
+
const socket = set.createConnection({host: "target.internal", port: 443})
|
|
296
|
+
assert.equal(/** @type {{gatewayId?: string}} */ (socket).gatewayId, undefined)
|
|
297
|
+
assert.equal(/** @type {{routeClass?: string}} */ (socket).routeClass, "starlink")
|
|
298
|
+
assert.deepEqual(set.status().members.map((member) => ({gatewayId: member.gatewayId, routeClass: member.routeClass})), [
|
|
299
|
+
{gatewayId: undefined, routeClass: "starlink"},
|
|
300
|
+
{gatewayId: undefined, routeClass: "zyxel"}
|
|
301
|
+
])
|
|
302
|
+
await set.close()
|
|
303
|
+
})
|
|
304
|
+
|
|
263
305
|
test("logs transport-set startup, generation replacement, and terminal failure without error text", async () => {
|
|
264
306
|
/** @type {Array<{event: string} & Record<string, unknown>>} */
|
|
265
307
|
const entries = []
|
|
@@ -41,7 +41,9 @@ test("TensorBuzz exclusively owns verification and container build validation",
|
|
|
41
41
|
assert.match(tensorbuzz, /privileged: true/)
|
|
42
42
|
assert.match(tensorbuzz, /docker build --tag socketduct-ci \./)
|
|
43
43
|
assert.doesNotMatch(tensorbuzz, /docker (?:login|push)/)
|
|
44
|
-
|
|
44
|
+
// release-patch refuses to publish a package marked "private": true, so the
|
|
45
|
+
// published UNLICENSED npm release requires the flag to be absent or false.
|
|
46
|
+
assert.notEqual(packageMetadata.private, true)
|
|
45
47
|
assert.equal(packageMetadata.license, "UNLICENSED")
|
|
46
48
|
})
|
|
47
49
|
|
|
@@ -254,12 +254,13 @@ test("isLoopbackHost rejects non-loopback addresses", () => {
|
|
|
254
254
|
assert.equal(isLoopbackHost("::ffff:127.0.0.1"), false)
|
|
255
255
|
})
|
|
256
256
|
|
|
257
|
-
test("loadEnvironmentConfig parses reverse hub identity bindings and
|
|
257
|
+
test("loadEnvironmentConfig parses reverse hub identity bindings, exact routes, and route-class routes", () => {
|
|
258
258
|
const config = loadEnvironmentConfig({
|
|
259
259
|
SOCKETDUCT_MODE: "reverse-hub",
|
|
260
260
|
SOCKETDUCT_AUTH_TOKENS: "home-o2:gateway-token,tensorbuzz:client-token",
|
|
261
261
|
SOCKETDUCT_REVERSE_GATEWAY_BINDINGS: "o2:home-o2,zyxel:home-zyxel",
|
|
262
262
|
SOCKETDUCT_REVERSE_ROUTES: "tensorbuzz:o2:docker-server:2375,tensorbuzz:zyxel:docker-server:2375",
|
|
263
|
+
SOCKETDUCT_REVERSE_ROUTE_CLASS_ROUTES: "tensorbuzz:starlink:o2:docker-a:2375,tensorbuzz:starlink:zyxel:docker-b:2375",
|
|
263
264
|
SOCKETDUCT_TLS_CERT_FILE: "/certs/hub.crt",
|
|
264
265
|
SOCKETDUCT_TLS_KEY_FILE: "/certs/hub.key"
|
|
265
266
|
})
|
|
@@ -269,6 +270,10 @@ test("loadEnvironmentConfig parses reverse hub identity bindings and exact route
|
|
|
269
270
|
{username: "tensorbuzz", gatewayId: "o2", host: "docker-server", port: 2375},
|
|
270
271
|
{username: "tensorbuzz", gatewayId: "zyxel", host: "docker-server", port: 2375}
|
|
271
272
|
])
|
|
273
|
+
assert.deepEqual(config.reverseRouteClassRoutes, [
|
|
274
|
+
{username: "tensorbuzz", routeClass: "starlink", gatewayId: "o2", host: "docker-a", port: 2375},
|
|
275
|
+
{username: "tensorbuzz", routeClass: "starlink", gatewayId: "zyxel", host: "docker-b", port: 2375}
|
|
276
|
+
])
|
|
272
277
|
})
|
|
273
278
|
|
|
274
279
|
test("loadEnvironmentConfig parses outbound reverse gateway verified TLS settings", () => {
|
|
@@ -296,6 +301,7 @@ test("loadEnvironmentConfig rejects malformed reverse mode policy and credential
|
|
|
296
301
|
assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "unknown"}), /Unsupported SOCKETDUCT_MODE/)
|
|
297
302
|
assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "reverse-hub", SOCKETDUCT_REVERSE_GATEWAY_BINDINGS: "missing-user"}), /gatewayId:username/)
|
|
298
303
|
assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "reverse-hub", SOCKETDUCT_REVERSE_ROUTES: "too:few:fields"}), /username:gatewayId:host:port/)
|
|
304
|
+
assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "reverse-hub", SOCKETDUCT_REVERSE_ROUTE_CLASS_ROUTES: "too:few:fields"}), /username:routeClass:gatewayId:host:port/)
|
|
299
305
|
assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "reverse-gateway", SOCKETDUCT_REVERSE_TOKEN: "replace-me"}), /placeholder/)
|
|
300
306
|
assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "reverse-gateway", SOCKETDUCT_REVERSE_HUB_CLIENT_CERT_FILE: "/certs/client.crt"}), /must both be set/)
|
|
301
307
|
})
|