mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 03:06:57 +00:00
feat(mesh): bidirectional routing via tcp_open_reverse and central relay (#1003)
* 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.
This commit is contained in:
@@ -590,8 +590,14 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
|
||||
src.on('close', () => teardown());
|
||||
}
|
||||
|
||||
/** Find the bridge-network IP of the first container of `<stack>/<service>`. */
|
||||
private async resolveContainerIp(target: MeshTarget): Promise<string | null> {
|
||||
/**
|
||||
* Find the bridge-network IP of the first container of
|
||||
* `<stack>/<service>`. Public so the central-side
|
||||
* `PilotTunnelBridge` reverse-open handler (Phase B) can dial the same
|
||||
* target shape when a pilot's mesh forwarder routes traffic back to
|
||||
* central.
|
||||
*/
|
||||
public async resolveContainerIp(target: { stack: string; service: string }): Promise<string | null> {
|
||||
try {
|
||||
const docker = DockerController.getInstance().getDocker();
|
||||
// Compose default container name pattern; -1 is the first replica.
|
||||
@@ -644,28 +650,54 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
|
||||
return info.NetworkSettings?.IPAddress || null;
|
||||
}
|
||||
|
||||
private openCrossNode(target: MeshTarget, src: net.Socket): void {
|
||||
/**
|
||||
* Pluggable reverse dialer. Set by `PilotAgent` on a pilot host; left
|
||||
* null on a central host. When set, `openCrossNode` routes outbound
|
||||
* mesh dials through the agent's `tcp_open_reverse` path; when unset,
|
||||
* `openCrossNode` uses the central-side `PilotTunnelManager.getBridge`
|
||||
* directly. Lets the same MeshService code work on both sides.
|
||||
*/
|
||||
private reverseDialer: ReverseMeshDialer | null = null;
|
||||
|
||||
public setReverseDialer(dialer: ReverseMeshDialer | null): void {
|
||||
this.reverseDialer = dialer;
|
||||
}
|
||||
|
||||
private dialMeshTcpStream(target: MeshTarget): MeshTcpStreamLike | null {
|
||||
if (this.reverseDialer) {
|
||||
return this.reverseDialer.openMeshTcpStream({
|
||||
nodeId: target.nodeId,
|
||||
stack: target.stack,
|
||||
service: target.service,
|
||||
port: target.port,
|
||||
});
|
||||
}
|
||||
const ptm = PilotTunnelManager.getInstance();
|
||||
if (!ptm.hasActiveTunnel(target.nodeId)) {
|
||||
if (!ptm.hasActiveTunnel(target.nodeId)) return null;
|
||||
const bridge = ptm.getBridge(target.nodeId);
|
||||
if (!bridge) return null;
|
||||
return bridge.openTcpStream({ stack: target.stack, service: target.service, port: target.port });
|
||||
}
|
||||
|
||||
private openCrossNode(target: MeshTarget, src: net.Socket): void {
|
||||
const tcpStream = this.dialMeshTcpStream(target);
|
||||
if (!tcpStream) {
|
||||
this.logActivity({
|
||||
source: 'pilot', level: 'error', type: 'tunnel.fail',
|
||||
nodeId: target.nodeId, alias: target.alias,
|
||||
message: `no active pilot tunnel to node ${target.nodeId}`,
|
||||
message: this.reverseDialer
|
||||
? `cannot open reverse mesh stream to node ${target.nodeId}`
|
||||
: `no active pilot tunnel to node ${target.nodeId}`,
|
||||
});
|
||||
try { src.destroy(); } catch { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
const bridge = ptm.getBridge(target.nodeId);
|
||||
if (!bridge) { try { src.destroy(); } catch { /* ignore */ } return; }
|
||||
const tcpStream = bridge.openTcpStream({ stack: target.stack, service: target.service, port: target.port });
|
||||
if (!tcpStream) { try { src.destroy(); } catch { /* ignore */ } return; }
|
||||
|
||||
const record = this.registerActiveStream(target.alias, tcpStream.streamId);
|
||||
const t0 = Date.now();
|
||||
tcpStream.on('open', () => {
|
||||
this.logActivity({
|
||||
source: 'mesh', level: 'info', type: 'route.resolve.ok',
|
||||
nodeId: target.nodeId, alias: target.alias, streamId: tcpStream.streamId,
|
||||
nodeId: target.nodeId, alias: target.alias, streamId: record.streamId,
|
||||
message: `cross-node connect to ${target.alias}`,
|
||||
});
|
||||
this.routeLatencyMap.set(target.alias, Date.now() - t0);
|
||||
@@ -677,14 +709,14 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
|
||||
tcpStream.on('error', (err: Error) => {
|
||||
this.logActivity({
|
||||
source: 'pilot', level: 'error', type: 'tunnel.fail',
|
||||
nodeId: target.nodeId, alias: target.alias, streamId: tcpStream.streamId,
|
||||
nodeId: target.nodeId, alias: target.alias, streamId: record.streamId,
|
||||
message: err.message,
|
||||
});
|
||||
this.activeStreams.delete(tcpStream.streamId);
|
||||
this.activeStreams.delete(record.streamId);
|
||||
try { src.destroy(); } catch { /* ignore */ }
|
||||
});
|
||||
tcpStream.on('close', () => {
|
||||
this.activeStreams.delete(tcpStream.streamId);
|
||||
this.activeStreams.delete(record.streamId);
|
||||
try { src.end(); } catch { /* ignore */ }
|
||||
});
|
||||
src.on('data', (chunk: Buffer) => {
|
||||
@@ -912,3 +944,31 @@ export class MeshError extends Error {
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Common surface of an outbound mesh TCP stream as MeshService consumes
|
||||
* it. Both the central-side `PilotTunnelBridge.TcpStream` and the
|
||||
* pilot-side `ReverseTcpStreamHandle` (from `pilot/agent.ts`) implement
|
||||
* this shape structurally so MeshService.openCrossNode can splice bytes
|
||||
* against either without caring which side initiated the stream.
|
||||
*/
|
||||
export interface MeshTcpStreamLike {
|
||||
readonly streamId: number;
|
||||
write(chunk: Buffer): boolean;
|
||||
end(): void;
|
||||
destroy(): void;
|
||||
on(event: 'open', listener: () => void): this;
|
||||
on(event: 'data', listener: (chunk: Buffer) => void): this;
|
||||
on(event: 'error', listener: (err: Error) => void): this;
|
||||
on(event: 'close', listener: () => void): this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pilot-side reverse dialer. Set by `PilotAgent` when the agent boots
|
||||
* (Phase B); leaves MeshService.openCrossNode able to route outbound
|
||||
* cross-node mesh traffic over the agent's outbound `tcp_open_reverse`
|
||||
* frame instead of central's `PilotTunnelManager.getBridge`.
|
||||
*/
|
||||
export interface ReverseMeshDialer {
|
||||
openMeshTcpStream(target: { nodeId: number; stack: string; service: string; port: number }): MeshTcpStreamLike | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user