diff --git a/backend/src/routes/system-admin.ts b/backend/src/routes/system-admin.ts index 2d42c99..0d1b90c 100644 --- a/backend/src/routes/system-admin.ts +++ b/backend/src/routes/system-admin.ts @@ -9,6 +9,7 @@ import { SynapseError, deleteRoom, getRoomDeleteStatus, + listFederationDestinations, listRooms, purgeMediaCache, } from "../services/synapse.js"; @@ -32,6 +33,15 @@ const listQuerySchema = z.object({ const roomParamsSchema = z.object({ roomId: matrixRoomIdSchema }); +/** + * PROJ-16: federation list is expected to be empty or tiny, so no search term — + * only paging, kept for the case where federation is switched on later. + */ +const federationQuerySchema = z.object({ + from: z.coerce.number().int().min(0).default(0), + limit: z.coerce.number().int().min(1).max(100).default(50), +}); + /** * PROJ-15: cut-off for the media cache purge. Everything cached *before* this * instant is dropped, so a future timestamp would mean "purge the entire @@ -359,4 +369,80 @@ export async function systemAdminRoutes(app: FastifyInstance) { }; }, ); + + /** + * PROJ-16: read-only view of the remote homeservers Synapse has talked to. + * + * Two distinctions the frontend depends on, and the reason this endpoint does + * more than pass the Synapse response through: + * + * 1. `federationAvailable: false` — the admin API itself is unavailable + * (404/400 from a build without the endpoint). Render as "deaktiviert", + * the expected state for TalkTime. + * 2. A 502 — Synapse is unreachable. That is NOT the same as "no servers + * connected" and must never be flattened into an empty list, otherwise an + * admin verifying isolation would read an outage as a clean bill of health. + * + * An empty list with `federationAvailable: true` is the normal, healthy + * result while `federation_domain_whitelist` stays empty. + * + * No audit entry: this is a pure read with no side effects, consistent with + * the room list (PROJ-14), which is likewise not audited. + */ + app.get( + "/api/system-admin/federation", + { preHandler: [app.authenticate, requireSystemAdmin] }, + async (request, reply) => { + const parsed = federationQuerySchema.safeParse(request.query); + if (!parsed.success) { + return reply.code(400).send({ error: parsed.error.issues[0].message }); + } + const { from, limit } = parsed.data; + + let page; + try { + page = await listFederationDestinations({ from, limit }); + } catch (err) { + if (err instanceof SynapseError && (err.status === 404 || err.status === 400)) { + app.log.info({ err }, "PROJ-16: federation admin API unavailable — treating as disabled"); + return { + federationAvailable: false, + destinations: [], + pagination: { from, limit, total: 0, nextToken: null }, + }; + } + app.log.error({ err }, "PROJ-16: failed to list federation destinations from Synapse"); + return reply + .code(502) + .send({ error: "Synapse ist derzeit nicht erreichbar. Bitte später erneut versuchen." }); + } + + return { + federationAvailable: true, + destinations: page.destinations.map((entry) => { + // `failure_ts` set means Synapse is currently backing off from this + // server; retry_last_ts/retry_interval describe that backoff. All + // three are 0 (not null) on a healthy destination in most versions, + // so normalize 0 → null instead of rendering "01.01.1970". + const failureTs = entry.failure_ts || null; + const retryLastTs = entry.retry_last_ts || null; + const retryIntervalMs = entry.retry_interval || null; + return { + destination: entry.destination, + failing: failureTs !== null, + failureSince: failureTs ? new Date(failureTs).toISOString() : null, + lastRetryAt: retryLastTs ? new Date(retryLastTs).toISOString() : null, + retryIntervalMs, + // Next attempt is only meaningful while backing off. + nextRetryAt: + retryLastTs && retryIntervalMs + ? new Date(retryLastTs + retryIntervalMs).toISOString() + : null, + lastSuccessfulStreamOrdering: entry.last_successful_stream_ordering ?? null, + }; + }), + pagination: { from, limit, total: page.total, nextToken: page.next_token }, + }; + }, + ); } diff --git a/backend/src/services/synapse.ts b/backend/src/services/synapse.ts index e7753ce..67f061b 100644 --- a/backend/src/services/synapse.ts +++ b/backend/src/services/synapse.ts @@ -328,6 +328,54 @@ export async function purgeMediaCache(beforeTs: number): Promise { + const query = new URLSearchParams({ + from: String(params.from), + limit: String(params.limit), + order_by: "destination", + }); + const page = await synapseFetch>( + `/_synapse/admin/v1/federation/destinations?${query.toString()}`, + ); + return { + destinations: page.destinations ?? [], + total: typeof page.total === "number" ? page.total : (page.destinations?.length ?? 0), + next_token: page.next_token ?? null, + }; +} + export async function addUserToRoom(roomId: string, userId: string): Promise { await synapseFetch(`/_synapse/admin/v1/join/${encodeURIComponent(roomId)}`, { method: "POST", diff --git a/features/PROJ-16-federation-status.md b/features/PROJ-16-federation-status.md index cf9fcbb..ea741b2 100644 --- a/features/PROJ-16-federation-status.md +++ b/features/PROJ-16-federation-status.md @@ -58,3 +58,11 @@ Keine. ### Status Vorabprüfung gegen die echte Synapse-Instanz abgeschlossen — keine offenen technischen Fragen mehr vor der Umsetzung. + +## Implementation Notes (Backend) +- `listFederationDestinations()` in `backend/src/services/synapse.ts` (read-only Wrapper um `GET /_synapse/admin/v1/federation/destinations`, keine Block-/Unblock-Endpoints — Non-Goal). +- `GET /api/system-admin/federation` in `backend/src/routes/system-admin.ts`, SYSTEM_ADMIN-only via `app.authenticate` + `requireSystemAdmin`, Zod-validiertes `from`/`limit` (max 100). +- Antwort unterscheidet bewusst drei Fälle: `federationAvailable: false` (Admin-API nicht vorhanden → "deaktiviert"), leere Liste bei `federationAvailable: true` (Normalfall, kein Fehler) und HTTP 502 (Synapse nicht erreichbar — wird **nicht** zu einer leeren Liste verflacht). +- Zeitstempel `0` werden zu `null` normalisiert; `nextRetryAt` wird aus `retry_last_ts + retry_interval` berechnet. +- Kein DB-Schema, keine Migration, kein Audit-Eintrag (reiner Lesezugriff, wie die Raum-Liste in PROJ-14). +- Offen: Frontend-Seite `/system-admin/federation` (Tabelle + Hinweistext für den leeren Zustand).