mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 12:17:34 +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.
85 lines
3.5 KiB
TypeScript
85 lines
3.5 KiB
TypeScript
/**
|
|
* Central instance id rotation invariant.
|
|
*
|
|
* `MeshCentralRegistry.upsert` is the single source of truth for the
|
|
* locally-cached callback material. When central regenerates its
|
|
* `instance_id` (factory reset, DB swap, container rebuild without volume),
|
|
* the next bootstrap frame arrives with a different `centralInstanceId`. The
|
|
* registry MUST:
|
|
* - persist the new row,
|
|
* - emit a single `central-instance-changed` event so subscribers (loud
|
|
* log, dialer reset) can react.
|
|
*
|
|
* Failing this invariant means the peer would keep dialing back with the
|
|
* old JWT (rejected with `instance_mismatch`) without anyone noticing.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
|
import { MeshCentralRegistry } from '../services/MeshCentralRegistry';
|
|
import { DatabaseService } from '../services/DatabaseService';
|
|
|
|
let tmpDir: string;
|
|
beforeAll(async () => { tmpDir = await setupTestDb(); });
|
|
afterAll(() => cleanupTestDb(tmpDir));
|
|
|
|
describe('Central instance id change', () => {
|
|
beforeEach(() => {
|
|
MeshCentralRegistry.resetForTest();
|
|
// Per-test cleanup of the mesh_centrals table. The DB lives once per
|
|
// file (beforeAll); resetting setupTestDb per test would invalidate
|
|
// the DatabaseService singleton's connection on Linux.
|
|
DatabaseService.getInstance().getDb().prepare('DELETE FROM mesh_centrals').run();
|
|
});
|
|
|
|
it('drops the old row and accepts the new one with a central-instance-changed event', () => {
|
|
const reg = MeshCentralRegistry.getInstance();
|
|
reg.upsert({
|
|
centralInstanceId: 'old-uuid',
|
|
centralApiUrl: 'https://central.example.com',
|
|
callbackJwt: 'jwt-old',
|
|
jwtIssuedAt: 1,
|
|
jwtExpiresAt: 999999,
|
|
});
|
|
const events: Array<{ previousInstanceId: string; newInstanceId: string }> = [];
|
|
reg.on('central-instance-changed', (e: { previousInstanceId: string; newInstanceId: string }) => events.push(e));
|
|
|
|
reg.upsert({
|
|
centralInstanceId: 'new-uuid',
|
|
centralApiUrl: 'https://central.example.com',
|
|
callbackJwt: 'jwt-new',
|
|
jwtIssuedAt: 2,
|
|
jwtExpiresAt: 999999,
|
|
});
|
|
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0].previousInstanceId).toBe('old-uuid');
|
|
expect(events[0].newInstanceId).toBe('new-uuid');
|
|
// getActive returns the most-recently-bootstrapped row.
|
|
expect(reg.getActive()?.centralInstanceId).toBe('new-uuid');
|
|
expect(reg.getActive()?.callbackJwt).toBe('jwt-new');
|
|
});
|
|
|
|
it('an upsert with the same instance id does NOT emit central-instance-changed', () => {
|
|
const reg = MeshCentralRegistry.getInstance();
|
|
reg.upsert({
|
|
centralInstanceId: 'stable-uuid',
|
|
centralApiUrl: 'https://central.example.com',
|
|
callbackJwt: 'jwt-1',
|
|
jwtIssuedAt: 1,
|
|
jwtExpiresAt: 999999,
|
|
});
|
|
const events: Array<unknown> = [];
|
|
reg.on('central-instance-changed', (e: unknown) => events.push(e));
|
|
reg.upsert({
|
|
centralInstanceId: 'stable-uuid',
|
|
centralApiUrl: 'https://central.example.com',
|
|
callbackJwt: 'jwt-2-rotated',
|
|
jwtIssuedAt: 2,
|
|
jwtExpiresAt: 999999,
|
|
});
|
|
expect(events).toHaveLength(0);
|
|
// Same id, the JWT material is overwritten in place.
|
|
expect(reg.getActive()?.callbackJwt).toBe('jwt-2-rotated');
|
|
});
|
|
});
|