From 5e48d67cb032f35515710af83d5e8f2b1594a9d9 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:54:35 +0200 Subject: [PATCH] feat: queue auto-peer requests for admin approval when autoAcceptPeering is off --- packages/server/src/routes/federation.ts | 98 +++++++++++++++++++++++- 1 file changed, 94 insertions(+), 4 deletions(-) diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index dcd2b8c3..55b7bcab 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -295,6 +295,11 @@ export async function federationRoutes(app: FastifyInstance): Promise { body: JSON.stringify({ sourceOrigin: localOrigin, hmacSecret, + instanceName: db + .select({ name: schema.instanceSettings.instanceName }) + .from(schema.instanceSettings) + .where(eq(schema.instanceSettings.id, 1)) + .get()?.name ?? undefined, }), signal: AbortSignal.timeout(10_000), }); @@ -405,10 +410,63 @@ export async function federationRoutes(app: FastifyInstance): Promise { .get(); if (!localPending) { - return reply.code(403).send({ - error: 'This instance requires manual peering approval', - code: 'PEERING_REQUIRES_APPROVAL', - statusCode: 403, + // Check if this origin is blocked (previously denied) + const blockedPeer = db + .select({ id: schema.federationPeers.id }) + .from(schema.federationPeers) + .where( + and( + eq(schema.federationPeers.origin, sourceOrigin), + eq(schema.federationPeers.status, 'rejected'), + ), + ) + .get(); + + if (blockedPeer) { + return reply.code(403).send({ + error: 'This instance requires manual peering approval', + code: 'PEERING_REQUIRES_APPROVAL', + statusCode: 403, + }); + } + + // Queue for admin approval — upsert into peer_approval_requests + const { instanceName: reqInstanceName } = request.body as { instanceName?: string }; + const now = Date.now(); + const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; + + const existingRequest = db + .select({ id: schema.peerApprovalRequests.id }) + .from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.origin, sourceOrigin)) + .get(); + + if (existingRequest) { + db.update(schema.peerApprovalRequests) + .set({ + instanceName: reqInstanceName ?? null, + hmacSecret, + requestedAt: now, + expiresAt: now + THIRTY_DAYS_MS, + }) + .where(eq(schema.peerApprovalRequests.id, existingRequest.id)) + .run(); + } else { + db.insert(schema.peerApprovalRequests) + .values({ + id: generateSnowflake(), + origin: sourceOrigin, + instanceName: reqInstanceName ?? null, + hmacSecret, + requestedAt: now, + expiresAt: now + THIRTY_DAYS_MS, + }) + .run(); + } + + return reply.code(202).send({ + queued: true, + message: 'Request queued for admin approval', }); } } @@ -453,6 +511,27 @@ export async function federationRoutes(app: FastifyInstance): Promise { return reply.code(200).send({ accepted: true }); } + if (existing.status === 'awaiting_approval') { + // Remote admin approved — this is a fresh handshake from them. + db.update(schema.federationPeers) + .set({ + hmacSecret, + status: 'active', + lastSeenAt: Date.now(), + }) + .where(eq(schema.federationPeers.id, existing.id)) + .run(); + + // Broadcast activation + 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({ @@ -511,11 +590,22 @@ export async function federationRoutes(app: FastifyInstance): Promise { const { ensurePeered } = await import('../utils/federationPeering.js'); const result = await ensurePeered(remoteOrigin); + // NOTE: The internal EnsurePeeredResult status names differ from the client-facing + // peeringStatus values. The mapping: + // 'active' → 'active' (peer is live) + // 'rejected' → 'rejected' (permanently blocked) + // 'pending' → 'awaiting_approval' (queued on remote, waiting for admin) + // 'failed' → 'pending' (transient error, will retry automatically) + // The internal 'pending' means "we got a 202 from the remote — admin hasn't acted yet", + // while 'failed' means "network/timeout — the outbox worker will retry next tick". + // The client sees 'awaiting_approval' (actionable info) vs 'pending' (transient, will resolve). 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 'pending': + return reply.code(200).send({ peeringStatus: 'awaiting_approval', error: result.error }); case 'failed': return reply.code(200).send({ peeringStatus: 'pending', error: result.error }); default: