import net from 'net'; import { sanitizeForLog } from '../utils/safeLog'; /** * In-process mesh TCP forwarder. Owns per-port `net.Server` listeners and * delegates accepted sockets to the host (MeshService) for resolve + * splice. All routing and splicing logic lives on MeshService; * MeshForwarder is a thin lifecycle layer that opens and closes ports. * * Sencho runs in standard Docker bridge mode and attaches its own * container to the shared `sencho_mesh` network at a stable IP. Meshed * user containers are attached to the same network, so they reach the * forwarder by the Sencho IP without `network_mode: host` or the * `extra_hosts: :host-gateway` indirection an older sidecar * design required. */ export interface MeshForwarderHost { /** Called on each accepted inbound socket. The host owns the splice * lifecycle; MeshForwarder only manages listener boilerplate. */ handleAccept(port: number, source: net.Socket): Promise; } export class MeshForwarder { private readonly listeners = new Map(); /** * In-flight `listen(port)` promises so concurrent callers race-safely * deduplicate. Without this guard, two concurrent calls to listen on * the same port would both pass the `listeners.has(port)` check (which * is only populated after the listening event resolves) and the second * would fail with EADDRINUSE. */ private readonly pending = new Map>(); private shuttingDown = false; constructor(private readonly host: MeshForwarderHost) {} public async listen(port: number): Promise { if (this.shuttingDown) return; if (this.listeners.has(port)) return; const inflight = this.pending.get(port); if (inflight) return inflight; const promise = (async () => { const server = net.createServer((socket) => this.acceptConnection(port, socket)); try { await new Promise((resolve, reject) => { const onError = (err: Error) => { server.removeListener('listening', onListening); reject(err); }; const onListening = () => { server.removeListener('error', onError); resolve(); }; server.once('error', onError); server.once('listening', onListening); // Bind on all interfaces inside the Sencho container's // networking namespace. The sencho_mesh bridge attaches // both Sencho and meshed user containers, so peers reach // this listener at Sencho's mesh-network IP. server.listen(port, '0.0.0.0'); }); this.listeners.set(port, server); } finally { this.pending.delete(port); } })(); this.pending.set(port, promise); return promise; } public async unlisten(port: number): Promise { const server = this.listeners.get(port); if (!server) return; this.listeners.delete(port); await new Promise((resolve) => server.close(() => resolve())); } public async shutdown(): Promise { this.shuttingDown = true; const ports = Array.from(this.listeners.keys()); await Promise.all(ports.map((p) => this.unlisten(p))); } public getListenerPorts(): number[] { return Array.from(this.listeners.keys()); } public isListening(port: number): boolean { return this.listeners.has(port); } private acceptConnection(port: number, source: net.Socket): void { if (this.shuttingDown) { try { source.destroy(); } catch { /* ignore */ } return; } // Defer to the host for resolve + splice. MeshForwarder itself does // not look at the source bytes; routing lives on MeshService where // the alias map and the cross-node bridge dispatch are. this.host.handleAccept(port, source).catch((err) => { console.warn('[MeshForwarder] accept handler failed:', sanitizeForLog((err as Error).message)); try { source.destroy(); } catch { /* ignore */ } }); } }