mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 19:26:56 +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.
296 lines
13 KiB
TypeScript
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();
|
|
});
|
|
});
|