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,563 @@
|
|
|
1
|
+
/* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param, jsdoc/require-property-description, jsdoc/require-returns */
|
|
2
|
+
import {readFileSync} from "node:fs"
|
|
3
|
+
import {createServer as createTlsServer} from "node:tls"
|
|
4
|
+
|
|
5
|
+
import {availableCodecs, negotiateCodec, zstdAvailable} from "../protocol/compression.js"
|
|
6
|
+
import {decodeV3Data, encodeV3Data, AdaptiveCompressor} from "../protocol/v3-compression.js"
|
|
7
|
+
import {decodeV3Control, encodeV3Control, encodeV3Frame, V3FrameDecoder, V3FrameType} from "../protocol/v3-frame.js"
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_FRAME_BYTES,
|
|
10
|
+
DEFAULT_HANDSHAKE_MS,
|
|
11
|
+
DEFAULT_KEEP_ALIVE_INITIAL_DELAY_MS,
|
|
12
|
+
DEFAULT_MAX_INPUT_BYTES,
|
|
13
|
+
DEFAULT_MAX_STREAMS,
|
|
14
|
+
DEFAULT_WINDOW,
|
|
15
|
+
GATEWAY_ID,
|
|
16
|
+
MAX_CONTROL_BYTES,
|
|
17
|
+
parseTarget,
|
|
18
|
+
positiveInteger,
|
|
19
|
+
protocolError,
|
|
20
|
+
STABLE_IDENTIFIER,
|
|
21
|
+
writeSocket
|
|
22
|
+
} from "./common.js"
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {{userId: string, username: string}} Principal
|
|
26
|
+
* @typedef {{host: string, port: number}} Target
|
|
27
|
+
* @typedef {object} ReverseGatewayHubOptions
|
|
28
|
+
* @property {string} [host] - TLS listen host, default loopback.
|
|
29
|
+
* @property {number} [port] - TLS listen port.
|
|
30
|
+
* @property {{authenticate(token: string): Principal | undefined}} tokens - Client and gateway bearer authenticator.
|
|
31
|
+
* @property {{certFile: string, keyFile: string, clientCaFile?: string}} tls - Mandatory outer TLS server configuration.
|
|
32
|
+
* @property {(input: {principal: Principal, gatewayId: string}) => boolean} authorizeGateway - Stable gateway identity binding.
|
|
33
|
+
* @property {(input: {principal: Principal, gatewayId: string}) => boolean} authorizeClientGateway - Client-to-gateway selection policy.
|
|
34
|
+
* @property {(input: {principal: Principal, gatewayId: string, target: Target}) => boolean} authorizeOpen - Hub-side route policy.
|
|
35
|
+
* @property {string[]} [knownGatewayIds] - Configured IDs that may be temporarily unavailable.
|
|
36
|
+
* @property {number} [initialWindowBytes] - Per-direction flow-control window.
|
|
37
|
+
* @property {number} [maxFrameBytes] - Maximum uncompressed DATA frame.
|
|
38
|
+
* @property {number} [maxStreams] - Process-wide bridge limit.
|
|
39
|
+
* @property {number} [maxPhysicalConnections] - Simultaneous TCP connection bound, including pre-TLS handshakes.
|
|
40
|
+
* @property {number} [maxConnectionIdentities] - Retained stale-generation identity bound.
|
|
41
|
+
* @property {number} [handshakeTimeoutMs] - Authentication deadline.
|
|
42
|
+
* @property {number} [maxInputBytesPerConnection] - Pre-authentication byte bound.
|
|
43
|
+
* @property {import('../protocol/compression.js').CompressionLimits} [compressionLimits] - zstd safety bounds.
|
|
44
|
+
* @typedef {object} HubPhysical
|
|
45
|
+
* @property {import('node:tls').TLSSocket} socket
|
|
46
|
+
* @property {Principal | undefined} principal
|
|
47
|
+
* @property {'gateway' | 'client' | undefined} role
|
|
48
|
+
* @property {string | undefined} connectionId
|
|
49
|
+
* @property {number} generation
|
|
50
|
+
* @property {string | undefined} codec
|
|
51
|
+
* @property {string | undefined} gatewayId
|
|
52
|
+
* @property {Map<number, Bridge>} streams
|
|
53
|
+
* @property {number} lastClientStreamId
|
|
54
|
+
* @property {number} nextGatewayStreamId
|
|
55
|
+
* @property {boolean} authenticated
|
|
56
|
+
* @property {boolean} closing
|
|
57
|
+
* @property {(() => void) | undefined} clearHandshake
|
|
58
|
+
* @typedef {object} GatewayOwner
|
|
59
|
+
* @property {string} gatewayId
|
|
60
|
+
* @property {string} connectionId
|
|
61
|
+
* @property {string} userId
|
|
62
|
+
* @property {number} generation
|
|
63
|
+
* @property {HubPhysical | undefined} physical
|
|
64
|
+
* @typedef {object} ClientOwner
|
|
65
|
+
* @property {string} connectionId
|
|
66
|
+
* @property {string} userId
|
|
67
|
+
* @property {number} generation
|
|
68
|
+
* @property {HubPhysical | undefined} physical
|
|
69
|
+
* @typedef {object} Bridge
|
|
70
|
+
* @property {HubPhysical} client
|
|
71
|
+
* @property {number} clientStreamId
|
|
72
|
+
* @property {HubPhysical} gateway
|
|
73
|
+
* @property {number} gatewayStreamId
|
|
74
|
+
* @property {string} identifier
|
|
75
|
+
* @property {Target} target
|
|
76
|
+
* @property {'opening' | 'open' | 'half-closed' | 'closing'} state
|
|
77
|
+
* @property {number} clientToGatewaySequence
|
|
78
|
+
* @property {number} gatewayToClientSequence
|
|
79
|
+
* @property {number} clientToGatewayAck
|
|
80
|
+
* @property {number} gatewayToClientAck
|
|
81
|
+
* @property {boolean} clientEnded
|
|
82
|
+
* @property {boolean} gatewayEnded
|
|
83
|
+
* @property {AdaptiveCompressor} clientToGatewayCompressor
|
|
84
|
+
* @property {AdaptiveCompressor} gatewayToClientCompressor
|
|
85
|
+
*/
|
|
86
|
+
|
|
87
|
+
/** Starts the Server3 reverse hub. */
|
|
88
|
+
export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions} */ options) {
|
|
89
|
+
validateOptions(options)
|
|
90
|
+
const initialWindow = positiveInteger(options.initialWindowBytes, "initialWindowBytes", DEFAULT_WINDOW)
|
|
91
|
+
const maxFrameBytes = Math.min(positiveInteger(options.maxFrameBytes, "maxFrameBytes", DEFAULT_FRAME_BYTES), 64 * 1024)
|
|
92
|
+
const maxStreams = positiveInteger(options.maxStreams, "maxStreams", DEFAULT_MAX_STREAMS)
|
|
93
|
+
const maxIdentities = positiveInteger(options.maxConnectionIdentities, "maxConnectionIdentities", 4096)
|
|
94
|
+
const maxPhysicalConnections = positiveInteger(options.maxPhysicalConnections, "maxPhysicalConnections", 8192)
|
|
95
|
+
/** @type {Map<string, GatewayOwner>} */
|
|
96
|
+
const gateways = new Map()
|
|
97
|
+
/** @type {Map<string, ClientOwner>} */
|
|
98
|
+
const clients = new Map()
|
|
99
|
+
/** @type {Set<HubPhysical>} */
|
|
100
|
+
const physicals = new Set()
|
|
101
|
+
/** @type {Set<import('node:net').Socket>} */
|
|
102
|
+
const tcpSockets = new Set()
|
|
103
|
+
/** @type {Map<string, {socket: import('node:net').Socket, timer: ReturnType<typeof setTimeout>}>} */
|
|
104
|
+
const pendingTls = new Map()
|
|
105
|
+
let bridges = 0
|
|
106
|
+
let physicalAccepts = 0
|
|
107
|
+
let gatewayRegistrations = 0
|
|
108
|
+
let rejectedRegistrations = 0
|
|
109
|
+
let rejectedOpens = 0
|
|
110
|
+
let rawDataBytes = 0
|
|
111
|
+
let wireDataBytes = 0
|
|
112
|
+
let closing = false
|
|
113
|
+
|
|
114
|
+
const server = createTlsServer({
|
|
115
|
+
cert: readFileSync(options.tls.certFile),
|
|
116
|
+
key: readFileSync(options.tls.keyFile),
|
|
117
|
+
...(options.tls.clientCaFile === undefined ? {} : {ca: readFileSync(options.tls.clientCaFile)}),
|
|
118
|
+
requestCert: options.tls.clientCaFile !== undefined,
|
|
119
|
+
rejectUnauthorized: options.tls.clientCaFile !== undefined
|
|
120
|
+
}, accept)
|
|
121
|
+
server.prependListener("connection", admitTcp)
|
|
122
|
+
|
|
123
|
+
/** @param {import('node:net').Socket} socket - Newly accepted TCP socket before TLS negotiation. */
|
|
124
|
+
function admitTcp(socket) {
|
|
125
|
+
if (closing || tcpSockets.size >= maxPhysicalConnections) { socket.destroy(); return }
|
|
126
|
+
physicalAccepts += 1
|
|
127
|
+
tcpSockets.add(socket)
|
|
128
|
+
const key = socketKey(socket)
|
|
129
|
+
const timer = setTimeout(() => socket.destroy(), options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_MS)
|
|
130
|
+
timer.unref?.()
|
|
131
|
+
pendingTls.set(key, {socket, timer})
|
|
132
|
+
socket.once("close", () => {
|
|
133
|
+
clearTimeout(timer)
|
|
134
|
+
tcpSockets.delete(socket)
|
|
135
|
+
if (pendingTls.get(key)?.socket === socket) pendingTls.delete(key)
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** @param {import('node:tls').TLSSocket} socket - Accepted verified server-side TLS socket. */
|
|
140
|
+
function accept(socket) {
|
|
141
|
+
const pending = pendingTls.get(socketKey(socket))
|
|
142
|
+
if (closing || pending === undefined) { socket.destroy(); return }
|
|
143
|
+
clearTimeout(pending.timer)
|
|
144
|
+
pendingTls.delete(socketKey(socket))
|
|
145
|
+
socket.setNoDelay(true)
|
|
146
|
+
socket.setKeepAlive(true, DEFAULT_KEEP_ALIVE_INITIAL_DELAY_MS)
|
|
147
|
+
/** @type {HubPhysical} */
|
|
148
|
+
const physical = {
|
|
149
|
+
socket,
|
|
150
|
+
principal: undefined,
|
|
151
|
+
role: undefined,
|
|
152
|
+
connectionId: undefined,
|
|
153
|
+
generation: 0,
|
|
154
|
+
codec: undefined,
|
|
155
|
+
gatewayId: undefined,
|
|
156
|
+
streams: new Map(),
|
|
157
|
+
lastClientStreamId: 0,
|
|
158
|
+
nextGatewayStreamId: 2,
|
|
159
|
+
authenticated: false,
|
|
160
|
+
closing: false,
|
|
161
|
+
clearHandshake: undefined
|
|
162
|
+
}
|
|
163
|
+
physicals.add(physical)
|
|
164
|
+
const decoder = new V3FrameDecoder({maxFrameBytes: Math.max(MAX_CONTROL_BYTES, maxFrameBytes + 4), maxBufferedBytes: Math.max(MAX_CONTROL_BYTES, maxFrameBytes + 4) * 2})
|
|
165
|
+
let unauthenticatedBytes = 0
|
|
166
|
+
let queue = Promise.resolve()
|
|
167
|
+
const handshakeTimer = setTimeout(() => terminate(physical, "HANDSHAKE_TIMEOUT"), options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_MS)
|
|
168
|
+
handshakeTimer.unref?.()
|
|
169
|
+
|
|
170
|
+
socket.on("data", (chunk) => {
|
|
171
|
+
socket.pause()
|
|
172
|
+
if (!physical.authenticated) unauthenticatedBytes += chunk.length
|
|
173
|
+
queue = queue.then(async () => {
|
|
174
|
+
if (!physical.authenticated && unauthenticatedBytes > (options.maxInputBytesPerConnection ?? DEFAULT_MAX_INPUT_BYTES)) throw protocolError("INPUT_LIMIT_EXCEEDED")
|
|
175
|
+
const frames = decoder.push(chunk)
|
|
176
|
+
for (const frame of frames) await handleFrame(physical, frame)
|
|
177
|
+
}).catch(async (error) => {
|
|
178
|
+
const code = typeof /** @type {{code?: unknown}} */ (error).code === "string" ? /** @type {{code: string}} */ (error).code : "PROTOCOL_VIOLATION"
|
|
179
|
+
await sendControl(physical, V3FrameType.GOAWAY, 0, 0, {code}).catch(() => {})
|
|
180
|
+
socket.destroy()
|
|
181
|
+
}).finally(() => {
|
|
182
|
+
if (!socket.destroyed && !physical.closing) socket.resume()
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
socket.on("error", () => {})
|
|
186
|
+
socket.once("close", () => {
|
|
187
|
+
clearTimeout(handshakeTimer)
|
|
188
|
+
void queue.finally(() => detachPhysical(physical, "TRANSPORT_CLOSED"))
|
|
189
|
+
})
|
|
190
|
+
physical.clearHandshake = () => clearTimeout(handshakeTimer)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** @param {HubPhysical} physical - Source. @param {import('../protocol/v3-frame.js').V3Frame} frame - Frame. */
|
|
194
|
+
async function handleFrame(physical, frame) {
|
|
195
|
+
if (physical.closing) throw protocolError("STALE_GENERATION")
|
|
196
|
+
if (!physical.authenticated) {
|
|
197
|
+
if (frame.type !== V3FrameType.AUTH || frame.streamId !== 0 || frame.flags !== 0) throw protocolError("AUTH_REQUIRED")
|
|
198
|
+
const auth = decodeV3Control(frame.payload)
|
|
199
|
+
if ((auth.role !== "gateway" && auth.role !== "client") || !Array.isArray(auth.versions) || !auth.versions.includes(3) || typeof auth.connectionId !== "string" || !STABLE_IDENTIFIER.test(auth.connectionId) || !Number.isSafeInteger(auth.generation) || /** @type {number} */ (auth.generation) <= 0) throw protocolError("PROTOCOL_NEGOTIATION_FAILED")
|
|
200
|
+
const principal = typeof auth.token === "string" ? options.tokens.authenticate(auth.token) : undefined
|
|
201
|
+
if (principal === undefined) {
|
|
202
|
+
await sendControl(physical, V3FrameType.AUTH_ERROR, 0, 0, {code: "AUTH_REQUIRED"})
|
|
203
|
+
physical.socket.end()
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
physical.role = auth.role
|
|
207
|
+
physical.principal = principal
|
|
208
|
+
physical.connectionId = auth.connectionId
|
|
209
|
+
physical.generation = /** @type {number} */ (auth.generation)
|
|
210
|
+
physical.codec = negotiateCodec(auth.compression, availableCodecs())
|
|
211
|
+
if (auth.role === "client") authenticateClientOwner(physical, maxIdentities)
|
|
212
|
+
physical.authenticated = true
|
|
213
|
+
physical.clearHandshake?.()
|
|
214
|
+
await sendControl(physical, V3FrameType.AUTH_OK, 0, physical.generation, {
|
|
215
|
+
version: 3,
|
|
216
|
+
role: physical.role,
|
|
217
|
+
connectionId: physical.connectionId,
|
|
218
|
+
generation: physical.generation,
|
|
219
|
+
compression: physical.codec ?? null,
|
|
220
|
+
initialWindow
|
|
221
|
+
})
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
if (frame.type === V3FrameType.PING) {
|
|
225
|
+
await sendControl(physical, V3FrameType.PONG, 0, frame.sequence, {})
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
if (frame.type === V3FrameType.PONG) return
|
|
229
|
+
if (frame.type === V3FrameType.GOAWAY) {
|
|
230
|
+
physical.socket.end()
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
if (physical.role === "gateway" && physical.gatewayId !== undefined && gateways.get(physical.gatewayId)?.physical !== physical) throw protocolError("STALE_GENERATION")
|
|
234
|
+
if (physical.role === "client" && physical.connectionId !== undefined && clients.get(physical.connectionId)?.physical !== physical) throw protocolError("STALE_GENERATION")
|
|
235
|
+
if (physical.role === "gateway" && physical.gatewayId === undefined) {
|
|
236
|
+
if (frame.type !== V3FrameType.GATEWAY_REGISTER) throw protocolError("GATEWAY_REGISTRATION_REQUIRED")
|
|
237
|
+
await registerGateway(physical, frame)
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
if (physical.role === "client" && physical.gatewayId === undefined) {
|
|
241
|
+
if (frame.type !== V3FrameType.GATEWAY_SELECT) throw protocolError("GATEWAY_SELECTION_REQUIRED")
|
|
242
|
+
await selectGateway(physical, frame)
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
if (frame.type === V3FrameType.GATEWAY_REGISTER || frame.type === V3FrameType.GATEWAY_REGISTERED || frame.type === V3FrameType.GATEWAY_SELECT || frame.type === V3FrameType.GATEWAY_SELECTED || frame.type === V3FrameType.AUTH || frame.type === V3FrameType.AUTH_OK || frame.type === V3FrameType.AUTH_ERROR || frame.type === V3FrameType.RESUME || frame.type === V3FrameType.RESUMED) throw protocolError("UNSUPPORTED_FRAME")
|
|
246
|
+
if (physical.role === "client" && frame.type === V3FrameType.OPEN) {
|
|
247
|
+
await openBridge(physical, frame)
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
await relayStreamFrame(physical, frame)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** @param {HubPhysical} physical - Authenticated client owner. @param {number} limit - Bound. */
|
|
254
|
+
function authenticateClientOwner(physical, limit) {
|
|
255
|
+
const id = /** @type {string} */ (physical.connectionId)
|
|
256
|
+
const principal = /** @type {Principal} */ (physical.principal)
|
|
257
|
+
const existing = clients.get(id)
|
|
258
|
+
if (existing !== undefined && existing.userId !== principal.userId) throw protocolError("AUTH_OWNERSHIP_MISMATCH")
|
|
259
|
+
if (existing !== undefined && physical.generation <= existing.generation) throw protocolError("STALE_GENERATION")
|
|
260
|
+
if (existing === undefined && clients.size >= limit) evictDisconnectedClientIdentity()
|
|
261
|
+
if (existing === undefined && clients.size >= limit) throw protocolError("IDENTITY_LIMIT")
|
|
262
|
+
if (existing?.physical !== undefined) terminate(existing.physical, "CONNECTION_REPLACED")
|
|
263
|
+
clients.set(id, {connectionId: id, userId: principal.userId, generation: physical.generation, physical})
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Evicts the oldest disconnected client identity while active owners remain fenced. */
|
|
267
|
+
function evictDisconnectedClientIdentity() {
|
|
268
|
+
for (const [id, owner] of clients) {
|
|
269
|
+
if (owner.physical !== undefined) continue
|
|
270
|
+
clients.delete(id)
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** @param {HubPhysical} physical - Authenticated gateway. @param {import('../protocol/v3-frame.js').V3Frame} frame - Register frame. */
|
|
276
|
+
async function registerGateway(physical, frame) {
|
|
277
|
+
if (frame.streamId !== 0 || frame.sequence !== physical.generation || frame.flags !== 0) throw protocolError("INVALID_GATEWAY_REGISTRATION")
|
|
278
|
+
const request = decodeV3Control(frame.payload)
|
|
279
|
+
if (typeof request.gatewayId !== "string" || !GATEWAY_ID.test(request.gatewayId) || physical.principal === undefined || options.authorizeGateway({principal: physical.principal, gatewayId: request.gatewayId}) !== true) {
|
|
280
|
+
rejectedRegistrations += 1
|
|
281
|
+
throw protocolError("GATEWAY_UNAUTHORIZED")
|
|
282
|
+
}
|
|
283
|
+
const existing = gateways.get(request.gatewayId)
|
|
284
|
+
const connectionId = /** @type {string} */ (physical.connectionId)
|
|
285
|
+
if (existing !== undefined && existing.userId !== physical.principal.userId) {
|
|
286
|
+
rejectedRegistrations += 1
|
|
287
|
+
throw protocolError("AUTH_OWNERSHIP_MISMATCH")
|
|
288
|
+
}
|
|
289
|
+
if (existing !== undefined && existing.connectionId !== connectionId) {
|
|
290
|
+
rejectedRegistrations += 1
|
|
291
|
+
throw protocolError("DUPLICATE_GATEWAY")
|
|
292
|
+
}
|
|
293
|
+
if (existing !== undefined && physical.generation <= existing.generation) {
|
|
294
|
+
rejectedRegistrations += 1
|
|
295
|
+
throw protocolError("STALE_GENERATION")
|
|
296
|
+
}
|
|
297
|
+
if (existing === undefined && gateways.size >= maxIdentities) throw protocolError("IDENTITY_LIMIT")
|
|
298
|
+
if (existing?.physical !== undefined) terminate(existing.physical, "GATEWAY_REPLACED")
|
|
299
|
+
physical.gatewayId = request.gatewayId
|
|
300
|
+
gateways.set(request.gatewayId, {
|
|
301
|
+
gatewayId: request.gatewayId,
|
|
302
|
+
connectionId,
|
|
303
|
+
userId: physical.principal.userId,
|
|
304
|
+
generation: physical.generation,
|
|
305
|
+
physical
|
|
306
|
+
})
|
|
307
|
+
gatewayRegistrations += 1
|
|
308
|
+
await sendControl(physical, V3FrameType.GATEWAY_REGISTERED, 0, physical.generation, {gatewayId: request.gatewayId, generation: physical.generation})
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** @param {HubPhysical} physical - Authenticated client. @param {import('../protocol/v3-frame.js').V3Frame} frame - Selection frame. */
|
|
312
|
+
async function selectGateway(physical, frame) {
|
|
313
|
+
if (frame.streamId !== 0 || frame.sequence !== physical.generation || frame.flags !== 0) throw protocolError("INVALID_GATEWAY_SELECTION")
|
|
314
|
+
const request = decodeV3Control(frame.payload)
|
|
315
|
+
if (typeof request.gatewayId !== "string" || !GATEWAY_ID.test(request.gatewayId)) throw protocolError("INVALID_GATEWAY_SELECTION")
|
|
316
|
+
if (physical.principal === undefined || options.authorizeClientGateway({principal: physical.principal, gatewayId: request.gatewayId}) !== true) throw protocolError("GATEWAY_UNAUTHORIZED")
|
|
317
|
+
const owner = gateways.get(request.gatewayId)
|
|
318
|
+
const known = owner !== undefined || options.knownGatewayIds?.includes(request.gatewayId) === true
|
|
319
|
+
if (!known) throw protocolError("UNKNOWN_GATEWAY_ID")
|
|
320
|
+
if (owner?.physical === undefined) throw protocolError("GATEWAY_UNAVAILABLE")
|
|
321
|
+
physical.gatewayId = request.gatewayId
|
|
322
|
+
await sendControl(physical, V3FrameType.GATEWAY_SELECTED, 0, physical.generation, {gatewayId: request.gatewayId, generation: physical.generation})
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** @param {HubPhysical} client - Client physical. @param {import('../protocol/v3-frame.js').V3Frame} frame - OPEN. */
|
|
326
|
+
async function openBridge(client, frame) {
|
|
327
|
+
if (frame.sequence !== 0) throw protocolError("INVALID_STREAM_OPEN")
|
|
328
|
+
if (frame.streamId % 2 === 0 || frame.streamId <= client.lastClientStreamId || client.streams.size >= maxStreams || bridges >= maxStreams) throw protocolError("INVALID_STREAM_ID")
|
|
329
|
+
client.lastClientStreamId = frame.streamId
|
|
330
|
+
const request = decodeV3Control(frame.payload)
|
|
331
|
+
const target = parseTarget(request.target)
|
|
332
|
+
if (target === undefined || typeof request.gatewayId !== "string" || request.gatewayId !== client.gatewayId || !GATEWAY_ID.test(request.gatewayId) || typeof request.identifier !== "string" || !STABLE_IDENTIFIER.test(request.identifier) || typeof request.name !== "string" || request.name.length === 0 || request.name.length > 256 || client.principal === undefined) {
|
|
333
|
+
await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "INVALID_STREAM_OPEN"})
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
if (options.authorizeOpen({principal: client.principal, gatewayId: request.gatewayId, target}) !== true) {
|
|
337
|
+
rejectedOpens += 1
|
|
338
|
+
await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "TARGET_UNAUTHORIZED"})
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
const gatewayOwner = gateways.get(request.gatewayId)
|
|
342
|
+
const gateway = gatewayOwner?.physical
|
|
343
|
+
if (gateway === undefined || gateway.socket.destroyed || gateway.gatewayId !== request.gatewayId) {
|
|
344
|
+
rejectedOpens += 1
|
|
345
|
+
await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: gatewayOwner === undefined ? "UNKNOWN_GATEWAY_ID" : "GATEWAY_UNAVAILABLE"})
|
|
346
|
+
return
|
|
347
|
+
}
|
|
348
|
+
const gatewayStreamId = gateway.nextGatewayStreamId
|
|
349
|
+
if (gatewayStreamId > 0xffff_fffe || gateway.streams.size >= maxStreams) {
|
|
350
|
+
await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "GATEWAY_STREAM_LIMIT"})
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
gateway.nextGatewayStreamId += 2
|
|
354
|
+
/** @type {Bridge} */
|
|
355
|
+
const bridge = {
|
|
356
|
+
client,
|
|
357
|
+
clientStreamId: frame.streamId,
|
|
358
|
+
gateway,
|
|
359
|
+
gatewayStreamId,
|
|
360
|
+
identifier: request.identifier,
|
|
361
|
+
target,
|
|
362
|
+
state: "opening",
|
|
363
|
+
clientToGatewaySequence: 0,
|
|
364
|
+
gatewayToClientSequence: 0,
|
|
365
|
+
clientToGatewayAck: 0,
|
|
366
|
+
gatewayToClientAck: 0,
|
|
367
|
+
clientEnded: false,
|
|
368
|
+
gatewayEnded: false,
|
|
369
|
+
clientToGatewayCompressor: new AdaptiveCompressor(),
|
|
370
|
+
gatewayToClientCompressor: new AdaptiveCompressor()
|
|
371
|
+
}
|
|
372
|
+
client.streams.set(frame.streamId, bridge)
|
|
373
|
+
gateway.streams.set(gatewayStreamId, bridge)
|
|
374
|
+
bridges += 1
|
|
375
|
+
await sendControl(gateway, V3FrameType.OPEN, gatewayStreamId, 0, {identifier: request.identifier, name: request.name, target, initialWindow})
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** @param {HubPhysical} source - Frame source. @param {import('../protocol/v3-frame.js').V3Frame} frame - Stream frame. */
|
|
379
|
+
async function relayStreamFrame(source, frame) {
|
|
380
|
+
const bridge = source.streams.get(frame.streamId)
|
|
381
|
+
if (bridge === undefined) {
|
|
382
|
+
if (frame.type !== V3FrameType.CLOSE && frame.type !== V3FrameType.RESET) await sendControl(source, V3FrameType.RESET, frame.streamId, 0, {code: "UNKNOWN_STREAM"})
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
const fromClient = source === bridge.client
|
|
386
|
+
const destination = fromClient ? bridge.gateway : bridge.client
|
|
387
|
+
const destinationStreamId = fromClient ? bridge.gatewayStreamId : bridge.clientStreamId
|
|
388
|
+
if (frame.type === V3FrameType.OPENED) {
|
|
389
|
+
if (fromClient || bridge.state !== "opening" || frame.sequence !== 0) throw protocolError("UNEXPECTED_OPENED")
|
|
390
|
+
const detail = decodeV3Control(frame.payload)
|
|
391
|
+
if (detail.identifier !== bridge.identifier) throw protocolError("OPEN_IDENTIFIER_MISMATCH")
|
|
392
|
+
bridge.state = "open"
|
|
393
|
+
await sendControl(destination, V3FrameType.OPENED, destinationStreamId, 0, {identifier: bridge.identifier, initialWindow})
|
|
394
|
+
return
|
|
395
|
+
}
|
|
396
|
+
if (bridge.state === "opening" && frame.type !== V3FrameType.RESET) throw protocolError("STREAM_NOT_OPEN")
|
|
397
|
+
if (frame.type === V3FrameType.ACK) {
|
|
398
|
+
const maximum = fromClient ? bridge.gatewayToClientSequence : bridge.clientToGatewaySequence
|
|
399
|
+
const previous = fromClient ? bridge.gatewayToClientAck : bridge.clientToGatewayAck
|
|
400
|
+
if (!Number.isSafeInteger(frame.sequence) || frame.sequence < previous || frame.sequence > maximum) throw protocolError("INVALID_ACK")
|
|
401
|
+
if (fromClient) bridge.gatewayToClientAck = frame.sequence
|
|
402
|
+
else bridge.clientToGatewayAck = frame.sequence
|
|
403
|
+
if (bridge.state !== "closing") await sendControl(destination, V3FrameType.ACK, destinationStreamId, frame.sequence, {})
|
|
404
|
+
return
|
|
405
|
+
}
|
|
406
|
+
if (frame.type === V3FrameType.WINDOW) {
|
|
407
|
+
if (frame.sequence <= 0 || frame.sequence > initialWindow) throw protocolError("INVALID_WINDOW")
|
|
408
|
+
if (bridge.state !== "closing") await sendControl(destination, V3FrameType.WINDOW, destinationStreamId, frame.sequence, {})
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
if (bridge.state === "closing") {
|
|
412
|
+
if (frame.type === V3FrameType.DATA || frame.type === V3FrameType.END) {
|
|
413
|
+
closeBridge(bridge, "STREAM_CLOSING")
|
|
414
|
+
return
|
|
415
|
+
}
|
|
416
|
+
if (frame.type !== V3FrameType.CLOSE && frame.type !== V3FrameType.RESET) throw protocolError("STREAM_CLOSING")
|
|
417
|
+
}
|
|
418
|
+
if (frame.type === V3FrameType.DATA) {
|
|
419
|
+
if (fromClient ? bridge.clientEnded : bridge.gatewayEnded) throw protocolError("DATA_AFTER_END")
|
|
420
|
+
const raw = await decodeV3Data(frame, source.codec, options.compressionLimits)
|
|
421
|
+
if (raw.length === 0 || raw.length > maxFrameBytes) throw protocolError("FRAME_TOO_LARGE")
|
|
422
|
+
const expected = (fromClient ? bridge.clientToGatewaySequence : bridge.gatewayToClientSequence) + raw.length
|
|
423
|
+
if (frame.sequence !== expected) throw protocolError("SEQUENCE_GAP")
|
|
424
|
+
if (fromClient) bridge.clientToGatewaySequence = frame.sequence
|
|
425
|
+
else bridge.gatewayToClientSequence = frame.sequence
|
|
426
|
+
const encoded = await encodeV3Data(raw, destination.codec, fromClient ? bridge.clientToGatewayCompressor : bridge.gatewayToClientCompressor, options.compressionLimits)
|
|
427
|
+
rawDataBytes += encoded.rawBytes
|
|
428
|
+
wireDataBytes += encoded.wireBytes
|
|
429
|
+
const delivered = await writeSocket(destination.socket, encodeV3Frame({type: V3FrameType.DATA, streamId: destinationStreamId, sequence: frame.sequence, flags: encoded.flags}, encoded.payload))
|
|
430
|
+
if (!delivered) closeBridge(bridge, "DESTINATION_CLOSED", source)
|
|
431
|
+
return
|
|
432
|
+
}
|
|
433
|
+
if (frame.type === V3FrameType.END) {
|
|
434
|
+
const expected = fromClient ? bridge.clientToGatewaySequence : bridge.gatewayToClientSequence
|
|
435
|
+
if (frame.sequence !== expected || (fromClient ? bridge.clientEnded : bridge.gatewayEnded)) throw protocolError("INVALID_END")
|
|
436
|
+
if (fromClient) bridge.clientEnded = true
|
|
437
|
+
else bridge.gatewayEnded = true
|
|
438
|
+
bridge.state = "half-closed"
|
|
439
|
+
await sendControl(destination, V3FrameType.END, destinationStreamId, frame.sequence, {})
|
|
440
|
+
return
|
|
441
|
+
}
|
|
442
|
+
if (frame.type === V3FrameType.CLOSE || frame.type === V3FrameType.RESET) {
|
|
443
|
+
const expected = fromClient ? bridge.clientToGatewaySequence : bridge.gatewayToClientSequence
|
|
444
|
+
if ((frame.type === V3FrameType.CLOSE && frame.sequence !== expected) || (frame.type === V3FrameType.RESET && frame.sequence !== 0)) throw protocolError("INVALID_STREAM_CLOSE")
|
|
445
|
+
const detail = frame.payload.length === 0 ? {} : decodeV3Control(frame.payload)
|
|
446
|
+
await sendControl(destination, frame.type, destinationStreamId, frame.sequence, detail)
|
|
447
|
+
if (frame.type === V3FrameType.RESET || fromClient) removeBridge(bridge)
|
|
448
|
+
else bridge.state = "closing"
|
|
449
|
+
return
|
|
450
|
+
}
|
|
451
|
+
throw protocolError("UNSUPPORTED_FRAME")
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** @param {Bridge} bridge - Stream. */
|
|
455
|
+
function removeBridge(bridge) {
|
|
456
|
+
const existed = bridge.client.streams.delete(bridge.clientStreamId)
|
|
457
|
+
bridge.gateway.streams.delete(bridge.gatewayStreamId)
|
|
458
|
+
if (existed) bridges = Math.max(0, bridges - 1)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** @param {Bridge} bridge - Stream. @param {string} code - Reset code. @param {HubPhysical} [excluded] - Already-dead peer. */
|
|
462
|
+
function closeBridge(bridge, code, excluded) {
|
|
463
|
+
if (bridge.client !== excluded && !bridge.client.socket.destroyed) void sendControl(bridge.client, V3FrameType.RESET, bridge.clientStreamId, 0, {code})
|
|
464
|
+
if (bridge.gateway !== excluded && !bridge.gateway.socket.destroyed) void sendControl(bridge.gateway, V3FrameType.RESET, bridge.gatewayStreamId, 0, {code})
|
|
465
|
+
removeBridge(bridge)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** @param {HubPhysical} physical - Connection. @param {string} code - Reason. */
|
|
469
|
+
function terminate(physical, code) {
|
|
470
|
+
if (physical.closing) return
|
|
471
|
+
physical.closing = true
|
|
472
|
+
const forceTimer = setTimeout(() => physical.socket.destroy(), 1_000)
|
|
473
|
+
forceTimer.unref?.()
|
|
474
|
+
void sendControl(physical, V3FrameType.GOAWAY, 0, 0, {code}).finally(() => {
|
|
475
|
+
clearTimeout(forceTimer)
|
|
476
|
+
physical.socket.destroy()
|
|
477
|
+
})
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** @param {HubPhysical} physical - Closed connection. @param {string} code - Stream reset. */
|
|
481
|
+
function detachPhysical(physical, code) {
|
|
482
|
+
if (!physicals.delete(physical)) return
|
|
483
|
+
physical.closing = true
|
|
484
|
+
for (const bridge of [...physical.streams.values()]) closeBridge(bridge, physical.role === "gateway" ? "GATEWAY_DISCONNECTED" : code, physical)
|
|
485
|
+
if (physical.gatewayId !== undefined) {
|
|
486
|
+
const owner = gateways.get(physical.gatewayId)
|
|
487
|
+
if (physical.role === "gateway" && owner?.physical === physical) {
|
|
488
|
+
owner.physical = undefined
|
|
489
|
+
for (const client of [...physicals]) {
|
|
490
|
+
if (client.role === "client" && client.gatewayId === physical.gatewayId) terminate(client, "GATEWAY_DISCONNECTED")
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (physical.role === "client" && physical.connectionId !== undefined) {
|
|
495
|
+
const owner = clients.get(physical.connectionId)
|
|
496
|
+
if (owner?.physical === physical) owner.physical = undefined
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** @param {HubPhysical} physical - Destination. @param {number} type - Frame type. @param {number} streamId - Stream ID. @param {number} sequence - Sequence. @param {Record<string, unknown>} value - Control body. */
|
|
501
|
+
async function sendControl(physical, type, streamId, sequence, value) {
|
|
502
|
+
return await writeSocket(physical.socket, encodeV3Frame({type, streamId, sequence}, encodeV3Control(value)))
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
await new Promise((resolve, reject) => {
|
|
506
|
+
server.once("error", reject)
|
|
507
|
+
server.listen(options.port ?? 0, options.host ?? "127.0.0.1", () => { server.off("error", reject); resolve(undefined) })
|
|
508
|
+
})
|
|
509
|
+
const address = server.address()
|
|
510
|
+
if (address === null || typeof address === "string") throw new Error("Reverse gateway hub did not bind to a TCP port")
|
|
511
|
+
|
|
512
|
+
return {
|
|
513
|
+
server,
|
|
514
|
+
port: address.port,
|
|
515
|
+
status: () => ({
|
|
516
|
+
physicalAccepts,
|
|
517
|
+
physicalConnections: tcpSockets.size,
|
|
518
|
+
gatewayPhysicalConnections: [...physicals].filter((physical) => physical.role === "gateway" && physical.gatewayId !== undefined).length,
|
|
519
|
+
clientPhysicalConnections: [...physicals].filter((physical) => physical.role === "client" && physical.authenticated).length,
|
|
520
|
+
retainedClientIdentities: clients.size,
|
|
521
|
+
registeredGateways: gateways.size,
|
|
522
|
+
healthyGateways: [...gateways.values()].filter((owner) => owner.physical !== undefined).length,
|
|
523
|
+
gatewayRegistrations,
|
|
524
|
+
rejectedRegistrations,
|
|
525
|
+
rejectedOpens,
|
|
526
|
+
virtualStreams: bridges,
|
|
527
|
+
rawDataBytes,
|
|
528
|
+
wireDataBytes,
|
|
529
|
+
compressionBytesSaved: Math.max(0, rawDataBytes - wireDataBytes)
|
|
530
|
+
}),
|
|
531
|
+
closeActiveGateway: async (/** @type {string} */ gatewayId) => {
|
|
532
|
+
const physical = gateways.get(gatewayId)?.physical
|
|
533
|
+
if (physical === undefined) return
|
|
534
|
+
await new Promise((resolve) => { physical.socket.once("close", resolve); physical.socket.destroy() })
|
|
535
|
+
},
|
|
536
|
+
close: async () => {
|
|
537
|
+
if (closing) return
|
|
538
|
+
closing = true
|
|
539
|
+
for (const {socket} of pendingTls.values()) socket.destroy()
|
|
540
|
+
for (const physical of [...physicals]) terminate(physical, "HUB_SHUTDOWN")
|
|
541
|
+
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve(undefined)))
|
|
542
|
+
gateways.clear()
|
|
543
|
+
clients.clear()
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** @param {import('node:net').Socket} socket - TCP or TLS socket. */
|
|
549
|
+
function socketKey(socket) {
|
|
550
|
+
return `${socket.remoteAddress}\0${socket.remotePort}\0${socket.localAddress}\0${socket.localPort}`
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** @param {ReverseGatewayHubOptions} options - Options. */
|
|
554
|
+
function validateOptions(options) {
|
|
555
|
+
if (typeof options !== "object" || options === null || options.tokens === undefined || typeof options.tokens.authenticate !== "function") throw new TypeError("Reverse gateway hub requires a token authenticator")
|
|
556
|
+
if (typeof options.authorizeGateway !== "function" || typeof options.authorizeClientGateway !== "function" || typeof options.authorizeOpen !== "function") throw new TypeError("Reverse gateway hub requires gateway, client selection, and open authorization policies")
|
|
557
|
+
if (typeof options.tls !== "object" || options.tls === null || typeof options.tls.certFile !== "string" || typeof options.tls.keyFile !== "string") throw new TypeError("Reverse gateway hub requires TLS certFile and keyFile")
|
|
558
|
+
if (!zstdAvailable()) throw new Error("Reverse gateway hub requires Node.js zstd support")
|
|
559
|
+
if (options.knownGatewayIds !== undefined && (!Array.isArray(options.knownGatewayIds) || options.knownGatewayIds.some((gatewayId) => typeof gatewayId !== "string" || !GATEWAY_ID.test(gatewayId)) || new Set(options.knownGatewayIds).size !== options.knownGatewayIds.length)) throw new TypeError("knownGatewayIds must contain unique valid gateway IDs")
|
|
560
|
+
const frame = positiveInteger(options.maxFrameBytes, "maxFrameBytes", DEFAULT_FRAME_BYTES)
|
|
561
|
+
const window = positiveInteger(options.initialWindowBytes, "initialWindowBytes", DEFAULT_WINDOW)
|
|
562
|
+
if (frame > window) throw new RangeError("maxFrameBytes must not exceed initialWindowBytes")
|
|
563
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param, jsdoc/require-returns */
|
|
2
|
+
import {StaticTokenStore} from "../auth/static-token-store.js"
|
|
3
|
+
import {OutboundReverseGateway} from "./gateway.js"
|
|
4
|
+
import {startReverseGatewayHub} from "./hub.js"
|
|
5
|
+
|
|
6
|
+
/** @param {import('../config/environment.js').RelayEnvironmentConfig} config - Parsed environment. */
|
|
7
|
+
export async function startReverseHubRuntime(config) {
|
|
8
|
+
if (config.mode !== "reverse-hub") throw new Error("Reverse hub runtime requires SOCKETDUCT_MODE=reverse-hub")
|
|
9
|
+
if (config.relayTls === undefined) throw new Error("Reverse hub mode requires SOCKETDUCT_TLS_CERT_FILE and SOCKETDUCT_TLS_KEY_FILE")
|
|
10
|
+
if (config.authTokens.length === 0) throw new Error("SOCKETDUCT_AUTH_TOKENS must contain hub client and gateway credentials")
|
|
11
|
+
if (config.reverseGatewayBindings === undefined || config.reverseGatewayBindings.length === 0) throw new Error("SOCKETDUCT_REVERSE_GATEWAY_BINDINGS must bind at least one gateway ID to a credential username")
|
|
12
|
+
if (config.reverseRoutes === undefined || config.reverseRoutes.length === 0) throw new Error("SOCKETDUCT_REVERSE_ROUTES must allow at least one client/gateway/target route")
|
|
13
|
+
if (config.compression === "off") throw new Error("Reverse hub mode requires SOCKETDUCT_COMPRESSION=auto")
|
|
14
|
+
const bindings = new Map(config.reverseGatewayBindings.map((binding) => [binding.gatewayId, binding.username]))
|
|
15
|
+
const routes = new Set(config.reverseRoutes.map((route) => formatRoute(route.username, route.gatewayId, {host: route.host, port: route.port})))
|
|
16
|
+
const selections = new Set(config.reverseRoutes.map((route) => `${route.username}\0${route.gatewayId}`))
|
|
17
|
+
if (bindings.size !== config.reverseGatewayBindings.length) throw new Error("SOCKETDUCT_REVERSE_GATEWAY_BINDINGS contains a duplicate gateway ID")
|
|
18
|
+
if (routes.size !== config.reverseRoutes.length) throw new Error("SOCKETDUCT_REVERSE_ROUTES contains a duplicate route")
|
|
19
|
+
const relayTls = config.relayTls
|
|
20
|
+
const tls = {
|
|
21
|
+
certFile: relayTls.certFile,
|
|
22
|
+
keyFile: relayTls.keyFile,
|
|
23
|
+
...(relayTls.clientCaFile === undefined ? {} : {clientCaFile: relayTls.clientCaFile})
|
|
24
|
+
}
|
|
25
|
+
const hub = await startReverseGatewayHub({
|
|
26
|
+
host: config.relayHost,
|
|
27
|
+
port: config.relayPort,
|
|
28
|
+
tls,
|
|
29
|
+
tokens: new StaticTokenStore(config.authTokens),
|
|
30
|
+
knownGatewayIds: [...bindings.keys()],
|
|
31
|
+
authorizeGateway: ({principal, gatewayId}) => bindings.get(gatewayId) === principal.username,
|
|
32
|
+
authorizeClientGateway: ({principal, gatewayId}) => selections.has(`${principal.username}\0${gatewayId}`),
|
|
33
|
+
authorizeOpen: ({principal, gatewayId, target}) => routes.has(formatRoute(principal.username, gatewayId, target)),
|
|
34
|
+
handshakeTimeoutMs: config.handshakeTimeoutMs,
|
|
35
|
+
maxInputBytesPerConnection: config.maxInputBytesPerConnection,
|
|
36
|
+
compressionLimits: compressionLimits(config)
|
|
37
|
+
})
|
|
38
|
+
return {hub, close: hub.close}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @param {import('../config/environment.js').RelayEnvironmentConfig} config - Parsed environment. @param {import('../logging/structured-logger.js').DiagnosticLogger} [logger] - Optional structured logger. */
|
|
42
|
+
export async function startOutboundReverseGatewayRuntime(config, logger) {
|
|
43
|
+
if (config.mode !== "reverse-gateway") throw new Error("Outbound gateway runtime requires SOCKETDUCT_MODE=reverse-gateway")
|
|
44
|
+
if (config.reverseGatewayId === undefined || config.reverseGatewayId.length === 0) throw new Error("SOCKETDUCT_REVERSE_GATEWAY_ID is required")
|
|
45
|
+
if (config.reverseConnectionId === undefined || config.reverseConnectionId.length === 0) throw new Error("SOCKETDUCT_REVERSE_CONNECTION_ID is required")
|
|
46
|
+
if (config.reverseHub === undefined || config.reverseHub.host.length === 0 || config.reverseHub.token.length === 0) throw new Error("SOCKETDUCT_REVERSE_HUB_HOST and SOCKETDUCT_REVERSE_TOKEN are required")
|
|
47
|
+
if (config.targetAllowlist.length === 0) throw new Error("SOCKETDUCT_TARGET_ALLOWLIST must contain at least one local gateway target")
|
|
48
|
+
if (config.compression === "off") throw new Error("Reverse gateway mode requires SOCKETDUCT_COMPRESSION=auto")
|
|
49
|
+
const targets = new Set(config.targetAllowlist.map((target) => `${target.host}:${target.port}`))
|
|
50
|
+
const gateway = new OutboundReverseGateway({
|
|
51
|
+
gatewayId: config.reverseGatewayId,
|
|
52
|
+
connectionId: config.reverseConnectionId,
|
|
53
|
+
hub: config.reverseHub,
|
|
54
|
+
tls: config.reverseHubTls ?? {},
|
|
55
|
+
authorizeTarget: (target) => targets.has(`${target.host}:${target.port}`),
|
|
56
|
+
...(config.initialTargetConnectTimeoutMs === undefined ? {} : {targetConnectTimeoutMs: config.initialTargetConnectTimeoutMs}),
|
|
57
|
+
maxStreamBufferBytes: config.maxTargetBufferBytes,
|
|
58
|
+
maxTotalBufferBytes: config.maxTotalBufferBytes,
|
|
59
|
+
compressionLimits: compressionLimits(config),
|
|
60
|
+
...(logger === undefined ? {} : {logger})
|
|
61
|
+
})
|
|
62
|
+
await gateway.start()
|
|
63
|
+
return {gateway, close: () => gateway.close()}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** @param {string} username - Client. @param {string} gatewayId - Gateway. @param {{host: string, port: number}} target - Target. */
|
|
67
|
+
function formatRoute(username, gatewayId, target) {
|
|
68
|
+
return `${username}\0${gatewayId}\0${target.host}\0${target.port}`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** @param {import('../config/environment.js').RelayEnvironmentConfig} config - Runtime limits. @returns {import('../protocol/compression.js').CompressionLimits} Defined codec limits. */
|
|
72
|
+
function compressionLimits(config) {
|
|
73
|
+
return {
|
|
74
|
+
...(config.minCompressBytes === undefined ? {} : {minCompressBytes: config.minCompressBytes}),
|
|
75
|
+
...(config.maxDecompressedBytes === undefined ? {} : {maxDecompressedBytes: config.maxDecompressedBytes}),
|
|
76
|
+
...(config.maxExpansionRatio === undefined ? {} : {maxExpansionRatio: config.maxExpansionRatio})
|
|
77
|
+
}
|
|
78
|
+
}
|