fix(mesh): gate Trigger 2 re-bootstrap on actual api_token change (#1076)

The PUT /api/nodes/:id handler closed and re-dialed the mesh callback
bridge on every save that included api_token in the body, even when the
token was unchanged. The frontend always sends the full formData on
Save, so renames and compose_dir edits against a mesh-enabled proxy
remote produced a wasted closeBridge + ensureBridge round-trip and a
spurious manager_rejected entry in the activity log.

Gate the re-bootstrap on a real value diff against the persisted token.
Adds an existing-node lookup (returns 404 on missing id, which the
handler previously lacked) so the comparison has the pre-update value.
Reorders the guards so resource-not-found beats payload validation.

Adds one integration test for the same-token path; the existing three
trigger-2 cases continue to assert close + ensure firing on a real
rotation, no-token-in-payload, and mesh-disabled.
This commit is contained in:
Anso
2026-05-17 01:48:02 -04:00
committed by GitHub
parent 7e3e22138b
commit 9aaa8573c0
2 changed files with 33 additions and 4 deletions
@@ -174,4 +174,24 @@ describe('Trigger 2: api_token rotation forces re-bootstrap', () => {
expect(closeSpy).not.toHaveBeenCalled();
expect(ensureSpy).not.toHaveBeenCalled();
});
it('does not fire when api_token in payload equals the current value', async () => {
const closeSpy = vi.spyOn(MeshProxyTunnelDialer.getInstance(), 'closeBridge');
const ensureSpy = vi.spyOn(MeshProxyTunnelDialer.getInstance(), 'ensureBridge')
.mockResolvedValue(null);
const nodeId = seedProxyNode(true);
const existingToken = DatabaseService.getInstance().getNode(nodeId)?.api_token;
expect(existingToken).toBeTruthy();
const res = await request(app)
.put(`/api/nodes/${nodeId}`)
.set('Authorization', authHeader)
.send({ name: `renamed-${uniqueSuffix()}`, api_token: existingToken });
expect(res.status).toBe(200);
await new Promise((r) => setImmediate(r));
expect(closeSpy).not.toHaveBeenCalled();
expect(ensureSpy).not.toHaveBeenCalled();
});
});
+13 -4
View File
@@ -224,6 +224,11 @@ nodesRouter.put('/:id', async (req: Request, res: Response) => {
const id = parseInt(nodeId);
const updates = req.body;
const existingNode = DatabaseService.getInstance().getNode(id);
if (!existingNode) {
return res.status(404).json({ error: 'Node not found' });
}
if (updates.api_url !== undefined && updates.api_url !== '') {
const urlCheck = isValidRemoteUrl(updates.api_url);
if (!urlCheck.valid) {
@@ -239,11 +244,15 @@ nodesRouter.put('/:id', async (req: Request, res: Response) => {
// Trigger 2: if the api_token was rotated on a mesh-enabled proxy-mode
// remote, close the existing callback bridge and re-dial. The next
// ensureBridge mints a JWT with the fresh token fingerprint so the
// remote's tunnel auth gate accepts the upgrade.
if (typeof updates.api_token === 'string') {
const node = DatabaseService.getInstance().getNode(id);
// remote's tunnel auth gate accepts the upgrade. Gate on actual value
// change so a Save that only edits name / compose_dir does not cycle
// the bridge (the frontend sends the full formData on every Save).
const tokenChanged =
typeof updates.api_token === 'string' &&
updates.api_token !== existingNode.api_token;
if (tokenChanged) {
const meshEnabled = DatabaseService.getInstance().getNodeMeshEnabled(id);
if (node && node.type === 'remote' && node.mode === 'proxy' && meshEnabled) {
if (existingNode.type === 'remote' && existingNode.mode === 'proxy' && meshEnabled) {
MeshProxyTunnelDialer.getInstance().closeBridge(id, 'peer token rotated');
void MeshProxyTunnelDialer.getInstance().ensureBridge(id).catch((err) => {
console.warn(`[Mesh] proactive re-bootstrap on token rotation failed for node ${id}: ${(err as Error).message}`);