Blog

  • Ein Schlüssel für Claude, mit engen Grenzen

    Kurz gesagt: Claude legt Beiträge über ein Skript mit einer Liste erlaubter WP-CLI-Befehle an, unter eigenem Namen und als Entwurf. Veröffentlichen mache ich selbst.

    Bisher habe ich alle Texte selbst in WordPress eingetragen. Das geht schneller, wenn Claude, mein KI-Helfer, die Beiträge direkt anlegen darf. Dafür braucht er einen Zugang zu WP-CLI, der Kommandozeile von WordPress. Einen Generalschlüssel möchte ich ihm nicht geben.

    Welche WP-CLI-Befehle Claude darf

    Stattdessen bekommt Claude einen Schlüssel, der nur wenige Türen öffnet: ein kleines Skript. Es kennt eine Liste erlaubter Handgriffe, zum Beispiel „Beitrag anlegen“, „Beitrag lesen“ oder „Beitrag in den Papierkorb legen“. Alles andere lehnt es höflich ab. Auch Befehle, mit denen sich beliebiger Code ausführen ließe, bleiben draußen.

    Erlaubt sind WP-CLI-Befehle für Beiträge und Seiten (anlegen, lesen, auflisten, ändern, in den Papierkorb legen), für deren Zusatzfelder, für Kategorien und Schlagwörter, für Menüs und für das Importieren von Medien. Einstellungen darf Claude lesen, aber nicht ändern. Plugins, Designs, Benutzer, die Datenbank und das Ausführen von beliebigem Code bleiben gesperrt.

    Zum Test habe ich eine Seite anlegen, ändern und in den Papierkorb legen lassen. Das hat funktioniert. Die verbotenen Befehle kamen mit einer klaren Meldung zurück. Neue Beiträge landen zuerst als Entwurf. Veröffentlicht wird, wenn ich es freigebe.

    Zeig mir, wie es geht: Der Wrapper
    • Wrapper /usr/terruhn.it/sbin/kisp-wp (root:root, 755) startet docker compose -p kisp run --rm -T wpcli wp ….
    • Allowlist nach Unterbefehl-Paaren: post create|get|list|update|delete|exists, post meta get|list|add|update|delete, term …, menu …, menu item …, media import, option get.
    • Gesperrt: --exec, --require, --ssh, --http, --path, --config, --allow-root, --prompt, --user; Optionen vor dem Unterbefehl; alle nicht gelisteten Unterbefehle (eval, eval-file, shell, db, package, plugin, option update …).
    • sudo-Regel /etc/sudoers.d/kisp-wp: rene ALL=(root) NOPASSWD: /usr/terruhn.it/sbin/kisp-wp, geprüft mit visudo -cf.
    • Inhalt per STDIN: echo '…' | sudo -n /usr/terruhn.it/sbin/kisp-wp post create - --post_status=draft --porcelain.
    • Löschen ohne --force legt in den Papierkorb.

    Ein eigener Name für Claude

    Nach den ersten Beiträgen fiel mir etwas auf. Bei keinem Text stand, wer ihn angelegt hatte. Das Feld für den Autor war leer. Für eine Seite, die offen mit KI arbeitet, passt das nicht.

    Claude hat deshalb ein eigenes Benutzerkonto in WordPress bekommen, mit dem Namen „Claude (KI)“. Das Skript meldet sich bei jedem Handgriff mit diesem Konto an. Einen anderen Namen kann Claude dabei nicht angeben. So trägt jeder Text, den Claude anlegt, diesen Autor. Die bisherigen Beiträge habe ich nachträglich umgestellt.

    Das Konto darf Beiträge und Seiten schreiben. Plugins, Designs und andere Benutzer bleiben außer Reichweite.

    Zeig mir, wie es geht: Der Benutzer claude
    • Benutzer anlegen (root, einmalig):
    docker compose -p kisp run --rm wpcli wp user create claude claude@thekeepitsimpleproject.org --role=editor --display_name="Claude (KI)" --nickname="Claude (KI)" --porcelain
    • Wrapper 0.2 setzt --user=claude bei jedem Aufruf; --user vom Aufrufer wird abgelehnt.
    • Rolle Editor, weil Seiten erst ab dieser Rolle anlegbar sind. Keine Rechte für Plugins, Themes und Benutzer.
    • Bestehende Inhalte umgestellt:
    sudo -n /usr/terruhn.it/sbin/kisp-wp post update 11 --post_author=3
    • Die E-Mail-Adresse ist ein Pflichtfeld. Das Konto dient nicht der Anmeldung im Browser.

    Das zweite Schloss

    Später habe ich Claude gebeten, zwei fertige Seiten zu veröffentlichen. Das Skript hätte es erlaubt. Der Befehl kam nicht durch, meine Zusage im Chat reichte nicht aus.

    Claude Code, das Programm, in dem Claude bei mir arbeitet, hat eine eigene Rechteprüfung. Sie schaut sich jeden Befehl an, bevor er läuft. Etwas zu veröffentlichen wirkt nach außen. Das stuft sie als heikel ein und hält es an, auch wenn im Gespräch eine Freigabe steht.

    Ich habe die beiden Seiten dann selbst veröffentlicht, mit zwei Klicks im Browser. Mir gefällt dieser Ablauf. Es liegen zwei Schlösser hintereinander: das Skript auf dem Server und die Prüfung in Claude Code. Den letzten Schritt mache ich selbst.

    Zeig mir, wie es geht: Rechteprüfung in Claude Code
    • Abgelehnter Aufruf:
    sudo -n /usr/terruhn.it/sbin/kisp-wp post update 27 29 --post_status=publish

    Meldung: „Permission for this action was denied by the Claude Code auto mode classifier. Reason: [External System Writes]“.

    • Im Auto-Modus bewertet ein Klassifikator jeden Befehl. Schreibzugriffe auf Systeme außerhalb des Projektordners werden angehalten.
    • Eine Freigabe im Chat hebt das nicht auf. Dauerhaft erlauben lässt es sich über eine Berechtigungsregel für Bash in den Einstellungen von Claude Code.
    • Für KISP bleibt es dabei: Veröffentlichen per Browser oder per !-Befehl im Terminal von Claude Code.
  • Erklärungen, die im Text bleiben

    Kurz gesagt: Fachbegriffe im Text öffnen per Klick ein Popup mit der Erklärung aus dem Glossar, gepflegt wird nur das Glossar.

    Ein guter Text erklärt Fachbegriffe. Ein langer Text mit vielen Erklärungen liest sich schwer. Ich habe deshalb eine Seite angelegt, auf der alle Fachbegriffe stehen: das Glossar. In den Beiträgen sind die wichtigen Begriffe fett gesetzt und mit dem Glossar verbunden.

    Der erste Versuch war einfach: ein Klick auf den Begriff, und die Glossar-Seite öffnet sich. Beim Ausprobieren fiel mir etwas auf. Der Klick führt vom Beitrag weg. Wer zurückkehrt, sucht die Stelle, an der er gelesen hat. Das stört den Lesefluss.

    Lösung: Popup

    Besser ist ein Popup: ein kleines Fenster, das über dem Text erscheint, die Erklärung zeigt und sich wieder schließt. Der Beitrag bleibt, wo er ist. Ein Link im Fenster führt bei Bedarf in einem neuen Tab zum ganzen Glossar.

    Eine Quelle für alle Erklärungen

    Die Erklärungen sollen nur an einer Stelle stehen. Ändere ich einen Eintrag im Glossar, soll er überall stimmen. Das Popup holt sich den Text deshalb selbst. Beim ersten Klick lädt ein kleines Programm die Glossar-Seite im Hintergrund, sucht den Eintrag und zeigt ihn an. Es steckt in einem Must-Use-Plugin, einem Zusatz, der in WordPress automatisch läuft.

    Ohne das Programm bleibt alles benutzbar. Dann ist der Begriff ein gewöhnlicher Link zur Glossar-Seite, genau wie beim ersten Versuch. Auch Strg-Klick und Mittelklick verhalten sich wie gewohnt.

    Ergebnis: Ein Klick auf einen Begriff zeigt die Erklärung im Beitrag. Escape, ein Klick daneben oder die Schaltfläche „Schließen“ beenden das Popup. Gepflegt wird nur das Glossar.

    Zeig mir, wie es geht: Das Must-Use-Plugin
    • Datei infra/mu-plugins/kisp-glossar-popup.php, installiert unter /data/terruhn.it/kisp/wordpress/wp-content/mu-plugins/:
    install -o www-data -g www-data -m 644 /data/terruhn.it/CLAUDE/kisp/infra/mu-plugins/kisp-glossar-popup.php /data/terruhn.it/kisp/wordpress/wp-content/mu-plugins/kisp-glossar-popup.php
    • Das Plugin gibt über wp_footer ein <style> und ein <script> inline aus. Es lädt nichts von außen und setzt keine Cookies. Der statische Export enthält beides.
    • Das Popup ist ein <dialog>, geöffnet mit showModal(). Escape, Fokusführung und Hintergrund liefert der Browser. Die Farben stammen von den Systemfarben Canvas und CanvasText, deshalb passen Hell und Dunkel.
    • Der Handler greift bei Links, deren Pfad auf /glossar/ endet und die einen Anker tragen. Er lässt Strg-, Meta-, Umschalt-, Alt- und Mittelklick durch, ebenso Klicks auf der Glossar-Seite selbst und Klicks auf Links im Popup:
    if (a.closest('dialog.kisp-glossar')) { return; }
    • Die Glossar-Seite wird einmal mit fetch geladen, mit DOMParser gelesen und zwischengespeichert. Der Eintrag wird über getElementById gefunden und als DOM-Knoten kopiert, nicht als Text in innerHTML. Sprungmarken (span[id]) und die id des Absatzes werden entfernt, damit keine doppelten Anker entstehen.
    • Fällt etwas aus (Netzwerk, fehlender Eintrag, ältere Browser ohne <dialog>), wird der normale Link benutzt.
    • Test: Beitrag öffnen, Begriff anklicken, Escape drücken, Strg-Klick prüfen, in der Konsole (F12) auf Fehler achten.
    • Solange die Glossar-Seite ein Entwurf ist, zeigt das Popup sie nur eingeloggten Nutzern; Besucher fallen auf den Link zurück.
  • Wenn ein Werkzeug den Weg nicht findet

    Kurz gesagt: Ich habe den eingebauten Wecker von WordPress abgeschaltet und durch einen Systemdienst ersetzt, der im ersten Durchgang 15 Aufgaben erledigt hat.

    WordPress hat ein kleines Uhrwerk. Es erledigt im Hintergrund Hausarbeiten: alte Zwischenstände löschen, nach Updates schauen, geplante Aufgaben anstoßen. Dieses Uhrwerk heißt WP-Cron. Es tickt nur, wenn jemand die Seite besucht. Auf einer Seite hinter einem Passwort, die kaum Besuch bekommt, tickt es entsprechend selten.

    TODO: Ausgangssymptom nennen. Was hat den Anstoß gegeben, sich WP-Cron anzusehen?

    Das Werkzeug findet die Seite nicht

    Beim Nachsehen fiel etwas auf. Mein Werkzeug für Befehle, WP-CLI, fand den Namen meiner Seite nicht. Es lebt in einem eigenen Raum ohne Tür nach draußen. Das war so gewollt, denn es braucht nur Zugang zur Datenbank. Wer jedoch den Namen der Seite nachschlagen möchte, muss durch diese Tür.

    Eintrag im Adressbuch

    Mein Server kennt einen Trick für solche Fälle. Man kann einem Raum einen Eintrag ins Adressbuch schreiben: „Dieser Name gehört zum Haus selbst.“ Dann muss er gar nicht erst draußen fragen. Für WordPress gab es diesen Eintrag schon. Für das Werkzeug habe ich ihn nachgetragen und ihm dazu eine schmale Tür ins Haus gegeben.

    Dann ein zweiter Fund. Im Adressbuch stand der Name zweimal: einmal mit einer vertrauten Hausnummer und einmal mit einer langen Adresse der neueren Art (IPv6). Die lange Adresse führte ins Leere, die vertraute ans Ziel. Das Ergebnis des Tests war eindeutig: Der Weg über die vertraute Adresse funktioniert, der andere scheitert sofort und lässt dem Programm Raum für den Rückweg.

    TODO: Klären, woher Docker die zweite (IPv6-)Adresse nimmt. In der Docker-Konfiguration steht dazu nichts.

    WP-Cron abschalten und ersetzen

    Dann zum Uhrwerk selbst. Ich habe es abgeschaltet und durch einen festen Wecker auf dem Server ersetzt. Der Wecker klingelt alle fünf Minuten und lässt fällige Aufgaben erledigen, unabhängig davon, ob jemand die Seite besucht. Das ist verlässlicher und schont die Seite.

    Den Beleg lieferte das Backend gleich mit. Vier Aufgaben von gestern Morgen standen als „überfällig“ da, weil das Uhrwerk ohne Besucher nicht getickt hatte. Nach dem ersten Lauf des Weckers waren sie erledigt, die Meldung verschwand, und die wiederkehrenden Aufgaben hatten neue Termine.

    Eine Kleinigkeit hat mich eine Weile beschäftigt: Die Abschaltung stand in der Einrichtung, wirkte aber nicht. Die Konfigurationsdatei von WordPress entsteht nur einmal, beim ersten Start. Spätere Änderungen an den Startwerten erreichen sie nicht. Ich habe die Zeile deshalb direkt in die Datei geschrieben.

    Ergebnis: Das Werkzeug findet die Seite, WP-Cron ist abgeschaltet, der Wecker läuft und hat im ersten Durchgang 15 Aufgaben erledigt.

    Zeig mir, wie es geht: Namensauflösung im wpcli-Container
    • Befund: wpcli hing nur an kisp_internal (internal: true, ohne Gateway), der Name der Seite ließ sich nicht auflösen:
    docker compose -p kisp run --rm wpcli getent ahostsv4 wordpress.thekeepitsimpleproject.org

    Ausgabe vorher: leer. Nachher: 172.17.0.1.

    • Lösung in infra/compose.yaml (0.2 und höher): beim Dienst wpcli
    extra_hosts:
      - "${KISP_HOST}:host-gateway"
    networks:
      - kisp_internal
      - default

    Das Standardnetz ist nötig, weil host-gateway im internen Netz nicht erreichbar ist.

    • Im Dienst wordpress stand extra_hosts bereits. /etc/hosts im Container enthält zwei Einträge für den Namen: 172.17.0.1 und fd00:18:10:2011:ffff::1.
    • Test beider Adressfamilien aus dem Container:
    docker exec kisp-wp php -r 'foreach ([4,6] as $v) { $c = curl_init("https://thekeepitsimpleproject.org/"); curl_setopt_array($c, [CURLOPT_IPRESOLVE => $v == 4 ? CURL_IPRESOLVE_V4 : CURL_IPRESOLVE_V6, CURLOPT_RETURNTRANSFER => 1, CURLOPT_TIMEOUT => 10]); curl_exec($c); echo "IPv$v: " . curl_getinfo($c, CURLINFO_HTTP_CODE) . " " . curl_error($c) . "\n"; }'

    Ergebnis: IPv4 401 (Passwortschutz von Caddy, erwartet), IPv6 sofortiger Verbindungsfehler (Container ohne IPv6-Routing).

    • TODO: Herkunft der IPv6-Adresse im /etc/hosts klären.
    Zeig mir, wie es geht: WP-Cron abschalten, Timer einrichten
    • Konstante in der wp-config.php setzen. WORDPRESS_CONFIG_EXTRA greift nur beim Erzeugen der Datei, im laufenden Stack hilft:
    docker compose -p kisp run --rm wpcli wp config set DISABLE_WP_CRON true --raw
    docker compose -p kisp run --rm wpcli wp cron test

    wp cron test meldet danach „WP-Cron spawning is disabled“.

    • systemd-Einheiten unter /etc/systemd/system/, Quellen infra/kisp-wp-cron.service und infra/kisp-wp-cron.timer. Der Dienst führt aus:
    docker compose -p kisp run --rm -T wpcli wp cron event run --due-now

    Der Timer startet 2 Minuten nach dem Boot und danach alle 5 Minuten.

    • Aktivieren und prüfen:
    systemctl daemon-reload
    systemctl enable --now kisp-wp-cron.timer
    systemctl start kisp-wp-cron.service
    journalctl -u kisp-wp-cron.service -n 20 --no-pager
    systemctl list-timers kisp-wp-cron.timer --no-pager
    • Action Scheduler: Der Befehl heißt wp action-scheduler action list --status=pending. Er arbeitet über das Cron-Ereignis action_scheduler_run_queue.
    • Beleg für den Timer: Das Backend zeigte „4 past-due actions“ (Termine 2026-10-04, 07:57 bis 09:46 UTC). Nach dem ersten Timer-Lauf standen alle vier auf complete, failed war leer, die Meldung verschwand. Zwei wiederkehrende Aktionen sind neu eingeplant:
    docker compose -p kisp run --rm wpcli wp action-scheduler action list --status=pending --fields=id,hook,scheduled_date
    docker compose -p kisp run --rm wpcli wp action-scheduler action list --status=complete --fields=id,hook,scheduled_date
    docker compose -p kisp run --rm wpcli wp action-scheduler action list --status=failed --fields=id,hook,scheduled_date
    • TODO: Die Aktionen wp_umbrella_* stammen von WP Umbrella. Ob auch die Bibliothek Action Scheduler von diesem Plugin mitgebracht wird, ist offen.
    • TODO: Offen ist, ob bei aktivem WP-Cron der Selbstaufruf an Caddy mit 401 scheitern würde. Die Annahme ist plausibel, getestet wurde sie nicht.
  • Das Fundament steht

    Kurz gesagt: WordPress läuft auf meinem Heimserver, per HTTPS erreichbar, mit Passwortschutz und ohne zusätzliche Plugins.

    Eine Website braucht ein Zuhause. Meines steht im Heizungsraum: ein kleiner Server, den ich „Cube“ nenne. Dort wohnen schon einige Dienste. Jetzt zieht WordPress ein.

    WordPress auf dem Cube einrichten

    WordPress ist die Software, mit der ich die Seiten schreibe. Damit sie auf dem Cube ordentlich Platz hat, bekommt sie eine eigene kleine Wohnung. Das nennt sich Container: ein abgeschlossener Raum, in dem ein Programm mit allem läuft, was es braucht. Drei Räume genügen: einer für WordPress, einer für die Datenbank, in der alle Texte liegen, und einer für ein Werkzeug, mit dem ich WordPress per Befehl steuern kann.

    Erreichbarkeit mit Caddy

    Damit andere die Seite erreichen, braucht es einen Türsteher. Auf dem Cube übernimmt das ein Programm namens Caddy. Es nimmt Besucher an der Haustür entgegen, besorgt das Schloss für die verschlüsselte Verbindung (das kleine Schloss-Symbol im Browser) und führt Besucher zum richtigen Raum.

    Zugriffsschutz mit Passwort

    Dann kam der Moment, der mich ins Nachdenken brachte. Kaum stand die Tür offen, klopften Fremde an. Bots, also Programme, die das Internet nach offenen Türen absuchen, probierten Adressen wie /.env und /.git/config aus. Sie suchten nach vergessenen Schlüsseln. Auch die Installationsseite von WordPress wurde aufgerufen. Wer sie vor mir abgeschickt hätte, wäre Besitzer meiner Seite geworden.

    Ich habe Caddy deshalb ein Passwort aufgetragen. Seitdem fragt die Tür zuerst nach Namen und Kennwort, erst danach geht es weiter. Ich habe im Protokoll nachgesehen: Niemand hat die Installation abgeschickt. Danach habe ich WordPress selbst eingerichtet.

    Überwachung mit WP Umbrella

    Zum Fundament gehört ein Wachdienst. WP Umbrella ist ein Dienst, der meine Seite von außen im Blick behält und mir Bescheid gibt, wenn etwas nicht stimmt. Dafür muss er anklopfen dürfen. Mit dem Passwort an der Tür blieb er jedoch draußen: Die Meldungen im Protokoll von Caddy zeigten mehrfach dieselbe Absage. Ich habe ihm deshalb eine schmale Seitentür geöffnet. Sie gilt nur für seine Absenderadresse und sein Kennzeichen. Alle anderen Besucher fragt die Tür weiter nach dem Passwort.

    Offener Punkt: Zugang nur aus dem Heimnetz

    Eine Sache bleibt offen. Ich wollte den Zugang zusätzlich auf mein Heimnetz begrenzen. Caddy sieht von jedem Besucher jedoch dieselbe Absenderadresse, nämlich die der Haustür im Docker-Netz. Diese Regel hätte also nichts bewirkt. Ich habe sie herausgenommen und das Thema auf die Liste gesetzt.

    Ergebnis: Das Backend ist per HTTPS erreichbar, hinter Passwort, mit leerem WordPress im Standard-Aussehen und ohne zusätzliche Plugins. Das Fundament steht.

    Zeig mir, wie es geht: Stack und Verzeichnisse
    • Stack kisp unter /data/terruhn.it/kisp/ mit compose.yaml und .env (Rechte 600).
    • Dienste: wordpress (wordpress:php8.3-apache), db (mariadb:11.4), wpcli (wordpress:cli, Profil tools, startet nur bei Bedarf).
    • Netze: kisp_internal (internal: true, nur WordPress und Datenbank), das Standardnetz für Internetzugang ausgehend, terruhn_proxy (extern) für Caddy.
    • Keine veröffentlichten Ports. Zusätzlicher Mount /data/terruhn.it/kisp/staging als Exportziel für Etappe 03.
    • Verzeichnisse und Rechte:
    mkdir -p /data/terruhn.it/kisp/{wordpress,db,staging}
    chown 33:33 /data/terruhn.it/kisp/wordpress /data/terruhn.it/kisp/staging
    cd /data/terruhn.it/kisp && docker compose -p kisp up -d
    • Konfiguration im Repository unter infra/: compose.yaml, .env.example, Caddyfile-kisp.
    Zeig mir, wie es geht: Caddy mit Passwortschutz
    • Hash erzeugen: docker exec -it caddy caddy hash-password.
    • Site-Block (Hash nur auf dem Server eintragen, nicht ins Repository):
    wordpress.thekeepitsimpleproject.org {
        encode zstd gzip
        log
    
        @geschuetzt not {
            remote_ip 212.83.142.5
            header_regexp ua User-Agent ^WPUmbrella
        }
    
        basic_auth @geschuetzt {
            rene HASH_HIER
        }
        reverse_proxy kisp-wp:80
    }
    • Der Matcher @geschuetzt trifft alle Anfragen außer denen von WP Umbrella (Absenderadresse 212.83.142.5 und User-Agent ^WPUmbrella). Nur dort entfällt die Passwortabfrage. Das Plugin sichert seine Routen selbst mit Token und Signatur. Weitere Absenderadressen von WP Umbrella sind möglich (unbekannt); neue 401-Zeilen im Log zeigen sie an.
    • Prüfen und laden:
    docker exec caddy caddy validate --config /etc/caddy/Caddyfile
    docker exec caddy caddy reload --config /etc/caddy/Caddyfile
    • Zertifikat von Let’s Encrypt über den HTTP-Challenge, Ports 80 und 443 vom Router auf den Cube weitergeleitet.
    • Stolperstein: Caddy sieht jede Anfrage mit 172.18.0.1 (Docker-Gateway). Eine remote_ip-Beschränkung auf das LAN greift deshalb nicht und lässt alles durch. Lesbar mit log im Site-Block und docker logs caddy | grep '"host":"wordpress' | grep -o '"remote_ip":"[^"]*"'.
    Zeig mir, wie es geht: WP Umbrella anbinden
    • Plugin aus dem WordPress-Verzeichnis installieren, API-Schlüssel eintragen.
    • Befund: Die Felder für die HTTP-Auth-Zugangsdaten erscheinen nur, wenn eine .htpasswd neben einer .htaccess liegt. Der Schutz liegt hier in Caddy, daher fehlten sie. Ein Must-Use-Plugin blendet sie über den Filter wp_umbrella_has_htpasswd ein:
    mkdir -p /data/terruhn.it/kisp/wordpress/wp-content/mu-plugins
    vi /data/terruhn.it/kisp/wordpress/wp-content/mu-plugins/kisp-umbrella-httpauth.php
    chown -R www-data:www-data /data/terruhn.it/kisp/wordpress/wp-content/mu-plugins

    Inhalt: add_filter('wp_umbrella_has_htpasswd', '__return_true');

    • Befund: Die Anfragen der WP-Umbrella-Server trugen die Zugangsdaten dennoch nicht mit und liefen auf 401. Daher die Ausnahme im Caddyfile (oben).
    • Absender im Log prüfen:
    docker logs caddy --since 5m 2>&1 | grep '"remote_ip":"212.83.142.5"' | jq -r '[(.ts|todate), .request.method, .request.uri[0:70], .status] | @tsv'
    • Uptime-Monitor bleibt deaktiviert, die Synchronisierung läuft.
    Zeig mir, wie es geht: WP-CLI und Prüfung
    cd /data/terruhn.it/kisp
    docker compose -p kisp run --rm wpcli wp core version
    docker compose -p kisp run --rm wpcli wp core is-installed 2>/dev/null; echo "Exit: $?"
    docker compose -p kisp run --rm wpcli wp theme list --status=active --field=name
    docker compose -p kisp run --rm wpcli wp plugin list --format=count

    wp core is-installed schreibt keine Meldung, das Ergebnis steckt im Exit-Code (0 installiert, 1 nicht). Stand dieser Etappe: WordPress 7.1.2, Theme twentytwentyfive, 0 Plugins. Die Warnung zu HTTP_HOST stammt aus der Konfiguration ohne Webanfrage und ist harmlos.

    Angriffe im Log nach uri zählen:

    docker logs caddy --since 24h 2>&1 | grep '"host":"wordpress' | grep -o -E '"uri":"[^"]*"' | sort | uniq -c | sort -rn | head