diff --git a/src/CodeConfigSample/CCProxy/CCProxy.csproj b/src/CodeConfigSample/CCProxy/CCProxy.csproj new file mode 100644 index 0000000..1a21507 --- /dev/null +++ b/src/CodeConfigSample/CCProxy/CCProxy.csproj @@ -0,0 +1,13 @@ + + + + net5.0 + + + + + + + + + diff --git a/src/CodeConfigSample/CCProxy/ConsulMonitorWorker.cs b/src/CodeConfigSample/CCProxy/ConsulMonitorWorker.cs new file mode 100644 index 0000000..019ee9d --- /dev/null +++ b/src/CodeConfigSample/CCProxy/ConsulMonitorWorker.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Consul; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.ReverseProxy.Abstractions; +using Microsoft.ReverseProxy.Service; + +namespace CCProxy +{ + public class ConsulMonitorWorker : BackgroundService + { + private readonly IConsulClient _consulClient; + private readonly IProxyConfigProvider _proxyConfigProvider; + private readonly IConfigValidator _proxyConfigValidator; + private readonly ILogger _logger; + private const int DEFAULT_CONSUL_POLL_INTERVAL_MINS = 2; + + public ConsulMonitorWorker(IConsulClient consulClient, IProxyConfigProvider proxyConfigProvider, + IConfigValidator proxyConfigValidator, ILogger logger) + { + _consulClient = consulClient; + _proxyConfigProvider = proxyConfigProvider; + _proxyConfigValidator = proxyConfigValidator; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + var serviceResult = await _consulClient.Agent.Services(stoppingToken); + + if (serviceResult.StatusCode == HttpStatusCode.OK) + { + var clusters = await ExtractClusters(serviceResult); + var routes = await ExtractRoutes(serviceResult); + + (_proxyConfigProvider as ConsulProxyConfigProvider)?.Update(routes, clusters); + } + + await Task.Delay(TimeSpan.FromMinutes(DEFAULT_CONSUL_POLL_INTERVAL_MINS), stoppingToken); + } + } + + private async Task> ExtractClusters(QueryResult> serviceResult) + { + var clusters = new Dictionary(); + var serviceMapping = serviceResult.Response; + foreach (var (key, svc) in serviceMapping) + { + var cluster = clusters.ContainsKey(svc.Service) + ? clusters[svc.Service] + : new Cluster {Id = svc.Service}; + + cluster.Destinations.Add(svc.ID, new Destination {Address = $"{svc.Address}:{svc.Port}"}); + + var clusterErrs = await _proxyConfigValidator.ValidateClusterAsync(cluster); + if (clusterErrs.Any()) + { + _logger.LogError("Errors found when creating clusters for {Service}", svc.Service); + clusterErrs.ForEach(err => _logger.LogError(err, $"{svc.Service} cluster validation error")); + continue; + } + + clusters[svc.Service] = cluster; + } + + return clusters.Values.ToList(); + } + + private async Task> ExtractRoutes(QueryResult> serviceResult) + { + var serviceMapping = serviceResult.Response; + List routes = new List(); + foreach (var (key, svc) in serviceMapping) + { + if (svc.Meta.TryGetValue("yarp", out string enableYarp) && + enableYarp.Equals("on", StringComparison.InvariantCulture)) + { + if (routes.Any(r => r.ClusterId == svc.Service)) continue; + + ProxyRoute route = new ProxyRoute + { + ClusterId = svc.Service, + RouteId = $"{svc.Service}-route", + Match = + { + Path = svc.Meta.ContainsKey("yarp_path")?svc.Meta["yarp_path"] : default, + Hosts = svc.Meta.ContainsKey("yarp_hosts")? svc.Meta["yarp_hosts"].Split(',') : default + } + }; + + var routeErrs = await _proxyConfigValidator.ValidateRouteAsync(route); + if (routeErrs.Any()) + { + _logger.LogError("Errors found when trying to generate routes for {Service}", svc.Service); + routeErrs.ForEach(err => _logger.LogError(err, $"{svc.Service} route validation error")); + continue; + } + routes.Add(route); + } + } + return routes; + } + } +} \ No newline at end of file diff --git a/src/CodeConfigSample/CCProxy/ConsulProxyConfigProvider.cs b/src/CodeConfigSample/CCProxy/ConsulProxyConfigProvider.cs new file mode 100644 index 0000000..8b4d131 --- /dev/null +++ b/src/CodeConfigSample/CCProxy/ConsulProxyConfigProvider.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using System.Threading; +using Consul; +using Microsoft.Extensions.Primitives; +using Microsoft.ReverseProxy.Abstractions; +using Microsoft.ReverseProxy.Service; + +namespace CCProxy +{ + public class ConsulProxyConfigProvider : IProxyConfigProvider + { + private readonly IConsulClient _consulClient; + private volatile ConsulProxyConfig _config; + + public ConsulProxyConfigProvider(IConsulClient consulClient) + { + _consulClient = consulClient; + _config = new ConsulProxyConfig(null, null); + } + + public IProxyConfig GetConfig() => _config; + + public virtual void Update(IReadOnlyList routes, IReadOnlyList clusters) + { + var oldConfig = _config; + _config = new ConsulProxyConfig(routes, clusters); + oldConfig.SignalChange(); + } + + private class ConsulProxyConfig : IProxyConfig + { + private readonly CancellationTokenSource _cts = new CancellationTokenSource(); + public IReadOnlyList Routes { get; } + public IReadOnlyList Clusters { get; } + public IChangeToken ChangeToken { get; } + + public ConsulProxyConfig(IReadOnlyList routes, IReadOnlyList clusters) + { + Routes = routes; + Clusters = clusters; + ChangeToken = new CancellationChangeToken(_cts.Token); + } + + internal void SignalChange() + { + _cts.Cancel(); + } + } + } +} \ No newline at end of file diff --git a/src/CodeConfigSample/CCProxy/Controllers/YarpController.cs b/src/CodeConfigSample/CCProxy/Controllers/YarpController.cs new file mode 100644 index 0000000..3caffb4 --- /dev/null +++ b/src/CodeConfigSample/CCProxy/Controllers/YarpController.cs @@ -0,0 +1,52 @@ +using System; +using System.Linq; +using Microsoft.AspNetCore.Mvc; +using Microsoft.ReverseProxy.Service; + +namespace CCProxy.Controllers +{ + [Route("/yarp")] + [ApiController] + public class YarpController : Controller + { + private readonly IProxyConfigProvider _proxyConfigProvider; + + public YarpController(IProxyConfigProvider proxyConfigProvider) + { + _proxyConfigProvider = proxyConfigProvider; + } + + [HttpGet("routes")] + public ActionResult DumpRoutes() + { + var proxyConfig = _proxyConfigProvider.GetConfig(); + return Ok(proxyConfig.Routes); + } + + [HttpGet("clusters")] + public ActionResult DumpClusters() + { + var proxyConfig = _proxyConfigProvider.GetConfig(); + return Ok(proxyConfig.Clusters); + } + + [HttpGet("incoming")] + public IActionResult Dump() + { + var result = new + { + Request.Method, + Request.Protocol, + Request.Scheme, + Host = Request.Host.Value, + PathBase = Request.PathBase.Value, + Path = Request.Path.Value, + Query = Request.QueryString.Value, + Headers = Request.Headers.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.ToArray()), + Time = DateTimeOffset.UtcNow + }; + + return Ok(result); + } + } +} \ No newline at end of file diff --git a/src/CodeConfigSample/CCProxy/Dockerfile b/src/CodeConfigSample/CCProxy/Dockerfile new file mode 100644 index 0000000..33f23f7 --- /dev/null +++ b/src/CodeConfigSample/CCProxy/Dockerfile @@ -0,0 +1,19 @@ +FROM mcr.microsoft.com/dotnet/aspnet:5.0 AS base +WORKDIR /app +EXPOSE 80 + +FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build +WORKDIR /src +COPY ["CCProxy.csproj", "./"] +RUN dotnet restore "CCProxy.csproj" +COPY . . +WORKDIR "/src" +RUN dotnet build "CCProxy.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "CCProxy.csproj" -c Release -o /app/publish + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "CCProxy.dll"] diff --git a/src/CodeConfigSample/CCProxy/Middleware/HttpInspectorMiddleware.cs b/src/CodeConfigSample/CCProxy/Middleware/HttpInspectorMiddleware.cs new file mode 100644 index 0000000..b23dd34 --- /dev/null +++ b/src/CodeConfigSample/CCProxy/Middleware/HttpInspectorMiddleware.cs @@ -0,0 +1,57 @@ +using System; +using System.Data.Common; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.ReverseProxy.Middleware; + +namespace CCProxy.Middleware +{ + public class HttpInspectorMiddleware + { + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public HttpInspectorMiddleware(RequestDelegate next, + ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task Invoke(HttpContext context) + { + LogRequest(context); + LogDestinations(context); + await _next(context); + } + + private void LogDestinations(HttpContext context) + { + _logger.LogDebug("Available Destinations: "); + var proxyFeature = context.Features.Get(); + + proxyFeature.AvailableDestinations.ForEach(dest => + { + _logger.LogDebug("Destination {ID} {Address}", dest.DestinationId, dest.Config.Address); + }); + } + + private void LogRequest(HttpContext context) + { + var buffer = new StringBuilder(); + + context.Request.Headers + .ForEach(kvp => buffer.Append($"{kvp.Key}: {kvp.Value}{Environment.NewLine}")); + + _logger.LogDebug($"Http Request Information:{Environment.NewLine}" + + $"Schema: {context.Request.Scheme}{Environment.NewLine}" + + $"Host: {context.Request.Host}{Environment.NewLine}" + + $"Path: {context.Request.Path}{Environment.NewLine}" + + $"QueryString: {context.Request.QueryString}{Environment.NewLine}" + + $"Headers: {Environment.NewLine}{buffer}{Environment.NewLine}"); + } + } +} \ No newline at end of file diff --git a/src/CodeConfigSample/CCProxy/Program.cs b/src/CodeConfigSample/CCProxy/Program.cs new file mode 100644 index 0000000..fe0db69 --- /dev/null +++ b/src/CodeConfigSample/CCProxy/Program.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace CCProxy +{ + public class Program + { + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); + } +} diff --git a/src/CodeConfigSample/CCProxy/Properties/launchSettings.json b/src/CodeConfigSample/CCProxy/Properties/launchSettings.json new file mode 100644 index 0000000..d8b549b --- /dev/null +++ b/src/CodeConfigSample/CCProxy/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "CCProxy": { + "commandName": "Project", + "dotnetRunMessages": "true", + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_URLS" : "http://0.0.0.0:5000" + } + } + } +} \ No newline at end of file diff --git a/src/CodeConfigSample/CCProxy/ServiceCollectionExtensions.cs b/src/CodeConfigSample/CCProxy/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..61e0164 --- /dev/null +++ b/src/CodeConfigSample/CCProxy/ServiceCollectionExtensions.cs @@ -0,0 +1,31 @@ +using System; +using Consul; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CCProxy +{ + public static class ServiceCollectionExtensions + { + public static IServiceCollection AddConsulClient(this IServiceCollection services) + { + return services.AddConsulClient(options => { }); + } + + public static IServiceCollection AddConsulClient( + this IServiceCollection services, + Action options) + { + /* + * CONSUL_HTTP_ADDR + * CONSUL_HTTP_SSL + * CONSUL_HTTP_SSL_VERIFY + * CONSUL_HTTP_AUTH + * CONSUL_HTTP_TOKEN + */ + services.TryAddSingleton(sp => new ConsulClient(options)); + + return services; + } + } +} \ No newline at end of file diff --git a/src/CodeConfigSample/CCProxy/Startup.cs b/src/CodeConfigSample/CCProxy/Startup.cs new file mode 100644 index 0000000..b5276c7 --- /dev/null +++ b/src/CodeConfigSample/CCProxy/Startup.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using CCProxy.Middleware; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.ReverseProxy.Service; + +namespace CCProxy +{ + public class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + public void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(); + + services.AddReverseProxy(); + + services.AddHealthChecks(); + services.AddControllers() + .AddJsonOptions(option => + { + option.JsonSerializerOptions.IgnoreNullValues = true; + option.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + }); + + services.AddConsulClient(opts => + { + var consulClientConfig = Configuration.GetSection("consul:client"); + var port = consulClientConfig.GetValue("port"); + var host = consulClientConfig.GetValue("host"); + var scheme = consulClientConfig.GetValue("scheme"); + var dc = consulClientConfig.GetValue("datacenter"); + + var address = $"{scheme}://{host}:{port}"; + opts.Address = new Uri(address); + opts.Datacenter = dc; + }); + + services.AddHostedService(); + } + + + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + app.UseRouting(); + + app.UseEndpoints(endpoints => + { + endpoints.MapHealthChecks("/health"); + endpoints.MapReverseProxy(pipeline => pipeline.UseMiddleware()); + endpoints.MapControllers(); + + endpoints.MapGet("/", async context => + { + await context.Response.WriteAsync("Hello World!"); + }); + }); + } + } +} diff --git a/src/CodeConfigSample/CCProxy/appsettings.Development.json b/src/CodeConfigSample/CCProxy/appsettings.Development.json new file mode 100644 index 0000000..d33fbc8 --- /dev/null +++ b/src/CodeConfigSample/CCProxy/appsettings.Development.json @@ -0,0 +1,17 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "consul": { + "client": { + "host": "localhost", + "port": 8500, + "scheme": "http", + "datacenter" : "yarp_dc" + } + } +} diff --git a/src/CodeConfigSample/CCProxy/appsettings.Docker.json b/src/CodeConfigSample/CCProxy/appsettings.Docker.json new file mode 100644 index 0000000..1f09ea4 --- /dev/null +++ b/src/CodeConfigSample/CCProxy/appsettings.Docker.json @@ -0,0 +1,17 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "consul": { + "client": { + "host": "consul_service", + "port": 8500, + "scheme": "http", + "datacenter" : "yarp_dc" + } + } +} diff --git a/src/CodeConfigSample/CCProxy/appsettings.json b/src/CodeConfigSample/CCProxy/appsettings.json new file mode 100644 index 0000000..d9d9a9b --- /dev/null +++ b/src/CodeConfigSample/CCProxy/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/src/CodeConfigSample/CCProxy/proxyrequests.http b/src/CodeConfigSample/CCProxy/proxyrequests.http new file mode 100644 index 0000000..7e3c407 --- /dev/null +++ b/src/CodeConfigSample/CCProxy/proxyrequests.http @@ -0,0 +1,14 @@ +### Cluters +GET http://0.0.0.0:5000/yarp/clusters +Accept: application/json + +### Routes +GET http://0.0.0.0:5000/yarp/incoming +Accept: application/json + +### Proxied Items +GET http://0.0.0.0:5000/api/items +Accept: application/json +Host: items + +### \ No newline at end of file