socketduct 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/.dockerignore +11 -0
  2. package/.env.example +131 -0
  3. package/AGENTS.md +168 -0
  4. package/Dockerfile +30 -0
  5. package/README.md +134 -0
  6. package/changelog.d/20260716083144-v2-ack-wire-format.md +3 -0
  7. package/changelog.d/20260716092044-v2-only-stream-framing.md +1 -0
  8. package/changelog.d/20260727113000-relay-connect-timeout-destroy.md +1 -0
  9. package/changelog.d/20260813093000-private-relay-hardening.md +1 -0
  10. package/changelog.d/20260824183412-initial-target-recovery-https.md +1 -0
  11. package/changelog.d/20260824230000-tls-relay-session-close.md +1 -0
  12. package/changelog.d/20260825120000-reconnect-close-target-timeout.md +1 -0
  13. package/changelog.d/20260829120000-protocol-v3-multiplexing.md +1 -0
  14. package/changelog.d/20260829160000-v3-review-corrections.md +1 -0
  15. package/changelog.d/20260830000000-fix-socket-idle-timeout.md +1 -0
  16. package/changelog.d/20260830090000-multi-gateway-transport-set.md +1 -0
  17. package/changelog.d/20260830093000-relay-close-settlement.md +1 -0
  18. package/changelog.d/20260830120000-immutable-gateway-image.md +1 -0
  19. package/changelog.d/20260902210000-native-reverse-gateway.md +1 -0
  20. package/changelog.d/20260902230000-relay-resume-terminal-order.md +1 -0
  21. package/changelog.d/20260902230708-multiplexed-open-ownership.md +1 -0
  22. package/changelog.d/20260902231541-multiplexed-terminal-close.md +1 -0
  23. package/changelog.d/20260902231542-reverse-close-position.md +1 -0
  24. package/changelog.d/20260902231543-reverse-terminal-connect.md +1 -0
  25. package/changelog.d/20260902233000-reverse-gateway-bounds.md +1 -0
  26. package/changelog.d/20260903001000-reverse-data-ordering.md +1 -0
  27. package/changelog.d/20260903003000-reverse-hub-restart-window.md +1 -0
  28. package/changelog.d/20260903010000-reverse-terminal-lifecycle.md +1 -0
  29. package/changelog.d/20260904142743-transport-set-readiness-wait.md +1 -0
  30. package/changelog.d/20260904152855-deferred-open-snapshots-binary-views.md +1 -0
  31. package/changelog.d/20260904163041-tensorbuzz-container-workflow.md +1 -0
  32. package/changelog.d/20260904174955-transport-set-terminal-causes.md +1 -0
  33. package/changelog.d/20260905081000-contain-reverse-auth-write-errors.md +1 -0
  34. package/changelog.d/20260905100520-reverse-hub-close-race.md +1 -0
  35. package/changelog.d/20260905120000-persistent-jsonl-logging.md +1 -0
  36. package/changelog.d/20260908071009-jsonl-partial-append-recovery.md +1 -0
  37. package/changelog.d/20260908074916-private-log-directory-setup.md +1 -0
  38. package/changelog.d/20260909093057-reverse-large-write-backpressure.md +1 -0
  39. package/changelog.d/20260909135830-actionable-error-diagnostics.md +1 -0
  40. package/changelog.d/20260909150000-transport-set-error-redaction.md +1 -0
  41. package/docker-compose.dev.yml +19 -0
  42. package/docker-compose.reverse-gateway.yml +36 -0
  43. package/docker-compose.yml +73 -0
  44. package/docs/architecture.md +264 -0
  45. package/docs/docker-topology.md +164 -0
  46. package/docs/docker.md +281 -0
  47. package/docs/logging.md +185 -0
  48. package/docs/plans/initial-implementation.md +205 -0
  49. package/docs/plans/socket-docker-integration.md +221 -0
  50. package/docs/protocol.md +558 -0
  51. package/docs/releasing.md +18 -0
  52. package/docs/reverse-gateway.md +186 -0
  53. package/docs/runbooks/jsonl-integrity-rollout.md +144 -0
  54. package/docs/running.md +215 -0
  55. package/docs/security.md +258 -0
  56. package/docs/session-lifecycle.md +127 -0
  57. package/docs/socket-api.md +448 -0
  58. package/eslint.config.js +36 -0
  59. package/package.json +44 -0
  60. package/scripts/deploy-gateway.js +194 -0
  61. package/scripts/test.js +28 -0
  62. package/src/auth/passwords.js +38 -0
  63. package/src/auth/static-token-store.js +41 -0
  64. package/src/auth/user-store.js +139 -0
  65. package/src/client/connect.js +76 -0
  66. package/src/client/deferred-virtual-socket.js +312 -0
  67. package/src/client/http-agent.js +101 -0
  68. package/src/client/https-agent.js +366 -0
  69. package/src/client/multiplexed-connection.js +1504 -0
  70. package/src/client/multiplexed-transport-set.js +917 -0
  71. package/src/client/multiplexed.js +3 -0
  72. package/src/client/socketduct-socket.js +1174 -0
  73. package/src/client/virtual-socket.js +256 -0
  74. package/src/config/environment.js +400 -0
  75. package/src/edge/tcp-edge.js +82 -0
  76. package/src/index.js +101 -0
  77. package/src/logging/structured-logger.js +745 -0
  78. package/src/protocol/compression.js +325 -0
  79. package/src/protocol/frame.js +341 -0
  80. package/src/protocol/v3-compression.js +44 -0
  81. package/src/protocol/v3-frame.js +209 -0
  82. package/src/proxy/multiplexed-relay.js +798 -0
  83. package/src/proxy/tcp-relay.js +1191 -0
  84. package/src/relay.js +3 -0
  85. package/src/reverse/client.js +721 -0
  86. package/src/reverse/common.js +88 -0
  87. package/src/reverse/gateway.js +663 -0
  88. package/src/reverse/hub.js +563 -0
  89. package/src/reverse/index.js +3 -0
  90. package/src/reverse/runtime.js +78 -0
  91. package/src/server/control-handler.js +170 -0
  92. package/src/server/primary.js +64 -0
  93. package/src/server/relay-runtime.js +124 -0
  94. package/src/server/worker.js +39 -0
  95. package/src/sessions/session.js +212 -0
  96. package/src/spool/spool-file.js +451 -0
  97. package/tensorbuzz.yml +36 -0
  98. package/test/e2e/gateway-process.test.js +262 -0
  99. package/test/e2e/reverse-gateway-process.test.js +285 -0
  100. package/test/fixtures/logging/enospc-matrix.mjs +238 -0
  101. package/test/fixtures/logging/enospc-recovery.mjs +146 -0
  102. package/test/fixtures/logging/process-writer.mjs +20 -0
  103. package/test/helpers/tls-certificates.js +115 -0
  104. package/test/integration/initial-target-recovery.test.js +518 -0
  105. package/test/integration/logging-recovery.test.js +163 -0
  106. package/test/integration/multiplexed-compatibility.test.js +254 -0
  107. package/test/integration/multiplexed-corrections.test.js +551 -0
  108. package/test/integration/multiplexed-failures.test.js +339 -0
  109. package/test/integration/multiplexed-transport-set.test.js +456 -0
  110. package/test/integration/multiplexed-transport.test.js +208 -0
  111. package/test/integration/reverse-gateway.test.js +1293 -0
  112. package/test/integration/socketduct-http-agent.test.js +435 -0
  113. package/test/integration/socketduct-https-agent.test.js +669 -0
  114. package/test/integration/stream-compression.test.js +1267 -0
  115. package/test/integration/tcp-forwarding.test.js +2878 -0
  116. package/test/integration/tls-forwarding.test.js +748 -0
  117. package/test/unit/auth/auth.test.js +64 -0
  118. package/test/unit/auth/static-token-store.test.js +14 -0
  119. package/test/unit/client/connect.test.js +58 -0
  120. package/test/unit/client/multiplexed-spool-budget.test.js +309 -0
  121. package/test/unit/client/multiplexed-transport-set.test.js +906 -0
  122. package/test/unit/client/socketduct-socket.test.js +1124 -0
  123. package/test/unit/client/virtual-socket.test.js +98 -0
  124. package/test/unit/config/docker-gateway-files.test.js +274 -0
  125. package/test/unit/config/environment.test.js +507 -0
  126. package/test/unit/index.test.js +78 -0
  127. package/test/unit/logging/structured-logger.test.js +674 -0
  128. package/test/unit/protocol/compression.test.js +421 -0
  129. package/test/unit/protocol/frame.test.js +336 -0
  130. package/test/unit/protocol/v3-compression.test.js +31 -0
  131. package/test/unit/protocol/v3-frame.test.js +57 -0
  132. package/test/unit/release-configuration.test.js +21 -0
  133. package/test/unit/server/control-handler.test.js +291 -0
  134. package/test/unit/server/primary.test.js +59 -0
  135. package/test/unit/server/relay-runtime.test.js +434 -0
  136. package/test/unit/sessions/session.test.js +84 -0
  137. package/test/unit/spool/spool-file.test.js +295 -0
  138. package/tsconfig.json +20 -0
@@ -0,0 +1,563 @@
1
+ /* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param, jsdoc/require-property-description, jsdoc/require-returns */
2
+ import {readFileSync} from "node:fs"
3
+ import {createServer as createTlsServer} from "node:tls"
4
+
5
+ import {availableCodecs, negotiateCodec, zstdAvailable} from "../protocol/compression.js"
6
+ import {decodeV3Data, encodeV3Data, AdaptiveCompressor} from "../protocol/v3-compression.js"
7
+ import {decodeV3Control, encodeV3Control, encodeV3Frame, V3FrameDecoder, V3FrameType} from "../protocol/v3-frame.js"
8
+ import {
9
+ DEFAULT_FRAME_BYTES,
10
+ DEFAULT_HANDSHAKE_MS,
11
+ DEFAULT_KEEP_ALIVE_INITIAL_DELAY_MS,
12
+ DEFAULT_MAX_INPUT_BYTES,
13
+ DEFAULT_MAX_STREAMS,
14
+ DEFAULT_WINDOW,
15
+ GATEWAY_ID,
16
+ MAX_CONTROL_BYTES,
17
+ parseTarget,
18
+ positiveInteger,
19
+ protocolError,
20
+ STABLE_IDENTIFIER,
21
+ writeSocket
22
+ } from "./common.js"
23
+
24
+ /**
25
+ * @typedef {{userId: string, username: string}} Principal
26
+ * @typedef {{host: string, port: number}} Target
27
+ * @typedef {object} ReverseGatewayHubOptions
28
+ * @property {string} [host] - TLS listen host, default loopback.
29
+ * @property {number} [port] - TLS listen port.
30
+ * @property {{authenticate(token: string): Principal | undefined}} tokens - Client and gateway bearer authenticator.
31
+ * @property {{certFile: string, keyFile: string, clientCaFile?: string}} tls - Mandatory outer TLS server configuration.
32
+ * @property {(input: {principal: Principal, gatewayId: string}) => boolean} authorizeGateway - Stable gateway identity binding.
33
+ * @property {(input: {principal: Principal, gatewayId: string}) => boolean} authorizeClientGateway - Client-to-gateway selection policy.
34
+ * @property {(input: {principal: Principal, gatewayId: string, target: Target}) => boolean} authorizeOpen - Hub-side route policy.
35
+ * @property {string[]} [knownGatewayIds] - Configured IDs that may be temporarily unavailable.
36
+ * @property {number} [initialWindowBytes] - Per-direction flow-control window.
37
+ * @property {number} [maxFrameBytes] - Maximum uncompressed DATA frame.
38
+ * @property {number} [maxStreams] - Process-wide bridge limit.
39
+ * @property {number} [maxPhysicalConnections] - Simultaneous TCP connection bound, including pre-TLS handshakes.
40
+ * @property {number} [maxConnectionIdentities] - Retained stale-generation identity bound.
41
+ * @property {number} [handshakeTimeoutMs] - Authentication deadline.
42
+ * @property {number} [maxInputBytesPerConnection] - Pre-authentication byte bound.
43
+ * @property {import('../protocol/compression.js').CompressionLimits} [compressionLimits] - zstd safety bounds.
44
+ * @typedef {object} HubPhysical
45
+ * @property {import('node:tls').TLSSocket} socket
46
+ * @property {Principal | undefined} principal
47
+ * @property {'gateway' | 'client' | undefined} role
48
+ * @property {string | undefined} connectionId
49
+ * @property {number} generation
50
+ * @property {string | undefined} codec
51
+ * @property {string | undefined} gatewayId
52
+ * @property {Map<number, Bridge>} streams
53
+ * @property {number} lastClientStreamId
54
+ * @property {number} nextGatewayStreamId
55
+ * @property {boolean} authenticated
56
+ * @property {boolean} closing
57
+ * @property {(() => void) | undefined} clearHandshake
58
+ * @typedef {object} GatewayOwner
59
+ * @property {string} gatewayId
60
+ * @property {string} connectionId
61
+ * @property {string} userId
62
+ * @property {number} generation
63
+ * @property {HubPhysical | undefined} physical
64
+ * @typedef {object} ClientOwner
65
+ * @property {string} connectionId
66
+ * @property {string} userId
67
+ * @property {number} generation
68
+ * @property {HubPhysical | undefined} physical
69
+ * @typedef {object} Bridge
70
+ * @property {HubPhysical} client
71
+ * @property {number} clientStreamId
72
+ * @property {HubPhysical} gateway
73
+ * @property {number} gatewayStreamId
74
+ * @property {string} identifier
75
+ * @property {Target} target
76
+ * @property {'opening' | 'open' | 'half-closed' | 'closing'} state
77
+ * @property {number} clientToGatewaySequence
78
+ * @property {number} gatewayToClientSequence
79
+ * @property {number} clientToGatewayAck
80
+ * @property {number} gatewayToClientAck
81
+ * @property {boolean} clientEnded
82
+ * @property {boolean} gatewayEnded
83
+ * @property {AdaptiveCompressor} clientToGatewayCompressor
84
+ * @property {AdaptiveCompressor} gatewayToClientCompressor
85
+ */
86
+
87
+ /** Starts the Server3 reverse hub. */
88
+ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions} */ options) {
89
+ validateOptions(options)
90
+ const initialWindow = positiveInteger(options.initialWindowBytes, "initialWindowBytes", DEFAULT_WINDOW)
91
+ const maxFrameBytes = Math.min(positiveInteger(options.maxFrameBytes, "maxFrameBytes", DEFAULT_FRAME_BYTES), 64 * 1024)
92
+ const maxStreams = positiveInteger(options.maxStreams, "maxStreams", DEFAULT_MAX_STREAMS)
93
+ const maxIdentities = positiveInteger(options.maxConnectionIdentities, "maxConnectionIdentities", 4096)
94
+ const maxPhysicalConnections = positiveInteger(options.maxPhysicalConnections, "maxPhysicalConnections", 8192)
95
+ /** @type {Map<string, GatewayOwner>} */
96
+ const gateways = new Map()
97
+ /** @type {Map<string, ClientOwner>} */
98
+ const clients = new Map()
99
+ /** @type {Set<HubPhysical>} */
100
+ const physicals = new Set()
101
+ /** @type {Set<import('node:net').Socket>} */
102
+ const tcpSockets = new Set()
103
+ /** @type {Map<string, {socket: import('node:net').Socket, timer: ReturnType<typeof setTimeout>}>} */
104
+ const pendingTls = new Map()
105
+ let bridges = 0
106
+ let physicalAccepts = 0
107
+ let gatewayRegistrations = 0
108
+ let rejectedRegistrations = 0
109
+ let rejectedOpens = 0
110
+ let rawDataBytes = 0
111
+ let wireDataBytes = 0
112
+ let closing = false
113
+
114
+ const server = createTlsServer({
115
+ cert: readFileSync(options.tls.certFile),
116
+ key: readFileSync(options.tls.keyFile),
117
+ ...(options.tls.clientCaFile === undefined ? {} : {ca: readFileSync(options.tls.clientCaFile)}),
118
+ requestCert: options.tls.clientCaFile !== undefined,
119
+ rejectUnauthorized: options.tls.clientCaFile !== undefined
120
+ }, accept)
121
+ server.prependListener("connection", admitTcp)
122
+
123
+ /** @param {import('node:net').Socket} socket - Newly accepted TCP socket before TLS negotiation. */
124
+ function admitTcp(socket) {
125
+ if (closing || tcpSockets.size >= maxPhysicalConnections) { socket.destroy(); return }
126
+ physicalAccepts += 1
127
+ tcpSockets.add(socket)
128
+ const key = socketKey(socket)
129
+ const timer = setTimeout(() => socket.destroy(), options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_MS)
130
+ timer.unref?.()
131
+ pendingTls.set(key, {socket, timer})
132
+ socket.once("close", () => {
133
+ clearTimeout(timer)
134
+ tcpSockets.delete(socket)
135
+ if (pendingTls.get(key)?.socket === socket) pendingTls.delete(key)
136
+ })
137
+ }
138
+
139
+ /** @param {import('node:tls').TLSSocket} socket - Accepted verified server-side TLS socket. */
140
+ function accept(socket) {
141
+ const pending = pendingTls.get(socketKey(socket))
142
+ if (closing || pending === undefined) { socket.destroy(); return }
143
+ clearTimeout(pending.timer)
144
+ pendingTls.delete(socketKey(socket))
145
+ socket.setNoDelay(true)
146
+ socket.setKeepAlive(true, DEFAULT_KEEP_ALIVE_INITIAL_DELAY_MS)
147
+ /** @type {HubPhysical} */
148
+ const physical = {
149
+ socket,
150
+ principal: undefined,
151
+ role: undefined,
152
+ connectionId: undefined,
153
+ generation: 0,
154
+ codec: undefined,
155
+ gatewayId: undefined,
156
+ streams: new Map(),
157
+ lastClientStreamId: 0,
158
+ nextGatewayStreamId: 2,
159
+ authenticated: false,
160
+ closing: false,
161
+ clearHandshake: undefined
162
+ }
163
+ physicals.add(physical)
164
+ const decoder = new V3FrameDecoder({maxFrameBytes: Math.max(MAX_CONTROL_BYTES, maxFrameBytes + 4), maxBufferedBytes: Math.max(MAX_CONTROL_BYTES, maxFrameBytes + 4) * 2})
165
+ let unauthenticatedBytes = 0
166
+ let queue = Promise.resolve()
167
+ const handshakeTimer = setTimeout(() => terminate(physical, "HANDSHAKE_TIMEOUT"), options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_MS)
168
+ handshakeTimer.unref?.()
169
+
170
+ socket.on("data", (chunk) => {
171
+ socket.pause()
172
+ if (!physical.authenticated) unauthenticatedBytes += chunk.length
173
+ queue = queue.then(async () => {
174
+ if (!physical.authenticated && unauthenticatedBytes > (options.maxInputBytesPerConnection ?? DEFAULT_MAX_INPUT_BYTES)) throw protocolError("INPUT_LIMIT_EXCEEDED")
175
+ const frames = decoder.push(chunk)
176
+ for (const frame of frames) await handleFrame(physical, frame)
177
+ }).catch(async (error) => {
178
+ const code = typeof /** @type {{code?: unknown}} */ (error).code === "string" ? /** @type {{code: string}} */ (error).code : "PROTOCOL_VIOLATION"
179
+ await sendControl(physical, V3FrameType.GOAWAY, 0, 0, {code}).catch(() => {})
180
+ socket.destroy()
181
+ }).finally(() => {
182
+ if (!socket.destroyed && !physical.closing) socket.resume()
183
+ })
184
+ })
185
+ socket.on("error", () => {})
186
+ socket.once("close", () => {
187
+ clearTimeout(handshakeTimer)
188
+ void queue.finally(() => detachPhysical(physical, "TRANSPORT_CLOSED"))
189
+ })
190
+ physical.clearHandshake = () => clearTimeout(handshakeTimer)
191
+ }
192
+
193
+ /** @param {HubPhysical} physical - Source. @param {import('../protocol/v3-frame.js').V3Frame} frame - Frame. */
194
+ async function handleFrame(physical, frame) {
195
+ if (physical.closing) throw protocolError("STALE_GENERATION")
196
+ if (!physical.authenticated) {
197
+ if (frame.type !== V3FrameType.AUTH || frame.streamId !== 0 || frame.flags !== 0) throw protocolError("AUTH_REQUIRED")
198
+ const auth = decodeV3Control(frame.payload)
199
+ if ((auth.role !== "gateway" && auth.role !== "client") || !Array.isArray(auth.versions) || !auth.versions.includes(3) || typeof auth.connectionId !== "string" || !STABLE_IDENTIFIER.test(auth.connectionId) || !Number.isSafeInteger(auth.generation) || /** @type {number} */ (auth.generation) <= 0) throw protocolError("PROTOCOL_NEGOTIATION_FAILED")
200
+ const principal = typeof auth.token === "string" ? options.tokens.authenticate(auth.token) : undefined
201
+ if (principal === undefined) {
202
+ await sendControl(physical, V3FrameType.AUTH_ERROR, 0, 0, {code: "AUTH_REQUIRED"})
203
+ physical.socket.end()
204
+ return
205
+ }
206
+ physical.role = auth.role
207
+ physical.principal = principal
208
+ physical.connectionId = auth.connectionId
209
+ physical.generation = /** @type {number} */ (auth.generation)
210
+ physical.codec = negotiateCodec(auth.compression, availableCodecs())
211
+ if (auth.role === "client") authenticateClientOwner(physical, maxIdentities)
212
+ physical.authenticated = true
213
+ physical.clearHandshake?.()
214
+ await sendControl(physical, V3FrameType.AUTH_OK, 0, physical.generation, {
215
+ version: 3,
216
+ role: physical.role,
217
+ connectionId: physical.connectionId,
218
+ generation: physical.generation,
219
+ compression: physical.codec ?? null,
220
+ initialWindow
221
+ })
222
+ return
223
+ }
224
+ if (frame.type === V3FrameType.PING) {
225
+ await sendControl(physical, V3FrameType.PONG, 0, frame.sequence, {})
226
+ return
227
+ }
228
+ if (frame.type === V3FrameType.PONG) return
229
+ if (frame.type === V3FrameType.GOAWAY) {
230
+ physical.socket.end()
231
+ return
232
+ }
233
+ if (physical.role === "gateway" && physical.gatewayId !== undefined && gateways.get(physical.gatewayId)?.physical !== physical) throw protocolError("STALE_GENERATION")
234
+ if (physical.role === "client" && physical.connectionId !== undefined && clients.get(physical.connectionId)?.physical !== physical) throw protocolError("STALE_GENERATION")
235
+ if (physical.role === "gateway" && physical.gatewayId === undefined) {
236
+ if (frame.type !== V3FrameType.GATEWAY_REGISTER) throw protocolError("GATEWAY_REGISTRATION_REQUIRED")
237
+ await registerGateway(physical, frame)
238
+ return
239
+ }
240
+ if (physical.role === "client" && physical.gatewayId === undefined) {
241
+ if (frame.type !== V3FrameType.GATEWAY_SELECT) throw protocolError("GATEWAY_SELECTION_REQUIRED")
242
+ await selectGateway(physical, frame)
243
+ return
244
+ }
245
+ if (frame.type === V3FrameType.GATEWAY_REGISTER || frame.type === V3FrameType.GATEWAY_REGISTERED || frame.type === V3FrameType.GATEWAY_SELECT || frame.type === V3FrameType.GATEWAY_SELECTED || frame.type === V3FrameType.AUTH || frame.type === V3FrameType.AUTH_OK || frame.type === V3FrameType.AUTH_ERROR || frame.type === V3FrameType.RESUME || frame.type === V3FrameType.RESUMED) throw protocolError("UNSUPPORTED_FRAME")
246
+ if (physical.role === "client" && frame.type === V3FrameType.OPEN) {
247
+ await openBridge(physical, frame)
248
+ return
249
+ }
250
+ await relayStreamFrame(physical, frame)
251
+ }
252
+
253
+ /** @param {HubPhysical} physical - Authenticated client owner. @param {number} limit - Bound. */
254
+ function authenticateClientOwner(physical, limit) {
255
+ const id = /** @type {string} */ (physical.connectionId)
256
+ const principal = /** @type {Principal} */ (physical.principal)
257
+ const existing = clients.get(id)
258
+ if (existing !== undefined && existing.userId !== principal.userId) throw protocolError("AUTH_OWNERSHIP_MISMATCH")
259
+ if (existing !== undefined && physical.generation <= existing.generation) throw protocolError("STALE_GENERATION")
260
+ if (existing === undefined && clients.size >= limit) evictDisconnectedClientIdentity()
261
+ if (existing === undefined && clients.size >= limit) throw protocolError("IDENTITY_LIMIT")
262
+ if (existing?.physical !== undefined) terminate(existing.physical, "CONNECTION_REPLACED")
263
+ clients.set(id, {connectionId: id, userId: principal.userId, generation: physical.generation, physical})
264
+ }
265
+
266
+ /** Evicts the oldest disconnected client identity while active owners remain fenced. */
267
+ function evictDisconnectedClientIdentity() {
268
+ for (const [id, owner] of clients) {
269
+ if (owner.physical !== undefined) continue
270
+ clients.delete(id)
271
+ return
272
+ }
273
+ }
274
+
275
+ /** @param {HubPhysical} physical - Authenticated gateway. @param {import('../protocol/v3-frame.js').V3Frame} frame - Register frame. */
276
+ async function registerGateway(physical, frame) {
277
+ if (frame.streamId !== 0 || frame.sequence !== physical.generation || frame.flags !== 0) throw protocolError("INVALID_GATEWAY_REGISTRATION")
278
+ const request = decodeV3Control(frame.payload)
279
+ if (typeof request.gatewayId !== "string" || !GATEWAY_ID.test(request.gatewayId) || physical.principal === undefined || options.authorizeGateway({principal: physical.principal, gatewayId: request.gatewayId}) !== true) {
280
+ rejectedRegistrations += 1
281
+ throw protocolError("GATEWAY_UNAUTHORIZED")
282
+ }
283
+ const existing = gateways.get(request.gatewayId)
284
+ const connectionId = /** @type {string} */ (physical.connectionId)
285
+ if (existing !== undefined && existing.userId !== physical.principal.userId) {
286
+ rejectedRegistrations += 1
287
+ throw protocolError("AUTH_OWNERSHIP_MISMATCH")
288
+ }
289
+ if (existing !== undefined && existing.connectionId !== connectionId) {
290
+ rejectedRegistrations += 1
291
+ throw protocolError("DUPLICATE_GATEWAY")
292
+ }
293
+ if (existing !== undefined && physical.generation <= existing.generation) {
294
+ rejectedRegistrations += 1
295
+ throw protocolError("STALE_GENERATION")
296
+ }
297
+ if (existing === undefined && gateways.size >= maxIdentities) throw protocolError("IDENTITY_LIMIT")
298
+ if (existing?.physical !== undefined) terminate(existing.physical, "GATEWAY_REPLACED")
299
+ physical.gatewayId = request.gatewayId
300
+ gateways.set(request.gatewayId, {
301
+ gatewayId: request.gatewayId,
302
+ connectionId,
303
+ userId: physical.principal.userId,
304
+ generation: physical.generation,
305
+ physical
306
+ })
307
+ gatewayRegistrations += 1
308
+ await sendControl(physical, V3FrameType.GATEWAY_REGISTERED, 0, physical.generation, {gatewayId: request.gatewayId, generation: physical.generation})
309
+ }
310
+
311
+ /** @param {HubPhysical} physical - Authenticated client. @param {import('../protocol/v3-frame.js').V3Frame} frame - Selection frame. */
312
+ async function selectGateway(physical, frame) {
313
+ if (frame.streamId !== 0 || frame.sequence !== physical.generation || frame.flags !== 0) throw protocolError("INVALID_GATEWAY_SELECTION")
314
+ const request = decodeV3Control(frame.payload)
315
+ if (typeof request.gatewayId !== "string" || !GATEWAY_ID.test(request.gatewayId)) throw protocolError("INVALID_GATEWAY_SELECTION")
316
+ if (physical.principal === undefined || options.authorizeClientGateway({principal: physical.principal, gatewayId: request.gatewayId}) !== true) throw protocolError("GATEWAY_UNAUTHORIZED")
317
+ const owner = gateways.get(request.gatewayId)
318
+ const known = owner !== undefined || options.knownGatewayIds?.includes(request.gatewayId) === true
319
+ if (!known) throw protocolError("UNKNOWN_GATEWAY_ID")
320
+ if (owner?.physical === undefined) throw protocolError("GATEWAY_UNAVAILABLE")
321
+ physical.gatewayId = request.gatewayId
322
+ await sendControl(physical, V3FrameType.GATEWAY_SELECTED, 0, physical.generation, {gatewayId: request.gatewayId, generation: physical.generation})
323
+ }
324
+
325
+ /** @param {HubPhysical} client - Client physical. @param {import('../protocol/v3-frame.js').V3Frame} frame - OPEN. */
326
+ async function openBridge(client, frame) {
327
+ if (frame.sequence !== 0) throw protocolError("INVALID_STREAM_OPEN")
328
+ if (frame.streamId % 2 === 0 || frame.streamId <= client.lastClientStreamId || client.streams.size >= maxStreams || bridges >= maxStreams) throw protocolError("INVALID_STREAM_ID")
329
+ client.lastClientStreamId = frame.streamId
330
+ const request = decodeV3Control(frame.payload)
331
+ const target = parseTarget(request.target)
332
+ if (target === undefined || typeof request.gatewayId !== "string" || request.gatewayId !== client.gatewayId || !GATEWAY_ID.test(request.gatewayId) || typeof request.identifier !== "string" || !STABLE_IDENTIFIER.test(request.identifier) || typeof request.name !== "string" || request.name.length === 0 || request.name.length > 256 || client.principal === undefined) {
333
+ await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "INVALID_STREAM_OPEN"})
334
+ return
335
+ }
336
+ if (options.authorizeOpen({principal: client.principal, gatewayId: request.gatewayId, target}) !== true) {
337
+ rejectedOpens += 1
338
+ await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "TARGET_UNAUTHORIZED"})
339
+ return
340
+ }
341
+ const gatewayOwner = gateways.get(request.gatewayId)
342
+ const gateway = gatewayOwner?.physical
343
+ if (gateway === undefined || gateway.socket.destroyed || gateway.gatewayId !== request.gatewayId) {
344
+ rejectedOpens += 1
345
+ await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: gatewayOwner === undefined ? "UNKNOWN_GATEWAY_ID" : "GATEWAY_UNAVAILABLE"})
346
+ return
347
+ }
348
+ const gatewayStreamId = gateway.nextGatewayStreamId
349
+ if (gatewayStreamId > 0xffff_fffe || gateway.streams.size >= maxStreams) {
350
+ await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "GATEWAY_STREAM_LIMIT"})
351
+ return
352
+ }
353
+ gateway.nextGatewayStreamId += 2
354
+ /** @type {Bridge} */
355
+ const bridge = {
356
+ client,
357
+ clientStreamId: frame.streamId,
358
+ gateway,
359
+ gatewayStreamId,
360
+ identifier: request.identifier,
361
+ target,
362
+ state: "opening",
363
+ clientToGatewaySequence: 0,
364
+ gatewayToClientSequence: 0,
365
+ clientToGatewayAck: 0,
366
+ gatewayToClientAck: 0,
367
+ clientEnded: false,
368
+ gatewayEnded: false,
369
+ clientToGatewayCompressor: new AdaptiveCompressor(),
370
+ gatewayToClientCompressor: new AdaptiveCompressor()
371
+ }
372
+ client.streams.set(frame.streamId, bridge)
373
+ gateway.streams.set(gatewayStreamId, bridge)
374
+ bridges += 1
375
+ await sendControl(gateway, V3FrameType.OPEN, gatewayStreamId, 0, {identifier: request.identifier, name: request.name, target, initialWindow})
376
+ }
377
+
378
+ /** @param {HubPhysical} source - Frame source. @param {import('../protocol/v3-frame.js').V3Frame} frame - Stream frame. */
379
+ async function relayStreamFrame(source, frame) {
380
+ const bridge = source.streams.get(frame.streamId)
381
+ if (bridge === undefined) {
382
+ if (frame.type !== V3FrameType.CLOSE && frame.type !== V3FrameType.RESET) await sendControl(source, V3FrameType.RESET, frame.streamId, 0, {code: "UNKNOWN_STREAM"})
383
+ return
384
+ }
385
+ const fromClient = source === bridge.client
386
+ const destination = fromClient ? bridge.gateway : bridge.client
387
+ const destinationStreamId = fromClient ? bridge.gatewayStreamId : bridge.clientStreamId
388
+ if (frame.type === V3FrameType.OPENED) {
389
+ if (fromClient || bridge.state !== "opening" || frame.sequence !== 0) throw protocolError("UNEXPECTED_OPENED")
390
+ const detail = decodeV3Control(frame.payload)
391
+ if (detail.identifier !== bridge.identifier) throw protocolError("OPEN_IDENTIFIER_MISMATCH")
392
+ bridge.state = "open"
393
+ await sendControl(destination, V3FrameType.OPENED, destinationStreamId, 0, {identifier: bridge.identifier, initialWindow})
394
+ return
395
+ }
396
+ if (bridge.state === "opening" && frame.type !== V3FrameType.RESET) throw protocolError("STREAM_NOT_OPEN")
397
+ if (frame.type === V3FrameType.ACK) {
398
+ const maximum = fromClient ? bridge.gatewayToClientSequence : bridge.clientToGatewaySequence
399
+ const previous = fromClient ? bridge.gatewayToClientAck : bridge.clientToGatewayAck
400
+ if (!Number.isSafeInteger(frame.sequence) || frame.sequence < previous || frame.sequence > maximum) throw protocolError("INVALID_ACK")
401
+ if (fromClient) bridge.gatewayToClientAck = frame.sequence
402
+ else bridge.clientToGatewayAck = frame.sequence
403
+ if (bridge.state !== "closing") await sendControl(destination, V3FrameType.ACK, destinationStreamId, frame.sequence, {})
404
+ return
405
+ }
406
+ if (frame.type === V3FrameType.WINDOW) {
407
+ if (frame.sequence <= 0 || frame.sequence > initialWindow) throw protocolError("INVALID_WINDOW")
408
+ if (bridge.state !== "closing") await sendControl(destination, V3FrameType.WINDOW, destinationStreamId, frame.sequence, {})
409
+ return
410
+ }
411
+ if (bridge.state === "closing") {
412
+ if (frame.type === V3FrameType.DATA || frame.type === V3FrameType.END) {
413
+ closeBridge(bridge, "STREAM_CLOSING")
414
+ return
415
+ }
416
+ if (frame.type !== V3FrameType.CLOSE && frame.type !== V3FrameType.RESET) throw protocolError("STREAM_CLOSING")
417
+ }
418
+ if (frame.type === V3FrameType.DATA) {
419
+ if (fromClient ? bridge.clientEnded : bridge.gatewayEnded) throw protocolError("DATA_AFTER_END")
420
+ const raw = await decodeV3Data(frame, source.codec, options.compressionLimits)
421
+ if (raw.length === 0 || raw.length > maxFrameBytes) throw protocolError("FRAME_TOO_LARGE")
422
+ const expected = (fromClient ? bridge.clientToGatewaySequence : bridge.gatewayToClientSequence) + raw.length
423
+ if (frame.sequence !== expected) throw protocolError("SEQUENCE_GAP")
424
+ if (fromClient) bridge.clientToGatewaySequence = frame.sequence
425
+ else bridge.gatewayToClientSequence = frame.sequence
426
+ const encoded = await encodeV3Data(raw, destination.codec, fromClient ? bridge.clientToGatewayCompressor : bridge.gatewayToClientCompressor, options.compressionLimits)
427
+ rawDataBytes += encoded.rawBytes
428
+ wireDataBytes += encoded.wireBytes
429
+ const delivered = await writeSocket(destination.socket, encodeV3Frame({type: V3FrameType.DATA, streamId: destinationStreamId, sequence: frame.sequence, flags: encoded.flags}, encoded.payload))
430
+ if (!delivered) closeBridge(bridge, "DESTINATION_CLOSED", source)
431
+ return
432
+ }
433
+ if (frame.type === V3FrameType.END) {
434
+ const expected = fromClient ? bridge.clientToGatewaySequence : bridge.gatewayToClientSequence
435
+ if (frame.sequence !== expected || (fromClient ? bridge.clientEnded : bridge.gatewayEnded)) throw protocolError("INVALID_END")
436
+ if (fromClient) bridge.clientEnded = true
437
+ else bridge.gatewayEnded = true
438
+ bridge.state = "half-closed"
439
+ await sendControl(destination, V3FrameType.END, destinationStreamId, frame.sequence, {})
440
+ return
441
+ }
442
+ if (frame.type === V3FrameType.CLOSE || frame.type === V3FrameType.RESET) {
443
+ const expected = fromClient ? bridge.clientToGatewaySequence : bridge.gatewayToClientSequence
444
+ if ((frame.type === V3FrameType.CLOSE && frame.sequence !== expected) || (frame.type === V3FrameType.RESET && frame.sequence !== 0)) throw protocolError("INVALID_STREAM_CLOSE")
445
+ const detail = frame.payload.length === 0 ? {} : decodeV3Control(frame.payload)
446
+ await sendControl(destination, frame.type, destinationStreamId, frame.sequence, detail)
447
+ if (frame.type === V3FrameType.RESET || fromClient) removeBridge(bridge)
448
+ else bridge.state = "closing"
449
+ return
450
+ }
451
+ throw protocolError("UNSUPPORTED_FRAME")
452
+ }
453
+
454
+ /** @param {Bridge} bridge - Stream. */
455
+ function removeBridge(bridge) {
456
+ const existed = bridge.client.streams.delete(bridge.clientStreamId)
457
+ bridge.gateway.streams.delete(bridge.gatewayStreamId)
458
+ if (existed) bridges = Math.max(0, bridges - 1)
459
+ }
460
+
461
+ /** @param {Bridge} bridge - Stream. @param {string} code - Reset code. @param {HubPhysical} [excluded] - Already-dead peer. */
462
+ function closeBridge(bridge, code, excluded) {
463
+ if (bridge.client !== excluded && !bridge.client.socket.destroyed) void sendControl(bridge.client, V3FrameType.RESET, bridge.clientStreamId, 0, {code})
464
+ if (bridge.gateway !== excluded && !bridge.gateway.socket.destroyed) void sendControl(bridge.gateway, V3FrameType.RESET, bridge.gatewayStreamId, 0, {code})
465
+ removeBridge(bridge)
466
+ }
467
+
468
+ /** @param {HubPhysical} physical - Connection. @param {string} code - Reason. */
469
+ function terminate(physical, code) {
470
+ if (physical.closing) return
471
+ physical.closing = true
472
+ const forceTimer = setTimeout(() => physical.socket.destroy(), 1_000)
473
+ forceTimer.unref?.()
474
+ void sendControl(physical, V3FrameType.GOAWAY, 0, 0, {code}).finally(() => {
475
+ clearTimeout(forceTimer)
476
+ physical.socket.destroy()
477
+ })
478
+ }
479
+
480
+ /** @param {HubPhysical} physical - Closed connection. @param {string} code - Stream reset. */
481
+ function detachPhysical(physical, code) {
482
+ if (!physicals.delete(physical)) return
483
+ physical.closing = true
484
+ for (const bridge of [...physical.streams.values()]) closeBridge(bridge, physical.role === "gateway" ? "GATEWAY_DISCONNECTED" : code, physical)
485
+ if (physical.gatewayId !== undefined) {
486
+ const owner = gateways.get(physical.gatewayId)
487
+ if (physical.role === "gateway" && owner?.physical === physical) {
488
+ owner.physical = undefined
489
+ for (const client of [...physicals]) {
490
+ if (client.role === "client" && client.gatewayId === physical.gatewayId) terminate(client, "GATEWAY_DISCONNECTED")
491
+ }
492
+ }
493
+ }
494
+ if (physical.role === "client" && physical.connectionId !== undefined) {
495
+ const owner = clients.get(physical.connectionId)
496
+ if (owner?.physical === physical) owner.physical = undefined
497
+ }
498
+ }
499
+
500
+ /** @param {HubPhysical} physical - Destination. @param {number} type - Frame type. @param {number} streamId - Stream ID. @param {number} sequence - Sequence. @param {Record<string, unknown>} value - Control body. */
501
+ async function sendControl(physical, type, streamId, sequence, value) {
502
+ return await writeSocket(physical.socket, encodeV3Frame({type, streamId, sequence}, encodeV3Control(value)))
503
+ }
504
+
505
+ await new Promise((resolve, reject) => {
506
+ server.once("error", reject)
507
+ server.listen(options.port ?? 0, options.host ?? "127.0.0.1", () => { server.off("error", reject); resolve(undefined) })
508
+ })
509
+ const address = server.address()
510
+ if (address === null || typeof address === "string") throw new Error("Reverse gateway hub did not bind to a TCP port")
511
+
512
+ return {
513
+ server,
514
+ port: address.port,
515
+ status: () => ({
516
+ physicalAccepts,
517
+ physicalConnections: tcpSockets.size,
518
+ gatewayPhysicalConnections: [...physicals].filter((physical) => physical.role === "gateway" && physical.gatewayId !== undefined).length,
519
+ clientPhysicalConnections: [...physicals].filter((physical) => physical.role === "client" && physical.authenticated).length,
520
+ retainedClientIdentities: clients.size,
521
+ registeredGateways: gateways.size,
522
+ healthyGateways: [...gateways.values()].filter((owner) => owner.physical !== undefined).length,
523
+ gatewayRegistrations,
524
+ rejectedRegistrations,
525
+ rejectedOpens,
526
+ virtualStreams: bridges,
527
+ rawDataBytes,
528
+ wireDataBytes,
529
+ compressionBytesSaved: Math.max(0, rawDataBytes - wireDataBytes)
530
+ }),
531
+ closeActiveGateway: async (/** @type {string} */ gatewayId) => {
532
+ const physical = gateways.get(gatewayId)?.physical
533
+ if (physical === undefined) return
534
+ await new Promise((resolve) => { physical.socket.once("close", resolve); physical.socket.destroy() })
535
+ },
536
+ close: async () => {
537
+ if (closing) return
538
+ closing = true
539
+ for (const {socket} of pendingTls.values()) socket.destroy()
540
+ for (const physical of [...physicals]) terminate(physical, "HUB_SHUTDOWN")
541
+ await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve(undefined)))
542
+ gateways.clear()
543
+ clients.clear()
544
+ }
545
+ }
546
+ }
547
+
548
+ /** @param {import('node:net').Socket} socket - TCP or TLS socket. */
549
+ function socketKey(socket) {
550
+ return `${socket.remoteAddress}\0${socket.remotePort}\0${socket.localAddress}\0${socket.localPort}`
551
+ }
552
+
553
+ /** @param {ReverseGatewayHubOptions} options - Options. */
554
+ function validateOptions(options) {
555
+ if (typeof options !== "object" || options === null || options.tokens === undefined || typeof options.tokens.authenticate !== "function") throw new TypeError("Reverse gateway hub requires a token authenticator")
556
+ if (typeof options.authorizeGateway !== "function" || typeof options.authorizeClientGateway !== "function" || typeof options.authorizeOpen !== "function") throw new TypeError("Reverse gateway hub requires gateway, client selection, and open authorization policies")
557
+ if (typeof options.tls !== "object" || options.tls === null || typeof options.tls.certFile !== "string" || typeof options.tls.keyFile !== "string") throw new TypeError("Reverse gateway hub requires TLS certFile and keyFile")
558
+ if (!zstdAvailable()) throw new Error("Reverse gateway hub requires Node.js zstd support")
559
+ if (options.knownGatewayIds !== undefined && (!Array.isArray(options.knownGatewayIds) || options.knownGatewayIds.some((gatewayId) => typeof gatewayId !== "string" || !GATEWAY_ID.test(gatewayId)) || new Set(options.knownGatewayIds).size !== options.knownGatewayIds.length)) throw new TypeError("knownGatewayIds must contain unique valid gateway IDs")
560
+ const frame = positiveInteger(options.maxFrameBytes, "maxFrameBytes", DEFAULT_FRAME_BYTES)
561
+ const window = positiveInteger(options.initialWindowBytes, "initialWindowBytes", DEFAULT_WINDOW)
562
+ if (frame > window) throw new RangeError("maxFrameBytes must not exceed initialWindowBytes")
563
+ }
@@ -0,0 +1,3 @@
1
+ export {createReverseGatewayConnection, ReverseGatewayConnection} from "./client.js"
2
+ export {createOutboundReverseGateway, OutboundReverseGateway} from "./gateway.js"
3
+ export {startReverseGatewayHub} from "./hub.js"
@@ -0,0 +1,78 @@
1
+ /* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param, jsdoc/require-returns */
2
+ import {StaticTokenStore} from "../auth/static-token-store.js"
3
+ import {OutboundReverseGateway} from "./gateway.js"
4
+ import {startReverseGatewayHub} from "./hub.js"
5
+
6
+ /** @param {import('../config/environment.js').RelayEnvironmentConfig} config - Parsed environment. */
7
+ export async function startReverseHubRuntime(config) {
8
+ if (config.mode !== "reverse-hub") throw new Error("Reverse hub runtime requires SOCKETDUCT_MODE=reverse-hub")
9
+ if (config.relayTls === undefined) throw new Error("Reverse hub mode requires SOCKETDUCT_TLS_CERT_FILE and SOCKETDUCT_TLS_KEY_FILE")
10
+ if (config.authTokens.length === 0) throw new Error("SOCKETDUCT_AUTH_TOKENS must contain hub client and gateway credentials")
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
+ if (config.reverseRoutes === undefined || config.reverseRoutes.length === 0) throw new Error("SOCKETDUCT_REVERSE_ROUTES must allow at least one client/gateway/target route")
13
+ if (config.compression === "off") throw new Error("Reverse hub mode requires SOCKETDUCT_COMPRESSION=auto")
14
+ const bindings = new Map(config.reverseGatewayBindings.map((binding) => [binding.gatewayId, binding.username]))
15
+ const routes = new Set(config.reverseRoutes.map((route) => formatRoute(route.username, route.gatewayId, {host: route.host, port: route.port})))
16
+ const selections = new Set(config.reverseRoutes.map((route) => `${route.username}\0${route.gatewayId}`))
17
+ if (bindings.size !== config.reverseGatewayBindings.length) throw new Error("SOCKETDUCT_REVERSE_GATEWAY_BINDINGS contains a duplicate gateway ID")
18
+ if (routes.size !== config.reverseRoutes.length) throw new Error("SOCKETDUCT_REVERSE_ROUTES contains a duplicate route")
19
+ const relayTls = config.relayTls
20
+ const tls = {
21
+ certFile: relayTls.certFile,
22
+ keyFile: relayTls.keyFile,
23
+ ...(relayTls.clientCaFile === undefined ? {} : {clientCaFile: relayTls.clientCaFile})
24
+ }
25
+ const hub = await startReverseGatewayHub({
26
+ host: config.relayHost,
27
+ port: config.relayPort,
28
+ tls,
29
+ tokens: new StaticTokenStore(config.authTokens),
30
+ knownGatewayIds: [...bindings.keys()],
31
+ authorizeGateway: ({principal, gatewayId}) => bindings.get(gatewayId) === principal.username,
32
+ authorizeClientGateway: ({principal, gatewayId}) => selections.has(`${principal.username}\0${gatewayId}`),
33
+ authorizeOpen: ({principal, gatewayId, target}) => routes.has(formatRoute(principal.username, gatewayId, target)),
34
+ handshakeTimeoutMs: config.handshakeTimeoutMs,
35
+ maxInputBytesPerConnection: config.maxInputBytesPerConnection,
36
+ compressionLimits: compressionLimits(config)
37
+ })
38
+ return {hub, close: hub.close}
39
+ }
40
+
41
+ /** @param {import('../config/environment.js').RelayEnvironmentConfig} config - Parsed environment. @param {import('../logging/structured-logger.js').DiagnosticLogger} [logger] - Optional structured logger. */
42
+ export async function startOutboundReverseGatewayRuntime(config, logger) {
43
+ if (config.mode !== "reverse-gateway") throw new Error("Outbound gateway runtime requires SOCKETDUCT_MODE=reverse-gateway")
44
+ if (config.reverseGatewayId === undefined || config.reverseGatewayId.length === 0) throw new Error("SOCKETDUCT_REVERSE_GATEWAY_ID is required")
45
+ if (config.reverseConnectionId === undefined || config.reverseConnectionId.length === 0) throw new Error("SOCKETDUCT_REVERSE_CONNECTION_ID is required")
46
+ if (config.reverseHub === undefined || config.reverseHub.host.length === 0 || config.reverseHub.token.length === 0) throw new Error("SOCKETDUCT_REVERSE_HUB_HOST and SOCKETDUCT_REVERSE_TOKEN are required")
47
+ if (config.targetAllowlist.length === 0) throw new Error("SOCKETDUCT_TARGET_ALLOWLIST must contain at least one local gateway target")
48
+ if (config.compression === "off") throw new Error("Reverse gateway mode requires SOCKETDUCT_COMPRESSION=auto")
49
+ const targets = new Set(config.targetAllowlist.map((target) => `${target.host}:${target.port}`))
50
+ const gateway = new OutboundReverseGateway({
51
+ gatewayId: config.reverseGatewayId,
52
+ connectionId: config.reverseConnectionId,
53
+ hub: config.reverseHub,
54
+ tls: config.reverseHubTls ?? {},
55
+ authorizeTarget: (target) => targets.has(`${target.host}:${target.port}`),
56
+ ...(config.initialTargetConnectTimeoutMs === undefined ? {} : {targetConnectTimeoutMs: config.initialTargetConnectTimeoutMs}),
57
+ maxStreamBufferBytes: config.maxTargetBufferBytes,
58
+ maxTotalBufferBytes: config.maxTotalBufferBytes,
59
+ compressionLimits: compressionLimits(config),
60
+ ...(logger === undefined ? {} : {logger})
61
+ })
62
+ await gateway.start()
63
+ return {gateway, close: () => gateway.close()}
64
+ }
65
+
66
+ /** @param {string} username - Client. @param {string} gatewayId - Gateway. @param {{host: string, port: number}} target - Target. */
67
+ function formatRoute(username, gatewayId, target) {
68
+ return `${username}\0${gatewayId}\0${target.host}\0${target.port}`
69
+ }
70
+
71
+ /** @param {import('../config/environment.js').RelayEnvironmentConfig} config - Runtime limits. @returns {import('../protocol/compression.js').CompressionLimits} Defined codec limits. */
72
+ function compressionLimits(config) {
73
+ return {
74
+ ...(config.minCompressBytes === undefined ? {} : {minCompressBytes: config.minCompressBytes}),
75
+ ...(config.maxDecompressedBytes === undefined ? {} : {maxDecompressedBytes: config.maxDecompressedBytes}),
76
+ ...(config.maxExpansionRatio === undefined ? {} : {maxExpansionRatio: config.maxExpansionRatio})
77
+ }
78
+ }