kundenplattform/routers/installationsmedium.py
Thomas Stallinger 19b39af0ce feat: Selfservice-UI fuer Organisation + Installationsmedium (Phase 5)
Zwei neue Menuepunkte fuer eingeloggte Kunden (nicht der
organisationsuebergreifende /admin/-Bereich):

- "Organisation": Name bearbeiten (ruft Phase-3-PATCH auf
  provisioning-server auf).
- "Installationsmedium": WLAN-SSID/-Passwort, Vorlagenwahl, Kontingent
  und Gueltigkeitsdauer speichern, personalisierte Kunden-ISO per
  Klick erstellen (Phase-4-Endpoints), Fortschritt/Status-Anzeige
  (Meta-Refresh waehrend pending/running), Download + dd/Rufus-
  Anleitung. Getrennte "aufladen"-Aktion erhoeht Kontingent/
  Gueltigkeit der bestehenden Enrollment Session (neuer PATCH-
  Endpoint), ohne eine neue ISO zu erzeugen - die bereits ausgeteilte
  bleibt gueltig.

Neue Tabelle installationsmedium_konfiguration (Migration 0004, eigene
DB, kein Foreign Key auf provisioning-server-Ressourcen, siehe
ADR-0011) haelt SSID/verschluesselten PSK/Vorlage/Kontingent-Defaults
sowie die IDs der aktuell gueltigen Enrollment Session und des
zugehoerigen ISO-Baus.

WLAN-PSK wird Fernet-verschluesselt gespeichert (encryption.py, neuer
KUNDENPLATTFORM_ENCRYPTION_KEY) - echte Zugangsdaten, kein
Passwort-Hash-Fall. Download laeuft ueber einen Streaming-Proxy
(anode_stream() in anode_client.py) statt eines direkten Links, weil
der Browser kein KUNDENPLATTFORM_TOKEN besitzt.

Lokal end-to-end verifiziert (Wegwerf-Postgres fuer beide Services,
echter build_customer_iso.sh-Lauf ueber die Kundenplattform-UI):
Formular -> Enrollment Session + ISO-Bau -> Status-Polling ->
Download-Proxy liefert byte-identische, korrekt personalisierte Datei
(SSID/PSK/Aktivierungscode per xorriso-Extraktion gegengeprueft) ->
Aufladen erhoeht Kontingent/Gueltigkeit der bestehenden Session ohne
neuen Bau -> "neues Installationsmedium erstellen" erzeugt neue
Session + ISO, alte Session bleibt unangetastet nutzbar (alter
ISO-Bau wird laut Phase-4-Cleanup automatisch entfernt), leeres
PSK-Feld uebernimmt das zuvor gespeicherte PSK korrekt.
Organisation-Name-Aenderung ueber PATCH verifiziert. Unauthentifizierter
Zugriff auf beide neuen Routen leitet korrekt zu /login um.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 01:36:46 +02:00

282 lines
11 KiB
Python

from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import RedirectResponse, StreamingResponse
from anode_client import anode_request, anode_stream
from auth import get_current_user
from db import get_database_connection
from encryption import decrypt_psk, encrypt_psk
from templating import templates
router = APIRouter()
def fetch_konfiguration(organization_id: str) -> dict | None:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT wifi_ssid, wifi_psk_encrypted, bereitstellungsvorlage_id,
max_devices, validity_days, enrollment_session_id, iso_build_id
FROM installationsmedium_konfiguration
WHERE organization_id = %s
""",
(organization_id,),
)
row = cur.fetchone()
if row is None:
return None
(wifi_ssid, wifi_psk_encrypted, bereitstellungsvorlage_id,
max_devices, validity_days, enrollment_session_id, iso_build_id) = row
return {
"wifi_ssid": wifi_ssid,
"hat_wifi_psk": wifi_psk_encrypted is not None,
"bereitstellungsvorlage_id": str(bereitstellungsvorlage_id) if bereitstellungsvorlage_id else None,
"max_devices": max_devices,
"validity_days": validity_days,
"enrollment_session_id": str(enrollment_session_id) if enrollment_session_id else None,
"iso_build_id": str(iso_build_id) if iso_build_id else None,
}
def speichere_konfiguration_basis(
organization_id: str,
wifi_ssid: str,
wifi_psk: str,
bereitstellungsvorlage_id: str,
max_devices: int,
validity_days: int,
) -> None:
# Leeres PSK-Feld laesst ein zuvor gespeichertes PSK unangetastet (siehe
# installationsmedium.html - das Feld zeigt nie den Klartext an, nur
# explizites Neueintippen ueberschreibt es).
psk_encrypted = encrypt_psk(wifi_psk) if wifi_psk else None
with get_database_connection() as conn:
with conn.cursor() as cur:
if wifi_psk:
cur.execute(
"""
INSERT INTO installationsmedium_konfiguration
(organization_id, wifi_ssid, wifi_psk_encrypted, bereitstellungsvorlage_id,
max_devices, validity_days, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
ON CONFLICT (organization_id) DO UPDATE SET
wifi_ssid = EXCLUDED.wifi_ssid,
wifi_psk_encrypted = EXCLUDED.wifi_psk_encrypted,
bereitstellungsvorlage_id = EXCLUDED.bereitstellungsvorlage_id,
max_devices = EXCLUDED.max_devices,
validity_days = EXCLUDED.validity_days,
updated_at = CURRENT_TIMESTAMP
""",
(organization_id, wifi_ssid, psk_encrypted, bereitstellungsvorlage_id,
max_devices, validity_days),
)
else:
cur.execute(
"""
INSERT INTO installationsmedium_konfiguration
(organization_id, wifi_ssid, bereitstellungsvorlage_id,
max_devices, validity_days, updated_at)
VALUES (%s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
ON CONFLICT (organization_id) DO UPDATE SET
wifi_ssid = EXCLUDED.wifi_ssid,
bereitstellungsvorlage_id = EXCLUDED.bereitstellungsvorlage_id,
max_devices = EXCLUDED.max_devices,
validity_days = EXCLUDED.validity_days,
updated_at = CURRENT_TIMESTAMP
""",
(organization_id, wifi_ssid, bereitstellungsvorlage_id,
max_devices, validity_days),
)
def setze_aktuellen_build(organization_id: str, enrollment_session_id: str, iso_build_id: str) -> None:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE installationsmedium_konfiguration
SET enrollment_session_id = %s, iso_build_id = %s
WHERE organization_id = %s
""",
(enrollment_session_id, iso_build_id, organization_id),
)
def lade_gespeichertes_psk(organization_id: str) -> str | None:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT wifi_psk_encrypted FROM installationsmedium_konfiguration WHERE organization_id = %s",
(organization_id,),
)
row = cur.fetchone()
return decrypt_psk(row[0]) if row else None
def finde_enrollment_session(organization_id: str, session_id: str) -> dict | None:
result = anode_request("GET", f"/api/v1/organizations/{organization_id}/enrollment-sessions")
sessions = result.get("enrollment_sessions", []) if result.get("success") else []
return next((s for s in sessions if s["id"] == session_id), None)
@router.get("/installationsmedium")
def installationsmedium_ansicht(request: Request, user=Depends(get_current_user)):
organization_id = str(user.organization_id)
konfiguration = fetch_konfiguration(organization_id)
vorlagen_result = anode_request("GET", f"/api/v1/organizations/{organization_id}/bereitstellungsvorlagen")
vorlagen = vorlagen_result.get("bereitstellungsvorlagen", []) if vorlagen_result.get("success") else []
enrollment_session = None
iso_build = None
if konfiguration and konfiguration["enrollment_session_id"]:
enrollment_session = finde_enrollment_session(organization_id, konfiguration["enrollment_session_id"])
if konfiguration and konfiguration["iso_build_id"]:
build_result = anode_request("GET", f"/api/v1/iso-builds/{konfiguration['iso_build_id']}")
iso_build = build_result.get("iso_build") if build_result.get("success") else None
return templates.TemplateResponse(
request,
"installationsmedium.html",
{
"user": user,
"konfiguration": konfiguration,
"vorlagen": vorlagen,
"enrollment_session": enrollment_session,
"iso_build": iso_build,
},
)
@router.post("/installationsmedium")
def installationsmedium_erstellen(
wifi_ssid: str = Form(default=""),
wifi_psk: str = Form(default=""),
bereitstellungsvorlage_id: str = Form(...),
max_devices: int = Form(...),
validity_days: int = Form(...),
user=Depends(get_current_user),
):
organization_id = str(user.organization_id)
speichere_konfiguration_basis(
organization_id, wifi_ssid, wifi_psk, bereitstellungsvorlage_id, max_devices, validity_days
)
# Nur fuer den ISO-Bau gebraucht, nie ueber diesen Aufruf hinaus
# gespeichert (siehe Phase-4-Design von provisioning-server) - bei
# leerem Feld wird das zuvor gespeicherte PSK weiterverwendet.
psk_fuer_build = wifi_psk or (lade_gespeichertes_psk(organization_id) or "")
expires_at = (datetime.now(timezone.utc) + timedelta(days=validity_days)).isoformat()
session_result = anode_request(
"POST",
f"/api/v1/organizations/{organization_id}/enrollment-sessions",
json={
"bereitstellungsvorlage_id": bereitstellungsvorlage_id,
"max_devices": max_devices,
"expires_at": expires_at,
},
)
if not session_result.get("success"):
raise HTTPException(status_code=400, detail=session_result.get("message", "Enrollment Session konnte nicht erstellt werden."))
session_id = session_result["enrollment_session"]["id"]
build_result = anode_request(
"POST",
f"/api/v1/organizations/{organization_id}/iso-builds",
json={"activation_code": session_id, "wifi_ssid": wifi_ssid, "wifi_psk": psk_fuer_build},
)
if not build_result.get("success"):
raise HTTPException(status_code=400, detail=build_result.get("message", "ISO-Bau konnte nicht gestartet werden."))
setze_aktuellen_build(organization_id, session_id, build_result["iso_build"]["id"])
return RedirectResponse("/installationsmedium", status_code=303)
@router.post("/installationsmedium/aufladen")
def installationsmedium_aufladen(
additional_devices: int = Form(default=0),
additional_days: int = Form(default=0),
user=Depends(get_current_user),
):
organization_id = str(user.organization_id)
konfiguration = fetch_konfiguration(organization_id)
if not konfiguration or not konfiguration["enrollment_session_id"]:
raise HTTPException(status_code=404, detail="Es wurde noch kein Installationsmedium erstellt.")
session_id = konfiguration["enrollment_session_id"]
neues_expires_at = None
if additional_days > 0:
aktuelle_session = finde_enrollment_session(organization_id, session_id)
if aktuelle_session is None:
raise HTTPException(status_code=404, detail="Enrollment Session wurde nicht gefunden.")
aktuelles_ablaufdatum = datetime.fromisoformat(aktuelle_session["expires_at"])
neues_expires_at = (aktuelles_ablaufdatum + timedelta(days=additional_days)).isoformat()
result = anode_request(
"PATCH",
f"/api/v1/enrollment-sessions/{session_id}",
json={"additional_devices": additional_devices, "expires_at": neues_expires_at},
)
if not result.get("success"):
raise HTTPException(status_code=400, detail=result.get("message", "Aufladen fehlgeschlagen."))
return RedirectResponse("/installationsmedium", status_code=303)
@router.get("/installationsmedium/download")
def installationsmedium_download(user=Depends(get_current_user)):
organization_id = str(user.organization_id)
konfiguration = fetch_konfiguration(organization_id)
if not konfiguration or not konfiguration["iso_build_id"]:
raise HTTPException(status_code=404, detail="Es wurde noch kein Installationsmedium erstellt.")
build_id = konfiguration["iso_build_id"]
status_result = anode_request("GET", f"/api/v1/iso-builds/{build_id}")
iso_build = status_result.get("iso_build") if status_result.get("success") else None
# Besitz-Validierung wie bei Geraeten (ADR-0011): iso_build_id kommt
# zwar aus der eigenen Konfigurationszeile, aber sicherheitshalber noch
# einmal explizit gegenpruefen statt der gespeicherten Kennung blind zu
# vertrauen.
if iso_build is None or iso_build["organization_id"] != organization_id:
raise HTTPException(status_code=404, detail="Installationsmedium wurde nicht gefunden.")
if iso_build["status"] != "completed":
raise HTTPException(status_code=409, detail="Das Installationsmedium ist noch nicht fertig gebaut.")
def stream_datei():
with anode_stream("GET", f"/api/v1/iso-builds/{build_id}/download") as response:
response.raise_for_status()
for chunk in response.iter_bytes(chunk_size=1024 * 1024):
yield chunk
return StreamingResponse(
stream_datei(),
media_type="application/octet-stream",
headers={"Content-Disposition": 'attachment; filename="tuxflotte-installationsmedium.iso"'},
)