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

29 KiB

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

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

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

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

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

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 to implement static files and WebSocket handlers.