From 297071d4f4c90723a979e70733a743f792f99215 Mon Sep 17 00:00:00 2001 From: Henrik Jensen Date: Thu, 10 Jul 2025 15:29:14 +0200 Subject: [PATCH 1/2] Add Aspire integration package. Add example for Aspire. --- Directory.Build.props | 4 +- Directory.Build.targets | 3 - ReverseProxy.sln | 92 ++++++++++++++ examples/.editorconfig | 5 + .../Examples.Aspire.AppHost.csproj | 22 ++++ examples/Aspire.AppHost/Program.cs | 25 ++++ .../Properties/launchSettings.json | 17 +++ examples/Aspire.AppHost/appsettings.json | 8 ++ .../Examples.Aspire.ReverseProxy.csproj | 13 ++ examples/Aspire.ReverseProxy/Program.cs | 31 +++++ .../Properties/launchSettings.json | 9 ++ examples/Aspire.ReverseProxy/appsettings.json | 12 ++ .../Examples.Aspire.ServiceDefaults.csproj | 21 ++++ examples/Aspire.ServiceDefaults/Extensions.cs | 94 +++++++++++++++ .../Controllers/HomeController.cs | 8 ++ .../Examples.Aspire.Website.csproj | 12 ++ examples/Aspire.Website/Program.cs | 24 ++++ .../Properties/launchSettings.json | 12 ++ .../Aspire.Website/Views/Home/Index.cshtml | 14 +++ .../appsettings.Development.json | 8 ++ examples/Aspire.Website/appsettings.json | 9 ++ examples/Aspire.Website/wwwroot/favicon.ico | Bin 0 -> 5430 bytes src/ReverseProxy.Aspire/Constants.cs | 22 ++++ src/ReverseProxy.Aspire/README.md | 6 + .../ResourceBuilderExtensions.cs | 36 ++++++ .../ReverseProxy.Aspire.csproj | 37 ++++++ src/ReverseProxy.Aspire/ServiceDiscovery.cs | 80 ++++++++++++ .../ServiceDiscoveryStartupFilter.cs | 84 +++++++++++++ .../Certificate/CertificateConstants.cs | 6 +- .../Certificate/CertificateFactory.cs | 4 +- src/ReverseProxy/ReverseProxy.csproj | 3 + .../ReverseProxy.Aspire.UnitTest.csproj | 35 ++++++ .../ServiceDiscoveryTests.cs | 114 ++++++++++++++++++ .../ReverseProxy.UnitTest.csproj | 16 ++- 34 files changed, 872 insertions(+), 14 deletions(-) create mode 100644 examples/.editorconfig create mode 100644 examples/Aspire.AppHost/Examples.Aspire.AppHost.csproj create mode 100644 examples/Aspire.AppHost/Program.cs create mode 100644 examples/Aspire.AppHost/Properties/launchSettings.json create mode 100644 examples/Aspire.AppHost/appsettings.json create mode 100644 examples/Aspire.ReverseProxy/Examples.Aspire.ReverseProxy.csproj create mode 100644 examples/Aspire.ReverseProxy/Program.cs create mode 100644 examples/Aspire.ReverseProxy/Properties/launchSettings.json create mode 100644 examples/Aspire.ReverseProxy/appsettings.json create mode 100644 examples/Aspire.ServiceDefaults/Examples.Aspire.ServiceDefaults.csproj create mode 100644 examples/Aspire.ServiceDefaults/Extensions.cs create mode 100644 examples/Aspire.Website/Controllers/HomeController.cs create mode 100644 examples/Aspire.Website/Examples.Aspire.Website.csproj create mode 100644 examples/Aspire.Website/Program.cs create mode 100644 examples/Aspire.Website/Properties/launchSettings.json create mode 100644 examples/Aspire.Website/Views/Home/Index.cshtml create mode 100644 examples/Aspire.Website/appsettings.Development.json create mode 100644 examples/Aspire.Website/appsettings.json create mode 100644 examples/Aspire.Website/wwwroot/favicon.ico create mode 100644 src/ReverseProxy.Aspire/Constants.cs create mode 100644 src/ReverseProxy.Aspire/README.md create mode 100644 src/ReverseProxy.Aspire/ResourceBuilderExtensions.cs create mode 100644 src/ReverseProxy.Aspire/ReverseProxy.Aspire.csproj create mode 100644 src/ReverseProxy.Aspire/ServiceDiscovery.cs create mode 100644 src/ReverseProxy.Aspire/ServiceDiscoveryStartupFilter.cs create mode 100644 test/ReverseProxy.Aspire.UnitTest/ReverseProxy.Aspire.UnitTest.csproj create mode 100644 test/ReverseProxy.Aspire.UnitTest/ServiceDiscoveryTests.cs 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 0000000000000000000000000000000000000000..63e859b476eff5055e0e557aaa151ca8223fbeef GIT binary patch literal 5430 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 +// 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..f9ae4c3 --- /dev/null +++ b/src/ReverseProxy.Aspire/ReverseProxy.Aspire.csproj @@ -0,0 +1,37 @@ + + + + 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 + From b0a8923538ead0681cb67a98f8aa2a5389f5d7a7 Mon Sep 17 00:00:00 2001 From: Henrik Jensen Date: Thu, 10 Jul 2025 15:32:19 +0200 Subject: [PATCH 2/2] Add missing README --- src/ReverseProxy.Aspire/ReverseProxy.Aspire.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ReverseProxy.Aspire/ReverseProxy.Aspire.csproj b/src/ReverseProxy.Aspire/ReverseProxy.Aspire.csproj index f9ae4c3..99c7e49 100644 --- a/src/ReverseProxy.Aspire/ReverseProxy.Aspire.csproj +++ b/src/ReverseProxy.Aspire/ReverseProxy.Aspire.csproj @@ -17,6 +17,7 @@ +