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