Changelog

Versionen nach SemVer. Die aktuelle Version steht in VERSION.

Unveröffentlicht

  • MySQL 8: groups ist dort ein reserviertes Wort. Tabelle in Schema, Migration, Code und Demo-SQL mit Backticks (MariaDB und SQLite unverĂ€ndert). Auf MariaDB und SQLite getestet, auf MySQL 8 noch nicht.
  • Node ohne Docker: bin/TLS-node-doktor.bash (Voraussetzungen, Dump-VorprĂŒfung, DB, Rechte, Apache, FPM-Pool, https-Gegenprobe), Pool-Vorlage deploy/node/tlsgate-pool.conf.example, Abschnitt in deploy/README.md, Handout docs/HANDOUT_NODE_OHNE_DOCKER.md.

v3.16.0 — 2026-10-12

Seit v3.15.0 (8b05cba): NetBox-Connector Schritt 4 (GerĂ€te-Zertifikate), NetBox-Spiegel im Web und Verwaltung der NetBox-ZugĂ€nge im Web, Buchen und Beenden im Web (Katalog und Dashboard), Fehlermeldungen mit dem Text von NetBox; dazu die Befunde aus dem Live-Test gegen seeds.crumbforest.io (docs/HANDOUT_NETBOX_LIVETEST.md). VORLÄUFIG, nicht von der Crew bestĂ€tigt. Getaggt ist der Commit, der in der VM gelaufen ist (@<commit> im Seitenkopf).

NetBox-ZugĂ€nge im Web verwalten (public/netbox.php, Entscheidung N9 in docs/DESIGN_NETBOX_CONNECTOR.md, Spec docs/superpowers/specs/2026-10-12-netbox-zugaenge-ui-design.md). Branko, 12.10.: Beim Live-Test gegen seeds.crumbforest.io standen die Fehler nur in der CLI-Ausgabe, und drei TestzugĂ€nge mussten per SQL gelöscht werden: „warum bauen wir das nicht so aus wie SOAP-ZugĂ€nge“. VORLÄUFIG, nicht von der Crew bestĂ€tigt.

  • Im Web: Zugang anlegen (Token als Passwortfeld, nie zurĂŒck ins HTML), Token tauschen, an/aus, Verbindung testen, löschen, Kontobindung an/aus. Alle Aktionen stehen als web.netbox_cred_<op> im Audit (ok, denied mit reason, fail). Die Lesesicht bleibt unverĂ€ndert, der Sync bleibt auf der CLI.
  • Rechte: group_admin verwaltet nur die eigene Gruppe und setzt nie die Kontobindung. ZugĂ€nge mit Kontobindung darf er nur testen und an/aus schalten. Die Kontobindung schaltet nur admin. Ein Zugang einer fremden Gruppe gilt als „Unbekannter Zugang“. Löschen und Kontobindung brauchen ein Pflicht-HĂ€kchen.
  • Verbindung testen (NetBoxClient::probe): liest Version, GerĂ€teanzahl des Tenants und je eine Zeile Interfaces/IP-Adressen und schreibt nichts. Nur HTTP 403 gilt als fehlendes Recht. Andere Fehler (z. B. 502) zeigen den Text von NetBox.
  • CLI: netbox_cli.php zugang delete <gruppe> <label> löscht nur, wenn weder gespiegelte GerĂ€te noch Ereignisse noch Zertifikate daran hĂ€ngen, sonst muss man abschalten. Neu ist auch zugang konto-bindung <gruppe> <label> an|aus; die Meldung nennt die laufenden Buchungen mit Anker auf GerĂ€te dieses Zugangs (DeviceMirror::anchoredBookings). Der Tenant bleibt unverĂ€nderlich.
  • Fehlermeldung mit dem Text von NetBox (NetBoxClient::get): z. B. „HTTP 400 fĂŒr /api/dcim/devices/: tenant: Select a valid choice 
 (Tenant-Slug prĂŒfen)“ oder „HTTP 403 
: Invalid token“. HTML-Fehlerseiten erscheinen ohne Tags, gekĂŒrzt auf 200 Zeichen; ein zurĂŒckgespiegelter Token wird durch *** ersetzt.
  • Live-Befunde und Folgen fĂŒr die Ansible-Rolle (u. a. RZ ohne Tenants): docs/HANDOUT_NETBOX_LIVETEST.md.
  • Tests: test_netbox 79, test_netbox_ui 44, VM p8 28 (neu: Verbindung testen im Web gegen die Fake-NetBox, eigenes Testkonto p8nb).

Buchen und Beenden im Web (docs/DESIGN_KATALOG_BUCHUNGEN.md). Branko, 12.10.: Das Dashboard zeigte Zuweisung, VerfĂŒgbarkeit und Preis, aber man konnte nichts buchen. VORLÄUFIG.

  • Katalog (katalog.php, nur admin): Im Abschnitt „Buchen fĂŒr eine Person“ der eigenen Gruppe wĂ€hlt man Person, Produkt · Kategorie (nur freigegebene, verfĂŒgbare, aktive Paare) und „gehört zu“, dazu Anker, Systemakte, Preis und SchĂ€tzung. Die Buchung landet ĂŒber Bookings::add im Zustand offered. Darunter stehen die laufenden Buchungen der Gruppe als Baum (CatalogAdmin::groupBookings, ohne Systemakte), jeweils mit „beenden“ und Pflicht-HĂ€kchen. Ein Grundprodukt lĂ€sst sich erst beenden, wenn darunter keine ZusĂ€tze mehr laufen.
  • Dashboard: Unter „Zusatz buchen“ bucht man eigene ZusĂ€tze unter eigenen laufenden Grundprodukten (Tiles::build → add). Ohne Preis: Der kommt aus der Freigabe und wird nur angezeigt; Preis und Anker aus dem POST werden ignoriert. user und group_admin sehen keine Kategorien der Achse access, admin nur diese (mit Systemakte). Eigene ZusĂ€tze lassen sich mit Pflicht-HĂ€kchen beenden.
  • Bookings::end: readonly beendet nichts mehr (rolle). Mit $nurZusatz (Dashboard) lassen sich nur ZusĂ€tze beenden (nur_zusatz). Die CLI ist unverĂ€ndert.
  • „warten“ statt „wartetn“ im Hinweis auf offene Buchungen.
  • Tests: test_katalog 188, test_bookings 80, test_dashboard_tiles 29 (POST nur activate, add, end, kein SQL in der Seite).

Test-VM: docker_vm/README.md startet jetzt mit docker_vm/build.sh statt docker compose 
 up --build. Sonst bleibt app/BUILD leer, und run.sh und p8.sh werden rot.

Schritt 4 des NetBox-Connectors: GerĂ€te-Zertifikate der Node-CA (docs/DESIGN_NETBOX_CONNECTOR.md §6). VORLÄUFIG, nicht von der Crew bestĂ€tigt. Branko, 11.10.: „nein, kein mac/wg ins zertifikat — schritt 4 starten“.

  • node_ca.php device-issue|device-list|device-cert|device-revoke, Netbox\DeviceCert, NodeCa::signDevice. Ausgestellt wird nur fĂŒr ein GerĂ€t im Spiegel der eigenen Gruppe, das nicht weg ist, nur bei Kontobindung (Regel b) und nur, solange es kein gĂŒltiges Zertifikat hat (kein stilles Umbinden).
  • Im Zertifikat steht nur der GerĂ€tename (CN + SAN DNS), nur clientAuth, 90 Tage. Keine MAC, kein wg-Key, keine NetBox-ID. Das CSR-Subject muss genau CN=<gerĂ€tename> sein, die CSR-Signatur wird geprĂŒft.
  • device_certs, device_cert_revocations (append-only, Migration 0007). Widerrufene GerĂ€te stehen in derselben CRL wie widerrufene Bestellungen. OrderStore::parseCsr ist jetzt öffentlich. Der Doktor kennt 24 Tabellen.
  • netbox.php (Wurzel): Spalte „Zertifikat“ je GerĂ€t.
  • Nicht im Frontend native_php: Die Node-CA ist dort bewusst nicht enthalten. Gespiegelt werden nur die Tabellen. Die ozm-Knoten können deshalb noch nicht ausstellen, der Ort ist offen.
  • Tests: test_device_cert 42 (neu), test_netbox_ui 18.

NetBox-Spiegel im Web, nur lesen (public/netbox.php, Link „NetBox“ in der Navigation fĂŒr group_admin/admin; docs/DESIGN_NETBOX_CONNECTOR.md §5). Branko, 11.10.: Er sah als admin@ozm03 keinen NetBox-Link, weil Schritt 1–3 nur auf der CLI liefen. Die Seite zeigt ZugĂ€nge (ohne Token), den Spiegel je Zugang (MAC, VPN-IP, wg-Key gekĂŒrzt, Link aufs GerĂ€t in NetBox), die letzten Ereignisse und den Anker-Bericht der Buchungen. Sie hat keine Aktion: Anlegen (Token ĂŒber STDIN) und sync bleiben auf der Node, weil der Abruf nach außen geht. Jeder Aufruf steht als web.netbox_view im Audit. Test: test_netbox_ui 17.

v3.15.0 — 2026-10-11

Seit v3.14.0 (e494a87): NetBox-Connector je Gruppe (Schritt 1–3) und die RĂŒckmeldung von OZMAI zu v3.13.0. VORLÄUFIG, nicht von der Crew bestĂ€tigt. Getaggt ist der Commit, der in der VM gelaufen ist (@<commit> im Seitenkopf).

SSL-<n>-SchlĂŒssel nur fĂŒr die bestĂ€tigte Form (OZMAI, 11.10.: „Dass auch ssl-04 auf 4 normalisiert wird, ist eure Implementierungsregel. Sie sollte nicht still als Provider-Garantie gelesen werden.“)

  • CatalogLoader::refKey: nur SSL-<n> und <n> → <n>, wie Branko es bestĂ€tigt hat. ssl-4 (Kleinschreibung) und SSL-04 (fĂŒhrende Null) bleiben jetzt wie geliefert, vorher wurden sie auf 4 normalisiert. Der gelieferte Wert wird weiter nie ĂŒberschrieben.
  • Grund einer Abweichung (fĂŒr den Dry-Run in der VPN-Umgebung): Die Meldung ABWEICHUNG im Abgleich und inventory_cli.php status (unbekannt_grund) unterscheiden jetzt kein_katalogeintrag („SchlĂŒssel 4: kein Katalogeintrag, Produktliste importiert?“) von form_nicht_normalisiert (Form ist nicht SSL-<n>/<n>). CatalogLoader::refMissReason.
  • Tests: test_stammdaten 35, test_inventory 72.

NetBox-Connector je Gruppe, Schritt 1–3 (docs/DESIGN_NETBOX_CONNECTOR.md). Branko, 11.10.: Das Gate ist die Ansible-Node, NetBox und Dienste laufen schon, SOAP braucht es nur fĂŒr gekaufte Zertifikate. Es fehlt der Connector an der BrĂŒcke, um Produkte, Dienste und ZugĂ€nge innerhalb der Gruppe an GerĂ€te zu binden. Danach kommt als erste Passkante die eigene Crumbforest-CA. VORLÄUFIG, nicht von der Crew bestĂ€tigt. Nur lesen, NetBox bleibt fĂŒhrend.

  • Zugang (netbox_credentials, NetBoxStore, Zweck netbox im WurzelschlĂŒssel): URL (https, ohne Query) + Token (STDIN, versiegelt, an Gruppe und Label gebunden) + Tenant-Slug + konto_bindung. netbox_cli.php zugang add|replace|enable|disable|list. user_cli.php key status|migrate kennen die NetBox-Blobs. TLSGATE_NETBOX_HOSTS schrĂ€nkt die Hosts ein.
  • Spiegel (netbox_devices, netbox_device_events append-only; DeviceMirror, NetBoxClient): netbox_cli.php fetch|load|sync|status|events|geraete. Gelesen werden dcim/devices?tenant=, dcim/interfaces (MAC aus primary_mac_address, 4.2+, sonst das alte Feld), ipam/ip-addresses (VPN-IP im VRF CrumbVPN oder an wg0) und der wg-Key aus dem Kommentar (wg-pub <key>, Konvention von netbox-mobile-clients.yml). Ausgegeben werden nur diese Felder. Wie beim Registrar-Spiegel ist der erste Lauf die Baseline, danach werden nur Änderungen protokolliert.
  • Fail closed: Die Zeilenzahl muss dem count von NetBox entsprechen. Jede Zeile muss den eigenen Tenant tragen, eine fremde Zeile lehnt alles ab. Doppelte oder ungĂŒltige Zeilen (auch eine ungĂŒltige MAC) brechen ab. Eine leere oder halbierte Liste geht nur mit --force durch. Der Token geht nur an den hinterlegten Host: kein Redirect, und ein next-Link auf einen fremden Ursprung wird abgelehnt. TLS wird geprĂŒft. Jeder Lauf steht als cli.netbox_sync/cli.netbox_fetch mit ctx.build und Tenant im Audit.
  • Anker (netbox_cli.php anker [--json]): Buchungen mit netbox:<id> (empfohlen, GerĂ€te-Slot), mac:<MAC> (hart an Hardware) oder wg:<key> werden gegen den Spiegel geprĂŒft: ok, unbekannt (Abweichung, nichts wird angelegt), weg, mehrdeutig. Dazu HARDWARE GEÄNDERT, wenn sich die MAC des GerĂ€ts seit der Buchung geĂ€ndert hat. Die MAC ist eine beobachtete Tatsache, kein SchlĂŒssel. Es gibt kein stilles Umbinden; der Reset (widerrufen + neu ausstellen) ist eine bewusste Handlung und kommt in Schritt 4. Freitext-Anker bleiben ungeprĂŒft.
  • Regel (b) (Branko 11.10.): ZugĂ€nge ohne konto_bindung (Standard, KrĂŒmel-Tenants) werden nur gespiegelt. Bookings::add lehnt einen Anker auf ein solches GerĂ€t ab (kein_kontobezug), eine ungĂŒltige Ankerform ebenfalls (anker_ungueltig). Der Bericht meldet Ă€ltere Buchungen mit REGEL B.
  • Schema: drei Tabellen, Migration 0006 (MariaDB). Der Doktor kennt 22 Tabellen.
  • Fake-NetBox in der VM (docker_vm/fake-netbox, https://netbox.fake.test, Fake-CA). Szenarien wĂ€hlt der Token: Grundstand, Zero verloren, fremder Tenant, fremder next-Host.
  • Tests: tests/test_netbox.sh (56; Gegenproben ohne Tenant-PrĂŒfung, ohne Regel b, ohne UrsprungsprĂŒfung, ohne count je rot). docker_vm/tests/p8.sh (neu) gegen MariaDB und Fake-NetBox, p5 erwartet 22 Tabellen. Befund beim Testen: Buchung und Hardware-Wechsel in derselben Sekunde wurden nicht gemeldet (ts > created_at), jetzt >=, also im Zweifel melden.
  • Nicht gebaut: Schritt 4 (Node-CA stellt fĂŒr ein gespiegeltes GerĂ€t aus, Reset bei Hardware-Wechsel), Web-Ansicht, VMs (virtualization/
), Tags → FĂ€higkeiten. Nicht belegt: der Lauf gegen die echte NetBox (seeds.crumbforest.io).

v3.14.0 — 2026-10-11

Seit v3.13.0 (00bad35): Monitor fĂŒr offene Buchungen, nur lesend. VORLÄUFIG, nicht von der Crew bestĂ€tigt. Getaggt ist der Commit, der in der VM gelaufen ist (@<commit> im Seitenkopf).

Monitor fĂŒr offene Buchungen (Zustand offered: freigeschaltet, vom Inhaber noch nicht aktiviert). Branko, 10.10.: Der bewusst offene Prozess gehört in den Monitor. Nur lesen.

  • Bookings::open([gruppe]): Ă€lteste zuerst, je Buchung Gruppe, Konto als user_id + Rolle, Produkt, Kategorie, Grundprodukt (parent), since, days, waiting_on: inhaber, dazu freigabe_aktiv/produkt_aktiv. Ist eins davon false, ist es eine Sackgasse: Der Inhaber sieht die Buchung, die Quelle ist aber abgeschaltet. Ohne E-Mail, Systemakte, Anker, Preis und SchĂ€tzung.
  • Export: bin/export.php schreibt das Feld open_bookings in den Monitor-Vertrag (Zusatzfeld, Version bleibt 1). Meldung und Audit cli.export zĂ€hlen die offenen Buchungen.
  • Monitor-Seite resources/mock/sample.html?data=
: Tabelle „Offene Buchungen (offered)“, nur textContent. Farben: unter 1 Tag tĂŒrkis, ab 1 Tag gelb, ab 7 Tagen rot. Die Schwellen sind eine Annahme des Entwicklers. Ein alter Export ohne das Feld wird als solcher benannt, die Seite behauptet dann nicht „keine“.
  • Bash: booking_cli.php open [<gruppe>] [--older-than <tage>] [--json]. Die Textausgabe zeigt die E-Mail (Operator, nur root/sudo), --json ist dieselbe Form wie im Export, ohne E-Mail, zum Weiterreichen an andere Werkzeuge. Auch in der VM ĂŒber instance-cli.sh <name> booking_cli.php open.
  • tests/test_open_bookings.sh (26), drei absichtlich kaputte Fassungen je rot (alle ZustĂ€nde, E-Mail immer, Sackgasse nie). p7 +3 gegen MariaDB.
  • Nicht gebaut: Alarm oder Benachrichtigung, ein Cron, der den Export schreibt. Im Dashboard sieht der Inhaber seine offenen Buchungen schon (Hinweis ĂŒber „Meine Buchungen“). In der VM gibt es export.php nicht (Spiegel native_php ohne MonitorExport).

v3.13.0 — 2026-10-11

Seit v3.12.0 (5e064de): Seite Katalog mit Löschen und Audit, „aktivieren“ im Dashboard, Instanzname auf der Anmeldeseite, Storno-Grund und Ablehnungen im Audit, Code-Stand im Audit, SSL-<n> = ID <n>. VORLÄUFIG, nicht von der Crew bestĂ€tigt. Getaggt ist genau der Commit, der in der VM gelaufen ist (@<commit> im Seitenkopf).

Achtung, geĂ€ndertes Verhalten: order_cli.php order cancel <id> <grund> verlangt jetzt einen Grund. Ein Aufruf ohne Grund bricht mit Nutzungshinweis ab und Ă€ndert nichts (vorher Standard „abgebrochen“).

Code-Stand im Audit (Commit-Stempel). Anlass: OZMAI 10.10. „VERSION_LABEL ⇏ EXACT_IMPLEMENTATION_STATE“ — im PrĂŒfbeleg eines SOAP-Laufs soll stehen, welcher Commit lief, nicht nur die Version.

  • Config::build()/buildStamp(): Quelle 1 ist die Datei BUILD neben VERSION (commit=<sha>, dirty=0|1), geschrieben beim Image-Bau. Quelle 2 ist eine Arbeitskopie mit .git (HEAD direkt gelesen, ohne git aufzurufen; Worktree und packed-refs eingeschlossen). Kurzform: <12 Hex> (Bau aus dem Commit), <12 Hex>-dirty (Bau mit Abweichung oder ohne Angabe), <12 Hex>-git (Arbeitskopie, Änderungen ungeprĂŒft), sonst unbekannt.
  • Audit: Aktionen, die den Registrar berĂŒhren oder seine Antwort verarbeiten, tragen ctx.build: ssl.*, cli.inventory_sync, cli.order_tick, cli.cred_test, web.cred_test, cli.catalog_import_products. Auch LĂ€ufe ohne Änderung, dry_run, rejected und denied. Andere Aktionen bleiben unverĂ€ndert.
  • Bau: docker_vm/build.sh (statt docker compose 
 up -d --build) gibt Commit und dirty als Build-Args ins Image (.git liegt nicht im Build-Kontext); instance.sh meldet den Code-Stand. Ohne build.sh steht „unbekannt“ im Image, nichts Erfundenes. BUILD ist nicht versioniert.
  • Seitenkopf: v<Version> @<Stand> nach der Anmeldung.
  • tests/test_build_stamp.sh (25), ohne Stempel 5 rot; docker_vm/tests/run.sh +4 (BUILD = Host-Commit, dirty=0, Audit ssl.exportProducts mit ctx.build, Seitenkopf).
  • Nicht verdrahtet: Ansible-Rolle/Betrieb (dort gilt: kein BUILD → Arbeitskopie oder „unbekannt“). Image-Digest nicht erfasst (OZMAI: KĂŒr).

SSL-<n> im Bestand = ID <n> der Produktliste, bestĂ€tigt (Branko, 11.10.: Die Order ĂŒbergibt als Option nur die Ziffer, die SOAP-Details geben sie mit PrĂ€fix aus). Anlass: Der Live-Lauf vom 9.10. meldete SSL-4 und SSL-80 als unbekannt.

  • CatalogLoader::refKey(): SSL-4, ssl-04, 4 → 4; andere Formen bleiben wie geliefert. Gespeichert wird weiter der gelieferte Wert (Bestand SSL-4, Import 4).
  • Verglichen wird ĂŒber den SchlĂŒssel: unbekannte Produkte beim Abgleich (Inventory::unknownProducts), Produktname auf bestand.php, Bestand ↔ Katalog und Zeilenzahl auf stammdaten.php, Löschsperre im_bestand im Katalog (genau 4 und SSL-4, statt vorher LIKE '%-4').
  • stammdaten.php: „Eintrag der Registrar-Produktliste“ ohne Fragezeichen.
  • Tests: test_inventory (Katalog 4 trifft Bestand SSL-4), test_stammdaten 34 (+2), p7 ohne Fragezeichen. Gegenproben ohne SchlĂŒssel je rot.

Seite „Katalog“ (katalog.php), nur Rolle admin: Produkte und Kategorien pflegen, Zuordnungen setzen und lösen, Freigabe fĂŒr die eigene Gruppe. VORLÄUFIG, nicht von der Crew bestĂ€tigt.

  • Kategorien: anlegen (Slug, Name, Achse, Preisart, Oberkategorie), bearbeiten, ab- und einschalten. Produkte: anlegen mit mindestens einer aktiven Kategorie, Name und Art bearbeiten, ab- und einschalten. Zuordnung Produkt ↔ Kategorie setzen und lösen. Freigabe fĂŒr die Gruppe des Admins setzen (mit optionaler Preisreferenz, ĂŒber Bookings::offer) und zurĂŒcknehmen. Erst danach erscheint ein Produkt auf dem Dashboard der Gruppe und ist buchbar.
  • Löschen ist endgĂŒltig (Branko, 9.10.: keine inaktiven DatensĂ€tze vorhalten). Der gelöschte Datensatz steht im Audit (catalog.product_delete mit Kategorien und Freigaben, catalog.category_delete). Abgelehnt, solange etwas darauf zeigt: Buchungen in jedem Zustand, auch beendete, denn sie bleiben die Quelle der Berechnung (in_buchungen), Verbrauch (hat_verbrauch), Registrar-Bestand mit der Registrar-ID, gezĂ€hlt als SSL-4 und 4 (im_bestand), zugeordnete Produkte (hat_produkte), Unterkategorien (hat_unterkategorien). FĂŒr diesen Fall bleibt abschalten: Neu bucht niemand mehr, die Quelle bleibt. Freigabe zurĂŒcknehmen: ohne jede Buchung wird die Zeile gelöscht, mit nur beendeten Buchungen bleibt sie abgeschaltet stehen.
  • Slugs sind nach dem Anlegen fest (von Branko bestĂ€tigt).
  • Regeln (src/Catalog/CatalogAdmin.php, Code im Audit catalog.* als reason): nur admin (nur_admin); Achse access nur mit Preisart none (access_ohne_preis); Achse und Preisart einer Kategorie bleiben, solange laufende Buchungen sie tragen (kategorie_in_gebrauch), die Art eines Produkts ebenso (produkt_in_gebrauch); kein Kreis bei Oberkategorien (parent_zyklus); die letzte Kategorie eines Produkts bleibt (letzte_kategorie), eine von einer laufenden Buchung genutzte Zuordnung auch (zuordnung_in_gebrauch); eine Freigabe mit laufenden Buchungen wird nicht zurĂŒckgenommen (freigabe_in_gebrauch); Name und Art von Registrar-Produkten kommen nur aus dem Import (registrar_produkt), zuordnen und freigeben geht.
  • Kein neues Schema, keine Migration. Kopfzeile: Link „Katalog“ fĂŒr admin. stammdaten.php bleibt reine Lesesicht und verweist auf den Katalog.
  • Bekannt: catalog_cli.php load setzt Name, Art, Achse und Preisart der EintrĂ€ge aus catalog_matrix.json auf den Stand der Datei zurĂŒck. Der Matrix-Abgleich findet Produkte ĂŒber den Namen, ein Umbenennen macht die Zeile also offen (✗).
  • tests/test_katalog.sh (97): jede Regel mit Code, Löschen samt Ablehnungen, die Seite ĂŒber HTTP (403 fĂŒr group_admin, CSRF, Formulare, Maskierung, kein Inline-JS), Durchstich bis Dashboard und Stammdaten. Sechs absichtlich kaputte Fassungen sind je rot. Regeln und Darstellung auch gegen MariaDB (tlsgate-my) geprĂŒft. VM (Image neu gebaut, 9.10.): run 86, p3 48, p4 54, p5 31, p6 22, p7 50 (+6: Katalog-Klickpfad gegen MariaDB mit DML-Benutzer — anlegen, zuordnen, freigeben, Löschen abgelehnt und endgĂŒltig, Audit, leeres PHP-Fehlerlog). Klicktest-Instanz ozm03 frisch aufgebaut: mockup.sql + demo_matrix.sh (24 Produkte, 12 Kategorien, 18 aktive Buchungen, 12 Zeilen Beispielbestand an abgeschaltetem Zugang).

Instanzname auf der Anmeldeseite (Dev): login.php zeigt „Instanz: “, wenn TLSGATE_INSTANCE_LABEL gesetzt ist (Config::instanceLabel, nur A-Za-z0-9._-, höchstens 32 Zeichen, sonst nichts). Ohne die Variable bleibt die Seite wie bisher: Im Betrieb verrĂ€t die Anmeldeseite keinen Namen. In der VM setzt instance.sh die Variable aus LOGIN_INSTANCE=1|0 in /root/.provision/<name>.env, Standard 1, eine fehlende Zeile wird ergĂ€nzt. Anlass: Ein alter Chrome-Tab traf ozm01 statt ozm03, die Fehlermeldung sagte nicht, welche Instanz antwortete. tests/test_login_instance.sh (5), VM run.sh 87 (+1), An/Aus in der VM von Hand geprĂŒft.

  • Löschen-Knopf im Katalog rot hinterlegt (Kontrast 6,5:1).
  • CSS und JS mit Versionsstempel (View::asset: assets/tlsgate.css?v=<Änderungszeit>). Anlass: Der rote Knopf blieb im Browser unsichtbar, obwohl der Server die neue CSS lieferte. nginx schickt kein Cache-Control, der Browser nahm die alte Fassung aus dem Cache. Nach jeder Änderung gibt es jetzt eine neue Adresse.
  • Dashboard: Hinweis oben in „Meine Buchungen“ („n Buchung(en) warten auf deine Aktivierung“), Knopf „aktivieren“ gefĂŒllt in der Akzentfarbe. Anlass: Branko fand den Knopf nicht, obwohl er ausgeliefert wurde (serverseitig gerendert und ĂŒber die SeitengrĂ¶ĂŸe geprĂŒft). Er stand klein und durchsichtig am Ende eines Baums mit neun aktiven EintrĂ€gen.
  • Dashboard: „aktivieren“ fĂŒr den Inhaber. Eine freigeschaltete Buchung (offered) trĂ€gt im eigenen Baum den Knopf „aktivieren“ (POST + CSRF, Bookings::activate, Audit booking.activate). Nur der Inhaber (nur_inhaber) und nur aus offered (zustand), Ablehnungen stehen im Audit. Sonst bleibt das Dashboard lesend. Anlass: Branko meldete die User als „aktiviert“, das Audit zeigte aber nur Anmeldung und TOTP-Einrichtung. Einen Knopf zum Aktivieren gab es nicht, obwohl der Entwickler ihn angekĂŒndigt hatte. tests/test_katalog.sh 111 (+8: Knopf, CSRF, Admin darf fremde Buchung nicht, Inhaber aktiviert, zweimal abgelehnt, Audit), test_dashboard_tiles prĂŒft „POST nur fĂŒr aktivieren“.
  • Katalog-Ablehnungen im Audit lesbar: Ein denied-Eintrag trĂ€gt jetzt, was versucht wurde (nur die Felder, die sich Ă€ndern sollten, als {"price_mode": {"von": "priced", "nach": "free"}}), und was im Weg stand (buchungen, produkte, unterkategorien, verbrauch, bestand). Die Meldung auf der Seite nennt die Anzahl. Anlass: Brankos Eintrag catalog.category_edit denied container {"reason":"kategorie_in_gebrauch"} sagte nicht, was er Ă€ndern wollte. tests/test_katalog.sh 103.
  • Storno-Grund im Audit: order_cli.php order cancel <id> <grund>, der Grund ist jetzt Pflicht (vorher optional, Standard „abgebrochen“). cli.order_cancel trĂ€gt grund, von (vorheriger Zustand) und registrar_ref, vorher stand der Grund nur in order_events. Abfragbar auf auditlog.php ĂŒber die Suche (Aktion und Kontext), z. B. „order_cancel“ oder „falscher CSR“. tests/test_orders.sh 119. Ein Storno beim Registrar gibt es weiterhin nicht im Code.

v3.12.0 — 2026-10-08

  • inventory_cli.php load|status|events nehmen auch einen abgeschalteten SOAP-Zugang (dabei lĂ€uft kein SOAP-Aufruf; Hinweis auf stderr). Ohne TLSGATE_CRED: Label default, sonst der einzige Zugang der Gruppe. soap_cli.php sync und „Aktualisieren“ im Web brauchen weiter einen aktiven Zugang.
  • database/demo/bestand_beispiel.json: 12 synthetische Bestandszeilen (example.test, source mock) im Format von soap_cli.php list. FĂ€lle: drei Orders auf derselben Domain, eine Order mit zwei Domains, Ablauf in 5 und 15 Tagen, Wildcard SSL-80, SSL-6 ohne Katalogeintrag. Laden nur fĂŒr Demo: TLSGATE_GROUP=<gruppe> php bin/inventory_cli.php load database/demo/bestand_beispiel.json --allow-mock.
  • tests/test_inventory.sh (70).
  • Seite „Stammdaten“ (stammdaten.php), ab Rolle group_admin, nur lesen: PrĂŒfungen (Produkte ohne Kategorie, SSL ohne Registrar-ID, Bestand ohne Katalog, Personen ohne Buchung), Bestand ↔ Katalog mit Zeilenzahl und Kandidat aus der Registrar-Produktliste (mit Fragezeichen: „SSL-4 = ID 4“ ist vermutet), Produkte mit Kategorien und „eingerechnet“ je Rolle (Admin: Kategorie der Achse access ohne Preis; User: Kategorie mit Preis), Freigabe und Buchungen in der eigenen Gruppe, Kategorien, Buchungen je Person als Baum mit Abrechnung je Eintrag (ohne Systemakte, nur „hinterlegt“). Kein POST. Logik src/Catalog/MasterData.php.
  • database/produktliste_registrar.json: die 131 EintrĂ€ge der Produkt-Auswahlliste der Registrar-Konsole (ID, Name, Laufzeit; Quelle Branko 8.10.). Referenz fĂŒr Kandidaten, legt nichts an.
  • database/demo/stammdaten_beispiel.sql: Vorlage fĂŒr den manuellen Import, legt PositiveSSL 1 Jahr (SSL-4) und PositiveSSL Wildcard 1 Jahr (SSL-80) mit den Kategorien hosting und admin an. SQLite und MariaDB, mehrfach ladbar.
  • Matrix-Abgleich auf stammdaten.php: jede Zeile von Brankos Produktmatrix (database/matrix_branko.txt, wörtlich, 17 Zeilen) mit ✓/✗ — Produkt im Katalog, Kategorie zugeordnet, aktive Buchung in der Gruppe (von wem), Abrechnung (eingerechnet/mit Preis). MasterData::matrixCheck.
  • bin/demo_matrix.sh <gruppe> [--vm <instanz>]: Demo-Mockup, das die Matrix vollstĂ€ndig bucht: Katalog, Beispiel-Import, Freigaben aller Produkte, zwei Demo-User, 18 Buchungen ĂŒber booking_cli.php (also durch alle Buchungsregeln), als Inhaber aktiviert, Beispielbestand an einem abgeschalteten Zugang demo. Admin: #admin (eingerechnet, mit Systemakte); User 01: VM Small #proxmox mit allen ZusĂ€tzen und Server #baremetal; User 02: Hosting #managed mit Managed #hosting, Cluster #HA, RPI zero #container. Passwörter zufĂ€llig, in der VM unter /root/.provision/<instanz>.demo.
  • tests/test_matrix_mockup.sh (20): 17 von 17 bestĂ€tigt, #admin genau die vier Admin-Zeilen, Gegenproben (andere Gruppe 0, falsche Zeilen offen, beendete Buchung zĂ€hlt nicht).
  • Tests: tests/test_stammdaten.sh (32, zwei absichtlich kaputte Fassungen je rot), docker_vm/tests/p7.sh (44: Seite und Import gegen MariaDB).

v3.11.1 — 2026-10-08

Klick-Dummy: Seite „Bestand“ (bestand.php) zeigt den Registrar-Spiegel der eigenen Gruppe aus der DB. VORLÄUFIG.

  • Lesen ab readonly, nur Gruppen mit SSL-Zugang (eigen). Sortierbar (Order, Domain, Status, gĂŒltig bis, gesehen seit), Filter nach Status, Suche in Order/Domain (%/_ sind keine Platzhalter), „auch verschwundene“. Nach der Baseline aufgetauchte Orders sind als NEU markiert (es gibt ein new-Ereignis), je Zeile der Bash-Befehl soap_cli.php info <order> zum Kopieren. Darunter die letzten 15 Änderungen. Produktname aus dem Katalog, sonst „(nicht im Katalog)“.
  • „Aktualisieren“ ab user: POST + CSRF, holt sslListExtended live und gleicht mit denselben Schutzregeln wie die CLI ab (--force gibt es nur in der CLI). Höchstens alle 5 Minuten je Zugang (sonst gilt der Spiegel), Audit ssl.inventory_sync (ok/denied/rejected/failed). Nie Beispieldaten.
  • Kopfzeile: Link „Bestand“ fĂŒr Gruppen eigen.
  • VM: Spiegel native_php/ nachgezogen (vorher fehlten Inventory, inventory_cli.php, Schema 0005: sync lief in der VM nicht), Doktor kennt 19 Tabellen, instance-cli.sh reicht TLSGATE_GROUP/TLSGATE_CRED durch. Neue Suite docker_vm/tests/p7.sh (37): sync live gegen den Fake-Registrar und MariaDB, load, Trigger, Schutzregeln, Klickpfad mit Anmeldung, Pause, Aktualisieren, CSRF.
  • Tests: tests/test_inventory.sh (63) mit Modell und gerendertem HTML (Sortierung, Filter, Gruppentrennung, Maskierung, kein <script>).
  • Nicht geprĂŒft: echte SOAP-Antwort mit ĂŒber 600 Zeilen (Laufzeit eines Web-Abgleichs), Rolle user/readonly im Klickpfad der VM (nur das Modell), Browser-Optik.

v3.11.0 — 2026-10-08

Feature: Bestand beim Registrar in die DB spiegeln, danach nur noch Änderungen erkennen. VORLÄUFIG, nicht von der Crew bestĂ€tigt. Nur Lesespiegel: das Bestandssystem bleibt fĂŒhrend, TLS-Gate bestellt hier nichts.

  • Schema: registrar_inventory (eine Zeile je Zugang, Order und Domain, denn eine Order kann mehrere Domains tragen) und registrar_inventory_events (append-only: baseline, new, changed mit {feld:[alt,neu]}, gone, back). Migration 0005 nur MariaDB (vermerkt die Version, die Tabellen kommen aus schema.mysql.sql); SQLite gleicht selbst an. Doktor/p5.sh erwarten 19 Tabellen.
  • Abgleich: php bin/soap_cli.php sync [--dry-run] [--force] (live, braucht TLSGATE_GROUP) oder php bin/inventory_cli.php load <datei|-> 
 fĂŒr die gespeicherte Ausgabe von soap_cli.php list; dazu status und events [N]. Der erste Lauf je Zugang ist die Baseline (ein Ereignis mit Anzahl, keine 600 Einzelereignisse), danach steht nur noch, was sich Ă€ndert. Alles in einer Transaktion, --dry-run schreibt nichts, jeder Lauf im Audit (cli.inventory_sync).
  • Schutzregeln (fail closed): nur source soap (mock nur mit --allow-mock, unavailable nie) · Liste ab 2000 Zeilen = vermutlich abgeschnitten, auch mit --force abgelehnt · leere Liste oder mehr als die HĂ€lfte weg (ab 10 vorhandenen Zeilen) nur mit --force · eine ungĂŒltige oder doppelte Zeile bricht alles ab.
  • Abweichung statt Automatik: Ein productId (z. B. SSL-4) ohne passendes products.registrar_ref wird gemeldet (ABWEICHUNG: 
), nie angelegt. Sonderprodukte, Stammdaten und VerknĂŒpfungen pflegt der Mensch per SQL-Import. Zeit 0 (Zertifizierung lĂ€uft) wird NULL.
  • Datenschutz: Der Bestand liegt nur in der DB. tests/test_inventory.sh (44) arbeitet ausschließlich mit synthetischen Daten (example.test); vier Schutzregeln und der SchlĂŒssel (Order+Domain) sind mutationsgetestet (je rot). Gegen MariaDB von Hand geprĂŒft (Migration 1–5, Baseline, Änderung, Append-only-Trigger).
  • Nicht gebaut: Benachrichtigung der Crew bei Abweichung („resonieren“), VerknĂŒpfung der Bestandszeilen mit Stammdaten/Gruppen/Usern (Lese- und Schreibsicht), Dashboard-Anzeige, Paging ĂŒber 2000 Zeilen, regelmĂ€ĂŸiger Lauf (Cron). Offen: ob die echte Liste weitere Status/Felder kennt; die Statuswerte zertifizierung_laeuft und ssl_ok stammen aus einem eingefĂŒgten Beispiel.

v3.10.1 — 2026-10-07

Fix: Admin-Dienste (Postgres, VPN, Gitea) standen als Angebot auch im Dashboard normaler User. Aufgefallen beim Durchsehen der Seiten von ozm03: user01 sah „Postgres“ unter „Produkte und Freigaben“, obwohl er es nicht buchen kann (nur_admin). Tiles::products blendet jetzt Produkte aus, deren Kategorien alle der Achse access angehören, außer fĂŒr Konten mit Rolle admin. Produkte ohne Kategorie (Altbestand, Demo) bleiben fĂŒr alle sichtbar.

  • tests/test_bookings.sh (78): Admin sieht Postgres, User nicht, Storage und SSL sieht der User; ohne den Filter rot.

v3.10.0 — 2026-10-07

Feature: Export-Button fĂŒr die SOAP-Produktliste und Import in den Katalog. VORLÄUFIG, nicht von der Crew bestĂ€tigt.

  • Export: Button „Produktliste exportieren (JSON)“ im Reiter „SSL List & Status“ (ab Rolle user), public/products_export.php (nur POST, CSRF, Audit ssl.exportProducts), dazu soap_cli.php products. SslHandler::exportProducts() liefert die Zeilen von sslGetProductList roh (alle Felder, keine Zugangsdaten, keine Bestellungen). source: soap live · mock nur mit TLSGATE_MOCK=1 · unavailable ohne Ersatzliste (die Seite antwortet 502 statt einer Beispielliste). Dateiname trĂ€gt die Quelle.
  • Import: php bin/catalog_cli.php import-products <datei|-> [--dry-run] [--allow-mock]. Nur source soap; mock nur mit --allow-mock (Demo), unavailable nie. Abgleich ĂŒber registrar_ref (nie ĂŒber den Namen); Slug ssl-<id>; ein vorhandener Slug mit anderem registrar_ref bricht ab; alles in einer Transaktion. Ein vorhandenes Produkt behĂ€lt active; ein neues ist aktiv nur bei status activated. Neue SSL-Produkte bekommen die Kategorie hosting (nicht admin).
  • Schema: products.registrar_ref und products.registrar_meta (Rohzeile als JSON); Migration 0004 nur MariaDB.
  • „Großes Zertifikat“ (Branko: Wildcard, mehrere Domains oder DigiCert) wird aus den API-Feldern gelesen (wildcard, type, maxDomain, CA), nicht als eigenes Flag: CatalogLoader::isLargeCert(), in catalog_cli.php list als Spalte GROSS. „Mehrere Domains“ ab 2 ist Annahme des Entwicklers.
  • Tests: tests/test_products_import.sh (33; Fixture tests/fixtures/soap_products_doc.json ist aus der Doku nachgebaut, nicht live), vier absichtlich kaputte Fassungen je rot. docker_vm/tests/run.sh +12 gegen MariaDB (Button, Export-Header, Import, Idempotenz, 405/403/302, Audit).
  • Nicht belegt: die echte Antwort von sslGetProductList (Feldnamen laut Doku, nicht live gesehen); ob SSL-4 aus sslListExtended dem id der Produktliste entspricht.

v3.9.1 — 2026-10-07

Fix: Passwort zurĂŒcksetzen in users.php scheiterte unter MariaDB mit SQLSTATE 1370 
 routine 'unixepoch'. pw_changed_at = unixepoch() ist eine SQLite-Funktion; jetzt kommt die Zeit aus PHP (time()), wie schon in user_cli.php. Der Fehler bestand seit dem Web-Reset und fiel unter SQLite nicht auf.

  • tests/test_sql_portable.sh erkennt unixepoch(), julianday() und sqlite_* im Anwendungscode (mit dem alten users.php rot, 3/1).
  • docker_vm/tests/run.sh (74) prĂŒft den Reset gegen MariaDB: Meldung „gesetzt“ ohne SQLSTATE, pw_changed_at gesetzt, Audit admin.user_passwd. Gegen den alten Stand in der VM nicht gelaufen.

v3.9.0 — 2026-10-07

Feature: Katalog nach Matrix, Kategorien, Buchungen je Person (docs/DESIGN_KATALOG_BUCHUNGEN.md). VORLÄUFIG, nicht von der Crew bestĂ€tigt.

  • Schema: categories, product_categories, user_products; products.kind um container, addon, service, token erweitert (VARCHAR(16)). Migration 0003 nur fĂŒr MariaDB (liest den Namen der CHECK-Regel, idempotent); SQLite gleicht selbst an. Doktor und p5.sh erwarten 17 Tabellen.
  • Matrix als Daten: database/catalog_matrix.json (12 Kategorien, 16 Produkte, 21 Zuordnungen), bin/catalog_cli.php load [--dry-run]|list. Idempotent, löscht nichts, schaltet Abgeschaltetes nicht wieder an, stellt abweichende Namen zurĂŒck.
  • Buchungen: src/Catalog/Bookings.php (Grundprodukt mit Anker, ZusĂ€tze darunter, Admin-Dienste ohne Preis mit Systemakte), bin/booking_cli.php offer|add|list|activate|end. 20 Regeln mit festen Fehlercodes, jede Ablehnung im Audit, die Systemakte nie.
  • Sicht: Die Systemakte (sot_ref) sieht nur der Inhaber. Das Dashboard zeigt „Meine Buchungen“ (eigener Baum); die Systemakte der Freigabe (group_products.sot_ref) steht nicht mehr auf den Freigabe-Kacheln (Vorgabe: „der Admin sieht den NetBox-Link der User nicht“).
  • Tests: tests/test_bookings.sh (75, mit fĂŒnf absichtlich kaputten Fassungen je rot), test_dashboard_tiles.sh (29), VM-Suiten gegen MariaDB.
  • docker_vm: instance-cli.sh <name> catalog_cli.php|booking_cli.php 
 startet auch diese Werkzeuge.
  • Nicht gebaut: Domains/Orders, SOAP-JSON-Export (kommt als nĂ€chstes), Import der Systemakten, Preisberechnung.

v3.8.0 — 2026-10-06

Feature: Dashboard-Seite mit Kacheln (public/dashboard.php), nur lesend. Die SSL-Konsole index.php bleibt unverÀndert und weiter das Ziel nach dem Login; die Kopfzeile verlinkt das Dashboard.

  • src/Modules/Tiles.php (reine Auswertung, schreibt nichts, kein Audit): chain (Anzeige aus Chain::status, sperrt nichts), modules (vorhandene Seiten mit Schnelllink; sichtbar nach Rolle, SSL-Konsole nur Gruppentyp eigen wie Auth::sslAllowed()), products (je aktive Freigabe aktiver Produkte der eigenen Gruppe: ready/pending mit „noch n min/Std/Tage“, Verbrauch der jĂŒngsten Periode, Preisreferenz, SOT-Verweis).
  • VorlĂ€ufig, nicht von der Crew bestĂ€tigt: Ein Produkt hat noch kein Ziel-Feld. Verlinkt wird nur ssl → index.php (und nur, wenn verfĂŒgbar); alle anderen Kacheln zeigen „kein Ziel hinterlegt“, kein erfundener Link. Angezeigt wird die Preisreferenz, nie ein berechneter Preis.
  • Tests: tests/test_dashboard_tiles.sh (27: Modell und HTML mit den Demo-Daten, Gruppentrennung, Escape-Probe <script> im Produktnamen, Redirect ohne Anmeldung), gegengeprobt mit drei kaputten Fassungen (inaktive Freigabe sichtbar, Link fĂŒr jedes Produkt, Name ohne Maskierung): je rot. docker_vm/tests/run.sh +6 PrĂŒfungen (58) gegen MariaDB: Freigabe einfĂŒgen, Kachel erscheint, aufrĂ€umen.
  • Demo: database/demo/mockup_freigaben_fuer_gruppe.sql gibt einer bestehenden Gruppe die Demo-Produkte frei (fĂŒr den Klicktest mit echtem Konto, z. B. ozm03).
  • Nicht gebaut: Chain ist weiter in keiner Seite verdrahtet, keine Sperre, keine Weiterleitung nach dem Login auf das Dashboard, kein products.target_url.

v3.7.0 — 2026-10-06

Feature: Env-Flag TLSGATE_MFA_DEFAULT=qr|code fĂŒr den MFA-Modus neu angelegter Gruppen. Standard ohne Angabe: qr (vorher fest code).

  • Config::mfaDefault(); genutzt von user_cli group add und public/users.php. UngĂŒltiger oder leerer Wert fĂ€llt auf qr zurĂŒck, Groß-/Kleinschreibung egal. Bestehende Gruppen behalten ihr groups.mfa_policy; es gibt keinen stillen Wechsel, Umstellen weiter per group mfa <slug> code|qr. Das Schema (DEFAULT 'code') bleibt unverĂ€ndert, der Wert wird beim Anlegen gesetzt.
  • Achtung, Sicherheit: qr heißt Passwort allein reicht zur Einrichtung, TOTP ist dann kein zweiter Faktor mehr (siehe v3.4.0). FĂŒr Gruppen mit Root/Managed/SSL (bare, vm, ha, ssl) nach dem Anlegen auf code stellen oder die Variable auf code setzen. Die Festlegung „qr als Standard“ ist Brankos Vorgabe, die Wirkung auf Root-Gruppen ist nicht mit der Crew besprochen.
  • tests/test_mfa_default.sh (8), gegengeprobt mit vertauschtem Standard: rot. test_mfa, test_auth, test_credentials_ui setzen TLSGATE_MFA_DEFAULT=code, weil sie den Code-Weg prĂŒfen.
  • docker_vm: Image mit neuem Docker (29.8.2) neu gebaut, run 52, p3 48, p4 54, p5 31, p6 22 grĂŒn. Die Suiten stellen den MFA-Modus der Instanz weiterhin selbst auf code (lib.sh).

v3.6.1 — 2026-10-06

Fix: eine neue Order-ID wurde nach sslReplace/sslCreate verschluckt oder durch die alte ersetzt.

  • Befund: Das Doku-Beispiel zu sslReplace (S. 170) zeigt <orderNumber nil>, live kam am 10.9. eine neue Nummer. renewCert() gab bei fehlender Nummer still die angefragte zurĂŒck, die Rohantwort wurde nirgends festgehalten.
    Am 6.10. (zwei Fehlversuche wegen alter CAA-EintrĂ€ge, Orders 3973250/3973252) erschien die neue ID erst nach Neuladen des SOAP-Index. Ob die Antwort sie trug, lĂ€sst sich fĂŒr diesen Fall nicht mehr belegen.
  • SslHandler::writeInfo() (fĂŒr renewCert und orderCert): orderNumber nur, wenn die Antwort eine enthĂ€lt, sonst null; dazu requestedOrder, newIdGiven, newIdIsNew und raw (orderNumber, returnCode, returnSubCode, returnMessage, serverTRID).
  • Audit: ssl.renewCert/ssl.orderCert schreiben die Rohantwort in audit_log.ctx, Ergebnis accepted, accepted_no_new_id oder failed. dashboard.sh schreibt renew_response bzw. ok_no_new_id mit raw= ins actions.log und macht bei fehlender ID nicht mit der alten Nummer weiter.
  • OberflĂ€che: Hinweis „keine neue Order-ID geliefert“ in den Ergebnisseiten von Order und Renew.
  • tests/test_writeinfo.sh (12), gegengeprobt mit RĂŒckfall auf die angefragte Nummer: rot.
  • Nicht gelöst: Domains mit n Orders in der DB (Abrechnungszeitraum ≠ SSL-Laufzeit) und der Kategorie-Layer (z. B. digicert #abrechnung, letsencrypt #free). Entwurf steht aus, Form vorlĂ€ufig.

v3.6.0 — 2026-10-05

Feature: Modulkette als Regel (src/Modules/Chain.php). In keiner Seite verdrahtet, kein Schema-Wechsel, Standardverhalten unverÀndert.

  • Neu: Chain wertet #login → #crew → #schluessel → #ca → #hosting → #ssl je Gruppe aus den Stammdaten aus (status, access, next, check, requireModule). Zwei Regeln nach Vorgabe:
    (1) keine Sackgasse: der Zugang zu einem Glied verlangt nur, dass die Glieder davor nicht fehlen, nicht dass es selbst erfĂŒllt ist, sodass das nĂ€chste Glied nach dem ersten Zugang immer anlegbar bleibt;
    (2) Fehlversuch im Audit: wer ein Modul ohne Berechtigung aufruft, wird abgewiesen, module.denied / denied mit reason (vorgaenger_fehlt, keine_freigabe, unbekanntes_modul) und blocked_by.
    Glied „ca“ ist open (Platzhalter, blockiert nicht): die CA ist heute nur eine Test-CA der Node. Nicht eingebaut, weil die SSL-Konsole sonst fĂŒr alle Gruppen ohne Freigabe sofort gesperrt wĂ€re.
  • VorlĂ€ufige Festlegungen, nicht von der Crew bestĂ€tigt (Kopf von Chain.php, docs/DESIGN_DASHBOARD.md): login = erste erfolgreiche Anmeldung in der Gruppe, crew = mindestens ein aktives Konto, schluessel = aktiver SOAP-Zugang,
    hosting/ssl = aktive, verfĂŒgbare Freigabe der passenden Produktart. Der Test hĂ€lt sie fest, damit eine Änderung auffĂ€llt.
  • tests/test_chain.sh (43): Ende-zu-Ende ĂŒber alle sechs Glieder, von der leeren Gruppe bis zum freigegebenen SSL, mit der 30-Minuten-Verzögerung ĂŒber available_from, getrennten Gruppen und Audit-ZĂ€hlung (jede Ablehnung, keine Erlaubnis).
    Gegengeprobt mit drei absichtlich kaputten Fassungen (ohne Audit, mit Sackgasse, open blockiert): je rot.

v3.5.0 — 2026-10-05

Schema: Stammdaten fĂŒr Produkte und Freigaben (docs/DESIGN_DASHBOARD.md, Schritt 1). Nur Tabellen, noch keine OberflĂ€che und kein Programmcode, der sie liest.

  • Neu: products (Katalog, kind = shared|bare|vm|ha|ssl), group_products (Freigabe je Gruppe: active, available_from, price_ref, sot_ref, created_by) und product_usage (Verbrauch je Periode JJJJ-MM
    und Metrik). FremdschlĂŒssel ohne Löschweitergabe, UNIQUE je Gruppe/Produkt und je Freigabe/Periode/Metrik. available_from, price_ref, sot_ref sind bewusst nullable: Startpunkt der 30-Minuten-Regel, Preis- und SOT-Quelle sind offen.
    groups.type bleibt der Rechte-Schalter und ist nicht der Produkttyp. tests/test_products.sh 48 (SQLite und MariaDB gleiche PrĂŒfungen, migrate auf dem Stand v3.4.1).
  • Fix (Migrator, MySQL/MariaDB): migrate --status kannte nur zwei feste Tabellen. Eine neue Tabelle ohne Migrationsdatei meldete "nichts zu tun", und docker_vm/instance.sh ĂŒbersprang migrate: die Instanz lief ohne die Tabellen
    weiter (bei ozm01 gefunden, weil p5 den Doktor mit 14 Tabellen erwartet). Jetzt liest der Status die Tabellennamen aus schema.mysql.sql und meldet fehlende als create. Regressionstest mit altem Migrator rot, mit neuem grĂŒn.
  • Doktor: erwartet 14 Tabellen (bin/TLS-doktor.bash). docker_vm: lib.sh stellt den MFA-Modus von ozm01 vor jeder Abnahme auf code; ein Klicktest mit group mfa 
 qr ließ sonst 3 bis 24 PrĂŒfungen je Suite scheitern.

v3.4.1 — 2026-10-05

Patch: veraltetes Schema meldet sich im Web als 503 mit dem Befehl. Kein Schema-Wechsel.

  • Fix: Wer v3.4.0 einspielte und migrate vergaß, bekam bei der Anmeldung eine leere Seite (HTTP 200, Fatal no such column: g.mfa_policy nur im Log). bootstrap.php fĂ€ngt im Web
    fehlende Spalten/Tabellen (SQLite-Text, MySQL 42S22/42S02) ab und antwortet 503 — 
 Datenbank-Schema veraltet (php bin/user_cli.php migrate; vorher migrate --status). Andere Fehler laufen unverĂ€ndert weiter.
    Neu: tests/test_schema_503.sh (12), alter Stand aus der Git-Historie, dazu die Gegenprobe, dass andere Fehler ein 500 mit Ursache im Log bleiben (ein erster Entwurf mit Rethrow lief in eine Endlosrekursion; docker_vm p3 hat das gefunden). Merke: nach jedem Update php bin/user_cli.php migrate.

v3.4.0 — 2026-10-05

Feature: MFA-Modus je Gruppe (groups.mfa_policy). Schema-Wechsel (Migration 0002), Standardverhalten unverÀndert (code).

  • Neu: groups.mfa_policy = code|qr (Default code). Bei qr erzeugt jeder Passwort-Login ein neues Secret + QR, also auch nach Abmelden und Session-Ablauf; bei code gilt nach der Einrichtung nur der Code der App.
    Setzen: user_cli.php group mfa <slug> <code|qr> oder in users.php (Spalte MFA). group list zeigt den Modus. Der Support-Weg user code / reset-mfa auf der Bash bleibt.
    Sicherheit: wie bei TLSGATE_MFA_QR_EVERY_LOGIN: wer das Passwort kennt, sieht das QR. Gedacht fĂŒr Login-Produkte (Shared/Bare), nicht fĂŒr Gruppen mit Root/Managed/SSL.
    Der Env-Schalter bleibt als globale Übersteuerung. Auslöser: nach dem Abmelden kam kein QR, nur der Hinweis auf die Bash. Ursache war der Default, kein Fehler im Code.
  • Migration 0002_group_mfa_policy nur fĂŒr MySQL/MariaDB (idempotent ĂŒber information_schema); SQLite gleicht groups selbst an. tests/test_mfa.sh 39 (+11), MariaDB-Migration grĂŒn.

v3.3.0 — 2026-10-03

Feature: optional neues TOTP-QR bei jedem Passwort-Login; login.doktor.sh. Kein Schema-Wechsel, Standardverhalten unverÀndert.

  • Neu: TLSGATE_MFA_QR_EVERY_LOGIN=1 (src/Auth/Auth.php): jeder Passwort-Login erzeugt ein neues TOTP-Secret, legt es versiegelt in der DB ab (totp_enabled=0 bis zur BestĂ€tigung)
    und zeigt das QR; erst der Code des neuen Secrets meldet an. Das alte Secret ist sofort ungĂŒltig, totp_last_step wird zurĂŒckgesetzt (ein eben benutzter Code ist nach Logout nicht mehr gesperrt).
    Sicherheit: wer das Passwort kennt, sieht das QR — TOTP ist dann kein zweiter Faktor mehr. Nur Mockup/Demo; Standard aus. tests/test_mfa.sh 28 (+4).
  • docker_vm: instance.sh <name> [mock] [qrlogin] setzt die Variable im Pool (env[TLSGATE_MFA_QR_EVERY_LOGIN] = 1). Ohne qrlogin entfĂ€llt sie beim nĂ€chsten Lauf wieder. Bewusst nicht
    fest im Pool-Template: die Abnahmen (run.sh, p3–p6) melden sich mit user code an und brauchen das bestehende Secret. Im Container von Hand belegt: zwei Logins, je neues Secret, 302, danach mfa.
    Nicht automatisiert abgesichert: die Option qrlogin in instance.sh (kein eigener Test in docker_vm/tests/).
  • Neu: bin/login.doktor.sh [URL] [Instanz] [E-Mail] — warum geht die Anmeldung nicht? Nur lesend: Port vergessen, falscher Dienst auf dem Port, Vhost/Host-Header,
    __Host--Cookie ĂŒber http, login.php/CSRF, Pool-Schalter, Konto (gesperrt/deaktiviert/mfa-offen) und die letzten auth.*-Audit-EintrĂ€ge (bad_password, bad_code, blocked_ip).

v3.2.1 — 2026-10-02

Patch: Dubletten in der CLI melden einen Satz statt einer PDOException. Kein Schema-Wechsel.

  • Fix: group add und user add mit vorhandenem Slug bzw. vorhandener E-Mail (bin/user_cli.php): Vorab-PrĂŒfung, Exit 1, „
 gibt es schon 
“; bei user add vor der Passwortabfrage.
    ZusĂ€tzlich wird SQLSTATE 23000 aus dem INSERT (Wettlauf) abgefangen. Die WeboberflĂ€che (users.php) prĂŒfte schon vorab. tests/test_auth.sh 76 (+3, Gegenprobe: mit dem alten
    user_cli.php scheitern die zwei MeldungsprĂŒfungen), docker_vm/tests/p3.sh +2. Nicht einzeln belegt: die catch-Zweige fĂŒr den Wettlauf.

  • Fix: cred add mit vorhandenem Label zeigt einen Satz statt der PDOException. CredentialStore::add() prĂŒft vorab und fĂ€ngt zusĂ€tzlich SQLSTATE 23000 aus dem INSERT (Wettlauf);
    die Meldung nennt den Weg (replace bzw. „Zugangsdaten ersetzen“) und gilt fĂŒr CLI und Web (credentials.php zeigte vorher den rohen SQL-Text). tests/test_credentials.sh 62 (+3),
    docker_vm/tests/p3.sh 46 (+1, echter Aufruf im Container). Nicht einzeln belegt: der catch-Zweig fĂŒr den Wettlauf (die Gegenprobe zeigt nur, dass ohne VorprĂŒfung die DB-Meldung kĂ€me). Offen bleibt group add mit vorhandenem Slug.
    Auch in native_php/.

v3.2.0 — 2026-10-02

Feature: Deploy-ZIP mit ssl_outbox; Fix: neuer aktiver SOAP-Zugang in der VM. Kein Schema-Wechsel.

  • Fix (docker_vm): neuer aktiver SOAP-Zugang liess sich in der VM nicht anlegen. instance.sh <name> fake schrieb env[TLSGATE_SOAP_HOSTS] = soap.fake.test in den Pool;
    CredentialStore::check() lehnte damit jede andere WSDL ab. Der Modus fake ist entfernt (instance.sh kennt nur noch mock), ebenso die fest eingetragenen
    Fake-Zugangsdaten in der .profile der Instanz. TLSGATE_SOAP_HOSTS bleibt eine bewusste, manuelle Betriebsoption. Der Fake-Registrar im Container bleibt als gewoehnliches
    Test-Ziel; p3.sh legt seinen Zugang selbst an und belegt, dass ein Zugang mit fremdem WSDL-Host angelegt werden kann (45 Pruefungen). p4.sh reicht die Fake-Zugangsdaten
    nur fuer den dashboard.sh-Aufruf mit. Bestehende Instanzen verlieren die Einschraenkung beim naechsten instance.sh <name>.
  • Neu: Deploy-ZIP. In der Detailansicht (getOrder) der Knopf „Deploy-ZIP“: public/outbox.php holt sslInfo mit dem Zugang der eigenen Gruppe und liefert
    <domain>.zip mit <domain>/<domain>.cert und <domain>/<domain>.chain.pem (dieselben Dateien wie dashboard.sh deploy, native_bash_cli). Nie ein Key: TLS-Gate besitzt keinen,
    ein PEM mit „PRIVATE KEY“ bricht ab. Dieselben Bytes liegen in der ssl_outbox (Config::outboxDir(), Standard <state>/ssl_outbox/<gruppe>/<domain>.zip, Ordner 0700, Datei 0640,
    umlenkbar mit TLSGATE_OUTBOX_DIR) zum Einsammeln durch die Orchestrierung. Ab Rolle user, CSRF, Audit ssl.deployZip (requested/ok/fail, ok mit SHA-256). ZIP ohne PHP-Erweiterung
    (src/Export/CertBundle.php, Methode „stored“). tests/test_outbox.sh (27 Pruefungen, offline) und docker_vm/tests/p6.sh (22 Pruefungen gegen die Instanz). Auch in native_php/.
    Nicht belegt: ein Zertifikat einer echten CA (nur Fake-PEM), die Weiterverteilung aus der Outbox.
  • Doku (docker_vm/HANDBUCH.md): §3a SOAP-Zugang anlegen, §3b Deploy-ZIP und Outbox, §3c der Weg vom Nullpunkt (down -v). Fix: der ssh-Ablauf (§5) liess install -d /root/.provision/ssh aus; nach down -v scheiterte docker cp des Schluessels mit „Could not find the file“, und instance.sh ozm01 fake gibt es nicht mehr.
  • Referenz-Stand (frisch nach down -v): run.sh 52, p3.sh 45, p4.sh 54, p5.sh 31, p6.sh 22.

v3.1.0 — 2026-10-01

Feature: Zwei-Faktor erneuern, angemeldet. Kein Schema-Wechsel.

  • Neu: public/mfa.php („2FA“ im Kopf der Konsole). Wer angemeldet ist, kann sein TOTP selbst ersetzen: aktuelles Passwort bestĂ€tigen, neuer QR-Code
    (mit SchlĂŒssel und otpauth://-URI), erst der erste gĂŒltige Code des neuen Secrets tauscht es in der DB. Bis dahin gilt das alte (kein Aussperren bei
    misslungenem Scan); „Abbrechen“ Ă€ndert nichts. Das neue Secret liegt bis zur BestĂ€tigung versiegelt in der Session (5 min), nicht in der DB; ein falsches Passwort
    zĂ€hlt fĂŒr die Kontosperre; fĂŒnf falsche Codes verwerfen den Vorgang. Jede Rolle darf ihr eigenes TOTP erneuern. Audit: auth.mfa_rotate_begin, auth.mfa_rotate
    (ok/fail), nie das Secret. Auth::mfaRotationBegin/Confirm/Cancel/Pending/Data. Vorher gab es nur user reset-mfa per CLI oder die Einrichtung beim allerersten Login.
  • tests/test_mfa.sh (24 PrĂŒfungen): Passwortzwang, QR, altes Secret gilt bis zur BestĂ€tigung, danach nur das neue, Abbruch, Audit ohne Secret, CSRF. LĂ€uft lokal (eigene SQLite-DB), nicht in docker_vm/.
  • Fix: docker_vm/tests/p4.sh war locale-abhĂ€ngig. Das Muster ^G.ltig erwartet, dass . das ĂŒ trifft; unter LANG=C sind es zwei Bytes. Jetzt ^G.{1,2}ltig. Die Ausgabe von dashboard.sh war nie falsch.
  • Referenz-Stand (frisch gebaute VM nach down -v): run.sh 52, p3.sh 43, p4.sh 54, p5.sh 31 PrĂŒfungen grĂŒn.
  • .codebase-memory/graph.db.zst wird nicht mehr getrackt (abgeleiteter Index, in .gitignore).

v3.0.4 — 2026-10-01

Patch: Der 500 in der Detailansicht ist jetzt mit einem Fake-Registrar nachgestellt und an zwei weiteren Stellen behoben (Strukturen in Textfeldern, fehlendes php-mbstring).
Kein Schema-Wechsel.

  • Fix: Struktur in einem Textfeld gab weiter einen 500 (templates/ssl/*): 21 Stellen castelten mit (string) vor ->e() und umgingen damit den Fix aus v3.0.2
    (stdClass in domain, status, reseller, EigentĂŒmerdaten, comodostatus). Die Casts sind entfernt; e() liest Listen und Objekte selbst. tests/test_view.sh (22 PrĂŒfungen) verbietet ->e((string)
).
  • Fix: fehlendes php-mbstring ließ die Detailansicht mit einem fatalen Fehler ausfallen (nicht nur den Fehlerpfad: SslController ruft mb_substr im Normalfall). bootstrap.php
    meldet fehlendes mbstring/sodium jetzt sofort mit HTTP 503 und dem Paketnamen (auch im Log, CLI: Exit 1); composer.json verlangt ext-mbstring; TLS-doktor.bash prĂŒft es.
  • Fix: TLS-doktor.bash brach unter GNU/Linux ab und kannte nur SQLite. perm()/owner()/fgrp() riefen zuerst BSD-stat -f, das unter GNU „gelingt“ (Dateisystem-Info statt Rechte; Abbruch unbound variable). Jetzt Weiche per stat --version. Neu: Treiber TLSGATE_DB_DRIVER=sqlite|mysql (bei mysql pdo_mysql Pflicht, Schema und Version ĂŒber Db::pdo()/Migrator, keine DB-Datei-PrĂŒfungen; SQLite bleibt der Offline-Dev-Standard), WEB_USER fĂŒr den php-fpm-Pool-Benutzer, LesbarkeitsprĂŒfung als root per runuser. In bin/ und native_php/bin/. docker_vm/tests/p5.sh (31 PrĂŒfungen: Sollstand, root.key 0644, falsches DB-Passwort, MariaDB gestoppt, state/-Besitz, Guard aus, Pool-Datei). Neu: Pool-Check — der Doktor liest per php-fpm -tt den Pool des Webserver-Benutzers und prĂŒft disable_functions (AUD-33), clear_env, open_basedir, display_errors.
  • Fix: dashboard.sh zeigte gegen Live-SOAP rohe Unix-Zeitstempel (ohne Resttage und Farbe): days_left() verstand nur ISO, die API liefert Unix-Sekunden (0 = kein Datum). Neu to_epoch/fmt_ts (GNU und BSD date), eingesetzt in Liste, Details und Cross-Check, in bin/dashboard.sh und native_bash_cli/. tests/test_dates.sh (16 PrĂŒfungen).
  • docker_vm/ P4: ozm02, ssh nur mit SchlĂŒssel (Port 2222, Instanz-Shell mit dashboard.sh gegen den Fake-Registrar), tests/p4.sh (52 PrĂŒfungen: Isolation, Guard, getrennte Logs, Reseller-Sperre, ssh). Bekannt, nicht behoben: dashboard.sh zeigt gegen Live-SOAP rohe Unix-Zeitstempel (siehe docker_vm/PLAN.md 5c).
  • docker_vm/: Fake-Registrar (fake-soap/: WSDL, Szenarien je Order-Nummer, Fake-CA, nur im Container) und tests/p3.sh (43 PrĂŒfungen). Reproduziert den 500 der Node mit dem alten View.php.

v3.0.3 — 2026-10-01

Patch fĂŒr Installationen mit MariaDB/MySQL (TLSGATE_DB_DRIVER=mysql): Fehlversuche beim Anmelden lieferten einen 500. Mit SQLite (Standard) war nichts betroffen.
Kein Schema-Wechsel.

  • Fix: HTTP 500 bei jedem Fehlversuch unter MariaDB/MySQL (Auth::accountFail()): UPDATE 
 RETURNING kennt nur SQLite. Bei TLSGATE_DB_DRIVER=mysql
    warf jeder Fehlversuch (falsches Passwort, falscher TOTP-Code) bei einem bestehenden Konto einen PDOException und damit einen 500; unbekannte Konten kamen durch.
    Jetzt UPDATE, dann SELECT (portabel). Neu tests/test_sql_portable.sh (statische PrĂŒfung auf SQLite-only-SQL). Gefunden mit docker_vm/tests/run.sh.
  • docker_vm/: P1 (provision/instance.sh, idempotent) und P2 (tests/run.sh, 52 PrĂŒfungen: Web-Login mit QR/MFA, Konsole mit Mockdaten, Seiten, Logs, Bash-Dashboard).

v3.0.2 — 2026-10-01

Patch nach dem RZ-Test von v3.0.1: Der 500er in der Detailansicht blieb, weil die Ursache ein anderer Wert war als in 3.0.1 behoben (SOAP-Objekte, nicht Listen).
Dazu die seit 3.0.1 aufgelaufenen Änderungen. Kein Schema-Wechsel.

  • Fix: 500 in der Detailansicht durch SOAP-Objekte (Http\View::e(), copyDt()): v3.0.1 deckte Listen ab, aber SOAP liefert Strukturfelder
    (z. B. validationDNS) auch als stdClass. (string) $objekt wirft in PHP 8 Error: Object of class stdClass could not be converted to string,
    und copyDt() hatte das <dt> schon ausgegeben — die Seite brach mitten im Satz ab (RZ-Fund 1.10., Gegenprobe: die drei echo in copyDt auskommentiert).
    Jetzt werden Listen und Objekte gleich flach gelesen (Werte kommagetrennt, __toString als Text, Tiefengrenze 8); ein Objekt in einer Liste ging vorher
    stillschweigend verloren. copyDt() berechnet zuerst und gibt erst dann aus und ĂŒberspringt, was nach dem Abflachen leer ist. tests/test_view.sh 21 PrĂŒfungen.
  • bin/dashboard-ro.sh + bin/ro_cli.php: rein lesender Abruf (list, info, info --file, raw). Zugang aus TLSGATE_RO_WSDL/_USER/_PASS,
    keine DB, kein Guard; nur sslListExtended und sslInfo (mit getCSR=1). info wertet CSR (Selbstsignatur, Key, Hash),
    Zertifikat (Restlaufzeit, CSR↔Zertifikat-Key) und Chain (Zertifikate, Root-Warnung, openssl verify) aus. Das CSR-Alter ist aus SOAP nicht
    ableitbar und wird nur als Untergrenze (seit notValidBefore) genannt. Bewusste Ausnahme zur Regel „Zugang nie aus der Umgebung“, Grenzen in
    docs/SECURITY.md §1. Jeder Aufruf (auch abgelehnt) hÀngt eine Zeile an ~/.local/state/tlsgate/ro.log
    (0600, TLSGATE_RO_LOG=<datei>|off), ohne Zugangsdaten. tests/test_dashboard_ro.sh (41 PrĂŒfungen, offline mit selbst erzeugter CA).
  • Neu im Repo, ohne Einfluss auf den Lauf: native_bash_cli/ (unverĂ€nderter Stand 3dec8a8 vom 24.9., vor Login/DB) und native_php/ (nur, was das Frontend nutzt;
    ohne Bestellschicht, Export und Node-CA) als Vorlagen fĂŒr eigene Repos, docker_vm/PLAN.md (RZ-Setup im Container, noch nicht gebaut). Nichts im SOT wurde gelöscht.
  • docs/AUDIT.md: doppelte BWA-AUD-30 bereinigt — der Guard-Besitzerweg ist jetzt BWA-AUD-33, neu BWA-AUD-32 (Ausnahme zu AUD-25 fĂŒr dashboard-ro.sh).
  • CLI-Guard: Besitzer der Instanz (Guard::instanceOwner): Neben root/sudo darf die bin/-Werkzeuge ausfĂŒhren, wem alles Vorhandene aus
    State-Verzeichnis, SQLite-DB und WurzelschlĂŒsseldatei gehört (mindestens eines muss existieren). FĂŒr den RZ-Betrieb, in dem jeder User eigene
    Dienste hat und es kein root gibt. Gemischter Besitz, fehlende Dateien, Web-SAPI und der Dienst-User (nur Klasse service) bleiben draußen.
    Akteur im Audit ist der echte User, SUDO_USER zÀhlt ohne euid 0 weiterhin nie. Grenze steht in docs/DESIGN_TENANTS.md §5
    (gehört dem Web-User alles, ist er auch CLI-Operator). Exit-Codes unverÀndert. tests/test_guard.sh um die Besitzer-FÀlle erweitert.

v3.0.1 — 2026-09-30 · Tag auf ad07090; RZ-Test: 500er in der Detailansicht blieb, behoben in v3.0.2

Patch nach dem ersten Lauf auf einer Node mit Apache + PHP-FPM (RĂŒckmeldung vom 30.9.). Kein Schema-Wechsel, kein Guard-Umbau.

  • Fix View::e() / copyDt(): SOAP-Felder, die als Liste kommen (z. B. mehrere Validierungswerte), wurden als „Array“ mit PHP-Warning
    ausgegeben. Jetzt kommagetrennt, leere Listen entfallen. Mutmaßliche Ursache des 500 auf der Details-Seite im RZ (lokal nur die Warnung
    reproduziert, der 500 selbst nicht bestÀtigt; bei Wiederholung die Zeile aus dem FPM-Log nachreichen). Neu: tests/test_view.sh.
  • Doktor: warnt, wenn root.key an mehreren ĂŒblichen Orten liegt (/etc/tlsgate, /var/lib/tlsgate, state/), und prĂŒft, ob der Web-User
    ihn lesen kann (nur mit passwortlosem sudo). Der Behebungstext empfahl sudo -u <WEBUSER> 
 migrate, das der Guard ablehnt: jetzt sudo 
 migrate, danach chown.
  • Doku: deploy/apache-vhost.conf.example sagt dasselbe wie deploy/README.md (Befund #9 der RĂŒckmeldung).
  • Bewusst nicht in diesem Patch: Guard fĂŒr den Besitzer der Instanz (siehe Unreleased); der CLI-Weg im RZ war hier noch sudo + chown.
  • Nicht belegt: der Lauf im RZ selbst; der Zweig „mehrere root.key“ und der Lesetest des Doktors sind nur als Syntax-/Lauftest auf einem Mac gesehen.

  • Demo-Setup resources/demo/digdafunk.sh [domain] [--reset]: baut eine eigene Instanz unter state-demo/ (gitignored, fasst state/ nicht an).
    Mensch mit SchlĂŒssel + CSR, Node-CA, zwei Bestellungen, Tick-LĂ€ufe, CRL, Export mit echter Außensicht, sample.html daneben.
    Die Validierung der ersten Bestellung kommt aus einer DNS-Fixture (Herkunft fixture), die zweite bleibt beim echten DNS stehen.

  • order_tick.php --node-ca --dns-fixture <datei>: CNAMEs aus einer Datei statt echtem DNS, nur mit TLSGATE_MOCK=1, Herkunft fixture.
    Getestet in tests/test_export.sh (Ablehnung ohne Mock-Schalter und ohne --node-ca).

v3.0.0 — 2026-09-29 · Alle sieben Schritte des Mandanten-Entwurfs

Mit v3.0.0 ist docs/DESIGN_TENANTS.md §9 vollstĂ€ndig umgesetzt: WurzelschlĂŒssel, SOAP-ZugĂ€nge je Gruppe, CLI-Guard,
Bestellzustand mit Herkunft, order_tick, Export/Monitor und die Verwaltung der ZugÀnge im Browser. Dazu Node-CA mit CRL,
Sensor-IdentitÀt per mTLS und der Migrator.

GegenĂŒber v2.2.0 neu fĂŒr Betrieb und Crew:
- group_admin pflegt die SOAP-ZugÀnge der eigenen Gruppe selbst im Browser (SOAP-ZugÀnge), admin die aller Gruppen.
Die CLI (user_cli.php cred 
) bleibt gleichwertig.
- Kein Schema-Wechsel gegenĂŒber v2.2.0. Update: migrate --status sollte „nichts zu tun“ melden.

Weiterhin nicht belegt: ein Lauf gegen den echten Registrar (cred test / „Live testen“), der Scan des TOTP-QR mit einer
Authenticator-App, OCSP und automatische CRL-Verteilung, automatischer Tabellenumbau unter MySQL.

  • SOAP-ZugĂ€nge im Web (Schritt 7 aus docs/DESIGN_TENANTS.md): public/credentials.php, Link in der SSL-Konsole fĂŒr admin und group_admin.
  • group_admin verwaltet nur die ZugĂ€nge der eigenen Gruppe, admin alle. GeprĂŒft je Aktion am Datensatz: Eine gefĂ€lschte cid einer fremden
    Gruppe ergibt „Unbekannter Zugang“ (verrĂ€t die Existenz nicht) und steht als denied im Audit. Eine gefĂ€lschte group_id beim Anlegen landet
    in der eigenen Gruppe. Gegenversuch ohne diese PrĂŒfung: 4 rote PrĂŒfungen.
  • Anlegen, Zugangsdaten ersetzen, an/aus, „Live testen“: sslListExtended lesend, zeigt Anzahl und die sichtbaren Zertifikate (erste 50),
    gekennzeichnet als „Live SOAP“ mit lokaler Zeit. Bei Störung „SOAP nicht verfĂŒgbar 
 keine Beispieldaten“.
  • Benutzer und Passwort werden nie zurĂŒck ins HTML, ins Audit oder in Meldungen geschrieben. Strikte CSP, kein Inline-JS, CSRF auf jeder Aktion.
  • Tests: tests/test_credentials_ui.sh (35) ĂŒber echte HTTP-Anfragen mit Login (Passwort + TOTP) je Rolle. Die Anzeige eines erfolgreichen
    Live-Tests ist am Template mit einem synthetischen Ergebnis geprĂŒft (inkl. Escaping einer Domain aus der SOAP-Antwort). Gegen einen echten
    Registrar ist die Seite nicht gelaufen.

v2.2.0 — 2026-09-29 · Mandanten-Kern: SchlĂŒssel, ZugĂ€nge, Bestellzustand, Node-CA, Monitor

Breaking gegenĂŒber v2.1.0:
- SOAP-ZugĂ€nge liegen je Gruppe verschlĂŒsselt in der DB (user_cli.php cred add). BWA_SOAP_* und Config::soap() sind entfernt.
- Kein stiller Mock mehr: ohne Zugang oder bei Störung „SOAP nicht verfĂŒgbar“, Beispieldaten nur mit TLSGATE_MOCK=1.
- Die bin/-Werkzeuge verlangen root/sudo (Guard, Exit 3); Dev/Tests: TLSGATE_GUARD=off. Deploy: migrate als root, danach chown.
- TOTP-Secrets im neuen Format (tg1:), Umzug per user_cli.php key migrate.

Update von v2.1.0: php bin/user_cli.php migrate --dry-run, dann migrate (Sicherung liegt daneben), key migrate, cred add + cred test,
danach BWA_SOAP_* aus Pool/vhost entfernen und bin/TLS-doktor.bash grĂŒn ziehen.

Einzelheiten, in der Reihenfolge der Entstehung:

  • MySQL/MariaDB-Connector neben SQLite (TLSGATE_DB_DRIVER=sqlite|mysql, database/schema.mysql.sql).
  • TOTP per QR: TlsGate\Auth\Qr ist ein QR-Encoder ohne AbhĂ€ngigkeit (Byte-Modus, Fehlerkorrektur M,
    bis 213 Byte). Das Secret verlÀsst den Server nie, es gibt keinen externen QR-Dienst.
  • php bin/user_cli.php user totp-setup <email> [--watch]: QR im Terminal (schwarz auf weiß, theme-unabhĂ€ngig),
    Live-Countdown und Prompt. TOTP wird erst nach dem ersten gĂŒltigen Code aktiv, nach 5 Fehlversuchen
    oder 5 Minuten bricht es ab. Gesperrte Konten bleiben gesperrt, das Secret landet nicht im Audit-Log.
    --watch zeigt zusĂ€tzlich den erwarteten Code, nur fĂŒr Dev/Mockup.
  • Browser-Enrollment (public/login.php) zeigt den QR als Inline-SVG ĂŒber dem manuellen SchlĂŒssel.
  • Tests: tests/test_qr.sh (28). GeprĂŒft wird gegen die Norm-Struktur (Finder, Timing, Formatinfo mit BCH)
    und, falls python3 + OpenCV da sind, im Round-Trip mit einem unabhÀngigen Decoder (91 Texte von 1 bis 213 Byte).
    Der Àltere OpenCV-Decoder liest einzelne Version-10-Codes nicht, der Aruco-Decoder liest alle.

  • WurzelschlĂŒssel + KDF (TlsGate\Security\Secrets, Schritt 1 aus docs/DESIGN_TENANTS.md): ein SchlĂŒssel
    (TLSGATE_ROOT_KEY oder TLSGATE_ROOT_KEY_FILE, Standard state/root.key, 0400), getrennte UnterschlĂŒssel je Zweck,
    XChaCha20-Poly1305 mit Bindung an den Datensatz. Blob tg1: + base64(version | key_id | nonce | ct).

  • TOTP-Secrets nutzen ihn, gebunden an die User-ID. Totp::encrypt/decrypt bekommen dafĂŒr die User-ID.
  • user_cli.php key status | rotate | migrate. Altformat-Blobs (secretbox, tlsgate.key) bleiben lesbar, bis key migrate lief.
  • migrate legt root.key an, aber keine tlsgate.key mehr. Die App verweigert eine fĂŒr alle lesbare Wurzeldatei.
  • Doktor prĂŒft WurzelschlĂŒssel und warnt, wenn er neben der DB liegt. Deploy-Vorlagen und Doku nutzen root.key.
  • Tests: tests/test_secrets.sh (51).
  • Umstellung bestehender Installationen: php bin/user_cli.php migrate (legt root.key an), dann key migrate
    mit noch gesetztem TLSGATE_SECRET_KEY/TLSGATE_KEY_FILE. Danach kann der alte SchlĂŒssel weg.

  • SOAP-ZugĂ€nge je Gruppe in der DB (Schritt 2 aus docs/DESIGN_TENANTS.md): Tabelle soap_credentials
    (database/schema*.sql, idempotent per migrate), Security\CredentialStore, Wertobjekt Security\SoapCredential.
    Blob mit dem WurzelschlĂŒssel versiegelt (Zweck soap), gebunden an Gruppe + Label. Wird bei key rotate/key migrate mitgenommen.

  • user_cli.php cred add | list | replace | enable | disable | test. Benutzer/Passwort werden abgefragt, nie als Argument.
    WSDL nur https:// ohne eingebettete Zugangsdaten, optional eingeschrÀnkt durch TLSGATE_SOAP_HOSTS.
  • Breaking: Config::soap() und BWA_SOAP_USER/PASS/WSDL sind entfernt. Web: Zugang der Gruppe des angemeldeten Users.
    CLI/Bash: TLSGATE_GROUP=<slug> (optional TLSGATE_CRED=<label>), z. B. TLSGATE_GROUP=bwa bin/dashboard.sh list.
  • Breaking: Kein stiller Mock mehr. Ohne Zugang oder bei gestörter Verbindung zeigt die UI „SOAP nicht verfĂŒgbar“ mit Grund
    (source: unavailable), soap_cli.php endet mit Exit 1. Beispieldaten nur mit TLSGATE_MOCK=1. Das gilt auch fĂŒr
    AuthCode und Besitzerdaten (vorher erfundene Werte). SslHandler bekommt den Zugang als Konstruktorargument.
  • Tests: tests/test_credentials.sh (59), neuer Check in tests/test_auth.sh; tests/test_smoke.sh setzt TLSGATE_MOCK=1 ausdrĂŒcklich.
  • Umstellung: php bin/user_cli.php migrate (legt die Tabelle an), cred add, cred test. Danach BWA_SOAP_* aus Pool/vhost entfernen.
  • Nicht belegt: Der Live-Aufruf gegen den echten Registrar. Getestet ist die Verdrahtung bis zum SoapClient (gegen einen toten Host).

  • CLI-Guard (Security\Guard, Schritt 3 aus docs/DESIGN_TENANTS.md): bin/user_cli.php und bin/soap_cli.php verlangen root
    oder sudo (Exit 3, es wird nichts geschrieben). Bei sudo muss SUDO_USER in einer Operator-Gruppe sein
    (sudo, wheel, admin; TLSGATE_OPERATOR_GROUPS). SUDO_USER allein reicht nie. Der Web-Prozess kommt nie durch.

  • Breaking: Diese Werkzeuge (und damit dashboard.sh) brauchen sudo. Dev/Tests: TLSGATE_GUARD=off (nur exakt off).
    Das steht im Audit ("guard":"off"), der Doktor warnt, und am Terminal erscheint eine Warnzeile (nicht bei umgeleitetem stderr, weil dashboard.sh es mit dem JSON auswertet).
  • Jeder CLI-Audit-Eintrag trĂ€gt den Akteur ("actor":"cli:alice"); cred_add schreibt ihn nach created_by.
  • Deploy: migrate, group add, user add, cred add laufen jetzt als root/sudo, nicht mehr als sudo -u <WEBUSER> (das wird abgelehnt),
    danach chown -R <WEBUSER> /var/lib/tlsgate (deploy/README.md, README.md). Nicht auf einem Linux-Host durchgespielt.
  • Klasse service (Dienst-User, TLSGATE_SERVICE_USER) fĂŒr einen spĂ€teren order tick vorbereitet, noch ohne Verbraucher.
  • GeĂ€ndert: soap_cli.php meldet „SOAP nicht verfĂŒgbar“ mit Exit 1 (Schritt 2 hatte Exit 3). Exit 3 gehört jetzt dem Guard.
  • Doktor prĂŒft die PHP-Erweiterung posix und warnt bei TLSGATE_GUARD=off.
  • Tests: tests/test_guard.sh (40: Entscheidungsmatrix, echte Skripte als normaler User). Nicht belegt: ein Lauf als root/sudo
    (sudo bash tests/test_guard.sh fĂŒhrt den root-Zweig aus). Alle anderen Tests setzen TLSGATE_GUARD=off ausdrĂŒcklich.

  • Inventar und Bestellzustand (Schritt 4 aus docs/DESIGN_TENANTS.md): Tabellen assets, cert_orders, order_events
    (database/schema*.sql, idempotent per migrate), Klassen TlsGate\Orders\{OrderStore, AssetStore, OrderState, EvidenceKind},
    neue CLI bin/order_cli.php (asset add|list, order new|list|show|move|cancel, states). Nur root/sudo (Guard).

  • Zustandsautomat als Vertrag: database/order_states.json (ZustĂ€nde, ÜbergĂ€nge, EndzustĂ€nde, Herkunftsregeln, Exit-Codes) wird von
    OrderState gelesen und ist sprachneutral fĂŒr den Rust-Kern gedacht. Enum und Datei sind per Test deckungsgleich.
    csr_submitted → awaiting_dns → validated → issued, dazu failed/cancelled aus jedem Nicht-Endzustand. Kein ZurĂŒck, kein Überspringen.
  • Herkunft je Ereignis (self_reported, registrar_reported, dns_observed), nie umgerechnet. Regel ĂŒber den Entwurf hinaus:
    validated verlangt registrar_reported oder dns_observed, eine Selbstmeldung reicht nicht. issued verlangt ein Zertifikat,
    das zur CSR passt (gleicher öffentlicher SchlĂŒssel); Fingerabdruck und Laufzeit werden aus dem Zertifikat gelesen, nicht ĂŒbernommen.
  • Kein privater SchlĂŒssel, nirgends: Eingaben mit PRIVATE KEY werden verworfen, es gibt keine Spalte dafĂŒr, ein Test sucht in
    database/*.sql und in der angelegten DB nach Spaltennamen mit priv/key_pem. RSA unter 2048 Bit wird abgelehnt (EC geht).
  • NebenlĂ€ufigkeit: Jeder Wechsel lĂ€uft in einer Transaktion mit bedingtem UPDATE 
 WHERE state = <gelesen>. SQLite: BEGIN IMMEDIATE
    (mit normalem BEGIN bekam der Verlierer „database is locked“ mit Exit 1; per Gegenversuch belegt). MariaDB/MySQL: SELECT 
 FOR UPDATE
    auf der Bestellzeile, zusĂ€tzlich werden Fehler 1020 (Snapshot-Isolation, MariaDB ≄ 11.6) und 1213 (Deadlock) als Konflikt gemeldet.
    Ergebnis in beiden Datenbanken: Bei gleichzeitigem, gleichem Wechsel gewinnt genau einer, der andere bekommt Exit 4.
  • Mandantengrenze: OrderStore fragt immer mit group_id; eine fremde Bestellung ist „unbekannt“, nicht „verboten“. null (alle Gruppen) nur fĂŒr die CLI.
  • order_events ist append-only (Trigger in SQLite und MariaDB, in beiden geprĂŒft). ts_local ist die lokale Uhr, keine extern bestĂ€tigte Zeit.
  • Exit-Codes der CLI: 0 ok · 1 fachlicher Fehler · 2 Nutzung · 3 keine Berechtigung · 4 Zustand passt nicht.
  • Tests: tests/test_orders.sh (115). ZusĂ€tzlich von Hand gegen MariaDB 11 (tlsgate-my, Scratch-DB): Schema zweimal angewendet
    (inkl. soap_credentials aus Schritt 2, bisher nur geschrieben), kompletter Ablauf, 10 WettlÀufe, Trigger.
  • Nicht dabei: kein SOAP-Aufruf (Bestellung beim Registrar, Statusabfrage: kommt mit order tick), keine UI, keine DNS-Auflösung.
    --evidence registrar_reported per CLI ist die Angabe des Operators, nicht von TLS-Gate geprĂŒft (die CLI sagt das und das Ereignis nennt den Akteur).
    Die MariaDB-LĂ€ufe stehen nicht in der Test-Suite (brauchen einen Container).

  • order_tick fĂŒr den Cron (Schritt 5 aus docs/DESIGN_TENANTS.md): bin/order_tick.php, TlsGate\Orders\Tick\*.
    Je offener Bestellung höchstens ein Schritt, nur lesende Abfragen beim Registrar (sslInfo) und im DNS (CNAME).

  • Bestellt nie. Einreichen beim Registrar kostet Geld und bleibt ein Operator-Schritt. Im Portal bestellte AuftrĂ€ge werden
    gepollt, sobald die Referenz eingetragen ist: neuer Befehl order_cli.php order ref <id> <registrar_ref> (ĂŒberschreibt nie).
  • Ports mit zwei Varianten: SoapRegistrar/SystemDns (live) und FixtureRegistrar/FixtureDns (Dummy- oder aufgezeichnete
    Daten). Die Abbildung einer sslInfo-Antwort steht an einer Stelle (RegistrarStatus::fromSslInfo), Unbekanntes bleibt „pending“.
  • Neue Herkunft fixture (database/order_states.json → Version 2). Beim ersten Demo-Lauf trugen Fixture-Aussagen
    registrar_reported/dns_observed: eine stille Aufwertung von Dummy-Daten. Jetzt nennt jeder Port seine Herkunft selbst, und
    --fixtures verlangt TLSGATE_MOCK=1. Per Gegenversuch belegt, dass der Test rot wird, wenn ein Fixture-Port wieder registrar_reported meldet.
  • Frische in neuer Tabelle order_checks (letzte PrĂŒfung, Ergebnis, seit wann gleich). Ein Ereignis in order_events gibt es nur bei
    neuer Information oder einem Wechsel: 5 gleiche LĂ€ufe erzeugen kein Ereignis. Das ist eine eigene Speicherentscheidung, keine Folge von „Wiederholung ≠ BestĂ€tigung“.
  • annotate: neue Information ohne Zustandswechsel (Registrar nennt die Challenge) als Info-Ereignis mit from = to. Gespeicherte Werte werden nie ĂŒberschrieben.
  • Fehler einer Bestellung (error) oder ein Konflikt (z. B. Zertifikat passt nicht zur CSR) stoppen den Lauf nicht und bewegen nichts.
    flock gegen ĂŒberlappende LĂ€ufe auf einem Host, zwischen Nodes schĂŒtzt das bedingte UPDATE je Bestellung. --dry-run schreibt nichts.
  • Guard-Klasse service hat jetzt ihren ersten Verbraucher: Der Dienst-User (TLSGATE_SERVICE_USER) darf order_tick, aber nicht order_cli. Scharf getestet.
  • Dummy-Fixtures unter resources/fixtures/tick/ (nur example.org/example.net, kein privater SchlĂŒssel) mit README zum Austauschen.
    Neuer Befehl soap_cli.php record <orderNumber> gibt die rohe sslInfo-Antwort in genau dieser Form aus.
  • Fix: Der Doktor erwartete genau die vier Tabellen von v2.1 und meldete deshalb seit soap_credentials fĂ€lschlich „Schema unvollstĂ€ndig“.
    Jetzt prĂŒft er, dass alle erwarteten Tabellen da sind, und warnt, wenn order_events noch die alte CHECK-Regel ohne fixture hat.
  • Dev-DBs aus Schritt 4: CREATE TABLE IF NOT EXISTS Ă€ndert die CHECK-Regel von order_events nicht. order_tick --fixtures bricht dort sichtbar ab
    (Transaktion zurĂŒckgerollt, kein Wechsel). Abhilfe: migrate (Migrator, siehe oben).
  • Tests: tests/test_tick.sh (71). Nicht belegt: ein Lauf gegen den echten Registrar. Offen sind die echte Form von validationDNS
    und die Werte von status. Der Live-Pfad ist mit einem Test-Port nachgestellt, SoapRegistrar nur gegen einen toten Host geprĂŒft.

  • Export, Monitor und Node-CA (Schritt 6 aus docs/DESIGN_TENANTS.md). Durchgespielt mit digdafunk.de: docs/LAUF_NODE_CA.md.

  • bin/export.php (TlsGate\Export\MonitorExport) schreibt den Vertrag, den resources/mock/sample.html liest, plus Herkunft je
    Zertifikat (source live | node_ca | fixture, Herkunftskette, public_trust, letzte PrĂŒfung). Ohne soap_username, api_endpoint,
    Zugangsdaten, CSR oder SchlĂŒssel. Atomar (Temp-Datei, fsync, rename, 0644). --group oder ausdrĂŒcklich --all, --seal-to versiegelt
    fĂŒr einen EmpfĂ€nger (sodium_crypto_box_seal). Guard-Klasse service: lĂ€uft auch im Cron des Dienst-Users.
  • --probe (TlsGate\Export\TlsProbe): Außensicht je Bestellung, von dieser Node aus: served_match, served_other, no_tls, unreachable.
  • sample.html wird zum Monitor: ?data=<datei>.json lĂ€dt einen Export. Ein Banner nennt Quelle, Zeit und Standort. Zwei neue
    Spalten: Herkunft und Außensicht. Fix XSS: Die Tabelle wurde per innerHTML aus den Daten gebaut, ein Common Name wie
    <img onerror=
> aus einer fremden CSR wÀre als Skript gelaufen. Jetzt nur textContent, im Browser belegt (headless Chrome).
    Fix stiller Mock: Scheiterte fetch, zeigte die Seite drei erfundene Zertifikate. Im Monitor-Modus steht jetzt „KEINE DATEN“,
    nur die Sandbox ohne ?data= zeigt Beispiele, laut als MOCK gekennzeichnet. Ebenso die Produktliste: ohne Produkte im Export stand dort fest le-free-01/sec-dv-03, jetzt „keine Produktliste in dieser Quelle“. .. im data-Pfad wird abgelehnt.
  • Node-CA (bin/node_ca.php, TlsGate\Orders\Tick\NodeCa): eigene Test-CA der Node (EC P-256), nicht öffentlich vertrauenswĂŒrdig.
    SchlĂŒssel versiegelt mit dem WurzelschlĂŒssel (neuer Zweck ca), gebunden an den Fingerabdruck von ca.crt. submit nennt den CNAME,
    order_tick --node-ca stellt erst aus, wenn TLS-Gate ihn selbst gesehen hat. Neue Herkunft node_ca (Vertrag v3); sie reicht fĂŒr
    validated nicht, die Node-CA validiert also nie sich selbst. Blatt: 90 Tage, SAN aus der Bestellung, CA:FALSE, serverAuth/clientAuth.
  • Ports haben jetzt handles(): Ein Node-CA-Lauf fasst Registrar-Bestellungen nicht an und umgekehrt.
  • Beobachtung digdafunk.de (von einem Mac, 29.09.): :443 offen, TLS-Handshake mit Alert 40 abgelehnt, kein Zertifikat; :80 Apache 200.
    Der echte Lauf endet richtig bei awaiting_dns (CNAME nicht gesetzt). Auf einer Kopie mit DNS-Fixture bis issued gespielt:
    openssl verify gegen die Node-CA OK, Außensicht weiter no_tls. Ausgestellt ≠ ausgeliefert.
  • Tests: tests/test_export.sh (71, inkl. lokaler TLS-/TCP-Server fĂŒr die Probe und Browser-PrĂŒfung, falls Chrome da ist).
    Gegenversuch: Mit der alten sample.html werden 7 PrĂŒfungen rot, darunter der XSS-Test im Browser.
  • Nicht belegt: Probe gegen fremde Hosts in der Suite (nur die Handbeobachtung oben), Stromausfall zwischen fsync und rename,
    ~~Widerruf der Node-CA~~ (inzwischen CRL, siehe „Widerruf fĂŒr die Node-CA“), kein OCSP. Node-CA-Bestellungen brauchen noch einen SOAP-Zugang der Gruppe (credential_id ist Pflicht).

  • Sensor-IdentitĂ€t per mTLS (Dev-Test): bin/cert_whois.php und TlsGate\Orders\CertIdentity beantworten aus einem vorgelegten Zertifikat,
    wohin es gehört: Aussteller (Node-CA?), Bestellung, Objekt, Gruppe, laut Inventar. Urteile: known, foreign_ca, unknown_cert, expired,
    not_yet_valid, not_issued, invalid. Nimmt nie einen privaten SchlĂŒssel an; jedes Nachschlagen steht im Audit.

  • tests/test_sensor.sh (28): Ein virtueller Sensor erzeugt seinen Dev-SchlĂŒssel selbst, bekommt ein Client-Zertifikat der Node-CA
    (DNS per Fixture, Herkunft fixture) und meldet sich per mTLS an einem Test-Gateway. Erkannt als sensor/sensor-17 in Gruppe bwa,
    Ort laut Inventar pv-container/batterie. Abgewiesen: ohne Zertifikat, selbstsigniert, fremde CA, echtes Zertifikat mit falschem SchlĂŒssel.
    Gegenversuch mit gelockertem Gateway (verify_peer aus): 7 rote PrĂŒfungen.
  • Grenze, ausdrĂŒcklich getestet: Eine Kopie von Zertifikat und SchlĂŒssel ist fĂŒr das Gateway derselbe Sensor. Gegenmittel: Widerruf (siehe unten).
  • Beobachtung: Unter TLS 1.3 ist der Handshake fĂŒr den Client fertig, bevor der Server ein fehlendes Client-Zertifikat ablehnt. Abgewiesen
    heißt deshalb „keine Antwort vom Gateway“, nicht „Verbindung scheitert“.
  • docs/SECURITY.md: neuer Abschnitt „Wer darf was lesen, was wird protokolliert, was schĂŒtzt die Outbox“. Lesen des Exports wird nicht protokolliert.

  • Widerruf fĂŒr die Node-CA, „selbst ausgestellt“ gegen „gekauft“ (Vertrag v4: neuer Zustand revoked, issued → revoked).

  • node_ca.php revoke <id> <grund>: nur fĂŒr Zertifikate, die diese Node-CA signiert hat (PrĂŒfung per Signatur gegen ca.crt, nicht per Namen).
    Gekaufte Zertifikate lehnt TLS-Gate ab: die widerruft der Registrar. Neue Tabelle cert_revocations (append-only, beide Datenbanken).
    Reihenfolge: erst der Eintrag (Quelle der CRL), dann der Zustandswechsel. Scheitert der, bleibt der Widerruf: im Zweifel widerrufen.
  • node_ca.php crl [--days N]: echte X.509-CRL (node-ca/crl.pem, 0644) mit nextUpdate und fortlaufender Nummer, fĂŒr nginx ssl_crl,
    openssl verify -crl_check oder einen Zero ohne Verbindung. PHP kann keine CRL signieren, deshalb die openssl-CLI. Den CA-SchlĂŒssel bekommt
    sie ĂŒber eine Pipe (/dev/fd/3), nie als Datei.
  • „Nix ist null“, belegt mit openssl verify: vor der neuen CRL gilt der widerrufene Sensor noch als gĂŒltig (Widerruf wirkt erst mit
    Verteilung); ohne CRL-Datei lehnt -crl_check ab; nach nextUpdate lehnt openssl auch ein gĂŒltiges Zertifikat ab („CRL has expired“).
    Ein unbekannter Widerrufsstand ist nie „gĂŒltig“.
  • „Selbst ausgestellt“ gegen „gekauft“: issuer_kind (node_ca | extern | fixture) in cert_whois, Export und Monitor. CertIdentity kennt
    gekaufte Zertifikate aus dem Inventar jetzt als known (vorher foreign_ca), mit dem Hinweis, dass die Kette zur öffentlichen CA hier nicht geprĂŒft wird.
    Neues Urteil revoked, auch in cert_whois (Exit 1).
  • Wildcard *.schule.xyz: Die Challenge liegt an der Basis-Domain (_tlsgate-dcv.schule.xyz). Vorher wĂ€re _tlsgate-dcv.*.schule.xyz entstanden, kein gĂŒltiger DNS-Name.
  • Monitor: REVOKED (Grund) rot, Spalte Herkunft nennt „selbst ausgestellt“ oder „gekauft/extern“.
  • Tests: tests/test_revoke.sh (41), Sensor-Test erweitert (32: nach Widerruf meldet das Gateway Kopie und Original als revoked).
    Gegenversuche: ohne Aussteller-PrĂŒfung und mit leerer CRL werden jeweils die passenden PrĂŒfungen rot. Von Hand auf MariaDB 11.
  • Dev-DBs aus Schritt 4 bis 6: Die CHECK-Regel von cert_orders kennt revoked nicht, revoke bricht dort ab. Behoben durch den Migrator (siehe oben).
  • Nicht dabei: OCSP, automatisches Verteilen der CRL, Widerruf gekaufter Zertifikate ĂŒber SOAP.

  • Migrator (src/Storage/Migrator.php, user_cli.php migrate [--dry-run|--status]). Aufgebaut „von null“ mit dem jetzigen Stand
    als Basis: Version 1 = basis-v2.2, vermerkt in der neuen Tabelle schema_migrations (append-only). schema*.sql beschreibt immer den
    neuesten Stand; spĂ€tere Änderungen kommen als database/migrations/NNNN_name.{sqlite,mysql}.sql (Regeln: database/migrations/README.md).

  • SQLite gleicht selbst an: Das Soll-Schema entsteht in einer In-Memory-DB aus schema.sql. Abweichende Tabellen (die alten CHECK-Regeln
    ohne fixture, node_ca, revoked) werden nach dem SQLite-Verfahren umgebaut, Indizes und Trigger neu angelegt. Alles in einer Transaktion,
    FremdschlĂŒssel geprĂŒft, am Ende Kontrolle „kein Unterschied mehr zum Soll“, sonst Rollback. Vorher Sicherung per VACUUM INTO (0600, eindeutiger Name).
  • Reihenfolge: Migrationsdateien laufen vor dem Angleichen (sehen den alten Stand, dĂŒrfen Daten umformen, mĂŒssen idempotent sein).
    Auf einer frischen DB laufen sie nicht, sie werden als „in schema.sql enthalten“ vermerkt.
  • Abbruch statt Raten: Spalten, die im Soll fehlen (Daten gingen verloren), verletzte FremdschlĂŒssel, fehlerhafte Dateien → nichts geĂ€ndert.
    Gegenversuch: ohne diesen Schutz hÀtte der Umbau eine fremde Spalte private_key_pem still verworfen.
  • MySQL/MariaDB: fehlende Tabellen, Dateien, Version. Veraltete CHECK-Regeln werden erkannt (information_schema) und fĂŒhren zum Abbruch mit Hinweis.
  • Doktor: meldet „Schema-Version N (aktuell)“ oder „ohne Versionsangabe“ statt der Einzelwarnungen zu CHECK-Regeln und legt dabei nichts an.
  • Fix unterwegs: Zwei Migrationen in derselben Sekunde scheiterten, weil der Name der Sicherung nur sekundengenau war.
  • Tests: tests/test_migrate.sh (94). Jeder Schema-Stand aus der Git-Historie (28f2211, 13bf68b, 4e298ca, 6950951, 766274d, f18d59e) mit Daten:
    Daten erhalten (PrĂŒfsumme je Tabelle), Schema gleich frisch (Tabellen, Indizes, Trigger), Append-only greift wieder, Sicherung = alter Stand.
    Dazu AbbrĂŒche, Migrationsdateien genau einmal, MariaDB (falls tlsgate-my lĂ€uft). Gegenversuch „Umbau ohne Datenkopie“: 5 rote PrĂŒfungen.
  • Damit erledigt: Die Hinweise „Dev-DB neu anlegen“ weiter oben gelten nicht mehr. php bin/user_cli.php migrate bringt jede DB aus diesen StĂ€nden hoch.

v2.1.0 — 2026-09-28 · Phase 2: SSL-Konsole entflochten

Keine neue Funktion. Die Seite rendert bis auf eingebundenes CSS/JS identisch; geprĂŒft wurde das per
HTML-Vergleich alt gegen neu fĂŒr GET und alle Aktionen.

  • public/index.php ist von 1054 auf 37 Zeilen geschrumpft und dient nur noch als Einstieg: Zugang, Controller, Rendern.
  • TlsGate\Http\SslController: RollenprĂŒfung, Audit, Pflicht-Grund, Aufruf der Aktion und Laden der
    Anzeigedaten (Liste mit Restlaufzeit, Produkte, Software). Heraus kommt ein reines Array.
  • TlsGate\Http\View + templates/ (außerhalb des Webroots): layout.php, ssl/page.php, je eine
    Ergebnis-Karte und ein Reiter als eigenes Template. Templates rufen nichts selbst auf.
  • public/assets/tlsgate.css und public/assets/ssl.js: Die 18 Inline-onclick/oninput sind durch
    data-*-Attribute mit Event-Delegation ersetzt.
  • Strikte CSP auch fĂŒr die SSL-Konsole: script-src 'self', keine Inline-Skripte mehr. Damit ist ein
    offener Punkt aus docs/SECURITY.md 1c erledigt.
  • Tests: tests/test_auth.sh 71 (neu: CSP, kein Inline-JS, Assets erreichbar, src/Http und templates/ nicht im Web).
  • Fix: .gitignore bindet state/ und SSL/ jetzt an den Repo-Root (/state/, /SSL/). Auf macOS
    (case-insensitive) hat SSL/ sonst auch templates/ssl/ still ausgeblendet.
  • Fix: flackernder Replay-Check in tests/test_auth.sh. Er rechnete den „benutzten“ TOTP-Code neu aus, statt
    ihn wiederzuverwenden, und wurde deshalb rot, wenn der Lauf ĂŒber eine 30-s-Grenze fiel (Folgefehler: IP-Sperre).
    Der Fehler steckte schon in v2.0.0 und ist jetzt per Start an der Fenstergrenze nachgewiesen und behoben.

v2.0.0 — 2026-09-28 · „Core mit Login, sauber verpackt"

Die erste v2. Aus dem SSL-Werkzeug wird der Anfang des Mandanten-Cores (docs/ARCHITEKTUR.md):
Login-Pflicht, Audit und Web-Verwaltung, und das Ganze in einer Struktur, die sich sauber auf einen
Produktionshost bringen lÀsst.

Nicht rĂŒckwĂ€rtskompatibel zu v1:
- Web-UI nur noch mit Anmeldung (Passwort + TOTP). Ohne DB antwortet sie mit 503 (fail closed).
- DocumentRoot ist public/ statt Repo-Root. Der vhost muss umgestellt werden (deploy/).
- Befehle liegen unter bin/: php bin/user_cli.php, php bin/soap_cli.php, bin/dashboard.sh.
- sample.json/api.php sind nicht mehr per Web erreichbar (resources/mock/).

Login & Audit (M1)

  • Login mit Passwort (Argon2id, ≄ 15 Zeichen) und TOTP-Pflicht (RFC 6238, mit Testvektoren geprĂŒft),
    Gruppen (eigen/reseller) und Rollen (readonly < user < group_admin < admin).
  • Sperren pro Konto und pro IP, Replay-Schutz fĂŒr TOTP, CSRF auf jedem Formular, Sicherheits-Header.
    Session-Cookie __Host-, SameSite=Strict.
  • audit_log per Trigger nur anhĂ€ngbar. Jede SSL-Aktion und jede VerwaltungsĂ€nderung wird protokolliert.
  • SQLite in TLS_GATE_STATE_DIR, TOTP-Secrets mit sodium verschlĂŒsselt.
  • bin/user_cli.php: migrate, Gruppen, Benutzer, enroll, code, audit.

Web-Verwaltung

  • users.php: Benutzer und Gruppen verwalten (nur admin). Man kann sich nicht selbst aussperren,
    und der letzte Admin ist geschĂŒtzt.
  • auditlog.php: Audit-Ansicht mit Filtern (nur admin).
  • Pflichtfeld „Grund/Zweck" bei SSL-Bestellungen. Der Grund landet im audit_log und wird nicht an die CA geschickt.

Struktur & Betrieb

  • PSR-4: TlsGate\ → src/ (Auth, Audit, Storage, Ssl, Config), Autoloader in bootstrap.php
    ohne Composer, dazu composer.json mit derselben Zuordnung.
  • public/ als einziger Webroot. router.php ist jetzt eine Whitelist statt einer Deny-Liste.
  • TlsGate\Config: Pfade und Umgebungsvariablen an einer Stelle (ersetzt die $GLOBALS-Credentials).
  • Vorlagen fĂŒr Apache und nginx sowie Hinweise pro Betriebssystem in deploy/.
  • bin/TLS-doktor.bash: Umgebungs-Diagnose je PHP-Binary und Web-User.
  • bin/serve.sh: Dev-Server, der vom Terminal losgelöst lĂ€uft.
  • Tests: tests/test_auth.sh (66), tests/test_smoke.sh (44), tests/test_totp_rfc6238.sh (11).

SOAP / Terminal

  • soap_cli.php probe (M0): Live-PrĂŒfung von level/forReseller, nur lesend.

v1 — 2026-07-17 
 2026-09-24 · SSL-Werkzeug ohne Login

Stand bis Commit 3dec8a8 (vor 6d75485, „Login fĂŒr das TLS-Frontend"). LĂ€uft als v1 in der
Test-VM. Flaches Layout: Repo-Root ist gleichzeitig Webroot, kein Login.

  • index.php: SOAP-Portal (Liste mit Expire, Suche, CSR/Key, Bestellen, VerlĂ€ngern ĂŒber sslReplace,
    Authcode, Besitzerdaten, DCV per DNS/HTTP, Cross-Check), Mock-Fallback auf sample.json.
  • SslHandler.php: gemeinsamer SOAP-Kern fĂŒr Web und CLI. soap_cli.php: JSON-BrĂŒcke.
  • dashboard.sh: Terminal-Dashboard, OpenSSL-Toolbox, deploy mit Match- und Chain-PrĂŒfung,
    Stammdaten-Layer state/state.json + state/actions.log.
  • getRenewability: Renewal-Fenster aus dem Produktparameter.
  • sample.html: Offline-Visualizer mit Anzeige der rohen SOAP-Pakete.
  • Audit-Findings BWA-AUD-01 
 15 (docs/AUDIT.md).