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.
@@ -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 exact routes", () => {
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
  })