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()
@@ -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>
@@ -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;
}
}
}
@@ -132,6 +132,13 @@ namespace Deploymentcenter.UpdateAgent
case "configure":
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:
return ShowHelp();
}
@@ -166,14 +173,23 @@ namespace Deploymentcenter.UpdateAgent
};
AnsiConsole.Write(panel);
ReleaseManifest? releaseManifest = null;
ManifestLookup lookup = new ManifestLookup();
await AnsiConsole.Status()
.Spinner(Spinner.Known.Dots)
.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)
{
AnsiConsole.MarkupLine("[bold red]Fehler: Es konnte kein Release ermittelt werden.[/]");
@@ -282,7 +298,18 @@ namespace Deploymentcenter.UpdateAgent
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)
{
Console.WriteLine("ERROR: Kein Release-Manifest verfuegbar.");
@@ -311,7 +338,14 @@ namespace Deploymentcenter.UpdateAgent
try
{
var releaseManifest = await FetchManifestAsync(options);
var lookup = await FetchManifestAsync(options);
if (lookup.Unauthorized)
{
return 1;
}
var releaseManifest = lookup.Manifest;
if (releaseManifest == null)
{
AnsiConsole.MarkupLine("[bold red]Fehler: Release-Manifest konnte weder statisch noch ueber die API abgerufen werden.[/]");
@@ -561,18 +595,48 @@ namespace Deploymentcenter.UpdateAgent
/// klappte, sagte die Anwendung "Update verfuegbar" und der Agent "kein
/// Release gefunden". Der Rueckfall auf die API stand nur im SDK.
/// </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);
if (fromStatic?.Latest != null)
{
return fromStatic;
}
public ReleaseManifest? Manifest { get; init; }
return await FetchApiManifestAsync(options);
/// <summary>Die Ablage hat die Zugangsdaten abgelehnt.</summary>
public bool Unauthorized { get; init; }
}
static async Task<ReleaseManifest?> FetchStaticManifestAsync(AgentOptions options)
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)
{
return new ManifestLookup { Manifest = fromStatic };
}
return new ManifestLookup { Manifest = await FetchApiManifestAsync(options) };
}
static async Task<(ReleaseManifest? Manifest, bool Unauthorized)> FetchStaticManifestAsync(AgentOptions options)
{
// Plattformunabhaengige Releases liegen weiterhin im alten Pfad
// ohne Zwischenebene.
@@ -597,15 +661,12 @@ namespace Deploymentcenter.UpdateAgent
var resp = await HttpClient.SendAsync(request);
// Ein 401 ist keine "Datei fehlt"-Lage: die Lizenz traegt
// nicht mehr. Weiterzuprobieren wuerde die Ursache nur
// hinter einer allgemeinen Fehlermeldung verstecken.
// Ein 401 wird nach oben durchgereicht, statt hier
// gemeldet zu werden: sonst steht die Meldung schon im
// Bild, waehrend der Aufrufer noch weitersucht.
if (resp.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
AnsiConsole.MarkupLine("[bold red]"
+ Markup.Escape(ReleaseCredentials.DescribeUnauthorized(options.Credentials))
+ "[/]");
return null;
return (null, true);
}
if (!resp.IsSuccessStatusCode)
@@ -633,7 +694,7 @@ namespace Deploymentcenter.UpdateAgent
.FirstOrDefault();
if (manifest.Latest != null)
return manifest;
return (manifest, false);
}
catch
{
@@ -641,7 +702,7 @@ namespace Deploymentcenter.UpdateAgent
}
}
return null;
return (null, false);
}
/// <summary>
@@ -1140,8 +1201,8 @@ namespace Deploymentcenter.UpdateAgent
Console.WriteLine();
Console.WriteLine(" --project, -p <slug> Projekt-Slug");
Console.WriteLine(" --channel, -c <kanal> prod | beta | dev");
Console.WriteLine(" --action, -a <aktion> interactive | install | configure | check |");
Console.WriteLine(" update | repair | list");
Console.WriteLine(" --action, -a <aktion> interactive | install | configure | monitor |");
Console.WriteLine(" check | update | repair | list");
Console.WriteLine(" --version, -v <version> Zielversion oder 'latest'");
Console.WriteLine(" --target-dir, -t <pfad> Zu aktualisierendes Verzeichnis");
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;
}
}
}
+1 -1
View File
@@ -33,7 +33,7 @@ kompromittiert zu behandeln:
- [ ] `.htpasswd`-Passwort für `deploy` neu setzen
- [ ] Git-Token `eb429575…` widerrufen und neu ausstellen
- [ ] 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)
Wenn die Historie bereinigt werden soll, geht das mit
+124 -2
View File
@@ -6,6 +6,7 @@
* POST /api/setup/v1/login Benutzername + Passwort -> kurzlebiges Token
* GET /api/setup/v1/catalog Was ist hier installierbar?
* 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
* 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\Setup\SetupCatalog;
use Deploymentcenter\Modules\UpdateService\UpdateManager;
use Deploymentcenter\Modules\Watchdog\AgentScript;
use Deploymentcenter\Modules\Watchdog\MonitorRepo;
Http::beginJson(['GET', 'POST', 'OPTIONS'], true);
@@ -222,9 +225,128 @@ switch (resolveSetupAction()) {
'message' => 'Token ausgestellt. Es wird nur einmal ausgeliefert.',
], 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:
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));
return match ($last) {
'login', 'catalog', 'token' => $last,
'login', 'catalog', 'token', 'agent' => $last,
default => 'unknown',
};
}
+59 -1
View File
@@ -2,9 +2,67 @@
"_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.5.2",
"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",
+29 -42
View File
@@ -35,6 +35,7 @@ use Deploymentcenter\Modules\License\KeyGen;
use Deploymentcenter\Modules\UpdateService\ReleaseGuard;
use Deploymentcenter\Modules\UpdateService\ReleaseSigner;
use Deploymentcenter\Modules\UpdateService\UpdateManager;
use Deploymentcenter\Modules\Watchdog\AgentScript;
use Deploymentcenter\Modules\Watchdog\Evaluator;
use Deploymentcenter\Modules\Watchdog\EventLog;
use Deploymentcenter\Modules\Watchdog\MonitorRepo;
@@ -365,46 +366,18 @@ if (($_GET['action'] ?? '') === 'download_agent') {
$os = trim((string)($_GET['os'] ?? 'windows'));
$token = trim((string)($_GET['token'] ?? ''));
// Nur unkritische Zeichen, damit nichts in das erzeugte Skript
// hineingeschmuggelt werden kann.
$source = preg_replace('/[^A-Za-z0-9._ -]/', '', $source) ?? 'server-node';
$token = preg_replace('/[^A-Za-z0-9._-]/', '', $token) ?? '';
$endpoint = Http::baseUrl() . '/api/watchdog/v1/ping';
if ($os === 'windows') {
$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';
}
// Erzeugt wird in AgentScript - dieselbe Fassung, die der Installer ueber
// den Setup-Weg bekommt. Das Saeubern der Eingaben passiert dort mit.
$agent = AgentScript::build(
$os,
$source,
$token,
Http::baseUrl() . '/api/watchdog/v1/ping'
);
header('Content-Type: text/plain; charset=utf-8');
header('Content-Disposition: attachment; filename="' . preg_replace('/[^A-Za-z0-9._-]/', '', $filename) . '"');
echo $script;
header('Content-Disposition: attachment; filename="' . $agent['filename'] . '"');
echo $agent['script'];
exit;
}
@@ -1379,10 +1352,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Installationskonten stehen in jeder .htpasswd - ohne sie
// waere eine Erstinstallation nicht moeglich, weil es dabei
// noch keinen Lizenzschluessel gibt.
if ($newRole === 'installer') {
dc_guard_releases($pdo);
}
// noch keinen Lizenzschluessel gibt. Administratoren zaehlen
// 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_flash(sprintf(
'Konto "%s" mit der Rolle %s angelegt.%s',
@@ -3407,6 +3382,12 @@ chmod +x update-agent
man Nutzer sonst warnt.
</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;">
Für die Installation wird ein Konto der Rolle <strong>installer</strong>
gebraucht anzulegen unter <em>System 👤 Benutzer</em>. Ein
@@ -3713,6 +3694,10 @@ chmod +x update-agent
'resolved' => 'Gelöst',
'closed' => 'Geschlossen',
'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): ?>
<option value="<?= e($value) ?>" <?= $btFilters['status'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
@@ -3783,6 +3768,7 @@ chmod +x update-agent
'in_progress' => '<span class="badge badge-warning">IN BEARBEITUNG</span>',
'resolved' => '<span class="badge badge-up">GELÖST</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>',
};
@@ -5841,6 +5827,7 @@ SYSTEM
<option value="resolved">Gelöst / Umgesetzt</option>
<option value="closed">Geschlossen</option>
<option value="rejected">Abgelehnt</option>
<option value="triage_external">📥 Fremdmeldung (ungesichtet)</option>
</select>
</div>
<div class="form-group">
+3
View File
@@ -63,6 +63,9 @@ try {
Write-Host 'Weiter mit:'
Write-Host " & '$target' --action install"
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 'gebraucht. Ein Administratorkonto tut es auch, gehoert aber nicht auf ein'
Write-Host 'Zielsystem.'
+3
View File
@@ -104,6 +104,9 @@ echo ""
echo "Weiter mit:"
echo " $target --action install"
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 "gebraucht. Ein Administratorkonto tut es auch, gehoert aber nicht auf ein"
echo "Zielsystem."
+19 -9
View File
@@ -26,9 +26,9 @@ use PDO;
* ueber product_id an genau ein Projekt gebunden ist, faellt die Trennung beim
* Erzeugen von selbst ab.
*
* Zusaetzlich stehen die Installationskonten in jeder Datei: Bei einer
* Erstinstallation gibt es noch keinen Lizenzschluessel, mit dem sich das
* Paket holen liesse.
* Zusaetzlich stehen die Installationskonten in jeder Datei - die der Rolle
* 'installer' wie die der Rolle 'admin': Bei einer Erstinstallation gibt es
* noch keinen Lizenzschluessel, mit dem sich das Paket holen liesse.
*/
final class ReleaseGuard
{
@@ -234,6 +234,14 @@ final class ReleaseGuard
// ist die Anwendung noch nicht eingerichtet und hat keinen
// 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
// password_hash() erzeugt bcrypt im Format $2y$, genau das versteht
// Apache. Ein Klartextpasswort wird dafuer nirgends gebraucht.
@@ -241,7 +249,7 @@ final class ReleaseGuard
$stmt = $db->query("
SELECT username, password_hash
FROM dc_users
WHERE role = 'installer' AND disabled = 0
WHERE role IN ('installer', 'admin') AND disabled = 0
");
foreach ($stmt->fetchAll() ?: [] as $row) {
@@ -408,19 +416,21 @@ final class ReleaseGuard
}
/**
* Format der erzeugten .htpasswd. Bei einer Aenderung muessen alle Dateien
* neu geschrieben werden, sonst passen Server und Client nicht mehr
* zusammen.
* 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.
* 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 = 2;
private const FORMAT_VERSION = 3;
/**
* Prueft, ob der Schutz tatsaechlich greift - durch einen echten Abruf.
+356
View File
@@ -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';
}
}