socketduct 0.1.25 → 0.1.27

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 (58) hide show
  1. package/AGENTS.md +2 -2
  2. package/changelog.d/20261010130509-bound-log-frame-compression-memory.md +1 -0
  3. package/changelog.d/20261025070000-reclaim-unreadable-retention-lock.md +14 -0
  4. package/docs/runbooks/jsonl-integrity-rollout.md +8 -6
  5. package/package.json +2 -2
  6. package/scripts/test.js +9 -14
  7. package/src/logging/compressed-log-writer.js +1 -1
  8. package/src/logging/log-retention.js +36 -1
  9. package/src/logging/zstd-frame-codec.js +15 -1
  10. package/test/e2e/gateway-process.test.js +93 -92
  11. package/test/e2e/logger-shutdown-process.test.js +40 -37
  12. package/test/e2e/reverse-client-recovery-process.test.js +107 -103
  13. package/test/e2e/reverse-gateway-process.test.js +328 -318
  14. package/test/integration/initial-target-recovery.test.js +333 -334
  15. package/test/integration/logging-cli.test.js +121 -119
  16. package/test/integration/logging-compressed-recovery.test.js +96 -95
  17. package/test/integration/multiplexed-compatibility.test.js +178 -177
  18. package/test/integration/multiplexed-corrections.test.js +360 -359
  19. package/test/integration/multiplexed-failures.test.js +253 -251
  20. package/test/integration/multiplexed-transport-set.test.js +334 -322
  21. package/test/integration/multiplexed-transport.test.js +135 -134
  22. package/test/integration/reverse-gateway-stream-id-reset.test.js +90 -89
  23. package/test/integration/reverse-gateway.test.js +2309 -2218
  24. package/test/integration/reverse-resume-continuity.test.js +219 -218
  25. package/test/integration/reverse-route-class.test.js +358 -341
  26. package/test/integration/socketduct-http-agent.test.js +314 -312
  27. package/test/integration/socketduct-https-agent.test.js +516 -485
  28. package/test/integration/stream-compression.test.js +862 -824
  29. package/test/integration/tcp-forwarding.test.js +2550 -2447
  30. package/test/integration/tls-forwarding.test.js +593 -592
  31. package/test/unit/auth/auth.test.js +43 -44
  32. package/test/unit/auth/static-token-store.test.js +9 -8
  33. package/test/unit/client/connect.test.js +41 -40
  34. package/test/unit/client/deferred-virtual-socket.test.js +95 -94
  35. package/test/unit/client/multiplexed-spool-budget.test.js +155 -154
  36. package/test/unit/client/multiplexed-transport-set.test.js +830 -748
  37. package/test/unit/client/socketduct-socket.test.js +768 -771
  38. package/test/unit/client/virtual-socket.test.js +198 -168
  39. package/test/unit/config/docker-gateway-files.test.js +224 -224
  40. package/test/unit/config/environment.test.js +472 -492
  41. package/test/unit/index.test.js +53 -52
  42. package/test/unit/logging/compressed-log-worker.test.js +23 -22
  43. package/test/unit/logging/compressed-log-writer.test.js +155 -154
  44. package/test/unit/logging/log-retention.test.js +71 -48
  45. package/test/unit/logging/log-segment-index.test.js +84 -81
  46. package/test/unit/logging/zstd-frame-codec.test.js +28 -19
  47. package/test/unit/protocol/compression.test.js +254 -298
  48. package/test/unit/protocol/frame.test.js +280 -276
  49. package/test/unit/protocol/v3-compression.test.js +21 -23
  50. package/test/unit/protocol/v3-frame.test.js +42 -41
  51. package/test/unit/release-configuration.test.js +15 -12
  52. package/test/unit/reverse/disconnect-attribution.test.js +65 -64
  53. package/test/unit/reverse-terminal-outcomes.test.js +53 -50
  54. package/test/unit/server/control-handler.test.js +232 -231
  55. package/test/unit/server/primary.test.js +51 -49
  56. package/test/unit/server/relay-runtime.test.js +280 -294
  57. package/test/unit/sessions/session.test.js +48 -62
  58. package/test/unit/spool/spool-file.test.js +401 -385
@@ -1,578 +1,558 @@
1
- import assert from "node:assert/strict"
2
- import test from "node:test"
1
+ import {describe, expect, it} from "@velocious/testing"
3
2
 
4
3
  import {isLoopbackHost, loadEnvironmentConfig} from "../../../src/config/environment.js"
5
4
 
6
- test("loadEnvironmentConfig parses relay Docker environment", () => {
7
- const config = loadEnvironmentConfig({
8
- SOCKETDUCT_MODE: "relay",
9
- SOCKETDUCT_RELAY_HOST: "0.0.0.0",
10
- SOCKETDUCT_RELAY_PORT: "3100",
11
- SOCKETDUCT_RESUME_WINDOW_MS: "45000",
12
- SOCKETDUCT_AUTH_TOKENS: "edge:token-one,backup:token-two",
13
- SOCKETDUCT_TARGET_ALLOWLIST: "tensorbuzz.internal:443,127.0.0.1:8080"
14
- })
15
-
16
- assert.deepEqual(config, {
17
- mode: "relay",
18
- relayHost: "0.0.0.0",
19
- relayPort: 3100,
20
- resumeWindowMs: 45000,
21
- maxTargetBufferBytes: 1_048_576,
22
- maxTotalBufferBytes: 16_777_216,
23
- initialTargetRetryDelayMs: 250,
24
- initialTargetRetryMaxDelayMs: 5_000,
25
- initialTargetConnectTimeoutMs: 10_000,
26
- handshakeTimeoutMs: 10_000,
27
- maxInputBytesPerConnection: 65_536,
28
- statusIntervalMs: 30_000,
29
- compression: "auto",
30
- protocolVersion: 2,
31
- minCompressBytes: 256,
32
- maxDecompressedBytes: 16_777_216,
33
- maxExpansionRatio: 1000,
34
- authTokens: [
35
- {username: "edge", token: "token-one"},
36
- {username: "backup", token: "token-two"}
37
- ],
38
- targetAllowlist: [
39
- {host: "tensorbuzz.internal", port: 443},
40
- {host: "127.0.0.1", port: 8080}
41
- ],
42
- allowInsecurePublic: false
43
- })
44
- })
45
-
46
- test("loadEnvironmentConfig accepts legacy Seamline relay secrets during migration", () => {
47
- const config = loadEnvironmentConfig({
48
- SOCKETDUCT_MODE: "relay",
49
- SEAMLINE_AUTH_TOKENS: "edge:legacy-token",
50
- SEAMLINE_TARGET_ALLOWLIST: "docker-socket-shim:2375"
5
+ describe("environment config", () => {
6
+ it("loadEnvironmentConfig parses relay Docker environment", () => {
7
+ const config = loadEnvironmentConfig({
8
+ SOCKETDUCT_MODE: "relay",
9
+ SOCKETDUCT_RELAY_HOST: "0.0.0.0",
10
+ SOCKETDUCT_RELAY_PORT: "3100",
11
+ SOCKETDUCT_RESUME_WINDOW_MS: "45000",
12
+ SOCKETDUCT_AUTH_TOKENS: "edge:token-one,backup:token-two",
13
+ SOCKETDUCT_TARGET_ALLOWLIST: "tensorbuzz.internal:443,127.0.0.1:8080"
14
+ })
15
+
16
+ expect(config).toEqual({
17
+ mode: "relay",
18
+ relayHost: "0.0.0.0",
19
+ relayPort: 3100,
20
+ resumeWindowMs: 45000,
21
+ maxTargetBufferBytes: 1_048_576,
22
+ maxTotalBufferBytes: 16_777_216,
23
+ initialTargetRetryDelayMs: 250,
24
+ initialTargetRetryMaxDelayMs: 5_000,
25
+ initialTargetConnectTimeoutMs: 10_000,
26
+ handshakeTimeoutMs: 10_000,
27
+ maxInputBytesPerConnection: 65_536,
28
+ statusIntervalMs: 30_000,
29
+ compression: "auto",
30
+ protocolVersion: 2,
31
+ minCompressBytes: 256,
32
+ maxDecompressedBytes: 16_777_216,
33
+ maxExpansionRatio: 1000,
34
+ authTokens: [
35
+ {username: "edge", token: "token-one"},
36
+ {username: "backup", token: "token-two"}
37
+ ],
38
+ targetAllowlist: [
39
+ {host: "tensorbuzz.internal", port: 443},
40
+ {host: "127.0.0.1", port: 8080}
41
+ ],
42
+ allowInsecurePublic: false
43
+ })
51
44
  })
52
45
 
53
- assert.deepEqual(config.authTokens, [{username: "edge", token: "legacy-token"}])
54
- assert.deepEqual(config.targetAllowlist, [{host: "docker-socket-shim", port: 2375}])
55
- })
46
+ it("loadEnvironmentConfig accepts legacy Seamline relay secrets during migration", () => {
47
+ const config = loadEnvironmentConfig({
48
+ SOCKETDUCT_MODE: "relay",
49
+ SEAMLINE_AUTH_TOKENS: "edge:legacy-token",
50
+ SEAMLINE_TARGET_ALLOWLIST: "docker-socket-shim:2375"
51
+ })
56
52
 
57
- test("loadEnvironmentConfig prefers Socketduct relay secrets over legacy Seamline aliases", () => {
58
- const config = loadEnvironmentConfig({
59
- SOCKETDUCT_AUTH_TOKENS: "edge:new-token",
60
- SOCKETDUCT_TARGET_ALLOWLIST: "docker-socket-shim:2375",
61
- SEAMLINE_AUTH_TOKENS: "edge:legacy-token",
62
- SEAMLINE_TARGET_ALLOWLIST: "legacy-docker:2375"
53
+ expect(config.authTokens).toEqual([{username: "edge", token: "legacy-token"}])
54
+ expect(config.targetAllowlist).toEqual([{host: "docker-socket-shim", port: 2375}])
63
55
  })
64
56
 
65
- assert.deepEqual(config.authTokens, [{username: "edge", token: "new-token"}])
66
- assert.deepEqual(config.targetAllowlist, [{host: "docker-socket-shim", port: 2375}])
67
- })
68
-
69
- test("loadEnvironmentConfig rejects malformed ports and list entries", () => {
70
- assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_RELAY_PORT: "0"}), /positive integer/)
71
- assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_RELAY_PORT: "70000"}), /TCP port/)
72
- assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_AUTH_TOKENS: "missing-token"}), /username:token/)
73
- assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_TARGET_ALLOWLIST: "missing-port"}), /host:port/)
74
- })
75
-
76
- test("loadEnvironmentConfig parses or disables the status interval", () => {
77
- assert.equal(loadEnvironmentConfig({SOCKETDUCT_STATUS_INTERVAL_MS: "45000"}).statusIntervalMs, 45_000)
78
- assert.equal(loadEnvironmentConfig({SOCKETDUCT_STATUS_INTERVAL_MS: "0"}).statusIntervalMs, 0)
79
- assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_STATUS_INTERVAL_MS: "-1"}), /non-negative integer/)
80
- })
57
+ it("loadEnvironmentConfig prefers Socketduct relay secrets over legacy Seamline aliases", () => {
58
+ const config = loadEnvironmentConfig({
59
+ SOCKETDUCT_AUTH_TOKENS: "edge:new-token",
60
+ SOCKETDUCT_TARGET_ALLOWLIST: "docker-socket-shim:2375",
61
+ SEAMLINE_AUTH_TOKENS: "edge:legacy-token",
62
+ SEAMLINE_TARGET_ALLOWLIST: "legacy-docker:2375"
63
+ })
81
64
 
82
- test("loadEnvironmentConfig keeps persistent logging disabled by default and parses compressed bounds", () => {
83
- assert.equal(loadEnvironmentConfig({}).fileLogging, undefined)
84
- assert.equal(loadEnvironmentConfig({SOCKETDUCT_LOG_DIRECTORY: " "}).fileLogging, undefined)
85
- assert.deepEqual(loadEnvironmentConfig({
86
- SOCKETDUCT_LOG_DIRECTORY: " /var/log/socketduct ",
87
- SOCKETDUCT_LOG_FRAME_MAX_BYTES: "2097152",
88
- SOCKETDUCT_LOG_FLUSH_INTERVAL_MS: "1500",
89
- SOCKETDUCT_LOG_CRITICAL_FLUSH_INTERVAL_MS: "200",
90
- SOCKETDUCT_LOG_SEGMENT_MAX_BYTES: "268435456",
91
- SOCKETDUCT_LOG_RETENTION_DAYS: "14",
92
- SOCKETDUCT_LOG_RETENTION_MAX_BYTES: "3221225472",
93
- SOCKETDUCT_LOG_QUEUE_MAX_BYTES: "33554432",
94
- SOCKETDUCT_LOG_CRITICAL_RESERVE_BYTES: "4194304",
95
- SOCKETDUCT_LOG_COMPRESSION_LEVEL: "18"
96
- }).fileLogging, {
97
- directory: "/var/log/socketduct",
98
- frameMaxBytes: 2_097_152,
99
- flushIntervalMs: 1_500,
100
- criticalFlushIntervalMs: 200,
101
- maxSegmentBytes: 268_435_456,
102
- retentionDays: 14,
103
- retentionBytes: 3_221_225_472,
104
- maxQueueBytes: 33_554_432,
105
- criticalReserveBytes: 4_194_304,
106
- compressionLevel: 18
65
+ expect(config.authTokens).toEqual([{username: "edge", token: "new-token"}])
66
+ expect(config.targetAllowlist).toEqual([{host: "docker-socket-shim", port: 2375}])
107
67
  })
108
- })
109
-
110
- test("loadEnvironmentConfig rejects unsafe compressed logging bounds", () => {
111
- assert.throws(() => loadEnvironmentConfig({
112
- SOCKETDUCT_LOG_DIRECTORY: "/var/log/socketduct",
113
- SOCKETDUCT_LOG_FRAME_MAX_BYTES: "100"
114
- }), /SOCKETDUCT_LOG_FRAME_MAX_BYTES/)
115
- assert.throws(() => loadEnvironmentConfig({
116
- SOCKETDUCT_LOG_DIRECTORY: "/var/log/socketduct",
117
- SOCKETDUCT_LOG_COMPRESSION_LEVEL: "23"
118
- }), /SOCKETDUCT_LOG_COMPRESSION_LEVEL/)
119
- assert.throws(() => loadEnvironmentConfig({
120
- SOCKETDUCT_LOG_DIRECTORY: "/var/log/socketduct",
121
- SOCKETDUCT_LOG_QUEUE_MAX_BYTES: "1024",
122
- SOCKETDUCT_LOG_CRITICAL_RESERVE_BYTES: "1024"
123
- }), /SOCKETDUCT_LOG_CRITICAL_RESERVE_BYTES/)
124
- assert.throws(() => loadEnvironmentConfig({
125
- SOCKETDUCT_LOG_DIRECTORY: "/var/log/socketduct",
126
- SOCKETDUCT_LOG_SEGMENT_MAX_BYTES: "5368709120",
127
- SOCKETDUCT_LOG_RETENTION_MAX_BYTES: "4294967296"
128
- }), /SOCKETDUCT_LOG_SEGMENT_MAX_BYTES/)
129
- assert.throws(() => loadEnvironmentConfig({
130
- SOCKETDUCT_LOG_DIRECTORY: "/var/log/socketduct",
131
- SOCKETDUCT_LOG_MAX_FILE_BYTES: "10485760"
132
- }), /SOCKETDUCT_LOG_MAX_FILE_BYTES/)
133
- })
134
68
 
135
- test("loadEnvironmentConfig accepts a paired hub-only fingerprint key and epoch", () => {
136
- const encodedKey = Buffer.alloc(32, 0x5a).toString("base64url")
137
- const config = loadEnvironmentConfig({
138
- SOCKETDUCT_MODE: "reverse-hub",
139
- SOCKETDUCT_LOG_DIRECTORY: "/var/log/socketduct",
140
- SOCKETDUCT_CONNECTION_FINGERPRINT_KEY: encodedKey,
141
- SOCKETDUCT_CONNECTION_FINGERPRINT_KEY_EPOCH: "hub-2026-09"
142
- })
143
- assert.deepEqual(config.connectionFingerprint, {key: Buffer.alloc(32, 0x5a), keyEpoch: "hub-2026-09"})
144
- assert.throws(() => loadEnvironmentConfig({
145
- SOCKETDUCT_MODE: "reverse-hub",
146
- SOCKETDUCT_LOG_DIRECTORY: "/var/log/socketduct"
147
- }), /SOCKETDUCT_CONNECTION_FINGERPRINT_KEY/)
148
- assert.throws(() => loadEnvironmentConfig({
149
- SOCKETDUCT_MODE: "reverse-gateway",
150
- SOCKETDUCT_CONNECTION_FINGERPRINT_KEY: encodedKey,
151
- SOCKETDUCT_CONNECTION_FINGERPRINT_KEY_EPOCH: "hub-2026-09"
152
- }), /hub-only/)
153
- })
154
-
155
- test("loadEnvironmentConfig selects v3 explicitly and keeps v2 as rollback default", () => {
156
- assert.equal(loadEnvironmentConfig({}).protocolVersion, 2)
157
- assert.equal(loadEnvironmentConfig({SOCKETDUCT_PROTOCOL_VERSION: "3"}).protocolVersion, 3)
158
- assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_PROTOCOL_VERSION: "1"}), /must be 2 or 3/)
159
- })
69
+ it("loadEnvironmentConfig rejects malformed ports and list entries", () => {
70
+ expect(() => loadEnvironmentConfig({SOCKETDUCT_RELAY_PORT: "0"})).toThrow(/positive integer/)
71
+ expect(() => loadEnvironmentConfig({SOCKETDUCT_RELAY_PORT: "70000"})).toThrow(/TCP port/)
72
+ expect(() => loadEnvironmentConfig({SOCKETDUCT_AUTH_TOKENS: "missing-token"})).toThrow(/username:token/)
73
+ expect(() => loadEnvironmentConfig({SOCKETDUCT_TARGET_ALLOWLIST: "missing-port"})).toThrow(/host:port/)
74
+ })
160
75
 
161
- test("loadEnvironmentConfig rejects placeholder auth tokens", () => {
162
- assert.throws(
163
- () => loadEnvironmentConfig({
164
- SOCKETDUCT_AUTH_TOKENS: "tensorbuzz-edge:replace-with-generated-token",
165
- SOCKETDUCT_TARGET_ALLOWLIST: "127.0.0.1:443"
166
- }),
167
- /placeholder/
168
- )
169
- assert.throws(
170
- () => loadEnvironmentConfig({
171
- SOCKETDUCT_AUTH_TOKENS: "tensorbuzz-edge:paste-generated-token-here",
172
- SOCKETDUCT_TARGET_ALLOWLIST: "127.0.0.1:443"
173
- }),
174
- /placeholder/
175
- )
176
- })
76
+ it("loadEnvironmentConfig parses or disables the status interval", () => {
77
+ expect(loadEnvironmentConfig({SOCKETDUCT_STATUS_INTERVAL_MS: "45000"}).statusIntervalMs).toBe(45_000)
78
+ expect(loadEnvironmentConfig({SOCKETDUCT_STATUS_INTERVAL_MS: "0"}).statusIntervalMs).toBe(0)
79
+ expect(() => loadEnvironmentConfig({SOCKETDUCT_STATUS_INTERVAL_MS: "-1"})).toThrow(/non-negative integer/)
80
+ })
177
81
 
178
- test("loadEnvironmentConfig parses TLS cert/key as an atomic pair", () => {
179
- const config = loadEnvironmentConfig({
180
- SOCKETDUCT_TLS_CERT_FILE: "/certs/relay.crt",
181
- SOCKETDUCT_TLS_KEY_FILE: "/certs/relay.key"
82
+ it("loadEnvironmentConfig keeps persistent logging disabled by default and parses compressed bounds", () => {
83
+ expect(loadEnvironmentConfig({}).fileLogging).toBeUndefined()
84
+ expect(loadEnvironmentConfig({SOCKETDUCT_LOG_DIRECTORY: " "}).fileLogging).toBeUndefined()
85
+ expect(loadEnvironmentConfig({
86
+ SOCKETDUCT_LOG_DIRECTORY: " /var/log/socketduct ",
87
+ SOCKETDUCT_LOG_FRAME_MAX_BYTES: "2097152",
88
+ SOCKETDUCT_LOG_FLUSH_INTERVAL_MS: "1500",
89
+ SOCKETDUCT_LOG_CRITICAL_FLUSH_INTERVAL_MS: "200",
90
+ SOCKETDUCT_LOG_SEGMENT_MAX_BYTES: "268435456",
91
+ SOCKETDUCT_LOG_RETENTION_DAYS: "14",
92
+ SOCKETDUCT_LOG_RETENTION_MAX_BYTES: "3221225472",
93
+ SOCKETDUCT_LOG_QUEUE_MAX_BYTES: "33554432",
94
+ SOCKETDUCT_LOG_CRITICAL_RESERVE_BYTES: "4194304",
95
+ SOCKETDUCT_LOG_COMPRESSION_LEVEL: "18"
96
+ }).fileLogging).toEqual({
97
+ directory: "/var/log/socketduct",
98
+ frameMaxBytes: 2_097_152,
99
+ flushIntervalMs: 1_500,
100
+ criticalFlushIntervalMs: 200,
101
+ maxSegmentBytes: 268_435_456,
102
+ retentionDays: 14,
103
+ retentionBytes: 3_221_225_472,
104
+ maxQueueBytes: 33_554_432,
105
+ criticalReserveBytes: 4_194_304,
106
+ compressionLevel: 18
107
+ })
182
108
  })
183
109
 
184
- assert.deepEqual(config.relayTls, {
185
- certFile: "/certs/relay.crt",
186
- keyFile: "/certs/relay.key"
110
+ it("loadEnvironmentConfig rejects unsafe compressed logging bounds", () => {
111
+ expect(() => loadEnvironmentConfig({
112
+ SOCKETDUCT_LOG_DIRECTORY: "/var/log/socketduct",
113
+ SOCKETDUCT_LOG_FRAME_MAX_BYTES: "100"
114
+ })).toThrow(/SOCKETDUCT_LOG_FRAME_MAX_BYTES/)
115
+ expect(() => loadEnvironmentConfig({
116
+ SOCKETDUCT_LOG_DIRECTORY: "/var/log/socketduct",
117
+ SOCKETDUCT_LOG_COMPRESSION_LEVEL: "23"
118
+ })).toThrow(/SOCKETDUCT_LOG_COMPRESSION_LEVEL/)
119
+ expect(() => loadEnvironmentConfig({
120
+ SOCKETDUCT_LOG_DIRECTORY: "/var/log/socketduct",
121
+ SOCKETDUCT_LOG_QUEUE_MAX_BYTES: "1024",
122
+ SOCKETDUCT_LOG_CRITICAL_RESERVE_BYTES: "1024"
123
+ })).toThrow(/SOCKETDUCT_LOG_CRITICAL_RESERVE_BYTES/)
124
+ expect(() => loadEnvironmentConfig({
125
+ SOCKETDUCT_LOG_DIRECTORY: "/var/log/socketduct",
126
+ SOCKETDUCT_LOG_SEGMENT_MAX_BYTES: "5368709120",
127
+ SOCKETDUCT_LOG_RETENTION_MAX_BYTES: "4294967296"
128
+ })).toThrow(/SOCKETDUCT_LOG_SEGMENT_MAX_BYTES/)
129
+ expect(() => loadEnvironmentConfig({
130
+ SOCKETDUCT_LOG_DIRECTORY: "/var/log/socketduct",
131
+ SOCKETDUCT_LOG_MAX_FILE_BYTES: "10485760"
132
+ })).toThrow(/SOCKETDUCT_LOG_MAX_FILE_BYTES/)
187
133
  })
188
- })
189
134
 
190
- test("loadEnvironmentConfig parses optional client CA for mTLS", () => {
191
- const config = loadEnvironmentConfig({
192
- SOCKETDUCT_TLS_CERT_FILE: "/certs/relay.crt",
193
- SOCKETDUCT_TLS_KEY_FILE: "/certs/relay.key",
194
- SOCKETDUCT_TLS_CLIENT_CA_FILE: "/certs/client-ca.crt"
135
+ it("loadEnvironmentConfig accepts a paired hub-only fingerprint key and epoch", () => {
136
+ const encodedKey = Buffer.alloc(32, 0x5a).toString("base64url")
137
+ const config = loadEnvironmentConfig({
138
+ SOCKETDUCT_MODE: "reverse-hub",
139
+ SOCKETDUCT_LOG_DIRECTORY: "/var/log/socketduct",
140
+ SOCKETDUCT_CONNECTION_FINGERPRINT_KEY: encodedKey,
141
+ SOCKETDUCT_CONNECTION_FINGERPRINT_KEY_EPOCH: "hub-2026-09"
142
+ })
143
+ expect(config.connectionFingerprint?.key.equals(Buffer.alloc(32, 0x5a))).toBe(true)
144
+ expect(config.connectionFingerprint?.keyEpoch).toBe("hub-2026-09")
145
+ expect(() => loadEnvironmentConfig({
146
+ SOCKETDUCT_MODE: "reverse-hub",
147
+ SOCKETDUCT_LOG_DIRECTORY: "/var/log/socketduct"
148
+ })).toThrow(/SOCKETDUCT_CONNECTION_FINGERPRINT_KEY/)
149
+ expect(() => loadEnvironmentConfig({
150
+ SOCKETDUCT_MODE: "reverse-gateway",
151
+ SOCKETDUCT_CONNECTION_FINGERPRINT_KEY: encodedKey,
152
+ SOCKETDUCT_CONNECTION_FINGERPRINT_KEY_EPOCH: "hub-2026-09"
153
+ })).toThrow(/hub-only/)
195
154
  })
196
155
 
197
- assert.deepEqual(config.relayTls, {
198
- certFile: "/certs/relay.crt",
199
- keyFile: "/certs/relay.key",
200
- clientCaFile: "/certs/client-ca.crt"
156
+ it("loadEnvironmentConfig selects v3 explicitly and keeps v2 as rollback default", () => {
157
+ expect(loadEnvironmentConfig({}).protocolVersion).toBe(2)
158
+ expect(loadEnvironmentConfig({SOCKETDUCT_PROTOCOL_VERSION: "3"}).protocolVersion).toBe(3)
159
+ expect(() => loadEnvironmentConfig({SOCKETDUCT_PROTOCOL_VERSION: "1"})).toThrow(/must be 2 or 3/)
201
160
  })
202
- })
203
161
 
204
- test("loadEnvironmentConfig returns undefined relayTls when no TLS vars are set", () => {
205
- const config = loadEnvironmentConfig({
206
- SOCKETDUCT_AUTH_TOKENS: "edge:token",
207
- SOCKETDUCT_TARGET_ALLOWLIST: "127.0.0.1:443"
162
+ it("loadEnvironmentConfig rejects placeholder auth tokens", () => {
163
+ expect(
164
+ () => loadEnvironmentConfig({
165
+ SOCKETDUCT_AUTH_TOKENS: "tensorbuzz-edge:replace-with-generated-token",
166
+ SOCKETDUCT_TARGET_ALLOWLIST: "127.0.0.1:443"
167
+ })
168
+ ).toThrow(/placeholder/)
169
+ expect(
170
+ () => loadEnvironmentConfig({
171
+ SOCKETDUCT_AUTH_TOKENS: "tensorbuzz-edge:paste-generated-token-here",
172
+ SOCKETDUCT_TARGET_ALLOWLIST: "127.0.0.1:443"
173
+ })
174
+ ).toThrow(/placeholder/)
208
175
  })
209
176
 
210
- assert.equal(config.relayTls, undefined)
211
- })
177
+ it("loadEnvironmentConfig parses TLS cert/key as an atomic pair", () => {
178
+ const config = loadEnvironmentConfig({
179
+ SOCKETDUCT_TLS_CERT_FILE: "/certs/relay.crt",
180
+ SOCKETDUCT_TLS_KEY_FILE: "/certs/relay.key"
181
+ })
212
182
 
213
- test("loadEnvironmentConfig rejects TLS cert without key", () => {
214
- assert.throws(
215
- () => loadEnvironmentConfig({SOCKETDUCT_TLS_CERT_FILE: "/certs/relay.crt"}),
216
- /atomic pair/
217
- )
218
- assert.throws(
219
- () => loadEnvironmentConfig({SOCKETDUCT_TLS_KEY_FILE: "/certs/relay.key"}),
220
- /atomic pair/
221
- )
222
- })
183
+ expect(config.relayTls).toEqual({
184
+ certFile: "/certs/relay.crt",
185
+ keyFile: "/certs/relay.key"
186
+ })
187
+ })
223
188
 
224
- test("loadEnvironmentConfig trims whitespace from TLS file paths", () => {
225
- const config = loadEnvironmentConfig({
226
- SOCKETDUCT_TLS_CERT_FILE: " /certs/relay.crt ",
227
- SOCKETDUCT_TLS_KEY_FILE: " /certs/relay.key "
189
+ it("loadEnvironmentConfig parses optional client CA for mTLS", () => {
190
+ const config = loadEnvironmentConfig({
191
+ SOCKETDUCT_TLS_CERT_FILE: "/certs/relay.crt",
192
+ SOCKETDUCT_TLS_KEY_FILE: "/certs/relay.key",
193
+ SOCKETDUCT_TLS_CLIENT_CA_FILE: "/certs/client-ca.crt"
194
+ })
195
+
196
+ expect(config.relayTls).toEqual({
197
+ certFile: "/certs/relay.crt",
198
+ keyFile: "/certs/relay.key",
199
+ clientCaFile: "/certs/client-ca.crt"
200
+ })
228
201
  })
229
202
 
230
- assert.deepEqual(config.relayTls, {
231
- certFile: "/certs/relay.crt",
232
- keyFile: "/certs/relay.key"
203
+ it("loadEnvironmentConfig returns undefined relayTls when no TLS vars are set", () => {
204
+ const config = loadEnvironmentConfig({
205
+ SOCKETDUCT_AUTH_TOKENS: "edge:token",
206
+ SOCKETDUCT_TARGET_ALLOWLIST: "127.0.0.1:443"
207
+ })
208
+
209
+ expect(config.relayTls).toBeUndefined()
233
210
  })
234
- })
235
211
 
236
- test("loadEnvironmentConfig parses SOCKETDUCT_ALLOW_INSECURE_PUBLIC valid values", () => {
237
- assert.equal(loadEnvironmentConfig({SOCKETDUCT_ALLOW_INSECURE_PUBLIC: "1"}).allowInsecurePublic, true)
238
- assert.equal(loadEnvironmentConfig({SOCKETDUCT_ALLOW_INSECURE_PUBLIC: "true"}).allowInsecurePublic, true)
239
- assert.equal(loadEnvironmentConfig({SOCKETDUCT_ALLOW_INSECURE_PUBLIC: " true "}).allowInsecurePublic, true)
240
- assert.equal(loadEnvironmentConfig({SOCKETDUCT_ALLOW_INSECURE_PUBLIC: ""}).allowInsecurePublic, false)
241
- assert.equal(loadEnvironmentConfig({}).allowInsecurePublic, false)
242
- })
212
+ it("loadEnvironmentConfig rejects TLS cert without key", () => {
213
+ expect(
214
+ () => loadEnvironmentConfig({SOCKETDUCT_TLS_CERT_FILE: "/certs/relay.crt"})
215
+ ).toThrow(/atomic pair/)
216
+ expect(
217
+ () => loadEnvironmentConfig({SOCKETDUCT_TLS_KEY_FILE: "/certs/relay.key"})
218
+ ).toThrow(/atomic pair/)
219
+ })
243
220
 
244
- test("loadEnvironmentConfig rejects invalid SOCKETDUCT_ALLOW_INSECURE_PUBLIC values", () => {
245
- assert.throws(
246
- () => loadEnvironmentConfig({SOCKETDUCT_ALLOW_INSECURE_PUBLIC: "yes"}),
247
- /SOCKETDUCT_ALLOW_INSECURE_PUBLIC/
248
- )
249
- assert.throws(
250
- () => loadEnvironmentConfig({SOCKETDUCT_ALLOW_INSECURE_PUBLIC: "0"}),
251
- /SOCKETDUCT_ALLOW_INSECURE_PUBLIC/
252
- )
253
- assert.throws(
254
- () => loadEnvironmentConfig({SOCKETDUCT_ALLOW_INSECURE_PUBLIC: "false"}),
255
- /SOCKETDUCT_ALLOW_INSECURE_PUBLIC/
256
- )
257
- })
221
+ it("loadEnvironmentConfig trims whitespace from TLS file paths", () => {
222
+ const config = loadEnvironmentConfig({
223
+ SOCKETDUCT_TLS_CERT_FILE: " /certs/relay.crt ",
224
+ SOCKETDUCT_TLS_KEY_FILE: " /certs/relay.key "
225
+ })
258
226
 
259
- test("loadEnvironmentConfig rejects client CA without cert/key pair", () => {
260
- assert.throws(
261
- () => loadEnvironmentConfig({SOCKETDUCT_TLS_CLIENT_CA_FILE: "/certs/client-ca.crt"}),
262
- /SOCKETDUCT_TLS_CLIENT_CA_FILE/
263
- )
264
- assert.throws(
265
- () => loadEnvironmentConfig({
266
- SOCKETDUCT_TLS_CLIENT_CA_FILE: "/certs/client-ca.crt",
267
- SOCKETDUCT_TLS_CERT_FILE: "/certs/relay.crt"
268
- }),
269
- /SOCKETDUCT_TLS_CLIENT_CA_FILE/
270
- )
271
- assert.throws(
272
- () => loadEnvironmentConfig({
273
- SOCKETDUCT_TLS_CLIENT_CA_FILE: "/certs/client-ca.crt",
274
- SOCKETDUCT_TLS_KEY_FILE: "/certs/relay.key"
275
- }),
276
- /SOCKETDUCT_TLS_CLIENT_CA_FILE/
277
- )
278
- })
227
+ expect(config.relayTls).toEqual({
228
+ certFile: "/certs/relay.crt",
229
+ keyFile: "/certs/relay.key"
230
+ })
231
+ })
279
232
 
280
- test("isLoopbackHost identifies loopback addresses", () => {
281
- assert.equal(isLoopbackHost("127.0.0.1"), true)
282
- assert.equal(isLoopbackHost("127.0.0.2"), true)
283
- assert.equal(isLoopbackHost("127.255.255.255"), true)
284
- assert.equal(isLoopbackHost("localhost"), true)
285
- assert.equal(isLoopbackHost("::1"), true)
286
- assert.equal(isLoopbackHost("[::1]"), true)
287
- })
233
+ it("loadEnvironmentConfig parses SOCKETDUCT_ALLOW_INSECURE_PUBLIC valid values", () => {
234
+ expect(loadEnvironmentConfig({SOCKETDUCT_ALLOW_INSECURE_PUBLIC: "1"}).allowInsecurePublic).toBe(true)
235
+ expect(loadEnvironmentConfig({SOCKETDUCT_ALLOW_INSECURE_PUBLIC: "true"}).allowInsecurePublic).toBe(true)
236
+ expect(loadEnvironmentConfig({SOCKETDUCT_ALLOW_INSECURE_PUBLIC: " true "}).allowInsecurePublic).toBe(true)
237
+ expect(loadEnvironmentConfig({SOCKETDUCT_ALLOW_INSECURE_PUBLIC: ""}).allowInsecurePublic).toBe(false)
238
+ expect(loadEnvironmentConfig({}).allowInsecurePublic).toBe(false)
239
+ })
288
240
 
289
- test("isLoopbackHost rejects non-loopback addresses", () => {
290
- assert.equal(isLoopbackHost("0.0.0.0"), false)
291
- assert.equal(isLoopbackHost("192.168.1.1"), false)
292
- assert.equal(isLoopbackHost("10.0.0.1"), false)
293
- assert.equal(isLoopbackHost("172.16.0.1"), false)
294
- assert.equal(isLoopbackHost("::ffff:127.0.0.1"), false)
295
- })
241
+ it("loadEnvironmentConfig rejects invalid SOCKETDUCT_ALLOW_INSECURE_PUBLIC values", () => {
242
+ expect(
243
+ () => loadEnvironmentConfig({SOCKETDUCT_ALLOW_INSECURE_PUBLIC: "yes"})
244
+ ).toThrow(/SOCKETDUCT_ALLOW_INSECURE_PUBLIC/)
245
+ expect(
246
+ () => loadEnvironmentConfig({SOCKETDUCT_ALLOW_INSECURE_PUBLIC: "0"})
247
+ ).toThrow(/SOCKETDUCT_ALLOW_INSECURE_PUBLIC/)
248
+ expect(
249
+ () => loadEnvironmentConfig({SOCKETDUCT_ALLOW_INSECURE_PUBLIC: "false"})
250
+ ).toThrow(/SOCKETDUCT_ALLOW_INSECURE_PUBLIC/)
251
+ })
296
252
 
297
- test("loadEnvironmentConfig parses reverse hub identity bindings, exact routes, and route-class routes", () => {
298
- const config = loadEnvironmentConfig({
299
- SOCKETDUCT_MODE: "reverse-hub",
300
- SOCKETDUCT_AUTH_TOKENS: "home-o2:gateway-token,tensorbuzz:client-token",
301
- SOCKETDUCT_REVERSE_GATEWAY_BINDINGS: "o2:home-o2,zyxel:home-zyxel",
302
- SOCKETDUCT_REVERSE_ROUTES: "tensorbuzz:o2:docker-server:2375,tensorbuzz:zyxel:docker-server:2375",
303
- SOCKETDUCT_REVERSE_ROUTE_CLASS_ROUTES: "tensorbuzz:starlink:o2:docker-a:2375,tensorbuzz:starlink:zyxel:docker-b:2375",
304
- SOCKETDUCT_TLS_CERT_FILE: "/certs/hub.crt",
305
- SOCKETDUCT_TLS_KEY_FILE: "/certs/hub.key"
306
- })
307
- assert.equal(config.mode, "reverse-hub")
308
- assert.deepEqual(config.reverseGatewayBindings, [{gatewayId: "o2", username: "home-o2"}, {gatewayId: "zyxel", username: "home-zyxel"}])
309
- assert.deepEqual(config.reverseRoutes, [
310
- {username: "tensorbuzz", gatewayId: "o2", host: "docker-server", port: 2375},
311
- {username: "tensorbuzz", gatewayId: "zyxel", host: "docker-server", port: 2375}
312
- ])
313
- assert.deepEqual(config.reverseRouteClassRoutes, [
314
- {username: "tensorbuzz", routeClass: "starlink", gatewayId: "o2", host: "docker-a", port: 2375},
315
- {username: "tensorbuzz", routeClass: "starlink", gatewayId: "zyxel", host: "docker-b", port: 2375}
316
- ])
317
- })
253
+ it("loadEnvironmentConfig rejects client CA without cert/key pair", () => {
254
+ expect(
255
+ () => loadEnvironmentConfig({SOCKETDUCT_TLS_CLIENT_CA_FILE: "/certs/client-ca.crt"})
256
+ ).toThrow(/SOCKETDUCT_TLS_CLIENT_CA_FILE/)
257
+ expect(
258
+ () => loadEnvironmentConfig({
259
+ SOCKETDUCT_TLS_CLIENT_CA_FILE: "/certs/client-ca.crt",
260
+ SOCKETDUCT_TLS_CERT_FILE: "/certs/relay.crt"
261
+ })
262
+ ).toThrow(/SOCKETDUCT_TLS_CLIENT_CA_FILE/)
263
+ expect(
264
+ () => loadEnvironmentConfig({
265
+ SOCKETDUCT_TLS_CLIENT_CA_FILE: "/certs/client-ca.crt",
266
+ SOCKETDUCT_TLS_KEY_FILE: "/certs/relay.key"
267
+ })
268
+ ).toThrow(/SOCKETDUCT_TLS_CLIENT_CA_FILE/)
269
+ })
318
270
 
319
- test("loadEnvironmentConfig parses outbound reverse gateway verified TLS settings", () => {
320
- const config = loadEnvironmentConfig({
321
- SOCKETDUCT_MODE: "reverse-gateway",
322
- SOCKETDUCT_REVERSE_GATEWAY_ID: "starlink",
323
- SOCKETDUCT_REVERSE_CONNECTION_ID: "home_starlink_01",
324
- SOCKETDUCT_REVERSE_HUB_HOST: "server3.example.test",
325
- SOCKETDUCT_REVERSE_HUB_PORT: "443",
326
- SOCKETDUCT_REVERSE_TOKEN: "gateway-token",
327
- SOCKETDUCT_REVERSE_HUB_CA_FILE: "/certs/ca.crt",
328
- SOCKETDUCT_REVERSE_HUB_CLIENT_CERT_FILE: "/certs/client.crt",
329
- SOCKETDUCT_REVERSE_HUB_CLIENT_KEY_FILE: "/certs/client.key",
330
- SOCKETDUCT_REVERSE_HUB_SERVERNAME: "server3.example.test",
331
- SOCKETDUCT_TARGET_ALLOWLIST: "docker-server:2375"
332
- })
333
- assert.equal(config.mode, "reverse-gateway")
334
- assert.equal(config.reverseGatewayId, "starlink")
335
- assert.equal(config.reverseConnectionId, "home_starlink_01")
336
- assert.deepEqual(config.reverseHub, {host: "server3.example.test", port: 443, token: "gateway-token"})
337
- assert.deepEqual(config.reverseHubTls, {caFile: "/certs/ca.crt", certFile: "/certs/client.crt", keyFile: "/certs/client.key", servername: "server3.example.test"})
338
- })
271
+ it("isLoopbackHost identifies loopback addresses", () => {
272
+ expect(isLoopbackHost("127.0.0.1")).toBe(true)
273
+ expect(isLoopbackHost("127.0.0.2")).toBe(true)
274
+ expect(isLoopbackHost("127.255.255.255")).toBe(true)
275
+ expect(isLoopbackHost("localhost")).toBe(true)
276
+ expect(isLoopbackHost("::1")).toBe(true)
277
+ expect(isLoopbackHost("[::1]")).toBe(true)
278
+ })
339
279
 
340
- test("loadEnvironmentConfig parses the reverse-gateway spool directory", () => {
341
- const config = loadEnvironmentConfig({
342
- SOCKETDUCT_MODE: "reverse-gateway",
343
- SOCKETDUCT_REVERSE_GATEWAY_ID: "starlink",
344
- SOCKETDUCT_REVERSE_CONNECTION_ID: "home_starlink_01",
345
- SOCKETDUCT_REVERSE_HUB_HOST: "server3.example.test",
346
- SOCKETDUCT_REVERSE_TOKEN: "gateway-token",
347
- SOCKETDUCT_TARGET_ALLOWLIST: "docker-server:2375",
348
- SOCKETDUCT_SPOOL_DIRECTORY: " /spool/reverse-gateway "
349
- })
350
- assert.equal(config.spoolDirectory, "/spool/reverse-gateway")
351
- assert.equal("spoolDirectory" in loadEnvironmentConfig({
352
- SOCKETDUCT_MODE: "reverse-gateway",
353
- SOCKETDUCT_REVERSE_GATEWAY_ID: "starlink",
354
- SOCKETDUCT_REVERSE_CONNECTION_ID: "home_starlink_01",
355
- SOCKETDUCT_REVERSE_HUB_HOST: "server3.example.test",
356
- SOCKETDUCT_REVERSE_TOKEN: "gateway-token",
357
- SOCKETDUCT_TARGET_ALLOWLIST: "docker-server:2375",
358
- SOCKETDUCT_SPOOL_DIRECTORY: " "
359
- }), false)
360
- assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "relay", SOCKETDUCT_SPOOL_DIRECTORY: "/spool"}), /reverse-gateway-only/)
361
- assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "reverse-hub", SOCKETDUCT_SPOOL_DIRECTORY: "/spool"}), /reverse-gateway-only/)
362
- assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_SPOOL_DIRECTORY: "/spool"}), /reverse-gateway-only/)
363
- })
280
+ it("isLoopbackHost rejects non-loopback addresses", () => {
281
+ expect(isLoopbackHost("0.0.0.0")).toBe(false)
282
+ expect(isLoopbackHost("192.168.1.1")).toBe(false)
283
+ expect(isLoopbackHost("10.0.0.1")).toBe(false)
284
+ expect(isLoopbackHost("172.16.0.1")).toBe(false)
285
+ expect(isLoopbackHost("::ffff:127.0.0.1")).toBe(false)
286
+ })
364
287
 
365
- test("loadEnvironmentConfig rejects malformed reverse mode policy and credentials", () => {
366
- assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "unknown"}), /Unsupported SOCKETDUCT_MODE/)
367
- assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "reverse-hub", SOCKETDUCT_REVERSE_GATEWAY_BINDINGS: "missing-user"}), /gatewayId:username/)
368
- assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "reverse-hub", SOCKETDUCT_REVERSE_ROUTES: "too:few:fields"}), /username:gatewayId:host:port/)
369
- assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "reverse-hub", SOCKETDUCT_REVERSE_ROUTE_CLASS_ROUTES: "too:few:fields"}), /username:routeClass:gatewayId:host:port/)
370
- assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "reverse-gateway", SOCKETDUCT_REVERSE_TOKEN: "replace-me"}), /placeholder/)
371
- assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "reverse-gateway", SOCKETDUCT_REVERSE_HUB_CLIENT_CERT_FILE: "/certs/client.crt"}), /must both be set/)
372
- })
288
+ it("loadEnvironmentConfig parses reverse hub identity bindings, exact routes, and route-class routes", () => {
289
+ const config = loadEnvironmentConfig({
290
+ SOCKETDUCT_MODE: "reverse-hub",
291
+ SOCKETDUCT_AUTH_TOKENS: "home-o2:gateway-token,tensorbuzz:client-token",
292
+ SOCKETDUCT_REVERSE_GATEWAY_BINDINGS: "o2:home-o2,zyxel:home-zyxel",
293
+ SOCKETDUCT_REVERSE_ROUTES: "tensorbuzz:o2:docker-server:2375,tensorbuzz:zyxel:docker-server:2375",
294
+ SOCKETDUCT_REVERSE_ROUTE_CLASS_ROUTES: "tensorbuzz:starlink:o2:docker-a:2375,tensorbuzz:starlink:zyxel:docker-b:2375",
295
+ SOCKETDUCT_TLS_CERT_FILE: "/certs/hub.crt",
296
+ SOCKETDUCT_TLS_KEY_FILE: "/certs/hub.key"
297
+ })
298
+ expect(config.mode).toBe("reverse-hub")
299
+ expect(config.reverseGatewayBindings).toEqual([{gatewayId: "o2", username: "home-o2"}, {gatewayId: "zyxel", username: "home-zyxel"}])
300
+ expect(config.reverseRoutes).toEqual([
301
+ {username: "tensorbuzz", gatewayId: "o2", host: "docker-server", port: 2375},
302
+ {username: "tensorbuzz", gatewayId: "zyxel", host: "docker-server", port: 2375}
303
+ ])
304
+ expect(config.reverseRouteClassRoutes).toEqual([
305
+ {username: "tensorbuzz", routeClass: "starlink", gatewayId: "o2", host: "docker-a", port: 2375},
306
+ {username: "tensorbuzz", routeClass: "starlink", gatewayId: "zyxel", host: "docker-b", port: 2375}
307
+ ])
308
+ })
373
309
 
374
- test("isLoopbackHost rejects 127-prefixed hostnames", () => {
375
- assert.equal(isLoopbackHost("127.0.0.1.nip.io"), false)
376
- assert.equal(isLoopbackHost("127localhost"), false)
377
- assert.equal(isLoopbackHost("127anything"), false)
378
- })
310
+ it("loadEnvironmentConfig parses outbound reverse gateway verified TLS settings", () => {
311
+ const config = loadEnvironmentConfig({
312
+ SOCKETDUCT_MODE: "reverse-gateway",
313
+ SOCKETDUCT_REVERSE_GATEWAY_ID: "starlink",
314
+ SOCKETDUCT_REVERSE_CONNECTION_ID: "home_starlink_01",
315
+ SOCKETDUCT_REVERSE_HUB_HOST: "server3.example.test",
316
+ SOCKETDUCT_REVERSE_HUB_PORT: "443",
317
+ SOCKETDUCT_REVERSE_TOKEN: "gateway-token",
318
+ SOCKETDUCT_REVERSE_HUB_CA_FILE: "/certs/ca.crt",
319
+ SOCKETDUCT_REVERSE_HUB_CLIENT_CERT_FILE: "/certs/client.crt",
320
+ SOCKETDUCT_REVERSE_HUB_CLIENT_KEY_FILE: "/certs/client.key",
321
+ SOCKETDUCT_REVERSE_HUB_SERVERNAME: "server3.example.test",
322
+ SOCKETDUCT_TARGET_ALLOWLIST: "docker-server:2375"
323
+ })
324
+ expect(config.mode).toBe("reverse-gateway")
325
+ expect(config.reverseGatewayId).toBe("starlink")
326
+ expect(config.reverseConnectionId).toBe("home_starlink_01")
327
+ expect(config.reverseHub).toEqual({host: "server3.example.test", port: 443, token: "gateway-token"})
328
+ expect(config.reverseHubTls).toEqual({caFile: "/certs/ca.crt", certFile: "/certs/client.crt", keyFile: "/certs/client.key", servername: "server3.example.test"})
329
+ })
379
330
 
380
- test("isLoopbackHost rejects invalid loopback-like IPs", () => {
381
- assert.equal(isLoopbackHost("127.0.0."), false)
382
- assert.equal(isLoopbackHost("127.0.0"), false)
383
- assert.equal(isLoopbackHost("127."), false)
384
- assert.equal(isLoopbackHost("127.0.0.0.1"), false)
385
- })
331
+ it("loadEnvironmentConfig parses the reverse-gateway spool directory", () => {
332
+ const config = loadEnvironmentConfig({
333
+ SOCKETDUCT_MODE: "reverse-gateway",
334
+ SOCKETDUCT_REVERSE_GATEWAY_ID: "starlink",
335
+ SOCKETDUCT_REVERSE_CONNECTION_ID: "home_starlink_01",
336
+ SOCKETDUCT_REVERSE_HUB_HOST: "server3.example.test",
337
+ SOCKETDUCT_REVERSE_TOKEN: "gateway-token",
338
+ SOCKETDUCT_TARGET_ALLOWLIST: "docker-server:2375",
339
+ SOCKETDUCT_SPOOL_DIRECTORY: " /spool/reverse-gateway "
340
+ })
341
+ expect(config.spoolDirectory).toBe("/spool/reverse-gateway")
342
+ expect("spoolDirectory" in loadEnvironmentConfig({
343
+ SOCKETDUCT_MODE: "reverse-gateway",
344
+ SOCKETDUCT_REVERSE_GATEWAY_ID: "starlink",
345
+ SOCKETDUCT_REVERSE_CONNECTION_ID: "home_starlink_01",
346
+ SOCKETDUCT_REVERSE_HUB_HOST: "server3.example.test",
347
+ SOCKETDUCT_REVERSE_TOKEN: "gateway-token",
348
+ SOCKETDUCT_TARGET_ALLOWLIST: "docker-server:2375",
349
+ SOCKETDUCT_SPOOL_DIRECTORY: " "
350
+ })).toBe(false)
351
+ expect(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "relay", SOCKETDUCT_SPOOL_DIRECTORY: "/spool"})).toThrow(/reverse-gateway-only/)
352
+ expect(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "reverse-hub", SOCKETDUCT_SPOOL_DIRECTORY: "/spool"})).toThrow(/reverse-gateway-only/)
353
+ expect(() => loadEnvironmentConfig({SOCKETDUCT_SPOOL_DIRECTORY: "/spool"})).toThrow(/reverse-gateway-only/)
354
+ })
386
355
 
387
- test("isLoopbackHost rejects loopback IPs with invalid octets", () => {
388
- assert.equal(isLoopbackHost("127.999.999.999"), false)
389
- assert.equal(isLoopbackHost("127.256.0.1"), false)
390
- assert.equal(isLoopbackHost("127.-1.0.1"), false)
391
- })
356
+ it("loadEnvironmentConfig rejects malformed reverse mode policy and credentials", () => {
357
+ expect(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "unknown"})).toThrow(/Unsupported SOCKETDUCT_MODE/)
358
+ expect(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "reverse-hub", SOCKETDUCT_REVERSE_GATEWAY_BINDINGS: "missing-user"})).toThrow(/gatewayId:username/)
359
+ expect(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "reverse-hub", SOCKETDUCT_REVERSE_ROUTES: "too:few:fields"})).toThrow(/username:gatewayId:host:port/)
360
+ expect(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "reverse-hub", SOCKETDUCT_REVERSE_ROUTE_CLASS_ROUTES: "too:few:fields"})).toThrow(/username:routeClass:gatewayId:host:port/)
361
+ expect(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "reverse-gateway", SOCKETDUCT_REVERSE_TOKEN: "replace-me"})).toThrow(/placeholder/)
362
+ expect(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "reverse-gateway", SOCKETDUCT_REVERSE_HUB_CLIENT_CERT_FILE: "/certs/client.crt"})).toThrow(/must both be set/)
363
+ })
392
364
 
393
- test("loadEnvironmentConfig parses SOCKETDUCT_MAX_TARGET_BUFFER_BYTES and SOCKETDUCT_MAX_TOTAL_BUFFER_BYTES", () => {
394
- const config = loadEnvironmentConfig({
395
- SOCKETDUCT_MODE: "relay",
396
- SOCKETDUCT_MAX_TARGET_BUFFER_BYTES: "65536",
397
- SOCKETDUCT_MAX_TOTAL_BUFFER_BYTES: "262144",
398
- SOCKETDUCT_AUTH_TOKENS: "edge:token-one",
399
- SOCKETDUCT_TARGET_ALLOWLIST: "127.0.0.1:8080"
365
+ it("isLoopbackHost rejects 127-prefixed hostnames", () => {
366
+ expect(isLoopbackHost("127.0.0.1.nip.io")).toBe(false)
367
+ expect(isLoopbackHost("127localhost")).toBe(false)
368
+ expect(isLoopbackHost("127anything")).toBe(false)
400
369
  })
401
370
 
402
- assert.equal(config.maxTargetBufferBytes, 65536)
403
- assert.equal(config.maxTotalBufferBytes, 262144)
404
- })
371
+ it("isLoopbackHost rejects invalid loopback-like IPs", () => {
372
+ expect(isLoopbackHost("127.0.0.")).toBe(false)
373
+ expect(isLoopbackHost("127.0.0")).toBe(false)
374
+ expect(isLoopbackHost("127.")).toBe(false)
375
+ expect(isLoopbackHost("127.0.0.0.1")).toBe(false)
376
+ })
405
377
 
406
- test("loadEnvironmentConfig parses SOCKETDUCT_COMPRESSION and its limits", () => {
407
- const config = loadEnvironmentConfig({
408
- SOCKETDUCT_MODE: "relay",
409
- SOCKETDUCT_COMPRESSION: "off",
410
- SOCKETDUCT_MIN_COMPRESS_BYTES: "1024",
411
- SOCKETDUCT_MAX_DECOMPRESSED_BYTES: "65536",
412
- SOCKETDUCT_MAX_EXPANSION_RATIO: "50",
413
- SOCKETDUCT_AUTH_TOKENS: "edge:token-one",
414
- SOCKETDUCT_TARGET_ALLOWLIST: "127.0.0.1:8080"
415
- })
416
-
417
- assert.equal(config.compression, "off")
418
- assert.equal(config.minCompressBytes, 1024)
419
- assert.equal(config.maxDecompressedBytes, 65536)
420
- assert.equal(config.maxExpansionRatio, 50)
421
- })
378
+ it("isLoopbackHost rejects loopback IPs with invalid octets", () => {
379
+ expect(isLoopbackHost("127.999.999.999")).toBe(false)
380
+ expect(isLoopbackHost("127.256.0.1")).toBe(false)
381
+ expect(isLoopbackHost("127.-1.0.1")).toBe(false)
382
+ })
422
383
 
423
- test("loadEnvironmentConfig rejects an unknown SOCKETDUCT_COMPRESSION instead of silently defaulting", () => {
424
- assert.throws(
425
- () => loadEnvironmentConfig({
384
+ it("loadEnvironmentConfig parses SOCKETDUCT_MAX_TARGET_BUFFER_BYTES and SOCKETDUCT_MAX_TOTAL_BUFFER_BYTES", () => {
385
+ const config = loadEnvironmentConfig({
426
386
  SOCKETDUCT_MODE: "relay",
427
- SOCKETDUCT_COMPRESSION: "zstd",
387
+ SOCKETDUCT_MAX_TARGET_BUFFER_BYTES: "65536",
388
+ SOCKETDUCT_MAX_TOTAL_BUFFER_BYTES: "262144",
428
389
  SOCKETDUCT_AUTH_TOKENS: "edge:token-one",
429
390
  SOCKETDUCT_TARGET_ALLOWLIST: "127.0.0.1:8080"
430
- }),
431
- /Unsupported SOCKETDUCT_COMPRESSION/
432
- )
433
- })
391
+ })
434
392
 
435
- test("loadEnvironmentConfig defaults buffer limits when env vars are absent", () => {
436
- const config = loadEnvironmentConfig({
437
- SOCKETDUCT_MODE: "relay",
438
- SOCKETDUCT_AUTH_TOKENS: "edge:token-one",
439
- SOCKETDUCT_TARGET_ALLOWLIST: "127.0.0.1:8080"
393
+ expect(config.maxTargetBufferBytes).toBe(65536)
394
+ expect(config.maxTotalBufferBytes).toBe(262144)
440
395
  })
441
396
 
442
- assert.equal(config.maxTargetBufferBytes, 1_048_576)
443
- assert.equal(config.maxTotalBufferBytes, 16_777_216)
444
- })
445
-
446
- test("loadEnvironmentConfig rejects non-positive buffer limit values", () => {
447
- assert.throws(
448
- () => loadEnvironmentConfig({
449
- SOCKETDUCT_MAX_TARGET_BUFFER_BYTES: "0",
397
+ it("loadEnvironmentConfig parses SOCKETDUCT_COMPRESSION and its limits", () => {
398
+ const config = loadEnvironmentConfig({
399
+ SOCKETDUCT_MODE: "relay",
400
+ SOCKETDUCT_COMPRESSION: "off",
401
+ SOCKETDUCT_MIN_COMPRESS_BYTES: "1024",
402
+ SOCKETDUCT_MAX_DECOMPRESSED_BYTES: "65536",
403
+ SOCKETDUCT_MAX_EXPANSION_RATIO: "50",
450
404
  SOCKETDUCT_AUTH_TOKENS: "edge:token-one",
451
405
  SOCKETDUCT_TARGET_ALLOWLIST: "127.0.0.1:8080"
452
- }),
453
- /positive integer/
454
- )
406
+ })
455
407
 
456
- assert.throws(
457
- () => loadEnvironmentConfig({
458
- SOCKETDUCT_MAX_TARGET_BUFFER_BYTES: "-1",
459
- SOCKETDUCT_AUTH_TOKENS: "edge:token-one",
460
- SOCKETDUCT_TARGET_ALLOWLIST: "127.0.0.1:8080"
461
- }),
462
- /positive integer/
463
- )
408
+ expect(config.compression).toBe("off")
409
+ expect(config.minCompressBytes).toBe(1024)
410
+ expect(config.maxDecompressedBytes).toBe(65536)
411
+ expect(config.maxExpansionRatio).toBe(50)
412
+ })
464
413
 
465
- assert.throws(
466
- () => loadEnvironmentConfig({
467
- SOCKETDUCT_MAX_TOTAL_BUFFER_BYTES: "not-a-number",
414
+ it("loadEnvironmentConfig rejects an unknown SOCKETDUCT_COMPRESSION instead of silently defaulting", () => {
415
+ expect(
416
+ () => loadEnvironmentConfig({
417
+ SOCKETDUCT_MODE: "relay",
418
+ SOCKETDUCT_COMPRESSION: "zstd",
419
+ SOCKETDUCT_AUTH_TOKENS: "edge:token-one",
420
+ SOCKETDUCT_TARGET_ALLOWLIST: "127.0.0.1:8080"
421
+ })
422
+ ).toThrow(/Unsupported SOCKETDUCT_COMPRESSION/)
423
+ })
424
+
425
+ it("loadEnvironmentConfig defaults buffer limits when env vars are absent", () => {
426
+ const config = loadEnvironmentConfig({
427
+ SOCKETDUCT_MODE: "relay",
468
428
  SOCKETDUCT_AUTH_TOKENS: "edge:token-one",
469
429
  SOCKETDUCT_TARGET_ALLOWLIST: "127.0.0.1:8080"
470
- }),
471
- /positive integer/
472
- )
473
- })
430
+ })
474
431
 
475
- test("loadEnvironmentConfig parses handshakeTimeoutMs and maxInputBytesPerConnection", () => {
476
- const config = loadEnvironmentConfig({
477
- SOCKETDUCT_MODE: "relay",
478
- SOCKETDUCT_HANDSHAKE_TIMEOUT_MS: "5000",
479
- SOCKETDUCT_MAX_INPUT_BYTES_PER_CONNECTION: "32768",
480
- SOCKETDUCT_AUTH_TOKENS: "edge:token-one",
481
- SOCKETDUCT_TARGET_ALLOWLIST: "127.0.0.1:8080"
432
+ expect(config.maxTargetBufferBytes).toBe(1_048_576)
433
+ expect(config.maxTotalBufferBytes).toBe(16_777_216)
482
434
  })
483
435
 
484
- assert.equal(config.handshakeTimeoutMs, 5000)
485
- assert.equal(config.maxInputBytesPerConnection, 32768)
486
- })
487
-
488
- test("loadEnvironmentConfig parses and validates initial target retry delays", () => {
489
- const config = loadEnvironmentConfig({
490
- SOCKETDUCT_INITIAL_TARGET_RETRY_DELAY_MS: "25",
491
- SOCKETDUCT_INITIAL_TARGET_RETRY_MAX_DELAY_MS: "200"
436
+ it("loadEnvironmentConfig rejects non-positive buffer limit values", () => {
437
+ expect(
438
+ () => loadEnvironmentConfig({
439
+ SOCKETDUCT_MAX_TARGET_BUFFER_BYTES: "0",
440
+ SOCKETDUCT_AUTH_TOKENS: "edge:token-one",
441
+ SOCKETDUCT_TARGET_ALLOWLIST: "127.0.0.1:8080"
442
+ })
443
+ ).toThrow(/positive integer/)
444
+
445
+ expect(
446
+ () => loadEnvironmentConfig({
447
+ SOCKETDUCT_MAX_TARGET_BUFFER_BYTES: "-1",
448
+ SOCKETDUCT_AUTH_TOKENS: "edge:token-one",
449
+ SOCKETDUCT_TARGET_ALLOWLIST: "127.0.0.1:8080"
450
+ })
451
+ ).toThrow(/positive integer/)
452
+
453
+ expect(
454
+ () => loadEnvironmentConfig({
455
+ SOCKETDUCT_MAX_TOTAL_BUFFER_BYTES: "not-a-number",
456
+ SOCKETDUCT_AUTH_TOKENS: "edge:token-one",
457
+ SOCKETDUCT_TARGET_ALLOWLIST: "127.0.0.1:8080"
458
+ })
459
+ ).toThrow(/positive integer/)
492
460
  })
493
461
 
494
- assert.equal(config.initialTargetRetryDelayMs, 25)
495
- assert.equal(config.initialTargetRetryMaxDelayMs, 200)
496
- assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_INITIAL_TARGET_RETRY_DELAY_MS: "0"}), /positive integer/)
497
- assert.throws(() => loadEnvironmentConfig({
498
- SOCKETDUCT_INITIAL_TARGET_RETRY_DELAY_MS: "500",
499
- SOCKETDUCT_INITIAL_TARGET_RETRY_MAX_DELAY_MS: "250"
500
- }), /greater than or equal/i)
501
- })
462
+ it("loadEnvironmentConfig parses handshakeTimeoutMs and maxInputBytesPerConnection", () => {
463
+ const config = loadEnvironmentConfig({
464
+ SOCKETDUCT_MODE: "relay",
465
+ SOCKETDUCT_HANDSHAKE_TIMEOUT_MS: "5000",
466
+ SOCKETDUCT_MAX_INPUT_BYTES_PER_CONNECTION: "32768",
467
+ SOCKETDUCT_AUTH_TOKENS: "edge:token-one",
468
+ SOCKETDUCT_TARGET_ALLOWLIST: "127.0.0.1:8080"
469
+ })
502
470
 
503
- test("loadEnvironmentConfig parses and validates the initial target connect timeout", () => {
504
- assert.equal(loadEnvironmentConfig({SOCKETDUCT_INITIAL_TARGET_CONNECT_TIMEOUT_MS: "750"}).initialTargetConnectTimeoutMs, 750)
505
- assert.throws(
506
- () => loadEnvironmentConfig({SOCKETDUCT_INITIAL_TARGET_CONNECT_TIMEOUT_MS: "0"}),
507
- /SOCKETDUCT_INITIAL_TARGET_CONNECT_TIMEOUT_MS.*positive integer/
508
- )
509
- })
471
+ expect(config.handshakeTimeoutMs).toBe(5000)
472
+ expect(config.maxInputBytesPerConnection).toBe(32768)
473
+ })
510
474
 
511
- test("loadEnvironmentConfig defaults handshakeTimeoutMs and maxInputBytesPerConnection when env vars are absent", () => {
512
- const config = loadEnvironmentConfig({
513
- SOCKETDUCT_MODE: "relay",
514
- SOCKETDUCT_AUTH_TOKENS: "edge:token-one",
515
- SOCKETDUCT_TARGET_ALLOWLIST: "127.0.0.1:8080"
475
+ it("loadEnvironmentConfig parses and validates initial target retry delays", () => {
476
+ const config = loadEnvironmentConfig({
477
+ SOCKETDUCT_INITIAL_TARGET_RETRY_DELAY_MS: "25",
478
+ SOCKETDUCT_INITIAL_TARGET_RETRY_MAX_DELAY_MS: "200"
479
+ })
480
+
481
+ expect(config.initialTargetRetryDelayMs).toBe(25)
482
+ expect(config.initialTargetRetryMaxDelayMs).toBe(200)
483
+ expect(() => loadEnvironmentConfig({SOCKETDUCT_INITIAL_TARGET_RETRY_DELAY_MS: "0"})).toThrow(/positive integer/)
484
+ expect(() => loadEnvironmentConfig({
485
+ SOCKETDUCT_INITIAL_TARGET_RETRY_DELAY_MS: "500",
486
+ SOCKETDUCT_INITIAL_TARGET_RETRY_MAX_DELAY_MS: "250"
487
+ })).toThrow(/greater than or equal/i)
516
488
  })
517
489
 
518
- assert.equal(config.handshakeTimeoutMs, 10_000)
519
- assert.equal(config.maxInputBytesPerConnection, 65_536)
520
- })
490
+ it("loadEnvironmentConfig parses and validates the initial target connect timeout", () => {
491
+ expect(loadEnvironmentConfig({SOCKETDUCT_INITIAL_TARGET_CONNECT_TIMEOUT_MS: "750"}).initialTargetConnectTimeoutMs).toBe(750)
492
+ expect(
493
+ () => loadEnvironmentConfig({SOCKETDUCT_INITIAL_TARGET_CONNECT_TIMEOUT_MS: "0"})
494
+ ).toThrow(/SOCKETDUCT_INITIAL_TARGET_CONNECT_TIMEOUT_MS.*positive integer/)
495
+ })
521
496
 
522
- test("loadEnvironmentConfig rejects non-positive handshakeTimeoutMs and maxInputBytesPerConnection", () => {
523
- assert.throws(
524
- () => loadEnvironmentConfig({
525
- SOCKETDUCT_HANDSHAKE_TIMEOUT_MS: "0",
497
+ it("loadEnvironmentConfig defaults handshakeTimeoutMs and maxInputBytesPerConnection when env vars are absent", () => {
498
+ const config = loadEnvironmentConfig({
499
+ SOCKETDUCT_MODE: "relay",
526
500
  SOCKETDUCT_AUTH_TOKENS: "edge:token-one",
527
501
  SOCKETDUCT_TARGET_ALLOWLIST: "127.0.0.1:8080"
528
- }),
529
- /positive integer/
530
- )
502
+ })
531
503
 
532
- assert.throws(
533
- () => loadEnvironmentConfig({
534
- SOCKETDUCT_MAX_INPUT_BYTES_PER_CONNECTION: "-1",
535
- SOCKETDUCT_AUTH_TOKENS: "edge:token-one",
536
- SOCKETDUCT_TARGET_ALLOWLIST: "127.0.0.1:8080"
537
- }),
538
- /positive integer/
539
- )
540
- })
504
+ expect(config.handshakeTimeoutMs).toBe(10_000)
505
+ expect(config.maxInputBytesPerConnection).toBe(65_536)
506
+ })
507
+
508
+ it("loadEnvironmentConfig rejects non-positive handshakeTimeoutMs and maxInputBytesPerConnection", () => {
509
+ expect(
510
+ () => loadEnvironmentConfig({
511
+ SOCKETDUCT_HANDSHAKE_TIMEOUT_MS: "0",
512
+ SOCKETDUCT_AUTH_TOKENS: "edge:token-one",
513
+ SOCKETDUCT_TARGET_ALLOWLIST: "127.0.0.1:8080"
514
+ })
515
+ ).toThrow(/positive integer/)
516
+
517
+ expect(
518
+ () => loadEnvironmentConfig({
519
+ SOCKETDUCT_MAX_INPUT_BYTES_PER_CONNECTION: "-1",
520
+ SOCKETDUCT_AUTH_TOKENS: "edge:token-one",
521
+ SOCKETDUCT_TARGET_ALLOWLIST: "127.0.0.1:8080"
522
+ })
523
+ ).toThrow(/positive integer/)
524
+ })
541
525
 
542
- test("loadEnvironmentConfig mode validation", async (t) => {
543
- await t.test("defaults to summary when SOCKETDUCT_MODE is not set", () => {
526
+ it("defaults to summary when SOCKETDUCT_MODE is not set", () => {
544
527
  const config = loadEnvironmentConfig({})
545
- assert.equal(config.mode, "summary")
528
+ expect(config.mode).toBe("summary")
546
529
  })
547
530
 
548
- await t.test("returns relay when SOCKETDUCT_MODE is relay", () => {
531
+ it("returns relay when SOCKETDUCT_MODE is relay", () => {
549
532
  const config = loadEnvironmentConfig({SOCKETDUCT_MODE: "relay"})
550
- assert.equal(config.mode, "relay")
533
+ expect(config.mode).toBe("relay")
551
534
  })
552
535
 
553
- await t.test("returns summary when SOCKETDUCT_MODE is summary", () => {
536
+ it("returns summary when SOCKETDUCT_MODE is summary", () => {
554
537
  const config = loadEnvironmentConfig({SOCKETDUCT_MODE: "summary"})
555
- assert.equal(config.mode, "summary")
538
+ expect(config.mode).toBe("summary")
556
539
  })
557
540
 
558
- await t.test("throws on invalid SOCKETDUCT_MODE", () => {
559
- assert.throws(
560
- () => loadEnvironmentConfig({SOCKETDUCT_MODE: "relya"}),
561
- /Unsupported SOCKETDUCT_MODE/
562
- )
541
+ it("throws on invalid SOCKETDUCT_MODE", () => {
542
+ expect(
543
+ () => loadEnvironmentConfig({SOCKETDUCT_MODE: "relya"})
544
+ ).toThrow(/Unsupported SOCKETDUCT_MODE/)
563
545
  })
564
546
 
565
- await t.test("throws on arbitrary SOCKETDUCT_MODE value", () => {
566
- assert.throws(
567
- () => loadEnvironmentConfig({SOCKETDUCT_MODE: "garbage"}),
568
- /Unsupported SOCKETDUCT_MODE/
569
- )
547
+ it("throws on arbitrary SOCKETDUCT_MODE value", () => {
548
+ expect(
549
+ () => loadEnvironmentConfig({SOCKETDUCT_MODE: "garbage"})
550
+ ).toThrow(/Unsupported SOCKETDUCT_MODE/)
570
551
  })
571
552
 
572
- await t.test("mode validation is case-sensitive", () => {
573
- assert.throws(
574
- () => loadEnvironmentConfig({SOCKETDUCT_MODE: "RELAY"}),
575
- /Unsupported SOCKETDUCT_MODE/
576
- )
553
+ it("mode validation is case-sensitive", () => {
554
+ expect(
555
+ () => loadEnvironmentConfig({SOCKETDUCT_MODE: "RELAY"})
556
+ ).toThrow(/Unsupported SOCKETDUCT_MODE/)
577
557
  })
578
558
  })