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.
This commit is contained in:
Jannis Braun
2026-03-31 05:20:16 +02:00
parent 37fc52d85b
commit 2cac39a460
4 changed files with 88 additions and 99 deletions
+6 -11
View File
@@ -675,8 +675,8 @@ Triggered once at server startup (async, non-blocking). Finds peers with `status
**For each unsynced peer:** **For each unsynced peer:**
1. **DM sync pass:** Paginate through `POST {peerOrigin}/api/federation/sync` with `sinceTimestamp = 0`, `limit = 100` 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 2. **Direct processing:** Call `processRelayEvents()` to process received events in-process (no HTTP round-trip)
3. **Friend sync pass:** Same pagination with `contextType: 'friend'` 3. **Friend sync pass:** Same pagination with `contextType: 'friend'`, also processed via `processRelayEvents()`
4. Update `lastSyncedAt = Date.now()` after completion 4. Update `lastSyncedAt = Date.now()` after completion
5. On failure: don't update `lastSyncedAt` -- retried on next startup 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'` - Queries `federation_mutation_log WHERE context_type = 'friend'`
- Returns stored payloads directly (friend events carry their complete data) - 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: 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.
- 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.
--- ---
@@ -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 | | 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 | | 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 ### 6. DM Calls Do Not Work over Federation
+2 -2
View File
@@ -373,8 +373,8 @@ When a new peer is established (`federation_peers.lastSyncedAt = 0`), the federa
**Flow (`federationWorker.ts:runInitialSyncForNewPeers`):** **Flow (`federationWorker.ts:runInitialSyncForNewPeers`):**
1. Query all active peers with `lastSyncedAt = 0` 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 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 relay-to-self pattern 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 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. 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.
+75 -58
View File
@@ -419,64 +419,7 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
} }
// 3. Process each event // 3. Process each event
const accepted: string[] = []; const { accepted, rejected } = processRelayEvents(body.events, sourceInstance, peer.origin, db);
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' });
}
}
// 4. Update peer status // 4. Update peer status
db.update(schema.federationPeers) db.update(schema.federationPeers)
@@ -865,6 +808,80 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
); );
} }
// ─── 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<typeof getDb>,
): { 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 ────────────────────────────────────────────────── // ─── Relay Event Processors ──────────────────────────────────────────────────
/** /**
+5 -28
View File
@@ -9,6 +9,7 @@ import { generateSnowflake } from './snowflake.js';
import { getDmMessageWithUser } from '../routes/dm.js'; import { getDmMessageWithUser } from '../routes/dm.js';
import { connectionManager } from '../ws/handler.js'; import { connectionManager } from '../ws/handler.js';
import { generateThumbnail } from './thumbnail.js'; import { generateThumbnail } from './thumbnail.js';
import { processRelayEvents } from '../routes/federation.js';
import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent } from '@backspace/shared'; import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent } from '@backspace/shared';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
@@ -781,21 +782,8 @@ async function runInitialSyncForNewPeers(): Promise<void> {
if (data.events.length === 0) break; if (data.events.length === 0) break;
// Relay the events through our own relay endpoint logic // Process events directly — no HTTP round-trip (FED-005)
// For simplicity, POST them to ourselves processRelayEvents(data.events, peer.origin, peer.origin, db);
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),
});
totalEvents += data.events.length; totalEvents += data.events.length;
sinceTimestamp = data.checkpoint; sinceTimestamp = data.checkpoint;
@@ -825,19 +813,8 @@ async function runInitialSyncForNewPeers(): Promise<void> {
if (friendData.events.length === 0) break; if (friendData.events.length === 0) break;
const friendRelayBody = JSON.stringify({ // Process events directly — no HTTP round-trip (FED-005)
version: 1, processRelayEvents(friendData.events, peer.origin, peer.origin, db);
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),
});
totalEvents += friendData.events.length; totalEvents += friendData.events.length;
friendSinceTimestamp = friendData.checkpoint; friendSinceTimestamp = friendData.checkpoint;