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:
@@ -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 ⚠️
|
### AP 4.1 — Kestrel-Binding und Absicherung ⚠️
|
||||||
`UseUrls($"http://localhost:{port}")` (`EmbeddedWebServer.cs:122`) konfigurierbar machen
|
`UseUrls($"http://localhost:{port}")` (`EmbeddedWebServer.cs:122`) konfigurierbar machen
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Predictalytics.Api.Endpoints;
|
using Predictalytics.Api.Endpoints;
|
||||||
|
using Predictalytics.Api.Security;
|
||||||
using Predictalytics.Infrastructure;
|
using Predictalytics.Infrastructure;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
@@ -59,23 +60,47 @@ public static class ApiConfiguration
|
|||||||
routes.MapCoMovementEndpoints();
|
routes.MapCoMovementEndpoints();
|
||||||
routes.MapGet("/api/health", () => Results.Ok(new { Status = "OK", Timestamp = DateTime.UtcNow }));
|
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);
|
CanControl = ControlEndpointsMapped,
|
||||||
var authRequired = config.GetValue<bool>("ApiSettings:AuthRequired", false);
|
AuthRequired = ControlEndpointsRequireToken,
|
||||||
return Results.Ok(new { CanControl = canControl, AuthRequired = authRequired });
|
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>
|
/// <summary>
|
||||||
/// Registers all Control endpoints that trigger crawls, modifications, or database updates.
|
/// Registers all Control endpoints that trigger crawls, modifications, or database updates.
|
||||||
/// Excluded from the Public API routing to ensure system security.
|
|
||||||
/// </summary>
|
/// </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();
|
IEndpointRouteBuilder target = routes;
|
||||||
routes.MapAlertControlEndpoints();
|
|
||||||
routes.MapJobEndpoints();
|
if (!string.IsNullOrWhiteSpace(requiredToken))
|
||||||
routes.MapDevEndpoints();
|
{
|
||||||
|
// 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.Domain\Predictalytics.Domain.csproj" />
|
||||||
<ProjectReference Include="..\Predictalytics.Infrastructure\Predictalytics.Infrastructure.csproj" />
|
<ProjectReference Include="..\Predictalytics.Infrastructure\Predictalytics.Infrastructure.csproj" />
|
||||||
<ProjectReference Include="..\Predictalytics.Worker\Predictalytics.Worker.csproj" />
|
<ProjectReference Include="..\Predictalytics.Worker\Predictalytics.Worker.csproj" />
|
||||||
|
<ProjectReference Include="..\Predictalytics.Hosting\Predictalytics.Hosting.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -17,8 +17,29 @@ public static class LoggingSetup
|
|||||||
private const string SimpleTemplate =
|
private const string SimpleTemplate =
|
||||||
"[{Timestamp:HH:mm:ss}] [{Level:u3}] {Message:lj}{NewLine}{Exception}";
|
"[{Timestamp:HH:mm:ss}] [{Level:u3}] {Message:lj}{NewLine}{Exception}";
|
||||||
|
|
||||||
/// <summary>Standard-Logverzeichnis neben der Programmdatei.</summary>
|
/// <summary>Umgebungsvariable, mit der das Logverzeichnis festgelegt werden kann.</summary>
|
||||||
public static string DefaultLogDirectory => Path.Combine(AppContext.BaseDirectory, "logs");
|
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>
|
/// <summary>
|
||||||
/// Richtet den globalen Serilog-Logger ein.
|
/// Richtet den globalen Serilog-Logger ein.
|
||||||
|
|||||||
@@ -36,6 +36,13 @@
|
|||||||
<ProjectReference Include="..\..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.Client.csproj" />
|
<ProjectReference Include="..\..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.Client.csproj" />
|
||||||
</ItemGroup>
|
</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)
|
<!-- Erzeugt Predictalytics.Hosting.BuildInfo (Version, Git-Commit, Build-Datum, Kanal)
|
||||||
zur Uebersetzungszeit aus <Version> und dem Git-Stand. -->
|
zur Uebersetzungszeit aus <Version> und dem Git-Stand. -->
|
||||||
<Import Project="..\..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.BuildInfo.targets" />
|
<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",
|
builder.Services.AddSwaggerGen(c => c.SwaggerDoc("v1",
|
||||||
new() { Title = "Predictalytics Analytics API", Version = "v1" }));
|
new() { Title = "Predictalytics Analytics API", Version = "v1" }));
|
||||||
|
|
||||||
var allowedOrigins = builder.Configuration.GetSection("ApiSettings:AllowedOrigins").Get<string[]>()
|
// Die eigene Bind-URL ist immer erlaubt; zusaetzliche Herkuenfte koennen
|
||||||
?? new[] { Options.WebserverUrl };
|
// 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 =>
|
builder.Services.AddCors(o => o.AddDefaultPolicy(p =>
|
||||||
p.WithOrigins(allowedOrigins).AllowAnyMethod().AllowAnyHeader()));
|
p.WithOrigins(allowedOrigins).AllowAnyMethod().AllowAnyHeader()));
|
||||||
builder.Host.UseSerilog();
|
builder.Host.UseSerilog();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
app.UseCors();
|
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();
|
var wwwrootPath = FindWwwrootPath();
|
||||||
if (wwwrootPath != null)
|
if (wwwrootPath != null)
|
||||||
@@ -220,7 +235,7 @@ public sealed class PredictalyticsHost
|
|||||||
// Single shared registration — see ApiConfiguration.MapPredictalyticsEndpoints.
|
// Single shared registration — see ApiConfiguration.MapPredictalyticsEndpoints.
|
||||||
// Do NOT map endpoints individually here.
|
// Do NOT map endpoints individually here.
|
||||||
app.MapPredictalyticsReadEndpoints();
|
app.MapPredictalyticsReadEndpoints();
|
||||||
app.MapPredictalyticsControlEndpoints();
|
MapControlEndpointsGuarded(app);
|
||||||
|
|
||||||
await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(app.Services, Options.DbConnectionDebug);
|
await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(app.Services, Options.DbConnectionDebug);
|
||||||
|
|
||||||
@@ -379,6 +394,43 @@ public sealed class PredictalyticsHost
|
|||||||
// Intern
|
// 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) =>
|
private static string MaskPassword(string? connectionString) =>
|
||||||
System.Text.RegularExpressions.Regex.Replace(connectionString ?? "NULL", "Password=[^;]+", "Password=****");
|
System.Text.RegularExpressions.Regex.Replace(connectionString ?? "NULL", "Password=[^;]+", "Password=****");
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,32 @@ public sealed class PredictalyticsOptions
|
|||||||
[DefaultValue(false)]
|
[DefaultValue(false)]
|
||||||
public bool DbConnectionDebug { get; set; } = 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 ───
|
// ─── Egress ───
|
||||||
|
|
||||||
private string _egressChannelsText = "";
|
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) =>
|
handler.ConnectCallback = async (context, cancellationToken) =>
|
||||||
{
|
{
|
||||||
var socket = new Socket(ip.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
|
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));
|
socket.Bind(new IPEndPoint(ip, 0));
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ public partial class App : global::Avalonia.Application
|
|||||||
|
|
||||||
private async Task StartupAsync(IClassicDesktopStyleApplicationLifetime desktop)
|
private async Task StartupAsync(IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
|
RuntimeSetup.UseInvariantCulture();
|
||||||
|
|
||||||
// Einstellungen zuerst: das Fehler-Reporting braucht das Token, bevor die
|
// Einstellungen zuerst: das Fehler-Reporting braucht das Token, bevor die
|
||||||
// Lizenzschranke laeuft — sonst taucht eine fehlgeschlagene Aktivierung
|
// Lizenzschranke laeuft — sonst taucht eine fehlgeschlagene Aktivierung
|
||||||
// nirgends im Fehler-Stream auf.
|
// nirgends im Fehler-Stream auf.
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ internal static class HeadlessRunner
|
|||||||
|
|
||||||
public static async Task<int> RunAsync()
|
public static async Task<int> RunAsync()
|
||||||
{
|
{
|
||||||
|
RuntimeSetup.UseInvariantCulture();
|
||||||
|
|
||||||
var options = PredictalyticsOptions.Load();
|
var options = PredictalyticsOptions.Load();
|
||||||
|
|
||||||
// Kein Terminal-Sink: Ausgabe geht auf die Konsole und in die Logdateien.
|
// 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." />
|
Text="'localhost' = nur lokal erreichbar. '0.0.0.0' macht die API von außen erreichbar — dann unbedingt absichern." />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</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,*">
|
<Grid ColumnDefinitions="200,*">
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="Datenbank-Debug" />
|
<TextBlock Grid.Column="0" Classes="label" Text="Datenbank-Debug" />
|
||||||
<CheckBox Grid.Column="1" IsChecked="{Binding DbConnectionDebug}"
|
<CheckBox Grid.Column="1" IsChecked="{Binding DbConnectionDebug}"
|
||||||
|
|||||||
Reference in New Issue
Block a user