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
@@ -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;
}
}
}