Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d0261306c | ||
|
|
687ee0cefc | ||
|
|
fc9b698141 | ||
|
|
e579ca1acc | ||
|
|
f8771c8d1b | ||
|
|
a8b9f6f7c9 | ||
|
|
1967b49ad7 | ||
|
|
e01a608c08 | ||
|
|
56b1d2631a | ||
|
|
7a3a5dad69 |
@@ -7,6 +7,9 @@ var/*.lock
|
|||||||
artifacts/
|
artifacts/
|
||||||
*.bak
|
*.bak
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
# Bytecode-Cache der Deployment-Skripte
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
bin/
|
bin/
|
||||||
obj/
|
obj/
|
||||||
*.user
|
*.user
|
||||||
@@ -14,6 +17,7 @@ obj/
|
|||||||
# --- Secrets: NIEMALS committen ---
|
# --- Secrets: NIEMALS committen ---
|
||||||
config/config.php
|
config/config.php
|
||||||
config/.htpasswd
|
config/.htpasswd
|
||||||
|
config/*.pem
|
||||||
scripts/deploy_config.json
|
scripts/deploy_config.json
|
||||||
Serverdaten.txt
|
Serverdaten.txt
|
||||||
*.local.php
|
*.local.php
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ Options -Indexes
|
|||||||
RewriteRule ^api/updateservice/v1(?:/(.*))?$ public/api/updateservice/v1/index.php [L,QSA]
|
RewriteRule ^api/updateservice/v1(?:/(.*))?$ public/api/updateservice/v1/index.php [L,QSA]
|
||||||
RewriteRule ^api/tokens/v1/provision/?$ public/api/tokens/v1/provision.php [L,QSA]
|
RewriteRule ^api/tokens/v1/provision/?$ public/api/tokens/v1/provision.php [L,QSA]
|
||||||
RewriteRule ^api/setup/v1(?:/(.*))?$ public/api/setup/v1/index.php [L,QSA]
|
RewriteRule ^api/setup/v1(?:/(.*))?$ public/api/setup/v1/index.php [L,QSA]
|
||||||
|
RewriteRule ^api/supervisor/v1(?:/(.*))?$ public/api/supervisor/v1/index.php [L,QSA]
|
||||||
|
|
||||||
# Fallback fuer direkt adressierte Dateien unterhalb von /api/
|
# Fallback fuer direkt adressierte Dateien unterhalb von /api/
|
||||||
RewriteRule ^api/(.*)$ public/api/$1 [L,QSA]
|
RewriteRule ^api/(.*)$ public/api/$1 [L,QSA]
|
||||||
@@ -57,7 +58,7 @@ Options -Indexes
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# 5. Alles Uebrige sperren: Anwendungscode, Konfiguration, Skripte
|
# 5. Alles Uebrige sperren: Anwendungscode, Konfiguration, Skripte
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
RewriteRule ^(config|src|sql|scripts|cli|var|client-dotnet)(/|$) - [F,L]
|
RewriteRule ^(config|src|sql|scripts|cli|var|client-dotnet|client-php|mcp)(/|$) - [F,L]
|
||||||
RewriteRule ^Serverdaten\.txt$ - [F,L]
|
RewriteRule ^Serverdaten\.txt$ - [F,L]
|
||||||
</IfModule>
|
</IfModule>
|
||||||
|
|
||||||
|
|||||||
@@ -159,6 +159,29 @@ foreach ($guardResult['errors'] as $guardError) {
|
|||||||
Logger::warning('Release-Zugangsschutz', ['error' => $guardError]);
|
Logger::warning('Release-Zugangsschutz', ['error' => $guardError]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nach einer Neuerzeugung gegenpruefen, ob der Schutz wirklich greift.
|
||||||
|
// Vorhandene Dateien beweisen nichts: Unter Nginx werden sie ignoriert, und
|
||||||
|
// dann waeren die Verzeichnisse offen, ohne dass es jemandem auffiele.
|
||||||
|
if ($guardResult['ran']) {
|
||||||
|
$guardTest = \Deploymentcenter\Modules\UpdateService\ReleaseGuard::selfTest($db);
|
||||||
|
|
||||||
|
foreach ($guardTest['failed'] as $failure) {
|
||||||
|
Logger::error('Release-Zugangsschutz greift nicht', [
|
||||||
|
'product' => $failure['slug'] ?? '?',
|
||||||
|
'status' => $failure['status'] ?? null,
|
||||||
|
'reason' => $failure['reason'] ?? '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$options['quiet'] && $guardTest['failed'] !== []) {
|
||||||
|
printf(
|
||||||
|
"ACHTUNG: Zugangsschutz greift bei %d von %d Produkt(en) NICHT.\n",
|
||||||
|
count($guardTest['failed']),
|
||||||
|
$guardTest['checked']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$result = Evaluator::run($db);
|
$result = Evaluator::run($db);
|
||||||
$wallMs = (int)round((microtime(true) - $startedAt) * 1000);
|
$wallMs = (int)round((microtime(true) - $startedAt) * 1000);
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,6 @@
|
|||||||
<DeploymentcenterBuildInfoNamespace Condition="'$(DeploymentcenterBuildInfoNamespace)' == ''">$(MSBuildProjectName)</DeploymentcenterBuildInfoNamespace>
|
<DeploymentcenterBuildInfoNamespace Condition="'$(DeploymentcenterBuildInfoNamespace)' == ''">$(MSBuildProjectName)</DeploymentcenterBuildInfoNamespace>
|
||||||
|
|
||||||
<BuildInfoFile>$(IntermediateOutputPath)DeploymentcenterBuildInfo.g.cs</BuildInfoFile>
|
<BuildInfoFile>$(IntermediateOutputPath)DeploymentcenterBuildInfo.g.cs</BuildInfoFile>
|
||||||
<BuildDateUtc>$([System.DateTime]::UtcNow.ToString("o"))</BuildDateUtc>
|
|
||||||
|
|
||||||
<!-- Version aus <Version>, ersatzweise <AssemblyVersion>, sonst 0.0.0. -->
|
<!-- Version aus <Version>, ersatzweise <AssemblyVersion>, sonst 0.0.0. -->
|
||||||
<BuildVersion Condition="'$(BuildVersion)' == ''">$(Version)</BuildVersion>
|
<BuildVersion Condition="'$(BuildVersion)' == ''">$(Version)</BuildVersion>
|
||||||
@@ -73,6 +72,29 @@
|
|||||||
<Output TaskParameter="ConsoleOutput" PropertyName="DcGitCommitShort" />
|
<Output TaskParameter="ConsoleOutput" PropertyName="DcGitCommitShort" />
|
||||||
</Exec>
|
</Exec>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<!--
|
||||||
|
Nur das Datum, nicht die Uhrzeit.
|
||||||
|
|
||||||
|
Zuvor stand hier DateTime.UtcNow mit voller Genauigkeit. Das aenderte
|
||||||
|
sich bei JEDEM Build, die erzeugte .g.cs unterschied sich dadurch
|
||||||
|
immer, WriteOnlyWhenDifferent griff nie - und jedes Projekt, das diese
|
||||||
|
Targets einbindet, wurde bei jedem Build neu uebersetzt, samt allem,
|
||||||
|
was davon abhaengt. Bei einer Projektmappe mit mehreren
|
||||||
|
Abhaengigkeiten kostet das jedes Mal spuerbar Zeit.
|
||||||
|
|
||||||
|
Mit Tagesgenauigkeit faellt hoechstens ein Neubau pro Tag an. Die
|
||||||
|
genaue Bauzeit war ohnehin selten die interessante Angabe - welcher
|
||||||
|
Stand gebaut wurde, sagt GitCommit praeziser, und der aendert sich
|
||||||
|
genau dann, wenn eine Neuuebersetzung faellig ist.
|
||||||
|
|
||||||
|
Das Commit-Datum waere noch stabiler, laesst sich aber nicht
|
||||||
|
verlaesslich holen: Die Formatangabe von "git log" muss durch MSBuild
|
||||||
|
und cmd.exe, und das Prozentzeichen ueberlebt diese Kette nicht.
|
||||||
|
-->
|
||||||
|
<BuildDateUtc>$([System.DateTime]::UtcNow.ToString("yyyy-MM-dd"))</BuildDateUtc>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<DcGitCommitLong Condition="'$(DcGitCommitLong)' == ''">UNKNOWN_COMMIT</DcGitCommitLong>
|
<DcGitCommitLong Condition="'$(DcGitCommitLong)' == ''">UNKNOWN_COMMIT</DcGitCommitLong>
|
||||||
<DcGitCommitShort Condition="'$(DcGitCommitShort)' == ''">UNKNOWN</DcGitCommitShort>
|
<DcGitCommitShort Condition="'$(DcGitCommitShort)' == ''">UNKNOWN</DcGitCommitShort>
|
||||||
|
|||||||
@@ -8,8 +8,41 @@
|
|||||||
<RootNamespace>Deploymentcenter.Client</RootNamespace>
|
<RootNamespace>Deploymentcenter.Client</RootNamespace>
|
||||||
<AssemblyName>Deploymentcenter.Client</AssemblyName>
|
<AssemblyName>Deploymentcenter.Client</AssemblyName>
|
||||||
<NoWarn>$(NoWarn);CA1416</NoWarn>
|
<NoWarn>$(NoWarn);CA1416</NoWarn>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Als NuGet-Paket beziehbar machen.
|
||||||
|
|
||||||
|
Die Anleitung empfahl bisher einen Import per relativem Pfad ins
|
||||||
|
Nachbar-Repository:
|
||||||
|
|
||||||
|
<Import Project="..\Deploymentcenter.Client\Deploymentcenter.BuildInfo.targets" />
|
||||||
|
|
||||||
|
Das setzt voraus, dass beide Repositories nebeneinander ausgecheckt sind
|
||||||
|
und in derselben Fassung stehen - genau die Kopplung, die ein Paket
|
||||||
|
vermeidet.
|
||||||
|
-->
|
||||||
|
<IsPackable>true</IsPackable>
|
||||||
|
<PackageId>Deploymentcenter.Client</PackageId>
|
||||||
|
<Version>2.6.0</Version>
|
||||||
|
<Authors>Deploymentcenter</Authors>
|
||||||
|
<Description>Client-SDK für Lizenzprüfung, Update-Verteilung und Erstinstallation über das Deploymentcenter.</Description>
|
||||||
|
<PackageTags>deploymentcenter;update;lizenz</PackageTags>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Das Targets-File wandert nach build/ im Paket und wird von NuGet
|
||||||
|
automatisch in jedes Projekt importiert, das auf das Paket verweist.
|
||||||
|
Ein <Import> von Hand entfaellt damit - und mit ihm der Pfad ins
|
||||||
|
Nachbar-Repository.
|
||||||
|
|
||||||
|
Der Dateiname muss dafuer der Paket-Kennung entsprechen.
|
||||||
|
-->
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="Deploymentcenter.BuildInfo.targets"
|
||||||
|
Pack="true"
|
||||||
|
PackagePath="build\Deploymentcenter.Client.targets" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Deploymentcenter.Client
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Auskunft ueber die eingebundene SDK-Fassung.
|
||||||
|
///
|
||||||
|
/// Wozu das gut ist: Wer eine Anbindung aktualisiert, will nicht die
|
||||||
|
/// gesamte Historie lesen, sondern nur wissen, was sich seit der eigenen
|
||||||
|
/// Fassung geaendert hat. Dafuer muss die Anwendung festhalten, gegen
|
||||||
|
/// welche Fassung sie gebaut wurde - und genau das ist
|
||||||
|
/// <see cref="Version"/>.
|
||||||
|
///
|
||||||
|
/// Der Wert wandert automatisch in die Update-Pruefung und den Heartbeat,
|
||||||
|
/// sodass im Deploymentcenter sichtbar wird, welche Installation auf
|
||||||
|
/// welchem Stand haengt. Ohne diese Angabe bliebe nur Nachfragen.
|
||||||
|
///
|
||||||
|
/// Abfragen laesst sich der Unterschied so:
|
||||||
|
///
|
||||||
|
/// GET /api/updateservice/v1/changelog?since=2.2.0
|
||||||
|
///
|
||||||
|
/// Die Antwort nennt nur die neueren Fassungen, dazu die Anzahl der
|
||||||
|
/// Punkte mit Handlungsbedarf und der brechenden Aenderungen.
|
||||||
|
/// </summary>
|
||||||
|
public static class DeploymentcenterSdk
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Fassung dieses SDK. Bei jeder Auslieferung mitzufuehren - sie ist
|
||||||
|
/// der Bezugspunkt fuer den Changelog.
|
||||||
|
/// </summary>
|
||||||
|
public const string Version = "2.5.1";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adresse, unter der sich der Unterschied zur eigenen Fassung
|
||||||
|
/// abfragen laesst.
|
||||||
|
/// </summary>
|
||||||
|
public static string ChangelogUrl(string baseUrl, string? since = null)
|
||||||
|
{
|
||||||
|
string clean = (baseUrl ?? string.Empty).TrimEnd('/');
|
||||||
|
string url = clean + "/api/updateservice/v1/changelog";
|
||||||
|
|
||||||
|
string from = string.IsNullOrWhiteSpace(since) ? Version : since!;
|
||||||
|
return url + "?since=" + Uri.EscapeDataString(from);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Kurzer Vermerk fuer Protokolle und Fehlerberichte. Taucht ein
|
||||||
|
/// Problem auf, ist damit sofort ersichtlich, gegen welche Fassung die
|
||||||
|
/// Anbindung gebaut wurde.
|
||||||
|
/// </summary>
|
||||||
|
public static string Describe(string? appVersion = null)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(appVersion)
|
||||||
|
? $"Deploymentcenter-SDK {Version}"
|
||||||
|
: $"{appVersion} (Deploymentcenter-SDK {Version})";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -378,6 +378,99 @@ public class LicenseClient
|
|||||||
/// stiller Neustart bauen, ohne den Schluessel ein zweites Mal abzulegen.
|
/// stiller Neustart bauen, ohne den Schluessel ein zweites Mal abzulegen.
|
||||||
/// Liefert null, wenn kein brauchbarer Cache vorliegt.
|
/// Liefert null, wenn kein brauchbarer Cache vorliegt.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <summary>
|
||||||
|
/// Stellt sicher, dass eine gueltige Lizenz vorliegt - und fragt notfalls
|
||||||
|
/// nach dem Schluessel.
|
||||||
|
///
|
||||||
|
/// Bis Version 2.5 nahm der Konstruktor ein <see cref="ILicensePrompt"/>
|
||||||
|
/// entgegen, legte es ab und benutzte es nie. Das Versprechen der
|
||||||
|
/// Schnittstelle - Schluessel abfragen, Fehler und Erfolg melden - loeste
|
||||||
|
/// nichts ein; wer darauf eine headless-Story aufbaute, baute auf Sand.
|
||||||
|
/// Diese Methode ist die Naht, die dabei fehlte.
|
||||||
|
///
|
||||||
|
/// Ablauf: zwischengespeicherten Schluessel nehmen, sonst fragen; pruefen;
|
||||||
|
/// bei Ablehnung erneut fragen, bis <paramref name="maxAttempts"/>
|
||||||
|
/// erschoepft ist.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="allowPrompt">
|
||||||
|
/// Auf false setzen, wenn kein Mensch davorsitzt - etwa in einem Dienst.
|
||||||
|
/// Dann wird ausschliesslich der zwischengespeicherte Schluessel geprueft
|
||||||
|
/// und bei dessen Fehlen abgelehnt, statt auf eine Eingabe zu warten, die
|
||||||
|
/// nie kommt.
|
||||||
|
/// </param>
|
||||||
|
public async Task<LicenseValidationResult> EnsureLicensedAsync(
|
||||||
|
string productSlug,
|
||||||
|
string serverBaseUrl,
|
||||||
|
bool allowPrompt = true,
|
||||||
|
int maxAttempts = 3,
|
||||||
|
string? appVersion = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
string? key = TryGetCachedKey(productSlug);
|
||||||
|
LicenseValidationResult? last = null;
|
||||||
|
|
||||||
|
for (int attempt = 0; attempt < Math.Max(1, maxAttempts); attempt++)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(key))
|
||||||
|
{
|
||||||
|
if (!allowPrompt)
|
||||||
|
{
|
||||||
|
_prompt.ShowLicenseError(
|
||||||
|
"Keine Lizenz hinterlegt",
|
||||||
|
$"Fuer \"{productSlug}\" liegt kein Schluessel vor, und es kann nicht danach gefragt werden.");
|
||||||
|
|
||||||
|
return last ?? new LicenseValidationResult
|
||||||
|
{
|
||||||
|
IsValid = false,
|
||||||
|
Status = "no_license",
|
||||||
|
Message = "Kein Lizenzschluessel hinterlegt."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
key = await _prompt.RequestLicenseKeyAsync(productSlug).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(key))
|
||||||
|
{
|
||||||
|
// Abbruch durch den Benutzer - erneutes Fragen waere
|
||||||
|
// Bevormundung.
|
||||||
|
return new LicenseValidationResult
|
||||||
|
{
|
||||||
|
IsValid = false,
|
||||||
|
Status = "no_license",
|
||||||
|
Message = "Es wurde kein Lizenzschluessel eingegeben."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
last = await ValidateAsync(productSlug, key!, serverBaseUrl, appVersion, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (last.IsValid)
|
||||||
|
{
|
||||||
|
_prompt.ShowLicenseInfo("Lizenz gueltig", last.Message);
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Netzwerk- oder Serverproblem ist kein Grund, den Schluessel
|
||||||
|
// erneut abzufragen - er ist ja nicht falsch.
|
||||||
|
if (last.IsTransient)
|
||||||
|
{
|
||||||
|
_prompt.ShowLicenseError("Lizenzpruefung nicht moeglich", last.Message);
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
|
||||||
|
_prompt.ShowLicenseError("Lizenz abgelehnt", last.Message);
|
||||||
|
key = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return last ?? new LicenseValidationResult
|
||||||
|
{
|
||||||
|
IsValid = false,
|
||||||
|
Status = "invalid",
|
||||||
|
Message = "Lizenzpruefung fehlgeschlagen."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public static string? TryGetCachedKey(string productSlug)
|
public static string? TryGetCachedKey(string productSlug)
|
||||||
{
|
{
|
||||||
var cache = TryGetCachedState(productSlug);
|
var cache = TryGetCachedState(productSlug);
|
||||||
|
|||||||
@@ -31,8 +31,13 @@ public static class LicenseConfig
|
|||||||
productSlug = "default_app";
|
productSlug = "default_app";
|
||||||
|
|
||||||
// 1. Explicitly set property
|
// 1. Explicitly set property
|
||||||
|
//
|
||||||
|
// Auch hier wird <slug>/license angehaengt - genau wie beim Weg ueber
|
||||||
|
// die Umgebungsvariable. Zuvor wurde der gesetzte Pfad roh benutzt:
|
||||||
|
// Zwei Produkte im selben Prozess mit gesetztem Override schrieben
|
||||||
|
// damit in dieselbe state.dat und ueberschrieben sich gegenseitig.
|
||||||
if (!string.IsNullOrWhiteSpace(_storageDirectoryOverride))
|
if (!string.IsNullOrWhiteSpace(_storageDirectoryOverride))
|
||||||
return ValidateNonEmpty(_storageDirectoryOverride!);
|
return ValidateNonEmpty(Path.Combine(_storageDirectoryOverride!, productSlug, "license"));
|
||||||
|
|
||||||
// 2. Environment Variable
|
// 2. Environment Variable
|
||||||
var envDir = Environment.GetEnvironmentVariable("DEPLOYMENTCENTER_STORAGE_DIR")
|
var envDir = Environment.GetEnvironmentVariable("DEPLOYMENTCENTER_STORAGE_DIR")
|
||||||
@@ -50,6 +55,21 @@ public static class LicenseConfig
|
|||||||
var home = Environment.GetEnvironmentVariable("HOME");
|
var home = Environment.GetEnvironmentVariable("HOME");
|
||||||
if (!string.IsNullOrWhiteSpace(home))
|
if (!string.IsNullOrWhiteSpace(home))
|
||||||
return ValidateNonEmpty(Path.Combine(home!, ".config", productSlug, "license"));
|
return ValidateNonEmpty(Path.Combine(home!, ".config", productSlug, "license"));
|
||||||
|
|
||||||
|
// Dienst ohne Heimatverzeichnis.
|
||||||
|
//
|
||||||
|
// Eine systemd-Unit mit User= und ohne eigenes $HOME hat weder
|
||||||
|
// XDG_CONFIG_HOME noch HOME. Der Rueckfall landete dann im
|
||||||
|
// Installationsverzeichnis - unter /opt fuer den Dienstbenutzer
|
||||||
|
// typischerweise nicht beschreibbar. Ergebnis: kein Offline-Cache,
|
||||||
|
// ausgerechnet im kopflosen Betrieb, fuer den er gedacht ist.
|
||||||
|
//
|
||||||
|
// /var/lib ist der vorgesehene Ort fuer veraenderlichen Zustand
|
||||||
|
// eines Dienstes. Genommen wird er nur, wenn er auch beschreibbar
|
||||||
|
// ist - sonst waere es derselbe stille Fehlschlag an anderer Stelle.
|
||||||
|
var varLib = Path.Combine("/var", "lib", productSlug, "license");
|
||||||
|
if (CanWriteTo(varLib))
|
||||||
|
return ValidateNonEmpty(varLib);
|
||||||
}
|
}
|
||||||
else if (OperatingSystemHelpers.IsWindows())
|
else if (OperatingSystemHelpers.IsWindows())
|
||||||
{
|
{
|
||||||
@@ -71,6 +91,29 @@ public static class LicenseConfig
|
|||||||
_storageDirectoryOverride = path;
|
_storageDirectoryOverride = path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Laesst sich dort wirklich schreiben? Ein Pfad, den man nur zurueckgibt,
|
||||||
|
/// weil er ueblich ist, hilft nicht weiter - der Fehlschlag faellt dann
|
||||||
|
/// erst beim Speichern auf.
|
||||||
|
/// </summary>
|
||||||
|
private static bool CanWriteTo(string directory)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
|
||||||
|
string probe = Path.Combine(directory, ".dc-write-test");
|
||||||
|
File.WriteAllText(probe, string.Empty);
|
||||||
|
File.Delete(probe);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static string ValidateNonEmpty(string path)
|
private static string ValidateNonEmpty(string path)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(path))
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
|||||||
@@ -43,10 +43,58 @@ namespace Deploymentcenter.Client.Models
|
|||||||
/// <summary>Eine Datei, in die Werte geschrieben werden.</summary>
|
/// <summary>Eine Datei, in die Werte geschrieben werden.</summary>
|
||||||
public class SetupTarget
|
public class SetupTarget
|
||||||
{
|
{
|
||||||
/// <summary>Pfad relativ zum Installationsverzeichnis.</summary>
|
/// <summary>
|
||||||
|
/// Pfad, bezogen auf <see cref="Location"/>. Umgebungsvariablen sind
|
||||||
|
/// in beiden Schreibweisen erlaubt: <c>${XDG_CONFIG_HOME}</c> und
|
||||||
|
/// <c>%APPDATA%</c>. Ein absoluter Pfad wird unveraendert benutzt.
|
||||||
|
/// </summary>
|
||||||
[JsonPropertyName("file")]
|
[JsonPropertyName("file")]
|
||||||
public string File { get; set; } = string.Empty;
|
public string File { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Abweichender Pfad unter Windows.
|
||||||
|
///
|
||||||
|
/// Konfigurationsordner heissen plattformabhaengig unterschiedlich:
|
||||||
|
/// unter Windows ist "%APPDATA%\MeineAnwendung" ueblich, unter Linux
|
||||||
|
/// "$XDG_CONFIG_HOME/meineanwendung" klein. Meist genuegt es, die
|
||||||
|
/// kleingeschriebene Form anzugeben - NTFS unterscheidet nicht nach
|
||||||
|
/// Gross- und Kleinschreibung, umgekehrt entstuende unter Linux ein
|
||||||
|
/// zweites, leeres Verzeichnis neben dem, aus dem die Anwendung liest.
|
||||||
|
///
|
||||||
|
/// Das traegt aber nur, solange das Dateisystem tatsaechlich
|
||||||
|
/// unempfindlich ist - auf APFS mit Beachtung der Schreibweise oder
|
||||||
|
/// einem per fsutil umgestellten NTFS-Ordner nicht mehr. Und es hilft
|
||||||
|
/// gar nicht, wenn sich die Namen um mehr als die Schreibweise
|
||||||
|
/// unterscheiden. Fuer diese Faelle sind die folgenden Felder da; ohne
|
||||||
|
/// Angabe gilt <see cref="File"/>.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("fileWindows")]
|
||||||
|
public string FileWindows { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Abweichender Pfad unter Linux.</summary>
|
||||||
|
[JsonPropertyName("fileLinux")]
|
||||||
|
public string FileLinux { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Abweichender Pfad unter macOS.</summary>
|
||||||
|
[JsonPropertyName("fileMacOS")]
|
||||||
|
public string FileMacOS { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wo die Datei liegt:
|
||||||
|
///
|
||||||
|
/// install (Vorgabe) - im Installationsverzeichnis
|
||||||
|
/// config - %APPDATA% bzw. $XDG_CONFIG_HOME (~/.config)
|
||||||
|
/// data - %LOCALAPPDATA% bzw. $XDG_DATA_HOME
|
||||||
|
/// home - Benutzerverzeichnis
|
||||||
|
///
|
||||||
|
/// Anwendungen, die sich unter Linux richtig verhalten, legen ihre
|
||||||
|
/// Konfiguration nicht neben das Programm - /opt/<app> ist fuer den
|
||||||
|
/// Dienstbenutzer meist nicht schreibbar. Ohne diese Angabe schriebe
|
||||||
|
/// der Installer dorthin, wo die Anwendung nie nachsieht.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("location")]
|
||||||
|
public string Location { get; set; } = "install";
|
||||||
|
|
||||||
/// <summary>"json" oder "env".</summary>
|
/// <summary>"json" oder "env".</summary>
|
||||||
[JsonPropertyName("format")]
|
[JsonPropertyName("format")]
|
||||||
public string Format { get; set; } = "json";
|
public string Format { get; set; } = "json";
|
||||||
|
|||||||
@@ -18,27 +18,63 @@ namespace Deploymentcenter.Client
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ReleaseCredentials
|
public sealed class ReleaseCredentials
|
||||||
{
|
{
|
||||||
private ReleaseCredentials(string user, string password)
|
private ReleaseCredentials(string user, string password, bool fromLicense)
|
||||||
{
|
{
|
||||||
User = user;
|
User = user;
|
||||||
Password = password;
|
Password = password;
|
||||||
|
FromLicense = fromLicense;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string User { get; }
|
public string User { get; }
|
||||||
|
|
||||||
public string Password { get; }
|
public string Password { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stammt der Zugang aus einem Lizenzschluessel oder aus einem
|
||||||
|
/// Installationskonto? Nur fuer die Fehlermeldung von Belang - ein
|
||||||
|
/// abgelehntes Konto hat andere Ursachen als eine abgelehnte Lizenz,
|
||||||
|
/// und wer die falschen genannt bekommt, sucht an der falschen Stelle.
|
||||||
|
/// </summary>
|
||||||
|
public bool FromLicense { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Zugang ueber den Lizenzschluessel.
|
/// Zugang ueber den Lizenzschluessel.
|
||||||
///
|
///
|
||||||
/// Benutzername und Passwort sind beide der Schluessel: Basic Auth
|
/// Der Benutzername wird aus dem Schluessel abgeleitet, das Passwort
|
||||||
/// verlangt zwei Felder, es gibt aber nur ein Geheimnis, und
|
/// ist der Schluessel selbst. Der Grund liegt im htpasswd-Format:
|
||||||
/// Benutzernamen muessen innerhalb einer .htpasswd eindeutig sein.
|
/// gehasht wird dort nur die Passwortspalte. Stuende der Schluessel
|
||||||
|
/// auch als Benutzername in der Datei, waere sie eine vollstaendige
|
||||||
|
/// Klartext-Kundenliste und der Hash daneben blosse Dekoration.
|
||||||
|
///
|
||||||
|
/// MUSS zeichengenau mit ReleaseGuard::licenseUsername() auf dem
|
||||||
|
/// Server uebereinstimmen.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static ReleaseCredentials? FromLicenseKey(string? licenseKey)
|
public static ReleaseCredentials? FromLicenseKey(string? licenseKey)
|
||||||
{
|
{
|
||||||
string key = (licenseKey ?? string.Empty).Trim();
|
string key = (licenseKey ?? string.Empty).Trim();
|
||||||
return key.Length == 0 ? null : new ReleaseCredentials(key, key);
|
|
||||||
|
if (key.Length == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return new ReleaseCredentials(UsernameForLicenseKey(key), key, fromLicense: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ableitung des Benutzernamens: "lic_" plus die ersten 16 Hexzeichen
|
||||||
|
/// des SHA-256 ueber den Schluessel.
|
||||||
|
/// </summary>
|
||||||
|
public static string UsernameForLicenseKey(string licenseKey)
|
||||||
|
{
|
||||||
|
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||||
|
byte[] hash = sha256.ComputeHash(Encoding.UTF8.GetBytes((licenseKey ?? string.Empty).Trim()));
|
||||||
|
|
||||||
|
var builder = new StringBuilder("lic_", 20);
|
||||||
|
for (int i = 0; i < 8; i++)
|
||||||
|
{
|
||||||
|
builder.Append(hash[i].ToString("x2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Zugang ueber ein Installationskonto.</summary>
|
/// <summary>Zugang ueber ein Installationskonto.</summary>
|
||||||
@@ -47,7 +83,7 @@ namespace Deploymentcenter.Client
|
|||||||
string u = (user ?? string.Empty).Trim();
|
string u = (user ?? string.Empty).Trim();
|
||||||
string p = password ?? string.Empty;
|
string p = password ?? string.Empty;
|
||||||
|
|
||||||
return u.Length == 0 ? null : new ReleaseCredentials(u, p);
|
return u.Length == 0 ? null : new ReleaseCredentials(u, p, fromLicense: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Wert fuer den Authorization-Header.</summary>
|
/// <summary>Wert fuer den Authorization-Header.</summary>
|
||||||
@@ -86,6 +122,17 @@ namespace Deploymentcenter.Client
|
|||||||
+ "Erwartet wird der Lizenzschluessel dieser Installation.";
|
+ "Erwartet wird der Lizenzschluessel dieser Installation.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!credentials.FromLicense)
|
||||||
|
{
|
||||||
|
// Bei einer Erstinstallation gibt es noch gar keine Lizenz -
|
||||||
|
// von abgelaufenen Vertraegen zu sprechen schickt den Menschen
|
||||||
|
// davor in die falsche Richtung.
|
||||||
|
return "Die Release-Ablage hat das Konto '" + credentials.User + "' abgelehnt. "
|
||||||
|
+ "Zugang haben nur Konten der Rollen installer und admin, die nicht "
|
||||||
|
+ "abgeschaltet sind. Der Zugangsschutz wird auf dem Server erzeugt und "
|
||||||
|
+ "kennt Kontoaenderungen erst nach dem naechsten Lauf.";
|
||||||
|
}
|
||||||
|
|
||||||
return "Die Release-Ablage hat die Zugangsdaten abgelehnt. Moegliche Gruende: die Lizenz "
|
return "Die Release-Ablage hat die Zugangsdaten abgelehnt. Moegliche Gruende: die Lizenz "
|
||||||
+ "ist abgelaufen, wurde widerrufen oder gehoert zu einem anderen Produkt.";
|
+ "ist abgelaufen, wurde widerrufen oder gehoert zu einem anderen Produkt.";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,31 @@ namespace Deploymentcenter.Client
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Fehler beim Ansprechen der Setup-Schnittstelle.</summary>
|
/// <summary>Fehler beim Ansprechen der Setup-Schnittstelle.</summary>
|
||||||
|
/// <summary>
|
||||||
|
/// Ein fertiger Watchdog-Agent fuer die Maschine, auf der installiert
|
||||||
|
/// wird: Skript, Dateiname und der Befehl, mit dem es sich einplanen
|
||||||
|
/// laesst. Das Token steckt bereits im Skript - es wird nur einmal
|
||||||
|
/// ausgeliefert und liegt danach nirgendwo sonst.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WatchdogAgent
|
||||||
|
{
|
||||||
|
public string Source { get; set; } = string.Empty;
|
||||||
|
public string Os { get; set; } = string.Empty;
|
||||||
|
public string FileName { get; set; } = string.Empty;
|
||||||
|
public string Script { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Einplanungsbefehl mit dem Platzhalter {PFAD} - der Ablageort steht
|
||||||
|
/// erst fest, wenn die Datei geschrieben ist.
|
||||||
|
/// </summary>
|
||||||
|
public string Schedule { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int IntervalSeconds { get; set; } = 60;
|
||||||
|
|
||||||
|
/// <summary>Gab es diesen Monitor schon? Dann wurde er uebernommen.</summary>
|
||||||
|
public bool Existed { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class SetupException : Exception
|
public sealed class SetupException : Exception
|
||||||
{
|
{
|
||||||
public SetupException(string message, string code = "") : base(message)
|
public SetupException(string message, string code = "") : base(message)
|
||||||
@@ -222,6 +247,63 @@ namespace Deploymentcenter.Client
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Laesst einen Watchdog-Agenten fuer diese Maschine ausstellen:
|
||||||
|
/// Monitor anlegen, Token erzeugen, passendes Skript bauen.
|
||||||
|
///
|
||||||
|
/// Der Name entscheidet, unter welcher Bezeichnung der Host im
|
||||||
|
/// Dashboard steht. Gibt es ihn schon, lehnt der Server mit
|
||||||
|
/// "monitor_exists" ab - eine zweite Maschine soll die Historie der
|
||||||
|
/// ersten nicht stillschweigend uebernehmen. Erst <paramref
|
||||||
|
/// name="overwrite"/> laesst das zu.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<WatchdogAgent> RequestWatchdogAgentAsync(
|
||||||
|
string project,
|
||||||
|
string source,
|
||||||
|
string os,
|
||||||
|
int intervalSeconds = 60,
|
||||||
|
bool overwrite = false,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
RequireSession();
|
||||||
|
|
||||||
|
var payload = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["project"] = project,
|
||||||
|
["source"] = source,
|
||||||
|
["os"] = os,
|
||||||
|
["interval"] = intervalSeconds,
|
||||||
|
["overwrite"] = overwrite
|
||||||
|
};
|
||||||
|
|
||||||
|
using var doc = await PostAsync("agent", payload, useToken: true, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
string script = GetString(root, "script");
|
||||||
|
|
||||||
|
if (script.Length == 0)
|
||||||
|
{
|
||||||
|
throw new SetupException("Der Server hat kein Agent-Skript geliefert.", "no_script");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WatchdogAgent
|
||||||
|
{
|
||||||
|
Source = GetString(root, "source"),
|
||||||
|
Os = GetString(root, "os"),
|
||||||
|
FileName = GetString(root, "filename"),
|
||||||
|
Script = script,
|
||||||
|
Schedule = GetString(root, "schedule"),
|
||||||
|
IntervalSeconds = root.TryGetProperty("interval", out var interval)
|
||||||
|
&& interval.TryGetInt32(out int parsed)
|
||||||
|
? parsed
|
||||||
|
: intervalSeconds,
|
||||||
|
Existed = root.TryGetProperty("existing", out var existed)
|
||||||
|
&& existed.ValueKind == JsonValueKind.True
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
private void RequireSession()
|
private void RequireSession()
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using Deploymentcenter.Client.Models;
|
||||||
|
|
||||||
|
namespace Deploymentcenter.Client
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Loest die Zielpfade einer <c>setup.json</c> plattformrichtig auf.
|
||||||
|
///
|
||||||
|
/// Ohne das schrieb der Installer ausschliesslich ins
|
||||||
|
/// Installationsverzeichnis. Eine Anwendung, die sich unter Linux richtig
|
||||||
|
/// verhaelt, liest ihre Konfiguration aber aus $XDG_CONFIG_HOME und unter
|
||||||
|
/// Windows aus %APPDATA% - schon deshalb, weil /opt/<app> fuer den
|
||||||
|
/// Dienstbenutzer typischerweise nicht schreibbar ist. Der Installer legte
|
||||||
|
/// die Datei also dorthin, wo die Anwendung nie nachsieht.
|
||||||
|
/// </summary>
|
||||||
|
public static class SetupPaths
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Ermittelt den vollstaendigen Pfad eines Ziels.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="target">Ziel aus der setup.json.</param>
|
||||||
|
/// <param name="installDir">Installationsverzeichnis der Anwendung.</param>
|
||||||
|
public static string Resolve(SetupTarget target, string installDir)
|
||||||
|
{
|
||||||
|
string file = Expand(FileFor(target));
|
||||||
|
|
||||||
|
// Ein absoluter Pfad ist bereits die Antwort - etwa nachdem
|
||||||
|
// ${XDG_CONFIG_HOME} ersetzt wurde.
|
||||||
|
if (Path.IsPathRooted(file))
|
||||||
|
{
|
||||||
|
return Path.GetFullPath(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
string root = BaseDirectory(target?.Location, installDir);
|
||||||
|
return Path.GetFullPath(Path.Combine(root, file.Replace('/', Path.DirectorySeparatorChar)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Waehlt den Pfad fuer die laufende Plattform.
|
||||||
|
///
|
||||||
|
/// Konfigurationsordner heissen plattformabhaengig unterschiedlich -
|
||||||
|
/// "ClawdDotNet" unter Windows, "clawddotnet" unter Linux. Die
|
||||||
|
/// kleingeschriebene Form allein traegt nur, solange das Dateisystem
|
||||||
|
/// die Schreibweise ignoriert; auf einem case-sensitiven Volume
|
||||||
|
/// entstuende sonst ein zweites, leeres Verzeichnis neben dem, aus dem
|
||||||
|
/// die Anwendung liest.
|
||||||
|
/// </summary>
|
||||||
|
public static string FileFor(SetupTarget? target)
|
||||||
|
{
|
||||||
|
if (target == null)
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
string specific = OperatingSystemHelpers.IsWindows() ? target.FileWindows
|
||||||
|
: OperatingSystemHelpers.IsMacOS() ? target.FileMacOS
|
||||||
|
: target.FileLinux;
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(specific) ? (target.File ?? string.Empty) : specific;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wurzelverzeichnis fuer eine Ortsangabe.
|
||||||
|
///
|
||||||
|
/// install (Vorgabe) - das Installationsverzeichnis
|
||||||
|
/// config - %APPDATA% bzw. $XDG_CONFIG_HOME (~/.config)
|
||||||
|
/// data - %LOCALAPPDATA% bzw. $XDG_DATA_HOME (~/.local/share)
|
||||||
|
/// home - Benutzerverzeichnis
|
||||||
|
/// </summary>
|
||||||
|
public static string BaseDirectory(string? location, string installDir)
|
||||||
|
{
|
||||||
|
switch ((location ?? string.Empty).Trim().ToLowerInvariant())
|
||||||
|
{
|
||||||
|
case "config":
|
||||||
|
return ConfigDirectory();
|
||||||
|
|
||||||
|
case "data":
|
||||||
|
return DataDirectory();
|
||||||
|
|
||||||
|
case "home":
|
||||||
|
return HomeDirectory();
|
||||||
|
|
||||||
|
case "":
|
||||||
|
case "install":
|
||||||
|
return installDir;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Eine unbekannte Angabe ins Installationsverzeichnis
|
||||||
|
// umzubiegen ist die harmlosere Auslegung: dort landet die
|
||||||
|
// Datei sichtbar statt an einem geratenen Ort.
|
||||||
|
return installDir;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ConfigDirectory()
|
||||||
|
{
|
||||||
|
if (OperatingSystemHelpers.IsWindows())
|
||||||
|
{
|
||||||
|
string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
||||||
|
return string.IsNullOrWhiteSpace(appData)
|
||||||
|
? Path.Combine(HomeDirectory(), "AppData", "Roaming")
|
||||||
|
: appData;
|
||||||
|
}
|
||||||
|
|
||||||
|
string xdg = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME") ?? string.Empty;
|
||||||
|
return string.IsNullOrWhiteSpace(xdg)
|
||||||
|
? Path.Combine(HomeDirectory(), ".config")
|
||||||
|
: xdg;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string DataDirectory()
|
||||||
|
{
|
||||||
|
if (OperatingSystemHelpers.IsWindows())
|
||||||
|
{
|
||||||
|
string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||||
|
return string.IsNullOrWhiteSpace(localAppData)
|
||||||
|
? Path.Combine(HomeDirectory(), "AppData", "Local")
|
||||||
|
: localAppData;
|
||||||
|
}
|
||||||
|
|
||||||
|
string xdg = Environment.GetEnvironmentVariable("XDG_DATA_HOME") ?? string.Empty;
|
||||||
|
return string.IsNullOrWhiteSpace(xdg)
|
||||||
|
? Path.Combine(HomeDirectory(), ".local", "share")
|
||||||
|
: xdg;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string HomeDirectory()
|
||||||
|
{
|
||||||
|
string home = Environment.GetEnvironmentVariable("HOME") ?? string.Empty;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(home))
|
||||||
|
{
|
||||||
|
home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(home) ? Directory.GetCurrentDirectory() : home;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ersetzt Umgebungsvariablen in beiden gebraeuchlichen Schreibweisen:
|
||||||
|
/// <c>${NAME}</c> und <c>%NAME%</c>. Unbekannte Namen bleiben stehen -
|
||||||
|
/// eine leere Ersetzung wuerde stillschweigend einen falschen Pfad
|
||||||
|
/// ergeben, ein stehengebliebener Platzhalter faellt dagegen auf.
|
||||||
|
/// </summary>
|
||||||
|
public static string Expand(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var builder = new StringBuilder(value.Length);
|
||||||
|
|
||||||
|
for (int i = 0; i < value.Length; i++)
|
||||||
|
{
|
||||||
|
// ${NAME}
|
||||||
|
if (value[i] == '$' && i + 1 < value.Length && value[i + 1] == '{')
|
||||||
|
{
|
||||||
|
int close = value.IndexOf('}', i + 2);
|
||||||
|
if (close > i + 2)
|
||||||
|
{
|
||||||
|
string name = value.Substring(i + 2, close - i - 2);
|
||||||
|
string? resolved = Lookup(name);
|
||||||
|
|
||||||
|
if (resolved != null)
|
||||||
|
{
|
||||||
|
builder.Append(resolved);
|
||||||
|
i = close;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// %NAME%
|
||||||
|
if (value[i] == '%')
|
||||||
|
{
|
||||||
|
int close = value.IndexOf('%', i + 1);
|
||||||
|
if (close > i + 1)
|
||||||
|
{
|
||||||
|
string name = value.Substring(i + 1, close - i - 1);
|
||||||
|
string? resolved = Lookup(name);
|
||||||
|
|
||||||
|
if (resolved != null)
|
||||||
|
{
|
||||||
|
builder.Append(resolved);
|
||||||
|
i = close;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.Append(value[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Schlaegt einen Namen nach. Neben echten Umgebungsvariablen werden
|
||||||
|
/// die XDG-Namen auch dann beantwortet, wenn sie nicht gesetzt sind -
|
||||||
|
/// sonst muesste jede setup.json den Rueckfall auf ~/.config selbst
|
||||||
|
/// ausformulieren.
|
||||||
|
/// </summary>
|
||||||
|
private static string? Lookup(string name)
|
||||||
|
{
|
||||||
|
string value = Environment.GetEnvironmentVariable(name) ?? string.Empty;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(value))
|
||||||
|
return value;
|
||||||
|
|
||||||
|
switch (name.ToUpperInvariant())
|
||||||
|
{
|
||||||
|
case "XDG_CONFIG_HOME": return ConfigDirectory();
|
||||||
|
case "XDG_DATA_HOME": return DataDirectory();
|
||||||
|
case "HOME":
|
||||||
|
case "USERPROFILE": return HomeDirectory();
|
||||||
|
case "APPDATA": return ConfigDirectory();
|
||||||
|
case "LOCALAPPDATA": return DataDirectory();
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,11 +12,14 @@ namespace Deploymentcenter.Client
|
|||||||
/// <summary>Ein zu schreibender Konfigurationswert.</summary>
|
/// <summary>Ein zu schreibender Konfigurationswert.</summary>
|
||||||
public sealed class SetupValue
|
public sealed class SetupValue
|
||||||
{
|
{
|
||||||
public SetupValue(string key, string value, string type = "string")
|
private readonly bool? _isSecret;
|
||||||
|
|
||||||
|
public SetupValue(string key, string value, string type = "string", bool? isSecret = null)
|
||||||
{
|
{
|
||||||
Key = key;
|
Key = key;
|
||||||
Value = value;
|
Value = value;
|
||||||
Type = type;
|
Type = type;
|
||||||
|
_isSecret = isSecret;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Schluessel, Ebenen durch Doppelpunkt getrennt.</summary>
|
/// <summary>Schluessel, Ebenen durch Doppelpunkt getrennt.</summary>
|
||||||
@@ -26,6 +29,15 @@ namespace Deploymentcenter.Client
|
|||||||
|
|
||||||
/// <summary>string | secret | url | int | bool | enum</summary>
|
/// <summary>string | secret | url | int | bool | enum</summary>
|
||||||
public string Type { get; }
|
public string Type { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ist der Wert schutzbeduerftig? Ohne ausdrueckliche Angabe wird der
|
||||||
|
/// Typ herangezogen. Ein per <c>source: "provision"</c> geholtes Token
|
||||||
|
/// traegt haeufig den Typ "string" und muss deshalb gesondert
|
||||||
|
/// gekennzeichnet werden.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsSecret =>
|
||||||
|
_isSecret ?? string.Equals(Type, "secret", StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -137,13 +149,68 @@ namespace Deploymentcenter.Client
|
|||||||
/// <summary>Schreibt in das Format, das zum Ziel passt.</summary>
|
/// <summary>Schreibt in das Format, das zum Ziel passt.</summary>
|
||||||
public static void Write(string path, string format, IEnumerable<SetupValue> values)
|
public static void Write(string path, string format, IEnumerable<SetupValue> values)
|
||||||
{
|
{
|
||||||
|
var list = values as IList<SetupValue> ?? new List<SetupValue>(values);
|
||||||
|
|
||||||
if (string.Equals(format, "env", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(format, "env", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
WriteEnv(path, values);
|
WriteEnv(path, list);
|
||||||
return;
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
WriteJson(path, list);
|
||||||
}
|
}
|
||||||
|
|
||||||
WriteJson(path, values);
|
// Enthaelt die Datei Geheimnisse, gehoert sie niemandem sonst.
|
||||||
|
foreach (var value in list)
|
||||||
|
{
|
||||||
|
if (value.IsSecret)
|
||||||
|
{
|
||||||
|
RestrictPermissions(path);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Beschraenkt die Datei auf den eigenen Benutzer.
|
||||||
|
///
|
||||||
|
/// Ohne das schreibt .NET mit der Standardmaske - unter Linux
|
||||||
|
/// ueblicherweise 644, also weltlesbar. In dieser Datei stehen der
|
||||||
|
/// Lizenzschluessel und das Token der Anwendung; jeder Benutzer des
|
||||||
|
/// Systems koennte sie lesen. Der Lizenz-Cache in
|
||||||
|
/// <see cref="StateStore"/> wird aus demselben Grund auf 600 gesetzt.
|
||||||
|
///
|
||||||
|
/// Unter Windows erben Dateien in %APPDATA% die Zugriffsrechte des
|
||||||
|
/// Profils; dort ist nichts zu tun.
|
||||||
|
/// </summary>
|
||||||
|
public static void RestrictPermissions(string path)
|
||||||
|
{
|
||||||
|
if (OperatingSystemHelpers.IsWindows())
|
||||||
|
return;
|
||||||
|
|
||||||
|
#if NET8_0_OR_GREATER
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Auf Dateisystemen ohne Unix-Rechte schlicht nicht anwendbar.
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var proc = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "chmod",
|
||||||
|
Arguments = $"600 \"{path}\"",
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true
|
||||||
|
});
|
||||||
|
proc?.WaitForExit();
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -123,6 +123,17 @@ namespace Deploymentcenter.Client
|
|||||||
response = await _httpClient.GetAsync(apiUrl, cancellationToken).ConfigureAwait(false);
|
response = await _httpClient.GetAsync(apiUrl, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auch der API-Zweig kann 401 liefern - etwa wenn die
|
||||||
|
// Lese-Endpunkte spaeter ebenfalls hinter den Zugangsschutz
|
||||||
|
// wandern. Zuvor wurde das nur auf dem statischen Weg erkannt
|
||||||
|
// und hier als gewoehnlicher HTTP-Fehler gemeldet.
|
||||||
|
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||||
|
{
|
||||||
|
result.Unauthorized = true;
|
||||||
|
result.Message = ReleaseCredentials.DescribeUnauthorized(credentials);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
result.Message = $"Update-Pruefung fehlgeschlagen: HTTP {(int)response.StatusCode}";
|
result.Message = $"Update-Pruefung fehlgeschlagen: HTTP {(int)response.StatusCode}";
|
||||||
@@ -327,6 +338,15 @@ namespace Deploymentcenter.Client
|
|||||||
/// Anwendung abwartet, bevor er Dateien ersetzt. Ohne diesen Handschlag
|
/// Anwendung abwartet, bevor er Dateien ersetzt. Ohne diesen Handschlag
|
||||||
/// kopiert er bei langsamem Herunterfahren ueber gesperrte Dateien.
|
/// kopiert er bei langsamem Herunterfahren ueber gesperrte Dateien.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="requireSignature">
|
||||||
|
/// Reicht <c>--require-signature</c> an den Agenten durch. Ohne diesen
|
||||||
|
/// Parameter gab es dafuer nur den Kommandozeilenschalter - jede
|
||||||
|
/// Anwendung, die dem Leitfaden folgt und ueber diese Methode
|
||||||
|
/// startet, aktualisierte damit ungeprueft, waehrend derselbe Aufruf
|
||||||
|
/// von Hand auf der Kommandozeile geschuetzt gewesen waere. Den
|
||||||
|
/// oeffentlichen Schluessel muss dafuer niemand selbst verwalten: der
|
||||||
|
/// Agent holt und cached ihn ohnehin von <c>/api/updateservice/v1/pubkey</c>.
|
||||||
|
/// </param>
|
||||||
public static bool LaunchUpdateAgent(
|
public static bool LaunchUpdateAgent(
|
||||||
string agentPath,
|
string agentPath,
|
||||||
string projectId,
|
string projectId,
|
||||||
@@ -339,7 +359,9 @@ namespace Deploymentcenter.Client
|
|||||||
string? currentVersion = null,
|
string? currentVersion = null,
|
||||||
string? platform = null,
|
string? platform = null,
|
||||||
bool waitForCurrentProcess = true,
|
bool waitForCurrentProcess = true,
|
||||||
string? licenseKey = null)
|
string? licenseKey = null,
|
||||||
|
int waitTimeoutSeconds = 60,
|
||||||
|
bool requireSignature = false)
|
||||||
{
|
{
|
||||||
if (!File.Exists(agentPath))
|
if (!File.Exists(agentPath))
|
||||||
{
|
{
|
||||||
@@ -361,17 +383,37 @@ namespace Deploymentcenter.Client
|
|||||||
AppendArg(args, "--current-version", currentVersion!);
|
AppendArg(args, "--current-version", currentVersion!);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ohne den Schluessel kommt der Agent nicht mehr an die Pakete -
|
// Der Lizenzschluessel wird BEWUSST NICHT als Argument uebergeben.
|
||||||
// die Release-Ablage verlangt seit Version 2.4 Zugangsdaten.
|
//
|
||||||
|
// Was in argv steht, zeigt "ps" jedem Benutzer der Maschine. Aus
|
||||||
|
// genau diesem Grund verwirft UPGRADE.md §5 den Weg, den Shared
|
||||||
|
// Key im Crontab zu hinterlegen - fuer einen Lizenzschluessel
|
||||||
|
// gilt dasselbe.
|
||||||
|
//
|
||||||
|
// Stattdessen wird die Variable auf dem eigenen Prozess gesetzt;
|
||||||
|
// der Agent erbt den Umgebungsblock beim Start und liest sie dort.
|
||||||
|
// Das funktioniert auch mit UseShellExecute=true, wo sich
|
||||||
|
// ProcessStartInfo.Environment nicht setzen laesst.
|
||||||
|
bool licenseKeySet = false;
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(licenseKey))
|
if (!string.IsNullOrWhiteSpace(licenseKey))
|
||||||
{
|
{
|
||||||
AppendArg(args, "--license-key", licenseKey!);
|
Environment.SetEnvironmentVariable("DC_LICENSE_KEY", licenseKey);
|
||||||
|
licenseKeySet = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (waitForCurrentProcess)
|
if (waitForCurrentProcess)
|
||||||
{
|
{
|
||||||
AppendArg(args, "--wait-for-pid",
|
AppendArg(args, "--wait-for-pid",
|
||||||
Process.GetCurrentProcess().Id.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
Process.GetCurrentProcess().Id.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
|
||||||
|
// Ohne diesen Wert galten fest 60 Sekunden. Eine Anwendung,
|
||||||
|
// die allein fuer host.StopAsync 30 Sekunden braucht, kommt
|
||||||
|
// damit gefaehrlich nah an die Grenze - und laeuft sie ab,
|
||||||
|
// bricht der Agent ab, statt ueber gesperrte Dateien zu
|
||||||
|
// schreiben. Sicher, aber eben auch kein Update.
|
||||||
|
AppendArg(args, "--wait-timeout",
|
||||||
|
Math.Max(5, waitTimeoutSeconds).ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ohne ausdruecklichen Pfad die eigene ausfuehrbare Datei.
|
// Ohne ausdruecklichen Pfad die eigene ausfuehrbare Datei.
|
||||||
@@ -386,6 +428,11 @@ namespace Deploymentcenter.Client
|
|||||||
AppendArg(args, "--restart", restart!);
|
AppendArg(args, "--restart", restart!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (requireSignature)
|
||||||
|
{
|
||||||
|
AppendFlag(args, "--require-signature");
|
||||||
|
}
|
||||||
|
|
||||||
var startInfo = new ProcessStartInfo
|
var startInfo = new ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = agentPath,
|
FileName = agentPath,
|
||||||
@@ -394,10 +441,31 @@ namespace Deploymentcenter.Client
|
|||||||
UseShellExecute = true
|
UseShellExecute = true
|
||||||
};
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
Process.Start(startInfo);
|
Process.Start(startInfo);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Nach dem Start wieder entfernen: Der Schluessel soll nur so
|
||||||
|
// lange im eigenen Umgebungsblock stehen, wie er zum Vererben
|
||||||
|
// gebraucht wird.
|
||||||
|
if (licenseKeySet)
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable("DC_LICENSE_KEY", null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (exitCurrentApp)
|
if (exitCurrentApp)
|
||||||
{
|
{
|
||||||
|
// Environment.Exit beendet den Prozess sofort: laufende
|
||||||
|
// finally-Bloecke anderer Threads, IHostApplicationLifetime
|
||||||
|
// und Destruktoren kommen nicht mehr zum Zug. Fuer eine
|
||||||
|
// Anwendung mit offenem Zustand - Positionen, Transaktionen,
|
||||||
|
// ungeschriebene Puffer - ist das die falsche Wahl. Dort
|
||||||
|
// gehoert exitCurrentApp:false gesetzt und danach der eigene
|
||||||
|
// geordnete Weg gegangen; der Agent wartet ohnehin auf das
|
||||||
|
// Prozessende.
|
||||||
Environment.Exit(0);
|
Environment.Exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,6 +483,12 @@ namespace Deploymentcenter.Client
|
|||||||
builder.Append("\" ");
|
builder.Append("\" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void AppendFlag(StringBuilder builder, string name)
|
||||||
|
{
|
||||||
|
builder.Append(name);
|
||||||
|
builder.Append(' ');
|
||||||
|
}
|
||||||
|
|
||||||
private static string? GetCurrentExecutablePath()
|
private static string? GetCurrentExecutablePath()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="FluentFTP" Version="54.1.2" />
|
<PackageReference Include="FluentFTP" Version="54.1.2" />
|
||||||
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using System.Net.Http;
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Deploymentcenter.Client;
|
using Deploymentcenter.Client;
|
||||||
using Deploymentcenter.Client.Models;
|
using Deploymentcenter.Client.Models;
|
||||||
@@ -266,7 +267,10 @@ namespace Deploymentcenter.Packager
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
WarnAboutUnprotectedSecrets(filteredFiles, publishDir, config);
|
if (!CheckForSecrets(filteredFiles, publishDir, config, HasFlag(args, "--allow-secrets")))
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
// Paket bauen
|
// Paket bauen
|
||||||
@@ -798,6 +802,197 @@ namespace Deploymentcenter.Packager
|
|||||||
/// Publish-Verzeichnis liegt, wandert sonst ins Paket und ueberschreibt
|
/// Publish-Verzeichnis liegt, wandert sonst ins Paket und ueberschreibt
|
||||||
/// beim Update die Konfiguration jedes Zielsystems.
|
/// beim Update die Konfiguration jedes Zielsystems.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <summary>
|
||||||
|
/// Prueft die zu packenden Dateien auf Zugangsdaten und bricht ab,
|
||||||
|
/// wenn welche gefunden werden.
|
||||||
|
///
|
||||||
|
/// Frueher wurde nur gewarnt. Das hat nicht gereicht: Ein echter
|
||||||
|
/// OpenRouter-Schluessel lag dadurch in einem oeffentlich abrufbaren
|
||||||
|
/// Paket. Der Zugangsschutz macht das nicht harmlos - dann ist er eben
|
||||||
|
/// fuer jeden Lizenznehmer lesbar statt fuer alle.
|
||||||
|
///
|
||||||
|
/// Zwei Stufen: der Dateiname (eine appsettings.Local.json gehoert
|
||||||
|
/// nicht ins Paket) und der Inhalt (ein gefuelltes Password= oder ein
|
||||||
|
/// Schluessel im Format sk-... verraet sich selbst). Der zweite Teil
|
||||||
|
/// findet auch Dateien, deren Name unverdaechtig ist.
|
||||||
|
///
|
||||||
|
/// Rueckgabe false bedeutet: nicht weitermachen.
|
||||||
|
/// </summary>
|
||||||
|
static bool CheckForSecrets(List<string> files, string publishDir, PackagerConfig config, bool allowSecrets)
|
||||||
|
{
|
||||||
|
var byName = new List<string>();
|
||||||
|
var byContent = new List<(string Path, string Reason)>();
|
||||||
|
|
||||||
|
string[] suspiciousNames =
|
||||||
|
{
|
||||||
|
"appsettings.Local.json", "appsettings.*.Local.json", "*.local.json",
|
||||||
|
"secrets.json", "connectionstrings.json",
|
||||||
|
"master.key", "*.pfx", "*.key", "*.pem", "*.p12",
|
||||||
|
"server_settings.xml", "*.db", "*.sqlite", "*.sqlite3"
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
string relPath = Path.GetRelativePath(publishDir, file).Replace('\\', '/');
|
||||||
|
|
||||||
|
if (GlobMatcher.IsMatch(relPath, suspiciousNames))
|
||||||
|
{
|
||||||
|
byName.Add(relPath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? reason = FindSecretInContent(file);
|
||||||
|
if (reason != null)
|
||||||
|
{
|
||||||
|
byContent.Add((relPath, reason));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (byName.Count == 0 && byContent.Count == 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.ForegroundColor = allowSecrets ? ConsoleColor.Yellow : ConsoleColor.Red;
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine($"[{(allowSecrets ? "WARNUNG" : "ABBRUCH")}] Im Paket liegen mutmasslich Zugangsdaten:");
|
||||||
|
|
||||||
|
foreach (var name in byName)
|
||||||
|
{
|
||||||
|
Console.WriteLine($" - {name} (Dateiname)");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (path, reason) in byContent)
|
||||||
|
{
|
||||||
|
Console.WriteLine($" - {path} ({reason})");
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine(" Release-Pakete sind fuer jeden Lizenznehmer lesbar. Was hier steht,");
|
||||||
|
Console.WriteLine(" ist damit an alle Kunden ausgeliefert.");
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine(" - Gehoert die Datei gar nicht ins Paket: in excludePatterns aufnehmen");
|
||||||
|
Console.WriteLine(" und pruefen, warum sie im Publish-Verzeichnis liegt (haeufig eine");
|
||||||
|
Console.WriteLine(" CopyToOutputDirectory-Regel in der csproj).");
|
||||||
|
Console.WriteLine(" - Ist es eine Vorlage: die echten Werte durch Platzhalter ersetzen");
|
||||||
|
Console.WriteLine(" und sie in preservePatterns aufnehmen.");
|
||||||
|
|
||||||
|
if (!allowSecrets)
|
||||||
|
{
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine(" Bewusst gewollt? --allow-secrets");
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.ResetColor();
|
||||||
|
Console.WriteLine();
|
||||||
|
|
||||||
|
return allowSecrets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sucht in einer Textdatei nach Werten, die wie Zugangsdaten
|
||||||
|
/// aussehen. Binaerdateien und grosse Dateien werden uebersprungen -
|
||||||
|
/// dort waere die Trefferquote schlecht und die Laufzeit hoch.
|
||||||
|
/// </summary>
|
||||||
|
static string? FindSecretInContent(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var info = new FileInfo(path);
|
||||||
|
|
||||||
|
if (info.Length == 0 || info.Length > 512 * 1024)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
string extension = Path.GetExtension(path).ToLowerInvariant();
|
||||||
|
string[] textLike = { ".json", ".xml", ".config", ".ini", ".yml", ".yaml", ".env", ".txt", ".toml" };
|
||||||
|
|
||||||
|
if (Array.IndexOf(textLike, extension) < 0 && extension.Length != 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
string content = File.ReadAllText(path);
|
||||||
|
|
||||||
|
// Gefuelltes Passwortfeld - "Password=" allein ist eine leere
|
||||||
|
// Vorlage und damit in Ordnung.
|
||||||
|
var password = Regex.Match(content,
|
||||||
|
@"(?i)\b(password|passwort|pwd)\s*[=:]\s*""?([^""\s;,}]{4,})",
|
||||||
|
RegexOptions.CultureInvariant);
|
||||||
|
|
||||||
|
if (password.Success && !LooksLikePlaceholder(password.Groups[2].Value))
|
||||||
|
{
|
||||||
|
return "gefuelltes Passwortfeld";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verbreitete Schluesselformate.
|
||||||
|
//
|
||||||
|
// Auch hier wird der Treffer gegen die Platzhalterliste
|
||||||
|
// gehalten. Ohne das blockiert "sk-DEIN-SCHLUESSEL-HIER" in
|
||||||
|
// einer Vorlage jeden Build - und wer das erlebt, setzt
|
||||||
|
// --allow-secrets kuenftig gewohnheitsmaessig. Dann haette die
|
||||||
|
// Sperre genau nichts gebracht.
|
||||||
|
foreach (var (pattern, label) in new[]
|
||||||
|
{
|
||||||
|
(@"\bsk-[A-Za-z0-9_\-]{16,}", "API-Schluessel (sk-...)"),
|
||||||
|
(@"\bghp_[A-Za-z0-9]{20,}", "GitHub-Token"),
|
||||||
|
(@"\bdc_(master|sub|setup)_[0-9a-f]{16,}", "Deploymentcenter-Token"),
|
||||||
|
(@"\bAKIA[0-9A-Z]{16}\b", "AWS-Zugriffsschluessel"),
|
||||||
|
(@"-----BEGIN [A-Z ]*PRIVATE KEY-----", "privater Schluessel")
|
||||||
|
})
|
||||||
|
{
|
||||||
|
foreach (Match match in Regex.Matches(content, pattern, RegexOptions.CultureInvariant))
|
||||||
|
{
|
||||||
|
if (!LooksLikePlaceholder(match.Value))
|
||||||
|
{
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Nicht lesbar heisst nicht verdaechtig.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Erkennt Vorlagenwerte, damit eine ausgelieferte Musterdatei nicht
|
||||||
|
/// jeden Build blockiert.
|
||||||
|
/// </summary>
|
||||||
|
static bool LooksLikePlaceholder(string value)
|
||||||
|
{
|
||||||
|
string lower = value.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
if (lower.Length == 0)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
string[] markers =
|
||||||
|
{
|
||||||
|
"changeme", "change-me", "todo", "xxx", "platzhalter", "placeholder",
|
||||||
|
"your-", "dein-", "deine-", "<", "beispiel", "example", "secret-here",
|
||||||
|
"password", "schluessel-hier", "schlüssel-hier", "hier-einfuegen",
|
||||||
|
"replace-me", "insert-", "dummy", "sample"
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var marker in markers)
|
||||||
|
{
|
||||||
|
if (lower.Contains(marker))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Durchgehend Grossbuchstaben mit Bindestrichen und ohne Ziffern
|
||||||
|
// ist typisch fuer Platzhalter (SK-MEIN-SCHLUESSEL), aber nicht
|
||||||
|
// fuer echte Schluessel - die tragen praktisch immer Ziffern.
|
||||||
|
string body = value.Trim();
|
||||||
|
if (body.Length > 4 && body == body.ToUpperInvariant() && !body.Any(char.IsDigit))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
static void WarnAboutUnprotectedSecrets(List<string> files, string publishDir, PackagerConfig config)
|
static void WarnAboutUnprotectedSecrets(List<string> files, string publishDir, PackagerConfig config)
|
||||||
{
|
{
|
||||||
string[] suspicious =
|
string[] suspicious =
|
||||||
@@ -952,6 +1147,7 @@ namespace Deploymentcenter.Packager
|
|||||||
Console.WriteLine(" --main-assembly <datei> Assembly fuer die Versionsgegenprobe");
|
Console.WriteLine(" --main-assembly <datei> Assembly fuer die Versionsgegenprobe");
|
||||||
Console.WriteLine(" --ignore-version-mismatch Versionskonflikt nur als Warnung behandeln");
|
Console.WriteLine(" --ignore-version-mismatch Versionskonflikt nur als Warnung behandeln");
|
||||||
Console.WriteLine(" --allow-any-platform Warnung zu 'any' unterdruecken");
|
Console.WriteLine(" --allow-any-platform Warnung zu 'any' unterdruecken");
|
||||||
|
Console.WriteLine(" --allow-secrets Trotz gefundener Zugangsdaten veroeffentlichen");
|
||||||
Console.WriteLine(" --config <datei> Abweichende packager.config.json");
|
Console.WriteLine(" --config <datei> Abweichende packager.config.json");
|
||||||
Console.WriteLine(" --token <token> Token mit updateservice:publish");
|
Console.WriteLine(" --token <token> Token mit updateservice:publish");
|
||||||
Console.WriteLine(" --ftp-host/--ftp-user/--ftp-pass/--remote-dir");
|
Console.WriteLine(" --ftp-host/--ftp-user/--ftp-pass/--remote-dir");
|
||||||
|
|||||||
@@ -192,9 +192,42 @@ namespace Deploymentcenter.UpdateAgent
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// 5. Einrichten
|
// 5. Laeuft das hier ueberhaupt?
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
return await ConfigureAsync(options, client, chosen.Slug, interactive: true);
|
// Erst jetzt zu pruefen hat einen Grund: was die Anwendung
|
||||||
|
// braucht, steht in ihrer runtimeconfig.json - und die liegt erst
|
||||||
|
// vor, wenn die Dateien da sind. Vorher waere jede Aussage
|
||||||
|
// geraten, und ein eigenstaendig veroeffentlichtes Paket bekaeme
|
||||||
|
// eine Warnung, die auf es nicht zutrifft.
|
||||||
|
var runtime = RuntimeCheck.Inspect(targetDir);
|
||||||
|
bool runtimeOk = RuntimeCheck.Report(runtime);
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 6. Einrichten
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
int configured = await ConfigureAsync(options, client, chosen.Slug, interactive: true);
|
||||||
|
if (configured != 0)
|
||||||
|
{
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 7. Ueberwachung dieser Maschine
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
await Monitoring.OfferAsync(options, client, chosen.Slug, interactive: true);
|
||||||
|
|
||||||
|
// Zum Schluss noch einmal: zwischen der Meldung oben und dem Ende
|
||||||
|
// des Ablaufs liegen inzwischen Einrichtung und Ueberwachung, und
|
||||||
|
// eine fehlende Laufzeit ist das Einzige, was den Start dieser
|
||||||
|
// Anwendung sicher verhindert.
|
||||||
|
if (!runtimeOk)
|
||||||
|
{
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
AnsiConsole.MarkupLine("[bold yellow]Nicht vergessen: ohne die fehlende .NET-Laufzeit startet "
|
||||||
|
+ "die Anwendung nicht.[/]");
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -300,18 +333,27 @@ namespace Deploymentcenter.UpdateAgent
|
|||||||
{
|
{
|
||||||
var forTarget = resolved
|
var forTarget = resolved
|
||||||
.Where(pair => BelongsToTarget(pair.Field, target, targets))
|
.Where(pair => BelongsToTarget(pair.Field, target, targets))
|
||||||
.Select(pair => new SetupValue(pair.Field.Key, pair.Value, pair.Field.Type))
|
// IsSecret ausdruecklich weiterreichen: ein per provision
|
||||||
|
// geholtes Token traegt oft den Typ "string", muss die
|
||||||
|
// Datei aber trotzdem auf den eigenen Benutzer beschraenken.
|
||||||
|
.Select(pair => new SetupValue(
|
||||||
|
pair.Field.Key, pair.Value, pair.Field.Type, pair.Field.IsSecret))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
if (forTarget.Count == 0)
|
if (forTarget.Count == 0)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
string path = Path.Combine(options.TargetDir, target.File.Replace('/', Path.DirectorySeparatorChar));
|
string path = SetupPaths.Resolve(target, options.TargetDir);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
SetupWriter.Write(path, target.Format, forTarget);
|
SetupWriter.Write(path, target.Format, forTarget);
|
||||||
AnsiConsole.MarkupLine($"[green]Geschrieben:[/] {Markup.Escape(target.File)} "
|
|
||||||
|
// Den aufgeloesten Pfad ausgeben, nicht die Angabe aus der
|
||||||
|
// setup.json: Bei location=config entscheidet die Umgebung,
|
||||||
|
// und wer den Installer unter einem anderen Konto startet
|
||||||
|
// als den Dienst, muss das sehen koennen.
|
||||||
|
AnsiConsole.MarkupLine($"[green]Geschrieben:[/] {Markup.Escape(path)} "
|
||||||
+ $"[grey]({forTarget.Count} Wert(e))[/]");
|
+ $"[grey]({forTarget.Count} Wert(e))[/]");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -360,8 +402,9 @@ namespace Deploymentcenter.UpdateAgent
|
|||||||
var target = FindTarget(field, targets);
|
var target = FindTarget(field, targets);
|
||||||
if (target != null && string.Equals(target.Format, "json", StringComparison.OrdinalIgnoreCase))
|
if (target != null && string.Equals(target.Format, "json", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
string path = Path.Combine(options.TargetDir,
|
// Muss denselben Pfad ergeben wie beim Schreiben - sonst
|
||||||
target.File.Replace('/', Path.DirectorySeparatorChar));
|
// prueft der Installer eine andere Datei, als er anlegt.
|
||||||
|
string path = SetupPaths.Resolve(target, options.TargetDir);
|
||||||
|
|
||||||
string? existing = SetupWriter.ReadExistingJson(path, field.Key);
|
string? existing = SetupWriter.ReadExistingJson(path, field.Key);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,566 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Deploymentcenter.Client;
|
||||||
|
using Spectre.Console;
|
||||||
|
|
||||||
|
namespace Deploymentcenter.UpdateAgent
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Richtet die Ueberwachung der Maschine ein, auf der gerade installiert
|
||||||
|
/// wurde.
|
||||||
|
///
|
||||||
|
/// Bisher war das ein Weg ueber die Oberflaeche an einem anderen Rechner:
|
||||||
|
/// Monitor anlegen, Token erzeugen, Skript herunterladen, auf den Host
|
||||||
|
/// kopieren, einplanen. Fuenf Schritte fuer etwas, das genau dann ansteht,
|
||||||
|
/// wenn ohnehin jemand auf dem Zielsystem sitzt - entsprechend oft
|
||||||
|
/// unterblieb es, und die frisch installierte Anwendung lief auf einem
|
||||||
|
/// unbeobachteten Host.
|
||||||
|
///
|
||||||
|
/// Hier sind es zwei Fragen: Name und ob eingeplant werden soll. Alles
|
||||||
|
/// andere - Token, Monitor, Skript, Cron-Eintrag bzw. geplante Aufgabe -
|
||||||
|
/// entsteht daraus.
|
||||||
|
/// </summary>
|
||||||
|
internal static class Monitoring
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Bietet die Einrichtung an und fuehrt sie durch.
|
||||||
|
///
|
||||||
|
/// Rueckgabe false heisst nur "nicht eingerichtet" - nie, dass die
|
||||||
|
/// Installation gescheitert waere. Eine fehlende Ueberwachung darf
|
||||||
|
/// eine funktionierende Anwendung nicht in Frage stellen.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<bool> OfferAsync(
|
||||||
|
AgentOptions options,
|
||||||
|
SetupClient client,
|
||||||
|
string project,
|
||||||
|
bool interactive)
|
||||||
|
{
|
||||||
|
if (!interactive)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
AnsiConsole.Write(new Rule("[bold]Ueberwachung[/]").LeftJustified());
|
||||||
|
AnsiConsole.MarkupLine("[grey]Ein kleines Skript meldet dem Deploymentcenter minuetlich, dass diese "
|
||||||
|
+ "Maschine laeuft, und schickt Last, Speicher und Plattenbelegung mit.[/]");
|
||||||
|
|
||||||
|
if (!AnsiConsole.Confirm("Diese Maschine ueberwachen?"))
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine("[grey]Uebersprungen. Nachholbar mit [bold]--action monitor[/].[/]");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string suggested = SuggestName();
|
||||||
|
|
||||||
|
string source = AnsiConsole.Prompt(
|
||||||
|
new TextPrompt<string>("[bold]Name im Dashboard[/]:")
|
||||||
|
.DefaultValue(suggested)
|
||||||
|
.Validate(value => value.Trim().Length >= 2
|
||||||
|
? ValidationResult.Success()
|
||||||
|
: ValidationResult.Error("[red]Bitte einen Namen mit mindestens zwei Zeichen.[/]")));
|
||||||
|
|
||||||
|
string os = OperatingSystem.IsWindows() ? "windows" : "linux";
|
||||||
|
|
||||||
|
WatchdogAgent agent;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
agent = await client.RequestWatchdogAgentAsync(project, source, os);
|
||||||
|
}
|
||||||
|
catch (SetupException ex) when (ex.Code == "monitor_exists")
|
||||||
|
{
|
||||||
|
// Denselben Namen zweimal zu vergeben ist bei einer
|
||||||
|
// Neuinstallation derselben Maschine der Normalfall - bei zwei
|
||||||
|
// verschiedenen Maschinen dagegen der Anfang einer Historie,
|
||||||
|
// die zwei Rechner vermischt. Das kann nur entscheiden, wer
|
||||||
|
// davorsitzt.
|
||||||
|
AnsiConsole.MarkupLine($"[yellow]{Markup.Escape(ex.Message)}[/]");
|
||||||
|
|
||||||
|
if (!AnsiConsole.Confirm("Ist das dieselbe Maschine? Dann den bestehenden Monitor uebernehmen", false))
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine("[grey]Ueberwachung nicht eingerichtet.[/]");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
agent = await client.RequestWatchdogAgentAsync(project, source, os, overwrite: true);
|
||||||
|
}
|
||||||
|
catch (SetupException retry)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine($"[red]Nicht eingerichtet: {Markup.Escape(retry.Message)}[/]");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (SetupException ex)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine($"[red]Nicht eingerichtet: {Markup.Escape(ex.Message)}[/]");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine($"[red]Nicht eingerichtet: {Markup.Escape(ex.Message)}[/]");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? scriptPath = WriteScript(agent);
|
||||||
|
|
||||||
|
if (scriptPath == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
AnsiConsole.MarkupLine($"[green]Abgelegt:[/] {Markup.Escape(scriptPath)}");
|
||||||
|
AnsiConsole.MarkupLine($"[grey]Monitor \"{Markup.Escape(agent.Source)}\" ist angelegt, das Token steckt "
|
||||||
|
+ "im Skript.[/]");
|
||||||
|
|
||||||
|
// Ein erster Heartbeat beantwortet sofort, ob Token, Adresse und
|
||||||
|
// Werkzeuge auf dieser Maschine zusammenpassen. Ohne ihn faellt ein
|
||||||
|
// Fehler erst auf, wenn der Monitor nach Minuten auf "down" geht -
|
||||||
|
// und dann sieht es aus wie ein Ausfall, nicht wie ein Tippfehler.
|
||||||
|
RunOnce(scriptPath);
|
||||||
|
|
||||||
|
OfferSchedule(agent, scriptPath);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Eigenstaendiger Weg fuer eine Maschine, auf der nichts installiert
|
||||||
|
/// werden soll - etwa den Hypervisor unter den Anwendungen.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<int> RunStandaloneAsync(AgentOptions options, System.Net.Http.HttpClient http)
|
||||||
|
{
|
||||||
|
AnsiConsole.Write(new FigletText("Monitor").LeftJustified().Color(Color.DodgerBlue1));
|
||||||
|
|
||||||
|
string baseUrl = AnsiConsole.Prompt(
|
||||||
|
new TextPrompt<string>("[bold]Deploymentcenter[/]:")
|
||||||
|
.DefaultValue(options.BaseUrl)
|
||||||
|
.Validate(value => value.StartsWith("http", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? ValidationResult.Success()
|
||||||
|
: ValidationResult.Error("[red]Bitte eine vollstaendige Adresse angeben, mit https:// davor.[/]")));
|
||||||
|
|
||||||
|
options.BaseUrl = baseUrl.TrimEnd('/');
|
||||||
|
|
||||||
|
var client = new SetupClient(options.BaseUrl, http);
|
||||||
|
|
||||||
|
string username = AnsiConsole.Prompt(new TextPrompt<string>("[bold]Benutzer[/]:"));
|
||||||
|
string password = AnsiConsole.Prompt(new TextPrompt<string>("[bold]Passwort[/]:").Secret());
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await client.LoginAsync(username, password, Environment.MachineName);
|
||||||
|
}
|
||||||
|
catch (SetupException ex)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(ex.Message)}[/]");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
catch (System.Net.Http.HttpRequestException ex)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine($"[red]Keine Verbindung zum Deploymentcenter: {Markup.Escape(ex.Message)}[/]");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das Token haengt am Produkt - auch ein reiner Host-Monitor
|
||||||
|
// braucht also eines. Welches, sagt entweder --project oder die
|
||||||
|
// Auswahl aus dem Katalog.
|
||||||
|
string project = options.Project;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(project) || project == "myapp")
|
||||||
|
{
|
||||||
|
List<CatalogEntry> catalog;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
catalog = await client.GetCatalogAsync(options.Platform);
|
||||||
|
}
|
||||||
|
catch (SetupException ex)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine($"[red]Katalog nicht abrufbar: {Markup.Escape(ex.Message)}[/]");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (catalog.Count == 0)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine("[red]Es ist kein Produkt hinterlegt, an das sich der Monitor haengen "
|
||||||
|
+ "liesse. Mit [bold]--project <slug>[/] eines angeben.[/]");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var chosen = AnsiConsole.Prompt(
|
||||||
|
new SelectionPrompt<CatalogEntry>()
|
||||||
|
.Title("\n[bold]Zu welchem Produkt gehoert diese Maschine?[/]")
|
||||||
|
.PageSize(12)
|
||||||
|
.UseConverter(entry => Markup.Escape(entry.Name))
|
||||||
|
.AddChoices(catalog));
|
||||||
|
|
||||||
|
project = chosen.Slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await OfferAsync(options, client, project, interactive: true) ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Vorschlag fuer den Namen: der Rechnername, wie ihn auch jeder
|
||||||
|
/// andere sieht, der auf die Maschine schaut.
|
||||||
|
/// </summary>
|
||||||
|
private static string SuggestName()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string name = Environment.MachineName.Trim();
|
||||||
|
return name.Length > 0 ? name : "host";
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return "host";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Legt das Skript ab - moeglichst systemweit, sonst beim Benutzer.
|
||||||
|
/// Rueckgabe null heisst: nirgends schreibbar.
|
||||||
|
/// </summary>
|
||||||
|
private static string? WriteScript(WatchdogAgent agent)
|
||||||
|
{
|
||||||
|
foreach (string directory in CandidateDirectories())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
|
||||||
|
string path = Path.Combine(directory, agent.FileName);
|
||||||
|
|
||||||
|
// Zeilenenden passend zum Ziel: ein Shell-Skript mit CRLF
|
||||||
|
// scheitert unter Linux an "\r: Datei oder Verzeichnis
|
||||||
|
// nicht gefunden" - eine Meldung, die niemand mit
|
||||||
|
// Zeilenenden in Verbindung bringt.
|
||||||
|
string script = agent.Os == "windows"
|
||||||
|
? agent.Script.Replace("\n", "\r\n")
|
||||||
|
: agent.Script.Replace("\r\n", "\n");
|
||||||
|
|
||||||
|
File.WriteAllText(path, script);
|
||||||
|
|
||||||
|
Protect(path, agent.Os);
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine($"[grey]{Markup.Escape(directory)}: {Markup.Escape(ex.Message)}[/]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AnsiConsole.MarkupLine("[red]Das Skript liess sich nirgends ablegen.[/]");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bevorzugt ein systemweiter Ort - der Cron des Systems kommt an das
|
||||||
|
/// Heimatverzeichnis eines Benutzers nicht zwangslaeufig heran.
|
||||||
|
/// </summary>
|
||||||
|
private static IEnumerable<string> CandidateDirectories()
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
string programData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
|
||||||
|
if (programData.Length > 0)
|
||||||
|
{
|
||||||
|
yield return Path.Combine(programData, "Deploymentcenter", "watchdog");
|
||||||
|
}
|
||||||
|
|
||||||
|
string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||||
|
if (localAppData.Length > 0)
|
||||||
|
{
|
||||||
|
yield return Path.Combine(localAppData, "Deploymentcenter", "watchdog");
|
||||||
|
}
|
||||||
|
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return "/opt/deploymentcenter/watchdog";
|
||||||
|
|
||||||
|
string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||||
|
if (home.Length > 0)
|
||||||
|
{
|
||||||
|
yield return Path.Combine(home, ".deploymentcenter", "watchdog");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Im Skript steht ein Token. Unter Unix bleibt die Datei deshalb dem
|
||||||
|
/// Eigentuemer vorbehalten - und ausfuehrbar, sonst startet der Cron
|
||||||
|
/// sie nicht.
|
||||||
|
/// </summary>
|
||||||
|
private static void Protect(string path, string os)
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.SetUnixFileMode(path,
|
||||||
|
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine($"[yellow]Dateirechte nicht gesetzt: {Markup.Escape(ex.Message)}[/] "
|
||||||
|
+ "[grey]Bitte selbst auf 0700 setzen - die Datei enthaelt ein Token.[/]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Fuehrt das Skript einmal aus und zeigt, was es sagt.</summary>
|
||||||
|
private static void RunOnce(string scriptPath)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine("[grey]Sende einen ersten Heartbeat ...[/]");
|
||||||
|
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true
|
||||||
|
};
|
||||||
|
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
startInfo.FileName = "powershell";
|
||||||
|
startInfo.ArgumentList.Add("-NoProfile");
|
||||||
|
startInfo.ArgumentList.Add("-ExecutionPolicy");
|
||||||
|
startInfo.ArgumentList.Add("Bypass");
|
||||||
|
startInfo.ArgumentList.Add("-File");
|
||||||
|
startInfo.ArgumentList.Add(scriptPath);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
startInfo.FileName = "/bin/bash";
|
||||||
|
startInfo.ArgumentList.Add(scriptPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var process = Process.Start(startInfo);
|
||||||
|
|
||||||
|
if (process == null)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine("[yellow]Der Testlauf liess sich nicht starten.[/]");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string output = process.StandardOutput.ReadToEnd().Trim();
|
||||||
|
string error = process.StandardError.ReadToEnd().Trim();
|
||||||
|
|
||||||
|
if (!process.WaitForExit(30000))
|
||||||
|
{
|
||||||
|
try { process.Kill(entireProcessTree: true); } catch { }
|
||||||
|
AnsiConsole.MarkupLine("[yellow]Der Testlauf hat nicht geantwortet.[/]");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.ExitCode == 0)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine($"[green]{Markup.Escape(output.Length > 0 ? output : "Heartbeat gesendet.")}[/]");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AnsiConsole.MarkupLine("[yellow]Der erste Heartbeat kam nicht durch:[/]");
|
||||||
|
|
||||||
|
foreach (string line in new[] { output, error })
|
||||||
|
{
|
||||||
|
if (line.Length > 0)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine($"[grey]{Markup.Escape(line)}[/]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AnsiConsole.MarkupLine("[grey]Das Skript liegt bereits am Ziel - es laesst sich von Hand "
|
||||||
|
+ "nachvollziehen.[/]");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine($"[yellow]Testlauf nicht moeglich: {Markup.Escape(ex.Message)}[/]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Traegt das Skript in Cron bzw. die Aufgabenplanung ein - auf
|
||||||
|
/// Nachfrage. Klappt das nicht, bleibt der Befehl sichtbar stehen.
|
||||||
|
/// </summary>
|
||||||
|
private static void OfferSchedule(WatchdogAgent agent, string scriptPath)
|
||||||
|
{
|
||||||
|
string command = BuildScheduleCommand(agent, scriptPath);
|
||||||
|
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
|
||||||
|
if (!AnsiConsole.Confirm($"Minuetlich ausfuehren lassen?"))
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine("[grey]Nicht eingeplant. Ohne regelmaessigen Lauf faellt der Monitor "
|
||||||
|
+ "nach kurzer Zeit auf \"down\". Von Hand:[/]");
|
||||||
|
AnsiConsole.MarkupLine($" [cyan]{Markup.Escape(command)}[/]");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Schedule(agent, scriptPath, out string problem))
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine("[green]Eingeplant.[/] [grey]Der Monitor sollte innerhalb einer Minute "
|
||||||
|
+ "gruen werden.[/]");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AnsiConsole.MarkupLine($"[yellow]Automatisch nicht eingerichtet: {Markup.Escape(problem)}[/]");
|
||||||
|
AnsiConsole.MarkupLine("[grey]Von Hand - unter Windows in einer Eingabeaufforderung mit "
|
||||||
|
+ "Administratorrechten:[/]");
|
||||||
|
AnsiConsole.MarkupLine($" [cyan]{Markup.Escape(command)}[/]");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Der Einplanungsbefehl mit eingesetztem Pfad. Die Vorlage kommt vom
|
||||||
|
/// Server, damit Oberflaeche und Installer dasselbe zeigen.
|
||||||
|
/// </summary>
|
||||||
|
private static string BuildScheduleCommand(WatchdogAgent agent, string scriptPath)
|
||||||
|
{
|
||||||
|
string quoted = scriptPath.Contains(' ') ? "\"" + scriptPath + "\"" : scriptPath;
|
||||||
|
|
||||||
|
return agent.Schedule.Length > 0
|
||||||
|
? agent.Schedule.Replace("{PFAD}", quoted)
|
||||||
|
: quoted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Schedule(WatchdogAgent agent, string scriptPath, out string problem)
|
||||||
|
{
|
||||||
|
problem = string.Empty;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return OperatingSystem.IsWindows()
|
||||||
|
? ScheduleWindows(agent, scriptPath, out problem)
|
||||||
|
: ScheduleCron(agent, scriptPath, out problem);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
problem = ex.Message;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Traegt eine Zeile in die Crontab des aufrufenden Benutzers ein.
|
||||||
|
///
|
||||||
|
/// Bestehende Zeilen zu diesem Skript fallen dabei weg - sonst
|
||||||
|
/// sammelt jede Neuinstallation einen weiteren Eintrag an, und die
|
||||||
|
/// Maschine schickt am Ende drei Heartbeats pro Minute.
|
||||||
|
/// </summary>
|
||||||
|
private static bool ScheduleCron(WatchdogAgent agent, string scriptPath, out string problem)
|
||||||
|
{
|
||||||
|
problem = string.Empty;
|
||||||
|
|
||||||
|
string line = BuildScheduleCommand(agent, scriptPath);
|
||||||
|
|
||||||
|
// Einfache Anfuehrungszeichen im Pfad wuerden das Shell-Literal
|
||||||
|
// zerlegen. Sie kommen in Pfaden praktisch nicht vor - wenn doch,
|
||||||
|
// ist ein ehrlicher Abbruch besser als ein zerschossener Crontab.
|
||||||
|
if (scriptPath.Contains('\'') || line.Contains('\''))
|
||||||
|
{
|
||||||
|
problem = "Der Pfad enthaelt ein Anfuehrungszeichen.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string script =
|
||||||
|
"(crontab -l 2>/dev/null | grep -Fv '" + scriptPath + "'; " +
|
||||||
|
"echo '" + line + "') | crontab -";
|
||||||
|
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "/bin/sh",
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true
|
||||||
|
};
|
||||||
|
|
||||||
|
startInfo.ArgumentList.Add("-c");
|
||||||
|
startInfo.ArgumentList.Add(script);
|
||||||
|
|
||||||
|
using var process = Process.Start(startInfo);
|
||||||
|
|
||||||
|
if (process == null)
|
||||||
|
{
|
||||||
|
problem = "crontab liess sich nicht aufrufen.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string error = process.StandardError.ReadToEnd().Trim();
|
||||||
|
process.WaitForExit(15000);
|
||||||
|
|
||||||
|
if (process.ExitCode == 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
problem = error.Length > 0 ? error : $"crontab endete mit Code {process.ExitCode}.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Legt eine geplante Aufgabe an. /F ueberschreibt eine gleichnamige -
|
||||||
|
/// eine zweite Installation soll keine zweite Aufgabe hinterlassen.
|
||||||
|
/// </summary>
|
||||||
|
private static bool ScheduleWindows(WatchdogAgent agent, string scriptPath, out string problem)
|
||||||
|
{
|
||||||
|
problem = string.Empty;
|
||||||
|
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "schtasks",
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true
|
||||||
|
};
|
||||||
|
|
||||||
|
startInfo.ArgumentList.Add("/Create");
|
||||||
|
startInfo.ArgumentList.Add("/SC");
|
||||||
|
startInfo.ArgumentList.Add("MINUTE");
|
||||||
|
startInfo.ArgumentList.Add("/MO");
|
||||||
|
startInfo.ArgumentList.Add("1");
|
||||||
|
startInfo.ArgumentList.Add("/TN");
|
||||||
|
startInfo.ArgumentList.Add("Deploymentcenter Watchdog " + agent.Source);
|
||||||
|
startInfo.ArgumentList.Add("/TR");
|
||||||
|
startInfo.ArgumentList.Add(
|
||||||
|
"powershell -NoProfile -ExecutionPolicy Bypass -File \"" + scriptPath + "\"");
|
||||||
|
startInfo.ArgumentList.Add("/F");
|
||||||
|
|
||||||
|
using var process = Process.Start(startInfo);
|
||||||
|
|
||||||
|
if (process == null)
|
||||||
|
{
|
||||||
|
problem = "schtasks liess sich nicht aufrufen.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string output = process.StandardOutput.ReadToEnd().Trim();
|
||||||
|
string error = process.StandardError.ReadToEnd().Trim();
|
||||||
|
process.WaitForExit(15000);
|
||||||
|
|
||||||
|
if (process.ExitCode == 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
problem = error.Length > 0
|
||||||
|
? error
|
||||||
|
: (output.Length > 0 ? output : $"schtasks endete mit Code {process.ExitCode}.");
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,10 +73,13 @@ namespace Deploymentcenter.UpdateAgent
|
|||||||
// Betrieb ist das der Lizenzschluessel der Anwendung; bei
|
// Betrieb ist das der Lizenzschluessel der Anwendung; bei
|
||||||
// einer Erstinstallation gibt es den noch nicht, dort treten
|
// einer Erstinstallation gibt es den noch nicht, dort treten
|
||||||
// die Zugangsdaten des Installationskontos an seine Stelle.
|
// die Zugangsdaten des Installationskontos an seine Stelle.
|
||||||
Credentials = ReleaseCredentials.FromLicenseKey(GetArg(args, "--license-key"))
|
//
|
||||||
?? ReleaseCredentials.FromUser(
|
// Umgebungsvariablen haben Vorrang vor den Argumenten: Was in
|
||||||
GetArg(args, "--download-user"),
|
// argv steht, zeigt "ps" jedem Benutzer der Maschine. Aus
|
||||||
GetArg(args, "--download-password"))
|
// genau diesem Grund verwirft UPGRADE.md §5 den Weg, den
|
||||||
|
// Shared Key im Crontab zu hinterlegen - fuer einen
|
||||||
|
// Lizenzschluessel gilt nichts anderes.
|
||||||
|
Credentials = ReadCredentials(args)
|
||||||
};
|
};
|
||||||
|
|
||||||
PackageManifest? installedManifest = ReadInstalledManifest(targetDir);
|
PackageManifest? installedManifest = ReadInstalledManifest(targetDir);
|
||||||
@@ -129,6 +132,13 @@ namespace Deploymentcenter.UpdateAgent
|
|||||||
case "configure":
|
case "configure":
|
||||||
return await Installation.ConfigureOnlyAsync(options, HttpClient);
|
return await Installation.ConfigureOnlyAsync(options, HttpClient);
|
||||||
|
|
||||||
|
case "monitor":
|
||||||
|
// Fuer Maschinen, auf denen nichts zu installieren ist -
|
||||||
|
// der Hypervisor unter den Anwendungen etwa - und zum
|
||||||
|
// Nachholen, wenn die Frage bei der Installation verneint
|
||||||
|
// wurde.
|
||||||
|
return await Monitoring.RunStandaloneAsync(options, HttpClient);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return ShowHelp();
|
return ShowHelp();
|
||||||
}
|
}
|
||||||
@@ -163,14 +173,23 @@ namespace Deploymentcenter.UpdateAgent
|
|||||||
};
|
};
|
||||||
AnsiConsole.Write(panel);
|
AnsiConsole.Write(panel);
|
||||||
|
|
||||||
ReleaseManifest? releaseManifest = null;
|
ManifestLookup lookup = new ManifestLookup();
|
||||||
await AnsiConsole.Status()
|
await AnsiConsole.Status()
|
||||||
.Spinner(Spinner.Known.Dots)
|
.Spinner(Spinner.Known.Dots)
|
||||||
.StartAsync("Lade verfuegbare Releases ...", async ctx =>
|
.StartAsync("Lade verfuegbare Releases ...", async ctx =>
|
||||||
{
|
{
|
||||||
releaseManifest = await FetchManifestAsync(options);
|
lookup = await FetchManifestAsync(options);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Der Grund steht schon im Bild - hier noch einmal nach fehlenden
|
||||||
|
// Releases zu suchen, waere eine zweite, falsche Faehrte.
|
||||||
|
if (lookup.Unauthorized)
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
ReleaseManifest? releaseManifest = lookup.Manifest;
|
||||||
|
|
||||||
if (releaseManifest?.Latest == null)
|
if (releaseManifest?.Latest == null)
|
||||||
{
|
{
|
||||||
AnsiConsole.MarkupLine("[bold red]Fehler: Es konnte kein Release ermittelt werden.[/]");
|
AnsiConsole.MarkupLine("[bold red]Fehler: Es konnte kein Release ermittelt werden.[/]");
|
||||||
@@ -279,7 +298,18 @@ namespace Deploymentcenter.UpdateAgent
|
|||||||
|
|
||||||
static async Task<int> DoList(AgentOptions options)
|
static async Task<int> DoList(AgentOptions options)
|
||||||
{
|
{
|
||||||
var manifest = await FetchManifestAsync(options);
|
var lookup = await FetchManifestAsync(options);
|
||||||
|
|
||||||
|
if (lookup.Unauthorized)
|
||||||
|
{
|
||||||
|
// Derselbe Rueckgabewert wie bei DoCheck - wer den Agenten aus
|
||||||
|
// einem Skript ruft, unterscheidet damit "kein Zugang" von
|
||||||
|
// "nichts veroeffentlicht".
|
||||||
|
Console.WriteLine("UNAUTHORIZED");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifest = lookup.Manifest;
|
||||||
if (manifest == null)
|
if (manifest == null)
|
||||||
{
|
{
|
||||||
Console.WriteLine("ERROR: Kein Release-Manifest verfuegbar.");
|
Console.WriteLine("ERROR: Kein Release-Manifest verfuegbar.");
|
||||||
@@ -308,7 +338,14 @@ namespace Deploymentcenter.UpdateAgent
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var releaseManifest = await FetchManifestAsync(options);
|
var lookup = await FetchManifestAsync(options);
|
||||||
|
|
||||||
|
if (lookup.Unauthorized)
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var releaseManifest = lookup.Manifest;
|
||||||
if (releaseManifest == null)
|
if (releaseManifest == null)
|
||||||
{
|
{
|
||||||
AnsiConsole.MarkupLine("[bold red]Fehler: Release-Manifest konnte weder statisch noch ueber die API abgerufen werden.[/]");
|
AnsiConsole.MarkupLine("[bold red]Fehler: Release-Manifest konnte weder statisch noch ueber die API abgerufen werden.[/]");
|
||||||
@@ -558,18 +595,48 @@ namespace Deploymentcenter.UpdateAgent
|
|||||||
/// klappte, sagte die Anwendung "Update verfuegbar" und der Agent "kein
|
/// klappte, sagte die Anwendung "Update verfuegbar" und der Agent "kein
|
||||||
/// Release gefunden". Der Rueckfall auf die API stand nur im SDK.
|
/// Release gefunden". Der Rueckfall auf die API stand nur im SDK.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
static async Task<ReleaseManifest?> FetchManifestAsync(AgentOptions options)
|
/// <summary>
|
||||||
|
/// Ergebnis eines Manifest-Abrufs. Ein leeres Manifest wegen 401 ist
|
||||||
|
/// etwas anderes als ein leeres, weil nichts veroeffentlicht wurde -
|
||||||
|
/// die Aufrufer sollen dafuer nicht dieselbe Meldung zeigen.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ManifestLookup
|
||||||
{
|
{
|
||||||
var fromStatic = await FetchStaticManifestAsync(options);
|
public ReleaseManifest? Manifest { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Die Ablage hat die Zugangsdaten abgelehnt.</summary>
|
||||||
|
public bool Unauthorized { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task<ManifestLookup> FetchManifestAsync(AgentOptions options)
|
||||||
|
{
|
||||||
|
var (fromStatic, unauthorized) = await FetchStaticManifestAsync(options);
|
||||||
|
|
||||||
|
if (unauthorized)
|
||||||
|
{
|
||||||
|
// Ein 401 ist keine "Datei fehlt"-Lage: die Zugangsdaten
|
||||||
|
// tragen nicht. Die API wuerde zwar antworten, sie liegt nicht
|
||||||
|
// hinter dem Zugangsschutz - das Paket selbst aber schon. Der
|
||||||
|
// Lauf endete also ein paar Schritte spaeter mit genau
|
||||||
|
// derselben Meldung ein zweites Mal, dazwischen Ausgaben ueber
|
||||||
|
// Signaturen, die den eigentlichen Grund nach oben aus dem
|
||||||
|
// Bild schieben.
|
||||||
|
AnsiConsole.MarkupLine("[bold red]"
|
||||||
|
+ Markup.Escape(ReleaseCredentials.DescribeUnauthorized(options.Credentials))
|
||||||
|
+ "[/]");
|
||||||
|
|
||||||
|
return new ManifestLookup { Unauthorized = true };
|
||||||
|
}
|
||||||
|
|
||||||
if (fromStatic?.Latest != null)
|
if (fromStatic?.Latest != null)
|
||||||
{
|
{
|
||||||
return fromStatic;
|
return new ManifestLookup { Manifest = fromStatic };
|
||||||
}
|
}
|
||||||
|
|
||||||
return await FetchApiManifestAsync(options);
|
return new ManifestLookup { Manifest = await FetchApiManifestAsync(options) };
|
||||||
}
|
}
|
||||||
|
|
||||||
static async Task<ReleaseManifest?> FetchStaticManifestAsync(AgentOptions options)
|
static async Task<(ReleaseManifest? Manifest, bool Unauthorized)> FetchStaticManifestAsync(AgentOptions options)
|
||||||
{
|
{
|
||||||
// Plattformunabhaengige Releases liegen weiterhin im alten Pfad
|
// Plattformunabhaengige Releases liegen weiterhin im alten Pfad
|
||||||
// ohne Zwischenebene.
|
// ohne Zwischenebene.
|
||||||
@@ -594,15 +661,12 @@ namespace Deploymentcenter.UpdateAgent
|
|||||||
|
|
||||||
var resp = await HttpClient.SendAsync(request);
|
var resp = await HttpClient.SendAsync(request);
|
||||||
|
|
||||||
// Ein 401 ist keine "Datei fehlt"-Lage: die Lizenz traegt
|
// Ein 401 wird nach oben durchgereicht, statt hier
|
||||||
// nicht mehr. Weiterzuprobieren wuerde die Ursache nur
|
// gemeldet zu werden: sonst steht die Meldung schon im
|
||||||
// hinter einer allgemeinen Fehlermeldung verstecken.
|
// Bild, waehrend der Aufrufer noch weitersucht.
|
||||||
if (resp.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
if (resp.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
||||||
{
|
{
|
||||||
AnsiConsole.MarkupLine("[bold red]"
|
return (null, true);
|
||||||
+ Markup.Escape(ReleaseCredentials.DescribeUnauthorized(options.Credentials))
|
|
||||||
+ "[/]");
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!resp.IsSuccessStatusCode)
|
if (!resp.IsSuccessStatusCode)
|
||||||
@@ -630,7 +694,7 @@ namespace Deploymentcenter.UpdateAgent
|
|||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
|
|
||||||
if (manifest.Latest != null)
|
if (manifest.Latest != null)
|
||||||
return manifest;
|
return (manifest, false);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -638,7 +702,7 @@ namespace Deploymentcenter.UpdateAgent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return (null, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1068,6 +1132,40 @@ namespace Deploymentcenter.UpdateAgent
|
|||||||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ermittelt die Zugangsdaten fuer die Release-Ablage.
|
||||||
|
///
|
||||||
|
/// Reihenfolge: Umgebungsvariable vor Argument. Ein Geheimnis in argv
|
||||||
|
/// steht in der Prozessliste und ist damit fuer jeden Benutzer der
|
||||||
|
/// Maschine sichtbar; die Umgebung eines fremden Prozesses ist unter
|
||||||
|
/// Linux nur fuer den Eigentuemer und root lesbar.
|
||||||
|
///
|
||||||
|
/// Das SDK setzt die Variable vor dem Start auf sich selbst - der
|
||||||
|
/// Agent erbt sie und der Schluessel taucht nirgends in einer
|
||||||
|
/// Kommandozeile auf.
|
||||||
|
/// </summary>
|
||||||
|
static ReleaseCredentials? ReadCredentials(string[] args)
|
||||||
|
{
|
||||||
|
string? licenseKey = Env("DC_LICENSE_KEY") ?? GetArg(args, "--license-key");
|
||||||
|
|
||||||
|
var fromLicense = ReleaseCredentials.FromLicenseKey(licenseKey);
|
||||||
|
if (fromLicense != null)
|
||||||
|
{
|
||||||
|
return fromLicense;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? user = Env("DC_DOWNLOAD_USER") ?? GetArg(args, "--download-user");
|
||||||
|
string? pass = Env("DC_DOWNLOAD_PASSWORD") ?? GetArg(args, "--download-password");
|
||||||
|
|
||||||
|
return ReleaseCredentials.FromUser(user, pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
static string? Env(string name)
|
||||||
|
{
|
||||||
|
string? value = Environment.GetEnvironmentVariable(name);
|
||||||
|
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||||
|
}
|
||||||
|
|
||||||
static int ParsePid(string? value)
|
static int ParsePid(string? value)
|
||||||
{
|
{
|
||||||
return int.TryParse(value, out int pid) && pid > 0 ? pid : 0;
|
return int.TryParse(value, out int pid) && pid > 0 ? pid : 0;
|
||||||
@@ -1103,8 +1201,8 @@ namespace Deploymentcenter.UpdateAgent
|
|||||||
Console.WriteLine();
|
Console.WriteLine();
|
||||||
Console.WriteLine(" --project, -p <slug> Projekt-Slug");
|
Console.WriteLine(" --project, -p <slug> Projekt-Slug");
|
||||||
Console.WriteLine(" --channel, -c <kanal> prod | beta | dev");
|
Console.WriteLine(" --channel, -c <kanal> prod | beta | dev");
|
||||||
Console.WriteLine(" --action, -a <aktion> interactive | install | configure | check |");
|
Console.WriteLine(" --action, -a <aktion> interactive | install | configure | monitor |");
|
||||||
Console.WriteLine(" update | repair | list");
|
Console.WriteLine(" check | update | repair | list");
|
||||||
Console.WriteLine(" --version, -v <version> Zielversion oder 'latest'");
|
Console.WriteLine(" --version, -v <version> Zielversion oder 'latest'");
|
||||||
Console.WriteLine(" --target-dir, -t <pfad> Zu aktualisierendes Verzeichnis");
|
Console.WriteLine(" --target-dir, -t <pfad> Zu aktualisierendes Verzeichnis");
|
||||||
Console.WriteLine(" --platform <rid> Laufzeitkennung (Vorgabe: die des Systems)");
|
Console.WriteLine(" --platform <rid> Laufzeitkennung (Vorgabe: die des Systems)");
|
||||||
|
|||||||
@@ -0,0 +1,451 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Spectre.Console;
|
||||||
|
|
||||||
|
namespace Deploymentcenter.UpdateAgent
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Prueft, ob die Laufzeit vorhanden ist, die die installierte Anwendung
|
||||||
|
/// braucht.
|
||||||
|
///
|
||||||
|
/// Ohne diese Pruefung endet eine gelungene Installation mit einer
|
||||||
|
/// Anwendung, die sich nicht starten laesst - und die Fehlersuche beginnt
|
||||||
|
/// beim Deploymentcenter, weil das der letzte Schritt war, den jemand
|
||||||
|
/// bewusst getan hat. Die Meldung des Systems ("You must install .NET to
|
||||||
|
/// run this application") sieht dann jeder, der von Hand startet, aber
|
||||||
|
/// niemand, der einen Dienst einrichtet.
|
||||||
|
///
|
||||||
|
/// Gefragt wird nicht die Anwendung, sondern ihre runtimeconfig.json: dort
|
||||||
|
/// steht, was sie tatsaechlich erwartet. Das kostet die ausliefernde Seite
|
||||||
|
/// nichts - die Datei entsteht bei jedem dotnet publish von selbst - und
|
||||||
|
/// deckt auch Pakete ab, die lange vor dieser Pruefung gebaut wurden.
|
||||||
|
///
|
||||||
|
/// Ein eigenstaendig veroeffentlichtes Paket (self-contained) bringt seine
|
||||||
|
/// Laufzeit mit; dort gibt es nichts zu pruefen und entsprechend nichts zu
|
||||||
|
/// melden.
|
||||||
|
/// </summary>
|
||||||
|
internal static class RuntimeCheck
|
||||||
|
{
|
||||||
|
/// <summary>Ein Framework, das die Anwendung erwartet.</summary>
|
||||||
|
internal sealed class Requirement
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Version { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wie weit die Laufzeit nach oben abweichen darf. Vorgabe von
|
||||||
|
/// .NET ist "Minor": eine hoehere Nebenversion derselben
|
||||||
|
/// Hauptversion wird genommen, eine hoehere Hauptversion nicht.
|
||||||
|
/// </summary>
|
||||||
|
public string RollForward { get; set; } = "Minor";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Was auf dieser Maschine installiert ist.</summary>
|
||||||
|
internal sealed class InstalledRuntime
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public Version Version { get; set; } = new Version(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class Result
|
||||||
|
{
|
||||||
|
/// <summary>Liess sich ueberhaupt etwas feststellen?</summary>
|
||||||
|
public bool Inspected { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Das Paket bringt seine Laufzeit selbst mit.</summary>
|
||||||
|
public bool SelfContained { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Ist ueberhaupt ein "dotnet" auffindbar?</summary>
|
||||||
|
public bool DotnetFound { get; set; }
|
||||||
|
|
||||||
|
public List<Requirement> Required { get; } = new List<Requirement>();
|
||||||
|
public List<InstalledRuntime> Installed { get; } = new List<InstalledRuntime>();
|
||||||
|
|
||||||
|
/// <summary>Anforderungen, die nichts Installiertes erfuellt.</summary>
|
||||||
|
public List<Requirement> Missing { get; } = new List<Requirement>();
|
||||||
|
|
||||||
|
/// <summary>Nichts zu beanstanden - oder nichts zu pruefen.</summary>
|
||||||
|
public bool Ok => SelfContained || !Inspected || Missing.Count == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Untersucht das Zielverzeichnis und die Maschine.
|
||||||
|
/// </summary>
|
||||||
|
public static Result Inspect(string targetDir)
|
||||||
|
{
|
||||||
|
var result = new Result();
|
||||||
|
|
||||||
|
var requirements = ReadRequirements(targetDir, result);
|
||||||
|
|
||||||
|
if (!result.Inspected || result.SelfContained)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Required.AddRange(requirements);
|
||||||
|
|
||||||
|
foreach (var runtime in ListInstalledRuntimes())
|
||||||
|
{
|
||||||
|
result.Installed.Add(runtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.DotnetFound = result.Installed.Count > 0;
|
||||||
|
|
||||||
|
foreach (var requirement in requirements)
|
||||||
|
{
|
||||||
|
if (!IsSatisfied(requirement, result.Installed))
|
||||||
|
{
|
||||||
|
result.Missing.Add(requirement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gibt das Ergebnis aus. Rueckgabe false heisst: es fehlt etwas -
|
||||||
|
/// die Anwendung wird so nicht starten.
|
||||||
|
/// </summary>
|
||||||
|
public static bool Report(Result result)
|
||||||
|
{
|
||||||
|
if (result.SelfContained)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine("[grey]Laufzeit: das Paket bringt sie selbst mit.[/]");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.Inspected)
|
||||||
|
{
|
||||||
|
// Kein Grund zur Beunruhigung: nicht jedes Paket ist eine
|
||||||
|
// .NET-Anwendung, und ohne runtimeconfig.json gibt es hier
|
||||||
|
// schlicht nichts zu sagen.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.Missing.Count == 0)
|
||||||
|
{
|
||||||
|
foreach (var requirement in result.Required)
|
||||||
|
{
|
||||||
|
var best = BestMatch(requirement, result.Installed);
|
||||||
|
AnsiConsole.MarkupLine($"[green] Laufzeit vorhanden:[/] "
|
||||||
|
+ $"{Markup.Escape(requirement.Name)} {Markup.Escape(best?.Version.ToString() ?? "?")} "
|
||||||
|
+ $"[grey](verlangt {Markup.Escape(requirement.Version)})[/]");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
|
||||||
|
if (!result.DotnetFound)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine("[bold red]Auf dieser Maschine ist keine .NET-Laufzeit installiert.[/]");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine("[bold red]Die benoetigte .NET-Laufzeit fehlt.[/]");
|
||||||
|
|
||||||
|
string installed = string.Join(", ", result.Installed
|
||||||
|
.GroupBy(r => r.Name)
|
||||||
|
.Select(g => $"{g.Key} {string.Join("/", g.Select(r => r.Version.ToString()).Distinct())}"));
|
||||||
|
|
||||||
|
AnsiConsole.MarkupLine($"[grey]Vorhanden: {Markup.Escape(installed)}[/]");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var missing in result.Missing)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine($"[red]Verlangt: {Markup.Escape(missing.Name)} {Markup.Escape(missing.Version)}[/]");
|
||||||
|
}
|
||||||
|
|
||||||
|
AnsiConsole.MarkupLine("[yellow]Die Anwendung ist vollstaendig installiert, wird sich so aber nicht "
|
||||||
|
+ "starten lassen.[/]");
|
||||||
|
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
AnsiConsole.MarkupLine("[bold]Nachzuholen mit:[/]");
|
||||||
|
|
||||||
|
foreach (string hint in InstallHints(result.Missing))
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine($" [cyan]{Markup.Escape(hint)}[/]");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Verwechslung ist haeufig genug, um sie hier auszuraeumen:
|
||||||
|
// zum Ausfuehren genuegt die Laufzeit. Das SDK ist zum Bauen da
|
||||||
|
// und bringt ein Vielfaches an Umfang mit.
|
||||||
|
AnsiConsole.MarkupLine("[grey]Zum Ausfuehren genuegt die Laufzeit - das SDK wird nur zum Bauen "
|
||||||
|
+ "gebraucht.[/]");
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Liest die Anforderungen aus der runtimeconfig.json im
|
||||||
|
/// Zielverzeichnis.
|
||||||
|
/// </summary>
|
||||||
|
private static List<Requirement> ReadRequirements(string targetDir, Result result)
|
||||||
|
{
|
||||||
|
var requirements = new List<Requirement>();
|
||||||
|
|
||||||
|
string[] candidates;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
candidates = Directory.GetFiles(targetDir, "*.runtimeconfig.json", SearchOption.TopDirectoryOnly);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return requirements;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (string path in candidates)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||||
|
|
||||||
|
if (!doc.RootElement.TryGetProperty("runtimeOptions", out var options))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
result.Inspected = true;
|
||||||
|
|
||||||
|
// Eigenstaendig veroeffentlicht: die Laufzeit liegt daneben
|
||||||
|
// im Verzeichnis, es gibt nichts zu installieren.
|
||||||
|
if (options.TryGetProperty("includedFrameworks", out var included)
|
||||||
|
&& included.ValueKind == JsonValueKind.Array
|
||||||
|
&& included.GetArrayLength() > 0)
|
||||||
|
{
|
||||||
|
result.SelfContained = true;
|
||||||
|
return requirements;
|
||||||
|
}
|
||||||
|
|
||||||
|
string rollForward = options.TryGetProperty("rollForward", out var roll)
|
||||||
|
&& roll.ValueKind == JsonValueKind.String
|
||||||
|
? (roll.GetString() ?? "Minor")
|
||||||
|
: "Minor";
|
||||||
|
|
||||||
|
// Einzelnes Framework oder eine Liste - beide Schreibweisen
|
||||||
|
// kommen vor, je nach Projektart und SDK-Fassung.
|
||||||
|
if (options.TryGetProperty("framework", out var single))
|
||||||
|
{
|
||||||
|
AddRequirement(requirements, single, rollForward);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.TryGetProperty("frameworks", out var many)
|
||||||
|
&& many.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var entry in many.EnumerateArray())
|
||||||
|
{
|
||||||
|
AddRequirement(requirements, entry, rollForward);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Eine unlesbare runtimeconfig.json ist kein Grund, die
|
||||||
|
// Installation zu bemaengeln - nur einer, hier nichts zu
|
||||||
|
// behaupten.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return requirements;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddRequirement(List<Requirement> into, JsonElement element, string rollForward)
|
||||||
|
{
|
||||||
|
if (element.ValueKind != JsonValueKind.Object)
|
||||||
|
return;
|
||||||
|
|
||||||
|
string name = element.TryGetProperty("name", out var n) && n.ValueKind == JsonValueKind.String
|
||||||
|
? (n.GetString() ?? string.Empty)
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
string version = element.TryGetProperty("version", out var v) && v.ValueKind == JsonValueKind.String
|
||||||
|
? (v.GetString() ?? string.Empty)
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
if (name.Length == 0 || version.Length == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (into.Any(r => string.Equals(r.Name, name, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
return;
|
||||||
|
|
||||||
|
into.Add(new Requirement { Name = name, Version = version, RollForward = rollForward });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fragt "dotnet --list-runtimes" ab. Fehlt der Befehl, ist die
|
||||||
|
/// Antwort leer - genau das ist die Auskunft, um die es geht.
|
||||||
|
/// </summary>
|
||||||
|
private static List<InstalledRuntime> ListInstalledRuntimes()
|
||||||
|
{
|
||||||
|
var found = new List<InstalledRuntime>();
|
||||||
|
|
||||||
|
string output;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var process = Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "dotnet",
|
||||||
|
Arguments = "--list-runtimes",
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process == null)
|
||||||
|
return found;
|
||||||
|
|
||||||
|
output = process.StandardOutput.ReadToEnd();
|
||||||
|
|
||||||
|
// Ein haengendes dotnet darf die Installation nicht aufhalten.
|
||||||
|
if (!process.WaitForExit(15000))
|
||||||
|
{
|
||||||
|
try { process.Kill(entireProcessTree: true); } catch { }
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Kein dotnet im Pfad.
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zeilenform: "Microsoft.AspNetCore.App 8.0.14 [/usr/share/dotnet/shared/...]"
|
||||||
|
var pattern = new Regex(@"^(\S+)\s+(\d+\.\d+\.\d+\S*)\s+\[", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
foreach (string line in output.Split('\n'))
|
||||||
|
{
|
||||||
|
var match = pattern.Match(line.Trim());
|
||||||
|
if (!match.Success)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (TryParseVersion(match.Groups[2].Value, out var version))
|
||||||
|
{
|
||||||
|
found.Add(new InstalledRuntime { Name = match.Groups[1].Value, Version = version });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wird die Anforderung von irgendetwas Installiertem erfuellt?
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsSatisfied(Requirement requirement, List<InstalledRuntime> installed)
|
||||||
|
{
|
||||||
|
return BestMatch(requirement, installed) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InstalledRuntime? BestMatch(Requirement requirement, List<InstalledRuntime> installed)
|
||||||
|
{
|
||||||
|
if (!TryParseVersion(requirement.Version, out var wanted))
|
||||||
|
{
|
||||||
|
// Ohne verwertbare Angabe wird nichts bemaengelt: die Namen
|
||||||
|
// muessen dann genuegen.
|
||||||
|
return installed.FirstOrDefault(r =>
|
||||||
|
string.Equals(r.Name, requirement.Name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
string mode = (requirement.RollForward ?? "Minor").ToLowerInvariant();
|
||||||
|
|
||||||
|
var candidates = installed
|
||||||
|
.Where(r => string.Equals(r.Name, requirement.Name, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Where(r => Matches(mode, wanted, r.Version))
|
||||||
|
.OrderByDescending(r => r.Version)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return candidates.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Matches(string mode, Version wanted, Version have)
|
||||||
|
{
|
||||||
|
switch (mode)
|
||||||
|
{
|
||||||
|
// Ausdruecklich abgeschaltet: es muss genau diese Fassung sein.
|
||||||
|
case "disable":
|
||||||
|
return have == wanted;
|
||||||
|
|
||||||
|
// Eine hoehere Hauptversion ist erlaubt.
|
||||||
|
case "major":
|
||||||
|
case "latestmajor":
|
||||||
|
return have >= wanted;
|
||||||
|
|
||||||
|
// Vorgabe und alle uebrigen Werte (Minor, LatestMinor,
|
||||||
|
// LatestPatch): dieselbe Hauptversion, mindestens die
|
||||||
|
// verlangte Fassung.
|
||||||
|
default:
|
||||||
|
return have.Major == wanted.Major && have >= wanted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseVersion(string raw, out Version version)
|
||||||
|
{
|
||||||
|
// Vorabversionen ("8.0.0-preview.3") lassen sich nicht als Version
|
||||||
|
// lesen; der Teil vor dem Bindestrich genuegt hier.
|
||||||
|
string cleaned = raw.Split('-')[0].Trim();
|
||||||
|
|
||||||
|
return Version.TryParse(cleaned, out version!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Was der Mensch davor jetzt tun muss - als Befehl, nicht als
|
||||||
|
/// Verweis auf eine Downloadseite.
|
||||||
|
/// </summary>
|
||||||
|
private static List<string> InstallHints(List<Requirement> missing)
|
||||||
|
{
|
||||||
|
var hints = new List<string>();
|
||||||
|
bool windows = OperatingSystem.IsWindows();
|
||||||
|
|
||||||
|
foreach (var requirement in missing)
|
||||||
|
{
|
||||||
|
if (!TryParseVersion(requirement.Version, out var version))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
string majorMinor = $"{version.Major}.{version.Minor}";
|
||||||
|
|
||||||
|
if (windows)
|
||||||
|
{
|
||||||
|
string package = requirement.Name switch
|
||||||
|
{
|
||||||
|
"Microsoft.AspNetCore.App" => $"Microsoft.DotNet.AspNetCore.{version.Major}",
|
||||||
|
"Microsoft.WindowsDesktop.App" => $"Microsoft.DotNet.DesktopRuntime.{version.Major}",
|
||||||
|
_ => $"Microsoft.DotNet.Runtime.{version.Major}",
|
||||||
|
};
|
||||||
|
|
||||||
|
hints.Add($"winget install --id {package} --source winget");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
string package = requirement.Name switch
|
||||||
|
{
|
||||||
|
"Microsoft.AspNetCore.App" => $"aspnetcore-runtime-{majorMinor}",
|
||||||
|
_ => $"dotnet-runtime-{majorMinor}",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debian und Ubuntu fuehren die Pakete seit 22.04 in den
|
||||||
|
// eigenen Quellen; wo nicht, hilft der Verweis darunter.
|
||||||
|
hints.Add($"sudo apt-get install -y {package}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hints.Count == 0)
|
||||||
|
{
|
||||||
|
hints.Add("https://dotnet.microsoft.com/download");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
hints.Add("Falls das Paket dort nicht gefuehrt wird: https://dotnet.microsoft.com/download");
|
||||||
|
}
|
||||||
|
|
||||||
|
return hints;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,8 +62,12 @@ return [
|
|||||||
'bugtracker' => [
|
'bugtracker' => [
|
||||||
// Projekt-Slug, unter dem das Deploymentcenter eigene Fehler meldet.
|
// Projekt-Slug, unter dem das Deploymentcenter eigene Fehler meldet.
|
||||||
'self_project' => 'deploymentcenter',
|
'self_project' => 'deploymentcenter',
|
||||||
// Reports pro IP und Minute am öffentlichen Ingest-Endpunkt.
|
// Reports pro IP und Minute an /api/bugtracker/v1/report.
|
||||||
'report_rate' => 60,
|
'report_rate' => 60,
|
||||||
|
// Reports pro IP und Minute an /api/errors/v1/report. Bewusst höher:
|
||||||
|
// Dort meldet ein globaler Exception-Handler, und ein Fehlerschauer
|
||||||
|
// erzeugt in Sekunden mehr Meldungen als ein Mensch am Tag.
|
||||||
|
'error_rate' => 300,
|
||||||
// Wie lange ein Agent ein Item exklusiv beansprucht (Minuten).
|
// Wie lange ein Agent ein Item exklusiv beansprucht (Minuten).
|
||||||
'lease_minutes' => 30,
|
'lease_minutes' => 30,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -129,6 +129,35 @@ Reagiere auf `code`, nicht auf den Text:
|
|||||||
- `401 unauthorized` — Token prüfen, nicht wiederholen
|
- `401 unauthorized` — Token prüfen, nicht wiederholen
|
||||||
- `409 already_claimed` — nächstes Item nehmen
|
- `409 already_claimed` — nächstes Item nehmen
|
||||||
- `429 rate_limited` — Intervall verdoppeln, später erneut
|
- `429 rate_limited` — Intervall verdoppeln, später erneut
|
||||||
|
|
||||||
|
### Halte fest, gegen welche Fassung du integriert hast
|
||||||
|
|
||||||
|
**Pflicht bei jeder Änderung an der Deploymentcenter-Anbindung.** Trage die
|
||||||
|
Fassung in dieser Datei ein, direkt hier:
|
||||||
|
|
||||||
|
```
|
||||||
|
Deploymentcenter-Anbindung: SDK 2.5.1, integriert am 2026-08-14
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Wert steht in `DeploymentcenterSdk.Version`. Ohne ihn muss beim nächsten
|
||||||
|
Mal die gesamte Historie gelesen werden.
|
||||||
|
|
||||||
|
**Bevor du die Anbindung anfasst,** frag zuerst, was sich seitdem geändert hat:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/updateservice/v1/changelog?since=2.5.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Antwort nennt nur die neueren Fassungen, dazu `action_items` (Punkte mit
|
||||||
|
Handlungsbedarf) und `breaking` (brechende Änderungen). Jeder Eintrag hat ein
|
||||||
|
Feld `action` mit dem, was konkret zu tun ist — steht dort `null`, ist nichts
|
||||||
|
zu tun.
|
||||||
|
|
||||||
|
Kommt `"count": 0` zurück, bist du auf Stand und musst gar nichts lesen.
|
||||||
|
|
||||||
|
Zum Nachschlagen für Menschen: `https://dc.mhdf.de/docs/changelog.php?since=2.5.1`
|
||||||
|
|
||||||
|
**Nach getaner Arbeit die Zeile oben auf die neue Fassung setzen.**
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -141,5 +170,10 @@ Melde Fehler und Ideen an https://dc.mhdf.de/api/bugtracker/v1/report
|
|||||||
description, error_message, stack_trace, severity, file_path, line_no,
|
description, error_message, stack_trace, severity, file_path, line_no,
|
||||||
client_ref). Vor der Arbeit an einem Item: POST .../manage?action=next zum
|
client_ref). Vor der Arbeit an einem Item: POST .../manage?action=next zum
|
||||||
Übernehmen. Danach ?action=resolve mit resolved_in_build.
|
Übernehmen. Danach ?action=resolve mit resolved_in_build.
|
||||||
|
|
||||||
|
Deploymentcenter-Anbindung: SDK 2.5.1
|
||||||
|
Bevor du sie anfasst: GET /api/updateservice/v1/changelog?since=2.5.1 —
|
||||||
|
zeigt nur, was seitdem dazukam. Danach die Zeile hochsetzen.
|
||||||
|
|
||||||
Vollständige Beschreibung: https://dc.mhdf.de/api/openapi.json
|
Vollständige Beschreibung: https://dc.mhdf.de/api/openapi.json
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -387,6 +387,47 @@ else
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Wenn Beenden die gefährlichere Option ist
|
||||||
|
|
||||||
|
`Environment.Exit(1)` ist die richtige Antwort für ein Werkzeug, das man
|
||||||
|
einfach nicht mehr benutzen darf. Für ein System, das **offene Verpflichtungen
|
||||||
|
verwaltet**, ist es die falsche: Ein Handelssystem mit offenen Positionen, eine
|
||||||
|
Maschinensteuerung im Zyklus, ein Dienst mitten in einer Transaktion — die
|
||||||
|
dürfen bei einer abgelaufenen Lizenz nicht einfach aufhören. Der Schaden aus
|
||||||
|
dem abrupten Ende wäre größer als der aus dem Weiterlaufen.
|
||||||
|
|
||||||
|
Die brauchbare Antwort ist **Sperrbetrieb statt Abbruch**: Was neue
|
||||||
|
Verpflichtungen eingeht, wird abgeschaltet; was bestehende abwickelt, läuft
|
||||||
|
weiter.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (!res.IsValid && !res.IsTransient)
|
||||||
|
{
|
||||||
|
logger.Error("Lizenz ungültig: {0}. Wechsle in den Sperrbetrieb.", res.Message);
|
||||||
|
|
||||||
|
// Was neue Verpflichtungen eingeht: aus.
|
||||||
|
strategyEngine.StopOpeningPositions();
|
||||||
|
scheduler.PauseNewJobs();
|
||||||
|
|
||||||
|
// Was bestehende abwickelt: bleibt an.
|
||||||
|
// - Risikoüberwachung
|
||||||
|
// - Schließen offener Positionen
|
||||||
|
// - Ordnungsgemäßes Herunterfahren, wenn nichts mehr offen ist
|
||||||
|
riskManager.KeepRunning();
|
||||||
|
|
||||||
|
notifier.Alert("Lizenz abgelaufen - Sperrbetrieb. Keine neuen Positionen.");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Wer diesen Weg geht, sollte zwei Dinge festhalten: **wann** aus dem
|
||||||
|
Sperrbetrieb ein Ende wird (etwa sobald keine Position mehr offen ist), und
|
||||||
|
**dass der Zustand sichtbar ist** — ein Sperrbetrieb, den niemand bemerkt, ist
|
||||||
|
ein stiller Ausfall.
|
||||||
|
|
||||||
|
Der Offline-Cache federt das übrigens schon ab: Eine kurzzeitig nicht
|
||||||
|
erreichbare Prüfung führt gar nicht erst hierher, dafür ist `IsTransient` da.
|
||||||
|
Hier geht es um das echte Urteil.
|
||||||
|
|
||||||
| Status | `IsTransient` | Bedeutung |
|
| Status | `IsTransient` | Bedeutung |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `valid` | – | Vom Server bestätigt |
|
| `valid` | – | Vom Server bestätigt |
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ Monitoring und einen Bugtracker, den Coding-Agenten selbständig bedienen.
|
|||||||
| Dokument | Wofür |
|
| Dokument | Wofür |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **[UPGRADE.md](./UPGRADE.md)** | **Ablaufplan für die Umstellung auf 2.0.** Enthält Pflichtschritte: Zugangsdaten wechseln, Migration, Evaluator-Cron. |
|
| **[UPGRADE.md](./UPGRADE.md)** | **Ablaufplan für die Umstellung auf 2.0.** Enthält Pflichtschritte: Zugangsdaten wechseln, Migration, Evaluator-Cron. |
|
||||||
|
| **[Änderungen](../public/docs/changelog.json)** | Was sich je Fassung geändert hat und was zu tun ist. Öffentlich unter `/docs/changelog.php`, maschinenlesbar über `GET /api/updateservice/v1/changelog?since=X` |
|
||||||
|
| **[Release-Anleitung für Agenten](../public/docs/release.md)** | Ein Projekt veröffentlichungsfähig machen: Vorlage kopieren, konfigurieren, ausliefern |
|
||||||
| [Agent-Prompt-Vorlage](./AGENT_PROMPT_TEMPLATE.md) | Textbaustein für `CLAUDE.md` / `AGENTS.md` eines Projekts |
|
| [Agent-Prompt-Vorlage](./AGENT_PROMPT_TEMPLATE.md) | Textbaustein für `CLAUDE.md` / `AGENTS.md` eines Projekts |
|
||||||
| [Agenten-Handbuch](../public/docs/bugtracker.md) | Vollständige Beschreibung des Bugtracker-Workflows, öffentlich unter `/docs/` |
|
| [Agenten-Handbuch](../public/docs/bugtracker.md) | Vollständige Beschreibung des Bugtracker-Workflows, öffentlich unter `/docs/` |
|
||||||
|
|
||||||
@@ -48,6 +50,23 @@ hinterlegte Textblock entfällt damit.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Drosselung
|
||||||
|
|
||||||
|
Je IP und Minute. Wird die Grenze überschritten, antwortet der Endpunkt mit
|
||||||
|
`429 rate_limited` — dann das Intervall verdoppeln und später erneut versuchen,
|
||||||
|
nicht sofort wiederholen.
|
||||||
|
|
||||||
|
| Endpunkt | Grenze | Einstellbar über |
|
||||||
|
|---|---|---|
|
||||||
|
| `/api/errors/v1/report` | **300** | `bugtracker.error_rate` |
|
||||||
|
| `/api/bugtracker/v1/report` | 60 | `bugtracker.report_rate` |
|
||||||
|
| `/api/license/v1/*` | 120 | fest |
|
||||||
|
| `/api/updateservice/v1/*` | 240 | fest |
|
||||||
|
| `/api/setup/v1/login` | 10 | fest — dort werden Passwörter geprüft |
|
||||||
|
| `/api/tokens/v1/provision` | 20 | fest |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Antwortformat
|
## Antwortformat
|
||||||
|
|
||||||
Alle JSON-Endpunkte antworten einheitlich:
|
Alle JSON-Endpunkte antworten einheitlich:
|
||||||
|
|||||||
@@ -182,6 +182,91 @@ entpackt sie dann nur und fragt nichts ab.
|
|||||||
| `validate` | Regulärer Ausdruck |
|
| `validate` | Regulärer Ausdruck |
|
||||||
| `target` | `id` des Ziels, wenn es mehrere gibt |
|
| `target` | `id` des Ziels, wenn es mehrere gibt |
|
||||||
|
|
||||||
|
### Wohin geschrieben wird
|
||||||
|
|
||||||
|
Ein Ziel hat neben `file` eine Ortsangabe:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"targets": [
|
||||||
|
{ "id": "app", "file": "appsettings.json", "format": "json" },
|
||||||
|
{ "id": "user", "file": "clawddotnet/Settings.json",
|
||||||
|
"location": "config", "format": "json" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
| `location` | Windows | Linux / macOS |
|
||||||
|
|---|---|---|
|
||||||
|
| `install` (Vorgabe) | Installationsverzeichnis | Installationsverzeichnis |
|
||||||
|
| `config` | `%APPDATA%` | `$XDG_CONFIG_HOME`, sonst `~/.config` |
|
||||||
|
| `data` | `%LOCALAPPDATA%` | `$XDG_DATA_HOME`, sonst `~/.local/share` |
|
||||||
|
| `home` | Benutzerprofil | `$HOME` |
|
||||||
|
|
||||||
|
**Warum das nötig ist:** Eine Anwendung, die sich unter Linux richtig verhält,
|
||||||
|
legt ihre Konfiguration nicht neben das Programm — `/opt/<app>` ist für den
|
||||||
|
Dienstbenutzer typischerweise nicht schreibbar. Ohne Ortsangabe schriebe der
|
||||||
|
Installer die Datei dorthin, wo die Anwendung nie nachsieht.
|
||||||
|
|
||||||
|
In `file` sind Umgebungsvariablen in beiden Schreibweisen erlaubt:
|
||||||
|
`${XDG_CONFIG_HOME}/app/Settings.json` und `%APPDATA%\app\Settings.json`. Ein
|
||||||
|
absoluter Pfad wird unverändert benutzt. Unbekannte Variablen bleiben stehen,
|
||||||
|
statt zu einer leeren Zeichenkette zu werden — ein stehengebliebener
|
||||||
|
Platzhalter fällt auf, ein stillschweigend falscher Pfad nicht.
|
||||||
|
|
||||||
|
> **Wer den Installer startet, entscheidet mit.** `config`, `data` und `home`
|
||||||
|
> beziehen sich auf das Konto, unter dem der Installer läuft. Wird er als
|
||||||
|
> `root` oder Administrator gestartet, der Dienst aber unter einem eigenen
|
||||||
|
> Konto betrieben, landet die Datei im falschen Profil. Der Installer gibt
|
||||||
|
> deshalb den **aufgelösten Pfad** aus — prüfe ihn. Für Dienste ist ein
|
||||||
|
> absoluter Pfad oft die ehrlichere Angabe.
|
||||||
|
|
||||||
|
#### Wenn die Schreibweise plattformabhängig ist
|
||||||
|
|
||||||
|
Konfigurationsordner heißen nicht überall gleich: `%APPDATA%\MeineAnwendung`
|
||||||
|
ist unter Windows üblich, `$XDG_CONFIG_HOME/meineanwendung` unter Linux klein.
|
||||||
|
|
||||||
|
Meistens genügt es, die **kleingeschriebene Form** anzugeben. Sie trifft unter
|
||||||
|
Linux exakt und unter Windows ebenfalls, weil NTFS die Schreibweise ignoriert.
|
||||||
|
Andersherum funktioniert es nicht — unter Linux entstünde ein zweites, leeres
|
||||||
|
Verzeichnis neben dem, aus dem die Anwendung liest.
|
||||||
|
|
||||||
|
Dieser Kniff trägt aber nur, solange das Dateisystem wirklich unempfindlich
|
||||||
|
ist. Auf APFS mit Beachtung der Schreibweise, auf einem per `fsutil`
|
||||||
|
umgestellten NTFS-Ordner oder wenn sich die Namen um **mehr als die
|
||||||
|
Schreibweise** unterscheiden, braucht es eine eigene Angabe:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "user",
|
||||||
|
"file": "meineanwendung/Settings.json",
|
||||||
|
"fileWindows": "MeineAnwendung/Settings.json",
|
||||||
|
"location": "config"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`fileLinux` und `fileMacOS` gibt es ebenso. Ohne Angabe gilt `file`.
|
||||||
|
|
||||||
|
### Dateirechte
|
||||||
|
|
||||||
|
Enthält ein Ziel mindestens einen als geheim gekennzeichneten Wert, wird die
|
||||||
|
geschriebene Datei auf den eigenen Benutzer beschränkt (`600` unter Linux und
|
||||||
|
macOS). Ohne das schriebe .NET mit der Standardmaske, unter Linux üblicherweise
|
||||||
|
`644` — und in dieser Datei stehen Lizenzschlüssel und Anwendungstoken.
|
||||||
|
|
||||||
|
Als geheim gilt jedes Feld mit `type: "secret"` **und** jedes mit
|
||||||
|
`source: "provision"` — ein so geholtes Token trägt oft den Typ `string`, ist
|
||||||
|
aber genauso schutzbedürftig.
|
||||||
|
|
||||||
|
Unter Windows geschieht nichts: Dateien in `%APPDATA%` erben die
|
||||||
|
Zugriffsrechte des Profils.
|
||||||
|
|
||||||
|
> **Der Installer verschlüsselt nicht.** Er schreibt Klartext; ob und wie
|
||||||
|
> Geheimnisse anschließend geschützt werden, entscheidet die Anwendung — etwa
|
||||||
|
> über DPAPI beim ersten Speichern. Das setzt voraus, dass eure
|
||||||
|
> Entschlüsselung Klartext unverändert durchreicht, statt an ihm zu scheitern.
|
||||||
|
> Wer eine solche Weiche hat, sollte sie im Test als **aktiv genutzt**
|
||||||
|
> beschreiben und nicht als Altlast — sonst wird sie eines Tages als tot
|
||||||
|
> entfernt und sperrt jede frisch installierte Instanz aus.
|
||||||
|
|
||||||
`type` bestimmt auch, **wie** geschrieben wird: `int` und `bool` landen als
|
`type` bestimmt auch, **wie** geschrieben wird: `int` und `bool` landen als
|
||||||
JSON-Zahl beziehungsweise -Wahrheitswert, nicht als Zeichenkette. Sonst
|
JSON-Zahl beziehungsweise -Wahrheitswert, nicht als Zeichenkette. Sonst
|
||||||
scheitert die Bindung in der Anwendung.
|
scheitert die Bindung in der Anwendung.
|
||||||
@@ -239,6 +324,64 @@ der Installer schreibt die abgefragten Werte hinein. Siehe
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 4A. Einen laufenden systemd-Dienst aktualisieren
|
||||||
|
|
||||||
|
Der `update-agent` schreibt nach `--target-dir` — unter `/opt/<app>` hat der
|
||||||
|
Dienstbenutzer dort keinen Schreibzugriff. Als `root` gestartet würde er zwar
|
||||||
|
schreiben können, dann aber über `--restart` **an systemd vorbei** einen
|
||||||
|
zweiten Prozess starten.
|
||||||
|
|
||||||
|
Die Abfolge lautet deshalb: anhalten, aktualisieren, starten — und der Agent
|
||||||
|
startet **nichts** selbst.
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# /etc/systemd/system/myapp-update.service
|
||||||
|
[Unit]
|
||||||
|
Description=Update für myapp einspielen
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
# Der Agent braucht Schreibrecht in /opt/myapp.
|
||||||
|
User=root
|
||||||
|
|
||||||
|
# Der Lizenzschlüssel gehört nicht in die Kommandozeile - "ps" zeigt sie
|
||||||
|
# jedem Benutzer der Maschine. EnvironmentFile mit 0600 und root:root.
|
||||||
|
EnvironmentFile=/etc/myapp/update.env
|
||||||
|
|
||||||
|
ExecStartPre=/usr/bin/systemctl stop myapp.service
|
||||||
|
ExecStart=/opt/myapp/update-agent \
|
||||||
|
--action update --project myapp --channel prod \
|
||||||
|
--target-dir /opt/myapp --version latest
|
||||||
|
ExecStartPost=/usr/bin/systemctl start myapp.service
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# /etc/myapp/update.env chmod 600, chown root:root
|
||||||
|
DC_LICENSE_KEY=XXXXX-XXXXX-XXXXX-XXXXX-XXXXX
|
||||||
|
```
|
||||||
|
|
||||||
|
Auslösen von Hand oder über einen Timer:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl start myapp-update.service
|
||||||
|
```
|
||||||
|
|
||||||
|
**Kein `--restart` und kein `--wait-for-pid`.** Den Neustart übernimmt
|
||||||
|
`ExecStartPost`, das Anhalten `ExecStartPre` — der Dienst ist beim Anwenden
|
||||||
|
bereits beendet, es gibt keinen Prozess, auf den zu warten wäre.
|
||||||
|
|
||||||
|
Wird der Update-Weg dagegen **aus der Anwendung heraus** angestoßen, gilt
|
||||||
|
`restartPath: ""` und `exitCurrentApp: false`: Die Anwendung fährt sich selbst
|
||||||
|
geordnet herunter, systemd startet sie über `Restart=on-success` neu.
|
||||||
|
|
||||||
|
> **Dateirechte nach dem Update.** Der Agent läuft als `root` und legt die
|
||||||
|
> Dateien entsprechend an. Gehört der Dienst einem anderen Benutzer, gehört
|
||||||
|
> ein `ExecStartPost=/usr/bin/chown -R myapp:myapp /opt/myapp` davor — sonst
|
||||||
|
> startet der Dienst danach nicht mehr.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 5. Nachträglich einrichten
|
## 5. Nachträglich einrichten
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -26,8 +26,27 @@
|
|||||||
> - `LaunchUpdateAgent` übergibt jetzt Neustart, Prozesskennung und Plattform,
|
> - `LaunchUpdateAgent` übergibt jetzt Neustart, Prozesskennung und Plattform,
|
||||||
> siehe **[§2A](#a-referenz-auf-deploymentcenterclient)**.
|
> siehe **[§2A](#a-referenz-auf-deploymentcenterclient)**.
|
||||||
|
|
||||||
|
> **⚠️ Geändert in Version 2.5.2**
|
||||||
|
> - **`LaunchUpdateAgent` reicht `--require-signature` durch.** Zuvor gab es
|
||||||
|
> den Schalter nur auf der Kommandozeile — der empfohlene Weg über das SDK
|
||||||
|
> aktualisierte damit ungeprüft, siehe
|
||||||
|
> **[§6, "Erzwingen über LaunchUpdateAgent"](#erzwingen-über-launchupdateagent)**.
|
||||||
|
|
||||||
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: statisch ausgelieferte Pakete plus eine PHP-API.
|
||||||
|
|
||||||
|
> **⚠️ Der Webserver ist nicht beliebig.** Ältere Fassungen dieser Anleitung
|
||||||
|
> beschrieben den Stack durchgehend als „LEMP (Nginx + PHP)". Der
|
||||||
|
> Zugangsschutz aus **[§5A](#5a-zugangsschutz-der-release-verzeichnisse)**
|
||||||
|
> beruht auf `.htaccess` und wird **von Nginx vollständig ignoriert** — dort
|
||||||
|
> wären die Release-Verzeichnisse offen und die `.htpasswd` sogar öffentlich
|
||||||
|
> abrufbar, während die Oberfläche „geschützt" meldete.
|
||||||
|
>
|
||||||
|
> `dc.mhdf.de` läuft auf **Apache** mit aktivem `AllowOverride`, dort trägt es.
|
||||||
|
> Wer auf Nginx ausrollt, muss den Schutz in der Serverkonfiguration
|
||||||
|
> nachbilden — die Vorlage steht in [§5A](#nginx-statt-apache). Verlass dich
|
||||||
|
> nicht auf die Anzeige, sondern auf den **Selbsttest**: er ruft die eigene
|
||||||
|
> Paket-Adresse ohne Zugangsdaten ab und erwartet 401.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -37,7 +56,8 @@ Das **UpdateService-Modul** des Deploymentcenters bietet ein unternehmensweites,
|
|||||||
- **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.
|
- **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 Verteilung**: Downloads und Versionen-Manifeste (`latest.json`, `manifest.json`, `package.tar.gz`) liefert der Webserver direkt aus, ohne PHP im Weg.
|
||||||
|
- **Zugangsschutz über den Lizenzschlüssel** — setzt Apache voraus, siehe Kasten oben.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -150,16 +170,28 @@ ein Dienst-Manager den Neustart übernimmt.
|
|||||||
|
|
||||||
### 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:
|
Version, UTC-Build-Datum und Git-Commit entstehen automatisch zur
|
||||||
|
Übersetzungszeit. **Seit Version 2.5 genügt dafür der Paketverweis:**
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>1.4.3</Version>
|
<Version>1.4.3</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<Import Project="..\Deploymentcenter.Client\Deploymentcenter.BuildInfo.targets" />
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Deploymentcenter.Client" Version="2.5.1" />
|
||||||
|
</ItemGroup>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Das Targets-File liegt im Paket unter `build/` und wird von NuGet selbst
|
||||||
|
importiert — ein `<Import>` von Hand entfällt.
|
||||||
|
|
||||||
|
> **Der bisherige Weg koppelte zwei Repositories.** Die Anleitung empfahl
|
||||||
|
> `<Import Project="..\Deploymentcenter.Client\Deploymentcenter.BuildInfo.targets" />`
|
||||||
|
> — das setzt voraus, dass beide Arbeitskopien nebeneinander liegen und in
|
||||||
|
> derselben Fassung stehen. Wer nur das Paket bezieht, hatte das Target nicht.
|
||||||
|
> Der Import per Pfad funktioniert weiterhin, ist aber nicht mehr nötig.
|
||||||
|
|
||||||
Die Klasse entsteht im Namensraum deines Projekts (`$(RootNamespace)`), nicht im
|
Die Klasse entsteht im Namensraum deines Projekts (`$(RootNamespace)`), nicht im
|
||||||
SDK. Du erreichst sie also ohne `using`:
|
SDK. Du erreichst sie also ohne `using`:
|
||||||
|
|
||||||
@@ -219,6 +251,20 @@ für die API-Antwort. `UpdateCheckResult.LatestRelease` ist in beiden Fällen ei
|
|||||||
|
|
||||||
## 3. Packaging & Deployment CLI (`pack-and-deploy`)
|
## 3. Packaging & Deployment CLI (`pack-and-deploy`)
|
||||||
|
|
||||||
|
> **Woher das Werkzeug kommt.** Frühere Fassungen dieser Anleitung benutzten
|
||||||
|
> `pack-and-deploy`, als läge es im PATH — beziehbar war es nirgends. Es steht
|
||||||
|
> jetzt unter `/installer/` bereit:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> wget https://dc.mhdf.de/installer/pack-and-deploy-linux-x64 -O pack-and-deploy
|
||||||
|
> chmod +x pack-and-deploy
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> Wer nicht von Hand aufrufen will, nimmt die **Release-Vorlage**: ein Skript
|
||||||
|
> zum Kopieren ins eigene Projekt, das je Plattform `dotnet publish` und
|
||||||
|
> `pack-and-deploy` verkettet und sich das Werkzeug selbst holt. Siehe
|
||||||
|
> **[Release-Anleitung für Agenten](../public/docs/release.md)**.
|
||||||
|
|
||||||
Das Packaging-Tool verpackt den `dotnet publish`-Output, berechnet Hashes, erzeugt das `manifest.json` und lädt alles per FTP auf den LEMP-Server.
|
Das Packaging-Tool verpackt den `dotnet publish`-Output, berechnet Hashes, erzeugt das `manifest.json` und lädt alles per FTP auf den LEMP-Server.
|
||||||
|
|
||||||
### Aufruf-Beispiel:
|
### Aufruf-Beispiel:
|
||||||
@@ -332,8 +378,27 @@ Die Muster sind jetzt echte Globs:
|
|||||||
| `wwwroot/**/*.css` | auch in Unterverzeichnissen |
|
| `wwwroot/**/*.css` | auch in Unterverzeichnissen |
|
||||||
| `appsettings*.json` | `appsettings.json`, `appsettings.Production.json`, … |
|
| `appsettings*.json` | `appsettings.json`, `appsettings.Production.json`, … |
|
||||||
|
|
||||||
Der Packager warnt zusätzlich von sich aus, wenn eine Datei nach Zugangsdaten
|
### Der Packager bricht bei Zugangsdaten ab
|
||||||
aussieht und auf keiner der beiden Listen steht.
|
|
||||||
|
Seit Version 2.5 nicht mehr nur eine Warnung. Anlass war ein echter
|
||||||
|
API-Schlüssel, der so in ein öffentlich abrufbares Paket geriet — und die
|
||||||
|
Warnung wurde damals ausgerechnet unterdrückt, weil die Datei auf der
|
||||||
|
`preserve`-Liste stand.
|
||||||
|
|
||||||
|
| Stufe | Findet |
|
||||||
|
|---|---|
|
||||||
|
| Dateiname | `appsettings.Local.json`, `secrets.json`, `master.key`, `*.pfx`, `*.db`, `server_settings.xml`, … |
|
||||||
|
| Inhalt | gefüllte `Password=`-Felder, `sk-…`, `ghp_…`, `dc_master_…`, `AKIA…`, private Schlüssel |
|
||||||
|
|
||||||
|
Die Inhaltsprüfung findet auch Dateien mit unverdächtigem Namen. Platzhalter
|
||||||
|
wie `sk-DEIN-SCHLUESSEL-HIER` oder `Password=changeme` lösen **nicht** aus —
|
||||||
|
sonst würde jede ausgelieferte Vorlage den Build blockieren, und
|
||||||
|
`--allow-secrets` wäre nach einer Woche Gewohnheit.
|
||||||
|
|
||||||
|
**Der Zugangsschutz macht das nicht harmlos.** Ein Schlüssel im Paket ist dann
|
||||||
|
für jeden Lizenznehmer lesbar statt für alle — besser, aber immer noch falsch.
|
||||||
|
|
||||||
|
Notausgang für bewusste Fälle: `--allow-secrets`.
|
||||||
|
|
||||||
### Versionsgegenprobe
|
### Versionsgegenprobe
|
||||||
|
|
||||||
@@ -604,12 +669,21 @@ Darin stehen:
|
|||||||
|
|
||||||
| Eintrag | Benutzername | Passwort |
|
| Eintrag | Benutzername | Passwort |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Gültige Lizenz | der Lizenzschlüssel | derselbe Schlüssel |
|
| Gültige Lizenz | `lic_` + erste 16 Hexzeichen von SHA-256(Schlüssel) | der Schlüssel |
|
||||||
| Installationskonto | DC-Benutzername | dessen Passwort |
|
| Installationskonto | DC-Benutzername | dessen Passwort |
|
||||||
|
|
||||||
Beim Lizenzschlüssel sind Benutzername und Passwort identisch: Basic Auth
|
**Der Benutzername ist eine Ableitung, nicht der Schlüssel selbst.** Das
|
||||||
verlangt zwei Felder, es gibt aber nur ein Geheimnis, und Benutzernamen müssen
|
htpasswd-Format hasht nur die Passwortspalte. Stünde der Lizenzschlüssel auch
|
||||||
eindeutig sein.
|
als Benutzername darin, wäre die Datei eine vollständige Klartext-Kundenliste
|
||||||
|
und der bcrypt-Hash daneben bloße Dekoration — ein einziger
|
||||||
|
Konfigurationsfehler vom Leak entfernt. So enthält sie nur eine
|
||||||
|
Einwegableitung und einen Hash über einen hochentropen Schlüssel; selbst
|
||||||
|
offengelegt ist damit nichts anzufangen.
|
||||||
|
|
||||||
|
Die Ableitung muss auf beiden Seiten zeichengenau übereinstimmen:
|
||||||
|
`ReleaseGuard::licenseUsername()` serverseitig,
|
||||||
|
`ReleaseCredentials.UsernameForLicenseKey()` im SDK. Weichen sie voneinander
|
||||||
|
ab, kommt niemand mehr an seine Updates.
|
||||||
|
|
||||||
Die Installationskonten stehen in **jeder** Datei — bei einer Erstinstallation
|
Die Installationskonten stehen in **jeder** Datei — bei einer Erstinstallation
|
||||||
gibt es noch keinen Lizenzschlüssel, mit dem sich das Paket holen ließe. Ihr
|
gibt es noch keinen Lizenzschlüssel, mit dem sich das Paket holen ließe. Ihr
|
||||||
@@ -617,6 +691,47 @@ Hash wird unverändert aus `dc_users` übernommen: PHPs `password_hash()` erzeug
|
|||||||
bcrypt im Format `$2y$`, und genau das versteht Apache. Ein Klartextpasswort
|
bcrypt im Format `$2y$`, und genau das versteht Apache. Ein Klartextpasswort
|
||||||
wird nirgends gebraucht.
|
wird nirgends gebraucht.
|
||||||
|
|
||||||
|
### Woher die Anwendung den Schlüssel nimmt
|
||||||
|
|
||||||
|
Die häufigste Rückfrage bei der Integration: Der Schlüssel steht **im
|
||||||
|
Lizenz-Cache**, den das Lizenzmodul ohnehin führt. Es muss nichts zusätzlich
|
||||||
|
gespeichert werden.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Beim Start einmal: Lizenz prüfen (legt den Schlüssel im Cache ab)
|
||||||
|
var license = await new LicenseClient().EnsureLicensedAsync(
|
||||||
|
productSlug: "myapp",
|
||||||
|
serverBaseUrl: "https://dc.mhdf.de",
|
||||||
|
allowPrompt: false); // im Dienst: nicht nach einer Eingabe warten
|
||||||
|
|
||||||
|
if (!license.IsValid) { /* Sperrbetrieb, siehe Lizenz-Leitfaden §6 */ }
|
||||||
|
|
||||||
|
// Danach jederzeit für den Update-Weg:
|
||||||
|
string? key = LicenseClient.TryGetCachedKey("myapp");
|
||||||
|
|
||||||
|
var check = await new UpdateClient().CheckForUpdateAsync(
|
||||||
|
baseUrl: "https://dc.mhdf.de",
|
||||||
|
projectId: "myapp",
|
||||||
|
currentVersion: BuildInfo.Version,
|
||||||
|
channel: "prod",
|
||||||
|
credentials: ReleaseCredentials.FromLicenseKey(key));
|
||||||
|
|
||||||
|
if (check.Unauthorized)
|
||||||
|
{
|
||||||
|
// Kein Netzwerkfehler: Die Lizenz trägt nicht mehr.
|
||||||
|
log.Warn(check.Message);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`TryGetCachedKey` ist statisch und liest den verschlüsselten Cache aus
|
||||||
|
`LicenseConfig.GetStorageDirectory(slug)`. Liefert er `null`, wurde noch nie
|
||||||
|
erfolgreich validiert — dann gibt es auch keinen Update-Zugang.
|
||||||
|
|
||||||
|
> **Im Dienst ohne Heimatverzeichnis** (systemd `User=` ohne `$HOME`) fällt der
|
||||||
|
> Cache auf `/var/lib/<slug>/license` zurück. Ist auch das nicht beschreibbar,
|
||||||
|
> gibt es keinen Offline-Cache — dann muss `DEPLOYMENTCENTER_STORAGE_DIR` auf
|
||||||
|
> ein beschreibbares Verzeichnis zeigen.
|
||||||
|
|
||||||
### Was sich für Clients ändert
|
### Was sich für Clients ändert
|
||||||
|
|
||||||
**Ohne Nachziehen bekommt keine bestehende Installation mehr Updates.**
|
**Ohne Nachziehen bekommt keine bestehende Installation mehr Updates.**
|
||||||
@@ -641,14 +756,51 @@ UpdateClient.LaunchUpdateAgent(
|
|||||||
licenseKey: meineLizenz); // reicht --license-key an den Agenten durch
|
licenseKey: meineLizenz); // reicht --license-key an den Agenten durch
|
||||||
```
|
```
|
||||||
|
|
||||||
Auf der Kommandozeile:
|
Auf der Kommandozeile — **bevorzugt über die Umgebung**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
update-agent --project myapp --action update --license-key XXXXX-XXXXX-XXXXX-XXXXX-XXXXX
|
DC_LICENSE_KEY=XXXXX-XXXXX-XXXXX-XXXXX-XXXXX \
|
||||||
|
update-agent --project myapp --action update
|
||||||
```
|
```
|
||||||
|
|
||||||
Ersatzweise `--download-user` und `--download-password` für ein
|
`--license-key` gibt es weiterhin, ist aber die schlechtere Wahl: Was in `argv`
|
||||||
Installationskonto — das nutzt der Installer bei der Erstinstallation.
|
steht, zeigt `ps` jedem Benutzer der Maschine. Aus genau diesem Grund verwirft
|
||||||
|
[UPGRADE.md §5](./UPGRADE.md#5-cron-für-den-watchdog-evaluator-einrichten) den
|
||||||
|
Weg, den Shared Key im Crontab zu hinterlegen — für einen Lizenzschlüssel gilt
|
||||||
|
dasselbe. Umgebungsvariablen haben Vorrang vor den Argumenten.
|
||||||
|
|
||||||
|
`LaunchUpdateAgent` übergibt den Schlüssel deshalb **nicht** als Argument: Es
|
||||||
|
setzt `DC_LICENSE_KEY` auf dem eigenen Prozess, der Agent erbt den
|
||||||
|
Umgebungsblock beim Start, und danach wird die Variable wieder entfernt.
|
||||||
|
|
||||||
|
Ersatzweise `DC_DOWNLOAD_USER` und `DC_DOWNLOAD_PASSWORD` (oder
|
||||||
|
`--download-user` / `--download-password`) für ein Installationskonto — das
|
||||||
|
nutzt der Installer bei der Erstinstallation.
|
||||||
|
|
||||||
|
### Wartezeit beim Übergeben
|
||||||
|
|
||||||
|
`LaunchUpdateAgent` gibt dem Agenten mit, wie lange er auf das Ende der
|
||||||
|
Anwendung warten soll. Vorgabe sind 60 Sekunden:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
UpdateClient.LaunchUpdateAgent(
|
||||||
|
agentPath: agentPath,
|
||||||
|
projectId: "myapp",
|
||||||
|
licenseKey: meineLizenz,
|
||||||
|
waitTimeoutSeconds: 180, // reicht auch für ein langsames StopAsync
|
||||||
|
exitCurrentApp: false); // siehe unten
|
||||||
|
```
|
||||||
|
|
||||||
|
Läuft die Zeit ab, **bricht der Agent ab, ohne etwas zu verändern** — sicher,
|
||||||
|
aber eben auch kein Update. Wer allein für `host.StopAsync` 30 Sekunden
|
||||||
|
braucht, sollte den Wert heraufsetzen.
|
||||||
|
|
||||||
|
> **`exitCurrentApp: true` beendet den Prozess mit `Environment.Exit(0)`.**
|
||||||
|
> Laufende `finally`-Blöcke anderer Threads, `IHostApplicationLifetime` und
|
||||||
|
> Destruktoren kommen dann nicht mehr zum Zug. Für eine Anwendung mit offenem
|
||||||
|
> Zustand — Positionen, Transaktionen, ungeschriebene Puffer — ist das die
|
||||||
|
> falsche Wahl. Dort `exitCurrentApp: false` setzen und anschließend den
|
||||||
|
> eigenen geordneten Weg gehen; der Agent wartet ohnehin auf das Prozessende.
|
||||||
|
|
||||||
`UpdateCheckResult.Unauthorized` ist bewusst von einem gewöhnlichen Fehler
|
`UpdateCheckResult.Unauthorized` ist bewusst von einem gewöhnlichen Fehler
|
||||||
getrennt. Ohne diese Unterscheidung sieht ein abgelaufener Vertrag aus wie eine
|
getrennt. Ohne diese Unterscheidung sieht ein abgelaufener Vertrag aus wie eine
|
||||||
@@ -669,10 +821,52 @@ Produkte geschützt sind und wie viele Zugänge jeweils eingetragen sind.
|
|||||||
|
|
||||||
| Anzeige | Bedeutung |
|
| Anzeige | Bedeutung |
|
||||||
|---|---|
|
|---|---|
|
||||||
| GESCHÜTZT | alles in Ordnung |
|
| GESCHÜTZT | die Dateien liegen vor — **das allein beweist nichts** |
|
||||||
| OFFEN | keine `.htaccess` — jeder im Internet kann laden |
|
| OFFEN | keine `.htaccess` — jeder im Internet kann laden |
|
||||||
| GESPERRT | Datei vorhanden, aber leer: weder gültige Lizenzen noch Installationskonten |
|
| GESPERRT | Datei vorhanden, aber leer: weder gültige Lizenzen noch Installationskonten |
|
||||||
|
|
||||||
|
### Der Selbsttest ist die einzige belastbare Aussage
|
||||||
|
|
||||||
|
Dass `.htaccess` und `.htpasswd` existieren, sagt nichts darüber, ob sie
|
||||||
|
ausgewertet werden. Unter Nginx werden sie ignoriert, bei abgeschaltetem
|
||||||
|
`AllowOverride` ebenso, und ein Tippfehler in der Datei führt zu 500 statt 401.
|
||||||
|
In allen drei Fällen stünde in der Übersicht „GESCHÜTZT", während die Pakete
|
||||||
|
offen im Netz lägen.
|
||||||
|
|
||||||
|
Der Selbsttest ruft deshalb die **eigene Paket-Adresse ohne Zugangsdaten** ab
|
||||||
|
und erwartet 401. Er läuft bei jedem manuellen Erzeugen mit und nach jeder
|
||||||
|
automatischen Neuerzeugung durch `cli/tick.php`; das Ergebnis steht in der
|
||||||
|
Oberfläche und bei Fehlschlag im Log.
|
||||||
|
|
||||||
|
Von Hand nachprüfen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -I https://dc.mhdf.de/releases/<produkt>/prod/<rid>/<version>/package.tar.gz # 401
|
||||||
|
curl -I https://dc.mhdf.de/releases/<produkt>/.htpasswd # 403
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nginx statt Apache
|
||||||
|
|
||||||
|
Dort greift `.htaccess` nicht. Der Schutz muss in die Serverkonfiguration:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location ^~ /releases/ {
|
||||||
|
# Je Produkt eine eigene Datei - sonst öffnet eine Lizenz für A auch B.
|
||||||
|
# $1 ist der Produkt-Slug aus dem Pfad.
|
||||||
|
location ~ ^/releases/([^/]+)/ {
|
||||||
|
auth_basic "Deploymentcenter Releases";
|
||||||
|
auth_basic_user_file /pfad/zum/webroot/releases/$1/.htpasswd;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Die Zugangsdateien selbst nie ausliefern.
|
||||||
|
location ~ /\.ht { deny all; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ReleaseGuard` erzeugt die `.htpasswd`-Dateien unverändert weiter — nur die
|
||||||
|
`.htaccess` bleibt dort wirkungslos. Der Selbsttest bestätigt anschließend,
|
||||||
|
dass es trägt.
|
||||||
|
|
||||||
### Grenzen
|
### Grenzen
|
||||||
|
|
||||||
**Das macht Pakete nicht sicher.** Jeder lizenzierte Kunde kann sie weiterhin
|
**Das macht Pakete nicht sicher.** Jeder lizenzierte Kunde kann sie weiterhin
|
||||||
@@ -755,6 +949,36 @@ auf. War der Wechsel beabsichtigt, die Datei löschen.
|
|||||||
Ohne hinterlegten Schlüssel bleibt also alles funktionsfähig — es fehlt nur die
|
Ohne hinterlegten Schlüssel bleibt also alles funktionsfähig — es fehlt nur die
|
||||||
Vertrauenskette, und darauf wird bei jedem Update hingewiesen.
|
Vertrauenskette, und darauf wird bei jedem Update hingewiesen.
|
||||||
|
|
||||||
|
### Erzwingen über `LaunchUpdateAgent`
|
||||||
|
|
||||||
|
**Seit 2.5.2.** `--require-signature` war vorher nur über die Kommandozeile
|
||||||
|
erreichbar — `LaunchUpdateAgent`, der empfohlene Weg aus
|
||||||
|
**[§2A](#a-referenz-auf-deploymentcenterclient)**, hatte dafür keinen
|
||||||
|
Parameter. Eine Anwendung, die dem Leitfaden folgte, aktualisierte damit
|
||||||
|
ungeprüft, während derselbe Vorgang von Hand auf der Kommandozeile geschützt
|
||||||
|
gewesen wäre — ausgerechnet bei dem Feature, das genau diesen Pfad absichern
|
||||||
|
soll.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
UpdateClient.LaunchUpdateAgent(
|
||||||
|
agentPath: agentPath,
|
||||||
|
projectId: "myapp",
|
||||||
|
licenseKey: meineLizenz,
|
||||||
|
requireSignature: true);
|
||||||
|
```
|
||||||
|
|
||||||
|
Um den öffentlichen Schlüssel muss sich niemand zusätzlich kümmern: der Agent
|
||||||
|
holt und cached ihn ohnehin selbst von `/api/updateservice/v1/pubkey` (siehe
|
||||||
|
oben, "Prüfen"). `--pubkey` bleibt nötig, wenn ein Schlüssel ohne
|
||||||
|
Netzwerkzugriff hinterlegt werden soll.
|
||||||
|
|
||||||
|
**Erst scharfschalten, wenn wirklich alles signiert ist.** Ist
|
||||||
|
`security.release_private_key` gesetzt, signiert der Server nur *neue*
|
||||||
|
Veröffentlichungen — bereits abgelegte Releases bleiben unsigniert, bis sie
|
||||||
|
erneut veröffentlicht werden. Mit `requireSignature: true` bricht der Agent
|
||||||
|
bei jedem noch unsignierten Release ab, auch bei einem sonst harmlosen
|
||||||
|
Downgrade-Schutz oder einer Reparatur auf eine ältere Version.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Release-Verwaltung (Bearbeiten & Löschen)
|
## 7. Release-Verwaltung (Bearbeiten & Löschen)
|
||||||
@@ -775,7 +999,7 @@ Veröffentlichte Releases können sowohl über das Web-Interface als auch über
|
|||||||
- Antwort (200 OK):
|
- Antwort (200 OK):
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "ok",
|
"status": "success",
|
||||||
"release_id": 42,
|
"release_id": 42,
|
||||||
"updated": true,
|
"updated": true,
|
||||||
"platform": "win-x64",
|
"platform": "win-x64",
|
||||||
@@ -791,7 +1015,7 @@ Veröffentlichte Releases können sowohl über das Web-Interface als auch über
|
|||||||
- Antwort (200 OK):
|
- Antwort (200 OK):
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "ok",
|
"status": "success",
|
||||||
"release_id": 42,
|
"release_id": 42,
|
||||||
"deleted": true,
|
"deleted": true,
|
||||||
"message": "Release v1.2.0 (prod, win-x64) fuer \"myapp\" geloescht."
|
"message": "Release v1.2.0 (prod, win-x64) fuer \"myapp\" geloescht."
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ kompromittiert zu behandeln:
|
|||||||
- [ ] `.htpasswd`-Passwort für `deploy` neu setzen
|
- [ ] `.htpasswd`-Passwort für `deploy` neu setzen
|
||||||
- [ ] Git-Token `eb429575…` widerrufen und neu ausstellen
|
- [ ] Git-Token `eb429575…` widerrufen und neu ausstellen
|
||||||
- [ ] Admin-Passwort im WebUI ändern (die alte Fassung setzte es bei jedem
|
- [ ] Admin-Passwort im WebUI ändern (die alte Fassung setzte es bei jedem
|
||||||
Aufruf von `install_db.php` auf `Admin1337!` zurück — jeder im Internet
|
Aufruf von `install_db.php` auf `Admin1337!Admin1337!` zurück — jeder im Internet
|
||||||
konnte das auslösen)
|
konnte das auslösen)
|
||||||
|
|
||||||
Wenn die Historie bereinigt werden soll, geht das mit
|
Wenn die Historie bereinigt werden soll, geht das mit
|
||||||
@@ -529,6 +529,21 @@ Läuft `cli/tick.php` als Cron, erzeugt es den Schutz beim ersten Lauf nach dem
|
|||||||
Deployment **von selbst**. Wer die Reihenfolge einhalten will, spielt den
|
Deployment **von selbst**. Wer die Reihenfolge einhalten will, spielt den
|
||||||
Serverteil also erst dann ein, wenn Schritt 3 erledigt ist.
|
Serverteil also erst dann ein, wenn Schritt 3 erledigt ist.
|
||||||
|
|
||||||
|
> **Neue Produkte: die Reihenfolge oben gilt für sie nicht.**
|
||||||
|
>
|
||||||
|
> `ReleaseGuard` überspringt Verzeichnisse, die es nicht gibt — für ein Produkt
|
||||||
|
> ohne Release ist `/releases/<slug>/` schlicht nicht vorhanden und damit auch
|
||||||
|
> nicht geschützt. Das Verzeichnis entsteht erst mit dem **ersten** Upload, und
|
||||||
|
> der nächste `tick.php`-Lauf legt den Schutz darin an.
|
||||||
|
>
|
||||||
|
> Es gibt also kein Zeitfenster, in dem sich ein noch ungeschütztes Release
|
||||||
|
> herunterladen ließe, um dann das SDK nachzuziehen. **Der erste ausgelieferte
|
||||||
|
> Build muss die Zugangsdaten bereits mitbringen** — sonst fällt die Tür hinter
|
||||||
|
> ihm zu.
|
||||||
|
>
|
||||||
|
> Für Neuprodukte lautet die Reihenfolge daher schlicht: SDK 2.4 einbinden,
|
||||||
|
> `licenseKey` übergeben, *dann* das erste Release veröffentlichen.
|
||||||
|
|
||||||
### 16.2 Was wo eingetragen wird
|
### 16.2 Was wo eingetragen wird
|
||||||
|
|
||||||
Je Produktverzeichnis eine `.htpasswd` mit den aktiven, nicht abgelaufenen
|
Je Produktverzeichnis eine `.htpasswd` mit den aktiven, nicht abgelaufenen
|
||||||
@@ -552,9 +567,15 @@ wget -qO- https://dc.mhdf.de/installer/install.sh | sh
|
|||||||
|
|
||||||
### 16.4 Prüfen
|
### 16.4 Prüfen
|
||||||
|
|
||||||
|
- [ ] **Selbsttest** im WebUI unter *UpdateService → 🔒 Zugangsschutz* — er ruft
|
||||||
|
die eigene Paket-Adresse ohne Zugangsdaten ab und erwartet 401. Die
|
||||||
|
Anzeige „GESCHÜTZT" allein beweist nichts: Sie prüft nur, ob die Dateien
|
||||||
|
existieren, nicht ob der Server sie auswertet.
|
||||||
- [ ] `curl -I https://dc.mhdf.de/releases/<produkt>/prod/<rid>/<version>/package.tar.gz`
|
- [ ] `curl -I https://dc.mhdf.de/releases/<produkt>/prod/<rid>/<version>/package.tar.gz`
|
||||||
→ **401**
|
→ **401**
|
||||||
- [ ] Mit `-u "<lizenzschlüssel>:<lizenzschlüssel>"` → **200**
|
- [ ] Mit `-u "$(printf 'lic_%s' "$(printf %s "<schlüssel>" | sha256sum | cut -c1-16)"):<schlüssel>"` → **200**
|
||||||
|
(Benutzername ist die Ableitung, nicht der Schlüssel — siehe
|
||||||
|
[§5A](./UPDATESERVICE_INTEGRATION_GUIDE.md#5a-zugangsschutz-der-release-verzeichnisse))
|
||||||
- [ ] `curl -I https://dc.mhdf.de/releases/<produkt>/.htpasswd` → **403**
|
- [ ] `curl -I https://dc.mhdf.de/releases/<produkt>/.htpasswd` → **403**
|
||||||
- [ ] `https://dc.mhdf.de/installer/update-agent-linux-x64` → **200**, weiterhin offen
|
- [ ] `https://dc.mhdf.de/installer/update-agent-linux-x64` → **200**, weiterhin offen
|
||||||
- [ ] Eine Lizenz widerrufen und erneut mit ihr laden → **401**
|
- [ ] Eine Lizenz widerrufen und erneut mit ihr laden → **401**
|
||||||
|
|||||||
@@ -5,10 +5,18 @@
|
|||||||
"displayName": "Beispielanwendung",
|
"displayName": "Beispielanwendung",
|
||||||
"description": "Fuer den Betrieb werden eine Datenbank und ein Zugang zum Kursdatenanbieter benoetigt.",
|
"description": "Fuer den Betrieb werden eine Datenbank und ein Zugang zum Kursdatenanbieter benoetigt.",
|
||||||
|
|
||||||
"_targets_comment": "Wohin geschrieben wird. Pfade sind relativ zum Installationsverzeichnis. format ist 'json' oder 'env'. Ueber 'id' lassen sich einzelne Felder gezielt einer Datei zuordnen; ohne Angabe schreibt ein Feld in das erste Ziel.",
|
"_targets_comment": "Wohin geschrieben wird. 'location' bestimmt den Bezugspunkt: install (Vorgabe, Installationsverzeichnis), config (%APPDATA% bzw. $XDG_CONFIG_HOME), data, home. In 'file' sind ${VAR} und %VAR% erlaubt, ein absoluter Pfad wird unveraendert benutzt. format ist 'json' oder 'env'. Ueber 'id' lassen sich einzelne Felder gezielt einer Datei zuordnen; ohne Angabe schreibt ein Feld in das erste Ziel.",
|
||||||
|
"_platform_comment": "Heisst der Ordner je nach Plattform anders, hilft meist die kleingeschriebene Form allein: sie trifft unter Linux exakt und unter Windows auch, weil NTFS die Schreibweise ignoriert. Auf case-sensitiven Dateisystemen oder bei groesseren Unterschieden gibt es fileWindows, fileLinux und fileMacOS.",
|
||||||
"targets": [
|
"targets": [
|
||||||
{ "id": "app", "file": "appsettings.json", "format": "json" },
|
{ "id": "app", "file": "appsettings.json", "location": "install", "format": "json" },
|
||||||
{ "id": "env", "file": ".env", "format": "env" }
|
{
|
||||||
|
"id": "user",
|
||||||
|
"file": "beispielanwendung/Settings.json",
|
||||||
|
"fileWindows": "Beispielanwendung/Settings.json",
|
||||||
|
"location": "config",
|
||||||
|
"format": "json"
|
||||||
|
},
|
||||||
|
{ "id": "env", "file": ".env", "location": "install", "format": "env" }
|
||||||
],
|
],
|
||||||
|
|
||||||
"_fields_comment": "source steuert, ob gefragt wird: 'ask' (Vorgabe) fragt nach, 'detect:...' leitet ab, 'provision' laesst ein Token ausstellen. Ist der Wert in der Zieldatei bereits gesetzt und kein Platzhalter, wird er uebernommen und nicht erneut erfragt.",
|
"_fields_comment": "source steuert, ob gefragt wird: 'ask' (Vorgabe) fragt nach, 'detect:...' leitet ab, 'provision' laesst ein Token ausstellen. Ist der Wert in der Zieldatei bereits gesetzt und kein Platzhalter, wird er uebernommen und nicht erneut erfragt.",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
* POST /api/setup/v1/login Benutzername + Passwort -> kurzlebiges Token
|
* POST /api/setup/v1/login Benutzername + Passwort -> kurzlebiges Token
|
||||||
* GET /api/setup/v1/catalog Was ist hier installierbar?
|
* GET /api/setup/v1/catalog Was ist hier installierbar?
|
||||||
* POST /api/setup/v1/token Dauertoken fuer die eingerichtete Anwendung
|
* POST /api/setup/v1/token Dauertoken fuer die eingerichtete Anwendung
|
||||||
|
* POST /api/setup/v1/agent Watchdog-Agent fuer die Maschine selbst
|
||||||
*
|
*
|
||||||
* Warum ein eigener Anmeldeweg statt eines Dauertokens: Ein Token muesste
|
* Warum ein eigener Anmeldeweg statt eines Dauertokens: Ein Token muesste
|
||||||
* vorher jemand erzeugen und auf das Zielsystem bringen - genau die Huerde,
|
* vorher jemand erzeugen und auf das Zielsystem bringen - genau die Huerde,
|
||||||
@@ -36,6 +37,8 @@ use Deploymentcenter\Core\TokenManager;
|
|||||||
use Deploymentcenter\Modules\License\RateLimiter;
|
use Deploymentcenter\Modules\License\RateLimiter;
|
||||||
use Deploymentcenter\Modules\Setup\SetupCatalog;
|
use Deploymentcenter\Modules\Setup\SetupCatalog;
|
||||||
use Deploymentcenter\Modules\UpdateService\UpdateManager;
|
use Deploymentcenter\Modules\UpdateService\UpdateManager;
|
||||||
|
use Deploymentcenter\Modules\Watchdog\AgentScript;
|
||||||
|
use Deploymentcenter\Modules\Watchdog\MonitorRepo;
|
||||||
|
|
||||||
Http::beginJson(['GET', 'POST', 'OPTIONS'], true);
|
Http::beginJson(['GET', 'POST', 'OPTIONS'], true);
|
||||||
|
|
||||||
@@ -222,9 +225,128 @@ switch (resolveSetupAction()) {
|
|||||||
'message' => 'Token ausgestellt. Es wird nur einmal ausgeliefert.',
|
'message' => 'Token ausgestellt. Es wird nur einmal ausgeliefert.',
|
||||||
], 201);
|
], 201);
|
||||||
|
|
||||||
|
case 'agent':
|
||||||
|
// Der Watchdog-Agent fuer die Maschine, auf der gerade installiert
|
||||||
|
// wird.
|
||||||
|
//
|
||||||
|
// Bisher gab es diesen Weg nur ueber die Oberflaeche: Monitor von Hand
|
||||||
|
// anlegen, Token erzeugen, Skript herunterladen, auf den Host kopieren,
|
||||||
|
// Cron-Eintrag schreiben. Fuenf Schritte an einem anderen Rechner, fuer
|
||||||
|
// etwas, das genau dann ansteht, wenn man ohnehin auf dem Zielsystem
|
||||||
|
// sitzt - entsprechend oft unterblieb es, und die frisch installierte
|
||||||
|
// Anwendung lief auf einem unbeobachteten Host.
|
||||||
|
//
|
||||||
|
// Ausgestellt wird dasselbe wie bei 'token': ein Token mit genau
|
||||||
|
// watchdog:ping. Es liegt anschliessend im Klartext im Skript auf dem
|
||||||
|
// Zielsystem - mehr Rechte duerfen daran nicht haengen.
|
||||||
|
if (Http::method() !== 'POST') {
|
||||||
|
Http::fail(405, 'method_not_allowed', 'Dieser Endpunkt erwartet POST.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = ApiAuth::requireScope($db, 'setup:install', null, false);
|
||||||
|
|
||||||
|
$project = Http::str('project') ?? Http::str('product_slug');
|
||||||
|
if ($project === null || $project === '') {
|
||||||
|
Http::fail(400, 'missing_project', 'Der Parameter "project" wird benoetigt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiAuth::enforceProject($context, $project);
|
||||||
|
|
||||||
|
$exists = $db->prepare('SELECT slug FROM dc_projects WHERE slug = :slug LIMIT 1');
|
||||||
|
$exists->execute([':slug' => $project]);
|
||||||
|
if ($exists->fetch() === false) {
|
||||||
|
Http::fail(404, 'unknown_project', sprintf('Ein Projekt "%s" ist nicht angelegt.', $project));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Name ist das, was spaeter im Dashboard steht. Gesaeubert wird in
|
||||||
|
// AgentScript - hier wird nur geprueft, ob ueberhaupt etwas uebrig
|
||||||
|
// bleibt, damit nicht stillschweigend ein Monitor "host" entsteht.
|
||||||
|
$rawSource = trim((string)(Http::str('source') ?? ''));
|
||||||
|
if ($rawSource === '') {
|
||||||
|
Http::fail(400, 'missing_source', 'Der Parameter "source" (Name des Monitors) wird benoetigt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$source = AgentScript::sanitizeSource($rawSource);
|
||||||
|
|
||||||
|
$monitorRepo = new MonitorRepo($db);
|
||||||
|
$existing = $monitorRepo->getMonitor($source);
|
||||||
|
|
||||||
|
// Ein Name, den es schon gibt, ist nicht zwangslaeufig ein Fehler -
|
||||||
|
// eine Neuinstallation derselben Maschine trifft ihren eigenen Monitor
|
||||||
|
// wieder. Nur entscheiden muss das der Aufrufer, nicht dieser
|
||||||
|
// Endpunkt: sonst uebernimmt eine zweite Maschine unbemerkt die
|
||||||
|
// Historie der ersten.
|
||||||
|
$overwrite = filter_var(Http::input('overwrite', false), FILTER_VALIDATE_BOOLEAN);
|
||||||
|
|
||||||
|
if ($existing !== null && !$overwrite) {
|
||||||
|
Http::fail(409, 'monitor_exists', sprintf(
|
||||||
|
'Ein Monitor "%s" ist bereits angelegt (Zustand: %s). Mit overwrite=true uebernehmen.',
|
||||||
|
$source,
|
||||||
|
(string)($existing['state'] ?? 'unbekannt')
|
||||||
|
), null, [
|
||||||
|
'source' => $source,
|
||||||
|
'state' => $existing['state'] ?? null,
|
||||||
|
'last_seen' => $existing['last_seen_utc'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$os = AgentScript::normalizeOs(Http::str('os'));
|
||||||
|
$interval = Http::int('interval', 60);
|
||||||
|
|
||||||
|
$manager = new TokenManager($db);
|
||||||
|
$agentToken = $manager->createMasterToken(
|
||||||
|
sprintf('Watchdog %s', $source),
|
||||||
|
$project,
|
||||||
|
null,
|
||||||
|
'host',
|
||||||
|
$source,
|
||||||
|
['watchdog:ping'],
|
||||||
|
Http::str('environment') ?? 'all'
|
||||||
|
);
|
||||||
|
|
||||||
|
$monitorRepo->createMonitor($source, [
|
||||||
|
'type' => 'host',
|
||||||
|
'os' => $os,
|
||||||
|
'expected_interval_sec' => $interval,
|
||||||
|
'group_key' => Http::str('group'),
|
||||||
|
'parent_source' => Http::str('parent'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$agent = AgentScript::build(
|
||||||
|
$os,
|
||||||
|
$source,
|
||||||
|
$agentToken['raw_token'],
|
||||||
|
Http::baseUrl() . '/api/watchdog/v1/ping',
|
||||||
|
$interval
|
||||||
|
);
|
||||||
|
|
||||||
|
Logger::info('Watchdog-Agent ueber den Setup-Weg ausgestellt', [
|
||||||
|
'project' => $project,
|
||||||
|
'source' => $source,
|
||||||
|
'os' => $os,
|
||||||
|
'existing' => $existing !== null,
|
||||||
|
'actor' => $context['actor'],
|
||||||
|
'token_id' => $agentToken['token_id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Http::ok([
|
||||||
|
'source' => $source,
|
||||||
|
'os' => $agent['os'],
|
||||||
|
'filename' => $agent['filename'],
|
||||||
|
'script' => $agent['script'],
|
||||||
|
'schedule' => $agent['schedule'],
|
||||||
|
'interval' => $agent['interval'],
|
||||||
|
'token_id' => $agentToken['token_id'],
|
||||||
|
'existing' => $existing !== null,
|
||||||
|
'message' => sprintf(
|
||||||
|
'Monitor "%s" eingerichtet. Das Skript enthaelt das Token und wird nur einmal ausgeliefert.',
|
||||||
|
$source
|
||||||
|
),
|
||||||
|
], 201);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
Http::fail(404, 'unknown_action', 'Endpunkt nicht gefunden.', null, [
|
Http::fail(404, 'unknown_action', 'Endpunkt nicht gefunden.', null, [
|
||||||
'available' => ['login', 'catalog', 'token'],
|
'available' => ['login', 'catalog', 'token', 'agent'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,7 +369,7 @@ function resolveSetupAction(): string
|
|||||||
$last = strtolower((string)end($segments));
|
$last = strtolower((string)end($segments));
|
||||||
|
|
||||||
return match ($last) {
|
return match ($last) {
|
||||||
'login', 'catalog', 'token' => $last,
|
'login', 'catalog', 'token', 'agent' => $last,
|
||||||
default => 'unknown',
|
default => 'unknown',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,90 @@ switch ($action) {
|
|||||||
);
|
);
|
||||||
Http::ok(['count' => count($releases), 'releases' => $releases]);
|
Http::ok(['count' => count($releases), 'releases' => $releases]);
|
||||||
|
|
||||||
|
case 'changelog':
|
||||||
|
// Was hat sich seit einer bestimmten Fassung geaendert?
|
||||||
|
//
|
||||||
|
// Damit muss ein Agent, der eine Anbindung aktualisiert, nicht die
|
||||||
|
// gesamte Historie lesen. Er merkt sich die Fassung, gegen die er
|
||||||
|
// integriert hat, und fragt spaeter nur nach dem Unterschied.
|
||||||
|
$changelogPath = dirname(__DIR__, 3) . '/docs/changelog.json';
|
||||||
|
|
||||||
|
if (!is_file($changelogPath)) {
|
||||||
|
Http::fail(404, 'no_changelog', 'Es ist kein Changelog hinterlegt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = (string)file_get_contents($changelogPath);
|
||||||
|
// Windows-Werkzeuge stellen gern ein BOM voran; json_decode scheitert daran.
|
||||||
|
$raw = preg_replace('/^\xEF\xBB\xBF/', '', $raw) ?? $raw;
|
||||||
|
|
||||||
|
$changelog = json_decode($raw, true);
|
||||||
|
|
||||||
|
if (!is_array($changelog) || !isset($changelog['versions']) || !is_array($changelog['versions'])) {
|
||||||
|
Http::fail(500, 'invalid_changelog', 'Der hinterlegte Changelog ist nicht lesbar.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$since = Http::str('since');
|
||||||
|
$area = Http::str('area');
|
||||||
|
|
||||||
|
$entries = [];
|
||||||
|
$actionItems = 0;
|
||||||
|
$breaking = 0;
|
||||||
|
|
||||||
|
foreach ($changelog['versions'] as $entry) {
|
||||||
|
if (!is_array($entry) || !isset($entry['version'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur echt neuere Fassungen. Wer auf 2.1.0 sitzt, will nicht
|
||||||
|
// wieder ueber 2.1.0 lesen.
|
||||||
|
if ($since !== null && $since !== ''
|
||||||
|
&& !Version::isNewer((string)$entry['version'], $since)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($area !== null && $area !== '') {
|
||||||
|
$entry['changes'] = array_values(array_filter(
|
||||||
|
$entry['changes'] ?? [],
|
||||||
|
static fn(array $c): bool => ($c['area'] ?? '') === $area
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($entry['changes'] === []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($entry['changes'] ?? [] as $change) {
|
||||||
|
if (!empty($change['action'])) { $actionItems++; }
|
||||||
|
if (!empty($change['breaking'])) { $breaking++; }
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries[] = $entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absteigend: das Neueste zuerst.
|
||||||
|
usort($entries, static fn(array $a, array $b): int
|
||||||
|
=> Version::compare((string)$b['version'], (string)$a['version']));
|
||||||
|
|
||||||
|
Http::ok([
|
||||||
|
'current' => $changelog['current'] ?? null,
|
||||||
|
'since' => $since,
|
||||||
|
'count' => count($entries),
|
||||||
|
'action_items' => $actionItems,
|
||||||
|
'breaking' => $breaking,
|
||||||
|
'versions' => $entries,
|
||||||
|
'message' => $entries === []
|
||||||
|
? ($since !== null && $since !== ''
|
||||||
|
? sprintf('Seit %s hat sich nichts geaendert.', $since)
|
||||||
|
: 'Es ist nichts hinterlegt.')
|
||||||
|
: sprintf(
|
||||||
|
'%d Fassung(en) neuer als %s, davon %d mit Handlungsbedarf und %d mit Bruch.',
|
||||||
|
count($entries),
|
||||||
|
$since !== null && $since !== '' ? $since : 'Anbeginn',
|
||||||
|
$actionItems,
|
||||||
|
$breaking
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
case 'pubkey':
|
case 'pubkey':
|
||||||
// Oeffentlicher Schluessel zum Pruefen der Release-Signaturen.
|
// Oeffentlicher Schluessel zum Pruefen der Release-Signaturen.
|
||||||
// Bewusst ohne Token: er ist oeffentlich, und der Agent braucht ihn,
|
// Bewusst ohne Token: er ist oeffentlich, und der Agent braucht ihn,
|
||||||
@@ -393,7 +477,8 @@ function resolveUpdateAction(): string
|
|||||||
$last = strtolower((string)end($segments));
|
$last = strtolower((string)end($segments));
|
||||||
|
|
||||||
return match ($last) {
|
return match ($last) {
|
||||||
'check', 'latest', 'releases', 'pubkey', 'publish', 'update', 'edit', 'delete' => $last === 'edit' ? 'update' : $last,
|
'check', 'latest', 'releases', 'pubkey', 'changelog', 'publish', 'update', 'edit', 'delete'
|
||||||
|
=> $last === 'edit' ? 'update' : $last,
|
||||||
'publish_release' => 'publish',
|
'publish_release' => 'publish',
|
||||||
default => 'check',
|
default => 'check',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="24" height="24">
|
|
||||||
<rect width="100" height="100" rx="20" fill="#F2622E"/>
|
|
||||||
<path d="M50 20 L80 35 L80 65 L50 80 L20 65 L20 35 Z" fill="none" stroke="#FFF" stroke-width="6"/>
|
|
||||||
<circle cx="50" cy="50" r="10" fill="#FFF"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 299 B |
@@ -1,10 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="24" height="24">
|
|
||||||
<rect width="100" height="100" rx="20" fill="#2496ED"/>
|
|
||||||
<rect x="20" y="45" width="12" height="10" rx="2" fill="#FFF"/>
|
|
||||||
<rect x="35" y="45" width="12" height="10" rx="2" fill="#FFF"/>
|
|
||||||
<rect x="50" y="45" width="12" height="10" rx="2" fill="#FFF"/>
|
|
||||||
<rect x="35" y="32" width="12" height="10" rx="2" fill="#FFF"/>
|
|
||||||
<rect x="50" y="32" width="12" height="10" rx="2" fill="#FFF"/>
|
|
||||||
<rect x="65" y="45" width="12" height="10" rx="2" fill="#FFF"/>
|
|
||||||
<path d="M15 62 Q 50 82 85 62 Q 90 70 85 75 Q 45 92 10 70 Z" fill="#FFF"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 624 B |
@@ -1,11 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="24" height="24">
|
|
||||||
<circle cx="50" cy="50" r="48" fill="#FCC624"/>
|
|
||||||
<ellipse cx="50" cy="42" rx="22" ry="26" fill="#000000"/>
|
|
||||||
<ellipse cx="50" cy="45" rx="16" ry="20" fill="#FFFFFF"/>
|
|
||||||
<circle cx="43" cy="35" r="4" fill="#000"/>
|
|
||||||
<circle cx="57" cy="35" r="4" fill="#000"/>
|
|
||||||
<polygon points="46,40 54,40 50,48" fill="#FFA500"/>
|
|
||||||
<ellipse cx="50" cy="72" rx="24" ry="14" fill="#000000"/>
|
|
||||||
<ellipse cx="36" cy="84" rx="12" ry="6" fill="#FFA500"/>
|
|
||||||
<ellipse cx="64" cy="84" rx="12" ry="6" fill="#FFA500"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 588 B |
@@ -1,6 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="24" height="24">
|
|
||||||
<rect width="100" height="100" rx="20" fill="#00618A"/>
|
|
||||||
<path d="M20 75 C 20 40, 45 25, 75 35 C 80 50, 70 70, 50 75 Z" fill="#E48E00"/>
|
|
||||||
<circle cx="65" cy="40" r="4" fill="#FFF"/>
|
|
||||||
<text x="50" y="88" font-size="18" font-family="sans-serif" font-weight="bold" fill="#FFF" text-anchor="middle">MySQL</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 406 B |
@@ -1,4 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="24" height="24">
|
|
||||||
<polygon points="50,5 92,28 92,72 50,95 8,72 8,28" fill="#009639"/>
|
|
||||||
<path d="M30 70 L30 30 L65 70 L65 30" stroke="#FFF" stroke-width="8" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 295 B |
@@ -1,4 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="24" height="24">
|
|
||||||
<polygon points="50,10 90,32 90,78 50,100 10,78 10,32" fill="#5FA04E"/>
|
|
||||||
<text x="50" y="62" font-size="24" font-family="sans-serif" font-weight="bold" fill="#FFF" text-anchor="middle">node</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 293 B |
@@ -1,4 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="24" height="24">
|
|
||||||
<ellipse cx="50" cy="50" rx="46" ry="28" fill="#777BB4"/>
|
|
||||||
<text x="50" y="58" font-size="28" font-family="sans-serif" font-weight="bold" fill="#FFF" text-anchor="middle">php</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 278 B |
@@ -1,4 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="24" height="24">
|
|
||||||
<rect width="100" height="100" rx="20" fill="#E57000"/>
|
|
||||||
<path d="M25 25 L75 25 L75 45 L45 45 L45 55 L75 55 L75 75 L25 75 Z" fill="#FFFFFF"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 238 B |
@@ -1,6 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="24" height="24">
|
|
||||||
<path d="M48 10 C 25 10, 25 22, 25 22 L 25 34 L 50 34 L 50 38 L 20 38 C 20 38, 8 38, 8 60 C 8 82, 20 82, 20 82 L 32 82 L 32 70 C 32 70, 32 56, 48 56 L 62 56 C 62 56, 75 56, 75 42 L 75 22 C 75 22, 75 10, 48 10 Z" fill="#3776AB"/>
|
|
||||||
<circle cx="36" cy="22" r="4" fill="#FFF"/>
|
|
||||||
<path d="M52 90 C 75 90, 75 78, 75 78 L 75 66 L 50 66 L 50 62 L 80 62 C 80 62, 92 62, 92 40 C 92 18, 80 18, 80 18 L 68 18 L 68 30 C 68 30, 68 44, 52 44 L 38 44 C 38 44, 25 44, 25 58 L 25 78 C 25 78, 25 90, 52 90 Z" fill="#FFD43B"/>
|
|
||||||
<circle cx="64" cy="78" r="4" fill="#000"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 650 B |
@@ -1,5 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="24" height="24">
|
|
||||||
<rect width="100" height="100" rx="20" fill="#D82C20"/>
|
|
||||||
<polygon points="50,20 80,35 50,50 20,35" fill="#FFF"/>
|
|
||||||
<polygon points="50,45 80,60 50,75 20,60" fill="#FFF" opacity="0.8"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 281 B |
@@ -1,9 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="24" height="24">
|
|
||||||
<rect width="100" height="100" rx="16" fill="#1E232B"/>
|
|
||||||
<rect x="15" y="15" width="70" height="30" rx="6" fill="#242A33" stroke="#F2622E" stroke-width="3"/>
|
|
||||||
<circle cx="30" cy="30" r="4" fill="#37C26B"/>
|
|
||||||
<circle cx="42" cy="30" r="4" fill="#37C26B"/>
|
|
||||||
<rect x="15" y="55" width="70" height="30" rx="6" fill="#242A33" stroke="#F2622E" stroke-width="3"/>
|
|
||||||
<circle cx="30" cy="70" r="4" fill="#37C26B"/>
|
|
||||||
<circle cx="42" cy="70" r="4" fill="#37C26B"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 553 B |
@@ -1,3 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 88 88" width="24" height="24">
|
|
||||||
<path fill="#00ADEF" d="M0 12.5L35.7 7.6v33.4H0V12.5zm0 32.5h35.7v33.5L0 73.8V45zm39.5-38L88 0v41H39.5V7zm0 38H88v41l-48.5-6.8V45z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 227 B |
@@ -0,0 +1,369 @@
|
|||||||
|
{
|
||||||
|
"_comment": "Einzige Quelle der Wahrheit fuer den Changelog. Wird von /docs/changelog.php gerendert und von GET /api/updateservice/v1/changelog?since=X ausgeliefert. Neue Eintraege oben einfuegen.",
|
||||||
|
|
||||||
|
"schema": 1,
|
||||||
|
"current": "2.6.0",
|
||||||
|
|
||||||
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "2.6.0",
|
||||||
|
"date": "2026-08-21",
|
||||||
|
"summary": "Der Installer prueft die Laufzeit und richtet die Ueberwachung des Hosts ein.",
|
||||||
|
"actionRequired": false,
|
||||||
|
"changes": [
|
||||||
|
{
|
||||||
|
"area": "client",
|
||||||
|
"breaking": false,
|
||||||
|
"title": "Installer prueft die .NET-Laufzeit",
|
||||||
|
"text": "Bisher endete eine gelungene Installation auf einer Maschine ohne .NET mit einer Anwendung, die sich nicht starten laesst - und die Fehlersuche begann beim Deploymentcenter, weil das der letzte bewusste Schritt war. Nach dem Uebertragen der Dateien liest der Installer jetzt die runtimeconfig.json der Anwendung, vergleicht sie mit \"dotnet --list-runtimes\" und nennt bei Bedarf den Installationsbefehl fuer diese Plattform. Eigenstaendig veroeffentlichte Pakete bringen ihre Laufzeit mit und werden nicht bemaengelt; rollForward wird beachtet.",
|
||||||
|
"action": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "client",
|
||||||
|
"breaking": false,
|
||||||
|
"title": "Ueberwachung der Maschine direkt aus dem Installer",
|
||||||
|
"text": "Am Ende der Installation genuegen zwei Fragen - Name im Dashboard und ob eingeplant werden soll. Monitor, Token, Agent-Skript, Dateirechte, ein erster Heartbeat zur Probe und der Cron-Eintrag bzw. die geplante Aufgabe entstehen daraus. Fuer Maschinen ohne Installation, etwa den Hypervisor darunter, gibt es denselben Weg als \"--action monitor\".",
|
||||||
|
"action": "Keine. Wer die Frage verneint, holt es spaeter mit --action monitor nach."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "server",
|
||||||
|
"breaking": false,
|
||||||
|
"title": "Agent-Skripte melden jetzt Kennzahlen",
|
||||||
|
"text": "Das aus der Oberflaeche heruntergeladene Skript schickte nur \"status: ok\" - damit beantwortete ein Host-Monitor genau eine Frage und keine der Fragen, wegen derer man auf einen Host schaut. Die Skripte melden jetzt Last, Speicherbelegung, Plattenbelegung und Laufzeit mit und stufen sich ab 90 Prozent Plattenbelegung selbst auf \"warning\". Erzeugt werden sie an einer Stelle (Modules/Watchdog/AgentScript.php), die Oberflaeche und Installer gemeinsam nutzen.",
|
||||||
|
"action": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "api",
|
||||||
|
"breaking": false,
|
||||||
|
"title": "POST /api/setup/v1/agent",
|
||||||
|
"text": "Legt den Monitor an, stellt ein Token mit genau watchdog:ping aus und liefert das fertige Skript fuer den Host. Ein bereits vorhandener Monitor gleichen Namens wird mit 409 abgelehnt, damit nicht eine zweite Maschine stillschweigend die Historie der ersten uebernimmt; overwrite=true erlaubt es ausdruecklich. Im SDK: SetupClient.RequestWatchdogAgentAsync(...).",
|
||||||
|
"action": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.5.3",
|
||||||
|
"date": "2026-08-21",
|
||||||
|
"summary": "Administratorkonten kommen an die Release-Ablage.",
|
||||||
|
"actionRequired": false,
|
||||||
|
"changes": [
|
||||||
|
{
|
||||||
|
"area": "server",
|
||||||
|
"breaking": false,
|
||||||
|
"title": "Administratorkonten im Release-Zugangsschutz",
|
||||||
|
"text": "Der Zugangsschutz nahm bisher nur Konten der Rolle installer auf, waehrend die Installationsskripte und der Installer ausdruecklich sagten, ein Administratorkonto tue es auch. Eine Erstinstallation mit einem Administratorkonto meldete sich erfolgreich an, zeigte den Katalog und scheiterte erst beim Herunterladen mit einer Meldung ueber abgelaufene Lizenzen. Administratoren zaehlen jetzt zu den Installationskonten; die .htpasswd-Dateien werden beim naechsten Tick neu erzeugt. Neu angelegte Konten landen ausserdem sofort darin - zuvor galt das nur fuer die Rolle installer, ein frisches Administratorkonto wartete bis zum naechsten turnusmaessigen Lauf.",
|
||||||
|
"action": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "client",
|
||||||
|
"breaking": false,
|
||||||
|
"title": "Ehrliche Meldung bei abgelehntem Zugang",
|
||||||
|
"text": "Ein 401 der Release-Ablage nannte immer Lizenzgruende - abgelaufen, widerrufen, falsches Produkt. Bei einer Erstinstallation gibt es noch gar keine Lizenz; die Meldung schickte damit in die falsche Richtung. Wurde mit Benutzername und Passwort zugegriffen, nennt sie jetzt das Konto und die Rollen, die Zugang haben. Ausserdem bricht der Agent bei 401 sofort ab, statt ueber die API weiterzusuchen und dieselbe Meldung ein paar Schritte spaeter ein zweites Mal zu zeigen.",
|
||||||
|
"action": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.5.2",
|
||||||
|
"date": "2026-08-15",
|
||||||
|
"summary": "LaunchUpdateAgent reicht --require-signature durch.",
|
||||||
|
"actionRequired": true,
|
||||||
|
"changes": [
|
||||||
|
{
|
||||||
|
"area": "sdk",
|
||||||
|
"breaking": false,
|
||||||
|
"title": "requireSignature-Parameter auf LaunchUpdateAgent",
|
||||||
|
"text": "--require-signature gab es bisher nur als Kommandozeilenschalter. Jede Anwendung, die dem Leitfaden folgt und ueber LaunchUpdateAgent startet - der empfohlene Weg - aktualisierte damit ungeprueft, waehrend derselbe Vorgang von Hand auf der Kommandozeile geschuetzt gewesen waere. Der oeffentliche Schluessel muss dafuer nicht separat verwaltet werden, der Agent holt und cached ihn ohnehin selbst.",
|
||||||
|
"action": "LaunchUpdateAgent(..., requireSignature: true) setzen, sobald server.security.release_private_key hinterlegt ist und alle ausgelieferten Releases signiert wurden."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.5.1",
|
||||||
|
"date": "2026-08-14",
|
||||||
|
"summary": "Zugangsschutz je Produkt abschaltbar, Dienst-Betrieb dokumentiert, inkrementelle Builds repariert.",
|
||||||
|
"actionRequired": true,
|
||||||
|
"changes": [
|
||||||
|
{
|
||||||
|
"area": "server",
|
||||||
|
"breaking": false,
|
||||||
|
"title": "Produkte lassen sich vom Zugangsschutz ausnehmen",
|
||||||
|
"text": "Der Schutz entstand beim ersten Lauf nach dem Deployment von selbst - auch fuer Produkte, deren ausgelieferte Installationen noch ohne Lizenzschluessel bauen. Die bekamen ab dem Moment 401 statt Updates; bei Predictalytics ist genau das passiert. Unter UpdateService -> Zugangsschutz laesst sich je Produkt umschalten.",
|
||||||
|
"action": "Wer eine Installationsbasis ohne SDK 2.4 hat: Produkt ausnehmen, Build mit Schluessel ausliefern, danach wieder einschalten."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "sdk",
|
||||||
|
"breaking": false,
|
||||||
|
"title": "BuildInfo.targets verhindert keine inkrementellen Builds mehr",
|
||||||
|
"text": "BuildDateUtc trug die volle Uhrzeit und aenderte sich bei jedem Build; WriteOnlyWhenDifferent griff nie, und jedes einbindende Projekt wurde jedes Mal neu uebersetzt. Jetzt tagesgenau - hoechstens ein Neubau pro Tag.",
|
||||||
|
"action": "Keine. BuildInfo.BuildDateUtc enthaelt nur noch das Datum, nicht die Uhrzeit."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "sdk",
|
||||||
|
"breaking": true,
|
||||||
|
"title": "LicenseConfig.SetStorageDirectory haengt jetzt <slug>/license an",
|
||||||
|
"text": "Zuvor wurde der gesetzte Pfad roh benutzt, waehrend der Weg ueber DEPLOYMENTCENTER_STORAGE_DIR <slug>/license anhaengte. Zwei Produkte im selben Prozess mit gesetztem Override schrieben damit in dieselbe state.dat.",
|
||||||
|
"action": "Wer SetStorageDirectory benutzt: Der Cache liegt jetzt eine Ebene tiefer. Ein vorhandener Cache wird einmalig neu aufgebaut."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "sdk",
|
||||||
|
"breaking": false,
|
||||||
|
"title": "Lizenz-Cache faellt auf /var/lib/<slug> zurueck",
|
||||||
|
"text": "Eine systemd-Unit mit User= und ohne eigenes Heimatverzeichnis hat weder XDG_CONFIG_HOME noch HOME. Der Rueckfall landete im Installationsverzeichnis, unter /opt fuer den Dienstbenutzer nicht beschreibbar - also kein Offline-Cache, ausgerechnet im kopflosen Betrieb.",
|
||||||
|
"action": "Wenn /var/lib nicht beschreibbar ist: DEPLOYMENTCENTER_STORAGE_DIR setzen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "docs",
|
||||||
|
"breaking": false,
|
||||||
|
"title": "Lueckenschluss in den Leitfaeden",
|
||||||
|
"text": "Woher die Anwendung den Lizenzschluessel fuer den Update-Zugang nimmt (TryGetCachedKey), wie ein laufender systemd-Dienst aktualisiert wird, was statt Environment.Exit(1) zu tun ist, wenn Beenden die gefaehrlichere Option waere, und die Drosselungsgrenzen aller Endpunkte.",
|
||||||
|
"action": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.5.0",
|
||||||
|
"date": "2026-08-14",
|
||||||
|
"summary": "Geheimnisse raus aus der Kommandozeile, Packager sperrt statt zu warnen, ILicensePrompt lebt.",
|
||||||
|
"actionRequired": true,
|
||||||
|
"changes": [
|
||||||
|
{
|
||||||
|
"area": "sdk",
|
||||||
|
"breaking": false,
|
||||||
|
"title": "Lizenzschluessel wird nicht mehr als Argument uebergeben",
|
||||||
|
"text": "Der Agent nahm --license-key nur aus argv. 'ps' zeigt das jedem Benutzer der Maschine - exakt die Begruendung, mit der UPGRADE.md §5 den Crontab-Weg verwirft. Der Agent liest jetzt DC_LICENSE_KEY, DC_DOWNLOAD_USER und DC_DOWNLOAD_PASSWORD; Umgebung vor Argument. LaunchUpdateAgent setzt die Variable auf dem eigenen Prozess, das Kind erbt sie.",
|
||||||
|
"action": "Keine Aenderung noetig, wenn ihr licenseKey an LaunchUpdateAgent uebergebt. Wer den Agenten selbst aufruft, sollte von --license-key auf DC_LICENSE_KEY umstellen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "sdk",
|
||||||
|
"breaking": false,
|
||||||
|
"title": "waitTimeoutSeconds an LaunchUpdateAgent",
|
||||||
|
"text": "Der Agent kannte --wait-timeout, das SDK reichte es nicht durch - es galten fest 60 Sekunden. Laeuft die Zeit ab, bricht der Agent ab, ohne etwas zu veraendern.",
|
||||||
|
"action": "Wenn euer Herunterfahren laenger als etwa 40 Sekunden dauert: waitTimeoutSeconds heraufsetzen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "sdk",
|
||||||
|
"breaking": false,
|
||||||
|
"title": "Environment.Exit(0) ist dokumentiert, nicht geaendert",
|
||||||
|
"text": "exitCurrentApp:true beendet den Prozess hart - laufende finally-Bloecke, IHostApplicationLifetime und Destruktoren kommen nicht mehr zum Zug.",
|
||||||
|
"action": "Bei offenem Zustand (Positionen, Transaktionen, ungeschriebene Puffer) exitCurrentApp:false setzen und selbst geordnet herunterfahren. Der Agent wartet ohnehin auf das Prozessende."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "sdk",
|
||||||
|
"breaking": false,
|
||||||
|
"title": "EnsureLicensedAsync - ILicensePrompt wird endlich benutzt",
|
||||||
|
"text": "Der Konstruktor nahm ILicensePrompt entgegen, legte es ab und rief es nie auf. Neu: EnsureLicensedAsync() nimmt den zwischengespeicherten Schluessel, fragt sonst nach, prueft, und fragt bei Ablehnung erneut. allowPrompt:false lehnt ohne Cache ab, statt im Dienst auf eine Eingabe zu warten, die nie kommt.",
|
||||||
|
"action": "Optional. Wer eine eigene Abfrage gebaut hat, kann sie behalten."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "packager",
|
||||||
|
"breaking": true,
|
||||||
|
"title": "Bricht bei Zugangsdaten im Paket ab",
|
||||||
|
"text": "Zuvor nur eine Warnung - und die war ausgerechnet unterdrueckt, wenn die Datei auf der preserve-Liste stand. So geriet ein echter API-Schluessel in ein oeffentlich abrufbares Paket. Geprueft werden Dateiname (appsettings.Local.json, master.key, *.pfx, *.db, server_settings.xml) und Inhalt (gefuelltes Password=, sk-, ghp_, dc_master_, AKIA, private Schluessel). Platzhalter loesen nicht aus.",
|
||||||
|
"action": "Pruefen, was im Publish-Verzeichnis landet - haeufig eine CopyToOutputDirectory-Regel in der csproj. Notausgang: --allow-secrets."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "sdk",
|
||||||
|
"breaking": false,
|
||||||
|
"title": "BuildInfo.targets liegt im NuGet-Paket",
|
||||||
|
"text": "Die Anleitung empfahl einen <Import> per relativem Pfad ins Nachbar-Repository - das setzt voraus, dass beide Arbeitskopien nebeneinander liegen und in derselben Fassung stehen. Das Target liegt jetzt unter build/ im Paket, NuGet importiert es selbst.",
|
||||||
|
"action": "Den <Import Project=\"..\\Deploymentcenter.Client\\Deploymentcenter.BuildInfo.targets\" /> aus der csproj entfernen und stattdessen PackageReference auf Deploymentcenter.Client 2.5.0 setzen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "sdk",
|
||||||
|
"breaking": false,
|
||||||
|
"title": "Unauthorized auch im API-Zweig",
|
||||||
|
"text": "Ein 401 wurde nur auf dem statischen Weg als Lizenzproblem erkannt; ueber die API kam er als gewoehnlicher HTTP-Fehler an.",
|
||||||
|
"action": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "tooling",
|
||||||
|
"breaking": false,
|
||||||
|
"title": "pack-and-deploy ist beziehbar, Release-Vorlage vorhanden",
|
||||||
|
"text": "Das Werkzeug wurde in der Anleitung benutzt, als laege es im PATH - beziehbar war es nirgends. Es steht jetzt unter /installer/ neben dem Agenten. Dazu eine Vorlage zum Kopieren ins eigene Projekt, die je Plattform dotnet publish und pack-and-deploy verkettet und sich das Werkzeug selbst holt.",
|
||||||
|
"action": "Empfohlen: Vorlage von /docs/release-template/ holen. Anleitung unter /docs/release.md."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.4.0",
|
||||||
|
"date": "2026-08-13",
|
||||||
|
"summary": "Release-Ablage liegt hinter Zugangsschutz. Ohne Lizenzschluessel keine Updates mehr.",
|
||||||
|
"actionRequired": true,
|
||||||
|
"changes": [
|
||||||
|
{
|
||||||
|
"area": "server",
|
||||||
|
"breaking": true,
|
||||||
|
"title": "/releases/ verlangt Zugangsdaten",
|
||||||
|
"text": "Zuvor konnte jeder im Internet die vollstaendigen Pakete herunterladen. Zugang haben jetzt gueltige Lizenzschluessel des jeweiligen Produkts sowie die Installationskonten. Je Produkt eine eigene .htpasswd - eine gemeinsame wuerde bedeuten, dass eine Lizenz fuer Produkt A auch Produkt B oeffnet.",
|
||||||
|
"action": "PFLICHT: credentials: ReleaseCredentials.FromLicenseKey(schluessel) an CheckForUpdateAsync, und licenseKey an LaunchUpdateAgent. Ohne das bekommt die Anwendung 401 und keine Updates mehr."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "server",
|
||||||
|
"breaking": true,
|
||||||
|
"title": "Benutzername ist eine Ableitung, nicht der Schluessel",
|
||||||
|
"text": "Das htpasswd-Format hasht nur die Passwortspalte. Stuende der Lizenzschluessel auch als Benutzername darin, waere die Datei eine Klartext-Kundenliste. Der Benutzername ist deshalb lic_<sha256(schluessel), 16 Hexzeichen>.",
|
||||||
|
"action": "Nur relevant, wenn ihr Basic Auth selbst baut statt ReleaseCredentials zu benutzen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "sdk",
|
||||||
|
"breaking": false,
|
||||||
|
"title": "UpdateCheckResult.Unauthorized",
|
||||||
|
"text": "Trennt 'Lizenz traegt nicht mehr' von einem Netzwerkfehler. Ohne diese Unterscheidung sieht ein abgelaufener Vertrag aus wie eine Stoerung, und man sucht an der falschen Stelle.",
|
||||||
|
"action": "Empfohlen: Unauthorized abfragen und dem Benutzer als Lizenzhinweis zeigen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "server",
|
||||||
|
"breaking": false,
|
||||||
|
"title": "Reihenfolge bei Neuprodukten",
|
||||||
|
"text": "Fuer ein Produkt ohne Release existiert /releases/<slug>/ nicht und wird uebersprungen. Das Verzeichnis entsteht mit dem ersten Upload, der naechste Abgleich schuetzt es. Es gibt also kein Fenster, um ein ungeschuetztes Release zu ziehen und danach das SDK nachzuruesten.",
|
||||||
|
"action": "Der erste ausgelieferte Build eines neuen Produkts muss die Zugangsdaten schon mitbringen."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.3.0",
|
||||||
|
"date": "2026-08-13",
|
||||||
|
"summary": "Erstinstallation ueber den Update-Agent, setup.json, Installationskonto.",
|
||||||
|
"actionRequired": false,
|
||||||
|
"changes": [
|
||||||
|
{
|
||||||
|
"area": "agent",
|
||||||
|
"breaking": false,
|
||||||
|
"title": "update-agent --action install",
|
||||||
|
"text": "Fuehrt durch Anmeldung, Auswahl aus dem Katalog, Zielverzeichnis, Installation und Einrichtung. Die Dateien kommen ueber denselben Pfad wie ein Update - mit Pruefsumme, Signatur, Staging und Rollback.",
|
||||||
|
"action": "Optional: setup.json ins Publish-Verzeichnis legen, damit die Erstinstallation die noetigen Werte abfragen kann."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "agent",
|
||||||
|
"breaking": false,
|
||||||
|
"title": "setup.json beschreibt die einzurichtenden Werte",
|
||||||
|
"text": "Gefragt wird nur, was uebrig bleibt: bereits gesetzt -> detect:... -> provision -> fragen. Ziele haben ein 'location' (install, config, data, home) und optional fileWindows/fileLinux/fileMacOS. Dateien mit geheimen Werten werden auf den eigenen Benutzer beschraenkt.",
|
||||||
|
"action": "Optional. Ohne setup.json laesst sich die Anwendung installieren, aber nicht einrichten."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "server",
|
||||||
|
"breaking": false,
|
||||||
|
"title": "Rolle 'installer' und Benutzerverwaltung",
|
||||||
|
"text": "Konten dieser Rolle koennen sich ausschliesslich ueber /api/setup/v1/login anmelden und Anwendungen einrichten - nicht am WebUI. Die Zugangsdaten werden auf jedem Zielsystem eingetippt; mit einem Administratorkonto verteilte man den Zugang zur gesamten Verwaltung.",
|
||||||
|
"action": "Migration 012 einspielen. Fuer Erstinstallationen ein installer-Konto anlegen."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.2.0",
|
||||||
|
"date": "2026-08-09",
|
||||||
|
"summary": "Plattform-Dimension, signierte Releases, Update mit Rollback, geschuetzte Konfigurationsdateien.",
|
||||||
|
"actionRequired": true,
|
||||||
|
"changes": [
|
||||||
|
{
|
||||||
|
"area": "server",
|
||||||
|
"breaking": true,
|
||||||
|
"title": "Releases tragen eine Plattform",
|
||||||
|
"text": "Zuvor gab es die Dimension nicht: win-x64 und linux-x64 landeten unter derselben Version im selben Kanal und ueberschrieben sich: ein Linux-System zog sich das Windows-Paket. Ein Client, der keine Plattform mitschickt, sieht ausschliesslich Releases mit platform=any.",
|
||||||
|
"action": "PFLICHT beim Veroeffentlichen: --platform win-x64 (o. ae.) an pack-and-deploy. Clientseitig passiert es von selbst - CheckForUpdateAsync schickt ohne Angabe die Kennung des laufenden Systems."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "packager",
|
||||||
|
"breaking": true,
|
||||||
|
"title": "Version wird gegen die Hauptassembly geprueft",
|
||||||
|
"text": "Weicht --version von der einkompilierten ab, bricht der Vorgang ab. Wird 1.0.1 als 1.0.2 veroeffentlicht, aktualisieren alle Clients, melden danach weiter 1.0.1, halten das Release erneut fuer neu - eine Endlosschleife ueber die gesamte Installationsbasis.",
|
||||||
|
"action": "<Version> in die Directory.Build.props, nicht in einzelne csproj-Dateien. Notausgang: --ignore-version-mismatch."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "packager",
|
||||||
|
"breaking": true,
|
||||||
|
"title": "preservePatterns schuetzt Konfigurationsdateien",
|
||||||
|
"text": "Zuvor ueberschrieb jedes Update die eingerichteten Werte des Zielsystems. Ausschluss und Schutz sind zwei verschiedene Dinge: excludePatterns haelt eine Datei aus dem Paket, preservePatterns liefert sie aus, laesst am Ziel aber die vorhandene Fassung in Ruhe. Die Muster sind jetzt echte Globs - 'logs/**' traf zuvor nie zu.",
|
||||||
|
"action": "preservePatterns in die packager.config.json aufnehmen. Pruefen, ob eure Konfigurationsdateien bisher ueberschrieben wurden."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "agent",
|
||||||
|
"breaking": false,
|
||||||
|
"title": "Anwenden mit Backup und Rollback",
|
||||||
|
"text": "Die Stelle war als 'Atomic Replace with Backup' kommentiert und war eine Kopierschleife. Bricht sie ab, blieb eine halb aktualisierte Installation zurueck. Jetzt: Plan, Backup, Anwenden, bei Fehler vollstaendiger Rollback. Verwaiste Dateien werden entfernt - aber nur solche aus dem Manifest der Vorversion.",
|
||||||
|
"action": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "sdk",
|
||||||
|
"breaking": true,
|
||||||
|
"title": "LaunchUpdateAgent uebergibt Neustart und Prozesskennung",
|
||||||
|
"text": "--restart wurde nie uebergeben - die Anwendung schloss sich nach 'Jetzt installieren' und blieb zu. --wait-for-pid gab es nicht: der Agent kopierte bei langsamem Herunterfahren ueber gesperrte Dateien. ResolveAgentPath() liefert den plattformrichtigen Namen; ein fest verdrahtetes update-agent.exe wird unter Linux nie gefunden.",
|
||||||
|
"action": "UpdateClient.ResolveAgentPath() statt eines festen Pfads benutzen. currentVersion: BuildInfo.Version mitgeben - fuer Installationen ohne manifest.json."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "server",
|
||||||
|
"breaking": false,
|
||||||
|
"title": "Releases werden signiert",
|
||||||
|
"text": "Der SHA256 stammt aus derselben Quelle wie das Paket. Wer den Webroot kontrolliert, tauscht beide gemeinsam aus. Der Server signiert jetzt mit RSA-SHA256, der Agent prueft gegen /api/updateservice/v1/pubkey. Bewusst asymmetrisch: bei HMAC braeuchte der pruefende Agent denselben geheimen Schluessel.",
|
||||||
|
"action": "Serverseitig security.release_private_key hinterlegen. Ohne Schluessel bleiben Releases unsigniert und installierbar."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "sdk",
|
||||||
|
"breaking": true,
|
||||||
|
"title": "CheckForUpdateAsync hat einen Parameter mehr",
|
||||||
|
"text": "platform steht vor dem CancellationToken. Wer den Token positionell uebergeben hat, bekommt einen Uebersetzungsfehler - kein stilles Fehlverhalten.",
|
||||||
|
"action": "Benannte Argumente benutzen."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.1.0",
|
||||||
|
"date": "2026-08-08",
|
||||||
|
"summary": "Korrekturen im .NET-SDK, app_version am Heartbeat.",
|
||||||
|
"actionRequired": true,
|
||||||
|
"changes": [
|
||||||
|
{
|
||||||
|
"area": "sdk",
|
||||||
|
"breaking": true,
|
||||||
|
"title": "BuildInfo.targets erzeugt im Namensraum des Projekts",
|
||||||
|
"text": "Die vorherige Fassung erzeugte fest in Deploymentcenter.Client.Models und war damit nicht einbindbar (CS0433).",
|
||||||
|
"action": "Ein etwaiges 'using Deploymentcenter.Client.Models' fuer BuildInfo entfernen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "sdk",
|
||||||
|
"breaking": false,
|
||||||
|
"title": "API-Rueckfall der Update-Pruefung liefert vollstaendige Daten",
|
||||||
|
"text": "Fehlte die latest.json, kamen ueber die API weder Download-Adresse noch Pruefsumme, Changelog oder Kritikalitaet an - nur 'version' stimmte in beiden Formaten ueberein.",
|
||||||
|
"action": "Eigene Umgehungen koennen entfallen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "sdk",
|
||||||
|
"breaking": true,
|
||||||
|
"title": "unknown_error entfaellt, cache_ttl_hours wird ausgewertet",
|
||||||
|
"text": "Der Cache-Rueckfall greift jetzt bei jedem HTTP-Fehler, nicht nur bei bestimmten. app_version ist Parameter statt fest.",
|
||||||
|
"action": "Wer unknown_error abfaengt, prueft stattdessen IsTransient. LicenseClient.DefaultAppVersion beim Start setzen."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"date": "2026-08-07",
|
||||||
|
"summary": "Sicherheitsumstellung: Veroeffentlichen braucht ein Token, Versionsvergleich nach Semver.",
|
||||||
|
"actionRequired": true,
|
||||||
|
"changes": [
|
||||||
|
{
|
||||||
|
"area": "server",
|
||||||
|
"breaking": true,
|
||||||
|
"title": "Veroeffentlichen verlangt updateservice:publish",
|
||||||
|
"text": "Zuvor voellig ungeschuetzt - jeder konnte download_url und sha256_hash eines bestehenden Releases ueberschreiben und allen Clients ein beliebiges Paket unterschieben.",
|
||||||
|
"action": "Token mit dem Recht updateservice:publish erzeugen und als DC_TOKEN hinterlegen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "server",
|
||||||
|
"breaking": true,
|
||||||
|
"title": "Versionsvergleich folgt der semantischen Ordnung",
|
||||||
|
"text": "Zuvor verglich SQL lexikografisch: 1.9.0 galt als neuer als 1.10.0, und Clients bekamen ein Downgrade als Update angeboten.",
|
||||||
|
"action": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "server",
|
||||||
|
"breaking": true,
|
||||||
|
"title": "Einheitliches Antwortformat",
|
||||||
|
"text": "Alle JSON-Endpunkte antworten mit {\"status\":\"success\",...} bzw. {\"status\":\"error\",\"error\":{\"code\":...}}. Der code ist stabil und fuer Programme gedacht, die message richtet sich an Menschen.",
|
||||||
|
"action": "Auswertung der Antworten auf das neue Format umstellen."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lesbare Ansicht des Changelogs.
|
||||||
|
*
|
||||||
|
* Quelle ist changelog.json - dieselbe Datei, die
|
||||||
|
* GET /api/updateservice/v1/changelog ausliefert. Zwei Fassungen zu pflegen
|
||||||
|
* hiesse, sie frueher oder spaeter auseinanderlaufen zu lassen.
|
||||||
|
*
|
||||||
|
* /docs/changelog.php alles
|
||||||
|
* /docs/changelog.php?since=2.2.0 nur was seitdem kam
|
||||||
|
* /docs/changelog.php?raw die rohe JSON-Datei
|
||||||
|
*/
|
||||||
|
|
||||||
|
$path = __DIR__ . '/changelog.json';
|
||||||
|
$raw = is_file($path) ? (string)file_get_contents($path) : '';
|
||||||
|
$raw = preg_replace('/^\xEF\xBB\xBF/', '', $raw) ?? $raw;
|
||||||
|
|
||||||
|
if (isset($_GET['raw'])) {
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo $raw;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
// Auch bei kaputter Datei ein Array - sonst greift die Ausgabe unten auf null zu.
|
||||||
|
$data = is_array($decoded) ? $decoded : [];
|
||||||
|
$versions = isset($data['versions']) && is_array($data['versions'])
|
||||||
|
? $data['versions']
|
||||||
|
: [];
|
||||||
|
|
||||||
|
$since = isset($_GET['since']) ? trim((string)$_GET['since']) : '';
|
||||||
|
|
||||||
|
/** Semantischer Vergleich - dieselbe Ordnung wie serverseitig. */
|
||||||
|
function cl_core(string $v): array
|
||||||
|
{
|
||||||
|
$v = ltrim(trim($v), 'vV');
|
||||||
|
$plus = strpos($v, '+');
|
||||||
|
if ($plus !== false) { $v = substr($v, 0, $plus); }
|
||||||
|
$dash = strpos($v, '-');
|
||||||
|
if ($dash !== false) { $v = substr($v, 0, $dash); }
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach (explode('.', $v) as $part) {
|
||||||
|
$out[] = (int)preg_replace('/\D/', '', $part);
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cl_newer(string $candidate, string $than): bool
|
||||||
|
{
|
||||||
|
$a = cl_core($candidate);
|
||||||
|
$b = cl_core($than);
|
||||||
|
for ($i = 0; $i < max(count($a), count($b)); $i++) {
|
||||||
|
$x = $a[$i] ?? 0;
|
||||||
|
$y = $b[$i] ?? 0;
|
||||||
|
if ($x !== $y) { return $x > $y; }
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($since !== '') {
|
||||||
|
$versions = array_values(array_filter(
|
||||||
|
$versions,
|
||||||
|
static fn(array $v): bool => cl_newer((string)($v['version'] ?? '0'), $since)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
$actionItems = 0;
|
||||||
|
$breaking = 0;
|
||||||
|
foreach ($versions as $v) {
|
||||||
|
foreach ($v['changes'] ?? [] as $c) {
|
||||||
|
if (!empty($c['action'])) { $actionItems++; }
|
||||||
|
if (!empty($c['breaking'])) { $breaking++; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function e(?string $s): string
|
||||||
|
{
|
||||||
|
return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
$areaLabels = [
|
||||||
|
'server' => 'Server',
|
||||||
|
'sdk' => 'SDK',
|
||||||
|
'agent' => 'Update-Agent',
|
||||||
|
'packager' => 'Packager',
|
||||||
|
'tooling' => 'Werkzeuge',
|
||||||
|
'docs' => 'Dokumentation',
|
||||||
|
];
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Deploymentcenter — Änderungen</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0b0f19; --card: #141b2d; --accent: #5b9dff;
|
||||||
|
--text: #e2e8f0; --muted: #94a3b8; --border: #1f2937;
|
||||||
|
--warn: #fbbf24; --break: #f87171; --ok: #4ade80;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; padding: 2rem 1rem; background: var(--bg); color: var(--text);
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", sans-serif; line-height: 1.6; }
|
||||||
|
.wrap { max-width: 60rem; margin: 0 auto; }
|
||||||
|
h1 { margin: 0 0 .25rem; font-size: 1.75rem; }
|
||||||
|
.lead { color: var(--muted); margin: 0 0 1.5rem; }
|
||||||
|
code { font-family: "Fira Code", ui-monospace, monospace; font-size: .85em;
|
||||||
|
background: rgba(91,157,255,.12); padding: .1rem .35rem; border-radius: 3px; }
|
||||||
|
pre code { display: block; padding: .75rem; overflow-x: auto; }
|
||||||
|
.box { background: var(--card); border: 1px solid var(--border);
|
||||||
|
border-radius: 8px; padding: 1rem 1.25rem; margin-bottom: 1.25rem; }
|
||||||
|
.filter { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
|
||||||
|
input[type=text] { background: var(--bg); border: 1px solid var(--border); color: var(--text);
|
||||||
|
padding: .4rem .6rem; border-radius: 5px; font-family: inherit; }
|
||||||
|
button { background: var(--accent); border: 0; color: #041225; font-weight: 600;
|
||||||
|
padding: .45rem .9rem; border-radius: 5px; cursor: pointer; font-family: inherit; }
|
||||||
|
.ver { border-left: 3px solid var(--accent); padding-left: 1rem; margin: 2rem 0; }
|
||||||
|
.ver h2 { margin: 0; font-size: 1.3rem; }
|
||||||
|
.date { color: var(--muted); font-size: .85rem; }
|
||||||
|
.summary { margin: .4rem 0 1rem; }
|
||||||
|
.chg { border-top: 1px solid var(--border); padding: .9rem 0; }
|
||||||
|
.chg:first-of-type { border-top: 0; }
|
||||||
|
.chg h3 { margin: 0 0 .35rem; font-size: 1rem; }
|
||||||
|
.tag { display: inline-block; font-size: .7rem; font-weight: 700; letter-spacing: .03em;
|
||||||
|
padding: .1rem .45rem; border-radius: 4px; margin-right: .4rem; vertical-align: middle; }
|
||||||
|
.t-area { background: rgba(148,163,184,.18); color: var(--muted); }
|
||||||
|
.t-break { background: rgba(248,113,113,.16); color: var(--break); }
|
||||||
|
.t-act { background: rgba(251,191,36,.16); color: var(--warn); }
|
||||||
|
.action { background: rgba(251,191,36,.08); border-left: 2px solid var(--warn);
|
||||||
|
padding: .5rem .75rem; margin-top: .5rem; font-size: .92rem; }
|
||||||
|
.action strong { color: var(--warn); }
|
||||||
|
.none { color: var(--muted); font-style: italic; }
|
||||||
|
a { color: var(--accent); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
|
||||||
|
<h1>Änderungen am Deploymentcenter</h1>
|
||||||
|
<p class="lead">
|
||||||
|
Aktuelle Fassung: <code><?= e($data['current'] ?? '?') ?></code>.
|
||||||
|
Trage ein, gegen welche Fassung deine Anbindung gebaut wurde — dann steht hier
|
||||||
|
nur, was seitdem dazugekommen ist.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="box">
|
||||||
|
<form method="get" class="filter">
|
||||||
|
<label for="since">Meine Fassung:</label>
|
||||||
|
<input type="text" id="since" name="since" value="<?= e($since) ?>" placeholder="2.2.0" size="10">
|
||||||
|
<button type="submit">Unterschied zeigen</button>
|
||||||
|
<?php if ($since !== ''): ?>
|
||||||
|
<a href="changelog.php" style="margin-left:.5rem;">alles zeigen</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</form>
|
||||||
|
<p style="margin:.75rem 0 0; color:var(--muted); font-size:.9rem;">
|
||||||
|
Maschinenlesbar: <code>GET /api/updateservice/v1/changelog?since=<?= e($since !== '' ? $since : '2.2.0') ?></code>
|
||||||
|
· <a href="changelog.php?raw">rohes JSON</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($versions === []): ?>
|
||||||
|
<p class="none">
|
||||||
|
<?= $since !== ''
|
||||||
|
? 'Seit ' . e($since) . ' hat sich nichts geändert.'
|
||||||
|
: 'Es ist nichts hinterlegt.' ?>
|
||||||
|
</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="box">
|
||||||
|
<strong><?= count($versions) ?></strong> Fassung(en)<?= $since !== '' ? ' neuer als ' . e($since) : '' ?>,
|
||||||
|
davon <strong style="color:var(--warn);"><?= $actionItems ?></strong> Punkt(e) mit Handlungsbedarf
|
||||||
|
und <strong style="color:var(--break);"><?= $breaking ?></strong> mit Bruch.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php foreach ($versions as $v): ?>
|
||||||
|
<div class="ver">
|
||||||
|
<h2><?= e($v['version'] ?? '?') ?>
|
||||||
|
<span class="date">— <?= e($v['date'] ?? '') ?></span>
|
||||||
|
</h2>
|
||||||
|
<p class="summary"><?= e($v['summary'] ?? '') ?></p>
|
||||||
|
|
||||||
|
<?php foreach ($v['changes'] ?? [] as $c): ?>
|
||||||
|
<div class="chg">
|
||||||
|
<h3>
|
||||||
|
<span class="tag t-area"><?= e($areaLabels[$c['area'] ?? ''] ?? ($c['area'] ?? '?')) ?></span>
|
||||||
|
<?php if (!empty($c['breaking'])): ?><span class="tag t-break">BRUCH</span><?php endif; ?>
|
||||||
|
<?php if (!empty($c['action'])): ?><span class="tag t-act">TUN</span><?php endif; ?>
|
||||||
|
<?= e($c['title'] ?? '') ?>
|
||||||
|
</h3>
|
||||||
|
<div><?= e($c['text'] ?? '') ?></div>
|
||||||
|
<?php if (!empty($c['action'])): ?>
|
||||||
|
<div class="action"><strong>Zu tun:</strong> <?= e($c['action']) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<p style="margin-top:2.5rem; color:var(--muted); font-size:.9rem;">
|
||||||
|
<a href="/docs/">Agenten-Handbuch</a> ·
|
||||||
|
<a href="/docs/release.md">Veröffentlichen</a> ·
|
||||||
|
<a href="/api/openapi.json">OpenAPI</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"_comment": "Kopie als scripts/release.config.json anlegen und ausfuellen. Diese Datei enthaelt KEINE Zugangsdaten - die kommen aus Umgebungsvariablen (DC_FTP_HOST, DC_FTP_USER, DC_FTP_PASS, DC_TOKEN) oder aus einer packager.config.json neben dem Werkzeug. release.config.json darf deshalb versioniert werden.",
|
||||||
|
|
||||||
|
"_project_comment": "Projekt-Slug im Deploymentcenter. Muss dort unter Projekte angelegt sein, sonst schlaegt die Registrierung fehl.",
|
||||||
|
"project": "myapp",
|
||||||
|
|
||||||
|
"_csproj_comment": "Pfad zur Startprojektdatei, relativ zur Repository-Wurzel.",
|
||||||
|
"csproj": "src/MyApp/MyApp.csproj",
|
||||||
|
|
||||||
|
"_runtimes_comment": "Fuer welche Laufzeitkennungen gebaut wird. Je Eintrag entsteht ein eigenes Release - ohne Plattformangabe wuerden sie sich gegenseitig ueberschreiben.",
|
||||||
|
"runtimes": ["win-x64", "linux-x64"],
|
||||||
|
|
||||||
|
"_selfContained_comment": "true nimmt die .NET-Laufzeit ins Paket. Fuer Zielsysteme ohne vorinstalliertes .NET die richtige Wahl - das Paket wird dadurch deutlich groesser.",
|
||||||
|
"selfContained": true,
|
||||||
|
|
||||||
|
"_publishSingleFile_comment": "Alles in eine ausfuehrbare Datei. Bequem, erschwert aber das gezielte Ersetzen einzelner Dateien beim Update.",
|
||||||
|
"publishSingleFile": false,
|
||||||
|
|
||||||
|
"_setupJson_comment": "Beschreibung der einzurichtenden Werte, relativ zur Repository-Wurzel. Wird ins Paket kopiert, damit der Installer sie findet. Fehlt die Datei, wird ohne Einrichtungsschritt ausgeliefert.",
|
||||||
|
"setupJson": "setup.json",
|
||||||
|
|
||||||
|
"_mainAssembly_comment": "Optional. Datei, gegen die pack-and-deploy die Version gegenprueft. Ohne Angabe wird sie aus dem Projekt-Slug bzw. der runtimeconfig.json abgeleitet.",
|
||||||
|
"mainAssembly": "",
|
||||||
|
|
||||||
|
"_baseUrl_comment": "Adresse des Deploymentcenters. Von hier wird auch pack-and-deploy geholt.",
|
||||||
|
"baseUrl": "https://dc.mhdf.de"
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Veroeffentlicht dieses Projekt im Deploymentcenter.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Vorlage zum Kopieren nach scripts/release.ps1 des eigenen Projekts.
|
||||||
|
Anzupassen ist nur der Kopf von release.config.json - dieses Skript
|
||||||
|
selbst bleibt unveraendert.
|
||||||
|
|
||||||
|
Der Ablauf je Zielplattform:
|
||||||
|
dotnet publish -r <rid> -> pack-and-deploy --platform <rid>
|
||||||
|
|
||||||
|
pack-and-deploy uebernimmt dabei Pruefsummen, Dateimanifest, das
|
||||||
|
Fortschreiben der latest.json und die Anmeldung beim Deploymentcenter.
|
||||||
|
Das hier nachzubauen waere ein zweiter Ort fuer dieselben Fehler; das
|
||||||
|
Skript orchestriert nur.
|
||||||
|
|
||||||
|
Fehlt das Werkzeug, wird es geholt und die Pruefsumme geprueft.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\scripts\release.ps1 -Version 1.4.3 -Changelog "Behebt den Login-Fehler."
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\scripts\release.ps1 -Version 1.5.0 -Channel beta -WhatIf
|
||||||
|
#>
|
||||||
|
|
||||||
|
[CmdletBinding(SupportsShouldProcess = $true)]
|
||||||
|
param(
|
||||||
|
# Ohne Angabe wird die Version aus Directory.Build.props bzw. der csproj gelesen.
|
||||||
|
[string] $Version,
|
||||||
|
|
||||||
|
[ValidateSet('prod', 'beta', 'dev')]
|
||||||
|
[string] $Channel = 'prod',
|
||||||
|
|
||||||
|
[string] $Changelog,
|
||||||
|
|
||||||
|
# Als kritisches Update kennzeichnen (Rollout priorisieren).
|
||||||
|
[switch] $Critical,
|
||||||
|
|
||||||
|
[string] $ConfigFile = (Join-Path $PSScriptRoot 'release.config.json')
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Konfiguration
|
||||||
|
if (-not (Test-Path $ConfigFile)) {
|
||||||
|
throw "Konfiguration fehlt: $ConfigFile`nVorlage kopieren: release.config.example.json -> release.config.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = Get-Content $ConfigFile -Raw | ConvertFrom-Json
|
||||||
|
|
||||||
|
foreach ($required in @('project', 'csproj', 'runtimes')) {
|
||||||
|
if (-not $config.$required) {
|
||||||
|
throw "In $ConfigFile fehlt der Eintrag '$required'."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..')
|
||||||
|
$csprojRel = $config.csproj
|
||||||
|
$csproj = Join-Path $repoRoot $csprojRel
|
||||||
|
|
||||||
|
if (-not (Test-Path $csproj)) {
|
||||||
|
throw "Projektdatei nicht gefunden: $csproj"
|
||||||
|
}
|
||||||
|
|
||||||
|
$baseUrl = if ($config.baseUrl) { $config.baseUrl.TrimEnd('/') } else { 'https://dc.mhdf.de' }
|
||||||
|
$toolDir = Join-Path $repoRoot '.dc-tools'
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------- Version
|
||||||
|
function Get-ProjectVersion {
|
||||||
|
# Directory.Build.props zuerst: Steht <Version> nur in einem von mehreren
|
||||||
|
# Projekten, laufen die Angaben frueher oder spaeter auseinander - und
|
||||||
|
# pack-and-deploy bricht dann zu Recht mit einem Versionskonflikt ab.
|
||||||
|
foreach ($candidate in @(
|
||||||
|
(Join-Path $repoRoot 'Directory.Build.props'),
|
||||||
|
$csproj
|
||||||
|
)) {
|
||||||
|
if (-not (Test-Path $candidate)) { continue }
|
||||||
|
|
||||||
|
$match = [regex]::Match((Get-Content $candidate -Raw), '<Version>\s*([^<]+?)\s*</Version>')
|
||||||
|
if ($match.Success) {
|
||||||
|
return $match.Groups[1].Value.Trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $Version) {
|
||||||
|
$Version = Get-ProjectVersion
|
||||||
|
if (-not $Version) {
|
||||||
|
throw "Keine <Version> in Directory.Build.props oder $csprojRel gefunden. Bitte -Version angeben."
|
||||||
|
}
|
||||||
|
Write-Host "Version aus dem Projekt gelesen: $Version" -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $Changelog) {
|
||||||
|
$Changelog = "Release v$Version"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- Werkzeug
|
||||||
|
function Get-PackAndDeploy {
|
||||||
|
$exe = Join-Path $toolDir 'pack-and-deploy.exe'
|
||||||
|
if (Test-Path $exe) { return $exe }
|
||||||
|
|
||||||
|
Write-Host "pack-and-deploy wird geholt ..." -ForegroundColor Cyan
|
||||||
|
New-Item -ItemType Directory -Force -Path $toolDir | Out-Null
|
||||||
|
|
||||||
|
$name = 'pack-and-deploy-win-x64.exe'
|
||||||
|
$temp = Join-Path $toolDir 'download.tmp'
|
||||||
|
|
||||||
|
Invoke-WebRequest -Uri "$baseUrl/installer/$name" -OutFile $temp -UseBasicParsing
|
||||||
|
|
||||||
|
$expectedRaw = (Invoke-WebRequest -Uri "$baseUrl/installer/$name.sha256" -UseBasicParsing).Content
|
||||||
|
$expected = if ($expectedRaw -is [byte[]]) {
|
||||||
|
[System.Text.Encoding]::ASCII.GetString($expectedRaw)
|
||||||
|
} else { [string]$expectedRaw }
|
||||||
|
$expected = $expected.Trim().ToLower()
|
||||||
|
|
||||||
|
$actual = (Get-FileHash $temp -Algorithm SHA256).Hash.ToLower()
|
||||||
|
|
||||||
|
if ($actual -ne $expected) {
|
||||||
|
Remove-Item $temp -Force
|
||||||
|
throw "Pruefsumme von $name stimmt nicht.`n erwartet: $expected`n erhalten: $actual"
|
||||||
|
}
|
||||||
|
|
||||||
|
Move-Item $temp $exe -Force
|
||||||
|
try { Unblock-File $exe -ErrorAction SilentlyContinue } catch { }
|
||||||
|
|
||||||
|
Write-Host " Pruefsumme in Ordnung." -ForegroundColor DarkGray
|
||||||
|
return $exe
|
||||||
|
}
|
||||||
|
|
||||||
|
$packAndDeploy = Get-PackAndDeploy
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- Ablauf
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host "Projekt : $($config.project)" -ForegroundColor White
|
||||||
|
Write-Host "Version : $Version"
|
||||||
|
Write-Host "Kanal : $Channel"
|
||||||
|
Write-Host "Plattform : $($config.runtimes -join ', ')"
|
||||||
|
Write-Host ''
|
||||||
|
|
||||||
|
$results = @()
|
||||||
|
|
||||||
|
foreach ($rid in $config.runtimes) {
|
||||||
|
Write-Host "=== $rid ===" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$publishDir = Join-Path $repoRoot "artifacts/publish/$rid"
|
||||||
|
|
||||||
|
if ($PSCmdlet.ShouldProcess("$($config.project) $Version ($rid)", 'dotnet publish')) {
|
||||||
|
# Sauber neu bauen: Reste einer vorherigen Laufzeitkennung wuerden
|
||||||
|
# sonst mit ins Paket wandern.
|
||||||
|
if (Test-Path $publishDir) { Remove-Item $publishDir -Recurse -Force }
|
||||||
|
|
||||||
|
$publishArgs = @(
|
||||||
|
'publish', $csproj,
|
||||||
|
'-c', 'Release',
|
||||||
|
'-r', $rid,
|
||||||
|
'-o', $publishDir,
|
||||||
|
'--nologo'
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($config.selfContained) { $publishArgs += '--self-contained', 'true' }
|
||||||
|
else { $publishArgs += '--self-contained', 'false' }
|
||||||
|
|
||||||
|
if ($config.publishSingleFile) { $publishArgs += '-p:PublishSingleFile=true' }
|
||||||
|
|
||||||
|
& dotnet @publishArgs
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "dotnet publish fuer $rid ist fehlgeschlagen." }
|
||||||
|
}
|
||||||
|
|
||||||
|
# setup.json mitliefern, damit der Installer weiss, was einzurichten ist.
|
||||||
|
# Bewusst kein Ternary-Operator: den gibt es erst ab PowerShell 7, und
|
||||||
|
# diese Vorlage soll auch mit dem mitgelieferten 5.1 laufen.
|
||||||
|
$setupRel = if ($config.setupJson) { $config.setupJson } else { 'setup.json' }
|
||||||
|
$setupJson = Join-Path $repoRoot $setupRel
|
||||||
|
if (Test-Path $setupJson) {
|
||||||
|
Copy-Item $setupJson (Join-Path $publishDir 'setup.json') -Force
|
||||||
|
Write-Host " setup.json mitgenommen" -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($PSCmdlet.ShouldProcess("$($config.project) $Version ($rid)", 'pack-and-deploy')) {
|
||||||
|
$packArgs = @(
|
||||||
|
'--project', $config.project,
|
||||||
|
'--version', $Version,
|
||||||
|
'--channel', $Channel,
|
||||||
|
'--platform', $rid,
|
||||||
|
'--publish-dir', $publishDir,
|
||||||
|
'--changelog', $Changelog
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($Critical) { $packArgs += '--critical' }
|
||||||
|
if ($config.mainAssembly) { $packArgs += '--main-assembly', $config.mainAssembly }
|
||||||
|
|
||||||
|
& $packAndDeploy @packArgs
|
||||||
|
$code = $LASTEXITCODE
|
||||||
|
|
||||||
|
$results += [pscustomobject]@{ Runtime = $rid; ExitCode = $code }
|
||||||
|
|
||||||
|
# 1 = Konfigurationsfehler oder Versionskonflikt: dann stimmt etwas
|
||||||
|
# Grundsaetzliches, und die weiteren Plattformen wuerden genauso
|
||||||
|
# scheitern.
|
||||||
|
if ($code -eq 1) { throw "pack-and-deploy meldet einen Konfigurations- oder Versionsfehler." }
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ''
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ Ergebnis
|
||||||
|
Write-Host '=== Ergebnis ===' -ForegroundColor White
|
||||||
|
|
||||||
|
foreach ($r in $results) {
|
||||||
|
$text = switch ($r.ExitCode) {
|
||||||
|
0 { 'vollstaendig veroeffentlicht' }
|
||||||
|
2 { 'TEILWEISE - Upload oder Registrierung fehlgeschlagen' }
|
||||||
|
default { "unerwarteter Rueckgabewert $($r.ExitCode)" }
|
||||||
|
}
|
||||||
|
$color = if ($r.ExitCode -eq 0) { 'Green' } else { 'Yellow' }
|
||||||
|
Write-Host (" {0,-16} {1}" -f $r.Runtime, $text) -ForegroundColor $color
|
||||||
|
}
|
||||||
|
|
||||||
|
$failed = @($results | Where-Object { $_.ExitCode -ne 0 })
|
||||||
|
|
||||||
|
if ($failed.Count -gt 0) {
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host 'Nicht alle Plattformen sind durchgelaufen. Vor einem erneuten Versuch pruefen,' -ForegroundColor Yellow
|
||||||
|
Write-Host 'ob die bereits hochgeladenen Dateien konsistent sind.' -ForegroundColor Yellow
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host "Fertig. $($config.project) $Version ist im Kanal $Channel verfuegbar." -ForegroundColor Green
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Veroeffentlicht dieses Projekt im Deploymentcenter.
|
||||||
|
#
|
||||||
|
# Vorlage zum Kopieren nach scripts/release.sh des eigenen Projekts.
|
||||||
|
# Anzupassen ist nur scripts/release.config.json - dieses Skript selbst
|
||||||
|
# bleibt unveraendert.
|
||||||
|
#
|
||||||
|
# Der Ablauf je Zielplattform:
|
||||||
|
# dotnet publish -r <rid> -> pack-and-deploy --platform <rid>
|
||||||
|
#
|
||||||
|
# pack-and-deploy uebernimmt Pruefsummen, Dateimanifest, das Fortschreiben der
|
||||||
|
# latest.json und die Anmeldung beim Deploymentcenter. Das hier nachzubauen
|
||||||
|
# waere ein zweiter Ort fuer dieselben Fehler; das Skript orchestriert nur.
|
||||||
|
#
|
||||||
|
# ./scripts/release.sh --version 1.4.3 --changelog "Behebt den Login-Fehler."
|
||||||
|
# ./scripts/release.sh --version 1.5.0 --channel beta --dry-run
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
CONFIG_FILE="${DC_RELEASE_CONFIG:-$SCRIPT_DIR/release.config.json}"
|
||||||
|
|
||||||
|
VERSION=""
|
||||||
|
CHANNEL="prod"
|
||||||
|
CHANGELOG=""
|
||||||
|
CRITICAL=0
|
||||||
|
DRY_RUN=0
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--version) VERSION="$2"; shift 2 ;;
|
||||||
|
--channel) CHANNEL="$2"; shift 2 ;;
|
||||||
|
--changelog) CHANGELOG="$2"; shift 2 ;;
|
||||||
|
--critical) CRITICAL=1; shift ;;
|
||||||
|
--dry-run|-n) DRY_RUN=1; shift ;;
|
||||||
|
--help|-h) usage ;;
|
||||||
|
*) echo "Unbekannte Option: $1" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- Voraussetzungen
|
||||||
|
command -v dotnet >/dev/null 2>&1 || {
|
||||||
|
echo "FEHLER: dotnet wird gebraucht, ist aber nicht installiert." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[ -f "$CONFIG_FILE" ] || {
|
||||||
|
echo "FEHLER: Konfiguration fehlt: $CONFIG_FILE" >&2
|
||||||
|
echo " Vorlage kopieren: release.config.example.json -> release.config.json" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# JSON lesen - mit jq, sonst mit Python.
|
||||||
|
#
|
||||||
|
# Bewusst nicht nur jq: Es ist auf den wenigsten Systemen vorinstalliert, und
|
||||||
|
# an einer fehlenden Abhaengigkeit soll die Vorlage nicht scheitern. Python
|
||||||
|
# liegt auf den meisten Entwickler- und CI-Systemen ohnehin bereit.
|
||||||
|
JSON_READER=""
|
||||||
|
|
||||||
|
if command -v jq >/dev/null 2>&1; then
|
||||||
|
JSON_READER="jq"
|
||||||
|
else
|
||||||
|
# Nicht nur pruefen, ob der Befehl existiert, sondern ob er laeuft:
|
||||||
|
# Windows legt unter WindowsApps einen python3-Platzhalter ab, der
|
||||||
|
# gefunden wird, beim Aufruf aber nur auf den Store verweist.
|
||||||
|
for candidate in python3 python; do
|
||||||
|
if command -v "$candidate" >/dev/null 2>&1 \
|
||||||
|
&& "$candidate" -c "import json" >/dev/null 2>&1; then
|
||||||
|
JSON_READER="$candidate"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$JSON_READER" ]; then
|
||||||
|
echo "FEHLER: Zum Lesen von $CONFIG_FILE wird jq oder ein lauffaehiges Python gebraucht." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Liest einen Skalar. Pfad in jq-Schreibweise, z. B. .project
|
||||||
|
cfg() {
|
||||||
|
if [ "$JSON_READER" = "jq" ]; then
|
||||||
|
jq -r "$1 // empty" "$CONFIG_FILE"
|
||||||
|
else
|
||||||
|
"$JSON_READER" -c "
|
||||||
|
import json,sys
|
||||||
|
d=json.load(open(sys.argv[1], encoding='utf-8'))
|
||||||
|
for part in sys.argv[2].lstrip('.').split('.'):
|
||||||
|
if not isinstance(d, dict): d=None; break
|
||||||
|
d=d.get(part)
|
||||||
|
if d is None: print('')
|
||||||
|
elif isinstance(d, bool): print('true' if d else 'false')
|
||||||
|
else: print(d)
|
||||||
|
" "$CONFIG_FILE" "$1"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Liest ein Feld mit Zeichenketten, eine je Zeile.
|
||||||
|
cfg_list() {
|
||||||
|
if [ "$JSON_READER" = "jq" ]; then
|
||||||
|
jq -r "$1[]?" "$CONFIG_FILE"
|
||||||
|
else
|
||||||
|
"$JSON_READER" -c "
|
||||||
|
import json,sys
|
||||||
|
d=json.load(open(sys.argv[1], encoding='utf-8'))
|
||||||
|
for part in sys.argv[2].lstrip('.').split('.'):
|
||||||
|
d = d.get(part) if isinstance(d, dict) else None
|
||||||
|
for item in (d or []): print(item)
|
||||||
|
" "$CONFIG_FILE" "$1"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
PROJECT="$(cfg .project)"
|
||||||
|
CSPROJ_REL="$(cfg .csproj)"
|
||||||
|
BASE_URL="$(cfg .baseUrl)"
|
||||||
|
BASE_URL="${BASE_URL:-https://dc.mhdf.de}"
|
||||||
|
BASE_URL="${BASE_URL%/}"
|
||||||
|
SETUP_REL="$(cfg .setupJson)"
|
||||||
|
SETUP_REL="${SETUP_REL:-setup.json}"
|
||||||
|
MAIN_ASSEMBLY="$(cfg .mainAssembly)"
|
||||||
|
|
||||||
|
[ -n "$PROJECT" ] || { echo "FEHLER: 'project' fehlt in $CONFIG_FILE" >&2; exit 1; }
|
||||||
|
[ -n "$CSPROJ_REL" ] || { echo "FEHLER: 'csproj' fehlt in $CONFIG_FILE" >&2; exit 1; }
|
||||||
|
|
||||||
|
CSPROJ="$REPO_ROOT/$CSPROJ_REL"
|
||||||
|
[ -f "$CSPROJ" ] || { echo "FEHLER: Projektdatei nicht gefunden: $CSPROJ" >&2; exit 1; }
|
||||||
|
|
||||||
|
mapfile -t RUNTIMES < <(cfg_list .runtimes)
|
||||||
|
[ "${#RUNTIMES[@]}" -gt 0 ] || { echo "FEHLER: 'runtimes' ist leer." >&2; exit 1; }
|
||||||
|
|
||||||
|
SELF_CONTAINED="$(cfg .selfContained)"
|
||||||
|
SINGLE_FILE="$(cfg .publishSingleFile)"
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- Version
|
||||||
|
if [ -z "$VERSION" ]; then
|
||||||
|
# Directory.Build.props zuerst: Steht <Version> nur in einem von mehreren
|
||||||
|
# Projekten, laufen die Angaben auseinander - und pack-and-deploy bricht
|
||||||
|
# dann zu Recht mit einem Versionskonflikt ab.
|
||||||
|
for candidate in "$REPO_ROOT/Directory.Build.props" "$CSPROJ"; do
|
||||||
|
[ -f "$candidate" ] || continue
|
||||||
|
VERSION="$(sed -n 's:.*<Version>\s*\([^<]*\)\s*</Version>.*:\1:p' "$candidate" | head -1 | tr -d '[:space:]')"
|
||||||
|
[ -n "$VERSION" ] && break
|
||||||
|
done
|
||||||
|
|
||||||
|
[ -n "$VERSION" ] || {
|
||||||
|
echo "FEHLER: Keine <Version> gefunden. Bitte --version angeben." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo "Version aus dem Projekt gelesen: $VERSION"
|
||||||
|
fi
|
||||||
|
|
||||||
|
CHANGELOG="${CHANGELOG:-Release v$VERSION}"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- Werkzeug
|
||||||
|
TOOL_DIR="$REPO_ROOT/.dc-tools"
|
||||||
|
PACK="$TOOL_DIR/pack-and-deploy"
|
||||||
|
|
||||||
|
fetch_tool() {
|
||||||
|
[ -x "$PACK" ] && return 0
|
||||||
|
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64|amd64) rid="linux-x64" ;;
|
||||||
|
aarch64|arm64) rid="linux-arm64" ;;
|
||||||
|
*) echo "FEHLER: Nicht unterstuetzte Architektur $(uname -m)" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "pack-and-deploy wird geholt ($rid) ..."
|
||||||
|
mkdir -p "$TOOL_DIR"
|
||||||
|
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
curl -fsSL "$BASE_URL/installer/pack-and-deploy-$rid" -o "$tmp"
|
||||||
|
expected="$(curl -fsSL "$BASE_URL/installer/pack-and-deploy-$rid.sha256" | tr -d ' \t\r\n')"
|
||||||
|
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
actual="$(sha256sum "$tmp" | cut -d' ' -f1)"
|
||||||
|
else
|
||||||
|
actual="$(shasum -a 256 "$tmp" | cut -d' ' -f1)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$actual" != "$expected" ]; then
|
||||||
|
rm -f "$tmp"
|
||||||
|
echo "FEHLER: Pruefsumme stimmt nicht." >&2
|
||||||
|
echo " erwartet: $expected" >&2
|
||||||
|
echo " erhalten: $actual" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
chmod +x "$tmp"
|
||||||
|
mv "$tmp" "$PACK"
|
||||||
|
echo " Pruefsumme in Ordnung."
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch_tool
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- Ablauf
|
||||||
|
echo
|
||||||
|
echo "Projekt : $PROJECT"
|
||||||
|
echo "Version : $VERSION"
|
||||||
|
echo "Kanal : $CHANNEL"
|
||||||
|
echo "Plattform : ${RUNTIMES[*]}"
|
||||||
|
[ "$DRY_RUN" -eq 1 ] && echo "(Probelauf - es wird nichts hochgeladen)"
|
||||||
|
echo
|
||||||
|
|
||||||
|
FAILED=0
|
||||||
|
|
||||||
|
for rid in "${RUNTIMES[@]}"; do
|
||||||
|
echo "=== $rid ==="
|
||||||
|
|
||||||
|
PUBLISH_DIR="$REPO_ROOT/artifacts/publish/$rid"
|
||||||
|
|
||||||
|
if [ "$DRY_RUN" -eq 0 ]; then
|
||||||
|
# Sauber neu bauen: Reste einer vorherigen Laufzeitkennung wuerden
|
||||||
|
# sonst mit ins Paket wandern.
|
||||||
|
rm -rf "$PUBLISH_DIR"
|
||||||
|
|
||||||
|
publish_args=(publish "$CSPROJ" -c Release -r "$rid" -o "$PUBLISH_DIR" --nologo)
|
||||||
|
|
||||||
|
if [ "$SELF_CONTAINED" = "true" ]; then
|
||||||
|
publish_args+=(--self-contained true)
|
||||||
|
else
|
||||||
|
publish_args+=(--self-contained false)
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ "$SINGLE_FILE" = "true" ] && publish_args+=(-p:PublishSingleFile=true)
|
||||||
|
|
||||||
|
dotnet "${publish_args[@]}"
|
||||||
|
|
||||||
|
# setup.json mitliefern, damit der Installer weiss, was einzurichten ist.
|
||||||
|
if [ -f "$REPO_ROOT/$SETUP_REL" ]; then
|
||||||
|
cp "$REPO_ROOT/$SETUP_REL" "$PUBLISH_DIR/setup.json"
|
||||||
|
echo " setup.json mitgenommen"
|
||||||
|
fi
|
||||||
|
|
||||||
|
pack_args=(
|
||||||
|
--project "$PROJECT"
|
||||||
|
--version "$VERSION"
|
||||||
|
--channel "$CHANNEL"
|
||||||
|
--platform "$rid"
|
||||||
|
--publish-dir "$PUBLISH_DIR"
|
||||||
|
--changelog "$CHANGELOG"
|
||||||
|
)
|
||||||
|
|
||||||
|
[ "$CRITICAL" -eq 1 ] && pack_args+=(--critical)
|
||||||
|
[ -n "$MAIN_ASSEMBLY" ] && pack_args+=(--main-assembly "$MAIN_ASSEMBLY")
|
||||||
|
|
||||||
|
set +e
|
||||||
|
"$PACK" "${pack_args[@]}"
|
||||||
|
code=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
case "$code" in
|
||||||
|
0) echo " -> vollstaendig veroeffentlicht" ;;
|
||||||
|
1)
|
||||||
|
# Konfigurationsfehler oder Versionskonflikt: die weiteren
|
||||||
|
# Plattformen wuerden genauso scheitern.
|
||||||
|
echo "FEHLER: Konfigurations- oder Versionsfehler - Abbruch." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
2) echo " -> TEILWEISE: Upload oder Registrierung fehlgeschlagen"; FAILED=1 ;;
|
||||||
|
*) echo " -> unerwarteter Rueckgabewert $code"; FAILED=1 ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$FAILED" -ne 0 ]; then
|
||||||
|
echo "Nicht alle Plattformen sind durchgelaufen. Vor einem erneuten Versuch pruefen," >&2
|
||||||
|
echo "ob die bereits hochgeladenen Dateien konsistent sind." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Fertig. $PROJECT $VERSION ist im Kanal $CHANNEL verfuegbar."
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
# Ein Projekt veröffentlichungsfähig machen
|
||||||
|
|
||||||
|
> Für Coding-Agenten, die den UpdateService in ein Projekt integrieren.
|
||||||
|
> Ergebnis: `./scripts/release.ps1 -Version 1.4.3` baut, packt, lädt hoch und
|
||||||
|
> meldet das Release beim Deploymentcenter an — für alle Zielplattformen.
|
||||||
|
|
||||||
|
> **Aktualisierst du eine bestehende Anbindung?** Dann lies nicht alles neu.
|
||||||
|
> Frag zuerst, was seit deiner Fassung dazugekommen ist:
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> GET /api/updateservice/v1/changelog?since=2.2.0
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> `count: 0` heißt: du bist auf Stand. Sonst nennt jeder Eintrag unter
|
||||||
|
> `action`, was konkret zu tun ist. Für Menschen: [/docs/changelog.php](./changelog.php).
|
||||||
|
> Die eigene Fassung steht in `DeploymentcenterSdk.Version` — **und gehört in
|
||||||
|
> die `AGENTS.md` deines Projekts**, sonst fängst du beim nächsten Mal wieder
|
||||||
|
> von vorn an.
|
||||||
|
|
||||||
|
Es gibt bereits ein Werkzeug, das den schwierigen Teil erledigt:
|
||||||
|
**`pack-and-deploy`**. Es berechnet Prüfsummen, erzeugt das Dateimanifest,
|
||||||
|
schreibt die `latest.json` fort und meldet das Release über die API an. **Baue
|
||||||
|
das nicht nach.** Ein zweiter Ort für dieselbe Logik ist ein zweiter Ort, an
|
||||||
|
dem dieselben Fehler wieder entstehen — und dieses Werkzeug hat sie bereits
|
||||||
|
hinter sich.
|
||||||
|
|
||||||
|
Was fehlt, ist nur die Orchestrierung: pro Zielplattform einmal
|
||||||
|
`dotnet publish`, dann `pack-and-deploy`. Genau das ist die Vorlage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Einrichten
|
||||||
|
|
||||||
|
Drei Dateien, einmalig:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p scripts .dc-tools
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
curl -fsSL https://dc.mhdf.de/docs/release-template/release.ps1 -o scripts/release.ps1
|
||||||
|
|
||||||
|
# Linux / CI
|
||||||
|
curl -fsSL https://dc.mhdf.de/docs/release-template/release.sh -o scripts/release.sh
|
||||||
|
chmod +x scripts/release.sh
|
||||||
|
|
||||||
|
# in beiden Fällen
|
||||||
|
curl -fsSL https://dc.mhdf.de/docs/release-template/release.config.example.json \
|
||||||
|
-o scripts/release.config.json
|
||||||
|
|
||||||
|
echo '.dc-tools/' >> .gitignore
|
||||||
|
echo 'artifacts/' >> .gitignore
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nur `release.config.json` wird angepasst.** Die Skripte selbst bleiben
|
||||||
|
unverändert — dann lassen sie sich bei einer neuen Fassung einfach ersetzen.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"project": "myapp",
|
||||||
|
"csproj": "src/MyApp/MyApp.csproj",
|
||||||
|
"runtimes": ["win-x64", "linux-x64"],
|
||||||
|
"selfContained": true,
|
||||||
|
"setupJson": "setup.json"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`release.config.json` enthält **keine Zugangsdaten** und darf versioniert
|
||||||
|
werden.
|
||||||
|
|
||||||
|
### Zugangsdaten
|
||||||
|
|
||||||
|
Die kommen aus Umgebungsvariablen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export DC_FTP_HOST=ftp.example.com
|
||||||
|
export DC_FTP_USER=...
|
||||||
|
export DC_FTP_PASS=...
|
||||||
|
export DC_TOKEN=dc_master_... # braucht das Recht updateservice:publish
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternativ eine `packager.config.json` neben dem Werkzeug — die steht dann in
|
||||||
|
`.gitignore`. Ohne `DC_TOKEN` wird das Paket zwar gebaut und hochgeladen, aber
|
||||||
|
**nicht angemeldet und nicht signiert**; der Rückgabewert ist dann 2.
|
||||||
|
|
||||||
|
### Voraussetzungen
|
||||||
|
|
||||||
|
| | Windows | Linux |
|
||||||
|
|---|---|---|
|
||||||
|
| Skript | `release.ps1` (PowerShell 5.1 genügt) | `release.sh` |
|
||||||
|
| Nötig | .NET SDK | .NET SDK, `curl`, dazu `jq` **oder** Python |
|
||||||
|
|
||||||
|
`pack-and-deploy` holt sich das Skript beim ersten Lauf selbst von
|
||||||
|
`/installer/`, prüft die Prüfsumme und legt es unter `.dc-tools/` ab. Das
|
||||||
|
Verzeichnis gehört in die `.gitignore`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Veröffentlichen
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\release.ps1 -Version 1.4.3 -Changelog "Behebt den Login-Fehler."
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/release.sh --version 1.4.3 --changelog "Behebt den Login-Fehler."
|
||||||
|
```
|
||||||
|
|
||||||
|
Ohne `-Version` wird sie aus `Directory.Build.props` oder der `.csproj`
|
||||||
|
gelesen. Weitere Schalter: `-Channel beta`, `-Critical`, `-WhatIf`
|
||||||
|
beziehungsweise `--channel`, `--critical`, `--dry-run`.
|
||||||
|
|
||||||
|
Je Laufzeitkennung entsteht ein eigenes Release. **Ohne Plattformangabe würden
|
||||||
|
sie sich gegenseitig überschreiben** — bis Version 2.2 war genau das der Fall,
|
||||||
|
und ein Linux-System zog sich das Windows-Paket.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Was du im Projekt vorbereiten musst
|
||||||
|
|
||||||
|
### `<Version>` in die `Directory.Build.props`
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<Version>1.4.3</Version>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nicht in einzelne `.csproj`-Dateien.** `pack-and-deploy` liest die Version
|
||||||
|
aus der Hauptassembly und **bricht bei einer Abweichung ab**. Das ist Absicht:
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Konfigurationsdateien schützen
|
||||||
|
|
||||||
|
Zwei verschiedene Dinge, und die Verwechslung hat schon einen echten
|
||||||
|
API-Schlüssel öffentlich gemacht:
|
||||||
|
|
||||||
|
| | `excludePatterns` | `preservePatterns` |
|
||||||
|
|---|---|---|
|
||||||
|
| Im Paket? | nein | ja |
|
||||||
|
| Erstinstallation | fehlt | wird geschrieben |
|
||||||
|
| Update | — | vorhandene Datei bleibt unangetastet |
|
||||||
|
|
||||||
|
Eine `appsettings.json` mit echten Zugangsdaten gehört in **keine** von beiden
|
||||||
|
Listen — sie gehört gar nicht erst ins Publish-Verzeichnis. Was ausgeliefert
|
||||||
|
wird, muss eine **Vorlage mit Platzhaltern** sein.
|
||||||
|
|
||||||
|
**`pack-and-deploy` bricht ab, wenn es Zugangsdaten findet** — geprüft werden
|
||||||
|
Dateinamen (`appsettings.Local.json`, `master.key`, `*.pfx`, `*.db`, …) und der
|
||||||
|
Inhalt (gefüllte `Password=`-Felder, `sk-…`, `dc_master_…`, private Schlüssel).
|
||||||
|
Platzhalter wie `sk-DEIN-SCHLUESSEL-HIER` lösen nicht aus.
|
||||||
|
|
||||||
|
Häufigste Ursache: eine `CopyToOutputDirectory`-Regel in der `.csproj`, die
|
||||||
|
eine lokale Konfigurationsdatei ins Ausgabeverzeichnis kopiert. Dann gehört sie
|
||||||
|
in `excludePatterns` — und die Regel auf den Prüfstand.
|
||||||
|
|
||||||
|
Notausgang: `--allow-secrets`. **Nur benutzen, wenn du weißt, warum.**
|
||||||
|
Release-Pakete liegen hinter einem Zugangsschutz, aber jeder lizenzierte Kunde
|
||||||
|
kann sie auspacken.
|
||||||
|
|
||||||
|
### `setup.json` anlegen
|
||||||
|
|
||||||
|
Beschreibt, was die Erstinstallation abfragen muss. Das Skript kopiert sie ins
|
||||||
|
Publish-Verzeichnis, sodass sie im Paket landet.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema": 1,
|
||||||
|
"targets": [
|
||||||
|
{ "id": "app", "file": "myapp/Settings.json", "location": "config" }
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{ "key": "ConnectionStrings:Main", "label": "Datenbank", "type": "secret" },
|
||||||
|
{ "key": "Deploymentcenter:Token", "source": "provision",
|
||||||
|
"scopes": ["watchdog:ping"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Vollständig beschrieben in `docs/SETUP_INTEGRATION_GUIDE.md` im
|
||||||
|
Deploymentcenter-Repository.
|
||||||
|
Ohne `setup.json` lässt sich die Anwendung installieren, aber nicht einrichten.
|
||||||
|
|
||||||
|
### Das SDK einbinden
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var check = await new UpdateClient().CheckForUpdateAsync(
|
||||||
|
baseUrl: "https://dc.mhdf.de", projectId: "myapp",
|
||||||
|
currentVersion: BuildInfo.Version, channel: "prod",
|
||||||
|
credentials: ReleaseCredentials.FromLicenseKey(meineLizenz));
|
||||||
|
```
|
||||||
|
|
||||||
|
**Der Lizenzschlüssel ist Pflicht.** Die Release-Ablage liegt hinter
|
||||||
|
HTTP-Basic-Auth; ohne ihn bekommt die Anwendung 401 und keine Updates mehr.
|
||||||
|
Details in `docs/UPDATESERVICE_INTEGRATION_GUIDE.md` §5A
|
||||||
|
(Zugangsschutz der Release-Verzeichnisse).
|
||||||
|
|
||||||
|
> **Für ein Produkt, das noch nie veröffentlicht hat, gilt eine besondere
|
||||||
|
> Reihenfolge.** `/releases/<slug>/` existiert noch nicht und ist deshalb auch
|
||||||
|
> nicht geschützt. Das Verzeichnis entsteht mit dem ersten Upload, und der
|
||||||
|
> nächste Abgleich schützt es. Es gibt also kein Zeitfenster, um ein
|
||||||
|
> ungeschütztes Release zu ziehen und danach das SDK nachzurüsten: **der erste
|
||||||
|
> ausgelieferte Build muss die Zugangsdaten schon mitbringen.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Rückgabewerte
|
||||||
|
|
||||||
|
| Wert | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| `0` | vollständig veröffentlicht |
|
||||||
|
| `1` | Konfigurationsfehler oder Versionskonflikt — **nichts wurde ausgeführt** |
|
||||||
|
| `2` | teilweise: Upload oder Registrierung fehlgeschlagen |
|
||||||
|
|
||||||
|
Bei `1` bricht das Skript sofort ab, statt die übrigen Plattformen ins Leere
|
||||||
|
laufen zu lassen. Bei `2` läuft es weiter und meldet am Ende, welche
|
||||||
|
Plattformen betroffen sind — dort ist zu prüfen, ob die bereits hochgeladenen
|
||||||
|
Dateien zusammenpassen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Prüfen, ob es getragen hat
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://dc.mhdf.de/api/updateservice/v1/check?product=myapp&version=0.0.0&channel=prod&platform=win-x64"
|
||||||
|
```
|
||||||
|
|
||||||
|
Erwartet: `update_available: true` mit der neuen Version und `"signed": true`
|
||||||
|
in der Antwort des Publish-Aufrufs. Im WebUI erscheint das Release unter
|
||||||
|
*UpdateService → Releases* mit Plattform-Spalte; steht dort **UNSIGNIERT**,
|
||||||
|
fehlt auf dem Server der Signierschlüssel.
|
||||||
|
|
||||||
|
Und der Zugangsschutz:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -I https://dc.mhdf.de/releases/myapp/prod/win-x64/1.4.3/package.tar.gz # 401 erwartet
|
||||||
|
```
|
||||||
|
|
||||||
|
Antwortet das mit **200**, ist das Paket öffentlich abrufbar — dann im WebUI
|
||||||
|
unter *UpdateService → 🔒 Zugangsschutz* den Selbsttest laufen lassen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Häufige Stolperstellen
|
||||||
|
|
||||||
|
| Symptom | Ursache |
|
||||||
|
|---|---|
|
||||||
|
| `Versionskonflikt` beim Packen | `<Version>` steht nur in einem von mehreren Projekten — gehört in die `Directory.Build.props` |
|
||||||
|
| Rückgabewert 2, „Registrierung fehlgeschlagen" | `DC_TOKEN` fehlt oder hat nicht `updateservice:publish` |
|
||||||
|
| `unknown_project` | Der Slug ist im Deploymentcenter nicht angelegt |
|
||||||
|
| Client bekommt 401 statt Updates | Kein `licenseKey` übergeben, oder die Lizenz ist abgelaufen |
|
||||||
|
| Linux-Paket startet nicht | Unter Windows gebaut — der Agent setzt das Ausführungsbit beim Anwenden, ein von Hand entpacktes Archiv nicht |
|
||||||
|
| Update lädt endlos erneut | Veröffentlichte Version weicht von der einkompilierten ab |
|
||||||
@@ -35,6 +35,7 @@ use Deploymentcenter\Modules\License\KeyGen;
|
|||||||
use Deploymentcenter\Modules\UpdateService\ReleaseGuard;
|
use Deploymentcenter\Modules\UpdateService\ReleaseGuard;
|
||||||
use Deploymentcenter\Modules\UpdateService\ReleaseSigner;
|
use Deploymentcenter\Modules\UpdateService\ReleaseSigner;
|
||||||
use Deploymentcenter\Modules\UpdateService\UpdateManager;
|
use Deploymentcenter\Modules\UpdateService\UpdateManager;
|
||||||
|
use Deploymentcenter\Modules\Watchdog\AgentScript;
|
||||||
use Deploymentcenter\Modules\Watchdog\Evaluator;
|
use Deploymentcenter\Modules\Watchdog\Evaluator;
|
||||||
use Deploymentcenter\Modules\Watchdog\EventLog;
|
use Deploymentcenter\Modules\Watchdog\EventLog;
|
||||||
use Deploymentcenter\Modules\Watchdog\MonitorRepo;
|
use Deploymentcenter\Modules\Watchdog\MonitorRepo;
|
||||||
@@ -365,46 +366,18 @@ if (($_GET['action'] ?? '') === 'download_agent') {
|
|||||||
$os = trim((string)($_GET['os'] ?? 'windows'));
|
$os = trim((string)($_GET['os'] ?? 'windows'));
|
||||||
$token = trim((string)($_GET['token'] ?? ''));
|
$token = trim((string)($_GET['token'] ?? ''));
|
||||||
|
|
||||||
// Nur unkritische Zeichen, damit nichts in das erzeugte Skript
|
// Erzeugt wird in AgentScript - dieselbe Fassung, die der Installer ueber
|
||||||
// hineingeschmuggelt werden kann.
|
// den Setup-Weg bekommt. Das Saeubern der Eingaben passiert dort mit.
|
||||||
$source = preg_replace('/[^A-Za-z0-9._ -]/', '', $source) ?? 'server-node';
|
$agent = AgentScript::build(
|
||||||
$token = preg_replace('/[^A-Za-z0-9._-]/', '', $token) ?? '';
|
$os,
|
||||||
$endpoint = Http::baseUrl() . '/api/watchdog/v1/ping';
|
$source,
|
||||||
|
$token,
|
||||||
if ($os === 'windows') {
|
Http::baseUrl() . '/api/watchdog/v1/ping'
|
||||||
$script = "# Deploymentcenter Watchdog Agent (Windows PowerShell)\n"
|
);
|
||||||
. "# Als geplante Aufgabe minuetlich ausfuehren.\n\n"
|
|
||||||
. '$WatchdogUrl = "' . $endpoint . "\"\n"
|
|
||||||
. '$Token = "' . $token . "\"\n"
|
|
||||||
. '$Source = "' . $source . "\"\n\n"
|
|
||||||
. '$body = @{ source = $Source; status = "ok"; message = "Heartbeat via PowerShell Task"; interval = 60 } | ConvertTo-Json' . "\n"
|
|
||||||
. 'try {' . "\n"
|
|
||||||
. ' Invoke-RestMethod -Uri $WatchdogUrl -Method Post -Body $body -ContentType "application/json" -Headers @{ "X-Agent-Token" = $Token } | Out-Null' . "\n"
|
|
||||||
. ' Write-Host "[OK] Heartbeat gesendet."' . "\n"
|
|
||||||
. '} catch {' . "\n"
|
|
||||||
. ' Write-Error "Heartbeat fehlgeschlagen: $_"' . "\n"
|
|
||||||
. ' exit 1' . "\n"
|
|
||||||
. '}' . "\n";
|
|
||||||
$filename = 'watchdog-agent-' . $source . '.ps1';
|
|
||||||
} else {
|
|
||||||
$script = "#!/usr/bin/env bash\n"
|
|
||||||
. "# Deploymentcenter Watchdog Agent (Linux)\n"
|
|
||||||
. "# Per Cron minuetlich ausfuehren.\n\n"
|
|
||||||
. 'set -euo pipefail' . "\n\n"
|
|
||||||
. 'WATCHDOG_URL="' . $endpoint . "\"\n"
|
|
||||||
. 'TOKEN="' . $token . "\"\n"
|
|
||||||
. 'SOURCE="' . $source . "\"\n\n"
|
|
||||||
. 'curl -fsS -X POST "$WATCHDOG_URL" \\' . "\n"
|
|
||||||
. ' -H "Content-Type: application/json" \\' . "\n"
|
|
||||||
. ' -H "X-Agent-Token: $TOKEN" \\' . "\n"
|
|
||||||
. ' -d "{\"source\": \"$SOURCE\", \"status\": \"ok\", \"message\": \"Heartbeat via Cron\", \"interval\": 60}" \\' . "\n"
|
|
||||||
. ' > /dev/null && echo "[OK] Heartbeat gesendet."' . "\n";
|
|
||||||
$filename = 'watchdog-agent-' . $source . '.sh';
|
|
||||||
}
|
|
||||||
|
|
||||||
header('Content-Type: text/plain; charset=utf-8');
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
header('Content-Disposition: attachment; filename="' . preg_replace('/[^A-Za-z0-9._-]/', '', $filename) . '"');
|
header('Content-Disposition: attachment; filename="' . $agent['filename'] . '"');
|
||||||
echo $script;
|
echo $agent['script'];
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1265,9 +1238,55 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
dc_redirect('#sub-system-rocketchat');
|
dc_redirect('#sub-system-rocketchat');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'toggle_release_guard_exempt': {
|
||||||
|
$slug = trim((string)($_POST['slug'] ?? ''));
|
||||||
|
$makeExempt = ($_POST['exempt'] ?? '') === '1';
|
||||||
|
|
||||||
|
if ($slug === '') {
|
||||||
|
dc_flash('Kein Produkt angegeben.', 'danger');
|
||||||
|
dc_redirect('#sub-update-guard');
|
||||||
|
}
|
||||||
|
|
||||||
|
ReleaseGuard::setExempt($pdo, $slug, $makeExempt);
|
||||||
|
|
||||||
|
if ($makeExempt) {
|
||||||
|
dc_flash(sprintf(
|
||||||
|
'Achtung: "%s" ist jetzt OHNE Zugangsschutz - jeder im Internet kann die Pakete '
|
||||||
|
. 'herunterladen. Nur so lange lassen, bis die ausgelieferten Installationen ihren '
|
||||||
|
. 'Lizenzschluessel mitschicken.',
|
||||||
|
e($slug)
|
||||||
|
), 'danger');
|
||||||
|
} else {
|
||||||
|
dc_flash(sprintf(
|
||||||
|
'Zugangsschutz fuer "%s" ist wieder aktiv. Installationen ohne Lizenzschluessel '
|
||||||
|
. 'bekommen ab jetzt 401.',
|
||||||
|
e($slug)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
dc_redirect('#sub-update-guard');
|
||||||
|
}
|
||||||
|
|
||||||
case 'regenerate_release_guard': {
|
case 'regenerate_release_guard': {
|
||||||
$guard = ReleaseGuard::regenerateAll($pdo);
|
$guard = ReleaseGuard::regenerateAll($pdo);
|
||||||
|
|
||||||
|
// Direkt gegenpruefen, ob der Schutz tatsaechlich greift.
|
||||||
|
// Vorhandene Dateien beweisen nichts - unter Nginx werden sie
|
||||||
|
// ignoriert, und die Oberflaeche meldete trotzdem Erfolg.
|
||||||
|
$test = ReleaseGuard::selfTest($pdo);
|
||||||
|
|
||||||
|
if ($test['failed'] !== []) {
|
||||||
|
$reasons = array_map(
|
||||||
|
static fn(array $f): string => ($f['slug'] ?? '?') . ': ' . ($f['reason'] ?? ''),
|
||||||
|
$test['failed']
|
||||||
|
);
|
||||||
|
dc_flash(
|
||||||
|
'Zugangsschutz erzeugt, aber der Selbsttest schlaegt fehl! ' . e(implode(' | ', $reasons)),
|
||||||
|
'danger'
|
||||||
|
);
|
||||||
|
dc_redirect('#sub-update-guard');
|
||||||
|
}
|
||||||
|
|
||||||
if ($guard['errors'] !== []) {
|
if ($guard['errors'] !== []) {
|
||||||
dc_flash(
|
dc_flash(
|
||||||
'Zugangsschutz teilweise erneuert. Fehler: ' . e(implode(' | ', $guard['errors'])),
|
'Zugangsschutz teilweise erneuert. Fehler: ' . e(implode(' | ', $guard['errors'])),
|
||||||
@@ -1275,9 +1294,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
dc_flash(sprintf(
|
dc_flash(sprintf(
|
||||||
'Zugangsschutz erneuert: %d Produkt(e), %d Zugang/Zugaenge.%s',
|
'Zugangsschutz erneuert und geprueft: %d Produkt(e), %d Zugang/Zugaenge, '
|
||||||
|
. '%d Adresse(n) antworten ohne Zugangsdaten korrekt mit 401.%s',
|
||||||
$guard['projects'],
|
$guard['projects'],
|
||||||
$guard['entries'],
|
$guard['entries'],
|
||||||
|
$test['ok'],
|
||||||
$guard['skipped'] !== []
|
$guard['skipped'] !== []
|
||||||
? ' Ohne Zugaenge und damit gesperrt: ' . e(implode(', ', $guard['skipped'])) . '.'
|
? ' Ohne Zugaenge und damit gesperrt: ' . e(implode(', ', $guard['skipped'])) . '.'
|
||||||
: ''
|
: ''
|
||||||
@@ -1331,10 +1352,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
|
|
||||||
// Installationskonten stehen in jeder .htpasswd - ohne sie
|
// Installationskonten stehen in jeder .htpasswd - ohne sie
|
||||||
// waere eine Erstinstallation nicht moeglich, weil es dabei
|
// waere eine Erstinstallation nicht moeglich, weil es dabei
|
||||||
// noch keinen Lizenzschluessel gibt.
|
// noch keinen Lizenzschluessel gibt. Administratoren zaehlen
|
||||||
if ($newRole === 'installer') {
|
// dazu; die Bedingung stand hier noch aus der Zeit, als nur
|
||||||
|
// die Rolle 'installer' Zugang hatte, und liess ein frisches
|
||||||
|
// Administratorkonto bis zum naechsten turnusmaessigen Lauf
|
||||||
|
// vor verschlossener Ablage stehen.
|
||||||
dc_guard_releases($pdo);
|
dc_guard_releases($pdo);
|
||||||
}
|
|
||||||
|
|
||||||
dc_flash(sprintf(
|
dc_flash(sprintf(
|
||||||
'Konto "%s" mit der Rolle %s angelegt.%s',
|
'Konto "%s" mit der Rolle %s angelegt.%s',
|
||||||
@@ -1650,9 +1673,15 @@ function getMonitorIconUrl(?string $icon, ?string $source, ?string $os, ?string
|
|||||||
if (str_starts_with($icon, 'custom/')) {
|
if (str_starts_with($icon, 'custom/')) {
|
||||||
return 'assets/icons/' . $icon;
|
return 'assets/icons/' . $icon;
|
||||||
}
|
}
|
||||||
if (str_starts_with($icon, 'assets/')) {
|
if (str_starts_with($icon, 'assets/icons/')) {
|
||||||
return $icon;
|
return $icon;
|
||||||
}
|
}
|
||||||
|
// Fruehere Fassungen legten die Symbole flach unter assets/ ab und
|
||||||
|
// schrieben diesen Pfad in die Datenbank. Die Dateien liegen jetzt nur
|
||||||
|
// noch unter assets/icons/ - Altwerte werden hier umgesetzt.
|
||||||
|
if (str_starts_with($icon, 'assets/')) {
|
||||||
|
return 'assets/icons/' . substr($icon, strlen('assets/'));
|
||||||
|
}
|
||||||
return 'assets/icons/' . $icon;
|
return 'assets/icons/' . $icon;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1702,7 +1731,11 @@ try {
|
|||||||
$usersSchemaReady = $users === [] || array_key_exists('role', $users[0]);
|
$usersSchemaReady = $users === [] || array_key_exists('role', $users[0]);
|
||||||
|
|
||||||
// Zustand des Zugangsschutzes der Release-Verzeichnisse.
|
// Zustand des Zugangsschutzes der Release-Verzeichnisse.
|
||||||
$releaseGuard = ReleaseGuard::status();
|
$releaseGuard = ReleaseGuard::status($pdo);
|
||||||
|
|
||||||
|
// Ergebnis des letzten Selbsttests. Bewusst nur gelesen: ein HTTP-Abruf je
|
||||||
|
// Produkt bei jedem Seitenaufbau waere Verschwendung.
|
||||||
|
$releaseGuardTest = ReleaseGuard::lastSelfTest($pdo);
|
||||||
|
|
||||||
// Installer-Downloads. Die Binaries liegen unter /installer/ im Webroot und
|
// Installer-Downloads. Die Binaries liegen unter /installer/ im Webroot und
|
||||||
// werden nicht von deploy.py uebertragen, sondern von
|
// werden nicht von deploy.py uebertragen, sondern von
|
||||||
@@ -3159,17 +3192,21 @@ Antwort:
|
|||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Zugänge</th>
|
<th>Zugänge</th>
|
||||||
<th>Zuletzt erzeugt</th>
|
<th>Zuletzt erzeugt</th>
|
||||||
|
<th>Aktion</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php if ($releaseGuard === []): ?>
|
<?php if ($releaseGuard === []): ?>
|
||||||
<tr><td colspan="4" style="color:var(--text-muted);">Es liegen noch keine Release-Verzeichnisse vor.</td></tr>
|
<tr><td colspan="5" style="color:var(--text-muted);">Es liegen noch keine Release-Verzeichnisse vor.</td></tr>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php foreach ($releaseGuard as $g): ?>
|
<?php foreach ($releaseGuard as $g): ?>
|
||||||
|
<?php $isExempt = !empty($g['exempt']); ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong><?= e((string)$g['slug']) ?></strong></td>
|
<td><strong><?= e((string)$g['slug']) ?></strong></td>
|
||||||
<td>
|
<td>
|
||||||
<?php if (!$g['protected']): ?>
|
<?php if ($isExempt): ?>
|
||||||
|
<span class="badge badge-down">AUSGENOMMEN</span>
|
||||||
|
<?php elseif (!$g['protected']): ?>
|
||||||
<span class="badge badge-down">OFFEN</span>
|
<span class="badge badge-down">OFFEN</span>
|
||||||
<?php elseif ((int)$g['entries'] === 0): ?>
|
<?php elseif ((int)$g['entries'] === 0): ?>
|
||||||
<span class="badge badge-warning">GESPERRT</span>
|
<span class="badge badge-warning">GESPERRT</span>
|
||||||
@@ -3184,12 +3221,58 @@ Antwort:
|
|||||||
<td style="font-size:0.8rem; color:var(--text-muted);">
|
<td style="font-size:0.8rem; color:var(--text-muted);">
|
||||||
<?= $g['updated_at'] !== null ? e((string)$g['updated_at']) : '—' ?>
|
<?= $g['updated_at'] !== null ? e((string)$g['updated_at']) : '—' ?>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<form method="POST" action="index.php" style="display:inline;"
|
||||||
|
<?= $isExempt ? '' : 'onsubmit="return confirm(\'Damit kann jeder im Internet die Pakete von ' . e((string)$g['slug']) . ' herunterladen. Wirklich?\');"' ?>>
|
||||||
|
<?= $csrfField ?>
|
||||||
|
<input type="hidden" name="action" value="toggle_release_guard_exempt">
|
||||||
|
<input type="hidden" name="_return" value="sub-update-guard">
|
||||||
|
<input type="hidden" name="slug" value="<?= e((string)$g['slug']) ?>">
|
||||||
|
<input type="hidden" name="exempt" value="<?= $isExempt ? '0' : '1' ?>">
|
||||||
|
<button type="submit" class="btn btn-sm <?= $isExempt ? '' : 'btn-secondary' ?>">
|
||||||
|
<?= $isExempt ? 'Schutz aktivieren' : 'Ausnehmen' ?>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<?php if ($releaseGuardTest !== null): ?>
|
||||||
|
<?php $testFailed = !empty($releaseGuardTest['failed']); ?>
|
||||||
|
<div class="alert" style="margin-top:1rem; <?= $testFailed
|
||||||
|
? 'background:rgba(255,70,70,0.12); border:1px solid rgba(255,70,70,0.35); color:#fecaca;'
|
||||||
|
: 'background:rgba(60,200,120,0.12); border:1px solid rgba(60,200,120,0.3); color:#bbf7d0;' ?>">
|
||||||
|
<strong>Selbsttest vom <?= e((string)($releaseGuardTest['ran_at'] ?? '?')) ?> UTC:</strong>
|
||||||
|
<?= (int)($releaseGuardTest['ok'] ?? 0) ?> von
|
||||||
|
<?= (int)($releaseGuardTest['checked'] ?? 0) ?> Adresse(n) antworten ohne
|
||||||
|
Zugangsdaten korrekt mit 401.
|
||||||
|
<?php if ($testFailed): ?>
|
||||||
|
<ul style="margin:0.5rem 0 0 1rem;">
|
||||||
|
<?php foreach ($releaseGuardTest['failed'] as $f): ?>
|
||||||
|
<li><strong><?= e((string)($f['slug'] ?? '?')) ?>:</strong> <?= e((string)($f['reason'] ?? '')) ?></li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<p style="color:var(--text-muted); font-size:0.8rem; margin-top:1rem;">
|
<p style="color:var(--text-muted); font-size:0.8rem; margin-top:1rem;">
|
||||||
|
<strong>Der Selbsttest ist die einzige belastbare Aussage.</strong> Dass die
|
||||||
|
Dateien vorhanden sind, beweist nichts: Unter Nginx werden
|
||||||
|
<code>.htaccess</code>-Dateien vollständig ignoriert — die Verzeichnisse wären
|
||||||
|
dann offen und die <code>.htpasswd</code> öffentlich abrufbar, während hier
|
||||||
|
„GESCHÜTZT“ stünde. Der Test ruft die eigene Paket-Adresse ohne Zugangsdaten
|
||||||
|
ab und erwartet 401.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="color:var(--text-muted); font-size:0.8rem; margin-top:1rem;">
|
||||||
|
<strong>AUSGENOMMEN</strong> heißt: bewusst offen gelassen — für Produkte,
|
||||||
|
deren ausgelieferte Installationen noch mit einem SDK vor 2.4 gebaut sind und
|
||||||
|
deshalb keinen Lizenzschlüssel mitschicken. Sie bekämen sonst 401 statt
|
||||||
|
Updates. <strong>Sobald ein Build mit Schlüssel draußen ist, wieder
|
||||||
|
einschalten.</strong><br>
|
||||||
<strong>OFFEN</strong> heißt: jeder im Internet kann die Pakete laden.
|
<strong>OFFEN</strong> heißt: jeder im Internet kann die Pakete laden.
|
||||||
<strong>GESPERRT</strong> heißt: niemand kommt heran, weil es weder gültige
|
<strong>GESPERRT</strong> heißt: niemand kommt heran, weil es weder gültige
|
||||||
Lizenzen noch Installationskonten gibt — bei internen Anwendungen ohne
|
Lizenzen noch Installationskonten gibt — bei internen Anwendungen ohne
|
||||||
@@ -3305,6 +3388,12 @@ chmod +x update-agent
|
|||||||
man Nutzer sonst warnt.
|
man Nutzer sonst warnt.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p style="color:var(--text-muted); font-size:0.875rem; margin:1rem 0 0.75rem;">
|
||||||
|
Nur die <strong>Maschine überwachen</strong>, ohne etwas zu installieren —
|
||||||
|
legt Monitor und Token an, schreibt das Agent-Skript und plant es ein:
|
||||||
|
</p>
|
||||||
|
<div class="prompt-box">./update-agent --action monitor</div>
|
||||||
|
|
||||||
<p style="color:var(--text-muted); font-size:0.8rem; margin-top:0.75rem;">
|
<p style="color:var(--text-muted); font-size:0.8rem; margin-top:0.75rem;">
|
||||||
Für die Installation wird ein Konto der Rolle <strong>installer</strong>
|
Für die Installation wird ein Konto der Rolle <strong>installer</strong>
|
||||||
gebraucht — anzulegen unter <em>System → 👤 Benutzer</em>. Ein
|
gebraucht — anzulegen unter <em>System → 👤 Benutzer</em>. Ein
|
||||||
@@ -3611,6 +3700,10 @@ chmod +x update-agent
|
|||||||
'resolved' => 'Gelöst',
|
'resolved' => 'Gelöst',
|
||||||
'closed' => 'Geschlossen',
|
'closed' => 'Geschlossen',
|
||||||
'rejected' => 'Abgelehnt',
|
'rejected' => 'Abgelehnt',
|
||||||
|
// Meldungen aus einem anderen Projekt. Ohne eigenen
|
||||||
|
// Filtereintrag wären sie nur unter "Alle Status" zu
|
||||||
|
// finden - ein Eingang, den niemand sieht, ist keiner.
|
||||||
|
'triage_external' => '📥 Fremdmeldungen',
|
||||||
];
|
];
|
||||||
foreach ($statusOptions as $value => $label): ?>
|
foreach ($statusOptions as $value => $label): ?>
|
||||||
<option value="<?= e($value) ?>" <?= $btFilters['status'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
|
<option value="<?= e($value) ?>" <?= $btFilters['status'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
|
||||||
@@ -3681,6 +3774,7 @@ chmod +x update-agent
|
|||||||
'in_progress' => '<span class="badge badge-warning">IN BEARBEITUNG</span>',
|
'in_progress' => '<span class="badge badge-warning">IN BEARBEITUNG</span>',
|
||||||
'resolved' => '<span class="badge badge-up">GELÖST</span>',
|
'resolved' => '<span class="badge badge-up">GELÖST</span>',
|
||||||
'rejected' => '<span class="badge badge-stopped">ABGELEHNT</span>',
|
'rejected' => '<span class="badge badge-stopped">ABGELEHNT</span>',
|
||||||
|
'triage_external' => '<span class="badge" style="background:rgba(120,160,255,0.2); color:#7aa2ff; border:1px solid #7aa2ff;">📥 FREMDMELDUNG</span>',
|
||||||
default => '<span class="badge badge-stopped">' . e(strtoupper((string)$item['status'])) . '</span>',
|
default => '<span class="badge badge-stopped">' . e(strtoupper((string)$item['status'])) . '</span>',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -5739,6 +5833,7 @@ SYSTEM
|
|||||||
<option value="resolved">Gelöst / Umgesetzt</option>
|
<option value="resolved">Gelöst / Umgesetzt</option>
|
||||||
<option value="closed">Geschlossen</option>
|
<option value="closed">Geschlossen</option>
|
||||||
<option value="rejected">Abgelehnt</option>
|
<option value="rejected">Abgelehnt</option>
|
||||||
|
<option value="triage_external">📥 Fremdmeldung (ungesichtet)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ $ErrorActionPreference = 'Stop'
|
|||||||
|
|
||||||
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..')
|
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..')
|
||||||
$project = Join-Path $repoRoot 'client-dotnet\Deploymentcenter.UpdateAgent\Deploymentcenter.UpdateAgent.csproj'
|
$project = Join-Path $repoRoot 'client-dotnet\Deploymentcenter.UpdateAgent\Deploymentcenter.UpdateAgent.csproj'
|
||||||
|
$packager = Join-Path $repoRoot 'client-dotnet\Deploymentcenter.Packager\Deploymentcenter.Packager.csproj'
|
||||||
$staging = Join-Path ([System.IO.Path]::GetTempPath()) ("dc-installer-build-" + [guid]::NewGuid().ToString('N'))
|
$staging = Join-Path ([System.IO.Path]::GetTempPath()) ("dc-installer-build-" + [guid]::NewGuid().ToString('N'))
|
||||||
|
|
||||||
if (-not (Test-Path $project)) {
|
if (-not (Test-Path $project)) {
|
||||||
@@ -83,12 +84,64 @@ try {
|
|||||||
Write-Host (" {0,-22} {1,6:N1} MB {2}" -f $targetName, ($size / 1MB), $hash.Substring(0, 16))
|
Write-Host (" {0,-22} {1,6:N1} MB {2}" -f $targetName, ($size / 1MB), $hash.Substring(0, 16))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# pack-and-deploy
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Das Veroeffentlichungswerkzeug wurde in der Anleitung benutzt, als laege
|
||||||
|
# es im PATH - beziehbar war es nirgends. Fremde Projekte konnten also
|
||||||
|
# nicht veroeffentlichen, ohne dieses Repository auszuchecken und selbst zu
|
||||||
|
# uebersetzen.
|
||||||
|
$tools = @()
|
||||||
|
|
||||||
|
if (Test-Path $packager) {
|
||||||
|
foreach ($rid in $Runtimes) {
|
||||||
|
Write-Host "Baue pack-and-deploy fuer $rid ..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$ridOut = Join-Path $staging "packager-$rid"
|
||||||
|
|
||||||
|
dotnet publish $packager `
|
||||||
|
-c Release -r $rid `
|
||||||
|
--self-contained true `
|
||||||
|
-p:PublishSingleFile=true `
|
||||||
|
-p:EnableCompressionInSingleFile=true `
|
||||||
|
-p:DebugType=None `
|
||||||
|
-o $ridOut `
|
||||||
|
-v q --nologo
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "dotnet publish (pack-and-deploy) fuer $rid ist fehlgeschlagen."
|
||||||
|
}
|
||||||
|
|
||||||
|
$isWindows = $rid.StartsWith('win')
|
||||||
|
$sourceName = if ($isWindows) { 'pack-and-deploy.exe' } else { 'pack-and-deploy' }
|
||||||
|
$targetName = if ($isWindows) { "pack-and-deploy-$rid.exe" } else { "pack-and-deploy-$rid" }
|
||||||
|
|
||||||
|
$target = Join-Path $OutputDir $targetName
|
||||||
|
Copy-Item (Join-Path $ridOut $sourceName) $target -Force
|
||||||
|
|
||||||
|
$hash = (Get-FileHash $target -Algorithm SHA256).Hash.ToLower()
|
||||||
|
[System.IO.File]::WriteAllText("$target.sha256", $hash)
|
||||||
|
|
||||||
|
$tools += [ordered]@{
|
||||||
|
platform = $rid
|
||||||
|
file = $targetName
|
||||||
|
sha256 = $hash
|
||||||
|
sizeBytes = (Get-Item $target).Length
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host (" {0,-26} {1,6:N1} MB {2}" -f $targetName, ((Get-Item $target).Length / 1MB), $hash.Substring(0, 16))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Warning "Packager-Projekt nicht gefunden - pack-and-deploy wird nicht mit ausgeliefert."
|
||||||
|
}
|
||||||
|
|
||||||
$manifest = [ordered]@{
|
$manifest = [ordered]@{
|
||||||
tool = 'update-agent'
|
tool = 'update-agent'
|
||||||
version = $Version
|
version = $Version
|
||||||
gitCommit = $gitCommit
|
gitCommit = $gitCommit
|
||||||
buildDateUtc = (Get-Date).ToUniversalTime().ToString('o')
|
buildDateUtc = (Get-Date).ToUniversalTime().ToString('o')
|
||||||
binaries = $binaries
|
binaries = $binaries
|
||||||
|
tools = $tools
|
||||||
}
|
}
|
||||||
|
|
||||||
# Bewusst ueber WriteAllText mit einer BOM-freien Kodierung: Out-File
|
# Bewusst ueber WriteAllText mit einer BOM-freien Kodierung: Out-File
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ IGNORE_PATTERNS = {
|
|||||||
'log',
|
'log',
|
||||||
'__pycache__',
|
'__pycache__',
|
||||||
'client-dotnet',
|
'client-dotnet',
|
||||||
|
# Bibliothek zum Mitnehmen ins Zielprojekt bzw. lokal ueber stdio
|
||||||
|
# betriebener MCP-Server - beide gehoeren nicht ins Webroot.
|
||||||
|
'client-php',
|
||||||
|
'mcp',
|
||||||
# Gebaute Installer-Binaries. Sie gehoeren nach /installer/ und werden
|
# Gebaute Installer-Binaries. Sie gehoeren nach /installer/ und werden
|
||||||
# von upload_installer.py dorthin gebracht - ueber diesen Weg landeten
|
# von upload_installer.py dorthin gebracht - ueber diesen Weg landeten
|
||||||
# sonst ~100 MB zusaetzlich unter /artifacts/ im Webroot.
|
# sonst ~100 MB zusaetzlich unter /artifacts/ im Webroot.
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ try {
|
|||||||
Write-Host 'Weiter mit:'
|
Write-Host 'Weiter mit:'
|
||||||
Write-Host " & '$target' --action install"
|
Write-Host " & '$target' --action install"
|
||||||
Write-Host ''
|
Write-Host ''
|
||||||
|
Write-Host 'Nur diese Maschine ueberwachen, ohne etwas zu installieren:'
|
||||||
|
Write-Host " & '$target' --action monitor"
|
||||||
|
Write-Host ''
|
||||||
Write-Host "Dafuer werden Benutzername und Passwort eines Kontos der Rolle 'installer'"
|
Write-Host "Dafuer werden Benutzername und Passwort eines Kontos der Rolle 'installer'"
|
||||||
Write-Host 'gebraucht. Ein Administratorkonto tut es auch, gehoert aber nicht auf ein'
|
Write-Host 'gebraucht. Ein Administratorkonto tut es auch, gehoert aber nicht auf ein'
|
||||||
Write-Host 'Zielsystem.'
|
Write-Host 'Zielsystem.'
|
||||||
|
|||||||
@@ -104,6 +104,9 @@ echo ""
|
|||||||
echo "Weiter mit:"
|
echo "Weiter mit:"
|
||||||
echo " $target --action install"
|
echo " $target --action install"
|
||||||
echo ""
|
echo ""
|
||||||
|
echo "Nur diese Maschine ueberwachen, ohne etwas zu installieren:"
|
||||||
|
echo " $target --action monitor"
|
||||||
|
echo ""
|
||||||
echo "Dafuer werden Benutzername und Passwort eines Kontos der Rolle 'installer'"
|
echo "Dafuer werden Benutzername und Passwort eines Kontos der Rolle 'installer'"
|
||||||
echo "gebraucht. Ein Administratorkonto tut es auch, gehoert aber nicht auf ein"
|
echo "gebraucht. Ein Administratorkonto tut es auch, gehoert aber nicht auf ein"
|
||||||
echo "Zielsystem."
|
echo "Zielsystem."
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ use PDO;
|
|||||||
* ueber product_id an genau ein Projekt gebunden ist, faellt die Trennung beim
|
* ueber product_id an genau ein Projekt gebunden ist, faellt die Trennung beim
|
||||||
* Erzeugen von selbst ab.
|
* Erzeugen von selbst ab.
|
||||||
*
|
*
|
||||||
* Zusaetzlich stehen die Installationskonten in jeder Datei: Bei einer
|
* Zusaetzlich stehen die Installationskonten in jeder Datei - die der Rolle
|
||||||
* Erstinstallation gibt es noch keinen Lizenzschluessel, mit dem sich das
|
* 'installer' wie die der Rolle 'admin': Bei einer Erstinstallation gibt es
|
||||||
* Paket holen liesse.
|
* noch keinen Lizenzschluessel, mit dem sich das Paket holen liesse.
|
||||||
*/
|
*/
|
||||||
final class ReleaseGuard
|
final class ReleaseGuard
|
||||||
{
|
{
|
||||||
@@ -47,6 +47,18 @@ final class ReleaseGuard
|
|||||||
/** Von Apache verstandene Hash-Praefixe. */
|
/** Von Apache verstandene Hash-Praefixe. */
|
||||||
private const APACHE_BCRYPT_PREFIXES = ['$2y$', '$2a$', '$2b$'];
|
private const APACHE_BCRYPT_PREFIXES = ['$2y$', '$2a$', '$2b$'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leitet den Basic-Auth-Benutzernamen aus einem Lizenzschluessel ab.
|
||||||
|
*
|
||||||
|
* MUSS zeichengenau mit ReleaseCredentials.FromLicenseKey() im .NET-SDK
|
||||||
|
* uebereinstimmen - weichen die beiden voneinander ab, kommt niemand mehr
|
||||||
|
* an seine Updates.
|
||||||
|
*/
|
||||||
|
public static function licenseUsername(string $licenseKey): string
|
||||||
|
{
|
||||||
|
return 'lic_' . substr(hash('sha256', trim($licenseKey)), 0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
/** Wurzelverzeichnis der Releases im Dateisystem. */
|
/** Wurzelverzeichnis der Releases im Dateisystem. */
|
||||||
public static function releasesRoot(): string
|
public static function releasesRoot(): string
|
||||||
{
|
{
|
||||||
@@ -103,10 +115,15 @@ final class ReleaseGuard
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auch der Weg ueber die Oberflaeche vermerkt das Format - sonst
|
||||||
|
// sieht der naechste Tick eine Formataenderung, die es nicht gibt.
|
||||||
|
self::writeSetting($db, self::FORMAT_KEY, (string)self::FORMAT_VERSION);
|
||||||
|
|
||||||
Logger::info('Release-Zugangsschutz neu erzeugt', [
|
Logger::info('Release-Zugangsschutz neu erzeugt', [
|
||||||
'projects' => $result['projects'],
|
'projects' => $result['projects'],
|
||||||
'entries' => $result['entries'],
|
'entries' => $result['entries'],
|
||||||
'errors' => count($result['errors']),
|
'errors' => count($result['errors']),
|
||||||
|
'format' => self::FORMAT_VERSION,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
@@ -130,6 +147,15 @@ final class ReleaseGuard
|
|||||||
return ['entries' => 0, 'error' => sprintf('Kein Schreibrecht in %s', $dir)];
|
return ['entries' => 0, 'error' => sprintf('Kein Schreibrecht in %s', $dir)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ausgenommene Produkte bleiben offen. Eine bestehende .htaccess muss
|
||||||
|
// dabei weg - sonst bliebe der Schutz stehen und die Ausnahme waere
|
||||||
|
// wirkungslos.
|
||||||
|
if (self::isExempt($db, $slug)) {
|
||||||
|
@unlink($dir . '/.htaccess');
|
||||||
|
@unlink($dir . '/.htpasswd');
|
||||||
|
return ['entries' => 0, 'error' => null];
|
||||||
|
}
|
||||||
|
|
||||||
$entries = self::collectEntries($db, $slug);
|
$entries = self::collectEntries($db, $slug);
|
||||||
|
|
||||||
$lines = [];
|
$lines = [];
|
||||||
@@ -165,9 +191,16 @@ final class ReleaseGuard
|
|||||||
|
|
||||||
// 1. Gueltige Lizenzen dieses Produkts.
|
// 1. Gueltige Lizenzen dieses Produkts.
|
||||||
//
|
//
|
||||||
// Benutzername und Passwort sind beide der Lizenzschluessel: Basic Auth
|
// Der Benutzername wird aus dem Schluessel ABGELEITET, er ist nicht der
|
||||||
// verlangt zwei Felder, es gibt aber nur ein Geheimnis, und
|
// Schluessel selbst. Das htpasswd-Format hasht nur die Passwortspalte;
|
||||||
// Benutzernamen muessen eindeutig sein.
|
// stuende der Schluessel als Benutzername daneben, waere die Datei eine
|
||||||
|
// vollstaendige Klartext-Kundenliste und der bcrypt-Hash daneben blosse
|
||||||
|
// Dekoration. So enthaelt sie nur eine Einwegableitung und einen Hash
|
||||||
|
// ueber einen hochentropen Schluessel - selbst offengelegt ist damit
|
||||||
|
// nichts anzufangen.
|
||||||
|
//
|
||||||
|
// Die Ableitung muss zeichengenau der clientseitigen in
|
||||||
|
// ReleaseCredentials.FromLicenseKey() entsprechen.
|
||||||
try {
|
try {
|
||||||
$stmt = $db->prepare("
|
$stmt = $db->prepare("
|
||||||
SELECT l.license_key
|
SELECT l.license_key
|
||||||
@@ -185,7 +218,8 @@ final class ReleaseGuard
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$entries[$key] = password_hash($key, PASSWORD_BCRYPT, ['cost' => self::LICENSE_HASH_COST]);
|
$entries[self::licenseUsername($key)] =
|
||||||
|
password_hash($key, PASSWORD_BCRYPT, ['cost' => self::LICENSE_HASH_COST]);
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Logger::warning('Lizenzen fuer den Release-Schutz nicht lesbar', [
|
Logger::warning('Lizenzen fuer den Release-Schutz nicht lesbar', [
|
||||||
@@ -200,6 +234,14 @@ final class ReleaseGuard
|
|||||||
// ist die Anwendung noch nicht eingerichtet und hat keinen
|
// ist die Anwendung noch nicht eingerichtet und hat keinen
|
||||||
// Lizenzschluessel, mit dem sie das Paket holen koennte.
|
// Lizenzschluessel, mit dem sie das Paket holen koennte.
|
||||||
//
|
//
|
||||||
|
// Administratoren zaehlen dazu. Zuvor stand hier nur die Rolle
|
||||||
|
// 'installer' - waehrend die Installationsskripte und der Agent
|
||||||
|
// ausdruecklich sagten, ein Administratorkonto tue es auch. Es tat es
|
||||||
|
// nicht: der Login gelang, der Katalog kam, und erst der Download
|
||||||
|
// endete mit 401 und einer Meldung ueber abgelaufene Lizenzen. Ein
|
||||||
|
// Administrator darf ohnehin alles, was hier zu holen waere - die
|
||||||
|
// Zeile in der .htpasswd eroeffnet ihm nichts Neues.
|
||||||
|
//
|
||||||
// Der in dc_users abgelegte Hash wird unveraendert uebernommen - PHPs
|
// Der in dc_users abgelegte Hash wird unveraendert uebernommen - PHPs
|
||||||
// password_hash() erzeugt bcrypt im Format $2y$, genau das versteht
|
// password_hash() erzeugt bcrypt im Format $2y$, genau das versteht
|
||||||
// Apache. Ein Klartextpasswort wird dafuer nirgends gebraucht.
|
// Apache. Ein Klartextpasswort wird dafuer nirgends gebraucht.
|
||||||
@@ -207,7 +249,7 @@ final class ReleaseGuard
|
|||||||
$stmt = $db->query("
|
$stmt = $db->query("
|
||||||
SELECT username, password_hash
|
SELECT username, password_hash
|
||||||
FROM dc_users
|
FROM dc_users
|
||||||
WHERE role = 'installer' AND disabled = 0
|
WHERE role IN ('installer', 'admin') AND disabled = 0
|
||||||
");
|
");
|
||||||
|
|
||||||
foreach ($stmt->fetchAll() ?: [] as $row) {
|
foreach ($stmt->fetchAll() ?: [] as $row) {
|
||||||
@@ -304,6 +346,242 @@ final class ReleaseGuard
|
|||||||
/** Schluessel in dc_settings, unter dem der letzte Lauf vermerkt wird. */
|
/** Schluessel in dc_settings, unter dem der letzte Lauf vermerkt wird. */
|
||||||
private const LAST_RUN_KEY = 'release_guard_last_run';
|
private const LAST_RUN_KEY = 'release_guard_last_run';
|
||||||
|
|
||||||
|
/** Schluessel in dc_settings fuer das Ergebnis des letzten Selbsttests. */
|
||||||
|
private const SELFTEST_KEY = 'release_guard_selftest';
|
||||||
|
|
||||||
|
/** Schluessel in dc_settings fuer das Format der erzeugten Dateien. */
|
||||||
|
private const FORMAT_KEY = 'release_guard_format';
|
||||||
|
|
||||||
|
/** Schluessel in dc_settings fuer die ausgenommenen Produkte. */
|
||||||
|
private const EXEMPT_KEY = 'release_guard_exempt';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produkte, die bewusst ohne Zugangsschutz ausgeliefert werden.
|
||||||
|
*
|
||||||
|
* Wozu das noetig ist: Der Schutz entsteht beim ersten Lauf nach dem
|
||||||
|
* Deployment von selbst - auch fuer Produkte, deren ausgelieferte
|
||||||
|
* Installationen noch mit einem SDK vor 2.4 gebaut sind. Die schicken
|
||||||
|
* keine Zugangsdaten und bekommen ab dem Moment 401 statt Updates.
|
||||||
|
* Genau das ist bei Predictalytics passiert.
|
||||||
|
*
|
||||||
|
* Ohne diese Liste bliebe nur, den Schutz global abzuschalten oder die
|
||||||
|
* Installationsbasis auszusperren. Mit ihr laesst sich je Produkt
|
||||||
|
* umschalten, sobald dessen Clients nachgezogen sind.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function exemptProjects(PDO $db): array
|
||||||
|
{
|
||||||
|
$raw = self::readSetting($db, self::EXEMPT_KEY);
|
||||||
|
|
||||||
|
if ($raw === null || trim($raw) === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_filter(array_map(
|
||||||
|
static fn(string $s): string => strtolower(trim($s)),
|
||||||
|
explode(',', $raw)
|
||||||
|
), static fn(string $s): bool => $s !== ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isExempt(PDO $db, string $slug): bool
|
||||||
|
{
|
||||||
|
return in_array(strtolower(trim($slug)), self::exemptProjects($db), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nimmt ein Produkt vom Schutz aus oder hebt die Ausnahme wieder auf.
|
||||||
|
* Die Dateien werden anschliessend neu erzeugt bzw. entfernt.
|
||||||
|
*/
|
||||||
|
public static function setExempt(PDO $db, string $slug, bool $exempt): void
|
||||||
|
{
|
||||||
|
$slug = strtolower(trim($slug));
|
||||||
|
$list = self::exemptProjects($db);
|
||||||
|
|
||||||
|
if ($exempt) {
|
||||||
|
if (!in_array($slug, $list, true)) {
|
||||||
|
$list[] = $slug;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$list = array_values(array_filter($list, static fn(string $s): bool => $s !== $slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
self::writeSetting($db, self::EXEMPT_KEY, implode(',', $list));
|
||||||
|
|
||||||
|
Logger::warning($exempt
|
||||||
|
? 'Produkt vom Release-Zugangsschutz ausgenommen'
|
||||||
|
: 'Ausnahme vom Release-Zugangsschutz aufgehoben', ['product' => $slug]);
|
||||||
|
|
||||||
|
self::regenerateForProject($db, $slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aufbau der erzeugten .htpasswd. Aendert sich hier etwas, muessen alle
|
||||||
|
* Dateien neu geschrieben werden, sonst passen Server und Client nicht
|
||||||
|
* mehr zusammen.
|
||||||
|
*
|
||||||
|
* 1 - Benutzername = Lizenzschluessel im Klartext
|
||||||
|
* 2 - Benutzername = lic_<sha256(schluessel), 16 Hexzeichen>
|
||||||
|
* 3 - Administratorkonten zaehlen zu den Installationskonten
|
||||||
|
*
|
||||||
|
* Ohne diese Kennung waere der Umstieg auf 2 unbemerkt liegengeblieben:
|
||||||
|
* reconcile() sah keinen Anlass, und die Dateien behielten bis zum
|
||||||
|
* naechsten turnusmaessigen Lauf das alte Format - waehrend die Clients
|
||||||
|
* bereits das neue schicken. Fuer 3 gilt dasselbe: die Administratoren
|
||||||
|
* kaemen sonst bis zu sechs Stunden lang weiter nicht an ihr Paket.
|
||||||
|
*/
|
||||||
|
private const FORMAT_VERSION = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft, ob der Schutz tatsaechlich greift - durch einen echten Abruf.
|
||||||
|
*
|
||||||
|
* Dass .htaccess und .htpasswd vorhanden sind, beweist nichts. Unter Nginx
|
||||||
|
* werden .htaccess-Dateien vollstaendig ignoriert; die Verzeichnisse
|
||||||
|
* waeren dann offen und die .htpasswd oeffentlich abrufbar, waehrend die
|
||||||
|
* Oberflaeche "geschuetzt" meldet. Dasselbe gilt bei abgeschaltetem
|
||||||
|
* AllowOverride oder einem Tippfehler in der erzeugten Datei.
|
||||||
|
*
|
||||||
|
* Nur ein Abruf der eigenen Paket-Adresse ohne Zugangsdaten beantwortet
|
||||||
|
* die Frage, und der findet alle drei Faelle auf einmal.
|
||||||
|
*
|
||||||
|
* @return array{checked:int,ok:int,failed:list<array<string,mixed>>,ran_at:string}
|
||||||
|
*/
|
||||||
|
public static function selfTest(PDO $db, ?string $slug = null): array
|
||||||
|
{
|
||||||
|
$baseUrl = rtrim((string)\Deploymentcenter\Core\Config::get('app.url', ''), '/');
|
||||||
|
|
||||||
|
$result = ['checked' => 0, 'ok' => 0, 'failed' => [], 'ran_at' => gmdate('Y-m-d H:i:s')];
|
||||||
|
|
||||||
|
if ($baseUrl === '') {
|
||||||
|
$result['failed'][] = ['slug' => '-', 'reason' => 'app.url ist nicht konfiguriert.'];
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = '
|
||||||
|
SELECT product_slug, download_url
|
||||||
|
FROM updateservice_releases
|
||||||
|
WHERE download_url <> ""
|
||||||
|
';
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($slug !== null && $slug !== '') {
|
||||||
|
$sql .= ' AND product_slug = :slug';
|
||||||
|
$params[':slug'] = $slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= ' ORDER BY product_slug ASC, created_at DESC';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = $db->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$rows = $stmt->fetchAll() ?: [];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$result['failed'][] = ['slug' => '-', 'reason' => 'Releases nicht lesbar: ' . $e->getMessage()];
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Je Produkt genuegt eine Adresse.
|
||||||
|
$seen = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$product = (string)$row['product_slug'];
|
||||||
|
if (isset($seen[$product])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seen[$product] = true;
|
||||||
|
|
||||||
|
// Ein bewusst ausgenommenes Produkt darf den Selbsttest nicht
|
||||||
|
// rot faerben - sonst gewoehnt man sich an einen roten Test.
|
||||||
|
if (self::isExempt($db, $product)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = (string)$row['download_url'];
|
||||||
|
$result['checked']++;
|
||||||
|
|
||||||
|
$status = self::probe($url);
|
||||||
|
|
||||||
|
if ($status === 401) {
|
||||||
|
$result['ok']++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result['failed'][] = [
|
||||||
|
'slug' => $product,
|
||||||
|
'status' => $status,
|
||||||
|
'url' => $url,
|
||||||
|
'reason' => $status === 200
|
||||||
|
? 'Das Paket ist OHNE Zugangsdaten abrufbar. Wird .htaccess ausgewertet? '
|
||||||
|
. 'Unter Nginx wirkungslos, unter Apache AllowOverride pruefen.'
|
||||||
|
: sprintf('Unerwarteter Status %s - erwartet wurde 401.', $status === null ? 'keiner' : (string)$status),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
self::writeSetting($db, self::SELFTEST_KEY, json_encode($result) ?: '');
|
||||||
|
|
||||||
|
Logger::info('Release-Selbsttest', [
|
||||||
|
'checked' => $result['checked'],
|
||||||
|
'ok' => $result['ok'],
|
||||||
|
'failed' => count($result['failed']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ergebnis des letzten Selbsttests, oder null. */
|
||||||
|
public static function lastSelfTest(PDO $db): ?array
|
||||||
|
{
|
||||||
|
$raw = self::readSetting($db, self::SELFTEST_KEY);
|
||||||
|
if ($raw === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
return is_array($decoded) ? $decoded : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruft eine Adresse ohne Zugangsdaten ab und liefert den Statuscode.
|
||||||
|
* Bewusst nur der Kopf der Antwort - es geht um den Status, nicht um
|
||||||
|
* mehrere Megabyte Paket.
|
||||||
|
*/
|
||||||
|
private static function probe(string $url): ?int
|
||||||
|
{
|
||||||
|
if (function_exists('curl_init')) {
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_NOBODY => true,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 10,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 5,
|
||||||
|
CURLOPT_FOLLOWLOCATION => false,
|
||||||
|
// Bewusst kein Zugangsdaten-Header: geprueft wird ja gerade,
|
||||||
|
// ob der Abruf ohne scheitert.
|
||||||
|
]);
|
||||||
|
curl_exec($ch);
|
||||||
|
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
return $status > 0 ? $status : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = stream_context_create(['http' => [
|
||||||
|
'method' => 'HEAD',
|
||||||
|
'timeout' => 10,
|
||||||
|
'ignore_errors' => true,
|
||||||
|
]]);
|
||||||
|
|
||||||
|
@file_get_contents($url, false, $context);
|
||||||
|
|
||||||
|
foreach ($http_response_header ?? [] as $header) {
|
||||||
|
if (preg_match('#^HTTP/\S+\s+(\d{3})#', $header, $m) === 1) {
|
||||||
|
return (int)$m[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fuer den Cron gedacht: erzeugt nur dann neu, wenn es noetig ist.
|
* Fuer den Cron gedacht: erzeugt nur dann neu, wenn es noetig ist.
|
||||||
*
|
*
|
||||||
@@ -325,6 +603,8 @@ final class ReleaseGuard
|
|||||||
|
|
||||||
if ($lastRun === null) {
|
if ($lastRun === null) {
|
||||||
$reason = 'noch nie gelaufen';
|
$reason = 'noch nie gelaufen';
|
||||||
|
} elseif ((int)(self::readSetting($db, self::FORMAT_KEY) ?? '1') !== self::FORMAT_VERSION) {
|
||||||
|
$reason = 'Dateiformat geaendert';
|
||||||
} elseif (self::hasExpiredSince($db, $lastRun)) {
|
} elseif (self::hasExpiredSince($db, $lastRun)) {
|
||||||
$reason = 'Lizenz abgelaufen';
|
$reason = 'Lizenz abgelaufen';
|
||||||
} elseif ((time() - strtotime($lastRun . ' UTC')) >= $maxAge) {
|
} elseif ((time() - strtotime($lastRun . ' UTC')) >= $maxAge) {
|
||||||
@@ -366,10 +646,21 @@ final class ReleaseGuard
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static function readLastRun(PDO $db): ?string
|
private static function readLastRun(PDO $db): ?string
|
||||||
|
{
|
||||||
|
return self::readSetting($db, self::LAST_RUN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function writeLastRun(PDO $db): void
|
||||||
|
{
|
||||||
|
self::writeSetting($db, self::LAST_RUN_KEY, gmdate('Y-m-d H:i:s'));
|
||||||
|
self::writeSetting($db, self::FORMAT_KEY, (string)self::FORMAT_VERSION);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function readSetting(PDO $db, string $key): ?string
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$stmt = $db->prepare('SELECT svalue FROM dc_settings WHERE skey = :k LIMIT 1');
|
$stmt = $db->prepare('SELECT svalue FROM dc_settings WHERE skey = :k LIMIT 1');
|
||||||
$stmt->execute([':k' => self::LAST_RUN_KEY]);
|
$stmt->execute([':k' => $key]);
|
||||||
$value = $stmt->fetchColumn();
|
$value = $stmt->fetchColumn();
|
||||||
return is_string($value) && $value !== '' ? $value : null;
|
return is_string($value) && $value !== '' ? $value : null;
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
@@ -377,16 +668,16 @@ final class ReleaseGuard
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function writeLastRun(PDO $db): void
|
private static function writeSetting(PDO $db, string $key, string $value): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$stmt = $db->prepare('
|
$stmt = $db->prepare('
|
||||||
INSERT INTO dc_settings (skey, svalue) VALUES (:k, :v)
|
INSERT INTO dc_settings (skey, svalue) VALUES (:k, :v)
|
||||||
ON DUPLICATE KEY UPDATE svalue = VALUES(svalue)
|
ON DUPLICATE KEY UPDATE svalue = VALUES(svalue)
|
||||||
');
|
');
|
||||||
$stmt->execute([':k' => self::LAST_RUN_KEY, ':v' => gmdate('Y-m-d H:i:s')]);
|
$stmt->execute([':k' => $key, ':v' => $value]);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Logger::warning('Zeitpunkt des Release-Schutzes nicht vermerkt', ['error' => $e->getMessage()]);
|
Logger::warning('Einstellung nicht gespeichert', ['key' => $key, 'error' => $e->getMessage()]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,7 +687,7 @@ final class ReleaseGuard
|
|||||||
*
|
*
|
||||||
* @return list<array<string,mixed>>
|
* @return list<array<string,mixed>>
|
||||||
*/
|
*/
|
||||||
public static function status(): array
|
public static function status(?PDO $db = null): array
|
||||||
{
|
{
|
||||||
$root = self::releasesRoot();
|
$root = self::releasesRoot();
|
||||||
|
|
||||||
@@ -404,6 +695,7 @@ final class ReleaseGuard
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$exempt = $db !== null ? self::exemptProjects($db) : [];
|
||||||
$report = [];
|
$report = [];
|
||||||
|
|
||||||
foreach (scandir($root) ?: [] as $entry) {
|
foreach (scandir($root) ?: [] as $entry) {
|
||||||
@@ -425,6 +717,7 @@ final class ReleaseGuard
|
|||||||
$report[] = [
|
$report[] = [
|
||||||
'slug' => $entry,
|
'slug' => $entry,
|
||||||
'protected' => $protected,
|
'protected' => $protected,
|
||||||
|
'exempt' => in_array(strtolower((string)$entry), $exempt, true),
|
||||||
'entries' => $count,
|
'entries' => $count,
|
||||||
'updated_at' => $protected ? gmdate('Y-m-d H:i:s', (int)filemtime($htpasswd)) : null,
|
'updated_at' => $protected ? gmdate('Y-m-d H:i:s', (int)filemtime($htpasswd)) : null,
|
||||||
'writable' => is_writable($dir),
|
'writable' => is_writable($dir),
|
||||||
|
|||||||
@@ -0,0 +1,356 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Deploymentcenter\Modules\Watchdog;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt die Agent-Skripte, die auf einer Host-Maschine minuetlich laufen
|
||||||
|
* und einen Heartbeat samt Kennzahlen schicken.
|
||||||
|
*
|
||||||
|
* Warum hier und nicht dort, wo sie gebraucht werden: Die Skripte werden an
|
||||||
|
* zwei Stellen ausgeliefert - als Download aus der Oberflaeche und ueber den
|
||||||
|
* Setup-Weg an den Installer. Zwei Fassungen desselben Skripts laufen
|
||||||
|
* unweigerlich auseinander; auffallen wuerde das erst, wenn ein Host andere
|
||||||
|
* Werte liefert als der daneben.
|
||||||
|
*
|
||||||
|
* Die Erstfassung (inline in index.php) schickte nur "status: ok" ohne eine
|
||||||
|
* einzige Kennzahl. Damit beantwortete der Monitor genau eine Frage - laeuft
|
||||||
|
* die Kiste noch - und keine der Fragen, wegen derer man auf einen Host
|
||||||
|
* schaut: Ist die Platte voll? Frisst etwas den Speicher? Die Skripte hier
|
||||||
|
* melden Last, Speicher, Belegung und Laufzeit mit; der MetricStore nimmt
|
||||||
|
* jede numerische Angabe ohne weiteres Zutun auf.
|
||||||
|
*
|
||||||
|
* Bewusst ohne Fremdwerkzeuge: bash, awk und curl bzw. Windows PowerShell 5.1
|
||||||
|
* sind ueberall da. Ein Agent, der erst installiert werden muss, wird nicht
|
||||||
|
* installiert.
|
||||||
|
*/
|
||||||
|
final class AgentScript
|
||||||
|
{
|
||||||
|
/** Ab dieser Belegung meldet der Host "warning" statt "ok". */
|
||||||
|
private const DISK_WARN_PERCENT = 90;
|
||||||
|
|
||||||
|
/** Dasselbe fuer den Arbeitsspeicher. */
|
||||||
|
private const MEM_WARN_PERCENT = 95;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erlaubte Zeichen in einem Monitor-Namen.
|
||||||
|
*
|
||||||
|
* Der Name landet in einem Shell- bzw. PowerShell-Literal. Alles, was
|
||||||
|
* dort ausbrechen koennte - Anfuehrungszeichen, Dollar, Backticks,
|
||||||
|
* Zeilenumbrueche - hat darin nichts zu suchen.
|
||||||
|
*/
|
||||||
|
public static function sanitizeSource(string $source): string
|
||||||
|
{
|
||||||
|
$clean = trim(preg_replace('/[^A-Za-z0-9._ -]/', '', trim($source)) ?? '');
|
||||||
|
|
||||||
|
return $clean !== '' ? mb_substr($clean, 0, 100) : 'host';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dasselbe fuer ein Token - die Zeichen, die dc_tokens vergibt. */
|
||||||
|
public static function sanitizeToken(string $token): string
|
||||||
|
{
|
||||||
|
return preg_replace('/[^A-Za-z0-9._-]/', '', trim($token)) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalisiert die Betriebssystemangabe auf "windows" oder "linux". */
|
||||||
|
public static function normalizeOs(?string $os): string
|
||||||
|
{
|
||||||
|
$value = strtolower(trim((string)$os));
|
||||||
|
|
||||||
|
return in_array($value, ['windows', 'win', 'win32', 'win-x64', 'win-arm64'], true)
|
||||||
|
? 'windows'
|
||||||
|
: 'linux';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut das Skript.
|
||||||
|
*
|
||||||
|
* @return array{filename:string,script:string,os:string,interval:int,schedule:string}
|
||||||
|
* schedule ist der Befehl, mit dem sich das Skript einplanen
|
||||||
|
* laesst - der Installer setzt ihn ein, die Oberflaeche zeigt ihn
|
||||||
|
* an. {PFAD} steht darin fuer den Ablageort, den erst der kennt,
|
||||||
|
* der die Datei hinlegt.
|
||||||
|
*/
|
||||||
|
public static function build(
|
||||||
|
string $os,
|
||||||
|
string $source,
|
||||||
|
string $token,
|
||||||
|
string $endpoint,
|
||||||
|
int $intervalSec = 60
|
||||||
|
): array {
|
||||||
|
$os = self::normalizeOs($os);
|
||||||
|
$source = self::sanitizeSource($source);
|
||||||
|
$token = self::sanitizeToken($token);
|
||||||
|
$interval = max(30, min($intervalSec, 3600));
|
||||||
|
|
||||||
|
// Der Endpunkt stammt aus der eigenen Konfiguration, nicht aus einer
|
||||||
|
// Anfrage - trotzdem nicht ungeprueft in ein Skript schreiben.
|
||||||
|
$endpoint = filter_var($endpoint, FILTER_VALIDATE_URL) !== false ? $endpoint : '';
|
||||||
|
|
||||||
|
return $os === 'windows'
|
||||||
|
? self::buildWindows($source, $token, $endpoint, $interval)
|
||||||
|
: self::buildLinux($source, $token, $endpoint, $interval);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{filename:string,script:string,os:string,interval:int,schedule:string}
|
||||||
|
*/
|
||||||
|
private static function buildLinux(string $source, string $token, string $endpoint, int $interval): array
|
||||||
|
{
|
||||||
|
$diskWarn = self::DISK_WARN_PERCENT;
|
||||||
|
$memWarn = self::MEM_WARN_PERCENT;
|
||||||
|
$filename = 'watchdog-agent-' . self::fileSlug($source) . '.sh';
|
||||||
|
|
||||||
|
// Die Werte stammen aus /proc und df - kein Paket, das erst
|
||||||
|
// nachinstalliert werden muesste. awk rechnet, damit auch ein System
|
||||||
|
// ohne bc auskommt.
|
||||||
|
$script = <<<BASH
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Deploymentcenter Watchdog Agent
|
||||||
|
#
|
||||||
|
# Meldet Erreichbarkeit und Kennzahlen dieser Maschine. Erzeugt vom
|
||||||
|
# Deploymentcenter fuer den Monitor "{$source}" - nicht von Hand
|
||||||
|
# aendern, ein neu erzeugtes Skript ueberschreibt diese Datei.
|
||||||
|
#
|
||||||
|
# Einplanen (minuetlich):
|
||||||
|
# crontab -e
|
||||||
|
# * * * * * /pfad/zu/{$filename} >/dev/null 2>&1
|
||||||
|
#
|
||||||
|
# Die Datei enthaelt ein Token. Rechte 0600 belassen.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
# Ohne feste Locale gibt awk auf einem deutschen System "12,5" statt
|
||||||
|
# "12.5" aus - das erzeugte JSON waere kaputt, und der Server lehnte
|
||||||
|
# jeden Heartbeat mit einem Parserfehler ab.
|
||||||
|
export LC_ALL=C
|
||||||
|
|
||||||
|
WATCHDOG_URL="{$endpoint}"
|
||||||
|
TOKEN="{$token}"
|
||||||
|
SOURCE="{$source}"
|
||||||
|
INTERVAL={$interval}
|
||||||
|
|
||||||
|
# Ab diesen Werten wird "warning" gemeldet statt "ok".
|
||||||
|
DISK_WARN={$diskWarn}
|
||||||
|
MEM_WARN={$memWarn}
|
||||||
|
|
||||||
|
# --- Kennzahlen ---------------------------------------------------
|
||||||
|
# Last als Anteil der verfuegbaren Kerne: 1.0 bedeutet auf einer
|
||||||
|
# Maschine mit vier Kernen etwas anderes als auf einer mit einem.
|
||||||
|
cores=\$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 1)
|
||||||
|
load1=\$(awk '{print \$1}' /proc/loadavg 2>/dev/null || echo 0)
|
||||||
|
load_percent=\$(awk -v l="\$load1" -v c="\$cores" 'BEGIN { if (c <= 0) c = 1; printf "%.1f", (l / c) * 100 }')
|
||||||
|
|
||||||
|
mem_total=\$(awk '/^MemTotal:/ {print \$2}' /proc/meminfo 2>/dev/null || echo 0)
|
||||||
|
|
||||||
|
# MemAvailable gibt es erst ab Kernel 3.14. Fehlt es, ist MemFree die
|
||||||
|
# naechstbeste Auskunft - ohne diesen Rueckfall stuende dort 0, und
|
||||||
|
# der Host meldete auf ewig 100 Prozent Speicherbelegung.
|
||||||
|
mem_avail=\$(awk '/^MemAvailable:/ {print \$2}' /proc/meminfo 2>/dev/null)
|
||||||
|
if [ -z "\${mem_avail:-}" ]; then
|
||||||
|
mem_avail=\$(awk '/^MemFree:/ {print \$2}' /proc/meminfo 2>/dev/null || echo 0)
|
||||||
|
fi
|
||||||
|
|
||||||
|
mem_percent=\$(awk -v t="\$mem_total" -v a="\${mem_avail:-0}" 'BEGIN { if (t <= 0) { print 0 } else { printf "%.1f", ((t - a) / t) * 100 } }')
|
||||||
|
mem_total_mb=\$(awk -v t="\$mem_total" 'BEGIN { printf "%.0f", t / 1024 }')
|
||||||
|
|
||||||
|
# Von hinten gezaehlt, nicht von vorn: -P erzwingt zwar eine Zeile je
|
||||||
|
# Dateisystem, aber ein Geraetename mit Leerzeichen verschiebt trotzdem
|
||||||
|
# jede Spalte. Belegung und freier Platz stehen verlaesslich an
|
||||||
|
# vorletzter und drittletzter Stelle, weil der Mountpunkt hier "/" ist.
|
||||||
|
disk_percent=\$(df -P / 2>/dev/null | awk 'NR==2 {gsub("%","",\$(NF-1)); print \$(NF-1)}')
|
||||||
|
disk_free_gb=\$(df -P / 2>/dev/null | awk 'NR==2 {printf "%.1f", \$(NF-2) / 1048576}')
|
||||||
|
uptime_sec=\$(awk '{printf "%.0f", \$1}' /proc/uptime 2>/dev/null || echo 0)
|
||||||
|
|
||||||
|
: "\${disk_percent:=0}"
|
||||||
|
: "\${disk_free_gb:=0}"
|
||||||
|
|
||||||
|
# Sicherheitsnetz gegen ein df, das anders zaehlt als erwartet: ein
|
||||||
|
# Prozentwert jenseits von 0 bis 100 ist keine Belegung, sondern ein
|
||||||
|
# verrutschtes Feld - und wuerde als Dauerwarnung durchschlagen.
|
||||||
|
case "\$disk_percent" in
|
||||||
|
''|*[!0-9]*) disk_percent=0 ;;
|
||||||
|
*) [ "\$disk_percent" -gt 100 ] && disk_percent=0 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# --- Bewertung ----------------------------------------------------
|
||||||
|
status="ok"
|
||||||
|
message="Heartbeat"
|
||||||
|
|
||||||
|
if awk -v v="\$disk_percent" -v w="\$DISK_WARN" 'BEGIN { exit !(v >= w) }'; then
|
||||||
|
status="warning"
|
||||||
|
message="Platte zu \${disk_percent}% belegt"
|
||||||
|
elif awk -v v="\$mem_percent" -v w="\$MEM_WARN" 'BEGIN { exit !(v >= w) }'; then
|
||||||
|
status="warning"
|
||||||
|
message="Arbeitsspeicher zu \${mem_percent}% belegt"
|
||||||
|
fi
|
||||||
|
|
||||||
|
payload=\$(cat <<JSON
|
||||||
|
{
|
||||||
|
"source": "\$SOURCE",
|
||||||
|
"type": "host",
|
||||||
|
"os": "linux",
|
||||||
|
"status": "\$status",
|
||||||
|
"message": "\$message",
|
||||||
|
"interval": \$INTERVAL,
|
||||||
|
"metrics": {
|
||||||
|
"cpu_load_percent": \$load_percent,
|
||||||
|
"load_1min": \$load1,
|
||||||
|
"cpu_cores": \$cores,
|
||||||
|
"memory_used_percent": \$mem_percent,
|
||||||
|
"memory_total_mb": \$mem_total_mb,
|
||||||
|
"disk_used_percent": \$disk_percent,
|
||||||
|
"disk_free_gb": \$disk_free_gb,
|
||||||
|
"uptime_sec": \$uptime_sec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
)
|
||||||
|
|
||||||
|
# --fail laesst curl bei 4xx/5xx einen Fehlercode liefern; ohne das
|
||||||
|
# galte auch eine abgelehnte Anmeldung als erfolgreich gesendet.
|
||||||
|
if curl -fsS --max-time 20 -X POST "\$WATCHDOG_URL" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-H "X-Agent-Token: \$TOKEN" \\
|
||||||
|
-d "\$payload" > /dev/null; then
|
||||||
|
echo "[OK] Heartbeat gesendet (\$status)."
|
||||||
|
else
|
||||||
|
echo "[FEHLER] Heartbeat nicht angenommen." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BASH;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'filename' => $filename,
|
||||||
|
'script' => $script,
|
||||||
|
'os' => 'linux',
|
||||||
|
'interval' => $interval,
|
||||||
|
'schedule' => '* * * * * {PFAD} >/dev/null 2>&1',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{filename:string,script:string,os:string,interval:int,schedule:string}
|
||||||
|
*/
|
||||||
|
private static function buildWindows(string $source, string $token, string $endpoint, int $interval): array
|
||||||
|
{
|
||||||
|
$diskWarn = self::DISK_WARN_PERCENT;
|
||||||
|
$memWarn = self::MEM_WARN_PERCENT;
|
||||||
|
$filename = 'watchdog-agent-' . self::fileSlug($source) . '.ps1';
|
||||||
|
|
||||||
|
// Get-CimInstance statt Get-WmiObject: letzteres gibt es in
|
||||||
|
// PowerShell 7 nicht mehr, und dort landet man inzwischen schneller,
|
||||||
|
// als einem lieb ist.
|
||||||
|
$script = <<<POWERSHELL
|
||||||
|
# Deploymentcenter Watchdog Agent
|
||||||
|
#
|
||||||
|
# Meldet Erreichbarkeit und Kennzahlen dieser Maschine. Erzeugt vom
|
||||||
|
# Deploymentcenter fuer den Monitor "{$source}" - nicht von Hand
|
||||||
|
# aendern, ein neu erzeugtes Skript ueberschreibt diese Datei.
|
||||||
|
#
|
||||||
|
# Einplanen (minuetlich):
|
||||||
|
# schtasks /Create /SC MINUTE /MO 1 /TN "Deploymentcenter Watchdog {$source}"
|
||||||
|
# /TR "powershell -NoProfile -ExecutionPolicy Bypass -File <Pfad>\\{$filename}"
|
||||||
|
#
|
||||||
|
# Die Datei enthaelt ein Token.
|
||||||
|
|
||||||
|
\$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
\$WatchdogUrl = "{$endpoint}"
|
||||||
|
\$Token = "{$token}"
|
||||||
|
\$Source = "{$source}"
|
||||||
|
\$Interval = {$interval}
|
||||||
|
|
||||||
|
# Ab diesen Werten wird "warning" gemeldet statt "ok".
|
||||||
|
\$DiskWarn = {$diskWarn}
|
||||||
|
\$MemWarn = {$memWarn}
|
||||||
|
|
||||||
|
function Get-HostMetrics {
|
||||||
|
\$os = Get-CimInstance Win32_OperatingSystem
|
||||||
|
\$cpu = (Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average
|
||||||
|
\$disk = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='\$(\$env:SystemDrive)'"
|
||||||
|
|
||||||
|
\$memTotalMb = [math]::Round(\$os.TotalVisibleMemorySize / 1KB, 0)
|
||||||
|
\$memUsedPct = if (\$os.TotalVisibleMemorySize -gt 0) {
|
||||||
|
[math]::Round(((\$os.TotalVisibleMemorySize - \$os.FreePhysicalMemory) / \$os.TotalVisibleMemorySize) * 100, 1)
|
||||||
|
} else { 0 }
|
||||||
|
|
||||||
|
\$diskUsedPct = if (\$disk -and \$disk.Size -gt 0) {
|
||||||
|
[math]::Round(((\$disk.Size - \$disk.FreeSpace) / \$disk.Size) * 100, 1)
|
||||||
|
} else { 0 }
|
||||||
|
|
||||||
|
\$diskFreeGb = if (\$disk) { [math]::Round(\$disk.FreeSpace / 1GB, 1) } else { 0 }
|
||||||
|
|
||||||
|
# LastBootUpTime kommt als DateTime - die Differenz ist die Laufzeit.
|
||||||
|
\$uptimeSec = [math]::Round(((Get-Date) - \$os.LastBootUpTime).TotalSeconds, 0)
|
||||||
|
|
||||||
|
return [ordered]@{
|
||||||
|
cpu_load_percent = \$(if (\$null -ne \$cpu) { [double]\$cpu } else { 0 })
|
||||||
|
cpu_cores = [int]\$env:NUMBER_OF_PROCESSORS
|
||||||
|
memory_used_percent = \$memUsedPct
|
||||||
|
memory_total_mb = \$memTotalMb
|
||||||
|
disk_used_percent = \$diskUsedPct
|
||||||
|
disk_free_gb = \$diskFreeGb
|
||||||
|
uptime_sec = \$uptimeSec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
\$metrics = Get-HostMetrics
|
||||||
|
\$status = "ok"
|
||||||
|
\$message = "Heartbeat"
|
||||||
|
|
||||||
|
if (\$metrics.disk_used_percent -ge \$DiskWarn) {
|
||||||
|
\$status = "warning"
|
||||||
|
\$message = "Platte zu \$(\$metrics.disk_used_percent)% belegt"
|
||||||
|
} elseif (\$metrics.memory_used_percent -ge \$MemWarn) {
|
||||||
|
\$status = "warning"
|
||||||
|
\$message = "Arbeitsspeicher zu \$(\$metrics.memory_used_percent)% belegt"
|
||||||
|
}
|
||||||
|
|
||||||
|
\$body = [ordered]@{
|
||||||
|
source = \$Source
|
||||||
|
type = "host"
|
||||||
|
os = "windows"
|
||||||
|
status = \$status
|
||||||
|
message = \$message
|
||||||
|
interval = \$Interval
|
||||||
|
metrics = \$metrics
|
||||||
|
} | ConvertTo-Json -Depth 4 -Compress
|
||||||
|
|
||||||
|
# Ohne diese Zeile scheitert der Aufruf auf Maschinen, die noch
|
||||||
|
# mit TLS 1.0 als Vorgabe laufen - der Fehler sieht dort aus wie
|
||||||
|
# ein Netzwerkproblem.
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
|
|
||||||
|
Invoke-RestMethod -Uri \$WatchdogUrl -Method Post -Body \$body -ContentType "application/json" -Headers @{ "X-Agent-Token" = \$Token } | Out-Null
|
||||||
|
|
||||||
|
Write-Host "[OK] Heartbeat gesendet (\$status)."
|
||||||
|
} catch {
|
||||||
|
Write-Error "[FEHLER] Heartbeat nicht angenommen: \$_"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
POWERSHELL;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'filename' => $filename,
|
||||||
|
'script' => $script,
|
||||||
|
'os' => 'windows',
|
||||||
|
'interval' => $interval,
|
||||||
|
'schedule' => 'schtasks /Create /SC MINUTE /MO 1 /TN "Deploymentcenter Watchdog ' . $source . '" '
|
||||||
|
. '/TR "powershell -NoProfile -ExecutionPolicy Bypass -File {PFAD}" /F',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dateinamensteil aus dem Monitor-Namen: nur Unverfaengliches. */
|
||||||
|
private static function fileSlug(string $source): string
|
||||||
|
{
|
||||||
|
$slug = trim(strtolower(preg_replace('/[^A-Za-z0-9]+/', '-', $source) ?? ''), '-');
|
||||||
|
|
||||||
|
return $slug !== '' ? $slug : 'host';
|
||||||
|
}
|
||||||
|
}
|
||||||