from pathlib import Path from datetime import datetime, timezone import hashlib import hmac import json import os import secrets from uuid import UUID, uuid4 import psycopg from psycopg.types.json import Jsonb from fastapi import FastAPI, Header, Request from pydantic import BaseModel from fastapi.responses import FileResponse LOG_PATH = Path("activation.log") DATABASE_URL = os.environ.get("TUXFLOTTE_DATABASE_URL") AGENT_POLL_INTERVAL_SECONDS = int(os.environ.get("TUXFLOTTE_AGENT_POLL_INTERVAL", "300")) ANSIBLE_CONTENT_REPO = os.environ.get("TUXFLOTTE_ANSIBLE_CONTENT_REPO", "") ADMIN_TOKEN = os.environ.get("TUXFLOTTE_ADMIN_TOKEN", "") KUNDENPLATTFORM_TOKEN = os.environ.get("TUXFLOTTE_KUNDENPLATTFORM_TOKEN", "") AGENT_REPORTABLE_EVENT_TYPES = {"applied", "apply_failed", "removed", "remove_failed"} app = FastAPI(title="Provisioning Activation Server", version="0.1.0") class ActivationRequest(BaseModel): activation_code: str device_fingerprint: str hostname: str | None = None machine_id: str | None = None client_version: str | None = None hardware: dict class ResolveRequest(BaseModel): device_id: str class AgentBootstrapRequest(BaseModel): device_id: str class AgentCheckinRequest(BaseModel): device_id: str class AgentReportEntry(BaseModel): merkmal: str event_type: str detail: dict | None = None class AgentReportRequest(BaseModel): device_id: str results: list[AgentReportEntry] class AuftragSelectRequest(BaseModel): optionen: dict = {} class CreateOrganizationRequest(BaseModel): name: str class UpdateOrganizationRequest(BaseModel): name: str class CreateMerkmalRequest(BaseModel): key: str name: str description: str | None = None class UpdateMerkmalRequest(BaseModel): name: str description: str | None = None kategorie_id: str | None = None im_auftragskatalog: bool = False class CreateKategorieRequest(BaseModel): key: str name: str description: str | None = None sort_order: int = 0 class CreateWorkspaceRequest(BaseModel): key: str name: str description: str | None = None organization_id: str | None = None class CreateEnrollmentSessionRequest(BaseModel): bereitstellungsvorlage_id: str max_devices: int expires_at: str def write_log(entry: dict) -> None: with LOG_PATH.open("a", encoding="utf-8") as f: f.write(json.dumps(entry, ensure_ascii=False) + "\n") def get_database_connection(): if not DATABASE_URL: raise RuntimeError("TUXFLOTTE_DATABASE_URL ist nicht gesetzt.") return psycopg.connect(DATABASE_URL) def load_or_create_device( organization_id, device_fingerprint: str, hostname: str | None, ) -> tuple[dict, bool]: """ Lädt ein bekanntes Gerät oder legt es neu an. Rückgabe: (device, created) """ with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT id, organization_id, device_fingerprint, hostname, created_at, last_seen FROM devices WHERE device_fingerprint = %s """, (device_fingerprint,), ) row = cur.fetchone() if row is not None: if str(row[1]) != str(organization_id): raise RuntimeError( "Das Gerät ist bereits einer anderen Organization zugeordnet." ) cur.execute( """ UPDATE devices SET hostname = %s, last_seen = CURRENT_TIMESTAMP WHERE id = %s RETURNING id, organization_id, device_fingerprint, hostname, created_at, last_seen """, (hostname, row[0]), ) row = cur.fetchone() created = False else: device_id = uuid4() cur.execute( """ INSERT INTO devices ( id, organization_id, device_fingerprint, hostname ) VALUES (%s, %s, %s, %s) RETURNING id, organization_id, device_fingerprint, hostname, created_at, last_seen """, ( device_id, organization_id, device_fingerprint, hostname, ), ) row = cur.fetchone() created = True device = { "id": str(row[0]), "organization_id": str(row[1]), "device_fingerprint": row[2], "hostname": row[3], "created_at": row[4].isoformat(), "last_seen": row[5].isoformat(), } return device, created def store_hardware_snapshot( device_id: str, hardware: dict, ) -> str: snapshot_id = uuid4() identity = hardware.get("identity", {}) system = hardware.get("system", {}) mainboard = hardware.get("mainboard", {}) firmware = hardware.get("firmware", {}) security = hardware.get("security", {}) cpu = system.get("cpu", {}) with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ INSERT INTO hardware_snapshots ( id, device_id, architecture, manufacturer, product_name, product_version, system_uuid, system_serial, board_vendor, board_name, board_serial, bios_vendor, bios_version, boot_mode, secure_boot, tpm_version, cpu_model, cpu_logical_count, memory_bytes ) VALUES ( %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s ) """, ( snapshot_id, device_id, system["architecture"], system.get("manufacturer"), system.get("product_name"), system.get("product_version"), identity.get("system_uuid"), identity.get("system_serial"), mainboard.get("vendor"), mainboard.get("name"), identity.get("board_serial"), firmware.get("bios_vendor"), firmware.get("bios_version"), firmware.get("boot_mode"), firmware.get("secure_boot"), security.get("tpm_version"), cpu.get("model"), cpu.get("logical_count"), system.get("memory_bytes"), ), ) for interface in hardware.get("network_interfaces", []): cur.execute( """ INSERT INTO network_interfaces ( id, hardware_snapshot_id, name, type, mac_address ) VALUES (%s, %s, %s, %s, %s) """, ( uuid4(), snapshot_id, interface["name"], interface["type"], interface["mac"], ), ) for storage_device in hardware.get("storage_devices", []): cur.execute( """ INSERT INTO storage_devices ( id, hardware_snapshot_id, name, model, serial, size_bytes, transport ) VALUES (%s, %s, %s, %s, %s, %s, %s) """, ( uuid4(), snapshot_id, storage_device["name"], storage_device.get("model"), storage_device.get("serial"), storage_device["size_bytes"], storage_device.get("transport"), ), ) return str(snapshot_id) def find_activation_code(code: str): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT ac.organization_id, o.name FROM activation_codes ac JOIN organizations o ON o.id = ac.organization_id WHERE ac.code = %s AND ac.active """, (code,), ) row = cur.fetchone() if row is None: return None return {"organization_id": str(row[0]), "organization_name": row[1]} def find_enrollment_session(code: str): """ Enrollment Sessions haben kein eigenes code-Feld - ihre id dient direkt als Bearer-Credential (unratbare UUID, siehe migrations/0012). Nur gültige Sessions (nicht widerrufen, nicht abgelaufen, Kontingent nicht erschöpft) werden zurückgegeben; jede andere Bedingung lässt /activate denselben "invalid_activation_code"-Fehlschlag melden wie ein ungültiger klassischer Aktivierungscode - das bricht den Installer bereits beim Server-Handshake (Schritt 15) ab, lange vor jeder destruktiven Aktion (siehe ADR-0008). """ try: session_id = UUID(code) except ValueError: return None with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT es.id, es.organization_id, o.name, es.bereitstellungsvorlage_id, es.max_devices, es.devices_used, es.expires_at, es.revoked_at FROM enrollment_sessions es JOIN organizations o ON o.id = es.organization_id WHERE es.id = %s """, (session_id,), ) row = cur.fetchone() if row is None: return None ( session_id, organization_id, organization_name, bereitstellungsvorlage_id, max_devices, devices_used, expires_at, revoked_at, ) = row if revoked_at is not None: return None if expires_at <= datetime.now(timezone.utc): return None if devices_used >= max_devices: return None return { "session_id": str(session_id), "organization_id": str(organization_id), "organization_name": organization_name, "bereitstellungsvorlage_id": str(bereitstellungsvorlage_id), } def consume_enrollment_session(session_id: str) -> None: with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( "UPDATE enrollment_sessions SET devices_used = devices_used + 1 WHERE id = %s", (session_id,), ) def fetch_templates(organization_id: str) -> list[dict]: with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT bv.id, bv.label, bv.is_default, w.id, w.name, b.id, b.name, b.version FROM bereitstellungsvorlagen bv JOIN workspaces w ON w.id = bv.workspace_id JOIN backends b ON b.id = bv.backend_id WHERE bv.organization_id = %s ORDER BY bv.is_default DESC, bv.label """, (organization_id,), ) rows = cur.fetchall() return [ { "id": str(row[0]), "label": row[1], "workspace": {"id": str(row[3]), "name": row[4]}, "backend": {"id": str(row[5]), "name": row[6], "version": row[7]}, "is_default": row[2], } for row in rows ] def require_service_token(authorization: str | None) -> bool: """ Akzeptiert entweder den Betreiber-Admin-Token oder das separate Kundenplattform-Service-Token (siehe ADR-0011) - beide dürfen die Auftragskatalog-Endpoints aufrufen, Kompromittierung/Rotation des einen betrifft den anderen nicht. """ if authorization is None or not authorization.startswith("Bearer "): return False provided_token = authorization.removeprefix("Bearer ") if ADMIN_TOKEN and hmac.compare_digest(provided_token, ADMIN_TOKEN): return True if KUNDENPLATTFORM_TOKEN and hmac.compare_digest(provided_token, KUNDENPLATTFORM_TOKEN): return True return False def hash_agent_secret(secret: str) -> str: return hashlib.sha256(secret.encode("utf-8")).hexdigest() def verify_agent_secret(device_id: str, provided_secret: str) -> bool: with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( "SELECT agent_secret_hash FROM devices WHERE id = %s", (device_id,), ) row = cur.fetchone() if row is None or row[0] is None: return False return hmac.compare_digest(row[0], hash_agent_secret(provided_secret)) def fetch_assigned_blueprints(device_id: str): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT bv.workspace_id, bv.backend_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, backend_id = assignment_row cur.execute( """ SELECT m.key, bp.ansible_role FROM workspace_merkmale wm JOIN merkmale m ON m.id = wm.merkmal_id JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s WHERE wm.workspace_id = %s """, (backend_id, workspace_id), ) return [ {"merkmal": key, "ansible_role": ansible_role} for key, ansible_role in cur.fetchall() ] def fetch_auftragskatalog_state(device_id: str): """ Voller Soll-Zustand (present/absent) aller katalogfähigen Merkmale für das Backend des Device (siehe ADR-0010). Zustandslos aus der aktuellen Auswahl abgeleitet, keine Historie nötig. Default ohne explizite Auftragszuweisung ist die Workspace-Zugehörigkeit: der Auftragskatalog besteht aus denselben Merkmalen, aus denen auch Workspaces zusammengesetzt werden, und ein Workspace ist fachlich nichts anderes als eine Vorauswahl aus dem Katalog. Eine vorhandene device_merkmale-Zeile überschreibt diesen Default in beide Richtungen (auch ein workspace-komponiertes Merkmal lässt sich damit geräteweise abwählen). """ with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT bv.workspace_id, bv.backend_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, backend_id = assignment_row cur.execute( """ SELECT m.key, bp.ansible_role, COALESCE(dm.aktiv, wm.merkmal_id IS NOT NULL), COALESCE(dm.optionen, '{}'::jsonb) FROM merkmale m JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s LEFT JOIN device_merkmale dm ON dm.merkmal_id = m.id AND dm.device_id = %s LEFT JOIN workspace_merkmale wm ON wm.merkmal_id = m.id AND wm.workspace_id = %s WHERE m.im_auftragskatalog """, (backend_id, device_id, workspace_id), ) return [ { "merkmal": key, "ansible_role": ansible_role, "state": "present" if aktiv else "absent", "optionen": optionen, } for key, ansible_role, aktiv, optionen in cur.fetchall() ] def fetch_auftragskatalog_listing(device_id: str): """ Wie fetch_auftragskatalog_state, aber für die Auswahl-API angereichert um Anzeige-relevante Felder (Name, Beschreibung, Kategorie) statt nur die für den Agenten nötigen (Rollenname, State). """ with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT bv.workspace_id, bv.backend_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 None workspace_id, backend_id = assignment_row cur.execute( """ SELECT m.key, m.name, m.description, k.key, k.name, k.sort_order, COALESCE(dm.aktiv, wm.merkmal_id IS NOT NULL), COALESCE(dm.optionen, '{}'::jsonb) FROM merkmale m JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s LEFT JOIN kategorien k ON k.id = m.kategorie_id LEFT JOIN device_merkmale dm ON dm.merkmal_id = m.id AND dm.device_id = %s LEFT JOIN workspace_merkmale wm ON wm.merkmal_id = m.id AND wm.workspace_id = %s WHERE m.im_auftragskatalog ORDER BY k.sort_order NULLS LAST, m.name """, (backend_id, device_id, workspace_id), ) return [ { "merkmal": key, "name": name, "description": description, "kategorie": ( {"key": kat_key, "name": kat_name, "sort_order": sort_order} if kat_key is not None else None ), "state": "present" if aktiv else "absent", "optionen": optionen, } for ( key, name, description, kat_key, kat_name, sort_order, aktiv, optionen, ) in cur.fetchall() ] def set_auftrag_selection(device_id: str, merkmal_key: str, aktiv: bool, optionen: dict): """ Setzt die geräteweise Auswahl eines katalogfähigen Merkmals (Upsert in device_merkmale) und protokolliert die Änderung als selected/deselected- Event. Gibt False zurück, wenn kein katalogfähiges Merkmal mit diesem Key existiert (unbekannter Key oder im_auftragskatalog = false). """ with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( "SELECT id FROM merkmale WHERE key = %s AND im_auftragskatalog", (merkmal_key,), ) merkmal_row = cur.fetchone() if merkmal_row is None: return False merkmal_id = merkmal_row[0] cur.execute( """ INSERT INTO device_merkmale (id, device_id, merkmal_id, aktiv, optionen) VALUES (%s, %s, %s, %s, %s) ON CONFLICT (device_id, merkmal_id) DO UPDATE SET aktiv = EXCLUDED.aktiv, optionen = EXCLUDED.optionen, updated_at = CURRENT_TIMESTAMP """, (uuid4(), device_id, merkmal_id, aktiv, Jsonb(optionen)), ) cur.execute( """ INSERT INTO device_merkmal_events (id, device_id, merkmal_id, event_type) VALUES (%s, %s, %s, %s) """, (uuid4(), device_id, merkmal_id, "selected" if aktiv else "deselected"), ) return True def fetch_devices_for_organization(organization_id: str): """ Für Kundenplattforms Geräteliste + Besitz-Validierung (siehe ADR-0011): alle Geräte einer Organisation, unabhängig von Bereitstellungsvorlagen- Zuweisung. """ with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT id, hostname, device_fingerprint, agent_last_checkin FROM devices WHERE organization_id = %s ORDER BY hostname NULLS LAST, created_at """, (organization_id,), ) return [ { "id": str(device_id), "hostname": hostname, "device_fingerprint": fingerprint, "agent_last_checkin": ( last_checkin.isoformat() if last_checkin is not None else None ), } for device_id, hostname, fingerprint, last_checkin in cur.fetchall() ] def fetch_all_devices(): """ Organisationsübergreifende Geräteliste für den Kundenplattform-Admin- Bereich (Verwaltung -> Geräte) - Pendant zu fetch_devices_for_organization, ohne Org-Filter, mit Organisationsname gejoint. """ with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT d.id, d.hostname, d.device_fingerprint, d.agent_last_checkin, d.organization_id, o.name FROM devices d JOIN organizations o ON o.id = d.organization_id ORDER BY o.name, d.hostname NULLS LAST, d.created_at """ ) return [ { "id": str(device_id), "hostname": hostname, "device_fingerprint": fingerprint, "agent_last_checkin": ( last_checkin.isoformat() if last_checkin is not None else None ), "organization_id": str(organization_id), "organization_name": organization_name, } for ( device_id, hostname, fingerprint, last_checkin, organization_id, organization_name, ) in cur.fetchall() ] def fetch_organizations(): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( "SELECT id, name, created_at FROM organizations ORDER BY name" ) return [ {"id": str(org_id), "name": name, "created_at": created_at.isoformat()} for org_id, name, created_at in cur.fetchall() ] def create_organization(name: str): organization_id = uuid4() with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( "INSERT INTO organizations (id, name) VALUES (%s, %s)", (organization_id, name), ) return {"id": str(organization_id), "name": name} def update_organization(organization_id: str, name: str): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( "UPDATE organizations SET name = %s WHERE id = %s RETURNING id, name", (name, organization_id), ) row = cur.fetchone() if row is None: return None return {"id": str(row[0]), "name": row[1]} def fetch_activation_codes(organization_id: str): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT code, active, created_at FROM activation_codes WHERE organization_id = %s ORDER BY created_at DESC """, (organization_id,), ) return [ {"code": code, "active": active, "created_at": created_at.isoformat()} for code, active, created_at in cur.fetchall() ] def create_activation_code(organization_id: str): code = "-".join( secrets.token_hex(3).upper()[i:i + 3] for i in range(0, 6, 3) ) with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( "INSERT INTO activation_codes (code, organization_id) VALUES (%s, %s)", (code, organization_id), ) return {"code": code, "active": True} def fetch_merkmale(): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT m.id, m.key, m.name, m.description, m.im_auftragskatalog, m.kategorie_id, k.name FROM merkmale m LEFT JOIN kategorien k ON k.id = m.kategorie_id ORDER BY m.key """ ) return [ { "id": str(mid), "key": key, "name": name, "description": description, "im_auftragskatalog": im_auftragskatalog, "kategorie_id": str(kategorie_id) if kategorie_id else None, "kategorie_name": kategorie_name, } for ( mid, key, name, description, im_auftragskatalog, kategorie_id, kategorie_name, ) in cur.fetchall() ] def create_merkmal(key: str, name: str, description: str | None): merkmal_id = uuid4() with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( "INSERT INTO merkmale (id, key, name, description) VALUES (%s, %s, %s, %s)", (merkmal_id, key, name, description), ) return {"id": str(merkmal_id), "key": key, "name": name} def update_merkmal(merkmal_id: str, name: str, description: str | None, kategorie_id: str | None, im_auftragskatalog: bool): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ UPDATE merkmale SET name = %s, description = %s, kategorie_id = %s, im_auftragskatalog = %s WHERE id = %s RETURNING id """, (name, description, kategorie_id, im_auftragskatalog, merkmal_id), ) return cur.fetchone() is not None def fetch_kategorien(): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( "SELECT id, key, name, description, sort_order FROM kategorien ORDER BY sort_order, name" ) return [ { "id": str(kid), "key": key, "name": name, "description": description, "sort_order": sort_order, } for kid, key, name, description, sort_order in cur.fetchall() ] def create_kategorie(key: str, name: str, description: str | None, sort_order: int): kategorie_id = uuid4() with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ INSERT INTO kategorien (id, key, name, description, sort_order) VALUES (%s, %s, %s, %s, %s) """, (kategorie_id, key, name, description, sort_order), ) return {"id": str(kategorie_id), "key": key, "name": name} def fetch_workspaces(): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT w.id, w.key, w.name, w.description, w.organization_id, o.name FROM workspaces w LEFT JOIN organizations o ON o.id = w.organization_id ORDER BY o.name NULLS FIRST, w.name """ ) return [ { "id": str(wid), "key": key, "name": name, "description": description, "organization_id": str(organization_id) if organization_id else None, "organization_name": organization_name, } for ( wid, key, name, description, organization_id, organization_name, ) in cur.fetchall() ] def create_workspace(key: str, name: str, description: str | None, organization_id: str | None): workspace_id = uuid4() with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ INSERT INTO workspaces (id, organization_id, key, name, description) VALUES (%s, %s, %s, %s, %s) """, (workspace_id, organization_id, key, name, description), ) return {"id": str(workspace_id), "key": key, "name": name} def fetch_workspace_detail(workspace_id: str): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT w.id, w.key, w.name, w.description, w.organization_id, o.name FROM workspaces w LEFT JOIN organizations o ON o.id = w.organization_id WHERE w.id = %s """, (workspace_id,), ) row = cur.fetchone() if row is None: return None cur.execute( """ SELECT m.id, m.key, m.name FROM workspace_merkmale wm JOIN merkmale m ON m.id = wm.merkmal_id WHERE wm.workspace_id = %s ORDER BY m.key """, (workspace_id,), ) merkmale = [ {"id": str(mid), "key": key, "name": name} for mid, key, name in cur.fetchall() ] return { "id": str(row[0]), "key": row[1], "name": row[2], "description": row[3], "organization_id": str(row[4]) if row[4] else None, "organization_name": row[5], "merkmale": merkmale, } def add_merkmal_to_workspace(workspace_id: str, merkmal_id: str): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ INSERT INTO workspace_merkmale (workspace_id, merkmal_id) VALUES (%s, %s) ON CONFLICT DO NOTHING """, (workspace_id, merkmal_id), ) def remove_merkmal_from_workspace(workspace_id: str, merkmal_id: str): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( "DELETE FROM workspace_merkmale WHERE workspace_id = %s AND merkmal_id = %s", (workspace_id, merkmal_id), ) def fetch_backends(): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute("SELECT id, key, name, installer_type, version FROM backends ORDER BY key") return [ {"id": str(bid), "key": key, "name": name, "installer_type": installer_type, "version": version} for bid, key, name, installer_type, version in cur.fetchall() ] def fetch_blueprints(): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT m.key, b.key, bp.ansible_role FROM blueprints bp JOIN merkmale m ON m.id = bp.merkmal_id JOIN backends b ON b.id = bp.backend_id ORDER BY m.key, b.key """ ) return [ {"merkmal": merkmal_key, "backend": backend_key, "ansible_role": ansible_role} for merkmal_key, backend_key, ansible_role in cur.fetchall() ] def fetch_unassigned_devices(organization_id: str): """ Geräte einer Organisation ohne Bereitstellungsvorlagen-Zuweisung - die Kandidaten für die Neugerät-Bestätigung (ADR-0008) im Flows-Bereich. """ with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT d.id, d.hostname, d.device_fingerprint, d.created_at FROM devices d LEFT JOIN assignments a ON a.device_id = d.id WHERE d.organization_id = %s AND a.id IS NULL ORDER BY d.created_at """, (organization_id,), ) return [ { "id": str(device_id), "hostname": hostname, "device_fingerprint": fingerprint, "created_at": created_at.isoformat(), } for device_id, hostname, fingerprint, created_at in cur.fetchall() ] def fetch_enrollment_sessions(organization_id: str): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT es.id, bv.label, es.max_devices, es.devices_used, es.expires_at, es.revoked_at, es.created_at FROM enrollment_sessions es JOIN bereitstellungsvorlagen bv ON bv.id = es.bereitstellungsvorlage_id WHERE es.organization_id = %s ORDER BY es.created_at DESC """, (organization_id,), ) return [ { "id": str(sid), "bereitstellungsvorlage_label": label, "max_devices": max_devices, "devices_used": devices_used, "expires_at": expires_at.isoformat(), "revoked_at": revoked_at.isoformat() if revoked_at else None, "created_at": created_at.isoformat(), } for ( sid, label, max_devices, devices_used, expires_at, revoked_at, created_at, ) in cur.fetchall() ] def create_enrollment_session(organization_id: str, bereitstellungsvorlage_id: str, max_devices: int, expires_at: str): session_id = uuid4() with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ INSERT INTO enrollment_sessions (id, organization_id, bereitstellungsvorlage_id, max_devices, expires_at) VALUES (%s, %s, %s, %s, %s) """, (session_id, organization_id, bereitstellungsvorlage_id, max_devices, expires_at), ) return {"id": str(session_id)} def revoke_enrollment_session(session_id: str): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ UPDATE enrollment_sessions SET revoked_at = CURRENT_TIMESTAMP WHERE id = %s AND revoked_at IS NULL RETURNING id """, (session_id,), ) return cur.fetchone() is not None @app.get("/health") def health(): return { "status": "ok", "service": "provisioning-server", "version": "0.1.0" } @app.post("/api/v1/activate") def activate(payload: ActivationRequest, request: Request): # Enrollment Sessions zuerst versuchen (Auto-Modus-ISOs, siehe # tuxflotte-installer Phase 2/3) - ihre id ist selbst der Code, kollidiert # nicht mit klassischen Aktivierungscodes (eigene, meist sprechende # Strings wie "LAB-2026-START"). Fällt bei Nicht-UUID sofort auf den # klassischen Pfad zurück. enrollment_session = find_enrollment_session(payload.activation_code) activation = None if enrollment_session is not None: organization_id = enrollment_session["organization_id"] organization_name = enrollment_session["organization_name"] success = True else: activation = find_activation_code(payload.activation_code) success = activation is not None if success: organization_id = activation["organization_id"] organization_name = activation["organization_name"] device = None device_created = False hardware_snapshot_id = None if success: device, device_created = load_or_create_device( organization_id, payload.device_fingerprint, payload.hostname, ) hardware_snapshot_id = store_hardware_snapshot( device["id"], payload.hardware, ) if enrollment_session is not None: consume_enrollment_session(enrollment_session["session_id"]) write_log({ "timestamp": datetime.now(timezone.utc).isoformat(), "event": "activate", "success": success, "remote_ip": request.client.host if request.client else None, "activation_code": payload.activation_code, "hostname": payload.hostname, "machine_id": payload.machine_id, "client_version": payload.client_version }) if not success: return { "success": False, "error": "invalid_activation_code", "message": "Der Aktivierungscode ist ungültig." } if enrollment_session is not None: # Die Bereitstellungsvorlage steht durch die Enrollment Session schon # fest - nicht die volle Organisationsliste zurückgeben (20_profile_ # selection.sh würde im Auto-Modus sonst nichts Eindeutiges zum # Auswählen haben), sondern nur diese eine, als is_default markiert # (dieselbe Erkennung, die der Installer schon für den regulären # Standard-Vorlagen-Fall benutzt - keine Installer-Änderung nötig). templates = [ {**template, "is_default": True} for template in fetch_templates(organization_id) if template["id"] == enrollment_session["bereitstellungsvorlage_id"] ] else: templates = fetch_templates(organization_id) return { "success": True, "device": { "id": device["id"], "fingerprint": device["device_fingerprint"], "hostname": device["hostname"], "registration_status": ( "registered" if device_created else "existing" ), "hardware_snapshot_id": hardware_snapshot_id, }, "customer": { "id": organization_id, "organization_id": organization_id, "name": organization_name, }, "templates": templates, } @app.post("/api/v1/templates/{template_id}/resolve") def resolve_template(template_id: str, payload: ResolveRequest): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT bv.workspace_id, bv.backend_id, w.key, b.key, bv.disk_encryption, bv.partitioning, bv.secure_boot_required FROM bereitstellungsvorlagen bv JOIN workspaces w ON w.id = bv.workspace_id JOIN backends b ON b.id = bv.backend_id WHERE bv.id = %s """, (template_id,), ) template_row = cur.fetchone() if template_row is None: return { "success": False, "error": "template_not_found", "message": "Die angeforderte Bereitstellungsvorlage wurde nicht gefunden." } ( workspace_id, backend_id, workspace_key, backend_key, disk_encryption, partitioning, secure_boot_required, ) = template_row cur.execute( """ SELECT m.key, bp.ansible_role FROM workspace_merkmale wm JOIN merkmale m ON m.id = wm.merkmal_id JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s WHERE wm.workspace_id = %s """, (backend_id, workspace_id), ) blueprints = [ {"merkmal": key, "ansible_role": ansible_role} for key, ansible_role in cur.fetchall() ] cur.execute( """ INSERT INTO assignments (id, device_id, bereitstellungsvorlage_id) VALUES (%s, %s, %s) ON CONFLICT (device_id) DO UPDATE SET bereitstellungsvorlage_id = EXCLUDED.bereitstellungsvorlage_id """, (uuid4(), payload.device_id, template_id), ) return { "success": True, "runtime_blueprint": { "workspace_id": workspace_key, "backend_id": backend_key, "blueprints": blueprints, "installation_directives": { "disk_encryption": disk_encryption, "partitioning": partitioning, "secure_boot_required": secure_boot_required, }, }, } @app.get("/installers/fedora-workstation/ks.cfg") def get_fedora_kickstart(): return FileResponse( "installers/fedora-workstation/ks.cfg", media_type="text/plain" ) @app.post("/api/v1/agent/bootstrap") def agent_bootstrap(payload: AgentBootstrapRequest): agent_secret = secrets.token_urlsafe(32) with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ UPDATE devices SET agent_secret_hash = %s, agent_secret_issued_at = %s WHERE id = %s RETURNING id """, ( hash_agent_secret(agent_secret), datetime.now(timezone.utc), payload.device_id, ), ) updated = cur.fetchone() if updated is None: return { "success": False, "error": "device_not_found", "message": "Das angegebene Gerät wurde nicht gefunden.", } return {"success": True, "agent_secret": agent_secret} @app.post("/api/v1/agent/checkin") def agent_checkin( payload: AgentCheckinRequest, authorization: str | None = Header(default=None), ): if authorization is None or not authorization.startswith("Bearer "): return { "success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Authorization-Header.", } provided_secret = authorization.removeprefix("Bearer ") if not verify_agent_secret(payload.device_id, provided_secret): return { "success": False, "error": "unauthorized", "message": "Ungültiges Agent-Secret.", } with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( "UPDATE devices SET agent_last_checkin = %s WHERE id = %s", (datetime.now(timezone.utc), payload.device_id), ) return { "success": True, "blueprints": fetch_assigned_blueprints(payload.device_id), "auftraege": fetch_auftragskatalog_state(payload.device_id), "ansible_repo": ANSIBLE_CONTENT_REPO, "poll_interval_seconds": AGENT_POLL_INTERVAL_SECONDS, } @app.post("/api/v1/agent/report") def agent_report( payload: AgentReportRequest, authorization: str | None = Header(default=None), ): if authorization is None or not authorization.startswith("Bearer "): return { "success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Authorization-Header.", } provided_secret = authorization.removeprefix("Bearer ") if not verify_agent_secret(payload.device_id, provided_secret): return { "success": False, "error": "unauthorized", "message": "Ungültiges Agent-Secret.", } for entry in payload.results: if entry.event_type not in AGENT_REPORTABLE_EVENT_TYPES: return { "success": False, "error": "invalid_event_type", "message": f"Kein vom Agenten meldbarer event_type: {entry.event_type}", } with get_database_connection() as conn: with conn.cursor() as cur: merkmal_ids = {} for entry in payload.results: if entry.merkmal in merkmal_ids: continue cur.execute( "SELECT id FROM merkmale WHERE key = %s", (entry.merkmal,), ) merkmal_row = cur.fetchone() if merkmal_row is None: return { "success": False, "error": "unknown_merkmal", "message": f"Unbekanntes Merkmal: {entry.merkmal}", } merkmal_ids[entry.merkmal] = merkmal_row[0] for entry in payload.results: cur.execute( """ INSERT INTO device_merkmal_events ( id, device_id, merkmal_id, event_type, detail ) VALUES (%s, %s, %s, %s, %s) """, ( uuid4(), payload.device_id, merkmal_ids[entry.merkmal], entry.event_type, Jsonb(entry.detail) if entry.detail is not None else None, ), ) return {"success": True} @app.get("/api/v1/organizations/{organization_id}/devices") def get_organization_devices( organization_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, "devices": fetch_devices_for_organization(organization_id)} @app.get("/api/v1/devices") def list_all_devices(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, "devices": fetch_all_devices()} @app.get("/api/v1/organizations") def list_organizations(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, "organizations": fetch_organizations()} @app.post("/api/v1/organizations") def create_organization_endpoint( payload: CreateOrganizationRequest, 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, "organization": create_organization(payload.name)} @app.patch("/api/v1/organizations/{organization_id}") def update_organization_endpoint( organization_id: str, payload: UpdateOrganizationRequest, authorization: str | None = Header(default=None), ): if not require_service_token(authorization): return { "success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token.", } organization = update_organization(organization_id, payload.name) if organization is None: return { "success": False, "error": "organization_not_found", "message": "Die Organisation wurde nicht gefunden.", } return {"success": True, "organization": organization} @app.get("/api/v1/organizations/{organization_id}/activation-codes") def list_activation_codes( organization_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, "activation_codes": fetch_activation_codes(organization_id)} @app.post("/api/v1/organizations/{organization_id}/activation-codes") def create_activation_code_endpoint( organization_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, "activation_code": create_activation_code(organization_id)} @app.get("/api/v1/merkmale") def list_merkmale(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, "merkmale": fetch_merkmale()} @app.post("/api/v1/merkmale") def create_merkmal_endpoint(payload: CreateMerkmalRequest, 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, "merkmal": create_merkmal(payload.key, payload.name, payload.description)} @app.patch("/api/v1/merkmale/{merkmal_id}") def update_merkmal_endpoint( merkmal_id: str, payload: UpdateMerkmalRequest, authorization: str | None = Header(default=None) ): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} updated = update_merkmal( merkmal_id, payload.name, payload.description, payload.kategorie_id, payload.im_auftragskatalog ) if not updated: return {"success": False, "error": "not_found", "message": "Merkmal wurde nicht gefunden."} return {"success": True} @app.get("/api/v1/kategorien") def list_kategorien(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, "kategorien": fetch_kategorien()} @app.post("/api/v1/kategorien") def create_kategorie_endpoint(payload: CreateKategorieRequest, 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, "kategorie": create_kategorie(payload.key, payload.name, payload.description, payload.sort_order), } @app.get("/api/v1/workspaces") def list_workspaces(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, "workspaces": fetch_workspaces()} @app.post("/api/v1/workspaces") def create_workspace_endpoint(payload: CreateWorkspaceRequest, 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, "workspace": create_workspace(payload.key, payload.name, payload.description, payload.organization_id), } @app.get("/api/v1/workspaces/{workspace_id}") def get_workspace(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."} workspace = fetch_workspace_detail(workspace_id) if workspace is None: return {"success": False, "error": "not_found", "message": "Workspace wurde nicht gefunden."} return {"success": True, "workspace": workspace} @app.post("/api/v1/workspaces/{workspace_id}/merkmale/{merkmal_id}") def add_workspace_merkmal(workspace_id: str, merkmal_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."} add_merkmal_to_workspace(workspace_id, merkmal_id) return {"success": True} @app.delete("/api/v1/workspaces/{workspace_id}/merkmale/{merkmal_id}") def remove_workspace_merkmal(workspace_id: str, merkmal_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."} remove_merkmal_from_workspace(workspace_id, merkmal_id) return {"success": True} @app.get("/api/v1/backends") def list_backends(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, "backends": fetch_backends()} @app.get("/api/v1/blueprints") def list_blueprints(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, "blueprints": fetch_blueprints()} @app.get("/api/v1/organizations/{organization_id}/bereitstellungsvorlagen") def list_bereitstellungsvorlagen(organization_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, "bereitstellungsvorlagen": fetch_templates(organization_id)} @app.get("/api/v1/organizations/{organization_id}/devices/unassigned") def list_unassigned_devices(organization_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, "devices": fetch_unassigned_devices(organization_id)} @app.get("/api/v1/organizations/{organization_id}/enrollment-sessions") def list_enrollment_sessions(organization_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, "enrollment_sessions": fetch_enrollment_sessions(organization_id)} @app.post("/api/v1/organizations/{organization_id}/enrollment-sessions") def create_enrollment_session_endpoint( organization_id: str, payload: CreateEnrollmentSessionRequest, authorization: str | None = Header(default=None), ): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} session = create_enrollment_session( organization_id, payload.bereitstellungsvorlage_id, payload.max_devices, payload.expires_at ) return {"success": True, "enrollment_session": session} @app.post("/api/v1/enrollment-sessions/{session_id}/revoke") def revoke_enrollment_session_endpoint(session_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."} revoked = revoke_enrollment_session(session_id) if not revoked: return {"success": False, "error": "not_found", "message": "Enrollment Session wurde nicht gefunden oder ist bereits widerrufen."} return {"success": True} @app.get("/api/v1/devices/{device_id}/auftragskatalog") def get_device_auftragskatalog( device_id: str, authorization: str | None = Header(default=None), ): if not require_service_token(authorization): return { "success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Admin-Token.", } katalog = fetch_auftragskatalog_listing(device_id) if katalog is None: return { "success": False, "error": "device_not_assigned", "message": "Das Gerät hat keine aktive Bereitstellungsvorlagen-Zuweisung.", } return {"success": True, "auftragskatalog": katalog} @app.post("/api/v1/devices/{device_id}/auftragskatalog/{merkmal_key}/select") def select_auftrag( device_id: str, merkmal_key: str, payload: AuftragSelectRequest, authorization: str | None = Header(default=None), ): if not require_service_token(authorization): return { "success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Admin-Token.", } if not set_auftrag_selection(device_id, merkmal_key, True, payload.optionen): return { "success": False, "error": "unknown_merkmal", "message": f"Kein katalogfähiges Merkmal mit Key: {merkmal_key}", } return {"success": True} @app.post("/api/v1/devices/{device_id}/auftragskatalog/{merkmal_key}/deselect") def deselect_auftrag( device_id: str, merkmal_key: str, authorization: str | None = Header(default=None), ): if not require_service_token(authorization): return { "success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Admin-Token.", } if not set_auftrag_selection(device_id, merkmal_key, False, {}): return { "success": False, "error": "unknown_merkmal", "message": f"Kein katalogfähiges Merkmal mit Key: {merkmal_key}", } return {"success": True}