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 { /// Ergebnis einer Setup-Anmeldung. 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; /// Verbleibende Gueltigkeit, nie negativ. public TimeSpan Remaining { get { var left = ExpiresAt - DateTimeOffset.UtcNow; return left < TimeSpan.Zero ? TimeSpan.Zero : left; } } } /// Eine installierbare Anwendung aus dem Katalog. 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 Channels { get; set; } = new List(); } /// Ein Kanal mit dem dort neuesten Release. 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; } /// 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) { Code = code; } /// Stabiler Fehlercode der API, sofern vorhanden. public string Code { get; } } /// /// 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. /// 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; } /// Meldet sich an und merkt sich das Token fuer die Folgeaufrufe. public async Task LoginAsync( string username, string password, string? hostname = null, CancellationToken cancellationToken = default) { var payload = new Dictionary { ["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; } /// Holt die auf dieser Plattform installierbaren Anwendungen. public async Task> 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(); 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; } /// /// Laesst ein Dauertoken fuer die eingerichtete Anwendung ausstellen. /// Welche Rechte tatsaechlich vergeben werden, entscheidet der Server - /// die Anfrage kann nur einschraenken. /// public async Task RequestApplicationTokenAsync( string project, IEnumerable scopes, string? hostname = null, CancellationToken cancellationToken = default) { RequireSession(); var payload = new Dictionary { ["project"] = project, ["scopes"] = new List(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; } /// /// 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() { 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 PostAsync( string action, Dictionary 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); } /// /// 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. /// 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"; } } } }