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>
108 lines
3.2 KiB
Python
108 lines
3.2 KiB
Python
"""Wartungs-Endpoints: Update-Badge, OS-/Engine-Update, Reboot, Restart, Logs.
|
|
|
|
v3-Umbau P1 (28.08.2026): Das Sudo-Passwort ist hier ersatzlos entfallen. Auf der Box
|
|
gemessen — `sudo -n true` läuft durch, weil `/etc/sudoers` den Dienst-Nutzer mit
|
|
`NOPASSWD: ALL` führt. Das Passwort wurde also nie gebraucht, lag aber im
|
|
`localStorage` des Browsers und reiste bei jedem mutierenden Request mit. Sollte die
|
|
sudoers-Zeile je fallen, meldet `services.maintenance` sauber `password_required`
|
|
statt still zu scheitern — das ist dann ein Konfigurations-Signal und nichts, was man
|
|
mit einem im Browser geparkten Geheimnis übertüncht.
|
|
"""
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
from pydantic import BaseModel
|
|
from services import geheimnisse, maintenance
|
|
|
|
router = APIRouter(prefix="/api")
|
|
|
|
|
|
class RestartReq(BaseModel):
|
|
service: str
|
|
|
|
|
|
class GeheimnisReq(BaseModel):
|
|
schluessel: str
|
|
wert: str | None = None
|
|
|
|
|
|
@router.get("/maintenance/updates")
|
|
def updates() -> dict:
|
|
return maintenance.updates()
|
|
|
|
|
|
@router.get("/maintenance/update-details")
|
|
def update_details(kind: str) -> dict:
|
|
if kind not in ("os", "engine", "swap", "hermes"):
|
|
raise HTTPException(400, "Unbekannte Update-Art.")
|
|
return maintenance.update_details(kind)
|
|
|
|
@router.post("/maintenance/check-updates")
|
|
def check_updates() -> dict:
|
|
res = maintenance.check_updates_job()
|
|
if isinstance(res, dict) and not res.get("ok", True):
|
|
return res
|
|
return res
|
|
|
|
|
|
@router.post("/maintenance/os-update")
|
|
def os_update() -> dict:
|
|
res = maintenance.os_update_job()
|
|
if isinstance(res, dict) and not res.get("ok", True):
|
|
return res
|
|
return res
|
|
|
|
|
|
@router.post("/maintenance/engine-update")
|
|
def engine_update() -> dict:
|
|
res = maintenance.engine_update_job()
|
|
if not res:
|
|
raise HTTPException(400, "Kein Engine-Update-Befehl gesetzt (MC_ENGINE_UPDATE_CMD).")
|
|
return res
|
|
|
|
|
|
@router.post("/maintenance/swap-update")
|
|
def swap_update() -> dict:
|
|
res = maintenance.swap_update_job()
|
|
if not res:
|
|
raise HTTPException(400, "Kein Router-Update-Befehl gesetzt (MC_SWAP_UPDATE_CMD).")
|
|
return res
|
|
|
|
|
|
@router.post("/maintenance/hermes-update")
|
|
def hermes_update() -> dict:
|
|
return maintenance.hermes_update_job()
|
|
|
|
|
|
@router.post("/maintenance/update-all")
|
|
def update_all() -> dict:
|
|
return maintenance.update_all_job()
|
|
|
|
|
|
@router.post("/maintenance/reboot")
|
|
def reboot() -> dict:
|
|
return maintenance.reboot()
|
|
|
|
|
|
@router.post("/maintenance/restart")
|
|
def restart(body: RestartReq) -> dict:
|
|
return maintenance.restart_service(body.service)
|
|
|
|
|
|
@router.get("/maintenance/logs")
|
|
def logs(service: str, lines: int = 200) -> dict:
|
|
return maintenance.logs(service, lines)
|
|
|
|
|
|
@router.get("/maintenance/geheimnisse")
|
|
def geheimnisse_status() -> dict:
|
|
"""Nur der Zustand — ob ein Token gesetzt ist, niemals sein Wert."""
|
|
return geheimnisse.status()
|
|
|
|
|
|
@router.post("/maintenance/geheimnisse")
|
|
def geheimnisse_setzen(body: GeheimnisReq) -> dict:
|
|
"""Setzt (oder löscht bei leerem Wert) ein Geheimnis in der Box-Ablage."""
|
|
if not geheimnisse.setzen(body.schluessel, body.wert):
|
|
raise HTTPException(400, f"Geheimnis '{body.schluessel}' konnte nicht gespeichert werden.")
|
|
return {"ok": True, **geheimnisse.status()}
|