feat(federation): add peering handshake and admin peer management endpoints
Add federation routes module with four endpoints: - POST /api/federation/peer/initiate — admin-only, starts peering handshake with a remote instance (validates origin, generates HMAC secret, sends server-to-server accept request with 10s timeout) - POST /api/federation/peer/accept — server-to-server first contact, rate-limited to 10 req/min per IP, creates/activates peer record - GET /api/federation/peers — admin-only, lists all peers (hmacSecret excluded) - DELETE /api/federation/peers/:id — admin-only, revokes peer and cleans outbox
This commit is contained in:
@@ -25,6 +25,7 @@ import { exploreRoutes } from './routes/explore.js';
|
|||||||
import { searchRoutes } from './routes/search.js';
|
import { searchRoutes } from './routes/search.js';
|
||||||
import { adminRoutes } from './routes/admin.js';
|
import { adminRoutes } from './routes/admin.js';
|
||||||
import { gifRoutes } from './routes/gif.js';
|
import { gifRoutes } from './routes/gif.js';
|
||||||
|
import { federationRoutes } from './routes/federation.js';
|
||||||
|
|
||||||
import { registerWebSocket } from './ws/handler.js';
|
import { registerWebSocket } from './ws/handler.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -95,6 +96,7 @@ async function main(): Promise<void> {
|
|||||||
await app.register(searchRoutes);
|
await app.register(searchRoutes);
|
||||||
await app.register(adminRoutes);
|
await app.register(adminRoutes);
|
||||||
await app.register(gifRoutes);
|
await app.register(gifRoutes);
|
||||||
|
await app.register(federationRoutes);
|
||||||
await app.register(registerWebSocket);
|
await app.register(registerWebSocket);
|
||||||
|
|
||||||
app.get('/api/health', async () => {
|
app.get('/api/health', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,371 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { authenticate, requireAdmin } from '../utils/auth.js';
|
||||||
|
import { generateHmacSecret } from '../utils/federationAuth.js';
|
||||||
|
import { generateSnowflake } from '../utils/snowflake.js';
|
||||||
|
import { getDb, schema } from '../db/index.js';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
|
||||||
|
/** Fields safe to expose to admin callers (everything except hmacSecret). */
|
||||||
|
interface SanitizedPeer {
|
||||||
|
id: string;
|
||||||
|
origin: string;
|
||||||
|
instanceName: string | null;
|
||||||
|
status: string;
|
||||||
|
lastSeenAt: number | null;
|
||||||
|
lastFailureAt: number | null;
|
||||||
|
consecutiveFailures: number | null;
|
||||||
|
lastSyncedAt: number | null;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizePeer(row: typeof schema.federationPeers.$inferSelect): SanitizedPeer {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
origin: row.origin,
|
||||||
|
instanceName: row.instanceName,
|
||||||
|
status: row.status,
|
||||||
|
lastSeenAt: row.lastSeenAt,
|
||||||
|
lastFailureAt: row.lastFailureAt,
|
||||||
|
consecutiveFailures: row.consecutiveFailures,
|
||||||
|
lastSyncedAt: row.lastSyncedAt,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine this instance's public origin.
|
||||||
|
* Prefer the explicit DOMAIN env var; fall back to the request Host header.
|
||||||
|
*/
|
||||||
|
function resolveLocalOrigin(request: { headers: Record<string, string | string[] | undefined> }): string {
|
||||||
|
if (config.domain) {
|
||||||
|
return `https://${config.domain}`;
|
||||||
|
}
|
||||||
|
const hostHeader = request.headers.host;
|
||||||
|
const host = Array.isArray(hostHeader) ? hostHeader[0] : hostHeader;
|
||||||
|
if (!host) {
|
||||||
|
throw new Error('Cannot determine local origin: no DOMAIN configured and no Host header');
|
||||||
|
}
|
||||||
|
const protocol = (request as Record<string, unknown>).protocol === 'https' ? 'https' : 'http';
|
||||||
|
return `${protocol}://${host}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a string is a well-formed HTTP(S) URL origin.
|
||||||
|
* Returns the normalized origin (no trailing slash) or null if invalid.
|
||||||
|
*/
|
||||||
|
function validateOrigin(raw: string): string | null {
|
||||||
|
try {
|
||||||
|
const url = new URL(raw);
|
||||||
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
||||||
|
// origin is scheme + host (+ port if non-default) — no trailing slash
|
||||||
|
return url.origin;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── In-memory rate limiter for the accept endpoint ──────────────────────────
|
||||||
|
const acceptRateBuckets = new Map<string, number[]>();
|
||||||
|
const ACCEPT_RATE_WINDOW_MS = 60_000;
|
||||||
|
const ACCEPT_RATE_MAX = 10;
|
||||||
|
|
||||||
|
function isAcceptRateLimited(ip: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
let timestamps = acceptRateBuckets.get(ip);
|
||||||
|
if (!timestamps) {
|
||||||
|
timestamps = [];
|
||||||
|
acceptRateBuckets.set(ip, timestamps);
|
||||||
|
}
|
||||||
|
// Prune entries outside the window
|
||||||
|
const cutoff = now - ACCEPT_RATE_WINDOW_MS;
|
||||||
|
while (timestamps.length > 0 && (timestamps[0] ?? Infinity) < cutoff) {
|
||||||
|
timestamps.shift();
|
||||||
|
}
|
||||||
|
if (timestamps.length >= ACCEPT_RATE_MAX) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
timestamps.push(now);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Periodically clean stale buckets to prevent unbounded memory growth
|
||||||
|
setInterval(() => {
|
||||||
|
const cutoff = Date.now() - ACCEPT_RATE_WINDOW_MS;
|
||||||
|
for (const [ip, timestamps] of acceptRateBuckets) {
|
||||||
|
// Remove expired entries
|
||||||
|
while (timestamps.length > 0 && (timestamps[0] ?? Infinity) < cutoff) {
|
||||||
|
timestamps.shift();
|
||||||
|
}
|
||||||
|
if (timestamps.length === 0) {
|
||||||
|
acceptRateBuckets.delete(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, ACCEPT_RATE_WINDOW_MS).unref();
|
||||||
|
|
||||||
|
export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
// ─── POST /api/federation/peer/initiate ────────────────────────────────────
|
||||||
|
// Admin-only: start a peering handshake with a remote instance.
|
||||||
|
app.post<{ Body: { remoteOrigin: string } }>(
|
||||||
|
'/api/federation/peer/initiate',
|
||||||
|
{ preHandler: [authenticate, requireAdmin] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const { remoteOrigin: rawOrigin } = request.body ?? {};
|
||||||
|
if (!rawOrigin || typeof rawOrigin !== 'string') {
|
||||||
|
return reply.code(400).send({ error: 'remoteOrigin is required', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteOrigin = validateOrigin(rawOrigin);
|
||||||
|
if (!remoteOrigin) {
|
||||||
|
return reply.code(400).send({ error: 'remoteOrigin must be a valid HTTP/HTTPS URL', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
// Check if a peer already exists for this origin
|
||||||
|
const existing = db
|
||||||
|
.select()
|
||||||
|
.from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.origin, remoteOrigin))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (existing.status === 'active') {
|
||||||
|
return reply.code(200).send({ peer: sanitizePeer(existing) });
|
||||||
|
}
|
||||||
|
if (existing.status === 'pending') {
|
||||||
|
return reply.code(409).send({
|
||||||
|
error: 'A peering handshake with this instance is already in progress',
|
||||||
|
statusCode: 409,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// If revoked, allow re-initiation by removing the old record
|
||||||
|
if (existing.status === 'revoked') {
|
||||||
|
db.delete(schema.federationPeers).where(eq(schema.federationPeers.id, existing.id)).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let localOrigin: string;
|
||||||
|
try {
|
||||||
|
localOrigin = resolveLocalOrigin(request);
|
||||||
|
} catch {
|
||||||
|
return reply.code(500).send({
|
||||||
|
error: 'Cannot determine local instance origin. Set the DOMAIN environment variable.',
|
||||||
|
statusCode: 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent self-peering
|
||||||
|
if (localOrigin === remoteOrigin) {
|
||||||
|
return reply.code(400).send({ error: 'Cannot peer with yourself', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hmacSecret = generateHmacSecret();
|
||||||
|
const challenge = randomBytes(16).toString('hex');
|
||||||
|
const peerId = generateSnowflake();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Store peer as pending
|
||||||
|
db.insert(schema.federationPeers).values({
|
||||||
|
id: peerId,
|
||||||
|
origin: remoteOrigin,
|
||||||
|
hmacSecret,
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: now,
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
// Initiate the server-to-server handshake
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${remoteOrigin}/api/federation/peer/accept`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sourceOrigin: localOrigin,
|
||||||
|
challenge,
|
||||||
|
hmacSecret,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = `Remote instance rejected peering (HTTP ${response.status})`;
|
||||||
|
try {
|
||||||
|
const body = await response.json() as { error?: string };
|
||||||
|
if (body.error) {
|
||||||
|
errorMessage = body.error;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore parse failures — use the default error message
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up the pending peer
|
||||||
|
db.delete(schema.federationPeers).where(eq(schema.federationPeers.id, peerId)).run();
|
||||||
|
return reply.code(502).send({ error: errorMessage, statusCode: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remote accepted — activate the peer
|
||||||
|
db.update(schema.federationPeers)
|
||||||
|
.set({ status: 'active', lastSeenAt: Date.now() })
|
||||||
|
.where(eq(schema.federationPeers.id, peerId))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const peer = db
|
||||||
|
.select()
|
||||||
|
.from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.id, peerId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!peer) {
|
||||||
|
return reply.code(500).send({ error: 'Failed to read peer after activation', statusCode: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.code(200).send({ peer: sanitizePeer(peer) });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// Clean up the pending peer on network/timeout errors
|
||||||
|
db.delete(schema.federationPeers).where(eq(schema.federationPeers.id, peerId)).run();
|
||||||
|
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
if (err instanceof DOMException && err.name === 'TimeoutError') {
|
||||||
|
return reply.code(504).send({
|
||||||
|
error: 'Remote instance did not respond within 10 seconds',
|
||||||
|
statusCode: 504,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return reply.code(502).send({
|
||||||
|
error: `Failed to reach remote instance: ${message}`,
|
||||||
|
statusCode: 502,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── POST /api/federation/peer/accept ──────────────────────────────────────
|
||||||
|
// Server-to-server: accept a peering request from a remote instance.
|
||||||
|
// No JWT auth — this is first contact. Rate-limited by IP.
|
||||||
|
app.post<{ Body: { sourceOrigin: string; challenge: string; hmacSecret: string } }>(
|
||||||
|
'/api/federation/peer/accept',
|
||||||
|
async (request, reply) => {
|
||||||
|
const clientIp = request.ip;
|
||||||
|
if (isAcceptRateLimited(clientIp)) {
|
||||||
|
return reply.code(429).send({
|
||||||
|
error: 'Too many peering requests — try again later',
|
||||||
|
statusCode: 429,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sourceOrigin: rawOrigin, challenge, hmacSecret } = request.body ?? {};
|
||||||
|
|
||||||
|
if (!rawOrigin || typeof rawOrigin !== 'string') {
|
||||||
|
return reply.code(400).send({ error: 'sourceOrigin is required', statusCode: 400 });
|
||||||
|
}
|
||||||
|
if (!challenge || typeof challenge !== 'string') {
|
||||||
|
return reply.code(400).send({ error: 'challenge is required', statusCode: 400 });
|
||||||
|
}
|
||||||
|
if (!hmacSecret || typeof hmacSecret !== 'string') {
|
||||||
|
return reply.code(400).send({ error: 'hmacSecret is required', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceOrigin = validateOrigin(rawOrigin);
|
||||||
|
if (!sourceOrigin) {
|
||||||
|
return reply.code(400).send({ error: 'sourceOrigin must be a valid HTTP/HTTPS URL', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
// Check if peer already exists
|
||||||
|
const existing = db
|
||||||
|
.select()
|
||||||
|
.from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.origin, sourceOrigin))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (existing.status === 'active') {
|
||||||
|
// Idempotent — already peered
|
||||||
|
return reply.code(200).send({ accepted: true });
|
||||||
|
}
|
||||||
|
if (existing.status === 'revoked') {
|
||||||
|
return reply.code(403).send({
|
||||||
|
error: 'Peering with this instance has been revoked',
|
||||||
|
statusCode: 403,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Pending — update with new secret and activate
|
||||||
|
db.update(schema.federationPeers)
|
||||||
|
.set({
|
||||||
|
hmacSecret,
|
||||||
|
status: 'active',
|
||||||
|
lastSeenAt: Date.now(),
|
||||||
|
})
|
||||||
|
.where(eq(schema.federationPeers.id, existing.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return reply.code(200).send({ accepted: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// New peer — create and activate
|
||||||
|
const peerId = generateSnowflake();
|
||||||
|
db.insert(schema.federationPeers).values({
|
||||||
|
id: peerId,
|
||||||
|
origin: sourceOrigin,
|
||||||
|
hmacSecret,
|
||||||
|
status: 'active',
|
||||||
|
lastSeenAt: Date.now(),
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
return reply.code(200).send({ accepted: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── GET /api/federation/peers ─────────────────────────────────────────────
|
||||||
|
// Admin-only: list all federation peers (hmacSecret excluded).
|
||||||
|
app.get(
|
||||||
|
'/api/federation/peers',
|
||||||
|
{ preHandler: [authenticate, requireAdmin] },
|
||||||
|
async (_request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
const peers = db
|
||||||
|
.select()
|
||||||
|
.from(schema.federationPeers)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
return reply.code(200).send({ peers: peers.map(sanitizePeer) });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── DELETE /api/federation/peers/:id ──────────────────────────────────────
|
||||||
|
// Admin-only: revoke a federation peer and clean up its outbox.
|
||||||
|
app.delete<{ Params: { id: string } }>(
|
||||||
|
'/api/federation/peers/:id',
|
||||||
|
{ preHandler: [authenticate, requireAdmin] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const peer = db
|
||||||
|
.select()
|
||||||
|
.from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!peer) {
|
||||||
|
return reply.code(404).send({ error: 'Peer not found', statusCode: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke the peer
|
||||||
|
db.update(schema.federationPeers)
|
||||||
|
.set({ status: 'revoked' })
|
||||||
|
.where(eq(schema.federationPeers.id, id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// Delete all outbox entries for this peer
|
||||||
|
db.delete(schema.federationOutbox)
|
||||||
|
.where(eq(schema.federationOutbox.peerId, id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return reply.code(200).send({ success: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user