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.
- package/.dockerignore +11 -0
- package/.env.example +131 -0
- package/AGENTS.md +168 -0
- package/Dockerfile +30 -0
- package/README.md +134 -0
- package/changelog.d/20260716083144-v2-ack-wire-format.md +3 -0
- package/changelog.d/20260716092044-v2-only-stream-framing.md +1 -0
- package/changelog.d/20260727113000-relay-connect-timeout-destroy.md +1 -0
- package/changelog.d/20260813093000-private-relay-hardening.md +1 -0
- package/changelog.d/20260824183412-initial-target-recovery-https.md +1 -0
- package/changelog.d/20260824230000-tls-relay-session-close.md +1 -0
- package/changelog.d/20260825120000-reconnect-close-target-timeout.md +1 -0
- package/changelog.d/20260829120000-protocol-v3-multiplexing.md +1 -0
- package/changelog.d/20260829160000-v3-review-corrections.md +1 -0
- package/changelog.d/20260830000000-fix-socket-idle-timeout.md +1 -0
- package/changelog.d/20260830090000-multi-gateway-transport-set.md +1 -0
- package/changelog.d/20260830093000-relay-close-settlement.md +1 -0
- package/changelog.d/20260830120000-immutable-gateway-image.md +1 -0
- package/changelog.d/20260902210000-native-reverse-gateway.md +1 -0
- package/changelog.d/20260902230000-relay-resume-terminal-order.md +1 -0
- package/changelog.d/20260902230708-multiplexed-open-ownership.md +1 -0
- package/changelog.d/20260902231541-multiplexed-terminal-close.md +1 -0
- package/changelog.d/20260902231542-reverse-close-position.md +1 -0
- package/changelog.d/20260902231543-reverse-terminal-connect.md +1 -0
- package/changelog.d/20260902233000-reverse-gateway-bounds.md +1 -0
- package/changelog.d/20260903001000-reverse-data-ordering.md +1 -0
- package/changelog.d/20260903003000-reverse-hub-restart-window.md +1 -0
- package/changelog.d/20260903010000-reverse-terminal-lifecycle.md +1 -0
- package/changelog.d/20260904142743-transport-set-readiness-wait.md +1 -0
- package/changelog.d/20260904152855-deferred-open-snapshots-binary-views.md +1 -0
- package/changelog.d/20260904163041-tensorbuzz-container-workflow.md +1 -0
- package/changelog.d/20260904174955-transport-set-terminal-causes.md +1 -0
- package/changelog.d/20260905081000-contain-reverse-auth-write-errors.md +1 -0
- package/changelog.d/20260905100520-reverse-hub-close-race.md +1 -0
- package/changelog.d/20260905120000-persistent-jsonl-logging.md +1 -0
- package/changelog.d/20260908071009-jsonl-partial-append-recovery.md +1 -0
- package/changelog.d/20260908074916-private-log-directory-setup.md +1 -0
- package/changelog.d/20260909093057-reverse-large-write-backpressure.md +1 -0
- package/changelog.d/20260909135830-actionable-error-diagnostics.md +1 -0
- package/changelog.d/20260909150000-transport-set-error-redaction.md +1 -0
- package/docker-compose.dev.yml +19 -0
- package/docker-compose.reverse-gateway.yml +36 -0
- package/docker-compose.yml +73 -0
- package/docs/architecture.md +264 -0
- package/docs/docker-topology.md +164 -0
- package/docs/docker.md +281 -0
- package/docs/logging.md +185 -0
- package/docs/plans/initial-implementation.md +205 -0
- package/docs/plans/socket-docker-integration.md +221 -0
- package/docs/protocol.md +558 -0
- package/docs/releasing.md +18 -0
- package/docs/reverse-gateway.md +186 -0
- package/docs/runbooks/jsonl-integrity-rollout.md +144 -0
- package/docs/running.md +215 -0
- package/docs/security.md +258 -0
- package/docs/session-lifecycle.md +127 -0
- package/docs/socket-api.md +448 -0
- package/eslint.config.js +36 -0
- package/package.json +44 -0
- package/scripts/deploy-gateway.js +194 -0
- package/scripts/test.js +28 -0
- package/src/auth/passwords.js +38 -0
- package/src/auth/static-token-store.js +41 -0
- package/src/auth/user-store.js +139 -0
- package/src/client/connect.js +76 -0
- package/src/client/deferred-virtual-socket.js +312 -0
- package/src/client/http-agent.js +101 -0
- package/src/client/https-agent.js +366 -0
- package/src/client/multiplexed-connection.js +1504 -0
- package/src/client/multiplexed-transport-set.js +917 -0
- package/src/client/multiplexed.js +3 -0
- package/src/client/socketduct-socket.js +1174 -0
- package/src/client/virtual-socket.js +256 -0
- package/src/config/environment.js +400 -0
- package/src/edge/tcp-edge.js +82 -0
- package/src/index.js +101 -0
- package/src/logging/structured-logger.js +745 -0
- package/src/protocol/compression.js +325 -0
- package/src/protocol/frame.js +341 -0
- package/src/protocol/v3-compression.js +44 -0
- package/src/protocol/v3-frame.js +209 -0
- package/src/proxy/multiplexed-relay.js +798 -0
- package/src/proxy/tcp-relay.js +1191 -0
- package/src/relay.js +3 -0
- package/src/reverse/client.js +721 -0
- package/src/reverse/common.js +88 -0
- package/src/reverse/gateway.js +663 -0
- package/src/reverse/hub.js +563 -0
- package/src/reverse/index.js +3 -0
- package/src/reverse/runtime.js +78 -0
- package/src/server/control-handler.js +170 -0
- package/src/server/primary.js +64 -0
- package/src/server/relay-runtime.js +124 -0
- package/src/server/worker.js +39 -0
- package/src/sessions/session.js +212 -0
- package/src/spool/spool-file.js +451 -0
- package/tensorbuzz.yml +36 -0
- package/test/e2e/gateway-process.test.js +262 -0
- package/test/e2e/reverse-gateway-process.test.js +285 -0
- package/test/fixtures/logging/enospc-matrix.mjs +238 -0
- package/test/fixtures/logging/enospc-recovery.mjs +146 -0
- package/test/fixtures/logging/process-writer.mjs +20 -0
- package/test/helpers/tls-certificates.js +115 -0
- package/test/integration/initial-target-recovery.test.js +518 -0
- package/test/integration/logging-recovery.test.js +163 -0
- package/test/integration/multiplexed-compatibility.test.js +254 -0
- package/test/integration/multiplexed-corrections.test.js +551 -0
- package/test/integration/multiplexed-failures.test.js +339 -0
- package/test/integration/multiplexed-transport-set.test.js +456 -0
- package/test/integration/multiplexed-transport.test.js +208 -0
- package/test/integration/reverse-gateway.test.js +1293 -0
- package/test/integration/socketduct-http-agent.test.js +435 -0
- package/test/integration/socketduct-https-agent.test.js +669 -0
- package/test/integration/stream-compression.test.js +1267 -0
- package/test/integration/tcp-forwarding.test.js +2878 -0
- package/test/integration/tls-forwarding.test.js +748 -0
- package/test/unit/auth/auth.test.js +64 -0
- package/test/unit/auth/static-token-store.test.js +14 -0
- package/test/unit/client/connect.test.js +58 -0
- package/test/unit/client/multiplexed-spool-budget.test.js +309 -0
- package/test/unit/client/multiplexed-transport-set.test.js +906 -0
- package/test/unit/client/socketduct-socket.test.js +1124 -0
- package/test/unit/client/virtual-socket.test.js +98 -0
- package/test/unit/config/docker-gateway-files.test.js +274 -0
- package/test/unit/config/environment.test.js +507 -0
- package/test/unit/index.test.js +78 -0
- package/test/unit/logging/structured-logger.test.js +674 -0
- package/test/unit/protocol/compression.test.js +421 -0
- package/test/unit/protocol/frame.test.js +336 -0
- package/test/unit/protocol/v3-compression.test.js +31 -0
- package/test/unit/protocol/v3-frame.test.js +57 -0
- package/test/unit/release-configuration.test.js +21 -0
- package/test/unit/server/control-handler.test.js +291 -0
- package/test/unit/server/primary.test.js +59 -0
- package/test/unit/server/relay-runtime.test.js +434 -0
- package/test/unit/sessions/session.test.js +84 -0
- package/test/unit/spool/spool-file.test.js +295 -0
- 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
|
+
}
|