Sync Gate v0.11: Zwei Bugs, die Self-Pair-Tests strukturell verschluckt haben

Sync Gate hatte bis heute ein ehrliches Loch in der Qualitätssicherung: alle Tests liefen als Self-Pair — die Site pairt sich mit sich selbst. Das war pragmatisch (kein zweiter Server nötig), aber es hat eine ganze Klasse von Bugs strukturell verschluckt. Genau die Klasse, die auftaucht, wenn zwei verschiedene WordPress-Instanzen miteinander reden.

Also für v0.11 zuerst das Test-Setup nachgezogen, das schon lange überfällig war. Zwei komplette WP-Instanzen (sga.test:8181 und sgb.test:8182) auf demselben Host, mit eigener Datenbank, eigenem Nginx-Server-Block, eigenem Docroot. Kein Docker — die Nginx- und MySQL-Instanz des Hosts nutzen, aber alles andere separat. Das gesamte Setup ist in tools/e2e/setup.sh als idempotentes Bash-Skript, 6 Tests, in ~10 Sekunden komplett grün.

Der erste Durchlauf hat zwei Bugs zutage gefördert, die Self-Pair niemals gesehen hätte. Beide waren real, beide sind gefixt, beide brauchen kein Migrations-Rezept.

Bug 1: Client konnte Server-Exclusion umgehen (SECURITY)

Sync Gate hat eine sensible Default-Exclusion-Liste für Tabellen, die niemals gesynct werden sollten: users, usermeta, WooCommerce-Orders, itdatex-eigene Tabellen. Sie wird zweimal geprüft — einmal vom Client vor dem Absetzen des Requests, einmal vom Server beim Empfang. So sollte es zumindest sein.

Test 06 hat versucht, das zu verifizieren: SGB ruft wp sync-gate pull users --pair-id=1 --force. Das --force uebersteuert die Client-seitige Exclusion — das ist gewollt, weil ein Server-Admin mit gutem Grund die eigene Users-Tabelle syncen wollen kann. Aber der Server, so das Design, sollte die Anfrage trotzdem ablehnen. Defense in depth.

Das war der Testfall. Ergebnis: der Pull ging durch. SGB hatte hinterher die Users-Tabelle von SGA. Nicht gut.

Ursache im Code:

// PullController.php (vor v0.11):
$force = ! empty( $params['force'] );
if ( ! $force && $this->exclusions->is_excluded( $short ) ) {
    return $this->err( 403, 'excluded', ... );
}

Der Server respektierte das Client-force-Flag. Damit war die zweite Verteidigungslinie effektiv keine Verteidigungslinie mehr, sondern eine Höflichkeit. Ein kompromittierter Pair-Partner konnte einfach "force": true im Body mitschicken und die Server-Exclusion umgehen.

Fix ist trivial: die $force-Bedingung raus. Der Server-Check ist absolut. Wer server-seitig die Users-Tabelle syncen will, muss die Server-Exclusion-Liste anpassen — nicht den Client-Flag setzen.

Das Muster war identisch im PushController. Beide gefixt.

Bug 2: meta.json korrupt bei parallelen Chunks

Test 04: SGB pusht ein 15-MB-Random-File an SGA. chunk_size=1 MB, chunk_concurrency=3. Das sind 15 Chunks in 5 Batches à 3 parallelen HTTP-Requests. Genau der Fall, den Push seit v0.5 kann.

Ergebnis: eine Warning: chunk offset=12582912 [400]: meta.json korrupt-Meldung mitten im Push, danach eine kaputte Ziel-Datei. Reproducible, aber nicht deterministisch — der Bug war ein Race, der bei Self-Pair-Tests strukturell verdeckt war (dort läuft Server und Client im selben PHP-Prozess, keine echte Parallelität am Server-Endpoint).

Der schuldige Code im ChunkStore::append_chunk:

// vor v0.11:
$meta = $this->load_meta( $session_id );    // liest ohne Lock
...
$fp = fopen( $meta_path, 'c+' );
flock( $fp, LOCK_EX );                       // lockt erst hier
...

load_meta() war ein file_get_contents ohne Lock. Der eigentliche Update-Zyklus (Lock, Read via stream_get_contents, Modify, ftruncate, Write, Unlock) war zwar geflockt. Aber zwischen dem ftruncate und dem fwrite im Update-Zyklus von Request A konnte Request B mit seinem ungelockten load_meta ankommen und die frisch getruncatete, aber noch nicht beschriebene meta.json als leeres Byte-Array lesen. json_decode('') = null. Exception.

Fix: der komplette read-modify-write-Zyklus läuft unter einem einzigen flock(LOCK_EX). Kein load_meta()-Call mehr aus append_chunk heraus; das Lesen wird ins geflockte File-Handle integriert. Der PullSessionStore aus v0.10 hatte das schon von Anfang an so, aber der ältere ChunkStore hatte das Legacy-Muster behalten.

Was das Setup nicht abdeckt

Cross-Site-Test heisst hier: zwei WP-Instanzen mit unterschiedlichen Domains und getrennten Datenbanken auf demselben Host. Das exerziert URL-Rewrite, Pair-Handshake, HMAC-Verifikation gegen unterschiedliche Absender-URLs, Diff auf unterschiedlichen Manifesten. Was es NICHT exerziert:

  • Echter Netzwerk-Abbruch: Der Pull-Resume-Test (Test 05) simuliert den Abbruch per Reflection — er ruft fetch_chunks_parallel direkt mit nur 3 von 10 Slices auf. Das testet die Persistenz-Logik, nicht das Verhalten bei mid-transfer TCP-Reset. Für den echten Case brauchts iptables -A OUTPUT-Manipulation, das steht auf der v0.12-Liste.
  • Grosse Files > 100 MB: Test 04 macht 15 MB, weil Docker-freie Tests auf shared Nginx laufen und Docroots im Home-Volume liegen. Ich hab keine Lust auf Random-Dateien >500 MB, wenn’s kein echter Kunde-Case ist. Später ggf. als optionaler Long-Test.
  • Veränderte Datei während eines Pulls: würde man bewusst mid-transfer ändern, um zu sehen ob das final total_sha256 matcht. Nice-to-have, aber nicht kritisch — der Client wirft dann eh eine Exception.

Warum das Setup native ohne Docker läuft

Ich hatte kurz überlegt, ob docker-compose mit zwei WP-Containern der bessere Weg wäre. Es hätte den Vorteil, dass das Setup ephemeral ist — docker compose down -v und alles ist weg, kein Rest in /etc/hosts und kein DB-User, der herumlungert.

Aber: Docker war auf dem Zielserver nicht installiert. Und die Site selbst läuft auf demselben Server. Auf einem Live-System einfach mal Docker draufwerfen ist eine grosse Aktion mit langem Impact. Native Setup nutzt Nginx und MySQL, die eh schon laufen, addet nur zwei Docroots, zwei DBs, zwei Nginx-Server-Blöcke und zwei /etc/hosts-Zeilen. Alles reversibel mit einem teardown.sh-Aufruf.

Trade-Off ist bewusst: weniger reproducible-across-machines, mehr pragmatic-hier. Für CI-Runs wäre Docker der richtige Ansatz — dann ist das Ziel eine andere Maschine, kein Live-Server.

Was du davon merkst

Wenn du Sync Gate produktiv einsetzt und server-seitig Users oder WooCommerce-Bestellungen ausgeschlossen hattest: bitte auf v0.11.0 upgraden. Vorher konnte ein kompromittierter Pair-Partner via --force die Exclusion umgehen.

Wenn du chunk_concurrency > 1 gesetzt hattest und gelegentlich “meta.json korrupt”-Fehler mid-Push gesehen hast: auch das ist gefixt.

Wenn du in beiden Fällen nichts gemerkt hast: v0.11 ändert nichts an deinem Verhalten. Kein DB-Schema, keine Migration, keine Setting-Änderungen.

Details im GitHub-Release oder auf der Plugin-Seite.