Files
ClawdDotNet/src/ClawdDotNet.Manager/ManagerSettings.cs
T
RichardandClaude Opus 5 fb185ca84d Sicherungen mit dem Lizenzschluessel verschluesselt, optional per FTP (#530)
Eine Sicherung des Managers ist jetzt als Ganzes mit dem Lizenzschluessel
verschluesselt. Beim Zurueckspielen wird der eigene Schluessel probiert; passt er
nicht, fragt die Oberflaeche nach dem Lizenzschluessel der Quellinstallation.

Der Lizenzschluessel ist der eine Wert, den jede Installation ohnehin hat, und er
liegt im Manager, nicht neben den Archiven. Damit faellt der Grund weg, aus dem die
taegliche Sicherung bisher keine Zugangsdaten enthielt: Eine Passphrase haette
gespeichert werden muessen, und neben den Sicherungen waere sie wirkungslos
gewesen. Jetzt reisen die Zugangsdaten mit, und eine Sicherung uebersteht den
Maschinenwechsel ohne Passphrase.

LicenseBackupCipher: eigener Behaelter .clawdbak. Kopf mit Schluesselprobe, das
Manifest als eigener Abschnitt, das ZIP in 1-MiB-Stuecken mit AES-256-GCM und
Endmarke. PBKDF2 aus Lizenzschluessel und festem Salt, der Schluessel normiert.
Kein RAR (braucht das lizenzpflichtige Herstellerprogramm), kein ZIP mit Kennwort
(kann .NET nicht schreiben, ZipCrypto ist gebrochen, und mit abgeleitetem
Schluessel braechte 7-Zip-Vertraeglichkeit nichts).

Zurueckspielen: Die Schluesselprobe laeuft VOR dem Anhalten der Instanz. Passt
der Schluessel nicht, antwortet der Endpunkt mit 422 und dem Code fremde_lizenz,
und ein Fenster verlangt den Schluessel der Quelle.

ManagerBackups: Gefuehrte Instanzen sichern sich nicht mehr selbst, weil nur der
Manager den Schluessel hat; er liest ihre Einstellungen aus InstanceSettings.json.
Einmal am Tag auch ueber einen Neustart des Managers hinweg. Ohne Lizenzschluessel
wird nichts gesichert, auch nicht ersatzweise unverschluesselt.

FTP (FluentFTP, schon im Loesungsbaum): TLS wo angeboten, requireTls erzwingt es;
Upload unter .part-Namen und danach umbenannt; Aufraeumen auf dem Server je
Instanz; ein gescheiterter Upload laesst die lokale Sicherung stehen. Das
Kennwort liegt verschluesselt in manager.json und geht nie an die Oberflaeche.

Welche Datei zu welcher Instanz gehoert, entscheidet ein Zeitstempel nach dem
Namen und nicht das Praefix - Test und Test_2 werden nicht mehr verwechselt.

Grenze, bewusst und dokumentiert (Konzept 9.10): Wer Lizenzschluessel und Archiv
hat, hat die Zugangsdaten. Der Schluessel ist dem Hersteller bekannt und gilt fuer
alle Instanzen einer Installation.

Nicht ueber die Oberflaeche abgenommen: Fenster fuer die fremde Lizenz und FTP
gegen einen echten Server stehen als Block H in #537.

860 Tests gruen, 0 Warnungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 11:03:25 +02:00

344 lines
14 KiB
C#

using System.Text.Json;
using System.Text.Json.Serialization;
using ClawdDotNet.Core.Security;
using ClawdDotNet.Core.Storage;
namespace ClawdDotNet.Manager;
/// <summary>
/// Alles, was der Instanzmanager weiß — und mehr soll er nicht wissen (E-M4).
///
/// <para><b>Die Kürze dieser Klasse ist die Zusage.</b> 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.</para>
///
/// <para>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.</para>
///
/// <para><b>Nicht dabei: eine Datenbank.</b> Was der Manager braucht, liegt als JSON im
/// Projektverzeichnis — diese Datei und je Instanz eine kleine Zustandsdatei. Die
/// <c>state.db</c> einer Instanz fasst er nicht an: Sie gehört genau einem Prozess, und
/// prozessübergreifend gilt die Schreibsperre von <c>SqliteStorage</c> nicht.</para>
/// </summary>
public sealed class ManagerSettings
{
// ─── Wo die Instanzen liegen ───
[JsonPropertyName("instancesDirectory")]
public string InstancesDirectory { get; set; } = Path.Combine(AppPaths.DataDirectory, "Instances");
/// <summary>
/// Die Programmdatei, mit der ein Instanzprozess gestartet wird.
///
/// <para>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.</para>
/// </summary>
[JsonPropertyName("instanceExecutable")]
public string InstanceExecutable { get; set; } = "";
/// <summary>
/// Ob gestartete Instanzen ein eigenes Fenster bekommen (E-M2).
///
/// <para>Vorbelegt auf <c>true</c>: 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 <c>false</c> hin;
/// dann laufen die Instanzen kopflos und sind nur über die Weboberfläche zu
/// bedienen.</para>
/// </summary>
[JsonPropertyName("instanceWindows")]
public bool InstanceWindows { get; set; } = true;
// ─── Die eine Weboberfläche (E-M5) ───
[JsonPropertyName("webUiPort")]
public int WebUiPort { get; set; } = 7000;
/// <summary><c>loopback</c> (Vorgabe) oder <c>any</c>.</summary>
[JsonPropertyName("webUiBinding")]
public string WebUiBinding { get; set; } = "loopback";
/// <summary>Zugangstoken der Weboberfläche. Verschlüsselt gespeichert.</summary>
[JsonPropertyName("webUiToken")]
public string WebUiToken { get; set; } = "";
// ─── Lizenz (E-M3) ───
/// <summary>
/// Der Lizenzschlüssel der Installation. Verschlüsselt gespeichert.
///
/// <para>Er liegt hier und nicht in den Instanzen: Eine Lizenz gilt der Installation
/// und erlaubt beliebig viele Instanzen.</para>
/// </summary>
[JsonPropertyName("licenseKey")]
public string LicenseKey { get; set; } = "";
// ─── Sicherungen ───
/// <summary>
/// Wohin fertige Sicherungen zusätzlich hochgeladen werden. Aus, solange nichts
/// eingetragen ist.
/// </summary>
[JsonPropertyName("backupFtp")]
public BackupFtpSettings BackupFtp { get; set; } = new();
// ─── Deploymentcenter ───
[JsonPropertyName("deploymentcenterUrl")]
public string DeploymentcenterUrl { get; set; } = "https://dc.mhdf.de";
/// <summary>
/// Das Master-Token. Verschlüsselt gespeichert.
///
/// <para>Aus ihm entstehen die Sub-Token der Instanzen — jedes eingeschränkt auf
/// <c>watchdog:ping</c> und <c>bugtracker:report</c> und einzeln widerrufbar. Nach dem
/// Umbau liegt auf keiner Instanz mehr das Master-Token.</para>
/// </summary>
[JsonPropertyName("deploymentcenterMasterToken")]
public string DeploymentcenterMasterToken { get; set; } = "";
/// <summary>Aktualisiert wird das Programm, nicht die Instanz.</summary>
[JsonPropertyName("updateChannel")]
public string UpdateChannel { get; set; } = "prod";
// ─── Aufsicht ───
/// <summary>Wie oft nachgesehen wird, in Sekunden.</summary>
[JsonPropertyName("probeIntervalSeconds")]
public int ProbeIntervalSeconds { get; set; } = 15;
/// <summary>Wie lange eine frisch gestartete Instanz Zeit hat, sich zu melden.</summary>
[JsonPropertyName("startTimeoutSeconds")]
public int StartTimeoutSeconds { get; set; } = 60;
/// <summary>Wie viele Fehlversuche in Folge „antwortet nicht" ergeben.</summary>
[JsonPropertyName("silentChecksBeforeAlarm")]
public int SilentChecksBeforeAlarm { get; set; } = 3;
/// <summary>Wie lange eine Instanz nach der Bitte zu enden noch bekommt.</summary>
[JsonPropertyName("stopGraceSeconds")]
public int StopGraceSeconds { get; set; } = 20;
// ─── Neustart nach Absturz ───
/// <summary>
/// Wie oft eine Instanz innerhalb von <see cref="RestartWindowMinutes"/> wieder
/// gestartet wird, bevor der Manager aussetzt.
///
/// <para>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.</para>
/// </summary>
[JsonPropertyName("maxRestartsInWindow")]
public int MaxRestartsInWindow { get; set; } = 3;
/// <summary>Der Zeitraum, über den <see cref="MaxRestartsInWindow"/> zählt.</summary>
[JsonPropertyName("restartWindowMinutes")]
public int RestartWindowMinutes { get; set; } = 10;
/// <summary>
/// Wie lange eine verstummte Instanz nach der angeordneten Beendigung noch bekommt,
/// bevor der Prozess hart beendet wird.
/// </summary>
[JsonPropertyName("silentKillGraceSeconds")]
public int SilentKillGraceSeconds { get; set; } = 30;
/// <summary>
/// Die Obergrenze beim Anhalten, solange die Instanz meldet, dass sie aufräumt.
///
/// <para>Muss über der Frist liegen, die sich die Instanz selbst gibt — sonst schießt
/// der Manager in das Aufräumen hinein, um das er gebeten hat.</para>
/// </summary>
[JsonPropertyName("maxDrainWaitSeconds")]
public int MaxDrainWaitSeconds { get; set; } = 300;
/// <summary>Die geprüfte Neustartregel.</summary>
public RestartPolicy ToRestartPolicy() => new(
MaxRestarts: Math.Clamp(MaxRestartsInWindow, 1, 100),
Window: TimeSpan.FromMinutes(Math.Clamp(RestartWindowMinutes, 1, 1440)));
/// <summary>
/// Die Werte aus dieser Datei als geprüfte Aufsichtsvorgaben.
///
/// <para>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.</para>
/// </summary>
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)),
SilentKillGrace: TimeSpan.FromSeconds(Math.Clamp(SilentKillGraceSeconds, 1, 600)),
MaxDrainWait: TimeSpan.FromSeconds(Math.Clamp(MaxDrainWaitSeconds, 1, 3600)));
}
/// <summary>
/// Lädt und speichert <c>manager.json</c>.
///
/// <para><b>Dieselbe Bauart wie die Anwendungseinstellungen, mit Absicht.</b> Der Ort
/// kommt von <see cref="AppPaths.ConfigDirectory"/>, 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.</para>
/// </summary>
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);
/// <summary>Der Ort der Datei — für Meldungen und Diagnose.</summary>
public string SettingsPath { get; }
public ManagerSettings Settings { get; private set; } = new();
/// <summary>
/// Warum die Datei nicht gelesen werden konnte — oder <c>null</c>, wenn alles ging.
///
/// <para><b>Warum das nach aussen muss.</b> 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.</para>
/// </summary>
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<ManagerSettings>(
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);
Settings.BackupFtp ??= new BackupFtpSettings();
Settings.BackupFtp.Password = TryUnprotect(Settings.BackupFtp.Password);
return Settings;
}
public void Save()
{
var lizenz = Settings.LicenseKey;
var master = Settings.DeploymentcenterMasterToken;
var web = Settings.WebUiToken;
var ftp = Settings.BackupFtp.Password;
try
{
Settings.LicenseKey = SecretProtector.Protect(lizenz) ?? "";
Settings.DeploymentcenterMasterToken = SecretProtector.Protect(master) ?? "";
Settings.WebUiToken = SecretProtector.Protect(web) ?? "";
Settings.BackupFtp.Password = SecretProtector.Protect(ftp) ?? "";
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;
Settings.BackupFtp.Password = ftp;
}
}
/// <summary>
/// Entschlüsselt einen Wert; bei Nicht-Lesbarkeit (anderer Benutzer, anderer Rechner)
/// leer, damit er neu eingetragen wird, statt unbrauchbar an eine Gegenstelle zu gehen.
/// </summary>
private static string TryUnprotect(string value)
{
try { return SecretProtector.Unprotect(value) ?? ""; }
catch (SecretProtectionException) { return ""; }
}
}
/// <summary>
/// Der FTP-Upload fertiger Sicherungen.
///
/// <para><b>Warum das vertretbar ist.</b> Eine Sicherung auf einem fremden Server ist nur
/// deshalb unbedenklich, weil sie als Ganzes verschlüsselt ist
/// (<c>LicenseBackupCipher</c>): Wer den Server liest, liest Rauschen. Das Kennwort des
/// Servers dagegen ginge bei reinem FTP im Klartext über die Leitung — deshalb wird TLS
/// genommen, wo der Server es anbietet, und mit <see cref="RequireTls"/> erzwungen.</para>
/// </summary>
public sealed class BackupFtpSettings
{
[JsonPropertyName("enabled")]
public bool Enabled { get; set; }
[JsonPropertyName("host")]
public string Host { get; set; } = "";
[JsonPropertyName("port")]
public int Port { get; set; } = 21;
[JsonPropertyName("username")]
public string Username { get; set; } = "";
/// <summary>Verschlüsselt gespeichert.</summary>
[JsonPropertyName("password")]
public string Password { get; set; } = "";
/// <summary>Darunter bekommt jede Instanz einen eigenen Ordner.</summary>
[JsonPropertyName("remoteDirectory")]
public string RemoteDirectory { get; set; } = "/clawd-backups";
/// <summary>
/// Ohne TLS gar nicht verbinden. Aus heißt: TLS, wenn der Server es anbietet, sonst
/// ohne — dann steht eine Warnung im Protokoll.
/// </summary>
[JsonPropertyName("requireTls")]
public bool RequireTls { get; set; }
/// <summary>
/// Wie viele Sicherungen je Instanz auf dem Server bleiben. Ohne Aufräumen füllt ein
/// automatischer Upload den Server, bis er voll ist — und dann schlägt ausgerechnet
/// die jüngste Sicherung fehl.
/// </summary>
[JsonPropertyName("keepCount")]
public int KeepCount { get; set; } = 14;
[JsonIgnore]
public bool IsConfigured => Enabled && !string.IsNullOrWhiteSpace(Host);
}