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)
sslListExtendedmitlevel=allâ enthĂ€lt die Antwort Orders mit gefĂŒlltem Feldreseller?sslInfo(orderNumber, forReseller=<ID>)fĂŒr eine bekannte Reseller-Order â Returncode 1000?- Dieselbe Order mit falscher
forReseller-ID â kommt2004? (Belegt, dass die Mandantengrenze auch serverseitig durchgesetzt wird.) - Ist
resellerin der Antwort dieselbe ID, dieforResellererwartet?
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
- Vorab importieren (Hintergrundjob) aus den bestehenden Kunden- und Abrechnungssystemen, dazu
paidUntilDateausdomainInfo, wo vorhanden, mit Angabe der Quelle. - Wenn nichts importiert wurde, mĂŒssen die Stammdaten spĂ€testens im Erneuerungsprozess erfasst
und gespeichert werden. Die VerlÀngerung wartet, bis das geschehen ist. - 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_idaus der
Session erzwingt. Module schreiben kein eigenesWHERE tenant_id. ZusĂ€tzlich möglich: Postgres Row-Level-Security. cert_inventoryist ein gemeinsamer Bestand fĂŒr Partnergate und acme.sh.state/state.jsonundstate/actions.logbleiben 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-soapausgereift ist undSslHandlerdas 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_inventoryundaudit_logbilden den
Vertrag. Ein spÀterer Rust-Worker (axum/tokio, wiequestions-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 mitSecure/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.