Files
Deploymentcenter BotandClaude Opus 5 687ee0cefc 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>
2026-08-21 20:51:37 +02:00

412 lines
15 KiB
C#

using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Deploymentcenter.Client
{
/// <summary>Ergebnis einer Setup-Anmeldung.</summary>
public sealed class SetupSession
{
public string Token { get; set; } = string.Empty;
public string Role { get; set; } = string.Empty;
public DateTimeOffset ExpiresAt { get; set; }
public bool IsExpired => DateTimeOffset.UtcNow >= ExpiresAt;
/// <summary>Verbleibende Gueltigkeit, nie negativ.</summary>
public TimeSpan Remaining
{
get
{
var left = ExpiresAt - DateTimeOffset.UtcNow;
return left < TimeSpan.Zero ? TimeSpan.Zero : left;
}
}
}
/// <summary>Eine installierbare Anwendung aus dem Katalog.</summary>
public sealed class CatalogEntry
{
public string Slug { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Notes { get; set; }
public List<CatalogChannel> Channels { get; set; } = new List<CatalogChannel>();
}
/// <summary>Ein Kanal mit dem dort neuesten Release.</summary>
public sealed class CatalogChannel
{
public string Channel { get; set; } = "prod";
public string Version { get; set; } = string.Empty;
public string Platform { get; set; } = PlatformId.Any;
public long SizeBytes { get; set; }
public bool IsCritical { get; set; }
public bool Signed { get; set; }
public string? ReleaseNotes { get; set; }
public string DownloadUrl { get; set; } = string.Empty;
}
/// <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)
{
Code = code;
}
/// <summary>Stabiler Fehlercode der API, sofern vorhanden.</summary>
public string Code { get; }
}
/// <summary>
/// Spricht /api/setup/v1/* an: anmelden, Katalog holen, Anwendungstoken
/// ausstellen lassen.
///
/// Das Setup-Token lebt ausschliesslich im Speicher dieses Objekts. Es
/// wird nirgends abgelegt - es entsteht aus Zugangsdaten, die auf einem
/// fremden Rechner eingegeben wurden, und laeuft nach 30 Minuten ohnehin ab.
/// </summary>
public sealed class SetupClient
{
private readonly HttpClient _http;
private readonly string _baseUrl;
public SetupClient(string baseUrl, HttpClient? httpClient = null)
{
_baseUrl = (baseUrl ?? string.Empty).TrimEnd('/');
_http = httpClient ?? new HttpClient();
}
public SetupSession? Session { get; private set; }
/// <summary>Meldet sich an und merkt sich das Token fuer die Folgeaufrufe.</summary>
public async Task<SetupSession> LoginAsync(
string username,
string password,
string? hostname = null,
CancellationToken cancellationToken = default)
{
var payload = new Dictionary<string, object?>
{
["username"] = username,
["password"] = password,
["hostname"] = hostname ?? SafeHostName()
};
using var doc = await PostAsync("login", payload, useToken: false, cancellationToken)
.ConfigureAwait(false);
var root = doc.RootElement;
var session = new SetupSession
{
Token = root.TryGetProperty("setup_token", out var t) ? (t.GetString() ?? string.Empty) : string.Empty,
Role = root.TryGetProperty("role", out var r) ? (r.GetString() ?? string.Empty) : string.Empty,
};
int expiresIn = root.TryGetProperty("expires_in", out var e) && e.TryGetInt32(out int seconds)
? seconds
: 1800;
// Etwas Sicherheitsabstand, damit ein Aufruf nicht genau auf der
// Ablaufgrenze scheitert.
session.ExpiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(30, expiresIn - 15));
if (session.Token.Length == 0)
{
throw new SetupException("Die Anmeldung lieferte kein Token.", "no_token");
}
Session = session;
return session;
}
/// <summary>Holt die auf dieser Plattform installierbaren Anwendungen.</summary>
public async Task<List<CatalogEntry>> GetCatalogAsync(
string? platform = null,
CancellationToken cancellationToken = default)
{
RequireSession();
string rid = PlatformId.Normalize(platform ?? PlatformId.Current);
string url = $"{_baseUrl}/api/setup/v1/catalog?platform={Uri.EscapeDataString(rid)}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("Authorization", "Bearer " + Session!.Token);
using var response = await _http.SendAsync(request, cancellationToken).ConfigureAwait(false);
string body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
using var doc = ParseOrThrow(body, response.IsSuccessStatusCode, (int)response.StatusCode);
var result = new List<CatalogEntry>();
if (!doc.RootElement.TryGetProperty("projects", out var projects)
|| projects.ValueKind != JsonValueKind.Array)
{
return result;
}
foreach (var project in projects.EnumerateArray())
{
var entry = new CatalogEntry
{
Slug = GetString(project, "slug"),
Name = GetString(project, "name"),
Notes = project.TryGetProperty("notes", out var n) && n.ValueKind == JsonValueKind.String
? n.GetString()
: null
};
if (project.TryGetProperty("channels", out var channels)
&& channels.ValueKind == JsonValueKind.Array)
{
foreach (var channel in channels.EnumerateArray())
{
entry.Channels.Add(new CatalogChannel
{
Channel = GetString(channel, "channel"),
Version = GetString(channel, "version"),
Platform = GetString(channel, "platform"),
DownloadUrl = GetString(channel, "download_url"),
ReleaseNotes = channel.TryGetProperty("release_notes", out var rn) && rn.ValueKind == JsonValueKind.String
? rn.GetString()
: null,
SizeBytes = channel.TryGetProperty("size_bytes", out var sb) && sb.TryGetInt64(out long size) ? size : 0,
IsCritical = channel.TryGetProperty("is_critical", out var ic) && ic.ValueKind == JsonValueKind.True,
Signed = channel.TryGetProperty("signed", out var sg) && sg.ValueKind == JsonValueKind.True
});
}
}
result.Add(entry);
}
return result;
}
/// <summary>
/// Laesst ein Dauertoken fuer die eingerichtete Anwendung ausstellen.
/// Welche Rechte tatsaechlich vergeben werden, entscheidet der Server -
/// die Anfrage kann nur einschraenken.
/// </summary>
public async Task<string> RequestApplicationTokenAsync(
string project,
IEnumerable<string> scopes,
string? hostname = null,
CancellationToken cancellationToken = default)
{
RequireSession();
var payload = new Dictionary<string, object?>
{
["project"] = project,
["scopes"] = new List<string>(scopes),
["hostname"] = hostname ?? SafeHostName()
};
using var doc = await PostAsync("token", payload, useToken: true, cancellationToken)
.ConfigureAwait(false);
string token = GetString(doc.RootElement, "token");
if (token.Length == 0)
{
throw new SetupException("Der Server hat kein Anwendungstoken geliefert.", "no_token");
}
return token;
}
/// <summary>
/// Laesst einen Watchdog-Agenten fuer diese Maschine ausstellen:
/// Monitor anlegen, Token erzeugen, passendes Skript bauen.
///
/// Der Name entscheidet, unter welcher Bezeichnung der Host im
/// Dashboard steht. Gibt es ihn schon, lehnt der Server mit
/// "monitor_exists" ab - eine zweite Maschine soll die Historie der
/// ersten nicht stillschweigend uebernehmen. Erst <paramref
/// name="overwrite"/> laesst das zu.
/// </summary>
public async Task<WatchdogAgent> RequestWatchdogAgentAsync(
string project,
string source,
string os,
int intervalSeconds = 60,
bool overwrite = false,
CancellationToken cancellationToken = default)
{
RequireSession();
var payload = new Dictionary<string, object?>
{
["project"] = project,
["source"] = source,
["os"] = os,
["interval"] = intervalSeconds,
["overwrite"] = overwrite
};
using var doc = await PostAsync("agent", payload, useToken: true, cancellationToken)
.ConfigureAwait(false);
var root = doc.RootElement;
string script = GetString(root, "script");
if (script.Length == 0)
{
throw new SetupException("Der Server hat kein Agent-Skript geliefert.", "no_script");
}
return new WatchdogAgent
{
Source = GetString(root, "source"),
Os = GetString(root, "os"),
FileName = GetString(root, "filename"),
Script = script,
Schedule = GetString(root, "schedule"),
IntervalSeconds = root.TryGetProperty("interval", out var interval)
&& interval.TryGetInt32(out int parsed)
? parsed
: intervalSeconds,
Existed = root.TryGetProperty("existing", out var existed)
&& existed.ValueKind == JsonValueKind.True
};
}
// ------------------------------------------------------------------
private void RequireSession()
{
if (Session == null)
{
throw new SetupException("Nicht angemeldet.", "not_authenticated");
}
if (Session.IsExpired)
{
throw new SetupException(
"Die Setup-Sitzung ist abgelaufen. Bitte erneut anmelden.", "session_expired");
}
}
private async Task<JsonDocument> PostAsync(
string action,
Dictionary<string, object?> payload,
bool useToken,
CancellationToken cancellationToken)
{
if (useToken)
{
RequireSession();
}
using var request = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl}/api/setup/v1/{action}")
{
Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")
};
if (useToken)
{
request.Headers.Add("Authorization", "Bearer " + Session!.Token);
}
using var response = await _http.SendAsync(request, cancellationToken).ConfigureAwait(false);
string body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
return ParseOrThrow(body, response.IsSuccessStatusCode, (int)response.StatusCode);
}
/// <summary>
/// Wertet die einheitliche Antwortform aus. Bei einem Fehler wird die
/// Meldung des Servers weitergereicht - sie ist fuer Menschen gedacht
/// und deutlich hilfreicher als ein blosser Statuscode.
/// </summary>
private static JsonDocument ParseOrThrow(string body, bool success, int statusCode)
{
JsonDocument doc;
try
{
doc = JsonDocument.Parse(body);
}
catch (JsonException)
{
throw new SetupException(
$"Unerwartete Antwort des Servers (HTTP {statusCode}).", "invalid_response");
}
if (success)
{
return doc;
}
string message = $"HTTP {statusCode}";
string code = string.Empty;
if (doc.RootElement.TryGetProperty("error", out var error)
&& error.ValueKind == JsonValueKind.Object)
{
code = GetString(error, "code");
string serverMessage = GetString(error, "message");
if (serverMessage.Length > 0)
{
message = serverMessage;
}
}
doc.Dispose();
throw new SetupException(message, code);
}
private static string GetString(JsonElement element, string property)
{
return element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String
? (value.GetString() ?? string.Empty)
: string.Empty;
}
private static string SafeHostName()
{
try
{
return Environment.MachineName;
}
catch
{
return "unbekannt";
}
}
}
}