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
+3 -2
View File
@@ -327,9 +327,10 @@ In a single transaction, `markPeerReset`:
**Idempotent / double-reset:** if an *unresolved* `federation_reset_events` row already exists for the origin (the peer reset again before an admin resolved the first), the original `dead_epoch` and `detected_at` are **preserved** (that is the incarnation whose users are already snapshotted) — only the summary counts are refreshed. `dead_epoch` is never overwritten on an unresolved row. A prior *resolved* reset starts a fresh journal entry.
**Detection sources (both wired in this feature):**
**Detection sources (all three wired in this feature):**
- **Inbound handshake** — `/peer/accept` landing on an `active`/`needs_attention` row (`routes/federation.ts`): before the idempotent-200 return, `if (reqInstanceId && existing.peerInstanceId && reqInstanceId !== existing.peerInstanceId) markPeerReset(...)`. The guard still returns 200 and **still does not rekey** — the anti-hijack property is preserved verbatim; detection is layered on top.
- **Reachability probe** — `probePeerReachable()` (`utils/federationRecovery.ts`) now parses `instanceId` from the `/api/instance/info` response (returning `{ reachable, instanceId }`; a missing/unparseable epoch is `null`, never an error). The shared decision helper `recoverOrDetectReset(peer, result)` — used by both the background recovery tick (`processRecoveryTick`) and the manual recheck endpoint — routes to `markPeerReset` (returning `'reset_detected'`) when the peer has a non-null `peer_instance_id` and the probed epoch differs, and **does NOT call `markPeerRecovered`**. Rationale: a genuinely reset peer's HMAC secret is desynced, so flipping it back to `active` via a reachability probe would resume relay against a dead secret. Only when the probed epoch matches the baseline (or the baseline is null / epoch unknown) does the normal recovery-to-active path run. The manual recheck endpoint returns `{ recovered: false, status: 'needs_attention' }` on `'reset_detected'`.
- **Reachability probe (`unreachable` peers)** — `probePeerReachable()` (`utils/federationRecovery.ts`) now parses `instanceId` from the `/api/instance/info` response (returning `{ reachable, instanceId }`; a missing/unparseable epoch is `null`, never an error). The shared decision helper `recoverOrDetectReset(peer, result)` — used by both the background recovery tick (`processRecoveryTick`) and the manual recheck endpoint — routes to `markPeerReset` (returning `'reset_detected'`) when the peer has a non-null `peer_instance_id` and the probed epoch differs, and **does NOT call `markPeerRecovered`**. Rationale: a genuinely reset peer's HMAC secret is desynced, so flipping it back to `active` via a reachability probe would resume relay against a dead secret. Only when the probed epoch matches the baseline (or the baseline is null / epoch unknown) does the normal recovery-to-active path run. The manual recheck endpoint returns `{ recovered: false, status: 'needs_attention' }` on `'reset_detected'`.
- **Health-tick probe (`needs_attention` peers)** — `detectResetOnNeedsAttentionPeers()` (`utils/federationRecovery.ts`), called from `processHealthCheckTick` on the 15-minute tick, closes design §4.1's remaining sub-case. A reset peer can reach `needs_attention` via the **auth-failure path** — its HTTP is up but returns 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 therefore never observes its epoch change, so no journal is ever created; a later manual Re-peer would then run `healResetIncarnation` with no journal row → no heal → the split-brain persists. This pass selects peers with `status='needs_attention'` AND `peer_instance_id IS NOT NULL` AND `needs_attention_reason` not already `peer_reset_detected` (those already carry a journal), probes each (`probePeerReachable`, one `/instance/info` GET per qualifying peer per tick), and calls `markPeerReset` on an observed epoch mismatch. **Detection only:** unlike `recoverOrDetectReset`, it NEVER flips a `needs_attention` peer to `active` (a match / unknown / unreachable result is a pure no-op) — that peer's secret is desynced and only an admin-authenticated re-peer restores trust. It touches neither `peer_instance_id` nor `hmac_secret`.
Legacy peers advertise no epoch (`instanceId` null), so detection requires a non-null observed epoch differing from a non-null stored baseline — legacy peers never trigger it, and the existing `auth_failures → needs_attention → manual Reset` path continues unchanged for them.
@@ -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 ────────────────────────────────────────────