| = htmlspecialchars($r['product_slug']) ?> |
= strtoupper(htmlspecialchars($r['channel'] ?? 'prod')) ?> |
- v= htmlspecialchars($r['version']) ?> KRITISCH |
+
+
+ = htmlspecialchars($platformLabel) ?>
+ |
+
+ v= htmlspecialchars($r['version']) ?>
+ KRITISCH
+ UNSIGNIERT
+ |
= htmlspecialchars($r['git_commit'] ?? 'n/a') ?> |
= $sizeFormatted ?> |
= htmlspecialchars($r['release_notes'] ?? '-') ?> |
@@ -2633,6 +2694,22 @@ Antwort:
+
diff --git a/sql/migrations/008_watchdog_app_version.sql b/sql/migrations/008_watchdog_app_version.sql
new file mode 100644
index 0000000..afe4e9a
--- /dev/null
+++ b/sql/migrations/008_watchdog_app_version.sql
@@ -0,0 +1,22 @@
+-- Migration 008: Anwendungsversion am Heartbeat
+--
+-- Additive Migration. Der Migrator toleriert 1050/1060/1061/1062.
+
+-- ---------------------------------------------------------------------------
+-- Welche Version laeuft auf diesem Monitor?
+-- ---------------------------------------------------------------------------
+-- Der Fehler-Stream fuehrt build_version, der Bugtracker ebenso, die
+-- Aktivierungsliste des Lizenzmoduls app_version - nur der Watchdog konnte
+-- nicht sagen, welche Version eines Dienstes tatsaechlich laeuft. Damit war
+-- eine Aussage wie "Monitor X ist seit dem Rollout von 1.4.3 unten" nur ueber
+-- Umwege zu bekommen.
+--
+-- Das Feld ist optional; bestehende Agenten senden es nicht und bleiben
+-- unveraendert lauffaehig.
+ALTER TABLE watchdog_monitors
+ ADD COLUMN app_version VARCHAR(64) NULL AFTER os;
+
+-- Rollouts lassen sich damit gruppieren: "zeige alle Monitore, die noch auf
+-- der Vorversion stehen".
+ALTER TABLE watchdog_monitors
+ ADD KEY ix_wd_app_version (app_version);
diff --git a/sql/migrations/009_updateservice_platform.sql b/sql/migrations/009_updateservice_platform.sql
new file mode 100644
index 0000000..64ab99a
--- /dev/null
+++ b/sql/migrations/009_updateservice_platform.sql
@@ -0,0 +1,55 @@
+-- Migration 009: Plattform-Dimension im UpdateService
+--
+-- Additive Migration. Der Migrator toleriert 1050/1060/1061/1062/1091.
+
+-- ---------------------------------------------------------------------------
+-- Fuer welche Plattform gilt dieses Release?
+-- ---------------------------------------------------------------------------
+-- Der UpdateService kannte Produkt, Kanal und Version - aber keine Plattform.
+-- Seit fuer mehrere Laufzeitkennungen gebaut wird (win-x64, linux-x64, ...)
+-- landeten beide Pakete im selben Kanal unter derselben Version und
+-- ueberschrieben sich gegenseitig; ein Linux-System zog sich das
+-- Windows-Paket. Behelfe waren getrennte Produkt-Slugs oder zweckentfremdete
+-- Kanaele - beides trug nicht weit.
+--
+-- 'any' ist der Wert fuer plattformunabhaengige Releases und zugleich der
+-- Bestandsschutz: alles, was vor dieser Migration veroeffentlicht wurde, gilt
+-- weiterhin fuer jeden Client, der keine Plattform mitschickt.
+ALTER TABLE updateservice_releases
+ ADD COLUMN platform VARCHAR(32) NOT NULL DEFAULT 'any' AFTER channel;
+
+-- Die Eindeutigkeit muss die Plattform einschliessen, sonst verdraengt das
+-- zuletzt veroeffentlichte Paket einer Version alle anderen Plattformen
+-- derselben Version (ON DUPLICATE KEY UPDATE greift auf dem alten Schluessel).
+ALTER TABLE updateservice_releases
+ DROP INDEX uq_prod_ver_chan;
+
+ALTER TABLE updateservice_releases
+ ADD UNIQUE KEY uq_prod_ver_chan_plat (product_slug, version, channel, platform);
+
+-- Die Abfrage lautet immer "Produkt + Kanal + passende Plattform".
+ALTER TABLE updateservice_releases
+ ADD KEY ix_us_lookup (product_slug, channel, platform);
+
+-- ---------------------------------------------------------------------------
+-- Signatur des Releases
+-- ---------------------------------------------------------------------------
+-- Der SHA256 eines Pakets stammt aus derselben Quelle wie das Paket selbst.
+-- Das schuetzt gegen Uebertragungsfehler, nicht gegen einen manipulierten
+-- Webroot oder gestohlene FTP-Zugangsdaten - ausgerechnet auf dem Pfad, der
+-- fremden Code ausfuehrt.
+--
+-- Bewusst KEIN HMAC: Bei einem HMAC braucht der Pruefende denselben
+-- geheimen Schluessel wie der Signierende. Der Agent laeuft auf fremden
+-- Systemen; ein dort hinterlegter Schluessel koennte gestohlen und zum
+-- Signieren beliebiger Pakete benutzt werden - die Signatur waere wertlos.
+-- Beim Lizenzmodul geht HMAC auf, weil dort der Server prueft.
+--
+-- Stattdessen RSA-SHA256: der Server signiert mit dem privaten Schluessel
+-- (security.release_private_key), der Agent prueft mit dem oeffentlichen aus
+-- /api/updateservice/v1/pubkey. Base64 einer 2048-bit-Signatur sind 344
+-- Zeichen, daher TEXT und nicht VARCHAR(64).
+--
+-- Optional: Releases ohne Signatur bleiben installierbar, der Agent warnt.
+ALTER TABLE updateservice_releases
+ ADD COLUMN manifest_signature TEXT NULL AFTER manifest_json;
diff --git a/sql/schema.sql b/sql/schema.sql
index af4fc1d..375bf1e 100644
--- a/sql/schema.sql
+++ b/sql/schema.sql
@@ -94,6 +94,8 @@ CREATE TABLE IF NOT EXISTS watchdog_monitors (
is_muted TINYINT(1) NOT NULL DEFAULT 0,
expect_running TINYINT(1) NOT NULL DEFAULT 1,
os VARCHAR(50) NULL,
+ -- Version der laufenden Anwendung, vom Heartbeat mitgeliefert (optional).
+ app_version VARCHAR(64) NULL,
first_contact_deadline_utc DATETIME NULL,
ack_until_utc DATETIME NULL,
acknowledged_by VARCHAR(100) NULL,
@@ -168,15 +170,25 @@ CREATE TABLE IF NOT EXISTS updateservice_releases (
product_slug VARCHAR(64) NOT NULL,
version VARCHAR(32) NOT NULL,
channel VARCHAR(32) NOT NULL DEFAULT 'prod',
+ -- Laufzeitkennung (win-x64, linux-x64, ...) oder 'any' fuer
+ -- plattformunabhaengige Releases. Ohne diese Spalte ueberschrieben sich
+ -- Pakete verschiedener Plattformen derselben Version gegenseitig.
+ platform VARCHAR(32) NOT NULL DEFAULT 'any',
release_notes TEXT NULL,
download_url VARCHAR(255) NOT NULL,
sha256_hash VARCHAR(64) NULL,
git_commit VARCHAR(64) NULL,
size_bytes BIGINT NOT NULL DEFAULT 0,
manifest_json JSON NULL,
+ -- RSA-SHA256 ueber die kanonische Release-Zeile, base64-kodiert. Signiert
+ -- mit security.release_private_key, geprueft vom Agenten gegen den
+ -- oeffentlichen Schluessel. Kein HMAC - der Pruefende laeuft auf fremden
+ -- Systemen und darf den Signierschluessel nicht besitzen.
+ manifest_signature TEXT NULL,
is_critical TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- UNIQUE KEY uq_prod_ver_chan (product_slug, version, channel)
+ UNIQUE KEY uq_prod_ver_chan_plat (product_slug, version, channel, platform),
+ KEY ix_us_lookup (product_slug, channel, platform)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 5. Core Token Hierarchy & Bugtracker Tables
diff --git a/src/Modules/UpdateService/ReleaseSigner.php b/src/Modules/UpdateService/ReleaseSigner.php
new file mode 100644
index 0000000..a12743d
--- /dev/null
+++ b/src/Modules/UpdateService/ReleaseSigner.php
@@ -0,0 +1,157 @@
+db = $db;
}
+ /**
+ * Bringt eine Laufzeitkennung auf eine gueltige Form.
+ *
+ * Erwartet wird eine .NET-RID wie win-x64 oder linux-musl-arm64. Alles
+ * Leere wird zu 'any'; unbrauchbare Angaben ebenfalls, damit ein
+ * verunglueckter Parameter nicht dazu fuehrt, dass gar kein Release mehr
+ * gefunden wird.
+ */
+ public static function normalizePlatform(?string $platform): string
+ {
+ $value = strtolower(trim((string)$platform));
+
+ if ($value === '' || preg_match('/^[a-z0-9][a-z0-9._-]{0,31}$/', $value) !== 1) {
+ return self::PLATFORM_ANY;
+ }
+
+ return $value;
+ }
+
+ /**
+ * Releases eines Kanals, gefiltert auf die passende Plattform.
+ *
+ * Die Regel lautet: je Version gewinnt das Paket der angefragten
+ * Plattform, sonst das plattformunabhaengige. Damit bleiben Releases von
+ * vor der Plattform-Migration ('any') fuer alle Clients erreichbar,
+ * waehrend ein Client mit Plattformangabe niemals das Paket einer fremden
+ * Plattform bekommt.
+ *
+ * Ein Client ohne Plattformangabe sieht ausschliesslich 'any'. Das ist
+ * bewusst streng: lieber kein Update als das Windows-Paket auf einem
+ * Linux-System.
+ *
+ * @return list
>
+ */
+ private function releasesFor(string $productSlug, string $channel, ?string $platform): array
+ {
+ $requested = self::normalizePlatform($platform);
+
+ $candidates = $requested === self::PLATFORM_ANY
+ ? [self::PLATFORM_ANY]
+ : [$requested, self::PLATFORM_ANY];
+
+ $placeholders = implode(', ', array_map(
+ static fn(int $i): string => ':platform' . $i,
+ array_keys($candidates)
+ ));
+
+ $stmt = $this->db->prepare('
+ SELECT * FROM updateservice_releases
+ WHERE product_slug = :slug AND channel = :channel
+ AND platform IN (' . $placeholders . ')
+ ');
+
+ $params = [':slug' => $productSlug, ':channel' => $channel];
+ foreach ($candidates as $i => $candidate) {
+ $params[':platform' . $i] = $candidate;
+ }
+ $stmt->execute($params);
+
+ $rows = $stmt->fetchAll() ?: [];
+
+ // Je Version nur einen Datensatz behalten - den plattformgenauen.
+ $byVersion = [];
+ foreach ($rows as $row) {
+ $version = (string)($row['version'] ?? '');
+ if ($version === '') {
+ continue;
+ }
+
+ $rowPlatform = (string)($row['platform'] ?? self::PLATFORM_ANY);
+ $existing = $byVersion[$version] ?? null;
+
+ if ($existing === null) {
+ $byVersion[$version] = $row;
+ continue;
+ }
+
+ $existingPlatform = (string)($existing['platform'] ?? self::PLATFORM_ANY);
+ if ($existingPlatform === self::PLATFORM_ANY && $rowPlatform !== self::PLATFORM_ANY) {
+ $byVersion[$version] = $row;
+ }
+ }
+
+ return array_values($byVersion);
+ }
+
/**
* Ermittelt das neueste Release, das echt neuer ist als die uebergebene Version.
*
* @return array|null
*/
- public function checkUpdate(string $productSlug, string $currentVersion, string $channel = 'prod'): ?array
- {
- $stmt = $this->db->prepare('
- SELECT * FROM updateservice_releases
- WHERE product_slug = :slug AND channel = :channel
- ');
- $stmt->execute([':slug' => $productSlug, ':channel' => $channel]);
- $releases = $stmt->fetchAll() ?: [];
+ public function checkUpdate(
+ string $productSlug,
+ string $currentVersion,
+ string $channel = 'prod',
+ ?string $platform = null
+ ): ?array {
+ $releases = $this->releasesFor($productSlug, $channel, $platform);
if ($releases === []) {
return null;
@@ -52,14 +140,9 @@ final class UpdateManager
}
/** Hoechstes Release eines Kanals, unabhaengig von der Client-Version. */
- public function latestRelease(string $productSlug, string $channel = 'prod'): ?array
+ public function latestRelease(string $productSlug, string $channel = 'prod', ?string $platform = null): ?array
{
- $stmt = $this->db->prepare('
- SELECT * FROM updateservice_releases
- WHERE product_slug = :slug AND channel = :channel
- ');
- $stmt->execute([':slug' => $productSlug, ':channel' => $channel]);
- return Version::highest($stmt->fetchAll() ?: []);
+ return Version::highest($this->releasesFor($productSlug, $channel, $platform));
}
/**
@@ -78,41 +161,50 @@ final class UpdateManager
int $sizeBytes = 0,
?string $manifestJson = null,
bool $isCritical = false,
- string $author = 'admin'
+ string $author = 'admin',
+ ?string $platform = null,
+ ?string $manifestSignature = null
): array {
- $existing = $this->findRelease($productSlug, $version, $channel);
+ $platform = self::normalizePlatform($platform);
+
+ $existing = $this->findRelease($productSlug, $version, $channel, $platform);
$stmt = $this->db->prepare('
INSERT INTO updateservice_releases (
- product_slug, version, channel, release_notes, download_url,
- sha256_hash, git_commit, size_bytes, manifest_json, is_critical
+ product_slug, version, channel, platform, release_notes, download_url,
+ sha256_hash, git_commit, size_bytes, manifest_json, manifest_signature,
+ is_critical
) VALUES (
- :slug, :version, :channel, :notes, :url,
- :hash, :git, :size, :manifest, :critical
+ :slug, :version, :channel, :platform, :notes, :url,
+ :hash, :git, :size, :manifest, :signature,
+ :critical
) ON DUPLICATE KEY UPDATE
- release_notes = VALUES(release_notes),
- download_url = VALUES(download_url),
- sha256_hash = VALUES(sha256_hash),
- git_commit = VALUES(git_commit),
- size_bytes = VALUES(size_bytes),
- manifest_json = VALUES(manifest_json),
- is_critical = VALUES(is_critical)
+ release_notes = VALUES(release_notes),
+ download_url = VALUES(download_url),
+ sha256_hash = VALUES(sha256_hash),
+ git_commit = VALUES(git_commit),
+ size_bytes = VALUES(size_bytes),
+ manifest_json = VALUES(manifest_json),
+ manifest_signature = VALUES(manifest_signature),
+ is_critical = VALUES(is_critical)
');
$stmt->execute([
- ':slug' => $productSlug,
- ':version' => $version,
- ':channel' => $channel,
- ':notes' => $releaseNotes,
- ':url' => $downloadUrl,
- ':hash' => $sha256Hash !== null && $sha256Hash !== '' ? $sha256Hash : null,
- ':git' => $gitCommit !== null && $gitCommit !== '' ? $gitCommit : null,
- ':size' => $sizeBytes,
- ':manifest' => $manifestJson,
- ':critical' => $isCritical ? 1 : 0,
+ ':slug' => $productSlug,
+ ':version' => $version,
+ ':channel' => $channel,
+ ':platform' => $platform,
+ ':notes' => $releaseNotes,
+ ':url' => $downloadUrl,
+ ':hash' => $sha256Hash !== null && $sha256Hash !== '' ? $sha256Hash : null,
+ ':git' => $gitCommit !== null && $gitCommit !== '' ? $gitCommit : null,
+ ':size' => $sizeBytes,
+ ':manifest' => $manifestJson,
+ ':signature' => $manifestSignature !== null && $manifestSignature !== '' ? $manifestSignature : null,
+ ':critical' => $isCritical ? 1 : 0,
]);
- $release = $this->findRelease($productSlug, $version, $channel);
+ $release = $this->findRelease($productSlug, $version, $channel, $platform);
$releaseId = $release !== null ? (int)$release['id'] : 0;
// Bugtracker-Items, die fuer genau diesen Build vorgemerkt sind,
@@ -130,6 +222,7 @@ final class UpdateManager
'project_slug' => $productSlug,
'version' => $version,
'channel' => $channel,
+ 'platform' => $platform,
'is_critical' => $isCritical,
'download_url' => $downloadUrl,
'auto_resolved' => $autoResolved,
@@ -139,10 +232,11 @@ final class UpdateManager
}
Logger::info('Release veroeffentlicht', [
- 'product' => $productSlug,
- 'version' => $version,
- 'channel' => $channel,
- 'author' => $author,
+ 'product' => $productSlug,
+ 'version' => $version,
+ 'channel' => $channel,
+ 'platform' => $platform,
+ 'author' => $author,
]);
return [
@@ -152,14 +246,24 @@ final class UpdateManager
];
}
- public function findRelease(string $productSlug, string $version, string $channel): ?array
- {
+ public function findRelease(
+ string $productSlug,
+ string $version,
+ string $channel,
+ ?string $platform = null
+ ): ?array {
$stmt = $this->db->prepare('
SELECT * FROM updateservice_releases
WHERE product_slug = :slug AND version = :version AND channel = :channel
+ AND platform = :platform
LIMIT 1
');
- $stmt->execute([':slug' => $productSlug, ':version' => $version, ':channel' => $channel]);
+ $stmt->execute([
+ ':slug' => $productSlug,
+ ':version' => $version,
+ ':channel' => $channel,
+ ':platform' => self::normalizePlatform($platform),
+ ]);
$row = $stmt->fetch();
return is_array($row) ? $row : null;
}
@@ -176,8 +280,12 @@ final class UpdateManager
*
* @return list>
*/
- public function getReleases(?string $productSlug = null, ?string $channel = null, int $limit = 200): array
- {
+ public function getReleases(
+ ?string $productSlug = null,
+ ?string $channel = null,
+ int $limit = 200,
+ ?string $platform = null
+ ): array {
$where = [];
$params = [];
@@ -190,17 +298,26 @@ final class UpdateManager
$params[':channel'] = $channel;
}
+ // Hier wird bewusst exakt gefiltert statt aufgeloest: dieser Endpunkt
+ // listet den Bestand, er waehlt kein Paket aus.
+ if ($platform !== null && $platform !== '') {
+ $where[] = 'platform = :platform';
+ $params[':platform'] = self::normalizePlatform($platform);
+ }
+
$sql = 'SELECT * FROM updateservice_releases';
if ($where !== []) {
$sql .= ' WHERE ' . implode(' AND ', $where);
}
- $sql .= ' ORDER BY product_slug ASC, channel ASC, created_at DESC LIMIT ' . max(1, min($limit, 1000));
+ $sql .= ' ORDER BY product_slug ASC, channel ASC, platform ASC, created_at DESC LIMIT '
+ . max(1, min($limit, 1000));
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
$releases = $stmt->fetchAll() ?: [];
- // Innerhalb einer Produkt/Kanal-Gruppe nach echter Versionsordnung sortieren.
+ // Innerhalb einer Produkt/Kanal/Plattform-Gruppe nach echter
+ // Versionsordnung sortieren.
usort($releases, static function (array $a, array $b): int {
$bySlug = strcmp((string)$a['product_slug'], (string)$b['product_slug']);
if ($bySlug !== 0) {
@@ -210,6 +327,13 @@ final class UpdateManager
if ($byChannel !== 0) {
return $byChannel;
}
+ $byPlatform = strcmp(
+ (string)($a['platform'] ?? self::PLATFORM_ANY),
+ (string)($b['platform'] ?? self::PLATFORM_ANY)
+ );
+ if ($byPlatform !== 0) {
+ return $byPlatform;
+ }
return Version::compare((string)$b['version'], (string)$a['version']);
});
diff --git a/src/Modules/Watchdog/Evaluator.php b/src/Modules/Watchdog/Evaluator.php
index 229d7dc..e7c2e5e 100644
--- a/src/Modules/Watchdog/Evaluator.php
+++ b/src/Modules/Watchdog/Evaluator.php
@@ -25,7 +25,11 @@ use PDO;
* stuft Monitore entsprechend auf warning bzw. down. Zustandswechsel landen
* im Event-Log und loesen Webhooks aus.
*
- * Aufruf per Cron (empfohlen minuetlich):
+ * Aufruf per Cron (empfohlen minuetlich), bevorzugt intern ohne HTTP:
+ * * * * * * /usr/bin/php /pfad/zum/deploymentcenter/cli/tick.php --quiet
+ *
+ * Alternativ ueber die Schnittstelle, wenn der Cron auf einer anderen Maschine
+ * laeuft als die Anwendung:
* curl -H "Authorization: Bearer " https://dc.example.com/api/watchdog/v1/evaluate
*/
final class Evaluator
diff --git a/src/Modules/Watchdog/MonitorRepo.php b/src/Modules/Watchdog/MonitorRepo.php
index 8a07f37..bf63c8b 100644
--- a/src/Modules/Watchdog/MonitorRepo.php
+++ b/src/Modules/Watchdog/MonitorRepo.php
@@ -86,7 +86,8 @@ final class MonitorRepo
?string $message,
?string $groupKey = null,
?string $os = null,
- $checks = null
+ $checks = null,
+ ?string $appVersion = null
): array {
$metricsJson = (is_array($metrics) || is_object($metrics))
? json_encode($metrics, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
@@ -139,11 +140,11 @@ final class MonitorRepo
INSERT INTO watchdog_monitors (
source, instance, type, state, last_state_change_utc, expected_interval_sec,
last_seen_utc, last_status, last_message, metrics_json, health_json,
- failing_checks, group_key, os, created_utc, updated_utc
+ failing_checks, group_key, os, app_version, created_utc, updated_utc
) VALUES (
:source, :instance, :type, :state, UTC_TIMESTAMP(), :interval,
UTC_TIMESTAMP(), :last_status, :message, :metrics, :health,
- :failing, :group_key, :os, UTC_TIMESTAMP(), UTC_TIMESTAMP()
+ :failing, :group_key, :os, :app_version, UTC_TIMESTAMP(), UTC_TIMESTAMP()
)
ON DUPLICATE KEY UPDATE
-- Reihenfolge ist relevant: MySQL wertet die Zuweisungen von
@@ -161,6 +162,9 @@ final class MonitorRepo
failing_checks = VALUES(failing_checks),
group_key = COALESCE(VALUES(group_key), group_key),
os = COALESCE(VALUES(os), os),
+ -- COALESCE, damit ein Agent, der die Version nicht mitschickt,
+ -- eine frueher gemeldete nicht loescht.
+ app_version = COALESCE(VALUES(app_version), app_version),
updated_utc = VALUES(updated_utc)
');
@@ -177,6 +181,7 @@ final class MonitorRepo
':failing' => $failing !== [] ? mb_substr(implode(', ', $failing), 0, 255) : null,
':group_key' => $groupKey,
':os' => $os,
+ ':app_version' => $appVersion !== null ? mb_substr($appVersion, 0, 64) : null,
]);
$monitor = $this->getMonitor($source, $instance);