commit c4825acd61ff70c628e3c36fced731b8dbf8704a Author: Thomas Stallinger Date: Tue Aug 4 17:00:03 2026 +0200 feat: initiale Kundenplattform (Login + Auftragskatalog-Auswahl) Neuer, von provisioning-server (anode) getrennter Dienst, siehe ADR-0011: FastAPI + Jinja2, eigene Postgres-DB (benutzer-Tabelle), eigenes Service-Token (TUXFLOTTE_KUNDENPLATTFORM_TOKEN) für die Kommunikation mit anode. Org-Zugehörigkeits-Prüfung passiert hier in der Anwendung vor jedem Aufruf gegen anode. Umfang v1: Login, Geräteliste, Auftragskatalog geräteweise aus-/abwählen (inkl. der standardbrowser-Option für Brave). systemd-Unit läuft unter eigenem unprivilegiertem System-User statt root. Co-Authored-By: Claude Sonnet 5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..77ac754 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.venv/ +__pycache__/ +*.pyc diff --git a/README.md b/README.md new file mode 100644 index 0000000..7960e0d --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# kundenplattform + +Kunden-Web-UI für den Tuxflotte-Auftragskatalog — von `provisioning-server` +(anode) bewusst getrennter Dienst, siehe [ADR-0011](https://git.tuxflotte.de/admin/platform-docs/raw/branch/main/adr/0011-kundenplattform-getrennter-dienst.md). + +## Architektur + +- FastAPI + Jinja2 (serverseitig gerendert, kein Frontend-Build). +- Eigene Postgres-Datenbank (`benutzer`-Tabelle: E-Mail/Passwort-Hash pro + Organisation) — getrennt von provisioning-servers Datenbank, keine + Cross-Database-Referenzen. +- Spricht mit anode ausschließlich über dessen HTTP-API, authentifiziert mit + einem eigenen Service-Token (`TUXFLOTTE_KUNDENPLATTFORM_TOKEN`, getrennt + vom Admin-Token). Organisationszugehörigkeits-Prüfung (darf dieser Login + auf dieses Gerät zugreifen?) passiert hier in der Anwendung, vor jedem + Aufruf gegen anode — siehe `find_device_in_organization()` in `app.py`. + +## Setup (lokal) + +``` +python3 -m venv .venv +.venv/bin/pip install -r requirements.txt +KUNDENPLATTFORM_DATABASE_URL=postgresql://... .venv/bin/psql ... -f migrations/0001_initial_schema.sql +``` + +Env-Variablen: + +- `TUXFLOTTE_ANODE_URL` (Default `http://127.0.0.1:8080`) +- `TUXFLOTTE_KUNDENPLATTFORM_TOKEN` — muss mit dem gleichnamigen Wert in + provisioning-servers `/etc/tuxflotte/provisioning-server.env` übereinstimmen +- `KUNDENPLATTFORM_DATABASE_URL` +- `KUNDENPLATTFORM_SESSION_SECRET` — beliebiger zufälliger String zum Signieren + der Session-Cookie (z.B. `openssl rand -base64 32`) + +Start: `.venv/bin/uvicorn app:app --port 8081` + +## Konto anlegen + +Kein Self-Service in v1 (siehe ADR-0011) — Konten werden manuell angelegt: + +``` +KUNDENPLATTFORM_DATABASE_URL=... .venv/bin/python scripts/create_user.py \ + --organization-id \ + --email admin@schule-beispiel.de +``` + +## Deploy (anode) + +Wie bei `provisioning-server`, git-basiert statt scp: + +``` +git pull --ff-only +systemctl restart kundenplattform +``` + +Läuft unter einem eigenen, unprivilegierten System-User `kundenplattform` +(nicht root, anders als provisioning-server) — siehe `kundenplattform.service`. +Port `8081` (provisioning-server belegt `8080`). + +**Öffentliche Erreichbarkeit über Pangolin ist nicht Teil dieses Repos** — +das ist ein separater Ops/DNS/Reverse-Proxy-Schritt. diff --git a/app.py b/app.py new file mode 100644 index 0000000..2ce171e --- /dev/null +++ b/app.py @@ -0,0 +1,196 @@ +import os +from uuid import uuid4 + +import bcrypt +import httpx +import psycopg +from fastapi import Depends, FastAPI, Form, HTTPException, Request +from fastapi.responses import RedirectResponse +from fastapi.templating import Jinja2Templates +from starlette.middleware.sessions import SessionMiddleware + + +ANODE_URL = os.environ.get("TUXFLOTTE_ANODE_URL", "http://127.0.0.1:8080") +KUNDENPLATTFORM_TOKEN = os.environ.get("TUXFLOTTE_KUNDENPLATTFORM_TOKEN", "") +DATABASE_URL = os.environ.get("KUNDENPLATTFORM_DATABASE_URL") +SESSION_SECRET = os.environ.get("KUNDENPLATTFORM_SESSION_SECRET", "") + +# Merkmale, für die die UI eine zusätzliche Auswahl-Option anbietet (siehe +# ADR-0010 optionen-Mechanismus). Bewusst hier als kleine, explizite Map +# statt eines generischen Options-Schemas - nur ein Fall existiert aktuell. +OPTIONEN_FELDER = { + "browser-brave": [ + {"key": "standardbrowser", "label": "Als Standardbrowser festlegen"}, + ], +} + +app = FastAPI(title="Tuxflotte Kundenplattform") +app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET, session_cookie="kundenplattform_session") +templates = Jinja2Templates(directory="templates") + + +def get_database_connection(): + if not DATABASE_URL: + raise RuntimeError("KUNDENPLATTFORM_DATABASE_URL ist nicht gesetzt.") + + return psycopg.connect(DATABASE_URL) + + +def anode_request(method: str, path: str, **kwargs) -> dict: + response = httpx.request( + method, + ANODE_URL + path, + headers={"Authorization": f"Bearer {KUNDENPLATTFORM_TOKEN}"}, + timeout=15, + **kwargs, + ) + response.raise_for_status() + + return response.json() + + +def get_current_user(request: Request) -> dict: + user = request.session.get("user") + + if user is None: + raise HTTPException(status_code=303, headers={"Location": "/login"}) + + return user + + +def find_device_in_organization(device_id: str, organization_id: str) -> dict | None: + """ + Besitz-Validierung (siehe ADR-0011): ein device_id-Wert aus der URL wird + nur akzeptiert, wenn er tatsächlich in der Geräteliste der eigenen + Organisation auftaucht - verhindert, dass ein eingeloggter Kunde per + manipulierter URL auf fremde Geräte zugreift. + """ + + result = anode_request("GET", f"/api/v1/organizations/{organization_id}/devices") + devices = result.get("devices", []) if result.get("success") else [] + + for device in devices: + if device["id"] == device_id: + return device + + return None + + +@app.get("/") +def index(user: dict = Depends(get_current_user)): + return RedirectResponse("/geraete", status_code=303) + + +@app.get("/login") +def login_form(request: Request): + if request.session.get("user") is not None: + return RedirectResponse("/geraete", status_code=303) + + return templates.TemplateResponse(request, "login.html", {"error": None}) + + +@app.post("/login") +def login_submit(request: Request, email: str = Form(...), password: str = Form(...)): + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT id, organization_id, password_hash FROM benutzer WHERE email = %s", + (email,), + ) + row = cur.fetchone() + + if row is None or not bcrypt.checkpw(password.encode("utf-8"), row[2].encode("utf-8")): + return templates.TemplateResponse( + request, "login.html", {"error": "E-Mail oder Passwort ist falsch."}, status_code=401 + ) + + request.session["user"] = { + "id": str(row[0]), + "organization_id": str(row[1]), + "email": email, + } + + return RedirectResponse("/geraete", status_code=303) + + +@app.get("/logout") +def logout(request: Request): + request.session.clear() + + return RedirectResponse("/login", status_code=303) + + +@app.get("/geraete") +def geraete_liste(request: Request, user: dict = Depends(get_current_user)): + result = anode_request("GET", f"/api/v1/organizations/{user['organization_id']}/devices") + devices = result.get("devices", []) if result.get("success") else [] + + return templates.TemplateResponse( + request, "geraete_liste.html", {"user": user, "devices": devices} + ) + + +@app.get("/geraete/{device_id}") +def auftragskatalog_ansicht(request: Request, device_id: str, user: dict = Depends(get_current_user)): + device = find_device_in_organization(device_id, user["organization_id"]) + + if device is None: + raise HTTPException(status_code=404, detail="Gerät wurde nicht gefunden.") + + result = anode_request("GET", f"/api/v1/devices/{device_id}/auftragskatalog") + katalog = result.get("auftragskatalog", []) if result.get("success") else [] + fehler = None if result.get("success") else result.get("message") + + # Bereits nach kategorie.sort_order sortiert (siehe anode-Query) - hier + # nur noch gruppieren, kein erneutes Sortieren (würde die Reihenfolge + # durcheinanderbringen). + kategorien: dict[str, list[dict]] = {} + for eintrag in katalog: + kategorie_name = eintrag["kategorie"]["name"] if eintrag["kategorie"] else "Ohne Kategorie" + kategorien.setdefault(kategorie_name, []).append(eintrag) + + return templates.TemplateResponse( + request, + "auftragskatalog.html", + { + "user": user, + "device": device, + "kategorien": kategorien, + "fehler": fehler, + "optionen_felder": OPTIONEN_FELDER, + }, + ) + + +@app.post("/geraete/{device_id}/auftragskatalog/{merkmal_key}/select") +async def auftrag_select( + request: Request, device_id: str, merkmal_key: str, user: dict = Depends(get_current_user) +): + if find_device_in_organization(device_id, user["organization_id"]) is None: + raise HTTPException(status_code=404, detail="Gerät wurde nicht gefunden.") + + form = await request.form() + optionen = { + feld["key"]: (form.get(f"optionen.{feld['key']}") == "on") + for feld in OPTIONEN_FELDER.get(merkmal_key, []) + } + + anode_request( + "POST", + f"/api/v1/devices/{device_id}/auftragskatalog/{merkmal_key}/select", + json={"optionen": optionen}, + ) + + return RedirectResponse(f"/geraete/{device_id}", status_code=303) + + +@app.post("/geraete/{device_id}/auftragskatalog/{merkmal_key}/deselect") +def auftrag_deselect(device_id: str, merkmal_key: str, user: dict = Depends(get_current_user)): + if find_device_in_organization(device_id, user["organization_id"]) is None: + raise HTTPException(status_code=404, detail="Gerät wurde nicht gefunden.") + + anode_request( + "POST", f"/api/v1/devices/{device_id}/auftragskatalog/{merkmal_key}/deselect" + ) + + return RedirectResponse(f"/geraete/{device_id}", status_code=303) diff --git a/kundenplattform.service b/kundenplattform.service new file mode 100644 index 0000000..de909cb --- /dev/null +++ b/kundenplattform.service @@ -0,0 +1,25 @@ +[Unit] +Description=Tuxflotte Kundenplattform +After=network.target + +[Service] +Type=simple +User=kundenplattform +Group=kundenplattform +WorkingDirectory=/opt/kundenplattform +EnvironmentFile=/etc/tuxflotte/kundenplattform.env +ExecStart=/opt/kundenplattform/.venv/bin/uvicorn app:app --host 0.0.0.0 --port 8081 +Restart=always +RestartSec=3 + +# Härtung - läuft bewusst unter einem eigenen, unprivilegierten System-User +# statt root (anders als provisioning-server, siehe ADR-0011: exponiertere, +# kundenzugängliche Komponente). +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/opt/kundenplattform + +[Install] +WantedBy=multi-user.target diff --git a/migrations/0001_initial_schema.sql b/migrations/0001_initial_schema.sql new file mode 100644 index 0000000..9011429 --- /dev/null +++ b/migrations/0001_initial_schema.sql @@ -0,0 +1,15 @@ +BEGIN; + +-- organization_id verweist auf provisioning-servers organizations.id, aber +-- bewusst ohne Foreign Key: Kundenplattform hat eine eigene Datenbank, +-- getrennt von provisioning-server (siehe ADR-0011), keine Cross-Database- +-- Referenzen möglich oder gewollt. +CREATE TABLE benutzer ( + id UUID PRIMARY KEY, + organization_id UUID NOT NULL, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +COMMIT; diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cb45fbe --- /dev/null +++ b/requirements.txt @@ -0,0 +1,23 @@ +annotated-doc==0.0.5 +annotated-types==0.8.0 +anyio==4.14.2 +bcrypt==5.0.0 +certifi==2026.7.22 +click==8.4.2 +fastapi==0.141.1 +h11==0.16.0 +httpcore==1.0.9 +httpx==0.28.1 +idna==3.18 +itsdangerous==2.2.0 +Jinja2==3.1.6 +MarkupSafe==3.0.3 +psycopg==3.3.4 +psycopg-binary==3.3.4 +pydantic==2.13.4 +pydantic_core==2.46.4 +python-multipart==0.0.32 +starlette==1.3.1 +typing-inspection==0.4.2 +typing_extensions==4.16.0 +uvicorn==0.52.1 diff --git a/scripts/create_user.py b/scripts/create_user.py new file mode 100644 index 0000000..f99f5b1 --- /dev/null +++ b/scripts/create_user.py @@ -0,0 +1,53 @@ +""" +Manuelle Konto-Anlage für die Kundenplattform (kein Self-Service in v1, siehe +ADR-0011). Beispiel: + + KUNDENPLATTFORM_DATABASE_URL=... .venv/bin/python scripts/create_user.py \\ + --organization-id 11111111-1111-4111-8111-111111111111 \\ + --email admin@schule-beispiel.de +""" + +import argparse +import getpass +import os +import sys +from uuid import uuid4 + +import bcrypt +import psycopg + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--organization-id", required=True) + parser.add_argument("--email", required=True) + args = parser.parse_args() + + database_url = os.environ.get("KUNDENPLATTFORM_DATABASE_URL") + + if not database_url: + sys.exit("KUNDENPLATTFORM_DATABASE_URL ist nicht gesetzt.") + + password = getpass.getpass("Passwort: ") + password_confirm = getpass.getpass("Passwort (Wiederholung): ") + + if password != password_confirm: + sys.exit("Passwörter stimmen nicht überein.") + + password_hash = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + with psycopg.connect(database_url) as conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO benutzer (id, organization_id, email, password_hash) + VALUES (%s, %s, %s, %s) + """, + (uuid4(), args.organization_id, args.email, password_hash), + ) + + print(f"Konto für {args.email} angelegt.") + + +if __name__ == "__main__": + main() diff --git a/templates/auftragskatalog.html b/templates/auftragskatalog.html new file mode 100644 index 0000000..5235ad3 --- /dev/null +++ b/templates/auftragskatalog.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% block title %}Auftragskatalog {{ device.hostname or device.device_fingerprint }} — Tuxflotte{% endblock %} +{% block content %} +

← Geräteliste

+

Auftragskatalog: {{ device.hostname or device.device_fingerprint }}

+ +{% if fehler %} +

{{ fehler }}

+{% else %} + {% for kategorie_name, eintraege in kategorien.items() %} +
+

{{ kategorie_name }}

+ + + {% for eintrag in eintraege %} + + + + + + {% endfor %} + +
+ {{ eintrag.name }} + {% if eintrag.description %}
{{ eintrag.description }}{% endif %} +
+ {{ "ausgewählt" if eintrag.state == "present" else "nicht ausgewählt" }} + + {% if eintrag.state == "present" %} +
+ +
+ {% else %} +
+ {% if optionen_felder.get(eintrag.merkmal) %} +
+ {% for feld in optionen_felder[eintrag.merkmal] %} + + {% endfor %} +
+ {% endif %} + +
+ {% endif %} +
+
+ {% endfor %} +{% endif %} +{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..7b5d25e --- /dev/null +++ b/templates/base.html @@ -0,0 +1,32 @@ + + + + + {% block title %}Tuxflotte{% endblock %} + + + +
+

Tuxflotte Kundenplattform

+ {% if user %} + {{ user.email }} — Abmelden + {% endif %} +
+ {% block content %}{% endblock %} + + diff --git a/templates/geraete_liste.html b/templates/geraete_liste.html new file mode 100644 index 0000000..26de558 --- /dev/null +++ b/templates/geraete_liste.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block title %}Geräte — Tuxflotte{% endblock %} +{% block content %} +

Ihre Geräte

+{% if not devices %} +

Keine Geräte gefunden.

+{% else %} + + + + + + {% for device in devices %} + + + + + + + {% endfor %} + +
HostnameKennungLetzter Check-in
{{ device.hostname or "—" }}{{ device.device_fingerprint }}{{ device.agent_last_checkin or "noch nie" }}Auftragskatalog →
+{% endif %} +{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..f020fec --- /dev/null +++ b/templates/login.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% block title %}Anmelden — Tuxflotte{% endblock %} +{% block content %} +

Anmelden

+{% if error %}

{{ error }}

{% endif %} +
+

+

+ +
+{% endblock %}