Files
Deploymentcenter/cli/tick.php
T
Deploymentcenter BotandClaude Opus 5 ceb977187e feat(releases): Zugangsschutz ueber Lizenzschluessel
/releases/ wurde bisher offen ausgeliefert, damit ausgelieferte Anwendungen
ohne Zugangsdaten nach Updates suchen koennen. Das bedeutete aber auch, dass
jeder im Internet die vollstaendigen Pakete herunterladen konnte - mitsamt
allem, was versehentlich darin liegt. Genau so lag ein echter API-Schluessel
in einer mitgelieferten appsettings.json oeffentlich abrufbar.

Zugang haengt jetzt am Lizenzschluessel: Wer eine gueltige Lizenz fuer ein
Produkt hat, kommt an dessen Updates. Die Anwendung kennt ihren Schluessel
ohnehin und versorgt sich damit selbst - es muss nichts verteilt werden.

Server
- ReleaseGuard erzeugt je Produktverzeichnis .htaccess und .htpasswd.
  Bewusst getrennt: eine gemeinsame Datei wuerde bedeuten, dass eine Lizenz
  fuer Produkt A auch Produkt B oeffnet. license_licenses.product_id bindet
  jeden Schluessel ohnehin an genau ein Projekt.
- Eingetragen werden aktive, nicht abgelaufene Lizenzen (Benutzername =
  Passwort = Schluessel; Basic Auth braucht zwei Felder, es gibt aber nur ein
  Geheimnis) sowie alle Installationskonten - bei einer Erstinstallation gibt
  es noch keinen Schluessel, mit dem sich das Paket holen liesse.
- Deren Hash wird unveraendert aus dc_users uebernommen: password_hash()
  erzeugt bcrypt im Format $2y$, genau das versteht Apache. Ein
  Klartextpasswort wird nirgends gebraucht. Argon2-Hashes werden erkannt und
  uebersprungen statt eine unbrauchbare Datei zu erzeugen.
- Lizenzschluessel werden mit Kosten 8 gehasht statt 12: 29 Zeichen
  maschineller Zufall sind kein Menschenpasswort, Apache prueft aber bei
  *jeder* Anfrage neu.
- Geschrieben wird ueber eine temporaere Datei mit rename() - ein Abbruch
  wuerde sonst eine halbe Zugangsdatei hinterlassen und in dem Moment die
  halbe Kundschaft aussperren.
- Neu erzeugt bei jeder Lizenz- und Kontoaenderung. Abgelaufene Lizenzen
  loesen anders als ein Widerruf nichts aus; dafuer gleicht cli/tick.php nach
  und erzeugt spaetestens alle sechs Stunden neu.
- Statusanzeige und Schaltflaeche im WebUI unter UpdateService.

Client
- ReleaseCredentials: Lizenzschluessel oder Installationskonto als Basic Auth.
- UpdateClient und Agent senden sie fuer latest.json und package.tar.gz.
- UpdateCheckResult.Unauthorized trennt "Lizenz traegt nicht mehr" von einem
  Netzwerkfehler. Ohne diese Unterscheidung sucht man an der falschen Stelle.
- LaunchUpdateAgent reicht licenseKey als --license-key durch.
- Der Installer benutzt die beim Anmelden eingegebenen Zugangsdaten auch fuer
  den Paketabruf; das Setup-Token taugt dafuer nicht, weil Apache prueft und
  nicht die Anwendung.

Sonstiges
- deploy.py klammert artifacts/ aus. Ohne das landeten die gebauten
  Installer-Binaries zusaetzlich unter /artifacts/ im Webroot.

ACHTUNG Reihenfolge: Der Schutz sperrt jede Anwendung aus, die noch mit dem
alten SDK gebaut ist. Erst ausliefern, dann scharfschalten - siehe
UPGRADE.md §16.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 11:21:49 +02:00

297 lines
9.5 KiB
PHP

#!/usr/bin/env php
<?php
/**
* Watchdog-Tick — Evaluationslauf von der Kommandozeile.
*
* Ersetzt den frueheren /watchdog/cli/tick.php. Macht dasselbe wie
* GET /api/watchdog/v1/evaluate, aber ohne HTTP-Umweg:
*
* - kein Shared Key im Crontab und damit nicht in der Prozessliste
* jedes Nutzers auf der Maschine (ps zeigt curl-Argumente),
* - keine Abhaengigkeit von Webserver, TLS, DNS oder Timeouts,
* - laeuft auch, wenn der Webserver gerade nicht erreichbar ist -
* also genau dann, wenn das Erkennen von Ausfaellen wichtig ist.
*
* Cron-Eintrag (minuetlich):
* * * * * * /usr/bin/php /pfad/zum/deploymentcenter/cli/tick.php --quiet
*
* Aufrufe:
* php cli/tick.php Lauf mit lesbarer Zusammenfassung
* php cli/tick.php --quiet nur bei Zustandswechseln und Fehlern Ausgabe
* php cli/tick.php --json maschinenlesbares Ergebnis
* php cli/tick.php --no-lock Sperre uebergehen (nur zur Fehlersuche)
* php cli/tick.php --help
*
* Rueckgabewerte:
* 0 Lauf erfolgreich
* 1 Fehler
* 2 uebersprungen, weil bereits ein Lauf aktiv ist
*/
declare(strict_types=1);
use Deploymentcenter\Core\Db;
use Deploymentcenter\Core\Logger;
use Deploymentcenter\Modules\Watchdog\Evaluator;
// ---------------------------------------------------------------------------
// Nur Kommandozeile.
//
// Erste und wichtigste Verteidigungslinie: sie wirkt unabhaengig davon, ob
// mod_rewrite, AllowOverride oder die .htaccess-Dateien greifen. Ohne sie
// koennte ein Aufruf ueber den Webserver einen Evaluationslauf ohne jede
// Authentifizierung ausloesen - der HTTP-Endpunkt verlangt dafuer aus gutem
// Grund den Shared Key oder eine angemeldete Sitzung.
// ---------------------------------------------------------------------------
if (PHP_SAPI !== 'cli') {
http_response_code(403);
header('Content-Type: text/plain; charset=utf-8');
exit("Dieses Skript laeuft ausschliesslich auf der Kommandozeile.\n");
}
require_once __DIR__ . '/../src/bootstrap.php';
// ---------------------------------------------------------------------------
// Fehlerbehandlung fuer die Kommandozeile.
//
// Der Handler aus bootstrap.php ruft Http::fail() auf: der schickt JSON samt
// Kopfzeilen und ist fuer eine HTTP-Antwort gedacht. Im Cron waere das eine
// unbrauchbare Mail und - schlimmer - der Rueckgabewert 0, sodass ein
// gescheiterter Lauf unbemerkt bliebe.
// ---------------------------------------------------------------------------
set_exception_handler(static function (\Throwable $e): void {
Logger::error('Watchdog-Tick abgebrochen', [
'error' => $e->getMessage(),
'file' => $e->getFile() . ':' . $e->getLine(),
]);
fwrite(STDERR, sprintf(
"[FEHLER] %s: %s\n %s:%d\n",
get_class($e),
$e->getMessage(),
$e->getFile(),
$e->getLine()
));
exit(1);
});
$options = parseArguments($argv);
if ($options['help']) {
printUsage();
exit(0);
}
// ---------------------------------------------------------------------------
// Ueberlappende Laeufe verhindern.
//
// Bei minuetlichem Cron und einem Lauf, der laenger als eine Minute braucht -
// etwa weil die Datenbank klemmt oder viele Webhooks zugestellt werden -
// starten sonst mehrere Evaluatoren gleichzeitig. Die wuerden dieselben
// Zustandswechsel doppelt protokollieren und doppelt alarmieren.
// ---------------------------------------------------------------------------
$lockHandle = null;
if (!$options['no-lock']) {
$lockFile = DC_VAR . '/watchdog-tick.lock';
if (!is_dir(DC_VAR)) {
@mkdir(DC_VAR, 0775, true);
}
$lockHandle = @fopen($lockFile, 'c');
if ($lockHandle === false) {
fwrite(STDERR, "[FEHLER] Sperrdatei nicht beschreibbar: {$lockFile}\n");
exit(1);
}
if (!flock($lockHandle, LOCK_EX | LOCK_NB)) {
// Kein Fehler, sondern der Normalfall bei einem langsamen Vorlauf.
if (!$options['quiet']) {
fwrite(STDERR, "[UEBERSPRUNGEN] Es laeuft bereits ein Tick.\n");
}
exit(2);
}
// Der Rueckgabewert von ftruncate ist hier ohne Belang: die Datei traegt
// nur die PID zur Fehlersuche, ihr Inhalt steuert nichts.
ftruncate($lockHandle, 0);
fwrite($lockHandle, (string)getmypid());
fflush($lockHandle);
}
// Kein try/finally um den Lauf: exit() fuehrt in PHP keine finally-Bloecke aus,
// die Freigabe waere dort also nur zum Schein notiert. Der Handler oben beendet
// den Prozess bei einer Exception - dabei gibt das Betriebssystem die Sperre
// ohnehin frei. Fuer den regulaeren Weg steht die Freigabe am Ende.
$startedAt = microtime(true);
$db = Db::init();
if ($options['report']) {
$reportRes = \Deploymentcenter\Modules\Notify\RocketChatNotifier::sendStatusReport($db, true);
if (!$options['quiet']) {
printf("Rocket.Chat Statusbericht: %s (%s)\n", $reportRes['sent'] ? 'ERFOLGREICH' : 'FEHLER', $reportRes['reason']);
}
}
// Zugangsschutz der Release-Verzeichnisse abgleichen.
//
// Abgelaufene Lizenzen loesen anders als ein Widerruf nichts aus - der
// Zeitpunkt vergeht einfach. Ohne diesen Abgleich behielten sie ihren Zugang.
// reconcile() prueft selbst, ob es etwas zu tun gibt, und macht im Normalfall
// nichts; ein minuetlicher Aufruf ist deshalb unbedenklich.
$guardResult = \Deploymentcenter\Modules\UpdateService\ReleaseGuard::reconcile($db);
if ($guardResult['ran'] && !$options['quiet']) {
printf(
"Release-Zugangsschutz erneuert (%s): %d Produkt(e), %d Zugang/Zugaenge%s\n",
$guardResult['reason'],
$guardResult['projects'],
$guardResult['entries'],
$guardResult['errors'] !== [] ? ', ' . count($guardResult['errors']) . ' Fehler' : ''
);
}
foreach ($guardResult['errors'] as $guardError) {
Logger::warning('Release-Zugangsschutz', ['error' => $guardError]);
}
$result = Evaluator::run($db);
$wallMs = (int)round((microtime(true) - $startedAt) * 1000);
if ($options['json']) {
echo json_encode(
$result + ['wall_ms' => $wallMs],
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
), PHP_EOL;
} elseif (!($options['quiet'] && $result['changed'] === 0)) {
// Im Cron soll nur etwas ankommen, wenn es etwas zu berichten gibt -
// sonst erzeugt ein minuetlicher Job 1440 Mails am Tag.
printSummary($result, $wallMs);
}
releaseLock($lockHandle);
exit(0);
// ===========================================================================
/**
* @param list<string> $argv
* @return array{help:bool,json:bool,quiet:bool,no-lock:bool}
*/
function parseArguments(array $argv): array
{
$options = ['help' => false, 'json' => false, 'quiet' => false, 'no-lock' => false, 'report' => false];
foreach (array_slice($argv, 1) as $arg) {
switch ($arg) {
case '-h':
case '--help':
$options['help'] = true;
break;
case '-j':
case '--json':
$options['json'] = true;
break;
case '-q':
case '--quiet':
$options['quiet'] = true;
break;
case '--no-lock':
$options['no-lock'] = true;
break;
case '-r':
case '--report':
case '--rocketchat-report':
$options['report'] = true;
break;
default:
fwrite(STDERR, "Unbekannte Option: {$arg}\n\n");
printUsage();
exit(1);
}
}
return $options;
}
/**
* Gibt die Sperre frei. Ohne aktive Sperre ein No-Op.
*
* @param resource|null $handle
*/
function releaseLock($handle): void
{
if ($handle === null) {
return;
}
flock($handle, LOCK_UN);
fclose($handle);
}
/** @param array<string,mixed> $result */
function printSummary(array $result, int $wallMs): void
{
printf(
"Watchdog-Tick: %d Monitor(e) geprueft, %d Zustandswechsel in %d ms (gesamt %d ms).\n",
$result['checked'],
$result['changed'],
$result['duration_ms'],
$wallMs
);
foreach ($result['changes'] as $change) {
printf(
" %-28s %s -> %-8s %s%s\n",
$change['source'],
$change['from'],
$change['to'],
$change['reason'],
$change['suppressed'] !== null
? sprintf(' [Alarm unterdrueckt, %s ist selbst unten]', $change['suppressed'])
: ''
);
}
if (($result['released_leases'] ?? 0) > 0) {
printf(" %d abgelaufene Bugtracker-Lease(s) freigegeben.\n", $result['released_leases']);
}
if (($result['purged_metrics'] ?? 0) > 0) {
printf(" %d alte Metrikwerte abgeraeumt.\n", $result['purged_metrics']);
}
}
function printUsage(): void
{
echo <<<TXT
Watchdog-Tick - Evaluationslauf von der Kommandozeile.
Stuft Monitore anhand ihres erwarteten Intervalls auf warning bzw. down,
protokolliert Zustandswechsel, loest Webhooks aus, gibt abgelaufene
Bugtracker-Leases frei und raeumt stuendlich den Metrik-Verlauf ab.
Aufruf:
php cli/tick.php [Optionen]
Optionen:
-q, --quiet Ausgabe nur bei Zustandswechseln (fuer Cron)
-j, --json Ergebnis als JSON
--no-lock Sperre gegen ueberlappende Laeufe uebergehen
-h, --help Diese Hilfe
Rueckgabewerte:
0 erfolgreich
1 Fehler
2 uebersprungen, es laeuft bereits ein Tick
Cron (minuetlich):
* * * * * /usr/bin/php /pfad/zum/deploymentcenter/cli/tick.php --quiet
TXT;
}