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,2878 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import {mkdtemp, rm} from "node:fs/promises"
|
|
3
|
+
import {tmpdir} from "node:os"
|
|
4
|
+
import {join} from "node:path"
|
|
5
|
+
import {createConnection, createServer} from "node:net"
|
|
6
|
+
import zlib from "node:zlib"
|
|
7
|
+
import test from "node:test"
|
|
8
|
+
|
|
9
|
+
import {InMemoryUserStore, login, TokenStore} from "../../src/auth/user-store.js"
|
|
10
|
+
import {connectSocketduct} from "../../src/client/socketduct-socket.js"
|
|
11
|
+
import {startTcpEdge} from "../../src/edge/tcp-edge.js"
|
|
12
|
+
import {encodeCompactAck, encodeCompactData, encodeFrame as encodeEnvelope, FrameDecoder} from "../../src/protocol/frame.js"
|
|
13
|
+
import {zstdAvailable} from "../../src/protocol/compression.js"
|
|
14
|
+
import {startTcpRelay} from "../../src/proxy/tcp-relay.js"
|
|
15
|
+
import {SessionStore} from "../../src/sessions/session.js"
|
|
16
|
+
import {SessionSpool} from "../../src/spool/spool-file.js"
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Encode controls as envelopes and post-handshake stream records as compact V2.
|
|
20
|
+
* @param {import('../../src/protocol/frame.js').FrameHeader} header - Frame metadata.
|
|
21
|
+
* @param {Buffer} [payload] - Frame payload.
|
|
22
|
+
* @returns {Buffer} Encoded wire bytes.
|
|
23
|
+
*/
|
|
24
|
+
function encodeFrame(header, payload = Buffer.alloc(0)) {
|
|
25
|
+
if (header.type === "stream.data") {
|
|
26
|
+
return encodeCompactData({
|
|
27
|
+
sequence: /** @type {number} */ (header.sequence),
|
|
28
|
+
payload,
|
|
29
|
+
...(header.encoding === "zstd" ? {dataLength: /** @type {number} */ (header.dataLength)} : {})
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
if (header.type === "stream.ack") return encodeCompactAck(/** @type {number} */ (header.ackSequence))
|
|
33
|
+
const framedHeader = header.type === "session.create" || header.type === "session.resume"
|
|
34
|
+
? {...header, framing: header.framing ?? [2]}
|
|
35
|
+
: header
|
|
36
|
+
return encodeEnvelope(framedHeader, payload)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {import('node:net').Server} server - Server to bind.
|
|
41
|
+
* @returns {Promise<number>} - Bound port.
|
|
42
|
+
*/
|
|
43
|
+
async function listen(server) {
|
|
44
|
+
await new Promise((resolve, reject) => {
|
|
45
|
+
server.once("error", reject)
|
|
46
|
+
server.listen(0, "127.0.0.1", () => {
|
|
47
|
+
server.off("error", reject)
|
|
48
|
+
resolve(undefined)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
const address = server.address()
|
|
52
|
+
if (address === null || typeof address === "string") {
|
|
53
|
+
throw new Error("Server did not bind to a TCP port")
|
|
54
|
+
}
|
|
55
|
+
return /** @type {import('node:net').AddressInfo} */ (address).port
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {import('node:net').Socket} socket - Socket to read from.
|
|
60
|
+
* @returns {Promise<Buffer>} - Next data chunk.
|
|
61
|
+
*/
|
|
62
|
+
async function readOnce(socket) {
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
socket.once("data", (chunk) => resolve(chunk))
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {import('node:net').Socket} socket
|
|
70
|
+
* @param {number} [timeoutMs]
|
|
71
|
+
* @returns {Promise<Buffer>} Next socket data.
|
|
72
|
+
*/
|
|
73
|
+
async function readOnceWithin(socket, timeoutMs = 1_000) {
|
|
74
|
+
return await new Promise((resolve, reject) => {
|
|
75
|
+
/** @param {Buffer} chunk */
|
|
76
|
+
const onData = (chunk) => {
|
|
77
|
+
clearTimeout(timer)
|
|
78
|
+
resolve(chunk)
|
|
79
|
+
}
|
|
80
|
+
const timer = setTimeout(() => {
|
|
81
|
+
socket.off("data", onData)
|
|
82
|
+
reject(new Error("timed out waiting for socket data"))
|
|
83
|
+
}, timeoutMs)
|
|
84
|
+
socket.once("data", onData)
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {import('node:net').Socket} socket - Socket to wait on.
|
|
90
|
+
* @returns {Promise<void>} - Resolves after connection.
|
|
91
|
+
*/
|
|
92
|
+
async function waitConnect(socket) {
|
|
93
|
+
await new Promise((resolve, reject) => {
|
|
94
|
+
socket.once("error", reject)
|
|
95
|
+
socket.once("connect", () => {
|
|
96
|
+
socket.off("error", reject)
|
|
97
|
+
resolve(undefined)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param {import('node:net').Socket} socket - Socket to wait on.
|
|
104
|
+
* @returns {Promise<void>} - Resolves when closed.
|
|
105
|
+
*/
|
|
106
|
+
async function waitClose(socket) {
|
|
107
|
+
if (socket.destroyed) {
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
await new Promise((resolve) => {
|
|
111
|
+
socket.once("close", () => resolve(undefined))
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @param {() => boolean} predicate - Condition to await.
|
|
117
|
+
* @param {number} [timeoutMs] - Maximum wait.
|
|
118
|
+
* @returns {Promise<void>} Resolves when the condition is true.
|
|
119
|
+
*/
|
|
120
|
+
async function waitUntil(predicate, timeoutMs = 1_000) {
|
|
121
|
+
const deadline = Date.now() + timeoutMs
|
|
122
|
+
while (!predicate()) {
|
|
123
|
+
if (Date.now() >= deadline) throw new Error("timed out waiting for condition")
|
|
124
|
+
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
test("TCP edge relays traffic and replays spooled client data after relay reconnect", async () => {
|
|
129
|
+
const tempDir = await mkdtemp(join(tmpdir(), "socketduct-forwarding-test-"))
|
|
130
|
+
const echoServer = createServer((socket) => {
|
|
131
|
+
socket.on("data", (chunk) => socket.write(chunk))
|
|
132
|
+
})
|
|
133
|
+
const targetPort = await listen(echoServer)
|
|
134
|
+
|
|
135
|
+
const users = new InMemoryUserStore()
|
|
136
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
137
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
138
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
139
|
+
|
|
140
|
+
const relay = await startTcpRelay({
|
|
141
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
142
|
+
tokens,
|
|
143
|
+
sessions: new SessionStore({now: () => 1000})
|
|
144
|
+
})
|
|
145
|
+
const edge = await startTcpEdge({
|
|
146
|
+
relayHost: "127.0.0.1",
|
|
147
|
+
relayPort: relay.port,
|
|
148
|
+
token: token.value,
|
|
149
|
+
sessionName: "integration session",
|
|
150
|
+
spool: new SessionSpool({rootDir: tempDir}),
|
|
151
|
+
reconnectDelayMs: 10,
|
|
152
|
+
target: {host: "127.0.0.1", port: targetPort}
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
const appSocket = createConnection({host: "127.0.0.1", port: edge.port})
|
|
156
|
+
await waitConnect(appSocket)
|
|
157
|
+
appSocket.write("first")
|
|
158
|
+
assert.equal((await readOnce(appSocket)).toString("utf8"), "first")
|
|
159
|
+
|
|
160
|
+
await relay.closeActiveTunnel()
|
|
161
|
+
appSocket.write("second")
|
|
162
|
+
assert.equal((await readOnce(appSocket)).toString("utf8"), "second")
|
|
163
|
+
|
|
164
|
+
appSocket.destroy()
|
|
165
|
+
await edge.close()
|
|
166
|
+
await relay.close()
|
|
167
|
+
await new Promise((resolve, reject) => {
|
|
168
|
+
echoServer.close((error) => {
|
|
169
|
+
if (error) {
|
|
170
|
+
reject(error)
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
resolve(undefined)
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
await rm(tempDir, {recursive: true, force: true})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test("TCP edge includes the requested target in session create and relay connects to it", async () => {
|
|
180
|
+
const tempDir = await mkdtemp(join(tmpdir(), "socketduct-target-create-test-"))
|
|
181
|
+
let targetConnections = 0
|
|
182
|
+
const targetServer = createServer((socket) => {
|
|
183
|
+
targetConnections += 1
|
|
184
|
+
socket.on("data", (chunk) => socket.write(chunk))
|
|
185
|
+
})
|
|
186
|
+
const targetPort = await listen(targetServer)
|
|
187
|
+
|
|
188
|
+
const users = new InMemoryUserStore()
|
|
189
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
190
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
191
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
192
|
+
const sessions = new SessionStore({now: () => 1000})
|
|
193
|
+
/** @type {Array<{host: string, port: number}>} */
|
|
194
|
+
const authorizedTargets = []
|
|
195
|
+
|
|
196
|
+
const relay = await startTcpRelay({
|
|
197
|
+
authorizeTarget: ({target}) => {
|
|
198
|
+
authorizedTargets.push(target)
|
|
199
|
+
return target.host === "127.0.0.1" && target.port === targetPort
|
|
200
|
+
},
|
|
201
|
+
tokens,
|
|
202
|
+
sessions
|
|
203
|
+
})
|
|
204
|
+
const edge = await startTcpEdge({
|
|
205
|
+
relayHost: "127.0.0.1",
|
|
206
|
+
relayPort: relay.port,
|
|
207
|
+
token: token.value,
|
|
208
|
+
sessionName: "target create session",
|
|
209
|
+
target: {host: "127.0.0.1", port: targetPort},
|
|
210
|
+
spool: new SessionSpool({rootDir: tempDir}),
|
|
211
|
+
reconnectDelayMs: 10
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
const appSocket = createConnection({host: "127.0.0.1", port: edge.port})
|
|
215
|
+
await waitConnect(appSocket)
|
|
216
|
+
appSocket.write("target-ok")
|
|
217
|
+
assert.equal((await readOnce(appSocket)).toString("utf8"), "target-ok")
|
|
218
|
+
|
|
219
|
+
assert.deepEqual(authorizedTargets, [{host: "127.0.0.1", port: targetPort}])
|
|
220
|
+
assert.equal(targetConnections, 1)
|
|
221
|
+
|
|
222
|
+
appSocket.destroy()
|
|
223
|
+
await edge.close()
|
|
224
|
+
await relay.close()
|
|
225
|
+
await new Promise((resolve, reject) => {
|
|
226
|
+
targetServer.close((error) => {
|
|
227
|
+
if (error) {
|
|
228
|
+
reject(error)
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
resolve(undefined)
|
|
232
|
+
})
|
|
233
|
+
})
|
|
234
|
+
await rm(tempDir, {recursive: true, force: true})
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
test("TCP relay rejects unauthorized targets during session create", async () => {
|
|
238
|
+
const users = new InMemoryUserStore()
|
|
239
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
240
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
241
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
242
|
+
const relay = await startTcpRelay({
|
|
243
|
+
authorizeTarget: () => false,
|
|
244
|
+
tokens,
|
|
245
|
+
sessions: new SessionStore({now: () => 1000})
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
const socket = createConnection({host: "127.0.0.1", port: relay.port})
|
|
249
|
+
const decoder = new FrameDecoder({compactDirection: "target-to-client"})
|
|
250
|
+
await waitConnect(socket)
|
|
251
|
+
socket.write(encodeFrame({
|
|
252
|
+
type: "session.create",
|
|
253
|
+
token: token.value,
|
|
254
|
+
name: "blocked",
|
|
255
|
+
target: {host: "127.0.0.1", port: 9}
|
|
256
|
+
}))
|
|
257
|
+
const [frame] = decoder.push(await readOnce(socket))
|
|
258
|
+
assert.equal(frame?.header.type, "error")
|
|
259
|
+
assert.equal(frame?.header.code, "TARGET_UNAUTHORIZED")
|
|
260
|
+
|
|
261
|
+
socket.destroy()
|
|
262
|
+
await relay.close()
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
test("TCP relay keeps target socket alive across tunnel reconnect", async () => {
|
|
266
|
+
let targetConnections = 0
|
|
267
|
+
const targetServer = createServer((socket) => {
|
|
268
|
+
targetConnections += 1
|
|
269
|
+
socket.on("data", (chunk) => socket.write(chunk))
|
|
270
|
+
})
|
|
271
|
+
const targetPort = await listen(targetServer)
|
|
272
|
+
|
|
273
|
+
const users = new InMemoryUserStore()
|
|
274
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
275
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
276
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
277
|
+
|
|
278
|
+
const relay = await startTcpRelay({
|
|
279
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
280
|
+
tokens,
|
|
281
|
+
sessions: new SessionStore({now: () => 1000}),
|
|
282
|
+
resumeWindowMs: 1_000
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
const firstTunnel = createConnection({host: "127.0.0.1", port: relay.port})
|
|
286
|
+
const firstDecoder = new FrameDecoder({compactDirection: "target-to-client"})
|
|
287
|
+
await waitConnect(firstTunnel)
|
|
288
|
+
firstTunnel.write(encodeFrame({
|
|
289
|
+
type: "session.create",
|
|
290
|
+
token: token.value,
|
|
291
|
+
name: "retention-test",
|
|
292
|
+
identifier: "session-retained-target",
|
|
293
|
+
target: {host: "127.0.0.1", port: targetPort}
|
|
294
|
+
}))
|
|
295
|
+
firstDecoder.push(await readOnce(firstTunnel))
|
|
296
|
+
firstTunnel.write(encodeFrame({type: "stream.data", direction: "client-to-target", sequence: 5}, Buffer.from("first")))
|
|
297
|
+
await readOnce(firstTunnel)
|
|
298
|
+
assert.equal(targetConnections, 1)
|
|
299
|
+
|
|
300
|
+
relay.closeActiveTunnel()
|
|
301
|
+
await waitClose(firstTunnel)
|
|
302
|
+
|
|
303
|
+
const secondTunnel = createConnection({host: "127.0.0.1", port: relay.port})
|
|
304
|
+
const secondDecoder = new FrameDecoder({compactDirection: "target-to-client"})
|
|
305
|
+
await waitConnect(secondTunnel)
|
|
306
|
+
secondTunnel.write(encodeFrame({type: "session.resume", token: token.value, identifier: "session-retained-target"}))
|
|
307
|
+
secondDecoder.push(await readOnce(secondTunnel))
|
|
308
|
+
secondTunnel.write(encodeFrame({type: "stream.data", direction: "client-to-target", sequence: 11}, Buffer.from("second")))
|
|
309
|
+
|
|
310
|
+
let echoed
|
|
311
|
+
while (echoed === undefined) {
|
|
312
|
+
for (const frame of secondDecoder.push(await readOnce(secondTunnel))) {
|
|
313
|
+
if (frame.header.type === "stream.data") {
|
|
314
|
+
echoed = frame.payload.toString("utf8")
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
assert.equal(echoed, "second")
|
|
320
|
+
assert.equal(targetConnections, 1)
|
|
321
|
+
|
|
322
|
+
secondTunnel.destroy()
|
|
323
|
+
await relay.close()
|
|
324
|
+
await new Promise((resolve, reject) => {
|
|
325
|
+
targetServer.close((error) => {
|
|
326
|
+
if (error) {
|
|
327
|
+
reject(error)
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
resolve(undefined)
|
|
331
|
+
})
|
|
332
|
+
})
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
test("TCP relay rejects stream data before session authentication", async () => {
|
|
336
|
+
const echoServer = createServer((socket) => {
|
|
337
|
+
socket.on("data", (chunk) => socket.write(chunk))
|
|
338
|
+
})
|
|
339
|
+
const targetPort = await listen(echoServer)
|
|
340
|
+
|
|
341
|
+
const relay = await startTcpRelay({
|
|
342
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
343
|
+
tokens: new TokenStore(),
|
|
344
|
+
sessions: new SessionStore()
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
const socket = createConnection({host: "127.0.0.1", port: relay.port})
|
|
348
|
+
const decoder = new FrameDecoder({compactDirection: "target-to-client"})
|
|
349
|
+
await waitConnect(socket)
|
|
350
|
+
socket.write(encodeFrame({
|
|
351
|
+
type: "stream.data",
|
|
352
|
+
direction: "client-to-target",
|
|
353
|
+
sequence: 1
|
|
354
|
+
}, Buffer.from("blocked")))
|
|
355
|
+
|
|
356
|
+
const frames = decoder.push(await readOnce(socket))
|
|
357
|
+
const [frame] = frames
|
|
358
|
+
if (frame === undefined) {
|
|
359
|
+
throw new Error("Relay did not return an error frame")
|
|
360
|
+
}
|
|
361
|
+
assert.equal(frame.header.type, "error")
|
|
362
|
+
assert.equal(frame.header.code, "AUTH_REQUIRED")
|
|
363
|
+
|
|
364
|
+
socket.destroy()
|
|
365
|
+
await relay.close()
|
|
366
|
+
await new Promise((resolve, reject) => {
|
|
367
|
+
echoServer.close((error) => {
|
|
368
|
+
if (error) {
|
|
369
|
+
reject(error)
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
resolve(undefined)
|
|
373
|
+
})
|
|
374
|
+
})
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
test("TCP relay closes malformed frames without crashing the server", async () => {
|
|
378
|
+
const echoServer = createServer((socket) => {
|
|
379
|
+
socket.on("data", (chunk) => socket.write(chunk))
|
|
380
|
+
})
|
|
381
|
+
const targetPort = await listen(echoServer)
|
|
382
|
+
|
|
383
|
+
const relay = await startTcpRelay({
|
|
384
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
385
|
+
tokens: new TokenStore(),
|
|
386
|
+
sessions: new SessionStore()
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
const malformed = createConnection({host: "127.0.0.1", port: relay.port})
|
|
390
|
+
await waitConnect(malformed)
|
|
391
|
+
malformed.write(Buffer.alloc(4))
|
|
392
|
+
await new Promise((resolve) => { setTimeout(resolve, 20) })
|
|
393
|
+
malformed.destroy()
|
|
394
|
+
|
|
395
|
+
const healthy = createConnection({host: "127.0.0.1", port: relay.port})
|
|
396
|
+
const decoder = new FrameDecoder({compactDirection: "target-to-client"})
|
|
397
|
+
await waitConnect(healthy)
|
|
398
|
+
healthy.write(encodeFrame({type: "stream.data", direction: "client-to-target", sequence: 1}, Buffer.from("blocked")))
|
|
399
|
+
const [frame] = decoder.push(await readOnce(healthy))
|
|
400
|
+
if (frame === undefined) {
|
|
401
|
+
throw new Error("Relay did not return an error frame after malformed input")
|
|
402
|
+
}
|
|
403
|
+
assert.equal(frame.header.type, "error")
|
|
404
|
+
assert.equal(frame.header.code, "AUTH_REQUIRED")
|
|
405
|
+
|
|
406
|
+
healthy.destroy()
|
|
407
|
+
await relay.close()
|
|
408
|
+
await new Promise((resolve, reject) => {
|
|
409
|
+
echoServer.close((error) => {
|
|
410
|
+
if (error) {
|
|
411
|
+
reject(error)
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
resolve(undefined)
|
|
415
|
+
})
|
|
416
|
+
})
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
test("TCP relay persists committed client sequence before acknowledging writes", async () => {
|
|
420
|
+
const echoServer = createServer((socket) => {
|
|
421
|
+
socket.resume()
|
|
422
|
+
})
|
|
423
|
+
const targetPort = await listen(echoServer)
|
|
424
|
+
|
|
425
|
+
const users = new InMemoryUserStore()
|
|
426
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
427
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
428
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
429
|
+
const sessions = new SessionStore({now: () => 1000})
|
|
430
|
+
|
|
431
|
+
const relay = await startTcpRelay({
|
|
432
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
433
|
+
tokens,
|
|
434
|
+
sessions
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
const socket = createConnection({host: "127.0.0.1", port: relay.port})
|
|
438
|
+
const decoder = new FrameDecoder({compactDirection: "target-to-client"})
|
|
439
|
+
await waitConnect(socket)
|
|
440
|
+
socket.write(encodeFrame({type: "session.create", token: token.value, name: "seq-test", identifier: "session-seq", target: {host: "127.0.0.1", port: targetPort}}))
|
|
441
|
+
decoder.push(await readOnce(socket))
|
|
442
|
+
socket.write(encodeFrame({type: "stream.data", direction: "client-to-target", session: "session-seq", sequence: 5}, Buffer.from("hello")))
|
|
443
|
+
|
|
444
|
+
let acked = false
|
|
445
|
+
while (!acked) {
|
|
446
|
+
for (const frame of decoder.push(await readOnce(socket))) {
|
|
447
|
+
if (frame.header.type === "stream.ack" && frame.header.direction === "client-to-target") {
|
|
448
|
+
acked = true
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
assert.equal(sessions.get("session-seq")?.clientSequence, 5)
|
|
454
|
+
|
|
455
|
+
socket.destroy()
|
|
456
|
+
await relay.close()
|
|
457
|
+
await new Promise((resolve, reject) => {
|
|
458
|
+
echoServer.close((error) => {
|
|
459
|
+
if (error) {
|
|
460
|
+
reject(error)
|
|
461
|
+
return
|
|
462
|
+
}
|
|
463
|
+
resolve(undefined)
|
|
464
|
+
})
|
|
465
|
+
})
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
test("TCP relay buffers target bytes while a resumable tunnel is down", async () => {
|
|
469
|
+
/** @type {import('node:net').Socket | undefined} */
|
|
470
|
+
let targetSocket
|
|
471
|
+
const targetServer = createServer((socket) => {
|
|
472
|
+
targetSocket = socket
|
|
473
|
+
socket.resume()
|
|
474
|
+
})
|
|
475
|
+
const targetPort = await listen(targetServer)
|
|
476
|
+
|
|
477
|
+
const users = new InMemoryUserStore()
|
|
478
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
479
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
480
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
481
|
+
const sessions = new SessionStore({now: () => 1000})
|
|
482
|
+
const relay = await startTcpRelay({
|
|
483
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
484
|
+
tokens,
|
|
485
|
+
sessions,
|
|
486
|
+
resumeWindowMs: 1_000
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
const firstTunnel = createConnection({host: "127.0.0.1", port: relay.port})
|
|
490
|
+
const firstDecoder = new FrameDecoder({compactDirection: "target-to-client"})
|
|
491
|
+
await waitConnect(firstTunnel)
|
|
492
|
+
firstTunnel.write(encodeFrame({type: "session.create", token: token.value, name: "buffer-test", identifier: "session-buffer", target: {host: "127.0.0.1", port: targetPort}}))
|
|
493
|
+
firstDecoder.push(await readOnce(firstTunnel))
|
|
494
|
+
|
|
495
|
+
relay.closeActiveTunnel()
|
|
496
|
+
await waitClose(firstTunnel)
|
|
497
|
+
targetSocket?.write("during-drop")
|
|
498
|
+
|
|
499
|
+
const secondTunnel = createConnection({host: "127.0.0.1", port: relay.port})
|
|
500
|
+
const secondDecoder = new FrameDecoder({compactDirection: "target-to-client"})
|
|
501
|
+
await waitConnect(secondTunnel)
|
|
502
|
+
secondTunnel.write(encodeFrame({type: "session.resume", token: token.value, identifier: "session-buffer"}))
|
|
503
|
+
|
|
504
|
+
let received
|
|
505
|
+
while (received === undefined) {
|
|
506
|
+
for (const frame of secondDecoder.push(await readOnce(secondTunnel))) {
|
|
507
|
+
if (frame.header.type === "stream.data") {
|
|
508
|
+
received = frame.payload.toString("utf8")
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
assert.equal(received, "during-drop")
|
|
514
|
+
assert.equal(sessions.get("session-buffer")?.relaySequence, "during-drop".length)
|
|
515
|
+
|
|
516
|
+
secondTunnel.destroy()
|
|
517
|
+
await relay.close()
|
|
518
|
+
await new Promise((resolve, reject) => {
|
|
519
|
+
targetServer.close((error) => {
|
|
520
|
+
if (error) {
|
|
521
|
+
reject(error)
|
|
522
|
+
return
|
|
523
|
+
}
|
|
524
|
+
resolve(undefined)
|
|
525
|
+
})
|
|
526
|
+
})
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
test("TCP relay ignores stale tunnel data after session resume", async () => {
|
|
530
|
+
/** @type {string[]} */
|
|
531
|
+
const targetWrites = []
|
|
532
|
+
const targetServer = createServer((socket) => {
|
|
533
|
+
socket.on("data", (chunk) => {
|
|
534
|
+
targetWrites.push(chunk.toString("utf8"))
|
|
535
|
+
socket.write(chunk)
|
|
536
|
+
})
|
|
537
|
+
})
|
|
538
|
+
const targetPort = await listen(targetServer)
|
|
539
|
+
|
|
540
|
+
const users = new InMemoryUserStore()
|
|
541
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
542
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
543
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
544
|
+
const sessions = new SessionStore({now: () => 1000})
|
|
545
|
+
const relay = await startTcpRelay({
|
|
546
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
547
|
+
tokens,
|
|
548
|
+
sessions,
|
|
549
|
+
resumeWindowMs: 1_000
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
const tunnelA = createConnection({host: "127.0.0.1", port: relay.port})
|
|
553
|
+
const decoderA = new FrameDecoder({compactDirection: "target-to-client"})
|
|
554
|
+
await waitConnect(tunnelA)
|
|
555
|
+
tunnelA.write(encodeFrame({
|
|
556
|
+
type: "session.create",
|
|
557
|
+
token: token.value,
|
|
558
|
+
name: "authority-test",
|
|
559
|
+
identifier: "session-authority",
|
|
560
|
+
target: {host: "127.0.0.1", port: targetPort}
|
|
561
|
+
}))
|
|
562
|
+
decoderA.push(await readOnce(tunnelA))
|
|
563
|
+
tunnelA.write(encodeFrame({type: "stream.data", direction: "client-to-target", sequence: 5}, Buffer.from("first")))
|
|
564
|
+
let firstEcho
|
|
565
|
+
while (firstEcho === undefined) {
|
|
566
|
+
for (const frame of decoderA.push(await readOnce(tunnelA))) {
|
|
567
|
+
if (frame.header.type === "stream.data") {
|
|
568
|
+
firstEcho = frame.payload.toString("utf8")
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
assert.equal(firstEcho, "first")
|
|
573
|
+
|
|
574
|
+
const tunnelB = createConnection({host: "127.0.0.1", port: relay.port})
|
|
575
|
+
const decoderB = new FrameDecoder({compactDirection: "target-to-client"})
|
|
576
|
+
await waitConnect(tunnelB)
|
|
577
|
+
tunnelB.write(encodeFrame({type: "session.resume", token: token.value, identifier: "session-authority"}))
|
|
578
|
+
decoderB.push(await readOnce(tunnelB))
|
|
579
|
+
|
|
580
|
+
tunnelA.write(encodeFrame({type: "stream.data", direction: "client-to-target", sequence: 999}, Buffer.from("stale")))
|
|
581
|
+
|
|
582
|
+
const staleResponse = await Promise.race([
|
|
583
|
+
readOnce(tunnelA).then(() => "data"),
|
|
584
|
+
new Promise((resolve) => setTimeout(() => resolve("timeout"), 200))
|
|
585
|
+
])
|
|
586
|
+
assert.equal(staleResponse, "timeout", "stale tunnel must not receive ack")
|
|
587
|
+
|
|
588
|
+
tunnelB.write(encodeFrame({type: "stream.data", direction: "client-to-target", sequence: 10}, Buffer.from("legit")))
|
|
589
|
+
let legitEcho
|
|
590
|
+
while (legitEcho === undefined) {
|
|
591
|
+
for (const frame of decoderB.push(await readOnce(tunnelB))) {
|
|
592
|
+
if (frame.header.type === "stream.data") {
|
|
593
|
+
legitEcho = frame.payload.toString("utf8")
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
assert.equal(legitEcho, "legit")
|
|
598
|
+
|
|
599
|
+
assert.deepEqual(targetWrites, ["first", "legit"])
|
|
600
|
+
assert.equal(sessions.get("session-authority")?.clientSequence, 10)
|
|
601
|
+
|
|
602
|
+
tunnelA.destroy()
|
|
603
|
+
tunnelB.write(encodeFrame({type: "stream.data", direction: "client-to-target", sequence: 21}, Buffer.from("after-close")))
|
|
604
|
+
let afterCloseEcho
|
|
605
|
+
while (afterCloseEcho === undefined) {
|
|
606
|
+
for (const frame of decoderB.push(await readOnce(tunnelB))) {
|
|
607
|
+
if (frame.header.type === "stream.data") {
|
|
608
|
+
afterCloseEcho = frame.payload.toString("utf8")
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
assert.equal(afterCloseEcho, "after-close")
|
|
613
|
+
assert.deepEqual(targetWrites, ["first", "legit", "after-close"])
|
|
614
|
+
|
|
615
|
+
tunnelB.destroy()
|
|
616
|
+
await relay.close()
|
|
617
|
+
await new Promise((resolve, reject) => {
|
|
618
|
+
targetServer.close((error) => {
|
|
619
|
+
if (error) {
|
|
620
|
+
reject(error)
|
|
621
|
+
return
|
|
622
|
+
}
|
|
623
|
+
resolve(undefined)
|
|
624
|
+
})
|
|
625
|
+
})
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
test("TCP relay ignores stale tunnel target-to-client ACK after session resume", async () => {
|
|
629
|
+
const echoServer = createServer((socket) => {
|
|
630
|
+
socket.on("data", (chunk) => socket.write(chunk))
|
|
631
|
+
})
|
|
632
|
+
const targetPort = await listen(echoServer)
|
|
633
|
+
|
|
634
|
+
const users = new InMemoryUserStore()
|
|
635
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
636
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
637
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
638
|
+
const sessions = new SessionStore({now: () => 1000})
|
|
639
|
+
|
|
640
|
+
const relay = await startTcpRelay({
|
|
641
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
642
|
+
tokens,
|
|
643
|
+
sessions,
|
|
644
|
+
resumeWindowMs: 2_000
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
const tunnel1 = createConnection({host: "127.0.0.1", port: relay.port})
|
|
648
|
+
const decoder1 = new FrameDecoder({compactDirection: "target-to-client"})
|
|
649
|
+
await waitConnect(tunnel1)
|
|
650
|
+
tunnel1.write(encodeFrame({
|
|
651
|
+
type: "session.create",
|
|
652
|
+
token: token.value,
|
|
653
|
+
name: "stale-ack-test",
|
|
654
|
+
identifier: "session-stale-ack",
|
|
655
|
+
target: {host: "127.0.0.1", port: targetPort}
|
|
656
|
+
}))
|
|
657
|
+
decoder1.push(await readOnce(tunnel1))
|
|
658
|
+
|
|
659
|
+
tunnel1.write(encodeFrame({type: "stream.data", direction: "client-to-target", sequence: 5}, Buffer.from("alpha")))
|
|
660
|
+
/** @type {string[]} */
|
|
661
|
+
const tunnel1Chunks = []
|
|
662
|
+
while (tunnel1Chunks.length < 1) {
|
|
663
|
+
for (const frame of decoder1.push(await readOnce(tunnel1))) {
|
|
664
|
+
if (frame.header.type === "stream.data" && frame.header.direction === "target-to-client") {
|
|
665
|
+
tunnel1Chunks.push(frame.payload.toString("utf8"))
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
tunnel1.write(encodeFrame({type: "stream.data", direction: "client-to-target", sequence: 10}, Buffer.from("bravo")))
|
|
671
|
+
while (tunnel1Chunks.length < 2) {
|
|
672
|
+
for (const frame of decoder1.push(await readOnce(tunnel1))) {
|
|
673
|
+
if (frame.header.type === "stream.data" && frame.header.direction === "target-to-client") {
|
|
674
|
+
tunnel1Chunks.push(frame.payload.toString("utf8"))
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const tunnel2 = createConnection({host: "127.0.0.1", port: relay.port})
|
|
680
|
+
const decoder2 = new FrameDecoder({compactDirection: "target-to-client"})
|
|
681
|
+
await waitConnect(tunnel2)
|
|
682
|
+
tunnel2.write(encodeFrame({type: "session.resume", token: token.value, identifier: "session-stale-ack"}))
|
|
683
|
+
|
|
684
|
+
let resumed2 = false
|
|
685
|
+
const replayed2 = []
|
|
686
|
+
while (!resumed2 || replayed2.length < 2) {
|
|
687
|
+
for (const frame of decoder2.push(await readOnce(tunnel2))) {
|
|
688
|
+
if (frame.header.type === "session.resumed") {
|
|
689
|
+
resumed2 = true
|
|
690
|
+
}
|
|
691
|
+
if (frame.header.type === "stream.data" && frame.header.direction === "target-to-client") {
|
|
692
|
+
replayed2.push(frame.payload.toString("utf8"))
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
assert.deepEqual(replayed2, ["alpha", "bravo"])
|
|
697
|
+
|
|
698
|
+
const bravoRelaySeq = sessions.get("session-stale-ack")?.relaySequence ?? 0
|
|
699
|
+
assert.ok(bravoRelaySeq > 0, "relaySequence must be > 0 after two echoed chunks")
|
|
700
|
+
|
|
701
|
+
tunnel1.write(encodeFrame({type: "stream.ack", direction: "target-to-client", ackSequence: bravoRelaySeq}))
|
|
702
|
+
|
|
703
|
+
await new Promise((resolve) => { setTimeout(resolve, 100) })
|
|
704
|
+
|
|
705
|
+
tunnel2.destroy()
|
|
706
|
+
await waitClose(tunnel2)
|
|
707
|
+
|
|
708
|
+
const tunnel3 = createConnection({host: "127.0.0.1", port: relay.port})
|
|
709
|
+
const decoder3 = new FrameDecoder({compactDirection: "target-to-client"})
|
|
710
|
+
await waitConnect(tunnel3)
|
|
711
|
+
tunnel3.write(encodeFrame({type: "session.resume", token: token.value, identifier: "session-stale-ack"}))
|
|
712
|
+
|
|
713
|
+
let resumed3 = false
|
|
714
|
+
const replayed3 = []
|
|
715
|
+
while (!resumed3 || replayed3.length < 2) {
|
|
716
|
+
for (const frame of decoder3.push(await readOnce(tunnel3))) {
|
|
717
|
+
if (frame.header.type === "session.resumed") {
|
|
718
|
+
resumed3 = true
|
|
719
|
+
}
|
|
720
|
+
if (frame.header.type === "stream.data" && frame.header.direction === "target-to-client") {
|
|
721
|
+
replayed3.push(frame.payload.toString("utf8"))
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
assert.deepEqual(replayed3, ["alpha", "bravo"])
|
|
726
|
+
|
|
727
|
+
tunnel1.destroy()
|
|
728
|
+
tunnel3.destroy()
|
|
729
|
+
await relay.close()
|
|
730
|
+
await new Promise((resolve, reject) => {
|
|
731
|
+
echoServer.close((error) => {
|
|
732
|
+
if (error) {
|
|
733
|
+
reject(error)
|
|
734
|
+
return
|
|
735
|
+
}
|
|
736
|
+
resolve(undefined)
|
|
737
|
+
})
|
|
738
|
+
})
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
test("TCP edge closes the app socket when relay rejects the session", async () => {
|
|
742
|
+
const echoServer = createServer((socket) => {
|
|
743
|
+
socket.on("data", (chunk) => socket.write(chunk))
|
|
744
|
+
})
|
|
745
|
+
const targetPort = await listen(echoServer)
|
|
746
|
+
const relay = await startTcpRelay({
|
|
747
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
748
|
+
tokens: new TokenStore(),
|
|
749
|
+
sessions: new SessionStore()
|
|
750
|
+
})
|
|
751
|
+
const tempDir = await mkdtemp(join(tmpdir(), "socketduct-invalid-token-test-"))
|
|
752
|
+
const edge = await startTcpEdge({
|
|
753
|
+
relayHost: "127.0.0.1",
|
|
754
|
+
relayPort: relay.port,
|
|
755
|
+
token: "invalid-token",
|
|
756
|
+
sessionName: "invalid session",
|
|
757
|
+
spool: new SessionSpool({rootDir: tempDir}),
|
|
758
|
+
reconnectDelayMs: 10,
|
|
759
|
+
target: {host: "127.0.0.1", port: targetPort}
|
|
760
|
+
})
|
|
761
|
+
|
|
762
|
+
const appSocket = createConnection({host: "127.0.0.1", port: edge.port})
|
|
763
|
+
await waitConnect(appSocket)
|
|
764
|
+
appSocket.write("blocked")
|
|
765
|
+
await waitClose(appSocket)
|
|
766
|
+
|
|
767
|
+
await edge.close()
|
|
768
|
+
await relay.close()
|
|
769
|
+
await new Promise((resolve, reject) => {
|
|
770
|
+
echoServer.close((error) => {
|
|
771
|
+
if (error) {
|
|
772
|
+
reject(error)
|
|
773
|
+
return
|
|
774
|
+
}
|
|
775
|
+
resolve(undefined)
|
|
776
|
+
})
|
|
777
|
+
})
|
|
778
|
+
await rm(tempDir, {recursive: true, force: true})
|
|
779
|
+
})
|
|
780
|
+
|
|
781
|
+
test("TCP relay replays target-to-client data not acknowledged before tunnel disconnect", async () => {
|
|
782
|
+
const echoServer = createServer((socket) => {
|
|
783
|
+
socket.on("data", (chunk) => socket.write(chunk))
|
|
784
|
+
})
|
|
785
|
+
const targetPort = await listen(echoServer)
|
|
786
|
+
|
|
787
|
+
const users = new InMemoryUserStore()
|
|
788
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
789
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
790
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
791
|
+
const sessions = new SessionStore({now: () => 1000})
|
|
792
|
+
|
|
793
|
+
const relay = await startTcpRelay({
|
|
794
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
795
|
+
tokens,
|
|
796
|
+
sessions,
|
|
797
|
+
resumeWindowMs: 2_000
|
|
798
|
+
})
|
|
799
|
+
|
|
800
|
+
const tunnel1 = createConnection({host: "127.0.0.1", port: relay.port})
|
|
801
|
+
const decoder1 = new FrameDecoder({compactDirection: "target-to-client"})
|
|
802
|
+
await waitConnect(tunnel1)
|
|
803
|
+
tunnel1.write(encodeFrame({
|
|
804
|
+
type: "session.create",
|
|
805
|
+
token: token.value,
|
|
806
|
+
name: "replay-test",
|
|
807
|
+
identifier: "session-replay",
|
|
808
|
+
target: {host: "127.0.0.1", port: targetPort}
|
|
809
|
+
}))
|
|
810
|
+
decoder1.push(await readOnce(tunnel1))
|
|
811
|
+
|
|
812
|
+
tunnel1.write(encodeFrame({type: "stream.data", direction: "client-to-target", sequence: 5}, Buffer.from("alpha")))
|
|
813
|
+
let alphaEcho
|
|
814
|
+
while (alphaEcho === undefined) {
|
|
815
|
+
for (const frame of decoder1.push(await readOnce(tunnel1))) {
|
|
816
|
+
if (frame.header.type === "stream.data" && frame.header.direction === "target-to-client") {
|
|
817
|
+
alphaEcho = frame.payload.toString("utf8")
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
assert.equal(alphaEcho, "alpha")
|
|
822
|
+
|
|
823
|
+
tunnel1.write(encodeFrame({type: "stream.data", direction: "client-to-target", sequence: 10}, Buffer.from("bravo")))
|
|
824
|
+
let bravoEcho
|
|
825
|
+
while (bravoEcho === undefined) {
|
|
826
|
+
for (const frame of decoder1.push(await readOnce(tunnel1))) {
|
|
827
|
+
if (frame.header.type === "stream.data" && frame.header.direction === "target-to-client") {
|
|
828
|
+
bravoEcho = frame.payload.toString("utf8")
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
assert.equal(bravoEcho, "bravo")
|
|
833
|
+
|
|
834
|
+
tunnel1.destroy()
|
|
835
|
+
await waitClose(tunnel1)
|
|
836
|
+
|
|
837
|
+
const tunnel2 = createConnection({host: "127.0.0.1", port: relay.port})
|
|
838
|
+
const decoder2 = new FrameDecoder({compactDirection: "target-to-client"})
|
|
839
|
+
await waitConnect(tunnel2)
|
|
840
|
+
tunnel2.write(encodeFrame({type: "session.resume", token: token.value, identifier: "session-replay"}))
|
|
841
|
+
|
|
842
|
+
let resumed
|
|
843
|
+
/** @type {string[]} */
|
|
844
|
+
const replayedChunks = []
|
|
845
|
+
while (resumed === undefined || replayedChunks.length < 2) {
|
|
846
|
+
for (const frame of decoder2.push(await readOnce(tunnel2))) {
|
|
847
|
+
if (frame.header.type === "session.resumed") {
|
|
848
|
+
resumed = true
|
|
849
|
+
}
|
|
850
|
+
if (frame.header.type === "stream.data" && frame.header.direction === "target-to-client") {
|
|
851
|
+
replayedChunks.push(frame.payload.toString("utf8"))
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
assert.deepEqual(replayedChunks, ["alpha", "bravo"])
|
|
857
|
+
assert.equal(sessions.get("session-replay")?.relaySequence, 10)
|
|
858
|
+
|
|
859
|
+
tunnel2.destroy()
|
|
860
|
+
await relay.close()
|
|
861
|
+
await new Promise((resolve, reject) => {
|
|
862
|
+
echoServer.close((error) => {
|
|
863
|
+
if (error) {
|
|
864
|
+
reject(error)
|
|
865
|
+
return
|
|
866
|
+
}
|
|
867
|
+
resolve(undefined)
|
|
868
|
+
})
|
|
869
|
+
})
|
|
870
|
+
})
|
|
871
|
+
|
|
872
|
+
test("TCP relay delivers buffered data then session.close when target closes while tunnel is detached", async () => {
|
|
873
|
+
let targetConnectionCount = 0
|
|
874
|
+
/** @type {import('node:net').Socket | undefined} */
|
|
875
|
+
let heldTargetSocket
|
|
876
|
+
const targetServer = createServer((socket) => {
|
|
877
|
+
targetConnectionCount += 1
|
|
878
|
+
heldTargetSocket = socket
|
|
879
|
+
socket.on("data", (chunk) => socket.write(chunk))
|
|
880
|
+
})
|
|
881
|
+
const targetPort = await listen(targetServer)
|
|
882
|
+
|
|
883
|
+
const users = new InMemoryUserStore()
|
|
884
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
885
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
886
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
887
|
+
const sessions = new SessionStore({now: () => 1000})
|
|
888
|
+
|
|
889
|
+
const relay = await startTcpRelay({
|
|
890
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
891
|
+
tokens,
|
|
892
|
+
sessions,
|
|
893
|
+
resumeWindowMs: 2_000
|
|
894
|
+
})
|
|
895
|
+
|
|
896
|
+
const tunnel1 = createConnection({host: "127.0.0.1", port: relay.port})
|
|
897
|
+
const decoder1 = new FrameDecoder({compactDirection: "target-to-client"})
|
|
898
|
+
await waitConnect(tunnel1)
|
|
899
|
+
tunnel1.write(encodeFrame({
|
|
900
|
+
type: "session.create",
|
|
901
|
+
token: token.value,
|
|
902
|
+
name: "eof-test",
|
|
903
|
+
identifier: "session-eof",
|
|
904
|
+
target: {host: "127.0.0.1", port: targetPort}
|
|
905
|
+
}))
|
|
906
|
+
decoder1.push(await readOnce(tunnel1))
|
|
907
|
+
tunnel1.write(encodeFrame({type: "stream.data", direction: "client-to-target", sequence: 4}, Buffer.from("ping")))
|
|
908
|
+
let pingEcho
|
|
909
|
+
while (pingEcho === undefined) {
|
|
910
|
+
for (const frame of decoder1.push(await readOnce(tunnel1))) {
|
|
911
|
+
if (frame.header.type === "stream.data" && frame.header.direction === "target-to-client") {
|
|
912
|
+
pingEcho = frame.payload.toString("utf8")
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
assert.equal(pingEcho, "ping")
|
|
917
|
+
assert.equal(targetConnectionCount, 1)
|
|
918
|
+
|
|
919
|
+
relay.closeActiveTunnel()
|
|
920
|
+
await waitClose(tunnel1)
|
|
921
|
+
|
|
922
|
+
if (heldTargetSocket !== undefined) {
|
|
923
|
+
heldTargetSocket.write("final")
|
|
924
|
+
heldTargetSocket.end()
|
|
925
|
+
await waitClose(heldTargetSocket)
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const tunnel2 = createConnection({host: "127.0.0.1", port: relay.port})
|
|
929
|
+
const decoder2 = new FrameDecoder({compactDirection: "target-to-client"})
|
|
930
|
+
await waitConnect(tunnel2)
|
|
931
|
+
tunnel2.write(encodeFrame({type: "session.resume", token: token.value, identifier: "session-eof", targetToClientAck: 0}))
|
|
932
|
+
|
|
933
|
+
let resumed = false
|
|
934
|
+
/** @type {string[]} */
|
|
935
|
+
const targetChunks = []
|
|
936
|
+
let gotClose = false
|
|
937
|
+
while (!resumed || !gotClose) {
|
|
938
|
+
for (const frame of decoder2.push(await readOnce(tunnel2))) {
|
|
939
|
+
if (frame.header.type === "session.resumed") {
|
|
940
|
+
resumed = true
|
|
941
|
+
}
|
|
942
|
+
if (frame.header.type === "stream.data" && frame.header.direction === "target-to-client") {
|
|
943
|
+
targetChunks.push(frame.payload.toString("utf8"))
|
|
944
|
+
}
|
|
945
|
+
if (frame.header.type === "session.close") {
|
|
946
|
+
gotClose = true
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
assert.deepEqual(targetChunks, ["ping", "final"])
|
|
952
|
+
assert.equal(gotClose, true)
|
|
953
|
+
assert.equal(targetConnectionCount, 1)
|
|
954
|
+
|
|
955
|
+
await new Promise((resolve) => { setTimeout(resolve, 200) })
|
|
956
|
+
assert.equal(targetConnectionCount, 1)
|
|
957
|
+
|
|
958
|
+
tunnel2.destroy()
|
|
959
|
+
await relay.close()
|
|
960
|
+
await new Promise((resolve, reject) => {
|
|
961
|
+
targetServer.close((error) => {
|
|
962
|
+
if (error) {
|
|
963
|
+
reject(error)
|
|
964
|
+
return
|
|
965
|
+
}
|
|
966
|
+
resolve(undefined)
|
|
967
|
+
})
|
|
968
|
+
})
|
|
969
|
+
})
|
|
970
|
+
|
|
971
|
+
test("TCP relay replays buffered target-to-client data within the per-session byte limit after reconnect", async () => {
|
|
972
|
+
/** @type {import('node:net').Socket | undefined} */
|
|
973
|
+
let targetSocket
|
|
974
|
+
const targetServer = createServer((socket) => {
|
|
975
|
+
targetSocket = socket
|
|
976
|
+
socket.resume()
|
|
977
|
+
})
|
|
978
|
+
const targetPort = await listen(targetServer)
|
|
979
|
+
|
|
980
|
+
const users = new InMemoryUserStore()
|
|
981
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
982
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
983
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
984
|
+
const sessions = new SessionStore({now: () => 1000})
|
|
985
|
+
|
|
986
|
+
const relay = await startTcpRelay({
|
|
987
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
988
|
+
tokens,
|
|
989
|
+
sessions,
|
|
990
|
+
resumeWindowMs: 2_000,
|
|
991
|
+
maxTargetBufferBytes: 100
|
|
992
|
+
})
|
|
993
|
+
|
|
994
|
+
const tunnel1 = createConnection({host: "127.0.0.1", port: relay.port})
|
|
995
|
+
const decoder1 = new FrameDecoder({compactDirection: "target-to-client"})
|
|
996
|
+
await waitConnect(tunnel1)
|
|
997
|
+
tunnel1.write(encodeFrame({
|
|
998
|
+
type: "session.create",
|
|
999
|
+
token: token.value,
|
|
1000
|
+
name: "buffer-limit-test",
|
|
1001
|
+
identifier: "session-buf-limit",
|
|
1002
|
+
target: {host: "127.0.0.1", port: targetPort}
|
|
1003
|
+
}))
|
|
1004
|
+
decoder1.push(await readOnce(tunnel1))
|
|
1005
|
+
|
|
1006
|
+
relay.closeActiveTunnel()
|
|
1007
|
+
await waitClose(tunnel1)
|
|
1008
|
+
|
|
1009
|
+
const withinLimitPayload = Buffer.alloc(80, 0x41)
|
|
1010
|
+
targetSocket?.write(withinLimitPayload)
|
|
1011
|
+
|
|
1012
|
+
const tunnel2 = createConnection({host: "127.0.0.1", port: relay.port})
|
|
1013
|
+
const decoder2 = new FrameDecoder({compactDirection: "target-to-client"})
|
|
1014
|
+
await waitConnect(tunnel2)
|
|
1015
|
+
tunnel2.write(encodeFrame({
|
|
1016
|
+
type: "session.resume",
|
|
1017
|
+
token: token.value,
|
|
1018
|
+
identifier: "session-buf-limit"
|
|
1019
|
+
}))
|
|
1020
|
+
|
|
1021
|
+
let resumed = false
|
|
1022
|
+
/** @type {Buffer[]} */
|
|
1023
|
+
const receivedChunks = []
|
|
1024
|
+
while (!resumed || receivedChunks.length < 1) {
|
|
1025
|
+
for (const frame of decoder2.push(await readOnce(tunnel2))) {
|
|
1026
|
+
if (frame.header.type === "session.resumed") {
|
|
1027
|
+
resumed = true
|
|
1028
|
+
}
|
|
1029
|
+
if (frame.header.type === "stream.data" && frame.header.direction === "target-to-client") {
|
|
1030
|
+
receivedChunks.push(frame.payload)
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
assert.equal(receivedChunks.length, 1)
|
|
1036
|
+
const firstChunk = /** @type {Buffer} */ (receivedChunks[0])
|
|
1037
|
+
assert.equal(firstChunk.length, 80)
|
|
1038
|
+
assert.deepEqual(firstChunk, withinLimitPayload)
|
|
1039
|
+
|
|
1040
|
+
tunnel2.destroy()
|
|
1041
|
+
await relay.close()
|
|
1042
|
+
await new Promise((resolve, reject) => {
|
|
1043
|
+
targetServer.close((error) => {
|
|
1044
|
+
if (error) {
|
|
1045
|
+
reject(error)
|
|
1046
|
+
return
|
|
1047
|
+
}
|
|
1048
|
+
resolve(undefined)
|
|
1049
|
+
})
|
|
1050
|
+
})
|
|
1051
|
+
})
|
|
1052
|
+
|
|
1053
|
+
test("TCP relay terminates session with nonretryable TARGET_BUFFER_OVERFLOW when per-session buffer limit is exceeded", async () => {
|
|
1054
|
+
/** @type {import('node:net').Socket | undefined} */
|
|
1055
|
+
let targetSocket
|
|
1056
|
+
const targetServer = createServer((socket) => {
|
|
1057
|
+
targetSocket = socket
|
|
1058
|
+
socket.resume()
|
|
1059
|
+
})
|
|
1060
|
+
const targetPort = await listen(targetServer)
|
|
1061
|
+
|
|
1062
|
+
const users = new InMemoryUserStore()
|
|
1063
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
1064
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
1065
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
1066
|
+
const sessions = new SessionStore({now: () => 1000})
|
|
1067
|
+
|
|
1068
|
+
const relay = await startTcpRelay({
|
|
1069
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
1070
|
+
tokens,
|
|
1071
|
+
sessions,
|
|
1072
|
+
resumeWindowMs: 2_000,
|
|
1073
|
+
maxTargetBufferBytes: 10
|
|
1074
|
+
})
|
|
1075
|
+
|
|
1076
|
+
const tunnel1 = createConnection({host: "127.0.0.1", port: relay.port})
|
|
1077
|
+
const decoder1 = new FrameDecoder({compactDirection: "target-to-client"})
|
|
1078
|
+
await waitConnect(tunnel1)
|
|
1079
|
+
tunnel1.write(encodeFrame({
|
|
1080
|
+
type: "session.create",
|
|
1081
|
+
token: token.value,
|
|
1082
|
+
name: "overflow-test",
|
|
1083
|
+
identifier: "session-overflow",
|
|
1084
|
+
target: {host: "127.0.0.1", port: targetPort}
|
|
1085
|
+
}))
|
|
1086
|
+
decoder1.push(await readOnce(tunnel1))
|
|
1087
|
+
|
|
1088
|
+
relay.closeActiveTunnel()
|
|
1089
|
+
await waitClose(tunnel1)
|
|
1090
|
+
|
|
1091
|
+
const overflowPayload = Buffer.alloc(128, 0x42)
|
|
1092
|
+
targetSocket?.write(overflowPayload)
|
|
1093
|
+
|
|
1094
|
+
await new Promise((resolve) => { setTimeout(resolve, 100) })
|
|
1095
|
+
|
|
1096
|
+
const tunnel2 = createConnection({host: "127.0.0.1", port: relay.port})
|
|
1097
|
+
const decoder2 = new FrameDecoder({compactDirection: "target-to-client"})
|
|
1098
|
+
await waitConnect(tunnel2)
|
|
1099
|
+
tunnel2.write(encodeFrame({
|
|
1100
|
+
type: "session.resume",
|
|
1101
|
+
token: token.value,
|
|
1102
|
+
identifier: "session-overflow"
|
|
1103
|
+
}))
|
|
1104
|
+
|
|
1105
|
+
let resumed = false
|
|
1106
|
+
/** @type {Array<{type: string, code?: string, retryable?: boolean}>} */
|
|
1107
|
+
const receivedFrames = []
|
|
1108
|
+
while (!resumed) {
|
|
1109
|
+
for (const frame of decoder2.push(await readOnce(tunnel2))) {
|
|
1110
|
+
receivedFrames.push(frame.header)
|
|
1111
|
+
if (frame.header.type === "session.resumed") {
|
|
1112
|
+
resumed = true
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
const errorFrame = receivedFrames.find((f) => f.type === "error")
|
|
1118
|
+
assert.ok(errorFrame !== undefined, "expected an error frame after reconnect for overflow session")
|
|
1119
|
+
assert.equal(errorFrame?.code, "TARGET_BUFFER_OVERFLOW")
|
|
1120
|
+
assert.equal(errorFrame?.retryable, false)
|
|
1121
|
+
|
|
1122
|
+
tunnel2.destroy()
|
|
1123
|
+
await relay.close()
|
|
1124
|
+
await new Promise((resolve, reject) => {
|
|
1125
|
+
targetServer.close((error) => {
|
|
1126
|
+
if (error) {
|
|
1127
|
+
reject(error)
|
|
1128
|
+
return
|
|
1129
|
+
}
|
|
1130
|
+
resolve(undefined)
|
|
1131
|
+
})
|
|
1132
|
+
})
|
|
1133
|
+
})
|
|
1134
|
+
|
|
1135
|
+
test("TCP relay enforces process-wide total buffer limit across two detached sessions: first resumes, second gets TARGET_BUFFER_OVERFLOW", async () => {
|
|
1136
|
+
/** @type {import('node:net').Socket | undefined} */
|
|
1137
|
+
let targetSocket1
|
|
1138
|
+
const targetServer1 = createServer((socket) => {
|
|
1139
|
+
targetSocket1 = socket
|
|
1140
|
+
socket.resume()
|
|
1141
|
+
})
|
|
1142
|
+
const targetPort1 = await listen(targetServer1)
|
|
1143
|
+
|
|
1144
|
+
/** @type {import('node:net').Socket | undefined} */
|
|
1145
|
+
let targetSocket2
|
|
1146
|
+
const targetServer2 = createServer((socket) => {
|
|
1147
|
+
targetSocket2 = socket
|
|
1148
|
+
socket.resume()
|
|
1149
|
+
})
|
|
1150
|
+
const targetPort2 = await listen(targetServer2)
|
|
1151
|
+
|
|
1152
|
+
const users = new InMemoryUserStore()
|
|
1153
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
1154
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
1155
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
1156
|
+
const sessions = new SessionStore({now: () => 1000})
|
|
1157
|
+
|
|
1158
|
+
const relay = await startTcpRelay({
|
|
1159
|
+
authorizeTarget: ({target}) =>
|
|
1160
|
+
target.host === "127.0.0.1" && (target.port === targetPort1 || target.port === targetPort2),
|
|
1161
|
+
tokens,
|
|
1162
|
+
sessions,
|
|
1163
|
+
resumeWindowMs: 2_000,
|
|
1164
|
+
maxTargetBufferBytes: 100,
|
|
1165
|
+
maxTotalBufferBytes: 150
|
|
1166
|
+
})
|
|
1167
|
+
|
|
1168
|
+
const tunnel1 = createConnection({host: "127.0.0.1", port: relay.port})
|
|
1169
|
+
const decoder1 = new FrameDecoder({compactDirection: "target-to-client"})
|
|
1170
|
+
await waitConnect(tunnel1)
|
|
1171
|
+
tunnel1.write(encodeFrame({
|
|
1172
|
+
type: "session.create",
|
|
1173
|
+
token: token.value,
|
|
1174
|
+
name: "proc-limit-s1",
|
|
1175
|
+
identifier: "proc-limit-s1",
|
|
1176
|
+
target: {host: "127.0.0.1", port: targetPort1}
|
|
1177
|
+
}))
|
|
1178
|
+
decoder1.push(await readOnce(tunnel1))
|
|
1179
|
+
|
|
1180
|
+
const tunnel2 = createConnection({host: "127.0.0.1", port: relay.port})
|
|
1181
|
+
const decoder2 = new FrameDecoder({compactDirection: "target-to-client"})
|
|
1182
|
+
await waitConnect(tunnel2)
|
|
1183
|
+
tunnel2.write(encodeFrame({
|
|
1184
|
+
type: "session.create",
|
|
1185
|
+
token: token.value,
|
|
1186
|
+
name: "proc-limit-s2",
|
|
1187
|
+
identifier: "proc-limit-s2",
|
|
1188
|
+
target: {host: "127.0.0.1", port: targetPort2}
|
|
1189
|
+
}))
|
|
1190
|
+
decoder2.push(await readOnce(tunnel2))
|
|
1191
|
+
|
|
1192
|
+
relay.closeActiveTunnel()
|
|
1193
|
+
await waitClose(tunnel1)
|
|
1194
|
+
await waitClose(tunnel2)
|
|
1195
|
+
|
|
1196
|
+
const s1Payload = Buffer.alloc(80, 0x41)
|
|
1197
|
+
const s2Payload = Buffer.alloc(80, 0x42)
|
|
1198
|
+
targetSocket1?.write(s1Payload)
|
|
1199
|
+
await new Promise((resolve) => { setTimeout(resolve, 50) })
|
|
1200
|
+
targetSocket2?.write(s2Payload)
|
|
1201
|
+
await new Promise((resolve) => { setTimeout(resolve, 50) })
|
|
1202
|
+
|
|
1203
|
+
const tunnel3 = createConnection({host: "127.0.0.1", port: relay.port})
|
|
1204
|
+
const decoder3 = new FrameDecoder({compactDirection: "target-to-client"})
|
|
1205
|
+
await waitConnect(tunnel3)
|
|
1206
|
+
tunnel3.write(encodeFrame({type: "session.resume", token: token.value, identifier: "proc-limit-s1"}))
|
|
1207
|
+
|
|
1208
|
+
let resumed1 = false
|
|
1209
|
+
const s1Received = []
|
|
1210
|
+
while (!resumed1 || s1Received.length < 1) {
|
|
1211
|
+
for (const frame of decoder3.push(await readOnce(tunnel3))) {
|
|
1212
|
+
if (frame.header.type === "session.resumed") {
|
|
1213
|
+
resumed1 = true
|
|
1214
|
+
}
|
|
1215
|
+
if (frame.header.type === "stream.data" && frame.header.direction === "target-to-client") {
|
|
1216
|
+
s1Received.push(frame.payload)
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
assert.equal(s1Received.length, 1)
|
|
1222
|
+
const s1Chunk = /** @type {Buffer} */ (s1Received[0])
|
|
1223
|
+
assert.equal(s1Chunk.length, 80)
|
|
1224
|
+
assert.deepEqual(s1Chunk, s1Payload)
|
|
1225
|
+
|
|
1226
|
+
tunnel3.destroy()
|
|
1227
|
+
|
|
1228
|
+
const tunnel4 = createConnection({host: "127.0.0.1", port: relay.port})
|
|
1229
|
+
const decoder4 = new FrameDecoder({compactDirection: "target-to-client"})
|
|
1230
|
+
await waitConnect(tunnel4)
|
|
1231
|
+
tunnel4.write(encodeFrame({type: "session.resume", token: token.value, identifier: "proc-limit-s2"}))
|
|
1232
|
+
|
|
1233
|
+
let resumed2 = false
|
|
1234
|
+
/** @type {Array<{type: string, code?: string, retryable?: boolean, message?: string, direction?: string}>} */
|
|
1235
|
+
const s2Headers = []
|
|
1236
|
+
while (!resumed2) {
|
|
1237
|
+
for (const frame of decoder4.push(await readOnce(tunnel4))) {
|
|
1238
|
+
s2Headers.push(frame.header)
|
|
1239
|
+
if (frame.header.type === "session.resumed") {
|
|
1240
|
+
resumed2 = true
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
const errorHeader = s2Headers.find((h) => h.type === "error")
|
|
1246
|
+
assert.ok(errorHeader !== undefined, "expected error frame after resume for overflowed session")
|
|
1247
|
+
assert.equal(errorHeader?.code, "TARGET_BUFFER_OVERFLOW")
|
|
1248
|
+
assert.equal(errorHeader?.retryable, false)
|
|
1249
|
+
assert.ok(
|
|
1250
|
+
typeof errorHeader?.message === "string" && errorHeader.message.includes("Process-wide"),
|
|
1251
|
+
`message should reference Process-wide limit, got: ${errorHeader?.message}`
|
|
1252
|
+
)
|
|
1253
|
+
|
|
1254
|
+
const s2DataHeaders = s2Headers.filter((h) => h.type === "stream.data" && h.direction === "target-to-client")
|
|
1255
|
+
assert.equal(s2DataHeaders.length, 0, "overflowed session must not receive stream.data from a replacement target")
|
|
1256
|
+
|
|
1257
|
+
tunnel4.destroy()
|
|
1258
|
+
await relay.close()
|
|
1259
|
+
await new Promise((resolve, reject) => {
|
|
1260
|
+
targetServer1.close((error) => {
|
|
1261
|
+
if (error) { reject(error); return }
|
|
1262
|
+
resolve(undefined)
|
|
1263
|
+
})
|
|
1264
|
+
})
|
|
1265
|
+
await new Promise((resolve, reject) => {
|
|
1266
|
+
targetServer2.close((error) => {
|
|
1267
|
+
if (error) { reject(error); return }
|
|
1268
|
+
resolve(undefined)
|
|
1269
|
+
})
|
|
1270
|
+
})
|
|
1271
|
+
})
|
|
1272
|
+
|
|
1273
|
+
test("TCP relay rejects a spoofed compact marker on a length-prefixed stream.data after a v2 handshake", async () => {
|
|
1274
|
+
const echoServer = createServer((socket) => {
|
|
1275
|
+
socket.on("data", (chunk) => socket.write(chunk))
|
|
1276
|
+
})
|
|
1277
|
+
const targetPort = await listen(echoServer)
|
|
1278
|
+
|
|
1279
|
+
const users = new InMemoryUserStore()
|
|
1280
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
1281
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
1282
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
1283
|
+
const sessions = new SessionStore({now: () => 1000})
|
|
1284
|
+
|
|
1285
|
+
const relay = await startTcpRelay({
|
|
1286
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
1287
|
+
tokens,
|
|
1288
|
+
sessions
|
|
1289
|
+
})
|
|
1290
|
+
|
|
1291
|
+
const socket = createConnection({host: "127.0.0.1", port: relay.port})
|
|
1292
|
+
const decoder = new FrameDecoder({compactDirection: "target-to-client"})
|
|
1293
|
+
await waitConnect(socket)
|
|
1294
|
+
socket.write(encodeFrame({
|
|
1295
|
+
type: "session.create",
|
|
1296
|
+
token: token.value,
|
|
1297
|
+
name: "neg-seq-test",
|
|
1298
|
+
identifier: "session-neg-seq",
|
|
1299
|
+
target: {host: "127.0.0.1", port: targetPort}
|
|
1300
|
+
}))
|
|
1301
|
+
decoder.push(await readOnce(socket))
|
|
1302
|
+
|
|
1303
|
+
socket.write(encodeEnvelope({
|
|
1304
|
+
type: "stream.data",
|
|
1305
|
+
direction: "client-to-target",
|
|
1306
|
+
sequence: -5,
|
|
1307
|
+
compact: true
|
|
1308
|
+
}, Buffer.from("negdata")))
|
|
1309
|
+
|
|
1310
|
+
let errorFrame
|
|
1311
|
+
while (errorFrame === undefined) {
|
|
1312
|
+
for (const frame of decoder.push(await readOnce(socket))) {
|
|
1313
|
+
if (frame.header.type === "error") {
|
|
1314
|
+
errorFrame = frame.header
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
assert.equal(errorFrame?.code, "PROTOCOL_VIOLATION")
|
|
1319
|
+
assert.ok(
|
|
1320
|
+
typeof errorFrame?.message === "string" && errorFrame.message.includes("Length-prefixed stream.data"),
|
|
1321
|
+
`unexpected message: ${errorFrame?.message}`
|
|
1322
|
+
)
|
|
1323
|
+
|
|
1324
|
+
assert.equal(sessions.get("session-neg-seq")?.clientSequence, 0)
|
|
1325
|
+
|
|
1326
|
+
socket.destroy()
|
|
1327
|
+
await relay.close()
|
|
1328
|
+
await new Promise((resolve, reject) => {
|
|
1329
|
+
echoServer.close((error) => {
|
|
1330
|
+
if (error) { reject(error); return }
|
|
1331
|
+
resolve(undefined)
|
|
1332
|
+
})
|
|
1333
|
+
})
|
|
1334
|
+
})
|
|
1335
|
+
|
|
1336
|
+
test("TCP relay rejects length-prefixed stream.ack after a v2 handshake", async () => {
|
|
1337
|
+
const echoServer = createServer((socket) => {
|
|
1338
|
+
socket.on("data", (chunk) => socket.write(chunk))
|
|
1339
|
+
})
|
|
1340
|
+
const targetPort = await listen(echoServer)
|
|
1341
|
+
|
|
1342
|
+
const users = new InMemoryUserStore()
|
|
1343
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
1344
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
1345
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
1346
|
+
const sessions = new SessionStore({now: () => 1000})
|
|
1347
|
+
|
|
1348
|
+
const relay = await startTcpRelay({
|
|
1349
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
1350
|
+
tokens,
|
|
1351
|
+
sessions
|
|
1352
|
+
})
|
|
1353
|
+
|
|
1354
|
+
const socket = createConnection({host: "127.0.0.1", port: relay.port})
|
|
1355
|
+
const decoder = new FrameDecoder({compactDirection: "target-to-client"})
|
|
1356
|
+
await waitConnect(socket)
|
|
1357
|
+
socket.write(encodeFrame({
|
|
1358
|
+
type: "session.create",
|
|
1359
|
+
token: token.value,
|
|
1360
|
+
name: "nan-seq-test",
|
|
1361
|
+
identifier: "session-nan-seq",
|
|
1362
|
+
target: {host: "127.0.0.1", port: targetPort}
|
|
1363
|
+
}))
|
|
1364
|
+
decoder.push(await readOnce(socket))
|
|
1365
|
+
|
|
1366
|
+
socket.write(encodeEnvelope({
|
|
1367
|
+
type: "stream.ack",
|
|
1368
|
+
direction: "target-to-client",
|
|
1369
|
+
ackSequence: 0,
|
|
1370
|
+
compact: true
|
|
1371
|
+
}))
|
|
1372
|
+
|
|
1373
|
+
let errorFrame
|
|
1374
|
+
while (errorFrame === undefined) {
|
|
1375
|
+
for (const frame of decoder.push(await readOnceWithin(socket))) {
|
|
1376
|
+
if (frame.header.type === "error") {
|
|
1377
|
+
errorFrame = frame.header
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
assert.equal(errorFrame?.code, "PROTOCOL_VIOLATION")
|
|
1382
|
+
assert.match(String(errorFrame?.message), /Length-prefixed stream\.ack/)
|
|
1383
|
+
assert.equal(sessions.get("session-nan-seq")?.clientSequence, 0)
|
|
1384
|
+
|
|
1385
|
+
socket.destroy()
|
|
1386
|
+
await relay.close()
|
|
1387
|
+
await new Promise((resolve, reject) => {
|
|
1388
|
+
echoServer.close((error) => {
|
|
1389
|
+
if (error) { reject(error); return }
|
|
1390
|
+
resolve(undefined)
|
|
1391
|
+
})
|
|
1392
|
+
})
|
|
1393
|
+
})
|
|
1394
|
+
|
|
1395
|
+
test("TCP relay rejects a length-prefixed stream.data regardless of envelope sequence metadata", async () => {
|
|
1396
|
+
const echoServer = createServer((socket) => {
|
|
1397
|
+
socket.on("data", (chunk) => socket.write(chunk))
|
|
1398
|
+
})
|
|
1399
|
+
const targetPort = await listen(echoServer)
|
|
1400
|
+
|
|
1401
|
+
const users = new InMemoryUserStore()
|
|
1402
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
1403
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
1404
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
1405
|
+
const sessions = new SessionStore({now: () => 1000})
|
|
1406
|
+
|
|
1407
|
+
const relay = await startTcpRelay({
|
|
1408
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
1409
|
+
tokens,
|
|
1410
|
+
sessions
|
|
1411
|
+
})
|
|
1412
|
+
|
|
1413
|
+
const socket = createConnection({host: "127.0.0.1", port: relay.port})
|
|
1414
|
+
const decoder = new FrameDecoder({compactDirection: "target-to-client"})
|
|
1415
|
+
await waitConnect(socket)
|
|
1416
|
+
socket.write(encodeFrame({
|
|
1417
|
+
type: "session.create",
|
|
1418
|
+
token: token.value,
|
|
1419
|
+
name: "unsafe-seq-test",
|
|
1420
|
+
identifier: "session-unsafe-seq",
|
|
1421
|
+
target: {host: "127.0.0.1", port: targetPort}
|
|
1422
|
+
}))
|
|
1423
|
+
decoder.push(await readOnce(socket))
|
|
1424
|
+
|
|
1425
|
+
socket.write(encodeEnvelope({
|
|
1426
|
+
type: "stream.data",
|
|
1427
|
+
direction: "client-to-target",
|
|
1428
|
+
sequence: Number.MAX_SAFE_INTEGER + 1
|
|
1429
|
+
}, Buffer.from("toobig")))
|
|
1430
|
+
|
|
1431
|
+
let errorFrame
|
|
1432
|
+
while (errorFrame === undefined) {
|
|
1433
|
+
for (const frame of decoder.push(await readOnce(socket))) {
|
|
1434
|
+
if (frame.header.type === "error") {
|
|
1435
|
+
errorFrame = frame.header
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
assert.equal(errorFrame?.code, "PROTOCOL_VIOLATION")
|
|
1440
|
+
assert.equal(sessions.get("session-unsafe-seq")?.clientSequence, 0)
|
|
1441
|
+
|
|
1442
|
+
socket.destroy()
|
|
1443
|
+
await relay.close()
|
|
1444
|
+
await new Promise((resolve, reject) => {
|
|
1445
|
+
echoServer.close((error) => {
|
|
1446
|
+
if (error) { reject(error); return }
|
|
1447
|
+
resolve(undefined)
|
|
1448
|
+
})
|
|
1449
|
+
})
|
|
1450
|
+
})
|
|
1451
|
+
|
|
1452
|
+
test("TCP relay rejects stream.data with a gapped client-to-target sequence and does not write the target", async () => {
|
|
1453
|
+
/** @type {string[]} */
|
|
1454
|
+
const targetWrites = []
|
|
1455
|
+
const targetServer = createServer((socket) => {
|
|
1456
|
+
socket.on("data", (chunk) => {
|
|
1457
|
+
targetWrites.push(chunk.toString("utf8"))
|
|
1458
|
+
socket.write(chunk)
|
|
1459
|
+
})
|
|
1460
|
+
})
|
|
1461
|
+
const targetPort = await listen(targetServer)
|
|
1462
|
+
|
|
1463
|
+
const users = new InMemoryUserStore()
|
|
1464
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
1465
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
1466
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
1467
|
+
const sessions = new SessionStore({now: () => 1000})
|
|
1468
|
+
|
|
1469
|
+
const relay = await startTcpRelay({
|
|
1470
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
1471
|
+
tokens,
|
|
1472
|
+
sessions
|
|
1473
|
+
})
|
|
1474
|
+
|
|
1475
|
+
const socket = createConnection({host: "127.0.0.1", port: relay.port})
|
|
1476
|
+
const decoder = new FrameDecoder({compactDirection: "target-to-client"})
|
|
1477
|
+
await waitConnect(socket)
|
|
1478
|
+
socket.write(encodeFrame({
|
|
1479
|
+
type: "session.create",
|
|
1480
|
+
token: token.value,
|
|
1481
|
+
name: "gap-test",
|
|
1482
|
+
identifier: "session-gap",
|
|
1483
|
+
target: {host: "127.0.0.1", port: targetPort}
|
|
1484
|
+
}))
|
|
1485
|
+
decoder.push(await readOnce(socket))
|
|
1486
|
+
|
|
1487
|
+
socket.write(encodeFrame({
|
|
1488
|
+
type: "stream.data",
|
|
1489
|
+
direction: "client-to-target",
|
|
1490
|
+
sequence: 12
|
|
1491
|
+
}, Buffer.from("gapped")))
|
|
1492
|
+
|
|
1493
|
+
let errorFrame
|
|
1494
|
+
while (errorFrame === undefined) {
|
|
1495
|
+
for (const frame of decoder.push(await readOnce(socket))) {
|
|
1496
|
+
if (frame.header.type === "error") {
|
|
1497
|
+
errorFrame = frame.header
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
assert.equal(errorFrame?.code, "PROTOCOL_VIOLATION")
|
|
1502
|
+
assert.ok(
|
|
1503
|
+
typeof errorFrame?.message === "string" && errorFrame.message.includes("gap"),
|
|
1504
|
+
`unexpected message: ${errorFrame?.message}`
|
|
1505
|
+
)
|
|
1506
|
+
|
|
1507
|
+
assert.deepEqual(targetWrites, [])
|
|
1508
|
+
assert.equal(sessions.get("session-gap")?.clientSequence, 0)
|
|
1509
|
+
|
|
1510
|
+
socket.destroy()
|
|
1511
|
+
await relay.close()
|
|
1512
|
+
await new Promise((resolve, reject) => {
|
|
1513
|
+
targetServer.close((error) => {
|
|
1514
|
+
if (error) { reject(error); return }
|
|
1515
|
+
resolve(undefined)
|
|
1516
|
+
})
|
|
1517
|
+
})
|
|
1518
|
+
})
|
|
1519
|
+
|
|
1520
|
+
test("TCP relay rejects stream.data with an overlapping client-to-target sequence and does not mutate state", async () => {
|
|
1521
|
+
/** @type {string[]} */
|
|
1522
|
+
const targetWrites = []
|
|
1523
|
+
const targetServer = createServer((socket) => {
|
|
1524
|
+
socket.on("data", (chunk) => {
|
|
1525
|
+
targetWrites.push(chunk.toString("utf8"))
|
|
1526
|
+
socket.write(chunk)
|
|
1527
|
+
})
|
|
1528
|
+
})
|
|
1529
|
+
const targetPort = await listen(targetServer)
|
|
1530
|
+
|
|
1531
|
+
const users = new InMemoryUserStore()
|
|
1532
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
1533
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
1534
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
1535
|
+
const sessions = new SessionStore({now: () => 1000})
|
|
1536
|
+
|
|
1537
|
+
const relay = await startTcpRelay({
|
|
1538
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
1539
|
+
tokens,
|
|
1540
|
+
sessions
|
|
1541
|
+
})
|
|
1542
|
+
|
|
1543
|
+
const socket = createConnection({host: "127.0.0.1", port: relay.port})
|
|
1544
|
+
const decoder = new FrameDecoder({compactDirection: "target-to-client"})
|
|
1545
|
+
await waitConnect(socket)
|
|
1546
|
+
socket.write(encodeFrame({
|
|
1547
|
+
type: "session.create",
|
|
1548
|
+
token: token.value,
|
|
1549
|
+
name: "overlap-test",
|
|
1550
|
+
identifier: "session-overlap",
|
|
1551
|
+
target: {host: "127.0.0.1", port: targetPort}
|
|
1552
|
+
}))
|
|
1553
|
+
decoder.push(await readOnce(socket))
|
|
1554
|
+
|
|
1555
|
+
socket.write(encodeFrame({
|
|
1556
|
+
type: "stream.data",
|
|
1557
|
+
direction: "client-to-target",
|
|
1558
|
+
sequence: 10
|
|
1559
|
+
}, Buffer.from("1234567890")))
|
|
1560
|
+
|
|
1561
|
+
let acked = false
|
|
1562
|
+
while (!acked) {
|
|
1563
|
+
for (const frame of decoder.push(await readOnce(socket))) {
|
|
1564
|
+
if (frame.header.type === "stream.ack" && frame.header.direction === "client-to-target") {
|
|
1565
|
+
acked = true
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
assert.deepEqual(targetWrites, ["1234567890"])
|
|
1570
|
+
assert.equal(sessions.get("session-overlap")?.clientSequence, 10)
|
|
1571
|
+
|
|
1572
|
+
socket.write(encodeFrame({
|
|
1573
|
+
type: "stream.data",
|
|
1574
|
+
direction: "client-to-target",
|
|
1575
|
+
sequence: 13
|
|
1576
|
+
}, Buffer.from("abcdef")))
|
|
1577
|
+
|
|
1578
|
+
let errorFrame
|
|
1579
|
+
while (errorFrame === undefined) {
|
|
1580
|
+
for (const frame of decoder.push(await readOnce(socket))) {
|
|
1581
|
+
if (frame.header.type === "error") {
|
|
1582
|
+
errorFrame = frame.header
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
assert.equal(errorFrame?.code, "PROTOCOL_VIOLATION")
|
|
1587
|
+
assert.ok(
|
|
1588
|
+
typeof errorFrame?.message === "string" && errorFrame.message.includes("overlap"),
|
|
1589
|
+
`unexpected message: ${errorFrame?.message}`
|
|
1590
|
+
)
|
|
1591
|
+
assert.equal(sessions.get("session-overlap")?.clientSequence, 10)
|
|
1592
|
+
|
|
1593
|
+
socket.destroy()
|
|
1594
|
+
await relay.close()
|
|
1595
|
+
await new Promise((resolve, reject) => {
|
|
1596
|
+
targetServer.close((error) => {
|
|
1597
|
+
if (error) { reject(error); return }
|
|
1598
|
+
resolve(undefined)
|
|
1599
|
+
})
|
|
1600
|
+
})
|
|
1601
|
+
})
|
|
1602
|
+
|
|
1603
|
+
test("TCP relay acks an already-committed duplicate client-to-target sequence without writing the target", async () => {
|
|
1604
|
+
/** @type {string[]} */
|
|
1605
|
+
const targetWrites = []
|
|
1606
|
+
const targetServer = createServer((socket) => {
|
|
1607
|
+
socket.on("data", (chunk) => {
|
|
1608
|
+
targetWrites.push(chunk.toString("utf8"))
|
|
1609
|
+
socket.write(chunk)
|
|
1610
|
+
})
|
|
1611
|
+
})
|
|
1612
|
+
const targetPort = await listen(targetServer)
|
|
1613
|
+
|
|
1614
|
+
const users = new InMemoryUserStore()
|
|
1615
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
1616
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
1617
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
1618
|
+
const sessions = new SessionStore({now: () => 1000})
|
|
1619
|
+
|
|
1620
|
+
const relay = await startTcpRelay({
|
|
1621
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
1622
|
+
tokens,
|
|
1623
|
+
sessions
|
|
1624
|
+
})
|
|
1625
|
+
|
|
1626
|
+
const socket = createConnection({host: "127.0.0.1", port: relay.port})
|
|
1627
|
+
const decoder = new FrameDecoder({compactDirection: "target-to-client"})
|
|
1628
|
+
await waitConnect(socket)
|
|
1629
|
+
socket.write(encodeFrame({
|
|
1630
|
+
type: "session.create",
|
|
1631
|
+
token: token.value,
|
|
1632
|
+
name: "dup-test",
|
|
1633
|
+
identifier: "session-dup",
|
|
1634
|
+
target: {host: "127.0.0.1", port: targetPort}
|
|
1635
|
+
}))
|
|
1636
|
+
decoder.push(await readOnce(socket))
|
|
1637
|
+
|
|
1638
|
+
socket.write(encodeFrame({
|
|
1639
|
+
type: "stream.data",
|
|
1640
|
+
direction: "client-to-target",
|
|
1641
|
+
sequence: 5
|
|
1642
|
+
}, Buffer.from("hello")))
|
|
1643
|
+
|
|
1644
|
+
let acked = false
|
|
1645
|
+
while (!acked) {
|
|
1646
|
+
for (const frame of decoder.push(await readOnce(socket))) {
|
|
1647
|
+
if (frame.header.type === "stream.ack" && frame.header.direction === "client-to-target") {
|
|
1648
|
+
acked = true
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
assert.deepEqual(targetWrites, ["hello"])
|
|
1653
|
+
assert.equal(sessions.get("session-dup")?.clientSequence, 5)
|
|
1654
|
+
|
|
1655
|
+
socket.write(encodeFrame({
|
|
1656
|
+
type: "stream.data",
|
|
1657
|
+
direction: "client-to-target",
|
|
1658
|
+
sequence: 5
|
|
1659
|
+
}, Buffer.from("hello")))
|
|
1660
|
+
|
|
1661
|
+
let dupAck = false
|
|
1662
|
+
let dupError = false
|
|
1663
|
+
while (!dupAck && !dupError) {
|
|
1664
|
+
for (const frame of decoder.push(await readOnce(socket))) {
|
|
1665
|
+
if (frame.header.type === "stream.ack" && frame.header.direction === "client-to-target" && frame.header.ackSequence === 5) {
|
|
1666
|
+
dupAck = true
|
|
1667
|
+
}
|
|
1668
|
+
if (frame.header.type === "error") {
|
|
1669
|
+
dupError = true
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
assert.ok(dupAck, "duplicate frame must receive ack")
|
|
1674
|
+
assert.equal(dupError, false, "duplicate frame must not receive an error")
|
|
1675
|
+
assert.deepEqual(targetWrites, ["hello"])
|
|
1676
|
+
assert.equal(sessions.get("session-dup")?.clientSequence, 5)
|
|
1677
|
+
|
|
1678
|
+
socket.destroy()
|
|
1679
|
+
await relay.close()
|
|
1680
|
+
await new Promise((resolve, reject) => {
|
|
1681
|
+
targetServer.close((error) => {
|
|
1682
|
+
if (error) { reject(error); return }
|
|
1683
|
+
resolve(undefined)
|
|
1684
|
+
})
|
|
1685
|
+
})
|
|
1686
|
+
})
|
|
1687
|
+
|
|
1688
|
+
test("TCP relay accepts consecutive contiguous stream.data frames decoded in one TCP chunk while the first target write is still pending", async () => {
|
|
1689
|
+
/** @type {string[]} */
|
|
1690
|
+
const targetWrites = []
|
|
1691
|
+
const targetServer = createServer((socket) => {
|
|
1692
|
+
socket.on("data", (chunk) => {
|
|
1693
|
+
targetWrites.push(chunk.toString("utf8"))
|
|
1694
|
+
socket.write(chunk)
|
|
1695
|
+
})
|
|
1696
|
+
})
|
|
1697
|
+
const targetPort = await listen(targetServer)
|
|
1698
|
+
|
|
1699
|
+
const users = new InMemoryUserStore()
|
|
1700
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
1701
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
1702
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
1703
|
+
const sessions = new SessionStore({now: () => 1000})
|
|
1704
|
+
|
|
1705
|
+
const relay = await startTcpRelay({
|
|
1706
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
1707
|
+
tokens,
|
|
1708
|
+
sessions
|
|
1709
|
+
})
|
|
1710
|
+
|
|
1711
|
+
const socket = createConnection({host: "127.0.0.1", port: relay.port})
|
|
1712
|
+
const decoder = new FrameDecoder({compactDirection: "target-to-client"})
|
|
1713
|
+
await waitConnect(socket)
|
|
1714
|
+
socket.write(encodeFrame({
|
|
1715
|
+
type: "session.create",
|
|
1716
|
+
token: token.value,
|
|
1717
|
+
name: "contiguous-one-chunk",
|
|
1718
|
+
identifier: "session-contig",
|
|
1719
|
+
target: {host: "127.0.0.1", port: targetPort}
|
|
1720
|
+
}))
|
|
1721
|
+
decoder.push(await readOnce(socket))
|
|
1722
|
+
|
|
1723
|
+
const frame1 = encodeFrame({
|
|
1724
|
+
type: "stream.data",
|
|
1725
|
+
direction: "client-to-target",
|
|
1726
|
+
sequence: 10
|
|
1727
|
+
}, Buffer.from("1234567890"))
|
|
1728
|
+
|
|
1729
|
+
const frame2 = encodeFrame({
|
|
1730
|
+
type: "stream.data",
|
|
1731
|
+
direction: "client-to-target",
|
|
1732
|
+
sequence: 20
|
|
1733
|
+
}, Buffer.from("abcdefghij"))
|
|
1734
|
+
|
|
1735
|
+
const combined = Buffer.concat([frame1, frame2])
|
|
1736
|
+
socket.write(combined)
|
|
1737
|
+
|
|
1738
|
+
let ackCount = 0
|
|
1739
|
+
let errorCount = 0
|
|
1740
|
+
while (ackCount < 2 && errorCount === 0) {
|
|
1741
|
+
for (const frame of decoder.push(await readOnce(socket))) {
|
|
1742
|
+
if (frame.header.type === "stream.ack" && frame.header.direction === "client-to-target") {
|
|
1743
|
+
ackCount++
|
|
1744
|
+
}
|
|
1745
|
+
if (frame.header.type === "error") {
|
|
1746
|
+
errorCount++
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
assert.equal(errorCount, 0, "no protocol violation for contiguous frames in one chunk")
|
|
1752
|
+
assert.equal(ackCount, 2, "both frames must receive an ack")
|
|
1753
|
+
const receivedData = targetWrites.join("")
|
|
1754
|
+
assert.equal(receivedData, "1234567890abcdefghij")
|
|
1755
|
+
assert.equal(sessions.get("session-contig")?.clientSequence, 20)
|
|
1756
|
+
|
|
1757
|
+
socket.destroy()
|
|
1758
|
+
await relay.close()
|
|
1759
|
+
await new Promise((resolve, reject) => {
|
|
1760
|
+
targetServer.close((error) => {
|
|
1761
|
+
if (error) { reject(error); return }
|
|
1762
|
+
resolve(undefined)
|
|
1763
|
+
})
|
|
1764
|
+
})
|
|
1765
|
+
})
|
|
1766
|
+
|
|
1767
|
+
test("TCP relay replays unacknowledged target-to-client data exactly once after reconnect and does not replay acknowledged chunks", async () => {
|
|
1768
|
+
const echoServer = createServer((socket) => {
|
|
1769
|
+
socket.on("data", (chunk) => socket.write(chunk))
|
|
1770
|
+
})
|
|
1771
|
+
const targetPort = await listen(echoServer)
|
|
1772
|
+
|
|
1773
|
+
const users = new InMemoryUserStore()
|
|
1774
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
1775
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
1776
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
1777
|
+
const sessions = new SessionStore({now: () => 1000})
|
|
1778
|
+
|
|
1779
|
+
const relay = await startTcpRelay({
|
|
1780
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
1781
|
+
tokens,
|
|
1782
|
+
sessions,
|
|
1783
|
+
resumeWindowMs: 2_000
|
|
1784
|
+
})
|
|
1785
|
+
|
|
1786
|
+
const tunnel1 = createConnection({host: "127.0.0.1", port: relay.port})
|
|
1787
|
+
const decoder1 = new FrameDecoder({compactDirection: "target-to-client"})
|
|
1788
|
+
await waitConnect(tunnel1)
|
|
1789
|
+
tunnel1.write(encodeFrame({
|
|
1790
|
+
type: "session.create",
|
|
1791
|
+
token: token.value,
|
|
1792
|
+
name: "replay-once-test",
|
|
1793
|
+
identifier: "session-replay-once",
|
|
1794
|
+
target: {host: "127.0.0.1", port: targetPort}
|
|
1795
|
+
}))
|
|
1796
|
+
decoder1.push(await readOnce(tunnel1))
|
|
1797
|
+
|
|
1798
|
+
tunnel1.write(encodeFrame({type: "stream.data", direction: "client-to-target", sequence: 5}, Buffer.from("alpha")))
|
|
1799
|
+
let alphaRelaySeq = 0
|
|
1800
|
+
while (alphaRelaySeq === 0) {
|
|
1801
|
+
for (const frame of decoder1.push(await readOnce(tunnel1))) {
|
|
1802
|
+
if (frame.header.type === "stream.data" && frame.header.direction === "target-to-client") {
|
|
1803
|
+
alphaRelaySeq = /** @type {number} */ (frame.header.sequence ?? 0)
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
assert.ok(alphaRelaySeq > 0, "alpha must be echoed with a target-to-client sequence")
|
|
1808
|
+
|
|
1809
|
+
tunnel1.write(encodeFrame({type: "stream.data", direction: "client-to-target", sequence: 11}, Buffer.from("bravo6")))
|
|
1810
|
+
let bravoRelaySeq = 0
|
|
1811
|
+
while (bravoRelaySeq === 0) {
|
|
1812
|
+
for (const frame of decoder1.push(await readOnce(tunnel1))) {
|
|
1813
|
+
if (frame.header.type === "stream.data" && frame.header.direction === "target-to-client" && frame.payload.toString("utf8") === "bravo6") {
|
|
1814
|
+
bravoRelaySeq = /** @type {number} */ (frame.header.sequence ?? 0)
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
tunnel1.write(encodeFrame({type: "stream.ack", direction: "target-to-client", ackSequence: alphaRelaySeq}))
|
|
1820
|
+
|
|
1821
|
+
tunnel1.destroy()
|
|
1822
|
+
await waitClose(tunnel1)
|
|
1823
|
+
|
|
1824
|
+
const tunnel2 = createConnection({host: "127.0.0.1", port: relay.port})
|
|
1825
|
+
const decoder2 = new FrameDecoder({compactDirection: "target-to-client"})
|
|
1826
|
+
await waitConnect(tunnel2)
|
|
1827
|
+
tunnel2.write(encodeFrame({type: "session.resume", token: token.value, identifier: "session-replay-once", targetToClientAck: alphaRelaySeq}))
|
|
1828
|
+
|
|
1829
|
+
let resumed = false
|
|
1830
|
+
/** @type {string[]} */
|
|
1831
|
+
const replayedChunks = []
|
|
1832
|
+
while (!resumed || replayedChunks.length < 1) {
|
|
1833
|
+
for (const frame of decoder2.push(await readOnce(tunnel2))) {
|
|
1834
|
+
if (frame.header.type === "session.resumed") {
|
|
1835
|
+
resumed = true
|
|
1836
|
+
}
|
|
1837
|
+
if (frame.header.type === "stream.data" && frame.header.direction === "target-to-client") {
|
|
1838
|
+
replayedChunks.push(frame.payload.toString("utf8"))
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
assert.deepEqual(replayedChunks, ["bravo6"], "only unacknowledged bytes must be replayed")
|
|
1844
|
+
|
|
1845
|
+
tunnel2.destroy()
|
|
1846
|
+
await relay.close()
|
|
1847
|
+
await new Promise((resolve, reject) => {
|
|
1848
|
+
echoServer.close((error) => {
|
|
1849
|
+
if (error) { reject(error); return }
|
|
1850
|
+
resolve(undefined)
|
|
1851
|
+
})
|
|
1852
|
+
})
|
|
1853
|
+
})
|
|
1854
|
+
|
|
1855
|
+
test("TCP relay handshake timeout sends HANDSHAKE_TIMEOUT before auth, not after", async () => {
|
|
1856
|
+
const relay = await startTcpRelay({
|
|
1857
|
+
authorizeTarget: () => false,
|
|
1858
|
+
tokens: new TokenStore(),
|
|
1859
|
+
sessions: new SessionStore(),
|
|
1860
|
+
handshakeTimeoutMs: 50
|
|
1861
|
+
})
|
|
1862
|
+
|
|
1863
|
+
const unauthed = createConnection({host: "127.0.0.1", port: relay.port})
|
|
1864
|
+
const decoder = new FrameDecoder({compactDirection: "target-to-client"})
|
|
1865
|
+
await waitConnect(unauthed)
|
|
1866
|
+
|
|
1867
|
+
const handshakeTimeout = new Promise((resolve, reject) => {
|
|
1868
|
+
unauthed.on("close", () => reject(new Error("socket closed without error frame")))
|
|
1869
|
+
unauthed.on("data", (chunk) => {
|
|
1870
|
+
for (const frame of decoder.push(chunk)) {
|
|
1871
|
+
if (frame.header.type === "error") {
|
|
1872
|
+
resolve(frame.header)
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
})
|
|
1876
|
+
})
|
|
1877
|
+
|
|
1878
|
+
const errorHeader = await Promise.race([
|
|
1879
|
+
handshakeTimeout,
|
|
1880
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("test timed out waiting for error frame")), 1000))
|
|
1881
|
+
])
|
|
1882
|
+
|
|
1883
|
+
assert.equal(errorHeader.type, "error")
|
|
1884
|
+
assert.equal(errorHeader.code, "HANDSHAKE_TIMEOUT")
|
|
1885
|
+
|
|
1886
|
+
const users = new InMemoryUserStore()
|
|
1887
|
+
const authedTokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
1888
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
1889
|
+
const token = await login(users, authedTokens, {username: "kasper", password: "correct horse battery staple"})
|
|
1890
|
+
|
|
1891
|
+
const authed = createConnection({host: "127.0.0.1", port: relay.port})
|
|
1892
|
+
const authedDecoder = new FrameDecoder({compactDirection: "target-to-client"})
|
|
1893
|
+
await waitConnect(authed)
|
|
1894
|
+
authed.write(encodeFrame({
|
|
1895
|
+
type: "session.create",
|
|
1896
|
+
token: token.value,
|
|
1897
|
+
name: "timeout-do-not-fire",
|
|
1898
|
+
identifier: "session-timeout-ok",
|
|
1899
|
+
target: {host: "127.0.0.1", port: 9}
|
|
1900
|
+
}))
|
|
1901
|
+
|
|
1902
|
+
const authData = await Promise.race([
|
|
1903
|
+
new Promise((resolve) => authed.once("data", (chunk) => { authedDecoder.push(chunk); resolve(undefined) })),
|
|
1904
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("authenticated connection timed out")), 500))
|
|
1905
|
+
])
|
|
1906
|
+
assert.notEqual(authData, "timeout", "authenticated connection must receive a response")
|
|
1907
|
+
|
|
1908
|
+
for (const frame of authedDecoder.push(Buffer.alloc(0))) {
|
|
1909
|
+
assert.notEqual(frame.header.code, "HANDSHAKE_TIMEOUT", "authenticated connection must not receive handshake timeout")
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
authed.destroy()
|
|
1913
|
+
await relay.close()
|
|
1914
|
+
})
|
|
1915
|
+
|
|
1916
|
+
test("TCP relay enforces maxInputBytesPerConnection before auth and lifts it after auth", async () => {
|
|
1917
|
+
const echoServer = createServer((socket) => {
|
|
1918
|
+
socket.on("data", (chunk) => socket.write(chunk))
|
|
1919
|
+
})
|
|
1920
|
+
const targetPort = await listen(echoServer)
|
|
1921
|
+
|
|
1922
|
+
const users = new InMemoryUserStore()
|
|
1923
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
1924
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
1925
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
1926
|
+
|
|
1927
|
+
const relay = await startTcpRelay({
|
|
1928
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
1929
|
+
tokens,
|
|
1930
|
+
sessions: new SessionStore({now: () => 1000}),
|
|
1931
|
+
maxInputBytesPerConnection: 256,
|
|
1932
|
+
handshakeTimeoutMs: 5000
|
|
1933
|
+
})
|
|
1934
|
+
|
|
1935
|
+
const errorHeader = await new Promise((resolve, reject) => {
|
|
1936
|
+
const preAuth = createConnection({host: "127.0.0.1", port: relay.port})
|
|
1937
|
+
const decoder = new FrameDecoder({compactDirection: "target-to-client"})
|
|
1938
|
+
|
|
1939
|
+
preAuth.on("connect", () => {
|
|
1940
|
+
const hugePayload = Buffer.alloc(300, 0x41)
|
|
1941
|
+
const hugeFrame = encodeFrame({
|
|
1942
|
+
type: "stream.data",
|
|
1943
|
+
direction: "client-to-target",
|
|
1944
|
+
sequence: 1
|
|
1945
|
+
}, hugePayload)
|
|
1946
|
+
|
|
1947
|
+
const half = Math.floor(hugeFrame.length / 2)
|
|
1948
|
+
preAuth.write(hugeFrame.subarray(0, half))
|
|
1949
|
+
preAuth.write(hugeFrame.subarray(half))
|
|
1950
|
+
})
|
|
1951
|
+
|
|
1952
|
+
preAuth.on("data", (chunk) => {
|
|
1953
|
+
for (const frame of decoder.push(chunk)) {
|
|
1954
|
+
if (frame.header.type === "error") {
|
|
1955
|
+
resolve(frame.header)
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
})
|
|
1959
|
+
|
|
1960
|
+
preAuth.on("close", () => reject(new Error("socket closed without error frame")))
|
|
1961
|
+
|
|
1962
|
+
setTimeout(() => reject(new Error("timed out waiting for error frame")), 1000)
|
|
1963
|
+
})
|
|
1964
|
+
|
|
1965
|
+
assert.equal(errorHeader.type, "error")
|
|
1966
|
+
assert.equal(errorHeader.code, "PROTOCOL_VIOLATION")
|
|
1967
|
+
|
|
1968
|
+
const createdHeader = await new Promise((resolve, reject) => {
|
|
1969
|
+
const authed = createConnection({host: "127.0.0.1", port: relay.port})
|
|
1970
|
+
const decoder = new FrameDecoder({compactDirection: "target-to-client"})
|
|
1971
|
+
|
|
1972
|
+
authed.on("connect", () => {
|
|
1973
|
+
authed.write(encodeFrame({
|
|
1974
|
+
type: "session.create",
|
|
1975
|
+
token: token.value,
|
|
1976
|
+
name: "budget-lift-test",
|
|
1977
|
+
identifier: "session-budget-lift",
|
|
1978
|
+
target: {host: "127.0.0.1", port: targetPort}
|
|
1979
|
+
}))
|
|
1980
|
+
})
|
|
1981
|
+
|
|
1982
|
+
authed.on("data", (chunk) => {
|
|
1983
|
+
for (const frame of decoder.push(chunk)) {
|
|
1984
|
+
if (frame.header.type === "session.created") {
|
|
1985
|
+
resolve(frame.header)
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
})
|
|
1989
|
+
|
|
1990
|
+
authed.on("close", () => reject(new Error("authed socket closed without session.created")))
|
|
1991
|
+
|
|
1992
|
+
setTimeout(() => reject(new Error("timed out waiting for session.created")), 1000)
|
|
1993
|
+
})
|
|
1994
|
+
|
|
1995
|
+
assert.equal(createdHeader.type, "session.created")
|
|
1996
|
+
|
|
1997
|
+
const largePayload = Buffer.alloc(256, 0x42)
|
|
1998
|
+
|
|
1999
|
+
const echoed = await new Promise((resolve, reject) => {
|
|
2000
|
+
const authed2 = createConnection({host: "127.0.0.1", port: relay.port})
|
|
2001
|
+
const decoder = new FrameDecoder({compactDirection: "target-to-client"})
|
|
2002
|
+
|
|
2003
|
+
authed2.on("connect", () => {
|
|
2004
|
+
authed2.write(encodeFrame({
|
|
2005
|
+
type: "session.create",
|
|
2006
|
+
token: token.value,
|
|
2007
|
+
name: "budget-lift-large",
|
|
2008
|
+
identifier: "session-budget-large",
|
|
2009
|
+
target: {host: "127.0.0.1", port: targetPort}
|
|
2010
|
+
}))
|
|
2011
|
+
})
|
|
2012
|
+
|
|
2013
|
+
authed2.on("data", (chunk) => {
|
|
2014
|
+
for (const frame of decoder.push(chunk)) {
|
|
2015
|
+
if (frame.header.type === "session.created") {
|
|
2016
|
+
authed2.write(encodeFrame({
|
|
2017
|
+
type: "stream.data",
|
|
2018
|
+
direction: "client-to-target",
|
|
2019
|
+
sequence: largePayload.length
|
|
2020
|
+
}, largePayload))
|
|
2021
|
+
}
|
|
2022
|
+
if (frame.header.type === "stream.data" && frame.header.direction === "target-to-client") {
|
|
2023
|
+
resolve(frame.payload)
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
})
|
|
2027
|
+
|
|
2028
|
+
authed2.on("close", () => reject(new Error("authed2 socket closed without echo")))
|
|
2029
|
+
|
|
2030
|
+
setTimeout(() => reject(new Error("timed out waiting for echo")), 1000)
|
|
2031
|
+
})
|
|
2032
|
+
|
|
2033
|
+
assert.ok(Buffer.isBuffer(echoed))
|
|
2034
|
+
assert.equal(echoed.length, largePayload.length)
|
|
2035
|
+
assert.deepEqual(echoed, largePayload)
|
|
2036
|
+
|
|
2037
|
+
await relay.close()
|
|
2038
|
+
await new Promise((resolve, reject) => {
|
|
2039
|
+
echoServer.close((error) => {
|
|
2040
|
+
if (error) { reject(error); return }
|
|
2041
|
+
resolve(undefined)
|
|
2042
|
+
})
|
|
2043
|
+
})
|
|
2044
|
+
})
|
|
2045
|
+
|
|
2046
|
+
test("TCP relay terminal clean-up on SocketductSocket.end() terminates target and rejects resume despite long resume window", async () => {
|
|
2047
|
+
const tempDir = await mkdtemp(join(tmpdir(), "socketduct-terminal-close-test-"))
|
|
2048
|
+
const echoServer = createServer((socket) => {
|
|
2049
|
+
socket.on("data", (chunk) => socket.write(chunk))
|
|
2050
|
+
})
|
|
2051
|
+
const targetPort = await listen(echoServer)
|
|
2052
|
+
|
|
2053
|
+
const users = new InMemoryUserStore()
|
|
2054
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
2055
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
2056
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
2057
|
+
const sessions = new SessionStore({now: () => 1000})
|
|
2058
|
+
|
|
2059
|
+
const relay = await startTcpRelay({
|
|
2060
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
2061
|
+
tokens,
|
|
2062
|
+
sessions,
|
|
2063
|
+
resumeWindowMs: 10_000
|
|
2064
|
+
})
|
|
2065
|
+
|
|
2066
|
+
const client = connectSocketduct({
|
|
2067
|
+
relay: {host: "127.0.0.1", port: relay.port, token: token.value},
|
|
2068
|
+
sessionName: "terminal-close-socket",
|
|
2069
|
+
target: {host: "127.0.0.1", port: targetPort},
|
|
2070
|
+
spoolDirectory: tempDir
|
|
2071
|
+
})
|
|
2072
|
+
await new Promise((resolve) => client.once("connect", resolve))
|
|
2073
|
+
|
|
2074
|
+
client.write("alpha")
|
|
2075
|
+
const echo = await new Promise((resolve) => {
|
|
2076
|
+
client.once("data", (data) => resolve(data.toString("utf8")))
|
|
2077
|
+
})
|
|
2078
|
+
assert.equal(echo, "alpha")
|
|
2079
|
+
|
|
2080
|
+
const sessionId = client.sessionIdentifier
|
|
2081
|
+
assert.ok(typeof sessionId === "string", "session identifier must be assigned")
|
|
2082
|
+
|
|
2083
|
+
const sessionBeforeClose = sessions.get(sessionId)
|
|
2084
|
+
assert.ok(sessionBeforeClose !== undefined, "session must exist before close")
|
|
2085
|
+
|
|
2086
|
+
const closePromise = new Promise((resolve) => client.once("close", resolve))
|
|
2087
|
+
client.end()
|
|
2088
|
+
await closePromise
|
|
2089
|
+
|
|
2090
|
+
const sessionAfterClose = sessions.get(sessionId)
|
|
2091
|
+
assert.ok(sessionAfterClose === undefined, "session must be removed after terminal close")
|
|
2092
|
+
|
|
2093
|
+
const tunnel2 = createConnection({host: "127.0.0.1", port: relay.port})
|
|
2094
|
+
const decoder2 = new FrameDecoder({compactDirection: "target-to-client"})
|
|
2095
|
+
await waitConnect(tunnel2)
|
|
2096
|
+
tunnel2.write(encodeFrame({type: "session.resume", token: token.value, identifier: sessionId}))
|
|
2097
|
+
|
|
2098
|
+
let resumeResponse
|
|
2099
|
+
while (resumeResponse === undefined) {
|
|
2100
|
+
for (const frame of decoder2.push(await readOnce(tunnel2))) {
|
|
2101
|
+
resumeResponse = frame.header
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
assert.equal(resumeResponse.type, "error")
|
|
2105
|
+
assert.ok(resumeResponse.code === "SESSION_NOT_FOUND" || resumeResponse.code === "SESSION_EXPIRED", "session must not be resumable after terminal close")
|
|
2106
|
+
|
|
2107
|
+
tunnel2.destroy()
|
|
2108
|
+
await relay.close()
|
|
2109
|
+
await new Promise((resolve, reject) => {
|
|
2110
|
+
echoServer.close((error) => {
|
|
2111
|
+
if (error) { reject(error); return }
|
|
2112
|
+
resolve(undefined)
|
|
2113
|
+
})
|
|
2114
|
+
})
|
|
2115
|
+
await rm(tempDir, {recursive: true, force: true})
|
|
2116
|
+
})
|
|
2117
|
+
|
|
2118
|
+
test("TCP relay retains resumable session after unexpected tunnel close via closeActiveTunnel", async () => {
|
|
2119
|
+
const echoServer = createServer((socket) => {
|
|
2120
|
+
socket.on("data", (chunk) => socket.write(chunk))
|
|
2121
|
+
})
|
|
2122
|
+
const targetPort = await listen(echoServer)
|
|
2123
|
+
|
|
2124
|
+
const users = new InMemoryUserStore()
|
|
2125
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
2126
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
2127
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
2128
|
+
const sessions = new SessionStore({now: () => 1000})
|
|
2129
|
+
|
|
2130
|
+
const relay = await startTcpRelay({
|
|
2131
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
2132
|
+
tokens,
|
|
2133
|
+
sessions,
|
|
2134
|
+
resumeWindowMs: 5_000
|
|
2135
|
+
})
|
|
2136
|
+
|
|
2137
|
+
const tunnel1 = createConnection({host: "127.0.0.1", port: relay.port})
|
|
2138
|
+
const decoder1 = new FrameDecoder({compactDirection: "target-to-client"})
|
|
2139
|
+
await waitConnect(tunnel1)
|
|
2140
|
+
tunnel1.write(encodeFrame({
|
|
2141
|
+
type: "session.create",
|
|
2142
|
+
token: token.value,
|
|
2143
|
+
name: "force-close-resumable",
|
|
2144
|
+
identifier: "session-force-close-resumable",
|
|
2145
|
+
target: {host: "127.0.0.1", port: targetPort}
|
|
2146
|
+
}))
|
|
2147
|
+
decoder1.push(await readOnce(tunnel1))
|
|
2148
|
+
|
|
2149
|
+
tunnel1.write(encodeFrame({type: "stream.data", direction: "client-to-target", sequence: 5}, Buffer.from("first")))
|
|
2150
|
+
let firstEcho
|
|
2151
|
+
while (firstEcho === undefined) {
|
|
2152
|
+
for (const frame of decoder1.push(await readOnce(tunnel1))) {
|
|
2153
|
+
if (frame.header.type === "stream.data" && frame.header.direction === "target-to-client") {
|
|
2154
|
+
firstEcho = frame.payload.toString("utf8")
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
assert.equal(firstEcho, "first")
|
|
2159
|
+
|
|
2160
|
+
relay.closeActiveTunnel()
|
|
2161
|
+
await waitClose(tunnel1)
|
|
2162
|
+
|
|
2163
|
+
const sessionBeforeResume = sessions.get("session-force-close-resumable")
|
|
2164
|
+
assert.ok(sessionBeforeResume !== undefined, "session must still exist after unexpected tunnel close")
|
|
2165
|
+
assert.ok(sessionBeforeResume.state === "interrupted" || sessionBeforeResume.state === "resuming" || sessionBeforeResume.state === "active", "session must be resumable after unexpected tunnel close")
|
|
2166
|
+
|
|
2167
|
+
const tunnel2 = createConnection({host: "127.0.0.1", port: relay.port})
|
|
2168
|
+
const decoder2 = new FrameDecoder({compactDirection: "target-to-client"})
|
|
2169
|
+
await waitConnect(tunnel2)
|
|
2170
|
+
tunnel2.write(encodeFrame({type: "session.resume", token: token.value, identifier: "session-force-close-resumable"}))
|
|
2171
|
+
|
|
2172
|
+
let resumed = false
|
|
2173
|
+
let gotReplay = false
|
|
2174
|
+
while (!resumed || !gotReplay) {
|
|
2175
|
+
for (const frame of decoder2.push(await readOnce(tunnel2))) {
|
|
2176
|
+
if (frame.header.type === "session.resumed") {
|
|
2177
|
+
resumed = true
|
|
2178
|
+
}
|
|
2179
|
+
if (frame.header.type === "stream.data" && frame.header.direction === "target-to-client") {
|
|
2180
|
+
gotReplay = true
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
assert.ok(resumed, "session must be resumable after unexpected tunnel drop")
|
|
2185
|
+
assert.ok(gotReplay, "unacknowledged target-to-client data must be replayed on resume")
|
|
2186
|
+
|
|
2187
|
+
tunnel2.destroy()
|
|
2188
|
+
await relay.close()
|
|
2189
|
+
await new Promise((resolve, reject) => {
|
|
2190
|
+
echoServer.close((error) => {
|
|
2191
|
+
if (error) { reject(error); return }
|
|
2192
|
+
resolve(undefined)
|
|
2193
|
+
})
|
|
2194
|
+
})
|
|
2195
|
+
})
|
|
2196
|
+
|
|
2197
|
+
test("TCP relay rejects resume after resume window expires", async () => {
|
|
2198
|
+
const echoServer = createServer((socket) => {
|
|
2199
|
+
socket.on("data", (chunk) => socket.write(chunk))
|
|
2200
|
+
})
|
|
2201
|
+
const targetPort = await listen(echoServer)
|
|
2202
|
+
|
|
2203
|
+
const users = new InMemoryUserStore()
|
|
2204
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
2205
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
2206
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
2207
|
+
const sessions = new SessionStore({now: () => 1000})
|
|
2208
|
+
|
|
2209
|
+
const relay = await startTcpRelay({
|
|
2210
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
2211
|
+
tokens,
|
|
2212
|
+
sessions,
|
|
2213
|
+
resumeWindowMs: 100
|
|
2214
|
+
})
|
|
2215
|
+
|
|
2216
|
+
const tunnel1 = createConnection({host: "127.0.0.1", port: relay.port})
|
|
2217
|
+
const decoder1 = new FrameDecoder({compactDirection: "target-to-client"})
|
|
2218
|
+
await waitConnect(tunnel1)
|
|
2219
|
+
tunnel1.write(encodeFrame({
|
|
2220
|
+
type: "session.create",
|
|
2221
|
+
token: token.value,
|
|
2222
|
+
name: "expiry-test",
|
|
2223
|
+
identifier: "session-expiry-test",
|
|
2224
|
+
target: {host: "127.0.0.1", port: targetPort}
|
|
2225
|
+
}))
|
|
2226
|
+
decoder1.push(await readOnce(tunnel1))
|
|
2227
|
+
|
|
2228
|
+
tunnel1.write(encodeFrame({type: "stream.data", direction: "client-to-target", sequence: 4}, Buffer.from("ping")))
|
|
2229
|
+
let echo
|
|
2230
|
+
while (echo === undefined) {
|
|
2231
|
+
for (const frame of decoder1.push(await readOnce(tunnel1))) {
|
|
2232
|
+
if (frame.header.type === "stream.data" && frame.header.direction === "target-to-client") {
|
|
2233
|
+
echo = frame.payload.toString("utf8")
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
assert.equal(echo, "ping")
|
|
2238
|
+
|
|
2239
|
+
tunnel1.destroy()
|
|
2240
|
+
await waitClose(tunnel1)
|
|
2241
|
+
|
|
2242
|
+
const sessionBeforeExpiry = sessions.get("session-expiry-test")
|
|
2243
|
+
assert.ok(sessionBeforeExpiry !== undefined, "session must exist before expiry")
|
|
2244
|
+
|
|
2245
|
+
await new Promise((resolve) => setTimeout(resolve, 200))
|
|
2246
|
+
|
|
2247
|
+
const sessionAfterExpiry = sessions.get("session-expiry-test")
|
|
2248
|
+
assert.ok(sessionAfterExpiry === undefined, "session must be removed after resume window expires")
|
|
2249
|
+
|
|
2250
|
+
const tunnel2 = createConnection({host: "127.0.0.1", port: relay.port})
|
|
2251
|
+
const decoder2 = new FrameDecoder({compactDirection: "target-to-client"})
|
|
2252
|
+
await waitConnect(tunnel2)
|
|
2253
|
+
tunnel2.write(encodeFrame({type: "session.resume", token: token.value, identifier: "session-expiry-test"}))
|
|
2254
|
+
|
|
2255
|
+
let resumeResponse
|
|
2256
|
+
while (resumeResponse === undefined) {
|
|
2257
|
+
for (const frame of decoder2.push(await readOnce(tunnel2))) {
|
|
2258
|
+
resumeResponse = frame.header
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
assert.equal(resumeResponse.type, "error")
|
|
2262
|
+
assert.ok(resumeResponse.code === "SESSION_NOT_FOUND" || resumeResponse.code === "SESSION_EXPIRED", "session must not be resumable after expiry")
|
|
2263
|
+
|
|
2264
|
+
tunnel2.destroy()
|
|
2265
|
+
await relay.close()
|
|
2266
|
+
await new Promise((resolve, reject) => {
|
|
2267
|
+
echoServer.close((error) => {
|
|
2268
|
+
if (error) { reject(error); return }
|
|
2269
|
+
resolve(undefined)
|
|
2270
|
+
})
|
|
2271
|
+
})
|
|
2272
|
+
})
|
|
2273
|
+
|
|
2274
|
+
/**
|
|
2275
|
+
* @param {number} size - Uncompressed byte length to produce.
|
|
2276
|
+
* @returns {Buffer} - Highly compressible text payload.
|
|
2277
|
+
*/
|
|
2278
|
+
function compressibleChunk(size) {
|
|
2279
|
+
const unit = "GET /api/v1/users?page=2 HTTP/1.1\r\nHost: example.com\r\nAccept: application/json\r\n\r\n"
|
|
2280
|
+
return Buffer.from(unit.repeat(Math.ceil(size / unit.length))).subarray(0, size)
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
/**
|
|
2284
|
+
* Holds the next asynchronous Zstd completion callback.
|
|
2285
|
+
* @returns {{entered: Promise<unknown>, release: () => void, restore: () => void}} Test controller.
|
|
2286
|
+
*/
|
|
2287
|
+
function holdNextZstdCompression() {
|
|
2288
|
+
const originalCompress = /** @type {NonNullable<typeof zlib.zstdCompress>} */ (zlib.zstdCompress)
|
|
2289
|
+
/** @type {import('node:zlib').CompressCallback | undefined} */
|
|
2290
|
+
let heldCallback
|
|
2291
|
+
/** @type {Buffer | undefined} */
|
|
2292
|
+
let heldInput
|
|
2293
|
+
/** @type {import('node:zlib').ZstdOptions | undefined} */
|
|
2294
|
+
let heldOptions
|
|
2295
|
+
/** @type {() => void} */
|
|
2296
|
+
let enteredResolve = () => {}
|
|
2297
|
+
const entered = new Promise((resolve) => { enteredResolve = () => resolve(undefined) })
|
|
2298
|
+
/** @type {(input: import('node:zlib').InputType, options: import('node:zlib').ZstdOptions, callback: import('node:zlib').CompressCallback) => void} */
|
|
2299
|
+
const patchedCompress = (input, options, callback) => {
|
|
2300
|
+
if (heldCallback === undefined) {
|
|
2301
|
+
heldCallback = callback
|
|
2302
|
+
heldInput = /** @type {Buffer} */ (/** @type {unknown} */ (input))
|
|
2303
|
+
heldOptions = options
|
|
2304
|
+
enteredResolve()
|
|
2305
|
+
return
|
|
2306
|
+
}
|
|
2307
|
+
originalCompress(input, options, callback)
|
|
2308
|
+
}
|
|
2309
|
+
// @ts-expect-error - TS lib __promisify__ required on typeof zstdCompress vs runtime
|
|
2310
|
+
zlib.zstdCompress = patchedCompress
|
|
2311
|
+
return {
|
|
2312
|
+
entered,
|
|
2313
|
+
release() {
|
|
2314
|
+
zlib.zstdCompress = originalCompress
|
|
2315
|
+
if (heldCallback !== undefined && heldInput !== undefined && heldOptions !== undefined) {
|
|
2316
|
+
originalCompress(heldInput, heldOptions, heldCallback)
|
|
2317
|
+
}
|
|
2318
|
+
},
|
|
2319
|
+
restore() {
|
|
2320
|
+
zlib.zstdCompress = originalCompress
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
test("TCP relay retains a queued target close across disconnect and resumes it after data", {skip: !zstdAvailable()}, async () => {
|
|
2326
|
+
/** @type {import('node:net').Socket | undefined} */
|
|
2327
|
+
let targetSocket
|
|
2328
|
+
const targetServer = createServer((socket) => {
|
|
2329
|
+
targetSocket = socket
|
|
2330
|
+
socket.resume()
|
|
2331
|
+
})
|
|
2332
|
+
const targetPort = await listen(targetServer)
|
|
2333
|
+
const users = new InMemoryUserStore()
|
|
2334
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
2335
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
2336
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
2337
|
+
const relay = await startTcpRelay({
|
|
2338
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
2339
|
+
tokens,
|
|
2340
|
+
sessions: new SessionStore({now: () => 1000}),
|
|
2341
|
+
resumeWindowMs: 2_000
|
|
2342
|
+
})
|
|
2343
|
+
const tunnel1 = createConnection({host: "127.0.0.1", port: relay.port})
|
|
2344
|
+
const decoder1 = new FrameDecoder({compactDirection: "target-to-client"})
|
|
2345
|
+
await waitConnect(tunnel1)
|
|
2346
|
+
tunnel1.write(encodeFrame({
|
|
2347
|
+
type: "session.create", token: token.value, name: "queued-terminal",
|
|
2348
|
+
identifier: "session-queued-terminal", target: {host: "127.0.0.1", port: targetPort}, compression: ["zstd"]
|
|
2349
|
+
}))
|
|
2350
|
+
let created = false
|
|
2351
|
+
while (!created) {
|
|
2352
|
+
for (const frame of decoder1.push(await readOnceWithin(tunnel1))) {
|
|
2353
|
+
if (frame.header.type === "session.created") created = true
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
while (targetSocket === undefined) await new Promise((resolve) => setTimeout(resolve, 1))
|
|
2357
|
+
|
|
2358
|
+
const held = holdNextZstdCompression()
|
|
2359
|
+
const payload = compressibleChunk(16_384)
|
|
2360
|
+
try {
|
|
2361
|
+
targetSocket.write(payload)
|
|
2362
|
+
await held.entered
|
|
2363
|
+
targetSocket.end()
|
|
2364
|
+
await waitClose(targetSocket)
|
|
2365
|
+
await relay.closeActiveTunnel()
|
|
2366
|
+
await waitClose(tunnel1)
|
|
2367
|
+
held.release()
|
|
2368
|
+
|
|
2369
|
+
const tunnel2 = createConnection({host: "127.0.0.1", port: relay.port})
|
|
2370
|
+
const decoder2 = new FrameDecoder({compactDirection: "target-to-client"})
|
|
2371
|
+
await waitConnect(tunnel2)
|
|
2372
|
+
tunnel2.write(encodeFrame({
|
|
2373
|
+
type: "session.resume", token: token.value, identifier: "session-queued-terminal",
|
|
2374
|
+
targetToClientAck: 0, compression: ["zstd"]
|
|
2375
|
+
}))
|
|
2376
|
+
/** @type {string[]} */
|
|
2377
|
+
const ordered = []
|
|
2378
|
+
while (!ordered.includes("close")) {
|
|
2379
|
+
for (const frame of decoder2.push(await readOnceWithin(tunnel2))) {
|
|
2380
|
+
if (frame.header.type === "stream.data") ordered.push("data")
|
|
2381
|
+
if (frame.header.type === "session.close") ordered.push("close")
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
assert.deepEqual(ordered, ["data", "close"])
|
|
2385
|
+
tunnel2.destroy()
|
|
2386
|
+
} finally {
|
|
2387
|
+
held.restore()
|
|
2388
|
+
tunnel1.destroy()
|
|
2389
|
+
await relay.close()
|
|
2390
|
+
await new Promise((resolve) => targetServer.close(() => resolve(undefined)))
|
|
2391
|
+
}
|
|
2392
|
+
})
|
|
2393
|
+
|
|
2394
|
+
test("TCP relay orders a target close created during resume after queued target data", {skip: !zstdAvailable()}, async () => {
|
|
2395
|
+
/** @type {import('node:net').Socket | undefined} */
|
|
2396
|
+
let targetSocket
|
|
2397
|
+
let targetConnections = 0
|
|
2398
|
+
const targetServer = createServer((socket) => {
|
|
2399
|
+
targetConnections += 1
|
|
2400
|
+
targetSocket = socket
|
|
2401
|
+
socket.resume()
|
|
2402
|
+
})
|
|
2403
|
+
const targetPort = await listen(targetServer)
|
|
2404
|
+
const users = new InMemoryUserStore()
|
|
2405
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
2406
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
2407
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
2408
|
+
const relay = await startTcpRelay({
|
|
2409
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
2410
|
+
tokens,
|
|
2411
|
+
sessions: new SessionStore({now: () => 1000}),
|
|
2412
|
+
resumeWindowMs: 2_000
|
|
2413
|
+
})
|
|
2414
|
+
const tunnel1 = createConnection({host: "127.0.0.1", port: relay.port})
|
|
2415
|
+
const decoder1 = new FrameDecoder({compactDirection: "target-to-client"})
|
|
2416
|
+
await waitConnect(tunnel1)
|
|
2417
|
+
tunnel1.write(encodeFrame({
|
|
2418
|
+
type: "session.create", token: token.value, name: "resume-terminal-order",
|
|
2419
|
+
identifier: "session-resume-terminal-order", target: {host: "127.0.0.1", port: targetPort}, compression: ["zstd"]
|
|
2420
|
+
}))
|
|
2421
|
+
let created = false
|
|
2422
|
+
while (!created) {
|
|
2423
|
+
for (const frame of decoder1.push(await readOnceWithin(tunnel1))) {
|
|
2424
|
+
if (frame.header.type === "session.created") created = true
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
while (targetSocket === undefined) await new Promise((resolve) => setImmediate(resolve))
|
|
2428
|
+
|
|
2429
|
+
const replayPayload = compressibleChunk(16_384)
|
|
2430
|
+
targetSocket.write(replayPayload)
|
|
2431
|
+
let receivedReplay = false
|
|
2432
|
+
while (!receivedReplay) {
|
|
2433
|
+
for (const frame of decoder1.push(await readOnceWithin(tunnel1))) {
|
|
2434
|
+
if (frame.header.type === "stream.data") receivedReplay = true
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
await relay.closeActiveTunnel()
|
|
2438
|
+
await waitClose(tunnel1)
|
|
2439
|
+
|
|
2440
|
+
const held = holdNextZstdCompression()
|
|
2441
|
+
/** @type {import('node:net').Socket | undefined} */
|
|
2442
|
+
let tunnel2
|
|
2443
|
+
try {
|
|
2444
|
+
tunnel2 = createConnection({host: "127.0.0.1", port: relay.port})
|
|
2445
|
+
const decoder2 = new FrameDecoder({compactDirection: "target-to-client"})
|
|
2446
|
+
/** @type {string[]} */
|
|
2447
|
+
const ordered = []
|
|
2448
|
+
let resumed = false
|
|
2449
|
+
tunnel2.on("data", (chunk) => {
|
|
2450
|
+
for (const frame of decoder2.push(chunk)) {
|
|
2451
|
+
if (frame.header.type === "session.resumed") resumed = true
|
|
2452
|
+
if (frame.header.type === "stream.data") ordered.push("data")
|
|
2453
|
+
if (frame.header.type === "session.close") ordered.push("close")
|
|
2454
|
+
}
|
|
2455
|
+
})
|
|
2456
|
+
await waitConnect(tunnel2)
|
|
2457
|
+
tunnel2.write(encodeFrame({
|
|
2458
|
+
type: "session.resume", token: token.value, identifier: "session-resume-terminal-order",
|
|
2459
|
+
targetToClientAck: 0, compression: ["zstd"]
|
|
2460
|
+
}))
|
|
2461
|
+
await held.entered
|
|
2462
|
+
await waitUntil(() => resumed)
|
|
2463
|
+
|
|
2464
|
+
targetSocket.end(Buffer.from("final-target-record"))
|
|
2465
|
+
await waitClose(targetSocket)
|
|
2466
|
+
held.release()
|
|
2467
|
+
|
|
2468
|
+
await waitUntil(() => ordered.includes("close"))
|
|
2469
|
+
assert.deepEqual(ordered, ["data", "data", "close"])
|
|
2470
|
+
assert.equal(targetConnections, 1)
|
|
2471
|
+
} finally {
|
|
2472
|
+
held.restore()
|
|
2473
|
+
tunnel2?.destroy()
|
|
2474
|
+
tunnel1.destroy()
|
|
2475
|
+
await relay.close()
|
|
2476
|
+
await new Promise((resolve) => targetServer.close(() => resolve(undefined)))
|
|
2477
|
+
}
|
|
2478
|
+
})
|
|
2479
|
+
|
|
2480
|
+
test("TCP relay preserves send queue across tunnel close when zstd compression callback is delayed past resume", {skip: !zstdAvailable()}, async () => {
|
|
2481
|
+
/** @type {import('node:net').Socket | undefined} */
|
|
2482
|
+
let targetSocket
|
|
2483
|
+
const targetServer = createServer((socket) => {
|
|
2484
|
+
targetSocket = socket
|
|
2485
|
+
socket.on("data", (chunk) => socket.write(chunk))
|
|
2486
|
+
})
|
|
2487
|
+
const targetPort = await listen(targetServer)
|
|
2488
|
+
|
|
2489
|
+
const users = new InMemoryUserStore()
|
|
2490
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
2491
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
2492
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
2493
|
+
const sessions = new SessionStore({now: () => 1000})
|
|
2494
|
+
|
|
2495
|
+
const relay = await startTcpRelay({
|
|
2496
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
2497
|
+
tokens,
|
|
2498
|
+
sessions,
|
|
2499
|
+
resumeWindowMs: 2_000,
|
|
2500
|
+
maxTargetBufferBytes: 8_192
|
|
2501
|
+
})
|
|
2502
|
+
|
|
2503
|
+
const tunnel1 = createConnection({host: "127.0.0.1", port: relay.port})
|
|
2504
|
+
const decoder1 = new FrameDecoder({compactDirection: "target-to-client"})
|
|
2505
|
+
await waitConnect(tunnel1)
|
|
2506
|
+
tunnel1.write(encodeFrame({
|
|
2507
|
+
type: "session.create",
|
|
2508
|
+
token: token.value,
|
|
2509
|
+
name: "zstd-queue-race",
|
|
2510
|
+
identifier: "session-zstd-queue-race",
|
|
2511
|
+
target: {host: "127.0.0.1", port: targetPort},
|
|
2512
|
+
compression: ["zstd"]
|
|
2513
|
+
}))
|
|
2514
|
+
|
|
2515
|
+
const createdFrames = decoder1.push(await readOnce(tunnel1))
|
|
2516
|
+
const created = createdFrames[0]
|
|
2517
|
+
if (created === undefined) throw new Error("session.created expected")
|
|
2518
|
+
assert.equal(created.header.compression, "zstd")
|
|
2519
|
+
|
|
2520
|
+
const chunk1 = compressibleChunk(8192)
|
|
2521
|
+
tunnel1.write(encodeFrame({
|
|
2522
|
+
type: "stream.data",
|
|
2523
|
+
direction: "client-to-target",
|
|
2524
|
+
sequence: chunk1.length
|
|
2525
|
+
}, chunk1))
|
|
2526
|
+
|
|
2527
|
+
let echo1Received = false
|
|
2528
|
+
while (!echo1Received) {
|
|
2529
|
+
for (const frame of decoder1.push(await readOnce(tunnel1))) {
|
|
2530
|
+
if (frame.header.type === "stream.data" && frame.header.direction === "target-to-client") {
|
|
2531
|
+
echo1Received = true
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
const originalCompress = /** @type {NonNullable<typeof zlib.zstdCompress>} */ (zlib.zstdCompress)
|
|
2537
|
+
/** @type {import('node:zlib').CompressCallback | undefined} */
|
|
2538
|
+
let heldCallback
|
|
2539
|
+
/** @type {Buffer | undefined} */
|
|
2540
|
+
let heldInput
|
|
2541
|
+
/** @type {object | undefined} */
|
|
2542
|
+
let heldOptions
|
|
2543
|
+
/** @type {(() => void) | undefined} */
|
|
2544
|
+
let resolveCompressorEntered
|
|
2545
|
+
|
|
2546
|
+
const compressorEntered = new Promise((resolve) => {
|
|
2547
|
+
resolveCompressorEntered = () => resolve(undefined)
|
|
2548
|
+
})
|
|
2549
|
+
|
|
2550
|
+
// Test-only monkey-patch: holds the next zstd compression callback to
|
|
2551
|
+
// reproduce a send-queue race where async encode outlives a tunnel close.
|
|
2552
|
+
/**
|
|
2553
|
+
* @param {import('node:zlib').InputType} input
|
|
2554
|
+
* @param {import('node:zlib').ZstdOptions} options
|
|
2555
|
+
* @param {import('node:zlib').CompressCallback} callback
|
|
2556
|
+
* @returns {void}
|
|
2557
|
+
*/
|
|
2558
|
+
const patchedCompress = (input, options, callback) => {
|
|
2559
|
+
if (heldCallback === undefined) {
|
|
2560
|
+
heldCallback = callback
|
|
2561
|
+
heldInput = /** @type {Buffer} */ (/** @type {unknown} */ (input))
|
|
2562
|
+
heldOptions = options
|
|
2563
|
+
if (resolveCompressorEntered) resolveCompressorEntered()
|
|
2564
|
+
return
|
|
2565
|
+
}
|
|
2566
|
+
originalCompress(input, options, callback)
|
|
2567
|
+
}
|
|
2568
|
+
// @ts-expect-error - TS lib __promisify__ required on typeof zstdCompress vs runtime
|
|
2569
|
+
zlib.zstdCompress = patchedCompress
|
|
2570
|
+
|
|
2571
|
+
try {
|
|
2572
|
+
const chunk2 = compressibleChunk(16384)
|
|
2573
|
+
targetSocket?.write(chunk2)
|
|
2574
|
+
await compressorEntered
|
|
2575
|
+
|
|
2576
|
+
// Awaiting this relay helper means its server-side close handler has removed
|
|
2577
|
+
// the sender, so the next target write is definitely detached.
|
|
2578
|
+
await relay.closeActiveTunnel()
|
|
2579
|
+
await waitClose(tunnel1)
|
|
2580
|
+
|
|
2581
|
+
const chunk3 = Buffer.from("detached-after-delayed-send")
|
|
2582
|
+
targetSocket?.write(chunk3)
|
|
2583
|
+
|
|
2584
|
+
const tunnel2 = createConnection({host: "127.0.0.1", port: relay.port})
|
|
2585
|
+
const decoder2 = new FrameDecoder({compactDirection: "target-to-client"})
|
|
2586
|
+
await waitConnect(tunnel2)
|
|
2587
|
+
tunnel2.write(encodeFrame({
|
|
2588
|
+
type: "session.resume",
|
|
2589
|
+
token: token.value,
|
|
2590
|
+
identifier: "session-zstd-queue-race",
|
|
2591
|
+
compression: ["zstd"]
|
|
2592
|
+
}))
|
|
2593
|
+
|
|
2594
|
+
let resumed = false
|
|
2595
|
+
while (!resumed) {
|
|
2596
|
+
for (const frame of decoder2.push(await readOnceWithin(tunnel2))) {
|
|
2597
|
+
if (frame.header.type === "session.resumed") {
|
|
2598
|
+
resumed = true
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
// Disconnect again while the first send still owns the queue. The queued
|
|
2604
|
+
// flush must leave chunk3 buffered for the next resume.
|
|
2605
|
+
// Ensure the queued resume flush sees its sender removed before completion.
|
|
2606
|
+
const closingTunnel2 = relay.closeActiveTunnel()
|
|
2607
|
+
await waitClose(tunnel2)
|
|
2608
|
+
|
|
2609
|
+
zlib.zstdCompress = originalCompress
|
|
2610
|
+
|
|
2611
|
+
if (heldCallback !== undefined && heldInput !== undefined && heldOptions !== undefined) {
|
|
2612
|
+
originalCompress(heldInput, heldOptions, heldCallback)
|
|
2613
|
+
}
|
|
2614
|
+
await closingTunnel2
|
|
2615
|
+
|
|
2616
|
+
const tunnel3 = createConnection({host: "127.0.0.1", port: relay.port})
|
|
2617
|
+
const decoder3 = new FrameDecoder({compactDirection: "target-to-client"})
|
|
2618
|
+
await waitConnect(tunnel3)
|
|
2619
|
+
tunnel3.write(encodeFrame({
|
|
2620
|
+
type: "session.resume",
|
|
2621
|
+
token: token.value,
|
|
2622
|
+
identifier: "session-zstd-queue-race",
|
|
2623
|
+
compression: ["zstd"]
|
|
2624
|
+
}))
|
|
2625
|
+
|
|
2626
|
+
/** @type {Array<{sequence: number, chunk: Buffer}>} */
|
|
2627
|
+
const received = []
|
|
2628
|
+
let resumedAgain = false
|
|
2629
|
+
let done = false
|
|
2630
|
+
while (!done || !resumedAgain) {
|
|
2631
|
+
const nextFrameBytes = await readOnceWithin(tunnel3)
|
|
2632
|
+
for (const frame of decoder3.push(nextFrameBytes)) {
|
|
2633
|
+
if (frame.header.type === "session.resumed") resumedAgain = true
|
|
2634
|
+
if (frame.header.type === "stream.data" && frame.header.direction === "target-to-client") {
|
|
2635
|
+
received.push({
|
|
2636
|
+
sequence: /** @type {number} */ (frame.header.sequence),
|
|
2637
|
+
chunk: Buffer.from(frame.payload)
|
|
2638
|
+
})
|
|
2639
|
+
if (received.length >= 3) {
|
|
2640
|
+
done = true
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
assert.equal(received.length, 3, "must receive exactly three target-to-client frames — no drops, no duplicates")
|
|
2647
|
+
const frame1 = /** @type {NonNullable<typeof received[0]>} */ (received[0])
|
|
2648
|
+
const frame2 = /** @type {NonNullable<typeof received[1]>} */ (received[1])
|
|
2649
|
+
const frame3 = /** @type {NonNullable<typeof received[2]>} */ (received[2])
|
|
2650
|
+
assert.ok(frame1.sequence < frame2.sequence && frame2.sequence < frame3.sequence, "sequences must be in ascending order")
|
|
2651
|
+
assert.equal(frame1.sequence, chunk1.length, "first frame sequence must equal chunk1 size")
|
|
2652
|
+
assert.equal(frame2.sequence, chunk1.length + chunk2.length, "second frame sequence must equal cumulative size")
|
|
2653
|
+
assert.equal(frame3.sequence, chunk1.length + chunk2.length + chunk3.length, "detached frame must follow the delayed pre-close frame")
|
|
2654
|
+
assert.ok(frame1.chunk.length < chunk1.length, "wire frame for chunk1 must be compressed")
|
|
2655
|
+
assert.ok(frame2.chunk.length < chunk2.length, "wire frame for chunk2 must be compressed")
|
|
2656
|
+
assert.deepEqual(frame3.chunk, chunk3)
|
|
2657
|
+
|
|
2658
|
+
tunnel3.destroy()
|
|
2659
|
+
} finally {
|
|
2660
|
+
zlib.zstdCompress = originalCompress
|
|
2661
|
+
await relay.close()
|
|
2662
|
+
await new Promise((resolve, reject) => {
|
|
2663
|
+
targetServer.close((error) => {
|
|
2664
|
+
if (error) { reject(error); return }
|
|
2665
|
+
resolve(undefined)
|
|
2666
|
+
})
|
|
2667
|
+
})
|
|
2668
|
+
}
|
|
2669
|
+
})
|
|
2670
|
+
|
|
2671
|
+
test("TCP relay enforces detached buffer overflow while an earlier zstd send is stalled", {skip: !zstdAvailable()}, async () => {
|
|
2672
|
+
/** @type {import('node:net').Socket | undefined} */
|
|
2673
|
+
let targetSocket
|
|
2674
|
+
const targetServer = createServer((socket) => {
|
|
2675
|
+
targetSocket = socket
|
|
2676
|
+
socket.resume()
|
|
2677
|
+
})
|
|
2678
|
+
const targetPort = await listen(targetServer)
|
|
2679
|
+
const users = new InMemoryUserStore()
|
|
2680
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
2681
|
+
await users.createUser({username: "buffer-race", password: "correct horse battery staple"})
|
|
2682
|
+
const token = await login(users, tokens, {username: "buffer-race", password: "correct horse battery staple"})
|
|
2683
|
+
const sessions = new SessionStore({now: () => 1000})
|
|
2684
|
+
const relay = await startTcpRelay({
|
|
2685
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
2686
|
+
tokens,
|
|
2687
|
+
sessions,
|
|
2688
|
+
resumeWindowMs: 2_000,
|
|
2689
|
+
maxTargetBufferBytes: 16
|
|
2690
|
+
})
|
|
2691
|
+
const tunnel1 = createConnection({host: "127.0.0.1", port: relay.port})
|
|
2692
|
+
const decoder1 = new FrameDecoder({compactDirection: "target-to-client"})
|
|
2693
|
+
let hold
|
|
2694
|
+
try {
|
|
2695
|
+
await waitConnect(tunnel1)
|
|
2696
|
+
tunnel1.write(encodeFrame({
|
|
2697
|
+
type: "session.create", token: token.value, name: "buffer-race", identifier: "buffer-race",
|
|
2698
|
+
target: {host: "127.0.0.1", port: targetPort}, compression: ["zstd"]
|
|
2699
|
+
}))
|
|
2700
|
+
decoder1.push(await readOnceWithin(tunnel1))
|
|
2701
|
+
await waitUntil(() => targetSocket !== undefined)
|
|
2702
|
+
|
|
2703
|
+
const prime = compressibleChunk(8_192)
|
|
2704
|
+
targetSocket?.write(prime)
|
|
2705
|
+
decoder1.push(await readOnceWithin(tunnel1))
|
|
2706
|
+
|
|
2707
|
+
hold = holdNextZstdCompression()
|
|
2708
|
+
targetSocket?.write(compressibleChunk(16_384))
|
|
2709
|
+
await Promise.race([hold.entered, new Promise((resolve, reject) => setTimeout(() => reject(new Error("compressor did not stall")), 1_000))])
|
|
2710
|
+
// Await relay-side detachment before testing detached-buffer admission.
|
|
2711
|
+
await relay.closeActiveTunnel()
|
|
2712
|
+
await waitClose(tunnel1)
|
|
2713
|
+
|
|
2714
|
+
const targetClosed = targetSocket === undefined ? Promise.reject(new Error("target socket missing")) : waitClose(targetSocket)
|
|
2715
|
+
targetSocket?.write(Buffer.alloc(32, 0x42))
|
|
2716
|
+
await Promise.race([targetClosed, new Promise((resolve, reject) => setTimeout(() => reject(new Error("overflow did not close target immediately")), 1_000))])
|
|
2717
|
+
|
|
2718
|
+
const tunnel2 = createConnection({host: "127.0.0.1", port: relay.port})
|
|
2719
|
+
const decoder2 = new FrameDecoder({compactDirection: "target-to-client"})
|
|
2720
|
+
await waitConnect(tunnel2)
|
|
2721
|
+
tunnel2.write(encodeFrame({type: "session.resume", token: token.value, identifier: "buffer-race", compression: ["zstd"]}))
|
|
2722
|
+
hold.release()
|
|
2723
|
+
let overflow
|
|
2724
|
+
while (overflow === undefined) {
|
|
2725
|
+
for (const frame of decoder2.push(await readOnceWithin(tunnel2))) {
|
|
2726
|
+
if (frame.header.code === "TARGET_BUFFER_OVERFLOW") overflow = frame.header
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
assert.equal(overflow.retryable, false)
|
|
2730
|
+
tunnel2.destroy()
|
|
2731
|
+
} finally {
|
|
2732
|
+
hold?.restore()
|
|
2733
|
+
tunnel1.destroy()
|
|
2734
|
+
await relay.close()
|
|
2735
|
+
await new Promise((resolve) => targetServer.close(() => resolve(undefined)))
|
|
2736
|
+
}
|
|
2737
|
+
})
|
|
2738
|
+
|
|
2739
|
+
test("TCP relay ignores delayed send completion after expiry and identifier reuse", {skip: !zstdAvailable()}, async () => {
|
|
2740
|
+
/** @type {import('node:net').Socket[]} */
|
|
2741
|
+
const targetSockets = []
|
|
2742
|
+
const targetServer = createServer((socket) => {
|
|
2743
|
+
targetSockets.push(socket)
|
|
2744
|
+
socket.resume()
|
|
2745
|
+
})
|
|
2746
|
+
const targetPort = await listen(targetServer)
|
|
2747
|
+
const users = new InMemoryUserStore()
|
|
2748
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
2749
|
+
await users.createUser({username: "expiry-race", password: "correct horse battery staple"})
|
|
2750
|
+
const token = await login(users, tokens, {username: "expiry-race", password: "correct horse battery staple"})
|
|
2751
|
+
const sessions = new SessionStore({now: () => 1000})
|
|
2752
|
+
const relay = await startTcpRelay({
|
|
2753
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
2754
|
+
tokens,
|
|
2755
|
+
sessions,
|
|
2756
|
+
resumeWindowMs: 50
|
|
2757
|
+
})
|
|
2758
|
+
const tunnel1 = createConnection({host: "127.0.0.1", port: relay.port})
|
|
2759
|
+
let tunnel2
|
|
2760
|
+
let hold
|
|
2761
|
+
try {
|
|
2762
|
+
await waitConnect(tunnel1)
|
|
2763
|
+
const decoder1 = new FrameDecoder({compactDirection: "target-to-client"})
|
|
2764
|
+
tunnel1.write(encodeFrame({
|
|
2765
|
+
type: "session.create", token: token.value, name: "old lifecycle", identifier: "reused-lifecycle",
|
|
2766
|
+
target: {host: "127.0.0.1", port: targetPort}, compression: ["zstd"]
|
|
2767
|
+
}))
|
|
2768
|
+
decoder1.push(await readOnceWithin(tunnel1))
|
|
2769
|
+
await waitUntil(() => targetSockets.length === 1)
|
|
2770
|
+
const oldTarget = targetSockets[0]
|
|
2771
|
+
assert.ok(oldTarget !== undefined)
|
|
2772
|
+
oldTarget.write(compressibleChunk(8_192))
|
|
2773
|
+
decoder1.push(await readOnceWithin(tunnel1))
|
|
2774
|
+
|
|
2775
|
+
hold = holdNextZstdCompression()
|
|
2776
|
+
const staleChunk = compressibleChunk(16_384)
|
|
2777
|
+
oldTarget.write(staleChunk)
|
|
2778
|
+
await Promise.race([hold.entered, new Promise((resolve, reject) => setTimeout(() => reject(new Error("compressor did not stall")), 1_000))])
|
|
2779
|
+
tunnel1.destroy()
|
|
2780
|
+
await waitClose(tunnel1)
|
|
2781
|
+
|
|
2782
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
2783
|
+
assert.equal(sessions.get("reused-lifecycle"), undefined)
|
|
2784
|
+
|
|
2785
|
+
tunnel2 = createConnection({host: "127.0.0.1", port: relay.port})
|
|
2786
|
+
const decoder2 = new FrameDecoder({compactDirection: "target-to-client"})
|
|
2787
|
+
await waitConnect(tunnel2)
|
|
2788
|
+
tunnel2.write(encodeFrame({
|
|
2789
|
+
type: "session.create", token: token.value, name: "new lifecycle", identifier: "reused-lifecycle",
|
|
2790
|
+
target: {host: "127.0.0.1", port: targetPort}
|
|
2791
|
+
}))
|
|
2792
|
+
const created = decoder2.push(await readOnceWithin(tunnel2))
|
|
2793
|
+
assert.equal(created[0]?.header.type, "session.created")
|
|
2794
|
+
await waitUntil(() => targetSockets.length === 2)
|
|
2795
|
+
hold.release()
|
|
2796
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
2797
|
+
|
|
2798
|
+
const newTarget = targetSockets[1]
|
|
2799
|
+
assert.ok(newTarget !== undefined)
|
|
2800
|
+
const freshChunk = Buffer.from("fresh-lifecycle-data")
|
|
2801
|
+
newTarget.write(freshChunk)
|
|
2802
|
+
let freshFrame
|
|
2803
|
+
while (freshFrame === undefined) {
|
|
2804
|
+
for (const frame of decoder2.push(await readOnceWithin(tunnel2))) {
|
|
2805
|
+
if (frame.header.type === "stream.data") freshFrame = frame
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2808
|
+
assert.equal(freshFrame.header.sequence, freshChunk.length, "stale completion must not advance the reused session")
|
|
2809
|
+
assert.deepEqual(freshFrame.payload, freshChunk)
|
|
2810
|
+
} finally {
|
|
2811
|
+
hold?.restore()
|
|
2812
|
+
tunnel1.destroy()
|
|
2813
|
+
tunnel2?.destroy()
|
|
2814
|
+
await relay.close()
|
|
2815
|
+
await new Promise((resolve) => targetServer.close(() => resolve(undefined)))
|
|
2816
|
+
}
|
|
2817
|
+
})
|
|
2818
|
+
|
|
2819
|
+
test("compact v2 relay coalesces only target chunks naturally queued behind async compression", {skip: !zstdAvailable()}, async () => {
|
|
2820
|
+
/** @type {import('node:net').Socket | undefined} */
|
|
2821
|
+
let targetSocket
|
|
2822
|
+
const targetServer = createServer((socket) => {
|
|
2823
|
+
targetSocket = socket
|
|
2824
|
+
socket.setNoDelay(true)
|
|
2825
|
+
})
|
|
2826
|
+
const targetPort = await listen(targetServer)
|
|
2827
|
+
const users = new InMemoryUserStore()
|
|
2828
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
2829
|
+
await users.createUser({username: "compact-coalesce", password: "correct horse battery staple"})
|
|
2830
|
+
const token = await login(users, tokens, {username: "compact-coalesce", password: "correct horse battery staple"})
|
|
2831
|
+
const relay = await startTcpRelay({
|
|
2832
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
2833
|
+
tokens,
|
|
2834
|
+
sessions: new SessionStore({now: () => 1000})
|
|
2835
|
+
})
|
|
2836
|
+
const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
|
|
2837
|
+
const decoder = new FrameDecoder({compactDirection: "target-to-client"})
|
|
2838
|
+
let hold
|
|
2839
|
+
try {
|
|
2840
|
+
await waitConnect(tunnel)
|
|
2841
|
+
tunnel.write(encodeFrame({
|
|
2842
|
+
type: "session.create", token: token.value, name: "compact coalescing",
|
|
2843
|
+
target: {host: "127.0.0.1", port: targetPort}, compression: ["zstd"], framing: [2]
|
|
2844
|
+
}))
|
|
2845
|
+
const created = decoder.push(await readOnceWithin(tunnel))[0]
|
|
2846
|
+
assert.equal(created?.header.framing, 2)
|
|
2847
|
+
assert.equal(created?.header.compression, "zstd")
|
|
2848
|
+
await waitUntil(() => targetSocket !== undefined)
|
|
2849
|
+
|
|
2850
|
+
hold = holdNextZstdCompression()
|
|
2851
|
+
const first = compressibleChunk(16_384)
|
|
2852
|
+
const second = compressibleChunk(4_096)
|
|
2853
|
+
const third = compressibleChunk(2_048)
|
|
2854
|
+
targetSocket?.write(first)
|
|
2855
|
+
await Promise.race([hold.entered, new Promise((resolve, reject) => setTimeout(() => reject(new Error("compressor did not stall")), 1_000))])
|
|
2856
|
+
targetSocket?.write(second)
|
|
2857
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
2858
|
+
targetSocket?.write(third)
|
|
2859
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
2860
|
+
hold.release()
|
|
2861
|
+
|
|
2862
|
+
const dataFrames = []
|
|
2863
|
+
while (dataFrames.length < 2) {
|
|
2864
|
+
for (const frame of decoder.push(await readOnceWithin(tunnel))) {
|
|
2865
|
+
if (frame.header.type === "stream.data") dataFrames.push(frame)
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
assert.equal(dataFrames.length, 2, "the first chunk is isolated and only already-queued adjacent chunks merge")
|
|
2869
|
+
assert.equal(dataFrames[0]?.header.sequence, first.length)
|
|
2870
|
+
assert.equal(dataFrames[1]?.header.sequence, first.length + second.length + third.length)
|
|
2871
|
+
assert.equal(dataFrames[1]?.header.dataLength, second.length + third.length)
|
|
2872
|
+
} finally {
|
|
2873
|
+
hold?.restore()
|
|
2874
|
+
tunnel.destroy()
|
|
2875
|
+
await relay.close()
|
|
2876
|
+
await new Promise((resolve) => targetServer.close(() => resolve(undefined)))
|
|
2877
|
+
}
|
|
2878
|
+
})
|