diff --git a/docs/systems/federation.md b/docs/systems/federation.md index 9f817d57..e489f9c7 100644 --- a/docs/systems/federation.md +++ b/docs/systems/federation.md @@ -51,10 +51,20 @@ Backspace federation is peer-to-peer with no central authority. Each instance ma **Phase 2 -- Accept** (`POST /api/federation/peer/accept`) - Auth: **none** (first contact -- no JWT, no HMAC) - Rate-limited: 10 requests per minute per IP (in-memory sliding window, buckets cleaned every 60s) -- Validates `sourceOrigin`, `challenge`, and `hmacSecret` from body +- Validates `sourceOrigin`, `challenge`, `hmacSecret`, and (optional) `instanceName` from body - Handles existing peers: active -> return 200 (idempotent), revoked -> return 403, pending -> update with new secret and activate - New peer: creates record with provided `hmacSecret`, sets `status='active'` -- Returns `{ accepted: true }` on success +- Returns `{ accepted: true, instanceName: }` on success — see "Instance name exchange" below + +### Instance name exchange + +The handshake is bidirectional for the `instance_name` label rendered in the federation panel and in DM-call toasts (`peerLabel`): + +- **Initiator → responder:** the request body to `/peer/accept` carries `{ sourceOrigin, hmacSecret, instanceName }`. The responder reads `instanceName` and persists it to `federation_peers.instance_name` on every state-mutating activation path: `pending → active`, `awaiting_approval → active`, `rejected → active` (override), and new-peer create. The idempotent early-return path for already-`active` and `needs_attention` peers does NOT overwrite — same security posture that already refuses to overwrite `hmac_secret` on these paths from an unauthenticated request. + +- **Responder → initiator:** the `/peer/accept` response body is `{ accepted: true, instanceName: }`. The initiator (`performHandshake` in `utils/federationPeering.ts` and `/peer/initiate` in `routes/federation.ts`) parses it and persists alongside the `status='active'` write. Older peers that omit the field are tolerated — the column stays `null`. Non-JSON bodies are tolerated defensively. + +`instance_name` is cosmetic metadata, eventually-consistent. Anywhere `peerLabel` is rendered falls back to origin hostname when `instance_name IS NULL`. Instance renames do not currently re-broadcast — that's a separate, unimplemented feature. ### Secret Storage & Rotation diff --git a/packages/server/src/routes/dm.federatedId.test.ts b/packages/server/src/routes/dm.federatedId.test.ts new file mode 100644 index 00000000..67669804 --- /dev/null +++ b/packages/server/src/routes/dm.federatedId.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from '../utils/snowflake.js'; + +setWorkerId(1); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; +let currentUserId = 'user-A'; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + schema, +})); + +vi.mock('../utils/auth.js', () => ({ + authenticate: async (req: { userId?: string }) => { + req.userId = currentUserId; + }, +})); + +vi.mock('../ws/handler.js', () => ({ + connectionManager: { + sendToUser: vi.fn(), + sendToDmMembers: vi.fn(), + sendToAdmins: vi.fn(), + getAllOnlineUserIds: () => [], + }, +})); + +vi.mock('../utils/federationOutbox.js', async () => { + const actual = await vi.importActual('../utils/federationOutbox.js'); + return { + ...actual, + isFederationRelayEnabled: () => true, + queueDmCloseRelay: vi.fn(), + sendTypingRelay: vi.fn(), + queueDmRelay: vi.fn(), + queueOutboxEvent: vi.fn(), + appendMutationLog: vi.fn(), + }; +}); + +function applyMigrations(db: Database.Database): void { + const migrationsDir = path.resolve(__dirname, '../../drizzle'); + const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort(); + for (const f of files) { + const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8'); + const statements = sqlText.split(/-->\s*statement-breakpoint/); + for (const stmt of statements) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +function seedTwoUsers(): void { + testDb.insert(schema.users).values({ + id: 'user-A', + username: 'alice', + displayName: 'Alice', + passwordHash: 'x', + homeUserId: 'user-A', + homeInstance: 'https://local.example', + createdAt: Date.now(), + }).run(); + + testDb.insert(schema.users).values({ + id: 'user-B', + username: 'bob', + displayName: 'Bob', + passwordHash: 'x', + homeUserId: 'remote-bob', + homeInstance: 'https://remote.example', + createdAt: Date.now(), + }).run(); +} + +async function buildApp(): Promise { + const app = Fastify({ logger: false }); + const { dmRoutes } = await import('./dm.js'); + await app.register(dmRoutes); + await app.ready(); + return app; +} + +describe('POST /api/dm — idempotent existing DM response includes federatedId', () => { + let app: FastifyInstance; + + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedTwoUsers(); + currentUserId = 'user-A'; + app = await buildApp(); + }); + + it('fresh-create returns a federatedId for federated 1-on-1 DM', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/dm', + payload: { userId: 'user-B' }, + }); + + expect(response.statusCode).toBe(201); + const body = response.json() as { id: string; federatedId: string | null }; + expect(body.federatedId).toMatch(/^[a-f0-9]{32}$/); + }); + + it('idempotent existing-DM path returns the same federatedId field', async () => { + // First call creates the DM + const first = await app.inject({ + method: 'POST', + url: '/api/dm', + payload: { userId: 'user-B' }, + }); + expect(first.statusCode).toBe(201); + const firstBody = first.json() as { id: string; federatedId: string | null }; + const expectedFederatedId = firstBody.federatedId; + expect(expectedFederatedId).toMatch(/^[a-f0-9]{32}$/); + + // Second call returns the existing DM idempotently — must carry federatedId + const second = await app.inject({ + method: 'POST', + url: '/api/dm', + payload: { userId: 'user-B' }, + }); + expect(second.statusCode).toBe(200); + const secondBody = second.json() as { id: string; federatedId: string | null }; + + expect(secondBody.id).toBe(firstBody.id); + expect(secondBody.federatedId).toBe(expectedFederatedId); + }); +}); diff --git a/packages/server/src/routes/dm.ts b/packages/server/src/routes/dm.ts index 532e8c5f..d35ba09b 100644 --- a/packages/server/src/routes/dm.ts +++ b/packages/server/src/routes/dm.ts @@ -471,6 +471,7 @@ export async function dmRoutes(app: FastifyInstance): Promise { const result: DmChannel = { id: dmChannel.id, ownerId: dmChannel.ownerId ?? null, + federatedId: dmChannel.federatedId ?? null, createdAt: dmChannel.createdAt, members: users.map(u => sanitizeUser(u)), lastMessage: lastMsg ? { diff --git a/packages/server/src/routes/federation.peerAccept.test.ts b/packages/server/src/routes/federation.peerAccept.test.ts new file mode 100644 index 00000000..6ce1e1c3 --- /dev/null +++ b/packages/server/src/routes/federation.peerAccept.test.ts @@ -0,0 +1,431 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from '../utils/snowflake.js'; + +setWorkerId(1); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +vi.mock('../utils/auth.js', () => ({ + authenticate: async (req: { userId?: string }) => { + req.userId = 'admin-user'; + }, + requireAdmin: async () => { + // no-op (peer/accept endpoint is unauthenticated anyway) + }, +})); + +vi.mock('../ws/handler.js', () => ({ + connectionManager: { + sendToAdmins: vi.fn(), + getAllOnlineUserIds: () => [], + sendToUser: vi.fn(), + sendToDmMembers: vi.fn(), + }, +})); + +vi.mock('../utils/federationPeerActivation.js', () => ({ + onPeerActivated: vi.fn(async () => undefined), +})); + +function applyMigrations(db: Database.Database): void { + const migrationsDir = path.resolve(__dirname, '../../drizzle'); + const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort(); + for (const f of files) { + const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8'); + const statements = sqlText.split(/-->\s*statement-breakpoint/); + for (const stmt of statements) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +function seedInstanceSettings(name: string): void { + testDb.insert(schema.instanceSettings).values({ + id: 1, + instanceName: name, + autoAcceptPeering: 1, + registrationOpen: 1, + updatedAt: Date.now(), + }).run(); +} + +async function buildApp(): Promise { + const app = Fastify({ logger: false }); + const { federationRoutes } = await import('./federation.js'); + await app.register(federationRoutes); + await app.ready(); + return app; +} + +describe('POST /api/federation/peer/accept — instance_name persistence', () => { + let app: FastifyInstance; + + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedInstanceSettings('Local Backspace'); + app = await buildApp(); + }); + + it('writes instance_name when creating a new peer (autoAccept=1, no existing row)', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peer/accept', + payload: { + sourceOrigin: 'https://remote.example', + hmacSecret: 'remote-secret', + instanceName: 'Remote Backspace', + }, + }); + + expect(response.statusCode).toBe(200); + const row = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://remote.example')).get(); + expect(row).toBeTruthy(); + expect(row?.instanceName).toBe('Remote Backspace'); + expect(row?.status).toBe('active'); + }); + + it('writes instance_name when activating an existing pending peer', async () => { + testDb.insert(schema.federationPeers).values({ + id: 'peer-pending', + origin: 'https://remote.example', + hmacSecret: 'old-secret', + status: 'pending', + createdAt: Date.now(), + }).run(); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peer/accept', + payload: { + sourceOrigin: 'https://remote.example', + hmacSecret: 'new-secret', + instanceName: 'Remote Backspace', + }, + }); + + expect(response.statusCode).toBe(200); + const row = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.id, 'peer-pending')).get(); + expect(row?.instanceName).toBe('Remote Backspace'); + expect(row?.status).toBe('active'); + }); + + it('writes instance_name when activating an existing awaiting_approval peer', async () => { + testDb.insert(schema.federationPeers).values({ + id: 'peer-await', + origin: 'https://remote.example', + hmacSecret: 'old-secret', + status: 'awaiting_approval', + createdAt: Date.now(), + }).run(); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peer/accept', + payload: { + sourceOrigin: 'https://remote.example', + hmacSecret: 'new-secret', + instanceName: 'Remote Backspace', + }, + }); + + expect(response.statusCode).toBe(200); + const row = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.id, 'peer-await')).get(); + expect(row?.instanceName).toBe('Remote Backspace'); + expect(row?.status).toBe('active'); + }); + + it('writes instance_name when overriding rejected → active', async () => { + testDb.insert(schema.federationPeers).values({ + id: 'peer-rejected', + origin: 'https://remote.example', + hmacSecret: 'old-secret', + status: 'rejected', + createdAt: Date.now(), + }).run(); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peer/accept', + payload: { + sourceOrigin: 'https://remote.example', + hmacSecret: 'new-secret', + instanceName: 'Remote Backspace', + }, + }); + + expect(response.statusCode).toBe(200); + const row = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.id, 'peer-rejected')).get(); + expect(row?.instanceName).toBe('Remote Backspace'); + expect(row?.status).toBe('active'); + }); + + it('does NOT overwrite instance_name on the active idempotent early-return path', async () => { + testDb.insert(schema.federationPeers).values({ + id: 'peer-active', + origin: 'https://remote.example', + hmacSecret: 'existing-secret', + status: 'active', + instanceName: 'Original Name', + createdAt: Date.now(), + }).run(); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peer/accept', + payload: { + sourceOrigin: 'https://remote.example', + hmacSecret: 'attacker-secret', + instanceName: 'Attacker Name', + }, + }); + + expect(response.statusCode).toBe(200); + const row = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.id, 'peer-active')).get(); + expect(row?.instanceName).toBe('Original Name'); + expect(row?.hmacSecret).toBe('existing-secret'); + }); + + it('writes null instance_name when body omits the field', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peer/accept', + payload: { + sourceOrigin: 'https://remote.example', + hmacSecret: 'remote-secret', + }, + }); + + expect(response.statusCode).toBe(200); + const row = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://remote.example')).get(); + expect(row?.instanceName).toBeNull(); + }); +}); + +describe('POST /api/federation/peer/accept — response body carries instanceName', () => { + let app: FastifyInstance; + + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedInstanceSettings('Local Backspace'); + app = await buildApp(); + }); + + it('returns our own instanceName in the response body on new-peer accept', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peer/accept', + payload: { + sourceOrigin: 'https://remote.example', + hmacSecret: 'remote-secret', + instanceName: 'Remote Backspace', + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { accepted: boolean; instanceName?: string | null }; + expect(body.accepted).toBe(true); + expect(body.instanceName).toBe('Local Backspace'); + }); + + it('returns our instanceName on the active idempotent path too', async () => { + testDb.insert(schema.federationPeers).values({ + id: 'peer-active', + origin: 'https://remote.example', + hmacSecret: 'existing-secret', + status: 'active', + instanceName: 'Original Name', + createdAt: Date.now(), + }).run(); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peer/accept', + payload: { + sourceOrigin: 'https://remote.example', + hmacSecret: 'remote-secret', + instanceName: 'Remote Backspace', + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { accepted: boolean; instanceName?: string | null }; + expect(body.instanceName).toBe('Local Backspace'); + }); + + it('returns instanceName: null when instanceSettings table is empty', async () => { + testDb.delete(schema.instanceSettings).run(); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peer/accept', + payload: { + sourceOrigin: 'https://remote.example', + hmacSecret: 'remote-secret', + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { accepted: boolean; instanceName?: string | null }; + expect(body.instanceName).toBeNull(); + }); +}); + +describe('POST /api/federation/peer/initiate — persists remote instanceName from handshake response', () => { + let app: FastifyInstance; + + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedInstanceSettings('Local Backspace'); + app = await buildApp(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('writes remote.instanceName when remote /peer/accept succeeds', async () => { + vi.stubGlobal('fetch', vi.fn(async () => + new Response(JSON.stringify({ accepted: true, instanceName: 'Remote Backspace' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + )); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peer/initiate', + payload: { remoteOrigin: 'https://remote.example' }, + }); + + expect(response.statusCode).toBe(200); + const row = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://remote.example')).get(); + expect(row?.status).toBe('active'); + expect(row?.instanceName).toBe('Remote Backspace'); + }); + + it('writes null instanceName when remote response omits the field', async () => { + vi.stubGlobal('fetch', vi.fn(async () => + new Response(JSON.stringify({ accepted: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + )); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peer/initiate', + payload: { remoteOrigin: 'https://remote.example' }, + }); + + expect(response.statusCode).toBe(200); + const row = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://remote.example')).get(); + expect(row?.instanceName).toBeNull(); + }); +}); + +describe('POST /api/federation/approval-requests/:id/approve — persists remote instanceName from handshake response', () => { + let app: FastifyInstance; + + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedInstanceSettings('Local Backspace'); + app = await buildApp(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function seedApprovalRequest(): string { + const id = 'approval-1'; + testDb.insert(schema.peerApprovalRequests).values({ + id, + origin: 'https://remote.example', + instanceName: 'Stale Name', + hmacSecret: 'their-old-secret', + requestedAt: Date.now(), + expiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000, + }).run(); + return id; + } + + it('writes remote.instanceName when remote /peer/accept succeeds', async () => { + const approvalId = seedApprovalRequest(); + + vi.stubGlobal('fetch', vi.fn(async () => + new Response(JSON.stringify({ accepted: true, instanceName: 'Remote Backspace' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + )); + + const response = await app.inject({ + method: 'POST', + url: `/api/federation/approval-requests/${approvalId}/approve`, + payload: {}, + }); + + expect(response.statusCode).toBe(200); + const row = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://remote.example')).get(); + expect(row?.status).toBe('active'); + expect(row?.instanceName).toBe('Remote Backspace'); + }); + + it('writes null instanceName when remote response omits the field', async () => { + const approvalId = seedApprovalRequest(); + + vi.stubGlobal('fetch', vi.fn(async () => + new Response(JSON.stringify({ accepted: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + )); + + const response = await app.inject({ + method: 'POST', + url: `/api/federation/approval-requests/${approvalId}/approve`, + payload: {}, + }); + + expect(response.statusCode).toBe(200); + const row = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://remote.example')).get(); + expect(row?.instanceName).toBeNull(); + }); +}); diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index b14a9121..11136582 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -351,9 +351,21 @@ export async function federationRoutes(app: FastifyInstance): Promise { return reply.code(502).send({ error: errorMessage, statusCode: 502 }); } - // Remote accepted — activate the peer + // Remote accepted — activate the peer. Parse the remote's instanceName + // from the response body so the federation panel renders a friendly + // label. Tolerate omission and non-JSON bodies. + let remoteInstanceName: string | null = null; + try { + const body = (await response.json()) as { instanceName?: string | null }; + if (typeof body?.instanceName === 'string' && body.instanceName.length > 0) { + remoteInstanceName = body.instanceName; + } + } catch { + // Non-JSON body — leave null. + } + db.update(schema.federationPeers) - .set({ status: 'active', lastSeenAt: Date.now() }) + .set({ status: 'active', lastSeenAt: Date.now(), instanceName: remoteInstanceName }) .where(eq(schema.federationPeers.id, peerId)) .run(); connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); @@ -394,7 +406,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { // ─── 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 } }>( + app.post<{ Body: { sourceOrigin: string; challenge?: string; hmacSecret: string; instanceName?: string } }>( '/api/federation/peer/accept', async (request, reply) => { const clientIp = request.ip; @@ -405,7 +417,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { }); } - const { sourceOrigin: rawOrigin, hmacSecret } = request.body ?? {}; + const { sourceOrigin: rawOrigin, hmacSecret, instanceName: reqInstanceName } = request.body ?? {}; if (!rawOrigin || typeof rawOrigin !== 'string') { return reply.code(400).send({ error: 'sourceOrigin is required', statusCode: 400 }); @@ -421,16 +433,22 @@ export async function federationRoutes(app: FastifyInstance): Promise { const db = getDb(); + const settings = db + .select({ + instanceName: schema.instanceSettings.instanceName, + autoAcceptPeering: schema.instanceSettings.autoAcceptPeering, + }) + .from(schema.instanceSettings) + .where(eq(schema.instanceSettings.id, 1)) + .get(); + + const ourInstanceName = settings?.instanceName ?? null; + const autoAccept = settings?.autoAcceptPeering ?? 1; + // ── 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) { // Check if the local admin already initiated or approved peering with this origin. @@ -471,7 +489,6 @@ export async function federationRoutes(app: FastifyInstance): Promise { } // 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; @@ -537,7 +554,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { // Legitimate recovery path: local admin clicks "Reset peering" → // row is deleted → remote's /peer/accept then lands on a // non-existent row and the normal handshake path runs. - return reply.code(200).send({ accepted: true }); + return reply.code(200).send({ accepted: true, instanceName: ourInstanceName }); } if (existing.status === 'revoked') { return reply.code(403).send({ @@ -551,6 +568,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { db.update(schema.federationPeers) .set({ hmacSecret, + instanceName: reqInstanceName ?? null, status: 'active', lastSeenAt: Date.now(), }) @@ -570,13 +588,14 @@ export async function federationRoutes(app: FastifyInstance): Promise { console.error('[federation] onPeerActivated from /peer/accept (rejected override) failed:', err) ); - return reply.code(200).send({ accepted: true }); + return reply.code(200).send({ accepted: true, instanceName: ourInstanceName }); } if (existing.status === 'awaiting_approval') { // Remote admin approved — this is a fresh handshake from them. db.update(schema.federationPeers) .set({ hmacSecret, + instanceName: reqInstanceName ?? null, status: 'active', lastSeenAt: Date.now(), }) @@ -596,12 +615,13 @@ export async function federationRoutes(app: FastifyInstance): Promise { console.error('[federation] onPeerActivated from /peer/accept (awaiting_approval) failed:', err) ); - return reply.code(200).send({ accepted: true }); + return reply.code(200).send({ accepted: true, instanceName: ourInstanceName }); } // Pending — update with new secret and activate db.update(schema.federationPeers) .set({ hmacSecret, + instanceName: reqInstanceName ?? null, status: 'active', lastSeenAt: Date.now(), }) @@ -613,7 +633,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { console.error('[federation] onPeerActivated from /peer/accept (pending) failed:', err) ); - return reply.code(200).send({ accepted: true }); + return reply.code(200).send({ accepted: true, instanceName: ourInstanceName }); } // New peer — create and activate @@ -622,6 +642,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { id: peerId, origin: sourceOrigin, hmacSecret, + instanceName: reqInstanceName ?? null, status: 'active', lastSeenAt: Date.now(), createdAt: Date.now(), @@ -632,7 +653,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { console.error('[federation] onPeerActivated from /peer/accept (new) failed:', err) ); - return reply.code(200).send({ accepted: true }); + return reply.code(200).send({ accepted: true, instanceName: ourInstanceName }); }, ); @@ -1148,8 +1169,21 @@ export async function federationRoutes(app: FastifyInstance): Promise { return reply.code(502).send({ error: errorMessage, statusCode: 502 }); } + // Parse the remote's instanceName from the response body so the + // federation panel renders a friendly label. Tolerate omission and + // non-JSON bodies — same pattern as performHandshake and /peer/initiate. + let remoteInstanceName: string | null = null; + try { + const body = (await response.json()) as { instanceName?: string | null }; + if (typeof body?.instanceName === 'string' && body.instanceName.length > 0) { + remoteInstanceName = body.instanceName; + } + } catch { + // Non-JSON body — leave null. + } + db.update(schema.federationPeers) - .set({ status: 'active', lastSeenAt: now }) + .set({ status: 'active', lastSeenAt: now, instanceName: remoteInstanceName }) .where(eq(schema.federationPeers.id, peerId)) .run(); diff --git a/packages/server/src/utils/federationPeering.instanceName.test.ts b/packages/server/src/utils/federationPeering.instanceName.test.ts new file mode 100644 index 00000000..c19402d5 --- /dev/null +++ b/packages/server/src/utils/federationPeering.instanceName.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from './snowflake.js'; + +setWorkerId(1); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + schema, +})); + +vi.mock('./federationAuth.js', async () => { + const actual = await vi.importActual('./federationAuth.js'); + return { + ...actual, + getOurOrigin: () => 'https://local.example', + generateHmacSecret: () => 'mock-hmac-secret', + }; +}); + +vi.mock('../routes/federation.js', () => ({ + validateOrigin: (raw: string) => { + try { + const url = new URL(raw); + return url.origin; + } catch { + return null; + } + }, +})); + +vi.mock('../ws/handler.js', () => ({ + connectionManager: { + sendToAdmins: vi.fn(), + sendToUser: vi.fn(), + getAllOnlineUserIds: () => [], + }, +})); + +vi.mock('./federationPeerActivation.js', () => ({ + onPeerActivated: vi.fn(async () => undefined), +})); + +function applyMigrations(db: Database.Database): void { + const migrationsDir = path.resolve(__dirname, '../../drizzle'); + const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort(); + for (const f of files) { + const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8'); + const statements = sqlText.split(/-->\s*statement-breakpoint/); + for (const stmt of statements) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +function seedInstanceSettings(): void { + testDb.insert(schema.instanceSettings).values({ + id: 1, + instanceName: 'Local Backspace', + autoAcceptPeering: 1, + registrationOpen: 1, + updatedAt: Date.now(), + }).run(); +} + +describe('performHandshake — persist remote instanceName', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedInstanceSettings(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sqlite.close(); + }); + + it('writes remote.instanceName from response body when handshake succeeds', async () => { + vi.stubGlobal('fetch', vi.fn(async () => + new Response(JSON.stringify({ accepted: true, instanceName: 'Remote Backspace' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + )); + + const { ensurePeered, _clearInFlightPeering } = await import('./federationPeering.js'); + _clearInFlightPeering(); + const result = await ensurePeered('https://remote.example'); + + expect(result.status).toBe('active'); + const row = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://remote.example')).get(); + expect(row?.instanceName).toBe('Remote Backspace'); + expect(row?.status).toBe('active'); + }); + + it('writes null instanceName when remote response omits the field (old peer)', async () => { + vi.stubGlobal('fetch', vi.fn(async () => + new Response(JSON.stringify({ accepted: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + )); + + const { ensurePeered, _clearInFlightPeering } = await import('./federationPeering.js'); + _clearInFlightPeering(); + const result = await ensurePeered('https://remote.example'); + + expect(result.status).toBe('active'); + const row = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://remote.example')).get(); + expect(row?.instanceName).toBeNull(); + expect(row?.status).toBe('active'); + }); + + it('does not crash when remote returns non-JSON body on 200', async () => { + vi.stubGlobal('fetch', vi.fn(async () => + new Response('not json', { + status: 200, + headers: { 'content-type': 'text/plain' }, + }), + )); + + const { ensurePeered, _clearInFlightPeering } = await import('./federationPeering.js'); + _clearInFlightPeering(); + const result = await ensurePeered('https://remote.example'); + + expect(result.status).toBe('active'); + const row = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://remote.example')).get(); + expect(row?.status).toBe('active'); + expect(row?.instanceName).toBeNull(); + }); +}); diff --git a/packages/server/src/utils/federationPeering.ts b/packages/server/src/utils/federationPeering.ts index 19ff03d8..4e8ea932 100644 --- a/packages/server/src/utils/federationPeering.ts +++ b/packages/server/src/utils/federationPeering.ts @@ -155,9 +155,21 @@ async function performHandshake( } if (response.ok) { - // 200 = peer accepted and activated + // 200 = peer accepted and activated. Parse remote's instanceName from + // the response body so we can render a friendly label for the peer. + // Tolerate omission (older peers) and non-JSON bodies (defensive). + let remoteInstanceName: string | null = null; + try { + const body = (await response.json()) as { instanceName?: string | null }; + if (typeof body?.instanceName === 'string' && body.instanceName.length > 0) { + remoteInstanceName = body.instanceName; + } + } catch { + // Non-JSON or empty body — leave remoteInstanceName as null. + } + db.update(schema.federationPeers) - .set({ status: 'active', lastSeenAt: Date.now() }) + .set({ status: 'active', lastSeenAt: Date.now(), instanceName: remoteInstanceName }) .where(eq(schema.federationPeers.id, peerId)) .run(); const { connectionManager } = await import('../ws/handler.js');