diff --git a/docs/PLAN-Linux-Portierung.md b/docs/PLAN-Linux-Portierung.md index d6d65d7..83ff9e2 100644 --- a/docs/PLAN-Linux-Portierung.md +++ b/docs/PLAN-Linux-Portierung.md @@ -544,7 +544,75 @@ Leitfaden liegt unter `Deploymentcenter/docs/WATCHDOG_INTEGRATION_GUIDE.md`. --- -## 6. Phase 4 — Aufräumen und Absichern (1–1,5 PT) +## 6. Phase 4 — Aufräumen und Absichern ✅ **erledigt (2026-08-08)** + +**Abnahme:** Build 0 Fehler, 8 Warnungen (alle vorbestehend). +`dotnet test` → **124 bestanden** (vorher 100; 24 neue Tests zur Absicherung). + +### AP 4.1 — Steuerendpunkte werden jetzt tatsächlich geschützt ✅ + +Der Befund war schlimmer als in der Analyse notiert: `/api/capabilities` **meldete** +`CanControl`/`AuthRequired` aus der Konfiguration, **erzwungen wurde nichts**. +`MapPredictalyticsControlEndpoints()` mappte Jobs, Trader-Steuerung und die Dev-Endpunkte +bedingungslos und ungeschützt. + +Neu: + +| Bindung | Token | Verhalten | +|---|---|---| +| localhost | — | Steuerendpunkte aktiv, ungeschützt (das Betriebssystem schützt) | +| localhost | gesetzt | Steuerendpunkte aktiv, Token wird verlangt | +| **extern** | **fehlt** | **Steuerendpunkte werden gar nicht gemappt**, dazu ein `Log.Error` | +| extern | gesetzt | Steuerendpunkte verlangen `X-Predictalytics-Key` | + +Bewusst fail-safe: eine unbedachte Umstellung der Bind-Adresse öffnet nichts stillschweigend. +`ApiTokenFilter` vergleicht laufzeitkonstant (`CryptographicOperations.FixedTimeEquals`), +damit die Antwortzeit nicht verrät, wie viele Zeichen stimmen. +Swagger ist bei externer Bindung abgeschaltet — es legt die gesamte API-Oberfläche offen. +`/api/capabilities` meldet nun den tatsächlichen Zustand statt einer Konfigurationsabsicht. + +### AP 4.2 — Globalisierung ✅ +`RuntimeSetup.UseInvariantCulture()`, aufgerufen als Erstes in beiden Startpfaden. +Bewusst **nicht** über `InvariantGlobalization` im Projekt: das änderte zusätzlich die +Semantik von Zeichenkettenvergleichen und wäre ein größerer Eingriff als nötig. + +### AP 4.3 — Log-Verzeichnis ✅ +Standard ist jetzt `%APPDATA%\Predictalytics\logs` bzw. `~/.config/Predictalytics/logs` — +neben den Einstellungen statt neben der Programmdatei, die unter Linux üblicherweise nicht +beschreibbar ist und beim Update überschrieben würde. Übersteuerbar über +`PREDICTALYTICS_LOG_DIR`, etwa auf `/var/log/predictalytics` im systemd-Betrieb. + +### AP 4.4 — Solution und Repo-Kopplung ✅ +Der WinFormsHost-Sonderfall entfällt, weil das Projekt entfernt ist — die Solution baut +unter Linux ohne Bedingung durch. Die relative Referenz auf das Schwester-Repo bleibt +(vier Ebenen aufwärts), scheitert jetzt aber mit einer verständlichen Meldung samt +Klon-URL statt mit einem Fehler über eine fehlende Projektdatei. + +**Offen:** Git-Submodul oder NuGet-Paket statt Pfadreferenz — für CI und Container relevant, +aber eine Entscheidung über Repo-Grenzen hinweg. + +### AP 4.5 — Egress `SourceIp` ✅ (dokumentiert) +Code ist korrekt; der Unterschied ist zur Laufzeit. Linux verlangt, dass die Adresse +tatsächlich auf einem Interface konfiguriert ist (sonst `EADDRNOTAVAIL`), Windows ist +toleranter. Als Kommentar an der Bind-Stelle hinterlegt. Praktischer Test steht in Phase 6 aus. + +### Neue Tests + +* `PredictalyticsOptionsTests` — `IsPubliclyBound` für Loopback-Schreibweisen, Leerwerte + und externe Adressen; `WebserverUrl`; SSL-Modus im Connection-String; und dass leere + Eingaben bestehende DB-Zugangsdaten **nicht** überschreiben. +* `ApiTokenFilterTests` — richtiger Token passiert, fehlender/falscher/zu kurzer/zu langer + sowie abweichend geschriebener Token werden abgewiesen. + +### ⚠️ Nicht Ende-zu-Ende verifiziert + +Die Entscheidungslogik und der Filter sind einzeln getestet, die Verdrahtung dazwischen +nicht: `StartWebServerAsync` bricht ohne Datenbankkonfiguration vorher ab. Nachzuholen, +sobald die DB-Zugangsdaten wieder hinterlegt sind. + +--- + +## 6a. Phase 4 — ursprüngliche Planung ### AP 4.1 — Kestrel-Binding und Absicherung ⚠️ `UseUrls($"http://localhost:{port}")` (`EmbeddedWebServer.cs:122`) konfigurierbar machen diff --git a/src/Predictalytics.Api/ApiConfiguration.cs b/src/Predictalytics.Api/ApiConfiguration.cs index 39a6fb1..5607213 100644 --- a/src/Predictalytics.Api/ApiConfiguration.cs +++ b/src/Predictalytics.Api/ApiConfiguration.cs @@ -1,4 +1,5 @@ using Predictalytics.Api.Endpoints; +using Predictalytics.Api.Security; using Predictalytics.Infrastructure; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -59,23 +60,47 @@ public static class ApiConfiguration routes.MapCoMovementEndpoints(); routes.MapGet("/api/health", () => Results.Ok(new { Status = "OK", Timestamp = DateTime.UtcNow })); - routes.MapGet("/api/capabilities", (IConfiguration config) => + // Meldet der WebUI den tatsaechlichen Zustand, nicht eine Konfigurationsabsicht: + // ControlState wird beim Mappen der Steuerendpunkte gesetzt. + routes.MapGet("/api/capabilities", () => Results.Ok(new { - var canControl = config.GetValue("ApiSettings:CanControl", true); - var authRequired = config.GetValue("ApiSettings:AuthRequired", false); - return Results.Ok(new { CanControl = canControl, AuthRequired = authRequired }); - }); + CanControl = ControlEndpointsMapped, + AuthRequired = ControlEndpointsRequireToken, + TokenHeader = ControlEndpointsRequireToken ? ApiTokenFilter.HeaderName : null + })); } + /// Ob die Steuerendpunkte ueberhaupt erreichbar sind. + public static bool ControlEndpointsMapped { get; private set; } + + /// Ob sie einen Token verlangen. + public static bool ControlEndpointsRequireToken { get; private set; } + /// /// Registers all Control endpoints that trigger crawls, modifications, or database updates. - /// Excluded from the Public API routing to ensure system security. /// - public static void MapPredictalyticsControlEndpoints(this IEndpointRouteBuilder routes) + /// + /// Wenn gesetzt, verlangt jeder Aufruf den Header + /// mit diesem Wert. Null oder leer bedeutet + /// ungeschuetzt — das ist nur vertretbar, solange Kestrel an localhost gebunden ist. + /// + public static void MapPredictalyticsControlEndpoints(this IEndpointRouteBuilder routes, string? requiredToken = null) { - routes.MapTraderControlEndpoints(); - routes.MapAlertControlEndpoints(); - routes.MapJobEndpoints(); - routes.MapDevEndpoints(); + IEndpointRouteBuilder target = routes; + + if (!string.IsNullOrWhiteSpace(requiredToken)) + { + // Leere Gruppe ohne Praefix: sie dient nur dazu, den Filter an alle + // darin gemappten Endpunkte zu haengen. + target = routes.MapGroup("").AddEndpointFilter(new ApiTokenFilter(requiredToken!)); + ControlEndpointsRequireToken = true; + } + + target.MapTraderControlEndpoints(); + target.MapAlertControlEndpoints(); + target.MapJobEndpoints(); + target.MapDevEndpoints(); + + ControlEndpointsMapped = true; } } diff --git a/src/Predictalytics.Api/Security/ApiTokenFilter.cs b/src/Predictalytics.Api/Security/ApiTokenFilter.cs new file mode 100644 index 0000000..a7810b2 --- /dev/null +++ b/src/Predictalytics.Api/Security/ApiTokenFilter.cs @@ -0,0 +1,47 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Http; + +namespace Predictalytics.Api.Security; + +/// +/// Schuetzt die schreibenden Endpunkte mit einem gemeinsamen Token. +/// +/// Solange Kestrel nur auf localhost lauscht, ist das Betriebssystem die Absicherung. +/// Sobald der Dienst von aussen erreichbar ist, faellt diese weg — dann muss jeder +/// Aufruf, der etwas veraendert, den Header mitschicken. +/// +/// +public sealed class ApiTokenFilter : IEndpointFilter +{ + public const string HeaderName = "X-Predictalytics-Key"; + + private readonly byte[] _expected; + + public ApiTokenFilter(string token) => _expected = Encoding.UTF8.GetBytes(token); + + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var provided = context.HttpContext.Request.Headers[HeaderName].ToString(); + + if (!IsMatch(provided)) + { + return Results.Json( + new { error = "unauthorized", message = $"Header {HeaderName} fehlt oder ist falsch." }, + statusCode: StatusCodes.Status401Unauthorized); + } + + return await next(context); + } + + /// + /// Laufzeitkonstanter Vergleich: ein zeichenweise abbrechender Vergleich verriete + /// ueber die Antwortzeit, wie viele Zeichen bereits stimmen. + /// + private bool IsMatch(string provided) + { + if (string.IsNullOrEmpty(provided)) return false; + var actual = Encoding.UTF8.GetBytes(provided); + return CryptographicOperations.FixedTimeEquals(actual, _expected); + } +} diff --git a/src/Predictalytics.Application.Tests/Hosting/ApiTokenFilterTests.cs b/src/Predictalytics.Application.Tests/Hosting/ApiTokenFilterTests.cs new file mode 100644 index 0000000..0552f9f --- /dev/null +++ b/src/Predictalytics.Application.Tests/Hosting/ApiTokenFilterTests.cs @@ -0,0 +1,64 @@ +using Microsoft.AspNetCore.Http; +using Predictalytics.Api.Security; + +namespace Predictalytics.Application.Tests.Hosting; + +/// +/// Der Filter ist das Einzige, was die schreibenden Endpunkte schuetzt, sobald der +/// Dienst ueber das Netz erreichbar ist. +/// +public class ApiTokenFilterTests +{ + private const string Token = "s3cr3t-token"; + + [Fact] + public async Task Correct_token_passes_through() + { + var (context, next, called) = Build(Token); + var result = await new ApiTokenFilter(Token).InvokeAsync(context, next); + + Assert.True(called.Value); + Assert.Equal("weiter", result); + } + + [Theory] + [InlineData("")] // Header fehlt + [InlineData("falsch")] + [InlineData("s3cr3t-toke")] // ein Zeichen zu kurz + [InlineData("s3cr3t-tokenX")] // ein Zeichen zu lang + [InlineData("S3CR3T-TOKEN")] // Schreibweise muss zaehlen + [InlineData(" s3cr3t-token")] // fuehrender Leerraum + public async Task Wrong_or_missing_token_is_rejected(string provided) + { + var (context, next, called) = Build(provided); + var result = await new ApiTokenFilter(Token).InvokeAsync(context, next); + + Assert.False(called.Value); + Assert.NotNull(result); + Assert.NotEqual("weiter", result); + } + + private static (DefaultEndpointFilterInvocationContext Context, EndpointFilterDelegate Next, StrongBox Called) + Build(string headerValue) + { + var httpContext = new DefaultHttpContext(); + if (headerValue.Length > 0) + { + httpContext.Request.Headers[ApiTokenFilter.HeaderName] = headerValue; + } + + var called = new StrongBox(false); + EndpointFilterDelegate next = _ => + { + called.Value = true; + return ValueTask.FromResult("weiter"); + }; + + return (new DefaultEndpointFilterInvocationContext(httpContext), next, called); + } + + private sealed class StrongBox(T value) + { + public T Value { get; set; } = value; + } +} diff --git a/src/Predictalytics.Application.Tests/Hosting/PredictalyticsOptionsTests.cs b/src/Predictalytics.Application.Tests/Hosting/PredictalyticsOptionsTests.cs new file mode 100644 index 0000000..242037d --- /dev/null +++ b/src/Predictalytics.Application.Tests/Hosting/PredictalyticsOptionsTests.cs @@ -0,0 +1,85 @@ +using Predictalytics.Hosting; + +namespace Predictalytics.Application.Tests.Hosting; + +/// +/// entscheidet, ob die schreibenden +/// API-Endpunkte ueberhaupt bereitgestellt werden und ob Swagger offen liegt. Ein falsches +/// Ergebnis ist deshalb kein Schoenheitsfehler, sondern eine offene Steuerschnittstelle. +/// +public class PredictalyticsOptionsTests +{ + [Theory] + [InlineData("localhost")] + [InlineData("LOCALHOST")] // Schreibweise darf nicht entscheiden + [InlineData("LocalHost")] + [InlineData("127.0.0.1")] + [InlineData("::1")] + [InlineData(" localhost ")] // Leerraum aus dem Eingabefeld + [InlineData("")] // leer = Vorgabe = lokal + [InlineData(" ")] + public void IsPubliclyBound_is_false_for_loopback(string host) + { + var options = new PredictalyticsOptions { WebserverHost = host }; + Assert.False(options.IsPubliclyBound); + } + + [Theory] + [InlineData("0.0.0.0")] + [InlineData("::")] + [InlineData("192.168.178.10")] + [InlineData("10.0.0.5")] + [InlineData("predictalytics.example.com")] + public void IsPubliclyBound_is_true_for_everything_else(string host) + { + var options = new PredictalyticsOptions { WebserverHost = host }; + Assert.True(options.IsPubliclyBound); + } + + [Fact] + public void WebserverUrl_falls_back_to_localhost_when_host_is_blank() + { + var options = new PredictalyticsOptions { WebserverHost = " ", WebserverPort = 5000 }; + Assert.Equal("http://localhost:5000", options.WebserverUrl); + } + + [Fact] + public void WebserverUrl_combines_host_and_port() + { + var options = new PredictalyticsOptions { WebserverHost = "0.0.0.0", WebserverPort = 8080 }; + Assert.Equal("http://0.0.0.0:8080", options.WebserverUrl); + } + + [Fact] + public void ConnectionString_honours_the_configured_ssl_mode() + { + var options = new PredictalyticsOptions + { + DbServer = "db.example.com", + DbName = "predictalytics", + DbUser = "app", + DbPassword = "secret", + DbSslMode = MySqlConnector.MySqlSslMode.Preferred + }; + + var builder = new MySqlConnector.MySqlConnectionStringBuilder(options.ConnectionString); + Assert.Equal(MySqlConnector.MySqlSslMode.Preferred, builder.SslMode); + Assert.Equal("predictalytics", builder.Database); + } + + /// + /// Die Setter ignorieren leere Werte bewusst — sonst wuerde ein versehentlich geleertes + /// Eingabefeld die hinterlegten Zugangsdaten verwerfen. + /// + [Fact] + public void Blank_database_values_do_not_overwrite_existing_ones() + { + var options = new PredictalyticsOptions { DbName = "predictalytics", DbUser = "app" }; + + options.DbName = ""; + options.DbUser = " "; + + Assert.Equal("predictalytics", options.DbName); + Assert.Equal("app", options.DbUser); + } +} diff --git a/src/Predictalytics.Application.Tests/Predictalytics.Application.Tests.csproj b/src/Predictalytics.Application.Tests/Predictalytics.Application.Tests.csproj index 4b5bdf7..507623a 100644 --- a/src/Predictalytics.Application.Tests/Predictalytics.Application.Tests.csproj +++ b/src/Predictalytics.Application.Tests/Predictalytics.Application.Tests.csproj @@ -23,6 +23,7 @@ + diff --git a/src/Predictalytics.Hosting/LoggingSetup.cs b/src/Predictalytics.Hosting/LoggingSetup.cs index 3b86534..512efa8 100644 --- a/src/Predictalytics.Hosting/LoggingSetup.cs +++ b/src/Predictalytics.Hosting/LoggingSetup.cs @@ -17,8 +17,29 @@ public static class LoggingSetup private const string SimpleTemplate = "[{Timestamp:HH:mm:ss}] [{Level:u3}] {Message:lj}{NewLine}{Exception}"; - /// Standard-Logverzeichnis neben der Programmdatei. - public static string DefaultLogDirectory => Path.Combine(AppContext.BaseDirectory, "logs"); + /// Umgebungsvariable, mit der das Logverzeichnis festgelegt werden kann. + public const string LogDirectoryVariable = "PREDICTALYTICS_LOG_DIR"; + + /// + /// Standard-Logverzeichnis: neben den Einstellungen, also + /// %APPDATA%\Predictalytics\logs bzw. ~/.config/Predictalytics/logs. + /// + /// Bewusst nicht mehr neben der Programmdatei: deren Verzeichnis ist unter Linux + /// ueblicherweise nicht beschreibbar, und beim Update wuerde es ueberschrieben. + /// Ueber uebersteuerbar — etwa auf + /// /var/log/predictalytics im systemd-Betrieb. + /// + /// + public static string DefaultLogDirectory + { + get + { + var configured = Environment.GetEnvironmentVariable(LogDirectoryVariable); + return string.IsNullOrWhiteSpace(configured) + ? Path.Combine(PredictalyticsOptions.SettingsDirectory, "logs") + : configured; + } + } /// /// Richtet den globalen Serilog-Logger ein. diff --git a/src/Predictalytics.Hosting/Predictalytics.Hosting.csproj b/src/Predictalytics.Hosting/Predictalytics.Hosting.csproj index f57a7ee..a7874c6 100644 --- a/src/Predictalytics.Hosting/Predictalytics.Hosting.csproj +++ b/src/Predictalytics.Hosting/Predictalytics.Hosting.csproj @@ -36,6 +36,13 @@ + + + + + diff --git a/src/Predictalytics.Hosting/PredictalyticsHost.cs b/src/Predictalytics.Hosting/PredictalyticsHost.cs index cc8c4b3..41230e5 100644 --- a/src/Predictalytics.Hosting/PredictalyticsHost.cs +++ b/src/Predictalytics.Hosting/PredictalyticsHost.cs @@ -194,16 +194,31 @@ public sealed class PredictalyticsHost builder.Services.AddSwaggerGen(c => c.SwaggerDoc("v1", new() { Title = "Predictalytics Analytics API", Version = "v1" })); - var allowedOrigins = builder.Configuration.GetSection("ApiSettings:AllowedOrigins").Get() - ?? new[] { Options.WebserverUrl }; + // Die eigene Bind-URL ist immer erlaubt; zusaetzliche Herkuenfte koennen + // ueber appsettings.json ergaenzt werden. + var allowedOrigins = (builder.Configuration.GetSection("ApiSettings:AllowedOrigins").Get() ?? []) + .Append(Options.WebserverUrl) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); builder.Services.AddCors(o => o.AddDefaultPolicy(p => p.WithOrigins(allowedOrigins).AllowAnyMethod().AllowAnyHeader())); builder.Host.UseSerilog(); var app = builder.Build(); app.UseCors(); - app.UseSwagger(); - app.UseSwaggerUI(); + + // Swagger legt die gesamte API-Oberflaeche offen — das ist im lokalen + // Betrieb praktisch und bei einer Bindung ans Netz eine Preisgabe. + if (!Options.IsPubliclyBound) + { + app.UseSwagger(); + app.UseSwaggerUI(); + } + else + { + Log.Information("Swagger ist deaktiviert, weil der Webserver über {Host} erreichbar ist.", + Options.WebserverHost); + } var wwwrootPath = FindWwwrootPath(); if (wwwrootPath != null) @@ -220,7 +235,7 @@ public sealed class PredictalyticsHost // Single shared registration — see ApiConfiguration.MapPredictalyticsEndpoints. // Do NOT map endpoints individually here. app.MapPredictalyticsReadEndpoints(); - app.MapPredictalyticsControlEndpoints(); + MapControlEndpointsGuarded(app); await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(app.Services, Options.DbConnectionDebug); @@ -379,6 +394,43 @@ public sealed class PredictalyticsHost // Intern // ───────────────────────────────────────────────────────────────────────── + /// + /// Entscheidet, ob und wie die schreibenden Endpunkte bereitgestellt werden. + /// + /// Bei einer Bindung an localhost schuetzt das Betriebssystem — dort bleibt es beim + /// bisherigen Verhalten. Sobald der Dienst ueber das Netz erreichbar ist, gilt: + /// ohne Token keine Steuerbefehle. Bewusst so herum, damit eine unbedachte Umstellung + /// der Bind-Adresse nicht stillschweigend Jobs, Trader-Steuerung und die + /// Dev-Endpunkte oeffnet. + /// + /// + private void MapControlEndpointsGuarded(WebApplication app) + { + var token = Options.ApiControlToken?.Trim(); + var hasToken = !string.IsNullOrWhiteSpace(token); + + if (!Options.IsPubliclyBound) + { + app.MapPredictalyticsControlEndpoints(hasToken ? token : null); + Log.Information("Steuerendpunkte aktiv ({Protection}) — Bindung an {Host}.", + hasToken ? "tokengeschützt" : "ungeschützt, nur lokal erreichbar", Options.WebserverHost); + return; + } + + if (!hasToken) + { + Log.Error("🔒 Der Webserver ist an {Host} gebunden und damit über das Netz erreichbar, " + + "aber es ist kein API-Token gesetzt. Die schreibenden Endpunkte (Jobs, " + + "Trader-Steuerung, Dev) werden deshalb NICHT bereitgestellt. Token in den " + + "Einstellungen hinterlegen oder wieder an localhost binden.", Options.WebserverHost); + return; + } + + app.MapPredictalyticsControlEndpoints(token); + Log.Warning("🔒 Webserver ist über {Host} erreichbar. Steuerendpunkte verlangen den Header {Header}.", + Options.WebserverHost, Predictalytics.Api.Security.ApiTokenFilter.HeaderName); + } + private static string MaskPassword(string? connectionString) => System.Text.RegularExpressions.Regex.Replace(connectionString ?? "NULL", "Password=[^;]+", "Password=****"); diff --git a/src/Predictalytics.Hosting/PredictalyticsOptions.cs b/src/Predictalytics.Hosting/PredictalyticsOptions.cs index 79fc389..4b502f7 100644 --- a/src/Predictalytics.Hosting/PredictalyticsOptions.cs +++ b/src/Predictalytics.Hosting/PredictalyticsOptions.cs @@ -37,6 +37,32 @@ public sealed class PredictalyticsOptions [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; } = ""; + + /// + /// Ob die Bind-Adresse den Dienst ueber das Netz erreichbar macht. Bei localhost + /// uebernimmt das Betriebssystem die Absicherung, sonst nicht mehr. + /// + [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"; + } + } + // ─── Egress ─── private string _egressChannelsText = ""; diff --git a/src/Predictalytics.Hosting/RuntimeSetup.cs b/src/Predictalytics.Hosting/RuntimeSetup.cs new file mode 100644 index 0000000..e3c5dfb --- /dev/null +++ b/src/Predictalytics.Hosting/RuntimeSetup.cs @@ -0,0 +1,33 @@ +using System.Globalization; + +namespace Predictalytics.Hosting; + +/// +/// Laufzeitvorgaben, die vor allem anderen gelten muessen — unabhaengig davon, +/// ob die Anwendung mit Oberflaeche oder headless startet. +/// +public static class RuntimeSetup +{ + /// + /// Legt die invariante Kultur fuer alle Threads fest. + /// + /// Die Anwendung verarbeitet ausschliesslich Maschinendaten: Preise, Groessen und + /// Zeitstempel der Plattform-APIs sowie SQL. Wuerde die Systemlocale durchschlagen, + /// haette derselbe Datensatz auf einem deutschen und einem englischen System + /// unterschiedliche Werte — unter Linux faellt das besonders leicht auf, weil dort + /// die Locale ueblicherweise ausdruecklich gesetzt wird. + /// + /// + /// Bewusst nicht ueber InvariantGlobalization im Projekt: das aendert + /// zusaetzlich die Semantik von Zeichenkettenvergleichen und waere ein groesserer + /// Eingriff als noetig. + /// + /// + public static void UseInvariantCulture() + { + CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture; + CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture; + CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; + CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture; + } +} diff --git a/src/Predictalytics.Infrastructure/Services/EgressPoolService.cs b/src/Predictalytics.Infrastructure/Services/EgressPoolService.cs index f6ce263..6d6513b 100644 --- a/src/Predictalytics.Infrastructure/Services/EgressPoolService.cs +++ b/src/Predictalytics.Infrastructure/Services/EgressPoolService.cs @@ -73,6 +73,10 @@ public class EgressPoolService : IEgressPoolService, IDisposable handler.ConnectCallback = async (context, cancellationToken) => { var socket = new Socket(ip.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + // Linux ist hier strenger als Windows: die Adresse muss tatsaechlich + // auf einem Interface konfiguriert sein, sonst schlaegt Bind mit + // EADDRNOTAVAIL fehl. Fuer nicht-lokale Adressen braeuchte es + // IP_FREEBIND bzw. CAP_NET_ADMIN. socket.Bind(new IPEndPoint(ip, 0)); try { diff --git a/src/Predictalytics.Shell/App.axaml.cs b/src/Predictalytics.Shell/App.axaml.cs index ddc2270..4ac5fcb 100644 --- a/src/Predictalytics.Shell/App.axaml.cs +++ b/src/Predictalytics.Shell/App.axaml.cs @@ -42,6 +42,8 @@ public partial class App : global::Avalonia.Application private async Task StartupAsync(IClassicDesktopStyleApplicationLifetime desktop) { + RuntimeSetup.UseInvariantCulture(); + // Einstellungen zuerst: das Fehler-Reporting braucht das Token, bevor die // Lizenzschranke laeuft — sonst taucht eine fehlgeschlagene Aktivierung // nirgends im Fehler-Stream auf. diff --git a/src/Predictalytics.Shell/Services/HeadlessRunner.cs b/src/Predictalytics.Shell/Services/HeadlessRunner.cs index 2da17a7..7df2653 100644 --- a/src/Predictalytics.Shell/Services/HeadlessRunner.cs +++ b/src/Predictalytics.Shell/Services/HeadlessRunner.cs @@ -20,6 +20,8 @@ internal static class HeadlessRunner public static async Task RunAsync() { + RuntimeSetup.UseInvariantCulture(); + var options = PredictalyticsOptions.Load(); // Kein Terminal-Sink: Ausgabe geht auf die Konsole und in die Logdateien. diff --git a/src/Predictalytics.Shell/Views/MainWindow.axaml b/src/Predictalytics.Shell/Views/MainWindow.axaml index 69c55ce..089433a 100644 --- a/src/Predictalytics.Shell/Views/MainWindow.axaml +++ b/src/Predictalytics.Shell/Views/MainWindow.axaml @@ -118,6 +118,14 @@ Text="'localhost' = nur lokal erreichbar. '0.0.0.0' macht die API von außen erreichbar — dann unbedingt absichern." /> + + + + + + +