feat(PROJ-16): backend for federation status (SYSTEM_ADMIN)
GET /api/system-admin/federation — SYSTEM_ADMIN-only, wraps Synapse's
GET /_synapse/admin/v1/federation/destinations read-only (no block/
unblock, per spec non-goals). Distinguishes federationAvailable:false
(admin API missing) from an empty destinations list (federation
disabled but the API works fine) from a 502 (Synapse unreachable) —
an admin verifying isolation must never read "Synapse is down" as
"no federation partners", which a flattened empty list would imply.
No schema change, no audit entry (pure read, same as the PROJ-14 room
list).
Verified end-to-end on talkhub/matrix: 403 without SYSTEM_ADMIN, 200
with {federationAvailable:true, destinations:[], total:0} — the
expected result with federation disabled.
Frontend page (/system-admin/federation) still outstanding.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143yYvjesjHndBACoweyK67
This commit is contained in:
1 parent
9498262836
commit
f9bfca9211
3 files changed
+142
No files matched your search
@@ -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 },
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -328,6 +328,54 @@ export async function purgeMediaCache(beforeTs: number): Promise<PurgeMediaCache
|
||||
};
|
||||
}
|
||||
|
||||
export interface SynapseDestination {
|
||||
destination: string;
|
||||
/** Timestamp (ms) of the last *failed* contact attempt, 0/null when healthy. */
|
||||
retry_last_ts: number | null;
|
||||
/** Backoff in ms until the next retry; 0 means "not backing off". */
|
||||
retry_interval: number | null;
|
||||
/** Timestamp (ms) when this destination started failing, null when healthy. */
|
||||
failure_ts: number | null;
|
||||
last_successful_stream_ordering: number | null;
|
||||
}
|
||||
|
||||
export interface SynapseDestinationPage {
|
||||
destinations: SynapseDestination[];
|
||||
total: number;
|
||||
next_token: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* PROJ-16: lists the remote homeservers this Synapse has had contact with.
|
||||
*
|
||||
* With federation disabled (TalkTime's default: `federation_domain_whitelist: []`)
|
||||
* this legitimately returns an empty list — that is the expected healthy state,
|
||||
* not an error, and callers must not dress it up as one. Verified against the
|
||||
* live instance: the endpoint answers immediately with `{destinations: [], total: 0}`
|
||||
* rather than erroring out, so no version-specific fallback is needed here.
|
||||
*
|
||||
* Read-only by design; the sibling "block/unblock destination" admin endpoints
|
||||
* are deliberately not wrapped (PROJ-16 non-goal).
|
||||
*/
|
||||
export async function listFederationDestinations(params: {
|
||||
from: number;
|
||||
limit: number;
|
||||
}): Promise<SynapseDestinationPage> {
|
||||
const query = new URLSearchParams({
|
||||
from: String(params.from),
|
||||
limit: String(params.limit),
|
||||
order_by: "destination",
|
||||
});
|
||||
const page = await synapseFetch<Partial<SynapseDestinationPage>>(
|
||||
`/_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<void> {
|
||||
await synapseFetch(`/_synapse/admin/v1/join/${encodeURIComponent(roomId)}`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -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).
|
||||
Reference in new issue
Block a user