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>
This commit is contained in:
parent
576613ed0f
commit
19b39af0ce
@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@ -18,3 +19,22 @@ def anode_request(method: str, path: str, **kwargs) -> dict:
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
return response.json()
|
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
|
||||||
|
|||||||
12
app.py
12
app.py
@ -2,7 +2,15 @@ from fastapi import FastAPI
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
import auth
|
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")
|
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(auth.router)
|
||||||
app.include_router(geraete.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_technik.router)
|
||||||
app.include_router(admin_geraete.router)
|
app.include_router(admin_geraete.router)
|
||||||
app.include_router(admin_flows.router)
|
app.include_router(admin_flows.router)
|
||||||
|
|||||||
34
encryption.py
Normal file
34
encryption.py
Normal file
@ -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
|
||||||
32
migrations/0004_installationsmedium.sql
Normal file
32
migrations/0004_installationsmedium.sql
Normal file
@ -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;
|
||||||
281
routers/installationsmedium.py
Normal file
281
routers/installationsmedium.py
Normal file
@ -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"'},
|
||||||
|
)
|
||||||
28
routers/organisation.py
Normal file
28
routers/organisation.py
Normal file
@ -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)
|
||||||
@ -18,6 +18,9 @@
|
|||||||
<ul><li><strong><a href="/geraete">Tuxflotte Kundenplattform</a></strong></li></ul>
|
<ul><li><strong><a href="/geraete">Tuxflotte Kundenplattform</a></strong></li></ul>
|
||||||
{% if user %}
|
{% if user %}
|
||||||
<ul>
|
<ul>
|
||||||
|
<li><a href="/geraete">Geräte</a></li>
|
||||||
|
<li><a href="/installationsmedium">Installationsmedium</a></li>
|
||||||
|
<li><a href="/organisation">Organisation</a></li>
|
||||||
{% if user.is_superuser %}<li><a href="/admin/technik">Admin</a></li>{% endif %}
|
{% if user.is_superuser %}<li><a href="/admin/technik">Admin</a></li>{% endif %}
|
||||||
<li>{{ user.email }}</li>
|
<li>{{ user.email }}</li>
|
||||||
<li><a href="/logout" role="button" class="secondary outline">Abmelden</a></li>
|
<li><a href="/logout" role="button" class="secondary outline">Abmelden</a></li>
|
||||||
|
|||||||
112
templates/installationsmedium.html
Normal file
112
templates/installationsmedium.html
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Installationsmedium — Tuxflotte{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>Installationsmedium</h2>
|
||||||
|
|
||||||
|
{% if iso_build and iso_build.status in ("pending", "running") %}
|
||||||
|
<meta http-equiv="refresh" content="5">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if konfiguration %}
|
||||||
|
<section>
|
||||||
|
<h3>Aktuelles Installationsmedium</h3>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr><th>WLAN-SSID</th><td>{{ konfiguration.wifi_ssid or "—" }}</td></tr>
|
||||||
|
<tr><th>Vorlage</th><td>
|
||||||
|
{% set vorlage = vorlagen | selectattr("id", "equalto", konfiguration.bereitstellungsvorlage_id) | first %}
|
||||||
|
{{ vorlage.label if vorlage else "—" }}
|
||||||
|
</td></tr>
|
||||||
|
{% if enrollment_session %}
|
||||||
|
<tr><th>Kontingent</th><td>{{ enrollment_session.devices_used }} / {{ enrollment_session.max_devices }} Geräte</td></tr>
|
||||||
|
<tr><th>Gültig bis</th><td>{{ enrollment_session.expires_at }}</td></tr>
|
||||||
|
<tr><th>Widerrufen</th><td>{{ "ja" if enrollment_session.revoked_at else "nein" }}</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% if iso_build %}
|
||||||
|
{% if iso_build.status in ("pending", "running") %}
|
||||||
|
<p>⏳ Wird gerade erstellt (Status: {{ iso_build.status }}) — diese Seite aktualisiert sich automatisch alle 5 Sekunden.</p>
|
||||||
|
{% elif iso_build.status == "completed" %}
|
||||||
|
<p><a href="/installationsmedium/download" role="button">Installationsmedium herunterladen</a></p>
|
||||||
|
<details>
|
||||||
|
<summary role="button" class="secondary outline">Anleitung: auf USB-Stick übertragen</summary>
|
||||||
|
<p>
|
||||||
|
Unter Windows: <a href="https://rufus.ie/" target="_blank" rel="noopener">Rufus</a> verwenden —
|
||||||
|
heruntergeladene .iso-Datei auswählen, USB-Stick auswählen, "Start" klicken.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Unter Linux/macOS:
|
||||||
|
<code>sudo dd if=tuxflotte-installationsmedium.iso of=/dev/sdX bs=4M status=progress conv=fsync</code>
|
||||||
|
(<code>/dev/sdX</code> durch den tatsächlichen USB-Stick ersetzen — <strong>alle Daten darauf werden überschrieben</strong>).
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
{% elif iso_build.status == "failed" %}
|
||||||
|
<p><mark>Der Bau ist fehlgeschlagen. Bitte weiter unten erneut versuchen.</mark></p>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if enrollment_session and not enrollment_session.revoked_at %}
|
||||||
|
<section>
|
||||||
|
<h3>Kontingent/Gültigkeit aufladen</h3>
|
||||||
|
<p>
|
||||||
|
Erhöht Kontingent und/oder Gültigkeit der bestehenden Sitzung — das bereits heruntergeladene
|
||||||
|
Installationsmedium bleibt gültig, es muss <strong>kein</strong> neues erstellt werden.
|
||||||
|
</p>
|
||||||
|
<form method="post" action="/installationsmedium/aufladen">
|
||||||
|
<div class="grid">
|
||||||
|
<label>Zusätzliche Geräte
|
||||||
|
<input type="number" name="additional_devices" min="0" value="0">
|
||||||
|
</label>
|
||||||
|
<label>Zusätzliche Tage Gültigkeit
|
||||||
|
<input type="number" name="additional_days" min="0" value="0">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Aufladen</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>{{ "Neues Installationsmedium erstellen" if konfiguration else "Installationsmedium erstellen" }}</h3>
|
||||||
|
{% if konfiguration %}
|
||||||
|
<p>
|
||||||
|
Erstellt ein komplett neues Installationsmedium mit eigener Sitzung — nötig, wenn sich die
|
||||||
|
WLAN-Zugangsdaten geändert haben. Das bisherige Medium bleibt bis zu seinem Ablauf weiter nutzbar.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="/installationsmedium">
|
||||||
|
<label for="wifi_ssid">WLAN-SSID</label>
|
||||||
|
<input type="text" id="wifi_ssid" name="wifi_ssid" value="{{ konfiguration.wifi_ssid if konfiguration else '' }}">
|
||||||
|
|
||||||
|
<label for="wifi_psk">WLAN-Passwort</label>
|
||||||
|
<input type="password" id="wifi_psk" name="wifi_psk"
|
||||||
|
placeholder="{{ '•••••••• (gespeichert – zum Ändern neu eingeben)' if konfiguration and konfiguration.hat_wifi_psk else '' }}">
|
||||||
|
|
||||||
|
<label for="bereitstellungsvorlage_id">Vorlage</label>
|
||||||
|
<select id="bereitstellungsvorlage_id" name="bereitstellungsvorlage_id" required>
|
||||||
|
{% for v in vorlagen %}
|
||||||
|
<option value="{{ v.id }}"
|
||||||
|
{% if konfiguration and konfiguration.bereitstellungsvorlage_id == v.id %}selected
|
||||||
|
{% elif not konfiguration and v.is_default %}selected{% endif %}>
|
||||||
|
{{ v.label }} ({{ v.backend.name }} {{ v.backend.version }})
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<label>Kontingent (Anzahl Geräte)
|
||||||
|
<input type="number" name="max_devices" min="1" value="{{ konfiguration.max_devices if konfiguration else 20 }}" required>
|
||||||
|
</label>
|
||||||
|
<label>Gültigkeit (Tage)
|
||||||
|
<input type="number" name="validity_days" min="1" value="{{ konfiguration.validity_days if konfiguration else 30 }}" required>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit">{{ "Neues Installationsmedium erstellen" if konfiguration else "Installationsmedium erstellen" }}</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
11
templates/organisation.html
Normal file
11
templates/organisation.html
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Organisation — Tuxflotte{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>Organisation</h2>
|
||||||
|
|
||||||
|
<form method="post" action="/organisation">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input type="text" id="name" name="name" value="{{ organization.name }}" required>
|
||||||
|
<button type="submit">Speichern</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
Loading…
x
Reference in New Issue
Block a user