Add integration tests for migration categories and execution
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
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
- 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.
This commit is contained in:
890
docs/router/18-Step.md
Normal file
890
docs/router/18-Step.md
Normal file
@@ -0,0 +1,890 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user