From 14e10fac2da18ecc2cffc6b40880f1234b76d2bc Mon Sep 17 00:00:00 2001 From: Henrik Jensen Date: Sat, 21 Jun 2025 14:01:19 +0200 Subject: [PATCH 1/2] Initial commit --- .editorconfig | 524 ++++++++++++++++++ .github/dependabot.yml | 12 + .github/workflows/cd.yml | 32 ++ .github/workflows/ci.yml | 21 + .gitignore | 233 ++++++++ .vscode/extensions.json | 6 + .vscode/settings.json | 19 + Directory.Build.props | 20 + Directory.Build.targets | 17 + README.md | 4 +- ReverseProxy.sln | 51 ++ global.json | 7 + nuget.config | 13 + src/.editorconfig | 9 + .../Abstraction/FileSystemStore.cs | 35 ++ src/ReverseProxy/Abstraction/IFileStore.cs | 34 ++ .../Certificate/CertificateApp.cs | 71 +++ .../Certificate/CertificateConfig.cs | 46 ++ .../Certificate/CertificateConstants.cs | 32 ++ .../Certificate/CertificateFactory.cs | 98 ++++ .../Certificate/CertificateStore.cs | 63 +++ .../Certificate/ICertificateConfig.cs | 24 + .../Certificate/Models/SelfSignedOptions.cs | 26 + .../Certificate/Strategy/CertificateEcdsa.cs | 40 ++ .../Certificate/Strategy/CertificateRsa.cs | 40 ++ .../Strategy/ICertificateStrategy.cs | 40 ++ src/ReverseProxy/ReverseProxy.csproj | 45 ++ .../ReverseProxy/LoggerMiddleware.cs | 44 ++ .../ReverseProxy/Models/ClusterInputDto.cs | 24 + .../ReverseProxy/Models/RouteInputDto.cs | 24 + .../ReverseProxy/ReverseProxyApi.cs | 81 +++ .../ReverseProxy/ReverseProxyApp.cs | 92 +++ .../ReverseProxy/ReverseProxyConstants.cs | 22 + .../ServiceCollectionExtensions.cs | 135 +++++ stylecop.json | 8 + test/Directory.Build.props | 11 + .../Certificate/CertificateAppTests.cs | 162 ++++++ .../Certificate/CertificateConfigTests.cs | 64 +++ .../ReverseProxy.UnitTest.csproj | 24 + .../ReverseProxy/ReverseProxyApiTests.cs | 112 ++++ .../ReverseProxy/ReverseProxyAppTests.cs | 113 ++++ 41 files changed, 2476 insertions(+), 2 deletions(-) create mode 100644 .editorconfig create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/cd.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json create mode 100644 Directory.Build.props create mode 100644 Directory.Build.targets create mode 100644 ReverseProxy.sln create mode 100644 global.json create mode 100644 nuget.config create mode 100644 src/.editorconfig create mode 100644 src/ReverseProxy/Abstraction/FileSystemStore.cs create mode 100644 src/ReverseProxy/Abstraction/IFileStore.cs create mode 100644 src/ReverseProxy/Certificate/CertificateApp.cs create mode 100644 src/ReverseProxy/Certificate/CertificateConfig.cs create mode 100644 src/ReverseProxy/Certificate/CertificateConstants.cs create mode 100644 src/ReverseProxy/Certificate/CertificateFactory.cs create mode 100644 src/ReverseProxy/Certificate/CertificateStore.cs create mode 100644 src/ReverseProxy/Certificate/ICertificateConfig.cs create mode 100644 src/ReverseProxy/Certificate/Models/SelfSignedOptions.cs create mode 100644 src/ReverseProxy/Certificate/Strategy/CertificateEcdsa.cs create mode 100644 src/ReverseProxy/Certificate/Strategy/CertificateRsa.cs create mode 100644 src/ReverseProxy/Certificate/Strategy/ICertificateStrategy.cs create mode 100644 src/ReverseProxy/ReverseProxy.csproj create mode 100644 src/ReverseProxy/ReverseProxy/LoggerMiddleware.cs create mode 100644 src/ReverseProxy/ReverseProxy/Models/ClusterInputDto.cs create mode 100644 src/ReverseProxy/ReverseProxy/Models/RouteInputDto.cs create mode 100644 src/ReverseProxy/ReverseProxy/ReverseProxyApi.cs create mode 100644 src/ReverseProxy/ReverseProxy/ReverseProxyApp.cs create mode 100644 src/ReverseProxy/ReverseProxy/ReverseProxyConstants.cs create mode 100644 src/ReverseProxy/ServiceCollectionExtensions.cs create mode 100644 stylecop.json create mode 100644 test/Directory.Build.props create mode 100644 test/ReverseProxy.UnitTest/Certificate/CertificateAppTests.cs create mode 100644 test/ReverseProxy.UnitTest/Certificate/CertificateConfigTests.cs create mode 100644 test/ReverseProxy.UnitTest/ReverseProxy.UnitTest.csproj create mode 100644 test/ReverseProxy.UnitTest/ReverseProxy/ReverseProxyApiTests.cs create mode 100644 test/ReverseProxy.UnitTest/ReverseProxy/ReverseProxyAppTests.cs diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..b796817 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,524 @@ +# 20250613 +root = true + +[*] +indent_style = space +charset = utf-8 +trim_trailing_whitespace = true + +# XML project files +[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] +indent_size = 2 + +# XML config files +[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}] +indent_size = 2 + +[*.{json,jsonc}] +indent_size = 2 + +[*.{ps1,psm1}] +indent_size = 2 +insert_final_newline = true + +[*.sh] +indent_size = 4 +end_of_line = lf +insert_final_newline = true + +[*.{razor,cshtml}] +charset = utf-8-bom +insert_final_newline = true + +[*.cs] +indent_size = 2 +insert_final_newline = true + +# Sort using and Import directives with System.* appearing first +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false + +# Avoid "this." and "Me." if not necessary +dotnet_style_qualification_for_field = false:refactoring +dotnet_style_qualification_for_property = false:refactoring +dotnet_style_qualification_for_method = false:refactoring +dotnet_style_qualification_for_event = false:refactoring + +# Use language keywords instead of framework type names for type references +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion + +# Require var all the time. +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = true:suggestion + +# Suggest more modern language features when available +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion +csharp_style_prefer_extended_property_pattern = true:suggestion + +# Non-private static fields are PascalCase +dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.severity = warning +dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.symbols = non_private_static_fields +dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.style = non_private_static_field_style + +dotnet_naming_symbols.non_private_static_fields.applicable_kinds = field +dotnet_naming_symbols.non_private_static_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected +dotnet_naming_symbols.non_private_static_fields.required_modifiers = static + +dotnet_naming_style.non_private_static_field_style.capitalization = pascal_case + +# Non-private readonly fields are PascalCase +dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.severity = warning +dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.symbols = non_private_readonly_fields +dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.style = non_private_readonly_field_style + +dotnet_naming_symbols.non_private_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.non_private_readonly_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected +dotnet_naming_symbols.non_private_readonly_fields.required_modifiers = readonly + +dotnet_naming_style.non_private_readonly_field_style.capitalization = pascal_case + +# Constants are PascalCase +dotnet_naming_rule.constants_should_be_pascal_case.severity = warning +dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants +dotnet_naming_rule.constants_should_be_pascal_case.style = constant_style + +dotnet_naming_symbols.constants.applicable_kinds = field, local +dotnet_naming_symbols.constants.required_modifiers = const + +dotnet_naming_style.constant_style.capitalization = pascal_case + +# Static fields are camelCase and start with _ +dotnet_naming_rule.static_fields_should_be_camel_case.severity = warning +dotnet_naming_rule.static_fields_should_be_camel_case.symbols = static_fields +dotnet_naming_rule.static_fields_should_be_camel_case.style = static_field_style + +dotnet_naming_symbols.static_fields.applicable_kinds = field +dotnet_naming_symbols.static_fields.required_modifiers = static + +dotnet_naming_style.static_field_style.capitalization = camel_case +dotnet_naming_style.static_field_style.required_prefix = _ + +# Instance fields are camelCase and start with _ +dotnet_naming_rule.instance_fields_should_be_camel_case.severity = warning +dotnet_naming_rule.instance_fields_should_be_camel_case.symbols = instance_fields +dotnet_naming_rule.instance_fields_should_be_camel_case.style = instance_field_style + +dotnet_naming_symbols.instance_fields.applicable_kinds = field + +dotnet_naming_style.instance_field_style.capitalization = camel_case +dotnet_naming_style.instance_field_style.required_prefix = _ + +# Async methods should have "Async" suffix +dotnet_naming_rule.async_methods_end_in_async.symbols = any_async_methods +dotnet_naming_rule.async_methods_end_in_async.style = end_in_async +dotnet_naming_rule.async_methods_end_in_async.severity = warning + +dotnet_naming_symbols.any_async_methods.applicable_kinds = method +dotnet_naming_symbols.any_async_methods.applicable_accessibilities = * +dotnet_naming_symbols.any_async_methods.required_modifiers = async + +dotnet_naming_style.end_in_async.required_prefix = +dotnet_naming_style.end_in_async.required_suffix = Async +dotnet_naming_style.end_in_async.capitalization = pascal_case +dotnet_naming_style.end_in_async.word_separator = + +# Locals and parameters are camelCase +dotnet_naming_rule.locals_should_be_camel_case.severity = warning +dotnet_naming_rule.locals_should_be_camel_case.symbols = locals_and_parameters +dotnet_naming_rule.locals_should_be_camel_case.style = camel_case_style + +dotnet_naming_symbols.locals_and_parameters.applicable_kinds = parameter, local + +dotnet_naming_style.camel_case_style.capitalization = camel_case + +# Local functions are PascalCase +dotnet_naming_rule.local_functions_should_be_pascal_case.severity = warning +dotnet_naming_rule.local_functions_should_be_pascal_case.symbols = local_functions +dotnet_naming_rule.local_functions_should_be_pascal_case.style = local_function_style + +dotnet_naming_symbols.local_functions.applicable_kinds = local_function + +dotnet_naming_style.local_function_style.capitalization = pascal_case + +# By default, name items with PascalCase +dotnet_naming_rule.members_should_be_pascal_case.severity = warning +dotnet_naming_rule.members_should_be_pascal_case.symbols = all_members +dotnet_naming_rule.members_should_be_pascal_case.style = pascal_case_style + +dotnet_naming_symbols.all_members.applicable_kinds = * + +dotnet_naming_style.pascal_case_style.capitalization = pascal_case + +# Newline settings +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_switch_labels = true +csharp_indent_labels = flush_left + +# Whitespace options +csharp_style_allow_embedded_statements_on_same_line_experimental = false +csharp_style_allow_blank_lines_between_consecutive_braces_experimental = false +csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = false +csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = false +csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = false + +# Prefer method-like constructs to have a block body +csharp_style_expression_bodied_methods = false:none +csharp_style_expression_bodied_constructors = false:none +csharp_style_expression_bodied_operators = false:none + +# Prefer property-like constructs to have an expression-body +csharp_style_expression_bodied_properties = true:none +csharp_style_expression_bodied_indexers = true:none +csharp_style_expression_bodied_accessors = true:none + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = do_not_ignore +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Namespace settings +csharp_style_namespace_declarations = file_scoped + +# Brace settings +csharp_prefer_braces = true + +# SYSLIB1054: Use 'LibraryImportAttribute' instead of 'DllImportAttribute' to generate P/Invoke marshalling code at compile time +dotnet_diagnostic.SYSLIB1054.severity = warning + +# CA1515: Consider making public types internal +dotnet_diagnostic.CA1515.severity = suggestion + +# CA1018: Mark attributes with AttributeUsageAttribute +dotnet_diagnostic.CA1018.severity = warning + +# CA1034: Nested types should not be visible +dotnet_diagnostic.CA1034.severity = warning + +# CA1047: Do not declare protected member in sealed type +dotnet_diagnostic.CA1047.severity = warning + +# CA1305: Specify IFormatProvider +dotnet_diagnostic.CA1305.severity = warning + +# CA1307: Specify StringComparison for clarity +dotnet_diagnostic.CA1307.severity = warning + +# CA1507: Use nameof to express symbol names +dotnet_diagnostic.CA1507.severity = warning + +# CA1508: CA1508: Avoid dead conditional code +dotnet_diagnostic.CA1508.severity = error + +# CA1510: Use ArgumentNullException throw helper +dotnet_diagnostic.CA1510.severity = warning + +# CA1511: Use ArgumentException throw helper +dotnet_diagnostic.CA1511.severity = warning + +# CA1512: Use ArgumentOutOfRangeException throw helper +dotnet_diagnostic.CA1512.severity = warning + +# CA1513: Use ObjectDisposedException throw helper +dotnet_diagnostic.CA1513.severity = warning + +# CA1725: Parameter names should match base declaration +dotnet_diagnostic.CA1725.severity = suggestion + +# CA1802: Use literals where appropriate +dotnet_diagnostic.CA1802.severity = warning + +# CA1805: Do not initialize unnecessarily +dotnet_diagnostic.CA1805.severity = warning + +# CA1810: Do not initialize unnecessarily +dotnet_diagnostic.CA1810.severity = warning + +# CA1821: Remove empty Finalizers +dotnet_diagnostic.CA1821.severity = warning + +# CA1822: Make member static +dotnet_diagnostic.CA1822.severity = warning +dotnet_code_quality.CA1822.api_surface = private, internal + +# CA1823: Avoid unused private fields +dotnet_diagnostic.CA1823.severity = warning + +# CA1825: Avoid zero-length array allocations +dotnet_diagnostic.CA1825.severity = warning + +# CA1826: Do not use Enumerable methods on indexable collections. Instead use the collection directly +dotnet_diagnostic.CA1826.severity = warning + +# CA1827: Do not use Count() or LongCount() when Any() can be used +dotnet_diagnostic.CA1827.severity = warning + +# CA1828: Do not use CountAsync() or LongCountAsync() when AnyAsync() can be used +dotnet_diagnostic.CA1828.severity = warning + +# CA1829: Use Length/Count property instead of Count() when available +dotnet_diagnostic.CA1829.severity = warning + +# CA1830: Prefer strongly-typed Append and Insert method overloads on StringBuilder +dotnet_diagnostic.CA1830.severity = warning + +# CA1831: Use AsSpan or AsMemory instead of Range-based indexers when appropriate +dotnet_diagnostic.CA1831.severity = warning + +# CA1832: Use AsSpan or AsMemory instead of Range-based indexers when appropriate +dotnet_diagnostic.CA1832.severity = warning + +# CA1833: Use AsSpan or AsMemory instead of Range-based indexers when appropriate +dotnet_diagnostic.CA1833.severity = warning + +# CA1834: Consider using 'StringBuilder.Append(char)' when applicable +dotnet_diagnostic.CA1834.severity = warning + +# CA1835: Prefer the 'Memory'-based overloads for 'ReadAsync' and 'WriteAsync' +dotnet_diagnostic.CA1835.severity = warning + +# CA1836: Prefer IsEmpty over Count +dotnet_diagnostic.CA1836.severity = warning + +# CA1837: Use 'Environment.ProcessId' +dotnet_diagnostic.CA1837.severity = warning + +# CA1838: Avoid 'StringBuilder' parameters for P/Invokes +dotnet_diagnostic.CA1838.severity = warning + +# CA1839: Use 'Environment.ProcessPath' +dotnet_diagnostic.CA1839.severity = warning + +# CA1840: Use 'Environment.CurrentManagedThreadId' +dotnet_diagnostic.CA1840.severity = warning + +# CA1841: Prefer Dictionary.Contains methods +dotnet_diagnostic.CA1841.severity = warning + +# CA1842: Do not use 'WhenAll' with a single task +dotnet_diagnostic.CA1842.severity = warning + +# CA1843: Do not use 'WaitAll' with a single task +dotnet_diagnostic.CA1843.severity = warning + +# CA1844: Provide memory-based overrides of async methods when subclassing 'Stream' +dotnet_diagnostic.CA1844.severity = warning + +# CA1845: Use span-based 'string.Concat' +dotnet_diagnostic.CA1845.severity = warning + +# CA1846: Prefer AsSpan over Substring +dotnet_diagnostic.CA1846.severity = warning + +# CA1847: Use string.Contains(char) instead of string.Contains(string) with single characters +dotnet_diagnostic.CA1847.severity = warning + +# CA1848: Use the LoggerMessage delegates +dotnet_diagnostic.CA1848.severity = none + +# CA1852: Seal internal types +dotnet_diagnostic.CA1852.severity = warning + +# CA1854: Prefer the IDictionary.TryGetValue(TKey, out TValue) method +dotnet_diagnostic.CA1854.severity = warning + +# CA1855: Prefer 'Clear' over 'Fill' +dotnet_diagnostic.CA1855.severity = warning + +# CA1856: Incorrect usage of ConstantExpected attribute +dotnet_diagnostic.CA1856.severity = error + +# CA1857: A constant is expected for the parameter +dotnet_diagnostic.CA1857.severity = warning + +# CA1858: Use 'StartsWith' instead of 'IndexOf' +dotnet_diagnostic.CA1858.severity = warning + +# CA2000: Dispose objects before losing scope +dotnet_diagnostic.CA2000.severity = error + +# CA2007: Consider calling ConfigureAwait on the awaited task +# It is generally appropriate to suppress this warning entirely for projects that represent application code rather than library code +dotnet_diagnostic.CA2007.severity = none + +# CA2008: Do not create tasks without passing a TaskScheduler +dotnet_diagnostic.CA2008.severity = warning + +# CA2009: Do not call ToImmutableCollection on an ImmutableCollection value +dotnet_diagnostic.CA2009.severity = warning + +# CA2011: Avoid infinite recursion +dotnet_diagnostic.CA2011.severity = warning + +# CA2012: Use ValueTask correctly +dotnet_diagnostic.CA2012.severity = warning + +# CA2013: Do not use ReferenceEquals with value types +dotnet_diagnostic.CA2013.severity = warning + +# CA2014: Do not use stackalloc in loops. +dotnet_diagnostic.CA2014.severity = warning + +# CA2016: Forward the 'CancellationToken' parameter to methods that take one +dotnet_diagnostic.CA2016.severity = warning + +# CA2200: Rethrow to preserve stack details +dotnet_diagnostic.CA2200.severity = warning + +# CA2201: Do not raise reserved exception types +dotnet_diagnostic.CA2201.severity = warning + +# CA2208: Instantiate argument exceptions correctly +dotnet_diagnostic.CA2208.severity = warning + +# CA2245: Do not assign a property to itself +dotnet_diagnostic.CA2245.severity = warning + +# CA2246: Assigning symbol and its member in the same statement +dotnet_diagnostic.CA2246.severity = warning + +# CA2249: Use string.Contains instead of string.IndexOf to improve readability. +dotnet_diagnostic.CA2249.severity = warning + +# CS1591: Missing XML comment for publicly visible type or member 'Type_or_Member' +dotnet_diagnostic.CS1591.severity = none + +# IDE0005: Remove unnecessary usings +dotnet_diagnostic.IDE0005.severity = warning + +# IDE0011: Curly braces to surround blocks of code +dotnet_diagnostic.IDE0011.severity = warning + +# IDE0029: Use coalesce expression (non-nullable types) +dotnet_diagnostic.IDE0029.severity = warning + +# IDE0030: Use coalesce expression (nullable types) +dotnet_diagnostic.IDE0030.severity = warning + +# IDE0031: Use null propagation +dotnet_diagnostic.IDE0031.severity = warning + +# IDE0035: Remove unreachable code +dotnet_diagnostic.IDE0035.severity = warning + +# IDE0036: Order modifiers +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion +dotnet_diagnostic.IDE0036.severity = warning + +# IDE0038: Use pattern matching to avoid is check followed by a cast (without variable) - also see IDE0020 +dotnet_diagnostic.IDE0038.severity = warning + +# IDE0043: Format string contains invalid placeholder +dotnet_diagnostic.IDE0043.severity = warning + +# IDE0044: Make field readonly +dotnet_diagnostic.IDE0044.severity = warning + +# IDE0051: Remove unused private members +dotnet_diagnostic.IDE0051.severity = warning + +# IDE0055: All formatting rules +dotnet_diagnostic.IDE0055.severity = suggestion + +# IDE0059: Unnecessary assignment to a value +dotnet_diagnostic.IDE0059.severity = warning + +# IDE0060: Remove unused parameter +dotnet_code_quality_unused_parameters = non_public +dotnet_diagnostic.IDE0060.severity = warning + +# IDE0062: Make local function static +dotnet_diagnostic.IDE0062.severity = warning + +# IDE0073: File header +dotnet_diagnostic.IDE0073.severity = suggestion + +# IDE0161: Convert to file-scoped namespace +dotnet_diagnostic.IDE0161.severity = warning + +# IDE0200: Lambda expression can be removed +dotnet_diagnostic.IDE0200.severity = warning + +# IDE0290: Use primary constructor +dotnet_diagnostic.IDE0290.severity = none + +# IDE2000: Disallow multiple blank lines +dotnet_style_allow_multiple_blank_lines_experimental = false +dotnet_diagnostic.IDE2000.severity = warning + +# The spacing around a C# keyword is incorrect +dotnet_diagnostic.SA1000.severity = none + +# A closing parenthesis within a C# statement is not spaced correctly +dotnet_diagnostic.SA1009.severity = none + +# An opening square bracket within a C# statement is not spaced correctly +dotnet_diagnostic.SA1010.severity = none + +# A closing square bracket within a C# statement is not spaced correctly. +dotnet_diagnostic.SA1011.severity = none + +# SA1101: A call to an instance member of the local class or a base class is not prefixed with 'this.', within a C# code file +dotnet_diagnostic.SA1101.severity = none + +# SA1200: A C# using directive is placed outside of a namespace element +dotnet_diagnostic.SA1200.severity = none + +# SA1206: The keywords within the declaration of an element do not follow a standard ordering scheme +dotnet_diagnostic.SA1206.severity = none + +# SA1309: A field name in C# begins with an underscore +dotnet_diagnostic.SA1309.severity = none + +# SA1515: A single-line comment within C# code is not preceded by a blank line +dotnet_diagnostic.SA1515.severity = none + +# SA1600: A C# code element is missing a documentation header +dotnet_diagnostic.SA1600.severity = none + +# SA1601: A C# partial element is missing a documentation header +dotnet_diagnostic.SA1601.severity = none + +# SA1633: A C# code file is missing a standard file header +dotnet_diagnostic.SA1633.severity = none diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..8c73057 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: "nuget" + target-branch: "develop" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "github-actions" + target-branch: "develop" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..f41e785 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,32 @@ +name: cd + +on: + pull_request: + branches: [ "main" ] + +jobs: + build: + runs-on: ubuntu-latest + permissions: + security-events: write + packages: read + + steps: + - uses: actions/checkout@v4 + - name: Setup CodeQL + uses: github/codeql-action/init@v3 + with: + languages: csharp + build-mode: none + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + - name: Restore + run: dotnet restore + - name: Build + run: dotnet build --no-restore + - name: CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..85789eb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: ci + +on: + push: + branches: [ "develop" ] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + - name: Build + run: dotnet build + - name: Test + run: dotnet test --no-build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ab987c6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,233 @@ +.attic +TestReport + +## Visual Studio +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +*.snk + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +## macOS +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..7c080ae --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "editorconfig.editorconfig", + "ms-dotnettools.csdevkit" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..6d8df2c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,19 @@ +{ + "dotnet.defaultSolution": "ReverseProxy.sln", + "editor.minimap.enabled": false, + "files.exclude": { + "**/bin": true, + "**/obj": true + }, + "git.enableCommitSigning": true, + "csharp.debug.symbolOptions.searchNuGetOrgSymbolServer": true, + "csharp.debug.symbolOptions.searchMicrosoftSymbolServer": true, + "csharp.debug.justMyCode": true, + "csharp.debug.requireExactSource": true, + "csharp.debug.suppressJITOptimizations": false, + "editor.inlayHints.enabled": "on", + "csharp.inlayHints.enableInlayHintsForImplicitVariableTypes": true, + "csharp.inlayHints.enableInlayHintsForTypes": true, + "dotnet.inlayHints.enableInlayHintsForIndexerParameters": true, + "dotnet.inlayHints.enableInlayHintsForLiteralParameters": true +} diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..c5f8d6f --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,20 @@ + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..0a66993 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,17 @@ + + + + Hj.$(MSBuildProjectName.Replace(" ", "_")) + Latest + enable + enable + Henrik Jensen + Pairs Microsoft YARP with a web API for runtime configuration and automatic self-signed certificates. + testing;mock;reverse-proxy;certificates + https://github.com/henrikhimself/DotNet-ReverseProxy + https://github.com/henrikhimself/DotNet-ReverseProxy.git + git + 1.0.0 + + + diff --git a/README.md b/README.md index d6a37b8..c72c6e4 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,2 @@ -# DotNet-ReverseProxy -Pairs Microsoft YARP with a web API for runtime configuration and automatic self-signed certificates. +# A Reverse Proxy. +This is a reverse proxy that pairs Microsoft YARP with a web API for runtime configuration and automatic self-signed certificates. diff --git a/ReverseProxy.sln b/ReverseProxy.sln new file mode 100644 index 0000000..6c95cb9 --- /dev/null +++ b/ReverseProxy.sln @@ -0,0 +1,51 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReverseProxy", "src\ReverseProxy\ReverseProxy.csproj", "{D23542D6-A4BE-5955-E1A1-5BF417045CCA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReverseProxy.UnitTest", "test\ReverseProxy.UnitTest\ReverseProxy.UnitTest.csproj", "{44CD6750-C3BF-45B7-B292-8BFBCC5216C4}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {D23542D6-A4BE-5955-E1A1-5BF417045CCA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D23542D6-A4BE-5955-E1A1-5BF417045CCA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D23542D6-A4BE-5955-E1A1-5BF417045CCA}.Debug|x64.ActiveCfg = Debug|Any CPU + {D23542D6-A4BE-5955-E1A1-5BF417045CCA}.Debug|x64.Build.0 = Debug|Any CPU + {D23542D6-A4BE-5955-E1A1-5BF417045CCA}.Debug|x86.ActiveCfg = Debug|Any CPU + {D23542D6-A4BE-5955-E1A1-5BF417045CCA}.Debug|x86.Build.0 = Debug|Any CPU + {D23542D6-A4BE-5955-E1A1-5BF417045CCA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D23542D6-A4BE-5955-E1A1-5BF417045CCA}.Release|Any CPU.Build.0 = Release|Any CPU + {D23542D6-A4BE-5955-E1A1-5BF417045CCA}.Release|x64.ActiveCfg = Release|Any CPU + {D23542D6-A4BE-5955-E1A1-5BF417045CCA}.Release|x64.Build.0 = Release|Any CPU + {D23542D6-A4BE-5955-E1A1-5BF417045CCA}.Release|x86.ActiveCfg = Release|Any CPU + {D23542D6-A4BE-5955-E1A1-5BF417045CCA}.Release|x86.Build.0 = Release|Any CPU + {44CD6750-C3BF-45B7-B292-8BFBCC5216C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {44CD6750-C3BF-45B7-B292-8BFBCC5216C4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {44CD6750-C3BF-45B7-B292-8BFBCC5216C4}.Debug|x64.ActiveCfg = Debug|Any CPU + {44CD6750-C3BF-45B7-B292-8BFBCC5216C4}.Debug|x64.Build.0 = Debug|Any CPU + {44CD6750-C3BF-45B7-B292-8BFBCC5216C4}.Debug|x86.ActiveCfg = Debug|Any CPU + {44CD6750-C3BF-45B7-B292-8BFBCC5216C4}.Debug|x86.Build.0 = Debug|Any CPU + {44CD6750-C3BF-45B7-B292-8BFBCC5216C4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {44CD6750-C3BF-45B7-B292-8BFBCC5216C4}.Release|Any CPU.Build.0 = Release|Any CPU + {44CD6750-C3BF-45B7-B292-8BFBCC5216C4}.Release|x64.ActiveCfg = Release|Any CPU + {44CD6750-C3BF-45B7-B292-8BFBCC5216C4}.Release|x64.Build.0 = Release|Any CPU + {44CD6750-C3BF-45B7-B292-8BFBCC5216C4}.Release|x86.ActiveCfg = Release|Any CPU + {44CD6750-C3BF-45B7-B292-8BFBCC5216C4}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {5CEEA7D1-CF67-453E-BE4B-8ADC68410EA6} + EndGlobalSection +EndGlobal diff --git a/global.json b/global.json new file mode 100644 index 0000000..4220370 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "9.0.0", + "rollForward": "major", + "allowPrerelease": false + } +} diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..ad3140a --- /dev/null +++ b/nuget.config @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/.editorconfig b/src/.editorconfig new file mode 100644 index 0000000..92cd91f --- /dev/null +++ b/src/.editorconfig @@ -0,0 +1,9 @@ +root = false + +[*.cs] +# SA1633: A C# code file is missing a standard file header +dotnet_diagnostic.SA1633.severity = error +file_header_template=\nCopyright 2025 Henrik Jensen\n\nLicensed under the Apache License, Version 2.0 (the "License")\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\nhttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an "AS IS" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n + +# SA1636: The file header at the top of a C# code file does not contain the appropriate copyright text +dotnet_diagnostic.SA1636.severity = none diff --git a/src/ReverseProxy/Abstraction/FileSystemStore.cs b/src/ReverseProxy/Abstraction/FileSystemStore.cs new file mode 100644 index 0000000..f9dcf4b --- /dev/null +++ b/src/ReverseProxy/Abstraction/FileSystemStore.cs @@ -0,0 +1,35 @@ +// +// 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.Abstraction; + +[ExcludeFromCodeCoverage] +internal sealed class FileSystemStore : IFileStore +{ + public string CombinePath(string path1, string path2) => Path.Combine(path1, path2); + + public string GetFullPath(string path) => Path.GetFullPath(path); + + public bool FileExists(string? path) => File.Exists(path); + + public bool DirectoryExists(string? path) => Directory.Exists(path); + + public string ReadAllText(string path) => File.ReadAllText(path); + + public void WriteAllBytes(string path, byte[] bytes) => File.WriteAllBytes(path, bytes); + + public void WriteAllText(string path, string? contents) => File.WriteAllText(path, contents); +} diff --git a/src/ReverseProxy/Abstraction/IFileStore.cs b/src/ReverseProxy/Abstraction/IFileStore.cs new file mode 100644 index 0000000..b37c823 --- /dev/null +++ b/src/ReverseProxy/Abstraction/IFileStore.cs @@ -0,0 +1,34 @@ +// +// 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.Abstraction; + +internal interface IFileStore +{ + string CombinePath(string path1, string path2); + + string GetFullPath(string path); + + bool FileExists(string? path); + + bool DirectoryExists(string? path); + + string ReadAllText(string path); + + void WriteAllText(string path, string? contents); + + void WriteAllBytes(string path, byte[] bytes); +} diff --git a/src/ReverseProxy/Certificate/CertificateApp.cs b/src/ReverseProxy/Certificate/CertificateApp.cs new file mode 100644 index 0000000..ca3e831 --- /dev/null +++ b/src/ReverseProxy/Certificate/CertificateApp.cs @@ -0,0 +1,71 @@ +// +// Copyright 2025 Henrik Jensen +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Hj.ReverseProxy.Certificate.Models; +using Microsoft.Extensions.Caching.Memory; + +namespace Hj.ReverseProxy.Certificate; + +internal sealed class CertificateApp( + ILogger logger, + ICertificateConfig certificateConfig, + IMemoryCache memoryCache, + CertificateStore certificateStore, + CertificateFactory certificateFactory) +{ + public X509Certificate2 GetCertificate(string dnsName) + { + var certificate = memoryCache.GetOrCreate(dnsName, entry => + { + var selfSignedOptions = certificateConfig.GetOptions(); + + using var ca = GetOrCreateCa(selfSignedOptions); + using var key = certificateFactory.CreateKey(selfSignedOptions.AlgorithmOid); + + var isWildcard = dnsName.StartsWith('*'); + var cn = isWildcard + ? dnsName[2..] + : dnsName; + + logger.LogInformation("Missing certificate, dns name '{DnsName}', is wildcard '{IsWildcard}'", dnsName, isWildcard); + return certificateFactory.CreateCertificate(key, ca, $"CN={cn}", san => + { + san.AddIpAddress(IPAddress.Loopback); + + san.AddDnsName(cn); + if (isWildcard) + { + san.AddDnsName("*." + dnsName); + } + }); + }); + + return certificate!; + } + + private X509Certificate2 GetOrCreateCa(SelfSignedOptions selfSignedOptions) + { + var ca = certificateStore.LoadCa(selfSignedOptions); + if (ca == null) + { + using var key = certificateFactory.CreateKey(selfSignedOptions.AlgorithmOid); + ca = certificateFactory.CreateCa(key, selfSignedOptions.SubjectName); + certificateStore.SaveCa(selfSignedOptions, ca); + } + + return ca; + } +} diff --git a/src/ReverseProxy/Certificate/CertificateConfig.cs b/src/ReverseProxy/Certificate/CertificateConfig.cs new file mode 100644 index 0000000..4d53d5e --- /dev/null +++ b/src/ReverseProxy/Certificate/CertificateConfig.cs @@ -0,0 +1,46 @@ +// +// Copyright 2025 Henrik Jensen +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Hj.ReverseProxy.Certificate.Models; + +namespace Hj.ReverseProxy.Certificate; + +internal sealed class CertificateConfig( + IConfiguration configuration) : ICertificateConfig +{ + public SelfSignedOptions GetOptions() + { + var selfSignedOptions = configuration.GetSection("SelfSignedCertificate").Get() + ?? throw new InvalidOperationException("Configuration is missing"); + + if (string.IsNullOrWhiteSpace(selfSignedOptions.CaFilePath)) + { + throw new InvalidOperationException("CA file path is not configured"); + } + + if (string.IsNullOrWhiteSpace(selfSignedOptions.AlgorithmOid)) + { + selfSignedOptions.AlgorithmOid = CertificateConstants.EcdsaOid; + } + + if (string.IsNullOrWhiteSpace(selfSignedOptions.SubjectName)) + { + selfSignedOptions.SubjectName = CertificateConstants.DefaultCaSubjectName; + } + + return selfSignedOptions; + } +} diff --git a/src/ReverseProxy/Certificate/CertificateConstants.cs b/src/ReverseProxy/Certificate/CertificateConstants.cs new file mode 100644 index 0000000..26b32bb --- /dev/null +++ b/src/ReverseProxy/Certificate/CertificateConstants.cs @@ -0,0 +1,32 @@ +// +// 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; + +internal static class CertificateConstants +{ + public const string RsaOid = "1.2.840.113549.1.1.1"; + + public const string EcdsaOid = "1.2.840.10045.2.1"; + + public const string DefaultCaSubjectName = "CN=ReverseProxy Root CA"; + + public const string CaCrtFileName = "ca.crt.pem"; + + public const string CaKeyFileName = "ca.key.pem"; + + public const string CaPfxFileName = "ca.pfx"; +} diff --git a/src/ReverseProxy/Certificate/CertificateFactory.cs b/src/ReverseProxy/Certificate/CertificateFactory.cs new file mode 100644 index 0000000..c9235bd --- /dev/null +++ b/src/ReverseProxy/Certificate/CertificateFactory.cs @@ -0,0 +1,98 @@ +// +// Copyright 2025 Henrik Jensen +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Hj.ReverseProxy.Certificate.Strategy; + +namespace Hj.ReverseProxy.Certificate; + +internal sealed class CertificateFactory( + ILogger logger, + IEnumerable strategies) +{ + public X509Certificate2 CreateCa(AsymmetricAlgorithm key, string subjectName) + { + var strategy = GetStrategy(key); + + var request = strategy.CreateCertificateRequest(key, new(subjectName)); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign, true)); + request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); + + var utcNow = DateTimeOffset.UtcNow; + 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); + return ca; + } + + public X509Certificate2 CreateCertificate(AsymmetricAlgorithm key, X509Certificate2 ca, string subjectName, Action configureSan) + { + var strategy = GetStrategy(key); + + var request = strategy.CreateCertificateRequest(key, new(subjectName)); + + var sanBuilder = new SubjectAlternativeNameBuilder(); + configureSan(sanBuilder); + request.CertificateExtensions.Add(sanBuilder.Build()); + + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false)); + request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension([new("1.3.6.1.5.5.7.3.1")], true)); + request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); + + var caSignatureGenerator = GetStrategy(ca).GetSignatureGenerator(ca); + + var utcNow = DateTimeOffset.UtcNow; + var validFrom = utcNow.AddDays(-1); + var validTo = utcNow.AddYears(1); + + 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); + + var certificateWithKey = strategy.CopyWithPrivateKey(certificate, key); + + var pfxBytes = certificateWithKey.Export(X509ContentType.Pkcs12); +#pragma warning disable SYSLIB0057 // Type or member is obsolete + var pfx = new X509Certificate2(pfxBytes, (string?)null, X509KeyStorageFlags.Exportable | X509KeyStorageFlags.EphemeralKeySet); +#pragma warning restore SYSLIB0057 // Type or member is obsolete + return pfx; + } + + public AsymmetricAlgorithm CreateKey(string algorithmOid) => GetStrategy(algorithmOid).CreateKey(); + + public string ExportPrivateKeyPem(X509Certificate2 certificate) + => GetStrategy(certificate).ExportPrivateKeyPem(certificate); + + private ICertificateStrategy GetStrategy(X509Certificate2 certificate) => GetStrategy(certificate.GetKeyAlgorithm()); + + private ICertificateStrategy GetStrategy(string publicKeyAlgOid) + => strategies.FirstOrDefault(x => x.CanHandle(publicKeyAlgOid)) ?? throw new NotSupportedException($"Algorithm oid '{publicKeyAlgOid}' is not supported"); + + 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/CertificateStore.cs b/src/ReverseProxy/Certificate/CertificateStore.cs new file mode 100644 index 0000000..d17e9f3 --- /dev/null +++ b/src/ReverseProxy/Certificate/CertificateStore.cs @@ -0,0 +1,63 @@ +// +// Copyright 2025 Henrik Jensen +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Hj.ReverseProxy.Abstraction; +using Hj.ReverseProxy.Certificate.Models; + +namespace Hj.ReverseProxy.Certificate; + +internal sealed class CertificateStore( + ILogger logger, + IFileStore fileStore, + CertificateFactory certificateFactory) +{ + public X509Certificate2? LoadCa(SelfSignedOptions options) + { + GetCaFilePaths(options, out var caCrtPemFilePath, out var caKeyPemFilePath, out _); + + if (!fileStore.FileExists(caCrtPemFilePath) || !fileStore.FileExists(caKeyPemFilePath)) + { + 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); + + var certContents = fileStore.ReadAllText(caCrtPemFilePath); + var keyContents = fileStore.ReadAllText(caKeyPemFilePath); + var ca = X509Certificate2.CreateFromPem(certContents, keyContents); + return ca; + } + + public void SaveCa(SelfSignedOptions options, X509Certificate2 ca) + { + 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); + + fileStore.WriteAllText(caCrtPemFilePath, ca.ExportCertificatePem()); + fileStore.WriteAllText(caKeyPemFilePath, certificateFactory.ExportPrivateKeyPem(ca)); + + // 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)); + } + + 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); + } +} diff --git a/src/ReverseProxy/Certificate/ICertificateConfig.cs b/src/ReverseProxy/Certificate/ICertificateConfig.cs new file mode 100644 index 0000000..3c35386 --- /dev/null +++ b/src/ReverseProxy/Certificate/ICertificateConfig.cs @@ -0,0 +1,24 @@ +// +// Copyright 2025 Henrik Jensen +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Hj.ReverseProxy.Certificate.Models; + +namespace Hj.ReverseProxy.Certificate; + +internal interface ICertificateConfig +{ + SelfSignedOptions GetOptions(); +} diff --git a/src/ReverseProxy/Certificate/Models/SelfSignedOptions.cs b/src/ReverseProxy/Certificate/Models/SelfSignedOptions.cs new file mode 100644 index 0000000..5fec642 --- /dev/null +++ b/src/ReverseProxy/Certificate/Models/SelfSignedOptions.cs @@ -0,0 +1,26 @@ +// +// 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.Models; + +internal sealed class SelfSignedOptions +{ + public required string CaFilePath { get; set; } + + public required string AlgorithmOid { get; set; } + + public required string SubjectName { get; set; } +} diff --git a/src/ReverseProxy/Certificate/Strategy/CertificateEcdsa.cs b/src/ReverseProxy/Certificate/Strategy/CertificateEcdsa.cs new file mode 100644 index 0000000..044e659 --- /dev/null +++ b/src/ReverseProxy/Certificate/Strategy/CertificateEcdsa.cs @@ -0,0 +1,40 @@ +// +// 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; + +internal sealed class CertificateEcdsa : ICertificateStrategy +{ + public bool CanHandle(Type asymmetricAlgorithm) => typeof(ECDsa).IsAssignableFrom(asymmetricAlgorithm); + + public bool CanHandle(string publicKeyAlgOid) => publicKeyAlgOid == CertificateConstants.EcdsaOid; + + public AsymmetricAlgorithm CreateKey() => ECDsa.Create(ECCurve.NamedCurves.nistP256); + + public CertificateRequest CreateCertificateRequest(AsymmetricAlgorithm key, X500DistinguishedName distinguishedName) + => new(distinguishedName, (ECDsa)key, HashAlgorithmName.SHA256); + + public X509SignatureGenerator GetSignatureGenerator(X509Certificate2 certificate) + => X509SignatureGenerator.CreateForECDsa(GetKey(certificate)); + + public X509Certificate2 CopyWithPrivateKey(X509Certificate2 certificate, AsymmetricAlgorithm key) + => certificate.CopyWithPrivateKey((ECDsa)key); + + public string ExportPrivateKeyPem(X509Certificate2 certificate) => GetKey(certificate).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 new file mode 100644 index 0000000..c60aea3 --- /dev/null +++ b/src/ReverseProxy/Certificate/Strategy/CertificateRsa.cs @@ -0,0 +1,40 @@ +// +// 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; + +internal sealed class CertificateRsa : ICertificateStrategy +{ + public bool CanHandle(Type asymmetricAlgorithm) => typeof(RSA).IsAssignableFrom(asymmetricAlgorithm); + + public bool CanHandle(string publicKeyAlgOid) => publicKeyAlgOid == CertificateConstants.RsaOid; + + public AsymmetricAlgorithm CreateKey() => RSA.Create(2048); + + public CertificateRequest CreateCertificateRequest(AsymmetricAlgorithm key, X500DistinguishedName distinguishedName) + => new(distinguishedName, (RSA)key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + public X509SignatureGenerator GetSignatureGenerator(X509Certificate2 certificate) + => X509SignatureGenerator.CreateForRSA(GetKey(certificate), RSASignaturePadding.Pkcs1); + + public X509Certificate2 CopyWithPrivateKey(X509Certificate2 certificate, AsymmetricAlgorithm key) + => certificate.CopyWithPrivateKey((RSA)key); + + public string ExportPrivateKeyPem(X509Certificate2 certificate) => GetKey(certificate).ExportRSAPrivateKeyPem(); + + private static RSA GetKey(X509Certificate2 certificate) + => certificate.GetRSAPrivateKey() ?? throw new InvalidOperationException("Certificate has no private key"); +} diff --git a/src/ReverseProxy/Certificate/Strategy/ICertificateStrategy.cs b/src/ReverseProxy/Certificate/Strategy/ICertificateStrategy.cs new file mode 100644 index 0000000..6d1757b --- /dev/null +++ b/src/ReverseProxy/Certificate/Strategy/ICertificateStrategy.cs @@ -0,0 +1,40 @@ +// +// 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; + +internal interface ICertificateStrategy +{ + bool CanHandle(Type asymmetricAlgorithm); + + /// + /// A public key OID is an object identifier identifying the algorithm. + /// See more. + /// + /// The algorithm OID. + /// True if strategy matches the provided OID. + bool CanHandle(string publicKeyAlgOid); + + AsymmetricAlgorithm CreateKey(); + + CertificateRequest CreateCertificateRequest(AsymmetricAlgorithm key, X500DistinguishedName distinguishedName); + + X509SignatureGenerator GetSignatureGenerator(X509Certificate2 certificate); + + X509Certificate2 CopyWithPrivateKey(X509Certificate2 certificate, AsymmetricAlgorithm key); + + string ExportPrivateKeyPem(X509Certificate2 certificate); +} diff --git a/src/ReverseProxy/ReverseProxy.csproj b/src/ReverseProxy/ReverseProxy.csproj new file mode 100644 index 0000000..9d48ef4 --- /dev/null +++ b/src/ReverseProxy/ReverseProxy.csproj @@ -0,0 +1,45 @@ + + + + net8.0;net9.0 + Recommended + true + true + true + snupkg + true + HenrikJensen.ReverseProxy + README.md + Apache-2.0 + + + + + + + + + + + + + + + + + + + + + + + + + <_Parameter1>$(MSBuildProjectName).UnitTest + + + <_Parameter1>DynamicProxyGenAssembly2 + + + + diff --git a/src/ReverseProxy/ReverseProxy/LoggerMiddleware.cs b/src/ReverseProxy/ReverseProxy/LoggerMiddleware.cs new file mode 100644 index 0000000..58874c9 --- /dev/null +++ b/src/ReverseProxy/ReverseProxy/LoggerMiddleware.cs @@ -0,0 +1,44 @@ +// +// Copyright 2025 Henrik Jensen +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; + +namespace Hj.ReverseProxy.ReverseProxy; + +internal sealed class LoggerMiddleware(ILogger logger) +{ + public async Task InvokeAsync(HttpContext context, RequestDelegate next) + { + var proxyFeature = context.GetReverseProxyFeature(); + var route = proxyFeature.Route.Config; + + if (string.Equals(ReverseProxyConstants.BlackholeId, route.RouteId, StringComparison.OrdinalIgnoreCase)) + { + logger.LogDebug("Route: '{Url}', unknown route", context.Request.GetDisplayUrl()); + context.Response.StatusCode = (int)HttpStatusCode.NotFound; + await context.Response.CompleteAsync(); + return; + } + + if (logger.IsEnabled(LogLevel.Debug)) + { + logger.LogDebug("Route '{RouteId}', match '{Match}', cluster '{ClusterId}'", route.RouteId, route.Match.Path, route.ClusterId); + } + + await next(context); + } +} diff --git a/src/ReverseProxy/ReverseProxy/Models/ClusterInputDto.cs b/src/ReverseProxy/ReverseProxy/Models/ClusterInputDto.cs new file mode 100644 index 0000000..4d62d83 --- /dev/null +++ b/src/ReverseProxy/ReverseProxy/Models/ClusterInputDto.cs @@ -0,0 +1,24 @@ +// +// Copyright 2025 Henrik Jensen +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Yarp.ReverseProxy.Configuration; + +namespace Hj.ReverseProxy.ReverseProxy.Models; + +internal sealed class ClusterInputDto +{ + public List? Clusters { get; set; } +} diff --git a/src/ReverseProxy/ReverseProxy/Models/RouteInputDto.cs b/src/ReverseProxy/ReverseProxy/Models/RouteInputDto.cs new file mode 100644 index 0000000..e24ea0a --- /dev/null +++ b/src/ReverseProxy/ReverseProxy/Models/RouteInputDto.cs @@ -0,0 +1,24 @@ +// +// Copyright 2025 Henrik Jensen +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Yarp.ReverseProxy.Configuration; + +namespace Hj.ReverseProxy.ReverseProxy.Models; + +internal sealed class RouteInputDto +{ + public List? Routes { get; set; } +} diff --git a/src/ReverseProxy/ReverseProxy/ReverseProxyApi.cs b/src/ReverseProxy/ReverseProxy/ReverseProxyApi.cs new file mode 100644 index 0000000..8977f38 --- /dev/null +++ b/src/ReverseProxy/ReverseProxy/ReverseProxyApi.cs @@ -0,0 +1,81 @@ +// +// Copyright 2025 Henrik Jensen +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Hj.ReverseProxy.ReverseProxy.Models; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace Hj.ReverseProxy.ReverseProxy; + +internal static class ReverseProxyApi +{ + public static IEndpointRouteBuilder MapApi(this IEndpointRouteBuilder app, string? routePrefix) + { + var api = app; + if (!string.IsNullOrWhiteSpace(routePrefix)) + { + api = app.MapGroup(routePrefix); + } + + api.MapGet("/route", GetRoute) + .WithName("GetRoutes") + .WithDescription("Get list of configured routes."); + api.MapPost("/route", PostRouteAsync) + .WithName("AddRoute") + .WithDescription("Appends routes to the configuration."); + + api.MapGet("/cluster", GetCluster) + .WithName("GetClusters") + .WithDescription("Get list of configured clusters."); + api.MapPost("/cluster", PostClusterAsync) + .WithName("AddCluster") + .WithDescription("Appends clusters to the configuration."); + + return app; + } + + public static IResult GetRoute([FromServices] ReverseProxyApp reverseProxyApp) => Results.Json(reverseProxyApp.GetRouteConfigs()); + + public static async ValueTask PostRouteAsync([FromServices] ReverseProxyApp reverseProxyApp, [FromBody] RouteInputDto routeInput) + { + if (routeInput.Routes is not null) + { + foreach (var routeConfig in routeInput.Routes) + { + await reverseProxyApp.AddRouteAsync(routeConfig); + } + } + + return Results.Ok(); + } + + public static IResult GetCluster([FromServices] ReverseProxyApp reverseProxyApp) => Results.Json(reverseProxyApp.GetClusterConfigs()); + + public static async ValueTask PostClusterAsync([FromServices] ReverseProxyApp reverseProxyApp, [FromBody] ClusterInputDto clusterInput) + { + if (clusterInput.Clusters is not null) + { + foreach (var clusterConfig in clusterInput.Clusters) + { + await reverseProxyApp.AddClusterAsync(clusterConfig); + } + } + + return Results.Ok(); + } +} diff --git a/src/ReverseProxy/ReverseProxy/ReverseProxyApp.cs b/src/ReverseProxy/ReverseProxy/ReverseProxyApp.cs new file mode 100644 index 0000000..31bd382 --- /dev/null +++ b/src/ReverseProxy/ReverseProxy/ReverseProxyApp.cs @@ -0,0 +1,92 @@ +// +// Copyright 2025 Henrik Jensen +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Yarp.ReverseProxy.Configuration; + +namespace Hj.ReverseProxy.ReverseProxy; + +internal sealed class ReverseProxyApp( + IConfigValidator configValidator, + InMemoryConfigProvider inMemoryConfigProvider, + IEnumerable proxyConfigProviders) +{ + private readonly RouteConfig _blackholeRoute = new() + { + RouteId = ReverseProxyConstants.BlackholeId, + ClusterId = ReverseProxyConstants.BlackholeId, + Order = int.MaxValue, + Match = new() { Path = "/{**catch-all}", }, + }; + + private readonly ClusterConfig _blackholeCluster = new() + { + ClusterId = ReverseProxyConstants.BlackholeId, + Destinations = new Dictionary() + { + { ReverseProxyConstants.BlackholeId, new() { Address = "https:///", } }, + }, + }; + + private readonly List _routes = []; + private readonly List _clusters = []; + + public IReadOnlyList GetRouteConfigs() => proxyConfigProviders.SelectMany(x => x.GetConfig().Routes).ToList().AsReadOnly(); + + public IReadOnlyList GetClusterConfigs() => proxyConfigProviders.SelectMany(x => x.GetConfig().Clusters).ToList().AsReadOnly(); + + public void AddBlackholeCatchAll() + { + _routes.Add(_blackholeRoute); + _clusters.Add(_blackholeCluster); + Update(); + } + + public async ValueTask AddRouteAsync(RouteConfig route) + { + var validationErrors = await configValidator.ValidateRouteAsync(route); + if (validationErrors.Count > 0) + { + throw new AggregateException("Could not add route.", validationErrors); + } + + if (proxyConfigProviders.Any(x => x.GetConfig().Routes.Any(y => y.RouteId == route.RouteId))) + { + throw new InvalidOperationException($"Route with id '{route.RouteId}' already exists."); + } + + _routes.Add(route); + Update(); + } + + public async ValueTask AddClusterAsync(ClusterConfig cluster) + { + var validationErrors = await configValidator.ValidateClusterAsync(cluster); + if (validationErrors.Count > 0) + { + throw new AggregateException("Could not add cluser.", validationErrors); + } + + if (proxyConfigProviders.Any(x => x.GetConfig().Clusters.Any(y => y.ClusterId == cluster.ClusterId))) + { + throw new InvalidOperationException($"Cluster with id '{cluster.ClusterId}' already exists."); + } + + _clusters.Add(cluster); + Update(); + } + + public void Update() => inMemoryConfigProvider.Update(_routes.AsReadOnly(), _clusters.AsReadOnly()); +} diff --git a/src/ReverseProxy/ReverseProxy/ReverseProxyConstants.cs b/src/ReverseProxy/ReverseProxy/ReverseProxyConstants.cs new file mode 100644 index 0000000..83fb864 --- /dev/null +++ b/src/ReverseProxy/ReverseProxy/ReverseProxyConstants.cs @@ -0,0 +1,22 @@ +// +// Copyright 2025 Henrik Jensen +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Hj.ReverseProxy.ReverseProxy; + +internal static class ReverseProxyConstants +{ + public const string BlackholeId = "blackhole"; +} diff --git a/src/ReverseProxy/ServiceCollectionExtensions.cs b/src/ReverseProxy/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..ebcb55a --- /dev/null +++ b/src/ReverseProxy/ServiceCollectionExtensions.cs @@ -0,0 +1,135 @@ +// +// Copyright 2025 Henrik Jensen +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Hj.ReverseProxy.Abstraction; +using Hj.ReverseProxy.Certificate; +using Hj.ReverseProxy.Certificate.Strategy; +using Hj.ReverseProxy.ReverseProxy; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.HttpLogging; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Hj.ReverseProxy; + +public static class ServiceCollectionExtensions +{ + /// + /// Configures the Kestrel server to use automatic self-signed certificate generation for HTTPS connections. + /// + /// The instance to configure. + public static void UseSelfSignedCertificate(this KestrelServerOptions options) + { + options.ConfigureHttpsDefaults(httpsOptions => + { + httpsOptions.ServerCertificateSelector = (context, hostName) => + { + if (hostName == null) + { + return null; + } + + using var scope = options.ApplicationServices.CreateScope(); + var certificateApp = scope.ServiceProvider.GetRequiredService(); + return certificateApp.GetCertificate(hostName); + }; + }); + } + + /// + /// Configures reverse proxy services for the application. + /// + /// The to which the reverse proxy services will be added. + /// The instance containing an optional reverse proxy configuration. + /// The updated with reverse proxy services configured. + public static IServiceCollection ConfigureReverseProxy(this IServiceCollection services, IConfiguration configuration) + { + services.AddSingleton(); + + services.AddHttpLogging(options => + { + options.LoggingFields = HttpLoggingFields.RequestPath | HttpLoggingFields.RequestQuery; + options.RequestBodyLogLimit = int.MaxValue; + options.ResponseBodyLogLimit = int.MaxValue; + }); + + services.AddSingleton(); + + services.AddReverseProxy() + .LoadFromMemory([], []) + .LoadFromConfig(configuration.GetSection("ReverseProxy")) + .AddTransforms(builderContext => + { + builderContext.CopyRequestHeaders = true; + builderContext.CopyResponseHeaders = true; + }); + + services.AddMemoryCache(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } + + /// + /// Configures the application to use the reverse proxy. + /// + /// The instance to configure. + /// The configured instance. + public static WebApplication UseReverseProxy(this WebApplication app) + { + var loggerMiddleware = app.Services.GetRequiredService(); + + app.MapReverseProxy(proxyPipeline => + { + proxyPipeline.UseHttpLogging(); + proxyPipeline.Use(async (context, next) => await loggerMiddleware.InvokeAsync(context, next)); + }); + + return app; + } + + /// + /// Configures the application to enable the Reverse Proxy API with an optional route prefix. + /// + /// The instance to configure. + /// An optional route prefix for the Reverse Proxy API. If or empty, the API will be mapped to + /// the root route. + /// The configured instance. + public static WebApplication UseReverseProxyApi(this WebApplication app, string? routePrefix = null) + { + app.MapApi(routePrefix); + return app; + } + + /// + /// Configures the application to handle all unmatched requests with a "blackhole" catch-all route. + /// + /// The instance to configure. + /// The configured instance, allowing for further chaining of calls. + public static WebApplication UseBlackholeCatchAll(this WebApplication app) + { + var reverseProxyApp = app.Services.GetRequiredService(); + reverseProxyApp.AddBlackholeCatchAll(); + return app; + } +} diff --git a/stylecop.json b/stylecop.json new file mode 100644 index 0000000..ec0e302 --- /dev/null +++ b/stylecop.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", + "settings": { + "documentationRules": { + "xmlHeader": true + } + } +} diff --git a/test/Directory.Build.props b/test/Directory.Build.props new file mode 100644 index 0000000..63f8f5f --- /dev/null +++ b/test/Directory.Build.props @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/test/ReverseProxy.UnitTest/Certificate/CertificateAppTests.cs b/test/ReverseProxy.UnitTest/Certificate/CertificateAppTests.cs new file mode 100644 index 0000000..7b50b78 --- /dev/null +++ b/test/ReverseProxy.UnitTest/Certificate/CertificateAppTests.cs @@ -0,0 +1,162 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Hj.ReverseProxy.Abstraction; +using Hj.ReverseProxy.Certificate; +using Hj.ReverseProxy.Certificate.Strategy; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; + +namespace Hj.ReverseProxy.UnitTest.Certificate; + +public class CertificateAppTests +{ + private const string SubjectName = "CN=Unit Test CA"; + + private const string TestRsaCaCrt = @"-----BEGIN CERTIFICATE----- +MIIBIzCBzqADAgECAgh1r8sQnbYIlDANBgkqhkiG9w0BAQsFADAXMRUwEwYDVQQD +EwxVbml0IFRlc3QgQ0EwHhcNMjUwNjE1MDYwOTAxWhcNMzUwNjE2MDYwOTAxWjAX +MRUwEwYDVQQDEwxVbml0IFRlc3QgQ0EwXDANBgkqhkiG9w0BAQEFAANLADBIAkEA +sY8/uATfaiA6sI3HWZiIGMx9hI8j+QNbLX41FT2OxPWD0czqEqBnpULQOODYkjxz +4HWbhTJbGBwFSKzvA2qvsQIDAQABMA0GCSqGSIb3DQEBCwUAA0EAE+Px+WgOw/in +uUBVsi6p/5BEUOWzCaF+NHX+izRFy5r3LtcSQNSRYdYmgNdrXJln76cfV0xjlXfm +qBA0aeRvOA== +-----END CERTIFICATE-----"; + + private const string TestRsaCaKey = @"-----BEGIN RSA PRIVATE KEY----- +MIIBOwIBAAJBALGPP7gE32ogOrCNx1mYiBjMfYSPI/kDWy1+NRU9jsT1g9HM6hKg +Z6VC0Djg2JI8c+B1m4UyWxgcBUis7wNqr7ECAwEAAQJAGcz58lBq8m3aeVsws3kx +lYDpYEC4dm+haRvktMBsJXxVQ0mVoD6YAKqvjpUOL9F8dpcsLWDP463Cuo1zZE49 +HQIhAMK/4zkfJP8+576nRpxCOEIZXvz2K59rlWGP1PSiIEeDAiEA6WdSbUsBP4E/ +FAIB+wTV3mMD8UK8EuTMypXdb8gSUbsCIQCIoAfvtfrFmsMIDOBLlWVUceoiuyzl +XZth43751JeiswIgbY5MCHUObuqR2yheGZ9Za/t6HELA2PWAkw7pU9DLmIUCIQCs +HAiMP+ImbKpfNI6y9AROOlhsphQzyxgOxCPVVdeAZw== +-----END RSA PRIVATE KEY-----"; + + private const string TestEcdsaCaCrt = @"-----BEGIN CERTIFICATE----- +MIIBITCByaADAgECAgkA0epGWb7QAEEwCgYIKoZIzj0EAwIwFzEVMBMGA1UEAxMM +VW5pdCBUZXN0IENBMB4XDTI1MDYxNTA2MDQ0NloXDTM1MDYxNjA2MDQ0NlowFzEV +MBMGA1UEAxMMVW5pdCBUZXN0IENBMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE +mt3pWSUy5hsYy+c1gr6Hii9i62g1xaIvVudqUQYnL4aiKeCYaBhOaSkb7U6H7JZH +gRuXcDt0/uIx/bguxUNKGTAKBggqhkjOPQQDAgNHADBEAiBIKQRfinS1RgTKNkp3 +vtBZXkyTPqvZB/rOSlZBYGxsJAIgDerVd87SYQ3hmgfxDtwdHEQNKOTB4i5rK75u +JkBAikc= +-----END CERTIFICATE-----"; + + private const string TestEcdsaCaKey = @"-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIJOVKzjwE1wFw+fEGCOmeiIOGm2u+MZGbwZrTe5NLbUmoAoGCCqGSM49 +AwEHoUQDQgAEmt3pWSUy5hsYy+c1gr6Hii9i62g1xaIvVudqUQYnL4aiKeCYaBhO +aSkb7U6H7JZHgRuXcDt0/uIx/bguxUNKGQ== +-----END EC PRIVATE KEY-----"; + + [Theory] + [InlineData(CertificateConstants.RsaOid, TestRsaCaCrt, TestRsaCaKey)] + [InlineData(CertificateConstants.EcdsaOid, TestEcdsaCaCrt, TestEcdsaCaKey)] + public void GetCertificate_GivenDnsName_ReturnsCertificate(string algorithmOid, string caCrtPem, string caKeyPem) + { + // arrange + IFileStore? fileStore = null; + + var sut = SystemUnderTest.For(arrange => + { + var settings = arrange.Instance>(); + settings.Add("SelfSignedCertificate:AlgorithmOid", 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); + }); + + // act + var result = sut.GetCertificate("example.com"); + + // assert + Assert.Equal("CN=example.com", result.Subject); + Assert.Equal(SubjectName, result.Issuer); + Assert.Equal(algorithmOid, result.PublicKey.Oid.Value); + + var constraints = result.Extensions.OfType().Single(); + Assert.False(constraints.CertificateAuthority); + + var usage = result.Extensions.OfType().Single().EnhancedKeyUsages; + Assert.Contains(usage.Cast(), x => x.Value == "1.3.6.1.5.5.7.3.1"); + + fileStore!.Received(2).ReadAllText(Arg.Any()); // ca crt and key + } + + [Theory] + [InlineData(CertificateConstants.RsaOid)] + [InlineData(CertificateConstants.EcdsaOid)] + public void GetCertificate_GivenMissingCa_CreatesCa(string algorithmOid) + { + // arrange + IFileStore? fileStore = null; + + var sut = SystemUnderTest.For(arrange => + { + var settings = arrange.Instance>(); + settings.Add("SelfSignedCertificate:AlgorithmOid", algorithmOid); + + SetHappyPath(arrange); + + fileStore = arrange.Instance(); + fileStore.FileExists(Arg.Any()).Returns(false); + }); + + // act + var result = sut.GetCertificate("example.com"); + + // assert + Assert.Equal(SubjectName, result.Issuer); + Assert.Equal(algorithmOid, result.PublicKey.Oid.Value); + + fileStore!.Received(2).WriteAllText(Arg.Any(), Arg.Any()); // ca crt and key + fileStore!.Received(1).WriteAllBytes(Arg.Any(), Arg.Any()); // ca pfx + } + + [Fact] + public void GetCertificate_GivenMissingStrategy_Throws() + { + // arrange + var sut = SystemUnderTest.For(arrange => + { + var settings = arrange.Instance>(); + settings.Add("SelfSignedCertificate:AlgorithmOid", "oid that does not exist"); + + SetHappyPath(arrange); + }); + + // act & asset + Assert.ThrowsAny(() => sut.GetCertificate("www.example.com")); + } + + private static void SetHappyPath(InputBuilder arrange) + { + var settings = arrange.Instance>()!; + settings.TryAdd("SelfSignedCertificate:CaFilePath", "/my/ca/path"); + settings.TryAdd("SelfSignedCertificate:AlgorithmOid", CertificateConstants.RsaOid); + settings.TryAdd("SelfSignedCertificate:SubjectName", SubjectName); + + arrange.Advanced.Instance(() => new ConfigurationBuilder().AddInMemoryCollection(settings!).Build()); + arrange.Instance(); + + var memoryCache = arrange.Instance(); + memoryCache.TryGetValue(Arg.Any(), out Arg.Any()).Returns(args => + { + args[1] = null; + return false; + }); + + arrange.Instance(); + arrange.Instance(); + + 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); + } +} diff --git a/test/ReverseProxy.UnitTest/Certificate/CertificateConfigTests.cs b/test/ReverseProxy.UnitTest/Certificate/CertificateConfigTests.cs new file mode 100644 index 0000000..6ef8684 --- /dev/null +++ b/test/ReverseProxy.UnitTest/Certificate/CertificateConfigTests.cs @@ -0,0 +1,64 @@ +using Hj.ReverseProxy.Certificate; +using Microsoft.Extensions.Configuration; + +namespace Hj.ReverseProxy.UnitTest.Certificate; + +public class CertificateConfigTests +{ + [Fact] + public void GetOptions_GivenMissingCaFilePath_Throws() + { + // arrange + var configuration = CreateConfiguration(new() + { + { "SelfSignedCertificate:AlgorithmOid", CertificateConstants.RsaOid }, + { "SelfSignedCertificate:SubjectName", "CN=Test CA" }, + }); + + var sut = new CertificateConfig(configuration); + + // act & assert + Assert.ThrowsAny(sut.GetOptions); + } + + [Fact] + public void GetOptions_GivenMissingAlgorithmOid_UseDefault() + { + // arrange + var configuration = CreateConfiguration(new() + { + { "SelfSignedCertificate:CaFilePath", "/my/ca/path" }, + { "SelfSignedCertificate:SubjectName", "CN=Test CA" }, + }); + + var sut = new CertificateConfig(configuration); + + // act + var result = sut.GetOptions(); + + // Assert + Assert.Equal(CertificateConstants.EcdsaOid, result.AlgorithmOid); + } + + [Fact] + public void GetOptions_GivenMissingSubjectName_UseDefault() + { + // arrange + var configuration = CreateConfiguration(new() + { + { "SelfSignedCertificate:CaFilePath", "/my/ca/path" }, + { "SelfSignedCertificate:AlgorithmOid", CertificateConstants.RsaOid }, + }); + + var sut = new CertificateConfig(configuration); + + // act + var result = sut.GetOptions(); + + // Assert + Assert.Equal(CertificateConstants.DefaultCaSubjectName, result.SubjectName); + } + + 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 new file mode 100644 index 0000000..2ff530d --- /dev/null +++ b/test/ReverseProxy.UnitTest/ReverseProxy.UnitTest.csproj @@ -0,0 +1,24 @@ + + + + net8.0;net9.0 + true + false + + + + + + + + + + + + + + + + + + diff --git a/test/ReverseProxy.UnitTest/ReverseProxy/ReverseProxyApiTests.cs b/test/ReverseProxy.UnitTest/ReverseProxy/ReverseProxyApiTests.cs new file mode 100644 index 0000000..9199015 --- /dev/null +++ b/test/ReverseProxy.UnitTest/ReverseProxy/ReverseProxyApiTests.cs @@ -0,0 +1,112 @@ +using Hj.ReverseProxy.ReverseProxy; +using Hj.ReverseProxy.ReverseProxy.Models; +using Microsoft.AspNetCore.Http.HttpResults; +using Yarp.ReverseProxy.Configuration; + +namespace Hj.ReverseProxy.UnitTest.ReverseProxy; + +public class ReverseProxyApiTests +{ + [Fact] + public void GetRoute__ReturnsRoute() + { + // arrange + SutBuilder sutBuilder = new(); + var inputBuilder = SetHappyPath(sutBuilder.InputBuilder); + + RouteConfig routeConfig = new() { RouteId = Guid.NewGuid().ToString(), }; + + inputBuilder.Instance() + .Update([routeConfig], []); + + var reverseProxyApp = inputBuilder.Instance(); + + // act + var result = ReverseProxyApi.GetRoute(reverseProxyApp) as JsonHttpResult>; + + // assert + Assert.NotNull(result); + Assert.Equal(routeConfig.RouteId, result?.Value?[0].RouteId); + } + + [Fact] + public async Task PostRoute__GivenRouteInput_AddsRouteAsync() + { + // arrange + SutBuilder sutBuilder = new(); + var inputBuilder = SetHappyPath(sutBuilder.InputBuilder); + + var inMemoryConfig = inputBuilder.Instance(); + var reverseProxyApp = inputBuilder.Instance(); + + var routeId = Guid.NewGuid().ToString(); + RouteInputDto routeInput = new() + { + Routes = [new() { RouteId = routeId, }], + }; + + // act + var result = await ReverseProxyApi.PostRouteAsync(reverseProxyApp, routeInput) as Ok; + + // assert + Assert.NotNull(result); + Assert.Equal(routeId, inMemoryConfig.GetConfig().Routes[0].RouteId); + } + + [Fact] + public void GetCluster__ReturnsCluster() + { + // arrange + SutBuilder sutBuilder = new(); + var inputBuilder = SetHappyPath(sutBuilder.InputBuilder); + + ClusterConfig clusterConfig = new() { ClusterId = Guid.NewGuid().ToString() }; + + inputBuilder.Instance() + .Update([], [clusterConfig]); + + var reverseProxyApp = inputBuilder.Instance(); + + // act + var result = ReverseProxyApi.GetCluster(reverseProxyApp) as JsonHttpResult>; + + // assert + Assert.NotNull(result); + Assert.Equal(clusterConfig.ClusterId, result?.Value?[0].ClusterId); + } + + [Fact] + public async Task PostCluster_GivenClusterInput_AddsClusterAsync() + { + // arrange + SutBuilder sutBuilder = new(); + var inputBuilder = SetHappyPath(sutBuilder.InputBuilder); + + var inMemoryConfig = inputBuilder.Instance(); + var reverseProxyApp = inputBuilder.Instance(); + + var clusterId = Guid.NewGuid().ToString(); + ClusterInputDto clusterInput = new() + { + Clusters = [new() { ClusterId = clusterId, }], + }; + + // act + var result = await ReverseProxyApi.PostClusterAsync(reverseProxyApp, clusterInput) as Ok; + + // assert + Assert.NotNull(result); + Assert.Equal(clusterId, inMemoryConfig!.GetConfig().Clusters[0].ClusterId); + } + + private static InputBuilder SetHappyPath(InputBuilder arrange) + { + arrange.Advanced.Instance(() => new InMemoryConfigProvider([], [])); + + var configValidator = arrange.Instance(); + configValidator.ValidateRouteAsync(Arg.Any()).Returns([]); + configValidator.ValidateClusterAsync(Arg.Any()).Returns([]); + + return arrange; + } +} diff --git a/test/ReverseProxy.UnitTest/ReverseProxy/ReverseProxyAppTests.cs b/test/ReverseProxy.UnitTest/ReverseProxy/ReverseProxyAppTests.cs new file mode 100644 index 0000000..79d4159 --- /dev/null +++ b/test/ReverseProxy.UnitTest/ReverseProxy/ReverseProxyAppTests.cs @@ -0,0 +1,113 @@ +using Hj.ReverseProxy.ReverseProxy; +using Yarp.ReverseProxy.Configuration; + +namespace Hj.ReverseProxy.UnitTest.ReverseProxy; + +public class ReverseProxyAppTests +{ + [Fact] + public void AddBlackholeCatchAll__AddsBlackhole() + { + // arrange + InMemoryConfigProvider? inMemoryConfig = null; + + var sut = SystemUnderTest.For(arrange => + { + SetHappyPath(arrange); + inMemoryConfig = arrange.Instance(); + }); + + // act + sut.AddBlackholeCatchAll(); + + // assert + var config = inMemoryConfig!.GetConfig(); + + var route0 = config.Routes[0]; + Assert.Equal(ReverseProxyConstants.BlackholeId, route0.RouteId); + + var cluster0 = config.Clusters[0]; + Assert.Equal(ReverseProxyConstants.BlackholeId, cluster0.ClusterId); + } + + [Fact] + public async Task AddRouteAsync_GivenValidationError_ThrowsAsync() + { + // arrange + var sut = SystemUnderTest.For(arrange => + { + SetHappyPath(arrange); + + arrange.Instance() + .ValidateRouteAsync(Arg.Any()) + .Returns([new InvalidOperationException()]); + }); + + RouteConfig routeConfig = new(); + + // act & assert + await Assert.ThrowsAnyAsync(async () => await sut.AddRouteAsync(routeConfig)); + } + + [Fact] + public async Task AddRouteAsync_GivenExisting_ThrowsAsync() + { + // arrange + RouteConfig routeConfig = new() { RouteId = Guid.NewGuid().ToString() }; + var sut = SystemUnderTest.For(arrange => + { + SetHappyPath(arrange); + + arrange.Instance() + .Update([routeConfig], []); + }); + + // act & assert + await Assert.ThrowsAnyAsync(async () => await sut.AddRouteAsync(routeConfig)); + } + + [Fact] + public async Task AddClusterAsync_GivenValidationError_ThrowsAsync() + { + // arrange + var sut = SystemUnderTest.For(arrange => + { + SetHappyPath(arrange); + + arrange.Instance() + .ValidateClusterAsync(Arg.Any()) + .Returns([new InvalidOperationException()]); + }); + + ClusterConfig clusterConfig = new(); + + // act & assert + await Assert.ThrowsAnyAsync(async () => await sut.AddClusterAsync(clusterConfig)); + } + + [Fact] + public async Task AddClusterAsync_GivenExisting_ThrowsAsync() + { + // arrange + ClusterConfig clusterConfig = new() { ClusterId = Guid.NewGuid().ToString() }; + var sut = SystemUnderTest.For(arrange => + { + SetHappyPath(arrange); + + arrange.Instance() + .Update([], [clusterConfig]); + }); + + // act & assert + await Assert.ThrowsAnyAsync(async () => await sut.AddClusterAsync(clusterConfig)); + } + + private static void SetHappyPath(InputBuilder inputBuilder) + { + inputBuilder.Advanced.Instance(() => new InMemoryConfigProvider([], [])); + + var configValidator = inputBuilder.Instance(); + configValidator.ValidateRouteAsync(Arg.Any()).Returns([]); + configValidator.ValidateClusterAsync(Arg.Any()).Returns([]); + } +} From 6334d46e88d74f30ab0ab921f729dea244bcc851 Mon Sep 17 00:00:00 2001 From: Henrik Jensen Date: Sat, 21 Jun 2025 14:08:06 +0200 Subject: [PATCH 2/2] Prepare beta1 --- Directory.Build.targets | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Build.targets b/Directory.Build.targets index 0a66993..e8a1890 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -8,10 +8,10 @@ Henrik Jensen Pairs Microsoft YARP with a web API for runtime configuration and automatic self-signed certificates. testing;mock;reverse-proxy;certificates - https://github.com/henrikhimself/DotNet-ReverseProxy + https://github.com/henrikhimself/DotNet-ReverseProxy.git https://github.com/henrikhimself/DotNet-ReverseProxy.git git - 1.0.0 + 1.0.0-beta1