mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 14:33:19 +00:00
cf618dd866
* 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.
120 lines
4.0 KiB
TypeScript
120 lines
4.0 KiB
TypeScript
import { EventEmitter } from 'events';
|
|
import { DatabaseService } from './DatabaseService';
|
|
|
|
export interface MeshCentralMaterial {
|
|
centralInstanceId: string;
|
|
centralApiUrl: string;
|
|
callbackJwt: string;
|
|
jwtIssuedAt: number;
|
|
jwtExpiresAt: number;
|
|
}
|
|
|
|
export interface MeshCentralRow extends MeshCentralMaterial {
|
|
lastBootstrapAt: number;
|
|
lastUsedAt: number | null;
|
|
lastRejectedAt: number | null;
|
|
lastRejectReason: string | null;
|
|
}
|
|
|
|
export class MeshCentralRegistry extends EventEmitter {
|
|
private static instance: MeshCentralRegistry | null = null;
|
|
private warnedMultiRow = false;
|
|
|
|
private constructor() { super(); }
|
|
|
|
public static getInstance(): MeshCentralRegistry {
|
|
if (!this.instance) this.instance = new MeshCentralRegistry();
|
|
return this.instance;
|
|
}
|
|
|
|
public static resetForTest(): void {
|
|
this.instance = null;
|
|
}
|
|
|
|
public upsert(material: MeshCentralMaterial): void {
|
|
const db = DatabaseService.getInstance().getDb();
|
|
const prior = this.getActive();
|
|
db.prepare(`
|
|
INSERT INTO mesh_centrals (
|
|
central_instance_id, central_api_url, callback_jwt,
|
|
jwt_issued_at, jwt_expires_at, last_bootstrap_at,
|
|
last_used_at, last_rejected_at, last_reject_reason
|
|
) VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, NULL)
|
|
ON CONFLICT(central_instance_id) DO UPDATE SET
|
|
central_api_url = excluded.central_api_url,
|
|
callback_jwt = excluded.callback_jwt,
|
|
jwt_issued_at = excluded.jwt_issued_at,
|
|
jwt_expires_at = excluded.jwt_expires_at,
|
|
last_bootstrap_at = excluded.last_bootstrap_at
|
|
`).run(
|
|
material.centralInstanceId,
|
|
material.centralApiUrl,
|
|
material.callbackJwt,
|
|
material.jwtIssuedAt,
|
|
material.jwtExpiresAt,
|
|
Date.now(),
|
|
);
|
|
|
|
const isInstanceChange = prior && prior.centralInstanceId !== material.centralInstanceId;
|
|
if (isInstanceChange) {
|
|
this.emit('central-instance-changed', {
|
|
previousInstanceId: prior!.centralInstanceId,
|
|
newInstanceId: material.centralInstanceId,
|
|
});
|
|
}
|
|
this.emit('central-bootstrap', { centralInstanceId: material.centralInstanceId });
|
|
}
|
|
|
|
public getActive(): MeshCentralRow | null {
|
|
const db = DatabaseService.getInstance().getDb();
|
|
const rows = db.prepare(`
|
|
SELECT * FROM mesh_centrals ORDER BY last_bootstrap_at DESC
|
|
`).all() as Array<{
|
|
central_instance_id: string;
|
|
central_api_url: string;
|
|
callback_jwt: string;
|
|
jwt_issued_at: number;
|
|
jwt_expires_at: number;
|
|
last_bootstrap_at: number;
|
|
last_used_at: number | null;
|
|
last_rejected_at: number | null;
|
|
last_reject_reason: string | null;
|
|
}>;
|
|
if (rows.length === 0) return null;
|
|
if (rows.length > 1 && !this.warnedMultiRow) {
|
|
console.warn(`[MeshCentralRegistry] multiple central rows present (${rows.length}), multi-central unsupported in v0.79`);
|
|
this.warnedMultiRow = true;
|
|
}
|
|
const r = rows[0];
|
|
return {
|
|
centralInstanceId: r.central_instance_id,
|
|
centralApiUrl: r.central_api_url,
|
|
callbackJwt: r.callback_jwt,
|
|
jwtIssuedAt: r.jwt_issued_at,
|
|
jwtExpiresAt: r.jwt_expires_at,
|
|
lastBootstrapAt: r.last_bootstrap_at,
|
|
lastUsedAt: r.last_used_at,
|
|
lastRejectedAt: r.last_rejected_at,
|
|
lastRejectReason: r.last_reject_reason,
|
|
};
|
|
}
|
|
|
|
public clearForInstance(centralInstanceId: string): void {
|
|
DatabaseService.getInstance().getDb()
|
|
.prepare(`DELETE FROM mesh_centrals WHERE central_instance_id = ?`)
|
|
.run(centralInstanceId);
|
|
}
|
|
|
|
public markUsed(centralInstanceId: string): void {
|
|
DatabaseService.getInstance().getDb()
|
|
.prepare(`UPDATE mesh_centrals SET last_used_at = ? WHERE central_instance_id = ?`)
|
|
.run(Date.now(), centralInstanceId);
|
|
}
|
|
|
|
public markRejected(centralInstanceId: string, reason: string): void {
|
|
DatabaseService.getInstance().getDb()
|
|
.prepare(`UPDATE mesh_centrals SET last_rejected_at = ?, last_reject_reason = ? WHERE central_instance_id = ?`)
|
|
.run(Date.now(), reason, centralInstanceId);
|
|
}
|
|
}
|