socketduct 0.1.16 → 0.1.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +3 -0
- package/changelog.d/20260924120000-wan-route-classes.md +1 -0
- package/changelog.d/20260925120000-reverse-stream-transport-resume.md +1 -0
- package/docs/architecture.md +28 -8
- package/docs/protocol.md +59 -15
- package/docs/reverse-gateway.md +121 -55
- package/docs/security.md +28 -5
- package/docs/session-lifecycle.md +21 -10
- package/docs/socket-api.md +59 -23
- package/package.json +1 -1
- package/src/client/deferred-virtual-socket.js +16 -4
- package/src/client/multiplexed-transport-set.js +106 -65
- package/src/config/environment.js +24 -1
- package/src/reverse/client.js +283 -50
- package/src/reverse/common.js +1 -0
- package/src/reverse/gateway.js +265 -38
- package/src/reverse/hub.js +464 -52
- package/src/reverse/runtime.js +32 -6
- package/test/e2e/reverse-gateway-process.test.js +93 -0
- package/test/integration/reverse-gateway.test.js +576 -28
- package/test/integration/reverse-route-class.test.js +377 -0
- package/test/unit/client/multiplexed-transport-set.test.js +42 -0
- package/test/unit/config/environment.test.js +7 -1
|
@@ -0,0 +1,377 @@
|
|
|
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,
|
|
136
|
+
maxReconnectDelayMs: 20
|
|
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,
|
|
145
|
+
maxReconnectDelayMs: 20
|
|
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 resumed = Promise.all([onceEvent(streamA, "ready"), onceEvent(streamB, "ready")])
|
|
210
|
+
await hub.closeActiveGateway("server-a-starlink")
|
|
211
|
+
await withTimeout(resumed, "route-class streams resume after gateway transport replacement")
|
|
212
|
+
await waitUntil(() => hub.status().healthyGateways === 2, "gateway A re-registration")
|
|
213
|
+
assert.equal(await exchange(streamA, "resumed"), "a:resumed")
|
|
214
|
+
assert.equal(await exchange(streamB, "still-open"), "b:still-open")
|
|
215
|
+
|
|
216
|
+
const anotherA = client.createConnection({host: targetA.host, port: targetA.port})
|
|
217
|
+
await onceEvent(anotherA, "connect")
|
|
218
|
+
assert.equal(await exchange(anotherA, "new-a"), "a:new-a")
|
|
219
|
+
const anotherB = client.createConnection({host: targetB.host, port: targetB.port})
|
|
220
|
+
await onceEvent(anotherB, "connect")
|
|
221
|
+
assert.equal(await exchange(anotherB, "new"), "b:new")
|
|
222
|
+
|
|
223
|
+
streamA.destroy()
|
|
224
|
+
anotherA.destroy()
|
|
225
|
+
await gatewayA.close()
|
|
226
|
+
const replacementA = new OutboundReverseGateway({
|
|
227
|
+
gatewayId: "server-a-starlink",
|
|
228
|
+
connectionId: "route_class_gateway_a",
|
|
229
|
+
initialGeneration: gatewayA.status().connectionGeneration,
|
|
230
|
+
hub: {host: "127.0.0.1", port: hub.port, token: "gateway-a-token"},
|
|
231
|
+
tls,
|
|
232
|
+
authorizeTarget: (target) => target.host === targetA.host && target.port === targetA.port
|
|
233
|
+
})
|
|
234
|
+
t.after(() => replacementA.close())
|
|
235
|
+
await replacementA.start()
|
|
236
|
+
const rejoinedA = client.createConnection({host: targetA.host, port: targetA.port})
|
|
237
|
+
await onceEvent(rejoinedA, "connect")
|
|
238
|
+
assert.equal(await exchange(rejoinedA, "rejoined"), "a:rejoined")
|
|
239
|
+
assert(client.status().physicalConnects >= 2)
|
|
240
|
+
assert.equal(hub.status().clientPhysicalConnections, 1)
|
|
241
|
+
streamB.destroy()
|
|
242
|
+
anotherB.destroy()
|
|
243
|
+
rejoinedA.destroy()
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
test("a backpressured gateway does not block DATA or a new OPEN for another route-class gateway", async (t) => {
|
|
247
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-route-class-backpressure-"))
|
|
248
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
249
|
+
const certificates = generateTestCertificates(root)
|
|
250
|
+
const targetA = await createEchoTarget("a:")
|
|
251
|
+
const targetB = await createEchoTarget("b:")
|
|
252
|
+
t.after(() => targetA.close())
|
|
253
|
+
t.after(() => targetB.close())
|
|
254
|
+
|
|
255
|
+
const hub = await startReverseGatewayHub({
|
|
256
|
+
tokens: new StaticTokenStore([
|
|
257
|
+
{username: "home-a", token: "gateway-a-token"},
|
|
258
|
+
{username: "home-b", token: "gateway-b-token"},
|
|
259
|
+
{username: "tensorbuzz", token: "client-token"}
|
|
260
|
+
]),
|
|
261
|
+
tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
|
|
262
|
+
knownGatewayIds: ["server-a-starlink", "server-b-starlink"],
|
|
263
|
+
knownRouteClasses: ["starlink"],
|
|
264
|
+
authorizeGateway: ({principal, gatewayId}) => (
|
|
265
|
+
(principal.username === "home-a" && gatewayId === "server-a-starlink")
|
|
266
|
+
|| (principal.username === "home-b" && gatewayId === "server-b-starlink")
|
|
267
|
+
),
|
|
268
|
+
authorizeClientGateway: () => false,
|
|
269
|
+
authorizeClientRouteClass: ({principal, routeClass}) => principal.username === "tensorbuzz" && routeClass === "starlink",
|
|
270
|
+
resolveOpenGateways: ({routeClass, target}) => {
|
|
271
|
+
if (routeClass !== "starlink" || target.host !== "127.0.0.1") return []
|
|
272
|
+
if (target.port === targetA.port) return ["server-a-starlink"]
|
|
273
|
+
if (target.port === targetB.port) return ["server-b-starlink"]
|
|
274
|
+
return []
|
|
275
|
+
},
|
|
276
|
+
authorizeOpen: ({gatewayId, target}) => (
|
|
277
|
+
(gatewayId === "server-a-starlink" && target.port === targetA.port)
|
|
278
|
+
|| (gatewayId === "server-b-starlink" && target.port === targetB.port)
|
|
279
|
+
),
|
|
280
|
+
maxOutputBytesPerConnection: 1_024
|
|
281
|
+
})
|
|
282
|
+
t.after(() => hub.close())
|
|
283
|
+
/** @type {import('node:tls').TLSSocket[]} */
|
|
284
|
+
const accepted = []
|
|
285
|
+
hub.server.on("secureConnection", (socket) => accepted.push(socket))
|
|
286
|
+
const tls = {caFile: certificates.caCertFile, servername: "localhost"}
|
|
287
|
+
const gatewayA = new OutboundReverseGateway({
|
|
288
|
+
gatewayId: "server-a-starlink",
|
|
289
|
+
connectionId: "backpressure_gateway_a",
|
|
290
|
+
hub: {host: "127.0.0.1", port: hub.port, token: "gateway-a-token"},
|
|
291
|
+
tls,
|
|
292
|
+
authorizeTarget: (target) => target.host === targetA.host && target.port === targetA.port,
|
|
293
|
+
reconnectDelayMs: 10_000,
|
|
294
|
+
maxReconnectDelayMs: 10_000
|
|
295
|
+
})
|
|
296
|
+
const gatewayB = new OutboundReverseGateway({
|
|
297
|
+
gatewayId: "server-b-starlink",
|
|
298
|
+
connectionId: "backpressure_gateway_b",
|
|
299
|
+
hub: {host: "127.0.0.1", port: hub.port, token: "gateway-b-token"},
|
|
300
|
+
tls,
|
|
301
|
+
authorizeTarget: (target) => target.host === targetB.host && target.port === targetB.port,
|
|
302
|
+
reconnectDelayMs: 10_000,
|
|
303
|
+
maxReconnectDelayMs: 10_000
|
|
304
|
+
})
|
|
305
|
+
t.after(() => gatewayA.close())
|
|
306
|
+
t.after(() => gatewayB.close())
|
|
307
|
+
await gatewayA.start()
|
|
308
|
+
await gatewayB.start()
|
|
309
|
+
assert.equal(accepted.length, 2)
|
|
310
|
+
|
|
311
|
+
const client = new ReverseGatewayConnection({
|
|
312
|
+
routeClass: "starlink",
|
|
313
|
+
connectionId: "backpressure_route_client",
|
|
314
|
+
hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
|
|
315
|
+
tls,
|
|
316
|
+
continuousReconnect: false,
|
|
317
|
+
reconnectDelayMs: 10_000,
|
|
318
|
+
maxReconnectDelayMs: 10_000
|
|
319
|
+
})
|
|
320
|
+
t.after(() => client.close())
|
|
321
|
+
await client.connect()
|
|
322
|
+
const streamA = client.createConnection({host: targetA.host, port: targetA.port})
|
|
323
|
+
const streamB = client.createConnection({host: targetB.host, port: targetB.port})
|
|
324
|
+
await Promise.all([onceEvent(streamA, "connect"), onceEvent(streamB, "connect")])
|
|
325
|
+
|
|
326
|
+
const gatewayASocket = /** @type {import('node:tls').TLSSocket} */ (accepted[0])
|
|
327
|
+
const writable = /** @type {{write: (chunk: Buffer) => boolean}} */ (/** @type {unknown} */ (gatewayASocket))
|
|
328
|
+
const originalWrite = writable.write
|
|
329
|
+
let blocked = false
|
|
330
|
+
/** @type {() => void} */
|
|
331
|
+
let markBlocked = () => {}
|
|
332
|
+
const backpressured = new Promise((resolve) => { markBlocked = () => resolve(undefined) })
|
|
333
|
+
writable.write = (chunk) => {
|
|
334
|
+
const acceptedWrite = Reflect.apply(originalWrite, gatewayASocket, [chunk])
|
|
335
|
+
if (!blocked && chunk[5] === V3FrameType.DATA) {
|
|
336
|
+
blocked = true
|
|
337
|
+
markBlocked()
|
|
338
|
+
return false
|
|
339
|
+
}
|
|
340
|
+
return acceptedWrite
|
|
341
|
+
}
|
|
342
|
+
t.after(() => {
|
|
343
|
+
writable.write = originalWrite
|
|
344
|
+
if (blocked) gatewayASocket.emit("drain")
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
streamA.write("blocked")
|
|
348
|
+
await withTimeout(backpressured, "gateway A backpressure")
|
|
349
|
+
const newStreamB = client.createConnection({host: targetB.host, port: targetB.port})
|
|
350
|
+
t.after(() => newStreamB.destroy())
|
|
351
|
+
const newStreamBExchange = (async () => {
|
|
352
|
+
await onceEvent(newStreamB, "connect")
|
|
353
|
+
return await exchange(newStreamB, "new-open")
|
|
354
|
+
})()
|
|
355
|
+
assert.deepEqual(await withTimeout(Promise.all([
|
|
356
|
+
exchange(streamB, "existing"),
|
|
357
|
+
newStreamBExchange
|
|
358
|
+
]), "healthy gateway progress while gateway A is backpressured"), ["b:existing", "b:new-open"])
|
|
359
|
+
assert.equal(client.status().physicalConnects, 1)
|
|
360
|
+
assert(hub.status().queuedOutputBytes > 0)
|
|
361
|
+
const streamAError = nextError(streamA)
|
|
362
|
+
const queuedAErrors = Array.from({length: 8}, () => {
|
|
363
|
+
const socket = client.createConnection({host: targetA.host, port: targetA.port, sessionName: "a".repeat(256)})
|
|
364
|
+
t.after(() => socket.destroy())
|
|
365
|
+
return nextError(socket)
|
|
366
|
+
})
|
|
367
|
+
assert.equal((await withTimeout(streamAError, "gateway A output limit")).code, "OUTPUT_BACKPRESSURE_LIMIT")
|
|
368
|
+
await withTimeout(Promise.all(queuedAErrors), "queued gateway A stream cleanup")
|
|
369
|
+
assert.equal(await exchange(streamB, "after-limit"), "b:after-limit")
|
|
370
|
+
assert.equal(hub.status().healthyGateways, 1)
|
|
371
|
+
assert.equal(hub.status().clientPhysicalConnections, 1)
|
|
372
|
+
assert.equal(client.status().physicalConnects, 1)
|
|
373
|
+
writable.write = originalWrite
|
|
374
|
+
assert.equal(hub.status().queuedOutputBytes, 0)
|
|
375
|
+
streamA.destroy()
|
|
376
|
+
streamB.destroy()
|
|
377
|
+
})
|
|
@@ -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 = []
|
|
@@ -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
|
})
|