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 <noreply@anthropic.com>
This commit is contained in:
Thomas Stallinger 2026-08-04 17:00:03 +02:00
commit c4825acd61
11 changed files with 492 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.venv/
__pycache__/
*.pyc

61
README.md Normal file
View File

@ -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 <organization-uuid> \
--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.

196
app.py Normal file
View File

@ -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)

25
kundenplattform.service Normal file
View File

@ -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

View File

@ -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;

23
requirements.txt Normal file
View File

@ -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

53
scripts/create_user.py Normal file
View File

@ -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()

View File

@ -0,0 +1,49 @@
{% extends "base.html" %}
{% block title %}Auftragskatalog {{ device.hostname or device.device_fingerprint }} — Tuxflotte{% endblock %}
{% block content %}
<p><a href="/geraete">← Geräteliste</a></p>
<h2>Auftragskatalog: {{ device.hostname or device.device_fingerprint }}</h2>
{% if fehler %}
<p class="fehler">{{ fehler }}</p>
{% else %}
{% for kategorie_name, eintraege in kategorien.items() %}
<div class="kategorie">
<h2>{{ kategorie_name }}</h2>
<table>
<tbody>
{% for eintrag in eintraege %}
<tr>
<td>
<strong>{{ eintrag.name }}</strong>
{% if eintrag.description %}<br><small>{{ eintrag.description }}</small>{% endif %}
</td>
<td class="state-{{ eintrag.state }}">
{{ "ausgewählt" if eintrag.state == "present" else "nicht ausgewählt" }}
</td>
<td>
{% if eintrag.state == "present" %}
<form class="inline" method="post" action="/geraete/{{ device.id }}/auftragskatalog/{{ eintrag.merkmal }}/deselect">
<button type="submit">Abwählen</button>
</form>
{% else %}
<form class="inline" method="post" action="/geraete/{{ device.id }}/auftragskatalog/{{ eintrag.merkmal }}/select">
{% if optionen_felder.get(eintrag.merkmal) %}
<div class="optionen">
{% for feld in optionen_felder[eintrag.merkmal] %}
<label><input type="checkbox" name="optionen.{{ feld.key }}"> {{ feld.label }}</label>
{% endfor %}
</div>
{% endif %}
<button type="submit">Auswählen</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endfor %}
{% endif %}
{% endblock %}

32
templates/base.html Normal file
View File

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>{% block title %}Tuxflotte{% endblock %}</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 60rem; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; }
header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 2rem; border-bottom: 1px solid #ddd; padding-bottom: 0.5rem; }
header a { color: inherit; text-decoration: none; }
h1 { font-size: 1.2rem; margin: 0; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid #eee; }
.kategorie { margin-top: 1.5rem; }
.kategorie h2 { font-size: 1rem; color: #555; margin-bottom: 0.3rem; }
.state-present { color: #1a7f37; font-weight: 600; }
.state-absent { color: #999; }
form.inline { display: inline; }
button { cursor: pointer; padding: 0.3rem 0.8rem; }
.fehler { background: #fee; border: 1px solid #f99; padding: 0.6rem; border-radius: 4px; margin-bottom: 1rem; }
.optionen label { font-weight: normal; font-size: 0.9rem; color: #555; display: block; margin: 0.2rem 0; }
</style>
</head>
<body>
<header>
<h1><a href="/geraete">Tuxflotte Kundenplattform</a></h1>
{% if user %}
<span>{{ user.email }} — <a href="/logout">Abmelden</a></span>
{% endif %}
</header>
{% block content %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block title %}Geräte — Tuxflotte{% endblock %}
{% block content %}
<h2>Ihre Geräte</h2>
{% if not devices %}
<p>Keine Geräte gefunden.</p>
{% else %}
<table>
<thead>
<tr><th>Hostname</th><th>Kennung</th><th>Letzter Check-in</th><th></th></tr>
</thead>
<tbody>
{% for device in devices %}
<tr>
<td>{{ device.hostname or "—" }}</td>
<td><code>{{ device.device_fingerprint }}</code></td>
<td>{{ device.agent_last_checkin or "noch nie" }}</td>
<td><a href="/geraete/{{ device.id }}">Auftragskatalog →</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock %}

11
templates/login.html Normal file
View File

@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}Anmelden — Tuxflotte{% endblock %}
{% block content %}
<h2>Anmelden</h2>
{% if error %}<p class="fehler">{{ error }}</p>{% endif %}
<form method="post" action="/login">
<p><label>E-Mail<br><input type="email" name="email" required autofocus></label></p>
<p><label>Passwort<br><input type="password" name="password" required></label></p>
<button type="submit">Anmelden</button>
</form>
{% endblock %}