Files
sencho/backend/src/__tests__/pilot-bridge-reverse.test.ts
T
Anso 982c7830b1 fix(mesh): address audit follow-ups (early-data, recompose, admiral gate, error codes, docs) (#1126)
* 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.
2026-05-20 12:53:29 -04:00

296 lines
13 KiB
TypeScript

/**
* Phase B: PilotTunnelBridge handles `tcp_open_reverse` from the agent.
* Two paths:
* - target is the central's local node: dial via Dockerode container IP
* and splice bytes between the resulting `net.Socket` and the tunnel.
* - target is another pilot: open a forward `TcpStream` on the target
* pilot's bridge and relay.
*
* The local-dial path is the common case for small fleets. This file
* exercises the validation gate (agent-id-range, stream cap) and the
* local-dial outcome by intercepting `MeshService.resolveContainerIp`.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import net from 'net';
import { EventEmitter } from 'events';
import { WebSocket } from 'ws';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
import {
AGENT_REVERSE_ID_BASE,
BinaryFrameType,
decodeJsonFrame,
encodeBinaryFrame,
encodeJsonFrame,
} from '../pilot/protocol';
let tmpDir: string;
let PilotTunnelBridge: typeof import('../services/PilotTunnelBridge').PilotTunnelBridge;
let MeshService: typeof import('../services/MeshService').MeshService;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ PilotTunnelBridge } = await import('../services/PilotTunnelBridge'));
({ MeshService } = await import('../services/MeshService'));
});
afterAll(() => {
vi.restoreAllMocks();
cleanupTestDb(tmpDir);
});
function makeMockTunnelWs(): EventEmitter & {
sent: unknown[];
readyState: number;
bufferedAmount: number;
send: (data: unknown) => void;
ping: () => void;
close: () => void;
} {
const ws = new EventEmitter() as EventEmitter & {
sent: unknown[]; readyState: number; bufferedAmount: number;
send: (data: unknown) => void; ping: () => void; close: () => void;
};
ws.sent = [];
ws.readyState = WebSocket.OPEN;
ws.bufferedAmount = 0;
ws.send = (data: unknown) => { ws.sent.push(data); };
ws.ping = () => { /* no-op */ };
ws.close = () => { ws.readyState = WebSocket.CLOSED; ws.emit('close'); };
return ws;
}
function findAck(ws: { sent: unknown[] }, s: number): { ok: boolean; err?: string } | undefined {
for (const item of ws.sent) {
if (typeof item !== 'string') continue;
try {
const f = decodeJsonFrame(item);
if (f.t === 'tcp_open_ack' && f.s === s) return { ok: f.ok, err: f.err };
} catch { /* ignore */ }
}
return undefined;
}
async function waitFor<T>(check: () => T | undefined): Promise<T> {
const deadline = Date.now() + 1500;
while (Date.now() < deadline) {
const v = check();
if (v !== undefined) return v;
await new Promise((r) => setTimeout(r, 10));
}
throw new Error('timeout');
}
describe('PilotTunnelBridge handles tcp_open_reverse (Phase B)', () => {
it('rejects frames whose s is below the agent-reverse range', async () => {
const mockWs = makeMockTunnelWs();
const bridge = new PilotTunnelBridge(1, mockWs as unknown as WebSocket);
await bridge.start();
// Drive the tunnel-message handler with a low-range id; the
// validation gate must reject and emit a tcp_open_ack {ok: false}.
mockWs.emit('message', encodeJsonFrame({
t: 'tcp_open_reverse', s: 5,
targetNodeId: 1, stack: 's', service: 'svc', port: 80,
}), false);
const ack = await waitFor(() => findAck(mockWs, 5));
expect(ack.ok).toBe(false);
expect(ack.err).toBe('agent_error');
bridge.close();
});
it('local target with no Dockerode resolution returns ok:false err:no_target', async () => {
const mockWs = makeMockTunnelWs();
const bridge = new PilotTunnelBridge(1, mockWs as unknown as WebSocket);
await bridge.start();
// The default node id from the test DB. Force resolveContainerIp to
// return null to simulate a stack that does not exist locally.
const localNodeId = (await import('../services/NodeRegistry')).NodeRegistry.getInstance().getDefaultNodeId();
const spy = vi.spyOn(MeshService.getInstance(), 'resolveContainerIp').mockResolvedValue(null);
const s = AGENT_REVERSE_ID_BASE + 1;
mockWs.emit('message', encodeJsonFrame({
t: 'tcp_open_reverse', s,
targetNodeId: localNodeId, stack: 'missing', service: 'svc', port: 80,
}), false);
const ack = await waitFor(() => findAck(mockWs, s));
expect(ack.ok).toBe(false);
expect(ack.err).toBe('no_target');
expect(spy).toHaveBeenCalledTimes(1);
bridge.close();
vi.restoreAllMocks();
});
it('buffers TcpData arriving during the resolveContainerIp await and flushes it on connect (F-9 reverse race fix)', async () => {
const mockWs = makeMockTunnelWs();
const bridge = new PilotTunnelBridge(1, mockWs as unknown as WebSocket);
await bridge.start();
// Upstream that captures received bytes so we can assert the peer's
// request body was delivered (not silently dropped during the
// resolveContainerIp window).
let resolveReceived!: (buf: Buffer) => void;
const received = new Promise<Buffer>((r) => { resolveReceived = r; });
const upstream = net.createServer((socket) => {
let acc = Buffer.alloc(0);
socket.on('data', (chunk: Buffer) => {
acc = Buffer.concat([acc, chunk]);
if (acc.length >= 'POST /hook HTTP/1.1\r\n\r\n'.length) resolveReceived(acc);
});
});
await new Promise<void>((resolve) => upstream.listen(0, '127.0.0.1', () => resolve()));
const addr = upstream.address();
if (!addr || typeof addr === 'string') throw new Error('no address');
const upstreamPort = addr.port;
const localNodeId = (await import('../services/NodeRegistry')).NodeRegistry.getInstance().getDefaultNodeId();
// Delay the resolve to widen the race window; without the fix the
// TcpData frame below lands while this.streams has no entry for s
// and the bytes are dropped at the lookup-miss path in
// handleBinaryFrame, so the upstream never sees the request.
vi.spyOn(MeshService.getInstance(), 'resolveContainerIp')
.mockImplementation(() => new Promise((r) => setTimeout(() => r('127.0.0.1'), 50)));
const s = AGENT_REVERSE_ID_BASE + 3;
mockWs.emit('message', encodeJsonFrame({
t: 'tcp_open_reverse', s,
targetNodeId: localNodeId, stack: 'real', service: 'svc', port: upstreamPort,
}), false);
// Immediately push the "HTTP request" bytes while resolve is in flight.
mockWs.emit('message',
encodeBinaryFrame(BinaryFrameType.TcpData, s, Buffer.from('POST /hook HTTP/1.1\r\n\r\n')),
true);
const body = await received;
expect(body.toString()).toBe('POST /hook HTTP/1.1\r\n\r\n');
// ack:true should also have landed (after the flush).
const ack = await waitFor(() => findAck(mockWs, s));
expect(ack.ok).toBe(true);
upstream.close();
bridge.close();
vi.restoreAllMocks();
});
it('local target with a working dial sends ok:true and registers a reverse stream', async () => {
const mockWs = makeMockTunnelWs();
const bridge = new PilotTunnelBridge(1, mockWs as unknown as WebSocket);
await bridge.start();
// Spin up a real local server the bridge will dial as the target.
const upstream = net.createServer((socket) => {
socket.write('hello-upstream');
});
await new Promise<void>((resolve) => upstream.listen(0, '127.0.0.1', () => resolve()));
const addr = upstream.address();
if (!addr || typeof addr === 'string') throw new Error('no address');
const upstreamPort = addr.port;
const localNodeId = (await import('../services/NodeRegistry')).NodeRegistry.getInstance().getDefaultNodeId();
vi.spyOn(MeshService.getInstance(), 'resolveContainerIp').mockResolvedValue('127.0.0.1');
const s = AGENT_REVERSE_ID_BASE + 2;
mockWs.emit('message', encodeJsonFrame({
t: 'tcp_open_reverse', s,
targetNodeId: localNodeId, stack: 'real', service: 'svc', port: upstreamPort,
}), false);
const ack = await waitFor(() => findAck(mockWs, s));
expect(ack.ok).toBe(true);
// Stream is registered on the bridge.
const streams = (bridge as unknown as { streams: Map<number, { kind: string }> }).streams;
const state = streams.get(s);
expect(state?.kind).toBe('reverse_local');
upstream.close();
bridge.close();
vi.restoreAllMocks();
});
it('buffers TcpData arriving during ensureBridge and flushes it on target open (reverse-relay early-data fix)', async () => {
const mockWs = makeMockTunnelWs();
const bridge = new PilotTunnelBridge(1, mockWs as unknown as WebSocket);
await bridge.start();
// Fake target stream: an EventEmitter with a write() method that
// captures bytes. We control when 'open' fires to widen the
// race window the fix guards.
const writes: Buffer[] = [];
const fakeTargetStream = new EventEmitter() as EventEmitter & {
write: (b: Buffer) => void;
};
fakeTargetStream.write = (b: Buffer) => { writes.push(b); };
const fakeTargetBridge = {
openTcpStream: vi.fn(() => fakeTargetStream),
getBufferedAmount: () => 0,
};
const { PilotTunnelManager } = await import('../services/PilotTunnelManager');
// Delay ensureBridge so a TcpData frame fired back-to-back with
// tcp_open_reverse lands while acceptReverseRelay is still
// waiting on the target bridge. Without the fix the bytes are
// dropped at the lookup-miss path in handleBinaryFrame.
vi.spyOn(PilotTunnelManager.getInstance(), 'ensureBridge')
.mockImplementation(() => new Promise((r) => setTimeout(() => r(fakeTargetBridge as never), 50)));
const { NodeRegistry } = await import('../services/NodeRegistry');
const localNodeId = NodeRegistry.getInstance().getDefaultNodeId();
const remoteTargetNodeId = localNodeId + 99;
const s = AGENT_REVERSE_ID_BASE + 7;
mockWs.emit('message', encodeJsonFrame({
t: 'tcp_open_reverse', s,
targetNodeId: remoteTargetNodeId, stack: 'real', service: 'svc', port: 8080,
}), false);
// Immediately push the request bytes while ensureBridge is in flight.
mockWs.emit('message',
encodeBinaryFrame(BinaryFrameType.TcpData, s, Buffer.from('POST /hook HTTP/1.1\r\n\r\n')),
true);
// Wait until acceptReverseRelay has actually swapped the reservation
// to kind:'reverse_relay' (and targetOpen is still false because the
// target hasn't emitted 'open'). Polling the bridge's stream state
// directly is more deterministic than polling openTcpStream.mock
// calls, since the state swap is synchronous after openTcpStream
// returns but the JS scheduler decides when the test sees it.
const bridgeStreams = (bridge as unknown as { streams: Map<number, { kind: string; targetOpen?: boolean }> }).streams;
await waitFor(() => {
const cur = bridgeStreams.get(s);
return (cur?.kind === 'reverse_relay' && cur.targetOpen === false) ? true : undefined;
});
// Second TcpData frame in the targetOpen===false window after the
// state swap. Without the reverse_relay branch's pendingData buffer,
// these bytes would be silently dropped.
mockWs.emit('message',
encodeBinaryFrame(BinaryFrameType.TcpData, s, Buffer.from('mid-window')),
true);
// Fire 'open' on the fake target so the relay flushes its pending buffer.
fakeTargetStream.emit('open');
const ack = await waitFor(() => findAck(mockWs, s));
expect(ack.ok).toBe(true);
// Both pre-openTcpStream and post-state-swap buffered bytes were
// flushed exactly once, intact, in order.
await waitFor(() => (Buffer.concat(writes).toString().includes('mid-window')) ? true : undefined);
const combined = Buffer.concat(writes).toString();
expect(combined).toBe('POST /hook HTTP/1.1\r\n\r\nmid-window');
// Subsequent post-open writes also flow.
mockWs.emit('message',
encodeBinaryFrame(BinaryFrameType.TcpData, s, Buffer.from('trailer')),
true);
await waitFor(() => (Buffer.concat(writes).toString().endsWith('trailer')) ? true : undefined);
expect(Buffer.concat(writes).toString()).toBe('POST /hook HTTP/1.1\r\n\r\nmid-windowtrailer');
bridge.close();
vi.restoreAllMocks();
});
});