feat(installer): Laufzeitpruefung, Host-Ueberwachung, Zugang fuer Adminkonten

Drei Dinge, die beim ersten Lauf des Installers auf einer Linux-Maschine
auffielen.

1. Die Release-Ablage wies Administratorkonten ab. ReleaseGuard nahm nur
   die Rolle 'installer' in die .htpasswd auf, waehrend Installskripte und
   Agent ausdruecklich sagten, ein Administratorkonto tue es auch:
   Anmeldung und Katalog gelangen, erst der Download endete mit 401 - und
   die Meldung sprach von abgelaufenen Lizenzen, die es bei einer
   Erstinstallation gar nicht geben kann. Adminkonten zaehlen jetzt zu den
   Installationskonten. FORMAT_VERSION auf 3, damit reconcile() die
   Dateien sofort neu schreibt statt erst beim naechsten turnusmaessigen
   Lauf; ein neu angelegtes Konto landet ausserdem unabhaengig von seiner
   Rolle sofort darin. Bei einem 401 mit Benutzerzugangsdaten nennt der
   Client jetzt Konto und zugangsberechtigte Rollen, und der Agent bricht
   ab, statt ueber die API weiterzusuchen und dieselbe Meldung ein paar
   Schritte spaeter ein zweites Mal zu zeigen.

2. Der Installer prueft die .NET-Laufzeit. 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. Gelesen
   wird die runtimeconfig.json der Anwendung und mit "dotnet
   --list-runtimes" verglichen; fehlt etwas, nennt der Installer den
   Installationsbefehl fuer diese Plattform. Eigenstaendig
   veroeffentlichte Pakete werden nicht bemaengelt, rollForward wird
   beachtet.

3. Die Ueberwachung der Maschine entsteht im Installer. Zwei Fragen -
   Name im Dashboard und ob eingeplant werden soll - statt fuenf Schritten
   in der Oberflaeche an einem anderen Rechner. Monitor, Token mit genau
   watchdog:ping, Skript, Dateirechte, ein Heartbeat zur Probe und der
   Cron-Eintrag bzw. die geplante Aufgabe entstehen daraus. Fuer Maschinen
   ohne Installation: --action monitor.

Die Agent-Skripte werden jetzt in src/Modules/Watchdog/AgentScript.php
erzeugt - von Oberflaeche und Installer gemeinsam - und melden Last,
Speicher, Plattenbelegung und Laufzeit mit, statt nur "status: ok". Beim
Ausfuehren fielen zwei Fehler auf, die dort behoben sind: df -P verrutscht
bei Geraetenamen mit Leerzeichen (gezaehlt wird jetzt von hinten), und
ohne LC_ALL=C erzeugt awk auf einem deutschen System "12,5" und damit
kaputtes JSON.

Neu: POST /api/setup/v1/agent. SDK 2.6.0 mit
SetupClient.RequestWatchdogAgentAsync().

Die OpenAPI-Beschreibung des neuen Endpunkts bleibt zunaechst aussen vor:
public/api/openapi.php traegt gerade auch fremde, noch nicht committete
Aenderungen aus einer parallel laufenden Arbeit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Deploymentcenter Bot
2026-08-21 20:51:37 +02:00
co-authored by Claude Opus 5
parent fc9b698141
commit 687ee0cefc
15 changed files with 1836 additions and 84 deletions
@@ -23,7 +23,7 @@
-->
<IsPackable>true</IsPackable>
<PackageId>Deploymentcenter.Client</PackageId>
<Version>2.5.2</Version>
<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>
@@ -18,16 +18,25 @@ namespace Deploymentcenter.Client
/// </summary>
public sealed class ReleaseCredentials
{
private ReleaseCredentials(string user, string password)
private ReleaseCredentials(string user, string password, bool fromLicense)
{
User = user;
Password = password;
FromLicense = fromLicense;
}
public string User { 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>
/// Zugang ueber den Lizenzschluessel.
///
@@ -47,7 +56,7 @@ namespace Deploymentcenter.Client
if (key.Length == 0)
return null;
return new ReleaseCredentials(UsernameForLicenseKey(key), key);
return new ReleaseCredentials(UsernameForLicenseKey(key), key, fromLicense: true);
}
/// <summary>
@@ -74,7 +83,7 @@ namespace Deploymentcenter.Client
string u = (user ?? string.Empty).Trim();
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>
@@ -113,6 +122,17 @@ namespace Deploymentcenter.Client
+ "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 "
+ "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>
/// 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 SetupException(string message, string code = "") : base(message)
@@ -222,6 +247,63 @@ namespace Deploymentcenter.Client
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()