mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 19:26:56 +00:00
fix(mesh): persist rotated handshake when peer bridge owns reverse-dialer slot (#1073)
When the proxy-mode mesh peer already holds an open peer-initiated callback bridge to central, central's Trigger 2 dial (api_token rotation) arrives at the peer's `/api/mesh/proxy-tunnel` handler with a fresh `mesh_handshake` frame. The peer's `attachSwitchboard` refuses the new WS at the reverse-dialer CAS swap, but it was also closing the connection before its `'message'` listener had been attached, so the handshake frame went unread and the peer's cached callback JWT stayed at the pre-rotation fingerprint until a central restart. Restructures `attachSwitchboard` to attach the `'message'`, `'close'`, and `'error'` listeners synchronously after the switchboard is created, before the async MeshService import and the CAS swap run. On the CAS-fail branch, holds the WS open for up to FIRST_FRAME_WAIT_MS (30ms, well above localhost RTT and below operator perception) before sending the 1013 close, so an in-flight first frame lands in `onMessage` and the bootstrap material is persisted via `MeshCentralRegistry.upsert` regardless of bridge fate. The success path is untouched; reverseDialer install timing on accepted dials is unchanged. Adds a regression test that pre-installs a stub reverse dialer (simulating a peer-initiated bridge owning the slot), sends a `mesh_handshake` frame on the new WS, and asserts the registry receives the rotated material before the 1013 close fires.
This commit is contained in:
@@ -184,4 +184,44 @@ describe('meshProxyTunnel first-frame state machine', () => {
|
||||
await srv.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('persists mesh_handshake material even when reverse-dialer CAS rejects the bridge', async () => {
|
||||
// Simulate a peer-initiated callback bridge that already owns the
|
||||
// reverseDialer slot. Central's Trigger-2 dial then arrives with a
|
||||
// rotated mesh_handshake frame; the CAS swap must refuse the new
|
||||
// bridge, but the bootstrap material has to land on the peer
|
||||
// anyway so subsequent peer-initiated callbacks authenticate with
|
||||
// the rotated JWT.
|
||||
MeshService.getInstance().setReverseDialer({
|
||||
openMeshTcpStream: () => null,
|
||||
});
|
||||
|
||||
const srv = await startServer();
|
||||
try {
|
||||
const ws = await dialTunnel(srv.port, '?nodeId=7');
|
||||
|
||||
const expiresAt = Math.floor(Date.now() / 1000) + 7200;
|
||||
const closeInfo = new Promise<{ code: number }>((resolve) => {
|
||||
ws.once('close', (code) => resolve({ code }));
|
||||
});
|
||||
ws.send(makeHandshakeFrame({
|
||||
centralInstanceId: 'rotation-central-id',
|
||||
centralApiUrl: 'https://central.example.test',
|
||||
meshTunnelJwt: 'rotation.jwt.value',
|
||||
jwtExpiresAt: expiresAt,
|
||||
}));
|
||||
|
||||
const info = await closeInfo;
|
||||
expect(info.code).toBe(1013);
|
||||
|
||||
const row = MeshCentralRegistry.getInstance().getActive();
|
||||
expect(row).not.toBeNull();
|
||||
expect(row?.centralInstanceId).toBe('rotation-central-id');
|
||||
expect(row?.centralApiUrl).toBe('https://central.example.test');
|
||||
expect(row?.callbackJwt).toBe('rotation.jwt.value');
|
||||
expect(row?.jwtExpiresAt).toBe(expiresAt);
|
||||
} finally {
|
||||
await srv.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user