Webapp-Mechanismus (Phase 2): Server-seitige DB+API+Favicon-Fetch

Nachtrag: Migration 0030 (App-Store-Batch-1-Merkmale) war im letzten
Commit versehentlich nicht mit dabei - jetzt nachgeholt (git status hat
es aufgedeckt).

Neue Tabelle workspace_webapps (Migration 0032): kundeneigene
{name, url}-Eintraege pro Workspace, kein fester Katalog wie bei
Merkmalen - jede Organisation/jeder Workspace hat ihre/seine eigene
Liste (Nutzer-Anforderung 09.09.2026: 'der Kunde hat diverse URLs').

app.py-Aenderungen:
- fetch_favicon(): bestes-Effort-Ermittlung des Website-Favicons
  (erst <link rel=icon> per Regex aus dem HTML, sonst /favicon.ico an
  der Domain-Wurzel) - bewusst ohne neue pip-Abhaengigkeit (nur
  urllib, passt zum bisher sehr schlanken requirements.txt).
- CRUD-Endpunkte /api/v1/workspaces/{id}/webapps[/​{webapp_id}]
  (GET/POST/PUT/DELETE), Icon-Serving unter /webapp-icons/{filename}
  (unauthentifiziert wie /golden-images/, gleicher
  Path-Traversal-Schutz).
- Checkin-Response um 'webapps' erweitert (fetch_workspace_webapps(),
  gleiches assignments->bereitstellungsvorlagen-Join-Muster wie
  fetch_assigned_blueprints()).
- WICHTIG: Server komponiert nie eine eigene oeffentliche URL (ANODE_URL
  ist nur client-seitig bekannt, siehe agent.py/backend.sh) - Checkin
  liefert nur den relativen icon_path, der Agent haengt ANODE_URL davor.

Gehört zusammen mit admin/ansible-content (neue Rolle
webapp-shortcuts, echt end-to-end getestet) und admin/kundenplattform
(Admin-UI unter /admin/technik/workspaces/{id}, Icon-Proxy analog zum
bestehenden ISO-Download-Proxy-Muster).

Live auf anode angewendet: Migration 0032 (workspace_webapps-Tabelle).
app.py-Deployment (git pull + Neustart) folgt separat.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLk93dhDMrkPXefVdknMV9
This commit is contained in:
adminandClaude Sonnet 5 committed 2026-09-09 14:58:51 +02:00
1 parent ead3535e72
commit 4b229a992b
3 files changed
+416

No files matched your search

+285
View File
@@ -3,6 +3,7 @@ from datetime import datetime, timedelta, timezone
import hashlib
import hmac
import json
import mimetypes
import os
import re
import secrets
@@ -10,6 +11,9 @@ import subprocess
import threading
import time
import unicodedata
import urllib.error
import urllib.request
from urllib.parse import urljoin, urlparse
from uuid import UUID, uuid4
import psycopg
@@ -60,6 +64,12 @@ ISO_BUILD_OUTPUT_DIR = Path(os.environ.get("TUXFLOTTE_ISO_BUILD_OUTPUT_DIR", "da
# personalisierter/kundenspezifischer Inhalt (anders als ISO-Builds oben),
# nur ein generisches Basis-Root-Dateisystem pro Distribution.
GOLDEN_IMAGES_DIR = Path(os.environ.get("TUXFLOTTE_GOLDEN_IMAGES_DIR", "data/golden-images")).resolve()
# Webapp-Favicons (Phase 2): serverseitig beim Anlegen/Bearbeiten einer
# Webapp geholt und hier abgelegt (siehe fetch_favicon()), unauthentifiziert
# ausgeliefert wie die Golden Images - der Agent laedt sie vor jedem
# ansible-pull-Lauf, kennt aber (anders als Golden Images) keinen
# Aktivierungs-Kontext dafuer.
WEBAPP_ICONS_DIR = Path(os.environ.get("TUXFLOTTE_WEBAPP_ICONS_DIR", "data/webapp-icons")).resolve()
ISO_BUILD_TIMEOUT_SECONDS = 1800
# ISO-Ablauf nach 7 Tagen (Migration 0021): ergaenzt die bestehende
# "neuer Build ersetzt alten"-Aufraeumung um eine reine Zeitdimension.
@@ -180,6 +190,18 @@ class CreateWorkspaceRequest(BaseModel):
organization_id: str | None = None
class CreateWebappRequest(BaseModel):
name: str
url: str
sort_order: int = 0
class UpdateWebappRequest(BaseModel):
name: str
url: str
sort_order: int = 0
class CreateBereitstellungsvorlageRequest(BaseModel):
workspace_id: str
backend_id: str
@@ -682,6 +704,109 @@ def verify_agent_secret(device_id: str, provided_secret: str) -> bool:
return hmac.compare_digest(row[0], hash_agent_secret(provided_secret))
FAVICON_FETCH_TIMEOUT_SECONDS = 5
# Grosszuegig genug fuer jedes realistische Favicon (ueblich: wenige KB bis
# niedrige zweistellige KB), verhindert aber, dass eine falsch verlinkte
# grosse Datei den Request lange blockiert oder viel Speicher belegt.
FAVICON_MAX_BYTES = 2 * 1024 * 1024
FAVICON_LINK_REGEX = re.compile(
r'<link[^>]+rel=["\'](?:shortcut icon|icon|apple-touch-icon)["\'][^>]*>',
re.IGNORECASE,
)
FAVICON_HREF_REGEX = re.compile(r'href=["\']([^"\']+)["\']', re.IGNORECASE)
def _fetch_url_bytes(url: str, max_bytes: int) -> tuple[bytes, str] | None:
"""Best-effort GET mit Groessen-/Zeitlimit. None bei jedem Fehlschlag -
Aufrufer entscheiden ueber Fallback, kein Exception-Handling noetig."""
request = urllib.request.Request(
url,
headers={"User-Agent": "Mozilla/5.0 (compatible; TuxflotteWebappIconFetcher/1.0)"},
)
try:
with urllib.request.urlopen(request, timeout=FAVICON_FETCH_TIMEOUT_SECONDS) as response:
content_type = response.headers.get_content_type()
data = response.read(max_bytes + 1)
if len(data) > max_bytes:
return None
return data, content_type
except (urllib.error.URLError, TimeoutError, ValueError):
return None
def fetch_favicon(page_url: str) -> tuple[bytes, str] | None:
"""Best-effort Favicon-Ermittlung fuer eine Webapp-URL (09.09.2026,
Phase 2): erst die Seite selbst holen und nach <link rel="icon"> (oder
"shortcut icon"/"apple-touch-icon") suchen, sonst /favicon.ico an der
Domain-Wurzel probieren. Kein HTML-Parser als Abhaengigkeit (bewusst
minimalistisch wie der Rest von provisioning-server) - einfacher
Regex reicht fuer den ueblichen Fall, deckt keine JS-generierten Icons
ab. Liefert None, wenn gar nichts gefunden wurde - der Aufrufer legt
dann kein icon_filename an, das Geraet nutzt sein Fallback-Icon."""
parsed = urlparse(page_url)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
return None
page = _fetch_url_bytes(page_url, FAVICON_MAX_BYTES)
if page is not None:
html = page[0].decode("utf-8", errors="ignore")
for link_tag in FAVICON_LINK_REGEX.findall(html):
href_match = FAVICON_HREF_REGEX.search(link_tag)
if href_match:
icon_url = urljoin(page_url, href_match.group(1))
fetched = _fetch_url_bytes(icon_url, FAVICON_MAX_BYTES)
if fetched is not None:
return fetched
fallback_url = f"{parsed.scheme}://{parsed.netloc}/favicon.ico"
return _fetch_url_bytes(fallback_url, FAVICON_MAX_BYTES)
def fetch_workspace_webapps(device_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT bv.workspace_id
FROM assignments a
JOIN bereitstellungsvorlagen bv ON bv.id = a.bereitstellungsvorlage_id
WHERE a.device_id = %s
""",
(device_id,),
)
assignment_row = cur.fetchone()
if assignment_row is None:
return []
(workspace_id,) = assignment_row
cur.execute(
"""
SELECT id, name, url, icon_filename
FROM workspace_webapps
WHERE workspace_id = %s
ORDER BY sort_order, name
""",
(workspace_id,),
)
return [
{
"id": str(webapp_id),
"name": name,
"url": url,
# Relativer Pfad, kein voller URL - der Server kennt
# seine eigene oeffentliche Adresse nirgends (siehe
# ANODE_URL in tuxflotte-installer/provisioning-agent,
# bewusst nur client-seitig bekannt, gleiches Muster wie
# bei GOLDEN_IMAGE_URL). Der Agent haengt ANODE_URL davor.
"icon_path": f"/webapp-icons/{icon_filename}" if icon_filename else None,
}
for webapp_id, name, url, icon_filename in cur.fetchall()
]
def fetch_assigned_blueprints(device_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
@@ -2104,6 +2229,106 @@ def add_merkmal_to_workspace(workspace_id: str, merkmal_id: str):
(workspace_id, merkmal_id),
)
def _write_webapp_icon(webapp_id, icon_bytes: bytes, content_type: str) -> str:
extension = mimetypes.guess_extension(content_type) or ".ico"
# image/x-icon (klassisches favicon.ico) fehlt in manchen mimetypes-
# Installationen - .ico ist bei irrigem/leerem Ergebnis der sinnvollste
# Default, weit haeufigster Favicon-Fall.
if extension == ".jpe":
extension = ".jpg"
filename = f"{webapp_id}{extension}"
WEBAPP_ICONS_DIR.mkdir(parents=True, exist_ok=True)
(WEBAPP_ICONS_DIR / filename).write_bytes(icon_bytes)
return filename
def fetch_webapps_for_workspace(workspace_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, name, url, icon_filename, sort_order
FROM workspace_webapps
WHERE workspace_id = %s
ORDER BY sort_order, name
""",
(workspace_id,),
)
return [
{
"id": str(webapp_id),
"name": name,
"url": url,
"icon_path": f"/webapp-icons/{icon_filename}" if icon_filename else None,
"sort_order": sort_order,
}
for webapp_id, name, url, icon_filename, sort_order in cur.fetchall()
]
def create_webapp(workspace_id: str, name: str, url: str, sort_order: int):
webapp_id = uuid4()
favicon = fetch_favicon(url)
icon_filename = _write_webapp_icon(webapp_id, favicon[0], favicon[1]) if favicon else None
icon_content_type = favicon[1] if favicon else None
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO workspace_webapps (id, workspace_id, name, url, icon_filename, icon_content_type, sort_order)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
(webapp_id, workspace_id, name, url, icon_filename, icon_content_type, sort_order),
)
return {"id": str(webapp_id), "name": name, "url": url, "icon_path": f"/webapp-icons/{icon_filename}" if icon_filename else None}
def update_webapp(webapp_id: str, name: str, url: str, sort_order: int):
# Favicon bei jeder Bearbeitung neu holen (einfacher als zu erkennen,
# ob sich nur die URL geaendert hat - Fetch ist billig, siehe
# fetch_favicon()) - alte Icon-Datei wird durch die neue ID-basierte
# Benennung nicht ueberschrieben, sondern bleibt als Datei-Leiche liegen,
# falls sich die Content-Type-Endung aendert; bewusst nicht aufgeraeumt
# (seltener Fall, kleine Dateien, kein Cleanup-Mechanismus fuer sowas
# aktuell vorhanden - analog zu anderen bekannten kleinen Aufraeum-
# Luecken im Projekt).
favicon = fetch_favicon(url)
icon_filename = _write_webapp_icon(webapp_id, favicon[0], favicon[1]) if favicon else None
icon_content_type = favicon[1] if favicon else None
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE workspace_webapps
SET name = %s, url = %s, sort_order = %s, icon_filename = %s,
icon_content_type = %s, updated_at = now()
WHERE id = %s
""",
(name, url, sort_order, icon_filename, icon_content_type, webapp_id),
)
return {"id": webapp_id, "name": name, "url": url, "icon_path": f"/webapp-icons/{icon_filename}" if icon_filename else None}
def delete_webapp(webapp_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT icon_filename FROM workspace_webapps WHERE id = %s",
(webapp_id,),
)
row = cur.fetchone()
cur.execute("DELETE FROM workspace_webapps WHERE id = %s", (webapp_id,))
if row and row[0]:
icon_path = WEBAPP_ICONS_DIR / row[0]
if icon_path.is_file():
icon_path.unlink()
def remove_merkmal_from_workspace(workspace_id: str, merkmal_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
@@ -2738,6 +2963,31 @@ def get_golden_image(filename: str):
return FileResponse(file_path, media_type="application/zstd")
@app.get("/webapp-icons/{filename}")
def get_webapp_icon(filename: str):
# Gleicher Path-Traversal-Schutz wie get_golden_image() oben,
# unauthentifiziert aus demselben Grund (Agent hat vor dem ersten
# ansible-pull keinen anderen Kontext als die URL selbst).
safe_name = Path(filename).name
if safe_name != filename:
raise HTTPException(status_code=400, detail="invalid_filename")
file_path = WEBAPP_ICONS_DIR / safe_name
if not file_path.is_file():
raise HTTPException(status_code=404, detail="not_found")
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT icon_content_type FROM workspace_webapps WHERE icon_filename = %s LIMIT 1",
(safe_name,),
)
row = cur.fetchone()
media_type = row[0] if row and row[0] else "application/octet-stream"
return FileResponse(file_path, media_type=media_type)
@app.post("/api/v1/agent/bootstrap")
def agent_bootstrap(payload: AgentBootstrapRequest):
agent_secret = secrets.token_urlsafe(32)
@@ -2888,6 +3138,7 @@ def agent_checkin(
"success": True,
"blueprints": fetch_assigned_blueprints(payload.device_id),
"auftraege": fetch_auftragskatalog_state(payload.device_id),
"webapps": fetch_workspace_webapps(payload.device_id),
"ansible_repo": ANSIBLE_CONTENT_REPO,
"poll_interval_seconds": effektives_intervall,
}
@@ -3319,6 +3570,40 @@ def remove_workspace_merkmal(workspace_id: str, merkmal_id: str, authorization:
return {"success": True}
@app.get("/api/v1/workspaces/{workspace_id}/webapps")
def list_workspace_webapps(workspace_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "webapps": fetch_webapps_for_workspace(workspace_id)}
@app.post("/api/v1/workspaces/{workspace_id}/webapps")
def create_workspace_webapp(workspace_id: str, payload: CreateWebappRequest, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "webapp": create_webapp(workspace_id, payload.name, payload.url, payload.sort_order)}
@app.put("/api/v1/workspaces/{workspace_id}/webapps/{webapp_id}")
def update_workspace_webapp(workspace_id: str, webapp_id: str, payload: UpdateWebappRequest, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
return {"success": True, "webapp": update_webapp(webapp_id, payload.name, payload.url, payload.sort_order)}
@app.delete("/api/v1/workspaces/{workspace_id}/webapps/{webapp_id}")
def delete_workspace_webapp(workspace_id: str, webapp_id: str, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
delete_webapp(webapp_id)
return {"success": True}
@app.get("/api/v1/backends")
def list_backends(authorization: str | None = Header(default=None)):
if not require_service_token(authorization):