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,256 @@
1
+ /* eslint-disable jsdoc/escape-inline-tags, jsdoc/no-undefined-types, jsdoc/require-returns, jsdoc/ts-no-empty-object-type */
2
+ import {Duplex} from "node:stream"
3
+
4
+ /**
5
+ * @typedef {object} VirtualSocketOwner
6
+ * @property {(streamId: number, data: Buffer, callback: (error?: Error | null) => void) => void} _writeStream - Accept application bytes.
7
+ * @property {(streamId: number, callback: (error?: Error | null) => void) => void} _endStream - End the writable side.
8
+ * @property {(streamId: number, error: Error | null) => void} _destroyStream - Destroy the virtual stream.
9
+ * @property {(streamId: number) => void} _resumeReadable - Resume inbound flow.
10
+ * @property {() => {address?: string, port?: number}} physicalAddress - Current physical endpoint.
11
+ * @property {() => unknown} ref - Reference owner resources.
12
+ * @property {() => unknown} unref - Unreference owner resources.
13
+ */
14
+
15
+ /** A stream-local failure where the physical connection may remain usable. */
16
+ export class SocketductStreamError extends Error {
17
+ /**
18
+ * @param {string} message - Safe description.
19
+ * @param {string} code - Stable code.
20
+ */
21
+ constructor(message, code = "SOCKETDUCT_STREAM_ERROR") {
22
+ super(message)
23
+ this.name = "SocketductStreamError"
24
+ this.code = code
25
+ }
26
+ }
27
+
28
+ /** An unrecoverable resumable-session loss requiring caller reconciliation. */
29
+ export class SocketductSessionLostError extends SocketductStreamError {
30
+ /**
31
+ * @param {string} message - Safe description.
32
+ * @param {string} [code] - Stable code.
33
+ */
34
+ constructor(message, code = "SOCKETDUCT_SESSION_LOST") {
35
+ super(message, code)
36
+ this.name = "SocketductSessionLostError"
37
+ }
38
+ }
39
+
40
+ /**
41
+ * A net.Socket-compatible Duplex backed by one v3 virtual stream.
42
+ * @augments Duplex
43
+ */
44
+ export class VirtualSocket extends Duplex {
45
+ /** @type {VirtualSocketOwner} */
46
+ #connection
47
+
48
+ /** @type {number} */
49
+ #streamId
50
+
51
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
52
+ #timeoutTimer
53
+
54
+ /** @type {number} */
55
+ #timeoutMs = 0
56
+
57
+ /** @type {boolean} */
58
+ #connected = false
59
+
60
+ /** @type {boolean} */
61
+ #remoteEnded = false
62
+
63
+ /** @type {boolean} */
64
+ connecting = true
65
+
66
+ /** @type {boolean} */
67
+ pending = true
68
+
69
+ /** @type {number} */
70
+ bytesRead = 0
71
+
72
+ /** @type {number} */
73
+ bytesWritten = 0
74
+
75
+ /** @type {string | undefined} */
76
+ remoteAddress
77
+
78
+ /** @type {number | undefined} */
79
+ remotePort
80
+
81
+ /** @type {string | undefined} */
82
+ localAddress
83
+
84
+ /** @type {number | undefined} */
85
+ localPort
86
+
87
+ /** @type {string} */
88
+ remoteFamily = "IPv4"
89
+
90
+ /**
91
+ * @param {VirtualSocketOwner} connection - Owner.
92
+ * @param {number} streamId - Allocated stream ID.
93
+ * @param {{host: string, port: number, allowHalfOpen?: boolean, readableHighWaterMark?: number, writableHighWaterMark?: number}} options - Socket metadata/options.
94
+ */
95
+ constructor(connection, streamId, options) {
96
+ super({
97
+ allowHalfOpen: options.allowHalfOpen ?? false,
98
+ ...(options.readableHighWaterMark === undefined ? {} : {readableHighWaterMark: options.readableHighWaterMark}),
99
+ ...(options.writableHighWaterMark === undefined ? {} : {writableHighWaterMark: options.writableHighWaterMark})
100
+ })
101
+ this.#connection = connection
102
+ this.#streamId = streamId
103
+ this.remoteAddress = options.host
104
+ this.remotePort = options.port
105
+ this.remoteFamily = options.host.includes(":") ? "IPv6" : "IPv4"
106
+ }
107
+
108
+ /** @returns {number} Protocol stream ID. */
109
+ get streamId() {
110
+ return this.#streamId
111
+ }
112
+
113
+ /** @returns {this} Socket-style no-op connect; opening starts at construction. */
114
+ connect() {
115
+ return this
116
+ }
117
+
118
+ /**
119
+ * @override
120
+ * @returns {void} Resume inbound flow.
121
+ */
122
+ _read() {
123
+ this.#connection._resumeReadable(this.#streamId)
124
+ }
125
+
126
+ /**
127
+ * @override
128
+ * @param {Buffer | string | Uint8Array} chunk - Application bytes.
129
+ * @param {BufferEncoding} encoding - Encoding.
130
+ * @param {(error?: Error | null) => void} callback - Completion.
131
+ * @returns {void}
132
+ */
133
+ _write(chunk, encoding, callback) {
134
+ const data = typeof chunk === "string" ? Buffer.from(chunk, encoding) : Buffer.from(chunk)
135
+ this.#resetTimeout()
136
+ this.#connection._writeStream(this.#streamId, data, (error) => {
137
+ if (error === undefined) this.bytesWritten += data.length
138
+ callback(error)
139
+ })
140
+ }
141
+
142
+ /**
143
+ * @override
144
+ * @param {(error?: Error | null) => void} callback - Completion.
145
+ * @returns {void}
146
+ */
147
+ _final(callback) {
148
+ this.#connection._endStream(this.#streamId, callback)
149
+ }
150
+
151
+ /**
152
+ * @override
153
+ * @param {Error | null} error - Destroy cause.
154
+ * @param {(error?: Error | null) => void} callback - Completion.
155
+ * @returns {void}
156
+ */
157
+ _destroy(error, callback) {
158
+ this.#clearTimeout()
159
+ this.connecting = false
160
+ this.pending = false
161
+ this.#connection._destroyStream(this.#streamId, error)
162
+ callback(error)
163
+ }
164
+
165
+ /** @param {Buffer} chunk - Inbound bytes from the owner. @returns {boolean} Readable capacity. */
166
+ _receive(chunk) {
167
+ if (this.destroyed || this.#remoteEnded) return false
168
+ this.bytesRead += chunk.length
169
+ this.#resetTimeout()
170
+ return this.push(chunk)
171
+ }
172
+
173
+ /** Marks target readiness and emits standard socket events. @returns {void} */
174
+ _markConnected() {
175
+ if (this.destroyed) return
176
+ if (this.#connected) {
177
+ this.emit("ready")
178
+ return
179
+ }
180
+ this.#connected = true
181
+ this.connecting = false
182
+ this.pending = false
183
+ const physical = this.#connection.physicalAddress()
184
+ this.localAddress = physical.address
185
+ this.localPort = physical.port
186
+ this.emit("connect")
187
+ this.emit("ready")
188
+ }
189
+
190
+ /** Ends the readable side without affecting siblings. @returns {void} */
191
+ _receiveEnd() {
192
+ if (this.#remoteEnded || this.destroyed) return
193
+ this.#remoteEnded = true
194
+ this.push(null)
195
+ }
196
+
197
+ /** @param {Error} error - Stream-local terminal failure. @returns {void} */
198
+ _receiveError(error) {
199
+ if (!this.destroyed) this.destroy(error)
200
+ }
201
+
202
+ /**
203
+ * @param {number} timeout - Milliseconds.
204
+ * @param {() => void} [callback] - Timeout listener.
205
+ * @returns {this} This socket.
206
+ */
207
+ setTimeout(timeout, callback) {
208
+ if (!Number.isFinite(timeout) || timeout < 0) throw new RangeError("Timeout must be a non-negative finite number")
209
+ if (callback !== undefined) this.once("timeout", callback)
210
+ this.#timeoutMs = timeout
211
+ this.#resetTimeout()
212
+ return this
213
+ }
214
+
215
+ /** @returns {this} Compatibility no-op; framing controls batching. */
216
+ setNoDelay() {
217
+ return this
218
+ }
219
+
220
+ /** @returns {this} Compatibility no-op; heartbeat is connection-scoped. */
221
+ setKeepAlive() {
222
+ return this
223
+ }
224
+
225
+ /** @returns {{address: string, family: string, port: number} | {}} Local physical endpoint metadata. */
226
+ address() {
227
+ if (this.localAddress === undefined || this.localPort === undefined) return {}
228
+ return {address: this.localAddress, family: this.localAddress.includes(":") ? "IPv6" : "IPv4", port: this.localPort}
229
+ }
230
+
231
+ /** @returns {this} References the shared transport. */
232
+ ref() {
233
+ this.#connection.ref()
234
+ return this
235
+ }
236
+
237
+ /** @returns {this} Unreferences the shared transport. */
238
+ unref() {
239
+ this.#connection.unref()
240
+ return this
241
+ }
242
+
243
+ /** @returns {void} */
244
+ #clearTimeout() {
245
+ if (this.#timeoutTimer !== undefined) clearTimeout(this.#timeoutTimer)
246
+ this.#timeoutTimer = undefined
247
+ }
248
+
249
+ /** @returns {void} */
250
+ #resetTimeout() {
251
+ this.#clearTimeout()
252
+ if (this.#timeoutMs === 0 || this.destroyed) return
253
+ this.#timeoutTimer = setTimeout(() => this.emit("timeout"), this.#timeoutMs)
254
+ this.#timeoutTimer.unref?.()
255
+ }
256
+ }
@@ -0,0 +1,400 @@
1
+ import {isIP} from "node:net"
2
+
3
+ import {DEFAULT_LOG_MAX_FILE_BYTES, DEFAULT_LOG_MAX_FILES, MAX_LOG_FILES, MIN_LOG_MAX_FILE_BYTES} from "../logging/structured-logger.js"
4
+
5
+ const DEFAULT_RELAY_HOST = "127.0.0.1"
6
+ const DEFAULT_RELAY_PORT = 3100
7
+ const DEFAULT_RESUME_WINDOW_MS = 30_000
8
+ const DEFAULT_MAX_TARGET_BUFFER_BYTES = 1_048_576
9
+ const DEFAULT_MAX_TOTAL_BUFFER_BYTES = 16_777_216
10
+ const DEFAULT_INITIAL_TARGET_RETRY_DELAY_MS = 250
11
+ const DEFAULT_INITIAL_TARGET_RETRY_MAX_DELAY_MS = 5_000
12
+ const DEFAULT_INITIAL_TARGET_CONNECT_TIMEOUT_MS = 10_000
13
+ const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000
14
+ const DEFAULT_MAX_INPUT_BYTES_PER_CONNECTION = 65_536
15
+ const DEFAULT_STATUS_INTERVAL_MS = 30_000
16
+ const PLACEHOLDER_TOKENS = new Set([
17
+ "replace-with-generated-token",
18
+ "replace-me",
19
+ "changeme",
20
+ "example-token",
21
+ "paste-generated-token-here"
22
+ ])
23
+ const DEFAULT_MIN_COMPRESS_BYTES = 256
24
+ const DEFAULT_MAX_DECOMPRESSED_BYTES = 16_777_216
25
+ const DEFAULT_MAX_EXPANSION_RATIO = 1000
26
+ const LOOPBACK_V6_ADDRESS = "::1"
27
+ const SUPPORTED_MODES = new Set(["relay", "reverse-hub", "reverse-gateway", "summary"])
28
+ const SUPPORTED_COMPRESSION = new Set(["auto", "off"])
29
+ const SUPPORTED_PROTOCOL_VERSIONS = new Set([2, 3])
30
+
31
+ /**
32
+ * @typedef {object} TargetEndpoint
33
+ * @property {string} host - Target host.
34
+ * @property {number} port - Target port.
35
+ * @typedef {object} StaticTokenConfig
36
+ * @property {string} username - Username associated with the token.
37
+ * @property {string} token - Opaque bearer token.
38
+ * @typedef {object} RelayTlsConfig
39
+ * @property {string} certFile - Path to PEM certificate file.
40
+ * @property {string} keyFile - Path to PEM private key file.
41
+ * @property {string} [clientCaFile] - Optional path to client CA for mTLS.
42
+ * @typedef {object} RelayEnvironmentConfig
43
+ * @property {'summary' | 'relay' | 'reverse-hub' | 'reverse-gateway'} mode - Runtime mode.
44
+ * @property {string} relayHost - Relay listen host.
45
+ * @property {number} relayPort - Relay listen port.
46
+ * @property {number} resumeWindowMs - Resume window in milliseconds.
47
+ * @property {number} maxTargetBufferBytes - Per-session byte limit for buffered target data while the tunnel is detached.
48
+ * @property {number} maxTotalBufferBytes - Process-wide byte limit for buffered target data across all sessions.
49
+ * @property {number} [initialTargetRetryDelayMs] - Initial delay between target acquisition attempts. Default 250.
50
+ * @property {number} [initialTargetRetryMaxDelayMs] - Maximum delay between target acquisition attempts. Default 5000.
51
+ * @property {number} [initialTargetConnectTimeoutMs] - Maximum time for one initial target TCP connect attempt. Default 10000.
52
+ * @property {number} handshakeTimeoutMs - Milliseconds to wait for auth after TCP/TLS connect before closing the socket. Default 10000.
53
+ * @property {number} maxInputBytesPerConnection - Per-connection input byte budget before the first authenticated session frame. Default 65536.
54
+ * @property {number} [statusIntervalMs] - Safe status-log interval; zero disables periodic status logs.
55
+ * @property {{directory: string, maxFileBytes: number, maxFiles: number}} [fileLogging] - Optional persistent JSONL logging configuration.
56
+ * @property {'auto' | 'off'} [compression] - Whether to negotiate stream.data compression when the runtime supports it. Defaults to auto.
57
+ * @property {2 | 3} [protocolVersion] - Relay wire protocol; defaults to rollback-compatible v2.
58
+ * @property {number} [minCompressBytes] - Payloads smaller than this are sent raw.
59
+ * @property {number} [maxDecompressedBytes] - Maximum accepted decompressed stream.data size.
60
+ * @property {number} [maxExpansionRatio] - Maximum accepted decompressed/compressed expansion ratio.
61
+ * @property {StaticTokenConfig[]} authTokens - Static bearer tokens.
62
+ * @property {TargetEndpoint[]} targetAllowlist - Allowed target endpoints.
63
+ * @property {boolean} allowInsecurePublic - Explicit opt-out for non-loopback without TLS.
64
+ * @property {RelayTlsConfig} [relayTls] - Optional TLS configuration.
65
+ * @property {Array<{gatewayId: string, username: string}>} [reverseGatewayBindings] - Hub gateway identity bindings.
66
+ * @property {Array<{username: string, gatewayId: string, host: string, port: number}>} [reverseRoutes] - Hub client route policy.
67
+ * @property {string} [reverseGatewayId] - Outbound gateway stable ID.
68
+ * @property {string} [reverseConnectionId] - Outbound gateway stable transport identity.
69
+ * @property {{host: string, port: number, token: string}} [reverseHub] - Outbound hub endpoint.
70
+ * @property {{caFile?: string, certFile?: string, keyFile?: string, servername?: string}} [reverseHubTls] - Verified hub TLS options.
71
+ */
72
+
73
+ /**
74
+ * @param {Record<string, string | undefined>} env - Process environment.
75
+ * @returns {RelayEnvironmentConfig} Runtime configuration.
76
+ */
77
+ export function loadEnvironmentConfig(env = process.env) {
78
+ const rawMode = env.SOCKETDUCT_MODE?.trim()
79
+ if (rawMode !== undefined && !SUPPORTED_MODES.has(rawMode)) {
80
+ throw new Error(`Unsupported SOCKETDUCT_MODE: "${rawMode}". Must be one of: ${[...SUPPORTED_MODES].join(", ")}`)
81
+ }
82
+ const mode = rawMode === undefined ? "summary" : /** @type {'summary' | 'relay' | 'reverse-hub' | 'reverse-gateway'} */ (rawMode)
83
+ const rawCompression = env.SOCKETDUCT_COMPRESSION?.trim()
84
+ if (rawCompression !== undefined && !SUPPORTED_COMPRESSION.has(rawCompression)) {
85
+ throw new Error(`Unsupported SOCKETDUCT_COMPRESSION: "${rawCompression}". Must be one of: ${[...SUPPORTED_COMPRESSION].join(", ")}`)
86
+ }
87
+ const compression = rawCompression === "off" ? "off" : "auto"
88
+ const protocolVersion = parseProtocolVersion(env.SOCKETDUCT_PROTOCOL_VERSION)
89
+ const certFile = env.SOCKETDUCT_TLS_CERT_FILE?.trim() || undefined
90
+ const keyFile = env.SOCKETDUCT_TLS_KEY_FILE?.trim() || undefined
91
+ const clientCaFile = env.SOCKETDUCT_TLS_CLIENT_CA_FILE?.trim() || undefined
92
+ let relayTls
93
+
94
+ if (clientCaFile !== undefined && (certFile === undefined || keyFile === undefined)) {
95
+ throw new Error("SOCKETDUCT_TLS_CLIENT_CA_FILE requires SOCKETDUCT_TLS_CERT_FILE and SOCKETDUCT_TLS_KEY_FILE to be set (mTLS needs a TLS cert/key pair)")
96
+ }
97
+
98
+ if (certFile !== undefined || keyFile !== undefined) {
99
+ if (certFile === undefined || keyFile === undefined) {
100
+ throw new Error("SOCKETDUCT_TLS_CERT_FILE and SOCKETDUCT_TLS_KEY_FILE must both be set (atomic pair)")
101
+ }
102
+ /** @type {RelayTlsConfig} */
103
+ const tlsConfig = {certFile, keyFile}
104
+ if (clientCaFile !== undefined) {
105
+ tlsConfig.clientCaFile = clientCaFile
106
+ }
107
+ relayTls = tlsConfig
108
+ }
109
+
110
+ const initialTargetRetryDelayMs = parsePositiveInteger(
111
+ env.SOCKETDUCT_INITIAL_TARGET_RETRY_DELAY_MS,
112
+ "SOCKETDUCT_INITIAL_TARGET_RETRY_DELAY_MS",
113
+ DEFAULT_INITIAL_TARGET_RETRY_DELAY_MS
114
+ )
115
+ const initialTargetRetryMaxDelayMs = parsePositiveInteger(
116
+ env.SOCKETDUCT_INITIAL_TARGET_RETRY_MAX_DELAY_MS,
117
+ "SOCKETDUCT_INITIAL_TARGET_RETRY_MAX_DELAY_MS",
118
+ DEFAULT_INITIAL_TARGET_RETRY_MAX_DELAY_MS
119
+ )
120
+ if (initialTargetRetryMaxDelayMs < initialTargetRetryDelayMs) {
121
+ throw new Error("SOCKETDUCT_INITIAL_TARGET_RETRY_MAX_DELAY_MS must be greater than or equal to SOCKETDUCT_INITIAL_TARGET_RETRY_DELAY_MS")
122
+ }
123
+
124
+ const reverseToken = env.SOCKETDUCT_REVERSE_TOKEN?.trim()
125
+ if (mode === "reverse-gateway" && reverseToken !== undefined && PLACEHOLDER_TOKENS.has(reverseToken.toLowerCase())) throw new Error("SOCKETDUCT_REVERSE_TOKEN must not be a placeholder token")
126
+ const reverseClientCertFile = env.SOCKETDUCT_REVERSE_HUB_CLIENT_CERT_FILE?.trim() || undefined
127
+ const reverseClientKeyFile = env.SOCKETDUCT_REVERSE_HUB_CLIENT_KEY_FILE?.trim() || undefined
128
+ if (mode === "reverse-gateway" && (reverseClientCertFile === undefined) !== (reverseClientKeyFile === undefined)) throw new Error("SOCKETDUCT_REVERSE_HUB_CLIENT_CERT_FILE and SOCKETDUCT_REVERSE_HUB_CLIENT_KEY_FILE must both be set")
129
+ const reverseHubTls = {
130
+ ...(env.SOCKETDUCT_REVERSE_HUB_CA_FILE?.trim() ? {caFile: env.SOCKETDUCT_REVERSE_HUB_CA_FILE.trim()} : {}),
131
+ ...(reverseClientCertFile === undefined ? {} : {certFile: reverseClientCertFile, keyFile: /** @type {string} */ (reverseClientKeyFile)}),
132
+ ...(env.SOCKETDUCT_REVERSE_HUB_SERVERNAME?.trim() ? {servername: env.SOCKETDUCT_REVERSE_HUB_SERVERNAME.trim()} : {})
133
+ }
134
+ const logDirectory = env.SOCKETDUCT_LOG_DIRECTORY?.trim() || undefined
135
+ let fileLogging
136
+ if (logDirectory !== undefined) {
137
+ const maxFileBytes = parsePositiveInteger(env.SOCKETDUCT_LOG_MAX_FILE_BYTES, "SOCKETDUCT_LOG_MAX_FILE_BYTES", DEFAULT_LOG_MAX_FILE_BYTES)
138
+ if (!Number.isSafeInteger(maxFileBytes) || maxFileBytes < MIN_LOG_MAX_FILE_BYTES) throw new Error(`SOCKETDUCT_LOG_MAX_FILE_BYTES must be a safe integer of at least ${MIN_LOG_MAX_FILE_BYTES}`)
139
+ const maxFiles = parsePositiveInteger(env.SOCKETDUCT_LOG_MAX_FILES, "SOCKETDUCT_LOG_MAX_FILES", DEFAULT_LOG_MAX_FILES)
140
+ if (maxFiles > MAX_LOG_FILES) throw new Error(`SOCKETDUCT_LOG_MAX_FILES must be no greater than ${MAX_LOG_FILES}`)
141
+ fileLogging = {directory: logDirectory, maxFileBytes, maxFiles}
142
+ }
143
+
144
+ return {
145
+ mode,
146
+ relayHost: env.SOCKETDUCT_RELAY_HOST ?? DEFAULT_RELAY_HOST,
147
+ relayPort: parsePort(env.SOCKETDUCT_RELAY_PORT, "SOCKETDUCT_RELAY_PORT", DEFAULT_RELAY_PORT),
148
+ resumeWindowMs: parsePositiveInteger(
149
+ env.SOCKETDUCT_RESUME_WINDOW_MS,
150
+ "SOCKETDUCT_RESUME_WINDOW_MS",
151
+ DEFAULT_RESUME_WINDOW_MS
152
+ ),
153
+ maxTargetBufferBytes: parsePositiveInteger(
154
+ env.SOCKETDUCT_MAX_TARGET_BUFFER_BYTES,
155
+ "SOCKETDUCT_MAX_TARGET_BUFFER_BYTES",
156
+ DEFAULT_MAX_TARGET_BUFFER_BYTES
157
+ ),
158
+ maxTotalBufferBytes: parsePositiveInteger(
159
+ env.SOCKETDUCT_MAX_TOTAL_BUFFER_BYTES,
160
+ "SOCKETDUCT_MAX_TOTAL_BUFFER_BYTES",
161
+ DEFAULT_MAX_TOTAL_BUFFER_BYTES
162
+ ),
163
+ initialTargetRetryDelayMs,
164
+ initialTargetRetryMaxDelayMs,
165
+ initialTargetConnectTimeoutMs: parsePositiveInteger(
166
+ env.SOCKETDUCT_INITIAL_TARGET_CONNECT_TIMEOUT_MS,
167
+ "SOCKETDUCT_INITIAL_TARGET_CONNECT_TIMEOUT_MS",
168
+ DEFAULT_INITIAL_TARGET_CONNECT_TIMEOUT_MS
169
+ ),
170
+ handshakeTimeoutMs: parsePositiveInteger(
171
+ env.SOCKETDUCT_HANDSHAKE_TIMEOUT_MS,
172
+ "SOCKETDUCT_HANDSHAKE_TIMEOUT_MS",
173
+ DEFAULT_HANDSHAKE_TIMEOUT_MS
174
+ ),
175
+ maxInputBytesPerConnection: parsePositiveInteger(
176
+ env.SOCKETDUCT_MAX_INPUT_BYTES_PER_CONNECTION,
177
+ "SOCKETDUCT_MAX_INPUT_BYTES_PER_CONNECTION",
178
+ DEFAULT_MAX_INPUT_BYTES_PER_CONNECTION
179
+ ),
180
+ statusIntervalMs: parseNonNegativeInteger(
181
+ env.SOCKETDUCT_STATUS_INTERVAL_MS,
182
+ "SOCKETDUCT_STATUS_INTERVAL_MS",
183
+ DEFAULT_STATUS_INTERVAL_MS
184
+ ),
185
+ ...(fileLogging === undefined ? {} : {fileLogging}),
186
+ compression,
187
+ protocolVersion,
188
+ minCompressBytes: parsePositiveInteger(
189
+ env.SOCKETDUCT_MIN_COMPRESS_BYTES,
190
+ "SOCKETDUCT_MIN_COMPRESS_BYTES",
191
+ DEFAULT_MIN_COMPRESS_BYTES
192
+ ),
193
+ maxDecompressedBytes: parsePositiveInteger(
194
+ env.SOCKETDUCT_MAX_DECOMPRESSED_BYTES,
195
+ "SOCKETDUCT_MAX_DECOMPRESSED_BYTES",
196
+ DEFAULT_MAX_DECOMPRESSED_BYTES
197
+ ),
198
+ maxExpansionRatio: parsePositiveInteger(
199
+ env.SOCKETDUCT_MAX_EXPANSION_RATIO,
200
+ "SOCKETDUCT_MAX_EXPANSION_RATIO",
201
+ DEFAULT_MAX_EXPANSION_RATIO
202
+ ),
203
+ authTokens: parseAuthTokens(envValue(env, "SOCKETDUCT_AUTH_TOKENS", "SEAMLINE_AUTH_TOKENS") ?? ""),
204
+ targetAllowlist: parseTargetAllowlist(envValue(env, "SOCKETDUCT_TARGET_ALLOWLIST", "SEAMLINE_TARGET_ALLOWLIST") ?? ""),
205
+ allowInsecurePublic: parseInsecurePublic(env.SOCKETDUCT_ALLOW_INSECURE_PUBLIC),
206
+ ...(relayTls !== undefined ? {relayTls} : {}),
207
+ ...(mode === "reverse-hub" ? {
208
+ reverseGatewayBindings: parseReverseGatewayBindings(env.SOCKETDUCT_REVERSE_GATEWAY_BINDINGS ?? ""),
209
+ reverseRoutes: parseReverseRoutes(env.SOCKETDUCT_REVERSE_ROUTES ?? "")
210
+ } : {}),
211
+ ...(mode === "reverse-gateway" ? {
212
+ reverseGatewayId: env.SOCKETDUCT_REVERSE_GATEWAY_ID?.trim() ?? "",
213
+ reverseConnectionId: env.SOCKETDUCT_REVERSE_CONNECTION_ID?.trim() ?? "",
214
+ reverseHub: {
215
+ host: env.SOCKETDUCT_REVERSE_HUB_HOST?.trim() ?? "",
216
+ port: parsePort(env.SOCKETDUCT_REVERSE_HUB_PORT, "SOCKETDUCT_REVERSE_HUB_PORT", DEFAULT_RELAY_PORT),
217
+ token: reverseToken ?? ""
218
+ },
219
+ reverseHubTls
220
+ } : {})
221
+ }
222
+ }
223
+
224
+ /**
225
+ * @param {string} value - Comma-separated gatewayId:username bindings.
226
+ * @returns {Array<{gatewayId: string, username: string}>} Bindings.
227
+ */
228
+ function parseReverseGatewayBindings(value) {
229
+ return splitList(value).map((entry) => {
230
+ const fields = entry.split(":")
231
+ if (fields.length !== 2 || fields.some((field) => field.length === 0)) throw new Error("SOCKETDUCT_REVERSE_GATEWAY_BINDINGS entries must use gatewayId:username")
232
+ return {gatewayId: /** @type {string} */ (fields[0]), username: /** @type {string} */ (fields[1])}
233
+ })
234
+ }
235
+
236
+ /**
237
+ * @param {string} value - Comma-separated username:gatewayId:host:port routes.
238
+ * @returns {Array<{username: string, gatewayId: string, host: string, port: number}>} Routes.
239
+ */
240
+ function parseReverseRoutes(value) {
241
+ return splitList(value).map((entry) => {
242
+ const first = entry.indexOf(":")
243
+ const second = entry.indexOf(":", first + 1)
244
+ const last = entry.lastIndexOf(":")
245
+ if (first <= 0 || second <= first + 1 || last <= second + 1 || last === entry.length - 1) throw new Error("SOCKETDUCT_REVERSE_ROUTES entries must use username:gatewayId:host:port")
246
+ return {
247
+ username: entry.slice(0, first),
248
+ gatewayId: entry.slice(first + 1, second),
249
+ host: entry.slice(second + 1, last),
250
+ port: parsePort(entry.slice(last + 1), "SOCKETDUCT_REVERSE_ROUTES port", DEFAULT_RELAY_PORT)
251
+ }
252
+ })
253
+ }
254
+
255
+ /**
256
+ * @param {string | undefined} value - Raw version.
257
+ * @returns {2 | 3} Selected protocol version.
258
+ */
259
+ function parseProtocolVersion(value) {
260
+ if (value === undefined || value === "") return 2
261
+ const parsed = Number(value)
262
+ if (!SUPPORTED_PROTOCOL_VERSIONS.has(parsed)) throw new Error("SOCKETDUCT_PROTOCOL_VERSION must be 2 or 3")
263
+ return /** @type {2 | 3} */ (parsed)
264
+ }
265
+
266
+ /**
267
+ * @param {Record<string, string | undefined>} env - Process environment.
268
+ * @param {string} primaryName - Preferred environment variable name.
269
+ * @param {string} legacyName - Legacy environment variable name accepted during rename migration.
270
+ * @returns {string | undefined} Environment value.
271
+ */
272
+ function envValue(env, primaryName, legacyName) {
273
+ return env[primaryName] ?? env[legacyName]
274
+ }
275
+
276
+ /**
277
+ * @param {string | undefined} value - Raw value.
278
+ * @param {string} name - Environment variable name.
279
+ * @param {number} defaultValue - Default value.
280
+ * @returns {number} Parsed port.
281
+ */
282
+ function parsePort(value, name, defaultValue) {
283
+ const parsed = parsePositiveInteger(value, name, defaultValue)
284
+ if (parsed > 65_535) {
285
+ throw new Error(`${name} must be a TCP port from 1 to 65535`)
286
+ }
287
+ return parsed
288
+ }
289
+
290
+ /**
291
+ * @param {string | undefined} value - Raw value.
292
+ * @param {string} name - Environment variable name.
293
+ * @param {number} defaultValue - Default value.
294
+ * @returns {number} Parsed positive integer.
295
+ */
296
+ function parsePositiveInteger(value, name, defaultValue) {
297
+ if (value === undefined || value === "") {
298
+ return defaultValue
299
+ }
300
+
301
+ const parsed = Number(value)
302
+ if (!Number.isInteger(parsed) || parsed < 1) {
303
+ throw new Error(`${name} must be a positive integer`)
304
+ }
305
+ return parsed
306
+ }
307
+
308
+ /**
309
+ * @param {string | undefined} value - Raw value.
310
+ * @param {string} name - Environment variable name.
311
+ * @param {number} defaultValue - Default value.
312
+ * @returns {number} Parsed non-negative integer.
313
+ */
314
+ function parseNonNegativeInteger(value, name, defaultValue) {
315
+ if (value === undefined || value === "") return defaultValue
316
+ const parsed = Number(value)
317
+ if (!Number.isInteger(parsed) || parsed < 0) {
318
+ throw new Error(`${name} must be a non-negative integer`)
319
+ }
320
+ return parsed
321
+ }
322
+
323
+ /**
324
+ * @param {string} value - Comma-separated username:token entries.
325
+ * @returns {StaticTokenConfig[]} Parsed static tokens.
326
+ */
327
+ function parseAuthTokens(value) {
328
+ return splitList(value).map((entry) => {
329
+ const separator = entry.indexOf(":")
330
+ if (separator <= 0 || separator === entry.length - 1) {
331
+ throw new Error("SOCKETDUCT_AUTH_TOKENS entries must use username:token")
332
+ }
333
+
334
+ const token = entry.slice(separator + 1)
335
+ if (PLACEHOLDER_TOKENS.has(token.toLowerCase())) {
336
+ throw new Error("SOCKETDUCT_AUTH_TOKENS must not contain placeholder tokens")
337
+ }
338
+
339
+ return {
340
+ username: entry.slice(0, separator),
341
+ token
342
+ }
343
+ })
344
+ }
345
+
346
+ /**
347
+ * @param {string} value - Comma-separated host:port entries.
348
+ * @returns {TargetEndpoint[]} Parsed target allowlist.
349
+ */
350
+ function parseTargetAllowlist(value) {
351
+ return splitList(value).map((entry) => {
352
+ const separator = entry.lastIndexOf(":")
353
+ if (separator <= 0 || separator === entry.length - 1) {
354
+ throw new Error("SOCKETDUCT_TARGET_ALLOWLIST entries must use host:port")
355
+ }
356
+
357
+ return {
358
+ host: entry.slice(0, separator),
359
+ port: parsePort(entry.slice(separator + 1), "SOCKETDUCT_TARGET_ALLOWLIST port", DEFAULT_RELAY_PORT)
360
+ }
361
+ })
362
+ }
363
+
364
+ /**
365
+ * @param {string} value - Comma-separated values.
366
+ * @returns {string[]} Trimmed non-empty entries.
367
+ */
368
+ function splitList(value) {
369
+ return value
370
+ .split(",")
371
+ .map((entry) => entry.trim())
372
+ .filter((entry) => entry.length > 0)
373
+ }
374
+
375
+ /**
376
+ * @param {string | undefined} value - Raw environment value.
377
+ * @returns {boolean} - True only for allowed insecure-public values.
378
+ */
379
+ function parseInsecurePublic(value) {
380
+ if (value === undefined || value === "") {
381
+ return false
382
+ }
383
+ const normalised = value.trim().toLowerCase()
384
+ if (normalised === "1" || normalised === "true") {
385
+ return true
386
+ }
387
+ throw new Error("SOCKETDUCT_ALLOW_INSECURE_PUBLIC must be 1 or true when set")
388
+ }
389
+
390
+ /**
391
+ * @param {string} host - Listen host.
392
+ * @returns {boolean} - True if the host is a loopback address.
393
+ */
394
+ export function isLoopbackHost(host) {
395
+ const lower = host.toLowerCase()
396
+ if (lower === "localhost" || lower === LOOPBACK_V6_ADDRESS || lower === "[::1]") {
397
+ return true
398
+ }
399
+ return isIP(lower) === 4 && lower.startsWith("127.")
400
+ }