From 723680b7decdbfa27dc908a89bbc0c0a723dc7f7 Mon Sep 17 00:00:00 2001 From: Umut Kantarci Date: Wed, 14 Oct 2026 10:43:13 +0200 Subject: [PATCH] Add the central site registry and remote updates Every installation now reports address and version to sites.kant-it.de every 15 minutes and picks up update orders there (ADR 123). Reports and answers are HMAC-signed with a per-installation key sent on first contact; an order can only install a published release with checksum. The next request after the file swap runs the migrations, so a remote update finishes without anyone in the admin. The platform itself lives in zentrale/ (plain PHP + SQLite, not part of the release archive): overview with outdated, silent and unassigned installations, single and bulk updates, per-site detail and history. Co-Authored-By: Claude Opus 5.5 --- .claude/launch.json | 6 + .env.example | 3 + .gitignore | 5 + README.md | 2 + config/services.yaml | 18 + docs/ARCHITECTURE.md | 20 +- docs/STATUS.md | 12 + docs/UPDATE.md | 4 + scripts/build-release.php | 2 +- scripts/zentrale-check.php | 109 ++++++ src/Controller/SystemController.php | 26 +- .../RemoteUpdateFinishSubscriber.php | 73 ++++ src/Ops/ArchiveUpdate.php | 15 + src/Ops/Funktionspruefung.php | 38 ++ src/Ops/Tasks/ZentraleTask.php | 173 +++++++++ src/Ops/Zentrale.php | 287 +++++++++++++++ tests/ArchiveUpdateTest.php | 112 ++++++ tests/TaskScreenTest.php | 4 +- zentrale/.htaccess | 15 + zentrale/README.md | 78 ++++ zentrale/app/.htaccess | 1 + zentrale/app/Ansicht.php | 57 +++ zentrale/app/Anwendung.php | 336 ++++++++++++++++++ zentrale/app/Api.php | 142 ++++++++ zentrale/app/Bestand.php | 151 ++++++++ zentrale/app/Datenbank.php | 97 +++++ zentrale/app/Fassungen.php | 70 ++++ zentrale/app/Konten.php | 67 ++++ zentrale/app/vorlagen/anmelden.php | 13 + zentrale/app/vorlagen/einrichten.php | 19 + zentrale/app/vorlagen/fehlt.php | 5 + zentrale/app/vorlagen/installation.php | 100 ++++++ zentrale/app/vorlagen/rahmen.php | 33 ++ zentrale/app/vorlagen/uebersicht.php | 69 ++++ zentrale/config.example.php | 13 + zentrale/public/.htaccess | 15 + zentrale/public/app.css | 159 +++++++++ zentrale/public/index.php | 32 ++ zentrale/var/.htaccess | 1 + 39 files changed, 2355 insertions(+), 27 deletions(-) create mode 100644 scripts/zentrale-check.php create mode 100644 src/EventSubscriber/RemoteUpdateFinishSubscriber.php create mode 100644 src/Ops/Funktionspruefung.php create mode 100644 src/Ops/Tasks/ZentraleTask.php create mode 100644 src/Ops/Zentrale.php create mode 100644 zentrale/.htaccess create mode 100644 zentrale/README.md create mode 100644 zentrale/app/.htaccess create mode 100644 zentrale/app/Ansicht.php create mode 100644 zentrale/app/Anwendung.php create mode 100644 zentrale/app/Api.php create mode 100644 zentrale/app/Bestand.php create mode 100644 zentrale/app/Datenbank.php create mode 100644 zentrale/app/Fassungen.php create mode 100644 zentrale/app/Konten.php create mode 100644 zentrale/app/vorlagen/anmelden.php create mode 100644 zentrale/app/vorlagen/einrichten.php create mode 100644 zentrale/app/vorlagen/fehlt.php create mode 100644 zentrale/app/vorlagen/installation.php create mode 100644 zentrale/app/vorlagen/rahmen.php create mode 100644 zentrale/app/vorlagen/uebersicht.php create mode 100644 zentrale/config.example.php create mode 100644 zentrale/public/.htaccess create mode 100644 zentrale/public/app.css create mode 100644 zentrale/public/index.php create mode 100644 zentrale/var/.htaccess diff --git a/.claude/launch.json b/.claude/launch.json index 2643ecf..fbfe8a4 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -228,6 +228,12 @@ "$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/ebi-immobilien.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/ebi-immobilien-media'; $env:SITE_ORIGIN='http://127.0.0.1:8107'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8107 -t public bin/dev-router.php" ], "port": 8107 + }, + { + "name": "zentrale", + "runtimeExecutable": "C:/php84/php.exe", + "runtimeArgs": ["-d", "curl.cainfo=C:/Program Files/Git/mingw64/etc/ssl/certs/ca-bundle.crt", "-S", "127.0.0.1:8115", "-t", "zentrale/public", "zentrale/public/index.php"], + "port": 8115 } ] } diff --git a/.env.example b/.env.example index 80276f3..d296945 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,6 @@ MAILER_DSN=sendmail://default # Die Adresse der Verwaltung. Ohne Eintrag: /admin. Aenderbar mit # `php bin/console cms:admin:path ` - der Befehl schreibt sie nach .env. #ADMIN_PATH=admin +# Die Zentrale, bei der sich diese Installation mit Adresse und Fassung meldet und von der sie +# Updates annimmt. Ohne Eintrag: https://sites.kant-it.de. Abschalten mit ZENTRALE_URL=aus. +#ZENTRALE_URL=https://sites.kant-it.de diff --git a/.gitignore b/.gitignore index be56e36..2abf916 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,8 @@ # damit auf einem frischen Server nichts fehlt und nichts von Google geladen wird. Andere # Installationen holen ihre eigenen mit `cms:fonts:fetch` - deren Dateien gehoeren nicht hierher. /public/assets/build/ + +# Zentrale (sites.kant-it.de): Konfiguration und Daten gehoeren dem Server. +/zentrale/config.php +/zentrale/var/* +!/zentrale/var/.htaccess diff --git a/README.md b/README.md index c005b4a..ef61bbe 100644 --- a/README.md +++ b/README.md @@ -310,6 +310,8 @@ Ein JSON-Dokument lässt sich zusätzlich lokal über `php bin/console cms:save- Sicherung, geplante Veröffentlichung, Statistik verdichten, Updateprüfung und Aufräumen laufen über einen eingebauten Plan. Auf geteiltem Hosting gibt es oft keinen Cronjob – deshalb stößt hier **die Anfrage** die Arbeit an, nach der ausgelieferten Antwort (`kernel.terminate`), sodass kein Besucher darauf wartet. Der Bildschirm **Verwaltung → Aufgaben** zeigt Plan, letztes Ergebnis und Protokoll und schaltet jede Aufgabe einzeln. +Die Aufgabe „Bei der Zentrale melden“ schickt alle 15 Minuten Adresse und Fassung an `https://sites.kant-it.de` und holt dort angestoßene Updates ab (ADR 123, `zentrale/README.md`). Abschalten mit `ZENTRALE_URL=aus` in `.env`; Installationen mit lokalem `SITE_ORIGIN` melden nie. Protokollprüfung: `php scripts/zentrale-check.php`. + Vier Auslöser, in aufsteigender Verlässlichkeit: 1. **Besuch** – jeder Seitenaufruf prüft, ob etwas fällig ist. Der Normalfall kostet eine gelesene Datei (`var/scheduler.json`) und keine Datenbankabfrage. Standardmäßig an. diff --git a/config/services.yaml b/config/services.yaml index a694471..0dc675a 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -14,6 +14,9 @@ parameters: kant.mailer_dsn: 'sendmail://default' # Die Einstellung, die die Verwaltung anzeigt und ausprobiert; Tests setzen sie fest. kant.mail_dsn: '%env(default:kant.mailer_dsn:MAILER_DSN)%' + # Die Zentrale, bei der sich jede Installation meldet (ADR 123). ZENTRALE_URL=aus schaltet ab. + kant.zentrale_default: 'https://sites.kant-it.de' + kant.zentrale_url: '%env(default:kant.zentrale_default:ZENTRALE_URL)%' services: _defaults: @@ -150,6 +153,21 @@ services: arguments: $tasks: !tagged_iterator kant.task $projectDir: '%kernel.project_dir%' + App\Ops\Zentrale: + arguments: + $root: '%kernel.project_dir%' + $url: '%kant.zentrale_url%' + $siteOrigin: '%env(SITE_ORIGIN)%' + $databaseUrl: '%env(DATABASE_URL)%' + $senden: null + App\Ops\Tasks\ZentraleTask: + arguments: + $siteOrigin: '%env(SITE_ORIGIN)%' + $projectDir: '%kernel.project_dir%' + $anstossen: null + App\EventSubscriber\RemoteUpdateFinishSubscriber: + arguments: + $projectDir: '%kernel.project_dir%' App\Ops\Tasks\CleanupTask: arguments: $projectDir: '%kernel.project_dir%' diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b94ba43..42ed14c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,24 @@ # Architekturentscheidungen -Stand: 12.10.2026. Arbeitstitel KantSites; Produktname offen. +Stand: 14.10.2026. Arbeitstitel KantSites; Produktname offen. + +## ADR 123 – Zentrale auf sites.kant-it.de: Meldung, Übersicht, Update aus der Ferne (14.10.2026) + +Anlass: Umut: „Alle KantSites melden sich bei meiner Hauptplattform an … ich kann sehen, welche Domains welche Versionen nutzen, und kann von remote Aktualisierung anstoßen.“ Entschieden: `sites.kant-it.de`, ohne Lizenzschlüssel, Updates einzeln und für alle. + +**Meldung (CMS).** Neue Aufgabe `zentrale` (`App\Ops\Tasks\ZentraleTask`, alle 15 Minuten) ruft `App\Ops\Zentrale::melden()` auf: `POST https://sites.kant-it.de/api/melden` mit Kennung, Adresse (`SITE_ORIGIN`), Fassung, PHP-Version, Datenbankart, Git/Archiv, Wartungszustand und Updatestand. Keine Inhalte, keine Besucherdaten, keine Zugangsdaten. Kennung und Schlüssel (32 bzw. 64 Hexzeichen, zufällig) liegen in `var/zentrale.json` und bleiben bei Updates erhalten. Abschalten mit `ZENTRALE_URL=aus`; Installationen mit lokalem `SITE_ORIGIN` (localhost, 127.0.0.1, `*.test`, `*.localhost`, `*.invalid`) melden nie. + +**Echtheit ohne Lizenzschlüssel.** Beim ersten Kontakt schickt die Installation ihren Schlüssel über HTTPS mit (Vertrauen beim ersten Kontakt). Danach ist jede Meldung mit HMAC-SHA256 über den Rohtext unterschrieben (`X-KantSites-Signatur`), jede Antwort der Zentrale ebenso; die Installation verwirft Antworten ohne gültige Unterschrift. Eine bekannte Kennung mit fremdem Schlüssel weist die Zentrale ab (403). Kennt die Zentrale eine Kennung nicht mehr (409), meldet sich die Installation einmal mit Schlüssel neu an. Ein Auftrag kann nur „Update auf eine höhere Fassung der Form 1.2.3“ sein und läuft über `ArchiveUpdate` mit Prüfsumme der Releaseseite – mehr kann auch eine kompromittierte Zentrale nicht auslösen. + +**Pull statt Push.** Die Zentrale verbindet sich nie mit einer Website. Sie merkt sich den Auftrag; die Website holt ihn mit ihrer nächsten Meldung ab. Keine Installation braucht dafür eine von außen erreichbare Steuerschnittstelle, und es geht auch hinter Firewalls. + +**Update aus der Ferne.** `ZentraleTask` führt `ArchiveUpdate::run()` aus (Sicherung, Archiv, Prüfsumme, Dateitausch, Wartung an) und markiert den offenen zweiten Schritt mit `fern` (`ArchiveUpdate::markiereFern()`). Den zweiten Schritt erledigt die nächste Anfrage mit dem neuen Code: `RemoteUpdateFinishSubscriber` (kernel.request, Priorität 48, also vor der Wartungsseite) nimmt die Updatesperre, führt `finish()` mit der Funktionsprüfung aus und gibt die Website frei oder schaltet zurück. Damit das nicht auf den nächsten Besucher wartet, ruft die Aufgabe die eigene Startseite einmal auf (bis 300 s). Das Ergebnis geht mit der nächsten Meldung an die Zentrale und wird nach der Quittung aus `var/zentrale.json` gelöscht. Git-Arbeitskopien lehnen Aufträge ab. Die Funktionsprüfung nach den Migrationen ist dafür aus `SystemController` in `App\Ops\Funktionspruefung` gewandert; die Verwaltung nutzt denselben Dienst. + +**Zentrale.** Eigener Ordner `zentrale/` im Repository, nicht Teil des Releasearchivs (`scripts/build-release.php` nimmt nur die aufgezählten Einträge). Reines PHP ab 8.1 mit SQLite (`var/zentrale.db`), ohne Framework und Composer, damit sie per FTP auf jedes Hosting passt. Anmeldung mit `password_hash`, Sitzungscookie `__Host-` mit SameSite=Strict, CSRF-Token auf jedem Formular, fünf Fehlversuche je IP in 15 Minuten, strenge CSP ohne Skripte, `noindex`. Das erste Konto legt an, wer den `einrichtungscode` aus `config.php` kennt. Die Übersicht zeigt Domain, Kunde, Fassung, letzte Meldung und Auftragsstand, zählt aktuelle, veraltete, verstummte (über 48 h) und nicht zugeordnete Installationen und bietet „Auf x.y.z“ je Zeile und „Alle aktualisieren“ mit Bestätigung (ohne verstummte, wartende und Git-Installationen). Die neueste Fassung kommt von derselben Releaseseite wie bei den Installationen, 30 Minuten zwischengespeichert. Fremde Nutzung erkennt man an „Unbekannt“ (kein Kunde zugeordnet) und am Hinweis „vorher …“ bei einer Kennung, die sich unter neuer Domain meldet. + +**Grenzen.** Wer den Code verändert und die Meldung ausbaut, bleibt unsichtbar; ohne Lizenzschlüssel ist das kein Schutz, nur eine Übersicht. Installationen vor 1.36.0 erscheinen erst nach einem Update von Hand. Erreicht eine Website sich selbst nicht, steht die Wartungsseite bis zum nächsten Besuch oder Cron-Aufruf. Die Zentrale speichert die IP-Adresse der letzten Meldung; das gehört in die Datenschutzerklärung beziehungsweise Auftragsverarbeitung mit den Kunden. + +**Folgen.** Keine Migration im CMS: `var/zentrale.json` entsteht beim ersten Lauf, die Aufgabe trägt sich über ihr Etikett selbst in `scheduled_task` ein. Das Schema der Zentrale migriert sich über `PRAGMA user_version` (`zentrale/app/Datenbank.php`). Protokollprüfung ohne Netz: `php scripts/zentrale-check.php`; der Ablauf mit Dateitausch und Abschluss steht in `tests/ArchiveUpdateTest.php`. ## ADR 122 – E-Mail-Versand als eigene Seite, Probe nicht mehr unter /.well-known (12.10.2026) diff --git a/docs/STATUS.md b/docs/STATUS.md index 2b9cc60..db0f1dc 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,5 +1,17 @@ # Projektstatus +## Zentrale sites.kant-it.de: Meldung und Update aus der Ferne (14.10.2026) + +**Anlass:** Umut: Alle KantSites sollen sich bei einer Hauptplattform melden; dort Domains und Fassungen sehen und Updates anstoßen. Entschieden: `sites.kant-it.de`, ohne Lizenzschlüssel, Updates einzeln und für alle; Umut lädt die Plattform selbst hoch. + +**Erledigt:** CMS: Aufgabe „Bei der Zentrale melden“ (`zentrale`, 15 min) mit `App\Ops\Zentrale` (HMAC-signierte Meldung, Schlüssel beim ersten Kontakt, Antwort wird geprüft), Auftrag „Update“ über `ArchiveUpdate`, Abschluss durch die nächste Anfrage (`RemoteUpdateFinishSubscriber`), Funktionsprüfung als Dienst `Funktionspruefung`. `ZENTRALE_URL` (Standard `https://sites.kant-it.de`, `aus` schaltet ab), lokale Adressen melden nie. Plattform in `zentrale/` (PHP 8.1+, SQLite, ohne Composer): Einrichten mit Code aus `config.php`, Anmeldung mit Sperre nach Fehlversuchen, Übersicht mit Zählern, „Unbekannt“ für Installationen ohne Kunde, Domainwechsel sichtbar, Update je Zeile und „Alle aktualisieren“ mit Bestätigung, Detailseite mit Stand, Kunde/Notiz, Auftragsverlauf, Zurückziehen und Entfernen. Anleitung: `zentrale/README.md`. ADR 123. + +**Geprüft:** PHPUnit `ArchiveUpdateTest` (16 Tests, darunter der ganze Fernablauf mit Dateitausch, Abschluss durch die Folgeanfrage und Ergebnis an die Zentrale; gefälschte Antwort; lokale Installationen melden nicht), `TaskScreenTest`, `SchedulerTest`, `SystemScreenTest` grün. `php scripts/zentrale-check.php`: 14/14 (CMS-Klasse gegen echte API: Anmeldung, Auftrag abholen, nicht doppelt, Ergebnis, 403 bei falscher Unterschrift und fremdem Schlüssel, Neuanmeldung, Domainwechsel). Plattform lokal im Browser (Port 8115, Demodaten): Einrichten, Anmelden, Übersicht mit echter neuester Fassung 1.35.1 von git.kant-it.de, Einzelauftrag, Zurückziehen, „Alle aktualisieren“, Detailseite, 375 px ohne Querscrollen, keine Konsolenfehler, CSP und Cookie-Flags. Nicht geprüft: echter Lauf über das Internet gegen sites.kant-it.de (gibt es noch nicht), Selbstaufruf auf einem echten Hosting, Lighthouse der Plattform (interne Seite, `noindex`). Volle PHPUnit-Kette: 685 Tests grün (ein Fund unterwegs: `zentrale` musste in die Ausschlussliste von `build-release.php`), php-cs-fixer ohne Befund. + +**Bekannte Lücken:** Installationen vor 1.36.0 melden sich erst nach einem Update von Hand. Ohne Lizenzschlüssel bleibt unsichtbar, wer die Meldung ausbaut. Die IP-Adresse der letzten Meldung wird gespeichert – Hinweis in Datenschutz/AV-Vertrag nötig. Lokal fehlt PHP 8.4 ein Zertifikatsspeicher; die Launch-Konfiguration `zentrale` setzt dafür `curl.cainfo` auf das Bündel von Git. + +**Nächster Schritt:** Release 1.36.0 bauen und veröffentlichen, `zentrale/` nach sites.kant-it.de hochladen (Docroot `public/`, HTTPS, `config.php`), erstes Konto anlegen, kant-it.de auf 1.36.0 heben und die erste Meldung sowie ein Fern-Update mit 1.36.1 prüfen. + ## Redesign-Entwurf EBI Immobilien, Karlsruhe (13.10.2026) **Anlass:** Umut: „einfacher Rebuild und Redesign von ebi-immobilien.de, soll vollendet aussehen, aber nicht übertrieben clean“. diff --git a/docs/UPDATE.md b/docs/UPDATE.md index c60162b..a878139 100644 --- a/docs/UPDATE.md +++ b/docs/UPDATE.md @@ -97,6 +97,10 @@ Der Weg für den einen Fall, in dem die Karte im Bildschirm „System" noch nich Das ist einmalig: Ab 1.4.3 kennt jede Installation den Weg über das Archiv, und dann entfällt das alles. Dann steht im selben Bildschirm die Karte „Update aus dem Releasearchiv", und die Schritte 2 bis 8 erledigt sie selbst. +## Der vierte Weg: aus der Zentrale + +Ab 1.36.0 meldet sich jede Installation bei `sites.kant-it.de` und holt dort angestoßene Updates ab. Es läuft derselbe Archivweg wie über den Knopf: Sicherung, Archiv mit Prüfsumme, Dateitausch, Migrationen mit dem neuen Code, Prüfung der Startseite, sonst Zurückschalten. Den zweiten Schritt erledigt die nächste Anfrage an die Website; die Aufgabe ruft dafür die eigene Startseite einmal auf. Git-Arbeitskopien lehnen Aufträge ab. Einzelheiten: ADR 123, `zentrale/README.md`. + ## Ein Release veröffentlichen `php scripts/build-release.php` baut auf einem Rechner mit Composer das Archiv – Anwendung plus `vendor/` ohne Entwicklungspakete – und schreibt die SHA-256-Summe in eine Kopie von `install.php`. Beides gehört anschließend an das Git-Release des passenden Tags: diff --git a/scripts/build-release.php b/scripts/build-release.php index b83f404..b5a768d 100644 --- a/scripts/build-release.php +++ b/scripts/build-release.php @@ -68,7 +68,7 @@ echo "Fassung {$version}\n"; // Einrichten keine Migrationsliste und bricht mit "configuration file does not exist" ab - genau // dort, wo eine frische Installation ihre Tabellen anlegen will. $include = ['bin', 'config', 'migrations', 'plugins', 'public', 'src', 'templates', 'composer.json', 'composer.lock', 'migrations.php', 'cli-config.php', 'VERSION', 'README.md', '.env.example', '.htaccess']; -$skip = ['.git', '.github', '.claude', '.gitattributes', '.gitignore', '.env.lock', '.php-cs-fixer.cache', 'node_modules', 'var', 'tests', 'scripts', 'reports', 'phpunit.xml.dist', '.php-cs-fixer.dist.php', 'package.json', 'package-lock.json', 'AGENTS.md', 'ENTWICKLUNGSPLAN.md', 'docs', 'composer.phar', '.venv', 'vendor', 'phpunit.cache', 'migrations.php.bak']; +$skip = ['.git', '.github', '.claude', '.gitattributes', '.gitignore', '.env.lock', '.php-cs-fixer.cache', 'node_modules', 'var', 'tests', 'scripts', 'reports', 'phpunit.xml.dist', '.php-cs-fixer.dist.php', 'package.json', 'package-lock.json', 'AGENTS.md', 'ENTWICKLUNGSPLAN.md', 'docs', 'composer.phar', '.venv', 'vendor', 'phpunit.cache', 'migrations.php.bak', 'zentrale']; $copy = static function (string $from, string $to) use (&$copy, $skip): int { $count = 0; diff --git a/scripts/zentrale-check.php b/scripts/zentrale-check.php new file mode 100644 index 0000000..b1b2d9f --- /dev/null +++ b/scripts/zentrale-check.php @@ -0,0 +1,109 @@ +melden($body, $kopf['X-KantSites-Signatur'] ?? '', '203.0.113.7'); + + return ['status' => $antwort['status'], 'body' => $antwort['body'], 'signatur' => (string) $antwort['signatur']]; +}; + +$installation = static function (string $wurzel, string $adresse, \Closure $senden): CmsZentrale { + $db = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $updater = new Updater($wurzel, 'sqlite:///:memory:', new CommandRunner(), $db); + + return new CmsZentrale($wurzel, $updater, new ArchiveUpdate($wurzel, $updater, new Backup($wurzel, 'sqlite:///:memory:', $wurzel.'/var/media', $db, new CommandRunner()), new Download()), 'https://sites.example', $adresse, 'sqlite:///x', $senden); +}; + +$site = $installation($tmp.'/site', 'https://kunde-eins.de', $ueberApi); +$erste = $site->melden(); +$pruefe(null === $erste['auftrag'], 'Erste Meldung wird angenommen, kein Auftrag'); +$zeile = $pdo->query('SELECT * FROM installation')->fetch(); +$pruefe('kunde-eins.de' === ($zeile['domain'] ?? null) && '1.35.1' === $zeile['fassung'], 'Domain und Fassung stehen in der Zentrale'); +$ich = json_decode((string) file_get_contents($tmp.'/site/var/zentrale.json'), true); +$pruefe(true === $ich['registriert'], 'Installation gilt danach als angemeldet'); + +$bestand = new Zentrale\Bestand($pdo); +$pruefe(null === $bestand->beauftragen($zeile['id'], '1.36.0'), 'Update auf 1.36.0 lässt sich beauftragen'); +$zweite = $site->melden(); +$pruefe('1.36.0' === ($zweite['auftrag']['fassung'] ?? null) && 'update' === $zweite['auftrag']['aktion'], 'Folgemeldung holt den Auftrag ab'); +$pruefe(null === $site->melden()['auftrag'], 'Ein abgeholter Auftrag kommt nicht zweimal'); +$pruefe(null !== $bestand->beauftragen($zeile['id'], '1.36.0'), 'Während ein Auftrag läuft, gibt es keinen zweiten'); + +$site->auftragAngenommen($zweite['auftrag']['id'], '1.36.0'); +file_put_contents($tmp.'/site/VERSION', "1.36.0\n"); +$site->auftragErledigt(true, 'Fassung 1.36.0 läuft.'); +$site->melden(); +$auftrag = $pdo->query('SELECT * FROM auftrag')->fetch(); +$pruefe(1 === (int) $auftrag['ok'] && null !== $auftrag['erledigt'], 'Ergebnis kommt in der Zentrale an'); +$pruefe('1.36.0' === $pdo->query('SELECT fassung FROM installation')->fetchColumn(), 'Neue Fassung ist gemeldet'); +$pruefe(null === $site->offenerAuftrag(), 'Die Installation vergisst den quittierten Auftrag'); + +// Eine fremde Antwort: anderer Schlüssel unterschreibt. +$gefaelscht = $installation($tmp.'/site', 'https://kunde-eins.de', static function (string $url, string $body) use ($ueberApi): array { + $echt = $ueberApi($url, $body, []); + + return [...$echt, 'signatur' => hash_hmac('sha256', $echt['body'], str_repeat('b', 64))]; +}); +try { + $gefaelscht->melden(); + $pruefe(false, 'Unsignierte Meldung wird abgewiesen'); +} catch (\RuntimeException $e) { + $pruefe(str_contains($e->getMessage(), '403'), 'Meldung ohne gültige Unterschrift weist die Zentrale mit 403 ab'); +} + +// Jemand übernimmt die Kennung mit eigenem Schlüssel. +$body = json_encode(['id' => $zeile['id'], 'adresse' => 'https://fremd.de', 'fassung' => '1.36.0', 'schluessel' => str_repeat('c', 64)]); +$fremd = $api->melden($body, hash_hmac('sha256', $body, str_repeat('c', 64)), '198.51.100.1'); +$pruefe(403 === $fremd['status'], 'Fremder Schlüssel für eine bekannte Kennung wird abgewiesen'); + +// Die Zentrale hat die Installation vergessen: Sie meldet sich mit Schlüssel neu an. +$pdo->exec('DELETE FROM installation'); +$site->melden(); +$pruefe(1 === (int) $pdo->query('SELECT COUNT(*) FROM installation')->fetchColumn(), 'Nach Verlust in der Zentrale meldet sich die Installation neu an'); + +// Umzug: dieselbe Kennung, andere Domain. +$umzug = $installation($tmp.'/site', 'https://www.kunde-zwei.de', $ueberApi); +$umzug->melden(); +$neu = $pdo->query('SELECT domain, domain_vorher FROM installation')->fetch(); +$pruefe('www.kunde-zwei.de' === $neu['domain'] && 'kunde-eins.de' === $neu['domain_vorher'], 'Domainwechsel ist sichtbar'); + +echo $fehler > 0 ? "\n{$fehler} Prüfung(en) fehlgeschlagen.\n" : "\nAlle Prüfungen bestanden.\n"; +exit($fehler > 0 ? 1 : 0); diff --git a/src/Controller/SystemController.php b/src/Controller/SystemController.php index 753df6c..ec798c7 100644 --- a/src/Controller/SystemController.php +++ b/src/Controller/SystemController.php @@ -3,12 +3,11 @@ namespace App\Controller; use App\Command\SiteOriginCommand; -use App\Content\PageRepository; -use App\Content\SiteSettings; use App\Domain\InvalidPage; use App\Ops\ArchiveUpdate; use App\Ops\Backup; use App\Ops\Environment; +use App\Ops\Funktionspruefung; use App\Ops\HttpsRedirect; use App\Ops\Installation; use App\Ops\OriginCheck; @@ -18,7 +17,6 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\Routing\Attribute\Route; /** @@ -30,7 +28,7 @@ use Symfony\Component\Routing\Attribute\Route; */ final class SystemController extends AbstractController { - public function __construct(private Updater $updater, private Backup $backup, private ArchiveUpdate $archive, private Installation $installation, private string $adminPath, private string $databaseUrl, private string $mediaDirectory, private string $projectDir, private string $siteOrigin, private OriginCheck $originCheck, private HttpKernelInterface $kernel, private PageRepository $pages, private SiteSettings $settings, private SitePackage $packages, private HttpsRedirect $https) + public function __construct(private Updater $updater, private Backup $backup, private ArchiveUpdate $archive, private Installation $installation, private string $adminPath, private string $databaseUrl, private string $mediaDirectory, private string $projectDir, private string $siteOrigin, private OriginCheck $originCheck, private Funktionspruefung $pruefung, private SitePackage $packages, private HttpsRedirect $https) { } @@ -272,28 +270,10 @@ final class SystemController extends AbstractController return new JsonResponse(['ok' => false, 'meldung' => 'Dieser Abschluss gehört zu keinem offenen Update.'], 403); } @set_time_limit(0); - $ergebnis = $this->archive->finish($this->installation, $this->pruefen(...)); + $ergebnis = $this->archive->finish($this->installation, $this->pruefung); $this->sendJsonAndStop(['ok' => $ergebnis['ok'], 'status' => $this->archive->status()]); } - /** - * Läuft die neue Fassung wirklich? Datenbank, Startseite in der Datenbank und die Startseite - * als Seite - als Unteranfrage, die an der Wartungsseite vorbeigeht. null heißt: alles gut. - */ - private function pruefen(): ?string - { - $home = $this->settings->homePath(); - if (null === $this->pages->findPublished($home)) { - return sprintf('Die Startseite /%s ist nicht lesbar.', $home); - } - $antwort = $this->kernel->handle(Request::create('/'), HttpKernelInterface::SUB_REQUEST, false); - if ($antwort->getStatusCode() >= 400) { - return sprintf('Die Startseite antwortet mit %d.', $antwort->getStatusCode()); - } - - return null; - } - /** Wie sendAndStop, nur als JSON für die Systemseite. */ private function sendJsonAndStop(array $daten): never { diff --git a/src/EventSubscriber/RemoteUpdateFinishSubscriber.php b/src/EventSubscriber/RemoteUpdateFinishSubscriber.php new file mode 100644 index 0000000..1ed259d --- /dev/null +++ b/src/EventSubscriber/RemoteUpdateFinishSubscriber.php @@ -0,0 +1,73 @@ + ['onRequest', 48]]; + } + + public function onRequest(RequestEvent $event): void + { + // Der übliche Fall kostet eine Dateiprüfung. + if (!$event->isMainRequest() || !is_file($this->projectDir.'/'.ArchiveUpdate::MARKE)) { + return; + } + $offen = $this->archive->pending(); + if (true !== ($offen['fern'] ?? null)) { + return; + } + // Gleichzeitige Anfragen: Eine schließt ab, die anderen sehen die Wartungsseite. + $sperre = $this->updater->lock(); + if (null === $sperre) { + return; + } + try { + if (true !== ($this->archive->pending()['fern'] ?? null)) { + return; + } + @ignore_user_abort(true); + @set_time_limit(0); + $this->archive->finish($this->installation, $this->pruefung); + } catch (\Throwable) { + // Was schiefging, steht im Updateprotokoll; die Wartungsseite bleibt dann an. + } finally { + flock($sperre, LOCK_UN); + fclose($sperre); + } + } +} diff --git a/src/Ops/ArchiveUpdate.php b/src/Ops/ArchiveUpdate.php index 7d86a32..bc9c5a4 100644 --- a/src/Ops/ArchiveUpdate.php +++ b/src/Ops/ArchiveUpdate.php @@ -315,6 +315,21 @@ final class ArchiveUpdate return ['ok' => true, 'steps' => $schritte, 'migrate' => true, 'token' => $token, 'fassung' => $ziel]; } + /** + * Kennzeichnet den offenen zweiten Schritt als von der Zentrale angestoßen (ADR 123). + * + * Dann wartet niemand in der Verwaltung, der ihn aufruft; die nächste Anfrage mit dem neuen + * Code schließt ihn ab (@see RemoteUpdateFinishSubscriber). + */ + public function markiereFern(): void + { + $offen = $this->pending(); + if (null === $offen) { + return; + } + @file_put_contents($this->root.'/'.self::MARKE, json_encode([...$offen, 'fern' => true], JSON_UNESCAPED_SLASHES)); + } + /** * Steht ein zweiter Schritt aus? Gibt Fassung und Zeitpunkt zurück, sonst null. * diff --git a/src/Ops/Funktionspruefung.php b/src/Ops/Funktionspruefung.php new file mode 100644 index 0000000..a205f8e --- /dev/null +++ b/src/Ops/Funktionspruefung.php @@ -0,0 +1,38 @@ +settings->homePath(); + if (null === $this->pages->findPublished($home)) { + return sprintf('Die Startseite /%s ist nicht lesbar.', $home); + } + $antwort = $this->kernel->handle(Request::create('/'), HttpKernelInterface::SUB_REQUEST, false); + if ($antwort->getStatusCode() >= 400) { + return sprintf('Die Startseite antwortet mit %d.', $antwort->getStatusCode()); + } + + return null; + } +} diff --git a/src/Ops/Tasks/ZentraleTask.php b/src/Ops/Tasks/ZentraleTask.php new file mode 100644 index 0000000..a64630b --- /dev/null +++ b/src/Ops/Tasks/ZentraleTask.php @@ -0,0 +1,173 @@ +zentrale->grundInaktiv(); + if (null !== $grund) { + return $grund; + } + $this->ergebnisNachtragen(); + $antwort = $this->zentrale->melden(); + $auftrag = $antwort['auftrag']; + if (null === $auftrag) { + return sprintf('Gemeldet: Fassung %s.', $this->updater->version()); + } + + return $this->ausfuehren($auftrag['id'], $auftrag['aktion'], $auftrag['fassung']); + } + + /** + * Ein Auftrag, dessen zweiter Schritt in einer anderen Anfrage lief: Was ist daraus geworden? + * Steht nichts mehr aus, verrät es der Fortschritt des Updates. + */ + private function ergebnisNachtragen(): void + { + $auftrag = $this->zentrale->offenerAuftrag(); + if (null === $auftrag || array_key_exists('ok', $auftrag) || null !== $this->archive->pending()) { + return; + } + $status = $this->archive->status() ?? []; + if ($status['laeuft'] ?? false) { + return; + } + $phase = (string) ($status['phase'] ?? ''); + $fassung = (string) ($auftrag['fassung'] ?? ''); + $ok = 'fertig' === $phase || version_compare($this->updater->version(), $fassung, '>='); + $this->zentrale->auftragErledigt($ok, (string) ($status['meldung'] ?? ($ok ? 'Fassung '.$this->updater->version().' läuft.' : 'Ohne Ergebnis beendet.'))); + } + + private function ausfuehren(int $id, string $aktion, string $fassung): string + { + $this->zentrale->auftragAngenommen($id, $fassung); + $ablehnen = function (string $grund): string { + $this->zentrale->auftragErledigt(false, $grund); + $this->zentrale->melden(); + + return 'Auftrag abgelehnt: '.$grund; + }; + if ('update' !== $aktion) { + return $ablehnen(sprintf('Unbekannter Auftrag „%s“.', $aktion)); + } + if (1 !== preg_match('/^\d{1,4}\.\d{1,5}\.\d{1,6}$/D', $fassung)) { + return $ablehnen('Die Fassung hat nicht die Form 1.2.3.'); + } + if (version_compare($fassung, $this->updater->version(), '<=')) { + $this->zentrale->auftragErledigt(true, sprintf('Bereits auf Fassung %s.', $this->updater->version())); + $this->zentrale->melden(); + + return 'Auftrag erledigt: Diese Installation ist schon auf dem Stand.'; + } + if (is_dir($this->projectDir.'/.git')) { + return $ablehnen('Diese Installation ist eine Git-Arbeitskopie; sie wird über Git aktualisiert, nicht aus der Ferne.'); + } + if (null !== $this->archive->pending() || is_file($this->projectDir.'/'.Updater::MAINTENANCE_FILE)) { + return $ablehnen('Hier läuft gerade ein Update oder die Wartungsseite ist an.'); + } + + // Ein Update dauert länger als eine gewöhnliche Aufgabe. Die Antwort an den Besucher ist + // da schon unterwegs; abbrechen darf der Lauf jetzt nicht mehr. + @ignore_user_abort(true); + @set_time_limit(0); + $ergebnis = $this->archive->run($fassung); + if (!($ergebnis['migrate'] ?? false)) { + $letzter = end($ergebnis['steps']) ?: ['detail' => '']; + $this->zentrale->auftragErledigt($ergebnis['ok'], (string) $letzter['detail']); + $this->zentrale->melden(); + + return ($ergebnis['ok'] ? 'Auftrag erledigt: ' : 'Update gescheitert: ').$letzter['detail']; + } + + // Die neuen Dateien liegen; die Migrationen gehören in eine Anfrage mit dem neuen Code. + $this->archive->markiereFern(); + $this->anstossen(); + $this->ergebnisNachtragen(); + try { + $this->zentrale->melden(); + } catch (\Throwable) { + // Das Ergebnis bleibt in var/zentrale.json und geht mit der nächsten Meldung raus. + } + + return null === $this->archive->pending() + ? sprintf('Update auf %s aus der Ferne ausgeführt.', $fassung) + : sprintf('Dateien für %s getauscht; die Migrationen laufen mit der nächsten Anfrage.', $fassung); + } + + /** Ruft die Website einmal auf. Gelingt das nicht, schließt der nächste Besucher ab. */ + private function anstossen(): void + { + $adresse = rtrim($this->siteOrigin, '/').'/'; + if (null !== $this->anstossen) { + ($this->anstossen)($adresse); + + return; + } + if (function_exists('curl_init')) { + $curl = curl_init($adresse); + curl_setopt_array($curl, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 2, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_TIMEOUT => self::ABSCHLUSS_SEKUNDEN, + CURLOPT_USERAGENT => 'KantSites-Abschluss', + ]); + curl_exec($curl); + curl_close($curl); + + return; + } + $kontext = stream_context_create(['http' => ['timeout' => self::ABSCHLUSS_SEKUNDEN, 'ignore_errors' => true, 'header' => "User-Agent: KantSites-Abschluss\r\n"]]); + @file_get_contents($adresse, false, $kontext); + } +} diff --git a/src/Ops/Zentrale.php b/src/Ops/Zentrale.php new file mode 100644 index 0000000..205108b --- /dev/null +++ b/src/Ops/Zentrale.php @@ -0,0 +1,287 @@ +): array{status: int, body: string, signatur: string})|null */ + private ?\Closure $senden; + + public function __construct( + private string $root, + private Updater $updater, + private ArchiveUpdate $archive, + private string $url, + private string $siteOrigin, + private string $databaseUrl, + ?\Closure $senden = null, + ) { + $this->senden = $senden; + } + + /** + * Meldet diese Installation? Nein, wenn die Zentrale abgeschaltet ist (ZENTRALE_URL=aus) oder + * die Website nur auf diesem Rechner läuft - Entwicklungs- und Messinstallationen gehören + * nicht in die Liste. + */ + public function aktiv(): bool + { + return null === $this->grundInaktiv(); + } + + public function grundInaktiv(): ?string + { + if (!str_starts_with($this->url, 'https://')) { + return 'Abgeschaltet (ZENTRALE_URL ist nicht gesetzt oder keine HTTPS-Adresse).'; + } + $host = strtolower((string) parse_url($this->siteOrigin, PHP_URL_HOST)); + if ('' === $host || in_array($host, ['localhost', '127.0.0.1', '[::1]', '::1'], true) || str_ends_with($host, '.localhost') || str_ends_with($host, '.test') || str_ends_with($host, '.invalid')) { + return sprintf('Keine Meldung: Die Website läuft lokal (%s).', '' === $host ? 'ohne SITE_ORIGIN' : $host); + } + + return null; + } + + /** + * Schickt die Meldung und gibt den Auftrag zurück, falls einer wartet. + * + * @return array{auftrag: ?array{id: int, aktion: string, fassung: string}, registriert: bool} + * + * @throws \RuntimeException wenn die Zentrale nicht oder nicht echt antwortet + */ + public function melden(): array + { + $ich = $this->identitaet(); + $erneut = false; + while (true) { + $daten = $this->payload($ich); + $body = json_encode($daten, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + $antwort = $this->post(rtrim($this->url, '/').self::PFAD, $body, [ + 'Content-Type' => 'application/json', + 'X-KantSites-Signatur' => hash_hmac('sha256', $body, $ich['schluessel']), + ]); + // Die Zentrale kennt diese Kennung nicht (mehr) - etwa nach einem Neuaufsetzen dort. + // Dann einmal mit Schlüssel neu anmelden. + if (409 === $antwort['status'] && !$erneut) { + $ich['registriert'] = false; + $erneut = true; + continue; + } + break; + } + if (200 !== $antwort['status']) { + throw new \RuntimeException(sprintf('Die Zentrale antwortet mit %d. %s', $antwort['status'], $this->fehlertext($antwort['body']))); + } + if (!hash_equals(hash_hmac('sha256', $antwort['body'], $ich['schluessel']), $antwort['signatur'])) { + throw new \RuntimeException('Die Antwort der Zentrale trägt keine gültige Unterschrift und wird verworfen.'); + } + $inhalt = json_decode($antwort['body'], true); + if (!is_array($inhalt) || true !== ($inhalt['ok'] ?? null)) { + throw new \RuntimeException('Die Antwort der Zentrale ist unverständlich.'); + } + + $ich['registriert'] = true; + $ich['gemeldet'] = gmdate('c'); + // Ein gemeldetes Ergebnis ist angekommen; der Auftrag ist damit erledigt. + if (isset($daten['auftrag']['ok'])) { + unset($ich['auftrag']); + } + $this->speichern($ich); + + $auftrag = $inhalt['auftrag'] ?? null; + if (!is_array($auftrag) || !is_int($auftrag['id'] ?? null) || !is_string($auftrag['aktion'] ?? null) || !is_string($auftrag['fassung'] ?? null)) { + $auftrag = null; + } + + return ['auftrag' => null === $auftrag ? null : ['id' => $auftrag['id'], 'aktion' => $auftrag['aktion'], 'fassung' => $auftrag['fassung']], 'registriert' => true]; + } + + /** Merkt sich einen angenommenen Auftrag, bis sein Ergebnis gemeldet ist. */ + public function auftragAngenommen(int $id, string $fassung): void + { + $ich = $this->identitaet(); + $ich['auftrag'] = ['id' => $id, 'fassung' => $fassung, 'seit' => gmdate('c')]; + $this->speichern($ich); + } + + /** Hält das Ergebnis eines Auftrags fest; die nächste Meldung trägt es zur Zentrale. */ + public function auftragErledigt(bool $ok, string $meldung): void + { + $ich = $this->identitaet(); + if (!isset($ich['auftrag'])) { + return; + } + $ich['auftrag']['ok'] = $ok; + $ich['auftrag']['meldung'] = mb_substr($meldung, 0, 500); + $this->speichern($ich); + } + + /** @return array{id?: int, fassung?: string, seit?: string, ok?: bool, meldung?: string}|null */ + public function offenerAuftrag(): ?array + { + return $this->identitaet()['auftrag'] ?? null; + } + + /** + * Was gemeldet wird. + * + * @param array $ich + * + * @return array + */ + private function payload(array $ich): array + { + $status = $this->archive->status(); + $daten = [ + 'id' => $ich['id'], + 'adresse' => rtrim($this->siteOrigin, '/'), + 'fassung' => $this->updater->version(), + 'php' => PHP_VERSION, + 'datenbank' => strtolower((string) strtok($this->databaseUrl, ':')), + // Über Git aktualisierte Installationen nimmt die Zentrale nicht in Auftrag. + 'art' => is_dir($this->root.'/.git') ? 'git' : 'archiv', + 'wartung' => is_file($this->root.'/'.Updater::MAINTENANCE_FILE), + 'update' => null === $status ? null : [ + 'phase' => (string) ($status['phase'] ?? ''), + 'meldung' => mb_substr((string) ($status['meldung'] ?? ''), 0, 300), + 'zeit' => (string) ($status['aktualisiert'] ?? ''), + ], + ]; + if (!($ich['registriert'] ?? false)) { + $daten['schluessel'] = $ich['schluessel']; + } + $auftrag = $ich['auftrag'] ?? null; + if (is_array($auftrag)) { + $daten['auftrag'] = array_key_exists('ok', $auftrag) + ? ['id' => $auftrag['id'], 'ok' => $auftrag['ok'], 'meldung' => $auftrag['meldung'] ?? ''] + : ['id' => $auftrag['id']]; + } + + return $daten; + } + + /** @return array */ + private function identitaet(): array + { + $daten = json_decode((string) @file_get_contents($this->root.'/'.self::DATEI), true); + if (is_array($daten) && is_string($daten['id'] ?? null) && is_string($daten['schluessel'] ?? null)) { + return $daten; + } + $daten = ['id' => bin2hex(random_bytes(16)), 'schluessel' => bin2hex(random_bytes(32)), 'registriert' => false]; + $this->speichern($daten); + + return $daten; + } + + /** @param array $daten */ + private function speichern(array $daten): void + { + $datei = $this->root.'/'.self::DATEI; + @mkdir(dirname($datei), 0o775, true); + $zwischen = $datei.'.'.bin2hex(random_bytes(4)).'.tmp'; + if (false === @file_put_contents($zwischen, json_encode($daten, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)) || !@rename($zwischen, $datei)) { + @unlink($zwischen); + throw new \RuntimeException(self::DATEI.' ist nicht schreibbar.'); + } + } + + private function fehlertext(string $body): string + { + $daten = json_decode($body, true); + + return is_array($daten) && is_string($daten['fehler'] ?? null) ? mb_substr($daten['fehler'], 0, 200) : ''; + } + + /** + * POST über HTTPS, ohne Weiterleitungen: Eine Meldung, die woanders hin umgelenkt wird, soll + * scheitern und nicht ankommen. + * + * @param array $kopf + * + * @return array{status: int, body: string, signatur: string} + */ + private function post(string $url, string $body, array $kopf): array + { + if (null !== $this->senden) { + return ($this->senden)($url, $body, $kopf); + } + $zeilen = ['User-Agent: KantSites/'.$this->updater->version()]; + foreach ($kopf as $name => $wert) { + $zeilen[] = $name.': '.$wert; + } + $signatur = ''; + if (function_exists('curl_init')) { + $curl = curl_init($url); + curl_setopt_array($curl, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $body, + CURLOPT_HTTPHEADER => $zeilen, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_TIMEOUT => 20, + CURLOPT_HEADERFUNCTION => static function ($curl, string $zeile) use (&$signatur): int { + if (1 === preg_match('/^X-KantSites-Signatur:\s*([a-f0-9]{64})\s*$/i', $zeile, $treffer)) { + $signatur = strtolower($treffer[1]); + } + + return strlen($zeile); + }, + ]); + $antwort = curl_exec($curl); + $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE); + $fehler = curl_error($curl); + curl_close($curl); + if (!is_string($antwort)) { + throw new \RuntimeException('Die Zentrale ist nicht erreichbar: '.$fehler); + } + + return ['status' => $status, 'body' => $antwort, 'signatur' => $signatur]; + } + if (!filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOL)) { + throw new \RuntimeException('Dieser Server darf keine Adressen abrufen: curl fehlt und allow_url_fopen ist abgeschaltet.'); + } + $kontext = stream_context_create([ + 'http' => ['method' => 'POST', 'header' => implode("\r\n", $zeilen), 'content' => $body, 'timeout' => 20, 'follow_location' => 0, 'ignore_errors' => true], + 'ssl' => ['verify_peer' => true, 'verify_peer_name' => true], + ]); + $antwort = @file_get_contents($url, false, $kontext); + $kopfzeilen = $http_response_header ?? []; + if (!is_string($antwort) || [] === $kopfzeilen) { + throw new \RuntimeException('Die Zentrale ist nicht erreichbar.'); + } + preg_match('/^HTTP\/\S+\s+(\d{3})/', (string) $kopfzeilen[0], $treffer); + foreach ($kopfzeilen as $zeile) { + if (1 === preg_match('/^X-KantSites-Signatur:\s*([a-f0-9]{64})\s*$/i', $zeile, $s)) { + $signatur = strtolower($s[1]); + } + } + + return ['status' => (int) ($treffer[1] ?? 0), 'body' => $antwort, 'signatur' => $signatur]; + } +} diff --git a/tests/ArchiveUpdateTest.php b/tests/ArchiveUpdateTest.php index 966d445..945db89 100644 --- a/tests/ArchiveUpdateTest.php +++ b/tests/ArchiveUpdateTest.php @@ -8,16 +8,23 @@ use App\Content\MenuRepository; use App\Content\PageRepository; use App\Content\PartialRepository; use App\Content\SiteSettings; +use App\EventSubscriber\RemoteUpdateFinishSubscriber; use App\Infrastructure\ConnectionFactory; use App\Ops\ArchiveUpdate; use App\Ops\Backup; use App\Ops\CommandRunner; use App\Ops\Download; +use App\Ops\Funktionspruefung; use App\Ops\Installation; +use App\Ops\Tasks\ZentraleTask; use App\Ops\Updater; +use App\Ops\Zentrale; use App\Security\CmsUser; use Doctrine\DBAL\Connection; use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher; @@ -337,6 +344,111 @@ final class ArchiveUpdateTest extends TestCase self::assertStringContainsString('Zurückgeschaltet auf 1.0.0', $status['meldung']); } + public function testARemoteOrderUpdatesTheSiteAndTheNextRequestFinishesIt(): void + { + // ADR 123: Die Zentrale merkt sich „Update auf 1.1.0“, die Website holt es mit ihrer + // Meldung ab, tauscht die Dateien, und die nächste Anfrage führt die Migrationen aus. + $this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0')); + $server = $this->zentraleServer(['id' => 7, 'aktion' => 'update', 'fassung' => '1.1.0']); + $zentrale = $this->zentrale($server['senden']); + $task = new ZentraleTask($zentrale, $this->updater(), new Updater($this->root, 'sqlite:///'.$this->root.'/var/cms.db', new CommandRunner(), $this->db), 'https://kunde.example.de', $this->root, function (string $adresse): void { + self::assertSame('https://kunde.example.de/', $adresse); + $this->naechsteAnfrage(); + }); + + $meldung = $task->run(); + + self::assertSame('1.1.0', trim((string) file_get_contents($this->root.'/VERSION')), $meldung); + self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE, 'Die nächste Anfrage hat abgeschlossen und die Website freigegeben.'); + self::assertNull($this->updater()->pending()); + $meldungen = $server['meldungen'](); + self::assertArrayHasKey('schluessel', $meldungen[0], 'Beim ersten Kontakt geht der Schlüssel mit.'); + self::assertArrayNotHasKey('schluessel', $meldungen[1], 'Danach nur noch die Unterschrift.'); + self::assertSame('1.0.0', $meldungen[0]['fassung']); + $letzte = end($meldungen); + self::assertSame('1.1.0', $letzte['fassung']); + self::assertSame(['id' => 7, 'ok' => true], array_intersect_key($letzte['auftrag'] ?? [], ['id' => 1, 'ok' => 1]), 'Das Ergebnis geht an die Zentrale zurück.'); + self::assertNull($zentrale->offenerAuftrag(), 'Nach der Quittung ist der Auftrag erledigt.'); + } + + public function testAnAnswerWithoutAValidSignatureChangesNothing(): void + { + $this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0')); + $zentrale = $this->zentrale(static fn (): array => [ + 'status' => 200, + 'body' => '{"ok":true,"auftrag":{"id":1,"aktion":"update","fassung":"1.1.0"}}', + 'signatur' => str_repeat('0', 64), + ]); + + try { + $zentrale->melden(); + self::fail('Eine Antwort ohne gültige Unterschrift wurde angenommen.'); + } catch (\RuntimeException $fehler) { + self::assertStringContainsString('Unterschrift', $fehler->getMessage()); + } + self::assertSame('1.0.0', trim((string) file_get_contents($this->root.'/VERSION'))); + } + + public function testALocalInstallationDoesNotReport(): void + { + $gesendet = false; + $senden = static function () use (&$gesendet): array { + $gesendet = true; + + return ['status' => 500, 'body' => '', 'signatur' => '']; + }; + foreach (['http://127.0.0.1:8106', 'http://localhost', 'https://kunde.test'] as $adresse) { + $zentrale = new Zentrale($this->root, new Updater($this->root, 'sqlite:///x', new CommandRunner(), $this->db), $this->updater(), 'https://sites.example.invalid', $adresse, 'sqlite:///x', $senden); + self::assertFalse($zentrale->aktiv(), $adresse); + } + self::assertFalse($this->zentrale(null, 'aus')->aktiv(), 'ZENTRALE_URL=aus schaltet ab.'); + self::assertFalse($gesendet); + } + + /** Die Anfrage nach dem Tausch: startet mit dem neuen Code und schließt ab. */ + private function naechsteAnfrage(): void + { + $pruefung = $this->createStub(Funktionspruefung::class); + $pruefung->method('__invoke')->willReturn(null); + $runner = new CommandRunner(); + $subscriber = new RemoteUpdateFinishSubscriber($this->updater(), $this->installation(), new Updater($this->root, 'sqlite:///'.$this->root.'/var/cms.db', $runner, $this->db), $pruefung, $this->root); + $subscriber->onRequest(new RequestEvent($this->createStub(HttpKernelInterface::class), Request::create('/'), HttpKernelInterface::MAIN_REQUEST)); + } + + private function zentrale(?\Closure $senden, string $url = 'https://sites.example.invalid'): Zentrale + { + $url_db = 'sqlite:///'.$this->root.'/var/cms.db'; + + return new Zentrale($this->root, new Updater($this->root, $url_db, new CommandRunner(), $this->db), $this->updater(), $url, 'https://kunde.example.de', $url_db, $senden); + } + + /** + * Eine Zentrale im Speicher: lernt den Schlüssel beim ersten Kontakt, prüft danach jede + * Unterschrift, gibt den Auftrag einmal heraus und unterschreibt jede Antwort. + * + * @return array{senden: \Closure, meldungen: \Closure} + */ + private function zentraleServer(?array $auftrag): array + { + $schluessel = null; + $meldungen = []; + $senden = function (string $url, string $body, array $kopf) use (&$schluessel, &$meldungen, &$auftrag): array { + $daten = json_decode($body, true); + $meldungen[] = $daten; + $schluessel ??= $daten['schluessel'] ?? null; + self::assertIsString($schluessel); + self::assertTrue(hash_equals(hash_hmac('sha256', $body, $schluessel), $kopf['X-KantSites-Signatur']), 'Jede Meldung ist unterschrieben.'); + $antwort = json_encode(['ok' => true, 'auftrag' => $auftrag]); + $auftrag = null; + + return ['status' => 200, 'body' => $antwort, 'signatur' => hash_hmac('sha256', $antwort, $schluessel)]; + }; + + return ['senden' => $senden, 'meldungen' => static function () use (&$meldungen): array { + return $meldungen; + }]; + } + private function installation(): Installation { $settings = new SiteSettings($this->db, new MediaLibrary($this->db, $this->root.'/var/media')); diff --git a/tests/TaskScreenTest.php b/tests/TaskScreenTest.php index 9591afc..6d56be3 100644 --- a/tests/TaskScreenTest.php +++ b/tests/TaskScreenTest.php @@ -70,7 +70,7 @@ final class TaskScreenTest extends WebTestCase self::assertSame(200, $client->getResponse()->getStatusCode()); $html = (string) $client->getResponse()->getContent(); - foreach (['publish-due', 'stats-prune', 'backup', 'update-check', 'cleanup'] as $task) { + foreach (['publish-due', 'stats-prune', 'backup', 'update-check', 'cleanup', 'zentrale'] as $task) { self::assertStringContainsString($task, $html); } // Die Einschränkung gehört auf den Bildschirm und nicht in die Dokumentation: wer sie @@ -78,7 +78,7 @@ final class TaskScreenTest extends WebTestCase self::assertStringContainsString('Ohne Besuch und ohne geöffnete Verwaltung passiert nichts.', $html); self::assertStringContainsString('/cms-cron/'.Scheduler::cronToken(self::SECRET), $html); // Und der Plan steht danach in der Datenbank - eine Zeile je Aufgabe, nicht mehr. - self::assertSame(5, (int) $this->db->fetchOne('SELECT COUNT(*) FROM scheduled_task')); + self::assertSame(6, (int) $this->db->fetchOne('SELECT COUNT(*) FROM scheduled_task')); } public function testStartingWorkNeedsAnAdministratorAndAValidForm(): void diff --git a/zentrale/.htaccess b/zentrale/.htaccess new file mode 100644 index 0000000..55d2d2b --- /dev/null +++ b/zentrale/.htaccess @@ -0,0 +1,15 @@ +# Für den Fall, dass die Subdomain auf diesen Ordner zeigt statt auf public/: +# app/, var/ und config.php bleiben verschlossen, alles andere geht nach public/. +Options -Indexes + + + RewriteEngine On + RewriteRule ^(app|var)(/|$) - [F,L] + RewriteRule ^(config\.php|config\.example\.php|README\.md)$ - [F,L] + RewriteCond %{REQUEST_URI} !^/public/ + RewriteRule ^(.*)$ public/$1 [L] + + + + Require all denied + diff --git a/zentrale/README.md b/zentrale/README.md new file mode 100644 index 0000000..8eeefd6 --- /dev/null +++ b/zentrale/README.md @@ -0,0 +1,78 @@ +# KantSites Zentrale (sites.kant-it.de) + +Die Übersicht aller KantSites-Installationen: welche Domain, welche Fassung, wann zuletzt +gemeldet, und ein Knopf für Updates – einzeln oder für alle. Entscheidung und Protokoll: +ADR 123 in `docs/ARCHITECTURE.md`. + +Reines PHP (ab 8.1) mit SQLite, ohne Composer und ohne Framework. Nötig sind die Erweiterungen +`pdo_sqlite` und `curl` (oder `allow_url_fopen`) – beides ist auf üblichem Webhosting da. + +## Hochladen + +1. Den Ordner `zentrale/` komplett per FTP hochladen, zum Beispiel nach `/sites.kant-it.de/`. +2. Die Subdomain `sites.kant-it.de` auf den Unterordner **`public/`** zeigen lassen. + Geht das beim Hoster nicht, auf den Ordner selbst – die `.htaccess` dort leitet nach `public/` + und sperrt `app/`, `var/` und `config.php`. +3. HTTPS (Let's Encrypt) für die Subdomain einschalten. Die Installationen melden nur über HTTPS. +4. `config.example.php` als `config.php` kopieren und einen zufälligen `einrichtungscode` + (mindestens 16 Zeichen) eintragen. +5. `var/` muss für PHP beschreibbar sein (dort liegen `zentrale.db` und der Fassungs-Cache). +6. `https://sites.kant-it.de` aufrufen, mit dem Einrichtungscode das erste Konto anlegen und + anmelden. Danach wird der Code nicht mehr gebraucht. + +Prüfen: `https://sites.kant-it.de/api/melden` im Browser antwortet mit +`{"ok":false,"fehler":"Nur POST."}` – dann ist die Schnittstelle erreichbar. + +## Wie die Installationen sich melden + +Ab KantSites **1.36.0** meldet sich jede Installation alle 15 Minuten über die Aufgabe +„Bei der Zentrale melden“ (Verwaltung → Aufgaben). Sie läuft wie alle Aufgaben beim Besuch, +in der Verwaltung oder über den URL-Cronjob. Ältere Installationen erscheinen erst nach dem Update +auf 1.36.0 – das letzte Mal von Hand über „System“. + +Nicht gemeldet wird von Installationen, deren `SITE_ORIGIN` auf `localhost`, `127.0.0.1`, +`*.test` oder `*.localhost` zeigt. Abschalten pro Installation: `ZENTRALE_URL=aus` in `.env`. + +Übertragen werden: Adresse, Fassung, PHP-Version, Datenbankart (sqlite/mysql), Git oder +Archiv, Wartungszustand, Stand des letzten Updates. Keine Inhalte, keine Besucher, keine +Zugangsdaten. Die Zentrale speichert zusätzlich die IP-Adresse der letzten Meldung. + +## Update aus der Ferne + +1. In der Übersicht „Auf x.y.z“ (oder „Alle aktualisieren“ mit Bestätigung) drücken. +2. Die Installation holt den Auftrag mit ihrer nächsten Meldung ab, legt eine Sicherung an, + lädt das Release mit Prüfsumme, tauscht die Dateien und ruft sich selbst einmal auf. Diese + Anfrage führt mit dem neuen Code die Migrationen aus, prüft die Startseite und gibt die + Website frei – oder schaltet auf die alte Fassung zurück. +3. Das Ergebnis erscheint mit der nächsten Meldung in der Zentrale. + +Einschränkungen: + +- Git-Arbeitskopien werden nicht aus der Ferne aktualisiert. +- Erreicht sich eine Website nicht selbst (manche Hoster sperren das), schließt der nächste + Besucher oder Cron-Aufruf das Update ab. So lange zeigt sie die Wartungsseite. +- Ein abgeholter Auftrag ohne Ergebnis nach 2 Stunden erscheint als „keine Rückmeldung“. + +## Fremde Installationen erkennen + +Jede Installation, der in der Detailansicht kein Kunde zugeordnet ist, steht als +**Unbekannt** in der Liste. Meldet sich eine bekannte Kennung unter einer anderen Domain, zeigt die +Liste „vorher …“. Wer KantSites verändert und die Meldung ausbaut, taucht nicht auf – einen +vollständigen Schutz gibt es ohne Lizenzschlüssel nicht. + +## Lokal ausprobieren + +```bash +php -S 127.0.0.1:8115 -t zentrale/public zentrale/public/index.php +``` + +Protokollprüfung zwischen CMS und Zentrale (ohne Netz): + +```bash +php scripts/zentrale-check.php +``` + +## Sicherung + +Alles Wichtige steht in `var/zentrale.db`. Geht sie verloren, melden sich die Installationen bei +ihrer nächsten Meldung neu an; Kundenzuordnungen und Auftragsverlauf sind dann weg. diff --git a/zentrale/app/.htaccess b/zentrale/app/.htaccess new file mode 100644 index 0000000..b66e808 --- /dev/null +++ b/zentrale/app/.htaccess @@ -0,0 +1 @@ +Require all denied diff --git a/zentrale/app/Ansicht.php b/zentrale/app/Ansicht.php new file mode 100644 index 0000000..25a710c --- /dev/null +++ b/zentrale/app/Ansicht.php @@ -0,0 +1,57 @@ + $daten */ + public function vorlage(string $datei, array $daten): string + { + $v = $this; + extract($daten, EXTR_SKIP); + ob_start(); + require $datei; + + return (string) ob_get_clean(); + } + + public function e(mixed $wert): string + { + return htmlspecialchars((string) $wert, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + + public function csrf(): string + { + return ''; + } + + /** Ein Zeitpunkt in deutscher Ortszeit. */ + public function zeit(?string $iso): string + { + if (null === $iso || '' === $iso || false === strtotime($iso)) { + return '–'; + } + + return (new \DateTimeImmutable($iso))->setTimezone(new \DateTimeZone('Europe/Berlin'))->format('d.m.Y, H:i'); + } + + /** „vor 5 Minuten“ - für die letzte Meldung. */ + public function vor(int $sekunden): string + { + return match (true) { + $sekunden < 90 => 'gerade eben', + $sekunden < 5400 => sprintf('vor %d Minuten', (int) round($sekunden / 60)), + $sekunden < 129600 => sprintf('vor %d Stunden', (int) round($sekunden / 3600)), + default => sprintf('vor %d Tagen', (int) round($sekunden / 86400)), + }; + } +} diff --git a/zentrale/app/Anwendung.php b/zentrale/app/Anwendung.php new file mode 100644 index 0000000..f261f4b --- /dev/null +++ b/zentrale/app/Anwendung.php @@ -0,0 +1,336 @@ + $config */ + public function __construct(private array $config, private string $wurzel) + { + } + + public function bearbeiten(): void + { + $pfad = '/'.trim((string) parse_url((string) ($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH), '/'); + $methode = (string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'); + $this->kopfzeilen(); + + try { + $this->db = Datenbank::oeffnen($this->wurzel.'/var/zentrale.db'); + } catch (\Throwable $fehler) { + http_response_code(500); + header('Content-Type: text/plain; charset=UTF-8'); + echo "Die Datenbank lässt sich nicht öffnen. Ist var/ beschreibbar?\n"; + error_log('Zentrale: '.$fehler->getMessage()); + + return; + } + + if ('/api/melden' === $pfad) { + $this->api($methode); + + return; + } + if ('/robots.txt' === $pfad) { + header('Content-Type: text/plain; charset=UTF-8'); + echo "User-agent: *\nDisallow: /\n"; + + return; + } + + $this->sitzung(); + $konten = new Konten($this->db); + if (!$konten->gibtEs()) { + $this->einrichten($konten, $methode); + + return; + } + if ('/anmelden' === $pfad) { + $this->anmelden($konten, $methode); + + return; + } + if (!isset($_SESSION['konto'])) { + $this->weiter('/anmelden'); + + return; + } + if ('POST' === $methode && !$this->csrfGueltig()) { + $this->hinweis('Die Sitzung ist abgelaufen. Bitte noch einmal.', 'fehler'); + $this->weiter('/' === $pfad ? '/' : $pfad); + + return; + } + + $fassungen = new Fassungen((string) ($this->config['releases_api'] ?? ''), $this->wurzel.'/var/neueste.json'); + $bestand = new Bestand($this->db); + + match (true) { + '/' === $pfad && 'GET' === $methode => $this->uebersicht($bestand, $fassungen), + '/installation' === $pfad && 'GET' === $methode => $this->detail($bestand, $fassungen), + '/auftrag' === $pfad && 'POST' === $methode => $this->auftrag($bestand, $fassungen), + '/auftrag/alle' === $pfad && 'POST' === $methode => $this->alleAuftraege($bestand, $fassungen), + '/auftrag/zurueckziehen' === $pfad && 'POST' === $methode => $this->zurueckziehen($bestand), + '/notiz' === $pfad && 'POST' === $methode => $this->notiz($bestand), + '/entfernen' === $pfad && 'POST' === $methode => $this->entfernen($bestand), + '/fassung' === $pfad && 'POST' === $methode => $this->fassungNeu($fassungen), + '/abmelden' === $pfad && 'POST' === $methode => $this->abmelden(), + default => $this->nichtGefunden(), + }; + } + + private function api(string $methode): void + { + header('Content-Type: application/json; charset=UTF-8'); + if ('POST' !== $methode) { + http_response_code(405); + header('Allow: POST'); + echo '{"ok":false,"fehler":"Nur POST."}'; + + return; + } + $antwort = (new Api($this->db))->melden( + (string) file_get_contents('php://input', false, null, 0, 16385), + (string) ($_SERVER['HTTP_X_KANTSITES_SIGNATUR'] ?? ''), + (string) ($_SERVER['REMOTE_ADDR'] ?? ''), + ); + http_response_code($antwort['status']); + if (null !== $antwort['signatur']) { + header('X-KantSites-Signatur: '.$antwort['signatur']); + } + echo $antwort['body']; + } + + private function uebersicht(Bestand $bestand, Fassungen $fassungen): void + { + $neueste = $fassungen->neueste(); + $alle = $bestand->alle($neueste['version'] ?? null); + $this->zeigen('uebersicht', [ + 'titel' => 'Installationen', + 'neueste' => $neueste, + 'installationen' => $alle, + 'zahlen' => [ + 'alle' => count($alle), + 'aktuell' => null === $neueste ? null : count(array_filter($alle, static fn (array $i): bool => !$i['veraltet'] && 'unbekannt' !== $i['fassung'])), + 'veraltet' => count(array_filter($alle, static fn (array $i): bool => $i['veraltet'])), + 'still' => count(array_filter($alle, static fn (array $i): bool => $i['still'])), + 'unbekannt' => count(array_filter($alle, static fn (array $i): bool => $i['unbekannt'])), + ], + // Verstummte bleiben außen vor: Sie holen den Auftrag womöglich nie ab. Wartende haben schon einen. + 'aktualisierbar' => array_values(array_filter($alle, static fn (array $i): bool => $i['kann_update'] && !$i['still'] && 'wartet' !== ($i['auftrag_zustand']['art'] ?? null))), + ]); + } + + private function detail(Bestand $bestand, Fassungen $fassungen): void + { + $neueste = $fassungen->neueste(); + $eine = $bestand->eine((string) ($_GET['id'] ?? ''), $neueste['version'] ?? null); + if (null === $eine) { + $this->nichtGefunden(); + + return; + } + $this->zeigen('installation', ['titel' => $eine['domain'], 'i' => $eine, 'neueste' => $neueste]); + } + + private function auftrag(Bestand $bestand, Fassungen $fassungen): void + { + $id = (string) ($_POST['id'] ?? ''); + $neueste = $fassungen->neueste()['version'] ?? null; + $grund = null === $neueste ? 'Die neueste Fassung ist gerade nicht bekannt.' : $bestand->beauftragen($id, $neueste); + $this->hinweis(null === $grund ? sprintf('Update auf %s beauftragt. Die Website holt es mit ihrer nächsten Meldung ab (meist innerhalb von 15 Minuten).', $neueste) : $grund, null === $grund ? 'ok' : 'fehler'); + $this->weiter('detail' === ($_POST['zurueck'] ?? '') ? '/installation?id='.rawurlencode($id) : '/'); + } + + private function alleAuftraege(Bestand $bestand, Fassungen $fassungen): void + { + $neueste = $fassungen->neueste()['version'] ?? null; + if ('ja' !== ($_POST['bestaetigt'] ?? '') || null === $neueste) { + $this->hinweis(null === $neueste ? 'Die neueste Fassung ist gerade nicht bekannt.' : 'Bitte zuerst bestätigen, dass alle veralteten Installationen aktualisiert werden sollen.', 'fehler'); + $this->weiter('/'); + + return; + } + $anzahl = 0; + foreach ($bestand->alle($neueste) as $installation) { + if ($installation['kann_update'] && !$installation['still'] && null === $bestand->beauftragen((string) $installation['id'], $neueste)) { + ++$anzahl; + } + } + $this->hinweis(sprintf('%d Update(s) auf %s beauftragt.', $anzahl, $neueste), 'ok'); + $this->weiter('/'); + } + + private function zurueckziehen(Bestand $bestand): void + { + $id = (string) ($_POST['id'] ?? ''); + $this->hinweis($bestand->zurueckziehen($id) ? 'Auftrag zurückgezogen.' : 'Der Auftrag war schon abgeholt und lässt sich nicht mehr zurückziehen.', 'ok'); + $this->weiter('/installation?id='.rawurlencode($id)); + } + + private function notiz(Bestand $bestand): void + { + $id = (string) ($_POST['id'] ?? ''); + $bestand->notiz($id, (string) ($_POST['notiz'] ?? '')); + $this->hinweis('Gespeichert.', 'ok'); + $this->weiter('/installation?id='.rawurlencode($id)); + } + + private function entfernen(Bestand $bestand): void + { + $id = (string) ($_POST['id'] ?? ''); + if ('ja' !== ($_POST['bestaetigt'] ?? '')) { + $this->hinweis('Bitte bestätigen, dass der Eintrag entfernt werden soll.', 'fehler'); + $this->weiter('/installation?id='.rawurlencode($id)); + + return; + } + $bestand->entfernen($id); + $this->hinweis('Eintrag entfernt. Meldet sich die Installation wieder, erscheint sie neu.', 'ok'); + $this->weiter('/'); + } + + private function fassungNeu(Fassungen $fassungen): void + { + $neu = $fassungen->neueste(true); + $this->hinweis(null === $neu ? 'Die Releaseseite antwortet nicht.' : sprintf('Neueste Fassung: %s.', $neu['version']), null === $neu ? 'fehler' : 'ok'); + $this->weiter('/'); + } + + private function einrichten(Konten $konten, string $methode): void + { + $code = (string) ($this->config['einrichtungscode'] ?? ''); + $fehler = null; + if (strlen($code) < 16) { + $fehler = 'In config.php fehlt ein einrichtungscode mit mindestens 16 Zeichen. Erst danach lässt sich das erste Konto anlegen.'; + } elseif ('POST' === $methode) { + $name = trim((string) ($_POST['name'] ?? '')); + $passwort = (string) ($_POST['passwort'] ?? ''); + $ip = (string) ($_SERVER['REMOTE_ADDR'] ?? ''); + $fehler = match (true) { + !$this->csrfGueltig() => 'Die Sitzung ist abgelaufen. Bitte noch einmal.', + $konten->gesperrt($ip) => 'Zu viele Fehlversuche. Bitte in 15 Minuten noch einmal.', + !hash_equals($code, (string) ($_POST['code'] ?? '')) => 'Der Einrichtungscode stimmt nicht.', + 1 !== preg_match('/^[\p{L}\p{N}._@-]{3,60}$/uD', $name) => 'Der Name braucht 3 bis 60 Zeichen (Buchstaben, Ziffern, . _ @ -).', + mb_strlen($passwort) < 12 => 'Das Passwort braucht mindestens 12 Zeichen.', + $passwort !== (string) ($_POST['passwort2'] ?? '') => 'Die beiden Passwörter stimmen nicht überein.', + default => null, + }; + if (null !== $fehler && str_starts_with($fehler, 'Der Einrichtungscode')) { + $konten->fehlversuch($ip); + } + if (null === $fehler) { + $konten->anlegen($name, $passwort); + $this->hinweis('Konto angelegt. Bitte jetzt anmelden.', 'ok'); + $this->weiter('/anmelden'); + + return; + } + } + $this->zeigen('einrichten', ['titel' => 'Einrichten', 'fehler' => $fehler, 'name' => (string) ($_POST['name'] ?? '')], false); + } + + private function anmelden(Konten $konten, string $methode): void + { + if (isset($_SESSION['konto'])) { + $this->weiter('/'); + + return; + } + $fehler = null; + $ip = (string) ($_SERVER['REMOTE_ADDR'] ?? ''); + if ('POST' === $methode) { + if (!$this->csrfGueltig()) { + $fehler = 'Die Sitzung ist abgelaufen. Bitte noch einmal.'; + } elseif ($konten->gesperrt($ip)) { + $fehler = 'Zu viele Fehlversuche. Bitte in 15 Minuten noch einmal.'; + } else { + $konto = $konten->pruefen(trim((string) ($_POST['name'] ?? '')), (string) ($_POST['passwort'] ?? ''), $ip); + if (null !== $konto) { + session_regenerate_id(true); + $_SESSION['konto'] = $konto; + $_SESSION['csrf'] = bin2hex(random_bytes(32)); + $this->weiter('/'); + + return; + } + $fehler = 'Name oder Passwort stimmen nicht.'; + } + http_response_code(422); + } + $this->zeigen('anmelden', ['titel' => 'Anmelden', 'fehler' => $fehler, 'name' => (string) ($_POST['name'] ?? '')], false); + } + + private function abmelden(): void + { + $_SESSION = []; + session_regenerate_id(true); + $this->weiter('/anmelden'); + } + + private function nichtGefunden(): void + { + http_response_code(404); + $this->zeigen('fehlt', ['titel' => 'Nicht gefunden'], isset($_SESSION['konto'])); + } + + private function sitzung(): void + { + $https = 'on' === strtolower((string) ($_SERVER['HTTPS'] ?? '')) || '443' === (string) ($_SERVER['SERVER_PORT'] ?? ''); + session_name($https ? '__Host-zentrale' : 'zentrale'); + session_set_cookie_params(['lifetime' => 0, 'path' => '/', 'secure' => $https, 'httponly' => true, 'samesite' => 'Strict']); + ini_set('session.use_strict_mode', '1'); + ini_set('session.gc_maxlifetime', '28800'); + session_start(); + $_SESSION['csrf'] ??= bin2hex(random_bytes(32)); + } + + private function csrfGueltig(): bool + { + return is_string($_POST['_csrf'] ?? null) && hash_equals((string) ($_SESSION['csrf'] ?? ''), $_POST['_csrf']); + } + + private function hinweis(string $text, string $art): void + { + $_SESSION['hinweis'] = ['text' => $text, 'art' => $art]; + } + + private function weiter(string $ziel): void + { + http_response_code(303); + header('Location: '.$ziel); + } + + private function kopfzeilen(): void + { + header("Content-Security-Policy: default-src 'none'; style-src 'self'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"); + header('X-Content-Type-Options: nosniff'); + header('Referrer-Policy: no-referrer'); + header('X-Robots-Tag: noindex, nofollow'); + header('Cache-Control: no-store'); + } + + /** @param array $daten */ + private function zeigen(string $vorlage, array $daten, bool $angemeldet = true): void + { + header('Content-Type: text/html; charset=UTF-8'); + $hinweis = $_SESSION['hinweis'] ?? null; + unset($_SESSION['hinweis']); + $v = new Ansicht((string) ($_SESSION['csrf'] ?? '')); + $inhalt = $v->vorlage($this->wurzel.'/app/vorlagen/'.$vorlage.'.php', $daten); + echo $v->vorlage($this->wurzel.'/app/vorlagen/rahmen.php', [ + 'titel' => $daten['titel'] ?? 'Zentrale', + 'inhalt' => $inhalt, + 'hinweis' => $hinweis, + 'konto' => $angemeldet ? ($_SESSION['konto'] ?? null) : null, + ]); + } +} diff --git a/zentrale/app/Api.php b/zentrale/app/Api.php new file mode 100644 index 0000000..9b921c0 --- /dev/null +++ b/zentrale/app/Api.php @@ -0,0 +1,142 @@ + self::GROESSTE_MELDUNG) { + return $this->fehler(413, 'Die Meldung ist zu groß.'); + } + $daten = json_decode($body, true); + if (!is_array($daten)) { + return $this->fehler(400, 'Die Meldung ist kein JSON.'); + } + $pruef = $this->pruefen($daten); + if (is_string($pruef)) { + return $this->fehler(400, $pruef); + } + $signatur = strtolower(trim($signatur)); + + $gespeichert = $this->db->prepare('SELECT * FROM installation WHERE id = ?'); + $gespeichert->execute([$daten['id']]); + $installation = $gespeichert->fetch() ?: null; + $mitgebracht = is_string($daten['schluessel'] ?? null) && 1 === preg_match('/^[a-f0-9]{64}$/D', $daten['schluessel']) ? $daten['schluessel'] : null; + + if (null === $installation) { + if (null === $mitgebracht) { + // Die Installation hält sich für angemeldet, hier ist sie unbekannt: Sie schickt + // daraufhin ihren Schlüssel noch einmal. + return $this->fehler(409, 'Diese Installation ist hier nicht bekannt.'); + } + $schluessel = $mitgebracht; + } else { + $schluessel = (string) $installation['schluessel']; + if (null !== $mitgebracht && !hash_equals($schluessel, $mitgebracht)) { + return $this->fehler(403, 'Diese Kennung gehört zu einer anderen Installation.'); + } + } + if (!hash_equals(hash_hmac('sha256', $body, $schluessel), $signatur)) { + return $this->fehler(403, 'Die Unterschrift stimmt nicht.'); + } + + $jetzt = Datenbank::jetzt(); + $domain = strtolower((string) parse_url($daten['adresse'], PHP_URL_HOST)); + $update = is_array($daten['update'] ?? null) ? $daten['update'] : []; + $werte = [ + 'adresse' => $daten['adresse'], + 'domain' => $domain, + 'fassung' => $daten['fassung'], + 'php' => mb_substr((string) ($daten['php'] ?? ''), 0, 20), + 'datenbank' => mb_substr((string) ($daten['datenbank'] ?? ''), 0, 20), + 'art' => 'git' === ($daten['art'] ?? '') ? 'git' : 'archiv', + 'wartung' => true === ($daten['wartung'] ?? false) ? 1 : 0, + 'update_phase' => isset($update['phase']) ? mb_substr((string) $update['phase'], 0, 30) : null, + 'update_meldung' => isset($update['meldung']) ? mb_substr((string) $update['meldung'], 0, 300) : null, + 'update_zeit' => isset($update['zeit']) ? mb_substr((string) $update['zeit'], 0, 40) : null, + 'ip' => mb_substr($ip, 0, 45), + 'letzte_meldung' => $jetzt, + ]; + + $this->db->beginTransaction(); + try { + if (null === $installation) { + $sql = 'INSERT INTO installation (id, schluessel, erste_meldung, '.implode(', ', array_keys($werte)).') VALUES (?, ?, ?, '.implode(', ', array_fill(0, count($werte), '?')).')'; + $this->db->prepare($sql)->execute([$daten['id'], $schluessel, $jetzt, ...array_values($werte)]); + } else { + // Meldet sich dieselbe Kennung unter einer anderen Domain, wurde die Installation + // kopiert oder umgezogen. Beides will man sehen. + if ($installation['domain'] !== $domain) { + $werte['domain_vorher'] = $installation['domain']; + } + $sql = 'UPDATE installation SET '.implode(', ', array_map(static fn (string $spalte): string => $spalte.' = ?', array_keys($werte))).' WHERE id = ?'; + $this->db->prepare($sql)->execute([...array_values($werte), $daten['id']]); + } + + $ergebnis = $daten['auftrag'] ?? null; + if (is_array($ergebnis) && is_int($ergebnis['id'] ?? null) && is_bool($ergebnis['ok'] ?? null)) { + $this->db->prepare('UPDATE auftrag SET erledigt = ?, ok = ?, meldung = ?, abgeholt = COALESCE(abgeholt, ?) WHERE id = ? AND installation = ? AND erledigt IS NULL') + ->execute([$jetzt, $ergebnis['ok'] ? 1 : 0, mb_substr((string) ($ergebnis['meldung'] ?? ''), 0, 500), $jetzt, $ergebnis['id'], $daten['id']]); + } + + $offen = $this->db->prepare('SELECT id, aktion, fassung FROM auftrag WHERE installation = ? AND abgeholt IS NULL AND erledigt IS NULL ORDER BY id DESC LIMIT 1'); + $offen->execute([$daten['id']]); + $auftrag = $offen->fetch() ?: null; + if (null !== $auftrag) { + $this->db->prepare('UPDATE auftrag SET abgeholt = ? WHERE id = ?')->execute([$jetzt, $auftrag['id']]); + $auftrag = ['id' => (int) $auftrag['id'], 'aktion' => (string) $auftrag['aktion'], 'fassung' => (string) $auftrag['fassung']]; + } + $this->db->commit(); + } catch (\Throwable $fehler) { + $this->db->rollBack(); + throw $fehler; + } + + $antwort = json_encode(['ok' => true, 'auftrag' => $auftrag], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + + return ['status' => 200, 'body' => $antwort, 'signatur' => hash_hmac('sha256', $antwort, $schluessel)]; + } + + /** @param array $daten */ + private function pruefen(array $daten): ?string + { + if (!is_string($daten['id'] ?? null) || 1 !== preg_match('/^[a-f0-9]{32}$/D', $daten['id'])) { + return 'Die Kennung fehlt oder hat die falsche Form.'; + } + $adresse = $daten['adresse'] ?? null; + if (!is_string($adresse) || strlen($adresse) > 200 || 1 !== preg_match('#^https?://[a-z0-9.-]+(:\d{1,5})?(/[^\s]*)?$#iD', $adresse)) { + return 'Die Adresse fehlt oder ist keine Webadresse.'; + } + if (!is_string($daten['fassung'] ?? null) || 1 !== preg_match('/^(\d{1,4}\.\d{1,5}\.\d{1,6}|unbekannt)$/D', $daten['fassung'])) { + return 'Die Fassung hat nicht die Form 1.2.3.'; + } + + return null; + } + + /** @return array{status: int, body: string, signatur: null} */ + private function fehler(int $status, string $text): array + { + return ['status' => $status, 'body' => (string) json_encode(['ok' => false, 'fehler' => $text], JSON_UNESCAPED_UNICODE), 'signatur' => null]; + } +} diff --git a/zentrale/app/Bestand.php b/zentrale/app/Bestand.php new file mode 100644 index 0000000..68e05ad --- /dev/null +++ b/zentrale/app/Bestand.php @@ -0,0 +1,151 @@ +> */ + public function alle(?string $neueste): array + { + $zeilen = $this->db->query('SELECT * FROM installation ORDER BY domain, erste_meldung')->fetchAll(); + $auftraege = []; + foreach ($this->db->query('SELECT a.* FROM auftrag a JOIN (SELECT installation, MAX(id) AS id FROM auftrag GROUP BY installation) j ON j.id = a.id')->fetchAll() as $auftrag) { + $auftraege[$auftrag['installation']] = $auftrag; + } + + return array_map(fn (array $zeile): array => $this->anreichern($zeile, $auftraege[$zeile['id']] ?? null, $neueste), $zeilen); + } + + /** @return array|null */ + public function eine(string $id, ?string $neueste): ?array + { + $abfrage = $this->db->prepare('SELECT * FROM installation WHERE id = ?'); + $abfrage->execute([$id]); + $zeile = $abfrage->fetch() ?: null; + if (null === $zeile) { + return null; + } + $verlauf = $this->db->prepare('SELECT * FROM auftrag WHERE installation = ? ORDER BY id DESC LIMIT 30'); + $verlauf->execute([$id]); + $auftraege = $verlauf->fetchAll(); + $eine = $this->anreichern($zeile, $auftraege[0] ?? null, $neueste); + $eine['verlauf'] = array_map(fn (array $a): array => [...$a, 'zustand' => $this->auftragZustand($a)], $auftraege); + + return $eine; + } + + /** + * Ein Update in Auftrag geben. Gibt null zurück oder den Grund, warum nicht. + */ + public function beauftragen(string $id, string $fassung): ?string + { + $eine = $this->eine($id, $fassung); + if (null === $eine) { + return 'Diese Installation gibt es nicht.'; + } + if (!$eine['kann_update']) { + return $eine['kein_update_grund']; + } + $offen = $eine['auftrag']; + if (null !== $offen && null === $offen['abgeholt'] && null === $offen['erledigt']) { + $this->db->prepare('UPDATE auftrag SET fassung = ?, erstellt = ? WHERE id = ?')->execute([$fassung, Datenbank::jetzt(), $offen['id']]); + + return null; + } + $this->db->prepare('INSERT INTO auftrag (installation, aktion, fassung, erstellt) VALUES (?, ?, ?, ?)') + ->execute([$id, 'update', $fassung, Datenbank::jetzt()]); + + return null; + } + + /** Einen noch nicht abgeholten Auftrag zurückziehen. */ + public function zurueckziehen(string $id): bool + { + $abfrage = $this->db->prepare('DELETE FROM auftrag WHERE installation = ? AND abgeholt IS NULL AND erledigt IS NULL'); + $abfrage->execute([$id]); + + return $abfrage->rowCount() > 0; + } + + public function notiz(string $id, string $notiz): void + { + $this->db->prepare('UPDATE installation SET notiz = ? WHERE id = ?')->execute([mb_substr(trim($notiz), 0, 200), $id]); + } + + public function entfernen(string $id): void + { + $this->db->prepare('DELETE FROM installation WHERE id = ?')->execute([$id]); + } + + /** + * @param array $zeile + * @param array|null $auftrag + * + * @return array + */ + private function anreichern(array $zeile, ?array $auftrag, ?string $neueste): array + { + $alter = time() - (int) strtotime((string) $zeile['letzte_meldung']); + $veraltet = null !== $neueste && 'unbekannt' !== $zeile['fassung'] && version_compare((string) $zeile['fassung'], $neueste, '<'); + $zustand = null === $auftrag ? null : $this->auftragZustand($auftrag); + // Ein wartender Auftrag lässt sich noch ändern; ein abgeholter läuft. + $laeuft = 'laeuft' === ($zustand['art'] ?? null); + + $grund = match (true) { + null === $neueste => 'Die neueste Fassung ist gerade nicht bekannt.', + !$veraltet => 'Diese Installation ist auf dem neuesten Stand.', + 'git' === $zeile['art'] => 'Eine Git-Arbeitskopie wird über Git aktualisiert, nicht aus der Ferne.', + $laeuft => 'Ein Update läuft gerade.', + default => '', + }; + + return [ + ...$zeile, + 'alter' => $alter, + 'still' => $alter > self::STILL_STUNDEN * 3600, + 'veraltet' => $veraltet, + 'unbekannt' => '' === trim((string) $zeile['notiz']), + 'auftrag' => $auftrag, + 'auftrag_zustand' => $zustand, + 'kann_update' => '' === $grund, + 'kein_update_grund' => $grund, + ]; + } + + /** + * @param array $auftrag + * + * @return array{art: string, text: string} + */ + private function auftragZustand(array $auftrag): array + { + if (null !== $auftrag['erledigt']) { + return (int) $auftrag['ok'] === 1 + ? ['art' => 'ok', 'text' => sprintf('Update auf %s erledigt', $auftrag['fassung'])] + : ['art' => 'fehler', 'text' => sprintf('Update auf %s gescheitert', $auftrag['fassung'])]; + } + if (null === $auftrag['abgeholt']) { + return ['art' => 'wartet', 'text' => sprintf('Update auf %s wartet auf die nächste Meldung', $auftrag['fassung'])]; + } + if ((int) strtotime((string) $auftrag['abgeholt']) < time() - self::AUFTRAG_STUNDEN * 3600) { + return ['art' => 'fehler', 'text' => sprintf('Update auf %s: keine Rückmeldung', $auftrag['fassung'])]; + } + + return ['art' => 'laeuft', 'text' => sprintf('Update auf %s läuft', $auftrag['fassung'])]; + } +} diff --git a/zentrale/app/Datenbank.php b/zentrale/app/Datenbank.php new file mode 100644 index 0000000..f13b047 --- /dev/null +++ b/zentrale/app/Datenbank.php @@ -0,0 +1,97 @@ + [ + 'CREATE TABLE konto (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, passwort TEXT NOT NULL, erstellt TEXT NOT NULL)', + 'CREATE TABLE anmeldeversuch (ip TEXT NOT NULL, zeit TEXT NOT NULL)', + 'CREATE INDEX anmeldeversuch_ip ON anmeldeversuch (ip, zeit)', + 'CREATE TABLE installation ( + id TEXT PRIMARY KEY, + schluessel TEXT NOT NULL, + adresse TEXT NOT NULL, + domain TEXT NOT NULL, + domain_vorher TEXT, + fassung TEXT NOT NULL, + php TEXT NOT NULL DEFAULT \'\', + datenbank TEXT NOT NULL DEFAULT \'\', + art TEXT NOT NULL DEFAULT \'archiv\', + wartung INTEGER NOT NULL DEFAULT 0, + update_phase TEXT, + update_meldung TEXT, + update_zeit TEXT, + notiz TEXT NOT NULL DEFAULT \'\', + ip TEXT NOT NULL DEFAULT \'\', + erste_meldung TEXT NOT NULL, + letzte_meldung TEXT NOT NULL + )', + 'CREATE TABLE auftrag ( + id INTEGER PRIMARY KEY, + installation TEXT NOT NULL REFERENCES installation (id) ON DELETE CASCADE, + aktion TEXT NOT NULL, + fassung TEXT NOT NULL, + erstellt TEXT NOT NULL, + abgeholt TEXT, + erledigt TEXT, + ok INTEGER, + meldung TEXT + )', + 'CREATE INDEX auftrag_installation ON auftrag (installation, id)', + ], + ]; + + public static function oeffnen(string $datei): \PDO + { + $neu = !is_file($datei); + if ($neu && !is_dir(dirname($datei)) && !@mkdir(dirname($datei), 0o775, true)) { + throw new \RuntimeException(dirname($datei).' lässt sich nicht anlegen.'); + } + $pdo = new \PDO('sqlite:'.$datei, null, null, [ + \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, + \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC, + ]); + $pdo->exec('PRAGMA foreign_keys = ON'); + $pdo->exec('PRAGMA busy_timeout = 5000'); + if ($neu) { + $pdo->exec('PRAGMA journal_mode = WAL'); + } + self::migrieren($pdo); + + return $pdo; + } + + private static function migrieren(\PDO $pdo): void + { + $stand = (int) $pdo->query('PRAGMA user_version')->fetchColumn(); + foreach (self::MIGRATIONEN as $nummer => $anweisungen) { + if ($nummer <= $stand) { + continue; + } + $pdo->beginTransaction(); + try { + foreach ($anweisungen as $sql) { + $pdo->exec($sql); + } + $pdo->exec('PRAGMA user_version = '.$nummer); + $pdo->commit(); + } catch (\Throwable $fehler) { + $pdo->rollBack(); + throw $fehler; + } + } + } + + public static function jetzt(): string + { + return gmdate('Y-m-d\TH:i:s\Z'); + } +} diff --git a/zentrale/app/Fassungen.php b/zentrale/app/Fassungen.php new file mode 100644 index 0000000..67dc4a6 --- /dev/null +++ b/zentrale/app/Fassungen.php @@ -0,0 +1,70 @@ +cache), true); + $gespeichert = is_array($gespeichert) && is_string($gespeichert['version'] ?? null) ? $gespeichert : null; + if (!$frisch && null !== $gespeichert && (int) ($gespeichert['geholt'] ?? 0) > time() - self::GUELTIG) { + return $gespeichert; + } + $neu = $this->holen(); + if (null === $neu) { + return $gespeichert; + } + @file_put_contents($this->cache, json_encode([...$neu, 'geholt' => time()])); + + return $neu; + } + + /** @return array{version: string, name: string, published: string}|null */ + private function holen(): ?array + { + if (!str_starts_with($this->api, 'https://')) { + return null; + } + $kontext = stream_context_create([ + 'http' => ['timeout' => 8, 'ignore_errors' => true, 'header' => "User-Agent: KantSites-Zentrale\r\nAccept: application/json\r\n"], + 'ssl' => ['verify_peer' => true, 'verify_peer_name' => true], + ]); + if (function_exists('curl_init')) { + $curl = curl_init(rtrim($this->api, '/').'/releases/latest'); + curl_setopt_array($curl, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 8, + CURLOPT_USERAGENT => 'KantSites-Zentrale', + ]); + $antwort = curl_exec($curl); + $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE); + curl_close($curl); + $antwort = 200 === $status ? $antwort : false; + } else { + $antwort = @file_get_contents(rtrim($this->api, '/').'/releases/latest', false, $kontext); + } + $daten = is_string($antwort) ? json_decode($antwort, true) : null; + if (!is_array($daten) || 1 !== preg_match('/^v(\d+\.\d+\.\d+)$/D', (string) ($daten['tag_name'] ?? ''), $treffer)) { + return null; + } + + return ['version' => $treffer[1], 'name' => (string) ($daten['name'] ?? ''), 'published' => (string) ($daten['published_at'] ?? '')]; + } +} diff --git a/zentrale/app/Konten.php b/zentrale/app/Konten.php new file mode 100644 index 0000000..60049b7 --- /dev/null +++ b/zentrale/app/Konten.php @@ -0,0 +1,67 @@ +db->query('SELECT COUNT(*) FROM konto')->fetchColumn() > 0; + } + + public function anlegen(string $name, string $passwort): void + { + $this->db->prepare('INSERT INTO konto (name, passwort, erstellt) VALUES (?, ?, ?)') + ->execute([$name, password_hash($passwort, PASSWORD_DEFAULT), Datenbank::jetzt()]); + } + + public function gesperrt(string $ip): bool + { + $abfrage = $this->db->prepare('SELECT COUNT(*) FROM anmeldeversuch WHERE ip = ? AND zeit > ?'); + $abfrage->execute([$ip, gmdate('Y-m-d\TH:i:s\Z', time() - self::SPERRE_SEKUNDEN)]); + + return (int) $abfrage->fetchColumn() >= self::VERSUCHE; + } + + /** @return array{id: int, name: string}|null */ + public function pruefen(string $name, string $passwort, string $ip): ?array + { + $abfrage = $this->db->prepare('SELECT id, name, passwort FROM konto WHERE name = ?'); + $abfrage->execute([$name]); + $konto = $abfrage->fetch() ?: null; + // Auch ohne Konto einmal hashen: Die Antwortzeit verrät sonst, welche Namen es gibt. + $hash = null !== $konto ? (string) $konto['passwort'] : password_hash('attrappe', PASSWORD_DEFAULT); + if (!password_verify($passwort, $hash) || null === $konto) { + $this->fehlversuch($ip); + + return null; + } + if (password_needs_rehash($hash, PASSWORD_DEFAULT)) { + $this->db->prepare('UPDATE konto SET passwort = ? WHERE id = ?')->execute([password_hash($passwort, PASSWORD_DEFAULT), $konto['id']]); + } + $this->db->prepare('DELETE FROM anmeldeversuch WHERE ip = ?')->execute([$ip]); + + return ['id' => (int) $konto['id'], 'name' => (string) $konto['name']]; + } + + public function fehlversuch(string $ip): void + { + $this->db->prepare('INSERT INTO anmeldeversuch (ip, zeit) VALUES (?, ?)')->execute([$ip, Datenbank::jetzt()]); + $this->db->prepare('DELETE FROM anmeldeversuch WHERE zeit < ?')->execute([gmdate('Y-m-d\TH:i:s\Z', time() - 86400)]); + } +} diff --git a/zentrale/app/vorlagen/anmelden.php b/zentrale/app/vorlagen/anmelden.php new file mode 100644 index 0000000..196845f --- /dev/null +++ b/zentrale/app/vorlagen/anmelden.php @@ -0,0 +1,13 @@ + +
+

Anmelden

+ +
+ csrf() ?> + + > + + + +
+
diff --git a/zentrale/app/vorlagen/einrichten.php b/zentrale/app/vorlagen/einrichten.php new file mode 100644 index 0000000..8c4fd50 --- /dev/null +++ b/zentrale/app/vorlagen/einrichten.php @@ -0,0 +1,19 @@ + +
+

Zentrale einrichten

+

Es gibt noch kein Konto. Lege das erste an – dafür brauchst du den Einrichtungscode aus config.php.

+ +
+ csrf() ?> + + + + + +

Mindestens 12 Zeichen.

+ + + + +
+
diff --git a/zentrale/app/vorlagen/fehlt.php b/zentrale/app/vorlagen/fehlt.php new file mode 100644 index 0000000..1405375 --- /dev/null +++ b/zentrale/app/vorlagen/fehlt.php @@ -0,0 +1,5 @@ + +
+

Nicht gefunden

+

Diese Seite gibt es nicht. Zur Übersicht

+
diff --git a/zentrale/app/vorlagen/installation.php b/zentrale/app/vorlagen/installation.php new file mode 100644 index 0000000..756fc9d --- /dev/null +++ b/zentrale/app/vorlagen/installation.php @@ -0,0 +1,100 @@ + +

← Alle Installationen

+
+

e($i['domain']) ?>

+ Unbekannt +
+ +
+
+

Stand

+
+
Adresse
e($i['adresse']) ?>
+
Vorher gemeldet als
e($i['domain_vorher']) ?>
+
Fassung
e($i['fassung']) ?>e($neueste['version']) : '' ?>
+
Aktualisiert über
+
PHP
e($i['php'] ?: '–') ?>
+
Datenbank
e($i['datenbank'] ?: '–') ?>
+
Wartungsseite
+
Letzte Meldung
e($v->zeit($i['letzte_meldung'])) ?> (e($v->vor($i['alter'])) ?>)seit über 48 Stunden still' : '' ?>
+
Erste Meldung
e($v->zeit($i['erste_meldung'])) ?>
+
IP-Adresse
e($i['ip'] ?: '–') ?>
+ +
Letztes Update
e($i['update_meldung']) ?> (e($v->zeit($i['update_zeit'])) ?>)
+ +
+
+ +
+

Update

+ +

e($i['auftrag_zustand']['text']) ?>

+ + +

Die Website holt den Auftrag mit ihrer nächsten Meldung ab – meist innerhalb von 15 Minuten.

+ +
+ csrf() ?> + + +

Die Website holt den Auftrag mit ihrer nächsten Meldung ab, legt eine Sicherung an, spielt e($neueste['version']) ?> ein und schaltet bei einem Fehler zurück.

+ +
+ +

e($i['kein_update_grund']) ?>

+ + +
+ csrf() ?> + + +
+ +
+ +
+

Kunde

+
+ csrf() ?> + + +

Installationen ohne Eintrag stehen in der Übersicht als „Unbekannt“ – so fällt eine fremde Nutzung auf.

+ + +
+
+
+ +
+

Aufträge

+ +

Noch kein Auftrag.

+ +
+ + + + + + + + + + + + +
BeauftragtFassungAbgeholtErgebnis
e($v->zeit($a['erstellt'])) ?>e($a['fassung']) ?>e($v->zeit($a['abgeholt'])) ?>e($a['zustand']['text']) ?>e($a['meldung']) ?>
+
+ +
+ +
+ Eintrag entfernen +
+ csrf() ?> + +

Entfernt die Installation und ihre Aufträge aus dieser Liste. An der Website ändert sich nichts; meldet sie sich wieder, erscheint sie neu.

+ + +
+
diff --git a/zentrale/app/vorlagen/rahmen.php b/zentrale/app/vorlagen/rahmen.php new file mode 100644 index 0000000..578e34b --- /dev/null +++ b/zentrale/app/vorlagen/rahmen.php @@ -0,0 +1,33 @@ + + + + + + + +<?= $v->e($titel) ?> · KantSites Zentrale + + + + + +
+
+ KantSites Zentrale + +
+ csrf() ?> + e($konto['name']) ?> + +
+ +
+
+
+ +

e($hinweis['text']) ?>

+ + +
+ + diff --git a/zentrale/app/vorlagen/uebersicht.php b/zentrale/app/vorlagen/uebersicht.php new file mode 100644 index 0000000..1923e5e --- /dev/null +++ b/zentrale/app/vorlagen/uebersicht.php @@ -0,0 +1,69 @@ + +
+

Installationen

+
+ csrf() ?> + Neueste Fassung: e($neueste['version']) ?> + +
+
+ +
    +
  • gesamt
  • +
  • aktuell
  • +
  • veraltet
  • +
  • ohne Meldung seit 48 h
  • +
  • ohne Kundenzuordnung
  • +
+ + +
+ csrf() ?> +
+

Alle auf e($neueste['version']) ?> aktualisieren

+

Installation(en): e(implode(', ', array_column($aktualisierbar, 'domain'))) ?>. Jede holt den Auftrag mit ihrer nächsten Meldung ab, sichert sich vorher und schaltet bei einem Fehler zurück.

+ +
+ +
+ + + +
+

Noch keine Meldung

+

Sobald eine KantSites-Installation ab Fassung 1.36.0 läuft, meldet sie sich hier – spätestens 15 Minuten nach dem ersten Besuch oder Cron-Aufruf.

+
+ +
+ + + + + + + + + + + + + + + + + +
Alle gemeldeten Installationen
DomainKundeFassungLetzte MeldungZustandAktion
e($i['domain']) ?> + vorher e($i['domain_vorher']) ?>Unbekannt' : $v->e($i['notiz']) ?>e($i['fassung']) ?> Gite($v->vor($i['alter'])) ?> + Wartung + e($i['auftrag_zustand']['text']) ?>– + + +
+ csrf() ?> + + +
+ +
+
+ diff --git a/zentrale/config.example.php b/zentrale/config.example.php new file mode 100644 index 0000000..dbd86ce --- /dev/null +++ b/zentrale/config.example.php @@ -0,0 +1,13 @@ + '', + + // Wo die neueste KantSites-Fassung steht. Dieselbe Releaseseite fragen die Installationen. + 'releases_api' => 'https://git.kant-it.de/api/v1/repos/kantarci/KantSites', +]; diff --git a/zentrale/public/.htaccess b/zentrale/public/.htaccess new file mode 100644 index 0000000..7a110d0 --- /dev/null +++ b/zentrale/public/.htaccess @@ -0,0 +1,15 @@ +Options -Indexes +DirectoryIndex index.php + + + RewriteEngine On + RewriteCond %{HTTPS} off + RewriteCond %{HTTP_HOST} !^(localhost|127\.0\.0\.1)(:\d+)?$ + RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L] + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] + + + + Header always set Strict-Transport-Security "max-age=31536000" "expr=%{HTTPS} == 'on'" + diff --git a/zentrale/public/app.css b/zentrale/public/app.css new file mode 100644 index 0000000..1669912 --- /dev/null +++ b/zentrale/public/app.css @@ -0,0 +1,159 @@ +:root { + --grund: #f4f6f9; + --flaeche: #ffffff; + --text: #17202b; + --leise: #5a6575; + --linie: #dde3ea; + --marke: #173f6b; + --marke-hell: #e6eef7; + --ok: #17663a; + --ok-grund: #e3f4e9; + --warn: #8a4b00; + --warn-grund: #fdf0dc; + --fehler: #a4161a; + --fehler-grund: #fde7e7; + --still: #5b4a8a; + --still-grund: #eeeaf8; + --fokus: #1f6feb; + --radius: 10px; + color-scheme: light; +} + +* { box-sizing: border-box; } + +html { -webkit-text-size-adjust: 100%; } + +body { + margin: 0; + background: var(--grund); + color: var(--text); + font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +} + +a { color: var(--marke); text-underline-offset: 2px; } +a:hover { text-decoration-thickness: 2px; } + +:focus-visible { outline: 3px solid var(--fokus); outline-offset: 2px; border-radius: 4px; } + +h1 { font-size: 1.6rem; line-height: 1.25; margin: 0; } +h2 { font-size: 1.05rem; margin: 0 0 .6rem; } +p { margin: 0 0 .8rem; } +code { background: var(--marke-hell); padding: 1px 5px; border-radius: 4px; font-size: .92em; } + +.skip { position: absolute; left: -9999px; top: 8px; background: var(--flaeche); padding: 8px 12px; border-radius: 6px; z-index: 10; } +.skip:focus { left: 16px; } + +.unsichtbar { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } + +.kopf { background: var(--marke); color: #fff; } +.kopf-innen { max-width: 1180px; margin: 0 auto; padding: 12px 16px; display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } +.marke { color: #fff; text-decoration: none; font-weight: 700; font-size: 1.05rem; display: inline-flex; align-items: center; gap: 10px; } +.marke span:last-child { font-weight: 400; opacity: .85; } +.marke-zeichen { display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 7px; background: #fff; color: var(--marke); font-weight: 800; } +.abmelden { display: flex; align-items: center; gap: 10px; margin: 0; } +.wer { font-size: .9rem; opacity: .9; } +.kopf .knopf-leise { color: #fff; border-color: rgba(255, 255, 255, .5); background: transparent; } +.kopf .knopf-leise:hover { background: rgba(255, 255, 255, .12); } +.kopf :focus-visible { outline-color: #fff; } + +.seite { max-width: 1180px; margin: 0 auto; padding: 24px 16px 48px; } +.seite:focus { outline: none; } + +.titelzeile { display: flex; align-items: center; justify-content: space-between; gap: 12px 20px; flex-wrap: wrap; margin-bottom: 18px; } +.neueste { display: flex; align-items: center; gap: 10px; margin: 0; color: var(--leise); flex-wrap: wrap; } +.neueste strong { color: var(--text); } +.zurueck { margin-bottom: 8px; } + +.karte { background: var(--flaeche); border: 1px solid var(--linie); border-radius: var(--radius); padding: 18px 20px; margin-bottom: 18px; } +.schmal { max-width: 440px; margin: 32px auto; } +.schmal h1 { margin-bottom: 14px; } +.raster { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 0 18px; align-items: start; } + +.zahlen { list-style: none; padding: 0; margin: 0 0 18px; display: flex; flex-wrap: wrap; gap: 10px; } +.zahlen li { flex: 1 1 110px; background: var(--flaeche); border: 1px solid var(--linie); border-radius: var(--radius); padding: 10px 14px; color: var(--leise); font-size: .9rem; } +.zahlen strong { display: block; font-size: 1.35rem; color: var(--text); line-height: 1.2; } +.z-ok strong { color: var(--ok); } +.z-warn strong { color: var(--warn); } +.z-still strong { color: var(--still); } +.z-fremd strong { color: var(--fehler); } + +.alle { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; border-left: 4px solid var(--marke); } +.alle > div { flex: 1 1 420px; } +.alle p { color: var(--leise); } +.bestaetigen { display: flex; align-items: center; gap: 8px; font-weight: 600; margin: 4px 0 0; } +.bestaetigen input { width: 18px; height: 18px; margin: 0; accent-color: var(--marke); } + +.tabelle-rahmen { background: var(--flaeche); border: 1px solid var(--linie); border-radius: var(--radius); overflow: hidden; } +.karte .tabelle-rahmen { border: 0; border-radius: 0; } +.tabelle { width: 100%; border-collapse: collapse; } +.tabelle th, .tabelle td { text-align: left; padding: 11px 14px; border-bottom: 1px solid var(--linie); vertical-align: middle; } +.tabelle thead th { font-size: .8rem; text-transform: uppercase; letter-spacing: .04em; color: var(--leise); background: #f9fafc; font-weight: 600; } +.tabelle tbody tr:last-child > * { border-bottom: 0; } +.tabelle tbody th { font-weight: 600; } +.tabelle tbody tr:hover { background: #fafbfd; } +.aktion { text-align: right; white-space: nowrap; } +.aktion form { margin: 0; } + +.klein { color: var(--leise); font-size: .85rem; } +.block { display: block; margin-top: 3px; } +th .klein { display: block; font-weight: 400; } +.still { color: var(--still); font-weight: 600; } + +.fassung { font-variant-numeric: tabular-nums; font-weight: 600; padding: 2px 8px; border-radius: 999px; font-size: .9rem; } +.f-ok { background: var(--ok-grund); color: var(--ok); } +.f-alt { background: var(--warn-grund); color: var(--warn); } + +.pille { display: inline-block; padding: 2px 9px; border-radius: 999px; font-size: .85rem; font-weight: 600; } +.p-ok { background: var(--ok-grund); color: var(--ok); } +.p-fehler { background: var(--fehler-grund); color: var(--fehler); } +.p-warn, .p-wartet { background: var(--warn-grund); color: var(--warn); } +.p-laeuft { background: var(--marke-hell); color: var(--marke); } +.marke-fremd { display: inline-block; padding: 2px 9px; border-radius: 999px; font-size: .85rem; font-weight: 700; background: var(--fehler-grund); color: var(--fehler); } + +.fakten { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0; } +.fakten dt { color: var(--leise); } +.fakten dd { margin: 0; overflow-wrap: anywhere; } + +label { display: block; font-weight: 600; margin: 12px 0 4px; } +.feldhilfe { color: var(--leise); font-size: .88rem; margin: -2px 0 6px; } +input[type="text"], input[type="password"] { + width: 100%; font: inherit; font-size: 16px; padding: 9px 11px; border: 1px solid #b9c3cf; border-radius: 8px; background: #fff; color: var(--text); +} +input[type="text"]:focus-visible, input[type="password"]:focus-visible { border-color: var(--fokus); } + +.knopf { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 40px; padding: 8px 16px; + font: inherit; font-weight: 600; color: #fff; background: var(--marke); border: 1px solid var(--marke); border-radius: 8px; cursor: pointer; +} +.knopf:hover { background: #0f2f52; } +.knopf-klein { min-height: 34px; padding: 5px 12px; font-size: .9rem; } +.knopf-leise { background: var(--flaeche); color: var(--marke); border-color: #b9c3cf; min-height: 34px; padding: 5px 12px; font-size: .9rem; } +.knopf-leise:hover { background: var(--marke-hell); } +.knopf-gefahr { background: var(--fehler); border-color: var(--fehler); } +.knopf-gefahr:hover { background: #7f1013; } +.abstand { margin-top: 14px; } + +.hinweis { padding: 10px 14px; border-radius: 8px; margin-bottom: 18px; font-weight: 500; } +.hinweis-ok { background: var(--ok-grund); color: var(--ok); } +.hinweis-fehler { background: var(--fehler-grund); color: var(--fehler); } + +.gefahr summary { cursor: pointer; font-weight: 600; color: var(--fehler); } +.gefahr[open] summary { margin-bottom: 10px; } +.gefahr .knopf { margin-top: 12px; } + +.leer p { color: var(--leise); margin: 0; } + +@media (max-width: 760px) { + h1 { font-size: 1.35rem; } + .karte { padding: 16px; } + .tabelle thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } + .tabelle, .tabelle tbody, .tabelle tr, .tabelle th, .tabelle td { display: block; width: 100%; } + .tabelle tr { padding: 10px 14px; border-bottom: 1px solid var(--linie); } + .tabelle tbody tr:last-child { border-bottom: 0; } + .tabelle th, .tabelle td { border: 0; padding: 3px 0; } + .tabelle td[data-label]::before { content: attr(data-label) ": "; color: var(--leise); font-size: .85rem; } + .tabelle tbody th { font-size: 1.05rem; padding-bottom: 6px; } + .aktion { text-align: left; padding-top: 8px !important; } + .fakten { grid-template-columns: 1fr; gap: 0; } + .fakten dd { margin-bottom: 8px; } +} diff --git a/zentrale/public/index.php b/zentrale/public/index.php new file mode 100644 index 0000000..1e76be5 --- /dev/null +++ b/zentrale/public/index.php @@ -0,0 +1,32 @@ + 'https://git.kant-it.de/api/v1/repos/kantarci/KantSites', 'einrichtungscode' => '']; + +(new Zentrale\Anwendung($config, $wurzel))->bearbeiten(); diff --git a/zentrale/var/.htaccess b/zentrale/var/.htaccess new file mode 100644 index 0000000..b66e808 --- /dev/null +++ b/zentrale/var/.htaccess @@ -0,0 +1 @@ +Require all denied