feat(mesh): route mesh traffic over Distributed API remotes (#1048)

* refactor(mesh): extract shared TCP stream switchboard

Pull the `tcp_open` / `tcp_open_ack` / `tcp_open_reverse` / `tcp_close`
+ `TcpData` handling out of the pilot agent into a reusable module so a
second caller (the upcoming proxy-mode WS handler) can run the same
frame parser, stream allocator, idle timers, and Compose-label
resolver. One parser, two callers, zero drift on a
security-sensitive protocol surface.

The pilot agent keeps its public `openMeshTcpStream` API and delegates
to a per-connection switchboard constructed in `connect()` and torn
down in `cleanupAfterDisconnect`. Existing reverse-stream and resolver
tests are rewritten to exercise the shared module directly.

* feat(mesh): route mesh traffic over Distributed API remotes

Sencho Mesh now works against remotes in Distributed API mode in
addition to Pilot Agent mode. Central opens a short-lived WebSocket
tunnel to the remote's new `/api/mesh/proxy-tunnel` endpoint on demand
and tears it down after 5 minutes of idle (configurable via
`SENCHO_MESH_PROXY_TUNNEL_IDLE_MS`). Mesh dispatch is mode-agnostic at
the routing layer; `PilotTunnelManager.ensureBridge` resolves an
existing tunnel or asks the new `MeshProxyTunnelDialer` to dial.

The Routing tab badges now use a `reachableMode` classifier: `★ Local`
for local, `pilot offline` red badge for a pilot with a down tunnel,
and a new `unreachable` red badge surfacing the specific reason
(missing token, scope not full-admin, TLS failure, remote does not
support proxy mesh). Distributed API remotes with valid credentials
show no negative badge; the tunnel opens on first dial.

Auth: the proxy-tunnel WS upgrade requires an `Authorization: Bearer`
API token with the `full-admin` scope. Lower-scoped tokens are
rejected at upgrade time so a leaked read-only token cannot reach the
mesh data plane.

Bidirectional: the proxy-mode WS handler registers itself as the
local `MeshService` reverse dialer (compare-and-swap), so meshed
containers on a Distributed API remote can dial cross-node aliases
via `tcp_open_reverse` over the same tunnel. Cross-node relays
(`PilotTunnelBridge.acceptReverseRelay`) await `ensureBridge` so
proxy-to-proxy mesh works without standing tunnels.

* docs(mesh): describe Distributed API mesh and unreachable troubleshooting

Refresh the user-facing mesh documentation to reflect that mesh works
over both Pilot Agent and Distributed API remotes. Adds a
Troubleshooting accordion entry for the new `unreachable` Routing tab
badge so operators can match a tooltip reason ("api token rejected
(scope must be full-admin)", "remote does not support proxy mesh",
"TLS handshake failed", "api_url not set", "api token missing") to a
concrete fix.

* refactor(mesh): apply /simplify review findings

Five cleanups surfaced by the post-implementation code review pass.
No behaviour change for fleets running on `main`; only internal
structure improves.

- **Deterministic container IP shared.** Extract the compose-default
  network preference (`pickContainerIp`) and the conventional-name
  fast path (`lookupContainerIp`) into `backend/src/mesh/containerLookup.ts`.
  Both `MeshService.resolveContainerIp` (existing same-node fast
  path) and the switchboard's `resolveByComposeLabels` (proxy/pilot
  inbound dial) now use the same logic. Earlier the switchboard
  helper grabbed the first `Object.values(Networks)` IP, which
  could flip across daemon versions on multi-network containers;
  fixed.
- **WS URL upgrade extracted.** New `backend/src/utils/wsUrl.ts`
  exposes `httpUrlToWs(baseUrl)` that maps `http://` to `ws://` and
  `https://` to `wss://`. Used in both `pilot/agent.ts` and the
  proxy-tunnel dialer. The previous inline `replace(/^http/, 'ws')`
  silently downgraded `https://` to `ws://` (cleartext).
- **`computeReachable` takes the pre-fetched node.** `getStatus`
  already iterated `db.getNodes()`; the helper previously re-queried
  by id per node (N+1 reads). Pass the row in.
- **Reachable-reason ternary -> const map.** Replace the nested
  ternary in `MeshService.computeReachable` with a
  `Record<DialFailureCode, string>` lookup keyed on the dialer's
  failure code.
- **Activity-type ternary -> const map.** Same flattening inside
  `MeshProxyTunnelDialer.logActivity`.

All mesh tests pass (85/85). Full backend suite green (2126/2126).

* fix(mesh): cache proxy dial failures and contain WS handshake errors

`ensureBridge` now consults the recent-failure cache before dialing so a
continuous mesh workload against a misconfigured proxy-mode remote does
not produce one upgrade attempt per cross-node TCP open. `recordFailure`
emits at most one activity-log entry per cache window per (nodeId, code)
so a connect-loop on a single bad remote cannot flush the ring buffer.
Failure messages run through `redactSensitiveText` before reaching the
log so any embedded Bearer / JWT / inline-URL credentials are scrubbed.

`stop()` clears the inflight map and `dial()` checks `this.stopped`
between awaits so a shutdown does not leak a half-opened bridge.

When `awaitOpen` rejects (401, 4403, TLS), the dialer attaches a noop
'error' listener before calling `ws.close()`. Without it the ws library
emits a tail 'error' on a still-CONNECTING socket that propagates as an
unhandled exception. Surfaced by a live-network test against a real
proxy-mode peer.

Adds `mesh-proxy-tunnel-live.test.ts` (skipped unless MESH_AUDIT_URL
and MESH_AUDIT_TOKEN_FILE env vars are set) covering both the happy
path and the auth-rejected path against an actual remote Sencho.

* feat(mesh): instrument proxy tunnel observability

Adds three counters to `PilotMetrics`:
- `proxy_bridges_total` (incremented on `registerProxyBridge` success)
- `proxy_dials_failed` (every failed dial attempt, not deduped)
- `proxy_idle_closes` (idle-sweep teardowns)

All three surface automatically via `GET /api/system/pilot-tunnels`
since the route returns the full `Counters` snapshot.

Gates two pre-existing always-on `console.warn` calls in
`tcpStreamSwitchboard.ts` (mid-stream socket errors and Docker resolve
failures) behind `isDebugEnabled()`. Both fire on per-stream events and
would otherwise violate the diagnostic-log safety rule under load.

Adds `mesh-tcp-stream-switchboard.test.ts` covering the forward
`tcp_open` path: real localhost dial, resolver errors (no_target,
denied), per-tunnel cap saturation, frame-routing fall-through
invariants, `tcp_close` socket teardown.

Drops `MESH_CONNECT_TIMEOUT_MS` from the public surface; only the
switchboard itself uses it.

* fix(mesh): tighten Routing tab unreachable handling

`RoutingNodeCard.tsx`: the **Add stack to mesh** button now disables
when `reachableMode === 'unreachable'`, matching the existing
TogglePill behavior. Without this, an operator on an unreachable node
could open the opt-in sheet, confirm, and watch the redeploy proceed
against a target whose mesh data plane will silently fail to route.
Also drops the leftover `status.nodeId !== -1` guard on the pilot-
offline badge.

`MeshService.ts`: renames `isMeshReachable` to `isMeshConfigured` with
the new predicate that returns true for proxy-mode remotes whose creds
are valid (the tunnel is opened on demand). `getRouteDiagnostic` now
distinguishes "routable" (configured) from "pilotLive" (live tunnel
state, only meaningful for pilot mode); without the split, every idle
proxy-mode route would report `tunnel down`.

`MeshService.setReverseDialer` warns when the unconditional install
path silently overwrites a non-null current dialer. By topology a
Sencho is either pilot or central, so the branch flags a misconfigured
deployment rather than an expected race.

Drops the dead `export { ReverseTcpStreamHandle }` re-export from
`pilot/agent.ts`; its only consumer imports straight from
`mesh/tcpStreamSwitchboard.ts`. Fixes a stale doc comment in
`MeshService.ts` that referenced the wrong source file.

Adds `mesh-proxy-tunnel-handler.test.ts` covering the WS handler
lifecycle: pilot-mode 404 rejection, post-upgrade reverse-dialer
install, concurrent-upgrade 1013 rejection (single-tenant slot), and
error-path teardown.

Updates the troubleshooting accordion in the user docs to mention the
disabled-state behavior.

* fix(mesh): close ESLint and CodeQL findings on the proxy-tunnel diag logs

Remove the unused `reverseDialer` local in `meshProxyTunnel.ts`; the
closure target is `localDialer` above and this assignment was always
dead. Strip line breaks inline on the three `MeshProxyTunnelDialer`
diag log lines so CodeQL `js/log-injection` data flow recognises the
sanitisation that `sanitizeForLog` already performs.

No behaviour change; the diag logs render the same characters they
do today.
This commit is contained in:
Anso
2026-05-14 20:51:42 -04:00
committed by GitHub
parent 7c985238e1
commit a38a3e0226
24 changed files with 2225 additions and 561 deletions
+84
View File
@@ -0,0 +1,84 @@
/**
* Shared Compose-container lookup for mesh routing. Both
* `MeshService.resolveContainerIp` (same-node fast path) and the
* `TcpStreamSwitchboard` (inbound `tcp_open` handler) need to translate
* a stack/service pair to a single deterministic IPv4 address; their
* earlier implementations diverged on the network-preference rule,
* which caused flaky resolution on containers attached to multiple
* networks (compose `_default` plus `sencho_mesh`, for example).
*
* `pickContainerIp` is the canonical preference rule. `lookupContainerIp`
* runs the conventional-name fast path first (`<stack>-<service>-1`)
* before falling back to a compose-label container list.
*/
interface ContainerInspectInfo {
NetworkSettings?: {
Networks?: Record<string, { IPAddress?: string } | undefined>;
IPAddress?: string;
};
}
interface ContainerListItem {
Id: string;
}
interface DockerodeLike {
getContainer(id: string): { inspect(): Promise<ContainerInspectInfo> };
listContainers(opts: unknown): Promise<unknown[]>;
}
/**
* Pick a deterministic IPv4 for a container. Prefer the compose default
* network (`<stack>_default` or any network named `<stack>_*`), then any
* other attached network, then the legacy `NetworkSettings.IPAddress`.
* Without this preference order, `Object.values(Networks)` ordering on
* multi-network containers varies across daemon versions and can make
* same-node forwarding flaky on a redeploy.
*/
export function pickContainerIp(stackName: string, info: ContainerInspectInfo): string | null {
const networks = info.NetworkSettings?.Networks ?? {};
const composeDefault = networks[`${stackName}_default`];
if (composeDefault?.IPAddress) return composeDefault.IPAddress;
for (const [name, net] of Object.entries(networks)) {
if (name.startsWith(`${stackName}_`) && net?.IPAddress) return net.IPAddress;
}
for (const net of Object.values(networks)) {
if (net?.IPAddress) return net.IPAddress;
}
return info.NetworkSettings?.IPAddress || null;
}
/**
* Resolve a stack + service to a container IPv4. Tries the conventional
* compose name first, then falls back to a label-filtered list. Returns
* null when no container matches or the matching container has no IP on
* any attached network.
*
* Errors propagate. Callers that only need a single string-or-null
* surface should wrap with a try/catch (`MeshService.resolveContainerIp`
* does this); callers that need to distinguish "Docker errored" from
* "no container matched" use the thrown error to pick the right code.
*/
export async function lookupContainerIp(
docker: DockerodeLike,
stack: string,
service: string,
): Promise<string | null> {
const conventional = `${stack}-${service}-1`;
const fast = await docker.getContainer(conventional).inspect().catch(() => null);
const fastIp = fast ? pickContainerIp(stack, fast) : null;
if (fastIp) return fastIp;
const containers = (await docker.listContainers({
all: true,
filters: {
label: [
`com.docker.compose.project=${stack}`,
`com.docker.compose.service=${service}`,
],
},
})) as ContainerListItem[];
if (containers.length === 0) return null;
const info = await docker.getContainer(containers[0].Id).inspect().catch(() => null);
return info ? pickContainerIp(stack, info) : null;
}
+415
View File
@@ -0,0 +1,415 @@
import net from 'net';
import { EventEmitter } from 'events';
import WebSocket from 'ws';
import {
AGENT_REVERSE_ID_BASE,
BinaryFrameType,
DecodedBinaryFrame,
JsonFrame,
MAX_STREAMS_PER_TUNNEL,
MeshErrCode,
STREAM_IDLE_TIMEOUT_MS,
StreamIdAllocator,
encodeBinaryFrame,
encodeJsonFrame,
} from '../pilot/protocol';
import { lookupContainerIp } from './containerLookup';
import { sanitizeForLog } from '../utils/safeLog';
import { isDebugEnabled } from '../utils/debug';
/**
* Sencho Mesh TCP stream switchboard.
*
* Owns the "agent side" of the mesh frame protocol: accept `tcp_open`,
* resolve a target by Compose labels, dial a local container, and splice
* bytes via `TcpData` frames in both directions. Also owns the outbound
* `tcp_open_reverse` allocator so the local MeshForwarder can emit reverse
* streams when its meshed containers dial cross-node aliases.
*
* Single source of truth for the protocol's "agent" behavior. Two callers:
*
* - `backend/src/pilot/agent.ts` — pilot-mode WS client. Mesh ws is the
* long-lived pilot tunnel; the agent threads its HTTP and WebSocket
* stream count into `extraStreamCount` so the per-tunnel cap is shared.
*
* - `backend/src/websocket/meshProxyTunnel.ts` — proxy-mode WS server.
* Mesh ws is a short-lived central-initiated tunnel that only carries
* TCP frames; `extraStreamCount` defaults to 0.
*
* The switchboard does not touch authentication or lifecycle; callers
* authenticate the WS upgrade themselves and decide when to call
* `cleanup()` on disconnect. State held here (`tcpStreams`,
* `reverseTcpStreams`, `reverseStreamIds`, per-stream idle timers) is
* scoped to a single WS instance — recreate the switchboard on reconnect.
*/
const MESH_CONNECT_TIMEOUT_MS = 10_000;
export type MeshResolveResult =
| { ok: true; host: string; port: number }
| { ok: false; err: MeshErrCode };
export type ResolveTarget = (stack: string, service: string, port: number) => Promise<MeshResolveResult>;
export interface SwitchboardCtx {
/** Per-connection WebSocket. The switchboard sends frames on this and never reassigns it; recreate the switchboard on reconnect. */
ws: WebSocket;
/** Resolve `stack`+`service`+`port` to a TCP target. Implementations typically query Dockerode by Compose labels. */
resolveTarget: ResolveTarget;
/** Non-mesh streams sharing the per-tunnel cap (e.g., the pilot's HTTP + WS multiplex). 0 for pure-TCP tunnels. */
extraStreamCount?: () => number;
/** Diagnostic prefix for log lines emitted from this switchboard. */
logLabel?: string;
}
interface ForwardTcpStream {
socket: net.Socket;
accepted: boolean;
}
/**
* Handle returned by `openReverseStream` to MeshService. Mirrors the
* surface of `PilotTunnelBridge.TcpStream` (write/end/destroy +
* 'open'/'data'/'error'/'close' events) so MeshService.openCrossNode can
* splice bytes against it without caring whether the underlying tunnel is
* a pilot tunnel or a proxy-mode tunnel.
*/
export class ReverseTcpStreamHandle extends EventEmitter {
public readonly streamId: number;
private readonly sendData: (streamId: number, payload: Buffer) => void;
private readonly sendClose: (streamId: number) => void;
private closed = false;
constructor(
streamId: number,
sendData: (streamId: number, payload: Buffer) => void,
sendClose: (streamId: number) => void,
) {
super();
this.streamId = streamId;
this.sendData = sendData;
this.sendClose = sendClose;
}
public write(chunk: Buffer): boolean {
if (this.closed) return false;
this.sendData(this.streamId, chunk);
return true;
}
public end(): void {
if (this.closed) return;
this.closed = true;
this.sendClose(this.streamId);
}
public destroy(): void { this.end(); }
/** @internal Called by the switchboard on inbound `tcp_open_ack { ok: true }`. */
public _dispatchOpen(): void { this.emit('open'); }
/** @internal Called by the switchboard on inbound `TcpData` for this stream. */
public _dispatchData(chunk: Buffer): void { this.emit('data', chunk); }
/** @internal Called by the switchboard on tunnel-side error or rejection. */
public _dispatchError(err: Error): void { this.emit('error', err); }
/** @internal Called by the switchboard on tunnel-side close. */
public _dispatchClose(): void {
if (this.closed) return;
this.closed = true;
this.emit('close');
}
}
export class TcpStreamSwitchboard {
private readonly ctx: SwitchboardCtx;
private readonly logLabel: string;
private readonly tcpStreams = new Map<number, ForwardTcpStream>();
private readonly reverseTcpStreams = new Map<number, ReverseTcpStreamHandle>();
private reverseStreamIds = new StreamIdAllocator(AGENT_REVERSE_ID_BASE);
private readonly idleTimers = new Map<number, NodeJS.Timeout>();
constructor(ctx: SwitchboardCtx) {
this.ctx = ctx;
this.logLabel = ctx.logLabel || 'Mesh';
}
/** Active mesh stream count (forward + reverse) owned by this switchboard. */
public tcpStreamCount(): number {
return this.tcpStreams.size + this.reverseTcpStreams.size;
}
private totalStreamCount(): number {
return this.tcpStreamCount() + (this.ctx.extraStreamCount?.() ?? 0);
}
private refreshIdleTimer(streamId: number): void {
const existing = this.idleTimers.get(streamId);
if (existing) clearTimeout(existing);
const timer = setTimeout(() => this.onStreamIdle(streamId), STREAM_IDLE_TIMEOUT_MS);
this.idleTimers.set(streamId, timer);
}
private clearIdleTimer(streamId: number): void {
const timer = this.idleTimers.get(streamId);
if (timer) {
clearTimeout(timer);
this.idleTimers.delete(streamId);
}
}
private onStreamIdle(streamId: number): void {
this.idleTimers.delete(streamId);
const ws = this.ctx.ws;
const fwd = this.tcpStreams.get(streamId);
if (fwd) {
try { fwd.socket.destroy(); } catch { /* ignore */ }
this.tcpStreams.delete(streamId);
try { ws.send(encodeJsonFrame({ t: 'tcp_close', s: streamId })); } catch { /* ignore */ }
return;
}
const reverse = this.reverseTcpStreams.get(streamId);
if (reverse) {
this.reverseTcpStreams.delete(streamId);
reverse._dispatchError(new Error('mesh idle timeout'));
reverse._dispatchClose();
try { ws.send(encodeJsonFrame({ t: 'tcp_close', s: streamId })); } catch { /* ignore */ }
}
}
/**
* Dispatch a JSON frame. Returns true when the frame matched one of the
* TCP-related types and was handled; false otherwise so the caller's
* outer dispatcher can route HTTP / WS / control frames.
*/
public handleJsonFrame(frame: JsonFrame): boolean {
switch (frame.t) {
case 'tcp_open':
void this.onTcpOpen(frame);
return true;
case 'tcp_open_ack':
if (frame.s < AGENT_REVERSE_ID_BASE) return false;
this.onTcpOpenAckReverse(frame);
return true;
case 'tcp_close':
this.onTcpClose(frame.s);
return true;
default:
return false;
}
}
/**
* Dispatch a binary frame. Returns true iff the frame is `TcpData` (the
* only binary type owned by the switchboard).
*/
public handleBinaryFrame(frame: DecodedBinaryFrame): boolean {
if (frame.type !== BinaryFrameType.TcpData) return false;
if (frame.streamId >= AGENT_REVERSE_ID_BASE) {
const reverse = this.reverseTcpStreams.get(frame.streamId);
if (!reverse) return true;
reverse._dispatchData(frame.payload);
this.refreshIdleTimer(frame.streamId);
return true;
}
const fwd = this.tcpStreams.get(frame.streamId);
if (!fwd) return true;
try { fwd.socket.write(frame.payload); } catch { /* ignore */ }
this.refreshIdleTimer(frame.streamId);
return true;
}
private async onTcpOpen(frame: { s: number; stack: string; service: string; port: number }): Promise<void> {
const ws = this.ctx.ws;
if (this.totalStreamCount() >= MAX_STREAMS_PER_TUNNEL) {
try {
ws.send(encodeJsonFrame({ t: 'tcp_open_ack', s: frame.s, ok: false, err: 'agent_error' }));
} catch { /* ignore */ }
return;
}
const target = await this.ctx.resolveTarget(frame.stack, frame.service, frame.port);
if (!target.ok) {
try {
ws.send(encodeJsonFrame({ t: 'tcp_open_ack', s: frame.s, ok: false, err: target.err }));
} catch { /* ignore */ }
return;
}
const socket = net.createConnection({ host: target.host, port: target.port });
socket.setTimeout(MESH_CONNECT_TIMEOUT_MS);
const entry: ForwardTcpStream = { socket, accepted: false };
this.tcpStreams.set(frame.s, entry);
this.refreshIdleTimer(frame.s);
const sendAck = (ok: boolean, err?: MeshErrCode) => {
try { ws.send(encodeJsonFrame({ t: 'tcp_open_ack', s: frame.s, ok, err })); } catch { /* ignore */ }
};
socket.once('connect', () => {
entry.accepted = true;
socket.setTimeout(0);
sendAck(true);
this.refreshIdleTimer(frame.s);
});
socket.on('data', (chunk: Buffer) => {
try {
ws.send(encodeBinaryFrame(BinaryFrameType.TcpData, frame.s, chunk), { binary: true });
} catch { /* ignore */ }
this.refreshIdleTimer(frame.s);
});
socket.on('timeout', () => {
if (entry.accepted) return;
entry.accepted = true;
sendAck(false, 'unreachable');
this.tcpStreams.delete(frame.s);
this.clearIdleTimer(frame.s);
try { socket.destroy(); } catch { /* ignore */ }
});
socket.on('error', (err) => {
if (!entry.accepted) {
entry.accepted = true;
sendAck(false, 'unreachable');
this.tcpStreams.delete(frame.s);
this.clearIdleTimer(frame.s);
return;
}
if (isDebugEnabled()) {
console.warn(`[${this.logLabel}:diag] tcp stream error:`, sanitizeForLog(err.message));
}
if (this.tcpStreams.delete(frame.s)) {
this.clearIdleTimer(frame.s);
try { ws.send(encodeJsonFrame({ t: 'tcp_close', s: frame.s })); } catch { /* ignore */ }
}
});
socket.on('close', () => {
if (this.tcpStreams.delete(frame.s)) {
this.clearIdleTimer(frame.s);
try { ws.send(encodeJsonFrame({ t: 'tcp_close', s: frame.s })); } catch { /* ignore */ }
}
});
}
private onTcpClose(streamId: number): void {
if (streamId >= AGENT_REVERSE_ID_BASE) {
const handle = this.reverseTcpStreams.get(streamId);
if (!handle) return;
this.reverseTcpStreams.delete(streamId);
this.clearIdleTimer(streamId);
handle._dispatchClose();
return;
}
const entry = this.tcpStreams.get(streamId);
if (!entry) return;
this.tcpStreams.delete(streamId);
this.clearIdleTimer(streamId);
try { entry.socket.destroy(); } catch { /* ignore */ }
}
private onTcpOpenAckReverse(frame: { s: number; ok: boolean; err?: MeshErrCode }): void {
const handle = this.reverseTcpStreams.get(frame.s);
if (!handle) return;
if (frame.ok) {
handle._dispatchOpen();
this.refreshIdleTimer(frame.s);
} else {
this.reverseTcpStreams.delete(frame.s);
this.clearIdleTimer(frame.s);
handle._dispatchError(new Error(frame.err ?? 'tcp_open_reverse rejected'));
handle._dispatchClose();
}
}
/**
* Allocate a reverse stream id, send `tcp_open_reverse`, and return a
* handle MeshService can splice bytes through. Returns null if the WS
* is not OPEN or the per-tunnel cap is reached.
*/
public openReverseStream(target: { nodeId: number; stack: string; service: string; port: number }): ReverseTcpStreamHandle | null {
const ws = this.ctx.ws;
if (ws.readyState !== WebSocket.OPEN) return null;
if (this.totalStreamCount() >= MAX_STREAMS_PER_TUNNEL) return null;
const streamId = this.reverseStreamIds.allocate();
const handle = new ReverseTcpStreamHandle(
streamId,
(sid, payload) => {
if (ws.readyState !== WebSocket.OPEN) return;
try { ws.send(encodeBinaryFrame(BinaryFrameType.TcpData, sid, payload), { binary: true }); } catch { /* ignore */ }
this.refreshIdleTimer(sid);
},
(sid) => {
if (!this.reverseTcpStreams.has(sid)) return;
this.reverseTcpStreams.delete(sid);
this.clearIdleTimer(sid);
if (ws.readyState !== WebSocket.OPEN) return;
try { ws.send(encodeJsonFrame({ t: 'tcp_close', s: sid })); } catch { /* ignore */ }
},
);
this.reverseTcpStreams.set(streamId, handle);
this.refreshIdleTimer(streamId);
try {
ws.send(encodeJsonFrame({
t: 'tcp_open_reverse',
s: streamId,
targetNodeId: target.nodeId,
stack: target.stack,
service: target.service,
port: target.port,
}));
} catch (err) {
this.reverseTcpStreams.delete(streamId);
this.clearIdleTimer(streamId);
handle._dispatchError(err as Error);
handle._dispatchClose();
return null;
}
return handle;
}
/**
* Tear down all stream state. Call on WS disconnect; the next
* connection should construct a fresh switchboard.
*/
public cleanup(reason = 'mesh tunnel closed'): void {
for (const [, entry] of this.tcpStreams) {
try { entry.socket.destroy(); } catch { /* ignore */ }
}
this.tcpStreams.clear();
for (const [, handle] of this.reverseTcpStreams) {
try {
handle._dispatchError(new Error(reason));
handle._dispatchClose();
} catch { /* ignore */ }
}
this.reverseTcpStreams.clear();
// Reset the allocator so a long-lived caller that reconnects many
// times doesn't drift up the id range and approach the wrap point.
this.reverseStreamIds = new StreamIdAllocator(AGENT_REVERSE_ID_BASE);
for (const [, timer] of this.idleTimers) clearTimeout(timer);
this.idleTimers.clear();
}
}
export function attachTcpStreamSwitchboard(ctx: SwitchboardCtx): TcpStreamSwitchboard {
return new TcpStreamSwitchboard(ctx);
}
/**
* Resolve a Compose-managed container's IP address by stack name + service
* name. Honors the deterministic network preference shared with
* `MeshService.resolveContainerIp` so a redeploy that adds or reorders
* networks does not flip which IP is returned. Used by both the pilot
* agent and the proxy-mode WS handler.
*/
export async function resolveByComposeLabels(stack: string, service: string, port: number): Promise<MeshResolveResult> {
try {
const dockerodeMod = await import('dockerode');
const Docker = (dockerodeMod as { default: new (opts?: unknown) => Parameters<typeof lookupContainerIp>[0] }).default;
const docker = new Docker();
const ip = await lookupContainerIp(docker, stack, service);
if (!ip) return { ok: false, err: 'no_target' };
return { ok: true, host: ip, port };
} catch (err) {
if (isDebugEnabled()) {
console.warn('[Mesh:diag] resolveByComposeLabels failed:', sanitizeForLog((err as Error).message));
}
return { ok: false, err: 'agent_error' };
}
}