feat(federation): detect peer reset on needs_attention peers (§4.1)

A reset peer can reach needs_attention via the auth-failure path (HTTP up,
401/403 from a new incarnation crossing AUTH_FAILURE_THRESHOLD) without ever
passing through unreachable, so the unreachable-only recovery probe never
observes its epoch change and no reset journal is created — leaving a later
manual Re-peer with nothing to heal.

Add detectResetOnNeedsAttentionPeers() to the 15-minute health-check tick:
probe needs_attention peers with a non-null baseline (excluding those already
peer_reset_detected) and call markPeerReset on an observed epoch mismatch.
Detection only — never recovers a needs_attention peer to active; baseline
(peer_instance_id) and hmac_secret untouched.
This commit is contained in:
Jannis Braun
2026-07-02 00:34:33 +02:00
parent 769ed64314
commit d8fec00905
4 changed files with 159 additions and 4 deletions
@@ -150,4 +150,88 @@ describe('federationRecovery primitives', () => {
// A reset peer must NOT be recovered to active.
expect(onPeerActivated).not.toHaveBeenCalled();
});
// ── detectResetOnNeedsAttentionPeers (design §4.1) ─────────────────────────
// Closes the auth-failure sub-case: a reset peer whose HTTP is up (returning
// 401/403 because the new incarnation has no peer row for us) crosses
// AUTH_FAILURE_THRESHOLD and lands in `needs_attention` WITHOUT ever passing
// through `unreachable`, so the unreachable-only recovery probe never observes
// its epoch change. This pass probes those peers too — detection ONLY, never a
// recover-to-active.
function seedNeedsAttention(id: string, reason: string | null, peerInstanceId: string | null): void {
testDb.insert(schema.federationPeers).values({
id, origin: 'https://peer.example', hmacSecret: 'secret',
status: 'needs_attention', needsAttentionReason: reason,
peerInstanceId, consecutiveFailures: 0,
lastSyncedAt: Date.now(), createdAt: Date.now(),
}).run();
}
it('detectResetOnNeedsAttentionPeers flags an auth-failure peer whose epoch changed (detection only)', async () => {
seedNeedsAttention('peer-na', 'auth_failures', 'E0');
// A pure replicated stub belonging to the dead incarnation (bare-domain home).
testDb.insert(schema.users).values({
id: 'stub-1', username: 'carol', displayName: 'carol',
passwordHash: '!federation-replicated', homeInstance: 'peer.example',
createdAt: Date.now(),
}).run();
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{"instanceId":"E1"}', { status: 200 }));
const { detectResetOnNeedsAttentionPeers } = await import('./federationRecovery.js');
await detectResetOnNeedsAttentionPeers();
const row = testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.id, 'peer-na')).get()!;
expect(row.status).toBe('needs_attention'); // NOT flipped to active
expect(row.needsAttentionReason).toBe('peer_reset_detected');
expect(row.observedPeerInstanceId).toBe('E1'); // observed epoch recorded
expect(row.peerInstanceId).toBe('E0'); // trusted baseline untouched
expect(row.hmacSecret).toBe('secret'); // secret untouched
const journal = testDb.select().from(schema.federationResetEvents)
.where(eq(schema.federationResetEvents.origin, 'https://peer.example')).get()!;
expect(journal.deadEpoch).toBe('E0');
expect(journal.resolvedAt).toBeNull();
const stub = testDb.select().from(schema.users)
.where(eq(schema.users.id, 'stub-1')).get()!;
expect(stub.federationHealPending).toBe(1); // dead incarnation snapshotted
expect(onPeerActivated).not.toHaveBeenCalled(); // detection only
});
it('detectResetOnNeedsAttentionPeers is a no-op when the probed epoch matches the baseline', async () => {
seedNeedsAttention('peer-same', 'auth_failures', 'E0');
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{"instanceId":"E0"}', { status: 200 }));
const { detectResetOnNeedsAttentionPeers } = await import('./federationRecovery.js');
await detectResetOnNeedsAttentionPeers();
const row = testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.id, 'peer-same')).get()!;
expect(row.status).toBe('needs_attention');
expect(row.needsAttentionReason).toBe('auth_failures'); // unchanged
expect(testDb.select().from(schema.federationResetEvents).all()).toHaveLength(0);
expect(onPeerActivated).not.toHaveBeenCalled();
});
it('detectResetOnNeedsAttentionPeers skips peers already flagged peer_reset_detected (no probe)', async () => {
seedNeedsAttention('peer-done', 'peer_reset_detected', 'E0');
const spy = vi.spyOn(globalThis, 'fetch');
const { detectResetOnNeedsAttentionPeers } = await import('./federationRecovery.js');
await detectResetOnNeedsAttentionPeers();
expect(spy).not.toHaveBeenCalled(); // already journaled — not re-probed
});
it('detectResetOnNeedsAttentionPeers skips peers with a null baseline (nothing to compare)', async () => {
seedNeedsAttention('peer-nobase', 'auth_failures', null);
const spy = vi.spyOn(globalThis, 'fetch');
const { detectResetOnNeedsAttentionPeers } = await import('./federationRecovery.js');
await detectResetOnNeedsAttentionPeers();
expect(spy).not.toHaveBeenCalled(); // no trusted baseline → cannot detect a change
});
});
@@ -1,6 +1,6 @@
import { getDb } from '../db/index.js';
import * as schema from '../db/schema.js';
import { eq } from 'drizzle-orm';
import { and, eq, isNotNull, isNull, ne, or } from 'drizzle-orm';
import { onPeerActivated } from './federationPeerActivation.js';
import { markPeerReset } from './federationReset.js';
@@ -103,3 +103,62 @@ export async function recoverOrDetectReset(
await markPeerRecovered(peer.id);
return 'recovered';
}
/**
* Detection-only epoch probe for peers already parked in `needs_attention`
* (design §4.1). Runs on the 15-minute health-check tick.
*
* The gap this closes: a reset peer can reach `needs_attention` via the
* AUTH-FAILURE path — its HTTP is up and returning 401/403 because the new
* incarnation has no peer row for us, so `consecutive_auth_failures` crosses
* `AUTH_FAILURE_THRESHOLD` — WITHOUT ever transitioning through `unreachable`.
* The `unreachable`-only recovery probe (`processRecoveryTick`) therefore never
* sees such a peer, so its epoch change is never observed and no
* `federation_reset_events` journal is ever created. A later manual admin
* Re-peer would then run `healResetIncarnation` with no journal row → no heal →
* the stale-friendship / split-DM split-brain persists for this sub-case. This
* pass probes those peers so the journal is created at detection time.
*
* **Detection only — never a recover-to-active.** Unlike `recoverOrDetectReset`,
* this NEVER flips a peer to `active`: a `needs_attention` peer's HMAC secret is
* desynced, so a matching or unknown epoch means "still broken, still needs an
* admin," not "recovered." On an observed epoch mismatch it calls
* `markPeerReset` (snapshot + journal + admin notify) and nothing else; the
* trusted baseline (`peer_instance_id`) and `hmac_secret` are left untouched.
* On a match / unknown / unreachable result it does nothing at all.
*
* Candidate set (deliberately small — one `/instance/info` GET per peer per
* tick): `status='needs_attention'` AND `peer_instance_id IS NOT NULL` (a null
* baseline has nothing to compare against) AND the reason is not already
* `peer_reset_detected` (those peers already carry a journal — re-probing would
* be wasted work). Peers whose reason is `auth_failures` or NULL qualify.
*/
export async function detectResetOnNeedsAttentionPeers(signal?: AbortSignal): Promise<void> {
const db = getDb();
const peers = db
.select({
id: schema.federationPeers.id,
origin: schema.federationPeers.origin,
peerInstanceId: schema.federationPeers.peerInstanceId,
})
.from(schema.federationPeers)
.where(and(
eq(schema.federationPeers.status, 'needs_attention'),
isNotNull(schema.federationPeers.peerInstanceId),
or(
isNull(schema.federationPeers.needsAttentionReason),
ne(schema.federationPeers.needsAttentionReason, 'peer_reset_detected'),
),
))
.all();
for (const peer of peers) {
const result = await probePeerReachable(peer.origin, signal);
// Detection fires ONLY on a reachable peer advertising a non-null epoch that
// differs from the trusted baseline. Everything else (unreachable, unknown
// epoch, or a matching epoch) is a no-op — no recover-to-active from here.
if (result.reachable && result.instanceId && peer.peerInstanceId && result.instanceId !== peer.peerInstanceId) {
markPeerReset(peer.id, peer.origin, peer.peerInstanceId, result.instanceId);
}
}
}
+12 -1
View File
@@ -12,7 +12,7 @@ import { connectionManager } from '../ws/handler.js';
import { generateThumbnail } from './thumbnail.js';
import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent } from '@backspace/shared';
import { startupBootstrapSync, onPeerDeactivated } from './federationPeerActivation.js';
import { probePeerReachable, recoverOrDetectReset } from './federationRecovery.js';
import { probePeerReachable, recoverOrDetectReset, detectResetOnNeedsAttentionPeers } from './federationRecovery.js';
import { backfillReplicatedProfileAssets } from '../routes/federation.js';
import { invokePermanentFailureCallback } from './federationRollback.js';
import { refreshPeerEpochs, getInstanceId } from './federationEpoch.js';
@@ -1180,6 +1180,17 @@ async function processHealthCheckTick(): Promise<void> {
// relay/user activity. Best-effort — a failed fetch is a benign no-op retried
// next tick, so it never disturbs the rest of the health-check work.
await refreshPeerEpochs().catch(() => {});
// ── Reset detection for needs_attention peers (design §4.1) ─────────────────
// A reset peer can land in `needs_attention` via the auth-failure path (HTTP
// up, 401/403 from a new incarnation) WITHOUT ever passing through
// `unreachable`, so the unreachable-only recovery probe never observes its
// epoch change. Probe those peers here so a reset journal is created at
// detection time (otherwise a later manual Re-peer heals nothing). Detection
// ONLY — never flips a needs_attention peer to active. Best-effort: a failure
// is a benign no-op retried next tick and must not disturb the rest of the tick.
// No shared abort signal — probePeerReachable carries its own 10s timeout.
await detectResetOnNeedsAttentionPeers().catch(() => {});
}
// ─── Federated Call Health Sweep ────────────────────────────────────────────