Phase 4: Steuerendpunkte absichern, Kultur und Log-Pfad plattformfest

Die schreibenden API-Endpunkte waren ungeschuetzt. /api/capabilities meldete
zwar CanControl und AuthRequired aus der Konfiguration, erzwungen wurde davon
nichts: MapPredictalyticsControlEndpoints mappte Jobs, Trader-Steuerung und
Dev-Endpunkte bedingungslos.

Neu entscheidet die Bind-Adresse:
- localhost ohne Token: aktiv wie bisher, das Betriebssystem schuetzt
- localhost mit Token: aktiv, Token wird verlangt
- extern ohne Token: Endpunkte werden gar nicht gemappt, dazu ein Log.Error
- extern mit Token: Endpunkte verlangen X-Predictalytics-Key

Bewusst fail-safe herum, damit eine unbedachte Umstellung der Bind-Adresse
nicht stillschweigend die Steuerschnittstelle oeffnet. ApiTokenFilter
vergleicht laufzeitkonstant ueber CryptographicOperations.FixedTimeEquals.
Swagger ist bei externer Bindung abgeschaltet. /api/capabilities meldet jetzt
den tatsaechlichen Zustand statt einer Konfigurationsabsicht.

Weiter:
- RuntimeSetup.UseInvariantCulture als Erstes in beiden Startpfaden, damit
  die Systemlocale nicht auf Zahlen und Zeitstempel durchschlaegt
- Logverzeichnis neben den Einstellungen statt neben der Programmdatei,
  ueber PREDICTALYTICS_LOG_DIR uebersteuerbar
- CORS erlaubt immer die eigene Bind-URL, zusaetzliche Herkuenfte weiter
  ueber appsettings.json
- fehlendes Schwester-Repo Deploymentcenter scheitert mit verstaendlicher
  Meldung samt Klon-URL statt mit einem Fehler ueber eine fehlende csproj
- EgressPoolService: Linux-Verhalten von Socket.Bind kommentiert

24 neue Tests: IsPubliclyBound fuer Loopback-Schreibweisen, Leerwerte und
externe Adressen; dass leere Eingaben bestehende DB-Zugangsdaten nicht
ueberschreiben; und der Token-Filter gegen fehlende, falsche, zu kurze,
zu lange und abweichend geschriebene Token.

Build: 0 Fehler, 8 Warnungen (alle vorbestehend).
Tests: 124 bestanden, 0 Fehler, 1 uebersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-08-08 21:13:49 +02:00
co-authored by Claude Opus 5
parent 0e01886640
commit b175df6d3e
15 changed files with 464 additions and 19 deletions
+69 -1
View File
@@ -544,7 +544,75 @@ Leitfaden liegt unter `Deploymentcenter/docs/WATCHDOG_INTEGRATION_GUIDE.md`.
---
## 6. Phase 4 — Aufräumen und Absichern (11,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
+36 -11
View File
@@ -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<bool>("ApiSettings:CanControl", true);
var authRequired = config.GetValue<bool>("ApiSettings:AuthRequired", false);
return Results.Ok(new { CanControl = canControl, AuthRequired = authRequired });
});
CanControl = ControlEndpointsMapped,
AuthRequired = ControlEndpointsRequireToken,
TokenHeader = ControlEndpointsRequireToken ? ApiTokenFilter.HeaderName : null
}));
}
/// <summary>Ob die Steuerendpunkte ueberhaupt erreichbar sind.</summary>
public static bool ControlEndpointsMapped { get; private set; }
/// <summary>Ob sie einen Token verlangen.</summary>
public static bool ControlEndpointsRequireToken { get; private set; }
/// <summary>
/// Registers all Control endpoints that trigger crawls, modifications, or database updates.
/// Excluded from the Public API routing to ensure system security.
/// </summary>
public static void MapPredictalyticsControlEndpoints(this IEndpointRouteBuilder routes)
/// <param name="requiredToken">
/// Wenn gesetzt, verlangt jeder Aufruf den Header
/// <see cref="ApiTokenFilter.HeaderName"/> mit diesem Wert. Null oder leer bedeutet
/// ungeschuetzt — das ist nur vertretbar, solange Kestrel an localhost gebunden ist.
/// </param>
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;
}
}
@@ -0,0 +1,47 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Http;
namespace Predictalytics.Api.Security;
/// <summary>
/// Schuetzt die schreibenden Endpunkte mit einem gemeinsamen Token.
/// <para>
/// 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 <see cref="HeaderName"/> mitschicken.
/// </para>
/// </summary>
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<object?> 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);
}
/// <summary>
/// Laufzeitkonstanter Vergleich: ein zeichenweise abbrechender Vergleich verriete
/// ueber die Antwortzeit, wie viele Zeichen bereits stimmen.
/// </summary>
private bool IsMatch(string provided)
{
if (string.IsNullOrEmpty(provided)) return false;
var actual = Encoding.UTF8.GetBytes(provided);
return CryptographicOperations.FixedTimeEquals(actual, _expected);
}
}
@@ -0,0 +1,64 @@
using Microsoft.AspNetCore.Http;
using Predictalytics.Api.Security;
namespace Predictalytics.Application.Tests.Hosting;
/// <summary>
/// Der Filter ist das Einzige, was die schreibenden Endpunkte schuetzt, sobald der
/// Dienst ueber das Netz erreichbar ist.
/// </summary>
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<bool> Called)
Build(string headerValue)
{
var httpContext = new DefaultHttpContext();
if (headerValue.Length > 0)
{
httpContext.Request.Headers[ApiTokenFilter.HeaderName] = headerValue;
}
var called = new StrongBox<bool>(false);
EndpointFilterDelegate next = _ =>
{
called.Value = true;
return ValueTask.FromResult<object?>("weiter");
};
return (new DefaultEndpointFilterInvocationContext(httpContext), next, called);
}
private sealed class StrongBox<T>(T value)
{
public T Value { get; set; } = value;
}
}
@@ -0,0 +1,85 @@
using Predictalytics.Hosting;
namespace Predictalytics.Application.Tests.Hosting;
/// <summary>
/// <see cref="PredictalyticsOptions.IsPubliclyBound"/> 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.
/// </summary>
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);
}
/// <summary>
/// Die Setter ignorieren leere Werte bewusst — sonst wuerde ein versehentlich geleertes
/// Eingabefeld die hinterlegten Zugangsdaten verwerfen.
/// </summary>
[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);
}
}
@@ -23,6 +23,7 @@
<ProjectReference Include="..\Predictalytics.Domain\Predictalytics.Domain.csproj" />
<ProjectReference Include="..\Predictalytics.Infrastructure\Predictalytics.Infrastructure.csproj" />
<ProjectReference Include="..\Predictalytics.Worker\Predictalytics.Worker.csproj" />
<ProjectReference Include="..\Predictalytics.Hosting\Predictalytics.Hosting.csproj" />
</ItemGroup>
</Project>
+23 -2
View File
@@ -17,8 +17,29 @@ public static class LoggingSetup
private const string SimpleTemplate =
"[{Timestamp:HH:mm:ss}] [{Level:u3}] {Message:lj}{NewLine}{Exception}";
/// <summary>Standard-Logverzeichnis neben der Programmdatei.</summary>
public static string DefaultLogDirectory => Path.Combine(AppContext.BaseDirectory, "logs");
/// <summary>Umgebungsvariable, mit der das Logverzeichnis festgelegt werden kann.</summary>
public const string LogDirectoryVariable = "PREDICTALYTICS_LOG_DIR";
/// <summary>
/// Standard-Logverzeichnis: neben den Einstellungen, also
/// <c>%APPDATA%\Predictalytics\logs</c> bzw. <c>~/.config/Predictalytics/logs</c>.
/// <para>
/// Bewusst nicht mehr neben der Programmdatei: deren Verzeichnis ist unter Linux
/// ueblicherweise nicht beschreibbar, und beim Update wuerde es ueberschrieben.
/// Ueber <see cref="LogDirectoryVariable"/> uebersteuerbar — etwa auf
/// <c>/var/log/predictalytics</c> im systemd-Betrieb.
/// </para>
/// </summary>
public static string DefaultLogDirectory
{
get
{
var configured = Environment.GetEnvironmentVariable(LogDirectoryVariable);
return string.IsNullOrWhiteSpace(configured)
? Path.Combine(PredictalyticsOptions.SettingsDirectory, "logs")
: configured;
}
}
/// <summary>
/// Richtet den globalen Serilog-Logger ein.
@@ -36,6 +36,13 @@
<ProjectReference Include="..\..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.Client.csproj" />
</ItemGroup>
<!-- Ohne das Schwester-Repo scheitert der Build sonst mit einer Meldung ueber eine
fehlende Projektdatei, aus der niemand die eigentliche Ursache ablesen kann. -->
<Target Name="CheckDeploymentcenterCheckout" BeforeTargets="PrepareForBuild">
<Error Condition="!Exists('$(MSBuildThisFileDirectory)..\..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.Client.csproj')"
Text="Das Schwester-Repo 'Deploymentcenter' wurde nicht gefunden. Es muss neben dem Predictalytics-Checkout liegen (erwartet: $(MSBuildThisFileDirectory)..\..\..\..\Deploymentcenter). Klonen von http://192.168.178.10:8418/Richard/Deploymentcenter.git" />
</Target>
<!-- Erzeugt Predictalytics.Hosting.BuildInfo (Version, Git-Commit, Build-Datum, Kanal)
zur Uebersetzungszeit aus <Version> und dem Git-Stand. -->
<Import Project="..\..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.BuildInfo.targets" />
@@ -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<string[]>()
?? 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<string[]>() ?? [])
.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
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// Entscheidet, ob und wie die schreibenden Endpunkte bereitgestellt werden.
/// <para>
/// 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.
/// </para>
/// </summary>
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=****");
@@ -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; } = "";
/// <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";
}
}
// ─── Egress ───
private string _egressChannelsText = "";
@@ -0,0 +1,33 @@
using System.Globalization;
namespace Predictalytics.Hosting;
/// <summary>
/// Laufzeitvorgaben, die vor allem anderen gelten muessen — unabhaengig davon,
/// ob die Anwendung mit Oberflaeche oder headless startet.
/// </summary>
public static class RuntimeSetup
{
/// <summary>
/// Legt die invariante Kultur fuer alle Threads fest.
/// <para>
/// 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.
/// </para>
/// <para>
/// Bewusst nicht ueber <c>InvariantGlobalization</c> im Projekt: das aendert
/// zusaetzlich die Semantik von Zeichenkettenvergleichen und waere ein groesserer
/// Eingriff als noetig.
/// </para>
/// </summary>
public static void UseInvariantCulture()
{
CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture;
CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture;
}
}
@@ -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
{
+2
View File
@@ -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.
@@ -20,6 +20,8 @@ internal static class HeadlessRunner
public static async Task<int> RunAsync()
{
RuntimeSetup.UseInvariantCulture();
var options = PredictalyticsOptions.Load();
// Kein Terminal-Sink: Ausgabe geht auf die Konsole und in die Logdateien.
@@ -118,6 +118,14 @@
Text="'localhost' = nur lokal erreichbar. '0.0.0.0' macht die API von außen erreichbar — dann unbedingt absichern." />
</StackPanel>
</Grid>
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="API-Token" />
<StackPanel Grid.Column="1">
<TextBox Text="{Binding ApiControlToken}" PasswordChar="•" />
<TextBlock Classes="hint"
Text="Schützt die schreibenden Endpunkte (Jobs, Trader-Steuerung, Dev) über den Header X-Predictalytics-Key. Bei einer Bind-Adresse außerhalb von localhost Pflicht — ohne Token werden diese Endpunkte gar nicht bereitgestellt." />
</StackPanel>
</Grid>
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="Datenbank-Debug" />
<CheckBox Grid.Column="1" IsChecked="{Binding DbConnectionDebug}"