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 ⚠️
|
||||
`UseUrls($"http://localhost:{port}")` (`EmbeddedWebServer.cs:122`) konfigurierbar machen
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user