- 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
326 lines
13 KiB
C#
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 }));
|
|
}
|
|
}
|