diff --git a/anode_client.py b/anode_client.py index 5ca591f..b1454e4 100644 --- a/anode_client.py +++ b/anode_client.py @@ -1,4 +1,5 @@ import os +from contextlib import contextmanager import httpx @@ -18,3 +19,22 @@ def anode_request(method: str, path: str, **kwargs) -> dict: response.raise_for_status() return response.json() + + +@contextmanager +def anode_stream(method: str, path: str): + """ + Fuer den ISO-Download (Phase 5): der Browser hat kein + KUNDENPLATTFORM_TOKEN, daher muss Kundenplattform als Proxy streamen + statt einen direkten Link auf anode auszugeben. Kein Read-Timeout - eine + mehrere GB grosse Datei ueber die WireGuard-Verbindung zu anode kann + laenger dauern als der sonst genutzte 15s-Timeout erlaubt. + """ + + with httpx.stream( + method, + ANODE_URL + path, + headers={"Authorization": f"Bearer {KUNDENPLATTFORM_TOKEN}"}, + timeout=httpx.Timeout(15, read=None), + ) as response: + yield response diff --git a/app.py b/app.py index 7a9b2fe..1b8a9dd 100644 --- a/app.py +++ b/app.py @@ -2,7 +2,15 @@ from fastapi import FastAPI from fastapi.staticfiles import StaticFiles import auth -from routers import admin_flows, admin_geraete, admin_kunden, admin_technik, geraete +from routers import ( + admin_flows, + admin_geraete, + admin_kunden, + admin_technik, + geraete, + installationsmedium, + organisation, +) app = FastAPI(title="Tuxflotte Kundenplattform") @@ -10,6 +18,8 @@ app.mount("/static", StaticFiles(directory="static"), name="static") app.include_router(auth.router) app.include_router(geraete.router) +app.include_router(organisation.router) +app.include_router(installationsmedium.router) app.include_router(admin_technik.router) app.include_router(admin_geraete.router) app.include_router(admin_flows.router) diff --git a/encryption.py b/encryption.py new file mode 100644 index 0000000..6970655 --- /dev/null +++ b/encryption.py @@ -0,0 +1,34 @@ +import os + +from cryptography.fernet import Fernet, InvalidToken + + +# Phase 5: verschluesselt den WLAN-PSK at-rest (echte Zugangsdaten, kein +# Passwort-Hash-Fall - muss beim ISO-Bau wieder im Klartext verfuegbar +# sein). Eigener Schluessel statt Ableitung aus SESSION_SECRET, um +# Cookie/JWT-Signing und At-rest-Verschluesselung nicht zu vermischen. +# Erzeugen: python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +ENCRYPTION_KEY = os.environ.get("KUNDENPLATTFORM_ENCRYPTION_KEY", "") + + +def _fernet() -> Fernet: + if not ENCRYPTION_KEY: + raise RuntimeError("KUNDENPLATTFORM_ENCRYPTION_KEY ist nicht gesetzt.") + + return Fernet(ENCRYPTION_KEY.encode("ascii")) + + +def encrypt_psk(psk: str) -> bytes: + return _fernet().encrypt(psk.encode("utf-8")) + + +def decrypt_psk(psk_encrypted) -> str | None: + if psk_encrypted is None: + return None + + try: + return _fernet().decrypt(bytes(psk_encrypted)).decode("utf-8") + except InvalidToken: + # Sollte nur bei Schluesselwechsel/Datenkorruption passieren - dann + # lieber "kein PSK gespeichert" anzeigen als mit 500 abzubrechen. + return None diff --git a/migrations/0004_installationsmedium.sql b/migrations/0004_installationsmedium.sql new file mode 100644 index 0000000..da77d3a --- /dev/null +++ b/migrations/0004_installationsmedium.sql @@ -0,0 +1,32 @@ +BEGIN; + +-- Phase 5 des Self-Service-ISO-Plans: gespeicherte Installationsmedium- +-- Konfiguration je Organisation (WLAN-Zugangsdaten, gewaehlte +-- Bereitstellungsvorlage, Enrollment-Session-Parameter). Wie +-- benutzer.organization_id bewusst ohne Foreign Key - Kundenplattform hat +-- eine eigene, von provisioning-server getrennte Datenbank (siehe +-- ADR-0011). Ein Datensatz je Organisation reicht (organization_id ist +-- Primary Key statt eigener id-Spalte) - es gibt genau eine "aktuelle" +-- Konfiguration, keine Historie. +-- +-- wifi_psk_encrypted: echte WLAN-Zugangsdaten, kein Passwort-Hash-Fall - +-- muss entschluesselbar bleiben (wird beim ISO-Bau im Klartext an +-- build_customer_iso.sh durchgereicht, siehe encryption.py), daher +-- Fernet-verschluesselt statt gehasht. +-- +-- enrollment_session_id/iso_build_id verweisen auf provisioning-server- +-- Ressourcen (ebenfalls ohne Foreign Key, andere Datenbank). Beide sind +-- nullable, bis das erste Installationsmedium erstellt wurde. +CREATE TABLE installationsmedium_konfiguration ( + organization_id UUID PRIMARY KEY, + wifi_ssid TEXT, + wifi_psk_encrypted BYTEA, + bereitstellungsvorlage_id UUID, + max_devices INTEGER NOT NULL DEFAULT 20, + validity_days INTEGER NOT NULL DEFAULT 30, + enrollment_session_id UUID, + iso_build_id UUID, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +COMMIT; diff --git a/routers/installationsmedium.py b/routers/installationsmedium.py new file mode 100644 index 0000000..b8ba61d --- /dev/null +++ b/routers/installationsmedium.py @@ -0,0 +1,281 @@ +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"'}, + ) diff --git a/routers/organisation.py b/routers/organisation.py new file mode 100644 index 0000000..927c214 --- /dev/null +++ b/routers/organisation.py @@ -0,0 +1,28 @@ +from fastapi import APIRouter, Depends, Form, HTTPException, Request +from fastapi.responses import RedirectResponse + +from anode_client import anode_request +from auth import get_current_user +from templating import templates + + +router = APIRouter() + + +@router.get("/organisation") +def organisation_ansicht(request: Request, user=Depends(get_current_user)): + result = anode_request("GET", f"/api/v1/organizations/{user.organization_id}") + + if not result.get("success"): + raise HTTPException(status_code=404, detail="Organisation wurde nicht gefunden.") + + return templates.TemplateResponse( + request, "organisation.html", {"user": user, "organization": result["organization"]} + ) + + +@router.post("/organisation") +def organisation_speichern(name: str = Form(...), user=Depends(get_current_user)): + anode_request("PATCH", f"/api/v1/organizations/{user.organization_id}", json={"name": name}) + + return RedirectResponse("/organisation", status_code=303) diff --git a/templates/base.html b/templates/base.html index 84b05a4..4406efa 100644 --- a/templates/base.html +++ b/templates/base.html @@ -18,6 +18,9 @@ {% if user %}