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/.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/.github/workflows/cd.yml b/.github/workflows/cd.yml
index 2eb4dd3..a889b10 100644
--- a/.github/workflows/cd.yml
+++ b/.github/workflows/cd.yml
@@ -12,9 +12,9 @@ jobs:
packages: read
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- name: Setup CodeQL
- uses: github/codeql-action/init@v3
+ uses: github/codeql-action/init@v4
with:
languages: csharp
build-mode: none
@@ -23,10 +23,10 @@ jobs:
with:
dotnet-version: |
8.0.x
- 9.0.x
+ 10.0.x
- name: Restore
run: dotnet restore
- name: Build
run: dotnet build --no-restore
- name: CodeQL Analysis
- uses: github/codeql-action/analyze@v3
+ uses: github/codeql-action/analyze@v4
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3580fd8..8462f5a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -8,13 +8,13 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- name: Setup
uses: actions/setup-dotnet@v5
with:
dotnet-version: |
8.0.x
- 9.0.x
+ 10.0.x
- name: Build
run: dotnet build
- name: Test
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
new file mode 100644
index 0000000..4a23d9f
--- /dev/null
+++ b/.runsettings
@@ -0,0 +1,38 @@
+
+
+
+ 0
+
+
+
+
+
+ cobertura
+ Obsolete,GeneratedCodeAttribute,CompilerGeneratedAttribute,TestSDKAutoGeneratedCode
+ [*.UnitTest]*,[*.Example]*Tests
+ [*]Hj.ReverseProxy*
+ false
+ 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..687c399 100644
--- a/.vscode/extensions.json
+++ b/.vscode/extensions.json
@@ -1,6 +1,11 @@
{
"recommendations": [
"editorconfig.editorconfig",
- "ms-dotnettools.csdevkit"
+ "github.copilot-chat",
+ "github.vscode-github-actions",
+ "ms-vscode.powershell",
+ "ms-dotnettools.csdevkit",
+ "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/.vscode/settings.json b/.vscode/settings.json
index 6d8df2c..6e7ec96 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -6,6 +6,7 @@
"**/obj": 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/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.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..eb10b6b
--- /dev/null
+++ b/Directory.Packages.props
@@ -0,0 +1,26 @@
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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/Update-TestCoverage.ps1 b/Update-TestCoverage.ps1
new file mode 100755
index 0000000..3b44328
--- /dev/null
+++ b/Update-TestCoverage.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-TestCoverage.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..dc30ec3 100644
--- a/examples/Aspire.AppHost/Examples.Aspire.AppHost.csproj
+++ b/examples/Aspire.AppHost/Examples.Aspire.AppHost.csproj
@@ -1,17 +1,12 @@
-
+
-
-
Exe
- net9.0
+ net10.0
true
+ false
true
-
-
-
-
diff --git a/examples/Aspire.AppHost/Program.cs b/examples/Aspire.AppHost/Program.cs
index 27c2ace..b7961c0 100644
--- a/examples/Aspire.AppHost/Program.cs
+++ b/examples/Aspire.AppHost/Program.cs
@@ -13,15 +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.
- .WithHttpsEndpoint(443)
- // Map a host name to the endpoint of the example website.
- .WithReverseProxyReference("Website", website.GetEndpoint("http"), "example-website.local");
+ .WithExternalHttpEndpoints();
-// Wait for the website to be healthy.
+// 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/examples/Aspire.ReverseProxy/Examples.Aspire.ReverseProxy.csproj b/examples/Aspire.ReverseProxy/Examples.Aspire.ReverseProxy.csproj
index b5f8ae7..2da3c39 100644
--- a/examples/Aspire.ReverseProxy/Examples.Aspire.ReverseProxy.csproj
+++ b/examples/Aspire.ReverseProxy/Examples.Aspire.ReverseProxy.csproj
@@ -1,7 +1,8 @@
- net9.0
+ net10.0
+ false
true
true
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/examples/Aspire.ServiceDefaults/Examples.Aspire.ServiceDefaults.csproj b/examples/Aspire.ServiceDefaults/Examples.Aspire.ServiceDefaults.csproj
index a3c2d57..72b4add 100644
--- a/examples/Aspire.ServiceDefaults/Examples.Aspire.ServiceDefaults.csproj
+++ b/examples/Aspire.ServiceDefaults/Examples.Aspire.ServiceDefaults.csproj
@@ -1,21 +1,22 @@
- net9.0
+ net10.0
true
+ false
true
-
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/examples/Aspire.Website/Examples.Aspire.Website.csproj b/examples/Aspire.Website/Examples.Aspire.Website.csproj
index f1ba4cc..2faef77 100644
--- a/examples/Aspire.Website/Examples.Aspire.Website.csproj
+++ b/examples/Aspire.Website/Examples.Aspire.Website.csproj
@@ -1,7 +1,8 @@
- net9.0
+ net10.0
+ false
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-TestCoverage.ps1 b/scripts/Update-TestCoverage.ps1
new file mode 100644
index 0000000..2a67331
--- /dev/null
+++ b/scripts/Update-TestCoverage.ps1
@@ -0,0 +1,59 @@
+#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
+[string]$SourceDirectoryPath = Join-Path $TestDirectoryPath 'src'
+
+# 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
+}
+
+# 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:ReverseProxy",
+ "-reports:$ResultsDirectoryPath/**/coverage.cobertura.xml",
+ "-targetdir:$ReportDirectoryPath",
+ "-sourcedirs:$SourceDirectoryPath",
+ '-reporttypes:HtmlInline_AzurePipelines'
+)
+Write-Information "Report generator args: $($ReportGeneratorArgs | ConvertTo-Json)"
+& dotnet $ReportGeneratorArgs
diff --git a/src/ReverseProxy.Aspire/ResourceBuilderExtensions.cs b/src/ReverseProxy.Aspire/ResourceBuilderExtensions.cs
index 7d68f92..006ad0d 100644
--- a/src/ReverseProxy.Aspire/ResourceBuilderExtensions.cs
+++ b/src/ReverseProxy.Aspire/ResourceBuilderExtensions.cs
@@ -18,10 +18,24 @@ 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 yet that we use.");
+
+ 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 b074ee3..e08a0f5 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
@@ -13,7 +13,7 @@
testing;reverse-proxy;aspire
README.md
LICENSE
- 1.0.0-beta4
+ 1.0.0-beta5
@@ -31,8 +31,7 @@
-
-
+
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/CertificateConfig.cs b/src/ReverseProxy/Certificate/CertificateConfig.cs
index 4d53d5e..b2e06f1 100644
--- a/src/ReverseProxy/Certificate/CertificateConfig.cs
+++ b/src/ReverseProxy/Certificate/CertificateConfig.cs
@@ -31,6 +31,20 @@ 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;
+ }
+
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/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 d17e9f3..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));
@@ -56,8 +74,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/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/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/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..0ef0a01
--- /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)
+ {
+ using var certificate = request.Create(
+ issuerName,
+ caSignatureGenerator,
+ validFrom,
+ validTo,
+ serialNumber);
+
+ var strategy = GetStrategy(key);
+ using 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..24c3f7f
--- /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);
+ using 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 615b0c3..47ad7a0 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
@@ -13,7 +13,7 @@
testing;reverse-proxy;certificates
README.md
LICENSE
- 1.0.0-beta4
+ 1.0.0-beta5
@@ -27,11 +27,7 @@
-
-
-
-
-
+
@@ -45,4 +41,12 @@
+
+
+
+
+
+
+
+
diff --git a/src/ReverseProxy/ReverseProxy/LoggerMiddleware.cs b/src/ReverseProxy/ReverseProxy/LoggerMiddleware.cs
index 58874c9..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("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/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..0cdfe7d 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,28 +62,40 @@ 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)
{
- throw new AggregateException("Could not add cluser.", validationErrors);
+ throw new AggregateException("Could not add cluster.", 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/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/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..aa27b33 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,19 @@
-
+
+
+
+
+
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/Certificate/CertificateAppTests.cs b/test/ReverseProxy.UnitTest/Certificate/CertificateAppTests.cs
index 7b50b78..44a74b1 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
@@ -151,12 +151,28 @@ 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));
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/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();
}
diff --git a/test/ReverseProxy.UnitTest/ReverseProxy.UnitTest.csproj b/test/ReverseProxy.UnitTest/ReverseProxy.UnitTest.csproj
index a2b35b7..44179e4 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,26 @@
-
+
+
+
+
+
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/ReverseProxyAppTests.cs b/test/ReverseProxy.UnitTest/ReverseProxy/ReverseProxyAppTests.cs
index 79d4159..1953db4 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,41 @@ 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 +116,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 +133,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)