stela ops usage fixes roles propagation and timoeut, one account to support multi tenants, migrations consolidation, search to support documentation, doctor and open api vector db search

This commit is contained in:
master
2026-02-22 19:27:54 +02:00
parent a29f438f53
commit bd8fee6ed8
373 changed files with 832097 additions and 3369 deletions

View File

@@ -36,6 +36,25 @@ public sealed class AuthorizationMiddleware
return;
}
if (endpoint.AllowAnonymous)
{
await _next(context);
return;
}
var requiresAuthentication = EndpointAuthorizationSemantics.ResolveRequiresAuthentication(endpoint);
var isAuthenticated = context.User.Identity?.IsAuthenticated == true;
if (requiresAuthentication && !isAuthenticated)
{
_logger.LogWarning(
"Authorization failed for {Method} {Path}: unauthenticated principal",
endpoint.Method,
endpoint.Path);
await WriteUnauthorizedAsync(context, endpoint);
return;
}
var effectiveClaims = _claimsStore.GetEffectiveClaims(
endpoint.ServiceName,
endpoint.Method,
@@ -71,6 +90,22 @@ public sealed class AuthorizationMiddleware
await _next(context);
}
private static Task WriteUnauthorizedAsync(HttpContext context, EndpointDescriptor endpoint)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
context.Response.ContentType = "application/json; charset=utf-8";
var payload = new AuthorizationFailureResponse(
Error: "unauthorized",
Message: "Authentication required",
RequiredClaimType: string.Empty,
RequiredClaimValue: null,
Service: endpoint.ServiceName,
Version: endpoint.Version);
return JsonSerializer.SerializeAsync(context.Response.Body, payload, JsonOptions, context.RequestAborted);
}
private static Task WriteForbiddenAsync(
HttpContext context,
EndpointDescriptor endpoint,

View File

@@ -3,13 +3,6 @@ using StellaOps.Router.Gateway.Authorization;
namespace StellaOps.Gateway.WebService.Authorization;
public interface IEffectiveClaimsStore
public interface IEffectiveClaimsStore : StellaOps.Router.Gateway.Authorization.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

@@ -64,6 +64,12 @@ public sealed class GatewayMessagingTransportOptions
/// </summary>
public string RequestQueueTemplate { get; set; } = "router:requests:{service}";
/// <summary>
/// Reserved queue segment used for gateway control traffic.
/// Must not overlap with a real microservice name.
/// </summary>
public string GatewayControlQueueServiceName { get; set; } = "gateway-control";
/// <summary>
/// Queue name for gateway responses.
/// </summary>
@@ -139,6 +145,11 @@ public sealed class GatewayRoutingOptions
{
public string DefaultTimeout { get; set; } = "30s";
/// <summary>
/// Global timeout cap applied after endpoint and route timeout resolution.
/// </summary>
public string GlobalTimeoutCap { get; set; } = "120s";
public string MaxRequestBodySize { get; set; } = "100MB";
public bool StreamingEnabled { get; set; } = true;
@@ -173,6 +184,26 @@ public sealed class GatewayAuthOptions
/// </summary>
public bool AllowScopeHeader { get; set; } = false;
/// <summary>
/// Emit signed identity envelope headers for router-dispatched requests.
/// </summary>
public bool EmitIdentityEnvelope { get; set; } = true;
/// <summary>
/// Shared signing key used to sign identity envelopes.
/// </summary>
public string? IdentityEnvelopeSigningKey { get; set; }
/// <summary>
/// Identity envelope issuer identifier.
/// </summary>
public string IdentityEnvelopeIssuer { get; set; } = "stellaops-gateway-router";
/// <summary>
/// Identity envelope TTL in seconds.
/// </summary>
public int IdentityEnvelopeTtlSeconds { get; set; } = 120;
public GatewayAuthorityOptions Authority { get; set; } = new();
}
@@ -184,6 +215,11 @@ public sealed class GatewayAuthorityOptions
public string? MetadataAddress { get; set; }
/// <summary>
/// Optional explicit base URL for Authority claims override endpoint discovery.
/// </summary>
public string? ClaimsOverridesUrl { get; set; }
public List<string> Audiences { get; set; } = new();
public List<string> RequiredScopes { get; set; } = new();

View File

@@ -33,6 +33,7 @@ public static class GatewayOptionsValidator
}
_ = GatewayValueParser.ParseDuration(options.Routing.DefaultTimeout, TimeSpan.FromSeconds(30));
_ = GatewayValueParser.ParseDuration(options.Routing.GlobalTimeoutCap, TimeSpan.FromSeconds(120));
_ = GatewayValueParser.ParseSizeBytes(options.Routing.MaxRequestBodySize, 0);
_ = GatewayValueParser.ParseDuration(options.Health.StaleThreshold, TimeSpan.FromSeconds(30));
@@ -115,6 +116,10 @@ public static class GatewayOptionsValidator
break;
case StellaOpsRouteType.Microservice:
if (!string.IsNullOrWhiteSpace(route.DefaultTimeout))
{
_ = GatewayValueParser.ParseDuration(route.DefaultTimeout, TimeSpan.FromSeconds(30));
}
break;
}
}

View File

@@ -1,5 +1,6 @@
using StellaOps.Auth.Abstractions;
using StellaOps.Router.Common.Identity;
using System.Security.Claims;
using System.Text.Json;
@@ -42,6 +43,10 @@ public sealed class IdentityHeaderPolicyMiddleware
// Headers used by downstream services in header-based auth mode
"X-Scopes",
"X-Tenant-Id",
// Gateway-issued signed identity envelope headers
"X-StellaOps-Identity-Envelope",
"X-StellaOps-Identity-Envelope-Signature",
"X-StellaOps-Identity-Envelope-Algorithm",
// Raw claim headers (internal/legacy pass-through)
"sub",
"tid",
@@ -74,18 +79,11 @@ public sealed class IdentityHeaderPolicyMiddleware
return;
}
// Step 0: Preserve client-sent tenant header before stripping.
// When the Gateway runs in AllowAnonymous mode (no JWT validation),
// the principal has no claims and we cannot determine tenant from the token.
// In that case, we pass through the client-provided value and let the
// upstream service validate it against the JWT's tenant claim.
var clientTenant = context.Request.Headers["X-StellaOps-Tenant"].ToString();
// Step 1: Strip all reserved identity headers from incoming request
StripReservedHeaders(context);
// Step 2: Extract identity from validated principal
var identity = ExtractIdentity(context, clientTenant);
var identity = ExtractIdentity(context);
// Step 3: Store normalized identity in HttpContext.Items
StoreIdentityContext(context, identity);
@@ -121,23 +119,17 @@ public sealed class IdentityHeaderPolicyMiddleware
}
}
private IdentityContext ExtractIdentity(HttpContext context, string? clientTenant = null)
private IdentityContext ExtractIdentity(HttpContext context)
{
var principal = context.User;
var isAuthenticated = principal.Identity?.IsAuthenticated == true;
if (!isAuthenticated)
{
// In AllowAnonymous mode the Gateway cannot validate identity claims.
// Pass through the client-provided tenant so the upstream service
// can validate it against the JWT's own tenant claim.
var passThruTenant = !string.IsNullOrWhiteSpace(clientTenant) ? clientTenant.Trim() : "default";
return new IdentityContext
{
IsAnonymous = true,
Actor = "anonymous",
Tenant = passThruTenant,
Scopes = _options.AnonymousScopes ?? []
};
}
@@ -146,10 +138,9 @@ public sealed class IdentityHeaderPolicyMiddleware
var actor = principal.FindFirstValue(StellaOpsClaimTypes.Subject);
// Extract tenant - try canonical claim first, then legacy 'tid',
// then client-provided header, then fall back to "default"
// then fall back to "default".
var tenant = principal.FindFirstValue(StellaOpsClaimTypes.Tenant)
?? principal.FindFirstValue("tid")
?? (!string.IsNullOrWhiteSpace(clientTenant) ? clientTenant.Trim() : null)
?? "default";
// Extract project (optional)
@@ -157,6 +148,12 @@ public sealed class IdentityHeaderPolicyMiddleware
// Extract scopes - try 'scp' claims first (individual items), then 'scope' (space-separated)
var scopes = ExtractScopes(principal);
var roles = principal.FindAll(ClaimTypes.Role)
.Select(claim => claim.Value)
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(value => value, StringComparer.Ordinal)
.ToArray();
// Extract cnf (confirmation claim) for DPoP/sender constraint
var cnfJson = principal.FindFirstValue("cnf");
@@ -173,6 +170,7 @@ public sealed class IdentityHeaderPolicyMiddleware
Tenant = tenant,
Project = project,
Scopes = scopes,
Roles = roles,
CnfJson = cnfJson,
DpopThumbprint = dpopThumbprint
};
@@ -338,6 +336,29 @@ public sealed class IdentityHeaderPolicyMiddleware
{
headers["cnf.jkt"] = identity.DpopThumbprint;
}
if (_options.EmitIdentityEnvelope &&
!string.IsNullOrWhiteSpace(_options.IdentityEnvelopeSigningKey))
{
var envelope = new GatewayIdentityEnvelope
{
Issuer = _options.IdentityEnvelopeIssuer,
Subject = identity.Actor ?? "anonymous",
Tenant = identity.Tenant,
Project = identity.Project,
Scopes = identity.Scopes.OrderBy(scope => scope, StringComparer.Ordinal).ToArray(),
Roles = identity.Roles,
SenderConfirmation = identity.DpopThumbprint,
CorrelationId = context.TraceIdentifier,
IssuedAtUtc = DateTimeOffset.UtcNow,
ExpiresAtUtc = DateTimeOffset.UtcNow.Add(_options.IdentityEnvelopeTtl)
};
var signature = GatewayIdentityEnvelopeCodec.Sign(envelope, _options.IdentityEnvelopeSigningKey!);
headers["X-StellaOps-Identity-Envelope"] = signature.Payload;
headers["X-StellaOps-Identity-Envelope-Signature"] = signature.Signature;
headers["X-StellaOps-Identity-Envelope-Algorithm"] = signature.Algorithm;
}
}
private static bool TryParseCnfThumbprint(string json, out string? jkt)
@@ -368,6 +389,7 @@ public sealed class IdentityHeaderPolicyMiddleware
public string? Tenant { get; init; }
public string? Project { get; init; }
public HashSet<string> Scopes { get; init; } = [];
public IReadOnlyList<string> Roles { get; init; } = [];
public string? CnfJson { get; init; }
public string? DpopThumbprint { get; init; }
}
@@ -396,6 +418,26 @@ public sealed class IdentityHeaderPolicyOptions
/// </summary>
public bool AllowScopeHeaderOverride { get; set; } = false;
/// <summary>
/// When true, emit a signed identity envelope headers for downstream trust.
/// </summary>
public bool EmitIdentityEnvelope { get; set; } = true;
/// <summary>
/// Shared signing key used to sign identity envelopes.
/// </summary>
public string? IdentityEnvelopeSigningKey { get; set; }
/// <summary>
/// Identity envelope issuer identifier.
/// </summary>
public string IdentityEnvelopeIssuer { get; set; } = "stellaops-gateway-router";
/// <summary>
/// Identity envelope validity window.
/// </summary>
public TimeSpan IdentityEnvelopeTtl { get; set; } = TimeSpan.FromMinutes(2);
/// <summary>
/// Route prefixes where Authorization and DPoP headers should be preserved
/// (passed through to the upstream service) instead of stripped.

View File

@@ -10,9 +10,10 @@ public sealed class RequestRoutingMiddleware
public RequestRoutingMiddleware(
RequestDelegate next,
ILogger<RequestRoutingMiddleware> logger,
ILogger<TransportDispatchMiddleware> dispatchLogger)
ILogger<TransportDispatchMiddleware> dispatchLogger,
IHostEnvironment environment)
{
_dispatchMiddleware = new TransportDispatchMiddleware(next, dispatchLogger);
_dispatchMiddleware = new TransportDispatchMiddleware(next, dispatchLogger, environment);
}
public Task InvokeAsync(HttpContext context, ITransportClient transportClient, IGlobalRoutingState routingState)

View File

@@ -1,7 +1,9 @@
using System.Net.WebSockets;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.FileProviders;
using StellaOps.Gateway.WebService.Configuration;
using StellaOps.Router.Gateway.Configuration;
using StellaOps.Router.Gateway;
using StellaOps.Gateway.WebService.Routing;
namespace StellaOps.Gateway.WebService.Middleware;
@@ -52,17 +54,21 @@ public sealed class RouteDispatchMiddleware
return;
}
// SPA fallback: when a ReverseProxy route is matched but the request is a
// browser navigation (Accept: text/html, no file extension), serve the SPA
// index.html instead of proxying to the backend. This prevents collisions
// between Angular SPA routes and backend service proxy prefixes.
// Excludes known backend browser-navigation paths (e.g. OIDC /connect).
if (route.Type == StellaOpsRouteType.ReverseProxy && IsBrowserNavigation(context.Request))
// SPA fallback: when a service route (ReverseProxy or Microservice) is matched
// but the request is a browser navigation, serve the SPA index.html instead of
// proxying/dispatching to backend service routes. This prevents collisions
// between UI deep links (for example "/policy") and backend route prefixes.
// Excludes known backend browser-navigation paths (for example OIDC /connect).
if ((route.Type == StellaOpsRouteType.ReverseProxy || route.Type == StellaOpsRouteType.Microservice)
&& IsBrowserNavigation(context.Request))
{
var spaRoute = _resolver.FindSpaFallbackRoute();
if (spaRoute is not null)
{
_logger.LogDebug("SPA fallback: serving index.html for browser navigation to {Path}", context.Request.Path);
_logger.LogDebug(
"SPA fallback: serving index.html for browser navigation to {Path} (matched route type: {RouteType})",
context.Request.Path,
route.Type);
await HandleStaticFiles(context, spaRoute);
return;
}
@@ -83,6 +89,7 @@ public sealed class RouteDispatchMiddleware
await HandleWebSocket(context, route);
break;
case StellaOpsRouteType.Microservice:
PrepareMicroserviceRoute(context, route);
await _next(context);
break;
default:
@@ -272,6 +279,185 @@ public sealed class RouteDispatchMiddleware
}
}
private static void PrepareMicroserviceRoute(HttpContext context, StellaOpsRoute route)
{
var translatedPath = ResolveTranslatedMicroservicePath(context.Request.Path.Value, route);
if (!string.Equals(translatedPath, context.Request.Path.Value, StringComparison.Ordinal))
{
context.Items[RouterHttpContextKeys.TranslatedRequestPath] = translatedPath;
}
var targetMicroservice = ResolveRouteTargetMicroservice(route);
if (!string.IsNullOrWhiteSpace(targetMicroservice))
{
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] = targetMicroservice;
}
if (!string.IsNullOrWhiteSpace(route.DefaultTimeout))
{
var routeTimeout = GatewayValueParser.ParseDuration(route.DefaultTimeout, TimeSpan.FromSeconds(30));
context.Items[RouterHttpContextKeys.RouteDefaultTimeout] = routeTimeout;
}
}
private static string ResolveTranslatedMicroservicePath(string? requestPathValue, StellaOpsRoute route)
{
var requestPath = string.IsNullOrWhiteSpace(requestPathValue) ? "/" : requestPathValue!;
if (string.IsNullOrWhiteSpace(route.TranslatesTo))
{
return requestPath;
}
var targetPrefix = ResolveTargetPathPrefix(route);
if (string.IsNullOrWhiteSpace(targetPrefix))
{
return requestPath;
}
var normalizedRoutePath = NormalizePath(route.Path);
var normalizedRequestPath = NormalizePath(requestPath);
var remainingPath = normalizedRequestPath;
if (!route.IsRegex &&
normalizedRequestPath.StartsWith(normalizedRoutePath, StringComparison.OrdinalIgnoreCase))
{
remainingPath = normalizedRequestPath[normalizedRoutePath.Length..];
if (!remainingPath.StartsWith('/'))
{
remainingPath = "/" + remainingPath;
}
}
return targetPrefix == "/"
? NormalizePath(remainingPath)
: NormalizePath($"{targetPrefix.TrimEnd('/')}{remainingPath}");
}
private static string ResolveTargetPathPrefix(StellaOpsRoute route)
{
var rawValue = route.TranslatesTo;
if (string.IsNullOrWhiteSpace(rawValue))
{
return string.Empty;
}
if (Uri.TryCreate(rawValue, UriKind.Absolute, out var absolute))
{
return NormalizePath(absolute.AbsolutePath);
}
if (Uri.TryCreate(rawValue, UriKind.Relative, out _))
{
return NormalizePath(rawValue);
}
return string.Empty;
}
private static string? ResolveRouteTargetMicroservice(StellaOpsRoute route)
{
var hostService = ExtractServiceKeyFromTranslatesTo(route.TranslatesTo);
var pathService = ExtractServiceKeyFromPath(route.Path);
if (IsGenericServiceAlias(hostService) && !IsGenericServiceAlias(pathService))
{
return pathService;
}
return hostService ?? pathService;
}
private static string? ExtractServiceKeyFromTranslatesTo(string? translatesTo)
{
if (string.IsNullOrWhiteSpace(translatesTo))
{
return null;
}
if (!Uri.TryCreate(translatesTo, UriKind.Absolute, out var absolute))
{
return null;
}
return NormalizeServiceKey(absolute.Host);
}
private static string? ExtractServiceKeyFromPath(string? path)
{
var normalizedPath = NormalizePath(path);
var segments = normalizedPath
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (segments.Length == 0)
{
return null;
}
if (segments.Length >= 3 &&
string.Equals(segments[0], "api", StringComparison.OrdinalIgnoreCase) &&
string.Equals(segments[1], "v1", StringComparison.OrdinalIgnoreCase))
{
return NormalizeServiceKey(segments[2]);
}
return NormalizeServiceKey(segments[0]);
}
private static string? NormalizeServiceKey(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var normalized = value.Trim().ToLowerInvariant();
var portSeparator = normalized.IndexOf(':');
if (portSeparator >= 0)
{
normalized = normalized[..portSeparator];
}
const string localDomain = ".stella-ops.local";
if (normalized.EndsWith(localDomain, StringComparison.Ordinal))
{
normalized = normalized[..^localDomain.Length];
}
return string.IsNullOrWhiteSpace(normalized)
? null
: normalized;
}
private static bool IsGenericServiceAlias(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return true;
}
return value.Equals("api", StringComparison.OrdinalIgnoreCase) ||
value.Equals("web", StringComparison.OrdinalIgnoreCase) ||
value.Equals("service", StringComparison.OrdinalIgnoreCase);
}
private static string NormalizePath(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "/";
}
var normalized = value.Trim();
if (!normalized.StartsWith('/'))
{
normalized = "/" + normalized;
}
normalized = normalized.TrimEnd('/');
return string.IsNullOrEmpty(normalized) ? "/" : normalized;
}
private async Task HandleWebSocket(HttpContext context, StellaOpsRoute route)
{
if (!context.WebSockets.IsWebSocketRequest)
@@ -382,6 +568,9 @@ public sealed class RouteDispatchMiddleware
/// </summary>
private static bool IsBrowserNavigation(HttpRequest request)
{
if (!HttpMethods.IsGet(request.Method))
return false;
var path = request.Path.Value ?? string.Empty;
// Paths with file extensions are static asset requests, not SPA navigation
@@ -395,6 +584,16 @@ public sealed class RouteDispatchMiddleware
return false;
}
// API prefixes should continue to dispatch to backend handlers even when
// entered directly in a browser.
if (path.Equals("/api", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase) ||
path.Equals("/v1", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/v1/", StringComparison.OrdinalIgnoreCase))
{
return false;
}
var accept = request.Headers.Accept.ToString();
return accept.Contains("text/html", StringComparison.OrdinalIgnoreCase);
}

View File

@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
@@ -12,8 +13,6 @@ using StellaOps.Gateway.WebService.Middleware;
using StellaOps.Gateway.WebService.Routing;
using StellaOps.Gateway.WebService.Security;
using StellaOps.Gateway.WebService.Services;
using StellaOps.Messaging.DependencyInjection;
using StellaOps.Messaging.Transport.Valkey;
using StellaOps.Router.AspNet;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Models;
@@ -25,11 +24,6 @@ using StellaOps.Router.Gateway.Middleware;
using StellaOps.Router.Gateway.OpenApi;
using StellaOps.Router.Gateway.RateLimit;
using StellaOps.Router.Gateway.Routing;
using StellaOps.Router.Transport.Messaging;
using StellaOps.Router.Transport.Messaging.Options;
using StellaOps.Router.Transport.Tcp;
using StellaOps.Router.Transport.Tls;
using System.Net;
var builder = WebApplication.CreateBuilder(args);
@@ -55,6 +49,25 @@ builder.Services.AddRouterGatewayCore();
builder.Services.AddRouterRateLimiting(builder.Configuration);
builder.Services.AddSingleton<IEffectiveClaimsStore, EffectiveClaimsStore>();
builder.Services.AddSingleton<StellaOps.Router.Gateway.Authorization.IEffectiveClaimsStore>(sp =>
sp.GetRequiredService<IEffectiveClaimsStore>());
var authorityClaimsUrl = ResolveAuthorityClaimsUrl(bootstrapOptions.Auth.Authority);
StellaOps.Router.Gateway.Authorization.AuthorizationServiceCollectionExtensions.AddAuthorityIntegration(
builder.Services,
options =>
{
options.Enabled = !string.IsNullOrWhiteSpace(authorityClaimsUrl);
options.AuthorityUrl = authorityClaimsUrl ?? string.Empty;
options.RefreshInterval = TimeSpan.FromSeconds(30);
options.WaitForAuthorityOnStartup = false;
options.StartupTimeout = TimeSpan.FromSeconds(10);
options.UseAuthorityPushNotifications = false;
});
builder.Services.Replace(ServiceDescriptor.Singleton<StellaOps.Router.Gateway.Authorization.IEffectiveClaimsStore>(
sp => sp.GetRequiredService<IEffectiveClaimsStore>()));
builder.Services.AddSingleton<GatewayServiceStatus>();
builder.Services.AddSingleton<GatewayMetrics>();
@@ -62,55 +75,35 @@ builder.Services.AddSingleton<GatewayMetrics>();
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))
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()
.Where(assembly =>
assembly.GetName().Name?.StartsWith("StellaOps.Router.Transport.", StringComparison.OrdinalIgnoreCase) == true))
{
transportPluginLoader.LoadFromDirectory(pluginsPath);
transportPluginLoader.LoadFromAssembly(assembly);
}
// 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)
var pluginsPath = builder.Configuration["Gateway:TransportPlugins:Directory"];
if (string.IsNullOrWhiteSpace(pluginsPath))
{
tcpPlugin.Register(new RouterTransportRegistrationContext(
builder.Services, builder.Configuration, RouterTransportMode.Server)
{
ConfigurationSection = "Gateway:Transports:Tcp"
});
}
else
{
// Fallback to compile-time registration
builder.Services.AddTcpTransportServer();
pluginsPath = Path.Combine(AppContext.BaseDirectory, "plugins", "router", "transports");
}
if (tlsPlugin is not null)
var transportSearchPattern = builder.Configuration["Gateway:TransportPlugins:SearchPattern"];
if (string.IsNullOrWhiteSpace(transportSearchPattern))
{
tlsPlugin.Register(new RouterTransportRegistrationContext(
builder.Services, builder.Configuration, RouterTransportMode.Server)
{
ConfigurationSection = "Gateway:Transports:Tls"
});
}
else
{
// Fallback to compile-time registration
builder.Services.AddTlsTransportServer();
transportSearchPattern = "StellaOps.Router.Transport.*.dll";
}
// Messaging transport (Valkey)
if (bootstrapOptions.Transports.Messaging.Enabled)
{
builder.Services.AddMessagingTransport<ValkeyTransportPlugin>(builder.Configuration, "Gateway:Transports:Messaging");
builder.Services.AddMessagingTransportServer();
}
transportPluginLoader.LoadFromDirectory(pluginsPath, transportSearchPattern);
RegisterGatewayTransportIfEnabled("tcp", bootstrapOptions.Transports.Tcp.Enabled, "Gateway:Transports:Tcp");
RegisterGatewayTransportIfEnabled("tls", bootstrapOptions.Transports.Tls.Enabled, "Gateway:Transports:Tls");
RegisterGatewayTransportIfEnabled("messaging", bootstrapOptions.Transports.Messaging.Enabled, "Gateway:Transports:Messaging");
builder.Services.AddSingleton<GatewayTransportClient>();
builder.Services.AddSingleton<ITransportClient>(sp => sp.GetRequiredService<GatewayTransportClient>());
builder.Services.AddSingleton(new GatewayRouteCatalog(bootstrapOptions.Routes));
builder.Services.AddSingleton<IOpenApiDocumentGenerator, OpenApiDocumentGenerator>();
builder.Services.AddSingleton<IRouterOpenApiDocumentCache, RouterOpenApiDocumentCache>();
@@ -125,6 +118,10 @@ builder.Services.AddSingleton(new IdentityHeaderPolicyOptions
{
EnableLegacyHeaders = bootstrapOptions.Auth.EnableLegacyHeaders,
AllowScopeHeaderOverride = bootstrapOptions.Auth.AllowScopeHeader,
EmitIdentityEnvelope = bootstrapOptions.Auth.EmitIdentityEnvelope,
IdentityEnvelopeSigningKey = bootstrapOptions.Auth.IdentityEnvelopeSigningKey,
IdentityEnvelopeIssuer = bootstrapOptions.Auth.IdentityEnvelopeIssuer,
IdentityEnvelopeTtl = TimeSpan.FromSeconds(Math.Max(1, bootstrapOptions.Auth.IdentityEnvelopeTtlSeconds)),
JwtPassthroughPrefixes = bootstrapOptions.Routes
.Where(r => r.PreserveAuthHeaders)
.Select(r => r.Path)
@@ -149,11 +146,11 @@ ConfigureAuthentication(builder, bootstrapOptions);
ConfigureGatewayOptionsMapping(builder, bootstrapOptions);
// Stella Router integration
var routerOptions = builder.Configuration.GetSection("Gateway:Router").Get<StellaRouterOptionsBase>();
builder.Services.TryAddStellaRouter(
var routerEnabled = builder.Services.AddRouterMicroservice(
builder.Configuration,
serviceName: "gateway",
version: typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0",
routerOptions: routerOptions);
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
routerOptionsSection: "Router");
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
@@ -164,9 +161,14 @@ app.LogStellaOpsLocalHostname("router");
// Force browser traffic onto HTTPS so auth (PKCE/DPoP/WebCrypto) always runs in a secure context.
app.Use(async (context, next) =>
{
var isWebSocketUpgrade =
context.WebSockets.IsWebSocketRequest ||
string.Equals(context.Request.Headers.Upgrade, "websocket", StringComparison.OrdinalIgnoreCase);
if (!context.Request.IsHttps &&
context.Request.Host.HasValue &&
!GatewayRoutes.IsSystemPath(context.Request.Path))
!GatewayRoutes.IsSystemPath(context.Request.Path) &&
!isWebSocketUpgrade)
{
var host = context.Request.Host.Host;
var redirect = $"https://{host}{context.Request.PathBase}{context.Request.Path}{context.Request.QueryString}";
@@ -185,7 +187,7 @@ app.UseMiddleware<SenderConstraintMiddleware>();
// It strips reserved identity headers and overwrites them from validated claims (security fix)
app.UseMiddleware<IdentityHeaderPolicyMiddleware>();
app.UseMiddleware<HealthCheckMiddleware>();
app.TryUseStellaRouter(routerOptions);
app.TryUseStellaRouter(routerEnabled);
// WebSocket support (before route dispatch)
app.UseWebSockets();
@@ -216,10 +218,34 @@ app.UseWhen(
app.UseMiddleware<ErrorPageFallbackMiddleware>();
// Refresh Router endpoint cache
app.TryRefreshStellaRouterEndpoints(routerOptions);
app.TryRefreshStellaRouterEndpoints(routerEnabled);
await app.RunAsync();
void RegisterGatewayTransportIfEnabled(string transportName, bool enabled, string configurationSection)
{
if (!enabled)
{
return;
}
var plugin = transportPluginLoader.GetPlugin(transportName);
if (plugin is null)
{
throw new InvalidOperationException(
$"Gateway transport plugin '{transportName}' is not available. " +
$"Provide a plugin assembly in '{pluginsPath}' or add the transport plugin dependency.");
}
plugin.Register(new RouterTransportRegistrationContext(
builder.Services,
builder.Configuration,
RouterTransportMode.Server)
{
ConfigurationSection = configurationSection
});
}
static void ConfigureAuthentication(WebApplicationBuilder builder, GatewayOptions options)
{
var authOptions = options.Auth;
@@ -312,6 +338,7 @@ static void ConfigureGatewayOptionsMapping(WebApplicationBuilder builder, Gatewa
{
var routing = gateway.Value.Routing;
options.RoutingTimeoutMs = (int)GatewayValueParser.ParseDuration(routing.DefaultTimeout, TimeSpan.FromSeconds(30)).TotalMilliseconds;
options.GlobalTimeoutCapMs = (int)GatewayValueParser.ParseDuration(routing.GlobalTimeoutCap, TimeSpan.FromSeconds(120)).TotalMilliseconds;
options.PreferLocalRegion = routing.PreferLocalRegion;
options.AllowDegradedInstances = routing.AllowDegradedInstances;
options.StrictVersionMatching = routing.StrictVersionMatching;
@@ -346,51 +373,47 @@ static void ConfigureGatewayOptionsMapping(WebApplicationBuilder builder, Gatewa
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;
});
}
static string? ResolveAuthorityClaimsUrl(GatewayAuthorityOptions authorityOptions)
{
if (!string.IsNullOrWhiteSpace(authorityOptions.ClaimsOverridesUrl))
{
return authorityOptions.ClaimsOverridesUrl.TrimEnd('/');
}
var candidate = authorityOptions.Issuer;
if (string.IsNullOrWhiteSpace(candidate))
{
candidate = authorityOptions.MetadataAddress;
}
if (string.IsNullOrWhiteSpace(candidate))
{
return null;
}
if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri))
{
return candidate.TrimEnd('/');
}
// Authority runs HTTP on the internal compose network by default.
var builder = new UriBuilder(uri)
{
Path = string.Empty,
Query = string.Empty,
Fragment = string.Empty
};
if (string.Equals(builder.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
builder.Scheme = Uri.UriSchemeHttp;
builder.Port = builder.Port == 443 ? 80 : builder.Port;
}
return builder.Uri.GetLeftPart(UriPartial.Authority).TrimEnd('/');
}

View File

@@ -16,8 +16,8 @@ namespace StellaOps.Gateway.WebService.Services;
public sealed class GatewayHostedService : IHostedService
{
private readonly TcpTransportServer _tcpServer;
private readonly TlsTransportServer _tlsServer;
private readonly TcpTransportServer? _tcpServer;
private readonly TlsTransportServer? _tlsServer;
private readonly MessagingTransportServer? _messagingServer;
private readonly IGlobalRoutingState _routingState;
private readonly GatewayTransportClient _transportClient;
@@ -32,14 +32,14 @@ public sealed class GatewayHostedService : IHostedService
private bool _messagingEnabled;
public GatewayHostedService(
TcpTransportServer tcpServer,
TlsTransportServer tlsServer,
IGlobalRoutingState routingState,
GatewayTransportClient transportClient,
IEffectiveClaimsStore claimsStore,
IOptions<GatewayOptions> options,
GatewayServiceStatus status,
ILogger<GatewayHostedService> logger,
TcpTransportServer? tcpServer = null,
TlsTransportServer? tlsServer = null,
IRouterOpenApiDocumentCache? openApiCache = null,
MessagingTransportServer? messagingServer = null)
{
@@ -63,10 +63,25 @@ public sealed class GatewayHostedService : IHostedService
public async Task StartAsync(CancellationToken cancellationToken)
{
var options = _options.Value;
_tcpEnabled = options.Transports.Tcp.Enabled;
_tlsEnabled = options.Transports.Tls.Enabled;
_tcpEnabled = options.Transports.Tcp.Enabled && _tcpServer is not null;
_tlsEnabled = options.Transports.Tls.Enabled && _tlsServer is not null;
_messagingEnabled = options.Transports.Messaging.Enabled && _messagingServer is not null;
if (options.Transports.Tcp.Enabled && _tcpServer is null)
{
_logger.LogWarning("TCP transport is enabled in configuration but no TCP transport server is registered.");
}
if (options.Transports.Tls.Enabled && _tlsServer is null)
{
_logger.LogWarning("TLS transport is enabled in configuration but no TLS transport server is registered.");
}
if (options.Transports.Messaging.Enabled && _messagingServer is null)
{
_logger.LogWarning("Messaging transport is enabled in configuration but no messaging transport server is registered.");
}
if (!_tcpEnabled && !_tlsEnabled && !_messagingEnabled)
{
_logger.LogWarning("No transports enabled; gateway will not accept microservice connections.");
@@ -77,7 +92,7 @@ public sealed class GatewayHostedService : IHostedService
if (_tcpEnabled)
{
_tcpServer.OnFrame += HandleTcpFrame;
_tcpServer!.OnFrame += HandleTcpFrame;
_tcpServer.OnDisconnection += HandleTcpDisconnection;
await _tcpServer.StartAsync(cancellationToken);
_logger.LogInformation("TCP transport started on port {Port}", options.Transports.Tcp.Port);
@@ -85,7 +100,7 @@ public sealed class GatewayHostedService : IHostedService
if (_tlsEnabled)
{
_tlsServer.OnFrame += HandleTlsFrame;
_tlsServer!.OnFrame += HandleTlsFrame;
_tlsServer.OnDisconnection += HandleTlsDisconnection;
await _tlsServer.StartAsync(cancellationToken);
_logger.LogInformation("TLS transport started on port {Port}", options.Transports.Tls.Port);
@@ -117,14 +132,14 @@ public sealed class GatewayHostedService : IHostedService
if (_tcpEnabled)
{
await _tcpServer.StopAsync(cancellationToken);
await _tcpServer!.StopAsync(cancellationToken);
_tcpServer.OnFrame -= HandleTcpFrame;
_tcpServer.OnDisconnection -= HandleTcpDisconnection;
}
if (_tlsEnabled)
{
await _tlsServer.StopAsync(cancellationToken);
await _tlsServer!.StopAsync(cancellationToken);
_tlsServer.OnFrame -= HandleTlsFrame;
_tlsServer.OnDisconnection -= HandleTlsDisconnection;
}
@@ -457,13 +472,13 @@ public sealed class GatewayHostedService : IHostedService
{
if (transportType == TransportType.Tcp)
{
_tcpServer.GetConnection(connectionId)?.Close();
_tcpServer?.GetConnection(connectionId)?.Close();
return;
}
if (transportType == TransportType.Certificate)
{
_tlsServer.GetConnection(connectionId)?.Close();
_tlsServer?.GetConnection(connectionId)?.Close();
}
// Messaging transport connections are managed by the queue system

View File

@@ -13,17 +13,17 @@ namespace StellaOps.Gateway.WebService.Services;
public sealed class GatewayTransportClient : ITransportClient
{
private readonly TcpTransportServer _tcpServer;
private readonly TlsTransportServer _tlsServer;
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,
TcpTransportServer? tcpServer = null,
TlsTransportServer? tlsServer = null,
MessagingTransportServer? messagingServer = null)
{
_tcpServer = tcpServer;
@@ -147,9 +147,17 @@ public sealed class GatewayTransportClient : ITransportClient
switch (connection.TransportType)
{
case TransportType.Tcp:
if (_tcpServer is null)
{
throw new InvalidOperationException("TCP transport is not enabled for gateway dispatch.");
}
await _tcpServer.SendFrameAsync(connection.ConnectionId, frame, cancellationToken);
break;
case TransportType.Certificate:
if (_tlsServer is null)
{
throw new InvalidOperationException("TLS transport is not enabled for gateway dispatch.");
}
await _tlsServer.SendFrameAsync(connection.ConnectionId, frame, cancellationToken);
break;
case TransportType.Messaging:

View File

@@ -21,4 +21,8 @@
<ProjectReference Include="..\..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj" />
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
</ItemGroup>
<PropertyGroup Label="StellaOpsReleaseVersion">
<Version>1.0.0-alpha1</Version>
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
</PropertyGroup>
</Project>

View File

@@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0347-T | DONE | Revalidated 2026-01-07; test coverage audit for Router Gateway WebService. |
| AUDIT-0347-A | TODO | Pending approval (non-test project; revalidated 2026-01-07). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| RGH-01 | DONE | 2026-02-22: Added SPA fallback handling for browser deep links on microservice route matches; API prefixes remain backend-dispatched. |