diff --git a/backend/src/modules/selfhost-updater.ts b/backend/src/modules/selfhost-updater.ts index 394ca160..6518eeae 100644 --- a/backend/src/modules/selfhost-updater.ts +++ b/backend/src/modules/selfhost-updater.ts @@ -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)); } diff --git a/backend/src/routes/selfhostUpdates.ts b/backend/src/routes/selfhostUpdates.ts index 04a7202b..43d94275 100644 --- a/backend/src/routes/selfhostUpdates.ts +++ b/backend/src/routes/selfhostUpdates.ts @@ -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). diff --git a/backend/tests/selfhostUpdates.test.ts b/backend/tests/selfhostUpdates.test.ts index 713c8609..e21e4a6a 100644 --- a/backend/tests/selfhostUpdates.test.ts +++ b/backend/tests/selfhostUpdates.test.ts @@ -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" }); +}); diff --git a/docs/selfhost-updates.md b/docs/selfhost-updates.md index 41167cd9..d8e1fd30 100644 --- a/docs/selfhost-updates.md +++ b/docs/selfhost-updates.md @@ -101,7 +101,15 @@ Der aktuelle Updater erlaubt dabei gezielt das zusätzliche, schreibgeschützte Danach laufen weitere Anwendungs-Releases einschließlich Compose und Migrationen über den Button. Änderungen am Host-Dienst selbst benötigen weiterhin die gemeinsame Aktualisierung der Host-Dateien und den Dienstinstaller. -Die Backend-Routen `/api/admin/updates/status`, `/api/admin/updates/requests/:id`, `/api/admin/updates/check` und `/api/admin/updates/apply` prüfen bei jeder Anfrage die aktuellen globalen Administratorrechte aus der Datenbank. Mandantenadministratoren haben keinen Zugriff. Startanfragen benötigen zusätzlich den bereits von der Authentifizierung geprüften Bearer-JWT; Cookie-Anmeldung allein kann keine Hostaktion auslösen. Das Backend verwendet ausschließlich den festen Unix-Socket, begrenzt Antwortgröße und Wartezeit und liefert nur erlaubte Status-/Planfelder aus. CLI-Wiederherstellung, Übernahme fremder Compose-Dateien, Shell-Befehle und frei wählbare Download-Adressen bleiben auf dem Host. +Die Backend-Routen `/api/admin/updates/status`, `/api/admin/updates/requests/:id`, `/api/admin/updates/requests/:id/logs`, `/api/admin/updates/check` und `/api/admin/updates/apply` prüfen bei jeder Anfrage die aktuellen globalen Administratorrechte aus der Datenbank. Mandantenadministratoren haben keinen Zugriff. Startanfragen benötigen zusätzlich den bereits von der Authentifizierung geprüften Bearer-JWT; Cookie-Anmeldung allein kann keine Hostaktion auslösen. Das Backend verwendet ausschließlich den festen Unix-Socket, begrenzt Antwortgröße und Wartezeit und liefert nur erlaubte Status-/Planfelder und bereinigte Protokollauszüge aus. CLI-Wiederherstellung, Übernahme fremder Compose-Dateien, Shell-Befehle und frei wählbare Download-Adressen bleiben auf dem Host. + +### Update-Protokoll in der Oberfläche + +Unter **FEDEO-Updates → Update-Protokoll anzeigen** sehen Systemadministratoren die Fehlerdetails des Host-Dienstes, den Update-Verlauf und die Werkzeugausgaben (beispielsweise Docker-Pull- oder Migrationsfehler). Bei fehlgeschlagenen oder unterbrochenen Aufträgen öffnet sich die Ansicht automatisch. Solange sie geöffnet ist, wird sie zusammen mit dem Auftragsstatus aktualisiert; „Protokoll aktualisieren“ fragt sie zusätzlich sofort ab. Während der Wartungsunterbrechung bleibt die letzte geladene Ausgabe sichtbar. + +Pro Quelle werden höchstens die letzten 6000 UTF-8-Bytes angezeigt; gekürzte Ausgaben sind markiert. Die Bereinigung erfolgt auf dem Host vor der Übertragung: erkannte Kennwörter, Tokens, Authentifizierungsheader und URL-Zugangsdaten sowie bekannte geheime Werte aus der Umgebung und den aktuellen/gesicherten Konfigurationsdateien werden ersetzt. Die Originalprotokolle bleiben unverändert geschützt auf dem Host. Nicht als Zugangsdaten erkennbare Anwendungsdaten können weiterhin enthalten sein. + +Bestehende Installationen benötigen für diese Ansicht **auch einen aktualisierten Host-Dienst**, zusätzlich zu Backend und Frontend. Die vier zusammengehörigen Host-Dateien aus einem geprüften Stand bereitstellen und `selfhost-update-service-install.sh` erneut ausführen, wie unter „Host-Dienst installieren“ beschrieben. Ein Anwendungsupdate allein ersetzt den Host-Dienst nicht. Die Oberfläche erkennt ältere Dienste und zeigt dafür einen konkreten Hinweis. Bereits vorhandene Aufträge bleiben lesbar; ältere Protokolle werden anhand der gespeicherten Auftrags-ID zugeordnet. Nach Aktualisierung des Host-Dienstes lässt sich auch vor einem Anwendungsupdate direkt `GET /v1/requests//logs` über den Unix-Socket verwenden. ## Lokale Dienst-API @@ -112,6 +120,7 @@ Alle Antworten sind JSON mit `Cache-Control: no-store`. Mutationen benötigen `C | `GET /v1/status` | Installierter Stand, Zustand der Update-Engine und aktueller Dienstauftrag | | `POST /v1/check` | Asynchrone Prüfung; `requestId`, optional `version` ohne `v` | | `GET /v1/requests/` | Auftrag, bereinigter Prüfplan und aktueller Fortschritt | +| `GET /v1/requests//logs` | Bereinigte, begrenzte Auszüge aus Dienst-, Ablauf- und Werkzeugprotokoll; nur für diesen Auftrag | | `POST /v1/apply` | Asynchrones Update; `requestId`, feste `version` und erfolgreiche `checkRequestId` | Beispiel für eine Prüfung des neuesten Releases: diff --git a/frontend/components/SelfhostUpdateCard.vue b/frontend/components/SelfhostUpdateCard.vue index d7c44f06..594830a4 100644 --- a/frontend/components/SelfhostUpdateCard.vue +++ b/frontend/components/SelfhostUpdateCard.vue @@ -1,6 +1,6 @@ ', truncated: true }]) }) }) + vm.runInContext(ts.transpile(compiled, { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }), context) + const app = vue.createSSRApp(exports.default) + for (const name of ['UCard', 'UIcon', 'UBadge', 'UAlert', 'UProgress', 'UButton', 'UModal', 'UCheckbox']) { + app.component(name, { setup: (_props, { slots }) => () => vue.h('div', slots.default?.()) }) + } + const html = await require('@vue/server-renderer').renderToString(app) + assert.match(html, /Werkzeugausgaben/) + assert.match(html, /Gekürzte Anzeige/) + assert.match(html, /<script>/) + assert.doesNotMatch(html, /