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,
|
SynapseError,
|
||||||
deleteRoom,
|
deleteRoom,
|
||||||
getRoomDeleteStatus,
|
getRoomDeleteStatus,
|
||||||
|
listFederationDestinations,
|
||||||
listRooms,
|
listRooms,
|
||||||
purgeMediaCache,
|
purgeMediaCache,
|
||||||
} from "../services/synapse.js";
|
} from "../services/synapse.js";
|
||||||
@@ -32,6 +33,15 @@ const listQuerySchema = z.object({
|
|||||||
|
|
||||||
const roomParamsSchema = z.object({ roomId: matrixRoomIdSchema });
|
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
|
* 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
|
* 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> {
|
export async function addUserToRoom(roomId: string, userId: string): Promise<void> {
|
||||||
await synapseFetch(`/_synapse/admin/v1/join/${encodeURIComponent(roomId)}`, {
|
await synapseFetch(`/_synapse/admin/v1/join/${encodeURIComponent(roomId)}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -58,3 +58,11 @@ Keine.
|
|||||||
|
|
||||||
### Status
|
### Status
|
||||||
Vorabprüfung gegen die echte Synapse-Instanz abgeschlossen — keine offenen technischen Fragen mehr vor der Umsetzung.
|
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