From a49252a81a1474561ab87586c5d5abc2716138fd Mon Sep 17 00:00:00 2001 From: Thomas Stallinger Date: Thu, 15 Oct 2026 13:55:51 +0200 Subject: [PATCH] Veyon: Schluesselpaar je OE, Check-in reichert Optionen an (Migration 0063) Bauplan Schritt 1 (platform-docs roadmap/veyon-schluesselverteilung- diskussion-2026-10-14.md). Paar beim ersten Bedarf erzeugt (RSA 4096, PKCS#8/SPKI-PEM wie veyon-cli), privat Fernet-verschluesselt. Optionen: veyon_schluesselname, veyon_public_pem, veyon_raum, veyon_lehrer; nur Lehrergeraete zusaetzlich veyon_private_pem und veyon_rechner (.local, ohne sich selbst). Lehrer = Haekchen veyon_lehrer an irgendeiner aktiven Gruppe. Fehler brechen den Check-in nicht ab. Getestet gegen eine Wegwerf-Datenbank mit allen Migrationen. Co-Authored-By: Claude Opus 5.5 --- app.py | 169 ++++++++++++++++++++++++++- migrations/0063_veyon_schluessel.sql | 19 +++ 2 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 migrations/0063_veyon_schluessel.sql diff --git a/app.py b/app.py index 3155335..9c0b314 100644 --- a/app.py +++ b/app.py @@ -1597,11 +1597,15 @@ def fetch_auftragskatalog_state(device_id: str): aktiv = workspace_default optionen = {} + optionen = decrypt_sensitive_optionen(optionen) + if key == VEYON_MERKMAL and aktiv: + optionen = veyon_optionen_anreichern(device_id, merkmal_id, optionen) + ergebnis.append({ "merkmal": key, "ansible_role": ansible_role, "state": "present" if aktiv else "absent", - "optionen": decrypt_sensitive_optionen(optionen), + "optionen": optionen, }) # Standardbrowser der Workspace-Version: immer mitschicken, auch als @@ -1627,6 +1631,169 @@ def fetch_auftragskatalog_state(device_id: str): return ergebnis +# Veyon (15.10.2026, platform-docs roadmap/veyon-schluesselverteilung- +# diskussion-2026-10-14.md, Bauplan Schritt 1, Migration 0063): ein +# Schluesselpaar je OE (= Raum). Der Check-in reichert die Optionen des +# Merkmals "veyon" an - wie die Org-Ressourcen serverseitig berechnet, der +# Agent reicht sie ungesehen an die Rolle weiter (seit Agent 17d7186 ueber +# eine root-Datei, nicht mehr per Kommandozeile): +# veyon_schluesselname, veyon_public_pem, veyon_raum, veyon_lehrer +# nur Lehrergeraete zusaetzlich: veyon_private_pem, veyon_rechner +# Format wie `veyon-cli authkeys create` (RSA 4096, privat PKCS#8-PEM, +# oeffentlich SubjectPublicKeyInfo-PEM) - auf Liebherr-83/-35 am 15.10.2026 +# mit einem so erzeugten Paar geprueft. +VEYON_MERKMAL = "veyon" +VEYON_SCHLUESSEL_PRAEFIX = "tuxflotte" + + +def veyon_schluesselname(oe_id) -> str: + """Veyon erlaubt nur Buchstaben: Praefix + erste 12 Hex-Zeichen der + OE-ID, Ziffern 0-9 auf g-p abgebildet. Eindeutig genug je + Installation (UNIQUE in der Tabelle faengt den Rest ab), stabil.""" + hexteil = str(oe_id).replace("-", "")[:12].lower() + return VEYON_SCHLUESSEL_PRAEFIX + "".join( + chr(ord("g") + int(c)) if c.isdigit() else c for c in hexteil + ) + + +def erzeuge_veyon_schluesselpaar() -> tuple[str, str]: + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import rsa + + schluessel = rsa.generate_private_key(public_exponent=65537, key_size=4096) + privat = schluessel.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode("ascii") + oeffentlich = schluessel.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode("ascii") + return privat, oeffentlich + + +def hole_oder_erzeuge_veyon_schluessel(cur, oe_id) -> tuple[str, str, str]: + """(name, public_pem, private_pem) der OE; legt das Paar beim ersten + Bedarf an. ON CONFLICT DO NOTHING + erneutes Lesen: zwei Geraete + derselben OE, die gleichzeitig einchecken, bekommen dasselbe Paar.""" + cur.execute( + "SELECT name, public_pem, private_pem_encrypted FROM veyon_schluessel WHERE oe_id = %s", + (oe_id,), + ) + row = cur.fetchone() + if row is None: + privat, oeffentlich = erzeuge_veyon_schluesselpaar() + cur.execute( + """ + INSERT INTO veyon_schluessel (oe_id, name, public_pem, private_pem_encrypted) + VALUES (%s, %s, %s, %s) + ON CONFLICT (oe_id) DO NOTHING + """, + (oe_id, veyon_schluesselname(oe_id), oeffentlich, + _fernet().encrypt(privat.encode("ascii")).decode("ascii")), + ) + cur.execute( + "SELECT name, public_pem, private_pem_encrypted FROM veyon_schluessel WHERE oe_id = %s", + (oe_id,), + ) + row = cur.fetchone() + name, oeffentlich, privat_verschluesselt = row + privat = _fernet().decrypt(privat_verschluesselt.encode("ascii")).decode("ascii") + return name, oeffentlich, privat + + +def veyon_aktiv(cur, device_id, merkmal_id) -> bool: + """Effektiver Zustand des Merkmals veyon fuer ein (anderes) Geraet - + dieselbe Aufloesung wie fetch_auftragskatalog_state(): OE-/Gruppen- + Zwischenschicht, sonst der Workspace-Standard.""" + overrides = resolve_katalog_overrides(str(device_id)) + if merkmal_id in overrides: + return bool(overrides[merkmal_id][0]) + basis = geraete_basis(cur, str(device_id)) + if basis is None: + return False + version_id, _backend_id = basis + cur.execute( + "SELECT 1 FROM workspace_version_merkmale WHERE workspace_version_id = %s AND merkmal_id = %s", + (version_id, merkmal_id), + ) + return cur.fetchone() is not None + + +def veyon_ist_lehrer(cur, device_id, merkmal_id, optionen: dict) -> bool: + """Lehrergeraet, wenn IRGENDEINE aktive Quelle das Haekchen setzt - + nicht nur die eine, deren Optionen resolve_katalog_overrides() liefert + (dort gewinnt die OE, sobald Veyon dort aktiv ist; ein Haekchen an der + Gruppe "Veyon-Lehrer" ginge sonst verloren). Entscheidung 15.10.2026: + Checkbox veyon_lehrer an der Gruppe.""" + if optionen.get("veyon_lehrer") is True: + return True + cur.execute( + """ + SELECT 1 + FROM device_gruppen dg + JOIN gruppen_merkmale gm ON gm.gruppe_id = dg.gruppe_id + WHERE dg.device_id = %s AND gm.merkmal_id = %s AND gm.aktiv + AND (gm.optionen ->> 'veyon_lehrer') = 'true' + """, + (device_id, merkmal_id), + ) + return cur.fetchone() is not None + + +def veyon_optionen_anreichern(device_id, merkmal_id, optionen: dict) -> dict: + """Siehe VEYON_MERKMAL. Fehler (z.B. fehlender TUXFLOTTE_ENCRYPTION_KEY) + brechen den Check-in nicht ab: die Rolle bekommt dann keinen Schluessel + und laesst die Authentifizierung unangetastet.""" + try: + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT d.oe_id, oe.name + FROM devices d JOIN organisationseinheiten oe ON oe.id = d.oe_id + WHERE d.id = %s + """, + (device_id,), + ) + row = cur.fetchone() + if row is None: + return optionen + oe_id, oe_name = row + + name, oeffentlich, privat = hole_oder_erzeuge_veyon_schluessel(cur, oe_id) + lehrer = veyon_ist_lehrer(cur, device_id, merkmal_id, optionen) + + ergebnis = dict(optionen) + ergebnis.update({ + "veyon_schluesselname": name, + "veyon_public_pem": oeffentlich, + "veyon_raum": oe_name, + "veyon_lehrer": lehrer, + }) + if lehrer: + cur.execute( + """ + SELECT id, hostname FROM devices + WHERE oe_id = %s AND archived_at IS NULL AND deprovisioned_at IS NULL + AND hostname IS NOT NULL AND hostname <> '' + ORDER BY lower(hostname) + """, + (oe_id,), + ) + rechner = [ + {"name": hostname, "host": f"{hostname}.local"} + for anderes_id, hostname in cur.fetchall() + if str(anderes_id) != str(device_id) and veyon_aktiv(cur, anderes_id, merkmal_id) + ] + ergebnis["veyon_private_pem"] = privat + ergebnis["veyon_rechner"] = rechner + return ergebnis + except Exception as exc: # noqa: BLE001 - Check-in darf nicht scheitern + print(f"[veyon] Optionen fuer Geraet {device_id} nicht angereichert: {exc}") + return optionen + # Org-Ressourcen (15.10.2026, platform-docs roadmap/org-ressourcen-konzept.md, # Migration 0062): lokale Dienste einer Organisation, die App-Rollen zum # Vorbefüllen nutzen. Der Check-in schickt die für ein Gerät geltenden diff --git a/migrations/0063_veyon_schluessel.sql b/migrations/0063_veyon_schluessel.sql new file mode 100644 index 0000000..572375f --- /dev/null +++ b/migrations/0063_veyon_schluessel.sql @@ -0,0 +1,19 @@ +BEGIN; + +-- Veyon-Schluessel je OE (15.10.2026, platform-docs +-- roadmap/veyon-schluesselverteilung-diskussion-2026-10-14.md, Bauplan +-- Schritt 1). Ein Schluesselpaar je OE (= Raum), erzeugt beim ersten +-- Check-in eines Geraets dieser OE mit aktivem Merkmal "veyon". Der private +-- Schluessel liegt Fernet-verschluesselt (TUXFLOTTE_ENCRYPTION_KEY, wie die +-- _passwort-Optionen) und geht beim Check-in nur an Lehrergeraete. +-- name: nur Buchstaben (Veyon-Vorgabe), siehe veyon_schluesselname(). + +CREATE TABLE veyon_schluessel ( + oe_id UUID PRIMARY KEY REFERENCES organisationseinheiten(id) ON DELETE CASCADE, + name TEXT NOT NULL UNIQUE CHECK (name ~ '^[a-z]+$'), + public_pem TEXT NOT NULL, + private_pem_encrypted TEXT NOT NULL, + erstellt_am TIMESTAMPTZ NOT NULL DEFAULT now() +); + +COMMIT;