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
package/src/reverse/runtime.js
CHANGED
|
@@ -9,13 +9,28 @@ export async function startReverseHubRuntime(config) {
|
|
|
9
9
|
if (config.relayTls === undefined) throw new Error("Reverse hub mode requires SOCKETDUCT_TLS_CERT_FILE and SOCKETDUCT_TLS_KEY_FILE")
|
|
10
10
|
if (config.authTokens.length === 0) throw new Error("SOCKETDUCT_AUTH_TOKENS must contain hub client and gateway credentials")
|
|
11
11
|
if (config.reverseGatewayBindings === undefined || config.reverseGatewayBindings.length === 0) throw new Error("SOCKETDUCT_REVERSE_GATEWAY_BINDINGS must bind at least one gateway ID to a credential username")
|
|
12
|
-
|
|
12
|
+
const exactRoutes = config.reverseRoutes ?? []
|
|
13
|
+
const routeClassRoutes = config.reverseRouteClassRoutes ?? []
|
|
14
|
+
if (exactRoutes.length === 0 && routeClassRoutes.length === 0) throw new Error("SOCKETDUCT_REVERSE_ROUTES or SOCKETDUCT_REVERSE_ROUTE_CLASS_ROUTES must allow at least one client route")
|
|
13
15
|
if (config.compression === "off") throw new Error("Reverse hub mode requires SOCKETDUCT_COMPRESSION=auto")
|
|
14
16
|
const bindings = new Map(config.reverseGatewayBindings.map((binding) => [binding.gatewayId, binding.username]))
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
+
const authorizedRoutes = new Set([...exactRoutes, ...routeClassRoutes].map((route) => formatRoute(route.username, route.gatewayId, {host: route.host, port: route.port})))
|
|
18
|
+
const exactRouteKeys = new Set(exactRoutes.map((route) => formatRoute(route.username, route.gatewayId, {host: route.host, port: route.port})))
|
|
19
|
+
const exactSelections = new Set(exactRoutes.map((route) => `${route.username}\0${route.gatewayId}`))
|
|
20
|
+
const routeClassSelections = new Set(routeClassRoutes.map((route) => `${route.username}\0${route.routeClass}`))
|
|
21
|
+
const knownRouteClasses = [...new Set(routeClassRoutes.map((route) => route.routeClass))]
|
|
22
|
+
/** @type {Map<string, string[]>} */
|
|
23
|
+
const routeClassGateways = new Map()
|
|
24
|
+
for (const route of routeClassRoutes) {
|
|
25
|
+
const key = formatRouteClassRoute(route.username, route.routeClass, {host: route.host, port: route.port})
|
|
26
|
+
const gatewayIds = routeClassGateways.get(key) ?? []
|
|
27
|
+
gatewayIds.push(route.gatewayId)
|
|
28
|
+
routeClassGateways.set(key, gatewayIds)
|
|
29
|
+
}
|
|
30
|
+
const routeClassRows = new Set(routeClassRoutes.map((route) => `${formatRouteClassRoute(route.username, route.routeClass, {host: route.host, port: route.port})}\0${route.gatewayId}`))
|
|
17
31
|
if (bindings.size !== config.reverseGatewayBindings.length) throw new Error("SOCKETDUCT_REVERSE_GATEWAY_BINDINGS contains a duplicate gateway ID")
|
|
18
|
-
if (
|
|
32
|
+
if (exactRouteKeys.size !== exactRoutes.length) throw new Error("SOCKETDUCT_REVERSE_ROUTES contains a duplicate route")
|
|
33
|
+
if (routeClassRows.size !== routeClassRoutes.length) throw new Error("SOCKETDUCT_REVERSE_ROUTE_CLASS_ROUTES contains a duplicate route")
|
|
19
34
|
const relayTls = config.relayTls
|
|
20
35
|
const tls = {
|
|
21
36
|
certFile: relayTls.certFile,
|
|
@@ -28,9 +43,15 @@ export async function startReverseHubRuntime(config) {
|
|
|
28
43
|
tls,
|
|
29
44
|
tokens: new StaticTokenStore(config.authTokens),
|
|
30
45
|
knownGatewayIds: [...bindings.keys()],
|
|
46
|
+
...(knownRouteClasses.length === 0 ? {} : {knownRouteClasses}),
|
|
31
47
|
authorizeGateway: ({principal, gatewayId}) => bindings.get(gatewayId) === principal.username,
|
|
32
|
-
authorizeClientGateway: ({principal, gatewayId}) =>
|
|
33
|
-
|
|
48
|
+
authorizeClientGateway: ({principal, gatewayId}) => exactSelections.has(`${principal.username}\0${gatewayId}`),
|
|
49
|
+
...(knownRouteClasses.length === 0 ? {} : {
|
|
50
|
+
authorizeClientRouteClass: ({principal, routeClass}) => routeClassSelections.has(`${principal.username}\0${routeClass}`),
|
|
51
|
+
resolveOpenGateways: ({principal, routeClass, target}) => [...(routeClassGateways.get(formatRouteClassRoute(principal.username, routeClass, target)) ?? [])]
|
|
52
|
+
}),
|
|
53
|
+
authorizeOpen: ({principal, gatewayId, target}) => authorizedRoutes.has(formatRoute(principal.username, gatewayId, target)),
|
|
54
|
+
resumeWindowMs: config.resumeWindowMs,
|
|
34
55
|
handshakeTimeoutMs: config.handshakeTimeoutMs,
|
|
35
56
|
maxInputBytesPerConnection: config.maxInputBytesPerConnection,
|
|
36
57
|
compressionLimits: compressionLimits(config)
|
|
@@ -68,6 +89,11 @@ function formatRoute(username, gatewayId, target) {
|
|
|
68
89
|
return `${username}\0${gatewayId}\0${target.host}\0${target.port}`
|
|
69
90
|
}
|
|
70
91
|
|
|
92
|
+
/** @param {string} username - Client. @param {string} routeClass - Route class. @param {{host: string, port: number}} target - Target. */
|
|
93
|
+
function formatRouteClassRoute(username, routeClass, target) {
|
|
94
|
+
return `${username}\0${routeClass}\0${target.host}\0${target.port}`
|
|
95
|
+
}
|
|
96
|
+
|
|
71
97
|
/** @param {import('../config/environment.js').RelayEnvironmentConfig} config - Runtime limits. @returns {import('../protocol/compression.js').CompressionLimits} Defined codec limits. */
|
|
72
98
|
function compressionLimits(config) {
|
|
73
99
|
return {
|
|
@@ -6,6 +6,7 @@ import {createServer} from "node:net"
|
|
|
6
6
|
import {tmpdir} from "node:os"
|
|
7
7
|
import {join} from "node:path"
|
|
8
8
|
import test from "node:test"
|
|
9
|
+
import {connect as connectTls} from "node:tls"
|
|
9
10
|
|
|
10
11
|
import {ReverseGatewayConnection} from "../../src/reverse/client.js"
|
|
11
12
|
import {generateTestCertificates} from "../helpers/tls-certificates.js"
|
|
@@ -283,3 +284,95 @@ test("spawned reverse hub and outbound gateway expose an allowlisted Docker stre
|
|
|
283
284
|
assert(gatewayEntries.some(({event}) => event === "transport.connected"))
|
|
284
285
|
assert.doesNotMatch(gatewayContents, /process-gateway-token|127\.0\.0\.1|docker-server/)
|
|
285
286
|
})
|
|
287
|
+
|
|
288
|
+
test("one route-class client physical connection interleaves streams through different gateway processes", async (t) => {
|
|
289
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-route-class-process-"))
|
|
290
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
291
|
+
const certificates = generateTestCertificates(root)
|
|
292
|
+
const targetSockets = new Set()
|
|
293
|
+
const targetA = createServer((socket) => {
|
|
294
|
+
targetSockets.add(socket)
|
|
295
|
+
socket.once("close", () => targetSockets.delete(socket))
|
|
296
|
+
socket.on("data", (chunk) => socket.write(Buffer.concat([Buffer.from("a:"), chunk])))
|
|
297
|
+
})
|
|
298
|
+
const targetB = createServer((socket) => {
|
|
299
|
+
targetSockets.add(socket)
|
|
300
|
+
socket.once("close", () => targetSockets.delete(socket))
|
|
301
|
+
socket.on("data", (chunk) => socket.write(Buffer.concat([Buffer.from("b:"), chunk])))
|
|
302
|
+
})
|
|
303
|
+
const targetAPort = await listen(targetA)
|
|
304
|
+
const targetBPort = await listen(targetB)
|
|
305
|
+
t.after(() => {
|
|
306
|
+
for (const socket of targetSockets) socket.destroy()
|
|
307
|
+
return Promise.all([
|
|
308
|
+
new Promise((resolve) => targetA.close(() => resolve(undefined))),
|
|
309
|
+
new Promise((resolve) => targetB.close(() => resolve(undefined)))
|
|
310
|
+
])
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
const {child: hub, port: hubPort} = await startProcessOnReservedPort({
|
|
314
|
+
SOCKETDUCT_MODE: "reverse-hub",
|
|
315
|
+
SOCKETDUCT_RELAY_HOST: "127.0.0.1",
|
|
316
|
+
SOCKETDUCT_TLS_CERT_FILE: certificates.serverCertFile,
|
|
317
|
+
SOCKETDUCT_TLS_KEY_FILE: certificates.serverKeyFile,
|
|
318
|
+
SOCKETDUCT_AUTH_TOKENS: "home-a:gateway-a-token,home-b:gateway-b-token,tensorbuzz:client-token",
|
|
319
|
+
SOCKETDUCT_REVERSE_GATEWAY_BINDINGS: "server-a-starlink:home-a,server-b-starlink:home-b",
|
|
320
|
+
SOCKETDUCT_REVERSE_ROUTE_CLASS_ROUTES: `tensorbuzz:starlink:server-a-starlink:127.0.0.1:${targetAPort},tensorbuzz:starlink:server-b-starlink:127.0.0.1:${targetBPort}`,
|
|
321
|
+
SOCKETDUCT_STATUS_INTERVAL_MS: "0"
|
|
322
|
+
}, "reverse-hub.started")
|
|
323
|
+
t.after(() => stopProcess(hub))
|
|
324
|
+
|
|
325
|
+
const gatewayA = await startProcess({
|
|
326
|
+
SOCKETDUCT_MODE: "reverse-gateway",
|
|
327
|
+
SOCKETDUCT_REVERSE_GATEWAY_ID: "server-a-starlink",
|
|
328
|
+
SOCKETDUCT_REVERSE_CONNECTION_ID: "route_class_process_gateway_a",
|
|
329
|
+
SOCKETDUCT_REVERSE_HUB_HOST: "127.0.0.1",
|
|
330
|
+
SOCKETDUCT_REVERSE_HUB_PORT: String(hubPort),
|
|
331
|
+
SOCKETDUCT_REVERSE_TOKEN: "gateway-a-token",
|
|
332
|
+
SOCKETDUCT_REVERSE_HUB_CA_FILE: certificates.caCertFile,
|
|
333
|
+
SOCKETDUCT_REVERSE_HUB_SERVERNAME: "localhost",
|
|
334
|
+
SOCKETDUCT_TARGET_ALLOWLIST: `127.0.0.1:${targetAPort}`,
|
|
335
|
+
SOCKETDUCT_STATUS_INTERVAL_MS: "0"
|
|
336
|
+
}, "reverse-gateway.started")
|
|
337
|
+
t.after(() => stopProcess(gatewayA))
|
|
338
|
+
const gatewayB = await startProcess({
|
|
339
|
+
SOCKETDUCT_MODE: "reverse-gateway",
|
|
340
|
+
SOCKETDUCT_REVERSE_GATEWAY_ID: "server-b-starlink",
|
|
341
|
+
SOCKETDUCT_REVERSE_CONNECTION_ID: "route_class_process_gateway_b",
|
|
342
|
+
SOCKETDUCT_REVERSE_HUB_HOST: "127.0.0.1",
|
|
343
|
+
SOCKETDUCT_REVERSE_HUB_PORT: String(hubPort),
|
|
344
|
+
SOCKETDUCT_REVERSE_TOKEN: "gateway-b-token",
|
|
345
|
+
SOCKETDUCT_REVERSE_HUB_CA_FILE: certificates.caCertFile,
|
|
346
|
+
SOCKETDUCT_REVERSE_HUB_SERVERNAME: "localhost",
|
|
347
|
+
SOCKETDUCT_TARGET_ALLOWLIST: `127.0.0.1:${targetBPort}`,
|
|
348
|
+
SOCKETDUCT_STATUS_INTERVAL_MS: "0"
|
|
349
|
+
}, "reverse-gateway.started")
|
|
350
|
+
t.after(() => stopProcess(gatewayB))
|
|
351
|
+
|
|
352
|
+
let clientPhysicalConnections = 0
|
|
353
|
+
const client = new ReverseGatewayConnection({
|
|
354
|
+
routeClass: "starlink",
|
|
355
|
+
connectionId: "route_class_process_client",
|
|
356
|
+
hub: {host: "127.0.0.1", port: hubPort, token: "client-token"},
|
|
357
|
+
tls: {caFile: certificates.caCertFile, servername: "localhost"},
|
|
358
|
+
createPhysicalSocket: ({host, port, tls}) => {
|
|
359
|
+
clientPhysicalConnections += 1
|
|
360
|
+
return connectTls({host, port, ...tls})
|
|
361
|
+
}
|
|
362
|
+
})
|
|
363
|
+
t.after(() => client.close())
|
|
364
|
+
await withTimeout(client.connect(), "route-class process client connect")
|
|
365
|
+
const streamA = client.createConnection({host: "127.0.0.1", port: targetAPort, sessionName: "route-class-a"})
|
|
366
|
+
const streamB = client.createConnection({host: "127.0.0.1", port: targetBPort, sessionName: "route-class-b"})
|
|
367
|
+
await withTimeout(Promise.all([onceEvent(streamA, "connect"), onceEvent(streamB, "connect")]), "route-class process streams")
|
|
368
|
+
const responseA = new Promise((resolve, reject) => { streamA.once("error", reject); streamA.once("data", resolve) })
|
|
369
|
+
const responseB = new Promise((resolve, reject) => { streamB.once("error", reject); streamB.once("data", resolve) })
|
|
370
|
+
streamA.write("one")
|
|
371
|
+
streamB.write("two")
|
|
372
|
+
assert.equal(Buffer.from(/** @type {Uint8Array} */ (await withTimeout(responseA, "route-class response A"))).toString(), "a:one")
|
|
373
|
+
assert.equal(Buffer.from(/** @type {Uint8Array} */ (await withTimeout(responseB, "route-class response B"))).toString(), "b:two")
|
|
374
|
+
assert.equal(clientPhysicalConnections, 1)
|
|
375
|
+
assert.equal(client.status().physicalConnects, 1)
|
|
376
|
+
streamA.destroy()
|
|
377
|
+
streamB.destroy()
|
|
378
|
+
})
|