save checkpoint

This commit is contained in:
master
2026-02-12 21:02:43 +02:00
parent 5bca406787
commit 9911b7d73c
593 changed files with 174390 additions and 1376 deletions

View File

@@ -1,4 +1,5 @@
using System.Net;
using StellaOps.Router.Gateway.Configuration;
namespace StellaOps.Gateway.WebService.Configuration;
@@ -17,6 +18,8 @@ public sealed class GatewayOptions
public GatewayOpenApiOptions OpenApi { get; set; } = new();
public GatewayHealthOptions Health { get; set; } = new();
public List<StellaOpsRoute> Routes { get; set; } = new();
}
public sealed class GatewayNodeOptions

View File

@@ -1,3 +1,6 @@
using System.Text.RegularExpressions;
using StellaOps.Router.Gateway.Configuration;
namespace StellaOps.Gateway.WebService.Configuration;
public static class GatewayOptionsValidator
@@ -35,5 +38,85 @@ public static class GatewayOptionsValidator
_ = GatewayValueParser.ParseDuration(options.Health.StaleThreshold, TimeSpan.FromSeconds(30));
_ = GatewayValueParser.ParseDuration(options.Health.DegradedThreshold, TimeSpan.FromSeconds(15));
_ = GatewayValueParser.ParseDuration(options.Health.CheckInterval, TimeSpan.FromSeconds(5));
ValidateRoutes(options.Routes);
}
private static void ValidateRoutes(List<StellaOpsRoute> routes)
{
for (var i = 0; i < routes.Count; i++)
{
var route = routes[i];
var prefix = $"Route[{i}]";
if (string.IsNullOrWhiteSpace(route.Path))
{
throw new InvalidOperationException($"{prefix}: Path must not be empty.");
}
if (route.IsRegex)
{
try
{
_ = new Regex(route.Path, RegexOptions.Compiled, TimeSpan.FromSeconds(1));
}
catch (ArgumentException ex)
{
throw new InvalidOperationException($"{prefix}: Path is not a valid regex pattern: {ex.Message}");
}
}
switch (route.Type)
{
case StellaOpsRouteType.ReverseProxy:
if (string.IsNullOrWhiteSpace(route.TranslatesTo) ||
!Uri.TryCreate(route.TranslatesTo, UriKind.Absolute, out var proxyUri) ||
(proxyUri.Scheme != "http" && proxyUri.Scheme != "https"))
{
throw new InvalidOperationException($"{prefix}: ReverseProxy requires a valid HTTP(S) URL in TranslatesTo.");
}
break;
case StellaOpsRouteType.StaticFiles:
if (string.IsNullOrWhiteSpace(route.TranslatesTo))
{
throw new InvalidOperationException($"{prefix}: StaticFiles requires a directory path in TranslatesTo.");
}
break;
case StellaOpsRouteType.StaticFile:
if (string.IsNullOrWhiteSpace(route.TranslatesTo))
{
throw new InvalidOperationException($"{prefix}: StaticFile requires a file path in TranslatesTo.");
}
break;
case StellaOpsRouteType.WebSocket:
if (string.IsNullOrWhiteSpace(route.TranslatesTo) ||
!Uri.TryCreate(route.TranslatesTo, UriKind.Absolute, out var wsUri) ||
(wsUri.Scheme != "ws" && wsUri.Scheme != "wss"))
{
throw new InvalidOperationException($"{prefix}: WebSocket requires a valid ws:// or wss:// URL in TranslatesTo.");
}
break;
case StellaOpsRouteType.NotFoundPage:
if (string.IsNullOrWhiteSpace(route.TranslatesTo))
{
throw new InvalidOperationException($"{prefix}: NotFoundPage requires a file path in TranslatesTo.");
}
break;
case StellaOpsRouteType.ServerErrorPage:
if (string.IsNullOrWhiteSpace(route.TranslatesTo))
{
throw new InvalidOperationException($"{prefix}: ServerErrorPage requires a file path in TranslatesTo.");
}
break;
case StellaOpsRouteType.Microservice:
break;
}
}
}
}

View File

@@ -0,0 +1,98 @@
using StellaOps.Router.Gateway.Configuration;
namespace StellaOps.Gateway.WebService.Middleware;
public sealed class ErrorPageFallbackMiddleware
{
private readonly RequestDelegate _next;
private readonly string? _notFoundPagePath;
private readonly string? _serverErrorPagePath;
private readonly ILogger<ErrorPageFallbackMiddleware> _logger;
public ErrorPageFallbackMiddleware(
RequestDelegate next,
IEnumerable<StellaOpsRoute> errorRoutes,
ILogger<ErrorPageFallbackMiddleware> logger)
{
_next = next;
_logger = logger;
foreach (var route in errorRoutes)
{
switch (route.Type)
{
case StellaOpsRouteType.NotFoundPage:
_notFoundPagePath = route.TranslatesTo;
break;
case StellaOpsRouteType.ServerErrorPage:
_serverErrorPagePath = route.TranslatesTo;
break;
}
}
}
public async Task InvokeAsync(HttpContext context)
{
// Fast path: no error pages configured, skip body wrapping
if (_notFoundPagePath is null && _serverErrorPagePath is null)
{
await _next(context);
return;
}
// Capture the original response body to detect status codes
var originalBody = context.Response.Body;
using var memoryStream = new MemoryStream();
context.Response.Body = memoryStream;
try
{
await _next(context);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception in pipeline");
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
}
// Check if we need to serve a custom error page
if (context.Response.StatusCode == 404 && _notFoundPagePath is not null && memoryStream.Length == 0)
{
context.Response.Body = originalBody;
await ServeErrorPage(context, _notFoundPagePath, 404);
return;
}
if (context.Response.StatusCode >= 500 && _serverErrorPagePath is not null && memoryStream.Length == 0)
{
context.Response.Body = originalBody;
await ServeErrorPage(context, _serverErrorPagePath, context.Response.StatusCode);
return;
}
// No error page override, copy the original response
memoryStream.Position = 0;
context.Response.Body = originalBody;
await memoryStream.CopyToAsync(originalBody, context.RequestAborted);
}
private async Task ServeErrorPage(HttpContext context, string filePath, int statusCode)
{
if (!File.Exists(filePath))
{
_logger.LogWarning("Error page file not found: {FilePath}", filePath);
context.Response.StatusCode = statusCode;
context.Response.ContentType = "application/json; charset=utf-8";
await context.Response.WriteAsync(
$$"""{"error":"{{(statusCode == 404 ? "not_found" : "internal_server_error")}}","status":{{statusCode}}}""",
context.RequestAborted);
return;
}
context.Response.StatusCode = statusCode;
context.Response.ContentType = "text/html; charset=utf-8";
await using var stream = File.OpenRead(filePath);
await stream.CopyToAsync(context.Response.Body, context.RequestAborted);
}
}

View File

@@ -0,0 +1,346 @@
using System.Net.WebSockets;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.FileProviders;
using StellaOps.Router.Gateway.Configuration;
using StellaOps.Gateway.WebService.Routing;
namespace StellaOps.Gateway.WebService.Middleware;
public sealed class RouteDispatchMiddleware
{
private readonly RequestDelegate _next;
private readonly StellaOpsRouteResolver _resolver;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<RouteDispatchMiddleware> _logger;
private readonly FileExtensionContentTypeProvider _contentTypeProvider = new();
private static readonly HashSet<string> HopByHopHeaders = new(StringComparer.OrdinalIgnoreCase)
{
"Connection", "Keep-Alive", "Proxy-Authenticate", "Proxy-Authorization",
"TE", "Trailers", "Transfer-Encoding", "Upgrade"
};
public RouteDispatchMiddleware(
RequestDelegate next,
StellaOpsRouteResolver resolver,
IHttpClientFactory httpClientFactory,
ILogger<RouteDispatchMiddleware> logger)
{
_next = next;
_resolver = resolver;
_httpClientFactory = httpClientFactory;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
// System paths (health, metrics, openapi) bypass route dispatch
if (GatewayRoutes.IsSystemPath(context.Request.Path))
{
await _next(context);
return;
}
var route = _resolver.Resolve(context.Request.Path);
if (route is null)
{
await _next(context);
return;
}
switch (route.Type)
{
case StellaOpsRouteType.StaticFiles:
await HandleStaticFiles(context, route);
break;
case StellaOpsRouteType.StaticFile:
await HandleStaticFile(context, route);
break;
case StellaOpsRouteType.ReverseProxy:
await HandleReverseProxy(context, route);
break;
case StellaOpsRouteType.WebSocket:
await HandleWebSocket(context, route);
break;
case StellaOpsRouteType.Microservice:
await _next(context);
break;
default:
await _next(context);
break;
}
}
private async Task HandleStaticFiles(HttpContext context, StellaOpsRoute route)
{
var requestPath = context.Request.Path.Value ?? string.Empty;
var relativePath = requestPath;
if (requestPath.StartsWith(route.Path, StringComparison.OrdinalIgnoreCase))
{
relativePath = requestPath[route.Path.Length..];
if (!relativePath.StartsWith('/'))
{
relativePath = "/" + relativePath;
}
}
var directoryPath = route.TranslatesTo!;
if (!Directory.Exists(directoryPath))
{
_logger.LogWarning("StaticFiles directory not found: {Directory}", directoryPath);
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
var fileProvider = new PhysicalFileProvider(directoryPath);
var fileInfo = fileProvider.GetFileInfo(relativePath);
if (fileInfo.Exists && !fileInfo.IsDirectory)
{
await ServeFile(context, fileInfo, relativePath);
return;
}
// SPA fallback: serve index.html for paths without extensions
var spaFallback = route.Headers.TryGetValue("x-spa-fallback", out var spaValue) &&
string.Equals(spaValue, "true", StringComparison.OrdinalIgnoreCase);
if (spaFallback && !System.IO.Path.HasExtension(relativePath))
{
var indexFile = fileProvider.GetFileInfo("/index.html");
if (indexFile.Exists && !indexFile.IsDirectory)
{
await ServeFile(context, indexFile, "/index.html");
return;
}
}
context.Response.StatusCode = StatusCodes.Status404NotFound;
}
private async Task HandleStaticFile(HttpContext context, StellaOpsRoute route)
{
var requestPath = context.Request.Path.Value ?? string.Empty;
// StaticFile serves the exact file only at the exact path
if (!requestPath.Equals(route.Path, StringComparison.OrdinalIgnoreCase))
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
var filePath = route.TranslatesTo!;
if (!File.Exists(filePath))
{
_logger.LogWarning("StaticFile not found: {File}", filePath);
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
var fileName = System.IO.Path.GetFileName(filePath);
if (!_contentTypeProvider.TryGetContentType(fileName, out var contentType))
{
contentType = "application/octet-stream";
}
context.Response.StatusCode = StatusCodes.Status200OK;
context.Response.ContentType = contentType;
await using var stream = File.OpenRead(filePath);
await stream.CopyToAsync(context.Response.Body, context.RequestAborted);
}
private async Task HandleReverseProxy(HttpContext context, StellaOpsRoute route)
{
var requestPath = context.Request.Path.Value ?? string.Empty;
var remainingPath = requestPath;
if (!route.IsRegex && requestPath.StartsWith(route.Path, StringComparison.OrdinalIgnoreCase))
{
remainingPath = requestPath[route.Path.Length..];
}
var upstreamBase = route.TranslatesTo!.TrimEnd('/');
var upstreamUri = new Uri($"{upstreamBase}{remainingPath}{context.Request.QueryString}");
var client = _httpClientFactory.CreateClient("RouteDispatch");
client.Timeout = TimeSpan.FromSeconds(30);
var upstreamRequest = new HttpRequestMessage(new HttpMethod(context.Request.Method), upstreamUri);
// Copy request headers (excluding hop-by-hop)
foreach (var header in context.Request.Headers)
{
if (HopByHopHeaders.Contains(header.Key) ||
header.Key.Equals("Host", StringComparison.OrdinalIgnoreCase))
{
continue;
}
upstreamRequest.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray());
}
// Inject configured headers
foreach (var (key, value) in route.Headers)
{
upstreamRequest.Headers.TryAddWithoutValidation(key, value);
}
// Copy request body for methods that support it
if (context.Request.ContentLength > 0 || context.Request.ContentType is not null)
{
upstreamRequest.Content = new StreamContent(context.Request.Body);
if (context.Request.ContentType is not null)
{
upstreamRequest.Content.Headers.TryAddWithoutValidation("Content-Type", context.Request.ContentType);
}
}
HttpResponseMessage upstreamResponse;
try
{
upstreamResponse = await client.SendAsync(
upstreamRequest,
HttpCompletionOption.ResponseHeadersRead,
context.RequestAborted);
}
catch (TaskCanceledException) when (!context.RequestAborted.IsCancellationRequested)
{
context.Response.StatusCode = StatusCodes.Status504GatewayTimeout;
return;
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Reverse proxy upstream request failed for {Upstream}", upstreamUri);
context.Response.StatusCode = StatusCodes.Status502BadGateway;
return;
}
using (upstreamResponse)
{
context.Response.StatusCode = (int)upstreamResponse.StatusCode;
// Copy response headers
foreach (var header in upstreamResponse.Headers)
{
if (!HopByHopHeaders.Contains(header.Key))
{
context.Response.Headers[header.Key] = header.Value.ToArray();
}
}
foreach (var header in upstreamResponse.Content.Headers)
{
context.Response.Headers[header.Key] = header.Value.ToArray();
}
// Stream response body
await using var responseStream = await upstreamResponse.Content.ReadAsStreamAsync(context.RequestAborted);
await responseStream.CopyToAsync(context.Response.Body, context.RequestAborted);
}
}
private async Task HandleWebSocket(HttpContext context, StellaOpsRoute route)
{
if (!context.WebSockets.IsWebSocketRequest)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
return;
}
var requestPath = context.Request.Path.Value ?? string.Empty;
var remainingPath = requestPath;
if (!route.IsRegex && requestPath.StartsWith(route.Path, StringComparison.OrdinalIgnoreCase))
{
remainingPath = requestPath[route.Path.Length..];
}
var upstreamBase = route.TranslatesTo!.TrimEnd('/');
var upstreamUri = new Uri($"{upstreamBase}{remainingPath}");
using var clientWebSocket = new ClientWebSocket();
try
{
await clientWebSocket.ConnectAsync(upstreamUri, context.RequestAborted);
}
catch (Exception ex)
{
_logger.LogError(ex, "WebSocket upstream connection failed for {Upstream}", upstreamUri);
context.Response.StatusCode = StatusCodes.Status502BadGateway;
return;
}
using var serverWebSocket = await context.WebSockets.AcceptWebSocketAsync();
var cts = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted);
var clientToServer = PumpWebSocket(serverWebSocket, clientWebSocket, cts);
var serverToClient = PumpWebSocket(clientWebSocket, serverWebSocket, cts);
await Task.WhenAny(clientToServer, serverToClient);
await cts.CancelAsync();
}
private static async Task PumpWebSocket(
WebSocket source,
WebSocket destination,
CancellationTokenSource cts)
{
var buffer = new byte[4096];
try
{
while (!cts.Token.IsCancellationRequested)
{
var result = await source.ReceiveAsync(
new ArraySegment<byte>(buffer),
cts.Token);
if (result.MessageType == WebSocketMessageType.Close)
{
if (destination.State == WebSocketState.Open ||
destination.State == WebSocketState.CloseReceived)
{
await destination.CloseAsync(
result.CloseStatus ?? WebSocketCloseStatus.NormalClosure,
result.CloseStatusDescription,
cts.Token);
}
break;
}
if (destination.State == WebSocketState.Open)
{
await destination.SendAsync(
new ArraySegment<byte>(buffer, 0, result.Count),
result.MessageType,
result.EndOfMessage,
cts.Token);
}
}
}
catch (OperationCanceledException)
{
// Expected during shutdown
}
catch (WebSocketException)
{
// Connection closed unexpectedly
}
}
private async Task ServeFile(HttpContext context, IFileInfo fileInfo, string fileName)
{
if (!_contentTypeProvider.TryGetContentType(fileName, out var contentType))
{
contentType = "application/octet-stream";
}
context.Response.StatusCode = StatusCodes.Status200OK;
context.Response.ContentType = contentType;
context.Response.ContentLength = fileInfo.Length;
await using var stream = fileInfo.CreateReadStream();
await stream.CopyToAsync(context.Response.Body, context.RequestAborted);
}
}

View File

@@ -9,6 +9,7 @@ using StellaOps.Configuration;
using StellaOps.Gateway.WebService.Authorization;
using StellaOps.Gateway.WebService.Configuration;
using StellaOps.Gateway.WebService.Middleware;
using StellaOps.Gateway.WebService.Routing;
using StellaOps.Gateway.WebService.Security;
using StellaOps.Gateway.WebService.Services;
using StellaOps.Messaging.DependencyInjection;
@@ -126,6 +127,19 @@ builder.Services.AddSingleton(new IdentityHeaderPolicyOptions
AllowScopeHeaderOverride = bootstrapOptions.Auth.AllowScopeHeader
});
// Route table: resolver + error routes + HTTP client for reverse proxy
builder.Services.AddSingleton(new StellaOpsRouteResolver(bootstrapOptions.Routes));
builder.Services.AddSingleton<IEnumerable<StellaOpsRoute>>(
bootstrapOptions.Routes.Where(r =>
r.Type == StellaOpsRouteType.NotFoundPage ||
r.Type == StellaOpsRouteType.ServerErrorPage).ToList());
builder.Services.AddHttpClient("RouteDispatch")
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
});
ConfigureAuthentication(builder, bootstrapOptions);
ConfigureGatewayOptionsMapping(builder, bootstrapOptions);
@@ -152,6 +166,12 @@ app.UseMiddleware<IdentityHeaderPolicyMiddleware>();
app.UseMiddleware<HealthCheckMiddleware>();
app.TryUseStellaRouter(routerOptions);
// WebSocket support (before route dispatch)
app.UseWebSockets();
// Route dispatch for configured routes (static files, reverse proxy, websocket)
app.UseMiddleware<RouteDispatchMiddleware>();
if (bootstrapOptions.OpenApi.Enabled)
{
app.MapRouterOpenApi();
@@ -171,6 +191,9 @@ app.UseWhen(
branch.UseMiddleware<RequestRoutingMiddleware>();
});
// Error page fallback (after all other middleware)
app.UseMiddleware<ErrorPageFallbackMiddleware>();
// Refresh Router endpoint cache
app.TryRefreshStellaRouterEndpoints(routerOptions);

View File

@@ -0,0 +1,57 @@
using System.Text.RegularExpressions;
using StellaOps.Router.Gateway.Configuration;
namespace StellaOps.Gateway.WebService.Routing;
public sealed class StellaOpsRouteResolver
{
private readonly List<(StellaOpsRoute Route, Regex? Pattern)> _routes;
public StellaOpsRouteResolver(IEnumerable<StellaOpsRoute> routes)
{
_routes = new List<(StellaOpsRoute, Regex?)>();
foreach (var route in routes)
{
if (route.Type == StellaOpsRouteType.NotFoundPage ||
route.Type == StellaOpsRouteType.ServerErrorPage)
{
// Error page routes don't participate in path resolution
continue;
}
Regex? pattern = route.IsRegex
? new Regex(route.Path, RegexOptions.Compiled, TimeSpan.FromSeconds(1))
: null;
_routes.Add((route, pattern));
}
}
public StellaOpsRoute? Resolve(PathString path)
{
var pathValue = path.Value ?? string.Empty;
foreach (var (route, pattern) in _routes)
{
if (pattern is not null)
{
if (pattern.IsMatch(pathValue))
{
return route;
}
}
else
{
if (pathValue.Equals(route.Path, StringComparison.OrdinalIgnoreCase) ||
pathValue.StartsWith(route.Path + "/", StringComparison.OrdinalIgnoreCase) ||
pathValue.StartsWith(route.Path, StringComparison.OrdinalIgnoreCase) &&
route.Path.EndsWith('/'))
{
return route;
}
}
}
return null;
}
}

View File

@@ -63,6 +63,60 @@
"StaleThreshold": "30s",
"DegradedThreshold": "15s",
"CheckInterval": "5s"
}
},
"Routes": [
{ "Type": "ReverseProxy", "Path": "/api", "TranslatesTo": "http://platform.stella-ops.local/api" },
{ "Type": "ReverseProxy", "Path": "/platform", "TranslatesTo": "http://platform.stella-ops.local/platform" },
{ "Type": "ReverseProxy", "Path": "/connect", "TranslatesTo": "https://authority.stella-ops.local/connect" },
{ "Type": "ReverseProxy", "Path": "/.well-known", "TranslatesTo": "https://authority.stella-ops.local/.well-known" },
{ "Type": "ReverseProxy", "Path": "/jwks", "TranslatesTo": "https://authority.stella-ops.local/jwks" },
{ "Type": "ReverseProxy", "Path": "/authority", "TranslatesTo": "https://authority.stella-ops.local/authority" },
{ "Type": "ReverseProxy", "Path": "/console", "TranslatesTo": "https://authority.stella-ops.local/console" },
{ "Type": "ReverseProxy", "Path": "/gateway", "TranslatesTo": "http://gateway.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/scanner", "TranslatesTo": "http://scanner.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/policyGateway", "TranslatesTo": "http://policy-gateway.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/policyEngine", "TranslatesTo": "http://policy-engine.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/concelier", "TranslatesTo": "http://concelier.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/attestor", "TranslatesTo": "http://attestor.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/notify", "TranslatesTo": "http://notify.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/notifier", "TranslatesTo": "http://notifier.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/scheduler", "TranslatesTo": "http://scheduler.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/signals", "TranslatesTo": "http://signals.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/excititor", "TranslatesTo": "http://excititor.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/findingsLedger", "TranslatesTo": "http://findings.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/vexhub", "TranslatesTo": "http://vexhub.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/vexlens", "TranslatesTo": "http://vexlens.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/taskrunner", "TranslatesTo": "http://taskrunner.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/graph", "TranslatesTo": "http://graph.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/cartographer", "TranslatesTo": "http://cartographer.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/reachgraph", "TranslatesTo": "http://reachgraph.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/doctor", "TranslatesTo": "http://doctor.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/integrations", "TranslatesTo": "http://integrations.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/replay", "TranslatesTo": "http://replay.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/exportcenter", "TranslatesTo": "http://exportcenter.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/evidencelocker", "TranslatesTo": "http://evidencelocker.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/signer", "TranslatesTo": "http://signer.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/binaryindex", "TranslatesTo": "http://binaryindex.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/riskengine", "TranslatesTo": "http://riskengine.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/vulnexplorer", "TranslatesTo": "http://vulnexplorer.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/sbomservice", "TranslatesTo": "http://sbomservice.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/advisoryai", "TranslatesTo": "http://advisoryai.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/unknowns", "TranslatesTo": "http://unknowns.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/timeline", "TranslatesTo": "http://timeline.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/timelineindexer", "TranslatesTo": "http://timelineindexer.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/opsmemory", "TranslatesTo": "http://opsmemory.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/issuerdirectory", "TranslatesTo": "http://issuerdirectory.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/symbols", "TranslatesTo": "http://symbols.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/packsregistry", "TranslatesTo": "http://packsregistry.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/registryTokenservice", "TranslatesTo": "http://registry-token.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/airgapController", "TranslatesTo": "http://airgap-controller.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/airgapTime", "TranslatesTo": "http://airgap-time.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/smremote", "TranslatesTo": "http://smremote.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/envsettings.json", "TranslatesTo": "http://platform.stella-ops.local/platform/envsettings.json" },
{ "Type": "StaticFiles", "Path": "/", "TranslatesTo": "/app/wwwroot", "Headers": { "x-spa-fallback": "true" } },
{ "Type": "NotFoundPage", "Path": "/_error/404", "TranslatesTo": "/app/wwwroot/index.html" },
{ "Type": "ServerErrorPage", "Path": "/_error/500", "TranslatesTo": "/app/wwwroot/index.html" }
]
}
}