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,434 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import {createConnection, createServer} from "node:net"
|
|
3
|
+
import {mkdtempSync, rmSync} from "node:fs"
|
|
4
|
+
import {tmpdir} from "node:os"
|
|
5
|
+
import {join} from "node:path"
|
|
6
|
+
import test from "node:test"
|
|
7
|
+
|
|
8
|
+
import {encodeFrame, FrameDecoder} from "../../../src/protocol/frame.js"
|
|
9
|
+
import {encodeV3Control, encodeV3Frame, V3FrameType} from "../../../src/protocol/v3-frame.js"
|
|
10
|
+
import {MultiplexedSocketductConnection} from "../../../src/client/multiplexed-connection.js"
|
|
11
|
+
import {startRelayRuntime} from "../../../src/server/relay-runtime.js"
|
|
12
|
+
import {generateTestCertificates} from "../../helpers/tls-certificates.js"
|
|
13
|
+
|
|
14
|
+
/** @returns {Promise<{host: string, port: number, close: () => Promise<void>}>} Started TCP target. */
|
|
15
|
+
async function startTargetServer() {
|
|
16
|
+
const server = createServer((socket) => {
|
|
17
|
+
socket.on("data", (chunk) => socket.write(chunk))
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
await new Promise((resolve, reject) => {
|
|
21
|
+
server.once("error", reject)
|
|
22
|
+
server.listen(0, "127.0.0.1", () => {
|
|
23
|
+
server.off("error", reject)
|
|
24
|
+
resolve(undefined)
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const address = server.address()
|
|
29
|
+
if (address === null || typeof address === "string") {
|
|
30
|
+
throw new Error("Target server did not bind to a TCP port")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
host: "127.0.0.1",
|
|
35
|
+
port: address.port,
|
|
36
|
+
close: async () => {
|
|
37
|
+
await new Promise((resolve, reject) => {
|
|
38
|
+
server.close((error) => {
|
|
39
|
+
if (error) {
|
|
40
|
+
reject(error)
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
resolve(undefined)
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test("startRelayRuntime starts a token-authenticated allowlisted relay", async () => {
|
|
51
|
+
const target = await startTargetServer()
|
|
52
|
+
const runtime = await startRelayRuntime({
|
|
53
|
+
mode: "relay",
|
|
54
|
+
relayHost: "127.0.0.1",
|
|
55
|
+
relayPort: 0,
|
|
56
|
+
resumeWindowMs: 30_000,
|
|
57
|
+
maxTargetBufferBytes: 1_048_576,
|
|
58
|
+
maxTotalBufferBytes: 16_777_216,
|
|
59
|
+
handshakeTimeoutMs: 10_000,
|
|
60
|
+
maxInputBytesPerConnection: 65_536,
|
|
61
|
+
authTokens: [{username: "edge", token: "secret-token"}],
|
|
62
|
+
targetAllowlist: [{host: target.host, port: target.port}],
|
|
63
|
+
allowInsecurePublic: false
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test.after(async () => {
|
|
67
|
+
await runtime.close()
|
|
68
|
+
await target.close()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
assert.deepEqual(runtime.relay.status(), {
|
|
72
|
+
activeTunnels: 0,
|
|
73
|
+
activeTargets: 0,
|
|
74
|
+
sessions: 0,
|
|
75
|
+
attachedSessions: 0,
|
|
76
|
+
detachedSessions: 0,
|
|
77
|
+
bufferedTargetBytes: 0,
|
|
78
|
+
targetConnectionAttempts: 0,
|
|
79
|
+
targetRetryTimers: 0
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const socket = await new Promise((resolve, reject) => {
|
|
83
|
+
const connection = createConnection({host: "127.0.0.1", port: runtime.relay.port})
|
|
84
|
+
connection.once("error", reject)
|
|
85
|
+
connection.once("connect", () => {
|
|
86
|
+
connection.off("error", reject)
|
|
87
|
+
resolve(connection)
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const response = await sendControl(socket, {
|
|
92
|
+
type: "session.create",
|
|
93
|
+
token: "secret-token",
|
|
94
|
+
name: "docker-test",
|
|
95
|
+
target: {host: target.host, port: target.port}
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
assert.equal(response.type, "session.created")
|
|
99
|
+
socket.destroy()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test("startRelayRuntime can select protocol v3 while v2 remains the default", async () => {
|
|
103
|
+
const target = await startTargetServer()
|
|
104
|
+
const runtime = await startRelayRuntime({
|
|
105
|
+
mode: "relay",
|
|
106
|
+
protocolVersion: 3,
|
|
107
|
+
relayHost: "127.0.0.1",
|
|
108
|
+
relayPort: 0,
|
|
109
|
+
resumeWindowMs: 30_000,
|
|
110
|
+
maxTargetBufferBytes: 1_048_576,
|
|
111
|
+
maxTotalBufferBytes: 16_777_216,
|
|
112
|
+
handshakeTimeoutMs: 10_000,
|
|
113
|
+
maxInputBytesPerConnection: 65_536,
|
|
114
|
+
authTokens: [{username: "edge", token: "secret-token"}],
|
|
115
|
+
targetAllowlist: [{host: target.host, port: target.port}],
|
|
116
|
+
allowInsecurePublic: false
|
|
117
|
+
})
|
|
118
|
+
const connection = new MultiplexedSocketductConnection({
|
|
119
|
+
relay: {host: "127.0.0.1", port: runtime.relay.port, token: "secret-token"}
|
|
120
|
+
})
|
|
121
|
+
try {
|
|
122
|
+
const socket = connection.createConnection({host: target.host, port: target.port})
|
|
123
|
+
await new Promise((resolve, reject) => {
|
|
124
|
+
socket.once("connect", resolve)
|
|
125
|
+
socket.once("error", reject)
|
|
126
|
+
})
|
|
127
|
+
const response = new Promise((resolve, reject) => {
|
|
128
|
+
socket.once("data", (chunk) => resolve(chunk.toString()))
|
|
129
|
+
socket.once("error", reject)
|
|
130
|
+
})
|
|
131
|
+
socket.write("v3-runtime")
|
|
132
|
+
assert.equal(await response, "v3-runtime")
|
|
133
|
+
assert.equal(/** @type {{physicalAccepts: number}} */ (runtime.relay.status()).physicalAccepts, 1)
|
|
134
|
+
socket.destroy()
|
|
135
|
+
} finally {
|
|
136
|
+
await connection.close()
|
|
137
|
+
await runtime.close()
|
|
138
|
+
await target.close()
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test("startRelayRuntime enforces configured cumulative v3 unauthenticated input bytes", async () => {
|
|
143
|
+
const runtime = await startRelayRuntime({
|
|
144
|
+
mode: "relay",
|
|
145
|
+
protocolVersion: 3,
|
|
146
|
+
relayHost: "127.0.0.1",
|
|
147
|
+
relayPort: 0,
|
|
148
|
+
resumeWindowMs: 30_000,
|
|
149
|
+
maxTargetBufferBytes: 1_048_576,
|
|
150
|
+
maxTotalBufferBytes: 16_777_216,
|
|
151
|
+
handshakeTimeoutMs: 10_000,
|
|
152
|
+
maxInputBytesPerConnection: 32,
|
|
153
|
+
authTokens: [{username: "edge", token: "secret-token"}],
|
|
154
|
+
targetAllowlist: [{host: "127.0.0.1", port: 1}],
|
|
155
|
+
allowInsecurePublic: false
|
|
156
|
+
})
|
|
157
|
+
const socket = createConnection({host: "127.0.0.1", port: runtime.relay.port})
|
|
158
|
+
try {
|
|
159
|
+
await new Promise((resolve, reject) => {
|
|
160
|
+
socket.once("connect", resolve)
|
|
161
|
+
socket.once("error", reject)
|
|
162
|
+
})
|
|
163
|
+
const auth = encodeV3Frame({type: V3FrameType.AUTH, streamId: 0, sequence: 1}, encodeV3Control({
|
|
164
|
+
version: 3,
|
|
165
|
+
token: "secret-token",
|
|
166
|
+
connectionId: "runtime_input_limit_01",
|
|
167
|
+
generation: 1
|
|
168
|
+
}))
|
|
169
|
+
assert(auth.length > 33)
|
|
170
|
+
socket.resume()
|
|
171
|
+
const closed = new Promise((resolve) => socket.once("close", resolve))
|
|
172
|
+
socket.write(auth.subarray(0, 33))
|
|
173
|
+
await Promise.race([
|
|
174
|
+
closed,
|
|
175
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("v3 unauthenticated input limit was not enforced")), 250))
|
|
176
|
+
])
|
|
177
|
+
} finally {
|
|
178
|
+
socket.destroy()
|
|
179
|
+
await runtime.close()
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test("startRelayRuntime requires tokens and target allowlist", async () => {
|
|
184
|
+
await assert.rejects(
|
|
185
|
+
() => startRelayRuntime({
|
|
186
|
+
mode: "relay",
|
|
187
|
+
relayHost: "127.0.0.1",
|
|
188
|
+
relayPort: 0,
|
|
189
|
+
resumeWindowMs: 30_000,
|
|
190
|
+
maxTargetBufferBytes: 1_048_576,
|
|
191
|
+
maxTotalBufferBytes: 16_777_216,
|
|
192
|
+
handshakeTimeoutMs: 10_000,
|
|
193
|
+
maxInputBytesPerConnection: 65_536,
|
|
194
|
+
authTokens: [],
|
|
195
|
+
targetAllowlist: [{host: "127.0.0.1", port: 1}],
|
|
196
|
+
allowInsecurePublic: false
|
|
197
|
+
}),
|
|
198
|
+
/SOCKETDUCT_AUTH_TOKENS/
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
await assert.rejects(
|
|
202
|
+
() => startRelayRuntime({
|
|
203
|
+
mode: "relay",
|
|
204
|
+
relayHost: "127.0.0.1",
|
|
205
|
+
relayPort: 0,
|
|
206
|
+
resumeWindowMs: 30_000,
|
|
207
|
+
maxTargetBufferBytes: 1_048_576,
|
|
208
|
+
maxTotalBufferBytes: 16_777_216,
|
|
209
|
+
handshakeTimeoutMs: 10_000,
|
|
210
|
+
maxInputBytesPerConnection: 65_536,
|
|
211
|
+
authTokens: [{username: "edge", token: "secret-token"}],
|
|
212
|
+
targetAllowlist: [],
|
|
213
|
+
allowInsecurePublic: false
|
|
214
|
+
}),
|
|
215
|
+
/SOCKETDUCT_TARGET_ALLOWLIST/
|
|
216
|
+
)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* @param {import('node:net').Socket} socket - Connected relay socket.
|
|
221
|
+
* @param {Record<string, unknown> & {type: string}} header - Control frame header.
|
|
222
|
+
* @returns {Promise<Record<string, unknown>>} Decoded response header.
|
|
223
|
+
*/
|
|
224
|
+
async function sendControl(socket, header) {
|
|
225
|
+
const decoder = new FrameDecoder()
|
|
226
|
+
return await new Promise((resolve, reject) => {
|
|
227
|
+
socket.on("data", (chunk) => {
|
|
228
|
+
for (const frame of decoder.push(chunk)) {
|
|
229
|
+
resolve(frame.header)
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
socket.once("error", reject)
|
|
233
|
+
const framedHeader = header.type === "session.create" || header.type === "session.resume"
|
|
234
|
+
? {...header, framing: header.framing ?? [2]}
|
|
235
|
+
: header
|
|
236
|
+
socket.write(encodeFrame(framedHeader))
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
test("startRelayRuntime refuses non-loopback public bind without TLS or opt-out", async () => {
|
|
241
|
+
await assert.rejects(
|
|
242
|
+
() => startRelayRuntime({
|
|
243
|
+
mode: "relay",
|
|
244
|
+
relayHost: "0.0.0.0",
|
|
245
|
+
relayPort: 0,
|
|
246
|
+
resumeWindowMs: 30_000,
|
|
247
|
+
maxTargetBufferBytes: 1_048_576,
|
|
248
|
+
maxTotalBufferBytes: 16_777_216,
|
|
249
|
+
handshakeTimeoutMs: 10_000,
|
|
250
|
+
maxInputBytesPerConnection: 65_536,
|
|
251
|
+
authTokens: [{username: "edge", token: "secret-token"}],
|
|
252
|
+
targetAllowlist: [{host: "127.0.0.1", port: 1}],
|
|
253
|
+
allowInsecurePublic: false
|
|
254
|
+
}),
|
|
255
|
+
/public host.*without TLS/
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
await assert.rejects(
|
|
259
|
+
() => startRelayRuntime({
|
|
260
|
+
mode: "relay",
|
|
261
|
+
relayHost: "192.168.1.1",
|
|
262
|
+
relayPort: 0,
|
|
263
|
+
resumeWindowMs: 30_000,
|
|
264
|
+
maxTargetBufferBytes: 1_048_576,
|
|
265
|
+
maxTotalBufferBytes: 16_777_216,
|
|
266
|
+
handshakeTimeoutMs: 10_000,
|
|
267
|
+
maxInputBytesPerConnection: 65_536,
|
|
268
|
+
authTokens: [{username: "edge", token: "secret-token"}],
|
|
269
|
+
targetAllowlist: [{host: "127.0.0.1", port: 1}],
|
|
270
|
+
allowInsecurePublic: false
|
|
271
|
+
}),
|
|
272
|
+
/public host.*without TLS/
|
|
273
|
+
)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
test("startRelayRuntime allows non-loopback public bind with explicit opt-out", async () => {
|
|
277
|
+
const target = await startTargetServer()
|
|
278
|
+
const runtime = await startRelayRuntime({
|
|
279
|
+
mode: "relay",
|
|
280
|
+
relayHost: "0.0.0.0",
|
|
281
|
+
relayPort: 0,
|
|
282
|
+
resumeWindowMs: 30_000,
|
|
283
|
+
maxTargetBufferBytes: 1_048_576,
|
|
284
|
+
maxTotalBufferBytes: 16_777_216,
|
|
285
|
+
handshakeTimeoutMs: 10_000,
|
|
286
|
+
maxInputBytesPerConnection: 65_536,
|
|
287
|
+
authTokens: [{username: "edge", token: "secret-token"}],
|
|
288
|
+
targetAllowlist: [{host: target.host, port: target.port}],
|
|
289
|
+
allowInsecurePublic: true
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
test.after(async () => {
|
|
293
|
+
await runtime.close()
|
|
294
|
+
await target.close()
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
const socket = await new Promise((resolve, reject) => {
|
|
298
|
+
const connection = createConnection({host: "127.0.0.1", port: runtime.relay.port})
|
|
299
|
+
connection.once("error", reject)
|
|
300
|
+
connection.once("connect", () => {
|
|
301
|
+
connection.off("error", reject)
|
|
302
|
+
resolve(connection)
|
|
303
|
+
})
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
const response = await sendControl(socket, {
|
|
307
|
+
type: "session.create",
|
|
308
|
+
token: "secret-token",
|
|
309
|
+
name: "insecure-test",
|
|
310
|
+
target: {host: target.host, port: target.port}
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
assert.equal(response.type, "session.created")
|
|
314
|
+
socket.destroy()
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
test("startRelayRuntime accepts loopback bind without TLS", async () => {
|
|
318
|
+
const target = await startTargetServer()
|
|
319
|
+
const runtime = await startRelayRuntime({
|
|
320
|
+
mode: "relay",
|
|
321
|
+
relayHost: "127.0.0.1",
|
|
322
|
+
relayPort: 0,
|
|
323
|
+
resumeWindowMs: 30_000,
|
|
324
|
+
maxTargetBufferBytes: 1_048_576,
|
|
325
|
+
maxTotalBufferBytes: 16_777_216,
|
|
326
|
+
handshakeTimeoutMs: 10_000,
|
|
327
|
+
maxInputBytesPerConnection: 65_536,
|
|
328
|
+
authTokens: [{username: "edge", token: "secret-token"}],
|
|
329
|
+
targetAllowlist: [{host: target.host, port: target.port}],
|
|
330
|
+
allowInsecurePublic: false
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
test.after(async () => {
|
|
334
|
+
await runtime.close()
|
|
335
|
+
await target.close()
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
const socket = await new Promise((resolve, reject) => {
|
|
339
|
+
const connection = createConnection({host: "127.0.0.1", port: runtime.relay.port})
|
|
340
|
+
connection.once("error", reject)
|
|
341
|
+
connection.once("connect", () => {
|
|
342
|
+
connection.off("error", reject)
|
|
343
|
+
resolve(connection)
|
|
344
|
+
})
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
const response = await sendControl(socket, {
|
|
348
|
+
type: "session.create",
|
|
349
|
+
token: "secret-token",
|
|
350
|
+
name: "loopback-test",
|
|
351
|
+
target: {host: target.host, port: target.port}
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
assert.equal(response.type, "session.created")
|
|
355
|
+
socket.destroy()
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
test("startRelayRuntime starts a TLS relay with valid certificate files", async () => {
|
|
359
|
+
const target = await startTargetServer()
|
|
360
|
+
const certDir = mkdtempSync(join(tmpdir(), "socketduct-tls-startup-"))
|
|
361
|
+
const certs = generateTestCertificates(certDir)
|
|
362
|
+
|
|
363
|
+
const runtime = await startRelayRuntime({
|
|
364
|
+
mode: "relay",
|
|
365
|
+
relayHost: "127.0.0.1",
|
|
366
|
+
relayPort: 0,
|
|
367
|
+
resumeWindowMs: 30_000,
|
|
368
|
+
maxTargetBufferBytes: 1_048_576,
|
|
369
|
+
maxTotalBufferBytes: 16_777_216,
|
|
370
|
+
handshakeTimeoutMs: 10_000,
|
|
371
|
+
maxInputBytesPerConnection: 65_536,
|
|
372
|
+
authTokens: [{username: "edge", token: "secret-token"}],
|
|
373
|
+
targetAllowlist: [{host: target.host, port: target.port}],
|
|
374
|
+
relayTls: {certFile: certs.serverCertFile, keyFile: certs.serverKeyFile},
|
|
375
|
+
allowInsecurePublic: false
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
test.after(async () => {
|
|
379
|
+
await runtime.close()
|
|
380
|
+
await target.close()
|
|
381
|
+
rmSync(certDir, {recursive: true, force: true})
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
assert.ok(runtime.relay.port > 0)
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
test("startRelayRuntime fails loudly on missing TLS certificate file", async () => {
|
|
388
|
+
await assert.rejects(
|
|
389
|
+
() => startRelayRuntime({
|
|
390
|
+
mode: "relay",
|
|
391
|
+
relayHost: "127.0.0.1",
|
|
392
|
+
relayPort: 0,
|
|
393
|
+
resumeWindowMs: 30_000,
|
|
394
|
+
maxTargetBufferBytes: 1_048_576,
|
|
395
|
+
maxTotalBufferBytes: 16_777_216,
|
|
396
|
+
handshakeTimeoutMs: 10_000,
|
|
397
|
+
maxInputBytesPerConnection: 65_536,
|
|
398
|
+
authTokens: [{username: "edge", token: "secret-token"}],
|
|
399
|
+
targetAllowlist: [{host: "127.0.0.1", port: 1}],
|
|
400
|
+
relayTls: {certFile: "/nonexistent/cert.crt", keyFile: "/nonexistent/key.key"},
|
|
401
|
+
allowInsecurePublic: false
|
|
402
|
+
}),
|
|
403
|
+
/ENOENT/
|
|
404
|
+
)
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
test("startRelayRuntime allows non-loopback bind with TLS configured", async () => {
|
|
408
|
+
const target = await startTargetServer()
|
|
409
|
+
const certDir = mkdtempSync(join(tmpdir(), "socketduct-tls-loopback-"))
|
|
410
|
+
const certs = generateTestCertificates(certDir)
|
|
411
|
+
|
|
412
|
+
const runtime = await startRelayRuntime({
|
|
413
|
+
mode: "relay",
|
|
414
|
+
relayHost: "0.0.0.0",
|
|
415
|
+
relayPort: 0,
|
|
416
|
+
resumeWindowMs: 30_000,
|
|
417
|
+
maxTargetBufferBytes: 1_048_576,
|
|
418
|
+
maxTotalBufferBytes: 16_777_216,
|
|
419
|
+
handshakeTimeoutMs: 10_000,
|
|
420
|
+
maxInputBytesPerConnection: 65_536,
|
|
421
|
+
authTokens: [{username: "edge", token: "secret-token"}],
|
|
422
|
+
targetAllowlist: [{host: target.host, port: target.port}],
|
|
423
|
+
relayTls: {certFile: certs.serverCertFile, keyFile: certs.serverKeyFile},
|
|
424
|
+
allowInsecurePublic: false
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
test.after(async () => {
|
|
428
|
+
await runtime.close()
|
|
429
|
+
await target.close()
|
|
430
|
+
rmSync(certDir, {recursive: true, force: true})
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
assert.ok(runtime.relay.port > 0)
|
|
434
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import test from "node:test"
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
createSession,
|
|
6
|
+
resumeSession,
|
|
7
|
+
transitionSession,
|
|
8
|
+
SessionStore
|
|
9
|
+
} from "../../../src/sessions/session.js"
|
|
10
|
+
|
|
11
|
+
test("createSession creates a named creating session with an opaque identifier", () => {
|
|
12
|
+
const session = createSession({name: "Office link", ownerId: "user-1", target: {host: "127.0.0.1", port: 22}})
|
|
13
|
+
|
|
14
|
+
assert.equal(session.name, "Office link")
|
|
15
|
+
assert.equal(session.ownerId, "user-1")
|
|
16
|
+
assert.equal(session.state, "creating")
|
|
17
|
+
assert.match(session.identifier, /^[a-zA-Z0-9_-]{32,}$/)
|
|
18
|
+
assert.equal(session.clientSequence, 0)
|
|
19
|
+
assert.equal(session.relaySequence, 0)
|
|
20
|
+
assert.deepEqual(session.target, {host: "127.0.0.1", port: 22})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test("transitionSession only allows legal lifecycle transitions", () => {
|
|
24
|
+
const session = createSession({name: "Flaky line", ownerId: "user-1", target: {host: "127.0.0.1", port: 22}, identifier: "session_123456789012345678901234567890"})
|
|
25
|
+
|
|
26
|
+
assert.equal(transitionSession(session, "active").state, "active")
|
|
27
|
+
assert.equal(transitionSession({...session, state: "interrupted"}, "resuming").state, "resuming")
|
|
28
|
+
assert.equal(transitionSession({...session, state: "resuming"}, "active").state, "active")
|
|
29
|
+
|
|
30
|
+
assert.throws(
|
|
31
|
+
() => transitionSession({...session, state: "active"}, "resuming"),
|
|
32
|
+
/illegal session state transition/i
|
|
33
|
+
)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test("SessionStore resumes sessions by identifier for the owning user", () => {
|
|
37
|
+
const store = new SessionStore()
|
|
38
|
+
const session = store.create({name: "Müggelheim relay", ownerId: "kasper", target: {host: "127.0.0.1", port: 22}})
|
|
39
|
+
store.transition(session.identifier, "active")
|
|
40
|
+
store.transition(session.identifier, "interrupted")
|
|
41
|
+
|
|
42
|
+
const resumed = resumeSession(store, {
|
|
43
|
+
identifier: session.identifier,
|
|
44
|
+
ownerId: "kasper"
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
assert.equal(resumed.identifier, session.identifier)
|
|
48
|
+
assert.equal(resumed.state, "resuming")
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test("SessionStore rejects resume for a missing or expired session", () => {
|
|
52
|
+
const store = new SessionStore({now: () => 2_000})
|
|
53
|
+
store.create({name: "Expired", ownerId: "user-1", target: {host: "127.0.0.1", port: 22}, identifier: "expired_session_12345678901234567890", expiresAt: 1_000})
|
|
54
|
+
|
|
55
|
+
assert.throws(
|
|
56
|
+
() => resumeSession(store, {identifier: "missing_session_12345678901234567890", ownerId: "user-1"}),
|
|
57
|
+
/not found/i
|
|
58
|
+
)
|
|
59
|
+
assert.throws(
|
|
60
|
+
() => resumeSession(store, {identifier: "expired_session_12345678901234567890", ownerId: "user-1"}),
|
|
61
|
+
/expired/i
|
|
62
|
+
)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test("SessionStore rejects resume while initial target acquisition is still creating", () => {
|
|
66
|
+
const store = new SessionStore()
|
|
67
|
+
const session = store.create({name: "Pending", ownerId: "user-1", target: {host: "127.0.0.1", port: 22}})
|
|
68
|
+
|
|
69
|
+
assert.throws(
|
|
70
|
+
() => resumeSession(store, {identifier: session.identifier, ownerId: "user-1"}),
|
|
71
|
+
/not resumable/i
|
|
72
|
+
)
|
|
73
|
+
assert.equal(store.get(session.identifier)?.state, "creating")
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test("SessionStore prevents a different user from resuming a session", () => {
|
|
77
|
+
const store = new SessionStore()
|
|
78
|
+
const session = store.create({name: "Private", ownerId: "owner", target: {host: "127.0.0.1", port: 22}})
|
|
79
|
+
|
|
80
|
+
assert.throws(
|
|
81
|
+
() => resumeSession(store, {identifier: session.identifier, ownerId: "other-user"}),
|
|
82
|
+
/does not belong/i
|
|
83
|
+
)
|
|
84
|
+
})
|