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.", 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.", RECOVERY_REQUIRED: "Ein vorheriges Update benötigt Wiederherstellung auf dem Host.",
PLAN_CHANGED: "Release oder Konfiguration haben sich geändert. Bitte erneut prüfen.", 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.", 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_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.", 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, const status = z.object({ apiVersion: z.literal(1), acceptingRequests: z.boolean(), engine: state,
installed: state.pick({ version: true, revision: true }), request: job.optional(), 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. // 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) { export function createUpdaterClient(socketPath = "/run/fedeo-updater/control.sock", timeoutMs = 5000) {
return (method: "GET" | "POST", path: string, body?: unknown): Promise<{ status: number; body: unknown }> => { 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)))) { || (method === "POST" && ["/v1/check", "/v1/apply"].includes(path)))) {
return Promise.reject(new UpdaterError("INVALID_REQUEST", 400)); 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!) throw knownError(code) && [400, 404, 409, 503].includes(res.statusCode!)
? new UpdaterError(code, res.statusCode) : new UpdaterError("UPDATER_PROTOCOL", 502); ? 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) { } catch (error) {
reject(error instanceof UpdaterError ? error : new UpdaterError("UPDATER_PROTOCOL", 502)); 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" } }); 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}`); 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) { for (const command of ["check", "apply"] as const) {
server.post(`/admin/updates/${command}`, { bodyLimit: 4096 }, async (req, reply) => { server.post(`/admin/updates/${command}`, { bodyLimit: 4096 }, async (req, reply) => {
// The auth plugin verifies this JWT. Cookies alone cannot trigger host operations (CSRF). // 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); assert.equal((await f.app.inject({ url: "/admin/updates/status" })).statusCode, 202);
f.revoke(); f.revoke();
for (const [method, url] of [["GET", "/admin/updates/status"], ["GET", `/admin/updates/requests/${id}`], 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) { ["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((await f.app.inject({ method, url, headers, payload: method === "POST" ? { requestId: id } : undefined })).statusCode, 403);
} }
assert.equal(f.calls.length, 1); 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 => { test("unauthenticated and cookie-only requests cannot start jobs", async t => {
const anonymous = await fixture(t, true, false); const anonymous = await fixture(t, true, false);
assert.equal((await anonymous.app.inject({ url: "/admin/updates/status" })).statusCode, 401); 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" });
});

View File

@@ -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. 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/<requestId>/logs` über den Unix-Socket verwenden.
## Lokale Dienst-API ## 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 | | `GET /v1/status` | Installierter Stand, Zustand der Update-Engine und aktueller Dienstauftrag |
| `POST /v1/check` | Asynchrone Prüfung; `requestId`, optional `version` ohne `v` | | `POST /v1/check` | Asynchrone Prüfung; `requestId`, optional `version` ohne `v` |
| `GET /v1/requests/<requestId>` | Auftrag, bereinigter Prüfplan und aktueller Fortschritt | | `GET /v1/requests/<requestId>` | Auftrag, bereinigter Prüfplan und aktueller Fortschritt |
| `GET /v1/requests/<requestId>/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` | | `POST /v1/apply` | Asynchrones Update; `requestId`, feste `version` und erfolgreiche `checkRequestId` |
Beispiel für eine Prüfung des neuesten Releases: Beispiel für eine Prüfung des neuesten Releases:

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
const { overview, job, pending, connected, submitting, refreshing, message, accessDenied, recovery, busy, available, canApply, const { overview, job, pending, connected, submitting, refreshing, message, accessDenied, recovery, busy, available, canApply,
check, apply, refresh, sendPending } = useSelfhostUpdates() logsOpen, logsLoading, logsMessage, logEntries, refreshLogs, toggleLogs, check, apply, refresh, sendPending } = useSelfhostUpdates()
const build = useRuntimeConfig().public const build = useRuntimeConfig().public
const confirmOpen = ref(false) const confirmOpen = ref(false)
const confirmed = ref(false) const confirmed = ref(false)
@@ -16,6 +16,7 @@ const statusLabels: Record<string, string> = {
succeeded: 'Update erfolgreich abgeschlossen', failed: 'Auftrag fehlgeschlagen', interrupted: 'Auftrag unterbrochen', succeeded: 'Update erfolgreich abgeschlossen', failed: 'Auftrag fehlgeschlagen', interrupted: 'Auftrag unterbrochen',
idle: 'Bereit', recovered: 'Wiederherstellung bestätigt', unknown: 'Auftragsstatus wird geladen', idle: 'Bereit', recovered: 'Wiederherstellung bestätigt', unknown: 'Auftragsstatus wird geladen',
} }
const logLabels = { service: 'Fehlerdetails', events: 'Update-Verlauf', commands: 'Werkzeugausgaben' }
const progress = computed(() => job.value?.progress || (busy.value ? overview.value?.engine : null)) const progress = computed(() => job.value?.progress || (busy.value ? overview.value?.engine : null))
const statusLabel = computed(() => phaseLabels[progress.value?.phase || ''] || statusLabels[job.value?.status || 'idle']) const statusLabel = computed(() => phaseLabels[progress.value?.phase || ''] || statusLabels[job.value?.status || 'idle'])
const backupSize = computed(() => { const backupSize = computed(() => {
@@ -59,6 +60,24 @@ const reload = () => window.location.reload()
</dl> </dl>
<p v-if="job?.plan?.retiredServices.length" class="text-sm text-muted">Alte Matrix-Dienste werden stillgelegt; Daten und Container bleiben erhalten: {{ job.plan.retiredServices.join(', ') }}.</p> <p v-if="job?.plan?.retiredServices.length" class="text-sm text-muted">Alte Matrix-Dienste werden stillgelegt; Daten und Container bleiben erhalten: {{ job.plan.retiredServices.join(', ') }}.</p>
<p v-if="job?.requestId" class="break-all text-xs text-muted">Auftrag: {{ job.requestId }}</p> <p v-if="job?.requestId" class="break-all text-xs text-muted">Auftrag: {{ job.requestId }}</p>
<section v-if="job?.requestId && !accessDenied" aria-label="Update-Protokoll" class="space-y-3 rounded-lg border border-default p-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<UButton color="neutral" variant="ghost" icon="i-heroicons-document-text" :aria-expanded="logsOpen" @click="toggleLogs">
{{ logsOpen ? 'Update-Protokoll ausblenden' : 'Update-Protokoll anzeigen' }}
</UButton>
<UButton v-if="logsOpen && overview?.capabilities?.requestLogs" color="neutral" variant="outline" size="xs" :loading="logsLoading" :disabled="!connected" @click="refreshLogs">Protokoll aktualisieren</UButton>
</div>
<div v-if="logsOpen" class="space-y-3">
<p class="text-xs text-muted">Die letzten Protokolleinträge werden automatisch aktualisiert. Erkannte Zugangsdaten sind ausgeblendet.</p>
<UAlert v-if="logsMessage" color="warning" variant="soft" :title="logsMessage" />
<p v-else-if="!logEntries.length" class="text-sm text-muted">{{ logsLoading ? 'Protokoll wird geladen …' : 'Für diesen Auftrag liegen noch keine Protokolleinträge vor.' }}</p>
<div v-for="entry in logEntries" :key="entry.source" class="space-y-1">
<h3 class="text-sm font-medium">{{ logLabels[entry.source] }}</h3>
<p v-if="entry.truncated" class="text-xs text-muted">Gekürzte Anzeige der letzten Einträge. Das vollständige Protokoll liegt auf dem Host.</p>
<pre tabindex="0" :aria-label="logLabels[entry.source]" class="max-h-80 overflow-auto whitespace-pre-wrap break-words rounded-md bg-elevated p-3 text-xs">{{ entry.content }}</pre>
</div>
</div>
</section>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<UButton icon="i-heroicons-magnifying-glass" :disabled="!available" :loading="submitting && pending?.command === 'check'" @click="check">Nach Updates suchen</UButton> <UButton icon="i-heroicons-magnifying-glass" :disabled="!available" :loading="submitting && pending?.command === 'check'" @click="check">Nach Updates suchen</UButton>
<UButton v-if="canApply" icon="i-heroicons-arrow-down-tray" @click="openConfirmation">Update auf {{ job?.plan?.version }} installieren</UButton> <UButton v-if="canApply" icon="i-heroicons-arrow-down-tray" @click="openConfirmation">Update auf {{ job?.plan?.version }} installieren</UButton>

View File

@@ -7,9 +7,11 @@ export type UpdateJob = UpdateState & {
plan?: { version: string; revision: string; previousVersion?: string; updateAvailable: boolean; plan?: { version: string; revision: string; previousVersion?: string; updateAvailable: boolean;
requiresComposeAdoption: boolean; estimatedBackupBytes?: number; retiredServices: string[] }; requiresComposeAdoption: boolean; estimatedBackupBytes?: number; retiredServices: string[] };
} }
export type UpdateLogs = { requestId: string; logs: { source: 'service' | 'events' | 'commands'; content: string; truncated: boolean }[] }
type Overview = { type Overview = {
acceptingRequests: boolean; engine: UpdateState; request?: UpdateJob; acceptingRequests: boolean; engine: UpdateState; request?: UpdateJob;
installed: { version?: string }; backend: { version: string; revision: string }; installed: { version?: string }; backend: { version: string; revision: string };
capabilities?: { requestLogs: boolean };
} }
type Submission = { command: 'check' | 'apply'; body: { requestId: string; version?: string; checkRequestId?: string } } type Submission = { command: 'check' | 'apply'; body: { requestId: string; version?: string; checkRequestId?: string } }
@@ -24,6 +26,12 @@ export const useSelfhostUpdates = () => {
const refreshing = ref(false) const refreshing = ref(false)
const message = ref('') const message = ref('')
const accessDenied = ref(false) const accessDenied = ref(false)
const logs = ref<UpdateLogs | null>(null)
const logsOpen = ref(false)
const logsLoading = ref(false)
const logsMessage = ref('')
const logEntries = computed(() => logs.value?.requestId === job.value?.requestId ? logs.value?.logs || [] : [])
let openedFailureId: string | undefined
let stopped = false let stopped = false
let generation = 0 let generation = 0
let timer: ReturnType<typeof setTimeout> | undefined let timer: ReturnType<typeof setTimeout> | undefined
@@ -55,6 +63,28 @@ export const useSelfhostUpdates = () => {
message.value = error?.data?.error?.message || 'Der Update-Dienst ist nicht erreichbar. Dienst und Socket-Einbindung auf dem Host prüfen.' message.value = error?.data?.error?.message || 'Der Update-Dienst ist nicht erreichbar. Dienst und Socket-Einbindung auf dem Host prüfen.'
} }
} }
const refreshLogs = async () => {
const id = job.value?.requestId
if (!logsOpen.value || !id || stopped || accessDenied.value || logsLoading.value) return
if (logs.value?.requestId !== id) { logs.value = null; logsMessage.value = '' }
if (!overview.value?.capabilities?.requestLogs) {
logsMessage.value = 'Dieser Host-Update-Dienst unterstützt die Protokollansicht noch nicht. Die zusammengehörigen Host-Update-Dateien aktualisieren und den Dienstinstaller erneut ausführen. Bis dahin liegen die Protokolle auf dem Host unter .updates/.'
return
}
const currentGeneration = generation
logsLoading.value = true
try {
const result = await api<UpdateLogs>(`requests/${encodeURIComponent(id)}/logs`)
if (stopped || currentGeneration !== generation || job.value?.requestId !== id) return
logs.value = result
logsMessage.value = ''
} catch (error: any) {
if (stopped || currentGeneration !== generation || job.value?.requestId !== id) return
logsMessage.value = error?.data?.error?.message || 'Das Update-Protokoll konnte nicht geladen werden. Die Abfrage wird automatisch wiederholt.'
if ([401, 403].includes(errorStatus(error))) { logs.value = null; report(error) }
} finally { logsLoading.value = false }
}
const toggleLogs = async () => { logsOpen.value = !logsOpen.value; await refreshLogs() }
const refresh = async () => { const refresh = async () => {
if (refreshing.value || stopped || accessDenied.value) return if (refreshing.value || stopped || accessDenied.value) return
refreshing.value = true refreshing.value = true
@@ -81,6 +111,11 @@ export const useSelfhostUpdates = () => {
connected.value = true connected.value = true
message.value = pending.value ? 'Start noch nicht bestätigt. Du kannst denselben Auftrag erneut senden.' : '' message.value = pending.value ? 'Start noch nicht bestätigt. Du kannst denselben Auftrag erneut senden.' : ''
persist() persist()
if (['failed', 'interrupted'].includes(job.value?.status || '') && openedFailureId !== job.value?.requestId) {
openedFailureId = job.value?.requestId
logsOpen.value = true
}
await refreshLogs()
} catch (error) { } catch (error) {
if (currentGeneration !== generation || stopped) return if (currentGeneration !== generation || stopped) return
connected.value = false connected.value = false
@@ -90,6 +125,8 @@ export const useSelfhostUpdates = () => {
const sendPending = async () => { const sendPending = async () => {
if (!pending.value || submitting.value || accessDenied.value) return if (!pending.value || submitting.value || accessDenied.value) return
generation++ generation++
logs.value = null
logsMessage.value = ''
submitting.value = true submitting.value = true
const submission = pending.value const submission = pending.value
try { try {
@@ -109,6 +146,7 @@ export const useSelfhostUpdates = () => {
report(error) report(error)
} }
} finally { submitting.value = false; persist() } } finally { submitting.value = false; persist() }
await refreshLogs()
} }
const check = async () => { const check = async () => {
if (!available.value) return if (!available.value) return
@@ -145,5 +183,5 @@ export const useSelfhostUpdates = () => {
}) })
onBeforeUnmount(() => { stopped = true; clearTimeout(timer) }) onBeforeUnmount(() => { stopped = true; clearTimeout(timer) })
return { overview, job, pending, connected, submitting, refreshing, message, accessDenied, recovery, busy, available, canApply, return { overview, job, pending, connected, submitting, refreshing, message, accessDenied, recovery, busy, available, canApply,
check, apply, refresh, sendPending } logsOpen, logsLoading, logsMessage, logEntries, refreshLogs, toggleLogs, check, apply, refresh, sendPending }
} }

View File

@@ -99,3 +99,90 @@ test('an older polling response cannot erase a new submission whose response was
assert.equal(f.ui.pending.value.body.requestId, id) assert.equal(f.ui.pending.value.body.requestId, id)
assert.equal(f.ui.canApply.value, false) assert.equal(f.ui.canApply.value, false)
}) })
test('failed jobs automatically show logs and refresh them only while open', async t => {
const failed = { status: 'failed', command: 'apply', requestId: 'failed-request', error: { code: 'UPDATE_FAILED', message: 'Auftrag fehlgeschlagen.' } }
let content = 'pull access denied'
const f = await setup(t, async path => {
if (path.endsWith('/logs')) return { requestId: failed.requestId, logs: [{ source: 'commands', content, truncated: false }] }
return path.endsWith('/status') ? { ...overview(failed), capabilities: { requestLogs: true } } : failed
})
assert.equal(f.ui.logsOpen.value, true)
assert.equal(f.ui.logEntries.value[0].content, content)
content = 'final failure detail'
await f.ui.refresh()
assert.equal(f.ui.logEntries.value[0].content, content)
await f.ui.toggleLogs()
const count = f.calls.filter(c => c.path.endsWith('/logs')).length
await f.ui.refresh()
assert.equal(f.ui.logsOpen.value, false)
assert.equal(f.calls.filter(c => c.path.endsWith('/logs')).length, count)
})
test('older host services explain the required upgrade without requesting unsupported endpoints', async t => {
const failed = { status: 'failed', command: 'apply', requestId: 'failed-request' }
const f = await setup(t, async path => path.endsWith('/status') ? overview(failed) : failed)
assert.equal(f.ui.logsOpen.value, true)
assert.match(f.ui.logsMessage.value, /Host-Update-Dateien aktualisieren/)
assert.equal(f.calls.some(c => c.path.endsWith('/logs')), false)
assert.equal(f.ui.connected.value, true)
})
test('log failures preserve job state and revoked admin access clears the displayed logs', async t => {
let failure
const f = await setup(t, async path => {
if (path.endsWith('/logs')) {
if (failure) throw failure
return { requestId: check.requestId, logs: [{ source: 'commands', content: 'diagnostic', truncated: false }] }
}
return path.endsWith('/status') ? { ...overview(), capabilities: { requestLogs: true } } : check
})
await f.ui.toggleLogs()
failure = { statusCode: 503 }
await f.ui.refreshLogs()
assert.match(f.ui.logsMessage.value, /konnte nicht geladen/)
assert.equal(f.ui.connected.value, true)
assert.equal(f.ui.job.value.status, 'checked')
failure = { statusCode: 403 }
await f.ui.refreshLogs()
assert.equal(f.ui.accessDenied.value, true)
assert.equal(f.ui.logEntries.value.length, 0)
})
test('a delayed log response cannot appear under a newly submitted job', async t => {
let release
const f = await setup(t, async (path, options) => {
if (options.method === 'POST') return { status: 'queued', requestId: options.body.requestId, command: 'check' }
if (path.endsWith('/logs')) return new Promise(resolve => { release = resolve })
return path.endsWith('/status') ? { ...overview(), capabilities: { requestLogs: true } } : check
})
const loading = f.ui.toggleLogs()
await new Promise(setImmediate)
await f.ui.check()
release({ requestId: check.requestId, logs: [{ source: 'commands', content: 'previous job', truncated: false }] })
await loading
assert.notEqual(f.ui.job.value.requestId, check.requestId)
assert.equal(f.ui.logEntries.value.length, 0)
})
test('the update card renders log output as escaped text and explains truncation', async t => {
const f = await setup(t, async path => path.endsWith('/status') ? overview() : check)
f.ui.logsOpen.value = true
const { parse, compileScript } = require('@vue/compiler-sfc')
const descriptor = parse(fs.readFileSync(__dirname + '/../components/SelfhostUpdateCard.vue', 'utf8')).descriptor
const compiled = compileScript(descriptor, { id: 'update-card-test', inlineTemplate: true }).content
const exports = {}
const context = vm.createContext({ exports, require, ref: vue.ref, computed: vue.computed,
useRuntimeConfig: () => ({ public: {} }), useSelfhostUpdates: () => ({ ...f.ui,
logEntries: vue.ref([{ source: 'commands', content: '<script>alert("log")</script>', 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, /&lt;script&gt;/)
assert.doesNotMatch(html, /<script>/)
})

View File

@@ -14,6 +14,7 @@ import socketserver
import stat import stat
import threading import threading
import traceback import traceback
import urllib.parse
spec = importlib.util.spec_from_file_location("fedeo_updater", Path(__file__).with_name("selfhost-update.py")) spec = importlib.util.spec_from_file_location("fedeo_updater", Path(__file__).with_name("selfhost-update.py"))
engine = importlib.util.module_from_spec(spec) engine = importlib.util.module_from_spec(spec)
@@ -33,7 +34,8 @@ ERRORS = {
"COMPOSE_REVIEW_REQUIRED": "Die Compose-Datei muss zuerst auf dem Host geprüft und übernommen werden.", "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.", "RECOVERY_REQUIRED": "Ein vorheriges Update benötigt Wiederherstellung auf dem Host.",
"PLAN_CHANGED": "Release oder Konfiguration haben sich geändert. Erneut prüfen.", "PLAN_CHANGED": "Release oder Konfiguration haben sich geändert. 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.", "INTERRUPTED": "Der Auftrag wurde unterbrochen. Status auf dem Host prüfen.",
} }
@@ -82,6 +84,74 @@ def public_plan(plan):
return result return result
LOG_BYTES = 6000
SECRET_KEY = r"[\w.-]*(?:password|passwd|secret|token|credential|authorization|(?:api|private|access|encryption|signing)[_-]?key|database[_-]?url|dsn|auth)[\w.-]*"
QUOTED_VALUE = r"\"(?:\\.|[^\"\\])*\"|'[^']*'"
SECRET_VALUE = re.compile(r"(?im)(?<![\w.-])([\"']?" + SECRET_KEY + r"[\"']?\s*[:=]\s*)(" + QUOTED_VALUE + r"|[^\r\n,}\]]+)")
CONFIG_SECRET = re.compile(r"(?im)^\s*(?:export\s+|-\s*)?[\"']?" + SECRET_KEY + r"[\"']?\s*[:=]\s*(" + QUOTED_VALUE + r"|[^\r\n]+)")
def read_private(path, *, tail=False):
"""Read only regular files, never follow a log/config symlink or block on a FIFO."""
try:
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
except FileNotFoundError:
return "", False
with os.fdopen(fd, "rb") as stream:
size = os.fstat(stream.fileno()).st_size
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
raise OSError("Not a regular file")
limit = 32768 if tail else 1024 * 1024
if size > limit and not tail:
raise OSError("Configuration too large to redact safely")
truncated = size > limit
if truncated:
stream.seek(-limit, os.SEEK_END)
stream.readline(limit) # Never expose a suffix of a secret on a cut line.
return stream.read(limit).decode("utf-8", errors="replace"), truncated
def log_redactor(directory, job):
# Include original values after an update changed the active configuration.
paths = [directory / name for name in (".env", "docker-compose.yml", "docker-compose.override.yml", "docker-compose.override.yaml")]
if job:
paths += [job / "target.env", job / "target.compose.yml"]
paths += [job / "backup/config" / name for name in (".env", "docker-compose.yml", "docker-compose.override.yml", "docker-compose.override.yaml")]
values = {value for key, value in os.environ.items() if re.fullmatch(SECRET_KEY, key, re.I) and value}
for path in paths:
content, _ = read_private(path)
for match in CONFIG_SECRET.finditer(content):
value = match[1].strip()
if value[:1] in ("'", '"'):
if value.startswith('"'):
try:
values.add(json.loads(value))
except ValueError:
pass
value = value[1:-1]
else:
value = value.split(" #", 1)[0].strip()
if value:
values.add(value)
for match in re.finditer(r"[a-z][a-z0-9+.-]*://[^\s\"']+", content, re.I):
password = urllib.parse.urlsplit(match[0]).password
if password:
values.update((password, urllib.parse.unquote(password)))
values |= {urllib.parse.quote(value, safe="") for value in values}
def redact(content):
content = re.sub(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))", "", content)
for value in sorted(values, key=len, reverse=True):
content = content.replace(value, "[REDACTED]")
content = re.sub(r"(?is)-----BEGIN [^-]*PRIVATE KEY-----.*?(?:-----END [^-]*PRIVATE KEY-----|\Z)", "[REDACTED]", content)
content = re.sub(r"(?i)([a-z][a-z0-9+.-]*://)[^\s/]*@", r"\1[REDACTED]@", content)
content = re.sub(r"(?i)\b(Bearer|Basic|token)\s+[A-Za-z0-9._~+/=-]+", r"\1 [REDACTED]", content)
content = SECRET_VALUE.sub(lambda m: m[1] + "[REDACTED]", content)
content = re.sub(r"(?i)(--" + SECRET_KEY + r"\s+)(" + QUOTED_VALUE + r"|\S+)", r"\1[REDACTED]", content)
return re.sub(r"[\x00-\x08\x0b-\x1f\x7f]", "", content)
return redact
class Controller: class Controller:
def __init__(self, directory, *, updater_factory=engine.Updater): def __init__(self, directory, *, updater_factory=engine.Updater):
self.directory = Path(directory).resolve() self.directory = Path(directory).resolve()
@@ -140,9 +210,47 @@ class Controller:
result["progress"] = public_state(state) result["progress"] = public_state(state)
return result return result
def request_logs(self, request_id):
record = self.read(request_id)
try:
home = self.home.parent
state = engine.read_json(home / "status.json", {})
candidates = [Path(record["job"])] if record.get("job") else []
if state.get("requestId") == request_id and state.get("job"):
candidates.append(Path(state["job"]))
# Older service versions did not persist the engine-job association.
if not candidates:
candidates = [path.parent for path in home.glob("*/status.json")]
job = None
for candidate in candidates:
if candidate.parent != home or candidate.is_symlink() or not candidate.is_dir():
continue
content, _ = read_private(candidate / "status.json")
if content and json.loads(content).get("requestId") == request_id:
job = candidate
break
redact = log_redactor(self.directory, job)
files = [("service", self.home / (request_id + ".log"))]
if job:
files += [("events", job / "events.jsonl"), ("commands", job / "commands.log")]
logs = []
for source, path in files:
content, truncated = read_private(path, tail=True)
content = redact(content)
encoded = content.encode("utf-8")
if len(encoded) > LOG_BYTES:
content = encoded[-LOG_BYTES:].decode("utf-8", errors="ignore")
truncated = True
if content or truncated:
logs.append({"source": source, "content": content, "truncated": truncated})
return {"requestId": request_id, "logs": logs}
except (OSError, ValueError, TypeError):
raise ApiError("LOGS_UNAVAILABLE", 503) from None
def status(self): def status(self):
updater = self.factory(self.directory) updater = self.factory(self.directory)
result = {"apiVersion": 1, "engine": public_state(updater.status()), "acceptingRequests": not self.closing} result = {"apiVersion": 1, "engine": public_state(updater.status()), "acceptingRequests": not self.closing,
"capabilities": {"requestLogs": True}}
installed = engine.read_json(updater.home / "installed.json", {}).get("manifest", {}) installed = engine.read_json(updater.home / "installed.json", {}).get("manifest", {})
result["installed"] = {k: v for k, v in public_state({"version": installed.get("version"), result["installed"] = {k: v for k, v in public_state({"version": installed.get("version"),
"revision": installed.get("commit")}).items() if k != "status"} "revision": installed.get("commit")}).items() if k != "status"}
@@ -208,6 +316,7 @@ class Controller:
def run(self, record, fingerprint): def run(self, record, fingerprint):
request_id = record["requestId"] request_id = record["requestId"]
updater = None
try: try:
record.update(status="running", updatedAt=engine.now()) record.update(status="running", updatedAt=engine.now())
engine.write_json(self.path(request_id), record) engine.write_json(self.path(request_id), record)
@@ -224,6 +333,8 @@ class Controller:
os.chmod(log.name, 0o600) os.chmod(log.name, 0o600)
traceback.print_exc(file=log) traceback.print_exc(file=log)
finally: finally:
if updater and updater.state and updater.state.get("requestId") == request_id:
record["job"] = str(updater.job)
record["updatedAt"] = engine.now() record["updatedAt"] = engine.now()
engine.write_json(self.path(request_id), record) engine.write_json(self.path(request_id), record)
@@ -267,7 +378,9 @@ class Handler(BaseHTTPRequestHandler):
if self.command == "GET" and self.path == "/v1/status": if self.command == "GET" and self.path == "/v1/status":
self.send_json(200, controller.status()) self.send_json(200, controller.status())
elif self.command == "GET" and self.path.startswith("/v1/requests/"): elif self.command == "GET" and self.path.startswith("/v1/requests/"):
self.send_json(200, controller.request_status(self.path.removeprefix("/v1/requests/"))) suffix = self.path.removeprefix("/v1/requests/")
self.send_json(200, controller.request_logs(suffix.removesuffix("/logs"))
if suffix.endswith("/logs") else controller.request_status(suffix))
elif self.command == "POST" and self.path in ("/v1/check", "/v1/apply"): elif self.command == "POST" and self.path in ("/v1/check", "/v1/apply"):
lengths = self.headers.get_all("Content-Length", []) lengths = self.headers.get_all("Content-Length", [])
if (len(lengths) != 1 or not lengths[0].isdigit() or not 1 <= int(lengths[0]) <= 4096 if (len(lengths) != 1 or not lengths[0].isdigit() or not 1 <= int(lengths[0]) <= 4096

View File

@@ -8,6 +8,7 @@ import subprocess
import tempfile import tempfile
import threading import threading
import unittest import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[3] ROOT = Path(__file__).resolve().parents[3]
spec = importlib.util.spec_from_file_location('update_service', ROOT / 'scripts/selfhost-update-service.py') spec = importlib.util.spec_from_file_location('update_service', ROOT / 'scripts/selfhost-update-service.py')
@@ -232,6 +233,86 @@ class ServiceTests(unittest.TestCase):
self.assertEqual(self.request('GET', '/v1/logs')[0], 404) self.assertEqual(self.request('GET', '/v1/logs')[0], 404)
self.assertEqual((self.controller.home / 'check-request-01.log').stat().st_mode & 0o777, 0o600) self.assertEqual((self.controller.home / 'check-request-01.log').stat().st_mode & 0o777, 0o600)
def test_logs_include_failure_commands_and_events_with_credentials_redacted(self):
(self.root / '.env').write_text('POSTGRES_PASSWORD="database-secret"\nAPI_TOKEN=api-secret\n'
'SMTP_PASSWORD=complex,secret/suffix\n'
'DATABASE_URL=postgres://user:db%2Fsecret@db/fedeo\n')
(self.root / 'docker-compose.override.yml').write_text(' MINIO_ROOT_PASSWORD: "minio-secret"\n')
self.factory.failure = s.engine.UpdateError('Registry unauthorized: database-secret')
request_id = self.check()
job = self.root / '.updates' / ('job-' + request_id)
(job / 'target.env').write_text("SMTP_PASSWORD='previous-secret'\n")
(job / 'commands.log').write_text(
'\x1b[31mError: pull access denied\x1b[0m\n'
'database-secret api-secret minio-secret previous-secret release-secret\n'
'complex,secret/suffix complex%2Csecret%2Fsuffix db/secret\n'
'https://user:url-secret@registry.test/image\n'
'Authorization: Bearer header-secret\n'
'{"password": "json-secret", "access_token": "query-secret"}\n'
'TOKEN=unquoted-secret\n--password cli-secret\n')
with patch.dict(s.os.environ, {'FEDEO_RELEASE_TOKEN': 'release-secret'}):
status, result = self.request('GET', f'/v1/requests/{request_id}/logs')
self.assertEqual(status, 200)
self.assertEqual(result['requestId'], request_id)
logs = {entry['source']: entry['content'] for entry in result['logs']}
self.assertIn('Registry unauthorized', logs['service'])
self.assertIn('pull access denied', logs['commands'])
self.assertIn('validating', logs['events'])
for secret in ('database-secret', 'api-secret', 'minio-secret', 'previous-secret', 'release-secret',
'url-secret', 'header-secret', 'json-secret', 'query-secret', 'unquoted-secret', 'cli-secret',
'complex,secret/suffix', 'complex%2Csecret%2Fsuffix', 'db/secret', '\x1b'):
self.assertNotIn(secret, json.dumps(result))
self.assertTrue(self.request('GET', '/v1/status')[1]['capabilities']['requestLogs'])
def test_logs_survive_restart_and_find_legacy_jobs_without_crossing_requests(self):
first = self.check()
first_job = self.root / '.updates' / ('job-' + first)
(first_job / 'commands.log').write_text('first request only\n')
# Simulate the private request record from an older host service.
record = self.controller.read(first)
record.pop('job')
s.engine.write_json(self.controller.path(first), record)
second = self.check('check-request-02')
(self.root / '.updates' / ('job-' + second) / 'commands.log').write_text('second request only\n')
self.controller.close()
self.controller = s.Controller(self.root, updater_factory=self.factory)
self.server.controller = self.controller
for request_id, own, other in [(first, 'first', 'second'), (second, 'second', 'first')]:
payload = json.dumps(self.request('GET', f'/v1/requests/{request_id}/logs')[1])
self.assertIn(own + ' request only', payload)
self.assertNotIn(other + ' request only', payload)
self.assertEqual(self.request('GET', '/v1/requests/missing-request/logs')[0], 404)
self.assertEqual(self.request('GET', '/v1/requests/../../secret/logs')[0], 400)
def test_running_logs_are_bounded_and_do_not_expose_partial_secret_lines(self):
self.factory.release.clear()
request_id = 'check-running-logs'
self.request('POST', '/v1/check', {'requestId': request_id})
self.assertTrue(self.factory.started.wait(3))
job = self.root / '.updates' / ('job-' + request_id)
(job / 'commands.log').write_text('PASSWORD=' + 'secret-fragment' * 4000 + '\n' + 'old entry\n' * 900 + 'Last diagnostic: ä\n')
result = self.request('GET', f'/v1/requests/{request_id}/logs')[1]
commands = next(entry for entry in result['logs'] if entry['source'] == 'commands')
self.assertTrue(commands['truncated'])
self.assertLessEqual(len(commands['content'].encode()), s.LOG_BYTES)
self.assertIn('Last diagnostic: ä', commands['content'])
self.assertNotIn('secret-fragment', commands['content'])
self.assertLess(len(json.dumps(result).encode()), 65536)
def test_logs_fail_closed_for_symlinks_and_unreadable_redaction_configuration(self):
request_id = self.check()
job = self.root / '.updates' / ('job-' + request_id)
private = self.root / 'private.txt'
private.write_text('never publish this')
(job / 'commands.log').symlink_to(private)
status, result = self.request('GET', f'/v1/requests/{request_id}/logs')
self.assertEqual(status, 503)
self.assertEqual(result['error']['code'], 'LOGS_UNAVAILABLE')
self.assertNotIn('never publish this', json.dumps(result))
(job / 'commands.log').unlink()
(self.root / '.env').symlink_to(private)
self.assertEqual(self.request('GET', f'/v1/requests/{request_id}/logs')[0], 503)
def test_api_rejects_unsupported_commands_options_bodies_and_browser_requests(self): def test_api_rejects_unsupported_commands_options_bodies_and_browser_requests(self):
for body in [[], {'requestId': 'bad/path'}, {'requestId': 'check-request-01', 'version': ';reboot'}, for body in [[], {'requestId': 'bad/path'}, {'requestId': 'check-request-01', 'version': ';reboot'},
{'requestId': 'check-request-01', 'directory': '/etc'}, {'requestId': 'check-request-01', 'directory': '/etc'},