B.W.A TLS-Gate // Architektur-Blaupause: vom SSL-Tool zum Mandanten-Core

Stand: 2026-09-24 · Status: Entwurf zur Abstimmung mit dem RZ
Anlass: Die erste echte VerlÀngerung mit Chain wurde sauber ausgestellt und deployed. Damit wird sichtbar,
was fehlt: Reseller mit eigenem Partnergate-Zugang, deren Domains wir verwalten. Dazu kommen Stammdaten wie
„bezahlt bis“ und spĂ€ter acme.sh fĂŒr Let's Encrypt.

Leitgedanke: Wir bauen einen Core (Login, Mandanten, Stammdaten, Audit, Job-Queue). SSL/Partnergate
ist darin ein Modul unter mehreren. Der Core kann die alten PHP-, Ruby- und Python-Tools nach und nach
ablösen, aber einzeln und nie alle auf einmal.

Diese Datei ist ein Plan, kein Ist-Stand. Den Ist-Ablauf beschreibt PROZESS.md.


1. Befund: Mandanten ĂŒber forReseller (Weg B) trĂ€gt

GeprĂŒft wurde gegen die PDF-Doku und gegen die Live-WSDL
(https://www.domain-bestellsystem.de/soapstatus/wsdl/soap.wsdl, abgerufen am 2026-09-24). Wenn sich beide
widersprechen, gilt die WSDL (siehe den Fehler validationtyp/validationtype in README.md).

Punkt Quelle Ergebnis
Bedeutung von forReseller PDF S. 7, Kap. 3.1 „ermöglicht die AusfĂŒhrung des jeweiligen Befehls unter der Reseller-ID Ihres Resellers“
SSL-Befehle mit forReseller Live-WSDL alle: sslCreate, sslReplace, sslInfo, sslListExtended, sslCancel, sslReplaceValidation, sslResendDCV, sslGetDCVList
sslCreate PDF S. 142 ff. vs. WSDL Im PDF-Beispiel fehlt forReseller, die WSDL hat es aber (SslCreateRequestObject). Der zweite bekannte Doku-Fehler
Welchem Reseller eine Order gehört PDF S. 168, sslListExtended-Antwort Jede Order hat das Feld reseller (leer = eigener Bestand) → Zuordnung zum Mandanten automatisch möglich
Sichtbarkeit: eigener Bestand oder Reseller PDF S. 57, domainList level = all / own / reselleronly. Bei sslListExtended gibt es level laut WSDL auch, die Werte sind dort aber nicht dokumentiert
Reseller-Recht fĂŒr SSL PDF S. 224, resellerRights eigene Rechte ssl und orderSoap (true/false)
Fehler bei falschem Reseller PDF, mehrere Befehle Returncode 2004 „UngĂŒltiger forReseller“
Kurzlebiger Login PDF S. 10, Kap. 4 login liefert ein JWT, 1 h gĂŒltig, als Bearer-Token → kein dauerhaftes Passwort in jedem Request

Konsequenz: Wir arbeiten mit unserem eigenen Partner-Zugang und setzen pro Anfrage forReseller
auf die Reseller-ID des eingeloggten Mandanten. Wir speichern keine fremden Partnergate-Passwörter.
Die Reseller behalten ihren eigenen Zugang zum Partnergate. Unser Tool braucht ihn nicht.

Achtung, heutiger Stand im Code: SslHandler.php ruft sslListExtended mit level => 'own' auf, und
forReseller ist ĂŒberall fest ''. Reseller-Zertifikate sind im Inventar heute also gar nicht sichtbar.

Vor dem Umbau live zu prĂŒfen (nur lesend, ohne Risiko)

  1. sslListExtended mit level=all → enthĂ€lt die Antwort Orders mit gefĂŒlltem Feld reseller?
  2. sslInfo(orderNumber, forReseller=<ID>) fĂŒr eine bekannte Reseller-Order → Returncode 1000?
  3. Dieselbe Order mit falscher forReseller-ID → kommt 2004? (Belegt, dass die Mandantengrenze auch serverseitig durchgesetzt wird.)
  4. Ist reseller in der Antwort dieselbe ID, die forReseller erwartet?

Werkzeug dafĂŒr: soap_cli.php probe. Es arbeitet nur lesend und ohne RĂŒckfall auf die Mockdaten. Exit 0 heißt
returnCode 1000, Exit 1 heißt API-Fehler oder keine Verbindung.

php soap_cli.php probe list --level own               # Vergleichsbasis = heutiger Stand
php soap_cli.php probe list --level all               # 1. byReseller zeigt Reseller-IDs?
php soap_cli.php probe list --level reselleronly      #    (Wert bei SSL undokumentiert — testen)
php soap_cli.php probe info <order> --for-reseller <ID>      # 2. erwartet 1000, reseller == <ID> (4.)
php soap_cli.php probe info <order> --for-reseller gibtsnicht # 3. erwartet 2004
php soap_cli.php probe list --level all --expiring 10 # ersetzt JSON → CSV → awk $6

2. Der eigentliche Knackpunkt: die Stammdaten („bezahlt bis“)

Die ersten Bash-Tests liefen so: soap_cli.php → JSON → CSV → awk auf Spalte 6 → alle Zertifikate, die in
den nĂ€chsten 10 Tagen erneuert werden mĂŒssen. Das beantwortet aber nur „lĂ€uft ab?“, nicht
„soll verlĂ€ngert werden?“. Ein Zertifikat kann technisch gĂŒltig sein und trotzdem wissentlich nicht
verlÀngert werden, weil der Kunde nicht mehr zahlt.

Nebenbei: Das Filtern auf „lĂ€uft ab“ kann die API selbst ĂŒbernehmen. sslListExtended kennt den Filter
expiring=<Tage> (PDF S. 166), und SslHandler.php reicht ihn bereits durch (list_expiring). Der
awk-Schritt fÀllt damit weg.

Was die API zu „bezahlt“ hergibt

  • domainInfo → paidUntilDate: laut PDF S. 50 nur, „sofern im System hinterlegt“, und nur fĂŒr
    Domains „aus dem eigenen Bestand“, also Domains, die dort als Registrar gefĂŒhrt werden.
  • billingData / billingDataExtra (PDF S. 211 ff.): Rechnungsposten pro Monat, nur fĂŒr Reseller mit
    direktem VertragsverhĂ€ltnis. Daraus lĂ€sst sich die Abrechnung ablesen, aber kein „bezahlt bis“ pro Kunde.
  • FĂŒr SSL-Orders gibt es kein Feld „bezahlt bis“. Kunden, deren Domain woanders registriert ist, liefert
    die API gar nichts dazu.

→ Die Wahrheit ĂŒber „bezahlt bis“ liegt bei uns (Stammdaten), nicht beim Partnergate.

Regel: Stammdaten zuerst

  1. Vorab importieren (Hintergrundjob) aus den bestehenden Kunden- und Abrechnungssystemen, dazu
    paidUntilDate aus domainInfo, wo vorhanden, mit Angabe der Quelle.
  2. Wenn nichts importiert wurde, mĂŒssen die Stammdaten spĂ€testens im Erneuerungsprozess erfasst
    und gespeichert werden. Die VerlÀngerung wartet, bis das geschehen ist.
  3. Entscheidung pro Order:
LĂ€uft ab (≀ N Tage) Bezahlt bis Entscheidung Wird festgehalten
ja ≄ Ende der neuen Laufzeit verlĂ€ngern (Job) Audit: wer, wann, auf welcher Grundlage
ja < Ablauf / gekĂŒndigt bewusst auslaufen lassen Audit + Vermerk „wissentlich nicht verlĂ€ngert“ und Grund
ja unbekannt gesperrt, manuell klĂ€ren taucht in der Liste „Stammdaten fehlen“ auf
nein egal nichts tun –

„Nicht verlĂ€ngert“ ist damit eine dokumentierte Entscheidung und kein Versehen. Das ist der KrĂŒmel.


3. Datenmodell (Entwurf)

tenants           id, name, typ (eigen|reseller), pg_reseller_id (→ forReseller), parent_id, aktiv
customers         id, tenant_id, name, kundennummer, vertrag_status
contracts         id, customer_id, domain, paid_until, source (import|domainInfo|manuell),
                  source_ref, updated_by, updated_at
users             id, tenant_id, email, pw_hash (argon2id), role, webauthn_*, totp_secret_enc,
                  locked, last_login
cert_inventory    id, tenant_id, customer_id, domain, source (partnergate|acme),
                  order_number, product_id, status, valid_to, last_seen
renewal_decisions id, cert_id, decision (renew|let_expire|blocked), reason, decided_by, decided_at
jobs              id, tenant_id, type, payload, state (queued|running|done|failed), result, attempts
audit_log         id, ts, user_id, tenant_id, action, target, result, ip   ← nur anhĂ€ngen, kein UPDATE/DELETE
  • Mandantentrennung zentral: Jede Abfrage lĂ€uft ĂŒber eine Repository-Schicht, die tenant_id aus der
    Session erzwingt. Module schreiben kein eigenes WHERE tenant_id. ZusÀtzlich möglich: Postgres Row-Level-Security.
  • cert_inventory ist ein gemeinsamer Bestand fĂŒr Partnergate und acme.sh.
  • state/state.json und state/actions.log bleiben als Vorstufe erhalten, bis die DB sie ablöst.
    Der Import erfolgt einmalig.

Startbasis: wptransformer (lÀuft produktiv auf wptransformer.org, Mandanten mit DB)

Maßgeblich ist das Repo ~/wptransformeer-OZMv0, denn es ist weiter als der Online-Stand (v1.0.2): Reset mit
SHA-256-gehashtem Token, Db.php fĂŒr MySQL und Postgres, post_types. Nur online existieren zwei
Änderungen, die zurĂŒck ins Repo gehören:
- .htaccess sperrt zusÀtzlich *.log und *.ndjson. Im Repo fehlt das, dort wÀren die Logs öffentlich abrufbar.
- contact.php fÀngt es ab, wenn mail() auf dem Hoster deaktiviert ist.

Übernehmen Anpassen fĂŒr TLS
config.php + config.local.php (Secrets außerhalb von git, per .htaccess gesperrt) Secrets zusĂ€tzlich außerhalb des Webroots
globale Error-, Exception- und Shutdown-Handler → Logger –
Db.php (mysql/pgsql), Csrf.php –
TenantManager (Slug + JSON-Konfiguration pro Mandant) deploy-JSON wird zum Deploy-Ziel fĂŒr Zertifikate pro Mandant
migrate_*.php (idempotent, mysql+pgsql) nur als CLI, nicht ĂŒber das Web aufrufbar
– Deployer::ftp nicht fĂŒr Zertifikate/Keys verwenden (Klartext-FTP als RĂŒckfall, FTP-Passwörter in der DB)

4. Login: „so hart wie vorgeschrieben“

Solange nur Admins im Admin-VPN arbeiten, ist das Risiko gering. Sobald Reseller auf ihre Domains und
Kunden zugreifen, verlÀsst der Zugang das VPN.
Ab dann gilt:

Anforderung Herkunft Umsetzung
Mehrfaktor-Authentisierung NIS2 Art. 21 Abs. 2 lit. j · ISO 27001:2022 A.8.5 WebAuthn/Passkeys (FIDO2) als Standard: phishing-resistent, NIST SP 800-63B AAL3-fĂ€hig. TOTP nur als RĂŒckfalloption (AAL2)
Passwortregeln NIST SP 800-63B lang statt kompliziert (≄ 15 Zeichen ohne zweiten Faktor), Abgleich gegen Leak-Listen, keine erzwungene Rotation
Speicherung Stand der Technik Argon2id; TOTP-Secrets mit sodium verschlĂŒsselt, SchlĂŒssel außerhalb der DB
Sperre & Alarm ISO 27001 A.8.5 aus Retro_PWD_Reset (Sperre nach IP und Konto, Alarm an Admin)
Session NIST 800-63B session_regenerate_id nach Login, Timeout bei InaktivitĂ€t, erneute Authentisierung fĂŒr kritische Aktionen (revoke, cancel, create)
Rollen & Rechte ISO 27001 A.5.15/A.5.18 admin (nur VPN), reseller_admin, reseller_user, readonly. Minimalprinzip
Protokollierung ISO 27001 A.8.15 · DSGVO Art. 32 audit_log nur anhÀngen. Getrennt davon ein Betriebslog (Datei + ndjson, aus wptransformeer-OZMv0)
Datensparsamkeit DSGVO Art. 5, 25 Stammdaten nur so weit, wie fĂŒr die Entscheidung zur VerlĂ€ngerung nötig. Löschkonzept fĂŒr beendete VertrĂ€ge

Netztrennung als zweite Schutzschicht: Admin- und Schreibfunktionen fĂŒr alle Mandanten bleiben nur im
Admin-VPN
. Das Reseller-Portal ist eine eigene, schmale OberflÀche, die nur den eigenen Mandanten sieht.

Wiederverwendung aus bestehenden Repos

Baustein Quelle Nachbesserung vor Übernahme
Logger (Datei + ndjson + DB) wptransformeer-OZMv0/classes/Logger.php Audit getrennt vom Betriebslog
Login/Session wptransformeer-OZMv0/classes/Auth.php session_regenerate_id(true) fehlt; Argon2id statt bcrypt
Sperre, Rollen, Reset Retro_PWD_Reset/classes/{Auth,Logger}.php MindestlĂ€nge 6 → NIST-konform. Reset-Link nicht mit http:// + HTTP_HOST bauen (Host-Header-Injection), sondern mit fester Basis-URL. Reset-Token nur als Hash speichern

5. Module

            ┌───────────────── Core ─────────────────┐
 Web (PHP) ── Auth/MFA · Mandanten · Stammdaten ·     │
            │ Audit · Job-Queue · Secrets             │
            └───────┬───────────────┬─────────────────┘
                    │ jobs          │ jobs
          ┌─────────▌──────┐  ┌─────▌────────┐  ┌───────────┐
          │ ssl-partnergate│  │ acme (acme.sh)│  │ dns (spĂ€t.)│
          │ SslHandler     │  │ DNS-01        │  │ CNAME/TXT │
          └────────────────┘  └──────────────┘  └───────────┘

Gemeinsame Schnittstelle CertProvider: list · status · issue · renew · revoke.
Partnergate und acme.sh sind zwei Umsetzungen davon.

acme.sh
- Wird nie aus dem Web-Request gestartet. Das Web schreibt einen Job, ein Worker mit eigenem Systemuser
fĂŒhrt acme.sh aus. Das entspricht der bestehenden Linie „kein shell_exec“ (Cross-Check ist PHP-nativ).
- DNS-01 ĂŒber den eigenen DNS. Der DNS-Baustein wird geteilt: Partnergate-DCV (CNAME) und ACME-DNS-01
(TXT) sind derselbe offene Schritt 6 aus PROZESS.md.
- Account-Keys und Zertifikate unter einem eigenen --home/--cert-home, nicht im Webroot.

Zielsysteme: Die alten FreeBSD-Hosts sind nur Ziele fĂŒr das Deployment. Auf ihnen lĂ€uft kein Core-Code.


6. PHP jetzt, Rust spÀter (OZM_Nullfeld_v1)

  • Jetzt PHP, weil ext-soap ausgereift ist und SslHandler das gesammelte Wissen ĂŒber die API enthĂ€lt.
    In Rust gibt es keinen brauchbaren WSDL-Client. Das hieße, XML von Hand zu schreiben, und genau dort
    liegen die Doku-Fehler.
  • Die Nahtstelle zu Rust ist die DB: Die Tabellen jobs, cert_inventory und audit_log bilden den
    Vertrag. Ein spÀterer Rust-Worker (axum/tokio, wie questions-core) arbeitet dieselbe Queue ab, und PHP
    bleibt als schlankes Frontend.
  • Zuerst sollte der Teil mit den meisten Rechten migriert werden: Secrets, acme.sh-AusfĂŒhrung, SOAP-Schreibaufrufe.
  • Datenbank: In der Mockup-Phase SQLite, eine Datei in state/ und kein Server. Postgres (mit Rollenrechten
    und Row-Level-Security) kommt erst mit dem Rust-Core. Das Schema bleibt schlicht, damit der Umzug leicht fÀllt.
  • Abgesichertes PHP-Setup: PHP ≄ 8.3 auf einem neuen Host (nicht auf den alten FreeBSDs), eigener
    php-fpm-Pool und User, disable_functions (exec, shell_exec, system, passthru, proc_open, popen) im Web-Pool,
    open_basedir, Secrets außerhalb des Webroots, Cookies mit Secure/HttpOnly/SameSite=Strict, CSP,
    ausschließlich Prepared Statements, sodium, Argon2id.

7. Meilensteine

# Inhalt Fertig, wenn
M0 Live-PrĂŒfung aus Abschnitt 1 (nur lesend) level=all und forReseller belegt, inkl. Fehler 2004 bei falscher ID
M1 Core: DB-Schema, Login mit WebAuthn/TOTP, Rollen, audit_log Admin-Login im VPN, jede Aktion wird auditiert — Stand 2026-09-24: umgesetzt mit TOTP auf SQLite (Mockup, Auth.php, test_auth.sh 55/55). WebAuthn und offene Punkte: SECURITY.md 1c
M2 Stammdaten: Import aus Hintergrundsystemen + paidUntilDate + manuelle Erfassung Liste „Stammdaten fehlen“ ist leer oder erklĂ€rt
M3 SslHandler als Modul: Zugang pro Anfrage + forReseller aus der Session eigener Mandant + ein Test-Reseller mit echten Daten
M4 Entscheidung zur VerlÀngerung + Job-Worker (ersetzt den awk-Cron) tÀglicher Lauf erzeugt Entscheidungen, keine blinden VerlÀngerungen
M5 Reseller-Portal außerhalb des VPN Pentest/Review durch das RZ bestanden
M6 acme.sh-Modul interner Let's-Encrypt-Bestand in cert_inventory

Zwischen den Meilensteinen lĂ€uft das bestehende Tool unverĂ€ndert weiter. Es wird nichts „big bang“ umgestellt.