Files
kaffeekasse-saas/docs/deployment.md
T
clemensandClaude Opus 5 86334c2752 Offene Registrierung gegen Bot-Massenanlage absichern
Auf der Testumgebung liefen seit Juli 2026 taeglich 10-20 automatisierte
Registrierungen, die zufaellige Namen mit fremden echten E-Mail-Adressen
kombinierten - der Server verschickte Vertrags- und Verifikationsmail an
Unbeteiligte. Das IP-Rate-Limit griff nicht, weil jede Anfrage ueber eine
eigene Rechenzentrums-IP kam.

app/spam-guard.php ergaenzt daher Honigtopf, Zeitfalle, eine globale
Notbremse ueber alle IPs hinweg und eine MX/A-Pruefung der Mail-Domain.
Die ersten drei antworten mit derselben generischen Meldung wie das
Rate-Limit, damit die Antwort nicht verraet, welche Huerde angeschlagen
hat; Treffer landen im Error-Log. Bewusst ohne Captcha, um keinen
Drittanbieter in den Registrierungspfad zu holen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 23:09:38 +02:00

20 KiB
Raw Blame History

Deployment und Go-Live

Stand: 2026-08-22

Dieses Dokument beschreibt, wie die Anwendung auf den Netcup-Webspace (Plesk) ausgerollt und in Betrieb genommen wird. Es ergänzt docs/betrieb-backup-monitoring.md (Backup/Monitoring) und docs/betrieb-mail.md (Mailversand).

Umgebungen

Umgebung Host Webroot Tabellen-Prefix Zweck
Staging testumgebung.kaffeeliste.de /testumgebung.kaffeeliste.de/httpdocs/ test_ Vollständiger Durchlauf vor jedem Produktiv-Deploy
Produktion app.kaffeeliste.de (App) + kaffeeliste.de (Marketing) noch einzurichten prod_ Echtbetrieb

Beide Umgebungen verwenden dieselbe physische Datenbank, aber zwingend unterschiedliche Tabellen-Prefixe und jeweils eine eigene env.local.php. Damit sind Daten, Migrationstabellen und Fremdschlüssel von Staging und Produktion logisch getrennt. Die Staging-Umgebung läuft mit Stripe-Testkeys, die Produktion mit Live-Keys.

Der Prefix schützt vor versehentlichem Zugriff der einen Umgebung auf die Tabellen der anderen, ersetzt aber keine getrennte Datenbank als harte Sicherheitsgrenze: Ein kompromittierter gemeinsamer Datenbankzugang kann weiterhin alle Tabellen sehen. Deshalb Zugangsdaten besonders restriktiv behandeln und immer die gesamte Datenbank sichern.

Host-Split beachten

index.php prüft den Request-Host gegen APP_HOST: stimmt er nicht überein, wird statt des Dashboards die Landingpage ausgeliefert. Auf Staging muss deshalb APP_HOST=testumgebung.kaffeeliste.de gesetzt sein, sonst kommt man nie ins Dashboard.

In Produktion zeigen beide Vhosts (kaffeeliste.de und app.kaffeeliste.de) auf dasselbe Webroot; APP_HOST=app.kaffeeliste.de sorgt dann automatisch dafür, dass die Marketing-Domain die Landingpage und die App-Domain das Dashboard zeigt.

Was deployt wird — und was nicht

Der Upload läuft über den FTP-Sync (sync_config.jsonc, Umgebung netcup). Die Ausschlussliste dort ist sicherheitsrelevant und darf nicht geleert werden:

Nicht ausliefern: .git, .gitignore, .claude, .local, .vscode, .env.local, env.local.php, sync_config.jsonc, var/, docs/, README.md.

  • .git wäre sonst komplett herunterladbar (kompletter Quellcode plus Historie).
  • .env.local enthält die Zugangsdaten der Entwicklungsdatenbank.
  • sync_config.jsonc enthält das FTP-Passwort im Klartext.
  • var/ ist Laufzeitzustand des Servers (Sessions, Uploads) und würde vom lokalen Stand überschrieben.

scripts/ und database/ werden mit ausgeliefert — die Plesk-Scheduled-Tasks führen sie vom Webspace aus. Per URL sind sie durch die .htaccess gesperrt.

Zusätzlich sperrt die .htaccess var|database|scripts|docs|app|lib, die Vendor-Verzeichnisse TCPDF|PHPMailer|DataTables, Dotfile-Ordner sowie Konfigurationsdateien und .sql/.md/.jsonc/.sh/.log-Dateien. Im Ordner legal/ sind ausschließlich datierte .txt-Fassungen von AGB, Datenschutz, AVV und Widerrufsbelehrung als dauerhafte Downloads erlaubt. Sie erzwingt außerdem HTTPS — ohne Redirect käme ein Nutzer per http an und bekäme eine Session-Cookie ohne secure-Flag.

Erstinbetriebnahme einer Umgebung

1. Hosting-Voraussetzungen prüfen

In Plesk unter PHP-Einstellungen bzw. über eine temporäre phpinfo()-Datei kontrollieren:

  • PHP ≥ 8.1
  • Extensions: pdo_mysql, fileinfo (CSV-Upload), openssl, imap (nur für den PayPal-Mailabruf nötig)
  • allow_url_fopen = Onapp/stripe.php und app/dolibarr.php sprechen ihre APIs bewusst über PHP-Streams statt cURL an und fallen ohne diese Einstellung aus
  • display_errors = Off, log_errors = On (siehe docs/betrieb-backup-monitoring.md)
  • Let's-Encrypt-Zertifikat für den Host ausgestellt

Die temporäre phpinfo()-Datei danach wieder löschen.

2. Datenbank anlegen

In Plesk eine MySQL-Datenbank samt Benutzer anlegen. Beide Umgebungen dürfen dieselben Zugangsdaten verwenden; die Trennung erfolgt über DB_TABLE_PREFIX=test_ beziehungsweise DB_TABLE_PREFIX=prod_.

Unpräfixierte vorhandene Testdaten dürfen ausschließlich mit dem dafür vorgesehenen Übernahmeskript nach test_ kopiert werden. Sie dürfen niemals nach prod_ übernommen werden.

3. Dateien hochladen

FTP-Sync ausführen. Danach prüfen, dass folgende URLs 403/404 liefern und nicht etwa Inhalt:

https://<host>/sync_config.jsonc
https://<host>/.env.local
https://<host>/.git/config
https://<host>/scripts/migrate.php
https://<host>/docs/deployment.md
https://<host>/app/bootstrap.php

Liefert eine davon Inhalt, greift die .htaccess nicht (z. B. weil AllowOverride deaktiviert ist) — dann erst weitermachen, wenn das geklärt ist.

4. env.local.php anlegen

env.local.example.php als Vorlage nehmen, ausfüllen und direkt per FTP als env.local.php ins Webroot legen. Die Datei ist bewusst nicht im Repository und wird nicht mit deployt, damit ein Deploy sie nie überschreibt.

Für Staging mindestens:

putenv('APP_ENV=prod');            // 'dev' würde Mails nur ins Log schreiben
putenv('APP_HOST=testumgebung.kaffeeliste.de');
putenv('APP_TIMEZONE=Europe/Berlin');
putenv('APP_DB_DRIVER=mysql');
putenv('DB_HOST=localhost');
putenv('DB_NAME=…'); putenv('DB_USER=…'); putenv('DB_PASS=…');
putenv('DB_TABLE_PREFIX=test_');
putenv('APP_SESSION_PATH=' . __DIR__ . '/var/sessions');
putenv('APP_MAIL_TRANSPORT=mail');
putenv('APP_MAIL_FROM=noreply@kaffeeliste.de');

Stripe-, Dolibarr- und IMAP-Werte je Umgebung ergänzen (siehe unten). In der produktiven env.local.php muss stattdessen DB_TABLE_PREFIX=prod_ stehen. Ein leerer Prefix ist im laufenden Betrieb nicht zulässig.

5. Schreibrechte für var/

app_start_session() legt var/sessions bei Bedarf selbst an, braucht dafür aber Schreibrechte im Webroot. Nach dem ersten Aufruf prüfen, dass var/sessions existiert und beschreibbar ist; ebenso var/uploads für den CSV-Import. Beide sind per .htaccess von außen gesperrt.

6. Migrationen einspielen

Es gibt 30 versionierte Migrationen in database/migrations/, angewendet über scripts/migrate.php. Das Skript ist idempotent und wendet nur fehlende Migrationen an. Es arbeitet ausschließlich im Prefix aus der jeweiligen env.local.php; daher müssen Migrationen einmal in Staging und einmal in Produktion ausgeführt werden.

Für die einmalige Übernahme vorhandener, bisher unpräfixierter Testdaten:

php scripts/migrate-table-prefix.php --target-prefix=test_ --replace

Das Skript migriert zuerst das test_-Schema, ersetzt dessen Inhalt transaktional durch eine exakte Kopie der unpräfixierten Daten und vergleicht pro Tabelle die Zeilenzahl. Die unpräfixierten Quelltabellen bleiben als Rückfallmöglichkeit unverändert. Vor dem Lauf trotzdem ein vollständiges Datenbank-Backup erstellen.

Ohne SSH-Zugang läuft das über Plesk → Geplante Aufgaben (Scheduled Tasks) als einmalig ausgeführter Task vom Typ PHP-Skript ausführen:

Skriptpfad: /httpdocs/scripts/migrate.php

Der Task muss nach dem Ausführen wieder deaktiviert oder gelöscht werden. Die Ausgabe (Applied migration: … bzw. No new migrations.) lässt sich per E-Mail-Benachrichtigung des Tasks kontrollieren.

Alternative ohne Scheduled Task: die Migrationsdateien der Reihe nach in phpMyAdmin importieren — dann fehlt allerdings der Eintrag in der Versionstabelle, den migrate.php pflegt. Der Scheduled-Task-Weg ist deutlich vorzuziehen.

Nach jedem Deploy mit neuen Migrationen wiederholen.

7. Ersten Mandanten anlegen

Über register.php auf dem konfigurierten Host registrieren. Für Plattform-Administrationsrechte (Back-Office über alle Mandanten hinweg) anschließend scripts/grant-platform-admin.php als einmaligen Plesk-Task ausführen.

Wiederkehrende Aufgaben (Plesk Scheduled Tasks)

Ohne diese Tasks fehlen Zahlungserinnerungen, die automatische PayPal-Verbuchung und — kritisch — die Backups.

Task Skript / Befehl Empfohlener Takt
Zahlungserinnerungen /httpdocs/scripts/send-payment-reminders.php täglich, z. B. 07:00
PayPal-Mailabruf /httpdocs/scripts/fetch-paypal-payments.php alle 1530 Minuten
Technische Löschfristen /httpdocs/scripts/purge-expired-operational-data.php stündlich
Beendete Verträge löschen /httpdocs/scripts/purge-ended-contracts.php täglich
Datenbank-Backup Shell-Befehl, siehe docs/betrieb-backup-monitoring.md täglich nachts

Zum Backup-Task: Das Zielverzeichnis muss außerhalb von httpdocs liegen (z. B. /backups auf Vhost-Ebene), sonst wären die Dumps öffentlich abrufbar. Zusätzlich mindestens eine Kopie an einen anderen Ort übertragen — ein Backup, das nur auf demselben Webspace liegt, schützt nicht gegen den Ausfall des Anbieters.

Vor der Scharfschaltung die Aufgaben mit Lösch- oder Buchungswirkung zuerst manuell mit --dry-run auslösen. Dies unterstützen send-payment-reminders.php, fetch-paypal-payments.php, purge-expired-operational-data.php und purge-ended-contracts.php.

Mailversand und DNS

Der Versand läuft über PHPs mail() (APP_MAIL_TRANSPORT=mail, siehe docs/betrieb-mail.md). Das betrifft Registrierungsbestätigung, E-Mail-Verifikation, Passwort-Reset, Mitglieder-Einladungen, Zahlungserinnerungen und den Massenmailversand — also den kompletten Onboarding-Pfad.

Vor Go-Live für die Absenderdomain einrichten und prüfen:

  • SPF-Eintrag, der den Mailserver des Webspace autorisiert
  • DKIM-Signierung (in Plesk pro Domain aktivierbar)
  • DMARC-Eintrag
  • APP_MAIL_FROM auf eine echte Adresse der eigenen Domain setzen — eine fremde Absenderdomain bricht SPF/DKIM

Testen mit einer Registrierung auf eine externe Adresse (Gmail/Outlook) und Kontrolle, ob die Mail im Posteingang statt im Spam landet. Fehlversuche stehen in der Tabelle outbound_emails mit status = 'failed'.

Stripe und Dolibarr

Details zur Funktionsweise stehen in docs/billing.md. Für den Go-Live:

  1. Staging mit Stripe-Test-Keys betreiben und den kompletten Ablauf einmal durchspielen: Checkout, Upgrade, Downgrade, Kündigung, Customer Portal.
  2. Webhook-Endpunkt im Stripe-Dashboard auf https://<host>/stripe-webhook.php registrieren (je Umgebung einen eigenen) und das dort erzeugte Signing Secret als STRIPE_WEBHOOK_SECRET eintragen. Ohne gültiges Secret weist stripe-webhook.php alle Ereignisse ab.
  3. Für Produktion auf Live-Keys wechseln und den Webhook erneut registrieren — Test- und Live-Modus haben getrennte Webhooks und getrennte Secrets.
  4. Dolibarr: DOLIBARR_URL und DOLIBARR_API_KEY eintragen. Achtung, das ist die produktive Buchhaltungsinstanz des Kunden, es gibt keine Sandbox. Der validate()-Aufruf beim Rechnungsabschluss ist laut docs/billing.md bewusst nie live getestet worden — der erste echte Zahlungseingang ist dessen erster Test. Danach in Dolibarr kontrollieren, ob die Rechnung korrekt angelegt und validiert wurde.

Der Link „Zahlungsmethode verwalten“ startet technisch ausschließlich einen Stripe-Flow zur Aktualisierung der Zahlungsart. Im Stripe-Dashboard dürfen der öffentliche No-Code-Portal-Login und Tarifwechsel außerhalb der Anwendung nicht zusätzlich freigeschaltet werden; Bestellungen und Kündigungen sollen nur über die dokumentierten Abläufe der Kaffeeliste erfolgen.

Schutz der offenen Registrierung

Ab Juli 2026 liefen auf testumgebung.kaffeeliste.de täglich 1020 automatisierte Registrierungen: zufällige Namen kombiniert mit fremden, echten E-Mail-Adressen. Der Server verschickte daraufhin Vertrags- und Verifikationsmail an Leute, die nie etwas bestellt hatten — Mail-Bombing über die eigene Domain, auf Kosten der Zustellbarkeit. Das IP-Rate-Limit griff nicht, weil jede Anfrage über eine eigene Rechenzentrums-IP kam.

app/spam-guard.php stellt deshalb vor register.php:

  • Honigtopf — ein für Menschen unsichtbares Feld (contact_reference), das Formular-Bots ausfüllen. Die Positionierung steht bewusst inline und nicht in public.css: lädt das Stylesheet einmal nicht, wäre das Feld sonst sichtbar und ein echter Nutzer könnte hineinschreiben.
  • Zeitfalle — mindestens vier Sekunden zwischen Ausliefern und Absenden des Formulars.
  • Globale Notbremse — höchstens 20 Registrierungen pro Stunde über alle IPs hinweg, zusätzlich zum bestehenden Limit von 5 pro IP.
  • Domain-Prüfung — die Mail-Domain muss einen MX- oder A/AAAA-Record haben. Fehlt checkdnsrr(), wird durchgelassen: eine kaputte DNS-Auflösung darf keine echten Registrierungen blockieren.

Honigtopf, Zeitfalle und beide Rate-Limits antworten mit derselben generischen Meldung, damit die Antwort nicht verrät, welche Hürde angeschlagen hat. Treffer stehen im PHP-Error-Log (Spam-Guard: …) — dort lässt sich ablesen, ob die Maßnahmen greifen. Nur die Domain-Prüfung nennt den Grund, weil das in aller Regel ein Tippfehler eines echten Nutzers ist.

Bewusst kein Captcha: Turnstile oder hCaptcha wären wirksam, holen aber einen Drittanbieter in den Registrierungspfad und damit in die Datenschutzerklärung. Erst wenn die obigen Hürden nachweislich nicht reichen, ist das die nächste Stufe.

Abgedeckt von scripts/check-spam-guard.php (ohne Datenbank lauffähig).

Betreiber-Benachrichtigung bei neuer Registrierung

register.php schickt nach einer erfolgreichen Selbstregistrierung einen Pushover-Push an den Betreiber (app/pushover.php). Dafür in env.local.php PUSHOVER_TOKEN (App-Token aus dem Pushover-Dashboard) und PUSHOVER_USER (eigener User- oder Group-Key) setzen. Fehlt einer der beiden Werte, ist die Benachrichtigung still deaktiviert — auf Staging also einfach leer lassen.

Der Versand ist bewusst folgenlos: Schlägt er fehl, landet die Ursache im PHP-Error-Log, die Registrierung läuft trotzdem durch. Der Aufruf sitzt deshalb in register.php und nicht in saas_register_tenant_owner() — die check-*-Skripte in scripts/ registrieren Testmandanten und würden sonst bei jedem Lauf Pushes auslösen.

PayPal-Postfach

Für die automatische Verbuchung weitergeleiteter PayPal-Zahlungsmails muss das Postfach aus PAYPAL_INBOX_BASE (Vorgabe zahlungen@kaffeeliste.de) existieren und Plus-Adressierung an dieselbe Mailbox zustellen (Catch-All), damit die pro Mandant generierten Adressen zahlungen+<token>@… ankommen. IMAP-Zugangsdaten in env.local.php eintragen und die imap-Extension auf dem Host prüfen.

Erreichbarkeit prüfen

scripts/check-imap-support.php beantwortet in einem Lauf, ob dieser Host das Postfach erreichen kann: PHP-Version, ob die imap-Erweiterung geladen ist, ob die PAYPAL_*-Einstellungen gesetzt sind, und ob sich das Postfach auch ohne die Erweiterung über eine reine TLS-Verbindung öffnen lässt. Der Test ist nur lesend (EXAMINE, keine Flags), das Passwort wird nie ausgegeben.

Fehlt die Erweiterung: In Plesk unter PHP-Einstellungen prüfen, ob imap angeboten wird — nachinstallieren kann sie nur der Anbieter (plesk-php8x-imap). Seit PHP 8.4 ist imap kein Bestandteil von PHP mehr (nach PECL ausgelagert); auf 8.4/8.5 steht sie deshalb oft gar nicht mehr zur Wahl, dann hilft ein PHP-Handler auf 8.3 — oder ein Abruf über die TLS-Verbindung, die das Diagnoseskript bereits nachweist.

Erster Testlauf

scripts/fetch-paypal-payments.php --dry-run fasst nichts an: es meldet Verbindung, erkannten Mandanten und was mit jeder Mail passieren würde (would_book / would_queue / would_park / duplicate), speichert aber nichts, bucht nichts und markiert keine Mail als gelesen. Erst der Lauf ohne --dry-run verbucht.

Reihenfolge für die Inbetriebnahme:

  1. Eingangsadresse des Mandanten holen — sie steht auf paypal-zuordnung.php („PayPal-Zahlungen").
  2. Eine echte PayPal-Zahlungsmail dorthin weiterleiten.
  3. Task mit --dry-run auslösen, Ausgabe kontrollieren.
  4. Task ohne --dry-run auslösen, Ergebnis in der App prüfen (Journal-Vorschau bzw. Warteschlange).
  5. Erst danach den Task auf den regelmäßigen Takt stellen.

Ohne IMAP lässt sich die Verarbeitung auch mit einer gespeicherten Mail prüfen:

php scripts/fetch-paypal-payments.php --file=mail.html \
    --recipient=zahlungen+<token>@kaffeeliste.de [--dry-run]

Die Verarbeitungskette selbst (Token, Parser, Zuordnung, Buchung, Dedup, Parken) deckt scripts/check-paypal-inbox-flow.php als Regressionstest ab — alles außer der IMAP-Verbindung.

Deploy-Ablauf für ein Update

  1. Lokal committen und pushen.
  2. FTP-Sync ausführen (Ausschlussliste greift automatisch).
  3. Falls neue Migrationen dabei sind: Migrations-Task in Plesk einmal auslösen.
  4. Stichprobe: Login, Dashboard, eine Strich-Buchung, PDF-Export.

Ein echtes Rollback gibt es nicht; im Fehlerfall wird der vorherige Commit-Stand erneut hochgeladen. Migrationen sind nicht rückwärts lauffähig — vor einem Deploy mit Schemaänderungen daher immer ein frisches Backup ziehen.

Go-Live-Checkliste

Vor der Umstellung auf app.kaffeeliste.de:

Zuerst aus dem Projektverzeichnis ausführen:

php scripts/check-production-readiness.php

Der Check blockiert unter anderem bei fehlender B2C-Telefonnummer, unsicherer Basis-URL, fehlenden Zahlungs-/Abrechnungsschlüsseln, zu offenen Rechten der Secret-Dateien oder einer nicht angewendeten Legal-Migration.

  • Kompletter Funktionsdurchlauf auf Staging erfolgreich (Registrierung, Mailversand, Mitglieder, Striche, Einzahlungen, CSV-Import, PDF-Export, Jahresauswertung, Stripe im Testmodus)
  • Die sechs Abruf-Tests aus Schritt 3 liefern alle 403/404
  • display_errors produktiv aus, log_errors an
  • SPF/DKIM/DMARC gesetzt, Testmail landet nicht im Spam
  • Backup-Task läuft und ein Restore wurde einmal testweise eingespielt (docs/betrieb-backup-monitoring.md)
  • Stripe-Live-Keys und Produktiv-Webhook eingetragen
  • LEGAL_PHONE, LEGAL_EMAIL und LEGAL_TICKET_URL zeigen auf tatsächlich betreute Kontaktwege; Tickets werden werktags regelmäßig bearbeitet
  • Impressum, AGB, Datenschutz, AVV und Widerrufsbelehrung inhaltlich anwaltlich für B2B und B2C freigegeben
  • Kündigungs- und Widerrufsformular erzeugen beim Kunden und bei LEGAL_EMAIL eine E-Mail; die Textbestätigung lässt sich speichern
  • Offene Rechtserklärungen werden täglich im Back-Office geprüft; Rückzahlungen nach Widerruf werden manuell über Stripe fristgerecht bearbeitet (die Anwendung beendet das Abo, erstattet aber nicht automatisch)
  • Stripe Checkout läuft auf Deutsch, zeigt Endpreis und Monatslaufzeit; Test von Neubuchung, Tarifwechsel und Kündigung zum Periodenende
  • Webserver-Access-Logs werden spätestens nach sieben Tagen gelöscht oder anonymisiert; TLS/HSTS ist auf allen öffentlichen Domains aktiv
  • Täglicher Task php scripts/purge-ended-contracts.php ist eingerichtet; ein Vorlauf mit --dry-run wurde kontrolliert
  • Stündlicher Task php scripts/purge-expired-operational-data.php ist eingerichtet; ein Vorlauf mit --dry-run wurde kontrolliert
  • Frisches prod_-Schema ohne Testdaten; Staging verwendet ausschließlich test_

Bewusst offen

  • Content-Security-Policy: nicht gesetzt, weil die Templates durchgängig Inline-Styles nutzen. Begründung und Umfang in docs/m8-haertung.md.
  • Automatisiertes Deployment (CI/CD, Build-Schritt): nicht eingerichtet. Bei einer flachen PHP-App ohne Build-Prozess und einem einzelnen Deployer ist der FTP-Sync angemessen.
  • DataTables/ und js/: liegen im Repo, werden aber von keiner Seite mehr eingebunden. Sie sind per .htaccess gesperrt und können bei Gelegenheit entfernt werden.