Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.

This commit is contained in:
StellaOps Bot
2025-12-26 21:54:17 +02:00
parent 335ff7da16
commit c2b9cd8d1f
3717 changed files with 264714 additions and 48202 deletions

View File

@@ -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);
}

View File

@@ -0,0 +1,96 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Gateway.Authorization;
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);
}
}

View File

@@ -0,0 +1,15 @@
using StellaOps.Router.Common.Models;
using StellaOps.Router.Gateway.Authorization;
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);
}

View File

@@ -0,0 +1,213 @@
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 GatewayMessagingTransportOptions Messaging { get; set; } = new();
}
public sealed class GatewayMessagingTransportOptions
{
/// <summary>
/// Whether messaging (Valkey) transport is enabled.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Valkey connection string (e.g., "localhost:6379" or "valkey:6379,password=secret").
/// </summary>
public string ConnectionString { get; set; } = "localhost:6379";
/// <summary>
/// Valkey database number.
/// </summary>
public int? Database { get; set; }
/// <summary>
/// Queue name template for incoming requests. Use {service} placeholder.
/// </summary>
public string RequestQueueTemplate { get; set; } = "router:requests:{service}";
/// <summary>
/// Queue name for gateway responses.
/// </summary>
public string ResponseQueueName { get; set; } = "router:responses";
/// <summary>
/// Consumer group name for request processing.
/// </summary>
public string ConsumerGroup { get; set; } = "router-gateway";
/// <summary>
/// Timeout for RPC requests.
/// </summary>
public string RequestTimeout { get; set; } = "30s";
/// <summary>
/// Lease duration for message processing.
/// </summary>
public string LeaseDuration { get; set; } = "5m";
/// <summary>
/// Batch size for leasing messages.
/// </summary>
public int BatchSize { get; set; } = 10;
/// <summary>
/// Heartbeat interval.
/// </summary>
public string HeartbeatInterval { get; set; } = "10s";
}
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;
/// <summary>
/// Enable legacy X-Stella-* headers in addition to X-StellaOps-* headers.
/// Default: true (for migration compatibility).
/// </summary>
public bool EnableLegacyHeaders { get; set; } = true;
/// <summary>
/// Allow client-provided scope headers in offline/pre-prod mode.
/// Default: false (forbidden for security).
/// WARNING: Only enable this in explicitly isolated offline/pre-prod environments.
/// </summary>
public bool AllowScopeHeader { get; set; } = false;
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";
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View 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"]

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,13 @@
namespace StellaOps.Gateway.WebService.Middleware;
public static class GatewayContextKeys
{
public const string TenantId = "Gateway.TenantId";
public const string ProjectId = "Gateway.ProjectId";
public const string Actor = "Gateway.Actor";
public const string Scopes = "Gateway.Scopes";
public const string DpopThumbprint = "Gateway.DpopThumbprint";
public const string MtlsThumbprint = "Gateway.MtlsThumbprint";
public const string CnfJson = "Gateway.CnfJson";
public const string IsAnonymous = "Gateway.IsAnonymous";
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,333 @@
using System.Security.Claims;
using System.Text.Json;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Gateway.WebService.Middleware;
/// <summary>
/// Middleware that enforces the Gateway identity header policy:
/// 1. Strips all reserved identity headers from incoming requests (prevents spoofing)
/// 2. Computes effective identity from validated principal claims
/// 3. Writes downstream identity headers for microservice consumption
/// 4. Stores normalized identity context in HttpContext.Items
/// </summary>
/// <remarks>
/// This middleware replaces the legacy ClaimsPropagationMiddleware and TenantMiddleware
/// which used "set-if-missing" semantics that allowed client header spoofing.
/// </remarks>
public sealed class IdentityHeaderPolicyMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<IdentityHeaderPolicyMiddleware> _logger;
private readonly IdentityHeaderPolicyOptions _options;
/// <summary>
/// Reserved identity headers that must never be trusted from external clients.
/// These are stripped from incoming requests and overwritten from validated claims.
/// </summary>
private static readonly string[] ReservedHeaders =
[
// StellaOps canonical headers
"X-StellaOps-Tenant",
"X-StellaOps-Project",
"X-StellaOps-Actor",
"X-StellaOps-Scopes",
"X-StellaOps-Client",
// Legacy Stella headers (compatibility)
"X-Stella-Tenant",
"X-Stella-Project",
"X-Stella-Actor",
"X-Stella-Scopes",
// Raw claim headers (internal/legacy pass-through)
"sub",
"tid",
"scope",
"scp",
"cnf",
"cnf.jkt"
];
public IdentityHeaderPolicyMiddleware(
RequestDelegate next,
ILogger<IdentityHeaderPolicyMiddleware> logger,
IdentityHeaderPolicyOptions options)
{
_next = next;
_logger = logger;
_options = options;
}
public async Task InvokeAsync(HttpContext context)
{
// Skip processing for system paths (health, metrics, openapi, etc.)
if (GatewayRoutes.IsSystemPath(context.Request.Path))
{
await _next(context);
return;
}
// Step 1: Strip all reserved identity headers from incoming request
StripReservedHeaders(context);
// Step 2: Extract identity from validated principal
var identity = ExtractIdentity(context);
// Step 3: Store normalized identity in HttpContext.Items
StoreIdentityContext(context, identity);
// Step 4: Write downstream identity headers
WriteDownstreamHeaders(context, identity);
await _next(context);
}
private void StripReservedHeaders(HttpContext context)
{
foreach (var header in ReservedHeaders)
{
if (context.Request.Headers.ContainsKey(header))
{
_logger.LogDebug(
"Stripped reserved identity header {Header} from request {TraceId}",
header,
context.TraceIdentifier);
context.Request.Headers.Remove(header);
}
}
}
private IdentityContext ExtractIdentity(HttpContext context)
{
var principal = context.User;
var isAuthenticated = principal.Identity?.IsAuthenticated == true;
if (!isAuthenticated)
{
return new IdentityContext
{
IsAnonymous = true,
Actor = "anonymous",
Scopes = _options.AnonymousScopes ?? []
};
}
// Extract subject (actor)
var actor = principal.FindFirstValue(StellaOpsClaimTypes.Subject);
// Extract tenant - try canonical claim first, then legacy 'tid'
var tenant = principal.FindFirstValue(StellaOpsClaimTypes.Tenant)
?? principal.FindFirstValue("tid");
// Extract project (optional)
var project = principal.FindFirstValue(StellaOpsClaimTypes.Project);
// Extract scopes - try 'scp' claims first (individual items), then 'scope' (space-separated)
var scopes = ExtractScopes(principal);
// Extract cnf (confirmation claim) for DPoP/sender constraint
var cnfJson = principal.FindFirstValue("cnf");
string? dpopThumbprint = null;
if (!string.IsNullOrWhiteSpace(cnfJson))
{
TryParseCnfThumbprint(cnfJson, out dpopThumbprint);
}
return new IdentityContext
{
IsAnonymous = false,
Actor = actor,
Tenant = tenant,
Project = project,
Scopes = scopes,
CnfJson = cnfJson,
DpopThumbprint = dpopThumbprint
};
}
private static HashSet<string> ExtractScopes(ClaimsPrincipal principal)
{
var scopes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// First try individual scope claims (scp)
var scpClaims = principal.FindAll(StellaOpsClaimTypes.ScopeItem);
foreach (var claim in scpClaims)
{
if (!string.IsNullOrWhiteSpace(claim.Value))
{
scopes.Add(claim.Value.Trim());
}
}
// If no scp claims, try space-separated scope claim
if (scopes.Count == 0)
{
var scopeClaims = principal.FindAll(StellaOpsClaimTypes.Scope);
foreach (var claim in scopeClaims)
{
if (!string.IsNullOrWhiteSpace(claim.Value))
{
var parts = claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var part in parts)
{
scopes.Add(part);
}
}
}
}
return scopes;
}
private void StoreIdentityContext(HttpContext context, IdentityContext identity)
{
context.Items[GatewayContextKeys.IsAnonymous] = identity.IsAnonymous;
if (!string.IsNullOrEmpty(identity.Actor))
{
context.Items[GatewayContextKeys.Actor] = identity.Actor;
}
if (!string.IsNullOrEmpty(identity.Tenant))
{
context.Items[GatewayContextKeys.TenantId] = identity.Tenant;
}
if (!string.IsNullOrEmpty(identity.Project))
{
context.Items[GatewayContextKeys.ProjectId] = identity.Project;
}
if (identity.Scopes.Count > 0)
{
context.Items[GatewayContextKeys.Scopes] = identity.Scopes;
}
if (!string.IsNullOrEmpty(identity.CnfJson))
{
context.Items[GatewayContextKeys.CnfJson] = identity.CnfJson;
}
if (!string.IsNullOrEmpty(identity.DpopThumbprint))
{
context.Items[GatewayContextKeys.DpopThumbprint] = identity.DpopThumbprint;
}
}
private void WriteDownstreamHeaders(HttpContext context, IdentityContext identity)
{
var headers = context.Request.Headers;
// Actor header
if (!string.IsNullOrEmpty(identity.Actor))
{
headers["X-StellaOps-Actor"] = identity.Actor;
if (_options.EnableLegacyHeaders)
{
headers["X-Stella-Actor"] = identity.Actor;
}
}
// Tenant header
if (!string.IsNullOrEmpty(identity.Tenant))
{
headers["X-StellaOps-Tenant"] = identity.Tenant;
if (_options.EnableLegacyHeaders)
{
headers["X-Stella-Tenant"] = identity.Tenant;
}
}
// Project header (optional)
if (!string.IsNullOrEmpty(identity.Project))
{
headers["X-StellaOps-Project"] = identity.Project;
if (_options.EnableLegacyHeaders)
{
headers["X-Stella-Project"] = identity.Project;
}
}
// Scopes header (space-delimited, sorted for determinism)
if (identity.Scopes.Count > 0)
{
var sortedScopes = identity.Scopes.OrderBy(s => s, StringComparer.Ordinal);
var scopesValue = string.Join(" ", sortedScopes);
headers["X-StellaOps-Scopes"] = scopesValue;
if (_options.EnableLegacyHeaders)
{
headers["X-Stella-Scopes"] = scopesValue;
}
}
else if (identity.IsAnonymous)
{
// Explicit empty scopes for anonymous to prevent ambiguity
headers["X-StellaOps-Scopes"] = string.Empty;
if (_options.EnableLegacyHeaders)
{
headers["X-Stella-Scopes"] = string.Empty;
}
}
// DPoP thumbprint (if present)
if (!string.IsNullOrEmpty(identity.DpopThumbprint))
{
headers["cnf.jkt"] = identity.DpopThumbprint;
}
}
private static bool TryParseCnfThumbprint(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;
}
}
private sealed class IdentityContext
{
public bool IsAnonymous { get; init; }
public string? Actor { get; init; }
public string? Tenant { get; init; }
public string? Project { get; init; }
public HashSet<string> Scopes { get; init; } = [];
public string? CnfJson { get; init; }
public string? DpopThumbprint { get; init; }
}
}
/// <summary>
/// Configuration options for the identity header policy middleware.
/// </summary>
public sealed class IdentityHeaderPolicyOptions
{
/// <summary>
/// Enable legacy X-Stella-* headers in addition to X-StellaOps-* headers.
/// Default: true (for migration compatibility).
/// </summary>
public bool EnableLegacyHeaders { get; set; } = true;
/// <summary>
/// Scopes to assign to anonymous requests.
/// Default: empty (no scopes).
/// </summary>
public HashSet<string>? AnonymousScopes { get; set; }
/// <summary>
/// Allow client-provided scope headers in offline/pre-prod mode.
/// Default: false (forbidden for security).
/// </summary>
public bool AllowScopeHeaderOverride { get; set; } = false;
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,323 @@
using System.Net;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Auth.Abstractions;
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.DependencyInjection;
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;
using StellaOps.Router.Transport.Messaging;
using StellaOps.Router.Transport.Messaging.Options;
using StellaOps.Messaging.DependencyInjection;
using StellaOps.Messaging.Transport.Valkey;
using StellaOps.Router.AspNet;
using StellaOps.Router.Common.Plugins;
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>();
// Load router transport plugins
var transportPluginLoader = new RouterTransportPluginLoader(
NullLoggerFactory.Instance.CreateLogger<RouterTransportPluginLoader>());
// Try to load from plugins directory, fallback to direct registration if not found
var pluginsPath = Path.Combine(AppContext.BaseDirectory, "plugins", "router", "transports");
if (Directory.Exists(pluginsPath))
{
transportPluginLoader.LoadFromDirectory(pluginsPath);
}
// Register TCP and TLS transports (from plugins or fallback to compile-time references)
var tcpPlugin = transportPluginLoader.GetPlugin("tcp");
var tlsPlugin = transportPluginLoader.GetPlugin("tls");
if (tcpPlugin is not null)
{
tcpPlugin.Register(new RouterTransportRegistrationContext(
builder.Services, builder.Configuration, RouterTransportMode.Server)
{
ConfigurationSection = "Gateway:Transports:Tcp"
});
}
else
{
// Fallback to compile-time registration
builder.Services.AddTcpTransportServer();
}
if (tlsPlugin is not null)
{
tlsPlugin.Register(new RouterTransportRegistrationContext(
builder.Services, builder.Configuration, RouterTransportMode.Server)
{
ConfigurationSection = "Gateway:Transports:Tls"
});
}
else
{
// Fallback to compile-time registration
builder.Services.AddTlsTransportServer();
}
// Messaging transport (Valkey)
if (bootstrapOptions.Transports.Messaging.Enabled)
{
builder.Services.AddMessagingTransport<ValkeyTransportPlugin>(builder.Configuration, "Gateway:Transports:Messaging");
builder.Services.AddMessagingTransportServer();
}
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>();
// Identity header policy options
builder.Services.AddSingleton(new IdentityHeaderPolicyOptions
{
EnableLegacyHeaders = bootstrapOptions.Auth.EnableLegacyHeaders,
AllowScopeHeaderOverride = bootstrapOptions.Auth.AllowScopeHeader
});
ConfigureAuthentication(builder, bootstrapOptions);
ConfigureGatewayOptionsMapping(builder, bootstrapOptions);
// Stella Router integration
var routerOptions = builder.Configuration.GetSection("Gateway:Router").Get<StellaRouterOptionsBase>();
builder.Services.TryAddStellaRouter(
serviceName: "gateway",
version: typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0",
routerOptions: routerOptions);
var app = builder.Build();
app.UseMiddleware<CorrelationIdMiddleware>();
app.UseAuthentication();
app.UseMiddleware<SenderConstraintMiddleware>();
// IdentityHeaderPolicyMiddleware replaces TenantMiddleware and ClaimsPropagationMiddleware
// It strips reserved identity headers and overwrites them from validated claims (security fix)
app.UseMiddleware<IdentityHeaderPolicyMiddleware>();
app.UseMiddleware<HealthCheckMiddleware>();
app.TryUseStellaRouter(routerOptions);
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>();
});
// Refresh Router endpoint cache
app.TryRefreshStellaRouterEndpoints(routerOptions);
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;
});
builder.Services.AddOptions<MessagingTransportOptions>()
.Configure<IOptions<GatewayOptions>>((options, gateway) =>
{
var messaging = gateway.Value.Transports.Messaging;
options.RequestQueueTemplate = messaging.RequestQueueTemplate;
options.ResponseQueueName = messaging.ResponseQueueName;
options.ConsumerGroup = messaging.ConsumerGroup;
options.RequestTimeout = GatewayValueParser.ParseDuration(messaging.RequestTimeout, TimeSpan.FromSeconds(30));
options.LeaseDuration = GatewayValueParser.ParseDuration(messaging.LeaseDuration, TimeSpan.FromMinutes(5));
options.BatchSize = messaging.BatchSize;
options.HeartbeatInterval = GatewayValueParser.ParseDuration(messaging.HeartbeatInterval, TimeSpan.FromSeconds(10));
});
builder.Services.AddOptions<ValkeyTransportOptions>()
.Configure<IOptions<GatewayOptions>>((options, gateway) =>
{
var messaging = gateway.Value.Transports.Messaging;
options.ConnectionString = messaging.ConnectionString;
options.Database = messaging.Database;
});
}

View File

@@ -0,0 +1,12 @@
{
"profiles": {
"StellaOps.Gateway.WebService": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:62515;http://localhost:62516"
}
}
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,531 @@
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;
using StellaOps.Router.Transport.Messaging;
namespace StellaOps.Gateway.WebService.Services;
public sealed class GatewayHostedService : IHostedService
{
private readonly TcpTransportServer _tcpServer;
private readonly TlsTransportServer _tlsServer;
private readonly MessagingTransportServer? _messagingServer;
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;
private bool _messagingEnabled;
public GatewayHostedService(
TcpTransportServer tcpServer,
TlsTransportServer tlsServer,
IGlobalRoutingState routingState,
GatewayTransportClient transportClient,
IEffectiveClaimsStore claimsStore,
IOptions<GatewayOptions> options,
GatewayServiceStatus status,
ILogger<GatewayHostedService> logger,
IRouterOpenApiDocumentCache? openApiCache = null,
MessagingTransportServer? messagingServer = null)
{
_tcpServer = tcpServer;
_tlsServer = tlsServer;
_messagingServer = messagingServer;
_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;
_messagingEnabled = options.Transports.Messaging.Enabled && _messagingServer is not null;
if (!_tcpEnabled && !_tlsEnabled && !_messagingEnabled)
{
_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);
}
if (_messagingEnabled && _messagingServer is not null)
{
_messagingServer.OnHelloReceived += HandleMessagingHello;
_messagingServer.OnHeartbeatReceived += HandleMessagingHeartbeat;
_messagingServer.OnResponseReceived += HandleMessagingResponse;
_messagingServer.OnConnectionClosed += HandleMessagingDisconnection;
await _messagingServer.StartAsync(cancellationToken);
_logger.LogInformation("Messaging transport started (Valkey connection: {Connection})",
options.Transports.Messaging.ConnectionString);
}
_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;
}
if (_messagingEnabled && _messagingServer is not null)
{
await _messagingServer.StopAsync(cancellationToken);
_messagingServer.OnHelloReceived -= HandleMessagingHello;
_messagingServer.OnHeartbeatReceived -= HandleMessagingHeartbeat;
_messagingServer.OnResponseReceived -= HandleMessagingResponse;
_messagingServer.OnConnectionClosed -= HandleMessagingDisconnection;
}
}
private void HandleTcpFrame(string connectionId, Frame frame)
{
_ = HandleFrameAsync(TransportType.Tcp, connectionId, frame);
}
private void HandleTlsFrame(string connectionId, Frame frame)
{
_ = HandleFrameAsync(TransportType.Certificate, 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.Certificate)
{
_tlsServer.GetConnection(connectionId)?.Close();
}
// Messaging transport connections are managed by the queue system
// and do not support explicit close operations
}
#region Messaging Transport Event Handlers
private Task HandleMessagingHello(ConnectionState state, HelloPayload payload)
{
// The MessagingTransportServer already built the ConnectionState with TransportType.Messaging
// We need to add it to the routing state and update the claims store
_routingState.AddConnection(state);
_claimsStore.UpdateFromMicroservice(payload.Instance.ServiceName, payload.Endpoints);
_openApiCache?.Invalidate();
_logger.LogInformation(
"Messaging connection registered: {ConnectionId} service={ServiceName} version={Version}",
state.ConnectionId,
state.Instance.ServiceName,
state.Instance.Version);
return Task.CompletedTask;
}
private Task HandleMessagingHeartbeat(ConnectionState state, HeartbeatPayload payload)
{
_routingState.UpdateConnection(state.ConnectionId, conn =>
{
conn.LastHeartbeatUtc = DateTime.UtcNow;
conn.Status = payload.Status;
});
return Task.CompletedTask;
}
private Task HandleMessagingResponse(ConnectionState state, Frame frame)
{
_transportClient.HandleResponseFrame(frame);
return Task.CompletedTask;
}
private Task HandleMessagingDisconnection(string connectionId)
{
HandleDisconnect(connectionId);
return Task.CompletedTask;
}
#endregion
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));
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,253 @@
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;
using StellaOps.Router.Transport.Messaging;
namespace StellaOps.Gateway.WebService.Services;
public sealed class GatewayTransportClient : ITransportClient
{
private readonly TcpTransportServer _tcpServer;
private readonly TlsTransportServer _tlsServer;
private readonly MessagingTransportServer? _messagingServer;
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,
MessagingTransportServer? messagingServer = null)
{
_tcpServer = tcpServer;
_tlsServer = tlsServer;
_messagingServer = messagingServer;
_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.Certificate:
await _tlsServer.SendFrameAsync(connection.ConnectionId, frame, cancellationToken);
break;
case TransportType.Messaging:
if (_messagingServer is null)
{
throw new InvalidOperationException("Messaging transport is not enabled");
}
await _messagingServer.SendToMicroserviceAsync(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;
}
}
}
}
}

View File

@@ -0,0 +1,24 @@
<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>
<!-- Router Libraries (now in same module) -->
<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.Router.Transport.Messaging\StellaOps.Router.Transport.Messaging.csproj" />
<ProjectReference Include="..\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj" />
<ProjectReference Include="..\__Libraries\StellaOps.Messaging.Transport.Valkey\StellaOps.Messaging.Transport.Valkey.csproj" />
<ProjectReference Include="..\__Libraries\StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
<!-- External Dependencies -->
<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>

View File

@@ -0,0 +1,12 @@
{
"Gateway": {
"Transports": {
"Tcp": {
"Enabled": false
},
"Tls": {
"Enabled": false
}
}
}
}

View 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"
}
}
}