Refactor code structure for improved readability and maintainability; optimize performance in key functions.
This commit is contained in:
43
src/Gateway/AGENTS.md
Normal file
43
src/Gateway/AGENTS.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# AGENTS - Gateway WebService
|
||||
|
||||
## Mission
|
||||
- Provide a single HTTP/HTTPS ingress that authenticates callers, routes to microservices over the Router binary protocol, and aggregates OpenAPI and health signals.
|
||||
|
||||
## Roles
|
||||
- Backend engineer (.NET 10, C# preview) for Gateway host, routing, and transport integration.
|
||||
- QA engineer (xUnit, WebApplicationFactory, deterministic fixtures).
|
||||
- Docs maintainer for gateway module/runbook updates when behavior changes.
|
||||
|
||||
## Required Reading
|
||||
- docs/README.md
|
||||
- docs/07_HIGH_LEVEL_ARCHITECTURE.md
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
- docs/modules/gateway/architecture.md
|
||||
- docs/modules/gateway/openapi.md
|
||||
- docs/modules/router/architecture.md
|
||||
- docs/modules/authority/architecture.md
|
||||
- docs/product-advisories/archived/2025-12-21-reference-architecture/20-Dec-2025 - Stella Ops Reference Architecture.md
|
||||
|
||||
## Working Directory & Boundaries
|
||||
- Primary scope: src/Gateway/**
|
||||
- Tests: src/Gateway/__Tests/**
|
||||
- Allowed shared libraries: src/__Libraries/StellaOps.Router.Gateway, src/__Libraries/StellaOps.Router.Transport.Tcp, src/__Libraries/StellaOps.Router.Transport.Tls, src/__Libraries/StellaOps.Configuration, src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration
|
||||
- Avoid cross-module edits unless the sprint explicitly calls them out.
|
||||
|
||||
## Determinism & Offline Rules
|
||||
- Deterministic ordering for routing selections, OpenAPI output, and metrics labels.
|
||||
- No network calls in tests; use in-memory transports or local fixtures.
|
||||
- Use UTC timestamps and stable correlation IDs.
|
||||
|
||||
## Configuration & Security
|
||||
- Use StellaOps.Configuration defaults and environment prefix GATEWAY_.
|
||||
- Validate options on startup; fail fast on invalid TLS/auth settings.
|
||||
- Do not log secrets or raw tokens; redact or hash where needed.
|
||||
|
||||
## Testing Expectations
|
||||
- Unit tests for routing decisions, transport client, OpenAPI caching, and options validation.
|
||||
- Integration tests using Router.Transport.InMemory to validate request routing and streaming.
|
||||
|
||||
## Workflow
|
||||
- Update sprint status in docs/implplan/SPRINT_*.md when starting/finishing work.
|
||||
- If blocked by missing contracts or docs, mark the task BLOCKED in the sprint and record in Decisions & Risks.
|
||||
@@ -0,0 +1,99 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Gateway;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Authorization;
|
||||
|
||||
public sealed class AuthorizationMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly IEffectiveClaimsStore _claimsStore;
|
||||
private readonly ILogger<AuthorizationMiddleware> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public AuthorizationMiddleware(
|
||||
RequestDelegate next,
|
||||
IEffectiveClaimsStore claimsStore,
|
||||
ILogger<AuthorizationMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_claimsStore = claimsStore;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
if (!context.Items.TryGetValue(RouterHttpContextKeys.EndpointDescriptor, out var endpointObj) ||
|
||||
endpointObj is not EndpointDescriptor endpoint)
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var effectiveClaims = _claimsStore.GetEffectiveClaims(
|
||||
endpoint.ServiceName,
|
||||
endpoint.Method,
|
||||
endpoint.Path);
|
||||
|
||||
if (effectiveClaims.Count == 0)
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var required in effectiveClaims)
|
||||
{
|
||||
var userClaims = context.User.Claims;
|
||||
var hasClaim = required.Value == null
|
||||
? userClaims.Any(c => c.Type == required.Type)
|
||||
: userClaims.Any(c => c.Type == required.Type && c.Value == required.Value);
|
||||
|
||||
if (!hasClaim)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Authorization failed for {Method} {Path}: user lacks claim {ClaimType}={ClaimValue}",
|
||||
endpoint.Method,
|
||||
endpoint.Path,
|
||||
required.Type,
|
||||
required.Value ?? "(any)");
|
||||
|
||||
await WriteForbiddenAsync(context, endpoint, required);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
private static Task WriteForbiddenAsync(
|
||||
HttpContext context,
|
||||
EndpointDescriptor endpoint,
|
||||
ClaimRequirement required)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
context.Response.ContentType = "application/json; charset=utf-8";
|
||||
|
||||
var payload = new AuthorizationFailureResponse(
|
||||
Error: "forbidden",
|
||||
Message: "Authorization failed: missing required claim",
|
||||
RequiredClaimType: required.Type,
|
||||
RequiredClaimValue: required.Value,
|
||||
Service: endpoint.ServiceName,
|
||||
Version: endpoint.Version);
|
||||
|
||||
return JsonSerializer.SerializeAsync(context.Response.Body, payload, JsonOptions, context.RequestAborted);
|
||||
}
|
||||
|
||||
private sealed record AuthorizationFailureResponse(
|
||||
string Error,
|
||||
string Message,
|
||||
string RequiredClaimType,
|
||||
string? RequiredClaimValue,
|
||||
string Service,
|
||||
string Version);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Authorization;
|
||||
|
||||
public sealed class EffectiveClaimsStore : IEffectiveClaimsStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> _microserviceClaims = new();
|
||||
private readonly ConcurrentDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> _authorityClaims = new();
|
||||
private readonly ILogger<EffectiveClaimsStore> _logger;
|
||||
|
||||
public EffectiveClaimsStore(ILogger<EffectiveClaimsStore> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public IReadOnlyList<ClaimRequirement> GetEffectiveClaims(string serviceName, string method, string path)
|
||||
{
|
||||
var key = EndpointKey.Create(serviceName, method, path);
|
||||
|
||||
if (_authorityClaims.TryGetValue(key, out var authorityClaims))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Using Authority claims for {Endpoint}: {ClaimCount} claims",
|
||||
key,
|
||||
authorityClaims.Count);
|
||||
return authorityClaims;
|
||||
}
|
||||
|
||||
if (_microserviceClaims.TryGetValue(key, out var msClaims))
|
||||
{
|
||||
return msClaims;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public void UpdateFromMicroservice(string serviceName, IReadOnlyList<EndpointDescriptor> endpoints)
|
||||
{
|
||||
foreach (var endpoint in endpoints)
|
||||
{
|
||||
var key = EndpointKey.Create(serviceName, endpoint.Method, endpoint.Path);
|
||||
var claims = endpoint.RequiringClaims ?? [];
|
||||
|
||||
if (claims.Count > 0)
|
||||
{
|
||||
_microserviceClaims[key] = claims;
|
||||
_logger.LogDebug(
|
||||
"Registered {ClaimCount} claims from microservice for {Endpoint}",
|
||||
claims.Count,
|
||||
key);
|
||||
}
|
||||
else
|
||||
{
|
||||
_microserviceClaims.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateFromAuthority(IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> overrides)
|
||||
{
|
||||
_authorityClaims.Clear();
|
||||
|
||||
foreach (var (key, claims) in overrides)
|
||||
{
|
||||
if (claims.Count > 0)
|
||||
{
|
||||
_authorityClaims[key] = claims;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Updated Authority claims: {EndpointCount} endpoints with overrides",
|
||||
overrides.Count);
|
||||
}
|
||||
|
||||
public void RemoveService(string serviceName)
|
||||
{
|
||||
var normalizedServiceName = serviceName.ToLowerInvariant();
|
||||
var keysToRemove = _microserviceClaims.Keys
|
||||
.Where(k => k.ServiceName == normalizedServiceName)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in keysToRemove)
|
||||
{
|
||||
_microserviceClaims.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Removed {Count} endpoint claims for service {ServiceName}",
|
||||
keysToRemove.Count,
|
||||
serviceName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Authorization;
|
||||
|
||||
public interface IEffectiveClaimsStore
|
||||
{
|
||||
IReadOnlyList<ClaimRequirement> GetEffectiveClaims(string serviceName, string method, string path);
|
||||
|
||||
void UpdateFromMicroservice(string serviceName, IReadOnlyList<EndpointDescriptor> endpoints);
|
||||
|
||||
void UpdateFromAuthority(IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> overrides);
|
||||
|
||||
void RemoveService(string serviceName);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using System.Net;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Configuration;
|
||||
|
||||
public sealed class GatewayOptions
|
||||
{
|
||||
public const string SectionName = "Gateway";
|
||||
|
||||
public GatewayNodeOptions Node { get; set; } = new();
|
||||
|
||||
public GatewayTransportOptions Transports { get; set; } = new();
|
||||
|
||||
public GatewayRoutingOptions Routing { get; set; } = new();
|
||||
|
||||
public GatewayAuthOptions Auth { get; set; } = new();
|
||||
|
||||
public GatewayOpenApiOptions OpenApi { get; set; } = new();
|
||||
|
||||
public GatewayHealthOptions Health { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class GatewayNodeOptions
|
||||
{
|
||||
public string Region { get; set; } = "local";
|
||||
|
||||
public string NodeId { get; set; } = string.Empty;
|
||||
|
||||
public string Environment { get; set; } = "dev";
|
||||
|
||||
public List<string> NeighborRegions { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class GatewayTransportOptions
|
||||
{
|
||||
public GatewayTcpTransportOptions Tcp { get; set; } = new();
|
||||
|
||||
public GatewayTlsTransportOptions Tls { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class GatewayTcpTransportOptions
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
public string BindAddress { get; set; } = IPAddress.Any.ToString();
|
||||
|
||||
public int Port { get; set; } = 9100;
|
||||
|
||||
public int ReceiveBufferSize { get; set; } = 64 * 1024;
|
||||
|
||||
public int SendBufferSize { get; set; } = 64 * 1024;
|
||||
|
||||
public int MaxFrameSize { get; set; } = 16 * 1024 * 1024;
|
||||
}
|
||||
|
||||
public sealed class GatewayTlsTransportOptions
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
public string BindAddress { get; set; } = IPAddress.Any.ToString();
|
||||
|
||||
public int Port { get; set; } = 9443;
|
||||
|
||||
public int ReceiveBufferSize { get; set; } = 64 * 1024;
|
||||
|
||||
public int SendBufferSize { get; set; } = 64 * 1024;
|
||||
|
||||
public int MaxFrameSize { get; set; } = 16 * 1024 * 1024;
|
||||
|
||||
public string? CertificatePath { get; set; }
|
||||
|
||||
public string? CertificateKeyPath { get; set; }
|
||||
|
||||
public string? CertificatePassword { get; set; }
|
||||
|
||||
public bool RequireClientCertificate { get; set; }
|
||||
|
||||
public bool AllowSelfSigned { get; set; }
|
||||
}
|
||||
|
||||
public sealed class GatewayRoutingOptions
|
||||
{
|
||||
public string DefaultTimeout { get; set; } = "30s";
|
||||
|
||||
public string MaxRequestBodySize { get; set; } = "100MB";
|
||||
|
||||
public bool StreamingEnabled { get; set; } = true;
|
||||
|
||||
public bool PreferLocalRegion { get; set; } = true;
|
||||
|
||||
public bool AllowDegradedInstances { get; set; } = true;
|
||||
|
||||
public bool StrictVersionMatching { get; set; } = true;
|
||||
|
||||
public List<string> NeighborRegions { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class GatewayAuthOptions
|
||||
{
|
||||
public bool DpopEnabled { get; set; } = true;
|
||||
|
||||
public bool MtlsEnabled { get; set; }
|
||||
|
||||
public bool AllowAnonymous { get; set; } = true;
|
||||
|
||||
public GatewayAuthorityOptions Authority { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class GatewayAuthorityOptions
|
||||
{
|
||||
public string? Issuer { get; set; }
|
||||
|
||||
public bool RequireHttpsMetadata { get; set; } = true;
|
||||
|
||||
public string? MetadataAddress { get; set; }
|
||||
|
||||
public List<string> Audiences { get; set; } = new();
|
||||
|
||||
public List<string> RequiredScopes { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class GatewayOpenApiOptions
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public int CacheTtlSeconds { get; set; } = 300;
|
||||
|
||||
public string Title { get; set; } = "StellaOps Gateway API";
|
||||
|
||||
public string Description { get; set; } = "Unified API aggregating all connected microservices.";
|
||||
|
||||
public string Version { get; set; } = "1.0.0";
|
||||
|
||||
public string ServerUrl { get; set; } = "/";
|
||||
|
||||
public string TokenUrl { get; set; } = "/auth/token";
|
||||
}
|
||||
|
||||
public sealed class GatewayHealthOptions
|
||||
{
|
||||
public string StaleThreshold { get; set; } = "30s";
|
||||
|
||||
public string DegradedThreshold { get; set; } = "15s";
|
||||
|
||||
public string CheckInterval { get; set; } = "5s";
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace StellaOps.Gateway.WebService.Configuration;
|
||||
|
||||
public static class GatewayOptionsValidator
|
||||
{
|
||||
public static void Validate(GatewayOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.Node.Region))
|
||||
{
|
||||
throw new InvalidOperationException("Gateway node region is required.");
|
||||
}
|
||||
|
||||
if (options.Transports.Tcp.Enabled && options.Transports.Tcp.Port <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("TCP transport port must be greater than zero.");
|
||||
}
|
||||
|
||||
if (options.Transports.Tls.Enabled)
|
||||
{
|
||||
if (options.Transports.Tls.Port <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("TLS transport port must be greater than zero.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.Transports.Tls.CertificatePath))
|
||||
{
|
||||
throw new InvalidOperationException("TLS transport requires a certificate path when enabled.");
|
||||
}
|
||||
}
|
||||
|
||||
_ = GatewayValueParser.ParseDuration(options.Routing.DefaultTimeout, TimeSpan.FromSeconds(30));
|
||||
_ = GatewayValueParser.ParseSizeBytes(options.Routing.MaxRequestBodySize, 0);
|
||||
|
||||
_ = GatewayValueParser.ParseDuration(options.Health.StaleThreshold, TimeSpan.FromSeconds(30));
|
||||
_ = GatewayValueParser.ParseDuration(options.Health.DegradedThreshold, TimeSpan.FromSeconds(15));
|
||||
_ = GatewayValueParser.ParseDuration(options.Health.CheckInterval, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Configuration;
|
||||
|
||||
public static class GatewayValueParser
|
||||
{
|
||||
public static TimeSpan ParseDuration(string? value, TimeSpan fallback)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (TimeSpan.TryParse(value, CultureInfo.InvariantCulture, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
var (number, unit) = SplitNumberAndUnit(trimmed, defaultUnit: "s");
|
||||
if (!double.TryParse(number, NumberStyles.Float, CultureInfo.InvariantCulture, out var scalar))
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid duration value '{value}'.");
|
||||
}
|
||||
|
||||
return unit switch
|
||||
{
|
||||
"ms" => TimeSpan.FromMilliseconds(scalar),
|
||||
"s" => TimeSpan.FromSeconds(scalar),
|
||||
"m" => TimeSpan.FromMinutes(scalar),
|
||||
"h" => TimeSpan.FromHours(scalar),
|
||||
_ => throw new InvalidOperationException($"Unsupported duration unit '{unit}' in '{value}'.")
|
||||
};
|
||||
}
|
||||
|
||||
public static long ParseSizeBytes(string? value, long fallback)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
var (number, unit) = SplitNumberAndUnit(trimmed, defaultUnit: "b");
|
||||
if (!double.TryParse(number, NumberStyles.Float, CultureInfo.InvariantCulture, out var scalar))
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid size value '{value}'.");
|
||||
}
|
||||
|
||||
var multiplier = unit switch
|
||||
{
|
||||
"b" => 1L,
|
||||
"kb" => 1024L,
|
||||
"mb" => 1024L * 1024L,
|
||||
"gb" => 1024L * 1024L * 1024L,
|
||||
_ => throw new InvalidOperationException($"Unsupported size unit '{unit}' in '{value}'.")
|
||||
};
|
||||
|
||||
return (long)(scalar * multiplier);
|
||||
}
|
||||
|
||||
private static (string Number, string Unit) SplitNumberAndUnit(string value, string defaultUnit)
|
||||
{
|
||||
var trimmed = value.Trim();
|
||||
var numberPart = new string(trimmed.TakeWhile(ch => char.IsDigit(ch) || ch == '.' || ch == '-' || ch == '+').ToArray());
|
||||
var unitPart = trimmed[numberPart.Length..].Trim().ToLowerInvariant();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(unitPart))
|
||||
{
|
||||
unitPart = defaultUnit;
|
||||
}
|
||||
|
||||
if (!unitPart.EndsWith("b", StringComparison.Ordinal) &&
|
||||
unitPart is not "ms" and not "s" and not "m" and not "h")
|
||||
{
|
||||
unitPart += "b";
|
||||
}
|
||||
|
||||
return (numberPart, unitPart);
|
||||
}
|
||||
}
|
||||
14
src/Gateway/StellaOps.Gateway.WebService/Dockerfile
Normal file
14
src/Gateway/StellaOps.Gateway.WebService/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-preview AS base
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
EXPOSE 8443
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0-preview AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN dotnet publish src/Gateway/StellaOps.Gateway.WebService/StellaOps.Gateway.WebService.csproj -c Release -o /app/publish
|
||||
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
ENTRYPOINT ["dotnet", "StellaOps.Gateway.WebService.dll"]
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Middleware;
|
||||
|
||||
public sealed class ClaimsPropagationMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<ClaimsPropagationMiddleware> _logger;
|
||||
|
||||
public ClaimsPropagationMiddleware(RequestDelegate next, ILogger<ClaimsPropagationMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
if (GatewayRoutes.IsSystemPath(context.Request.Path))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var principal = context.User;
|
||||
|
||||
SetHeaderIfMissing(context, "sub", principal.FindFirstValue("sub"));
|
||||
SetHeaderIfMissing(context, "tid", principal.FindFirstValue("tid"));
|
||||
|
||||
var scopes = principal.FindAll("scope").Select(c => c.Value).ToArray();
|
||||
if (scopes.Length > 0)
|
||||
{
|
||||
SetHeaderIfMissing(context, "scope", string.Join(" ", scopes));
|
||||
}
|
||||
|
||||
var cnfJson = principal.FindFirstValue("cnf");
|
||||
if (!string.IsNullOrWhiteSpace(cnfJson))
|
||||
{
|
||||
context.Items[GatewayContextKeys.CnfJson] = cnfJson;
|
||||
|
||||
if (TryParseCnf(cnfJson, out var jkt))
|
||||
{
|
||||
context.Items[GatewayContextKeys.DpopThumbprint] = jkt;
|
||||
SetHeaderIfMissing(context, "cnf.jkt", jkt);
|
||||
}
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
private void SetHeaderIfMissing(HttpContext context, string name, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.Request.Headers.ContainsKey(name))
|
||||
{
|
||||
context.Request.Headers[name] = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Request header {Header} already set; skipping claim propagation", name);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseCnf(string json, out string? jkt)
|
||||
{
|
||||
jkt = null;
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
if (document.RootElement.TryGetProperty("jkt", out var jktElement) &&
|
||||
jktElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
jkt = jktElement.GetString();
|
||||
}
|
||||
|
||||
return !string.IsNullOrWhiteSpace(jkt);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace StellaOps.Gateway.WebService.Middleware;
|
||||
|
||||
public sealed class CorrelationIdMiddleware
|
||||
{
|
||||
public const string HeaderName = "X-Correlation-Id";
|
||||
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
public CorrelationIdMiddleware(RequestDelegate next)
|
||||
{
|
||||
_next = next;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
if (context.Request.Headers.TryGetValue(HeaderName, out var headerValue) &&
|
||||
!string.IsNullOrWhiteSpace(headerValue))
|
||||
{
|
||||
context.TraceIdentifier = headerValue.ToString();
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(context.TraceIdentifier))
|
||||
{
|
||||
context.TraceIdentifier = Guid.NewGuid().ToString("N");
|
||||
}
|
||||
|
||||
context.Response.Headers[HeaderName] = context.TraceIdentifier;
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace StellaOps.Gateway.WebService.Middleware;
|
||||
|
||||
public static class GatewayContextKeys
|
||||
{
|
||||
public const string TenantId = "Gateway.TenantId";
|
||||
public const string DpopThumbprint = "Gateway.DpopThumbprint";
|
||||
public const string MtlsThumbprint = "Gateway.MtlsThumbprint";
|
||||
public const string CnfJson = "Gateway.CnfJson";
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace StellaOps.Gateway.WebService.Middleware;
|
||||
|
||||
public static class GatewayRoutes
|
||||
{
|
||||
private static readonly HashSet<string> SystemPaths = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"/health",
|
||||
"/health/live",
|
||||
"/health/ready",
|
||||
"/health/startup",
|
||||
"/metrics",
|
||||
"/openapi.json",
|
||||
"/openapi.yaml",
|
||||
"/.well-known/openapi"
|
||||
};
|
||||
|
||||
public static bool IsSystemPath(PathString path)
|
||||
{
|
||||
var value = path.Value ?? string.Empty;
|
||||
return SystemPaths.Contains(value);
|
||||
}
|
||||
|
||||
public static bool IsHealthPath(PathString path)
|
||||
{
|
||||
var value = path.Value ?? string.Empty;
|
||||
return value.StartsWith("/health", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static bool IsMetricsPath(PathString path)
|
||||
{
|
||||
var value = path.Value ?? string.Empty;
|
||||
return string.Equals(value, "/metrics", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Gateway.WebService.Services;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Middleware;
|
||||
|
||||
public sealed class HealthCheckMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public HealthCheckMiddleware(RequestDelegate next)
|
||||
{
|
||||
_next = next;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context, GatewayServiceStatus status, GatewayMetrics metrics)
|
||||
{
|
||||
if (GatewayRoutes.IsMetricsPath(context.Request.Path))
|
||||
{
|
||||
await WriteMetricsAsync(context, metrics);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!GatewayRoutes.IsHealthPath(context.Request.Path))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var path = context.Request.Path.Value ?? string.Empty;
|
||||
if (path.Equals("/health/live", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await WriteHealthAsync(context, StatusCodes.Status200OK, "live", status);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.Equals("/health/ready", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var readyStatus = status.IsReady ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable;
|
||||
await WriteHealthAsync(context, readyStatus, "ready", status);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.Equals("/health/startup", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var startupStatus = status.IsStarted ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable;
|
||||
await WriteHealthAsync(context, startupStatus, "startup", status);
|
||||
return;
|
||||
}
|
||||
|
||||
await WriteHealthAsync(context, StatusCodes.Status200OK, "ok", status);
|
||||
}
|
||||
|
||||
private static Task WriteHealthAsync(HttpContext context, int statusCode, string status, GatewayServiceStatus serviceStatus)
|
||||
{
|
||||
context.Response.StatusCode = statusCode;
|
||||
context.Response.ContentType = "application/json; charset=utf-8";
|
||||
|
||||
var payload = new
|
||||
{
|
||||
status,
|
||||
started = serviceStatus.IsStarted,
|
||||
ready = serviceStatus.IsReady,
|
||||
traceId = context.TraceIdentifier
|
||||
};
|
||||
|
||||
return context.Response.WriteAsJsonAsync(payload, JsonOptions, context.RequestAborted);
|
||||
}
|
||||
|
||||
private static Task WriteMetricsAsync(HttpContext context, GatewayMetrics metrics)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status200OK;
|
||||
context.Response.ContentType = "text/plain; version=0.0.4";
|
||||
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("# TYPE gateway_active_connections gauge");
|
||||
builder.Append("gateway_active_connections ").AppendLine(metrics.GetActiveConnections().ToString());
|
||||
builder.AppendLine("# TYPE gateway_registered_endpoints gauge");
|
||||
builder.Append("gateway_registered_endpoints ").AppendLine(metrics.GetRegisteredEndpoints().ToString());
|
||||
|
||||
return context.Response.WriteAsync(builder.ToString(), context.RequestAborted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Gateway.Middleware;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Middleware;
|
||||
|
||||
public sealed class RequestRoutingMiddleware
|
||||
{
|
||||
private readonly TransportDispatchMiddleware _dispatchMiddleware;
|
||||
|
||||
public RequestRoutingMiddleware(
|
||||
RequestDelegate next,
|
||||
ILogger<RequestRoutingMiddleware> logger,
|
||||
ILogger<TransportDispatchMiddleware> dispatchLogger)
|
||||
{
|
||||
_dispatchMiddleware = new TransportDispatchMiddleware(next, dispatchLogger);
|
||||
}
|
||||
|
||||
public Task InvokeAsync(HttpContext context, ITransportClient transportClient, IGlobalRoutingState routingState)
|
||||
{
|
||||
return _dispatchMiddleware.Invoke(context, transportClient, routingState);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http.Extensions;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Security.Dpop;
|
||||
using StellaOps.Gateway.WebService.Configuration;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Middleware;
|
||||
|
||||
public sealed class SenderConstraintMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly IOptions<GatewayOptions> _options;
|
||||
private readonly IDpopProofValidator _dpopValidator;
|
||||
private readonly ILogger<SenderConstraintMiddleware> _logger;
|
||||
|
||||
public SenderConstraintMiddleware(
|
||||
RequestDelegate next,
|
||||
IOptions<GatewayOptions> options,
|
||||
IDpopProofValidator dpopValidator,
|
||||
ILogger<SenderConstraintMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_options = options;
|
||||
_dpopValidator = dpopValidator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
if (GatewayRoutes.IsSystemPath(context.Request.Path))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var authOptions = _options.Value.Auth;
|
||||
if (context.User.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
if (authOptions.AllowAnonymous)
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
await WriteUnauthorizedAsync(context, "unauthenticated", "Authentication required.");
|
||||
return;
|
||||
}
|
||||
|
||||
var confirmation = ParseConfirmation(context.User.FindFirstValue("cnf"));
|
||||
if (confirmation.Raw is not null)
|
||||
{
|
||||
context.Items[GatewayContextKeys.CnfJson] = confirmation.Raw;
|
||||
}
|
||||
|
||||
var requireDpop = authOptions.DpopEnabled && (!authOptions.MtlsEnabled || !string.IsNullOrWhiteSpace(confirmation.Jkt));
|
||||
var requireMtls = authOptions.MtlsEnabled && (!authOptions.DpopEnabled || !string.IsNullOrWhiteSpace(confirmation.X5tS256));
|
||||
|
||||
if (authOptions.DpopEnabled && authOptions.MtlsEnabled &&
|
||||
string.IsNullOrWhiteSpace(confirmation.Jkt) && string.IsNullOrWhiteSpace(confirmation.X5tS256))
|
||||
{
|
||||
requireDpop = true;
|
||||
requireMtls = true;
|
||||
}
|
||||
|
||||
if (requireDpop && !await ValidateDpopAsync(context, confirmation))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (requireMtls && !await ValidateMtlsAsync(context, confirmation))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
private async Task<bool> ValidateDpopAsync(HttpContext context, ConfirmationClaim confirmation)
|
||||
{
|
||||
if (!context.Request.Headers.TryGetValue("DPoP", out var proofHeader) ||
|
||||
string.IsNullOrWhiteSpace(proofHeader))
|
||||
{
|
||||
_logger.LogWarning("Missing DPoP proof for request {TraceId}", context.TraceIdentifier);
|
||||
await WriteUnauthorizedAsync(context, "dpop_missing", "DPoP proof is required.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var proof = proofHeader.ToString();
|
||||
var requestUri = new Uri(context.Request.GetDisplayUrl());
|
||||
|
||||
var result = await _dpopValidator.ValidateAsync(
|
||||
proof,
|
||||
context.Request.Method,
|
||||
requestUri,
|
||||
cancellationToken: context.RequestAborted);
|
||||
|
||||
if (!result.IsValid)
|
||||
{
|
||||
_logger.LogWarning("DPoP validation failed for {TraceId}: {Error}", context.TraceIdentifier, result.ErrorDescription);
|
||||
await WriteUnauthorizedAsync(context, result.ErrorCode ?? "dpop_invalid", result.ErrorDescription ?? "DPoP proof invalid.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (result.PublicKey is not JsonWebKey jwk)
|
||||
{
|
||||
_logger.LogWarning("DPoP validation failed for {TraceId}: JWK missing", context.TraceIdentifier);
|
||||
await WriteUnauthorizedAsync(context, "dpop_key_invalid", "DPoP proof must include a valid JWK.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var thumbprint = ComputeJwkThumbprint(jwk);
|
||||
context.Items[GatewayContextKeys.DpopThumbprint] = thumbprint;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(confirmation.Jkt) &&
|
||||
!string.Equals(confirmation.Jkt, thumbprint, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogWarning("DPoP thumbprint mismatch for {TraceId}", context.TraceIdentifier);
|
||||
await WriteUnauthorizedAsync(context, "dpop_thumbprint_mismatch", "DPoP proof does not match token confirmation.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<bool> ValidateMtlsAsync(HttpContext context, ConfirmationClaim confirmation)
|
||||
{
|
||||
var certificate = context.Connection.ClientCertificate;
|
||||
if (certificate is null)
|
||||
{
|
||||
_logger.LogWarning("mTLS required but no client certificate provided for {TraceId}", context.TraceIdentifier);
|
||||
await WriteUnauthorizedAsync(context, "mtls_required", "Client certificate required.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var hash = certificate.GetCertHash(HashAlgorithmName.SHA256);
|
||||
var thumbprint = Base64UrlEncoder.Encode(hash);
|
||||
context.Items[GatewayContextKeys.MtlsThumbprint] = thumbprint;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(confirmation.X5tS256) &&
|
||||
!string.Equals(confirmation.X5tS256, thumbprint, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogWarning("mTLS thumbprint mismatch for {TraceId}", context.TraceIdentifier);
|
||||
await WriteUnauthorizedAsync(context, "mtls_thumbprint_mismatch", "Client certificate does not match token confirmation.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string ComputeJwkThumbprint(JsonWebKey jwk)
|
||||
{
|
||||
object rawThumbprint = jwk.ComputeJwkThumbprint();
|
||||
return rawThumbprint switch
|
||||
{
|
||||
string thumbprint => thumbprint,
|
||||
byte[] bytes => Base64UrlEncoder.Encode(bytes),
|
||||
_ => throw new InvalidOperationException("Unable to compute JWK thumbprint.")
|
||||
};
|
||||
}
|
||||
|
||||
private static ConfirmationClaim ParseConfirmation(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return ConfirmationClaim.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var root = document.RootElement;
|
||||
|
||||
root.TryGetProperty("jkt", out var jktElement);
|
||||
root.TryGetProperty("x5t#S256", out var x5tElement);
|
||||
|
||||
return new ConfirmationClaim(
|
||||
json,
|
||||
jktElement.ValueKind == JsonValueKind.String ? jktElement.GetString() : null,
|
||||
x5tElement.ValueKind == JsonValueKind.String ? x5tElement.GetString() : null);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return ConfirmationClaim.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static Task WriteUnauthorizedAsync(HttpContext context, string error, string message)
|
||||
{
|
||||
if (context.Response.HasStarted)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
context.Response.ContentType = "application/json; charset=utf-8";
|
||||
|
||||
var payload = new
|
||||
{
|
||||
error,
|
||||
message,
|
||||
traceId = context.TraceIdentifier
|
||||
};
|
||||
|
||||
return context.Response.WriteAsJsonAsync(payload, context.RequestAborted);
|
||||
}
|
||||
|
||||
private sealed record ConfirmationClaim(string? Raw, string? Jkt, string? X5tS256)
|
||||
{
|
||||
public static ConfirmationClaim Empty { get; } = new(null, null, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Middleware;
|
||||
|
||||
public sealed class TenantMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<TenantMiddleware> _logger;
|
||||
|
||||
public TenantMiddleware(RequestDelegate next, ILogger<TenantMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
if (GatewayRoutes.IsSystemPath(context.Request.Path))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var tenantId = context.User.FindFirstValue("tid");
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
context.Items[GatewayContextKeys.TenantId] = tenantId;
|
||||
if (!context.Request.Headers.ContainsKey("tid"))
|
||||
{
|
||||
context.Request.Headers["tid"] = tenantId;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("No tenant claim found on request {TraceId}", context.TraceIdentifier);
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
}
|
||||
227
src/Gateway/StellaOps.Gateway.WebService/Program.cs
Normal file
227
src/Gateway/StellaOps.Gateway.WebService/Program.cs
Normal file
@@ -0,0 +1,227 @@
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Auth.Security.Dpop;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Gateway.WebService.Authorization;
|
||||
using StellaOps.Gateway.WebService.Configuration;
|
||||
using StellaOps.Gateway.WebService.Middleware;
|
||||
using StellaOps.Gateway.WebService.Security;
|
||||
using StellaOps.Gateway.WebService.Services;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Gateway;
|
||||
using StellaOps.Router.Gateway.Configuration;
|
||||
using StellaOps.Router.Gateway.Middleware;
|
||||
using StellaOps.Router.Gateway.OpenApi;
|
||||
using StellaOps.Router.Gateway.RateLimit;
|
||||
using StellaOps.Router.Gateway.Routing;
|
||||
using StellaOps.Router.Transport.Tcp;
|
||||
using StellaOps.Router.Transport.Tls;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Configuration.AddStellaOpsDefaults(options =>
|
||||
{
|
||||
options.BasePath = builder.Environment.ContentRootPath;
|
||||
options.EnvironmentPrefix = "GATEWAY_";
|
||||
});
|
||||
|
||||
var bootstrapOptions = builder.Configuration.BindOptions<GatewayOptions>(
|
||||
GatewayOptions.SectionName,
|
||||
(opts, _) => GatewayOptionsValidator.Validate(opts));
|
||||
|
||||
builder.Services.AddOptions<GatewayOptions>()
|
||||
.Bind(builder.Configuration.GetSection(GatewayOptions.SectionName))
|
||||
.PostConfigure(GatewayOptionsValidator.Validate)
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
|
||||
builder.Services.AddRouterGatewayCore();
|
||||
builder.Services.AddRouterRateLimiting(builder.Configuration);
|
||||
|
||||
builder.Services.AddSingleton<IEffectiveClaimsStore, EffectiveClaimsStore>();
|
||||
builder.Services.AddSingleton<GatewayServiceStatus>();
|
||||
builder.Services.AddSingleton<GatewayMetrics>();
|
||||
|
||||
builder.Services.AddTcpTransportServer();
|
||||
builder.Services.AddTlsTransportServer();
|
||||
|
||||
builder.Services.AddSingleton<GatewayTransportClient>();
|
||||
builder.Services.AddSingleton<ITransportClient>(sp => sp.GetRequiredService<GatewayTransportClient>());
|
||||
|
||||
builder.Services.AddSingleton<IOpenApiDocumentGenerator, OpenApiDocumentGenerator>();
|
||||
builder.Services.AddSingleton<IRouterOpenApiDocumentCache, RouterOpenApiDocumentCache>();
|
||||
|
||||
builder.Services.AddHostedService<GatewayHostedService>();
|
||||
builder.Services.AddHostedService<GatewayHealthMonitorService>();
|
||||
|
||||
builder.Services.AddSingleton<IDpopReplayCache, InMemoryDpopReplayCache>();
|
||||
builder.Services.AddSingleton<IDpopProofValidator, DpopProofValidator>();
|
||||
|
||||
ConfigureAuthentication(builder, bootstrapOptions);
|
||||
ConfigureGatewayOptionsMapping(builder, bootstrapOptions);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseMiddleware<CorrelationIdMiddleware>();
|
||||
app.UseAuthentication();
|
||||
app.UseMiddleware<SenderConstraintMiddleware>();
|
||||
app.UseMiddleware<TenantMiddleware>();
|
||||
app.UseMiddleware<ClaimsPropagationMiddleware>();
|
||||
app.UseMiddleware<HealthCheckMiddleware>();
|
||||
|
||||
if (bootstrapOptions.OpenApi.Enabled)
|
||||
{
|
||||
app.MapRouterOpenApi();
|
||||
}
|
||||
|
||||
app.UseWhen(
|
||||
context => !GatewayRoutes.IsSystemPath(context.Request.Path),
|
||||
branch =>
|
||||
{
|
||||
branch.UseMiddleware<RequestLoggingMiddleware>();
|
||||
branch.UseMiddleware<GlobalErrorHandlerMiddleware>();
|
||||
branch.UseMiddleware<PayloadLimitsMiddleware>();
|
||||
branch.UseMiddleware<EndpointResolutionMiddleware>();
|
||||
branch.UseMiddleware<AuthorizationMiddleware>();
|
||||
branch.UseRateLimiting();
|
||||
branch.UseMiddleware<RoutingDecisionMiddleware>();
|
||||
branch.UseMiddleware<RequestRoutingMiddleware>();
|
||||
});
|
||||
|
||||
await app.RunAsync();
|
||||
|
||||
static void ConfigureAuthentication(WebApplicationBuilder builder, GatewayOptions options)
|
||||
{
|
||||
var authOptions = options.Auth;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(authOptions.Authority.Issuer))
|
||||
{
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
builder.Configuration,
|
||||
configurationSection: null,
|
||||
configure: resourceOptions =>
|
||||
{
|
||||
resourceOptions.Authority = authOptions.Authority.Issuer;
|
||||
resourceOptions.RequireHttpsMetadata = authOptions.Authority.RequireHttpsMetadata;
|
||||
resourceOptions.MetadataAddress = authOptions.Authority.MetadataAddress;
|
||||
|
||||
resourceOptions.Audiences.Clear();
|
||||
foreach (var audience in authOptions.Authority.Audiences)
|
||||
{
|
||||
resourceOptions.Audiences.Add(audience);
|
||||
}
|
||||
});
|
||||
|
||||
if (authOptions.Authority.RequiredScopes.Count > 0)
|
||||
{
|
||||
builder.Services.AddAuthorization(config =>
|
||||
{
|
||||
config.AddPolicy("gateway.default", policy =>
|
||||
{
|
||||
policy.RequireAuthenticatedUser();
|
||||
policy.Requirements.Add(new StellaOpsScopeRequirement(authOptions.Authority.RequiredScopes));
|
||||
policy.AddAuthenticationSchemes(StellaOpsAuthenticationDefaults.AuthenticationScheme);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (authOptions.AllowAnonymous)
|
||||
{
|
||||
builder.Services.AddAuthentication(authConfig =>
|
||||
{
|
||||
authConfig.DefaultAuthenticateScheme = AllowAllAuthenticationHandler.SchemeName;
|
||||
authConfig.DefaultChallengeScheme = AllowAllAuthenticationHandler.SchemeName;
|
||||
}).AddScheme<AuthenticationSchemeOptions, AllowAllAuthenticationHandler>(
|
||||
AllowAllAuthenticationHandler.SchemeName,
|
||||
_ => { });
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Gateway authentication requires an Authority issuer or AllowAnonymous.");
|
||||
}
|
||||
|
||||
static void ConfigureGatewayOptionsMapping(WebApplicationBuilder builder, GatewayOptions gatewayOptions)
|
||||
{
|
||||
builder.Services.AddOptions<RouterNodeConfig>()
|
||||
.Configure<IOptions<GatewayOptions>>((options, gateway) =>
|
||||
{
|
||||
options.Region = gateway.Value.Node.Region;
|
||||
options.NodeId = gateway.Value.Node.NodeId;
|
||||
options.Environment = gateway.Value.Node.Environment;
|
||||
options.NeighborRegions = gateway.Value.Node.NeighborRegions;
|
||||
});
|
||||
|
||||
builder.Services.AddOptions<RoutingOptions>()
|
||||
.Configure<IOptions<GatewayOptions>>((options, gateway) =>
|
||||
{
|
||||
var routing = gateway.Value.Routing;
|
||||
options.RoutingTimeoutMs = (int)GatewayValueParser.ParseDuration(routing.DefaultTimeout, TimeSpan.FromSeconds(30)).TotalMilliseconds;
|
||||
options.PreferLocalRegion = routing.PreferLocalRegion;
|
||||
options.AllowDegradedInstances = routing.AllowDegradedInstances;
|
||||
options.StrictVersionMatching = routing.StrictVersionMatching;
|
||||
});
|
||||
|
||||
builder.Services.AddOptions<PayloadLimits>()
|
||||
.Configure<IOptions<GatewayOptions>>((options, gateway) =>
|
||||
{
|
||||
var routing = gateway.Value.Routing;
|
||||
options.MaxRequestBytesPerCall = GatewayValueParser.ParseSizeBytes(routing.MaxRequestBodySize, options.MaxRequestBytesPerCall);
|
||||
});
|
||||
|
||||
builder.Services.AddOptions<HealthOptions>()
|
||||
.Configure<IOptions<GatewayOptions>>((options, gateway) =>
|
||||
{
|
||||
var health = gateway.Value.Health;
|
||||
options.StaleThreshold = GatewayValueParser.ParseDuration(health.StaleThreshold, options.StaleThreshold);
|
||||
options.DegradedThreshold = GatewayValueParser.ParseDuration(health.DegradedThreshold, options.DegradedThreshold);
|
||||
options.CheckInterval = GatewayValueParser.ParseDuration(health.CheckInterval, options.CheckInterval);
|
||||
});
|
||||
|
||||
builder.Services.AddOptions<OpenApiAggregationOptions>()
|
||||
.Configure<IOptions<GatewayOptions>>((options, gateway) =>
|
||||
{
|
||||
var openApi = gateway.Value.OpenApi;
|
||||
options.Enabled = openApi.Enabled;
|
||||
options.CacheTtlSeconds = openApi.CacheTtlSeconds;
|
||||
options.Title = openApi.Title;
|
||||
options.Description = openApi.Description;
|
||||
options.Version = openApi.Version;
|
||||
options.ServerUrl = openApi.ServerUrl;
|
||||
options.TokenUrl = openApi.TokenUrl;
|
||||
});
|
||||
|
||||
builder.Services.AddOptions<TcpTransportOptions>()
|
||||
.Configure<IOptions<GatewayOptions>>((options, gateway) =>
|
||||
{
|
||||
var tcp = gateway.Value.Transports.Tcp;
|
||||
options.Port = tcp.Port;
|
||||
options.ReceiveBufferSize = tcp.ReceiveBufferSize;
|
||||
options.SendBufferSize = tcp.SendBufferSize;
|
||||
options.MaxFrameSize = tcp.MaxFrameSize;
|
||||
options.BindAddress = IPAddress.Parse(tcp.BindAddress);
|
||||
});
|
||||
|
||||
builder.Services.AddOptions<TlsTransportOptions>()
|
||||
.Configure<IOptions<GatewayOptions>>((options, gateway) =>
|
||||
{
|
||||
var tls = gateway.Value.Transports.Tls;
|
||||
options.Port = tls.Port;
|
||||
options.ReceiveBufferSize = tls.ReceiveBufferSize;
|
||||
options.SendBufferSize = tls.SendBufferSize;
|
||||
options.MaxFrameSize = tls.MaxFrameSize;
|
||||
options.BindAddress = IPAddress.Parse(tls.BindAddress);
|
||||
options.ServerCertificatePath = tls.CertificatePath;
|
||||
options.ServerCertificateKeyPath = tls.CertificateKeyPath;
|
||||
options.ServerCertificatePassword = tls.CertificatePassword;
|
||||
options.RequireClientCertificate = tls.RequireClientCertificate;
|
||||
options.AllowSelfSigned = tls.AllowSelfSigned;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Security;
|
||||
|
||||
internal sealed class AllowAllAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public const string SchemeName = "Gateway.AllowAll";
|
||||
|
||||
#pragma warning disable CS0618
|
||||
public AllowAllAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
ISystemClock clock)
|
||||
: base(options, logger, encoder, clock)
|
||||
{
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var identity = new ClaimsIdentity();
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, Scheme.Name);
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Gateway.Configuration;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Services;
|
||||
|
||||
public sealed class GatewayHealthMonitorService : BackgroundService
|
||||
{
|
||||
private readonly IGlobalRoutingState _routingState;
|
||||
private readonly IOptions<HealthOptions> _options;
|
||||
private readonly ILogger<GatewayHealthMonitorService> _logger;
|
||||
|
||||
public GatewayHealthMonitorService(
|
||||
IGlobalRoutingState routingState,
|
||||
IOptions<HealthOptions> options,
|
||||
ILogger<GatewayHealthMonitorService> logger)
|
||||
{
|
||||
_routingState = routingState;
|
||||
_options = options;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Health monitor started. Stale threshold: {StaleThreshold}, Check interval: {CheckInterval}",
|
||||
_options.Value.StaleThreshold,
|
||||
_options.Value.CheckInterval);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(_options.Value.CheckInterval, stoppingToken);
|
||||
CheckStaleConnections();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in health monitor loop");
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Health monitor stopped");
|
||||
}
|
||||
|
||||
private void CheckStaleConnections()
|
||||
{
|
||||
var staleThreshold = _options.Value.StaleThreshold;
|
||||
var degradedThreshold = _options.Value.DegradedThreshold;
|
||||
var now = DateTime.UtcNow;
|
||||
var staleCount = 0;
|
||||
var degradedCount = 0;
|
||||
|
||||
foreach (var connection in _routingState.GetAllConnections())
|
||||
{
|
||||
if (connection.Status == InstanceHealthStatus.Draining)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var age = now - connection.LastHeartbeatUtc;
|
||||
|
||||
if (age > staleThreshold && connection.Status != InstanceHealthStatus.Unhealthy)
|
||||
{
|
||||
_routingState.UpdateConnection(connection.ConnectionId, c =>
|
||||
c.Status = InstanceHealthStatus.Unhealthy);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Instance {InstanceId} ({ServiceName}/{Version}) marked Unhealthy: no heartbeat for {Age:g}",
|
||||
connection.Instance.InstanceId,
|
||||
connection.Instance.ServiceName,
|
||||
connection.Instance.Version,
|
||||
age);
|
||||
|
||||
staleCount++;
|
||||
}
|
||||
else if (age > degradedThreshold && connection.Status == InstanceHealthStatus.Healthy)
|
||||
{
|
||||
_routingState.UpdateConnection(connection.ConnectionId, c =>
|
||||
c.Status = InstanceHealthStatus.Degraded);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Instance {InstanceId} ({ServiceName}/{Version}) marked Degraded: delayed heartbeat ({Age:g})",
|
||||
connection.Instance.InstanceId,
|
||||
connection.Instance.ServiceName,
|
||||
connection.Instance.Version,
|
||||
age);
|
||||
|
||||
degradedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (staleCount > 0 || degradedCount > 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Health check completed: {StaleCount} stale, {DegradedCount} degraded",
|
||||
staleCount,
|
||||
degradedCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Gateway.WebService.Authorization;
|
||||
using StellaOps.Gateway.WebService.Configuration;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Gateway.OpenApi;
|
||||
using StellaOps.Router.Transport.Tcp;
|
||||
using StellaOps.Router.Transport.Tls;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Services;
|
||||
|
||||
public sealed class GatewayHostedService : IHostedService
|
||||
{
|
||||
private readonly TcpTransportServer _tcpServer;
|
||||
private readonly TlsTransportServer _tlsServer;
|
||||
private readonly IGlobalRoutingState _routingState;
|
||||
private readonly GatewayTransportClient _transportClient;
|
||||
private readonly IEffectiveClaimsStore _claimsStore;
|
||||
private readonly IRouterOpenApiDocumentCache? _openApiCache;
|
||||
private readonly IOptions<GatewayOptions> _options;
|
||||
private readonly GatewayServiceStatus _status;
|
||||
private readonly ILogger<GatewayHostedService> _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private bool _tcpEnabled;
|
||||
private bool _tlsEnabled;
|
||||
|
||||
public GatewayHostedService(
|
||||
TcpTransportServer tcpServer,
|
||||
TlsTransportServer tlsServer,
|
||||
IGlobalRoutingState routingState,
|
||||
GatewayTransportClient transportClient,
|
||||
IEffectiveClaimsStore claimsStore,
|
||||
IOptions<GatewayOptions> options,
|
||||
GatewayServiceStatus status,
|
||||
ILogger<GatewayHostedService> logger,
|
||||
IRouterOpenApiDocumentCache? openApiCache = null)
|
||||
{
|
||||
_tcpServer = tcpServer;
|
||||
_tlsServer = tlsServer;
|
||||
_routingState = routingState;
|
||||
_transportClient = transportClient;
|
||||
_claimsStore = claimsStore;
|
||||
_options = options;
|
||||
_status = status;
|
||||
_logger = logger;
|
||||
_openApiCache = openApiCache;
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var options = _options.Value;
|
||||
_tcpEnabled = options.Transports.Tcp.Enabled;
|
||||
_tlsEnabled = options.Transports.Tls.Enabled;
|
||||
|
||||
if (!_tcpEnabled && !_tlsEnabled)
|
||||
{
|
||||
_logger.LogWarning("No transports enabled; gateway will not accept microservice connections.");
|
||||
_status.MarkStarted();
|
||||
_status.MarkReady();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_tcpEnabled)
|
||||
{
|
||||
_tcpServer.OnFrame += HandleTcpFrame;
|
||||
_tcpServer.OnDisconnection += HandleTcpDisconnection;
|
||||
await _tcpServer.StartAsync(cancellationToken);
|
||||
_logger.LogInformation("TCP transport started on port {Port}", options.Transports.Tcp.Port);
|
||||
}
|
||||
|
||||
if (_tlsEnabled)
|
||||
{
|
||||
_tlsServer.OnFrame += HandleTlsFrame;
|
||||
_tlsServer.OnDisconnection += HandleTlsDisconnection;
|
||||
await _tlsServer.StartAsync(cancellationToken);
|
||||
_logger.LogInformation("TLS transport started on port {Port}", options.Transports.Tls.Port);
|
||||
}
|
||||
|
||||
_status.MarkStarted();
|
||||
_status.MarkReady();
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_status.MarkNotReady();
|
||||
|
||||
foreach (var connection in _routingState.GetAllConnections())
|
||||
{
|
||||
_routingState.UpdateConnection(connection.ConnectionId, c => c.Status = InstanceHealthStatus.Draining);
|
||||
}
|
||||
|
||||
if (_tcpEnabled)
|
||||
{
|
||||
await _tcpServer.StopAsync(cancellationToken);
|
||||
_tcpServer.OnFrame -= HandleTcpFrame;
|
||||
_tcpServer.OnDisconnection -= HandleTcpDisconnection;
|
||||
}
|
||||
|
||||
if (_tlsEnabled)
|
||||
{
|
||||
await _tlsServer.StopAsync(cancellationToken);
|
||||
_tlsServer.OnFrame -= HandleTlsFrame;
|
||||
_tlsServer.OnDisconnection -= HandleTlsDisconnection;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleTcpFrame(string connectionId, Frame frame)
|
||||
{
|
||||
_ = HandleFrameAsync(TransportType.Tcp, connectionId, frame);
|
||||
}
|
||||
|
||||
private void HandleTlsFrame(string connectionId, Frame frame)
|
||||
{
|
||||
_ = HandleFrameAsync(TransportType.Tls, connectionId, frame);
|
||||
}
|
||||
|
||||
private void HandleTcpDisconnection(string connectionId)
|
||||
{
|
||||
HandleDisconnect(connectionId);
|
||||
}
|
||||
|
||||
private void HandleTlsDisconnection(string connectionId)
|
||||
{
|
||||
HandleDisconnect(connectionId);
|
||||
}
|
||||
|
||||
private async Task HandleFrameAsync(TransportType transportType, string connectionId, Frame frame)
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (frame.Type)
|
||||
{
|
||||
case FrameType.Hello:
|
||||
await HandleHelloAsync(transportType, connectionId, frame);
|
||||
break;
|
||||
case FrameType.Heartbeat:
|
||||
await HandleHeartbeatAsync(connectionId, frame);
|
||||
break;
|
||||
case FrameType.Response:
|
||||
case FrameType.ResponseStreamData:
|
||||
_transportClient.HandleResponseFrame(frame);
|
||||
break;
|
||||
case FrameType.Cancel:
|
||||
_logger.LogDebug("Received CANCEL for {ConnectionId} correlation {CorrelationId}", connectionId, frame.CorrelationId);
|
||||
break;
|
||||
default:
|
||||
_logger.LogDebug("Ignoring frame type {FrameType} from {ConnectionId}", frame.Type, connectionId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error handling frame {FrameType} from {ConnectionId}", frame.Type, connectionId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleHelloAsync(TransportType transportType, string connectionId, Frame frame)
|
||||
{
|
||||
if (!TryParseHelloPayload(frame, out var payload, out var parseError))
|
||||
{
|
||||
_logger.LogWarning("Invalid HELLO payload for {ConnectionId}: {Error}", connectionId, parseError);
|
||||
CloseConnection(transportType, connectionId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload is not null && !TryValidateHelloPayload(payload, out var validationError))
|
||||
{
|
||||
_logger.LogWarning("HELLO validation failed for {ConnectionId}: {Error}", connectionId, validationError);
|
||||
CloseConnection(transportType, connectionId);
|
||||
return;
|
||||
}
|
||||
|
||||
var state = payload is null
|
||||
? BuildFallbackState(transportType, connectionId)
|
||||
: BuildConnectionState(transportType, connectionId, payload);
|
||||
|
||||
_routingState.AddConnection(state);
|
||||
|
||||
if (payload is not null)
|
||||
{
|
||||
_claimsStore.UpdateFromMicroservice(payload.Instance.ServiceName, payload.Endpoints);
|
||||
}
|
||||
|
||||
_openApiCache?.Invalidate();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Connection registered: {ConnectionId} service={ServiceName} version={Version}",
|
||||
connectionId,
|
||||
state.Instance.ServiceName,
|
||||
state.Instance.Version);
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task HandleHeartbeatAsync(string connectionId, Frame frame)
|
||||
{
|
||||
if (!_routingState.GetAllConnections().Any(c => c.ConnectionId == connectionId))
|
||||
{
|
||||
_logger.LogDebug("Heartbeat received for unknown connection {ConnectionId}", connectionId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryParseHeartbeatPayload(frame, out var payload))
|
||||
{
|
||||
_routingState.UpdateConnection(connectionId, conn =>
|
||||
{
|
||||
conn.LastHeartbeatUtc = DateTime.UtcNow;
|
||||
conn.Status = payload.Status;
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
_routingState.UpdateConnection(connectionId, conn =>
|
||||
{
|
||||
conn.LastHeartbeatUtc = DateTime.UtcNow;
|
||||
});
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void HandleDisconnect(string connectionId)
|
||||
{
|
||||
var connection = _routingState.GetConnection(connectionId);
|
||||
if (connection is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_routingState.RemoveConnection(connectionId);
|
||||
_openApiCache?.Invalidate();
|
||||
|
||||
var serviceName = connection.Instance.ServiceName;
|
||||
if (!string.IsNullOrWhiteSpace(serviceName))
|
||||
{
|
||||
var remaining = _routingState.GetAllConnections()
|
||||
.Any(c => string.Equals(c.Instance.ServiceName, serviceName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (!remaining)
|
||||
{
|
||||
_claimsStore.RemoveService(serviceName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryParseHelloPayload(Frame frame, out HelloPayload? payload, out string? error)
|
||||
{
|
||||
payload = null;
|
||||
error = null;
|
||||
|
||||
if (frame.Payload.IsEmpty)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
payload = JsonSerializer.Deserialize<HelloPayload>(frame.Payload.Span, _jsonOptions);
|
||||
if (payload is null)
|
||||
{
|
||||
error = "HELLO payload missing";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
error = ex.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryParseHeartbeatPayload(Frame frame, out HeartbeatPayload payload)
|
||||
{
|
||||
payload = new HeartbeatPayload
|
||||
{
|
||||
InstanceId = string.Empty,
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
TimestampUtc = DateTime.UtcNow
|
||||
};
|
||||
|
||||
if (frame.Payload.IsEmpty)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var parsed = JsonSerializer.Deserialize<HeartbeatPayload>(frame.Payload.Span, _jsonOptions);
|
||||
if (parsed is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
payload = parsed;
|
||||
return true;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryValidateHelloPayload(HelloPayload payload, out string error)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(payload.Instance.ServiceName))
|
||||
{
|
||||
error = "Instance.ServiceName is required";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(payload.Instance.Version))
|
||||
{
|
||||
error = "Instance.Version is required";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(payload.Instance.Region))
|
||||
{
|
||||
error = "Instance.Region is required";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(payload.Instance.InstanceId))
|
||||
{
|
||||
error = "Instance.InstanceId is required";
|
||||
return false;
|
||||
}
|
||||
|
||||
var seen = new HashSet<(string Method, string Path)>(new EndpointKeyComparer());
|
||||
|
||||
foreach (var endpoint in payload.Endpoints)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(endpoint.Method))
|
||||
{
|
||||
error = "Endpoint.Method is required";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(endpoint.Path) || !endpoint.Path.StartsWith('/'))
|
||||
{
|
||||
error = "Endpoint.Path must start with '/'";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(endpoint.ServiceName, payload.Instance.ServiceName, StringComparison.OrdinalIgnoreCase) ||
|
||||
!string.Equals(endpoint.Version, payload.Instance.Version, StringComparison.Ordinal))
|
||||
{
|
||||
error = "Endpoint.ServiceName/Version must match HelloPayload.Instance";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!seen.Add((endpoint.Method, endpoint.Path)))
|
||||
{
|
||||
error = $"Duplicate endpoint registration for {endpoint.Method} {endpoint.Path}";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (endpoint.SchemaInfo is not null)
|
||||
{
|
||||
if (endpoint.SchemaInfo.RequestSchemaId is not null &&
|
||||
!payload.Schemas.ContainsKey(endpoint.SchemaInfo.RequestSchemaId))
|
||||
{
|
||||
error = $"Endpoint schema reference missing: requestSchemaId='{endpoint.SchemaInfo.RequestSchemaId}'";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (endpoint.SchemaInfo.ResponseSchemaId is not null &&
|
||||
!payload.Schemas.ContainsKey(endpoint.SchemaInfo.ResponseSchemaId))
|
||||
{
|
||||
error = $"Endpoint schema reference missing: responseSchemaId='{endpoint.SchemaInfo.ResponseSchemaId}'";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static ConnectionState BuildFallbackState(TransportType transportType, string connectionId)
|
||||
{
|
||||
return new ConnectionState
|
||||
{
|
||||
ConnectionId = connectionId,
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = connectionId,
|
||||
ServiceName = "unknown",
|
||||
Version = "unknown",
|
||||
Region = "unknown"
|
||||
},
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
LastHeartbeatUtc = DateTime.UtcNow,
|
||||
TransportType = transportType
|
||||
};
|
||||
}
|
||||
|
||||
private static ConnectionState BuildConnectionState(TransportType transportType, string connectionId, HelloPayload payload)
|
||||
{
|
||||
var state = new ConnectionState
|
||||
{
|
||||
ConnectionId = connectionId,
|
||||
Instance = payload.Instance,
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
LastHeartbeatUtc = DateTime.UtcNow,
|
||||
TransportType = transportType,
|
||||
Schemas = payload.Schemas,
|
||||
OpenApiInfo = payload.OpenApiInfo
|
||||
};
|
||||
|
||||
foreach (var endpoint in payload.Endpoints)
|
||||
{
|
||||
state.Endpoints[(endpoint.Method, endpoint.Path)] = endpoint;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
private void CloseConnection(TransportType transportType, string connectionId)
|
||||
{
|
||||
if (transportType == TransportType.Tcp)
|
||||
{
|
||||
_tcpServer.GetConnection(connectionId)?.Close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (transportType == TransportType.Tls)
|
||||
{
|
||||
_tlsServer.GetConnection(connectionId)?.Close();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class EndpointKeyComparer : IEqualityComparer<(string Method, string Path)>
|
||||
{
|
||||
public bool Equals((string Method, string Path) x, (string Method, string Path) y)
|
||||
{
|
||||
return string.Equals(x.Method, y.Method, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(x.Path, y.Path, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public int GetHashCode((string Method, string Path) obj)
|
||||
{
|
||||
return HashCode.Combine(
|
||||
StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Method),
|
||||
StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Path));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Linq;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Services;
|
||||
|
||||
public sealed class GatewayMetrics
|
||||
{
|
||||
public const string MeterName = "StellaOps.Gateway.WebService";
|
||||
|
||||
private static readonly Meter Meter = new(MeterName, "1.0.0");
|
||||
private readonly IGlobalRoutingState _routingState;
|
||||
|
||||
public GatewayMetrics(IGlobalRoutingState routingState)
|
||||
{
|
||||
_routingState = routingState;
|
||||
|
||||
Meter.CreateObservableGauge(
|
||||
"gateway_active_connections",
|
||||
() => GetActiveConnections(),
|
||||
description: "Number of active microservice connections.");
|
||||
|
||||
Meter.CreateObservableGauge(
|
||||
"gateway_registered_endpoints",
|
||||
() => GetRegisteredEndpoints(),
|
||||
description: "Number of registered endpoints across all connections.");
|
||||
}
|
||||
|
||||
public long GetActiveConnections()
|
||||
{
|
||||
return _routingState.GetAllConnections().Count;
|
||||
}
|
||||
|
||||
public long GetRegisteredEndpoints()
|
||||
{
|
||||
return _routingState.GetAllConnections().Sum(c => c.Endpoints.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Threading;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Services;
|
||||
|
||||
public sealed class GatewayServiceStatus
|
||||
{
|
||||
private int _started;
|
||||
private int _ready;
|
||||
|
||||
public bool IsStarted => Volatile.Read(ref _started) == 1;
|
||||
|
||||
public bool IsReady => Volatile.Read(ref _ready) == 1;
|
||||
|
||||
public void MarkStarted()
|
||||
{
|
||||
Volatile.Write(ref _started, 1);
|
||||
}
|
||||
|
||||
public void MarkReady()
|
||||
{
|
||||
Volatile.Write(ref _ready, 1);
|
||||
}
|
||||
|
||||
public void MarkNotReady()
|
||||
{
|
||||
Volatile.Write(ref _ready, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
using System.Buffers;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading.Channels;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Transport.Tcp;
|
||||
using StellaOps.Router.Transport.Tls;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Services;
|
||||
|
||||
public sealed class GatewayTransportClient : ITransportClient
|
||||
{
|
||||
private readonly TcpTransportServer _tcpServer;
|
||||
private readonly TlsTransportServer _tlsServer;
|
||||
private readonly ILogger<GatewayTransportClient> _logger;
|
||||
private readonly ConcurrentDictionary<string, TaskCompletionSource<Frame>> _pendingRequests = new();
|
||||
private readonly ConcurrentDictionary<string, Channel<Frame>> _streamingResponses = new();
|
||||
|
||||
public GatewayTransportClient(
|
||||
TcpTransportServer tcpServer,
|
||||
TlsTransportServer tlsServer,
|
||||
ILogger<GatewayTransportClient> logger)
|
||||
{
|
||||
_tcpServer = tcpServer;
|
||||
_tlsServer = tlsServer;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Frame> SendRequestAsync(
|
||||
ConnectionState connection,
|
||||
Frame requestFrame,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var correlationId = EnsureCorrelationId(requestFrame);
|
||||
var frame = requestFrame with { CorrelationId = correlationId };
|
||||
|
||||
var tcs = new TaskCompletionSource<Frame>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
if (!_pendingRequests.TryAdd(correlationId, tcs))
|
||||
{
|
||||
throw new InvalidOperationException($"Duplicate correlation ID {correlationId}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await SendFrameAsync(connection, frame, cancellationToken);
|
||||
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(timeout);
|
||||
|
||||
return await tcs.Task.WaitAsync(timeoutCts.Token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pendingRequests.TryRemove(correlationId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SendCancelAsync(ConnectionState connection, Guid correlationId, string? reason = null)
|
||||
{
|
||||
var frame = new Frame
|
||||
{
|
||||
Type = FrameType.Cancel,
|
||||
CorrelationId = correlationId.ToString("N"),
|
||||
Payload = ReadOnlyMemory<byte>.Empty
|
||||
};
|
||||
|
||||
await SendFrameAsync(connection, frame, CancellationToken.None);
|
||||
}
|
||||
|
||||
public async Task SendStreamingAsync(
|
||||
ConnectionState connection,
|
||||
Frame requestHeader,
|
||||
Stream requestBody,
|
||||
Func<Stream, Task> readResponseBody,
|
||||
PayloadLimits limits,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var correlationId = EnsureCorrelationId(requestHeader);
|
||||
var headerFrame = requestHeader with
|
||||
{
|
||||
Type = FrameType.Request,
|
||||
CorrelationId = correlationId
|
||||
};
|
||||
|
||||
var channel = Channel.CreateUnbounded<Frame>(new UnboundedChannelOptions
|
||||
{
|
||||
SingleReader = true,
|
||||
SingleWriter = false
|
||||
});
|
||||
|
||||
if (!_streamingResponses.TryAdd(correlationId, channel))
|
||||
{
|
||||
throw new InvalidOperationException($"Duplicate correlation ID {correlationId}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await SendFrameAsync(connection, headerFrame, cancellationToken);
|
||||
await StreamRequestBodyAsync(connection, correlationId, requestBody, limits, cancellationToken);
|
||||
|
||||
using var responseStream = new MemoryStream();
|
||||
await ReadStreamingResponseAsync(channel.Reader, responseStream, cancellationToken);
|
||||
responseStream.Position = 0;
|
||||
await readResponseBody(responseStream);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_streamingResponses.TryRemove(correlationId, out var removed))
|
||||
{
|
||||
removed.Writer.TryComplete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void HandleResponseFrame(Frame frame)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(frame.CorrelationId))
|
||||
{
|
||||
_logger.LogDebug("Ignoring response frame without correlation ID");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_pendingRequests.TryGetValue(frame.CorrelationId, out var pending))
|
||||
{
|
||||
pending.TrySetResult(frame);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_streamingResponses.TryGetValue(frame.CorrelationId, out var channel))
|
||||
{
|
||||
channel.Writer.TryWrite(frame);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("No pending request for correlation ID {CorrelationId}", frame.CorrelationId);
|
||||
}
|
||||
|
||||
private async Task SendFrameAsync(ConnectionState connection, Frame frame, CancellationToken cancellationToken)
|
||||
{
|
||||
switch (connection.TransportType)
|
||||
{
|
||||
case TransportType.Tcp:
|
||||
await _tcpServer.SendFrameAsync(connection.ConnectionId, frame, cancellationToken);
|
||||
break;
|
||||
case TransportType.Tls:
|
||||
await _tlsServer.SendFrameAsync(connection.ConnectionId, frame, cancellationToken);
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException($"Transport type {connection.TransportType} is not supported by the gateway.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string EnsureCorrelationId(Frame frame)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(frame.CorrelationId))
|
||||
{
|
||||
return frame.CorrelationId;
|
||||
}
|
||||
|
||||
return Guid.NewGuid().ToString("N");
|
||||
}
|
||||
|
||||
private async Task StreamRequestBodyAsync(
|
||||
ConnectionState connection,
|
||||
string correlationId,
|
||||
Stream requestBody,
|
||||
PayloadLimits limits,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(8192);
|
||||
try
|
||||
{
|
||||
long totalBytesRead = 0;
|
||||
int bytesRead;
|
||||
|
||||
while ((bytesRead = await requestBody.ReadAsync(buffer, cancellationToken)) > 0)
|
||||
{
|
||||
totalBytesRead += bytesRead;
|
||||
|
||||
if (totalBytesRead > limits.MaxRequestBytesPerCall)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Request body exceeds limit of {limits.MaxRequestBytesPerCall} bytes");
|
||||
}
|
||||
|
||||
var dataFrame = new Frame
|
||||
{
|
||||
Type = FrameType.RequestStreamData,
|
||||
CorrelationId = correlationId,
|
||||
Payload = new ReadOnlyMemory<byte>(buffer, 0, bytesRead)
|
||||
};
|
||||
await SendFrameAsync(connection, dataFrame, cancellationToken);
|
||||
}
|
||||
|
||||
var endFrame = new Frame
|
||||
{
|
||||
Type = FrameType.RequestStreamData,
|
||||
CorrelationId = correlationId,
|
||||
Payload = ReadOnlyMemory<byte>.Empty
|
||||
};
|
||||
await SendFrameAsync(connection, endFrame, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ReadStreamingResponseAsync(
|
||||
ChannelReader<Frame> reader,
|
||||
Stream responseStream,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
while (await reader.WaitToReadAsync(cancellationToken))
|
||||
{
|
||||
while (reader.TryRead(out var frame))
|
||||
{
|
||||
if (frame.Type == FrameType.ResponseStreamData)
|
||||
{
|
||||
if (frame.Payload.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await responseStream.WriteAsync(frame.Payload, cancellationToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (frame.Type == FrameType.Response)
|
||||
{
|
||||
if (frame.Payload.Length > 0)
|
||||
{
|
||||
await responseStream.WriteAsync(frame.Payload, cancellationToken);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Gateway\StellaOps.Router.Gateway.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Transport.Tcp\StellaOps.Router.Transport.Tcp.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Transport.Tls\StellaOps.Router.Transport.Tls.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Auth.Security\StellaOps.Auth.Security.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"Gateway": {
|
||||
"Transports": {
|
||||
"Tcp": {
|
||||
"Enabled": false
|
||||
},
|
||||
"Tls": {
|
||||
"Enabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
68
src/Gateway/StellaOps.Gateway.WebService/appsettings.json
Normal file
68
src/Gateway/StellaOps.Gateway.WebService/appsettings.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"Gateway": {
|
||||
"Node": {
|
||||
"Region": "local",
|
||||
"NodeId": "gw-local-01",
|
||||
"Environment": "dev",
|
||||
"NeighborRegions": []
|
||||
},
|
||||
"Transports": {
|
||||
"Tcp": {
|
||||
"Enabled": false,
|
||||
"BindAddress": "0.0.0.0",
|
||||
"Port": 9100,
|
||||
"ReceiveBufferSize": 65536,
|
||||
"SendBufferSize": 65536,
|
||||
"MaxFrameSize": 16777216
|
||||
},
|
||||
"Tls": {
|
||||
"Enabled": false,
|
||||
"BindAddress": "0.0.0.0",
|
||||
"Port": 9443,
|
||||
"ReceiveBufferSize": 65536,
|
||||
"SendBufferSize": 65536,
|
||||
"MaxFrameSize": 16777216,
|
||||
"CertificatePath": "",
|
||||
"CertificateKeyPath": "",
|
||||
"CertificatePassword": "",
|
||||
"RequireClientCertificate": false,
|
||||
"AllowSelfSigned": false
|
||||
}
|
||||
},
|
||||
"Routing": {
|
||||
"DefaultTimeout": "30s",
|
||||
"MaxRequestBodySize": "100MB",
|
||||
"StreamingEnabled": true,
|
||||
"PreferLocalRegion": true,
|
||||
"AllowDegradedInstances": true,
|
||||
"StrictVersionMatching": true,
|
||||
"NeighborRegions": []
|
||||
},
|
||||
"Auth": {
|
||||
"DpopEnabled": true,
|
||||
"MtlsEnabled": false,
|
||||
"AllowAnonymous": true,
|
||||
"Authority": {
|
||||
"Issuer": "",
|
||||
"RequireHttpsMetadata": true,
|
||||
"MetadataAddress": "",
|
||||
"Audiences": [],
|
||||
"RequiredScopes": []
|
||||
}
|
||||
},
|
||||
"OpenApi": {
|
||||
"Enabled": true,
|
||||
"CacheTtlSeconds": 300,
|
||||
"Title": "StellaOps Gateway API",
|
||||
"Description": "Unified API aggregating all connected microservices.",
|
||||
"Version": "1.0.0",
|
||||
"ServerUrl": "/",
|
||||
"TokenUrl": "/auth/token"
|
||||
},
|
||||
"Health": {
|
||||
"StaleThreshold": "30s",
|
||||
"DegradedThreshold": "15s",
|
||||
"CheckInterval": "5s"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user