From ef11df7f854e66414aba4baa0497de99e0938dcc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Oct 2025 12:59:20 +0000 Subject: [PATCH 01/10] Bump github/codeql-action from 3 to 4 Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3 to 4. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v3...v4) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: '4' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/cd.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 2eb4dd3..d1e5aa9 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -14,7 +14,7 @@ jobs: steps: - uses: actions/checkout@v5 - name: Setup CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: csharp build-mode: none @@ -29,4 +29,4 @@ jobs: - name: Build run: dotnet build --no-restore - name: CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 From 2fa550d7c5268d0cf89265282fec359a120e2fdb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Nov 2025 12:52:18 +0000 Subject: [PATCH 02/10] Bump actions/checkout from 5 to 6 Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/cd.yml | 2 +- .github/workflows/ci.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 2eb4dd3..6011e15 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -12,7 +12,7 @@ jobs: packages: read steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Setup CodeQL uses: github/codeql-action/init@v3 with: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3580fd8..9f18fe1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Setup uses: actions/setup-dotnet@v5 with: From 8ff1fa6b7f6f31940e19e3e11b3fc8423fdfe645 Mon Sep 17 00:00:00 2001 From: Henrik Jensen <1175002+henrikhimself@users.noreply.github.com> Date: Tue, 9 Dec 2025 07:45:38 +0100 Subject: [PATCH 03/10] Add central package management. Add net10.0. Remove net9.0. Upgrade github workflows. Add test coverage report. --- .github/workflows/cd.yml | 2 +- .github/workflows/ci.yml | 2 +- .runsettings | 38 +++++++++++ .vscode/extensions.json | 5 +- .vscode/settings.json | 4 +- .vscode/tasks.json | 30 +++++++++ Directory.Build.props | 13 +--- Directory.Packages.props | 25 +++++++ Invoke-Test.ps1 | 12 ++++ .../Examples.Aspire.AppHost.csproj | 8 +-- .../Examples.Aspire.ReverseProxy.csproj | 2 +- .../Examples.Aspire.ServiceDefaults.csproj | 16 ++--- .../Examples.Aspire.Website.csproj | 2 +- global.json | 2 +- scripts/Update-TestReport.ps1 | 65 +++++++++++++++++++ .../ReverseProxy.Aspire.csproj | 6 +- src/ReverseProxy/ReverseProxy.csproj | 4 +- test/Directory.Build.props | 7 -- .../ReverseProxy.Aspire.UnitTest.csproj | 21 +++--- .../ReverseProxy.UnitTest.csproj | 25 ++++--- 20 files changed, 230 insertions(+), 59 deletions(-) create mode 100644 .runsettings create mode 100644 .vscode/tasks.json create mode 100644 Directory.Packages.props create mode 100755 Invoke-Test.ps1 create mode 100644 scripts/Update-TestReport.ps1 delete mode 100644 test/Directory.Build.props diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 972282b..a889b10 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -23,7 +23,7 @@ jobs: with: dotnet-version: | 8.0.x - 9.0.x + 10.0.x - name: Restore run: dotnet restore - name: Build diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f18fe1..8462f5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: with: dotnet-version: | 8.0.x - 9.0.x + 10.0.x - name: Build run: dotnet build - name: Test diff --git a/.runsettings b/.runsettings new file mode 100644 index 0000000..1e4b471 --- /dev/null +++ b/.runsettings @@ -0,0 +1,38 @@ + + + + 0 + + + + + + cobertura + Obsolete,GeneratedCodeAttribute,CompilerGeneratedAttribute,TestSDKAutoGeneratedCode + [*.UnitTest]*,[*.Example]*Tests + [*]Hj.ReverseProxy* + true + true + true + MissingAll,MissingAny,None + + + + + + denied + false + false + false + 0 + 0 + classAndMethod + none + false + true + true + true + quiet + false + + diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 7c080ae..b874741 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,6 +1,7 @@ { "recommendations": [ - "editorconfig.editorconfig", - "ms-dotnettools.csdevkit" + "ms-vscode.powershell", + "ms-dotnettools.csdevkit", + "editorconfig.editorconfig" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 6d8df2c..6e8019a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,9 +3,11 @@ "editor.minimap.enabled": false, "files.exclude": { "**/bin": true, - "**/obj": true + "**/obj": true, + "TestReport": true }, "git.enableCommitSigning": true, + "dotnet.unitTests.runSettingsPath": ".runsettings", "csharp.debug.symbolOptions.searchNuGetOrgSymbolServer": true, "csharp.debug.symbolOptions.searchMicrosoftSymbolServer": true, "csharp.debug.justMyCode": true, diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..f75f4b4 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,30 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "run tests", + "type": "shell", + "command": "${workspaceFolder}/Invoke-Test.ps1", + "problemMatcher": "$msCompile", + "group": { + "kind": "test", + "isDefault": true + } + }, + { + "label": "view test report", + "command": "", + "type": "process", + "windows": { + "command": "explorer" + }, + "osx": { + "command": "open" + }, + "args": [ + "${workspaceFolder}/TestReport/index.html" + ], + "problemMatcher": [] + } + ] +} diff --git a/Directory.Build.props b/Directory.Build.props index 406c9c5..87bcade 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,22 +1,13 @@ - - + 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.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..7be583d --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,25 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + diff --git a/Invoke-Test.ps1 b/Invoke-Test.ps1 new file mode 100755 index 0000000..2ad6fe7 --- /dev/null +++ b/Invoke-Test.ps1 @@ -0,0 +1,12 @@ +#!/usr/bin/env pwsh +#requires -Version 7 +Set-StrictMode -Version Latest +$InformationPreference = 'Continue' + +<# Global #> +[string]$UpdateTestReportScript = Join-Path $PSScriptRoot 'scripts' 'Update-TestReport.ps1' +[string]$SourceSolutionPath = Join-Path $PSScriptRoot 'ReverseProxy.sln' +[string]$TestReportPath = Join-Path $PSScriptRoot 'TestReport' + +<# Main #> +& $UpdateTestReportScript -TestPath $SourceSolutionPath -ReportDirectoryPath $TestReportPath diff --git a/examples/Aspire.AppHost/Examples.Aspire.AppHost.csproj b/examples/Aspire.AppHost/Examples.Aspire.AppHost.csproj index e2bd918..73b256b 100644 --- a/examples/Aspire.AppHost/Examples.Aspire.AppHost.csproj +++ b/examples/Aspire.AppHost/Examples.Aspire.AppHost.csproj @@ -1,16 +1,16 @@  - + Exe - net9.0 + net10.0 true true - + - + diff --git a/examples/Aspire.ReverseProxy/Examples.Aspire.ReverseProxy.csproj b/examples/Aspire.ReverseProxy/Examples.Aspire.ReverseProxy.csproj index b5f8ae7..adeb11a 100644 --- a/examples/Aspire.ReverseProxy/Examples.Aspire.ReverseProxy.csproj +++ b/examples/Aspire.ReverseProxy/Examples.Aspire.ReverseProxy.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 true true diff --git a/examples/Aspire.ServiceDefaults/Examples.Aspire.ServiceDefaults.csproj b/examples/Aspire.ServiceDefaults/Examples.Aspire.ServiceDefaults.csproj index a3c2d57..de88070 100644 --- a/examples/Aspire.ServiceDefaults/Examples.Aspire.ServiceDefaults.csproj +++ b/examples/Aspire.ServiceDefaults/Examples.Aspire.ServiceDefaults.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 true true @@ -9,13 +9,13 @@ - - - - - - - + + + + + + + diff --git a/examples/Aspire.Website/Examples.Aspire.Website.csproj b/examples/Aspire.Website/Examples.Aspire.Website.csproj index f1ba4cc..2aed457 100644 --- a/examples/Aspire.Website/Examples.Aspire.Website.csproj +++ b/examples/Aspire.Website/Examples.Aspire.Website.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 true true diff --git a/global.json b/global.json index 4220370..55c1a3e 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "9.0.0", + "version": "10.0.0", "rollForward": "major", "allowPrerelease": false } diff --git a/scripts/Update-TestReport.ps1 b/scripts/Update-TestReport.ps1 new file mode 100644 index 0000000..93911de --- /dev/null +++ b/scripts/Update-TestReport.ps1 @@ -0,0 +1,65 @@ +#requires -Version 7 +param ( + [parameter(mandatory = $true)] + [string]$TestPath, + [parameter(mandatory = $true)] + [string]$ReportDirectoryPath +) +Set-StrictMode -Version Latest +$InformationPreference = 'Continue' + +<# Global #> +# Check path of system under test. +if (-Not (Test-Path -Path $TestPath)) { + throw 'Cannot find test path.' +} +[string]$TestDirectoryPath = Split-Path -Path $TestPath -Parent + +# Check path of test coverage settings. +[string]$CoverletRunSettingsPath = Join-Path $TestDirectoryPath '.runsettings' +if (-Not (Test-Path -Path $CoverletRunSettingsPath -PathType leaf)) { + throw 'Cannot find coverlet run settings file.' +} + +<# Main #> +# Set up report results path. +if (-Not (Test-Path -Path $ReportDirectoryPath)) { + $null = New-Item -ItemType 'Directory' -Path $ReportDirectoryPath +} +[string]$ResultsDirectoryPath = Join-Path $ReportDirectoryPath 'Results' +if (-Not (Test-Path -Path $ResultsDirectoryPath)) { + $null = New-Item -ItemType 'Directory' -Path $ResultsDirectoryPath +} +[string]$HistoryDirectoryPath = Join-Path $ReportDirectoryPath 'History' +if (-Not (Test-Path -Path $HistoryDirectoryPath)) { + $null = New-Item -ItemType 'Directory' -Path $HistoryDirectoryPath +} + +# Move previous results to history directory. +Move-Item -Path (Join-Path $ResultsDirectoryPath '*') -Destination $HistoryDirectoryPath + +# Execute unit tests. +$TestArgs = @( + 'test', $TestPath, + '/property:GenerateFullPaths=true', + '/consoleloggerparameters:NoSummary', + "--results-directory:$ResultsDirectoryPath", + "--settings:$CoverletRunSettingsPath" +) +Write-Information "Test args: $($TestArgs | ConvertTo-Json)" +& dotnet $TestArgs +if ($LastExitCode -ne 0) { + exit 1 +} + +# Update test coverage report. +$ReportGeneratorArgs = @( + 'reportgenerator', + "-title:SutFactory", + "-reports:$ResultsDirectoryPath/**/coverage.cobertura.xml", + "-historydir:$HistoryDirectoryPath" + "-targetdir:$ReportDirectoryPath", + '-reporttypes:HtmlInline_AzurePipelines' +) +Write-Information "Report generator args: $($ReportGeneratorArgs | ConvertTo-Json)" +& dotnet $ReportGeneratorArgs diff --git a/src/ReverseProxy.Aspire/ReverseProxy.Aspire.csproj b/src/ReverseProxy.Aspire/ReverseProxy.Aspire.csproj index b074ee3..28fc93f 100644 --- a/src/ReverseProxy.Aspire/ReverseProxy.Aspire.csproj +++ b/src/ReverseProxy.Aspire/ReverseProxy.Aspire.csproj @@ -1,7 +1,7 @@  - net8.0;net9.0 + net8.0;net10.0 Recommended true true @@ -31,8 +31,8 @@ - - + + diff --git a/src/ReverseProxy/ReverseProxy.csproj b/src/ReverseProxy/ReverseProxy.csproj index 615b0c3..b8f12d4 100644 --- a/src/ReverseProxy/ReverseProxy.csproj +++ b/src/ReverseProxy/ReverseProxy.csproj @@ -1,7 +1,7 @@  - net8.0;net9.0 + net8.0;net10.0 Recommended true true @@ -28,7 +28,7 @@ - + diff --git a/test/Directory.Build.props b/test/Directory.Build.props deleted file mode 100644 index a10c705..0000000 --- a/test/Directory.Build.props +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/test/ReverseProxy.Aspire.UnitTest/ReverseProxy.Aspire.UnitTest.csproj b/test/ReverseProxy.Aspire.UnitTest/ReverseProxy.Aspire.UnitTest.csproj index 524a44c..3c0b9e8 100644 --- a/test/ReverseProxy.Aspire.UnitTest/ReverseProxy.Aspire.UnitTest.csproj +++ b/test/ReverseProxy.Aspire.UnitTest/ReverseProxy.Aspire.UnitTest.csproj @@ -1,9 +1,11 @@  - net8.0;net9.0 - true + net8.0;net10.0 + true false + false + $(NoWarn);SA0001 @@ -14,15 +16,18 @@ - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/test/ReverseProxy.UnitTest/ReverseProxy.UnitTest.csproj b/test/ReverseProxy.UnitTest/ReverseProxy.UnitTest.csproj index a2b35b7..7a57662 100644 --- a/test/ReverseProxy.UnitTest/ReverseProxy.UnitTest.csproj +++ b/test/ReverseProxy.UnitTest/ReverseProxy.UnitTest.csproj @@ -1,9 +1,11 @@  - net8.0;net9.0 - true + net8.0;net10.0 + true false + false + $(NoWarn);SA0001 @@ -14,18 +16,25 @@ - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - + all runtime; build; native; contentfiles; analyzers; buildtransitive + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + From 677bb30af1ea4f6ccca3efe66bfcb35404f64a12 Mon Sep 17 00:00:00 2001 From: Henrik Jensen Date: Wed, 29 Oct 2025 08:07:47 +0100 Subject: [PATCH 04/10] Made CaName configurable. Allow overwriting routes and clusters via API. --- .../Certificate/CertificateConfig.cs | 5 ++ .../Certificate/CertificateConstants.cs | 6 +- .../Certificate/CertificateStore.cs | 6 +- .../Certificate/Models/SelfSignedOptions.cs | 2 + src/ReverseProxy/ReverseProxy.csproj | 2 +- .../ReverseProxy/LoggerMiddleware.cs | 2 +- .../ReverseProxy/Models/ClusterInputDto.cs | 2 + .../ReverseProxy/Models/RouteInputDto.cs | 2 + .../ReverseProxy/ReverseProxyApi.cs | 4 +- .../ReverseProxy/ReverseProxyApp.cs | 20 ++++- .../Certificate/CertificateAppTests.cs | 8 +- .../ReverseProxy/ReverseProxyAppTests.cs | 84 ++++++++++++++++++- 12 files changed, 121 insertions(+), 22 deletions(-) diff --git a/src/ReverseProxy/Certificate/CertificateConfig.cs b/src/ReverseProxy/Certificate/CertificateConfig.cs index 4d53d5e..e0aed58 100644 --- a/src/ReverseProxy/Certificate/CertificateConfig.cs +++ b/src/ReverseProxy/Certificate/CertificateConfig.cs @@ -31,6 +31,11 @@ public SelfSignedOptions GetOptions() throw new InvalidOperationException("CA file path is not configured"); } + if (string.IsNullOrWhiteSpace(selfSignedOptions.CaName)) + { + selfSignedOptions.CaName = CertificateConstants.DefaultCaName; + } + if (string.IsNullOrWhiteSpace(selfSignedOptions.AlgorithmOid)) { selfSignedOptions.AlgorithmOid = CertificateConstants.EcdsaOid; diff --git a/src/ReverseProxy/Certificate/CertificateConstants.cs b/src/ReverseProxy/Certificate/CertificateConstants.cs index 263df03..e30d97b 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 = "ReverseProxy-RootCA.crt.pem"; + public const string DefaultCaName = "ReverseProxy-RootCA"; - public const string CaKeyFileName = "ReverseProxy-RootCA.key.pem"; + public const string CaCrtFileExtension = ".crt.pem"; - public const string CaPfxFileName = "ReverseProxy-RootCA.pfx"; + public const string CaKeyFileExtension = ".key.pem"; } diff --git a/src/ReverseProxy/Certificate/CertificateStore.cs b/src/ReverseProxy/Certificate/CertificateStore.cs index d17e9f3..84fc5c0 100644 --- a/src/ReverseProxy/Certificate/CertificateStore.cs +++ b/src/ReverseProxy/Certificate/CertificateStore.cs @@ -56,8 +56,8 @@ public void SaveCa(SelfSignedOptions options, X509Certificate2 ca) private void GetCaFilePaths(SelfSignedOptions options, out string caCrtPemFilePath, out string caKeyPemFilePath, out string caPfxFilePath) { - caCrtPemFilePath = fileStore.CombinePath(options.CaFilePath, CertificateConstants.CaCrtFileName); - caKeyPemFilePath = fileStore.CombinePath(options.CaFilePath, CertificateConstants.CaKeyFileName); - caPfxFilePath = fileStore.CombinePath(options.CaFilePath, CertificateConstants.CaPfxFileName); + caCrtPemFilePath = fileStore.CombinePath(options.CaFilePath, options.CaName + ".crt.pem"); + caKeyPemFilePath = fileStore.CombinePath(options.CaFilePath, options.CaName + ".key.pem"); + caPfxFilePath = fileStore.CombinePath(options.CaFilePath, options.CaName + ".pfx"); } } diff --git a/src/ReverseProxy/Certificate/Models/SelfSignedOptions.cs b/src/ReverseProxy/Certificate/Models/SelfSignedOptions.cs index 5fec642..2eb7f79 100644 --- a/src/ReverseProxy/Certificate/Models/SelfSignedOptions.cs +++ b/src/ReverseProxy/Certificate/Models/SelfSignedOptions.cs @@ -18,6 +18,8 @@ namespace Hj.ReverseProxy.Certificate.Models; internal sealed class SelfSignedOptions { + public required string CaName { get; set; } + public required string CaFilePath { get; set; } public required string AlgorithmOid { get; set; } diff --git a/src/ReverseProxy/ReverseProxy.csproj b/src/ReverseProxy/ReverseProxy.csproj index b8f12d4..cb18a8e 100644 --- a/src/ReverseProxy/ReverseProxy.csproj +++ b/src/ReverseProxy/ReverseProxy.csproj @@ -13,7 +13,7 @@ testing;reverse-proxy;certificates README.md LICENSE - 1.0.0-beta4 + 1.0.0-beta5 diff --git a/src/ReverseProxy/ReverseProxy/LoggerMiddleware.cs b/src/ReverseProxy/ReverseProxy/LoggerMiddleware.cs index 58874c9..670e8fe 100644 --- a/src/ReverseProxy/ReverseProxy/LoggerMiddleware.cs +++ b/src/ReverseProxy/ReverseProxy/LoggerMiddleware.cs @@ -28,7 +28,7 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next) if (string.Equals(ReverseProxyConstants.BlackholeId, route.RouteId, StringComparison.OrdinalIgnoreCase)) { - logger.LogDebug("Route: '{Url}', unknown route", context.Request.GetDisplayUrl()); + logger.LogDebug("Blackhole route: '{Url}', unknown route", context.Request.GetDisplayUrl()); context.Response.StatusCode = (int)HttpStatusCode.NotFound; await context.Response.CompleteAsync(); return; diff --git a/src/ReverseProxy/ReverseProxy/Models/ClusterInputDto.cs b/src/ReverseProxy/ReverseProxy/Models/ClusterInputDto.cs index 4d62d83..be0de43 100644 --- a/src/ReverseProxy/ReverseProxy/Models/ClusterInputDto.cs +++ b/src/ReverseProxy/ReverseProxy/Models/ClusterInputDto.cs @@ -21,4 +21,6 @@ namespace Hj.ReverseProxy.ReverseProxy.Models; internal sealed class ClusterInputDto { public List? Clusters { get; set; } + + public bool AllowOverwrite { get; set; } } diff --git a/src/ReverseProxy/ReverseProxy/Models/RouteInputDto.cs b/src/ReverseProxy/ReverseProxy/Models/RouteInputDto.cs index e24ea0a..08dfc53 100644 --- a/src/ReverseProxy/ReverseProxy/Models/RouteInputDto.cs +++ b/src/ReverseProxy/ReverseProxy/Models/RouteInputDto.cs @@ -21,4 +21,6 @@ namespace Hj.ReverseProxy.ReverseProxy.Models; internal sealed class RouteInputDto { public List? Routes { get; set; } + + public bool AllowOverwrite { get; set; } } diff --git a/src/ReverseProxy/ReverseProxy/ReverseProxyApi.cs b/src/ReverseProxy/ReverseProxy/ReverseProxyApi.cs index 8977f38..b1e498e 100644 --- a/src/ReverseProxy/ReverseProxy/ReverseProxyApi.cs +++ b/src/ReverseProxy/ReverseProxy/ReverseProxyApi.cs @@ -57,7 +57,7 @@ public static async ValueTask PostRouteAsync([FromServices] ReverseProx { foreach (var routeConfig in routeInput.Routes) { - await reverseProxyApp.AddRouteAsync(routeConfig); + await reverseProxyApp.AddRouteAsync(routeConfig, routeInput.AllowOverwrite); } } @@ -72,7 +72,7 @@ public static async ValueTask PostClusterAsync([FromServices] ReversePr { foreach (var clusterConfig in clusterInput.Clusters) { - await reverseProxyApp.AddClusterAsync(clusterConfig); + await reverseProxyApp.AddClusterAsync(clusterConfig, clusterInput.AllowOverwrite); } } diff --git a/src/ReverseProxy/ReverseProxy/ReverseProxyApp.cs b/src/ReverseProxy/ReverseProxy/ReverseProxyApp.cs index 31bd382..4e95576 100644 --- a/src/ReverseProxy/ReverseProxy/ReverseProxyApp.cs +++ b/src/ReverseProxy/ReverseProxy/ReverseProxyApp.cs @@ -54,7 +54,7 @@ public void AddBlackholeCatchAll() Update(); } - public async ValueTask AddRouteAsync(RouteConfig route) + public async ValueTask AddRouteAsync(RouteConfig route, bool allowOverwrite) { var validationErrors = await configValidator.ValidateRouteAsync(route); if (validationErrors.Count > 0) @@ -62,16 +62,22 @@ public async ValueTask AddRouteAsync(RouteConfig route) throw new AggregateException("Could not add route.", validationErrors); } - if (proxyConfigProviders.Any(x => x.GetConfig().Routes.Any(y => y.RouteId == route.RouteId))) + var hasExisting = proxyConfigProviders.Any(x => x.GetConfig().Routes.Any(y => y.RouteId == route.RouteId)); + if (hasExisting && !allowOverwrite) { throw new InvalidOperationException($"Route with id '{route.RouteId}' already exists."); } + if (hasExisting) + { + _routes.RemoveAll(x => x.RouteId == route.RouteId); + } + _routes.Add(route); Update(); } - public async ValueTask AddClusterAsync(ClusterConfig cluster) + public async ValueTask AddClusterAsync(ClusterConfig cluster, bool allowOverwrite) { var validationErrors = await configValidator.ValidateClusterAsync(cluster); if (validationErrors.Count > 0) @@ -79,11 +85,17 @@ public async ValueTask AddClusterAsync(ClusterConfig cluster) throw new AggregateException("Could not add cluser.", validationErrors); } - if (proxyConfigProviders.Any(x => x.GetConfig().Clusters.Any(y => y.ClusterId == cluster.ClusterId))) + var hasExisting = proxyConfigProviders.Any(x => x.GetConfig().Clusters.Any(y => y.ClusterId == cluster.ClusterId)); + if (hasExisting && !allowOverwrite) { throw new InvalidOperationException($"Cluster with id '{cluster.ClusterId}' already exists."); } + if (hasExisting) + { + _clusters.RemoveAll(x => x.ClusterId == cluster.ClusterId); + } + _clusters.Add(cluster); Update(); } diff --git a/test/ReverseProxy.UnitTest/Certificate/CertificateAppTests.cs b/test/ReverseProxy.UnitTest/Certificate/CertificateAppTests.cs index 7b50b78..bd2659b 100644 --- a/test/ReverseProxy.UnitTest/Certificate/CertificateAppTests.cs +++ b/test/ReverseProxy.UnitTest/Certificate/CertificateAppTests.cs @@ -64,8 +64,8 @@ public void GetCertificate_GivenDnsName_ReturnsCertificate(string algorithmOid, SetHappyPath(arrange); fileStore = arrange.Instance(); - fileStore.ReadAllText(Arg.Is(x => Path.GetFileName(x) == CertificateConstants.CaCrtFileName)).Returns(caCrtPem); - fileStore.ReadAllText(Arg.Is(x => Path.GetFileName(x) == CertificateConstants.CaKeyFileName)).Returns(caKeyPem); + fileStore.ReadAllText(Arg.Is(x => Path.GetFileName(x) == CertificateConstants.DefaultCaName + CertificateConstants.CaCrtFileExtension)).Returns(caCrtPem); + fileStore.ReadAllText(Arg.Is(x => Path.GetFileName(x) == CertificateConstants.DefaultCaName + CertificateConstants.CaKeyFileExtension)).Returns(caKeyPem); }); // act @@ -156,7 +156,7 @@ private static void SetHappyPath(InputBuilder arrange) fileStore.GetFullPath(Arg.Any()).Returns(args => args.ArgAt(0)); fileStore.FileExists(Arg.Any()).Returns(true); fileStore.DirectoryExists(Arg.Any()).Returns(true); - fileStore.ReadAllText(Arg.Is(x => Path.GetFileName(x) == CertificateConstants.CaCrtFileName)).Returns(TestRsaCaCrt); - fileStore.ReadAllText(Arg.Is(x => Path.GetFileName(x) == CertificateConstants.CaKeyFileName)).Returns(TestRsaCaKey); + fileStore.ReadAllText(Arg.Is(x => Path.GetFileName(x) == CertificateConstants.DefaultCaName + CertificateConstants.CaCrtFileExtension)).Returns(TestRsaCaCrt); + fileStore.ReadAllText(Arg.Is(x => Path.GetFileName(x) == CertificateConstants.DefaultCaName + CertificateConstants.CaKeyFileExtension)).Returns(TestRsaCaKey); } } diff --git a/test/ReverseProxy.UnitTest/ReverseProxy/ReverseProxyAppTests.cs b/test/ReverseProxy.UnitTest/ReverseProxy/ReverseProxyAppTests.cs index 79d4159..7e3c107 100644 --- a/test/ReverseProxy.UnitTest/ReverseProxy/ReverseProxyAppTests.cs +++ b/test/ReverseProxy.UnitTest/ReverseProxy/ReverseProxyAppTests.cs @@ -46,7 +46,7 @@ public async Task AddRouteAsync_GivenValidationError_ThrowsAsync() RouteConfig routeConfig = new(); // act & assert - await Assert.ThrowsAnyAsync(async () => await sut.AddRouteAsync(routeConfig)); + await Assert.ThrowsAnyAsync(async () => await sut.AddRouteAsync(routeConfig, false)); } [Fact] @@ -63,7 +63,43 @@ public async Task AddRouteAsync_GivenExisting_ThrowsAsync() }); // act & assert - await Assert.ThrowsAnyAsync(async () => await sut.AddRouteAsync(routeConfig)); + await Assert.ThrowsAnyAsync(async () => await sut.AddRouteAsync(routeConfig, false)); + } + + + + [Fact] + public async Task AddRouteAsync_GivenExistingAndAllowOverwrite_UpdatesAsync() + { + // arrange + var routeId = Guid.NewGuid().ToString(); + + var sut = SystemUnderTest.For(arrange => + { + SetHappyPath(arrange); + + RouteConfig oldRouteConfig = new() + { + RouteId = routeId, + ClusterId = "oldCluster", + }; + + arrange.Instance() + .Update([oldRouteConfig], []); + }); + + RouteConfig newRouteConfig = new() + { + RouteId = routeId, + ClusterId = "newCluster", + }; + + // act + await sut.AddRouteAsync(newRouteConfig, true); + + // assert + var routeConfig = sut.GetRouteConfigs().Single(x => x.RouteId == routeId); + Assert.Equal("newCluster", routeConfig.ClusterId); } [Fact] @@ -82,7 +118,7 @@ public async Task AddClusterAsync_GivenValidationError_ThrowsAsync() ClusterConfig clusterConfig = new(); // act & assert - await Assert.ThrowsAnyAsync(async () => await sut.AddClusterAsync(clusterConfig)); + await Assert.ThrowsAnyAsync(async () => await sut.AddClusterAsync(clusterConfig, false)); } [Fact] @@ -99,7 +135,47 @@ public async Task AddClusterAsync_GivenExisting_ThrowsAsync() }); // act & assert - await Assert.ThrowsAnyAsync(async () => await sut.AddClusterAsync(clusterConfig)); + await Assert.ThrowsAnyAsync(async () => await sut.AddClusterAsync(clusterConfig, false)); + } + + [Fact] + public async Task AddClusterAsync_GivenExistingAndAllowOverwrite_UpdatesAsync() + { + // arrange + var clusterId = Guid.NewGuid().ToString(); + + var sut = SystemUnderTest.For(arrange => + { + SetHappyPath(arrange); + + ClusterConfig oldClusterConfig = new() + { + ClusterId = clusterId, + Destinations = new Dictionary() + { + { "destination1", new() { Address = "https://example.com/old", } }, + }, + }; + + arrange.Instance() + .Update([], [oldClusterConfig]); + }); + + ClusterConfig newClusterConfig = new() + { + ClusterId = clusterId, + Destinations = new Dictionary() + { + { "destination1", new() { Address = "https://example.com/new", } }, + }, + }; + + // act + await sut.AddClusterAsync(newClusterConfig, true); + + // assert + var clusterConfig = sut.GetClusterConfigs().Single(x => x.ClusterId == clusterId); + Assert.Equal("https://example.com/new", clusterConfig.Destinations?["destination1"].Address); } private static void SetHappyPath(InputBuilder inputBuilder) From 3c213ae570320bda2cbcdc478fa2e724156d24f1 Mon Sep 17 00:00:00 2001 From: Henrik Jensen <1175002+henrikhimself@users.noreply.github.com> Date: Sun, 28 Dec 2025 01:32:50 +0100 Subject: [PATCH 05/10] Bump HenrikJensen.SutFactory --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 7be583d..5172738 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,7 +13,7 @@ - + From 582a4145dfa4550e4efd9ddf14ef7a7f600ea0ff Mon Sep 17 00:00:00 2001 From: Henrik Jensen <1175002+henrikhimself@users.noreply.github.com> Date: Wed, 31 Dec 2025 11:24:49 +0100 Subject: [PATCH 06/10] Add IsPackable where missing. Add NSubstitute. --- .github/copilot-instructions.md | 127 ++++++++++++++++++ Directory.Packages.props | 1 + .../Examples.Aspire.AppHost.csproj | 1 + .../Examples.Aspire.ReverseProxy.csproj | 1 + .../Examples.Aspire.ServiceDefaults.csproj | 1 + .../Examples.Aspire.Website.csproj | 1 + .../ReverseProxy.Aspire.UnitTest.csproj | 1 + .../ReverseProxy.UnitTest.csproj | 1 + 8 files changed, 134 insertions(+) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..55f2164 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,127 @@ +# Copilot Instructions for DotNet-ReverseProxy + +## Project Overview + +**HenrikJensen.ReverseProxy** is a .NET library that combines Microsoft YARP (Yet Another Reverse Proxy) with runtime configuration APIs and automatic self-signed certificate generation. It's designed to simplify development and testing of reverse proxy scenarios. + +### Key Components + +1. **ReverseProxy Core** ([src/ReverseProxy/](../src/ReverseProxy/)) + - **ReverseProxyApp**: Manages routes and clusters using YARP's configuration system + - **Certificate Management**: Automatic generation of self-signed certificates with caching with trust added by a local CA + - **Runtime API**: Web API endpoints for adding/managing routes and clusters dynamically + +2. **ReverseProxy.Aspire** ([src/ReverseProxy.Aspire/](../src/ReverseProxy.Aspire/)) + - Integrates with .NET Aspire orchestration platform for route/cluster configuration for services without requiring API calls + - `WithReverseProxyReference()`: Extension for configuring Aspire resources + - `ServiceDiscoveryStartupFilter`: Auto-discovers and configures annotated Aspire resources + +3. **Examples** ([examples/](../examples/)) + - AppHost: Aspire app host demonstrating the reverse proxy in action + - ReverseProxy: Example reverse proxy service using the library + - Website: Example backend service discoverable by the proxy + +## Architecture Patterns + +### Configuration Layering +- **InMemoryConfigProvider**: Holds runtime-added routes/clusters +- **IProxyConfigProvider**: Base abstraction; implementations are merged in ReverseProxyApp +- Multiple providers can coexist; routes/clusters are combined across all + +### Dependency Injection Pattern +Services use constructor injection with sealed internal classes: +```csharp +internal sealed class ReverseProxyApp( + IConfigValidator configValidator, + InMemoryConfigProvider inMemoryConfigProvider, + IEnumerable proxyConfigProviders) +``` + +Use the extension methods in [ReverseProxyExtensions.cs](../src/ReverseProxy/ReverseProxyExtensions.cs) to configure services: +- `ConfigureReverseProxy()`: Registers core services +- `UseSelfSignedCertificate()`: Enables automatic certificate generation on Kestrel + +### Certificate Management +- **Lazy Generation**: Certificates are created on-demand via ServerCertificateSelector +- **Caching**: IMemoryCache prevents redundant certificate generation +- **Wildcard Support**: Handles both `*.example.com` and specific domain names +- **Self-Signed CA**: Generated once and installed into the trusted root store on the platform; certificates derive trust from it + +## Testing Strategy + +### Test Framework & Patterns +- **xUnit** with **NSubstitute** for mocking +- **SutFactory** (HenrikJensen.SutFactory): Custom builder pattern for automatic arrangement of the dependency graph while creating the System Under Test instance + - Reduces boilerplate; use `SutBuilder` → `InputBuilder` → `Instance()` or if no spy instances are inspected, the preferred pattern `SystemUnderTest.For(arrange => { })` where 'arrange' is an InputBuilder. +- **Arrange-Act-Assert**: Standard pattern with setup helpers (see `SetHappyPath(InputBuilder arrange)` in tests) + +### Key Test Files +- [ReverseProxy.UnitTest/ReverseProxy/ReverseProxyApiTests.cs](../test/ReverseProxy.UnitTest/ReverseProxy/ReverseProxyApiTests.cs): Tests the API endpoints +- [ReverseProxy.UnitTest/ReverseProxy/ReverseProxyAppTests.cs](../test/ReverseProxy.UnitTest/ReverseProxy/ReverseProxyAppTests.cs): Tests configuration management + +### Running Tests +Prefer to use the cli command "dotnet test" from the solution root. Ignore the Invoke-Test.ps1 PowerShell script as its purpose is to generate a human readable test report in HTML format that shows test coverage. + +## Build & Development + +### Multi-Targeting +Projects target both `net8.0` and `net10.0`. Compatibility with Windows, macOS and Linux must be ensured when adding and changing code. + +### Code Analysis & Style +- **StyleCop.Analyzers**: Enforces XML documentation headers on public members +- **EnforceCodeStyleInBuild**: Code style violations fail the build +- Configuration: [stylecop.json](../stylecop.json) requires `xmlHeader: true` for packable projects + +### Package Management +Central package versioning via [Directory.Packages.props](../Directory.Packages.props): +- Add new dependencies by adding `` entries there +- Projects reference by `` (version auto-inherited) + +### Global Usings +Common namespaces are globally imported via [Directory.Build.props](../Directory.Build.props) and project `.csproj` files: +```csharp + + + +``` + +## Integration Points + +### YARP Integration +- Uses `Yarp.ReverseProxy` NuGet package (v2.3.0) +- Core types: `RouteConfig`, `ClusterConfig`, `DestinationConfig` +- Validation via `IConfigValidator` + +### Aspire Integration +- Targets `Aspire.Hosting.AppHost` (v9.5.1) +- DI service registration via `IServiceCollection`. +- Sstartup filters for configuring reverse proxy routes/clusters using injected service discovery environment variables. +- Resource builders follow Aspire conventions + +### Extension Points +- **IProxyConfigProvider**: Implement to add custom configuration sources +- **ICertificateConfig**: Customize certificate generation options (algorithm, subject name) +- **IFileStore**: Abstract file storage for certificates + +## Common Tasks + +### Adding a New Route at Runtime +1. Create `RouteConfig` with required properties (`RouteId`, `ClusterId`, `Match`) +2. Call `ReverseProxyApp.AddRouteAsync(route, allowOverwrite: true)` +3. Validation errors are wrapped in `AggregateException` + +### Adding Certificate Generation Strategy +1. Implement `ICertificateConfig` (provides values for key algorithm, ca path and name, subject name) +2. Register in DI via `ConfigureReverseProxy()` extension +3. Caching is automatic; no changes needed to `CertificateApp` + +### Publishing Changes +- Package ID: `HenrikJensen.ReverseProxy` +- Use semantic versioning (current: 1.0.0-beta5) +- Update version in [src/ReverseProxy/ReverseProxy.csproj](../src/ReverseProxy/ReverseProxy.csproj#L14) +- Ensure all tests pass before publishing +- Ensure all code is compatible with Windows, macOS and Linux + +## References +- **Solution**: [ReverseProxy.sln](../ReverseProxy.sln) (projects + examples + tests) +- **Global Configuration**: [global.json](../global.json) (SDK version pinning) diff --git a/Directory.Packages.props b/Directory.Packages.props index 5172738..91ff71b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,6 +14,7 @@ + diff --git a/examples/Aspire.AppHost/Examples.Aspire.AppHost.csproj b/examples/Aspire.AppHost/Examples.Aspire.AppHost.csproj index 73b256b..5c9009b 100644 --- a/examples/Aspire.AppHost/Examples.Aspire.AppHost.csproj +++ b/examples/Aspire.AppHost/Examples.Aspire.AppHost.csproj @@ -6,6 +6,7 @@ Exe net10.0 true + false true diff --git a/examples/Aspire.ReverseProxy/Examples.Aspire.ReverseProxy.csproj b/examples/Aspire.ReverseProxy/Examples.Aspire.ReverseProxy.csproj index adeb11a..2da3c39 100644 --- a/examples/Aspire.ReverseProxy/Examples.Aspire.ReverseProxy.csproj +++ b/examples/Aspire.ReverseProxy/Examples.Aspire.ReverseProxy.csproj @@ -2,6 +2,7 @@ net10.0 + false true true diff --git a/examples/Aspire.ServiceDefaults/Examples.Aspire.ServiceDefaults.csproj b/examples/Aspire.ServiceDefaults/Examples.Aspire.ServiceDefaults.csproj index de88070..72b4add 100644 --- a/examples/Aspire.ServiceDefaults/Examples.Aspire.ServiceDefaults.csproj +++ b/examples/Aspire.ServiceDefaults/Examples.Aspire.ServiceDefaults.csproj @@ -3,6 +3,7 @@ net10.0 true + false true diff --git a/examples/Aspire.Website/Examples.Aspire.Website.csproj b/examples/Aspire.Website/Examples.Aspire.Website.csproj index 2aed457..2faef77 100644 --- a/examples/Aspire.Website/Examples.Aspire.Website.csproj +++ b/examples/Aspire.Website/Examples.Aspire.Website.csproj @@ -2,6 +2,7 @@ net10.0 + false true true diff --git a/test/ReverseProxy.Aspire.UnitTest/ReverseProxy.Aspire.UnitTest.csproj b/test/ReverseProxy.Aspire.UnitTest/ReverseProxy.Aspire.UnitTest.csproj index 3c0b9e8..aa27b33 100644 --- a/test/ReverseProxy.Aspire.UnitTest/ReverseProxy.Aspire.UnitTest.csproj +++ b/test/ReverseProxy.Aspire.UnitTest/ReverseProxy.Aspire.UnitTest.csproj @@ -17,6 +17,7 @@ + diff --git a/test/ReverseProxy.UnitTest/ReverseProxy.UnitTest.csproj b/test/ReverseProxy.UnitTest/ReverseProxy.UnitTest.csproj index 7a57662..44179e4 100644 --- a/test/ReverseProxy.UnitTest/ReverseProxy.UnitTest.csproj +++ b/test/ReverseProxy.UnitTest/ReverseProxy.UnitTest.csproj @@ -17,6 +17,7 @@ + From 56cf56cd8f6d358248133742889d6af7bb8520c0 Mon Sep 17 00:00:00 2001 From: Henrik Jensen <1175002+henrikhimself@users.noreply.github.com> Date: Wed, 31 Dec 2025 12:28:45 +0100 Subject: [PATCH 07/10] Implement CA certificate management with platform-specific strategies for macOS and .NET 8. Add logging enhancements and update test report creation. --- .config/dotnet-tools.json | 12 +++ .gitignore | 1 + .runsettings | 2 +- .vscode/settings.json | 3 +- Directory.Packages.props | 1 + scripts/Update-TestReport.ps1 | 10 +- .../Certificate/CertificateApp.cs | 8 +- .../Certificate/CertificateFactory.cs | 48 ++++++---- .../Certificate/CertificateStore.cs | 34 +++++-- src/ReverseProxy/Certificate/ICaLoader.cs | 31 ++++++ .../Certificate/ICertificateCreator.cs | 57 +++++++++++ .../Certificate/Strategy/CertificateEcdsa.cs | 5 + .../Certificate/Strategy/CertificateRsa.cs | 5 + .../Certificate/Strategy/DefaultCaLoader.cs | 29 ++++++ .../Strategy/DefaultCertificateCreator.cs | 61 ++++++++++++ .../Strategy/ICertificateStrategy.cs | 4 + .../Certificate/Strategy/MacOSNet8CaLoader.cs | 64 +++++++++++++ .../Strategy/MacOSNet8CertificateCreator.cs | 96 +++++++++++++++++++ src/ReverseProxy/ReverseProxy.csproj | 4 + .../ReverseProxy/LoggerMiddleware.cs | 6 +- src/ReverseProxy/ReverseProxyExtensions.cs | 18 ++++ .../Certificate/CertificateAppTests.cs | 16 ++++ .../ReverseProxy/ReverseProxyAppTests.cs | 2 - 23 files changed, 477 insertions(+), 40 deletions(-) create mode 100644 .config/dotnet-tools.json create mode 100644 src/ReverseProxy/Certificate/ICaLoader.cs create mode 100644 src/ReverseProxy/Certificate/ICertificateCreator.cs create mode 100644 src/ReverseProxy/Certificate/Strategy/DefaultCaLoader.cs create mode 100644 src/ReverseProxy/Certificate/Strategy/DefaultCertificateCreator.cs create mode 100644 src/ReverseProxy/Certificate/Strategy/MacOSNet8CaLoader.cs create mode 100644 src/ReverseProxy/Certificate/Strategy/MacOSNet8CertificateCreator.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..113d9ea --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-reportgenerator-globaltool": { + "version": "4.8.13", + "commands": [ + "reportgenerator" + ] + } + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index d30827d..ab987c6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .attic +TestReport ## Visual Studio # User-specific files diff --git a/.runsettings b/.runsettings index 1e4b471..4a23d9f 100644 --- a/.runsettings +++ b/.runsettings @@ -11,7 +11,7 @@ Obsolete,GeneratedCodeAttribute,CompilerGeneratedAttribute,TestSDKAutoGeneratedCode [*.UnitTest]*,[*.Example]*Tests [*]Hj.ReverseProxy* - true + false true true MissingAll,MissingAny,None diff --git a/.vscode/settings.json b/.vscode/settings.json index 6e8019a..6e7ec96 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,8 +3,7 @@ "editor.minimap.enabled": false, "files.exclude": { "**/bin": true, - "**/obj": true, - "TestReport": true + "**/obj": true }, "git.enableCommitSigning": true, "dotnet.unitTests.runSettingsPath": ".runsettings", diff --git a/Directory.Packages.props b/Directory.Packages.props index 91ff71b..f92af0a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,6 +5,7 @@ + diff --git a/scripts/Update-TestReport.ps1 b/scripts/Update-TestReport.ps1 index 93911de..0e8c777 100644 --- a/scripts/Update-TestReport.ps1 +++ b/scripts/Update-TestReport.ps1 @@ -14,6 +14,7 @@ if (-Not (Test-Path -Path $TestPath)) { throw 'Cannot find test path.' } [string]$TestDirectoryPath = Split-Path -Path $TestPath -Parent +[string]$SourceDirectoryPath = Join-Path $TestDirectoryPath 'src' # Check path of test coverage settings. [string]$CoverletRunSettingsPath = Join-Path $TestDirectoryPath '.runsettings' @@ -30,13 +31,6 @@ if (-Not (Test-Path -Path $ReportDirectoryPath)) { if (-Not (Test-Path -Path $ResultsDirectoryPath)) { $null = New-Item -ItemType 'Directory' -Path $ResultsDirectoryPath } -[string]$HistoryDirectoryPath = Join-Path $ReportDirectoryPath 'History' -if (-Not (Test-Path -Path $HistoryDirectoryPath)) { - $null = New-Item -ItemType 'Directory' -Path $HistoryDirectoryPath -} - -# Move previous results to history directory. -Move-Item -Path (Join-Path $ResultsDirectoryPath '*') -Destination $HistoryDirectoryPath # Execute unit tests. $TestArgs = @( @@ -57,8 +51,8 @@ $ReportGeneratorArgs = @( 'reportgenerator', "-title:SutFactory", "-reports:$ResultsDirectoryPath/**/coverage.cobertura.xml", - "-historydir:$HistoryDirectoryPath" "-targetdir:$ReportDirectoryPath", + "-sourcedirs:$SourceDirectoryPath", '-reporttypes:HtmlInline_AzurePipelines' ) Write-Information "Report generator args: $($ReportGeneratorArgs | ConvertTo-Json)" diff --git a/src/ReverseProxy/Certificate/CertificateApp.cs b/src/ReverseProxy/Certificate/CertificateApp.cs index ca3e831..3cc2951 100644 --- a/src/ReverseProxy/Certificate/CertificateApp.cs +++ b/src/ReverseProxy/Certificate/CertificateApp.cs @@ -40,7 +40,11 @@ public X509Certificate2 GetCertificate(string dnsName) ? dnsName[2..] : dnsName; - logger.LogInformation("Missing certificate, dns name '{DnsName}', is wildcard '{IsWildcard}'", dnsName, isWildcard); + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation("Missing certificate, dns name '{DnsName}', is wildcard '{IsWildcard}'", dnsName, isWildcard); + } + return certificateFactory.CreateCertificate(key, ca, $"CN={cn}", san => { san.AddIpAddress(IPAddress.Loopback); @@ -63,7 +67,7 @@ private X509Certificate2 GetOrCreateCa(SelfSignedOptions selfSignedOptions) { using var key = certificateFactory.CreateKey(selfSignedOptions.AlgorithmOid); ca = certificateFactory.CreateCa(key, selfSignedOptions.SubjectName); - certificateStore.SaveCa(selfSignedOptions, ca); + certificateStore.SaveCa(selfSignedOptions, ca, key); } return ca; diff --git a/src/ReverseProxy/Certificate/CertificateFactory.cs b/src/ReverseProxy/Certificate/CertificateFactory.cs index fd94363..552a282 100644 --- a/src/ReverseProxy/Certificate/CertificateFactory.cs +++ b/src/ReverseProxy/Certificate/CertificateFactory.cs @@ -20,7 +20,8 @@ namespace Hj.ReverseProxy.Certificate; internal sealed class CertificateFactory( ILogger logger, - IEnumerable strategies) + IEnumerable strategies, + ICertificateCreator certificateCreator) { public X509Certificate2 CreateCa(AsymmetricAlgorithm key, string subjectName) { @@ -35,8 +36,13 @@ public X509Certificate2 CreateCa(AsymmetricAlgorithm key, string subjectName) var validFrom = utcNow.AddDays(-1); var validTo = utcNow.AddYears(10); - var ca = request.CreateSelfSigned(validFrom, validTo); - logger.LogInformation("Creating CA, subject '{SubjectName}', valid from '{ValidFrom}', valid to '{ValidTo}', thumbprint '{Thumbprint}', serial '{SerialNumber}'", subjectName, validFrom, validTo, ca.Thumbprint, ca.SerialNumber); + var ca = certificateCreator.CreateSelfSignedCa(key, request, validFrom, validTo); + + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation("Creating CA, subject '{SubjectName}', valid from '{ValidFrom}', valid to '{ValidTo}', thumbprint '{Thumbprint}', serial '{SerialNumber}'", subjectName, validFrom, validTo, ca.Thumbprint, ca.SerialNumber); + } + return ca; } @@ -63,21 +69,28 @@ public X509Certificate2 CreateCertificate(AsymmetricAlgorithm key, X509Certifica var serialNumber = new byte[16]; RandomNumberGenerator.Fill(serialNumber); - logger.LogInformation("Creating certificate, subject '{SubjectName}', CA thumbprint '{Thumbprint}', CA serial '{SerialNumber}'", subjectName, ca.Thumbprint, ca.SerialNumber); - var certificate = request.Create( - ca.IssuerName, - caSignatureGenerator, - validFrom, - validTo, - serialNumber); + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation("Creating certificate, subject '{SubjectName}', CA thumbprint '{Thumbprint}', CA serial '{SerialNumber}'", subjectName, ca.Thumbprint, ca.SerialNumber); + } + + var pfxBytes = certificateCreator.CreateSignedCertificate( + key, + request, + ca.IssuerName, + caSignatureGenerator, + validFrom, + validTo, + serialNumber); + + // EphemeralKeySet is not supported on macOS (requires keychain which writes to disk) + // but is supported on Windows, Linux, iOS/tvOS/MacCatalyst, and Android + var keyStorageFlags = OperatingSystem.IsMacOS() + ? X509KeyStorageFlags.Exportable + : X509KeyStorageFlags.Exportable | X509KeyStorageFlags.EphemeralKeySet; - var certificateWithKey = strategy.CopyWithPrivateKey(certificate, key); - - var pfxBytes = certificateWithKey.Export(X509ContentType.Pkcs12); #pragma warning disable SYSLIB0057 // Type or member is obsolete - var pfx = Environment.OSVersion.Platform == PlatformID.Win32NT - ? new X509Certificate2(pfxBytes, (string?)null, X509KeyStorageFlags.Exportable) - : new X509Certificate2(pfxBytes, (string?)null, X509KeyStorageFlags.Exportable | X509KeyStorageFlags.EphemeralKeySet); + var pfx = new X509Certificate2(pfxBytes, (string?)null, keyStorageFlags); #pragma warning restore SYSLIB0057 // Type or member is obsolete return pfx; } @@ -87,6 +100,9 @@ public X509Certificate2 CreateCertificate(AsymmetricAlgorithm key, X509Certifica public string ExportPrivateKeyPem(X509Certificate2 certificate) => GetStrategy(certificate).ExportPrivateKeyPem(certificate); + public string ExportPrivateKeyPem(AsymmetricAlgorithm key) + => GetStrategy(key).ExportPrivateKeyPem(key); + private ICertificateStrategy GetStrategy(X509Certificate2 certificate) => GetStrategy(certificate.GetKeyAlgorithm()); private ICertificateStrategy GetStrategy(string publicKeyAlgOid) diff --git a/src/ReverseProxy/Certificate/CertificateStore.cs b/src/ReverseProxy/Certificate/CertificateStore.cs index 84fc5c0..de4ee40 100644 --- a/src/ReverseProxy/Certificate/CertificateStore.cs +++ b/src/ReverseProxy/Certificate/CertificateStore.cs @@ -22,7 +22,8 @@ namespace Hj.ReverseProxy.Certificate; internal sealed class CertificateStore( ILogger logger, IFileStore fileStore, - CertificateFactory certificateFactory) + CertificateFactory certificateFactory, + ICaLoader caLoader) { public X509Certificate2? LoadCa(SelfSignedOptions options) { @@ -30,25 +31,42 @@ internal sealed class CertificateStore( if (!fileStore.FileExists(caCrtPemFilePath) || !fileStore.FileExists(caKeyPemFilePath)) { - logger.LogInformation("Missing CA, cert path '{CertPemPath}', key path '{CaKeyPath}'", caCrtPemFilePath, caKeyPemFilePath); + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation("Missing CA, cert path '{CertPemPath}', key path '{CaKeyPath}'", caCrtPemFilePath, caKeyPemFilePath); + } + return null; } - logger.LogInformation("Loading CA, cert path '{CertPemPath}', key path '{CaKeyPath}'", caCrtPemFilePath, caKeyPemFilePath); + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation("Loading CA, cert path '{CertPemPath}', key path '{CaKeyPath}'", caCrtPemFilePath, caKeyPemFilePath); + } var certContents = fileStore.ReadAllText(caCrtPemFilePath); var keyContents = fileStore.ReadAllText(caKeyPemFilePath); - var ca = X509Certificate2.CreateFromPem(certContents, keyContents); - return ca; + + return caLoader.LoadFromPem(certContents, keyContents); } - public void SaveCa(SelfSignedOptions options, X509Certificate2 ca) + public void SaveCa(SelfSignedOptions options, X509Certificate2 ca, AsymmetricAlgorithm? key = null) { GetCaFilePaths(options, out var caCrtPemFilePath, out var caKeyPemFilePath, out var caPfxFilePath); - logger.LogInformation("Saving CA, cert path '{CertPemPath}', key path '{CaKeyPemPath}', pfx path '{CaPfxPath}'", caCrtPemFilePath, caKeyPemFilePath, caPfxFilePath); + + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation("Saving CA, cert path '{CertPemPath}', key path '{CaKeyPemPath}', pfx path '{CaPfxPath}'", caCrtPemFilePath, caKeyPemFilePath, caPfxFilePath); + } fileStore.WriteAllText(caCrtPemFilePath, ca.ExportCertificatePem()); - fileStore.WriteAllText(caKeyPemFilePath, certificateFactory.ExportPrivateKeyPem(ca)); + + // On macOS, export PEM from the original key object before it's attached to the certificate + // because ECDSA keys in the macOS keychain aren't exportable even after reload + var keyPem = key != null + ? certificateFactory.ExportPrivateKeyPem(key) + : certificateFactory.ExportPrivateKeyPem(ca); + fileStore.WriteAllText(caKeyPemFilePath, keyPem); // Intentionally skipping adding a password here to make it easier to import ca into a trusted root ca store. fileStore.WriteAllBytes(caPfxFilePath, ca.Export(X509ContentType.Pfx)); diff --git a/src/ReverseProxy/Certificate/ICaLoader.cs b/src/ReverseProxy/Certificate/ICaLoader.cs new file mode 100644 index 0000000..528d584 --- /dev/null +++ b/src/ReverseProxy/Certificate/ICaLoader.cs @@ -0,0 +1,31 @@ +// +// 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.Certificate; + +/// +/// Defines a strategy for loading CA certificates from PEM files. +/// +internal interface ICaLoader +{ + /// + /// Loads a CA certificate from PEM-encoded certificate and key content. + /// + /// The PEM-encoded certificate content. + /// The PEM-encoded private key content. + /// An instance with the private key attached. + X509Certificate2 LoadFromPem(string certContents, string keyContents); +} diff --git a/src/ReverseProxy/Certificate/ICertificateCreator.cs b/src/ReverseProxy/Certificate/ICertificateCreator.cs new file mode 100644 index 0000000..493db97 --- /dev/null +++ b/src/ReverseProxy/Certificate/ICertificateCreator.cs @@ -0,0 +1,57 @@ +// +// 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.Certificate; + +/// +/// Defines a strategy for creating X.509 certificates. +/// +internal interface ICertificateCreator +{ + /// + /// Creates a self-signed CA certificate. + /// + /// The asymmetric key to use for the certificate. + /// The certificate request containing subject, extensions, etc. + /// The date and time when the certificate becomes valid. + /// The date and time when the certificate expires. + /// A self-signed CA certificate with the private key attached. + X509Certificate2 CreateSelfSignedCa( + AsymmetricAlgorithm key, + CertificateRequest request, + DateTimeOffset validFrom, + DateTimeOffset validTo); + + /// + /// Creates a signed certificate using a CA certificate. + /// + /// The asymmetric key to use for the certificate. + /// The certificate request containing subject, extensions, etc. + /// The issuer name from the CA certificate. + /// The signature generator from the CA certificate. + /// The date and time when the certificate becomes valid. + /// The date and time when the certificate expires. + /// The serial number for the certificate. + /// A byte array containing the certificate in PKCS#12 format. + byte[] CreateSignedCertificate( + AsymmetricAlgorithm key, + CertificateRequest request, + X500DistinguishedName issuerName, + X509SignatureGenerator caSignatureGenerator, + DateTimeOffset validFrom, + DateTimeOffset validTo, + byte[] serialNumber); +} diff --git a/src/ReverseProxy/Certificate/Strategy/CertificateEcdsa.cs b/src/ReverseProxy/Certificate/Strategy/CertificateEcdsa.cs index 044e659..5054d8c 100644 --- a/src/ReverseProxy/Certificate/Strategy/CertificateEcdsa.cs +++ b/src/ReverseProxy/Certificate/Strategy/CertificateEcdsa.cs @@ -30,11 +30,16 @@ public CertificateRequest CreateCertificateRequest(AsymmetricAlgorithm key, X500 public X509SignatureGenerator GetSignatureGenerator(X509Certificate2 certificate) => X509SignatureGenerator.CreateForECDsa(GetKey(certificate)); + public X509SignatureGenerator GetSignatureGenerator(AsymmetricAlgorithm key) + => X509SignatureGenerator.CreateForECDsa((ECDsa)key); + public X509Certificate2 CopyWithPrivateKey(X509Certificate2 certificate, AsymmetricAlgorithm key) => certificate.CopyWithPrivateKey((ECDsa)key); public string ExportPrivateKeyPem(X509Certificate2 certificate) => GetKey(certificate).ExportECPrivateKeyPem(); + public string ExportPrivateKeyPem(AsymmetricAlgorithm key) => ((ECDsa)key).ExportECPrivateKeyPem(); + private static ECDsa GetKey(X509Certificate2 certificate) => certificate.GetECDsaPrivateKey() ?? throw new InvalidOperationException("Certificate has no private key"); } diff --git a/src/ReverseProxy/Certificate/Strategy/CertificateRsa.cs b/src/ReverseProxy/Certificate/Strategy/CertificateRsa.cs index c60aea3..a880c9e 100644 --- a/src/ReverseProxy/Certificate/Strategy/CertificateRsa.cs +++ b/src/ReverseProxy/Certificate/Strategy/CertificateRsa.cs @@ -30,11 +30,16 @@ public CertificateRequest CreateCertificateRequest(AsymmetricAlgorithm key, X500 public X509SignatureGenerator GetSignatureGenerator(X509Certificate2 certificate) => X509SignatureGenerator.CreateForRSA(GetKey(certificate), RSASignaturePadding.Pkcs1); + public X509SignatureGenerator GetSignatureGenerator(AsymmetricAlgorithm key) + => X509SignatureGenerator.CreateForRSA((RSA)key, RSASignaturePadding.Pkcs1); + public X509Certificate2 CopyWithPrivateKey(X509Certificate2 certificate, AsymmetricAlgorithm key) => certificate.CopyWithPrivateKey((RSA)key); public string ExportPrivateKeyPem(X509Certificate2 certificate) => GetKey(certificate).ExportRSAPrivateKeyPem(); + public string ExportPrivateKeyPem(AsymmetricAlgorithm key) => ((RSA)key).ExportRSAPrivateKeyPem(); + private static RSA GetKey(X509Certificate2 certificate) => certificate.GetRSAPrivateKey() ?? throw new InvalidOperationException("Certificate has no private key"); } diff --git a/src/ReverseProxy/Certificate/Strategy/DefaultCaLoader.cs b/src/ReverseProxy/Certificate/Strategy/DefaultCaLoader.cs new file mode 100644 index 0000000..317e8e0 --- /dev/null +++ b/src/ReverseProxy/Certificate/Strategy/DefaultCaLoader.cs @@ -0,0 +1,29 @@ +// +// 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.Certificate.Strategy; + +/// +/// Default implementation for loading CA certificates using the standard .NET API. +/// +internal sealed class DefaultCaLoader : ICaLoader +{ + /// + public X509Certificate2 LoadFromPem(string certContents, string keyContents) + { + return X509Certificate2.CreateFromPem(certContents, keyContents); + } +} diff --git a/src/ReverseProxy/Certificate/Strategy/DefaultCertificateCreator.cs b/src/ReverseProxy/Certificate/Strategy/DefaultCertificateCreator.cs new file mode 100644 index 0000000..28bc328 --- /dev/null +++ b/src/ReverseProxy/Certificate/Strategy/DefaultCertificateCreator.cs @@ -0,0 +1,61 @@ +// +// 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.Certificate.Strategy; + +/// +/// Default implementation for creating certificates using standard .NET APIs. +/// +internal sealed class DefaultCertificateCreator(IEnumerable strategies) : ICertificateCreator +{ + /// + public X509Certificate2 CreateSelfSignedCa( + AsymmetricAlgorithm key, + CertificateRequest request, + DateTimeOffset validFrom, + DateTimeOffset validTo) + { + return request.CreateSelfSigned(validFrom, validTo); + } + + /// + public byte[] CreateSignedCertificate( + AsymmetricAlgorithm key, + CertificateRequest request, + X500DistinguishedName issuerName, + X509SignatureGenerator caSignatureGenerator, + DateTimeOffset validFrom, + DateTimeOffset validTo, + byte[] serialNumber) + { + var certificate = request.Create( + issuerName, + caSignatureGenerator, + validFrom, + validTo, + serialNumber); + + var strategy = GetStrategy(key); + var certificateWithKey = strategy.CopyWithPrivateKey(certificate, key); + return certificateWithKey.Export(X509ContentType.Pkcs12); + } + + private ICertificateStrategy GetStrategy(AsymmetricAlgorithm key) + { + var keyType = key.GetType(); + return strategies.FirstOrDefault(x => x.CanHandle(keyType)) ?? throw new NotSupportedException($"Algorithm type '{keyType.Name}' is not supported"); + } +} diff --git a/src/ReverseProxy/Certificate/Strategy/ICertificateStrategy.cs b/src/ReverseProxy/Certificate/Strategy/ICertificateStrategy.cs index 6d1757b..8bd3bbf 100644 --- a/src/ReverseProxy/Certificate/Strategy/ICertificateStrategy.cs +++ b/src/ReverseProxy/Certificate/Strategy/ICertificateStrategy.cs @@ -34,7 +34,11 @@ internal interface ICertificateStrategy X509SignatureGenerator GetSignatureGenerator(X509Certificate2 certificate); + X509SignatureGenerator GetSignatureGenerator(AsymmetricAlgorithm key); + X509Certificate2 CopyWithPrivateKey(X509Certificate2 certificate, AsymmetricAlgorithm key); string ExportPrivateKeyPem(X509Certificate2 certificate); + + string ExportPrivateKeyPem(AsymmetricAlgorithm key); } diff --git a/src/ReverseProxy/Certificate/Strategy/MacOSNet8CaLoader.cs b/src/ReverseProxy/Certificate/Strategy/MacOSNet8CaLoader.cs new file mode 100644 index 0000000..ed471fe --- /dev/null +++ b/src/ReverseProxy/Certificate/Strategy/MacOSNet8CaLoader.cs @@ -0,0 +1,64 @@ +// +// 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. +// +#if NET8_0 +using System.Security.Cryptography.Pkcs; + +namespace Hj.ReverseProxy.Certificate.Strategy; + +/// +/// macOS .NET 8 implementation for loading CA certificates using Pkcs12Builder to avoid keychain issues. +/// +internal sealed class MacOSNet8CaLoader : ICaLoader +{ + /// + public X509Certificate2 LoadFromPem(string certContents, string keyContents) + { + using var certificate = X509Certificate2.CreateFromPem(certContents); + AsymmetricAlgorithm key; + + // Try ECDsa first, then fall back to RSA + try + { + key = ECDsa.Create(); + key.ImportFromPem(keyContents); + } + catch + { + key = RSA.Create(); + key.ImportFromPem(keyContents); + } + + using (key) + { + var safeContents = new Pkcs12SafeContents(); + safeContents.AddCertificate(certificate); + safeContents.AddShroudedKey(key, string.Empty, new PbeParameters( + PbeEncryptionAlgorithm.Aes256Cbc, + HashAlgorithmName.SHA256, + 2048)); + var builder = new Pkcs12Builder(); + builder.AddSafeContentsUnencrypted(safeContents); + builder.SealWithMac(string.Empty, HashAlgorithmName.SHA256, 2048); + var pfxBytes = builder.Encode(); + +#pragma warning disable SYSLIB0057 // Type or member is obsolete + var ca = new X509Certificate2(pfxBytes, string.Empty, X509KeyStorageFlags.Exportable); +#pragma warning restore SYSLIB0057 // Type or member is obsolete + return ca; + } + } +} +#endif diff --git a/src/ReverseProxy/Certificate/Strategy/MacOSNet8CertificateCreator.cs b/src/ReverseProxy/Certificate/Strategy/MacOSNet8CertificateCreator.cs new file mode 100644 index 0000000..7a4c1f9 --- /dev/null +++ b/src/ReverseProxy/Certificate/Strategy/MacOSNet8CertificateCreator.cs @@ -0,0 +1,96 @@ +// +// 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. +// +#if NET8_0 +using System.Security.Cryptography.Pkcs; + +namespace Hj.ReverseProxy.Certificate.Strategy; + +/// +/// macOS .NET 8 implementation for creating certificates using Pkcs12Builder to avoid keychain issues. +/// +internal sealed class MacOSNet8CertificateCreator(IEnumerable strategies) : ICertificateCreator +{ + /// + public X509Certificate2 CreateSelfSignedCa( + AsymmetricAlgorithm key, + CertificateRequest request, + DateTimeOffset validFrom, + DateTimeOffset validTo) + { + var serialNumber = new byte[16]; + RandomNumberGenerator.Fill(serialNumber); + + var strategy = GetStrategy(key); + var caSignatureGenerator = strategy.GetSignatureGenerator(key); + var certificate = request.Create(request.SubjectName, caSignatureGenerator, validFrom, validTo, serialNumber); + + // Use Pkcs12Builder to manually create PKCS12 without touching keychain + var safeContents = new Pkcs12SafeContents(); + safeContents.AddCertificate(certificate); + safeContents.AddShroudedKey(key, string.Empty, new PbeParameters( + PbeEncryptionAlgorithm.Aes256Cbc, + HashAlgorithmName.SHA256, + 2048)); + var builder = new Pkcs12Builder(); + builder.AddSafeContentsUnencrypted(safeContents); + builder.SealWithMac(string.Empty, HashAlgorithmName.SHA256, 2048); + var pfxBytes = builder.Encode(); + +#pragma warning disable SYSLIB0057 // Type or member is obsolete + var ca = new X509Certificate2(pfxBytes, string.Empty, X509KeyStorageFlags.Exportable); +#pragma warning restore SYSLIB0057 // Type or member is obsolete + return ca; + } + + /// + public byte[] CreateSignedCertificate( + AsymmetricAlgorithm key, + CertificateRequest request, + X500DistinguishedName issuerName, + X509SignatureGenerator caSignatureGenerator, + DateTimeOffset validFrom, + DateTimeOffset validTo, + byte[] serialNumber) + { + var certificate = request.Create( + issuerName, + caSignatureGenerator, + validFrom, + validTo, + serialNumber); + + using (certificate) + { + var safeContents = new Pkcs12SafeContents(); + safeContents.AddCertificate(certificate); + safeContents.AddShroudedKey(key, string.Empty, new PbeParameters( + PbeEncryptionAlgorithm.Aes256Cbc, + HashAlgorithmName.SHA256, + 2048)); + var builder = new Pkcs12Builder(); + builder.AddSafeContentsUnencrypted(safeContents); + builder.SealWithMac(string.Empty, HashAlgorithmName.SHA256, 2048); + return builder.Encode(); + } + } + + private ICertificateStrategy GetStrategy(AsymmetricAlgorithm key) + { + var keyType = key.GetType(); + return strategies.FirstOrDefault(x => x.CanHandle(keyType)) ?? throw new NotSupportedException($"Algorithm type '{keyType.Name}' is not supported"); + } +} +#endif diff --git a/src/ReverseProxy/ReverseProxy.csproj b/src/ReverseProxy/ReverseProxy.csproj index cb18a8e..bab1118 100644 --- a/src/ReverseProxy/ReverseProxy.csproj +++ b/src/ReverseProxy/ReverseProxy.csproj @@ -31,6 +31,10 @@ + + + + diff --git a/src/ReverseProxy/ReverseProxy/LoggerMiddleware.cs b/src/ReverseProxy/ReverseProxy/LoggerMiddleware.cs index 670e8fe..34459a4 100644 --- a/src/ReverseProxy/ReverseProxy/LoggerMiddleware.cs +++ b/src/ReverseProxy/ReverseProxy/LoggerMiddleware.cs @@ -28,7 +28,11 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next) if (string.Equals(ReverseProxyConstants.BlackholeId, route.RouteId, StringComparison.OrdinalIgnoreCase)) { - logger.LogDebug("Blackhole route: '{Url}', unknown route", context.Request.GetDisplayUrl()); + if (logger.IsEnabled(LogLevel.Debug)) + { + logger.LogDebug("Blackhole route: '{Url}', unknown route", context.Request.GetDisplayUrl()); + } + context.Response.StatusCode = (int)HttpStatusCode.NotFound; await context.Response.CompleteAsync(); return; diff --git a/src/ReverseProxy/ReverseProxyExtensions.cs b/src/ReverseProxy/ReverseProxyExtensions.cs index ab4b513..752b48f 100644 --- a/src/ReverseProxy/ReverseProxyExtensions.cs +++ b/src/ReverseProxy/ReverseProxyExtensions.cs @@ -83,6 +83,24 @@ public static IServiceCollection ConfigureReverseProxy(this IServiceCollection s services.TryAddSingleton(); services.AddSingleton(); services.AddSingleton(); + + // Register appropriate CA loader strategy based on platform and .NET version +#if NET8_0 + if (OperatingSystem.IsMacOS()) + { + services.AddSingleton(); + services.AddSingleton(); + } + else + { + services.AddSingleton(); + services.AddSingleton(); + } +#else + services.AddSingleton(); + services.AddSingleton(); +#endif + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/test/ReverseProxy.UnitTest/Certificate/CertificateAppTests.cs b/test/ReverseProxy.UnitTest/Certificate/CertificateAppTests.cs index bd2659b..44a74b1 100644 --- a/test/ReverseProxy.UnitTest/Certificate/CertificateAppTests.cs +++ b/test/ReverseProxy.UnitTest/Certificate/CertificateAppTests.cs @@ -151,6 +151,22 @@ private static void SetHappyPath(InputBuilder arrange) arrange.Instance(); arrange.Instance(); +#if NET8_0 + if (OperatingSystem.IsMacOS()) + { + arrange.Instance(); + arrange.Instance(); + } + else + { + arrange.Instance(); + arrange.Instance(); + } +#else + arrange.Instance(); + arrange.Instance(); +#endif + var fileStore = arrange.Instance(); fileStore.CombinePath(Arg.Any(), Arg.Any()).Returns(args => Path.Combine(args.ArgAt(0), args.ArgAt(1))); fileStore.GetFullPath(Arg.Any()).Returns(args => args.ArgAt(0)); diff --git a/test/ReverseProxy.UnitTest/ReverseProxy/ReverseProxyAppTests.cs b/test/ReverseProxy.UnitTest/ReverseProxy/ReverseProxyAppTests.cs index 7e3c107..1953db4 100644 --- a/test/ReverseProxy.UnitTest/ReverseProxy/ReverseProxyAppTests.cs +++ b/test/ReverseProxy.UnitTest/ReverseProxy/ReverseProxyAppTests.cs @@ -66,8 +66,6 @@ public async Task AddRouteAsync_GivenExisting_ThrowsAsync() await Assert.ThrowsAnyAsync(async () => await sut.AddRouteAsync(routeConfig, false)); } - - [Fact] public async Task AddRouteAsync_GivenExistingAndAllowOverwrite_UpdatesAsync() { From dba2f1a68364575767189d02299c5413d02365a6 Mon Sep 17 00:00:00 2001 From: Henrik Jensen <1175002+henrikhimself@users.noreply.github.com> Date: Wed, 31 Dec 2025 21:23:34 +0100 Subject: [PATCH 08/10] Call dispose. Fix misc. comments. --- .github/copilot-instructions.md | 2 +- scripts/Update-TestReport.ps1 | 2 +- .../Certificate/Strategy/DefaultCertificateCreator.cs | 4 ++-- .../Certificate/Strategy/MacOSNet8CertificateCreator.cs | 2 +- src/ReverseProxy/ReverseProxy/ReverseProxyApp.cs | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 55f2164..2f63bf3 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -95,7 +95,7 @@ Common namespaces are globally imported via [Directory.Build.props](../Directory ### Aspire Integration - Targets `Aspire.Hosting.AppHost` (v9.5.1) - DI service registration via `IServiceCollection`. -- Sstartup filters for configuring reverse proxy routes/clusters using injected service discovery environment variables. +- Startup filters for configuring reverse proxy routes/clusters using injected service discovery environment variables. - Resource builders follow Aspire conventions ### Extension Points diff --git a/scripts/Update-TestReport.ps1 b/scripts/Update-TestReport.ps1 index 0e8c777..2a67331 100644 --- a/scripts/Update-TestReport.ps1 +++ b/scripts/Update-TestReport.ps1 @@ -49,7 +49,7 @@ if ($LastExitCode -ne 0) { # Update test coverage report. $ReportGeneratorArgs = @( 'reportgenerator', - "-title:SutFactory", + "-title:ReverseProxy", "-reports:$ResultsDirectoryPath/**/coverage.cobertura.xml", "-targetdir:$ReportDirectoryPath", "-sourcedirs:$SourceDirectoryPath", diff --git a/src/ReverseProxy/Certificate/Strategy/DefaultCertificateCreator.cs b/src/ReverseProxy/Certificate/Strategy/DefaultCertificateCreator.cs index 28bc328..0ef0a01 100644 --- a/src/ReverseProxy/Certificate/Strategy/DefaultCertificateCreator.cs +++ b/src/ReverseProxy/Certificate/Strategy/DefaultCertificateCreator.cs @@ -41,7 +41,7 @@ public byte[] CreateSignedCertificate( DateTimeOffset validTo, byte[] serialNumber) { - var certificate = request.Create( + using var certificate = request.Create( issuerName, caSignatureGenerator, validFrom, @@ -49,7 +49,7 @@ public byte[] CreateSignedCertificate( serialNumber); var strategy = GetStrategy(key); - var certificateWithKey = strategy.CopyWithPrivateKey(certificate, key); + using var certificateWithKey = strategy.CopyWithPrivateKey(certificate, key); return certificateWithKey.Export(X509ContentType.Pkcs12); } diff --git a/src/ReverseProxy/Certificate/Strategy/MacOSNet8CertificateCreator.cs b/src/ReverseProxy/Certificate/Strategy/MacOSNet8CertificateCreator.cs index 7a4c1f9..24c3f7f 100644 --- a/src/ReverseProxy/Certificate/Strategy/MacOSNet8CertificateCreator.cs +++ b/src/ReverseProxy/Certificate/Strategy/MacOSNet8CertificateCreator.cs @@ -35,7 +35,7 @@ public X509Certificate2 CreateSelfSignedCa( var strategy = GetStrategy(key); var caSignatureGenerator = strategy.GetSignatureGenerator(key); - var certificate = request.Create(request.SubjectName, caSignatureGenerator, validFrom, validTo, serialNumber); + using var certificate = request.Create(request.SubjectName, caSignatureGenerator, validFrom, validTo, serialNumber); // Use Pkcs12Builder to manually create PKCS12 without touching keychain var safeContents = new Pkcs12SafeContents(); diff --git a/src/ReverseProxy/ReverseProxy/ReverseProxyApp.cs b/src/ReverseProxy/ReverseProxy/ReverseProxyApp.cs index 4e95576..0cdfe7d 100644 --- a/src/ReverseProxy/ReverseProxy/ReverseProxyApp.cs +++ b/src/ReverseProxy/ReverseProxy/ReverseProxyApp.cs @@ -82,7 +82,7 @@ public async ValueTask AddClusterAsync(ClusterConfig cluster, bool allowOverwrit var validationErrors = await configValidator.ValidateClusterAsync(cluster); if (validationErrors.Count > 0) { - throw new AggregateException("Could not add cluser.", validationErrors); + throw new AggregateException("Could not add cluster.", validationErrors); } var hasExisting = proxyConfigProviders.Any(x => x.GetConfig().Clusters.Any(y => y.ClusterId == cluster.ClusterId)); From 3b63f49f833ec1455d74d67980e782647b2557a2 Mon Sep 17 00:00:00 2001 From: Henrik Jensen Date: Sat, 17 Jan 2026 11:49:44 +0100 Subject: [PATCH 09/10] Bump to beta 5. Add {REVERSEPROXY_HOME} env var for CaFilePath expanding to user home. Remove optional port in WithReverseProxyReference(...). Add Agents instructions file. Update Aspire to 13 and add settings for cli. --- .aspire/settings.json | 3 + .github/copilot-instructions.md | 127 -------------- .vscode/extensions.json | 6 +- .vscode/launch.json | 21 +++ AGENTS.md | 155 ++++++++++++++++++ Directory.Packages.props | 3 +- nuget.config => NuGet.config | 4 +- Invoke-Test.ps1 => Update-TestCoverage.ps1 | 2 +- .../Examples.Aspire.AppHost.csproj | 8 +- examples/Aspire.AppHost/Program.cs | 7 +- examples/Aspire.ReverseProxy/appsettings.json | 2 +- ...TestReport.ps1 => Update-TestCoverage.ps1} | 0 .../ResourceBuilderExtensions.cs | 8 +- .../ReverseProxy.Aspire.csproj | 5 +- .../Certificate/CertificateConfig.cs | 9 + src/ReverseProxy/ReverseProxy.csproj | 18 +- .../Certificate/CertificateConfigTests.cs | 66 ++++++++ 17 files changed, 287 insertions(+), 157 deletions(-) create mode 100644 .aspire/settings.json delete mode 100644 .github/copilot-instructions.md create mode 100644 .vscode/launch.json create mode 100644 AGENTS.md rename nuget.config => NuGet.config (83%) rename Invoke-Test.ps1 => Update-TestCoverage.ps1 (93%) rename scripts/{Update-TestReport.ps1 => Update-TestCoverage.ps1} (100%) diff --git a/.aspire/settings.json b/.aspire/settings.json new file mode 100644 index 0000000..a755cc2 --- /dev/null +++ b/.aspire/settings.json @@ -0,0 +1,3 @@ +{ + "appHostPath": "../examples/Aspire.AppHost/Examples.Aspire.AppHost.csproj" +} \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 2f63bf3..0000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,127 +0,0 @@ -# Copilot Instructions for DotNet-ReverseProxy - -## Project Overview - -**HenrikJensen.ReverseProxy** is a .NET library that combines Microsoft YARP (Yet Another Reverse Proxy) with runtime configuration APIs and automatic self-signed certificate generation. It's designed to simplify development and testing of reverse proxy scenarios. - -### Key Components - -1. **ReverseProxy Core** ([src/ReverseProxy/](../src/ReverseProxy/)) - - **ReverseProxyApp**: Manages routes and clusters using YARP's configuration system - - **Certificate Management**: Automatic generation of self-signed certificates with caching with trust added by a local CA - - **Runtime API**: Web API endpoints for adding/managing routes and clusters dynamically - -2. **ReverseProxy.Aspire** ([src/ReverseProxy.Aspire/](../src/ReverseProxy.Aspire/)) - - Integrates with .NET Aspire orchestration platform for route/cluster configuration for services without requiring API calls - - `WithReverseProxyReference()`: Extension for configuring Aspire resources - - `ServiceDiscoveryStartupFilter`: Auto-discovers and configures annotated Aspire resources - -3. **Examples** ([examples/](../examples/)) - - AppHost: Aspire app host demonstrating the reverse proxy in action - - ReverseProxy: Example reverse proxy service using the library - - Website: Example backend service discoverable by the proxy - -## Architecture Patterns - -### Configuration Layering -- **InMemoryConfigProvider**: Holds runtime-added routes/clusters -- **IProxyConfigProvider**: Base abstraction; implementations are merged in ReverseProxyApp -- Multiple providers can coexist; routes/clusters are combined across all - -### Dependency Injection Pattern -Services use constructor injection with sealed internal classes: -```csharp -internal sealed class ReverseProxyApp( - IConfigValidator configValidator, - InMemoryConfigProvider inMemoryConfigProvider, - IEnumerable proxyConfigProviders) -``` - -Use the extension methods in [ReverseProxyExtensions.cs](../src/ReverseProxy/ReverseProxyExtensions.cs) to configure services: -- `ConfigureReverseProxy()`: Registers core services -- `UseSelfSignedCertificate()`: Enables automatic certificate generation on Kestrel - -### Certificate Management -- **Lazy Generation**: Certificates are created on-demand via ServerCertificateSelector -- **Caching**: IMemoryCache prevents redundant certificate generation -- **Wildcard Support**: Handles both `*.example.com` and specific domain names -- **Self-Signed CA**: Generated once and installed into the trusted root store on the platform; certificates derive trust from it - -## Testing Strategy - -### Test Framework & Patterns -- **xUnit** with **NSubstitute** for mocking -- **SutFactory** (HenrikJensen.SutFactory): Custom builder pattern for automatic arrangement of the dependency graph while creating the System Under Test instance - - Reduces boilerplate; use `SutBuilder` → `InputBuilder` → `Instance()` or if no spy instances are inspected, the preferred pattern `SystemUnderTest.For(arrange => { })` where 'arrange' is an InputBuilder. -- **Arrange-Act-Assert**: Standard pattern with setup helpers (see `SetHappyPath(InputBuilder arrange)` in tests) - -### Key Test Files -- [ReverseProxy.UnitTest/ReverseProxy/ReverseProxyApiTests.cs](../test/ReverseProxy.UnitTest/ReverseProxy/ReverseProxyApiTests.cs): Tests the API endpoints -- [ReverseProxy.UnitTest/ReverseProxy/ReverseProxyAppTests.cs](../test/ReverseProxy.UnitTest/ReverseProxy/ReverseProxyAppTests.cs): Tests configuration management - -### Running Tests -Prefer to use the cli command "dotnet test" from the solution root. Ignore the Invoke-Test.ps1 PowerShell script as its purpose is to generate a human readable test report in HTML format that shows test coverage. - -## Build & Development - -### Multi-Targeting -Projects target both `net8.0` and `net10.0`. Compatibility with Windows, macOS and Linux must be ensured when adding and changing code. - -### Code Analysis & Style -- **StyleCop.Analyzers**: Enforces XML documentation headers on public members -- **EnforceCodeStyleInBuild**: Code style violations fail the build -- Configuration: [stylecop.json](../stylecop.json) requires `xmlHeader: true` for packable projects - -### Package Management -Central package versioning via [Directory.Packages.props](../Directory.Packages.props): -- Add new dependencies by adding `` entries there -- Projects reference by `` (version auto-inherited) - -### Global Usings -Common namespaces are globally imported via [Directory.Build.props](../Directory.Build.props) and project `.csproj` files: -```csharp - - - -``` - -## Integration Points - -### YARP Integration -- Uses `Yarp.ReverseProxy` NuGet package (v2.3.0) -- Core types: `RouteConfig`, `ClusterConfig`, `DestinationConfig` -- Validation via `IConfigValidator` - -### Aspire Integration -- Targets `Aspire.Hosting.AppHost` (v9.5.1) -- DI service registration via `IServiceCollection`. -- Startup filters for configuring reverse proxy routes/clusters using injected service discovery environment variables. -- Resource builders follow Aspire conventions - -### Extension Points -- **IProxyConfigProvider**: Implement to add custom configuration sources -- **ICertificateConfig**: Customize certificate generation options (algorithm, subject name) -- **IFileStore**: Abstract file storage for certificates - -## Common Tasks - -### Adding a New Route at Runtime -1. Create `RouteConfig` with required properties (`RouteId`, `ClusterId`, `Match`) -2. Call `ReverseProxyApp.AddRouteAsync(route, allowOverwrite: true)` -3. Validation errors are wrapped in `AggregateException` - -### Adding Certificate Generation Strategy -1. Implement `ICertificateConfig` (provides values for key algorithm, ca path and name, subject name) -2. Register in DI via `ConfigureReverseProxy()` extension -3. Caching is automatic; no changes needed to `CertificateApp` - -### Publishing Changes -- Package ID: `HenrikJensen.ReverseProxy` -- Use semantic versioning (current: 1.0.0-beta5) -- Update version in [src/ReverseProxy/ReverseProxy.csproj](../src/ReverseProxy/ReverseProxy.csproj#L14) -- Ensure all tests pass before publishing -- Ensure all code is compatible with Windows, macOS and Linux - -## References -- **Solution**: [ReverseProxy.sln](../ReverseProxy.sln) (projects + examples + tests) -- **Global Configuration**: [global.json](../global.json) (SDK version pinning) diff --git a/.vscode/extensions.json b/.vscode/extensions.json index b874741..687c399 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,7 +1,11 @@ { "recommendations": [ + "editorconfig.editorconfig", + "github.copilot-chat", + "github.vscode-github-actions", "ms-vscode.powershell", "ms-dotnettools.csdevkit", - "editorconfig.editorconfig" + "ms-vscode.live-server", + "microsoft-aspire.aspire-vscode" ] } diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..94a9555 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,21 @@ +{ + "configurations": [ + { + "type": "aspire", + "request": "launch", + "name": "Aspire: Launch", + "program": "${workspaceFolder}/examples/Aspire.AppHost/Examples.Aspire.AppHost.csproj", + "debuggers": { + "project": { + "console": "integratedTerminal", + "logging": { + "moduleLoad": false + } + }, + "apphost": { + "stopAtEntry": true + } + } + } + ] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b0c3884 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,155 @@ +# Copilot instructions + +## Project Overview +This repository is a .NET library that combines Microsoft YARP (Yet Another Reverse Proxy) with runtime configuration APIs and automatic self-signed ephemeral certificate generation. It's designed to simplify development and testing of reverse proxy scenarios. The `examples/` directory is set up to use Aspire. Aspire is an orchestrator for the entire application and will take care of configuring dependencies, building, and running the application. The resources that make up the application are defined in `examples/Aspire.AppHost/Program.cs` including application code and external dependencies. + +The `src/ReverseProxy` directory implements the core functionality of the library. The `src/ReverseProxy.Aspire` directory is an extension that integrates `src/ReverseProxy` into an Aspire solution for managing YARP route/cluster configuration. + +### Key Components +1. **ReverseProxy Core** (`src/ReverseProxy`) + - **ReverseProxyApp**: Manages routes/clusters using YARP's configuration system + - **Certificate Management**: Automatic generation of self-signed ephemeral certificates with caching and trust added by a local CA + - **Runtime API**: Web API endpoints for managing routes/clusters at runtime + +2. **ReverseProxy.Aspire** (`src/ReverseProxy.Aspire`) + - Integrates with the Aspire orchestration platform for route/cluster configuration for services without requiring the **Runtime API** + - `WithReverseProxyReference()`: Extension for configuring Aspire resources + - `ServiceDiscoveryStartupFilter`: Auto-discovers and configures annotated Aspire resources as YARP routes/clusters + +3. **Examples** (`examples/`) + - AppHost: Aspire app host demonstrating the reverse proxy in action + - ReverseProxy: Example reverse proxy service using the library + - Website: Example backend service discoverable by the proxy + +## Architecture Patterns + +### Configuration Layering +- **InMemoryConfigProvider**: Holds runtime-added routes/clusters +- **IProxyConfigProvider**: Base abstraction; implementations are merged in ReverseProxyApp +- Multiple providers can coexist; routes/clusters are combined across all + +### Dependency Injection Pattern +Services use primary constructor injection with sealed internal classes: +```csharp +internal sealed class ReverseProxyApp( + IConfigValidator configValidator, + InMemoryConfigProvider inMemoryConfigProvider, + IEnumerable proxyConfigProviders) +``` + +Use the extension methods in `src/ReverseProxy/ReverseProxyExtensions.cs` to configure services: +- `ConfigureReverseProxy()`: Registers core services +- `UseSelfSignedCertificate()`: Enables automatic certificate generation on Kestrel + +### Certificate Management +- **Lazy Generation**: Certificates are created on-demand via ServerCertificateSelector +- **Caching**: IMemoryCache prevents redundant certificate generation +- **Wildcard Support**: Handles both `*.example.com` and specific domain names +- **Self-Signed CA**: Generated once and installed into the trusted root store on the platform; certificates derive trust from it + +## General recommendations for working with Aspire +1. Before making any changes in the `examples/` directory, always run the apphost using `aspire run` and inspect the state of resources to make sure you are building from a known state. +1. Changes to the `examples/Aspire.AppHost/Program.cs` file will require a restart of the application to take effect. +2. Make changes incrementally and run the aspire application using the `aspire run` command to validate changes. +3. Use the Aspire MCP tools to check the status of resources and debug issues. + +## Running the example application +To run the example application run the following command: + +``` +aspire run +``` + +If there is already an instance of the application running it will prompt to stop the existing instance. You only need to restart the application if code in `examples/Aspire.AppHost/Program.cs` is changed, but if you experience problems it can be useful to reset everything to the starting state. + +## Checking Aspire resources +To check the status of Aspire resources defined in the AppHost model use the _list resources_ tool. This will show you the current state of each resource and if there are any issues. If a resource is not running as expected you can use the _execute resource command_ tool to restart it or perform other actions. + +## Listing Aspire integrations +IMPORTANT! When a user asks you to add a resource to the AppHost model you should first use the _list integrations_ tool to get a list of the current versions of all the available integrations. You should try to use the version of the integration which aligns with the version of the Aspire.AppHost.Sdk. Some integration versions may have a preview suffix. Once you have identified the correct integration you should always use the _get integration docs_ tool to fetch the latest documentation for the integration and follow the links to get additional guidance. + +## Debugging Aspire issues +IMPORTANT! Aspire is designed to capture rich logs and telemetry for all resources defined in the AppHost model. Use the following diagnostic tools when debugging issues with the application before making changes to make sure you are focusing on the right things. + +1. _list structured logs_; use this tool to get details about structured logs. +2. _list console logs_; use this tool to get details about console logs. +3. _list traces_; use this tool to get details about traces. +4. _list trace structured logs_; use this tool to get logs related to a trace + +## Other Aspire MCP tools +1. _select apphost_; use this tool if working with multiple app hosts within a workspace. +2. _list apphosts_; use this tool to get details about active app hosts. + +## Updating the Aspire AppHost +The user may request that you update the Aspire apphost. You can do this using the `aspire update` command. This will update the apphost to the latest version and some of the Aspire specific packages in referenced projects, however you may need to manually update other packages in the solution to ensure compatibility. You can consider using the `dotnet-outdated` with the users consent. To install the `dotnet-outdated` tool use the following command: + +``` +dotnet tool install --global dotnet-outdated-tool +``` + +## Aspire workload +IMPORTANT! The aspire workload is obsolete. You should never attempt to install or use the Aspire workload. + +## Official Aspire documentation +IMPORTANT! Always prefer official documentation when available. The following sites contain the official documentation for Aspire and related components. + +1. https://aspire.dev +2. https://learn.microsoft.com/dotnet/aspire +3. https://nuget.org (for specific integration package details) + +## Testing Strategy + +### Test Framework & Patterns +- **xUnit** with **SutFactory** for mocking +- **SutFactory** (HenrikJensen.SutFactory): Custom builder pattern for automatic arrangement of the dependency graph while creating the System Under Test instance + - Reduces boilerplate; use `SutBuilder` → `InputBuilder` → `Instance()` or if no spy instances are inspected, the preferred pattern `SystemUnderTest.For(arrange => { })` where 'arrange' is an InputBuilder. +- **Arrange-Act-Assert**: Standard pattern with setup helpers (see `SetHappyPath(InputBuilder arrange)` in tests) + +### Key Test Files +- `test/ReverseProxy.UnitTest/ReverseProxy/ReverseProxyApiTests.cs`: Tests the API endpoints +- `test/ReverseProxy.UnitTest/ReverseProxy/ReverseProxyAppTests.cs`: Tests configuration management +- `test/ReverseProxy.UnitTest/Certificate/CertificateAppTests.cs`: Tests certificate generation +- `test/ReverseProxy.UnitTest/Certificate/CertificateConfigTests.cs`: Tests configuration management +- `test/ReverseProxy.Aspire.UnitTest/ServiceDiscoveryTests.cs`: Tests the Apsire service discovery + +### Running Tests +To test the library run the following command from the solution root: + +``` +dotnet test +``` + +## Build & Development + +### Multi-Targeting +Projects target both `net8.0` and `net10.0`. Compatibility with Windows, macOS and Linux must be ensured when adding and changing code. Use dotnet test to verify. + +### Code Analysis & Style +- **EnforceCodeStyleInBuild**: Code style violations fail the build +- Configuration: `stylecop.json` requires `xmlHeader: true` for packable projects + +### Package Management +Central package versioning via `Directory.Packages.props`: +- Add new dependencies by adding `` entries there +- Projects reference by `` (version auto-inherited) + +### Global Usings +Common namespaces are globally imported via `Directory.Build.props` and project `.csproj` files + +## Integration Points + +### YARP Integration +- Uses `Yarp.ReverseProxy` NuGet package +- Core types: `RouteConfig`, `ClusterConfig`, `DestinationConfig` +- Validation via `IConfigValidator` + +### Aspire Integration +- DI service registration via `IServiceCollection`. +- Startup filters for configuring reverse proxy routes/clusters using injected service discovery environment variables. +- Resource builders follow Aspire conventions + +### Extension Points +- **IProxyConfigProvider**: Implement to add custom configuration sources +- **ICertificateConfig**: Customize certificate generation options (algorithm, subject name) +- **IFileStore**: Abstract file storage for certificates + diff --git a/Directory.Packages.props b/Directory.Packages.props index f92af0a..eb10b6b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,9 +6,8 @@ - - + diff --git a/nuget.config b/NuGet.config similarity index 83% rename from nuget.config rename to NuGet.config index ad3140a..6ba3fb1 100644 --- a/nuget.config +++ b/NuGet.config @@ -1,4 +1,4 @@ - + @@ -10,4 +10,4 @@ - + \ No newline at end of file diff --git a/Invoke-Test.ps1 b/Update-TestCoverage.ps1 similarity index 93% rename from Invoke-Test.ps1 rename to Update-TestCoverage.ps1 index 2ad6fe7..3b44328 100755 --- a/Invoke-Test.ps1 +++ b/Update-TestCoverage.ps1 @@ -4,7 +4,7 @@ Set-StrictMode -Version Latest $InformationPreference = 'Continue' <# Global #> -[string]$UpdateTestReportScript = Join-Path $PSScriptRoot 'scripts' 'Update-TestReport.ps1' +[string]$UpdateTestReportScript = Join-Path $PSScriptRoot 'scripts' 'Update-TestCoverage.ps1' [string]$SourceSolutionPath = Join-Path $PSScriptRoot 'ReverseProxy.sln' [string]$TestReportPath = Join-Path $PSScriptRoot 'TestReport' diff --git a/examples/Aspire.AppHost/Examples.Aspire.AppHost.csproj b/examples/Aspire.AppHost/Examples.Aspire.AppHost.csproj index 5c9009b..c893d4f 100644 --- a/examples/Aspire.AppHost/Examples.Aspire.AppHost.csproj +++ b/examples/Aspire.AppHost/Examples.Aspire.AppHost.csproj @@ -1,6 +1,4 @@ - - - + Exe @@ -10,10 +8,6 @@ true - - - - diff --git a/examples/Aspire.AppHost/Program.cs b/examples/Aspire.AppHost/Program.cs index 27c2ace..0a14358 100644 --- a/examples/Aspire.AppHost/Program.cs +++ b/examples/Aspire.AppHost/Program.cs @@ -16,8 +16,11 @@ // Add reverse proxy website. var reverseProxy = builder .AddProject("Reverse-Proxy") - // Configure reverse proxy to use https on port 443. - .WithHttpsEndpoint(443) + // Configure reverse proxy to use HTTPS on port 443. This requires admin/root privileges when + // starting the apphost and may not work if you forward ports for remote development. + // .WithHttpsEndpoint(443) + // Using a different port for HTTPS here to avoid requiring admin/root privileges until you discover this. + .WithHttpsEndpoint(8443) // Map a host name to the endpoint of the example website. .WithReverseProxyReference("Website", website.GetEndpoint("http"), "example-website.local"); diff --git a/examples/Aspire.ReverseProxy/appsettings.json b/examples/Aspire.ReverseProxy/appsettings.json index 5de8cf3..5149c54 100644 --- a/examples/Aspire.ReverseProxy/appsettings.json +++ b/examples/Aspire.ReverseProxy/appsettings.json @@ -7,6 +7,6 @@ }, "AllowedHosts": "*", "SelfSignedCertificate": { - "CaFilePath": "c:/" + "CaFilePath": "{REVERSEPROXY_HOME}" } } diff --git a/scripts/Update-TestReport.ps1 b/scripts/Update-TestCoverage.ps1 similarity index 100% rename from scripts/Update-TestReport.ps1 rename to scripts/Update-TestCoverage.ps1 diff --git a/src/ReverseProxy.Aspire/ResourceBuilderExtensions.cs b/src/ReverseProxy.Aspire/ResourceBuilderExtensions.cs index 7d68f92..3c18508 100644 --- a/src/ReverseProxy.Aspire/ResourceBuilderExtensions.cs +++ b/src/ReverseProxy.Aspire/ResourceBuilderExtensions.cs @@ -18,10 +18,14 @@ namespace Hj.ReverseProxy.Aspire; public static class ResourceBuilderExtensions { - public static IResourceBuilder WithReverseProxyReference(this IResourceBuilder builder, string serviceName, EndpointReference endpointReference, string hostName, int port = 443) + public static IResourceBuilder WithReverseProxyReference(this IResourceBuilder builder, string serviceName, EndpointReference endpointReference, string hostName) where T : IResourceWithEnvironment { - var externalUrl = $"https://{hostName}:{port}"; + var endpointAnnotation = builder.Resource.Annotations.OfType().SingleOrDefault(a => a.Name == "https") + ?? throw new InvalidOperationException($"Resource '{builder.Resource.Name}' does not have an HTTPS endpoint to reference"); + + var port = ":" + endpointAnnotation.Port; + var externalUrl = $"https://{hostName}{port}"; builder .WithUrl(externalUrl) diff --git a/src/ReverseProxy.Aspire/ReverseProxy.Aspire.csproj b/src/ReverseProxy.Aspire/ReverseProxy.Aspire.csproj index 28fc93f..0b57bac 100644 --- a/src/ReverseProxy.Aspire/ReverseProxy.Aspire.csproj +++ b/src/ReverseProxy.Aspire/ReverseProxy.Aspire.csproj @@ -1,4 +1,4 @@ - + net8.0;net10.0 @@ -13,7 +13,7 @@ testing;reverse-proxy;aspire README.md LICENSE - 1.0.0-beta4 + 1.0.0-beta5 @@ -31,7 +31,6 @@ - diff --git a/src/ReverseProxy/Certificate/CertificateConfig.cs b/src/ReverseProxy/Certificate/CertificateConfig.cs index e0aed58..b2e06f1 100644 --- a/src/ReverseProxy/Certificate/CertificateConfig.cs +++ b/src/ReverseProxy/Certificate/CertificateConfig.cs @@ -31,6 +31,15 @@ public SelfSignedOptions GetOptions() throw new InvalidOperationException("CA file path is not configured"); } + // Expand {REVERSEPROXY_HOME} token if present: use env var if set, otherwise use user home directory + // If token is not present, use the path as-is (physical file path) + if (selfSignedOptions.CaFilePath.Contains("{REVERSEPROXY_HOME}", StringComparison.Ordinal)) + { + var reverseProxyHome = Environment.GetEnvironmentVariable("REVERSEPROXY_HOME") + ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + selfSignedOptions.CaFilePath = selfSignedOptions.CaFilePath.Replace("{REVERSEPROXY_HOME}", reverseProxyHome, StringComparison.Ordinal); + } + if (string.IsNullOrWhiteSpace(selfSignedOptions.CaName)) { selfSignedOptions.CaName = CertificateConstants.DefaultCaName; diff --git a/src/ReverseProxy/ReverseProxy.csproj b/src/ReverseProxy/ReverseProxy.csproj index bab1118..47ad7a0 100644 --- a/src/ReverseProxy/ReverseProxy.csproj +++ b/src/ReverseProxy/ReverseProxy.csproj @@ -27,15 +27,7 @@ - - - - - - - - - + @@ -49,4 +41,12 @@ + + + + + + + + diff --git a/test/ReverseProxy.UnitTest/Certificate/CertificateConfigTests.cs b/test/ReverseProxy.UnitTest/Certificate/CertificateConfigTests.cs index 6ef8684..901161a 100644 --- a/test/ReverseProxy.UnitTest/Certificate/CertificateConfigTests.cs +++ b/test/ReverseProxy.UnitTest/Certificate/CertificateConfigTests.cs @@ -59,6 +59,72 @@ public void GetOptions_GivenMissingSubjectName_UseDefault() Assert.Equal(CertificateConstants.DefaultCaSubjectName, result.SubjectName); } + [Fact] + public void GetOptions_GivenPhysicalFilePath_UsesPathAsIs() + { + // arrange + var expectedPath = "/my/ca/path"; + var configuration = CreateConfiguration(new() + { + { "SelfSignedCertificate:CaFilePath", expectedPath }, + }); + + var sut = new CertificateConfig(configuration); + + // act + var result = sut.GetOptions(); + + // assert + Assert.Equal(expectedPath, result.CaFilePath); + } + + [Fact] + public void GetOptions_GivenReverseProxyHomeToken_ExpandsToUserHomeWhenEnvVarNotSet() + { + // arrange + Environment.SetEnvironmentVariable("REVERSEPROXY_HOME", null); + var expectedPath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var configuration = CreateConfiguration(new() + { + { "SelfSignedCertificate:CaFilePath", "{REVERSEPROXY_HOME}" }, + }); + + var sut = new CertificateConfig(configuration); + + // act + var result = sut.GetOptions(); + + // assert + Assert.Equal(expectedPath, result.CaFilePath); + } + + [Fact] + public void GetOptions_GivenReverseProxyHomeToken_ExpandsToEnvVarWhenSet() + { + // arrange + const string CustomPath = "/custom/reverseproxy/path"; + try + { + Environment.SetEnvironmentVariable("REVERSEPROXY_HOME", CustomPath); + var configuration = CreateConfiguration(new() + { + { "SelfSignedCertificate:CaFilePath", "{REVERSEPROXY_HOME}" }, + }); + + var sut = new CertificateConfig(configuration); + + // act + var result = sut.GetOptions(); + + // assert + Assert.Equal(CustomPath, result.CaFilePath); + } + finally + { + Environment.SetEnvironmentVariable("REVERSEPROXY_HOME", null); + } + } + private static IConfiguration CreateConfiguration(Dictionary settings) => new ConfigurationBuilder().AddInMemoryCollection(settings!).Build(); } From 53225ce67e4e5c9f790db3f3236b6076ec486d0e Mon Sep 17 00:00:00 2001 From: Henrik Jensen Date: Sun, 18 Jan 2026 12:09:45 +0100 Subject: [PATCH 10/10] Add more endpoint config scenarios to the reverse proxy example. Update to Aspire 13.1.0. --- .../Examples.Aspire.AppHost.csproj | 2 +- examples/Aspire.AppHost/Program.cs | 33 +++++++++++++------ .../ResourceBuilderExtensions.cs | 16 +++++++-- .../ReverseProxy.Aspire.csproj | 2 +- 4 files changed, 38 insertions(+), 15 deletions(-) diff --git a/examples/Aspire.AppHost/Examples.Aspire.AppHost.csproj b/examples/Aspire.AppHost/Examples.Aspire.AppHost.csproj index c893d4f..dc30ec3 100644 --- a/examples/Aspire.AppHost/Examples.Aspire.AppHost.csproj +++ b/examples/Aspire.AppHost/Examples.Aspire.AppHost.csproj @@ -1,4 +1,4 @@ - + Exe diff --git a/examples/Aspire.AppHost/Program.cs b/examples/Aspire.AppHost/Program.cs index 0a14358..b7961c0 100644 --- a/examples/Aspire.AppHost/Program.cs +++ b/examples/Aspire.AppHost/Program.cs @@ -13,18 +13,31 @@ // 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. +// Add reverse proxy website. The following shows 3 scenarios for configuring HTTPS endpoints. var reverseProxy = builder .AddProject("Reverse-Proxy") - // Configure reverse proxy to use HTTPS on port 443. This requires admin/root privileges when - // starting the apphost and may not work if you forward ports for remote development. - // .WithHttpsEndpoint(443) - // Using a different port for HTTPS here to avoid requiring admin/root privileges until you discover this. - .WithHttpsEndpoint(8443) - // 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. + .WithExternalHttpEndpoints(); + +// Configure the reverse proxy to use HTTPS on port 443 to allow nice urls without port numbers. This requires admin/root +// privileges when starting the apphost and may not work if you forward ports for remote development. +// reverseProxy.WithHttpsEndpoint(443); + +// Using a different port above 1024 for HTTPS avoids requiring the admin/root privileges mentioned above. But now we +// get port numbers in the urls which we probably won't have when deploying to production. This is not ideal but makes +// remote development using a port forward easier. +reverseProxy.WithHttpsEndpoint(port: 8443); + +// For remote development without port forwarding or dev tunnels, we can disable the Aspire proxying and configure Kestrel +// to bind to all interfaces. Unfortunately this means that we can no longer use Aspire scaling but for development this +// is often desired. +// reverseProxy.WithHttpsEndpoint(port: 8443, isProxied: false) +// .WithEnvironment("ASPNETCORE_URLS", "https://0.0.0.0:8443"); + +// Add each website with a nice host name. Since we apply HTTPS using the reverse proxy by configuring the endpoint above, +// we can use the HTTP endpoint of each proxied website without worrying about security. +reverseProxy.WithReverseProxyReference("Website", website.GetEndpoint("http"), "example-website.local"); + +// Wait for the website to be healthy before starting the reverse proxy. reverseProxy.WaitFor(website); await builder.Build().RunAsync(); diff --git a/src/ReverseProxy.Aspire/ResourceBuilderExtensions.cs b/src/ReverseProxy.Aspire/ResourceBuilderExtensions.cs index 3c18508..006ad0d 100644 --- a/src/ReverseProxy.Aspire/ResourceBuilderExtensions.cs +++ b/src/ReverseProxy.Aspire/ResourceBuilderExtensions.cs @@ -22,10 +22,20 @@ public static IResourceBuilder WithReverseProxyReference(this IResourceBui where T : IResourceWithEnvironment { var endpointAnnotation = builder.Resource.Annotations.OfType().SingleOrDefault(a => a.Name == "https") - ?? throw new InvalidOperationException($"Resource '{builder.Resource.Name}' does not have an HTTPS endpoint to reference"); + ?? throw new InvalidOperationException($"Resource '{builder.Resource.Name}' does not have an HTTPS endpoint yet that we use."); - var port = ":" + endpointAnnotation.Port; - var externalUrl = $"https://{hostName}{port}"; + var externalUrl = $"https://{hostName}"; + + var port = endpointAnnotation.Port; + if (port == 443) + { + port = null; + } + + if (port.HasValue) + { + externalUrl += $":{port.Value}"; + } builder .WithUrl(externalUrl) diff --git a/src/ReverseProxy.Aspire/ReverseProxy.Aspire.csproj b/src/ReverseProxy.Aspire/ReverseProxy.Aspire.csproj index 0b57bac..e08a0f5 100644 --- a/src/ReverseProxy.Aspire/ReverseProxy.Aspire.csproj +++ b/src/ReverseProxy.Aspire/ReverseProxy.Aspire.csproj @@ -1,4 +1,4 @@ - + net8.0;net10.0