mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 11:16:55 +00:00
982c7830b1
* fix(mesh): buffer early TcpData on reverse-relay path
The reverse-relay code dropped the local-shaped reservation and any
TcpData frames buffered in it before acceptReverseRelay had wired up
the target stream. A peer that sent a request body immediately after
tcp_open_reverse lost those bytes when the target lived on a third
node, since reverse_local buffers them but reverse_relay did not.
Carry the reservation through acceptReverseRelay: transplant its
pendingData and pendingBytes into the new reverse_relay state, gate
TcpData writes on targetOpen, and flush buffered frames into the
target stream before sending tcp_open_ack. Mirrors the reverse_local
pattern. Regression test fires tcp_open_reverse plus an immediate
TcpData while ensureBridge is in flight and verifies the bytes
arrive intact, in order, before the ack.
* fix(mesh): recompose affected stacks when node-level mesh is disabled
disableForNode used to clear DB rows and override files but leave the
running containers attached to sencho_mesh with stale /etc/hosts
alias entries until an operator redeployed every stack by hand.
Mirror optOutStack: after the existing alias/forwarder cleanup, call
regenerateOverridesAcrossFleet, cascadeRecomposeAcrossFleet, and
triggerRedeploy for each previously meshed stack on the disabled
node so containers detach from sencho_mesh and shed the alias
entries they owned. The disabled node's mesh_stacks rows are deleted
before the cascade so listMeshStacks returns the right set with no
skip tuple required. Route threads the actor through actorFor(req)
for parity with optInStack/optOutStack. Tests cover the redeploy
fan-out, the cascade no-skip-tuple invariant, and the default actor
fallback for non-route callers.
* fix(mesh): require Admiral on the WS proxy-tunnel upgrade
HTTP mesh routes in routes/mesh.ts all enforce requireAdmiral, but
the /api/mesh/proxy-tunnel WS upgrade accepted any node_proxy or
full-admin api_token regardless of the receiver's license. A node
downgraded from Admiral kept serving mesh data-plane traffic to a
sibling central while refusing every mesh management call.
Read the receiver's local LicenseService at the upgrade and 403 when
the tier is not paid+admiral. The check sits after the existing
credential gate and uses LicenseService directly rather than
effectiveTier (which trusts forwarded proxy headers); a remote peer
dialing in cannot be trusted to assert our entitlement. Dialer and
node_proxy token format are unchanged. Three regression tests cover
community-tier node_proxy, skipper-tier node_proxy, and
community-tier full-admin api_token all rejected with 403.
* fix(mesh): handle no_target alongside push_failed in inspectStackServices
proxyFetch throws MeshError('no_target') when getProxyTarget returns
null (pilot tunnel offline, proxy bridge unreachable), but the
inspectStackServices catch branch only matched push_failed. Offline
remotes fell through to the generic 'remote unreachable' error log,
which the Routing tab surfaces as an unexpected fault.
Match both error codes and emit the operator-friendly warn message
that names the unreachable node and the error code. Regression test
spies on console.warn/console.error to pin the branch.
* docs(mesh): align env defaults and forwarder comments with current architecture
SENCHO_MESH_PROXY_TUNNEL_IDLE_MS in .env.example carried the old
five-minute idle-close value (=300000), but the code default is
DEFAULT_IDLE_TTL_MS=0 (persistent tunnel). Copying the example
silently reintroduced the idle-close behavior the dialer removed.
MeshForwarder.ts's leading docblock and inline listen comment still
described host-network mode plus extra_hosts: host-gateway as
required for forwarder reachability. Sencho runs in standard bridge
mode and attaches to the shared sencho_mesh network at a stable IP;
meshed user containers reach the forwarder by that IP directly.
Flip the env default to 0, rewrite the env comment to describe the
persistent behavior and the opt-in for idle teardown, and rewrite
both forwarder comments to match the bridge-network reality.
* fix(mesh): trust forwarded tier on proxy-tunnel WS and remove remote overrides on disable
Admiral entitlement on the WS data plane now follows the same trust model
as the HTTP mesh routes: the central asserts its tier via x-sencho-tier
and x-sencho-variant on the WS handshake, and the receiver trusts those
headers only when the upgrade carries a node_proxy credential. When no
headers are present or the credential is a full-admin api_token, the
receiver falls back to its own local license. Without this an Admiral
central could be rejected by a Community remote and a Community central
could dial a locally-Admiral remote.
disableForNode now routes through removeOverrideFromNode so override
files pushed earlier via applyLocalOverride are removed on remote nodes
via DELETE /api/mesh/local-override/:stack. Sequential awaits are wrapped
in Promise.allSettled to match regenerateOverridesForNode's parallel
push pattern.
Other changes:
- Cover the post-state-swap buffering window in the reverse-relay test
(TcpData arriving after openTcpStream returns but before target open).
- Refresh stale MeshService comments that still referenced the removed
sidecar layer and host-network listener model.
* test(mesh): pin removeOverrideFromNode remote HTTP shape
The disableForNode regression test mocks removeOverrideFromNode itself,
so a regression inside the helper would not be caught. Add a narrow
contract test that spies on global fetch and asserts the request shape:
DELETE /api/mesh/local-override/:stack against the resolved proxy
target, with Authorization Bearer plus the x-sencho-tier and
x-sencho-variant headers. Also covers the encodeURIComponent path and
the swallowed-network-error behavior the disable cascade relies on.
* test(mesh): use createTestApiToken helper in proxy-tunnel api_token gate test
The full-admin api_token branch of the WS Admiral gate test inlined the
canonical generateApiToken + sha256 + addApiToken triple that already
lives in the createTestApiToken helper. Switching to the helper removes
the duplicated insertion logic and aligns this test with the helper used
by the other api_token call sites.
1045 lines
44 KiB
TypeScript
1045 lines
44 KiB
TypeScript
import http, { IncomingMessage, Server as HttpServer, ServerResponse } from 'http';
|
|
import net, { Socket } from 'net';
|
|
import { EventEmitter } from 'events';
|
|
import { WebSocket, WebSocketServer } from 'ws';
|
|
import {
|
|
AGENT_REVERSE_ID_BASE,
|
|
BinaryFrameType,
|
|
DecodedBinaryFrame,
|
|
MAX_STREAMS_PER_TUNNEL,
|
|
STREAM_IDLE_TIMEOUT_MS,
|
|
STREAM_PENDING_DATA_MAX_BYTES,
|
|
StreamIdAllocator,
|
|
decodeBinaryFrame,
|
|
decodeJsonFrame,
|
|
encodeBinaryFrame,
|
|
encodeJsonFrame,
|
|
wsDataToBuffer,
|
|
wsDataToString,
|
|
} from '../pilot/protocol';
|
|
import { isDebugEnabled } from '../utils/debug';
|
|
import { sanitizeForLog } from '../utils/safeLog';
|
|
import { PilotMetrics } from './PilotMetrics';
|
|
|
|
const BUFFER_HIGH_WATER_MARK = 4 * 1024 * 1024;
|
|
const PING_INTERVAL_MS = 30_000;
|
|
/**
|
|
* Poll cadence for the backpressure drain check. The `ws` WebSocket does not
|
|
* surface a usable 'drain' event, so when at least one stream is paused we
|
|
* sample `bufferedAmount` at this rate and resume / fan out 'drain' as soon
|
|
* as the buffer drops below the high-water mark. Dormant when nothing is
|
|
* paused, so steady-state cost is zero.
|
|
*/
|
|
const DRAIN_CHECK_INTERVAL_MS = 100;
|
|
|
|
interface StreamMeta {
|
|
idleTimer?: NodeJS.Timeout;
|
|
}
|
|
|
|
interface HttpStreamState extends StreamMeta {
|
|
kind: 'http';
|
|
res: ServerResponse;
|
|
headersWritten: boolean;
|
|
}
|
|
|
|
interface WsStreamState extends StreamMeta {
|
|
kind: 'ws';
|
|
rawSocket?: Socket;
|
|
rawHead?: Buffer;
|
|
upgradeRequest: IncomingMessage;
|
|
clientWs?: WebSocket;
|
|
}
|
|
|
|
interface TcpStreamState extends StreamMeta {
|
|
kind: 'tcp';
|
|
handle: TcpStream;
|
|
bytesIn: number;
|
|
bytesOut: number;
|
|
openedAt: number;
|
|
accepted: boolean;
|
|
}
|
|
|
|
/**
|
|
* Pilot-initiated reverse stream where the agent's mesh forwarder asked the
|
|
* primary to dial a service ON the primary's local Docker host. The primary
|
|
* holds the resulting `net.Socket` and pumps bytes between the socket and
|
|
* the tunnel `TcpData` frames keyed on the agent-allocated `s`.
|
|
*/
|
|
interface ReverseLocalTcpStreamState extends StreamMeta {
|
|
kind: 'reverse_local';
|
|
/**
|
|
* Null between the synchronous reservation in `handleTcpOpenReverse`
|
|
* and `net.createConnection` inside `acceptReverseLocal`. Filled in
|
|
* once the dial begins. Any `TcpData` for `s` arriving in that window
|
|
* is held in `pendingData` so the lookup-miss path in handleBinaryFrame
|
|
* does not silently drop the peer's request bytes.
|
|
*/
|
|
socket: Socket | null;
|
|
bytesIn: number;
|
|
bytesOut: number;
|
|
pendingData: Buffer[];
|
|
pendingBytes: number;
|
|
}
|
|
|
|
/**
|
|
* Pilot-initiated reverse stream where the target is *another* pilot. The
|
|
* primary acts as a transparent relay: it allocates a forward `TcpStream`
|
|
* on the target pilot's bridge and splices bytes between this bridge's
|
|
* incoming `TcpData` frames (keyed on the source agent's `s`) and the
|
|
* target stream's `write` / `'data'`.
|
|
*/
|
|
interface ReverseRelayTcpStreamState extends StreamMeta {
|
|
kind: 'reverse_relay';
|
|
target: TcpStream;
|
|
/**
|
|
* False between `acceptReverseRelay` swapping the reservation for this
|
|
* state and the target stream emitting `'open'`. `TcpData` frames
|
|
* arriving in that window are queued in `pendingData` rather than
|
|
* written into the target stream, whose own remote ack may not yet
|
|
* have landed.
|
|
*/
|
|
targetOpen: boolean;
|
|
pendingData: Buffer[];
|
|
pendingBytes: number;
|
|
bytesIn: number;
|
|
bytesOut: number;
|
|
}
|
|
|
|
type StreamState = HttpStreamState | WsStreamState | TcpStreamState | ReverseLocalTcpStreamState | ReverseRelayTcpStreamState;
|
|
|
|
/**
|
|
* Sencho Mesh TCP stream handle. EventEmitter-based duplex-like surface that
|
|
* MeshService consumes to bridge a local socket to a Compose service on the
|
|
* remote node behind this pilot tunnel.
|
|
*
|
|
* Events:
|
|
* 'open' tcp_open_ack ok received; safe to write/read
|
|
* 'data' (Buffer) bytes from the remote socket
|
|
* 'drain' send buffer below high-water mark
|
|
* 'error' (err) open rejected or mid-stream tunnel error
|
|
* 'close' stream closed (graceful or otherwise)
|
|
*/
|
|
export class TcpStream extends EventEmitter {
|
|
public readonly streamId: number;
|
|
private readonly bridge: PilotTunnelBridge;
|
|
|
|
constructor(streamId: number, bridge: PilotTunnelBridge) {
|
|
super();
|
|
this.streamId = streamId;
|
|
this.bridge = bridge;
|
|
}
|
|
|
|
/**
|
|
* Returns false when the underlying tunnel buffer is above the high-water
|
|
* mark; caller should pause its source until 'drain' fires.
|
|
*/
|
|
public write(chunk: Buffer): boolean {
|
|
return this.bridge._writeTcpData(this.streamId, chunk);
|
|
}
|
|
|
|
public end(): void {
|
|
this.bridge._closeTcpStream(this.streamId);
|
|
}
|
|
|
|
public destroy(): void {
|
|
this.end();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Narrow surface that Sencho Mesh consumes from a registered pilot tunnel.
|
|
* `PilotTunnelManager.getBridge` returns this interface (not the concrete
|
|
* bridge) so MeshService cannot reach into transport internals: close code,
|
|
* loopback URL, the per-stream maps, the underscored helpers, etc.
|
|
* Keep this set minimal: it is the contract a future alternative transport
|
|
* (a stub for tests, a different routing strategy) would have to implement.
|
|
*/
|
|
export interface MeshTunnelHandle {
|
|
openTcpStream(target: { stack: string; service: string; port: number }): TcpStream | null;
|
|
getBufferedAmount(): number;
|
|
}
|
|
|
|
/**
|
|
* Per-tunnel bridge: hosts a loopback HTTP server that demuxes requests into
|
|
* wire frames sent over the pilot WebSocket, and remuxes response frames back
|
|
* to the loopback caller.
|
|
*
|
|
* The primary's existing http-proxy-middleware setup treats the loopback URL
|
|
* as just another remote target, so HTTP and WebSocket proxy logic, header
|
|
* stripping/injection, and license-tier propagation all work unchanged.
|
|
*/
|
|
export class PilotTunnelBridge extends EventEmitter implements MeshTunnelHandle {
|
|
private readonly nodeId: number;
|
|
private readonly tunnelWs: WebSocket;
|
|
private readonly loopback: HttpServer;
|
|
private readonly wsUpgradeServer: WebSocketServer;
|
|
private readonly streamIds = new StreamIdAllocator();
|
|
private readonly streams = new Map<number, StreamState>();
|
|
private readonly connectedAt = Date.now();
|
|
private readonly pausedReqs = new Map<number, IncomingMessage>();
|
|
private readonly tcpAwaitingDrain = new Set<number>();
|
|
private loopbackUrl = '';
|
|
private pingTimer?: NodeJS.Timeout;
|
|
private drainTimer?: NodeJS.Timeout;
|
|
private closed = false;
|
|
|
|
constructor(nodeId: number, tunnelWs: WebSocket) {
|
|
super();
|
|
this.nodeId = nodeId;
|
|
this.tunnelWs = tunnelWs;
|
|
this.loopback = http.createServer();
|
|
this.wsUpgradeServer = new WebSocketServer({ noServer: true });
|
|
|
|
this.loopback.on('request', (req, res) => this.handleLoopbackRequest(req, res));
|
|
this.loopback.on('upgrade', (req, socket, head) => this.handleLoopbackUpgrade(req, socket as Socket, head));
|
|
this.loopback.on('clientError', (_err, socket) => {
|
|
try { socket.destroy(); } catch { /* ignore */ }
|
|
});
|
|
|
|
this.tunnelWs.on('message', (data, isBinary) => this.handleTunnelMessage(data, isBinary));
|
|
this.tunnelWs.on('close', () => this.onTunnelClose());
|
|
this.tunnelWs.on('error', () => this.onTunnelClose());
|
|
}
|
|
|
|
public async start(): Promise<void> {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const onError = (err: Error) => reject(err);
|
|
this.loopback.once('error', onError);
|
|
this.loopback.listen(0, '127.0.0.1', () => {
|
|
const addr = this.loopback.address();
|
|
if (!addr || typeof addr === 'string') {
|
|
reject(new Error('loopback server returned unexpected address'));
|
|
return;
|
|
}
|
|
this.loopbackUrl = `http://127.0.0.1:${addr.port}`;
|
|
this.loopback.removeListener('error', onError);
|
|
resolve();
|
|
});
|
|
});
|
|
this.pingTimer = setInterval(() => {
|
|
if (this.tunnelWs.readyState !== WebSocket.OPEN) return;
|
|
try { this.tunnelWs.ping(); } catch { /* surfaced via 'error' */ }
|
|
}, PING_INTERVAL_MS);
|
|
}
|
|
|
|
public getLoopbackUrl(): string { return this.loopbackUrl; }
|
|
public getConnectedAt(): number { return this.connectedAt; }
|
|
public getBufferedAmount(): number { return this.tunnelWs.bufferedAmount; }
|
|
/** Total active stream count across HTTP, WebSocket, forward TCP, and reverse TCP. Used by the proxy-tunnel dialer to detect idle bridges eligible for teardown. */
|
|
public getActiveStreamCount(): number { return this.streams.size; }
|
|
public isOpen(): boolean { return !this.closed && this.tunnelWs.readyState === WebSocket.OPEN; }
|
|
|
|
/**
|
|
* Open a TCP stream to a Compose service on the remote node. Caller listens
|
|
* on the returned TcpStream for 'open' (when the remote agent has accepted),
|
|
* 'data', 'error', and 'close'. Returns null if the tunnel is not open.
|
|
*/
|
|
public openTcpStream(target: { stack: string; service: string; port: number }): TcpStream | null {
|
|
if (!this.isOpen()) return null;
|
|
if (this.streams.size >= MAX_STREAMS_PER_TUNNEL) return null;
|
|
const streamId = this.streamIds.allocate();
|
|
const handle = new TcpStream(streamId, this);
|
|
const state: TcpStreamState = {
|
|
kind: 'tcp',
|
|
handle,
|
|
bytesIn: 0,
|
|
bytesOut: 0,
|
|
openedAt: Date.now(),
|
|
accepted: false,
|
|
};
|
|
this.streams.set(streamId, state);
|
|
this.refreshIdleTimer(streamId, state);
|
|
this.sendJson({
|
|
t: 'tcp_open',
|
|
s: streamId,
|
|
stack: target.stack,
|
|
service: target.service,
|
|
port: target.port,
|
|
});
|
|
return handle;
|
|
}
|
|
|
|
/**
|
|
* @internal Called only by TcpStream.write; applies the same 4 MB
|
|
* backpressure rule used by HTTP request bodies. Public for cross-class
|
|
* access only; not part of the bridge's outward API.
|
|
*/
|
|
public _writeTcpData(streamId: number, payload: Buffer): boolean {
|
|
const s = this.streams.get(streamId);
|
|
if (!s || s.kind !== 'tcp') return false;
|
|
if (!this.isOpen()) return false;
|
|
this.sendBinary(BinaryFrameType.TcpData, streamId, payload);
|
|
s.bytesOut += payload.length;
|
|
this.refreshIdleTimer(streamId, s);
|
|
const ok = this.tunnelWs.bufferedAmount <= BUFFER_HIGH_WATER_MARK;
|
|
if (!ok) {
|
|
// Backpressure: caller will pause until 'drain'. Track this TCP
|
|
// stream so checkDrain knows to fire 'drain' on it (and so the
|
|
// drain timer keeps running for TCP-only backpressure scenarios).
|
|
this.tcpAwaitingDrain.add(streamId);
|
|
this.ensureDrainTimer();
|
|
}
|
|
return ok;
|
|
}
|
|
|
|
/** @internal Called only by TcpStream.end / .destroy. */
|
|
public _closeTcpStream(streamId: number): void {
|
|
if (!this.streams.has(streamId)) return;
|
|
this.removeStream(streamId);
|
|
this.sendJson({ t: 'tcp_close', s: streamId });
|
|
}
|
|
|
|
public close(code = 1000, reason = 'closed by primary'): void {
|
|
if (this.closed) return;
|
|
this.closed = true;
|
|
if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = undefined; }
|
|
this.stopDrainTimer();
|
|
// Resume any paused IncomingMessage so its end event can fire and
|
|
// its parser unwinds; the loopback close below will tear down the
|
|
// socket either way, but this avoids holding a dangling parser
|
|
// state across teardown.
|
|
for (const [, req] of this.pausedReqs) {
|
|
try { req.resume(); } catch { /* ignore */ }
|
|
}
|
|
this.pausedReqs.clear();
|
|
this.tcpAwaitingDrain.clear();
|
|
|
|
for (const [, state] of this.streams) {
|
|
this.clearIdleTimer(state);
|
|
this.teardownStream(state);
|
|
}
|
|
this.streams.clear();
|
|
|
|
try { this.tunnelWs.close(code, reason); } catch { /* ignore */ }
|
|
try { this.loopback.close(); } catch { /* ignore */ }
|
|
try { this.wsUpgradeServer.close(); } catch { /* ignore */ }
|
|
this.emit('closed');
|
|
}
|
|
|
|
// --- Loopback HTTP ingress ---
|
|
|
|
private handleLoopbackRequest(req: IncomingMessage, res: ServerResponse): void {
|
|
if (this.closed || this.tunnelWs.readyState !== WebSocket.OPEN) {
|
|
res.statusCode = 502;
|
|
res.end('pilot tunnel not ready');
|
|
return;
|
|
}
|
|
if (this.streams.size >= MAX_STREAMS_PER_TUNNEL) {
|
|
res.statusCode = 503;
|
|
res.setHeader('content-type', 'text/plain');
|
|
res.end('pilot tunnel: stream cap reached');
|
|
return;
|
|
}
|
|
|
|
const streamId = this.streamIds.allocate();
|
|
const state: HttpStreamState = { kind: 'http', res, headersWritten: false };
|
|
this.streams.set(streamId, state);
|
|
this.refreshIdleTimer(streamId, state);
|
|
|
|
const headers: Record<string, string> = {};
|
|
for (const [k, v] of Object.entries(req.headers)) {
|
|
if (typeof v === 'string') headers[k] = v;
|
|
else if (Array.isArray(v)) headers[k] = v.join(', ');
|
|
}
|
|
|
|
this.sendJson({
|
|
t: 'http_req',
|
|
s: streamId,
|
|
method: req.method || 'GET',
|
|
path: req.url || '/',
|
|
headers,
|
|
});
|
|
|
|
req.on('data', (chunk: Buffer) => {
|
|
const s = this.streams.get(streamId);
|
|
if (!s) return;
|
|
this.sendBinary(BinaryFrameType.HttpReqBody, streamId, chunk);
|
|
this.refreshIdleTimer(streamId, s);
|
|
if (this.tunnelWs.bufferedAmount > BUFFER_HIGH_WATER_MARK) {
|
|
this.pauseRequest(streamId, req);
|
|
}
|
|
});
|
|
req.on('end', () => {
|
|
if (!this.streams.has(streamId)) return;
|
|
this.sendJson({ t: 'http_req_end', s: streamId });
|
|
});
|
|
req.on('error', () => {
|
|
const s = this.streams.get(streamId);
|
|
if (s) this.teardownStream(s);
|
|
this.removeStream(streamId);
|
|
});
|
|
|
|
res.on('close', () => {
|
|
// Client disconnected before response finished.
|
|
if (this.streams.has(streamId)) {
|
|
this.removeStream(streamId);
|
|
this.sendJson({ t: 'http_err', s: streamId, code: 'tunnel_down', message: 'client aborted' });
|
|
}
|
|
});
|
|
}
|
|
|
|
private handleLoopbackUpgrade(req: IncomingMessage, socket: Socket, head: Buffer): void {
|
|
if (this.closed || this.tunnelWs.readyState !== WebSocket.OPEN) {
|
|
socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n');
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
if (this.streams.size >= MAX_STREAMS_PER_TUNNEL) {
|
|
socket.write('HTTP/1.1 503 Service Unavailable\r\n\r\n');
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
|
|
const streamId = this.streamIds.allocate();
|
|
const state: WsStreamState = {
|
|
kind: 'ws',
|
|
rawSocket: socket,
|
|
rawHead: head,
|
|
upgradeRequest: req,
|
|
};
|
|
this.streams.set(streamId, state);
|
|
this.refreshIdleTimer(streamId, state);
|
|
|
|
const headers: Record<string, string> = {};
|
|
for (const [k, v] of Object.entries(req.headers)) {
|
|
if (typeof v === 'string') headers[k] = v;
|
|
else if (Array.isArray(v)) headers[k] = v.join(', ');
|
|
}
|
|
|
|
this.sendJson({
|
|
t: 'ws_open',
|
|
s: streamId,
|
|
path: req.url || '/',
|
|
headers,
|
|
});
|
|
|
|
socket.on('error', () => {
|
|
const s = this.streams.get(streamId);
|
|
if (s) this.teardownStream(s);
|
|
this.removeStream(streamId);
|
|
});
|
|
socket.on('close', () => {
|
|
if (this.streams.has(streamId)) {
|
|
this.sendJson({ t: 'ws_close', s: streamId, code: 1006, reason: 'client closed' });
|
|
this.removeStream(streamId);
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- Tunnel ingress (frames from agent) ---
|
|
|
|
private handleTunnelMessage(data: unknown, isBinary: boolean): void {
|
|
if (this.closed) return;
|
|
try {
|
|
if (isBinary) {
|
|
const buf = wsDataToBuffer(data);
|
|
if (!buf) return;
|
|
this.handleBinaryFrame(decodeBinaryFrame(buf));
|
|
} else {
|
|
const text = wsDataToString(data);
|
|
if (text == null) return;
|
|
const frame = decodeJsonFrame(text);
|
|
this.handleJsonFrame(frame);
|
|
}
|
|
} catch (err) {
|
|
// Malformed frame: kill the tunnel to force re-sync. Diag-gated
|
|
// so a flood of malformed frames cannot drown the log; the
|
|
// tunnel close itself is the loud signal.
|
|
PilotMetrics.increment('frame_decode_errors');
|
|
if (isDebugEnabled()) {
|
|
console.warn('[PilotBridge:diag] Malformed frame from agent:', sanitizeForLog((err as Error).message));
|
|
}
|
|
this.close(1002, 'protocol error');
|
|
}
|
|
}
|
|
|
|
private handleJsonFrame(frame: ReturnType<typeof decodeJsonFrame>): void {
|
|
switch (frame.t) {
|
|
case 'http_res': {
|
|
const s = this.streams.get(frame.s);
|
|
if (!s || s.kind !== 'http') return;
|
|
if (!s.headersWritten) {
|
|
try {
|
|
s.res.writeHead(frame.status, frame.headers);
|
|
} catch { /* headers already sent or invalid */ }
|
|
s.headersWritten = true;
|
|
}
|
|
this.refreshIdleTimer(frame.s, s);
|
|
break;
|
|
}
|
|
case 'http_res_end': {
|
|
const s = this.streams.get(frame.s);
|
|
if (!s || s.kind !== 'http') return;
|
|
try { s.res.end(); } catch { /* ignore */ }
|
|
this.removeStream(frame.s);
|
|
break;
|
|
}
|
|
case 'http_err': {
|
|
const s = this.streams.get(frame.s);
|
|
if (!s) return;
|
|
if (s.kind === 'http' && !s.headersWritten) {
|
|
try {
|
|
s.res.writeHead(502, { 'content-type': 'text/plain' });
|
|
s.res.end(`pilot tunnel error: ${frame.code} ${frame.message}`);
|
|
} catch { /* ignore */ }
|
|
} else {
|
|
this.teardownStream(s);
|
|
}
|
|
this.removeStream(frame.s);
|
|
break;
|
|
}
|
|
case 'ws_accept': {
|
|
const s = this.streams.get(frame.s);
|
|
if (!s || s.kind !== 'ws' || !s.rawSocket || !s.rawHead) return;
|
|
this.wsUpgradeServer.handleUpgrade(s.upgradeRequest, s.rawSocket, s.rawHead, (ws) => {
|
|
s.clientWs = ws;
|
|
s.rawSocket = undefined;
|
|
s.rawHead = undefined;
|
|
this.refreshIdleTimer(frame.s, s);
|
|
ws.on('message', (msg, isBin) => {
|
|
const cur = this.streams.get(frame.s);
|
|
if (cur) this.refreshIdleTimer(frame.s, cur);
|
|
if (isBin) {
|
|
this.sendBinary(BinaryFrameType.WsMessageBinary, frame.s, wsDataToBuffer(msg) ?? Buffer.alloc(0));
|
|
} else {
|
|
this.sendJson({ t: 'ws_msg_text', s: frame.s, data: wsDataToString(msg) ?? '' });
|
|
}
|
|
});
|
|
ws.on('close', (code, reason) => {
|
|
if (this.streams.has(frame.s)) {
|
|
this.sendJson({ t: 'ws_close', s: frame.s, code, reason: reason?.toString?.() });
|
|
this.removeStream(frame.s);
|
|
}
|
|
});
|
|
ws.on('error', () => {
|
|
if (this.streams.has(frame.s)) this.removeStream(frame.s);
|
|
});
|
|
});
|
|
break;
|
|
}
|
|
case 'ws_reject': {
|
|
const s = this.streams.get(frame.s);
|
|
if (!s || s.kind !== 'ws' || !s.rawSocket) return;
|
|
try {
|
|
s.rawSocket.write(`HTTP/1.1 ${frame.status} ${frame.message}\r\n\r\n`);
|
|
s.rawSocket.destroy();
|
|
} catch { /* ignore */ }
|
|
this.removeStream(frame.s);
|
|
break;
|
|
}
|
|
case 'ws_msg_text': {
|
|
const s = this.streams.get(frame.s);
|
|
if (!s || s.kind !== 'ws' || !s.clientWs) return;
|
|
try { s.clientWs.send(frame.data); } catch { /* ignore */ }
|
|
this.refreshIdleTimer(frame.s, s);
|
|
break;
|
|
}
|
|
case 'ws_close': {
|
|
const s = this.streams.get(frame.s);
|
|
if (!s || s.kind !== 'ws') return;
|
|
if (s.clientWs) {
|
|
try { s.clientWs.close(frame.code, frame.reason); } catch { /* ignore */ }
|
|
} else if (s.rawSocket) {
|
|
try { s.rawSocket.destroy(); } catch { /* ignore */ }
|
|
}
|
|
this.removeStream(frame.s);
|
|
break;
|
|
}
|
|
case 'ctrl': {
|
|
// Primary-side bridge does not act on control ops today; the
|
|
// upgrade handler consumes enroll_ack before registerTunnel is
|
|
// called, and ping/pong are handled by the WS layer.
|
|
break;
|
|
}
|
|
case 'tcp_open_ack': {
|
|
const s = this.streams.get(frame.s);
|
|
if (!s || s.kind !== 'tcp') return;
|
|
if (frame.ok) {
|
|
s.accepted = true;
|
|
this.refreshIdleTimer(frame.s, s);
|
|
s.handle.emit('open');
|
|
} else {
|
|
this.removeStream(frame.s);
|
|
s.handle.emit('error', new Error(frame.err ?? 'tcp_open rejected'));
|
|
s.handle.emit('close');
|
|
}
|
|
break;
|
|
}
|
|
case 'tcp_open_reverse': {
|
|
this.handleTcpOpenReverse(frame);
|
|
break;
|
|
}
|
|
case 'tcp_close': {
|
|
const s = this.streams.get(frame.s);
|
|
if (!s) return;
|
|
if (s.kind === 'tcp') {
|
|
this.removeStream(frame.s);
|
|
s.handle.emit('close');
|
|
} else if (s.kind === 'reverse_local') {
|
|
this.removeStream(frame.s);
|
|
// socket may be null if the peer sends tcp_close while
|
|
// resolveContainerIp is still in flight; the in-flight
|
|
// acceptReverseLocal sees the missing reservation and
|
|
// aborts the dial.
|
|
if (s.socket) {
|
|
try { s.socket.destroy(); } catch { /* ignore */ }
|
|
}
|
|
} else if (s.kind === 'reverse_relay') {
|
|
this.removeStream(frame.s);
|
|
try { s.target.destroy(); } catch { /* ignore */ }
|
|
}
|
|
break;
|
|
}
|
|
default:
|
|
// Ignore unknown JSON frame types for forward compatibility.
|
|
break;
|
|
}
|
|
}
|
|
|
|
private handleBinaryFrame(frame: DecodedBinaryFrame): void {
|
|
const s = this.streams.get(frame.streamId);
|
|
if (!s) return;
|
|
switch (frame.type) {
|
|
case BinaryFrameType.HttpResBody: {
|
|
if (s.kind !== 'http') return;
|
|
if (!s.headersWritten) {
|
|
// Agent sent body before headers; synthesize 200 so we don't drop data.
|
|
try { s.res.writeHead(200); } catch { /* ignore */ }
|
|
s.headersWritten = true;
|
|
}
|
|
try { s.res.write(frame.payload); } catch { /* ignore */ }
|
|
this.refreshIdleTimer(frame.streamId, s);
|
|
break;
|
|
}
|
|
case BinaryFrameType.WsMessageBinary: {
|
|
if (s.kind !== 'ws' || !s.clientWs) return;
|
|
try { s.clientWs.send(frame.payload, { binary: true }); } catch { /* ignore */ }
|
|
this.refreshIdleTimer(frame.streamId, s);
|
|
break;
|
|
}
|
|
case BinaryFrameType.HttpReqBody:
|
|
// Agent never originates request bodies; ignore for defense-in-depth.
|
|
break;
|
|
case BinaryFrameType.TcpData: {
|
|
if (s.kind === 'tcp') {
|
|
s.bytesIn += frame.payload.length;
|
|
this.refreshIdleTimer(frame.streamId, s);
|
|
s.handle.emit('data', frame.payload);
|
|
} else if (s.kind === 'reverse_local') {
|
|
s.bytesIn += frame.payload.length;
|
|
this.refreshIdleTimer(frame.streamId, s);
|
|
if (s.socket) {
|
|
try { s.socket.write(frame.payload); } catch { /* ignore */ }
|
|
} else {
|
|
// Reservation exists but the local socket is not
|
|
// created yet (still inside resolveContainerIp /
|
|
// pre-connect). Buffer up to the cap; over the cap,
|
|
// drop the stream and tell the peer to tear down.
|
|
// Copy the payload because decodeBinaryFrame returns
|
|
// a subarray view of the WS frame; holding the view
|
|
// would pin the parent buffer past its lifecycle.
|
|
if (s.pendingBytes + frame.payload.length > STREAM_PENDING_DATA_MAX_BYTES) {
|
|
this.removeStream(frame.streamId);
|
|
this.sendJson({ t: 'tcp_close', s: frame.streamId });
|
|
break;
|
|
}
|
|
s.pendingData.push(Buffer.from(frame.payload));
|
|
s.pendingBytes += frame.payload.length;
|
|
}
|
|
} else if (s.kind === 'reverse_relay') {
|
|
s.bytesIn += frame.payload.length;
|
|
this.refreshIdleTimer(frame.streamId, s);
|
|
if (s.targetOpen) {
|
|
s.target.write(frame.payload);
|
|
} else {
|
|
// Mirror the reverse_local pending-data path: between
|
|
// the state swap in acceptReverseRelay and the target
|
|
// stream's 'open' event, buffer up to the cap. Without
|
|
// this, a peer that sends body bytes immediately after
|
|
// tcp_open_reverse would drop them into a stream that
|
|
// is still waiting for its remote ack.
|
|
if (s.pendingBytes + frame.payload.length > STREAM_PENDING_DATA_MAX_BYTES) {
|
|
this.removeStream(frame.streamId);
|
|
this.sendJson({ t: 'tcp_close', s: frame.streamId });
|
|
break;
|
|
}
|
|
s.pendingData.push(Buffer.from(frame.payload));
|
|
s.pendingBytes += frame.payload.length;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
private onTunnelClose(): void {
|
|
if (this.closed) return;
|
|
this.close(1006, 'tunnel closed');
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
private pauseRequest(streamId: number, req: IncomingMessage): void {
|
|
if (this.pausedReqs.has(streamId)) return;
|
|
try { req.pause(); } catch { /* ignore */ }
|
|
this.pausedReqs.set(streamId, req);
|
|
this.ensureDrainTimer();
|
|
}
|
|
|
|
private ensureDrainTimer(): void {
|
|
if (this.drainTimer || this.closed) return;
|
|
this.drainTimer = setInterval(() => this.checkDrain(), DRAIN_CHECK_INTERVAL_MS);
|
|
}
|
|
|
|
private checkDrain(): void {
|
|
if (this.closed) {
|
|
this.stopDrainTimer();
|
|
return;
|
|
}
|
|
if (this.tunnelWs.bufferedAmount > BUFFER_HIGH_WATER_MARK) return;
|
|
for (const [, req] of this.pausedReqs) {
|
|
try { req.resume(); } catch { /* ignore */ }
|
|
}
|
|
this.pausedReqs.clear();
|
|
// Fire 'drain' only on TCP streams that signaled backpressure; do not
|
|
// wake every accepted TCP stream because that violates the documented
|
|
// event contract on TcpStream.
|
|
for (const streamId of this.tcpAwaitingDrain) {
|
|
const s = this.streams.get(streamId);
|
|
if (s && s.kind === 'tcp' && s.accepted) s.handle.emit('drain');
|
|
}
|
|
this.tcpAwaitingDrain.clear();
|
|
this.stopDrainTimer();
|
|
}
|
|
|
|
private stopDrainTimer(): void {
|
|
if (this.drainTimer) {
|
|
clearInterval(this.drainTimer);
|
|
this.drainTimer = undefined;
|
|
}
|
|
}
|
|
|
|
private refreshIdleTimer(streamId: number, state: StreamState): void {
|
|
if (state.idleTimer) clearTimeout(state.idleTimer);
|
|
state.idleTimer = setTimeout(() => this.onStreamIdle(streamId), STREAM_IDLE_TIMEOUT_MS);
|
|
}
|
|
|
|
private clearIdleTimer(state: StreamState): void {
|
|
if (state.idleTimer) {
|
|
clearTimeout(state.idleTimer);
|
|
state.idleTimer = undefined;
|
|
}
|
|
}
|
|
|
|
private removeStream(streamId: number): void {
|
|
const s = this.streams.get(streamId);
|
|
if (!s) return;
|
|
this.clearIdleTimer(s);
|
|
this.streams.delete(streamId);
|
|
this.pausedReqs.delete(streamId);
|
|
this.tcpAwaitingDrain.delete(streamId);
|
|
}
|
|
|
|
private onStreamIdle(streamId: number): void {
|
|
const s = this.streams.get(streamId);
|
|
if (!s) return;
|
|
// Tear down the loopback side and tell the agent to release its half.
|
|
this.teardownStream(s);
|
|
this.streams.delete(streamId);
|
|
this.pausedReqs.delete(streamId);
|
|
this.tcpAwaitingDrain.delete(streamId);
|
|
if (s.kind === 'tcp') {
|
|
this.sendJson({ t: 'tcp_close', s: streamId });
|
|
} else if (s.kind === 'ws') {
|
|
this.sendJson({ t: 'ws_close', s: streamId, code: 1001, reason: 'idle' });
|
|
} else {
|
|
this.sendJson({ t: 'http_err', s: streamId, code: 'timeout', message: 'stream idle timeout' });
|
|
}
|
|
}
|
|
|
|
private sendJson(frame: Parameters<typeof encodeJsonFrame>[0]): void {
|
|
if (this.tunnelWs.readyState !== WebSocket.OPEN) return;
|
|
try { this.tunnelWs.send(encodeJsonFrame(frame)); } catch { /* ignore */ }
|
|
}
|
|
|
|
private sendBinary(type: BinaryFrameType, streamId: number, payload: Buffer): void {
|
|
if (this.tunnelWs.readyState !== WebSocket.OPEN) return;
|
|
try { this.tunnelWs.send(encodeBinaryFrame(type, streamId, payload), { binary: true }); } catch { /* ignore */ }
|
|
}
|
|
|
|
private teardownStream(state: StreamState): void {
|
|
if (state.kind === 'http') {
|
|
try {
|
|
if (!state.headersWritten) {
|
|
state.res.writeHead(502, { 'content-type': 'text/plain' });
|
|
state.res.end('pilot tunnel closed');
|
|
} else {
|
|
state.res.end();
|
|
}
|
|
} catch { /* ignore */ }
|
|
} else if (state.kind === 'ws') {
|
|
if (state.clientWs) {
|
|
try { state.clientWs.close(1011, 'tunnel closed'); } catch { /* ignore */ }
|
|
} else if (state.rawSocket) {
|
|
try { state.rawSocket.destroy(); } catch { /* ignore */ }
|
|
}
|
|
} else if (state.kind === 'tcp') {
|
|
try {
|
|
if (!state.accepted) state.handle.emit('error', new Error('tunnel closed before accept'));
|
|
state.handle.emit('close');
|
|
} catch { /* ignore */ }
|
|
} else if (state.kind === 'reverse_local') {
|
|
// socket may be null if teardown fires while the resolve is
|
|
// still in flight; the pending buffer goes away with the state.
|
|
if (state.socket) {
|
|
try { state.socket.destroy(); } catch { /* ignore */ }
|
|
}
|
|
} else if (state.kind === 'reverse_relay') {
|
|
try { state.target.destroy(); } catch { /* ignore */ }
|
|
}
|
|
}
|
|
|
|
// ---- Phase B: pilot-initiated reverse mesh streams ----
|
|
|
|
/**
|
|
* Handle an inbound `tcp_open_reverse` from the agent. The agent's
|
|
* `MeshForwarder` accepted a connection destined for a node other than
|
|
* the agent's own and is asking the primary to dial the target. Two
|
|
* cases:
|
|
*
|
|
* - target is the primary's own node: dial a local container directly
|
|
* and splice bytes between the resulting socket and the tunnel.
|
|
* - target is another pilot: open a forward `TcpStream` on the target
|
|
* pilot's bridge and relay bytes between the two tunnels (primary
|
|
* in the middle).
|
|
*
|
|
* Stream id is allocated by the agent; the primary stores state keyed
|
|
* on the agent's id. Agent ids are required to be in the upper half of
|
|
* the 32-bit space so they cannot collide with primary-allocated ids
|
|
* for forward `tcp_open` streams on the same tunnel.
|
|
*/
|
|
private handleTcpOpenReverse(frame: { s: number; targetNodeId: number; stack: string; service: string; port: number }): void {
|
|
const { s, targetNodeId, stack, service, port } = frame;
|
|
if (s < AGENT_REVERSE_ID_BASE) {
|
|
this.sendJson({ t: 'tcp_open_ack', s, ok: false, err: 'agent_error' });
|
|
return;
|
|
}
|
|
if (this.streams.has(s) || this.streams.size >= MAX_STREAMS_PER_TUNNEL) {
|
|
this.sendJson({ t: 'tcp_open_ack', s, ok: false, err: 'agent_error' });
|
|
return;
|
|
}
|
|
// Reserve the slot synchronously here, BEFORE the IIFE awaits the
|
|
// NodeRegistry / MeshService dynamic imports plus resolveContainerIp.
|
|
// Without this, a TcpData frame the agent sends back-to-back with
|
|
// tcp_open_reverse lands while this.streams is empty for `s` and is
|
|
// dropped by the lookup-miss path in handleBinaryFrame. The
|
|
// reservation is reverse_local-shaped because that is the common
|
|
// case; the relay path discards it and sets up its own state when
|
|
// it discovers targetNodeId is remote.
|
|
const reservation: ReverseLocalTcpStreamState = {
|
|
kind: 'reverse_local', socket: null,
|
|
bytesIn: 0, bytesOut: 0, pendingData: [], pendingBytes: 0,
|
|
};
|
|
this.streams.set(s, reservation);
|
|
this.refreshIdleTimer(s, reservation);
|
|
// Lazy-import services that import this module to avoid a cycle.
|
|
void (async () => {
|
|
const { NodeRegistry } = await import('./NodeRegistry');
|
|
const localNodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
|
if (targetNodeId === localNodeId) {
|
|
await this.acceptReverseLocal(s, { stack, service, port });
|
|
} else {
|
|
// Leave the local-shaped reservation in place so any
|
|
// TcpData arriving while ensureBridge + openTcpStream
|
|
// resolve keeps buffering through the reverse_local
|
|
// branch in handleBinaryFrame. acceptReverseRelay
|
|
// transplants the buffered frames into the relay state
|
|
// and flushes them before the open ack.
|
|
await this.acceptReverseRelay(s, targetNodeId, { stack, service, port });
|
|
}
|
|
})().catch((err) => {
|
|
if (isDebugEnabled()) console.warn('[PilotBridge:diag] reverse-open dispatch failed:', sanitizeForLog((err as Error).message));
|
|
if (this.streams.get(s) === reservation) this.removeStream(s);
|
|
this.sendJson({ t: 'tcp_open_ack', s, ok: false, err: 'agent_error' });
|
|
});
|
|
}
|
|
|
|
private async acceptReverseLocal(s: number, target: { stack: string; service: string; port: number }): Promise<void> {
|
|
// The reservation was placed in `this.streams` synchronously by
|
|
// handleTcpOpenReverse so any TcpData arriving in the resolve
|
|
// window is already buffered in state.pendingData by
|
|
// handleBinaryFrame. Recover it here and abort if it's gone
|
|
// (cleanup ran, idle-evicted, or a concurrent close removed it).
|
|
const state = this.streams.get(s);
|
|
if (!state || state.kind !== 'reverse_local') return;
|
|
const { MeshService } = await import('./MeshService');
|
|
const meshSvc = MeshService.getInstance();
|
|
// Shared discriminator + target metadata so the Routing tab can
|
|
// separate peer-to-central (reverse) dispatch from central-to-peer
|
|
// (forward) dispatch when both flow through the same activity feed.
|
|
const baseDetails = {
|
|
direction: 'reverse' as const,
|
|
streamId: s,
|
|
targetStack: target.stack,
|
|
targetService: target.service,
|
|
targetPort: target.port,
|
|
peerNodeId: this.nodeId,
|
|
};
|
|
const ip = await meshSvc.resolveContainerIp({ stack: target.stack, service: target.service });
|
|
if (!ip) {
|
|
// Drop the reservation before acking the failure so the stream
|
|
// map stays honest and any over-cap-evicted pending frames do
|
|
// not strand on an empty entry.
|
|
if (this.streams.get(s) === state) this.removeStream(s);
|
|
meshSvc.logActivity({
|
|
source: 'mesh', level: 'error', type: 'route.resolve.fail',
|
|
nodeId: this.nodeId,
|
|
message: `reverse dial failed: container ${target.stack}/${target.service} not found`,
|
|
details: { ...baseDetails, reason: 'container_not_found' },
|
|
});
|
|
this.sendJson({ t: 'tcp_open_ack', s, ok: false, err: 'no_target' });
|
|
return;
|
|
}
|
|
// The reservation may have been evicted under the pending-bytes cap
|
|
// (see handleBinaryFrame's reverse_local branch) while we were
|
|
// resolving. If so, do not dial: the peer has already been told via
|
|
// tcp_close to tear down.
|
|
if (this.streams.get(s) !== state) return;
|
|
|
|
const socket = net.createConnection({ host: ip, port: target.port });
|
|
state.socket = socket;
|
|
|
|
const teardown = (sendClose: boolean) => {
|
|
if (!this.streams.has(s)) return;
|
|
this.removeStream(s);
|
|
try { socket.destroy(); } catch { /* ignore */ }
|
|
if (sendClose) this.sendJson({ t: 'tcp_close', s });
|
|
};
|
|
|
|
// Pre-connect failure: ack-fail and drop. The handler is removed in
|
|
// 'connect' below so post-connect errors fall through to the
|
|
// mid-stream teardown path instead of double-firing.
|
|
const onPreConnectError = (err?: Error) => {
|
|
if (!this.streams.has(s)) return;
|
|
this.streams.delete(s);
|
|
meshSvc.logActivity({
|
|
source: 'mesh', level: 'error', type: 'route.resolve.fail',
|
|
nodeId: this.nodeId,
|
|
message: `reverse dial failed pre-connect: ${err?.message ?? 'socket error'}`,
|
|
details: { ...baseDetails, reason: 'connect_error' },
|
|
});
|
|
this.sendJson({ t: 'tcp_open_ack', s, ok: false, err: 'unreachable' });
|
|
};
|
|
socket.once('error', onPreConnectError);
|
|
socket.once('connect', () => {
|
|
socket.off('error', onPreConnectError);
|
|
meshSvc.logActivity({
|
|
source: 'mesh', level: 'info', type: 'route.resolve.ok',
|
|
nodeId: this.nodeId,
|
|
message: `reverse dial ok: ${target.stack}/${target.service}:${target.port}`,
|
|
details: baseDetails,
|
|
});
|
|
// Ack only after buffered bytes are queued on the socket so
|
|
// peer sees a clean "ack + data" sequence rather than racing
|
|
// post-ack data ahead of the request body it carried in.
|
|
if (state.pendingData.length > 0) {
|
|
for (const buf of state.pendingData) {
|
|
try { socket.write(buf); } catch { /* ignore */ }
|
|
}
|
|
state.pendingData = [];
|
|
state.pendingBytes = 0;
|
|
}
|
|
this.sendJson({ t: 'tcp_open_ack', s, ok: true });
|
|
socket.on('data', (chunk: Buffer) => {
|
|
const cur = this.streams.get(s);
|
|
if (!cur || cur.kind !== 'reverse_local') return;
|
|
this.sendBinary(BinaryFrameType.TcpData, s, chunk);
|
|
cur.bytesOut += chunk.length;
|
|
this.refreshIdleTimer(s, cur);
|
|
});
|
|
socket.on('close', () => teardown(true));
|
|
socket.on('error', () => teardown(true));
|
|
});
|
|
}
|
|
|
|
private async acceptReverseRelay(s: number, targetNodeId: number, target: { stack: string; service: string; port: number }): Promise<void> {
|
|
// Pull the reverse_local-shaped reservation that handleTcpOpenReverse
|
|
// placed synchronously. Its pendingData buffer holds any TcpData
|
|
// frames that arrived while we were dispatching, which we transplant
|
|
// into the relay state so they can be flushed once the target stream
|
|
// opens.
|
|
const reservation = this.streams.get(s);
|
|
if (!reservation || reservation.kind !== 'reverse_local') return;
|
|
|
|
const { PilotTunnelManager } = await import('./PilotTunnelManager');
|
|
// ensureBridge resolves either an existing pilot tunnel or a fresh
|
|
// proxy-mode tunnel dialed on demand, so proxy <-> proxy relays
|
|
// succeed even when neither remote has a pilot agent.
|
|
const targetBridge = await PilotTunnelManager.getInstance().ensureBridge(targetNodeId);
|
|
if (!targetBridge) {
|
|
if (this.streams.get(s) === reservation) this.removeStream(s);
|
|
this.sendJson({ t: 'tcp_open_ack', s, ok: false, err: 'unreachable' });
|
|
return;
|
|
}
|
|
// Reservation may have been evicted under the pending-bytes cap
|
|
// while ensureBridge was resolving. If so, the peer has already
|
|
// been told via tcp_close to tear down; bail out cleanly.
|
|
if (this.streams.get(s) !== reservation) return;
|
|
|
|
const targetStream = targetBridge.openTcpStream(target);
|
|
if (!targetStream) {
|
|
if (this.streams.get(s) === reservation) this.removeStream(s);
|
|
this.sendJson({ t: 'tcp_open_ack', s, ok: false, err: 'unreachable' });
|
|
return;
|
|
}
|
|
const state: ReverseRelayTcpStreamState = {
|
|
kind: 'reverse_relay',
|
|
target: targetStream,
|
|
targetOpen: false,
|
|
pendingData: reservation.pendingData,
|
|
pendingBytes: reservation.pendingBytes,
|
|
bytesIn: reservation.bytesIn,
|
|
bytesOut: 0,
|
|
};
|
|
this.streams.set(s, state);
|
|
this.refreshIdleTimer(s, state);
|
|
|
|
targetStream.once('open', () => {
|
|
const cur = this.streams.get(s);
|
|
if (!cur || cur.kind !== 'reverse_relay') return;
|
|
// Flush buffered early data into the target stream BEFORE
|
|
// acking so the peer sees a clean "ack + data" sequence and
|
|
// the upstream receives the request body ahead of anything
|
|
// that arrives post-ack.
|
|
if (cur.pendingData.length > 0) {
|
|
for (const buf of cur.pendingData) {
|
|
try { cur.target.write(buf); } catch { /* ignore */ }
|
|
}
|
|
cur.pendingData = [];
|
|
cur.pendingBytes = 0;
|
|
}
|
|
cur.targetOpen = true;
|
|
this.sendJson({ t: 'tcp_open_ack', s, ok: true });
|
|
});
|
|
targetStream.on('data', (chunk: Buffer) => {
|
|
const cur = this.streams.get(s);
|
|
if (!cur || cur.kind !== 'reverse_relay') return;
|
|
this.sendBinary(BinaryFrameType.TcpData, s, chunk);
|
|
cur.bytesOut += chunk.length;
|
|
this.refreshIdleTimer(s, cur);
|
|
});
|
|
targetStream.once('error', () => {
|
|
if (!this.streams.has(s)) return;
|
|
this.streams.delete(s);
|
|
this.sendJson({ t: 'tcp_open_ack', s, ok: false, err: 'unreachable' });
|
|
});
|
|
targetStream.once('close', () => {
|
|
if (!this.streams.has(s)) return;
|
|
this.removeStream(s);
|
|
this.sendJson({ t: 'tcp_close', s });
|
|
});
|
|
}
|
|
}
|