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,518 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import {createConnection, createServer} from "node:net"
|
|
3
|
+
import test from "node:test"
|
|
4
|
+
|
|
5
|
+
import {InMemoryUserStore, login, TokenStore} from "../../src/auth/user-store.js"
|
|
6
|
+
import {connectSocketduct} from "../../src/client/socketduct-socket.js"
|
|
7
|
+
import {encodeCompactData, encodeFrame, FrameDecoder} from "../../src/protocol/frame.js"
|
|
8
|
+
import {startTcpRelay} from "../../src/proxy/tcp-relay.js"
|
|
9
|
+
import {SessionStore} from "../../src/sessions/session.js"
|
|
10
|
+
import {mkdtemp, rm} from "node:fs/promises"
|
|
11
|
+
import {tmpdir} from "node:os"
|
|
12
|
+
import {join} from "node:path"
|
|
13
|
+
import {EventEmitter} from "node:events"
|
|
14
|
+
|
|
15
|
+
/** @typedef {ReturnType<FrameDecoder["push"]>[number]} DecodedFrame */
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {import('node:net').Server} server - Server to bind.
|
|
19
|
+
* @param {number} [port] - Requested port.
|
|
20
|
+
* @returns {Promise<number>} Bound port.
|
|
21
|
+
*/
|
|
22
|
+
async function listen(server, port = 0) {
|
|
23
|
+
await new Promise((resolve, reject) => {
|
|
24
|
+
server.once("error", reject)
|
|
25
|
+
server.listen(port, "127.0.0.1", () => {
|
|
26
|
+
server.off("error", reject)
|
|
27
|
+
resolve(undefined)
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
const address = server.address()
|
|
31
|
+
if (address === null || typeof address === "string") throw new Error("server did not bind")
|
|
32
|
+
return address.port
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {import('node:net').Server} server - Server to close.
|
|
37
|
+
* @returns {Promise<void>}
|
|
38
|
+
*/
|
|
39
|
+
async function closeServer(server) {
|
|
40
|
+
if (!server.listening) return
|
|
41
|
+
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve(undefined)))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** @returns {Promise<number>} An unused loopback port. */
|
|
45
|
+
async function reservePort() {
|
|
46
|
+
const server = createServer()
|
|
47
|
+
const port = await listen(server)
|
|
48
|
+
await closeServer(server)
|
|
49
|
+
return port
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {() => boolean} predicate - Completion predicate.
|
|
54
|
+
* @param {number} [timeoutMs] - Maximum wait.
|
|
55
|
+
* @returns {Promise<void>}
|
|
56
|
+
*/
|
|
57
|
+
async function waitUntil(predicate, timeoutMs = 1_000) {
|
|
58
|
+
const deadline = Date.now() + timeoutMs
|
|
59
|
+
while (!predicate()) {
|
|
60
|
+
if (Date.now() >= deadline) throw new Error("timed out waiting for condition")
|
|
61
|
+
await new Promise((resolve) => setTimeout(resolve, 2))
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {import('node:net').Socket} socket - Socket to await.
|
|
67
|
+
* @returns {Promise<void>}
|
|
68
|
+
*/
|
|
69
|
+
async function waitConnect(socket) {
|
|
70
|
+
await new Promise((resolve, reject) => {
|
|
71
|
+
socket.once("error", reject)
|
|
72
|
+
socket.once("connect", () => {
|
|
73
|
+
socket.off("error", reject)
|
|
74
|
+
resolve(undefined)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {import('node:net').Socket} socket - Socket to await.
|
|
81
|
+
* @returns {Promise<void>}
|
|
82
|
+
*/
|
|
83
|
+
async function waitClose(socket) {
|
|
84
|
+
if (socket.destroyed) return
|
|
85
|
+
await new Promise((resolve) => socket.once("close", resolve))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @returns {Promise<{sessions: SessionStore, token: import('../../src/auth/user-store.js').AuthToken, tokens: TokenStore}>} Authenticated stores. */
|
|
89
|
+
async function authenticatedStores() {
|
|
90
|
+
const users = new InMemoryUserStore()
|
|
91
|
+
const tokens = new TokenStore({now: () => 1_000, tokenTtlMs: 60_000})
|
|
92
|
+
await users.createUser({username: "retry-user", password: "correct horse battery staple"})
|
|
93
|
+
const token = await login(users, tokens, {username: "retry-user", password: "correct horse battery staple"})
|
|
94
|
+
return {sessions: new SessionStore({now: () => 1_000}), token, tokens}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @param {import('node:net').Socket} socket
|
|
99
|
+
* @param {FrameDecoder} decoder
|
|
100
|
+
* @returns {{frames: DecodedFrame[], waitFor: (predicate: (frame: DecodedFrame) => boolean) => Promise<DecodedFrame>}} Frame collector.
|
|
101
|
+
*/
|
|
102
|
+
function collectFrames(socket, decoder) {
|
|
103
|
+
/** @type {DecodedFrame[]} */
|
|
104
|
+
const frames = []
|
|
105
|
+
/** @type {Set<() => void>} */
|
|
106
|
+
const changes = new Set()
|
|
107
|
+
socket.on("data", (chunk) => {
|
|
108
|
+
frames.push(...decoder.push(chunk))
|
|
109
|
+
for (const change of changes) change()
|
|
110
|
+
})
|
|
111
|
+
return {
|
|
112
|
+
frames,
|
|
113
|
+
waitFor: async (predicate) => {
|
|
114
|
+
const deadline = Date.now() + 1_000
|
|
115
|
+
while (true) {
|
|
116
|
+
const frame = frames.find(predicate)
|
|
117
|
+
if (frame !== undefined) return frame
|
|
118
|
+
const remaining = deadline - Date.now()
|
|
119
|
+
if (remaining <= 0) throw new Error("timed out waiting for frame")
|
|
120
|
+
await new Promise((resolve, reject) => {
|
|
121
|
+
const changed = () => {
|
|
122
|
+
clearTimeout(timer)
|
|
123
|
+
changes.delete(changed)
|
|
124
|
+
resolve(undefined)
|
|
125
|
+
}
|
|
126
|
+
const timer = setTimeout(() => {
|
|
127
|
+
changes.delete(changed)
|
|
128
|
+
reject(new Error("timed out waiting for frame"))
|
|
129
|
+
}, remaining)
|
|
130
|
+
changes.add(changed)
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
class HangingTargetSocket extends EventEmitter {
|
|
138
|
+
constructor() {
|
|
139
|
+
super()
|
|
140
|
+
this.destroyed = false
|
|
141
|
+
this.destroyCount = 0
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
destroy() {
|
|
145
|
+
this.destroyed = true
|
|
146
|
+
this.destroyCount += 1
|
|
147
|
+
return this
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
test("authenticated create stays pending without an error and the same request succeeds when the target starts", async () => {
|
|
152
|
+
const targetPort = await reservePort()
|
|
153
|
+
const {sessions, token, tokens} = await authenticatedStores()
|
|
154
|
+
const relay = await startTcpRelay({
|
|
155
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
156
|
+
handshakeTimeoutMs: 10,
|
|
157
|
+
initialTargetRetryDelayMs: 30,
|
|
158
|
+
initialTargetRetryMaxDelayMs: 30,
|
|
159
|
+
sessions,
|
|
160
|
+
tokens
|
|
161
|
+
})
|
|
162
|
+
const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
|
|
163
|
+
const collector = collectFrames(tunnel, new FrameDecoder({compactDirection: "target-to-client"}))
|
|
164
|
+
const targetServer = createServer((socket) => socket.on("data", (chunk) => socket.write(chunk)))
|
|
165
|
+
try {
|
|
166
|
+
await waitConnect(tunnel)
|
|
167
|
+
tunnel.write(encodeFrame({
|
|
168
|
+
type: "session.create", token: token.value, name: "pending-create", identifier: "pending-create",
|
|
169
|
+
target: {host: "127.0.0.1", port: targetPort}, framing: [2]
|
|
170
|
+
}))
|
|
171
|
+
|
|
172
|
+
await waitUntil(() => sessions.get("pending-create") !== undefined)
|
|
173
|
+
assert.equal(sessions.get("pending-create")?.state, "creating")
|
|
174
|
+
await waitUntil(() => relay.status().targetRetryTimers === 1)
|
|
175
|
+
assert.equal(collector.frames.length, 0, "initial refusal must not announce or emit an error")
|
|
176
|
+
|
|
177
|
+
// Deliberately cross the shorter authentication deadline while a retry is pending.
|
|
178
|
+
await new Promise((resolve) => setTimeout(resolve, 15))
|
|
179
|
+
assert.equal(collector.frames.some((frame) => frame.header.code === "HANDSHAKE_TIMEOUT"), false)
|
|
180
|
+
|
|
181
|
+
await listen(targetServer, targetPort)
|
|
182
|
+
const created = await collector.waitFor((frame) => frame.header.type === "session.created")
|
|
183
|
+
assert.equal(created.header.identifier, "pending-create")
|
|
184
|
+
assert.equal(created.header.state, "active")
|
|
185
|
+
assert.equal(sessions.get("pending-create")?.state, "active")
|
|
186
|
+
assert.equal(collector.frames.filter((frame) => frame.header.type === "session.created").length, 1)
|
|
187
|
+
} finally {
|
|
188
|
+
tunnel.destroy()
|
|
189
|
+
await relay.close()
|
|
190
|
+
await closeServer(targetServer)
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test("SocketductSocket does not deliver queued application bytes before readiness", async () => {
|
|
195
|
+
const targetPort = await reservePort()
|
|
196
|
+
const spoolDirectory = await mkdtemp(join(tmpdir(), "socketduct-initial-readiness-"))
|
|
197
|
+
const {sessions, token, tokens} = await authenticatedStores()
|
|
198
|
+
const relay = await startTcpRelay({
|
|
199
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
200
|
+
initialTargetRetryDelayMs: 10,
|
|
201
|
+
initialTargetRetryMaxDelayMs: 20,
|
|
202
|
+
sessions,
|
|
203
|
+
tokens
|
|
204
|
+
})
|
|
205
|
+
let clientConnected = false
|
|
206
|
+
let bytesBeforeConnect = 0
|
|
207
|
+
const targetServer = createServer((socket) => {
|
|
208
|
+
socket.on("data", (chunk) => {
|
|
209
|
+
if (!clientConnected) bytesBeforeConnect += chunk.length
|
|
210
|
+
socket.write(chunk)
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
const client = connectSocketduct({
|
|
214
|
+
relay: {host: "127.0.0.1", port: relay.port, token: token.value},
|
|
215
|
+
sessionName: "queued-before-ready",
|
|
216
|
+
spoolDirectory,
|
|
217
|
+
target: {host: "127.0.0.1", port: targetPort}
|
|
218
|
+
})
|
|
219
|
+
client.on("error", () => {})
|
|
220
|
+
client.once("connect", () => { clientConnected = true })
|
|
221
|
+
client.write("queued-application-bytes")
|
|
222
|
+
try {
|
|
223
|
+
await waitUntil(() => relay.status().targetRetryTimers === 1)
|
|
224
|
+
assert.equal(client.connecting, true)
|
|
225
|
+
assert.equal(bytesBeforeConnect, 0)
|
|
226
|
+
await listen(targetServer, targetPort)
|
|
227
|
+
const echoed = await new Promise((resolve, reject) => {
|
|
228
|
+
const timer = setTimeout(() => reject(new Error("timed out waiting for queued echo")), 1_000)
|
|
229
|
+
client.once("data", (chunk) => {
|
|
230
|
+
clearTimeout(timer)
|
|
231
|
+
resolve(chunk.toString("utf8"))
|
|
232
|
+
})
|
|
233
|
+
})
|
|
234
|
+
assert.equal(echoed, "queued-application-bytes")
|
|
235
|
+
assert.equal(bytesBeforeConnect, 0)
|
|
236
|
+
} finally {
|
|
237
|
+
client.destroy()
|
|
238
|
+
await relay.close()
|
|
239
|
+
await closeServer(targetServer)
|
|
240
|
+
await rm(spoolDirectory, {recursive: true, force: true})
|
|
241
|
+
}
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test("relay rejects stream frames while an authenticated create is not ready", async () => {
|
|
245
|
+
const targetPort = await reservePort()
|
|
246
|
+
const {sessions, token, tokens} = await authenticatedStores()
|
|
247
|
+
const relay = await startTcpRelay({
|
|
248
|
+
authorizeTarget: () => true,
|
|
249
|
+
initialTargetRetryDelayMs: 50,
|
|
250
|
+
initialTargetRetryMaxDelayMs: 50,
|
|
251
|
+
sessions,
|
|
252
|
+
tokens
|
|
253
|
+
})
|
|
254
|
+
const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
|
|
255
|
+
const collector = collectFrames(tunnel, new FrameDecoder({compactDirection: "target-to-client"}))
|
|
256
|
+
try {
|
|
257
|
+
await waitConnect(tunnel)
|
|
258
|
+
tunnel.write(encodeFrame({
|
|
259
|
+
type: "session.create", token: token.value, name: "premature-data", identifier: "premature-data",
|
|
260
|
+
target: {host: "127.0.0.1", port: targetPort}, framing: [2]
|
|
261
|
+
}))
|
|
262
|
+
await waitUntil(() => sessions.get("premature-data") !== undefined)
|
|
263
|
+
tunnel.write(encodeCompactData({sequence: 4, payload: Buffer.from("nope")}))
|
|
264
|
+
const violation = await collector.waitFor((frame) => frame.header.type === "error")
|
|
265
|
+
assert.equal(violation.header.code, "PROTOCOL_VIOLATION")
|
|
266
|
+
assert.equal(violation.header.retryable, false)
|
|
267
|
+
await waitUntil(() => sessions.get("premature-data") === undefined)
|
|
268
|
+
} finally {
|
|
269
|
+
tunnel.destroy()
|
|
270
|
+
await relay.close()
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
test("closing an unannounced tunnel deletes it and stale work cannot activate a reused identifier", async () => {
|
|
275
|
+
const targetPort = await reservePort()
|
|
276
|
+
const {sessions, token, tokens} = await authenticatedStores()
|
|
277
|
+
const relay = await startTcpRelay({
|
|
278
|
+
authorizeTarget: () => true,
|
|
279
|
+
initialTargetRetryDelayMs: 50,
|
|
280
|
+
initialTargetRetryMaxDelayMs: 50,
|
|
281
|
+
sessions,
|
|
282
|
+
tokens
|
|
283
|
+
})
|
|
284
|
+
const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
|
|
285
|
+
let targetConnections = 0
|
|
286
|
+
const targetServer = createServer(() => { targetConnections += 1 })
|
|
287
|
+
await waitConnect(tunnel)
|
|
288
|
+
tunnel.write(encodeFrame({
|
|
289
|
+
type: "session.create", token: token.value, name: "cancel-pending", identifier: "cancel-pending",
|
|
290
|
+
target: {host: "127.0.0.1", port: targetPort}, framing: [2]
|
|
291
|
+
}))
|
|
292
|
+
await waitUntil(() => relay.status().targetRetryTimers === 1)
|
|
293
|
+
tunnel.destroy()
|
|
294
|
+
await waitClose(tunnel)
|
|
295
|
+
await waitUntil(() => relay.status().sessions === 0)
|
|
296
|
+
assert.equal(sessions.get("cancel-pending"), undefined)
|
|
297
|
+
assert.deepEqual(relay.status(), {
|
|
298
|
+
activeTunnels: 0, activeTargets: 0, sessions: 0, attachedSessions: 0,
|
|
299
|
+
detachedSessions: 0, bufferedTargetBytes: 0, targetConnectionAttempts: 0, targetRetryTimers: 0
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
const replacementTunnel = createConnection({host: "127.0.0.1", port: relay.port})
|
|
303
|
+
const replacementCollector = collectFrames(replacementTunnel, new FrameDecoder({compactDirection: "target-to-client"}))
|
|
304
|
+
try {
|
|
305
|
+
await listen(targetServer, targetPort)
|
|
306
|
+
await waitConnect(replacementTunnel)
|
|
307
|
+
replacementTunnel.write(encodeFrame({
|
|
308
|
+
type: "session.create", token: token.value, name: "replacement", identifier: "cancel-pending",
|
|
309
|
+
target: {host: "127.0.0.1", port: targetPort}, framing: [2]
|
|
310
|
+
}))
|
|
311
|
+
await replacementCollector.waitFor((frame) => frame.header.type === "session.created")
|
|
312
|
+
assert.equal(sessions.get("cancel-pending")?.state, "active")
|
|
313
|
+
assert.equal(targetConnections, 1)
|
|
314
|
+
} finally {
|
|
315
|
+
replacementTunnel.destroy()
|
|
316
|
+
await relay.close()
|
|
317
|
+
await closeServer(targetServer)
|
|
318
|
+
}
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
test("relay close cancels all pending target attempts, retry timers, and creating sessions", async () => {
|
|
322
|
+
const targetPort = await reservePort()
|
|
323
|
+
const {sessions, token, tokens} = await authenticatedStores()
|
|
324
|
+
const relay = await startTcpRelay({
|
|
325
|
+
authorizeTarget: () => true,
|
|
326
|
+
initialTargetRetryDelayMs: 100,
|
|
327
|
+
initialTargetRetryMaxDelayMs: 100,
|
|
328
|
+
sessions,
|
|
329
|
+
tokens
|
|
330
|
+
})
|
|
331
|
+
const tunnels = Array.from({length: 3}, (_, index) => {
|
|
332
|
+
const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
|
|
333
|
+
tunnel.once("connect", () => tunnel.write(encodeFrame({
|
|
334
|
+
type: "session.create", token: token.value, name: `relay-close-${index}`, identifier: `relay-close-${index}`,
|
|
335
|
+
target: {host: "127.0.0.1", port: targetPort}, framing: [2]
|
|
336
|
+
})))
|
|
337
|
+
tunnel.on("error", () => {})
|
|
338
|
+
return tunnel
|
|
339
|
+
})
|
|
340
|
+
await waitUntil(() => relay.status().targetRetryTimers === tunnels.length)
|
|
341
|
+
await relay.close()
|
|
342
|
+
assert.equal(relay.status().targetRetryTimers, 0)
|
|
343
|
+
assert.equal(relay.status().targetConnectionAttempts, 0)
|
|
344
|
+
assert.equal(relay.status().sessions, 0)
|
|
345
|
+
for (let index = 0; index < tunnels.length; index += 1) {
|
|
346
|
+
assert.equal(sessions.get(`relay-close-${index}`), undefined)
|
|
347
|
+
}
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
test("target closure after announcement is terminal and never starts target recovery", async () => {
|
|
351
|
+
let targetConnections = 0
|
|
352
|
+
/** @type {import('node:net').Socket | undefined} */
|
|
353
|
+
let targetSocket
|
|
354
|
+
const targetServer = createServer((socket) => {
|
|
355
|
+
targetConnections += 1
|
|
356
|
+
targetSocket = socket
|
|
357
|
+
socket.on("error", () => {})
|
|
358
|
+
})
|
|
359
|
+
const targetPort = await listen(targetServer)
|
|
360
|
+
const {sessions, token, tokens} = await authenticatedStores()
|
|
361
|
+
const relay = await startTcpRelay({
|
|
362
|
+
authorizeTarget: () => true,
|
|
363
|
+
initialTargetRetryDelayMs: 10,
|
|
364
|
+
initialTargetRetryMaxDelayMs: 20,
|
|
365
|
+
sessions,
|
|
366
|
+
tokens
|
|
367
|
+
})
|
|
368
|
+
const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
|
|
369
|
+
const collector = collectFrames(tunnel, new FrameDecoder({compactDirection: "target-to-client"}))
|
|
370
|
+
try {
|
|
371
|
+
await waitConnect(tunnel)
|
|
372
|
+
tunnel.write(encodeFrame({
|
|
373
|
+
type: "session.create", token: token.value, name: "terminal-target", identifier: "terminal-target",
|
|
374
|
+
target: {host: "127.0.0.1", port: targetPort}, framing: [2]
|
|
375
|
+
}))
|
|
376
|
+
await collector.waitFor((frame) => frame.header.type === "session.created")
|
|
377
|
+
assert.ok(targetSocket !== undefined)
|
|
378
|
+
targetSocket.destroy()
|
|
379
|
+
await collector.waitFor((frame) => frame.header.type === "session.close" || frame.header.code === "TARGET_CLOSED")
|
|
380
|
+
assert.equal(relay.status().targetRetryTimers, 0)
|
|
381
|
+
assert.equal(targetConnections, 1)
|
|
382
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
383
|
+
assert.equal(targetConnections, 1, "an announced target must never reconnect")
|
|
384
|
+
} finally {
|
|
385
|
+
tunnel.destroy()
|
|
386
|
+
await relay.close()
|
|
387
|
+
await closeServer(targetServer)
|
|
388
|
+
}
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
test("repeated pending creates and aborts return to a zero-resource baseline", async () => {
|
|
392
|
+
const targetPort = await reservePort()
|
|
393
|
+
const {sessions, token, tokens} = await authenticatedStores()
|
|
394
|
+
const relay = await startTcpRelay({
|
|
395
|
+
authorizeTarget: () => true,
|
|
396
|
+
initialTargetRetryDelayMs: 20,
|
|
397
|
+
initialTargetRetryMaxDelayMs: 20,
|
|
398
|
+
sessions,
|
|
399
|
+
tokens
|
|
400
|
+
})
|
|
401
|
+
try {
|
|
402
|
+
for (let index = 0; index < 5; index += 1) {
|
|
403
|
+
const identifier = `bounded-pending-${index}`
|
|
404
|
+
const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
|
|
405
|
+
tunnel.on("error", () => {})
|
|
406
|
+
await waitConnect(tunnel)
|
|
407
|
+
tunnel.write(encodeFrame({
|
|
408
|
+
type: "session.create", token: token.value, name: identifier, identifier,
|
|
409
|
+
target: {host: "127.0.0.1", port: targetPort}, framing: [2]
|
|
410
|
+
}))
|
|
411
|
+
await waitUntil(() => relay.status().targetRetryTimers === 1)
|
|
412
|
+
tunnel.destroy()
|
|
413
|
+
await waitClose(tunnel)
|
|
414
|
+
await waitUntil(() => relay.status().sessions === 0)
|
|
415
|
+
assert.equal(sessions.get(identifier), undefined)
|
|
416
|
+
assert.equal(relay.status().targetRetryTimers, 0)
|
|
417
|
+
assert.equal(relay.status().targetConnectionAttempts, 0)
|
|
418
|
+
}
|
|
419
|
+
} finally {
|
|
420
|
+
await relay.close()
|
|
421
|
+
}
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
test("a hanging initial target attempt times out, is destroyed, and enters normal backoff", async () => {
|
|
425
|
+
const {sessions, token, tokens} = await authenticatedStores()
|
|
426
|
+
/** @type {HangingTargetSocket[]} */
|
|
427
|
+
const attempts = []
|
|
428
|
+
const relay = await startTcpRelay({
|
|
429
|
+
authorizeTarget: () => true,
|
|
430
|
+
createTargetConnection: () => {
|
|
431
|
+
const attempt = new HangingTargetSocket()
|
|
432
|
+
attempts.push(attempt)
|
|
433
|
+
return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (attempt))
|
|
434
|
+
},
|
|
435
|
+
initialTargetConnectTimeoutMs: 10,
|
|
436
|
+
initialTargetRetryDelayMs: 10,
|
|
437
|
+
initialTargetRetryMaxDelayMs: 10,
|
|
438
|
+
sessions,
|
|
439
|
+
tokens
|
|
440
|
+
})
|
|
441
|
+
const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
|
|
442
|
+
try {
|
|
443
|
+
await waitConnect(tunnel)
|
|
444
|
+
tunnel.write(encodeFrame({
|
|
445
|
+
type: "session.create", token: token.value, name: "hanging-target", identifier: "hanging-target",
|
|
446
|
+
target: {host: "fixed-target.invalid", port: 2375}, framing: [2]
|
|
447
|
+
}))
|
|
448
|
+
await waitUntil(() => attempts.length === 2)
|
|
449
|
+
assert.equal(attempts[0]?.destroyCount, 1)
|
|
450
|
+
assert.equal(attempts[0]?.listenerCount("connect"), 0)
|
|
451
|
+
assert.equal(attempts[0]?.listenerCount("error"), 0)
|
|
452
|
+
assert.equal(attempts[0]?.listenerCount("close"), 0)
|
|
453
|
+
assert.equal(relay.status().targetConnectionAttempts, 1)
|
|
454
|
+
assert.equal(sessions.get("hanging-target")?.state, "creating")
|
|
455
|
+
|
|
456
|
+
tunnel.destroy()
|
|
457
|
+
await relay.closeActiveTunnel()
|
|
458
|
+
await waitUntil(() => relay.status().sessions === 0)
|
|
459
|
+
assert.equal(attempts[1]?.destroyCount, 1)
|
|
460
|
+
assert.equal(attempts[1]?.listenerCount("connect"), 0)
|
|
461
|
+
assert.equal(attempts[1]?.listenerCount("error"), 0)
|
|
462
|
+
assert.equal(attempts[1]?.listenerCount("close"), 0)
|
|
463
|
+
assert.deepEqual(relay.status(), {
|
|
464
|
+
activeTunnels: 0, activeTargets: 0, sessions: 0, attachedSessions: 0,
|
|
465
|
+
detachedSessions: 0, bufferedTargetBytes: 0, targetConnectionAttempts: 0, targetRetryTimers: 0
|
|
466
|
+
})
|
|
467
|
+
} finally {
|
|
468
|
+
tunnel.destroy()
|
|
469
|
+
await relay.close()
|
|
470
|
+
}
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
test("relay validates the initial target connect timeout before opening resources", async () => {
|
|
474
|
+
const {sessions, tokens} = await authenticatedStores()
|
|
475
|
+
await assert.rejects(
|
|
476
|
+
() => startTcpRelay({
|
|
477
|
+
authorizeTarget: () => true,
|
|
478
|
+
initialTargetConnectTimeoutMs: 0,
|
|
479
|
+
sessions,
|
|
480
|
+
tokens
|
|
481
|
+
}),
|
|
482
|
+
/initialTargetConnectTimeoutMs must be a positive integer/
|
|
483
|
+
)
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
test("relay close cancels a hanging target attempt and its connect timeout", async () => {
|
|
487
|
+
const {sessions, token, tokens} = await authenticatedStores()
|
|
488
|
+
/** @type {HangingTargetSocket[]} */
|
|
489
|
+
const attempts = []
|
|
490
|
+
const relay = await startTcpRelay({
|
|
491
|
+
authorizeTarget: () => true,
|
|
492
|
+
createTargetConnection: () => {
|
|
493
|
+
const attempt = new HangingTargetSocket()
|
|
494
|
+
attempts.push(attempt)
|
|
495
|
+
return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (attempt))
|
|
496
|
+
},
|
|
497
|
+
initialTargetConnectTimeoutMs: 60_000,
|
|
498
|
+
sessions,
|
|
499
|
+
tokens
|
|
500
|
+
})
|
|
501
|
+
const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
|
|
502
|
+
tunnel.on("error", () => {})
|
|
503
|
+
await waitConnect(tunnel)
|
|
504
|
+
tunnel.write(encodeFrame({
|
|
505
|
+
type: "session.create", token: token.value, name: "relay-close-hanging", identifier: "relay-close-hanging",
|
|
506
|
+
target: {host: "fixed-target.invalid", port: 2375}, framing: [2]
|
|
507
|
+
}))
|
|
508
|
+
await waitUntil(() => attempts.length === 1)
|
|
509
|
+
|
|
510
|
+
await relay.close()
|
|
511
|
+
assert.equal(attempts[0]?.destroyCount, 1)
|
|
512
|
+
assert.equal(attempts[0]?.listenerCount("connect"), 0)
|
|
513
|
+
assert.equal(attempts[0]?.listenerCount("error"), 0)
|
|
514
|
+
assert.equal(attempts[0]?.listenerCount("close"), 0)
|
|
515
|
+
assert.equal(relay.status().targetConnectionAttempts, 0)
|
|
516
|
+
assert.equal(relay.status().targetRetryTimers, 0)
|
|
517
|
+
assert.equal(relay.status().sessions, 0)
|
|
518
|
+
})
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import {describe, expect, it, withTestCleanup} from "@velocious/testing"
|
|
2
|
+
import {execFile} from "node:child_process"
|
|
3
|
+
import {once} from "node:events"
|
|
4
|
+
import {chmod, mkdtemp, readFile, readdir, rm, stat, writeFile} from "node:fs/promises"
|
|
5
|
+
import {createServer} from "node:net"
|
|
6
|
+
import {homedir, tmpdir} from "node:os"
|
|
7
|
+
import {join} from "node:path"
|
|
8
|
+
import {fileURLToPath} from "node:url"
|
|
9
|
+
import {promisify} from "node:util"
|
|
10
|
+
|
|
11
|
+
import {StaticTokenStore} from "../../src/auth/static-token-store.js"
|
|
12
|
+
import {MultiplexedSocketductConnection} from "../../src/client/multiplexed-connection.js"
|
|
13
|
+
import {createJsonlFileLogger} from "../../src/logging/structured-logger.js"
|
|
14
|
+
import {startMultiplexedTcpRelay} from "../../src/proxy/multiplexed-relay.js"
|
|
15
|
+
|
|
16
|
+
const runProcess = promisify(execFile)
|
|
17
|
+
const writerPath = fileURLToPath(new URL("../fixtures/logging/process-writer.mjs", import.meta.url))
|
|
18
|
+
|
|
19
|
+
describe("logging recovery", {timeoutMs: 15000}, () => {
|
|
20
|
+
describe("fresh processes reuse a persistent home-bind log family without changing torn bytes", {timeoutMs: 15_000}, () => {
|
|
21
|
+
for (const maxFiles of [1, 2, 5]) {
|
|
22
|
+
it(`maxFiles=${maxFiles}`, async () => withTestCleanup(async (after) => {
|
|
23
|
+
const root = await mkdtemp(join(homedir(), ".socketduct-jsonl-restart-"))
|
|
24
|
+
after(() => rm(root, {recursive: true, force: true}))
|
|
25
|
+
const active = join(root, "reverse-gateway-1.jsonl")
|
|
26
|
+
const first = await runProcess(process.execPath, [writerPath, root, String(maxFiles), "1024"])
|
|
27
|
+
expect(first.stderr).toBe("")
|
|
28
|
+
expect(JSON.parse(first.stdout).reports).toEqual([])
|
|
29
|
+
await writeFile(active, '{"timestamp":"interrupted', {flag: "a"})
|
|
30
|
+
const torn = await readFile(active)
|
|
31
|
+
const original = await stat(active)
|
|
32
|
+
|
|
33
|
+
const second = await runProcess(process.execPath, [writerPath, root, String(maxFiles), "1024"])
|
|
34
|
+
expect(second.stderr).toBe("")
|
|
35
|
+
const evidencePath = maxFiles === 1 ? active : `${active}.1`
|
|
36
|
+
expect((await readFile(evidencePath)).equals(torn)).toBeTruthy()
|
|
37
|
+
expect((await stat(evidencePath)).ino).toBe(original.ino)
|
|
38
|
+
expect((await stat(evidencePath)).dev).toBe(original.dev)
|
|
39
|
+
if (maxFiles === 1) {
|
|
40
|
+
expect(JSON.parse(second.stdout).reports).toEqual(["Socketduct logger failure (LOGGER_IO_ERROR)\n"])
|
|
41
|
+
} else {
|
|
42
|
+
expect(JSON.parse(second.stdout).reports).toEqual([])
|
|
43
|
+
const contents = await readFile(active, "utf8")
|
|
44
|
+
expect(contents.endsWith("\n")).toBeTruthy()
|
|
45
|
+
expect(contents.trimEnd().split("\n").map((line) => JSON.parse(line).event)).toEqual(["process.started", "runtime.status"])
|
|
46
|
+
}
|
|
47
|
+
const third = await runProcess(process.execPath, [writerPath, root, String(maxFiles), "1024"])
|
|
48
|
+
expect(third.stderr).toBe("")
|
|
49
|
+
expect((await readFile(evidencePath)).equals(torn)).toBeTruthy()
|
|
50
|
+
expect((await readdir(root)).length <= maxFiles).toBeTruthy()
|
|
51
|
+
for (const file of await readdir(root)) {
|
|
52
|
+
const stats = await stat(join(root, file))
|
|
53
|
+
expect(stats.size <= 1024).toBeTruthy()
|
|
54
|
+
expect(stats.mode & 0o777).toBe(0o600)
|
|
55
|
+
}
|
|
56
|
+
expect((await stat(root)).mode & 0o777).toBe(0o700)
|
|
57
|
+
}))
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it("real live and unprobeable PID families survive another process startup", {timeoutMs: 10_000}, async () => withTestCleanup(async (after) => {
|
|
62
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-jsonl-real-pids-"))
|
|
63
|
+
after(() => rm(root, {recursive: true, force: true}))
|
|
64
|
+
const evidence = Buffer.from('{}\n{"timestamp":"interrupted')
|
|
65
|
+
const files = []
|
|
66
|
+
for (const processId of [process.pid, 2 ** 40]) {
|
|
67
|
+
for (const suffix of ["", ".1", ".2"]) {
|
|
68
|
+
const path = join(root, `reverse-gateway-${processId}.jsonl${suffix}`)
|
|
69
|
+
files.push(path)
|
|
70
|
+
await writeFile(path, evidence, {mode: 0o600})
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
expect(process.kill(process.pid, 0)).toBe(true)
|
|
74
|
+
await expect(Promise.resolve().then(() => process.kill(2 ** 40, 0))).rejects.toMatchObject({code: "ERR_INVALID_ARG_TYPE"})
|
|
75
|
+
const result = await runProcess(process.execPath, [writerPath, root, "1", "1024"])
|
|
76
|
+
expect(JSON.parse(result.stdout).reports).toEqual([])
|
|
77
|
+
for (const path of files) expect((await readFile(path)).equals(evidence)).toBeTruthy()
|
|
78
|
+
}))
|
|
79
|
+
|
|
80
|
+
it("authenticated local payload traffic continues during file logger failure and recovery", {timeoutMs: 15_000}, async () => withTestCleanup(async (after) => {
|
|
81
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-jsonl-traffic-"))
|
|
82
|
+
const active = join(root, "embedded-client-1.jsonl")
|
|
83
|
+
const torn = Buffer.from('{}\n{"timestamp":"interrupted')
|
|
84
|
+
await writeFile(active, torn, {mode: 0o600})
|
|
85
|
+
await chmod(root, 0o500)
|
|
86
|
+
/** @type {string[]} */
|
|
87
|
+
const reports = []
|
|
88
|
+
const logger = createJsonlFileLogger({
|
|
89
|
+
directory: root, role: "embedded-client", processId: 1,
|
|
90
|
+
stderr: {write: (/** @type {string} */ message) => reports.push(message)}
|
|
91
|
+
})
|
|
92
|
+
const target = createServer((socket) => socket.on("data", (chunk) => socket.write(chunk)))
|
|
93
|
+
target.listen(0, "127.0.0.1")
|
|
94
|
+
await once(target, "listening")
|
|
95
|
+
const address = target.address()
|
|
96
|
+
if (address === null || typeof address === "string") throw new Error("Expected a bound TCP address")
|
|
97
|
+
const token = "private-fixture-token-not-for-logging"
|
|
98
|
+
const relay = await startMultiplexedTcpRelay({
|
|
99
|
+
tokens: new StaticTokenStore([{username: "fixture", token}]),
|
|
100
|
+
authorizeTarget: ({target: requested}) => requested.host === "127.0.0.1" && requested.port === address.port
|
|
101
|
+
})
|
|
102
|
+
const connection = new MultiplexedSocketductConnection({
|
|
103
|
+
relay: {host: "127.0.0.1", port: relay.port, token}, logger
|
|
104
|
+
})
|
|
105
|
+
after(async () => {
|
|
106
|
+
await connection.close()
|
|
107
|
+
await relay.close()
|
|
108
|
+
await new Promise((resolve) => target.close(resolve))
|
|
109
|
+
await chmod(root, 0o700)
|
|
110
|
+
await rm(root, {recursive: true, force: true})
|
|
111
|
+
})
|
|
112
|
+
const stream = connection.createConnection({host: "127.0.0.1", port: address.port, sessionName: "private-fixture-session"})
|
|
113
|
+
await once(stream, "connect")
|
|
114
|
+
expect(relay.status().authenticatedConnections).toBe(1)
|
|
115
|
+
expect(relay.status().physicalAccepts).toBe(1)
|
|
116
|
+
const payload = Buffer.from("private-fixture-payload\0\xff", "utf8")
|
|
117
|
+
const firstEcho = readPayload(stream, payload.length)
|
|
118
|
+
stream.write(payload)
|
|
119
|
+
expect((await firstEcho).equals(payload)).toBeTrue()
|
|
120
|
+
expect((await readFile(active)).equals(torn)).toBeTruthy()
|
|
121
|
+
expect(reports).toEqual(["Socketduct logger failure (LOGGER_IO_ERROR)\n"])
|
|
122
|
+
|
|
123
|
+
await chmod(root, 0o700)
|
|
124
|
+
const second = connection.createConnection({host: "127.0.0.1", port: address.port, sessionName: "private-fixture-session-two"})
|
|
125
|
+
await once(second, "connect")
|
|
126
|
+
const secondEcho = readPayload(second, payload.length)
|
|
127
|
+
second.write(payload)
|
|
128
|
+
expect((await secondEcho).equals(payload)).toBeTrue()
|
|
129
|
+
expect(relay.status().authenticatedConnections).toBe(1)
|
|
130
|
+
expect(relay.status().physicalAccepts).toBe(1)
|
|
131
|
+
expect((await readFile(`${active}.1`)).equals(torn)).toBeTruthy()
|
|
132
|
+
const contents = await readFile(active, "utf8")
|
|
133
|
+
expect(contents.endsWith("\n")).toBeTruthy()
|
|
134
|
+
const entries = contents.trimEnd().split("\n").map((line) => JSON.parse(line))
|
|
135
|
+
expect(entries.some(({event}) => event === "virtual_stream.opened")).toBeTruthy()
|
|
136
|
+
expect(contents).not.toMatch(/private-fixture|127\.0\.0\.1/)
|
|
137
|
+
expect(reports).toEqual(["Socketduct logger failure (LOGGER_IO_ERROR)\n"])
|
|
138
|
+
}))
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @param {import('node:stream').Duplex} stream - Authenticated stream.
|
|
143
|
+
* @param {number} length - Expected payload length.
|
|
144
|
+
* @returns {Promise<Buffer>} Complete echoed payload.
|
|
145
|
+
*/
|
|
146
|
+
async function readPayload(stream, length) {
|
|
147
|
+
return await new Promise((resolve, reject) => {
|
|
148
|
+
/** @type {Buffer[]} */
|
|
149
|
+
const chunks = []
|
|
150
|
+
let received = 0
|
|
151
|
+
/** @param {Buffer} chunk - Stream data. */
|
|
152
|
+
const onData = (chunk) => {
|
|
153
|
+
chunks.push(chunk)
|
|
154
|
+
received += chunk.length
|
|
155
|
+
if (received < length) return
|
|
156
|
+
stream.off("data", onData)
|
|
157
|
+
stream.off("error", reject)
|
|
158
|
+
resolve(Buffer.concat(chunks))
|
|
159
|
+
}
|
|
160
|
+
stream.on("data", onData)
|
|
161
|
+
stream.once("error", reject)
|
|
162
|
+
})
|
|
163
|
+
}
|