diff --git a/client-dotnet/Deploymentcenter.Client/Deploymentcenter.Client.csproj b/client-dotnet/Deploymentcenter.Client/Deploymentcenter.Client.csproj index 8f01d5d..ae99ebe 100644 --- a/client-dotnet/Deploymentcenter.Client/Deploymentcenter.Client.csproj +++ b/client-dotnet/Deploymentcenter.Client/Deploymentcenter.Client.csproj @@ -23,7 +23,7 @@ --> true Deploymentcenter.Client - 2.5.2 + 2.6.0 Deploymentcenter Client-SDK für Lizenzprüfung, Update-Verteilung und Erstinstallation über das Deploymentcenter. deploymentcenter;update;lizenz diff --git a/client-dotnet/Deploymentcenter.Client/ReleaseCredentials.cs b/client-dotnet/Deploymentcenter.Client/ReleaseCredentials.cs index 72bb42e..e846541 100644 --- a/client-dotnet/Deploymentcenter.Client/ReleaseCredentials.cs +++ b/client-dotnet/Deploymentcenter.Client/ReleaseCredentials.cs @@ -18,16 +18,25 @@ namespace Deploymentcenter.Client /// 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; } + /// + /// 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. + /// + public bool FromLicense { get; } + /// /// 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); } /// @@ -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); } /// Wert fuer den Authorization-Header. @@ -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."; } diff --git a/client-dotnet/Deploymentcenter.Client/SetupClient.cs b/client-dotnet/Deploymentcenter.Client/SetupClient.cs index ff8ba3f..82da857 100644 --- a/client-dotnet/Deploymentcenter.Client/SetupClient.cs +++ b/client-dotnet/Deploymentcenter.Client/SetupClient.cs @@ -51,6 +51,31 @@ namespace Deploymentcenter.Client } /// Fehler beim Ansprechen der Setup-Schnittstelle. + /// + /// 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. + /// + 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; + + /// + /// Einplanungsbefehl mit dem Platzhalter {PFAD} - der Ablageort steht + /// erst fest, wenn die Datei geschrieben ist. + /// + public string Schedule { get; set; } = string.Empty; + + public int IntervalSeconds { get; set; } = 60; + + /// Gab es diesen Monitor schon? Dann wurde er uebernommen. + 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; } + /// + /// 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 laesst das zu. + /// + public async Task RequestWatchdogAgentAsync( + string project, + string source, + string os, + int intervalSeconds = 60, + bool overwrite = false, + CancellationToken cancellationToken = default) + { + RequireSession(); + + var payload = new Dictionary + { + ["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() diff --git a/client-dotnet/Deploymentcenter.UpdateAgent/Installation.cs b/client-dotnet/Deploymentcenter.UpdateAgent/Installation.cs index fb2427c..9d5647f 100644 --- a/client-dotnet/Deploymentcenter.UpdateAgent/Installation.cs +++ b/client-dotnet/Deploymentcenter.UpdateAgent/Installation.cs @@ -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; } /// diff --git a/client-dotnet/Deploymentcenter.UpdateAgent/Monitoring.cs b/client-dotnet/Deploymentcenter.UpdateAgent/Monitoring.cs new file mode 100644 index 0000000..b8df118 --- /dev/null +++ b/client-dotnet/Deploymentcenter.UpdateAgent/Monitoring.cs @@ -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 +{ + /// + /// 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. + /// + internal static class Monitoring + { + /// + /// 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. + /// + public static async Task 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("[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; + } + + /// + /// Eigenstaendiger Weg fuer eine Maschine, auf der nichts installiert + /// werden soll - etwa den Hypervisor unter den Anwendungen. + /// + public static async Task RunStandaloneAsync(AgentOptions options, System.Net.Http.HttpClient http) + { + AnsiConsole.Write(new FigletText("Monitor").LeftJustified().Color(Color.DodgerBlue1)); + + string baseUrl = AnsiConsole.Prompt( + new TextPrompt("[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("[bold]Benutzer[/]:")); + string password = AnsiConsole.Prompt(new TextPrompt("[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 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 [/] eines angeben.[/]"); + return 1; + } + + var chosen = AnsiConsole.Prompt( + new SelectionPrompt() + .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; + } + + // ------------------------------------------------------------------ + + /// + /// Vorschlag fuer den Namen: der Rechnername, wie ihn auch jeder + /// andere sieht, der auf die Maschine schaut. + /// + private static string SuggestName() + { + try + { + string name = Environment.MachineName.Trim(); + return name.Length > 0 ? name : "host"; + } + catch + { + return "host"; + } + } + + /// + /// Legt das Skript ab - moeglichst systemweit, sonst beim Benutzer. + /// Rueckgabe null heisst: nirgends schreibbar. + /// + 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; + } + + /// + /// Bevorzugt ein systemweiter Ort - der Cron des Systems kommt an das + /// Heimatverzeichnis eines Benutzers nicht zwangslaeufig heran. + /// + private static IEnumerable 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"); + } + } + + /// + /// Im Skript steht ein Token. Unter Unix bleibt die Datei deshalb dem + /// Eigentuemer vorbehalten - und ausfuehrbar, sonst startet der Cron + /// sie nicht. + /// + 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.[/]"); + } + } + + /// Fuehrt das Skript einmal aus und zeigt, was es sagt. + 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)}[/]"); + } + } + + /// + /// Traegt das Skript in Cron bzw. die Aufgabenplanung ein - auf + /// Nachfrage. Klappt das nicht, bleibt der Befehl sichtbar stehen. + /// + 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)}[/]"); + } + + /// + /// Der Einplanungsbefehl mit eingesetztem Pfad. Die Vorlage kommt vom + /// Server, damit Oberflaeche und Installer dasselbe zeigen. + /// + 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; + } + } + + /// + /// 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. + /// + 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; + } + + /// + /// Legt eine geplante Aufgabe an. /F ueberschreibt eine gleichnamige - + /// eine zweite Installation soll keine zweite Aufgabe hinterlassen. + /// + 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; + } + } +} diff --git a/client-dotnet/Deploymentcenter.UpdateAgent/Program.cs b/client-dotnet/Deploymentcenter.UpdateAgent/Program.cs index 4d77758..760a40e 100644 --- a/client-dotnet/Deploymentcenter.UpdateAgent/Program.cs +++ b/client-dotnet/Deploymentcenter.UpdateAgent/Program.cs @@ -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 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. /// - static async Task FetchManifestAsync(AgentOptions options) + /// + /// 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. + /// + internal sealed class ManifestLookup { - var fromStatic = await FetchStaticManifestAsync(options); - if (fromStatic?.Latest != null) - { - return fromStatic; - } + public ReleaseManifest? Manifest { get; init; } - return await FetchApiManifestAsync(options); + /// Die Ablage hat die Zugangsdaten abgelehnt. + public bool Unauthorized { get; init; } } - static async Task FetchStaticManifestAsync(AgentOptions options) + static async Task 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); } /// @@ -1140,8 +1201,8 @@ namespace Deploymentcenter.UpdateAgent Console.WriteLine(); Console.WriteLine(" --project, -p Projekt-Slug"); Console.WriteLine(" --channel, -c prod | beta | dev"); - Console.WriteLine(" --action, -a interactive | install | configure | check |"); - Console.WriteLine(" update | repair | list"); + Console.WriteLine(" --action, -a interactive | install | configure | monitor |"); + Console.WriteLine(" check | update | repair | list"); Console.WriteLine(" --version, -v Zielversion oder 'latest'"); Console.WriteLine(" --target-dir, -t Zu aktualisierendes Verzeichnis"); Console.WriteLine(" --platform Laufzeitkennung (Vorgabe: die des Systems)"); diff --git a/client-dotnet/Deploymentcenter.UpdateAgent/RuntimeCheck.cs b/client-dotnet/Deploymentcenter.UpdateAgent/RuntimeCheck.cs new file mode 100644 index 0000000..3d056a7 --- /dev/null +++ b/client-dotnet/Deploymentcenter.UpdateAgent/RuntimeCheck.cs @@ -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 +{ + /// + /// 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. + /// + internal static class RuntimeCheck + { + /// Ein Framework, das die Anwendung erwartet. + internal sealed class Requirement + { + public string Name { get; set; } = string.Empty; + public string Version { get; set; } = string.Empty; + + /// + /// Wie weit die Laufzeit nach oben abweichen darf. Vorgabe von + /// .NET ist "Minor": eine hoehere Nebenversion derselben + /// Hauptversion wird genommen, eine hoehere Hauptversion nicht. + /// + public string RollForward { get; set; } = "Minor"; + } + + /// Was auf dieser Maschine installiert ist. + internal sealed class InstalledRuntime + { + public string Name { get; set; } = string.Empty; + public Version Version { get; set; } = new Version(0, 0); + } + + internal sealed class Result + { + /// Liess sich ueberhaupt etwas feststellen? + public bool Inspected { get; set; } + + /// Das Paket bringt seine Laufzeit selbst mit. + public bool SelfContained { get; set; } + + /// Ist ueberhaupt ein "dotnet" auffindbar? + public bool DotnetFound { get; set; } + + public List Required { get; } = new List(); + public List Installed { get; } = new List(); + + /// Anforderungen, die nichts Installiertes erfuellt. + public List Missing { get; } = new List(); + + /// Nichts zu beanstanden - oder nichts zu pruefen. + public bool Ok => SelfContained || !Inspected || Missing.Count == 0; + } + + /// + /// Untersucht das Zielverzeichnis und die Maschine. + /// + 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; + } + + /// + /// Gibt das Ergebnis aus. Rueckgabe false heisst: es fehlt etwas - + /// die Anwendung wird so nicht starten. + /// + 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; + } + + // ------------------------------------------------------------------ + + /// + /// Liest die Anforderungen aus der runtimeconfig.json im + /// Zielverzeichnis. + /// + private static List ReadRequirements(string targetDir, Result result) + { + var requirements = new List(); + + 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 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 }); + } + + /// + /// Fragt "dotnet --list-runtimes" ab. Fehlt der Befehl, ist die + /// Antwort leer - genau das ist die Auskunft, um die es geht. + /// + private static List ListInstalledRuntimes() + { + var found = new List(); + + 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; + } + + /// + /// Wird die Anforderung von irgendetwas Installiertem erfuellt? + /// + private static bool IsSatisfied(Requirement requirement, List installed) + { + return BestMatch(requirement, installed) != null; + } + + private static InstalledRuntime? BestMatch(Requirement requirement, List 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!); + } + + /// + /// Was der Mensch davor jetzt tun muss - als Befehl, nicht als + /// Verweis auf eine Downloadseite. + /// + private static List InstallHints(List missing) + { + var hints = new List(); + 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; + } + } +} diff --git a/docs/UPGRADE.md b/docs/UPGRADE.md index b2a6d51..9f75152 100644 --- a/docs/UPGRADE.md +++ b/docs/UPGRADE.md @@ -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 diff --git a/public/api/setup/v1/index.php b/public/api/setup/v1/index.php index 5ca0830..471b021 100644 --- a/public/api/setup/v1/index.php +++ b/public/api/setup/v1/index.php @@ -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', }; } diff --git a/public/docs/changelog.json b/public/docs/changelog.json index 54117e2..375937d 100644 --- a/public/docs/changelog.json +++ b/public/docs/changelog.json @@ -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", diff --git a/public/index.php b/public/index.php index 0efcf7c..a896714 100644 --- a/public/index.php +++ b/public/index.php @@ -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.

+

+ Nur die Maschine überwachen, ohne etwas zu installieren — + legt Monitor und Token an, schreibt das Agent-Skript und plant es ein: +

+
./update-agent --action monitor
+

Für die Installation wird ein Konto der Rolle installer gebraucht — anzulegen unter System → 👤 Benutzer. 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): ?> @@ -3783,6 +3768,7 @@ chmod +x update-agent 'in_progress' => 'IN BEARBEITUNG', 'resolved' => 'GELÖST', 'rejected' => 'ABGELEHNT', + 'triage_external' => '📥 FREMDMELDUNG', default => '' . e(strtoupper((string)$item['status'])) . '', }; @@ -5841,6 +5827,7 @@ SYSTEM +

diff --git a/scripts/installer/install.ps1 b/scripts/installer/install.ps1 index 1064872..70005fe 100644 --- a/scripts/installer/install.ps1 +++ b/scripts/installer/install.ps1 @@ -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.' diff --git a/scripts/installer/install.sh b/scripts/installer/install.sh index 7b029a6..2b02f17 100644 --- a/scripts/installer/install.sh +++ b/scripts/installer/install.sh @@ -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." diff --git a/src/Modules/UpdateService/ReleaseGuard.php b/src/Modules/UpdateService/ReleaseGuard.php index 7575fe1..53872ee 100644 --- a/src/Modules/UpdateService/ReleaseGuard.php +++ b/src/Modules/UpdateService/ReleaseGuard.php @@ -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_ + * 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. diff --git a/src/Modules/Watchdog/AgentScript.php b/src/Modules/Watchdog/AgentScript.php new file mode 100644 index 0000000..624c787 --- /dev/null +++ b/src/Modules/Watchdog/AgentScript.php @@ -0,0 +1,356 @@ +/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 < /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 = <<\\{$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'; + } +}