feat(selfhost): show sanitized update logs in admin UI
All checks were successful
Build and Release FEDEO / verify (push) Successful in 48s
Build and Release FEDEO / build-and-release (push) Successful in 5m45s

This commit is contained in:
2026-09-25 18:42:26 +02:00
parent 8f06d06037
commit 29155f916a
9 changed files with 398 additions and 9 deletions

View File

@@ -13,7 +13,8 @@ const errors = {
COMPOSE_REVIEW_REQUIRED: "Die Compose-Datei muss zuerst auf dem Host geprüft und übernommen werden.",
RECOVERY_REQUIRED: "Ein vorheriges Update benötigt Wiederherstellung auf dem Host.",
PLAN_CHANGED: "Release oder Konfiguration haben sich geändert. Bitte erneut prüfen.",
UPDATE_FAILED: "Auftrag fehlgeschlagen. Geschützte Protokolle auf dem Host prüfen.",
UPDATE_FAILED: "Auftrag fehlgeschlagen.",
LOGS_UNAVAILABLE: "Das Update-Protokoll kann gerade nicht gelesen werden.",
INTERRUPTED: "Der Auftrag wurde unterbrochen. Status auf dem Host prüfen.",
UPDATER_UNAVAILABLE: "Der Host-Update-Dienst ist nicht erreichbar. Dienst und Socket-Einbindung prüfen.",
UPDATER_PROTOCOL: "Der Host-Update-Dienst hat eine ungültige Antwort geliefert.",
@@ -46,12 +47,17 @@ const job = state.extend({ plan: plan.optional(), progress: state.optional(),
});
const status = z.object({ apiVersion: z.literal(1), acceptingRequests: z.boolean(), engine: state,
installed: state.pick({ version: true, revision: true }), request: job.optional(),
capabilities: z.object({ requestLogs: z.boolean() }).optional(),
});
const logs = z.object({ requestId, logs: z.array(z.object({
source: z.enum(["service", "events", "commands"]), content: z.string().max(6000), truncated: z.boolean(),
})).max(3),
});
// No caller-controlled host, URL, socket, headers or command can reach the privileged service.
export function createUpdaterClient(socketPath = "/run/fedeo-updater/control.sock", timeoutMs = 5000) {
return (method: "GET" | "POST", path: string, body?: unknown): Promise<{ status: number; body: unknown }> => {
if (!((method === "GET" && (path === "/v1/status" || /^\/v1\/requests\/[A-Za-z0-9][A-Za-z0-9_-]{7,79}$/.test(path)))
if (!((method === "GET" && (path === "/v1/status" || /^\/v1\/requests\/[A-Za-z0-9][A-Za-z0-9_-]{7,79}(?:\/logs)?$/.test(path)))
|| (method === "POST" && ["/v1/check", "/v1/apply"].includes(path)))) {
return Promise.reject(new UpdaterError("INVALID_REQUEST", 400));
}
@@ -76,7 +82,12 @@ export function createUpdaterClient(socketPath = "/run/fedeo-updater/control.soc
throw knownError(code) && [400, 404, 409, 503].includes(res.statusCode!)
? new UpdaterError(code, res.statusCode) : new UpdaterError("UPDATER_PROTOCOL", 502);
}
resolve({ status: res.statusCode!, body: (path === "/v1/status" ? status : job).parse(data) });
const schema = path === "/v1/status" ? status : path.endsWith("/logs") ? logs : job;
const parsed = schema.parse(data);
if (path.endsWith("/logs") && data.requestId !== path.split("/")[3]) {
throw new UpdaterError("UPDATER_PROTOCOL", 502);
}
resolve({ status: res.statusCode!, body: parsed });
} catch (error) {
reject(error instanceof UpdaterError ? error : new UpdaterError("UPDATER_PROTOCOL", 502));
}

View File

@@ -24,6 +24,10 @@ export default async function selfhostUpdateRoutes(server: FastifyInstance, opti
if (!requestId.safeParse(req.params.id).success) return reply.code(400).send({ error: { code: "INVALID_REQUEST" } });
return forward(reply, "GET", `/v1/requests/${req.params.id}`);
});
server.get<{ Params: { id: string } }>("/admin/updates/requests/:id/logs", async (req, reply) => {
if (!requestId.safeParse(req.params.id).success) return reply.code(400).send({ error: { code: "INVALID_REQUEST" } });
return forward(reply, "GET", `/v1/requests/${req.params.id}/logs`);
});
for (const command of ["check", "apply"] as const) {
server.post(`/admin/updates/${command}`, { bodyLimit: 4096 }, async (req, reply) => {
// The auth plugin verifies this JWT. Cookies alone cannot trigger host operations (CSRF).

View File

@@ -30,11 +30,22 @@ test("all update endpoints require a current global admin, including after revoc
assert.equal((await f.app.inject({ url: "/admin/updates/status" })).statusCode, 202);
f.revoke();
for (const [method, url] of [["GET", "/admin/updates/status"], ["GET", `/admin/updates/requests/${id}`],
["GET", `/admin/updates/requests/${id}/logs`],
["POST", "/admin/updates/check"], ["POST", "/admin/updates/apply"]] as const) {
assert.equal((await f.app.inject({ method, url, headers, payload: method === "POST" ? { requestId: id } : undefined })).statusCode, 403);
}
assert.equal(f.calls.length, 1);
});
test("admin log access uses the fixed request route and does not cache responses", async t => {
const f = await fixture(t);
const response = await f.app.inject({ url: `/admin/updates/requests/${id}/logs` });
assert.equal(response.statusCode, 202);
assert.equal(response.headers["cache-control"], "no-store");
assert.deepEqual(f.calls[0], ["GET", `/v1/requests/${id}/logs`, undefined]);
assert.equal((await f.app.inject({ url: "/admin/updates/requests/bad.id/logs" })).statusCode, 400);
const anonymous = await fixture(t, true, false);
assert.equal((await anonymous.app.inject({ url: `/admin/updates/requests/${id}/logs` })).statusCode, 401);
});
test("unauthenticated and cookie-only requests cannot start jobs", async t => {
const anonymous = await fixture(t, true, false);
assert.equal((await anonymous.app.inject({ url: "/admin/updates/status" })).statusCode, 401);
@@ -117,3 +128,19 @@ test("Unix client bounds response size, deadline, and protocol shape", async t =
});
}
});
test("Unix client validates bounded log responses and their request association", async t => {
let payload: any = { requestId: id, logs: [{ source: "commands", content: "pull access denied", truncated: true,
path: "/private/commands.log" }], private: "omitted" };
const client = await socketFixture(t, (_req, res) => res.end(JSON.stringify(payload)));
const response = await client("GET", `/v1/requests/${id}/logs`);
assert.deepEqual(response.body, { requestId: id, logs: [{ source: "commands", content: "pull access denied", truncated: true }] });
for (const invalid of [
{ ...payload, requestId: "different-request" },
{ ...payload, logs: [{ source: "env", content: "secret", truncated: false }] },
{ ...payload, logs: [{ source: "commands", content: "x".repeat(6001), truncated: false }] },
]) {
payload = invalid;
await assert.rejects(client("GET", `/v1/requests/${id}/logs`), { code: "UPDATER_PROTOCOL" });
}
await assert.rejects(client("GET", `/v1/requests/${id}/logs?path=/etc/passwd`), { code: "INVALID_REQUEST" });
});