feat(updateservice): Plattform-Dimension, signierte Releases, Update mit Rollback

Behebt eine Reihe zusammenhaengender Fehler im Update-Weg, die zusammen
verhindert haben, fuer mehr als eine Plattform auszuliefern - und die im
Fehlerfall halb aktualisierte Installationen hinterliessen.

Server
- Migration 009: Spalte platform samt neuem Unique-Key. Zuvor verdraengte das
  zuletzt veroeffentlichte Paket alle anderen Plattformen derselben Version,
  weil ON DUPLICATE KEY auf (slug, version, channel) griff. Ein Linux-System
  zog sich damit das Windows-Paket.
- Aufloesungsregel: je Version das plattformgenaue Paket, sonst das
  plattformunabhaengige. Ein Client ohne Plattformangabe sieht ausschliesslich
  'any' - lieber kein Update als das falsche.
- manifest_json wird endlich befuellt; die Spalte blieb bisher immer leer,
  wodurch die API nie der Rueckfall sein konnte, als der sie gedacht war.
- Releases werden serverseitig mit RSA-SHA256 signiert, neuer Endpunkt
  /api/updateservice/v1/pubkey. Bewusst kein HMAC: der Pruefende laeuft auf
  fremden Systemen und darf den Signierschluessel nicht besitzen.

Packager
- Bricht ab, statt die Versionshistorie zu verlieren. Schlug das Lesen der
  bestehenden latest.json fehl, ersetzte ein leeres catch die komplette
  Historie durch einen einzigen Eintrag - ohne jede Meldung.
- Echte Glob-Muster. Zuvor trafen "logs/**" und "scratch/**" aus der
  mitgelieferten Beispielkonfiguration nie zu.
- preservePatterns: Konfigurationsvorlagen werden ausgeliefert, ersetzen am
  Ziel aber keine vorhandene Datei. Eine settings.json mit Zugangsdaten
  ueberschrieb bisher beim Update die Konfiguration jedes Zielsystems.
- Warnt vor Dateien, die nach Zugangsdaten aussehen und auf keiner Liste stehen.
- Prueft --version gegen die Hauptassembly. Eine Abweichung fuehrte zu einer
  Endlosschleife: Clients aktualisieren, melden weiter die alte Version,
  halten das Release erneut fuer neu.
- --platform mit Ableitung aus dem Publish-Pfad.

Agent
- Anwenden mit Plan, Backup und vollstaendigem Rollback. Die Stelle war als
  "Atomic Replace with Backup" kommentiert und war eine Kopierschleife.
- Verwaiste Dateien werden entfernt, aber nur solche aus dem Manifest der
  Vorversion. Was nicht aus einem Release stammt, bleibt liegen.
- Das laufende Agent-Binary wird zur Seite gelegt statt ueberschrieben.
- API-Rueckfall in FetchManifestAsync; bisher nur im SDK vorhanden, weshalb
  die Anwendung "Update verfuegbar" und der Agent "kein Release" sagen konnte.
- Installierte Version aus --current-version oder manifest.json statt des
  Textes "Unbekannt", der als 0 gelesen wurde und jede Version neuer erscheinen
  liess. Reparatur funktioniert damit auch ohne manifest.json.
- Setzt das Ausfuehrungsbit fuer Linux-Pakete, die unter Windows gebaut wurden.

SDK
- ResolveAgentPath() liefert den plattformrichtigen Namen; ein fest verdrahtetes
  "update-agent.exe" wird unter Linux nie gefunden.
- LaunchUpdateAgent uebergibt jetzt --restart (wurde nie uebergeben, die
  Anwendung blieb nach dem Update zu), --wait-for-pid (kein Wettlauf mehr mit
  dem Herunterfahren) und --platform.

Enthaelt ausserdem die bislang nicht committete Arbeit an Watchdog, Lizenz-
Client und cli/tick.php samt Migration 008; die betroffenen Dateien liessen
sich nicht getrennt stagen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Deploymentcenter Bot
2026-08-09 19:56:35 +02:00
co-authored by Claude Opus 5
parent 5f9b0c5596
commit 2388b5abe1
37 changed files with 5498 additions and 491 deletions
+2
View File
@@ -1,5 +1,7 @@
.deploy_cache.json .deploy_cache.json
scratch/ scratch/
# Laufzeitsperren der CLI-Skripte
var/*.lock
*.bak *.bak
.DS_Store .DS_Store
bin/ bin/
+1 -1
View File
@@ -56,7 +56,7 @@ Options -Indexes
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 5. Alles Uebrige sperren: Anwendungscode, Konfiguration, Skripte # 5. Alles Uebrige sperren: Anwendungscode, Konfiguration, Skripte
# ------------------------------------------------------------------ # ------------------------------------------------------------------
RewriteRule ^(config|src|sql|scripts|var|client-dotnet)(/|$) - [F,L] RewriteRule ^(config|src|sql|scripts|cli|var|client-dotnet)(/|$) - [F,L]
RewriteRule ^Serverdaten\.txt$ - [F,L] RewriteRule ^Serverdaten\.txt$ - [F,L]
</IfModule> </IfModule>
+14
View File
@@ -0,0 +1,14 @@
# Kommandozeilenskripte - kein Webzugriff.
#
# Zweite Verteidigungslinie. Die erste ist die SAPI-Pruefung in den Skripten
# selbst: sie wirkt auch dann, wenn mod_rewrite, AllowOverride oder diese
# Datei nicht greifen.
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
+261
View File
@@ -0,0 +1,261 @@
#!/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();
$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];
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;
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;
}
@@ -1,49 +1,117 @@
<Project> <Project>
<!-- MSBuild target to generate BuildInfo.g.cs automatically prior to compilation --> <!--
<Target Name="GenerateDeploymentcenterBuildInfo" BeforeTargets="CoreCompile"> Erzeugt vor jeder Uebersetzung eine BuildInfo-Klasse mit Version,
UTC-Build-Datum und Git-Commit.
Einbindung in der .csproj des Consumers:
<Import Project="..\Deploymentcenter.Client\Deploymentcenter.BuildInfo.targets" />
Die Klasse entsteht im Namensraum des einbindenden Projekts
($(RootNamespace)), nicht in dem des SDK.
Die vorherige Fassung erzeugte "public static partial class BuildInfo" im
Namensraum Deploymentcenter.Client.Models. Dort liefert das SDK aber bereits
eine gleichnamige, nicht partielle Klasse aus: im Consumer entstand ein
zweiter Typ mit demselben vollen Namen in einer zweiten Assembly (CS0433),
und der generierte statische Konstruktor setzte Eigenschaften, die in seiner
Teilklasse gar nicht deklariert waren (CS0103). Einbinden war damit
unmoeglich - obwohl der UpdateService-Leitfaden genau dazu riet. Der
generierte Code deklariert seine Werte jetzt selbst und braucht keine
Gegenstelle im SDK.
Ueberschreibbare Eigenschaften:
DeploymentcenterBuildInfoNamespace Zielnamensraum (Vorgabe: RootNamespace)
DeploymentcenterBuildInfoClass Klassenname (Vorgabe: BuildInfo)
BuildChannel prod, beta, ... (Vorgabe: prod)
GenerateDeploymentcenterBuildInfo auf false setzen, um abzuschalten
-->
<PropertyGroup>
<GenerateDeploymentcenterBuildInfo Condition="'$(GenerateDeploymentcenterBuildInfo)' == ''">true</GenerateDeploymentcenterBuildInfo>
<DeploymentcenterBuildInfoClass Condition="'$(DeploymentcenterBuildInfoClass)' == ''">BuildInfo</DeploymentcenterBuildInfoClass>
</PropertyGroup>
<Target Name="GenerateDeploymentcenterBuildInfo"
BeforeTargets="CoreCompile"
Condition="'$(GenerateDeploymentcenterBuildInfo)' == 'true'">
<PropertyGroup> <PropertyGroup>
<BuildInfoFile>$(IntermediateOutputPath)BuildInfo.g.cs</BuildInfoFile> <!-- Zielnamensraum: ausdrueckliche Angabe, sonst RootNamespace, sonst Projektname. -->
<DeploymentcenterBuildInfoNamespace Condition="'$(DeploymentcenterBuildInfoNamespace)' == ''">$(RootNamespace)</DeploymentcenterBuildInfoNamespace>
<DeploymentcenterBuildInfoNamespace Condition="'$(DeploymentcenterBuildInfoNamespace)' == ''">$(MSBuildProjectName)</DeploymentcenterBuildInfoNamespace>
<BuildInfoFile>$(IntermediateOutputPath)DeploymentcenterBuildInfo.g.cs</BuildInfoFile>
<BuildDateUtc>$([System.DateTime]::UtcNow.ToString("o"))</BuildDateUtc> <BuildDateUtc>$([System.DateTime]::UtcNow.ToString("o"))</BuildDateUtc>
<BuildVersion Condition="'$(Version)' != ''">$(Version)</BuildVersion>
<BuildVersion Condition="'$(BuildVersion)' == ''">1.0.0</BuildVersion> <!-- Version aus <Version>, ersatzweise <AssemblyVersion>, sonst 0.0.0. -->
<BuildVersion Condition="'$(BuildVersion)' == ''">$(Version)</BuildVersion>
<BuildVersion Condition="'$(BuildVersion)' == ''">$(AssemblyVersion)</BuildVersion>
<BuildVersion Condition="'$(BuildVersion)' == ''">0.0.0</BuildVersion>
<BuildChannel Condition="'$(BuildChannel)' == ''">prod</BuildChannel> <BuildChannel Condition="'$(BuildChannel)' == ''">prod</BuildChannel>
</PropertyGroup> </PropertyGroup>
<Exec Command="git rev-parse HEAD" ConsoleToMSBuild="true" IgnoreExitCode="true"> <!--
<Output TaskParameter="ConsoleOutput" PropertyName="GitCommitLong" /> ContinueOnError, damit ein Build ohne Git-Arbeitskopie oder ohne
installiertes Git nicht scheitert. IgnoreExitCode allein genuegte nicht:
fehlt die ausfuehrbare Datei, bricht Exec selbst ab.
-->
<Exec Command="git rev-parse HEAD"
ConsoleToMSBuild="true"
IgnoreExitCode="true"
ContinueOnError="true"
StandardErrorImportance="low">
<Output TaskParameter="ConsoleOutput" PropertyName="DcGitCommitLong" />
</Exec> </Exec>
<Exec Command="git rev-parse --short HEAD" ConsoleToMSBuild="true" IgnoreExitCode="true">
<Output TaskParameter="ConsoleOutput" PropertyName="GitCommitShort" /> <Exec Command="git rev-parse --short HEAD"
ConsoleToMSBuild="true"
IgnoreExitCode="true"
ContinueOnError="true"
StandardErrorImportance="low">
<Output TaskParameter="ConsoleOutput" PropertyName="DcGitCommitShort" />
</Exec> </Exec>
<PropertyGroup> <PropertyGroup>
<GitCommitLong Condition="'$(GitCommitLong)' == ''">UNKNOWN_COMMIT</GitCommitLong> <DcGitCommitLong Condition="'$(DcGitCommitLong)' == ''">UNKNOWN_COMMIT</DcGitCommitLong>
<GitCommitShort Condition="'$(GitCommitShort)' == ''">UNKNOWN</GitCommitShort> <DcGitCommitShort Condition="'$(DcGitCommitShort)' == ''">UNKNOWN</DcGitCommitShort>
<!-- Zeilenumbrueche aus der Git-Ausgabe entfernen, sie wuerden das Literal sprengen. -->
<DcGitCommitLong>$(DcGitCommitLong.Trim())</DcGitCommitLong>
<DcGitCommitShort>$(DcGitCommitShort.Trim())</DcGitCommitShort>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<BuildInfoLine Include="// &lt;auto-generated /&gt;" /> <BuildInfoLine Include="// &lt;auto-generated /&gt;" />
<BuildInfoLine Include="using System%3B" /> <BuildInfoLine Include="// Erzeugt von Deploymentcenter.BuildInfo.targets - nicht von Hand aendern." />
<BuildInfoLine Include="namespace Deploymentcenter.Client.Models" /> <BuildInfoLine Include="namespace $(DeploymentcenterBuildInfoNamespace)" />
<BuildInfoLine Include="{" /> <BuildInfoLine Include="{" />
<BuildInfoLine Include=" public static partial class BuildInfo" /> <BuildInfoLine Include=" /// &lt;summary&gt;Zur Uebersetzungszeit eingebettete Build-Daten.&lt;/summary&gt;" />
<BuildInfoLine Include=" public static class $(DeploymentcenterBuildInfoClass)" />
<BuildInfoLine Include=" {" /> <BuildInfoLine Include=" {" />
<BuildInfoLine Include=" static BuildInfo()" /> <BuildInfoLine Include=" public const string Version = &quot;$(BuildVersion)&quot;%3B" />
<BuildInfoLine Include=" public const string GitCommit = &quot;$(DcGitCommitLong)&quot;%3B" />
<BuildInfoLine Include=" public const string GitCommitShort = &quot;$(DcGitCommitShort)&quot;%3B" />
<BuildInfoLine Include=" public const string BuildDateUtc = &quot;$(BuildDateUtc)&quot;%3B" />
<BuildInfoLine Include=" public const string Channel = &quot;$(BuildChannel)&quot;%3B" />
<!-- Include darf nicht leer sein, daher ein Leerzeichen statt einer Leerzeile. -->
<BuildInfoLine Include=" " />
<BuildInfoLine Include=" public static string Summary" />
<BuildInfoLine Include=" {" /> <BuildInfoLine Include=" {" />
<BuildInfoLine Include=" Version = &quot;$(BuildVersion)&quot;%3B" /> <BuildInfoLine Include=" get { return &quot;v&quot; + Version + &quot; (&quot; + GitCommitShort + &quot;) built on &quot; + BuildDateUtc + &quot; [&quot; + Channel + &quot;]&quot;%3B }" />
<BuildInfoLine Include=" GitCommit = &quot;$(GitCommitLong)&quot;%3B" />
<BuildInfoLine Include=" GitCommitShort = &quot;$(GitCommitShort)&quot;%3B" />
<BuildInfoLine Include=" BuildDateUtc = &quot;$(BuildDateUtc)&quot;%3B" />
<BuildInfoLine Include=" Channel = &quot;$(BuildChannel)&quot;%3B" />
<BuildInfoLine Include=" }" /> <BuildInfoLine Include=" }" />
<BuildInfoLine Include=" }" /> <BuildInfoLine Include=" }" />
<BuildInfoLine Include="}" /> <BuildInfoLine Include="}" />
</ItemGroup> </ItemGroup>
<WriteLinesToFile File="$(BuildInfoFile)" Lines="@(BuildInfoLine)" Overwrite="true" WriteOnlyWhenDifferent="true" /> <WriteLinesToFile File="$(BuildInfoFile)"
Lines="@(BuildInfoLine)"
Overwrite="true"
WriteOnlyWhenDifferent="true" />
<ItemGroup> <ItemGroup>
<Compile Include="$(BuildInfoFile)" /> <Compile Include="$(BuildInfoFile)" />
<FileWrites Include="$(BuildInfoFile)" />
</ItemGroup> </ItemGroup>
</Target> </Target>
</Project> </Project>
@@ -0,0 +1,173 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.RegularExpressions;
namespace Deploymentcenter.Client
{
/// <summary>
/// Abgleich von Pfaden gegen Glob-Muster.
///
/// Der Packager verstand zuvor nur "*.endung" und exakte Namen. Muster wie
/// "logs/**" oder "wwwroot/cache/*" trafen deshalb nie zu - sie standen in
/// der mitgelieferten Beispielkonfiguration und weckten den Eindruck, die
/// betreffenden Dateien seien ausgeschlossen. Ein Konfigurationsfile, das
/// so unbemerkt ins Paket rutscht, ueberschreibt beim naechsten Update die
/// Einstellungen jedes Zielsystems.
///
/// Regeln, angelehnt an .gitignore:
/// * trifft beliebig viele Zeichen ausser dem Trenner /
/// ** trifft beliebig viele Zeichen einschliesslich /
/// ? trifft genau ein Zeichen ausser /
/// Muster ohne / werden gegen den Dateinamen geprueft,
/// Muster mit / gegen den vollstaendigen relativen Pfad.
/// Ein Muster, das auf / endet, trifft alles unterhalb dieses Ordners.
///
/// Verglichen wird ohne Beachtung der Gross-/Kleinschreibung: Ziel sind
/// Windows- und Linux-Systeme gleichermassen, und ein Muster, das nur auf
/// einer der beiden Plattformen greift, ist gefaehrlicher als ein Muster,
/// das etwas zu viel trifft.
/// </summary>
public static class GlobMatcher
{
private static readonly Dictionary<string, Regex> Cache = new Dictionary<string, Regex>(StringComparer.Ordinal);
private static readonly object CacheLock = new object();
/// <summary>
/// Prueft einen relativen Pfad gegen eine Liste von Mustern.
/// Leere Listen treffen nie.
/// </summary>
public static bool IsMatch(string relativePath, IEnumerable<string>? patterns)
{
if (patterns == null || string.IsNullOrEmpty(relativePath))
return false;
string normalized = Normalize(relativePath);
foreach (var pattern in patterns)
{
if (IsMatch(normalized, pattern, alreadyNormalized: true))
return true;
}
return false;
}
/// <summary>Prueft einen relativen Pfad gegen ein einzelnes Muster.</summary>
public static bool IsMatch(string relativePath, string? pattern, bool alreadyNormalized = false)
{
if (string.IsNullOrWhiteSpace(pattern) || string.IsNullOrEmpty(relativePath))
return false;
string path = alreadyNormalized ? relativePath : Normalize(relativePath);
string trimmed = pattern!.Trim();
if (trimmed.Length == 0)
return false;
// Ein fuehrendes ./ oder / bedeutet "ab Wurzel" und ist fuer den
// Vergleich mit einem ohnehin relativen Pfad ohne Bedeutung.
if (trimmed.StartsWith("./", StringComparison.Ordinal))
trimmed = trimmed.Substring(2);
else if (trimmed.StartsWith("/", StringComparison.Ordinal))
trimmed = trimmed.Substring(1);
// "logs/" meint alles unterhalb von logs.
if (trimmed.EndsWith("/", StringComparison.Ordinal))
trimmed += "**";
var regex = GetRegex(trimmed);
// Muster ohne Trenner gelten fuer den Dateinamen an beliebiger
// Stelle im Baum - "*.pdb" soll auch runtimes/x/y.pdb treffen.
if (trimmed.IndexOf('/') < 0)
{
int slash = path.LastIndexOf('/');
string fileName = slash >= 0 ? path.Substring(slash + 1) : path;
return regex.IsMatch(fileName);
}
return regex.IsMatch(path);
}
/// <summary>Vereinheitlicht Trenner und entfernt ein fuehrendes ./</summary>
public static string Normalize(string path)
{
string value = (path ?? string.Empty).Replace('\\', '/').TrimStart();
if (value.StartsWith("./", StringComparison.Ordinal))
value = value.Substring(2);
return value;
}
private static Regex GetRegex(string pattern)
{
lock (CacheLock)
{
if (Cache.TryGetValue(pattern, out var cached))
return cached;
var regex = new Regex(
"^" + Translate(pattern) + "$",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
// Die Musterlisten stammen aus Konfigurationsdateien und sind
// klein; die Obergrenze verhindert nur unbegrenztes Wachsen,
// falls doch einmal dynamisch erzeugte Muster hereinkommen.
if (Cache.Count < 512)
Cache[pattern] = regex;
return regex;
}
}
/// <summary>Uebersetzt ein Glob-Muster in einen regulaeren Ausdruck.</summary>
private static string Translate(string pattern)
{
var builder = new StringBuilder();
for (int i = 0; i < pattern.Length; i++)
{
char c = pattern[i];
switch (c)
{
case '*':
bool doubleStar = i + 1 < pattern.Length && pattern[i + 1] == '*';
if (doubleStar)
{
i++;
// "a/**/b" muss auch "a/b" treffen, sonst waere ein
// Muster wie "logs/**" auf den Ordner selbst blind.
if (i + 1 < pattern.Length && pattern[i + 1] == '/')
{
i++;
builder.Append("(?:.*/)?");
}
else
{
builder.Append(".*");
}
}
else
{
builder.Append("[^/]*");
}
break;
case '?':
builder.Append("[^/]");
break;
default:
builder.Append(Regex.Escape(c.ToString()));
break;
}
}
return builder.ToString();
}
}
}
@@ -1,7 +1,9 @@
using System; using System;
using System.Net.Http; using System.Net.Http;
using System.Reflection;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace Deploymentcenter.Client; namespace Deploymentcenter.Client;
@@ -13,25 +15,88 @@ public class LicenseValidationResult
public string Message { get; set; } = string.Empty; public string Message { get; set; } = string.Empty;
public string HardwareId { get; set; } = string.Empty; public string HardwareId { get; set; } = string.Empty;
public bool IsCached { get; set; } public bool IsCached { get; set; }
/// <summary>Ablauf der Lizenz selbst (Unix-Zeit), nicht der des Caches.</summary>
public long? ExpiresAt { get; set; } public long? ExpiresAt { get; set; }
/// <summary>
/// Ablauf der Offline-Gnadenfrist (Unix-Zeit). Danach verlangt der Client
/// wieder eine erreichbare Gegenstelle, auch wenn die Lizenz laenger laeuft.
/// </summary>
public long? CacheExpiresAt { get; set; }
/// <summary>
/// Wahr, wenn kein Lizenzurteil vorliegt, sondern nur die Verbindung zum
/// Server gescheitert ist (Netzfehler, HTTP 429/5xx, unlesbare Antwort).
///
/// Ein solcher Zustand darf eine Anwendung nicht beenden. Nur ein vom
/// Server geliefertes Urteil (revoked, expired, not_found,
/// activation_limit, suspended) ist endgueltig.
/// </summary>
public bool IsTransient { get; set; }
} }
public class LicenseClient public class LicenseClient
{ {
/// <summary>
/// Voreinstellung fuer app_version, wenn ein Aufrufer sie nicht je Aufruf
/// uebergibt. Einmal beim Start setzen, z. B. auf BuildInfo.Version.
/// </summary>
public static string? DefaultAppVersion { get; set; }
/// <summary>
/// Standard-Zeitgrenze fuer den intern erzeugten HttpClient. Ohne sie
/// stand eine Anwendung beim Start bis zu 100 Sekunden still, wenn der
/// Server nicht antwortete.
/// </summary>
public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(15);
private static readonly Lazy<HttpClient> SharedHttpClient = new Lazy<HttpClient>(
() => new HttpClient { Timeout = DefaultTimeout },
LazyThreadSafetyMode.ExecutionAndPublication);
/// <summary>Standard-Gnadenfrist, wenn der Server keine TTL mitschickt.</summary>
private const int FallbackCacheTtlHours = 168;
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly ILicensePrompt _prompt; private readonly ILicensePrompt _prompt;
/// <summary>
/// Ohne eigenen HttpClient wird eine gemeinsame Instanz mit
/// <see cref="DefaultTimeout"/> verwendet. Ein uebergebener HttpClient
/// wird nicht veraendert - dessen Zeitgrenze verantwortet der Aufrufer.
/// </summary>
public LicenseClient(HttpClient? httpClient = null, ILicensePrompt? prompt = null) public LicenseClient(HttpClient? httpClient = null, ILicensePrompt? prompt = null)
{ {
_httpClient = httpClient ?? new HttpClient(); _httpClient = httpClient ?? SharedHttpClient.Value;
_prompt = prompt ?? new ConsoleLicensePrompt(); _prompt = prompt ?? new ConsoleLicensePrompt();
} }
public async Task<LicenseValidationResult> ValidateAsync(string productSlug, string licenseKey, string serverBaseUrl) public Task<LicenseValidationResult> ValidateAsync(
string productSlug,
string licenseKey,
string serverBaseUrl,
CancellationToken cancellationToken)
=> ValidateAsync(productSlug, licenseKey, serverBaseUrl, null, cancellationToken);
/// <param name="appVersion">
/// Version der aufrufenden Anwendung. Landet in der Aktivierungsliste des
/// Deploymentcenters. Ohne Angabe wird <see cref="DefaultAppVersion"/> und
/// danach die Version der Startassembly verwendet.
/// </param>
public async Task<LicenseValidationResult> ValidateAsync(
string productSlug,
string licenseKey,
string serverBaseUrl,
string? appVersion = null,
CancellationToken cancellationToken = default)
{ {
var hwInfo = HardwareId.GetHardwareId(productSlug); var hwInfo = HardwareId.GetHardwareId(productSlug);
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
string? failureReason = null;
HttpResponseMessage? response = null;
try try
{ {
var payload = new var payload = new
@@ -44,7 +109,7 @@ public class LicenseClient
hwid_source = hwInfo.HwidSource, hwid_source = hwInfo.HwidSource,
platform = hwInfo.Platform, platform = hwInfo.Platform,
hostname = Environment.MachineName, hostname = Environment.MachineName,
app_version = "1.0.0", app_version = ResolveAppVersion(appVersion),
nonce = Guid.NewGuid().ToString("N") nonce = Guid.NewGuid().ToString("N")
}; };
@@ -52,122 +117,318 @@ public class LicenseClient
var content = new StringContent(jsonStr, Encoding.UTF8, "application/json"); var content = new StringContent(jsonStr, Encoding.UTF8, "application/json");
string endpoint = $"{serverBaseUrl.TrimEnd('/')}/api/license/v1/validate"; string endpoint = $"{serverBaseUrl.TrimEnd('/')}/api/license/v1/validate";
HttpResponseMessage response = await _httpClient.PostAsync(endpoint, content); response = await _httpClient.PostAsync(endpoint, content, cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode) }
{ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
string resBody = await response.Content.ReadAsStringAsync(); {
using var doc = JsonDocument.Parse(resBody); throw;
var root = doc.RootElement;
string status = root.TryGetProperty("status", out var sProp) ? sProp.GetString() ?? "unknown" : "unknown";
string message = root.TryGetProperty("message", out var mProp) ? mProp.GetString() ?? "" : "";
long? expiresAt = root.TryGetProperty("expires_at", out var eProp) && eProp.ValueKind == JsonValueKind.Number ? eProp.GetInt64() : null;
if (status.Equals("valid", StringComparison.OrdinalIgnoreCase))
{
// Save encrypted local cache
var cache = new LocalCacheData
{
SchemaVersion = 2,
ProductSlug = productSlug,
LicenseKey = licenseKey,
HardwareId = hwInfo.HardwareId,
Status = "valid",
IssuedAt = now,
ExpiresAt = expiresAt ?? (now + 7 * 86400),
MaxSeenTime = now,
Checksum = hwInfo.HardwareId
};
StateStore.Save(productSlug, hwInfo.HardwareId, cache);
return new LicenseValidationResult
{
IsValid = true,
Status = status,
Message = message,
HardwareId = hwInfo.HardwareId,
IsCached = false,
ExpiresAt = expiresAt
};
}
else
{
return new LicenseValidationResult
{
IsValid = false,
Status = status,
Message = message,
HardwareId = hwInfo.HardwareId,
IsCached = false
};
}
}
} }
catch (Exception ex) catch (Exception ex)
{ {
// Server request failed -> Fall back to encrypted offline cache failureReason = ex.Message;
var cache = StateStore.Load(productSlug, hwInfo.HardwareId); }
if (cache != null && cache.Status == "valid")
using (response)
{
if (response != null)
{ {
// Check time-rollback protection if (response.IsSuccessStatusCode)
if (now < cache.MaxSeenTime)
{ {
return new LicenseValidationResult string resBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
var verdict = TryReadVerdict(resBody, productSlug, licenseKey, hwInfo, now);
if (verdict != null)
{ {
IsValid = false, return verdict;
Status = "clock_rollback", }
Message = "System clock rollback detected! Online verification required.",
HardwareId = hwInfo.HardwareId // Erfolgreiche Antwort, aber kein lesbares Urteil - etwa die
}; // Fehlerseite eines Proxys mit Statuscode 200. Das ist keine
// Aussage ueber die Lizenz, also wird sie auch nicht als
// solche behandelt.
failureReason = "Antwort des Servers war nicht auswertbar";
} }
else
// Check cache expiry
if (cache.ExpiresAt > 0 && now > cache.ExpiresAt)
{ {
return new LicenseValidationResult // WICHTIG: Ein HTTP-Fehler ist kein Lizenzurteil.
{ //
IsValid = false, // Frueher lag der Cache-Zweig ausschliesslich im catch.
Status = "cache_expired", // Ein 429 (Drosselung) oder 500 warf keine Exception,
Message = "Cached license has expired.", // sondern fiel aus dem Erfolgszweig heraus und endete als
HardwareId = hwInfo.HardwareId // "unknown_error" - ohne den Cache auch nur zu befragen.
}; // Ein Serverfehler entzog damit die Lizenz, ein gezogenes
// Netzkabel nicht. Jetzt fuehrt jeder Nicht-Erfolg in
// denselben Offline-Zweig.
failureReason = $"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".Trim();
} }
}
}
// Update max_seen_time return OfflineFallback(productSlug, hwInfo, now, failureReason ?? "Server nicht erreichbar");
cache.MaxSeenTime = now; }
StateStore.Save(productSlug, hwInfo.HardwareId, cache);
return new LicenseValidationResult /// <summary>
{ /// Wertet die Serverantwort aus. Liefert null, wenn sie kein lesbares
IsValid = true, /// Lizenzurteil enthaelt - dann greift der Offline-Zweig.
Status = "valid_offline", /// </summary>
Message = "License validated via secure offline cache", private static LicenseValidationResult? TryReadVerdict(
HardwareId = hwInfo.HardwareId, string resBody,
IsCached = true, string productSlug,
ExpiresAt = cache.ExpiresAt string licenseKey,
}; HardwareIdResult hwInfo,
long now)
{
string status;
string message;
long? expiresAt;
int cacheTtlHours;
try
{
using var doc = JsonDocument.Parse(resBody);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty("status", out var sProp))
{
return null;
} }
status = sProp.GetString() ?? string.Empty;
if (string.IsNullOrWhiteSpace(status))
{
return null;
}
message = root.TryGetProperty("message", out var mProp) ? mProp.GetString() ?? "" : "";
expiresAt = root.TryGetProperty("expires_at", out var eProp) && eProp.ValueKind == JsonValueKind.Number
? eProp.GetInt64()
: (long?)null;
// Der Server bestimmt die Laenge der Offline-Gnadenfrist je Projekt.
// Vorher wurde das Feld ignoriert und stattdessen das Ablaufdatum
// der Lizenz eingetragen - bei einer Lizenz bis 2040 war die Frist
// praktisch unbegrenzt.
cacheTtlHours = root.TryGetProperty("cache_ttl_hours", out var tProp) && tProp.ValueKind == JsonValueKind.Number
? tProp.GetInt32()
: FallbackCacheTtlHours;
}
catch (JsonException)
{
return null;
}
if (cacheTtlHours <= 0)
{
cacheTtlHours = FallbackCacheTtlHours;
}
if (!status.Equals("valid", StringComparison.OrdinalIgnoreCase))
{
return new LicenseValidationResult return new LicenseValidationResult
{ {
IsValid = false, IsValid = false,
Status = "network_error", Status = status,
Message = $"Server communication error and no valid cache available: {ex.Message}", Message = message,
HardwareId = hwInfo.HardwareId HardwareId = hwInfo.HardwareId,
IsCached = false,
ExpiresAt = expiresAt,
IsTransient = false
};
}
// Die Gnadenfrist endet mit der TTL - spaetestens aber mit der Lizenz.
long cacheExpiresAt = now + (long)cacheTtlHours * 3600L;
if (expiresAt.HasValue && expiresAt.Value > 0 && expiresAt.Value < cacheExpiresAt)
{
cacheExpiresAt = expiresAt.Value;
}
var cache = new LocalCacheData
{
SchemaVersion = StateStore.CurrentSchemaVersion,
ProductSlug = productSlug,
LicenseKey = licenseKey,
HardwareId = hwInfo.HardwareId,
Status = "valid",
IssuedAt = now,
ExpiresAt = expiresAt ?? 0,
CacheExpiresAt = cacheExpiresAt,
CacheTtlHours = cacheTtlHours,
MaxSeenTime = now,
Checksum = hwInfo.HardwareId
};
StateStore.Save(productSlug, hwInfo.HardwareId, cache);
return new LicenseValidationResult
{
IsValid = true,
Status = status,
Message = message,
HardwareId = hwInfo.HardwareId,
IsCached = false,
ExpiresAt = expiresAt,
CacheExpiresAt = cacheExpiresAt,
IsTransient = false
};
}
/// <summary>
/// Gemeinsamer Zweig fuer jeden Fall, in dem der Server kein Urteil
/// geliefert hat: Netzfehler, HTTP-Fehler, unlesbare Antwort.
/// </summary>
private static LicenseValidationResult OfflineFallback(
string productSlug,
HardwareIdResult hwInfo,
long now,
string reason)
{
var cache = StateStore.Load(productSlug, hwInfo.HardwareId);
if (cache != null && cache.Status == "valid")
{
// Rueckdrehen der Systemuhr erkennen
if (now < cache.MaxSeenTime)
{
return new LicenseValidationResult
{
IsValid = false,
Status = "clock_rollback",
Message = "System clock rollback detected! Online verification required.",
HardwareId = hwInfo.HardwareId,
IsTransient = false
};
}
long cacheExpiresAt = ResolveCacheExpiry(cache);
if (cacheExpiresAt > 0 && now > cacheExpiresAt)
{
return new LicenseValidationResult
{
IsValid = false,
Status = "cache_expired",
Message = $"Offline-Gnadenfrist abgelaufen, Server nicht erreichbar ({reason}).",
HardwareId = hwInfo.HardwareId,
ExpiresAt = cache.ExpiresAt > 0 ? cache.ExpiresAt : null,
CacheExpiresAt = cacheExpiresAt,
IsTransient = true
};
}
// Auch offline darf eine abgelaufene Lizenz nicht weiterlaufen.
if (cache.ExpiresAt > 0 && now > cache.ExpiresAt)
{
return new LicenseValidationResult
{
IsValid = false,
Status = "expired",
Message = "Cached license has expired.",
HardwareId = hwInfo.HardwareId,
ExpiresAt = cache.ExpiresAt,
IsTransient = false
};
}
cache.MaxSeenTime = now;
StateStore.Save(productSlug, hwInfo.HardwareId, cache);
return new LicenseValidationResult
{
IsValid = true,
Status = "valid_offline",
Message = $"License validated via secure offline cache ({reason}).",
HardwareId = hwInfo.HardwareId,
IsCached = true,
ExpiresAt = cache.ExpiresAt > 0 ? cache.ExpiresAt : null,
CacheExpiresAt = cacheExpiresAt,
IsTransient = false
}; };
} }
return new LicenseValidationResult return new LicenseValidationResult
{ {
IsValid = false, IsValid = false,
Status = "unknown_error", Status = "server_unavailable",
Message = "Validation failed.", Message = $"Server communication error and no valid cache available: {reason}",
HardwareId = hwInfo.HardwareId HardwareId = hwInfo.HardwareId,
IsTransient = true
}; };
} }
public async Task<bool> DeactivateAsync(string productSlug, string licenseKey, string serverBaseUrl, string authToken = "") /// <summary>
/// Caches aus Schema 2 kennen kein CacheExpiresAt. Fuer sie wird die Frist
/// aus dem Ausstellungszeitpunkt und der Standard-TTL abgeleitet, statt
/// unbegrenzt zu gelten.
/// </summary>
private static long ResolveCacheExpiry(LocalCacheData cache)
{
if (cache.CacheExpiresAt > 0)
{
return cache.CacheExpiresAt;
}
int ttlHours = cache.CacheTtlHours > 0 ? cache.CacheTtlHours : FallbackCacheTtlHours;
return cache.IssuedAt > 0 ? cache.IssuedAt + (long)ttlHours * 3600L : 0;
}
/// <summary>
/// Liest den zuletzt erfolgreich geprueften Schluessel aus dem lokalen
/// Cache. Damit laesst sich ein Schalter wie --license-status oder ein
/// stiller Neustart bauen, ohne den Schluessel ein zweites Mal abzulegen.
/// Liefert null, wenn kein brauchbarer Cache vorliegt.
/// </summary>
public static string? TryGetCachedKey(string productSlug)
{
var cache = TryGetCachedState(productSlug);
return string.IsNullOrWhiteSpace(cache?.LicenseKey) ? null : cache!.LicenseKey;
}
/// <summary>
/// Vollstaendiger Cache-Eintrag fuer Statusausgaben (Ablauf, Gnadenfrist,
/// Hardware-ID). Liefert null, wenn keiner vorliegt oder er nicht zu dieser
/// Maschine gehoert.
/// </summary>
public static LocalCacheData? TryGetCachedState(string productSlug)
{
var hwInfo = HardwareId.GetHardwareId(productSlug);
return StateStore.Load(productSlug, hwInfo.HardwareId);
}
/// <summary>
/// Prueft erneut mit dem zuletzt zwischengespeicherten Schluessel.
/// Liefert not_found, wenn noch nie erfolgreich aktiviert wurde.
/// </summary>
public async Task<LicenseValidationResult> RevalidateAsync(
string productSlug,
string serverBaseUrl,
string? appVersion = null,
CancellationToken cancellationToken = default)
{
string? key = TryGetCachedKey(productSlug);
if (key == null)
{
var hwInfo = HardwareId.GetHardwareId(productSlug);
return new LicenseValidationResult
{
IsValid = false,
Status = "not_found",
Message = "Kein zwischengespeicherter Lizenzschluessel vorhanden.",
HardwareId = hwInfo.HardwareId,
IsTransient = false
};
}
return await ValidateAsync(productSlug, key, serverBaseUrl, appVersion, cancellationToken).ConfigureAwait(false);
}
public async Task<bool> DeactivateAsync(
string productSlug,
string licenseKey,
string serverBaseUrl,
string authToken = "",
CancellationToken cancellationToken = default)
{ {
var hwInfo = HardwareId.GetHardwareId(productSlug); var hwInfo = HardwareId.GetHardwareId(productSlug);
var payload = new var payload = new
@@ -182,25 +443,79 @@ public class LicenseClient
var content = new StringContent(jsonStr, Encoding.UTF8, "application/json"); var content = new StringContent(jsonStr, Encoding.UTF8, "application/json");
string endpoint = $"{serverBaseUrl.TrimEnd('/')}/api/license/v1/deactivate"; string endpoint = $"{serverBaseUrl.TrimEnd('/')}/api/license/v1/deactivate";
var request = new HttpRequestMessage(HttpMethod.Post, endpoint) using var request = new HttpRequestMessage(HttpMethod.Post, endpoint)
{ {
Content = content Content = content
}; };
if (!string.IsNullOrWhiteSpace(authToken)) if (!string.IsNullOrWhiteSpace(authToken))
{ {
request.Headers.Add("X-Watchdog-Key", authToken); // Nur noch der Standardweg. Der zusaetzliche X-Watchdog-Key war ein
// Ueberbleibsel des alten Servers und irrefuehrend benannt: hier
// gehoert der shared_key hin, kein Watchdog-Token. Erschwerend
// prueft der Server X-Watchdog-Key VOR Authorization - ein dort
// versehentlich eingetragenes Watchdog-Token haette das richtige
// Bearer-Token stillschweigend verdraengt.
request.Headers.Add("Authorization", $"Bearer {authToken}"); request.Headers.Add("Authorization", $"Bearer {authToken}");
} }
try try
{ {
HttpResponseMessage response = await _httpClient.SendAsync(request); using HttpResponseMessage response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
return response.IsSuccessStatusCode; return response.IsSuccessStatusCode;
} }
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch catch
{ {
return false; return false;
} }
} }
private static string ResolveAppVersion(string? explicitVersion)
{
if (!string.IsNullOrWhiteSpace(explicitVersion))
{
return explicitVersion!.Trim();
}
if (!string.IsNullOrWhiteSpace(DefaultAppVersion))
{
return DefaultAppVersion!.Trim();
}
// Vorher stand hier fest "1.0.0". In der Aktivierungsliste des
// Deploymentcenters trug damit jede Installation dieselbe Version,
// obwohl die Spalte app_version dafuer vorgesehen ist.
try
{
var assembly = Assembly.GetEntryAssembly();
if (assembly != null)
{
string? informational = assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
if (!string.IsNullOrWhiteSpace(informational))
{
// Das SDK haengt bei manchen Buildeinstellungen "+<commit>" an.
int plus = informational!.IndexOf('+');
return plus > 0 ? informational.Substring(0, plus) : informational;
}
var version = assembly.GetName().Version;
if (version != null)
{
return version.ToString();
}
}
}
catch
{
// Version zu ermitteln darf eine Lizenzpruefung nie scheitern lassen.
}
return "unknown";
}
} }
@@ -3,7 +3,18 @@ using System;
namespace Deploymentcenter.Client.Models namespace Deploymentcenter.Client.Models
{ {
/// <summary> /// <summary>
/// Runtime accessibility for build metadata embedded at compile-time. /// Zur Laufzeit setzbare Build-Daten des SDK selbst.
///
/// NICHT die Klasse, die Deploymentcenter.BuildInfo.targets erzeugt: die
/// entsteht im Namensraum des einbindenden Projekts und traegt dessen
/// Version. Diese hier ist nur ein Ablageort fuer Anwendungen, die ihre
/// Version zur Laufzeit von Hand setzen wollen.
///
/// Der Zielnamensraum des Targets laesst sich ueber
/// DeploymentcenterBuildInfoNamespace umstellen - er darf nur nicht auf
/// diesen hier zeigen: partial verbindet Teilklassen nur innerhalb
/// derselben Assembly, ueber Assemblygrenzen hinweg entstuenden zwei Typen
/// mit demselben vollen Namen (CS0433).
/// </summary> /// </summary>
public static class BuildInfo public static class BuildInfo
{ {
@@ -0,0 +1,58 @@
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Deploymentcenter.Client.Models
{
/// <summary>
/// Liest Wahrheitswerte, die nicht als JSON-Boolean ankommen.
///
/// Die Deploymentcenter-API reicht Release-Zeilen unveraendert aus MySQL
/// durch. <c>is_critical</c> ist dort TINYINT(1) und erscheint je nach
/// PDO-Einstellung als 1, "1" oder true. Der Standardkonverter von
/// System.Text.Json wirft bei allem ausser true/false - und der Fehler
/// haette den gesamten Release-Datensatz unbrauchbar gemacht.
/// </summary>
public class FlexibleBoolConverter : JsonConverter<bool>
{
public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
switch (reader.TokenType)
{
case JsonTokenType.True:
return true;
case JsonTokenType.False:
case JsonTokenType.Null:
return false;
case JsonTokenType.Number:
return reader.TryGetInt64(out long number) ? number != 0 : reader.GetDouble() != 0d;
case JsonTokenType.String:
string? value = reader.GetString();
if (string.IsNullOrWhiteSpace(value))
return false;
value = value!.Trim();
if (bool.TryParse(value, out bool parsed))
return parsed;
if (long.TryParse(value, out long numeric))
return numeric != 0;
return value.Equals("yes", StringComparison.OrdinalIgnoreCase)
|| value.Equals("on", StringComparison.OrdinalIgnoreCase);
default:
return false;
}
}
public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options)
{
writer.WriteBooleanValue(value);
}
}
}
@@ -19,6 +19,14 @@ namespace Deploymentcenter.Client.Models
[JsonPropertyName("channel")] [JsonPropertyName("channel")]
public string Channel { get; set; } = "prod"; public string Channel { get; set; } = "prod";
/// <summary>
/// Laufzeitkennung des Pakets ("win-x64", "linux-x64", ...) oder
/// "any". Fehlt das Feld, stammt das Manifest aus der Zeit vor der
/// Plattform-Dimension und gilt als plattformunabhaengig.
/// </summary>
[JsonPropertyName("platform")]
public string Platform { get; set; } = PlatformId.Any;
[JsonPropertyName("buildDate")] [JsonPropertyName("buildDate")]
public string BuildDate { get; set; } = string.Empty; public string BuildDate { get; set; } = string.Empty;
@@ -31,6 +39,20 @@ namespace Deploymentcenter.Client.Models
[JsonPropertyName("changelog")] [JsonPropertyName("changelog")]
public string Changelog { get; set; } = string.Empty; public string Changelog { get; set; } = string.Empty;
/// <summary>
/// Muster fuer Dateien, die zwar im Paket liegen, eine vorhandene
/// Datei am Ziel aber nicht ersetzen duerfen - typischerweise
/// Konfigurationsvorlagen wie appsettings.json.
///
/// Ausschluss beim Packen und Schutz beim Anwenden sind zwei
/// verschiedene Dinge. Wer eine Konfigurationsvorlage ausliefern will,
/// kann sie nicht einfach vom Paket ausnehmen; sie darf nur beim
/// Update nicht ueber die eingerichtete Fassung des Zielsystems
/// geschrieben werden.
/// </summary>
[JsonPropertyName("preserve")]
public List<string> Preserve { get; set; } = new List<string>();
[JsonPropertyName("files")] [JsonPropertyName("files")]
public List<PackageFileEntry> Files { get; set; } = new List<PackageFileEntry>(); public List<PackageFileEntry> Files { get; set; } = new List<PackageFileEntry>();
} }
@@ -15,6 +15,13 @@ namespace Deploymentcenter.Client.Models
[JsonPropertyName("channel")] [JsonPropertyName("channel")]
public string Channel { get; set; } = "prod"; public string Channel { get; set; } = "prod";
/// <summary>
/// Laufzeitkennung des Kanals. Fehlt sie, stammt die Datei aus der
/// Zeit vor der Plattform-Dimension und gilt als "any".
/// </summary>
[JsonPropertyName("platform")]
public string Platform { get; set; } = PlatformId.Any;
[JsonPropertyName("latest")] [JsonPropertyName("latest")]
public VersionInfo? Latest { get; set; } public VersionInfo? Latest { get; set; }
@@ -22,6 +29,80 @@ namespace Deploymentcenter.Client.Models
public List<VersionInfo> Versions { get; set; } = new List<VersionInfo>(); public List<VersionInfo> Versions { get; set; } = new List<VersionInfo>();
} }
/// <summary>
/// Release-Daten so, wie sie <c>/api/updateservice/v1/check</c> unter
/// <c>latest_release</c> liefert: die Zeile aus <c>updateservice_releases</c>
/// mit ihren Spaltennamen.
///
/// Bewusst ein eigenes Modell. Zuvor wurde die API-Antwort in
/// <see cref="VersionInfo"/> deserialisiert, das die camelCase-Namen der
/// statischen latest.json traegt. Von beiden Formaten stimmt nur "version"
/// ueberein - ueber die API kamen also weder Download-Adresse noch Pruefsumme,
/// Changelog oder Kritikalitaet an. Der API-Zweig ist genau der Rueckfall,
/// wenn die latest.json fehlt; er degradierte damit still.
/// </summary>
public class ApiReleaseInfo
{
[JsonPropertyName("version")]
public string Version { get; set; } = string.Empty;
[JsonPropertyName("channel")]
public string Channel { get; set; } = "prod";
[JsonPropertyName("platform")]
public string Platform { get; set; } = PlatformId.Any;
[JsonPropertyName("download_url")]
public string DownloadUrl { get; set; } = string.Empty;
/// <summary>
/// RSA-SHA256 ueber die kanonische Release-Zeile, base64. Der Server
/// bildet sie beim Veroeffentlichen; leer, wenn dort kein
/// Signierschluessel hinterlegt ist.
/// </summary>
[JsonPropertyName("manifest_signature")]
public string ManifestSignature { get; set; } = string.Empty;
[JsonPropertyName("sha256_hash")]
public string Sha256Hash { get; set; } = string.Empty;
[JsonPropertyName("release_notes")]
public string ReleaseNotes { get; set; } = string.Empty;
[JsonPropertyName("git_commit")]
public string GitCommit { get; set; } = string.Empty;
[JsonPropertyName("size_bytes")]
public long SizeBytes { get; set; }
[JsonPropertyName("created_at")]
public string CreatedAt { get; set; } = string.Empty;
/// <summary>
/// MySQL liefert TINYINT(1) als 0/1, PHP je nach Treiber auch als
/// Zeichenkette. <see cref="FlexibleBoolConverter"/> nimmt beides.
/// </summary>
[JsonPropertyName("is_critical")]
[JsonConverter(typeof(FlexibleBoolConverter))]
public bool IsCritical { get; set; }
/// <summary>Uebersetzt in das Modell, das Aufrufer bereits kennen.</summary>
public VersionInfo ToVersionInfo() => new VersionInfo
{
Version = Version,
BuildDate = CreatedAt,
GitCommit = GitCommit,
GitCommitShort = GitCommit.Length >= 7 ? GitCommit.Substring(0, 7) : GitCommit,
PackageUrl = DownloadUrl,
Sha256 = Sha256Hash,
SizeBytes = SizeBytes,
Changelog = ReleaseNotes,
IsCritical = IsCritical,
Platform = Platform,
Signature = ManifestSignature
};
}
/// <summary> /// <summary>
/// Individual release version details. /// Individual release version details.
/// </summary> /// </summary>
@@ -53,5 +134,21 @@ namespace Deploymentcenter.Client.Models
[JsonPropertyName("isCritical")] [JsonPropertyName("isCritical")]
public bool IsCritical { get; set; } public bool IsCritical { get; set; }
/// <summary>
/// Laufzeitkennung des Pakets. Aeltere latest.json-Dateien kennen das
/// Feld nicht; sie gelten dann als plattformunabhaengig.
/// </summary>
[JsonPropertyName("platform")]
public string Platform { get; set; } = PlatformId.Any;
/// <summary>
/// Signatur des Servers, sofern der Agent das Release ueber die API
/// bezogen hat. Die statische latest.json fuehrt sie nicht: sie wird
/// vom Packager geschrieben, und der besitzt den Signierschluessel
/// bewusst nicht.
/// </summary>
[JsonPropertyName("signature")]
public string Signature { get; set; } = string.Empty;
} }
} }
@@ -0,0 +1,203 @@
using System;
using System.Runtime.InteropServices;
namespace Deploymentcenter.Client
{
/// <summary>
/// Laufzeitkennung (.NET-RID) eines Releases.
///
/// Der UpdateService kannte lange nur Projekt, Kanal und Version. Sobald
/// fuer mehrere Plattformen gebaut wurde, landeten alle Pakete unter
/// derselben Version im selben Kanal und ueberschrieben sich - ein
/// Linux-System zog sich das Windows-Paket. Diese Klasse liefert die
/// gemeinsame Sprache dafuer: Packager, Agent und Server benutzen
/// dieselbe Schreibweise und dieselben Ablagepfade.
/// </summary>
public static class PlatformId
{
/// <summary>Kennung fuer plattformunabhaengige Releases.</summary>
public const string Any = "any";
/// <summary>
/// Kennung des laufenden Systems, z. B. "win-x64" oder "linux-arm64".
/// </summary>
public static string Current
{
get
{
#if NET8_0_OR_GREATER
string rid = RuntimeInformation.RuntimeIdentifier;
if (!string.IsNullOrWhiteSpace(rid))
{
return Normalize(rid);
}
#endif
return Normalize(ComposeFallback());
}
}
/// <summary>
/// Bringt eine Kennung auf die Form, die auch der Server verwendet.
/// Unbrauchbare Angaben werden zu "any" - ein verunglueckter Parameter
/// soll nicht dazu fuehren, dass gar kein Release mehr gefunden wird.
/// </summary>
public static string Normalize(string? platform)
{
string value = (platform ?? string.Empty).Trim().ToLowerInvariant();
if (value.Length == 0)
return Any;
// Portable RIDs tragen manchmal eine Betriebssystemversion
// ("win10-x64", "ubuntu.22.04-x64"). Fuer die Auswahl eines Pakets
// ist nur die Familie samt Architektur interessant.
value = CollapseVersioned(value);
foreach (char c in value)
{
bool allowed = (c >= 'a' && c <= 'z')
|| (c >= '0' && c <= '9')
|| c == '-' || c == '.' || c == '_';
if (!allowed)
return Any;
}
return value.Length > 32 ? Any : value;
}
/// <summary>Ist das die plattformunabhaengige Kennung?</summary>
public static bool IsAny(string? platform)
{
return Normalize(platform) == Any;
}
/// <summary>
/// Pfadsegment fuer die Release-Ablage.
///
/// Plattformunabhaengige Releases behalten bewusst den alten Pfad
/// ohne Zwischenebene. Andernfalls waeren alle bereits ausgelieferten
/// Anwendungen von einem Tag auf den anderen von ihren Updates
/// abgeschnitten.
/// </summary>
public static string PathSegment(string? platform)
{
string normalized = Normalize(platform);
return normalized == Any ? string.Empty : "/" + normalized;
}
/// <summary>
/// Versucht, die Kennung aus einem Publish-Pfad zu lesen, etwa
/// "bin/Release/net8.0/linux-x64/publish". Liefert null, wenn der Pfad
/// keine erkennbare Kennung enthaelt.
/// </summary>
public static string? InferFromPath(string? path)
{
if (string.IsNullOrWhiteSpace(path))
return null;
string[] segments = path!.Replace('\\', '/').Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
// Von hinten, weil die Kennung in der Konvention unmittelbar vor
// "publish" steht und weiter vorne zufaellig gleichnamige Ordner
// liegen koennen.
for (int i = segments.Length - 1; i >= 0; i--)
{
string candidate = segments[i].ToLowerInvariant();
if (LooksLikeRid(candidate))
{
return Normalize(candidate);
}
}
return null;
}
private static bool LooksLikeRid(string value)
{
int dash = value.IndexOf('-');
if (dash <= 0 || dash == value.Length - 1)
return false;
string os = value.Substring(0, dash);
string rest = value.Substring(dash + 1);
bool knownOs = os == "win" || os == "linux" || os == "osx"
|| os.StartsWith("win", StringComparison.Ordinal)
|| os.StartsWith("linux", StringComparison.Ordinal)
|| os.StartsWith("osx", StringComparison.Ordinal);
if (!knownOs)
return false;
return rest == "x64" || rest == "x86" || rest == "arm64" || rest == "arm"
|| rest.EndsWith("-x64", StringComparison.Ordinal)
|| rest.EndsWith("-arm64", StringComparison.Ordinal);
}
/// <summary>
/// "win10-x64" und "ubuntu.22.04-x64" auf "win-x64" bzw. "linux-x64"
/// zurueckfuehren. Ohne das entstuenden fuer dasselbe Paket mehrere
/// Kennungen, je nachdem, auf welchem System gebaut wurde.
/// </summary>
private static string CollapseVersioned(string value)
{
int dash = value.LastIndexOf('-');
if (dash <= 0)
return value;
string os = value.Substring(0, dash);
string arch = value.Substring(dash + 1);
if (os.StartsWith("win", StringComparison.Ordinal))
return "win-" + arch;
if (os.StartsWith("osx", StringComparison.Ordinal) || os.StartsWith("macos", StringComparison.Ordinal))
return "osx-" + arch;
// musl ist eine eigene Zielplattform - ein glibc-Paket laeuft dort
// nicht, die Unterscheidung muss also erhalten bleiben.
if (os.Contains("musl"))
return "linux-musl-" + arch;
if (os.StartsWith("linux", StringComparison.Ordinal)
|| os.StartsWith("ubuntu", StringComparison.Ordinal)
|| os.StartsWith("debian", StringComparison.Ordinal)
|| os.StartsWith("alpine", StringComparison.Ordinal)
|| os.StartsWith("rhel", StringComparison.Ordinal)
|| os.StartsWith("centos", StringComparison.Ordinal)
|| os.StartsWith("fedora", StringComparison.Ordinal))
{
return os.StartsWith("alpine", StringComparison.Ordinal)
? "linux-musl-" + arch
: "linux-" + arch;
}
return value;
}
private static string ComposeFallback()
{
string os;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
os = "win";
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
os = "osx";
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
os = "linux";
else
return Any;
string arch;
switch (RuntimeInformation.ProcessArchitecture)
{
case Architecture.X64: arch = "x64"; break;
case Architecture.X86: arch = "x86"; break;
case Architecture.Arm64: arch = "arm64"; break;
case Architecture.Arm: arch = "arm"; break;
default: return Any;
}
return os + "-" + arch;
}
}
}
@@ -0,0 +1,134 @@
using System;
using System.Security.Cryptography;
using System.Text;
#if NETSTANDARD2_0
using Org.BouncyCastle.Crypto;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Security;
#endif
namespace Deploymentcenter.Client
{
/// <summary>
/// Prueft die Signatur eines Releases.
///
/// Der SHA256 eines Pakets stammt aus derselben Quelle wie das Paket
/// selbst. Wer den Webroot oder die FTP-Zugangsdaten kontrolliert, tauscht
/// beide gemeinsam aus - der Hash schuetzt dann gegen Uebertragungsfehler,
/// nicht gegen Manipulation. Ausgerechnet auf dem Pfad, der fremden Code
/// ausfuehrt.
///
/// Bewusst asymmetrisch: bei einem HMAC braeuchte der pruefende Agent
/// denselben geheimen Schluessel wie der signierende Server. Der Agent
/// laeuft auf Kundensystemen; ein dort ausgelesener Schluessel liesse sich
/// zum Signieren beliebiger Pakete verwenden. Der Server signiert daher
/// mit einem privaten RSA-Schluessel, der Agent prueft mit dem
/// oeffentlichen aus /api/updateservice/v1/pubkey.
/// </summary>
public static class ReleaseVerifier
{
/// <summary>
/// Kanonische Darstellung eines Releases - muss zeichengenau der
/// serverseitigen Fassung in ReleaseSigner::canonical() entsprechen.
/// Signiert wird bewusst diese Zeile und nicht das Manifest-JSON:
/// JSON-Ausgabe ist nicht bytestabil (Schluesselreihenfolge, Escaping,
/// Zahlenformat), eine Signatur darueber waere unzuverlaessig pruefbar.
/// </summary>
public static string BuildCanonical(
string productSlug,
string version,
string channel,
string platform,
string? sha256Hash,
string downloadUrl,
long sizeBytes)
{
return string.Join("\n", new[]
{
"dc-release-v1",
productSlug ?? string.Empty,
version ?? string.Empty,
channel ?? string.Empty,
platform ?? string.Empty,
(sha256Hash ?? string.Empty).ToLowerInvariant(),
downloadUrl ?? string.Empty,
sizeBytes.ToString(System.Globalization.CultureInfo.InvariantCulture)
});
}
/// <summary>
/// Prueft eine base64-kodierte RSA-SHA256-Signatur gegen einen
/// oeffentlichen Schluessel im PEM-Format.
/// </summary>
public static bool Verify(string canonical, string? signatureBase64, string? publicKeyPem)
{
if (string.IsNullOrWhiteSpace(signatureBase64) || string.IsNullOrWhiteSpace(publicKeyPem))
return false;
byte[] signature;
try
{
signature = Convert.FromBase64String(signatureBase64!.Trim());
}
catch (FormatException)
{
return false;
}
byte[] data = Encoding.UTF8.GetBytes(canonical);
try
{
#if NET8_0_OR_GREATER
using var rsa = RSA.Create();
rsa.ImportFromPem(publicKeyPem!.ToCharArray());
return rsa.VerifyData(data, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
#else
AsymmetricKeyParameter key = PublicKeyFactory.CreateKey(DecodePem(publicKeyPem!));
ISigner signer = SignerUtilities.GetSigner("SHA256withRSA");
signer.Init(false, key);
signer.BlockUpdate(data, 0, data.Length);
return signer.VerifySignature(signature);
#endif
}
catch
{
// Ein nicht lesbarer Schluessel oder eine unpassende Signatur
// sind kein Sonderfall, sondern schlicht "nicht geprueft".
return false;
}
}
/// <summary>
/// Fingerabdruck eines PEM-Schluessels. Damit erkennt der Agent den
/// einmal geholten Schluessel wieder, statt ihm bei jedem Aufruf neu
/// zu vertrauen - ein spaeter ausgetauschter Schluessel faellt so auf.
/// </summary>
public static string Fingerprint(string publicKeyPem)
{
using var sha256 = SHA256.Create();
byte[] hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(publicKeyPem ?? string.Empty));
return BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant();
}
#if NETSTANDARD2_0
/// <summary>Entfernt Kopf- und Fusszeile eines PEM und dekodiert base64.</summary>
private static byte[] DecodePem(string pem)
{
var builder = new StringBuilder();
foreach (string line in pem.Split('\n'))
{
string trimmed = line.Trim();
if (trimmed.Length == 0 || trimmed.StartsWith("-----", StringComparison.Ordinal))
continue;
builder.Append(trimmed);
}
return Convert.FromBase64String(builder.ToString());
}
#endif
}
}
@@ -15,13 +15,28 @@ namespace Deploymentcenter.Client;
public class LocalCacheData public class LocalCacheData
{ {
public int SchemaVersion { get; set; } = 2; public int SchemaVersion { get; set; } = StateStore.CurrentSchemaVersion;
public string ProductSlug { get; set; } = string.Empty; public string ProductSlug { get; set; } = string.Empty;
public string LicenseKey { get; set; } = string.Empty; public string LicenseKey { get; set; } = string.Empty;
public string HardwareId { get; set; } = string.Empty; public string HardwareId { get; set; } = string.Empty;
public string Status { get; set; } = "invalid"; public string Status { get; set; } = "invalid";
public long IssuedAt { get; set; } public long IssuedAt { get; set; }
/// <summary>Ablauf der Lizenz selbst (Unix-Zeit), 0 wenn unbefristet.</summary>
public long ExpiresAt { get; set; } public long ExpiresAt { get; set; }
/// <summary>
/// Ende der Offline-Gnadenfrist (Unix-Zeit). Getrennt von
/// <see cref="ExpiresAt"/>, weil eine Lizenz bis 2040 laufen kann, die
/// Frist ohne Serverkontakt aber nur ueber die vom Server gemeldeten
/// cache_ttl_hours. Schema 2 kannte das Feld nicht; dort wird die Frist
/// aus IssuedAt abgeleitet.
/// </summary>
public long CacheExpiresAt { get; set; }
/// <summary>Vom Server gemeldete Gnadenfrist in Stunden.</summary>
public int CacheTtlHours { get; set; }
public long MaxSeenTime { get; set; } public long MaxSeenTime { get; set; }
public string Checksum { get; set; } = string.Empty; public string Checksum { get; set; } = string.Empty;
} }
@@ -30,6 +45,16 @@ public static class StateStore
{ {
private static readonly byte[] Magic = Encoding.UTF8.GetBytes("LLS2"); // 4 bytes: 0x4C, 0x4C, 0x53, 0x32 private static readonly byte[] Magic = Encoding.UTF8.GetBytes("LLS2"); // 4 bytes: 0x4C, 0x4C, 0x53, 0x32
/// <summary>Schema, das dieser Client schreibt.</summary>
public const int CurrentSchemaVersion = 3;
/// <summary>
/// Schemata, die noch gelesen werden. Schema 2 hat keine getrennte
/// Cache-Frist; ein Aufsteigen darf keinen Zwang zur Online-Pruefung
/// ausloesen, nur weil das SDK aktualisiert wurde.
/// </summary>
private static readonly int[] SupportedSchemaVersions = { 2, 3 };
public static LocalCacheData? Load(string productSlug, string hardwareId) public static LocalCacheData? Load(string productSlug, string hardwareId)
{ {
try try
@@ -70,25 +95,43 @@ public static class StateStore
string jsonStr = Encoding.UTF8.GetString(jsonBytes); string jsonStr = Encoding.UTF8.GetString(jsonBytes);
var data = JsonSerializer.Deserialize<LocalCacheData>(jsonStr); var data = JsonSerializer.Deserialize<LocalCacheData>(jsonStr);
if (data == null || data.SchemaVersion != 2) if (data == null || Array.IndexOf(SupportedSchemaVersions, data.SchemaVersion) < 0)
return null; // Incompatible schema -> Treat as Cache Miss return null; // Incompatible schema -> Treat as Cache Miss
if (!BelongsHere(data, productSlug, hardwareId))
return null;
return data; return data;
} }
// Legacy Migration Check (non-LLS2 file) // Legacy Migration Check (non-LLS2 file)
//
// Vorher wurde hier beliebiges JSON nach LocalCacheData
// deserialisiert und sofort im LLS2-Format zurueckgeschrieben.
// Passte kein einziges Feld, entstand ein Standardobjekt, das die
// urspruengliche Datei ueberschrieb. Da LicenseLabrador denselben
// Pfad und Dateinamen verwendet - GetStorageDirectory beruecksichtigt
// dafuer eigens LICENSELABRADOR_STORAGE_DIR - zerstoerte das den
// fremden Cache still. Uebernommen wird jetzt nur, was sich als
// Cache genau dieses Produkts auf genau dieser Maschine ausweist.
try try
{ {
string legacyJson = Encoding.UTF8.GetString(payloadBytes); string legacyJson = Encoding.UTF8.GetString(payloadBytes);
var legacyData = JsonSerializer.Deserialize<LocalCacheData>(legacyJson);
if (legacyData != null) // Ein Ueberbleibsel im Binaerformat ist kein JSON-Objekt.
if (legacyJson.TrimStart().StartsWith("{", StringComparison.Ordinal))
{ {
legacyData.SchemaVersion = 2; var legacyData = JsonSerializer.Deserialize<LocalCacheData>(legacyJson);
Save(productSlug, hardwareId, legacyData);
return legacyData; if (legacyData != null && BelongsHere(legacyData, productSlug, hardwareId))
{
legacyData.SchemaVersion = CurrentSchemaVersion;
Save(productSlug, hardwareId, legacyData);
return legacyData;
}
} }
} }
catch { } catch (JsonException) { }
return null; return null;
} }
@@ -98,11 +141,35 @@ public static class StateStore
} }
} }
/// <summary>
/// Prueft, ob ein gelesener Cache tatsaechlich zu diesem Produkt gehoert.
///
/// Fuer LLS2-Dateien ist die Hardware-Bindung bereits durch die
/// Schluesselableitung gegeben - dort faellt die Entschluesselung sonst aus.
/// Entscheidend ist der Produktbezug: ohne ihn wuerde eine fremde
/// state.dat im selben Verzeichnis uebernommen und ueberschrieben.
/// </summary>
private static bool BelongsHere(LocalCacheData data, string productSlug, string hardwareId)
{
if (string.IsNullOrWhiteSpace(data.ProductSlug))
return false;
if (!string.Equals(data.ProductSlug, productSlug, StringComparison.OrdinalIgnoreCase))
return false;
// Ein Cache ohne Schluessel taugt zu nichts und ist meist ein
// Standardobjekt aus einer Datei, die gar keine unsrige war.
if (string.IsNullOrWhiteSpace(data.LicenseKey))
return false;
return true;
}
public static bool Save(string productSlug, string hardwareId, LocalCacheData cacheData) public static bool Save(string productSlug, string hardwareId, LocalCacheData cacheData)
{ {
try try
{ {
cacheData.SchemaVersion = 2; cacheData.SchemaVersion = CurrentSchemaVersion;
string dir = LicenseConfig.GetStorageDirectory(productSlug); string dir = LicenseConfig.GetStorageDirectory(productSlug);
Directory.CreateDirectory(dir); Directory.CreateDirectory(dir);
string statePath = Path.Combine(dir, "state.dat"); string statePath = Path.Combine(dir, "state.dat");
@@ -43,21 +43,31 @@ namespace Deploymentcenter.Client
/// <summary> /// <summary>
/// Checks for update availability against LEMP static latest.json or Deploymentcenter API. /// Checks for update availability against LEMP static latest.json or Deploymentcenter API.
/// </summary> /// </summary>
/// <param name="platform">
/// Laufzeitkennung des Systems (z. B. "win-x64"). Ohne Angabe wird die
/// des laufenden Prozesses verwendet. Wird bewusst mitgeschickt: ohne
/// sie liefert der Server nur plattformunabhaengige Releases, damit ein
/// Client nie das Paket einer fremden Plattform angeboten bekommt.
/// </param>
public async Task<UpdateCheckResult> CheckForUpdateAsync( public async Task<UpdateCheckResult> CheckForUpdateAsync(
string baseUrl, string baseUrl,
string projectId, string projectId,
string currentVersion, string currentVersion,
string channel = "prod", string channel = "prod",
string? platform = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var result = new UpdateCheckResult(); var result = new UpdateCheckResult();
try try
{ {
string cleanBaseUrl = baseUrl.TrimEnd('/'); string cleanBaseUrl = baseUrl.TrimEnd('/');
string rid = PlatformId.Normalize(platform ?? PlatformId.Current);
// Primary check: LEMP static channel latest.json // Primary check: LEMP static channel latest.json
// Path pattern: https://domain/releases/{ProjectId}/{channel}/latest.json // Plattformunabhaengige Releases liegen weiterhin unter dem
string staticUrl = $"{cleanBaseUrl}/releases/{projectId}/{channel}/latest.json"; // alten Pfad ohne Zwischenebene, damit bereits ausgelieferte
// Anwendungen ihre Updates finden.
string staticUrl = $"{cleanBaseUrl}/releases/{projectId}/{channel}{PlatformId.PathSegment(rid)}/latest.json";
// Zuerst die statische latest.json, danach die API. // Zuerst die statische latest.json, danach die API.
// //
@@ -84,7 +94,8 @@ namespace Deploymentcenter.Client
string apiUrl = $"{cleanBaseUrl}/api/updateservice/v1/check" string apiUrl = $"{cleanBaseUrl}/api/updateservice/v1/check"
+ $"?product={Uri.EscapeDataString(projectId)}" + $"?product={Uri.EscapeDataString(projectId)}"
+ $"&version={Uri.EscapeDataString(currentVersion)}" + $"&version={Uri.EscapeDataString(currentVersion)}"
+ $"&channel={Uri.EscapeDataString(channel)}"; + $"&channel={Uri.EscapeDataString(channel)}"
+ $"&platform={Uri.EscapeDataString(rid)}";
response = await _httpClient.GetAsync(apiUrl, cancellationToken).ConfigureAwait(false); response = await _httpClient.GetAsync(apiUrl, cancellationToken).ConfigureAwait(false);
} }
@@ -121,16 +132,41 @@ namespace Deploymentcenter.Client
} }
} }
// Handle API response format // Handle API response format
//
// Die API antwortet in snake_case und reicht die Release-Zeile
// aus updateservice_releases durch. Sie wird deshalb ueber
// ApiReleaseInfo gelesen und erst danach in das Modell
// uebersetzt, das Aufrufer kennen. Zuvor wurde direkt nach
// VersionInfo deserialisiert - dessen camelCase-Namen gehoeren
// aber zur statischen latest.json, sodass ausser "version"
// nichts ankam.
else if (root.TryGetProperty("update_available", out var availProp)) else if (root.TryGetProperty("update_available", out var availProp))
{ {
bool available = availProp.GetBoolean(); bool available = availProp.ValueKind == JsonValueKind.True
|| (availProp.ValueKind == JsonValueKind.Number && availProp.GetInt32() != 0);
result.UpdateAvailable = available; result.UpdateAvailable = available;
if (root.TryGetProperty("latest_release", out var relProp))
if (root.TryGetProperty("latest_release", out var relProp)
&& relProp.ValueKind == JsonValueKind.Object)
{ {
var info = JsonSerializer.Deserialize<VersionInfo>(relProp.GetRawText()); var info = JsonSerializer.Deserialize<ApiReleaseInfo>(relProp.GetRawText());
result.LatestRelease = info; result.LatestRelease = info?.ToVersionInfo();
result.IsCritical = info?.IsCritical ?? false;
} }
// is_critical steht auf oberster Ebene der Antwort, nicht im
// Release-Objekt. Vorher wurde es aus dem deserialisierten
// Objekt gelesen und war damit immer false - ein kritisches
// Release wurde ueber diesen Weg nie als kritisch gemeldet.
if (root.TryGetProperty("is_critical", out var critProp))
{
result.IsCritical = ReadFlexibleBool(critProp);
}
else
{
result.IsCritical = result.LatestRelease?.IsCritical ?? false;
}
result.Message = available ? "Update available." : "Application is up to date."; result.Message = available ? "Update available." : "Application is up to date.";
} }
} }
@@ -143,6 +179,28 @@ namespace Deploymentcenter.Client
return result; return result;
} }
/// <summary>
/// Liest einen Wahrheitswert, der als Boolean, Zahl oder Zeichenkette
/// ankommen kann. MySQL liefert TINYINT(1), PHP gibt es je nach
/// PDO-Einstellung als 1 oder "1" weiter.
/// </summary>
private static bool ReadFlexibleBool(JsonElement element)
{
switch (element.ValueKind)
{
case JsonValueKind.True:
return true;
case JsonValueKind.Number:
return element.TryGetInt64(out long number) && number != 0;
case JsonValueKind.String:
string value = (element.GetString() ?? string.Empty).Trim();
if (bool.TryParse(value, out bool parsed)) return parsed;
return long.TryParse(value, out long numeric) && numeric != 0;
default:
return false;
}
}
/// <summary> /// <summary>
/// Validates local application integrity against manifest.json. /// Validates local application integrity against manifest.json.
/// </summary> /// </summary>
@@ -181,8 +239,56 @@ namespace Deploymentcenter.Client
} }
/// <summary> /// <summary>
/// Launches UpdateAgent process with appropriate parameters and optionally exits current application. /// Dateiname des Agenten auf dieser Plattform.
///
/// Unter Linux und macOS traegt das Binary keine Endung. Ein fest auf
/// "update-agent.exe" verdrahteter Name wird dort nie gefunden, und die
/// Anwendung meldet stumm "kein Agent vorhanden".
/// </summary> /// </summary>
public static string AgentFileName =>
OperatingSystemHelpers.IsWindows() ? "update-agent.exe" : "update-agent";
/// <summary>
/// Sucht den Agenten. Ohne Verzeichnisangabe wird neben der laufenden
/// Anwendung gesucht.
/// </summary>
public static string? ResolveAgentPath(string? directory = null)
{
string dir = string.IsNullOrWhiteSpace(directory)
? AppDomain.CurrentDomain.BaseDirectory
: directory!;
string candidate = Path.Combine(dir, AgentFileName);
if (File.Exists(candidate))
return candidate;
// Ein Paket, das fuer die jeweils andere Plattform gebaut wurde,
// bringt den Agenten unter dem dortigen Namen mit. Lieber finden
// als daran scheitern.
foreach (string alternative in new[] { "update-agent", "update-agent.exe" })
{
string path = Path.Combine(dir, alternative);
if (File.Exists(path))
return path;
}
return null;
}
/// <summary>
/// Startet den UpdateAgent und beendet auf Wunsch die laufende Anwendung.
/// </summary>
/// <param name="restartPath">
/// Anwendung, die der Agent nach dem Update wieder starten soll.
/// Wurde dieser Wert zuvor nie uebergeben - der Agent unterstuetzte
/// <c>--restart</c>, bekam es aber nie zu sehen, sodass die Anwendung
/// nach "Jetzt installieren" schlicht geschlossen blieb.
/// </param>
/// <param name="waitForCurrentProcess">
/// Uebergibt die eigene Prozesskennung, damit der Agent das Ende der
/// Anwendung abwartet, bevor er Dateien ersetzt. Ohne diesen Handschlag
/// kopiert er bei langsamem Herunterfahren ueber gesperrte Dateien.
/// </param>
public static bool LaunchUpdateAgent( public static bool LaunchUpdateAgent(
string agentPath, string agentPath,
string projectId, string projectId,
@@ -190,7 +296,11 @@ namespace Deploymentcenter.Client
string action = "update", string action = "update",
string version = "latest", string version = "latest",
string? targetDir = null, string? targetDir = null,
bool exitCurrentApp = true) bool exitCurrentApp = true,
string? restartPath = null,
string? currentVersion = null,
string? platform = null,
bool waitForCurrentProcess = true)
{ {
if (!File.Exists(agentPath)) if (!File.Exists(agentPath))
{ {
@@ -200,16 +310,41 @@ namespace Deploymentcenter.Client
targetDir ??= AppDomain.CurrentDomain.BaseDirectory; targetDir ??= AppDomain.CurrentDomain.BaseDirectory;
var args = new StringBuilder(); var args = new StringBuilder();
args.Append($"--project \"{projectId}\" "); AppendArg(args, "--project", projectId);
args.Append($"--channel \"{channel}\" "); AppendArg(args, "--channel", channel);
args.Append($"--action \"{action}\" "); AppendArg(args, "--action", action);
args.Append($"--version \"{version}\" "); AppendArg(args, "--version", version);
args.Append($"--target-dir \"{targetDir}\""); AppendArg(args, "--target-dir", targetDir);
AppendArg(args, "--platform", PlatformId.Normalize(platform ?? PlatformId.Current));
if (!string.IsNullOrWhiteSpace(currentVersion))
{
AppendArg(args, "--current-version", currentVersion!);
}
if (waitForCurrentProcess)
{
AppendArg(args, "--wait-for-pid",
Process.GetCurrentProcess().Id.ToString(System.Globalization.CultureInfo.InvariantCulture));
}
// Ohne ausdruecklichen Pfad die eigene ausfuehrbare Datei.
string? restart = restartPath;
if (string.IsNullOrWhiteSpace(restart))
{
restart = GetCurrentExecutablePath();
}
if (!string.IsNullOrWhiteSpace(restart))
{
AppendArg(args, "--restart", restart!);
}
var startInfo = new ProcessStartInfo var startInfo = new ProcessStartInfo
{ {
FileName = agentPath, FileName = agentPath,
Arguments = args.ToString(), Arguments = args.ToString().TrimEnd(),
WorkingDirectory = Path.GetDirectoryName(agentPath) ?? targetDir,
UseShellExecute = true UseShellExecute = true
}; };
@@ -223,6 +358,34 @@ namespace Deploymentcenter.Client
return true; return true;
} }
private static void AppendArg(StringBuilder builder, string name, string value)
{
builder.Append(name);
builder.Append(" \"");
// Ein abschliessender Backslash wuerde sonst das Anfuehrungszeichen
// maskieren und alle folgenden Argumente verschlucken - bei
// Windows-Pfaden wie C:\App\ ein realer Fall.
builder.Append(value.Replace("\"", "\\\"").TrimEnd('\\'));
builder.Append("\" ");
}
private static string? GetCurrentExecutablePath()
{
try
{
#if NET8_0_OR_GREATER
string? path = Environment.ProcessPath;
if (!string.IsNullOrWhiteSpace(path))
return path;
#endif
return Process.GetCurrentProcess().MainModule?.FileName;
}
catch
{
return null;
}
}
public static string ComputeSha256(string filePath) public static string ComputeSha256(string filePath)
{ {
using var sha256 = SHA256.Create(); using var sha256 = SHA256.Create();
@@ -10,11 +10,21 @@ using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using Deploymentcenter.Client;
using Deploymentcenter.Client.Models; using Deploymentcenter.Client.Models;
using FluentFTP; using FluentFTP;
namespace Deploymentcenter.Packager namespace Deploymentcenter.Packager
{ {
/// <summary>
/// Wird geworfen, wenn die vorhandene Versionshistorie nicht sicher
/// gelesen werden konnte. Dann darf latest.json nicht geschrieben werden.
/// </summary>
internal sealed class ReleaseHistoryException : Exception
{
public ReleaseHistoryException(string message) : base(message) { }
}
/// <summary> /// <summary>
/// Konfiguration des Packagers. /// Konfiguration des Packagers.
/// ///
@@ -39,9 +49,34 @@ namespace Deploymentcenter.Packager
/// </summary> /// </summary>
public string ApiToken { get; set; } = ""; public string ApiToken { get; set; } = "";
/// <summary>
/// Dateien, die gar nicht erst ins Paket kommen.
///
/// Die Muster werden seit dieser Fassung als echte Globs ausgewertet.
/// Zuvor verstand der Abgleich nur "*.endung" und exakte Namen, sodass
/// Eintraege wie "logs/**" nie zutrafen - sie standen in der
/// Beispielkonfiguration und erweckten den Eindruck, es sei etwas
/// ausgeschlossen.
/// </summary>
public List<string> ExcludePatterns { get; set; } = new List<string> public List<string> ExcludePatterns { get; set; } = new List<string>
{ {
"*.pdb", "*.xml", "appsettings.Development.json", "appsettings.Staging.json", "*.log", "logs/*" "*.pdb", "*.xml", "appsettings.Development.json", "appsettings.Staging.json",
"*.log", "logs/**", "*.tmp"
};
/// <summary>
/// Dateien, die ins Paket gehoeren, am Ziel aber eine vorhandene
/// Fassung nicht ersetzen duerfen.
///
/// Ausschluss und Schutz sind zwei verschiedene Dinge: eine
/// Konfigurationsvorlage soll ausgeliefert werden, damit eine
/// Erstinstallation vollstaendig ist - beim Update darf sie die
/// eingerichteten Werte des Zielsystems aber nicht ueberschreiben.
/// </summary>
public List<string> PreservePatterns { get; set; } = new List<string>
{
"appsettings.json", "appsettings.Production.json", "settings.json",
"config.json", ".env"
}; };
/// <summary>Umgebungsvariablen haben Vorrang vor der Konfigurationsdatei.</summary> /// <summary>Umgebungsvariablen haben Vorrang vor der Konfigurationsdatei.</summary>
@@ -72,9 +107,15 @@ namespace Deploymentcenter.Packager
static async Task<int> Main(string[] args) static async Task<int> Main(string[] args)
{ {
Console.WriteLine("================================================="); Console.WriteLine("=================================================");
Console.WriteLine(" Deploymentcenter Packager & Deploy Tool v1.0 "); Console.WriteLine(" Deploymentcenter Packager & Deploy Tool v2.0 ");
Console.WriteLine("================================================="); Console.WriteLine("=================================================");
if (HasFlag(args, "--help") || HasFlag(args, "-h"))
{
ShowHelp();
return 0;
}
string project = GetArg(args, "--project", "-p") ?? "myapp"; string project = GetArg(args, "--project", "-p") ?? "myapp";
string version = GetArg(args, "--version", "-v") ?? "1.0.0"; string version = GetArg(args, "--version", "-v") ?? "1.0.0";
string channel = GetArg(args, "--channel", "-c") ?? "prod"; string channel = GetArg(args, "--channel", "-c") ?? "prod";
@@ -119,6 +160,7 @@ namespace Deploymentcenter.Packager
Console.WriteLine(" Das Paket wird gebaut und hochgeladen, aber das Deploymentcenter"); Console.WriteLine(" Das Paket wird gebaut und hochgeladen, aber das Deploymentcenter");
Console.WriteLine(" erfaehrt nichts davon - Veroeffentlichen erfordert seit Version 2.0"); Console.WriteLine(" erfaehrt nichts davon - Veroeffentlichen erfordert seit Version 2.0");
Console.WriteLine(" ein Token mit dem Recht updateservice:publish."); Console.WriteLine(" ein Token mit dem Recht updateservice:publish.");
Console.WriteLine(" Ohne Registrierung entsteht ausserdem keine Signatur.");
Console.ResetColor(); Console.ResetColor();
} }
@@ -126,34 +168,109 @@ namespace Deploymentcenter.Packager
if (!Directory.Exists(publishDir)) if (!Directory.Exists(publishDir))
{ {
Console.ForegroundColor = ConsoleColor.Red; Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"[ERROR] Publish directory does not exist: {publishDir}"); Console.WriteLine($"[FEHLER] Publish-Verzeichnis existiert nicht: {publishDir}");
Console.ResetColor(); Console.ResetColor();
return 1; return 1;
} }
Console.WriteLine($"[INFO] Packaging Project : {project}"); // ---------------------------------------------------------------
Console.WriteLine($"[INFO] Version : {version}"); // Plattform bestimmen
Console.WriteLine($"[INFO] Channel : {channel}"); // ---------------------------------------------------------------
Console.WriteLine($"[INFO] Publish Directory : {publishDir}"); // Ohne Plattform landeten Pakete verschiedener Laufzeitkennungen
// unter derselben Version im selben Kanal und ueberschrieben sich.
string platform;
string platformSource;
// 1. Gather files and filter exclusions string? explicitPlatform = GetArg(args, "--platform") ?? GetArg(args, "--rid");
if (!string.IsNullOrWhiteSpace(explicitPlatform))
{
platform = PlatformId.Normalize(explicitPlatform);
platformSource = "Argument";
}
else
{
string? inferred = PlatformId.InferFromPath(publishDir);
if (inferred != null)
{
platform = inferred;
platformSource = "aus dem Publish-Pfad abgeleitet";
}
else
{
platform = PlatformId.Any;
platformSource = "Standard";
}
}
Console.WriteLine($"[INFO] Projekt : {project}");
Console.WriteLine($"[INFO] Version : {version}");
Console.WriteLine($"[INFO] Kanal : {channel}");
Console.WriteLine($"[INFO] Plattform : {platform} ({platformSource})");
Console.WriteLine($"[INFO] Publish-Verzeichnis: {publishDir}");
if (platform == PlatformId.Any && !HasFlag(args, "--allow-any-platform"))
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("[WARNUNG] Keine Plattform angegeben - das Release gilt als plattformunabhaengig.");
Console.WriteLine(" Wird fuer mehrere Laufzeitkennungen gebaut, ueberschreiben sich die");
Console.WriteLine(" Pakete gegenseitig. Mit --platform win-x64 (o. ae.) trennen.");
Console.ResetColor();
}
// ---------------------------------------------------------------
// Version gegen die Hauptassembly pruefen
// ---------------------------------------------------------------
// Weicht die veroeffentlichte Version von der einkompilierten ab,
// meldet die Anwendung nach dem Update weiterhin die alte Version,
// haelt das Release fuer neu und aktualisiert bei jedem Start
// erneut - eine Endlosschleife ueber die gesamte Installationsbasis.
if (!VerifyVersionAgainstAssembly(publishDir, project, version, GetArg(args, "--main-assembly"),
HasFlag(args, "--ignore-version-mismatch")))
{
return 1;
}
// ---------------------------------------------------------------
// Dateien einsammeln
// ---------------------------------------------------------------
var allFiles = Directory.GetFiles(publishDir, "*", SearchOption.AllDirectories); var allFiles = Directory.GetFiles(publishDir, "*", SearchOption.AllDirectories);
var filteredFiles = new List<string>(); var filteredFiles = new List<string>();
var preservedRelPaths = new List<string>();
foreach (var file in allFiles) foreach (var file in allFiles)
{ {
string relPath = Path.GetRelativePath(publishDir, file).Replace('\\', '/'); string relPath = Path.GetRelativePath(publishDir, file).Replace('\\', '/');
if (IsExcluded(relPath, config.ExcludePatterns))
if (GlobMatcher.IsMatch(relPath, config.ExcludePatterns))
{ {
Console.WriteLine($" [EXCLUDED] {relPath}"); Console.WriteLine($" [AUSGESCHLOSSEN] {relPath}");
continue; continue;
} }
filteredFiles.Add(file); filteredFiles.Add(file);
if (GlobMatcher.IsMatch(relPath, config.PreservePatterns))
{
preservedRelPaths.Add(relPath);
}
} }
Console.WriteLine($"[INFO] Total files selected for package: {filteredFiles.Count}"); Console.WriteLine($"[INFO] Dateien im Paket : {filteredFiles.Count}");
// 2. Prepare staging directory if (preservedRelPaths.Count > 0)
{
Console.WriteLine($"[INFO] Davon geschuetzt : {preservedRelPaths.Count} (ersetzen am Ziel keine vorhandene Datei)");
foreach (var p in preservedRelPaths)
{
Console.WriteLine($" [GESCHUETZT] {p}");
}
}
WarnAboutUnprotectedSecrets(filteredFiles, publishDir, config);
// ---------------------------------------------------------------
// Paket bauen
// ---------------------------------------------------------------
string outputTempDir = Path.Combine(Path.GetTempPath(), "dc_packager_" + Guid.NewGuid().ToString("N")); string outputTempDir = Path.Combine(Path.GetTempPath(), "dc_packager_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(outputTempDir); Directory.CreateDirectory(outputTempDir);
@@ -165,16 +282,17 @@ namespace Deploymentcenter.Packager
string gitCommitShort = GetGitCommitShort(); string gitCommitShort = GetGitCommitShort();
string buildDateUtc = DateTime.UtcNow.ToString("o"); string buildDateUtc = DateTime.UtcNow.ToString("o");
// Build Manifest
var packageManifest = new PackageManifest var packageManifest = new PackageManifest
{ {
ProjectId = project, ProjectId = project,
Version = version, Version = version,
Channel = channel, Channel = channel,
Platform = platform,
BuildDate = buildDateUtc, BuildDate = buildDateUtc,
GitCommit = gitCommit, GitCommit = gitCommit,
GitCommitShort = gitCommitShort, GitCommitShort = gitCommitShort,
Changelog = changelog, Changelog = changelog,
Preserve = new List<string>(config.PreservePatterns),
Files = new List<PackageFileEntry>() Files = new List<PackageFileEntry>()
}; };
@@ -191,12 +309,11 @@ namespace Deploymentcenter.Packager
}); });
} }
// Write manifest.json var manifestOptions = new JsonSerializerOptions { WriteIndented = true };
string manifestJson = JsonSerializer.Serialize(packageManifest, new JsonSerializerOptions { WriteIndented = true }); string manifestJson = JsonSerializer.Serialize(packageManifest, manifestOptions);
await File.WriteAllTextAsync(manifestJsonPath, manifestJson); await File.WriteAllTextAsync(manifestJsonPath, manifestJson);
// 3. Create package.tar.gz Console.WriteLine("[INFO] Erzeuge package.tar.gz ...");
Console.WriteLine("[INFO] Creating package.tar.gz archive...");
string archiveStaging = Path.Combine(outputTempDir, "archive_root"); string archiveStaging = Path.Combine(outputTempDir, "archive_root");
Directory.CreateDirectory(archiveStaging); Directory.CreateDirectory(archiveStaging);
@@ -208,10 +325,8 @@ namespace Deploymentcenter.Packager
File.Copy(file, targetFile, true); File.Copy(file, targetFile, true);
} }
// Also place manifest.json inside archive root
File.Copy(manifestJsonPath, Path.Combine(archiveStaging, "manifest.json"), true); File.Copy(manifestJsonPath, Path.Combine(archiveStaging, "manifest.json"), true);
// Compress to tar.gz using System.Formats.Tar + GZipStream
using (var fs = File.Create(packageTarGzPath)) using (var fs = File.Create(packageTarGzPath))
using (var gz = new GZipStream(fs, CompressionLevel.Optimal)) using (var gz = new GZipStream(fs, CompressionLevel.Optimal))
{ {
@@ -222,16 +337,23 @@ namespace Deploymentcenter.Packager
string packageSha256 = ComputeSha256(packageTarGzPath); string packageSha256 = ComputeSha256(packageTarGzPath);
await File.WriteAllTextAsync(sha256FilePath, packageSha256); await File.WriteAllTextAsync(sha256FilePath, packageSha256);
Console.WriteLine($"[SUCCESS] Package created successfully! ({packageSizeBytes} bytes)"); Console.WriteLine($"[OK] Paket erstellt ({packageSizeBytes} Bytes)");
Console.WriteLine($"[INFO] Package SHA256: {packageSha256}"); Console.WriteLine($"[INFO] SHA256: {packageSha256}");
// 4. FTP Upload to LEMP Release Server // ---------------------------------------------------------------
string remoteChannelPath = $"{remoteBase.TrimEnd('/')}/{project}/{channel}"; // Hochladen
// ---------------------------------------------------------------
// Plattformunabhaengige Releases behalten den alten Pfad ohne
// Zwischenebene, damit bereits ausgelieferte Anwendungen ihre
// Updates weiterhin finden.
string platformSegment = PlatformId.PathSegment(platform);
string remoteChannelPath = $"{remoteBase.TrimEnd('/')}/{project}/{channel}{platformSegment}";
string remoteVersionPath = $"{remoteChannelPath}/{version}"; string remoteVersionPath = $"{remoteChannelPath}/{version}";
Console.WriteLine($"[INFO] Uploading via FTP to {ftpHost}:{config.FtpPort} ({remoteVersionPath})..."); Console.WriteLine($"[INFO] Upload nach {ftpHost}:{config.FtpPort} ({remoteVersionPath}) ...");
bool ftpSucceeded = false; bool ftpSucceeded = false;
bool historyPreserved = true;
try try
{ {
@@ -240,44 +362,76 @@ namespace Deploymentcenter.Packager
await ftp.CreateDirectory(remoteVersionPath); await ftp.CreateDirectory(remoteVersionPath);
// Upload package.tar.gz, package.tar.gz.sha256, and manifest.json
await ftp.UploadFile(packageTarGzPath, $"{remoteVersionPath}/package.tar.gz", FtpRemoteExists.Overwrite); await ftp.UploadFile(packageTarGzPath, $"{remoteVersionPath}/package.tar.gz", FtpRemoteExists.Overwrite);
await ftp.UploadFile(sha256FilePath, $"{remoteVersionPath}/package.tar.gz.sha256", FtpRemoteExists.Overwrite); await ftp.UploadFile(sha256FilePath, $"{remoteVersionPath}/package.tar.gz.sha256", FtpRemoteExists.Overwrite);
await ftp.UploadFile(manifestJsonPath, $"{remoteVersionPath}/manifest.json", FtpRemoteExists.Overwrite); await ftp.UploadFile(manifestJsonPath, $"{remoteVersionPath}/manifest.json", FtpRemoteExists.Overwrite);
Console.WriteLine("[SUCCESS] Files uploaded to version directory!"); Console.WriteLine("[OK] Paketdateien hochgeladen.");
// 5. Update remote channel latest.json // -----------------------------------------------------------
// latest.json fortschreiben
// -----------------------------------------------------------
string remoteLatestJsonPath = $"{remoteChannelPath}/latest.json"; string remoteLatestJsonPath = $"{remoteChannelPath}/latest.json";
ReleaseManifest channelManifest = new ReleaseManifest
var channelManifest = new ReleaseManifest
{ {
ProjectId = project, ProjectId = project,
Channel = channel, Channel = channel,
Platform = platform,
Versions = new List<VersionInfo>() Versions = new List<VersionInfo>()
}; };
// Read existing latest.json if present on FTP // Die vorherige Fassung startete mit leerer Versionsliste und
// verschluckte jeden Fehler beim Lesen der bestehenden Datei
// in einem leeren catch. Schlug Download oder Parsen fehl,
// wurde die gesamte Historie durch einen einzigen Eintrag
// ersetzt - ohne jede Meldung. Jetzt bricht der Vorgang ab,
// bevor latest.json geschrieben wird.
if (await ftp.FileExists(remoteLatestJsonPath)) if (await ftp.FileExists(remoteLatestJsonPath))
{ {
string tempLatestLocal = Path.Combine(outputTempDir, "existing_latest.json"); string tempLatestLocal = Path.Combine(outputTempDir, "existing_latest.json");
var status = await ftp.DownloadFile(tempLatestLocal, remoteLatestJsonPath, FtpLocalExists.Overwrite); FtpStatus status;
if (status == FtpStatus.Success && File.Exists(tempLatestLocal))
try
{ {
try status = await ftp.DownloadFile(tempLatestLocal, remoteLatestJsonPath, FtpLocalExists.Overwrite);
{
string existingJson = await File.ReadAllTextAsync(tempLatestLocal);
var existingManifest = JsonSerializer.Deserialize<ReleaseManifest>(existingJson);
if (existingManifest != null && existingManifest.Versions != null)
{
channelManifest.Versions = existingManifest.Versions;
}
}
catch { }
} }
catch (Exception ex)
{
throw new ReleaseHistoryException(
$"Die vorhandene latest.json konnte nicht geladen werden: {ex.Message}");
}
if (status != FtpStatus.Success || !File.Exists(tempLatestLocal))
{
throw new ReleaseHistoryException(
"Die vorhandene latest.json konnte nicht geladen werden (Download nicht erfolgreich).");
}
ReleaseManifest? existingManifest;
try
{
string existingJson = await File.ReadAllTextAsync(tempLatestLocal);
existingManifest = JsonSerializer.Deserialize<ReleaseManifest>(existingJson);
}
catch (Exception ex)
{
throw new ReleaseHistoryException(
$"Die vorhandene latest.json ist nicht lesbar: {ex.Message}");
}
if (existingManifest?.Versions == null)
{
throw new ReleaseHistoryException(
"Die vorhandene latest.json enthaelt keine auswertbare Versionsliste.");
}
channelManifest.Versions = existingManifest.Versions;
Console.WriteLine($"[INFO] Bestehende Historie gelesen: {channelManifest.Versions.Count} Eintraege.");
} }
// Construct new version info string packagePublicUrl =
string packagePublicUrl = $"{config.ApiBaseUrl.TrimEnd('/')}/releases/{project}/{channel}/{version}/package.tar.gz"; $"{config.ApiBaseUrl.TrimEnd('/')}/releases/{project}/{channel}{platformSegment}/{version}/package.tar.gz";
var newVersionInfo = new VersionInfo var newVersionInfo = new VersionInfo
{ {
@@ -289,65 +443,99 @@ namespace Deploymentcenter.Packager
Sha256 = packageSha256, Sha256 = packageSha256,
SizeBytes = packageSizeBytes, SizeBytes = packageSizeBytes,
Changelog = changelog, Changelog = changelog,
IsCritical = isCritical IsCritical = isCritical,
Platform = platform
}; };
// Remove duplicate version entry if re-publishing same version
channelManifest.Versions.RemoveAll(v => v.Version.Equals(version, StringComparison.OrdinalIgnoreCase)); channelManifest.Versions.RemoveAll(v => v.Version.Equals(version, StringComparison.OrdinalIgnoreCase));
channelManifest.Versions.Insert(0, newVersionInfo); channelManifest.Versions.Insert(0, newVersionInfo);
// Keep last 15 releases // Nach Versionsordnung sortieren, damit "latest" auch dann
if (channelManifest.Versions.Count > 15) // stimmt, wenn nachtraeglich eine aeltere Version gebaut wird.
channelManifest.Versions.Sort((a, b) => UpdateClient.CompareVersions(b.Version, a.Version));
// Aeltere Eintraege werden nur aus der Liste genommen, die
// Dateien bleiben auf dem Server liegen. Ein Rollback auf eine
// herausgefallene Version ist ueber die Liste nicht mehr
// erreichbar - deshalb der Hinweis statt stiller Kuerzung.
const int keep = 15;
if (channelManifest.Versions.Count > keep)
{ {
channelManifest.Versions = channelManifest.Versions.Take(15).ToList(); var dropped = channelManifest.Versions.Skip(keep).Select(v => v.Version).ToList();
channelManifest.Versions = channelManifest.Versions.Take(keep).ToList();
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"[HINWEIS] latest.json fuehrt {keep} Versionen. Nicht mehr gelistet: "
+ string.Join(", ", dropped));
Console.WriteLine(" Die Dateien liegen weiterhin auf dem Server, sind ueber den Agenten");
Console.WriteLine(" aber nicht mehr auswaehlbar.");
Console.ResetColor();
} }
channelManifest.Latest = channelManifest.Versions.FirstOrDefault(); channelManifest.Latest = channelManifest.Versions.FirstOrDefault();
string updatedLatestJson = JsonSerializer.Serialize(channelManifest, new JsonSerializerOptions { WriteIndented = true }); string updatedLatestJson = JsonSerializer.Serialize(channelManifest, manifestOptions);
string localLatestJsonPath = Path.Combine(outputTempDir, "latest.json"); string localLatestJsonPath = Path.Combine(outputTempDir, "latest.json");
await File.WriteAllTextAsync(localLatestJsonPath, updatedLatestJson); await File.WriteAllTextAsync(localLatestJsonPath, updatedLatestJson);
await ftp.UploadFile(localLatestJsonPath, remoteLatestJsonPath, FtpRemoteExists.Overwrite); await ftp.UploadFile(localLatestJsonPath, remoteLatestJsonPath, FtpRemoteExists.Overwrite);
Console.WriteLine("[SUCCESS] Updated latest.json on FTP server!"); Console.WriteLine("[OK] latest.json fortgeschrieben.");
await ftp.Disconnect(); await ftp.Disconnect();
ftpSucceeded = true; ftpSucceeded = true;
} }
catch (ReleaseHistoryException ex)
{
historyPreserved = false;
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"[FEHLER] {ex.Message}");
Console.WriteLine(" latest.json wurde NICHT geschrieben - die bestehende Historie ist");
Console.WriteLine(" unveraendert. Die Paketdateien dieser Version liegen bereits auf dem");
Console.WriteLine(" Server; nach Behebung der Ursache genuegt ein erneuter Aufruf.");
Console.ResetColor();
}
catch (Exception ex) catch (Exception ex)
{ {
Console.ForegroundColor = ConsoleColor.Red; Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"[FEHLER] FTP-Upload fehlgeschlagen: {ex.Message}"); Console.WriteLine($"[FEHLER] FTP-Upload fehlgeschlagen: {ex.Message}");
Console.WriteLine(" Das Paket wurde NICHT ausgeliefert."); Console.WriteLine(" Das Paket wurde NICHT vollstaendig ausgeliefert.");
Console.ResetColor(); Console.ResetColor();
} }
// 6. Deploymentcenter benachrichtigen // ---------------------------------------------------------------
// // Deploymentcenter benachrichtigen
// Zuvor stand hier ein leeres catch, und ohne Erfolgsfall wurde gar // ---------------------------------------------------------------
// nichts ausgegeben. Ein fehlgeschlagener Aufruf blieb damit
// unsichtbar, waehrend das Programm am Ende Erfolg meldete.
bool apiNotified = false; bool apiNotified = false;
bool signed = false;
string apiMessage = "uebersprungen (kein Token gesetzt)"; string apiMessage = "uebersprungen (kein Token gesetzt)";
if (!string.IsNullOrWhiteSpace(apiToken)) if (!string.IsNullOrWhiteSpace(apiToken))
{ {
try try
{ {
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(60) };
string apiPublishUrl = $"{config.ApiBaseUrl.TrimEnd('/')}/api/updateservice/v1/publish"; string apiPublishUrl = $"{config.ApiBaseUrl.TrimEnd('/')}/api/updateservice/v1/publish";
var payload = new // Das Dateimanifest wandert mit. Damit kann die API als
// vollwertiger Rueckfall dienen, wenn die statische
// latest.json fehlt oder der FTP-Upload scheiterte.
using var manifestDoc = JsonDocument.Parse(manifestJson);
var payload = new Dictionary<string, object?>
{ {
product_slug = project, ["product_slug"] = project,
version = version, ["version"] = version,
channel = channel, ["channel"] = channel,
release_notes = changelog, ["platform"] = platform,
download_url = $"{config.ApiBaseUrl.TrimEnd('/')}/releases/{project}/{channel}/{version}/package.tar.gz", ["release_notes"] = changelog,
sha256_hash = packageSha256, ["download_url"] =
git_commit = gitCommitShort, $"{config.ApiBaseUrl.TrimEnd('/')}/releases/{project}/{channel}{platformSegment}/{version}/package.tar.gz",
size_bytes = packageSizeBytes, ["sha256_hash"] = packageSha256,
is_critical = isCritical ["git_commit"] = gitCommitShort,
["size_bytes"] = packageSizeBytes,
["is_critical"] = isCritical,
["manifest_json"] = manifestDoc.RootElement.Clone()
}; };
var request = new HttpRequestMessage(HttpMethod.Post, apiPublishUrl) var request = new HttpRequestMessage(HttpMethod.Post, apiPublishUrl)
@@ -363,6 +551,8 @@ namespace Deploymentcenter.Packager
{ {
apiNotified = true; apiNotified = true;
apiMessage = ExtractJsonString(body, "message") ?? "Release im Deploymentcenter eingetragen."; apiMessage = ExtractJsonString(body, "message") ?? "Release im Deploymentcenter eingetragen.";
signed = string.Equals(ExtractJsonString(body, "signed"), "True", StringComparison.OrdinalIgnoreCase)
|| ExtractJsonString(body, "signed") == "true";
string? autoResolved = ExtractJsonString(body, "auto_resolved"); string? autoResolved = ExtractJsonString(body, "auto_resolved");
if (!string.IsNullOrEmpty(autoResolved) && autoResolved != "0") if (!string.IsNullOrEmpty(autoResolved) && autoResolved != "0")
@@ -385,35 +575,267 @@ namespace Deploymentcenter.Packager
if (apiNotified) if (apiNotified)
{ {
Console.ForegroundColor = ConsoleColor.Green; Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine($"[SUCCESS] {apiMessage}"); Console.WriteLine($"[OK] {apiMessage}");
Console.ResetColor();
if (!signed)
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("[HINWEIS] Das Release ist unsigniert - auf dem Server ist kein");
Console.WriteLine(" Signierschluessel hinterlegt (security.release_private_key).");
Console.WriteLine(" Der Agent kann die Herkunft des Pakets dann nicht pruefen.");
Console.ResetColor();
}
} }
else else
{ {
Console.ForegroundColor = ConsoleColor.Yellow; Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"[WARNUNG] Deploymentcenter nicht benachrichtigt - {apiMessage}"); Console.WriteLine($"[WARNUNG] Deploymentcenter nicht benachrichtigt - {apiMessage}");
Console.ResetColor();
} }
Console.ResetColor();
// Cleanup temp
try { Directory.Delete(outputTempDir, true); } catch { } try { Directory.Delete(outputTempDir, true); } catch { }
// Der Rueckgabewert bildet jetzt ab, was tatsaechlich passiert ist.
// Zuvor wurde immer 0 und "successfully published" gemeldet, selbst
// wenn FTP-Upload und API-Aufruf beide fehlgeschlagen waren.
bool fullySucceeded = ftpSucceeded && apiNotified; bool fullySucceeded = ftpSucceeded && apiNotified;
Console.WriteLine(); Console.WriteLine();
Console.ForegroundColor = fullySucceeded ? ConsoleColor.Green : ConsoleColor.Yellow; Console.ForegroundColor = fullySucceeded ? ConsoleColor.Green : ConsoleColor.Yellow;
Console.WriteLine(fullySucceeded Console.WriteLine(fullySucceeded
? $"[FERTIG] Release {version} fuer {project} ({channel}) vollstaendig veroeffentlicht." ? $"[FERTIG] Release {version} fuer {project} ({channel}, {platform}) vollstaendig veroeffentlicht."
: $"[UNVOLLSTAENDIG] Release {version} fuer {project} ({channel}): " : $"[UNVOLLSTAENDIG] Release {version} fuer {project} ({channel}, {platform}): "
+ $"Upload {(ftpSucceeded ? "ok" : "FEHLGESCHLAGEN")}, " + $"Upload {(ftpSucceeded ? "ok" : "FEHLGESCHLAGEN")}, "
+ $"Registrierung {(apiNotified ? "ok" : "FEHLGESCHLAGEN")}."); + $"Registrierung {(apiNotified ? "ok" : "FEHLGESCHLAGEN")}"
+ (historyPreserved ? "." : ", Historie unveraendert."));
Console.ResetColor(); Console.ResetColor();
return fullySucceeded ? 0 : 2; return fullySucceeded ? 0 : 2;
} }
/// <summary>
/// Vergleicht die angegebene Version mit der, die tatsaechlich in der
/// Hauptassembly steht.
///
/// Rueckgabe false bedeutet: abbrechen. Laesst sich die Assembly nicht
/// bestimmen, wird nur gewarnt - ein nicht pruefbarer Fall ist kein
/// Fehler, ein nachgewiesener Widerspruch schon.
/// </summary>
static bool VerifyVersionAgainstAssembly(
string publishDir,
string project,
string declaredVersion,
string? mainAssemblyOverride,
bool ignoreMismatch)
{
string? assemblyPath = ResolveMainAssembly(publishDir, project, mainAssemblyOverride);
if (assemblyPath == null)
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("[WARNUNG] Hauptassembly nicht gefunden - die Version konnte nicht gegengeprueft");
Console.WriteLine(" werden. Mit --main-assembly <datei> gezielt angeben.");
Console.ResetColor();
return true;
}
string? actual = ReadAssemblyVersion(assemblyPath);
if (actual == null)
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"[WARNUNG] Aus {Path.GetFileName(assemblyPath)} liess sich keine Version lesen.");
Console.ResetColor();
return true;
}
if (VersionCoresMatch(actual, declaredVersion))
{
Console.WriteLine($"[OK] Version stimmt mit {Path.GetFileName(assemblyPath)} ueberein ({actual}).");
return true;
}
Console.ForegroundColor = ignoreMismatch ? ConsoleColor.Yellow : ConsoleColor.Red;
Console.WriteLine($"[{(ignoreMismatch ? "WARNUNG" : "FEHLER")}] Versionskonflikt:");
Console.WriteLine($" --version sagt : {declaredVersion}");
Console.WriteLine($" {Path.GetFileName(assemblyPath)} sagt : {actual}");
Console.WriteLine();
Console.WriteLine(" Wird so veroeffentlicht, meldet die Anwendung nach dem Update weiterhin");
Console.WriteLine(" ihre einkompilierte Version, haelt das Release fuer neu und aktualisiert");
Console.WriteLine(" bei jedem Start erneut - auf allen Installationen.");
Console.WriteLine();
Console.WriteLine(" Ueblicher Grund: <Version> steht nur in einem der beteiligten Projekte.");
Console.WriteLine(" Gehoert in die Directory.Build.props, damit alle denselben Wert tragen.");
if (!ignoreMismatch)
{
Console.WriteLine(" Bewusst gewollt? --ignore-version-mismatch");
}
Console.ResetColor();
return ignoreMismatch;
}
/// <summary>
/// Sucht die Assembly, deren Version fuer das Release massgeblich ist.
/// </summary>
static string? ResolveMainAssembly(string publishDir, string project, string? overrideName)
{
if (!string.IsNullOrWhiteSpace(overrideName))
{
string candidate = Path.IsPathRooted(overrideName!)
? overrideName!
: Path.Combine(publishDir, overrideName!);
return File.Exists(candidate) ? candidate : null;
}
// 1. Gleichnamig zum Projekt-Slug.
foreach (string extension in new[] { ".dll", ".exe" })
{
string candidate = Path.Combine(publishDir, project + extension);
if (File.Exists(candidate))
return candidate;
}
// 2. Ueber die runtimeconfig.json: sie traegt den Namen der
// Startassembly und existiert genau einmal je Anwendung.
var runtimeConfigs = Directory.GetFiles(publishDir, "*.runtimeconfig.json", SearchOption.TopDirectoryOnly);
if (runtimeConfigs.Length == 1)
{
string baseName = Path.GetFileName(runtimeConfigs[0]);
baseName = baseName.Substring(0, baseName.Length - ".runtimeconfig.json".Length);
foreach (string extension in new[] { ".dll", ".exe" })
{
string candidate = Path.Combine(publishDir, baseName + extension);
if (File.Exists(candidate))
return candidate;
}
}
return null;
}
/// <summary>
/// Liest die Version einer Assembly, ohne sie zu laden.
/// ProductVersion entspricht InformationalVersion und damit dem, was
/// in der csproj unter &lt;Version&gt; steht.
/// </summary>
static string? ReadAssemblyVersion(string path)
{
try
{
var info = FileVersionInfo.GetVersionInfo(path);
if (!string.IsNullOrWhiteSpace(info.ProductVersion))
return info.ProductVersion!.Trim();
if (!string.IsNullOrWhiteSpace(info.FileVersion))
return info.FileVersion!.Trim();
}
catch { }
try
{
var name = System.Reflection.AssemblyName.GetAssemblyName(path);
return name.Version?.ToString();
}
catch { }
return null;
}
/// <summary>
/// Vergleicht nur den numerischen Kern. "1.4.3" und "1.4.3.0" sind
/// dieselbe Version; "1.4.3+abc123" ebenso - Build-Metadaten und
/// Vorabkennungen sind fuer diese Pruefung ohne Bedeutung.
/// </summary>
static bool VersionCoresMatch(string a, string b)
{
var coreA = VersionCore(a);
var coreB = VersionCore(b);
int length = Math.Max(coreA.Count, coreB.Count);
for (int i = 0; i < length; i++)
{
int partA = i < coreA.Count ? coreA[i] : 0;
int partB = i < coreB.Count ? coreB[i] : 0;
if (partA != partB)
return false;
}
return true;
}
static List<int> VersionCore(string version)
{
string value = (version ?? string.Empty).Trim().TrimStart('v', 'V');
int cut = value.IndexOfAny(new[] { '-', '+', ' ' });
if (cut >= 0)
value = value.Substring(0, cut);
var core = new List<int>();
foreach (string part in value.Split('.'))
{
string digits = new string(part.Where(char.IsDigit).ToArray());
core.Add(digits.Length > 0 ? int.Parse(digits) : 0);
}
if (core.Count == 0)
core.Add(0);
return core;
}
/// <summary>
/// Warnt vor Dateien, die nach Zugangsdaten aussehen und weder
/// ausgeschlossen noch geschuetzt sind.
///
/// Eine settings.json mit Datenbankpasswort und DC-Token, die im
/// Publish-Verzeichnis liegt, wandert sonst ins Paket und ueberschreibt
/// beim Update die Konfiguration jedes Zielsystems.
/// </summary>
static void WarnAboutUnprotectedSecrets(List<string> files, string publishDir, PackagerConfig config)
{
string[] suspicious =
{
"appsettings*.json", "settings.json", "*.config.json", ".env*",
"secrets.json", "connectionstrings.json", "*.pfx", "*.key", "*.pem"
};
var hits = new List<string>();
foreach (var file in files)
{
string relPath = Path.GetRelativePath(publishDir, file).Replace('\\', '/');
if (GlobMatcher.IsMatch(relPath, config.PreservePatterns))
continue;
if (GlobMatcher.IsMatch(relPath, suspicious))
hits.Add(relPath);
}
if (hits.Count == 0)
return;
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine();
Console.WriteLine("[WARNUNG] Diese Dateien sehen nach Konfiguration oder Zugangsdaten aus, stehen");
Console.WriteLine(" aber weder unter excludePatterns noch unter preservePatterns:");
foreach (var hit in hits)
{
Console.WriteLine($" - {hit}");
}
Console.WriteLine();
Console.WriteLine(" Sie werden mit ausgeliefert UND ueberschreiben beim Update die Fassung");
Console.WriteLine(" auf dem Zielsystem. Entweder in excludePatterns (gar nicht ausliefern)");
Console.WriteLine(" oder in preservePatterns (ausliefern, aber nie ersetzen) aufnehmen.");
Console.ResetColor();
Console.WriteLine();
}
/// <summary> /// <summary>
/// Liest einen einzelnen Wert aus einer JSON-Antwort, ohne ein /// Liest einen einzelnen Wert aus einer JSON-Antwort, ohne ein
/// vollstaendiges Modell dafuer zu benoetigen. /// vollstaendiges Modell dafuer zu benoetigen.
@@ -468,32 +890,24 @@ namespace Deploymentcenter.Packager
try try
{ {
string json = File.ReadAllText(path); string json = File.ReadAllText(path);
var cfg = JsonSerializer.Deserialize<PackagerConfig>(json); var cfg = JsonSerializer.Deserialize<PackagerConfig>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (cfg != null) return cfg; if (cfg != null) return cfg;
} }
catch { } catch (Exception ex)
{
// Eine unlesbare Konfiguration still zu ignorieren hiesse,
// mit leeren Zugangsdaten weiterzumachen und den Nutzer
// ueber die Ursache im Unklaren zu lassen.
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"[WARNUNG] {Path.GetFileName(path)} ist nicht lesbar: {ex.Message}");
Console.WriteLine(" Es gelten Umgebungsvariablen und CLI-Argumente.");
Console.ResetColor();
}
} }
return new PackagerConfig(); return new PackagerConfig();
} }
static bool IsExcluded(string relPath, List<string> patterns)
{
string fileName = Path.GetFileName(relPath);
foreach (var pattern in patterns)
{
if (pattern.StartsWith("*."))
{
string ext = pattern.Substring(1);
if (fileName.EndsWith(ext, StringComparison.OrdinalIgnoreCase)) return true;
}
else if (pattern.Equals(relPath, StringComparison.OrdinalIgnoreCase) || pattern.Equals(fileName, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
static string ComputeSha256(string file) static string ComputeSha256(string file)
{ {
using var sha256 = SHA256.Create(); using var sha256 = SHA256.Create();
@@ -520,6 +934,30 @@ namespace Deploymentcenter.Packager
return args.Any(a => a.Equals(flag, StringComparison.OrdinalIgnoreCase)); return args.Any(a => a.Equals(flag, StringComparison.OrdinalIgnoreCase));
} }
static void ShowHelp()
{
Console.WriteLine();
Console.WriteLine("Aufruf: pack-and-deploy [Optionen]");
Console.WriteLine();
Console.WriteLine(" --project, -p <slug> Projekt-Slug im Deploymentcenter");
Console.WriteLine(" --version, -v <version> Zu veroeffentlichende Version");
Console.WriteLine(" --channel, -c <kanal> prod | beta | dev (Vorgabe: prod)");
Console.WriteLine(" --platform <rid> win-x64, linux-x64, ... (Vorgabe: aus dem");
Console.WriteLine(" Publish-Pfad abgeleitet, sonst 'any')");
Console.WriteLine(" --publish-dir, -d <pfad> Ausgabe von dotnet publish");
Console.WriteLine(" --changelog <text> Aenderungshinweise");
Console.WriteLine(" --critical Als kritisches Update kennzeichnen");
Console.WriteLine(" --main-assembly <datei> Assembly fuer die Versionsgegenprobe");
Console.WriteLine(" --ignore-version-mismatch Versionskonflikt nur als Warnung behandeln");
Console.WriteLine(" --allow-any-platform Warnung zu 'any' unterdruecken");
Console.WriteLine(" --config <datei> Abweichende packager.config.json");
Console.WriteLine(" --token <token> Token mit updateservice:publish");
Console.WriteLine(" --ftp-host/--ftp-user/--ftp-pass/--remote-dir");
Console.WriteLine();
Console.WriteLine("Rueckgabewerte: 0 vollstaendig, 1 Konfigurationsfehler, 2 teilweise fehlgeschlagen.");
Console.WriteLine();
}
static string GetGitCommitLong() static string GetGitCommitLong()
{ {
try try
@@ -11,6 +11,7 @@
"_apiToken_comment": "Token mit dem Recht updateservice:publish. Im WebUI unter Token-Verwaltung erzeugen.", "_apiToken_comment": "Token mit dem Recht updateservice:publish. Im WebUI unter Token-Verwaltung erzeugen.",
"apiToken": "", "apiToken": "",
"_excludePatterns_comment": "Kommt gar nicht erst ins Paket. Echte Glob-Muster: * trifft innerhalb eines Ordners, ** ueber Ordnergrenzen, ? genau ein Zeichen. Muster ohne / gelten fuer den Dateinamen an beliebiger Stelle.",
"excludePatterns": [ "excludePatterns": [
"*.pdb", "*.pdb",
"*.xml", "*.xml",
@@ -20,5 +21,14 @@
"logs/**", "logs/**",
"scratch/**", "scratch/**",
"*.tmp" "*.tmp"
],
"_preservePatterns_comment": "Wird ausgeliefert, ersetzt am Ziel aber niemals eine vorhandene Datei. Fuer Konfigurationsvorlagen: die Erstinstallation bekommt sie, ein Update laesst die eingerichteten Werte in Ruhe. Ausschluss und Schutz sind zwei verschiedene Dinge - was hier fehlt und Zugangsdaten enthaelt, ueberschreibt beim Update die Konfiguration jedes Zielsystems.",
"preservePatterns": [
"appsettings.json",
"appsettings.Production.json",
"settings.json",
"config.json",
".env"
] ]
} }
@@ -6,10 +6,20 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AppName>Deploymentcenter Test Suite</AppName> <AppName>Deploymentcenter Test Suite</AppName>
<RootNamespace>Deploymentcenter.TestClient</RootNamespace>
<Version>1.4.3</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Deploymentcenter.Client\Deploymentcenter.Client.csproj" /> <ProjectReference Include="..\Deploymentcenter.Client\Deploymentcenter.Client.csproj" />
</ItemGroup> </ItemGroup>
<!--
Bindet die BuildInfo-Erzeugung ein. Steht hier bewusst: der Pfad war
frueher in keinem Projekt eingebunden und wurde deshalb nie uebersetzt,
obwohl der UpdateService-Leitfaden ihn empfiehlt. So faellt ein Fehler
darin beim naechsten Build der Solution auf.
-->
<Import Project="..\Deploymentcenter.Client\Deploymentcenter.BuildInfo.targets" />
</Project> </Project>
@@ -282,7 +282,9 @@ class Program
private static async Task TestUpdateServiceModuleAsync() private static async Task TestUpdateServiceModuleAsync()
{ {
Console.ForegroundColor = ConsoleColor.Yellow; Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"5. Teste Modul: UpdateService [BuildInfo: {Deploymentcenter.Client.Models.BuildInfo.Summary}]..."); // Die vom Target erzeugte Klasse im eigenen Namensraum, nicht die des
// SDK: sie traegt die Version dieses Projekts (<Version> in der csproj).
Console.WriteLine($"5. Teste Modul: UpdateService [BuildInfo: {BuildInfo.Summary}]...");
Console.ResetColor(); Console.ResetColor();
try try
@@ -0,0 +1,426 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Deploymentcenter.Client;
using Deploymentcenter.Client.Models;
namespace Deploymentcenter.UpdateAgent
{
/// <summary>Was ein Anwenden tun wuerde beziehungsweise getan hat.</summary>
internal sealed class ApplyPlan
{
/// <summary>Relative Pfade, die geschrieben werden.</summary>
public List<string> Write { get; } = new List<string>();
/// <summary>Geschuetzte Dateien, die am Ziel bereits vorhanden sind.</summary>
public List<string> Preserved { get; } = new List<string>();
/// <summary>Dateien der Vorversion, die es im neuen Release nicht mehr gibt.</summary>
public List<string> Orphans { get; } = new List<string>();
/// <summary>Relativer Pfad des laufenden Agenten, falls er im Paket liegt.</summary>
public string? SelfPath { get; set; }
/// <summary>Konnten verwaiste Dateien ueberhaupt bestimmt werden?</summary>
public bool OrphanDetectionPossible { get; set; }
}
/// <summary>
/// Schreibt ein entpacktes Release ins Zielverzeichnis.
///
/// Die Vorgaengerfassung war als "Atomic Replace with Backup" kommentiert,
/// tatsaechlich aber eine Kopierschleife: kein Backup, kein Rollback, kein
/// Aufraeumen. Brach sie in der Mitte ab - gesperrte Datei, volle Platte -
/// blieb eine halb aktualisierte Installation zurueck, aus der kein Weg
/// zurueckfuehrte. Dateien, die es im neuen Release nicht mehr gab, blieben
/// ausserdem fuer immer liegen; bei .NET ein realer Weg in kaputte
/// Assembly-Aufloesung.
/// </summary>
internal static class Installer
{
private const string BackupDirectoryName = ".dc-update-backup";
/// <summary>
/// Endung, unter der eine laufende ausfuehrbare Datei zur Seite gelegt
/// wird. Unter Windows laesst sich eine laufende Datei umbenennen, aber
/// nicht ueberschreiben oder loeschen - genau darauf baut der
/// Selbstaustausch auf.
/// </summary>
private const string ReplacedSuffix = ".dc-old";
/// <summary>
/// Bestimmt, was zu tun ist, ohne etwas zu veraendern.
/// </summary>
public static ApplyPlan BuildPlan(
string stagingDir,
string targetDir,
PackageManifest newManifest,
PackageManifest? installedManifest,
string? runningAgentPath)
{
var plan = new ApplyPlan();
var preservePatterns = newManifest.Preserve ?? new List<string>();
var staged = Directory
.GetFiles(stagingDir, "*", SearchOption.AllDirectories)
.Select(f => GlobMatcher.Normalize(Path.GetRelativePath(stagingDir, f)))
.ToList();
var stagedSet = new HashSet<string>(staged, StringComparer.OrdinalIgnoreCase);
foreach (string rel in staged)
{
string targetPath = Path.Combine(targetDir, rel.Replace('/', Path.DirectorySeparatorChar));
// Geschuetzte Dateien werden nur bei der Erstinstallation
// geschrieben. Ein Update darf die eingerichtete Konfiguration
// des Zielsystems nicht ersetzen.
if (File.Exists(targetPath) && GlobMatcher.IsMatch(rel, preservePatterns))
{
plan.Preserved.Add(rel);
continue;
}
if (runningAgentPath != null && IsSameFile(targetPath, runningAgentPath))
{
plan.SelfPath = rel;
}
plan.Write.Add(rel);
}
// Verwaiste Dateien lassen sich nur bestimmen, wenn bekannt ist,
// was die Vorversion mitgebracht hat. Ohne dieses Wissen wird
// nichts geloescht - alles andere hiesse, fremde Dateien im
// Zielverzeichnis zu entfernen.
if (installedManifest?.Files != null && installedManifest.Files.Count > 0)
{
plan.OrphanDetectionPossible = true;
foreach (var entry in installedManifest.Files)
{
string rel = GlobMatcher.Normalize(entry.Path);
if (rel.Length == 0 || stagedSet.Contains(rel))
continue;
if (GlobMatcher.IsMatch(rel, preservePatterns))
continue;
string targetPath = Path.Combine(targetDir, rel.Replace('/', Path.DirectorySeparatorChar));
if (!File.Exists(targetPath))
continue;
if (runningAgentPath != null && IsSameFile(targetPath, runningAgentPath))
continue;
plan.Orphans.Add(rel);
}
}
return plan;
}
/// <summary>
/// Fuehrt den Plan aus. Bei einem Fehler wird der Ausgangszustand
/// wiederhergestellt und die Ausnahme weitergereicht.
/// </summary>
public static void Apply(
string stagingDir,
string targetDir,
ApplyPlan plan,
Action<string>? log = null)
{
Directory.CreateDirectory(targetDir);
string backupDir = Path.Combine(targetDir, BackupDirectoryName);
// Ein Rest aus einem frueheren Abbruch wuerde die Wiederherstellung
// mit fremden Daten fuettern.
SafeDeleteDirectory(backupDir);
Directory.CreateDirectory(backupDir);
// Merkt sich je Datei, was zurueckzunehmen waere.
var backedUp = new List<string>();
var created = new List<string>();
string? renamedSelf = null;
try
{
foreach (string rel in plan.Write)
{
string sourcePath = Path.Combine(stagingDir, rel.Replace('/', Path.DirectorySeparatorChar));
string targetPath = Path.Combine(targetDir, rel.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
bool isSelf = plan.SelfPath != null
&& string.Equals(rel, plan.SelfPath, StringComparison.OrdinalIgnoreCase);
if (File.Exists(targetPath))
{
if (isSelf)
{
// Die laufende Datei kann nicht ueberschrieben,
// wohl aber umbenannt werden. Ohne diesen Schritt
// bricht das Update unter Windows mitten im
// Kopieren mit einer Zugriffsverletzung ab - und
// hinterliess bisher eine halbe Installation.
renamedSelf = targetPath + ReplacedSuffix;
SafeDelete(renamedSelf);
File.Move(targetPath, renamedSelf);
log?.Invoke($"Laufendes Agent-Binary zur Seite gelegt: {rel}");
}
else
{
MoveToBackup(targetPath, targetDir, backupDir, rel);
backedUp.Add(rel);
}
}
else
{
created.Add(rel);
}
File.Copy(sourcePath, targetPath, overwrite: true);
CopyExecutableBit(sourcePath, targetPath, rel);
}
foreach (string rel in plan.Orphans)
{
string targetPath = Path.Combine(targetDir, rel.Replace('/', Path.DirectorySeparatorChar));
if (!File.Exists(targetPath))
continue;
MoveToBackup(targetPath, targetDir, backupDir, rel);
backedUp.Add(rel);
log?.Invoke($"Entfernt (nicht mehr Teil des Releases): {rel}");
}
}
catch
{
Rollback(targetDir, backupDir, backedUp, created, plan.SelfPath, renamedSelf, log);
throw;
}
// Erst jetzt ist der alte Stand entbehrlich.
SafeDeleteDirectory(backupDir);
// Die zur Seite gelegte eigene Datei laesst sich waehrend des
// Laufens nicht loeschen; das erledigt der naechste Start.
if (renamedSelf != null)
{
log?.Invoke("Der Agent hat sich selbst erneuert. Die alte Fassung wird beim naechsten Start entfernt.");
}
RemoveEmptyDirectories(targetDir);
}
/// <summary>
/// Raeumt Reste eines Selbstaustauschs weg. Wird beim Start aufgerufen,
/// weil die Datei zu diesem Zeitpunkt nicht mehr in Benutzung ist.
/// </summary>
public static void CleanupPreviousSelfUpdate(string directory)
{
try
{
if (!Directory.Exists(directory))
return;
foreach (string leftover in Directory.GetFiles(directory, "*" + ReplacedSuffix, SearchOption.TopDirectoryOnly))
{
SafeDelete(leftover);
}
}
catch
{
// Ein liegengebliebener Rest ist unschoen, aber kein Grund,
// den Start zu verweigern.
}
}
private static void Rollback(
string targetDir,
string backupDir,
List<string> backedUp,
List<string> created,
string? selfRel,
string? renamedSelf,
Action<string>? log)
{
log?.Invoke("Update abgebrochen - stelle den vorherigen Stand wieder her ...");
// Neu angelegte Dateien wieder entfernen.
foreach (string rel in created)
{
try
{
SafeDelete(Path.Combine(targetDir, rel.Replace('/', Path.DirectorySeparatorChar)));
}
catch { }
}
// Gesicherte Dateien zuruecklegen.
foreach (string rel in backedUp)
{
try
{
string backupPath = Path.Combine(backupDir, rel.Replace('/', Path.DirectorySeparatorChar));
string targetPath = Path.Combine(targetDir, rel.Replace('/', Path.DirectorySeparatorChar));
if (!File.Exists(backupPath))
continue;
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
SafeDelete(targetPath);
File.Move(backupPath, targetPath);
}
catch (Exception ex)
{
log?.Invoke($"Wiederherstellung von {rel} fehlgeschlagen: {ex.Message}");
}
}
// Den eigenen Namen zuruecknehmen, falls schon umbenannt wurde.
if (renamedSelf != null && selfRel != null)
{
try
{
string targetPath = Path.Combine(targetDir, selfRel.Replace('/', Path.DirectorySeparatorChar));
if (!File.Exists(targetPath) && File.Exists(renamedSelf))
{
File.Move(renamedSelf, targetPath);
}
}
catch { }
}
log?.Invoke("Vorheriger Stand wiederhergestellt.");
}
private static void MoveToBackup(string targetPath, string targetDir, string backupDir, string rel)
{
string backupPath = Path.Combine(backupDir, rel.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!);
SafeDelete(backupPath);
File.Move(targetPath, backupPath);
}
/// <summary>
/// Uebertraegt das Ausfuehrbar-Bit.
///
/// Wird unter Windows fuer linux-x64 gebaut, kennt das tar-Archiv keine
/// Unix-Rechte und alles landet als 644 - die Anwendung liesse sich auf
/// dem Zielsystem nicht starten. Betroffen sind der Apphost (traegt
/// unter Linux keine Endung) und Shell-Skripte.
/// </summary>
private static void CopyExecutableBit(string sourcePath, string targetPath, string rel)
{
// Bewusst OperatingSystem.IsWindows() statt des eigenen Helfers:
// nur diese Form erkennt die Plattformanalyse als Absicherung der
// unter Windows nicht unterstuetzten Unix-Rechte-Aufrufe.
if (OperatingSystem.IsWindows())
return;
try
{
var mode = File.GetUnixFileMode(sourcePath);
bool alreadyExecutable =
(mode & (UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute)) != 0;
if (!alreadyExecutable && LooksExecutable(rel))
{
mode |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
}
File.SetUnixFileMode(targetPath, mode);
}
catch
{
// Auf Dateisystemen ohne Unix-Rechte (etwa eine gemountete
// Windows-Freigabe) ist das schlicht nicht anwendbar.
}
}
private static bool LooksExecutable(string rel)
{
string name = rel;
int slash = name.LastIndexOf('/');
if (slash >= 0)
name = name.Substring(slash + 1);
if (name.EndsWith(".sh", StringComparison.OrdinalIgnoreCase))
return true;
// Der Apphost einer .NET-Anwendung traegt unter Linux keine
// Endung. Dateien ohne Punkt sind hier die einzigen Kandidaten.
return name.IndexOf('.') < 0;
}
/// <summary>
/// Entfernt Verzeichnisse, die durch das Aufraeumen leer geworden sind.
/// Das Zielverzeichnis selbst bleibt bestehen.
/// </summary>
private static void RemoveEmptyDirectories(string targetDir)
{
try
{
foreach (string dir in Directory
.GetDirectories(targetDir, "*", SearchOption.AllDirectories)
.OrderByDescending(d => d.Length))
{
try
{
if (Directory.GetFileSystemEntries(dir).Length == 0)
Directory.Delete(dir);
}
catch { }
}
}
catch { }
}
private static bool IsSameFile(string a, string b)
{
try
{
string fullA = Path.GetFullPath(a);
string fullB = Path.GetFullPath(b);
var comparison = OperatingSystemHelpers.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
return string.Equals(fullA, fullB, comparison);
}
catch
{
return false;
}
}
private static void SafeDelete(string path)
{
try
{
if (File.Exists(path))
File.Delete(path);
}
catch { }
}
private static void SafeDeleteDirectory(string path)
{
try
{
if (Directory.Exists(path))
Directory.Delete(path, recursive: true);
}
catch { }
}
}
}
File diff suppressed because it is too large Load Diff
+9
View File
@@ -49,6 +49,15 @@ return [
'webhook_key' => dc_env('DC_WEBHOOK_KEY', ''), 'webhook_key' => dc_env('DC_WEBHOOK_KEY', ''),
// Signaturschlüssel für Offline-Lizenzdateien (.lic). // Signaturschlüssel für Offline-Lizenzdateien (.lic).
'license_key' => dc_env('DC_LICENSE_SIGNING_KEY', ''), 'license_key' => dc_env('DC_LICENSE_SIGNING_KEY', ''),
// Privater RSA-Schlüssel, mit dem Releases signiert werden. Entweder
// das PEM selbst oder ein Pfad zur Schlüsseldatei.
// openssl genrsa -out /etc/dc/release-signing.pem 2048
// chmod 600 /etc/dc/release-signing.pem
// Leer lassen schaltet das Signieren ab; Releases bleiben dann
// installierbar, der Agent weist aber bei jedem Update darauf hin.
// Bewusst asymmetrisch: geprüft wird auf fremden Systemen, dort darf
// der Signierschlüssel nicht liegen.
'release_private_key' => dc_env('DC_RELEASE_SIGNING_KEY', ''),
], ],
'bugtracker' => [ 'bugtracker' => [
// Projekt-Slug, unter dem das Deploymentcenter eigene Fehler meldet. // Projekt-Slug, unter dem das Deploymentcenter eigene Fehler meldet.
+217 -9
View File
@@ -16,6 +16,18 @@
> >
> Umstellungsschritte: **[UPGRADE.md](./UPGRADE.md)** > Umstellungsschritte: **[UPGRADE.md](./UPGRADE.md)**
> **⚠️ Änderungen in Version 2.1 — betreffen jede bestehende Integration**
> - **Ein HTTP-Fehler ist kein Lizenzurteil mehr.** `ValidateAsync` fällt jetzt
> bei jedem erfolglosen Aufruf auf den Offline-Cache zurück, nicht nur bei
> einer Exception. Der Status `unknown_error` entfällt, siehe **[§6](#6-fehlerfälle-richtig-behandeln)**.
> - **`cache_ttl_hours` wird ausgewertet.** Die Offline-Gnadenfrist ist damit
> tatsächlich begrenzt, siehe **[§3](#3-zustandsspeicher-statestorecs--cache-härtung)**.
> - `ValidateAsync` nimmt `appVersion` und einen `CancellationToken` entgegen.
> - Neu: `LicenseClient.TryGetCachedKey()` und `RevalidateAsync()`.
> - `DeactivateAsync` sendet den Schlüssel nur noch als `Authorization: Bearer`.
> Der zusätzliche `X-Watchdog-Key` ist entfallen.
> - Standard-Zeitgrenze von 15 s statt 100 s.
--- ---
## 0. Antwortformat — bitte beachten ## 0. Antwortformat — bitte beachten
@@ -123,12 +135,18 @@ public class Program
string licenseKey = "LLAB1-98A72-B3C4D-5E6F7-89012"; string licenseKey = "LLAB1-98A72-B3C4D-5E6F7-89012";
// 3. Online-Validierung durchführen // 3. Online-Validierung durchführen
LicenseValidationResult res = await client.ValidateAsync(ProductSlug, licenseKey, ServerUrl); LicenseValidationResult res = await client.ValidateAsync(
ProductSlug, licenseKey, ServerUrl, appVersion: BuildInfo.Version);
if (res.IsValid) if (res.IsValid)
{ {
Console.WriteLine($"[✔] Lizenz gültig! (Status: {res.Status}, Cached: {res.IsCached})"); Console.WriteLine($"[✔] Lizenz gültig! (Status: {res.Status}, Cached: {res.IsCached})");
} }
else if (res.IsTransient)
{
// Kein Urteil, nur keine Verbindung. Weiterlaufen lassen.
Console.WriteLine($"[!] Lizenz vorerst nicht prüfbar: {res.Message}");
}
else else
{ {
Console.WriteLine($"[✖] Lizenz ungültig: {res.Message}"); Console.WriteLine($"[✖] Lizenz ungültig: {res.Message}");
@@ -138,6 +156,53 @@ public class Program
} }
``` ```
### 2.2 Signatur
```csharp
Task<LicenseValidationResult> ValidateAsync(
string productSlug,
string licenseKey,
string serverBaseUrl,
string? appVersion = null,
CancellationToken cancellationToken = default);
```
`appVersion` landet in der Aktivierungsliste des WebUI. Ohne Angabe greift
`LicenseClient.DefaultAppVersion` und danach die Version der Startassembly.
Einmal beim Start setzen genügt:
```csharp
LicenseClient.DefaultAppVersion = BuildInfo.Version;
```
> Vorher stand hier fest `"1.0.0"`. In der Verwaltungsansicht trug dadurch jede
> Installation dieselbe Version, obwohl die Spalte `app_version` dafür da ist.
### 2.3 Ergebnisfelder
| Feld | Bedeutung |
|---|---|
| `IsValid` | Darf die Anwendung laufen? |
| `Status` | `valid`, `valid_offline`, `revoked`, `expired`, `not_found`, `activation_limit`, `suspended`, `cache_expired`, `clock_rollback`, `server_unavailable` |
| `IsTransient` | **Neu.** Wahr, wenn kein Urteil vorliegt, sondern nur die Verbindung scheiterte |
| `IsCached` | Ergebnis stammt aus dem Offline-Cache |
| `ExpiresAt` | Ablauf der Lizenz (Unix-Zeit) |
| `CacheExpiresAt` | **Neu.** Ende der Offline-Gnadenfrist (Unix-Zeit) |
### 2.4 Zwischengespeicherten Schlüssel lesen
```csharp
// Für --license-status: was liegt lokal vor?
string? key = LicenseClient.TryGetCachedKey(ProductSlug);
LocalCacheData? state = LicenseClient.TryGetCachedState(ProductSlug);
// Erneut prüfen, ohne den Schlüssel selbst vorhalten zu müssen
var res = await client.RevalidateAsync(ProductSlug, ServerUrl);
```
`ValidateAsync` legt den Schlüssel bei erfolgreicher Prüfung selbst im
verschlüsselten Cache ab — eine eigene Ablage braucht es dafür nicht.
--- ---
## 3. Zustandsspeicher (`StateStore.cs`) & Cache-Härtung ## 3. Zustandsspeicher (`StateStore.cs`) & Cache-Härtung
@@ -148,11 +213,46 @@ public class Program
- **Linux**: Dateirechte `0600` (`chmod 600 state.dat`). - **Linux**: Dateirechte `0600` (`chmod 600 state.dat`).
- **Sicherheitsvorgabe**: Kein Klartext-Rückfall! Beschädigte oder manipulierte Cache-Dateien werden strikt als Cache-Fehltreffer behandelt. - **Sicherheitsvorgabe**: Kein Klartext-Rückfall! Beschädigte oder manipulierte Cache-Dateien werden strikt als Cache-Fehltreffer behandelt.
### 3.1 Offline-Gnadenfrist
Der Server meldet in jeder Antwort `cache_ttl_hours` — den projektspezifischen
Wert aus `dc_projects.default_cache_ttl_hours`, den du im WebUI unter
**Projekte** einstellst. Der Client trägt daraus `CacheExpiresAt` in den Cache
ein: **frühestens** die TTL, spätestens der Ablauf der Lizenz selbst.
> Vorher wurde das Feld ignoriert und stattdessen das Ablaufdatum der Lizenz als
> Cache-Ablauf eingetragen. Bei einer Lizenz bis 2040 war die Frist ohne
> Serverkontakt damit praktisch unbegrenzt — die Einstellung im WebUI blieb
> wirkungslos.
Wer eine eigene Anbindung schreibt: `cache_ttl_hours` auswerten, nicht durch
`expires_at` ersetzen.
### 3.2 Schema-Stand des Caches
`state.dat` trägt jetzt Schema 3 (zusätzlich `CacheExpiresAt`, `CacheTtlHours`).
Schema 2 wird weiterhin gelesen; die Frist wird dort aus `IssuedAt` plus der
Standard-TTL von 168 h abgeleitet. Ein SDK-Wechsel erzwingt also **keine**
Online-Prüfung.
### 3.3 Migration fremder Cache-Dateien
Eine Datei ohne `LLS2`-Kopf wird nur übernommen, wenn ihr `ProductSlug` zu
diesem Produkt passt und sie einen Lizenzschlüssel enthält.
> Vorher wurde beliebiges JSON nach `LocalCacheData` deserialisiert und sofort
> zurückgeschrieben. Passte kein einziges Feld, entstand ein Standardobjekt, das
> die ursprüngliche Datei überschrieb. Da LicenseLabrador denselben Pfad und
> Dateinamen verwendet — `GetStorageDirectory` berücksichtigt eigens
> `LICENSELABRADOR_STORAGE_DIR` —, zerstörte das dessen Cache still.
--- ---
## 4. Kopfloser Betrieb (Headless Services / systemd) ## 4. Kopfloser Betrieb (Headless Services / systemd)
Für Hintergrunddienste (ohne GUI) stehen folgende CLI-Schalter am Anwendungshost zur Verfügung: > **Diese Schalter baut deine Anwendung selbst** — das SDK bringt keine
> CLI mit. Es liefert die Bausteine dafür; die folgende Empfehlung ist eine
> Konvention, damit alle Dienste gleich zu bedienen sind.
```bash ```bash
# Status der Hardware-ID und des lokalen Caches ausgeben # Status der Hardware-ID und des lokalen Caches ausgeben
@@ -165,6 +265,52 @@ my-service --license-set-key LLAB1-98A72-B3C4D-5E6F7-89012
my-service --license-deactivate my-service --license-deactivate
``` ```
Umsetzung mit den Bausteinen des SDK:
```csharp
var client = new LicenseClient();
switch (args[0])
{
case "--license-status":
{
var hw = HardwareId.GetHardwareId(ProductSlug);
var state = LicenseClient.TryGetCachedState(ProductSlug);
Console.WriteLine($"HWID v2: {hw.HardwareId} ({hw.HwidSource})");
Console.WriteLine($"Schlüssel: {LicenseClient.TryGetCachedKey(ProductSlug) ?? "keiner"}");
Console.WriteLine($"Cache: {(state == null ? "leer" : state.Status)}");
if (state != null && state.CacheExpiresAt > 0)
{
var until = DateTimeOffset.FromUnixTimeSeconds(state.CacheExpiresAt);
Console.WriteLine($"Offline noch bis: {until:u}");
}
return 0;
}
case "--license-set-key":
// ValidateAsync legt den Schlüssel bei Erfolg selbst im Cache ab.
var res = await client.ValidateAsync(ProductSlug, args[1], ServerUrl);
Console.WriteLine(res.Message);
return res.IsValid ? 0 : 1;
case "--license-deactivate":
string? key = LicenseClient.TryGetCachedKey(ProductSlug);
if (key == null) { Console.Error.WriteLine("Kein Schlüssel hinterlegt."); return 1; }
bool ok = await client.DeactivateAsync(
ProductSlug, key, ServerUrl,
Environment.GetEnvironmentVariable("DC_SHARED_KEY") ?? "");
return ok ? 0 : 1;
}
```
> `TryGetCachedKey` und `TryGetCachedState` sind neu in 2.1. Vorher gab es
> keinen Weg, den Schlüssel aus dem eigenen Cache zu lesen — jede Anwendung
> musste ihn ein zweites Mal selbst ablegen oder direkt `StateStore.Load()`
> aufrufen.
### Deaktivierung braucht Authentifizierung ### Deaktivierung braucht Authentifizierung
`/api/license/v1/deactivate` gibt einen Aktivierungsplatz frei und ist deshalb `/api/license/v1/deactivate` gibt einen Aktivierungsplatz frei und ist deshalb
@@ -192,13 +338,12 @@ curl -X POST https://dc.mhdf.de/api/license/v1/deactivate \
Alternativ genügt für Einzelfälle der Knopf **Freigeben** in der Hardware-Liste Alternativ genügt für Einzelfälle der Knopf **Freigeben** in der Hardware-Liste
des WebUI — das ist der übliche Weg und braucht keinen Schlüssel im Feld. des WebUI — das ist der übliche Weg und braucht keinen Schlüssel im Feld.
### Was passiert bei Ratenbegrenzung > **Nur noch `Authorization: Bearer`.** Frühere SDK-Fassungen schickten denselben
> Wert zusätzlich als `X-Watchdog-Key`. Der Name führte in die Irre — hier gehört
`/validate` ist auf 120 Anfragen pro Minute und IP begrenzt. Darüber kommt > der `shared_key` hin, kein Watchdog-Token — und der Server prüft
`429` mit `{"status":"error","error":{"code":"rate_limited"}}` — hier greift > `X-Watchdog-Key` **vor** `Authorization`. Ein dort versehentlich eingetragenes
ausnahmsweise das Umschlagformat, weil die Drosselung vor der Lizenzlogik > Watchdog-Token hätte das richtige Bearer-Token stillschweigend verdrängt und
zuschlägt. Ein Client sollte in dem Fall den lokalen Cache verwenden und es > zu einem unerklärlichen 401 geführt.
später erneut versuchen, statt die Anwendung zu blockieren.
--- ---
@@ -208,3 +353,66 @@ Wenn ein bestehender Windows-Client auf Hardware-ID v2 aktualisiert wird:
- Der Client schickt `hardware_id` (v2) **und** `legacy_hardware_id` (v1) mit. - Der Client schickt `hardware_id` (v2) **und** `legacy_hardware_id` (v1) mit.
- Der Server findet die alte Aktivierung unter `legacy_hardware_id` und zieht den Datenbank-Eintrag lautlos auf v2 um. - Der Server findet die alte Aktivierung unter `legacy_hardware_id` und zieht den Datenbank-Eintrag lautlos auf v2 um.
- Es wird kein zusätzlicher Aktivierungsplatz verbraucht! - Es wird kein zusätzlicher Aktivierungsplatz verbraucht!
---
## 6. Fehlerfälle richtig behandeln
**Nur ein gelesenes Urteil ist ein Urteil.** Ein HTTP-Fehler sagt nichts über
die Lizenz aus — er sagt, dass die Frage nicht beantwortet wurde.
`ValidateAsync` fällt deshalb bei **jedem** erfolglosen Aufruf auf den
Offline-Cache zurück: Netzfehler, Zeitüberschreitung, `429`, `5xx`, und auch bei
einer erfolgreichen Antwort, die sich nicht auswerten lässt (etwa der
Fehlerseite eines Proxys mit Statuscode 200).
```csharp
var res = await client.ValidateAsync(ProductSlug, key, ServerUrl);
if (res.IsValid)
{
// valid oder valid_offline — weiterarbeiten
}
else if (res.IsTransient)
{
// server_unavailable, cache_expired: kein Urteil, nur keine Auskunft.
// Warnen, weiterlaufen lassen, später erneut prüfen.
logger.Warn(res.Message);
}
else
{
// revoked, expired, not_found, activation_limit, suspended, clock_rollback
// Das ist ein Urteil. Jetzt darf beendet werden.
Environment.Exit(1);
}
```
| Status | `IsTransient` | Bedeutung |
|---|---|---|
| `valid` | | Vom Server bestätigt |
| `valid_offline` | | Aus dem Cache, Gnadenfrist läuft noch |
| `server_unavailable` | **ja** | Keine Verbindung **und** kein brauchbarer Cache |
| `cache_expired` | **ja** | Gnadenfrist abgelaufen, Server weiterhin stumm |
| `revoked`, `expired`, `not_found`, `activation_limit`, `suspended` | nein | Endgültig |
| `clock_rollback` | nein | Systemuhr zurückgedreht, Online-Prüfung nötig |
> **Was sich geändert hat:** Der Cache-Zweig lag zuvor ausschließlich im
> `catch`. Ein `429` oder `500` wirft aber keine Exception — die Prüfung fiel aus
> dem Erfolgszweig heraus und endete als `unknown_error`, **ohne den Cache auch
> nur zu befragen**. Ein Server-Schluckauf entzog damit die Lizenz, ein gezogenes
> Netzkabel nicht. Der Status `unknown_error` existiert nicht mehr.
### Ratenbegrenzung
`/validate` ist auf 120 Anfragen pro Minute und IP begrenzt. Darüber kommt
`429` mit `{"status":"error","error":{"code":"rate_limited"}}` — hier greift
ausnahmsweise das Umschlagformat, weil die Drosselung vor der Lizenzlogik
zuschlägt. Hinter einem NAT mit vielen Installationen ist das erreichbar. Der
Client verwendet in diesem Fall den lokalen Cache und versucht es später erneut.
### Zeitgrenze
Der intern erzeugte `HttpClient` hat jetzt 15 s statt der 100 s des .NET-Standards
— sonst stand eine Anwendung beim Start bis zu anderthalb Minuten still, wenn
der Server nicht antwortete. Ein selbst übergebener `HttpClient` wird nicht
verändert; dessen Zeitgrenze verantwortet der Aufrufer.
+413 -22
View File
@@ -6,6 +6,26 @@
> jetzt der semantischen Versionsordnung, `1.10.0` gilt also korrekt als neuer > jetzt der semantischen Versionsordnung, `1.10.0` gilt also korrekt als neuer
> als `1.9.0`. Umstellungsschritte: **[UPGRADE.md](./UPGRADE.md)**. > als `1.9.0`. Umstellungsschritte: **[UPGRADE.md](./UPGRADE.md)**.
> **⚠️ Geändert in Version 2.1**
> - `Deploymentcenter.BuildInfo.targets` erzeugt die Klasse jetzt im Namensraum
> des einbindenden Projekts. Die vorherige Fassung war nicht einbindbar
> (CS0433), siehe **[§2B](#b-msbuild-buildinfo-generierung)**.
> - Der API-Rückfall in `CheckForUpdateAsync` liest die Antwort jetzt korrekt.
> Fehlte die `latest.json`, kamen zuvor weder Download-Adresse noch Prüfsumme,
> Changelog oder Kritikalität an, siehe **[§2C](#c-zwei-quellen-zwei-formate)**.
> **⚠️ Geändert in Version 2.2 — bitte vollständig lesen, bevor das nächste
> Release gebaut wird.** Umstellungsschritte: **[UPGRADE.md §15](./UPGRADE.md#15-umstellung-auf-22)**.
> - **Releases tragen eine Plattform.** Ohne sie überschrieben sich `win-x64`
> und `linux-x64` gegenseitig, siehe **[§1A](#1a-plattformen)**.
> - **Konfigurationsdateien überleben ein Update.** Bisher überschrieb jedes
> Update die eingerichteten Werte des Zielsystems, siehe **[§3A](#3a-ausschließen-oder-schützen)**.
> - **Anwenden mit Rollback.** Ein Abbruch hinterlässt keine halbe Installation
> mehr, und entfernte Dateien werden aufgeräumt, siehe **[§4B](#4b-wie-ein-update-angewendet-wird)**.
> - **Releases sind signiert**, siehe **[§6](#6-signatur-der-releases)**.
> - `LaunchUpdateAgent` übergibt jetzt Neustart, Prozesskennung und Plattform,
> siehe **[§2A](#a-referenz-auf-deploymentcenterclient)**.
Das **UpdateService-Modul** des Deploymentcenters bietet ein unternehmensweites, leichtgewichtiges Update-, Rollback- und Reparatur-Schema auf Basis eines LEMP-Stacks (Nginx Static Files + PHP API). Das **UpdateService-Modul** des Deploymentcenters bietet ein unternehmensweites, leichtgewichtiges Update-, Rollback- und Reparatur-Schema auf Basis eines LEMP-Stacks (Nginx Static Files + PHP API).
@@ -16,10 +36,56 @@ Das **UpdateService-Modul** des Deploymentcenters bietet ein unternehmensweites,
- **Kein dauerhafter Background-Dienst**: Hauptanwendungen prüfen beim Start einmalig schnell und netzwerktolerant auf verfügbare Updates und Dateiintegrität. - **Kein dauerhafter Background-Dienst**: Hauptanwendungen prüfen beim Start einmalig schnell und netzwerktolerant auf verfügbare Updates und Dateiintegrität.
- **Entkoppelte Ausführung**: Bei Handlungsbedarf beendet sich die Hauptanwendung sauber und übergibt die Kontrolle an den eigenständigen Console Agent (`update-agent.exe` / `update-agent`). - **Entkoppelte Ausführung**: Bei Handlungsbedarf beendet sich die Hauptanwendung sauber und übergibt die Kontrolle an den eigenständigen Console Agent (`update-agent.exe` / `update-agent`).
- **3-Kanal-System**: Kanäle `prod` (Produktiv), `beta` (Vorab-Test), `dev` (Entwicklung). - **3-Kanal-System**: Kanäle `prod` (Produktiv), `beta` (Vorab-Test), `dev` (Entwicklung).
- **Plattform-Dimension**: je Kanal getrennte Pakete für `win-x64`, `linux-x64` usw.
- **Statische LEMP-Verteilung**: Downloads und Versionen-Manifeste (`latest.json`, `manifest.json`, `package.tar.gz`) werden über Nginx extrem performant bereitgestellt. - **Statische LEMP-Verteilung**: Downloads und Versionen-Manifeste (`latest.json`, `manifest.json`, `package.tar.gz`) werden über Nginx extrem performant bereitgestellt.
--- ---
## 1A. Plattformen
Ein Release wird durch **vier** Angaben bestimmt: Projekt, Kanal, Version und
Plattform. Die Plattform ist eine .NET-Laufzeitkennung (`win-x64`,
`linux-x64`, `linux-musl-arm64`, `osx-arm64`, …) oder `any` für Pakete, die
überall laufen.
Zuvor gab es diese Dimension nicht. Wer für zwei Plattformen baute, veröffentlichte
beide Pakete unter derselben Version im selben Kanal — das zweite überschrieb
das erste, und ein Linux-System zog sich das Windows-Paket. Behelfe waren
getrennte Projekt-Slugs (`myapp-win`, `myapp-linux`) oder zweckentfremdete
Kanäle; beides trug nicht weit.
### Auswahlregel
| Client schickt | bekommt |
|---|---|
| `platform=win-x64` | Pakete mit `win-x64`, ersatzweise `any` |
| `platform=linux-x64` | Pakete mit `linux-x64`, ersatzweise `any` |
| nichts | **ausschließlich** `any` |
Der letzte Fall ist Absicht. Ein Client, der seine Kennung nicht nennt, soll
lieber kein Update bekommen als das Paket einer fremden Plattform. Alle vor
Version 2.2 veröffentlichten Releases stehen auf `any` und bleiben damit für
bereits ausgelieferte Anwendungen erreichbar.
Je Version gewinnt das plattformgenaue Paket; gibt es keines, wird das
plattformunabhängige genommen.
### Ablage
Plattformunabhängige Releases behalten den bisherigen Pfad, alle anderen
bekommen eine Zwischenebene:
```text
/releases/myapp/prod/1.4.3/package.tar.gz ← platform = any
/releases/myapp/prod/win-x64/1.4.3/package.tar.gz ← platform = win-x64
/releases/myapp/prod/linux-x64/1.4.3/package.tar.gz ← platform = linux-x64
```
Ohne diese Rücksicht wären alle bestehenden Installationen von einem Tag auf
den anderen von ihren Updates abgeschnitten gewesen.
---
## 2. Integration in .NET Client-Anwendungen ## 2. Integration in .NET Client-Anwendungen
### A. Referenz auf `Deploymentcenter.Client` ### A. Referenz auf `Deploymentcenter.Client`
@@ -37,32 +103,118 @@ var checkResult = await updateClient.CheckForUpdateAsync(
projectId: "myapp", projectId: "myapp",
currentVersion: BuildInfo.Version, currentVersion: BuildInfo.Version,
channel: "prod" channel: "prod"
// platform: entfällt - ohne Angabe die Kennung des laufenden Systems
); );
if (checkResult.UpdateAvailable) if (checkResult.UpdateAvailable)
{ {
Console.WriteLine($"[UPDATE] Neues Release v{checkResult.LatestRelease.Version} verfügbar!"); Console.WriteLine($"[UPDATE] Neues Release v{checkResult.LatestRelease.Version} verfügbar!");
// UpdateAgent starten und Hauptanwendung beenden var agentPath = UpdateClient.ResolveAgentPath();
if (agentPath == null)
{
Console.WriteLine("[UPDATE] Kein update-agent gefunden.");
return;
}
UpdateClient.LaunchUpdateAgent( UpdateClient.LaunchUpdateAgent(
agentPath: "update-agent.exe", agentPath: agentPath,
projectId: "myapp", projectId: "myapp",
channel: "prod", channel: "prod",
action: "update", action: "update",
version: "latest", version: "latest",
currentVersion: BuildInfo.Version,
exitCurrentApp: true exitCurrentApp: true
); );
} }
``` ```
**Nicht mehr `"update-agent.exe"` fest verdrahten.** `ResolveAgentPath()`
liefert den plattformrichtigen Namen — unter Linux und macOS trägt das Binary
keine Endung, ein fester `.exe`-Name wird dort nie gefunden, und die Anwendung
meldet stumm „kein Agent vorhanden".
Drei Dinge erledigt `LaunchUpdateAgent` seit 2.2 von selbst:
| Was | Warum |
|---|---|
| `--restart` mit dem Pfad der eigenen Anwendung | Zuvor wurde der Parameter nie übergeben. Der Agent unterstützte ihn, bekam ihn aber nie zu sehen — die Anwendung schloss sich und blieb zu |
| `--wait-for-pid` mit der eigenen Prozesskennung | Zuvor wurde der Agent gestartet und sofort `Environment.Exit(0)` gerufen. Bei langsamem Herunterfahren (Kestrel, EF, Log-Flush) kopierte er über noch gesperrte Dateien |
| `--platform` mit der Kennung des Systems | Verhindert, dass ein Paket der falschen Plattform gezogen wird |
Abschaltbar über `restartPath: ""` bzw. `waitForCurrentProcess: false`, wenn
ein Dienst-Manager den Neustart übernimmt.
`currentVersion` ist wichtig für Installationen ohne `manifest.json` — siehe
**[§4A](#4a-welche-version-ist-installiert)**.
### B. MSBuild BuildInfo Generierung ### B. MSBuild BuildInfo Generierung
Binde das `Deploymentcenter.BuildInfo.targets` Script in deine `.csproj` ein, damit Version, UTC-Build-Datum und Git Commit-Hash automatisch zur Übersetzungszeit generiert werden: Binde das `Deploymentcenter.BuildInfo.targets` Script in deine `.csproj` ein, damit Version, UTC-Build-Datum und Git Commit-Hash automatisch zur Übersetzungszeit generiert werden:
```xml ```xml
<PropertyGroup>
<Version>1.4.3</Version>
</PropertyGroup>
<Import Project="..\Deploymentcenter.Client\Deploymentcenter.BuildInfo.targets" /> <Import Project="..\Deploymentcenter.Client\Deploymentcenter.BuildInfo.targets" />
``` ```
Die Klasse entsteht im Namensraum deines Projekts (`$(RootNamespace)`), nicht im
SDK. Du erreichst sie also ohne `using`:
```csharp
Console.WriteLine(BuildInfo.Version); // "1.4.3" aus <Version>
Console.WriteLine(BuildInfo.Summary); // v1.4.3 (5f9b0c5) built on ... [prod]
```
Verfügbare Werte: `Version`, `GitCommit`, `GitCommitShort`, `BuildDateUtc`,
`Channel`, `Summary`. Ohne Git-Arbeitskopie stehen die Commit-Felder auf
`UNKNOWN`, der Build läuft trotzdem durch.
Überschreibbare MSBuild-Eigenschaften:
| Eigenschaft | Wirkung |
|---|---|
| `DeploymentcenterBuildInfoNamespace` | Zielnamensraum (Vorgabe: `$(RootNamespace)`) |
| `DeploymentcenterBuildInfoClass` | Klassenname (Vorgabe: `BuildInfo`) |
| `BuildChannel` | `prod`, `beta`, `dev` (Vorgabe: `prod`) |
| `GenerateDeploymentcenterBuildInfo` | auf `false` setzen, um die Erzeugung abzuschalten |
> **Nicht auf `Deploymentcenter.Client.Models` zeigen lassen.** Dort liefert das
> SDK bereits eine gleichnamige Klasse aus; `partial` verbindet Teilklassen nur
> innerhalb derselben Assembly. Genau daran scheiterte die vorherige Fassung des
> Targets: sie erzeugte fest in diesen Namensraum, wodurch im Consumer ein
> zweiter Typ mit demselben vollen Namen entstand (CS0433) und der generierte
> statische Konstruktor Eigenschaften setzte, die es dort nicht gab (CS0103).
> Ein Import war damit unmöglich.
### C. Zwei Quellen, zwei Formate
`CheckForUpdateAsync` fragt zuerst die statische
`/releases/{projectId}/{channel}/latest.json` ab und fällt auf
`/api/updateservice/v1/check` zurück. **Die beiden liefern unterschiedliche
Feldnamen:**
| Bedeutung | `latest.json` (Packager) | API-Antwort (Datenbank) |
|---|---|---|
| Download-Adresse | `packageUrl` | `download_url` |
| Prüfsumme | `sha256` | `sha256_hash` |
| Änderungshinweise | `changelog` | `release_notes` |
| Größe | `sizeBytes` | `size_bytes` |
| Kritisch | `isCritical` | `is_critical` **auf oberster Ebene** |
Das SDK bildet beide ab: `VersionInfo` für die `latest.json`, `ApiReleaseInfo`
für die API-Antwort. `UpdateCheckResult.LatestRelease` ist in beiden Fällen ein
`VersionInfo` — für die API wird übersetzt.
> Wer die API selbst anspricht, muss das berücksichtigen. Die vorherige SDK-Fassung
> deserialisierte die API-Antwort direkt nach `VersionInfo`; von beiden Formaten
> stimmt nur `version` überein. Über den API-Weg kam deshalb nichts weiter an —
> und `IsCritical` wurde aus dem Release-Objekt statt vom Wurzelfeld gelesen und
> war damit immer `false`. Da dieser Zweig genau der Rückfall ist, wenn die
> `latest.json` fehlt, degradierte die Update-Prüfung still.
--- ---
## 3. Packaging & Deployment CLI (`pack-and-deploy`) ## 3. Packaging & Deployment CLI (`pack-and-deploy`)
@@ -72,10 +224,16 @@ Das Packaging-Tool verpackt den `dotnet publish`-Output, berechnet Hashes, erzeu
### Aufruf-Beispiel: ### Aufruf-Beispiel:
```bash ```bash
# Automatisierter Release-Publish via CLI pack-and-deploy --project myapp --version 1.4.0 --channel prod \
pack-and-deploy --project myapp --version 1.4.0 --channel prod --publish-dir ./bin/Release/net8.0/publish --changelog "Fehlerbehebungen und Performance-Optimierung" --platform win-x64 \
--publish-dir ./bin/Release/net8.0/win-x64/publish \
--changelog "Fehlerbehebungen und Performance-Optimierung"
``` ```
Ohne `--platform` versucht der Packager, die Kennung aus dem Publish-Pfad zu
lesen (`.../net8.0/linux-x64/publish``linux-x64`). Gelingt das nicht, gilt
das Release als plattformunabhängig und es erscheint eine Warnung.
### Konfiguration (`packager.config.json`) ### Konfiguration (`packager.config.json`)
> Diese Datei enthält Zugangsdaten und ist per `.gitignore` von der > Diese Datei enthält Zugangsdaten und ist per `.gitignore` von der
@@ -98,13 +256,19 @@ pack-and-deploy --project myapp --version 1.4.0 --channel prod --publish-dir ./b
"appsettings.Development.json", "appsettings.Development.json",
"*.log", "*.log",
"logs/**" "logs/**"
],
"preservePatterns": [
"appsettings.json",
"settings.json",
".env"
] ]
} }
``` ```
`apiToken` braucht das Recht `updateservice:publish`. Ohne Token baut und lädt `apiToken` braucht das Recht `updateservice:publish`. Ohne Token baut und lädt
der Packager das Paket zwar hoch, meldet es aber nicht beim Deploymentcenter an der Packager das Paket zwar hoch, meldet es aber nicht beim Deploymentcenter an
und beendet sich mit Rückgabewert 2. und beendet sich mit Rückgabewert 2. Ohne Registrierung entsteht auch **keine
Signatur**.
### Alternative: Umgebungsvariablen ### Alternative: Umgebungsvariablen
@@ -118,7 +282,8 @@ export DC_FTP_PASS='...'
export DC_TOKEN='dc_sub_...' export DC_TOKEN='dc_sub_...'
pack-and-deploy --project myapp --version 1.4.0 --channel prod \ pack-and-deploy --project myapp --version 1.4.0 --channel prod \
--publish-dir ./bin/Release/net8.0/publish --platform linux-x64 \
--publish-dir ./bin/Release/net8.0/linux-x64/publish
``` ```
### Rückgabewerte ### Rückgabewerte
@@ -126,7 +291,7 @@ pack-and-deploy --project myapp --version 1.4.0 --channel prod \
| Wert | Bedeutung | | Wert | Bedeutung |
|---|---| |---|---|
| `0` | Paket gebaut, hochgeladen und im Deploymentcenter registriert | | `0` | Paket gebaut, hochgeladen und im Deploymentcenter registriert |
| `1` | Konfiguration unvollständig oder Publish-Verzeichnis fehlt — nichts wurde ausgeführt | | `1` | Konfiguration unvollständig, Publish-Verzeichnis fehlt oder Versionskonflikt — nichts wurde ausgeführt |
| `2` | Teilweise fehlgeschlagen: FTP-Upload oder Registrierung ging schief | | `2` | Teilweise fehlgeschlagen: FTP-Upload oder Registrierung ging schief |
Zuvor lieferte das Werkzeug in allen Fällen `0` und meldete „successfully Zuvor lieferte das Werkzeug in allen Fällen `0` und meldete „successfully
@@ -134,6 +299,65 @@ published", selbst wenn FTP-Upload und API-Aufruf beide fehlgeschlagen waren.
--- ---
## 3A. Ausschließen oder schützen
Das sind zwei verschiedene Dinge, und die Unterscheidung ist der Grund, warum
Updates bisher Konfigurationen zerstört haben.
| | `excludePatterns` | `preservePatterns` |
|---|---|---|
| Im Paket? | nein | ja |
| Bei der Erstinstallation? | fehlt | wird geschrieben |
| Beim Update? | — | vorhandene Datei bleibt unangetastet |
| Wofür | Build-Artefakte, Logs, Entwicklungs-Einstellungen | Konfigurationsvorlagen |
Eine `appsettings.json` gehört ins Paket — sonst ist eine Erstinstallation
unvollständig. Sie darf beim Update nur nicht über die eingerichteten Werte des
Zielsystems geschrieben werden. Genau dafür ist `preservePatterns` da; die
Liste wandert ins `manifest.json` und wird vom Agenten ausgewertet.
> **Vorher:** `IsExcluded` verstand ausschließlich `*.endung` und exakte
> Namen. Die mitgelieferte Beispielkonfiguration enthielt `logs/**` und
> `scratch/**` — beides traf **nie** zu. Und eine `settings.json` mit
> Datenbankpasswort und DC-Token stand auf keiner der beiden Listen: sie wurde
> mitgeliefert und überschrieb beim Update die Konfiguration jedes Zielsystems.
Die Muster sind jetzt echte Globs:
| Muster | trifft |
|---|---|
| `*.pdb` | jede `.pdb` in jedem Unterverzeichnis |
| `logs/**` | alles unterhalb von `logs/` |
| `wwwroot/*.css` | nur direkt in `wwwroot/`, nicht darunter |
| `wwwroot/**/*.css` | auch in Unterverzeichnissen |
| `appsettings*.json` | `appsettings.json`, `appsettings.Production.json`, … |
Der Packager warnt zusätzlich von sich aus, wenn eine Datei nach Zugangsdaten
aussieht und auf keiner der beiden Listen steht.
### Versionsgegenprobe
Der Packager liest die Version aus der Hauptassembly und bricht bei einer
Abweichung zu `--version` ab:
```
[FEHLER] Versionskonflikt:
--version sagt : 1.0.2
MyApp.dll sagt : 1.0.1
```
Der Grund dafür ist unangenehm genug, um dafür abzubrechen: Wird `1.0.1` als
`1.0.2` veröffentlicht, aktualisieren alle Clients, melden danach weiterhin
`1.0.1`, halten das Release erneut für neu — und aktualisieren bei jedem Start
wieder. Eine Endlosschleife über die gesamte Installationsbasis.
Üblicher Auslöser: `<Version>` steht nur in einem der beteiligten Projekte. Der
Wert gehört in die `Directory.Build.props`. Notausgang für bewusste
Abweichungen: `--ignore-version-mismatch`. Lässt sich die Assembly nicht
bestimmen, wird nur gewarnt — `--main-assembly` gibt sie gezielt an.
---
## 4. Standalone UpdateAgent (`update-agent`) ## 4. Standalone UpdateAgent (`update-agent`)
Der `update-agent` kann sowohl interaktiv (Spectre.Console Terminal UI) als auch im Headless CLI-Modus betrieben werden. Der `update-agent` kann sowohl interaktiv (Spectre.Console Terminal UI) als auch im Headless CLI-Modus betrieben werden.
@@ -157,16 +381,92 @@ update-agent --project myapp --channel prod --action repair
update-agent --project myapp --channel prod --action list update-agent --project myapp --channel prod --action list
``` ```
Zusätzliche Parameter seit 2.2:
| Parameter | Wirkung |
|---|---|
| `--platform <rid>` | Laufzeitkennung; Vorgabe ist die des laufenden Systems |
| `--current-version <ver>` | Installierte Version, wenn keine `manifest.json` vorliegt |
| `--wait-for-pid <pid>` | Vor dem Anwenden auf das Ende dieses Prozesses warten |
| `--wait-timeout <sek>` | Geduld dabei (Vorgabe 60). Läuft der Prozess danach noch, wird **nichts** verändert |
| `--pubkey <datei>` | Öffentlicher Schlüssel zur Signaturprüfung |
| `--require-signature` | Ohne gültige Signatur nicht installieren |
--- ---
## 4a. Prüf-Endpunkte (für eigene Anbindungen) ## 4A. Welche Version ist installiert?
Es gab zwei Antworten darauf, und sie widersprachen sich: Die Anwendung
verglich `BuildInfo.Version` (einkompiliert), der Agent las `manifest.json` im
Zielverzeichnis. Fehlte diese Datei — etwa bei einer von Hand aufgesetzten
Installation — meldete der Agent „Unbekannt" und hielt **jede** Version für
neuer. Die Reparatur suchte dann auf dem Server nach einer Version namens
„Unbekannt" und brach genau dann ab, wenn man sie braucht.
Die Reihenfolge ist jetzt:
1. `--current-version`, falls übergeben — die Anwendung kennt ihre eigene Version am sichersten
2. `manifest.json` im Zielverzeichnis
3. sonst `0.0.0`, und die Reparatur greift auf `latest` zurück
Deshalb sollte `LaunchUpdateAgent` immer `currentVersion: BuildInfo.Version`
mitgeben.
---
## 4B. Wie ein Update angewendet wird
Die Stelle war als „Atomic Replace with Backup" kommentiert und war
tatsächlich eine Kopierschleife: kein Backup, kein Rollback, kein Aufräumen.
Brach sie in der Mitte ab — gesperrte Datei, volle Platte —, blieb eine halb
aktualisierte Installation zurück, aus der kein Weg zurückführte.
Der Ablauf ist jetzt:
1. **Plan bilden.** Welche Dateien werden geschrieben, welche sind geschützt,
welche gehören nicht mehr zum Release?
2. **Sichern.** Jede Datei, die überschrieben oder entfernt wird, wandert
vorher nach `.dc-update-backup/`.
3. **Anwenden.** Schreiben, dann verwaiste Dateien entfernen.
4. **Bei einem Fehler:** vollständiger Rollback aus dem Backup, danach wird die
Ursache gemeldet. Die Installation bleibt auf dem alten Stand lauffähig.
5. **Bei Erfolg:** Backup löschen, leer gewordene Verzeichnisse entfernen.
### Verwaiste Dateien
Eine DLL, die es im neuen Release nicht mehr gibt, blieb bisher für immer im
Verzeichnis liegen — bei .NET ein realer Weg in kaputte Assembly-Auflösung.
Sie wird jetzt entfernt, aber **nur**, wenn sie in der `manifest.json` der
Vorversion stand. Ohne dieses Wissen wird nichts gelöscht; Dateien, die nicht
aus einem Release stammen, bleiben in jedem Fall unangetastet.
### Der Agent im Paket
`--target-dir` zeigt in der Vorgabe auf das Verzeichnis des Agenten selbst.
Liegt der Agent im Paket, kopierte er sich also unter laufendem Betrieb über
sich selbst — unter Windows eine Zugriffsverletzung mitten im Update.
Eine laufende ausführbare Datei lässt sich unter Windows nicht überschreiben,
aber umbenennen. Der Agent legt sich deshalb als `update-agent.exe.dc-old` zur
Seite, schreibt die neue Fassung und entfernt den Rest beim nächsten Start.
### Ausführungsrechte
Wird unter Windows für `linux-x64` gebaut, kennt das tar-Archiv keine
Unix-Rechte und alles landet als `644` — die Anwendung ließe sich auf dem
Zielsystem nicht starten. Der Agent setzt das Ausführungsbit beim Anwenden für
Dateien ohne Endung (der .NET-Apphost) und für `*.sh`.
---
## 4C. Prüf-Endpunkte (für eigene Anbindungen)
Die Lese-Endpunkte sind bewusst **ohne Token** erreichbar, damit ausgelieferte Die Lese-Endpunkte sind bewusst **ohne Token** erreichbar, damit ausgelieferte
Anwendungen ohne Anpassung weiter nach Updates suchen können. Sie liefern nur Anwendungen ohne Anpassung weiter nach Updates suchen können. Sie liefern nur
Release-Metadaten, die über die Download-URL ohnehin öffentlich sind. Release-Metadaten, die über die Download-URL ohnehin öffentlich sind.
```bash ```bash
GET /api/updateservice/v1/check?product=myapp&version=1.4.2&channel=prod GET /api/updateservice/v1/check?product=myapp&version=1.4.2&channel=prod&platform=win-x64
``` ```
```json ```json
@@ -175,25 +475,33 @@ GET /api/updateservice/v1/check?product=myapp&version=1.4.2&channel=prod
"update_available": true, "update_available": true,
"current_version": "1.4.2", "current_version": "1.4.2",
"latest_version": "1.4.3", "latest_version": "1.4.3",
"platform": "win-x64",
"is_critical": false, "is_critical": false,
"latest_release": { "latest_release": {
"version": "1.4.3", "version": "1.4.3",
"download_url": "https://dc.mhdf.de/releases/myapp/prod/1.4.3/package.tar.gz", "platform": "win-x64",
"download_url": "https://dc.mhdf.de/releases/myapp/prod/win-x64/1.4.3/package.tar.gz",
"sha256_hash": "e3b0c442...", "sha256_hash": "e3b0c442...",
"git_commit": "a21536f", "git_commit": "a21536f",
"size_bytes": 8412160, "size_bytes": 8412160,
"release_notes": "Behebt den Login-Fehler.", "release_notes": "Behebt den Login-Fehler.",
"manifest_signature": "hsuQVhef...",
"is_critical": 0 "is_critical": 0
} }
} }
``` ```
> **Ohne `platform` werden ausschließlich Releases mit `platform=any`
> berücksichtigt.** Wer die Endpunkte selbst anspricht und für mehrere
> Plattformen ausliefert, muss den Parameter mitschicken.
Weitere Endpunkte: Weitere Endpunkte:
| Aufruf | Zweck | | Aufruf | Zweck |
|---|---| |---|---|
| `GET .../latest?product=myapp&channel=prod` | Höchstes Release, unabhängig von der Client-Version | | `GET .../latest?product=myapp&channel=prod&platform=win-x64` | Höchstes Release, unabhängig von der Client-Version |
| `GET .../releases?product=myapp` | Alle Releases, nach Versionsordnung sortiert | | `GET .../releases?product=myapp` | Alle Releases, nach Versionsordnung sortiert. `platform` filtert hier **exakt** — der Endpunkt listet den Bestand, er wählt kein Paket aus |
| `GET .../pubkey` | Öffentlicher Schlüssel zur Signaturprüfung |
### Versionsvergleich ### Versionsvergleich
@@ -232,14 +540,97 @@ v1.4.3", der Packager veröffentlicht v1.4.3, und das Item schließt sich selbst
```text ```text
/var/www/releases/ (oder /public_html/releases/) /var/www/releases/ (oder /public_html/releases/)
└── {ProjectId}/ # z.B. myapp, polytrader └── {ProjectId}/ # z.B. myapp, polytrader
├── prod/ ├── prod/
│ ├── latest.json # Kanal-Übersicht & neueste Version │ ├── latest.json # nur platform = any
│ ├── 1.4.0/ │ ├── 1.4.0/ # nur platform = any
│ │ ├── package.tar.gz # Das gezippte Release │ │ ├── package.tar.gz
│ │ ├── package.tar.gz.sha256 │ │ ├── package.tar.gz.sha256
│ │ └── manifest.json # Einzeldateien + Hashes │ │ └── manifest.json # Einzeldateien, Hashes, preserve-Liste
── 1.3.9/ ── win-x64/
│ │ ├── latest.json # eigene Historie je Plattform
│ │ └── 1.4.0/
│ │ ├── package.tar.gz
│ │ ├── package.tar.gz.sha256
│ │ └── manifest.json
│ └── linux-x64/
│ ├── latest.json
│ └── 1.4.0/
├── beta/ ├── beta/
└── dev/ └── dev/
``` ```
Jede Plattform führt ihre eigene `latest.json`. Der Agent fragt zuerst den
plattformspezifischen Pfad ab und fällt auf den plattformlosen zurück — Pakete
einer fremden Plattform werden dabei verworfen.
### Aufbewahrung
`latest.json` führt die letzten 15 Versionen. Ältere Versionsverzeichnisse
bleiben auf dem Server liegen, sind über den Agenten aber nicht mehr
auswählbar. Der Packager weist beim Herausfallen einer Version ausdrücklich
darauf hin; wer weiter zurück muss, holt das Paket von Hand.
---
## 6. Signatur der Releases
Der SHA256 eines Pakets stammt aus derselben Quelle wie das Paket selbst. Wer
den Webroot oder die FTP-Zugangsdaten kontrolliert, tauscht beide gemeinsam
aus — der Hash schützt dann gegen Übertragungsfehler, nicht gegen Manipulation.
Ausgerechnet auf dem Pfad, der fremden Code ausführt.
### Warum kein HMAC
Beim Lizenzmodul wird mit HMAC signiert, und das geht dort auf, weil der
**Server** prüft. Ein Update wird auf dem Zielsystem geprüft. Ein HMAC bräuchte
dort denselben geheimen Schlüssel wie auf dem Server; wer ihn ausliest, kann
beliebige Pakete signieren — die Signatur verlöre genau die Eigenschaft, wegen
der es sie gibt.
Deshalb asymmetrisch: der Server signiert mit einem privaten RSA-Schlüssel, der
Agent prüft mit dem öffentlichen.
### Einrichten
```bash
openssl genrsa -out /etc/dc/release-signing.pem 2048
chmod 600 /etc/dc/release-signing.pem
```
```php
// config/config.php
'release_private_key' => dc_env('DC_RELEASE_SIGNING_KEY', '/etc/dc/release-signing.pem'),
```
Signiert wird beim Veröffentlichen, serverseitig. **Der Packager bekommt den
Schlüssel nicht zu sehen** — er läuft auf Entwicklerrechnern, und der Schlüssel
wäre so gut geschützt wie das schwächste dieser Systeme.
Signiert wird eine kanonische Zeile, nicht das Manifest-JSON: JSON-Ausgabe ist
nicht bytestabil (Schlüsselreihenfolge, Escaping, Zahlenformat), eine Signatur
darüber wäre unzuverlässig prüfbar.
```
dc-release-v1\n{product_slug}\n{version}\n{channel}\n{platform}\n
{sha256_hash klein}\n{download_url}\n{size_bytes}
```
### Prüfen
Der Agent holt den öffentlichen Schlüssel einmalig von
`/api/updateservice/v1/pubkey` und legt ihn als `dc-release-pubkey.pem` neben
sich ab. Meldet der Server später einen **anderen** Schlüssel, wird gewarnt und
weiterhin der hinterlegte benutzt — ein untergeschobener Server fällt damit
auf. War der Wechsel beabsichtigt, die Datei löschen.
| Lage | Verhalten |
|---|---|
| Signatur gültig | Installation läuft |
| Signatur ungültig | **Abbruch**, immer |
| Release unsigniert | Hinweis, Installation läuft |
| Kein öffentlicher Schlüssel | Hinweis, Installation läuft |
| `--require-signature` gesetzt | Die letzten beiden Fälle brechen ebenfalls ab |
Ohne hinterlegten Schlüssel bleibt also alles funktionsfähig — es fehlt nur die
Vertrauenskette, und darauf wird bei jedem Update hingewiesen.
+217
View File
@@ -3,6 +3,16 @@
Diese Fassung enthält Sicherheitskorrekturen, die das Verhalten der Diese Fassung enthält Sicherheitskorrekturen, die das Verhalten der
Schnittstellen ändern. Bitte in dieser Reihenfolge vorgehen. Schnittstellen ändern. Bitte in dieser Reihenfolge vorgehen.
> **Version 2.1** ergänzt Korrekturen im .NET-SDK und ein neues Feld am
> Heartbeat. Sie erfordert keine Konfigurationsänderung, aber eine
> Datenbankmigration und ein Nachziehen der Clients:
> **[§14 Umstellung auf 2.1](#14-umstellung-auf-21)**.
>
> **Version 2.2** überarbeitet den UpdateService grundlegend: Plattform-Dimension,
> signierte Releases, Staging-Swap mit Rollback und geschützte Konfigurationsdateien.
> **Vor dem nächsten Release einzuspielen** —
> **[§15 Umstellung auf 2.2](#15-umstellung-auf-22)**.
--- ---
## 1. Vor dem Deployment: Zugangsdaten wechseln ## 1. Vor dem Deployment: Zugangsdaten wechseln
@@ -102,10 +112,38 @@ Die Migration ist additiv und legt an bzw. korrigiert:
bisher vollständig — der Zustand änderte sich nur beim Eintreffen eines bisher vollständig — der Zustand änderte sich nur beim Eintreffen eines
Heartbeats, ein ausgefallener Server blieb dauerhaft grün. Heartbeats, ein ausgefallener Server blieb dauerhaft grün.
### Empfohlen: interner Aufruf (seit 2.1)
```bash
* * * * * /usr/bin/php /pfad/zum/deploymentcenter/cli/tick.php --quiet
```
Das entspricht dem früheren `watchdog/cli/tick.php`. Gegenüber dem HTTP-Weg:
- **Kein Schlüssel im Crontab.** `ps` zeigt die Argumente laufender Prozesse
jedem Nutzer der Maschine — bei einem `curl -H "Authorization: Bearer …"`
also auch den Shared Key.
- **Keine Abhängigkeit von Webserver, TLS oder DNS.** Der Evaluator läuft auch
dann, wenn der Webserver klemmt — also gerade dann, wenn das Erkennen von
Ausfällen wichtig ist.
- **Keine überlappenden Läufe.** Das Skript sperrt sich per `flock` selbst; ein
langsamer Vorlauf führt nicht zu doppelten Alarmen.
`--quiet` gibt nur bei Zustandswechseln und Fehlern etwas aus — sonst erzeugt
ein minütlicher Job 1440 Cron-Mails am Tag. Rückgabewerte: `0` erfolgreich,
`1` Fehler, `2` übersprungen (es lief bereits ein Tick).
### Alternativ: über die Schnittstelle
Sinnvoll, wenn der Cron auf einer anderen Maschine läuft als die Anwendung:
```bash ```bash
* * * * * curl -fsS -H "Authorization: Bearer <SHARED_KEY>" https://dc.mhdf.de/api/watchdog/v1/evaluate > /dev/null * * * * * curl -fsS -H "Authorization: Bearer <SHARED_KEY>" https://dc.mhdf.de/api/watchdog/v1/evaluate > /dev/null
``` ```
Beide Wege rufen denselben Code auf und schreiben denselben Lauf-Vermerk in
`watchdog_cron_jobs` — nur einen von beiden einrichten.
Solange der Job fehlt, zeigt das WebUI oben einen Warnhinweis mit einer Solange der Job fehlt, zeigt das WebUI oben einen Warnhinweis mit einer
Schaltfläche für einen einmaligen Lauf. Schaltfläche für einen einmaligen Lauf.
@@ -279,3 +317,182 @@ X-DC-Signature: sha256=<hex(hmac_sha256(secret, timestamp + "." + body))>
``` ```
Nach 20 Fehlversuchen in Folge deaktiviert sich ein Webhook selbst. Nach 20 Fehlversuchen in Folge deaktiviert sich ein Webhook selbst.
---
## 14. Umstellung auf 2.1
Diese Fassung korrigiert Fehler im .NET-SDK, ergänzt ein Feld am Heartbeat und
vervollständigt die OpenAPI-Beschreibung. **Keine Konfigurationsänderung nötig.**
### 14.1 Serverseitig
- [ ] Migration **008** ausführen (Spalte `app_version` in `watchdog_monitors`).
Läuft wie die übrigen über den Migrator, siehe [§4](#4-migration-ausführen).
- [ ] `/api/openapi.json` abrufen und prüfen, dass die Lizenz-Endpunkte und
`/api/watchdog/v1/event`, `/events`, `/status` jetzt enthalten sind.
Bestehende Agenten laufen unverändert weiter; alle neuen Felder sind optional.
### 14.2 Clientseitig (`Deploymentcenter.Client`)
Die folgenden Änderungen betreffen jede bestehende Integration:
| Änderung | Was zu tun ist |
|---|---|
| `unknown_error` entfällt | Wer diesen Status abfängt, prüft stattdessen `IsTransient` |
| Cache-Rückfall bei jedem HTTP-Fehler | Eigene Behelfslösungen für 429/5xx können entfallen |
| `cache_ttl_hours` wird ausgewertet | Offline-Gnadenfrist prüfen: Vorgabe je Projekt im WebUI |
| `app_version` ist Parameter | `LicenseClient.DefaultAppVersion` beim Start setzen |
| `X-Watchdog-Key` entfällt bei `DeactivateAsync` | Nur noch `Authorization: Bearer` |
| `BuildInfo.targets` erzeugt in `$(RootNamespace)` | Import ist jetzt möglich; ggf. `using` entfernen |
| API-Rückfall der Update-Prüfung liefert vollständige Daten | Eigene Umgehungen können entfallen |
Ausführlich: **[LICENSE_INTEGRATION_GUIDE §6](./LICENSE_INTEGRATION_GUIDE.md)**
und **[UPDATESERVICE_INTEGRATION_GUIDE §2B/§2C](./UPDATESERVICE_INTEGRATION_GUIDE.md)**.
### 14.3 Cache-Schema
`state.dat` trägt jetzt Schema 3. Schema 2 wird weiterhin gelesen — ein
SDK-Wechsel erzwingt **keine** erneute Online-Prüfung. Ein Rückschritt auf ein
älteres SDK dagegen schon: das kennt Schema 3 nicht und behandelt den Cache als
Fehltreffer.
---
## 15. Umstellung auf 2.2
Diese Fassung behebt eine Reihe von Fehlern im Update-Weg, die zusammengenommen
verhindert haben, dass für mehr als eine Plattform ausgeliefert werden konnte —
und die im Fehlerfall halb aktualisierte Installationen hinterließen.
**Diese Umstellung gehört vor das nächste Release**, nicht danach.
### 15.1 Was sich ändert
| Bereich | Vorher | Jetzt |
|---|---|---|
| Plattformen | nicht vorhanden — `win-x64` und `linux-x64` überschrieben sich | eigene Dimension `platform`, getrennte Ablage |
| Konfigurationsdateien | wurden beim Update überschrieben | `preservePatterns` schützt sie |
| Ausschlussmuster | nur `*.endung` und exakte Namen | echte Globs (`**`, `*`, `?`) |
| Abbruch beim Anwenden | halb aktualisierte Installation | Backup, automatischer Rollback |
| Entfernte Dateien | blieben für immer liegen | werden aufgeräumt |
| Agent im Paket | überschrieb sich selbst | wird zur Seite gelegt und ersetzt |
| Übergabe an den Agenten | Wettlauf, kein Neustart | PID-Handschlag und `--restart` |
| Versionsangabe | ungeprüft | wird gegen die Assembly gehalten |
| Paketherkunft | nur SHA256 aus derselben Quelle | RSA-Signatur des Servers |
| `manifest_json` | Spalte blieb immer leer | wird befüllt, API ist vollwertiger Rückfall |
### 15.2 Serverseitig
- [ ] Migration **009** ausführen (`platform`, `manifest_signature`, neuer
Unique-Key). Additiv; bestehende Releases erhalten `platform = 'any'` und
bleiben für alle Clients erreichbar.
- [ ] Signierschlüssel erzeugen und in `config/config.php` eintragen:
```bash
openssl genrsa -out /etc/dc/release-signing.pem 2048
chmod 600 /etc/dc/release-signing.pem
chown www-data:www-data /etc/dc/release-signing.pem
```
```php
'release_private_key' => dc_env('DC_RELEASE_SIGNING_KEY', '/etc/dc/release-signing.pem'),
```
- [ ] Prüfen, dass der öffentliche Schlüssel ausgeliefert wird:
```bash
curl https://dc.mhdf.de/api/updateservice/v1/pubkey
```
> **Bewusst RSA und kein HMAC.** Bei einem HMAC braucht der Prüfende denselben
> geheimen Schlüssel wie der Signierende. Der Agent läuft auf fremden Systemen;
> ein dort hinterlegter Schlüssel ließe sich auslesen und zum Signieren
> beliebiger Pakete verwenden — die Signatur wäre wertlos. Beim Lizenzmodul geht
> HMAC auf, weil dort der Server prüft.
Ohne Schlüssel bleibt alles funktionsfähig; Releases sind dann unsigniert, und
der Agent weist bei jedem Update darauf hin.
### 15.3 Packager
- [ ] `packager.config.json` um `preservePatterns` ergänzen (Vorlage in
`packager.config.example.json`).
- [ ] **Prüfen, welche Konfigurationsdateien im Publish-Verzeichnis liegen.**
Der Packager warnt jetzt von sich aus vor Dateien, die nach Zugangsdaten
aussehen und weder ausgeschlossen noch geschützt sind. Diese Warnung ernst
nehmen: eine `settings.json` mit Datenbankpasswort, die bisher mitgeliefert
wurde, hat beim Update die Konfiguration jedes Zielsystems überschrieben.
- [ ] Aufrufe um `--platform` ergänzen:
```bash
pack-and-deploy --project myapp --version 1.4.3 --channel prod \
--platform win-x64 \
--publish-dir ./bin/Release/net8.0/win-x64/publish
```
Ohne `--platform` versucht der Packager, die Kennung aus dem Publish-Pfad
abzuleiten. Gelingt das nicht, gilt das Release als plattformunabhängig und es
erscheint eine Warnung.
- [ ] Die Version wird jetzt gegen die Hauptassembly geprüft. Weicht sie ab,
bricht der Vorgang ab. Das ist der Fall, in dem `<Version>` nur in einem
der beteiligten Projekte steht — der Wert gehört in die
`Directory.Build.props`, damit alle denselben tragen. Notausgang für
bewusste Abweichungen: `--ignore-version-mismatch`.
### 15.4 Clientseitig
`UpdateClient.CheckForUpdateAsync` hat einen zusätzlichen Parameter `platform`
**vor** dem `CancellationToken`. Wer den Token bisher positionell übergeben hat,
bekommt einen Übersetzungsfehler — kein stilles Fehlverhalten.
Der Aufruf des Agenten sollte umgestellt werden:
```csharp
var agentPath = UpdateClient.ResolveAgentPath(); // plattformrichtiger Name
if (agentPath != null && checkResult.UpdateAvailable)
{
UpdateClient.LaunchUpdateAgent(
agentPath: agentPath,
projectId: "myapp",
channel: "prod",
currentVersion: BuildInfo.Version, // für Ziele ohne manifest.json
exitCurrentApp: true); // Neustart und PID-Handschlag
// sind jetzt Vorgabe
}
```
| Änderung | Wirkung |
|---|---|
| `ResolveAgentPath()` | Findet `update-agent` bzw. `update-agent.exe` — ein fest verdrahteter `.exe`-Name wird unter Linux nie gefunden |
| `--restart` wird übergeben | Die Anwendung startet nach dem Update wieder. Bisher blieb sie einfach zu |
| `--wait-for-pid` wird übergeben | Der Agent wartet auf das Ende der Anwendung, statt über gesperrte Dateien zu kopieren |
| `--platform` wird übergeben | Es kann kein Paket einer fremden Plattform mehr gezogen werden |
### 15.5 Den Agenten aus dem Paket nehmen
Liegt `update-agent` im Publish-Verzeichnis, wandert er ins Paket und wird beim
Update mit ausgeliefert. Das ist zulässig — der Agent legt seine laufende Datei
inzwischen zur Seite und ersetzt sie —, aber es lohnt sich, bewusst zu
entscheiden:
- **Mitliefern:** Der Agent aktualisiert sich selbst mit. Die alte Fassung bleibt
als `update-agent.exe.dc-old` liegen und wird beim nächsten Start entfernt.
- **Ausschließen:** `"update-agent*"` in `excludePatterns`. Dann wird der Agent
getrennt gepflegt.
### 15.6 Prüfen
- [ ] `curl "https://dc.mhdf.de/api/updateservice/v1/check?product=myapp&version=0.0.0&channel=prod&platform=win-x64"`
liefert das Windows-Paket, mit `platform=linux-x64` das Linux-Paket.
- [ ] Ohne `platform` kommen ausschließlich Releases mit `platform=any`. Das ist
Absicht: ein Client, der seine Kennung nicht nennt, soll lieber kein Update
bekommen als das falsche.
- [ ] Im WebUI zeigt die Release-Übersicht eine Spalte **Plattform** und
kennzeichnet unsignierte Releases.
- [ ] Ein Update auf einem Testsystem: `appsettings.json` behält danach die
eingerichteten Werte, und eine Datei, die es im neuen Release nicht mehr
gibt, ist verschwunden.
+102 -3
View File
@@ -3,7 +3,7 @@
Überwachung von Anwendungen, Diensten und Servern über Heartbeats. Überwachung von Anwendungen, Diensten und Servern über Heartbeats.
> **Stand:** Version 2.0 — vollständig überarbeitet. Wer eine ältere Integration > **Stand:** Version 2.0 — vollständig überarbeitet. Wer eine ältere Integration
> betreibt, findet die Änderungen in Abschnitt 8. > betreibt, findet die Änderungen in Abschnitt 9.
--- ---
@@ -26,10 +26,11 @@ ausgefallen.
> Dienst bliebe dauerhaft grün. > Dienst bliebe dauerhaft grün.
> >
> ```bash > ```bash
> * * * * * curl -fsS -H "Authorization: Bearer <SHARED_KEY>" https://dc.mhdf.de/api/watchdog/v1/evaluate > /dev/null > * * * * * /usr/bin/php /pfad/zum/deploymentcenter/cli/tick.php --quiet
> ``` > ```
> >
> Fehlt der Job, zeigt das WebUI oben einen Warnhinweis. > Fehlt der Job, zeigt das WebUI oben einen Warnhinweis. Details und die
> HTTP-Variante: **[§11](#11-der-evaluator-tick)**.
--- ---
@@ -72,6 +73,7 @@ Content-Type: application/json
"message": "Verarbeite Warteschlange", "message": "Verarbeite Warteschlange",
"group": "Applications", "group": "Applications",
"os": ".NET 8 Service", "os": ".NET 8 Service",
"version": "1.4.3",
"checks": { "db": { "ok": true }, "checks": { "db": { "ok": true },
"market_feed": { "ok": false, "message": "Letzter Tick vor 14 min" } }, "market_feed": { "ok": false, "message": "Letzter Tick vor 14 min" } },
@@ -89,9 +91,31 @@ Content-Type: application/json
| `message` | nein | Kurztext, erscheint im Dashboard | | `message` | nein | Kurztext, erscheint im Dashboard |
| `group` | nein | Gruppierung im Dashboard | | `group` | nein | Gruppierung im Dashboard |
| `os` | nein | Plattform, steuert auch die Icon-Erkennung | | `os` | nein | Plattform, steuert auch die Icon-Erkennung |
| `version` | nein | Version der laufenden Anwendung (Alias: `app_version`) |
| `checks` | nein | Selbst ermittelter Gesundheitszustand, siehe 4. | | `checks` | nein | Selbst ermittelter Gesundheitszustand, siehe 4. |
| `metrics` | nein | Numerische Werte, siehe 5. | | `metrics` | nein | Numerische Werte, siehe 5. |
### Welche Version läuft dort?
`version` erscheint als eigene Spalte in der Monitorliste und kommt im
Heartbeat-Antwortobjekt als `app_version` zurück. Damit ist auf einen Blick
sichtbar, ob ein Ausfall zeitlich zu einem Rollout passt — „Monitor X ist seit
dem Rollout von 1.4.3 unten".
Das Feld ist optional und **überschreibt einen früheren Wert nicht mit `null`**:
ein Agent, der es nicht mitschickt, löscht die zuletzt gemeldete Version nicht.
Bestehende Agenten laufen also unverändert weiter.
```csharp
// Am einfachsten aus der vom Build erzeugten Klasse, siehe
// UPDATESERVICE_INTEGRATION_GUIDE §2B
version = BuildInfo.Version
```
> Der Fehler-Stream führt `build`, der Bugtracker `build_version`, die
> Aktivierungsliste des Lizenzmoduls `app_version` — nur der Watchdog konnte
> bis 2.1 nicht sagen, welche Version tatsächlich läuft.
### Zulässige Werte für `status` ### Zulässige Werte für `status`
| Wert | Zustand | Wirkung | | Wert | Zustand | Wirkung |
@@ -217,6 +241,7 @@ public sealed class WatchdogReporter
interval = 60, interval = 60,
message, message,
os = Environment.OSVersion.ToString(), os = Environment.OSVersion.ToString(),
version = BuildInfo.Version, // erscheint in der Monitorliste
checks, checks,
metrics metrics
}; };
@@ -372,6 +397,8 @@ Zulässige `kind`-Werte: `started`, `stopped_graceful`, `crash_suspected`,
| Geplantes Beenden | nicht möglich | `status: "stopped"` bzw. `"maintenance"` | | Geplantes Beenden | nicht möglich | `status: "stopped"` bzw. `"maintenance"` |
| Alarme bei Ausfall eines Hosts | eine Meldung je Kind | Kinder werden unterdrückt | | Alarme bei Ausfall eines Hosts | eine Meldung je Kind | Kinder werden unterdrückt |
| Antwortformat | uneinheitlich | `{"status":"success",…}` bzw. `{"status":"error","error":{"code":…}}` | | Antwortformat | uneinheitlich | `{"status":"success",…}` bzw. `{"status":"error","error":{"code":…}}` |
| Laufende Version | nicht übermittelbar | Feld `version` (seit 2.1) |
| OpenAPI-Beschreibung | `/event`, `/events`, `/status` fehlten, ebenso `group` und `type` am Ping | vollständig |
Bestehende Agenten mit `wd_live_`-Token und einfachem `ok`-Ping laufen Bestehende Agenten mit `wd_live_`-Token und einfachem `ok`-Ping laufen
unverändert weiter — die neuen Felder sind alle optional. unverändert weiter — die neuen Felder sind alle optional.
@@ -389,3 +416,75 @@ unverändert weiter — die neuen Felder sind alle optional.
4. **`checks` nutzen.** Ein Heartbeat sagt nur, dass ein Thread läuft. 4. **`checks` nutzen.** Ein Heartbeat sagt nur, dass ein Thread läuft.
5. **Hierarchie pflegen**, wenn Dienste auf gemeinsamen Hosts laufen — sonst 5. **Hierarchie pflegen**, wenn Dienste auf gemeinsamen Hosts laufen — sonst
bringt ein Hostausfall eine Alarmlawine. bringt ein Hostausfall eine Alarmlawine.
6. **`version` mitschicken.** Ohne sie lässt sich ein Ausfall nicht mit einem
Rollout in Verbindung bringen.
---
## 11. Der Evaluator-Tick
Der Evaluator ist die Komponente, die Monitore anhand ihres erwarteten
Intervalls herabstuft. Er läuft **nicht** von selbst — ohne einen Cron-Eintrag
sind die Zustände im Dashboard wertlos.
Er erledigt in einem Durchlauf:
- Monitore anhand `expected_interval_sec` auf `warning` bzw. `down` stufen
- Zustandswechsel im Ereignisprotokoll festhalten und Webhooks auslösen
- Alarme für Kinder eines ausgefallenen Hosts unterdrücken
- abgelaufene Bugtracker-Leases freigeben
- stündlich den Metrik-Verlauf abräumen
### Interner Aufruf (empfohlen)
```bash
* * * * * /usr/bin/php /pfad/zum/deploymentcenter/cli/tick.php --quiet
```
Entspricht dem früheren `watchdog/cli/tick.php`. Kein Schlüssel im Crontab,
keine Abhängigkeit von Webserver, TLS oder DNS — und damit auch dann
lauffähig, wenn der Webserver gerade das Problem ist.
| Option | Wirkung |
|---|---|
| `-q`, `--quiet` | Ausgabe nur bei Zustandswechseln und Fehlern (für Cron) |
| `-j`, `--json` | Ergebnis maschinenlesbar |
| `--no-lock` | Sperre gegen überlappende Läufe übergehen (nur zur Fehlersuche) |
| `-h`, `--help` | Hilfe |
| Rückgabewert | Bedeutung |
|---|---|
| `0` | Lauf erfolgreich |
| `1` | Fehler — Meldung auf `stderr`, Einzelheiten im Log unter `var/log/` |
| `2` | Übersprungen, es lief bereits ein Tick |
Das Skript sperrt sich über `var/watchdog-tick.lock` selbst. Braucht ein Lauf
länger als eine Minute, überspringt der nächste Cron-Aufruf — sonst würden
zwei Evaluatoren dieselben Zustandswechsel doppelt melden.
> Der Aufruf ist auf `PHP_SAPI === 'cli'` beschränkt und `cli/` ist zusätzlich
> per `.htaccess` gesperrt. Über den Webserver ist das Skript also nicht
> erreichbar — sonst ließe sich ein Evaluationslauf ohne jede Authentifizierung
> auslösen, während der HTTP-Endpunkt dafür bewusst den Shared Key verlangt.
### Über die Schnittstelle
Sinnvoll, wenn der Cron auf einer anderen Maschine läuft:
```bash
* * * * * curl -fsS -H "Authorization: Bearer <SHARED_KEY>" https://dc.mhdf.de/api/watchdog/v1/evaluate > /dev/null
```
Beide Wege rufen denselben Code auf und schreiben denselben Lauf-Vermerk in
`watchdog_cron_jobs`. **Nur einen von beiden einrichten** — zwei parallele
Zeitpläne bringen keinen Gewinn, nur die Gefahr überlappender Läufe.
### Prüfen, ob er läuft
```bash
php cli/tick.php --json
curl https://dc.mhdf.de/api/health -H "Authorization: Bearer <SHARED_KEY>"
```
`/api/health` meldet unter `checks.evaluator` den letzten Lauf. Im WebUI zeigt
das Watchdog-Modul denselben Zustand als Abzeichen **AKTIV** / **INAKTIV**.
+399 -4
View File
@@ -15,6 +15,7 @@ require_once __DIR__ . '/../../src/bootstrap.php';
use Deploymentcenter\Core\Config; use Deploymentcenter\Core\Config;
use Deploymentcenter\Core\Http; use Deploymentcenter\Core\Http;
use Deploymentcenter\Modules\Bugtracker\BugRepo; use Deploymentcenter\Modules\Bugtracker\BugRepo;
use Deploymentcenter\Modules\Watchdog\EventLog;
use Deploymentcenter\Core\TokenManager; use Deploymentcenter\Core\TokenManager;
Http::beginJson(['GET', 'OPTIONS'], true); Http::beginJson(['GET', 'OPTIONS'], true);
@@ -32,7 +33,11 @@ $spec = [
'title' => 'Deploymentcenter API', 'title' => 'Deploymentcenter API',
'version' => (string)Config::get('app.version', '2.0.0'), 'version' => (string)Config::get('app.version', '2.0.0'),
'description' => 'description' =>
"Zentrale Schnittstelle fuer Bugtracker, UpdateService, Watchdog und Token-Provisionierung.\n\n" "Zentrale Schnittstelle fuer Bugtracker, UpdateService, Watchdog, Lizenzen und Token-Provisionierung.\n\n"
. "**Zwei Antwortformate.** Der Regelfall ist der Umschlag `{\"status\":\"success\",...}` bzw.\n"
. "`{\"status\":\"error\",\"error\":{\"code\":...}}`. Die Endpunkte unter `/api/license/v1/`\n"
. "antworten davon abweichend ohne Umschlag - dort ist `status` der Lizenzzustand.\n"
. "Siehe das Schema `LicenseResult`.\n\n"
. "Authentifizierung ueber `Authorization: Bearer <token>` oder `X-Agent-Token`.\n" . "Authentifizierung ueber `Authorization: Bearer <token>` oder `X-Agent-Token`.\n"
. "Tokens werden im WebUI erzeugt (Master-Token) und koennen sich per\n" . "Tokens werden im WebUI erzeugt (Master-Token) und koennen sich per\n"
. "`/api/tokens/v1/provision` selbst in Sub-Tokens aufteilen.\n\n" . "`/api/tokens/v1/provision` selbst in Sub-Tokens aufteilen.\n\n"
@@ -115,6 +120,94 @@ $spec = [
'context' => ['type' => 'object', 'description' => 'Beliebiger strukturierter Zusatzkontext'], 'context' => ['type' => 'object', 'description' => 'Beliebiger strukturierter Zusatzkontext'],
], ],
], ],
'Release' => [
'type' => 'object',
'description' =>
'Release-Zeile aus updateservice_releases. Die Feldnamen sind die der Datenbank '
. '(snake_case) und unterscheiden sich von der statischen latest.json, die der '
. 'Packager in camelCase erzeugt.',
'properties' => [
'id' => ['type' => 'integer'],
'product_slug' => ['type' => 'string'],
'version' => ['type' => 'string', 'example' => '1.4.3'],
'channel' => ['type' => 'string', 'default' => 'prod'],
'platform' => [
'type' => 'string',
'default' => 'any',
'example' => 'win-x64',
'description' =>
'Laufzeitkennung (.NET-RID) oder "any" fuer plattformunabhaengige Releases. '
. 'Ein Client, der "platform" mitschickt, bekommt nur Pakete seiner Plattform '
. 'oder "any"; ohne Angabe ausschliesslich "any".',
],
'release_notes' => ['type' => 'string', 'nullable' => true, 'description' => 'Entspricht "changelog" in der latest.json.'],
'download_url' => ['type' => 'string', 'description' => 'Entspricht "packageUrl" in der latest.json.'],
'sha256_hash' => ['type' => 'string', 'nullable' => true, 'description' => 'Entspricht "sha256" in der latest.json.'],
'git_commit' => ['type' => 'string', 'nullable' => true],
'size_bytes' => ['type' => 'integer'],
'manifest_signature' => [
'type' => 'string',
'nullable' => true,
'description' =>
'RSA-SHA256 ueber die kanonische Release-Zeile, base64. Pruefbar mit dem '
. 'Schluessel aus /api/updateservice/v1/pubkey. Null, wenn auf dem Server '
. 'kein Signierschluessel hinterlegt ist.',
],
'is_critical' => ['type' => 'boolean', 'description' => 'Kommt als TINYINT(1), also 0 oder 1.'],
'created_at' => ['type' => 'string', 'format' => 'date-time'],
],
],
'Monitor' => [
'type' => 'object',
'properties' => [
'source' => ['type' => 'string'],
'instance' => ['type' => 'string'],
'type' => ['type' => 'string', 'enum' => ['heartbeat', 'host', 'hypervisor_node', 'guest']],
'state' => ['type' => 'string', 'enum' => ['up', 'warning', 'down', 'error', 'stopped', 'maintenance']],
'expected_interval_sec' => ['type' => 'integer'],
'last_seen_utc' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
'last_status' => ['type' => 'string', 'enum' => ['ok', 'warning', 'error'], 'nullable' => true],
'last_message' => ['type' => 'string', 'nullable' => true],
'app_version' => ['type' => 'string', 'nullable' => true, 'description' => 'Vom Heartbeat gemeldete Anwendungsversion.'],
'group_key' => ['type' => 'string', 'nullable' => true],
'parent_source' => ['type' => 'string', 'nullable' => true],
'os' => ['type' => 'string', 'nullable' => true],
'metrics_json' => ['type' => 'object', 'nullable' => true],
'failing_checks' => ['type' => 'string', 'nullable' => true],
'is_muted' => ['type' => 'boolean'],
],
],
'LicenseResult' => [
'type' => 'object',
'description' =>
'ACHTUNG: ohne den status/error-Umschlag der uebrigen API. Das Feld "status" '
. 'traegt hier den Lizenzzustand, nicht "success".',
'properties' => [
'type' => ['type' => 'string', 'enum' => ['validation_result']],
'status' => [
'type' => 'string',
'enum' => ['valid', 'not_found', 'revoked', 'suspended', 'expired', 'activation_limit'],
'description' =>
'valid = gueltig und aktiviert; not_found = Projekt oder Schluessel unbekannt; '
. 'revoked = widerrufen oder diese Hardware gesperrt; suspended = voruebergehend '
. 'ausgesetzt; expired = Ablaufdatum ueberschritten; activation_limit = maximale '
. 'Anzahl Aktivierungen erreicht.',
],
'issued_at' => ['type' => 'integer', 'description' => 'Unix-Zeit der Ausstellung.'],
'expires_at' => ['type' => 'integer', 'nullable' => true, 'description' => 'Unix-Zeit des Lizenzablaufs, null bei unbefristet.'],
'cache_ttl_hours' => [
'type' => 'integer',
'example' => 168,
'description' => 'Laenge der Offline-Gnadenfrist in Stunden. Auswerten - nicht durch expires_at ersetzen.',
],
'product' => ['type' => 'string'],
'license_key' => ['type' => 'string'],
'hardware_id' => ['type' => 'string'],
'nonce' => ['type' => 'string', 'description' => 'Der uebergebene Wert, unveraendert.'],
'endpoints' => ['type' => 'object'],
'message' => ['type' => 'string', 'nullable' => true],
],
],
], ],
], ],
'security' => [['bearerAuth' => []], ['agentToken' => []]], 'security' => [['bearerAuth' => []], ['agentToken' => []]],
@@ -261,13 +354,125 @@ $spec = [
'get' => [ 'get' => [
'tags' => ['UpdateService'], 'tags' => ['UpdateService'],
'summary' => 'Auf Update pruefen', 'summary' => 'Auf Update pruefen',
'description' =>
"Ohne Token erreichbar.\n\n"
. "**Achtung, zwei verschiedene Formate:** diese Antwort ist in snake_case und "
. "reicht unter `latest_release` die Datenbankzeile durch (`download_url`, "
. "`sha256_hash`, `release_notes`, `size_bytes`). Die statische "
. "`/releases/{product}/{channel}/latest.json`, die der Packager erzeugt, "
. "verwendet dagegen camelCase (`packageUrl`, `sha256`, `changelog`, `isCritical`). "
. "Wer beide Quellen unterstuetzt, braucht zwei Modelle.\n\n"
. "`is_critical` steht auf oberster Ebene, nicht im Release-Objekt.\n\n"
. "**Plattform:** ohne `platform` werden ausschliesslich Releases mit `platform=any` "
. "beruecksichtigt. Das ist Absicht - ein Client, der seine Laufzeitkennung nicht "
. "nennt, soll lieber kein Update bekommen als das Paket einer fremden Plattform.",
'security' => [], 'security' => [],
'parameters' => [ 'parameters' => [
['name' => 'product', 'in' => 'query', 'required' => true, 'schema' => ['type' => 'string']], ['name' => 'product', 'in' => 'query', 'required' => true, 'schema' => ['type' => 'string']],
['name' => 'version', 'in' => 'query', 'required' => true, 'schema' => ['type' => 'string']], ['name' => 'version', 'in' => 'query', 'required' => true, 'schema' => ['type' => 'string']],
['name' => 'channel', 'in' => 'query', 'schema' => ['type' => 'string', 'default' => 'prod']], ['name' => 'channel', 'in' => 'query', 'schema' => ['type' => 'string', 'default' => 'prod']],
['name' => 'platform', 'in' => 'query', 'schema' => ['type' => 'string', 'example' => 'win-x64'],
'description' => '.NET-RID des anfragenden Systems. Alias: rid.'],
],
'responses' => [
'200' => [
'description' => 'Vergleich nach semantischer Versionsordnung',
'content' => ['application/json' => ['schema' => [
'type' => 'object',
'properties' => [
'update_available' => ['type' => 'boolean'],
'current_version' => ['type' => 'string'],
'latest_version' => ['type' => 'string'],
'is_critical' => ['type' => 'boolean', 'description' => 'Nur vorhanden, wenn ein Update ansteht.'],
'latest_release' => ['$ref' => '#/components/schemas/Release'],
'message' => ['type' => 'string'],
],
]]],
],
],
],
],
'/api/updateservice/v1/latest' => [
'get' => [
'tags' => ['UpdateService'],
'summary' => 'Hoechstes Release eines Kanals',
'description' => 'Ohne Token erreichbar. Unabhaengig von der Version des Aufrufers.',
'security' => [],
'parameters' => [
['name' => 'product', 'in' => 'query', 'required' => true, 'schema' => ['type' => 'string']],
['name' => 'channel', 'in' => 'query', 'schema' => ['type' => 'string', 'default' => 'prod']],
['name' => 'platform', 'in' => 'query', 'schema' => ['type' => 'string', 'example' => 'win-x64']],
],
'responses' => [
'200' => [
'description' => 'Release',
'content' => ['application/json' => ['schema' => [
'type' => 'object',
'properties' => ['release' => ['$ref' => '#/components/schemas/Release']],
]]],
],
'404' => $errorResponse,
],
],
],
'/api/updateservice/v1/releases' => [
'get' => [
'tags' => ['UpdateService'],
'summary' => 'Releases auflisten',
'description' => 'Ohne Token erreichbar. Ohne "product" alle Produkte.',
'security' => [],
'parameters' => [
['name' => 'product', 'in' => 'query', 'schema' => ['type' => 'string']],
['name' => 'channel', 'in' => 'query', 'schema' => ['type' => 'string']],
['name' => 'platform', 'in' => 'query', 'schema' => ['type' => 'string'],
'description' => 'Filtert exakt auf diese Plattform - dieser Endpunkt listet den Bestand, er waehlt kein Paket aus.'],
['name' => 'limit', 'in' => 'query', 'schema' => ['type' => 'integer', 'default' => 200]],
],
'responses' => [
'200' => [
'description' => 'Liste',
'content' => ['application/json' => ['schema' => [
'type' => 'object',
'properties' => [
'count' => ['type' => 'integer'],
'releases' => ['type' => 'array', 'items' => ['$ref' => '#/components/schemas/Release']],
],
]]],
],
],
],
],
'/api/updateservice/v1/pubkey' => [
'get' => [
'tags' => ['UpdateService'],
'summary' => 'Oeffentlichen Schluessel fuer Release-Signaturen holen',
'description' =>
"Ohne Token erreichbar - der Schluessel ist oeffentlich, und ein Agent braucht ihn, "
. "bevor er irgendetwas anderes pruefen kann.\n\n"
. "Signiert wird die kanonische Release-Zeile (`canonical-line-v1`): die Felder "
. "`dc-release-v1`, product_slug, version, channel, platform, sha256_hash (klein "
. "geschrieben), download_url und size_bytes, verbunden mit \\n. Geprueft wird "
. "`manifest_signature` (base64) mit RSA-SHA256.\n\n"
. "404 bedeutet, dass auf diesem Server kein Signierschluessel hinterlegt ist.",
'security' => [],
'responses' => [
'200' => [
'description' => 'Oeffentlicher Schluessel im PEM-Format',
'content' => ['application/json' => ['schema' => [
'type' => 'object',
'properties' => [
'algorithm' => ['type' => 'string', 'example' => 'RSA-SHA256'],
'format' => ['type' => 'string', 'example' => 'canonical-line-v1'],
'public_key' => ['type' => 'string', 'description' => 'PEM'],
'fingerprint' => ['type' => 'string', 'description' => 'SHA256 ueber das PEM, zum Wiedererkennen.'],
],
]]],
],
'404' => $errorResponse,
], ],
'responses' => ['200' => ['description' => 'Vergleich nach semantischer Versionsordnung']],
], ],
], ],
@@ -275,7 +480,11 @@ $spec = [
'post' => [ 'post' => [
'tags' => ['UpdateService'], 'tags' => ['UpdateService'],
'summary' => 'Release veroeffentlichen', 'summary' => 'Release veroeffentlichen',
'description' => 'Scope updateservice:publish. Schliesst automatisch alle Bugtracker-Items, deren resolved_in_build dieser Version entspricht.', 'description' =>
"Scope updateservice:publish. Schliesst automatisch alle Bugtracker-Items, deren "
. "resolved_in_build dieser Version entspricht.\n\n"
. "Die Signatur wird serverseitig gebildet und ist nicht Teil des Aufrufs - der "
. "Packager laeuft auf Entwicklerrechnern und darf den Signierschluessel nicht besitzen.",
'requestBody' => [ 'requestBody' => [
'required' => true, 'required' => true,
'content' => ['application/json' => ['schema' => [ 'content' => ['application/json' => ['schema' => [
@@ -285,12 +494,20 @@ $spec = [
'product_slug' => ['type' => 'string'], 'product_slug' => ['type' => 'string'],
'version' => ['type' => 'string', 'example' => '1.4.3'], 'version' => ['type' => 'string', 'example' => '1.4.3'],
'channel' => ['type' => 'string', 'default' => 'prod'], 'channel' => ['type' => 'string', 'default' => 'prod'],
'platform' => ['type' => 'string', 'default' => 'any', 'example' => 'win-x64'],
'download_url' => ['type' => 'string'], 'download_url' => ['type' => 'string'],
'sha256_hash' => ['type' => 'string', 'pattern' => '^[0-9a-fA-F]{64}$'], 'sha256_hash' => ['type' => 'string', 'pattern' => '^[0-9a-fA-F]{64}$'],
'git_commit' => ['type' => 'string'], 'git_commit' => ['type' => 'string'],
'size_bytes' => ['type' => 'integer'], 'size_bytes' => ['type' => 'integer'],
'release_notes' => ['type' => 'string'], 'release_notes' => ['type' => 'string'],
'is_critical' => ['type' => 'boolean'], 'is_critical' => ['type' => 'boolean'],
'manifest_json' => [
'type' => 'object',
'description' =>
'Dateimanifest des Pakets (Pfade, Groessen, SHA256, preserve-Liste). '
. 'Damit kann die API als vollwertiger Rueckfall dienen, wenn die '
. 'statische latest.json fehlt. Maximal 4 MB.',
],
], ],
]]], ]]],
], ],
@@ -309,8 +526,23 @@ $spec = [
'type' => 'object', 'type' => 'object',
'required' => ['source'], 'required' => ['source'],
'properties' => [ 'properties' => [
'source' => ['type' => 'string'], 'source' => ['type' => 'string', 'description' => 'Eindeutiger Name des Dienstes oder Hosts.'],
'instance' => ['type' => 'string', 'default' => 'default'], 'instance' => ['type' => 'string', 'default' => 'default'],
'type' => [
'type' => 'string',
'enum' => ['heartbeat', 'host', 'hypervisor_node', 'guest'],
'default' => 'heartbeat',
'description' => 'Bestimmt die Einordnung in der Hierarchie.',
],
'group' => [
'type' => 'string',
'description' => 'Gruppierung im Dashboard, z. B. "Applications". Alias: group_key.',
],
'version' => [
'type' => 'string',
'description' => 'Version der laufenden Anwendung, erscheint in der Monitorliste. Alias: app_version. Optional - bestehende Agenten senden es nicht.',
'example' => '1.4.3',
],
'status' => [ 'status' => [
'type' => 'string', 'type' => 'string',
'enum' => ['ok', 'warning', 'error', 'stopped', 'maintenance'], 'enum' => ['ok', 'warning', 'error', 'stopped', 'maintenance'],
@@ -331,6 +563,73 @@ $spec = [
], ],
], ],
'/api/watchdog/v1/event' => [
'post' => [
'tags' => ['Watchdog'],
'summary' => 'Einzelnes Ereignis protokollieren',
'description' =>
"Scope watchdog:ping, alternativ ein Agent-Token. Fuer einmalige Vorkommnisse "
. "statt zyklischer Meldungen - etwa ein angekuendigtes Herunterfahren oder eine "
. "wiederhergestellte Verbindung.\n\n"
. "Zustandswechsel durch Heartbeats werden ohnehin selbst protokolliert; dieser "
. "Endpunkt ist fuer alles, was dazwischen passiert.",
'requestBody' => [
'required' => true,
'content' => ['application/json' => ['schema' => [
'type' => 'object',
'required' => ['source'],
'properties' => [
'source' => ['type' => 'string'],
'instance' => ['type' => 'string', 'default' => 'default'],
'kind' => ['type' => 'string', 'enum' => EventLog::KINDS, 'default' => 'started'],
'severity' => ['type' => 'string', 'enum' => EventLog::SEVERITIES, 'default' => 'info'],
'message' => ['type' => 'string'],
'from_state' => ['type' => 'string'],
'to_state' => ['type' => 'string'],
'meta' => ['type' => 'object', 'description' => 'Beliebiger strukturierter Zusatzkontext.'],
],
]]],
],
'responses' => ['201' => ['description' => 'Protokolliert'], '401' => $errorResponse],
],
],
'/api/watchdog/v1/status' => [
'get' => [
'tags' => ['Watchdog'],
'summary' => 'Alle Monitore abrufen',
'description' => 'Scope watchdog:read. Liefert jede Zeile aus watchdog_monitors, einschliesslich app_version und group_key.',
'responses' => [
'200' => [
'description' => 'Monitorliste',
'content' => ['application/json' => ['schema' => [
'type' => 'object',
'properties' => [
'count' => ['type' => 'integer'],
'monitors' => ['type' => 'array', 'items' => ['$ref' => '#/components/schemas/Monitor']],
],
]]],
],
'401' => $errorResponse,
],
],
],
'/api/watchdog/v1/events' => [
'get' => [
'tags' => ['Watchdog'],
'summary' => 'Ereignisprotokoll lesen',
'description' => 'Scope watchdog:read.',
'parameters' => [
['name' => 'limit', 'in' => 'query', 'schema' => ['type' => 'integer', 'default' => 50]],
['name' => 'source', 'in' => 'query', 'schema' => ['type' => 'string']],
['name' => 'instance', 'in' => 'query', 'schema' => ['type' => 'string']],
['name' => 'severity', 'in' => 'query', 'schema' => ['type' => 'string', 'enum' => EventLog::SEVERITIES]],
],
'responses' => ['200' => ['description' => 'Ereignisliste'], '401' => $errorResponse],
],
],
'/api/watchdog/v1/evaluate' => [ '/api/watchdog/v1/evaluate' => [
'get' => [ 'get' => [
'tags' => ['Watchdog'], 'tags' => ['Watchdog'],
@@ -340,6 +639,102 @@ $spec = [
], ],
], ],
// ------------------------------------------------------------------
// Lizenzmodul
//
// Fehlte hier bisher vollstaendig, obwohl README und Prompt-Vorlage
// Agenten ausdruecklich auf diese Beschreibung verweisen. Wer sich
// daran orientierte, konnte das Lizenzsystem nicht anbinden - und
// kannte insbesondere dessen abweichendes Antwortformat nicht.
// ------------------------------------------------------------------
'/api/license/v1/validate' => [
'post' => [
'tags' => ['Lizenz'],
'summary' => 'Lizenz und Hardware pruefen',
'description' =>
"Bewusst ohne Token erreichbar: ausgelieferte Anwendungen pruefen hier ihre Lizenz.\n\n"
. "**Abweichendes Antwortformat.** Diese Antwort traegt NICHT den "
. "`status`/`error`-Umschlag der uebrigen API. `status` auf oberster Ebene ist der "
. "Lizenzzustand (`valid`, `not_found`, `revoked`, `suspended`, `expired`, "
. "`activation_limit`), nicht `success`. Das ist ein bereits ausgerollter Vertrag - "
. "ein Umschlag mit `status: \"success\"` wuerde von jedem bestehenden Client als "
. "\"nicht valid\" gelesen.\n\n"
. "**Nur ein gelesenes Urteil ist ein Urteil.** Ein HTTP-Fehler (429 durch "
. "Drosselung, 5xx) ist keine Aussage ueber die Lizenz. Clients muessen in diesem "
. "Fall ihren lokalen Cache verwenden und es spaeter erneut versuchen, statt die "
. "Anwendung zu blockieren.\n\n"
. "`cache_ttl_hours` bestimmt, wie lange ohne Serverkontakt weitergearbeitet "
. "werden darf. Der Wert stammt aus `dc_projects.default_cache_ttl_hours` und ist "
. "auszuwerten - nicht das Ablaufdatum der Lizenz dafuer heranzuziehen.\n\n"
. "Begrenzt auf 120 Anfragen pro Minute und IP.",
'security' => [],
'requestBody' => [
'required' => true,
'content' => ['application/json' => ['schema' => [
'type' => 'object',
'required' => ['product', 'license_key', 'hardware_id'],
'properties' => [
'product' => ['type' => 'string', 'description' => 'Slug aus dc_projects.'],
'license_key' => ['type' => 'string', 'example' => 'XXXXX-XXXXX-XXXXX-XXXXX-XXXXX'],
'hardware_id' => ['type' => 'string', 'example' => '2:win:a765bd47...', 'description' => 'Hardware-ID v2, Format 2:<plattform>:<64 Hex>.'],
'legacy_hardware_id' => ['type' => 'string', 'description' => 'Hardware-ID v1. Mitschicken, damit eine bestehende Aktivierung ohne zusaetzlichen Platz auf v2 umzieht.'],
'hwid_version' => ['type' => 'integer', 'default' => 2],
'hwid_source' => ['type' => 'string', 'example' => 'machine-guid'],
'platform' => ['type' => 'string', 'example' => 'win'],
'hostname' => ['type' => 'string'],
'app_version' => ['type' => 'string', 'example' => '1.4.3', 'description' => 'Version der Anwendung; erscheint in der Aktivierungsliste.'],
'nonce' => ['type' => 'string', 'description' => 'Zufallswert, wird unveraendert zurueckgegeben.'],
],
]]],
],
'responses' => [
'200' => [
'description' => 'Lizenzurteil (auch bei ungueltiger Lizenz)',
'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/LicenseResult']]],
],
'429' => $errorResponse,
],
],
],
'/api/license/v1/deactivate' => [
'post' => [
'tags' => ['Lizenz'],
'summary' => 'Aktivierung freigeben',
'description' =>
"Scope license:deactivate, alternativ der `shared_key` aus config/config.php als "
. "`Authorization: Bearer`. Geschuetzt, weil sonst jeder fremde Installationen "
. "abmelden koennte.\n\n"
. "Der Schluessel gehoert auf den Administrationsrechner, nicht in die "
. "ausgelieferte Anwendung. Fuer Einzelfaelle genuegt der Knopf **Freigeben** in "
. "der Hardware-Liste des WebUI.",
'requestBody' => [
'required' => true,
'content' => ['application/json' => ['schema' => [
'type' => 'object',
'required' => ['product', 'license_key', 'hardware_id'],
'properties' => [
'product' => ['type' => 'string'],
'license_key' => ['type' => 'string'],
'hardware_id' => ['type' => 'string'],
'nonce' => ['type' => 'string'],
],
]]],
],
'responses' => ['200' => ['description' => 'Freigegeben'], '401' => $errorResponse],
],
],
'/api/license/v1/status' => [
'get' => [
'tags' => ['Lizenz'],
'summary' => 'Verfuegbarkeitspruefung des Moduls',
'security' => [],
'responses' => ['200' => ['description' => 'Modulzustand']],
],
],
'/api/tokens/v1/provision' => [ '/api/tokens/v1/provision' => [
'post' => [ 'post' => [
'tags' => ['Tokens'], 'tags' => ['Tokens'],
+99 -16
View File
@@ -3,9 +3,10 @@
/** /**
* UpdateService API * UpdateService API
* *
* GET /api/updateservice/v1/check?product=myapp&version=1.0.0&channel=prod * GET /api/updateservice/v1/check?product=myapp&version=1.0.0&channel=prod&platform=win-x64
* GET /api/updateservice/v1/latest?product=myapp&channel=prod * GET /api/updateservice/v1/latest?product=myapp&channel=prod&platform=win-x64
* GET /api/updateservice/v1/releases?product=myapp * GET /api/updateservice/v1/releases?product=myapp
* GET /api/updateservice/v1/pubkey
* POST /api/updateservice/v1/publish (Scope updateservice:publish) * POST /api/updateservice/v1/publish (Scope updateservice:publish)
* *
* SICHERHEITSAENDERUNG: Das Veroeffentlichen eines Releases war vollstaendig * SICHERHEITSAENDERUNG: Das Veroeffentlichen eines Releases war vollstaendig
@@ -13,6 +14,11 @@
* Releases ueberschreiben und damit allen Clients ein beliebiges Paket * Releases ueberschreiben und damit allen Clients ein beliebiges Paket
* unterschieben. Publish verlangt jetzt ein Token mit "updateservice:publish". * unterschieben. Publish verlangt jetzt ein Token mit "updateservice:publish".
* *
* PLATTFORM: Releases tragen seit Migration 009 eine Laufzeitkennung. Ein
* Client, der "platform" mitschickt, bekommt nur Pakete seiner Plattform oder
* plattformunabhaengige ('any'). Ein Client ohne Angabe sieht ausschliesslich
* 'any' - lieber kein Update als das Paket einer fremden Plattform.
*
* Die Lese-Endpunkte bleiben ohne Token erreichbar, damit bereits ausgerollte * Die Lese-Endpunkte bleiben ohne Token erreichbar, damit bereits ausgerollte
* Client-Anwendungen weiter nach Updates suchen koennen. Sie liefern nur * Client-Anwendungen weiter nach Updates suchen koennen. Sie liefern nur
* Release-Metadaten, die ueber die Download-URL ohnehin oeffentlich sind. * Release-Metadaten, die ueber die Download-URL ohnehin oeffentlich sind.
@@ -26,6 +32,7 @@ use Deploymentcenter\Core\ApiAuth;
use Deploymentcenter\Core\Db; use Deploymentcenter\Core\Db;
use Deploymentcenter\Core\Http; use Deploymentcenter\Core\Http;
use Deploymentcenter\Modules\License\RateLimiter; use Deploymentcenter\Modules\License\RateLimiter;
use Deploymentcenter\Modules\UpdateService\ReleaseSigner;
use Deploymentcenter\Modules\UpdateService\UpdateManager; use Deploymentcenter\Modules\UpdateService\UpdateManager;
use Deploymentcenter\Modules\UpdateService\Version; use Deploymentcenter\Modules\UpdateService\Version;
@@ -49,17 +56,19 @@ switch ($action) {
Http::fail(400, 'missing_product', 'Der Parameter "product" wird benoetigt.'); Http::fail(400, 'missing_product', 'Der Parameter "product" wird benoetigt.');
} }
$current = Http::str('version') ?? Http::str('current_version') ?? '0.0.0'; $current = Http::str('version') ?? Http::str('current_version') ?? '0.0.0';
$channel = Http::str('channel') ?? 'prod'; $channel = Http::str('channel') ?? 'prod';
$platform = Http::str('platform') ?? Http::str('rid');
$latest = $manager->checkUpdate($product, $current, $channel); $latest = $manager->checkUpdate($product, $current, $channel, $platform);
if ($latest === null) { if ($latest === null) {
$installed = $manager->latestRelease($product, $channel); $installed = $manager->latestRelease($product, $channel, $platform);
Http::ok([ Http::ok([
'update_available' => false, 'update_available' => false,
'current_version' => $current, 'current_version' => $current,
'latest_version' => $installed !== null ? $installed['version'] : $current, 'latest_version' => $installed !== null ? $installed['version'] : $current,
'platform' => UpdateManager::normalizePlatform($platform),
'message' => 'Anwendung ist aktuell.', 'message' => 'Anwendung ist aktuell.',
]); ]);
} }
@@ -68,6 +77,7 @@ switch ($action) {
'update_available' => true, 'update_available' => true,
'current_version' => $current, 'current_version' => $current,
'latest_version' => $latest['version'], 'latest_version' => $latest['version'],
'platform' => $latest['platform'] ?? UpdateManager::PLATFORM_ANY,
'is_critical' => (bool)$latest['is_critical'], 'is_critical' => (bool)$latest['is_critical'],
'latest_release' => $latest, 'latest_release' => $latest,
]); ]);
@@ -78,7 +88,11 @@ switch ($action) {
Http::fail(400, 'missing_product', 'Der Parameter "product" wird benoetigt.'); Http::fail(400, 'missing_product', 'Der Parameter "product" wird benoetigt.');
} }
$release = $manager->latestRelease($product, Http::str('channel') ?? 'prod'); $release = $manager->latestRelease(
$product,
Http::str('channel') ?? 'prod',
Http::str('platform') ?? Http::str('rid')
);
if ($release === null) { if ($release === null) {
Http::fail(404, 'no_release', sprintf('Fuer "%s" ist kein Release hinterlegt.', $product)); Http::fail(404, 'no_release', sprintf('Fuer "%s" ist kein Release hinterlegt.', $product));
} }
@@ -89,10 +103,33 @@ switch ($action) {
$releases = $manager->getReleases( $releases = $manager->getReleases(
Http::str('product') ?? Http::str('product_slug'), Http::str('product') ?? Http::str('product_slug'),
Http::str('channel'), Http::str('channel'),
Http::int('limit', 200) Http::int('limit', 200),
Http::str('platform') ?? Http::str('rid')
); );
Http::ok(['count' => count($releases), 'releases' => $releases]); Http::ok(['count' => count($releases), 'releases' => $releases]);
case 'pubkey':
// Oeffentlicher Schluessel zum Pruefen der Release-Signaturen.
// Bewusst ohne Token: er ist oeffentlich, und der Agent braucht ihn,
// bevor er irgendetwas anderes vertrauen kann.
$pub = ReleaseSigner::publicKeyPem();
if ($pub === null) {
Http::fail(
404,
'signing_disabled',
'Auf diesem Deploymentcenter ist kein Signierschluessel hinterlegt '
. '(security.release_private_key). Releases werden unsigniert ausgeliefert.'
);
}
Http::ok([
'algorithm' => 'RSA-SHA256',
'format' => 'canonical-line-v1',
'public_key' => $pub,
'fingerprint' => ReleaseSigner::publicKeyFingerprint(),
]);
case 'publish': case 'publish':
if (Http::method() !== 'POST') { if (Http::method() !== 'POST') {
Http::fail(405, 'method_not_allowed', 'Das Veroeffentlichen erwartet POST.'); Http::fail(405, 'method_not_allowed', 'Das Veroeffentlichen erwartet POST.');
@@ -131,30 +168,76 @@ switch ($action) {
Http::fail(400, 'invalid_hash', 'sha256_hash muss 64 Hexadezimalzeichen enthalten.'); Http::fail(400, 'invalid_hash', 'sha256_hash muss 64 Hexadezimalzeichen enthalten.');
} }
$channel = Http::str('channel') ?? 'prod';
$platform = UpdateManager::normalizePlatform(Http::str('platform') ?? Http::str('rid'));
$size = Http::int('size_bytes', 0);
// Das Dateimanifest wandert mit in die Datenbank. Bisher blieb die
// Spalte manifest_json immer leer - damit konnte die API kein
// vollwertiger Rueckfall fuer den Agenten sein, wenn die statische
// latest.json fehlt.
$manifestJson = null;
$manifestRaw = Http::input('manifest_json', null);
if (is_array($manifestRaw)) {
$encoded = json_encode($manifestRaw, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$manifestJson = $encoded === false ? null : $encoded;
} elseif (is_string($manifestRaw) && trim($manifestRaw) !== '') {
if (json_decode($manifestRaw) === null && json_last_error() !== JSON_ERROR_NONE) {
Http::fail(400, 'invalid_manifest', 'manifest_json ist kein gueltiges JSON.');
}
$manifestJson = $manifestRaw;
}
if ($manifestJson !== null && strlen($manifestJson) > 4 * 1024 * 1024) {
Http::fail(400, 'manifest_too_large', 'manifest_json ueberschreitet 4 MB.');
}
// Signiert wird serverseitig. Der Packager laeuft auf Entwickler-
// rechnern; ein dort hinterlegter Signierschluessel waere so gut
// geschuetzt wie das schwaechste dieser Systeme.
$signature = ReleaseSigner::sign(ReleaseSigner::canonical(
$product,
$version,
$channel,
$platform,
$hash,
$url,
$size
));
$result = $manager->addRelease( $result = $manager->addRelease(
$product, $product,
$version, $version,
Http::str('channel') ?? 'prod', $channel,
Http::str('release_notes'), Http::str('release_notes'),
$url, $url,
$hash, $hash,
Http::str('git_commit'), Http::str('git_commit'),
Http::int('size_bytes', 0), $size,
null, $manifestJson,
filter_var(Http::input('is_critical', false), FILTER_VALIDATE_BOOLEAN), filter_var(Http::input('is_critical', false), FILTER_VALIDATE_BOOLEAN),
$context['actor'] $context['actor'],
$platform,
$signature
); );
Http::ok([ Http::ok([
'release_id' => $result['id'], 'release_id' => $result['id'],
'created' => $result['created'], 'created' => $result['created'],
'platform' => $platform,
'signed' => $signature !== null,
'auto_resolved' => $result['auto_resolved'], 'auto_resolved' => $result['auto_resolved'],
'message' => sprintf( 'message' => sprintf(
'Release %s (%s) fuer "%s" %s.%s', 'Release %s (%s, %s) fuer "%s" %s.%s%s',
$version, $version,
Http::str('channel') ?? 'prod', $channel,
$platform,
$product, $product,
$result['created'] ? 'veroeffentlicht' : 'aktualisiert', $result['created'] ? 'veroeffentlicht' : 'aktualisiert',
$signature === null
? ' Hinweis: unsigniert, kein Signierschluessel hinterlegt.'
: '',
$result['auto_resolved'] > 0 $result['auto_resolved'] > 0
? sprintf(' %d Bugtracker-Item(s) automatisch geschlossen.', $result['auto_resolved']) ? sprintf(' %d Bugtracker-Item(s) automatisch geschlossen.', $result['auto_resolved'])
: '' : ''
@@ -163,7 +246,7 @@ switch ($action) {
default: default:
Http::fail(404, 'unknown_action', 'Endpunkt nicht gefunden.', null, [ Http::fail(404, 'unknown_action', 'Endpunkt nicht gefunden.', null, [
'available' => ['check', 'latest', 'releases', 'publish'], 'available' => ['check', 'latest', 'releases', 'pubkey', 'publish'],
]); ]);
} }
@@ -187,7 +270,7 @@ function resolveUpdateAction(): string
$last = strtolower((string)end($segments)); $last = strtolower((string)end($segments));
return match ($last) { return match ($last) {
'check', 'latest', 'releases', 'publish' => $last, 'check', 'latest', 'releases', 'pubkey', 'publish' => $last,
'publish_release' => 'publish', 'publish_release' => 'publish',
default => 'check', default => 'check',
}; };
+10 -2
View File
@@ -14,7 +14,11 @@
* blieb ein ausgefallener Server dauerhaft gruen, weil der Zustand sich nur * blieb ein ausgefallener Server dauerhaft gruen, weil der Zustand sich nur
* beim Eintreffen eines Heartbeats aenderte. * beim Eintreffen eines Heartbeats aenderte.
* *
* Cron-Eintrag (minuetlich): * Cron-Eintrag (minuetlich). Bevorzugt intern, ohne HTTP und ohne Schluessel
* im Crontab:
* * * * * * /usr/bin/php /pfad/zum/deploymentcenter/cli/tick.php --quiet
*
* Ueber die Schnittstelle, wenn der Cron woanders laeuft:
* * * * * * curl -fsS -H "Authorization: Bearer <SHARED_KEY>" \ * * * * * * curl -fsS -H "Authorization: Bearer <SHARED_KEY>" \
* https://dc.example.com/api/watchdog/v1/evaluate > /dev/null * https://dc.example.com/api/watchdog/v1/evaluate > /dev/null
* *
@@ -71,7 +75,10 @@ switch ($action) {
Http::str('group') ?? Http::str('group_key'), Http::str('group') ?? Http::str('group_key'),
Http::str('os'), Http::str('os'),
// Gesundheitszustand, den die Anwendung selbst ermittelt hat. // Gesundheitszustand, den die Anwendung selbst ermittelt hat.
Http::input('checks') Http::input('checks'),
// Welche Version laeuft hier? Optional - bestehende Agenten
// schicken das Feld nicht und laufen unveraendert weiter.
Http::str('version') ?? Http::str('app_version')
); );
// Numerische Werte in den Verlauf uebernehmen, damit sich Trends // Numerische Werte in den Verlauf uebernehmen, damit sich Trends
@@ -102,6 +109,7 @@ switch ($action) {
'state' => $monitor['state'], 'state' => $monitor['state'],
'last_status' => $monitor['last_status'], 'last_status' => $monitor['last_status'],
'last_seen_utc' => $monitor['last_seen_utc'], 'last_seen_utc' => $monitor['last_seen_utc'],
'app_version' => $monitor['app_version'] ?? null,
'state_changed' => (bool)($monitor['_state_changed'] ?? false), 'state_changed' => (bool)($monitor['_state_changed'] ?? false),
'failing_checks' => $monitor['_failing_checks'] ?? [], 'failing_checks' => $monitor['_failing_checks'] ?? [],
], ],
+83 -6
View File
@@ -32,6 +32,7 @@ use Deploymentcenter\Modules\Bugtracker\BugRepo;
use Deploymentcenter\Modules\Bugtracker\IgnoreRules; use Deploymentcenter\Modules\Bugtracker\IgnoreRules;
use Deploymentcenter\Modules\License\Audit; use Deploymentcenter\Modules\License\Audit;
use Deploymentcenter\Modules\License\KeyGen; use Deploymentcenter\Modules\License\KeyGen;
use Deploymentcenter\Modules\UpdateService\ReleaseSigner;
use Deploymentcenter\Modules\UpdateService\UpdateManager; use Deploymentcenter\Modules\UpdateService\UpdateManager;
use Deploymentcenter\Modules\Watchdog\Evaluator; use Deploymentcenter\Modules\Watchdog\Evaluator;
use Deploymentcenter\Modules\Watchdog\EventLog; use Deploymentcenter\Modules\Watchdog\EventLog;
@@ -716,25 +717,49 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} }
$updateManager = new UpdateManager($pdo); $updateManager = new UpdateManager($pdo);
$channel = trim((string)($_POST['channel'] ?? 'prod'));
$platform = UpdateManager::normalizePlatform((string)($_POST['platform'] ?? ''));
$size = (int)($_POST['size_bytes'] ?? 0);
// Auch der Weg ueber die Oberflaeche wird signiert, sonst
// haetten von Hand eingetragene Releases keine Vertrauenskette.
$signature = ReleaseSigner::sign(ReleaseSigner::canonical(
$productSlug,
$version,
$channel,
$platform,
$hash !== '' ? $hash : null,
$url,
$size
));
$result = $updateManager->addRelease( $result = $updateManager->addRelease(
$productSlug, $productSlug,
$version, $version,
trim((string)($_POST['channel'] ?? 'prod')), $channel,
trim((string)($_POST['release_notes'] ?? '')), trim((string)($_POST['release_notes'] ?? '')),
$url, $url,
$hash !== '' ? $hash : null, $hash !== '' ? $hash : null,
trim((string)($_POST['git_commit'] ?? '')), trim((string)($_POST['git_commit'] ?? '')),
(int)($_POST['size_bytes'] ?? 0), $size,
null, null,
isset($_POST['is_critical']), isset($_POST['is_critical']),
$actor $actor,
$platform,
$signature
); );
dc_flash(sprintf( dc_flash(sprintf(
'Release %s fuer "%s" %s.%s', 'Release %s (%s, %s) fuer "%s" %s.%s%s',
e($version), e($version),
e($channel),
e($platform),
e($productSlug), e($productSlug),
$result['created'] ? 'veroeffentlicht' : 'aktualisiert', $result['created'] ? 'veroeffentlicht' : 'aktualisiert',
$signature === null
? ' Hinweis: unsigniert, kein Signierschluessel hinterlegt.'
: '',
$result['auto_resolved'] > 0 $result['auto_resolved'] > 0
? sprintf(' %d Bugtracker-Item(s) automatisch geschlossen.', $result['auto_resolved']) ? sprintf(' %d Bugtracker-Item(s) automatisch geschlossen.', $result['auto_resolved'])
: '' : ''
@@ -1654,7 +1679,18 @@ $csrfField = Csrf::field();
Der Evaluator lief noch nie. Der Evaluator lief noch nie.
<?php endif; ?> <?php endif; ?>
<div style="font-size:0.78rem; margin-top:0.5rem; font-family:'Roboto Mono',monospace; opacity:0.85;"> <div style="font-size:0.78rem; margin-top:0.5rem; font-family:'Roboto Mono',monospace; opacity:0.85;">
Cron einrichten: * * * * * curl -fsS -H "Authorization: Bearer &lt;SHARED_KEY&gt;" <?= e($baseUrl) ?>/api/watchdog/v1/evaluate &gt; /dev/null <!--
Der interne Aufruf steht zuerst: er braucht keinen Schluessel
im Crontab - der taucht sonst in der Prozessliste jedes
Nutzers auf - und laeuft auch dann, wenn der Webserver
gerade nicht erreichbar ist.
-->
Empfohlen (intern):<br>
* * * * * /usr/bin/php <?= e(DC_ROOT) ?>/cli/tick.php --quiet
<div style="margin-top:0.45rem; opacity:0.75;">
Alternativ über HTTP:<br>
* * * * * curl -fsS -H "Authorization: Bearer &lt;SHARED_KEY&gt;" <?= e($baseUrl) ?>/api/watchdog/v1/evaluate &gt; /dev/null
</div>
</div> </div>
</div> </div>
<form method="POST" action="index.php#tab-watchdog" style="flex-shrink:0;"> <form method="POST" action="index.php#tab-watchdog" style="flex-shrink:0;">
@@ -1698,6 +1734,7 @@ $csrfField = Csrf::field();
<th>Hierarchie / Entity Source</th> <th>Hierarchie / Entity Source</th>
<th>Typ</th> <th>Typ</th>
<th>Status</th> <th>Status</th>
<th>Version</th>
<th>Letzte Meldung</th> <th>Letzte Meldung</th>
<th>Zuletzt Gesehen</th> <th>Zuletzt Gesehen</th>
</tr> </tr>
@@ -1727,6 +1764,13 @@ $csrfField = Csrf::field();
<?= strtoupper($m['state']) ?> <?= strtoupper($m['state']) ?>
</span> </span>
</td> </td>
<td>
<?php if (!empty($m['app_version'])): ?>
<code><?= htmlspecialchars((string)$m['app_version']) ?></code>
<?php else: ?>
<span style="color:var(--text-muted);"></span>
<?php endif; ?>
</td>
<td><?= htmlspecialchars($m['last_message'] ?? '-') ?></td> <td><?= htmlspecialchars($m['last_message'] ?? '-') ?></td>
<td><?= htmlspecialchars($m['last_seen_utc'] ?? 'Nie') ?></td> <td><?= htmlspecialchars($m['last_seen_utc'] ?? 'Nie') ?></td>
</tr> </tr>
@@ -2358,6 +2402,7 @@ Antwort:
<th>Name</th> <th>Name</th>
<th>Typ</th> <th>Typ</th>
<th>Status</th> <th>Status</th>
<th>Version</th>
<th>Zuletzt gesehen</th> <th>Zuletzt gesehen</th>
<th>Kurz-Info / Message</th> <th>Kurz-Info / Message</th>
<th>Aktionen</th> <th>Aktionen</th>
@@ -2383,6 +2428,13 @@ Antwort:
</td> </td>
<td><code><?= htmlspecialchars($m['type']) ?></code></td> <td><code><?= htmlspecialchars($m['type']) ?></code></td>
<td><span class="badge badge-<?= $m['state'] === 'up' ? 'up' : 'down' ?>"><?= strtoupper($m['state']) ?></span></td> <td><span class="badge badge-<?= $m['state'] === 'up' ? 'up' : 'down' ?>"><?= strtoupper($m['state']) ?></span></td>
<td>
<?php if (!empty($m['app_version'])): ?>
<code><?= htmlspecialchars((string)$m['app_version']) ?></code>
<?php else: ?>
<span style="color:var(--text-muted);"></span>
<?php endif; ?>
</td>
<td><?= htmlspecialchars($m['last_seen_utc'] ?? 'Nie') ?></td> <td><?= htmlspecialchars($m['last_seen_utc'] ?? 'Nie') ?></td>
<td><span style="color:var(--text-muted); font-size:0.8rem;"><?= htmlspecialchars($m['last_message'] ?? '-') ?></span></td> <td><span style="color:var(--text-muted); font-size:0.8rem;"><?= htmlspecialchars($m['last_message'] ?? '-') ?></span></td>
<td style="display:flex; gap:0.35rem;"> <td style="display:flex; gap:0.35rem;">
@@ -2566,6 +2618,7 @@ Antwort:
<tr> <tr>
<th>Projekt</th> <th>Projekt</th>
<th>Kanal</th> <th>Kanal</th>
<th>Plattform</th>
<th>Version</th> <th>Version</th>
<th>Git Commit</th> <th>Git Commit</th>
<th>Größe</th> <th>Größe</th>
@@ -2588,7 +2641,15 @@ Antwort:
<tr> <tr>
<td><strong><?= htmlspecialchars($r['product_slug']) ?></strong></td> <td><strong><?= htmlspecialchars($r['product_slug']) ?></strong></td>
<td><span class="badge <?= $channelClass ?>"><?= strtoupper(htmlspecialchars($r['channel'] ?? 'prod')) ?></span></td> <td><span class="badge <?= $channelClass ?>"><?= strtoupper(htmlspecialchars($r['channel'] ?? 'prod')) ?></span></td>
<td><code>v<?= htmlspecialchars($r['version']) ?></code> <?php if (!empty($r['is_critical'])): ?><span class="badge badge-down">KRITISCH</span><?php endif; ?></td> <td>
<?php $platformLabel = (string)($r['platform'] ?? 'any'); ?>
<code style="font-size:0.75rem;<?= $platformLabel === 'any' ? 'color:var(--text-muted);' : '' ?>"><?= htmlspecialchars($platformLabel) ?></code>
</td>
<td>
<code>v<?= htmlspecialchars($r['version']) ?></code>
<?php if (!empty($r['is_critical'])): ?><span class="badge badge-down">KRITISCH</span><?php endif; ?>
<?php if (empty($r['manifest_signature'])): ?><span class="badge badge-warning" title="Ohne Signatur - der Agent kann die Herkunft nicht prüfen.">UNSIGNIERT</span><?php endif; ?>
</td>
<td><code style="color:var(--text-muted);"><?= htmlspecialchars($r['git_commit'] ?? 'n/a') ?></code></td> <td><code style="color:var(--text-muted);"><?= htmlspecialchars($r['git_commit'] ?? 'n/a') ?></code></td>
<td><span style="font-family:'Roboto Mono', monospace; font-size:0.8rem;"><?= $sizeFormatted ?></span></td> <td><span style="font-family:'Roboto Mono', monospace; font-size:0.8rem;"><?= $sizeFormatted ?></span></td>
<td><?= htmlspecialchars($r['release_notes'] ?? '-') ?></td> <td><?= htmlspecialchars($r['release_notes'] ?? '-') ?></td>
@@ -2633,6 +2694,22 @@ Antwort:
<option value="dev">dev (Entwicklung)</option> <option value="dev">dev (Entwicklung)</option>
</select> </select>
</div> </div>
<div class="form-group">
<label class="form-label">Plattform</label>
<select name="platform" class="form-input">
<option value="any" selected>any (plattformunabhängig)</option>
<option value="win-x64">win-x64</option>
<option value="win-arm64">win-arm64</option>
<option value="linux-x64">linux-x64</option>
<option value="linux-arm64">linux-arm64</option>
<option value="linux-musl-x64">linux-musl-x64</option>
<option value="osx-x64">osx-x64</option>
<option value="osx-arm64">osx-arm64</option>
</select>
<div style="font-size:0.7rem; color:var(--text-muted); margin-top:0.25rem;">
Clients ohne Plattformangabe sehen ausschließlich <code>any</code>.
</div>
</div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Version (z. B. 1.2.0)</label> <label class="form-label">Version (z. B. 1.2.0)</label>
<input type="text" name="version" class="form-input" required placeholder="1.2.0"> <input type="text" name="version" class="form-input" required placeholder="1.2.0">
@@ -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);
@@ -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;
+13 -1
View File
@@ -94,6 +94,8 @@ CREATE TABLE IF NOT EXISTS watchdog_monitors (
is_muted TINYINT(1) NOT NULL DEFAULT 0, is_muted TINYINT(1) NOT NULL DEFAULT 0,
expect_running TINYINT(1) NOT NULL DEFAULT 1, expect_running TINYINT(1) NOT NULL DEFAULT 1,
os VARCHAR(50) NULL, os VARCHAR(50) NULL,
-- Version der laufenden Anwendung, vom Heartbeat mitgeliefert (optional).
app_version VARCHAR(64) NULL,
first_contact_deadline_utc DATETIME NULL, first_contact_deadline_utc DATETIME NULL,
ack_until_utc DATETIME NULL, ack_until_utc DATETIME NULL,
acknowledged_by VARCHAR(100) NULL, acknowledged_by VARCHAR(100) NULL,
@@ -168,15 +170,25 @@ CREATE TABLE IF NOT EXISTS updateservice_releases (
product_slug VARCHAR(64) NOT NULL, product_slug VARCHAR(64) NOT NULL,
version VARCHAR(32) NOT NULL, version VARCHAR(32) NOT NULL,
channel VARCHAR(32) NOT NULL DEFAULT 'prod', 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, release_notes TEXT NULL,
download_url VARCHAR(255) NOT NULL, download_url VARCHAR(255) NOT NULL,
sha256_hash VARCHAR(64) NULL, sha256_hash VARCHAR(64) NULL,
git_commit VARCHAR(64) NULL, git_commit VARCHAR(64) NULL,
size_bytes BIGINT NOT NULL DEFAULT 0, size_bytes BIGINT NOT NULL DEFAULT 0,
manifest_json JSON NULL, 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, is_critical TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 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; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 5. Core Token Hierarchy & Bugtracker Tables -- 5. Core Token Hierarchy & Bugtracker Tables
+157
View File
@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Modules\UpdateService;
use Deploymentcenter\Core\Config;
use Deploymentcenter\Core\Logger;
/**
* Signiert Releases, damit ein Agent pruefen kann, dass Paket und Metadaten
* wirklich aus diesem Deploymentcenter stammen.
*
* Warum asymmetrisch und nicht HMAC wie beim Lizenzmodul: Beim Lizenzmodul
* prueft der Server, der Schluessel bleibt also hier. Ein Update wird dagegen
* auf dem Zielsystem geprueft. Ein dort hinterlegter HMAC-Schluessel liesse
* sich auslesen und zum Signieren beliebiger Pakete verwenden - die Signatur
* wuerde damit genau die Eigenschaft verlieren, wegen der es sie gibt.
*
* Signiert wird eine kanonische Zeile, nicht das Manifest-JSON: JSON-Ausgabe
* ist nicht bytestabil (Schluesselreihenfolge, Escaping, Zahlenformat), eine
* Signatur darueber waere unzuverlaessig pruefbar.
*
* Ohne hinterlegten Schluessel ist Signieren abgeschaltet. Releases bleiben
* dann unsigniert und installierbar; der Agent weist darauf hin.
*/
final class ReleaseSigner
{
/**
* Kanonische Darstellung eines Releases.
*
* Reihenfolge und Trennzeichen sind Teil des Formats und duerfen sich
* nicht aendern, ohne dass alle bestehenden Signaturen ungueltig werden.
* Der Paket-Hash ist der eigentlich zu schuetzende Wert - alles andere
* bindet ihn an genau ein Produkt, eine Version und eine Plattform.
*/
public static function canonical(
string $productSlug,
string $version,
string $channel,
string $platform,
?string $sha256Hash,
string $downloadUrl,
int $sizeBytes
): string {
return implode("\n", [
'dc-release-v1',
$productSlug,
$version,
$channel,
$platform,
strtolower((string)$sha256Hash),
$downloadUrl,
(string)$sizeBytes,
]);
}
/** Ist ein Signierschluessel hinterlegt? */
public static function isEnabled(): bool
{
return self::privateKeyPem() !== '';
}
/**
* Signiert die kanonische Zeile. Rueckgabe base64, oder null wenn kein
* Schluessel hinterlegt ist oder das Signieren fehlschlaegt.
*/
public static function sign(string $canonical): ?string
{
$pem = self::privateKeyPem();
if ($pem === '') {
return null;
}
if (!function_exists('openssl_sign')) {
Logger::warning('Release-Signatur nicht moeglich: OpenSSL-Erweiterung fehlt.');
return null;
}
$key = openssl_pkey_get_private($pem);
if ($key === false) {
Logger::warning('Release-Signatur nicht moeglich: privater Schluessel nicht lesbar.');
return null;
}
$signature = '';
$ok = openssl_sign($canonical, $signature, $key, OPENSSL_ALGO_SHA256);
if ($ok !== true || $signature === '') {
Logger::warning('Release-Signatur fehlgeschlagen.');
return null;
}
return base64_encode($signature);
}
/**
* Oeffentlicher Schluessel im PEM-Format, aus dem privaten abgeleitet.
* Er wird ueber /api/updateservice/v1/pubkey ausgeliefert.
*/
public static function publicKeyPem(): ?string
{
$pem = self::privateKeyPem();
if ($pem === '' || !function_exists('openssl_pkey_get_private')) {
return null;
}
$key = openssl_pkey_get_private($pem);
if ($key === false) {
return null;
}
$details = openssl_pkey_get_details($key);
if (!is_array($details) || !isset($details['key']) || !is_string($details['key'])) {
return null;
}
return $details['key'];
}
/**
* Fingerabdruck des oeffentlichen Schluessels (SHA256 ueber das PEM).
* Damit kann ein Agent den einmal geholten Schluessel wiedererkennen,
* statt ihm bei jedem Aufruf neu zu vertrauen.
*/
public static function publicKeyFingerprint(): ?string
{
$pub = self::publicKeyPem();
return $pub === null ? null : hash('sha256', $pub);
}
/**
* Der Schluessel darf als PEM direkt oder als Pfad zu einer Datei
* hinterlegt sein. Ein Pfad ist auf Servern meist die sauberere Ablage,
* weil der Schluessel dann nicht in der Konfiguration steht.
*/
private static function privateKeyPem(): string
{
$value = trim((string)Config::get('security.release_private_key', ''));
if ($value === '') {
return '';
}
if (str_contains($value, '-----BEGIN')) {
return $value;
}
if (is_file($value) && is_readable($value)) {
$contents = file_get_contents($value);
return $contents === false ? '' : trim($contents);
}
Logger::warning('security.release_private_key zeigt weder auf ein PEM noch auf eine lesbare Datei.');
return '';
}
}
+174 -50
View File
@@ -18,6 +18,9 @@ use PDO;
*/ */
final class UpdateManager final class UpdateManager
{ {
/** Kennung fuer plattformunabhaengige Releases. */
public const PLATFORM_ANY = 'any';
private PDO $db; private PDO $db;
public function __construct(PDO $db) public function __construct(PDO $db)
@@ -25,19 +28,104 @@ final class UpdateManager
$this->db = $db; $this->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<array<string,mixed>>
*/
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. * Ermittelt das neueste Release, das echt neuer ist als die uebergebene Version.
* *
* @return array<string,mixed>|null * @return array<string,mixed>|null
*/ */
public function checkUpdate(string $productSlug, string $currentVersion, string $channel = 'prod'): ?array public function checkUpdate(
{ string $productSlug,
$stmt = $this->db->prepare(' string $currentVersion,
SELECT * FROM updateservice_releases string $channel = 'prod',
WHERE product_slug = :slug AND channel = :channel ?string $platform = null
'); ): ?array {
$stmt->execute([':slug' => $productSlug, ':channel' => $channel]); $releases = $this->releasesFor($productSlug, $channel, $platform);
$releases = $stmt->fetchAll() ?: [];
if ($releases === []) { if ($releases === []) {
return null; return null;
@@ -52,14 +140,9 @@ final class UpdateManager
} }
/** Hoechstes Release eines Kanals, unabhaengig von der Client-Version. */ /** 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(' return Version::highest($this->releasesFor($productSlug, $channel, $platform));
SELECT * FROM updateservice_releases
WHERE product_slug = :slug AND channel = :channel
');
$stmt->execute([':slug' => $productSlug, ':channel' => $channel]);
return Version::highest($stmt->fetchAll() ?: []);
} }
/** /**
@@ -78,41 +161,50 @@ final class UpdateManager
int $sizeBytes = 0, int $sizeBytes = 0,
?string $manifestJson = null, ?string $manifestJson = null,
bool $isCritical = false, bool $isCritical = false,
string $author = 'admin' string $author = 'admin',
?string $platform = null,
?string $manifestSignature = null
): array { ): array {
$existing = $this->findRelease($productSlug, $version, $channel); $platform = self::normalizePlatform($platform);
$existing = $this->findRelease($productSlug, $version, $channel, $platform);
$stmt = $this->db->prepare(' $stmt = $this->db->prepare('
INSERT INTO updateservice_releases ( INSERT INTO updateservice_releases (
product_slug, version, channel, release_notes, download_url, product_slug, version, channel, platform, release_notes, download_url,
sha256_hash, git_commit, size_bytes, manifest_json, is_critical sha256_hash, git_commit, size_bytes, manifest_json, manifest_signature,
is_critical
) VALUES ( ) VALUES (
:slug, :version, :channel, :notes, :url, :slug, :version, :channel, :platform, :notes, :url,
:hash, :git, :size, :manifest, :critical :hash, :git, :size, :manifest, :signature,
:critical
) ON DUPLICATE KEY UPDATE ) ON DUPLICATE KEY UPDATE
release_notes = VALUES(release_notes), release_notes = VALUES(release_notes),
download_url = VALUES(download_url), download_url = VALUES(download_url),
sha256_hash = VALUES(sha256_hash), sha256_hash = VALUES(sha256_hash),
git_commit = VALUES(git_commit), git_commit = VALUES(git_commit),
size_bytes = VALUES(size_bytes), size_bytes = VALUES(size_bytes),
manifest_json = VALUES(manifest_json), manifest_json = VALUES(manifest_json),
is_critical = VALUES(is_critical) manifest_signature = VALUES(manifest_signature),
is_critical = VALUES(is_critical)
'); ');
$stmt->execute([ $stmt->execute([
':slug' => $productSlug, ':slug' => $productSlug,
':version' => $version, ':version' => $version,
':channel' => $channel, ':channel' => $channel,
':notes' => $releaseNotes, ':platform' => $platform,
':url' => $downloadUrl, ':notes' => $releaseNotes,
':hash' => $sha256Hash !== null && $sha256Hash !== '' ? $sha256Hash : null, ':url' => $downloadUrl,
':git' => $gitCommit !== null && $gitCommit !== '' ? $gitCommit : null, ':hash' => $sha256Hash !== null && $sha256Hash !== '' ? $sha256Hash : null,
':size' => $sizeBytes, ':git' => $gitCommit !== null && $gitCommit !== '' ? $gitCommit : null,
':manifest' => $manifestJson, ':size' => $sizeBytes,
':critical' => $isCritical ? 1 : 0, ':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; $releaseId = $release !== null ? (int)$release['id'] : 0;
// Bugtracker-Items, die fuer genau diesen Build vorgemerkt sind, // Bugtracker-Items, die fuer genau diesen Build vorgemerkt sind,
@@ -130,6 +222,7 @@ final class UpdateManager
'project_slug' => $productSlug, 'project_slug' => $productSlug,
'version' => $version, 'version' => $version,
'channel' => $channel, 'channel' => $channel,
'platform' => $platform,
'is_critical' => $isCritical, 'is_critical' => $isCritical,
'download_url' => $downloadUrl, 'download_url' => $downloadUrl,
'auto_resolved' => $autoResolved, 'auto_resolved' => $autoResolved,
@@ -139,10 +232,11 @@ final class UpdateManager
} }
Logger::info('Release veroeffentlicht', [ Logger::info('Release veroeffentlicht', [
'product' => $productSlug, 'product' => $productSlug,
'version' => $version, 'version' => $version,
'channel' => $channel, 'channel' => $channel,
'author' => $author, 'platform' => $platform,
'author' => $author,
]); ]);
return [ 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(' $stmt = $this->db->prepare('
SELECT * FROM updateservice_releases SELECT * FROM updateservice_releases
WHERE product_slug = :slug AND version = :version AND channel = :channel WHERE product_slug = :slug AND version = :version AND channel = :channel
AND platform = :platform
LIMIT 1 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(); $row = $stmt->fetch();
return is_array($row) ? $row : null; return is_array($row) ? $row : null;
} }
@@ -176,8 +280,12 @@ final class UpdateManager
* *
* @return list<array<string,mixed>> * @return list<array<string,mixed>>
*/ */
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 = []; $where = [];
$params = []; $params = [];
@@ -190,17 +298,26 @@ final class UpdateManager
$params[':channel'] = $channel; $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'; $sql = 'SELECT * FROM updateservice_releases';
if ($where !== []) { if ($where !== []) {
$sql .= ' WHERE ' . implode(' AND ', $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 = $this->db->prepare($sql);
$stmt->execute($params); $stmt->execute($params);
$releases = $stmt->fetchAll() ?: []; $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 { usort($releases, static function (array $a, array $b): int {
$bySlug = strcmp((string)$a['product_slug'], (string)$b['product_slug']); $bySlug = strcmp((string)$a['product_slug'], (string)$b['product_slug']);
if ($bySlug !== 0) { if ($bySlug !== 0) {
@@ -210,6 +327,13 @@ final class UpdateManager
if ($byChannel !== 0) { if ($byChannel !== 0) {
return $byChannel; 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']); return Version::compare((string)$b['version'], (string)$a['version']);
}); });
+5 -1
View File
@@ -25,7 +25,11 @@ use PDO;
* stuft Monitore entsprechend auf warning bzw. down. Zustandswechsel landen * stuft Monitore entsprechend auf warning bzw. down. Zustandswechsel landen
* im Event-Log und loesen Webhooks aus. * 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 <SHARED_KEY>" https://dc.example.com/api/watchdog/v1/evaluate * curl -H "Authorization: Bearer <SHARED_KEY>" https://dc.example.com/api/watchdog/v1/evaluate
*/ */
final class Evaluator final class Evaluator
+8 -3
View File
@@ -86,7 +86,8 @@ final class MonitorRepo
?string $message, ?string $message,
?string $groupKey = null, ?string $groupKey = null,
?string $os = null, ?string $os = null,
$checks = null $checks = null,
?string $appVersion = null
): array { ): array {
$metricsJson = (is_array($metrics) || is_object($metrics)) $metricsJson = (is_array($metrics) || is_object($metrics))
? json_encode($metrics, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ? json_encode($metrics, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
@@ -139,11 +140,11 @@ final class MonitorRepo
INSERT INTO watchdog_monitors ( INSERT INTO watchdog_monitors (
source, instance, type, state, last_state_change_utc, expected_interval_sec, source, instance, type, state, last_state_change_utc, expected_interval_sec,
last_seen_utc, last_status, last_message, metrics_json, health_json, 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 ( ) VALUES (
:source, :instance, :type, :state, UTC_TIMESTAMP(), :interval, :source, :instance, :type, :state, UTC_TIMESTAMP(), :interval,
UTC_TIMESTAMP(), :last_status, :message, :metrics, :health, 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 ON DUPLICATE KEY UPDATE
-- Reihenfolge ist relevant: MySQL wertet die Zuweisungen von -- Reihenfolge ist relevant: MySQL wertet die Zuweisungen von
@@ -161,6 +162,9 @@ final class MonitorRepo
failing_checks = VALUES(failing_checks), failing_checks = VALUES(failing_checks),
group_key = COALESCE(VALUES(group_key), group_key), group_key = COALESCE(VALUES(group_key), group_key),
os = COALESCE(VALUES(os), os), 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) updated_utc = VALUES(updated_utc)
'); ');
@@ -177,6 +181,7 @@ final class MonitorRepo
':failing' => $failing !== [] ? mb_substr(implode(', ', $failing), 0, 255) : null, ':failing' => $failing !== [] ? mb_substr(implode(', ', $failing), 0, 255) : null,
':group_key' => $groupKey, ':group_key' => $groupKey,
':os' => $os, ':os' => $os,
':app_version' => $appVersion !== null ? mb_substr($appVersion, 0, 64) : null,
]); ]);
$monitor = $this->getMonitor($source, $instance); $monitor = $this->getMonitor($source, $instance);