mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 09:54:26 +00:00
567e524450
* feat(mesh): bidirectional routing via tcp_open_reverse and central relay
Phase B of the mesh redesign. Adds the reverse-direction protocol so a
pilot's MeshForwarder can route cross-node traffic to central or to
another pilot. Central relays pilot-to-pilot streams transparently;
pilots keep their existing single outbound WS to central.
Protocol additions (backend/src/pilot/protocol.ts):
- TcpOpenReverseFrame { s, targetNodeId, stack, service, port } sent
agent to primary. Reuses existing tcp_open_ack, tcp_close, and
TcpData binary frames for the response and byte plane.
- AGENT_REVERSE_ID_BASE = 0x40000001 splits the 32-bit id space so
agent-allocated reverse stream ids never collide with primary-
allocated forward ids on the same tunnel.
- StreamIdAllocator now wraps to its configured start (not always 1)
so an allocator parameterized with the agent base stays in the
agent half across the wrap.
Agent (backend/src/pilot/agent.ts):
- New ReverseTcpStreamHandle exported class, EventEmitter facade
matching PilotTunnelBridge.TcpStream's surface (write, end,
destroy plus open, data, error, close events).
- Public openMeshTcpStream(target) allocates a reverse id, sends
tcp_open_reverse, returns the handle.
- onTcpOpenAckReverse handles inbound ack, dispatches open or
error+close to the matching handle.
- TcpData and tcp_close binary/JSON paths route ids in the reverse
range to reverseTcpStreams; existing primary-allocated paths
unchanged.
- streamCount, cleanupAfterDisconnect, onStreamIdle include reverse
streams. The allocator is reset to a fresh base on disconnect so
long-lived agents that reconnect many times do not drift up the id
space.
- startPilotAgent registers the agent as MeshService's reverse
dialer via lazy import.
Bridge (backend/src/services/PilotTunnelBridge.ts):
- Two new StreamState kinds: reverse_local (target = central, dial
Dockerode container IP) and reverse_relay (target = another pilot,
open a forward TcpStream on the target pilot's bridge).
- handleTcpOpenReverse validates the agent-id range and stream cap,
then dispatches to acceptReverseLocal or acceptReverseRelay via
lazy imports of MeshService, NodeRegistry, and PilotTunnelManager
(avoids module cycles).
- acceptReverseLocal calls MeshService.resolveContainerIp (now
public), opens net.createConnection, splices bytes through the
tunnel. Pre-connect error handler is removed inside connect to
avoid double-firing with the mid-stream error path.
- acceptReverseRelay opens a forward TcpStream on the target
bridge, splices bytes between the two tunnels.
- Existing tcp_close and TcpData handlers extended for the new
state kinds. teardownStream extended.
MeshService (backend/src/services/MeshService.ts):
- resolveContainerIp made public so the bridge's local-target path
can dial the same shape.
- New reverseDialer field plus setReverseDialer setter. Pilot mode
registers the agent at boot; central mode leaves it null.
- New private dialMeshTcpStream dispatcher: pilot side uses the
reverse dialer, central side uses PilotTunnelManager.getBridge.
- openCrossNode refactored to call the dispatcher and use the
active-stream record's id for activity logging.
- New exported MeshTcpStreamLike interface that both
PilotTunnelBridge.TcpStream and ReverseTcpStreamHandle satisfy
structurally; ReverseMeshDialer is the contract for the setter.
Tests (3 files, 16 new cases, 172 total pass):
- pilot-protocol-tcp.test.ts: tcp_open_reverse round-trip; agent
base invariant.
- pilot-agent-reverse-stream.test.ts: openMeshTcpStream allocation,
ws-not-open, frame shape, write encoding, end emits tcp_close,
ack-success, ack-failure, low-id ack ignored, inbound TcpData
routing.
- pilot-bridge-reverse.test.ts: id-range validation, no-target
failure, successful local dial against a real upstream server.
Together with PR #1000 (Phase A) and PR #1001 (deps), Phase B
completes the central-pilot-pilot mesh routing matrix. Phase C
(mesh over proxy-mode remotes) is the planned follow-up.
* test(pilot): drop unused encodeJsonFrame import
Lint failed on pilot-agent-reverse-stream.test.ts after the test
changed from constructing tcp_open_reverse frames inline to driving
the agent's frame-dispatch path with synthetic objects. The import
is no longer referenced; ESLint's no-unused-vars rule rejected it.
138 lines
5.2 KiB
TypeScript
138 lines
5.2 KiB
TypeScript
/**
|
|
* Tests for the Sencho Mesh TCP frames added to the pilot tunnel protocol.
|
|
*/
|
|
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
AGENT_REVERSE_ID_BASE,
|
|
BinaryFrameType,
|
|
decodeBinaryFrame,
|
|
decodeJsonFrame,
|
|
encodeBinaryFrame,
|
|
encodeJsonFrame,
|
|
} from '../pilot/protocol';
|
|
|
|
describe('Mesh TCP JSON frames', () => {
|
|
it('roundtrips a tcp_open frame', () => {
|
|
const raw = encodeJsonFrame({
|
|
t: 'tcp_open',
|
|
s: 42,
|
|
stack: 'api',
|
|
service: 'db',
|
|
port: 5432,
|
|
});
|
|
const decoded = decodeJsonFrame(raw);
|
|
expect(decoded.t).toBe('tcp_open');
|
|
if (decoded.t !== 'tcp_open') throw new Error('narrowing');
|
|
expect(decoded.s).toBe(42);
|
|
expect(decoded.stack).toBe('api');
|
|
expect(decoded.service).toBe('db');
|
|
expect(decoded.port).toBe(5432);
|
|
});
|
|
|
|
it('roundtrips a tcp_open_ack success', () => {
|
|
const raw = encodeJsonFrame({ t: 'tcp_open_ack', s: 42, ok: true });
|
|
const decoded = decodeJsonFrame(raw);
|
|
expect(decoded.t).toBe('tcp_open_ack');
|
|
if (decoded.t !== 'tcp_open_ack') throw new Error('narrowing');
|
|
expect(decoded.ok).toBe(true);
|
|
expect(decoded.err).toBeUndefined();
|
|
});
|
|
|
|
it('roundtrips a tcp_open_ack failure with error code', () => {
|
|
const raw = encodeJsonFrame({
|
|
t: 'tcp_open_ack',
|
|
s: 42,
|
|
ok: false,
|
|
err: 'unreachable',
|
|
});
|
|
const decoded = decodeJsonFrame(raw);
|
|
if (decoded.t !== 'tcp_open_ack') throw new Error('narrowing');
|
|
expect(decoded.ok).toBe(false);
|
|
expect(decoded.err).toBe('unreachable');
|
|
});
|
|
|
|
it('roundtrips a tcp_close frame', () => {
|
|
const raw = encodeJsonFrame({ t: 'tcp_close', s: 42 });
|
|
const decoded = decodeJsonFrame(raw);
|
|
expect(decoded.t).toBe('tcp_close');
|
|
if (decoded.t !== 'tcp_close') throw new Error('narrowing');
|
|
expect(decoded.s).toBe(42);
|
|
});
|
|
});
|
|
|
|
describe('Mesh TcpData binary frames', () => {
|
|
it('encodes the 0x04 type discriminator', () => {
|
|
const payload = Buffer.from('hello');
|
|
const encoded = encodeBinaryFrame(BinaryFrameType.TcpData, 1, payload);
|
|
expect(encoded[0]).toBe(0x04);
|
|
});
|
|
|
|
it('roundtrips streamId + payload', () => {
|
|
const payload = Buffer.from('SELECT 1;');
|
|
const encoded = encodeBinaryFrame(BinaryFrameType.TcpData, 0xdeadbeef, payload);
|
|
const decoded = decodeBinaryFrame(encoded);
|
|
expect(decoded.type).toBe(BinaryFrameType.TcpData);
|
|
expect(decoded.streamId).toBe(0xdeadbeef);
|
|
expect(decoded.payload.toString()).toBe('SELECT 1;');
|
|
});
|
|
|
|
it('roundtrips an empty payload', () => {
|
|
const encoded = encodeBinaryFrame(BinaryFrameType.TcpData, 7, Buffer.alloc(0));
|
|
const decoded = decodeBinaryFrame(encoded);
|
|
expect(decoded.type).toBe(BinaryFrameType.TcpData);
|
|
expect(decoded.streamId).toBe(7);
|
|
expect(decoded.payload.length).toBe(0);
|
|
});
|
|
|
|
it('preserves binary payloads byte-for-byte', () => {
|
|
const payload = Buffer.from([0x00, 0xff, 0x01, 0x80, 0x7f, 0x10]);
|
|
const encoded = encodeBinaryFrame(BinaryFrameType.TcpData, 1, payload);
|
|
const decoded = decodeBinaryFrame(encoded);
|
|
expect(decoded.payload.equals(payload)).toBe(true);
|
|
});
|
|
|
|
it('rejects an unknown binary frame type', () => {
|
|
const buf = Buffer.alloc(5);
|
|
buf.writeUInt8(0x99, 0);
|
|
buf.writeUInt32BE(1, 1);
|
|
expect(() => decodeBinaryFrame(buf)).toThrow(/unknown binary frame type/);
|
|
});
|
|
|
|
it('continues to accept the existing http and ws binary types', () => {
|
|
for (const t of [BinaryFrameType.HttpReqBody, BinaryFrameType.HttpResBody, BinaryFrameType.WsMessageBinary]) {
|
|
const buf = encodeBinaryFrame(t, 1, Buffer.from('x'));
|
|
expect(() => decodeBinaryFrame(buf)).not.toThrow();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Phase B: tcp_open_reverse', () => {
|
|
it('roundtrips a tcp_open_reverse frame including targetNodeId', () => {
|
|
const raw = encodeJsonFrame({
|
|
t: 'tcp_open_reverse',
|
|
s: AGENT_REVERSE_ID_BASE + 7,
|
|
targetNodeId: 12,
|
|
stack: 'api',
|
|
service: 'db',
|
|
port: 5432,
|
|
});
|
|
const decoded = decodeJsonFrame(raw);
|
|
expect(decoded.t).toBe('tcp_open_reverse');
|
|
if (decoded.t !== 'tcp_open_reverse') throw new Error('narrowing');
|
|
expect(decoded.s).toBe(AGENT_REVERSE_ID_BASE + 7);
|
|
expect(decoded.targetNodeId).toBe(12);
|
|
expect(decoded.stack).toBe('api');
|
|
expect(decoded.service).toBe('db');
|
|
expect(decoded.port).toBe(5432);
|
|
});
|
|
|
|
it('AGENT_REVERSE_ID_BASE is in the upper half of the 32-bit space and clears primary allocator collision risk', () => {
|
|
// Primary allocator wraps at 0x7fffffff. Agent base must be above
|
|
// that range so primary's id sequence (1..0x7fffffff) and the
|
|
// agent's reverse sequence never collide on the same tunnel for
|
|
// the lifetime of the tunnel (well beyond MAX_STREAMS_PER_TUNNEL).
|
|
expect(AGENT_REVERSE_ID_BASE).toBeGreaterThan(0x40000000);
|
|
expect(AGENT_REVERSE_ID_BASE).toBeLessThan(0x80000000);
|
|
});
|
|
});
|