Refactor code structure for improved readability and maintainability; optimize performance in key functions.

This commit is contained in:
master
2025-12-22 19:06:31 +02:00
parent dfaa2079aa
commit 4602ccc3a3
1444 changed files with 109919 additions and 8058 deletions

43
src/Gateway/AGENTS.md Normal file
View 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.

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

View File

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

View File

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

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,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";
}

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

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

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

View File

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

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