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 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5.5 committed 2026-10-14 10:43:13 +02:00
1 parent 32cb77b72e
commit 723680b7de
39 files changed
+2355 -27

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
<?php /** @var Zentrale\Ansicht $v */ ?>
<div class="schmal karte">
<h1>Anmelden</h1>
<?php if (null !== $fehler) { ?><p class="hinweis hinweis-fehler" role="alert" id="fehler"><?= $v->e($fehler) ?></p><?php } ?>
<form method="post" action="/anmelden">
<?= $v->csrf() ?>
<label for="name">Name</label>
<input id="name" name="name" type="text" autocomplete="username" required value="<?= $v->e($name) ?>" <?= null !== $fehler ? 'aria-describedby="fehler"' : '' ?>>
<label for="passwort">Passwort</label>
<input id="passwort" name="passwort" type="password" autocomplete="current-password" required>
<button type="submit" class="knopf abstand">Anmelden</button>
</form>
</div>
+19
View File
@@ -0,0 +1,19 @@
<?php /** @var Zentrale\Ansicht $v */ ?>
<div class="schmal karte">
<h1>Zentrale einrichten</h1>
<p>Es gibt noch kein Konto. Lege das erste an – dafür brauchst du den Einrichtungscode aus <code>config.php</code>.</p>
<?php if (null !== $fehler) { ?><p class="hinweis hinweis-fehler" role="alert"><?= $v->e($fehler) ?></p><?php } ?>
<form method="post" action="/">
<?= $v->csrf() ?>
<label for="code">Einrichtungscode</label>
<input id="code" name="code" type="password" autocomplete="off" required>
<label for="name">Name</label>
<input id="name" name="name" type="text" autocomplete="username" required minlength="3" maxlength="60" value="<?= $v->e($name) ?>">
<label for="passwort">Passwort</label>
<p class="feldhilfe" id="pw-hilfe">Mindestens 12 Zeichen.</p>
<input id="passwort" name="passwort" type="password" autocomplete="new-password" required minlength="12" aria-describedby="pw-hilfe">
<label for="passwort2">Passwort wiederholen</label>
<input id="passwort2" name="passwort2" type="password" autocomplete="new-password" required minlength="12">
<button type="submit" class="knopf abstand">Konto anlegen</button>
</form>
</div>
+5
View File
@@ -0,0 +1,5 @@
<?php /** @var Zentrale\Ansicht $v */ ?>
<div class="schmal karte">
<h1>Nicht gefunden</h1>
<p>Diese Seite gibt es nicht. <a href="/">Zur Übersicht</a></p>
</div>
+100
View File
@@ -0,0 +1,100 @@
<?php /** @var Zentrale\Ansicht $v */ ?>
<p class="zurueck"><a href="/">← Alle Installationen</a></p>
<div class="titelzeile">
<h1><?= $v->e($i['domain']) ?></h1>
<?php if ($i['unbekannt']) { ?><span class="marke-fremd">Unbekannt</span><?php } ?>
</div>
<div class="raster">
<section class="karte" aria-labelledby="t-stand">
<h2 id="t-stand">Stand</h2>
<dl class="fakten">
<dt>Adresse</dt><dd><a href="<?= $v->e($i['adresse']) ?>" rel="noopener noreferrer"><?= $v->e($i['adresse']) ?></a></dd>
<?php if (null !== $i['domain_vorher']) { ?><dt>Vorher gemeldet als</dt><dd><?= $v->e($i['domain_vorher']) ?></dd><?php } ?>
<dt>Fassung</dt><dd><span class="fassung <?= $i['veraltet'] ? 'f-alt' : 'f-ok' ?>"><?= $v->e($i['fassung']) ?></span><?= $i['veraltet'] && null !== $neueste ? ' – neueste ist '.$v->e($neueste['version']) : '' ?></dd>
<dt>Aktualisiert über</dt><dd><?= 'git' === $i['art'] ? 'Git-Arbeitskopie' : 'Releasearchiv' ?></dd>
<dt>PHP</dt><dd><?= $v->e($i['php'] ?: '–') ?></dd>
<dt>Datenbank</dt><dd><?= $v->e($i['datenbank'] ?: '–') ?></dd>
<dt>Wartungsseite</dt><dd><?= $i['wartung'] ? 'an' : 'aus' ?></dd>
<dt>Letzte Meldung</dt><dd><?= $v->e($v->zeit($i['letzte_meldung'])) ?> (<?= $v->e($v->vor($i['alter'])) ?>)<?= $i['still'] ? ' – <strong>seit über 48 Stunden still</strong>' : '' ?></dd>
<dt>Erste Meldung</dt><dd><?= $v->e($v->zeit($i['erste_meldung'])) ?></dd>
<dt>IP-Adresse</dt><dd><?= $v->e($i['ip'] ?: '–') ?></dd>
<?php if (null !== $i['update_phase']) { ?>
<dt>Letztes Update</dt><dd><?= $v->e($i['update_meldung']) ?> <span class="klein">(<?= $v->e($v->zeit($i['update_zeit'])) ?>)</span></dd>
<?php } ?>
</dl>
</section>
<section class="karte" aria-labelledby="t-update">
<h2 id="t-update">Update</h2>
<?php if (null !== $i['auftrag_zustand']) { ?>
<p><span class="pille p-<?= $v->e($i['auftrag_zustand']['art']) ?>"><?= $v->e($i['auftrag_zustand']['text']) ?></span></p>
<?php } ?>
<?php if ('wartet' === ($i['auftrag_zustand']['art'] ?? null)) { ?>
<p>Die Website holt den Auftrag mit ihrer nächsten Meldung ab – meist innerhalb von 15 Minuten.</p>
<?php } elseif ($i['kann_update'] && null !== $neueste) { ?>
<form method="post" action="/auftrag">
<?= $v->csrf() ?>
<input type="hidden" name="id" value="<?= $v->e($i['id']) ?>">
<input type="hidden" name="zurueck" value="detail">
<p>Die Website holt den Auftrag mit ihrer nächsten Meldung ab, legt eine Sicherung an, spielt <?= $v->e($neueste['version']) ?> ein und schaltet bei einem Fehler zurück.</p>
<button type="submit" class="knopf">Auf <?= $v->e($neueste['version']) ?> aktualisieren</button>
</form>
<?php } else { ?>
<p><?= $v->e($i['kein_update_grund']) ?></p>
<?php } ?>
<?php if (null !== $i['auftrag'] && null === $i['auftrag']['abgeholt'] && null === $i['auftrag']['erledigt']) { ?>
<form method="post" action="/auftrag/zurueckziehen" class="abstand">
<?= $v->csrf() ?>
<input type="hidden" name="id" value="<?= $v->e($i['id']) ?>">
<button type="submit" class="knopf knopf-leise">Auftrag zurückziehen</button>
</form>
<?php } ?>
</section>
<section class="karte" aria-labelledby="t-kunde">
<h2 id="t-kunde">Kunde</h2>
<form method="post" action="/notiz">
<?= $v->csrf() ?>
<input type="hidden" name="id" value="<?= $v->e($i['id']) ?>">
<label for="notiz">Kunde oder Notiz</label>
<p class="feldhilfe" id="notiz-hilfe">Installationen ohne Eintrag stehen in der Übersicht als „Unbekannt“ – so fällt eine fremde Nutzung auf.</p>
<input id="notiz" name="notiz" type="text" maxlength="200" value="<?= $v->e($i['notiz']) ?>" aria-describedby="notiz-hilfe">
<button type="submit" class="knopf abstand">Speichern</button>
</form>
</section>
</div>
<section class="karte" aria-labelledby="t-verlauf">
<h2 id="t-verlauf">Aufträge</h2>
<?php if ([] === $i['verlauf']) { ?>
<p>Noch kein Auftrag.</p>
<?php } else { ?>
<div class="tabelle-rahmen">
<table class="tabelle">
<thead><tr><th scope="col">Beauftragt</th><th scope="col">Fassung</th><th scope="col">Abgeholt</th><th scope="col">Ergebnis</th></tr></thead>
<tbody>
<?php foreach ($i['verlauf'] as $a) { ?>
<tr>
<td data-label="Beauftragt"><?= $v->e($v->zeit($a['erstellt'])) ?></td>
<td data-label="Fassung"><?= $v->e($a['fassung']) ?></td>
<td data-label="Abgeholt"><?= $v->e($v->zeit($a['abgeholt'])) ?></td>
<td data-label="Ergebnis"><span class="pille p-<?= $v->e($a['zustand']['art']) ?>"><?= $v->e($a['zustand']['text']) ?></span><?php if ('' !== (string) $a['meldung']) { ?><span class="klein block"><?= $v->e($a['meldung']) ?></span><?php } ?></td>
</tr>
<?php } ?>
</tbody>
</table>
</div>
<?php } ?>
</section>
<details class="karte gefahr">
<summary>Eintrag entfernen</summary>
<form method="post" action="/entfernen">
<?= $v->csrf() ?>
<input type="hidden" name="id" value="<?= $v->e($i['id']) ?>">
<p>Entfernt die Installation und ihre Aufträge aus dieser Liste. An der Website ändert sich nichts; meldet sie sich wieder, erscheint sie neu.</p>
<label class="bestaetigen"><input type="checkbox" name="bestaetigt" value="ja" required> Ja, <?= $v->e($i['domain']) ?> aus der Liste entfernen</label>
<button type="submit" class="knopf knopf-gefahr">Entfernen</button>
</form>
</details>
+33
View File
@@ -0,0 +1,33 @@
<?php /** @var Zentrale\Ansicht $v */ ?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title><?= $v->e($titel) ?> · KantSites Zentrale</title>
<link rel="stylesheet" href="/app.css?v=1">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23173f6b'/%3E%3Cpath d='M9 8v16M9 16l9-8M12.5 13l7 11' stroke='%23fff' stroke-width='3.2' fill='none' stroke-linecap='round'/%3E%3C/svg%3E">
</head>
<body>
<a class="skip" href="#inhalt">Zum Inhalt</a>
<header class="kopf">
<div class="kopf-innen">
<a class="marke" href="/"><span class="marke-zeichen" aria-hidden="true">K</span> KantSites <span>Zentrale</span></a>
<?php if (null !== $konto) { ?>
<form method="post" action="/abmelden" class="abmelden">
<?= $v->csrf() ?>
<span class="wer"><?= $v->e($konto['name']) ?></span>
<button type="submit" class="knopf knopf-leise">Abmelden</button>
</form>
<?php } ?>
</div>
</header>
<main id="inhalt" class="seite" tabindex="-1">
<?php if (null !== $hinweis) { ?>
<p class="hinweis hinweis-<?= $v->e($hinweis['art']) ?>" role="<?= 'fehler' === $hinweis['art'] ? 'alert' : 'status' ?>"><?= $v->e($hinweis['text']) ?></p>
<?php } ?>
<?= $inhalt ?>
</main>
</body>
</html>
+69
View File
@@ -0,0 +1,69 @@
<?php /** @var Zentrale\Ansicht $v */ ?>
<div class="titelzeile">
<h1>Installationen</h1>
<form method="post" action="/fassung" class="neueste">
<?= $v->csrf() ?>
<span>Neueste Fassung: <strong><?= null === $neueste ? 'unbekannt' : $v->e($neueste['version']) ?></strong></span>
<button type="submit" class="knopf knopf-leise">Neu abfragen</button>
</form>
</div>
<ul class="zahlen" aria-label="Zusammenfassung">
<li><strong><?= $zahlen['alle'] ?></strong> gesamt</li>
<li class="z-ok"><strong><?= $zahlen['aktuell'] ?? '–' ?></strong> aktuell</li>
<li class="z-warn"><strong><?= $zahlen['veraltet'] ?></strong> veraltet</li>
<li class="z-still"><strong><?= $zahlen['still'] ?></strong> ohne Meldung seit 48 h</li>
<li class="z-fremd"><strong><?= $zahlen['unbekannt'] ?></strong> ohne Kundenzuordnung</li>
</ul>
<?php if ([] !== $aktualisierbar && null !== $neueste) { ?>
<form method="post" action="/auftrag/alle" class="karte alle">
<?= $v->csrf() ?>
<div>
<h2>Alle auf <?= $v->e($neueste['version']) ?> aktualisieren</h2>
<p><?= count($aktualisierbar) ?> Installation(en): <?= $v->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.</p>
<label class="bestaetigen"><input type="checkbox" name="bestaetigt" value="ja" required> Ja, alle <?= count($aktualisierbar) ?> aktualisieren</label>
</div>
<button type="submit" class="knopf">Alle aktualisieren</button>
</form>
<?php } ?>
<?php if ([] === $installationen) { ?>
<div class="karte leer">
<h2>Noch keine Meldung</h2>
<p>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.</p>
</div>
<?php } else { ?>
<div class="tabelle-rahmen">
<table class="tabelle">
<caption class="unsichtbar">Alle gemeldeten Installationen</caption>
<thead>
<tr><th scope="col">Domain</th><th scope="col">Kunde</th><th scope="col">Fassung</th><th scope="col">Letzte Meldung</th><th scope="col">Zustand</th><th scope="col"><span class="unsichtbar">Aktion</span></th></tr>
</thead>
<tbody>
<?php foreach ($installationen as $i) { ?>
<tr>
<th scope="row" data-label="Domain"><a href="/installation?id=<?= $v->e(rawurlencode($i['id'])) ?>"><?= $v->e($i['domain']) ?></a>
<?php if (null !== $i['domain_vorher']) { ?><span class="klein">vorher <?= $v->e($i['domain_vorher']) ?></span><?php } ?></th>
<td data-label="Kunde"><?= $i['unbekannt'] ? '<span class="marke-fremd">Unbekannt</span>' : $v->e($i['notiz']) ?></td>
<td data-label="Fassung"><span class="fassung <?= $i['veraltet'] ? 'f-alt' : 'f-ok' ?>"><?= $v->e($i['fassung']) ?></span><?php if ('git' === $i['art']) { ?> <span class="klein">Git</span><?php } ?></td>
<td data-label="Letzte Meldung"><span class="<?= $i['still'] ? 'still' : '' ?>"><?= $v->e($v->vor($i['alter'])) ?></span></td>
<td data-label="Zustand">
<?php if ($i['wartung']) { ?><span class="pille p-warn">Wartung</span> <?php } ?>
<?php if (null !== $i['auftrag_zustand']) { ?><span class="pille p-<?= $v->e($i['auftrag_zustand']['art']) ?>"><?= $v->e($i['auftrag_zustand']['text']) ?></span><?php } elseif (!$i['wartung']) { ?><span class="klein">–</span><?php } ?>
</td>
<td class="aktion">
<?php if ($i['kann_update'] && null !== $neueste && 'wartet' !== ($i['auftrag_zustand']['art'] ?? null)) { ?>
<form method="post" action="/auftrag">
<?= $v->csrf() ?>
<input type="hidden" name="id" value="<?= $v->e($i['id']) ?>">
<button type="submit" class="knopf knopf-klein">Auf <?= $v->e($neueste['version']) ?><span class="unsichtbar"> aktualisieren: <?= $v->e($i['domain']) ?></span></button>
</form>
<?php } ?>
</td>
</tr>
<?php } ?>
</tbody>
</table>
</div>
<?php } ?>