using System.Text.Json; using System.Text.Json.Serialization; using ClawdDotNet.Core.Security; using ClawdDotNet.Core.Storage; namespace ClawdDotNet.Manager; /// /// Alles, was der Instanzmanager weiß — und mehr soll er nicht wissen (E-M4). /// /// Die Kürze dieser Klasse ist die Zusage. 99 Prozent der Einstellungen /// gehören der Instanz und gelten für jede einzeln; hier steht nur, was sich gar nicht je /// Instanz sagen lässt: eine Weboberfläche für alle, eine Lizenz für die Installation, ein /// Master-Token, aus dem die Sub-Token entstehen, und der Ort, an dem die Instanzen /// liegen — den muss er wissen, bevor es überhaupt eine gibt. /// /// Farbschema, Teilerstellungen, Protokollstufe, Sicherungszeiten: alles Werte einer /// Oberfläche oder einer Instanz. Wer hier ein Feld ergänzt, sollte erst beantworten, /// warum es nicht je Instanz gelten kann. /// /// Nicht dabei: eine Datenbank. Was der Manager braucht, liegt als JSON im /// Projektverzeichnis — diese Datei und je Instanz eine kleine Zustandsdatei. Die /// state.db einer Instanz fasst er nicht an: Sie gehört genau einem Prozess, und /// prozessübergreifend gilt die Schreibsperre von SqliteStorage nicht. /// public sealed class ManagerSettings { // ─── Wo die Instanzen liegen ─── [JsonPropertyName("instancesDirectory")] public string InstancesDirectory { get; set; } = Path.Combine(AppPaths.DataDirectory, "Instances"); /// /// Die Programmdatei, mit der ein Instanzprozess gestartet wird. /// /// Leer heißt: neben der des Managers. Das ist der Normalfall und der Grund, /// warum hier fast immer nichts steht — beide kommen aus demselben Paket. /// [JsonPropertyName("instanceExecutable")] public string InstanceExecutable { get; set; } = ""; /// /// Ob gestartete Instanzen ein eigenes Fenster bekommen (E-M2). /// /// Vorbelegt auf true: Jede Instanz behält ihr Desktop-Fenster, sie laufen /// im Hintergrund, und beim Wechsel kommt eines nach vorn. Auf einer Maschine ohne /// Bildschirmsitzung — einem Server, einem Bauknecht — gehört hier false hin; /// dann laufen die Instanzen kopflos und sind nur über die Weboberfläche zu /// bedienen. /// [JsonPropertyName("instanceWindows")] public bool InstanceWindows { get; set; } = true; // ─── Die eine Weboberfläche (E-M5) ─── [JsonPropertyName("webUiPort")] public int WebUiPort { get; set; } = 7000; /// loopback (Vorgabe) oder any. [JsonPropertyName("webUiBinding")] public string WebUiBinding { get; set; } = "loopback"; /// Zugangstoken der Weboberfläche. Verschlüsselt gespeichert. [JsonPropertyName("webUiToken")] public string WebUiToken { get; set; } = ""; // ─── Lizenz (E-M3) ─── /// /// Der Lizenzschlüssel der Installation. Verschlüsselt gespeichert. /// /// Er liegt hier und nicht in den Instanzen: Eine Lizenz gilt der Installation /// und erlaubt beliebig viele Instanzen. /// [JsonPropertyName("licenseKey")] public string LicenseKey { get; set; } = ""; // ─── Deploymentcenter ─── [JsonPropertyName("deploymentcenterUrl")] public string DeploymentcenterUrl { get; set; } = "https://dc.mhdf.de"; /// /// Das Master-Token. Verschlüsselt gespeichert. /// /// Aus ihm entstehen die Sub-Token der Instanzen — jedes eingeschränkt auf /// watchdog:ping und bugtracker:report und einzeln widerrufbar. Nach dem /// Umbau liegt auf keiner Instanz mehr das Master-Token. /// [JsonPropertyName("deploymentcenterMasterToken")] public string DeploymentcenterMasterToken { get; set; } = ""; /// Aktualisiert wird das Programm, nicht die Instanz. [JsonPropertyName("updateChannel")] public string UpdateChannel { get; set; } = "prod"; // ─── Aufsicht ─── /// Wie oft nachgesehen wird, in Sekunden. [JsonPropertyName("probeIntervalSeconds")] public int ProbeIntervalSeconds { get; set; } = 15; /// Wie lange eine frisch gestartete Instanz Zeit hat, sich zu melden. [JsonPropertyName("startTimeoutSeconds")] public int StartTimeoutSeconds { get; set; } = 60; /// Wie viele Fehlversuche in Folge „antwortet nicht" ergeben. [JsonPropertyName("silentChecksBeforeAlarm")] public int SilentChecksBeforeAlarm { get; set; } = 3; /// Wie lange eine Instanz nach der Bitte zu enden noch bekommt. [JsonPropertyName("stopGraceSeconds")] public int StopGraceSeconds { get; set; } = 20; // ─── Neustart nach Absturz ─── /// /// Wie oft eine Instanz innerhalb von wieder /// gestartet wird, bevor der Manager aussetzt. /// /// Der Sicherungsautomat zum Autostart: Was an einer kaputten Konfiguration /// scheitert, scheitert beim zehnten Versuch genauso — dann hat man statt einer toten /// Instanz eine tote Instanz und ein volles Protokoll. Nach dem Aussetzen bleibt sie /// aus, bis jemand sie von Hand startet; das ist gemeldet und nicht still. /// [JsonPropertyName("maxRestartsInWindow")] public int MaxRestartsInWindow { get; set; } = 3; /// Der Zeitraum, über den zählt. [JsonPropertyName("restartWindowMinutes")] public int RestartWindowMinutes { get; set; } = 10; /// Die geprüfte Neustartregel. public RestartPolicy ToRestartPolicy() => new( MaxRestarts: Math.Clamp(MaxRestartsInWindow, 1, 100), Window: TimeSpan.FromMinutes(Math.Clamp(RestartWindowMinutes, 1, 1440))); /// /// Die Werte aus dieser Datei als geprüfte Aufsichtsvorgaben. /// /// Untergrenzen, weil unsinnige Zahlen in einer von Hand bearbeitbaren Datei /// stehen können: Ein Abfragetakt von 0 wäre eine Endlosschleife, ein Alarm nach null /// Fehlversuchen ein Alarm bei jedem Aussetzer. /// public SupervisionOptions ToSupervisionOptions() => new( ProbeInterval: TimeSpan.FromSeconds(Math.Clamp(ProbeIntervalSeconds, 1, 3600)), StartTimeout: TimeSpan.FromSeconds(Math.Clamp(StartTimeoutSeconds, 5, 600)), SilentChecksBeforeAlarm: Math.Clamp(SilentChecksBeforeAlarm, 1, 100), StopGrace: TimeSpan.FromSeconds(Math.Clamp(StopGraceSeconds, 1, 600))); } /// /// Lädt und speichert manager.json. /// /// Dieselbe Bauart wie die Anwendungseinstellungen, mit Absicht. Der Ort /// kommt von , Geheimnisse liegen in der Datei /// verschlüsselt und zur Laufzeit im Klartext, geschrieben wird atomar, und eine /// unlesbare Datei verhindert den Start nicht. Wer eine der beiden kennt, kennt die /// andere. /// public sealed class ManagerSettingsStore { private const string FileName = "manager.json"; private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = true, ReadCommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true, PropertyNameCaseInsensitive = true }; public ManagerSettingsStore(string? basePath = null) => SettingsPath = Path.Combine(basePath ?? AppPaths.ConfigDirectory, FileName); /// Der Ort der Datei — für Meldungen und Diagnose. public string SettingsPath { get; } public ManagerSettings Settings { get; private set; } = new(); /// /// Warum die Datei nicht gelesen werden konnte — oder null, wenn alles ging. /// /// Warum das nach aussen muss. Eine unlesbare Datei verhindert den Start /// nicht; das wäre der schlechtere Zustand, denn dann liefe gar keine Instanz mehr. /// Aber „läuft weiter" heisst nicht „niemand erfährt es": Mit Vorgaben statt der /// eigenen Einstellungen sucht der Manager im falschen Verzeichnis nach Instanzen, /// hört auf einem anderen Port und fragt nach einer Lizenz, die woanders steht — und /// nichts davon sagt, woran es liegt. Genau das ist beim ersten Abnahmelauf passiert: /// ein einzelner Backslash zu wenig, und eine Viertelstunde Ratelei. /// public string? LoadProblem { get; private set; } public ManagerSettings Load() { LoadProblem = null; if (!File.Exists(SettingsPath)) { Settings = new ManagerSettings(); Save(); // Vorgaben festschreiben, damit der Ort sichtbar wird return Settings; } try { Settings = JsonSerializer.Deserialize( AtomicFile.ReadAllText(SettingsPath), JsonOpts) ?? new ManagerSettings(); } catch (Exception ex) when (ex is JsonException or IOException) { // Eine unlesbare Datei darf den Manager nicht am Start hindern - dann liefe // gar keine Instanz mehr, und das ist der schlechtere Zustand. Aber der Grund // gehoert nach draussen, sonst arbeitet er stillschweigend mit Vorgaben. LoadProblem = ex.Message; Settings = new ManagerSettings(); return Settings; } Settings.LicenseKey = TryUnprotect(Settings.LicenseKey); Settings.DeploymentcenterMasterToken = TryUnprotect(Settings.DeploymentcenterMasterToken); Settings.WebUiToken = TryUnprotect(Settings.WebUiToken); return Settings; } public void Save() { var lizenz = Settings.LicenseKey; var master = Settings.DeploymentcenterMasterToken; var web = Settings.WebUiToken; try { Settings.LicenseKey = SecretProtector.Protect(lizenz) ?? ""; Settings.DeploymentcenterMasterToken = SecretProtector.Protect(master) ?? ""; Settings.WebUiToken = SecretProtector.Protect(web) ?? ""; AppPaths.EnsureDirectory(Path.GetDirectoryName(SettingsPath)!); AtomicFile.WriteAllText(SettingsPath, JsonSerializer.Serialize(Settings, JsonOpts)); AppPaths.RestrictToOwner(SettingsPath); } finally { Settings.LicenseKey = lizenz; Settings.DeploymentcenterMasterToken = master; Settings.WebUiToken = web; } } /// /// Entschlüsselt einen Wert; bei Nicht-Lesbarkeit (anderer Benutzer, anderer Rechner) /// leer, damit er neu eingetragen wird, statt unbrauchbar an eine Gegenstelle zu gehen. /// private static string TryUnprotect(string value) { try { return SecretProtector.Unprotect(value) ?? ""; } catch (SecretProtectionException) { return ""; } } }