fix(security): v3-Umbau P1 — Geheimnisse raus aus dem Browser, Frontend endlich getestet

Zweite Etappe. Kern: das Frontend traegt kein Geheimnis mehr, und es laeuft
nicht laenger als einziger Teil des Stacks ungeprueft durchs Gate.

## B-01 — Box-Sudo-Passwort: ersatzlos entfernt

Das Passwort lag im localStorage und reiste bei JEDEM mutierenden Request mit —
als Header `X-Sudo-Password` UND im JSON-Rumpf. Da /etc/sudoers den Dienst-Nutzer
mit `NOPASSWD: ALL` fuehrt, waere ein einziger XSS in der SPA Root auf der Box
gewesen.

AUF DER BOX GEMESSEN: `sudo -n true` laeuft durch. Das Passwort wurde also nie
gebraucht — es war reines Risiko ohne Gegenwert. Darum keine Umkonstruktion
(Sitzungs-Cookie o. AE.), sondern Loeschung, quer durch den ganzen Pfad:

  frontend/src/lib/api.ts             kein localStorage-Zugriff mehr
  frontend/.../SettingsTab.tsx        Eingabefeld weg, dafuer die Erklaerung warum
  backend/routers/maintenance.py      SudoReq entfaellt, 8 Endpunkte entschlackt
  backend/services/maintenance.py     _run() nutzt immer `sudo -n`
  backend/services/jobengine.py       keine stdin-Pipe mehr (DEVNULL)

`password_required` bleibt als ehrliches Signal: Verlangt sudo je doch ein
Passwort, ist das eine Konfigurations-Frage auf der Box — nichts, was man mit
einem im Browser geparkten Geheimnis uebertuencht.

## HuggingFace-Token: liegt jetzt auf der Box

Derselbe Fehler, kleinerer Radius. Neu: backend/services/geheimnisse.py — Datei
neben den anderen mc2-*.json, Rechte 0600, atomar geschrieben. Die Oberflaeche
erfaehrt nur, OB ein Token gesetzt ist, nie seinen Wert. Ein Schluessel-Allowlist
verhindert, dass ein fehlgeleiteter Request beliebige Felder hineinschreibt.
Prozess-Env (HF_TOKEN) hat Vorrang und wird als solche angezeigt.
Verifiziert gegen das lokale Backend: setzen/lesen/loeschen ok, unerlaubter
Schluessel wird mit Klartext-Grund abgewiesen, der Wert kommt nie zurueck.

## B-14 — Fehlermeldungen sagen jetzt, was los ist

api() warf `new Error("500 Internal Server Error")` und verwarf den Rumpf; der
eigentliche Grund aus FastAPIs `detail` erreichte die Oberflaeche nie. Neu:
ApiError mit status + detail, inklusive Validierungslisten und HTML-Fehlerseiten
(ein kaputter Rumpf darf die Meldung nicht in einen zweiten Fehler verwandeln).
Zwei Aufrufstellen zeigen den Grund jetzt statt "Fehler" (Discover, ModelBrowse).

## B-07 — Tests und Linter, ehrlich eingeordnet

Praezisierung gegenueber dem Audit: Die MC2-Ampel fuehrt bewusst GAR KEINE Tests
aus (dokumentiert: die Python-Dienste haengen an ML-Wheels, die echten Tests sind
Pruefstand + Box). Das ist fuer die Dienste richtig — fuer Frontend-Unit-Tests
nicht: die laufen in jsdom, brauchen weder Modell noch GPU, und sind in 1,3 s durch.

  Vitest + Testing Library, 17 Tests in 3 Dateien
  ESLint (flat config) + Prettier
  Beides jetzt Teil der Ampel

Die Tests sind kein Feigenblatt: acht davon sind der Zaun um B-01 — sie beweisen,
dass api() weder Kopfzeilen noch Rumpf aus dem localStorage anreichert. Dazu eine
ESLint-Regel, die localStorage-Zugriffe auf Schluessel mit password/token/secret
im Namen hart abweist (an einer Probe verifiziert; harmlose Schluessel wie
mc_sidebar_collapsed bleiben erlaubt).

ESLint meldet 0 Fehler / 93 Warnungen. Die 18 Treffer der neuen React-Compiler-
Regeln (setState im Effekt, Ref-Zugriff im Render) sind ECHT, aber quer durch
10 500 Zeilen zu beheben ist P5-Arbeit. Sie stehen als sichtbare Warn-Liste statt
abgeschaltet — ein ab Tag eins rotes Gate ist kein Gate mehr.

## Nebenbefund, im Browser reproduziert: veraltetes Buendel nach Deploy

Ein Deploy ersetzt dist und startet den Dienst neu; offene Tabs behalten aber ihr
altes Start-Buendel, dessen Nachlade-Chunks nun fehlen — der naechste
Ansichtswechsel wirft. Galt schon fuer die 10 lazy Views, traf durch P0 nun auch
die Startseite. lib/veralteteVersion.ts faengt Vites `vite:preloadError` ab und
laedt EINMAL neu (Sperre in sessionStorage gegen Endlosschleife).

## Nachgemessen

  Start-Chunk gzip 111 411 B · dist gesamt 1 480 097 B (beide im Ampel-Budget)
  tsc --noEmit sauber · 17/17 Tests gruen · ESLint 0 Fehler

Im echten Browser gegen das lokale Backend geprueft: Einstellungen holen den
Token-Zustand von der Box, kein Passwort-Feld mehr, Knoepfe korrekt gesperrt,
beide Cockpit-Diagramme rendern (Achsen + Zeitachse sichtbar).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-28 08:44:48 +02:00
co-authored by Claude Opus 5
parent 8aa22e6591
commit ae69c5ce31
47 changed files with 9347 additions and 6240 deletions
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest"
import { render, screen, waitFor } from "@testing-library/react"
import { LiveAreaChart } from "./LiveAreaChart"
import type { ChartSeries } from "./chartTypes"
const SERIEN: ChartSeries[] = [{ key: "cpu", label: "CPU", color: "#2dd4bf" }]
const DATEN = [
{ t: 1_700_000_000_000, cpu: 12 },
{ t: 1_700_000_003_000, cpu: 34 },
]
// Die Lazy-Huelle ist der Grund, warum Recharts nicht mehr im Startbuendel liegt (P0).
// Wichtig ist dabei nur eines: Der Platzhalter muss GENAU so hoch sein wie das spaetere
// Diagramm — sonst springt die Karte beim Nachladen.
describe("LiveAreaChart (Lazy-Huelle)", () => {
it("haelt vor dem Nachladen die volle Diagrammhoehe frei", () => {
const { container } = render(<LiveAreaChart data={DATEN} series={SERIEN} height={176} />)
const platzhalter = container.querySelector<HTMLElement>('[aria-hidden="true"]')
expect(platzhalter).not.toBeNull()
expect(platzhalter!.style.height).toBe("176px")
})
it("uebernimmt eine abweichende Hoehe", () => {
const { container } = render(<LiveAreaChart data={DATEN} series={SERIEN} height={96} />)
expect(container.querySelector<HTMLElement>('[aria-hidden="true"]')!.style.height).toBe("96px")
})
it("laedt die Recharts-Umsetzung nach und zeigt sie an", async () => {
const { container } = render(<LiveAreaChart data={DATEN} series={SERIEN} height={176} />)
await waitFor(() => {
expect(container.querySelector(".recharts-responsive-container")).not.toBeNull()
})
// Platzhalter ist verschwunden, sobald das Diagramm da ist.
expect(container.querySelector('[aria-hidden="true"]')).toBeNull()
})
it("ist fuer Screenreader stumm — der Platzhalter ist reine Optik", () => {
render(<LiveAreaChart data={DATEN} series={SERIEN} height={176} />)
expect(screen.queryByText(/l(ä|ae)dt/i)).toBeNull()
})
})
+182 -123
View File
@@ -1,123 +1,182 @@
import { useEffect, useState } from "react"
import { Eye, EyeOff, Key, Shield } from "lucide-react"
// Einstellungen-Tab des SystemDrawers (Review P2-14: extrahiert). Hält seinen State komplett
// selbst (localStorage-Zugangsdaten) — der Drawer liefert nur `open` (zum Neu-Laden) und
// `showAlert` (gemeinsamer Dialog).
export function SettingsTab({ open, showAlert }: {
open: boolean
showAlert: (title: string, message: string) => void
}) {
const [sudoPasswordInput, setSudoPasswordInput] = useState("")
const [hfTokenInput, setHfTokenInput] = useState("")
const [showSudo, setShowSudo] = useState(false)
const [showHf, setShowHf] = useState(false)
useEffect(() => {
if (open) {
setSudoPasswordInput(localStorage.getItem("mc_sudo_password") || "")
setHfTokenInput(localStorage.getItem("mc_hf_token") || "")
}
}, [open])
return (
<div className="space-y-6">
<div className="space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Zugangsdaten & Schlüssel</h3>
<p className="text-[10px] text-muted-foreground leading-normal">
Diese Daten werden ausschließlich lokal in deinem Browser (localStorage) gespeichert und bei Bedarf an das Backend übertragen.
</p>
</div>
{/* Sudo Passwort */}
<div className="space-y-2">
<label className="text-xs font-semibold flex items-center gap-1.5">
<Shield className="h-4 w-4 text-amber-400" />
Host Sudo-Passwort
</label>
<div className="relative">
<input
type={showSudo ? "text" : "password"}
value={sudoPasswordInput}
onChange={(e) => setSudoPasswordInput(e.target.value)}
aria-label="Host Sudo-Passwort"
name="sudo-password"
autoComplete="off"
spellCheck={false}
placeholder="Sudo-Passwort für System-Operationen"
className="w-full h-10 pl-3 pr-10 text-xs bg-background/40 border border-border/60 rounded-lg outline-none focus:border-primary transition-colors text-foreground"
/>
<button
type="button"
onClick={() => setShowSudo(!showSudo)}
aria-label={showSudo ? "Passwort verbergen" : "Passwort anzeigen"}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
>
{showSudo ? <EyeOff className="h-4 w-4" aria-hidden="true" /> : <Eye className="h-4 w-4" aria-hidden="true" />}
</button>
</div>
<p className="text-[9px] text-muted-foreground leading-normal">
Wird benötigt für systemd-Dienste (Llama Swap logs/restart), OS-Update (apt) und Reboot.
</p>
</div>
{/* HuggingFace Token */}
<div className="space-y-2">
<label className="text-xs font-semibold flex items-center gap-1.5">
<Key className="h-4 w-4 text-violet-400" />
HuggingFace API Token
</label>
<div className="relative">
<input
type={showHf ? "text" : "password"}
value={hfTokenInput}
onChange={(e) => setHfTokenInput(e.target.value)}
aria-label="HuggingFace API Token"
name="hf-token"
autoComplete="off"
spellCheck={false}
placeholder="hf_…"
className="w-full h-10 pl-3 pr-10 text-xs bg-background/40 border border-border/60 rounded-lg outline-none focus:border-primary transition-colors text-foreground"
/>
<button
type="button"
onClick={() => setShowHf(!showHf)}
aria-label={showHf ? "Token verbergen" : "Token anzeigen"}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
>
{showHf ? <EyeOff className="h-4 w-4" aria-hidden="true" /> : <Eye className="h-4 w-4" aria-hidden="true" />}
</button>
</div>
<p className="text-[9px] text-muted-foreground leading-normal">
Erlaubt das Herunterladen von geschützten oder Gate-restricted Modellen direkt über Mission Control.
</p>
</div>
{/* Buttons */}
<div className="flex gap-3 pt-2">
<button
onClick={() => {
localStorage.setItem("mc_sudo_password", sudoPasswordInput);
localStorage.setItem("mc_hf_token", hfTokenInput);
showAlert("Erfolgreich", "Einstellungen erfolgreich lokal gespeichert.");
}}
className="flex-1 h-9 flex items-center justify-center text-xs font-semibold text-primary-foreground bg-primary hover:bg-primary/90 rounded-lg transition-colors shadow shadow-primary/10 cursor-pointer"
>
Speichern
</button>
<button
onClick={() => {
setSudoPasswordInput("");
setHfTokenInput("");
localStorage.removeItem("mc_sudo_password");
localStorage.removeItem("mc_hf_token");
showAlert("Gelöscht", "Zugangsdaten gelöscht.");
}}
className="h-9 px-4 flex items-center justify-center text-xs font-semibold text-muted-foreground border border-border/60 bg-background/20 hover:bg-accent rounded-lg transition-colors cursor-pointer"
>
Zurücksetzen
</button>
</div>
</div>
)
}
import { useEffect, useState } from "react"
import { Eye, EyeOff, Key, Loader2, ShieldCheck } from "lucide-react"
import { api } from "@/lib/api"
// Einstellungen-Tab des SystemDrawers.
//
// v3-Umbau P1 (28.08.2026): Hier standen zwei Geheimnisse, die im localStorage des
// Browsers lagen und bei jedem mutierenden Request mitreisten.
//
// · Das Box-Sudo-Passwort ist ERSATZLOS weg. Auf der Box gemessen: `sudo -n true`
// laeuft durch, weil /etc/sudoers den Dienst-Nutzer mit `NOPASSWD: ALL` fuehrt.
// Das Feld hat also nie etwas bewirkt — es war reines Risiko (ein XSS in dieser
// SPA haette Root bedeutet).
// · Der HuggingFace-Token liegt jetzt auf der Box (services/geheimnisse.py, 0600).
// Diese Ansicht erfaehrt nur noch, OB einer gesetzt ist — nie seinen Wert. Deshalb
// gibt es kein "Anzeigen" fuer einen gespeicherten Token, sondern nur Ersetzen
// oder Loeschen.
interface GeheimnisStatus {
hf_token_gesetzt: boolean
hf_token_aus_env: boolean
schreibbar: boolean
}
export function SettingsTab({ open, showAlert }: {
open: boolean
showAlert: (title: string, message: string) => void
}) {
const [status, setStatus] = useState<GeheimnisStatus | null>(null)
const [eingabe, setEingabe] = useState("")
const [sichtbar, setSichtbar] = useState(false)
const [laedt, setLaedt] = useState(false)
const [arbeitet, setArbeitet] = useState(false)
async function statusLaden() {
setLaedt(true)
try {
setStatus(await api<GeheimnisStatus>("/api/maintenance/geheimnisse"))
} catch (e: any) {
showAlert("Nicht erreichbar", `Der Geheimnis-Zustand konnte nicht geladen werden: ${e?.message || e}`)
} finally {
setLaedt(false)
}
}
useEffect(() => {
if (open) {
setEingabe("")
setSichtbar(false)
void statusLaden()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
async function setzen(wert: string | null) {
setArbeitet(true)
try {
const r = await api<GeheimnisStatus>("/api/maintenance/geheimnisse", {
method: "POST",
body: JSON.stringify({ schluessel: "hf_token", wert }),
})
setStatus(r)
setEingabe("")
showAlert(wert ? "Gespeichert" : "Gelöscht",
wert ? "Der Token liegt jetzt auf der Box — der Browser behält ihn nicht."
: "Der Token wurde von der Box entfernt.")
} catch (e: any) {
showAlert("Fehlgeschlagen", e?.message || String(e))
} finally {
setArbeitet(false)
}
}
return (
<div className="space-y-6">
<div className="space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Zugangsdaten &amp; Schlüssel</h3>
<p className="text-[10px] leading-normal text-muted-foreground">
Geheimnisse liegen auf der Box (Datei mit Rechten 0600), nicht im Browser. Diese Seite
zeigt nur, ob etwas gesetzt ist den Wert bekommt sie nie zurück.
</p>
</div>
{/* Sudo: bewusst nur noch eine Erklaerung, kein Eingabefeld. */}
<div className="mc-card-sm space-y-1.5 p-3">
<div className="flex items-center gap-1.5 text-xs font-semibold">
<ShieldCheck className="h-4 w-4 text-emerald-400" aria-hidden="true" />
Box-Sudo-Passwort nicht mehr nötig
</div>
<p className="text-[10px] leading-normal text-muted-foreground">
Systemdienste, OS-Update und Neustart laufen passwortlos (<code className="font-mono text-primary">NOPASSWD</code> in
<code className="font-mono"> /etc/sudoers</code>). Das frühere Feld hat nie etwas bewirkt und lag dabei
im Browser-Speicher es ist am 28.08.2026 ersatzlos entfallen. Verlangt <code className="font-mono">sudo</code> auf
der Box je doch ein Passwort, sagt die Wartung das im Klartext; das ist dann dort zu lösen.
</p>
</div>
{/* HuggingFace-Token */}
<div className="space-y-2">
<label htmlFor="hf-token" className="flex items-center gap-1.5 text-xs font-semibold">
<Key className="h-4 w-4 text-violet-400" aria-hidden="true" />
HuggingFace-Token
</label>
<div className="flex items-center gap-2 text-[11px]">
{laedt ? (
<span className="flex items-center gap-1.5 text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" /> Zustand wird geladen
</span>
) : status?.hf_token_gesetzt ? (
<span className="flex items-center gap-1.5 font-semibold text-emerald-400">
<span className="h-2 w-2 rounded-full bg-emerald-500" aria-hidden="true" />
Gesetzt{status.hf_token_aus_env ? " (aus der Dienst-Umgebung)" : ""}
</span>
) : (
<span className="flex items-center gap-1.5 text-muted-foreground">
<span className="h-2 w-2 rounded-full bg-muted-foreground/50" aria-hidden="true" />
Nicht gesetzt
</span>
)}
</div>
{status?.hf_token_aus_env && (
<p className="text-[9px] leading-normal text-amber-400/90">
Der Token kommt aus der Umgebung des Dienstes und hat Vorrang. Hier gespeicherte Werte
bleiben wirkungslos, solange <code className="font-mono">HF_TOKEN</code> gesetzt ist.
</p>
)}
{status && !status.schreibbar && (
<p className="text-[9px] leading-normal text-amber-400/90">
Die Ablage ist nicht beschreibbar auf einem Entwicklungsrechner ohne den Modell-Ordner
der Box ist das normal. Speichern würde hier ins Leere laufen.
</p>
)}
<div className="relative">
<input
id="hf-token"
type={sichtbar ? "text" : "password"}
value={eingabe}
onChange={(e) => setEingabe(e.target.value)}
autoComplete="off"
spellCheck={false}
placeholder={status?.hf_token_gesetzt ? "Neuen Token eingeben, um zu ersetzen" : "hf_…"}
className="h-10 w-full rounded-lg border border-border/60 bg-background/40 pl-3 pr-10 text-xs text-foreground outline-none transition-colors focus:border-primary"
/>
<button
type="button"
onClick={() => setSichtbar(!sichtbar)}
aria-label={sichtbar ? "Token verbergen" : "Token anzeigen"}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground transition-colors hover:text-foreground"
>
{sichtbar ? <EyeOff className="h-4 w-4" aria-hidden="true" /> : <Eye className="h-4 w-4" aria-hidden="true" />}
</button>
</div>
<p className="text-[9px] leading-normal text-muted-foreground">
Nötig für Modelle mit Zugangsschranke. Wird beim Download als
<code className="font-mono"> HF_TOKEN</code> an den Job gereicht und verlässt die Box nicht.
</p>
</div>
<div className="flex gap-3 pt-2">
<button
onClick={() => setzen(eingabe.trim())}
disabled={arbeitet || !eingabe.trim()}
className="flex h-9 flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg bg-primary text-xs font-semibold text-primary-foreground shadow shadow-primary/10 transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-40"
>
{arbeitet && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />}
Auf der Box speichern
</button>
<button
onClick={() => setzen(null)}
disabled={arbeitet || !status?.hf_token_gesetzt}
className="flex h-9 cursor-pointer items-center justify-center rounded-lg border border-border/60 bg-background/20 px-4 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-40"
>
Löschen
</button>
</div>
</div>
)
}