diff --git a/Directory.Build.props b/Directory.Build.props index 86d3d07..e8751a4 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,11 +5,11 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Directory.Build.targets b/Directory.Build.targets index 661281f..fe29bd5 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -6,12 +6,9 @@ enable enable Henrik Jensen - Pairs Microsoft YARP with a web API for runtime configuration and automatic self-signed certificates. - testing;reverse-proxy;certificates https://github.com/henrikhimself/DotNet-ReverseProxy https://github.com/henrikhimself/DotNet-ReverseProxy.git git - 1.0.0-beta2 diff --git a/ReverseProxy.sln b/ReverseProxy.sln index 6c95cb9..b0948e7 100644 --- a/ReverseProxy.sln +++ b/ReverseProxy.sln @@ -7,6 +7,20 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReverseProxy", "src\Reverse EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReverseProxy.UnitTest", "test\ReverseProxy.UnitTest\ReverseProxy.UnitTest.csproj", "{44CD6750-C3BF-45B7-B292-8BFBCC5216C4}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Examples.Aspire", "Examples.Aspire", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Examples.Aspire.AppHost", "examples\Aspire.AppHost\Examples.Aspire.AppHost.csproj", "{707F131B-6DB8-B2E3-2789-F0F2BCEEE929}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReverseProxy.Aspire", "src\ReverseProxy.Aspire\ReverseProxy.Aspire.csproj", "{E3A0F3EA-CA80-AABB-ACF6-27E8C34A1D63}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Examples.Aspire.ReverseProxy", "examples\Aspire.ReverseProxy\Examples.Aspire.ReverseProxy.csproj", "{24BE0E35-2042-4683-25DD-2F8B4990ACE1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Examples.Aspire.Website", "examples\Aspire.Website\Examples.Aspire.Website.csproj", "{0187E74D-09DC-B681-B851-F6949AED4278}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Examples.Aspire.ServiceDefaults", "examples\Aspire.ServiceDefaults\Examples.Aspire.ServiceDefaults.csproj", "{166DED15-CF0E-B2E5-532F-53B48FA98A65}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReverseProxy.Aspire.UnitTest", "test\ReverseProxy.Aspire.UnitTest\ReverseProxy.Aspire.UnitTest.csproj", "{62D393F1-7F63-7A78-36E2-A309877EB476}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -41,10 +55,88 @@ Global {44CD6750-C3BF-45B7-B292-8BFBCC5216C4}.Release|x64.Build.0 = Release|Any CPU {44CD6750-C3BF-45B7-B292-8BFBCC5216C4}.Release|x86.ActiveCfg = Release|Any CPU {44CD6750-C3BF-45B7-B292-8BFBCC5216C4}.Release|x86.Build.0 = Release|Any CPU + {707F131B-6DB8-B2E3-2789-F0F2BCEEE929}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {707F131B-6DB8-B2E3-2789-F0F2BCEEE929}.Debug|Any CPU.Build.0 = Debug|Any CPU + {707F131B-6DB8-B2E3-2789-F0F2BCEEE929}.Debug|x64.ActiveCfg = Debug|Any CPU + {707F131B-6DB8-B2E3-2789-F0F2BCEEE929}.Debug|x64.Build.0 = Debug|Any CPU + {707F131B-6DB8-B2E3-2789-F0F2BCEEE929}.Debug|x86.ActiveCfg = Debug|Any CPU + {707F131B-6DB8-B2E3-2789-F0F2BCEEE929}.Debug|x86.Build.0 = Debug|Any CPU + {707F131B-6DB8-B2E3-2789-F0F2BCEEE929}.Release|Any CPU.ActiveCfg = Release|Any CPU + {707F131B-6DB8-B2E3-2789-F0F2BCEEE929}.Release|Any CPU.Build.0 = Release|Any CPU + {707F131B-6DB8-B2E3-2789-F0F2BCEEE929}.Release|x64.ActiveCfg = Release|Any CPU + {707F131B-6DB8-B2E3-2789-F0F2BCEEE929}.Release|x64.Build.0 = Release|Any CPU + {707F131B-6DB8-B2E3-2789-F0F2BCEEE929}.Release|x86.ActiveCfg = Release|Any CPU + {707F131B-6DB8-B2E3-2789-F0F2BCEEE929}.Release|x86.Build.0 = Release|Any CPU + {E3A0F3EA-CA80-AABB-ACF6-27E8C34A1D63}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E3A0F3EA-CA80-AABB-ACF6-27E8C34A1D63}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E3A0F3EA-CA80-AABB-ACF6-27E8C34A1D63}.Debug|x64.ActiveCfg = Debug|Any CPU + {E3A0F3EA-CA80-AABB-ACF6-27E8C34A1D63}.Debug|x64.Build.0 = Debug|Any CPU + {E3A0F3EA-CA80-AABB-ACF6-27E8C34A1D63}.Debug|x86.ActiveCfg = Debug|Any CPU + {E3A0F3EA-CA80-AABB-ACF6-27E8C34A1D63}.Debug|x86.Build.0 = Debug|Any CPU + {E3A0F3EA-CA80-AABB-ACF6-27E8C34A1D63}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E3A0F3EA-CA80-AABB-ACF6-27E8C34A1D63}.Release|Any CPU.Build.0 = Release|Any CPU + {E3A0F3EA-CA80-AABB-ACF6-27E8C34A1D63}.Release|x64.ActiveCfg = Release|Any CPU + {E3A0F3EA-CA80-AABB-ACF6-27E8C34A1D63}.Release|x64.Build.0 = Release|Any CPU + {E3A0F3EA-CA80-AABB-ACF6-27E8C34A1D63}.Release|x86.ActiveCfg = Release|Any CPU + {E3A0F3EA-CA80-AABB-ACF6-27E8C34A1D63}.Release|x86.Build.0 = Release|Any CPU + {24BE0E35-2042-4683-25DD-2F8B4990ACE1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {24BE0E35-2042-4683-25DD-2F8B4990ACE1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {24BE0E35-2042-4683-25DD-2F8B4990ACE1}.Debug|x64.ActiveCfg = Debug|Any CPU + {24BE0E35-2042-4683-25DD-2F8B4990ACE1}.Debug|x64.Build.0 = Debug|Any CPU + {24BE0E35-2042-4683-25DD-2F8B4990ACE1}.Debug|x86.ActiveCfg = Debug|Any CPU + {24BE0E35-2042-4683-25DD-2F8B4990ACE1}.Debug|x86.Build.0 = Debug|Any CPU + {24BE0E35-2042-4683-25DD-2F8B4990ACE1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {24BE0E35-2042-4683-25DD-2F8B4990ACE1}.Release|Any CPU.Build.0 = Release|Any CPU + {24BE0E35-2042-4683-25DD-2F8B4990ACE1}.Release|x64.ActiveCfg = Release|Any CPU + {24BE0E35-2042-4683-25DD-2F8B4990ACE1}.Release|x64.Build.0 = Release|Any CPU + {24BE0E35-2042-4683-25DD-2F8B4990ACE1}.Release|x86.ActiveCfg = Release|Any CPU + {24BE0E35-2042-4683-25DD-2F8B4990ACE1}.Release|x86.Build.0 = Release|Any CPU + {0187E74D-09DC-B681-B851-F6949AED4278}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0187E74D-09DC-B681-B851-F6949AED4278}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0187E74D-09DC-B681-B851-F6949AED4278}.Debug|x64.ActiveCfg = Debug|Any CPU + {0187E74D-09DC-B681-B851-F6949AED4278}.Debug|x64.Build.0 = Debug|Any CPU + {0187E74D-09DC-B681-B851-F6949AED4278}.Debug|x86.ActiveCfg = Debug|Any CPU + {0187E74D-09DC-B681-B851-F6949AED4278}.Debug|x86.Build.0 = Debug|Any CPU + {0187E74D-09DC-B681-B851-F6949AED4278}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0187E74D-09DC-B681-B851-F6949AED4278}.Release|Any CPU.Build.0 = Release|Any CPU + {0187E74D-09DC-B681-B851-F6949AED4278}.Release|x64.ActiveCfg = Release|Any CPU + {0187E74D-09DC-B681-B851-F6949AED4278}.Release|x64.Build.0 = Release|Any CPU + {0187E74D-09DC-B681-B851-F6949AED4278}.Release|x86.ActiveCfg = Release|Any CPU + {0187E74D-09DC-B681-B851-F6949AED4278}.Release|x86.Build.0 = Release|Any CPU + {166DED15-CF0E-B2E5-532F-53B48FA98A65}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {166DED15-CF0E-B2E5-532F-53B48FA98A65}.Debug|Any CPU.Build.0 = Debug|Any CPU + {166DED15-CF0E-B2E5-532F-53B48FA98A65}.Debug|x64.ActiveCfg = Debug|Any CPU + {166DED15-CF0E-B2E5-532F-53B48FA98A65}.Debug|x64.Build.0 = Debug|Any CPU + {166DED15-CF0E-B2E5-532F-53B48FA98A65}.Debug|x86.ActiveCfg = Debug|Any CPU + {166DED15-CF0E-B2E5-532F-53B48FA98A65}.Debug|x86.Build.0 = Debug|Any CPU + {166DED15-CF0E-B2E5-532F-53B48FA98A65}.Release|Any CPU.ActiveCfg = Release|Any CPU + {166DED15-CF0E-B2E5-532F-53B48FA98A65}.Release|Any CPU.Build.0 = Release|Any CPU + {166DED15-CF0E-B2E5-532F-53B48FA98A65}.Release|x64.ActiveCfg = Release|Any CPU + {166DED15-CF0E-B2E5-532F-53B48FA98A65}.Release|x64.Build.0 = Release|Any CPU + {166DED15-CF0E-B2E5-532F-53B48FA98A65}.Release|x86.ActiveCfg = Release|Any CPU + {166DED15-CF0E-B2E5-532F-53B48FA98A65}.Release|x86.Build.0 = Release|Any CPU + {62D393F1-7F63-7A78-36E2-A309877EB476}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {62D393F1-7F63-7A78-36E2-A309877EB476}.Debug|Any CPU.Build.0 = Debug|Any CPU + {62D393F1-7F63-7A78-36E2-A309877EB476}.Debug|x64.ActiveCfg = Debug|Any CPU + {62D393F1-7F63-7A78-36E2-A309877EB476}.Debug|x64.Build.0 = Debug|Any CPU + {62D393F1-7F63-7A78-36E2-A309877EB476}.Debug|x86.ActiveCfg = Debug|Any CPU + {62D393F1-7F63-7A78-36E2-A309877EB476}.Debug|x86.Build.0 = Debug|Any CPU + {62D393F1-7F63-7A78-36E2-A309877EB476}.Release|Any CPU.ActiveCfg = Release|Any CPU + {62D393F1-7F63-7A78-36E2-A309877EB476}.Release|Any CPU.Build.0 = Release|Any CPU + {62D393F1-7F63-7A78-36E2-A309877EB476}.Release|x64.ActiveCfg = Release|Any CPU + {62D393F1-7F63-7A78-36E2-A309877EB476}.Release|x64.Build.0 = Release|Any CPU + {62D393F1-7F63-7A78-36E2-A309877EB476}.Release|x86.ActiveCfg = Release|Any CPU + {62D393F1-7F63-7A78-36E2-A309877EB476}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {707F131B-6DB8-B2E3-2789-F0F2BCEEE929} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {24BE0E35-2042-4683-25DD-2F8B4990ACE1} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {0187E74D-09DC-B681-B851-F6949AED4278} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {166DED15-CF0E-B2E5-532F-53B48FA98A65} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {5CEEA7D1-CF67-453E-BE4B-8ADC68410EA6} EndGlobalSection diff --git a/examples/.editorconfig b/examples/.editorconfig new file mode 100644 index 0000000..d9a88d4 --- /dev/null +++ b/examples/.editorconfig @@ -0,0 +1,5 @@ +root = false + +[*.cs] +# CA1515: Consider making public types internal +dotnet_diagnostic.CA1515.severity = none diff --git a/examples/Aspire.AppHost/Examples.Aspire.AppHost.csproj b/examples/Aspire.AppHost/Examples.Aspire.AppHost.csproj new file mode 100644 index 0000000..c8d6d49 --- /dev/null +++ b/examples/Aspire.AppHost/Examples.Aspire.AppHost.csproj @@ -0,0 +1,22 @@ + + + + + + Exe + net9.0 + true + true + + + + + + + + + + + + + diff --git a/examples/Aspire.AppHost/Program.cs b/examples/Aspire.AppHost/Program.cs new file mode 100644 index 0000000..55482d1 --- /dev/null +++ b/examples/Aspire.AppHost/Program.cs @@ -0,0 +1,25 @@ +using Hj.ReverseProxy.Aspire; +using Projects; + +var builder = DistributedApplication.CreateBuilder(args); + +// Add example website. +var website = builder.AddProject("Website", options => +{ + // Do not use endpoint configuration found in the Website project. We let aspire set up everything. + options.ExcludeLaunchProfile = true; + options.ExcludeKestrelEndpoints = true; +}) + // Add a HTTP endpoint. The reverse proxy will set up a secure HTTPS endpoint for you to connect to this resource. + .WithHttpEndpoint(); + +// Add reverse proxy website. +var reverseProxy = builder + .AddProject("Reverse-Proxy") + // Map a host name to the endpoint of the example website. + .WithReverseProxyReference("Website", website.GetEndpoint("http"), "example-website.local"); + +// Wait for the website to be healthy. +reverseProxy.WaitFor(website); + +await builder.Build().RunAsync(); diff --git a/examples/Aspire.AppHost/Properties/launchSettings.json b/examples/Aspire.AppHost/Properties/launchSettings.json new file mode 100644 index 0000000..733fcff --- /dev/null +++ b/examples/Aspire.AppHost/Properties/launchSettings.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17294", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21134", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22265" + } + } + } +} diff --git a/examples/Aspire.AppHost/appsettings.json b/examples/Aspire.AppHost/appsettings.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/examples/Aspire.AppHost/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/examples/Aspire.ReverseProxy/Examples.Aspire.ReverseProxy.csproj b/examples/Aspire.ReverseProxy/Examples.Aspire.ReverseProxy.csproj new file mode 100644 index 0000000..c3f90a2 --- /dev/null +++ b/examples/Aspire.ReverseProxy/Examples.Aspire.ReverseProxy.csproj @@ -0,0 +1,13 @@ + + + + net9.0 + true + + + + + + + + diff --git a/examples/Aspire.ReverseProxy/Program.cs b/examples/Aspire.ReverseProxy/Program.cs new file mode 100644 index 0000000..1fcac88 --- /dev/null +++ b/examples/Aspire.ReverseProxy/Program.cs @@ -0,0 +1,31 @@ +using Hj.ReverseProxy; +using Hj.ReverseProxy.Aspire; + +var builder = WebApplication.CreateBuilder(args); + +builder.WebHost.ConfigureKestrel(options => +{ + // Adds a certificate resolver to Kestrel that dynamically generates a self-signed certificate when + // needed to service a HTTP request. Trust is added to these using a Root Certificate Authority that + // is created and saved to a configured location (see "SelfSignedCertificate" section in appsettings). + // You need to make your system trust this Root CA by importing it into your certificate store. Also + // make sure you don't delete the generated Root CA once created and imported. + options.UseSelfSignedCertificate(); +}); + +// Register injected Aspire resource service discovery variables as YARP Routes and Clusters on startup. +// If you're missing some at runtime then you may want to consider making the reverse proxy resource +// wait for the resources that it should proxy. +builder.Services.AddTransient(); + +// Configure the reverse proxy. +builder.Services.ConfigureReverseProxy(builder.Configuration); + +var app = builder.Build(); + +// Use the configured reverse proxy. +app.UseReverseProxy(); + +// No further routes are registered. This means that you will get a 404 error status on all requests +// that do not match a proxied Aspire resource. +await app.RunAsync(); diff --git a/examples/Aspire.ReverseProxy/Properties/launchSettings.json b/examples/Aspire.ReverseProxy/Properties/launchSettings.json new file mode 100644 index 0000000..b2838f7 --- /dev/null +++ b/examples/Aspire.ReverseProxy/Properties/launchSettings.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "applicationUrl": "https://localhost:443" + } + } +} diff --git a/examples/Aspire.ReverseProxy/appsettings.json b/examples/Aspire.ReverseProxy/appsettings.json new file mode 100644 index 0000000..5de8cf3 --- /dev/null +++ b/examples/Aspire.ReverseProxy/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "SelfSignedCertificate": { + "CaFilePath": "c:/" + } +} diff --git a/examples/Aspire.ServiceDefaults/Examples.Aspire.ServiceDefaults.csproj b/examples/Aspire.ServiceDefaults/Examples.Aspire.ServiceDefaults.csproj new file mode 100644 index 0000000..ddd82c9 --- /dev/null +++ b/examples/Aspire.ServiceDefaults/Examples.Aspire.ServiceDefaults.csproj @@ -0,0 +1,21 @@ + + + + net9.0 + true + true + + + + + + + + + + + + + + + diff --git a/examples/Aspire.ServiceDefaults/Extensions.cs b/examples/Aspire.ServiceDefaults/Extensions.cs new file mode 100644 index 0000000..bcb8c68 --- /dev/null +++ b/examples/Aspire.ServiceDefaults/Extensions.cs @@ -0,0 +1,94 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Hj.Examples.Aspire.ServiceDefaults; + +public static class Extensions +{ + public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + http.AddStandardResilienceHandler(); + + http.AddServiceDiscovery(); + }); + + return builder; + } + + public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder) + { + builder.Services + .AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + app.MapHealthChecks("/health"); + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live"), + }); + + return app; + } + + private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder) + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services + .AddOpenTelemetry() + .UseOtlpExporter(); + } + + return builder; + } +} diff --git a/examples/Aspire.Website/Controllers/HomeController.cs b/examples/Aspire.Website/Controllers/HomeController.cs new file mode 100644 index 0000000..516df09 --- /dev/null +++ b/examples/Aspire.Website/Controllers/HomeController.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Hj.Examples.Aspire.Website.Controllers; + +public class HomeController : Controller +{ + public IActionResult Index() => View(); +} diff --git a/examples/Aspire.Website/Examples.Aspire.Website.csproj b/examples/Aspire.Website/Examples.Aspire.Website.csproj new file mode 100644 index 0000000..cb2d44b --- /dev/null +++ b/examples/Aspire.Website/Examples.Aspire.Website.csproj @@ -0,0 +1,12 @@ + + + + net9.0 + true + + + + + + + diff --git a/examples/Aspire.Website/Program.cs b/examples/Aspire.Website/Program.cs new file mode 100644 index 0000000..bf0f828 --- /dev/null +++ b/examples/Aspire.Website/Program.cs @@ -0,0 +1,24 @@ +using Hj.Examples.Aspire.ServiceDefaults; +using Microsoft.AspNetCore.HttpOverrides; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.Services.AddControllersWithViews(); + +var app = builder.Build(); + +// Enable handling forwarded headers. +app.UseForwardedHeaders(new ForwardedHeadersOptions +{ + ForwardedHeaders = ForwardedHeaders.All, +}); + +app.MapDefaultEndpoints(); + +app.UseRouting(); + +app.MapControllerRoute(name: "default", pattern: "{controller=Home}/{action=Index}"); + +await app.RunAsync(); diff --git a/examples/Aspire.Website/Properties/launchSettings.json b/examples/Aspire.Website/Properties/launchSettings.json new file mode 100644 index 0000000..b4ace90 --- /dev/null +++ b/examples/Aspire.Website/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Examples.Aspire.Website": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:60013;http://localhost:60014" + } + } +} \ No newline at end of file diff --git a/examples/Aspire.Website/Views/Home/Index.cshtml b/examples/Aspire.Website/Views/Home/Index.cshtml new file mode 100644 index 0000000..f31a46c --- /dev/null +++ b/examples/Aspire.Website/Views/Home/Index.cshtml @@ -0,0 +1,14 @@ + + + + + + Website - Example + + +

Website

+

You should see this page when navigating to "https://example-website.local/".

+

This website do not use SSL. Instead, a secure HTTPS connection is provided by the reverse proxy.

+

In case "example-website.local" cannot be found then you need to map it to 127.0.0.1 and ::1 in your local hosts file.

+ + diff --git a/examples/Aspire.Website/appsettings.Development.json b/examples/Aspire.Website/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/examples/Aspire.Website/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/examples/Aspire.Website/appsettings.json b/examples/Aspire.Website/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/examples/Aspire.Website/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/examples/Aspire.Website/wwwroot/favicon.ico b/examples/Aspire.Website/wwwroot/favicon.ico new file mode 100644 index 0000000..63e859b Binary files /dev/null and b/examples/Aspire.Website/wwwroot/favicon.ico differ diff --git a/src/ReverseProxy.Aspire/Constants.cs b/src/ReverseProxy.Aspire/Constants.cs new file mode 100644 index 0000000..78fa0f4 --- /dev/null +++ b/src/ReverseProxy.Aspire/Constants.cs @@ -0,0 +1,22 @@ +// +// Copyright 2025 Henrik Jensen +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Hj.ReverseProxy.Aspire; + +internal static class Constants +{ + public const string EnvPrefix = "reverseproxy"; +} diff --git a/src/ReverseProxy.Aspire/README.md b/src/ReverseProxy.Aspire/README.md new file mode 100644 index 0000000..ea6e116 --- /dev/null +++ b/src/ReverseProxy.Aspire/README.md @@ -0,0 +1,6 @@ +# A Reverse Proxy for .NET Aspire. +This package integrates the HenrikJensen.ReverseProxy package into a .NET Aspire solution. + +It contains an "WithReverseProxyReference" annotation for configuring Aspire resources in the Program.cs of the AppHost. + +It also contains a start up filter to be used in the website that acts as the reverse proxy. Registering the "ServiceDiscoveryStartupFilter" will configure the reverse proxy website to expose annotated resources. diff --git a/src/ReverseProxy.Aspire/ResourceBuilderExtensions.cs b/src/ReverseProxy.Aspire/ResourceBuilderExtensions.cs new file mode 100644 index 0000000..7d68f92 --- /dev/null +++ b/src/ReverseProxy.Aspire/ResourceBuilderExtensions.cs @@ -0,0 +1,36 @@ +// +// Copyright 2025 Henrik Jensen +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Hj.ReverseProxy.Aspire; + +public static class ResourceBuilderExtensions +{ + public static IResourceBuilder WithReverseProxyReference(this IResourceBuilder builder, string serviceName, EndpointReference endpointReference, string hostName, int port = 443) + where T : IResourceWithEnvironment + { + var externalUrl = $"https://{hostName}:{port}"; + + builder + .WithUrl(externalUrl) + .WithReference(endpointReference) + .WithEnvironment(context => + { + context.EnvironmentVariables[$"{Constants.EnvPrefix}__{serviceName}"] = hostName; + }); + + return builder; + } +} diff --git a/src/ReverseProxy.Aspire/ReverseProxy.Aspire.csproj b/src/ReverseProxy.Aspire/ReverseProxy.Aspire.csproj new file mode 100644 index 0000000..99c7e49 --- /dev/null +++ b/src/ReverseProxy.Aspire/ReverseProxy.Aspire.csproj @@ -0,0 +1,38 @@ + + + + net8.0;net9.0 + Recommended + true + true + true + snupkg + true + HenrikJensen.ReverseProxy.Aspire + .NET Aspire integration package. + testing;reverse-proxy;aspire + README.md + LICENSE + 1.0.0-beta1 + + + + + + + + + + <_Parameter1>$(MSBuildProjectName).UnitTest + + + <_Parameter1>DynamicProxyGenAssembly2 + + + + + + + + + diff --git a/src/ReverseProxy.Aspire/ServiceDiscovery.cs b/src/ReverseProxy.Aspire/ServiceDiscovery.cs new file mode 100644 index 0000000..1d8d4ef --- /dev/null +++ b/src/ReverseProxy.Aspire/ServiceDiscovery.cs @@ -0,0 +1,80 @@ +// +// Copyright 2025 Henrik Jensen +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Microsoft.Extensions.Configuration; + +namespace Hj.ReverseProxy.Aspire; + +internal static class ServiceDiscovery +{ + public static IEnumerable<(string ServiceName, string HostName)> ReadConfiguration(IConfiguration configuration) + { + var reverseProxySection = configuration.GetSection(Constants.EnvPrefix); + foreach (var hostMapping in reverseProxySection.GetChildren()) + { + var serviceName = hostMapping.Key; + var hostName = hostMapping.Value; + if (serviceName == null || hostName == null) + { + continue; + } + + yield return (serviceName, hostName); + } + } + + public static string[] DiscoverEndpointList(IConfiguration configuration, string query, string[]? allowedSchemes = null, bool allowAllSchemes = true) + { + if (!Uri.TryCreate(query, UriKind.Absolute, out var uri)) + { + throw new InvalidOperationException($"Invalid service discovery query '{query}'"); + } + + var serviceName = uri.Host; + var namedEndpoint = string.Empty; + var namedEndpointSeparator = serviceName.IndexOf('.', StringComparison.Ordinal); + if (serviceName[0] == '_' && namedEndpointSeparator > 1 && serviceName[^1] != '.') + { + namedEndpoint = serviceName[1..namedEndpointSeparator]; + serviceName = serviceName[(namedEndpointSeparator + 1)..]; + } + + allowedSchemes ??= uri.Scheme.Split('+'); + + ReadOnlySpan endpoints = [namedEndpoint, .. allowedSchemes]; + foreach (var endpoint in endpoints) + { + var section = configuration.GetSection($"Services:{serviceName}:{endpoint}"); + if (section.Exists()) + { + var uriStrings = section.Get(); + if (uriStrings == null || allowAllSchemes) + { + return uriStrings ?? []; + } + + uriStrings = [.. uriStrings.Where(x => + { + return Uri.TryCreate(x, default, out var uri) + && allowedSchemes.Contains(uri.Scheme, StringComparer.OrdinalIgnoreCase); + })]; + return uriStrings; + } + } + + return []; + } +} diff --git a/src/ReverseProxy.Aspire/ServiceDiscoveryStartupFilter.cs b/src/ReverseProxy.Aspire/ServiceDiscoveryStartupFilter.cs new file mode 100644 index 0000000..08d99af --- /dev/null +++ b/src/ReverseProxy.Aspire/ServiceDiscoveryStartupFilter.cs @@ -0,0 +1,84 @@ +// +// Copyright 2025 Henrik Jensen +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Yarp.ReverseProxy.Configuration; + +namespace Hj.ReverseProxy.Aspire; + +public sealed class ServiceDiscoveryStartupFilter : IStartupFilter +{ + public Action Configure(Action next) + { + return app => + { + using var scope = app.ApplicationServices.CreateScope(); + var serviceProvider = scope.ServiceProvider; + ConfigureYarp( + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService()); + + next(app); + }; + } + + private static void ConfigureYarp( + IConfiguration configuration, + InMemoryConfigProvider inMemoryConfigProvider) + { + List routes = []; + List clusters = []; + + var hostMappings = ServiceDiscovery.ReadConfiguration(configuration); + + foreach ((var serviceName, var hostName) in hostMappings) + { + var endpoints = ServiceDiscovery.DiscoverEndpointList(configuration, "https+http://" + serviceName); + if (endpoints.Length == 0) + { + continue; + } + + var destinations = new Dictionary(); + for (var i = 0; i < endpoints.Length; i++) + { + destinations.Add("destination" + i, new DestinationConfig() { Address = endpoints[i], }); + } + + clusters.Add(new ClusterConfig() + { + ClusterId = serviceName, + Destinations = destinations, + }); + + routes.Add(new RouteConfig() + { + RouteId = serviceName, + ClusterId = serviceName, + Match = new() + { + Hosts = [hostName], + Path = "/{**catch-all}", + }, + }); + } + + inMemoryConfigProvider.Update(routes, clusters); + } +} diff --git a/src/ReverseProxy/Certificate/CertificateConstants.cs b/src/ReverseProxy/Certificate/CertificateConstants.cs index 26b32bb..263df03 100644 --- a/src/ReverseProxy/Certificate/CertificateConstants.cs +++ b/src/ReverseProxy/Certificate/CertificateConstants.cs @@ -24,9 +24,9 @@ internal static class CertificateConstants public const string DefaultCaSubjectName = "CN=ReverseProxy Root CA"; - public const string CaCrtFileName = "ca.crt.pem"; + public const string CaCrtFileName = "ReverseProxy-RootCA.crt.pem"; - public const string CaKeyFileName = "ca.key.pem"; + public const string CaKeyFileName = "ReverseProxy-RootCA.key.pem"; - public const string CaPfxFileName = "ca.pfx"; + public const string CaPfxFileName = "ReverseProxy-RootCA.pfx"; } diff --git a/src/ReverseProxy/Certificate/CertificateFactory.cs b/src/ReverseProxy/Certificate/CertificateFactory.cs index c9235bd..fd94363 100644 --- a/src/ReverseProxy/Certificate/CertificateFactory.cs +++ b/src/ReverseProxy/Certificate/CertificateFactory.cs @@ -75,7 +75,9 @@ public X509Certificate2 CreateCertificate(AsymmetricAlgorithm key, X509Certifica var pfxBytes = certificateWithKey.Export(X509ContentType.Pkcs12); #pragma warning disable SYSLIB0057 // Type or member is obsolete - var pfx = new X509Certificate2(pfxBytes, (string?)null, X509KeyStorageFlags.Exportable | X509KeyStorageFlags.EphemeralKeySet); + var pfx = Environment.OSVersion.Platform == PlatformID.Win32NT + ? new X509Certificate2(pfxBytes, (string?)null, X509KeyStorageFlags.Exportable) + : new X509Certificate2(pfxBytes, (string?)null, X509KeyStorageFlags.Exportable | X509KeyStorageFlags.EphemeralKeySet); #pragma warning restore SYSLIB0057 // Type or member is obsolete return pfx; } diff --git a/src/ReverseProxy/ReverseProxy.csproj b/src/ReverseProxy/ReverseProxy.csproj index ccd31b5..54bce65 100644 --- a/src/ReverseProxy/ReverseProxy.csproj +++ b/src/ReverseProxy/ReverseProxy.csproj @@ -9,8 +9,11 @@ snupkg true HenrikJensen.ReverseProxy + Pairs Microsoft YARP with a web API for runtime configuration and automatic self-signed certificates. + testing;reverse-proxy;certificates README.md LICENSE + 1.0.0-beta3 diff --git a/test/ReverseProxy.Aspire.UnitTest/ReverseProxy.Aspire.UnitTest.csproj b/test/ReverseProxy.Aspire.UnitTest/ReverseProxy.Aspire.UnitTest.csproj new file mode 100644 index 0000000..827f4f1 --- /dev/null +++ b/test/ReverseProxy.Aspire.UnitTest/ReverseProxy.Aspire.UnitTest.csproj @@ -0,0 +1,35 @@ + + + + net8.0;net9.0 + true + false + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/test/ReverseProxy.Aspire.UnitTest/ServiceDiscoveryTests.cs b/test/ReverseProxy.Aspire.UnitTest/ServiceDiscoveryTests.cs new file mode 100644 index 0000000..b287dc8 --- /dev/null +++ b/test/ReverseProxy.Aspire.UnitTest/ServiceDiscoveryTests.cs @@ -0,0 +1,114 @@ +using Microsoft.Extensions.Configuration; + +namespace Hj.ReverseProxy.Aspire.UnitTest; + +public class ServiceDiscoveryTests +{ + [Fact] + public void DiscoverEndpointList_GivenInvalidQuery_ReturnsEmpty() + { + // arrange + var config = GetConfiguration([]); + + // act & assert + Assert.Throws(() => ServiceDiscovery.DiscoverEndpointList(config, "invalid-query")); + } + + [Fact] + public void DiscoverEndpointList_GivenMissingServiceDiscoverySection_ReturnsEmpty() + { + // arrange + var config = GetConfiguration([]); + + // act + var result = ServiceDiscovery.DiscoverEndpointList(config, "https://service"); + + // assert + Assert.Empty(result); + } + + [Fact] + public void DiscoverEndpointList_GivenService_ReturnsEndpoints() + { + // arrange + var config = GetConfiguration(new Dictionary() + { + { "Services:Service:Endpoint:0", "http://localhost" }, + { "Services:Service:Endpoint:1", "https://localhost" }, + }); + + // act + var result = ServiceDiscovery.DiscoverEndpointList(config, "endpoint://service"); + + // assert + Assert.Equal("http://localhost", result[0]); + Assert.Equal("https://localhost", result[1]); + } + + [Fact] + public void DiscoverEndpointList_GivenServiceAndNamedEndpoint_ReturnsNamedEndpoint() + { + // arrange + var config = GetConfiguration(new Dictionary() + { + { "Services:Service:Endpoint:0", "https://localhost" }, + { "Services:Service:Named-Endpoint:0", "https://named-endpoint" }, + }); + + // act + var result = ServiceDiscovery.DiscoverEndpointList(config, "https://_named-endpoint.service"); + + // assert + var item = Assert.Single(result); + Assert.Equal("https://named-endpoint", item); + } + + [Theory] + [InlineData("http")] + [InlineData("https")] + public void DiscoverEndpointList_GivenServiceWithAllowedSchemes_ReturnsEndpointWithAllowedScheme(string allowedScheme) + { + // arrange + var config = GetConfiguration(new Dictionary() + { + { "Services:Service:Endpoint:0", "http://localhost" }, + { "Services:Service:Endpoint:1", "https://localhost" }, + }); + + string[] allowedSchemes = [allowedScheme]; + + // act + var result = ServiceDiscovery.DiscoverEndpointList(config, "http+https://_endpoint.service", allowedSchemes, false); + + // assert + var item = Assert.Single(result); + Assert.Equal(allowedScheme + "://localhost", item); + } + + [Theory] + [InlineData("")] + [InlineData("http+")] + public void DiscoverEndpointList_GivenInvalidAllowedSchemes_SkipsAllowedSchemeReturnsNone(string allowedScheme) + { + // arrange + var config = GetConfiguration(new Dictionary() + { + { "Services:Service:Endpoint:0", "http://localhost" }, + { "Services:Service:Endpoint:1", "https://localhost" }, + }); + + string[]? allowedSchemes = [allowedScheme]; + + // act + var result = ServiceDiscovery.DiscoverEndpointList(config, "http+https://_endpoint.service", allowedSchemes, false); + + // assert + Assert.Empty(result); + } + + private static IConfiguration GetConfiguration(Dictionary settings) + { + var initialData = settings.Select(kvp => new KeyValuePair(kvp.Key, kvp.Value)); + return new ConfigurationBuilder().AddInMemoryCollection(initialData).Build(); + } +} diff --git a/test/ReverseProxy.UnitTest/ReverseProxy.UnitTest.csproj b/test/ReverseProxy.UnitTest/ReverseProxy.UnitTest.csproj index 3b7bf6a..487c38b 100644 --- a/test/ReverseProxy.UnitTest/ReverseProxy.UnitTest.csproj +++ b/test/ReverseProxy.UnitTest/ReverseProxy.UnitTest.csproj @@ -14,12 +14,18 @@ - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + - - - - + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive +