feat(selfhost): show sanitized update logs in admin UI #231
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
|
||||
@@ -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/<requestId>/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/<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` |
|
||||
|
||||
Beispiel für eine Prüfung des neuesten Releases:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
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 confirmOpen = 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',
|
||||
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 statusLabel = computed(() => phaseLabels[progress.value?.phase || ''] || statusLabels[job.value?.status || 'idle'])
|
||||
const backupSize = computed(() => {
|
||||
@@ -59,6 +60,24 @@ const reload = () => window.location.reload()
|
||||
</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?.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">
|
||||
<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>
|
||||
|
||||
@@ -7,9 +7,11 @@ export type UpdateJob = UpdateState & {
|
||||
plan?: { version: string; revision: string; previousVersion?: string; updateAvailable: boolean;
|
||||
requiresComposeAdoption: boolean; estimatedBackupBytes?: number; retiredServices: string[] };
|
||||
}
|
||||
export type UpdateLogs = { requestId: string; logs: { source: 'service' | 'events' | 'commands'; content: string; truncated: boolean }[] }
|
||||
type Overview = {
|
||||
acceptingRequests: boolean; engine: UpdateState; request?: UpdateJob;
|
||||
installed: { version?: string }; backend: { version: string; revision: string };
|
||||
capabilities?: { requestLogs: boolean };
|
||||
}
|
||||
type Submission = { command: 'check' | 'apply'; body: { requestId: string; version?: string; checkRequestId?: string } }
|
||||
|
||||
@@ -24,6 +26,12 @@ export const useSelfhostUpdates = () => {
|
||||
const refreshing = ref(false)
|
||||
const message = ref('')
|
||||
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 generation = 0
|
||||
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.'
|
||||
}
|
||||
}
|
||||
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 () => {
|
||||
if (refreshing.value || stopped || accessDenied.value) return
|
||||
refreshing.value = true
|
||||
@@ -81,6 +111,11 @@ export const useSelfhostUpdates = () => {
|
||||
connected.value = true
|
||||
message.value = pending.value ? 'Start noch nicht bestätigt. Du kannst denselben Auftrag erneut senden.' : ''
|
||||
persist()
|
||||
if (['failed', 'interrupted'].includes(job.value?.status || '') && openedFailureId !== job.value?.requestId) {
|
||||
openedFailureId = job.value?.requestId
|
||||
logsOpen.value = true
|
||||
}
|
||||
await refreshLogs()
|
||||
} catch (error) {
|
||||
if (currentGeneration !== generation || stopped) return
|
||||
connected.value = false
|
||||
@@ -90,6 +125,8 @@ export const useSelfhostUpdates = () => {
|
||||
const sendPending = async () => {
|
||||
if (!pending.value || submitting.value || accessDenied.value) return
|
||||
generation++
|
||||
logs.value = null
|
||||
logsMessage.value = ''
|
||||
submitting.value = true
|
||||
const submission = pending.value
|
||||
try {
|
||||
@@ -109,6 +146,7 @@ export const useSelfhostUpdates = () => {
|
||||
report(error)
|
||||
}
|
||||
} finally { submitting.value = false; persist() }
|
||||
await refreshLogs()
|
||||
}
|
||||
const check = async () => {
|
||||
if (!available.value) return
|
||||
@@ -145,5 +183,5 @@ export const useSelfhostUpdates = () => {
|
||||
})
|
||||
onBeforeUnmount(() => { stopped = true; clearTimeout(timer) })
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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.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, /<script>/)
|
||||
assert.doesNotMatch(html, /<script>/)
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ import socketserver
|
||||
import stat
|
||||
import threading
|
||||
import traceback
|
||||
import urllib.parse
|
||||
|
||||
spec = importlib.util.spec_from_file_location("fedeo_updater", Path(__file__).with_name("selfhost-update.py"))
|
||||
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.",
|
||||
"RECOVERY_REQUIRED": "Ein vorheriges Update benötigt Wiederherstellung auf dem Host.",
|
||||
"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.",
|
||||
}
|
||||
|
||||
@@ -82,6 +84,74 @@ def public_plan(plan):
|
||||
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:
|
||||
def __init__(self, directory, *, updater_factory=engine.Updater):
|
||||
self.directory = Path(directory).resolve()
|
||||
@@ -140,9 +210,47 @@ class Controller:
|
||||
result["progress"] = public_state(state)
|
||||
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):
|
||||
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", {})
|
||||
result["installed"] = {k: v for k, v in public_state({"version": installed.get("version"),
|
||||
"revision": installed.get("commit")}).items() if k != "status"}
|
||||
@@ -208,6 +316,7 @@ class Controller:
|
||||
|
||||
def run(self, record, fingerprint):
|
||||
request_id = record["requestId"]
|
||||
updater = None
|
||||
try:
|
||||
record.update(status="running", updatedAt=engine.now())
|
||||
engine.write_json(self.path(request_id), record)
|
||||
@@ -224,6 +333,8 @@ class Controller:
|
||||
os.chmod(log.name, 0o600)
|
||||
traceback.print_exc(file=log)
|
||||
finally:
|
||||
if updater and updater.state and updater.state.get("requestId") == request_id:
|
||||
record["job"] = str(updater.job)
|
||||
record["updatedAt"] = engine.now()
|
||||
engine.write_json(self.path(request_id), record)
|
||||
|
||||
@@ -267,7 +378,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if self.command == "GET" and self.path == "/v1/status":
|
||||
self.send_json(200, controller.status())
|
||||
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"):
|
||||
lengths = self.headers.get_all("Content-Length", [])
|
||||
if (len(lengths) != 1 or not lengths[0].isdigit() or not 1 <= int(lengths[0]) <= 4096
|
||||
|
||||
@@ -8,6 +8,7 @@ import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
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.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):
|
||||
for body in [[], {'requestId': 'bad/path'}, {'requestId': 'check-request-01', 'version': ';reboot'},
|
||||
{'requestId': 'check-request-01', 'directory': '/etc'},
|
||||
|
||||
Reference in New Issue
Block a user