From 2cac39a460edceb1b4d542ef73ae87416a613045 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 31 Mar 2026 05:20:16 +0200 Subject: [PATCH] fix(federation): replace DNS hairpin self-POST with direct function call (FED-005) Extract processRelayEvents() from the relay HTTP handler and call it directly in runInitialSyncForNewPeers(), eliminating the HTTP round-trip through public DNS that failed on networks without hairpin NAT. --- docs/systems/federation.md | 17 +-- docs/systems/social.md | 4 +- packages/server/src/routes/federation.ts | 133 ++++++++++-------- packages/server/src/utils/federationWorker.ts | 33 +---- 4 files changed, 88 insertions(+), 99 deletions(-) diff --git a/docs/systems/federation.md b/docs/systems/federation.md index e96ab681..47187c8d 100644 --- a/docs/systems/federation.md +++ b/docs/systems/federation.md @@ -675,8 +675,8 @@ Triggered once at server startup (async, non-blocking). Finds peers with `status **For each unsynced peer:** 1. **DM sync pass:** Paginate through `POST {peerOrigin}/api/federation/sync` with `sinceTimestamp = 0`, `limit = 100` -2. **Self-POST:** Relay received events by POSTing to `{ourOrigin}/api/federation/relay` -- this routes through the standard inbound processing -3. **Friend sync pass:** Same pagination with `contextType: 'friend'` +2. **Direct processing:** Call `processRelayEvents()` to process received events in-process (no HTTP round-trip) +3. **Friend sync pass:** Same pagination with `contextType: 'friend'`, also processed via `processRelayEvents()` 4. Update `lastSyncedAt = Date.now()` after completion 5. On failure: don't update `lastSyncedAt` -- retried on next startup @@ -706,14 +706,9 @@ HMAC-authenticated. Returns events from the `federation_mutation_log`. - Queries `federation_mutation_log WHERE context_type = 'friend'` - Returns stored payloads directly (friend events carry their complete data) -### Known Bug: DNS Hairpin Self-POST +### Relay Event Processing -`runInitialSyncForNewPeers` POSTs to `{ourOrigin}/api/federation/relay` where `ourOrigin = getOurOrigin()`. In production, `ourOrigin` is `https://{DOMAIN}`, e.g., `https://nova.ddns.net`. This means the server makes an HTTP request to itself through the public DNS and reverse proxy (Caddy). This works but: -- Adds unnecessary network round-trip latency -- Fails if DNS hairpin is not supported by the network -- Fails if the server is behind NAT without hairpin NAT configured - -A direct function call to the relay processing logic would be more robust. +The event processing logic is extracted into `processRelayEvents()` (exported from `federation.ts`), shared by both the HTTP relay endpoint and the initial sync worker. This avoids the DNS hairpin self-POST bug (FED-005) where the server would HTTP-request itself through public DNS, which failed on networks without hairpin NAT. --- @@ -852,9 +847,9 @@ The third case is the most dangerous -- it looks like the event was queued but n | Replay attacks | 15-minute timestamp window | No nonce -- valid requests can be replayed within the window | | Message content manipulation | None | A compromised peer can forge message content attributed to any user on their instance | -### 5. DNS Hairpin Self-POST Bug +### 5. ~~DNS Hairpin Self-POST Bug~~ (Fixed — FED-005) -`runInitialSyncForNewPeers` (`federationWorker.ts:792-797`) POSTs received sync events to `{ourOrigin}/api/federation/relay` via public DNS. This adds unnecessary latency and fails when DNS hairpin is not configured. The function should call the relay processing logic directly instead of making an HTTP request to itself. +Resolved. Initial sync now calls `processRelayEvents()` directly instead of self-POSTing through public DNS. ### 6. DM Calls Do Not Work over Federation diff --git a/docs/systems/social.md b/docs/systems/social.md index 220424cc..bdcde69e 100644 --- a/docs/systems/social.md +++ b/docs/systems/social.md @@ -373,8 +373,8 @@ When a new peer is established (`federation_peers.lastSyncedAt = 0`), the federa **Flow (`federationWorker.ts:runInitialSyncForNewPeers`):** 1. Query all active peers with `lastSyncedAt = 0` -2. **First pass (DM events):** Paginates through `POST /federation/sync` with no `contextType` filter (defaults to DM events), relaying each batch through the local `/api/federation/relay` endpoint -3. **Second pass (friend events):** Paginates through `POST /federation/sync` with `contextType: 'friend'`, same relay-to-self pattern +2. **First pass (DM events):** Paginates through `POST /federation/sync` with no `contextType` filter (defaults to DM events), processing each batch via `processRelayEvents()` directly +3. **Second pass (friend events):** Paginates through `POST /federation/sync` with `contextType: 'friend'`, same direct processing 4. After both passes complete, updates `lastSyncedAt = Date.now()` so the sync doesn't repeat The sync endpoint (`POST /api/federation/sync`) returns events from the `federation_mutation_log` table, which retains entries for 90 days. This means friend relationships established within the last 90 days are backfilled when a new peer connection is created. diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 2be7b842..a64c1071 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -419,64 +419,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { } // 3. Process each event - const accepted: string[] = []; - const rejected: Array<{ messageId: string; reason: string }> = []; - - for (const event of body.events) { - try { - switch (event.eventType) { - case 'create': - processCreateEvent(event, sourceInstance, peer.origin, db, accepted, rejected); - break; - case 'update': - processUpdateEvent(event, sourceInstance, db, accepted, rejected); - break; - case 'delete': - processDeleteEvent(event, sourceInstance, db, accepted, rejected); - break; - case 'reaction_add': - processReactionAddEvent(event, sourceInstance, db, accepted, rejected); - break; - case 'reaction_remove': - processReactionRemoveEvent(event, sourceInstance, db, accepted, rejected); - break; - case 'member_add': - processMemberAddEvent(event, sourceInstance, db, accepted, rejected); - break; - case 'member_remove': - processMemberRemoveEvent(event, sourceInstance, db, accepted, rejected); - break; - case 'ownership_transfer': - processOwnershipTransferEvent(event, sourceInstance, db, accepted, rejected); - break; - case 'friend_request_create': - processFriendRequestCreateEvent(event, sourceInstance, db, accepted, rejected); - break; - case 'friend_request_update': - processFriendRequestUpdateEvent(event, sourceInstance, db, accepted, rejected); - break; - case 'friend_request_cancel': - processFriendRequestCancelEvent(event, sourceInstance, db, accepted, rejected); - break; - case 'friend_add': - processFriendAddEvent(event, sourceInstance, db, accepted, rejected); - break; - case 'friend_remove': - processFriendRemoveEvent(event, sourceInstance, db, accepted, rejected); - break; - case 'file_rejected': - processFileRejectedEvent(event, sourceInstance, db, accepted, rejected); - break; - default: - rejected.push({ messageId: event.messageId, reason: 'unknown_event_type' }); - break; - } - } catch (err) { - const errMsg = err instanceof Error ? err.message : 'unknown_error'; - console.error(`[federation-relay] Error processing event ${event.messageId}:`, errMsg); - rejected.push({ messageId: event.messageId, reason: 'processing_error' }); - } - } + const { accepted, rejected } = processRelayEvents(body.events, sourceInstance, peer.origin, db); // 4. Update peer status db.update(schema.federationPeers) @@ -865,6 +808,80 @@ export async function federationRoutes(app: FastifyInstance): Promise { ); } +// ─── Relay Event Processing (shared by HTTP handler and initial sync) ──────── + +/** + * Process an array of federation relay events. Used by the HTTP relay endpoint + * and directly by the initial-sync worker (which skips the HTTP round-trip). + */ +export function processRelayEvents( + events: FederationRelayEvent[], + sourceInstance: string, + peerOrigin: string, + db: ReturnType, +): { accepted: string[]; rejected: Array<{ messageId: string; reason: string }> } { + const accepted: string[] = []; + const rejected: Array<{ messageId: string; reason: string }> = []; + + for (const event of events) { + try { + switch (event.eventType) { + case 'create': + processCreateEvent(event, sourceInstance, peerOrigin, db, accepted, rejected); + break; + case 'update': + processUpdateEvent(event, sourceInstance, db, accepted, rejected); + break; + case 'delete': + processDeleteEvent(event, sourceInstance, db, accepted, rejected); + break; + case 'reaction_add': + processReactionAddEvent(event, sourceInstance, db, accepted, rejected); + break; + case 'reaction_remove': + processReactionRemoveEvent(event, sourceInstance, db, accepted, rejected); + break; + case 'member_add': + processMemberAddEvent(event, sourceInstance, db, accepted, rejected); + break; + case 'member_remove': + processMemberRemoveEvent(event, sourceInstance, db, accepted, rejected); + break; + case 'ownership_transfer': + processOwnershipTransferEvent(event, sourceInstance, db, accepted, rejected); + break; + case 'friend_request_create': + processFriendRequestCreateEvent(event, sourceInstance, db, accepted, rejected); + break; + case 'friend_request_update': + processFriendRequestUpdateEvent(event, sourceInstance, db, accepted, rejected); + break; + case 'friend_request_cancel': + processFriendRequestCancelEvent(event, sourceInstance, db, accepted, rejected); + break; + case 'friend_add': + processFriendAddEvent(event, sourceInstance, db, accepted, rejected); + break; + case 'friend_remove': + processFriendRemoveEvent(event, sourceInstance, db, accepted, rejected); + break; + case 'file_rejected': + processFileRejectedEvent(event, sourceInstance, db, accepted, rejected); + break; + default: + rejected.push({ messageId: event.messageId, reason: 'unknown_event_type' }); + break; + } + } catch (err) { + const errMsg = err instanceof Error ? err.message : 'unknown_error'; + console.error(`[federation-relay] Error processing event ${event.messageId}:`, errMsg); + rejected.push({ messageId: event.messageId, reason: 'processing_error' }); + } + } + + return { accepted, rejected }; +} + // ─── Relay Event Processors ────────────────────────────────────────────────── /** diff --git a/packages/server/src/utils/federationWorker.ts b/packages/server/src/utils/federationWorker.ts index d5539866..88164d9c 100644 --- a/packages/server/src/utils/federationWorker.ts +++ b/packages/server/src/utils/federationWorker.ts @@ -9,6 +9,7 @@ import { generateSnowflake } from './snowflake.js'; import { getDmMessageWithUser } from '../routes/dm.js'; import { connectionManager } from '../ws/handler.js'; import { generateThumbnail } from './thumbnail.js'; +import { processRelayEvents } from '../routes/federation.js'; import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent } from '@backspace/shared'; import fs from 'node:fs'; import path from 'node:path'; @@ -781,21 +782,8 @@ async function runInitialSyncForNewPeers(): Promise { if (data.events.length === 0) break; - // Relay the events through our own relay endpoint logic - // For simplicity, POST them to ourselves - const relayBody = JSON.stringify({ - version: 1, - sourceInstance: peer.origin, - events: data.events, - }); - const relayHeaders = buildFederationHeaders(relayBody, peer.hmacSecret, peer.origin); - - await fetch(`${ourOrigin}/api/federation/relay`, { - method: 'POST', - headers: relayHeaders, - body: relayBody, - signal: AbortSignal.timeout(30_000), - }); + // Process events directly — no HTTP round-trip (FED-005) + processRelayEvents(data.events, peer.origin, peer.origin, db); totalEvents += data.events.length; sinceTimestamp = data.checkpoint; @@ -825,19 +813,8 @@ async function runInitialSyncForNewPeers(): Promise { if (friendData.events.length === 0) break; - const friendRelayBody = JSON.stringify({ - version: 1, - sourceInstance: peer.origin, - events: friendData.events, - }); - const friendRelayHeaders = buildFederationHeaders(friendRelayBody, peer.hmacSecret, peer.origin); - - await fetch(`${ourOrigin}/api/federation/relay`, { - method: 'POST', - headers: friendRelayHeaders, - body: friendRelayBody, - signal: AbortSignal.timeout(30_000), - }); + // Process events directly — no HTTP round-trip (FED-005) + processRelayEvents(friendData.events, peer.origin, peer.origin, db); totalEvents += friendData.events.length; friendSinceTimestamp = friendData.checkpoint;