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:
co-authored by
Claude Opus 5
parent
5f9b0c5596
commit
2388b5abe1
@@ -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/
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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="// <auto-generated />" />
|
<BuildInfoLine Include="// <auto-generated />" />
|
||||||
<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=" /// <summary>Zur Uebersetzungszeit eingebettete Build-Daten.</summary>" />
|
||||||
|
<BuildInfoLine Include=" public static class $(DeploymentcenterBuildInfoClass)" />
|
||||||
<BuildInfoLine Include=" {" />
|
<BuildInfoLine Include=" {" />
|
||||||
<BuildInfoLine Include=" static BuildInfo()" />
|
<BuildInfoLine Include=" public const string Version = "$(BuildVersion)"%3B" />
|
||||||
|
<BuildInfoLine Include=" public const string GitCommit = "$(DcGitCommitLong)"%3B" />
|
||||||
|
<BuildInfoLine Include=" public const string GitCommitShort = "$(DcGitCommitShort)"%3B" />
|
||||||
|
<BuildInfoLine Include=" public const string BuildDateUtc = "$(BuildDateUtc)"%3B" />
|
||||||
|
<BuildInfoLine Include=" public const string Channel = "$(BuildChannel)"%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 = "$(BuildVersion)"%3B" />
|
<BuildInfoLine Include=" get { return "v" + Version + " (" + GitCommitShort + ") built on " + BuildDateUtc + " [" + Channel + "]"%3B }" />
|
||||||
<BuildInfoLine Include=" GitCommit = "$(GitCommitLong)"%3B" />
|
|
||||||
<BuildInfoLine Include=" GitCommitShort = "$(GitCommitShort)"%3B" />
|
|
||||||
<BuildInfoLine Include=" BuildDateUtc = "$(BuildDateUtc)"%3B" />
|
|
||||||
<BuildInfoLine Include=" Channel = "$(BuildChannel)"%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 <Version> 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
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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'],
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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 <SHARED_KEY>" <?= e($baseUrl) ?>/api/watchdog/v1/evaluate > /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 <SHARED_KEY>" <?= e($baseUrl) ?>/api/watchdog/v1/evaluate > /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
@@ -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
|
||||||
|
|||||||
@@ -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 '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user