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,551 @@
|
|
|
1
|
+
/* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-returns */
|
|
2
|
+
import assert from "node:assert/strict"
|
|
3
|
+
import {mkdir, mkdtemp, rm, writeFile} from "node:fs/promises"
|
|
4
|
+
import {createConnection, createServer} from "node:net"
|
|
5
|
+
import {tmpdir} from "node:os"
|
|
6
|
+
import {join} from "node:path"
|
|
7
|
+
import {Duplex} from "node:stream"
|
|
8
|
+
import test from "node:test"
|
|
9
|
+
|
|
10
|
+
import {StaticTokenStore} from "../../src/auth/static-token-store.js"
|
|
11
|
+
import {MultiplexedSocketductConnection} from "../../src/client/multiplexed-connection.js"
|
|
12
|
+
import {decodeV3Control, encodeV3Control, encodeV3Frame, V3FrameDecoder, V3FrameType} from "../../src/protocol/v3-frame.js"
|
|
13
|
+
import {startMultiplexedTcpRelay} from "../../src/proxy/multiplexed-relay.js"
|
|
14
|
+
import {SessionSpool} from "../../src/spool/spool-file.js"
|
|
15
|
+
|
|
16
|
+
/** @type {WeakMap<import('node:net').Server, Set<import('node:net').Socket>>} */
|
|
17
|
+
const socketsByServer = new WeakMap()
|
|
18
|
+
|
|
19
|
+
/** @param {import('node:net').Server} server - Server. @returns {Promise<number>} */
|
|
20
|
+
async function listen(server) {
|
|
21
|
+
const sockets = new Set()
|
|
22
|
+
socketsByServer.set(server, sockets)
|
|
23
|
+
server.on("connection", (socket) => {
|
|
24
|
+
sockets.add(socket)
|
|
25
|
+
socket.once("close", () => sockets.delete(socket))
|
|
26
|
+
})
|
|
27
|
+
await new Promise((resolve, reject) => {
|
|
28
|
+
server.once("error", reject)
|
|
29
|
+
server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(undefined) })
|
|
30
|
+
})
|
|
31
|
+
const address = server.address()
|
|
32
|
+
assert(address !== null && typeof address !== "string")
|
|
33
|
+
return address.port
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** @param {import('node:net').Server} server - Server. @returns {Promise<void>} */
|
|
37
|
+
async function closeServer(server) {
|
|
38
|
+
for (const socket of socketsByServer.get(server) ?? []) socket.destroy()
|
|
39
|
+
if (server.listening) await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve(undefined)))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {import('node:events').EventEmitter} emitter - Emitter.
|
|
44
|
+
* @param {string} event - Event.
|
|
45
|
+
* @returns {Promise<unknown[]>} Event arguments.
|
|
46
|
+
*/
|
|
47
|
+
async function onceEvent(emitter, event) {
|
|
48
|
+
return await new Promise((resolve, reject) => {
|
|
49
|
+
emitter.once("error", reject)
|
|
50
|
+
/** @param {...unknown} values - Event arguments. */
|
|
51
|
+
const onEvent = (...values) => { emitter.off("error", reject); resolve(values) }
|
|
52
|
+
emitter.once(event, onEvent)
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** @param {import('node:stream').Duplex} socket - Socket. @returns {Promise<Error & {code?: string}>} */
|
|
57
|
+
async function nextError(socket) {
|
|
58
|
+
return await new Promise((resolve) => socket.once("error", (error) => resolve(/** @type {Error & {code?: string}} */ (error))))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {() => boolean} predicate - Condition.
|
|
63
|
+
* @param {number} [timeoutMs] - Deadline.
|
|
64
|
+
* @returns {Promise<void>} Completion.
|
|
65
|
+
*/
|
|
66
|
+
async function waitUntil(predicate, timeoutMs = 1_000) {
|
|
67
|
+
const deadline = Date.now() + timeoutMs
|
|
68
|
+
while (!predicate()) {
|
|
69
|
+
if (Date.now() >= deadline) throw new Error("timed out waiting for condition")
|
|
70
|
+
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** @returns {StaticTokenStore} Test token store. */
|
|
75
|
+
function tokens() {
|
|
76
|
+
return new StaticTokenStore([{username: "edge", token: "valid-token"}])
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Creates a frame-aware TCP fault proxy. Only the first physical generation is
|
|
81
|
+
* faulted; later generations pass through unchanged.
|
|
82
|
+
* @param {number} relayPort - Upstream relay port.
|
|
83
|
+
* @param {"drop-client-window" | "drop-second-relay-data" | "observe-client"} mode - Injected loss or observation.
|
|
84
|
+
*/
|
|
85
|
+
async function createFaultProxy(relayPort, mode) {
|
|
86
|
+
let accepts = 0
|
|
87
|
+
let faultTriggered = false
|
|
88
|
+
/** @type {import('../../src/protocol/v3-frame.js').V3Frame[]} */
|
|
89
|
+
const clientFrames = []
|
|
90
|
+
const proxy = createServer((downstream) => {
|
|
91
|
+
accepts += 1
|
|
92
|
+
const generation = accepts
|
|
93
|
+
const upstream = createConnection({host: "127.0.0.1", port: relayPort})
|
|
94
|
+
if (generation !== 1) {
|
|
95
|
+
downstream.pipe(upstream)
|
|
96
|
+
upstream.pipe(downstream)
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
const clientDecoder = new V3FrameDecoder({maxFrameBytes: 64 * 1024, maxBufferedBytes: 128 * 1024})
|
|
100
|
+
const relayDecoder = new V3FrameDecoder({maxFrameBytes: 64 * 1024, maxBufferedBytes: 128 * 1024})
|
|
101
|
+
let clientDataBytes = 0
|
|
102
|
+
let relayDataFrames = 0
|
|
103
|
+
const sever = () => {
|
|
104
|
+
if (faultTriggered) return
|
|
105
|
+
faultTriggered = true
|
|
106
|
+
downstream.destroy()
|
|
107
|
+
upstream.destroy()
|
|
108
|
+
}
|
|
109
|
+
downstream.on("data", (chunk) => {
|
|
110
|
+
for (const frame of clientDecoder.push(chunk)) {
|
|
111
|
+
clientFrames.push(frame)
|
|
112
|
+
if (mode === "drop-client-window" && frame.type === V3FrameType.DATA) {
|
|
113
|
+
clientDataBytes += frame.payload.length
|
|
114
|
+
if (clientDataBytes >= 32) sever()
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
upstream.write(encodeV3Frame(frame, frame.payload))
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
upstream.on("data", (chunk) => {
|
|
121
|
+
for (const frame of relayDecoder.push(chunk)) {
|
|
122
|
+
if (mode === "drop-second-relay-data" && frame.type === V3FrameType.DATA) {
|
|
123
|
+
relayDataFrames += 1
|
|
124
|
+
if (relayDataFrames === 2) {
|
|
125
|
+
sever()
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
downstream.write(encodeV3Frame(frame, frame.payload))
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
downstream.on("error", () => {})
|
|
133
|
+
upstream.on("error", () => {})
|
|
134
|
+
downstream.once("close", () => upstream.destroy())
|
|
135
|
+
upstream.once("close", () => downstream.destroy())
|
|
136
|
+
})
|
|
137
|
+
const port = await listen(proxy)
|
|
138
|
+
return {proxy, port, status: () => ({accepts, faultTriggered, clientFrames})}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Minimal v3 endpoint: the configured number of first accepts stay silent,
|
|
143
|
+
* then AUTH and OPEN complete successfully.
|
|
144
|
+
* @param {number} blackholeAccepts - Silent physical accepts.
|
|
145
|
+
*/
|
|
146
|
+
async function createBlackholeThenReadyServer(blackholeAccepts) {
|
|
147
|
+
let accepts = 0
|
|
148
|
+
const server = createServer((socket) => {
|
|
149
|
+
accepts += 1
|
|
150
|
+
if (accepts <= blackholeAccepts) return
|
|
151
|
+
const decoder = new V3FrameDecoder()
|
|
152
|
+
socket.on("data", (chunk) => {
|
|
153
|
+
for (const frame of decoder.push(chunk)) {
|
|
154
|
+
if (frame.type === V3FrameType.AUTH) {
|
|
155
|
+
const auth = decodeV3Control(frame.payload)
|
|
156
|
+
socket.write(encodeV3Frame({type: V3FrameType.AUTH_OK, streamId: 0, sequence: frame.sequence}, encodeV3Control({
|
|
157
|
+
version: 3,
|
|
158
|
+
connectionId: auth.connectionId,
|
|
159
|
+
generation: auth.generation,
|
|
160
|
+
resumed: false
|
|
161
|
+
})))
|
|
162
|
+
} else if (frame.type === V3FrameType.OPEN) {
|
|
163
|
+
const opened = decodeV3Control(frame.payload)
|
|
164
|
+
socket.write(encodeV3Frame({type: V3FrameType.OPENED, streamId: frame.streamId, sequence: 0}, encodeV3Control({
|
|
165
|
+
identifier: opened.identifier,
|
|
166
|
+
initialWindow: 32
|
|
167
|
+
})))
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
const port = await listen(server)
|
|
173
|
+
return {server, port, status: () => ({accepts})}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
test("a full lost client window is credited again before replay", async (t) => {
|
|
177
|
+
let received = Buffer.alloc(0)
|
|
178
|
+
const target = createServer((socket) => socket.on("data", (chunk) => { received = Buffer.concat([received, chunk]) }))
|
|
179
|
+
const targetPort = await listen(target)
|
|
180
|
+
t.after(() => closeServer(target))
|
|
181
|
+
const relay = await startMultiplexedTcpRelay({tokens: tokens(), authorizeTarget: ({target: requested}) => requested.port === targetPort, initialWindowBytes: 32, maxFrameBytes: 32})
|
|
182
|
+
t.after(() => relay.close())
|
|
183
|
+
const fault = await createFaultProxy(relay.port, "drop-client-window")
|
|
184
|
+
t.after(() => closeServer(fault.proxy))
|
|
185
|
+
const connection = new MultiplexedSocketductConnection({relay: {host: "127.0.0.1", port: fault.port, token: "valid-token"}, initialWindowBytes: 32, maxFrameBytes: 32, reconnectDelayMs: 10})
|
|
186
|
+
t.after(() => connection.close())
|
|
187
|
+
const socket = connection.createConnection({host: "127.0.0.1", port: targetPort})
|
|
188
|
+
await onceEvent(socket, "connect")
|
|
189
|
+
socket.write(Buffer.alloc(32, 0x61))
|
|
190
|
+
await waitUntil(() => fault.status().faultTriggered)
|
|
191
|
+
await waitUntil(() => received.length === 32)
|
|
192
|
+
assert.equal(received.toString(), "a".repeat(32))
|
|
193
|
+
assert.equal(fault.status().accepts, 2)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test("partial target replay is delivered exactly once after reconnect", async (t) => {
|
|
197
|
+
const payload = Buffer.from("0123456789abcdefABCDEFGHIJKLMNOP")
|
|
198
|
+
const target = createServer((socket) => socket.write(payload))
|
|
199
|
+
const targetPort = await listen(target)
|
|
200
|
+
t.after(() => closeServer(target))
|
|
201
|
+
const relay = await startMultiplexedTcpRelay({tokens: tokens(), authorizeTarget: ({target: requested}) => requested.port === targetPort, initialWindowBytes: 32, maxFrameBytes: 16})
|
|
202
|
+
t.after(() => relay.close())
|
|
203
|
+
const fault = await createFaultProxy(relay.port, "drop-second-relay-data")
|
|
204
|
+
t.after(() => closeServer(fault.proxy))
|
|
205
|
+
const connection = new MultiplexedSocketductConnection({relay: {host: "127.0.0.1", port: fault.port, token: "valid-token"}, initialWindowBytes: 32, maxFrameBytes: 16, reconnectDelayMs: 10})
|
|
206
|
+
t.after(() => connection.close())
|
|
207
|
+
const socket = connection.createConnection({host: "127.0.0.1", port: targetPort, readableHighWaterMark: 1})
|
|
208
|
+
await onceEvent(socket, "connect")
|
|
209
|
+
await waitUntil(() => fault.status().faultTriggered)
|
|
210
|
+
await waitUntil(() => connection.status().reconnectSuccesses === 1)
|
|
211
|
+
await waitUntil(() => relay.status().bufferedBytes === 16)
|
|
212
|
+
/** @type {Buffer[]} */
|
|
213
|
+
const chunks = []
|
|
214
|
+
socket.on("data", (chunk) => chunks.push(Buffer.from(chunk)))
|
|
215
|
+
socket.resume()
|
|
216
|
+
await waitUntil(() => Buffer.concat(chunks).length === payload.length)
|
|
217
|
+
assert.deepEqual(Buffer.concat(chunks), payload)
|
|
218
|
+
assert.equal(fault.status().accepts, 2)
|
|
219
|
+
await connection.close()
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test("a recreated client recovers durable replay without reopening the target", async (t) => {
|
|
223
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-v3-recovery-"))
|
|
224
|
+
let targetAccepts = 0
|
|
225
|
+
let received = Buffer.alloc(0)
|
|
226
|
+
const target = createServer((socket) => {
|
|
227
|
+
targetAccepts += 1
|
|
228
|
+
socket.on("data", (chunk) => { received = Buffer.concat([received, chunk]) })
|
|
229
|
+
})
|
|
230
|
+
const targetPort = await listen(target)
|
|
231
|
+
t.after(() => closeServer(target))
|
|
232
|
+
const relay = await startMultiplexedTcpRelay({tokens: tokens(), authorizeTarget: ({target: requested}) => requested.port === targetPort, initialWindowBytes: 32, maxFrameBytes: 32, resumeWindowMs: 2_000})
|
|
233
|
+
t.after(() => relay.close())
|
|
234
|
+
const fault = await createFaultProxy(relay.port, "drop-client-window")
|
|
235
|
+
t.after(() => closeServer(fault.proxy))
|
|
236
|
+
const shared = {relay: {host: "127.0.0.1", port: fault.port, token: "valid-token"}, connectionId: "stable_connection_01", spoolDirectory: root, initialWindowBytes: 32, maxFrameBytes: 32, reconnectDelayMs: 1_000}
|
|
237
|
+
const first = new MultiplexedSocketductConnection(shared)
|
|
238
|
+
const initial = first.createConnection({host: "127.0.0.1", port: targetPort, identifier: "stable_stream_01", sessionName: "durable"})
|
|
239
|
+
await onceEvent(initial, "connect")
|
|
240
|
+
await new Promise((resolve, reject) => initial.write(Buffer.alloc(32, 0x72), (error) => error ? reject(error) : resolve(undefined)))
|
|
241
|
+
await waitUntil(() => fault.status().faultTriggered)
|
|
242
|
+
await first.detach()
|
|
243
|
+
|
|
244
|
+
const accidentalFresh = new MultiplexedSocketductConnection(shared)
|
|
245
|
+
const duplicate = accidentalFresh.createConnection({host: "127.0.0.1", port: targetPort, identifier: "stable_stream_01", sessionName: "durable"})
|
|
246
|
+
assert.match((await nextError(duplicate)).message, /explicit recovery is required/i)
|
|
247
|
+
await accidentalFresh.close()
|
|
248
|
+
|
|
249
|
+
const second = new MultiplexedSocketductConnection(shared)
|
|
250
|
+
t.after(() => second.close())
|
|
251
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
252
|
+
const recovered = second.createConnection({host: "127.0.0.1", port: targetPort, identifier: "stable_stream_01", sessionName: "durable", recover: true})
|
|
253
|
+
await onceEvent(recovered, "connect")
|
|
254
|
+
await waitUntil(() => received.length === 32)
|
|
255
|
+
assert.equal(received.toString(), "r".repeat(32))
|
|
256
|
+
assert.equal(targetAccepts, 1)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
test("corrupt durable recovery state fails closed", async (t) => {
|
|
260
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-v3-corrupt-"))
|
|
261
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
262
|
+
const directory = join(root, "stable_stream_02")
|
|
263
|
+
await mkdir(directory, {recursive: true})
|
|
264
|
+
await writeFile(join(directory, "metadata.json"), JSON.stringify({sessionId: "stable_stream_02", ownerId: "edge-v3", createdAt: new Date().toISOString()}))
|
|
265
|
+
await writeFile(join(directory, "recovery.json"), "{not-json")
|
|
266
|
+
const connection = new MultiplexedSocketductConnection({relay: {host: "127.0.0.1", port: 9, token: "valid-token"}, connectionId: "stable_connection_02", spoolDirectory: root})
|
|
267
|
+
assert.throws(() => connection.createConnection({host: "127.0.0.1", port: 9, identifier: "stable_stream_02", recover: true}), /recovery/i)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
test("relay enforces one process-wide retained buffer ceiling", async (t) => {
|
|
271
|
+
const targets = new Map()
|
|
272
|
+
const relay = await startMultiplexedTcpRelay({
|
|
273
|
+
tokens: tokens(),
|
|
274
|
+
authorizeTarget: () => true,
|
|
275
|
+
createTargetConnection: (requested) => {
|
|
276
|
+
const target = new Duplex({read() {}, write(_chunk, _encoding, callback) { callback() }})
|
|
277
|
+
targets.set(requested.port, target)
|
|
278
|
+
setImmediate(() => target.emit("connect"))
|
|
279
|
+
return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (target))
|
|
280
|
+
},
|
|
281
|
+
initialWindowBytes: 64,
|
|
282
|
+
maxFrameBytes: 32,
|
|
283
|
+
maxStreamBufferBytes: 64,
|
|
284
|
+
maxConnectionBufferBytes: 64,
|
|
285
|
+
maxTotalBufferBytes: 64
|
|
286
|
+
})
|
|
287
|
+
t.after(() => relay.close())
|
|
288
|
+
const options = {relay: {host: "127.0.0.1", port: relay.port, token: "valid-token"}, initialWindowBytes: 64, maxFrameBytes: 32}
|
|
289
|
+
const firstConnection = new MultiplexedSocketductConnection(options)
|
|
290
|
+
const secondConnection = new MultiplexedSocketductConnection(options)
|
|
291
|
+
t.after(() => Promise.all([firstConnection.close(), secondConnection.close()]))
|
|
292
|
+
const first = firstConnection.createConnection({host: "target.internal", port: 1, readableHighWaterMark: 1})
|
|
293
|
+
const second = secondConnection.createConnection({host: "target.internal", port: 2, readableHighWaterMark: 1})
|
|
294
|
+
await Promise.all([onceEvent(first, "connect"), onceEvent(second, "connect")])
|
|
295
|
+
const secondError = nextError(second)
|
|
296
|
+
targets.get(1)?.push(Buffer.alloc(40, 0x61))
|
|
297
|
+
await waitUntil(() => relay.status().bufferedBytes === 40)
|
|
298
|
+
targets.get(2)?.push(Buffer.alloc(40, 0x62))
|
|
299
|
+
assert.equal((await secondError).code, "PROCESS_BUFFER_LIMIT")
|
|
300
|
+
assert.equal(relay.status().bufferedBytes, 40)
|
|
301
|
+
assert.equal(relay.status().bufferLimitBytes, 64)
|
|
302
|
+
first.destroy()
|
|
303
|
+
await waitUntil(() => relay.status().bufferedBytes === 0)
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
test("authentication timeout covers AUTH_OK and permits a later open", async (t) => {
|
|
307
|
+
const fixture = await createBlackholeThenReadyServer(1)
|
|
308
|
+
t.after(() => closeServer(fixture.server))
|
|
309
|
+
const connection = new MultiplexedSocketductConnection({relay: {host: "127.0.0.1", port: fixture.port, token: "valid-token"}, connectTimeoutMs: 40})
|
|
310
|
+
t.after(() => connection.close())
|
|
311
|
+
const first = connection.createConnection({host: "target.internal", port: 1})
|
|
312
|
+
const error = await Promise.race([
|
|
313
|
+
nextError(first),
|
|
314
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("authentication timeout did not settle")), 250))
|
|
315
|
+
])
|
|
316
|
+
assert.equal(error.code, "SOCKETDUCT_AUTH_TIMEOUT")
|
|
317
|
+
const second = connection.createConnection({host: "target.internal", port: 1})
|
|
318
|
+
await onceEvent(second, "connect")
|
|
319
|
+
assert.equal(fixture.status().accepts, 2)
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
test("slow spool initialization does not consume the authentication timeout", async (t) => {
|
|
323
|
+
const originalCreateNew = SessionSpool.prototype.createNew
|
|
324
|
+
/** @type {() => void} */
|
|
325
|
+
let releaseCreateNew = () => {}
|
|
326
|
+
/** @type {Promise<void>} */
|
|
327
|
+
const createNewGate = new Promise((resolve) => { releaseCreateNew = () => resolve() })
|
|
328
|
+
SessionSpool.prototype.createNew = async function (/** @type {{sessionId: string, ownerId: string}} */ options) {
|
|
329
|
+
await createNewGate
|
|
330
|
+
return await originalCreateNew.call(this, options)
|
|
331
|
+
}
|
|
332
|
+
t.after(() => { SessionSpool.prototype.createNew = originalCreateNew })
|
|
333
|
+
const fixture = await createBlackholeThenReadyServer(0)
|
|
334
|
+
t.after(() => closeServer(fixture.server))
|
|
335
|
+
let physicalAttempts = 0
|
|
336
|
+
const connection = new MultiplexedSocketductConnection({relay: {host: "127.0.0.1", port: fixture.port, token: "valid-token"}, connectTimeoutMs: 20, createPhysicalSocket: (options) => {
|
|
337
|
+
physicalAttempts += 1
|
|
338
|
+
return createConnection(options)
|
|
339
|
+
}})
|
|
340
|
+
t.after(() => connection.close())
|
|
341
|
+
const socket = connection.createConnection({host: "target.internal", port: 1})
|
|
342
|
+
void nextError(socket)
|
|
343
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
344
|
+
const attemptsBeforeSpoolReady = physicalAttempts
|
|
345
|
+
releaseCreateNew()
|
|
346
|
+
assert.equal(attemptsBeforeSpoolReady, 0)
|
|
347
|
+
await onceEvent(socket, "connect")
|
|
348
|
+
assert.equal(physicalAttempts, 1)
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
test("cancelling the only opening stream clears physical attempt ownership", async (t) => {
|
|
352
|
+
const originalCreateNew = SessionSpool.prototype.createNew
|
|
353
|
+
/** @type {() => void} */
|
|
354
|
+
let releaseCreateNew = () => {}
|
|
355
|
+
/** @type {Promise<void>} */
|
|
356
|
+
const createNewGate = new Promise((resolve) => { releaseCreateNew = () => resolve() })
|
|
357
|
+
/** @type {() => void} */
|
|
358
|
+
let markFirstCreateNewDone = () => {}
|
|
359
|
+
/** @type {Promise<void>} */
|
|
360
|
+
const firstCreateNewDone = new Promise((resolve) => { markFirstCreateNewDone = () => resolve() })
|
|
361
|
+
let createNewCalls = 0
|
|
362
|
+
SessionSpool.prototype.createNew = async function (/** @type {{sessionId: string, ownerId: string}} */ options) {
|
|
363
|
+
createNewCalls += 1
|
|
364
|
+
if (createNewCalls !== 1) return await originalCreateNew.call(this, options)
|
|
365
|
+
await createNewGate
|
|
366
|
+
const created = await originalCreateNew.call(this, options)
|
|
367
|
+
markFirstCreateNewDone()
|
|
368
|
+
return created
|
|
369
|
+
}
|
|
370
|
+
t.after(() => { SessionSpool.prototype.createNew = originalCreateNew })
|
|
371
|
+
const fixture = await createBlackholeThenReadyServer(0)
|
|
372
|
+
t.after(() => closeServer(fixture.server))
|
|
373
|
+
let physicalAttempts = 0
|
|
374
|
+
const connection = new MultiplexedSocketductConnection({relay: {host: "127.0.0.1", port: fixture.port, token: "valid-token"}, connectTimeoutMs: 1_000, createPhysicalSocket: (options) => {
|
|
375
|
+
physicalAttempts += 1
|
|
376
|
+
return createConnection(options)
|
|
377
|
+
}})
|
|
378
|
+
t.after(() => connection.close())
|
|
379
|
+
const first = connection.createConnection({host: "target.internal", port: 1})
|
|
380
|
+
first.destroy()
|
|
381
|
+
releaseCreateNew()
|
|
382
|
+
await firstCreateNewDone
|
|
383
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
384
|
+
assert.equal(physicalAttempts, 0)
|
|
385
|
+
const second = connection.createConnection({host: "target.internal", port: 1})
|
|
386
|
+
await Promise.race([
|
|
387
|
+
onceEvent(second, "connect"),
|
|
388
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("later open reused a stale connect promise")), 250))
|
|
389
|
+
])
|
|
390
|
+
assert.equal(physicalAttempts, 1)
|
|
391
|
+
assert.equal(fixture.status().accepts, 1)
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
test("close-delimited streams release relay capacity after CLOSE delivery", async (t) => {
|
|
395
|
+
const originalWriteRecoveryState = SessionSpool.prototype.writeRecoveryState
|
|
396
|
+
/** @type {() => void} */
|
|
397
|
+
let releaseReceiveAck = () => {}
|
|
398
|
+
/** @type {Promise<void>} */
|
|
399
|
+
const receiveAckGate = new Promise((resolve) => { releaseReceiveAck = resolve })
|
|
400
|
+
/** @type {() => void} */
|
|
401
|
+
let markReceiveAckStarted = () => {}
|
|
402
|
+
/** @type {Promise<void>} */
|
|
403
|
+
const receiveAckStarted = new Promise((resolve) => { markReceiveAckStarted = resolve })
|
|
404
|
+
SessionSpool.prototype.writeRecoveryState = async function (/** @type {string} */ sessionId, /** @type {Record<string, unknown>} */ state) {
|
|
405
|
+
if (sessionId === "close_stream_1" && typeof state.receiveAck === "number" && state.receiveAck > 0) {
|
|
406
|
+
markReceiveAckStarted()
|
|
407
|
+
await receiveAckGate
|
|
408
|
+
}
|
|
409
|
+
return await originalWriteRecoveryState.call(this, sessionId, state)
|
|
410
|
+
}
|
|
411
|
+
t.after(() => { SessionSpool.prototype.writeRecoveryState = originalWriteRecoveryState })
|
|
412
|
+
let targetAccepts = 0
|
|
413
|
+
const target = createServer((socket) => {
|
|
414
|
+
targetAccepts += 1
|
|
415
|
+
socket.end(`response-${targetAccepts}`)
|
|
416
|
+
})
|
|
417
|
+
const targetPort = await listen(target)
|
|
418
|
+
t.after(() => closeServer(target))
|
|
419
|
+
const relay = await startMultiplexedTcpRelay({
|
|
420
|
+
tokens: tokens(),
|
|
421
|
+
authorizeTarget: ({target: requested}) => requested.port === targetPort,
|
|
422
|
+
maxStreams: 1
|
|
423
|
+
})
|
|
424
|
+
t.after(() => relay.close())
|
|
425
|
+
const observer = await createFaultProxy(relay.port, "observe-client")
|
|
426
|
+
t.after(() => closeServer(observer.proxy))
|
|
427
|
+
const connection = new MultiplexedSocketductConnection({relay: {host: "127.0.0.1", port: observer.port, token: "valid-token"}})
|
|
428
|
+
t.after(() => connection.close())
|
|
429
|
+
/** @type {number[]} */
|
|
430
|
+
const streamIds = []
|
|
431
|
+
|
|
432
|
+
for (const [index, expected] of ["response-1", "response-2"].entries()) {
|
|
433
|
+
const socket = connection.createConnection({host: "127.0.0.1", port: targetPort, identifier: `close_stream_${index + 1}`, allowHalfOpen: true})
|
|
434
|
+
streamIds.push(socket.streamId)
|
|
435
|
+
/** @type {Buffer[]} */
|
|
436
|
+
const chunks = []
|
|
437
|
+
socket.on("data", (chunk) => chunks.push(Buffer.from(chunk)))
|
|
438
|
+
const closed = onceEvent(socket, "close")
|
|
439
|
+
const ended = onceEvent(socket, "end")
|
|
440
|
+
await onceEvent(socket, "connect")
|
|
441
|
+
await waitUntil(() => observer.status().clientFrames.some((frame) => frame.streamId === socket.streamId && frame.type === V3FrameType.CLOSE))
|
|
442
|
+
if (index === 0) {
|
|
443
|
+
await receiveAckStarted
|
|
444
|
+
releaseReceiveAck()
|
|
445
|
+
}
|
|
446
|
+
await ended
|
|
447
|
+
socket.end()
|
|
448
|
+
await closed
|
|
449
|
+
assert.equal(Buffer.concat(chunks).toString(), expected)
|
|
450
|
+
await waitUntil(() => /** @type {{virtualStreams: number}} */ (relay.status()).virtualStreams === 0)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const firstCloseIndex = observer.status().clientFrames.findIndex((frame) => frame.streamId === streamIds[0] && frame.type === V3FrameType.CLOSE)
|
|
454
|
+
assert.deepEqual(observer.status().clientFrames.slice(firstCloseIndex).filter((frame) => frame.streamId === streamIds[0]).map((frame) => frame.type), [V3FrameType.CLOSE])
|
|
455
|
+
assert.equal(targetAccepts, 2)
|
|
456
|
+
assert.equal(/** @type {{physicalAccepts: number}} */ (relay.status()).physicalAccepts, 1)
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
test("replacement recovery replays target bytes not durably acknowledged", async (t) => {
|
|
460
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-v3-receive-recovery-"))
|
|
461
|
+
const payload = Buffer.from("unread-target-payload")
|
|
462
|
+
let targetAccepts = 0
|
|
463
|
+
const target = createServer((socket) => {
|
|
464
|
+
socket.write(payload)
|
|
465
|
+
})
|
|
466
|
+
const targetPort = await listen(target)
|
|
467
|
+
t.after(() => closeServer(target))
|
|
468
|
+
// Prove target-open accounting is owned by the relay, not arbitrary clients of the fixture listener.
|
|
469
|
+
const unrelated = createConnection({host: "127.0.0.1", port: targetPort})
|
|
470
|
+
const unrelatedData = onceEvent(unrelated, "data")
|
|
471
|
+
const unrelatedClosed = onceEvent(unrelated, "close")
|
|
472
|
+
await onceEvent(unrelated, "connect")
|
|
473
|
+
await unrelatedData
|
|
474
|
+
unrelated.end()
|
|
475
|
+
await unrelatedClosed
|
|
476
|
+
const relay = await startMultiplexedTcpRelay({
|
|
477
|
+
tokens: tokens(),
|
|
478
|
+
authorizeTarget: ({target: requested}) => requested.port === targetPort,
|
|
479
|
+
createTargetConnection: (requested) => {
|
|
480
|
+
targetAccepts += 1
|
|
481
|
+
return createConnection(requested)
|
|
482
|
+
},
|
|
483
|
+
initialWindowBytes: 32,
|
|
484
|
+
maxFrameBytes: 32,
|
|
485
|
+
resumeWindowMs: 2_000
|
|
486
|
+
})
|
|
487
|
+
t.after(() => relay.close())
|
|
488
|
+
const shared = {
|
|
489
|
+
relay: {host: "127.0.0.1", port: relay.port, token: "valid-token"},
|
|
490
|
+
connectionId: "stable_connection_receive_01",
|
|
491
|
+
spoolDirectory: root,
|
|
492
|
+
initialWindowBytes: 32,
|
|
493
|
+
maxFrameBytes: 32
|
|
494
|
+
}
|
|
495
|
+
const first = new MultiplexedSocketductConnection(shared)
|
|
496
|
+
const initial = first.createConnection({host: "127.0.0.1", port: targetPort, identifier: "stable_stream_receive_01", readableHighWaterMark: 1})
|
|
497
|
+
await onceEvent(initial, "connect")
|
|
498
|
+
await waitUntil(() => initial.bytesRead === payload.length)
|
|
499
|
+
await waitUntil(() => /** @type {{bufferedBytes: number}} */ (relay.status()).bufferedBytes === payload.length)
|
|
500
|
+
await first.detach()
|
|
501
|
+
|
|
502
|
+
const second = new MultiplexedSocketductConnection(shared)
|
|
503
|
+
t.after(() => second.close())
|
|
504
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
505
|
+
const recovered = second.createConnection({host: "127.0.0.1", port: targetPort, identifier: "stable_stream_receive_01", readableHighWaterMark: 1, recover: true})
|
|
506
|
+
/** @type {Buffer[]} */
|
|
507
|
+
const chunks = []
|
|
508
|
+
recovered.on("data", (chunk) => chunks.push(Buffer.from(chunk)))
|
|
509
|
+
recovered.resume()
|
|
510
|
+
await onceEvent(recovered, "connect")
|
|
511
|
+
await waitUntil(() => Buffer.concat(chunks).length === payload.length)
|
|
512
|
+
assert.deepEqual(Buffer.concat(chunks), payload)
|
|
513
|
+
assert.equal(targetAccepts, 1)
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
test("replacement recovery reconciles a durable opening stream without a duplicate OPEN", async (t) => {
|
|
517
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-v3-opening-recovery-"))
|
|
518
|
+
let targetAttempts = 0
|
|
519
|
+
/** @type {Duplex | undefined} */
|
|
520
|
+
let pendingTarget
|
|
521
|
+
const relay = await startMultiplexedTcpRelay({
|
|
522
|
+
tokens: tokens(),
|
|
523
|
+
authorizeTarget: () => true,
|
|
524
|
+
resumeWindowMs: 2_000,
|
|
525
|
+
createTargetConnection: () => {
|
|
526
|
+
targetAttempts += 1
|
|
527
|
+
pendingTarget = new Duplex({read() {}, write(_chunk, _encoding, callback) { callback() }})
|
|
528
|
+
return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (pendingTarget))
|
|
529
|
+
}
|
|
530
|
+
})
|
|
531
|
+
t.after(() => relay.close())
|
|
532
|
+
const shared = {
|
|
533
|
+
relay: {host: "127.0.0.1", port: relay.port, token: "valid-token"},
|
|
534
|
+
connectionId: "stable_connection_opening_01",
|
|
535
|
+
spoolDirectory: root
|
|
536
|
+
}
|
|
537
|
+
const first = new MultiplexedSocketductConnection(shared)
|
|
538
|
+
first.createConnection({host: "target.internal", port: 443, identifier: "stable_stream_opening_01"})
|
|
539
|
+
await waitUntil(() => pendingTarget !== undefined)
|
|
540
|
+
await first.detach()
|
|
541
|
+
|
|
542
|
+
const second = new MultiplexedSocketductConnection(shared)
|
|
543
|
+
t.after(() => second.close())
|
|
544
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
545
|
+
const recovered = second.createConnection({host: "target.internal", port: 443, identifier: "stable_stream_opening_01", recover: true})
|
|
546
|
+
const connected = onceEvent(recovered, "connect")
|
|
547
|
+
pendingTarget?.emit("connect")
|
|
548
|
+
await connected
|
|
549
|
+
assert.equal(targetAttempts, 1)
|
|
550
|
+
await second.detach()
|
|
551
|
+
})
|