mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 04:38:11 +00:00
feat(mesh): symmetric WS dial for proxy-mode mesh peers (#1066)
* chore(mesh): foundation for symmetric callback dial Adds the data-plane scaffolding that the symmetric callback dial fix builds on: - mesh_centrals table for peer-side bootstrap material - MeshCentralRegistry service (upsert/getActive/clear/markUsed/markRejected) - PilotTunnelManager kind discriminator and replaceOrRegisterProxyBridge - mesh_proxy_callback_bootstrap capability registration - MeshProxyTunnelDialer reason-tagged proxy-bridge-down events from a single tearDownBridge emission point - Reactive redial scheduler that skips idle and auth_failed reasons * feat(mesh): add reverse-direction activity log entries (closes R1-B) acceptReverseLocal now emits route.resolve.ok with direction=reverse on connect ack and route.resolve.fail with direction=reverse plus reason=container_not_found / connect_error pre-connect. Post-connect close/error stays silent. Reuses existing event types via the new details.direction discriminator so frontend filters are unaffected. * feat(mesh): add peer-to-central callback dial path (closes R1-A2) Closes the architectural gap where proxy-mode mesh peers could not re-establish their tunnel to central after any non-idle bridge teardown (idle close, network blip, central restart, peer reboot). Central remains the hub for the data plane; the change is purely about WS initiation. Symmetric WS initiation, asymmetric protocol roles. Central retains PilotTunnelBridge ownership; peer retains TcpStreamSwitchboard + reverseDialer ownership. Central bootstraps callback credentials over the first authenticated central-initiated mesh tunnel via a one-shot mesh_handshake JSON frame; peer persists the material in a new mesh_centrals SQLite table and dials central's new /api/mesh/proxy-tunnel-from-peer endpoint when local cross-node traffic needs a bridge and none is live. Mesh_tunnel JWT (HS256, signed with auth_jwt_secret) carries scope, audience, issuer (central instance id), peer api_token fingerprint, kid. Validation on inbound peer dial: algorithm pin, signature, scope, audience, instance, time bounds, node existence and mode, fingerprint match. Failures return HTTP 401 with a machine-readable reason; peer routes the response per a clear-vs-keep cache matrix. Triggers proactive bootstrap on mesh-enable and api_token rotation; central startup fans out to mesh-enabled proxy-mode nodes with mesh_stacks rows (throttled, fire-and-forget). Reactive redial on non-idle bridge loss. Capability-gated handshake send (mesh_proxy_callback_bootstrap) makes the upgrade path safe against older peers in mixed-version fleets. Adds peer-side /api/system/pilot-tunnels centralCallback diag block, bounded counter metrics for bootstrap and dial events. SENCHO_PRIMARY_URL preflight warning when unset on a central with mesh-enabled proxy nodes. Tested with unit suites for the validation chain, registry, manager, and both dialers; integration tests for bootstrap E2E (asserts protocol-role invariant), api_token rotation, instance id change, version skew, and pilot-mode regression. * fix(mesh): green CI on the symmetric callback branch Two independent CI failures, both surgical: 1. Backend tests (11 fails): four mesh test files called setupTestDb in beforeEach. setupTestDb does not reset the DatabaseService singleton, so the per-test afterEach rm of the previous tmpdir left the singleton connection pointing at a deleted file. The next beforeEach's line-55 write threw SQLITE_READONLY_DBMOVED on Linux. Windows file-lock semantics hid this locally. Hoist setupTestDb / cleanupTestDb to file-scope beforeAll / afterAll; per-test state resets stay in beforeEach. Matches the convention in the eight mesh test files that already pass. 2. CodeQL (4 high alerts): js/insufficient-password-hash flagged sha256(api_token) at four sites. The api_token is a 256-bit opaque bearer (sen_sk_-prefixed), not a human password; sha256 is the correct fingerprint primitive for binding the mesh_tunnel JWT to a specific token. Add the two production files plus the two test files that mint the fingerprint to the existing path-scoped query-filter for that rule. * fix(mesh): drop unused afterEach import and revert dead codeql config ESLint flagged afterEach as unused in mesh-central-registry.test.ts:1 after the previous commit hoisted setup/teardown to file-scope beforeAll/afterAll. Remove from the vitest import line. Revert the codeql-config.yml additions from the previous commit. The paths: sub-key under query-filters > exclude is not a documented CodeQL feature and silently no-ops. The four js/insufficient-password-hash alerts on api_token fingerprinting are tracked as dismissed false positives in the GitHub Security tab rather than via dead config.
This commit is contained in:
@@ -149,6 +149,7 @@ export interface MeshTunnelHandle {
|
||||
* stripping/injection, and license-tier propagation all work unchanged.
|
||||
*/
|
||||
export class PilotTunnelBridge extends EventEmitter implements MeshTunnelHandle {
|
||||
private readonly nodeId: number;
|
||||
private readonly tunnelWs: WebSocket;
|
||||
private readonly loopback: HttpServer;
|
||||
private readonly wsUpgradeServer: WebSocketServer;
|
||||
@@ -162,8 +163,9 @@ export class PilotTunnelBridge extends EventEmitter implements MeshTunnelHandle
|
||||
private drainTimer?: NodeJS.Timeout;
|
||||
private closed = false;
|
||||
|
||||
constructor(_nodeId: number, tunnelWs: WebSocket) {
|
||||
constructor(nodeId: number, tunnelWs: WebSocket) {
|
||||
super();
|
||||
this.nodeId = nodeId;
|
||||
this.tunnelWs = tunnelWs;
|
||||
this.loopback = http.createServer();
|
||||
this.wsUpgradeServer = new WebSocketServer({ noServer: true });
|
||||
@@ -782,8 +784,26 @@ export class PilotTunnelBridge extends EventEmitter implements MeshTunnelHandle
|
||||
|
||||
private async acceptReverseLocal(s: number, target: { stack: string; service: string; port: number }): Promise<void> {
|
||||
const { MeshService } = await import('./MeshService');
|
||||
const ip = await MeshService.getInstance().resolveContainerIp({ stack: target.stack, service: target.service });
|
||||
const meshSvc = MeshService.getInstance();
|
||||
// Shared discriminator + target metadata so the Routing tab can
|
||||
// separate peer-to-central (reverse) dispatch from central-to-peer
|
||||
// (forward) dispatch when both flow through the same activity feed.
|
||||
const baseDetails = {
|
||||
direction: 'reverse' as const,
|
||||
streamId: s,
|
||||
targetStack: target.stack,
|
||||
targetService: target.service,
|
||||
targetPort: target.port,
|
||||
peerNodeId: this.nodeId,
|
||||
};
|
||||
const ip = await meshSvc.resolveContainerIp({ stack: target.stack, service: target.service });
|
||||
if (!ip) {
|
||||
meshSvc.logActivity({
|
||||
source: 'mesh', level: 'error', type: 'route.resolve.fail',
|
||||
nodeId: this.nodeId,
|
||||
message: `reverse dial failed: container ${target.stack}/${target.service} not found`,
|
||||
details: { ...baseDetails, reason: 'container_not_found' },
|
||||
});
|
||||
this.sendJson({ t: 'tcp_open_ack', s, ok: false, err: 'no_target' });
|
||||
return;
|
||||
}
|
||||
@@ -802,14 +822,26 @@ export class PilotTunnelBridge extends EventEmitter implements MeshTunnelHandle
|
||||
// Pre-connect failure: ack-fail and drop. The handler is removed in
|
||||
// 'connect' below so post-connect errors fall through to the
|
||||
// mid-stream teardown path instead of double-firing.
|
||||
const onPreConnectError = () => {
|
||||
const onPreConnectError = (err?: Error) => {
|
||||
if (!this.streams.has(s)) return;
|
||||
this.streams.delete(s);
|
||||
meshSvc.logActivity({
|
||||
source: 'mesh', level: 'error', type: 'route.resolve.fail',
|
||||
nodeId: this.nodeId,
|
||||
message: `reverse dial failed pre-connect: ${err?.message ?? 'socket error'}`,
|
||||
details: { ...baseDetails, reason: 'connect_error' },
|
||||
});
|
||||
this.sendJson({ t: 'tcp_open_ack', s, ok: false, err: 'unreachable' });
|
||||
};
|
||||
socket.once('error', onPreConnectError);
|
||||
socket.once('connect', () => {
|
||||
socket.off('error', onPreConnectError);
|
||||
meshSvc.logActivity({
|
||||
source: 'mesh', level: 'info', type: 'route.resolve.ok',
|
||||
nodeId: this.nodeId,
|
||||
message: `reverse dial ok: ${target.stack}/${target.service}:${target.port}`,
|
||||
details: baseDetails,
|
||||
});
|
||||
this.sendJson({ t: 'tcp_open_ack', s, ok: true });
|
||||
socket.on('data', (chunk: Buffer) => {
|
||||
const cur = this.streams.get(s);
|
||||
|
||||
Reference in New Issue
Block a user