mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 08:57:25 +00:00
e65c5e8551
* fix(pilot): route remote WS upgrades through NodeRegistry.getProxyTarget Pilot-mode nodes carry empty api_url and api_token by design and expose their API on a per-tunnel loopback bridge. The upgrade handler gated the remote-forwarder branch on `node.api_url && node.api_token`, so WS requests targeting pilot nodes silently fell through to the local handlers (live logs, exec, generic) instead of tunneling to the agent. Resolve the target via NodeRegistry.getProxyTarget so pilot and proxy modes share one dispatch path, mirroring the HTTP proxy. handleRemoteForwarder now takes the resolved target and, when the target is the pilot loopback (empty token), skips the console-token exchange and the Authorization injection so the tunnel-side auth is the only source of truth on that path. Unresolvable targets reject the upgrade with HTTP 503 instead of being served gateway-local data. * fix(pilot): emit tunnel-down and mark node offline on closeTunnel PilotTunnelManager.closeTunnel closed the underlying WebSocket but skipped the cleanup the natural-disconnect path runs, so explicit closures (enrollment regenerate, node deletion) left the node row at status='online' until the next reconnect. The dashboard kept showing the stale state for the entire interval. closeTunnel now writes nodes.status='offline' and emits tunnel-down for pilot bridges, and emits proxy-bridge-down for central-initiated proxy bridges. The maps are cleared before bridge.close() so the natural 'closed' handler's bridge-identity guard short-circuits and we do not double-emit. * fix(mesh): buffer cross-node source data until tcp_open_ack arrives openCrossNode piped src socket data straight to tcpStream.write before the forward TcpStream emitted 'open'. The first packet on a fresh cross-node stream raced ahead of the agent's tcp_open_ack on the wire, which broke protocols that send immediately after connect (HTTP, TLS, Redis, Postgres) on Pilot and proxy mesh paths. Buffer src chunks in a local array capped at STREAM_PENDING_DATA_MAX_BYTES until tcpStream emits 'open', then flush them in order before any post-open writes. Tear down both sockets if the buffer overflows so a misbehaving source cannot exhaust gateway memory while waiting for the ack. * docs(pilot): clarify host-console non-parity and narrow the parity claim Pilot mode disables the host-console capability at the capability registry (the agent container has no useful host shell to surface), but the public docs listed host console among the WebSockets that ride through the tunnel and described pilot as behaving identically to proxy mode. State the shared-capability claim more carefully and call out the intentional non-parity in a dedicated subsection.
118 lines
4.4 KiB
TypeScript
118 lines
4.4 KiB
TypeScript
/**
|
|
* Regression for PilotTunnelManager.closeTunnel lifecycle parity.
|
|
*
|
|
* The natural-disconnect path (bridge's `'closed'` event) writes
|
|
* `nodes.status='offline'` and emits `tunnel-down` for pilot bridges, or
|
|
* emits `proxy-bridge-down` for proxy bridges. Explicit `closeTunnel` calls
|
|
* (enrollment regenerate, node deletion) must run the same cleanup so the
|
|
* UI does not keep showing a Pilot node as Online after the operator has
|
|
* intentionally torn its session down.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import { EventEmitter } from 'events';
|
|
import { WebSocket } from 'ws';
|
|
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
|
import { PilotTunnelManager } from '../services/PilotTunnelManager';
|
|
|
|
let tmpDir: string;
|
|
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
({ DatabaseService } = await import('../services/DatabaseService'));
|
|
});
|
|
|
|
afterAll(() => 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;
|
|
}
|
|
|
|
describe('PilotTunnelManager.closeTunnel lifecycle parity', () => {
|
|
it('marks the node offline and emits tunnel-down when closing a pilot bridge', async () => {
|
|
const mgr = PilotTunnelManager.getInstance();
|
|
const nodeId = DatabaseService.getInstance().addNode({
|
|
name: `pilot-close-${Date.now()}`,
|
|
type: 'remote',
|
|
mode: 'pilot_agent',
|
|
compose_dir: '/tmp/x',
|
|
is_default: false,
|
|
api_url: '',
|
|
api_token: '',
|
|
});
|
|
|
|
const ws = makeMockTunnelWs();
|
|
await mgr.registerTunnel(nodeId, ws as unknown as WebSocket, 'test-1.0.0');
|
|
|
|
// Confirm the registration write actually landed before we measure
|
|
// the close-side delta; otherwise the assertion would pass for the
|
|
// wrong reason on a fresh node that defaulted to status=null.
|
|
expect(DatabaseService.getInstance().getNode(nodeId)?.status).toBe('online');
|
|
|
|
let tunnelDownNodeId: number | null = null;
|
|
const onTunnelDown = (id: number): void => { tunnelDownNodeId = id; };
|
|
mgr.once('tunnel-down', onTunnelDown);
|
|
|
|
mgr.closeTunnel(nodeId);
|
|
|
|
expect(DatabaseService.getInstance().getNode(nodeId)?.status).toBe('offline');
|
|
expect(tunnelDownNodeId).toBe(nodeId);
|
|
expect(mgr.hasActiveTunnel(nodeId)).toBe(false);
|
|
});
|
|
|
|
it('does not double-emit tunnel-down when the bridge close fires after closeTunnel', async () => {
|
|
const mgr = PilotTunnelManager.getInstance();
|
|
const nodeId = DatabaseService.getInstance().addNode({
|
|
name: `pilot-no-double-${Date.now()}`,
|
|
type: 'remote',
|
|
mode: 'pilot_agent',
|
|
compose_dir: '/tmp/x',
|
|
is_default: false,
|
|
api_url: '',
|
|
api_token: '',
|
|
});
|
|
|
|
const ws = makeMockTunnelWs();
|
|
await mgr.registerTunnel(nodeId, ws as unknown as WebSocket, 'test-1.0.0');
|
|
|
|
let emitCount = 0;
|
|
const onTunnelDown = (id: number): void => {
|
|
if (id === nodeId) emitCount += 1;
|
|
};
|
|
mgr.on('tunnel-down', onTunnelDown);
|
|
|
|
// closeTunnel deletes the map entry, then calls bridge.close() which
|
|
// synchronously emits 'closed' on the mock ws. The bridge's
|
|
// `bridges.get(nodeId) === bridge` check inside its 'closed' handler
|
|
// must short-circuit because we already deleted the entry, so this
|
|
// produces exactly one tunnel-down emission.
|
|
mgr.closeTunnel(nodeId);
|
|
// Yield a tick for any deferred listeners.
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
|
|
mgr.off('tunnel-down', onTunnelDown);
|
|
expect(emitCount).toBe(1);
|
|
});
|
|
});
|