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,1191 @@
|
|
|
1
|
+
import {createConnection, createServer} from "node:net"
|
|
2
|
+
import {createServer as createTlsServer} from "node:tls"
|
|
3
|
+
import {readFileSync} from "node:fs"
|
|
4
|
+
|
|
5
|
+
import {AdaptiveCompressor, availableCodecs, decodeStreamPayload, encodeStreamPayload, getMaxDecompressedBytes} from "../protocol/compression.js"
|
|
6
|
+
import {encodeCompactAck, encodeCompactData, encodeFrame, FrameDecoder} from "../protocol/frame.js"
|
|
7
|
+
import {handleControlMessage} from "../server/control-handler.js"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {import('../protocol/compression.js').CompressionLimits} CompressionLimits
|
|
11
|
+
* @typedef {import('../sessions/session.js').SessionStore} SessionStore
|
|
12
|
+
* @typedef {{host: string, port: number}} SessionTarget
|
|
13
|
+
* @typedef {{userId: string, username: string}} Principal
|
|
14
|
+
* @typedef {object} TokenAuthenticator
|
|
15
|
+
* @property {(value: string) => Principal | undefined} authenticate - Authenticate a bearer token.
|
|
16
|
+
* @typedef {{sequence: number, chunk: Buffer}} SentChunk
|
|
17
|
+
* @typedef {object} TcpRelayOptions
|
|
18
|
+
* @property {string} [host] - Relay listen host.
|
|
19
|
+
* @property {number} [port] - Relay listen port.
|
|
20
|
+
* @property {number} [resumeWindowMs] - How long to keep session target sockets after tunnel drops.
|
|
21
|
+
* @property {number} [maxReplayBytes] - Maximum bytes of unacknowledged target-to-client data retained for replay. Default 16 MiB.
|
|
22
|
+
* @property {number} [maxTargetBufferBytes] - Per-session byte limit for buffered target data while the tunnel is detached. Default 1 MiB.
|
|
23
|
+
* @property {number} [maxTotalBufferBytes] - Process-wide byte limit for buffered target data across all sessions. Default 16 MiB.
|
|
24
|
+
* @property {number} [initialTargetRetryDelayMs] - Initial target acquisition retry delay. Default 250ms.
|
|
25
|
+
* @property {number} [initialTargetRetryMaxDelayMs] - Maximum initial target acquisition retry delay. Default 5000ms.
|
|
26
|
+
* @property {number} [initialTargetConnectTimeoutMs] - Maximum time for one initial target TCP connect attempt. Default 10000ms.
|
|
27
|
+
* @property {number} [handshakeTimeoutMs] - Milliseconds to wait for auth after TCP/TLS connect before closing the socket. Default 10000.
|
|
28
|
+
* @property {number} [maxInputBytesPerConnection] - Per-connection input byte budget before the first authenticated session frame. Default 65536.
|
|
29
|
+
* @property {boolean} [compressionEnabled] - Whether to negotiate stream.data compression when the runtime supports it. Default true.
|
|
30
|
+
* @property {CompressionLimits} [compressionLimits] - Compression size and expansion-ratio bounds.
|
|
31
|
+
* @property {(input: {principal: Principal, target: SessionTarget}) => boolean} authorizeTarget - Target authorization policy.
|
|
32
|
+
* @property {TokenAuthenticator} tokens - Token store.
|
|
33
|
+
* @property {SessionStore} sessions - Session store.
|
|
34
|
+
* @property {(options: {host: string, port: number}) => import('node:net').Socket} [createTargetConnection] - Target socket factory.
|
|
35
|
+
* @property {{certFile: string, keyFile: string, clientCaFile?: string}} [tls] - TLS configuration.
|
|
36
|
+
* @typedef {object} TcpRelayHandle
|
|
37
|
+
* @property {import('node:tls').Server | import('node:net').Server} server - Relay server.
|
|
38
|
+
* @property {number} port - Bound relay port.
|
|
39
|
+
* @property {() => {activeTunnels: number, activeTargets: number, sessions: number, attachedSessions: number, detachedSessions: number, bufferedTargetBytes: number, targetConnectionAttempts: number, targetRetryTimers: number}} status - Safe runtime counters.
|
|
40
|
+
* @property {() => Promise<void>} closeActiveTunnel - Closes active tunnel sockets after their relay-side close handlers ran.
|
|
41
|
+
* @property {() => Promise<void>} close - Close function.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/** @typedef {{active: boolean, announced: boolean}} SessionLifecycle */
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {TcpRelayOptions} options - Relay options.
|
|
48
|
+
* @returns {Promise<TcpRelayHandle>} - Started relay.
|
|
49
|
+
*/
|
|
50
|
+
export async function startTcpRelay(options) {
|
|
51
|
+
/** @type {Set<import('node:net').Socket>} */
|
|
52
|
+
const tunnels = new Set()
|
|
53
|
+
/** @type {Map<import('node:net').Socket, {promise?: Promise<void>}>} */
|
|
54
|
+
const tunnelCloseSettlements = new Map()
|
|
55
|
+
/** @type {Set<import('node:net').Socket>} */
|
|
56
|
+
const targets = new Set()
|
|
57
|
+
/** @type {Map<string, import('node:net').Socket>} */
|
|
58
|
+
const sessionTargets = new Map()
|
|
59
|
+
/** @type {Map<string, import('node:net').Socket>} */
|
|
60
|
+
const sessionInitialTargetAttempts = new Map()
|
|
61
|
+
/** @type {Map<string, ReturnType<typeof setTimeout>>} */
|
|
62
|
+
const sessionInitialTargetRetryTimers = new Map()
|
|
63
|
+
/** @type {Map<string, ReturnType<typeof setTimeout>>} */
|
|
64
|
+
const sessionInitialTargetConnectTimers = new Map()
|
|
65
|
+
/**
|
|
66
|
+
* Active tunnel send functions, one per session. Each session has at most one
|
|
67
|
+
* owning tunnel at a time. On resume the new tunnel's sendFrame replaces the
|
|
68
|
+
* old one atomically.
|
|
69
|
+
*
|
|
70
|
+
* **Sender-capture rule**: functions that deliver target data MUST capture
|
|
71
|
+
* the sender from this map at enqueue time, not at delivery time, because
|
|
72
|
+
* `queueSessionSend` defers execution. A target data event observed with a
|
|
73
|
+
* sender must use that sender; an event observed detached (sender undefined)
|
|
74
|
+
* must buffer. Resume replay/flush captures the resumed sender that owns its
|
|
75
|
+
* queued operation so stale work cannot adopt a later tunnel.
|
|
76
|
+
* @type {Map<string, (header: import('../protocol/frame.js').FrameHeader, payload?: Buffer) => Promise<boolean>>}
|
|
77
|
+
*/
|
|
78
|
+
const sessionSenders = new Map()
|
|
79
|
+
/** @type {Map<string, Buffer[]>} */
|
|
80
|
+
const sessionTargetBuffers = new Map()
|
|
81
|
+
/** @type {Map<string, SentChunk[]>} */
|
|
82
|
+
const sessionTargetSentChunks = new Map()
|
|
83
|
+
/** @type {Map<string, ReturnType<typeof setTimeout>>} */
|
|
84
|
+
const sessionTargetExpiryTimers = new Map()
|
|
85
|
+
/** @type {Map<string, import('../protocol/frame.js').FrameHeader>} */
|
|
86
|
+
const sessionTargetTerminalState = new Map()
|
|
87
|
+
|
|
88
|
+
/** @type {Map<string, number>} */
|
|
89
|
+
const sessionPendingClientSequence = new Map()
|
|
90
|
+
|
|
91
|
+
/** Codec negotiated on the connection currently serving each session. */
|
|
92
|
+
/** @type {Map<string, string>} */
|
|
93
|
+
const sessionSendCodec = new Map()
|
|
94
|
+
|
|
95
|
+
/** @type {Map<string, AdaptiveCompressor>} */
|
|
96
|
+
const sessionCompressor = new Map()
|
|
97
|
+
|
|
98
|
+
/** @type {Map<string, {tail: Promise<void>}>} */
|
|
99
|
+
const sessionSendQueue = new Map()
|
|
100
|
+
|
|
101
|
+
/** @type {Map<string, {lifecycle: SessionLifecycle, sender: (header: import('../protocol/frame.js').FrameHeader, payload?: Buffer) => Promise<boolean>, chunks: Buffer[], bytes: number}>} */
|
|
102
|
+
const sessionQueuedTargetBatch = new Map()
|
|
103
|
+
|
|
104
|
+
/** Object identity prevents stale async work from attaching to a reused identifier. */
|
|
105
|
+
/** @type {Map<string, SessionLifecycle>} */
|
|
106
|
+
const sessionLifecycles = new Map()
|
|
107
|
+
|
|
108
|
+
/** @type {Map<string, number>} */
|
|
109
|
+
const sessionTargetBufferBytes = new Map()
|
|
110
|
+
let totalProcessBufferBytes = 0
|
|
111
|
+
|
|
112
|
+
const compressionLimits = options.compressionLimits ?? {}
|
|
113
|
+
getMaxDecompressedBytes(compressionLimits)
|
|
114
|
+
const supportedCodecs = options.compressionEnabled === false ? [] : availableCodecs()
|
|
115
|
+
|
|
116
|
+
const maxReplayBytes = options.maxReplayBytes ?? (16 * 1024 * 1024)
|
|
117
|
+
const maxTargetBufferBytes = options.maxTargetBufferBytes ?? (1_048_576)
|
|
118
|
+
const maxTotalBufferBytes = options.maxTotalBufferBytes ?? (16_777_216)
|
|
119
|
+
const initialTargetRetryDelayMs = options.initialTargetRetryDelayMs ?? 250
|
|
120
|
+
const initialTargetRetryMaxDelayMs = options.initialTargetRetryMaxDelayMs ?? 5_000
|
|
121
|
+
const initialTargetConnectTimeoutMs = options.initialTargetConnectTimeoutMs ?? 10_000
|
|
122
|
+
const createTargetConnection = options.createTargetConnection ?? ((target) => createConnection(target))
|
|
123
|
+
const handshakeTimeoutMs = options.handshakeTimeoutMs ?? 10_000
|
|
124
|
+
const maxInputBytesPerConnection = options.maxInputBytesPerConnection ?? 65_536
|
|
125
|
+
if (!Number.isSafeInteger(initialTargetRetryDelayMs) || initialTargetRetryDelayMs <= 0) {
|
|
126
|
+
throw new Error("initialTargetRetryDelayMs must be a positive integer")
|
|
127
|
+
}
|
|
128
|
+
if (!Number.isSafeInteger(initialTargetRetryMaxDelayMs) || initialTargetRetryMaxDelayMs < initialTargetRetryDelayMs) {
|
|
129
|
+
throw new Error("initialTargetRetryMaxDelayMs must be an integer greater than or equal to initialTargetRetryDelayMs")
|
|
130
|
+
}
|
|
131
|
+
if (!Number.isSafeInteger(initialTargetConnectTimeoutMs) || initialTargetConnectTimeoutMs <= 0) {
|
|
132
|
+
throw new Error("initialTargetConnectTimeoutMs must be a positive integer")
|
|
133
|
+
}
|
|
134
|
+
let closing = false
|
|
135
|
+
|
|
136
|
+
const tlsEnabled = options.tls !== undefined
|
|
137
|
+
/** @type {{cert: Buffer, key: Buffer, ca?: Buffer} | undefined} */
|
|
138
|
+
let tlsContext
|
|
139
|
+
if (tlsEnabled) {
|
|
140
|
+
const tlsOptions = /** @type {NonNullable<typeof options.tls>} */ (options.tls)
|
|
141
|
+
tlsContext = {
|
|
142
|
+
cert: readFileSync(tlsOptions.certFile),
|
|
143
|
+
key: readFileSync(tlsOptions.keyFile)
|
|
144
|
+
}
|
|
145
|
+
if (tlsOptions.clientCaFile !== undefined) {
|
|
146
|
+
tlsContext.ca = readFileSync(tlsOptions.clientCaFile)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** @type {(identifier: string) => void} */
|
|
151
|
+
const clearSessionTargetExpiry = (identifier) => {
|
|
152
|
+
const timer = sessionTargetExpiryTimers.get(identifier)
|
|
153
|
+
if (timer !== undefined) {
|
|
154
|
+
clearTimeout(timer)
|
|
155
|
+
sessionTargetExpiryTimers.delete(identifier)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** @type {(identifier: string) => void} */
|
|
160
|
+
const clearInitialTargetRetry = (identifier) => {
|
|
161
|
+
const timer = sessionInitialTargetRetryTimers.get(identifier)
|
|
162
|
+
if (timer !== undefined) {
|
|
163
|
+
clearTimeout(timer)
|
|
164
|
+
sessionInitialTargetRetryTimers.delete(identifier)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** @type {(identifier: string) => void} */
|
|
169
|
+
const clearInitialTargetConnectTimeout = (identifier) => {
|
|
170
|
+
const timer = sessionInitialTargetConnectTimers.get(identifier)
|
|
171
|
+
if (timer !== undefined) {
|
|
172
|
+
clearTimeout(timer)
|
|
173
|
+
sessionInitialTargetConnectTimers.delete(identifier)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** @type {(identifier: string) => void} */
|
|
178
|
+
const destroyInitialTargetAttempt = (identifier) => {
|
|
179
|
+
clearInitialTargetRetry(identifier)
|
|
180
|
+
clearInitialTargetConnectTimeout(identifier)
|
|
181
|
+
const attempt = sessionInitialTargetAttempts.get(identifier)
|
|
182
|
+
sessionInitialTargetAttempts.delete(identifier)
|
|
183
|
+
if (attempt !== undefined) {
|
|
184
|
+
attempt.removeAllListeners()
|
|
185
|
+
attempt.destroy()
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** @type {(identifier: string) => void} */
|
|
190
|
+
const destroySessionTarget = (identifier) => {
|
|
191
|
+
destroyInitialTargetAttempt(identifier)
|
|
192
|
+
const lifecycle = sessionLifecycles.get(identifier)
|
|
193
|
+
if (lifecycle !== undefined) {
|
|
194
|
+
lifecycle.active = false
|
|
195
|
+
sessionLifecycles.delete(identifier)
|
|
196
|
+
}
|
|
197
|
+
clearSessionTargetExpiry(identifier)
|
|
198
|
+
const bufferBytes = sessionTargetBufferBytes.get(identifier) ?? 0
|
|
199
|
+
if (bufferBytes > 0) {
|
|
200
|
+
totalProcessBufferBytes -= bufferBytes
|
|
201
|
+
sessionTargetBufferBytes.delete(identifier)
|
|
202
|
+
}
|
|
203
|
+
sessionTargetBuffers.delete(identifier)
|
|
204
|
+
sessionTargetSentChunks.delete(identifier)
|
|
205
|
+
sessionTargetTerminalState.delete(identifier)
|
|
206
|
+
sessionPendingClientSequence.delete(identifier)
|
|
207
|
+
sessionSendCodec.delete(identifier)
|
|
208
|
+
sessionCompressor.delete(identifier)
|
|
209
|
+
sessionSendQueue.delete(identifier)
|
|
210
|
+
sessionQueuedTargetBatch.delete(identifier)
|
|
211
|
+
const sessionTarget = sessionTargets.get(identifier)
|
|
212
|
+
sessionTargets.delete(identifier)
|
|
213
|
+
if (sessionTarget !== undefined) {
|
|
214
|
+
targets.delete(sessionTarget)
|
|
215
|
+
sessionTarget.removeAllListeners()
|
|
216
|
+
sessionTarget.destroy()
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** @type {(identifier: string, ackSequence: number) => void} */
|
|
221
|
+
const acknowledgeTargetData = (identifier, ackSequence) => {
|
|
222
|
+
const chunks = sessionTargetSentChunks.get(identifier)
|
|
223
|
+
if (chunks === undefined || chunks.length === 0) {
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
const remaining = chunks.filter((entry) => entry.sequence > ackSequence)
|
|
227
|
+
if (remaining.length === 0) {
|
|
228
|
+
sessionTargetSentChunks.delete(identifier)
|
|
229
|
+
} else {
|
|
230
|
+
sessionTargetSentChunks.set(identifier, remaining)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** @type {(identifier: string) => number} */
|
|
235
|
+
const currentReplayBytes = (identifier) => {
|
|
236
|
+
const chunks = sessionTargetSentChunks.get(identifier)
|
|
237
|
+
if (chunks === undefined) {
|
|
238
|
+
return 0
|
|
239
|
+
}
|
|
240
|
+
return chunks.reduce((sum, entry) => sum + entry.chunk.length, 0)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Encodes one target-to-client chunk for the wire. Retained replay chunks stay
|
|
245
|
+
* raw, so a replayed frame is re-encoded under whatever codec the current
|
|
246
|
+
* connection negotiated (including none).
|
|
247
|
+
* @type {(identifier: string, lifecycle: SessionLifecycle, sender: (header: import('../protocol/frame.js').FrameHeader, payload?: Buffer) => Promise<boolean>, sequence: number, chunk: Buffer) => Promise<boolean>}
|
|
248
|
+
*/
|
|
249
|
+
const sendStreamData = async (identifier, lifecycle, sender, sequence, chunk) => {
|
|
250
|
+
if (!lifecycle.active || sessionLifecycles.get(identifier) !== lifecycle) {
|
|
251
|
+
return false
|
|
252
|
+
}
|
|
253
|
+
const codec = sessionSendCodec.get(identifier)
|
|
254
|
+
const encoded = await encodeStreamPayload(chunk, codec, sessionCompressor.get(identifier), compressionLimits)
|
|
255
|
+
if (!lifecycle.active || sessionLifecycles.get(identifier) !== lifecycle) {
|
|
256
|
+
return false
|
|
257
|
+
}
|
|
258
|
+
/** @type {import('../protocol/frame.js').FrameHeader} */
|
|
259
|
+
const header = {type: "stream.data", direction: "target-to-client", sequence}
|
|
260
|
+
if (encoded.encoding !== undefined) {
|
|
261
|
+
header.encoding = encoded.encoding
|
|
262
|
+
header.dataLength = encoded.dataLength
|
|
263
|
+
}
|
|
264
|
+
// A close while awaiting socket drain does not discard bytes already
|
|
265
|
+
// observed with this captured sender: retain them for replay on resume.
|
|
266
|
+
await sender(header, encoded.payload)
|
|
267
|
+
return lifecycle.active && sessionLifecycles.get(identifier) === lifecycle
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Replays all unacknowledged target-to-client chunks for a session. Called
|
|
272
|
+
* on resume inside the session send queue after the sender has been
|
|
273
|
+
* registered. The resume operation supplies its owning sender so stale work
|
|
274
|
+
* cannot adopt a later tunnel after a second disconnect.
|
|
275
|
+
* @param {string} identifier - Session identifier.
|
|
276
|
+
* @param {SessionLifecycle} lifecycle - Current live session lifecycle.
|
|
277
|
+
* @param {(header: import('../protocol/frame.js').FrameHeader, payload?: Buffer) => Promise<boolean>} sender - Sender owned by this resume operation.
|
|
278
|
+
* @returns {Promise<void>} - Resolves after all replays are sent.
|
|
279
|
+
*/
|
|
280
|
+
const replayUnackedTargetData = async (identifier, lifecycle, sender) => {
|
|
281
|
+
if (!lifecycle.active || sessionLifecycles.get(identifier) !== lifecycle || sessionSenders.get(identifier) !== sender) {
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
const chunks = sessionTargetSentChunks.get(identifier)
|
|
285
|
+
if (chunks === undefined || chunks.length === 0) {
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
for (const entry of chunks) {
|
|
289
|
+
if (!await sendStreamData(identifier, lifecycle, sender, entry.sequence, entry.chunk)) {
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** @type {(identifier: string) => void} */
|
|
296
|
+
const expireSessionTargetLater = (identifier) => {
|
|
297
|
+
clearSessionTargetExpiry(identifier)
|
|
298
|
+
const lifecycle = sessionLifecycles.get(identifier)
|
|
299
|
+
const timer = setTimeout(() => {
|
|
300
|
+
if (lifecycle === undefined || !lifecycle.active || sessionLifecycles.get(identifier) !== lifecycle) {
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
sessionTargetExpiryTimers.delete(identifier)
|
|
304
|
+
destroySessionTarget(identifier)
|
|
305
|
+
try {
|
|
306
|
+
if (options.sessions.get(identifier) !== undefined) {
|
|
307
|
+
options.sessions.transition(identifier, "expired")
|
|
308
|
+
}
|
|
309
|
+
} catch {
|
|
310
|
+
// session already in a terminal state
|
|
311
|
+
}
|
|
312
|
+
options.sessions.delete(identifier)
|
|
313
|
+
}, options.resumeWindowMs ?? 30_000)
|
|
314
|
+
sessionTargetExpiryTimers.set(identifier, timer)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Serializes all target-to-client encoding, replay, and delivery work for a
|
|
319
|
+
* session. Zstd runs asynchronously, so this queue — not callback completion
|
|
320
|
+
* order — defines the protocol's uncompressed sequence order.
|
|
321
|
+
*
|
|
322
|
+
* **Sender-capture rule**: because operations are deferred through this queue,
|
|
323
|
+
* callers that deliver target data MUST capture the sender from
|
|
324
|
+
* `sessionSenders` before calling this function, not inside the queued
|
|
325
|
+
* operation. Otherwise a queued operation could observe a different tunnel's
|
|
326
|
+
* sender after a disconnect/resume cycle.
|
|
327
|
+
* @param {string} identifier - Session identifier.
|
|
328
|
+
* @param {() => Promise<void>} operation - One ordered operation.
|
|
329
|
+
* @returns {Promise<void>} - Resolves after the operation completes.
|
|
330
|
+
*/
|
|
331
|
+
const queueSessionSend = (identifier, operation) => {
|
|
332
|
+
const queue = sessionSendQueue.get(identifier)
|
|
333
|
+
// Start an idle queue synchronously. Async compression may yield, but a sole
|
|
334
|
+
// target chunk is never held merely to see whether another chunk arrives.
|
|
335
|
+
const next = queue === undefined
|
|
336
|
+
? Promise.resolve(operation())
|
|
337
|
+
: queue.tail.then(operation)
|
|
338
|
+
const state = {tail: next.catch(() => {})}
|
|
339
|
+
sessionSendQueue.set(identifier, state)
|
|
340
|
+
state.tail.finally(() => {
|
|
341
|
+
if (sessionSendQueue.get(identifier) === state) sessionSendQueue.delete(identifier)
|
|
342
|
+
})
|
|
343
|
+
return next
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Persists a target terminal result before placing its delivery behind any
|
|
348
|
+
* queued data. The first terminal result wins (notably, error before close),
|
|
349
|
+
* and the state remains resumable until normal session destruction/expiry.
|
|
350
|
+
* @param {string} identifier - Session identifier.
|
|
351
|
+
* @param {SessionLifecycle} lifecycle - Lifecycle captured by the target.
|
|
352
|
+
* @param {import('../protocol/frame.js').FrameHeader} terminal - Terminal frame.
|
|
353
|
+
*/
|
|
354
|
+
const persistAndQueueTargetTerminal = (identifier, lifecycle, terminal) => {
|
|
355
|
+
if (!lifecycle.active || sessionLifecycles.get(identifier) !== lifecycle || sessionTargetTerminalState.has(identifier)) return
|
|
356
|
+
sessionTargetTerminalState.set(identifier, terminal)
|
|
357
|
+
const sender = sessionSenders.get(identifier)
|
|
358
|
+
if (sender === undefined) return
|
|
359
|
+
void queueSessionSend(identifier, async () => {
|
|
360
|
+
if (!lifecycle.active || sessionLifecycles.get(identifier) !== lifecycle ||
|
|
361
|
+
sessionSenders.get(identifier) !== sender || sessionTargetTerminalState.get(identifier) !== terminal) return
|
|
362
|
+
await sender(terminal)
|
|
363
|
+
})
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Enqueues one target-to-client chunk for ordered delivery. Captures the
|
|
368
|
+
* current sender at enqueue time so the queued operation cannot later read
|
|
369
|
+
* a different tunnel's sender after a disconnect/resume cycle. When the
|
|
370
|
+
* session is detached (sender undefined) the chunk will be buffered.
|
|
371
|
+
* @param {string} identifier - Session identifier.
|
|
372
|
+
* @param {SessionLifecycle} lifecycle - Lifecycle captured by the target socket.
|
|
373
|
+
* @param {Buffer} chunk - Raw target data chunk.
|
|
374
|
+
* @returns {Promise<void>} - Resolves after the chunk is delivered or buffered.
|
|
375
|
+
*/
|
|
376
|
+
const deliverTargetChunk = (identifier, lifecycle, chunk) => {
|
|
377
|
+
if (!lifecycle.active || sessionLifecycles.get(identifier) !== lifecycle) {
|
|
378
|
+
return Promise.resolve()
|
|
379
|
+
}
|
|
380
|
+
const sender = sessionSenders.get(identifier)
|
|
381
|
+
if (sender === undefined) {
|
|
382
|
+
bufferDetachedTargetChunk(identifier, lifecycle, chunk)
|
|
383
|
+
return Promise.resolve()
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const maxBytes = getMaxDecompressedBytes(compressionLimits)
|
|
387
|
+
if (chunk.length > maxBytes) {
|
|
388
|
+
const deliveries = []
|
|
389
|
+
for (let offset = 0; offset < chunk.length; offset += maxBytes) {
|
|
390
|
+
deliveries.push(deliverTargetChunk(identifier, lifecycle, chunk.subarray(offset, Math.min(offset + maxBytes, chunk.length))))
|
|
391
|
+
}
|
|
392
|
+
return Promise.all(deliveries).then(() => {})
|
|
393
|
+
}
|
|
394
|
+
const queuedBatch = sessionQueuedTargetBatch.get(identifier)
|
|
395
|
+
if (sessionSendQueue.has(identifier) && queuedBatch !== undefined &&
|
|
396
|
+
queuedBatch.lifecycle === lifecycle && queuedBatch.sender === sender &&
|
|
397
|
+
queuedBatch.bytes + chunk.length <= maxBytes) {
|
|
398
|
+
// Copy while the target's data callback still owns the bytes. This batch
|
|
399
|
+
// exists only because earlier async send/compression work is in flight.
|
|
400
|
+
queuedBatch.chunks.push(Buffer.from(chunk))
|
|
401
|
+
queuedBatch.bytes += chunk.length
|
|
402
|
+
return sessionSendQueue.get(identifier)?.tail ?? Promise.resolve()
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (sessionSendQueue.has(identifier)) {
|
|
406
|
+
const batch = {lifecycle, sender, chunks: [Buffer.from(chunk)], bytes: chunk.length}
|
|
407
|
+
sessionQueuedTargetBatch.set(identifier, batch)
|
|
408
|
+
return queueSessionSend(identifier, async () => {
|
|
409
|
+
if (sessionQueuedTargetBatch.get(identifier) === batch) sessionQueuedTargetBatch.delete(identifier)
|
|
410
|
+
const merged = batch.chunks.length === 1 ? batch.chunks[0] : Buffer.concat(batch.chunks, batch.bytes)
|
|
411
|
+
if (merged !== undefined) await deliverTargetChunkNow(identifier, lifecycle, merged, sender)
|
|
412
|
+
})
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return queueSessionSend(identifier, async () => {
|
|
416
|
+
await deliverTargetChunkNow(identifier, lifecycle, chunk, sender)
|
|
417
|
+
})
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Copies, charges and admits detached target data synchronously, before any
|
|
422
|
+
* earlier asynchronous send in the ordered queue can complete.
|
|
423
|
+
* @param {string} identifier
|
|
424
|
+
* @param {SessionLifecycle} lifecycle
|
|
425
|
+
* @param {Buffer} chunk
|
|
426
|
+
*/
|
|
427
|
+
const bufferDetachedTargetChunk = (identifier, lifecycle, chunk) => {
|
|
428
|
+
if (!lifecycle.active || sessionLifecycles.get(identifier) !== lifecycle) return
|
|
429
|
+
|
|
430
|
+
const chunkCopy = Buffer.from(chunk)
|
|
431
|
+
const previousSessionBytes = sessionTargetBufferBytes.get(identifier) ?? 0
|
|
432
|
+
const newSessionBytes = previousSessionBytes + chunkCopy.length
|
|
433
|
+
const perSessionOverflow = newSessionBytes > maxTargetBufferBytes
|
|
434
|
+
const newTotalBytes = totalProcessBufferBytes + chunkCopy.length
|
|
435
|
+
const totalOverflow = newTotalBytes > maxTotalBufferBytes
|
|
436
|
+
|
|
437
|
+
if (perSessionOverflow || totalOverflow) {
|
|
438
|
+
if (previousSessionBytes > 0) totalProcessBufferBytes -= previousSessionBytes
|
|
439
|
+
sessionTargetBufferBytes.delete(identifier)
|
|
440
|
+
sessionTargetBuffers.delete(identifier)
|
|
441
|
+
const terminalError = perSessionOverflow
|
|
442
|
+
? {type: "error", code: "TARGET_BUFFER_OVERFLOW", message: `Session target-to-client buffer exceeded the per-session limit of ${maxTargetBufferBytes} bytes while the tunnel was detached.`, retryable: false}
|
|
443
|
+
: {type: "error", code: "TARGET_BUFFER_OVERFLOW", message: `Process-wide target-to-client buffer exceeded the limit of ${maxTotalBufferBytes} bytes while the tunnel was detached.`, retryable: false}
|
|
444
|
+
sessionTargetTerminalState.set(identifier, terminalError)
|
|
445
|
+
const sessionTarget = sessionTargets.get(identifier)
|
|
446
|
+
sessionTarget?.destroy()
|
|
447
|
+
sessionTargets.delete(identifier)
|
|
448
|
+
clearSessionTargetExpiry(identifier)
|
|
449
|
+
return
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
totalProcessBufferBytes = newTotalBytes
|
|
453
|
+
sessionTargetBufferBytes.set(identifier, newSessionBytes)
|
|
454
|
+
const buffer = sessionTargetBuffers.get(identifier) ?? []
|
|
455
|
+
buffer.push(chunkCopy)
|
|
456
|
+
sessionTargetBuffers.set(identifier, buffer)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Delivers (or buffers) one target-to-client chunk. The sender must be
|
|
461
|
+
* captured by the caller at enqueue time — see {@link deliverTargetChunk}.
|
|
462
|
+
*
|
|
463
|
+
* When the captured sender is undefined the chunk is buffered and will be
|
|
464
|
+
* flushed later by {@link flushBufferedTargetChunksNow} on resume. When a
|
|
465
|
+
* sender is present the chunk is encoded, sent, and retained for replay.
|
|
466
|
+
* @param {string} identifier - Session identifier.
|
|
467
|
+
* @param {SessionLifecycle} lifecycle - Lifecycle captured by the target socket.
|
|
468
|
+
* @param {Buffer} chunk - Raw target data chunk.
|
|
469
|
+
* @param {((header: import('../protocol/frame.js').FrameHeader, payload?: Buffer) => Promise<boolean>) | undefined} sender - Captured sender (undefined if detached).
|
|
470
|
+
* @returns {Promise<boolean>} - True when the chunk entered replay retention.
|
|
471
|
+
*/
|
|
472
|
+
async function deliverTargetChunkNow(identifier, lifecycle, chunk, sender) {
|
|
473
|
+
if (!lifecycle.active || sessionLifecycles.get(identifier) !== lifecycle || sender === undefined) {
|
|
474
|
+
return false
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Sequences, replay retention and every byte limit stay in uncompressed units.
|
|
478
|
+
const current = options.sessions.get(identifier)?.relaySequence ?? 0
|
|
479
|
+
const nextRelaySequence = current + chunk.length
|
|
480
|
+
if (!await sendStreamData(identifier, lifecycle, sender, nextRelaySequence, chunk)) return false
|
|
481
|
+
|
|
482
|
+
// Do not move protocol state until async encoding has completed successfully.
|
|
483
|
+
if (!lifecycle.active || sessionLifecycles.get(identifier) !== lifecycle || options.sessions.get(identifier) === undefined) return false
|
|
484
|
+
options.sessions.updateSequences(identifier, {relaySequence: nextRelaySequence})
|
|
485
|
+
|
|
486
|
+
const sentChunks = sessionTargetSentChunks.get(identifier) ?? []
|
|
487
|
+
sentChunks.push({sequence: nextRelaySequence, chunk: Buffer.from(chunk)})
|
|
488
|
+
sessionTargetSentChunks.set(identifier, sentChunks)
|
|
489
|
+
|
|
490
|
+
const newTotal = currentReplayBytes(identifier)
|
|
491
|
+
if (newTotal > maxReplayBytes) {
|
|
492
|
+
const sessionTarget = sessionTargets.get(identifier)
|
|
493
|
+
const terminalError = {type: "error", code: "SPOOL_TOO_LARGE", message: "Unacknowledged replay buffer exceeded maximum size.", retryable: false}
|
|
494
|
+
sender(terminalError)
|
|
495
|
+
sessionTargetTerminalState.set(identifier, terminalError)
|
|
496
|
+
sessionTarget?.destroy()
|
|
497
|
+
sessionTargets.delete(identifier)
|
|
498
|
+
clearSessionTargetExpiry(identifier)
|
|
499
|
+
}
|
|
500
|
+
return true
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Flushes all buffered target-to-client chunks for a session. Called on
|
|
505
|
+
* resume after replay with the sender owned by that resume operation.
|
|
506
|
+
* @param {string} identifier - Session identifier.
|
|
507
|
+
* @param {SessionLifecycle} lifecycle - Current live session lifecycle.
|
|
508
|
+
* @param {(header: import('../protocol/frame.js').FrameHeader, payload?: Buffer) => Promise<boolean>} sender - Sender owned by this resume operation.
|
|
509
|
+
* @returns {Promise<void>} - Resolves after all buffered chunks are delivered.
|
|
510
|
+
*/
|
|
511
|
+
const flushBufferedTargetChunksNow = async (identifier, lifecycle, sender) => {
|
|
512
|
+
if (!lifecycle.active || sessionLifecycles.get(identifier) !== lifecycle || sessionSenders.get(identifier) !== sender) return
|
|
513
|
+
const chunks = sessionTargetBuffers.get(identifier)
|
|
514
|
+
while (chunks !== undefined && chunks.length > 0) {
|
|
515
|
+
if (!lifecycle.active || sessionLifecycles.get(identifier) !== lifecycle || sessionSenders.get(identifier) !== sender) return
|
|
516
|
+
const chunk = chunks[0]
|
|
517
|
+
if (chunk === undefined || !await deliverTargetChunkNow(identifier, lifecycle, chunk, sender)) return
|
|
518
|
+
|
|
519
|
+
// Transfer ownership only after the chunk has entered replay retention.
|
|
520
|
+
// If the tunnel detaches before the next iteration, the remaining chunks
|
|
521
|
+
// and their accounting stay available for the following resume.
|
|
522
|
+
chunks.shift()
|
|
523
|
+
const previousSessionBytes = sessionTargetBufferBytes.get(identifier) ?? 0
|
|
524
|
+
const remainingSessionBytes = previousSessionBytes - chunk.length
|
|
525
|
+
totalProcessBufferBytes -= chunk.length
|
|
526
|
+
if (remainingSessionBytes > 0) {
|
|
527
|
+
sessionTargetBufferBytes.set(identifier, remainingSessionBytes)
|
|
528
|
+
} else {
|
|
529
|
+
sessionTargetBufferBytes.delete(identifier)
|
|
530
|
+
sessionTargetBuffers.delete(identifier)
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const server = tlsEnabled
|
|
536
|
+
? createTlsServer({
|
|
537
|
+
.../** @type {NonNullable<typeof tlsContext>} */ (tlsContext),
|
|
538
|
+
requestCert: tlsContext?.ca !== undefined,
|
|
539
|
+
rejectUnauthorized: true
|
|
540
|
+
}, (socket) => {
|
|
541
|
+
setupTunnel(socket)
|
|
542
|
+
})
|
|
543
|
+
: createServer((socket) => {
|
|
544
|
+
setupTunnel(socket)
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
/** @type {(socket: import('node:net').Socket) => void} */
|
|
548
|
+
function setupTunnel(socket) {
|
|
549
|
+
tunnels.add(socket)
|
|
550
|
+
/** @type {{promise?: Promise<void>}} */
|
|
551
|
+
const tunnelCloseSettlement = {}
|
|
552
|
+
tunnelCloseSettlements.set(socket, tunnelCloseSettlement)
|
|
553
|
+
const decoder = new FrameDecoder({maxInputBytes: maxInputBytesPerConnection, compactDirection: "client-to-target"})
|
|
554
|
+
let authenticatedSession = false
|
|
555
|
+
/** @type {string | undefined} */
|
|
556
|
+
let sessionIdentifier
|
|
557
|
+
/** @type {SessionLifecycle | undefined} */
|
|
558
|
+
let tunnelSessionLifecycle
|
|
559
|
+
/** Codec negotiated for this connection; undefined means uncompressed compact data. */
|
|
560
|
+
/** @type {string | undefined} */
|
|
561
|
+
let connectionCodec
|
|
562
|
+
/** @type {number | undefined} */
|
|
563
|
+
let connectionFraming
|
|
564
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
565
|
+
let handshakeTimer
|
|
566
|
+
|
|
567
|
+
const clearHandshakeTimeout = () => {
|
|
568
|
+
if (handshakeTimer !== undefined) {
|
|
569
|
+
clearTimeout(handshakeTimer)
|
|
570
|
+
handshakeTimer = undefined
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
handshakeTimer = setTimeout(() => {
|
|
575
|
+
if (!authenticatedSession) {
|
|
576
|
+
const errorFrame = encodeFrame({type: "error", code: "HANDSHAKE_TIMEOUT", message: "Authentication not completed within the handshake deadline.", retryable: false})
|
|
577
|
+
try {
|
|
578
|
+
if (!socket.destroyed && socket.writable) {
|
|
579
|
+
socket.end(errorFrame)
|
|
580
|
+
}
|
|
581
|
+
} catch {
|
|
582
|
+
// socket may already be destroyed
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}, handshakeTimeoutMs)
|
|
586
|
+
|
|
587
|
+
/** @type {(header: import('../protocol/frame.js').FrameHeader, payload?: Buffer) => Promise<boolean>} */
|
|
588
|
+
const sendFrame = (header, payload = Buffer.alloc(0)) => {
|
|
589
|
+
if (socket.destroyed || socket.writableEnded || !socket.writable) {
|
|
590
|
+
return Promise.resolve(false)
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
try {
|
|
594
|
+
let bytes
|
|
595
|
+
if ((header.type === "stream.data" || header.type === "stream.ack") && connectionFraming !== 2) {
|
|
596
|
+
throw new Error("Refusing to emit a non-compact stream record")
|
|
597
|
+
}
|
|
598
|
+
if (header.type === "stream.data") {
|
|
599
|
+
bytes = encodeCompactData({sequence: /** @type {number} */ (header.sequence), payload, ...(header.encoding === "zstd" ? {dataLength: /** @type {number} */ (header.dataLength)} : {})})
|
|
600
|
+
} else if (header.type === "stream.ack") {
|
|
601
|
+
bytes = encodeCompactAck(/** @type {number} */ (header.ackSequence))
|
|
602
|
+
} else {
|
|
603
|
+
bytes = encodeFrame(header, payload)
|
|
604
|
+
}
|
|
605
|
+
const writable = socket.write(bytes, (error) => {
|
|
606
|
+
if (error !== null && error !== undefined) {
|
|
607
|
+
socket.destroy(error)
|
|
608
|
+
}
|
|
609
|
+
})
|
|
610
|
+
if (writable) return Promise.resolve(true)
|
|
611
|
+
return new Promise((resolve) => {
|
|
612
|
+
const cleanup = () => {
|
|
613
|
+
socket.off("drain", onDrain)
|
|
614
|
+
socket.off("close", onClose)
|
|
615
|
+
}
|
|
616
|
+
const onDrain = () => {
|
|
617
|
+
cleanup()
|
|
618
|
+
resolve(true)
|
|
619
|
+
}
|
|
620
|
+
const onClose = () => {
|
|
621
|
+
cleanup()
|
|
622
|
+
resolve(false)
|
|
623
|
+
}
|
|
624
|
+
socket.once("drain", onDrain)
|
|
625
|
+
socket.once("close", onClose)
|
|
626
|
+
})
|
|
627
|
+
} catch (error) {
|
|
628
|
+
socket.destroy(error instanceof Error ? error : undefined)
|
|
629
|
+
return Promise.resolve(false)
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Removes a session that never crossed the readiness/announcement boundary.
|
|
635
|
+
* Object identity prevents a stale tunnel callback from deleting a reused id.
|
|
636
|
+
* @param {string} identifier
|
|
637
|
+
* @param {SessionLifecycle} lifecycle
|
|
638
|
+
*/
|
|
639
|
+
const deleteUnannouncedSession = (identifier, lifecycle) => {
|
|
640
|
+
if (lifecycle.announced || sessionLifecycles.get(identifier) !== lifecycle) return
|
|
641
|
+
destroySessionTarget(identifier)
|
|
642
|
+
sessionSenders.delete(identifier)
|
|
643
|
+
options.sessions.delete(identifier)
|
|
644
|
+
if (sessionIdentifier === identifier) {
|
|
645
|
+
sessionIdentifier = undefined
|
|
646
|
+
tunnelSessionLifecycle = undefined
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Installs handlers for a target that has crossed the TCP connect boundary.
|
|
652
|
+
* Initial connection failures never enter this terminal target lifecycle.
|
|
653
|
+
* @param {string} identifier
|
|
654
|
+
* @param {SessionLifecycle} lifecycle
|
|
655
|
+
* @param {import('node:net').Socket} targetSocket
|
|
656
|
+
*/
|
|
657
|
+
const attachConnectedTarget = (identifier, lifecycle, targetSocket) => {
|
|
658
|
+
targetSocket.on("data", (chunk) => {
|
|
659
|
+
void deliverTargetChunk(identifier, lifecycle, chunk).catch((error) => {
|
|
660
|
+
if (!lifecycle.active || sessionLifecycles.get(identifier) !== lifecycle) return
|
|
661
|
+
const terminalError = {
|
|
662
|
+
type: "error",
|
|
663
|
+
code: "TARGET_CLOSED",
|
|
664
|
+
message: "Target-to-client delivery failed.",
|
|
665
|
+
retryable: false
|
|
666
|
+
}
|
|
667
|
+
persistAndQueueTargetTerminal(identifier, lifecycle, terminalError)
|
|
668
|
+
targetSocket.destroy(error instanceof Error ? error : undefined)
|
|
669
|
+
})
|
|
670
|
+
})
|
|
671
|
+
targetSocket.on("error", () => {
|
|
672
|
+
if (!lifecycle.active || sessionLifecycles.get(identifier) !== lifecycle) return
|
|
673
|
+
targets.delete(targetSocket)
|
|
674
|
+
if (sessionTargets.get(identifier) === targetSocket) sessionTargets.delete(identifier)
|
|
675
|
+
if (!sessionTargetTerminalState.has(identifier)) {
|
|
676
|
+
persistAndQueueTargetTerminal(identifier, lifecycle, {
|
|
677
|
+
type: "error",
|
|
678
|
+
code: "TARGET_CLOSED",
|
|
679
|
+
message: "Target connection failed.",
|
|
680
|
+
retryable: false
|
|
681
|
+
})
|
|
682
|
+
}
|
|
683
|
+
})
|
|
684
|
+
targetSocket.on("close", () => {
|
|
685
|
+
targets.delete(targetSocket)
|
|
686
|
+
if (!lifecycle.active || sessionLifecycles.get(identifier) !== lifecycle) return
|
|
687
|
+
if (sessionTargets.get(identifier) === targetSocket) sessionTargets.delete(identifier)
|
|
688
|
+
if (!sessionTargetTerminalState.has(identifier)) {
|
|
689
|
+
persistAndQueueTargetTerminal(identifier, lifecycle, {type: "session.close", direction: "target-to-client"})
|
|
690
|
+
}
|
|
691
|
+
})
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Starts one bounded-rate initial target acquisition loop for this tunnel.
|
|
696
|
+
* @param {string} identifier
|
|
697
|
+
* @param {SessionLifecycle} lifecycle
|
|
698
|
+
* @param {import('../protocol/frame.js').FrameHeader} createdResponse
|
|
699
|
+
*/
|
|
700
|
+
const startInitialTargetAcquisition = (identifier, lifecycle, createdResponse) => {
|
|
701
|
+
let retryDelayMs = initialTargetRetryDelayMs
|
|
702
|
+
|
|
703
|
+
const acquisitionStopped = () => closing || socket.destroyed || socket.writableEnded || !socket.writable ||
|
|
704
|
+
!lifecycle.active || lifecycle.announced || sessionLifecycles.get(identifier) !== lifecycle ||
|
|
705
|
+
options.sessions.get(identifier)?.state !== "creating"
|
|
706
|
+
|
|
707
|
+
const scheduleRetry = () => {
|
|
708
|
+
if (acquisitionStopped() || sessionInitialTargetRetryTimers.has(identifier)) return
|
|
709
|
+
const timer = setTimeout(() => {
|
|
710
|
+
if (sessionInitialTargetRetryTimers.get(identifier) !== timer) return
|
|
711
|
+
sessionInitialTargetRetryTimers.delete(identifier)
|
|
712
|
+
attemptTarget()
|
|
713
|
+
}, retryDelayMs)
|
|
714
|
+
sessionInitialTargetRetryTimers.set(identifier, timer)
|
|
715
|
+
retryDelayMs = Math.min(retryDelayMs * 2, initialTargetRetryMaxDelayMs)
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const attemptTarget = () => {
|
|
719
|
+
if (acquisitionStopped() || sessionInitialTargetAttempts.has(identifier) || sessionInitialTargetRetryTimers.has(identifier)) return
|
|
720
|
+
const session = options.sessions.get(identifier)
|
|
721
|
+
if (session === undefined) return
|
|
722
|
+
const attempt = createTargetConnection({host: session.target.host, port: session.target.port})
|
|
723
|
+
sessionInitialTargetAttempts.set(identifier, attempt)
|
|
724
|
+
let settled = false
|
|
725
|
+
|
|
726
|
+
const clearAttempt = () => {
|
|
727
|
+
clearInitialTargetConnectTimeout(identifier)
|
|
728
|
+
if (sessionInitialTargetAttempts.get(identifier) === attempt) {
|
|
729
|
+
sessionInitialTargetAttempts.delete(identifier)
|
|
730
|
+
}
|
|
731
|
+
attempt.off("connect", onConnect)
|
|
732
|
+
attempt.off("error", onFailure)
|
|
733
|
+
attempt.off("close", onFailure)
|
|
734
|
+
}
|
|
735
|
+
const onFailure = () => {
|
|
736
|
+
if (settled) return
|
|
737
|
+
settled = true
|
|
738
|
+
clearAttempt()
|
|
739
|
+
attempt.destroy()
|
|
740
|
+
scheduleRetry()
|
|
741
|
+
}
|
|
742
|
+
const onConnect = () => {
|
|
743
|
+
if (settled) return
|
|
744
|
+
settled = true
|
|
745
|
+
clearAttempt()
|
|
746
|
+
if (acquisitionStopped()) {
|
|
747
|
+
attempt.destroy()
|
|
748
|
+
return
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
clearInitialTargetRetry(identifier)
|
|
752
|
+
sessionTargets.set(identifier, attempt)
|
|
753
|
+
targets.add(attempt)
|
|
754
|
+
attachConnectedTarget(identifier, lifecycle, attempt)
|
|
755
|
+
options.sessions.transition(identifier, "active")
|
|
756
|
+
|
|
757
|
+
void (async () => {
|
|
758
|
+
const announced = await sendFrame({...createdResponse, state: "active"})
|
|
759
|
+
if (!announced || closing || socket.destroyed || !lifecycle.active ||
|
|
760
|
+
sessionLifecycles.get(identifier) !== lifecycle) {
|
|
761
|
+
deleteUnannouncedSession(identifier, lifecycle)
|
|
762
|
+
return
|
|
763
|
+
}
|
|
764
|
+
lifecycle.announced = true
|
|
765
|
+
// Only a terminal observed before publishing this sender lacks its
|
|
766
|
+
// own ordered queue entry. Later terminals enqueue behind live data.
|
|
767
|
+
const detachedTerminalState = sessionTargetTerminalState.get(identifier)
|
|
768
|
+
sessionSenders.set(identifier, sendFrame)
|
|
769
|
+
if (connectionCodec === undefined) {
|
|
770
|
+
sessionSendCodec.delete(identifier)
|
|
771
|
+
sessionCompressor.delete(identifier)
|
|
772
|
+
} else {
|
|
773
|
+
sessionSendCodec.set(identifier, connectionCodec)
|
|
774
|
+
sessionCompressor.set(identifier, new AdaptiveCompressor())
|
|
775
|
+
}
|
|
776
|
+
await queueSessionSend(identifier, async () => {
|
|
777
|
+
if (!lifecycle.active || sessionLifecycles.get(identifier) !== lifecycle || sessionSenders.get(identifier) !== sendFrame) return
|
|
778
|
+
await flushBufferedTargetChunksNow(identifier, lifecycle, sendFrame)
|
|
779
|
+
if (detachedTerminalState !== undefined && sessionTargetTerminalState.get(identifier) === detachedTerminalState && sessionSenders.get(identifier) === sendFrame) await sendFrame(detachedTerminalState)
|
|
780
|
+
})
|
|
781
|
+
})()
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
attempt.once("connect", onConnect)
|
|
785
|
+
attempt.once("error", onFailure)
|
|
786
|
+
attempt.once("close", onFailure)
|
|
787
|
+
const connectTimer = setTimeout(() => {
|
|
788
|
+
if (sessionInitialTargetConnectTimers.get(identifier) !== connectTimer ||
|
|
789
|
+
sessionInitialTargetAttempts.get(identifier) !== attempt || acquisitionStopped()) return
|
|
790
|
+
onFailure()
|
|
791
|
+
}, initialTargetConnectTimeoutMs)
|
|
792
|
+
connectTimer.unref?.()
|
|
793
|
+
sessionInitialTargetConnectTimers.set(identifier, connectTimer)
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
attemptTarget()
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
let tunnelFrameQueue = Promise.resolve()
|
|
800
|
+
|
|
801
|
+
socket.on("data", (chunk) => {
|
|
802
|
+
const frameOperation = tunnelFrameQueue.then(async () => {
|
|
803
|
+
let frames
|
|
804
|
+
try {
|
|
805
|
+
frames = decoder.push(chunk)
|
|
806
|
+
} catch {
|
|
807
|
+
const errorFrame = encodeFrame({type: "error", code: "PROTOCOL_VIOLATION", message: "Malformed frame.", retryable: false})
|
|
808
|
+
socket.end(errorFrame)
|
|
809
|
+
return
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
for (const frame of frames) {
|
|
813
|
+
if (frame.wireFormat === "compact" && authenticatedSession && connectionFraming !== 2) {
|
|
814
|
+
sendFrame({type: "error", code: "PROTOCOL_VIOLATION", message: "Compact framing version 2 was not negotiated.", retryable: false})
|
|
815
|
+
socket.end()
|
|
816
|
+
return
|
|
817
|
+
}
|
|
818
|
+
if (frame.header.type === "session.create" || frame.header.type === "session.resume") {
|
|
819
|
+
if (authenticatedSession) {
|
|
820
|
+
await sendFrame({type: "error", code: "PROTOCOL_VIOLATION", message: "A tunnel can authenticate exactly one session.", retryable: false})
|
|
821
|
+
socket.end()
|
|
822
|
+
return
|
|
823
|
+
}
|
|
824
|
+
const response = handleControlMessage({
|
|
825
|
+
tokens: options.tokens,
|
|
826
|
+
sessions: options.sessions,
|
|
827
|
+
authorizeTarget: options.authorizeTarget,
|
|
828
|
+
supportedCodecs,
|
|
829
|
+
supportedFraming: [2]
|
|
830
|
+
}, frame.header)
|
|
831
|
+
if (response.type !== "session.created" && response.type !== "session.resumed") {
|
|
832
|
+
await sendFrame(response)
|
|
833
|
+
continue
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
authenticatedSession = true
|
|
837
|
+
decoder.disableInputBudget()
|
|
838
|
+
clearHandshakeTimeout()
|
|
839
|
+
connectionCodec = typeof response.compression === "string" ? response.compression : undefined
|
|
840
|
+
connectionFraming = response.framing === 2 ? 2 : undefined
|
|
841
|
+
sessionIdentifier = typeof response.identifier === "string" ? response.identifier : undefined
|
|
842
|
+
if (sessionIdentifier === undefined) {
|
|
843
|
+
await sendFrame({type: "error", code: "PROTOCOL_VIOLATION", message: "Session response did not contain an identifier.", retryable: false})
|
|
844
|
+
socket.end()
|
|
845
|
+
return
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
if (frame.header.type === "session.create") {
|
|
849
|
+
const lifecycle = {active: true, announced: false}
|
|
850
|
+
tunnelSessionLifecycle = lifecycle
|
|
851
|
+
sessionLifecycles.set(sessionIdentifier, lifecycle)
|
|
852
|
+
startInitialTargetAcquisition(sessionIdentifier, lifecycle, response)
|
|
853
|
+
continue
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const lifecycle = sessionLifecycles.get(sessionIdentifier)
|
|
857
|
+
if (lifecycle === undefined || !lifecycle.active || !lifecycle.announced) {
|
|
858
|
+
await sendFrame({type: "error", code: "SESSION_NOT_FOUND", message: "Session target is not available for resume.", retryable: false})
|
|
859
|
+
socket.end()
|
|
860
|
+
return
|
|
861
|
+
}
|
|
862
|
+
tunnelSessionLifecycle = lifecycle
|
|
863
|
+
const sent = await sendFrame(response)
|
|
864
|
+
if (!sent || socket.destroyed || !lifecycle.active || sessionLifecycles.get(sessionIdentifier) !== lifecycle) return
|
|
865
|
+
if (options.sessions.get(sessionIdentifier)?.state === "resuming") {
|
|
866
|
+
options.sessions.transition(sessionIdentifier, "active")
|
|
867
|
+
}
|
|
868
|
+
// Snapshot before publishing the replacement sender. A terminal
|
|
869
|
+
// observed afterward owns a queue entry behind preceding target data.
|
|
870
|
+
const detachedTerminalState = sessionTargetTerminalState.get(sessionIdentifier)
|
|
871
|
+
sessionSenders.set(sessionIdentifier, sendFrame)
|
|
872
|
+
if (connectionCodec === undefined) {
|
|
873
|
+
sessionSendCodec.delete(sessionIdentifier)
|
|
874
|
+
sessionCompressor.delete(sessionIdentifier)
|
|
875
|
+
} else {
|
|
876
|
+
sessionSendCodec.set(sessionIdentifier, connectionCodec)
|
|
877
|
+
sessionCompressor.set(sessionIdentifier, new AdaptiveCompressor())
|
|
878
|
+
}
|
|
879
|
+
const resumedSessionIdentifier = sessionIdentifier
|
|
880
|
+
if (typeof frame.header.targetToClientAck === "number") {
|
|
881
|
+
acknowledgeTargetData(resumedSessionIdentifier, frame.header.targetToClientAck)
|
|
882
|
+
}
|
|
883
|
+
await queueSessionSend(resumedSessionIdentifier, async () => {
|
|
884
|
+
if (!lifecycle.active || sessionLifecycles.get(resumedSessionIdentifier) !== lifecycle || sessionSenders.get(resumedSessionIdentifier) !== sendFrame) return
|
|
885
|
+
await replayUnackedTargetData(resumedSessionIdentifier, lifecycle, sendFrame)
|
|
886
|
+
await flushBufferedTargetChunksNow(resumedSessionIdentifier, lifecycle, sendFrame)
|
|
887
|
+
if (detachedTerminalState !== undefined && sessionTargetTerminalState.get(resumedSessionIdentifier) === detachedTerminalState && lifecycle.active && sessionLifecycles.get(resumedSessionIdentifier) === lifecycle && sessionSenders.get(resumedSessionIdentifier) === sendFrame) {
|
|
888
|
+
await sendFrame(detachedTerminalState)
|
|
889
|
+
}
|
|
890
|
+
})
|
|
891
|
+
continue
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
if (frame.header.type === "stream.data" && frame.header.direction === "client-to-target") {
|
|
895
|
+
if (!authenticatedSession) {
|
|
896
|
+
sendFrame({type: "error", code: "AUTH_REQUIRED", message: "Authentication is required before stream data.", retryable: false})
|
|
897
|
+
continue
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const lifecycle = sessionIdentifier === undefined ? undefined : sessionLifecycles.get(sessionIdentifier)
|
|
901
|
+
if (lifecycle === undefined || !lifecycle.active || !lifecycle.announced) {
|
|
902
|
+
await sendFrame({type: "error", code: "PROTOCOL_VIOLATION", message: "Stream data is forbidden before target readiness.", retryable: false})
|
|
903
|
+
socket.end()
|
|
904
|
+
return
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
if (frame.wireFormat !== "compact") {
|
|
908
|
+
sendFrame({type: "error", code: "PROTOCOL_VIOLATION", message: "Length-prefixed stream.data is forbidden after framing version 2 is established.", retryable: false})
|
|
909
|
+
socket.end()
|
|
910
|
+
return
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
if (sessionIdentifier !== undefined && sessionSenders.get(sessionIdentifier) !== sendFrame) {
|
|
914
|
+
continue
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const sequence = frame.header.sequence
|
|
918
|
+
if (!isValidStreamDataSequence(sequence)) {
|
|
919
|
+
sendFrame({type: "error", code: "PROTOCOL_VIOLATION", message: "stream.data sequence must be a positive safe integer.", retryable: false})
|
|
920
|
+
continue
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Decompress and verify before any sequence accounting: the contiguity
|
|
924
|
+
// check below is defined over uncompressed bytes, so a lying dataLength
|
|
925
|
+
// must never be able to move the acknowledgement watermark.
|
|
926
|
+
/** @type {Buffer} */
|
|
927
|
+
let dataBytes
|
|
928
|
+
try {
|
|
929
|
+
dataBytes = await decodeStreamPayload(frame.header, frame.payload ?? Buffer.alloc(0), connectionCodec, compressionLimits)
|
|
930
|
+
} catch (error) {
|
|
931
|
+
// Fail closed: a frame we cannot decode is never written to the target,
|
|
932
|
+
// and the connection ends rather than continuing on a desynchronised
|
|
933
|
+
// uncompressed byte stream.
|
|
934
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
935
|
+
sendFrame({type: "error", code: "PROTOCOL_VIOLATION", message: detail, retryable: false})
|
|
936
|
+
socket.end()
|
|
937
|
+
return
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
const currentSession = sessionIdentifier !== undefined ? options.sessions.get(sessionIdentifier) : undefined
|
|
941
|
+
const committedClientSequence = currentSession?.clientSequence ?? 0
|
|
942
|
+
const pendingClientSeq = sessionIdentifier !== undefined ? (sessionPendingClientSequence.get(sessionIdentifier) ?? 0) : 0
|
|
943
|
+
const effectiveClientSequence = Math.max(committedClientSequence, pendingClientSeq)
|
|
944
|
+
const payloadLength = dataBytes.length
|
|
945
|
+
const expectedSequence = effectiveClientSequence + payloadLength
|
|
946
|
+
|
|
947
|
+
if (sequence <= effectiveClientSequence) {
|
|
948
|
+
sendFrame({type: "stream.ack", direction: "client-to-target", ackSequence: committedClientSequence})
|
|
949
|
+
continue
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
if (sequence !== expectedSequence) {
|
|
953
|
+
const detail = sequence < expectedSequence
|
|
954
|
+
? `overlap (expected contiguous offset ${expectedSequence})`
|
|
955
|
+
: `gap (expected contiguous offset ${expectedSequence})`
|
|
956
|
+
sendFrame({type: "error", code: "PROTOCOL_VIOLATION", message: `stream.data sequence ${sequence} creates a ${detail}.`, retryable: false})
|
|
957
|
+
continue
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
const destination = sessionIdentifier === undefined ? undefined : sessionTargets.get(sessionIdentifier)
|
|
961
|
+
if (destination === undefined || destination.destroyed) {
|
|
962
|
+
sendFrame({type: "error", code: "TARGET_CLOSED", message: "Target socket is not available.", retryable: false})
|
|
963
|
+
socket.destroy()
|
|
964
|
+
continue
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const targetSessionIdentifier = sessionIdentifier
|
|
968
|
+
if (targetSessionIdentifier === undefined) continue
|
|
969
|
+
sessionPendingClientSequence.set(targetSessionIdentifier, sequence)
|
|
970
|
+
destination.write(dataBytes, (error) => {
|
|
971
|
+
if (error || destination.destroyed) {
|
|
972
|
+
const terminalError = {type: "error", code: "TARGET_CLOSED", message: "Target write failed; delivery outcome is uncertain.", retryable: false}
|
|
973
|
+
persistAndQueueTargetTerminal(targetSessionIdentifier, lifecycle, terminalError)
|
|
974
|
+
sendFrame(terminalError)
|
|
975
|
+
socket.destroy()
|
|
976
|
+
return
|
|
977
|
+
}
|
|
978
|
+
options.sessions.updateSequences(targetSessionIdentifier, {clientSequence: sequence})
|
|
979
|
+
const newCommitted = options.sessions.get(targetSessionIdentifier)?.clientSequence ?? 0
|
|
980
|
+
const currentPending = sessionPendingClientSequence.get(targetSessionIdentifier) ?? 0
|
|
981
|
+
sessionPendingClientSequence.set(targetSessionIdentifier, Math.max(currentPending, newCommitted))
|
|
982
|
+
sendFrame({type: "stream.ack", direction: "client-to-target", ackSequence: sequence})
|
|
983
|
+
})
|
|
984
|
+
continue
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
if (frame.header.type === "stream.ack") {
|
|
988
|
+
const lifecycle = sessionIdentifier === undefined ? undefined : sessionLifecycles.get(sessionIdentifier)
|
|
989
|
+
if (authenticatedSession && (lifecycle === undefined || !lifecycle.active || !lifecycle.announced)) {
|
|
990
|
+
await sendFrame({type: "error", code: "PROTOCOL_VIOLATION", message: "Stream acknowledgements are forbidden before target readiness.", retryable: false})
|
|
991
|
+
socket.end()
|
|
992
|
+
return
|
|
993
|
+
}
|
|
994
|
+
if (authenticatedSession && frame.wireFormat !== "compact") {
|
|
995
|
+
sendFrame({type: "error", code: "PROTOCOL_VIOLATION", message: "Length-prefixed stream.ack is forbidden after framing version 2 is established.", retryable: false})
|
|
996
|
+
socket.end()
|
|
997
|
+
return
|
|
998
|
+
}
|
|
999
|
+
if (frame.header.direction === "target-to-client" && typeof frame.header.ackSequence === "number" && sessionIdentifier !== undefined) {
|
|
1000
|
+
if (sessionSenders.get(sessionIdentifier) !== sendFrame) {
|
|
1001
|
+
continue
|
|
1002
|
+
}
|
|
1003
|
+
acknowledgeTargetData(sessionIdentifier, frame.header.ackSequence)
|
|
1004
|
+
}
|
|
1005
|
+
continue
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
if (frame.header.type === "session.close" && frame.header.direction === "client-to-relay") {
|
|
1009
|
+
if (!authenticatedSession) {
|
|
1010
|
+
sendFrame({type: "error", code: "AUTH_REQUIRED", message: "Authentication is required.", retryable: false})
|
|
1011
|
+
continue
|
|
1012
|
+
}
|
|
1013
|
+
const lifecycle = sessionIdentifier === undefined ? undefined : sessionLifecycles.get(sessionIdentifier)
|
|
1014
|
+
if (sessionIdentifier !== undefined && lifecycle !== undefined && !lifecycle.announced) {
|
|
1015
|
+
const sid = sessionIdentifier
|
|
1016
|
+
deleteUnannouncedSession(sid, lifecycle)
|
|
1017
|
+
await sendFrame({type: "session.close", direction: "target-to-client", acknowledged: true})
|
|
1018
|
+
continue
|
|
1019
|
+
}
|
|
1020
|
+
if (sessionIdentifier !== undefined && sessionSenders.get(sessionIdentifier) !== sendFrame) {
|
|
1021
|
+
continue
|
|
1022
|
+
}
|
|
1023
|
+
if (sessionIdentifier !== undefined) {
|
|
1024
|
+
const sid = sessionIdentifier
|
|
1025
|
+
destroySessionTarget(sid)
|
|
1026
|
+
sessionSenders.delete(sid)
|
|
1027
|
+
try {
|
|
1028
|
+
if (options.sessions.get(sid) !== undefined) {
|
|
1029
|
+
options.sessions.transition(sid, "closed")
|
|
1030
|
+
}
|
|
1031
|
+
} catch {
|
|
1032
|
+
// session already in a terminal state
|
|
1033
|
+
}
|
|
1034
|
+
options.sessions.delete(sid)
|
|
1035
|
+
sendFrame({type: "session.close", direction: "target-to-client", acknowledged: true})
|
|
1036
|
+
sessionIdentifier = undefined
|
|
1037
|
+
tunnelSessionLifecycle = undefined
|
|
1038
|
+
}
|
|
1039
|
+
continue
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
sendFrame({type: "error", code: "PROTOCOL_VIOLATION", message: `Unsupported frame type: ${frame.header.type}`, retryable: false})
|
|
1043
|
+
}
|
|
1044
|
+
})
|
|
1045
|
+
tunnelFrameQueue = frameOperation.catch((error) => {
|
|
1046
|
+
socket.destroy(error instanceof Error ? error : new Error(String(error)))
|
|
1047
|
+
})
|
|
1048
|
+
})
|
|
1049
|
+
|
|
1050
|
+
socket.on("close", () => {
|
|
1051
|
+
clearHandshakeTimeout()
|
|
1052
|
+
tunnels.delete(socket)
|
|
1053
|
+
const settleTunnelClose = () => {
|
|
1054
|
+
const lifecycle = tunnelSessionLifecycle
|
|
1055
|
+
if (sessionIdentifier !== undefined && lifecycle !== undefined &&
|
|
1056
|
+
sessionLifecycles.get(sessionIdentifier) === lifecycle && !lifecycle.announced) {
|
|
1057
|
+
deleteUnannouncedSession(sessionIdentifier, lifecycle)
|
|
1058
|
+
} else if (sessionIdentifier !== undefined && lifecycle !== undefined &&
|
|
1059
|
+
sessionLifecycles.get(sessionIdentifier) === lifecycle &&
|
|
1060
|
+
sessionSenders.get(sessionIdentifier) === sendFrame) {
|
|
1061
|
+
sessionSenders.delete(sessionIdentifier)
|
|
1062
|
+
sessionPendingClientSequence.delete(sessionIdentifier)
|
|
1063
|
+
sessionSendCodec.delete(sessionIdentifier)
|
|
1064
|
+
sessionCompressor.delete(sessionIdentifier)
|
|
1065
|
+
expireSessionTargetLater(sessionIdentifier)
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
// A TLS socket can emit close after delivering bytes but before the
|
|
1069
|
+
// promise-backed frame queue has processed them. Settle every frame that
|
|
1070
|
+
// was received first so an intentional session.close wins over the
|
|
1071
|
+
// transport-detach/resume path.
|
|
1072
|
+
const settleAndForget = () => {
|
|
1073
|
+
try {
|
|
1074
|
+
settleTunnelClose()
|
|
1075
|
+
} finally {
|
|
1076
|
+
tunnelCloseSettlements.delete(socket)
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
const settlement = tunnelFrameQueue.then(settleAndForget, settleAndForget)
|
|
1080
|
+
tunnelCloseSettlement.promise = settlement
|
|
1081
|
+
})
|
|
1082
|
+
socket.on("error", () => {
|
|
1083
|
+
clearHandshakeTimeout()
|
|
1084
|
+
})
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
await new Promise((resolve, reject) => {
|
|
1088
|
+
server.once("error", reject)
|
|
1089
|
+
server.listen(options.port ?? 0, options.host ?? "127.0.0.1", () => {
|
|
1090
|
+
server.off("error", reject)
|
|
1091
|
+
resolve(undefined)
|
|
1092
|
+
})
|
|
1093
|
+
})
|
|
1094
|
+
|
|
1095
|
+
const address = server.address()
|
|
1096
|
+
if (address === null || typeof address === "string") {
|
|
1097
|
+
throw new Error("Relay did not bind to a TCP port")
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
return {
|
|
1101
|
+
server,
|
|
1102
|
+
port: address.port,
|
|
1103
|
+
status: () => {
|
|
1104
|
+
let announcedSessions = 0
|
|
1105
|
+
for (const lifecycle of sessionLifecycles.values()) {
|
|
1106
|
+
if (lifecycle.announced) announcedSessions += 1
|
|
1107
|
+
}
|
|
1108
|
+
return {
|
|
1109
|
+
activeTunnels: tunnels.size,
|
|
1110
|
+
activeTargets: targets.size,
|
|
1111
|
+
sessions: sessionLifecycles.size,
|
|
1112
|
+
attachedSessions: sessionSenders.size,
|
|
1113
|
+
detachedSessions: Math.max(0, announcedSessions - sessionSenders.size),
|
|
1114
|
+
bufferedTargetBytes: totalProcessBufferBytes,
|
|
1115
|
+
targetConnectionAttempts: sessionInitialTargetAttempts.size,
|
|
1116
|
+
targetRetryTimers: sessionInitialTargetRetryTimers.size
|
|
1117
|
+
}
|
|
1118
|
+
},
|
|
1119
|
+
closeActiveTunnel: async () => {
|
|
1120
|
+
const closing = [...tunnelCloseSettlements].map(async ([tunnel, closeSettlement]) => {
|
|
1121
|
+
if (!tunnel.closed) {
|
|
1122
|
+
await new Promise((resolve) => {
|
|
1123
|
+
tunnel.once("close", resolve)
|
|
1124
|
+
tunnel.destroy()
|
|
1125
|
+
})
|
|
1126
|
+
}
|
|
1127
|
+
if (closeSettlement?.promise === undefined) {
|
|
1128
|
+
throw new Error("Tunnel close settlement was not registered")
|
|
1129
|
+
}
|
|
1130
|
+
await closeSettlement.promise
|
|
1131
|
+
})
|
|
1132
|
+
await Promise.all(closing)
|
|
1133
|
+
},
|
|
1134
|
+
close: async () => {
|
|
1135
|
+
closing = true
|
|
1136
|
+
for (const timer of sessionInitialTargetRetryTimers.values()) clearTimeout(timer)
|
|
1137
|
+
sessionInitialTargetRetryTimers.clear()
|
|
1138
|
+
for (const timer of sessionInitialTargetConnectTimers.values()) clearTimeout(timer)
|
|
1139
|
+
sessionInitialTargetConnectTimers.clear()
|
|
1140
|
+
for (const attempt of sessionInitialTargetAttempts.values()) {
|
|
1141
|
+
attempt.removeAllListeners()
|
|
1142
|
+
attempt.destroy()
|
|
1143
|
+
}
|
|
1144
|
+
sessionInitialTargetAttempts.clear()
|
|
1145
|
+
for (const [identifier, lifecycle] of sessionLifecycles) {
|
|
1146
|
+
if (!lifecycle.announced) options.sessions.delete(identifier)
|
|
1147
|
+
}
|
|
1148
|
+
for (const tunnel of tunnels) {
|
|
1149
|
+
tunnel.destroy()
|
|
1150
|
+
}
|
|
1151
|
+
for (const target of targets) {
|
|
1152
|
+
target.destroy()
|
|
1153
|
+
}
|
|
1154
|
+
sessionTargets.clear()
|
|
1155
|
+
sessionSenders.clear()
|
|
1156
|
+
sessionTargetBuffers.clear()
|
|
1157
|
+
sessionTargetSentChunks.clear()
|
|
1158
|
+
sessionTargetTerminalState.clear()
|
|
1159
|
+
sessionPendingClientSequence.clear()
|
|
1160
|
+
sessionSendCodec.clear()
|
|
1161
|
+
sessionCompressor.clear()
|
|
1162
|
+
sessionSendQueue.clear()
|
|
1163
|
+
sessionQueuedTargetBatch.clear()
|
|
1164
|
+
for (const lifecycle of sessionLifecycles.values()) lifecycle.active = false
|
|
1165
|
+
sessionLifecycles.clear()
|
|
1166
|
+
sessionTargetBufferBytes.clear()
|
|
1167
|
+
totalProcessBufferBytes = 0
|
|
1168
|
+
for (const timer of sessionTargetExpiryTimers.values()) {
|
|
1169
|
+
clearTimeout(timer)
|
|
1170
|
+
}
|
|
1171
|
+
sessionTargetExpiryTimers.clear()
|
|
1172
|
+
await new Promise((resolve, reject) => {
|
|
1173
|
+
server.close((error) => {
|
|
1174
|
+
if (error) {
|
|
1175
|
+
reject(error)
|
|
1176
|
+
return
|
|
1177
|
+
}
|
|
1178
|
+
resolve(undefined)
|
|
1179
|
+
})
|
|
1180
|
+
})
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/**
|
|
1186
|
+
* @param {unknown} value - Candidate sequence value.
|
|
1187
|
+
* @returns {value is number} - True if valid.
|
|
1188
|
+
*/
|
|
1189
|
+
function isValidStreamDataSequence(value) {
|
|
1190
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0
|
|
1191
|
+
}
|