Files
Predictalytics/src/Predictalytics.Hosting/PredictalyticsOptions.cs
T
Richard 1131d257cf Eigenständiges token-geschütztes Verwaltungs-WebGUI (Admin WebGUI) hinzugefügt
- Unabhängige Kestrel-Instanz auf Port 5001 (vollständig getrennt vom Analyse-Webserver)
- Admin API Endpunkte ohne Swagger-Exposition
- Token-Gatekeeper mit sofortigem Login-Overlay bei unautorisiertem Zugriff
- Live-Terminal via SSE-Log-Streaming und Log-Download-Übersicht
- Steuerung von Workern, Analyse-Webserver, Wartungsaktionen und Einstellungen
- Nahtlose Integration in Avalonia Desktop-Shell und Linux Headless-Modus
- Unit-Tests für Admin-Filter, Optionen und Log-Puffer
2026-09-07 21:09:03 +02:00

326 lines
13 KiB
C#

using System.ComponentModel;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Configuration;
namespace Predictalytics.Hosting;
/// <summary>
/// Laufzeit-Konfiguration der Anwendung.
/// <para>
/// Die <see cref="System.ComponentModel"/>-Attribute sind plattformneutral (Teil der
/// Basisbibliothek). Die Avalonia-Shell nutzt sie fuer Beschriftungen und Hilfetexte
/// der Einstellungsansicht.
/// </para>
/// </summary>
public sealed class PredictalyticsOptions
{
private const string FileName = "settings.json";
// ─── Webserver ───
[Category("Webserver")]
[DisplayName("Port")]
[Description("Der Port, über den die WebUI und API erreichbar sind.")]
[DefaultValue(5000)]
public int WebserverPort { get; set; } = 5000;
[Category("Webserver")]
[DisplayName("Bind-Adresse")]
[Description("Netzwerkschnittstelle, auf der Kestrel lauscht. 'localhost' = nur lokal (Standard), " +
"'0.0.0.0' = von außen erreichbar. Bei 0.0.0.0 muss die API abgesichert werden!")]
[DefaultValue("localhost")]
public string WebserverHost { get; set; } = "localhost";
[Category("Webserver")]
[DisplayName("Database Debug")]
[Description("Wenn aktiv, werden detaillierte Verbindungsinformationen im Terminal angezeigt.")]
[DefaultValue(false)]
public bool DbConnectionDebug { get; set; } = false;
[Category("Webserver")]
[DisplayName("API-Token (Steuerbefehle)")]
[Description("Schützt die schreibenden Endpunkte (Jobs, Trader-Steuerung, Dev). Aufrufer müssen dann " +
"den Header X-Predictalytics-Key mitschicken. Bei einer Bind-Adresse außerhalb von localhost " +
"ist der Token Pflicht — ohne ihn werden die Steuerbefehle gar nicht erst bereitgestellt.")]
[PasswordPropertyText(true)]
public string ApiControlToken { get; set; } = "";
/// <summary>
/// Ob die Bind-Adresse den Dienst ueber das Netz erreichbar macht. Bei localhost
/// uebernimmt das Betriebssystem die Absicherung, sonst nicht mehr.
/// </summary>
[Browsable(false)]
[JsonIgnore]
public bool IsPubliclyBound
{
get
{
var host = (WebserverHost ?? "").Trim();
return host.Length > 0
&& !host.Equals("localhost", StringComparison.OrdinalIgnoreCase)
&& host != "127.0.0.1"
&& host != "::1";
}
}
// ─── Verwaltungs-Webserver (Admin WebGUI) ───
[Category("Verwaltungs-Webserver")]
[DisplayName("Admin Webserver aktiv")]
[Description("Aktiviert das separate Verwaltungs-WebGUI zur Steuerung und Wartung.")]
[DefaultValue(true)]
public bool AdminServerEnabled { get; set; } = true;
[Category("Verwaltungs-Webserver")]
[DisplayName("Port")]
[Description("Der Port, über den das Verwaltungs-WebGUI erreichbar ist.")]
[DefaultValue(5001)]
public int AdminServerPort { get; set; } = 5001;
[Category("Verwaltungs-Webserver")]
[DisplayName("Bind-Adresse")]
[Description("Netzwerkschnittstelle, auf der das Verwaltungs-WebGUI lauscht. 'localhost' = nur lokal (Standard), '0.0.0.0' = netzwerkweit erreichbar.")]
[DefaultValue("localhost")]
public string AdminServerHost { get; set; } = "localhost";
[Category("Verwaltungs-Webserver")]
[DisplayName("Admin-Token")]
[Description("Schützt das gesamte Verwaltungs-WebGUI. Ohne diesen Token ist kein Zugriff möglich. Kann neu generiert werden.")]
[PasswordPropertyText(true)]
public string AdminToken { get; set; } = "";
/// <summary>
/// Prüft, ob die Admin-Bind-Adresse öffentlich bzw. über das Netzwerk erreichbar ist.
/// </summary>
[Browsable(false)]
[JsonIgnore]
public bool IsAdminPubliclyBound
{
get
{
var host = (AdminServerHost ?? "").Trim();
return host.Length > 0
&& !host.Equals("localhost", StringComparison.OrdinalIgnoreCase)
&& host != "127.0.0.1"
&& host != "::1";
}
}
/// <summary>Kestrel-Bind-URL für das Admin-WebGUI.</summary>
[Browsable(false)]
[JsonIgnore]
public string AdminServerUrl =>
$"http://{(string.IsNullOrWhiteSpace(AdminServerHost) ? "localhost" : AdminServerHost.Trim())}:{AdminServerPort}";
/// <summary>Generiert einen sicheren, kryptografischen Zufallstoken für das Admin-WebGUI.</summary>
public static string GenerateSecureToken(int byteLength = 24) =>
Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(byteLength)).ToLowerInvariant();
// ─── Egress ───
private string _egressChannelsText = "";
[Category("Egress (Proxy/IP)")]
[DisplayName("Egress Channels")]
[Description("Liste der Egress-Kanäle im Format: id|type|value (Zeilengetrennt). " +
"Beispiel: prox-1|Proxy|http://user:pass@proxy:8080\nip-1|SourceIp|192.168.1.100")]
public string EgressChannelsText { get => _egressChannelsText; set => _egressChannelsText = value ?? ""; }
// ─── Deployment Center ───
[Category("Deployment Center")]
[DisplayName("Server URL")]
[Description("Basis-URL des Deployment Centers. Fest einkompiliert — ein einstellbarer Endpoint würde erlauben, die App auf einen gefälschten Lizenz- oder Update-Server zu zeigen.")]
[ReadOnly(true)]
[JsonIgnore]
public string DcServerUrl => DcConfig.BaseUrl;
[Category("Deployment Center")]
[DisplayName("API Token")]
[Description("Token des Deployment Centers (Authorization: Bearer). Benötigte Rechte: 'watchdog:ping' für Heartbeats, 'bugtracker:report' für das Fehler-Reporting. Ohne Token werden weder Heartbeats noch Fehler gemeldet.")]
[PasswordPropertyText(true)]
public string DcToken { get; set; } = "";
[Category("Deployment Center")]
[DisplayName("Heartbeat aktiv")]
[Description("Sendet periodische Heartbeats an den Watchdog des Deployment Centers (Dead-Man's-Switch). Benötigt ein Token.")]
[DefaultValue(true)]
public bool DcHeartbeatEnabled { get; set; } = true;
[Category("Deployment Center")]
[DisplayName("Monitor Source")]
[Description("Eindeutiger Monitor-Name dieses Dienstes im Watchdog-Dashboard.")]
[DefaultValue("Predictalytics")]
public string DcSource { get; set; } = "Predictalytics";
[Category("Deployment Center")]
[DisplayName("Monitor Instance")]
[Description("Instanz-Kennung, falls mehrere Predictalytics-Instanzen laufen.")]
[DefaultValue("default")]
public string DcInstance { get; set; } = "default";
[Category("Deployment Center")]
[DisplayName("Heartbeat-Intervall (Sekunden)")]
[Description("Sende-Takt der Heartbeats. Der Evaluator stuft nach dem Doppelten auf 'warning' und nach dem Vierfachen auf 'down'.")]
[DefaultValue(60)]
public int DcHeartbeatIntervalSeconds { get; set; } = 60;
[Category("Deployment Center")]
[DisplayName("Fehler melden")]
[Description("Meldet Laufzeitfehler (Error/Fatal) an den Fehler-Stream des Deployment Centers. Gleiche Fehler werden dort gruppiert und hochgezählt.")]
[DefaultValue(true)]
public bool DcErrorReportingEnabled { get; set; } = true;
[Category("Deployment Center")]
[DisplayName("Update-Prüfung beim Start")]
[Description("Prüft beim Start, ob im gewählten Kanal ein neueres Release vorliegt. Installiert wird nichts automatisch.")]
[DefaultValue(true)]
public bool DcUpdateCheckEnabled { get; set; } = true;
[Category("Deployment Center")]
[DisplayName("Update-Kanal")]
[Description("prod, beta oder dev.")]
[DefaultValue("prod")]
public string DcUpdateChannel { get; set; } = "prod";
// ─── Database ───
private string _dbServer = "localhost";
private string _dbName = "";
private string _dbUser = "";
private string _dbPassword = "";
[Category("Database")]
[DisplayName("Server")]
public string DbServer { get => _dbServer; set => _dbServer = string.IsNullOrWhiteSpace(value) ? _dbServer : value.Trim(); }
[Category("Database")]
[DisplayName("Database")]
public string DbName { get => _dbName; set => _dbName = string.IsNullOrWhiteSpace(value) ? _dbName : value.Trim(); }
[Category("Database")]
[DisplayName("User")]
public string DbUser { get => _dbUser; set => _dbUser = string.IsNullOrWhiteSpace(value) ? _dbUser : value.Trim(); }
[Category("Database")]
[DisplayName("Password")]
[PasswordPropertyText(true)]
public string DbPassword { get => _dbPassword; set => _dbPassword = string.IsNullOrWhiteSpace(value) ? _dbPassword : value.Trim(); }
[Category("Database")]
[DisplayName("SSL Mode")]
[Description("Verschlüsselung der MySQL-Verbindung. 'None' = unverschlüsselt (bisheriges Verhalten), " +
"'Preferred' = verschlüsselt wenn der Server es unterstützt. Bei entfernten Servern 'Preferred' empfohlen.")]
[DefaultValue(MySqlConnector.MySqlSslMode.None)]
public MySqlConnector.MySqlSslMode DbSslMode { get; set; } = MySqlConnector.MySqlSslMode.None;
[Browsable(false)]
[JsonIgnore]
public string ConnectionString
{
get
{
var builder = new MySqlConnector.MySqlConnectionStringBuilder
{
Server = DbServer?.Trim(),
Database = DbName?.Trim(),
UserID = DbUser?.Trim(),
Password = DbPassword?.Trim(),
AllowPublicKeyRetrieval = true,
SslMode = DbSslMode,
Pooling = true,
MinimumPoolSize = 0,
MaximumPoolSize = 100
};
return builder.ConnectionString;
}
}
/// <summary>Kestrel-Bind-URL aus Host und Port.</summary>
[Browsable(false)]
[JsonIgnore]
public string WebserverUrl =>
$"http://{(string.IsNullOrWhiteSpace(WebserverHost) ? "localhost" : WebserverHost.Trim())}:{WebserverPort}";
// ─── Persistenz ───
/// <summary>
/// Ablageort der Einstellungen: %APPDATA%\Predictalytics unter Windows,
/// ~/.config/Predictalytics unter Linux. Bewusst nicht neben der Programmdatei —
/// deren Verzeichnis ist unter Linux ueblicherweise nicht beschreibbar.
/// </summary>
public static string SettingsDirectory => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"Predictalytics");
public static string SettingsFilePath => Path.Combine(SettingsDirectory, FileName);
/// <summary>Alter Ablageort neben der Programmdatei (bis einschliesslich der WinForms-Fassung).</summary>
private static string LegacySettingsFilePath => Path.Combine(AppContext.BaseDirectory, FileName);
public static PredictalyticsOptions Load()
{
// Neuer Ort hat Vorrang; sonst einmalig aus dem alten uebernehmen, damit
// bestehende Zugangsdaten beim Umstieg nicht verloren gehen.
var path = File.Exists(SettingsFilePath) ? SettingsFilePath
: File.Exists(LegacySettingsFilePath) ? LegacySettingsFilePath
: null;
if (path == null)
{
var fresh = new PredictalyticsOptions();
fresh.Save();
return fresh;
}
try
{
var options = JsonSerializer.Deserialize<PredictalyticsOptions>(File.ReadAllText(path))
?? new PredictalyticsOptions();
var modified = path == LegacySettingsFilePath;
if (string.IsNullOrWhiteSpace(options.AdminToken))
{
options.AdminToken = GenerateSecureToken();
modified = true;
}
if (modified) options.Save(); // Migration oder Initial-Token festschreiben
return options;
}
catch
{
var fallback = new PredictalyticsOptions();
if (string.IsNullOrWhiteSpace(fallback.AdminToken))
{
fallback.AdminToken = GenerateSecureToken();
try { fallback.Save(); } catch { }
}
return fallback;
}
}
/// <summary>
/// Ermöglicht das Übersteuern von Einstellungen über appsettings.json oder Umgebungsvariablen.
/// </summary>
public void ApplyConfigurationOverrides(Microsoft.Extensions.Configuration.IConfiguration? configuration)
{
if (configuration == null) return;
var section = configuration.GetSection("Admin");
if (section.Exists())
{
if (bool.TryParse(section["Enabled"], out var enabled)) AdminServerEnabled = enabled;
if (int.TryParse(section["Port"], out var port) && port > 0) AdminServerPort = port;
if (!string.IsNullOrWhiteSpace(section["Host"])) AdminServerHost = section["Host"]!.Trim();
if (!string.IsNullOrWhiteSpace(section["Token"])) AdminToken = section["Token"]!.Trim();
}
}
public void Save()
{
Directory.CreateDirectory(SettingsDirectory);
File.WriteAllText(SettingsFilePath,
JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true }));
}
}