Files
git.stella-ops.org/docs/router/18-Step.md
master 75f6942769
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Add integration tests for migration categories and execution
- Implemented MigrationCategoryTests to validate migration categorization for startup, release, seed, and data migrations.
- Added tests for edge cases, including null, empty, and whitespace migration names.
- Created StartupMigrationHostTests to verify the behavior of the migration host with real PostgreSQL instances using Testcontainers.
- Included tests for migration execution, schema creation, and handling of pending release migrations.
- Added SQL migration files for testing: creating a test table, adding a column, a release migration, and seeding data.
2025-12-04 19:10:54 +02:00

891 lines
29 KiB
Markdown

# Step 18: Reverse Proxy Handler Implementation
**Phase 4: Handler Plugins**
**Estimated Complexity:** Medium
**Dependencies:** Step 10 (Microservice Handler)
---
## Overview
The Reverse Proxy handler forwards requests to external HTTP services without using the internal transport protocol. It's used for legacy services, third-party APIs, and services that can't be modified to use the Stella transport layer.
---
## Goals
1. Forward HTTP requests to configurable upstream servers
2. Support connection pooling and HTTP/2 multiplexing
3. Handle request/response transformation
4. Support health checks and circuit breaking
5. Maintain correlation IDs for tracing
---
## Core Architecture
```
┌────────────────────────────────────────────────────────────────┐
│ Reverse Proxy Handler │
├────────────────────────────────────────────────────────────────┤
│ │
│ Incoming Request │
│ │ │
│ ▼ │
│ ┌───────────────┐ ┌─────────────────────┐ │
│ │Path Rewriter │───►│ URL Transformation │ │
│ └───────┬───────┘ └─────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────┐ ┌─────────────────────┐ │
│ │ Header Filter │───►│ Add/Remove Headers │ │
│ └───────┬───────┘ └─────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────┐ ┌─────────────────────┐ │
│ │ Load Balancer │───►│ Round Robin/Weighted │ │
│ └───────┬───────┘ └─────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────┐ │
│ │ HttpClient Pool │ │
│ │ (Connection pooling, HTTP/2, retries) │ │
│ └───────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────┘
```
---
## Configuration
```csharp
namespace StellaOps.Router.Handlers.ReverseProxy;
public class ReverseProxyConfig
{
/// <summary>Upstream definitions by name.</summary>
public Dictionary<string, UpstreamConfig> Upstreams { get; set; } = new();
/// <summary>Route-to-upstream mappings.</summary>
public List<ProxyRoute> Routes { get; set; } = new();
/// <summary>Default timeout for upstream requests.</summary>
public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>Whether to forward X-Forwarded-* headers.</summary>
public bool AddForwardedHeaders { get; set; } = true;
/// <summary>Whether to preserve host header.</summary>
public bool PreserveHost { get; set; } = false;
/// <summary>Connection pool settings.</summary>
public ConnectionPoolConfig ConnectionPool { get; set; } = new();
}
public class UpstreamConfig
{
/// <summary>Upstream server addresses.</summary>
public List<UpstreamServer> Servers { get; set; } = new();
/// <summary>Load balancing strategy.</summary>
public LoadBalanceStrategy LoadBalance { get; set; } = LoadBalanceStrategy.RoundRobin;
/// <summary>Health check configuration.</summary>
public HealthCheckConfig? HealthCheck { get; set; }
/// <summary>Circuit breaker configuration.</summary>
public CircuitBreakerConfig? CircuitBreaker { get; set; }
/// <summary>Retry configuration.</summary>
public RetryConfig? Retry { get; set; }
}
public class UpstreamServer
{
public string Address { get; set; } = "";
public int Weight { get; set; } = 1;
public bool Backup { get; set; } = false;
}
public class ProxyRoute
{
/// <summary>Path pattern to match.</summary>
public string PathPattern { get; set; } = "";
/// <summary>Target upstream name.</summary>
public string Upstream { get; set; } = "";
/// <summary>Path rewrite rule.</summary>
public PathRewriteRule? Rewrite { get; set; }
/// <summary>Header transformations.</summary>
public HeaderTransformConfig? Headers { get; set; }
/// <summary>Timeout override.</summary>
public TimeSpan? Timeout { get; set; }
/// <summary>Required claims for access.</summary>
public List<string>? RequiredClaims { get; set; }
}
public class PathRewriteRule
{
public string Pattern { get; set; } = "";
public string Replacement { get; set; } = "";
}
public class HeaderTransformConfig
{
public Dictionary<string, string> Add { get; set; } = new();
public List<string> Remove { get; set; } = new();
public Dictionary<string, string> Set { get; set; } = new();
public bool ForwardClaims { get; set; } = false;
public string ClaimsHeaderPrefix { get; set; } = "X-Claim-";
}
public class HealthCheckConfig
{
public string Path { get; set; } = "/health";
public TimeSpan Interval { get; set; } = TimeSpan.FromSeconds(10);
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(5);
public int UnhealthyThreshold { get; set; } = 3;
public int HealthyThreshold { get; set; } = 2;
}
public class CircuitBreakerConfig
{
public int FailureThreshold { get; set; } = 5;
public TimeSpan SamplingDuration { get; set; } = TimeSpan.FromSeconds(30);
public TimeSpan BreakDuration { get; set; } = TimeSpan.FromSeconds(30);
public double FailureRatioThreshold { get; set; } = 0.5;
}
public class RetryConfig
{
public int MaxRetries { get; set; } = 3;
public TimeSpan InitialDelay { get; set; } = TimeSpan.FromMilliseconds(100);
public double BackoffMultiplier { get; set; } = 2.0;
public List<int> RetryableStatusCodes { get; set; } = new() { 502, 503, 504 };
}
public class ConnectionPoolConfig
{
public int MaxConnectionsPerServer { get; set; } = 100;
public TimeSpan ConnectionIdleTimeout { get; set; } = TimeSpan.FromMinutes(2);
public bool EnableHttp2 { get; set; } = true;
}
public enum LoadBalanceStrategy
{
RoundRobin,
Random,
LeastConnections,
WeightedRoundRobin,
IPHash
}
```
---
## Reverse Proxy Handler Implementation
```csharp
namespace StellaOps.Router.Handlers.ReverseProxy;
public sealed class ReverseProxyHandler : IRouteHandler
{
public string HandlerType => "ReverseProxy";
public int Priority => 50;
private readonly ReverseProxyConfig _config;
private readonly IUpstreamManager _upstreamManager;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<ReverseProxyHandler> _logger;
public ReverseProxyHandler(
IOptions<ReverseProxyConfig> config,
IUpstreamManager upstreamManager,
IHttpClientFactory httpClientFactory,
ILogger<ReverseProxyHandler> logger)
{
_config = config.Value;
_upstreamManager = upstreamManager;
_httpClientFactory = httpClientFactory;
_logger = logger;
}
public bool CanHandle(RouteMatchResult match)
{
if (match.Handler == "ReverseProxy")
return true;
return _config.Routes.Any(r => IsRouteMatch(match.Route.Path, r.PathPattern));
}
public async Task<RouteHandlerResult> HandleAsync(
HttpContext context,
RouteMatchResult match,
IReadOnlyDictionary<string, string> claims,
CancellationToken cancellationToken)
{
// Find matching route
var route = _config.Routes.FirstOrDefault(r =>
IsRouteMatch(context.Request.Path, r.PathPattern));
if (route == null)
{
return new RouteHandlerResult { Handled = false };
}
// Check required claims
if (route.RequiredClaims?.Any() == true)
{
if (!route.RequiredClaims.All(c => claims.ContainsKey(c)))
{
return new RouteHandlerResult
{
Handled = true,
StatusCode = 403,
Body = Encoding.UTF8.GetBytes("Forbidden")
};
}
}
// Get upstream server
var server = await _upstreamManager.GetServerAsync(route.Upstream, context, cancellationToken);
if (server == null)
{
_logger.LogWarning("No healthy upstream for {Upstream}", route.Upstream);
return new RouteHandlerResult
{
Handled = true,
StatusCode = 503,
Body = Encoding.UTF8.GetBytes("Service unavailable")
};
}
try
{
return await ForwardRequestAsync(context, route, server, claims, cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Proxy error for {Upstream}", route.Upstream);
_upstreamManager.ReportFailure(route.Upstream, server.Address);
return new RouteHandlerResult
{
Handled = true,
StatusCode = 502,
Body = Encoding.UTF8.GetBytes("Bad gateway")
};
}
}
private bool IsRouteMatch(string path, string pattern)
{
if (pattern.EndsWith("*"))
{
return path.StartsWith(pattern.TrimEnd('*'), StringComparison.OrdinalIgnoreCase);
}
return string.Equals(path, pattern, StringComparison.OrdinalIgnoreCase);
}
private async Task<RouteHandlerResult> ForwardRequestAsync(
HttpContext context,
ProxyRoute route,
UpstreamServer server,
IReadOnlyDictionary<string, string> claims,
CancellationToken cancellationToken)
{
var request = context.Request;
// Build upstream URL
var targetUri = BuildTargetUri(server.Address, request, route.Rewrite);
// Create HTTP request
var httpRequest = new HttpRequestMessage
{
Method = new HttpMethod(request.Method),
RequestUri = targetUri
};
// Copy headers
CopyRequestHeaders(request, httpRequest, route.Headers, claims);
// Add forwarded headers
if (_config.AddForwardedHeaders)
{
AddForwardedHeaders(context, httpRequest);
}
// Copy body for non-GET/HEAD requests
if (!HttpMethods.IsGet(request.Method) && !HttpMethods.IsHead(request.Method))
{
httpRequest.Content = new StreamContent(request.Body);
if (request.ContentType != null)
{
httpRequest.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(request.ContentType);
}
}
// Send request
var client = _httpClientFactory.CreateClient("proxy");
var timeout = route.Timeout ?? _config.DefaultTimeout;
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(timeout);
var response = await client.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, cts.Token);
// Copy response
return await BuildResponseAsync(context, response, route.Headers, cancellationToken);
}
private Uri BuildTargetUri(string serverAddress, HttpRequest request, PathRewriteRule? rewrite)
{
var path = request.Path.Value ?? "/";
if (rewrite != null)
{
path = Regex.Replace(path, rewrite.Pattern, rewrite.Replacement);
}
var query = request.QueryString.Value ?? "";
var baseUri = new Uri(serverAddress.TrimEnd('/'));
return new Uri(baseUri, path + query);
}
private void CopyRequestHeaders(
HttpRequest source,
HttpRequestMessage target,
HeaderTransformConfig? transform,
IReadOnlyDictionary<string, string> claims)
{
// Skip hop-by-hop headers
var skipHeaders = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"Connection", "Keep-Alive", "Proxy-Authenticate", "Proxy-Authorization",
"TE", "Trailer", "Transfer-Encoding", "Upgrade", "Host"
};
// Headers to remove
if (transform?.Remove != null)
{
foreach (var header in transform.Remove)
{
skipHeaders.Add(header);
}
}
foreach (var header in source.Headers)
{
if (skipHeaders.Contains(header.Key))
continue;
target.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray());
}
// Add configured headers
if (transform?.Add != null)
{
foreach (var (key, value) in transform.Add)
{
target.Headers.TryAddWithoutValidation(key, value);
}
}
// Set configured headers (overwrite)
if (transform?.Set != null)
{
foreach (var (key, value) in transform.Set)
{
target.Headers.Remove(key);
target.Headers.TryAddWithoutValidation(key, value);
}
}
// Forward claims as headers
if (transform?.ForwardClaims == true)
{
var prefix = transform.ClaimsHeaderPrefix ?? "X-Claim-";
foreach (var (key, value) in claims)
{
var headerName = prefix + key.Replace('/', '-').Replace(':', '-');
target.Headers.TryAddWithoutValidation(headerName, value);
}
}
// Preserve or set Host
if (_config.PreserveHost)
{
target.Headers.Host = source.Host.Value;
}
}
private void AddForwardedHeaders(HttpContext context, HttpRequestMessage request)
{
var connection = context.Connection;
var httpRequest = context.Request;
// X-Forwarded-For
var forwardedFor = httpRequest.Headers["X-Forwarded-For"].FirstOrDefault();
var clientIp = connection.RemoteIpAddress?.ToString();
if (!string.IsNullOrEmpty(clientIp))
{
forwardedFor = string.IsNullOrEmpty(forwardedFor)
? clientIp
: $"{forwardedFor}, {clientIp}";
}
request.Headers.TryAddWithoutValidation("X-Forwarded-For", forwardedFor);
// X-Forwarded-Proto
request.Headers.TryAddWithoutValidation("X-Forwarded-Proto", httpRequest.Scheme);
// X-Forwarded-Host
request.Headers.TryAddWithoutValidation("X-Forwarded-Host", httpRequest.Host.Value);
// X-Real-IP
if (connection.RemoteIpAddress != null)
{
request.Headers.TryAddWithoutValidation("X-Real-IP", connection.RemoteIpAddress.ToString());
}
// X-Request-ID (correlation)
request.Headers.TryAddWithoutValidation("X-Request-ID", context.TraceIdentifier);
}
private async Task<RouteHandlerResult> BuildResponseAsync(
HttpContext context,
HttpResponseMessage response,
HeaderTransformConfig? transform,
CancellationToken cancellationToken)
{
var httpResponse = context.Response;
httpResponse.StatusCode = (int)response.StatusCode;
// Copy response headers
var skipHeaders = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"Transfer-Encoding", "Connection"
};
foreach (var header in response.Headers)
{
if (skipHeaders.Contains(header.Key))
continue;
httpResponse.Headers[header.Key] = header.Value.ToArray();
}
foreach (var header in response.Content.Headers)
{
if (skipHeaders.Contains(header.Key))
continue;
httpResponse.Headers[header.Key] = header.Value.ToArray();
}
// Stream response body
await response.Content.CopyToAsync(httpResponse.Body, cancellationToken);
return new RouteHandlerResult
{
Handled = true,
StatusCode = (int)response.StatusCode
};
}
}
```
---
## Upstream Manager
```csharp
namespace StellaOps.Router.Handlers.ReverseProxy;
public interface IUpstreamManager
{
Task<UpstreamServer?> GetServerAsync(
string upstreamName,
HttpContext context,
CancellationToken cancellationToken);
void ReportSuccess(string upstreamName, string serverAddress);
void ReportFailure(string upstreamName, string serverAddress);
}
public sealed class UpstreamManager : IUpstreamManager, IHostedService
{
private readonly ReverseProxyConfig _config;
private readonly ILogger<UpstreamManager> _logger;
private readonly ConcurrentDictionary<string, ServerState> _serverStates = new();
private readonly ConcurrentDictionary<string, int> _roundRobinCounters = new();
private Timer? _healthCheckTimer;
public UpstreamManager(
IOptions<ReverseProxyConfig> config,
ILogger<UpstreamManager> logger)
{
_config = config.Value;
_logger = logger;
InitializeServerStates();
}
private void InitializeServerStates()
{
foreach (var (name, upstream) in _config.Upstreams)
{
foreach (var server in upstream.Servers)
{
var key = $"{name}:{server.Address}";
_serverStates[key] = new ServerState
{
Address = server.Address,
Weight = server.Weight,
IsHealthy = true,
IsBackup = server.Backup
};
}
}
}
public Task<UpstreamServer?> GetServerAsync(
string upstreamName,
HttpContext context,
CancellationToken cancellationToken)
{
if (!_config.Upstreams.TryGetValue(upstreamName, out var upstream))
{
return Task.FromResult<UpstreamServer?>(null);
}
var healthyServers = upstream.Servers
.Where(s => IsServerHealthy(upstreamName, s.Address) && !s.Backup)
.ToList();
// Fall back to backup servers if no primary available
if (healthyServers.Count == 0)
{
healthyServers = upstream.Servers
.Where(s => IsServerHealthy(upstreamName, s.Address) && s.Backup)
.ToList();
}
if (healthyServers.Count == 0)
{
return Task.FromResult<UpstreamServer?>(null);
}
var server = upstream.LoadBalance switch
{
LoadBalanceStrategy.RoundRobin => SelectRoundRobin(upstreamName, healthyServers),
LoadBalanceStrategy.Random => SelectRandom(healthyServers),
LoadBalanceStrategy.WeightedRoundRobin => SelectWeightedRoundRobin(upstreamName, healthyServers),
LoadBalanceStrategy.LeastConnections => SelectLeastConnections(upstreamName, healthyServers),
LoadBalanceStrategy.IPHash => SelectIPHash(context, healthyServers),
_ => healthyServers[0]
};
return Task.FromResult<UpstreamServer?>(server);
}
private bool IsServerHealthy(string upstreamName, string address)
{
var key = $"{upstreamName}:{address}";
return _serverStates.TryGetValue(key, out var state) && state.IsHealthy;
}
private UpstreamServer SelectRoundRobin(string upstreamName, List<UpstreamServer> servers)
{
var counter = _roundRobinCounters.AddOrUpdate(upstreamName, 0, (_, c) => c + 1);
return servers[counter % servers.Count];
}
private UpstreamServer SelectRandom(List<UpstreamServer> servers)
{
return servers[Random.Shared.Next(servers.Count)];
}
private UpstreamServer SelectWeightedRoundRobin(string upstreamName, List<UpstreamServer> servers)
{
var totalWeight = servers.Sum(s => s.Weight);
var counter = _roundRobinCounters.AddOrUpdate(upstreamName, 0, (_, c) => c + 1);
var position = counter % totalWeight;
var cumulative = 0;
foreach (var server in servers)
{
cumulative += server.Weight;
if (position < cumulative)
return server;
}
return servers[^1];
}
private UpstreamServer SelectLeastConnections(string upstreamName, List<UpstreamServer> servers)
{
return servers
.OrderBy(s =>
{
var key = $"{upstreamName}:{s.Address}";
return _serverStates.TryGetValue(key, out var state) ? state.ActiveConnections : 0;
})
.First();
}
private UpstreamServer SelectIPHash(HttpContext context, List<UpstreamServer> servers)
{
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "127.0.0.1";
var hash = ip.GetHashCode();
return servers[Math.Abs(hash) % servers.Count];
}
public void ReportSuccess(string upstreamName, string serverAddress)
{
var key = $"{upstreamName}:{serverAddress}";
if (_serverStates.TryGetValue(key, out var state))
{
state.ConsecutiveFailures = 0;
state.ConsecutiveSuccesses++;
// Check circuit breaker reset
if (!state.IsHealthy && state.ConsecutiveSuccesses >= GetHealthyThreshold(upstreamName))
{
state.IsHealthy = true;
_logger.LogInformation("Server {Server} marked healthy", serverAddress);
}
}
}
public void ReportFailure(string upstreamName, string serverAddress)
{
var key = $"{upstreamName}:{serverAddress}";
if (_serverStates.TryGetValue(key, out var state))
{
state.ConsecutiveSuccesses = 0;
state.ConsecutiveFailures++;
// Check circuit breaker trip
if (state.IsHealthy && state.ConsecutiveFailures >= GetUnhealthyThreshold(upstreamName))
{
state.IsHealthy = false;
_logger.LogWarning("Server {Server} marked unhealthy after {Failures} failures",
serverAddress, state.ConsecutiveFailures);
}
}
}
private int GetUnhealthyThreshold(string upstreamName)
{
return _config.Upstreams.TryGetValue(upstreamName, out var upstream)
? upstream.HealthCheck?.UnhealthyThreshold ?? 3
: 3;
}
private int GetHealthyThreshold(string upstreamName)
{
return _config.Upstreams.TryGetValue(upstreamName, out var upstream)
? upstream.HealthCheck?.HealthyThreshold ?? 2
: 2;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_healthCheckTimer = new Timer(PerformHealthChecks, null, TimeSpan.Zero, TimeSpan.FromSeconds(10));
return Task.CompletedTask;
}
private async void PerformHealthChecks(object? state)
{
foreach (var (name, upstream) in _config.Upstreams)
{
if (upstream.HealthCheck == null)
continue;
foreach (var server in upstream.Servers)
{
await CheckServerHealthAsync(name, server, upstream.HealthCheck);
}
}
}
private async Task CheckServerHealthAsync(
string upstreamName,
UpstreamServer server,
HealthCheckConfig config)
{
try
{
using var client = new HttpClient { Timeout = config.Timeout };
var uri = new Uri(new Uri(server.Address), config.Path);
var response = await client.GetAsync(uri);
if (response.IsSuccessStatusCode)
{
ReportSuccess(upstreamName, server.Address);
}
else
{
ReportFailure(upstreamName, server.Address);
}
}
catch
{
ReportFailure(upstreamName, server.Address);
}
}
public Task StopAsync(CancellationToken cancellationToken)
{
_healthCheckTimer?.Dispose();
return Task.CompletedTask;
}
}
internal class ServerState
{
public string Address { get; set; } = "";
public int Weight { get; set; } = 1;
public bool IsHealthy { get; set; } = true;
public bool IsBackup { get; set; }
public int ConsecutiveFailures { get; set; }
public int ConsecutiveSuccesses { get; set; }
public int ActiveConnections { get; set; }
}
```
---
## Service Registration
```csharp
namespace StellaOps.Router.Handlers.ReverseProxy;
public static class ReverseProxyExtensions
{
public static IServiceCollection AddReverseProxyHandler(
this IServiceCollection services,
IConfiguration configuration)
{
services.Configure<ReverseProxyConfig>(
configuration.GetSection("ReverseProxy"));
services.AddSingleton<IUpstreamManager, UpstreamManager>();
services.AddHostedService(sp => (UpstreamManager)sp.GetRequiredService<IUpstreamManager>());
services.AddHttpClient("proxy", client =>
{
client.DefaultRequestVersion = HttpVersion.Version20;
client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
})
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
MaxConnectionsPerServer = 100,
EnableMultipleHttp2Connections = true
});
services.AddSingleton<IRouteHandler, ReverseProxyHandler>();
return services;
}
}
```
---
## YAML Configuration
```yaml
ReverseProxy:
DefaultTimeout: "00:00:30"
AddForwardedHeaders: true
PreserveHost: false
ConnectionPool:
MaxConnectionsPerServer: 100
ConnectionIdleTimeout: "00:02:00"
EnableHttp2: true
Upstreams:
legacy-api:
LoadBalance: RoundRobin
Servers:
- Address: "http://legacy-api-1:8080"
Weight: 2
- Address: "http://legacy-api-2:8080"
Weight: 1
- Address: "http://legacy-api-backup:8080"
Backup: true
HealthCheck:
Path: "/health"
Interval: "00:00:10"
Timeout: "00:00:05"
UnhealthyThreshold: 3
HealthyThreshold: 2
CircuitBreaker:
FailureThreshold: 5
SamplingDuration: "00:00:30"
BreakDuration: "00:00:30"
Retry:
MaxRetries: 3
InitialDelay: "00:00:00.100"
BackoffMultiplier: 2.0
RetryableStatusCodes: [502, 503, 504]
external-service:
LoadBalance: LeastConnections
Servers:
- Address: "https://api.external-service.com"
Routes:
- PathPattern: "/legacy/*"
Upstream: "legacy-api"
Rewrite:
Pattern: "^/legacy"
Replacement: "/api/v1"
Headers:
Add:
X-Proxy-Source: "stella-router"
Remove:
- "X-Internal-Token"
ForwardClaims: true
ClaimsHeaderPrefix: "X-User-"
RequiredClaims:
- "sub"
- PathPattern: "/external/*"
Upstream: "external-service"
Timeout: "00:01:00"
Headers:
Set:
Authorization: "Bearer ${EXTERNAL_API_KEY}"
```
---
## Deliverables
1. `StellaOps.Router.Handlers.ReverseProxy/ReverseProxyHandler.cs`
2. `StellaOps.Router.Handlers.ReverseProxy/ReverseProxyConfig.cs`
3. `StellaOps.Router.Handlers.ReverseProxy/IUpstreamManager.cs`
4. `StellaOps.Router.Handlers.ReverseProxy/UpstreamManager.cs`
5. `StellaOps.Router.Handlers.ReverseProxy/ReverseProxyExtensions.cs`
6. Load balancing strategy tests
7. Health check tests
8. Circuit breaker tests
9. Header transformation tests
---
## Next Step
Proceed to [Step 19: Additional Handler Plugins](19-Step.md) to implement static files and WebSocket handlers.