From 051646763af5de42ec961b71bf3dfb0dda8daec8 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 9 Apr 2026 13:46:15 +0200 Subject: [PATCH] feat: add autoAcceptPeering gate on peer/accept and POST /api/federation/peer/ensure endpoint --- packages/server/src/routes/federation.ts | 127 +++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 04058c12..89dc8b50 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -133,6 +133,42 @@ function isRelayRateLimited(peerOrigin: string): boolean { return false; } +// ─── In-memory rate limiter for the ensure endpoint (per-user) ───────────── +const ensureRateBuckets = new Map(); +const ENSURE_RATE_WINDOW_MS = 15 * 60_000; // 15 minutes +const ENSURE_RATE_MAX = 3; + +function isEnsureRateLimited(userId: string): boolean { + const now = Date.now(); + let timestamps = ensureRateBuckets.get(userId); + if (!timestamps) { + timestamps = []; + ensureRateBuckets.set(userId, timestamps); + } + const cutoff = now - ENSURE_RATE_WINDOW_MS; + while (timestamps.length > 0 && (timestamps[0] ?? Infinity) < cutoff) { + timestamps.shift(); + } + if (timestamps.length >= ENSURE_RATE_MAX) { + return true; + } + timestamps.push(now); + return false; +} + +// Clean up stale ensure rate limit buckets every 15 minutes +setInterval(() => { + const cutoff = Date.now() - ENSURE_RATE_WINDOW_MS; + for (const [userId, timestamps] of ensureRateBuckets) { + while (timestamps.length > 0 && (timestamps[0] ?? Infinity) < cutoff) { + timestamps.shift(); + } + if (timestamps.length === 0) { + ensureRateBuckets.delete(userId); + } + } +}, ENSURE_RATE_WINDOW_MS).unref(); + // ─── In-memory nonce store for replay protection (per-peer) ────────────────── // Maps peerOrigin → (nonce → insertion timestamp). Nonces are evicted after // NONCE_MAX_AGE_MS (15 min) to match the HMAC timestamp window. @@ -345,6 +381,38 @@ export async function federationRoutes(app: FastifyInstance): Promise { const db = getDb(); + // ── autoAcceptPeering gate ────────────────────────────────────────── + // When auto-accept is disabled, only allow incoming accept requests + // that correspond to a local pending peer (i.e., a local admin + // initiated the handshake). Unsolicited requests are rejected. + const settings = db + .select({ autoAcceptPeering: schema.instanceSettings.autoAcceptPeering }) + .from(schema.instanceSettings) + .where(eq(schema.instanceSettings.id, 1)) + .get(); + const autoAccept = settings?.autoAcceptPeering ?? 1; + + if (autoAccept === 0) { + const localPending = db + .select({ id: schema.federationPeers.id }) + .from(schema.federationPeers) + .where( + and( + eq(schema.federationPeers.origin, sourceOrigin), + eq(schema.federationPeers.status, 'pending'), + ), + ) + .get(); + + if (!localPending) { + return reply.code(403).send({ + error: 'This instance requires manual peering approval', + code: 'PEERING_REQUIRES_APPROVAL', + statusCode: 403, + }); + } + } + // Check if peer already exists const existing = db .select() @@ -363,6 +431,28 @@ export async function federationRoutes(app: FastifyInstance): Promise { statusCode: 403, }); } + if (existing.status === 'rejected') { + // A remote admin manually initiated peering with us after we + // previously auto-rejected them. Override rejected → active. + db.update(schema.federationPeers) + .set({ + hmacSecret, + status: 'active', + lastSeenAt: Date.now(), + }) + .where(eq(schema.federationPeers.id, existing.id)) + .run(); + + // Broadcast activation to all connected local users + for (const uid of connectionManager.getAllOnlineUserIds()) { + connectionManager.sendToUser(uid, { + type: 'federation_peer_active' as const, + peerOrigin: sourceOrigin, + }); + } + + return reply.code(200).send({ accepted: true }); + } // Pending — update with new secret and activate db.update(schema.federationPeers) .set({ @@ -391,6 +481,43 @@ export async function federationRoutes(app: FastifyInstance): Promise { }, ); + // ─── POST /api/federation/peer/ensure ────────────────────────────────────── + // JWT-authenticated (any user): trigger auto-peering with a remote instance. + // Rate-limited per user (3 requests per 15 minutes). + app.post<{ Body: { remoteOrigin: string } }>( + '/api/federation/peer/ensure', + { preHandler: [authenticate] }, + async (request, reply) => { + const { remoteOrigin: rawOrigin } = request.body ?? {}; + if (!rawOrigin || typeof rawOrigin !== 'string') { + return reply.code(200).send({ peeringStatus: 'failed', error: 'remoteOrigin is required' }); + } + + const remoteOrigin = validateOrigin(rawOrigin); + if (!remoteOrigin) { + return reply.code(200).send({ peeringStatus: 'failed', error: 'remoteOrigin must be a valid HTTPS URL (HTTP is only allowed for localhost)' }); + } + + if (isEnsureRateLimited(request.userId)) { + return reply.code(200).send({ peeringStatus: 'failed', error: 'Too many peering requests — try again later' }); + } + + const { ensurePeered } = await import('../utils/federationPeering.js'); + const result = await ensurePeered(remoteOrigin); + + switch (result.status) { + case 'active': + return reply.code(200).send({ peeringStatus: 'active', peerId: result.peerId }); + case 'rejected': + return reply.code(200).send({ peeringStatus: 'rejected', error: result.error }); + case 'failed': + return reply.code(200).send({ peeringStatus: 'failed', error: result.error }); + default: + return reply.code(200).send({ peeringStatus: 'failed', error: 'Unknown peering result' }); + } + }, + ); + // ─── POST /api/federation/peer/rotate ─────────────────────────────────────── // Server-to-server: accept a secret rotation request from a peer instance. // Authenticated via HMAC-SHA256 signature (current secret), NOT JWT.