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,1124 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import {mkdtemp, readdir, rm} from "node:fs/promises"
|
|
3
|
+
import {tmpdir} from "node:os"
|
|
4
|
+
import {join} from "node:path"
|
|
5
|
+
import {createServer} from "node:net"
|
|
6
|
+
import {Duplex} from "node:stream"
|
|
7
|
+
import {EventEmitter} from "node:events"
|
|
8
|
+
import test from "node:test"
|
|
9
|
+
|
|
10
|
+
import {InMemoryUserStore, login, TokenStore} from "../../../src/auth/user-store.js"
|
|
11
|
+
import {connectSocketduct, SocketductSocket} from "../../../src/client/socketduct-socket.js"
|
|
12
|
+
import {encodeCompactData, encodeFrame, FrameDecoder} from "../../../src/protocol/frame.js"
|
|
13
|
+
import {startTcpRelay} from "../../../src/proxy/tcp-relay.js"
|
|
14
|
+
import {SessionStore} from "../../../src/sessions/session.js"
|
|
15
|
+
import {SessionSpool} from "../../../src/spool/spool-file.js"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @returns {Promise<{rootDir: string, spool: SessionSpool}>} - Test spool.
|
|
19
|
+
*/
|
|
20
|
+
async function createTestSpool() {
|
|
21
|
+
const rootDir = await mkdtemp(join(tmpdir(), "socketduct-socket-test-"))
|
|
22
|
+
return {rootDir, spool: new SessionSpool({rootDir})}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {import('node:net').Server} server - Server to bind.
|
|
27
|
+
* @returns {Promise<number>} - Bound port.
|
|
28
|
+
*/
|
|
29
|
+
async function listen(server) {
|
|
30
|
+
await new Promise((resolve, reject) => {
|
|
31
|
+
server.once("error", reject)
|
|
32
|
+
server.listen(0, "127.0.0.1", () => {
|
|
33
|
+
server.off("error", reject)
|
|
34
|
+
resolve(undefined)
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
const address = server.address()
|
|
38
|
+
if (address === null || typeof address === "string") {
|
|
39
|
+
throw new Error("Server did not bind to a TCP port")
|
|
40
|
+
}
|
|
41
|
+
return /** @type {import('node:net').AddressInfo} */ (address).port
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {() => boolean} predicate - Completion predicate.
|
|
46
|
+
* @param {number} [timeoutMs] - Maximum wait.
|
|
47
|
+
*/
|
|
48
|
+
async function waitUntil(predicate, timeoutMs = 1_000) {
|
|
49
|
+
const deadline = Date.now() + timeoutMs
|
|
50
|
+
while (!predicate()) {
|
|
51
|
+
if (Date.now() >= deadline) throw new Error("timed out waiting for condition")
|
|
52
|
+
await new Promise((resolve) => setTimeout(resolve, 2))
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @template T
|
|
58
|
+
* @param {Promise<T>} promise - Operation to bound.
|
|
59
|
+
* @param {string} message - Timeout error.
|
|
60
|
+
* @returns {Promise<T>} Bounded operation.
|
|
61
|
+
*/
|
|
62
|
+
function withDeadline(promise, message) {
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
const timer = setTimeout(() => reject(new Error(message)), 250)
|
|
65
|
+
promise.then(
|
|
66
|
+
(value) => {
|
|
67
|
+
clearTimeout(timer)
|
|
68
|
+
resolve(value)
|
|
69
|
+
},
|
|
70
|
+
(error) => {
|
|
71
|
+
clearTimeout(timer)
|
|
72
|
+
reject(error)
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {SocketductSocket} socket - Socket to read from.
|
|
80
|
+
* @returns {Promise<Buffer>} - Next chunk.
|
|
81
|
+
*/
|
|
82
|
+
async function readOnce(socket) {
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
socket.once("data", (chunk) => resolve(chunk))
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
class FakeRelaySocket extends EventEmitter {
|
|
89
|
+
constructor() {
|
|
90
|
+
super()
|
|
91
|
+
this.destroyed = false
|
|
92
|
+
this.destroyCount = 0
|
|
93
|
+
this.localAddress = "127.0.0.1"
|
|
94
|
+
this.localPort = 43100
|
|
95
|
+
/** @type {Buffer[]} */
|
|
96
|
+
this.writes = []
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** @returns {this} - Destroyed fake socket. */
|
|
100
|
+
destroy() {
|
|
101
|
+
this.destroyCount += 1
|
|
102
|
+
this.destroyed = true
|
|
103
|
+
return this
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @param {Buffer} chunk - Bytes written to the fake socket.
|
|
108
|
+
* @returns {boolean} - Always writable.
|
|
109
|
+
*/
|
|
110
|
+
write(chunk) {
|
|
111
|
+
this.writes.push(chunk)
|
|
112
|
+
return true
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
resume() {}
|
|
116
|
+
|
|
117
|
+
pause() {}
|
|
118
|
+
|
|
119
|
+
ref() {}
|
|
120
|
+
|
|
121
|
+
unref() {}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** @returns {Promise<void>} Allows queued relay-frame work to settle. */
|
|
125
|
+
async function settleRelayFrames() {
|
|
126
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
127
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** @param {SocketductSocket} socket - Socket to destroy and await. */
|
|
131
|
+
async function destroyAndWait(socket) {
|
|
132
|
+
if (socket.closed) return
|
|
133
|
+
const closed = new Promise((resolve) => socket.once("close", resolve))
|
|
134
|
+
socket.destroy()
|
|
135
|
+
await closed
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {FakeRelaySocket} relaySocket - Fake relay whose writes should be decoded.
|
|
140
|
+
* @returns {import('../../../src/protocol/frame.js').FrameHeader[]} Decoded control headers.
|
|
141
|
+
*/
|
|
142
|
+
function writtenHeaders(relaySocket) {
|
|
143
|
+
const decoder = new FrameDecoder({compactDirection: "client-to-target"})
|
|
144
|
+
return relaySocket.writes.flatMap((write) => decoder.push(write)).map((frame) => frame.header)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
class FakeTimers {
|
|
148
|
+
constructor() {
|
|
149
|
+
/** @type {Array<{active: boolean, callback: () => void, delay: number}>} */
|
|
150
|
+
this.timers = []
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* @param {() => void} callback - Timer callback.
|
|
155
|
+
* @param {number} delay - Timer delay.
|
|
156
|
+
* @returns {{active: boolean, callback: () => void, delay: number}} - Fake timer.
|
|
157
|
+
*/
|
|
158
|
+
setTimeout(callback, delay) {
|
|
159
|
+
const timer = {active: true, callback, delay}
|
|
160
|
+
|
|
161
|
+
this.timers.push(timer)
|
|
162
|
+
return timer
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** @param {{active: boolean}} timer - Fake timer to clear. */
|
|
166
|
+
clearTimeout(timer) {
|
|
167
|
+
timer.active = false
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* @param {number} delay - Timer delay to select.
|
|
172
|
+
* @returns {{active: boolean, callback: () => void, delay: number} | undefined} - Active matching timer.
|
|
173
|
+
*/
|
|
174
|
+
activeTimer(delay) {
|
|
175
|
+
return this.timers.find((timer) => timer.active && timer.delay === delay)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* @param {{active: boolean, callback: () => void}} timer - Active timer to run.
|
|
180
|
+
* @returns {void}
|
|
181
|
+
*/
|
|
182
|
+
run(timer) {
|
|
183
|
+
assert.equal(timer.active, true, "expected timer to be active")
|
|
184
|
+
timer.active = false
|
|
185
|
+
timer.callback()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** @returns {void} - Runs the active relay connect timer. */
|
|
189
|
+
runConnectTimeout() {
|
|
190
|
+
const timer = this.timers.find((candidate) => candidate.active && candidate.delay === 10_000)
|
|
191
|
+
|
|
192
|
+
assert.ok(timer, "expected the relay connect timeout to be armed")
|
|
193
|
+
timer.active = false
|
|
194
|
+
timer.callback()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** @returns {number} - Active reconnect timer count. */
|
|
198
|
+
activeReconnectTimers() {
|
|
199
|
+
return this.timers.filter((timer) => timer.active && timer.delay === 60_000).length
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* @param {{tls?: boolean}} [options]
|
|
205
|
+
* @returns {Promise<{fakeRelaySocket: FakeRelaySocket, rootDir: string, socket: SocketductSocket, timers: FakeTimers}>} - Fake client and controls.
|
|
206
|
+
*/
|
|
207
|
+
async function createFakeRelaySocketClient({tls = false} = {}) {
|
|
208
|
+
const {rootDir, spool} = await createTestSpool()
|
|
209
|
+
const fakeRelaySocket = new FakeRelaySocket()
|
|
210
|
+
const timers = new FakeTimers()
|
|
211
|
+
const socket = new SocketductSocket({
|
|
212
|
+
relayHost: "relay.invalid",
|
|
213
|
+
relayPort: 3100,
|
|
214
|
+
token: "token",
|
|
215
|
+
sessionName: tls ? "tls timeout" : "plain timeout",
|
|
216
|
+
target: {host: "docker-api", port: 2375},
|
|
217
|
+
spool,
|
|
218
|
+
reconnectDelayMs: 60_000,
|
|
219
|
+
...(tls ? {tls: {rejectUnauthorized: false}} : {})
|
|
220
|
+
}, {
|
|
221
|
+
clearTimeout: (timer) => timers.clearTimeout(/** @type {{active: boolean}} */ (timer)),
|
|
222
|
+
createRelaySocket: () => /** @type {import("node:net").Socket} */ (/** @type {unknown} */ (fakeRelaySocket)),
|
|
223
|
+
setTimeout: (callback, delay) => timers.setTimeout(callback, delay)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
return {fakeRelaySocket, rootDir, socket, timers}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
test("SocketductSocket is a Duplex-compatible socket facade", async () => {
|
|
230
|
+
const {rootDir, spool} = await createTestSpool()
|
|
231
|
+
const socket = new SocketductSocket({
|
|
232
|
+
relayHost: "127.0.0.1",
|
|
233
|
+
relayPort: 3100,
|
|
234
|
+
token: "token",
|
|
235
|
+
sessionName: "unit socket",
|
|
236
|
+
target: {host: "docker-api", port: 2375},
|
|
237
|
+
spool
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
assert.equal(socket instanceof Duplex, true)
|
|
241
|
+
assert.equal(socket.connecting, false)
|
|
242
|
+
assert.equal(socket.pending, true)
|
|
243
|
+
assert.equal(socket.remoteAddress, "docker-api")
|
|
244
|
+
assert.equal(socket.remotePort, 2375)
|
|
245
|
+
assert.equal(socket.bytesRead, 0)
|
|
246
|
+
assert.equal(socket.bytesWritten, 0)
|
|
247
|
+
assert.equal(typeof socket.connect, "function")
|
|
248
|
+
assert.equal(socket.setNoDelay(), socket)
|
|
249
|
+
assert.equal(socket.setKeepAlive(), socket)
|
|
250
|
+
assert.equal(socket.setTimeout(0), socket)
|
|
251
|
+
assert.equal(socket.ref(), socket)
|
|
252
|
+
assert.equal(socket.unref(), socket)
|
|
253
|
+
assert.deepEqual(socket.address(), {address: "0.0.0.0", family: "IPv4", port: 0})
|
|
254
|
+
|
|
255
|
+
socket.destroy()
|
|
256
|
+
await rm(rootDir, {recursive: true, force: true})
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
test("SocketductSocket and relay reject invalid compression chunk limits", async () => {
|
|
260
|
+
const {rootDir, spool} = await createTestSpool()
|
|
261
|
+
try {
|
|
262
|
+
assert.throws(
|
|
263
|
+
() => new SocketductSocket({
|
|
264
|
+
relayHost: "127.0.0.1",
|
|
265
|
+
relayPort: 3100,
|
|
266
|
+
token: "token",
|
|
267
|
+
sessionName: "invalid compression limit",
|
|
268
|
+
target: {host: "docker-api", port: 2375},
|
|
269
|
+
spool,
|
|
270
|
+
compressionLimits: {maxDecompressedBytes: 0}
|
|
271
|
+
}),
|
|
272
|
+
/compressionLimits\.maxDecompressedBytes must be a positive safe integer/
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
await assert.rejects(
|
|
276
|
+
() => startTcpRelay({
|
|
277
|
+
authorizeTarget: () => false,
|
|
278
|
+
compressionLimits: {maxDecompressedBytes: 1.5},
|
|
279
|
+
tokens: new TokenStore({now: () => 1000, tokenTtlMs: 60_000}),
|
|
280
|
+
sessions: new SessionStore({now: () => 1000})
|
|
281
|
+
}),
|
|
282
|
+
/compressionLimits\.maxDecompressedBytes must be a positive safe integer/
|
|
283
|
+
)
|
|
284
|
+
} finally {
|
|
285
|
+
await rm(rootDir, {recursive: true, force: true})
|
|
286
|
+
}
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
test("SocketductSocket connect returns the socket", async () => {
|
|
290
|
+
const {rootDir, spool} = await createTestSpool()
|
|
291
|
+
const socket = new SocketductSocket({
|
|
292
|
+
relayHost: "127.0.0.1",
|
|
293
|
+
relayPort: 9,
|
|
294
|
+
token: "token",
|
|
295
|
+
sessionName: "unit socket",
|
|
296
|
+
target: {host: "docker-api", port: 2375},
|
|
297
|
+
spool,
|
|
298
|
+
reconnectDelayMs: 60_000
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
assert.equal(socket.connect(), socket)
|
|
302
|
+
assert.equal(socket.connecting, true)
|
|
303
|
+
socket.destroy()
|
|
304
|
+
await rm(rootDir, {recursive: true, force: true})
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
test("SocketductSocket idle timeout resets on write and read activity before firing when idle", async () => {
|
|
308
|
+
const {fakeRelaySocket, rootDir, socket, timers} = await createFakeRelaySocketClient()
|
|
309
|
+
let timeoutEvents = 0
|
|
310
|
+
socket.on("timeout", () => { timeoutEvents += 1 })
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
socket.connect()
|
|
314
|
+
fakeRelaySocket.emit("connect")
|
|
315
|
+
const connected = new Promise((resolve) => socket.once("connect", resolve))
|
|
316
|
+
fakeRelaySocket.emit("data", encodeFrame({type: "session.created", identifier: "idle-timeout", framing: 2}))
|
|
317
|
+
await connected
|
|
318
|
+
|
|
319
|
+
socket.setTimeout(50)
|
|
320
|
+
const initialTimer = timers.activeTimer(50)
|
|
321
|
+
assert.ok(initialTimer, "setting an idle timeout must arm the injected scheduler")
|
|
322
|
+
|
|
323
|
+
await new Promise((resolve, reject) => {
|
|
324
|
+
socket.write("ping", (error) => error ? reject(error) : resolve(undefined))
|
|
325
|
+
})
|
|
326
|
+
const afterWriteTimer = timers.activeTimer(50)
|
|
327
|
+
assert.ok(afterWriteTimer, "write activity must leave one idle timer armed")
|
|
328
|
+
assert.notEqual(afterWriteTimer, initialTimer)
|
|
329
|
+
assert.equal(initialTimer.active, false)
|
|
330
|
+
assert.equal(timeoutEvents, 0)
|
|
331
|
+
|
|
332
|
+
const received = readOnce(socket)
|
|
333
|
+
fakeRelaySocket.emit("data", encodeCompactData({sequence: 4, payload: Buffer.from("pong")}))
|
|
334
|
+
assert.equal((await received).toString("utf8"), "pong")
|
|
335
|
+
const afterReadTimer = timers.activeTimer(50)
|
|
336
|
+
assert.ok(afterReadTimer, "read activity must leave one idle timer armed")
|
|
337
|
+
assert.notEqual(afterReadTimer, afterWriteTimer)
|
|
338
|
+
assert.equal(afterWriteTimer.active, false)
|
|
339
|
+
assert.equal(timeoutEvents, 0)
|
|
340
|
+
|
|
341
|
+
initialTimer.callback()
|
|
342
|
+
afterWriteTimer.callback()
|
|
343
|
+
assert.equal(timeoutEvents, 0, "cleared idle timer callbacks already queued before activity must be stale")
|
|
344
|
+
|
|
345
|
+
timers.run(afterReadTimer)
|
|
346
|
+
assert.equal(timeoutEvents, 1, "the current idle timer must fire without later activity")
|
|
347
|
+
socket.setTimeout(0)
|
|
348
|
+
assert.equal(timers.activeTimer(50), undefined)
|
|
349
|
+
} finally {
|
|
350
|
+
socket.setTimeout(0)
|
|
351
|
+
const closed = new Promise((resolve) => socket.once("close", resolve))
|
|
352
|
+
socket.destroy()
|
|
353
|
+
await settleRelayFrames()
|
|
354
|
+
fakeRelaySocket.emit("data", encodeFrame({type: "session.close", direction: "target-to-client", acknowledged: true}))
|
|
355
|
+
await closed
|
|
356
|
+
await rm(rootDir, {recursive: true, force: true})
|
|
357
|
+
}
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
test("SocketductSocket destroys a timed-out plain relay socket once and ignores late events", async () => {
|
|
361
|
+
const {fakeRelaySocket, rootDir, socket, timers} = await createFakeRelaySocketClient()
|
|
362
|
+
let connectEvents = 0
|
|
363
|
+
let readyEvents = 0
|
|
364
|
+
|
|
365
|
+
socket.on("connect", () => { connectEvents += 1 })
|
|
366
|
+
socket.on("ready", () => { readyEvents += 1 })
|
|
367
|
+
socket.connect()
|
|
368
|
+
timers.runConnectTimeout()
|
|
369
|
+
|
|
370
|
+
assert.equal(fakeRelaySocket.destroyCount, 1)
|
|
371
|
+
assert.equal(timers.activeReconnectTimers(), 1)
|
|
372
|
+
|
|
373
|
+
fakeRelaySocket.emit("connect")
|
|
374
|
+
fakeRelaySocket.emit("error", new Error("late connect error"))
|
|
375
|
+
fakeRelaySocket.emit("close")
|
|
376
|
+
|
|
377
|
+
assert.equal(fakeRelaySocket.destroyCount, 1)
|
|
378
|
+
assert.equal(fakeRelaySocket.writes.length, 0)
|
|
379
|
+
assert.equal(connectEvents, 0)
|
|
380
|
+
assert.equal(readyEvents, 0)
|
|
381
|
+
assert.equal(socket.connecting, true)
|
|
382
|
+
assert.equal(socket.pending, true)
|
|
383
|
+
assert.equal(timers.activeReconnectTimers(), 1)
|
|
384
|
+
|
|
385
|
+
socket.destroy()
|
|
386
|
+
await rm(rootDir, {recursive: true, force: true})
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
test("SocketductSocket destroys a timed-out TLS relay socket once and ignores late secureConnect events", async () => {
|
|
390
|
+
const {fakeRelaySocket, rootDir, socket, timers} = await createFakeRelaySocketClient({tls: true})
|
|
391
|
+
let connectEvents = 0
|
|
392
|
+
let readyEvents = 0
|
|
393
|
+
|
|
394
|
+
socket.on("connect", () => { connectEvents += 1 })
|
|
395
|
+
socket.on("ready", () => { readyEvents += 1 })
|
|
396
|
+
socket.connect()
|
|
397
|
+
timers.runConnectTimeout()
|
|
398
|
+
|
|
399
|
+
assert.equal(fakeRelaySocket.destroyCount, 1)
|
|
400
|
+
assert.equal(timers.activeReconnectTimers(), 1)
|
|
401
|
+
|
|
402
|
+
fakeRelaySocket.emit("secureConnect")
|
|
403
|
+
fakeRelaySocket.emit("error", new Error("late TLS error"))
|
|
404
|
+
fakeRelaySocket.emit("close")
|
|
405
|
+
|
|
406
|
+
assert.equal(fakeRelaySocket.destroyCount, 1)
|
|
407
|
+
assert.equal(fakeRelaySocket.writes.length, 0)
|
|
408
|
+
assert.equal(connectEvents, 0)
|
|
409
|
+
assert.equal(readyEvents, 0)
|
|
410
|
+
assert.equal(socket.connecting, true)
|
|
411
|
+
assert.equal(socket.pending, true)
|
|
412
|
+
assert.equal(timers.activeReconnectTimers(), 1)
|
|
413
|
+
|
|
414
|
+
socket.destroy()
|
|
415
|
+
await rm(rootDir, {recursive: true, force: true})
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
test("SocketductSocket rejects a legacy session handshake without framing v2", async () => {
|
|
419
|
+
const {rootDir, spool} = await createTestSpool()
|
|
420
|
+
const relay = createServer((peer) => {
|
|
421
|
+
const decoder = new FrameDecoder()
|
|
422
|
+
peer.on("data", (chunk) => {
|
|
423
|
+
const frames = decoder.push(chunk)
|
|
424
|
+
if (frames.some((frame) => frame.header.type === "session.create")) {
|
|
425
|
+
peer.write(encodeFrame({type: "session.created", identifier: "legacy-session"}))
|
|
426
|
+
}
|
|
427
|
+
})
|
|
428
|
+
})
|
|
429
|
+
const relayPort = await listen(relay)
|
|
430
|
+
const socket = new SocketductSocket({
|
|
431
|
+
relayHost: "127.0.0.1",
|
|
432
|
+
relayPort,
|
|
433
|
+
token: "token",
|
|
434
|
+
sessionName: "legacy handshake",
|
|
435
|
+
target: {host: "docker-api", port: 2375},
|
|
436
|
+
spool,
|
|
437
|
+
reconnectDelayMs: 60_000
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
try {
|
|
441
|
+
let connected = false
|
|
442
|
+
socket.on("connect", () => { connected = true })
|
|
443
|
+
const error = new Promise((resolve) => socket.once("error", resolve))
|
|
444
|
+
socket.connect()
|
|
445
|
+
const handshakeError = await error
|
|
446
|
+
assert.match(String(handshakeError), /required framing version 2/)
|
|
447
|
+
assert.equal(connected, false)
|
|
448
|
+
assert.deepEqual(await readdir(rootDir), [])
|
|
449
|
+
} finally {
|
|
450
|
+
socket.destroy()
|
|
451
|
+
await new Promise((resolve, reject) => relay.close((closeError) => closeError ? reject(closeError) : resolve(undefined)))
|
|
452
|
+
await rm(rootDir, {recursive: true, force: true})
|
|
453
|
+
}
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
test("SocketductSocket relays target traffic directly", async () => {
|
|
457
|
+
const {rootDir, spool} = await createTestSpool()
|
|
458
|
+
const echoServer = createServer((socket) => {
|
|
459
|
+
socket.on("data", (chunk) => socket.write(Buffer.from(`echo:${chunk.toString("utf8")}`)))
|
|
460
|
+
})
|
|
461
|
+
const targetPort = await listen(echoServer)
|
|
462
|
+
|
|
463
|
+
const users = new InMemoryUserStore()
|
|
464
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
465
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
466
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
467
|
+
const relay = await startTcpRelay({
|
|
468
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
469
|
+
tokens,
|
|
470
|
+
sessions: new SessionStore({now: () => 1000})
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
const socket = new SocketductSocket({
|
|
474
|
+
relayHost: "127.0.0.1",
|
|
475
|
+
relayPort: relay.port,
|
|
476
|
+
token: token.value,
|
|
477
|
+
sessionName: "direct socket integration",
|
|
478
|
+
target: {host: "127.0.0.1", port: targetPort},
|
|
479
|
+
spool,
|
|
480
|
+
reconnectDelayMs: 10
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
socket.connect()
|
|
484
|
+
await new Promise((resolve, reject) => {
|
|
485
|
+
socket.once("error", reject)
|
|
486
|
+
socket.once("connect", () => {
|
|
487
|
+
socket.off("error", reject)
|
|
488
|
+
resolve(undefined)
|
|
489
|
+
})
|
|
490
|
+
})
|
|
491
|
+
socket.write("hello")
|
|
492
|
+
assert.equal((await readOnce(socket)).toString("utf8"), "echo:hello")
|
|
493
|
+
assert.equal(socket.bytesWritten, 5)
|
|
494
|
+
assert.equal(socket.bytesRead, 10)
|
|
495
|
+
|
|
496
|
+
socket.destroy()
|
|
497
|
+
await relay.close()
|
|
498
|
+
await new Promise((resolve, reject) => {
|
|
499
|
+
echoServer.close((error) => {
|
|
500
|
+
if (error) {
|
|
501
|
+
reject(error)
|
|
502
|
+
return
|
|
503
|
+
}
|
|
504
|
+
resolve(undefined)
|
|
505
|
+
})
|
|
506
|
+
})
|
|
507
|
+
await rm(rootDir, {recursive: true, force: true})
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
test("SocketductSocket end does not reopen the relay tunnel", async () => {
|
|
511
|
+
const {rootDir, spool} = await createTestSpool()
|
|
512
|
+
const echoServer = createServer((socket) => {
|
|
513
|
+
socket.on("data", (chunk) => socket.write(Buffer.from(`ended:${chunk.toString("utf8")}`)))
|
|
514
|
+
})
|
|
515
|
+
const targetPort = await listen(echoServer)
|
|
516
|
+
|
|
517
|
+
const users = new InMemoryUserStore()
|
|
518
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
519
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
520
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
521
|
+
const relay = await startTcpRelay({
|
|
522
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
523
|
+
tokens,
|
|
524
|
+
sessions: new SessionStore({now: () => 1000})
|
|
525
|
+
})
|
|
526
|
+
let relayConnections = 0
|
|
527
|
+
relay.server.on("connection", () => {
|
|
528
|
+
relayConnections += 1
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
const socket = new SocketductSocket({
|
|
532
|
+
relayHost: "127.0.0.1",
|
|
533
|
+
relayPort: relay.port,
|
|
534
|
+
token: token.value,
|
|
535
|
+
sessionName: "intentional end",
|
|
536
|
+
target: {host: "127.0.0.1", port: targetPort},
|
|
537
|
+
spool,
|
|
538
|
+
reconnectDelayMs: 10
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
socket.connect()
|
|
542
|
+
await new Promise((resolve, reject) => {
|
|
543
|
+
socket.once("error", reject)
|
|
544
|
+
socket.once("connect", () => {
|
|
545
|
+
socket.off("error", reject)
|
|
546
|
+
resolve(undefined)
|
|
547
|
+
})
|
|
548
|
+
})
|
|
549
|
+
socket.write("bye")
|
|
550
|
+
assert.equal((await readOnce(socket)).toString("utf8"), "ended:bye")
|
|
551
|
+
socket.end()
|
|
552
|
+
await new Promise((resolve) => { setTimeout(resolve, 50) })
|
|
553
|
+
assert.equal(relayConnections, 1)
|
|
554
|
+
|
|
555
|
+
socket.destroy()
|
|
556
|
+
await relay.close()
|
|
557
|
+
await new Promise((resolve, reject) => {
|
|
558
|
+
echoServer.close((error) => {
|
|
559
|
+
if (error) {
|
|
560
|
+
reject(error)
|
|
561
|
+
return
|
|
562
|
+
}
|
|
563
|
+
resolve(undefined)
|
|
564
|
+
})
|
|
565
|
+
})
|
|
566
|
+
await rm(rootDir, {recursive: true, force: true})
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
test("SocketductSocket reconnect replays writes without re-emitting connect", async () => {
|
|
570
|
+
const {rootDir, spool} = await createTestSpool()
|
|
571
|
+
const echoServer = createServer((socket) => {
|
|
572
|
+
socket.on("data", (chunk) => socket.write(Buffer.from(`again:${chunk.toString("utf8")}`)))
|
|
573
|
+
})
|
|
574
|
+
const targetPort = await listen(echoServer)
|
|
575
|
+
|
|
576
|
+
const users = new InMemoryUserStore()
|
|
577
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
578
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
579
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
580
|
+
const relay = await startTcpRelay({
|
|
581
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
582
|
+
tokens,
|
|
583
|
+
sessions: new SessionStore({now: () => 1000}),
|
|
584
|
+
resumeWindowMs: 1_000
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
const socket = new SocketductSocket({
|
|
588
|
+
relayHost: "127.0.0.1",
|
|
589
|
+
relayPort: relay.port,
|
|
590
|
+
token: token.value,
|
|
591
|
+
sessionName: "socket reconnect",
|
|
592
|
+
target: {host: "127.0.0.1", port: targetPort},
|
|
593
|
+
spool,
|
|
594
|
+
reconnectDelayMs: 10
|
|
595
|
+
})
|
|
596
|
+
try {
|
|
597
|
+
let connectEvents = 0
|
|
598
|
+
let readyEvents = 0
|
|
599
|
+
socket.on("connect", () => { connectEvents += 1 })
|
|
600
|
+
socket.on("ready", () => { readyEvents += 1 })
|
|
601
|
+
|
|
602
|
+
socket.connect()
|
|
603
|
+
await new Promise((resolve, reject) => {
|
|
604
|
+
socket.once("error", reject)
|
|
605
|
+
socket.once("connect", () => {
|
|
606
|
+
socket.off("error", reject)
|
|
607
|
+
resolve(undefined)
|
|
608
|
+
})
|
|
609
|
+
})
|
|
610
|
+
socket.write("first")
|
|
611
|
+
assert.equal((await readOnce(socket)).toString("utf8"), "again:first")
|
|
612
|
+
|
|
613
|
+
const resumed = new Promise((resolve, reject) => {
|
|
614
|
+
socket.once("error", reject)
|
|
615
|
+
socket.once("ready", () => {
|
|
616
|
+
socket.off("error", reject)
|
|
617
|
+
resolve(undefined)
|
|
618
|
+
})
|
|
619
|
+
})
|
|
620
|
+
relay.closeActiveTunnel()
|
|
621
|
+
socket.write("second")
|
|
622
|
+
await resumed
|
|
623
|
+
assert.equal((await readOnce(socket)).toString("utf8"), "again:second")
|
|
624
|
+
assert.equal(connectEvents, 1)
|
|
625
|
+
assert.equal(readyEvents >= 2, true)
|
|
626
|
+
} finally {
|
|
627
|
+
socket.destroy()
|
|
628
|
+
await relay.close()
|
|
629
|
+
await new Promise((resolve, reject) => {
|
|
630
|
+
echoServer.close((error) => {
|
|
631
|
+
if (error) {
|
|
632
|
+
reject(error)
|
|
633
|
+
return
|
|
634
|
+
}
|
|
635
|
+
resolve(undefined)
|
|
636
|
+
})
|
|
637
|
+
})
|
|
638
|
+
await rm(rootDir, {recursive: true, force: true})
|
|
639
|
+
}
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
test("SocketductSocket defers an intentional close until an in-flight reconnect is authenticated and resumed", async () => {
|
|
643
|
+
const {rootDir, spool} = await createTestSpool()
|
|
644
|
+
const firstRelay = new FakeRelaySocket()
|
|
645
|
+
const secondRelay = new FakeRelaySocket()
|
|
646
|
+
const relaySockets = [firstRelay, secondRelay]
|
|
647
|
+
const timers = new FakeTimers()
|
|
648
|
+
const socket = new SocketductSocket({
|
|
649
|
+
relayHost: "relay.invalid",
|
|
650
|
+
relayPort: 3100,
|
|
651
|
+
token: "token",
|
|
652
|
+
sessionName: "close during reconnect",
|
|
653
|
+
target: {host: "docker-api", port: 2375},
|
|
654
|
+
spool,
|
|
655
|
+
reconnectDelayMs: 60_000
|
|
656
|
+
}, {
|
|
657
|
+
clearTimeout: (timer) => timers.clearTimeout(/** @type {{active: boolean}} */ (timer)),
|
|
658
|
+
createRelaySocket: () => /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (relaySockets.shift())),
|
|
659
|
+
setTimeout: (callback, delay) => timers.setTimeout(callback, delay)
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
try {
|
|
663
|
+
socket.connect()
|
|
664
|
+
firstRelay.emit("connect")
|
|
665
|
+
const connected = new Promise((resolve) => socket.once("connect", resolve))
|
|
666
|
+
firstRelay.emit("data", encodeFrame({type: "session.created", identifier: "close-reconnect", framing: 2}))
|
|
667
|
+
await connected
|
|
668
|
+
|
|
669
|
+
firstRelay.destroyed = true
|
|
670
|
+
firstRelay.emit("close")
|
|
671
|
+
const reconnectTimer = timers.timers.find((timer) => timer.active && timer.delay === 60_000)
|
|
672
|
+
assert.ok(reconnectTimer)
|
|
673
|
+
reconnectTimer.active = false
|
|
674
|
+
reconnectTimer.callback()
|
|
675
|
+
|
|
676
|
+
const closeEvent = new Promise((resolve) => socket.once("close", resolve))
|
|
677
|
+
socket.destroy()
|
|
678
|
+
secondRelay.emit("connect")
|
|
679
|
+
await settleRelayFrames()
|
|
680
|
+
assert.deepEqual(writtenHeaders(secondRelay).map((header) => header.type), ["session.resume"])
|
|
681
|
+
|
|
682
|
+
secondRelay.emit("data", encodeFrame({type: "session.resumed", identifier: "close-reconnect", framing: 2}))
|
|
683
|
+
await settleRelayFrames()
|
|
684
|
+
assert.deepEqual(writtenHeaders(secondRelay).map((header) => header.type), ["session.resume", "session.close"])
|
|
685
|
+
secondRelay.emit("data", encodeFrame({type: "session.close", direction: "target-to-client", acknowledged: true}))
|
|
686
|
+
await closeEvent
|
|
687
|
+
assert.equal(relaySockets.length, 0)
|
|
688
|
+
assert.equal(timers.timers.filter((timer) => timer.active).length, 0)
|
|
689
|
+
assert.equal(secondRelay.destroyCount, 1)
|
|
690
|
+
assert.equal(secondRelay.listenerCount("connect"), 0)
|
|
691
|
+
assert.equal(secondRelay.listenerCount("data"), 0)
|
|
692
|
+
assert.equal(secondRelay.listenerCount("close"), 0)
|
|
693
|
+
assert.deepEqual(await readdir(rootDir), [])
|
|
694
|
+
} finally {
|
|
695
|
+
await destroyAndWait(socket)
|
|
696
|
+
await rm(rootDir, {recursive: true, force: true})
|
|
697
|
+
}
|
|
698
|
+
})
|
|
699
|
+
|
|
700
|
+
test("SocketductSocket reconnects immediately to close an announced session between attempts", async () => {
|
|
701
|
+
const {rootDir, spool} = await createTestSpool()
|
|
702
|
+
const firstRelay = new FakeRelaySocket()
|
|
703
|
+
const secondRelay = new FakeRelaySocket()
|
|
704
|
+
const relaySockets = [firstRelay, secondRelay]
|
|
705
|
+
const timers = new FakeTimers()
|
|
706
|
+
const socket = new SocketductSocket({
|
|
707
|
+
relayHost: "relay.invalid",
|
|
708
|
+
relayPort: 3100,
|
|
709
|
+
token: "token",
|
|
710
|
+
sessionName: "close between reconnects",
|
|
711
|
+
target: {host: "docker-api", port: 2375},
|
|
712
|
+
spool,
|
|
713
|
+
reconnectDelayMs: 60_000
|
|
714
|
+
}, {
|
|
715
|
+
clearTimeout: (timer) => timers.clearTimeout(/** @type {{active: boolean}} */ (timer)),
|
|
716
|
+
createRelaySocket: () => /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (relaySockets.shift())),
|
|
717
|
+
setTimeout: (callback, delay) => timers.setTimeout(callback, delay)
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
try {
|
|
721
|
+
socket.connect()
|
|
722
|
+
firstRelay.emit("connect")
|
|
723
|
+
const connected = new Promise((resolve) => socket.once("connect", resolve))
|
|
724
|
+
firstRelay.emit("data", encodeFrame({type: "session.created", identifier: "close-between", framing: 2}))
|
|
725
|
+
await connected
|
|
726
|
+
firstRelay.destroyed = true
|
|
727
|
+
firstRelay.emit("close")
|
|
728
|
+
assert.equal(timers.activeReconnectTimers(), 1)
|
|
729
|
+
|
|
730
|
+
const closeEvent = new Promise((resolve) => socket.once("close", resolve))
|
|
731
|
+
socket.destroy()
|
|
732
|
+
assert.equal(timers.activeReconnectTimers(), 0)
|
|
733
|
+
assert.equal(relaySockets.length, 0, "logical close should start its own authenticated reconnect immediately")
|
|
734
|
+
secondRelay.emit("connect")
|
|
735
|
+
await settleRelayFrames()
|
|
736
|
+
assert.deepEqual(writtenHeaders(secondRelay).map((header) => header.type), ["session.resume"])
|
|
737
|
+
secondRelay.emit("data", encodeFrame({type: "session.resumed", identifier: "close-between", framing: 2}))
|
|
738
|
+
await settleRelayFrames()
|
|
739
|
+
assert.deepEqual(writtenHeaders(secondRelay).map((header) => header.type), ["session.resume", "session.close"])
|
|
740
|
+
secondRelay.emit("data", encodeFrame({type: "session.close", direction: "target-to-client", acknowledged: true}))
|
|
741
|
+
await closeEvent
|
|
742
|
+
assert.equal(timers.timers.filter((timer) => timer.active).length, 0)
|
|
743
|
+
assert.equal(secondRelay.destroyCount, 1)
|
|
744
|
+
assert.equal(secondRelay.listenerCount("connect"), 0)
|
|
745
|
+
assert.equal(secondRelay.listenerCount("data"), 0)
|
|
746
|
+
assert.equal(secondRelay.listenerCount("close"), 0)
|
|
747
|
+
assert.deepEqual(await readdir(rootDir), [])
|
|
748
|
+
} finally {
|
|
749
|
+
await destroyAndWait(socket)
|
|
750
|
+
await rm(rootDir, {recursive: true, force: true})
|
|
751
|
+
}
|
|
752
|
+
})
|
|
753
|
+
|
|
754
|
+
test("SocketductSocket destroy while detached promptly removes the resumable relay session", async () => {
|
|
755
|
+
const {rootDir, spool} = await createTestSpool()
|
|
756
|
+
const targetServer = createServer((socket) => socket.on("data", (chunk) => socket.write(chunk)))
|
|
757
|
+
const targetPort = await listen(targetServer)
|
|
758
|
+
const users = new InMemoryUserStore()
|
|
759
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
760
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
761
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
762
|
+
const relay = await startTcpRelay({
|
|
763
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
764
|
+
resumeWindowMs: 10_000,
|
|
765
|
+
sessions: new SessionStore({now: () => 1000}),
|
|
766
|
+
tokens
|
|
767
|
+
})
|
|
768
|
+
const socket = new SocketductSocket({
|
|
769
|
+
relayHost: "127.0.0.1",
|
|
770
|
+
relayPort: relay.port,
|
|
771
|
+
token: token.value,
|
|
772
|
+
sessionName: "destroy while detached",
|
|
773
|
+
target: {host: "127.0.0.1", port: targetPort},
|
|
774
|
+
spool,
|
|
775
|
+
reconnectDelayMs: 60_000
|
|
776
|
+
})
|
|
777
|
+
|
|
778
|
+
try {
|
|
779
|
+
const connected = new Promise((resolve) => socket.once("connect", resolve))
|
|
780
|
+
socket.connect()
|
|
781
|
+
await connected
|
|
782
|
+
await relay.closeActiveTunnel()
|
|
783
|
+
await waitUntil(() => relay.status().detachedSessions === 1)
|
|
784
|
+
|
|
785
|
+
await destroyAndWait(socket)
|
|
786
|
+
await waitUntil(() => relay.status().sessions === 0)
|
|
787
|
+
assert.deepEqual(relay.status(), {
|
|
788
|
+
activeTunnels: 0, activeTargets: 0, sessions: 0, attachedSessions: 0,
|
|
789
|
+
detachedSessions: 0, bufferedTargetBytes: 0, targetConnectionAttempts: 0, targetRetryTimers: 0
|
|
790
|
+
})
|
|
791
|
+
assert.deepEqual(await readdir(rootDir), [])
|
|
792
|
+
} finally {
|
|
793
|
+
await destroyAndWait(socket)
|
|
794
|
+
await relay.close()
|
|
795
|
+
await new Promise((resolve, reject) => targetServer.close((error) => error ? reject(error) : resolve(undefined)))
|
|
796
|
+
await rm(rootDir, {recursive: true, force: true})
|
|
797
|
+
}
|
|
798
|
+
})
|
|
799
|
+
|
|
800
|
+
test("SocketductSocket ignores queued target data after target EOF", async () => {
|
|
801
|
+
const {rootDir, spool} = await createTestSpool()
|
|
802
|
+
const relay = new FakeRelaySocket()
|
|
803
|
+
const socket = new SocketductSocket({
|
|
804
|
+
relayHost: "relay.invalid",
|
|
805
|
+
relayPort: 3100,
|
|
806
|
+
token: "token",
|
|
807
|
+
sessionName: "queued data after eof",
|
|
808
|
+
target: {host: "docker-api", port: 2375},
|
|
809
|
+
spool,
|
|
810
|
+
reconnectDelayMs: 60_000
|
|
811
|
+
}, {
|
|
812
|
+
createRelaySocket: () => /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (relay))
|
|
813
|
+
})
|
|
814
|
+
/** @type {Error[]} */
|
|
815
|
+
const errors = []
|
|
816
|
+
/** @type {Buffer[]} */
|
|
817
|
+
const chunks = []
|
|
818
|
+
socket.on("error", (error) => errors.push(error))
|
|
819
|
+
socket.on("data", (chunk) => chunks.push(chunk))
|
|
820
|
+
|
|
821
|
+
try {
|
|
822
|
+
socket.connect()
|
|
823
|
+
relay.emit("connect")
|
|
824
|
+
const connected = new Promise((resolve) => socket.once("connect", resolve))
|
|
825
|
+
relay.emit("data", encodeFrame({type: "session.created", identifier: "queued-after-eof", framing: 2}))
|
|
826
|
+
await connected
|
|
827
|
+
relay.emit("data", Buffer.concat([
|
|
828
|
+
encodeFrame({type: "session.close", direction: "target-to-client"}),
|
|
829
|
+
encodeCompactData({sequence: 4, payload: Buffer.from("late")})
|
|
830
|
+
]))
|
|
831
|
+
await settleRelayFrames()
|
|
832
|
+
|
|
833
|
+
assert.deepEqual(chunks, [])
|
|
834
|
+
assert.deepEqual(errors, [])
|
|
835
|
+
assert.equal(socket.bytesRead, 0)
|
|
836
|
+
assert.equal(socket.destroyed, false)
|
|
837
|
+
assert.equal(socket.readable, false)
|
|
838
|
+
const closed = new Promise((resolve) => socket.once("close", resolve))
|
|
839
|
+
socket.destroy()
|
|
840
|
+
await settleRelayFrames()
|
|
841
|
+
assert.equal(writtenHeaders(relay).at(-1)?.type, "session.close")
|
|
842
|
+
relay.emit("data", encodeFrame({type: "session.close", direction: "target-to-client", acknowledged: true}))
|
|
843
|
+
await withDeadline(closed, "timed out waiting for logical close acknowledgement")
|
|
844
|
+
} finally {
|
|
845
|
+
await destroyAndWait(socket)
|
|
846
|
+
await rm(rootDir, {recursive: true, force: true})
|
|
847
|
+
}
|
|
848
|
+
})
|
|
849
|
+
|
|
850
|
+
test("connectSocketduct creates and connects the public socket option shape", async () => {
|
|
851
|
+
const {rootDir} = await createTestSpool()
|
|
852
|
+
const echoServer = createServer((socket) => {
|
|
853
|
+
socket.on("data", (chunk) => socket.write(Buffer.from(`factory:${chunk.toString("utf8")}`)))
|
|
854
|
+
})
|
|
855
|
+
const targetPort = await listen(echoServer)
|
|
856
|
+
|
|
857
|
+
const users = new InMemoryUserStore()
|
|
858
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
859
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
860
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
861
|
+
const relay = await startTcpRelay({
|
|
862
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
863
|
+
tokens,
|
|
864
|
+
sessions: new SessionStore({now: () => 1000})
|
|
865
|
+
})
|
|
866
|
+
|
|
867
|
+
const socket = connectSocketduct({
|
|
868
|
+
relay: {host: "127.0.0.1", port: relay.port, token: token.value},
|
|
869
|
+
sessionName: "public socket options",
|
|
870
|
+
target: {host: "127.0.0.1", port: targetPort},
|
|
871
|
+
spoolDirectory: rootDir,
|
|
872
|
+
reconnect: {initialDelayMs: 10}
|
|
873
|
+
})
|
|
874
|
+
|
|
875
|
+
await new Promise((resolve, reject) => {
|
|
876
|
+
socket.once("error", reject)
|
|
877
|
+
socket.once("connect", () => {
|
|
878
|
+
socket.off("error", reject)
|
|
879
|
+
resolve(undefined)
|
|
880
|
+
})
|
|
881
|
+
})
|
|
882
|
+
socket.write("hello")
|
|
883
|
+
assert.equal((await readOnce(socket)).toString("utf8"), "factory:hello")
|
|
884
|
+
|
|
885
|
+
socket.destroy()
|
|
886
|
+
await relay.close()
|
|
887
|
+
await new Promise((resolve, reject) => {
|
|
888
|
+
echoServer.close((error) => {
|
|
889
|
+
if (error) {
|
|
890
|
+
reject(error)
|
|
891
|
+
return
|
|
892
|
+
}
|
|
893
|
+
resolve(undefined)
|
|
894
|
+
})
|
|
895
|
+
})
|
|
896
|
+
await rm(rootDir, {recursive: true, force: true})
|
|
897
|
+
})
|
|
898
|
+
|
|
899
|
+
test("SocketductSocket cleans up spool directory on destroy", async () => {
|
|
900
|
+
const {rootDir, spool} = await createTestSpool()
|
|
901
|
+
const echoServer = createServer((socket) => {
|
|
902
|
+
socket.on("data", (chunk) => socket.write(Buffer.from(`cleanup:${chunk.toString("utf8")}`)))
|
|
903
|
+
})
|
|
904
|
+
const targetPort = await listen(echoServer)
|
|
905
|
+
|
|
906
|
+
const users = new InMemoryUserStore()
|
|
907
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
908
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
909
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
910
|
+
const relay = await startTcpRelay({
|
|
911
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
912
|
+
tokens,
|
|
913
|
+
sessions: new SessionStore({now: () => 1000})
|
|
914
|
+
})
|
|
915
|
+
|
|
916
|
+
const socket = new SocketductSocket({
|
|
917
|
+
relayHost: "127.0.0.1",
|
|
918
|
+
relayPort: relay.port,
|
|
919
|
+
token: token.value,
|
|
920
|
+
sessionName: "spool cleanup on destroy",
|
|
921
|
+
target: {host: "127.0.0.1", port: targetPort},
|
|
922
|
+
spool,
|
|
923
|
+
reconnectDelayMs: 10
|
|
924
|
+
})
|
|
925
|
+
|
|
926
|
+
socket.connect()
|
|
927
|
+
await new Promise((resolve, reject) => {
|
|
928
|
+
socket.once("error", reject)
|
|
929
|
+
socket.once("connect", () => {
|
|
930
|
+
socket.off("error", reject)
|
|
931
|
+
resolve(undefined)
|
|
932
|
+
})
|
|
933
|
+
})
|
|
934
|
+
socket.write("hello")
|
|
935
|
+
assert.equal((await readOnce(socket)).toString("utf8"), "cleanup:hello")
|
|
936
|
+
|
|
937
|
+
const closePromise = new Promise((resolve, hookReject) => {
|
|
938
|
+
socket.on("error", hookReject)
|
|
939
|
+
socket.on("close", () => {
|
|
940
|
+
socket.off("error", hookReject)
|
|
941
|
+
resolve(undefined)
|
|
942
|
+
})
|
|
943
|
+
})
|
|
944
|
+
socket.destroy()
|
|
945
|
+
await closePromise
|
|
946
|
+
|
|
947
|
+
const {readdir} = await import("node:fs/promises")
|
|
948
|
+
const entries = await readdir(rootDir)
|
|
949
|
+
assert.deepEqual(entries, [])
|
|
950
|
+
|
|
951
|
+
await relay.close()
|
|
952
|
+
await new Promise((resolve, reject) => {
|
|
953
|
+
echoServer.close((error) => {
|
|
954
|
+
if (error) {
|
|
955
|
+
reject(error)
|
|
956
|
+
return
|
|
957
|
+
}
|
|
958
|
+
resolve(undefined)
|
|
959
|
+
})
|
|
960
|
+
})
|
|
961
|
+
await rm(rootDir, {recursive: true, force: true})
|
|
962
|
+
})
|
|
963
|
+
|
|
964
|
+
test("SocketductSocket end cleans up spool directory before finish event", async () => {
|
|
965
|
+
const {rootDir, spool} = await createTestSpool()
|
|
966
|
+
const echoServer = createServer((socket) => {
|
|
967
|
+
socket.on("data", (chunk) => socket.write(Buffer.from(`endclean:${chunk.toString("utf8")}`)))
|
|
968
|
+
})
|
|
969
|
+
const targetPort = await listen(echoServer)
|
|
970
|
+
|
|
971
|
+
const users = new InMemoryUserStore()
|
|
972
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
973
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
974
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
975
|
+
const relay = await startTcpRelay({
|
|
976
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
977
|
+
tokens,
|
|
978
|
+
sessions: new SessionStore({now: () => 1000})
|
|
979
|
+
})
|
|
980
|
+
|
|
981
|
+
const socket = new SocketductSocket({
|
|
982
|
+
relayHost: "127.0.0.1",
|
|
983
|
+
relayPort: relay.port,
|
|
984
|
+
token: token.value,
|
|
985
|
+
sessionName: "spool cleanup on end",
|
|
986
|
+
target: {host: "127.0.0.1", port: targetPort},
|
|
987
|
+
spool,
|
|
988
|
+
reconnectDelayMs: 10
|
|
989
|
+
})
|
|
990
|
+
|
|
991
|
+
socket.connect()
|
|
992
|
+
await new Promise((resolve, reject) => {
|
|
993
|
+
socket.once("error", reject)
|
|
994
|
+
socket.once("connect", () => {
|
|
995
|
+
socket.off("error", reject)
|
|
996
|
+
resolve(undefined)
|
|
997
|
+
})
|
|
998
|
+
})
|
|
999
|
+
socket.write("endtest")
|
|
1000
|
+
|
|
1001
|
+
const finishPromise = new Promise((resolve, hookReject) => {
|
|
1002
|
+
socket.on("error", hookReject)
|
|
1003
|
+
socket.on("finish", () => {
|
|
1004
|
+
socket.off("error", hookReject)
|
|
1005
|
+
resolve(undefined)
|
|
1006
|
+
})
|
|
1007
|
+
})
|
|
1008
|
+
socket.end()
|
|
1009
|
+
await finishPromise
|
|
1010
|
+
|
|
1011
|
+
const {readdir: readdir2} = await import("node:fs/promises")
|
|
1012
|
+
const entries2 = await readdir2(rootDir)
|
|
1013
|
+
assert.deepEqual(entries2, [])
|
|
1014
|
+
|
|
1015
|
+
socket.destroy()
|
|
1016
|
+
await relay.close()
|
|
1017
|
+
await new Promise((resolve, reject) => {
|
|
1018
|
+
echoServer.close((error) => {
|
|
1019
|
+
if (error) {
|
|
1020
|
+
reject(error)
|
|
1021
|
+
return
|
|
1022
|
+
}
|
|
1023
|
+
resolve(undefined)
|
|
1024
|
+
})
|
|
1025
|
+
})
|
|
1026
|
+
await rm(rootDir, {recursive: true, force: true})
|
|
1027
|
+
})
|
|
1028
|
+
|
|
1029
|
+
test("connectSocketduct forwards maxBytes to spool storage", async () => {
|
|
1030
|
+
const rootDir2 = await mkdtemp(join(tmpdir(), "socketduct-socket-test-"))
|
|
1031
|
+
const echoServer = createServer((socket) => {
|
|
1032
|
+
socket.on("data", (chunk) => socket.write(Buffer.from(`maxbytes:${chunk.toString("utf8")}`)))
|
|
1033
|
+
})
|
|
1034
|
+
const targetPort = await listen(echoServer)
|
|
1035
|
+
|
|
1036
|
+
const users = new InMemoryUserStore()
|
|
1037
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
1038
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
1039
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
1040
|
+
const relay = await startTcpRelay({
|
|
1041
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
1042
|
+
tokens,
|
|
1043
|
+
sessions: new SessionStore({now: () => 1000})
|
|
1044
|
+
})
|
|
1045
|
+
|
|
1046
|
+
const socket = connectSocketduct({
|
|
1047
|
+
relay: {host: "127.0.0.1", port: relay.port, token: token.value},
|
|
1048
|
+
sessionName: "public maxBytes",
|
|
1049
|
+
target: {host: "127.0.0.1", port: targetPort},
|
|
1050
|
+
spoolDirectory: rootDir2,
|
|
1051
|
+
maxBytes: 50,
|
|
1052
|
+
reconnect: {initialDelayMs: 10}
|
|
1053
|
+
})
|
|
1054
|
+
|
|
1055
|
+
await new Promise((resolve, reject) => {
|
|
1056
|
+
socket.once("error", reject)
|
|
1057
|
+
socket.once("connect", () => {
|
|
1058
|
+
socket.off("error", reject)
|
|
1059
|
+
resolve(undefined)
|
|
1060
|
+
})
|
|
1061
|
+
})
|
|
1062
|
+
|
|
1063
|
+
socket.write(Buffer.alloc(60))
|
|
1064
|
+
const errorPromise = new Promise((resolve) => {
|
|
1065
|
+
socket.once("error", (err) => resolve(err))
|
|
1066
|
+
})
|
|
1067
|
+
const timeout = new Promise((resolve) => setTimeout(() => resolve(null), 500))
|
|
1068
|
+
const result = await Promise.race([errorPromise, timeout])
|
|
1069
|
+
assert.ok(result instanceof Error, "Expected spool quota error to surface as an error event")
|
|
1070
|
+
|
|
1071
|
+
socket.destroy()
|
|
1072
|
+
await relay.close()
|
|
1073
|
+
await new Promise((resolve, reject) => {
|
|
1074
|
+
echoServer.close((error) => {
|
|
1075
|
+
if (error) {
|
|
1076
|
+
reject(error)
|
|
1077
|
+
return
|
|
1078
|
+
}
|
|
1079
|
+
resolve(undefined)
|
|
1080
|
+
})
|
|
1081
|
+
})
|
|
1082
|
+
await rm(rootDir2, {recursive: true, force: true})
|
|
1083
|
+
})
|
|
1084
|
+
|
|
1085
|
+
test("createSocketductConnectionFactory forwards maxAgeMs to factory options", async () => {
|
|
1086
|
+
const {createSocketductConnectionFactory} = await import("../../../src/client/connect.js")
|
|
1087
|
+
|
|
1088
|
+
const spoolDir = await mkdtemp(join(tmpdir(), "socketduct-factory-test-"))
|
|
1089
|
+
try {
|
|
1090
|
+
const factory = createSocketductConnectionFactory({
|
|
1091
|
+
relay: {host: "127.0.0.1", port: 9999, token: "test-token"},
|
|
1092
|
+
spoolDirectory: spoolDir,
|
|
1093
|
+
maxAgeMs: 1
|
|
1094
|
+
})
|
|
1095
|
+
|
|
1096
|
+
const socket = factory({host: "127.0.0.1", port: 2375})
|
|
1097
|
+
assert.ok(socket instanceof SocketductSocket)
|
|
1098
|
+
|
|
1099
|
+
socket.destroy()
|
|
1100
|
+
} finally {
|
|
1101
|
+
await rm(spoolDir, {recursive: true, force: true})
|
|
1102
|
+
}
|
|
1103
|
+
})
|
|
1104
|
+
|
|
1105
|
+
test("SocketductHttpAgent forwards maxBytes to createConnection sockets", async () => {
|
|
1106
|
+
const {SocketductHttpAgent} = await import("../../../src/client/http-agent.js")
|
|
1107
|
+
|
|
1108
|
+
const spoolDir = await mkdtemp(join(tmpdir(), "socketduct-agent-test-"))
|
|
1109
|
+
try {
|
|
1110
|
+
const agent = new SocketductHttpAgent({
|
|
1111
|
+
relay: {host: "127.0.0.1", port: 9999, token: "test-token"},
|
|
1112
|
+
target: {host: "127.0.0.1", port: 2375},
|
|
1113
|
+
spoolDirectory: spoolDir,
|
|
1114
|
+
maxBytes: 200
|
|
1115
|
+
})
|
|
1116
|
+
|
|
1117
|
+
const socket = agent.createConnection({})
|
|
1118
|
+
assert.ok(socket instanceof SocketductSocket)
|
|
1119
|
+
|
|
1120
|
+
socket.destroy()
|
|
1121
|
+
} finally {
|
|
1122
|
+
await rm(spoolDir, {recursive: true, force: true})
|
|
1123
|
+
}
|
|
1124
|
+
})
|